Files
Hermit/app/src/core/PdfTools.cpp
T
WHC 4c69ef39ab pdf: text that names no colour comes out black, not the palette
A QTextDocument takes its default text colour from the application
palette, and markdown names no colour at all: in a window running a dark
palette -- which is what a phone in dark mode hands Qt -- every plain
paragraph was laid out #eeeeee on a white page. Printed, that is a blank
document; on screen it reads as "the font is oddly faint". The first
PDFs off the phone were exactly that.

- PdfTools: paperDocument() sets the default stylesheet (body colour
  #000000) before setHtml, and writePdf() lays that out. A colour the
  document does name still wins, so an .html handed to the tool keeps its
  own -- checked with a #cc0000 document under both palettes.
- --uitest: draws that document on a white image under a dark palette and
  looks for ink (238 before the fix, 0 after).

Verified on the NX789J: md_to_pdf on the device now writes CID TrueType
NotoSansCJKHK-Regular/-Bold text whose page renders at min=0.
2026-10-11 20:35:41 +08:00

334 lines
12 KiB
C++

/* 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 <QByteArray>
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QMarginsF>
#include <QPageLayout>
#include <QPageSize>
#include <QPainter>
#include <QPdfWriter>
#include <QRectF>
#include <QString>
#include <QTextDocument>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <memory>
#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<size_t>(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<QTextDocument> 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