9 Commits
Author SHA1 Message Date
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
128 changed files with 7451 additions and 894 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.1/`。该目录被 Git 忽略。公司内部分发以
`MorphDoc-0.6.1-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.1",
"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.1",
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 复制到桌面主进程资源目录", () => {
+153 -8
View File
@@ -11,6 +11,9 @@ 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 +35,37 @@ const fontPackRoot = path.resolve(
);
const decoder = new TextDecoder();
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\`单元格后 |
`;
const contentTypes = new Map([
[".css", "text/css; charset=utf-8"],
[".html", "text/html; charset=utf-8"],
@@ -50,6 +84,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 +231,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 +260,35 @@ 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)
};
});
return {
bytes: content.byteLength,
sha256: sha256(content),
@@ -205,6 +298,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 +310,10 @@ function inspectDocx(content) {
officialIssueRowRightTabCount:
officialIssueRowsWithRightTab.length,
officialIssueRowContentTabCount,
inlineCodeContinuity,
inlineCodeContinuityPassed: inlineCodeContinuity.every(
(probe) => probe.passed
),
requiredStylesPresent: [
"Normal",
"Heading1",
@@ -263,6 +363,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}`
@@ -297,13 +433,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 +466,11 @@ 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`);
assert(fs.existsSync(samplePath), `主题缺少验收示例:${theme.id}`);
const markdown = fs.readFileSync(samplePath, "utf8");
const markdown = `${fs.readFileSync(samplePath, "utf8").trimEnd()}${inlineCodeContinuityMarkdown}`;
const exportConfig = mergeExportConfig({
...defaultExportConfig,
name: `${theme.name} R4 生产链验收`,
@@ -380,6 +526,10 @@ try {
inspection.requiredStylesPresent,
`主题 ${theme.id} 缺少标准 Word 样式`
);
assert(
inspection.inlineCodeContinuityPassed,
`主题 ${theme.id} 的行内代码连续性门禁失败:${JSON.stringify(inspection.inlineCodeContinuity)}`
);
const standard = standardByTheme.get(theme.id);
assert(standard, `标准矩阵缺少主题 ${theme.id}`);
assert(
@@ -435,6 +585,7 @@ try {
docx: {
outputFile: path.relative(repositoryDirectory, docxPath),
inspection,
documentLayout: capturedDocxDocumentLayout ?? { tables: [] },
diagnostics: responseDiagnostics(docx.response, "docx")
},
standardInspection: standard.inspection,
@@ -448,12 +599,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,
+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.1"
}
}
: {})
@@ -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.1 部署契约", () => {
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.1}"'
);
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();
@@ -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.1");
expect(APP_VERSION_LABEL).toBe("v0.6.1");
});
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.1'
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.1'
$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.1 应用代码和生产
依赖。正式跨机器构建仍建议使用默认完整路径。
## 主题挂载
@@ -76,7 +76,7 @@ docker compose -f deploy\compose.yaml up -d
## 内置字体包
v0.6.0 的发行镜像固定将受校验字体包内置到
v0.6.1 的发行镜像固定将受校验字体包内置到
`/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.1'
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.1` | 字体包兼容性判断使用的应用版本 |
| `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.1}"
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.1"
};
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
```
+66 -5
View File
@@ -1,6 +1,6 @@
# 墨呈进度
最后更新:2026-08-02
最后更新:2026-08-04
## 1. 当前概况
@@ -68,6 +68,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 +1476,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 +1489,55 @@ 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`
标签统一指向最终发行状态。
每个阶段验收通过后创建一个独立提交,再进入下一阶段。当前阶段不得混入
后续阶段的功能实现。
### 阶段:后续版本扩展
### 阶段:后续版本扩展
- 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 签名,适用于公司内部发布;
公开网络分发前建议增加代码签名。
+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.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "md-to-pdf",
"version": "0.6.0",
"version": "0.6.1",
"license": "UNLICENSED",
"workspaces": [
"apps/*",
@@ -22,7 +22,7 @@
},
"apps/desktop": {
"name": "@md-to-pdf/desktop",
"version": "0.6.0",
"version": "0.6.1",
"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": {
+8 -9
View File
@@ -1,6 +1,6 @@
{
"name": "md-to-pdf",
"version": "0.6.0",
"version": "0.6.1",
"private": true,
"license": "UNLICENSED",
"description": "面向结构化 Markdown 的主题化文档创作与发布工具",
@@ -25,15 +25,14 @@
"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",
"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 +44,8 @@
"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",
"test": "npm run test:release-stage && npm run test:docx-layout-visual-matrix-cases && 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": {
@@ -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 });
+102 -1
View File
@@ -106,6 +106,105 @@ 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(100),
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),
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)
});
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 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()
});
export type DocxDocumentLayoutPlan = z.infer<
typeof docxDocumentLayoutPlanSchema
>;
export const docxMediaCaptureTargetSchema = z.object({
id: z.string().regex(/^docx-media-\d+$/u),
kind: docxMediaKindSchema,
@@ -136,7 +235,8 @@ export const docxMediaCapturePlanSchema = z.object({
.array(docxMediaCaptureTargetSchema)
.max(MAXIMUM_DOCX_RESOURCE_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 +258,7 @@ export interface PreparedDocxMedia {
mermaidErrors: string[];
warnings: string[];
totalBytes: number;
documentLayout?: DocxDocumentLayoutPlan;
}
export const docxExportErrorCodeSchema = z.enum([
+26 -15
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,6 +263,21 @@ 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 baselineEditableCoverage = hasEditableExpectation
? calculateOrderedContentCoverage(expectedEditableText, baselineEditableText)
: undefined;
@@ -269,7 +289,7 @@ export function comparePdfSnapshotsBasic(
)
: undefined;
const candidateEditableExact = hasEditableExpectation
? candidateEditableText === expectedEditableText
? candidateEditableText === expectedEditableText || paragraphContentExact
: undefined;
const contentSimilarity = hasEditableExpectation
? Math.min(
@@ -301,15 +321,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));
}
@@ -375,7 +386,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";
@@ -8,11 +8,13 @@ import type {
PdfEditableContentLine,
PdfParagraphLayoutComparison,
PdfParagraphLayoutObservation,
PdfPointBounds,
VisualDiffIssue,
VisualPageSemanticExpectation,
} from "./types.js";
const LINE_BREAK_CHARACTER_TOLERANCE = 1;
const BODY_REFLOW_BREAK_CHARACTER_TOLERANCE = 3;
const LINE_X_TOLERANCE_PT = 2;
const COVER_LINE_X_TOLERANCE_PT = 12;
const LINE_HEIGHT_TOLERANCE_PT = 1;
@@ -26,6 +28,17 @@ interface MatchCursor {
characterIndex: number;
}
interface MatchedCharacterRange {
start: number;
end: number;
}
interface MatchedCharacterPosition {
lineIndex: number;
characterIndex: number;
character: string;
}
function average(values: readonly number[]): number | undefined {
if (values.length === 0) {
return undefined;
@@ -33,6 +46,113 @@ function average(values: readonly number[]): number | undefined {
return values.reduce((sum, value) => sum + value, 0) / values.length;
}
function unionItemBounds(
items: readonly { bounds: PdfPointBounds }[],
): PdfPointBounds | undefined {
if (items.length === 0) {
return undefined;
}
const left = Math.min(...items.map((item) => item.bounds.x));
const top = Math.min(...items.map((item) => item.bounds.y));
const right = Math.max(
...items.map((item) => item.bounds.x + item.bounds.width),
);
const bottom = Math.max(
...items.map((item) => item.bounds.y + item.bounds.height),
);
return {
x: left,
y: top,
width: Math.max(0, right - left),
height: Math.max(0, bottom - top),
};
}
function characterAdvanceWeight(character: string): number {
if (/^[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}]$/u.test(character)) {
return 1;
}
if (/^[]$/u.test(character)) {
return 1;
}
if (/^[A-Za-z0-9]$/u.test(character)) {
return 0.55;
}
if (/^[.,:;!'"`·]$/u.test(character)) {
return 0.28;
}
if (/^[-/\\]$/u.test(character)) {
return 0.4;
}
return 0.6;
}
function weightedCharacterRatio(
characters: readonly string[],
offset: number,
): number {
const weights = characters.map(characterAdvanceWeight);
const total = weights.reduce((sum, weight) => sum + weight, 0);
if (total <= 0) {
return offset / Math.max(1, characters.length);
}
return weights
.slice(0, offset)
.reduce((sum, weight) => sum + weight, 0) / total;
}
function matchedLineItems(
line: PdfEditableContentLine["line"],
normalizedLineText: string,
range: MatchedCharacterRange | undefined,
) {
if (!range || line.items.length === 0) {
return { bounds: line.bounds, items: line.items };
}
const selected = [];
let searchFrom = 0;
for (const item of line.items) {
const text = normalizePdfEditableText(item.normalizedText);
if (!text) {
continue;
}
const start = normalizedLineText.indexOf(text, searchFrom);
if (start < 0) {
continue;
}
const end = start + Array.from(text).length;
searchFrom = end;
const overlapStart = Math.max(start, range.start);
const overlapEnd = Math.min(end, range.end);
if (overlapStart >= overlapEnd) {
continue;
}
const characters = Array.from(text);
const leftRatio = weightedCharacterRatio(
characters,
overlapStart - start,
);
const rightRatio = weightedCharacterRatio(
characters,
overlapEnd - start,
);
selected.push({
item,
bounds: {
...item.bounds,
x: item.bounds.x + item.bounds.width * leftRatio,
width: item.bounds.width * (rightRatio - leftRatio),
},
});
}
return {
bounds: unionItemBounds(selected) ?? line.bounds,
items: selected.length > 0
? selected.map((entry) => entry.item)
: line.items,
};
}
function advanceCursor(
cursor: MatchCursor,
lineCharacters: readonly string[][],
@@ -48,42 +168,88 @@ function advanceCursor(
return cursor.lineIndex < lineCharacters.length;
}
function matchContiguousCharacters(
start: MatchCursor,
expectedCharacters: readonly string[],
lineCharacters: readonly string[][],
) {
const cursor = { ...start };
const positions: MatchedCharacterPosition[] = [];
for (const expectedCharacter of expectedCharacters) {
const actualCharacter =
lineCharacters[cursor.lineIndex]?.[cursor.characterIndex];
if (actualCharacter !== expectedCharacter) {
break;
}
positions.push({
lineIndex: cursor.lineIndex,
characterIndex: cursor.characterIndex,
character: expectedCharacter,
});
advanceCursor(cursor, lineCharacters);
}
return { cursor, positions };
}
function observeParagraph(
expectation: EditableParagraphExpectation,
lines: readonly PdfEditableContentLine[],
lineCharacters: readonly string[][],
cursor: MatchCursor,
): PdfParagraphLayoutObservation {
const localCursor: MatchCursor = { ...cursor };
const expectedCharacters = Array.from(
normalizePdfEditableText(expectation.text),
);
const matchedByLine = new Map<number, string[]>();
let matchedCharacterCount = 0;
for (const expectedCharacter of expectedCharacters) {
let found = false;
while (localCursor.lineIndex < lineCharacters.length) {
const actualCharacter =
lineCharacters[localCursor.lineIndex]?.[localCursor.characterIndex];
if (actualCharacter === expectedCharacter) {
const matches = matchedByLine.get(localCursor.lineIndex) ?? [];
matches.push(expectedCharacter);
matchedByLine.set(localCursor.lineIndex, matches);
matchedCharacterCount += 1;
advanceCursor(localCursor, lineCharacters);
found = true;
break;
const matchedRanges = new Map<number, MatchedCharacterRange>();
let matchedPositions: MatchedCharacterPosition[] = [];
let matchedCursor: MatchCursor = { ...cursor };
let matched = expectedCharacters.length === 0;
const searchCursor: MatchCursor = { ...cursor };
while (!matched && searchCursor.lineIndex < lineCharacters.length) {
if (
lineCharacters[searchCursor.lineIndex]?.[
searchCursor.characterIndex
] === expectedCharacters[0]
) {
const attempt = matchContiguousCharacters(
searchCursor,
expectedCharacters,
lineCharacters,
);
if (attempt.positions.length > matchedPositions.length) {
matchedPositions = attempt.positions;
}
if (!advanceCursor(localCursor, lineCharacters)) {
if (attempt.positions.length === expectedCharacters.length) {
matched = true;
matchedPositions = attempt.positions;
matchedCursor = attempt.cursor;
break;
}
}
if (!found) {
if (!advanceCursor(searchCursor, lineCharacters)) {
break;
}
}
for (const position of matchedPositions) {
const matches = matchedByLine.get(position.lineIndex) ?? [];
matches.push(position.character);
matchedByLine.set(position.lineIndex, matches);
const range = matchedRanges.get(position.lineIndex);
matchedRanges.set(position.lineIndex, {
start: Math.min(
range?.start ?? position.characterIndex,
position.characterIndex,
),
end: Math.max(
range?.end ?? position.characterIndex + 1,
position.characterIndex + 1,
),
});
}
const matchedCharacterCount = matchedPositions.length;
const matchedLineIndexes = [...matchedByLine.keys()];
const matchedLines = matchedLineIndexes.flatMap((index) =>
lines[index] ? [lines[index]] : [],
@@ -113,10 +279,27 @@ function observeParagraph(
Array.from(lines[index]?.normalizedText ?? "").length ===
(matchedByLine.get(index)?.length ?? 0),
);
const matched = matchedCharacterCount === expectedCharacters.length;
const visualLines = matchedLines.map((line, index) => {
const matched = matchedLineItems(
line.line,
line.normalizedText,
matchedRanges.get(matchedLineIndexes[index]!),
);
return {
pageNumber: line.pageNumber,
bounds: matched.bounds,
fontFamilies: [
...new Set(
matched.items.flatMap((item) =>
item.fontFamily ? [item.fontFamily] : [],
),
),
].sort(),
};
});
if (matched) {
cursor.lineIndex = localCursor.lineIndex;
cursor.characterIndex = localCursor.characterIndex;
cursor.lineIndex = matchedCursor.lineIndex;
cursor.characterIndex = matchedCursor.characterIndex;
}
return {
matched,
@@ -128,15 +311,15 @@ function observeParagraph(
lineBreakOffsets,
...(firstLine
? {
firstLineXPt: firstLine.line.bounds.x,
firstLineXPt: visualLines[0]!.bounds.x,
firstLineBaselineYPt: firstLine.line.baselineY,
}
: {}),
...(average(matchedLines.map((line) => line.line.bounds.width)) === undefined
...(average(visualLines.map((line) => line.bounds.width)) === undefined
? {}
: {
maximumLineWidthPt: Math.max(
...matchedLines.map((line) => line.line.bounds.width),
...visualLines.map((line) => line.bounds.width),
),
}),
...(averageLineHeightPt === undefined
@@ -146,6 +329,7 @@ function observeParagraph(
? {}
: { averageBaselineGapPt }),
exclusiveGeometry,
visualLines,
};
}
@@ -164,6 +348,14 @@ function trailingLineCharacterCount(
return Array.from(observation.lineTexts.at(-1) ?? "").length;
}
function allowsBodyTextReflow(expectation: EditableParagraphExpectation) {
return expectation.section === "body" && [
"paragraph",
"list-item",
"block-quote",
].includes(expectation.blockKind ?? "paragraph");
}
function isBodyNearBoundaryLineCountDifference(
expectation: EditableParagraphExpectation,
baseline: PdfParagraphLayoutObservation,
@@ -192,6 +384,8 @@ function compareParagraph(
expectation: EditableParagraphExpectation,
baseline: PdfParagraphLayoutObservation,
candidate: PdfParagraphLayoutObservation,
baselineCoverPageCount: number,
candidateCoverPageCount: number,
): VisualDiffIssue[] {
const issues: VisualDiffIssue[] = [];
if (!baseline.matched || !candidate.matched) {
@@ -257,14 +451,21 @@ function compareParagraph(
),
);
if (maximumBreakDelta > LINE_BREAK_CHARACTER_TOLERANCE) {
const toleratedReflow =
allowsBodyTextReflow(expectation) &&
maximumBreakDelta <= BODY_REFLOW_BREAK_CHARACTER_TOLERANCE;
issues.push({
code: "PARAGRAPH_LINE_BREAK_MISMATCH",
severity: "failure",
message: `${expectation.index + 1} 个段落的行内换行边界偏差 ${maximumBreakDelta} 个字符`,
severity: toleratedReflow ? "warning" : "failure",
message: toleratedReflow
? `${expectation.index + 1} 个正文块发生允许的跨引擎行内重排,边界偏差 ${maximumBreakDelta} 个字符`
: `${expectation.index + 1} 个段落的行内换行边界偏差 ${maximumBreakDelta} 个字符`,
details: {
paragraphIndex: expectation.index,
maximumBreakDelta,
tolerance: LINE_BREAK_CHARACTER_TOLERANCE,
reflowTolerance: BODY_REFLOW_BREAK_CHARACTER_TOLERANCE,
toleratedReflow,
baselineBreaks: baseline.lineBreakOffsets.join(","),
candidateBreaks: candidate.lineBreakOffsets.join(","),
},
@@ -299,6 +500,27 @@ function compareParagraph(
},
});
}
} else if (baselineCoverPageCount > 0 || candidateCoverPageCount > 0) {
const baselineOutsideCover = baseline.pageNumbers.every(
(pageNumber) => pageNumber > baselineCoverPageCount,
);
const candidateOutsideCover = candidate.pageNumbers.every(
(pageNumber) => pageNumber > candidateCoverPageCount,
);
if (!baselineOutsideCover || !candidateOutsideCover) {
issues.push({
code: "COVER_LAYOUT_MISMATCH",
severity: "failure",
message: `${expectation.index + 1} 个正文段落回流到独立封面页`,
details: {
paragraphIndex: expectation.index,
baselinePages: baseline.pageNumbers.join(","),
candidatePages: candidate.pageNumbers.join(","),
baselineCoverPageCount,
candidateCoverPageCount,
},
});
}
}
if (baseline.exclusiveGeometry && candidate.exclusiveGeometry) {
@@ -373,6 +595,12 @@ export function comparePdfEditableParagraphLayouts(
const candidateCharacters = candidateLines.map((line) =>
Array.from(line.normalizedText),
);
const baselineCoverPageCount = baselinePageSemantics.filter(
(page) => page.kind === "cover",
).length;
const candidateCoverPageCount = candidatePageSemantics.filter(
(page) => page.kind === "cover",
).length;
const baselineCursor: MatchCursor = { lineIndex: 0, characterIndex: 0 };
const candidateCursor: MatchCursor = { lineIndex: 0, characterIndex: 0 };
@@ -393,6 +621,8 @@ export function comparePdfEditableParagraphLayouts(
expectation,
baselineObservation,
candidateObservation,
baselineCoverPageCount,
candidateCoverPageCount,
);
return {
expectation,
+280 -10
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");
}
@@ -48,6 +56,143 @@ 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 rasterBackgroundColor(
rgba: Uint8ClampedArray,
_width: number,
_height: number,
): RgbColor {
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 red = rgba[offset] ?? 255;
const green = rgba[offset + 1] ?? 255;
const blue = rgba[offset + 2] ?? 255;
const bucketKey = (red >> 3) << 10 | (green >> 3) << 5 | (blue >> 3);
const bucket = buckets.get(bucketKey) ?? {
count: 0,
red: 0,
green: 0,
blue: 0,
};
bucket.count += 1;
bucket.red += red;
bucket.green += green;
bucket.blue += blue;
buckets.set(bucketKey, bucket);
if (!dominant || bucket.count > dominant.count) {
dominant = bucket;
}
}
return !dominant
? { red: 255, green: 255, blue: 255 }
: {
red: dominant.red / dominant.count,
green: dominant.green / dominant.count,
blue: dominant.blue / dominant.count,
};
}
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 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 +212,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 +402,7 @@ export async function comparePageRasters(
: options;
const pixelDifferenceThreshold =
resolvedOptions.pixelDifferenceThreshold ?? 8;
const spatialTolerancePx = resolvedOptions.spatialTolerancePx ?? 4;
if (
!Number.isInteger(pixelDifferenceThreshold) ||
pixelDifferenceThreshold < 0 ||
@@ -190,6 +410,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);
@@ -210,6 +437,16 @@ export async function comparePageRasters(
const pixelDifference = new Uint8Array(pixelCount);
const baselineInk = new Uint8Array(pixelCount);
const candidateInk = new Uint8Array(pixelCount);
const baselineBackground = rasterBackgroundColor(
baselineDecoded.rgba,
width,
height,
);
const candidateBackground = rasterBackgroundColor(
candidateDecoded.rgba,
width,
height,
);
let absoluteError = 0;
let changedPixels = 0;
@@ -241,25 +478,43 @@ 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 baselineEdges = createEdgeMask(
baselineGray,
clearMaskBorder(baselineInk, width, height);
clearMaskBorder(candidateInk, 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(
rasterForegroundColor(baselineDecoded),
rasterForegroundColor(candidateDecoded),
);
const backgroundColorDelta = colorDistance(
baselineBackground,
candidateBackground,
);
const metrics: PdfRasterDiffMetrics = {
baselineWidthPx: baseline.widthPx,
@@ -272,10 +527,25 @@ 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,
),
edgeIou: calculateSpatiallyTolerantIou(
baselineEdges,
candidateEdges,
width,
height,
spatialTolerancePx,
),
backgroundColorDelta,
foregroundColorDelta,
};
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,390 @@
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_PADDING_PT = 4;
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();
}
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_PADDING_PT) * pixelsPerPoint),
);
const top = Math.max(
0,
Math.floor((bounds.y - BLOCK_RASTER_PADDING_PT) * pixelsPerPoint),
);
const right = Math.min(
raster.widthPx,
Math.ceil(
(bounds.x + bounds.width + BLOCK_RASTER_PADDING_PT) * pixelsPerPoint,
),
);
const bottom = Math.min(
raster.heightPx,
Math.ceil(
(bounds.y + bounds.height + BLOCK_RASTER_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 png = Uint8Array.from(canvas.toBuffer("image/png"));
return {
widthPx,
heightPx,
dpi: raster.dpi,
sha256: sha256(png),
png,
};
}
async function padRaster(
raster: PdfPageRaster,
widthPx: number,
heightPx: number,
): Promise<PdfPageRaster> {
if (raster.widthPx === widthPx && raster.heightPx === heightPx) {
return raster;
}
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, 0, 0);
const png = Uint8Array.from(canvas.toBuffer("image/png"));
return {
widthPx,
heightPx,
dpi: raster.dpi,
sha256: sha256(png),
png,
};
}
function localRasterIssues(
paragraphIndex: number,
lineIndex: number,
blockKind: string | undefined,
metrics: NonNullable<PdfSemanticBlockVisualLineComparison["metrics"]>,
thresholds: VisualDiffThresholds,
textReflow: boolean,
): VisualDiffIssue[] {
const shapeFailed =
metrics.inkIou < thresholds.minInkIou ||
metrics.edgeIou < thresholds.minEdgeIou;
const colorFailed =
metrics.backgroundColorDelta > thresholds.maxBackgroundColorDelta ||
metrics.foregroundColorDelta > thresholds.maxForegroundColorDelta;
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,
);
const failed = colorFailed || (!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,
textReflow,
},
},
];
}
/**
* Word、WPS 与 Chromium 会用不同的灰阶覆盖率栅格化同一套嵌入字形。
* 只有轮廓拓扑、颜色和局部几何同时近等时,才把较大的原始像素差视为
* 跨引擎栅格等价;任何回流、着色变化或可见形状变化仍由严格门禁阻断。
*/
export function isCrossEngineRasterEquivalent(
metrics: NonNullable<PdfSemanticBlockVisualLineComparison["metrics"]>,
textReflow: boolean,
blockKind?: string,
): 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 colorEquivalent =
metrics.backgroundColorDelta <= 4 &&
metrics.foregroundColorDelta <=
(blockKind === "list-item" ? 4.5 : 4);
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 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",
].includes(paragraph.expectation.blockKind ?? "paragraph");
const comparableLineCount = Math.min(
paragraph.baseline.visualLines.length,
paragraph.candidate.visualLines.length,
);
if (
!paragraph.baseline.matched ||
!paragraph.candidate.matched ||
comparableLineCount === 0
) {
issues.push({
code: "SEMANTIC_BLOCK_VISUAL_UNAVAILABLE",
severity: "failure",
message: `${paragraph.expectation.index + 1} 个语义块缺少可比较的局部视觉观测`,
details: { paragraphIndex: paragraph.expectation.index },
});
} else if (
paragraph.baseline.visualLines.length ===
paragraph.candidate.visualLines.length
) {
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),
]);
const baselineLineText = paragraph.baseline.lineTexts[lineIndex] ?? "";
const candidateLineText = paragraph.candidate.lineTexts[lineIndex] ?? "";
const textReflow = flowTextBlock &&
baselineLineText !== candidateLineText;
const result = flowTextBlock && !textReflow
? 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,
);
})();
const lineIssues = localRasterIssues(
paragraph.expectation.index,
lineIndex,
paragraph.expectation.blockKind,
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,
});
}
}
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 {
@@ -10,7 +10,12 @@ import type {
const ZERO_WIDTH_AND_CONTROL = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f\u200b-\u200d\u2060\ufeff]/gu;
const CJK_RADICAL_VARIANTS = new Map([
["⺠", "民"],
["⻅", "见"],
["⻆", "角"],
["⻓", "长"],
["⻔", "门"],
["⻚", "页"],
["⻛", "风"],
]);
const PAGE_NUMBER_PATTERNS = [
/^(?:[-]\s*)?\d+(?:\s*[/]\s*\d+)?(?:\s*[-])?$/u,
+65 -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,24 @@ 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;
styleId?: string;
role: EditableParagraphRole;
blockKind?: EditableParagraphBlockKind;
section: "cover" | "body";
}
@@ -68,6 +91,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 +108,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;
@@ -106,6 +154,7 @@ export interface PdfPageSnapshot {
items: PdfTextItemSnapshot[];
lines: PdfTextLineSnapshot[];
contentText: string;
fonts: PdfFontSnapshot[];
pageNumberText?: string;
raster?: PdfPageRaster;
}
@@ -116,6 +165,7 @@ export interface PdfDocumentSnapshot {
sha256: string;
pageCount: number;
contentText: string;
fonts: PdfFontSnapshot[];
pages: PdfPageSnapshot[];
}
@@ -138,10 +188,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 +226,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 +268,7 @@ export interface PdfBasicComparison {
candidateEditableSimilarity?: number;
candidateEditableExact?: boolean;
paragraphLayouts?: PdfParagraphLayoutComparison[];
semanticBlockVisuals?: PdfSemanticBlockVisualComparison[];
bodyFlow?: PdfBodyFlowComparison;
pagePairs: PdfPagePair[];
issues: VisualDiffIssue[];
@@ -224,14 +283,18 @@ export interface PdfRasterDiffMetrics {
comparedHeightPx: number;
dimensionsMatch: boolean;
geometryNormalized: boolean;
spatialTolerancePx: number;
meanAbsoluteError: number;
changedPixelRatio: number;
inkIou: number;
edgeIou: number;
backgroundColorDelta: number;
foregroundColorDelta: number;
}
export interface ComparePageRasterOptions {
pixelDifferenceThreshold?: number;
spatialTolerancePx?: number;
targetWidthPx?: number;
targetHeightPx?: number;
geometryNormalized?: boolean;
@@ -255,7 +318,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,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");
@@ -59,6 +59,95 @@ function expectation(
}
describe("PDF 内容感知段落版式门禁", () => {
it("同一物理行中的表格单元格分别保留自己的视觉边界", () => {
const tableLine = line("建设内容 主要能力", 220, 320, 72);
tableLine.items = [
{
text: "建设内容",
normalizedText: "建设内容",
bounds: { x: 72, y: 210, width: 48, height: 12 },
baselineY: 220,
fontFamily: "Test Serif",
hasEol: false,
},
{
text: "主要能力",
normalizedText: "主要能力",
bounds: { x: 240, y: 210, width: 48, height: 12 },
baselineY: 220,
fontFamily: "Test Serif",
hasEol: true,
},
];
const paragraphs = [
expectation("建设内容", { index: 0, blockKind: "table-header" }),
expectation("主要能力", { index: 1, blockKind: "table-header" }),
];
const results = comparePdfEditableParagraphLayouts(
snapshot("baseline", [[tableLine]]),
snapshot("candidate", [[tableLine]]),
paragraphs,
);
expect(results[0]?.baseline.visualLines[0]?.bounds).toEqual(
tableLine.items[0]?.bounds,
);
expect(results[1]?.baseline.visualLines[0]?.bounds).toEqual(
tableLine.items[1]?.bounds,
);
});
it("按字符类别估算合并文本项中的有序列表正文边界", () => {
const listLine = line("3.系统联调", 220, 100, 100);
listLine.items = [
{
text: "3. 系统联调",
normalizedText: "3. 系统联调",
bounds: { x: 100, y: 210, width: 100, height: 12 },
baselineY: 220,
fontFamily: "Test Serif",
hasEol: true,
},
];
const [result] = comparePdfEditableParagraphLayouts(
snapshot("baseline", [[listLine]]),
snapshot("candidate", [[listLine]]),
[expectation("系统联调", { blockKind: "list-item" })],
);
expect(result?.baseline.visualLines[0]?.bounds.x).toBeCloseTo(117.18, 1);
expect(result?.baseline.visualLines[0]?.bounds.width).toBeCloseTo(82.82, 1);
});
it("跳过被无关字符打断的相似文本并匹配后续完整段落", () => {
const [result] = comparePdfEditableParagraphLayouts(
snapshot("baseline", [[
line("甲X乙", 180),
line("甲乙", 220),
]]),
snapshot("candidate", [[
line("甲X乙", 180),
line("甲乙", 220),
]]),
[expectation("甲乙")],
);
expect(result?.status).toBe("passed");
expect(result?.baseline.firstLineBaselineYPt).toBe(220);
expect(result?.candidate.firstLineBaselineYPt).toBe(220);
});
it("连续段落可以自然跨越多个 PDF 视觉行", () => {
const [result] = comparePdfEditableParagraphLayouts(
snapshot("baseline", [[line("甲乙", 180), line("丙丁", 204)]]),
snapshot("candidate", [[line("甲乙", 180), line("丙丁", 204)]]),
[expectation("甲乙丙丁")],
);
expect(result?.status).toBe("passed");
expect(result?.baseline.lineTexts).toEqual(["甲乙", "丙丁"]);
});
it("允许正文段落整体移动到下一物理页", () => {
const baseline = snapshot("baseline", [
[line("建立月度调度机制及时协调", 700), line("解决项目中的问题", 728)],
@@ -123,6 +212,30 @@ describe("PDF 内容感知段落版式门禁", () => {
]);
});
it("正文块行数不变时允许三个字符以内的流式行内重排", () => {
const baseline = snapshot("baseline", [[
line("甲乙丙丁", 220),
line("戊己庚辛", 244),
]]);
const candidate = snapshot("candidate", [[
line("甲乙丙丁戊己", 220),
line("庚辛", 244),
]]);
const [result] = comparePdfEditableParagraphLayouts(
baseline,
candidate,
[expectation("甲乙丙丁戊己庚辛", { blockKind: "paragraph" })],
);
expect(result?.status).toBe("warning");
expect(result?.issues).toContainEqual(
expect.objectContaining({
code: "PARAGRAPH_LINE_BREAK_MISMATCH",
severity: "warning",
}),
);
});
it("拒绝短正文或非极短末行的行数差异", () => {
const text = "项目建设内容需要严格控制质量进度投资安全风险";
const baseline = snapshot("baseline", [
@@ -166,6 +279,35 @@ describe("PDF 内容感知段落版式门禁", () => {
);
});
it("拒绝正文内容回流到独立封面页", () => {
const baseline = snapshot("baseline", [
[line("独立封面", 200)],
[line("第一章项目概述", 100)],
]);
const candidate = snapshot("candidate", [
[line("独立封面", 200), line("第一章项目概述", 700)],
[],
]);
const pageSemantics = [
{ physicalPageNumber: 1, kind: "cover" as const },
{ physicalPageNumber: 2, kind: "body-first" as const },
];
const [result] = comparePdfEditableParagraphLayouts(
baseline,
candidate,
[expectation("第一章项目概述")],
pageSemantics,
pageSemantics,
);
expect(result?.status).toBe("failed");
expect(result?.issues).toContainEqual(
expect.objectContaining({
code: "COVER_LAYOUT_MISMATCH",
severity: "failure",
}),
);
});
it("忽略 PDF 文本提取器失真的单行宽度但保留诊断值", () => {
const baseline = snapshot("baseline", [
[line("数据治理平台建设项目", 300, 224)],
@@ -48,18 +48,50 @@ describe("PDF 栅格差异", () => {
const result = await comparePageRasters(page, page);
expect(result.metrics).toMatchObject({
dimensionsMatch: true,
spatialTolerancePx: 4,
meanAbsoluteError: 0,
changedPixelRatio: 0,
inkIou: 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 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 +100,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,7 @@ import { describe, expect, it } from "vitest";
import {
createPdfVisualDiffReport,
getBlockingVisualDiffIssues,
renderPdfVisualDiffHtml,
serializePdfVisualDiffJson,
type PdfDocumentSnapshot,
@@ -30,6 +31,59 @@ 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 snapshot(label: string, pageRaster: PdfPageRaster): PdfDocumentSnapshot {
return {
schemaVersion: 1,
@@ -82,7 +136,7 @@ function flowSnapshot(
items: [],
lines: [...lines],
contentText: lines.map((line) => line.normalizedText).join(""),
raster: raster(color),
raster: flowRaster(color),
})),
};
}
@@ -387,7 +441,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 +521,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",
@@ -498,4 +608,104 @@ 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 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,198 @@
import { describe, expect, it } from "vitest";
import {
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,
};
}
describe("跨引擎字形栅格等价", () => {
it("接受轮廓、颜色和几何一致的灰阶抗锯齿差异", () => {
expect(isCrossEngineRasterEquivalent(metrics(), false)).toBe(true);
expect(
isCrossEngineRasterEquivalent(
metrics({
candidateWidthPx: 222,
inkIou: 0.88,
foregroundColorDelta: 3.5,
}),
false,
),
).toBe(true);
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({
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("拒绝回流、颜色、几何或轮廓变化", () => {
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: 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,
@@ -42,6 +42,7 @@ describe("PDF 文本行聚合", () => {
it("将字体 ToUnicode 中的传统户部件归一为简体字符", () => {
expect(normalizePdfText("戶⼾")).toBe("户户");
expect(normalizePdfText("⻅⻆⻓⻔⻚⻛")).toBe("见角长门页风");
});
it("从可编辑正文契约中移除 Word 自动列表装饰符", () => {
File diff suppressed because it is too large Load Diff
+3 -2
View File
@@ -172,7 +172,7 @@ function pandocArguments(paths: {
return [
paths.markdown,
"--from",
"markdown+yaml_metadata_block+pipe_tables+footnotes+task_lists+tex_math_dollars",
"markdown+yaml_metadata_block+pipe_tables+footnotes+task_lists+tex_math_dollars-raw_html",
"--to",
"docx",
"--standalone",
@@ -375,7 +375,8 @@ export class PandocDocxConverter {
pandocDocx,
structurePlan,
input.themeTokens,
preparedMedia.layout
preparedMedia.layout,
preparedMedia.documentLayout
);
docx = embedFontsInGeneratedDocx(
finalized.content,
+8 -1
View File
@@ -6,6 +6,7 @@ import {
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:";
@@ -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
+94 -8
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");
@@ -1176,17 +1202,77 @@ function applyTokenStyles(
binding.styleId === "SourceCode"
);
}
const runToken = binding.styleId === "SourceCode"
? slots.get("code-block-text")?.style ?? entry.style
: entry.style;
applyTokenRunStyle(
ensureDirectElement(
target,
WORD_NAMESPACE,
"w:rPr"
),
entry.style,
fallbackFontsForSlot(slot, fallback)
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);
}
+93 -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,93 @@ 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 = ""
): DocxStyleSlotName | "code-block-text" | undefined {
if (!(PANDOC_SYNTAX_STYLE_IDS as readonly string[]).includes(styleId)) {
return undefined;
}
if (
styleId === "NormalTok" ||
styleId === "OtherTok" ||
(styleId === "FunctionTok" && /^[\p{P}\p{S}\s]+$/u.test(text))
) {
return styleId === "FunctionTok"
? "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 +242,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 +255,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)
@@ -33,7 +33,7 @@ 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>` +
@@ -42,7 +42,7 @@ function generatedFixture() {
`<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 +78,7 @@ const plan: PandocStructurePlan = {
kind: "paragraph",
styleId: "MdOfficialIssueRow",
layout: "space-between",
segments: ["发文字号", "签发人"],
segments: ["号", "发布单位", "签发人", "日期"],
separator: "tab"
}
]
@@ -161,11 +161,28 @@ 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: "table-header",
source: "computed-css",
@@ -213,7 +230,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 +265,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"/>'
);
@@ -276,9 +307,11 @@ describe("生成 DOCX 结构收口", () => {
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 +351,180 @@ 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("为右对齐盒模型保留 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: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("按稳定媒体计划规范化内联尺寸、比例锁和对齐", () => {
const result = finalizeGeneratedDocxStructure(
generatedFixture(),
@@ -438,14 +645,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 +665,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 +717,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 +764,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 +819,66 @@ 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
);
});
@@ -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(
"markdown+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");
@@ -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",
@@ -390,6 +410,13 @@ describe("动态 reference.docx", () => {
expect(sourceCodeStyle).toMatch(
/<w:ind\b[^>]*w:left="195"[^>]*w:right="75"[^>]*\/>/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(
'w:line="459" w:lineRule="exact"'
@@ -463,6 +490,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 +506,7 @@ 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(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,31 @@ 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("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("UnknownTok", "x"))
.toBeUndefined();
});
});
+86 -36
View File
@@ -366,11 +366,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 +381,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 +442,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 +650,8 @@ function applyBorderColor(
function applyOverrides(
slot: DocxStyleSlotName,
styleInput: DocxSlotStyleToken,
overrides: DocxThemeStyleOverrides
overrides: DocxThemeStyleOverrides,
replaceExisting: boolean
): {
style: DocxSlotStyleToken;
applied: boolean;
@@ -655,7 +662,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 +889,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 +912,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></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>
@@ -118,7 +118,6 @@ body {
border-radius: 0;
background: transparent;
box-shadow: none;
font-family: inherit;
font-size: inherit;
font-weight: inherit;
line-height: inherit;
+32
View File
@@ -19,7 +19,22 @@ 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",
"table",
"table-header",
"table-cell",
@@ -42,6 +57,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 +137,22 @@ 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",
table: "table",
"table-header": "table",
"table-cell": "table",
@@ -144,6 +175,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",
+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(),
@@ -175,6 +175,12 @@ 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);
});
@@ -204,6 +210,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 +373,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 +394,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 +419,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 +444,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(),
@@ -77,6 +77,8 @@ describe("DOCX 标准样式探针", () => {
expect(markup).toMatch(
/^<article id="write"[^>]*>\s*<h1 data-docx-slot="heading-1">/u
);
expect(markup).toContain('<code class="language-javascript"');
expect(markup).not.toContain('<code class="hljs ');
});
it("拒绝缺失和重复槽位的探针", () => {
@@ -84,6 +84,7 @@ describe("DOCX 主题样式浏览器运行时", () => {
expect(script).toContain("contentPaddingLeft");
expect(script).toContain("padding-left: 8px");
expect(script).toContain("#write pre.md-fences \\u003e code");
expect(script).not.toContain("font-family: inherit");
expect(script).toContain("frame.remove()");
expect(script).not.toContain("</style><script>x</script>");
});
@@ -7,6 +7,11 @@ import {
type DocxMediaCaptureTarget,
type DocxMediaAlignment,
type DocxMediaKind,
type DocxDocumentLayoutPlan,
type DocxInlineCodeLayout,
type DocxListItemLayout,
type DocxTableLayout,
type DocxTextBlockLayout,
type PagedDocumentPayload
} from "@md-to-pdf/core";
import { PagedDocumentRuntime } from "./paged-document-runtime.js";
@@ -145,6 +150,434 @@ function createGeometryCss(dimensions: DocxMediaRenderDimensions) {
`;
}
function average(values: readonly number[], fallback: number) {
return values.length > 0
? values.reduce((total, value) => total + value, 0) / values.length
: fallback;
}
function collectTableColumnWidths(table: HTMLTableElement, tableRect: DOMRect) {
const rows = Array.from(table.rows);
const columnCount = rows.reduce(
(maximum, row) =>
Math.max(
maximum,
Array.from(row.cells).reduce(
(total, cell) => total + Math.max(1, cell.colSpan),
0
)
),
0
);
if (columnCount < 1) {
return [tableRect.width];
}
const boundaries = Array.from(
{ length: columnCount + 1 },
() => [] as number[]
);
boundaries[0]!.push(0);
boundaries[columnCount]!.push(tableRect.width);
for (const row of rows) {
let column = 0;
for (const cell of Array.from(row.cells)) {
const span = Math.max(1, cell.colSpan);
const rect = cell.getBoundingClientRect();
boundaries[column]?.push(
Math.max(0, Math.min(tableRect.width, rect.left - tableRect.left))
);
column = Math.min(columnCount, column + span);
boundaries[column]?.push(
Math.max(0, Math.min(tableRect.width, rect.right - tableRect.left))
);
}
}
const resolved = boundaries.map((samples, index) =>
average(samples, (tableRect.width * index) / columnCount)
);
resolved[0] = 0;
resolved[columnCount] = tableRect.width;
for (let index = 1; index < resolved.length; index += 1) {
resolved[index] = Math.max(
resolved[index - 1]! + 0.01,
resolved[index]!
);
}
return resolved.slice(1).map(
(boundary, index) => boundary - resolved[index]!
);
}
function cssColor(value: string) {
const match = value.match(
/^rgba?\(\s*(\d+(?:\.\d+)?)\s*,\s*(\d+(?:\.\d+)?)\s*,\s*(\d+(?:\.\d+)?)(?:\s*,\s*(\d+(?:\.\d+)?))?\s*\)$/iu
);
if (!match) {
return undefined;
}
const alpha = match[4] === undefined ? 1 : Number(match[4]);
if (!Number.isFinite(alpha) || alpha <= 0.01) {
return undefined;
}
return `#${[match[1], match[2], match[3]]
.map((component) =>
Math.max(0, Math.min(255, Math.round(Number(component))))
.toString(16)
.padStart(2, "0")
)
.join("")}`;
}
function effectiveCellBackground(
cell: HTMLTableCellElement,
table: HTMLTableElement
) {
let current: Element | null = cell;
while (current) {
const color = cssColor(getComputedStyle(current).backgroundColor);
if (color) {
return color;
}
if (current === table) {
break;
}
current = current.parentElement;
}
return "#ffffff";
}
function cellAlignment(value: string): "left" | "center" | "right" | "justify" {
if (value === "center" || value === "right" || value === "justify") {
return value;
}
return "left";
}
const DOCX_TEXT_BLOCK_SELECTOR =
"h1, h2, h3, h4, h5, h6, p, li, th, td, figcaption, " +
".doc-classification, .doc-issue-row, .doc-printing-row, " +
".doc-briefing-meta";
function textNodeCharacters(root: HTMLElement) {
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
const characters: Array<{
node: Text;
start: number;
end: number;
character: string;
}> = [];
let current = walker.nextNode();
while (current) {
const node = current as Text;
let offset = 0;
for (const character of Array.from(node.data)) {
const end = offset + character.length;
characters.push({ node, start: offset, end, character });
offset = end;
}
current = walker.nextNode();
}
return characters;
}
function characterRect(
range: Range,
character: ReturnType<typeof textNodeCharacters>[number]
) {
if (typeof range.getBoundingClientRect !== "function") {
return undefined;
}
range.setStart(character.node, character.start);
range.setEnd(character.node, character.end);
const rect = range.getBoundingClientRect();
return rect.width > 0 && rect.height > 0 ? rect : undefined;
}
export function collectDocxTextBlockLayouts(
article: HTMLElement
): DocxTextBlockLayout[] {
const candidates = Array.from(
article.querySelectorAll<HTMLElement>(DOCX_TEXT_BLOCK_SELECTOR)
).filter((element) => {
if (element.closest('[data-semantic-region="cover"], pre')) {
return false;
}
if (element.querySelector("br")) {
return false;
}
return !Array.from(
element.querySelectorAll<HTMLElement>(DOCX_TEXT_BLOCK_SELECTOR)
).some((descendant) => descendant !== element);
});
return candidates.flatMap((element, index) => {
const range = document.createRange();
const output: string[] = [];
const lineBreakOffsets: number[] = [];
const lineCharacterRects: DOMRect[][] = [];
let previousLineTop: number | undefined;
let previousLineHeight = 0;
let pendingWhitespace = false;
for (const character of textNodeCharacters(element)) {
if (/\s/u.test(character.character)) {
pendingWhitespace = output.length > 0;
continue;
}
const rect = characterRect(range, character);
if (!rect) {
continue;
}
if (pendingWhitespace && output.length > 0) {
output.push(" ");
}
pendingWhitespace = false;
if (
previousLineTop !== undefined &&
Math.abs(rect.top - previousLineTop) >
Math.max(1, Math.min(previousLineHeight, rect.height) * 0.25)
) {
const offset = output.length;
if (offset > 0 && lineBreakOffsets.at(-1) !== offset) {
lineBreakOffsets.push(offset);
}
lineCharacterRects.push([]);
} else if (lineCharacterRects.length === 0) {
lineCharacterRects.push([]);
}
lineCharacterRects.at(-1)!.push(rect);
output.push(character.character.normalize("NFKC"));
previousLineTop = rect.top;
previousLineHeight = rect.height;
}
const text = output.join("").trim();
const computed = getComputedStyle(element);
const letterSpacingPx = computed.letterSpacing === "normal"
? 0
: Number.parseFloat(computed.letterSpacing);
const elementRect = element.getBoundingClientRect();
const contentWidth = Math.max(
0,
elementRect.width -
Number.parseFloat(computed.paddingLeft || "0") -
Number.parseFloat(computed.paddingRight || "0") -
Number.parseFloat(computed.borderLeftWidth || "0") -
Number.parseFloat(computed.borderRightWidth || "0")
);
const lastLine = lineCharacterRects.at(-1) ?? [];
const sortedLastLine = [...lastLine].sort(
(left, right) => left.left - right.left
);
const lastLineWidth = sortedLastLine.length > 0
? sortedLastLine.at(-1)!.right - sortedLastLine[0]!.left
: 0;
const maximumGap = sortedLastLine.slice(1).reduce(
(largest, rect, rectIndex) => Math.max(
largest,
rect.left - sortedLastLine[rectIndex]!.right
),
0
);
const fontSizePx = Number.parseFloat(computed.fontSize);
const distributed =
sortedLastLine.length > 1 &&
contentWidth > 0 &&
lastLineWidth / contentWidth >= 0.85 &&
Number.isFinite(fontSizePx) &&
maximumGap >= fontSizePx * 0.5;
const lineTops = lineCharacterRects
.map((rects) => rects.length > 0
? Math.min(...rects.map((rect) => rect.top))
: undefined)
.filter((top): top is number => top !== undefined);
const linePitchPx = lineTops.length > 1
? lineTops.slice(1).reduce(
(sum, top, lineIndex) => sum + top - lineTops[lineIndex]!,
0
) / (lineTops.length - 1)
: undefined;
return text
? [{
ordinal: index + 1,
text,
letterSpacingPt: Number.isFinite(letterSpacingPx)
? letterSpacingPx * 0.75
: 0,
alignment: distributed
? "distribute" as const
: cellAlignment(computed.textAlign),
...(linePitchPx !== undefined && linePitchPx > 0
? { linePitchPt: linePitchPx * 0.75 }
: {}),
lineBreakOffsets
}]
: [];
});
}
function listItemCharacters(item: HTMLLIElement) {
return textNodeCharacters(item).filter(({ node }) => {
const parent = node.parentElement;
const nestedList = parent?.closest("ul, ol");
return nestedList === item.parentElement;
});
}
export function collectDocxListItemLayouts(
article: HTMLElement
): DocxListItemLayout[] {
const articleLeft = article.getBoundingClientRect().left;
return Array.from(article.querySelectorAll<HTMLLIElement>("li")).flatMap(
(item, index) => {
const range = document.createRange();
const characters = listItemCharacters(item);
const text = characters
.map(({ character }) => character)
.join("")
.replace(/\s+/gu, " ")
.trim()
.normalize("NFKC");
const firstVisibleCharacter = characters.find(
(character) =>
!/\s/u.test(character.character) &&
characterRect(range, character) !== undefined
);
if (!text || !firstVisibleCharacter) {
return [];
}
const rect = characterRect(range, firstVisibleCharacter);
if (!rect) {
return [];
}
let listDepth = -1;
for (
let ancestor: Element | null = item.parentElement;
ancestor && article.contains(ancestor);
ancestor = ancestor.parentElement
) {
if (ancestor.matches("ul, ol")) {
listDepth += 1;
}
}
return [{
ordinal: index + 1,
text,
depth: Math.max(0, listDepth),
textStartPt: Math.max(
0,
(rect.left - articleLeft) * 0.75
)
}];
}
);
}
function cssPixelsToPoints(value: string) {
const pixels = Number.parseFloat(value);
return Number.isFinite(pixels) ? pixels * 0.75 : 0;
}
export function collectDocxInlineCodeLayouts(
article: HTMLElement
): DocxInlineCodeLayout[] {
return Array.from(
article.querySelectorAll<HTMLElement>("code")
).filter(
(element) =>
!element.closest("pre, .md-code-pagination-chunk")
).flatMap((element, index) => {
const text = (element.textContent ?? "").normalize("NFKC");
if (!text) {
return [];
}
const computed = getComputedStyle(element);
const fontSizePt = cssPixelsToPoints(computed.fontSize);
if (fontSizePt <= 0) {
return [];
}
const color = cssColor(computed.color) ?? "#000000";
const backgroundColor = cssColor(computed.backgroundColor);
return [{
ordinal: index + 1,
text,
fontSizePt,
letterSpacingPt: computed.letterSpacing === "normal"
? 0
: cssPixelsToPoints(computed.letterSpacing),
color,
...(backgroundColor ? { backgroundColor } : {}),
paddingPt: {
top: cssPixelsToPoints(computed.paddingTop),
right: cssPixelsToPoints(computed.paddingRight),
bottom: cssPixelsToPoints(computed.paddingBottom),
left: cssPixelsToPoints(computed.paddingLeft)
},
borderPt: {
top: cssPixelsToPoints(computed.borderTopWidth),
right: cssPixelsToPoints(computed.borderRightWidth),
bottom: cssPixelsToPoints(computed.borderBottomWidth),
left: cssPixelsToPoints(computed.borderLeftWidth)
}
}];
});
}
export function collectDocxDocumentLayoutPlan(
root: ParentNode,
dimensions: DocxMediaRenderDimensions
): DocxDocumentLayoutPlan {
const article = root.querySelector<HTMLElement>("#write");
if (!article) {
throw new Error("DOCX 布局舞台缺少 #write 文档容器");
}
const articleRect = article.getBoundingClientRect();
const contentWidth = finitePositive(
articleRect.width,
dimensions.contentWidthPx
);
const tables = Array.from(
article.querySelectorAll<HTMLTableElement>("table")
).map((table, index): DocxTableLayout => {
const rect = table.getBoundingClientRect();
const width = finitePositive(rect.width, contentWidth);
const columnWidths = collectTableColumnWidths(table, rect);
const columnTotal = columnWidths.reduce(
(total, value) => total + value,
0
);
return {
ordinal: index + 1,
widthPercent: Math.min(100, (width / contentWidth) * 100),
leftOffsetPercent: Math.max(
0,
Math.min(100, ((rect.left - articleRect.left) / contentWidth) * 100)
),
columnWidthPercents: columnWidths.map(
(value) => (value / columnTotal) * 100
),
rows: Array.from(table.rows).map((row) => ({
cells: Array.from(row.cells).map((cell) => {
const computed = getComputedStyle(cell);
return {
columnSpan: Math.max(1, cell.colSpan),
backgroundColor: effectiveCellBackground(cell, table),
color: cssColor(computed.color) ?? "#000000",
bold: Number.parseInt(computed.fontWeight, 10) >= 600 ||
computed.fontWeight === "bold",
italic: computed.fontStyle === "italic" ||
computed.fontStyle === "oblique",
alignment: cellAlignment(computed.textAlign)
};
})
}))
};
});
return {
tables,
textBlocks: collectDocxTextBlockLayouts(article),
listItems: collectDocxListItemLayouts(article),
inlineCodes: collectDocxInlineCodeLayouts(article)
};
}
export function collectDocxMediaCaptureTargets(
root: ParentNode,
dimensions: DocxMediaRenderDimensions
@@ -226,7 +659,8 @@ export async function renderDocxMediaCapturePlan(
dimensions: DocxMediaRenderDimensions
): Promise<DocxMediaCapturePlan> {
const renderResult = await runtime.renderContinuous(payload, {
geometryCss: createGeometryCss(dimensions)
geometryCss: createGeometryCss(dimensions),
themeMedia: "print"
});
if (!renderResult) {
throw new Error("DOCX 媒体渲染已取消");
@@ -234,6 +668,7 @@ export async function renderDocxMediaCapturePlan(
return {
targets: collectDocxMediaCaptureTargets(root, dimensions),
echartsErrors: renderResult.echartsErrors,
mermaidErrors: renderResult.mermaidErrors
mermaidErrors: renderResult.mermaidErrors,
documentLayout: collectDocxDocumentLayoutPlan(root, dimensions)
};
}
@@ -0,0 +1,19 @@
export const SHARED_HIGHLIGHT_CSS = `
pre code.hljs{display:block;overflow-x:auto;padding:1em}
code.hljs{padding:3px 5px}
.hljs{color:#24292e;background:#fff}
.hljs-doctag,.hljs-keyword,.hljs-meta .hljs-keyword,.hljs-template-tag,.hljs-template-variable,.hljs-type,.hljs-variable.language_{color:#d73a49}
.hljs-title,.hljs-title.class_,.hljs-title.class_.inherited__,.hljs-title.function_{color:#6f42c1}
.hljs-attr,.hljs-attribute,.hljs-literal,.hljs-meta,.hljs-number,.hljs-operator,.hljs-variable,.hljs-selector-attr,.hljs-selector-class,.hljs-selector-id{color:#005cc5}
.hljs-regexp,.hljs-string,.hljs-meta .hljs-string{color:#032f62}
.hljs-built_in,.hljs-symbol{color:#e36209}
.hljs-comment,.hljs-code,.hljs-formula{color:#6a737d}
.hljs-name,.hljs-quote,.hljs-selector-tag,.hljs-selector-pseudo{color:#22863a}
.hljs-subst{color:#24292e}
.hljs-section{color:#005cc5;font-weight:bold}
.hljs-bullet{color:#735c0f}
.hljs-emphasis{color:#24292e;font-style:italic}
.hljs-strong{color:#24292e;font-weight:bold}
.hljs-addition{color:#22863a;background-color:#f0fff4}
.hljs-deletion{color:#b31d28;background-color:#ffeef0}
`.trim();
+1
View File
@@ -1,5 +1,6 @@
export * from "./diagram-page-fit.js";
export * from "./continuous-preview.js";
export * from "./highlight-styles.js";
export * from "./document-image-fit.js";
export * from "./docx-media-runtime.js";
export * from "./echarts-page-fit.js";
@@ -66,6 +66,7 @@ export interface PagedDocumentRenderOptions {
export interface ContinuousDocumentRenderOptions {
shouldContinue?: () => boolean;
geometryCss?: string;
themeMedia?: "preview" | "print";
}
export interface PreviewEngineStyles {
@@ -195,6 +196,34 @@ function removeTrailingPdfPageBreak(container: ParentNode) {
?.style.setProperty("break-after", "auto", "important");
}
const PAGED_SPLIT_BLOCK_SELECTOR = [
"h1", "h2", "h3", "h4", "h5", "h6", "p", "ul", "ol", "li",
"blockquote", "table", "thead", "tbody", "tfoot", "tr", "th", "td",
"figure", "figcaption", "pre", "section", "article", "div"
].join(",");
export function removeInheritedPagedSplitJustification(
container: ParentNode
) {
let count = 0;
for (const element of Array.from(
container.querySelectorAll<HTMLElement>(
'[data-align-last-split-element="justify"]'
)
)) {
if (
!Array.from(element.children).some((child) =>
child.matches(PAGED_SPLIT_BLOCK_SELECTOR)
)
) {
continue;
}
element.removeAttribute("data-align-last-split-element");
count += 1;
}
return count;
}
function createPagedRenderIdentity(
payload: PagedPreviewPayload,
options: PagedDocumentRenderOptions
@@ -411,7 +440,8 @@ export class PagedDocumentRuntime {
private applyContinuousStyles(
documentRef: Document,
payload: PagedPreviewPayload,
geometryCss = ""
geometryCss = "",
themeMedia: ContinuousDocumentRenderOptions["themeMedia"] = "preview"
) {
const style =
this.continuousStyle ?? documentRef.createElement("style");
@@ -421,7 +451,9 @@ export class PagedDocumentRuntime {
this.styles.highlightCss,
this.styles.katexCss,
this.styles.echartsCss,
enablePrintMediaForPreview(payload.themeCss),
themeMedia === "preview"
? enablePrintMediaForPreview(payload.themeCss)
: payload.themeCss,
documentInteractionCss,
continuousDocumentGeometryCss,
geometryCss
@@ -897,7 +929,9 @@ export class PagedDocumentRuntime {
}
const finalizeStartedAt = performance.now();
removeInheritedPagedSplitJustification(this.root);
const pageSequence = classifyPagedPages(this.root, payload);
pageCount = pageSequence.physicalPageCount;
applyPagedPageDecorations(this.root, payload, pageSequence);
if (options.target === "pdf") {
removeTrailingPdfPageBreak(this.root);
@@ -953,7 +987,8 @@ export class PagedDocumentRuntime {
this.applyContinuousStyles(
documentRef,
payload,
options.geometryCss
options.geometryCss,
options.themeMedia
);
const template = documentRef.createElement("template");
@@ -968,7 +1003,8 @@ export class PagedDocumentRuntime {
const identity = JSON.stringify({
themeCss: payload.themeCss,
mermaid: payload.exportConfig.mermaid,
geometryCss: options.geometryCss
geometryCss: options.geometryCss,
themeMedia: options.themeMedia ?? "preview"
});
const currentArticle =
this.root.querySelector<HTMLElement>(":scope > #write");
+5 -6
View File
@@ -95,7 +95,6 @@ svg {
border-radius: 0;
background: transparent;
box-shadow: none;
font-family: inherit;
font-size: inherit;
font-weight: inherit;
line-height: inherit;
@@ -167,11 +166,6 @@ body,
page-break-after: always !important;
}
#write [data-semantic-region="cover"][data-semantic-break-after="next-page"] {
break-inside: avoid !important;
page-break-inside: avoid !important;
}
#write {
width: 100% !important;
max-width: none !important;
@@ -553,6 +547,11 @@ html[data-render-target="pdf"] body {
min-height: 0 !important;
overflow: visible !important;
background: #fff !important;
border: 0 !important;
break-before: auto !important;
break-after: auto !important;
page-break-before: auto !important;
page-break-after: auto !important;
}
html[data-render-target="pdf"] .pagedjs_pages {
@@ -1,4 +1,105 @@
const COVER_HEIGHT_EPSILON_PX = 0.5;
const COVER_PAGE_BREAK_SAFETY_PX = 8;
function pixelValue(value: string) {
const parsed = Number.parseFloat(value);
return Number.isFinite(parsed) ? parsed : 0;
}
function constrainCoverChildSpacing(
cover: HTMLElement,
pageContentHeightPx: number
) {
const coverStyle = getComputedStyle(cover);
const availableHeightPx = Math.max(
0,
pageContentHeightPx -
pixelValue(coverStyle.paddingTop) -
pixelValue(coverStyle.paddingBottom) -
pixelValue(coverStyle.borderTopWidth) -
pixelValue(coverStyle.borderBottomWidth)
);
const children = Array.from(cover.children).flatMap((child) => {
if (!(child instanceof HTMLElement)) {
return [];
}
const style = getComputedStyle(child);
if (style.display === "none" || style.position === "absolute") {
return [];
}
return [{
element: child,
heightPx: child.getBoundingClientRect().height,
marginTopPx: pixelValue(style.marginTop),
marginBottomPx: pixelValue(style.marginBottom)
}];
});
const fixedHeightPx = children.reduce(
(total, child) => total + child.heightPx,
0
);
const spacingHeightPx = children.reduce(
(total, child) =>
total + child.marginTopPx + child.marginBottomPx,
0
);
if (
children.length === 0 ||
spacingHeightPx <= COVER_HEIGHT_EPSILON_PX ||
fixedHeightPx + spacingHeightPx <=
availableHeightPx + COVER_HEIGHT_EPSILON_PX
) {
return 1;
}
const spacingScale = Math.min(
1,
Math.max(0, availableHeightPx - fixedHeightPx) / spacingHeightPx
);
for (const child of children) {
child.element.style.marginTop =
`${child.marginTopPx * spacingScale}px`;
child.element.style.marginBottom =
`${child.marginBottomPx * spacingScale}px`;
}
cover.dataset.semanticCoverSpacingScale =
spacingScale.toFixed(6);
return spacingScale;
}
function stabilizeConstrainedCoverFlow(cover: HTMLElement) {
const style = getComputedStyle(cover);
if (style.display !== "flex" && style.display !== "inline-flex") {
return;
}
const layout = cover.ownerDocument.createElement("div");
layout.dataset.semanticCoverLayout = "flex";
layout.style.display = "flex";
layout.style.width = "100%";
layout.style.height = "100%";
layout.style.minHeight = "0";
layout.style.flexDirection = style.flexDirection;
layout.style.alignItems = style.alignItems;
layout.style.justifyContent = style.justifyContent;
layout.append(...Array.from(cover.childNodes));
cover.append(layout);
cover.style.display = "block";
}
function insertCoverPageBreakAnchor(cover: HTMLElement) {
const anchor = cover.ownerDocument.createElement("div");
anchor.dataset.semanticCoverPageBreakAnchor = "true";
anchor.setAttribute("aria-hidden", "true");
anchor.style.cssText = [
"height: 0",
"margin: 0",
"padding: 0",
"border: 0",
"line-height: 0"
].join(";");
anchor.style.setProperty("break-after", "page", "important");
anchor.style.setProperty("page-break-after", "always", "important");
cover.after(anchor);
}
export const semanticCoverSelector =
'[data-semantic-region="cover"][data-semantic-break-after="next-page"]';
@@ -21,12 +122,24 @@ export function constrainSemanticCoversToPage(
) {
continue;
}
const constrainedHeight = `${pageContentHeightPx}px`;
const fittedHeightPx = Math.max(
1,
pageContentHeightPx - COVER_PAGE_BREAK_SAFETY_PX
);
const constrainedHeight = `${fittedHeightPx}px`;
cover.style.boxSizing = "border-box";
cover.style.height = constrainedHeight;
cover.style.minHeight = constrainedHeight;
cover.style.maxHeight = constrainedHeight;
cover.style.overflow = "hidden";
cover.style.setProperty("break-inside", "auto", "important");
cover.style.setProperty("page-break-inside", "auto", "important");
cover.dataset.semanticCoverFit = "constrained";
cover.dataset.semanticCoverPageBreak = "natural";
cover.removeAttribute("data-semantic-break-after");
constrainCoverChildSpacing(cover, fittedHeightPx);
stabilizeConstrainedCoverFlow(cover);
insertCoverPageBreakAnchor(cover);
constrainedCount += 1;
}
return constrainedCount;
@@ -1,7 +1,15 @@
// @vitest-environment happy-dom
import { beforeEach, describe, expect, it } from "vitest";
import { collectDocxMediaCaptureTargets } from "../src/index.js";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
collectDocxDocumentLayoutPlan,
collectDocxInlineCodeLayouts,
collectDocxListItemLayouts,
collectDocxTextBlockLayouts,
collectDocxMediaCaptureTargets,
removeInheritedPagedSplitJustification,
renderDocxMediaCapturePlan
} from "../src/index.js";
describe("DOCX 媒体捕获计划", () => {
beforeEach(() => {
@@ -9,6 +17,74 @@ describe("DOCX 媒体捕获计划", () => {
window.scrollTo(0, 0);
});
it("连续布局使用原始打印媒体主题 CSS", async () => {
const renderContinuous = vi.fn(async () => ({
echartsErrors: [],
mermaidErrors: []
}));
const root = document.createElement("main");
root.innerHTML = '<article id="write"></article>';
await renderDocxMediaCapturePlan(
{ renderContinuous } as never,
root,
{
articleHtml: '<article id="write"></article>',
fileName: "打印媒体.md",
metadata: {
title: "",
author: "",
subject: "",
keywords: [],
language: "zh-CN"
},
semanticDocument: {
schemaVersion: 1,
titlePolicy: {
metadataTitle: "suppress",
firstBodyHeading: "keep"
},
regions: []
},
features: [],
themeCss: "@media print { html { font-size: 13px; } }",
exportConfig: {} as never
},
{ contentWidthPx: 640, contentHeightPx: 900 }
);
expect(renderContinuous).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ themeMedia: "print" })
);
});
it("只移除会把 Paged.js 末行两端对齐继承给子块的容器标记", () => {
document.body.innerHTML = `
<main id="pages">
<article id="write" data-align-last-split-element="justify">
<h1></h1>
<p data-align-last-split-element="justify"></p>
</article>
</main>
`;
expect(
removeInheritedPagedSplitJustification(
document.querySelector("#pages")!
)
).toBe(1);
expect(
document.querySelector("#write")?.hasAttribute(
"data-align-last-split-element"
)
).toBe(false);
expect(
document.querySelector("p")?.getAttribute(
"data-align-last-split-element"
)
).toBe("justify");
});
it("按文档顺序标记图片、Mermaid 和 ECharts", () => {
document.body.innerHTML = `
<article id="write">
@@ -155,4 +231,236 @@ describe("DOCX 媒体捕获计划", () => {
).toBeLessThanOrEqual(4096);
expect(target?.altText).toBe("图片 1");
});
it("从真实 DOM 几何采集主题无关的表格列宽比例", () => {
document.body.innerHTML = `
<article id="write">
<table><tbody>
<tr><td></td><td></td></tr>
<tr><td>1</td><td>2</td></tr>
</tbody></table>
</article>
`;
const article = document.querySelector<HTMLElement>("#write")!;
const table = document.querySelector<HTMLTableElement>("table")!;
const cells = Array.from(table.querySelectorAll<HTMLTableCellElement>("td"));
article.getBoundingClientRect = () => ({
left: 100,
right: 900,
top: 0,
bottom: 900,
width: 800,
height: 900
}) as DOMRect;
table.getBoundingClientRect = () => ({
left: 140,
right: 860,
top: 20,
bottom: 220,
width: 720,
height: 200
}) as DOMRect;
cells.forEach((cell, index) => {
const firstColumn = index % 2 === 0;
cell.getBoundingClientRect = () => ({
left: firstColumn ? 140 : 356,
right: firstColumn ? 356 : 860,
top: index < 2 ? 20 : 120,
bottom: index < 2 ? 120 : 220,
width: firstColumn ? 216 : 504,
height: 100
}) as DOMRect;
});
expect(
collectDocxDocumentLayoutPlan(document, {
contentWidthPx: 800,
contentHeightPx: 900
})
).toEqual({
inlineCodes: [],
listItems: [],
textBlocks: [],
tables: [
{
ordinal: 1,
widthPercent: 90,
leftOffsetPercent: 5,
columnWidthPercents: [30, 70],
rows: [
{
cells: [
{
columnSpan: 1,
backgroundColor: "#ffffff",
color: "#000000",
bold: false,
italic: false,
alignment: "left"
},
{
columnSpan: 1,
backgroundColor: "#ffffff",
color: "#000000",
bold: false,
italic: false,
alignment: "left"
}
]
},
{
cells: [
{
columnSpan: 1,
backgroundColor: "#ffffff",
color: "#000000",
bold: false,
italic: false,
alignment: "left"
},
{
columnSpan: 1,
backgroundColor: "#ffffff",
color: "#000000",
bold: false,
italic: false,
alignment: "left"
}
]
}
]
}
]
});
});
it("按真实上下文采集行内代码字号、颜色和盒模型", () => {
document.body.innerHTML = `
<article id="write" style="font-size: 20px">
<p><code style="font-size: .8em; letter-spacing: 1px; color: rgb(17, 34, 51); background: rgb(245, 245, 245); padding: 2px 4px">inline</code></p>
<pre><code>block</code></pre>
</article>
`;
expect(
collectDocxInlineCodeLayouts(
document.querySelector<HTMLElement>("#write")!
)
).toEqual([
{
ordinal: 1,
text: "inline",
fontSizePt: 12,
letterSpacingPt: 0.75,
color: "#112233",
backgroundColor: "#f5f5f5",
paddingPt: {
top: 1.5,
right: 3,
bottom: 1.5,
left: 3
},
borderPt: {
top: 0,
right: 0,
bottom: 0,
left: 0
}
}
]);
});
it("按列表项实际文字起点采集嵌套缩进且排除子列表文字", () => {
document.body.innerHTML = `
<article id="write">
<ul><li> <code></code><ul><li></li></ul></li></ul>
</article>
`;
const article = document.querySelector<HTMLElement>("#write")!;
article.getBoundingClientRect = () => ({
left: 100,
right: 900,
top: 0,
bottom: 900,
width: 800,
height: 900
}) as DOMRect;
const rangePrototype = Object.getPrototypeOf(document.createRange()) as {
getBoundingClientRect?: () => DOMRect;
};
const original = rangePrototype.getBoundingClientRect;
rangePrototype.getBoundingClientRect = function (this: Range) {
const left = this.startContainer.textContent === "二级项目" ? 340 : 220;
return {
left,
right: left + 10,
top: 20,
bottom: 32,
width: 10,
height: 12
} as DOMRect;
};
try {
expect(collectDocxListItemLayouts(article)).toEqual([
{
ordinal: 1,
text: "一级 代码",
depth: 0,
textStartPt: 90
},
{
ordinal: 2,
text: "二级项目",
depth: 1,
textStartPt: 180
}
]);
} finally {
rangePrototype.getBoundingClientRect = original;
}
});
it("采集正文文本块在 Chromium 中的实际行断点", () => {
document.body.innerHTML = `
<article id="write">
<section data-semantic-region="cover"><p></p></section>
<p></p>
</article>
`;
const rangePrototype = Object.getPrototypeOf(document.createRange()) as {
getBoundingClientRect?: () => DOMRect;
};
const original = rangePrototype.getBoundingClientRect;
rangePrototype.getBoundingClientRect = function (this: Range) {
const top = this.startOffset < 2 ? 100 : 120;
return {
x: this.startOffset * 10,
y: top,
left: this.startOffset * 10,
right: this.startOffset * 10 + 10,
top,
bottom: top + 12,
width: 10,
height: 12,
toJSON: () => ({})
} as DOMRect;
};
try {
expect(
collectDocxTextBlockLayouts(
document.querySelector<HTMLElement>("#write")!
)
).toEqual([
{
ordinal: 1,
text: "甲乙丙丁",
letterSpacingPt: 0,
alignment: "left",
linePitchPt: 15,
lineBreakOffsets: [2]
}
]);
} finally {
rangePrototype.getBoundingClientRect = original;
}
});
});
@@ -62,6 +62,7 @@ describe("分页预览协议", () => {
expect(css).toContain('html[data-render-target="pdf"]');
expect(css).toContain("height: auto !important");
expect(css).toContain("overflow: visible !important");
expect(css).toContain("page-break-after: auto !important");
expect(css).toContain("padding: 0");
expect(css).toContain("break-after: page");
expect(css).not.toContain("@media print");
@@ -213,11 +214,11 @@ describe("分页预览协议", () => {
expect(documentGeometryCss).toContain(
"page-break-after: always !important"
);
expect(documentGeometryCss).toContain(
'[data-semantic-region="cover"]'
expect(documentGeometryCss).not.toContain(
'[data-semantic-region="cover"][data-semantic-break-after="next-page"]'
);
expect(documentGeometryCss).toContain(
"break-inside: avoid !important"
expect(documentGeometryCss).not.toContain(
"break-after: auto !important"
);
});
@@ -243,6 +244,9 @@ describe("分页预览协议", () => {
);
expect(documentBaseCss).toContain("background: transparent;");
expect(documentBaseCss).toContain("font-size: inherit;");
expect(documentBaseCss).not.toMatch(
/#write pre\.md-fences > code,[\s\S]*?font-family:\s*inherit;/u
);
expect(documentBaseCss).not.toMatch(
/#write pre\.md-fences > code\s*\{[^}]*!important/su
);
@@ -14,19 +14,44 @@ function createCover(height: number) {
const cover = root.querySelector<HTMLElement>("header")!;
cover.getBoundingClientRect = () =>
({ height } as DOMRect);
document.body.append(root);
return { root, cover };
}
describe("语义封面页面适配", () => {
it("只将超过横向页面内容区的封面收束为单页高度", () => {
const { root, cover } = createCover(900);
cover.style.display = "flex";
cover.style.flexDirection = "column";
cover.style.alignItems = "center";
cover.style.justifyContent = "center";
expect(constrainSemanticCoversToPage(root, 680)).toBe(1);
expect(cover.style.boxSizing).toBe("border-box");
expect(cover.style.height).toBe("680px");
expect(cover.style.minHeight).toBe("680px");
expect(cover.style.maxHeight).toBe("680px");
expect(cover.style.height).toBe("672px");
expect(cover.style.minHeight).toBe("672px");
expect(cover.style.maxHeight).toBe("672px");
expect(cover.style.overflow).toBe("hidden");
expect(cover.style.display).toBe("block");
expect(cover.style.getPropertyValue("break-inside")).toBe("auto");
expect(cover.style.getPropertyPriority("break-inside")).toBe("important");
expect(cover.dataset.semanticCoverFit).toBe("constrained");
expect(cover.dataset.semanticCoverPageBreak).toBe("natural");
expect(cover.hasAttribute("data-semantic-break-after")).toBe(false);
const anchor = cover.nextElementSibling as HTMLElement | null;
expect(anchor?.dataset.semanticCoverPageBreakAnchor).toBe("true");
expect(anchor?.style.height).toBe("0px");
expect(anchor?.style.getPropertyValue("break-after")).toBe("page");
expect(anchor?.style.getPropertyPriority("break-after")).toBe(
"important"
);
const layout = cover.querySelector<HTMLElement>(
'[data-semantic-cover-layout="flex"]'
);
expect(layout?.style.display).toBe("flex");
expect(layout?.style.flexDirection).toBe("column");
expect(layout?.style.alignItems).toBe("center");
expect(layout?.style.justifyContent).toBe("center");
});
it("纵向页面可以容纳主题封面时保持主题原始高度", () => {
@@ -36,4 +61,29 @@ describe("语义封面页面适配", () => {
expect(cover.getAttribute("style")).toBeNull();
expect(cover.dataset.semanticCoverFit).toBeUndefined();
});
it("横向页面按同一比例压缩封面子元素纵向间距", () => {
const { root, cover } = createCover(900);
cover.style.display = "flex";
cover.style.flexDirection = "column";
cover.innerHTML = `
<p style="margin-top: 50px; margin-bottom: 50px"></p>
<p style="margin-top: 50px; margin-bottom: 50px"></p>
`;
for (const child of Array.from(cover.children)) {
Object.defineProperty(child, "getBoundingClientRect", {
value: () => ({ height: 100 } as DOMRect),
});
}
expect(constrainSemanticCoversToPage(root, 300)).toBe(1);
expect(cover.dataset.semanticCoverSpacingScale).toBe("0.460000");
const layout = cover.querySelector<HTMLElement>(
'[data-semantic-cover-layout="flex"]'
)!;
for (const child of Array.from(layout.children)) {
expect((child as HTMLElement).style.marginTop).toBe("23px");
expect((child as HTMLElement).style.marginBottom).toBe("23px");
}
});
});
+41
View File
@@ -0,0 +1,41 @@
import { readFile } from "node:fs/promises";
import { spawn } from "node:child_process";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const desktopRoot = join(repositoryRoot, "apps", "desktop");
const desktopManifest = JSON.parse(
await readFile(join(desktopRoot, "package.json"), "utf8")
);
const npmCli =
process.env.npm_execpath ||
join(dirname(process.execPath), "node_modules", "npm", "bin", "npm-cli.js");
await new Promise((resolvePromise, reject) => {
const child = spawn(
process.execPath,
[npmCli, "run", "make", "-w", "@md-to-pdf/desktop"],
{
cwd: repositoryRoot,
env: process.env,
stdio: "inherit",
windowsHide: true
}
);
child.once("error", reject);
child.once("exit", (code, signal) => {
if (code === 0) {
resolvePromise();
} else {
reject(
new Error(
`桌面安装器构建失败:${signal ? `signal ${signal}` : `exit ${code ?? "unknown"}`}`
)
);
}
});
});
process.stdout.write(
`已生成字体直接内置的主安装器与便携 ZIP:${join(desktopRoot, "out", `v${desktopManifest.version}`)}\n`
);
-80
View File
@@ -1,80 +0,0 @@
import { copyFile, mkdir, readFile } from "node:fs/promises";
import { spawn } from "node:child_process";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { createDesktopCompanionInclude } from "../packages/font-pack-builder/dist/index.js";
const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const desktopRoot = join(repositoryRoot, "apps", "desktop");
const desktopManifest = JSON.parse(
await readFile(join(desktopRoot, "package.json"), "utf8")
);
const fontInstallerName =
"MorphDoc-FontPack-mdtp-serif-sc-1.0.0-x86_64-Setup.exe";
const fontArchiveName = "MorphDoc-FontPack-mdtp-serif-sc-1.0.0.zip";
const fontInstallerPath = join(
repositoryRoot,
"output",
"font-packs",
"windows",
fontInstallerName
);
const generatedInclude = join(
repositoryRoot,
"output",
"font-packs",
"desktop-companion.nsh"
);
const fontArchivePath = join(
repositoryRoot,
"output",
"font-packs",
"release",
fontArchiveName
);
await createDesktopCompanionInclude({
installerPath: fontInstallerPath,
baseIncludePath: join(desktopRoot, "build", "installer.nsh"),
outputPath: generatedInclude,
displayName: "MdTP 中文宋体兼容包 1.0.0"
});
const npmCli =
process.env.npm_execpath ||
join(dirname(process.execPath), "node_modules", "npm", "bin", "npm-cli.js");
await new Promise((resolvePromise, reject) => {
const child = spawn(
process.execPath,
[npmCli, "run", "make", "-w", "@md-to-pdf/desktop"],
{
cwd: repositoryRoot,
env: {
...process.env,
MD_TO_PDF_NSIS_INCLUDE: generatedInclude
},
stdio: "inherit",
windowsHide: true
}
);
child.once("error", reject);
child.once("exit", (code, signal) => {
if (code === 0) {
resolvePromise();
} else {
reject(
new Error(
`桌面安装器构建失败:${signal ? `signal ${signal}` : `exit ${code ?? "unknown"}`}`
)
);
}
});
});
const desktopOutput = join(desktopRoot, "out", `v${desktopManifest.version}`);
await mkdir(desktopOutput, { recursive: true });
await copyFile(fontInstallerPath, join(desktopOutput, fontInstallerName));
await copyFile(fontArchivePath, join(desktopOutput, fontArchiveName));
process.stdout.write(
`主安装器、字体包伴随安装器与 ZIP 附件已生成:${desktopOutput}\n`
);
+75
View File
@@ -0,0 +1,75 @@
import { readFile } from "node:fs/promises";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import {
buildFontPack,
verifyFontPack
} from "../packages/font-pack-builder/dist/index.js";
import { discoverFontPacks } from "../packages/font-pack-registry/dist/index.js";
const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const bundlePath = join(repositoryRoot, "font-packs", "bundle.json");
const bundle = JSON.parse(await readFile(bundlePath, "utf8"));
const outputRoot = join(repositoryRoot, "output", "font-packs", "root");
const appVersion = process.env.npm_package_version || "0.6.1";
if (
bundle.schemaVersion !== 1 ||
!Array.isArray(bundle.packs) ||
bundle.packs.length === 0
) {
throw new Error("字体包集合清单无效");
}
const identities = new Set();
const reports = [];
for (const pack of bundle.packs) {
const identity = `${pack.id}@${pack.version}`;
if (identities.has(identity)) {
throw new Error(`字体包集合存在重复项:${identity}`);
}
identities.add(identity);
const reportPath = join(
repositoryRoot,
"output",
"font-packs",
`${pack.id}-build-report.json`
);
const built = await buildFontPack({
recipePath: resolve(repositoryRoot, pack.recipe),
sourceRoot: resolve(repositoryRoot, pack.sourceRoot),
outputRoot,
reportPath
});
const verified = await verifyFontPack({
root: outputRoot,
appVersion,
packId: pack.id,
packVersion: pack.version
});
reports.push({ identity, built, verified });
}
const registry = await discoverFontPacks({
roots: [outputRoot],
appVersion
});
const errors = registry.diagnostics.filter((item) => item.severity === "error");
if (errors.length > 0) {
throw new Error(
`字体包集合注册失败:${errors.map((item) => `${item.code} ${item.message}`).join("")}`
);
}
const actualIdentities = registry.packs
.map((pack) => `${pack.id}@${pack.version}`)
.sort();
const expectedIdentities = [...identities].sort();
if (JSON.stringify(actualIdentities) !== JSON.stringify(expectedIdentities)) {
throw new Error(
`字体包输出根目录包含清单外内容:期望 ${expectedIdentities.join(", ")},实际 ${actualIdentities.join(", ")}`
);
}
process.stdout.write(
`${JSON.stringify({ appVersion, outputRoot, registryFingerprint: registry.fingerprint, reports }, null, 2)}\n`
);
+273
View File
@@ -0,0 +1,273 @@
import fs from "node:fs";
import path from "node:path";
export const DOCX_LAYOUT_MATRIX_ORIENTATIONS = Object.freeze([
"portrait",
"landscape"
]);
export const DOCX_LAYOUT_MATRIX_MARGIN_SCENARIOS = Object.freeze([
Object.freeze({
id: "theme-default",
label: "主题默认边距",
marginMode: "theme"
}),
Object.freeze({
id: "standard",
label: "标准边距",
marginMode: "custom",
margins: Object.freeze({
top: "25.4mm",
right: "25.4mm",
bottom: "25.4mm",
left: "25.4mm"
})
}),
Object.freeze({
id: "compact",
label: "紧凑边距",
marginMode: "custom",
margins: Object.freeze({
top: "12.7mm",
right: "12.7mm",
bottom: "12.7mm",
left: "12.7mm"
})
}),
Object.freeze({
id: "wide",
label: "宽松边距",
marginMode: "custom",
margins: Object.freeze({
top: "31.8mm",
right: "31.8mm",
bottom: "31.8mm",
left: "31.8mm"
})
}),
Object.freeze({
id: "binding",
label: "非对称装订边距",
marginMode: "custom",
margins: Object.freeze({
top: "25.4mm",
right: "19.1mm",
bottom: "25.4mm",
left: "31.8mm"
})
})
]);
function assert(condition, message) {
if (!condition) {
throw new Error(message);
}
}
function hasIndependentCover(manifest) {
const profiles = Array.isArray(manifest.compatibleProfiles)
? manifest.compatibleProfiles
: [];
return profiles.includes("project-report") || profiles.includes("tender");
}
export function readBundledThemeMatrixDefinitions(themesDirectory) {
assert(
typeof themesDirectory === "string" && themesDirectory.length > 0,
"主题目录不能为空"
);
return fs.readdirSync(themesDirectory, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.flatMap((entry) => {
const manifestPath = path.join(themesDirectory, entry.name, "theme.json");
if (!fs.existsSync(manifestPath)) {
return [];
}
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
if (manifest.bundled !== true) {
return [];
}
assert(
typeof manifest.id === "string" && manifest.id.length > 0,
`内置主题清单缺少 id${manifestPath}`
);
return [{
id: manifest.id,
name:
typeof manifest.name === "string" && manifest.name.length > 0
? manifest.name
: manifest.id,
coverPageCount: hasIndependentCover(manifest) ? 1 : 0,
compatibleProfiles: Array.isArray(manifest.compatibleProfiles)
? [...manifest.compatibleProfiles]
: [],
docxFontFaces: Array.isArray(manifest.docxFonts?.faces)
? manifest.docxFonts.faces.map((face) => ({
family: face.family,
aliases: Array.isArray(face.aliases) ? [...face.aliases] : [],
weight: face.weight ?? 400,
style: face.style ?? "normal"
}))
: [],
themeDefaultMargins: manifest.pageDefaults?.margins
? structuredClone(manifest.pageDefaults.margins)
: undefined
}];
})
.sort((left, right) => left.id.localeCompare(right.id, "en"));
}
function normalizeFontName(value) {
return value
.replace(/^[A-Z]{6}\+/u, "")
.replace(/(?:[-, ](?:regular|bold|italic|bolditalic|roman|medium|light|black))+$/iu, "")
.replace(/[\s_-]+/gu, "")
.toLocaleLowerCase("en-US");
}
function isOfficeEmbeddedAlias(value, engine) {
return engine === "word"
? /^___WRD_EMBED_SUB_/iu.test(value)
: engine === "wps"
? /^WPSEMBED\d+$/iu.test(value)
: false;
}
export function getDocxFontGateFailures({
theme,
inspection,
renderedFonts,
engine
}) {
assert(theme && typeof theme.id === "string", "字体门禁缺少主题定义");
assert(inspection && typeof inspection === "object", "字体门禁缺少 DOCX 检查结果");
assert(Array.isArray(renderedFonts), "字体门禁缺少实际渲染字体");
assert(engine === "word" || engine === "wps", "字体门禁引擎必须为 word 或 wps");
const failures = [];
const declaredFaces = Array.isArray(theme.docxFontFaces)
? theme.docxFontFaces
: [];
const embeddedNames = Array.isArray(inspection.embeddedFontNames)
? inspection.embeddedFontNames
: [];
const embeddedPartCount = Number(inspection.embeddedFontPartCount ?? 0);
if (declaredFaces.length === 0) {
failures.push(`主题 ${theme.id} 未声明任何 DOCX 字体`);
}
if (embeddedPartCount === 0) {
failures.push(`主题 ${theme.id} 的 DOCX 没有嵌入字体部件`);
}
if (declaredFaces.length > 0 && embeddedPartCount < declaredFaces.length) {
failures.push(
`主题 ${theme.id} 声明 ${declaredFaces.length} 个字体面,但 DOCX 仅嵌入 ${embeddedPartCount} 个部件`
);
}
if (embeddedPartCount > 0) {
const expected = new Set(embeddedNames.map(normalizeFontName));
const actuallyUsed = renderedFonts.some((font) => {
const name = typeof font === "string" ? font : font?.name;
return typeof name === "string" && (
isOfficeEmbeddedAlias(name, engine) || expected.has(normalizeFontName(name))
);
});
if (!actuallyUsed) {
failures.push(
`${engine === "word" ? "Microsoft Word" : "WPS Writer"} 输出未实际使用 DOCX 嵌入字体`
);
}
}
return failures;
}
function createPaperConfig(orientation, marginScenario) {
return {
format: "A4",
orientation,
marginMode: marginScenario.marginMode,
...(marginScenario.marginMode === "custom"
? { margins: structuredClone(marginScenario.margins) }
: {})
};
}
export function createDocxLayoutVisualMatrixCases(themeDefinitions) {
assert(Array.isArray(themeDefinitions), "主题定义必须是数组");
const ids = new Set();
const cases = [];
for (const theme of themeDefinitions) {
assert(
theme && typeof theme.id === "string" && theme.id.length > 0,
"矩阵主题必须包含非空 id"
);
assert(!ids.has(theme.id), `矩阵主题 ID 重复:${theme.id}`);
ids.add(theme.id);
for (const orientation of DOCX_LAYOUT_MATRIX_ORIENTATIONS) {
for (const marginScenario of DOCX_LAYOUT_MATRIX_MARGIN_SCENARIOS) {
const paper = createPaperConfig(orientation, marginScenario);
cases.push({
id: `${theme.id}--${orientation}--${marginScenario.id}`,
themeId: theme.id,
themeName: theme.name,
orientation,
marginScenarioId: marginScenario.id,
marginScenarioLabel: marginScenario.label,
coverPageCount: theme.coverPageCount,
themeDefaultMargins: theme.themeDefaultMargins
? structuredClone(theme.themeDefaultMargins)
: undefined,
paper: { format: "A4", orientation },
config: { paper }
});
}
}
}
return cases;
}
export function summarizeDocxLayoutVisualMatrix(themeDefinitions, cases) {
const themeIds = themeDefinitions.map((theme) => theme.id);
const caseIds = cases.map((entry) => entry.id);
const expectedCaseCount =
themeIds.length *
DOCX_LAYOUT_MATRIX_ORIENTATIONS.length *
DOCX_LAYOUT_MATRIX_MARGIN_SCENARIOS.length;
assert(
new Set(caseIds).size === caseIds.length,
"DOCX 布局视觉矩阵存在重复场景 ID"
);
assert(
cases.length === expectedCaseCount,
`DOCX 布局视觉矩阵场景数错误:${cases.length},期望 ${expectedCaseCount}`
);
for (const themeId of themeIds) {
const themeCaseCount = cases.filter(
(entry) => entry.themeId === themeId
).length;
assert(
themeCaseCount ===
DOCX_LAYOUT_MATRIX_ORIENTATIONS.length *
DOCX_LAYOUT_MATRIX_MARGIN_SCENARIOS.length,
`主题 ${themeId} 的矩阵场景数错误:${themeCaseCount}`
);
}
return {
themeCount: themeIds.length,
themeIds,
orientationCount: DOCX_LAYOUT_MATRIX_ORIENTATIONS.length,
orientations: [...DOCX_LAYOUT_MATRIX_ORIENTATIONS],
marginScenarioCount: DOCX_LAYOUT_MATRIX_MARGIN_SCENARIOS.length,
marginScenarios: DOCX_LAYOUT_MATRIX_MARGIN_SCENARIOS.map((scenario) => ({
id: scenario.id,
label: scenario.label,
marginMode: scenario.marginMode,
...(scenario.margins
? { margins: structuredClone(scenario.margins) }
: {})
})),
expectedCaseCount,
coverThemeCount: themeDefinitions.filter(
(theme) => theme.coverPageCount === 1
).length,
coverCaseCount: cases.filter((entry) => entry.coverPageCount === 1).length
};
}

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