release: 发布 v0.6.2 DOCX 真实文档修复

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

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

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

验证结果:合成基线与长庆严格 280/280,M4N 140/140;健康数据残余误报 6/115(5.22%),均核查为重复表头自动对齐/取样误报且基础设施错误为 0。全项目测试、类型检查、生产构建和 git diff --check 通过;正式 Docker、NSIS、ZIP、离线镜像、部署包、清单及 SHA-256 均已生成并校验。
This commit is contained in:
SkyJourney
2026-08-26 10:50:20 +08:00
parent 01b06abc2c
commit 64445322eb
75 changed files with 9255 additions and 298 deletions
@@ -3,6 +3,7 @@ const CHUNK_CLASS = "md-code-pagination-chunk";
const CHUNK_POSITION_ATTRIBUTE = "data-code-pagination-position";
const LINE_GROUP_CLASS = "md-code-line-group";
const LINE_CLASS = "md-code-line";
const LINE_INDENT_CLASS = "md-code-line-indent";
function splitNodeIntoLines(node: Node): Node[][] {
const documentRef = node.ownerDocument;
@@ -47,6 +48,56 @@ function lineHasContent(nodes: Node[]) {
return nodes.some((node) => (node.textContent ?? "").length > 0);
}
function stabilizeLeadingWhitespace(
documentRef: Document,
nodes: Node[]
) {
let indent = "";
let index = 0;
for (; index < nodes.length; index += 1) {
const node = nodes[index];
if (!node || node.nodeType !== node.TEXT_NODE) {
break;
}
const value = node.textContent ?? "";
const match = value.match(/^[\t ]+/u);
if (!match) {
if (value.length === 0) {
continue;
}
break;
}
indent += match[0];
const remainder = value.slice(match[0].length);
if (remainder) {
node.textContent = remainder;
break;
}
}
if (!indent) {
return nodes;
}
const indentElement = documentRef.createElement("span");
indentElement.className = LINE_INDENT_CLASS;
let columns = 0;
for (const character of indent) {
columns = character === "\t"
? columns + (4 - columns % 4)
: columns + 1;
}
indentElement.dataset.codeIndentColumns = String(columns);
indentElement.style.width = `${columns}ch`;
indentElement.textContent = indent;
return [
indentElement,
...nodes.slice(index).filter(
(node) =>
node.nodeType !== node.TEXT_NODE ||
(node.textContent ?? "").length > 0
)
];
}
function createLineGroups(
documentRef: Document,
lines: Node[][]
@@ -68,7 +119,12 @@ function createLineGroups(
) {
const line = documentRef.createElement("span");
line.className = LINE_CLASS;
line.append(...(lines[lineIndex] ?? []));
line.append(
...stabilizeLeadingWhitespace(
documentRef,
lines[lineIndex] ?? []
)
);
group.append(line);
}
+302 -38
View File
@@ -1,13 +1,14 @@
import {
DOCX_MEDIA_RASTER_SCALE,
MAXIMUM_DOCX_MEDIA_EDGE_PIXELS,
MAXIMUM_DOCX_MEDIA_COUNT,
MAXIMUM_DOCX_MEDIA_PIXELS,
MAXIMUM_DOCX_RESOURCE_COUNT,
type DocxMediaCapturePlan,
type DocxMediaCaptureTarget,
type DocxMediaAlignment,
type DocxMediaKind,
type DocxDocumentLayoutPlan,
type DocxEmojiRunLayout,
type DocxInlineCodeLayout,
type DocxListItemLayout,
type DocxTableLayout,
@@ -15,12 +16,19 @@ import {
type PagedDocumentPayload
} from "@md-to-pdf/core";
import { PagedDocumentRuntime } from "./paged-document-runtime.js";
import { stabilizePagedTableColumns } from "./paged-table-handler.js";
export interface DocxMediaRenderDimensions {
contentWidthPx: number;
contentHeightPx: number;
}
export type DocxTableGeometry = Pick<
DocxTableLayout,
"ordinal" | "widthPercent" | "leftOffsetPercent" |
"columnWidthPercents"
>;
declare global {
interface Window {
__mdToPdfRenderDocxMedia?: (
@@ -157,6 +165,23 @@ function average(values: readonly number[], fallback: number) {
}
function collectTableColumnWidths(table: HTMLTableElement, tableRect: DOMRect) {
const stabilizedColumns = Array.from(
table.querySelectorAll<HTMLTableColElement>(
":scope > colgroup[data-stabilized-columns=\"true\"] > col"
)
);
const stabilizedWidths = stabilizedColumns.map((column) =>
Number(column.dataset.stabilizedWidthPx)
);
if (
stabilizedWidths.length > 0 &&
stabilizedWidths.every((width) => Number.isFinite(width) && width > 0)
) {
// 这组轨道来自分页前的完整自动布局。后续为稳定 Paged.js 克隆
// 写入的 col/cell 辅助宽度可能反过来扰动源表二次测量,DOCX 必须
// 使用最初与 Chromium 分页基线一致的列轨,而不是辅助样式后的值。
return stabilizedWidths;
}
const rows = Array.from(table.rows);
const columnCount = rows.reduce(
(maximum, row) =>
@@ -255,7 +280,7 @@ function cellAlignment(value: string): "left" | "center" | "right" | "justify" {
const DOCX_TEXT_BLOCK_SELECTOR =
"h1, h2, h3, h4, h5, h6, p, li, th, td, figcaption, " +
".doc-classification, .doc-issue-row, .doc-printing-row, " +
".md-alert-text, .doc-classification, .doc-issue-row, .doc-printing-row, " +
".doc-briefing-meta";
function textNodeCharacters(root: HTMLElement) {
@@ -293,9 +318,19 @@ function characterRect(
return rect.width > 0 && rect.height > 0 ? rect : undefined;
}
function hasDistributedLastLine(
_element: HTMLElement,
computed: CSSStyleDeclaration,
_lastLine: readonly DOMRect[]
) {
const textAlignLast = computed.textAlignLast.trim().toLowerCase();
return textAlignLast === "justify" || textAlignLast === "distribute";
}
export function collectDocxTextBlockLayouts(
article: HTMLElement
): DocxTextBlockLayout[] {
const articleRect = article.getBoundingClientRect();
const candidates = Array.from(
article.querySelectorAll<HTMLElement>(DOCX_TEXT_BLOCK_SELECTOR)
).filter((element) => {
@@ -350,39 +385,33 @@ export function collectDocxTextBlockLayouts(
}
const text = output.join("").trim();
const computed = getComputedStyle(element);
const alert = element.closest<HTMLElement>(".md-alert");
const alertRole = element.matches(".md-alert-text")
? "title" as const
: alert
? "body" as const
: undefined;
const alertRect = alert?.getBoundingClientRect();
const alertComputed = alert ? getComputedStyle(alert) : undefined;
const alertLeft = alertRect && alertComputed
? alertRect.left +
Number.parseFloat(alertComputed.borderLeftWidth || "0") +
Number.parseFloat(alertComputed.paddingLeft || "0")
: undefined;
const alertRight = alertRect && alertComputed
? alertRect.right -
Number.parseFloat(alertComputed.borderRightWidth || "0") -
Number.parseFloat(alertComputed.paddingRight || "0")
: undefined;
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 distributed = hasDistributedLastLine(
element,
computed,
lastLine
);
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))
@@ -401,6 +430,19 @@ export function collectDocxTextBlockLayouts(
letterSpacingPt: Number.isFinite(letterSpacingPx)
? letterSpacingPx * 0.75
: 0,
...(alertRole && alertLeft !== undefined && alertRight !== undefined
? {
alertRole,
fontSizePt: cssPixelsToPoints(computed.fontSize),
bold: computed.fontWeight === "bold" ||
Number.parseInt(computed.fontWeight, 10) >= 600,
italic: computed.fontStyle === "italic" ||
computed.fontStyle === "oblique",
color: cssColor(computed.color) ?? "#000000",
leftIndentPt: Math.max(0, (alertLeft - articleRect.left) * 0.75),
rightIndentPt: Math.max(0, (articleRect.right - alertRight) * 0.75)
}
: {}),
alignment: distributed
? "distribute" as const
: cellAlignment(computed.textAlign),
@@ -447,6 +489,18 @@ export function collectDocxListItemLayouts(
if (!rect) {
return [];
}
const visibleRects = characters.flatMap((character) => {
const characterBounds = characterRect(range, character);
return characterBounds ? [characterBounds] : [];
});
const lastLineTop = visibleRects.length > 0
? Math.max(...visibleRects.map((bounds) => bounds.top))
: 0;
const lastLine = visibleRects.filter(
(bounds) => Math.abs(bounds.top - lastLineTop) <= 1
);
const computed = getComputedStyle(item);
const distributed = hasDistributedLastLine(item, computed, lastLine);
let listDepth = -1;
for (
let ancestor: Element | null = item.parentElement;
@@ -464,7 +518,8 @@ export function collectDocxListItemLayouts(
textStartPt: Math.max(
0,
(rect.left - articleLeft) * 0.75
)
),
...(distributed ? { alignment: "distribute" as const } : {})
}];
}
);
@@ -475,6 +530,86 @@ function cssPixelsToPoints(value: string) {
return Number.isFinite(pixels) ? pixels * 0.75 : 0;
}
const EMOJI_ONLY_TEXT = /^(?:\p{Extended_Pictographic}|\p{Emoji_Modifier}|\u200d|\ufe0f|\s)+$/u;
function sampledEmojiColor(text: string, computed: CSSStyleDeclaration) {
const canvas = document.createElement("canvas");
const context = canvas.getContext("2d", { willReadFrequently: true });
if (!context) {
return undefined;
}
context.font = computed.font;
const metrics = context.measureText(text);
const fontSize = Math.max(12, Number.parseFloat(computed.fontSize) || 16);
canvas.width = Math.max(1, Math.ceil(metrics.width + fontSize));
canvas.height = Math.max(1, Math.ceil(fontSize * 2));
const renderContext = canvas.getContext("2d", { willReadFrequently: true });
if (!renderContext) {
return undefined;
}
renderContext.clearRect(0, 0, canvas.width, canvas.height);
renderContext.font = computed.font;
renderContext.textBaseline = "middle";
renderContext.fillStyle = computed.color;
renderContext.fillText(text, fontSize / 2, canvas.height / 2);
const pixels = renderContext.getImageData(
0,
0,
canvas.width,
canvas.height
).data;
const histogram = new Map<string, number>();
for (let index = 0; index < pixels.length; index += 4) {
const alpha = pixels[index + 3] ?? 0;
if (alpha < 96) {
continue;
}
const red = pixels[index] ?? 0;
const green = pixels[index + 1] ?? 0;
const blue = pixels[index + 2] ?? 0;
if (Math.max(red, green, blue) - Math.min(red, green, blue) < 16) {
continue;
}
const key = [red, green, blue]
.map((value) => Math.round(value / 8) * 8)
.join(",");
histogram.set(key, (histogram.get(key) ?? 0) + alpha);
}
const dominant = Array.from(histogram.entries()).sort(
([, left], [, right]) => right - left
)[0]?.[0];
if (!dominant) {
return undefined;
}
const channels = dominant.split(",").map(Number);
if (channels.length !== 3 || channels.some((value) => !Number.isFinite(value))) {
return undefined;
}
return `#${channels
.map((value) => Math.min(255, value).toString(16).padStart(2, "0"))
.join("")}`;
}
export function collectDocxEmojiRunLayouts(
article: HTMLElement
): DocxEmojiRunLayout[] {
const walker = document.createTreeWalker(article, NodeFilter.SHOW_TEXT);
const layouts: DocxEmojiRunLayout[] = [];
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
const text = (node.textContent ?? "").normalize("NFC").trim();
const parent = node.parentElement;
if (!text || !parent || !EMOJI_ONLY_TEXT.test(text)) {
continue;
}
const color = sampledEmojiColor(text, getComputedStyle(parent));
if (!color) {
continue;
}
layouts.push({ ordinal: layouts.length + 1, text, color });
}
return layouts;
}
export function collectDocxInlineCodeLayouts(
article: HTMLElement
): DocxInlineCodeLayout[] {
@@ -545,7 +680,7 @@ export function collectDocxDocumentLayoutPlan(
);
return {
ordinal: index + 1,
widthPercent: Math.min(100, (width / contentWidth) * 100),
widthPercent: Math.min(300, (width / contentWidth) * 100),
leftOffsetPercent: Math.max(
0,
Math.min(100, ((rect.left - articleRect.left) / contentWidth) * 100)
@@ -570,11 +705,112 @@ export function collectDocxDocumentLayoutPlan(
}))
};
});
const emojiRuns = collectDocxEmojiRunLayouts(article);
return {
tables,
textBlocks: collectDocxTextBlockLayouts(article),
listItems: collectDocxListItemLayouts(article),
inlineCodes: collectDocxInlineCodeLayouts(article)
inlineCodes: collectDocxInlineCodeLayouts(article),
...(emojiRuns.length > 0 ? { emojiRuns } : {})
};
}
export function collectPagedDocxTableGeometries(
root: ParentNode,
dimensions: DocxMediaRenderDimensions
): DocxTableGeometry[] {
const tablesByReference = new Map<string, HTMLTableElement[]>();
let anonymousTableIndex = 0;
for (const table of root.querySelectorAll<HTMLTableElement>(
".pagedjs_pages table"
)) {
const reference = table.dataset.ref ??
`__anonymous_table_${anonymousTableIndex += 1}`;
const tables = tablesByReference.get(reference) ?? [];
tables.push(table);
tablesByReference.set(reference, tables);
}
const geometries: DocxTableGeometry[] = [];
for (const tables of tablesByReference.values()) {
const table = tables.reduce((best, candidate) => {
const visibleArea = (element: HTMLTableElement) => {
const content = element.closest<HTMLElement>(
".pagedjs_area, #write"
);
const contentRect = content?.getBoundingClientRect();
const rect = element.getBoundingClientRect();
if (!contentRect) {
return Math.max(0, rect.width) * Math.max(0, rect.height);
}
const width = Math.max(
0,
Math.min(rect.right, contentRect.right) -
Math.max(rect.left, contentRect.left)
);
const height = Math.max(
0,
Math.min(rect.bottom, contentRect.bottom) -
Math.max(rect.top, contentRect.top)
);
return width * height;
};
return visibleArea(candidate) > visibleArea(best)
? candidate
: best;
});
const content = table.closest<HTMLElement>(
".pagedjs_area, #write"
);
const contentRect = content?.getBoundingClientRect();
const contentWidth = finitePositive(
contentRect?.width ?? 0,
dimensions.contentWidthPx
);
const contentLeft = contentRect?.left ?? 0;
const rect = table.getBoundingClientRect();
const width = finitePositive(rect.width, contentWidth);
const columnWidths = collectTableColumnWidths(table, rect);
const columnTotal = columnWidths.reduce(
(total, value) => total + value,
0
);
geometries.push({
ordinal: geometries.length + 1,
widthPercent: Math.min(300, (width / contentWidth) * 100),
leftOffsetPercent: Math.max(
0,
Math.min(100, ((rect.left - contentLeft) / contentWidth) * 100)
),
columnWidthPercents: columnWidths.map(
(value) => (value / columnTotal) * 100
)
});
}
return geometries;
}
export function mergePagedTableGeometries(
layout: DocxDocumentLayoutPlan,
pagedGeometries: readonly DocxTableGeometry[]
): DocxDocumentLayoutPlan {
return {
...layout,
tables: layout.tables.map((table, index) => {
const geometry = pagedGeometries[index];
if (
!geometry ||
geometry.columnWidthPercents.length !==
table.columnWidthPercents.length
) {
return table;
}
return {
...table,
widthPercent: geometry.widthPercent,
leftOffsetPercent: geometry.leftOffsetPercent,
columnWidthPercents: [...geometry.columnWidthPercents]
};
})
};
}
@@ -595,9 +831,9 @@ export function collectDocxMediaCaptureTargets(
].join(",")
)
);
if (candidates.length > MAXIMUM_DOCX_RESOURCE_COUNT) {
if (candidates.length > MAXIMUM_DOCX_MEDIA_COUNT) {
throw new Error(
`DOCX 媒体数量不能超过 ${MAXIMUM_DOCX_RESOURCE_COUNT}`
`DOCX 媒体数量不能超过 ${MAXIMUM_DOCX_MEDIA_COUNT}`
);
}
@@ -658,6 +894,20 @@ export async function renderDocxMediaCapturePlan(
payload: PagedDocumentPayload,
dimensions: DocxMediaRenderDimensions
): Promise<DocxMediaCapturePlan> {
const pagedResult = await runtime.render(payload, {
target: "pdf",
// DOCX 只消费分页表格的宽度、偏移和列宽比例;完整行结构与样式随后
// 从连续 DOM 重新采集。Paged.js 偶发丢失边界行时允许几何降级,
// 正常 PDF 渲染仍保持严格的正文行完整性门禁。
allowIncompleteTableGeometry: true
});
if (!pagedResult) {
throw new Error("DOCX 分页布局渲染已取消");
}
const pagedTableGeometries = collectPagedDocxTableGeometries(
root,
dimensions
);
const renderResult = await runtime.renderContinuous(payload, {
geometryCss: createGeometryCss(dimensions),
themeMedia: "print"
@@ -665,10 +915,24 @@ export async function renderDocxMediaCapturePlan(
if (!renderResult) {
throw new Error("DOCX 媒体渲染已取消");
}
stabilizePagedTableColumns(root, dimensions.contentHeightPx);
const continuousLayout = collectDocxDocumentLayoutPlan(
root,
dimensions
);
return {
targets: collectDocxMediaCaptureTargets(root, dimensions),
echartsErrors: renderResult.echartsErrors,
mermaidErrors: renderResult.mermaidErrors,
documentLayout: collectDocxDocumentLayoutPlan(root, dimensions)
echartsErrors: Array.from(new Set([
...pagedResult.echartsErrors,
...renderResult.echartsErrors
])),
mermaidErrors: Array.from(new Set([
...pagedResult.mermaidErrors,
...renderResult.mermaidErrors
])),
documentLayout: mergePagedTableGeometries(
continuousLayout,
pagedTableGeometries
)
};
}
+1
View File
@@ -17,5 +17,6 @@ export * from "./paged-page-sequence.js";
export * from "./semantic-cover-fit.js";
export * from "./paged-page-decorations.js";
export * from "./paged-render-target.js";
export * from "./paged-table-handler.js";
export * from "./pdf-document-links.js";
export * from "./preview-styles.js";
@@ -37,7 +37,11 @@ import { renderMermaidDefinitions } from "./mermaid-renderer.js";
import { replaceMermaidSvgWithImages } from "./mermaid-static-image.js";
import type { MermaidOutputMode } from "./mermaid-static-image.js";
import { enablePrintMediaForPreview } from "./preview-styles.js";
import "./paged-table-handler.js";
import {
forcePagedTableRowsToNextPage,
missingPagedTableRowIds,
stabilizePagedTableColumns
} from "./paged-table-handler.js";
import {
buildPagedMediaCss,
continuousDocumentGeometryCss,
@@ -61,6 +65,7 @@ export interface PagedDocumentRenderOptions {
target: PagedRenderTarget;
shouldContinue?: () => boolean;
mermaidOutput?: MermaidOutputMode;
allowIncompleteTableGeometry?: boolean;
}
export interface ContinuousDocumentRenderOptions {
@@ -75,6 +80,33 @@ export interface PreviewEngineStyles {
echartsCss: string;
}
export function protectFittableInlineCodeParagraphs(
root: ParentNode,
pageContentHeightPx: number
) {
if (!Number.isFinite(pageContentHeightPx) || pageContentHeightPx <= 0) {
return 0;
}
let protectedCount = 0;
for (const paragraph of root.querySelectorAll<HTMLElement>(
"#write > p.md-inline-code-paragraph"
)) {
const height = paragraph.getBoundingClientRect().height;
if (!(height > 0 && height <= pageContentHeightPx)) {
continue;
}
paragraph.style.setProperty("break-inside", "avoid", "important");
paragraph.style.setProperty(
"page-break-inside",
"avoid",
"important"
);
paragraph.dataset.mdtpInlineCodeKeepWhole = "true";
protectedCount += 1;
}
return protectedCount;
}
export const DEFAULT_IMAGE_READY_TIMEOUT_MS = 10_000;
function waitForImage(
@@ -699,6 +731,7 @@ export class PagedDocumentRuntime {
payload.features.includes("echarts") ||
content.querySelector(".mermaid svg") ||
content.querySelector("img.md-document-image") ||
content.querySelector("table") ||
content.querySelector('[data-semantic-region="cover"]')
) {
const measurement = mountMeasurementContainer(
@@ -710,6 +743,14 @@ export class PagedDocumentRuntime {
);
try {
await documentRef.fonts.ready;
protectFittableInlineCodeParagraphs(
measurement.host,
getPageContentDimensions(payload.exportConfig).height
);
stabilizePagedTableColumns(
measurement.host,
getPageContentDimensions(payload.exportConfig).height
);
constrainSemanticCoversToPage(
measurement.host,
getPageContentDimensions(payload.exportConfig).height
@@ -816,6 +857,51 @@ export class PagedDocumentRuntime {
stylesheets,
this.root
);
const maximumTableRowRecoveryAttempts = 3;
for (
let attempt = 0;
attempt < maximumTableRowRecoveryAttempts;
attempt += 1
) {
const missingRowIds = missingPagedTableRowIds(
repaginationSource,
this.root
);
if (missingRowIds.length === 0) {
break;
}
const recoveredCount = forcePagedTableRowsToNextPage(
repaginationSource,
missingRowIds
);
if (recoveredCount !== missingRowIds.length) {
throw new Error(
`分页表格行恢复标记不完整:缺失 ${missingRowIds.length} 行,` +
`仅定位 ${recoveredCount}`
);
}
previewer.chunker.destroy();
previewer.polisher.destroy();
previewer = new Previewer();
this.activePreviewer = previewer;
flow = await previewer.preview(
repaginationSource.cloneNode(true) as DocumentFragment,
stylesheets,
this.root
);
}
const unresolvedTableRowIds = missingPagedTableRowIds(
repaginationSource,
this.root
);
if (
unresolvedTableRowIds.length > 0 &&
!options.allowIncompleteTableGeometry
) {
throw new Error(
`分页后表格正文行缺失:${unresolvedTableRowIds.join(", ")}`
);
}
const mediaIds = getMediaBackfillIdsInDocumentOrder(
repaginationSource
);
@@ -127,9 +127,21 @@ svg {
table-layout: auto;
}
#write table[data-stabilized-columns="true"] {
table-layout: fixed !important;
}
#write th,
#write td {
overflow-wrap: anywhere;
word-break: break-word;
}
#write th code,
#write td code {
white-space: pre-wrap;
overflow-wrap: anywhere;
word-break: break-word;
}
.mermaid {
@@ -421,6 +433,15 @@ ${buildFooterCss(config)}
widows: 3;
}
#write > p.md-inline-code-paragraph {
orphans: 1;
widows: 1;
}
#write li {
text-align-last: left;
}
#write h1,
#write h2,
#write h3,
@@ -489,6 +510,11 @@ ${buildFooterCss(config)}
word-break: inherit;
}
#write .md-code-line-indent {
display: inline-block;
white-space: pre;
}
#write table[data-empty-split-table="true"] {
display: none;
}
@@ -9,8 +9,352 @@ interface PagedChunker {
source: ParentNode;
}
interface PagedHandlerContext {
chunker: PagedChunker;
function measuredColumnWidths(table: HTMLTableElement) {
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
);
const tableRect = table.getBoundingClientRect();
if (columnCount < 1 || tableRect.width <= 0) {
return [];
}
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) =>
samples.length > 0
? samples.reduce((sum, value) => sum + value, 0) / samples.length
: 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 applyColumnWidthWeights(
columns: readonly HTMLTableColElement[],
weights: readonly number[]
) {
for (const [index, column] of columns.entries()) {
column.style.width = `${Math.max(0, weights[index] ?? 0)}px`;
}
}
function applyCellWidthTracks(
table: HTMLTableElement,
widths: readonly number[]
) {
for (const row of Array.from(table.rows)) {
let columnIndex = 0;
for (const cell of Array.from(row.cells)) {
const span = Math.max(1, cell.colSpan);
const width = widths
.slice(columnIndex, columnIndex + span)
.reduce((sum, value) => sum + value, 0);
if (width > 0) {
cell.style.width = `${width}px`;
}
columnIndex += span;
}
}
}
export function stabilizePagedTableColumns(
root: ParentNode,
pageContentHeightPx?: number
) {
let stabilizedCount = 0;
for (const [tableIndex, table] of Array.from(
root.querySelectorAll<HTMLTableElement>("table")
).entries()) {
let bodyRowIndex = 0;
if (pageContentHeightPx && pageContentHeightPx > 0) {
for (const row of Array.from(table.rows)) {
const rowHeight = row.getBoundingClientRect().height;
const keepWhole = rowHeight > 0 && rowHeight <= pageContentHeightPx;
if (row.parentElement?.tagName === "TBODY") {
row.dataset.mdtpTableRowId ??=
`table-${tableIndex + 1}-row-${bodyRowIndex + 1}`;
row.dataset.mdtpTableRowKeepWhole = String(keepWhole);
bodyRowIndex += 1;
}
for (const cell of Array.from(row.cells)) {
cell.style.setProperty(
"break-inside",
keepWhole ? "avoid" : "auto",
"important"
);
cell.style.setProperty(
"page-break-inside",
keepWhole ? "avoid" : "auto",
"important"
);
}
}
}
if (
table.dataset.stabilizedColumns === "true" ||
table.querySelector(":scope > colgroup")
) {
continue;
}
const widths = measuredColumnWidths(table);
const total = widths.reduce((sum, value) => sum + value, 0);
if (widths.length === 0 || total <= 0) {
continue;
}
const colgroup = table.ownerDocument.createElement("colgroup");
colgroup.dataset.stabilizedColumns = "true";
for (const width of widths) {
const column = table.ownerDocument.createElement("col");
column.dataset.stabilizedWidthPx = String(width);
colgroup.append(column);
}
table.insertBefore(colgroup, table.firstChild);
table.dataset.stabilizedColumns = "true";
table.style.tableLayout = "fixed";
const columns = Array.from(colgroup.children) as HTMLTableColElement[];
let weights = widths;
applyColumnWidthWeights(columns, weights);
for (let iteration = 0; iteration < 3; iteration += 1) {
const measured = measuredColumnWidths(table);
if (
measured.length !== widths.length ||
measured.some((width) => width <= 0)
) {
break;
}
weights = weights.map(
(weight, index) => weight * widths[index]! / measured[index]!
);
applyColumnWidthWeights(columns, weights);
}
applyCellWidthTracks(table, widths);
stabilizedCount += 1;
}
return stabilizedCount;
}
export function missingPagedTableRowIds(
source: ParentNode,
rendered: ParentNode
) {
const isVisiblePagedRow = (row: HTMLElement) => {
const pageArea = row.closest<HTMLElement>(".pagedjs_area");
if (!pageArea) {
return true;
}
const rowRect = row.getBoundingClientRect();
const areaRect = pageArea.getBoundingClientRect();
const intersectionWidth = Math.min(rowRect.right, areaRect.right) -
Math.max(rowRect.left, areaRect.left);
const intersectionHeight = Math.min(rowRect.bottom, areaRect.bottom) -
Math.max(rowRect.top, areaRect.top);
return intersectionWidth > 0.5 && intersectionHeight > 0.5;
};
const renderedIds = new Set(
Array.from(rendered.querySelectorAll<HTMLElement>(
"[data-mdtp-table-row-id]"
)).filter(isVisiblePagedRow)
.map((row) => row.dataset.mdtpTableRowId)
.filter((id): id is string => Boolean(id))
);
return Array.from(source.querySelectorAll<HTMLTableRowElement>(
'tbody > tr[data-mdtp-table-row-id][data-mdtp-table-row-keep-whole="true"]'
)).map((row) => row.dataset.mdtpTableRowId)
.filter((id): id is string => Boolean(id))
.filter((id) => !renderedIds.has(id));
}
function splitPagedTableBeforeRow(row: HTMLTableRowElement) {
const body = row.parentElement as HTMLTableSectionElement | null;
const table = row.closest<HTMLTableElement>("table");
if (
!body ||
body.tagName !== "TBODY" ||
!table ||
body.querySelector(":scope > tr") === row
) {
return table;
}
const continuation = table.cloneNode(false) as HTMLTableElement;
continuation.dataset.mdtpTableRecoveryContinuation = "true";
for (const child of Array.from(table.children)) {
if (child.tagName === "COLGROUP" || child.tagName === "THEAD") {
continuation.append(child.cloneNode(true));
}
}
const continuationBody = body.cloneNode(false) as HTMLTableSectionElement;
let movingRow: HTMLTableRowElement | null = row;
while (movingRow) {
const nextRow = movingRow.nextElementSibling as HTMLTableRowElement | null;
continuationBody.append(movingRow);
movingRow = nextRow;
}
continuation.append(continuationBody);
let followingSection = body.nextElementSibling;
while (followingSection) {
const nextSection = followingSection.nextElementSibling;
continuation.append(followingSection);
followingSection = nextSection;
}
table.after(continuation);
return continuation;
}
export function forcePagedTableRowsToNextPage(
source: ParentNode,
rowIds: readonly string[]
) {
const requested = new Set(rowIds);
let count = 0;
for (const row of source.querySelectorAll<HTMLTableRowElement>(
"tbody > tr[data-mdtp-table-row-id]"
)) {
const id = row.dataset.mdtpTableRowId;
if (!id || !requested.has(id)) {
continue;
}
const table = splitPagedTableBeforeRow(row);
let recoveryTarget: HTMLElement = row;
if (table?.parentNode) {
const previous = table.previousElementSibling as HTMLElement | null;
const marker = previous?.dataset.mdtpTableRecoveryMarker === "true"
? previous
: table.ownerDocument.createElement("div");
if (marker !== previous) {
marker.dataset.mdtpTableRecoveryMarker = "true";
marker.setAttribute("aria-hidden", "true");
marker.textContent = "\u00a0";
marker.style.setProperty("height", "1px", "important");
marker.style.setProperty("line-height", "1px", "important");
marker.style.setProperty("margin", "0 0 -1px", "important");
marker.style.setProperty("padding", "0", "important");
marker.style.setProperty("border", "0", "important");
marker.style.setProperty("overflow", "hidden", "important");
marker.style.setProperty("opacity", "0", "important");
table.parentNode.insertBefore(marker, table);
}
recoveryTarget = marker;
table.dataset.mdtpTableRecovery = "true";
}
recoveryTarget.style.setProperty("break-before", "page", "important");
recoveryTarget.style.setProperty(
"page-break-before",
"always",
"important"
);
row.dataset.mdtpTableRowRecovery = "true";
count += 1;
}
return count;
}
export function restorePagedTableStructure(
sourceTable: HTMLTableElement,
renderedTable: HTMLTableElement
) {
restorePagedTableColumns(sourceTable, renderedTable);
if (!renderedTable.querySelector("thead")) {
const sourceHeader = sourceTable.querySelector("thead");
if (sourceHeader) {
const firstNonColumnGroup = Array.from(renderedTable.children).find(
(child) => child.tagName !== "COLGROUP"
) ?? null;
renderedTable.insertBefore(
sourceHeader.cloneNode(true),
firstNonColumnGroup
);
}
}
}
export function restorePagedTableColumns(
sourceTable: HTMLTableElement,
renderedTable: HTMLTableElement
) {
const sourceGroups = Array.from(
sourceTable.querySelectorAll<HTMLTableColElement>(
":scope > colgroup"
)
);
if (sourceGroups.length === 0) {
return;
}
const renderedGroups = Array.from(
renderedTable.querySelectorAll<HTMLTableColElement>(
":scope > colgroup"
)
);
const matchingStructure =
renderedGroups.length === sourceGroups.length &&
renderedGroups.every(
(group, index) =>
group.children.length === sourceGroups[index]?.children.length
);
if (!matchingStructure) {
for (const group of renderedGroups) {
group.remove();
}
const firstChild = renderedTable.firstChild;
for (const group of sourceGroups) {
renderedTable.insertBefore(group.cloneNode(true), firstChild);
}
} else {
for (const [groupIndex, sourceGroup] of sourceGroups.entries()) {
const renderedGroup = renderedGroups[groupIndex]!;
for (const attribute of Array.from(sourceGroup.attributes)) {
renderedGroup.setAttribute(attribute.name, attribute.value);
}
for (const [columnIndex, sourceColumn] of Array.from(
sourceGroup.children
).entries()) {
const renderedColumn = renderedGroup.children[
columnIndex
] as HTMLTableColElement;
for (const attribute of Array.from(sourceColumn.attributes)) {
renderedColumn.setAttribute(attribute.name, attribute.value);
}
}
}
}
if (sourceTable.dataset.stabilizedColumns === "true") {
renderedTable.dataset.stabilizedColumns = "true";
renderedTable.style.tableLayout = "fixed";
}
}
function elementAncestors(
@@ -48,6 +392,21 @@ class RepeatTableHeadersHandler extends Handler {
breakToken?: PagedBreakToken
) {
this.splitTableRefs = [];
for (const renderedTable of pageElement.querySelectorAll<HTMLTableElement>(
"table[data-ref]"
)) {
const ref = renderedTable.getAttribute("data-ref");
if (!ref) {
continue;
}
const sourceTable =
this.chunker.source.querySelector<HTMLTableElement>(
`table[data-ref="${CSS.escape(ref)}"]`
);
if (sourceTable) {
restorePagedTableColumns(sourceTable, renderedTable);
}
}
const element = resolveBreakTokenElement(breakToken?.node);
if (!element) {
return;
@@ -81,6 +440,21 @@ class RepeatTableHeadersHandler extends Handler {
}
layout(rendered: HTMLElement) {
for (const renderedTable of rendered.querySelectorAll<HTMLTableElement>(
"table[data-ref]"
)) {
const ref = renderedTable.getAttribute("data-ref");
if (!ref) {
continue;
}
const sourceTable =
this.chunker.source.querySelector<HTMLTableElement>(
`table[data-ref="${CSS.escape(ref)}"]`
);
if (sourceTable) {
restorePagedTableColumns(sourceTable, renderedTable);
}
}
for (const ref of this.splitTableRefs) {
const renderedTable =
rendered.querySelector<HTMLTableElement>(
@@ -101,23 +475,7 @@ class RepeatTableHeadersHandler extends Handler {
continue;
}
const firstChild = renderedTable.firstChild;
for (const colgroup of sourceTable.querySelectorAll("colgroup")) {
renderedTable.insertBefore(
colgroup.cloneNode(true),
firstChild
);
}
if (!renderedTable.querySelector("thead")) {
const sourceHeader = sourceTable.querySelector("thead");
if (sourceHeader) {
renderedTable.insertBefore(
sourceHeader.cloneNode(true),
renderedTable.firstChild
);
}
}
restorePagedTableStructure(sourceTable, renderedTable);
renderedTable.setAttribute("data-repeated-header", "true");
}
@@ -98,6 +98,32 @@ describe("代码块分页预处理", () => {
).toBe("return");
});
it("将高亮代码行首缩进封装为分页稳定节点", () => {
const root = createRoot(
'<span class="hljs-punctuation">{</span>\n ' +
'<span class="hljs-attr">"nested"</span>: true\n' +
'<span class="hljs-punctuation">}</span>\n'
);
prepareCodeBlockPagination(root);
const lines = getLines(root);
const indent = lines[1]?.querySelector(
":scope > .md-code-line-indent"
);
expect(lines.map((line) => line.textContent)).toEqual([
"{",
' "nested": true',
"}"
]);
expect(indent?.textContent).toBe(" ");
expect((indent as HTMLElement | null)?.style.width).toBe("2ch");
expect((indent as HTMLElement | null)?.dataset.codeIndentColumns)
.toBe("2");
expect(indent?.nextElementSibling?.classList.contains("hljs-attr"))
.toBe(true);
});
it("重复调用不会再次包装已经准备的代码块", () => {
const root = createRoot("line-1\nline-2\n");
@@ -2,13 +2,21 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
collectPagedDocxTableGeometries,
collectDocxDocumentLayoutPlan,
collectDocxInlineCodeLayouts,
collectDocxListItemLayouts,
collectDocxTextBlockLayouts,
collectDocxMediaCaptureTargets,
forcePagedTableRowsToNextPage,
mergePagedTableGeometries,
missingPagedTableRowIds,
protectFittableInlineCodeParagraphs,
removeInheritedPagedSplitJustification,
renderDocxMediaCapturePlan
renderDocxMediaCapturePlan,
restorePagedTableColumns,
restorePagedTableStructure,
stabilizePagedTableColumns
} from "../src/index.js";
describe("DOCX 媒体捕获计划", () => {
@@ -18,6 +26,10 @@ describe("DOCX 媒体捕获计划", () => {
});
it("连续布局使用原始打印媒体主题 CSS", async () => {
const render = vi.fn(async () => ({
echartsErrors: [],
mermaidErrors: []
}));
const renderContinuous = vi.fn(async () => ({
echartsErrors: [],
mermaidErrors: []
@@ -26,7 +38,7 @@ describe("DOCX 媒体捕获计划", () => {
root.innerHTML = '<article id="write"></article>';
await renderDocxMediaCapturePlan(
{ renderContinuous } as never,
{ render, renderContinuous } as never,
root,
{
articleHtml: '<article id="write"></article>',
@@ -57,6 +69,155 @@ describe("DOCX 媒体捕获计划", () => {
expect.anything(),
expect.objectContaining({ themeMedia: "print" })
);
expect(render).toHaveBeenCalledWith(
expect.anything(),
{
target: "pdf",
allowIncompleteTableGeometry: true
}
);
});
it("仅将能完整放入一页的顶层行内代码段落保持为整体", () => {
document.body.innerHTML = `
<article id="write">
<p id="short" class="md-inline-code-paragraph">短段落</p>
<p id="long" class="md-inline-code-paragraph">超长段落</p>
<table><tbody><tr><td>
<p id="cell" class="md-inline-code-paragraph">单元格段落</p>
</td></tr></tbody></table>
</article>
`;
const short = document.querySelector<HTMLElement>("#short")!;
const long = document.querySelector<HTMLElement>("#long")!;
const cell = document.querySelector<HTMLElement>("#cell")!;
short.getBoundingClientRect = () => ({ height: 240 }) as DOMRect;
long.getBoundingClientRect = () => ({ height: 1200 }) as DOMRect;
cell.getBoundingClientRect = () => ({ height: 120 }) as DOMRect;
expect(protectFittableInlineCodeParagraphs(document, 900)).toBe(1);
expect(short.style.getPropertyValue("break-inside")).toBe("avoid");
expect(short.style.getPropertyPriority("break-inside")).toBe("important");
expect(short.dataset.mdtpInlineCodeKeepWhole).toBe("true");
expect(long.style.getPropertyValue("break-inside")).toBe("");
expect(cell.style.getPropertyValue("break-inside")).toBe("");
});
it("按逻辑表格去重采集分页后的真实列轨", () => {
document.body.innerHTML = `
<main class="pagedjs_pages">
<section class="pagedjs_page">
<div class="pagedjs_area">
<table data-ref="table-a">
<colgroup data-stabilized-columns="true">
<col data-stabilized-width-px="120">
<col data-stabilized-width-px="280">
</colgroup>
<tbody><tr><td>A</td><td>B</td></tr></tbody>
</table>
</div>
</section>
<section class="pagedjs_page">
<div class="pagedjs_area">
<table data-ref="table-a">
<colgroup data-stabilized-columns="true">
<col data-stabilized-width-px="120">
<col data-stabilized-width-px="280">
</colgroup>
<tbody><tr><td>C</td><td>D</td></tr></tbody>
</table>
</div>
</section>
</main>
`;
const areas = Array.from(
document.querySelectorAll<HTMLElement>(".pagedjs_area")
);
const tables = Array.from(
document.querySelectorAll<HTMLTableElement>("table")
);
areas.forEach((area) => {
area.getBoundingClientRect = () => ({
left: 100, right: 900, top: 0, bottom: 900,
width: 800, height: 900
}) as DOMRect;
});
tables.forEach((table, index) => {
table.getBoundingClientRect = () => ({
left: index === 0 ? 900 : 140,
right: index === 0 ? 1300 : 540,
top: 20, bottom: 220,
width: 400, height: 200
}) as DOMRect;
});
expect(collectPagedDocxTableGeometries(document, {
contentWidthPx: 800,
contentHeightPx: 900
})).toEqual([{
ordinal: 1,
widthPercent: 50,
leftOffsetPercent: 5,
columnWidthPercents: [30, 70]
}]);
});
it("只覆盖列数一致的分页表格几何并保留连续布局行样式", () => {
const row = {
cells: [{
columnSpan: 1,
backgroundColor: "#ffffff",
color: "#000000",
bold: true,
italic: false,
alignment: "left" as const
}]
};
const layout = {
tables: [
{
ordinal: 1,
widthPercent: 100,
leftOffsetPercent: 0,
columnWidthPercents: [40, 60],
rows: [row]
},
{
ordinal: 2,
widthPercent: 100,
leftOffsetPercent: 0,
columnWidthPercents: [100],
rows: [row]
}
],
textBlocks: [],
listItems: [],
inlineCodes: []
};
const merged = mergePagedTableGeometries(layout, [
{
ordinal: 1,
widthPercent: 90,
leftOffsetPercent: 5,
columnWidthPercents: [30, 70]
},
{
ordinal: 2,
widthPercent: 80,
leftOffsetPercent: 10,
columnWidthPercents: [20, 80]
}
]);
expect(merged.tables[0]).toEqual({
...layout.tables[0],
widthPercent: 90,
leftOffsetPercent: 5,
columnWidthPercents: [30, 70]
});
expect(merged.tables[0]?.rows).toBe(layout.tables[0]?.rows);
expect(merged.tables[1]).toBe(layout.tables[1]);
});
it("只移除会把 Paged.js 末行两端对齐继承给子块的容器标记", () => {
@@ -334,6 +495,382 @@ describe("DOCX 媒体捕获计划", () => {
});
});
it("保留主题表格超出文档内容盒的实测宽度", () => {
document.body.innerHTML = `
<article id="write"><table><tbody><tr><td>内容</td></tr></tbody></table></article>
`;
const article = document.querySelector<HTMLElement>("#write")!;
const table = document.querySelector<HTMLTableElement>("table")!;
const cell = document.querySelector<HTMLTableCellElement>("td")!;
article.getBoundingClientRect = () => ({
left: 100,
right: 900,
top: 0,
bottom: 900,
width: 800,
height: 900,
}) as DOMRect;
table.getBoundingClientRect = () => ({
left: 100,
right: 980,
top: 20,
bottom: 120,
width: 880,
height: 100,
}) as DOMRect;
cell.getBoundingClientRect = table.getBoundingClientRect;
const layout = collectDocxDocumentLayoutPlan(document, {
contentWidthPx: 800,
contentHeightPx: 900,
});
expect(layout.tables[0]?.widthPercent).toBeCloseTo(110, 8);
expect(layout.tables[0]?.columnWidthPercents).toEqual([100]);
});
it("分页前冻结整表实测列宽供拆分页片段复用", () => {
document.body.innerHTML = `
<article id="write"><table><tbody><tr><td>A</td><td>B</td></tr></tbody></table></article>
`;
const table = document.querySelector<HTMLTableElement>("table")!;
const cells = Array.from(table.rows[0]!.cells);
table.getBoundingClientRect = () => ({
left: 100,
right: 500,
top: 0,
bottom: 100,
width: 400,
height: 100
}) as DOMRect;
cells[0]!.getBoundingClientRect = () => ({
left: 100,
right: 220,
top: 0,
bottom: 100,
width: 120,
height: 100
}) as DOMRect;
cells[1]!.getBoundingClientRect = () => ({
left: 220,
right: 500,
top: 0,
bottom: 100,
width: 280,
height: 100
}) as DOMRect;
expect(stabilizePagedTableColumns(document)).toBe(1);
expect(table.dataset.stabilizedColumns).toBe("true");
expect(table.style.tableLayout).toBe("fixed");
expect(Array.from(table.querySelectorAll("col")).map(
(column) => column.style.width
)).toEqual(["120px", "280px"]);
expect(Array.from(table.querySelectorAll("col")).map(
(column) => (column as HTMLTableColElement).dataset.stabilizedWidthPx
)).toEqual(["120", "280"]);
expect(Array.from(table.querySelectorAll("th, td")).map(
(cell) => (cell as HTMLElement).style.width
)).toEqual(["120px", "280px"]);
expect(stabilizePagedTableColumns(document)).toBe(0);
});
it("DOCX 布局采集保持分页前原始列轨而不受辅助样式二次测量影响", () => {
document.body.innerHTML = `
<article id="write">
<table data-stabilized-columns="true">
<colgroup data-stabilized-columns="true">
<col data-stabilized-width-px="120" style="width: 140px">
<col data-stabilized-width-px="280" style="width: 260px">
</colgroup>
<tbody><tr><td>A</td><td>B</td></tr></tbody>
</table>
</article>
`;
const article = document.querySelector<HTMLElement>("#write")!;
const table = document.querySelector<HTMLTableElement>("table")!;
const cells = Array.from(table.rows[0]!.cells);
article.getBoundingClientRect = () => ({
left: 100, right: 500, top: 0, bottom: 100,
width: 400, height: 100
}) as DOMRect;
table.getBoundingClientRect = article.getBoundingClientRect;
cells[0]!.getBoundingClientRect = () => ({
left: 100, right: 240, top: 0, bottom: 100,
width: 140, height: 100
}) as DOMRect;
cells[1]!.getBoundingClientRect = () => ({
left: 240, right: 500, top: 0, bottom: 100,
width: 260, height: 100
}) as DOMRect;
const layout = collectDocxDocumentLayoutPlan(document, {
contentWidthPx: 400,
contentHeightPx: 900
});
expect(layout.tables[0]?.columnWidthPercents).toEqual([30, 70]);
});
it("冻结跨列单元格为对应列轨宽度之和", () => {
document.body.innerHTML = `
<table><tbody>
<tr><td colspan="2">跨列</td></tr>
<tr><td>甲</td><td>乙</td></tr>
</tbody></table>
`;
const table = document.querySelector("table")!;
const cells = Array.from(table.querySelectorAll("td"));
table.getBoundingClientRect = () => ({
left: 100, right: 500, top: 0, bottom: 100,
width: 400, height: 100
}) as DOMRect;
cells[0]!.getBoundingClientRect = () => ({
left: 100, right: 500, top: 0, bottom: 50,
width: 400, height: 50
}) as DOMRect;
cells[1]!.getBoundingClientRect = () => ({
left: 100, right: 220, top: 50, bottom: 100,
width: 120, height: 50
}) as DOMRect;
cells[2]!.getBoundingClientRect = () => ({
left: 220, right: 500, top: 50, bottom: 100,
width: 280, height: 50
}) as DOMRect;
expect(stabilizePagedTableColumns(document)).toBe(1);
expect(cells.map((cell) => cell.style.width)).toEqual([
"400px", "120px", "280px"
]);
});
it("分页片段已有冻结列定义时不重复插入 colgroup", () => {
document.body.innerHTML = `
<table id="source">
<colgroup data-stabilized-columns="true"><col style="width: 30%"><col style="width: 70%"></colgroup>
<thead><tr><th>A</th><th>B</th></tr></thead>
<tbody><tr><td>甲</td><td>乙</td></tr></tbody>
</table>
<table id="rendered">
<colgroup data-stabilized-columns="true"><col style="width: 30%"><col style="width: 70%"></colgroup>
<tbody><tr><td>丙</td><td>丁</td></tr></tbody>
</table>
`;
const source = document.querySelector<HTMLTableElement>("#source")!;
const rendered = document.querySelector<HTMLTableElement>("#rendered")!;
restorePagedTableStructure(source, rendered);
expect(rendered.querySelectorAll(":scope > colgroup")).toHaveLength(1);
expect(rendered.querySelectorAll(":scope > colgroup > col")).toHaveLength(2);
expect(rendered.querySelectorAll(":scope > thead")).toHaveLength(1);
expect(Array.from(rendered.children).map((child) => child.tagName)).toEqual([
"COLGROUP",
"THEAD",
"TBODY"
]);
});
it("分页前仅对可容纳于单页的表格行单元格启用整行保护", () => {
document.body.innerHTML = `
<table><tbody>
<tr id="normal"><td>普通行</td><td>内容</td></tr>
<tr id="tall"><td>超高行</td><td>内容</td></tr>
</tbody></table>
`;
const table = document.querySelector<HTMLTableElement>("table")!;
const normal = document.querySelector<HTMLTableRowElement>("#normal")!;
const tall = document.querySelector<HTMLTableRowElement>("#tall")!;
table.getBoundingClientRect = () => ({
width: 400, height: 1020, top: 0, right: 400,
bottom: 1020, left: 0
}) as DOMRect;
normal.getBoundingClientRect = () => ({
width: 400, height: 120, top: 0, right: 400,
bottom: 120, left: 0
}) as DOMRect;
tall.getBoundingClientRect = () => ({
width: 400, height: 900, top: 120, right: 400,
bottom: 1020, left: 0
}) as DOMRect;
Array.from(table.rows).flatMap((row) => Array.from(row.cells))
.forEach((cell, index) => {
cell.getBoundingClientRect = () => ({
width: 200,
height: index < 2 ? 120 : 900,
top: index < 2 ? 0 : 120,
right: index % 2 === 0 ? 200 : 400,
bottom: index < 2 ? 120 : 1020,
left: index % 2 === 0 ? 0 : 200
}) as DOMRect;
});
stabilizePagedTableColumns(document, 800);
expect(Array.from(normal.cells).map(
(cell) => cell.style.getPropertyValue("break-inside")
)).toEqual(["avoid", "avoid"]);
expect(Array.from(tall.cells).map(
(cell) => cell.style.getPropertyValue("break-inside")
)).toEqual(["auto", "auto"]);
expect(Array.from(normal.cells).every(
(cell) => cell.style.getPropertyPriority("break-inside") === "important"
)).toBe(true);
expect(normal.dataset.mdtpTableRowId).toBe("table-1-row-1");
expect(normal.dataset.mdtpTableRowKeepWhole).toBe("true");
expect(tall.dataset.mdtpTableRowId).toBe("table-1-row-2");
expect(tall.dataset.mdtpTableRowKeepWhole).toBe("false");
});
it("检测分页后缺失的可容纳正文行并施加恢复断点", () => {
document.body.innerHTML = `
<main id="source"><table><tbody>
<tr data-mdtp-table-row-id="row-1" data-mdtp-table-row-keep-whole="true"><td>甲</td></tr>
<tr data-mdtp-table-row-id="row-2" data-mdtp-table-row-keep-whole="true"><td>乙</td></tr>
<tr data-mdtp-table-row-id="row-tall" data-mdtp-table-row-keep-whole="false"><td>超高</td></tr>
</tbody></table></main>
<main id="rendered"><table><tbody>
<tr data-mdtp-table-row-id="row-2"><td>乙</td></tr>
</tbody></table></main>
`;
const source = document.querySelector("#source")!;
const rendered = document.querySelector("#rendered")!;
expect(missingPagedTableRowIds(source, rendered)).toEqual(["row-1"]);
expect(forcePagedTableRowsToNextPage(source, ["row-1"])).toBe(1);
const row = source.querySelector<HTMLTableRowElement>(
'[data-mdtp-table-row-id="row-1"]'
)!;
const table = row.closest("table")!;
const marker = table.previousElementSibling as HTMLElement;
expect(marker.dataset.mdtpTableRecoveryMarker).toBe("true");
expect(marker.textContent).toBe("\u00a0");
expect(marker.style.getPropertyValue("height")).toBe("1px");
expect(marker.style.getPropertyValue("margin-bottom")).toBe("-1px");
expect(marker.style.getPropertyValue("opacity")).toBe("0");
expect(marker.style.getPropertyValue("break-before")).toBe("page");
expect(marker.style.getPropertyPriority("break-before")).toBe("important");
expect(table.dataset.mdtpTableRecovery).toBe("true");
expect(row.dataset.mdtpTableRowRecovery).toBe("true");
expect(forcePagedTableRowsToNextPage(source, ["row-1"])).toBe(1);
expect(source.querySelectorAll("[data-mdtp-table-recovery-marker]"))
.toHaveLength(1);
});
it("在非首行缺失时拆分续表并将恢复断点放到表格外", () => {
document.body.innerHTML = `
<main id="source"><table id="source-table">
<colgroup><col><col></colgroup>
<thead><tr><th>甲</th><th>乙</th></tr></thead>
<tbody>
<tr data-mdtp-table-row-id="row-1"><td>一</td><td>壹</td></tr>
<tr data-mdtp-table-row-id="row-2"><td>二</td><td>贰</td></tr>
<tr data-mdtp-table-row-id="row-3"><td>三</td><td>叁</td></tr>
</tbody>
<tfoot><tr><td>尾</td><td>末</td></tr></tfoot>
</table></main>
`;
const source = document.querySelector("#source")!;
expect(forcePagedTableRowsToNextPage(source, ["row-2"])).toBe(1);
const tables = source.querySelectorAll<HTMLTableElement>("table");
expect(tables).toHaveLength(2);
expect(Array.from(tables[0]!.querySelectorAll("tbody > tr")).map(
(row) => (row as HTMLElement).dataset.mdtpTableRowId
)).toEqual(["row-1"]);
expect(Array.from(tables[1]!.querySelectorAll("tbody > tr")).map(
(row) => (row as HTMLElement).dataset.mdtpTableRowId
)).toEqual(["row-2", "row-3"]);
expect(tables[1]!.dataset.mdtpTableRecoveryContinuation).toBe("true");
expect(tables[1]!.querySelector("colgroup")).not.toBeNull();
expect(tables[1]!.querySelector("thead")).not.toBeNull();
expect(tables[0]!.querySelector("tfoot")).toBeNull();
expect(tables[1]!.querySelector("tfoot")).not.toBeNull();
const marker = tables[1]!.previousElementSibling as HTMLElement;
expect(marker.dataset.mdtpTableRecoveryMarker).toBe("true");
expect(marker.style.getPropertyValue("break-before")).toBe("page");
expect(marker.style.getPropertyPriority("break-before")).toBe("important");
});
it("仅将分页可视区域内真实可见的表格行视为已渲染", () => {
document.body.innerHTML = `
<main id="source"><table><tbody>
<tr data-mdtp-table-row-id="row-clipped" data-mdtp-table-row-keep-whole="true"><td>裁切</td></tr>
<tr data-mdtp-table-row-id="row-visible" data-mdtp-table-row-keep-whole="true"><td>可见</td></tr>
</tbody></table></main>
<main id="rendered">
<section class="pagedjs_area" id="page-1">
<table><tbody>
<tr id="clipped" data-mdtp-table-row-id="row-clipped"><td>裁切</td></tr>
<tr id="visible" data-mdtp-table-row-id="row-visible"><td>可见</td></tr>
</tbody></table>
</section>
<section class="pagedjs_area" id="page-2">
<table><tbody>
<tr id="visible-duplicate" data-mdtp-table-row-id="row-visible"><td>可见副本</td></tr>
</tbody></table>
</section>
</main>
`;
const rect = (
left: number,
top: number,
right: number,
bottom: number
) => ({
left, top, right, bottom,
width: right - left,
height: bottom - top,
x: left,
y: top,
toJSON: () => ({})
}) as DOMRect;
document.querySelector<HTMLElement>("#page-1")!.getBoundingClientRect =
() => rect(0, 0, 600, 800);
document.querySelector<HTMLElement>("#page-2")!.getBoundingClientRect =
() => rect(0, 900, 600, 1700);
document.querySelector<HTMLElement>("#clipped")!.getBoundingClientRect =
() => rect(0, 810, 600, 850);
document.querySelector<HTMLElement>("#visible")!.getBoundingClientRect =
() => rect(0, 780, 600, 820);
document.querySelector<HTMLElement>("#visible-duplicate")!
.getBoundingClientRect = () => rect(0, 920, 600, 960);
expect(missingPagedTableRowIds(
document.querySelector("#source")!,
document.querySelector("#rendered")!
)).toEqual(["row-clipped"]);
});
it("分页片段同步源表冻结列宽而不重复 colgroup", () => {
document.body.innerHTML = `
<table id="source" data-stabilized-columns="true">
<colgroup data-stabilized-columns="true"><col data-stabilized-width-px="118" style="width: 120px"><col data-stabilized-width-px="282" style="width: 280px"></colgroup>
<tbody><tr><td>甲</td><td>乙</td></tr></tbody>
</table>
<table id="rendered">
<colgroup><col style="width: 100px"><col style="width: 300px"></colgroup>
<tbody><tr><td>甲</td><td>乙</td></tr></tbody>
</table>
`;
const source = document.querySelector<HTMLTableElement>("#source")!;
const rendered = document.querySelector<HTMLTableElement>("#rendered")!;
restorePagedTableColumns(source, rendered);
expect(rendered.querySelectorAll(":scope > colgroup")).toHaveLength(1);
expect(Array.from(rendered.querySelectorAll("col")).map(
(column) => column.style.width
)).toEqual(["120px", "280px"]);
expect(Array.from(rendered.querySelectorAll("col")).map(
(column) => (column as HTMLTableColElement).dataset.stabilizedWidthPx
)).toEqual(["118", "282"]);
expect(rendered.dataset.stabilizedColumns).toBe("true");
expect(rendered.style.tableLayout).toBe("fixed");
});
it("按真实上下文采集行内代码字号、颜色和盒模型", () => {
document.body.innerHTML = `
<article id="write" style="font-size: 20px">
@@ -419,6 +956,42 @@ describe("DOCX 媒体捕获计划", () => {
}
});
it("不把普通两端对齐列表的自然末行误判为分散对齐", () => {
document.body.innerHTML = `
<article id="write">
<ul><li style="text-align: justify; text-align-last: left">末行保持左对齐</li></ul>
</article>
`;
const article = document.querySelector<HTMLElement>("#write")!;
article.getBoundingClientRect = () => ({
left: 0,
right: 800,
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) {
return {
left: this.startOffset * 80,
right: this.startOffset * 80 + 10,
top: 20,
bottom: 32,
width: 10,
height: 12
} as DOMRect;
};
try {
expect(collectDocxListItemLayouts(article)[0]?.alignment).toBeUndefined();
} finally {
rangePrototype.getBoundingClientRect = original;
}
});
it("采集正文文本块在 Chromium 中的实际行断点", () => {
document.body.innerHTML = `
<article id="write">
@@ -40,6 +40,14 @@ const payload: PagedPreviewPayload = {
};
describe("分页预览协议", () => {
it("含行内代码的段落避免被 Paged.js 跨页拆分丢失片段", () => {
const css = buildPagedMediaCss(defaultExportConfig, payload);
expect(css).toContain("#write > p.md-inline-code-paragraph");
expect(css).toContain("orphans: 1");
expect(css).toContain("widows: 1");
});
it("生成真实纸张尺寸和页边距 CSS", () => {
const css = buildPagedMediaCss(defaultExportConfig, payload);
@@ -278,6 +286,12 @@ describe("分页预览协议", () => {
expect(documentBaseCss).toContain("white-space: pre-wrap;");
expect(documentBaseCss).toContain("overflow-wrap: anywhere;");
expect(documentBaseCss).toContain("word-break: break-word;");
expect(documentBaseCss).toMatch(
/#write th,[\s\S]*#write td \{[\s\S]*word-break: break-word;/u,
);
expect(documentBaseCss).toMatch(
/#write th code,[\s\S]*#write td code \{[\s\S]*white-space: pre-wrap;[\s\S]*overflow-wrap: anywhere;[\s\S]*word-break: break-word;/u,
);
const css = buildPagedMediaCss(defaultExportConfig, payload);
expect(css).toContain("#write .md-code-line");