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:
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user