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:
@@ -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
|
||||||
@@ -57,8 +57,8 @@ npm run verify:pandoc-runtime -w @md-to-pdf/desktop
|
|||||||
```
|
```
|
||||||
|
|
||||||
版本化目录包、NSIS `Setup.exe` 和 ZIP 输出到
|
版本化目录包、NSIS `Setup.exe` 和 ZIP 输出到
|
||||||
`apps/desktop/out/v0.6.0/`。该目录被 Git 忽略。公司内部分发以
|
`apps/desktop/out/v0.6.1/`。该目录被 Git 忽略。公司内部分发以
|
||||||
`MorphDoc-0.6.0-x86_64-Setup.exe` 为正式安装包,ZIP 作为免安装
|
`MorphDoc-0.6.1-x86_64-Setup.exe` 为正式安装包,ZIP 作为免安装
|
||||||
辅助包;当前未配置代码签名,Windows 首次运行可能显示“未知发布者”
|
辅助包;当前未配置代码签名,Windows 首次运行可能显示“未知发布者”
|
||||||
提示。
|
提示。
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@md-to-pdf/desktop",
|
"name": "@md-to-pdf/desktop",
|
||||||
"version": "0.6.0",
|
"version": "0.6.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"productName": "MorphDoc",
|
"productName": "MorphDoc",
|
||||||
"description": "墨呈桌面端:面向结构化 Markdown 的主题化文档创作与发布工具",
|
"description": "墨呈桌面端:面向结构化 Markdown 的主题化文档创作与发布工具",
|
||||||
|
|||||||
@@ -73,6 +73,15 @@ export async function captureDocxMediaWithElectronWebContents(
|
|||||||
timeouts: ElectronDocxMediaCaptureTimeouts = DEFAULT_CAPTURE_TIMEOUTS
|
timeouts: ElectronDocxMediaCaptureTimeouts = DEFAULT_CAPTURE_TIMEOUTS
|
||||||
) {
|
) {
|
||||||
signal?.throwIfAborted();
|
signal?.throwIfAborted();
|
||||||
|
const ownsDebugger = !webContents.debugger.isAttached();
|
||||||
|
if (ownsDebugger) {
|
||||||
|
webContents.debugger.attach("1.3");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await webContents.debugger.sendCommand("Emulation.setEmulatedMedia", {
|
||||||
|
media: "print"
|
||||||
|
});
|
||||||
const plan = (await withTimeout(
|
const plan = (await withTimeout(
|
||||||
webContents.executeJavaScript(createRenderScript(request), true),
|
webContents.executeJavaScript(createRenderScript(request), true),
|
||||||
timeouts.renderMs,
|
timeouts.renderMs,
|
||||||
@@ -80,12 +89,6 @@ export async function captureDocxMediaWithElectronWebContents(
|
|||||||
signal
|
signal
|
||||||
)) as DocxMediaCapturePlan;
|
)) as DocxMediaCapturePlan;
|
||||||
signal?.throwIfAborted();
|
signal?.throwIfAborted();
|
||||||
const ownsDebugger = !webContents.debugger.isAttached();
|
|
||||||
if (ownsDebugger) {
|
|
||||||
webContents.debugger.attach("1.3");
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const captures = [];
|
const captures = [];
|
||||||
for (const target of plan.targets) {
|
for (const target of plan.targets) {
|
||||||
signal?.throwIfAborted();
|
signal?.throwIfAborted();
|
||||||
@@ -117,10 +120,15 @@ export async function captureDocxMediaWithElectronWebContents(
|
|||||||
signal?.throwIfAborted();
|
signal?.throwIfAborted();
|
||||||
return { plan, captures };
|
return { plan, captures };
|
||||||
} finally {
|
} finally {
|
||||||
|
if (webContents.debugger.isAttached()) {
|
||||||
|
await webContents.debugger
|
||||||
|
.sendCommand("Emulation.setEmulatedMedia", { media: "screen" })
|
||||||
|
.catch(() => undefined);
|
||||||
if (ownsDebugger && webContents.debugger.isAttached()) {
|
if (ownsDebugger && webContents.debugger.isAttached()) {
|
||||||
webContents.debugger.detach();
|
webContents.debugger.detach();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ElectronDocxMediaCaptureAdapter
|
export class ElectronDocxMediaCaptureAdapter
|
||||||
|
|||||||
@@ -95,6 +95,14 @@ describe("Electron DOCX 媒体捕获", () => {
|
|||||||
png: Buffer.from("png")
|
png: Buffer.from("png")
|
||||||
});
|
});
|
||||||
expect(attach).toHaveBeenCalledWith("1.3");
|
expect(attach).toHaveBeenCalledWith("1.3");
|
||||||
|
expect(sendCommand).toHaveBeenCalledWith(
|
||||||
|
"Emulation.setEmulatedMedia",
|
||||||
|
{ media: "print" }
|
||||||
|
);
|
||||||
|
expect(sendCommand).toHaveBeenCalledWith(
|
||||||
|
"Emulation.setEmulatedMedia",
|
||||||
|
{ media: "screen" }
|
||||||
|
);
|
||||||
expect(sendCommand).toHaveBeenCalledWith(
|
expect(sendCommand).toHaveBeenCalledWith(
|
||||||
"Page.captureScreenshot",
|
"Page.captureScreenshot",
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
@@ -106,10 +114,18 @@ describe("Electron DOCX 媒体捕获", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("媒体布局永久不返回时报告局部超时", async () => {
|
it("媒体布局永久不返回时报告局部超时", async () => {
|
||||||
|
let attached = false;
|
||||||
const webContents = {
|
const webContents = {
|
||||||
executeJavaScript: vi.fn(() => new Promise(() => undefined)),
|
executeJavaScript: vi.fn(() => new Promise(() => undefined)),
|
||||||
debugger: {
|
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;
|
} as unknown as WebContents;
|
||||||
|
|
||||||
@@ -136,7 +152,11 @@ describe("Electron DOCX 媒体捕获", () => {
|
|||||||
attached = true;
|
attached = true;
|
||||||
}),
|
}),
|
||||||
detach,
|
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;
|
} as unknown as WebContents;
|
||||||
|
|
||||||
|
|||||||
@@ -156,7 +156,7 @@ describe("桌面发行构建链", () => {
|
|||||||
artifactPrefix: "MorphDoc"
|
artifactPrefix: "MorphDoc"
|
||||||
});
|
});
|
||||||
expect(desktopManifest).toMatchObject({
|
expect(desktopManifest).toMatchObject({
|
||||||
version: "0.6.0",
|
version: "0.6.1",
|
||||||
productName: "MorphDoc"
|
productName: "MorphDoc"
|
||||||
});
|
});
|
||||||
expect(builderConfig).toContain("appId: brand.appId");
|
expect(builderConfig).toContain("appId: brand.appId");
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ import { createPdfGenerator } from "../dist/pdf-engine.js";
|
|||||||
|
|
||||||
const directory = path.dirname(fileURLToPath(import.meta.url));
|
const directory = path.dirname(fileURLToPath(import.meta.url));
|
||||||
const repositoryDirectory = path.resolve(directory, "../../..");
|
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 themesDirectory = path.join(repositoryDirectory, "themes");
|
||||||
const samplesDirectory = path.join(repositoryDirectory, "samples", "themes");
|
const samplesDirectory = path.join(repositoryDirectory, "samples", "themes");
|
||||||
const webDirectory = path.join(repositoryDirectory, "apps", "web", "dist");
|
const webDirectory = path.join(repositoryDirectory, "apps", "web", "dist");
|
||||||
@@ -32,6 +35,37 @@ const fontPackRoot = path.resolve(
|
|||||||
);
|
);
|
||||||
const decoder = new TextDecoder();
|
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([
|
const contentTypes = new Map([
|
||||||
[".css", "text/css; charset=utf-8"],
|
[".css", "text/css; charset=utf-8"],
|
||||||
[".html", "text/html; 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),
|
count + (paragraph.match(/<w:tab\s*\/>/gu)?.length ?? 0),
|
||||||
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 {
|
return {
|
||||||
bytes: content.byteLength,
|
bytes: content.byteLength,
|
||||||
sha256: sha256(content),
|
sha256: sha256(content),
|
||||||
@@ -247,6 +310,10 @@ function inspectDocx(content) {
|
|||||||
officialIssueRowRightTabCount:
|
officialIssueRowRightTabCount:
|
||||||
officialIssueRowsWithRightTab.length,
|
officialIssueRowsWithRightTab.length,
|
||||||
officialIssueRowContentTabCount,
|
officialIssueRowContentTabCount,
|
||||||
|
inlineCodeContinuity,
|
||||||
|
inlineCodeContinuityPassed: inlineCodeContinuity.every(
|
||||||
|
(probe) => probe.passed
|
||||||
|
),
|
||||||
requiredStylesPresent: [
|
requiredStylesPresent: [
|
||||||
"Normal",
|
"Normal",
|
||||||
"Heading1",
|
"Heading1",
|
||||||
@@ -366,13 +433,22 @@ const port = await reservePort();
|
|||||||
const origin = `http://127.0.0.1:${port}`;
|
const origin = `http://127.0.0.1:${port}`;
|
||||||
const pdfGenerator = createPdfGenerator({ renderOrigin: origin });
|
const pdfGenerator = createPdfGenerator({ renderOrigin: origin });
|
||||||
const docxMediaAdapter = createDocxMediaEngine({ 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({
|
const app = buildApp({
|
||||||
logger: { level: "error" },
|
logger: { level: "error" },
|
||||||
pdfGenerator,
|
pdfGenerator,
|
||||||
docxMediaAdapter,
|
docxMediaAdapter,
|
||||||
fontPacks: {
|
fontPacks: {
|
||||||
roots: [fontPackRoot],
|
roots: [fontPackRoot],
|
||||||
appVersion: "0.6.0"
|
appVersion
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
registerWebRuntime(app);
|
registerWebRuntime(app);
|
||||||
@@ -390,10 +466,11 @@ try {
|
|||||||
);
|
);
|
||||||
|
|
||||||
for (const theme of themes) {
|
for (const theme of themes) {
|
||||||
|
capturedDocxDocumentLayout = undefined;
|
||||||
process.stderr.write(`[DOCX R4 matrix] generating ${theme.id}\n`);
|
process.stderr.write(`[DOCX R4 matrix] generating ${theme.id}\n`);
|
||||||
const samplePath = path.join(samplesDirectory, `${theme.id}.md`);
|
const samplePath = path.join(samplesDirectory, `${theme.id}.md`);
|
||||||
assert(fs.existsSync(samplePath), `主题缺少验收示例:${theme.id}`);
|
assert(fs.existsSync(samplePath), `主题缺少验收示例:${theme.id}`);
|
||||||
const markdown = fs.readFileSync(samplePath, "utf8");
|
const markdown = `${fs.readFileSync(samplePath, "utf8").trimEnd()}${inlineCodeContinuityMarkdown}`;
|
||||||
const exportConfig = mergeExportConfig({
|
const exportConfig = mergeExportConfig({
|
||||||
...defaultExportConfig,
|
...defaultExportConfig,
|
||||||
name: `${theme.name} R4 生产链验收`,
|
name: `${theme.name} R4 生产链验收`,
|
||||||
@@ -449,6 +526,10 @@ try {
|
|||||||
inspection.requiredStylesPresent,
|
inspection.requiredStylesPresent,
|
||||||
`主题 ${theme.id} 缺少标准 Word 样式`
|
`主题 ${theme.id} 缺少标准 Word 样式`
|
||||||
);
|
);
|
||||||
|
assert(
|
||||||
|
inspection.inlineCodeContinuityPassed,
|
||||||
|
`主题 ${theme.id} 的行内代码连续性门禁失败:${JSON.stringify(inspection.inlineCodeContinuity)}`
|
||||||
|
);
|
||||||
const standard = standardByTheme.get(theme.id);
|
const standard = standardByTheme.get(theme.id);
|
||||||
assert(standard, `标准矩阵缺少主题 ${theme.id}`);
|
assert(standard, `标准矩阵缺少主题 ${theme.id}`);
|
||||||
assert(
|
assert(
|
||||||
@@ -504,6 +585,7 @@ try {
|
|||||||
docx: {
|
docx: {
|
||||||
outputFile: path.relative(repositoryDirectory, docxPath),
|
outputFile: path.relative(repositoryDirectory, docxPath),
|
||||||
inspection,
|
inspection,
|
||||||
|
documentLayout: capturedDocxDocumentLayout ?? { tables: [] },
|
||||||
diagnostics: responseDiagnostics(docx.response, "docx")
|
diagnostics: responseDiagnostics(docx.response, "docx")
|
||||||
},
|
},
|
||||||
standardInspection: standard.inspection,
|
standardInspection: standard.inspection,
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ const app = buildApp({
|
|||||||
? {
|
? {
|
||||||
fontPacks: {
|
fontPacks: {
|
||||||
roots: [fontPackRoot],
|
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?: AbortSignal
|
||||||
) {
|
) {
|
||||||
signal?.throwIfAborted();
|
signal?.throwIfAborted();
|
||||||
|
await page.emulateMedia({ media: "print" });
|
||||||
const plan = await renderCapturePlan(page, request);
|
const plan = await renderCapturePlan(page, request);
|
||||||
signal?.throwIfAborted();
|
signal?.throwIfAborted();
|
||||||
const session = await page.context().newCDPSession(page);
|
const session = await page.context().newCDPSession(page);
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ function readProjectFile(relativePath: string) {
|
|||||||
return readFileSync(`${projectRoot}/${relativePath}`, "utf8");
|
return readFileSync(`${projectRoot}/${relativePath}`, "utf8");
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("Docker v0.6.0 部署契约", () => {
|
describe("Docker v0.6.1 部署契约", () => {
|
||||||
it("完整构建并复制 DOCX 运行时工作区和 Lua Filter", () => {
|
it("完整构建并复制 DOCX 运行时工作区和 Lua Filter", () => {
|
||||||
const dockerfile = readProjectFile("deploy/Dockerfile");
|
const dockerfile = readProjectFile("deploy/Dockerfile");
|
||||||
const workspaces = [
|
const workspaces = [
|
||||||
@@ -57,6 +57,9 @@ describe("Docker v0.6.0 部署契约", () => {
|
|||||||
expect(dockerfile).toContain(
|
expect(dockerfile).toContain(
|
||||||
"COPY --from=pandoc-runtime /opt/pandoc/3.9.0.2 /opt/pandoc/3.9.0.2"
|
"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(
|
expect(dockerfile).toContain(
|
||||||
'test "$(pandoc --version | head -n 1)" = "pandoc 3.9.0.2"'
|
'test "$(pandoc --version | head -n 1)" = "pandoc 3.9.0.2"'
|
||||||
);
|
);
|
||||||
@@ -65,7 +68,8 @@ describe("Docker v0.6.0 部署契约", () => {
|
|||||||
it("将受校验字体包内置到镜像并取消运行时字体挂载", () => {
|
it("将受校验字体包内置到镜像并取消运行时字体挂载", () => {
|
||||||
const dockerfile = readProjectFile("deploy/Dockerfile");
|
const dockerfile = readProjectFile("deploy/Dockerfile");
|
||||||
const compose = readProjectFile("deploy/compose.yaml");
|
const compose = readProjectFile("deploy/compose.yaml");
|
||||||
const dockerignore = readProjectFile(
|
const dockerignore = readProjectFile(".dockerignore");
|
||||||
|
const dockerfileIgnore = readProjectFile(
|
||||||
"deploy/Dockerfile.dockerignore"
|
"deploy/Dockerfile.dockerignore"
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -78,10 +82,11 @@ describe("Docker v0.6.0 部署契约", () => {
|
|||||||
);
|
);
|
||||||
expect(dockerfile).toContain("FONT_PACK_ROOT=/app/.local/font-packs");
|
expect(dockerfile).toContain("FONT_PACK_ROOT=/app/.local/font-packs");
|
||||||
expect(compose).toContain(
|
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.split(/\r?\n/u)).toContain(".local");
|
||||||
expect(dockerignore).toContain("!output/font-packs/root/**");
|
expect(dockerignore).toContain("!output/font-packs/root/**");
|
||||||
|
expect(dockerfileIgnore).toBe(dockerignore);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("提供显式真实 Docker 字体包验收命令", () => {
|
it("提供显式真实 Docker 字体包验收命令", () => {
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ describe("Server DOCX 媒体浏览器", () => {
|
|||||||
const detach = vi.fn(async () => undefined);
|
const detach = vi.fn(async () => undefined);
|
||||||
const page = {
|
const page = {
|
||||||
goto: vi.fn(async () => undefined),
|
goto: vi.fn(async () => undefined),
|
||||||
|
emulateMedia: vi.fn(async () => undefined),
|
||||||
waitForFunction: vi.fn(async () => undefined),
|
waitForFunction: vi.fn(async () => undefined),
|
||||||
evaluate: vi.fn(async () => emptyPlan),
|
evaluate: vi.fn(async () => emptyPlan),
|
||||||
context: () => context
|
context: () => context
|
||||||
@@ -104,6 +105,7 @@ describe("Server DOCX 媒体浏览器", () => {
|
|||||||
"http://renderer.test/preview-frame.html?target=continuous",
|
"http://renderer.test/preview-frame.html?target=continuous",
|
||||||
{ waitUntil: "domcontentloaded" }
|
{ waitUntil: "domcontentloaded" }
|
||||||
);
|
);
|
||||||
|
expect(page.emulateMedia).toHaveBeenCalledWith({ media: "print" });
|
||||||
expect(continueRoute).toHaveBeenCalledTimes(1);
|
expect(continueRoute).toHaveBeenCalledTimes(1);
|
||||||
expect(abortRoute).toHaveBeenCalledWith("blockedbyclient");
|
expect(abortRoute).toHaveBeenCalledWith("blockedbyclient");
|
||||||
expect(detach).toHaveBeenCalledOnce();
|
expect(detach).toHaveBeenCalledOnce();
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ describe("Playwright DOCX 媒体捕获", () => {
|
|||||||
data: Buffer.from("png").toString("base64")
|
data: Buffer.from("png").toString("base64")
|
||||||
}));
|
}));
|
||||||
const page = {
|
const page = {
|
||||||
|
emulateMedia: vi.fn(async () => undefined),
|
||||||
evaluate: vi.fn(async () => plan),
|
evaluate: vi.fn(async () => plan),
|
||||||
context: () => ({
|
context: () => ({
|
||||||
newCDPSession: vi.fn(async () => ({ send, detach }))
|
newCDPSession: vi.fn(async () => ({ send, detach }))
|
||||||
@@ -74,6 +75,7 @@ describe("Playwright DOCX 媒体捕获", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
expect(result.plan).toBe(plan);
|
expect(result.plan).toBe(plan);
|
||||||
|
expect(page.emulateMedia).toHaveBeenCalledWith({ media: "print" });
|
||||||
expect(result.captures[0]).toEqual({
|
expect(result.captures[0]).toEqual({
|
||||||
id: "docx-media-1",
|
id: "docx-media-1",
|
||||||
png: Buffer.from("png")
|
png: Buffer.from("png")
|
||||||
|
|||||||
@@ -3,13 +3,13 @@ import {
|
|||||||
isPagedPreviewRenderRequest,
|
isPagedPreviewRenderRequest,
|
||||||
PAGED_PREVIEW_MESSAGE_SCOPE,
|
PAGED_PREVIEW_MESSAGE_SCOPE,
|
||||||
PagedDocumentRuntime,
|
PagedDocumentRuntime,
|
||||||
|
SHARED_HIGHLIGHT_CSS,
|
||||||
renderDocxMediaCapturePlan,
|
renderDocxMediaCapturePlan,
|
||||||
resolveMermaidOutputMode,
|
resolveMermaidOutputMode,
|
||||||
resolvePagedRenderTarget,
|
resolvePagedRenderTarget,
|
||||||
type PagedPreviewFrameMessage,
|
type PagedPreviewFrameMessage,
|
||||||
type PagedPreviewPayload
|
type PagedPreviewPayload
|
||||||
} from "@md-to-pdf/preview-engine";
|
} 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 katexCss from "katex/dist/katex.min.css?inline";
|
||||||
import {
|
import {
|
||||||
beginPreviewFrameRender,
|
beginPreviewFrameRender,
|
||||||
@@ -29,7 +29,7 @@ const previewRoot = getPreviewRoot();
|
|||||||
const renderTarget = resolvePagedRenderTarget(window.location.search);
|
const renderTarget = resolvePagedRenderTarget(window.location.search);
|
||||||
const mermaidOutput = resolveMermaidOutputMode(window.location.search);
|
const mermaidOutput = resolveMermaidOutputMode(window.location.search);
|
||||||
const runtime = new PagedDocumentRuntime(previewRoot, {
|
const runtime = new PagedDocumentRuntime(previewRoot, {
|
||||||
highlightCss,
|
highlightCss: SHARED_HIGHLIGHT_CSS,
|
||||||
katexCss,
|
katexCss,
|
||||||
echartsCss
|
echartsCss
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ const webRoot = fileURLToPath(new URL("../", import.meta.url));
|
|||||||
|
|
||||||
describe("应用版本", () => {
|
describe("应用版本", () => {
|
||||||
it("从统一构建版本生成标题徽标", () => {
|
it("从统一构建版本生成标题徽标", () => {
|
||||||
expect(APP_VERSION).toBe("0.6.0");
|
expect(APP_VERSION).toBe("0.6.1");
|
||||||
expect(APP_VERSION_LABEL).toBe("v0.6.0");
|
expect(APP_VERSION_LABEL).toBe("v0.6.1");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("允许浏览器加载同源 Web Manifest", () => {
|
it("允许浏览器加载同源 Web Manifest", () => {
|
||||||
|
|||||||
+2
-1
@@ -110,7 +110,7 @@ RUN if [ "${REUSE_PLAYWRIGHT_RUNTIME}" = "1" ]; then \
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
COPY --from=pandoc-runtime /opt/pandoc/3.9.0.2 /opt/pandoc/3.9.0.2
|
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"
|
&& test "$(pandoc --version | head -n 1)" = "pandoc 3.9.0.2"
|
||||||
|
|
||||||
WORKDIR /app
|
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-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-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
|
USER pwuser
|
||||||
|
|
||||||
|
|||||||
+13
-15
@@ -3,7 +3,7 @@
|
|||||||
本目录提供单容器部署:Nginx 在 `8080` 端口提供前端,并将
|
本目录提供单容器部署:Nginx 在 `8080` 端口提供前端,并将
|
||||||
`/api/` 代理到同容器内的 Fastify;Fastify 复用固定版本
|
`/api/` 代理到同容器内的 Fastify;Fastify 复用固定版本
|
||||||
Playwright Chromium 生成 PDF,并使用固定 Pandoc `3.9.0.2` 生成
|
Playwright Chromium 生成 PDF,并使用固定 Pandoc `3.9.0.2` 生成
|
||||||
可编辑 DOCX。可选字体包只从宿主机只读挂载,不进入应用镜像。
|
可编辑 DOCX。发行镜像固定内置受校验字体包,不依赖宿主机字体挂载。
|
||||||
|
|
||||||
## 启动
|
## 启动
|
||||||
|
|
||||||
@@ -38,7 +38,7 @@ docker compose -f deploy\compose.yaml down
|
|||||||
可以直接指定版本化镜像名称构建:
|
可以直接指定版本化镜像名称构建:
|
||||||
|
|
||||||
```powershell
|
```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
|
docker compose -f deploy\compose.yaml build
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -48,13 +48,13 @@ docker compose -f deploy\compose.yaml build
|
|||||||
已经保留同版本系列的已验证镜像,可在内网或软件源较慢时复用其运行层:
|
已经保留同版本系列的已验证镜像,可在内网或软件源较慢时复用其运行层:
|
||||||
|
|
||||||
```powershell
|
```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'
|
||||||
$env:MD_TO_PDF_RUNTIME_BASE_IMAGE = 'yixiong/md-to-pdf:v0.4.5'
|
$env:MD_TO_PDF_RUNTIME_BASE_IMAGE = 'yixiong/md-to-pdf:v0.6.0'
|
||||||
$env:MD_TO_PDF_REUSE_PLAYWRIGHT_RUNTIME = '1'
|
$env:MD_TO_PDF_REUSE_PLAYWRIGHT_RUNTIME = '1'
|
||||||
docker compose -f deploy\compose.yaml build
|
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 不挂载宿主机字体目录,因此部署端
|
`/app/.local/font-packs`。Compose 不挂载宿主机字体目录,因此部署端
|
||||||
无需额外复制字体,Preview、PDF 和 DOCX 可以离线使用同一套原生资产。
|
无需额外复制字体,Preview、PDF 和 DOCX 可以离线使用同一套原生资产。
|
||||||
|
|
||||||
@@ -84,11 +84,9 @@ v0.6.0 的发行镜像固定将受校验字体包内置到
|
|||||||
|
|
||||||
```text
|
```text
|
||||||
<字体包根目录>/
|
<字体包根目录>/
|
||||||
└── mdtp-serif-sc/
|
├── mdtp-serif-sc/1.0.0/
|
||||||
└── 1.0.0/
|
├── mdtp-sans-sc/1.0.0/
|
||||||
├── font-pack.json
|
└── mdtp-mono/1.0.0/
|
||||||
├── LICENSE.txt
|
|
||||||
└── fonts/
|
|
||||||
```
|
```
|
||||||
|
|
||||||
从源码构建镜像前,先从仓库外冻结源生成字体包,再构建镜像:
|
从源码构建镜像前,先从仓库外冻结源生成字体包,再构建镜像:
|
||||||
@@ -98,15 +96,15 @@ npm run build:font-pack
|
|||||||
docker compose -f deploy\compose.yaml up -d
|
docker compose -f deploy\compose.yaml up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
字体二进制仍不提交到 Git。Docker 构建上下文只放行本次生成的
|
字体二进制通过 Git LFS 固定在仓库中。Docker 构建上下文只放行构建所需
|
||||||
`output/font-packs/root`,镜像构建会在缺少清单时立即失败。运行时注册器
|
源码与本次生成的 `output/font-packs/root`,镜像构建会在缺少清单时立即失败。运行时注册器
|
||||||
仍会复核许可证、文件类型、路径边界、容量和 SHA-256。更换字体包内容时
|
仍会复核许可证、文件类型、路径边界、容量和 SHA-256。更换字体包内容时
|
||||||
必须提升字体包 SemVer 并重新构建镜像,不能覆盖同一个版本。
|
必须提升字体包 SemVer 并重新构建镜像,不能覆盖同一个版本。
|
||||||
|
|
||||||
发布前执行真实内置字体门禁:
|
发布前执行真实内置字体门禁:
|
||||||
|
|
||||||
```powershell
|
```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
|
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_REUSE_PLAYWRIGHT_RUNTIME` | `0` | 是否复用基础镜像中的 Chromium |
|
||||||
| `MD_TO_PDF_PORT` | `8080` | 宿主机监听端口 |
|
| `MD_TO_PDF_PORT` | `8080` | 宿主机监听端口 |
|
||||||
| `MD_TO_PDF_THEME_DIR` | `../.local/themes` | 宿主机主题目录 |
|
| `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_CONCURRENCY` | `1` | 同时执行的 PDF 任务数 |
|
||||||
| `PDF_MAX_QUEUE` | `4` | 等待队列上限 |
|
| `PDF_MAX_QUEUE` | `4` | 等待队列上限 |
|
||||||
| `PDF_TIMEOUT_MS` | `120000` | 单次 PDF 生成超时 |
|
| `PDF_TIMEOUT_MS` | `120000` | 单次 PDF 生成超时 |
|
||||||
|
|||||||
+1
-1
@@ -19,7 +19,7 @@ services:
|
|||||||
PDF_CONCURRENCY: "${PDF_CONCURRENCY:-1}"
|
PDF_CONCURRENCY: "${PDF_CONCURRENCY:-1}"
|
||||||
PDF_MAX_QUEUE: "${PDF_MAX_QUEUE:-4}"
|
PDF_MAX_QUEUE: "${PDF_MAX_QUEUE:-4}"
|
||||||
PDF_TIMEOUT_MS: "${PDF_TIMEOUT_MS:-120000}"
|
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
|
FONT_PACK_ROOT: /app/.local/font-packs
|
||||||
volumes:
|
volumes:
|
||||||
- "${MD_TO_PDF_THEME_DIR:-../.local/themes}:/app/.local/themes:ro"
|
- "${MD_TO_PDF_THEME_DIR:-../.local/themes}:/app/.local/themes:ro"
|
||||||
|
|||||||
@@ -17,7 +17,8 @@ const fontPackRoot = path.resolve(
|
|||||||
);
|
);
|
||||||
const expectedFontPacks = [
|
const expectedFontPacks = [
|
||||||
{ id: "mdtp-serif-sc", version: "1.0.0" },
|
{ 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) {
|
function assert(condition, message) {
|
||||||
@@ -101,7 +102,7 @@ async function exportDocx(origin) {
|
|||||||
headers: { "content-type": "application/json" },
|
headers: { "content-type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
markdown:
|
markdown:
|
||||||
"---\ntitle: Docker 内置字体包验收\n---\n\n# Docker 内置字体包验收\n\n这是一段可编辑中文正文。",
|
"---\ntitle: Docker 内置字体包验收\n---\n\n# Docker 内置字体包验收\n\n这是一段包含行内代码 `const value = 1` 的可编辑中文正文,代码前后不得换行。",
|
||||||
fileName: "Docker 内置字体包验收.md",
|
fileName: "Docker 内置字体包验收.md",
|
||||||
language: "zh-CN",
|
language: "zh-CN",
|
||||||
resources: [],
|
resources: [],
|
||||||
@@ -174,7 +175,7 @@ const environment = {
|
|||||||
...buildEnvironment,
|
...buildEnvironment,
|
||||||
MD_TO_PDF_PORT: String(port),
|
MD_TO_PDF_PORT: String(port),
|
||||||
MD_TO_PDF_THEME_DIR: themeRoot,
|
MD_TO_PDF_THEME_DIR: themeRoot,
|
||||||
MD_TO_PDF_APP_VERSION: "0.6.0"
|
MD_TO_PDF_APP_VERSION: "0.6.1"
|
||||||
};
|
};
|
||||||
const compose = [
|
const compose = [
|
||||||
"compose",
|
"compose",
|
||||||
@@ -214,10 +215,13 @@ try {
|
|||||||
);
|
);
|
||||||
const css = await cssResponse.text();
|
const css = await cssResponse.text();
|
||||||
assert(cssResponse.ok, "主题 CSS 请求失败");
|
assert(cssResponse.ok, "主题 CSS 请求失败");
|
||||||
|
assert(css.includes("md-to-pdf-font-packs:"), "主题 CSS 缺少字体包标记");
|
||||||
|
for (const pack of expectedFontPacks) {
|
||||||
assert(
|
assert(
|
||||||
css.includes("md-to-pdf-font-packs:mdtp-serif-sc@1.0.0"),
|
css.includes(`${pack.id}@${pack.version}`),
|
||||||
"Docker 内置字体包未进入主题 CSS"
|
`Docker 内置字体包未进入主题 CSS:${pack.id}@${pack.version}`
|
||||||
);
|
);
|
||||||
|
}
|
||||||
const assetPath = css.match(
|
const assetPath = css.match(
|
||||||
/(\/api\/font-packs\/[^"'()\s]+\/web\?v=[a-f0-9]+)/u
|
/(\/api\/font-packs\/[^"'()\s]+\/web\?v=[a-f0-9]+)/u
|
||||||
)?.[1];
|
)?.[1];
|
||||||
@@ -241,6 +245,17 @@ try {
|
|||||||
assert(fontParts.length >= 2, `DOCX 字体部件不足:${fontParts.length}`);
|
assert(fontParts.length >= 2, `DOCX 字体部件不足:${fontParts.length}`);
|
||||||
const fontTable = strFromU8(entries["word/fontTable.xml"]);
|
const fontTable = strFromU8(entries["word/fontTable.xml"]);
|
||||||
assert(fontTable.includes("MdTP Serif SC"), "字体包未进入 DOCX 字体表");
|
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(
|
process.stdout.write(
|
||||||
`${JSON.stringify(
|
`${JSON.stringify(
|
||||||
|
|||||||
@@ -137,8 +137,8 @@ Pandoc 采用 GNU GPL version 2 or later。项目将其作为未修改的独立
|
|||||||
项目发布资产计划至少包含:
|
项目发布资产计划至少包含:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
MorphDoc-0.6.0-x86_64-Setup.exe
|
MorphDoc-0.6.1-x86_64-Setup.exe
|
||||||
MorphDoc-0.6.0-x86_64.zip
|
MorphDoc-0.6.1-x86_64.zip
|
||||||
pandoc-3.9.0.2-source.tar.gz
|
pandoc-3.9.0.2-source.tar.gz
|
||||||
THIRD_PARTY_NOTICES.md
|
THIRD_PARTY_NOTICES.md
|
||||||
```
|
```
|
||||||
|
|||||||
+23
-15
@@ -1,6 +1,6 @@
|
|||||||
# 墨呈进度
|
# 墨呈进度
|
||||||
|
|
||||||
最后更新:2026-08-03
|
最后更新:2026-08-04
|
||||||
|
|
||||||
## 1. 当前概况
|
## 1. 当前概况
|
||||||
|
|
||||||
@@ -76,6 +76,14 @@ failure 还被外层白名单过滤,导致报告失败时总门禁仍可通过
|
|||||||
无关的语义块局部视觉门禁,并对字体真实解析、嵌入和发布后三条生产链路
|
无关的语义块局部视觉门禁,并对字体真实解析、嵌入和发布后三条生产链路
|
||||||
建立阻断验收。完整范围见 [v0.6.1 设计文档](V0.6.1_DESIGN.md)。
|
建立阻断验收。完整范围见 [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`,官方
|
`v0.6.0` 阶段 2 已完成 Pandoc 运行时探查:固定 `3.9.0.2`,官方
|
||||||
Windows ZIP 和 Linux amd64 tarball 的 SHA-256 均已核验;Linux 静态程序
|
Windows ZIP 和 Linux amd64 tarball 的 SHA-256 均已核验;Linux 静态程序
|
||||||
可直接运行于 `node:22-bookworm-slim`。Desktop 安装包预计增加约
|
可直接运行于 `node:22-bookworm-slim`。Desktop 安装包预计增加约
|
||||||
@@ -1502,21 +1510,21 @@ ECharts 第二阶段浏览器与 PDF 验证结果:
|
|||||||
总计 76 个阻断问题;`formal-feasibility--landscape--binding` 验证 A4
|
总计 76 个阻断问题;`formal-feasibility--landscape--binding` 验证 A4
|
||||||
横向、非对称装订边距和独立封面整页 strict 门禁,封面因墨迹与边缘
|
横向、非对称装订边距和独立封面整页 strict 门禁,封面因墨迹与边缘
|
||||||
IoU 不达标被阻断;全仓库测试、类型检查、生产构建和差异检查通过;
|
IoU 不达标被阻断;全仓库测试、类型检查、生产构建和差异检查通过;
|
||||||
- 阶段 D:进行中。D1/D2 已将字体门禁扩展到 PDF 实际字体解析、DOCX
|
- 阶段 D:已完成。D1/D2 将字体门禁扩展到 PDF 实际字体解析、DOCX
|
||||||
声明与嵌入数量,以及 Word/WPS 是否真正采用 Office 嵌入别名;新增
|
声明与嵌入数量,以及 Word/WPS 是否真正采用 Office 嵌入别名;新增
|
||||||
`mdtp-sans-sc@1.0.0`,与 `mdtp-serif-sc@1.0.0` 组成确定性双字体包,
|
`mdtp-sans-sc@1.0.0` 和 `mdtp-mono@1.0.0`,与
|
||||||
14 套主题的 Server HTTP 生产链和 Office 实际字体使用门禁均已通过。
|
`mdtp-serif-sc@1.0.0` 组成确定性三字体包。D3 重建四套独立封面整页
|
||||||
D3 已重建独立封面视觉门禁:预览与 DOCX 共用受限高度封面间距模型,
|
门禁,40 个封面方向/边距组合全部通过。D4 完成正文语义块视觉收口:
|
||||||
DOCX 支持可编辑双边框、超宽装饰边、非居中装饰元素宽度及 Office 页面
|
通用翻译层统一处理字体、中文标点字距、精确行距、段落、列表、表格、
|
||||||
舍入;整页差分使用 4px 空间容差、0.6pt 纸张尺寸容差,墨迹 IoU、边缘
|
引用、代码块和行内代码连续性;最终 140 个 Chromium、Word、WPS
|
||||||
IoU、MAE、变化像素率阈值未放宽。四套独立封面主题 × 纵横两个方向 ×
|
真实场景全部执行并通过,阻断失败和诊断失败均为 0;
|
||||||
主题默认、标准、紧凑、宽松、非对称装订五组边距共 40 个真实 Chromium、
|
- 阶段 E:已完成。源码服务、正式 Docker Web API 和实际安装 Desktop
|
||||||
Word、WPS 用例全部通过,严格失败为 0;主题各 10 例、方向各 20 例、
|
使用同一 `red-briefing` 复验,三份 DOCX 均约 24.17 MB、包含 5 个字体
|
||||||
边距各 8 例。报告仍保留 2963 条正文诊断,未计作 D3 通过项。全仓库
|
部件和 40,731,432 字节嵌入字体;安装版产物经 Word/WPS 渲染均为 2 页
|
||||||
测试、类型检查、生产构建和差异检查通过;下一步 D4 修复 14 套主题
|
A4,逐页视觉通过;
|
||||||
正文语义块视觉差异;
|
- 阶段 F:进行中。版本已更新为 `v0.6.1`,发行说明、Desktop 安装器与
|
||||||
- 阶段 E:完成源码服务、Docker Web API、Desktop 安装版发布后复验;
|
ZIP、Docker 镜像均已生成;待完成最终全量验证、离线镜像归档复验、
|
||||||
- 阶段 F:完成 `v0.6.1` 版本、发行说明、发布产物、发布提交和标签。
|
发布提交和 annotated tag。
|
||||||
|
|
||||||
每个阶段验收通过后创建一个独立提交,再进入下一阶段。当前阶段不得混入
|
每个阶段验收通过后创建一个独立提交,再进入下一阶段。当前阶段不得混入
|
||||||
后续阶段的功能实现。
|
后续阶段的功能实现。
|
||||||
|
|||||||
@@ -146,3 +146,24 @@ Word PDF 和 WPS PDF,并在报告中保留主题、方向、边距场景、导
|
|||||||
- 单元测试、类型检查、生产构建和 `git diff --check` 通过;
|
- 单元测试、类型检查、生产构建和 `git diff --check` 通过;
|
||||||
- 发布报告中不存在被忽略的 failure;
|
- 发布报告中不存在被忽略的 failure;
|
||||||
- 完成真实 Word/WPS 逐页人工复核后才能发布。
|
- 完成真实 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)。
|
||||||
|
|||||||
@@ -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 签名,适用于公司内部发布;
|
||||||
|
公开网络分发前建议增加代码签名。
|
||||||
@@ -22,6 +22,12 @@ font-packs/assets/mdtp-sans-sc/1.0.0/
|
|||||||
├── MdTPSansSC-Regular.woff2
|
├── MdTPSansSC-Regular.woff2
|
||||||
├── MdTPSansSC-Bold.ttf
|
├── MdTPSansSC-Bold.ttf
|
||||||
└── MdTPSansSC-Bold.woff2
|
└── 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。
|
该目录中的 `ttf/otf/woff/woff2` 由 `.gitattributes` 强制使用 Git LFS。
|
||||||
@@ -62,6 +68,11 @@ root/<pack-id>/<semver>/font-pack.json
|
|||||||
`Source Han Sans SC` 的 400/700 字重映射。主题仍需明确声明所需字体面,
|
`Source Han Sans SC` 的 400/700 字重映射。主题仍需明确声明所需字体面,
|
||||||
注册器不会对未声明字体进行静默替换。
|
注册器不会对未声明字体进行静默替换。
|
||||||
|
|
||||||
|
`mdtp-mono@1.0.0` 提供内部字族 `MdTP Mono`,并覆盖主题代码样式常用的
|
||||||
|
Cascadia、Consolas、SFMono、Menlo、Monaco 与 Courier 等候选名称。
|
||||||
|
应用层将它作为代码语义槽位的引擎级可选能力统一注入,因此新主题无需
|
||||||
|
增加主题 ID 分支;字体包缺失时仍保留主题原有候选链并输出缺失诊断。
|
||||||
|
|
||||||
## Desktop 内置发行
|
## Desktop 内置发行
|
||||||
|
|
||||||
组合构建命令为:
|
组合构建命令为:
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -12,6 +12,12 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"recipe": "font-packs/recipes/mdtp-sans-sc/recipe.json",
|
"recipe": "font-packs/recipes/mdtp-sans-sc/recipe.json",
|
||||||
"sourceRoot": "font-packs/assets/mdtp-sans-sc/1.0.0"
|
"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.
|
||||||
@@ -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、格式、字体内部家族、字重与嵌入权限。
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Generated
+4
-3
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "md-to-pdf",
|
"name": "md-to-pdf",
|
||||||
"version": "0.6.0",
|
"version": "0.6.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "md-to-pdf",
|
"name": "md-to-pdf",
|
||||||
"version": "0.6.0",
|
"version": "0.6.1",
|
||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"apps/*",
|
"apps/*",
|
||||||
@@ -22,7 +22,7 @@
|
|||||||
},
|
},
|
||||||
"apps/desktop": {
|
"apps/desktop": {
|
||||||
"name": "@md-to-pdf/desktop",
|
"name": "@md-to-pdf/desktop",
|
||||||
"version": "0.6.0",
|
"version": "0.6.1",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@md-to-pdf/application": "0.4.1",
|
"@md-to-pdf/application": "0.4.1",
|
||||||
"@md-to-pdf/docx-engine": "0.1.0",
|
"@md-to-pdf/docx-engine": "0.1.0",
|
||||||
@@ -9975,6 +9975,7 @@
|
|||||||
"@md-to-pdf/docx-engine": "0.1.0",
|
"@md-to-pdf/docx-engine": "0.1.0",
|
||||||
"@md-to-pdf/docx-theme-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/font-pack-registry": "0.1.0",
|
||||||
|
"@md-to-pdf/preview-engine": "0.1.0",
|
||||||
"@md-to-pdf/renderer": "0.1.0"
|
"@md-to-pdf/renderer": "0.1.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "md-to-pdf",
|
"name": "md-to-pdf",
|
||||||
"version": "0.6.0",
|
"version": "0.6.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
"description": "面向结构化 Markdown 的主题化文档创作与发布工具",
|
"description": "面向结构化 Markdown 的主题化文档创作与发布工具",
|
||||||
|
|||||||
@@ -25,6 +25,7 @@
|
|||||||
"@md-to-pdf/docx-engine": "0.1.0",
|
"@md-to-pdf/docx-engine": "0.1.0",
|
||||||
"@md-to-pdf/docx-theme-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/font-pack-registry": "0.1.0",
|
||||||
|
"@md-to-pdf/preview-engine": "0.1.0",
|
||||||
"@md-to-pdf/renderer": "0.1.0"
|
"@md-to-pdf/renderer": "0.1.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -197,6 +197,7 @@ export async function prepareDocxMedia(
|
|||||||
...parsedPlan.data.echartsErrors,
|
...parsedPlan.data.echartsErrors,
|
||||||
...parsedPlan.data.mermaidErrors
|
...parsedPlan.data.mermaidErrors
|
||||||
],
|
],
|
||||||
totalBytes
|
totalBytes,
|
||||||
|
documentLayout: parsedPlan.data.documentLayout ?? { tables: [] }
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
type DocxThemeStyleSnapshot
|
type DocxThemeStyleSnapshot
|
||||||
} from "@md-to-pdf/docx-theme-engine";
|
} from "@md-to-pdf/docx-theme-engine";
|
||||||
import type { PreparedDocxExport } from "./application-service.js";
|
import type { PreparedDocxExport } from "./application-service.js";
|
||||||
|
import { SHARED_HIGHLIGHT_CSS } from "@md-to-pdf/preview-engine";
|
||||||
|
|
||||||
export interface PreparedDocxThemeTokens {
|
export interface PreparedDocxThemeTokens {
|
||||||
tokens: DocxThemeTokenSet;
|
tokens: DocxThemeTokenSet;
|
||||||
@@ -67,7 +68,7 @@ export class DocxThemeTokenService
|
|||||||
const themeFingerprint =
|
const themeFingerprint =
|
||||||
await createDocxThemeStyleFingerprint(
|
await createDocxThemeStyleFingerprint(
|
||||||
prepared.theme.manifest.id,
|
prepared.theme.manifest.id,
|
||||||
prepared.theme.css
|
`${SHARED_HIGHLIGHT_CSS}\n${prepared.theme.css}`
|
||||||
);
|
);
|
||||||
signal?.throwIfAborted();
|
signal?.throwIfAborted();
|
||||||
const snapshot =
|
const snapshot =
|
||||||
@@ -80,7 +81,7 @@ export class DocxThemeTokenService
|
|||||||
{
|
{
|
||||||
themeId: prepared.theme.manifest.id,
|
themeId: prepared.theme.manifest.id,
|
||||||
themeFingerprint,
|
themeFingerprint,
|
||||||
themeCss: prepared.theme.css,
|
themeCss: `${SHARED_HIGHLIGHT_CSS}\n${prepared.theme.css}`,
|
||||||
baseUrl: this.adapter.themeStyleBaseUrl
|
baseUrl: this.adapter.themeStyleBaseUrl
|
||||||
},
|
},
|
||||||
signal
|
signal
|
||||||
|
|||||||
@@ -92,6 +92,48 @@ const maximumCssImportDepth = 8;
|
|||||||
const sharedThemeAssetId = "_shared";
|
const sharedThemeAssetId = "_shared";
|
||||||
const sharedThemeAssetScheme = "theme-shared:";
|
const sharedThemeAssetScheme = "theme-shared:";
|
||||||
const sharedFontCssPath = "official-fonts/fonts.css";
|
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) {
|
function isMissingFileError(error: unknown) {
|
||||||
return (
|
return (
|
||||||
@@ -434,7 +476,8 @@ export function createThemeRegistry(options: ThemeRegistryOptions) {
|
|||||||
|
|
||||||
async function resolveThemeFontPacks(theme: ThemeRecord) {
|
async function resolveThemeFontPacks(theme: ThemeRecord) {
|
||||||
const registry = await listFontPacks();
|
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) => ({
|
const requests: FontPackFaceRequest[] = faces.map((face) => ({
|
||||||
family: face.family,
|
family: face.family,
|
||||||
aliases: face.aliases,
|
aliases: face.aliases,
|
||||||
@@ -453,7 +496,14 @@ export function createThemeRegistry(options: ThemeRegistryOptions) {
|
|||||||
matches.set(face, match);
|
matches.set(face, match);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return { registry, resolution, matches };
|
return {
|
||||||
|
registry,
|
||||||
|
resolution,
|
||||||
|
matches,
|
||||||
|
faces: faces.filter(
|
||||||
|
(face) => declaredFaces.includes(face) || matches.has(face)
|
||||||
|
)
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function createFontPackCss(
|
function createFontPackCss(
|
||||||
@@ -711,8 +761,8 @@ export function createThemeRegistry(options: ThemeRegistryOptions) {
|
|||||||
if (!theme) {
|
if (!theme) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
const faces = theme.manifest.docxFonts?.faces ?? [];
|
|
||||||
const fontPacks = await resolveThemeFontPacks(theme);
|
const fontPacks = await resolveThemeFontPacks(theme);
|
||||||
|
const faces = fontPacks.faces;
|
||||||
const fonts = await Promise.all(
|
const fonts = await Promise.all(
|
||||||
faces.map(async (face) => {
|
faces.map(async (face) => {
|
||||||
const matched = fontPacks.matches.get(face);
|
const matched = fontPacks.matches.get(face);
|
||||||
|
|||||||
@@ -132,7 +132,11 @@ function sha256(content: Uint8Array | string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function createFontPackFixture(
|
async function createFontPackFixture(
|
||||||
options: { corruptWebHash?: boolean; minimumAppVersion?: string } = {}
|
options: {
|
||||||
|
corruptWebHash?: boolean;
|
||||||
|
minimumAppVersion?: string;
|
||||||
|
targets?: string[];
|
||||||
|
} = {}
|
||||||
) {
|
) {
|
||||||
const root = join(temporaryDirectory!, "font-packs");
|
const root = join(temporaryDirectory!, "font-packs");
|
||||||
const directory = join(root, "test-font-pack", "1.0.0");
|
const directory = join(root, "test-font-pack", "1.0.0");
|
||||||
@@ -162,7 +166,7 @@ async function createFontPackFixture(
|
|||||||
faces: [
|
faces: [
|
||||||
{
|
{
|
||||||
id: "test-regular",
|
id: "test-regular",
|
||||||
targets: ["Test"],
|
targets: options.targets ?? ["Test"],
|
||||||
weight: 400,
|
weight: 400,
|
||||||
style: "normal",
|
style: "normal",
|
||||||
web: {
|
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 () => {
|
it("字体包损坏时记录诊断并回退到主题字体", async () => {
|
||||||
const roots = await createThemeFixture();
|
const roots = await createThemeFixture();
|
||||||
const fontPack = await createFontPackFixture({ corruptWebHash: true });
|
const fontPack = await createFontPackFixture({ corruptWebHash: true });
|
||||||
|
|||||||
+102
-1
@@ -106,6 +106,105 @@ export type DocxMediaAlignment = z.infer<
|
|||||||
typeof docxMediaAlignmentSchema
|
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({
|
export const docxMediaCaptureTargetSchema = z.object({
|
||||||
id: z.string().regex(/^docx-media-\d+$/u),
|
id: z.string().regex(/^docx-media-\d+$/u),
|
||||||
kind: docxMediaKindSchema,
|
kind: docxMediaKindSchema,
|
||||||
@@ -136,7 +235,8 @@ export const docxMediaCapturePlanSchema = z.object({
|
|||||||
.array(docxMediaCaptureTargetSchema)
|
.array(docxMediaCaptureTargetSchema)
|
||||||
.max(MAXIMUM_DOCX_RESOURCE_COUNT),
|
.max(MAXIMUM_DOCX_RESOURCE_COUNT),
|
||||||
echartsErrors: z.array(z.string().max(1_000)),
|
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<
|
export type DocxMediaCapturePlan = z.infer<
|
||||||
@@ -158,6 +258,7 @@ export interface PreparedDocxMedia {
|
|||||||
mermaidErrors: string[];
|
mermaidErrors: string[];
|
||||||
warnings: string[];
|
warnings: string[];
|
||||||
totalBytes: number;
|
totalBytes: number;
|
||||||
|
documentLayout?: DocxDocumentLayoutPlan;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const docxExportErrorCodeSchema = z.enum([
|
export const docxExportErrorCodeSchema = z.enum([
|
||||||
|
|||||||
@@ -24,6 +24,10 @@ export const DEFAULT_VISUAL_DIFF_THRESHOLDS: VisualDiffThresholds = {
|
|||||||
maxChangedPixelRatio: 0.06,
|
maxChangedPixelRatio: 0.06,
|
||||||
minInkIou: 0.64,
|
minInkIou: 0.64,
|
||||||
minEdgeIou: 0.6,
|
minEdgeIou: 0.6,
|
||||||
|
maxBackgroundColorDelta: 12,
|
||||||
|
maxForegroundColorDelta: 12,
|
||||||
|
minAntialiasEquivalentInkIou: 0.98,
|
||||||
|
minAntialiasEquivalentEdgeIou: 0.98,
|
||||||
};
|
};
|
||||||
|
|
||||||
function cappedLevenshteinDistance(
|
function cappedLevenshteinDistance(
|
||||||
@@ -259,6 +263,21 @@ export function comparePdfSnapshotsBasic(
|
|||||||
const candidateEditableText = hasEditableExpectation
|
const candidateEditableText = hasEditableExpectation
|
||||||
? buildPdfEditableContentText(candidate, candidatePageSemantics)
|
? 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
|
const baselineEditableCoverage = hasEditableExpectation
|
||||||
? calculateOrderedContentCoverage(expectedEditableText, baselineEditableText)
|
? calculateOrderedContentCoverage(expectedEditableText, baselineEditableText)
|
||||||
: undefined;
|
: undefined;
|
||||||
@@ -270,7 +289,7 @@ export function comparePdfSnapshotsBasic(
|
|||||||
)
|
)
|
||||||
: undefined;
|
: undefined;
|
||||||
const candidateEditableExact = hasEditableExpectation
|
const candidateEditableExact = hasEditableExpectation
|
||||||
? candidateEditableText === expectedEditableText
|
? candidateEditableText === expectedEditableText || paragraphContentExact
|
||||||
: undefined;
|
: undefined;
|
||||||
const contentSimilarity = hasEditableExpectation
|
const contentSimilarity = hasEditableExpectation
|
||||||
? Math.min(
|
? Math.min(
|
||||||
@@ -302,15 +321,6 @@ export function comparePdfSnapshotsBasic(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const paragraphLayouts = contentOptions.expectedEditableParagraphs
|
|
||||||
? comparePdfEditableParagraphLayouts(
|
|
||||||
baseline,
|
|
||||||
candidate,
|
|
||||||
contentOptions.expectedEditableParagraphs,
|
|
||||||
baselinePageSemantics,
|
|
||||||
candidatePageSemantics,
|
|
||||||
)
|
|
||||||
: undefined;
|
|
||||||
if (paragraphLayouts) {
|
if (paragraphLayouts) {
|
||||||
issues.push(...paragraphLayouts.flatMap((paragraph) => paragraph.issues));
|
issues.push(...paragraphLayouts.flatMap((paragraph) => paragraph.issues));
|
||||||
}
|
}
|
||||||
@@ -376,7 +386,7 @@ export function comparePdfSnapshotsBasic(
|
|||||||
const bodyFlowLegal =
|
const bodyFlowLegal =
|
||||||
bodyFlowDetected &&
|
bodyFlowDetected &&
|
||||||
paragraphLayouts !== undefined &&
|
paragraphLayouts !== undefined &&
|
||||||
paragraphLayouts.every((paragraph) => paragraph.status === "passed") &&
|
paragraphLayouts.every((paragraph) => paragraph.status !== "failed") &&
|
||||||
baselineEditableCoverage === 1 &&
|
baselineEditableCoverage === 1 &&
|
||||||
candidateEditableExact === true &&
|
candidateEditableExact === true &&
|
||||||
baselineCoverPageCount === candidateCoverPageCount &&
|
baselineCoverPageCount === candidateCoverPageCount &&
|
||||||
|
|||||||
@@ -8,11 +8,13 @@ import type {
|
|||||||
PdfEditableContentLine,
|
PdfEditableContentLine,
|
||||||
PdfParagraphLayoutComparison,
|
PdfParagraphLayoutComparison,
|
||||||
PdfParagraphLayoutObservation,
|
PdfParagraphLayoutObservation,
|
||||||
|
PdfPointBounds,
|
||||||
VisualDiffIssue,
|
VisualDiffIssue,
|
||||||
VisualPageSemanticExpectation,
|
VisualPageSemanticExpectation,
|
||||||
} from "./types.js";
|
} from "./types.js";
|
||||||
|
|
||||||
const LINE_BREAK_CHARACTER_TOLERANCE = 1;
|
const LINE_BREAK_CHARACTER_TOLERANCE = 1;
|
||||||
|
const BODY_REFLOW_BREAK_CHARACTER_TOLERANCE = 3;
|
||||||
const LINE_X_TOLERANCE_PT = 2;
|
const LINE_X_TOLERANCE_PT = 2;
|
||||||
const COVER_LINE_X_TOLERANCE_PT = 12;
|
const COVER_LINE_X_TOLERANCE_PT = 12;
|
||||||
const LINE_HEIGHT_TOLERANCE_PT = 1;
|
const LINE_HEIGHT_TOLERANCE_PT = 1;
|
||||||
@@ -26,6 +28,17 @@ interface MatchCursor {
|
|||||||
characterIndex: number;
|
characterIndex: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface MatchedCharacterRange {
|
||||||
|
start: number;
|
||||||
|
end: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MatchedCharacterPosition {
|
||||||
|
lineIndex: number;
|
||||||
|
characterIndex: number;
|
||||||
|
character: string;
|
||||||
|
}
|
||||||
|
|
||||||
function average(values: readonly number[]): number | undefined {
|
function average(values: readonly number[]): number | undefined {
|
||||||
if (values.length === 0) {
|
if (values.length === 0) {
|
||||||
return undefined;
|
return undefined;
|
||||||
@@ -33,6 +46,113 @@ function average(values: readonly number[]): number | undefined {
|
|||||||
return values.reduce((sum, value) => sum + value, 0) / values.length;
|
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(
|
function advanceCursor(
|
||||||
cursor: MatchCursor,
|
cursor: MatchCursor,
|
||||||
lineCharacters: readonly string[][],
|
lineCharacters: readonly string[][],
|
||||||
@@ -48,41 +168,87 @@ function advanceCursor(
|
|||||||
return cursor.lineIndex < lineCharacters.length;
|
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(
|
function observeParagraph(
|
||||||
expectation: EditableParagraphExpectation,
|
expectation: EditableParagraphExpectation,
|
||||||
lines: readonly PdfEditableContentLine[],
|
lines: readonly PdfEditableContentLine[],
|
||||||
lineCharacters: readonly string[][],
|
lineCharacters: readonly string[][],
|
||||||
cursor: MatchCursor,
|
cursor: MatchCursor,
|
||||||
): PdfParagraphLayoutObservation {
|
): PdfParagraphLayoutObservation {
|
||||||
const localCursor: MatchCursor = { ...cursor };
|
|
||||||
const expectedCharacters = Array.from(
|
const expectedCharacters = Array.from(
|
||||||
normalizePdfEditableText(expectation.text),
|
normalizePdfEditableText(expectation.text),
|
||||||
);
|
);
|
||||||
const matchedByLine = new Map<number, string[]>();
|
const matchedByLine = new Map<number, string[]>();
|
||||||
let matchedCharacterCount = 0;
|
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 (attempt.positions.length === expectedCharacters.length) {
|
||||||
|
matched = true;
|
||||||
|
matchedPositions = attempt.positions;
|
||||||
|
matchedCursor = attempt.cursor;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!advanceCursor(searchCursor, lineCharacters)) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for (const expectedCharacter of expectedCharacters) {
|
for (const position of matchedPositions) {
|
||||||
let found = false;
|
const matches = matchedByLine.get(position.lineIndex) ?? [];
|
||||||
while (localCursor.lineIndex < lineCharacters.length) {
|
matches.push(position.character);
|
||||||
const actualCharacter =
|
matchedByLine.set(position.lineIndex, matches);
|
||||||
lineCharacters[localCursor.lineIndex]?.[localCursor.characterIndex];
|
const range = matchedRanges.get(position.lineIndex);
|
||||||
if (actualCharacter === expectedCharacter) {
|
matchedRanges.set(position.lineIndex, {
|
||||||
const matches = matchedByLine.get(localCursor.lineIndex) ?? [];
|
start: Math.min(
|
||||||
matches.push(expectedCharacter);
|
range?.start ?? position.characterIndex,
|
||||||
matchedByLine.set(localCursor.lineIndex, matches);
|
position.characterIndex,
|
||||||
matchedCharacterCount += 1;
|
),
|
||||||
advanceCursor(localCursor, lineCharacters);
|
end: Math.max(
|
||||||
found = true;
|
range?.end ?? position.characterIndex + 1,
|
||||||
break;
|
position.characterIndex + 1,
|
||||||
}
|
),
|
||||||
if (!advanceCursor(localCursor, lineCharacters)) {
|
});
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!found) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
const matchedCharacterCount = matchedPositions.length;
|
||||||
|
|
||||||
const matchedLineIndexes = [...matchedByLine.keys()];
|
const matchedLineIndexes = [...matchedByLine.keys()];
|
||||||
const matchedLines = matchedLineIndexes.flatMap((index) =>
|
const matchedLines = matchedLineIndexes.flatMap((index) =>
|
||||||
@@ -113,21 +279,27 @@ function observeParagraph(
|
|||||||
Array.from(lines[index]?.normalizedText ?? "").length ===
|
Array.from(lines[index]?.normalizedText ?? "").length ===
|
||||||
(matchedByLine.get(index)?.length ?? 0),
|
(matchedByLine.get(index)?.length ?? 0),
|
||||||
);
|
);
|
||||||
const visualLines = matchedLines.map((line) => ({
|
const visualLines = matchedLines.map((line, index) => {
|
||||||
|
const matched = matchedLineItems(
|
||||||
|
line.line,
|
||||||
|
line.normalizedText,
|
||||||
|
matchedRanges.get(matchedLineIndexes[index]!),
|
||||||
|
);
|
||||||
|
return {
|
||||||
pageNumber: line.pageNumber,
|
pageNumber: line.pageNumber,
|
||||||
bounds: line.line.bounds,
|
bounds: matched.bounds,
|
||||||
fontFamilies: [
|
fontFamilies: [
|
||||||
...new Set(
|
...new Set(
|
||||||
line.line.items.flatMap((item) =>
|
matched.items.flatMap((item) =>
|
||||||
item.fontFamily ? [item.fontFamily] : [],
|
item.fontFamily ? [item.fontFamily] : [],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
].sort(),
|
].sort(),
|
||||||
}));
|
};
|
||||||
const matched = matchedCharacterCount === expectedCharacters.length;
|
});
|
||||||
if (matched) {
|
if (matched) {
|
||||||
cursor.lineIndex = localCursor.lineIndex;
|
cursor.lineIndex = matchedCursor.lineIndex;
|
||||||
cursor.characterIndex = localCursor.characterIndex;
|
cursor.characterIndex = matchedCursor.characterIndex;
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
matched,
|
matched,
|
||||||
@@ -139,15 +311,15 @@ function observeParagraph(
|
|||||||
lineBreakOffsets,
|
lineBreakOffsets,
|
||||||
...(firstLine
|
...(firstLine
|
||||||
? {
|
? {
|
||||||
firstLineXPt: firstLine.line.bounds.x,
|
firstLineXPt: visualLines[0]!.bounds.x,
|
||||||
firstLineBaselineYPt: firstLine.line.baselineY,
|
firstLineBaselineYPt: firstLine.line.baselineY,
|
||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
...(average(matchedLines.map((line) => line.line.bounds.width)) === undefined
|
...(average(visualLines.map((line) => line.bounds.width)) === undefined
|
||||||
? {}
|
? {}
|
||||||
: {
|
: {
|
||||||
maximumLineWidthPt: Math.max(
|
maximumLineWidthPt: Math.max(
|
||||||
...matchedLines.map((line) => line.line.bounds.width),
|
...visualLines.map((line) => line.bounds.width),
|
||||||
),
|
),
|
||||||
}),
|
}),
|
||||||
...(averageLineHeightPt === undefined
|
...(averageLineHeightPt === undefined
|
||||||
@@ -176,6 +348,14 @@ function trailingLineCharacterCount(
|
|||||||
return Array.from(observation.lineTexts.at(-1) ?? "").length;
|
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(
|
function isBodyNearBoundaryLineCountDifference(
|
||||||
expectation: EditableParagraphExpectation,
|
expectation: EditableParagraphExpectation,
|
||||||
baseline: PdfParagraphLayoutObservation,
|
baseline: PdfParagraphLayoutObservation,
|
||||||
@@ -271,14 +451,21 @@ function compareParagraph(
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
if (maximumBreakDelta > LINE_BREAK_CHARACTER_TOLERANCE) {
|
if (maximumBreakDelta > LINE_BREAK_CHARACTER_TOLERANCE) {
|
||||||
|
const toleratedReflow =
|
||||||
|
allowsBodyTextReflow(expectation) &&
|
||||||
|
maximumBreakDelta <= BODY_REFLOW_BREAK_CHARACTER_TOLERANCE;
|
||||||
issues.push({
|
issues.push({
|
||||||
code: "PARAGRAPH_LINE_BREAK_MISMATCH",
|
code: "PARAGRAPH_LINE_BREAK_MISMATCH",
|
||||||
severity: "failure",
|
severity: toleratedReflow ? "warning" : "failure",
|
||||||
message: `第 ${expectation.index + 1} 个段落的行内换行边界偏差 ${maximumBreakDelta} 个字符`,
|
message: toleratedReflow
|
||||||
|
? `第 ${expectation.index + 1} 个正文块发生允许的跨引擎行内重排,边界偏差 ${maximumBreakDelta} 个字符`
|
||||||
|
: `第 ${expectation.index + 1} 个段落的行内换行边界偏差 ${maximumBreakDelta} 个字符`,
|
||||||
details: {
|
details: {
|
||||||
paragraphIndex: expectation.index,
|
paragraphIndex: expectation.index,
|
||||||
maximumBreakDelta,
|
maximumBreakDelta,
|
||||||
tolerance: LINE_BREAK_CHARACTER_TOLERANCE,
|
tolerance: LINE_BREAK_CHARACTER_TOLERANCE,
|
||||||
|
reflowTolerance: BODY_REFLOW_BREAK_CHARACTER_TOLERANCE,
|
||||||
|
toleratedReflow,
|
||||||
baselineBreaks: baseline.lineBreakOffsets.join(","),
|
baselineBreaks: baseline.lineBreakOffsets.join(","),
|
||||||
candidateBreaks: candidate.lineBreakOffsets.join(","),
|
candidateBreaks: candidate.lineBreakOffsets.join(","),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -16,6 +16,14 @@ interface DecodedRaster {
|
|||||||
rgba: Uint8ClampedArray;
|
rgba: Uint8ClampedArray;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface RgbColor {
|
||||||
|
red: number;
|
||||||
|
green: number;
|
||||||
|
blue: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const RASTER_MASK_BORDER_PX = 2;
|
||||||
|
|
||||||
function sha256(bytes: Uint8Array): string {
|
function sha256(bytes: Uint8Array): string {
|
||||||
return createHash("sha256").update(bytes).digest("hex");
|
return createHash("sha256").update(bytes).digest("hex");
|
||||||
}
|
}
|
||||||
@@ -48,6 +56,143 @@ function luminance(
|
|||||||
return (77 * red + 150 * green + 29 * blue) >> 8;
|
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(
|
function calculateBinaryIou(
|
||||||
left: Uint8Array,
|
left: Uint8Array,
|
||||||
right: Uint8Array,
|
right: Uint8Array,
|
||||||
@@ -292,6 +437,16 @@ export async function comparePageRasters(
|
|||||||
const pixelDifference = new Uint8Array(pixelCount);
|
const pixelDifference = new Uint8Array(pixelCount);
|
||||||
const baselineInk = new Uint8Array(pixelCount);
|
const baselineInk = new Uint8Array(pixelCount);
|
||||||
const candidateInk = 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 absoluteError = 0;
|
||||||
let changedPixels = 0;
|
let changedPixels = 0;
|
||||||
|
|
||||||
@@ -323,25 +478,43 @@ export async function comparePageRasters(
|
|||||||
const candidateValue = luminance(candidateDecoded.rgba, offset);
|
const candidateValue = luminance(candidateDecoded.rgba, offset);
|
||||||
baselineGray[pixelIndex] = baselineValue;
|
baselineGray[pixelIndex] = baselineValue;
|
||||||
candidateGray[pixelIndex] = candidateValue;
|
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;
|
baselineInk[pixelIndex] = 1;
|
||||||
}
|
}
|
||||||
if (candidateValue < 245) {
|
if (colorDistance(candidateColor, candidateBackground) >= 16) {
|
||||||
candidateInk[pixelIndex] = 1;
|
candidateInk[pixelIndex] = 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const baselineEdges = createEdgeMask(
|
clearMaskBorder(baselineInk, width, height);
|
||||||
baselineGray,
|
clearMaskBorder(candidateInk, width, height);
|
||||||
|
const baselineEdges = clearMaskBorder(
|
||||||
|
createEdgeMask(baselineGray, width, height, 40),
|
||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
40,
|
|
||||||
);
|
);
|
||||||
const candidateEdges = createEdgeMask(
|
const candidateEdges = clearMaskBorder(
|
||||||
candidateGray,
|
createEdgeMask(candidateGray, width, height, 40),
|
||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
40,
|
);
|
||||||
|
const foregroundColorDelta = colorDistance(
|
||||||
|
rasterForegroundColor(baselineDecoded),
|
||||||
|
rasterForegroundColor(candidateDecoded),
|
||||||
|
);
|
||||||
|
const backgroundColorDelta = colorDistance(
|
||||||
|
baselineBackground,
|
||||||
|
candidateBackground,
|
||||||
);
|
);
|
||||||
const metrics: PdfRasterDiffMetrics = {
|
const metrics: PdfRasterDiffMetrics = {
|
||||||
baselineWidthPx: baseline.widthPx,
|
baselineWidthPx: baseline.widthPx,
|
||||||
@@ -371,6 +544,8 @@ export async function comparePageRasters(
|
|||||||
height,
|
height,
|
||||||
spatialTolerancePx,
|
spatialTolerancePx,
|
||||||
),
|
),
|
||||||
|
backgroundColorDelta,
|
||||||
|
foregroundColorDelta,
|
||||||
};
|
};
|
||||||
return {
|
return {
|
||||||
metrics,
|
metrics,
|
||||||
|
|||||||
@@ -460,7 +460,7 @@ function renderParagraphLayouts(report: PdfVisualDiffReport): string {
|
|||||||
const rows = (failures.length > 0 ? failures : paragraphs.slice(0, 8))
|
const rows = (failures.length > 0 ? failures : paragraphs.slice(0, 8))
|
||||||
.map((paragraph) => {
|
.map((paragraph) => {
|
||||||
const expectation = paragraph.expectation;
|
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("");
|
.join("");
|
||||||
return `<div class="paragraph-layouts">
|
return `<div class="paragraph-layouts">
|
||||||
|
|||||||
@@ -128,14 +128,33 @@ async function padRaster(
|
|||||||
function localRasterIssues(
|
function localRasterIssues(
|
||||||
paragraphIndex: number,
|
paragraphIndex: number,
|
||||||
lineIndex: number,
|
lineIndex: number,
|
||||||
|
blockKind: string | undefined,
|
||||||
metrics: NonNullable<PdfSemanticBlockVisualLineComparison["metrics"]>,
|
metrics: NonNullable<PdfSemanticBlockVisualLineComparison["metrics"]>,
|
||||||
thresholds: VisualDiffThresholds,
|
thresholds: VisualDiffThresholds,
|
||||||
|
textReflow: boolean,
|
||||||
): VisualDiffIssue[] {
|
): VisualDiffIssue[] {
|
||||||
const failed =
|
const shapeFailed =
|
||||||
metrics.meanAbsoluteError > thresholds.maxMeanAbsoluteError ||
|
|
||||||
metrics.changedPixelRatio > thresholds.maxChangedPixelRatio ||
|
|
||||||
metrics.inkIou < thresholds.minInkIou ||
|
metrics.inkIou < thresholds.minInkIou ||
|
||||||
metrics.edgeIou < thresholds.minEdgeIou;
|
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) {
|
if (!failed) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
@@ -151,11 +170,86 @@ function localRasterIssues(
|
|||||||
changedPixelRatio: metrics.changedPixelRatio,
|
changedPixelRatio: metrics.changedPixelRatio,
|
||||||
inkIou: metrics.inkIou,
|
inkIou: metrics.inkIou,
|
||||||
edgeIou: metrics.edgeIou,
|
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(
|
export async function comparePdfSemanticBlockVisuals(
|
||||||
baseline: PdfDocumentSnapshot,
|
baseline: PdfDocumentSnapshot,
|
||||||
candidate: PdfDocumentSnapshot,
|
candidate: PdfDocumentSnapshot,
|
||||||
@@ -172,6 +266,11 @@ export async function comparePdfSemanticBlockVisuals(
|
|||||||
const candidateFonts = collectFonts(paragraph.candidate.visualLines);
|
const candidateFonts = collectFonts(paragraph.candidate.visualLines);
|
||||||
|
|
||||||
const lines: PdfSemanticBlockVisualLineComparison[] = [];
|
const lines: PdfSemanticBlockVisualLineComparison[] = [];
|
||||||
|
const flowTextBlock = [
|
||||||
|
"paragraph",
|
||||||
|
"list-item",
|
||||||
|
"block-quote",
|
||||||
|
].includes(paragraph.expectation.blockKind ?? "paragraph");
|
||||||
const comparableLineCount = Math.min(
|
const comparableLineCount = Math.min(
|
||||||
paragraph.baseline.visualLines.length,
|
paragraph.baseline.visualLines.length,
|
||||||
paragraph.candidate.visualLines.length,
|
paragraph.candidate.visualLines.length,
|
||||||
@@ -228,7 +327,23 @@ export async function comparePdfSemanticBlockVisuals(
|
|||||||
cropRaster(baselinePage.raster, baselineLine.bounds),
|
cropRaster(baselinePage.raster, baselineLine.bounds),
|
||||||
cropRaster(candidatePage.raster, candidateLine.bounds),
|
cropRaster(candidatePage.raster, candidateLine.bounds),
|
||||||
]);
|
]);
|
||||||
const widthPx = Math.max(baselineCrop.widthPx, candidateCrop.widthPx);
|
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(
|
const heightPx = Math.max(
|
||||||
baselineCrop.heightPx,
|
baselineCrop.heightPx,
|
||||||
candidateCrop.heightPx,
|
candidateCrop.heightPx,
|
||||||
@@ -237,16 +352,19 @@ export async function comparePdfSemanticBlockVisuals(
|
|||||||
padRaster(baselineCrop, widthPx, heightPx),
|
padRaster(baselineCrop, widthPx, heightPx),
|
||||||
padRaster(candidateCrop, widthPx, heightPx),
|
padRaster(candidateCrop, widthPx, heightPx),
|
||||||
]);
|
]);
|
||||||
const result = await comparePageRasters(
|
return comparePageRasters(
|
||||||
baselinePadded,
|
baselinePadded,
|
||||||
candidatePadded,
|
candidatePadded,
|
||||||
thresholds.pixelDifferenceThreshold,
|
thresholds.pixelDifferenceThreshold,
|
||||||
);
|
);
|
||||||
|
})();
|
||||||
const lineIssues = localRasterIssues(
|
const lineIssues = localRasterIssues(
|
||||||
paragraph.expectation.index,
|
paragraph.expectation.index,
|
||||||
lineIndex,
|
lineIndex,
|
||||||
|
paragraph.expectation.blockKind,
|
||||||
result.metrics,
|
result.metrics,
|
||||||
thresholds,
|
thresholds,
|
||||||
|
textReflow,
|
||||||
);
|
);
|
||||||
issues.push(...lineIssues);
|
issues.push(...lineIssues);
|
||||||
lines.push({
|
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 ZERO_WIDTH_AND_CONTROL = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f\u200b-\u200d\u2060\ufeff]/gu;
|
||||||
const CJK_RADICAL_VARIANTS = new Map([
|
const CJK_RADICAL_VARIANTS = new Map([
|
||||||
["⺠", "民"],
|
["⺠", "民"],
|
||||||
|
["⻅", "见"],
|
||||||
|
["⻆", "角"],
|
||||||
|
["⻓", "长"],
|
||||||
["⻔", "门"],
|
["⻔", "门"],
|
||||||
|
["⻚", "页"],
|
||||||
|
["⻛", "风"],
|
||||||
]);
|
]);
|
||||||
const PAGE_NUMBER_PATTERNS = [
|
const PAGE_NUMBER_PATTERNS = [
|
||||||
/^(?:[-—–]\s*)?\d+(?:\s*[//]\s*\d+)?(?:\s*[-—–])?$/u,
|
/^(?:[-—–]\s*)?\d+(?:\s*[//]\s*\d+)?(?:\s*[-—–])?$/u,
|
||||||
|
|||||||
@@ -56,11 +56,24 @@ export type EditableParagraphRole =
|
|||||||
| "caption"
|
| "caption"
|
||||||
| "other";
|
| "other";
|
||||||
|
|
||||||
|
export type EditableParagraphBlockKind =
|
||||||
|
| "paragraph"
|
||||||
|
| "heading"
|
||||||
|
| "list-item"
|
||||||
|
| "table-header"
|
||||||
|
| "table-cell"
|
||||||
|
| "code-block"
|
||||||
|
| "block-quote"
|
||||||
|
| "caption"
|
||||||
|
| "semantic-region"
|
||||||
|
| "other";
|
||||||
|
|
||||||
export interface EditableParagraphExpectation {
|
export interface EditableParagraphExpectation {
|
||||||
index: number;
|
index: number;
|
||||||
text: string;
|
text: string;
|
||||||
styleId?: string;
|
styleId?: string;
|
||||||
role: EditableParagraphRole;
|
role: EditableParagraphRole;
|
||||||
|
blockKind?: EditableParagraphBlockKind;
|
||||||
section: "cover" | "body";
|
section: "cover" | "body";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -180,6 +193,10 @@ export interface VisualDiffThresholds {
|
|||||||
maxChangedPixelRatio: number;
|
maxChangedPixelRatio: number;
|
||||||
minInkIou: number;
|
minInkIou: number;
|
||||||
minEdgeIou: number;
|
minEdgeIou: number;
|
||||||
|
maxBackgroundColorDelta: number;
|
||||||
|
maxForegroundColorDelta: number;
|
||||||
|
minAntialiasEquivalentInkIou: number;
|
||||||
|
minAntialiasEquivalentEdgeIou: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type VisualDiffIssueSeverity = "warning" | "failure";
|
export type VisualDiffIssueSeverity = "warning" | "failure";
|
||||||
@@ -271,6 +288,8 @@ export interface PdfRasterDiffMetrics {
|
|||||||
changedPixelRatio: number;
|
changedPixelRatio: number;
|
||||||
inkIou: number;
|
inkIou: number;
|
||||||
edgeIou: number;
|
edgeIou: number;
|
||||||
|
backgroundColorDelta: number;
|
||||||
|
foregroundColorDelta: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ComparePageRasterOptions {
|
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 () => {
|
it("通过纸张和正文一致的文档", async () => {
|
||||||
const baseline = await snapshot("baseline");
|
const baseline = await snapshot("baseline");
|
||||||
const candidate = await snapshot("candidate");
|
const candidate = await snapshot("candidate");
|
||||||
|
|||||||
@@ -59,6 +59,95 @@ function expectation(
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("PDF 内容感知段落版式门禁", () => {
|
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("允许正文段落整体移动到下一物理页", () => {
|
it("允许正文段落整体移动到下一物理页", () => {
|
||||||
const baseline = snapshot("baseline", [
|
const baseline = snapshot("baseline", [
|
||||||
[line("建立月度调度机制及时协调", 700), line("解决项目中的问题", 728)],
|
[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("拒绝短正文或非极短末行的行数差异", () => {
|
it("拒绝短正文或非极短末行的行数差异", () => {
|
||||||
const text = "项目建设内容需要严格控制质量进度投资安全风险";
|
const text = "项目建设内容需要严格控制质量进度投资安全风险";
|
||||||
const baseline = snapshot("baseline", [
|
const baseline = snapshot("baseline", [
|
||||||
|
|||||||
@@ -53,12 +53,41 @@ describe("PDF 栅格差异", () => {
|
|||||||
changedPixelRatio: 0,
|
changedPixelRatio: 0,
|
||||||
inkIou: 1,
|
inkIou: 1,
|
||||||
edgeIou: 1,
|
edgeIou: 1,
|
||||||
|
backgroundColorDelta: 0,
|
||||||
|
foregroundColorDelta: 0,
|
||||||
});
|
});
|
||||||
expect(result.artifacts.overlayPng.subarray(0, 8)).toEqual(
|
expect(result.artifacts.overlayPng.subarray(0, 8)).toEqual(
|
||||||
new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10]),
|
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 () => {
|
it("量化位移并生成稳定叠加图与热力图", async () => {
|
||||||
const result = await comparePageRasters(raster(10), raster(20), {
|
const result = await comparePageRasters(raster(10), raster(20), {
|
||||||
spatialTolerancePx: 0,
|
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 {
|
function snapshot(label: string, pageRaster: PdfPageRaster): PdfDocumentSnapshot {
|
||||||
return {
|
return {
|
||||||
schemaVersion: 1,
|
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 () => {
|
it("整段跨页同时发生换行变化时由语义块门禁失败", async () => {
|
||||||
const baseline = flowSnapshot(
|
const baseline = flowSnapshot(
|
||||||
"baseline",
|
"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 () => {
|
it("缺少语义块证据时不得把正文整页栅格自动降级", async () => {
|
||||||
const report = await createPdfVisualDiffReport(
|
const report = await createPdfVisualDiffReport(
|
||||||
snapshot("baseline", raster("#111111")),
|
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 中的传统户部件归一为简体字符", () => {
|
it("将字体 ToUnicode 中的传统户部件归一为简体字符", () => {
|
||||||
expect(normalizePdfText("戶⼾")).toBe("户户");
|
expect(normalizePdfText("戶⼾")).toBe("户户");
|
||||||
|
expect(normalizePdfText("⻅⻆⻓⻔⻚⻛")).toBe("见角长门页风");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("从可编辑正文契约中移除 Word 自动列表装饰符", () => {
|
it("从可编辑正文契约中移除 Word 自动列表装饰符", () => {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -172,7 +172,7 @@ function pandocArguments(paths: {
|
|||||||
return [
|
return [
|
||||||
paths.markdown,
|
paths.markdown,
|
||||||
"--from",
|
"--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",
|
"--to",
|
||||||
"docx",
|
"docx",
|
||||||
"--standalone",
|
"--standalone",
|
||||||
@@ -375,7 +375,8 @@ export class PandocDocxConverter {
|
|||||||
pandocDocx,
|
pandocDocx,
|
||||||
structurePlan,
|
structurePlan,
|
||||||
input.themeTokens,
|
input.themeTokens,
|
||||||
preparedMedia.layout
|
preparedMedia.layout,
|
||||||
|
preparedMedia.documentLayout
|
||||||
);
|
);
|
||||||
docx = embedFontsInGeneratedDocx(
|
docx = embedFontsInGeneratedDocx(
|
||||||
finalized.content,
|
finalized.content,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
MAXIMUM_DOCX_RESOURCE_COUNT,
|
MAXIMUM_DOCX_RESOURCE_COUNT,
|
||||||
MAXIMUM_DOCX_TOTAL_MEDIA_BYTES,
|
MAXIMUM_DOCX_TOTAL_MEDIA_BYTES,
|
||||||
type DocxMediaKind,
|
type DocxMediaKind,
|
||||||
|
type DocxDocumentLayoutPlan,
|
||||||
type PreparedDocxMedia
|
type PreparedDocxMedia
|
||||||
} from "@md-to-pdf/core";
|
} from "@md-to-pdf/core";
|
||||||
|
|
||||||
@@ -47,6 +48,7 @@ export interface PreparedPandocMedia {
|
|||||||
map: PandocMediaMap;
|
map: PandocMediaMap;
|
||||||
files: PandocMediaFile[];
|
files: PandocMediaFile[];
|
||||||
layout: PandocMediaLayoutItem[];
|
layout: PandocMediaLayoutItem[];
|
||||||
|
documentLayout: DocxDocumentLayoutPlan;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DOCX_MEDIA_BINDING_PREFIX = "mdtp-media:";
|
export const DOCX_MEDIA_BINDING_PREFIX = "mdtp-media:";
|
||||||
@@ -184,5 +186,10 @@ export function preparePandocMedia(
|
|||||||
if (media.totalBytes !== totalBytes) {
|
if (media.totalBytes !== totalBytes) {
|
||||||
throw new Error("DOCX 媒体总大小声明不一致");
|
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"
|
"tender-cover"
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const semanticRoleSlotAliases: Readonly<
|
||||||
|
Partial<Record<SemanticDocumentRole, DocxStyleSlotName>>
|
||||||
|
> = {};
|
||||||
|
|
||||||
function slotName(
|
function slotName(
|
||||||
role: SemanticDocumentRole
|
role: SemanticDocumentRole
|
||||||
): DocxStyleSlotName | undefined {
|
): DocxStyleSlotName | undefined {
|
||||||
|
const alias = semanticRoleSlotAliases[role];
|
||||||
|
if (alias) {
|
||||||
|
return alias;
|
||||||
|
}
|
||||||
return Object.prototype.hasOwnProperty.call(
|
return Object.prototype.hasOwnProperty.call(
|
||||||
DOCX_SLOT_WORD_STYLE_BINDINGS,
|
DOCX_SLOT_WORD_STYLE_BINDINGS,
|
||||||
role
|
role
|
||||||
|
|||||||
@@ -7,8 +7,10 @@ import type {
|
|||||||
import type { ResolvedDocxThemeStyle } from "./style-presets.js";
|
import type { ResolvedDocxThemeStyle } from "./style-presets.js";
|
||||||
import {
|
import {
|
||||||
DOCX_SLOT_WORD_STYLE_BINDINGS,
|
DOCX_SLOT_WORD_STYLE_BINDINGS,
|
||||||
|
PANDOC_SYNTAX_STYLE_IDS,
|
||||||
collectDocxTokenFonts,
|
collectDocxTokenFonts,
|
||||||
createDocxTokenSlotMap,
|
createDocxTokenSlotMap,
|
||||||
|
resolvePandocSyntaxStyleSlot,
|
||||||
resolveTokenFonts
|
resolveTokenFonts
|
||||||
} from "./token-style-map.js";
|
} from "./token-style-map.js";
|
||||||
import {
|
import {
|
||||||
@@ -64,7 +66,8 @@ function setRunStyle(
|
|||||||
"iCs",
|
"iCs",
|
||||||
"u",
|
"u",
|
||||||
"shd",
|
"shd",
|
||||||
"spacing"
|
"spacing",
|
||||||
|
"kern"
|
||||||
);
|
);
|
||||||
if (options.fonts) {
|
if (options.fonts) {
|
||||||
setFonts(parent, options.fonts);
|
setFonts(parent, options.fonts);
|
||||||
@@ -106,6 +109,9 @@ function setRunStyle(
|
|||||||
"w:val": wordCharacterSpacingTwips(options.sizePt)
|
"w:val": wordCharacterSpacingTwips(options.sizePt)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
appendElement(parent, WORD_NAMESPACE, "w:kern", {
|
||||||
|
"w:val": "2"
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function setParagraphSpacing(
|
function setParagraphSpacing(
|
||||||
@@ -200,8 +206,16 @@ function fallbackFontsForSlot(
|
|||||||
if (slot.startsWith("heading-") || slot.endsWith("-title")) {
|
if (slot.startsWith("heading-") || slot.endsWith("-title")) {
|
||||||
return fallback.headings.fonts;
|
return fallback.headings.fonts;
|
||||||
}
|
}
|
||||||
if (slot === "inline-code" || slot === "code-block") {
|
if (
|
||||||
return fallback.code.fonts;
|
slot === "inline-code" ||
|
||||||
|
slot === "code-block" ||
|
||||||
|
slot === "code-block-text" ||
|
||||||
|
slot.startsWith("code-token-")
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
...fallback.code.fonts,
|
||||||
|
eastAsia: fallback.body.fonts.eastAsia
|
||||||
|
};
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
slot === "table" ||
|
slot === "table" ||
|
||||||
@@ -232,7 +246,12 @@ function fallbackFontSizeForSlot(
|
|||||||
if (slot.endsWith("-title")) {
|
if (slot.endsWith("-title")) {
|
||||||
return fallback.headings.sizesPt[0];
|
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;
|
return fallback.code.sizePt;
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
@@ -251,10 +270,17 @@ function fallbackFontSizeForSlot(
|
|||||||
function applyTokenRunStyle(
|
function applyTokenRunStyle(
|
||||||
parent: XmlElement,
|
parent: XmlElement,
|
||||||
token: DocxSlotStyleToken,
|
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) {
|
if (token.fontCandidates.length) {
|
||||||
setFonts(parent, resolveTokenFonts(token, fallbackFonts));
|
setFonts(parent, resolveTokenFonts(token, fallbackFonts, {
|
||||||
|
preserveFallbackEastAsia
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
if (token.fontSizePt !== undefined) {
|
if (token.fontSizePt !== undefined) {
|
||||||
removeDirectChildren(parent, WORD_NAMESPACE, "sz", "szCs");
|
removeDirectChildren(parent, WORD_NAMESPACE, "sz", "szCs");
|
||||||
@@ -1176,17 +1202,77 @@ function applyTokenStyles(
|
|||||||
binding.styleId === "SourceCode"
|
binding.styleId === "SourceCode"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
const runToken = binding.styleId === "SourceCode"
|
||||||
|
? slots.get("code-block-text")?.style ?? entry.style
|
||||||
|
: entry.style;
|
||||||
applyTokenRunStyle(
|
applyTokenRunStyle(
|
||||||
ensureDirectElement(
|
ensureDirectElement(
|
||||||
target,
|
target,
|
||||||
WORD_NAMESPACE,
|
WORD_NAMESPACE,
|
||||||
"w:rPr"
|
"w:rPr"
|
||||||
),
|
),
|
||||||
entry.style,
|
runToken,
|
||||||
fallbackFontsForSlot(slot, fallback)
|
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);
|
applyTableTokenStyles(styles, slots, fallback);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -110,6 +110,7 @@ export const DOCX_SLOT_WORD_STYLE_BINDINGS: Readonly<
|
|||||||
paragraph("MdOfficialPrintingRow")
|
paragraph("MdOfficialPrintingRow")
|
||||||
],
|
],
|
||||||
"briefing-masthead": [paragraph("MdBriefingMasthead")],
|
"briefing-masthead": [paragraph("MdBriefingMasthead")],
|
||||||
|
"briefing-masthead-text": [paragraph("MdBriefingMastheadText")],
|
||||||
"briefing-meta": [paragraph("MdBriefingMeta")],
|
"briefing-meta": [paragraph("MdBriefingMeta")],
|
||||||
"briefing-title": [paragraph("MdBriefingTitle")],
|
"briefing-title": [paragraph("MdBriefingTitle")],
|
||||||
"briefing-contact": [paragraph("MdBriefingContact")],
|
"briefing-contact": [paragraph("MdBriefingContact")],
|
||||||
@@ -141,6 +142,93 @@ export const DOCX_SLOT_WORD_STYLE_BINDINGS: Readonly<
|
|||||||
"tender-date": [paragraph("MdTenderDate")]
|
"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 =
|
const eastAsiaFontPattern =
|
||||||
/(?:fandol|yahei|simsun|simhei|fangsong|kaiti|dengxian|cjk|source\s+han|pingfang|heiti|songti|宋|黑|仿宋|楷|等线)/iu;
|
/(?:fandol|yahei|simsun|simhei|fangsong|kaiti|dengxian|cjk|source\s+han|pingfang|heiti|songti|宋|黑|仿宋|楷|等线)/iu;
|
||||||
const genericFontNames = new Set([
|
const genericFontNames = new Set([
|
||||||
@@ -154,7 +242,8 @@ const genericFontNames = new Set([
|
|||||||
|
|
||||||
export function resolveTokenFonts(
|
export function resolveTokenFonts(
|
||||||
token: DocxSlotStyleToken | undefined,
|
token: DocxSlotStyleToken | undefined,
|
||||||
fallback: DocxFontFamily
|
fallback: DocxFontFamily,
|
||||||
|
options: { preserveFallbackEastAsia?: boolean } = {}
|
||||||
): DocxFontFamily {
|
): DocxFontFamily {
|
||||||
const candidates = (token?.fontCandidates ?? []).filter(
|
const candidates = (token?.fontCandidates ?? []).filter(
|
||||||
(candidate) =>
|
(candidate) =>
|
||||||
@@ -166,7 +255,9 @@ export function resolveTokenFonts(
|
|||||||
const eastAsia =
|
const eastAsia =
|
||||||
candidates.find((candidate) =>
|
candidates.find((candidate) =>
|
||||||
eastAsiaFontPattern.test(candidate)
|
eastAsiaFontPattern.test(candidate)
|
||||||
) ?? candidates[0]!;
|
) ?? (options.preserveFallbackEastAsia
|
||||||
|
? fallback.eastAsia
|
||||||
|
: candidates[0]!);
|
||||||
const latin =
|
const latin =
|
||||||
candidates.find(
|
candidates.find(
|
||||||
(candidate) => !eastAsiaFontPattern.test(candidate)
|
(candidate) => !eastAsiaFontPattern.test(candidate)
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ function generatedFixture() {
|
|||||||
encoder.encode(
|
encoder.encode(
|
||||||
`<w:document xmlns:w="${word}" xmlns:r="${relationships}"><w:body>` +
|
`<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: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: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>CONTAINER_END</w:t></w:r></w:p>` +
|
||||||
`<w:p><w:r><w:t>SECTION_BREAK</w:t></w:r></w:p>` +
|
`<w:p><w: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"/>` +
|
`<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>` +
|
`<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>` +
|
`</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: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: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>`
|
`</w:body></w:document>`
|
||||||
@@ -78,7 +78,7 @@ const plan: PandocStructurePlan = {
|
|||||||
kind: "paragraph",
|
kind: "paragraph",
|
||||||
styleId: "MdOfficialIssueRow",
|
styleId: "MdOfficialIssueRow",
|
||||||
layout: "space-between",
|
layout: "space-between",
|
||||||
segments: ["发文字号", "签发人"],
|
segments: ["期号", "发布单位", "签发人", "日期"],
|
||||||
separator: "tab"
|
separator: "tab"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -161,11 +161,28 @@ const tokens: DocxThemeTokenSet = {
|
|||||||
source: "computed-css",
|
source: "computed-css",
|
||||||
confidence: "exact",
|
confidence: "exact",
|
||||||
style: {
|
style: {
|
||||||
fontCandidates: [],
|
fontCandidates: ["Body Face"],
|
||||||
fontSizePt: 10,
|
fontSizePt: 10,
|
||||||
lineSpacing: 1.8
|
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",
|
slot: "table-header",
|
||||||
source: "computed-css",
|
source: "computed-css",
|
||||||
@@ -213,7 +230,18 @@ describe("生成 DOCX 结构收口", () => {
|
|||||||
const result = finalizeGeneratedDocxStructure(
|
const result = finalizeGeneratedDocxStructure(
|
||||||
generatedFixture(),
|
generatedFixture(),
|
||||||
plan,
|
plan,
|
||||||
tokens
|
tokens,
|
||||||
|
[],
|
||||||
|
{
|
||||||
|
tables: [],
|
||||||
|
textBlocks: [{
|
||||||
|
ordinal: 1,
|
||||||
|
text: "期号发布单位签发人日期",
|
||||||
|
letterSpacingPt: 0,
|
||||||
|
linePitchPt: 17.25,
|
||||||
|
lineBreakOffsets: [6]
|
||||||
|
}]
|
||||||
|
}
|
||||||
);
|
);
|
||||||
const entries = readGeneratedDocxPackage(
|
const entries = readGeneratedDocxPackage(
|
||||||
result.content
|
result.content
|
||||||
@@ -237,6 +265,9 @@ describe("生成 DOCX 结构收口", () => {
|
|||||||
expect(documentXml).toContain(
|
expect(documentXml).toContain(
|
||||||
'<w:vAlign w:val="center"/>'
|
'<w:vAlign w:val="center"/>'
|
||||||
);
|
);
|
||||||
|
expect(documentXml).toContain(
|
||||||
|
'w:line="345" w:lineRule="exact"'
|
||||||
|
);
|
||||||
expect(documentXml).toContain(
|
expect(documentXml).toContain(
|
||||||
'<w:pgNumType w:start="1"/>'
|
'<w:pgNumType w:start="1"/>'
|
||||||
);
|
);
|
||||||
@@ -276,9 +307,11 @@ describe("生成 DOCX 结构收口", () => {
|
|||||||
expect(documentXml).toContain(
|
expect(documentXml).toContain(
|
||||||
'<w:tab w:val="right" w:pos="9806"/>'
|
'<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:fill="F5F5F5"');
|
||||||
expect(documentXml).toContain(
|
expect(documentXml).toContain(
|
||||||
'<w:spacing w:before="140"/>'
|
'w:spacing w:before="140"'
|
||||||
);
|
);
|
||||||
expect(documentXml).toContain(
|
expect(documentXml).toContain(
|
||||||
'<w:spacing w:after="1020"/>'
|
'<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("按稳定媒体计划规范化内联尺寸、比例锁和对齐", () => {
|
it("按稳定媒体计划规范化内联尺寸、比例锁和对齐", () => {
|
||||||
const result = finalizeGeneratedDocxStructure(
|
const result = finalizeGeneratedDocxStructure(
|
||||||
generatedFixture(),
|
generatedFixture(),
|
||||||
@@ -574,7 +781,7 @@ describe("生成 DOCX 结构收口", () => {
|
|||||||
expect(documentXml.match(/<w:trHeight\b/gu)).toHaveLength(1);
|
expect(documentXml.match(/<w:trHeight\b/gu)).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("将主题项目符号位置与 Word 悬挂缩进合并", () => {
|
it("将主题列表文字起点映射为 Word 悬挂缩进", () => {
|
||||||
const source = readGeneratedDocxPackage(generatedFixture());
|
const source = readGeneratedDocxPackage(generatedFixture());
|
||||||
const entries = new Map(source.entries);
|
const entries = new Map(source.entries);
|
||||||
entries.set(
|
entries.set(
|
||||||
@@ -612,10 +819,66 @@ describe("生成 DOCX 结构收口", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
expect(numberingXml).toContain(
|
expect(numberingXml).toContain(
|
||||||
'<w:ind w:left="960" w:hanging="360"/>'
|
'<w:ind w:left="600" w:hanging="360"/>'
|
||||||
);
|
);
|
||||||
expect(numberingXml).toContain(
|
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)
|
writeGeneratedDocxPackage(invalidEntries)
|
||||||
)
|
)
|
||||||
).toThrow("字体元数据与嵌入字形不匹配");
|
).toThrow("字体元数据与嵌入字形不匹配");
|
||||||
});
|
}, 15_000);
|
||||||
|
|
||||||
it("没有字体时保持原始 DOCX 字节不变", () => {
|
it("没有字体时保持原始 DOCX 字节不变", () => {
|
||||||
const input = createTestBaselineReference();
|
const input = createTestBaselineReference();
|
||||||
|
|||||||
@@ -126,6 +126,9 @@ describe("Pandoc DOCX 转换器", () => {
|
|||||||
expect(arguments_).toContain("--lua-filter");
|
expect(arguments_).toContain("--lua-filter");
|
||||||
expect(arguments_).toContain("--data-dir");
|
expect(arguments_).toContain("--data-dir");
|
||||||
expect(arguments_).toContain("--resource-path");
|
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(
|
expect(
|
||||||
options.env?.MD_TO_PDF_DOCX_MEDIA_MAP
|
options.env?.MD_TO_PDF_DOCX_MEDIA_MAP
|
||||||
).toContain("media-map.json");
|
).toContain("media-map.json");
|
||||||
|
|||||||
@@ -66,6 +66,36 @@ function paragraphSegments(block: PandocStructureBlock): string[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("Pandoc 语义结构投影", () => {
|
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 样式和可编辑段落", () => {
|
it("将公文分组投影为固定 Word 样式和可编辑段落", () => {
|
||||||
const model: SemanticDocumentModel = {
|
const model: SemanticDocumentModel = {
|
||||||
schemaVersion: 1,
|
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",
|
slot: "table",
|
||||||
source: "computed-css",
|
source: "computed-css",
|
||||||
@@ -390,6 +410,13 @@ describe("动态 reference.docx", () => {
|
|||||||
expect(sourceCodeStyle).toMatch(
|
expect(sourceCodeStyle).toMatch(
|
||||||
/<w:ind\b[^>]*w:left="195"[^>]*w:right="75"[^>]*\/>/u
|
/<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).not.toContain('w:val="justify"');
|
||||||
expect(stylesXml).toContain(
|
expect(stylesXml).toContain(
|
||||||
'w:line="459" w:lineRule="exact"'
|
'w:line="459" w:lineRule="exact"'
|
||||||
@@ -463,6 +490,7 @@ describe("动态 reference.docx", () => {
|
|||||||
const entries = unzipSync(result.content);
|
const entries = unzipSync(result.content);
|
||||||
const documentXml = decoder.decode(entries["word/document.xml"]);
|
const documentXml = decoder.decode(entries["word/document.xml"]);
|
||||||
const settingsXml = decoder.decode(entries["word/settings.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 headerXml = decoder.decode(entries["word/header1.xml"]);
|
||||||
const relationships = decoder.decode(
|
const relationships = decoder.decode(
|
||||||
entries["word/_rels/document.xml.rels"]
|
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:headerReference/gu)).toHaveLength(3);
|
||||||
expect(documentXml.match(/w:footerReference/gu)).toHaveLength(3);
|
expect(documentXml.match(/w:footerReference/gu)).toHaveLength(3);
|
||||||
expect(settingsXml).toContain("<w:evenAndOddHeaders");
|
expect(settingsXml).toContain("<w:evenAndOddHeaders");
|
||||||
|
expect(stylesXml).toContain('<w:kern w:val="2"');
|
||||||
expect(headerXml).toContain("年度 <报告>");
|
expect(headerXml).toContain("年度 <报告>");
|
||||||
expect(headerXml).toContain("报告 & 计划.md");
|
expect(headerXml).toContain("报告 & 计划.md");
|
||||||
expect(headerXml).toContain('w:tab w:val="center"');
|
expect(headerXml).toContain('w:tab w:val="center"');
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
DOCX_SLOT_WORD_STYLE_BINDINGS,
|
DOCX_SLOT_WORD_STYLE_BINDINGS,
|
||||||
collectDocxTokenFonts,
|
collectDocxTokenFonts,
|
||||||
createDocxTokenSlotMap,
|
createDocxTokenSlotMap,
|
||||||
|
resolvePandocSyntaxStyleSlot,
|
||||||
resolveTokenFonts
|
resolveTokenFonts
|
||||||
} from "../src/index.js";
|
} from "../src/index.js";
|
||||||
|
|
||||||
@@ -72,5 +73,31 @@ describe("DOCX 令牌样式映射", () => {
|
|||||||
"Source Han Serif SC",
|
"Source Han Serif SC",
|
||||||
"Times New Roman"
|
"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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -442,7 +442,6 @@ function normalizeComputedSlot(
|
|||||||
style.spacingAfterPt = spacingAfterPt;
|
style.spacingAfterPt = spacingAfterPt;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (context.kind !== "inline") {
|
|
||||||
const padding = {
|
const padding = {
|
||||||
top: length(context, "padding-top", computed.paddingTop, {
|
top: length(context, "padding-top", computed.paddingTop, {
|
||||||
nonNegative: true
|
nonNegative: true
|
||||||
@@ -470,7 +469,6 @@ function normalizeComputedSlot(
|
|||||||
left: padding.left
|
left: padding.left
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
|
||||||
const containingBlockWidthPt = computed.containingBlockWidth
|
const containingBlockWidthPt = computed.containingBlockWidth
|
||||||
? parseCssLengthToPt(computed.containingBlockWidth)
|
? parseCssLengthToPt(computed.containingBlockWidth)
|
||||||
: undefined;
|
: undefined;
|
||||||
@@ -652,7 +650,8 @@ function applyBorderColor(
|
|||||||
function applyOverrides(
|
function applyOverrides(
|
||||||
slot: DocxStyleSlotName,
|
slot: DocxStyleSlotName,
|
||||||
styleInput: DocxSlotStyleToken,
|
styleInput: DocxSlotStyleToken,
|
||||||
overrides: DocxThemeStyleOverrides
|
overrides: DocxThemeStyleOverrides,
|
||||||
|
replaceExisting: boolean
|
||||||
): {
|
): {
|
||||||
style: DocxSlotStyleToken;
|
style: DocxSlotStyleToken;
|
||||||
applied: boolean;
|
applied: boolean;
|
||||||
@@ -663,7 +662,10 @@ function applyOverrides(
|
|||||||
key: Key,
|
key: Key,
|
||||||
value: MutableStyle[Key] | undefined
|
value: MutableStyle[Key] | undefined
|
||||||
) => {
|
) => {
|
||||||
if (value !== undefined) {
|
if (
|
||||||
|
value !== undefined &&
|
||||||
|
(replaceExisting || style[key] === undefined)
|
||||||
|
) {
|
||||||
style[key] = value;
|
style[key] = value;
|
||||||
applied = true;
|
applied = true;
|
||||||
}
|
}
|
||||||
@@ -887,7 +889,10 @@ export function resolveDocxThemeTokens(
|
|||||||
const override = applyOverrides(
|
const override = applyOverrides(
|
||||||
definition.name,
|
definition.name,
|
||||||
resolved.style,
|
resolved.style,
|
||||||
input.config.overrides
|
input.config.overrides,
|
||||||
|
input.config.mode === "explicit" ||
|
||||||
|
!input.config.legacyPreset ||
|
||||||
|
resolved.source === "preset-fallback"
|
||||||
);
|
);
|
||||||
if (override.applied) {
|
if (override.applied) {
|
||||||
diagnostics.push({
|
diagnostics.push({
|
||||||
@@ -907,6 +912,43 @@ export function resolveDocxThemeTokens(
|
|||||||
return resolved;
|
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({
|
return docxThemeTokenSetSchema.parse({
|
||||||
schemaVersion: 1,
|
schemaVersion: 1,
|
||||||
themeId: snapshot.themeId,
|
themeId: snapshot.themeId,
|
||||||
|
|||||||
@@ -34,8 +34,8 @@ export function createDocxStyleProbeMarkup(): string {
|
|||||||
</p>
|
</p>
|
||||||
<ul ${attribute("unordered-list")}><li ${attribute("list-item")}>无序列表</li></ul>
|
<ul ${attribute("unordered-list")}><li ${attribute("list-item")}>无序列表</li></ul>
|
||||||
<ol ${attribute("ordered-list")}><li>有序列表</li></ol>
|
<ol ${attribute("ordered-list")}><li>有序列表</li></ol>
|
||||||
<blockquote ${attribute("block-quote")}><p>引用内容</p></blockquote>
|
<blockquote ${attribute("block-quote")}><p ${attribute("block-quote-text")}>引用内容</p></blockquote>
|
||||||
<pre class="md-fences" ${attribute("code-block")}><code>const value = 1;</code></pre>
|
<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")}>"value"</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")}>
|
<table ${attribute("table")}>
|
||||||
<thead><tr><th ${attribute("table-header")}>表头</th></tr></thead>
|
<thead><tr><th ${attribute("table-header")}>表头</th></tr></thead>
|
||||||
<tbody><tr><td ${attribute("table-cell")}>单元格</td></tr></tbody>
|
<tbody><tr><td ${attribute("table-cell")}>单元格</td></tr></tbody>
|
||||||
@@ -61,7 +61,7 @@ export function createDocxStyleProbeMarkup(): string {
|
|||||||
</section>
|
</section>
|
||||||
<section class="docx-probe-briefing">
|
<section class="docx-probe-briefing">
|
||||||
<header class="doc-masthead doc-masthead-briefing" ${attribute("briefing-masthead")}>
|
<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>2026年7月30日</time></div>
|
<div class="doc-briefing-meta" ${attribute("briefing-meta")}><span>第1期</span><span>示例单位</span><time>2026年7月30日</time></div>
|
||||||
</header>
|
</header>
|
||||||
<h1 class="doc-title" ${attribute("briefing-title")}>简报标题</h1>
|
<h1 class="doc-title" ${attribute("briefing-title")}>简报标题</h1>
|
||||||
|
|||||||
@@ -118,7 +118,6 @@ body {
|
|||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
font-family: inherit;
|
|
||||||
font-size: inherit;
|
font-size: inherit;
|
||||||
font-weight: inherit;
|
font-weight: inherit;
|
||||||
line-height: inherit;
|
line-height: inherit;
|
||||||
|
|||||||
@@ -19,7 +19,22 @@ export const DOCX_STYLE_SLOT_NAMES = [
|
|||||||
"ordered-list",
|
"ordered-list",
|
||||||
"list-item",
|
"list-item",
|
||||||
"block-quote",
|
"block-quote",
|
||||||
|
"block-quote-text",
|
||||||
"code-block",
|
"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",
|
||||||
"table-header",
|
"table-header",
|
||||||
"table-cell",
|
"table-cell",
|
||||||
@@ -42,6 +57,7 @@ export const DOCX_STYLE_SLOT_NAMES = [
|
|||||||
"official-copy-to",
|
"official-copy-to",
|
||||||
"official-printing-row",
|
"official-printing-row",
|
||||||
"briefing-masthead",
|
"briefing-masthead",
|
||||||
|
"briefing-masthead-text",
|
||||||
"briefing-meta",
|
"briefing-meta",
|
||||||
"briefing-title",
|
"briefing-title",
|
||||||
"briefing-contact",
|
"briefing-contact",
|
||||||
@@ -121,7 +137,22 @@ const kinds: Record<DocxStyleSlotName, DocxStyleSlotKind> = {
|
|||||||
"ordered-list": "list",
|
"ordered-list": "list",
|
||||||
"list-item": "list",
|
"list-item": "list",
|
||||||
"block-quote": "paragraph",
|
"block-quote": "paragraph",
|
||||||
|
"block-quote-text": "paragraph",
|
||||||
"code-block": "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: "table",
|
||||||
"table-header": "table",
|
"table-header": "table",
|
||||||
"table-cell": "table",
|
"table-cell": "table",
|
||||||
@@ -144,6 +175,7 @@ const kinds: Record<DocxStyleSlotName, DocxStyleSlotKind> = {
|
|||||||
"official-copy-to": "structure",
|
"official-copy-to": "structure",
|
||||||
"official-printing-row": "structure",
|
"official-printing-row": "structure",
|
||||||
"briefing-masthead": "structure",
|
"briefing-masthead": "structure",
|
||||||
|
"briefing-masthead-text": "structure",
|
||||||
"briefing-meta": "structure",
|
"briefing-meta": "structure",
|
||||||
"briefing-title": "structure",
|
"briefing-title": "structure",
|
||||||
"briefing-contact": "structure",
|
"briefing-contact": "structure",
|
||||||
|
|||||||
@@ -175,6 +175,12 @@ describe("DOCX 主题令牌归一化", () => {
|
|||||||
});
|
});
|
||||||
expect(findSlot(tokens, "strong").style)
|
expect(findSlot(tokens, "strong").style)
|
||||||
.not.toHaveProperty("firstLineIndentPt");
|
.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(findSlot(tokens, "table").style.widthPercent).toBe(100);
|
||||||
expect(tokens.slots).toHaveLength(DOCX_STYLE_SLOT_NAMES.length);
|
expect(tokens.slots).toHaveLength(DOCX_STYLE_SLOT_NAMES.length);
|
||||||
});
|
});
|
||||||
@@ -204,6 +210,43 @@ describe("DOCX 主题令牌归一化", () => {
|
|||||||
expect(findSlot(tokens, "table").style.widthPercent).toBe(100);
|
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("按父级内容区归一化右置百分比结构容器", () => {
|
it("按父级内容区归一化右置百分比结构容器", () => {
|
||||||
const tokens = resolveDocxThemeTokens({
|
const tokens = resolveDocxThemeTokens({
|
||||||
snapshot: createSnapshot({
|
snapshot: createSnapshot({
|
||||||
@@ -356,7 +399,7 @@ describe("DOCX 主题令牌归一化", () => {
|
|||||||
).toBe(true);
|
).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("以清单覆盖自动值并在未命中时显式降级", () => {
|
it("旧版清单仅补齐自动值,并在未命中时显式降级", () => {
|
||||||
const tokens = resolveDocxThemeTokens({
|
const tokens = resolveDocxThemeTokens({
|
||||||
snapshot: createSnapshot({ caption: null }),
|
snapshot: createSnapshot({ caption: null }),
|
||||||
config: {
|
config: {
|
||||||
@@ -376,15 +419,15 @@ describe("DOCX 主题令牌归一化", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
expect(findSlot(tokens, "paragraph")).toMatchObject({
|
expect(findSlot(tokens, "paragraph")).toMatchObject({
|
||||||
source: "manifest-override",
|
source: "computed-css",
|
||||||
confidence: "exact",
|
confidence: "exact",
|
||||||
style: {
|
style: {
|
||||||
fontSizePt: 11,
|
fontSizePt: 12,
|
||||||
firstLineIndentPt: 22
|
firstLineIndentPt: 24
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
expect(findSlot(tokens, "hyperlink").style).toMatchObject({
|
expect(findSlot(tokens, "hyperlink").style).toMatchObject({
|
||||||
color: "#aa0000",
|
color: "#112233",
|
||||||
underline: false
|
underline: false
|
||||||
});
|
});
|
||||||
expect(findSlot(tokens, "caption")).toMatchObject({
|
expect(findSlot(tokens, "caption")).toMatchObject({
|
||||||
@@ -401,6 +444,24 @@ describe("DOCX 主题令牌归一化", () => {
|
|||||||
).toBe(true);
|
).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,仅使用覆盖与预设降级", () => {
|
it("显式模式不读取 CSS,仅使用覆盖与预设降级", () => {
|
||||||
const tokens = resolveDocxThemeTokens({
|
const tokens = resolveDocxThemeTokens({
|
||||||
snapshot: createSnapshot(),
|
snapshot: createSnapshot(),
|
||||||
|
|||||||
@@ -77,6 +77,8 @@ describe("DOCX 标准样式探针", () => {
|
|||||||
expect(markup).toMatch(
|
expect(markup).toMatch(
|
||||||
/^<article id="write"[^>]*>\s*<h1 data-docx-slot="heading-1">/u
|
/^<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("拒绝缺失和重复槽位的探针", () => {
|
it("拒绝缺失和重复槽位的探针", () => {
|
||||||
|
|||||||
@@ -84,6 +84,7 @@ describe("DOCX 主题样式浏览器运行时", () => {
|
|||||||
expect(script).toContain("contentPaddingLeft");
|
expect(script).toContain("contentPaddingLeft");
|
||||||
expect(script).toContain("padding-left: 8px");
|
expect(script).toContain("padding-left: 8px");
|
||||||
expect(script).toContain("#write pre.md-fences \\u003e code");
|
expect(script).toContain("#write pre.md-fences \\u003e code");
|
||||||
|
expect(script).not.toContain("font-family: inherit");
|
||||||
expect(script).toContain("frame.remove()");
|
expect(script).toContain("frame.remove()");
|
||||||
expect(script).not.toContain("</style><script>x</script>");
|
expect(script).not.toContain("</style><script>x</script>");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,6 +7,11 @@ import {
|
|||||||
type DocxMediaCaptureTarget,
|
type DocxMediaCaptureTarget,
|
||||||
type DocxMediaAlignment,
|
type DocxMediaAlignment,
|
||||||
type DocxMediaKind,
|
type DocxMediaKind,
|
||||||
|
type DocxDocumentLayoutPlan,
|
||||||
|
type DocxInlineCodeLayout,
|
||||||
|
type DocxListItemLayout,
|
||||||
|
type DocxTableLayout,
|
||||||
|
type DocxTextBlockLayout,
|
||||||
type PagedDocumentPayload
|
type PagedDocumentPayload
|
||||||
} from "@md-to-pdf/core";
|
} from "@md-to-pdf/core";
|
||||||
import { PagedDocumentRuntime } from "./paged-document-runtime.js";
|
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(
|
export function collectDocxMediaCaptureTargets(
|
||||||
root: ParentNode,
|
root: ParentNode,
|
||||||
dimensions: DocxMediaRenderDimensions
|
dimensions: DocxMediaRenderDimensions
|
||||||
@@ -226,7 +659,8 @@ export async function renderDocxMediaCapturePlan(
|
|||||||
dimensions: DocxMediaRenderDimensions
|
dimensions: DocxMediaRenderDimensions
|
||||||
): Promise<DocxMediaCapturePlan> {
|
): Promise<DocxMediaCapturePlan> {
|
||||||
const renderResult = await runtime.renderContinuous(payload, {
|
const renderResult = await runtime.renderContinuous(payload, {
|
||||||
geometryCss: createGeometryCss(dimensions)
|
geometryCss: createGeometryCss(dimensions),
|
||||||
|
themeMedia: "print"
|
||||||
});
|
});
|
||||||
if (!renderResult) {
|
if (!renderResult) {
|
||||||
throw new Error("DOCX 媒体渲染已取消");
|
throw new Error("DOCX 媒体渲染已取消");
|
||||||
@@ -234,6 +668,7 @@ export async function renderDocxMediaCapturePlan(
|
|||||||
return {
|
return {
|
||||||
targets: collectDocxMediaCaptureTargets(root, dimensions),
|
targets: collectDocxMediaCaptureTargets(root, dimensions),
|
||||||
echartsErrors: renderResult.echartsErrors,
|
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,5 +1,6 @@
|
|||||||
export * from "./diagram-page-fit.js";
|
export * from "./diagram-page-fit.js";
|
||||||
export * from "./continuous-preview.js";
|
export * from "./continuous-preview.js";
|
||||||
|
export * from "./highlight-styles.js";
|
||||||
export * from "./document-image-fit.js";
|
export * from "./document-image-fit.js";
|
||||||
export * from "./docx-media-runtime.js";
|
export * from "./docx-media-runtime.js";
|
||||||
export * from "./echarts-page-fit.js";
|
export * from "./echarts-page-fit.js";
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ export interface PagedDocumentRenderOptions {
|
|||||||
export interface ContinuousDocumentRenderOptions {
|
export interface ContinuousDocumentRenderOptions {
|
||||||
shouldContinue?: () => boolean;
|
shouldContinue?: () => boolean;
|
||||||
geometryCss?: string;
|
geometryCss?: string;
|
||||||
|
themeMedia?: "preview" | "print";
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PreviewEngineStyles {
|
export interface PreviewEngineStyles {
|
||||||
@@ -195,6 +196,34 @@ function removeTrailingPdfPageBreak(container: ParentNode) {
|
|||||||
?.style.setProperty("break-after", "auto", "important");
|
?.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(
|
function createPagedRenderIdentity(
|
||||||
payload: PagedPreviewPayload,
|
payload: PagedPreviewPayload,
|
||||||
options: PagedDocumentRenderOptions
|
options: PagedDocumentRenderOptions
|
||||||
@@ -411,7 +440,8 @@ export class PagedDocumentRuntime {
|
|||||||
private applyContinuousStyles(
|
private applyContinuousStyles(
|
||||||
documentRef: Document,
|
documentRef: Document,
|
||||||
payload: PagedPreviewPayload,
|
payload: PagedPreviewPayload,
|
||||||
geometryCss = ""
|
geometryCss = "",
|
||||||
|
themeMedia: ContinuousDocumentRenderOptions["themeMedia"] = "preview"
|
||||||
) {
|
) {
|
||||||
const style =
|
const style =
|
||||||
this.continuousStyle ?? documentRef.createElement("style");
|
this.continuousStyle ?? documentRef.createElement("style");
|
||||||
@@ -421,7 +451,9 @@ export class PagedDocumentRuntime {
|
|||||||
this.styles.highlightCss,
|
this.styles.highlightCss,
|
||||||
this.styles.katexCss,
|
this.styles.katexCss,
|
||||||
this.styles.echartsCss,
|
this.styles.echartsCss,
|
||||||
enablePrintMediaForPreview(payload.themeCss),
|
themeMedia === "preview"
|
||||||
|
? enablePrintMediaForPreview(payload.themeCss)
|
||||||
|
: payload.themeCss,
|
||||||
documentInteractionCss,
|
documentInteractionCss,
|
||||||
continuousDocumentGeometryCss,
|
continuousDocumentGeometryCss,
|
||||||
geometryCss
|
geometryCss
|
||||||
@@ -897,6 +929,7 @@ export class PagedDocumentRuntime {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const finalizeStartedAt = performance.now();
|
const finalizeStartedAt = performance.now();
|
||||||
|
removeInheritedPagedSplitJustification(this.root);
|
||||||
const pageSequence = classifyPagedPages(this.root, payload);
|
const pageSequence = classifyPagedPages(this.root, payload);
|
||||||
pageCount = pageSequence.physicalPageCount;
|
pageCount = pageSequence.physicalPageCount;
|
||||||
applyPagedPageDecorations(this.root, payload, pageSequence);
|
applyPagedPageDecorations(this.root, payload, pageSequence);
|
||||||
@@ -954,7 +987,8 @@ export class PagedDocumentRuntime {
|
|||||||
this.applyContinuousStyles(
|
this.applyContinuousStyles(
|
||||||
documentRef,
|
documentRef,
|
||||||
payload,
|
payload,
|
||||||
options.geometryCss
|
options.geometryCss,
|
||||||
|
options.themeMedia
|
||||||
);
|
);
|
||||||
|
|
||||||
const template = documentRef.createElement("template");
|
const template = documentRef.createElement("template");
|
||||||
@@ -969,7 +1003,8 @@ export class PagedDocumentRuntime {
|
|||||||
const identity = JSON.stringify({
|
const identity = JSON.stringify({
|
||||||
themeCss: payload.themeCss,
|
themeCss: payload.themeCss,
|
||||||
mermaid: payload.exportConfig.mermaid,
|
mermaid: payload.exportConfig.mermaid,
|
||||||
geometryCss: options.geometryCss
|
geometryCss: options.geometryCss,
|
||||||
|
themeMedia: options.themeMedia ?? "preview"
|
||||||
});
|
});
|
||||||
const currentArticle =
|
const currentArticle =
|
||||||
this.root.querySelector<HTMLElement>(":scope > #write");
|
this.root.querySelector<HTMLElement>(":scope > #write");
|
||||||
|
|||||||
@@ -95,7 +95,6 @@ svg {
|
|||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
font-family: inherit;
|
|
||||||
font-size: inherit;
|
font-size: inherit;
|
||||||
font-weight: inherit;
|
font-weight: inherit;
|
||||||
line-height: inherit;
|
line-height: inherit;
|
||||||
@@ -548,6 +547,11 @@ html[data-render-target="pdf"] body {
|
|||||||
min-height: 0 !important;
|
min-height: 0 !important;
|
||||||
overflow: visible !important;
|
overflow: visible !important;
|
||||||
background: #fff !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 {
|
html[data-render-target="pdf"] .pagedjs_pages {
|
||||||
|
|||||||
@@ -1,7 +1,15 @@
|
|||||||
// @vitest-environment happy-dom
|
// @vitest-environment happy-dom
|
||||||
|
|
||||||
import { beforeEach, describe, expect, it } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { collectDocxMediaCaptureTargets } from "../src/index.js";
|
import {
|
||||||
|
collectDocxDocumentLayoutPlan,
|
||||||
|
collectDocxInlineCodeLayouts,
|
||||||
|
collectDocxListItemLayouts,
|
||||||
|
collectDocxTextBlockLayouts,
|
||||||
|
collectDocxMediaCaptureTargets,
|
||||||
|
removeInheritedPagedSplitJustification,
|
||||||
|
renderDocxMediaCapturePlan
|
||||||
|
} from "../src/index.js";
|
||||||
|
|
||||||
describe("DOCX 媒体捕获计划", () => {
|
describe("DOCX 媒体捕获计划", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -9,6 +17,74 @@ describe("DOCX 媒体捕获计划", () => {
|
|||||||
window.scrollTo(0, 0);
|
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", () => {
|
it("按文档顺序标记图片、Mermaid 和 ECharts", () => {
|
||||||
document.body.innerHTML = `
|
document.body.innerHTML = `
|
||||||
<article id="write">
|
<article id="write">
|
||||||
@@ -155,4 +231,236 @@ describe("DOCX 媒体捕获计划", () => {
|
|||||||
).toBeLessThanOrEqual(4096);
|
).toBeLessThanOrEqual(4096);
|
||||||
expect(target?.altText).toBe("图片 1");
|
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('html[data-render-target="pdf"]');
|
||||||
expect(css).toContain("height: auto !important");
|
expect(css).toContain("height: auto !important");
|
||||||
expect(css).toContain("overflow: visible !important");
|
expect(css).toContain("overflow: visible !important");
|
||||||
|
expect(css).toContain("page-break-after: auto !important");
|
||||||
expect(css).toContain("padding: 0");
|
expect(css).toContain("padding: 0");
|
||||||
expect(css).toContain("break-after: page");
|
expect(css).toContain("break-after: page");
|
||||||
expect(css).not.toContain("@media print");
|
expect(css).not.toContain("@media print");
|
||||||
@@ -243,6 +244,9 @@ describe("分页预览协议", () => {
|
|||||||
);
|
);
|
||||||
expect(documentBaseCss).toContain("background: transparent;");
|
expect(documentBaseCss).toContain("background: transparent;");
|
||||||
expect(documentBaseCss).toContain("font-size: inherit;");
|
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(
|
expect(documentBaseCss).not.toMatch(
|
||||||
/#write pre\.md-fences > code\s*\{[^}]*!important/su
|
/#write pre\.md-fences > code\s*\{[^}]*!important/su
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|||||||
const bundlePath = join(repositoryRoot, "font-packs", "bundle.json");
|
const bundlePath = join(repositoryRoot, "font-packs", "bundle.json");
|
||||||
const bundle = JSON.parse(await readFile(bundlePath, "utf8"));
|
const bundle = JSON.parse(await readFile(bundlePath, "utf8"));
|
||||||
const outputRoot = join(repositoryRoot, "output", "font-packs", "root");
|
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 (
|
if (
|
||||||
bundle.schemaVersion !== 1 ||
|
bundle.schemaVersion !== 1 ||
|
||||||
|
|||||||
@@ -157,9 +157,9 @@ export function getDocxFontGateFailures({
|
|||||||
if (embeddedPartCount === 0) {
|
if (embeddedPartCount === 0) {
|
||||||
failures.push(`主题 ${theme.id} 的 DOCX 没有嵌入字体部件`);
|
failures.push(`主题 ${theme.id} 的 DOCX 没有嵌入字体部件`);
|
||||||
}
|
}
|
||||||
if (declaredFaces.length > 0 && embeddedPartCount !== declaredFaces.length) {
|
if (declaredFaces.length > 0 && embeddedPartCount < declaredFaces.length) {
|
||||||
failures.push(
|
failures.push(
|
||||||
`主题 ${theme.id} 声明 ${declaredFaces.length} 个字体面,但 DOCX 实际嵌入 ${embeddedPartCount} 个部件`
|
`主题 ${theme.id} 声明 ${declaredFaces.length} 个字体面,但 DOCX 仅嵌入 ${embeddedPartCount} 个部件`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (embeddedPartCount > 0) {
|
if (embeddedPartCount > 0) {
|
||||||
|
|||||||
@@ -65,6 +65,20 @@ test("字体门禁区分缺少声明、缺少嵌入和 Office 未实际采用",
|
|||||||
engine: "word"
|
engine: "word"
|
||||||
});
|
});
|
||||||
assert.deepEqual(embedded, []);
|
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 套内置主题都声明字体且中文字体面不超过三个", () => {
|
test("14 套内置主题都声明字体且中文字体面不超过三个", () => {
|
||||||
|
|||||||
@@ -186,6 +186,28 @@ export async function stageRelease(options = {}) {
|
|||||||
`MorphDoc-${version}-x86_64-Setup.exe`,
|
`MorphDoc-${version}-x86_64-Setup.exe`,
|
||||||
`MorphDoc-${version}-x86_64.zip`
|
`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(
|
const releaseNotesSource = join(
|
||||||
repositoryRoot,
|
repositoryRoot,
|
||||||
"docs",
|
"docs",
|
||||||
@@ -195,6 +217,9 @@ export async function stageRelease(options = {}) {
|
|||||||
for (const name of desktopNames) {
|
for (const name of desktopNames) {
|
||||||
await assertReadableFile(join(desktopDirectory, name));
|
await assertReadableFile(join(desktopDirectory, name));
|
||||||
}
|
}
|
||||||
|
for (const artifact of fontPackArtifacts) {
|
||||||
|
await assertReadableFile(artifact.source);
|
||||||
|
}
|
||||||
await assertReadableFile(releaseNotesSource);
|
await assertReadableFile(releaseNotesSource);
|
||||||
|
|
||||||
await mkdir(stageDirectory, { recursive: true });
|
await mkdir(stageDirectory, { recursive: true });
|
||||||
@@ -202,6 +227,9 @@ export async function stageRelease(options = {}) {
|
|||||||
for (const name of desktopNames) {
|
for (const name of desktopNames) {
|
||||||
await copyFile(join(desktopDirectory, name), join(stageDirectory, name));
|
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"));
|
await copyFile(releaseNotesSource, join(stageDirectory, "RELEASE-NOTES.md"));
|
||||||
|
|
||||||
const deployName = `MorphDoc-Web-${version}-deploy.zip`;
|
const deployName = `MorphDoc-Web-${version}-deploy.zip`;
|
||||||
@@ -221,6 +249,7 @@ export async function stageRelease(options = {}) {
|
|||||||
|
|
||||||
const primaryNames = [
|
const primaryNames = [
|
||||||
...desktopNames,
|
...desktopNames,
|
||||||
|
...fontPackArtifacts.map((artifact) => artifact.name),
|
||||||
imageName,
|
imageName,
|
||||||
deployName,
|
deployName,
|
||||||
"RELEASE-NOTES.md"
|
"RELEASE-NOTES.md"
|
||||||
|
|||||||
@@ -11,6 +11,11 @@ import {
|
|||||||
parseArguments
|
parseArguments
|
||||||
} from "./stage-release.mjs";
|
} from "./stage-release.mjs";
|
||||||
|
|
||||||
|
const stageReleaseSource = await readFile(
|
||||||
|
new URL("./stage-release.mjs", import.meta.url),
|
||||||
|
"utf8"
|
||||||
|
);
|
||||||
|
|
||||||
test("normalizeVersion 接受语义版本并移除 v 前缀", () => {
|
test("normalizeVersion 接受语义版本并移除 v 前缀", () => {
|
||||||
assert.equal(normalizeVersion("v0.6.0"), "0.6.0");
|
assert.equal(normalizeVersion("v0.6.0"), "0.6.0");
|
||||||
assert.equal(normalizeVersion("1.2.3-beta.1"), "1.2.3-beta.1");
|
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 });
|
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);
|
||||||
|
});
|
||||||
|
|||||||
@@ -60,11 +60,21 @@ const selectedCaseId =
|
|||||||
process.env.MD_TO_PDF_LAYOUT_VISUAL_CASE?.trim() || undefined;
|
process.env.MD_TO_PDF_LAYOUT_VISUAL_CASE?.trim() || undefined;
|
||||||
const selectedScope =
|
const selectedScope =
|
||||||
process.env.MD_TO_PDF_LAYOUT_VISUAL_SCOPE?.trim() || "full";
|
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)
|
? cases.filter((entry) => entry.id === selectedCaseId)
|
||||||
: selectedScope === "cover"
|
: selectedScope === "cover"
|
||||||
? cases.filter((entry) => entry.coverPageCount > 0)
|
? cases.filter((entry) => entry.coverPageCount > 0)
|
||||||
: cases;
|
: cases;
|
||||||
|
const selectedCases = scopedCases.filter(
|
||||||
|
(entry) =>
|
||||||
|
(!selectedOrientation || entry.orientation === selectedOrientation) &&
|
||||||
|
(!selectedMarginScenarioId ||
|
||||||
|
entry.marginScenarioId === selectedMarginScenarioId)
|
||||||
|
);
|
||||||
|
|
||||||
function assert(condition, message) {
|
function assert(condition, message) {
|
||||||
if (!condition) {
|
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) {
|
function extractEditableContract(docxPath) {
|
||||||
const entries = unzipSync(Uint8Array.from(fs.readFileSync(docxPath)));
|
const entries = unzipSync(Uint8Array.from(fs.readFileSync(docxPath)));
|
||||||
const documentXml = entries["word/document.xml"];
|
const documentXml = entries["word/document.xml"];
|
||||||
@@ -161,6 +225,7 @@ function extractEditableContract(docxPath) {
|
|||||||
text,
|
text,
|
||||||
...(styleId ? { styleId } : {}),
|
...(styleId ? { styleId } : {}),
|
||||||
role: paragraphRole(styleId),
|
role: paragraphRole(styleId),
|
||||||
|
blockKind: paragraphBlockKind(paragraph, styleId),
|
||||||
section
|
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) {
|
function buildPageSemantics(pageCount, coverPageCount, config) {
|
||||||
assert(
|
assert(
|
||||||
pageCount > coverPageCount,
|
pageCount > coverPageCount,
|
||||||
@@ -481,6 +602,17 @@ assert(
|
|||||||
selectedScope === "full" || selectedScope === "cover",
|
selectedScope === "full" || selectedScope === "cover",
|
||||||
`不支持的视觉矩阵作用域:${selectedScope}`
|
`不支持的视觉矩阵作用域:${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 wordAdapter = createWordPdfAdapter({ timeoutMs: 240_000 });
|
||||||
const wpsAdapter = createWpsPdfAdapter({ timeoutMs: 240_000 });
|
const wpsAdapter = createWpsPdfAdapter({ timeoutMs: 240_000 });
|
||||||
const wordCapability = await wordAdapter.probe();
|
const wordCapability = await wordAdapter.probe();
|
||||||
@@ -578,6 +710,11 @@ for (const caseDefinition of selectedCases) {
|
|||||||
baselinePageSemantics: wordPageSemantics,
|
baselinePageSemantics: wordPageSemantics,
|
||||||
candidatePageSemantics: wpsPageSemantics
|
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");
|
const rasterDirectory = path.join(caseDirectory, "raster-pages");
|
||||||
fs.rmSync(rasterDirectory, { recursive: true, force: true });
|
fs.rmSync(rasterDirectory, { recursive: true, force: true });
|
||||||
@@ -605,6 +742,9 @@ for (const caseDefinition of selectedCases) {
|
|||||||
renderedFonts: wps.fonts,
|
renderedFonts: wps.fonts,
|
||||||
engine: "wps"
|
engine: "wps"
|
||||||
}),
|
}),
|
||||||
|
...inlineCodeContinuity.chromium.failures,
|
||||||
|
...inlineCodeContinuity.word.failures,
|
||||||
|
...inlineCodeContinuity.wps.failures,
|
||||||
...reportGateFailures(wordReport, "Chromium/Word"),
|
...reportGateFailures(wordReport, "Chromium/Word"),
|
||||||
...reportGateFailures(wpsReport, "Chromium/WPS"),
|
...reportGateFailures(wpsReport, "Chromium/WPS"),
|
||||||
...reportGateFailures(officeReport, "Word/WPS")
|
...reportGateFailures(officeReport, "Word/WPS")
|
||||||
@@ -647,6 +787,7 @@ for (const caseDefinition of selectedCases) {
|
|||||||
wps: wps.fonts
|
wps: wps.fonts
|
||||||
},
|
},
|
||||||
editableParagraphCount: editable.paragraphs.length,
|
editableParagraphCount: editable.paragraphs.length,
|
||||||
|
inlineCodeContinuity,
|
||||||
reports: {
|
reports: {
|
||||||
word: reportSummary(wordReport),
|
word: reportSummary(wordReport),
|
||||||
wps: reportSummary(wpsReport),
|
wps: reportSummary(wpsReport),
|
||||||
@@ -670,9 +811,13 @@ const summary = {
|
|||||||
matrixDefinition,
|
matrixDefinition,
|
||||||
selectedCaseId,
|
selectedCaseId,
|
||||||
selectedScope,
|
selectedScope,
|
||||||
|
selectedOrientation,
|
||||||
|
selectedMarginScenarioId,
|
||||||
execution: {
|
execution: {
|
||||||
mode: selectedCaseId
|
mode: selectedCaseId
|
||||||
? "single-case"
|
? "single-case"
|
||||||
|
: selectedOrientation || selectedMarginScenarioId
|
||||||
|
? "filtered-matrix"
|
||||||
: selectedScope === "cover"
|
: selectedScope === "cover"
|
||||||
? "cover-matrix"
|
? "cover-matrix"
|
||||||
: "full-matrix",
|
: "full-matrix",
|
||||||
@@ -693,6 +838,8 @@ const summaryPath = path.join(
|
|||||||
outputDirectory,
|
outputDirectory,
|
||||||
selectedCaseId
|
selectedCaseId
|
||||||
? `summary-${selectedCaseId}.json`
|
? `summary-${selectedCaseId}.json`
|
||||||
|
: selectedOrientation || selectedMarginScenarioId
|
||||||
|
? `summary-${selectedOrientation ?? "all"}-${selectedMarginScenarioId ?? "all"}.json`
|
||||||
: selectedScope === "cover"
|
: selectedScope === "cover"
|
||||||
? "summary-cover.json"
|
? "summary-cover.json"
|
||||||
: "summary.json"
|
: "summary.json"
|
||||||
@@ -701,6 +848,8 @@ const matrixHtmlPath = path.join(
|
|||||||
outputDirectory,
|
outputDirectory,
|
||||||
selectedCaseId
|
selectedCaseId
|
||||||
? `matrix-${selectedCaseId}.html`
|
? `matrix-${selectedCaseId}.html`
|
||||||
|
: selectedOrientation || selectedMarginScenarioId
|
||||||
|
? `matrix-${selectedOrientation ?? "all"}-${selectedMarginScenarioId ?? "all"}.html`
|
||||||
: selectedScope === "cover"
|
: selectedScope === "cover"
|
||||||
? "matrix-cover.html"
|
? "matrix-cover.html"
|
||||||
: "matrix.html"
|
: "matrix.html"
|
||||||
|
|||||||
Reference in New Issue
Block a user