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 产物仍为未签名内部发行。
This commit is contained in:
SkyJourney
2026-08-04 10:30:44 +08:00
parent b275c671fc
commit 2c5c1bd317
84 changed files with 4404 additions and 344 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
+2 -2
View File
@@ -57,8 +57,8 @@ 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 首次运行可能显示“未知发布者”
提示。
+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();
}
}
}
}
@@ -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;
+1 -1
View File
@@ -156,7 +156,7 @@ describe("桌面发行构建链", () => {
artifactPrefix: "MorphDoc"
});
expect(desktopManifest).toMatchObject({
version: "0.6.0",
version: "0.6.1",
productName: "MorphDoc"
});
expect(builderConfig).toContain("appId: brand.appId");
+84 -2
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"],
@@ -226,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),
@@ -247,6 +310,10 @@ function inspectDocx(content) {
officialIssueRowRightTabCount:
officialIssueRowsWithRightTab.length,
officialIssueRowContentTabCount,
inlineCodeContinuity,
inlineCodeContinuityPassed: inlineCodeContinuity.every(
(probe) => probe.passed
),
requiredStylesPresent: [
"Normal",
"Heading1",
@@ -366,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);
@@ -390,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 生产链验收`,
@@ -449,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(
@@ -504,6 +585,7 @@ try {
docx: {
outputFile: path.relative(repositoryDirectory, docxPath),
inspection,
documentLayout: capturedDocxDocumentLayout ?? { tables: [] },
diagnostics: responseDiagnostics(docx.response, "docx")
},
standardInspection: standard.inspection,
+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", () => {
+2 -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
@@ -167,6 +167,7 @@ ENV HOST=127.0.0.1 \
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"
+22 -7
View File
@@ -17,7 +17,8 @@ const fontPackRoot = path.resolve(
);
const expectedFontPacks = [
{ id: "mdtp-serif-sc", version: "1.0.0" },
{ id: "mdtp-sans-sc", version: "1.0.0" }
{ id: "mdtp-sans-sc", version: "1.0.0" },
{ id: "mdtp-mono", version: "1.0.0" }
];
function assert(condition, message) {
@@ -101,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: [],
@@ -174,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",
@@ -214,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];
@@ -241,6 +245,17 @@ 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(
+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
```
+23 -15
View File
@@ -1,6 +1,6 @@
# 墨呈进度
最后更新:2026-08-03
最后更新:2026-08-04
## 1. 当前概况
@@ -76,6 +76,14 @@ failure 还被外层白名单过滤,导致报告失败时总门禁仍可通过
无关的语义块局部视觉门禁,并对字体真实解析、嵌入和发布后三条生产链路
建立阻断验收。完整范围见 [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 安装包预计增加约
@@ -1502,21 +1510,21 @@ ECharts 第二阶段浏览器与 PDF 验证结果:
总计 76 个阻断问题;`formal-feasibility--landscape--binding` 验证 A4
横向、非对称装订边距和独立封面整页 strict 门禁,封面因墨迹与边缘
IoU 不达标被阻断;全仓库测试、类型检查、生产构建和差异检查通过;
- 阶段 D进行中。D1/D2 将字体门禁扩展到 PDF 实际字体解析、DOCX
- 阶段 D已完成。D1/D2 将字体门禁扩展到 PDF 实际字体解析、DOCX
声明与嵌入数量,以及 Word/WPS 是否真正采用 Office 嵌入别名;新增
`mdtp-sans-sc@1.0.0`,与 `mdtp-serif-sc@1.0.0` 组成确定性双字体包,
14 套主题的 Server HTTP 生产链和 Office 实际字体使用门禁均已通过。
D3 已重建独立封面视觉门禁:预览与 DOCX 共用受限高度封面间距模型,
DOCX 支持可编辑双边框、超宽装饰边、非居中装饰元素宽度及 Office 页面
舍入;整页差分使用 4px 空间容差、0.6pt 纸张尺寸容差,墨迹 IoU、边缘
IoU、MAE、变化像素率阈值未放宽。四套独立封面主题 × 纵横两个方向 ×
主题默认、标准、紧凑、宽松、非对称装订五组边距共 40 个真实 Chromium、
Word、WPS 用例全部通过,严格失败为 0;主题各 10 例、方向各 20 例、
边距各 8 例。报告仍保留 2963 条正文诊断,未计作 D3 通过项。全仓库
测试、类型检查、生产构建和差异检查通过;下一步 D4 修复 14 套主题
正文语义块视觉差异;
- 阶段 E:完成源码服务、Docker Web API、Desktop 安装版发布后复验
- 阶段 F:完成 `v0.6.1` 版本、发行说明、发布产物、发布提交和标签
`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:进行中。版本已更新为 `v0.6.1`,发行说明、Desktop 安装器与
ZIP、Docker 镜像均已生成;待完成最终全量验证、离线镜像归档复验
发布提交和 annotated tag
每个阶段验收通过后创建一个独立提交,再进入下一阶段。当前阶段不得混入
后续阶段的功能实现。
+21
View File
@@ -146,3 +146,24 @@ Word PDF 和 WPS PDF,并在报告中保留主题、方向、边距场景、导
- 单元测试、类型检查、生产构建和 `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)。
+49
View File
@@ -0,0 +1,49 @@
# 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,独立封面及
正文逐页复核通过。
- Docker 镜像内置三套字体包与 Pandoc 3.9.0.2;修复复用运行层时 Pandoc
软链接已存在导致构建失败的问题,并通过根级 `.dockerignore` 固定精简
构建上下文。
- 全项目测试、类型检查、生产构建及 `git diff --check` 通过。
## 发行与兼容性
- Desktop 安装器和免安装 ZIP 版本均为 0.6.1;支持覆盖 v0.6.0 安装。
- Docker 发布镜像为 `md-to-pdf:v0.6.1`,内容 ID 为
`sha256:1f901fd481ca078d62d22f975691373a160c716e3bfed05721d0704ae5098696`
- DOCX 保持原生可编辑段落、Run、列表和表格结构,没有以整页图片替代正文。
- 当前 Windows 安装器未进行 Authenticode 签名,适用于公司内部发布;
公开网络分发前建议增加代码签名。
+11
View File
@@ -22,6 +22,12 @@ font-packs/assets/mdtp-sans-sc/1.0.0/
├── 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。
@@ -62,6 +68,11 @@ root/<pack-id>/<semver>/font-pack.json
`Source Han Sans SC` 的 400/700 字重映射。主题仍需明确声明所需字体面,
注册器不会对未声明字体进行静默替换。
`mdtp-mono@1.0.0` 提供内部字族 `MdTP Mono`,并覆盖主题代码样式常用的
Cascadia、Consolas、SFMono、Menlo、Monaco 与 Courier 等候选名称。
应用层将它作为代码语义槽位的引擎级可选能力统一注入,因此新主题无需
增加主题 ID 分支;字体包缺失时仍保留主题原有候选链并输出缺失诊断。
## Desktop 内置发行
组合构建命令为:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+6
View File
@@ -12,6 +12,12 @@
"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"
}
}
]
}
+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": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "md-to-pdf",
"version": "0.6.0",
"version": "0.6.1",
"private": true,
"license": "UNLICENSED",
"description": "面向结构化 Markdown 的主题化文档创作与发布工具",
+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([
+23 -13
View File
@@ -22,8 +22,12 @@ export const DEFAULT_VISUAL_DIFF_THRESHOLDS: VisualDiffThresholds = {
spatialTolerancePx: 4,
maxMeanAbsoluteError: 8,
maxChangedPixelRatio: 0.06,
minInkIou: 0.64,
minEdgeIou: 0.6,
minInkIou: 0.64,
minEdgeIou: 0.6,
maxBackgroundColorDelta: 12,
maxForegroundColorDelta: 12,
minAntialiasEquivalentInkIou: 0.98,
minAntialiasEquivalentEdgeIou: 0.98,
};
function cappedLevenshteinDistance(
@@ -259,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;
@@ -270,7 +289,7 @@ export function comparePdfSnapshotsBasic(
)
: undefined;
const candidateEditableExact = hasEditableExpectation
? candidateEditableText === expectedEditableText
? candidateEditableText === expectedEditableText || paragraphContentExact
: undefined;
const contentSimilarity = hasEditableExpectation
? Math.min(
@@ -302,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));
}
@@ -376,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 &&
@@ -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,21 +279,27 @@ function observeParagraph(
Array.from(lines[index]?.normalizedText ?? "").length ===
(matchedByLine.get(index)?.length ?? 0),
);
const visualLines = matchedLines.map((line) => ({
pageNumber: line.pageNumber,
bounds: line.line.bounds,
fontFamilies: [
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(
line.line.items.flatMap((item) =>
matched.items.flatMap((item) =>
item.fontFamily ? [item.fontFamily] : [],
),
),
].sort(),
}));
const matched = matchedCharacterCount === expectedCharacters.length;
};
});
if (matched) {
cursor.lineIndex = localCursor.lineIndex;
cursor.characterIndex = localCursor.characterIndex;
cursor.lineIndex = matchedCursor.lineIndex;
cursor.characterIndex = matchedCursor.characterIndex;
}
return {
matched,
@@ -139,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
@@ -176,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,
@@ -271,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(","),
},
+183 -8
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,
@@ -292,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;
@@ -323,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,
@@ -371,6 +544,8 @@ export async function comparePageRasters(
height,
spatialTolerancePx,
),
backgroundColorDelta,
foregroundColorDelta,
};
return {
metrics,
+1 -1
View File
@@ -460,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">
@@ -128,14 +128,33 @@ async function padRaster(
function localRasterIssues(
paragraphIndex: number,
lineIndex: number,
blockKind: string | undefined,
metrics: NonNullable<PdfSemanticBlockVisualLineComparison["metrics"]>,
thresholds: VisualDiffThresholds,
textReflow: boolean,
): VisualDiffIssue[] {
const failed =
metrics.meanAbsoluteError > thresholds.maxMeanAbsoluteError ||
metrics.changedPixelRatio > thresholds.maxChangedPixelRatio ||
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 [];
}
@@ -151,11 +170,86 @@ function localRasterIssues(
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,
@@ -172,6 +266,11 @@ export async function comparePdfSemanticBlockVisuals(
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,
@@ -228,25 +327,44 @@ export async function comparePdfSemanticBlockVisuals(
cropRaster(baselinePage.raster, baselineLine.bounds),
cropRaster(candidatePage.raster, candidateLine.bounds),
]);
const widthPx = Math.max(baselineCrop.widthPx, candidateCrop.widthPx);
const heightPx = Math.max(
baselineCrop.heightPx,
candidateCrop.heightPx,
);
const [baselinePadded, candidatePadded] = await Promise.all([
padRaster(baselineCrop, widthPx, heightPx),
padRaster(candidateCrop, widthPx, heightPx),
]);
const result = await comparePageRasters(
baselinePadded,
candidatePadded,
thresholds.pixelDifferenceThreshold,
);
const 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({
@@ -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,
@@ -56,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";
}
@@ -180,6 +193,10 @@ export interface VisualDiffThresholds {
maxChangedPixelRatio: number;
minInkIou: number;
minEdgeIou: number;
maxBackgroundColorDelta: number;
maxForegroundColorDelta: number;
minAntialiasEquivalentInkIou: number;
minAntialiasEquivalentEdgeIou: number;
}
export type VisualDiffIssueSeverity = "warning" | "failure";
@@ -271,6 +288,8 @@ export interface PdfRasterDiffMetrics {
changedPixelRatio: number;
inkIou: number;
edgeIou: number;
backgroundColorDelta: number;
foregroundColorDelta: number;
}
export interface ComparePageRasterOptions {
@@ -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", [
@@ -53,12 +53,41 @@ describe("PDF 栅格差异", () => {
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), {
spatialTolerancePx: 0,
@@ -65,6 +65,25 @@ function blockRaster(color: string): PdfPageRaster {
};
}
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,
@@ -422,6 +441,62 @@ describe("PDF 视觉差异报告", () => {
);
});
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",
@@ -563,6 +638,32 @@ describe("PDF 视觉差异报告", () => {
);
});
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")),
@@ -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);
});
});
@@ -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(),
@@ -574,7 +781,7 @@ describe("生成 DOCX 结构收口", () => {
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(
@@ -612,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();
});
});
+72 -30
View File
@@ -442,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)
@@ -652,7 +650,8 @@ function applyBorderColor(
function applyOverrides(
slot: DocxStyleSlotName,
styleInput: DocxSlotStyleToken,
overrides: DocxThemeStyleOverrides
overrides: DocxThemeStyleOverrides,
replaceExisting: boolean
): {
style: DocxSlotStyleToken;
applied: boolean;
@@ -663,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;
}
@@ -887,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({
@@ -907,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",
@@ -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({
@@ -356,7 +399,7 @@ describe("DOCX 主题令牌归一化", () => {
).toBe(true);
});
it("以清单覆盖自动值并在未命中时显式降级", () => {
it("旧版清单仅补齐自动值并在未命中时显式降级", () => {
const tokens = resolveDocxThemeTokens({
snapshot: createSnapshot({ caption: null }),
config: {
@@ -376,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({
@@ -401,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,6 +929,7 @@ export class PagedDocumentRuntime {
}
const finalizeStartedAt = performance.now();
removeInheritedPagedSplitJustification(this.root);
const pageSequence = classifyPagedPages(this.root, payload);
pageCount = pageSequence.physicalPageCount;
applyPagedPageDecorations(this.root, payload, pageSequence);
@@ -954,7 +987,8 @@ export class PagedDocumentRuntime {
this.applyContinuousStyles(
documentRef,
payload,
options.geometryCss
options.geometryCss,
options.themeMedia
);
const template = documentRef.createElement("template");
@@ -969,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 -1
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;
@@ -548,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,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");
@@ -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
);
+1 -1
View File
@@ -11,7 +11,7 @@ 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.0";
const appVersion = process.env.npm_package_version || "0.6.1";
if (
bundle.schemaVersion !== 1 ||
+2 -2
View File
@@ -157,9 +157,9 @@ export function getDocxFontGateFailures({
if (embeddedPartCount === 0) {
failures.push(`主题 ${theme.id} 的 DOCX 没有嵌入字体部件`);
}
if (declaredFaces.length > 0 && embeddedPartCount !== declaredFaces.length) {
if (declaredFaces.length > 0 && embeddedPartCount < declaredFaces.length) {
failures.push(
`主题 ${theme.id} 声明 ${declaredFaces.length} 个字体面,但 DOCX 实际嵌入 ${embeddedPartCount} 个部件`
`主题 ${theme.id} 声明 ${declaredFaces.length} 个字体面,但 DOCX 嵌入 ${embeddedPartCount} 个部件`
);
}
if (embeddedPartCount > 0) {
@@ -65,6 +65,20 @@ test("字体门禁区分缺少声明、缺少嵌入和 Office 未实际采用",
engine: "word"
});
assert.deepEqual(embedded, []);
const embeddedWithEngineFaces = getDocxFontGateFailures({
theme: {
id: "engine-faces",
docxFontFaces: [{ family: "FandolSong", aliases: [], weight: 400, style: "normal" }]
},
inspection: {
embeddedFontPartCount: 3,
embeddedFontNames: ["MdTP Serif SC", "MdTP Mono"]
},
renderedFonts: [{ name: "WPSEMBED1" }],
engine: "wps"
});
assert.deepEqual(embeddedWithEngineFaces, []);
});
test("14 套内置主题都声明字体且中文字体面不超过三个", () => {
+29
View File
@@ -186,6 +186,28 @@ export async function stageRelease(options = {}) {
`MorphDoc-${version}-x86_64-Setup.exe`,
`MorphDoc-${version}-x86_64.zip`
];
const fontPackArtifacts = [
{
name: "MorphDoc-FontPack-mdtp-serif-sc-1.0.0-x86_64-Setup.exe",
source: join(
repositoryRoot,
"output",
"font-packs",
"windows",
"MorphDoc-FontPack-mdtp-serif-sc-1.0.0-x86_64-Setup.exe"
)
},
{
name: "MorphDoc-FontPack-mdtp-serif-sc-1.0.0.zip",
source: join(
repositoryRoot,
"output",
"font-packs",
"release",
"MorphDoc-FontPack-mdtp-serif-sc-1.0.0.zip"
)
}
];
const releaseNotesSource = join(
repositoryRoot,
"docs",
@@ -195,6 +217,9 @@ export async function stageRelease(options = {}) {
for (const name of desktopNames) {
await assertReadableFile(join(desktopDirectory, name));
}
for (const artifact of fontPackArtifacts) {
await assertReadableFile(artifact.source);
}
await assertReadableFile(releaseNotesSource);
await mkdir(stageDirectory, { recursive: true });
@@ -202,6 +227,9 @@ export async function stageRelease(options = {}) {
for (const name of desktopNames) {
await copyFile(join(desktopDirectory, name), join(stageDirectory, name));
}
for (const artifact of fontPackArtifacts) {
await copyFile(artifact.source, join(stageDirectory, artifact.name));
}
await copyFile(releaseNotesSource, join(stageDirectory, "RELEASE-NOTES.md"));
const deployName = `MorphDoc-Web-${version}-deploy.zip`;
@@ -221,6 +249,7 @@ export async function stageRelease(options = {}) {
const primaryNames = [
...desktopNames,
...fontPackArtifacts.map((artifact) => artifact.name),
imageName,
deployName,
"RELEASE-NOTES.md"
+17
View File
@@ -11,6 +11,11 @@ import {
parseArguments
} from "./stage-release.mjs";
const stageReleaseSource = await readFile(
new URL("./stage-release.mjs", import.meta.url),
"utf8"
);
test("normalizeVersion 接受语义版本并移除 v 前缀", () => {
assert.equal(normalizeVersion("v0.6.0"), "0.6.0");
assert.equal(normalizeVersion("1.2.3-beta.1"), "1.2.3-beta.1");
@@ -55,3 +60,15 @@ test("Web 离线部署 ZIP 包含 Compose 和导入说明", async () => {
await rm(directory, { recursive: true, force: true });
}
});
test("正式发布强制归集独立字体安装器和 ZIP", () => {
assert.match(
stageReleaseSource,
/MorphDoc-FontPack-mdtp-serif-sc-1\.0\.0-x86_64-Setup\.exe/u
);
assert.match(
stageReleaseSource,
/MorphDoc-FontPack-mdtp-serif-sc-1\.0\.0\.zip/u
);
assert.match(stageReleaseSource, /fontPackArtifacts\.map/u);
});
+150 -1
View File
@@ -60,11 +60,21 @@ const selectedCaseId =
process.env.MD_TO_PDF_LAYOUT_VISUAL_CASE?.trim() || undefined;
const selectedScope =
process.env.MD_TO_PDF_LAYOUT_VISUAL_SCOPE?.trim() || "full";
const selectedCases = selectedCaseId
const selectedOrientation =
process.env.MD_TO_PDF_LAYOUT_VISUAL_ORIENTATION?.trim() || undefined;
const selectedMarginScenarioId =
process.env.MD_TO_PDF_LAYOUT_VISUAL_MARGIN?.trim() || undefined;
const scopedCases = selectedCaseId
? cases.filter((entry) => entry.id === selectedCaseId)
: selectedScope === "cover"
? cases.filter((entry) => entry.coverPageCount > 0)
: cases;
const selectedCases = scopedCases.filter(
(entry) =>
(!selectedOrientation || entry.orientation === selectedOrientation) &&
(!selectedMarginScenarioId ||
entry.marginScenarioId === selectedMarginScenarioId)
);
function assert(condition, message) {
if (!condition) {
@@ -121,6 +131,60 @@ function isInternalLayoutSpacerParagraph(descendants, text) {
);
}
function hasAncestor(paragraph, localName) {
let current = paragraph.parentNode;
while (current) {
if (
current.localName === localName &&
current.namespaceURI === WORD_NAMESPACE
) {
return true;
}
current = current.parentNode;
}
return false;
}
function isTableHeaderParagraph(paragraph) {
let current = paragraph.parentNode;
while (current) {
if (current.localName === "tr" && current.namespaceURI === WORD_NAMESPACE) {
return Array.from(current.getElementsByTagName("*")).some(
(node) =>
node.localName === "tblHeader" &&
node.namespaceURI === WORD_NAMESPACE
);
}
current = current.parentNode;
}
return false;
}
function paragraphBlockKind(paragraph, styleId) {
if (hasAncestor(paragraph, "tc")) {
return isTableHeaderParagraph(paragraph) ? "table-header" : "table-cell";
}
if (styleId === "SourceCode") {
return "code-block";
}
if (styleId === "BlockText") {
return "block-quote";
}
if (/(?:Caption)$/u.test(styleId)) {
return "caption";
}
if (/^Heading[1-6]$/u.test(styleId)) {
return "heading";
}
if (styleId === "Compact" || styleId === "ListParagraph") {
return "list-item";
}
if (/^Md(?:Official|Briefing|ProjectReport|Tender)/u.test(styleId)) {
return "semantic-region";
}
return "paragraph";
}
function extractEditableContract(docxPath) {
const entries = unzipSync(Uint8Array.from(fs.readFileSync(docxPath)));
const documentXml = entries["word/document.xml"];
@@ -161,6 +225,7 @@ function extractEditableContract(docxPath) {
text,
...(styleId ? { styleId } : {}),
role: paragraphRole(styleId),
blockKind: paragraphBlockKind(paragraph, styleId),
section
});
}
@@ -217,6 +282,62 @@ function pageGeometryFailures(snapshot, caseDefinition, label) {
});
}
const inlineCodeContinuityProbes = [
{
id: "paragraph",
code: "mdtp_ic_p",
continuousText: "段落前mdtp_ic_p段落后"
},
{
id: "list-item",
code: "mdtp_ic_l",
continuousText: "列表前mdtp_ic_l列表后"
},
{
id: "table-cell",
code: "mdtp_ic_c",
continuousText: "单元格前mdtp_ic_c单元格后"
}
];
function inspectInlineCodeContinuity(snapshot, label) {
const normalizedLines = snapshot.pages.flatMap((page) =>
page.lines.map((line) => ({
pageNumber: page.pageNumber,
text: line.text,
normalizedText: line.text.normalize("NFKC").replace(/\s+/gu, "")
}))
);
const probes = inlineCodeContinuityProbes.map((probe) => {
const matchingLines = normalizedLines.filter((line) =>
line.normalizedText.includes(probe.code)
);
const passed =
matchingLines.length === 1 &&
matchingLines[0].normalizedText.includes(probe.continuousText);
return {
...probe,
passed,
matchingLines: matchingLines.map(({ pageNumber, text }) => ({
pageNumber,
text
}))
};
});
return {
label,
passed: probes.every((probe) => probe.passed),
probes,
failures: probes.flatMap((probe) =>
probe.passed
? []
: [
`${label}: INLINE_CODE_CONTINUITY_MISMATCH - ${probe.id} 行内代码未与前后文本保持同一视觉行`
]
)
};
}
function buildPageSemantics(pageCount, coverPageCount, config) {
assert(
pageCount > coverPageCount,
@@ -481,6 +602,17 @@ assert(
selectedScope === "full" || selectedScope === "cover",
`不支持的视觉矩阵作用域:${selectedScope}`
);
assert(
!selectedOrientation || ["portrait", "landscape"].includes(selectedOrientation),
`不支持的视觉矩阵方向:${selectedOrientation}`
);
assert(
!selectedMarginScenarioId ||
matrixDefinition.marginScenarios.some(
(entry) => entry.id === selectedMarginScenarioId
),
`不支持的视觉矩阵边距场景:${selectedMarginScenarioId}`
);
const wordAdapter = createWordPdfAdapter({ timeoutMs: 240_000 });
const wpsAdapter = createWpsPdfAdapter({ timeoutMs: 240_000 });
const wordCapability = await wordAdapter.probe();
@@ -578,6 +710,11 @@ for (const caseDefinition of selectedCases) {
baselinePageSemantics: wordPageSemantics,
candidatePageSemantics: wpsPageSemantics
});
const inlineCodeContinuity = {
chromium: inspectInlineCodeContinuity(chromium, "Chromium"),
word: inspectInlineCodeContinuity(word, "Microsoft Word"),
wps: inspectInlineCodeContinuity(wps, "WPS Writer")
};
const rasterDirectory = path.join(caseDirectory, "raster-pages");
fs.rmSync(rasterDirectory, { recursive: true, force: true });
@@ -605,6 +742,9 @@ for (const caseDefinition of selectedCases) {
renderedFonts: wps.fonts,
engine: "wps"
}),
...inlineCodeContinuity.chromium.failures,
...inlineCodeContinuity.word.failures,
...inlineCodeContinuity.wps.failures,
...reportGateFailures(wordReport, "Chromium/Word"),
...reportGateFailures(wpsReport, "Chromium/WPS"),
...reportGateFailures(officeReport, "Word/WPS")
@@ -647,6 +787,7 @@ for (const caseDefinition of selectedCases) {
wps: wps.fonts
},
editableParagraphCount: editable.paragraphs.length,
inlineCodeContinuity,
reports: {
word: reportSummary(wordReport),
wps: reportSummary(wpsReport),
@@ -670,9 +811,13 @@ const summary = {
matrixDefinition,
selectedCaseId,
selectedScope,
selectedOrientation,
selectedMarginScenarioId,
execution: {
mode: selectedCaseId
? "single-case"
: selectedOrientation || selectedMarginScenarioId
? "filtered-matrix"
: selectedScope === "cover"
? "cover-matrix"
: "full-matrix",
@@ -693,6 +838,8 @@ const summaryPath = path.join(
outputDirectory,
selectedCaseId
? `summary-${selectedCaseId}.json`
: selectedOrientation || selectedMarginScenarioId
? `summary-${selectedOrientation ?? "all"}-${selectedMarginScenarioId ?? "all"}.json`
: selectedScope === "cover"
? "summary-cover.json"
: "summary.json"
@@ -701,6 +848,8 @@ const matrixHtmlPath = path.join(
outputDirectory,
selectedCaseId
? `matrix-${selectedCaseId}.html`
: selectedOrientation || selectedMarginScenarioId
? `matrix-${selectedOrientation ?? "all"}-${selectedMarginScenarioId ?? "all"}.html`
: selectedScope === "cover"
? "matrix-cover.html"
: "matrix.html"