release: 发布 v0.6.1 DOCX 视觉一致性修复
新增通用 CSS 到 OOXML 翻译修复,统一字体、字距、精确行距、段落、列表、表格、引用、代码块与行内代码连续性,不引入按主题 ID 分支。 新增 MdTP Mono 并统一 Serif、Sans、Mono 三字体包的 Chromium 与 DOCX 使用链;字体声明、嵌入部件和 Word/WPS 实际采用均进入硬门禁。 重建封面整页及正文语义块视觉差分,14 套主题、纵横两个方向、五组页边距共 140 个真实场景全部通过,阻断失败和诊断失败均为零。 源码服务、Docker Web API 与实际安装 Desktop 的 red-briefing 导出均包含 5 个字体部件;Word/WPS 原生渲染和逐页复核通过。修复 Docker 构建上下文与运行层复用软链接,并完善 v0.6.1 版本、发行说明和发布归集。 验证:npm test(116 个文件、616 项测试)、npm run typecheck、npm run build、git diff --check 全部通过。Desktop 安装器与 ZIP、Docker v0.6.1 镜像已生成;Windows 产物仍为未签名内部发行。
This commit is contained in:
@@ -7,6 +7,11 @@ import {
|
||||
type DocxMediaCaptureTarget,
|
||||
type DocxMediaAlignment,
|
||||
type DocxMediaKind,
|
||||
type DocxDocumentLayoutPlan,
|
||||
type DocxInlineCodeLayout,
|
||||
type DocxListItemLayout,
|
||||
type DocxTableLayout,
|
||||
type DocxTextBlockLayout,
|
||||
type PagedDocumentPayload
|
||||
} from "@md-to-pdf/core";
|
||||
import { PagedDocumentRuntime } from "./paged-document-runtime.js";
|
||||
@@ -145,6 +150,434 @@ function createGeometryCss(dimensions: DocxMediaRenderDimensions) {
|
||||
`;
|
||||
}
|
||||
|
||||
function average(values: readonly number[], fallback: number) {
|
||||
return values.length > 0
|
||||
? values.reduce((total, value) => total + value, 0) / values.length
|
||||
: fallback;
|
||||
}
|
||||
|
||||
function collectTableColumnWidths(table: HTMLTableElement, tableRect: DOMRect) {
|
||||
const rows = Array.from(table.rows);
|
||||
const columnCount = rows.reduce(
|
||||
(maximum, row) =>
|
||||
Math.max(
|
||||
maximum,
|
||||
Array.from(row.cells).reduce(
|
||||
(total, cell) => total + Math.max(1, cell.colSpan),
|
||||
0
|
||||
)
|
||||
),
|
||||
0
|
||||
);
|
||||
if (columnCount < 1) {
|
||||
return [tableRect.width];
|
||||
}
|
||||
const boundaries = Array.from(
|
||||
{ length: columnCount + 1 },
|
||||
() => [] as number[]
|
||||
);
|
||||
boundaries[0]!.push(0);
|
||||
boundaries[columnCount]!.push(tableRect.width);
|
||||
for (const row of rows) {
|
||||
let column = 0;
|
||||
for (const cell of Array.from(row.cells)) {
|
||||
const span = Math.max(1, cell.colSpan);
|
||||
const rect = cell.getBoundingClientRect();
|
||||
boundaries[column]?.push(
|
||||
Math.max(0, Math.min(tableRect.width, rect.left - tableRect.left))
|
||||
);
|
||||
column = Math.min(columnCount, column + span);
|
||||
boundaries[column]?.push(
|
||||
Math.max(0, Math.min(tableRect.width, rect.right - tableRect.left))
|
||||
);
|
||||
}
|
||||
}
|
||||
const resolved = boundaries.map((samples, index) =>
|
||||
average(samples, (tableRect.width * index) / columnCount)
|
||||
);
|
||||
resolved[0] = 0;
|
||||
resolved[columnCount] = tableRect.width;
|
||||
for (let index = 1; index < resolved.length; index += 1) {
|
||||
resolved[index] = Math.max(
|
||||
resolved[index - 1]! + 0.01,
|
||||
resolved[index]!
|
||||
);
|
||||
}
|
||||
return resolved.slice(1).map(
|
||||
(boundary, index) => boundary - resolved[index]!
|
||||
);
|
||||
}
|
||||
|
||||
function cssColor(value: string) {
|
||||
const match = value.match(
|
||||
/^rgba?\(\s*(\d+(?:\.\d+)?)\s*,\s*(\d+(?:\.\d+)?)\s*,\s*(\d+(?:\.\d+)?)(?:\s*,\s*(\d+(?:\.\d+)?))?\s*\)$/iu
|
||||
);
|
||||
if (!match) {
|
||||
return undefined;
|
||||
}
|
||||
const alpha = match[4] === undefined ? 1 : Number(match[4]);
|
||||
if (!Number.isFinite(alpha) || alpha <= 0.01) {
|
||||
return undefined;
|
||||
}
|
||||
return `#${[match[1], match[2], match[3]]
|
||||
.map((component) =>
|
||||
Math.max(0, Math.min(255, Math.round(Number(component))))
|
||||
.toString(16)
|
||||
.padStart(2, "0")
|
||||
)
|
||||
.join("")}`;
|
||||
}
|
||||
|
||||
function effectiveCellBackground(
|
||||
cell: HTMLTableCellElement,
|
||||
table: HTMLTableElement
|
||||
) {
|
||||
let current: Element | null = cell;
|
||||
while (current) {
|
||||
const color = cssColor(getComputedStyle(current).backgroundColor);
|
||||
if (color) {
|
||||
return color;
|
||||
}
|
||||
if (current === table) {
|
||||
break;
|
||||
}
|
||||
current = current.parentElement;
|
||||
}
|
||||
return "#ffffff";
|
||||
}
|
||||
|
||||
function cellAlignment(value: string): "left" | "center" | "right" | "justify" {
|
||||
if (value === "center" || value === "right" || value === "justify") {
|
||||
return value;
|
||||
}
|
||||
return "left";
|
||||
}
|
||||
|
||||
const DOCX_TEXT_BLOCK_SELECTOR =
|
||||
"h1, h2, h3, h4, h5, h6, p, li, th, td, figcaption, " +
|
||||
".doc-classification, .doc-issue-row, .doc-printing-row, " +
|
||||
".doc-briefing-meta";
|
||||
|
||||
function textNodeCharacters(root: HTMLElement) {
|
||||
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
|
||||
const characters: Array<{
|
||||
node: Text;
|
||||
start: number;
|
||||
end: number;
|
||||
character: string;
|
||||
}> = [];
|
||||
let current = walker.nextNode();
|
||||
while (current) {
|
||||
const node = current as Text;
|
||||
let offset = 0;
|
||||
for (const character of Array.from(node.data)) {
|
||||
const end = offset + character.length;
|
||||
characters.push({ node, start: offset, end, character });
|
||||
offset = end;
|
||||
}
|
||||
current = walker.nextNode();
|
||||
}
|
||||
return characters;
|
||||
}
|
||||
|
||||
function characterRect(
|
||||
range: Range,
|
||||
character: ReturnType<typeof textNodeCharacters>[number]
|
||||
) {
|
||||
if (typeof range.getBoundingClientRect !== "function") {
|
||||
return undefined;
|
||||
}
|
||||
range.setStart(character.node, character.start);
|
||||
range.setEnd(character.node, character.end);
|
||||
const rect = range.getBoundingClientRect();
|
||||
return rect.width > 0 && rect.height > 0 ? rect : undefined;
|
||||
}
|
||||
|
||||
export function collectDocxTextBlockLayouts(
|
||||
article: HTMLElement
|
||||
): DocxTextBlockLayout[] {
|
||||
const candidates = Array.from(
|
||||
article.querySelectorAll<HTMLElement>(DOCX_TEXT_BLOCK_SELECTOR)
|
||||
).filter((element) => {
|
||||
if (element.closest('[data-semantic-region="cover"], pre')) {
|
||||
return false;
|
||||
}
|
||||
if (element.querySelector("br")) {
|
||||
return false;
|
||||
}
|
||||
return !Array.from(
|
||||
element.querySelectorAll<HTMLElement>(DOCX_TEXT_BLOCK_SELECTOR)
|
||||
).some((descendant) => descendant !== element);
|
||||
});
|
||||
return candidates.flatMap((element, index) => {
|
||||
const range = document.createRange();
|
||||
const output: string[] = [];
|
||||
const lineBreakOffsets: number[] = [];
|
||||
const lineCharacterRects: DOMRect[][] = [];
|
||||
let previousLineTop: number | undefined;
|
||||
let previousLineHeight = 0;
|
||||
let pendingWhitespace = false;
|
||||
for (const character of textNodeCharacters(element)) {
|
||||
if (/\s/u.test(character.character)) {
|
||||
pendingWhitespace = output.length > 0;
|
||||
continue;
|
||||
}
|
||||
const rect = characterRect(range, character);
|
||||
if (!rect) {
|
||||
continue;
|
||||
}
|
||||
if (pendingWhitespace && output.length > 0) {
|
||||
output.push(" ");
|
||||
}
|
||||
pendingWhitespace = false;
|
||||
if (
|
||||
previousLineTop !== undefined &&
|
||||
Math.abs(rect.top - previousLineTop) >
|
||||
Math.max(1, Math.min(previousLineHeight, rect.height) * 0.25)
|
||||
) {
|
||||
const offset = output.length;
|
||||
if (offset > 0 && lineBreakOffsets.at(-1) !== offset) {
|
||||
lineBreakOffsets.push(offset);
|
||||
}
|
||||
lineCharacterRects.push([]);
|
||||
} else if (lineCharacterRects.length === 0) {
|
||||
lineCharacterRects.push([]);
|
||||
}
|
||||
lineCharacterRects.at(-1)!.push(rect);
|
||||
output.push(character.character.normalize("NFKC"));
|
||||
previousLineTop = rect.top;
|
||||
previousLineHeight = rect.height;
|
||||
}
|
||||
const text = output.join("").trim();
|
||||
const computed = getComputedStyle(element);
|
||||
const letterSpacingPx = computed.letterSpacing === "normal"
|
||||
? 0
|
||||
: Number.parseFloat(computed.letterSpacing);
|
||||
const elementRect = element.getBoundingClientRect();
|
||||
const contentWidth = Math.max(
|
||||
0,
|
||||
elementRect.width -
|
||||
Number.parseFloat(computed.paddingLeft || "0") -
|
||||
Number.parseFloat(computed.paddingRight || "0") -
|
||||
Number.parseFloat(computed.borderLeftWidth || "0") -
|
||||
Number.parseFloat(computed.borderRightWidth || "0")
|
||||
);
|
||||
const lastLine = lineCharacterRects.at(-1) ?? [];
|
||||
const sortedLastLine = [...lastLine].sort(
|
||||
(left, right) => left.left - right.left
|
||||
);
|
||||
const lastLineWidth = sortedLastLine.length > 0
|
||||
? sortedLastLine.at(-1)!.right - sortedLastLine[0]!.left
|
||||
: 0;
|
||||
const maximumGap = sortedLastLine.slice(1).reduce(
|
||||
(largest, rect, rectIndex) => Math.max(
|
||||
largest,
|
||||
rect.left - sortedLastLine[rectIndex]!.right
|
||||
),
|
||||
0
|
||||
);
|
||||
const fontSizePx = Number.parseFloat(computed.fontSize);
|
||||
const distributed =
|
||||
sortedLastLine.length > 1 &&
|
||||
contentWidth > 0 &&
|
||||
lastLineWidth / contentWidth >= 0.85 &&
|
||||
Number.isFinite(fontSizePx) &&
|
||||
maximumGap >= fontSizePx * 0.5;
|
||||
const lineTops = lineCharacterRects
|
||||
.map((rects) => rects.length > 0
|
||||
? Math.min(...rects.map((rect) => rect.top))
|
||||
: undefined)
|
||||
.filter((top): top is number => top !== undefined);
|
||||
const linePitchPx = lineTops.length > 1
|
||||
? lineTops.slice(1).reduce(
|
||||
(sum, top, lineIndex) => sum + top - lineTops[lineIndex]!,
|
||||
0
|
||||
) / (lineTops.length - 1)
|
||||
: undefined;
|
||||
return text
|
||||
? [{
|
||||
ordinal: index + 1,
|
||||
text,
|
||||
letterSpacingPt: Number.isFinite(letterSpacingPx)
|
||||
? letterSpacingPx * 0.75
|
||||
: 0,
|
||||
alignment: distributed
|
||||
? "distribute" as const
|
||||
: cellAlignment(computed.textAlign),
|
||||
...(linePitchPx !== undefined && linePitchPx > 0
|
||||
? { linePitchPt: linePitchPx * 0.75 }
|
||||
: {}),
|
||||
lineBreakOffsets
|
||||
}]
|
||||
: [];
|
||||
});
|
||||
}
|
||||
|
||||
function listItemCharacters(item: HTMLLIElement) {
|
||||
return textNodeCharacters(item).filter(({ node }) => {
|
||||
const parent = node.parentElement;
|
||||
const nestedList = parent?.closest("ul, ol");
|
||||
return nestedList === item.parentElement;
|
||||
});
|
||||
}
|
||||
|
||||
export function collectDocxListItemLayouts(
|
||||
article: HTMLElement
|
||||
): DocxListItemLayout[] {
|
||||
const articleLeft = article.getBoundingClientRect().left;
|
||||
return Array.from(article.querySelectorAll<HTMLLIElement>("li")).flatMap(
|
||||
(item, index) => {
|
||||
const range = document.createRange();
|
||||
const characters = listItemCharacters(item);
|
||||
const text = characters
|
||||
.map(({ character }) => character)
|
||||
.join("")
|
||||
.replace(/\s+/gu, " ")
|
||||
.trim()
|
||||
.normalize("NFKC");
|
||||
const firstVisibleCharacter = characters.find(
|
||||
(character) =>
|
||||
!/\s/u.test(character.character) &&
|
||||
characterRect(range, character) !== undefined
|
||||
);
|
||||
if (!text || !firstVisibleCharacter) {
|
||||
return [];
|
||||
}
|
||||
const rect = characterRect(range, firstVisibleCharacter);
|
||||
if (!rect) {
|
||||
return [];
|
||||
}
|
||||
let listDepth = -1;
|
||||
for (
|
||||
let ancestor: Element | null = item.parentElement;
|
||||
ancestor && article.contains(ancestor);
|
||||
ancestor = ancestor.parentElement
|
||||
) {
|
||||
if (ancestor.matches("ul, ol")) {
|
||||
listDepth += 1;
|
||||
}
|
||||
}
|
||||
return [{
|
||||
ordinal: index + 1,
|
||||
text,
|
||||
depth: Math.max(0, listDepth),
|
||||
textStartPt: Math.max(
|
||||
0,
|
||||
(rect.left - articleLeft) * 0.75
|
||||
)
|
||||
}];
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function cssPixelsToPoints(value: string) {
|
||||
const pixels = Number.parseFloat(value);
|
||||
return Number.isFinite(pixels) ? pixels * 0.75 : 0;
|
||||
}
|
||||
|
||||
export function collectDocxInlineCodeLayouts(
|
||||
article: HTMLElement
|
||||
): DocxInlineCodeLayout[] {
|
||||
return Array.from(
|
||||
article.querySelectorAll<HTMLElement>("code")
|
||||
).filter(
|
||||
(element) =>
|
||||
!element.closest("pre, .md-code-pagination-chunk")
|
||||
).flatMap((element, index) => {
|
||||
const text = (element.textContent ?? "").normalize("NFKC");
|
||||
if (!text) {
|
||||
return [];
|
||||
}
|
||||
const computed = getComputedStyle(element);
|
||||
const fontSizePt = cssPixelsToPoints(computed.fontSize);
|
||||
if (fontSizePt <= 0) {
|
||||
return [];
|
||||
}
|
||||
const color = cssColor(computed.color) ?? "#000000";
|
||||
const backgroundColor = cssColor(computed.backgroundColor);
|
||||
return [{
|
||||
ordinal: index + 1,
|
||||
text,
|
||||
fontSizePt,
|
||||
letterSpacingPt: computed.letterSpacing === "normal"
|
||||
? 0
|
||||
: cssPixelsToPoints(computed.letterSpacing),
|
||||
color,
|
||||
...(backgroundColor ? { backgroundColor } : {}),
|
||||
paddingPt: {
|
||||
top: cssPixelsToPoints(computed.paddingTop),
|
||||
right: cssPixelsToPoints(computed.paddingRight),
|
||||
bottom: cssPixelsToPoints(computed.paddingBottom),
|
||||
left: cssPixelsToPoints(computed.paddingLeft)
|
||||
},
|
||||
borderPt: {
|
||||
top: cssPixelsToPoints(computed.borderTopWidth),
|
||||
right: cssPixelsToPoints(computed.borderRightWidth),
|
||||
bottom: cssPixelsToPoints(computed.borderBottomWidth),
|
||||
left: cssPixelsToPoints(computed.borderLeftWidth)
|
||||
}
|
||||
}];
|
||||
});
|
||||
}
|
||||
|
||||
export function collectDocxDocumentLayoutPlan(
|
||||
root: ParentNode,
|
||||
dimensions: DocxMediaRenderDimensions
|
||||
): DocxDocumentLayoutPlan {
|
||||
const article = root.querySelector<HTMLElement>("#write");
|
||||
if (!article) {
|
||||
throw new Error("DOCX 布局舞台缺少 #write 文档容器");
|
||||
}
|
||||
const articleRect = article.getBoundingClientRect();
|
||||
const contentWidth = finitePositive(
|
||||
articleRect.width,
|
||||
dimensions.contentWidthPx
|
||||
);
|
||||
const tables = Array.from(
|
||||
article.querySelectorAll<HTMLTableElement>("table")
|
||||
).map((table, index): DocxTableLayout => {
|
||||
const rect = table.getBoundingClientRect();
|
||||
const width = finitePositive(rect.width, contentWidth);
|
||||
const columnWidths = collectTableColumnWidths(table, rect);
|
||||
const columnTotal = columnWidths.reduce(
|
||||
(total, value) => total + value,
|
||||
0
|
||||
);
|
||||
return {
|
||||
ordinal: index + 1,
|
||||
widthPercent: Math.min(100, (width / contentWidth) * 100),
|
||||
leftOffsetPercent: Math.max(
|
||||
0,
|
||||
Math.min(100, ((rect.left - articleRect.left) / contentWidth) * 100)
|
||||
),
|
||||
columnWidthPercents: columnWidths.map(
|
||||
(value) => (value / columnTotal) * 100
|
||||
),
|
||||
rows: Array.from(table.rows).map((row) => ({
|
||||
cells: Array.from(row.cells).map((cell) => {
|
||||
const computed = getComputedStyle(cell);
|
||||
return {
|
||||
columnSpan: Math.max(1, cell.colSpan),
|
||||
backgroundColor: effectiveCellBackground(cell, table),
|
||||
color: cssColor(computed.color) ?? "#000000",
|
||||
bold: Number.parseInt(computed.fontWeight, 10) >= 600 ||
|
||||
computed.fontWeight === "bold",
|
||||
italic: computed.fontStyle === "italic" ||
|
||||
computed.fontStyle === "oblique",
|
||||
alignment: cellAlignment(computed.textAlign)
|
||||
};
|
||||
})
|
||||
}))
|
||||
};
|
||||
});
|
||||
return {
|
||||
tables,
|
||||
textBlocks: collectDocxTextBlockLayouts(article),
|
||||
listItems: collectDocxListItemLayouts(article),
|
||||
inlineCodes: collectDocxInlineCodeLayouts(article)
|
||||
};
|
||||
}
|
||||
|
||||
export function collectDocxMediaCaptureTargets(
|
||||
root: ParentNode,
|
||||
dimensions: DocxMediaRenderDimensions
|
||||
@@ -226,7 +659,8 @@ export async function renderDocxMediaCapturePlan(
|
||||
dimensions: DocxMediaRenderDimensions
|
||||
): Promise<DocxMediaCapturePlan> {
|
||||
const renderResult = await runtime.renderContinuous(payload, {
|
||||
geometryCss: createGeometryCss(dimensions)
|
||||
geometryCss: createGeometryCss(dimensions),
|
||||
themeMedia: "print"
|
||||
});
|
||||
if (!renderResult) {
|
||||
throw new Error("DOCX 媒体渲染已取消");
|
||||
@@ -234,6 +668,7 @@ export async function renderDocxMediaCapturePlan(
|
||||
return {
|
||||
targets: collectDocxMediaCaptureTargets(root, dimensions),
|
||||
echartsErrors: renderResult.echartsErrors,
|
||||
mermaidErrors: renderResult.mermaidErrors
|
||||
mermaidErrors: renderResult.mermaidErrors,
|
||||
documentLayout: collectDocxDocumentLayoutPlan(root, dimensions)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
export const SHARED_HIGHLIGHT_CSS = `
|
||||
pre code.hljs{display:block;overflow-x:auto;padding:1em}
|
||||
code.hljs{padding:3px 5px}
|
||||
.hljs{color:#24292e;background:#fff}
|
||||
.hljs-doctag,.hljs-keyword,.hljs-meta .hljs-keyword,.hljs-template-tag,.hljs-template-variable,.hljs-type,.hljs-variable.language_{color:#d73a49}
|
||||
.hljs-title,.hljs-title.class_,.hljs-title.class_.inherited__,.hljs-title.function_{color:#6f42c1}
|
||||
.hljs-attr,.hljs-attribute,.hljs-literal,.hljs-meta,.hljs-number,.hljs-operator,.hljs-variable,.hljs-selector-attr,.hljs-selector-class,.hljs-selector-id{color:#005cc5}
|
||||
.hljs-regexp,.hljs-string,.hljs-meta .hljs-string{color:#032f62}
|
||||
.hljs-built_in,.hljs-symbol{color:#e36209}
|
||||
.hljs-comment,.hljs-code,.hljs-formula{color:#6a737d}
|
||||
.hljs-name,.hljs-quote,.hljs-selector-tag,.hljs-selector-pseudo{color:#22863a}
|
||||
.hljs-subst{color:#24292e}
|
||||
.hljs-section{color:#005cc5;font-weight:bold}
|
||||
.hljs-bullet{color:#735c0f}
|
||||
.hljs-emphasis{color:#24292e;font-style:italic}
|
||||
.hljs-strong{color:#24292e;font-weight:bold}
|
||||
.hljs-addition{color:#22863a;background-color:#f0fff4}
|
||||
.hljs-deletion{color:#b31d28;background-color:#ffeef0}
|
||||
`.trim();
|
||||
@@ -1,5 +1,6 @@
|
||||
export * from "./diagram-page-fit.js";
|
||||
export * from "./continuous-preview.js";
|
||||
export * from "./highlight-styles.js";
|
||||
export * from "./document-image-fit.js";
|
||||
export * from "./docx-media-runtime.js";
|
||||
export * from "./echarts-page-fit.js";
|
||||
|
||||
@@ -66,6 +66,7 @@ export interface PagedDocumentRenderOptions {
|
||||
export interface ContinuousDocumentRenderOptions {
|
||||
shouldContinue?: () => boolean;
|
||||
geometryCss?: string;
|
||||
themeMedia?: "preview" | "print";
|
||||
}
|
||||
|
||||
export interface PreviewEngineStyles {
|
||||
@@ -195,6 +196,34 @@ function removeTrailingPdfPageBreak(container: ParentNode) {
|
||||
?.style.setProperty("break-after", "auto", "important");
|
||||
}
|
||||
|
||||
const PAGED_SPLIT_BLOCK_SELECTOR = [
|
||||
"h1", "h2", "h3", "h4", "h5", "h6", "p", "ul", "ol", "li",
|
||||
"blockquote", "table", "thead", "tbody", "tfoot", "tr", "th", "td",
|
||||
"figure", "figcaption", "pre", "section", "article", "div"
|
||||
].join(",");
|
||||
|
||||
export function removeInheritedPagedSplitJustification(
|
||||
container: ParentNode
|
||||
) {
|
||||
let count = 0;
|
||||
for (const element of Array.from(
|
||||
container.querySelectorAll<HTMLElement>(
|
||||
'[data-align-last-split-element="justify"]'
|
||||
)
|
||||
)) {
|
||||
if (
|
||||
!Array.from(element.children).some((child) =>
|
||||
child.matches(PAGED_SPLIT_BLOCK_SELECTOR)
|
||||
)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
element.removeAttribute("data-align-last-split-element");
|
||||
count += 1;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
function createPagedRenderIdentity(
|
||||
payload: PagedPreviewPayload,
|
||||
options: PagedDocumentRenderOptions
|
||||
@@ -411,7 +440,8 @@ export class PagedDocumentRuntime {
|
||||
private applyContinuousStyles(
|
||||
documentRef: Document,
|
||||
payload: PagedPreviewPayload,
|
||||
geometryCss = ""
|
||||
geometryCss = "",
|
||||
themeMedia: ContinuousDocumentRenderOptions["themeMedia"] = "preview"
|
||||
) {
|
||||
const style =
|
||||
this.continuousStyle ?? documentRef.createElement("style");
|
||||
@@ -421,7 +451,9 @@ export class PagedDocumentRuntime {
|
||||
this.styles.highlightCss,
|
||||
this.styles.katexCss,
|
||||
this.styles.echartsCss,
|
||||
enablePrintMediaForPreview(payload.themeCss),
|
||||
themeMedia === "preview"
|
||||
? enablePrintMediaForPreview(payload.themeCss)
|
||||
: payload.themeCss,
|
||||
documentInteractionCss,
|
||||
continuousDocumentGeometryCss,
|
||||
geometryCss
|
||||
@@ -897,6 +929,7 @@ export class PagedDocumentRuntime {
|
||||
}
|
||||
|
||||
const finalizeStartedAt = performance.now();
|
||||
removeInheritedPagedSplitJustification(this.root);
|
||||
const pageSequence = classifyPagedPages(this.root, payload);
|
||||
pageCount = pageSequence.physicalPageCount;
|
||||
applyPagedPageDecorations(this.root, payload, pageSequence);
|
||||
@@ -954,7 +987,8 @@ export class PagedDocumentRuntime {
|
||||
this.applyContinuousStyles(
|
||||
documentRef,
|
||||
payload,
|
||||
options.geometryCss
|
||||
options.geometryCss,
|
||||
options.themeMedia
|
||||
);
|
||||
|
||||
const template = documentRef.createElement("template");
|
||||
@@ -969,7 +1003,8 @@ export class PagedDocumentRuntime {
|
||||
const identity = JSON.stringify({
|
||||
themeCss: payload.themeCss,
|
||||
mermaid: payload.exportConfig.mermaid,
|
||||
geometryCss: options.geometryCss
|
||||
geometryCss: options.geometryCss,
|
||||
themeMedia: options.themeMedia ?? "preview"
|
||||
});
|
||||
const currentArticle =
|
||||
this.root.querySelector<HTMLElement>(":scope > #write");
|
||||
|
||||
@@ -95,7 +95,6 @@ svg {
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
font-weight: inherit;
|
||||
line-height: inherit;
|
||||
@@ -548,6 +547,11 @@ html[data-render-target="pdf"] body {
|
||||
min-height: 0 !important;
|
||||
overflow: visible !important;
|
||||
background: #fff !important;
|
||||
border: 0 !important;
|
||||
break-before: auto !important;
|
||||
break-after: auto !important;
|
||||
page-break-before: auto !important;
|
||||
page-break-after: auto !important;
|
||||
}
|
||||
|
||||
html[data-render-target="pdf"] .pagedjs_pages {
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { collectDocxMediaCaptureTargets } from "../src/index.js";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
collectDocxDocumentLayoutPlan,
|
||||
collectDocxInlineCodeLayouts,
|
||||
collectDocxListItemLayouts,
|
||||
collectDocxTextBlockLayouts,
|
||||
collectDocxMediaCaptureTargets,
|
||||
removeInheritedPagedSplitJustification,
|
||||
renderDocxMediaCapturePlan
|
||||
} from "../src/index.js";
|
||||
|
||||
describe("DOCX 媒体捕获计划", () => {
|
||||
beforeEach(() => {
|
||||
@@ -9,6 +17,74 @@ describe("DOCX 媒体捕获计划", () => {
|
||||
window.scrollTo(0, 0);
|
||||
});
|
||||
|
||||
it("连续布局使用原始打印媒体主题 CSS", async () => {
|
||||
const renderContinuous = vi.fn(async () => ({
|
||||
echartsErrors: [],
|
||||
mermaidErrors: []
|
||||
}));
|
||||
const root = document.createElement("main");
|
||||
root.innerHTML = '<article id="write"></article>';
|
||||
|
||||
await renderDocxMediaCapturePlan(
|
||||
{ renderContinuous } as never,
|
||||
root,
|
||||
{
|
||||
articleHtml: '<article id="write"></article>',
|
||||
fileName: "打印媒体.md",
|
||||
metadata: {
|
||||
title: "",
|
||||
author: "",
|
||||
subject: "",
|
||||
keywords: [],
|
||||
language: "zh-CN"
|
||||
},
|
||||
semanticDocument: {
|
||||
schemaVersion: 1,
|
||||
titlePolicy: {
|
||||
metadataTitle: "suppress",
|
||||
firstBodyHeading: "keep"
|
||||
},
|
||||
regions: []
|
||||
},
|
||||
features: [],
|
||||
themeCss: "@media print { html { font-size: 13px; } }",
|
||||
exportConfig: {} as never
|
||||
},
|
||||
{ contentWidthPx: 640, contentHeightPx: 900 }
|
||||
);
|
||||
|
||||
expect(renderContinuous).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({ themeMedia: "print" })
|
||||
);
|
||||
});
|
||||
|
||||
it("只移除会把 Paged.js 末行两端对齐继承给子块的容器标记", () => {
|
||||
document.body.innerHTML = `
|
||||
<main id="pages">
|
||||
<article id="write" data-align-last-split-element="justify">
|
||||
<h1>标题</h1>
|
||||
<p data-align-last-split-element="justify">跨页正文</p>
|
||||
</article>
|
||||
</main>
|
||||
`;
|
||||
expect(
|
||||
removeInheritedPagedSplitJustification(
|
||||
document.querySelector("#pages")!
|
||||
)
|
||||
).toBe(1);
|
||||
expect(
|
||||
document.querySelector("#write")?.hasAttribute(
|
||||
"data-align-last-split-element"
|
||||
)
|
||||
).toBe(false);
|
||||
expect(
|
||||
document.querySelector("p")?.getAttribute(
|
||||
"data-align-last-split-element"
|
||||
)
|
||||
).toBe("justify");
|
||||
});
|
||||
|
||||
it("按文档顺序标记图片、Mermaid 和 ECharts", () => {
|
||||
document.body.innerHTML = `
|
||||
<article id="write">
|
||||
@@ -155,4 +231,236 @@ describe("DOCX 媒体捕获计划", () => {
|
||||
).toBeLessThanOrEqual(4096);
|
||||
expect(target?.altText).toBe("图片 1");
|
||||
});
|
||||
|
||||
it("从真实 DOM 几何采集主题无关的表格列宽比例", () => {
|
||||
document.body.innerHTML = `
|
||||
<article id="write">
|
||||
<table><tbody>
|
||||
<tr><td>短列</td><td>较长内容列</td></tr>
|
||||
<tr><td>1</td><td>2</td></tr>
|
||||
</tbody></table>
|
||||
</article>
|
||||
`;
|
||||
const article = document.querySelector<HTMLElement>("#write")!;
|
||||
const table = document.querySelector<HTMLTableElement>("table")!;
|
||||
const cells = Array.from(table.querySelectorAll<HTMLTableCellElement>("td"));
|
||||
article.getBoundingClientRect = () => ({
|
||||
left: 100,
|
||||
right: 900,
|
||||
top: 0,
|
||||
bottom: 900,
|
||||
width: 800,
|
||||
height: 900
|
||||
}) as DOMRect;
|
||||
table.getBoundingClientRect = () => ({
|
||||
left: 140,
|
||||
right: 860,
|
||||
top: 20,
|
||||
bottom: 220,
|
||||
width: 720,
|
||||
height: 200
|
||||
}) as DOMRect;
|
||||
cells.forEach((cell, index) => {
|
||||
const firstColumn = index % 2 === 0;
|
||||
cell.getBoundingClientRect = () => ({
|
||||
left: firstColumn ? 140 : 356,
|
||||
right: firstColumn ? 356 : 860,
|
||||
top: index < 2 ? 20 : 120,
|
||||
bottom: index < 2 ? 120 : 220,
|
||||
width: firstColumn ? 216 : 504,
|
||||
height: 100
|
||||
}) as DOMRect;
|
||||
});
|
||||
|
||||
expect(
|
||||
collectDocxDocumentLayoutPlan(document, {
|
||||
contentWidthPx: 800,
|
||||
contentHeightPx: 900
|
||||
})
|
||||
).toEqual({
|
||||
inlineCodes: [],
|
||||
listItems: [],
|
||||
textBlocks: [],
|
||||
tables: [
|
||||
{
|
||||
ordinal: 1,
|
||||
widthPercent: 90,
|
||||
leftOffsetPercent: 5,
|
||||
columnWidthPercents: [30, 70],
|
||||
rows: [
|
||||
{
|
||||
cells: [
|
||||
{
|
||||
columnSpan: 1,
|
||||
backgroundColor: "#ffffff",
|
||||
color: "#000000",
|
||||
bold: false,
|
||||
italic: false,
|
||||
alignment: "left"
|
||||
},
|
||||
{
|
||||
columnSpan: 1,
|
||||
backgroundColor: "#ffffff",
|
||||
color: "#000000",
|
||||
bold: false,
|
||||
italic: false,
|
||||
alignment: "left"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
cells: [
|
||||
{
|
||||
columnSpan: 1,
|
||||
backgroundColor: "#ffffff",
|
||||
color: "#000000",
|
||||
bold: false,
|
||||
italic: false,
|
||||
alignment: "left"
|
||||
},
|
||||
{
|
||||
columnSpan: 1,
|
||||
backgroundColor: "#ffffff",
|
||||
color: "#000000",
|
||||
bold: false,
|
||||
italic: false,
|
||||
alignment: "left"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
});
|
||||
});
|
||||
|
||||
it("按真实上下文采集行内代码字号、颜色和盒模型", () => {
|
||||
document.body.innerHTML = `
|
||||
<article id="write" style="font-size: 20px">
|
||||
<p><code style="font-size: .8em; letter-spacing: 1px; color: rgb(17, 34, 51); background: rgb(245, 245, 245); padding: 2px 4px">inline</code></p>
|
||||
<pre><code>block</code></pre>
|
||||
</article>
|
||||
`;
|
||||
expect(
|
||||
collectDocxInlineCodeLayouts(
|
||||
document.querySelector<HTMLElement>("#write")!
|
||||
)
|
||||
).toEqual([
|
||||
{
|
||||
ordinal: 1,
|
||||
text: "inline",
|
||||
fontSizePt: 12,
|
||||
letterSpacingPt: 0.75,
|
||||
color: "#112233",
|
||||
backgroundColor: "#f5f5f5",
|
||||
paddingPt: {
|
||||
top: 1.5,
|
||||
right: 3,
|
||||
bottom: 1.5,
|
||||
left: 3
|
||||
},
|
||||
borderPt: {
|
||||
top: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
left: 0
|
||||
}
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
it("按列表项实际文字起点采集嵌套缩进且排除子列表文字", () => {
|
||||
document.body.innerHTML = `
|
||||
<article id="write">
|
||||
<ul><li>一级 <code>代码</code><ul><li>二级项目</li></ul></li></ul>
|
||||
</article>
|
||||
`;
|
||||
const article = document.querySelector<HTMLElement>("#write")!;
|
||||
article.getBoundingClientRect = () => ({
|
||||
left: 100,
|
||||
right: 900,
|
||||
top: 0,
|
||||
bottom: 900,
|
||||
width: 800,
|
||||
height: 900
|
||||
}) as DOMRect;
|
||||
const rangePrototype = Object.getPrototypeOf(document.createRange()) as {
|
||||
getBoundingClientRect?: () => DOMRect;
|
||||
};
|
||||
const original = rangePrototype.getBoundingClientRect;
|
||||
rangePrototype.getBoundingClientRect = function (this: Range) {
|
||||
const left = this.startContainer.textContent === "二级项目" ? 340 : 220;
|
||||
return {
|
||||
left,
|
||||
right: left + 10,
|
||||
top: 20,
|
||||
bottom: 32,
|
||||
width: 10,
|
||||
height: 12
|
||||
} as DOMRect;
|
||||
};
|
||||
try {
|
||||
expect(collectDocxListItemLayouts(article)).toEqual([
|
||||
{
|
||||
ordinal: 1,
|
||||
text: "一级 代码",
|
||||
depth: 0,
|
||||
textStartPt: 90
|
||||
},
|
||||
{
|
||||
ordinal: 2,
|
||||
text: "二级项目",
|
||||
depth: 1,
|
||||
textStartPt: 180
|
||||
}
|
||||
]);
|
||||
} finally {
|
||||
rangePrototype.getBoundingClientRect = original;
|
||||
}
|
||||
});
|
||||
|
||||
it("采集正文文本块在 Chromium 中的实际行断点", () => {
|
||||
document.body.innerHTML = `
|
||||
<article id="write">
|
||||
<section data-semantic-region="cover"><p>独立封面</p></section>
|
||||
<p>甲乙丙丁</p>
|
||||
</article>
|
||||
`;
|
||||
const rangePrototype = Object.getPrototypeOf(document.createRange()) as {
|
||||
getBoundingClientRect?: () => DOMRect;
|
||||
};
|
||||
const original = rangePrototype.getBoundingClientRect;
|
||||
rangePrototype.getBoundingClientRect = function (this: Range) {
|
||||
const top = this.startOffset < 2 ? 100 : 120;
|
||||
return {
|
||||
x: this.startOffset * 10,
|
||||
y: top,
|
||||
left: this.startOffset * 10,
|
||||
right: this.startOffset * 10 + 10,
|
||||
top,
|
||||
bottom: top + 12,
|
||||
width: 10,
|
||||
height: 12,
|
||||
toJSON: () => ({})
|
||||
} as DOMRect;
|
||||
};
|
||||
try {
|
||||
expect(
|
||||
collectDocxTextBlockLayouts(
|
||||
document.querySelector<HTMLElement>("#write")!
|
||||
)
|
||||
).toEqual([
|
||||
{
|
||||
ordinal: 1,
|
||||
text: "甲乙丙丁",
|
||||
letterSpacingPt: 0,
|
||||
alignment: "left",
|
||||
linePitchPt: 15,
|
||||
lineBreakOffsets: [2]
|
||||
}
|
||||
]);
|
||||
} finally {
|
||||
rangePrototype.getBoundingClientRect = original;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -62,6 +62,7 @@ describe("分页预览协议", () => {
|
||||
expect(css).toContain('html[data-render-target="pdf"]');
|
||||
expect(css).toContain("height: auto !important");
|
||||
expect(css).toContain("overflow: visible !important");
|
||||
expect(css).toContain("page-break-after: auto !important");
|
||||
expect(css).toContain("padding: 0");
|
||||
expect(css).toContain("break-after: page");
|
||||
expect(css).not.toContain("@media print");
|
||||
@@ -243,6 +244,9 @@ describe("分页预览协议", () => {
|
||||
);
|
||||
expect(documentBaseCss).toContain("background: transparent;");
|
||||
expect(documentBaseCss).toContain("font-size: inherit;");
|
||||
expect(documentBaseCss).not.toMatch(
|
||||
/#write pre\.md-fences > code,[\s\S]*?font-family:\s*inherit;/u
|
||||
);
|
||||
expect(documentBaseCss).not.toMatch(
|
||||
/#write pre\.md-fences > code\s*\{[^}]*!important/su
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user