/* The window's own tools, in Qt rather than in the engine: a document the * person asked for, laid out as a PDF. * * Why here and not in the C engine: laying text out on pages is exactly what * Qt does (QTextDocument + QPdfWriter, both in QtGui, which the app already * links), while the engine has no layout code at all -- and md_to_html, its * sibling, lives in the engine because rendering markdown is a shared * capability. This one is a front-end capability: it is registered under * owner "app:qt" and is available on every platform the app runs on. * * The path of a document is the one md_to_html walks: markdown (or a * workspace .md/.html file) -> cst_web_md_document() -> a file inside the * workspace -> cst_attach_import_artifact(), which is what turns it into a * file card the person can open or share. The bytes never reach the model. * * Pagination is ours on purpose: QPrinter/PrintSupport is a desktop-only * module (a phone has no print subsystem), and a round of * pageSize + translate + drawContents per page is all a paginated PDF needs. * The tool runs on the turn's worker thread, so a long document does not * freeze the window. */ #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include "core/AgentCore.h" #include "core/ToolProvider.h" #include "cst_agent.h" #include "cst_attach.h" #include "cst_tools.h" #include "cst_web_md.h" namespace app { namespace { /* md_to_html caps its input the same way: a document nobody could read is * not worth laying out. */ constexpr int kMaxInputBytes = 8 * 1024 * 1024; /* 150 dpi: crisp enough for text, and a good deal lighter on a phone than the * default 300 (a 40-page document is laid out and written in a moment). */ constexpr int kPdfDpi = 150; const char *argOf(const ctl_tool_call_t *call, const char *key) { for (int i = 0; i < call->nargs; i++) if (strcmp(call->args[i].key, key) == 0) return call->args[i].value; return nullptr; } int setOut(cst_tool_result_t *r, const QString &text, int rc) { const QByteArray b = text.toUtf8(); r->out = strdup(b.constData()); r->out_len = r->out ? strlen(r->out) : 0; r->exit_code = rc; return rc; } /* "docs/notes.md" -> "notes". */ QString stemOf(const QString &path) { QString base = path; const int slash = qMax(base.lastIndexOf(QLatin1Char('/')), base.lastIndexOf(QLatin1Char('\\'))); if (slash >= 0) base = base.mid(slash + 1); const int dot = base.lastIndexOf(QLatin1Char('.')); if (dot > 0) base = base.left(dot); return base.isEmpty() ? QStringLiteral("document") : base; } /* What the file is called: `out` when given (a directory takes the source's * name inside it), otherwise the source's stem, or the title. */ QString outPathOf(const ctl_tool_call_t *c, const QString &stem) { const char *out = argOf(c, "out"); QString name = out && out[0] ? QString::fromUtf8(out) : QString(); if (name.isEmpty()) return stem + QStringLiteral(".pdf"); if (name.endsWith(QLatin1Char('/')) || name.endsWith(QLatin1Char('\\'))) return name + stem + QStringLiteral(".pdf"); if (!QFileInfo(name).fileName().contains(QLatin1Char('.'))) name += QStringLiteral(".pdf"); return name; } /* The HTML that goes into the page: an .html file is already a document, a * .md file (or the `markdown` argument) goes through the engine's renderer, * which is the same one the WebUI and md_to_html use. */ bool sourceHtml(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *c, QString *html, QString *out_name, QString *err) { const char *md = argOf(c, "markdown"); const char *file = argOf(c, "file"); const char *given = argOf(c, "title"); QString title = given && given[0] ? QString::fromUtf8(given) : QString(); QByteArray text; QString stem; if (file && file[0]) { char rel[4096] = ""; if (cst_sandbox_resolve(ctx->workspace_root, file, rel, sizeof(rel)) != 0) { *err = QStringLiteral("error: path outside the workspace or missing " "parent: %1") .arg(QString::fromUtf8(file)); return false; } QFile in(QString::fromUtf8(rel)); if (!in.open(QIODevice::ReadOnly)) { *err = QStringLiteral("error: cannot read %1") .arg(QString::fromUtf8(file)); return false; } if (in.size() > kMaxInputBytes) { *err = QStringLiteral("error: %1 is larger than %2 MB") .arg(QString::fromUtf8(file)) .arg(kMaxInputBytes >> 20); return false; } text = in.readAll(); stem = stemOf(QString::fromUtf8(file)); const QString lower = QString::fromUtf8(file).toLower(); if (lower.endsWith(QLatin1String(".html")) || lower.endsWith(QLatin1String(".htm"))) { /* already a document: what md_to_html wrote, or anything else */ *html = QString::fromUtf8(text); if (title.isEmpty()) title = stem; *out_name = outPathOf(c, stem); return true; } } else if (md && md[0]) { text = QByteArray(md); if (stem.isEmpty()) stem = title.isEmpty() ? QStringLiteral("document") : stemOf(title); } else { *err = QStringLiteral("error: give markdown, or a workspace .md or .html " "file to render"); return false; } if (title.isEmpty()) title = stem; char *doc = cst_web_md_document(text.constData(), static_cast(text.size()), title.toUtf8().constData()); if (!doc) { *err = QStringLiteral("error: cannot render the markdown"); return false; } *html = QString::fromUtf8(doc); free(doc); *out_name = outPathOf(c, stem); return true; } /* Write `html` into `path` as an A4 PDF with 15 mm margins: one page rect, * translated down per page, and the document is asked for exactly that much * text at a time -- which is what makes Qt lay out pages instead of one long * strip. */ bool writePdf(const QString &html, const QString &path, const QString &title, QString *err) { std::unique_ptr doc(paperDocument(html)); if (!title.isEmpty()) doc->setMetaInformation(QTextDocument::DocumentTitle, title); QPdfWriter writer(path); writer.setPageSize(QPageSize(QPageSize::A4)); writer.setPageMargins(QMarginsF(15, 15, 15, 15), QPageLayout::Millimeter); writer.setResolution(kPdfDpi); if (!title.isEmpty()) writer.setTitle(title); QPainter painter(&writer); if (!painter.isActive()) { *err = QStringLiteral("error: cannot draw into %1").arg(path); return false; } const QRectF page = writer.pageLayout().paintRectPixels(writer.resolution()); doc->setPageSize(page.size()); const int pages = qMax(1, doc->pageCount()); for (int i = 0; i < pages; i++) { if (i) writer.newPage(); painter.save(); painter.translate(page.topLeft()); painter.translate(0, -i * page.height()); doc->drawContents(&painter, QRectF(0, 0, page.width(), page.height()) .translated(0, i * page.height())); painter.restore(); } painter.end(); /* The painter says nothing about the file: the only proof the PDF landed is * that there is one with bytes in it. */ if (QFileInfo(path).size() <= 0) { *err = QStringLiteral("error: nothing was written to %1").arg(path); return false; } return true; } int mdToPdfPreview(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *c, char **out) { (void)ctx; const char *md = argOf(c, "markdown"); const char *file = argOf(c, "file"); if ((!md || !md[0]) && (!file || !file[0])) { *out = strdup("error: give markdown, or a .md or .html file to render"); return -1; } const QString stem = file && file[0] ? stemOf(QString::fromUtf8(file)) : QStringLiteral("document"); const QString name = outPathOf(c, stem); QByteArray line; if (file && file[0]) line = QStringLiteral("lay out %1 → %2 as a PDF\n") .arg(QString::fromUtf8(file), name) .toUtf8(); else line = QStringLiteral("lay out %1 bytes of markdown → %2 as a PDF\n") .arg(md ? qstrlen(md) : 0) .arg(name) .toUtf8(); *out = strdup(line.constData()); return 0; } int mdToPdfRun(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *c, cst_tool_result_t *r, void *ud) { (void)ud; QString html, out_name, err; if (!sourceHtml(ctx, c, &html, &out_name, &err)) return setOut(r, err, 1); char full[4096] = ""; if (cst_sandbox_resolve(ctx->workspace_root, out_name.toUtf8().constData(), full, sizeof(full)) != 0) return setOut(r, QStringLiteral("error: path outside the workspace: %1") .arg(out_name), 1); const QString target = QString::fromUtf8(full); /* A directory in the way of the name is what the engine's write tools call * "missing parent": make it, the same way md_to_html does. */ QDir().mkpath(QFileInfo(target).absolutePath()); const char *given = argOf(c, "title"); const QString title = given && given[0] ? QString::fromUtf8(given) : QString(); if (!writePdf(html, target, title, &err)) return setOut(r, err, 1); const qint64 bytes = QFileInfo(target).size(); const char *session = ctx->agent ? cst_agent_session_path(ctx->agent) : nullptr; cst_attachment_t *a = nullptr; char aerr[256] = ""; if (session && session[0]) a = cst_attach_import_artifact(session, full, nullptr, aerr, sizeof(aerr)); QString text = QStringLiteral("wrote %1 (%2 KB, A4, 15 mm margins)\n") .arg(out_name) .arg(bytes / 1024); if (a) text += QStringLiteral("(the user sees it as a file card they can open or " "share; you will not be shown its bytes again)\n"); else if (aerr[0]) text += QStringLiteral("(the file is in the workspace; handing it over " "failed: %1)\n") .arg(QString::fromUtf8(aerr)); const int rc = setOut(r, text, 0); if (a) { r->attach = a; r->nattach = 1; } return rc; } } // namespace /* The document writePdf lays out, with a colour filled in for text that names * none of its own. * * Paper is white and has no theme, but a QTextDocument takes its default text * colour from the application palette: in a window running a dark palette -- * which is what a phone in dark mode hands Qt -- every plain markdown * paragraph came out #eeeeee on a white page, i.e. a PDF that looks blank * when printed, and reads as "the font is oddly faint" on screen. Naming the * colour makes the page depend on the paper rather than on the window. A * colour the document does name still wins (an .html file somebody handed us * keeps its own), so this only fills the holes. */ QTextDocument *paperDocument(const QString &html) { auto *doc = new QTextDocument; doc->setDefaultStyleSheet(QStringLiteral("body { color: #000000; }")); doc->setHtml(html); return doc; } int registerQtTools(AgentCore *core) { if (!core) return 0; static const cst_tool_param_t params[CST_TOOL_MAX_PARAMS] = { {"markdown", "string", 0, "markdown text to render"}, {"file", "string", 0, "workspace .md file to render, or an .html one to lay out as it is"}, {"out", "string", 0, "output path in the workspace (default: the source's name + .pdf)"}, {"title", "string", 0, "document title (default: the file name)"}, {nullptr, nullptr, 0, nullptr}, {nullptr, nullptr, 0, nullptr}}; static cst_tool_def_t def; memset(&def, 0, sizeof(def)); setMeta(&def, "md_to_pdf", "Write a document as a PDF: markdown (or a workspace .md/.html " "file) in, a laid-out A4 PDF out, saved in the workspace and " "handed to the person as a card they can open, print or share. " "Use it when the answer should be something to keep or send.", CST_TP_WRITE, "pdf", nullptr, nullptr); memcpy(def.params, params, sizeof(params)); def.run_ex = mdToPdfRun; def.preview = mdToPdfPreview; def.owner = "app:qt"; return core->registerTool(&def) ? 1 : 0; } } // namespace app