diff --git a/.project-docs/30-worklog/tasks/20260911-confirmation-pdf-html-import-01a0858b.md b/.project-docs/30-worklog/tasks/20260911-confirmation-pdf-html-import-01a0858b.md new file mode 100644 index 0000000..891828d --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260911-confirmation-pdf-html-import-01a0858b.md @@ -0,0 +1,42 @@ +# 确认书 PDF 首页空白与标题丢失修复 + +- Task ID: `20260911-confirmation-pdf-html-import-01a0858b` +- Mode: Feature +- Status: Complete (local implementation and validation) +- Scope: 根据用户持续排查和修复确认书导出问题的请求,修复服务端 HTML 文档转 PDF 的导入方式;本地验证,不部署。 + +## Concurrent Worktree Check + +- Branch: `main` +- Worktree: `/Users/andy/IdeaProjects/LWLT-AIBOT` +- Base commit: `a4d55a5c55032a166f6d1a3c76eadf471ba36535` +- Existing changes: 未跟踪 `.idea/` 和 `dist/ltjt-order-assistant-0.5.173.zip`,保留,不涉及当前改动。 +- Other local worktrees: 无。 +- Overlap result: Clear;转换器及测试无既有改动。单 Agent 实现与复核。 + +## Findings and implementation + +- 原始 `.doc` 实际是 UTF-8 Word HTML。本地 Debian 12 容器中的 LibreOffice `7.4.7.2 40(Build:2)` 及四项 Fontconfig 匹配结果与用户提供的 ACK 输出一致;架构为 arm64,生产架构未核实。 +- 现有转换函数可稳定复现 4 页 PDF、首页完全空白、中文标题缺失。仅改字体映射、HTML 字体名、命名页面或 layout-grid 均未消除问题。 +- 在相同版本、字体、输入下显式增加 `--infilter=HTML (StarWriter)` 后得到 3 页,中文标题恢复。全部页面已渲染检查;原始 HTML 中 121 个至少 4 个非空白字符的文本片段全部可在结果中找到。 +- 已在 PDF 转换参数中按现有 HTML 检测结果增加 `--infilter=HTML (StarWriter)`;保留原有宽度规范化、隔离 profile、超时、大小限制及失败回退。二进制 Word、RTF、PDF 透传与 XLSX 转换不强制 HTML 导入。 +- 原始客户文件与实验 PDF 仅在用户文件位置和本机临时目录;仓库只保存脱敏验证结论,不保存业务正文。测试容器断网,不访问 ERP 或外发消息。 + +## Verification + +- 回归先行:旧实现新增用例中的 5 个 HTML 子用例失败,3 个非 HTML 子用例通过;修复后转换器测试 15/15 PASS。覆盖 ERP vendor MIME、BOM、DOCTYPE、body-only HTML、HTML 扩展名/MIME,以及二进制 DOC、DOCX、RTF 的格式边界,已有 XLSX/PDF 透传和宽度测试通过。 +- `node --run check`:PASS;`node --run build`:PASS;`node --run test:control-plane`:211/211 PASS。 +- 工作目录 `check:repo` 为 8/10、`test:legacy` 为 288/290;失败仅为既有 `.idea`、根目录和 `chrome-extension/` 下 `.DS_Store`。保留这些文件,在 `/private/tmp/ltjt-pdf-clean-verify-k61osr4z` 的当前文件副本中排除编辑器杂项和秘密后,`check:repo` 10/10、`test:legacy` 290/290 PASS。legacy 测试经授权允许本机临时 HTTP 监听,不启动业务服务。 +- 用 `node --run build` 生成的实际转换模块、原始输入、默认 `soffice` 路径和隔离 profile 在断网 Linux 容器复验:3 页、首面标题存在、121/121 正文片段匹配;全部 3 页渲染与已逐页检查的参数实验结果逐像素一致。 +- 原始文件 SHA-256 未变化。未验证远程印章图片下载、生产架构及完整生产镜像一致性;本地结果不代表生产已部署。 +- [不可变脱敏证据](../../../archive/evidence/2026-09-11/20260911-confirmation-pdf-html-import-01a0858b/README.md)。原始客户文件、PDF 和截图不进入仓库。 +- 最终自行只读复核 PASS:源码仅增加 HTML 条件导入参数,未改 XLSX 路径、超时、回退或业务路由;`git diff --check` PASS。无子 Agent、无部署、无数据库或 ERP 操作。 + +## Outcome and follow-ups + +- 本地实现与验证完成。生效需用户重建并部署服务端镜像;不需要插件、数据库、字体包或 LibreOffice 升级。 +- 部署后需重新导出确认书,既有 OSS/PDF 文件不会因代码升级自动重生成。生产部署和实际导出验收由用户执行。 + +## Promotion Candidates + +- 集成时可在文档导出架构说明中登记 HTML-in-DOC PDF 转换必须显式使用 Writer HTML 导入器,避免 LibreOffice 7.4 默认导入导致首段丢失;本任务不改 canonical 文档。 diff --git a/archive/evidence/2026-09-11/20260911-confirmation-pdf-html-import-01a0858b/README.md b/archive/evidence/2026-09-11/20260911-confirmation-pdf-html-import-01a0858b/README.md new file mode 100644 index 0000000..ea24c51 --- /dev/null +++ b/archive/evidence/2026-09-11/20260911-confirmation-pdf-html-import-01a0858b/README.md @@ -0,0 +1,11 @@ +# HTML 确认书 PDF 导入回归证据 + +这是 2026-09-11 的本地验证快照,不是当前业务规则或生产部署记录。活动实现以 `control-plane/src/document-converter.ts` 为准。 + +- [脱敏验证数据](verification.json):运行环境、输入哈希、页数、标题与文本完整性结果;未存储客户原文件、PDF、截图或正文。 +- 测试环境由项目相同 Debian 12 基础镜像及 LibreOffice Writer/Calc、Noto CJK 依赖搭建,LibreOffice `7.4.7.2 40(Build:2)` 和四项字体匹配结果与用户的 ACK 输出相同。本地 arm64;未验证生产架构和 Debian 补丁包版本完全一致。 +- 原始 HTML-in-DOC + 现有转换流程稳定复现 4 页、首页纯白、中文标题缺失。字体映射、显式中文字体名、去除 layout-grid 和命名页面对照均未解决。 +- 相同输入、版本和字体下,加入 `--infilter=HTML (StarWriter)` 后变为 3 页,中文标题恢复。修复后编译出的实际模块复验通过,3 页 PNG 与已经逐页视觉检查的参数实验结果逐像素相同。 +- 121 个长度至少为 4 个非空白字符的源 HTML 正文文本片段均能在修复后的 PDF 中匹配;这不是对任意文档或所有排版细节的保证。 +- 测试容器全部断网,未验证远程印章图片的下载,未操作 ERP、数据库、生产服务或 AgentBus。 +- LibreOffice 官方的 [转换过滤器说明](https://help.libreoffice.org/latest/en-US/text/shared/guide/convertfilters.html) 将 `HTML (StarWriter)` 定义为 Writer 的 HTML 导入过滤器。 diff --git a/archive/evidence/2026-09-11/20260911-confirmation-pdf-html-import-01a0858b/verification.json b/archive/evidence/2026-09-11/20260911-confirmation-pdf-html-import-01a0858b/verification.json new file mode 100644 index 0000000..d3cf9e1 --- /dev/null +++ b/archive/evidence/2026-09-11/20260911-confirmation-pdf-html-import-01a0858b/verification.json @@ -0,0 +1,51 @@ +{ + "date": "2026-09-11", + "purpose": "Local offline regression evidence; not a production deployment receipt", + "environment": { + "version": "LibreOffice 7.4.7.2 40(Build:2)", + "packages": "fontconfig\t2.14.1-4\nfonts-noto-cjk\t1:20220127+repack1-1\nlibreoffice-core\t4:7.4.7-1+deb12u14\nlibreoffice-writer\t4:7.4.7-1+deb12u14", + "arch": "arm64", + "fonts": { + "SimSun": "DejaVuSerif.ttf: \"DejaVu Serif\" \"Book\"", + "宋体": "DejaVuSans.ttf: \"DejaVu Sans\" \"Book\"", + "Times New Roman": "DejaVuSerif.ttf: \"DejaVu Serif\" \"Book\"", + "SimSun:weight=bold:lang=zh-cn": "NotoSerifCJK-Bold.ttc: \"Noto Serif CJK SC\" \"Bold\"" + } + }, + "production_match": "LibreOffice version output and four fc-match outputs match user-supplied ACK diagnostics; production architecture and Debian package revision were not verified", + "source": { + "bytes": 46632, + "sha256": "b4c0489e33d632019dd49de899220a66e6fc61a65ef964bdfa7fea45d24c68a8", + "stored_in_repository": false + }, + "baseline": { + "pages": 4, + "page_text_lengths": [ + 0, + 965, + 666, + 667 + ], + "blank_first_page_verified_by_pixels": true, + "chinese_title_present": false + }, + "fixed": { + "page_count": 3, + "page_text_lengths": [ + 896, + 613, + 809 + ], + "first_page_has_title": true, + "text_chunks_checked": 121, + "text_chunks_matched": 121, + "source_sha256": "b4c0489e33d632019dd49de899220a66e6fc61a65ef964bdfa7fea45d24c68a8", + "pdf_sha256": "5cc0b09de25c337bd343b989f38e991aca6f9eb61cbd94b4a25ea8332126758d" + }, + "visual_review": { + "pages_reviewed": 3, + "compiled_module_render_matches_reviewed_experiment": true + }, + "network": "disabled for all conversion tests; remote stamp image retrieval was not tested", + "change": "Add --infilter=HTML (StarWriter) only for detected HTML in PDF conversion" +} diff --git a/control-plane/src/document-converter.ts b/control-plane/src/document-converter.ts index 3d88d14..9226425 100644 --- a/control-plane/src/document-converter.ts +++ b/control-plane/src/document-converter.ts @@ -237,6 +237,11 @@ export async function convertDocumentToPdf( '--nolockcheck', '--nofirststartwizard', `-env:UserInstallation=${pathToFileURL(profileDirectory).href}`, + // Default HTML import can lose the first paragraph and emit a blank page + // in LibreOffice 7.4. Use Writer's HTML importer for HTML-in-DOC as well. + ...(isHtmlDocument(inputName, input.contentType, input.content) + ? ['--infilter=HTML (StarWriter)'] + : []), '--convert-to', `pdf:${PDF_EXPORT_FILTER}`, '--outdir', diff --git a/control-plane/test/document-converter.test.ts b/control-plane/test/document-converter.test.ts index 5be9fee..f5e655d 100644 --- a/control-plane/test/document-converter.test.ts +++ b/control-plane/test/document-converter.test.ts @@ -46,6 +46,55 @@ test('conversion failure falls back to the source file', async () => { assert.deepEqual(result.content, content); }); +test('PDF conversion selects Writer HTML import only for HTML documents', { + skip: process.platform === 'win32' ? 'Unix fake converter fixture is not executable through Windows execFile.' : false +}, async (t) => { + const temporaryDirectory = await mkdtemp(join(tmpdir(), 'ltjt-converter-import-test-')); + const converterPath = join(temporaryDirectory, 'fake-soffice.sh'); + await writeFile(converterPath, [ + '#!/bin/sh', + 'outdir=""', + 'next_is_outdir=0', + 'for arg in "$@"; do', + ' if [ "$next_is_outdir" = "1" ]; then outdir="$arg"; next_is_outdir=0; continue; fi', + ' if [ "$arg" = "--outdir" ]; then next_is_outdir=1; fi', + 'done', + 'printf "%s\\n" "%PDF-1.7" "$@" > "$outdir/team.pdf"' + ].join('\n'), { mode: 0o700 }); + await chmod(converterPath, 0o700); + const cases = [ + { name: 'ERP Word HTML with vendor MIME', fileName: 'team.doc', contentType: 'application/vnd.ms-word; Charset=UTF-8', content: '\uFEFF\r\n
确认书标题
正文
', html: true }, + { name: 'HTML doctype in a Word file', fileName: 'team.doc', contentType: 'application/msword', content: '确认书', html: true }, + { name: 'legacy body-only Word HTML', fileName: 'team.doc', contentType: 'application/msword', content: '确认书
', html: true }, + { name: 'HTML extension', fileName: 'team.html', contentType: 'application/octet-stream', content: '确认书
', html: true }, + { name: 'HTML MIME without extension', fileName: 'team', contentType: 'text/html; charset=utf-8', content: '确认书
', html: true }, + { name: 'binary Word', fileName: 'team.doc', contentType: 'application/msword', content: Buffer.from([0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1]), html: false }, + { name: 'DOCX package', fileName: 'team.docx', contentType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', content: Buffer.from([0x50, 0x4b, 0x03, 0x04]), html: false }, + { name: 'RTF', fileName: 'team.rtf', contentType: 'application/rtf', content: '{\\rtf1 Confirmation}', html: false } + ]; + try { + for (const item of cases) { + await t.test(item.name, async () => { + const result = await convertDocumentToPdf(testConfig(converterPath), { + fileName: item.fileName, + contentType: item.contentType, + content: Buffer.from(item.content), + maxBytes: 10_000 + }); + assert.equal(result.converted, true); + const args = result.content.toString('utf8').split('\n'); + assert.deepEqual( + args.filter((arg) => arg.startsWith('--infilter')), + item.html ? ['--infilter=HTML (StarWriter)'] : [] + ); + assert.ok(args.includes('pdf:writer_pdf_Export')); + }); + } + } finally { + await rm(temporaryDirectory, { recursive: true, force: true }); + } +}); + test('a valid converter output replaces the source with a PDF', { skip: process.platform === 'win32' ? 'Unix fake converter fixture is not executable through Windows execFile.' : false }, async () => {