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:
SkyJourney
2026-08-04 10:30:44 +08:00
parent b275c671fc
commit 2c5c1bd317
84 changed files with 4404 additions and 344 deletions
@@ -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
View File
@@ -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");
+5 -1
View File
@@ -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 {