Add pagx embed command and enhance pagx font with --list flag#3405
Add pagx embed command and enhance pagx font with --list flag#3405
Conversation
… partial-read detection.
…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.
…Runs to follow coding convention.
…including node removal.
… generic fallback.
| 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); |
There was a problem hiding this comment.
🔴 [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;| auto paths = document->getExternalFilePaths(); | ||
| std::unordered_set<std::string> loaded; | ||
| for (const auto& path : paths) { | ||
| if (path.find("://") != std::string::npos) { |
There was a problem hiding this comment.
🟠 [M1] Major: URL 检测逻辑不够健壮
path.find("://") 存在几个问题:
- 路径中间碰巧包含
://的文件会被意外跳过 getExternalFilePaths()已经过滤了data:前缀,但这里用不同维度重复检查,两层过滤逻辑不一致
修复建议:使用更精确的 scheme 检测(如检查是否以 http://、https:// 等已知协议开头),或将 URL 过滤逻辑统一收敛到 getExternalFilePaths() 中,让 ImageEmbedder 只需信任其返回值。
| auto paths = document->getExternalFilePaths(); | ||
| std::unordered_set<std::string> loaded; | ||
| for (const auto& path : paths) { | ||
| if (path.find("://") != std::string::npos) { |
There was a problem hiding this comment.
🟠 [M1] Major: URL 检测逻辑不够健壮
path.find("://") 存在几个问题:
- 路径中间碰巧包含
://的文件会被意外跳过 getExternalFilePaths()已经过滤了data:前缀,但这里用不同维度重复检查,两层过滤逻辑不一致
修复建议:使用更精确的 scheme 检测(如检查是否以 http://、https:// 等已知协议开头),或将 URL 过滤逻辑统一收敛到 getExternalFilePaths() 中,让 ImageEmbedder 只需信任其返回值。
| return parseResult == -1 ? 0 : parseResult; | ||
| if (options.listMode) { | ||
| if (options.sizeSpecified) { | ||
| std::cerr << "pagx font: warning: --size is ignored in --list mode\n"; |
There was a problem hiding this comment.
🟠 [M2] Major: --list + --size 语义处理不一致
--list + --size发 warning 并继续执行(返回 0)--list + --file/--name则直接报错返回 1
这两种"不合法组合"的处理方式不一致。且 warning 容易被大量字体输出淹没,用户难以注意到。
修复建议:建议统一行为——要么 --size 在 --list 模式下也报错退出,要么直接静默忽略(更符合 UNIX 工具惯例)。
| return parseResult == -1 ? 0 : parseResult; | ||
| if (options.listMode) { | ||
| if (options.sizeSpecified) { | ||
| std::cerr << "pagx font: warning: --size is ignored in --list mode\n"; |
There was a problem hiding this comment.
🟠 [M2] Major: --list + --size 语义处理不一致
--list + --size发 warning 并继续执行(返回 0)--list + --file/--name则直接报错返回 1
这两种"不合法组合"的处理方式不一致。且 warning 容易被大量字体输出淹没,用户难以注意到。
修复建议:建议统一行为——要么 --size 在 --list 模式下也报错退出,要么直接静默忽略(更符合 UNIX 工具惯例)。
| if (i > 0) { | ||
| std::cout << ","; | ||
| } | ||
| std::cout << "{\"family\":\"" << EscapeJson(entries[i].family) << "\",\"styles\":["; |
There was a problem hiding this comment.
🟠 [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)
| if (i > 0) { | ||
| std::cout << ","; | ||
| } | ||
| std::cout << "{\"family\":\"" << EscapeJson(entries[i].family) << "\",\"styles\":["; |
There was a problem hiding this comment.
🟠 [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)
| return 1; | ||
| } | ||
| if (options->outputFile.empty()) { | ||
| options->outputFile = options->inputFile; |
There was a problem hiding this comment.
🟠 [M4] Major: 覆写输入文件场景下,失败时安全性存在隐患
当未指定 -o 时,输出路径默认等于输入路径。如果字体嵌入成功后图片嵌入失败,document 已被修改但不会写出——行为本身正确(不写出损坏文件),但用户可能不确定原文件是否安全。
修复建议:
- 当输出路径等于输入路径时,先写到临时文件再原子替换(rename)
- 或者至少在错误信息中明确提示 "original file was not modified"
| if (pattern == nullptr) { | ||
| return {}; | ||
| } | ||
| FcObjectSet* objectSet = FcObjectSetBuild(FC_FAMILY, FC_STYLE, (char*)0); |
There was a problem hiding this comment.
🟡 [m2] Minor: (char*)0 哨兵参数风格
(char*)0 用于 C 风格可变参数函数的终止符,功能正确。在 C++ 中可考虑使用 static_cast<char*>(nullptr) 使意图更显式,但由于这是对 C API (FcObjectSetBuild) 的调用,保持现状也可接受。
| if (pattern == nullptr) { | ||
| return {}; | ||
| } | ||
| FcObjectSet* objectSet = FcObjectSetBuild(FC_FAMILY, FC_STYLE, (char*)0); |
There was a problem hiding this comment.
🟡 [m2] Minor: (char*)0 哨兵参数风格
(char*)0 用于 C 风格可变参数函数的终止符,功能正确。在 C++ 中可考虑使用 static_cast<char*>(nullptr) 使意图更显式,但由于这是对 C API (FcObjectSetBuild) 的调用,保持现状也可接受。
| << "Exactly one of --file, --name, or --list must be specified.\n"; | ||
| } | ||
|
|
||
| static bool FontFamilyLess(const pagx::FontFamilyEntry& lhs, const pagx::FontFamilyEntry& rhs) { |
There was a problem hiding this comment.
🟡 [m3] Minor: 比较函数可考虑改为 operator<
项目编码规范要求避免 lambda,所以这里使用独立函数是正确的。不过可以考虑直接给 FontFamilyEntry 定义 operator<,这样 std::sort 无需传额外参数,代码更简洁。
| << "Exactly one of --file, --name, or --list must be specified.\n"; | ||
| } | ||
|
|
||
| static bool FontFamilyLess(const pagx::FontFamilyEntry& lhs, const pagx::FontFamilyEntry& rhs) { |
There was a problem hiding this comment.
🟡 [m3] Minor: 比较函数可考虑改为 operator<
项目编码规范要求避免 lambda,所以这里使用独立函数是正确的。不过可以考虑直接给 FontFamilyEntry 定义 operator<,这样 std::sort 无需传额外参数,代码更简洁。
|
|
||
| // Variant 1: with a positional argument | ||
| { | ||
| std::streambuf* oldCerr = std::cerr.rdbuf(); |
There was a problem hiding this comment.
🟡 [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 的风险。
|
|
||
| // Variant 1: with a positional argument | ||
| { | ||
| std::streambuf* oldCerr = std::cerr.rdbuf(); |
There was a problem hiding this comment.
🟡 [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 的风险。
| EXPECT_EQ(ret1, 0); | ||
| auto ret2 = CallRun(pagx::cli::RunEmbed, {"embed", out1, "-o", out2}); | ||
| EXPECT_EQ(ret2, 0); | ||
| EXPECT_EQ(ReadFile(out1), ReadFile(out2)); |
There was a problem hiding this comment.
🟡 [m5] Minor: 幂等性测试依赖字节级比较,存在脆弱性
第二次 embed 时,字体嵌入会先 ClearEmbeddedGlyphRuns 再重新 embed。如果 makeNode 生成的 id 带递增计数器,重新 embed 可能产生不同的 id,导致字节级不相等。当前能通过说明 id 生成是确定性的,但属于隐含假设。
建议:在测试注释中说明此假设,或考虑改为语义级比较(如分别检查 Image 节点的 data 不为空、无外部 filePath 等)。
| EXPECT_EQ(ret1, 0); | ||
| auto ret2 = CallRun(pagx::cli::RunEmbed, {"embed", out1, "-o", out2}); | ||
| EXPECT_EQ(ret2, 0); | ||
| EXPECT_EQ(ReadFile(out1), ReadFile(out2)); |
There was a problem hiding this comment.
🟡 [m5] Minor: 幂等性测试依赖字节级比较,存在脆弱性
第二次 embed 时,字体嵌入会先 ClearEmbeddedGlyphRuns 再重新 embed。如果 makeNode 生成的 id 带递增计数器,重新 embed 可能产生不同的 id,导致字节级不相等。当前能通过说明 id 生成是确定性的,但属于隐含假设。
建议:在测试注释中说明此假设,或考虑改为语义级比较(如分别检查 Image 节点的 data 不为空、无外部 filePath 等)。
| << "\n" | ||
| << "Options:\n" | ||
| << " -o, --output <path> Output file path (default: overwrite input)\n" | ||
| << " --file <path> Register a font file (can be specified multiple\n" |
There was a problem hiding this comment.
🟡 [m6] Minor: --file 在 embed 命令中可能引起歧义
pagx embed --file 指的是注册字体文件,但不了解上下文的用户在 embed 命令中看到 --file 可能误以为是指定输入文件。
建议:考虑重命名为 --font-file 或 --font 使语义更明确。如果保持 --file,至少在帮助文本描述中补充 "(font file)"。
…LI against oversized files. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
| nodes[writeIdx++] = std::move(nodes[readIdx]); | ||
| } | ||
| } | ||
| nodes.resize(writeIdx); |
There was a problem hiding this comment.
🔴 [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) 之前执行,此时指针仍有效。
|
|
||
| FontLocation SystemFonts::FindFont(const std::string&, const std::string&) { | ||
| // Windows FreeType backend already implements MakeFromName via DirectWrite. | ||
| return {}; |
There was a problem hiding this comment.
🔴 [P2] Windows FindFont 返回空,CLI 字体功能在 Windows 上失效
CliUtils.h 中 ResolveSystemTypeface 依赖 FindFont 作为 fallback。在 FreeType backend 下 MakeFromName 返回的字体族可能不匹配导致 fall through 到此处,而 Windows 直接返回空,使 pagx font --name 和 pagx 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) { |
There was a problem hiding this comment.
🟠 [P3] URL 检测逻辑不够精确
头文件注释说明跳过含 :// 的路径,但此处代码依赖 getExternalFilePaths() 返回的路径。如果该函数已过滤了 URL,则此处无需再做 URL 检测;如果未过滤,path.find("://") 可能误判 Windows 路径(如 C://Users/file.png)。建议明确过滤职责归属——要么收敛到 getExternalFilePaths(),要么此处改为检查已知 scheme 前缀(http://、https://、data:)。
| 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())) { |
There was a problem hiding this comment.
🟠 [P4] --list + --size 与 --list + --file/--name 错误处理不一致
--list + --file/--name 报错退出(返回 1),但 --list + --size 仅输出 warning 并继续执行(返回 0)。建议统一行为:直接静默忽略 --size(--list 模式不需要字号信息),无需输出 warning。
| // 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); |
There was a problem hiding this comment.
🟠 [P5] 路径函数不支持 Windows 反斜杠
ResolveFallbackTypeface(第 86 行)仅通过 specifier.find('/') 判断是否为文件路径,GetDirectory(第 136 行)也仅检查正斜杠。Windows 路径如 C:\fonts\Arial.ttf 不会被识别为文件路径。建议同时检查 '/' 和 '\\'。
| } | ||
| } | ||
| // Fallback: locate the font file via platform APIs and load by path. | ||
| auto location = pagx::SystemFonts::FindFont(family, style); |
There was a problem hiding this comment.
🟠 [P6] ResolveFallbackTypeface 中大写扩展名判断为死代码
第 90-92 行的 ext = specifier.substr(dot) 未做大小写转换,但后续同时比较 .ttf 和 .TTF。大小写混合的扩展名(如 .Ttf)两边都不匹配。而同文件 GetFileExtension 已做小写转换,两处逻辑不统一。建议对 ext 转小写后只保留小写比较。
| lastErrorPath_ = path; | ||
| return false; | ||
| } | ||
| document->loadFileData(path, data); |
There was a problem hiding this comment.
🟡 [P7] loadFileData 返回值未检查
document->loadFileData(path, data) 返回 bool 表示是否成功,但此处忽略了返回值。防御性编程建议检查返回值,失败时设置 lastErrorPath 并返回 false。
| @@ -24,6 +24,7 @@ | |||
| #include <vector> | |||
There was a problem hiding this comment.
🟡 [P8] 幂等性测试依赖隐式假设
Embed_Idempotent 测试通过字节级比较验证两次 embed 结果相同,但这依赖于 makeNode 的 ID 生成是确定性的。如果未来 ID 生成逻辑变化(如引入递增计数器),测试会假阳性失败。建议在测试注释中标注此假设,或改为语义级比较(如检查 Image 节点的 data 非空、无外部 filePath)。
| pagx font --list | ||
|
|
||
| # Embed fonts and images into a PAGX file | ||
| pagx embed input.pagx |
There was a problem hiding this comment.
🟡 [P9] embed 示例缺少输入文件参数
当前 pagx embed 示例没有展示输入文件参数,容易让用户困惑命令用法。建议改为 pagx embed input.pagx 以展示完整的最简用法。
| @@ -0,0 +1,204 @@ | |||
| <!-- refreshed: 2025-07-15 --> | |||
There was a problem hiding this comment.
🟡 [P10] .planning/codebase/ 目录不应入仓库
这些是 AI 辅助开发的上下文文件(共 7 个文件 +1663 行),不属于项目源码也不属于正式文档。建议从 PR 中移除:git rm --cached .planning/。
为 pagx CLI 工具新增 embed 子命令和字体查询增强功能: