Skip to content

Add pagx embed command, restructure pagx font, and support external font files.#3471

Open
YyZz-wy wants to merge 90 commits into
mainfrom
feature/codywwang_cli_embed
Open

Add pagx embed command, restructure pagx font, and support external font files.#3471
YyZz-wy wants to merge 90 commits into
mainfrom
feature/codywwang_cli_embed

Conversation

@YyZz-wy

@YyZz-wy YyZz-wy commented May 29, 2026

Copy link
Copy Markdown
Collaborator

本 PR 整合 PAGX CLI 的三阶段重构:

  1. 新增 pagx embed 命令:统一字形与图片嵌入流程,支持 --skip-fonts / --skip-images / --fallback 选项;ImageEmbedder 与 FontEmbedder 拆分清晰。
  2. 重构 pagx font 命令:扁平化为单一查询命令,移除 embed 子命令(保留兼容重定向),新增 --list 列出系统字体。
  3. PAGX Font 节点新增 file 属性:可引用外部字体文件,pagx embed 自动扫描并注册字体到 FontConfig,无需 --font-file 参数;ClearEmbeddedGlyphRuns 在 Font 含 file 时保留节点结构仅清除 Glyph 子节点,支持幂等 re-embed。

附带 SystemFonts 在 macOS / Windows / Linux / FreeType 后端新增 AllFontFamilies 与 FindFont 实现,修复若干内存泄漏与悬空指针问题,并更新 spec、skills 文档及 CLI 测试。

@YyZz-wy YyZz-wy force-pushed the feature/codywwang_cli_embed branch from b8fbace to 74428c8 Compare May 29, 2026 08:31
YyZz-wy added 25 commits May 29, 2026 16:34
…phRun nodes from document.

Previously only cleared Text::glyphRuns vector but left Font, Glyph, GlyphRun, PathData, and
bitmap Image nodes in document->nodes, causing duplicate nodes on re-embed.
…nd idempotency.

9 CLI_TEST cases cover EMBED-01..05, EMBED-07, EMBED-08, EMBED-10, EMBED-11.
…phRun nodes from document.

Previously only cleared Text::glyphRuns vector but left Font, Glyph, GlyphRun, PathData, and
bitmap Image nodes in document->nodes, causing duplicate nodes on re-embed.
@YyZz-wy YyZz-wy force-pushed the feature/codywwang_cli_embed branch from 74428c8 to 815d1fa Compare May 29, 2026 08:47
@codecov-commenter

codecov-commenter commented May 29, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 72.65625% with 210 lines in your changes missing coverage. Please review.
✅ Project coverage is 79.81%. Comparing base (1917e8e) to head (a7c64a0).

Files with missing lines Patch % Lines
src/pagx/SystemFonts.cpp 37.37% 50 Missing and 12 partials ⚠️
src/cli/CommandEmbed.cpp 62.61% 29 Missing and 11 partials ⚠️
src/cli/CliUtils.h 14.28% 28 Missing and 2 partials ⚠️
src/pagx/PAGXDocument.cpp 51.92% 19 Missing and 6 partials ⚠️
test/src/PAGXCliTest.cpp 94.47% 3 Missing and 15 partials ⚠️
src/cli/CommandFont.cpp 80.51% 12 Missing and 3 partials ⚠️
src/renderer/ImageEmbedder.cpp 61.29% 5 Missing and 7 partials ⚠️
src/renderer/FontEmbedder.cpp 75.00% 3 Missing and 3 partials ⚠️
src/pagx/PAGXImporter.cpp 77.77% 0 Missing and 2 partials ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #3471      +/-   ##
==========================================
+ Coverage   79.70%   79.81%   +0.10%     
==========================================
  Files         580      584       +4     
  Lines       60904    61526     +622     
  Branches    18921    19003      +82     
==========================================
+ Hits        48546    49105     +559     
- Misses       8685     8707      +22     
- Partials     3673     3714      +41     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Comment thread src/pagx/SystemFonts.cpp Outdated
Comment thread src/pagx/PAGXDocument.cpp
Comment thread src/pagx/PAGXImporter.cpp
}
if (node->nodeType() == NodeType::Font) {
auto* font = static_cast<Font*>(node.get());
ResolveRelativePath(basePath, font->file);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Font.file 在导入时被改写为绝对路径,但 PAGXExporter 在写出 Font 节点时直接 xml.addAttribute("file", font->file) 没有反向相对化(参见 src/pagx/PAGXExporter.cpp:1007-1009)。结果:用户写的 <Font file="./fonts/X.otf"/>pagx embed 后会变成 file="/abs/path/.../X.otf",把整个 PAGX 复制到别的机器或目录后外部字体引用就失效了。文档(attributes.md)声称嵌入后保留 file 是为了"源文件可追溯性",但绝对路径反而破坏了这一语义。新增测试 Embed_FontFile_ReembedPreservesNodeEmbed_FontFile_AutoRegistersAndEmbeds 都没断言路径形式,所以问题没暴露。建议导出时对原本是相对形式的路径做反向相对化,或在 importer 里保留原始字符串、仅在解析阶段使用临时副本。

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

已修改

Comment thread include/pagx/PAGXDocument.h Outdated
* invalidated after this call. Callers must first collect all affected nodes to remove
* before calling.
*/
void removeNodes(const std::unordered_set<Node*>& nodesToRemove);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

removeNodes / setNodeId / resetLayoutState 三个新方法本质上是为 FontEmbedder::ClearEmbeddedGlyphRuns 服务的内部协作接口,却暴露在公开头文件 include/pagx/PAGXDocument.h 中。第三方调用者一旦使用 removeNodes,就要承担注释里写的"any external pointers… become dangling"风险,没有任何编译期或运行期保护。建议把这三个方法改成 private + friend class FontEmbedder,或者迁移到内部头文件,避免污染公开 API surface。

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

已修改

Comment thread src/pagx/PAGXDocument.cpp
continue;
}
if (image->filePath.find("data:") == 0) {
if (IsUrlPath(image->filePath)) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

新的 IsUrlPath 仅识别 http://https://file://,不再排除 data: URI。代码注释说"data: is handled by PAGXImporter before this point",对 FromFile 路径成立;但 getExternalFilePaths 是公开 API,第三方完全可以绕过 importer 直接构造 PAGXDocument 并设置 image->filePath = "data:image/png;base64,..."。在新逻辑下:1) getExternalFilePaths 会返回 data:... 字符串;2) ImageEmbedder::embed 调用 ifstream 打开失败;3) pagx embedfailed to load image 'data:image/png...',对调用方非常迷惑。建议在 IsUrlPath 中也匹配 data: 前缀,与旧行为对齐(或改名为 IsNonFileScheme)。

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

已修改

Comment thread src/cli/CommandEmbed.cpp
std::cout << "pagx embed: wrote " << options.outputFile << "\n";
return 0;
}
if (!WriteStringToFile(xml, options.outputFile, "pagx embed")) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

覆盖输入文件路径走的是 tempPath + rename 的原子写入(上面 152-175 行),而显式 -o <other> 这条路径直接走 WriteStringToFile,是直接 ofstream 写入。如果 -o 指向的已存在文件,写到一半失败/崩溃就会得到一个截断的输出文件。两条路径的崩溃安全语义不一致。建议统一用 temp+rename helper,或在 WriteStringToFile 内部统一加上原子写入。

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

已修改

Comment thread src/cli/CommandEmbed.cpp
if ((arg == "-o" || arg == "--output") && i + 1 < argc) {
options->outputFile = argv[++i];
} else if (arg == "--fallback" && i + 1 < argc) {
options->fallbacks.push_back(argv[++i]);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

用户传 --fallback 但同时传 --skip-fonts 时,fallbacks 收到了值却完全不会被使用(下面 if (!options.skipFonts) 块被跳过),命令也不报错。脚本中常会无脑加 --fallback,发现某次没生效非常难排查。建议在 skipFonts && !fallbacks.empty() 时报错或打印警告。

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

已修改

Comment thread src/renderer/FontEmbedder.cpp Outdated
int fontIndex = 1;
if (vectorBuilder.font != nullptr) {
vectorBuilder.font->id = "font" + std::to_string(fontIndex++);
document->setNodeId(vectorBuilder.font, "__embed_font_" + std::to_string(fontIndex++));

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

setNodeId 内部调用 registerNode,撞 ID 时只在 document->errors 里追加一条记录,然后直接覆盖 nodeMap[id](参见 PAGXDocument.cpp:66-76)。如果用户的 PAGX 里恰好存在 id 为 __embed_font_1 的节点(不限于 Font,任何节点类型),它会被静默从 nodeMap 中踢掉,且 pagx embed 仍返回 0。__embed_font_ 前缀降低了概率,但前缀本身没有任何文档化的"保留"声明。建议要么在 spec 中明确声明 __embed_* 为保留前缀,要么在 setNodeId 撞 ID 时让命令行调用方拿到非零退出码。

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

已修改

Comment thread src/pagx/SystemFonts.cpp Outdated
if (!style.empty()) {
FcPatternAddString(pattern, FC_STYLE, reinterpret_cast<const FcChar8*>(style.c_str()));
}
FcPatternAddBool(pattern, FC_SCALABLE, FcTrue);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Linux FindFont 强制 FC_SCALABLE=true,与 FallbackTypefaces 行为一致;但当用户的 PAGX 显式声明使用一个仅有位图(embedded bitmap-only)的字体(如某些 emoji 字体)时,FindFont 会找不到。FallbackTypefaces 排除位图是合理的,但用户显式查询时是否要放宽这个限制需要确认产品定位。

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

已修改

Comment thread src/pagx/PAGXImporter.cpp
if (node->nodeType() == NodeType::Font) {
auto* font = static_cast<Font*>(node.get());
if (!font->file.empty()) {
font->fileOriginal = font->file;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fileOriginal 只在 PAGXImporter::FromFile 这一条加载路径上被设置,而 PAGXImporter::FromXML(content) 这条路径不经过这里——只在 ParseFont 中执行 font->file = GetAttribute(node, "file"),所以从 XML 字符串/字节流加载的文档,Font::fileOriginal 永远为空。

现状下没有功能 bug(FromXML 路径下 font->file 没被改写,导出端 !fileOriginal.empty() ? fileOriginal : font->file 退化到 font->file 仍是原值),但这把 fileOriginal 的语义碎片化到了具体加载路径上,而不是数据结构的不变量。

隐患:未来若新增第三种加载路径(如解压 zip 内嵌 PAGX 后再解析),开发者很容易忘记再补一遍这段备份逻辑。建议把"备份原值"的语义下沉到 ParseFont(解析时立即 font->fileOriginal = font->file),由 FromFile 单独负责解析后的相对→绝对重写。这样 fileOriginal == 原始 XML 中的 file 属性 在数据结构层面就是个稳定的不变量。

Comment thread src/cli/CliUtils.cpp
{
std::ofstream out(tempPath);
if (!out.is_open()) {
std::cerr << command << ": failed to write '" << tempPath << "'\n";

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

错误消息暴露了内部 .tmp 路径细节——tempPathfilePath + ".tmp" 这一实现选择,对用户而言完全是黑盒。

用户运行 pagx embed -o /readonly/out.pagx input.pagx 失败时会看到:

pagx embed: failed to write '/readonly/out.pagx.tmp'

用户指定的是 out.pagx,但报错里冒出来一个 out.pagx.tmp,会让人困惑(怀疑是不是自己拼写错了,或文件被别的进程占着)。第 73、79、87、88 行的错误消息都有同样问题。

参考主流 CLI(git、cargo、npm),失败消息都报用户指定的最终路径,而不是内部临时文件名。建议把错误消息中的 tempPath 替换为 filePath,例如:failed to write '<filePath>' (creating temp file failed)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants