/u
);
+ expect(markup).toContain(' {
diff --git a/packages/docx-theme-engine/tests/runtime.test.ts b/packages/docx-theme-engine/tests/runtime.test.ts
index 60bae3e..3607fb4 100644
--- a/packages/docx-theme-engine/tests/runtime.test.ts
+++ b/packages/docx-theme-engine/tests/runtime.test.ts
@@ -84,6 +84,7 @@ describe("DOCX 主题样式浏览器运行时", () => {
expect(script).toContain("contentPaddingLeft");
expect(script).toContain("padding-left: 8px");
expect(script).toContain("#write pre.md-fences \\u003e code");
+ expect(script).not.toContain("font-family: inherit");
expect(script).toContain("frame.remove()");
expect(script).not.toContain("");
});
diff --git a/packages/preview-engine/src/docx-media-runtime.ts b/packages/preview-engine/src/docx-media-runtime.ts
index 6ca5f7f..910c51a 100644
--- a/packages/preview-engine/src/docx-media-runtime.ts
+++ b/packages/preview-engine/src/docx-media-runtime.ts
@@ -7,6 +7,11 @@ import {
type DocxMediaCaptureTarget,
type DocxMediaAlignment,
type DocxMediaKind,
+ type DocxDocumentLayoutPlan,
+ type DocxInlineCodeLayout,
+ type DocxListItemLayout,
+ type DocxTableLayout,
+ type DocxTextBlockLayout,
type PagedDocumentPayload
} from "@md-to-pdf/core";
import { PagedDocumentRuntime } from "./paged-document-runtime.js";
@@ -145,6 +150,434 @@ function createGeometryCss(dimensions: DocxMediaRenderDimensions) {
`;
}
+function average(values: readonly number[], fallback: number) {
+ return values.length > 0
+ ? values.reduce((total, value) => total + value, 0) / values.length
+ : fallback;
+}
+
+function collectTableColumnWidths(table: HTMLTableElement, tableRect: DOMRect) {
+ const rows = Array.from(table.rows);
+ const columnCount = rows.reduce(
+ (maximum, row) =>
+ Math.max(
+ maximum,
+ Array.from(row.cells).reduce(
+ (total, cell) => total + Math.max(1, cell.colSpan),
+ 0
+ )
+ ),
+ 0
+ );
+ if (columnCount < 1) {
+ return [tableRect.width];
+ }
+ const boundaries = Array.from(
+ { length: columnCount + 1 },
+ () => [] as number[]
+ );
+ boundaries[0]!.push(0);
+ boundaries[columnCount]!.push(tableRect.width);
+ for (const row of rows) {
+ let column = 0;
+ for (const cell of Array.from(row.cells)) {
+ const span = Math.max(1, cell.colSpan);
+ const rect = cell.getBoundingClientRect();
+ boundaries[column]?.push(
+ Math.max(0, Math.min(tableRect.width, rect.left - tableRect.left))
+ );
+ column = Math.min(columnCount, column + span);
+ boundaries[column]?.push(
+ Math.max(0, Math.min(tableRect.width, rect.right - tableRect.left))
+ );
+ }
+ }
+ const resolved = boundaries.map((samples, index) =>
+ average(samples, (tableRect.width * index) / columnCount)
+ );
+ resolved[0] = 0;
+ resolved[columnCount] = tableRect.width;
+ for (let index = 1; index < resolved.length; index += 1) {
+ resolved[index] = Math.max(
+ resolved[index - 1]! + 0.01,
+ resolved[index]!
+ );
+ }
+ return resolved.slice(1).map(
+ (boundary, index) => boundary - resolved[index]!
+ );
+}
+
+function cssColor(value: string) {
+ const match = value.match(
+ /^rgba?\(\s*(\d+(?:\.\d+)?)\s*,\s*(\d+(?:\.\d+)?)\s*,\s*(\d+(?:\.\d+)?)(?:\s*,\s*(\d+(?:\.\d+)?))?\s*\)$/iu
+ );
+ if (!match) {
+ return undefined;
+ }
+ const alpha = match[4] === undefined ? 1 : Number(match[4]);
+ if (!Number.isFinite(alpha) || alpha <= 0.01) {
+ return undefined;
+ }
+ return `#${[match[1], match[2], match[3]]
+ .map((component) =>
+ Math.max(0, Math.min(255, Math.round(Number(component))))
+ .toString(16)
+ .padStart(2, "0")
+ )
+ .join("")}`;
+}
+
+function effectiveCellBackground(
+ cell: HTMLTableCellElement,
+ table: HTMLTableElement
+) {
+ let current: Element | null = cell;
+ while (current) {
+ const color = cssColor(getComputedStyle(current).backgroundColor);
+ if (color) {
+ return color;
+ }
+ if (current === table) {
+ break;
+ }
+ current = current.parentElement;
+ }
+ return "#ffffff";
+}
+
+function cellAlignment(value: string): "left" | "center" | "right" | "justify" {
+ if (value === "center" || value === "right" || value === "justify") {
+ return value;
+ }
+ return "left";
+}
+
+const DOCX_TEXT_BLOCK_SELECTOR =
+ "h1, h2, h3, h4, h5, h6, p, li, th, td, figcaption, " +
+ ".doc-classification, .doc-issue-row, .doc-printing-row, " +
+ ".doc-briefing-meta";
+
+function textNodeCharacters(root: HTMLElement) {
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
+ const characters: Array<{
+ node: Text;
+ start: number;
+ end: number;
+ character: string;
+ }> = [];
+ let current = walker.nextNode();
+ while (current) {
+ const node = current as Text;
+ let offset = 0;
+ for (const character of Array.from(node.data)) {
+ const end = offset + character.length;
+ characters.push({ node, start: offset, end, character });
+ offset = end;
+ }
+ current = walker.nextNode();
+ }
+ return characters;
+}
+
+function characterRect(
+ range: Range,
+ character: ReturnType[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(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(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("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("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("#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("table")
+ ).map((table, index): DocxTableLayout => {
+ const rect = table.getBoundingClientRect();
+ const width = finitePositive(rect.width, contentWidth);
+ const columnWidths = collectTableColumnWidths(table, rect);
+ const columnTotal = columnWidths.reduce(
+ (total, value) => total + value,
+ 0
+ );
+ return {
+ ordinal: index + 1,
+ widthPercent: Math.min(100, (width / contentWidth) * 100),
+ leftOffsetPercent: Math.max(
+ 0,
+ Math.min(100, ((rect.left - articleRect.left) / contentWidth) * 100)
+ ),
+ columnWidthPercents: columnWidths.map(
+ (value) => (value / columnTotal) * 100
+ ),
+ rows: Array.from(table.rows).map((row) => ({
+ cells: Array.from(row.cells).map((cell) => {
+ const computed = getComputedStyle(cell);
+ return {
+ columnSpan: Math.max(1, cell.colSpan),
+ backgroundColor: effectiveCellBackground(cell, table),
+ color: cssColor(computed.color) ?? "#000000",
+ bold: Number.parseInt(computed.fontWeight, 10) >= 600 ||
+ computed.fontWeight === "bold",
+ italic: computed.fontStyle === "italic" ||
+ computed.fontStyle === "oblique",
+ alignment: cellAlignment(computed.textAlign)
+ };
+ })
+ }))
+ };
+ });
+ return {
+ tables,
+ textBlocks: collectDocxTextBlockLayouts(article),
+ listItems: collectDocxListItemLayouts(article),
+ inlineCodes: collectDocxInlineCodeLayouts(article)
+ };
+}
+
export function collectDocxMediaCaptureTargets(
root: ParentNode,
dimensions: DocxMediaRenderDimensions
@@ -226,7 +659,8 @@ export async function renderDocxMediaCapturePlan(
dimensions: DocxMediaRenderDimensions
): Promise {
const renderResult = await runtime.renderContinuous(payload, {
- geometryCss: createGeometryCss(dimensions)
+ geometryCss: createGeometryCss(dimensions),
+ themeMedia: "print"
});
if (!renderResult) {
throw new Error("DOCX 媒体渲染已取消");
@@ -234,6 +668,7 @@ export async function renderDocxMediaCapturePlan(
return {
targets: collectDocxMediaCaptureTargets(root, dimensions),
echartsErrors: renderResult.echartsErrors,
- mermaidErrors: renderResult.mermaidErrors
+ mermaidErrors: renderResult.mermaidErrors,
+ documentLayout: collectDocxDocumentLayoutPlan(root, dimensions)
};
}
diff --git a/packages/preview-engine/src/highlight-styles.ts b/packages/preview-engine/src/highlight-styles.ts
new file mode 100644
index 0000000..eff2a09
--- /dev/null
+++ b/packages/preview-engine/src/highlight-styles.ts
@@ -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();
diff --git a/packages/preview-engine/src/index.ts b/packages/preview-engine/src/index.ts
index 23e67ac..dfbb6d6 100644
--- a/packages/preview-engine/src/index.ts
+++ b/packages/preview-engine/src/index.ts
@@ -1,5 +1,6 @@
export * from "./diagram-page-fit.js";
export * from "./continuous-preview.js";
+export * from "./highlight-styles.js";
export * from "./document-image-fit.js";
export * from "./docx-media-runtime.js";
export * from "./echarts-page-fit.js";
diff --git a/packages/preview-engine/src/paged-document-runtime.ts b/packages/preview-engine/src/paged-document-runtime.ts
index 8d26316..faab15d 100644
--- a/packages/preview-engine/src/paged-document-runtime.ts
+++ b/packages/preview-engine/src/paged-document-runtime.ts
@@ -66,6 +66,7 @@ export interface PagedDocumentRenderOptions {
export interface ContinuousDocumentRenderOptions {
shouldContinue?: () => boolean;
geometryCss?: string;
+ themeMedia?: "preview" | "print";
}
export interface PreviewEngineStyles {
@@ -195,6 +196,34 @@ function removeTrailingPdfPageBreak(container: ParentNode) {
?.style.setProperty("break-after", "auto", "important");
}
+const PAGED_SPLIT_BLOCK_SELECTOR = [
+ "h1", "h2", "h3", "h4", "h5", "h6", "p", "ul", "ol", "li",
+ "blockquote", "table", "thead", "tbody", "tfoot", "tr", "th", "td",
+ "figure", "figcaption", "pre", "section", "article", "div"
+].join(",");
+
+export function removeInheritedPagedSplitJustification(
+ container: ParentNode
+) {
+ let count = 0;
+ for (const element of Array.from(
+ container.querySelectorAll(
+ '[data-align-last-split-element="justify"]'
+ )
+ )) {
+ if (
+ !Array.from(element.children).some((child) =>
+ child.matches(PAGED_SPLIT_BLOCK_SELECTOR)
+ )
+ ) {
+ continue;
+ }
+ element.removeAttribute("data-align-last-split-element");
+ count += 1;
+ }
+ return count;
+}
+
function createPagedRenderIdentity(
payload: PagedPreviewPayload,
options: PagedDocumentRenderOptions
@@ -411,7 +440,8 @@ export class PagedDocumentRuntime {
private applyContinuousStyles(
documentRef: Document,
payload: PagedPreviewPayload,
- geometryCss = ""
+ geometryCss = "",
+ themeMedia: ContinuousDocumentRenderOptions["themeMedia"] = "preview"
) {
const style =
this.continuousStyle ?? documentRef.createElement("style");
@@ -421,7 +451,9 @@ export class PagedDocumentRuntime {
this.styles.highlightCss,
this.styles.katexCss,
this.styles.echartsCss,
- enablePrintMediaForPreview(payload.themeCss),
+ themeMedia === "preview"
+ ? enablePrintMediaForPreview(payload.themeCss)
+ : payload.themeCss,
documentInteractionCss,
continuousDocumentGeometryCss,
geometryCss
@@ -897,6 +929,7 @@ export class PagedDocumentRuntime {
}
const finalizeStartedAt = performance.now();
+ removeInheritedPagedSplitJustification(this.root);
const pageSequence = classifyPagedPages(this.root, payload);
pageCount = pageSequence.physicalPageCount;
applyPagedPageDecorations(this.root, payload, pageSequence);
@@ -954,7 +987,8 @@ export class PagedDocumentRuntime {
this.applyContinuousStyles(
documentRef,
payload,
- options.geometryCss
+ options.geometryCss,
+ options.themeMedia
);
const template = documentRef.createElement("template");
@@ -969,7 +1003,8 @@ export class PagedDocumentRuntime {
const identity = JSON.stringify({
themeCss: payload.themeCss,
mermaid: payload.exportConfig.mermaid,
- geometryCss: options.geometryCss
+ geometryCss: options.geometryCss,
+ themeMedia: options.themeMedia ?? "preview"
});
const currentArticle =
this.root.querySelector(":scope > #write");
diff --git a/packages/preview-engine/src/paged-preview.ts b/packages/preview-engine/src/paged-preview.ts
index 38460a5..12a30c9 100644
--- a/packages/preview-engine/src/paged-preview.ts
+++ b/packages/preview-engine/src/paged-preview.ts
@@ -95,7 +95,6 @@ svg {
border-radius: 0;
background: transparent;
box-shadow: none;
- font-family: inherit;
font-size: inherit;
font-weight: inherit;
line-height: inherit;
@@ -548,6 +547,11 @@ html[data-render-target="pdf"] body {
min-height: 0 !important;
overflow: visible !important;
background: #fff !important;
+ border: 0 !important;
+ break-before: auto !important;
+ break-after: auto !important;
+ page-break-before: auto !important;
+ page-break-after: auto !important;
}
html[data-render-target="pdf"] .pagedjs_pages {
diff --git a/packages/preview-engine/tests/docx-media-runtime.test.ts b/packages/preview-engine/tests/docx-media-runtime.test.ts
index 11b76fb..77b771f 100644
--- a/packages/preview-engine/tests/docx-media-runtime.test.ts
+++ b/packages/preview-engine/tests/docx-media-runtime.test.ts
@@ -1,7 +1,15 @@
// @vitest-environment happy-dom
-import { beforeEach, describe, expect, it } from "vitest";
-import { collectDocxMediaCaptureTargets } from "../src/index.js";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import {
+ collectDocxDocumentLayoutPlan,
+ collectDocxInlineCodeLayouts,
+ collectDocxListItemLayouts,
+ collectDocxTextBlockLayouts,
+ collectDocxMediaCaptureTargets,
+ removeInheritedPagedSplitJustification,
+ renderDocxMediaCapturePlan
+} from "../src/index.js";
describe("DOCX 媒体捕获计划", () => {
beforeEach(() => {
@@ -9,6 +17,74 @@ describe("DOCX 媒体捕获计划", () => {
window.scrollTo(0, 0);
});
+ it("连续布局使用原始打印媒体主题 CSS", async () => {
+ const renderContinuous = vi.fn(async () => ({
+ echartsErrors: [],
+ mermaidErrors: []
+ }));
+ const root = document.createElement("main");
+ root.innerHTML = '';
+
+ await renderDocxMediaCapturePlan(
+ { renderContinuous } as never,
+ root,
+ {
+ articleHtml: '',
+ 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 = `
+
+
+ 标题
+ 跨页正文
+
+
+ `;
+ expect(
+ removeInheritedPagedSplitJustification(
+ document.querySelector("#pages")!
+ )
+ ).toBe(1);
+ expect(
+ document.querySelector("#write")?.hasAttribute(
+ "data-align-last-split-element"
+ )
+ ).toBe(false);
+ expect(
+ document.querySelector("p")?.getAttribute(
+ "data-align-last-split-element"
+ )
+ ).toBe("justify");
+ });
+
it("按文档顺序标记图片、Mermaid 和 ECharts", () => {
document.body.innerHTML = `
@@ -155,4 +231,236 @@ describe("DOCX 媒体捕获计划", () => {
).toBeLessThanOrEqual(4096);
expect(target?.altText).toBe("图片 1");
});
+
+ it("从真实 DOM 几何采集主题无关的表格列宽比例", () => {
+ document.body.innerHTML = `
+
+
+ 短列 较长内容列
+ 1 2
+
+
+ `;
+ const article = document.querySelector("#write")!;
+ const table = document.querySelector("table")!;
+ const cells = Array.from(table.querySelectorAll("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 = `
+
+ inline
+ block
+
+ `;
+ expect(
+ collectDocxInlineCodeLayouts(
+ document.querySelector("#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 = `
+
+ - 一级
代码- 二级项目
+
+ `;
+ const article = document.querySelector("#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 = `
+
+ 独立封面
+ 甲乙丙丁
+
+ `;
+ 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("#write")!
+ )
+ ).toEqual([
+ {
+ ordinal: 1,
+ text: "甲乙丙丁",
+ letterSpacingPt: 0,
+ alignment: "left",
+ linePitchPt: 15,
+ lineBreakOffsets: [2]
+ }
+ ]);
+ } finally {
+ rangePrototype.getBoundingClientRect = original;
+ }
+ });
});
diff --git a/packages/preview-engine/tests/paged-preview.test.ts b/packages/preview-engine/tests/paged-preview.test.ts
index a43c2c7..6084712 100644
--- a/packages/preview-engine/tests/paged-preview.test.ts
+++ b/packages/preview-engine/tests/paged-preview.test.ts
@@ -62,6 +62,7 @@ describe("分页预览协议", () => {
expect(css).toContain('html[data-render-target="pdf"]');
expect(css).toContain("height: auto !important");
expect(css).toContain("overflow: visible !important");
+ expect(css).toContain("page-break-after: auto !important");
expect(css).toContain("padding: 0");
expect(css).toContain("break-after: page");
expect(css).not.toContain("@media print");
@@ -243,6 +244,9 @@ describe("分页预览协议", () => {
);
expect(documentBaseCss).toContain("background: transparent;");
expect(documentBaseCss).toContain("font-size: inherit;");
+ expect(documentBaseCss).not.toMatch(
+ /#write pre\.md-fences > code,[\s\S]*?font-family:\s*inherit;/u
+ );
expect(documentBaseCss).not.toMatch(
/#write pre\.md-fences > code\s*\{[^}]*!important/su
);
diff --git a/scripts/build-font-packs.mjs b/scripts/build-font-packs.mjs
index e3789cd..93fed62 100644
--- a/scripts/build-font-packs.mjs
+++ b/scripts/build-font-packs.mjs
@@ -11,7 +11,7 @@ const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const bundlePath = join(repositoryRoot, "font-packs", "bundle.json");
const bundle = JSON.parse(await readFile(bundlePath, "utf8"));
const outputRoot = join(repositoryRoot, "output", "font-packs", "root");
-const appVersion = process.env.npm_package_version || "0.6.0";
+const appVersion = process.env.npm_package_version || "0.6.1";
if (
bundle.schemaVersion !== 1 ||
diff --git a/scripts/docx-layout-visual-matrix-cases.mjs b/scripts/docx-layout-visual-matrix-cases.mjs
index 76a3397..a691aea 100644
--- a/scripts/docx-layout-visual-matrix-cases.mjs
+++ b/scripts/docx-layout-visual-matrix-cases.mjs
@@ -157,9 +157,9 @@ export function getDocxFontGateFailures({
if (embeddedPartCount === 0) {
failures.push(`主题 ${theme.id} 的 DOCX 没有嵌入字体部件`);
}
- if (declaredFaces.length > 0 && embeddedPartCount !== declaredFaces.length) {
+ if (declaredFaces.length > 0 && embeddedPartCount < declaredFaces.length) {
failures.push(
- `主题 ${theme.id} 声明 ${declaredFaces.length} 个字体面,但 DOCX 实际嵌入 ${embeddedPartCount} 个部件`
+ `主题 ${theme.id} 声明 ${declaredFaces.length} 个字体面,但 DOCX 仅嵌入 ${embeddedPartCount} 个部件`
);
}
if (embeddedPartCount > 0) {
diff --git a/scripts/docx-layout-visual-matrix-cases.test.mjs b/scripts/docx-layout-visual-matrix-cases.test.mjs
index 6e2c521..b23644d 100644
--- a/scripts/docx-layout-visual-matrix-cases.test.mjs
+++ b/scripts/docx-layout-visual-matrix-cases.test.mjs
@@ -65,6 +65,20 @@ test("字体门禁区分缺少声明、缺少嵌入和 Office 未实际采用",
engine: "word"
});
assert.deepEqual(embedded, []);
+
+ const embeddedWithEngineFaces = getDocxFontGateFailures({
+ theme: {
+ id: "engine-faces",
+ docxFontFaces: [{ family: "FandolSong", aliases: [], weight: 400, style: "normal" }]
+ },
+ inspection: {
+ embeddedFontPartCount: 3,
+ embeddedFontNames: ["MdTP Serif SC", "MdTP Mono"]
+ },
+ renderedFonts: [{ name: "WPSEMBED1" }],
+ engine: "wps"
+ });
+ assert.deepEqual(embeddedWithEngineFaces, []);
});
test("14 套内置主题都声明字体且中文字体面不超过三个", () => {
diff --git a/scripts/stage-release.mjs b/scripts/stage-release.mjs
index 738722a..884d3a7 100644
--- a/scripts/stage-release.mjs
+++ b/scripts/stage-release.mjs
@@ -186,6 +186,28 @@ export async function stageRelease(options = {}) {
`MorphDoc-${version}-x86_64-Setup.exe`,
`MorphDoc-${version}-x86_64.zip`
];
+ const fontPackArtifacts = [
+ {
+ name: "MorphDoc-FontPack-mdtp-serif-sc-1.0.0-x86_64-Setup.exe",
+ source: join(
+ repositoryRoot,
+ "output",
+ "font-packs",
+ "windows",
+ "MorphDoc-FontPack-mdtp-serif-sc-1.0.0-x86_64-Setup.exe"
+ )
+ },
+ {
+ name: "MorphDoc-FontPack-mdtp-serif-sc-1.0.0.zip",
+ source: join(
+ repositoryRoot,
+ "output",
+ "font-packs",
+ "release",
+ "MorphDoc-FontPack-mdtp-serif-sc-1.0.0.zip"
+ )
+ }
+ ];
const releaseNotesSource = join(
repositoryRoot,
"docs",
@@ -195,6 +217,9 @@ export async function stageRelease(options = {}) {
for (const name of desktopNames) {
await assertReadableFile(join(desktopDirectory, name));
}
+ for (const artifact of fontPackArtifacts) {
+ await assertReadableFile(artifact.source);
+ }
await assertReadableFile(releaseNotesSource);
await mkdir(stageDirectory, { recursive: true });
@@ -202,6 +227,9 @@ export async function stageRelease(options = {}) {
for (const name of desktopNames) {
await copyFile(join(desktopDirectory, name), join(stageDirectory, name));
}
+ for (const artifact of fontPackArtifacts) {
+ await copyFile(artifact.source, join(stageDirectory, artifact.name));
+ }
await copyFile(releaseNotesSource, join(stageDirectory, "RELEASE-NOTES.md"));
const deployName = `MorphDoc-Web-${version}-deploy.zip`;
@@ -221,6 +249,7 @@ export async function stageRelease(options = {}) {
const primaryNames = [
...desktopNames,
+ ...fontPackArtifacts.map((artifact) => artifact.name),
imageName,
deployName,
"RELEASE-NOTES.md"
diff --git a/scripts/stage-release.test.mjs b/scripts/stage-release.test.mjs
index 1361016..52573eb 100644
--- a/scripts/stage-release.test.mjs
+++ b/scripts/stage-release.test.mjs
@@ -11,6 +11,11 @@ import {
parseArguments
} from "./stage-release.mjs";
+const stageReleaseSource = await readFile(
+ new URL("./stage-release.mjs", import.meta.url),
+ "utf8"
+);
+
test("normalizeVersion 接受语义版本并移除 v 前缀", () => {
assert.equal(normalizeVersion("v0.6.0"), "0.6.0");
assert.equal(normalizeVersion("1.2.3-beta.1"), "1.2.3-beta.1");
@@ -55,3 +60,15 @@ test("Web 离线部署 ZIP 包含 Compose 和导入说明", async () => {
await rm(directory, { recursive: true, force: true });
}
});
+
+test("正式发布强制归集独立字体安装器和 ZIP", () => {
+ assert.match(
+ stageReleaseSource,
+ /MorphDoc-FontPack-mdtp-serif-sc-1\.0\.0-x86_64-Setup\.exe/u
+ );
+ assert.match(
+ stageReleaseSource,
+ /MorphDoc-FontPack-mdtp-serif-sc-1\.0\.0\.zip/u
+ );
+ assert.match(stageReleaseSource, /fontPackArtifacts\.map/u);
+});
diff --git a/scripts/verify-docx-layout-visual-matrix.mjs b/scripts/verify-docx-layout-visual-matrix.mjs
index e0ea24d..bb8b380 100644
--- a/scripts/verify-docx-layout-visual-matrix.mjs
+++ b/scripts/verify-docx-layout-visual-matrix.mjs
@@ -60,11 +60,21 @@ const selectedCaseId =
process.env.MD_TO_PDF_LAYOUT_VISUAL_CASE?.trim() || undefined;
const selectedScope =
process.env.MD_TO_PDF_LAYOUT_VISUAL_SCOPE?.trim() || "full";
-const selectedCases = selectedCaseId
+const selectedOrientation =
+ process.env.MD_TO_PDF_LAYOUT_VISUAL_ORIENTATION?.trim() || undefined;
+const selectedMarginScenarioId =
+ process.env.MD_TO_PDF_LAYOUT_VISUAL_MARGIN?.trim() || undefined;
+const scopedCases = selectedCaseId
? cases.filter((entry) => entry.id === selectedCaseId)
: selectedScope === "cover"
? cases.filter((entry) => entry.coverPageCount > 0)
: cases;
+const selectedCases = scopedCases.filter(
+ (entry) =>
+ (!selectedOrientation || entry.orientation === selectedOrientation) &&
+ (!selectedMarginScenarioId ||
+ entry.marginScenarioId === selectedMarginScenarioId)
+);
function assert(condition, message) {
if (!condition) {
@@ -121,6 +131,60 @@ function isInternalLayoutSpacerParagraph(descendants, text) {
);
}
+function hasAncestor(paragraph, localName) {
+ let current = paragraph.parentNode;
+ while (current) {
+ if (
+ current.localName === localName &&
+ current.namespaceURI === WORD_NAMESPACE
+ ) {
+ return true;
+ }
+ current = current.parentNode;
+ }
+ return false;
+}
+
+function isTableHeaderParagraph(paragraph) {
+ let current = paragraph.parentNode;
+ while (current) {
+ if (current.localName === "tr" && current.namespaceURI === WORD_NAMESPACE) {
+ return Array.from(current.getElementsByTagName("*")).some(
+ (node) =>
+ node.localName === "tblHeader" &&
+ node.namespaceURI === WORD_NAMESPACE
+ );
+ }
+ current = current.parentNode;
+ }
+ return false;
+}
+
+function paragraphBlockKind(paragraph, styleId) {
+ if (hasAncestor(paragraph, "tc")) {
+ return isTableHeaderParagraph(paragraph) ? "table-header" : "table-cell";
+ }
+ if (styleId === "SourceCode") {
+ return "code-block";
+ }
+ if (styleId === "BlockText") {
+ return "block-quote";
+ }
+ if (/(?:Caption)$/u.test(styleId)) {
+ return "caption";
+ }
+ if (/^Heading[1-6]$/u.test(styleId)) {
+ return "heading";
+ }
+ if (styleId === "Compact" || styleId === "ListParagraph") {
+ return "list-item";
+ }
+ if (/^Md(?:Official|Briefing|ProjectReport|Tender)/u.test(styleId)) {
+ return "semantic-region";
+ }
+ return "paragraph";
+}
+
function extractEditableContract(docxPath) {
const entries = unzipSync(Uint8Array.from(fs.readFileSync(docxPath)));
const documentXml = entries["word/document.xml"];
@@ -161,6 +225,7 @@ function extractEditableContract(docxPath) {
text,
...(styleId ? { styleId } : {}),
role: paragraphRole(styleId),
+ blockKind: paragraphBlockKind(paragraph, styleId),
section
});
}
@@ -217,6 +282,62 @@ function pageGeometryFailures(snapshot, caseDefinition, label) {
});
}
+const inlineCodeContinuityProbes = [
+ {
+ id: "paragraph",
+ code: "mdtp_ic_p",
+ continuousText: "段落前mdtp_ic_p段落后"
+ },
+ {
+ id: "list-item",
+ code: "mdtp_ic_l",
+ continuousText: "列表前mdtp_ic_l列表后"
+ },
+ {
+ id: "table-cell",
+ code: "mdtp_ic_c",
+ continuousText: "单元格前mdtp_ic_c单元格后"
+ }
+];
+
+function inspectInlineCodeContinuity(snapshot, label) {
+ const normalizedLines = snapshot.pages.flatMap((page) =>
+ page.lines.map((line) => ({
+ pageNumber: page.pageNumber,
+ text: line.text,
+ normalizedText: line.text.normalize("NFKC").replace(/\s+/gu, "")
+ }))
+ );
+ const probes = inlineCodeContinuityProbes.map((probe) => {
+ const matchingLines = normalizedLines.filter((line) =>
+ line.normalizedText.includes(probe.code)
+ );
+ const passed =
+ matchingLines.length === 1 &&
+ matchingLines[0].normalizedText.includes(probe.continuousText);
+ return {
+ ...probe,
+ passed,
+ matchingLines: matchingLines.map(({ pageNumber, text }) => ({
+ pageNumber,
+ text
+ }))
+ };
+ });
+ return {
+ label,
+ passed: probes.every((probe) => probe.passed),
+ probes,
+ failures: probes.flatMap((probe) =>
+ probe.passed
+ ? []
+ : [
+ `${label}: INLINE_CODE_CONTINUITY_MISMATCH - ${probe.id} 行内代码未与前后文本保持同一视觉行`
+ ]
+ )
+ };
+}
+
function buildPageSemantics(pageCount, coverPageCount, config) {
assert(
pageCount > coverPageCount,
@@ -481,6 +602,17 @@ assert(
selectedScope === "full" || selectedScope === "cover",
`不支持的视觉矩阵作用域:${selectedScope}`
);
+assert(
+ !selectedOrientation || ["portrait", "landscape"].includes(selectedOrientation),
+ `不支持的视觉矩阵方向:${selectedOrientation}`
+);
+assert(
+ !selectedMarginScenarioId ||
+ matrixDefinition.marginScenarios.some(
+ (entry) => entry.id === selectedMarginScenarioId
+ ),
+ `不支持的视觉矩阵边距场景:${selectedMarginScenarioId}`
+);
const wordAdapter = createWordPdfAdapter({ timeoutMs: 240_000 });
const wpsAdapter = createWpsPdfAdapter({ timeoutMs: 240_000 });
const wordCapability = await wordAdapter.probe();
@@ -578,6 +710,11 @@ for (const caseDefinition of selectedCases) {
baselinePageSemantics: wordPageSemantics,
candidatePageSemantics: wpsPageSemantics
});
+ const inlineCodeContinuity = {
+ chromium: inspectInlineCodeContinuity(chromium, "Chromium"),
+ word: inspectInlineCodeContinuity(word, "Microsoft Word"),
+ wps: inspectInlineCodeContinuity(wps, "WPS Writer")
+ };
const rasterDirectory = path.join(caseDirectory, "raster-pages");
fs.rmSync(rasterDirectory, { recursive: true, force: true });
@@ -605,6 +742,9 @@ for (const caseDefinition of selectedCases) {
renderedFonts: wps.fonts,
engine: "wps"
}),
+ ...inlineCodeContinuity.chromium.failures,
+ ...inlineCodeContinuity.word.failures,
+ ...inlineCodeContinuity.wps.failures,
...reportGateFailures(wordReport, "Chromium/Word"),
...reportGateFailures(wpsReport, "Chromium/WPS"),
...reportGateFailures(officeReport, "Word/WPS")
@@ -647,6 +787,7 @@ for (const caseDefinition of selectedCases) {
wps: wps.fonts
},
editableParagraphCount: editable.paragraphs.length,
+ inlineCodeContinuity,
reports: {
word: reportSummary(wordReport),
wps: reportSummary(wpsReport),
@@ -670,9 +811,13 @@ const summary = {
matrixDefinition,
selectedCaseId,
selectedScope,
+ selectedOrientation,
+ selectedMarginScenarioId,
execution: {
mode: selectedCaseId
? "single-case"
+ : selectedOrientation || selectedMarginScenarioId
+ ? "filtered-matrix"
: selectedScope === "cover"
? "cover-matrix"
: "full-matrix",
@@ -693,6 +838,8 @@ const summaryPath = path.join(
outputDirectory,
selectedCaseId
? `summary-${selectedCaseId}.json`
+ : selectedOrientation || selectedMarginScenarioId
+ ? `summary-${selectedOrientation ?? "all"}-${selectedMarginScenarioId ?? "all"}.json`
: selectedScope === "cover"
? "summary-cover.json"
: "summary.json"
@@ -701,6 +848,8 @@ const matrixHtmlPath = path.join(
outputDirectory,
selectedCaseId
? `matrix-${selectedCaseId}.html`
+ : selectedOrientation || selectedMarginScenarioId
+ ? `matrix-${selectedOrientation ?? "all"}-${selectedMarginScenarioId ?? "all"}.html`
: selectedScope === "cover"
? "matrix-cover.html"
: "matrix.html"
标题
+跨页正文
+| 短列 | 较长内容列 |
| 1 | 2 |
inline
block
+ - 一级
代码- 二级项目
独立封面
甲乙丙丁
+