Skip to content

Add pagx embed command and enhance pagx font with --list flag#3405

Open
YyZz-wy wants to merge 59 commits intomainfrom
feature/codywwang_cli_embed
Open

Add pagx embed command and enhance pagx font with --list flag#3405
YyZz-wy wants to merge 59 commits intomainfrom
feature/codywwang_cli_embed

Conversation

@YyZz-wy
Copy link
Copy Markdown
Collaborator

@YyZz-wy YyZz-wy commented Apr 27, 2026

为 pagx CLI 工具新增 embed 子命令和字体查询增强功能:

  1. 新增 pagx embed 命令,支持将字体字形和图片嵌入 PAGX 文件,提供 --skip-fonts 和 --skip-images 选项控制嵌入范围
  2. 将 pagx font 从多级子命令扁平化为单一查询命令,废弃原有的 font embed 和 font info 子层级
  3. 新增 pagx font --list 选项,支持列出系统已安装的字体家族(文本和 JSON 两种输出格式)
  4. 实现 SystemFonts::AllFontFamilies 跨平台支持(macOS、Windows、Linux)
  5. 新增 ImageEmbedder 模块,支持图片 base64 嵌入与去重读取
  6. 改进 FontEmbedder::ClearEmbeddedGlyphRuns,正确清理过期的 Font 和 GlyphRun 节点
  7. 补充 CLI 测试用例覆盖 embed 默认行为、skip flags、字体列表等场景
  8. 同步更新 npm README 文档和 PAGX spec 示例

YyZz-wy added 30 commits April 22, 2026 14:24
…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.
if (!in.is_open()) return nullptr;
std::streampos end = in.tellg();
if (end <= 0) return nullptr; // covers tellg failure and empty file
auto size = static_cast<size_t>(end);
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.

🔴 [C2] Critical: ReadFileToData 缺少文件大小上限,存在内存耗尽风险

如果 PAGX 文件引用了一个非常大的文件(数 GB),虽然 new(std::nothrow) 不会抛异常,但会尝试分配巨量内存,可能导致 OOM。对于 CLI 工具,恶意构造的 PAGX 文件可以利用此问题进行拒绝服务。

修复建议:增加合理的文件大小上限(如 256MB),超限时返回 nullptr 并输出明确错误信息:

static constexpr size_t MAX_IMAGE_FILE_SIZE = 256 * 1024 * 1024;
if (size > MAX_IMAGE_FILE_SIZE) return nullptr;

Comment thread src/renderer/ImageEmbedder.cpp Outdated
auto paths = document->getExternalFilePaths();
std::unordered_set<std::string> loaded;
for (const auto& path : paths) {
if (path.find("://") != std::string::npos) {
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.

🟠 [M1] Major: URL 检测逻辑不够健壮

path.find("://") 存在几个问题:

  1. 路径中间碰巧包含 :// 的文件会被意外跳过
  2. getExternalFilePaths() 已经过滤了 data: 前缀,但这里用不同维度重复检查,两层过滤逻辑不一致

修复建议:使用更精确的 scheme 检测(如检查是否以 http://https:// 等已知协议开头),或将 URL 过滤逻辑统一收敛到 getExternalFilePaths() 中,让 ImageEmbedder 只需信任其返回值。

Comment thread src/renderer/ImageEmbedder.cpp Outdated
auto paths = document->getExternalFilePaths();
std::unordered_set<std::string> loaded;
for (const auto& path : paths) {
if (path.find("://") != std::string::npos) {
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.

🟠 [M1] Major: URL 检测逻辑不够健壮

path.find("://") 存在几个问题:

  1. 路径中间碰巧包含 :// 的文件会被意外跳过
  2. getExternalFilePaths() 已经过滤了 data: 前缀,但这里用不同维度重复检查,两层过滤逻辑不一致

修复建议:使用更精确的 scheme 检测(如检查是否以 http://https:// 等已知协议开头),或将 URL 过滤逻辑统一收敛到 getExternalFilePaths() 中,让 ImageEmbedder 只需信任其返回值。

Comment thread src/cli/CommandFont.cpp Outdated
return parseResult == -1 ? 0 : parseResult;
if (options.listMode) {
if (options.sizeSpecified) {
std::cerr << "pagx font: warning: --size is ignored in --list mode\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.

🟠 [M2] Major: --list + --size 语义处理不一致

  • --list + --size 发 warning 并继续执行(返回 0)
  • --list + --file/--name 则直接报错返回 1

这两种"不合法组合"的处理方式不一致。且 warning 容易被大量字体输出淹没,用户难以注意到。

修复建议:建议统一行为——要么 --size--list 模式下也报错退出,要么直接静默忽略(更符合 UNIX 工具惯例)。

Comment thread src/cli/CommandFont.cpp Outdated
return parseResult == -1 ? 0 : parseResult;
if (options.listMode) {
if (options.sizeSpecified) {
std::cerr << "pagx font: warning: --size is ignored in --list mode\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.

🟠 [M2] Major: --list + --size 语义处理不一致

  • --list + --size 发 warning 并继续执行(返回 0)
  • --list + --file/--name 则直接报错返回 1

这两种"不合法组合"的处理方式不一致。且 warning 容易被大量字体输出淹没,用户难以注意到。

修复建议:建议统一行为——要么 --size--list 模式下也报错退出,要么直接静默忽略(更符合 UNIX 工具惯例)。

Comment thread src/cli/CommandFont.cpp
if (i > 0) {
std::cout << ",";
}
std::cout << "{\"family\":\"" << EscapeJson(entries[i].family) << "\",\"styles\":[";
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.

🟠 [M3] Major: 手动拼接 JSON 存在控制字符转义不完整的风险

EscapeJson() 处理了 "\\n\r\t\b 等,但未覆盖 U+0000–U+001F 范围内的其他控制字符(如 \f\x0C)。虽然系统字体名称极少包含控制字符,但生成的 JSON 可能不严格符合 RFC 8259 规范。

修复建议:在 EscapeJson 中对所有 ch < 0x20 的字符增加 \u00xx 格式的 fallback 转义。(此问题在 CliUtils.h 中,影响范围不限于本 PR)

Comment thread src/cli/CommandFont.cpp
if (i > 0) {
std::cout << ",";
}
std::cout << "{\"family\":\"" << EscapeJson(entries[i].family) << "\",\"styles\":[";
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.

🟠 [M3] Major: 手动拼接 JSON 存在控制字符转义不完整的风险

EscapeJson() 处理了 "\\n\r\t\b 等,但未覆盖 U+0000–U+001F 范围内的其他控制字符(如 \f\x0C)。虽然系统字体名称极少包含控制字符,但生成的 JSON 可能不严格符合 RFC 8259 规范。

修复建议:在 EscapeJson 中对所有 ch < 0x20 的字符增加 \u00xx 格式的 fallback 转义。(此问题在 CliUtils.h 中,影响范围不限于本 PR)

Comment thread src/cli/CommandEmbed.cpp
return 1;
}
if (options->outputFile.empty()) {
options->outputFile = options->inputFile;
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.

🟠 [M4] Major: 覆写输入文件场景下,失败时安全性存在隐患

当未指定 -o 时,输出路径默认等于输入路径。如果字体嵌入成功后图片嵌入失败,document 已被修改但不会写出——行为本身正确(不写出损坏文件),但用户可能不确定原文件是否安全。

修复建议

  1. 当输出路径等于输入路径时,先写到临时文件再原子替换(rename)
  2. 或者至少在错误信息中明确提示 "original file was not modified"

Comment thread src/pagx/SystemFonts.cpp
if (pattern == nullptr) {
return {};
}
FcObjectSet* objectSet = FcObjectSetBuild(FC_FAMILY, FC_STYLE, (char*)0);
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.

🟡 [m2] Minor: (char*)0 哨兵参数风格

(char*)0 用于 C 风格可变参数函数的终止符,功能正确。在 C++ 中可考虑使用 static_cast<char*>(nullptr) 使意图更显式,但由于这是对 C API (FcObjectSetBuild) 的调用,保持现状也可接受。

Comment thread src/pagx/SystemFonts.cpp
if (pattern == nullptr) {
return {};
}
FcObjectSet* objectSet = FcObjectSetBuild(FC_FAMILY, FC_STYLE, (char*)0);
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.

🟡 [m2] Minor: (char*)0 哨兵参数风格

(char*)0 用于 C 风格可变参数函数的终止符,功能正确。在 C++ 中可考虑使用 static_cast<char*>(nullptr) 使意图更显式,但由于这是对 C API (FcObjectSetBuild) 的调用,保持现状也可接受。

Comment thread src/cli/CommandFont.cpp Outdated
<< "Exactly one of --file, --name, or --list must be specified.\n";
}

static bool FontFamilyLess(const pagx::FontFamilyEntry& lhs, const pagx::FontFamilyEntry& rhs) {
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.

🟡 [m3] Minor: 比较函数可考虑改为 operator<

项目编码规范要求避免 lambda,所以这里使用独立函数是正确的。不过可以考虑直接给 FontFamilyEntry 定义 operator<,这样 std::sort 无需传额外参数,代码更简洁。

Comment thread src/cli/CommandFont.cpp Outdated
<< "Exactly one of --file, --name, or --list must be specified.\n";
}

static bool FontFamilyLess(const pagx::FontFamilyEntry& lhs, const pagx::FontFamilyEntry& rhs) {
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.

🟡 [m3] Minor: 比较函数可考虑改为 operator<

项目编码规范要求避免 lambda,所以这里使用独立函数是正确的。不过可以考虑直接给 FontFamilyEntry 定义 operator<,这样 std::sort 无需传额外参数,代码更简洁。

Comment thread test/src/PAGXCliTest.cpp Outdated

// Variant 1: with a positional argument
{
std::streambuf* oldCerr = std::cerr.rdbuf();
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.

🟡 [m4] Minor: cerr/cout rdbuf 重定向模式重复出现 10+ 次,建议提取 RAII 辅助类

以下模式在测试中大量重复:

std::streambuf* oldCerr = std::cerr.rdbuf();
std::ostringstream capturedErr;
std::cerr.rdbuf(capturedErr.rdbuf());
// ... test ...
std::cerr.rdbuf(oldCerr);

建议提取为 RAII 辅助类(如 CaptureStream),减少重复并避免异常/断言失败时未恢复 rdbuf 的风险。

Comment thread test/src/PAGXCliTest.cpp Outdated

// Variant 1: with a positional argument
{
std::streambuf* oldCerr = std::cerr.rdbuf();
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.

🟡 [m4] Minor: cerr/cout rdbuf 重定向模式重复出现 10+ 次,建议提取 RAII 辅助类

以下模式在测试中大量重复:

std::streambuf* oldCerr = std::cerr.rdbuf();
std::ostringstream capturedErr;
std::cerr.rdbuf(capturedErr.rdbuf());
// ... test ...
std::cerr.rdbuf(oldCerr);

建议提取为 RAII 辅助类(如 CaptureStream),减少重复并避免异常/断言失败时未恢复 rdbuf 的风险。

Comment thread test/src/PAGXCliTest.cpp Outdated
EXPECT_EQ(ret1, 0);
auto ret2 = CallRun(pagx::cli::RunEmbed, {"embed", out1, "-o", out2});
EXPECT_EQ(ret2, 0);
EXPECT_EQ(ReadFile(out1), ReadFile(out2));
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.

🟡 [m5] Minor: 幂等性测试依赖字节级比较,存在脆弱性

第二次 embed 时,字体嵌入会先 ClearEmbeddedGlyphRuns 再重新 embed。如果 makeNode 生成的 id 带递增计数器,重新 embed 可能产生不同的 id,导致字节级不相等。当前能通过说明 id 生成是确定性的,但属于隐含假设。

建议:在测试注释中说明此假设,或考虑改为语义级比较(如分别检查 Image 节点的 data 不为空、无外部 filePath 等)。

Comment thread test/src/PAGXCliTest.cpp Outdated
EXPECT_EQ(ret1, 0);
auto ret2 = CallRun(pagx::cli::RunEmbed, {"embed", out1, "-o", out2});
EXPECT_EQ(ret2, 0);
EXPECT_EQ(ReadFile(out1), ReadFile(out2));
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.

🟡 [m5] Minor: 幂等性测试依赖字节级比较,存在脆弱性

第二次 embed 时,字体嵌入会先 ClearEmbeddedGlyphRuns 再重新 embed。如果 makeNode 生成的 id 带递增计数器,重新 embed 可能产生不同的 id,导致字节级不相等。当前能通过说明 id 生成是确定性的,但属于隐含假设。

建议:在测试注释中说明此假设,或考虑改为语义级比较(如分别检查 Image 节点的 data 不为空、无外部 filePath 等)。

Comment thread src/cli/CommandEmbed.cpp Outdated
<< "\n"
<< "Options:\n"
<< " -o, --output <path> Output file path (default: overwrite input)\n"
<< " --file <path> Register a font file (can be specified multiple\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.

🟡 [m6] Minor: --file 在 embed 命令中可能引起歧义

pagx embed --file 指的是注册字体文件,但不了解上下文的用户在 embed 命令中看到 --file 可能误以为是指定输入文件。

建议:考虑重命名为 --font-file--font 使语义更明确。如果保持 --file,至少在帮助文本描述中补充 "(font file)"。

nodes[writeIdx++] = std::move(nodes[readIdx]);
}
}
nodes.resize(writeIdx);
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.

🔴 [P1] nodeMap 清理使用已释放指针值比较,属于未定义行为

nodes.resize(writeIdx) 触发被移除节点的 unique_ptr 析构后,toRemove 中保存的裸指针已成为悬空指针。下方 nodeMap 清理循环中 toRemove.count(it->second) 使用释放后的指针值做比较,C++ 标准定义这属于 UB([basic.stc.dynamic.deallocation])。虽然主流编译器不会实际崩溃,但属于潜在隐患。

修复建议:将 nodeMap 清理循环(第 424-430 行)移到 nodes.resize(writeIdx) 之前执行,此时指针仍有效。

Comment thread src/pagx/SystemFonts.cpp

FontLocation SystemFonts::FindFont(const std::string&, const std::string&) {
// Windows FreeType backend already implements MakeFromName via DirectWrite.
return {};
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.

🔴 [P2] Windows FindFont 返回空,CLI 字体功能在 Windows 上失效

CliUtils.hResolveSystemTypeface 依赖 FindFont 作为 fallback。在 FreeType backend 下 MakeFromName 返回的字体族可能不匹配导致 fall through 到此处,而 Windows 直接返回空,使 pagx font --namepagx embed 字体解析静默失败。

修复建议:使用 DirectWrite IDWriteFontCollection::FindFamilyName 实现,或在头文件注释中明确标注 Windows 平台限制。

if (document == nullptr) return false;
auto paths = document->getExternalFilePaths();
std::unordered_set<std::string> loaded;
for (const auto& path : paths) {
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.

🟠 [P3] URL 检测逻辑不够精确

头文件注释说明跳过含 :// 的路径,但此处代码依赖 getExternalFilePaths() 返回的路径。如果该函数已过滤了 URL,则此处无需再做 URL 检测;如果未过滤,path.find("://") 可能误判 Windows 路径(如 C://Users/file.png)。建议明确过滤职责归属——要么收敛到 getExternalFilePaths(),要么此处改为检查已知 scheme 前缀(http://https://data:)。

Comment thread src/cli/CommandFont.cpp
if (options->fontFile.empty() && options->fontName.empty()) {
std::cerr << "pagx font info: either --file or --name is required\n";

if (options.listMode && (!options.fontFile.empty() || !options.fontName.empty())) {
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.

🟠 [P4] --list + --size--list + --file/--name 错误处理不一致

--list + --file/--name 报错退出(返回 1),但 --list + --size 仅输出 warning 并继续执行(返回 0)。建议统一行为:直接静默忽略 --size--list 模式不需要字号信息),无需输出 warning。

Comment thread src/cli/CliUtils.h
// Fallback: locate the font file via platform APIs and load by path.
auto location = pagx::SystemFonts::FindFont(family, style);
if (!location.path.empty()) {
return tgfx::Typeface::MakeFromPath(location.path, location.ttcIndex);
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.

🟠 [P5] 路径函数不支持 Windows 反斜杠

ResolveFallbackTypeface(第 86 行)仅通过 specifier.find('/') 判断是否为文件路径,GetDirectory(第 136 行)也仅检查正斜杠。Windows 路径如 C:\fonts\Arial.ttf 不会被识别为文件路径。建议同时检查 '/''\\'

Comment thread src/cli/CliUtils.h
}
}
// Fallback: locate the font file via platform APIs and load by path.
auto location = pagx::SystemFonts::FindFont(family, style);
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.

🟠 [P6] ResolveFallbackTypeface 中大写扩展名判断为死代码

第 90-92 行的 ext = specifier.substr(dot) 未做大小写转换,但后续同时比较 .ttf.TTF。大小写混合的扩展名(如 .Ttf)两边都不匹配。而同文件 GetFileExtension 已做小写转换,两处逻辑不统一。建议对 ext 转小写后只保留小写比较。

lastErrorPath_ = path;
return false;
}
document->loadFileData(path, data);
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.

🟡 [P7] loadFileData 返回值未检查

document->loadFileData(path, data) 返回 bool 表示是否成功,但此处忽略了返回值。防御性编程建议检查返回值,失败时设置 lastErrorPath 并返回 false。

Comment thread test/src/PAGXCliTest.cpp
@@ -24,6 +24,7 @@
#include <vector>
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.

🟡 [P8] 幂等性测试依赖隐式假设

Embed_Idempotent 测试通过字节级比较验证两次 embed 结果相同,但这依赖于 makeNode 的 ID 生成是确定性的。如果未来 ID 生成逻辑变化(如引入递增计数器),测试会假阳性失败。建议在测试注释中标注此假设,或改为语义级比较(如检查 Image 节点的 data 非空、无外部 filePath)。

Comment thread cli/npm/README.md
pagx font --list

# Embed fonts and images into a PAGX file
pagx embed input.pagx
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.

🟡 [P9] embed 示例缺少输入文件参数

当前 pagx embed 示例没有展示输入文件参数,容易让用户困惑命令用法。建议改为 pagx embed input.pagx 以展示完整的最简用法。

@@ -0,0 +1,204 @@
<!-- refreshed: 2025-07-15 -->
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.

🟡 [P10] .planning/codebase/ 目录不应入仓库

这些是 AI 辅助开发的上下文文件(共 7 个文件 +1663 行),不属于项目源码也不属于正式文档。建议从 PR 中移除:git rm --cached .planning/

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