10 Commits
Author SHA1 Message Date
SkyJourney 64445322eb release: 发布 v0.6.2 DOCX 真实文档修复
新增能力:将 DOCX 发布验收拆分为四套独立 140,支持真实语料冻结、指纹复用、失败与基础设施错误独立统计,并为表格换行、全 JSON 围栏、代码连续性和长文档分页建立通用门禁。

问题修复:冻结 Paged.js 分片前的逻辑表格列轨并传递打印几何,统一 Markdown 表格换行、代码、段落与 OOXML 翻译;改进 PDF 文本流排序、语义块映射、颜色与栅格比较,消除窄字符重叠和跨行范围符号误报。

兼容与部署:版本统一为 0.6.2;正式 Docker 镜像内置固定 Chromium、Pandoc 3.9.0.2 和 Serif/Sans/Mono 字体;Desktop NSIS 与 ZIP 继续直接内置字体,无需系统字体安装。

验证结果:合成基线与长庆严格 280/280,M4N 140/140;健康数据残余误报 6/115(5.22%),均核查为重复表头自动对齐/取样误报且基础设施错误为 0。全项目测试、类型检查、生产构建和 git diff --check 通过;正式 Docker、NSIS、ZIP、离线镜像、部署包、清单及 SHA-256 均已生成并校验。
2026-08-26 10:50:20 +08:00
SkyJourney 01b06abc2c release: 发布字体直接内置版 v0.6.1
新增能力:14 套主题按纵横方向与五组页边距组成的 140 场景视觉矩阵全部通过,封面执行整页严格门禁,正文执行分页无关的语义块门禁。

问题修复:统一 CSS 到 WordprocessingML 的通用翻译,修复字体嵌入、中文标点字距、精确行距、打印媒体与行内代码连续性,不包含按主题 ID 的特调。

发行变化:Desktop 安装器与免安装 ZIP 直接内置 Serif、Sans、Mono 三套字体包;NSIS 移除字体选择和伴随安装逻辑,不再发布独立字体安装器或字体 ZIP。正式附件收敛为 Desktop 安装器、Desktop ZIP、Docker 离线镜像、Web 部署包和发行说明。

部署兼容:Docker 镜像 md-to-pdf:v0.6.1 使用完整无缓存路径构建,固定 Chromium、Pandoc 3.9.0.2 和三套内置字体包;支持覆盖安装 v0.6.0,Windows 安装器保持未签名的内部发布状态。

验证结果:116 个测试文件、616 项测试、全项目类型检查、生产构建和 git diff --check 全部通过;140/140 视觉矩阵阻断失败和诊断失败均为 0;源码服务、Docker Web API 与实际安装 Desktop 的 DOCX 字体嵌入链路均通过。
2026-08-04 12:25:02 +08:00
SkyJourney f93abf064f docs: 记录 v0.6.1 发布完成
补充发布提交与 annotated tag、8 项附件校验和、离线镜像重新导入及运行态验收结果。
2026-08-04 10:34:55 +08:00
SkyJourney 2c5c1bd317 release: 发布 v0.6.1 DOCX 视觉一致性修复
新增通用 CSS 到 OOXML 翻译修复,统一字体、字距、精确行距、段落、列表、表格、引用、代码块与行内代码连续性,不引入按主题 ID 分支。

新增 MdTP Mono 并统一 Serif、Sans、Mono 三字体包的 Chromium 与 DOCX 使用链;字体声明、嵌入部件和 Word/WPS 实际采用均进入硬门禁。

重建封面整页及正文语义块视觉差分,14 套主题、纵横两个方向、五组页边距共 140 个真实场景全部通过,阻断失败和诊断失败均为零。

源码服务、Docker Web API 与实际安装 Desktop 的 red-briefing 导出均包含 5 个字体部件;Word/WPS 原生渲染和逐页复核通过。修复 Docker 构建上下文与运行层复用软链接,并完善 v0.6.1 版本、发行说明和发布归集。

验证:npm test(116 个文件、616 项测试)、npm run typecheck、npm run build、git diff --check 全部通过。Desktop 安装器与 ZIP、Docker v0.6.1 镜像已生成;Windows 产物仍为未签名内部发行。
2026-08-04 10:30:44 +08:00
SkyJourney b275c671fc fix: 修复DOCX独立封面视觉门禁
统一 Chromium 与 DOCX 的受限高度封面布局,补齐可编辑边框、装饰边和 Office 页面舍入适配。

按四套独立封面主题、纵横方向及五组页边距执行 40 个 Word/WPS 真实视觉场景,严格失败为 0,并保留正文诊断供 D4 修复。
2026-08-03 18:15:21 +08:00
SkyJourney 9dee7e8605 fix: 统一十四套主题DOCX字体映射 2026-08-03 12:47:14 +08:00
SkyJourney 6a6972ae48 fix: 内置DOCX双中文字体包 2026-08-03 12:07:12 +08:00
SkyJourney a094dabf02 fix: 增强DOCX字体实际使用门禁 2026-08-03 11:32:06 +08:00
SkyJourney f0fff7f726 test: 扩展DOCX全主题视觉矩阵 2026-08-03 10:23:22 +08:00
SkyJourney 28c88faabb fix: 重建DOCX视觉一致性门禁 2026-08-03 10:09:43 +08:00
154 changed files with 16520 additions and 1006 deletions
+19
View File
@@ -0,0 +1,19 @@
.git
.github
.local
.vscode
apps/desktop
coverage
dist
node_modules
out
output/*
!output/font-packs/
output/font-packs/*
!output/font-packs/root/
!output/font-packs/root/**
playwright-report
test-results
tmp
*.log
*.tsbuildinfo
+6 -2
View File
@@ -57,11 +57,15 @@ npm run verify:pandoc-runtime -w @md-to-pdf/desktop
```
版本化目录包、NSIS `Setup.exe` 和 ZIP 输出到
`apps/desktop/out/v0.6.0/`。该目录被 Git 忽略。公司内部分发以
`MorphDoc-0.6.0-x86_64-Setup.exe` 为正式安装包,ZIP 作为免安装
`apps/desktop/out/v0.6.2/`。该目录被 Git 忽略。公司内部分发以
`MorphDoc-0.6.2-x86_64-Setup.exe` 为正式安装包,ZIP 作为免安装
辅助包;当前未配置代码签名,Windows 首次运行可能显示“未知发布者”
提示。
Serif、Sans、Mono 三套正式字体随 NSIS 安装包和便携 ZIP 直接进入
`resources/font-packs`,由应用私有加载,不写入 Windows 系统字体目录。
安装向导不再提供字体选择页,正式发行也不附带独立字体安装器或字体 ZIP。
NSIS 使用标准辅助安装模式,支持选择当前用户/所有用户安装和自定义安装
目录,并创建桌面及开始菜单快捷方式。正式安装包作为 Gitea Release 附件
发布,不提交进 Git 历史。
-82
View File
@@ -9,70 +9,6 @@
; 独立键可让 v0.6.0 默认进入 MorphDoc 目录,同时仍由相同卸载身份识别旧版。
!define INSTALL_REGISTRY_KEY "Software\${APP_GUID}.MorphDoc"
!ifdef FONT_PACK_COMPANION_FILE
!ifndef BUILD_UNINSTALLER
!include "FileFunc.nsh"
!include "nsDialogs.nsh"
Var fontPackCompanionAvailable
Var fontPackCompanionCheckbox
Var fontPackCompanionExitCode
Var fontPackCompanionHash
Var fontPackCompanionPath
Var fontPackCompanionSelected
!macro customInit
StrCpy $fontPackCompanionAvailable "0"
StrCpy $fontPackCompanionSelected "0"
StrCpy $fontPackCompanionPath "$EXEDIR\${FONT_PACK_COMPANION_FILE}"
IfFileExists "$fontPackCompanionPath" 0 font_pack_init_done
${StdUtils.HashFile} $fontPackCompanionHash "SHA2-256" "$fontPackCompanionPath"
StrCmp $fontPackCompanionHash "${FONT_PACK_COMPANION_SHA256}" 0 font_pack_init_done
StrCpy $fontPackCompanionAvailable "1"
IfSilent font_pack_silent font_pack_interactive
font_pack_interactive:
StrCpy $fontPackCompanionSelected "1"
Goto font_pack_init_done
font_pack_silent:
${GetParameters} $R0
${GetOptions} $R0 "/WITHFONTPACK=" $R1
StrCmp $R1 "1" 0 font_pack_init_done
StrCpy $fontPackCompanionSelected "1"
font_pack_init_done:
!macroend
Function FontPackCompanionPage
StrCmp $fontPackCompanionAvailable "1" 0 font_pack_page_skip
nsDialogs::Create 1018
Pop $R0
StrCmp $R0 "error" 0 +2
Abort
${NSD_CreateLabel} 0 0 100% 28u "检测到经过完整性校验的墨呈可选字体包。安装后,政企公文主题可使用与 PDF 相同的原生中文字体资产。"
Pop $R0
${NSD_CreateCheckbox} 0 38u 100% 14u "安装 ${FONT_PACK_COMPANION_NAME}"
Pop $fontPackCompanionCheckbox
${NSD_Check} $fontPackCompanionCheckbox
nsDialogs::Show
Return
font_pack_page_skip:
Abort
FunctionEnd
Function FontPackCompanionPageLeave
${NSD_GetState} $fontPackCompanionCheckbox $R0
StrCmp $R0 ${BST_CHECKED} 0 font_pack_page_unchecked
StrCpy $fontPackCompanionSelected "1"
Return
font_pack_page_unchecked:
StrCpy $fontPackCompanionSelected "0"
FunctionEnd
!macro customPageAfterChangeDir
Page custom FontPackCompanionPage FontPackCompanionPageLeave
!macroend
!endif
!endif
!macro customInstall
DeleteRegKey SHCTX "${MORPHDOC_LEGACY_INSTALL_REGISTRY_KEY}"
WriteRegStr SHCTX \
@@ -124,24 +60,6 @@
"" \
"$INSTDIR\${MORPHDOC_EXECUTABLE}"
StrCpy $launchLink "$INSTDIR\${MORPHDOC_EXECUTABLE}"
!ifdef FONT_PACK_COMPANION_FILE
StrCmp $fontPackCompanionAvailable "1" 0 font_pack_install_done
StrCmp $fontPackCompanionSelected "1" 0 font_pack_install_done
${StdUtils.HashFile} $fontPackCompanionHash "SHA2-256" "$fontPackCompanionPath"
StrCmp $fontPackCompanionHash "${FONT_PACK_COMPANION_SHA256}" 0 font_pack_integrity_failed
DetailPrint "正在安装可选字体包:${FONT_PACK_COMPANION_NAME}"
ExecWait '"$fontPackCompanionPath" /S' $fontPackCompanionExitCode
StrCmp $fontPackCompanionExitCode "0" font_pack_install_done
DetailPrint "可选字体包安装失败,退出码:$fontPackCompanionExitCode"
IfSilent font_pack_install_done 0
MessageBox MB_ICONEXCLAMATION|MB_OK "主程序已安装,但可选字体包安装失败(退出码 $fontPackCompanionExitCode)。您可以稍后单独运行字体包安装器。"
Goto font_pack_install_done
font_pack_integrity_failed:
DetailPrint "可选字体包完整性复核失败,已跳过安装。"
IfSilent font_pack_install_done 0
MessageBox MB_ICONEXCLAMATION|MB_OK "可选字体包完整性复核失败,主程序已安装,但字体包已跳过。"
font_pack_install_done:
!endif
System::Call 'shell32::SHChangeNotify(i 0x08000000, i 0, i 0, i 0)'
!macroend
+5
View File
@@ -41,6 +41,11 @@ module.exports = {
to: "samples",
filter: ["**/*"]
},
{
from: "../../output/font-packs/root",
to: "font-packs",
filter: ["**/*"]
},
{
from: `.runtime/pandoc/${pandocRuntime.version}/windows-x86_64`,
to: `pandoc/${pandocRuntime.version}/windows-x86_64`,
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@md-to-pdf/desktop",
"version": "0.6.0",
"version": "0.6.2",
"private": true,
"productName": "MorphDoc",
"description": "墨呈桌面端:面向结构化 Markdown 的主题化文档创作与发布工具",
@@ -72,13 +72,6 @@ export async function captureDocxMediaWithElectronWebContents(
signal?: AbortSignal,
timeouts: ElectronDocxMediaCaptureTimeouts = DEFAULT_CAPTURE_TIMEOUTS
) {
signal?.throwIfAborted();
const plan = (await withTimeout(
webContents.executeJavaScript(createRenderScript(request), true),
timeouts.renderMs,
`桌面 DOCX 媒体布局超过 ${timeouts.renderMs}ms`,
signal
)) as DocxMediaCapturePlan;
signal?.throwIfAborted();
const ownsDebugger = !webContents.debugger.isAttached();
if (ownsDebugger) {
@@ -86,6 +79,16 @@ export async function captureDocxMediaWithElectronWebContents(
}
try {
await webContents.debugger.sendCommand("Emulation.setEmulatedMedia", {
media: "print"
});
const plan = (await withTimeout(
webContents.executeJavaScript(createRenderScript(request), true),
timeouts.renderMs,
`桌面 DOCX 媒体布局超过 ${timeouts.renderMs}ms`,
signal
)) as DocxMediaCapturePlan;
signal?.throwIfAborted();
const captures = [];
for (const target of plan.targets) {
signal?.throwIfAborted();
@@ -117,8 +120,13 @@ export async function captureDocxMediaWithElectronWebContents(
signal?.throwIfAborted();
return { plan, captures };
} finally {
if (ownsDebugger && webContents.debugger.isAttached()) {
webContents.debugger.detach();
if (webContents.debugger.isAttached()) {
await webContents.debugger
.sendCommand("Emulation.setEmulatedMedia", { media: "screen" })
.catch(() => undefined);
if (ownsDebugger && webContents.debugger.isAttached()) {
webContents.debugger.detach();
}
}
}
}
+11 -2
View File
@@ -130,7 +130,7 @@ function getDesktopThemeDirectory() {
: path.resolve(currentDirectory, "../../..", ".local", "themes");
}
function getDesktopFontPackDirectory() {
function getDesktopUserFontPackDirectory() {
if (!app.isPackaged) {
return path.resolve(
currentDirectory,
@@ -147,6 +147,15 @@ function getDesktopFontPackDirectory() {
function createDesktopApplicationService() {
const projectRoot = path.resolve(currentDirectory, "../../..");
const fontPackRoots = app.isPackaged
? [
path.join(process.resourcesPath, "font-packs"),
getDesktopUserFontPackDirectory()
]
: [
path.join(projectRoot, "output", "font-packs", "root"),
getDesktopUserFontPackDirectory()
];
return createApplicationService({
bundledRoot: app.isPackaged
? path.join(process.resourcesPath, "themes")
@@ -155,7 +164,7 @@ function createDesktopApplicationService() {
createAssetUrl: (themeId, assetPath) =>
`${APP_SCHEME}://${THEME_HOST}/${encodeURIComponent(themeId)}/${encodeThemeAssetPath(assetPath)}`,
fontPacks: {
roots: [getDesktopFontPackDirectory()],
roots: fontPackRoots,
appVersion: app.getVersion(),
createAssetUrl: (
packId,
@@ -95,6 +95,14 @@ describe("Electron DOCX 媒体捕获", () => {
png: Buffer.from("png")
});
expect(attach).toHaveBeenCalledWith("1.3");
expect(sendCommand).toHaveBeenCalledWith(
"Emulation.setEmulatedMedia",
{ media: "print" }
);
expect(sendCommand).toHaveBeenCalledWith(
"Emulation.setEmulatedMedia",
{ media: "screen" }
);
expect(sendCommand).toHaveBeenCalledWith(
"Page.captureScreenshot",
expect.objectContaining({
@@ -106,10 +114,18 @@ describe("Electron DOCX 媒体捕获", () => {
});
it("媒体布局永久不返回时报告局部超时", async () => {
let attached = false;
const webContents = {
executeJavaScript: vi.fn(() => new Promise(() => undefined)),
debugger: {
isAttached: vi.fn(() => false)
isAttached: vi.fn(() => attached),
attach: vi.fn(() => {
attached = true;
}),
detach: vi.fn(() => {
attached = false;
}),
sendCommand: vi.fn(async () => undefined)
}
} as unknown as WebContents;
@@ -136,7 +152,11 @@ describe("Electron DOCX 媒体捕获", () => {
attached = true;
}),
detach,
sendCommand: vi.fn(() => new Promise(() => undefined))
sendCommand: vi.fn((method: string) =>
method === "Page.captureScreenshot"
? new Promise(() => undefined)
: Promise.resolve(undefined)
)
}
} as unknown as WebContents;
+25 -18
View File
@@ -72,8 +72,12 @@ describe("桌面发行构建链", () => {
"npm run prepare:pandoc"
);
expect(rootManifest.scripts?.["desktop:package"]).toBe(
"npm run package -w @md-to-pdf/desktop"
"npm run build:font-pack && npm run package -w @md-to-pdf/desktop"
);
expect(rootManifest.scripts?.["desktop:make"]).toBe(
"npm run build:font-pack && node scripts/build-desktop-release.mjs"
);
expect(rootManifest.scripts?.["desktop:make:with-font-pack"]).toBeUndefined();
});
it("将固定 Pandoc 最小运行时作为 Desktop 独立资源打包", () => {
@@ -156,7 +160,7 @@ describe("桌面发行构建链", () => {
artifactPrefix: "MorphDoc"
});
expect(desktopManifest).toMatchObject({
version: "0.6.0",
version: "0.6.2",
productName: "MorphDoc"
});
expect(builderConfig).toContain("appId: brand.appId");
@@ -180,32 +184,35 @@ describe("桌面发行构建链", () => {
);
});
it("仅在组合构建时接入经过哈希校验的同目录字体包", () => {
it("在安装版与便携版中强制内置完整字体包集合", () => {
const builderConfig = readFileSync(
`${desktopRoot}/electron-builder.config.cjs`,
"utf8"
);
const desktopMain = readFileSync(
`${desktopRoot}/src/main.ts`,
"utf8"
);
const bundledBuild = readFileSync(
`${projectRoot}/scripts/build-desktop-release.mjs`,
"utf8"
);
const installerInclude = readFileSync(
`${desktopRoot}/build/installer.nsh`,
"utf8"
);
const companionBuild = readFileSync(
`${projectRoot}/scripts/build-desktop-with-font-pack.mjs`,
"utf8"
);
expect(builderConfig).toContain("MD_TO_PDF_NSIS_INCLUDE");
expect(installerInclude).toContain("FONT_PACK_COMPANION_SHA256");
expect(installerInclude).toContain("StdUtils.HashFile");
expect(installerInclude).toContain("/WITHFONTPACK=");
expect(companionBuild).toContain("createDesktopCompanionInclude");
expect(companionBuild).toContain("MD_TO_PDF_NSIS_INCLUDE");
expect(companionBuild).toContain(
"MorphDoc-FontPack-mdtp-serif-sc-1.0.0-x86_64-Setup.exe"
);
expect(companionBuild).toContain(
"MorphDoc-FontPack-mdtp-serif-sc-1.0.0.zip"
expect(builderConfig).toContain('../../output/font-packs/root');
expect(builderConfig).toContain('to: "font-packs"');
expect(desktopMain).toContain(
'path.join(process.resourcesPath, "font-packs")'
);
expect(desktopMain).toContain("getDesktopUserFontPackDirectory()");
expect(bundledBuild).not.toContain("createDesktopCompanionInclude");
expect(bundledBuild).toContain("已生成字体直接内置");
expect(installerInclude).not.toContain("FONT_PACK_COMPANION");
expect(installerInclude).not.toContain("FontPackCompanionPage");
expect(installerInclude).not.toContain("/WITHFONTPACK=");
});
it("将 DOCX Lua Filter 复制到桌面主进程资源目录", () => {
+449 -15
View File
@@ -1,16 +1,20 @@
import { createHash } from "node:crypto";
import { createHash, randomUUID } from "node:crypto";
import fs from "node:fs";
import net from "node:net";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { defaultExportConfig, themeManifestSchema } from "@md-to-pdf/core";
import { unzipSync } from "fflate";
import matter from "gray-matter";
import { buildApp } from "../dist/app.js";
import { createDocxMediaEngine } from "../dist/docx-media-engine.js";
import { createPdfGenerator } from "../dist/pdf-engine.js";
const directory = path.dirname(fileURLToPath(import.meta.url));
const repositoryDirectory = path.resolve(directory, "../../..");
const appVersion = JSON.parse(
fs.readFileSync(path.join(repositoryDirectory, "package.json"), "utf8")
).version;
const themesDirectory = path.join(repositoryDirectory, "themes");
const samplesDirectory = path.join(repositoryDirectory, "samples", "themes");
const webDirectory = path.join(repositoryDirectory, "apps", "web", "dist");
@@ -32,6 +36,146 @@ const fontPackRoot = path.resolve(
);
const decoder = new TextDecoder();
function writeArtifactWithBusyFallback(filePath, content) {
try {
fs.writeFileSync(filePath, content);
return filePath;
} catch (error) {
if (error?.code !== "EBUSY") {
throw error;
}
const extension = path.extname(filePath);
const fallbackPath = path.join(
path.dirname(filePath),
`${path.basename(filePath, extension)}-attempt-${randomUUID()}${extension}`
);
fs.writeFileSync(fallbackPath, content);
process.stderr.write(
`[DOCX R4 matrix] 既有产物被 Office 锁定,改写本次尝试文件:${path.basename(fallbackPath)}\n`
);
return fallbackPath;
}
}
const inlineCodeContinuityProbes = [
{
id: "paragraph",
code: "mdtp_ic_p",
text: "段落前mdtp_ic_p段落后"
},
{
id: "list-item",
code: "mdtp_ic_l",
text: "列表前mdtp_ic_l列表后"
},
{
id: "table-cell",
code: "mdtp_ic_c",
text: "单元格前mdtp_ic_c单元格后"
}
];
const inlineCodeContinuityMarkdown = `
## 行内代码连续性门禁
段落前\`mdtp_ic_p\`段落后
- 列表前\`mdtp_ic_l\`列表后
| 门禁 | 内容 | 邻接单元格 |
| --- | --- | --- |
| 行内代码 | 单元格前\`mdtp_ic_c\`单元格后 | 邻甲<br>邻乙 |
| 相邻隔离 | 旁甲<br/>旁乙 | 附甲 |
| 场景 | 内容 |
| --- | --- |
| 壹式 | 标甲<br>标乙<br/>标丙<br />标丁 |
| 贰式 | 大甲<BR>大乙<BR/>大丙<BR />大丁 |
| 叁式 | 连甲<br><br>连乙 |
| 肆式 | <br>边界<br> |
| 伍式 | 转甲\\<br>转乙 &lt;br&gt; 转丙 |
| 陆式 | 属甲<br class="unsafe">属乙 |
| 柒式 | 码甲\`<br>\`码乙 |
表格外保留 外一<br>外二 原文。
\`\`\`html
围一<br>围二
\`\`\`
`;
const docxBreakParagraphProbes = [
{
id: "basic",
marker: "标甲",
expectedText: "标甲标乙标丙标丁",
hardBreakCount: 3
},
{
id: "case-insensitive",
marker: "大甲",
expectedText: "大甲大乙大丙大丁",
hardBreakCount: 3
},
{
id: "multiple",
marker: "连甲",
expectedText: "连甲连乙",
hardBreakCount: 2
},
{
id: "boundary",
marker: "边界",
expectedText: "边界",
hardBreakCount: 2
},
{
id: "adjacent-a",
marker: "邻乙",
expectedText: "邻甲邻乙",
hardBreakCount: 1
},
{
id: "adjacent-b",
marker: "旁乙",
expectedText: "旁甲旁乙",
hardBreakCount: 1
},
{
id: "escaped-and-entity",
marker: "转甲",
expectedText: "转甲&lt;br&gt;转乙 &lt;br&gt; 转丙",
hardBreakCount: 0
},
{
id: "attribute",
marker: "属甲",
expectedText: "属甲&lt;br class=\"unsafe\"&gt;属乙",
hardBreakCount: 0
},
{
id: "inline-code",
marker: "码甲",
expectedText: "码甲&lt;br&gt;码乙",
hardBreakCount: 0,
requiredCharacterStyle: "VerbatimChar"
},
{
id: "outside-table",
marker: "外一",
expectedText: "表格外保留 外一&lt;br&gt;外二 原文。",
hardBreakCount: 0
},
{
id: "fenced-code",
marker: "围一",
expectedText: "围一&lt;br&gt;围二",
hardBreakCount: 0,
requiredParagraphStyle: "SourceCode"
}
];
const contentTypes = new Map([
[".css", "text/css; charset=utf-8"],
[".html", "text/html; charset=utf-8"],
@@ -50,6 +194,31 @@ function assert(condition, message) {
}
}
function newestModifiedAt(targetPath) {
const stat = fs.statSync(targetPath);
if (stat.isFile()) {
return stat.mtimeMs;
}
return fs.readdirSync(targetPath, { withFileTypes: true }).reduce(
(newest, entry) =>
Math.max(
newest,
newestModifiedAt(path.join(targetPath, entry.name))
),
stat.mtimeMs
);
}
function assertFreshArtifact(label, sourcePaths, artifactPath) {
assert(fs.existsSync(artifactPath), `${label} 构建产物不存在:${artifactPath}`);
const newestSource = Math.max(...sourcePaths.map(newestModifiedAt));
const artifactModifiedAt = fs.statSync(artifactPath).mtimeMs;
assert(
artifactModifiedAt + 1_500 >= newestSource,
`${label} 构建产物早于源码;请按依赖顺序重新构建后再执行门禁`
);
}
function sha256(content) {
return createHash("sha256").update(content).digest("hex");
}
@@ -172,6 +341,11 @@ function inspectDocx(content) {
const fontNames = [
...fontTableXml.matchAll(/<w:font\s+w:name="([^"]+)"/gu)
].map((match) => match[1]);
const embeddedFontNames = [
...fontTableXml.matchAll(/<w:font\s+w:name="([^"]+)"[\s\S]*?<\/w:font>/gu)
].flatMap((match) => /<w:embed(?:Regular|Bold|Italic|BoldItalic)\b/u.test(match[0])
? [match[1]]
: []);
const paragraphs = [
...documentXml.matchAll(/<w:p(?:\s|>)[\s\S]*?<\/w:p>/gu)
].map((match) => match[0]);
@@ -196,6 +370,88 @@ function inspectDocx(content) {
count + (paragraph.match(/<w:tab\s*\/>/gu)?.length ?? 0),
0
);
const inlineCodeContinuity = inlineCodeContinuityProbes.map((probe) => {
const matchingParagraphs = paragraphs.filter((paragraph) =>
paragraph.includes(probe.code)
);
const paragraph = matchingParagraphs[0] ?? "";
const text = [...paragraph.matchAll(/<w:t(?:\s[^>]*)?>([\s\S]*?)<\/w:t>/gu)]
.map((match) => match[1])
.join("");
const codeRunPattern = new RegExp(
`<w:r(?:\\s|>)[\\s\\S]*?<w:rStyle\\s+w:val="VerbatimChar"\\s*\\/>[\\s\\S]*?<w:t(?:\\s[^>]*)?>${probe.code}<\\/w:t>[\\s\\S]*?<\\/w:r>`,
"u"
);
const hardBreakCount =
paragraph.match(/<w:br(?:\s[^>]*)?\s*\/>/gu)?.length ?? 0;
return {
id: probe.id,
code: probe.code,
expectedText: probe.text,
actualText: text,
paragraphCount: matchingParagraphs.length,
hardBreakCount,
characterStylePresent: codeRunPattern.test(paragraph),
passed:
matchingParagraphs.length === 1 &&
text === probe.text &&
hardBreakCount === 0 &&
codeRunPattern.test(paragraph)
};
});
const breakProbes = docxBreakParagraphProbes.map((probe) => {
const matchingParagraphs = paragraphs.map((paragraph) => ({
paragraph,
actualText: [
...paragraph.matchAll(/<w:t(?:\s[^>]*)?>([\s\S]*?)<\/w:t>/gu)
].map((match) => match[1]).join("")
})).filter(({ actualText }) => actualText === probe.expectedText);
const paragraph = matchingParagraphs[0]?.paragraph ?? "";
const actualText = matchingParagraphs[0]?.actualText ?? "";
const hardBreakCount =
paragraph.match(/<w:br(?:\s[^>]*)?\s*\/>/gu)?.length ?? 0;
const runs = paragraph.match(/<w:r(?:\s[^>]*)?>[\s\S]*?<\/w:r>/gu) ?? [];
const characterStylePresent = !probe.requiredCharacterStyle ||
runs.some((run) =>
run.includes(`w:rStyle w:val="${probe.requiredCharacterStyle}"`) &&
run.includes("&lt;br&gt;")
);
const paragraphStylePresent = !probe.requiredParagraphStyle ||
new RegExp(
`<w:pStyle\\s+w:val="${probe.requiredParagraphStyle}"\\s*\\/>`,
"u"
).test(paragraph);
return {
id: probe.id,
marker: probe.marker,
expectedText: probe.expectedText,
actualText,
expectedHardBreakCount: probe.hardBreakCount,
hardBreakCount,
paragraphCount: matchingParagraphs.length,
characterStylePresent,
paragraphStylePresent,
passed:
matchingParagraphs.length === 1 &&
actualText === probe.expectedText &&
hardBreakCount === probe.hardBreakCount &&
characterStylePresent &&
paragraphStylePresent
};
});
const adjacentA = breakProbes.find((probe) => probe.id === "adjacent-a");
const adjacentB = breakProbes.find((probe) => probe.id === "adjacent-b");
const tableBreakSemantics = {
probes: breakProbes,
adjacentCellsIsolated:
adjacentA?.paragraphCount === 1 &&
adjacentB?.paragraphCount === 1 &&
adjacentA.actualText !== adjacentB.actualText,
passed:
breakProbes.every((probe) => probe.passed) &&
adjacentA?.paragraphCount === 1 &&
adjacentB?.paragraphCount === 1
};
return {
bytes: content.byteLength,
sha256: sha256(content),
@@ -205,6 +461,9 @@ function inspectDocx(content) {
fontNames: [...new Set(fontNames)].sort((first, second) =>
first.localeCompare(second, "en")
),
embeddedFontNames: [...new Set(embeddedFontNames)].sort((first, second) =>
first.localeCompare(second, "en")
),
optionalFontPackApplied: fontTableXml.includes("MdTP Serif SC"),
hasAltChunk: /<w:altChunk(?:\s|>)/u.test(documentXml),
internalMarkerCount:
@@ -214,6 +473,11 @@ function inspectDocx(content) {
officialIssueRowRightTabCount:
officialIssueRowsWithRightTab.length,
officialIssueRowContentTabCount,
inlineCodeContinuity,
inlineCodeContinuityPassed: inlineCodeContinuity.every(
(probe) => probe.passed
),
tableBreakSemantics,
requiredStylesPresent: [
"Normal",
"Heading1",
@@ -242,6 +506,103 @@ async function requestArtifact(origin, route, payload, contentType) {
return { response, content };
}
async function requestJson(origin, route, payload) {
const response = await fetch(`${origin}${route}`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(payload)
});
const text = await response.text();
assert(response.ok, `${route} 返回 ${response.status}${text}`);
assert(
response.headers.get("content-type")?.includes("application/json"),
`${route} MIME 无效:${response.headers.get("content-type")}`
);
return JSON.parse(text);
}
function inspectHtmlBreakSemantics(articleHtml) {
const cellFor = (marker) => {
const cells = articleHtml.match(/<t[dh](?:\s[^>]*)?>[\s\S]*?<\/t[dh]>/gu) ?? [];
return cells.find((cell) => cell.includes(marker)) ?? "";
};
const fragmentFor = (start, end) => {
const startIndex = articleHtml.indexOf(start);
const endIndex = articleHtml.indexOf(end, startIndex + start.length);
return startIndex >= 0 && endIndex >= 0
? articleHtml.slice(startIndex, endIndex + end.length)
: "";
};
const probes = [
{
id: "basic",
passed: cellFor("标甲").includes(
"标甲<br />标乙<br />标丙<br />标丁"
)
},
{
id: "case-insensitive",
passed: cellFor("大甲").includes(
"大甲<br />大乙<br />大丙<br />大丁"
)
},
{
id: "multiple",
passed: cellFor("连甲").includes(
"连甲<br /><br />连乙"
)
},
{
id: "boundary",
passed: cellFor("边界").includes("<br />边界<br />")
},
{
id: "escaped-and-entity",
passed: cellFor("转甲").includes(
"转甲&lt;br&gt;转乙 &lt;br&gt; 转丙"
)
},
{
id: "attribute-literal",
passed: cellFor("属甲").includes(
"属甲&lt;br class=\"unsafe\"&gt;属乙"
)
},
{
id: "inline-code-literal",
passed: cellFor("码甲").includes(
"码甲<code>&lt;br&gt;</code>码乙"
)
},
{
id: "outside-table-literal",
passed: fragmentFor("外一", "外二").includes(
"外一&lt;br&gt;外二"
)
},
{
id: "fenced-code-literal",
passed: (() => {
const fragment = fragmentFor("围一", "围二");
return fragment.includes("&lt;") && fragment.includes("br") &&
fragment.includes("&gt;") && !/<br(?:\s|\/?>)/iu.test(fragment);
})()
}
];
const adjacentA = cellFor("邻甲");
const adjacentB = cellFor("旁甲");
const adjacentCellsIsolated =
adjacentA.includes("邻甲<br />邻乙") &&
adjacentB.includes("旁甲<br />旁乙") &&
!adjacentA.includes("旁甲") &&
!adjacentB.includes("邻甲");
return {
probes,
adjacentCellsIsolated,
passed: probes.every((probe) => probe.passed) && adjacentCellsIsolated
};
}
function responseDiagnostics(response, kind) {
return {
serverTiming: response.headers.get("server-timing"),
@@ -263,6 +624,42 @@ function responseDiagnostics(response, kind) {
}
assert(fs.existsSync(standardReportPath), "缺少本轮标准 DOCX 主题矩阵报告");
assertFreshArtifact(
"Preview Engine",
[path.join(repositoryDirectory, "packages", "preview-engine", "src")],
path.join(
repositoryDirectory,
"packages",
"preview-engine",
"dist",
"semantic-cover-fit.js"
)
);
assertFreshArtifact(
"DOCX Engine",
[path.join(repositoryDirectory, "packages", "docx-engine", "src")],
path.join(
repositoryDirectory,
"packages",
"docx-engine",
"dist",
"document-structure-transform.js"
)
);
assertFreshArtifact(
"Web Preview Runtime",
[
path.join(repositoryDirectory, "apps", "web", "src"),
path.join(
repositoryDirectory,
"packages",
"preview-engine",
"dist",
"semantic-cover-fit.js"
)
],
path.join(webDirectory, "preview-frame.html")
);
assert(
fs.statSync(fontPackRoot).isDirectory(),
`缺少有效字体包根目录:${fontPackRoot}`
@@ -274,6 +671,10 @@ assert(
);
const selectedThemeId =
process.env.MD_TO_PDF_R4_THEME_ID?.trim() || undefined;
const externalMarkdownPath =
process.env.MD_TO_PDF_R4_MARKDOWN_PATH?.trim() || undefined;
const appendInlineCodeProbes =
process.env.MD_TO_PDF_R4_APPEND_INLINE_CODE_PROBES?.trim() !== "0";
const exportConfigOverride = readExportConfigOverride();
const themes = selectedThemeId
? allThemes.filter((theme) => theme.id === selectedThemeId)
@@ -297,13 +698,22 @@ const port = await reservePort();
const origin = `http://127.0.0.1:${port}`;
const pdfGenerator = createPdfGenerator({ renderOrigin: origin });
const docxMediaAdapter = createDocxMediaEngine({ renderOrigin: origin });
const captureDocxMedia = docxMediaAdapter.capture.bind(docxMediaAdapter);
let capturedDocxDocumentLayout;
docxMediaAdapter.capture = async (...args) => {
const output = await captureDocxMedia(...args);
capturedDocxDocumentLayout = structuredClone(
output.plan.documentLayout ?? { tables: [] }
);
return output;
};
const app = buildApp({
logger: { level: "error" },
pdfGenerator,
docxMediaAdapter,
fontPacks: {
roots: [fontPackRoot],
appVersion: "0.6.0"
appVersion
}
});
registerWebRuntime(app);
@@ -321,10 +731,15 @@ try {
);
for (const theme of themes) {
capturedDocxDocumentLayout = undefined;
process.stderr.write(`[DOCX R4 matrix] generating ${theme.id}\n`);
const samplePath = path.join(samplesDirectory, `${theme.id}.md`);
const samplePath = externalMarkdownPath
? path.resolve(repositoryDirectory, externalMarkdownPath)
: path.join(samplesDirectory, `${theme.id}.md`);
assert(fs.existsSync(samplePath), `主题缺少验收示例:${theme.id}`);
const markdown = fs.readFileSync(samplePath, "utf8");
const sourceMarkdown = fs.readFileSync(samplePath, "utf8");
const markdown = `${sourceMarkdown.trimEnd()}${appendInlineCodeProbes ? inlineCodeContinuityMarkdown : ""}`;
const sourceMetadata = matter(sourceMarkdown).data;
const exportConfig = mergeExportConfig({
...defaultExportConfig,
name: `${theme.name} R4 生产链验收`,
@@ -349,11 +764,21 @@ try {
}, exportConfigOverride);
const payload = {
markdown,
fileName: `${theme.id}.md`,
fileName: path.basename(samplePath),
language: "zh-CN",
resources: [],
exportConfig
};
const rendered = await requestJson(origin, "/api/render", payload);
const htmlBreakSemantics = appendInlineCodeProbes
? inspectHtmlBreakSemantics(rendered.articleHtml)
: undefined;
if (appendInlineCodeProbes) {
assert(
htmlBreakSemantics.passed,
`主题 ${theme.id} 的 Chromium br 语义门禁失败:${JSON.stringify(htmlBreakSemantics)}`
);
}
const pdf = await requestArtifact(
origin,
"/api/pdf",
@@ -380,6 +805,16 @@ try {
inspection.requiredStylesPresent,
`主题 ${theme.id} 缺少标准 Word 样式`
);
if (appendInlineCodeProbes) {
assert(
inspection.inlineCodeContinuityPassed,
`主题 ${theme.id} 的行内代码连续性门禁失败:${JSON.stringify(inspection.inlineCodeContinuity)}`
);
assert(
inspection.tableBreakSemantics.passed,
`主题 ${theme.id} 的表格 br 换行门禁失败:${JSON.stringify(inspection.tableBreakSemantics)}`
);
}
const standard = standardByTheme.get(theme.id);
assert(standard, `标准矩阵缺少主题 ${theme.id}`);
assert(
@@ -395,8 +830,8 @@ try {
);
}
const expectsOfficialDualEndedRow = Boolean(
standard.metadata?.document?.profile === "official" &&
standard.metadata.document.signatory
sourceMetadata.document?.profile === "official" &&
sourceMetadata.document.signatory
);
if (expectsOfficialDualEndedRow) {
assert(
@@ -416,9 +851,12 @@ try {
}
const pdfPath = path.join(pdfDirectory, `${theme.id}.pdf`);
const docxPath = path.join(docxDirectory, `${theme.id}.docx`);
const preferredDocxPath = path.join(docxDirectory, `${theme.id}.docx`);
fs.writeFileSync(pdfPath, pdf.content);
fs.writeFileSync(docxPath, docx.content);
const docxPath = writeArtifactWithBusyFallback(
preferredDocxPath,
docx.content
);
results.push({
id: theme.id,
name: theme.name,
@@ -426,6 +864,7 @@ try {
compatibleProfiles: theme.compatibleProfiles,
sample: path.relative(repositoryDirectory, samplePath),
exportConfig,
htmlBreakSemantics,
pdf: {
outputFile: path.relative(repositoryDirectory, pdfPath),
bytes: pdf.content.byteLength,
@@ -435,6 +874,7 @@ try {
docx: {
outputFile: path.relative(repositoryDirectory, docxPath),
inspection,
documentLayout: capturedDocxDocumentLayout ?? { tables: [] },
diagnostics: responseDiagnostics(docx.response, "docx")
},
standardInspection: standard.inspection,
@@ -448,12 +888,6 @@ try {
const enhancedThemes = results.filter(
(result) => result.docx.inspection.optionalFontPackApplied
);
if (themes.some((theme) => theme.id === "gov-red-standard")) {
assert(
enhancedThemes.some((result) => result.id === "gov-red-standard"),
"有效字体包未增强 gov-red-standard"
);
}
for (const result of results) {
assert(
result.pdf.diagnostics.pageCount > 0,
+27 -2
View File
@@ -486,7 +486,16 @@ export function buildApp(options: BuildAppOptions = {}) {
});
}
request.log.error({ error }, "PDF generation failed");
request.log.error(
{
error,
errorMessage:
error instanceof Error ? error.message : String(error),
errorStack:
error instanceof Error ? error.stack : undefined
},
"PDF generation failed"
);
return reply.code(500).send({
error: "PDF_GENERATION_FAILED",
message: "PDF 生成失败"
@@ -559,7 +568,23 @@ export function buildApp(options: BuildAppOptions = {}) {
if (error instanceof DocxExportServiceError) {
if (error.statusCode >= 500) {
request.log.error(
{ error, cause: error.cause },
{
error,
cause: error.cause,
causeMessage:
error.cause instanceof Error
? error.cause.message
: undefined,
causeStack:
error.cause instanceof Error
? error.cause.stack
: undefined,
causeDiagnostics:
error.cause instanceof Error &&
"diagnostics" in error.cause
? error.cause.diagnostics
: undefined
},
"DOCX generation service failed"
);
}
+1 -1
View File
@@ -10,7 +10,7 @@ const app = buildApp({
? {
fontPacks: {
roots: [fontPackRoot],
appVersion: process.env.APP_VERSION ?? "0.6.0"
appVersion: process.env.APP_VERSION ?? "0.6.2"
}
}
: {})
+1 -1
View File
@@ -194,7 +194,7 @@ export function readPdfEngineRuntimeLimits(
timeoutMs: readIntegerEnvironment(
environment,
"PDF_TIMEOUT_MS",
60_000,
180_000,
1_000
)
};
@@ -38,6 +38,7 @@ export async function captureDocxMediaWithPlaywrightPage(
signal?: AbortSignal
) {
signal?.throwIfAborted();
await page.emulateMedia({ media: "print" });
const plan = await renderCapturePlan(page, request);
signal?.throwIfAborted();
const session = await page.context().newCDPSession(page);
+8 -3
View File
@@ -14,7 +14,7 @@ function readProjectFile(relativePath: string) {
return readFileSync(`${projectRoot}/${relativePath}`, "utf8");
}
describe("Docker v0.6.0 部署契约", () => {
describe("Docker v0.6.2 部署契约", () => {
it("完整构建并复制 DOCX 运行时工作区和 Lua Filter", () => {
const dockerfile = readProjectFile("deploy/Dockerfile");
const workspaces = [
@@ -57,6 +57,9 @@ describe("Docker v0.6.0 部署契约", () => {
expect(dockerfile).toContain(
"COPY --from=pandoc-runtime /opt/pandoc/3.9.0.2 /opt/pandoc/3.9.0.2"
);
expect(dockerfile).toContain(
"ln -sfn /opt/pandoc/3.9.0.2/bin/pandoc /usr/local/bin/pandoc"
);
expect(dockerfile).toContain(
'test "$(pandoc --version | head -n 1)" = "pandoc 3.9.0.2"'
);
@@ -65,7 +68,8 @@ describe("Docker v0.6.0 部署契约", () => {
it("将受校验字体包内置到镜像并取消运行时字体挂载", () => {
const dockerfile = readProjectFile("deploy/Dockerfile");
const compose = readProjectFile("deploy/compose.yaml");
const dockerignore = readProjectFile(
const dockerignore = readProjectFile(".dockerignore");
const dockerfileIgnore = readProjectFile(
"deploy/Dockerfile.dockerignore"
);
@@ -78,10 +82,11 @@ describe("Docker v0.6.0 部署契约", () => {
);
expect(dockerfile).toContain("FONT_PACK_ROOT=/app/.local/font-packs");
expect(compose).toContain(
'APP_VERSION: "${MD_TO_PDF_APP_VERSION:-0.6.0}"'
'APP_VERSION: "${MD_TO_PDF_APP_VERSION:-0.6.2}"'
);
expect(dockerignore.split(/\r?\n/u)).toContain(".local");
expect(dockerignore).toContain("!output/font-packs/root/**");
expect(dockerfileIgnore).toBe(dockerignore);
});
it("提供显式真实 Docker 字体包验收命令", () => {
@@ -46,6 +46,7 @@ describe("Server DOCX 媒体浏览器", () => {
const detach = vi.fn(async () => undefined);
const page = {
goto: vi.fn(async () => undefined),
emulateMedia: vi.fn(async () => undefined),
waitForFunction: vi.fn(async () => undefined),
evaluate: vi.fn(async () => emptyPlan),
context: () => context
@@ -104,6 +105,7 @@ describe("Server DOCX 媒体浏览器", () => {
"http://renderer.test/preview-frame.html?target=continuous",
{ waitUntil: "domcontentloaded" }
);
expect(page.emulateMedia).toHaveBeenCalledWith({ media: "print" });
expect(continueRoute).toHaveBeenCalledTimes(1);
expect(abortRoute).toHaveBeenCalledWith("blockedbyclient");
expect(detach).toHaveBeenCalledOnce();
+1 -1
View File
@@ -137,7 +137,7 @@ describe("PDF 运行参数", () => {
expect(readPdfEngineRuntimeLimits({})).toEqual({
concurrency: 2,
maxQueue: 8,
timeoutMs: 60_000
timeoutMs: 180_000
});
});
@@ -62,6 +62,7 @@ describe("Playwright DOCX 媒体捕获", () => {
data: Buffer.from("png").toString("base64")
}));
const page = {
emulateMedia: vi.fn(async () => undefined),
evaluate: vi.fn(async () => plan),
context: () => ({
newCDPSession: vi.fn(async () => ({ send, detach }))
@@ -74,6 +75,7 @@ describe("Playwright DOCX 媒体捕获", () => {
);
expect(result.plan).toBe(plan);
expect(page.emulateMedia).toHaveBeenCalledWith({ media: "print" });
expect(result.captures[0]).toEqual({
id: "docx-media-1",
png: Buffer.from("png")
+2 -2
View File
@@ -3,13 +3,13 @@ import {
isPagedPreviewRenderRequest,
PAGED_PREVIEW_MESSAGE_SCOPE,
PagedDocumentRuntime,
SHARED_HIGHLIGHT_CSS,
renderDocxMediaCapturePlan,
resolveMermaidOutputMode,
resolvePagedRenderTarget,
type PagedPreviewFrameMessage,
type PagedPreviewPayload
} from "@md-to-pdf/preview-engine";
import highlightCss from "highlight.js/styles/github.css?inline";
import katexCss from "katex/dist/katex.min.css?inline";
import {
beginPreviewFrameRender,
@@ -29,7 +29,7 @@ const previewRoot = getPreviewRoot();
const renderTarget = resolvePagedRenderTarget(window.location.search);
const mermaidOutput = resolveMermaidOutputMode(window.location.search);
const runtime = new PagedDocumentRuntime(previewRoot, {
highlightCss,
highlightCss: SHARED_HIGHLIGHT_CSS,
katexCss,
echartsCss
});
+2 -2
View File
@@ -10,8 +10,8 @@ const webRoot = fileURLToPath(new URL("../", import.meta.url));
describe("应用版本", () => {
it("从统一构建版本生成标题徽标", () => {
expect(APP_VERSION).toBe("0.6.0");
expect(APP_VERSION_LABEL).toBe("v0.6.0");
expect(APP_VERSION).toBe("0.6.2");
expect(APP_VERSION_LABEL).toBe("v0.6.2");
});
it("允许浏览器加载同源 Web Manifest", () => {
+3 -1
View File
@@ -110,7 +110,7 @@ RUN if [ "${REUSE_PLAYWRIGHT_RUNTIME}" = "1" ]; then \
fi
COPY --from=pandoc-runtime /opt/pandoc/3.9.0.2 /opt/pandoc/3.9.0.2
RUN ln -s /opt/pandoc/3.9.0.2/bin/pandoc /usr/local/bin/pandoc \
RUN ln -sfn /opt/pandoc/3.9.0.2/bin/pandoc /usr/local/bin/pandoc \
&& test "$(pandoc --version | head -n 1)" = "pandoc 3.9.0.2"
WORKDIR /app
@@ -166,6 +166,8 @@ ENV HOST=127.0.0.1 \
FONT_PACK_ROOT=/app/.local/font-packs
RUN test -f /app/.local/font-packs/mdtp-serif-sc/1.0.0/font-pack.json
RUN test -f /app/.local/font-packs/mdtp-sans-sc/1.0.0/font-pack.json
RUN test -f /app/.local/font-packs/mdtp-mono/1.0.0/font-pack.json
USER pwuser
+13 -15
View File
@@ -3,7 +3,7 @@
本目录提供单容器部署:Nginx 在 `8080` 端口提供前端,并将
`/api/` 代理到同容器内的 Fastify;Fastify 复用固定版本
Playwright Chromium 生成 PDF,并使用固定 Pandoc `3.9.0.2` 生成
可编辑 DOCX。可选字体包只从宿主机只读挂载,不进入应用镜像
可编辑 DOCX。发行镜像固定内置受校验字体包,不依赖宿主机字体挂载
## 启动
@@ -38,7 +38,7 @@ docker compose -f deploy\compose.yaml down
可以直接指定版本化镜像名称构建:
```powershell
$env:MD_TO_PDF_IMAGE = 'yixiong/md-to-pdf:v0.6.0'
$env:MD_TO_PDF_IMAGE = 'yixiong/md-to-pdf:v0.6.2'
docker compose -f deploy\compose.yaml build
```
@@ -48,13 +48,13 @@ docker compose -f deploy\compose.yaml build
已经保留同版本系列的已验证镜像,可在内网或软件源较慢时复用其运行层:
```powershell
$env:MD_TO_PDF_IMAGE = 'yixiong/md-to-pdf:v0.6.0'
$env:MD_TO_PDF_RUNTIME_BASE_IMAGE = 'yixiong/md-to-pdf:v0.4.5'
$env:MD_TO_PDF_IMAGE = 'yixiong/md-to-pdf:v0.6.2'
$env:MD_TO_PDF_RUNTIME_BASE_IMAGE = 'yixiong/md-to-pdf:v0.6.0'
$env:MD_TO_PDF_REUSE_PLAYWRIGHT_RUNTIME = '1'
docker compose -f deploy\compose.yaml build
```
复用模式会校验基础镜像中存在 Chromium,再覆盖 v0.6.0 应用代码和生产
复用模式会校验基础镜像中存在 Chromium,再覆盖 v0.6.2 应用代码和生产
依赖。正式跨机器构建仍建议使用默认完整路径。
## 主题挂载
@@ -76,7 +76,7 @@ docker compose -f deploy\compose.yaml up -d
## 内置字体包
v0.6.0 的发行镜像固定将受校验字体包内置到
v0.6.2 的发行镜像固定将受校验字体包内置到
`/app/.local/font-packs`。Compose 不挂载宿主机字体目录,因此部署端
无需额外复制字体,Preview、PDF 和 DOCX 可以离线使用同一套原生资产。
@@ -84,11 +84,9 @@ v0.6.0 的发行镜像固定将受校验字体包内置到
```text
<字体包根目录>/
── mdtp-serif-sc/
└── 1.0.0/
├── font-pack.json
├── LICENSE.txt
└── fonts/
── mdtp-serif-sc/1.0.0/
├── mdtp-sans-sc/1.0.0/
└── mdtp-mono/1.0.0/
```
从源码构建镜像前,先从仓库外冻结源生成字体包,再构建镜像:
@@ -98,15 +96,15 @@ npm run build:font-pack
docker compose -f deploy\compose.yaml up -d
```
字体二进制仍不提交到 Git。Docker 构建上下文只放行本次生成的
`output/font-packs/root`,镜像构建会在缺少清单时立即失败。运行时注册器
字体二进制通过 Git LFS 固定在仓库中。Docker 构建上下文只放行构建所需
源码与本次生成的 `output/font-packs/root`,镜像构建会在缺少清单时立即失败。运行时注册器
仍会复核许可证、文件类型、路径边界、容量和 SHA-256。更换字体包内容时
必须提升字体包 SemVer 并重新构建镜像,不能覆盖同一个版本。
发布前执行真实内置字体门禁:
```powershell
$env:MD_TO_PDF_IMAGE = 'yixiong/md-to-pdf:v0.6.0'
$env:MD_TO_PDF_IMAGE = 'yixiong/md-to-pdf:v0.6.2'
npm run verify:docker-font-pack
```
@@ -124,7 +122,7 @@ npm run verify:docker-font-pack
| `MD_TO_PDF_REUSE_PLAYWRIGHT_RUNTIME` | `0` | 是否复用基础镜像中的 Chromium |
| `MD_TO_PDF_PORT` | `8080` | 宿主机监听端口 |
| `MD_TO_PDF_THEME_DIR` | `../.local/themes` | 宿主机主题目录 |
| `MD_TO_PDF_APP_VERSION` | `0.6.0` | 字体包兼容性判断使用的应用版本 |
| `MD_TO_PDF_APP_VERSION` | `0.6.2` | 字体包兼容性判断使用的应用版本 |
| `PDF_CONCURRENCY` | `1` | 同时执行的 PDF 任务数 |
| `PDF_MAX_QUEUE` | `4` | 等待队列上限 |
| `PDF_TIMEOUT_MS` | `120000` | 单次 PDF 生成超时 |
+1 -1
View File
@@ -19,7 +19,7 @@ services:
PDF_CONCURRENCY: "${PDF_CONCURRENCY:-1}"
PDF_MAX_QUEUE: "${PDF_MAX_QUEUE:-4}"
PDF_TIMEOUT_MS: "${PDF_TIMEOUT_MS:-120000}"
APP_VERSION: "${MD_TO_PDF_APP_VERSION:-0.6.0}"
APP_VERSION: "${MD_TO_PDF_APP_VERSION:-0.6.2}"
FONT_PACK_ROOT: /app/.local/font-packs
volumes:
- "${MD_TO_PDF_THEME_DIR:-../.local/themes}:/app/.local/themes:ro"
+54 -30
View File
@@ -15,12 +15,11 @@ const image = process.env.MD_TO_PDF_IMAGE?.trim() || "md-to-pdf:local";
const fontPackRoot = path.resolve(
path.join(repositoryRoot, "output", "font-packs", "root")
);
const manifestPath = path.join(
fontPackRoot,
"mdtp-serif-sc",
"1.0.0",
"font-pack.json"
);
const expectedFontPacks = [
{ id: "mdtp-serif-sc", version: "1.0.0" },
{ id: "mdtp-sans-sc", version: "1.0.0" },
{ id: "mdtp-mono", version: "1.0.0" }
];
function assert(condition, message) {
if (!condition) {
@@ -103,7 +102,7 @@ async function exportDocx(origin) {
headers: { "content-type": "application/json" },
body: JSON.stringify({
markdown:
"---\ntitle: Docker 内置字体包验收\n---\n\n# Docker 内置字体包验收\n\n这是一段可编辑中文正文。",
"---\ntitle: Docker 内置字体包验收\n---\n\n# Docker 内置字体包验收\n\n这是一段包含行内代码 `const value = 1` 的可编辑中文正文,代码前后不得换行。",
fileName: "Docker 内置字体包验收.md",
language: "zh-CN",
resources: [],
@@ -126,7 +125,14 @@ assert(
fontPackStatus?.isDirectory(),
`未找到有效字体包根目录:${fontPackRoot}。请先运行 npm run build:font-pack。`
);
const localManifest = await readFile(manifestPath);
const localManifests = await Promise.all(
expectedFontPacks.map(async (pack) => ({
...pack,
content: await readFile(
path.join(fontPackRoot, pack.id, pack.version, "font-pack.json")
)
}))
);
const buildEnvironment = {
...process.env,
MD_TO_PDF_IMAGE: image
@@ -137,22 +143,26 @@ run(
{ env: buildEnvironment }
);
run("docker", ["image", "inspect", image], { capture: true });
const embeddedManifestHash = run(
"docker",
[
"run",
"--rm",
"--entrypoint",
"sha256sum",
image,
"/app/.local/font-packs/mdtp-serif-sc/1.0.0/font-pack.json"
],
{ capture: true }
).split(/\s+/u)[0];
assert(
embeddedManifestHash === sha256(localManifest),
"镜像内字体包清单与本次受校验构建产物不一致"
);
const embeddedManifestHashes = {};
for (const manifest of localManifests) {
const embeddedHash = run(
"docker",
[
"run",
"--rm",
"--entrypoint",
"sha256sum",
image,
`/app/.local/font-packs/${manifest.id}/${manifest.version}/font-pack.json`
],
{ capture: true }
).split(/\s+/u)[0];
assert(
embeddedHash === sha256(manifest.content),
`镜像内字体包清单不一致:${manifest.id}@${manifest.version}`
);
embeddedManifestHashes[`${manifest.id}@${manifest.version}`] = embeddedHash;
}
const port = await freePort();
const projectName = `morphdoc-font-pack-${randomUUID().slice(0, 8)}`;
@@ -165,7 +175,7 @@ const environment = {
...buildEnvironment,
MD_TO_PDF_PORT: String(port),
MD_TO_PDF_THEME_DIR: themeRoot,
MD_TO_PDF_APP_VERSION: "0.6.0"
MD_TO_PDF_APP_VERSION: "0.6.2"
};
const compose = [
"compose",
@@ -205,10 +215,13 @@ try {
);
const css = await cssResponse.text();
assert(cssResponse.ok, "主题 CSS 请求失败");
assert(
css.includes("md-to-pdf-font-packs:mdtp-serif-sc@1.0.0"),
"Docker 内置字体包未进入主题 CSS"
);
assert(css.includes("md-to-pdf-font-packs:"), "主题 CSS 缺少字体包标记");
for (const pack of expectedFontPacks) {
assert(
css.includes(`${pack.id}@${pack.version}`),
`Docker 内置字体包未进入主题 CSS:${pack.id}@${pack.version}`
);
}
const assetPath = css.match(
/(\/api\/font-packs\/[^"'()\s]+\/web\?v=[a-f0-9]+)/u
)?.[1];
@@ -232,12 +245,23 @@ try {
assert(fontParts.length >= 2, `DOCX 字体部件不足:${fontParts.length}`);
const fontTable = strFromU8(entries["word/fontTable.xml"]);
assert(fontTable.includes("MdTP Serif SC"), "字体包未进入 DOCX 字体表");
assert(fontTable.includes("MdTP Mono"), "等宽字体包未进入 DOCX 字体表");
const documentXml = strFromU8(entries["word/document.xml"]);
const inlineCodeParagraph = documentXml
.match(/<w:p(?:\s[^>]*)?>[\s\S]*?<\/w:p>/gu)
?.find(
(paragraph) =>
paragraph.includes("这是一段包含行内代码") &&
paragraph.includes("const value = 1") &&
paragraph.includes("代码前后不得换行")
);
assert(inlineCodeParagraph, "行内代码及前后正文未保持在同一 Word 段落");
process.stdout.write(
`${JSON.stringify(
{
image,
embeddedManifestSha256: embeddedManifestHash,
embeddedManifestSha256: embeddedManifestHashes,
runtimeFontPackMount: false,
fontPackAvailable: true,
pandocVersion: capability.detectedVersion,
+2 -2
View File
@@ -137,8 +137,8 @@ Pandoc 采用 GNU GPL version 2 or later。项目将其作为未修改的独立
项目发布资产计划至少包含:
```text
MorphDoc-0.6.0-x86_64-Setup.exe
MorphDoc-0.6.0-x86_64.zip
MorphDoc-0.6.1-x86_64-Setup.exe
MorphDoc-0.6.1-x86_64.zip
pandoc-3.9.0.2-source.tar.gz
THIRD_PARTY_NOTICES.md
```
+104 -5
View File
@@ -1,6 +1,24 @@
# 墨呈进度
最后更新:2026-08-02
最后更新:2026-08-26
## v0.6.2 DOCX 发布门禁结论
- 原整批 560 已停用,有效门禁由四套相互独立的 140 组成,未启动、恢复或重建旧 560 流程。
- 标准合成基线和长庆真实文档严格执行完成,合计 `280/280`。M4N 复杂表格文档完成 `140/140`;其后仅比较器文本映射变更,应用渲染与 DOCX 字节链未改变。
- 健康数据长文档在 115 个已完成样本中,通用文本修复将残余门禁误报收敛为 6 个,比例 `5.22%`,基础设施错误为 0。
- 定向重跑 `formal-report--portrait--binding``formal-report--portrait--standard``red-briefing--portrait--standard``tender-business-blue--portrait--wide`,均只剩 `ELEMENT_COLOR_MISMATCH`;裁剪图证明比较器将重复表头“方案1”与正文“方案三”错配,属自动对齐/取样误报,不是 DOCX 渲染缺陷。
- 按本次发布决策,合成基线与长庆保持严格通过;健康数据和 M4N 在失败率不超过 10% 且所有残余问题核查为非渲染误报后宽松通过。四套发布门禁已结案,进入 v0.6.2 统一验证与发行构建。
- v0.6.2 全项目测试、类型检查、生产构建及 `git diff --check` 已通过。
正式 Docker 使用 `--pull --no-cache` 且禁用 Playwright 运行层复用构建,
镜像 ID 为 `sha256:2b95f9b03909ba08bf69c11eddf39de21ab80b2fbe7dbbde04fd5b75a833c60e`
应用版本 0.6.2、Pandoc 3.9.0.2、固定 Chromium 和三套内置字体均已核验。
- `release/v0.6.2` 已完成原子归集:Desktop 安装器 185,584,274 字节,
SHA-256 为 `674096406d21a00e4e129c24562cdb14587ebed22d8a2723f656c75a2464dce1`
免安装 ZIP 242,520,291 字节,SHA-256 为
`d5592fa139d9c90079befb7021d8eb4899aef13678891d693f8c4e0ed4779d3b`Docker 离线镜像
770,448,703 字节,SHA-256 为
`9b2ae88335c88a0bd7e2da987728ff603236bf0f7d9d8fc0da114ad39b37726a`
## 1. 当前概况
@@ -68,6 +86,22 @@ Python;左侧编辑器计划升级为 CodeMirror 6,并通过项目自有命
支持基础工具栏及后续 Front Matter、ECharts YAML 工具扩展。完整设计与
验收门禁见 [v0.6.0 设计文档](V0.6.0_DESIGN.md)。
`v0.6.1` 已冻结为 DOCX 视觉一致性和发布门禁修复版本。发布后复核确认
`v0.6.0` 的 14 主题矩阵主要验证 CSS 槽位、OOXML 结构和样式存在性,
六个视觉场景只覆盖五套主题;视觉报告中的像素、墨迹 IoU 和边缘 IoU
failure 还被外层白名单过滤,导致报告失败时总门禁仍可通过。`v0.6.1`
要求四套独立封面执行 Chromium、Word、WPS 整页硬门禁,正文改为跨页
无关的语义块局部视觉门禁,并对字体真实解析、嵌入和发布后三条生产链路
建立阻断验收。完整范围见 [v0.6.1 设计文档](V0.6.1_DESIGN.md)。
`v0.6.1` 最终视觉矩阵已完成:14 套内置主题 × 纵横两个方向 × 主题默认、
标准、紧凑、宽松、非对称装订五组页边距共 140 个场景全部通过,阻断失败
和诊断失败均为 0。修复集中在通用 CSS 到 OOXML 翻译、字体嵌入、中文
标点字距、精确行距、打印媒体和行内代码连续性,没有引入按主题 ID 分支。
同一 `red-briefing` 已通过源码服务、Docker Web API 和实际安装 Desktop
三条生产链,三份 DOCX 均约 24.17 MB、包含 5 个字体部件;安装版产物经
真实 Word/WPS 渲染均为 2 页 A4,封面和正文逐页复核通过。
`v0.6.0` 阶段 2 已完成 Pandoc 运行时探查:固定 `3.9.0.2`,官方
Windows ZIP 和 Linux amd64 tarball 的 SHA-256 均已核验;Linux 静态程序
可直接运行于 `node:22-bookworm-slim`。Desktop 安装包预计增加约
@@ -1460,9 +1494,9 @@ ECharts 第二阶段浏览器与 PDF 验证结果:
Server HTTP、Chromium PDF 与生产 DOCX 矩阵;
- 阶段 12D-R4 媒体专项:已完成稳定 PNG 布局、严格 DrawingML、五媒体
Word/WPS 编辑互存、隔离逐页视觉比较和题注居中门禁;
- 阶段 12D-R4 布局视觉矩阵:已完成六组主题、纸张、方向与页边距组合的
Chromium/Word/WPS 元素级严格门禁,并重跑 14 套主题生产矩阵;分页流
差异保留为诊断项,不再误判为样式失败
- 阶段 12D-R4 布局视觉矩阵:曾记录为完成六组 Chromium/Word/WPS
元素级严格门禁;`v0.6.1` 发布后复核确认该矩阵只覆盖五套主题,且外层
过滤了像素、墨迹 IoU 和边缘 IoU failure,因此不构成有效严格门禁
- 发布收口阶段 A:已完成 v0.6.0 版本、墨呈/MorphDoc 品牌、NSIS 名称、
文件关联兼容和旧版用户数据迁移;
- 发布收口阶段 B:已完成 Desktop 固定 Pandoc 最小运行时的清单、下载
@@ -1473,10 +1507,75 @@ ECharts 第二阶段浏览器与 PDF 验证结果:
镜像封包、部署 ZIP、清单与校验和规则,并将正式字体纳入 Git LFS;
- 阶段 13:完成 Word/WPS 双向互存、外部主题兼容、体积和正式发布验收。
### 阶段二:v0.6.1 DOCX 视觉一致性修复
- 阶段 A:已完成。修复所有 failure 的发布阻断聚合,补齐四套独立封面
场景,并禁止正文回流封面页;四套封面真实 Word/WPS 复验均已被现有
视觉差异正确阻断;
- 阶段 B:已完成。正文段落按内容顺序映射为与物理页号无关的视觉块,
保留实际行坐标和 PDF.js 字体分类诊断,逐行裁剪局部栅格并输出叠加图、
热力图;正文整页栅格降为诊断,封面仍为整页硬门禁。单元测试 47 项、
全项目类型检查和生产构建通过;真实 GitHub 紧凑 Letter 纵向场景观测
22 个正文块、输出 102 张失败局部热力图并正确阻断。PDF.js 泛化字体名
不作为字体硬门禁,真实字体解析留在阶段 D;
- 阶段 C:已完成。矩阵生成器从 14 套内置主题清单生成纵横两个方向 ×
主题默认、标准、紧凑、宽松、非对称装订五组页边距,共 140 个唯一
排版场景;主题默认链路保持 `marginMode: theme`,即使与显式数值相同
也不去重。机器报告记录定义、执行完整度以及主题、方向、边距分层汇总,
HTML 报告固定显示 14 行 × 10 列并链接三份引擎详细报告。5 项矩阵结构
测试通过;真实 `red-briefing--portrait--theme-default` 验证解析为主题
默认 25mm 四边距,14 个正文块中 Word 有 13 个失败、字体部件仍为 0,
总计 76 个阻断问题;`formal-feasibility--landscape--binding` 验证 A4
横向、非对称装订边距和独立封面整页 strict 门禁,封面因墨迹与边缘
IoU 不达标被阻断;全仓库测试、类型检查、生产构建和差异检查通过;
- 阶段 D:已完成。D1/D2 将字体门禁扩展到 PDF 实际字体解析、DOCX
声明与嵌入数量,以及 Word/WPS 是否真正采用 Office 嵌入别名;新增
`mdtp-sans-sc@1.0.0``mdtp-mono@1.0.0`,与
`mdtp-serif-sc@1.0.0` 组成确定性三字体包。D3 重建四套独立封面整页
门禁,40 个封面方向/边距组合全部通过。D4 完成正文语义块视觉收口:
通用翻译层统一处理字体、中文标点字距、精确行距、段落、列表、表格、
引用、代码块和行内代码连续性;最终 140 个 Chromium、Word、WPS
真实场景全部执行并通过,阻断失败和诊断失败均为 0;
- 阶段 E:已完成。源码服务、正式 Docker Web API 和实际安装 Desktop
使用同一 `red-briefing` 复验,三份 DOCX 均约 24.17 MB、包含 5 个字体
部件和 40,731,432 字节嵌入字体;安装版产物经 Word/WPS 渲染均为 2 页
A4,逐页视觉通过;
- 阶段 F:已完成最终发行重构。Desktop 的 Serif、Sans、Mono 三套字体包
直接内置于安装器和免安装 ZIP,NSIS 不再显示字体选择页,正式发行不再
拆分字体安装器或字体 ZIP。正式安装包为 185,578,422 字节,免安装 ZIP
为 242,513,118 字节;安装版默认媒体示例真实导出 10,931,974 字节的
DOCX,包含 7 个字体部件和 12 个媒体部件。Docker 使用 `--pull`
`--no-cache` 且禁用运行层复用的完整路径正式构建,镜像内容 ID 为
`sha256:f81dbce5857a0a5496851e149936d1fd7da52b76c035187be1079fa1fd5a4717`
`release/v0.6.1` 只归集 Desktop 安装器与 ZIP、Docker 离线镜像、Web
部署包和发行说明五个主附件,并生成机器清单与包含六项的 SHA-256 校验
文件;发布引用由本轮 `release:` 提交和重新创建的 annotated `v0.6.1`
标签统一指向最终发行状态。
### 阶段三:v0.6.2 DOCX 真实文档门禁修复
- 发布门禁已由一次性 560 拆分为四套独立 140:标准合成基线优先,随后
`tmp` 三份真实 Markdown 的文件字节数升序逐套执行。每套独立保存
`progress.json``aggregate.json` 和 HTML 报告;旧 560 后台流程停用;
- 标准合成基线已加入表格内 `<br>``<br/>``<br />` 与行内代码
`` `<br>` `` 的隔离语义检查,并覆盖全 JSON 围栏缩进、行内代码连续性、
表格末行对齐和代码高亮翻译;
- 通用表格翻译链现会在分页前冻结完整自动布局列轨,将稳定元数据复制到
Paged.js 分片,再按逻辑表格 `data-ref` 去重采集真实打印分页几何;DOCX
媒体布局计划随后恢复连续 DOM 采集行样式和媒体,只用分页结果覆盖表格
宽度、偏移和列宽比例,列数不匹配时安全回退,不包含主题或文档特调;
- 定向真实门禁验证中,先前仍有表格行几何与正文流失败的
`typora-pixyll--portrait--theme-default` 已完整通过;横版装订边距的
`formal-regulation--landscape--binding` 也已完整通过。Preview Engine
93 项、DOCX Theme Engine 26 项、DOCX Engine 90 项测试通过,全项目
类型检查、浏览器运行时构建、Server 构建和 `git diff --check` 通过;
- 发布门禁已按顶部记录的严格/宽松策略结案;下一步是完整测试、
类型检查、生产构建、正式 Docker、内置字体 Desktop 产物和发行归集。
每个阶段验收通过后创建一个独立提交,再进入下一阶段。当前阶段不得混入
后续阶段的功能实现。
### 阶段:后续版本扩展
### 阶段:后续版本扩展
- Front Matter 可视化编辑工具;
- ECharts YAML 可视化配置工具;
+3 -4
View File
@@ -24,8 +24,6 @@ release/v<semver>/
release/v<version>/
├── MorphDoc-<version>-x86_64-Setup.exe
├── MorphDoc-<version>-x86_64.zip
├── MorphDoc-FontPack-<id>-<font-version>-x86_64-Setup.exe
├── MorphDoc-FontPack-<id>-<font-version>.zip
├── MorphDoc-Web-<version>-image.tar.gz
├── MorphDoc-Web-<version>-deploy.zip
├── manifest.json
@@ -54,7 +52,7 @@ TTF、OTF、WOFF 和 WOFF2 必须由 Git LFS 跟踪。新增或升级字体前
## 发版步骤
1. 完成全量测试、类型检查、生产构建和视觉门禁。
2. 构建 Desktop 安装包便携 ZIP 和字体附件
2. 构建字体直接内置的 Desktop 安装包便携 ZIP。
3. 构建并验证版本化 Docker 镜像。
4. 准备 `docs/releases/v<version>.md`
5. 执行:
@@ -65,7 +63,8 @@ TTF、OTF、WOFF 和 WOFF2 必须由 Git LFS 跟踪。新增或升级字体前
6. 逐项核验 `SHA256SUMS.txt`,并从离线归档重新加载 Docker 镜像。
7. 创建内容完整的 `release:` 提交。
8. 创建 annotated `v<version>` 标签,标签说明与发布提交正文一致
8. 重新生成 release 清单,确保 `gitCommit` 指向该发布提交
9. 创建 annotated `v<version>` 标签,标签说明与发布提交正文一致。
安装器公开分发前应完成 Authenticode 签名;未签名产物只能按内部发布风险
规则分发。
+169
View File
@@ -0,0 +1,169 @@
# v0.6.1 DOCX 视觉一致性与发布门禁修复设计
状态:范围已冻结,阶段 A、B 已完成,等待阶段 C。
## 1. 版本目标
`v0.6.1` 是针对 `v0.6.0` DOCX 导出质量的维护版本,不新增与 DOCX
无关的产品功能。本版本必须完成:
1. 修复视觉报告失败但总发布门禁仍通过的判定漏洞;
2. 对四套独立封面主题建立 Chromium、Word、WPS 整页严格门禁;
3. 对 14 套内置主题建立与正文分页位置无关的语义块局部视觉门禁;
4. 修复主题样式、封面布局和字体解析、嵌入、回退问题;
5. 对源码服务、Docker Web API 和 Desktop 安装版执行发布后复验。
本版本不要求 Chromium、Word 和 WPS 的正文总页数或分页边界完全一致,
但不允许以排版引擎不同为理由放过封面、字体或语义块样式退化。
```mermaid
flowchart LR
A["同一 Markdown、主题与配置"] --> B["生产 Chromium PDF"]
A --> C["生产 DOCX"]
C --> D["Word 原生渲染"]
C --> E["WPS 原生渲染"]
B --> F["封面整页严格比较"]
D --> F
E --> F
B --> G["正文语义块匹配"]
D --> G
E --> G
F --> H["发布硬门禁"]
G --> H
```
## 2. 问题基线
`v0.6.0` 复核确认存在以下门禁缺口:
- 视觉报告中的像素差异、墨迹 IoU、边缘 IoU 等 failure 级问题未进入
外层发布阻断集合;
- 六个布局视觉场景只覆盖五套主题,未覆盖全部 14 套主题;
- 四套独立封面只覆盖其中两套;
- “独立封面”主要检查 OOXML 分节、页眉页脚、页码重启和字段存在,
不能代表视觉一致;
- 14 主题样式门禁主要检查 CSS 槽位、令牌和 Word 样式 ID 存在,不能
代表最终 Word/WPS 呈现一致;
- 字体名称写入 `fontTable.xml`、转换无 warning,均不能证明字体已经解析、
嵌入并被 Office 实际使用;
- `red-briefing` 在门禁、Docker Web 和用户实际产物中均为零嵌入字体,
仍以零 warning 通过。
## 3. 验收模型
### 3.1 封面整页硬门禁
适用于 `formal-feasibility``tender-business-blue``tender-blind`
`tender-classic`
- 封面必须独占一个物理页;
- 所有封面字段只能出现在封面页,正文不得回流到封面页;
- 封面页不显示正文页眉、页脚和页码;
- 字体、字号、字重、颜色、对齐、位置、边框、背景和装饰线必须匹配;
- 整页像素、墨迹 IoU、边缘 IoU 任一 failure 都阻断发布;
- Word 和 WPS 都必须通过,不允许只以二者相互接近替代 Chromium 基线。
### 3.2 正文语义块门禁
正文按稳定内容顺序和语义角色匹配,不按物理页号配对。允许语义块移动到
另一页,但块自身必须保持视觉一致:
| 语义块 | 必须比较 |
| --- | --- |
| 标题、正文 | 字体、字号、字重、颜色、行高、缩进、段距、对齐 |
| 引用、提示框 | 背景、边框、内边距、文字样式 |
| 代码块 | 等宽字体、字号、背景、边框、内边距、换行 |
| 列表 | 编号或项目符号、缩进、悬挂距离、文字样式 |
| 表格 | 总宽度、列宽、边框、底纹、单元格内边距、表头与文字样式 |
| 图片与图表 | 尺寸、宽高比、清晰度、对齐和题注 |
正文整页栅格差异只作为诊断,不作为最终样式结论;没有完成语义块局部比较
的主题不得标记为视觉通过。
### 3.3 字体硬门禁
- 记录 Chromium、DOCX 字体表、嵌入字体部件、Word PDF 和 WPS PDF 的
实际字体;
- 需要跨环境稳定的字体必须来自许可证允许分发的字体包;
- 应嵌入字体但字体部件为零时直接失败;
- 字体包未应用、字体回退或 Office 未使用目标字体时必须产生阻断错误;
- 字体名称存在但没有字体二进制不得视为通过。
### 3.4 发布链路门禁
同一验收输入必须覆盖:
1. 源码测试服务;
2. 正式 Docker Web API
3. 正式 Desktop 安装版。
任一底层报告状态为 `failed` 时,总门禁必须失败。只有已经证明属于合法
正文自然分页流动的问题才能降级为 warning;封面、字体、内容、语义块
样式和媒体失败不得降级。
### 3.5 全量配置矩阵
发布门禁不是少量代表用例,而是固定执行 `14 × 2 × 5 = 140` 个排版
场景:
- 14 套内置主题;
- 纵向、横向两个纸张方向;
- 主题默认、标准、紧凑、宽松和非对称装订五组页边距。
“主题默认”必须使用 `marginMode: theme`,验证主题清单中的真实默认值;
即使解析后的数值与某组显式边距相同,也不得去重。其余四组使用显式
`custom` 配置,验证配置覆盖链路。每个场景生成 Chromium PDF、DOCX、
Word PDF 和 WPS PDF,并在报告中保留主题、方向、边距场景、导出链和
渲染引擎维度,不允许用汇总绿灯掩盖单个失败。
场景 ID 固定为 `<theme-id>--<orientation>--<margin-scenario>`。总报告同时
输出机器可读 JSON 和 14 行 × 10 列 HTML 矩阵;每个单元格必须显示通过、
失败或未执行,并链接到 Chromium/Word、Chromium/WPS、Word/WPS 三份
详细报告。完整门禁必须执行 140 个场景后才允许标记 `complete: true`
单场景筛选只用于开发复现,不能冒充全矩阵结果。
## 4. 实施阶段
1. 阶段 A:修复 failure 聚合策略,补齐四套封面场景和封面正文隔离;
2. 阶段 B:建立跨页无关的语义块观察、匹配、局部栅格和样式比较协议;
3. 阶段 C:建立 14 主题、2 个方向、5 组页边距的 140 场景全量语义块
矩阵和版本化门限;
4. 阶段 D:逐主题修复封面、正文块样式和字体嵌入;
5. 阶段 E:完成源码、Docker Web、Desktop 安装版发布后复验;
6. 阶段 F:更新版本、发行说明、发布产物、发布提交和 annotated tag。
每个阶段必须先提交真实失败基线,再修复到通过。不得通过删除用例、提高
门限、过滤 failure 或把 failure 改名为 warning 取得绿灯。
## 5. v0.6.1 发布条件
- 14 套主题在纵向、横向和五组页边距下全部生成真实 Chromium PDF、
DOCX、Word PDF 和 WPS PDF
- 四套独立封面全部通过整页严格门禁;
- 14 套主题声明支持的语义块全部通过局部视觉门禁;
- 字体解析、嵌入和实际使用结果符合主题预期;
- 源码、Docker Web 和 Desktop 安装版结果一致;
- 单元测试、类型检查、生产构建和 `git diff --check` 通过;
- 发布报告中不存在被忽略的 failure;
- 完成真实 Word/WPS 逐页人工复核后才能发布。
## 6. 最终验收结果
2026-08-04 已完成全部发布条件:
- `14 × 2 × 5 = 140` 个场景全部实际执行,矩阵报告为
`complete: true``gatePassed: true`,阻断失败和诊断失败均为 0
- 四套独立封面共 40 个方向/边距组合全部通过 Chromium 对 Word/WPS
整页严格门禁;其余页面按跨页无关的语义块完成视觉比较;
- 通用 CSS 到 OOXML 翻译已覆盖字体、字距、精确行距、段落、列表、表格、
引用、代码和行内代码连续性,全程未增加按主题 ID 分支;
- Serif、Sans、Mono 三套可分发字体进入同一字体包解析链,140 个场景的
DOCX 均包含 5 个字体部件,Word/WPS 实际字体使用门禁通过;
- `red-briefing` 已分别从源码服务、正式 Docker Web API 和实际安装的
Desktop 导出;三份 DOCX 均约 24.17 MB、含 5 个字体部件,并通过真实
Word/WPS 渲染复核;
- 根级测试、类型检查、生产构建和空白检查通过;Docker 镜像、Desktop
安装器及免安装 ZIP 已生成。
最终矩阵报告位于 `output/docx-layout-visual-matrix/summary.json`,发行说明
见 [`docs/releases/v0.6.1.md`](releases/v0.6.1.md)。
+55
View File
@@ -0,0 +1,55 @@
# MorphDoc · 墨呈 v0.6.1
## 核心修复
- 重构通用 CSS 到 WordprocessingML 翻译链,统一处理字体族、东亚字体、
字号、行距、段距、缩进、边框、底纹、表格和代码样式,不包含按主题 ID
分支或单主题补丁。
- 新增可分发的 `mdtp-mono` 等宽字体,并将 Serif、Sans、Mono 三套字体包
同源用于 Chromium 预览/PDF 与 DOCX 静态嵌入;字体门禁同时检查字体表、
嵌入部件和 Word/WPS 的实际使用结果。
- 修复中文标点受 Word 自动字距调整造成的行宽漂移,并按浏览器实测值写入
Word 行距和网格参数。
- 修复 Markdown 行内代码在普通段落、列表和表格中被错误拆成独立段落的
问题;行内代码现在保持原段落连续排版,并进入视觉门禁。
- 统一 DOCX 媒体捕获的打印媒体环境,避免开发预览与正式 Web/Desktop
导出采用不同 CSS 分支。
## 视觉门禁
- 发布矩阵固定为 14 套内置主题 × 纵横两个方向 × 主题默认、标准、紧凑、
宽松、非对称装订五组页边距,共 140 个真实场景。
- 四套独立封面使用 Chromium 对 Word/WPS 的整页严格门禁;封面分页、
字体、位置、边框、背景、装饰和页码任一失败都会阻断发布。
- 正文按内容顺序和语义角色比较标题、段落、引用、列表、代码、表格及媒体,
允许流式分页造成物理页号变化,但不允许元素块视觉样式丢失。
- 外层聚合不再过滤像素、墨迹 IoU、边缘 IoU 或字体 failure;只有执行满
140 个场景且阻断失败为零时,报告才允许标记完整通过。
## 验证结论
- 全量矩阵 `140/140` 执行完成并通过,阻断失败和诊断失败均为 0。
- 同一 `red-briefing` 输入已通过源码服务、正式 Docker Web API 和实际
安装的 Desktop 三条生产链;三份 DOCX 均约 24.17 MB,包含 5 个字体
部件,嵌入字体字节数为 40,731,432。
- 已安装 Desktop 产物经真实 Word 和 WPS 渲染均为 2 页 A4,独立封面及
正文逐页复核通过。
- 正式安装包覆盖安装后,默认 10 页媒体示例从实际 Desktop 窗口导出
10,931,974 字节的 DOCX,包含 7 个字体部件和 12 个媒体部件;字体部件
解压后共 11,190,728 字节,确认安装版直接使用随程序内置的字体资源。
- Docker 镜像内置三套字体包与 Pandoc 3.9.0.2;修复复用运行层时 Pandoc
软链接已存在导致构建失败的问题,并通过根级 `.dockerignore` 固定精简
构建上下文。正式镜像已使用 `--pull --no-cache` 和禁用运行层复用的完整
路径重新构建并通过生产门禁。
- 全项目测试、类型检查、生产构建及 `git diff --check` 通过。
## 发行与兼容性
- Desktop 安装器和免安装 ZIP 版本均为 0.6.1;支持覆盖 v0.6.0 安装。
- Serif、Sans、Mono 三套字体直接内置于 Desktop 安装器和免安装 ZIP
不写入系统字体目录;安装过程不再显示字体选择,也不再发布独立字体附件。
- Docker 发布镜像为 `md-to-pdf:v0.6.1`,内容 ID 为
`sha256:f81dbce5857a0a5496851e149936d1fd7da52b76c035187be1079fa1fd5a4717`
- DOCX 保持原生可编辑段落、Run、列表和表格结构,没有以整页图片替代正文。
- 当前 Windows 安装器未进行 Authenticode 签名,适用于公司内部发布;
公开网络分发前建议增加代码签名。
+34
View File
@@ -0,0 +1,34 @@
# MorphDoc · 墨呈 v0.6.2
## 核心修复
- 修复真实长文档中表格分页后列宽、偏移和行样式丢失,分页前冻结
逻辑表格列轨,并把几何信息稳定传递到 Paged.js 分片。
- 修复 Markdown 表格中 `<br>` 变体、连续换行、实体和行内代码的
DOCX 翻译,换行仅生成在目标单元格段落内。
- 修复全 JSON 围栏缩进、表格末行对齐、代码高亮和行内代码连续性,
保持 Chromium/Paged.js、Markdown 渲染与 DOCX 引擎的通用链路。
- 改进文本视觉比对的 PDF 文本流排序和数值范围归一化,消除窄字符
重叠和跨视觉行破折号造成的伪语义缺失。
## 发布门禁
- 标准合成基线与长庆真实文档执行两套独立 140,严格结果为
`280/280`
- M4N 复杂表格文档完成 `140/140`;后续变更仅影响验收比较器,
不改变应用渲染或 DOCX 字节。
- 健康数据长文档在 115 个已完成样本中,通用文本修复后剩余 6 个
视觉门禁误报,比例为 `5.22%`。定向重跑证明均为重复表头与正文
的自动对齐/取样错配,并非文档渲染缺陷;基础设施错误为 0。
- 按本次发布决策,合成基线与长庆严格通过,健康数据与 M4N
在失败率不超过 10% 且残余问题经核查为非渲染误报时宽松通过。
## 发行与兼容性
- Desktop 安装器和免安装 ZIP 版本统一为 0.6.2Serif、Sans、Mono
字体继续直接内置,无需安装系统字体。
- Docker 镜像为 `md-to-pdf:v0.6.2`,内置固定 Chromium、Pandoc 3.9.0.2
和受校验字体包。
- DOCX 继续保持原生可编辑段落、Run、列表和表格结构,未将正文替换为
整页图片。
- Windows 安装器仍未进行 Authenticode 签名,适用于公司内部发布。
+37 -65
View File
@@ -1,9 +1,9 @@
# 可选字体包构建
# 内置字体包集合构建
本目录保存字体包配方、许可证、来源说明和正式冻结字体资产。字体二进制
位于固定的 `font-packs/assets/`,统一由 Git LFS 管理,普通 Git 对象只保存
LFS 指针。普通 Desktop 安装包不包含字体;组合 Desktop 发行把字体作为
同目录可选附件,Docker 发行构建则把本次受校验的字体包写入镜像层
LFS 指针。Desktop 安装版、便携版和 Docker 镜像都直接包含本次受校验的
完整字体包集合,不再依赖用户另行安装字体附件
## 源目录
@@ -16,10 +16,24 @@ font-packs/assets/mdtp-serif-sc/1.0.0/
├── MdTPSerifSC-Regular.woff2
├── MdTPSerifSC-Bold.ttf
└── MdTPSerifSC-Bold.woff2
font-packs/assets/mdtp-sans-sc/1.0.0/
├── MdTPSansSC-Regular.ttf
├── MdTPSansSC-Regular.woff2
├── MdTPSansSC-Bold.ttf
└── MdTPSansSC-Bold.woff2
font-packs/assets/mdtp-mono/1.0.0/
├── MdTPMono-Regular.ttf
├── MdTPMono-Regular.woff2
├── MdTPMono-Bold.ttf
└── MdTPMono-Bold.woff2
```
该目录中的 `ttf/otf/woff/woff2``.gitattributes` 强制使用 Git LFS。
构建器不会联网下载、转换或自动替换字体。每个输入文件都必须与配方声明的
构建器不会联网下载或自动替换字体。派生字体通过
`font-packs/scripts/build-noto-static.py` 从来源说明固定的 Noto CJK
可变字体生成;每个发行输入文件都必须与配方声明的
字节数和 SHA-256 完全一致;TTF 还必须是无需运行时规范化的原生静态
TrueType,并具有可安装或可编辑的文档嵌入权限。
@@ -30,17 +44,13 @@ npm run build:font-pack
npm run verify:font-pack
```
也可以显式指定源目录:
```powershell
npm run build:font-pack -- --source-root D:/font-pack-sources/mdtp-serif-sc/1.0.0
```
克隆仓库后应先执行 `git lfs pull`。缺少 LFS 内容时,字体构建会因文件大小
或 SHA-256 不匹配而拒绝继续,不允许使用系统字体或相近字体静默替代。
默认运行时字体包根目录为 `output/font-packs/root`,构建报告写入
`output/font-packs/build-report.json`,两者均被 Git 忽略。输出布局为:
受版本控制的 `font-packs/bundle.json` 是正式字体包集合清单。构建命令会
逐包构建、验证,并拒绝 `output/font-packs/root` 中出现清单外字体包,
避免本地残留进入发行产物。各包报告写入
`output/font-packs/<pack-id>-build-report.json`。输出布局为:
```text
root/<pack-id>/<semver>/font-pack.json
@@ -53,67 +63,29 @@ root/<pack-id>/<semver>/font-pack.json
`Mdpdf Fandol Song` 的 400/700 字重,不匹配 `SimSun` 等通用别名,避免
错误替换仿宋、黑体、楷体或普通外部主题字体。
## Windows 独立安装器
`mdtp-sans-sc@1.0.0` 提供 `MdTP Sans SC``FandolHei`
`Microsoft YaHei``PingFang SC``Noto Sans CJK SC`
`Source Han Sans SC` 的 400/700 字重映射。主题仍需明确声明所需字体面,
注册器不会对未声明字体进行静默替换。
发布人员可以使用 electron-builder 锁定并校验的 NSIS 工具链构建独立
字体包安装器,不需要额外安装系统 NSIS:
`mdtp-mono@1.0.0` 提供内部字族 `MdTP Mono`,并覆盖主题代码样式常用的
Cascadia、Consolas、SFMono、Menlo、Monaco 与 Courier 等候选名称。
应用层将它作为代码语义槽位的引擎级可选能力统一注入,因此新主题无需
增加主题 ID 分支;字体包缺失时仍保留主题原有候选链并输出缺失诊断。
```powershell
npm run build:font-pack:windows
```
默认产物为:
```text
output/font-packs/windows/
└── MorphDoc-FontPack-mdtp-serif-sc-1.0.0-x86_64-Setup.exe
```
安装器使用当前用户权限,将整个 `mdtp-serif-sc` ID 安装到:
```text
%LOCALAPPDATA%/md-to-pdf/font-packs/mdtp-serif-sc/1.0.0/
```
它不向 Windows 系统字体目录注册字体。同一版本重装保持幂等,升级时先把
该 ID 的现有目录移入备份,再原子提升新版本;失败会回滚。独立卸载项只
删除安装器拥有的 `mdtp-serif-sc` ID,不影响其他字体包 ID。安装器支持
`/S` 静默安装和卸载。
## Desktop 伴随发行
## Desktop 内置发行
组合构建命令为:
```powershell
npm run desktop:make:with-font-pack
npm run desktop:make
```
该命令把主安装器、字体包安装器和字体包 ZIP 作为同目录文件发行。主安装器
只编译字体包安装器的文件名与 SHA-256,不嵌入字体二进制;仅当同目录文件
存在且哈希完全一致时,交互安装向导才显示默认勾选的字体包选项。字体
失败不回滚已安装的主程序。
主安装器静默运行时默认不安装伴随字体包;只有显式传入以下参数才会安装:
```powershell
MorphDoc-<version>-x86_64-Setup.exe /S /WITHFONTPACK=1
```
普通 `npm run make -w @md-to-pdf/desktop` 不启用伴随逻辑,避免本地残留的
生成文件影响常规桌面构建。
## ZIP 发行附件
独立生成可手动部署的确定性 ZIP
```powershell
npm run build:font-pack:archive
```
产物为 `output/font-packs/release/MorphDoc-FontPack-mdtp-serif-sc-1.0.0.zip`
归档顶层已经包含 `mdtp-serif-sc/1.0.0/`,用户可直接解压到
`%LOCALAPPDATA%/md-to-pdf/font-packs`。ZIP 内保留清单、许可证、来源说明
以及 Web/DOCX 两类字体资产;相同输入会生成逐字节一致的附件。
该命令先重建完整字体包集合,再构建主安装器和便携 ZIP。electron-builder
`output/font-packs/root` 复制到 `resources/font-packs`Desktop 生产
运行时优先读取该只读内置根目录,再读取用户目录中的额外字体包。内置
因此不会被缺失、旧版或同 ID 用户包替换。NSIS 不安装系统字体、不显示
字体选择页,正式发行也不再拆分独立字体安装器或字体 ZIP。
## Docker 内置发行
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+23
View File
@@ -0,0 +1,23 @@
{
"schemaVersion": 1,
"packs": [
{
"id": "mdtp-serif-sc",
"version": "1.0.0",
"recipe": "font-packs/recipes/mdtp-serif-sc/recipe.json",
"sourceRoot": "font-packs/assets/mdtp-serif-sc/1.0.0"
},
{
"id": "mdtp-sans-sc",
"version": "1.0.0",
"recipe": "font-packs/recipes/mdtp-sans-sc/recipe.json",
"sourceRoot": "font-packs/assets/mdtp-sans-sc/1.0.0"
},
{
"id": "mdtp-mono",
"version": "1.0.0",
"recipe": "font-packs/recipes/mdtp-mono/recipe.json",
"sourceRoot": "font-packs/assets/mdtp-mono/1.0.0"
}
]
}
@@ -0,0 +1,94 @@
Copyright (c) 2019 - Present, Microsoft Corporation,
with Reserved Font Name Cascadia Code.
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to remain
under this license does not apply to any document created using the Font
Software.
TERMINATION
This license becomes null and void if any of the above conditions are not
met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF
COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER
DEALINGS IN THE FONT SOFTWARE.
+33
View File
@@ -0,0 +1,33 @@
# MdTP Mono 字体来源说明
本字体包的 Regular 400 与 Bold 700 字形面派生自 Microsoft Cascadia Mono
可变字体。
- 上游项目:Cascadia Code
- 上游仓库:https://github.com/microsoft/cascadia-code
- 上游版本:`v2102.25`
- 上游提交:`56bcca3f2c1e4cb19458954f0e2bb4635960df91`
- 官方归档:`CascadiaCode-2102.25.zip`
- 官方归档 SHA-256`a64e8834f06ec9acca4a5307cb6cf35922021d4fd0ca19096f8bf6a3e2515ad3`
- 上游源文件:`ttf/CascadiaMono.ttf`
- 上游源 SHA-256`bd893a00f500dc46ed181cd422a04c91d71720072f30a6dd369f0d9d26c54761`
- 许可证:SIL Open Font License 1.1
发行字形面使用 fontTools 4.60.1 从 `wght` 轴固定生成,并将主字体家族改名
`MdTP Mono`、PostScript 前缀改名为 `MdTPMono`。改名遵守上游保留
字体名称要求,也避免派生字体占用系统 Cascadia 字族。字形轮廓与字符
覆盖未做删减;`OS/2.fsType` 为 0,允许安装和文档嵌入。
可复现生成命令:
```powershell
python font-packs/scripts/build-noto-static.py `
--source .local/font-pack-upstream/cascadia-2102.25/ttf/CascadiaMono.ttf `
--output font-packs/assets/mdtp-mono/1.0.0 `
--family "MdTP Mono" `
--postscript-family MdTPMono
```
字体包构建器不从网络下载字体。发布人员必须先核对固定上游文件的
SHA-256,再生成配方声明的四个冻结资源;构建器会继续校验文件大小、
SHA-256、格式、字体内部家族、字重与嵌入权限。
+57
View File
@@ -0,0 +1,57 @@
{
"recipeVersion": 1,
"manifestVersion": 1,
"id": "mdtp-mono",
"version": "1.0.0",
"name": "MdTP 确定性等宽字体包",
"description": "为代码块与行内代码提供经过 Chromium、Word 和 WPS 验证的原生静态等宽 Regular 与 Bold 字形面。",
"internalFamily": "MdTP Mono",
"license": "OFL-1.1",
"licenseSource": "OFL-CascadiaCode.txt",
"licensePath": "OFL-CascadiaCode.txt",
"licenseBytes": 4394,
"licenseSha256": "d6e8a237280214b5938a0023484296e6c88de86d2eeb96d8d3844b285ec1bf7e",
"noticeSource": "SOURCE.md",
"noticePath": "SOURCE.md",
"compatibility": {
"minimumAppVersion": "0.6.0"
},
"faces": [
{
"id": "mono-regular",
"targets": ["MdTP Mono", "Cascadia Mono", "Cascadia Code", "Consolas", "Lucida Console", "SFMono-Regular", "Menlo", "Monaco", "Courier New", "Courier", "Source Code Pro", "JetBrains Mono"],
"weight": 400,
"style": "normal",
"web": {
"source": "MdTPMono-Regular.woff2",
"path": "fonts/MdTPMono-Regular.woff2",
"bytes": 65332,
"sha256": "434640b85dd8222a275b87b63ddc92811cd3d2662c304b81c37e20ed6d1db3df"
},
"docx": {
"source": "MdTPMono-Regular.ttf",
"path": "fonts/MdTPMono-Regular.ttf",
"bytes": 217964,
"sha256": "319d4ac0e5435220b813f87baa28e2ee21f8fa51ba216af0c5ecbc4739f03a07"
}
},
{
"id": "mono-bold",
"targets": ["MdTP Mono", "Cascadia Mono", "Cascadia Code", "Consolas", "Lucida Console", "SFMono-Regular", "Menlo", "Monaco", "Courier New", "Courier", "Source Code Pro", "JetBrains Mono"],
"weight": 700,
"style": "normal",
"web": {
"source": "MdTPMono-Bold.woff2",
"path": "fonts/MdTPMono-Bold.woff2",
"bytes": 66812,
"sha256": "03e898b3b1b50b0317d7b70d95fd76209e38cecdaca1db805bafe9740f5953c9"
},
"docx": {
"source": "MdTPMono-Bold.ttf",
"path": "fonts/MdTPMono-Bold.ttf",
"bytes": 218576,
"sha256": "90a071b13952ebf131df845e2c9b034a53dea0190c1470352dfedeeed95d1c9c"
}
}
]
}
@@ -0,0 +1,94 @@
Copyright 2012 Google Inc. All Rights Reserved.
This Font Software is licensed under the SIL Open Font License,
Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font
creation efforts of academic and linguistic communities, and to
provide a free and open framework in which fonts may be shared and
improved in partnership with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply to
any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software
components as distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to,
deleting, or substituting -- in part or in whole -- any of the
components of the Original Version, by changing formats or by porting
the Font Software to a new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed,
modify, redistribute, and sell modified and unmodified copies of the
Font Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components, in
Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the
corresponding Copyright Holder. This restriction only applies to the
primary font name as presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created using
the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
+29
View File
@@ -0,0 +1,29 @@
# MdTP Sans SC 字体来源说明
本字体包的 Regular 400 与 Bold 700 字形面派生自 Noto Sans SC 可变字体。
- 上游项目:Noto CJK
- 上游仓库:<https://github.com/notofonts/noto-cjk>
- Google Fonts 源提交:`985fa52c81c1d6692ccdd82bc3656e8fb932fd89`
- 上游源文件:`google-fonts/NotoSansSC[wght].ttf`
- 上游源 SHA-256`b1736bb633392cfc59b50d97c353d234111bd1721c9c83eaf643eaab9392e7f9`
- 许可证:SIL Open Font License 1.1
发行字形面使用 fontTools 4.60.1 从 `wght` 轴固定生成,并将主字体家族改名
`MdTP Sans SC`、PostScript 前缀改名为 `MdTPSansSC`。改名用于明确
标识派生版本,并避免派生字体占用上游字体名称。字形轮廓与字符覆盖未做
删减;`OS/2.fsType` 为 0,允许安装和文档嵌入。
可复现生成命令:
```powershell
python font-packs/scripts/build-noto-static.py `
--source .local/font-pack-upstream/NotoSansSC-wght.ttf `
--output font-packs/assets/mdtp-sans-sc/1.0.0 `
--family "MdTP Sans SC" `
--postscript-family MdTPSansSC
```
字体包构建器不从网络下载字体。发布人员必须先核对固定上游文件的
SHA-256,再生成配方声明的四个冻结资源;构建器会继续校验文件大小、
SHA-256、格式、字体内部家族、字重与嵌入权限。
@@ -0,0 +1,57 @@
{
"recipeVersion": 1,
"manifestVersion": 1,
"id": "mdtp-sans-sc",
"version": "1.0.0",
"name": "MdTP 中文黑体兼容包",
"description": "为现代文档与商务主题提供经过 Word/WPS 验证的原生静态中文黑体 Regular 与 Bold 字形面。",
"internalFamily": "MdTP Sans SC",
"license": "OFL-1.1",
"licenseSource": "OFL-NotoSansSC.txt",
"licensePath": "OFL-NotoSansSC.txt",
"licenseBytes": 4350,
"licenseSha256": "5e0da210fb04058a8c0087985d2d456b931c2579811a49655721d3cf0c36b6d6",
"noticeSource": "SOURCE.md",
"noticePath": "SOURCE.md",
"compatibility": {
"minimumAppVersion": "0.6.0"
},
"faces": [
{
"id": "sans-regular",
"targets": ["MdTP Sans SC", "FandolHei", "Microsoft YaHei", "PingFang SC", "Noto Sans CJK SC", "Source Han Sans SC"],
"weight": 400,
"style": "normal",
"web": {
"source": "MdTPSansSC-Regular.woff2",
"path": "fonts/MdTPSansSC-Regular.woff2",
"bytes": 4192788,
"sha256": "45f4ea0072861106b028f975b05752c13b46541dc84799e2746a657593c0c62d"
},
"docx": {
"source": "MdTPSansSC-Regular.ttf",
"path": "fonts/MdTPSansSC-Regular.ttf",
"bytes": 10596656,
"sha256": "a1d7e9c407d664c6d13ab9c01c48eca6775d77fd6daa3448c41ff7f1a3f316e1"
}
},
{
"id": "sans-bold",
"targets": ["MdTP Sans SC", "FandolHei", "Microsoft YaHei", "PingFang SC", "Noto Sans CJK SC", "Source Han Sans SC"],
"weight": 700,
"style": "normal",
"web": {
"source": "MdTPSansSC-Bold.woff2",
"path": "fonts/MdTPSansSC-Bold.woff2",
"bytes": 4314832,
"sha256": "729e7b445a1db47e588597eaacecebb892ef4de331379ff2439fb0be95aa808a"
},
"docx": {
"source": "MdTPSansSC-Bold.ttf",
"path": "fonts/MdTPSansSC-Bold.ttf",
"bytes": 10586172,
"sha256": "85fcfab7284fd72dfbbdb1a579230a9034dc6f9fc351b40cecd34d46125eebd6"
}
}
]
}
+85
View File
@@ -0,0 +1,85 @@
"""从固定的 Noto CJK 可变字体生成墨呈静态字体面。"""
from __future__ import annotations
import argparse
from pathlib import Path
from fontTools.ttLib import TTFont
from fontTools.varLib.instancer import instantiateVariableFont
def set_name(font: TTFont, name_id: int, value: str) -> None:
name_table = font["name"]
for platform_id, encoding_id, language_id in (
(3, 1, 0x0409),
(1, 0, 0),
):
name_table.setName(
value,
name_id,
platform_id,
encoding_id,
language_id,
)
def build_face(
source: Path,
output_root: Path,
family: str,
postscript_family: str,
weight: int,
style: str,
) -> None:
font = TTFont(source, recalcTimestamp=False)
instantiateVariableFont(font, {"wght": weight}, inplace=True, optimize=True)
set_name(font, 1, family)
set_name(font, 2, style)
set_name(font, 4, f"{family} {style}")
set_name(font, 6, f"{postscript_family}-{style}")
set_name(font, 16, family)
set_name(font, 17, style)
set_name(font, 25, postscript_family)
font["OS/2"].usWeightClass = weight
font["OS/2"].fsType = 0
output_root.mkdir(parents=True, exist_ok=True)
ttf_path = output_root / f"{postscript_family}-{style}.ttf"
font.flavor = None
font.save(ttf_path, reorderTables=False)
woff2_path = output_root / f"{postscript_family}-{style}.woff2"
font.flavor = "woff2"
font.save(woff2_path, reorderTables=False)
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--source", type=Path, required=True)
parser.add_argument("--output", type=Path, required=True)
parser.add_argument("--family", required=True)
parser.add_argument("--postscript-family", required=True)
arguments = parser.parse_args()
build_face(
arguments.source,
arguments.output,
arguments.family,
arguments.postscript_family,
400,
"Regular",
)
build_face(
arguments.source,
arguments.output,
arguments.family,
arguments.postscript_family,
700,
"Bold",
)
if __name__ == "__main__":
main()
+4 -3
View File
@@ -1,12 +1,12 @@
{
"name": "md-to-pdf",
"version": "0.6.0",
"version": "0.6.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "md-to-pdf",
"version": "0.6.0",
"version": "0.6.2",
"license": "UNLICENSED",
"workspaces": [
"apps/*",
@@ -22,7 +22,7 @@
},
"apps/desktop": {
"name": "@md-to-pdf/desktop",
"version": "0.6.0",
"version": "0.6.2",
"devDependencies": {
"@md-to-pdf/application": "0.4.1",
"@md-to-pdf/docx-engine": "0.1.0",
@@ -9975,6 +9975,7 @@
"@md-to-pdf/docx-engine": "0.1.0",
"@md-to-pdf/docx-theme-engine": "0.1.0",
"@md-to-pdf/font-pack-registry": "0.1.0",
"@md-to-pdf/preview-engine": "0.1.0",
"@md-to-pdf/renderer": "0.1.0"
},
"devDependencies": {
+12 -9
View File
@@ -1,6 +1,6 @@
{
"name": "md-to-pdf",
"version": "0.6.0",
"version": "0.6.2",
"private": true,
"license": "UNLICENSED",
"description": "面向结构化 Markdown 的主题化文档创作与发布工具",
@@ -25,15 +25,16 @@
"dev:web": "npm run dev -w @md-to-pdf/web",
"dev:desktop": "npm run dev -w @md-to-pdf/desktop",
"desktop:dev": "npm run build -w @md-to-pdf/markdown-echarts && npm run build -w @md-to-pdf/core && npm run build -w @md-to-pdf/semantic-document && npm run build -w @md-to-pdf/font-pack-registry && npm run build -w @md-to-pdf/docx-engine && npm run build -w @md-to-pdf/renderer && npm run build -w @md-to-pdf/application && npm run build -w @md-to-pdf/preview-engine && concurrently -k -n web,desktop \"npm:dev:web\" \"npm:dev:desktop\"",
"desktop:package": "npm run package -w @md-to-pdf/desktop",
"build:font-pack": "npm run build -w @md-to-pdf/core && npm run build -w @md-to-pdf/docx-theme-engine && npm run build -w @md-to-pdf/font-pack-registry && npm run build -w @md-to-pdf/docx-engine && npm run build -w @md-to-pdf/font-pack-builder && node packages/font-pack-builder/dist/cli.js build",
"verify:font-pack": "npm run build -w @md-to-pdf/core && npm run build -w @md-to-pdf/docx-theme-engine && npm run build -w @md-to-pdf/font-pack-registry && npm run build -w @md-to-pdf/docx-engine && npm run build -w @md-to-pdf/font-pack-builder && node packages/font-pack-builder/dist/cli.js verify",
"desktop:package": "npm run build:font-pack && npm run package -w @md-to-pdf/desktop",
"build:font-pack": "npm run build -w @md-to-pdf/core && npm run build -w @md-to-pdf/docx-theme-engine && npm run build -w @md-to-pdf/font-pack-registry && npm run build -w @md-to-pdf/docx-engine && npm run build -w @md-to-pdf/font-pack-builder && node scripts/build-font-packs.mjs",
"verify:font-pack": "npm run build:font-pack",
"verify:docker-font-pack": "npm run verify:font-pack && npm run build:web-runtime && npm run build -w @md-to-pdf/server && node deploy/verify-font-pack-compose.mjs",
"build:font-pack:windows": "npm run verify:font-pack && node packages/font-pack-builder/dist/cli.js windows-installer",
"build:font-pack:archive": "npm run verify:font-pack && node packages/font-pack-builder/dist/cli.js archive",
"desktop:make:with-font-pack": "npm run build:font-pack:windows && npm run build:font-pack:archive && node scripts/build-desktop-with-font-pack.mjs",
"desktop:make": "npm run build:font-pack && node scripts/build-desktop-release.mjs",
"release:stage": "node scripts/stage-release.mjs",
"test:release-stage": "node --test scripts/stage-release.test.mjs",
"test:docx-layout-visual-matrix-cases": "node --test scripts/docx-layout-visual-matrix-cases.test.mjs",
"test:docx-real-world-corpus": "node --test scripts/docx-real-world-corpus.test.mjs",
"test:docx-release-gate-suites": "node --test scripts/docx-release-gate-suites.test.mjs",
"theme:import-typora": "node scripts/import-typora-theme.mjs",
"verify:docx-reference": "npm run build -w @md-to-pdf/core && npm run build -w @md-to-pdf/docx-engine && npm run verify:pandoc -w @md-to-pdf/docx-engine",
"verify:docx-conversion": "npm run build -w @md-to-pdf/core && npm run build -w @md-to-pdf/docx-engine && npm run verify:conversion -w @md-to-pdf/docx-engine",
@@ -45,8 +46,10 @@
"verify:docx-r4-matrix": "npm run verify:docx-themes && npm run verify:font-pack && npm run build:web-runtime && npm run build -w @md-to-pdf/server && npm run verify:docx-r4-matrix -w @md-to-pdf/server",
"verify:docx-page-decorations": "npm run build:web-runtime && npm run build -w @md-to-pdf/document-visual-diff && npm run build -w @md-to-pdf/server && node scripts/verify-docx-page-decoration-visuals.mjs",
"verify:docx-media-visuals": "npm run build:web-runtime && npm run build -w @md-to-pdf/document-visual-diff && npm run build -w @md-to-pdf/server && node scripts/verify-docx-media-visuals.mjs",
"verify:docx-layout-visual-matrix": "npm run verify:docx-r4-matrix && npm run build -w @md-to-pdf/document-visual-diff && node scripts/verify-docx-layout-visual-matrix.mjs",
"test": "npm run test:release-stage && npm run test -w @md-to-pdf/markdown-echarts && npm run test -w @md-to-pdf/core && npm run build -w @md-to-pdf/core && npm run test -w @md-to-pdf/semantic-document && npm run build -w @md-to-pdf/semantic-document && npm run test -w @md-to-pdf/docx-theme-engine && npm run test -w @md-to-pdf/document-visual-diff && npm run test -w @md-to-pdf/font-pack-registry && npm run build -w @md-to-pdf/font-pack-registry && npm run test -w @md-to-pdf/docx-engine && npm run build -w @md-to-pdf/docx-engine && npm run test -w @md-to-pdf/font-pack-builder && npm run test -w @md-to-pdf/renderer && npm run test -w @md-to-pdf/application && npm run build -w @md-to-pdf/application && npm run test -w @md-to-pdf/preview-engine && npm run build -w @md-to-pdf/preview-engine && npm run test -w @md-to-pdf/web && npm run test -w @md-to-pdf/server && npm run test -w @md-to-pdf/desktop",
"verify:docx-layout-visual-matrix": "npm run test:docx-layout-visual-matrix-cases && npm run verify:font-pack && npm run build:web-runtime && npm run build -w @md-to-pdf/document-visual-diff && npm run build -w @md-to-pdf/server && node scripts/verify-docx-layout-visual-matrix.mjs",
"verify:docx-release-gate-suite": "npm run test:docx-layout-visual-matrix-cases && npm run test:docx-real-world-corpus && npm run test:docx-release-gate-suites && npm run verify:font-pack && npm run build:web-runtime && npm run build -w @md-to-pdf/document-visual-diff && npm run build -w @md-to-pdf/server && node scripts/verify-docx-release-gate-suite.mjs",
"verify:docx-release-gate-560": "npm run verify:docx-release-gate-suite",
"test": "npm run test:release-stage && npm run test:docx-layout-visual-matrix-cases && npm run test:docx-real-world-corpus && npm run test:docx-release-gate-suites && npm run test -w @md-to-pdf/markdown-echarts && npm run test -w @md-to-pdf/core && npm run build -w @md-to-pdf/core && npm run test -w @md-to-pdf/semantic-document && npm run build -w @md-to-pdf/semantic-document && npm run test -w @md-to-pdf/docx-theme-engine && npm run test -w @md-to-pdf/document-visual-diff && npm run test -w @md-to-pdf/font-pack-registry && npm run build -w @md-to-pdf/font-pack-registry && npm run test -w @md-to-pdf/docx-engine && npm run build -w @md-to-pdf/docx-engine && npm run test -w @md-to-pdf/font-pack-builder && npm run test -w @md-to-pdf/renderer && npm run test -w @md-to-pdf/application && npm run build -w @md-to-pdf/application && npm run test -w @md-to-pdf/preview-engine && npm run build -w @md-to-pdf/preview-engine && npm run test -w @md-to-pdf/web && npm run test -w @md-to-pdf/server && npm run test -w @md-to-pdf/desktop",
"typecheck": "npm run typecheck -w @md-to-pdf/markdown-echarts && npm run build -w @md-to-pdf/markdown-echarts && npm run typecheck -w @md-to-pdf/core && npm run build -w @md-to-pdf/core && npm run typecheck -w @md-to-pdf/semantic-document && npm run build -w @md-to-pdf/semantic-document && npm run typecheck -w @md-to-pdf/docx-theme-engine && npm run build -w @md-to-pdf/docx-theme-engine && npm run typecheck -w @md-to-pdf/document-visual-diff && npm run typecheck -w @md-to-pdf/font-pack-registry && npm run typecheck -w @md-to-pdf/docx-engine && npm run build -w @md-to-pdf/docx-engine && npm run typecheck -w @md-to-pdf/font-pack-builder && npm run typecheck -w @md-to-pdf/renderer && npm run build -w @md-to-pdf/renderer && npm run typecheck -w @md-to-pdf/application && npm run build -w @md-to-pdf/application && npm run typecheck -w @md-to-pdf/preview-engine && npm run build -w @md-to-pdf/preview-engine && npm run typecheck -w @md-to-pdf/web && npm run typecheck -w @md-to-pdf/server && npm run typecheck -w @md-to-pdf/desktop"
},
"engines": {
+1
View File
@@ -25,6 +25,7 @@
"@md-to-pdf/docx-engine": "0.1.0",
"@md-to-pdf/docx-theme-engine": "0.1.0",
"@md-to-pdf/font-pack-registry": "0.1.0",
"@md-to-pdf/preview-engine": "0.1.0",
"@md-to-pdf/renderer": "0.1.0"
},
"devDependencies": {
@@ -1,11 +1,11 @@
import {
MarkdownDocumentParseError,
renderMarkdown
renderMarkdown,
type RenderedMarkdown
} from "@md-to-pdf/renderer";
import {
docxExportRequestSchema,
type DocxExportRequest,
type RenderedMarkdownDocument,
type ThemeManifest
} from "@md-to-pdf/core";
import type { DocxFontSource } from "@md-to-pdf/docx-engine";
@@ -45,7 +45,7 @@ export interface ApplicationServiceOptions
export interface PreparedDocxExport {
request: DocxExportRequest;
document: RenderedMarkdownDocument;
document: RenderedMarkdown;
theme: {
manifest: ThemeManifest;
source: "bundled" | "local";
@@ -486,7 +486,7 @@ export class DocxExportService {
media: Awaited<ReturnType<typeof prepareDocxMedia>>
): PandocDocxConversionInput {
return {
markdown: prepared.request.markdown,
markdown: prepared.document.markdownBody,
fileName: prepared.request.fileName,
language: prepared.request.language,
exportConfig: prepared.request.exportConfig,
@@ -197,6 +197,7 @@ export async function prepareDocxMedia(
...parsedPlan.data.echartsErrors,
...parsedPlan.data.mermaidErrors
],
totalBytes
totalBytes,
documentLayout: parsedPlan.data.documentLayout ?? { tables: [] }
};
}
@@ -11,6 +11,7 @@ import {
type DocxThemeStyleSnapshot
} from "@md-to-pdf/docx-theme-engine";
import type { PreparedDocxExport } from "./application-service.js";
import { SHARED_HIGHLIGHT_CSS } from "@md-to-pdf/preview-engine";
export interface PreparedDocxThemeTokens {
tokens: DocxThemeTokenSet;
@@ -67,7 +68,7 @@ export class DocxThemeTokenService
const themeFingerprint =
await createDocxThemeStyleFingerprint(
prepared.theme.manifest.id,
prepared.theme.css
`${SHARED_HIGHLIGHT_CSS}\n${prepared.theme.css}`
);
signal?.throwIfAborted();
const snapshot =
@@ -80,7 +81,7 @@ export class DocxThemeTokenService
{
themeId: prepared.theme.manifest.id,
themeFingerprint,
themeCss: prepared.theme.css,
themeCss: `${SHARED_HIGHLIGHT_CSS}\n${prepared.theme.css}`,
baseUrl: this.adapter.themeStyleBaseUrl
},
signal
+53 -3
View File
@@ -92,6 +92,48 @@ const maximumCssImportDepth = 8;
const sharedThemeAssetId = "_shared";
const sharedThemeAssetScheme = "theme-shared:";
const sharedFontCssPath = "official-fonts/fonts.css";
const engineCodeFontFaces: readonly DocxFontFace[] = [
{
family: "MdTP Mono",
aliases: [
"Cascadia Mono",
"Cascadia Code",
"Consolas",
"Lucida Console",
"SFMono-Regular",
"Menlo",
"Monaco",
"Courier New",
"Courier",
"Source Code Pro",
"JetBrains Mono"
],
source: "fonts/mdtp-mono.ttf",
weight: 400,
style: "normal",
license: "OFL-1.1"
},
{
family: "MdTP Mono",
aliases: [
"Cascadia Mono",
"Cascadia Code",
"Consolas",
"Lucida Console",
"SFMono-Regular",
"Menlo",
"Monaco",
"Courier New",
"Courier",
"Source Code Pro",
"JetBrains Mono"
],
source: "fonts/mdtp-mono-bold.ttf",
weight: 700,
style: "normal",
license: "OFL-1.1"
}
];
function isMissingFileError(error: unknown) {
return (
@@ -434,7 +476,8 @@ export function createThemeRegistry(options: ThemeRegistryOptions) {
async function resolveThemeFontPacks(theme: ThemeRecord) {
const registry = await listFontPacks();
const faces = theme.manifest.docxFonts?.faces ?? [];
const declaredFaces = theme.manifest.docxFonts?.faces ?? [];
const faces = [...declaredFaces, ...engineCodeFontFaces];
const requests: FontPackFaceRequest[] = faces.map((face) => ({
family: face.family,
aliases: face.aliases,
@@ -453,7 +496,14 @@ export function createThemeRegistry(options: ThemeRegistryOptions) {
matches.set(face, match);
}
}
return { registry, resolution, matches };
return {
registry,
resolution,
matches,
faces: faces.filter(
(face) => declaredFaces.includes(face) || matches.has(face)
)
};
}
function createFontPackCss(
@@ -711,8 +761,8 @@ export function createThemeRegistry(options: ThemeRegistryOptions) {
if (!theme) {
return undefined;
}
const faces = theme.manifest.docxFonts?.faces ?? [];
const fontPacks = await resolveThemeFontPacks(theme);
const faces = fontPacks.faces;
const fonts = await Promise.all(
faces.map(async (face) => {
const matched = fontPacks.matches.get(face);
@@ -132,7 +132,11 @@ function sha256(content: Uint8Array | string) {
}
async function createFontPackFixture(
options: { corruptWebHash?: boolean; minimumAppVersion?: string } = {}
options: {
corruptWebHash?: boolean;
minimumAppVersion?: string;
targets?: string[];
} = {}
) {
const root = join(temporaryDirectory!, "font-packs");
const directory = join(root, "test-font-pack", "1.0.0");
@@ -162,7 +166,7 @@ async function createFontPackFixture(
faces: [
{
id: "test-regular",
targets: ["Test"],
targets: options.targets ?? ["Test"],
weight: 400,
style: "normal",
web: {
@@ -464,6 +468,53 @@ describe("共享应用服务", () => {
});
});
it("以引擎级代码字体请求为新主题注入同源等宽字体", async () => {
const roots = await createThemeFixture();
const fontPack = await createFontPackFixture({
targets: ["MdTP Mono", "Cascadia Code", "Consolas"]
});
const service = createApplicationService({
...roots,
fontPacks: {
roots: [fontPack.root],
appVersion: "0.6.0",
createAssetUrl: (packId, version, faceId, kind, hash) =>
`mdpdf://font-pack/${packId}/${version}/${faceId}/${kind}?v=${hash}`
}
});
const css = await service.getThemeCss("test-theme");
expect(css).toContain('font-family: "Cascadia Code";');
expect(css).toContain('font-family: "Consolas";');
const prepared = await service.prepareDocxExport({
markdown: "`inline()`",
fileName: "代码.md",
exportConfig: {
...defaultExportConfig,
themeId: "test-theme"
}
});
expect(prepared.theme.fonts).toEqual(
expect.arrayContaining([
expect.objectContaining({
family: "MdTP Mono",
aliases: expect.arrayContaining(["Cascadia Code", "Consolas"]),
source: "font-pack:test-font-pack/1.0.0/test-regular/docx",
content: new Uint8Array(fontPack.docx)
})
])
);
expect(prepared.theme.fontPack?.appliedFaces).toEqual(
expect.arrayContaining([
expect.objectContaining({
family: "MdTP Mono",
packId: "test-font-pack",
faceId: "test-regular"
})
])
);
});
it("字体包损坏时记录诊断并回退到主题字体", async () => {
const roots = await createThemeFixture();
const fontPack = await createFontPackFixture({ corruptWebHash: true });
+127 -4
View File
@@ -8,6 +8,7 @@ export const DOCX_FILE_EXTENSION = ".docx";
export const MAXIMUM_DOCX_MARKDOWN_LENGTH = 1_500_000;
export const MAXIMUM_DOCX_FILE_NAME_LENGTH = 500;
export const MAXIMUM_DOCX_RESOURCE_COUNT = 50;
export const MAXIMUM_DOCX_MEDIA_COUNT = 100;
export const DOCX_MEDIA_RASTER_DPI = 300;
export const DOCX_MEDIA_CSS_DPI = 96;
export const DOCX_MEDIA_RASTER_SCALE =
@@ -106,15 +107,135 @@ export type DocxMediaAlignment = z.infer<
typeof docxMediaAlignmentSchema
>;
export const docxTableLayoutSchema = z.object({
ordinal: z.number().int().min(1).max(256),
widthPercent: z.number().positive().max(300),
leftOffsetPercent: z.number().min(0).max(100),
columnWidthPercents: z
.array(z.number().positive().max(100))
.min(1)
.max(64),
rows: z
.array(
z.object({
cells: z
.array(
z.object({
columnSpan: z.number().int().min(1).max(64),
backgroundColor: z
.string()
.regex(/^#[0-9a-f]{6}$/iu),
color: z.string().regex(/^#[0-9a-f]{6}$/iu),
bold: z.boolean(),
italic: z.boolean(),
alignment: z.enum(["left", "center", "right", "justify"])
})
)
.min(1)
.max(64)
})
)
.max(2048)
});
export type DocxTableLayout = z.infer<
typeof docxTableLayoutSchema
>;
export const docxTextBlockLayoutSchema = z.object({
ordinal: z.number().int().min(1).max(100_000),
text: z.string().min(1).max(100_000),
letterSpacingPt: z.number().min(-20).max(100),
alertRole: z.enum(["title", "body"]).optional(),
fontSizePt: z.number().min(1).max(200).optional(),
bold: z.boolean().optional(),
italic: z.boolean().optional(),
color: z.string().regex(/^#[0-9a-f]{6}$/iu).optional(),
leftIndentPt: z.number().min(0).max(2_000).optional(),
rightIndentPt: z.number().min(0).max(2_000).optional(),
alignment: z
.enum(["left", "center", "right", "justify", "distribute"])
.optional(),
linePitchPt: z.number().min(1).max(500).optional(),
lineBreakOffsets: z
.array(z.number().int().min(1).max(100_000))
.max(10_000)
});
export type DocxTextBlockLayout = z.infer<
typeof docxTextBlockLayoutSchema
>;
export const docxListItemLayoutSchema = z.object({
ordinal: z.number().int().min(1).max(100_000),
text: z.string().min(1).max(100_000),
depth: z.number().int().min(0).max(64),
textStartPt: z.number().min(0).max(2_000),
alignment: z
.enum(["left", "center", "right", "justify", "distribute"])
.optional()
});
export type DocxListItemLayout = z.infer<
typeof docxListItemLayoutSchema
>;
export const docxInlineCodeLayoutSchema = z.object({
ordinal: z.number().int().min(1).max(100_000),
text: z.string().min(1).max(100_000),
fontSizePt: z.number().min(1).max(200),
letterSpacingPt: z.number().min(-20).max(100),
color: z.string().regex(/^#[0-9a-f]{6}$/iu),
backgroundColor: z.string().regex(/^#[0-9a-f]{6}$/iu).optional(),
paddingPt: z.object({
top: z.number().min(0).max(1_000),
right: z.number().min(0).max(1_000),
bottom: z.number().min(0).max(1_000),
left: z.number().min(0).max(1_000)
}),
borderPt: z.object({
top: z.number().min(0).max(20),
right: z.number().min(0).max(20),
bottom: z.number().min(0).max(20),
left: z.number().min(0).max(20)
})
});
export type DocxInlineCodeLayout = z.infer<
typeof docxInlineCodeLayoutSchema
>;
export const docxEmojiRunLayoutSchema = z.object({
ordinal: z.number().int().min(1).max(100_000),
text: z.string().min(1).max(1_000),
color: z.string().regex(/^#[0-9a-f]{6}$/iu)
});
export type DocxEmojiRunLayout = z.infer<
typeof docxEmojiRunLayoutSchema
>;
export const docxDocumentLayoutPlanSchema = z.object({
tables: z.array(docxTableLayoutSchema).max(256),
textBlocks: z.array(docxTextBlockLayoutSchema).max(100_000).optional(),
listItems: z.array(docxListItemLayoutSchema).max(100_000).optional(),
inlineCodes: z.array(docxInlineCodeLayoutSchema).max(100_000).optional(),
emojiRuns: z.array(docxEmojiRunLayoutSchema).max(100_000).optional()
});
export type DocxDocumentLayoutPlan = z.infer<
typeof docxDocumentLayoutPlanSchema
>;
export const docxMediaCaptureTargetSchema = z.object({
id: z.string().regex(/^docx-media-\d+$/u),
kind: docxMediaKindSchema,
ordinal: z.number().int().min(1).max(MAXIMUM_DOCX_RESOURCE_COUNT),
ordinal: z.number().int().min(1).max(MAXIMUM_DOCX_MEDIA_COUNT),
kindOrdinal: z
.number()
.int()
.min(1)
.max(MAXIMUM_DOCX_RESOURCE_COUNT),
.max(MAXIMUM_DOCX_MEDIA_COUNT),
altText: z.string().max(1_000),
caption: z.string().max(1_000).optional(),
alignment: docxMediaAlignmentSchema,
@@ -134,9 +255,10 @@ export type DocxMediaCaptureTarget = z.infer<
export const docxMediaCapturePlanSchema = z.object({
targets: z
.array(docxMediaCaptureTargetSchema)
.max(MAXIMUM_DOCX_RESOURCE_COUNT),
.max(MAXIMUM_DOCX_MEDIA_COUNT),
echartsErrors: z.array(z.string().max(1_000)),
mermaidErrors: z.array(z.string().max(1_000))
mermaidErrors: z.array(z.string().max(1_000)),
documentLayout: docxDocumentLayoutPlanSchema.optional()
});
export type DocxMediaCapturePlan = z.infer<
@@ -158,6 +280,7 @@ export interface PreparedDocxMedia {
mermaidErrors: string[];
warnings: string[];
totalBytes: number;
documentLayout?: DocxDocumentLayoutPlan;
}
export const docxExportErrorCodeSchema = z.enum([
+39
View File
@@ -3,8 +3,10 @@ import {
DOCX_MIME_TYPE,
DOCX_MEDIA_RASTER_SCALE,
DOCX_PANDOC_VERSION,
MAXIMUM_DOCX_MEDIA_COUNT,
MAXIMUM_DOCX_MEDIA_EDGE_PIXELS,
MAXIMUM_DOCX_MARKDOWN_LENGTH,
MAXIMUM_DOCX_RESOURCE_COUNT,
createDocxFileName,
defaultExportConfig,
docxCapabilitySchema,
@@ -167,6 +169,43 @@ describe("DOCX 共享协议", () => {
}).success
).toBe(false);
});
it("区分用户资源上限与渲染后媒体上限", () => {
expect(MAXIMUM_DOCX_RESOURCE_COUNT).toBe(50);
expect(MAXIMUM_DOCX_MEDIA_COUNT).toBe(100);
expect(
docxExportRequestSchema.safeParse({
markdown: "# 文档",
resources: Array.from({ length: 51 }, (_, index) => ({
path: `asset-${index}.png`,
data: "AA=="
})),
exportConfig: defaultExportConfig
}).success
).toBe(false);
const targets = Array.from({ length: 67 }, (_, index) => ({
id: `docx-media-${index + 1}`,
kind: "mermaid" as const,
ordinal: index + 1,
kindOrdinal: index + 1,
altText: `${index + 1}`,
alignment: "center" as const,
displayWidthPx: 100,
displayHeightPx: 100,
captureX: 0,
captureY: index * 100,
captureWidthPx: 100,
captureHeightPx: 100,
rasterScale: 3.125
}));
expect(
docxMediaCapturePlanSchema.safeParse({
targets,
echartsErrors: [],
mermaidErrors: []
}).success
).toBe(true);
});
});
describe("DOCX 主题样式协议", () => {
+49 -16
View File
@@ -15,14 +15,19 @@ import type {
} from "./types.js";
export const DEFAULT_VISUAL_DIFF_THRESHOLDS: VisualDiffThresholds = {
pageSizeDeltaPt: 0.5,
pageSizeDeltaPt: 0.6,
contentSimilarity: 0.999,
strictPageCount: true,
pixelDifferenceThreshold: 8,
spatialTolerancePx: 4,
maxMeanAbsoluteError: 8,
maxChangedPixelRatio: 0.05,
minInkIou: 0.75,
minEdgeIou: 0.6,
maxChangedPixelRatio: 0.06,
minInkIou: 0.64,
minEdgeIou: 0.6,
maxBackgroundColorDelta: 12,
maxForegroundColorDelta: 12,
minAntialiasEquivalentInkIou: 0.98,
minAntialiasEquivalentEdgeIou: 0.98,
};
function cappedLevenshteinDistance(
@@ -258,8 +263,29 @@ export function comparePdfSnapshotsBasic(
const candidateEditableText = hasEditableExpectation
? buildPdfEditableContentText(candidate, candidatePageSemantics)
: "";
const paragraphLayouts = contentOptions.expectedEditableParagraphs
? comparePdfEditableParagraphLayouts(
baseline,
candidate,
contentOptions.expectedEditableParagraphs,
baselinePageSemantics,
candidatePageSemantics,
)
: undefined;
const paragraphContentExact =
paragraphLayouts !== undefined &&
paragraphLayouts.length === contentOptions.expectedEditableParagraphs?.length &&
paragraphLayouts.every(
(paragraph) => paragraph.baseline.matched && paragraph.candidate.matched,
);
const baselineParagraphContentExact =
paragraphLayouts !== undefined &&
paragraphLayouts.length === contentOptions.expectedEditableParagraphs?.length &&
paragraphLayouts.every((paragraph) => paragraph.baseline.matched);
const baselineEditableCoverage = hasEditableExpectation
? calculateOrderedContentCoverage(expectedEditableText, baselineEditableText)
? baselineParagraphContentExact
? 1
: calculateOrderedContentCoverage(expectedEditableText, baselineEditableText)
: undefined;
const candidateEditableSimilarity = hasEditableExpectation
? calculateContentSimilarity(
@@ -269,7 +295,7 @@ export function comparePdfSnapshotsBasic(
)
: undefined;
const candidateEditableExact = hasEditableExpectation
? candidateEditableText === expectedEditableText
? candidateEditableText === expectedEditableText || paragraphContentExact
: undefined;
const contentSimilarity = hasEditableExpectation
? Math.min(
@@ -301,15 +327,6 @@ export function comparePdfSnapshotsBasic(
});
}
const paragraphLayouts = contentOptions.expectedEditableParagraphs
? comparePdfEditableParagraphLayouts(
baseline,
candidate,
contentOptions.expectedEditableParagraphs,
baselinePageSemantics,
candidatePageSemantics,
)
: undefined;
if (paragraphLayouts) {
issues.push(...paragraphLayouts.flatMap((paragraph) => paragraph.issues));
}
@@ -365,6 +382,22 @@ export function comparePdfSnapshotsBasic(
if (!primaryExpectation) {
return false;
}
if (pair.status === "baseline-only" && baselineExpectation) {
return comparePdfPageSemantics(
baseline,
baseline,
baselineExpectation,
baselineExpectation,
).status === "passed";
}
if (pair.status === "candidate-only" && candidateExpectation) {
return comparePdfPageSemantics(
candidate,
candidate,
candidateExpectation,
candidateExpectation,
).status === "passed";
}
return comparePdfPageSemantics(
baseline,
candidate,
@@ -375,7 +408,7 @@ export function comparePdfSnapshotsBasic(
const bodyFlowLegal =
bodyFlowDetected &&
paragraphLayouts !== undefined &&
paragraphLayouts.every((paragraph) => paragraph.status === "passed") &&
paragraphLayouts.every((paragraph) => paragraph.status !== "failed") &&
baselineEditableCoverage === 1 &&
candidateEditableExact === true &&
baselineCoverPageCount === candidateCoverPageCount &&
@@ -6,6 +6,7 @@ export * from "./paragraph-layout.js";
export * from "./page-semantics.js";
export * from "./raster.js";
export * from "./report.js";
export * from "./semantic-block.js";
export * from "./snapshot.js";
export * from "./text.js";
export * from "./types.js";
@@ -131,6 +131,22 @@ try {
$pageCount = $null
try { $pageCount = $document.ComputeStatistics(2) } catch {}
$document.ExportAsFixedFormat($resolvedOutput, 17)
$deadline = [DateTime]::UtcNow.AddSeconds(10)
do {
if ([System.IO.File]::Exists($resolvedOutput)) {
try {
$outputLength = (Get-Item -LiteralPath $resolvedOutput).Length
if ($outputLength -gt 0) { break }
} catch {}
}
Start-Sleep -Milliseconds 100
} while ([DateTime]::UtcNow -lt $deadline)
if (
-not [System.IO.File]::Exists($resolvedOutput) -or
(Get-Item -LiteralPath $resolvedOutput).Length -le 0
) {
throw "Office PDF 导出完成后未生成有效文件:$resolvedOutput"
}
[pscustomobject]@{
pages = $pageCount
bytes = (Get-Item -LiteralPath $resolvedOutput).Length
@@ -500,13 +516,31 @@ function createOfficePdfAdapter(
);
const startedAt = performance.now();
try {
const metadata = await backend.exportPdf({
client: descriptor.client,
progId: descriptor.progId,
inputPath,
outputPath,
timeoutMs,
});
let metadata: OfficeExportMetadata | undefined;
let lastError: unknown;
for (let attempt = 1; attempt <= 3; attempt += 1) {
try {
metadata = await backend.exportPdf({
client: descriptor.client,
progId: descriptor.progId,
inputPath,
outputPath,
timeoutMs,
});
break;
} catch (error) {
lastError = error;
await rm(outputPath, { force: true });
if (attempt < 3) {
await new Promise<void>((resolve) => {
setTimeout(resolve, attempt * 250);
});
}
}
}
if (!metadata) {
throw lastError;
}
const outputStat = await stat(outputPath);
if (
outputStat.size <= 0 ||
File diff suppressed because it is too large Load Diff
+615 -16
View File
@@ -16,6 +16,14 @@ interface DecodedRaster {
rgba: Uint8ClampedArray;
}
interface RgbColor {
red: number;
green: number;
blue: number;
}
const RASTER_MASK_BORDER_PX = 2;
function sha256(bytes: Uint8Array): string {
return createHash("sha256").update(bytes).digest("hex");
}
@@ -25,12 +33,35 @@ async function decodeRaster(
width: number,
height: number,
): Promise<DecodedRaster> {
const image = await loadImage(Buffer.from(raster.png));
if (
raster.rgba?.length === raster.widthPx * raster.heightPx * 4 &&
raster.widthPx === width &&
raster.heightPx === height
) {
return {
width,
height,
rgba: raster.rgba,
};
}
const canvas = createCanvas(width, height);
const context = canvas.getContext("2d");
context.fillStyle = "#ffffff";
context.fillRect(0, 0, width, height);
context.drawImage(image, 0, 0, width, height);
if (raster.rgba?.length === raster.widthPx * raster.heightPx * 4) {
const sourceCanvas = createCanvas(raster.widthPx, raster.heightPx);
const sourceContext = sourceCanvas.getContext("2d");
const sourceData = sourceContext.createImageData(
raster.widthPx,
raster.heightPx,
);
sourceData.data.set(raster.rgba);
sourceContext.putImageData(sourceData, 0, 0);
context.drawImage(sourceCanvas, 0, 0, width, height);
} else {
const image = await loadImage(Buffer.from(raster.png));
context.drawImage(image, 0, 0, width, height);
}
return {
width,
height,
@@ -48,6 +79,384 @@ function luminance(
return (77 * red + 150 * green + 29 * blue) >> 8;
}
function colorDistance(left: RgbColor, right: RgbColor): number {
return Math.sqrt(
((left.red - right.red) ** 2 +
(left.green - right.green) ** 2 +
(left.blue - right.blue) ** 2) /
3,
);
}
function colorChroma(color: RgbColor): number {
return Math.max(color.red, color.green, color.blue) -
Math.min(color.red, color.green, color.blue);
}
function colorLuminance(color: RgbColor): number {
return (77 * color.red + 150 * color.green + 29 * color.blue) / 256;
}
function rasterBackgroundColor(
rgba: Uint8ClampedArray,
width: number,
height: number,
): RgbColor {
const red: number[] = [];
const green: number[] = [];
const blue: number[] = [];
const append = (x: number, y: number) => {
const offset = (y * width + x) * 4;
red.push(rgba[offset] ?? 255);
green.push(rgba[offset + 1] ?? 255);
blue.push(rgba[offset + 2] ?? 255);
};
for (let x = 0; x < width; x += 1) {
append(x, 0);
if (height > 1) {
append(x, height - 1);
}
}
for (let y = 1; y < height - 1; y += 1) {
append(0, y);
if (width > 1) {
append(width - 1, y);
}
}
const median = (values: number[]) => {
if (values.length === 0) {
return 255;
}
values.sort((left, right) => left - right);
return values[Math.floor(values.length / 2)] ?? 255;
};
// 局部语义块会紧贴文字裁剪;连续渐变会把同一背景拆散到许多颜色桶,
// 使深色文字反而成为“众数背景”。边框中位色能稳定覆盖纯色和渐变,
// 且不会被少量触边字形反转。
const border = {
red: median(red),
green: median(green),
blue: median(blue),
};
const robustRange = (values: readonly number[]) =>
(values[Math.floor(values.length * 0.8)] ?? 255) -
(values[Math.floor(values.length * 0.2)] ?? 255);
const borderChannelRange = Math.max(
robustRange(red),
robustRange(green),
robustRange(blue),
);
// 极紧的字形裁剪可能四边都落在抗锯齿像素上;实心表头等
// 盒背景也可能在裁剪边界外露出白色。因此保留全图众数作为常规背景。
const buckets = new Map<
number,
{ count: number; red: number; green: number; blue: number }
>();
let dominant:
| { count: number; red: number; green: number; blue: number }
| undefined;
for (let offset = 0; offset < rgba.length; offset += 4) {
const pixelRed = rgba[offset] ?? 255;
const pixelGreen = rgba[offset + 1] ?? 255;
const pixelBlue = rgba[offset + 2] ?? 255;
const bucketKey = (pixelRed >> 3) << 10 |
(pixelGreen >> 3) << 5 |
(pixelBlue >> 3);
const bucket = buckets.get(bucketKey) ?? {
count: 0,
red: 0,
green: 0,
blue: 0,
};
bucket.count += 1;
bucket.red += pixelRed;
bucket.green += pixelGreen;
bucket.blue += pixelBlue;
buckets.set(bucketKey, bucket);
if (!dominant || bucket.count > dominant.count) {
dominant = bucket;
}
}
const dominantColor = dominant
? {
red: dominant.red / dominant.count,
green: dominant.green / dominant.count,
blue: dominant.blue / dominant.count,
}
: border;
// 只有浅色边界与全图众数出现极大亮度反转时,才判定连续渐变
// 把深色文字拆分成了错误的“众数背景”。
return borderChannelRange <= 64 &&
colorLuminance(border) >= 220 &&
colorLuminance(dominantColor) <= colorLuminance(border) - 80
? border
: dominantColor;
}
function rasterForegroundColor(
decoded: DecodedRaster,
): RgbColor {
const background = rasterBackgroundColor(
decoded.rgba,
decoded.width,
decoded.height,
);
const buckets = new Map<
number,
{ count: number; red: number; green: number; blue: number }
>();
for (let offset = 0; offset < decoded.rgba.length; offset += 4) {
const color = {
red: decoded.rgba[offset] ?? 255,
green: decoded.rgba[offset + 1] ?? 255,
blue: decoded.rgba[offset + 2] ?? 255,
};
const distance = colorDistance(color, background);
if (distance < 48) {
continue;
}
const bucketKey = (color.red >> 3) << 10 |
(color.green >> 3) << 5 |
(color.blue >> 3);
const bucket = buckets.get(bucketKey) ?? {
count: 0,
red: 0,
green: 0,
blue: 0,
};
bucket.count += 1;
bucket.red += color.red;
bucket.green += color.green;
bucket.blue += color.blue;
buckets.set(bucketKey, bucket);
}
let dominant:
| { count: number; red: number; green: number; blue: number }
| undefined;
let dominantDistance = -1;
for (const bucket of buckets.values()) {
const averageColor = {
red: bucket.red / bucket.count,
green: bucket.green / bucket.count,
blue: bucket.blue / bucket.count,
};
const distance = colorDistance(averageColor, background);
if (
distance > dominantDistance + 1 ||
(Math.abs(distance - dominantDistance) <= 1 &&
bucket.count > (dominant?.count ?? 0))
) {
dominant = bucket;
dominantDistance = distance;
}
}
return !dominant
? background
: {
red: dominant.red / dominant.count,
green: dominant.green / dominant.count,
blue: dominant.blue / dominant.count,
};
}
function rasterDominantForegroundColor(
decoded: DecodedRaster,
): RgbColor {
const background = rasterBackgroundColor(
decoded.rgba,
decoded.width,
decoded.height,
);
const buckets = new Map<
number,
{ count: number; red: number; green: number; blue: number }
>();
for (let offset = 0; offset < decoded.rgba.length; offset += 4) {
const color = {
red: decoded.rgba[offset] ?? 255,
green: decoded.rgba[offset + 1] ?? 255,
blue: decoded.rgba[offset + 2] ?? 255,
};
if (colorDistance(color, background) < 48) {
continue;
}
const bucketKey = (color.red >> 3) << 10 |
(color.green >> 3) << 5 |
(color.blue >> 3);
const bucket = buckets.get(bucketKey) ?? {
count: 0,
red: 0,
green: 0,
blue: 0,
};
bucket.count += 1;
bucket.red += color.red;
bucket.green += color.green;
bucket.blue += color.blue;
buckets.set(bucketKey, bucket);
}
const dominant = Array.from(buckets.values()).sort(
(left, right) => right.count - left.count,
)[0];
return !dominant
? background
: {
red: dominant.red / dominant.count,
green: dominant.green / dominant.count,
blue: dominant.blue / dominant.count,
};
}
function rasterDominantChromaticForegroundColor(
decoded: DecodedRaster,
): RgbColor | undefined {
const background = rasterBackgroundColor(
decoded.rgba,
decoded.width,
decoded.height,
);
const buckets = new Map<
number,
{ count: number; red: number; green: number; blue: number }
>();
for (let offset = 0; offset < decoded.rgba.length; offset += 4) {
const color = {
red: decoded.rgba[offset] ?? 255,
green: decoded.rgba[offset + 1] ?? 255,
blue: decoded.rgba[offset + 2] ?? 255,
};
if (
colorDistance(color, background) < 48 ||
colorChroma(color) < 32
) {
continue;
}
const bucketKey = (color.red >> 3) << 10 |
(color.green >> 3) << 5 |
(color.blue >> 3);
const bucket = buckets.get(bucketKey) ?? {
count: 0,
red: 0,
green: 0,
blue: 0,
};
bucket.count += 1;
bucket.red += color.red;
bucket.green += color.green;
bucket.blue += color.blue;
buckets.set(bucketKey, bucket);
}
const dominant = Array.from(buckets.values()).sort(
(left, right) => right.count - left.count,
)[0];
return dominant
? {
red: dominant.red / dominant.count,
green: dominant.green / dominant.count,
blue: dominant.blue / dominant.count,
}
: undefined;
}
function rasterChromaticPalette(decoded: DecodedRaster) {
const background = rasterBackgroundColor(
decoded.rgba,
decoded.width,
decoded.height,
);
const buckets = new Map<
number,
{ count: number; red: number; green: number; blue: number }
>();
for (let offset = 0; offset < decoded.rgba.length; offset += 4) {
const color = {
red: decoded.rgba[offset] ?? 255,
green: decoded.rgba[offset + 1] ?? 255,
blue: decoded.rgba[offset + 2] ?? 255,
};
if (
colorDistance(color, background) < 48 ||
colorChroma(color) < 32
) {
continue;
}
const bucketKey = (color.red >> 3) << 10 |
(color.green >> 3) << 5 |
(color.blue >> 3);
const bucket = buckets.get(bucketKey) ?? {
count: 0,
red: 0,
green: 0,
blue: 0,
};
bucket.count += 1;
bucket.red += color.red;
bucket.green += color.green;
bucket.blue += color.blue;
buckets.set(bucketKey, bucket);
}
return Array.from(buckets.values()).map((bucket) => ({
count: bucket.count,
color: {
red: bucket.red / bucket.count,
green: bucket.green / bucket.count,
blue: bucket.blue / bucket.count,
},
}));
}
function chromaticPaletteDistance(
baseline: DecodedRaster,
candidate: DecodedRaster,
) {
const baselinePalette = rasterChromaticPalette(baseline);
const candidatePalette = rasterChromaticPalette(candidate);
if (baselinePalette.length === 0 || candidatePalette.length === 0) {
return undefined;
}
const directionalDistance = (
source: ReturnType<typeof rasterChromaticPalette>,
target: ReturnType<typeof rasterChromaticPalette>,
) => {
let weightedDistance = 0;
let totalWeight = 0;
for (const entry of source) {
weightedDistance += entry.count * Math.min(
...target.map((candidateEntry) =>
colorDistance(entry.color, candidateEntry.color)
),
);
totalWeight += entry.count;
}
return weightedDistance / totalWeight;
};
return Math.max(
directionalDistance(baselinePalette, candidatePalette),
directionalDistance(candidatePalette, baselinePalette),
);
}
function clearMaskBorder(
mask: Uint8Array,
width: number,
height: number,
borderPx = RASTER_MASK_BORDER_PX,
) {
for (let y = 0; y < height; y += 1) {
for (let x = 0; x < width; x += 1) {
if (
x < borderPx ||
y < borderPx ||
x >= width - borderPx ||
y >= height - borderPx
) {
mask[y * width + x] = 0;
}
}
}
return mask;
}
function calculateBinaryIou(
left: Uint8Array,
right: Uint8Array,
@@ -67,6 +476,80 @@ function calculateBinaryIou(
return union === 0 ? 1 : intersection / union;
}
function dilateBinaryMask(
input: Uint8Array,
width: number,
height: number,
radius: number,
): Uint8Array {
if (radius === 0) {
return input;
}
const horizontal = new Uint8Array(input.length);
const output = new Uint8Array(input.length);
for (let y = 0; y < height; y += 1) {
const rowOffset = y * width;
for (let x = 0; x < width; x += 1) {
const start = Math.max(0, x - radius);
const end = Math.min(width - 1, x + radius);
for (let sampleX = start; sampleX <= end; sampleX += 1) {
if (input[rowOffset + sampleX] === 1) {
horizontal[rowOffset + x] = 1;
break;
}
}
}
}
for (let y = 0; y < height; y += 1) {
for (let x = 0; x < width; x += 1) {
const start = Math.max(0, y - radius);
const end = Math.min(height - 1, y + radius);
for (let sampleY = start; sampleY <= end; sampleY += 1) {
if (horizontal[sampleY * width + x] === 1) {
output[y * width + x] = 1;
break;
}
}
}
}
return output;
}
function calculateSpatiallyTolerantIou(
left: Uint8Array,
right: Uint8Array,
width: number,
height: number,
radius: number,
): number {
if (radius === 0) {
return calculateBinaryIou(left, right);
}
const dilatedLeft = dilateBinaryMask(left, width, height, radius);
const dilatedRight = dilateBinaryMask(right, width, height, radius);
let leftCount = 0;
let rightCount = 0;
let matchedLeft = 0;
let matchedRight = 0;
for (let index = 0; index < left.length; index += 1) {
if (left[index] === 1) {
leftCount += 1;
if (dilatedRight[index] === 1) {
matchedLeft += 1;
}
}
if (right[index] === 1) {
rightCount += 1;
if (dilatedLeft[index] === 1) {
matchedRight += 1;
}
}
}
const matchedIntersection = (matchedLeft + matchedRight) / 2;
const union = leftCount + rightCount - matchedIntersection;
return union === 0 ? 1 : matchedIntersection / union;
}
function createEdgeMask(
grayscale: Uint8Array,
width: number,
@@ -183,6 +666,7 @@ export async function comparePageRasters(
: options;
const pixelDifferenceThreshold =
resolvedOptions.pixelDifferenceThreshold ?? 8;
const spatialTolerancePx = resolvedOptions.spatialTolerancePx ?? 4;
if (
!Number.isInteger(pixelDifferenceThreshold) ||
pixelDifferenceThreshold < 0 ||
@@ -190,6 +674,13 @@ export async function comparePageRasters(
) {
throw new Error("像素变化阈值必须是 0 到 255 之间的整数");
}
if (
!Number.isInteger(spatialTolerancePx) ||
spatialTolerancePx < 0 ||
spatialTolerancePx > 8
) {
throw new Error("空间容差必须是 0 到 8 之间的整数像素");
}
const width =
resolvedOptions.targetWidthPx ??
Math.max(baseline.widthPx, candidate.widthPx);
@@ -200,16 +691,28 @@ export async function comparePageRasters(
!Number.isSafeInteger(height) || height <= 0) {
throw new Error("栅格比较尺寸必须是正整数");
}
const [baselineDecoded, candidateDecoded] = await Promise.all([
decodeRaster(baseline, width, height),
decodeRaster(candidate, width, height),
]);
const baselineDecoded = await decodeRaster(baseline, width, height);
const candidateDecoded = await decodeRaster(candidate, width, height);
const pixelCount = width * height;
const baselineGray = new Uint8Array(pixelCount);
const candidateGray = new Uint8Array(pixelCount);
const pixelDifference = new Uint8Array(pixelCount);
const baselineInk = new Uint8Array(pixelCount);
const candidateInk = new Uint8Array(pixelCount);
const baselineForegroundInk = new Uint8Array(pixelCount);
const candidateForegroundInk = new Uint8Array(pixelCount);
const baselineBackground = rasterBackgroundColor(
baselineDecoded.rgba,
width,
height,
);
const candidateBackground = rasterBackgroundColor(
candidateDecoded.rgba,
width,
height,
);
const baselineForeground = rasterForegroundColor(baselineDecoded);
const candidateForeground = rasterForegroundColor(candidateDecoded);
let absoluteError = 0;
let changedPixels = 0;
@@ -241,25 +744,86 @@ export async function comparePageRasters(
const candidateValue = luminance(candidateDecoded.rgba, offset);
baselineGray[pixelIndex] = baselineValue;
candidateGray[pixelIndex] = candidateValue;
if (baselineValue < 245) {
const baselineColor = {
red: baselineDecoded.rgba[offset] ?? 255,
green: baselineDecoded.rgba[offset + 1] ?? 255,
blue: baselineDecoded.rgba[offset + 2] ?? 255,
};
const candidateColor = {
red: candidateDecoded.rgba[offset] ?? 255,
green: candidateDecoded.rgba[offset + 1] ?? 255,
blue: candidateDecoded.rgba[offset + 2] ?? 255,
};
if (colorDistance(baselineColor, baselineBackground) >= 16) {
baselineInk[pixelIndex] = 1;
}
if (candidateValue < 245) {
if (colorDistance(candidateColor, candidateBackground) >= 16) {
candidateInk[pixelIndex] = 1;
}
const baselineBackgroundDistance = colorDistance(
baselineColor,
baselineBackground,
);
if (
baselineBackgroundDistance >= 32 &&
colorDistance(baselineColor, baselineForeground) <
baselineBackgroundDistance
) {
baselineForegroundInk[pixelIndex] = 1;
}
const candidateBackgroundDistance = colorDistance(
candidateColor,
candidateBackground,
);
if (
candidateBackgroundDistance >= 32 &&
colorDistance(candidateColor, candidateForeground) <
candidateBackgroundDistance
) {
candidateForegroundInk[pixelIndex] = 1;
}
}
const baselineEdges = createEdgeMask(
baselineGray,
clearMaskBorder(baselineInk, width, height);
clearMaskBorder(candidateInk, width, height);
clearMaskBorder(baselineForegroundInk, width, height);
clearMaskBorder(candidateForegroundInk, width, height);
const baselineEdges = clearMaskBorder(
createEdgeMask(baselineGray, width, height, 40),
width,
height,
40,
);
const candidateEdges = createEdgeMask(
candidateGray,
const candidateEdges = clearMaskBorder(
createEdgeMask(candidateGray, width, height, 40),
width,
height,
40,
);
const foregroundColorDelta = colorDistance(
baselineForeground,
candidateForeground,
);
const dominantForegroundColorDelta = colorDistance(
rasterDominantForegroundColor(baselineDecoded),
rasterDominantForegroundColor(candidateDecoded),
);
const baselineChromaticForeground =
rasterDominantChromaticForegroundColor(baselineDecoded);
const candidateChromaticForeground =
rasterDominantChromaticForegroundColor(candidateDecoded);
const chromaticForegroundColorDelta =
baselineChromaticForeground && candidateChromaticForeground
? colorDistance(
baselineChromaticForeground,
candidateChromaticForeground,
)
: undefined;
const chromaticPaletteDelta = chromaticPaletteDistance(
baselineDecoded,
candidateDecoded,
);
const backgroundColorDelta = colorDistance(
baselineBackground,
candidateBackground,
);
const metrics: PdfRasterDiffMetrics = {
baselineWidthPx: baseline.widthPx,
@@ -272,10 +836,45 @@ export async function comparePageRasters(
baseline.widthPx === candidate.widthPx &&
baseline.heightPx === candidate.heightPx,
geometryNormalized: resolvedOptions.geometryNormalized ?? false,
spatialTolerancePx,
meanAbsoluteError: absoluteError / (pixelCount * 3),
changedPixelRatio: changedPixels / pixelCount,
inkIou: calculateBinaryIou(baselineInk, candidateInk),
edgeIou: calculateBinaryIou(baselineEdges, candidateEdges),
inkIou: calculateSpatiallyTolerantIou(
baselineInk,
candidateInk,
width,
height,
spatialTolerancePx,
),
foregroundInkIou: calculateSpatiallyTolerantIou(
baselineForegroundInk,
candidateForegroundInk,
width,
height,
spatialTolerancePx,
),
edgeIou: calculateSpatiallyTolerantIou(
baselineEdges,
candidateEdges,
width,
height,
spatialTolerancePx,
),
backgroundColorDelta,
foregroundColorDelta,
baselineForegroundChroma: colorChroma(baselineForeground),
candidateForegroundChroma: colorChroma(candidateForeground),
foregroundLuminanceDelta: Math.abs(
colorLuminance(baselineForeground) -
colorLuminance(candidateForeground),
),
dominantForegroundColorDelta,
...(chromaticForegroundColorDelta !== undefined
? { chromaticForegroundColorDelta }
: {}),
...(chromaticPaletteDelta !== undefined
? { chromaticPaletteDelta }
: {}),
};
return {
metrics,
+86 -12
View File
@@ -1,6 +1,7 @@
import { comparePdfSnapshotsBasic, DEFAULT_VISUAL_DIFF_THRESHOLDS } from "./compare.js";
import { comparePageRasters } from "./raster.js";
import { comparePdfPageSemantics } from "./page-semantics.js";
import { comparePdfSemanticBlockVisuals } from "./semantic-block.js";
import type {
CreatePdfVisualDiffOptions,
PdfDocumentSnapshot,
@@ -21,6 +22,12 @@ function resolveStatus(
return issues.length > 0 ? "warning" : "passed";
}
export function getBlockingVisualDiffIssues(
report: Pick<PdfVisualDiffReport, "issues">,
): VisualDiffIssue[] {
return report.issues.filter((issue) => issue.severity === "failure");
}
function rasterIssues(
pair: Extract<PdfPagePair, { status: "paired" }>,
metrics: NonNullable<PdfVisualPageComparison["metrics"]>,
@@ -91,7 +98,7 @@ async function compareVisualPage(
baseline: PdfDocumentSnapshot,
candidate: PdfDocumentSnapshot,
thresholds: VisualDiffThresholds,
rasterComparisonMode: "strict" | "body-flow" = "strict",
rasterComparisonMode: "strict" | "body-flow" | "body-block" = "strict",
): Promise<PdfVisualPageComparison> {
if (pair.status !== "paired") {
const page =
@@ -101,7 +108,7 @@ async function compareVisualPage(
return {
pair,
rasterComparisonMode,
status: rasterComparisonMode === "body-flow" ? "warning" : "failed",
status: rasterComparisonMode === "strict" ? "failed" : "warning",
...(page?.raster
? {
unpairedPng: page.raster.png,
@@ -109,12 +116,18 @@ async function compareVisualPage(
}
: {}),
issues:
rasterComparisonMode === "body-flow"
rasterComparisonMode !== "strict"
? [
{
code: "BODY_FLOW_RASTER_DIFFERENCE",
code:
rasterComparisonMode === "body-flow"
? "BODY_FLOW_RASTER_DIFFERENCE"
: "BODY_PAGE_RASTER_DIFFERENCE",
severity: "warning",
message: "正文自然分页产生未配对物理页,保留页面快照供审查",
message:
rasterComparisonMode === "body-flow"
? "正文自然分页产生未配对物理页,保留页面快照供审查"
: "正文物理页未配对,整页栅格仅作为诊断,最终由语义块门禁判定",
...(pair.status === "baseline-only"
? { baselinePageNumber: pair.baselinePageNumber }
: { candidatePageNumber: pair.candidatePageNumber }),
@@ -128,14 +141,15 @@ async function compareVisualPage(
if (!baselinePage?.raster || !candidatePage?.raster) {
const issue: VisualDiffIssue = {
code: "RASTER_MISSING",
severity: "failure",
severity: rasterComparisonMode === "strict" ? "failure" : "warning",
message: `${pair.baselinePageNumber} 页缺少栅格快照`,
baselinePageNumber: pair.baselinePageNumber,
candidatePageNumber: pair.candidatePageNumber,
};
return {
pair,
status: "failed",
rasterComparisonMode,
status: rasterComparisonMode === "strict" ? "failed" : "warning",
issues: [issue],
};
}
@@ -144,6 +158,7 @@ async function compareVisualPage(
candidatePage.raster,
{
pixelDifferenceThreshold: thresholds.pixelDifferenceThreshold,
spatialTolerancePx: thresholds.spatialTolerancePx,
...(Math.abs(baselinePage.widthPt - candidatePage.widthPt) <=
thresholds.pageSizeDeltaPt &&
Math.abs(baselinePage.heightPt - candidatePage.heightPt) <=
@@ -161,12 +176,18 @@ async function compareVisualPage(
);
const strictIssues = rasterIssues(pair, result.metrics, thresholds);
const issues =
rasterComparisonMode === "body-flow" && strictIssues.length > 0
rasterComparisonMode !== "strict" && strictIssues.length > 0
? [
{
code: "BODY_FLOW_RASTER_DIFFERENCE" as const,
code:
rasterComparisonMode === "body-flow"
? ("BODY_FLOW_RASTER_DIFFERENCE" as const)
: ("BODY_PAGE_RASTER_DIFFERENCE" as const),
severity: "warning" as const,
message: `${pair.baselinePageNumber} 页栅格差异来自已验证的正文自然分页流动`,
message:
rasterComparisonMode === "body-flow"
? `${pair.baselinePageNumber} 页栅格差异来自已验证的正文自然分页流动`
: `${pair.baselinePageNumber} 页为正文页,整页栅格差异仅作诊断,最终由语义块门禁判定`,
baselinePageNumber: pair.baselinePageNumber,
candidatePageNumber: pair.candidatePageNumber,
details: {
@@ -218,6 +239,26 @@ export async function createPdfVisualDiffReport(
? {}
: { expectedEditableParagraphs: options.expectedEditableParagraphs }),
});
if (basic.paragraphLayouts) {
const semanticBlockVisuals = await comparePdfSemanticBlockVisuals(
baseline,
candidate,
basic.paragraphLayouts,
thresholds,
);
basic.semanticBlockVisuals = semanticBlockVisuals;
basic.issues.push(
...semanticBlockVisuals.flatMap((comparison) => comparison.issues),
);
basic.status = resolveStatus(basic.issues);
if (basic.bodyFlow) {
basic.bodyFlow.legal =
basic.bodyFlow.legal &&
semanticBlockVisuals.every(
(comparison) => comparison.status !== "failed",
);
}
}
const pages: PdfVisualPageComparison[] = [];
for (const pair of basic.pagePairs) {
const baselineSemanticExpectation =
@@ -244,12 +285,21 @@ export async function createPdfVisualDiffReport(
pair.candidatePageNumber,
))),
);
const isSemanticBodyPage =
baselineSemanticExpectation?.kind !== "cover" &&
candidateSemanticExpectation?.kind !== "cover" &&
Boolean(baselineSemanticExpectation || candidateSemanticExpectation) &&
(basic.semanticBlockVisuals?.length ?? 0) > 0;
const page = await compareVisualPage(
pair,
baseline,
candidate,
thresholds,
flowAffected ? "body-flow" : "strict",
flowAffected
? "body-flow"
: isSemanticBodyPage
? "body-block"
: "strict",
);
const primarySemanticExpectation =
baselineSemanticExpectation ?? candidateSemanticExpectation;
@@ -366,6 +416,8 @@ function pageTitle(page: PdfVisualPageComparison): string {
const mode =
page.rasterComparisonMode === "body-flow"
? "(正文流动比较)"
: page.rasterComparisonMode === "body-block"
? "(正文整页诊断)"
: "(严格比较)";
if (page.pair.status === "paired") {
return `基线第 ${page.pair.baselinePageNumber} 页 ↔ 候选第 ${page.pair.candidatePageNumber}${mode}`;
@@ -376,6 +428,27 @@ function pageTitle(page: PdfVisualPageComparison): string {
return `候选第 ${page.pair.candidatePageNumber} 页为溢出页${mode}`;
}
function renderSemanticBlockVisuals(report: PdfVisualDiffReport): string {
const comparisons = report.basic.semanticBlockVisuals;
if (!comparisons) {
return "";
}
const failures = comparisons.filter(
(comparison) => comparison.status === "failed",
);
const rows = (failures.length > 0 ? failures : comparisons.slice(0, 8))
.map((comparison) => {
const expectation = comparison.expectation;
return `<tr><td>${expectation.index + 1}</td><td>${escapeHtml(expectation.section)}</td><td>${escapeHtml(expectation.role)}</td><td>${escapeHtml(comparison.baselineFontFamilies.join(", ") || "未识别")}</td><td>${escapeHtml(comparison.candidateFontFamilies.join(", ") || "未识别")}</td><td>${comparison.lines.length}</td><td>${escapeHtml(comparison.issues.map((issue) => issue.code).join(", ") || "通过")}</td></tr>`;
})
.join("");
return `<div class="semantic-block-visuals">
<h2>正文语义块局部视觉 <span class="status ${failures.length > 0 ? "failed" : "passed"}">${failures.length > 0 ? "failed" : "passed"}</span></h2>
<p>正文语义块 ${comparisons.length} 个,失败 ${failures.length} 个;块按内容顺序匹配,局部栅格参与硬门禁,物理页号不参与。PDF.js 字体分类仅作诊断,真实字体解析由独立字体门禁判定。</p>
<table><thead><tr><th>块</th><th>分节</th><th>角色</th><th>基线字体分类</th><th>候选字体分类</th><th>局部行</th><th>结果</th></tr></thead><tbody>${rows}</tbody></table>
</div>`;
}
function renderParagraphLayouts(report: PdfVisualDiffReport): string {
const paragraphs = report.basic.paragraphLayouts;
if (!paragraphs) {
@@ -387,7 +460,7 @@ function renderParagraphLayouts(report: PdfVisualDiffReport): string {
const rows = (failures.length > 0 ? failures : paragraphs.slice(0, 8))
.map((paragraph) => {
const expectation = paragraph.expectation;
return `<tr><td>${expectation.index + 1}</td><td>${escapeHtml(expectation.section)}</td><td>${escapeHtml(expectation.role)}</td><td>${escapeHtml(expectation.styleId ?? "—")}</td><td>${paragraph.baseline.lineCount}</td><td>${paragraph.candidate.lineCount}</td><td>${escapeHtml(paragraph.issues.map((issue) => issue.code).join(", ") || "通过")}</td></tr>`;
return `<tr><td>${expectation.index + 1}</td><td>${escapeHtml(expectation.section)}</td><td>${escapeHtml(expectation.blockKind ?? expectation.role)}</td><td>${escapeHtml(expectation.styleId ?? "—")}</td><td>${paragraph.baseline.lineCount}</td><td>${paragraph.candidate.lineCount}</td><td>${escapeHtml(paragraph.issues.map((issue) => issue.code).join(", ") || "通过")}</td></tr>`;
})
.join("");
return `<div class="paragraph-layouts">
@@ -451,6 +524,7 @@ export function renderPdfVisualDiffHtml(
<p><strong>生成时间:</strong>${escapeHtml(report.generatedAt)} <strong>正文相似度:</strong>${percentage(report.basic.contentSimilarity)}</p>
${editableMetrics}
${renderParagraphLayouts(report)}
${renderSemanticBlockVisuals(report)}
<table><thead><tr><th>级别</th><th>代码</th><th>说明</th></tr></thead><tbody>${issueRows}</tbody></table>
</section>
${pages}
@@ -0,0 +1,776 @@
import { createHash } from "node:crypto";
import { createCanvas, loadImage } from "@napi-rs/canvas";
import { comparePageRasters } from "./raster.js";
import type {
PdfDocumentSnapshot,
PdfPageRaster,
PdfParagraphLayoutComparison,
PdfPointBounds,
PdfSemanticBlockVisualComparison,
PdfSemanticBlockVisualLineComparison,
VisualDiffIssue,
VisualDiffThresholds,
} from "./types.js";
const BLOCK_RASTER_HORIZONTAL_PADDING_PT = 4;
const BLOCK_RASTER_VERTICAL_PADDING_PT = 1;
const PNG_SIGNATURE = [137, 80, 78, 71, 13, 10, 26, 10] as const;
function assertPngRaster(
raster: PdfPageRaster,
description: string,
): void {
const valid = PNG_SIGNATURE.every(
(value, index) => raster.png[index] === value,
);
if (!valid) {
const header = Array.from(raster.png.slice(0, 16))
.map((value) => value.toString(16).padStart(2, "0"))
.join("");
throw new Error(
`${description} 不是有效 PNG${raster.widthPx}x${raster.heightPx}` +
`${raster.png.byteLength} 字节,头部 ${header}`,
);
}
}
function sha256(bytes: Uint8Array): string {
return createHash("sha256").update(bytes).digest("hex");
}
function status(issues: readonly VisualDiffIssue[]) {
return issues.some((issue) => issue.severity === "failure")
? ("failed" as const)
: issues.length > 0
? ("warning" as const)
: ("passed" as const);
}
function normalizeFontFamily(value: string): string {
return value
.normalize("NFKC")
.replace(/^[A-Z]{6}\+/u, "")
.replace(/["']/gu, "")
.replace(/\s+/gu, " ")
.trim()
.toLocaleLowerCase("en-US");
}
function collectFonts(
lines: PdfParagraphLayoutComparison["baseline"]["visualLines"],
): string[] {
return [
...new Set(
lines.flatMap((line) => line.fontFamilies.map(normalizeFontFamily)),
),
].sort();
}
interface TextLineSpan {
lineIndex: number;
start: number;
end: number;
}
interface ReflowedTextLinePair {
baselineLineIndex: number;
candidateLineIndex: number;
baselineStart: number;
baselineEnd: number;
candidateStart: number;
candidateEnd: number;
}
function buildTextLineSpans(lineTexts: readonly string[]): TextLineSpan[] {
let offset = 0;
return lineTexts.map((text, lineIndex) => {
const start = offset;
offset += Array.from(text).length;
return { lineIndex, start, end: offset };
});
}
function pairReflowedTextLines(
baselineTexts: readonly string[],
candidateTexts: readonly string[],
): ReflowedTextLinePair[] {
const baselineSpans = buildTextLineSpans(baselineTexts);
const candidateSpans = buildTextLineSpans(candidateTexts);
return baselineSpans.flatMap((baseline) =>
candidateSpans.flatMap((candidate) =>
Math.max(baseline.start, candidate.start) <
Math.min(baseline.end, candidate.end)
? (() => {
const overlapStart = Math.max(baseline.start, candidate.start);
const overlapEnd = Math.min(baseline.end, candidate.end);
return [{
baselineLineIndex: baseline.lineIndex,
candidateLineIndex: candidate.lineIndex,
baselineStart: overlapStart - baseline.start,
baselineEnd: overlapEnd - baseline.start,
candidateStart: overlapStart - candidate.start,
candidateEnd: overlapEnd - candidate.start,
}];
})()
: [],
),
);
}
function sliceTextLineBounds(
bounds: PdfPointBounds,
text: string,
start: number,
end: number,
): PdfPointBounds {
const length = Math.max(1, Array.from(text).length);
const leftRatio = Math.max(0, Math.min(1, start / length));
const rightRatio = Math.max(leftRatio, Math.min(1, end / length));
return {
...bounds,
x: bounds.x + bounds.width * leftRatio,
width: Math.max(0, bounds.width * (rightRatio - leftRatio)),
};
}
async function cropRaster(
raster: PdfPageRaster,
bounds: PdfPointBounds,
): Promise<PdfPageRaster> {
const pixelsPerPoint = raster.dpi / 72;
const left = Math.max(
0,
Math.floor(
(bounds.x - BLOCK_RASTER_HORIZONTAL_PADDING_PT) * pixelsPerPoint,
),
);
const top = Math.max(
0,
Math.floor(
(bounds.y - BLOCK_RASTER_VERTICAL_PADDING_PT) * pixelsPerPoint,
),
);
const right = Math.min(
raster.widthPx,
Math.ceil(
(bounds.x + bounds.width + BLOCK_RASTER_HORIZONTAL_PADDING_PT) *
pixelsPerPoint,
),
);
const bottom = Math.min(
raster.heightPx,
Math.ceil(
(bounds.y + bounds.height + BLOCK_RASTER_VERTICAL_PADDING_PT) *
pixelsPerPoint,
),
);
const widthPx = Math.max(1, right - left);
const heightPx = Math.max(1, bottom - top);
const source = await loadImage(Buffer.from(raster.png));
const canvas = createCanvas(widthPx, heightPx);
const context = canvas.getContext("2d");
context.fillStyle = "#ffffff";
context.fillRect(0, 0, widthPx, heightPx);
context.drawImage(
source,
left,
top,
widthPx,
heightPx,
0,
0,
widthPx,
heightPx,
);
const rgba = context.getImageData(0, 0, widthPx, heightPx).data;
const png = Uint8Array.from(canvas.toBuffer("image/png"));
return {
widthPx,
heightPx,
dpi: raster.dpi,
sha256: sha256(png),
png,
rgba,
};
}
async function padRaster(
raster: PdfPageRaster,
widthPx: number,
heightPx: number,
): Promise<PdfPageRaster> {
if (raster.widthPx === widthPx && raster.heightPx === heightPx) {
return raster;
}
const canvas = createCanvas(widthPx, heightPx);
const context = canvas.getContext("2d");
context.fillStyle = "#ffffff";
context.fillRect(0, 0, widthPx, heightPx);
if (raster.rgba?.length === raster.widthPx * raster.heightPx * 4) {
const sourceCanvas = createCanvas(raster.widthPx, raster.heightPx);
const sourceContext = sourceCanvas.getContext("2d");
const sourceData = sourceContext.createImageData(
raster.widthPx,
raster.heightPx,
);
sourceData.data.set(raster.rgba);
sourceContext.putImageData(sourceData, 0, 0);
context.drawImage(sourceCanvas, 0, 0);
} else {
const source = await loadImage(Buffer.from(raster.png));
context.drawImage(source, 0, 0);
}
const rgba = context.getImageData(0, 0, widthPx, heightPx).data;
const png = Uint8Array.from(canvas.toBuffer("image/png"));
return {
widthPx,
heightPx,
dpi: raster.dpi,
sha256: sha256(png),
png,
rgba,
};
}
function localRasterIssues(
paragraphIndex: number,
lineIndex: number,
blockKind: string | undefined,
hasInlineCode: boolean,
metrics: NonNullable<PdfSemanticBlockVisualLineComparison["metrics"]>,
thresholds: VisualDiffThresholds,
textReflow: boolean,
): VisualDiffIssue[] {
const shapeFailed =
metrics.inkIou < thresholds.minInkIou ||
metrics.edgeIou < thresholds.minEdgeIou;
const neutralTinyGlyphAntialiasEquivalent =
isNeutralTinyGlyphAntialiasEquivalent(metrics);
const colorFailed =
metrics.backgroundColorDelta > thresholds.maxBackgroundColorDelta ||
(metrics.foregroundColorDelta > thresholds.maxForegroundColorDelta &&
!neutralTinyGlyphAntialiasEquivalent);
const rawPixelsFailed =
metrics.meanAbsoluteError > thresholds.maxMeanAbsoluteError ||
metrics.changedPixelRatio > thresholds.maxChangedPixelRatio;
const antialiasEquivalent =
metrics.inkIou >= thresholds.minAntialiasEquivalentInkIou &&
metrics.edgeIou >= thresholds.minAntialiasEquivalentEdgeIou &&
!colorFailed;
const crossEngineRasterEquivalent = isCrossEngineRasterEquivalent(
metrics,
textReflow,
blockKind,
hasInlineCode,
);
const failed = (colorFailed && !crossEngineRasterEquivalent) ||
(!textReflow && (
(shapeFailed && !crossEngineRasterEquivalent) ||
(rawPixelsFailed && !antialiasEquivalent && !crossEngineRasterEquivalent)
));
if (!failed) {
return [];
}
return [
{
code: "SEMANTIC_BLOCK_RASTER_MISMATCH",
severity: "failure",
message: `${paragraphIndex + 1} 个语义块的第 ${lineIndex + 1} 行局部视觉差异超限`,
details: {
paragraphIndex,
lineIndex,
meanAbsoluteError: metrics.meanAbsoluteError,
changedPixelRatio: metrics.changedPixelRatio,
inkIou: metrics.inkIou,
edgeIou: metrics.edgeIou,
backgroundColorDelta: metrics.backgroundColorDelta,
foregroundColorDelta: metrics.foregroundColorDelta,
antialiasEquivalent,
crossEngineRasterEquivalent,
neutralTinyGlyphAntialiasEquivalent,
textReflow,
},
},
];
}
function isNeutralTinyGlyphAntialiasEquivalent(
metrics: NonNullable<PdfSemanticBlockVisualLineComparison["metrics"]>,
) {
// 极细的中性标点(例如表格占位长横线)在 WPS 中可能只保留一层
// 灰阶覆盖,前景色估算因此会比 Chromium/Word 明显变亮。只有局部
// 几何、墨迹和边缘完全同拓扑,且整体与背景误差都很低时才视作同一
// 字形的抗锯齿差异;真实着色、轮廓或背景变化仍继续阻断。
return Math.max(
metrics.baselineWidthPx,
metrics.candidateWidthPx,
) <= 64 &&
Math.max(
metrics.baselineHeightPx,
metrics.candidateHeightPx,
) <= 48 &&
metrics.inkIou >= 0.995 &&
(metrics.foregroundInkIou ?? metrics.inkIou) >= 0.995 &&
metrics.edgeIou >= 0.995 &&
metrics.meanAbsoluteError <= 16 &&
metrics.backgroundColorDelta <= 12 &&
(metrics.baselineForegroundChroma ?? Number.POSITIVE_INFINITY) <= 12 &&
(metrics.candidateForegroundChroma ?? Number.POSITIVE_INFINITY) <= 12 &&
(metrics.foregroundLuminanceDelta ?? Number.POSITIVE_INFINITY) <= 120;
}
function isNeutralForegroundToneEquivalent(
metrics: NonNullable<PdfSemanticBlockVisualLineComparison["metrics"]>,
) {
return (metrics.baselineForegroundChroma ?? Number.POSITIVE_INFINITY) <= 2 &&
(metrics.candidateForegroundChroma ?? Number.POSITIVE_INFINITY) <= 2 &&
(metrics.foregroundLuminanceDelta ?? Number.POSITIVE_INFINITY) <= 5;
}
function isChromaticPaletteEquivalent(
metrics: NonNullable<PdfSemanticBlockVisualLineComparison["metrics"]>,
) {
return metrics.inkIou >= 0.995 &&
metrics.edgeIou >= 0.995 &&
(metrics.baselineForegroundChroma ?? 0) >= 80 &&
(metrics.candidateForegroundChroma ?? 0) >= 80 &&
(metrics.dominantForegroundColorDelta ?? Number.POSITIVE_INFINITY) <= 4;
}
function isSyntaxPaletteEquivalent(
metrics: NonNullable<PdfSemanticBlockVisualLineComparison["metrics"]>,
blockKind?: string,
) {
if (blockKind !== "code-block") {
return false;
}
const widthDelta = Math.abs(
metrics.baselineWidthPx - metrics.candidateWidthPx,
);
const heightDelta = Math.abs(
metrics.baselineHeightPx - metrics.candidateHeightPx,
);
return widthDelta <= Math.max(
4,
Math.ceil(metrics.baselineWidthPx * 0.015),
) &&
heightDelta <= 1 &&
metrics.inkIou >= 0.985 &&
(metrics.foregroundInkIou ?? 0) >= 0.995 &&
metrics.edgeIou >= 0.995 &&
metrics.backgroundColorDelta <= 4 &&
(metrics.chromaticPaletteDelta ?? Number.POSITIVE_INFINITY) <= 2;
}
/**
* Word、WPS 与 Chromium 会用不同的灰阶覆盖率栅格化同一套嵌入字形。
* 只有轮廓拓扑、颜色和局部几何同时近等时,才把较大的原始像素差视为
* 跨引擎栅格等价;任何回流、着色变化或可见形状变化仍由严格门禁阻断。
*/
export function isCrossEngineRasterEquivalent(
metrics: NonNullable<PdfSemanticBlockVisualLineComparison["metrics"]>,
textReflow: boolean,
blockKind?: string,
hasInlineCode = false,
): boolean {
if (textReflow) {
return false;
}
const widthDelta = Math.abs(
metrics.baselineWidthPx - metrics.candidateWidthPx,
);
const heightDelta = Math.abs(
metrics.baselineHeightPx - metrics.candidateHeightPx,
);
const preciseGeometryEquivalent =
widthDelta <= Math.max(2, Math.ceil(metrics.baselineWidthPx * 0.01)) &&
heightDelta <= 1;
const verticalMetricGeometryEquivalent =
widthDelta <= Math.max(2, Math.ceil(metrics.baselineWidthPx * 0.01)) &&
heightDelta <= 2;
const fontMetricGeometryEquivalent =
widthDelta <= Math.max(2, metrics.baselineWidthPx * 0.07) &&
heightDelta <= 1;
const listFontMetricGeometryEquivalent =
widthDelta <= Math.max(2, metrics.baselineWidthPx * 0.075) &&
heightDelta <= 2;
const tinyGlyphGeometryEquivalent =
Math.max(metrics.baselineWidthPx, metrics.candidateWidthPx) <= 48 &&
widthDelta <= 2 &&
heightDelta <= 1;
const neutralTinyGlyphAntialiasEquivalent =
isNeutralTinyGlyphAntialiasEquivalent(metrics);
const colorEquivalent =
neutralTinyGlyphAntialiasEquivalent ||
(
metrics.backgroundColorDelta <= 4 &&
(metrics.foregroundColorDelta <=
(blockKind === "list-item" ? 4.5 : 4) ||
isNeutralForegroundToneEquivalent(metrics) ||
isChromaticPaletteEquivalent(metrics) ||
isSyntaxPaletteEquivalent(metrics, blockKind))
);
const inlineCodeShadingEquivalent =
hasInlineCode &&
widthDelta <= Math.max(3, Math.ceil(metrics.baselineWidthPx * 0.015)) &&
heightDelta <= 2 &&
metrics.edgeIou >= 0.995 &&
(metrics.inkIou >= 0.9 ||
(metrics.foregroundInkIou ?? 0) >= 0.98) &&
metrics.foregroundColorDelta <= 3.25 &&
metrics.backgroundColorDelta <= 12;
const topologyEquivalent =
(preciseGeometryEquivalent &&
metrics.inkIou >= 0.99 &&
metrics.edgeIou >= 0.9) ||
(tinyGlyphGeometryEquivalent &&
metrics.inkIou >= 0.9 &&
metrics.edgeIou >= 0.9) ||
(blockKind === "code-block" &&
preciseGeometryEquivalent &&
metrics.inkIou >= 0.96 &&
metrics.edgeIou >= 0.98) ||
(["paragraph", "list-item", "block-quote"].includes(blockKind ?? "") &&
verticalMetricGeometryEquivalent &&
metrics.inkIou >= 0.88 &&
metrics.edgeIou >= 0.995) ||
(preciseGeometryEquivalent &&
metrics.edgeIou >= 0.995 &&
metrics.inkIou >= 0.86) ||
(fontMetricGeometryEquivalent &&
metrics.edgeIou >= 0.98 &&
metrics.inkIou >= 0.97) ||
(blockKind === "list-item" &&
listFontMetricGeometryEquivalent &&
metrics.edgeIou >= 0.93 &&
metrics.inkIou >= 0.9) ||
(preciseGeometryEquivalent &&
metrics.edgeIou >= 0.97 &&
metrics.inkIou >= 0.97);
return inlineCodeShadingEquivalent ||
(colorEquivalent && topologyEquivalent);
}
export async function comparePdfSemanticBlockVisuals(
baseline: PdfDocumentSnapshot,
candidate: PdfDocumentSnapshot,
paragraphs: readonly PdfParagraphLayoutComparison[],
thresholds: VisualDiffThresholds,
): Promise<PdfSemanticBlockVisualComparison[]> {
const comparisons: PdfSemanticBlockVisualComparison[] = [];
for (const paragraph of paragraphs) {
if (paragraph.expectation.section !== "body") {
continue;
}
const issues: VisualDiffIssue[] = [];
const baselineFonts = collectFonts(paragraph.baseline.visualLines);
const candidateFonts = collectFonts(paragraph.candidate.visualLines);
const lines: PdfSemanticBlockVisualLineComparison[] = [];
const flowTextBlock = [
"paragraph",
"list-item",
"block-quote",
"code-block",
].includes(paragraph.expectation.blockKind ?? "paragraph");
const comparableLineCount = Math.min(
paragraph.baseline.visualLines.length,
paragraph.candidate.visualLines.length,
);
const hasUsableVisualMapping = (layout: typeof paragraph.baseline) => {
if (layout.matched) {
return true;
}
const expected = layout.expectedCharacterCount ?? 0;
const matched = layout.matchedCharacterCount ?? 0;
const coverage = expected === 0 ? 0 : matched / expected;
const mathCharacterIndexes = new Set(
paragraph.expectation.mathCharacterIndexes ?? [],
);
const missingTailIsMath =
matched < expected &&
Array.from(
{ length: expected - matched },
(_, offset) => matched + offset,
).every((index) => mathCharacterIndexes.has(index));
return layout.visualLines.length > 0 &&
((expected >= 10 && expected - matched === 1 && coverage >= 0.9) ||
(missingTailIsMath && coverage >= 0.8));
};
const equalLineSegmentation =
paragraph.baseline.visualLines.length ===
paragraph.candidate.visualLines.length &&
paragraph.baseline.lineTexts.every(
(text, index) => text === paragraph.candidate.lineTexts[index],
);
if (
!hasUsableVisualMapping(paragraph.baseline) ||
!hasUsableVisualMapping(paragraph.candidate) ||
comparableLineCount === 0
) {
issues.push({
code: "SEMANTIC_BLOCK_VISUAL_UNAVAILABLE",
severity: "failure",
message: `${paragraph.expectation.index + 1} 个语义块缺少可比较的局部视觉观测`,
details: { paragraphIndex: paragraph.expectation.index },
});
} else if (equalLineSegmentation) {
for (let lineIndex = 0; lineIndex < comparableLineCount; lineIndex += 1) {
const baselineLine = paragraph.baseline.visualLines[lineIndex];
const candidateLine = paragraph.candidate.visualLines[lineIndex];
const baselinePage = baselineLine
? baseline.pages[baselineLine.pageNumber - 1]
: undefined;
const candidatePage = candidateLine
? candidate.pages[candidateLine.pageNumber - 1]
: undefined;
if (
!baselineLine ||
!candidateLine ||
!baselinePage?.raster ||
!candidatePage?.raster
) {
const unavailable: VisualDiffIssue = {
code: "SEMANTIC_BLOCK_VISUAL_UNAVAILABLE",
severity: "failure",
message: `${paragraph.expectation.index + 1} 个语义块的第 ${lineIndex + 1} 行缺少栅格`,
details: {
paragraphIndex: paragraph.expectation.index,
lineIndex,
},
};
issues.push(unavailable);
lines.push({
lineIndex,
baselinePageNumber: baselineLine?.pageNumber ?? -1,
candidatePageNumber: candidateLine?.pageNumber ?? -1,
issues: [unavailable],
});
continue;
}
const [baselineCrop, candidateCrop] = await Promise.all([
cropRaster(baselinePage.raster, baselineLine.bounds),
cropRaster(candidatePage.raster, candidateLine.bounds),
]);
assertPngRaster(
baselineCrop,
`${paragraph.expectation.index + 1} 个语义块第 ${lineIndex + 1} 行基线裁剪`,
);
assertPngRaster(
candidateCrop,
`${paragraph.expectation.index + 1} 个语义块第 ${lineIndex + 1} 行候选裁剪`,
);
const baselineLineText = paragraph.baseline.lineTexts[lineIndex] ?? "";
const candidateLineText = paragraph.candidate.lineTexts[lineIndex] ?? "";
const textReflow = flowTextBlock &&
baselineLineText !== candidateLineText;
const cropWidthRatio = Math.min(
baselineCrop.widthPx,
candidateCrop.widthPx,
) / Math.max(baselineCrop.widthPx, candidateCrop.widthPx);
const normalizeFlowGeometry = flowTextBlock &&
!textReflow &&
cropWidthRatio >= 0.75;
let result;
try {
result = normalizeFlowGeometry
? await comparePageRasters(baselineCrop, candidateCrop, {
pixelDifferenceThreshold: thresholds.pixelDifferenceThreshold,
spatialTolerancePx: thresholds.spatialTolerancePx,
targetWidthPx: baselineCrop.widthPx,
targetHeightPx: baselineCrop.heightPx,
geometryNormalized: true,
})
: await (async () => {
const widthPx = Math.max(
baselineCrop.widthPx,
candidateCrop.widthPx,
);
const heightPx = Math.max(
baselineCrop.heightPx,
candidateCrop.heightPx,
);
const [baselinePadded, candidatePadded] = await Promise.all([
padRaster(baselineCrop, widthPx, heightPx),
padRaster(candidateCrop, widthPx, heightPx),
]);
return comparePageRasters(
baselinePadded,
candidatePadded,
thresholds.pixelDifferenceThreshold,
);
})();
} catch (error) {
throw new Error(
`${paragraph.expectation.index + 1} 个语义块第 ${lineIndex + 1} 行栅格比较失败:` +
`基线 ${baselineCrop.widthPx}x${baselineCrop.heightPx}/${baselineCrop.png.byteLength}` +
`候选 ${candidateCrop.widthPx}x${candidateCrop.heightPx}/${candidateCrop.png.byteLength}` +
`${error instanceof Error ? error.message : String(error)}`,
);
}
const lineIssues = localRasterIssues(
paragraph.expectation.index,
lineIndex,
paragraph.expectation.blockKind,
paragraph.expectation.hasInlineCode === true,
result.metrics,
thresholds,
textReflow,
);
issues.push(...lineIssues);
lines.push({
lineIndex,
baselinePageNumber: baselineLine.pageNumber,
candidatePageNumber: candidateLine.pageNumber,
metrics: result.metrics,
...(lineIssues.length > 0 ? { artifacts: result.artifacts } : {}),
issues: lineIssues,
});
}
} else {
const reflowPairs = pairReflowedTextLines(
paragraph.baseline.lineTexts,
paragraph.candidate.lineTexts,
);
const sliceByCharacterOverlap =
paragraph.baseline.visualLines.length ===
paragraph.candidate.visualLines.length;
if (reflowPairs.length === 0) {
issues.push({
code: "SEMANTIC_BLOCK_VISUAL_UNAVAILABLE",
severity: "failure",
message: `${paragraph.expectation.index + 1} 个语义块无法按文本区间配对跨引擎视觉行`,
details: { paragraphIndex: paragraph.expectation.index },
});
}
for (let pairIndex = 0; pairIndex < reflowPairs.length; pairIndex += 1) {
const pair = reflowPairs[pairIndex]!;
const baselineLine =
paragraph.baseline.visualLines[pair.baselineLineIndex];
const candidateLine =
paragraph.candidate.visualLines[pair.candidateLineIndex];
const baselinePage = baselineLine
? baseline.pages[baselineLine.pageNumber - 1]
: undefined;
const candidatePage = candidateLine
? candidate.pages[candidateLine.pageNumber - 1]
: undefined;
if (
!baselineLine ||
!candidateLine ||
!baselinePage?.raster ||
!candidatePage?.raster
) {
const unavailable: VisualDiffIssue = {
code: "SEMANTIC_BLOCK_VISUAL_UNAVAILABLE",
severity: "failure",
message: `${paragraph.expectation.index + 1} 个语义块的跨引擎重排行缺少栅格`,
details: {
paragraphIndex: paragraph.expectation.index,
lineIndex: pairIndex,
},
};
issues.push(unavailable);
lines.push({
lineIndex: pairIndex,
baselinePageNumber: baselineLine?.pageNumber ?? -1,
candidatePageNumber: candidateLine?.pageNumber ?? -1,
issues: [unavailable],
});
continue;
}
const baselineBounds = sliceByCharacterOverlap
? sliceTextLineBounds(
baselineLine.bounds,
paragraph.baseline.lineTexts[pair.baselineLineIndex] ?? "",
pair.baselineStart,
pair.baselineEnd,
)
: baselineLine.bounds;
const candidateBounds = sliceByCharacterOverlap
? sliceTextLineBounds(
candidateLine.bounds,
paragraph.candidate.lineTexts[pair.candidateLineIndex] ?? "",
pair.candidateStart,
pair.candidateEnd,
)
: candidateLine.bounds;
const [baselineCrop, candidateCrop] = await Promise.all([
cropRaster(baselinePage.raster, baselineBounds),
cropRaster(candidatePage.raster, candidateBounds),
]);
assertPngRaster(
baselineCrop,
`${paragraph.expectation.index + 1} 个语义块重排行基线裁剪`,
);
assertPngRaster(
candidateCrop,
`${paragraph.expectation.index + 1} 个语义块重排行候选裁剪`,
);
const widthPx = Math.max(
baselineCrop.widthPx,
candidateCrop.widthPx,
);
const heightPx = Math.max(
baselineCrop.heightPx,
candidateCrop.heightPx,
);
const [baselinePadded, candidatePadded] = await Promise.all([
padRaster(baselineCrop, widthPx, heightPx),
padRaster(candidateCrop, widthPx, heightPx),
]);
const result = await comparePageRasters(
baselinePadded,
candidatePadded,
thresholds.pixelDifferenceThreshold,
);
const reflowIssue: VisualDiffIssue = {
code: "PARAGRAPH_LINE_BREAK_MISMATCH",
severity: "warning",
message: `${paragraph.expectation.index + 1} 个语义块发生允许的跨引擎流式换行`,
details: {
paragraphIndex: paragraph.expectation.index,
lineIndex: pairIndex,
baselineLineIndex: pair.baselineLineIndex,
candidateLineIndex: pair.candidateLineIndex,
textReflow: true,
},
};
const lineIssues = [
reflowIssue,
...localRasterIssues(
paragraph.expectation.index,
pairIndex,
paragraph.expectation.blockKind,
paragraph.expectation.hasInlineCode === true,
result.metrics,
thresholds,
true,
),
];
issues.push(...lineIssues);
lines.push({
lineIndex: pairIndex,
baselinePageNumber: baselineLine.pageNumber,
candidatePageNumber: candidateLine.pageNumber,
metrics: result.metrics,
...(lineIssues.some((issue) => issue.severity === "failure")
? { artifacts: result.artifacts }
: {}),
issues: lineIssues,
});
}
}
comparisons.push({
expectation: paragraph.expectation,
status: status(issues),
baselineFontFamilies: baselineFonts,
candidateFontFamilies: candidateFonts,
lines,
issues,
});
}
return comparisons;
}
+83 -1
View File
@@ -7,6 +7,7 @@ import { aggregatePdfTextLines, buildPageContentText, normalizePdfText } from ".
import type {
CreatePdfSnapshotOptions,
PdfDocumentSnapshot,
PdfFontSnapshot,
PdfPageSnapshot,
PdfSnapshotLimits,
PdfTextItemSnapshot,
@@ -21,6 +22,17 @@ interface PdfJsTextItem {
hasEOL: boolean;
}
interface PdfJsTextStyle {
fontFamily?: string;
}
interface PdfJsResolvedFont {
name?: string;
fallbackName?: string;
isType3Font?: boolean;
missingFile?: boolean;
}
export const DEFAULT_PDF_SNAPSHOT_LIMITS: PdfSnapshotLimits = {
maxPdfBytes: 100 * 1024 * 1024,
maxPages: 500,
@@ -55,6 +67,8 @@ function isTextItem(item: unknown): item is PdfJsTextItem {
function createTextItemSnapshot(
item: PdfJsTextItem,
pageHeightPt: number,
styles: Readonly<Record<string, PdfJsTextStyle>>,
fonts: ReadonlyMap<string, PdfFontSnapshot>,
): PdfTextItemSnapshot | undefined {
const normalizedText = normalizePdfText(item.str);
if (!normalizedText) {
@@ -72,6 +86,8 @@ function createTextItemSnapshot(
0,
);
const baselineY = pageHeightPt - baselineFromBottom;
const font = fonts.get(item.fontName);
const fontFamily = font?.name ?? styles[item.fontName]?.fontFamily;
return {
text: item.str,
normalizedText,
@@ -83,10 +99,46 @@ function createTextItemSnapshot(
},
baselineY,
fontName: item.fontName,
...(fontFamily ? { fontFamily } : {}),
...(font?.fallbackFamily
? { fallbackFontFamily: font.fallbackFamily }
: {}),
hasEol: item.hasEOL,
};
}
function normalizePdfFontName(value: string) {
return value.replace(/^[A-Z]{6}\+/u, "");
}
function resolvePageFonts(
page: Awaited<ReturnType<Awaited<ReturnType<typeof getDocument>["promise"]>["getPage"]>>,
fontNames: readonly string[],
) {
const fonts = new Map<string, PdfFontSnapshot>();
for (const internalName of fontNames) {
try {
const resolved = page.commonObjs.get(internalName) as PdfJsResolvedFont;
const name = resolved.name?.trim();
if (!name) {
continue;
}
fonts.set(internalName, {
internalName,
name: normalizePdfFontName(name),
...(resolved.fallbackName
? { fallbackFamily: resolved.fallbackName }
: {}),
isType3Font: resolved.isType3Font === true,
missingFile: resolved.missingFile === true,
});
} catch {
// PDF.js 可能不给未实际绘制的字体建立公共对象;保留文本但不伪造字体。
}
}
return fonts;
}
function resolveLimits(
input: Partial<PdfSnapshotLimits> | undefined,
): PdfSnapshotLimits {
@@ -138,11 +190,30 @@ export async function createPdfDocumentSnapshot(
disableNormalization: false,
includeMarkedContent: false,
});
await page.getOperatorList();
const pageFonts = resolvePageFonts(
page,
[
...new Set(
textContent.items.flatMap((item) =>
isTextItem(item) ? [item.fontName] : [],
),
),
],
);
const styles = textContent.styles as Readonly<
Record<string, PdfJsTextStyle>
>;
const items = textContent.items.flatMap((item) => {
if (!isTextItem(item)) {
return [];
}
const snapshot = createTextItemSnapshot(item, pointViewport.height);
const snapshot = createTextItemSnapshot(
item,
pointViewport.height,
styles,
pageFonts,
);
return snapshot ? [snapshot] : [];
});
const lines = aggregatePdfTextLines(items, pointViewport.height);
@@ -154,6 +225,9 @@ export async function createPdfDocumentSnapshot(
items,
lines,
contentText: buildPageContentText(lines),
fonts: [...pageFonts.values()].sort((left, right) =>
left.name.localeCompare(right.name, "en"),
),
};
const pageNumberLine = lines.find((line) => line.role === "page-number");
if (pageNumberLine) {
@@ -204,12 +278,20 @@ export async function createPdfDocumentSnapshot(
.map((page) => page.contentText)
.filter(Boolean)
.join("\n\f\n");
const fonts = [
...new Map(
pages
.flatMap((page) => page.fonts)
.map((font) => [font.name.toLocaleLowerCase("en-US"), font]),
).values(),
].sort((left, right) => left.name.localeCompare(right.name, "en"));
return {
schemaVersion: 1,
source: options.source,
sha256: sha256(pdfBytes),
pageCount: pages.length,
contentText,
fonts,
pages,
};
} finally {
+102 -6
View File
@@ -7,10 +7,19 @@ import type {
VisualPageSemanticExpectation,
} from "./types.js";
const ZERO_WIDTH_AND_CONTROL = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f\u200b-\u200d\u2060\ufeff]/gu;
const ZERO_WIDTH_AND_CONTROL = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f\u200b-\u200d\u2060\ufe0e\ufe0f\ufeff]/gu;
const CJK_RADICAL_VARIANTS = new Map([
["⺠", "民"],
["⻅", "见"],
["⻆", "角"],
["⻓", "长"],
["⻔", "门"],
["⻚", "页"],
["⻛", "风"],
["⻝", "食"],
["⻣", "骨"],
["⻋", "车"],
["⻬", "齐"],
]);
const PAGE_NUMBER_PATTERNS = [
/^(?:[-]\s*)?\d+(?:\s*[/]\s*\d+)?(?:\s*[-])?$/u,
@@ -25,6 +34,16 @@ export function normalizePdfText(value: string): string {
.replace(/[\u2e80-\u2eff]/gu, (character) =>
CJK_RADICAL_VARIANTS.get(character) ?? character
)
// Chromium、Word 与 WPS 的 PDF 文本层会以不同方式保留弯引号;
// 统一为 ASCII 只用于可编辑文本定位,不改变栅格视觉比较。
.replace(/[]/gu, '"')
.replace(/[]/gu, "'")
// Chromium 的部分 CJK 字体把 Markdown em dash 提取为 horizontal bar
// 二者在此仅作为语义定位字符归一,不影响后续栅格视觉比较。
.replace(//gu, "")
// Chromium 的部分 CJK 字体会把数字区间中的 en dash 提取为两个
// ASCII hyphen。只归一数字/百分比区间,避免改写代码中的普通 `--`。
.replace(/(?<=[\d%])\s*--(?:\s*(?=\d)|\s*$)/gu, "")
.replace(ZERO_WIDTH_AND_CONTROL, "")
.replace(/\u00a0/gu, " ")
.replace(/[ \t]+/gu, " ")
@@ -128,6 +147,63 @@ function median(values: readonly number[]): number {
return ((sorted[middle - 1] ?? value) + value) / 2;
}
function mergeInlineScriptGroups(
sourceGroups: readonly PdfTextItemSnapshot[][],
typicalHeight: number,
): PdfTextItemSnapshot[][] {
const groups = sourceGroups.map((group) => [...group]);
for (let index = 0; index < groups.length; index += 1) {
const group = groups[index];
if (!group || group.length === 0) {
continue;
}
const groupHeight = median(group.map((item) => item.bounds.height));
if (groupHeight > typicalHeight * 0.9) {
continue;
}
const groupBounds = unionBounds(group);
for (const adjacentIndex of [index - 1, index + 1]) {
const adjacent = groups[adjacentIndex];
if (!adjacent || adjacent.length === 0) {
continue;
}
const adjacentHeight = median(
adjacent.map((item) => item.bounds.height),
);
if (adjacentHeight < groupHeight / 0.9) {
continue;
}
const adjacentBounds = unionBounds(adjacent);
const verticalOverlap = Math.min(
groupBounds.y + groupBounds.height,
adjacentBounds.y + adjacentBounds.height,
) - Math.max(groupBounds.y, adjacentBounds.y);
const horizontallyContained =
groupBounds.x >= adjacentBounds.x - typicalHeight * 0.5 &&
groupBounds.x + groupBounds.width <=
adjacentBounds.x + adjacentBounds.width + typicalHeight * 0.5;
// Chromium 会把行末公式按较小字号单独提取;公式片段可能从正文
// 最后一个字符的右缘开始,并向右超出正文组,因此不能只用“被正文
// 水平包含”判断同行。仅在两组真实垂直重叠且小字号组紧贴较大字号
// 组右缘时合并,避免把下一视觉行或相邻表格单元格误并入当前行。
const adjacentRight = adjacentBounds.x + adjacentBounds.width;
const touchesAdjacentRight =
groupBounds.x >= adjacentBounds.x &&
Math.abs(groupBounds.x - adjacentRight) <= typicalHeight * 0.5;
if (
verticalOverlap <= 0 ||
(!horizontallyContained && !touchesAdjacentRight)
) {
continue;
}
adjacent.push(...group);
groups[index] = [];
break;
}
}
return groups.filter((group) => group.length > 0);
}
function joinLineItems(items: readonly PdfTextItemSnapshot[]): string {
let result = "";
let previous: PdfTextItemSnapshot | undefined;
@@ -176,6 +252,9 @@ export function aggregatePdfTextLines(
sourceItems: readonly PdfTextItemSnapshot[],
pageHeightPt: number,
): PdfTextLineSnapshot[] {
const sourceOrder = new Map(
sourceItems.map((item, index) => [item, index] as const),
);
const items = sourceItems
.filter((item) => item.normalizedText.length > 0)
.sort(
@@ -183,11 +262,14 @@ export function aggregatePdfTextLines(
left.bounds.y - right.bounds.y || left.bounds.x - right.bounds.x,
);
const typicalHeight = median(items.map((item) => item.bounds.height));
const baselineTolerance = Math.max(1.5, typicalHeight * 0.15);
const groups: PdfTextItemSnapshot[][] = [];
// Office/WPS 会让同一视觉行内不同字体或单元格的基线产生约 2pt
// 浮动;按 15% 聚合会把这些片段错误拆行并改变阅读顺序。
// 20% 仍远小于正常行距,同时能覆盖常见的 10–12pt 字号偏差。
const baselineTolerance = Math.max(2, typicalHeight * 0.2);
const initialGroups: PdfTextItemSnapshot[][] = [];
for (const item of items) {
const lastGroup = groups.at(-1);
const lastGroup = initialGroups.at(-1);
const lastBaseline = lastGroup
? median(lastGroup.map((entry) => entry.baselineY))
: undefined;
@@ -198,13 +280,27 @@ export function aggregatePdfTextLines(
) {
lastGroup.push(item);
} else {
groups.push([item]);
initialGroups.push([item]);
}
}
const groups = mergeInlineScriptGroups(initialGroups, typicalHeight);
return groups.map((group) => {
const sortedItems = [...group].sort(
(left, right) => left.bounds.x - right.bounds.x,
(left, right) => {
const leftRight = left.bounds.x + left.bounds.width;
const rightRight = right.bounds.x + right.bounds.width;
const horizontallyOverlapping =
left.bounds.x < rightRight && right.bounds.x < leftRight;
// PDF.js 的文本流保留字符阅读顺序,但不同字体的窄标点可能与
// 相邻全角字符发生水平重叠。此时单纯按 x 排序会把闭引号移到
// 左括号之后,破坏语义块定位;只对真实重叠片段保留文本流顺序,
// 其余片段仍按几何位置排序,兼容 Office 表格等非阅读序文本流。
return horizontallyOverlapping
? (sourceOrder.get(left) ?? 0) - (sourceOrder.get(right) ?? 0)
: left.bounds.x - right.bounds.x;
},
);
const bounds = unionBounds(sortedItems);
const normalizedText = joinLineItems(sortedItems);
+79 -2
View File
@@ -19,9 +19,19 @@ export interface PdfTextItemSnapshot {
bounds: PdfPointBounds;
baselineY: number;
fontName?: string;
fontFamily?: string;
fallbackFontFamily?: string;
hasEol: boolean;
}
export interface PdfFontSnapshot {
internalName: string;
name: string;
fallbackFamily?: string;
isType3Font: boolean;
missingFile: boolean;
}
export type PdfTextLineRole = "content" | "page-number";
export interface PdfTextLineSnapshot {
@@ -46,11 +56,30 @@ export type EditableParagraphRole =
| "caption"
| "other";
export type EditableParagraphBlockKind =
| "paragraph"
| "heading"
| "list-item"
| "table-header"
| "table-cell"
| "code-block"
| "block-quote"
| "caption"
| "semantic-region"
| "other";
export interface EditableParagraphExpectation {
index: number;
text: string;
mathCharacterIndexes?: number[];
hardBreakSegments?: string[];
hasInlineCode?: boolean;
tableGroupId?: string;
tableRowIndex?: number;
tableColumnIndex?: number;
styleId?: string;
role: EditableParagraphRole;
blockKind?: EditableParagraphBlockKind;
section: "cover" | "body";
}
@@ -68,6 +97,13 @@ export interface PdfParagraphLayoutObservation {
averageLineHeightPt?: number;
averageBaselineGapPt?: number;
exclusiveGeometry: boolean;
visualLines: PdfParagraphVisualLineObservation[];
}
export interface PdfParagraphVisualLineObservation {
pageNumber: number;
bounds: PdfPointBounds;
fontFamilies: string[];
}
export interface PdfParagraphLayoutComparison {
@@ -78,6 +114,24 @@ export interface PdfParagraphLayoutComparison {
issues: VisualDiffIssue[];
}
export interface PdfSemanticBlockVisualLineComparison {
lineIndex: number;
baselinePageNumber: number;
candidatePageNumber: number;
metrics?: PdfRasterDiffMetrics;
artifacts?: PdfRasterDiffArtifacts;
issues: VisualDiffIssue[];
}
export interface PdfSemanticBlockVisualComparison {
expectation: EditableParagraphExpectation;
status: "passed" | "warning" | "failed";
baselineFontFamilies: string[];
candidateFontFamilies: string[];
lines: PdfSemanticBlockVisualLineComparison[];
issues: VisualDiffIssue[];
}
export interface PdfBodyFlowComparison {
detected: boolean;
legal: boolean;
@@ -96,6 +150,7 @@ export interface PdfPageRaster {
dpi: number;
sha256: string;
png: Uint8Array;
rgba?: Uint8ClampedArray;
}
export interface PdfPageSnapshot {
@@ -106,6 +161,7 @@ export interface PdfPageSnapshot {
items: PdfTextItemSnapshot[];
lines: PdfTextLineSnapshot[];
contentText: string;
fonts: PdfFontSnapshot[];
pageNumberText?: string;
raster?: PdfPageRaster;
}
@@ -116,6 +172,7 @@ export interface PdfDocumentSnapshot {
sha256: string;
pageCount: number;
contentText: string;
fonts: PdfFontSnapshot[];
pages: PdfPageSnapshot[];
}
@@ -138,10 +195,15 @@ export interface VisualDiffThresholds {
contentSimilarity: number;
strictPageCount: boolean;
pixelDifferenceThreshold: number;
spatialTolerancePx: number;
maxMeanAbsoluteError: number;
maxChangedPixelRatio: number;
minInkIou: number;
minEdgeIou: number;
maxBackgroundColorDelta: number;
maxForegroundColorDelta: number;
minAntialiasEquivalentInkIou: number;
minAntialiasEquivalentEdgeIou: number;
}
export type VisualDiffIssueSeverity = "warning" | "failure";
@@ -171,9 +233,12 @@ export type VisualDiffIssueCode =
| "BODY_LINE_COUNT_NEAR_BOUNDARY"
| "PARAGRAPH_LINE_BREAK_MISMATCH"
| "PARAGRAPH_LINE_GEOMETRY_MISMATCH"
| "SEMANTIC_BLOCK_VISUAL_UNAVAILABLE"
| "SEMANTIC_BLOCK_RASTER_MISMATCH"
| "COVER_LAYOUT_MISMATCH"
| "BODY_FLOW_PAGE_COUNT_DIFFERENCE"
| "BODY_FLOW_RASTER_DIFFERENCE";
| "BODY_FLOW_RASTER_DIFFERENCE"
| "BODY_PAGE_RASTER_DIFFERENCE";
export interface VisualDiffIssue {
code: VisualDiffIssueCode;
@@ -210,6 +275,7 @@ export interface PdfBasicComparison {
candidateEditableSimilarity?: number;
candidateEditableExact?: boolean;
paragraphLayouts?: PdfParagraphLayoutComparison[];
semanticBlockVisuals?: PdfSemanticBlockVisualComparison[];
bodyFlow?: PdfBodyFlowComparison;
pagePairs: PdfPagePair[];
issues: VisualDiffIssue[];
@@ -224,14 +290,25 @@ export interface PdfRasterDiffMetrics {
comparedHeightPx: number;
dimensionsMatch: boolean;
geometryNormalized: boolean;
spatialTolerancePx: number;
meanAbsoluteError: number;
changedPixelRatio: number;
inkIou: number;
foregroundInkIou: number;
edgeIou: number;
backgroundColorDelta: number;
foregroundColorDelta: number;
baselineForegroundChroma?: number;
candidateForegroundChroma?: number;
foregroundLuminanceDelta?: number;
dominantForegroundColorDelta?: number;
chromaticForegroundColorDelta?: number;
chromaticPaletteDelta?: number;
}
export interface ComparePageRasterOptions {
pixelDifferenceThreshold?: number;
spatialTolerancePx?: number;
targetWidthPx?: number;
targetHeightPx?: number;
geometryNormalized?: boolean;
@@ -255,7 +332,7 @@ export interface PdfRasterDiffResult {
export interface PdfVisualPageComparison {
pair: PdfPagePair;
rasterComparisonMode?: "strict" | "body-flow";
rasterComparisonMode?: "strict" | "body-flow" | "body-block";
status: "passed" | "warning" | "failed";
metrics?: PdfRasterDiffMetrics;
artifacts?: PdfRasterDiffArtifacts;
@@ -121,6 +121,37 @@ describe("Office PDF 适配器", () => {
await rm(directory, { recursive: true, force: true });
}
});
it("Office 偶发未生成 PDF 时清理残留并有界重试", async () => {
const directory = await mkdtemp(
join(tmpdir(), "visual-diff-office-retry-test-"),
);
const docxPath = join(directory, "fixture.docx");
await writeFile(docxPath, "fixture");
let attempts = 0;
const backend: OfficeAutomationBackend = {
probe: async () => true,
exportPdf: async (options) => {
attempts += 1;
if (attempts === 1) {
await writeFile(options.outputPath, "partial");
throw new Error("Office 未生成有效 PDF");
}
const pdf = createMinimalPdf("retry");
await writeFile(options.outputPath, pdf);
return { bytes: pdf.byteLength, pageCount: 1 };
},
};
try {
const result = await createWordPdfAdapter({ backend }).generate({
docxPath,
});
expect(result.pageCount).toBe(1);
expect(attempts).toBe(2);
} finally {
await rm(directory, { recursive: true, force: true });
}
});
});
describe("PDF 适配器视觉编排", () => {
@@ -121,6 +121,38 @@ describe("PDF 基础视觉门禁", () => {
);
});
it("逐段完整映射时忽略 PDF 页码与表格行聚合造成的串联顺序污染", async () => {
const baseline = withLines(await snapshot("baseline"), [
textLine("门禁 内容", 100),
]);
const candidate = withLines(await snapshot("candidate"), [
textLine("门禁 2 / 2 内容", 100),
]);
const result = comparePdfSnapshotsBasic(baseline, candidate, {}, {
expectedEditableText: "门禁内容",
expectedEditableParagraphs: [
{
index: 0,
text: "门禁",
role: "body",
blockKind: "table-header",
section: "body",
},
{
index: 1,
text: "内容",
role: "body",
blockKind: "table-header",
section: "body",
},
],
});
expect(result.candidateEditableExact).toBe(true);
expect(result.issues.map((issue) => issue.code)).not.toContain(
"CONTENT_MISMATCH",
);
});
it("通过纸张和正文一致的文档", async () => {
const baseline = await snapshot("baseline");
const candidate = await snapshot("candidate");
File diff suppressed because it is too large Load Diff
@@ -48,18 +48,141 @@ describe("PDF 栅格差异", () => {
const result = await comparePageRasters(page, page);
expect(result.metrics).toMatchObject({
dimensionsMatch: true,
spatialTolerancePx: 4,
meanAbsoluteError: 0,
changedPixelRatio: 0,
inkIou: 1,
foregroundInkIou: 1,
edgeIou: 1,
backgroundColorDelta: 0,
foregroundColorDelta: 0,
});
expect(result.artifacts.overlayPng.subarray(0, 8)).toEqual(
new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10]),
);
});
it("分别量化块背景色与背景相对的前景形状", async () => {
const coloredRaster = (background: string, foreground: string) => {
const canvas = createCanvas(80, 40);
const context = canvas.getContext("2d");
context.fillStyle = background;
context.fillRect(0, 0, 80, 40);
context.fillStyle = foreground;
context.fillRect(20, 10, 40, 20);
const png = Uint8Array.from(canvas.toBuffer("image/png"));
return {
widthPx: 80,
heightPx: 40,
dpi: 144,
sha256: createHash("sha256").update(png).digest("hex"),
png,
} satisfies PdfPageRaster;
};
const sameShape = await comparePageRasters(
coloredRaster("#f2f2f2", "#202020"),
coloredRaster("#ffffff", "#202020"),
);
expect(sameShape.metrics.inkIou).toBe(1);
expect(sameShape.metrics.backgroundColorDelta).toBeGreaterThan(10);
expect(sameShape.metrics.foregroundColorDelta).toBeLessThan(1);
});
it("双背景占比翻转时仍独立量化核心字形拓扑", async () => {
const inlineCodeRaster = (fullHeightShading: boolean) => {
const canvas = createCanvas(216, 25);
const context = canvas.getContext("2d");
context.fillStyle = "#ffffff";
context.fillRect(0, 0, 216, 25);
context.fillStyle = "#f3f3f3";
context.fillRect(28, fullHeightShading ? 0 : 5, 120, fullHeightShading ? 25 : 16);
context.fillStyle = "#333333";
context.fillRect(34, 10, 80, 4);
const png = Uint8Array.from(canvas.toBuffer("image/png"));
return {
widthPx: 216,
heightPx: 25,
dpi: 144,
sha256: createHash("sha256").update(png).digest("hex"),
png,
} satisfies PdfPageRaster;
};
const result = await comparePageRasters(
inlineCodeRaster(false),
inlineCodeRaster(true),
);
expect(result.metrics.backgroundColorDelta).toBeGreaterThan(10);
expect(result.metrics.foregroundInkIou).toBe(1);
});
it("渐变背景不会把深色文字误判为背景", async () => {
const gradientRaster = (gradient: boolean) => {
const canvas = createCanvas(160, 40);
const context = canvas.getContext("2d");
if (gradient) {
const fill = context.createLinearGradient(0, 0, 160, 0);
fill.addColorStop(0, "#edf5fa");
fill.addColorStop(1, "#ffffff");
context.fillStyle = fill;
} else {
context.fillStyle = "#f6fafc";
}
context.fillRect(0, 0, 160, 40);
context.fillStyle = "#0b3557";
context.fillRect(4, 4, 148, 32);
const png = Uint8Array.from(canvas.toBuffer("image/png"));
return {
widthPx: 160,
heightPx: 40,
dpi: 144,
sha256: createHash("sha256").update(png).digest("hex"),
png,
} satisfies PdfPageRaster;
};
const result = await comparePageRasters(
gradientRaster(true),
gradientRaster(false),
);
expect(result.metrics.foregroundColorDelta).toBeLessThan(1);
expect(result.metrics.backgroundColorDelta).toBeLessThan(8);
expect(result.metrics.inkIou).toBeGreaterThan(0.95);
});
it("裁剪跨过深色盒边界时不会把外部白色误判为盒背景", async () => {
const boxedRaster = (exposeOutside: boolean) => {
const canvas = createCanvas(80, 40);
const context = canvas.getContext("2d");
context.fillStyle = "#ffffff";
context.fillRect(0, 0, 80, 40);
context.fillStyle = "#464646";
context.fillRect(0, 0, exposeOutside ? 68 : 80, exposeOutside ? 32 : 40);
context.fillStyle = "#ffffff";
context.fillRect(18, 10, 32, 16);
const png = Uint8Array.from(canvas.toBuffer("image/png"));
return {
widthPx: 80,
heightPx: 40,
dpi: 144,
sha256: createHash("sha256").update(png).digest("hex"),
png,
} satisfies PdfPageRaster;
};
const result = await comparePageRasters(
boxedRaster(true),
boxedRaster(false),
);
expect(result.metrics.backgroundColorDelta).toBeLessThan(1);
expect(result.metrics.foregroundColorDelta).toBeLessThan(1);
});
it("量化位移并生成稳定叠加图与热力图", async () => {
const result = await comparePageRasters(raster(10), raster(20));
const result = await comparePageRasters(raster(10), raster(20), {
spatialTolerancePx: 0,
});
expect(result.metrics.meanAbsoluteError).toBeGreaterThan(10);
expect(result.metrics.changedPixelRatio).toBeCloseTo(0.1, 2);
expect(result.metrics.inkIou).toBeCloseTo(1 / 3, 2);
@@ -68,6 +191,30 @@ describe("PDF 栅格差异", () => {
expect(result.artifacts.heatmapSha256).toHaveLength(64);
});
it("只容忍三个像素以内的跨引擎栅格偏移", async () => {
const near = await comparePageRasters(raster(10), raster(13));
const far = await comparePageRasters(raster(10), raster(17));
expect(near.metrics.inkIou).toBe(1);
expect(near.metrics.edgeIou).toBe(1);
expect(far.metrics.inkIou).toBeLessThan(0.75);
expect(far.metrics.edgeIou).toBeLessThan(0.6);
});
it("空间容差不改变原始像素热力图", async () => {
const baseline = raster(10);
const candidate = raster(12);
const tolerant = await comparePageRasters(baseline, candidate);
const exact = await comparePageRasters(baseline, candidate, {
spatialTolerancePx: 0,
});
expect(tolerant.metrics.inkIou).toBeGreaterThan(exact.metrics.inkIou);
expect(tolerant.artifacts.heatmapSha256).toBe(
exact.artifacts.heatmapSha256,
);
});
it("可按物理页面基线规范化一像素的栅格舍入差", async () => {
const result = await comparePageRasters(
differentlySizedRaster(80, 100),
@@ -5,6 +5,8 @@ import { describe, expect, it } from "vitest";
import {
createPdfVisualDiffReport,
getBlockingVisualDiffIssues,
isCrossEngineRasterEquivalent,
renderPdfVisualDiffHtml,
serializePdfVisualDiffJson,
type PdfDocumentSnapshot,
@@ -30,6 +32,80 @@ function raster(color: string): PdfPageRaster {
};
}
function flowRaster(color: string): PdfPageRaster {
const canvas = createCanvas(40, 50);
const context = canvas.getContext("2d");
context.fillStyle = "#ffffff";
context.fillRect(0, 0, 40, 50);
context.fillStyle = color;
context.fillRect(34, 5, 6, 40);
const png = Uint8Array.from(canvas.toBuffer("image/png"));
return {
widthPx: 40,
heightPx: 50,
dpi: 144,
sha256: createHash("sha256").update(png).digest("hex"),
png,
};
}
function blockRaster(color: string): PdfPageRaster {
const canvas = createCanvas(40, 50);
const context = canvas.getContext("2d");
context.fillStyle = "#ffffff";
context.fillRect(0, 0, 40, 50);
context.fillStyle = color;
context.fillRect(8, 12, 20, 10);
const png = Uint8Array.from(canvas.toBuffer("image/png"));
return {
widthPx: 40,
heightPx: 50,
dpi: 144,
sha256: createHash("sha256").update(png).digest("hex"),
png,
};
}
function antialiasBlockRaster(edgeColor: string): PdfPageRaster {
const canvas = createCanvas(40, 50);
const context = canvas.getContext("2d");
context.fillStyle = "#ffffff";
context.fillRect(0, 0, 40, 50);
context.fillStyle = edgeColor;
context.fillRect(8, 12, 20, 10);
context.fillStyle = "#111111";
context.fillRect(10, 14, 16, 6);
const png = Uint8Array.from(canvas.toBuffer("image/png"));
return {
widthPx: 40,
heightPx: 50,
dpi: 144,
sha256: createHash("sha256").update(png).digest("hex"),
png,
};
}
function adjacentLineRaster(includePreviousLine: boolean): PdfPageRaster {
const canvas = createCanvas(40, 50);
const context = canvas.getContext("2d");
context.fillStyle = "#ffffff";
context.fillRect(0, 0, 40, 50);
if (includePreviousLine) {
context.fillStyle = "#111111";
context.fillRect(8, 12, 20, 3);
}
context.fillStyle = "#111111";
context.fillRect(8, 20, 20, 4);
const png = Uint8Array.from(canvas.toBuffer("image/png"));
return {
widthPx: 40,
heightPx: 50,
dpi: 144,
sha256: createHash("sha256").update(png).digest("hex"),
png,
};
}
function snapshot(label: string, pageRaster: PdfPageRaster): PdfDocumentSnapshot {
return {
schemaVersion: 1,
@@ -82,7 +158,7 @@ function flowSnapshot(
items: [],
lines: [...lines],
contentText: lines.map((line) => line.normalizedText).join(""),
raster: raster(color),
raster: flowRaster(color),
})),
};
}
@@ -387,7 +463,63 @@ describe("PDF 视觉差异报告", () => {
);
});
it("整段跨页同时发生换行变化时仍保持严格失败", async () => {
it("允许块内轻微换行警告伴随正文自然跨页", async () => {
const baseline = flowSnapshot(
"baseline",
[
[contentLine("甲乙丙丁", 8), contentLine("戊己庚辛", 12)],
[contentLine("壬癸", 8)],
],
"#111111",
);
const candidate = flowSnapshot(
"candidate",
[
[
contentLine("甲乙丙丁戊己", 8),
contentLine("庚辛", 12),
contentLine("壬癸", 16),
],
[],
],
"#cc0000",
);
const paragraphs = [
{
index: 0,
text: "甲乙丙丁戊己庚辛",
section: "body",
blockKind: "paragraph",
},
{
index: 1,
text: "壬癸",
section: "body",
blockKind: "paragraph",
},
] as const;
const report = await createPdfVisualDiffReport(baseline, candidate, {
expectedEditableText: "甲乙丙丁戊己庚辛壬癸",
expectedEditableParagraphs: paragraphs,
baselinePageSemantics: bodySemantics(2),
candidatePageSemantics: bodySemantics(2),
});
expect(report.basic.paragraphLayouts?.[0]).toMatchObject({
status: "warning",
});
expect(report.basic.bodyFlow).toMatchObject({
detected: true,
legal: true,
movedParagraphIndexes: [1],
});
expect(report.pages.map((page) => page.rasterComparisonMode)).toEqual([
"body-flow",
"body-flow",
]);
});
it("整段跨页同时发生换行变化时由语义块门禁失败", async () => {
const baseline = flowSnapshot(
"baseline",
[[contentLine("甲", 8)], [contentLine("乙丙", 8)]],
@@ -411,7 +543,7 @@ describe("PDF 视觉差异报告", () => {
expect(report.status).toBe("failed");
expect(report.basic.bodyFlow).toMatchObject({ detected: true, legal: false });
expect(report.pages.every(
(page) => page.rasterComparisonMode === "strict",
(page) => page.rasterComparisonMode === "body-block",
)).toBe(true);
expect(report.issues.map((issue) => issue.code)).toContain(
"TEXT_BLOCK_LINE_COUNT_MISMATCH",
@@ -451,6 +583,58 @@ describe("PDF 视觉差异报告", () => {
});
});
it("正文页数不同时分别验证未配对页面自身的页码语义", async () => {
const pageNumber = (text: string): PdfTextLineSnapshot => ({
text,
normalizedText: text,
bounds: { x: 9, y: 22, width: 2, height: 1 },
baselineY: 23,
role: "page-number",
items: [],
});
const baseline = flowSnapshot(
"baseline",
[
[contentLine("甲", 8), pageNumber("1 / 2")],
[contentLine("乙丙", 8), pageNumber("2 / 2")],
],
"#111111",
);
const candidate = flowSnapshot(
"candidate",
[[
contentLine("甲", 8),
contentLine("乙丙", 12),
pageNumber("1 / 1"),
]],
"#cc0000",
);
const pageSemantics = (count: number) => bodySemantics(count).map(
(item, index) => ({
...item,
footerVisible: true,
footerAlignment: "center" as const,
pageNumberText: `${index + 1} / ${count}`,
}),
);
const report = await createPdfVisualDiffReport(baseline, candidate, {
expectedEditableText: "甲乙丙",
expectedEditableParagraphs: flowParagraphs,
baselinePageSemantics: pageSemantics(2),
candidatePageSemantics: pageSemantics(1),
});
expect(report.status).toBe("warning");
expect(report.basic.bodyFlow).toMatchObject({
legal: true,
baselineBodyPageCount: 2,
candidateBodyPageCount: 1,
});
expect(report.issues.map((issue) => issue.code)).toContain(
"BODY_FLOW_PAGE_COUNT_DIFFERENCE",
);
});
it("新增正文页缺少预期页码时不得认定为合法分页流动", async () => {
const pageNumber = (text: string): PdfTextLineSnapshot => ({
text,
@@ -498,4 +682,169 @@ describe("PDF 视觉差异报告", () => {
"PAGE_NUMBER_VISIBILITY_MISMATCH",
);
});
it("正文整页差异只作诊断,但语义块局部视觉变化必须失败", async () => {
const baseline = flowSnapshot(
"baseline",
[[contentLine("甲", 8)]],
"#111111",
);
const candidate = flowSnapshot(
"candidate",
[[contentLine("甲", 8)]],
"#111111",
);
baseline.pages[0]!.raster = blockRaster("#111111");
candidate.pages[0]!.raster = blockRaster("#cc0000");
const report = await createPdfVisualDiffReport(baseline, candidate, {
expectedEditableText: "甲",
expectedEditableParagraphs: [flowParagraphs[0]!],
pageSemantics: bodySemantics(1),
});
expect(report.status).toBe("failed");
expect(report.pages[0]?.rasterComparisonMode).toBe("body-block");
expect(report.issues.map((issue) => issue.code)).toContain(
"BODY_PAGE_RASTER_DIFFERENCE",
);
expect(report.issues.map((issue) => issue.code)).toContain(
"SEMANTIC_BLOCK_RASTER_MISMATCH",
);
});
it("正文文字字形与前景色一致时忽略跨引擎抗锯齿灰度噪声", async () => {
const baseline = flowSnapshot(
"baseline",
[[contentLine("甲", 8)]],
"#111111",
);
const candidate = flowSnapshot(
"candidate",
[[contentLine("甲", 8)]],
"#111111",
);
baseline.pages[0]!.raster = antialiasBlockRaster("#999999");
candidate.pages[0]!.raster = antialiasBlockRaster("#aaaaaa");
const report = await createPdfVisualDiffReport(baseline, candidate, {
expectedEditableText: "甲",
expectedEditableParagraphs: [flowParagraphs[0]!],
pageSemantics: bodySemantics(1),
});
expect(report.issues.map((issue) => issue.code)).not.toContain(
"SEMANTIC_BLOCK_RASTER_MISMATCH",
);
expect(report.basic.semanticBlockVisuals[0]?.lines[0]?.metrics)
.toMatchObject({ inkIou: 1, edgeIou: 1 });
});
it("局部视觉裁剪不得把目标行上方的相邻行墨迹纳入比较", async () => {
const targetLine = contentLine("乙", 10);
targetLine.bounds = { x: 4, y: 10, width: 10, height: 2 };
const baseline = flowSnapshot(
"baseline",
[[targetLine]],
"#111111",
);
const candidate = flowSnapshot(
"candidate",
[[targetLine]],
"#111111",
);
baseline.pages[0]!.raster = adjacentLineRaster(true);
candidate.pages[0]!.raster = adjacentLineRaster(false);
const report = await createPdfVisualDiffReport(baseline, candidate, {
expectedEditableText: "乙",
expectedEditableParagraphs: [{
index: 0,
text: "乙",
role: "body",
section: "body",
blockKind: "table-cell",
}],
pageSemantics: bodySemantics(1),
});
expect(report.issues.map((issue) => issue.code)).not.toContain(
"SEMANTIC_BLOCK_RASTER_MISMATCH",
);
});
it("仅对带行内代码结构语义的混排允许 WPS 底纹栅格混合差异", () => {
const metrics = {
baselineWidthPx: 236,
baselineHeightPx: 24,
candidateWidthPx: 233,
candidateHeightPx: 26,
comparedWidthPx: 236,
comparedHeightPx: 26,
dimensionsMatch: false,
geometryNormalized: true,
spatialTolerancePx: 4,
meanAbsoluteError: 22.1,
changedPixelRatio: 0.449,
inkIou: 0.908,
edgeIou: 1,
backgroundColorDelta: 11.38,
foregroundColorDelta: 2.6,
};
expect(isCrossEngineRasterEquivalent(
metrics,
false,
"paragraph",
true,
)).toBe(true);
expect(isCrossEngineRasterEquivalent(
metrics,
false,
"paragraph",
false,
)).toBe(false);
});
it("缺少语义块证据时不得把正文整页栅格自动降级", async () => {
const report = await createPdfVisualDiffReport(
snapshot("baseline", raster("#111111")),
snapshot("candidate", raster("#cc0000")),
{ pageSemantics: bodySemantics(1) },
);
expect(report.status).toBe("failed");
expect(report.pages[0]?.rasterComparisonMode).toBe("strict");
expect(report.issues.map((issue) => issue.code)).toContain(
"CHANGED_PIXEL_RATIO_EXCEEDED",
);
});
it("所有 failure 级视觉问题都必须进入发布阻断集合", () => {
const failure = {
code: "INK_IOU_BELOW_THRESHOLD" as const,
severity: "failure" as const,
message: "墨迹 IoU 未达到门限",
};
const warning = {
code: "BODY_FLOW_RASTER_DIFFERENCE" as const,
severity: "warning" as const,
message: "正文自然分页差异",
};
expect(getBlockingVisualDiffIssues({ issues: [failure, warning] })).toEqual([
failure,
]);
});
it("正文自然分页 warning 不阻断发布", () => {
expect(
getBlockingVisualDiffIssues({
issues: [
{
code: "BODY_FLOW_RASTER_DIFFERENCE",
severity: "warning",
message: "正文自然分页差异",
},
],
}),
).toEqual([]);
});
});
@@ -0,0 +1,744 @@
import { describe, expect, it } from "vitest";
import { createCanvas } from "@napi-rs/canvas";
import {
comparePdfSemanticBlockVisuals,
isCrossEngineRasterEquivalent,
type PdfRasterMetrics,
} from "../src/index.js";
function metrics(
overrides: Partial<PdfRasterMetrics> = {},
): PdfRasterMetrics {
return {
baselineWidthPx: 224,
baselineHeightPx: 37,
candidateWidthPx: 224,
candidateHeightPx: 37,
comparedWidthPx: 224,
comparedHeightPx: 37,
dimensionsMatch: true,
geometryNormalized: true,
spatialTolerancePx: 4,
meanAbsoluteError: 14.5,
changedPixelRatio: 0.27,
inkIou: 0.942,
edgeIou: 1,
backgroundColorDelta: 0.02,
foregroundColorDelta: 0,
...overrides,
};
}
function whitePageRaster() {
const canvas = createCanvas(400, 400);
const context = canvas.getContext("2d");
context.fillStyle = "#fff";
context.fillRect(0, 0, 400, 400);
context.fillStyle = "#111";
context.fillRect(40, 40, 120, 12);
context.fillRect(40, 80, 120, 12);
const rgba = context.getImageData(0, 0, 400, 400).data;
return {
widthPx: 400,
heightPx: 400,
dpi: 72,
sha256: "test",
png: Uint8Array.from(canvas.toBuffer("image/png")),
rgba,
};
}
function blankPageRaster() {
const canvas = createCanvas(400, 400);
const context = canvas.getContext("2d");
context.fillStyle = "#fff";
context.fillRect(0, 0, 400, 400);
const rgba = context.getImageData(0, 0, 400, 400).data;
return {
widthPx: 400,
heightPx: 400,
dpi: 72,
sha256: "blank",
png: Uint8Array.from(canvas.toBuffer("image/png")),
rgba,
};
}
describe("跨引擎字形栅格等价", () => {
it("缺少局部视觉行时必须阻断而不能静默通过", async () => {
const [comparison] = await comparePdfSemanticBlockVisuals(
{ pages: [] } as never,
{ pages: [] } as never,
[{
expectation: { index: 39, section: "body", blockKind: "table-cell" },
baseline: { matched: true, visualLines: [], lineTexts: [] },
candidate: { matched: true, visualLines: [], lineTexts: [] },
}] as never,
{} as never,
);
expect(comparison?.status).toBe("failed");
expect(comparison?.issues).toEqual([
expect.objectContaining({
code: "SEMANTIC_BLOCK_VISUAL_UNAVAILABLE",
severity: "failure",
}),
]);
});
it("跨引擎换行数不同时按字符区间配对视觉行而不是返回空观测", async () => {
const page = {
pageNumber: 1,
widthPt: 400,
heightPt: 400,
lines: [],
raster: whitePageRaster(),
};
const visualLine = (y: number) => ({
pageNumber: 1,
bounds: { x: 40, y, width: 120, height: 12 },
fontFamilies: ["Test Sans"],
});
const [comparison] = await comparePdfSemanticBlockVisuals(
{ pages: [page] } as never,
{ pages: [page] } as never,
[{
expectation: {
index: 7,
section: "body",
blockKind: "table-cell",
},
baseline: {
matched: true,
visualLines: [visualLine(40), visualLine(80)],
lineTexts: ["甲乙", "丙丁"],
},
candidate: {
matched: true,
visualLines: [visualLine(40)],
lineTexts: ["甲乙丙丁"],
},
}] as never,
{
pixelDifferenceThreshold: 8,
spatialTolerancePx: 4,
minInkIou: 0.75,
minEdgeIou: 0.75,
maxBackgroundColorDelta: 16,
maxForegroundColorDelta: 16,
maxMeanAbsoluteError: 20,
maxChangedPixelRatio: 0.35,
minAntialiasEquivalentInkIou: 0.9,
minAntialiasEquivalentEdgeIou: 0.9,
} as never,
);
expect(comparison?.status).toBe("warning");
expect(comparison?.lines).toHaveLength(2);
expect(comparison?.issues).not.toContainEqual(
expect.objectContaining({ code: "SEMANTIC_BLOCK_VISUAL_UNAVAILABLE" }),
);
expect(comparison?.lines.every((line) =>
line.issues.some((issue) => issue.details?.textReflow === true)
)).toBe(true);
});
it("PDF 文本提取仅遗漏长文本末尾单字时仍比较已有视觉行", async () => {
const page = {
pageNumber: 1,
widthPt: 400,
heightPt: 400,
lines: [],
raster: blankPageRaster(),
};
const visualLine = (y: number) => ({
pageNumber: 1,
bounds: { x: 40, y, width: 108, height: 12 },
fontFamilies: ["Test Sans"],
});
const [comparison] = await comparePdfSemanticBlockVisuals(
{ pages: [page] } as never,
{ pages: [page] } as never,
[{
expectation: {
index: 118,
section: "body",
blockKind: "table-header",
},
baseline: {
matched: true,
matchedCharacterCount: 10,
expectedCharacterCount: 10,
visualLines: [visualLine(40), visualLine(70)],
lineTexts: ["高血压预测模型目标", "值"],
},
candidate: {
matched: false,
matchedCharacterCount: 9,
expectedCharacterCount: 10,
visualLines: [visualLine(40)],
lineTexts: ["高血压预测模型目标"],
},
}] as never,
{
pixelDifferenceThreshold: 8,
spatialTolerancePx: 4,
minInkIou: 0.75,
minEdgeIou: 0.75,
maxBackgroundColorDelta: 16,
maxForegroundColorDelta: 16,
maxMeanAbsoluteError: 20,
maxChangedPixelRatio: 0.35,
minAntialiasEquivalentInkIou: 0.9,
minAntialiasEquivalentEdgeIou: 0.9,
} as never,
);
expect(comparison?.lines).toHaveLength(1);
expect(comparison?.issues).not.toContainEqual(
expect.objectContaining({ code: "SEMANTIC_BLOCK_VISUAL_UNAVAILABLE" }),
);
});
it("表格末尾数学字符未进入 PDF 文本层时仍比较已有视觉行", async () => {
const page = {
pageNumber: 1,
widthPt: 400,
heightPt: 400,
lines: [],
raster: blankPageRaster(),
};
const visualLine = {
pageNumber: 1,
bounds: { x: 40, y: 40, width: 108, height: 12 },
fontFamilies: ["Test Serif"],
};
const [comparison] = await comparePdfSemanticBlockVisuals(
{ pages: [page] } as never,
{ pages: [page] } as never,
[{
expectation: {
index: 71,
section: "body",
blockKind: "table-cell",
mathCharacterIndexes: [3, 4, 5, 6, 11, 12, 13],
},
baseline: {
matched: false,
matchedCharacterCount: 12,
expectedCharacterCount: 14,
visualLines: [visualLine],
lineTexts: ["收缩压≥140或舒张压≥"],
},
candidate: {
matched: true,
matchedCharacterCount: 14,
expectedCharacterCount: 14,
visualLines: [visualLine],
lineTexts: ["收缩压≥140或舒张压≥90"],
},
}] as never,
{
pixelDifferenceThreshold: 8,
spatialTolerancePx: 4,
minInkIou: 0.75,
minEdgeIou: 0.75,
maxBackgroundColorDelta: 16,
maxForegroundColorDelta: 16,
maxMeanAbsoluteError: 20,
maxChangedPixelRatio: 0.35,
minAntialiasEquivalentInkIou: 0.9,
minAntialiasEquivalentEdgeIou: 0.9,
} as never,
);
expect(comparison?.lines).toHaveLength(1);
expect(comparison?.issues).not.toContainEqual(
expect.objectContaining({ code: "SEMANTIC_BLOCK_VISUAL_UNAVAILABLE" }),
);
});
it("视觉行数量相同但字符分段不同时按重叠字符区间比较", async () => {
const page = {
pageNumber: 1,
widthPt: 400,
heightPt: 400,
lines: [],
raster: blankPageRaster(),
};
const visualLine = (y: number, width: number) => ({
pageNumber: 1,
bounds: { x: 40, y, width, height: 12 },
fontFamilies: ["Test Sans"],
});
const [comparison] = await comparePdfSemanticBlockVisuals(
{ pages: [page] } as never,
{ pages: [page] } as never,
[{
expectation: {
index: 117,
section: "body",
blockKind: "table-header",
},
baseline: {
matched: true,
visualLines: [visualLine(40, 20), visualLine(70, 40), visualLine(100, 40)],
lineTexts: ["记", "录/", "计算"],
},
candidate: {
matched: true,
visualLines: [visualLine(40, 40), visualLine(70, 40), visualLine(100, 20)],
lineTexts: ["记录", "/计", "算"],
},
}] as never,
{
pixelDifferenceThreshold: 8,
spatialTolerancePx: 4,
minInkIou: 0.75,
minEdgeIou: 0.75,
maxBackgroundColorDelta: 16,
maxForegroundColorDelta: 16,
maxMeanAbsoluteError: 20,
maxChangedPixelRatio: 0.35,
minAntialiasEquivalentInkIou: 0.9,
minAntialiasEquivalentEdgeIou: 0.9,
} as never,
);
expect(comparison?.status).toBe("warning");
expect(comparison?.lines).toHaveLength(5);
expect(comparison?.issues).not.toContainEqual(
expect.objectContaining({ code: "ELEMENT_COLOR_MISMATCH" }),
);
});
it("接受轮廓、颜色和几何一致的灰阶抗锯齿差异", () => {
expect(isCrossEngineRasterEquivalent(metrics(), false)).toBe(true);
expect(
isCrossEngineRasterEquivalent(
metrics({
candidateWidthPx: 222,
inkIou: 0.88,
foregroundColorDelta: 3.5,
}),
false,
),
).toBe(true);
expect(
isCrossEngineRasterEquivalent(
metrics({
baselineWidthPx: 56,
candidateWidthPx: 56,
baselineHeightPx: 37,
candidateHeightPx: 37,
comparedWidthPx: 56,
comparedHeightPx: 37,
inkIou: 1,
edgeIou: 1,
foregroundColorDelta: 17,
baselineForegroundChroma: 0,
candidateForegroundChroma: 0,
foregroundLuminanceDelta: 17,
}),
false,
"code-block",
),
).toBe(true);
const neutralSingleStrokeMetrics = metrics({
baselineWidthPx: 37,
candidateWidthPx: 37,
baselineHeightPx: 27,
candidateHeightPx: 27,
comparedWidthPx: 37,
comparedHeightPx: 27,
meanAbsoluteError: 12.78,
inkIou: 1,
foregroundInkIou: 1,
edgeIou: 1,
backgroundColorDelta: 10.54,
foregroundColorDelta: 108.69,
baselineForegroundChroma: 10,
candidateForegroundChroma: 5,
foregroundLuminanceDelta: 109.27,
});
expect(
isCrossEngineRasterEquivalent(
neutralSingleStrokeMetrics,
false,
"table-cell",
),
).toBe(true);
expect(
isCrossEngineRasterEquivalent(
{ ...neutralSingleStrokeMetrics, meanAbsoluteError: 16.01 },
false,
"table-cell",
),
).toBe(false);
expect(
isCrossEngineRasterEquivalent(
{ ...neutralSingleStrokeMetrics, candidateForegroundChroma: 12.01 },
false,
"table-cell",
),
).toBe(false);
expect(
isCrossEngineRasterEquivalent(
{ ...neutralSingleStrokeMetrics, edgeIou: 0.994 },
false,
"table-cell",
),
).toBe(false);
expect(
isCrossEngineRasterEquivalent(
metrics({ inkIou: 1, edgeIou: 0.946 }),
false,
"table-header",
),
).toBe(true);
expect(
isCrossEngineRasterEquivalent(
metrics({ inkIou: 0.969, edgeIou: 0.983 }),
false,
"code-block",
),
).toBe(true);
expect(
isCrossEngineRasterEquivalent(
metrics({
baselineWidthPx: 38,
candidateWidthPx: 38,
comparedWidthPx: 38,
inkIou: 0.902,
edgeIou: 0.904,
}),
false,
"code-block",
),
).toBe(true);
expect(
isCrossEngineRasterEquivalent(
metrics({
baselineWidthPx: 236,
candidateWidthPx: 233,
baselineHeightPx: 36,
candidateHeightPx: 38,
inkIou: 0.897,
edgeIou: 0.9996,
}),
false,
"paragraph",
),
).toBe(true);
expect(
isCrossEngineRasterEquivalent(
metrics({
baselineWidthPx: 272,
candidateWidthPx: 286,
baselineHeightPx: 43,
candidateHeightPx: 42,
inkIou: 0.948,
edgeIou: 0.958,
foregroundColorDelta: 4.2,
}),
false,
"list-item",
),
).toBe(true);
expect(
isCrossEngineRasterEquivalent(
metrics({
baselineWidthPx: 262,
candidateWidthPx: 272,
baselineHeightPx: 30,
candidateHeightPx: 31,
comparedWidthPx: 262,
comparedHeightPx: 30,
inkIou: 0.9792,
edgeIou: 0.9869,
foregroundColorDelta: 4.63,
baselineForegroundChroma: 0,
candidateForegroundChroma: 0,
foregroundLuminanceDelta: 4.63,
}),
false,
"list-item",
),
).toBe(true);
expect(
isCrossEngineRasterEquivalent(
metrics({
candidateWidthPx: 209,
inkIou: 0.974,
edgeIou: 0.984,
}),
false,
),
).toBe(true);
expect(
isCrossEngineRasterEquivalent(
metrics({
candidateWidthPx: 208,
inkIou: 0.91,
edgeIou: 0.94,
}),
false,
"list-item",
),
).toBe(true);
});
it("仅在高彩度字形轮廓与前景主色均一致时接受调色差异", () => {
const paletteMetrics = metrics({
inkIou: 1,
edgeIou: 1,
foregroundColorDelta: 35,
baselineForegroundChroma: 141,
candidateForegroundChroma: 176,
dominantForegroundColorDelta: 0.5,
});
expect(
isCrossEngineRasterEquivalent(paletteMetrics, false, "table-cell"),
).toBe(true);
expect(
isCrossEngineRasterEquivalent(
{ ...paletteMetrics, dominantForegroundColorDelta: 4.1 },
false,
"table-cell",
),
).toBe(false);
expect(
isCrossEngineRasterEquivalent(
{ ...paletteMetrics, edgeIou: 0.994 },
false,
"table-cell",
),
).toBe(false);
expect(
isCrossEngineRasterEquivalent(
{ ...paletteMetrics, baselineForegroundChroma: 79 },
false,
"table-cell",
),
).toBe(false);
});
it("多色代码行仅在语法主色和对应墨迹位置均一致时通过", () => {
const syntaxMetrics = metrics({
baselineWidthPx: 407,
candidateWidthPx: 403,
baselineHeightPx: 22,
candidateHeightPx: 23,
inkIou: 0.988,
foregroundInkIou: 1,
edgeIou: 1,
foregroundColorDelta: 92,
chromaticPaletteDelta: 0.8,
});
expect(
isCrossEngineRasterEquivalent(syntaxMetrics, false, "code-block"),
).toBe(true);
expect(
isCrossEngineRasterEquivalent(
{ ...syntaxMetrics, chromaticPaletteDelta: 2.1 },
false,
"code-block",
),
).toBe(false);
expect(
isCrossEngineRasterEquivalent(
{ ...syntaxMetrics, foregroundInkIou: 0.994 },
false,
"code-block",
),
).toBe(false);
expect(
isCrossEngineRasterEquivalent(syntaxMetrics, false, "paragraph"),
).toBe(false);
});
it("仅在行内代码轮廓和底纹严格一致时接受微小前景色差", () => {
const inlineCodeMetrics = metrics({
baselineWidthPx: 236,
candidateWidthPx: 234,
baselineHeightPx: 24,
candidateHeightPx: 25,
comparedWidthPx: 236,
comparedHeightPx: 25,
inkIou: 0.91759,
edgeIou: 1,
backgroundColorDelta: 11.317,
foregroundColorDelta: 3.158,
});
expect(
isCrossEngineRasterEquivalent(
inlineCodeMetrics,
false,
"paragraph",
true,
),
).toBe(true);
expect(
isCrossEngineRasterEquivalent(
{ ...inlineCodeMetrics, foregroundColorDelta: 3.26 },
false,
"paragraph",
true,
),
).toBe(false);
expect(
isCrossEngineRasterEquivalent(
{ ...inlineCodeMetrics, backgroundColorDelta: 12.01 },
false,
"paragraph",
true,
),
).toBe(false);
expect(
isCrossEngineRasterEquivalent(
{ ...inlineCodeMetrics, edgeIou: 0.994 },
false,
"paragraph",
true,
),
).toBe(false);
expect(
isCrossEngineRasterEquivalent(
{
...inlineCodeMetrics,
inkIou: 0.88,
foregroundInkIou: 0.99,
},
false,
"table-cell",
true,
),
).toBe(true);
expect(
isCrossEngineRasterEquivalent(
{
...inlineCodeMetrics,
inkIou: 0.88,
foregroundInkIou: 0.97,
},
false,
"table-cell",
true,
),
).toBe(false);
});
it("拒绝回流、颜色、几何或轮廓变化", () => {
expect(isCrossEngineRasterEquivalent(metrics(), true)).toBe(false);
expect(
isCrossEngineRasterEquivalent(
metrics({ foregroundColorDelta: 4.01 }),
false,
),
).toBe(false);
expect(
isCrossEngineRasterEquivalent(
metrics({ candidateWidthPx: 220 }),
false,
),
).toBe(false);
expect(
isCrossEngineRasterEquivalent(
metrics({
candidateWidthPx: 207,
inkIou: 0.91,
edgeIou: 0.94,
}),
false,
"list-item",
),
).toBe(false);
expect(
isCrossEngineRasterEquivalent(
metrics({
candidateWidthPx: 208,
inkIou: 0.974,
edgeIou: 0.984,
}),
false,
),
).toBe(false);
expect(
isCrossEngineRasterEquivalent(
metrics({
candidateWidthPx: 209,
inkIou: 0.91,
edgeIou: 0.94,
}),
false,
"paragraph",
),
).toBe(false);
expect(
isCrossEngineRasterEquivalent(metrics({ edgeIou: 0.96 }), false),
).toBe(false);
expect(
isCrossEngineRasterEquivalent(
metrics({
baselineWidthPx: 262,
candidateWidthPx: 272,
baselineHeightPx: 30,
candidateHeightPx: 31,
inkIou: 0.9792,
edgeIou: 0.9869,
foregroundColorDelta: 4.63,
baselineForegroundChroma: 2.01,
candidateForegroundChroma: 0,
foregroundLuminanceDelta: 4.63,
}),
false,
"list-item",
),
).toBe(false);
expect(
isCrossEngineRasterEquivalent(
metrics({
baselineWidthPx: 262,
candidateWidthPx: 272,
baselineHeightPx: 30,
candidateHeightPx: 31,
inkIou: 0.9792,
edgeIou: 0.9869,
foregroundColorDelta: 5.01,
baselineForegroundChroma: 0,
candidateForegroundChroma: 0,
foregroundLuminanceDelta: 5.01,
}),
false,
"list-item",
),
).toBe(false);
expect(
isCrossEngineRasterEquivalent(
metrics({
baselineWidthPx: 38,
candidateWidthPx: 38,
comparedWidthPx: 38,
inkIou: 0.89,
edgeIou: 0.904,
}),
false,
"code-block",
),
).toBe(false);
expect(
isCrossEngineRasterEquivalent(
metrics({
baselineHeightPx: 37,
candidateHeightPx: 40,
inkIou: 0.95,
edgeIou: 1,
}),
false,
"paragraph",
),
).toBe(false);
});
});
@@ -19,6 +19,18 @@ describe("PDF.js 页面快照", () => {
expect(first.pages[0]?.widthPt).toBeCloseTo(595, 3);
expect(first.pages[0]?.heightPt).toBeCloseTo(842, 3);
expect(first.contentText).toContain("Visual diff fixture");
expect(first.fonts).toEqual([
expect.objectContaining({
name: "Helvetica",
fallbackFamily: "sans-serif",
isType3Font: false,
missingFile: true,
}),
]);
expect(first.pages[0]?.items[0]).toMatchObject({
fontFamily: "Helvetica",
fallbackFontFamily: "sans-serif",
});
expect(first.pages[0]?.raster).toMatchObject({
widthPx: 1190,
heightPx: 1684,
@@ -40,8 +40,88 @@ describe("PDF 文本行聚合", () => {
]);
});
it("合并 Office 同一视觉行内小于字体高度两成的基线漂移", () => {
const left = item("源系统", 10, 100, 30, 10);
const middle = item("nut_pickup_order.meal_type", 42, 101.8, 140, 10);
const right = item("原值", 184, 100, 20, 10);
const lines = aggregatePdfTextLines([left, middle, right], 842);
expect(lines).toHaveLength(1);
expect(lines[0]?.normalizedText).toBe(
"源系统nut_pickup_order.meal_type原值",
);
});
it("将同行公式的上标片段按横坐标并回正文", () => {
const lines = aggregatePdfTextLines(
[
item("减因子", 10, 100, 42, 12),
item("e", 53, 97, 8, 15),
item("−λΔt", 62, 96, 24, 10),
item("的动态权重", 87, 100, 70, 12),
],
842,
);
expect(lines).toHaveLength(1);
expect(lines[0]?.normalizedText).toBe("减因子e−λΔt的动态权重");
});
it("将超出正文右缘但紧邻行末的公式片段并回正文", () => {
const lines = aggregatePdfTextLines(
[
item("性、记忆重要程度、时效性衰减权重(须支持基于时间衰减因子 e", 150, 469.5, 346, 12),
item("−λΔt", 495.7, 466.1, 26, 10.2),
item("下一视觉行", 150, 489.7, 60, 12),
],
842,
);
expect(lines.map((line) => line.normalizedText)).toEqual([
"性、记忆重要程度、时效性衰减权重(须支持基于时间衰减因子 e−λΔt",
"下一视觉行",
]);
});
it("将字体 ToUnicode 中的传统户部件归一为简体字符", () => {
expect(normalizePdfText("戶⼾")).toBe("户户");
expect(normalizePdfText("⻅⻆⻓⻔⻚⻛⻝⻣⻋⻬")).toBe(
"见角长门页风食骨车齐",
);
});
it("统一 Chromium 与 Office PDF 文本层的中英文弯引号", () => {
expect(normalizePdfText("“记录”与‘计算’")).toBe('"记录"与\'计算\'');
});
it("保留与全角括号重叠的窄引号文本流顺序", () => {
const lines = aggregatePdfTextLines(
[
item("主观定性判断", 532.3, 151.5, 58.5),
item("”", 590.8, 151.5, 3.4),
item("(如", 589.3, 151.5, 19.5),
item("“", 608.8, 151.5, 3.4),
item("高心率占比", 612.2, 151.5, 48.8),
],
595,
);
expect(lines).toHaveLength(1);
expect(lines[0]?.normalizedText).toBe('主观定性判断"(如"高心率占比');
});
it("忽略 Emoji 文本层可选的变体选择符", () => {
expect(normalizePdfText("⚠️ 提示")).toBe("⚠ 提示");
});
it("统一 CJK 字体文本层的 em dash 与 horizontal bar", () => {
expect(normalizePdfText("仓库―仓区")).toBe("仓库—仓区");
});
it("统一数字区间中被 Chromium 提取为双连字符的 en dash", () => {
expect(normalizePdfText("10% -- 20%")).toBe("10%20%");
expect(normalizePdfText("10% --")).toBe("10%");
expect(normalizePdfText("命令 --flag")).toBe("命令 --flag");
});
it("从可编辑正文契约中移除 Word 自动列表装饰符", () => {
@@ -98,6 +98,64 @@ local function replace_code_block(block)
return pandoc.Para { image }
end
local function preserve_raw_html_as_text(inline)
if inline.format == "html" then
return pandoc.Str(inline.text)
end
return inline
end
local function preserve_raw_html_block_as_text(block)
if block.format == "html" then
return pandoc.Para { pandoc.Str(block.text) }
end
return block
end
local function replace_table_breaks(table_block)
return table_block:walk {
RawInline = function(inline)
if inline.format ~= "html" then
return inline
end
local normalized = string.lower(inline.text)
if normalized == "<br>"
or normalized == "<br/>"
or normalized == "<br />" then
return pandoc.LineBreak()
end
return inline
end
}
end
local function normalize_task_list(list)
for _, item in ipairs(list.content) do
local block = item[1]
if block ~= nil and (block.t == "Plain" or block.t == "Para") then
local inlines = block.content
local first = inlines[1]
if first ~= nil and first.t == "Str" then
local marker = string.lower(first.text)
if marker == "[x]" then
first.text = ""
elseif marker == "[" then
local second = inlines[2]
local third = inlines[3]
if second ~= nil and second.t == "Space"
and third ~= nil and third.t == "Str"
and third.text == "]" then
first.text = ""
inlines:remove(3)
inlines:remove(2)
end
end
end
end
end
return list
end
local function append_inlines(target, value)
target:extend(pandoc.Inlines(value))
end
@@ -167,7 +225,10 @@ local function validate_counts()
error(
"DOCX media map contains unused "
.. kind
.. " resources"
.. " resources: consumed "
.. tostring(counters[kind])
.. " of "
.. tostring(#media_map[kind])
)
end
end
@@ -176,8 +237,14 @@ end
return {
Pandoc = function(document)
local transformed = document:walk {
Table = replace_table_breaks
}
transformed = transformed:walk {
Image = replace_image,
CodeBlock = replace_code_block
CodeBlock = replace_code_block,
RawInline = preserve_raw_html_as_text,
RawBlock = preserve_raw_html_block_as_text,
BulletList = normalize_task_list
}
if structure_plan.titlePolicy.metadataTitle == "suppress" then
transformed.meta.title = nil
@@ -424,7 +424,7 @@ for (const theme of themes) {
let conversion;
try {
conversion = await converter.convert({
markdown,
markdown: rendered.markdownBody,
fileName: `${theme.id}.md`,
language: rendered.metadata.language,
exportConfig,
@@ -433,7 +433,7 @@ for (const theme of themes) {
metadata: rendered.metadata,
semanticDocument: rendered.semanticDocument,
fonts: readThemeFonts(theme),
media: createMedia(markdown)
media: createMedia(rendered.markdownBody)
});
} catch (error) {
throw new Error(`主题 ${theme.id} 的 DOCX 转换失败`, {
File diff suppressed because it is too large Load Diff
+27 -4
View File
@@ -103,6 +103,12 @@ export interface PandocDocxConverterOptions {
referenceCacheSize?: number;
}
export interface PandocDocxConversionDiagnostics {
outcome: string;
exitCode: number | null;
stderr: string;
}
export class PandocDocxConversionError extends Error {
constructor(
readonly code: Extract<
@@ -113,11 +119,26 @@ export class PandocDocxConversionError extends Error {
| "DOCX_OUTPUT_INVALID"
>,
message: string,
options?: ErrorOptions
options?: ErrorOptions & {
diagnostics?: PandocDocxConversionDiagnostics;
}
) {
super(message, options);
this.name = "PandocDocxConversionError";
this.diagnostics = options?.diagnostics;
}
readonly diagnostics: PandocDocxConversionDiagnostics | undefined;
}
function processDiagnostics(
result: Awaited<ReturnType<PandocProcessRunner>>
): PandocDocxConversionDiagnostics {
return {
outcome: result.outcome,
exitCode: result.exitCode,
stderr: result.stderr.trim().slice(-16 * 1024)
};
}
function ensureOutputLimit(value: number | undefined) {
@@ -172,7 +193,7 @@ function pandocArguments(paths: {
return [
paths.markdown,
"--from",
"markdown+yaml_metadata_block+pipe_tables+footnotes+task_lists+tex_math_dollars",
"commonmark_x-yaml_metadata_block+pipe_tables+footnotes+task_lists+tex_math_dollars+raw_html",
"--to",
"docx",
"--standalone",
@@ -351,7 +372,8 @@ export class PandocDocxConverter {
processResult.outcome === "not-found"
? "DOCX_RUNTIME_NOT_FOUND"
: "DOCX_GENERATION_FAILED",
"Pandoc 未能生成 DOCX"
"Pandoc 未能生成 DOCX",
{ diagnostics: processDiagnostics(processResult) }
);
}
@@ -375,7 +397,8 @@ export class PandocDocxConverter {
pandocDocx,
structurePlan,
input.themeTokens,
preparedMedia.layout
preparedMedia.layout,
preparedMedia.documentLayout
);
docx = embedFontsInGeneratedDocx(
finalized.content,
+10 -3
View File
@@ -2,10 +2,11 @@ import {
DOCX_MEDIA_CSS_DPI,
MAXIMUM_DOCX_MEDIA_BYTES,
MAXIMUM_DOCX_MEDIA_EDGE_PIXELS,
MAXIMUM_DOCX_MEDIA_COUNT,
MAXIMUM_DOCX_MEDIA_PIXELS,
MAXIMUM_DOCX_RESOURCE_COUNT,
MAXIMUM_DOCX_TOTAL_MEDIA_BYTES,
type DocxMediaKind,
type DocxDocumentLayoutPlan,
type PreparedDocxMedia
} from "@md-to-pdf/core";
@@ -47,6 +48,7 @@ export interface PreparedPandocMedia {
map: PandocMediaMap;
files: PandocMediaFile[];
layout: PandocMediaLayoutItem[];
documentLayout: DocxDocumentLayoutPlan;
}
export const DOCX_MEDIA_BINDING_PREFIX = "mdtp-media:";
@@ -99,7 +101,7 @@ export function preparePandocMedia(
if (media.echartsErrors.length || media.mermaidErrors.length) {
throw new Error("DOCX 图表渲染存在错误,已中止转换");
}
if (media.resources.length > MAXIMUM_DOCX_RESOURCE_COUNT) {
if (media.resources.length > MAXIMUM_DOCX_MEDIA_COUNT) {
throw new Error("DOCX 媒体数量超过限制");
}
@@ -184,5 +186,10 @@ export function preparePandocMedia(
if (media.totalBytes !== totalBytes) {
throw new Error("DOCX 媒体总大小声明不一致");
}
return { map, files, layout };
return {
map,
files,
layout,
documentLayout: media.documentLayout ?? { tables: [] }
};
}
@@ -69,9 +69,17 @@ const containerRoles = new Set<SemanticDocumentRole>([
"tender-cover"
]);
const semanticRoleSlotAliases: Readonly<
Partial<Record<SemanticDocumentRole, DocxStyleSlotName>>
> = {};
function slotName(
role: SemanticDocumentRole
): DocxStyleSlotName | undefined {
const alias = semanticRoleSlotAliases[role];
if (alias) {
return alias;
}
return Object.prototype.hasOwnProperty.call(
DOCX_SLOT_WORD_STYLE_BINDINGS,
role
@@ -139,6 +139,19 @@ export function transformSettingsXml(
) {
const document = parseXmlPart(content, "word/settings.xml");
const settings = document.documentElement!;
const compatibility =
firstDirectChild(settings, WORD_NAMESPACE, "compat") ??
appendElement(settings, WORD_NAMESPACE, "w:compat");
removeDirectChildren(
compatibility,
WORD_NAMESPACE,
"doNotExpandShiftReturn"
);
appendElement(
compatibility,
WORD_NAMESPACE,
"w:doNotExpandShiftReturn"
);
removeDirectChildren(
settings,
WORD_NAMESPACE,
+131 -21
View File
@@ -7,8 +7,10 @@ import type {
import type { ResolvedDocxThemeStyle } from "./style-presets.js";
import {
DOCX_SLOT_WORD_STYLE_BINDINGS,
PANDOC_SYNTAX_STYLE_IDS,
collectDocxTokenFonts,
createDocxTokenSlotMap,
resolvePandocSyntaxStyleSlot,
resolveTokenFonts
} from "./token-style-map.js";
import {
@@ -64,7 +66,8 @@ function setRunStyle(
"iCs",
"u",
"shd",
"spacing"
"spacing",
"kern"
);
if (options.fonts) {
setFonts(parent, options.fonts);
@@ -106,6 +109,9 @@ function setRunStyle(
"w:val": wordCharacterSpacingTwips(options.sizePt)
});
}
appendElement(parent, WORD_NAMESPACE, "w:kern", {
"w:val": "2"
});
}
function setParagraphSpacing(
@@ -200,8 +206,16 @@ function fallbackFontsForSlot(
if (slot.startsWith("heading-") || slot.endsWith("-title")) {
return fallback.headings.fonts;
}
if (slot === "inline-code" || slot === "code-block") {
return fallback.code.fonts;
if (
slot === "inline-code" ||
slot === "code-block" ||
slot === "code-block-text" ||
slot.startsWith("code-token-")
) {
return {
...fallback.code.fonts,
eastAsia: fallback.body.fonts.eastAsia
};
}
if (
slot === "table" ||
@@ -232,7 +246,12 @@ function fallbackFontSizeForSlot(
if (slot.endsWith("-title")) {
return fallback.headings.sizesPt[0];
}
if (slot === "inline-code" || slot === "code-block") {
if (
slot === "inline-code" ||
slot === "code-block" ||
slot === "code-block-text" ||
slot.startsWith("code-token-")
) {
return fallback.code.sizePt;
}
if (
@@ -251,10 +270,17 @@ function fallbackFontSizeForSlot(
function applyTokenRunStyle(
parent: XmlElement,
token: DocxSlotStyleToken,
fallbackFonts: DocxFontFamily
fallbackFonts: DocxFontFamily,
preserveFallbackEastAsia = false
) {
removeDirectChildren(parent, WORD_NAMESPACE, "kern");
appendElement(parent, WORD_NAMESPACE, "w:kern", {
"w:val": "2"
});
if (token.fontCandidates.length) {
setFonts(parent, resolveTokenFonts(token, fallbackFonts));
setFonts(parent, resolveTokenFonts(token, fallbackFonts, {
preserveFallbackEastAsia
}));
}
if (token.fontSizePt !== undefined) {
removeDirectChildren(parent, WORD_NAMESPACE, "sz", "szCs");
@@ -441,8 +467,13 @@ function applyTokenParagraphStyle(
(token.paddingPt?.right ?? 0) > 0 ||
(token.borders?.right?.widthPt ?? 0) > 0
? (token.rightIndentPt ?? 0) +
(token.paddingPt?.right ?? 0) +
(token.borders?.right?.widthPt ?? 0)
// 带边框代码块通过 w:pBdr/w:space 表达 CSS 右内边距。
// 若再把 padding/border 写进 w:indWord/WPS 会重复扣减
// 内容宽度,使本可容纳的等宽代码行在末尾额外回流。
(horizontalPaddingThroughBorderSpace && token.borders?.right
? 0
: (token.paddingPt?.right ?? 0) +
(token.borders?.right?.widthPt ?? 0))
: undefined;
if (
token.firstLineIndentPt !== undefined ||
@@ -1165,26 +1196,105 @@ function applyTokenStyles(
binding.styleId === "ImageCaption"
? { ...entry.style, alignment: "center" as const }
: entry.style;
applyTokenParagraphStyle(
ensureDirectElement(
target,
const paragraphProperties = ensureDirectElement(
target,
WORD_NAMESPACE,
"w:pPr"
);
// SourceCode 的主题令牌来自浏览器完整计算样式。背景透明时,
// normalizer 会省略 backgroundColor;这里必须先清除基础模板的
// 兜底底纹,否则模板灰底会泄漏成主题代码块的整段背景。
if (binding.styleId === "SourceCode") {
removeDirectChildren(
paragraphProperties,
WORD_NAMESPACE,
"w:pPr"
),
"shd"
);
}
applyTokenParagraphStyle(
paragraphProperties,
paragraphToken,
fallbackFontSizeForSlot(slot, fallback),
binding.styleId === "SourceCode"
);
}
applyTokenRunStyle(
ensureDirectElement(
target,
WORD_NAMESPACE,
"w:rPr"
),
entry.style,
fallbackFontsForSlot(slot, fallback)
const runToken = binding.styleId === "SourceCode"
? slots.get("code-block-text")?.style ?? entry.style
: entry.style;
const runProperties = ensureDirectElement(
target,
WORD_NAMESPACE,
"w:rPr"
);
if (binding.styleId === "SourceCode") {
removeDirectChildren(
runProperties,
WORD_NAMESPACE,
"shd"
);
}
applyTokenRunStyle(
runProperties,
runToken,
fallbackFontsForSlot(slot, fallback),
binding.styleId === "SourceCode" ||
slot === "inline-code" ||
slot === "code-block" ||
slot === "code-block-text" ||
slot.startsWith("code-token-")
);
}
}
const codeTextToken =
slots.get("code-block-text")?.style ??
slots.get("code-block")?.style;
if (codeTextToken) {
const baseStyleId = "MdSourceCodeChar";
const baseStyle = ensureStyle(
styles,
baseStyleId,
"character",
"BodyTextChar"
);
applyTokenRunStyle(
ensureDirectElement(baseStyle, WORD_NAMESPACE, "w:rPr"),
codeTextToken,
{
...fallback.code.fonts,
eastAsia: fallback.body.fonts.eastAsia
},
true
);
for (const styleId of PANDOC_SYNTAX_STYLE_IDS) {
const target = ensureStyle(
styles,
styleId,
"character",
baseStyleId
);
const basedOn = firstDirectChild(
target,
WORD_NAMESPACE,
"basedOn"
);
if (basedOn) {
setWordAttribute(basedOn, "val", baseStyleId);
}
const syntaxSlot = resolvePandocSyntaxStyleSlot(styleId);
const syntaxToken = syntaxSlot
? slots.get(syntaxSlot)?.style
: undefined;
if (syntaxToken) {
applyTokenRunStyle(
ensureDirectElement(target, WORD_NAMESPACE, "w:rPr"),
syntaxToken,
{
...fallback.code.fonts,
eastAsia: fallback.body.fonts.eastAsia
},
true
);
}
}
}
applyTableTokenStyles(styles, slots, fallback);
+101 -2
View File
@@ -110,6 +110,7 @@ export const DOCX_SLOT_WORD_STYLE_BINDINGS: Readonly<
paragraph("MdOfficialPrintingRow")
],
"briefing-masthead": [paragraph("MdBriefingMasthead")],
"briefing-masthead-text": [paragraph("MdBriefingMastheadText")],
"briefing-meta": [paragraph("MdBriefingMeta")],
"briefing-title": [paragraph("MdBriefingTitle")],
"briefing-contact": [paragraph("MdBriefingContact")],
@@ -141,6 +142,101 @@ export const DOCX_SLOT_WORD_STYLE_BINDINGS: Readonly<
"tender-date": [paragraph("MdTenderDate")]
};
export const PANDOC_SYNTAX_STYLE_IDS = [
"AlertTok",
"AnnotationTok",
"AttributeTok",
"BaseNTok",
"BuiltInTok",
"CharTok",
"CommentTok",
"CommentVarTok",
"ConstantTok",
"ControlFlowTok",
"DataTypeTok",
"DecValTok",
"DocumentationTok",
"ErrorTok",
"ExtensionTok",
"FloatTok",
"FunctionTok",
"ImportTok",
"InformationTok",
"KeywordTok",
"NormalTok",
"OperatorTok",
"OtherTok",
"PreprocessorTok",
"SpecialCharTok",
"SpecialStringTok",
"StringTok",
"VariableTok",
"VerbatimStringTok",
"WarningTok"
] as const;
const pandocSyntaxSlots: Readonly<
Partial<Record<(typeof PANDOC_SYNTAX_STYLE_IDS)[number], DocxStyleSlotName>>
> = {
AlertTok: "code-token-keyword",
AnnotationTok: "code-token-comment",
AttributeTok: "code-token-attribute",
BaseNTok: "code-token-number",
BuiltInTok: "code-token-built-in",
CharTok: "code-token-string",
CommentTok: "code-token-comment",
CommentVarTok: "code-token-comment",
ConstantTok: "code-token-literal",
ControlFlowTok: "code-token-keyword",
DataTypeTok: "code-token-attribute",
DecValTok: "code-token-number",
DocumentationTok: "code-token-comment",
ErrorTok: "code-token-keyword",
ExtensionTok: "code-token-built-in",
FloatTok: "code-token-number",
FunctionTok: "code-token-title",
ImportTok: "code-token-keyword",
InformationTok: "code-token-comment",
KeywordTok: "code-token-keyword",
OperatorTok: "code-token-operator",
PreprocessorTok: "code-token-meta",
SpecialCharTok: "code-token-string",
SpecialStringTok: "code-token-string",
StringTok: "code-token-string",
VariableTok: "code-token-variable",
VerbatimStringTok: "code-token-string",
WarningTok: "code-token-keyword"
};
export function resolvePandocSyntaxStyleSlot(
styleId: string,
text = "",
context: { htmlTagName?: boolean; objectPropertyKey?: boolean } = {}
): DocxStyleSlotName | "code-block-text" | undefined {
if (!(PANDOC_SYNTAX_STYLE_IDS as readonly string[]).includes(styleId)) {
return undefined;
}
if (context.htmlTagName) {
return "code-token-name";
}
if (context.objectPropertyKey) {
return "code-token-attribute";
}
if (
styleId === "NormalTok" ||
styleId === "OtherTok" ||
((styleId === "FunctionTok" || styleId === "DataTypeTok") &&
/^[\p{P}\p{S}\s]+$/u.test(text))
) {
return styleId === "FunctionTok" || styleId === "DataTypeTok"
? "code-token-punctuation"
: "code-block-text";
}
return pandocSyntaxSlots[
styleId as (typeof PANDOC_SYNTAX_STYLE_IDS)[number]
] ?? "code-block-text";
}
const eastAsiaFontPattern =
/(?:fandol|yahei|simsun|simhei|fangsong|kaiti|dengxian|cjk|source\s+han|pingfang|heiti|songti|||仿||线)/iu;
const genericFontNames = new Set([
@@ -154,7 +250,8 @@ const genericFontNames = new Set([
export function resolveTokenFonts(
token: DocxSlotStyleToken | undefined,
fallback: DocxFontFamily
fallback: DocxFontFamily,
options: { preserveFallbackEastAsia?: boolean } = {}
): DocxFontFamily {
const candidates = (token?.fontCandidates ?? []).filter(
(candidate) =>
@@ -166,7 +263,9 @@ export function resolveTokenFonts(
const eastAsia =
candidates.find((candidate) =>
eastAsiaFontPattern.test(candidate)
) ?? candidates[0]!;
) ?? (options.preserveFallbackEastAsia
? fallback.eastAsia
: candidates[0]!);
const latin =
candidates.find(
(candidate) => !eastAsiaFontPattern.test(candidate)
@@ -22,6 +22,8 @@ const drawing =
"http://schemas.openxmlformats.org/drawingml/2006/main";
const picture =
"http://schemas.openxmlformats.org/drawingml/2006/picture";
const math =
"http://schemas.openxmlformats.org/officeDocument/2006/math";
function generatedFixture() {
const baseline = readReferenceDocxPackage(
@@ -33,16 +35,24 @@ function generatedFixture() {
encoder.encode(
`<w:document xmlns:w="${word}" xmlns:r="${relationships}"><w:body>` +
`<w:p><w:r><w:t>CONTAINER_START</w:t></w:r></w:p>` +
`<w:p><w:pPr><w:pStyle w:val="MdOfficialIssueRow"/></w:pPr><w:r><w:t>发文字号</w:t></w:r><w:r><w:tab/></w:r><w:r><w:t>签发人</w:t></w:r></w:p>` +
`<w:p><w:pPr><w:pStyle w:val="MdOfficialIssueRow"/></w:pPr><w:r><w:t>号</w:t></w:r><w:r><w:tab/></w:r><w:r><w:t>发布单位</w:t></w:r><w:r><w:tab/></w:r><w:r><w:t>签发人</w:t></w:r><w:r><w:tab/></w:r><w:r><w:t>日期</w:t></w:r></w:p>` +
`<w:p><w:pPr><w:pStyle w:val="MdTenderTitle"/></w:pPr><w:r><w:t>可编辑封面</w:t></w:r></w:p>` +
`<w:p><w:r><w:t>CONTAINER_END</w:t></w:r></w:p>` +
`<w:p><w:r><w:t>SECTION_BREAK</w:t></w:r></w:p>` +
`<w:p><w:pPr><w:pStyle w:val="Heading1"/></w:pPr><w:r><w:t>正文标题</w:t></w:r></w:p>` +
`<w:p><w:pPr><w:pStyle w:val="SourceCode"/></w:pPr>` +
`<w:r><w:rPr><w:rStyle w:val="DataTypeTok"/></w:rPr><w:t>&lt;</w:t></w:r>` +
`<w:r><w:rPr><w:rStyle w:val="KeywordTok"/></w:rPr><w:t>br</w:t></w:r>` +
`<w:r><w:rPr><w:rStyle w:val="DataTypeTok"/></w:rPr><w:t>&gt;</w:t></w:r></w:p>` +
`<w:p><w:pPr><w:pStyle w:val="SourceCode"/></w:pPr>` +
`<w:r><w:rPr><w:rStyle w:val="NormalTok"/></w:rPr><w:t xml:space="preserve"> method</w:t></w:r>` +
`<w:r><w:rPr><w:rStyle w:val="OperatorTok"/></w:rPr><w:t>:</w:t></w:r>` +
`<w:r><w:rPr><w:rStyle w:val="StringTok"/></w:rPr><w:t xml:space="preserve"> "POST"</w:t></w:r></w:p>` +
`<w:p><w:pPr><w:pStyle w:val="FirstParagraph"/></w:pPr><w:r><w:drawing>` +
`<wp:inline xmlns:wp="${wordprocessingDrawing}"><wp:extent cx="100" cy="200"/><wp:docPr id="1" name="Picture" descr="Mermaid 图表 1" title="mdtp-media:docx-media-1"/>` +
`<a:graphic xmlns:a="${drawing}"><a:graphicData uri="${picture}"><pic:pic xmlns:pic="${picture}"><pic:nvPicPr><pic:cNvPr id="0" name="image.png"/></pic:nvPicPr><pic:blipFill><a:blip r:embed="rIdImage"/><a:stretch><a:fillRect/></a:stretch></pic:blipFill><pic:spPr><a:xfrm rot="60000" flipH="1"><a:off x="0" y="0"/><a:ext cx="100" cy="200"/></a:xfrm></pic:spPr></pic:pic></a:graphicData></a:graphic>` +
`</wp:inline></w:drawing></w:r></w:p>` +
`<w:tbl><w:tblPr><w:tblW w:w="0" w:type="auto"/></w:tblPr><w:tblGrid><w:gridCol w:w="1000"/><w:gridCol w:w="2000"/></w:tblGrid><w:tr><w:trPr><w:tblHeader/></w:trPr><w:tc><w:tcPr/><w:p><w:r><w:t>A</w:t></w:r></w:p></w:tc><w:tc><w:tcPr/><w:p><w:r><w:t>B</w:t></w:r></w:p></w:tc></w:tr><w:tr><w:tc><w:tcPr/><w:p><w:r><w:t>C</w:t></w:r></w:p></w:tc><w:tc><w:tcPr/><w:p><w:r><w:t>D</w:t></w:r></w:p></w:tc></w:tr></w:tbl>` +
`<w:tbl><w:tblPr><w:tblW w:w="0" w:type="auto"/></w:tblPr><w:tblGrid><w:gridCol w:w="1000"/><w:gridCol w:w="2000"/></w:tblGrid><w:tr><w:trPr><w:tblHeader/></w:trPr><w:tc><w:tcPr/><w:p><w:r><w:t>A</w:t></w:r></w:p></w:tc><w:tc><w:tcPr/><w:p><w:r><w:t>B</w:t></w:r></w:p></w:tc></w:tr><w:tr><w:tc><w:tcPr/><w:p><w:r><w:t>C</w:t></w:r></w:p></w:tc><w:tc><w:tcPr/><w:p><w:r><w:t>单元格前</w:t></w:r><w:r><w:rPr><w:rStyle w:val="VerbatimChar"/></w:rPr><w:t>mdtp_ic_c</w:t></w:r><w:r><w:t>单元格后</w:t></w:r></w:p></w:tc></w:tr></w:tbl>` +
`<w:p><w:pPr><w:pStyle w:val="MdOfficialSignatureDate"/></w:pPr><w:r><w:t>2026年7月29日</w:t></w:r></w:p>` +
`<w:sectPr><w:footerReference w:type="default" r:id="rIdFooter"/><w:pgSz w:w="11906" w:h="16838"/><w:pgMar w:top="1000" w:right="1000" w:bottom="1000" w:left="1000"/><w:pgNumType w:start="5"/><w:titlePg/></w:sectPr>` +
`</w:body></w:document>`
@@ -78,7 +88,7 @@ const plan: PandocStructurePlan = {
kind: "paragraph",
styleId: "MdOfficialIssueRow",
layout: "space-between",
segments: ["发文字号", "签发人"],
segments: ["号", "发布单位", "签发人", "日期"],
separator: "tab"
}
]
@@ -161,11 +171,68 @@ const tokens: DocxThemeTokenSet = {
source: "computed-css",
confidence: "exact",
style: {
fontCandidates: [],
fontCandidates: ["Body Face"],
fontSizePt: 10,
lineSpacing: 1.8
}
},
{
slot: "inline-code",
source: "computed-css",
confidence: "exact",
style: {
fontCandidates: ["Code Face"],
fontSizePt: 9,
color: "#112233",
backgroundColor: "#f5f5f5",
paddingPt: {
top: 3,
right: 6,
bottom: 3,
left: 6
}
}
},
{
slot: "code-block-text",
source: "computed-css",
confidence: "exact",
style: {
fontCandidates: ["Code Face"],
fontSizePt: 9,
color: "#24292e"
}
},
{
slot: "code-token-punctuation",
source: "computed-css",
confidence: "exact",
style: {
fontCandidates: ["Code Face"],
fontSizePt: 9,
color: "#7a7a7a"
}
},
{
slot: "code-token-name",
source: "computed-css",
confidence: "exact",
style: {
fontCandidates: ["Code Face"],
fontSizePt: 9,
color: "#22863a"
}
},
{
slot: "code-token-attribute",
source: "computed-css",
confidence: "exact",
style: {
fontCandidates: ["Code Face"],
fontSizePt: 9,
color: "#005cc5"
}
},
{
slot: "table-header",
source: "computed-css",
@@ -213,7 +280,18 @@ describe("生成 DOCX 结构收口", () => {
const result = finalizeGeneratedDocxStructure(
generatedFixture(),
plan,
tokens
tokens,
[],
{
tables: [],
textBlocks: [{
ordinal: 1,
text: "期号发布单位签发人日期",
letterSpacingPt: 0,
linePitchPt: 17.25,
lineBreakOffsets: [6]
}]
}
);
const entries = readGeneratedDocxPackage(
result.content
@@ -237,6 +315,9 @@ describe("生成 DOCX 结构收口", () => {
expect(documentXml).toContain(
'<w:vAlign w:val="center"/>'
);
expect(documentXml).toContain(
'w:line="345" w:lineRule="exact"'
);
expect(documentXml).toContain(
'<w:pgNumType w:start="1"/>'
);
@@ -261,7 +342,12 @@ describe("生成 DOCX 结构收口", () => {
documentXml.match(
/<w:spacing[^>]*w:before="0"[^>]*w:after="0"[^>]*w:line="360"[^>]*w:lineRule="exact"/gu
)
).toHaveLength(2);
).toHaveLength(1);
expect(
documentXml.match(
/<w:spacing[^>]*w:before="0"[^>]*w:after="0"[^>]*w:line="360"[^>]*w:lineRule="atLeast"/gu
)
).toHaveLength(1);
expect(documentXml.match(/w:fill="17324D"/gu)).toHaveLength(2);
expect(documentXml.match(/w:val="FFFFFF"/gu)).toHaveLength(2);
expect(
@@ -273,12 +359,20 @@ describe("生成 DOCX 结构收口", () => {
expect(tableParagraph).toContain('<w:autoSpaceDE w:val="0"/>');
expect(tableParagraph).toContain('<w:autoSpaceDN w:val="0"/>');
expect(documentXml).toContain("<w:cantSplit/>");
expect(documentXml).toMatch(
/<w:p><w:pPr><w:pStyle w:val="SourceCode"\/>[\s\S]*?<w:color w:val="7A7A7A"\/>[\s\S]*?<w:t>&lt;<\/w:t>[\s\S]*?<w:color w:val="22863A"\/>[\s\S]*?<w:t>br<\/w:t>[\s\S]*?<w:color w:val="7A7A7A"\/>[\s\S]*?<w:t>&gt;<\/w:t>[\s\S]*?<\/w:p>/u
);
expect(documentXml).toMatch(
/<w:p><w:pPr><w:pStyle w:val="SourceCode"\/>[\s\S]*?<w:rStyle w:val="NormalTok"\/>[\s\S]*?<w:color w:val="005CC5"\/>[\s\S]*?<w:t xml:space="preserve"> method<\/w:t>[\s\S]*?<\/w:p>/u
);
expect(documentXml).toContain(
'<w:tab w:val="right" w:pos="9806"/>'
);
expect(documentXml).not.toContain('<w:tab w:val="center"');
expect(documentXml).toContain("<w:br/>");
expect(documentXml).toContain('w:fill="F5F5F5"');
expect(documentXml).toContain(
'<w:spacing w:before="140"/>'
'w:spacing w:before="140"'
);
expect(documentXml).toContain(
'<w:spacing w:after="1020"/>'
@@ -318,6 +412,379 @@ describe("生成 DOCX 结构收口", () => {
});
});
it("将 Chromium 避头尾规则留下的单个汉字尾行翻译为显式换行", () => {
const fixture = readGeneratedDocxPackage(generatedFixture());
const documentXml = decoder.decode(
fixture.entries.get("word/document.xml")!
).replace(
'<w:p><w:pPr><w:pStyle w:val="Heading1"/></w:pPr><w:r><w:t>正文标题</w:t></w:r></w:p>',
'<w:p><w:pPr><w:pStyle w:val="Heading1"/></w:pPr><w:r><w:t>正文标题</w:t></w:r></w:p>' +
'<w:p><w:pPr><w:pStyle w:val="FirstParagraph"/><w:jc w:val="both"/></w:pPr><w:r><w:t>浏览器末行保留项目。</w:t></w:r></w:p>'
);
fixture.entries.set("word/document.xml", encoder.encode(documentXml));
const result = finalizeGeneratedDocxStructure(
writeGeneratedDocxPackage(fixture.entries),
plan,
tokens,
[],
{
tables: [],
textBlocks: [{
ordinal: 1,
text: "浏览器末行保留项目。",
letterSpacingPt: 0,
alignment: "justify",
lineBreakOffsets: [8]
}]
}
);
const outputXml = decoder.decode(
readGeneratedDocxPackage(result.content).entries.get(
"word/document.xml"
)!
);
expect(outputXml).toContain(
'<w:t>浏览器末行保留项</w:t><w:br/><w:t>目。</w:t>'
);
});
it("含行内代码的实测行距允许 Word 扩展行盒", () => {
const fixture = readGeneratedDocxPackage(generatedFixture());
const documentXml = decoder.decode(
fixture.entries.get("word/document.xml")!
).replace(
"<w:sectPr>",
'<w:p><w:r><w:t>前缀</w:t></w:r><w:r><w:rPr><w:rStyle w:val="VerbatimChar"/></w:rPr><w:t>inline</w:t></w:r><w:r><w:t>后缀</w:t></w:r></w:p><w:sectPr>'
);
fixture.entries.set("word/document.xml", encoder.encode(documentXml));
const result = finalizeGeneratedDocxStructure(
writeGeneratedDocxPackage(fixture.entries),
plan,
tokens,
[],
{
tables: [],
textBlocks: [{
ordinal: 1,
text: "前缀inline后缀",
letterSpacingPt: 0,
linePitchPt: 11.15,
lineBreakOffsets: []
}]
}
);
const outputXml = decoder.decode(
readGeneratedDocxPackage(result.content).entries.get(
"word/document.xml"
)!
);
expect(outputXml).toMatch(
/w:line="223" w:lineRule="atLeast"[\s\S]*?<w:t><\/w:t>[\s\S]*?<w:rStyle w:val="VerbatimChar"\/>/u
);
});
it("未匹配实测布局的行内代码段落也不保留 exact 行盒", () => {
const fixture = readGeneratedDocxPackage(generatedFixture());
const documentXml = decoder.decode(
fixture.entries.get("word/document.xml")!
).replace(
"<w:sectPr>",
'<w:p><w:pPr><w:spacing w:line="312" w:lineRule="exact"/></w:pPr><w:r><w:t>前缀</w:t></w:r><w:r><w:rPr><w:rStyle w:val="VerbatimChar"/></w:rPr><w:t>inline</w:t></w:r></w:p><w:sectPr>'
);
fixture.entries.set("word/document.xml", encoder.encode(documentXml));
const result = finalizeGeneratedDocxStructure(
writeGeneratedDocxPackage(fixture.entries),
plan,
tokens
);
const outputXml = decoder.decode(
readGeneratedDocxPackage(result.content).entries.get(
"word/document.xml"
)!
);
expect(outputXml).toMatch(
/w:line="312" w:lineRule="atLeast"[\s\S]*?<w:rStyle w:val="VerbatimChar"\/>/u
);
});
it("表格纯公式段落加入零宽普通 Run 以服从段落对齐", () => {
const fixture = readGeneratedDocxPackage(generatedFixture());
const documentXml = decoder.decode(
fixture.entries.get("word/document.xml")!
).replace(
`xmlns:r="${relationships}"`,
`xmlns:r="${relationships}" xmlns:m="${math}"`
).replace(
"<w:p><w:r><w:t>B</w:t></w:r></w:p>",
'<w:p><w:pPr><w:jc w:val="left"/></w:pPr><m:oMath><m:r><m:t>≥0.80</m:t></m:r></m:oMath></w:p>'
);
fixture.entries.set("word/document.xml", encoder.encode(documentXml));
const result = finalizeGeneratedDocxStructure(
writeGeneratedDocxPackage(fixture.entries),
plan,
tokens
);
const outputXml = decoder.decode(
readGeneratedDocxPackage(result.content).entries.get(
"word/document.xml"
)!
);
expect(outputXml).toMatch(
/<w:jc w:val="left"\/>[\s\S]*?<m:oMath>[\s\S]*?<m:t>0\.80<\/m:t>[\s\S]*?<\/m:oMath><w:r><w:rPr><w:noProof\/><\/w:rPr><w:t xml:space="preserve">\u200B<\/w:t><\/w:r>/u
);
});
it("为右对齐盒模型保留 CSS 右侧内容内缩", () => {
const fixture = readGeneratedDocxPackage(generatedFixture());
const documentXml = decoder.decode(
fixture.entries.get("word/document.xml")!
).replace(
'<w:p><w:pPr><w:pStyle w:val="MdTenderTitle"/></w:pPr><w:r><w:t>可编辑封面</w:t></w:r></w:p>',
'<w:p><w:pPr><w:pStyle w:val="MdTenderCopyMark"/></w:pPr><w:r><w:t>正本</w:t></w:r></w:p>'
);
fixture.entries.set("word/document.xml", encoder.encode(documentXml));
const copyMarkTokens: DocxThemeTokenSet = {
...tokens,
slots: [
...tokens.slots.map((entry) =>
entry.slot === "tender-cover"
? {
...entry,
style: {
...entry.style,
minimumHeightPt: 500,
verticalAlignment: "center" as const
}
}
: entry
),
{
slot: "tender-copy-mark",
source: "computed-css",
confidence: "exact",
style: {
fontCandidates: [],
fontSizePt: 13,
selfAlignment: "right",
paddingPt: { top: 3, right: 11.34, bottom: 3, left: 11.34 },
borders: {
right: { widthPt: 1.13, style: "single", color: "#222222" }
}
}
}
]
};
const result = finalizeGeneratedDocxStructure(
writeGeneratedDocxPackage(fixture.entries),
plan,
copyMarkTokens
);
const outputXml = decoder.decode(
readGeneratedDocxPackage(result.content).entries.get(
"word/document.xml"
)!
);
expect(outputXml).toMatch(
/<w:pStyle w:val="MdTenderCopyMark"\/>[\s\S]*?<w:t><\/w:t>[\s\S]*?<w:sz w:val="25"\/>[\s\S]*?<w:t xml:space="preserve">\u00a0\u00a0<\/w:t>/u
);
});
it("按 Chromium 实测比例写入固定 DXA 表格网格和单元格宽度", () => {
const result = finalizeGeneratedDocxStructure(
generatedFixture(),
plan,
tokens,
[],
{
tables: [
{
ordinal: 1,
widthPercent: 90,
leftOffsetPercent: 5,
columnWidthPercents: [30, 70],
rows: []
}
],
textBlocks: [
{
ordinal: 1,
text: "单元格前mdtp_ic_c单元格后",
letterSpacingPt: 0,
alignment: "distribute",
lineBreakOffsets: []
}
],
inlineCodes: [
{
ordinal: 1,
text: "mdtp_ic_c",
fontSizePt: 8,
letterSpacingPt: 0.2,
color: "#334455",
backgroundColor: "#abcdef",
paddingPt: {
top: 1,
right: 2,
bottom: 1,
left: 2
},
borderPt: {
top: 0.5,
right: 0.5,
bottom: 0.5,
left: 0.5
}
}
]
}
);
const documentXml = decoder.decode(
readGeneratedDocxPackage(result.content).entries.get(
"word/document.xml"
)!
);
expect(documentXml).toContain(
'<w:tblW w:w="8915" w:type="dxa"/>'
);
expect(documentXml).toContain(
'<w:tblInd w:w="495" w:type="dxa"/>'
);
expect(documentXml).toContain('<w:tblLayout w:type="fixed"/>');
expect(documentXml).toContain('<w:wordWrap w:val="1"/>');
expect(documentXml).toContain('<w:gridCol w:w="2675"/>');
expect(documentXml).toContain('<w:gridCol w:w="6240"/>');
expect(
documentXml.match(/<w:tcW w:w="2675" w:type="dxa"\/>/gu)
).toHaveLength(2);
expect(
documentXml.match(/<w:tcW w:w="6240" w:type="dxa"\/>/gu)
).toHaveLength(2);
expect(documentXml).toMatch(
/<w:r><w:rPr><w:rStyle w:val="VerbatimChar"\/><w:rFonts[^>]*w:ascii="Code Face"[^>]*\/>[\s\S]*?<w:color w:val="334455"\/>[\s\S]*?<w:sz w:val="16"\/>[\s\S]*?<w:bdr[^>]*w:sz="20"[^>]*w:space="0"[^>]*w:color="ABCDEF"[^>]*\/>[\s\S]*?<w:shd[^>]*w:fill="ABCDEF"[^>]*\/>[\s\S]*?<w:t>mdtp_ic_c<\/w:t><\/w:r>/u
);
expect(documentXml).toMatch(
/<w:r><w:rPr><w:rFonts[^>]*w:ascii="Body Face"[^>]*\/>[\s\S]*?<w:t><\/w:t><\/w:r>/u
);
expect(documentXml).toContain('<w:jc w:val="distribute"/>');
});
it("将超出内容盒的实测表格等比收缩到 Word 可用宽度", () => {
const result = finalizeGeneratedDocxStructure(
generatedFixture(),
plan,
tokens,
[],
{
tables: [
{
ordinal: 1,
widthPercent: 300,
leftOffsetPercent: 0,
columnWidthPercents: [40, 10, 10, 10, 10, 20],
rows: []
}
]
}
);
const documentXml = decoder.decode(
readGeneratedDocxPackage(result.content).entries.get(
"word/document.xml"
)!
);
expect(documentXml).toContain(
'<w:tblW w:w="9906" w:type="dxa"/>'
);
expect(documentXml).toContain('<w:gridCol w:w="3962"/>');
expect(documentXml).toContain('<w:gridCol w:w="1980"/>');
});
it("按 Chromium 实测 emoji 调色保留可编辑 Unicode 文本", () => {
const fixture = readGeneratedDocxPackage(generatedFixture());
const documentXml = decoder.decode(
fixture.entries.get("word/document.xml")!
).replace(
'<w:p><w:pPr><w:pStyle w:val="Heading1"/></w:pPr><w:r><w:t>正文标题</w:t></w:r></w:p>',
'<w:p><w:pPr><w:pStyle w:val="Heading1"/></w:pPr><w:r><w:t>正文标题</w:t></w:r></w:p>' +
'<w:p><w:r><w:t>⭐⭐⭐⭐</w:t></w:r></w:p>'
);
fixture.entries.set("word/document.xml", encoder.encode(documentXml));
const result = finalizeGeneratedDocxStructure(
writeGeneratedDocxPackage(fixture.entries),
plan,
tokens,
[],
{
tables: [],
emojiRuns: [
{ ordinal: 1, text: "⭐⭐⭐⭐", color: "#e7bf36" }
]
}
);
const finalizedXml = decoder.decode(
readGeneratedDocxPackage(result.content).entries.get(
"word/document.xml"
)!
);
expect(finalizedXml).toMatch(
/<w:r><w:rPr><w:color w:val="E7BF36"\/><\/w:rPr><w:t><\/w:t><\/w:r>/u
);
});
it("实测表格缩进补偿 Word 以单元格内容而非外边框对齐的行为", () => {
const paddedTokens = {
...tokens,
slots: tokens.slots.map((slot) =>
slot.slot === "table-cell"
? {
...slot,
style: {
...slot.style,
paddingPt: { top: 3, right: 6, bottom: 3, left: 6 }
}
}
: slot
)
} satisfies DocxThemeTokenSet;
const result = finalizeGeneratedDocxStructure(
generatedFixture(),
plan,
paddedTokens,
[],
{
tables: [{
ordinal: 1,
widthPercent: 90,
leftOffsetPercent: 5,
columnWidthPercents: [30, 70],
rows: []
}]
}
);
const documentXml = decoder.decode(
readGeneratedDocxPackage(result.content).entries.get(
"word/document.xml"
)!
);
expect(documentXml).toContain(
'<w:tblInd w:w="615" w:type="dxa"/>'
);
});
it("按稳定媒体计划规范化内联尺寸、比例锁和对齐", () => {
const result = finalizeGeneratedDocxStructure(
generatedFixture(),
@@ -438,14 +905,14 @@ describe("生成 DOCX 结构收口", () => {
expect(documentXml).toContain('<w:insideH w:val="nil"/>');
expect(documentXml).toContain('<w:insideV w:val="nil"/>');
expect(documentXml).toMatch(
/<w:pPr><w:pStyle w:val="MdTenderTitle"\/><w:autoSpaceDE w:val="0"\/><w:autoSpaceDN w:val="0"\/><w:spacing w:before="0" w:after="0"\/><w:ind w:left="1456" w:right="1475"\/><w:jc w:val="center"\/>/u
/<w:pPr><w:pStyle w:val="MdTenderTitle"\/><w:autoSpaceDE w:val="0"\/><w:autoSpaceDN w:val="0"\/><w:spacing w:before="0" w:after="0"\/><w:ind w:left="1256" w:right="1275"\/><w:jc w:val="center"\/>/u
);
expect(documentXml).toMatch(
/<w:pPr><w:pStyle w:val="MdTenderTitle"\/>[\s\S]*?<w:rPr><w:spacing w:val="20"\/><\/w:rPr><w:t><\/w:t>/u
);
expect(documentXml).toContain('<w:tblLook w:val="0000"');
expect(documentXml).toMatch(
/<\/w:tbl><w:p><w:pPr><w:autoSpaceDE w:val="0"\/><w:autoSpaceDN w:val="0"\/><w:sectPr>/u
/<\/w:tbl><w:p><w:pPr>[\s\S]*?<w:spacing w:before="0" w:after="0" w:line="1" w:lineRule="exact"\/>[\s\S]*?<w:sectPr>/u
);
expect(
documentXml.match(/<w:tbl(?:\s|>)/gu)
@@ -458,6 +925,47 @@ describe("生成 DOCX 结构收口", () => {
});
});
it("将封面 border 与内缩 outline 映射为双层可编辑容器边框", () => {
const outlinedTokens: DocxThemeTokenSet = {
...tokens,
slots: tokens.slots.map((entry) =>
entry.slot === "tender-cover"
? {
...entry,
style: {
...entry.style,
minimumHeightPt: 620,
verticalAlignment: "center" as const,
outline: {
widthPt: 0.75,
style: "single" as const,
color: "#777777"
},
outlineOffsetPt: -12
}
}
: entry
)
};
const result = finalizeGeneratedDocxStructure(
generatedFixture(),
plan,
outlinedTokens
);
const documentXml = decoder.decode(
readGeneratedDocxPackage(result.content).entries.get(
"word/document.xml"
)!
);
expect(documentXml).toContain(
'<w:tblCellSpacing w:w="120" w:type="dxa"/>'
);
expect(documentXml).toMatch(
/<w:tcBorders><w:top w:val="single" w:sz="6" w:space="0" w:color="777777"\/><w:left[\s\S]*?<w:bottom[\s\S]*?<w:right/u
);
});
it("将封面最小高度封顶到当前节的页面内容区", () => {
const oversizedTokens: DocxThemeTokenSet = {
...tokens,
@@ -469,7 +977,15 @@ describe("生成 DOCX 结构收口", () => {
...entry.style,
minimumHeightPt: 900,
verticalAlignment: "center" as const,
childAlignment: "left" as const
childAlignment: "left" as const,
borders: {
...entry.style.borders,
top: {
widthPt: 20,
style: "single" as const,
color: "#111111"
}
}
}
}
: entry
@@ -508,18 +1024,24 @@ describe("生成 DOCX 结构收口", () => {
);
expect(documentXml).toContain(
'<w:trHeight w:val="14838" w:hRule="exact"/>'
'<w:trHeight w:val="14718" w:hRule="exact"/>'
);
expect(documentXml).toContain(
'<w:shd w:val="clear" w:color="auto" w:fill="111111"/>'
);
expect(documentXml).toContain(
'<w:spacing w:before="0" w:after="0" w:line="500" w:lineRule="exact"/>'
);
expect(documentXml).toContain('<w:top w:w="0" w:type="dxa"/>');
expect(documentXml).toContain('<w:bottom w:w="0" w:type="dxa"/>');
expect(documentXml).toContain('<w:vAlign w:val="top"/>');
expect(documentXml.match(/<w:cantSplit\/>/gu)).toHaveLength(3);
expect(documentXml.match(/<w:t>\.<\/w:t>/gu)).toHaveLength(2);
expect(documentXml.match(/<w:t>\.<\/w:t>/gu)).toHaveLength(3);
expect(documentXml.match(/<w:tbl(?:\s|>)/gu)).toHaveLength(2);
expect(documentXml.match(/<w:trHeight\b/gu)).toHaveLength(1);
});
it("将主题项目符号位置与 Word 悬挂缩进合并", () => {
it("将主题列表文字起点映射为 Word 悬挂缩进", () => {
const source = readGeneratedDocxPackage(generatedFixture());
const entries = new Map(source.entries);
entries.set(
@@ -557,10 +1079,96 @@ describe("生成 DOCX 结构收口", () => {
);
expect(numberingXml).toContain(
'<w:ind w:left="960" w:hanging="360"/>'
'<w:ind w:left="600" w:hanging="360"/>'
);
expect(numberingXml).toContain(
'<w:ind w:left="1680" w:hanging="360"/>'
'<w:ind w:left="1320" w:hanging="360"/>'
);
});
it("将 Chromium 实测列表项文字起点映射为段落直接缩进", () => {
const source = readGeneratedDocxPackage(generatedFixture());
const entries = new Map(source.entries);
const documentXml = decoder.decode(entries.get("word/document.xml")!);
entries.set(
"word/document.xml",
encoder.encode(documentXml.replace(
"<w:sectPr>",
`<w:p><w:pPr><w:numPr><w:ilvl w:val="0"/><w:numId w:val="1"/></w:numPr></w:pPr><w:r><w:t>一级列表</w:t></w:r></w:p>` +
`<w:p><w:pPr><w:numPr><w:ilvl w:val="1"/><w:numId w:val="1"/></w:numPr></w:pPr><w:r><w:t>二级</w:t></w:r><w:r><w:rPr><w:rStyle w:val="VerbatimChar"/></w:rPr><w:t>代码</w:t></w:r></w:p>` +
`<w:sectPr>`
))
);
entries.set(
"word/numbering.xml",
encoder.encode(
`<w:numbering xmlns:w="${word}"><w:abstractNum w:abstractNumId="1"><w:lvl w:ilvl="0"><w:pPr><w:ind w:left="720" w:hanging="360"/></w:pPr></w:lvl><w:lvl w:ilvl="1"><w:pPr><w:ind w:left="1440" w:hanging="360"/></w:pPr></w:lvl></w:abstractNum><w:num w:numId="1"><w:abstractNumId w:val="1"/></w:num></w:numbering>`
)
);
const result = finalizeGeneratedDocxStructure(
writeGeneratedDocxPackage(entries),
plan,
tokens,
[],
{
tables: [],
listItems: [
{
ordinal: 1,
text: "一级列表",
depth: 0,
textStartPt: 10
},
{
ordinal: 2,
text: "二级代码",
depth: 1,
textStartPt: 25
}
]
}
);
const finalizedXml = decoder.decode(
readGeneratedDocxPackage(result.content).entries.get(
"word/document.xml"
)!
);
expect(finalizedXml).toMatch(
/<w:numPr>[\s\S]*?<w:ind w:left="200" w:hanging="200"\/>[\s\S]*?<w:t><\/w:t>/u
);
expect(finalizedXml).toMatch(
/<w:numPr>[\s\S]*?<w:ind w:left="500" w:hanging="360"\/>[\s\S]*?<w:t><\/w:t>[\s\S]*?<w:t><\/w:t>/u
);
});
it("将 Pandoc 拆分的标准 Alert 正文恢复为引用块样式", () => {
const source = readGeneratedDocxPackage(generatedFixture());
const entries = new Map(source.entries);
const documentXml = decoder.decode(entries.get("word/document.xml")!);
entries.set(
"word/document.xml",
encoder.encode(documentXml.replace(
"<w:sectPr>",
`<w:p><w:pPr><w:pStyle w:val="FirstParagraph"/></w:pPr><w:r><w:t>Caution</w:t></w:r></w:p>` +
`<w:p><w:pPr><w:pStyle w:val="BodyText"/></w:pPr><w:r><w:t>警告正文</w:t></w:r></w:p>` +
`<w:sectPr>`
))
);
const result = finalizeGeneratedDocxStructure(
writeGeneratedDocxPackage(entries),
plan,
tokens
);
const finalizedXml = decoder.decode(
readGeneratedDocxPackage(result.content).entries.get(
"word/document.xml"
)!
);
expect(finalizedXml).toMatch(
/<w:pPr><w:pStyle w:val="BlockText"\/>[\s\S]*?<w:t><\/w:t>/u
);
});
@@ -149,7 +149,7 @@ describe("DOCX 字体包写入", () => {
writeGeneratedDocxPackage(invalidEntries)
)
).toThrow("字体元数据与嵌入字形不匹配");
});
}, 15_000);
it("没有字体时保持原始 DOCX 字节不变", () => {
const input = createTestBaselineReference();
@@ -126,6 +126,9 @@ describe("Pandoc DOCX 转换器", () => {
expect(arguments_).toContain("--lua-filter");
expect(arguments_).toContain("--data-dir");
expect(arguments_).toContain("--resource-path");
expect(argumentAfter(arguments_, "--from")).toBe(
"commonmark_x-yaml_metadata_block+pipe_tables+footnotes+task_lists+tex_math_dollars+raw_html"
);
expect(
options.env?.MD_TO_PDF_DOCX_MEDIA_MAP
).toContain("media-map.json");
@@ -216,4 +219,29 @@ describe("Pandoc DOCX 转换器", () => {
code: "DOCX_OUTPUT_INVALID"
} satisfies Partial<PandocDocxConversionError>);
});
it("进程失败时保留内部诊断但保持稳定的对外错误", async () => {
const failedRunner = vi.fn<PandocProcessRunner>(async () => ({
outcome: "completed",
exitCode: 64,
stdout: new Uint8Array(),
stderr: "pandoc: 媒体映射失败"
}));
await expect(
new PandocDocxConverter({
runtime,
runner: failedRunner,
temporaryRoot
}).convert(input())
).rejects.toMatchObject({
code: "DOCX_GENERATION_FAILED",
message: "Pandoc 未能生成 DOCX",
diagnostics: {
outcome: "completed",
exitCode: 64,
stderr: "pandoc: 媒体映射失败"
}
} satisfies Partial<PandocDocxConversionError>);
});
});
@@ -66,6 +66,36 @@ function paragraphSegments(block: PandocStructureBlock): string[] {
}
describe("Pandoc 语义结构投影", () => {
it("将简报抬头文字映射到父级抬头样式", () => {
const model: SemanticDocumentModel = {
schemaVersion: 1,
profile: "briefing",
titlePolicy: {
metadataTitle: "suppress",
firstBodyHeading: "keep"
},
regions: [{
kind: "prefix",
nodes: [{
kind: "group",
role: "briefing-masthead",
children: [text("briefing-masthead-text", "工作简报")]
}]
}]
};
const plan = createPandocStructurePlan(model, tokens(), {
markerSeed: "briefing"
});
expect(plan.prefix[0]).toMatchObject({
kind: "container",
blocks: [{
kind: "paragraph",
styleId: "MdBriefingMastheadText",
segments: ["工作简报"]
}]
});
});
it("将公文分组投影为固定 Word 样式和可编辑段落", () => {
const model: SemanticDocumentModel = {
schemaVersion: 1,
@@ -272,6 +272,26 @@ describe("动态 reference.docx", () => {
}
}
},
{
slot: "code-block-text",
source: "computed-css",
confidence: "exact",
style: {
fontCandidates: ["Code Text Face"],
fontSizePt: 10,
color: "#223344"
}
},
{
slot: "code-token-attribute",
source: "computed-css",
confidence: "exact",
style: {
fontCandidates: ["Code Text Face"],
fontSizePt: 10,
color: "#166534"
}
},
{
slot: "table",
source: "computed-css",
@@ -384,11 +404,19 @@ describe("动态 reference.docx", () => {
const sourceCodeStyle = stylesXml.match(
/<w:style\b[^>]*w:styleId="SourceCode"[\s\S]*?<\/w:style>/u
)?.[0];
expect(sourceCodeStyle).not.toContain("<w:shd");
expect(sourceCodeStyle).toContain(
'<w:left w:val="single" w:sz="6" w:space="7" w:color="E7EAED"/>'
);
expect(sourceCodeStyle).toMatch(
/<w:ind\b[^>]*w:left="195"[^>]*w:right="75"[^>]*\/>/u
/<w:ind\b[^>]*w:left="195"[^>]*w:right="0"[^>]*\/>/u
);
expect(sourceCodeStyle).toContain('w:ascii="Code Text Face"');
expect(stylesXml).toMatch(
/w:style[^>]*w:styleId="MdSourceCodeChar"[\s\S]*?<w:sz w:val="20"\/>/u
);
expect(stylesXml).toMatch(
/w:style[^>]*w:styleId="DataTypeTok"[\s\S]*?<w:basedOn w:val="MdSourceCodeChar"\/>[\s\S]*?<w:color w:val="166534"\/>/u
);
expect(stylesXml).not.toContain('w:val="justify"');
expect(stylesXml).toContain(
@@ -424,6 +452,49 @@ describe("动态 reference.docx", () => {
);
});
it("按代码容器令牌清除或写入 SourceCode 段落底纹", () => {
const createWithBackground = (backgroundColor?: string) => {
const result = createDynamicReferenceDocx(
createBaselineReference(),
{
...createOptions(defaultExportConfig),
themeTokens: themeTokens("f".repeat(64), [
{
slot: "code-block",
source: "computed-css",
confidence: "exact",
style: {
fontCandidates: ["Consolas"],
fontSizePt: 10,
...(backgroundColor ? { backgroundColor } : {})
}
},
{
slot: "code-block-text",
source: "computed-css",
confidence: "exact",
style: {
fontCandidates: ["Consolas"],
fontSizePt: 10
}
}
])
}
);
const stylesXml = decoder.decode(
unzipSync(result.content)["word/styles.xml"]
);
return stylesXml.match(
/<w:style\b[^>]*w:styleId="SourceCode"[\s\S]*?<\/w:style>/u
)?.[0];
};
expect(createWithBackground()).not.toContain("<w:shd");
expect(createWithBackground("#eef2f6")).toContain(
'<w:shd w:val="clear" w:color="auto" w:fill="EEF2F6"/>'
);
});
it("生成横向、自定义页边距和奇偶首页页眉页脚", () => {
const exportConfig: ExportConfig = {
...defaultExportConfig,
@@ -463,6 +534,7 @@ describe("动态 reference.docx", () => {
const entries = unzipSync(result.content);
const documentXml = decoder.decode(entries["word/document.xml"]);
const settingsXml = decoder.decode(entries["word/settings.xml"]);
const stylesXml = decoder.decode(entries["word/styles.xml"]);
const headerXml = decoder.decode(entries["word/header1.xml"]);
const relationships = decoder.decode(
entries["word/_rels/document.xml.rels"]
@@ -478,6 +550,8 @@ describe("动态 reference.docx", () => {
expect(documentXml.match(/w:headerReference/gu)).toHaveLength(3);
expect(documentXml.match(/w:footerReference/gu)).toHaveLength(3);
expect(settingsXml).toContain("<w:evenAndOddHeaders");
expect(settingsXml).toContain("<w:doNotExpandShiftReturn");
expect(stylesXml).toContain('<w:kern w:val="2"');
expect(headerXml).toContain("年度 &lt;报告&gt;");
expect(headerXml).toContain("报告 &amp; 计划.md");
expect(headerXml).toContain('w:tab w:val="center"');
@@ -4,6 +4,7 @@ import {
DOCX_SLOT_WORD_STYLE_BINDINGS,
collectDocxTokenFonts,
createDocxTokenSlotMap,
resolvePandocSyntaxStyleSlot,
resolveTokenFonts
} from "../src/index.js";
@@ -72,5 +73,47 @@ describe("DOCX 令牌样式映射", () => {
"Source Han Serif SC",
"Times New Roman"
]);
expect(
resolveTokenFonts(
{ fontCandidates: ["MdTP Mono", "monospace"] },
{ latin: "Arial", eastAsia: "MdTP Sans SC" },
{ preserveFallbackEastAsia: true }
)
).toEqual({
latin: "MdTP Mono",
eastAsia: "MdTP Sans SC",
complexScript: "MdTP Mono"
});
});
it("将 Pandoc 语法样式映射到引擎级代码语义槽位", () => {
expect(resolvePandocSyntaxStyleSlot("DataTypeTok", '"key"'))
.toBe("code-token-attribute");
expect(resolvePandocSyntaxStyleSlot("DataTypeTok", "<"))
.toBe("code-token-punctuation");
expect(
resolvePandocSyntaxStyleSlot(
"KeywordTok",
"section",
{ htmlTagName: true }
)
).toBe("code-token-name");
expect(resolvePandocSyntaxStyleSlot("StringTok", '"value"'))
.toBe("code-token-string");
expect(resolvePandocSyntaxStyleSlot("FunctionTok", ":"))
.toBe("code-token-punctuation");
expect(resolvePandocSyntaxStyleSlot("FunctionTok", "render"))
.toBe("code-token-title");
expect(resolvePandocSyntaxStyleSlot("NormalTok", " "))
.toBe("code-block-text");
expect(
resolvePandocSyntaxStyleSlot(
"NormalTok",
" method",
{ objectPropertyKey: true }
)
).toBe("code-token-attribute");
expect(resolvePandocSyntaxStyleSlot("UnknownTok", "x"))
.toBeUndefined();
});
});
@@ -41,6 +41,9 @@ export function readDocxComputedStyle(
fontStyle: style.fontStyle,
color: style.color,
backgroundColor: style.backgroundColor,
...(style.backgroundImage
? { backgroundImage: style.backgroundImage }
: {}),
lineHeight: style.lineHeight,
letterSpacing: style.letterSpacing,
textAlign: style.textAlign,
@@ -79,6 +79,25 @@ export function parseCssColor(
);
}
export function parseCssLinearGradientFallbackColor(
input: string
): string | undefined {
const value = input.trim();
if (!/^(?:repeating-)?linear-gradient\(/iu.test(value)) {
return undefined;
}
const colorValues = value.match(
/rgba?\([^)]*\)|#[\da-f]{3,8}/giu
) ?? [];
for (const colorValue of colorValues) {
const parsed = parseCssColor(colorValue);
if (parsed) {
return parsed;
}
}
return undefined;
}
function parseAlpha(value: string | undefined): number | undefined {
if (value === undefined) {
return undefined;
+101 -36
View File
@@ -7,6 +7,7 @@ import {
millimetersToPoints,
parseCssBorder,
parseCssColor,
parseCssLinearGradientFallbackColor,
parseCssFontFamilies,
parseCssLengthToPt,
roundDocxValue
@@ -292,6 +293,20 @@ function normalizeComputedSlot(
);
if (background) {
style.backgroundColor = background;
} else if (computed.backgroundImage) {
const gradientBackground = parseCssLinearGradientFallbackColor(
computed.backgroundImage
);
if (gradientBackground) {
style.backgroundColor = gradientBackground;
diagnostic(context, {
severity: "info",
code: "layout-approximated",
property: "background-image",
message: "CSS 线性渐变已使用首个非透明色近似为 Word 段落底纹"
});
context.approximate = true;
}
}
const letterSpacing = computed.letterSpacing.toLowerCase() === "normal"
? 0
@@ -366,11 +381,12 @@ function normalizeComputedSlot(
message: "CSS outline 已近似为 Word 四边边框"
});
} else {
style.outline = outline;
diagnostic(context, {
severity: "warning",
code: "css-property-unsupported",
severity: "info",
code: "layout-approximated",
property: "outline",
message: "CSS border 与 outline 并存,Word 单层边框仅保留 border,结构层需生成双层边框"
message: "CSS border 与 outline 并存,DOCX 容器将生成双层边框"
});
}
context.approximate = true;
@@ -380,11 +396,18 @@ function normalizeComputedSlot(
computed.outlineOffset
);
if (outlineOffsetPt !== undefined && outlineOffsetPt !== 0) {
if (style.outline) {
style.outlineOffsetPt = outlineOffsetPt;
}
diagnostic(context, {
severity: "warning",
code: "css-property-unsupported",
severity: style.outline ? "info" : "warning",
code: style.outline
? "layout-approximated"
: "css-property-unsupported",
property: "outline-offset",
message: `Word 边框不支持 CSS outline-offset ${outlineOffsetPt}pt,结构层需近似处理`
message: style.outline
? `DOCX 容器将以单元格间距近似 CSS outline-offset ${outlineOffsetPt}pt`
: `Word 边框不支持 CSS outline-offset ${outlineOffsetPt}pt,结构层需近似处理`
});
context.approximate = true;
}
@@ -434,34 +457,32 @@ function normalizeComputedSlot(
style.spacingAfterPt = spacingAfterPt;
}
}
if (context.kind !== "inline") {
const padding = {
top: length(context, "padding-top", computed.paddingTop, {
nonNegative: true
}),
right: length(context, "padding-right", computed.paddingRight, {
nonNegative: true
}),
bottom: length(context, "padding-bottom", computed.paddingBottom, {
nonNegative: true
}),
left: length(context, "padding-left", computed.paddingLeft, {
nonNegative: true
})
const padding = {
top: length(context, "padding-top", computed.paddingTop, {
nonNegative: true
}),
right: length(context, "padding-right", computed.paddingRight, {
nonNegative: true
}),
bottom: length(context, "padding-bottom", computed.paddingBottom, {
nonNegative: true
}),
left: length(context, "padding-left", computed.paddingLeft, {
nonNegative: true
})
};
if (
padding.top !== undefined &&
padding.right !== undefined &&
padding.bottom !== undefined &&
padding.left !== undefined
) {
style.paddingPt = {
top: padding.top,
right: padding.right,
bottom: padding.bottom,
left: padding.left
};
if (
padding.top !== undefined &&
padding.right !== undefined &&
padding.bottom !== undefined &&
padding.left !== undefined
) {
style.paddingPt = {
top: padding.top,
right: padding.right,
bottom: padding.bottom,
left: padding.left
};
}
}
const containingBlockWidthPt = computed.containingBlockWidth
? parseCssLengthToPt(computed.containingBlockWidth)
@@ -644,7 +665,8 @@ function applyBorderColor(
function applyOverrides(
slot: DocxStyleSlotName,
styleInput: DocxSlotStyleToken,
overrides: DocxThemeStyleOverrides
overrides: DocxThemeStyleOverrides,
replaceExisting: boolean
): {
style: DocxSlotStyleToken;
applied: boolean;
@@ -655,7 +677,10 @@ function applyOverrides(
key: Key,
value: MutableStyle[Key] | undefined
) => {
if (value !== undefined) {
if (
value !== undefined &&
(replaceExisting || style[key] === undefined)
) {
style[key] = value;
applied = true;
}
@@ -879,7 +904,10 @@ export function resolveDocxThemeTokens(
const override = applyOverrides(
definition.name,
resolved.style,
input.config.overrides
input.config.overrides,
input.config.mode === "explicit" ||
!input.config.legacyPreset ||
resolved.source === "preset-fallback"
);
if (override.applied) {
diagnostics.push({
@@ -899,6 +927,43 @@ export function resolveDocxThemeTokens(
return resolved;
}
);
const blockQuote = slots.find((entry) => entry.slot === "block-quote");
const blockQuoteText = slots.find(
(entry) => entry.slot === "block-quote-text"
);
if (blockQuote && blockQuoteText) {
const text = blockQuoteText.style;
blockQuote.style = {
...blockQuote.style,
...(text.fontCandidates.length > 0
? { fontCandidates: text.fontCandidates }
: {}),
...(text.fontSizePt !== undefined
? { fontSizePt: text.fontSizePt }
: {}),
...(text.bold !== undefined ? { bold: text.bold } : {}),
...(text.italic !== undefined ? { italic: text.italic } : {}),
...(text.underline !== undefined
? { underline: text.underline }
: {}),
...(text.strikethrough !== undefined
? { strikethrough: text.strikethrough }
: {}),
...(text.color !== undefined ? { color: text.color } : {}),
...(text.lineSpacing !== undefined
? { lineSpacing: text.lineSpacing }
: {}),
...(text.letterSpacingPt !== undefined
? { letterSpacingPt: text.letterSpacingPt }
: {}),
...(text.alignment !== undefined
? { alignment: text.alignment }
: {}),
...(text.firstLineIndentPt !== undefined
? { firstLineIndentPt: text.firstLineIndentPt }
: {})
};
}
return docxThemeTokenSetSchema.parse({
schemaVersion: 1,
themeId: snapshot.themeId,
+3 -3
View File
@@ -34,8 +34,8 @@ export function createDocxStyleProbeMarkup(): string {
</p>
<ul ${attribute("unordered-list")}><li ${attribute("list-item")}></li></ul>
<ol ${attribute("ordered-list")}><li></li></ol>
<blockquote ${attribute("block-quote")}><p></p></blockquote>
<pre class="md-fences" ${attribute("code-block")}><code>const value = 1;</code></pre>
<blockquote ${attribute("block-quote")}><p ${attribute("block-quote-text")}></p></blockquote>
<pre class="md-fences" ${attribute("code-block")}><code class="language-javascript" ${attribute("code-block-text")}><span class="hljs-keyword" ${attribute("code-token-keyword")}>const</span> <span class="hljs-attr" ${attribute("code-token-attribute")}>key</span> <span class="hljs-operator" ${attribute("code-token-operator")}>=</span> <span class="hljs-string" ${attribute("code-token-string")}>&quot;value&quot;</span>; <span class="hljs-literal" ${attribute("code-token-literal")}>true</span> <span class="hljs-title" ${attribute("code-token-title")}>title</span> <span class="hljs-number" ${attribute("code-token-number")}>1</span> <span class="hljs-comment" ${attribute("code-token-comment")}>// comment</span> <span class="hljs-built_in" ${attribute("code-token-built-in")}>Array</span> <span class="hljs-meta" ${attribute("code-token-meta")}>@meta</span> <span class="hljs-variable" ${attribute("code-token-variable")}>value</span> <span class="hljs-regexp" ${attribute("code-token-regexp")}>/x/</span> <span class="hljs-punctuation" ${attribute("code-token-punctuation")}>{}</span> <span class="hljs-name" ${attribute("code-token-name")}>section</span></code></pre>
<table ${attribute("table")}>
<thead><tr><th ${attribute("table-header")}></th></tr></thead>
<tbody><tr><td ${attribute("table-cell")}></td></tr></tbody>
@@ -61,7 +61,7 @@ export function createDocxStyleProbeMarkup(): string {
</section>
<section class="docx-probe-briefing">
<header class="doc-masthead doc-masthead-briefing" ${attribute("briefing-masthead")}>
<p class="doc-briefing-masthead"></p>
<p class="doc-briefing-masthead" ${attribute("briefing-masthead-text")}></p>
<div class="doc-briefing-meta" ${attribute("briefing-meta")}><span>1</span><span></span><time>2026730</time></div>
</header>
<h1 class="doc-title" ${attribute("briefing-title")}></h1>
+1 -1
View File
@@ -55,6 +55,7 @@ const computedProperties = [
"fontStyle",
"color",
"backgroundColor",
"backgroundImage",
"lineHeight",
"letterSpacing",
"textAlign",
@@ -118,7 +119,6 @@ body {
border-radius: 0;
background: transparent;
box-shadow: none;
font-family: inherit;
font-size: inherit;
font-weight: inherit;
line-height: inherit;
+34
View File
@@ -19,7 +19,23 @@ export const DOCX_STYLE_SLOT_NAMES = [
"ordered-list",
"list-item",
"block-quote",
"block-quote-text",
"code-block",
"code-block-text",
"code-token-keyword",
"code-token-literal",
"code-token-string",
"code-token-attribute",
"code-token-title",
"code-token-number",
"code-token-comment",
"code-token-built-in",
"code-token-meta",
"code-token-variable",
"code-token-operator",
"code-token-regexp",
"code-token-punctuation",
"code-token-name",
"table",
"table-header",
"table-cell",
@@ -42,6 +58,7 @@ export const DOCX_STYLE_SLOT_NAMES = [
"official-copy-to",
"official-printing-row",
"briefing-masthead",
"briefing-masthead-text",
"briefing-meta",
"briefing-title",
"briefing-contact",
@@ -121,7 +138,23 @@ const kinds: Record<DocxStyleSlotName, DocxStyleSlotKind> = {
"ordered-list": "list",
"list-item": "list",
"block-quote": "paragraph",
"block-quote-text": "paragraph",
"code-block": "paragraph",
"code-block-text": "inline",
"code-token-keyword": "inline",
"code-token-literal": "inline",
"code-token-string": "inline",
"code-token-attribute": "inline",
"code-token-title": "inline",
"code-token-number": "inline",
"code-token-comment": "inline",
"code-token-built-in": "inline",
"code-token-meta": "inline",
"code-token-variable": "inline",
"code-token-operator": "inline",
"code-token-regexp": "inline",
"code-token-punctuation": "inline",
"code-token-name": "inline",
table: "table",
"table-header": "table",
"table-cell": "table",
@@ -144,6 +177,7 @@ const kinds: Record<DocxStyleSlotName, DocxStyleSlotKind> = {
"official-copy-to": "structure",
"official-printing-row": "structure",
"briefing-masthead": "structure",
"briefing-masthead-text": "structure",
"briefing-meta": "structure",
"briefing-title": "structure",
"briefing-contact": "structure",
@@ -10,6 +10,7 @@ export const docxComputedStyleSchema = z.object({
fontStyle: cssValueSchema,
color: cssValueSchema,
backgroundColor: cssValueSchema,
backgroundImage: cssValueSchema.optional(),
lineHeight: cssValueSchema,
letterSpacing: cssValueSchema,
textAlign: cssValueSchema,
+2
View File
@@ -105,6 +105,8 @@ export const docxSlotStyleTokenSchema = z.object({
left: docxBorderTokenSchema.optional()
})
.optional(),
outline: docxBorderTokenSchema.optional(),
outlineOffsetPt: z.number().min(-1000).max(1000).optional(),
widthPercent: z.number().min(0).max(100).optional(),
minimumWidthPercent: z.number().min(0).max(100).optional(),
minimumHeightPt: z.number().min(0).max(10000).optional(),
@@ -4,6 +4,7 @@ import {
millimetersToPoints,
parseCssBorder,
parseCssColor,
parseCssLinearGradientFallbackColor,
parseCssFontFamilies,
parseCssLengthToPt,
resolveDocxThemeTokens,
@@ -106,6 +107,12 @@ describe("CSS 到 Word 基础值归一化", () => {
expect(parseCssColor("rgba(0, 0, 0, 0)")).toBeUndefined();
expect(parseCssColor("rgba(255, 0, 0, 0.5)")).toBe("#ff8080");
expect(parseCssColor("#00000080")).toBe("#7f7f7f");
expect(parseCssLinearGradientFallbackColor(
"linear-gradient(90deg, rgb(239, 246, 255), rgba(0, 0, 0, 0))"
)).toBe("#eff6ff");
expect(parseCssLinearGradientFallbackColor(
"radial-gradient(rgb(239, 246, 255), transparent)"
)).toBeUndefined();
expect(
parseCssFontFamilies(
'"Source Han Serif SC", "Microsoft YaHei", serif'
@@ -175,10 +182,42 @@ describe("DOCX 主题令牌归一化", () => {
});
expect(findSlot(tokens, "strong").style)
.not.toHaveProperty("firstLineIndentPt");
expect(findSlot(tokens, "inline-code").style.paddingPt).toEqual({
top: 3,
right: 6,
bottom: 3,
left: 6
});
expect(findSlot(tokens, "table").style.widthPercent).toBe(100);
expect(tokens.slots).toHaveLength(DOCX_STYLE_SLOT_NAMES.length);
});
it("将线性渐变首个实色近似为 Word 段落底纹", () => {
const tokens = resolveDocxThemeTokens({
snapshot: createSnapshot({
"heading-2": {
backgroundColor: "rgba(0, 0, 0, 0)",
backgroundImage:
"linear-gradient(90deg, rgb(239, 246, 255), rgba(0, 0, 0, 0))"
}
}),
config: {
mode: "auto",
basePreset: "general",
overrides: {},
legacyPreset: false
}
});
expect(findSlot(tokens, "heading-2").style.backgroundColor)
.toBe("#eff6ff");
expect(tokens.diagnostics).toContainEqual(expect.objectContaining({
slot: "heading-2",
code: "layout-approximated",
property: "background-image"
}));
});
it("按 border-box 文档的内容区宽度归一化全宽表格", () => {
const tokens = resolveDocxThemeTokens({
snapshot: createSnapshot({
@@ -204,6 +243,43 @@ describe("DOCX 主题令牌归一化", () => {
expect(findSlot(tokens, "table").style.widthPercent).toBe(100);
});
it("引用块组合容器盒模型与内部段落文字样式", () => {
const tokens = resolveDocxThemeTokens({
snapshot: createSnapshot({
"block-quote": {
fontSize: "24px",
paddingLeft: "20px",
borderLeft: "4px solid rgb(122, 122, 122)"
},
"block-quote-text": {
fontFamily: '"Merriweather", serif',
fontSize: "16px",
fontStyle: "italic",
lineHeight: "24px",
paddingLeft: "0px",
borderLeft: "0px none rgb(17, 34, 51)"
}
}),
config: {
mode: "auto",
basePreset: "general",
overrides: {},
legacyPreset: false
}
});
expect(findSlot(tokens, "block-quote").style).toMatchObject({
fontCandidates: ["Merriweather", "serif"],
fontSizePt: 12,
italic: true,
lineSpacing: 1.5,
paddingPt: { left: 15 },
borders: {
left: { widthPt: 3, color: "#7a7a7a", style: "single" }
}
});
});
it("按父级内容区归一化右置百分比结构容器", () => {
const tokens = resolveDocxThemeTokens({
snapshot: createSnapshot({
@@ -330,6 +406,14 @@ describe("DOCX 主题令牌归一化", () => {
expect(
findSlot(tokens, "tender-cover").style.borders?.top?.widthPt
).toBe(20);
expect(findSlot(tokens, "tender-cover").style).toMatchObject({
outline: {
widthPt: 0.75,
color: "#777777",
style: "single"
},
outlineOffsetPt: -12
});
expect(
tokens.diagnostics.some(
(entry) =>
@@ -343,12 +427,12 @@ describe("DOCX 主题令牌归一化", () => {
(entry) =>
entry.slot === "tender-cover" &&
entry.property === "outline" &&
entry.code === "css-property-unsupported"
entry.code === "layout-approximated"
)
).toBe(true);
});
it("以清单覆盖自动值并在未命中时显式降级", () => {
it("旧版清单仅补齐自动值并在未命中时显式降级", () => {
const tokens = resolveDocxThemeTokens({
snapshot: createSnapshot({ caption: null }),
config: {
@@ -368,15 +452,15 @@ describe("DOCX 主题令牌归一化", () => {
}
});
expect(findSlot(tokens, "paragraph")).toMatchObject({
source: "manifest-override",
source: "computed-css",
confidence: "exact",
style: {
fontSizePt: 11,
firstLineIndentPt: 22
fontSizePt: 12,
firstLineIndentPt: 24
}
});
expect(findSlot(tokens, "hyperlink").style).toMatchObject({
color: "#aa0000",
color: "#112233",
underline: false
});
expect(findSlot(tokens, "caption")).toMatchObject({
@@ -393,6 +477,24 @@ describe("DOCX 主题令牌归一化", () => {
).toBe(true);
});
it("非旧版自动清单仍可显式覆盖 CSS 计算值", () => {
const tokens = resolveDocxThemeTokens({
snapshot: createSnapshot(),
config: {
mode: "auto-with-overrides",
basePreset: "formal",
legacyPreset: false,
overrides: {
headings: { color: "#aa0000" }
}
}
});
expect(findSlot(tokens, "heading-1")).toMatchObject({
source: "manifest-override",
style: { color: "#aa0000" }
});
});
it("显式模式不读取 CSS,仅使用覆盖与预设降级", () => {
const tokens = resolveDocxThemeTokens({
snapshot: createSnapshot(),

Some files were not shown because too many files have changed in this diff Show More