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

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

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

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

验证结果:合成基线与长庆严格 280/280,M4N 140/140;健康数据残余误报 6/115(5.22%),均核查为重复表头自动对齐/取样误报且基础设施错误为 0。全项目测试、类型检查、生产构建和 git diff --check 通过;正式 Docker、NSIS、ZIP、离线镜像、部署包、清单及 SHA-256 均已生成并校验。
This commit is contained in:
SkyJourney
2026-08-26 10:50:20 +08:00
parent 01b06abc2c
commit 64445322eb
75 changed files with 9255 additions and 298 deletions
@@ -44,6 +44,8 @@ type DocxBorderToken = NonNullable<
const WORDPROCESSING_DRAWING_NAMESPACE =
"http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing";
const MATH_NAMESPACE =
"http://schemas.openxmlformats.org/officeDocument/2006/math";
const XML_NAMESPACE = "http://www.w3.org/XML/1998/namespace";
// Chromium 预留 6ptWord/WPS 的分节承载段落还需要额外分页保留量。
const COVER_PAGE_BREAK_SAFETY_PT = 6;
@@ -2064,9 +2066,10 @@ function applyDirectCharacterStyleTokens(
const inlineCodeLayouts = documentLayout.inlineCodes ?? [];
let inlineCodeCursor = 0;
let count = 0;
for (const run of Array.from(
const runs = Array.from(
document.getElementsByTagNameNS(WORD_NAMESPACE, "r")
)) {
);
for (const [runIndex, run] of runs.entries()) {
const properties = firstDirectChild(
run,
WORD_NAMESPACE,
@@ -2099,8 +2102,29 @@ function applyDirectCharacterStyleTokens(
const isSourceCode = paragraphStyle
? wordAttribute(paragraphStyle, "val") === "SourceCode"
: false;
const previousRun = runs[runIndex - 1];
const nextRun = runs[runIndex + 1];
const isHtmlTagName =
isSourceCode &&
styleId === "KeywordTok" &&
previousRun?.parentNode === run.parentNode &&
nextRun?.parentNode === run.parentNode &&
/^<\/?$/u.test(previousRun.textContent ?? "") &&
/^\/?>$/u.test(nextRun.textContent ?? "");
const isObjectPropertyKey =
isSourceCode &&
styleId === "NormalTok" &&
nextRun?.parentNode === run.parentNode &&
/^\s*:\s*$/u.test(nextRun.textContent ?? "");
const syntaxSlot = isSourceCode && styleId
? resolvePandocSyntaxStyleSlot(styleId, run.textContent ?? "")
? resolvePandocSyntaxStyleSlot(
styleId,
run.textContent ?? "",
{
htmlTagName: isHtmlTagName,
objectPropertyKey: isObjectPropertyKey
}
)
: undefined;
const token = isSourceCode
? syntaxSlot
@@ -2157,6 +2181,37 @@ function applyDirectCharacterStyleTokens(
return count;
}
function applyMeasuredEmojiRunColors(
document: XmlDocument,
documentLayout: DocxDocumentLayoutPlan
) {
const measuredRuns = documentLayout.emojiRuns ?? [];
let cursor = 0;
let count = 0;
for (const run of Array.from(
document.getElementsByTagNameNS(WORD_NAMESPACE, "r")
)) {
const text = (run.textContent ?? "").normalize("NFC").trim();
if (!text) {
continue;
}
const measuredIndex = measuredRuns.findIndex(
(layout, index) => index >= cursor && layout.text === text
);
if (measuredIndex < 0) {
continue;
}
cursor = measuredIndex + 1;
const properties = ensureFirstElement(run, "rPr", "w:rPr");
removeDirectChildren(properties, WORD_NAMESPACE, "color");
appendElement(properties, WORD_NAMESPACE, "w:color", {
"w:val": colorValue(measuredRuns[measuredIndex]!.color)
});
count += 1;
}
return count;
}
function applyTableCellToken(
cell: XmlElement,
token: DocxSlotStyleToken
@@ -2221,15 +2276,24 @@ function applyMeasuredTableGeometry(
table: XmlElement,
properties: XmlElement,
layout: DocxDocumentLayoutPlan["tables"][number],
contentWidthTwips: number
contentWidthTwips: number,
leadingCellInsetTwips: number
) {
const tableWidthTwips = Math.max(
const indentTwips = Math.max(
0,
Math.round(contentWidthTwips * layout.leftOffsetPercent / 100) +
leadingCellInsetTwips
);
const requestedTableWidthTwips = Math.max(
1,
Math.round(contentWidthTwips * layout.widthPercent / 100)
);
const indentTwips = Math.max(
0,
Math.round(contentWidthTwips * layout.leftOffsetPercent / 100)
const tableWidthTwips = Math.max(
1,
Math.min(
requestedTableWidthTwips,
Math.max(1, contentWidthTwips - indentTwips)
)
);
const columnWidths = measuredColumnWidths(
tableWidthTwips,
@@ -2315,7 +2379,11 @@ function applyTables(
table,
properties,
measuredLayout,
contentWidthTwips
contentWidthTwips,
pointsToTwips(Math.max(
tableCellToken?.paddingPt?.left ?? 0,
tableHeaderToken?.paddingPt?.left ?? 0
))
);
} else {
removeDirectChildren(
@@ -2427,8 +2495,15 @@ function applyTables(
WORD_NAMESPACE,
"p"
)) {
const properties = paragraphProperties(paragraph);
const wordWrap = ensureFirstElement(
properties,
"wordWrap",
"w:wordWrap"
);
setWordAttribute(wordWrap, "val", "1");
const spacing = ensureFirstElement(
paragraphProperties(paragraph),
properties,
"spacing",
"w:spacing"
);
@@ -2436,7 +2511,7 @@ function applyTables(
setWordAttribute(spacing, "after", "0");
for (const property of ["autoSpaceDE", "autoSpaceDN"] as const) {
const automaticSpacing = ensureFirstElement(
paragraphProperties(paragraph),
properties,
property,
`w:${property}`
);
@@ -2609,6 +2684,18 @@ function applyMeasuredListItemIndents(
String(Math.min(targetLeft, hanging))
);
}
if (measured.alignment) {
const alignment = ensureDirectElement(
properties,
WORD_NAMESPACE,
"w:jc"
);
setWordAttribute(
alignment,
"val",
measured.alignment === "justify" ? "both" : measured.alignment
);
}
appliedCount += 1;
}
return appliedCount;
@@ -2727,6 +2814,33 @@ function measuredLineBreakOffsetsForParagraph(
});
}
function paragraphHasInlineCode(paragraph: XmlElement) {
return Array.from(
paragraph.getElementsByTagNameNS(WORD_NAMESPACE, "rStyle")
).some((style) => wordAttribute(style, "val") === "VerbatimChar");
}
function stabilizeInlineCodeParagraphLineRules(document: XmlDocument) {
let count = 0;
for (const paragraph of Array.from(
document.getElementsByTagNameNS(WORD_NAMESPACE, "p")
)) {
if (!paragraphHasInlineCode(paragraph)) {
continue;
}
const spacing = directChildren(
paragraphProperties(paragraph),
WORD_NAMESPACE,
"spacing"
)[0];
if (spacing && wordAttribute(spacing, "lineRule") === "exact") {
setWordAttribute(spacing, "lineRule", "atLeast");
count += 1;
}
}
return count;
}
function applyMeasuredTextBlockLineBreaks(
document: XmlDocument,
documentLayout: DocxDocumentLayoutPlan
@@ -2765,7 +2879,11 @@ function applyMeasuredTextBlockLineBreaks(
"line",
String(pointsToTwips(measured.linePitchPt))
);
setWordAttribute(spacing, "lineRule", "exact");
setWordAttribute(
spacing,
"lineRule",
paragraphHasInlineCode(paragraph) ? "atLeast" : "exact"
);
}
for (const offset of [...measuredLineBreakOffsetsForParagraph(
paragraph,
@@ -2781,6 +2899,47 @@ function applyMeasuredTextBlockLineBreaks(
return appliedCount;
}
function stabilizeTableMathParagraphAlignment(document: XmlDocument) {
let count = 0;
for (const paragraph of Array.from(
document.getElementsByTagNameNS(WORD_NAMESPACE, "p")
)) {
const parent = paragraph.parentNode;
if (
!parent ||
parent.nodeType !== 1 ||
(parent as XmlElement).namespaceURI !== WORD_NAMESPACE ||
(parent as XmlElement).localName !== "tc"
) {
continue;
}
const content = Array.from(paragraph.childNodes).filter(
(node): node is XmlElement => node.nodeType === 1
).filter(
(child) =>
!(child.namespaceURI === WORD_NAMESPACE && child.localName === "pPr")
);
if (
content.length === 0 ||
!content.every(
(child) =>
child.namespaceURI === MATH_NAMESPACE &&
child.localName === "oMath"
)
) {
continue;
}
const run = appendElement(paragraph, WORD_NAMESPACE, "w:r");
const properties = appendElement(run, WORD_NAMESPACE, "w:rPr");
appendElement(properties, WORD_NAMESPACE, "w:noProof");
const text = appendElement(run, WORD_NAMESPACE, "w:t");
text.setAttributeNS(XML_NAMESPACE, "xml:space", "preserve");
text.textContent = "\u200B";
count += 1;
}
return count;
}
function pageBreakAfterStyleIds(tokens: DocxThemeTokenSet) {
const ids = new Set<string>();
for (const entry of tokens.slots) {
@@ -3098,6 +3257,99 @@ function disableAutomaticCharacterSpacing(document: XmlDocument) {
}
}
const PANDOC_ALERT_LABELS = new Set([
"note",
"tip",
"important",
"warning",
"caution"
]);
function normalizePandocAlertParagraphStyles(
body: XmlElement,
documentLayout: DocxDocumentLayoutPlan
) {
const children = directChildren(body, WORD_NAMESPACE, "p");
const measuredAlerts = (documentLayout.textBlocks ?? []).filter(
(block) => block.alertRole !== undefined
);
let measuredCursor = 0;
let count = 0;
for (const [index, paragraph] of children.entries()) {
if (
paragraphStyleId(paragraph) !== "FirstParagraph" ||
!PANDOC_ALERT_LABELS.has(
normalizedLayoutText(paragraphText(paragraph)).toLocaleLowerCase(
"en-US"
)
)
) {
continue;
}
const content = children[index + 1];
if (!content || paragraphStyleId(content) !== "BodyText") {
continue;
}
const style = ensureDirectElement(
paragraphProperties(content),
WORD_NAMESPACE,
"w:pStyle"
);
setWordAttribute(style, "val", "BlockText");
for (const [target, role] of [
[paragraph, "title"],
[content, "body"]
] as const) {
const text = normalizedLayoutText(paragraphText(target));
const relativeMatchIndex = measuredAlerts
.slice(measuredCursor)
.findIndex(
(block) =>
block.alertRole === role &&
normalizedLayoutText(block.text) === text
);
if (relativeMatchIndex < 0) {
continue;
}
const measuredIndex = measuredCursor + relativeMatchIndex;
const measured = measuredAlerts[measuredIndex]!;
measuredCursor = measuredIndex + 1;
const properties = paragraphProperties(target);
const indentation = ensureDirectElement(
properties,
WORD_NAMESPACE,
"w:ind"
);
if (measured.leftIndentPt !== undefined) {
setWordAttribute(
indentation,
"left",
String(pointsToTwips(measured.leftIndentPt))
);
}
if (measured.rightIndentPt !== undefined) {
setWordAttribute(
indentation,
"right",
String(pointsToTwips(measured.rightIndentPt))
);
}
applyDirectRunToken(target, {
fontCandidates: [],
...(measured.fontSizePt !== undefined
? { fontSizePt: measured.fontSizePt }
: {}),
bold: measured.bold,
italic: measured.italic,
color: measured.color,
letterSpacingPt: measured.letterSpacingPt
});
}
count += 1;
}
return count;
}
export function finalizeGeneratedDocxStructure(
content: Uint8Array,
plan: PandocStructurePlan,
@@ -3141,6 +3393,7 @@ export function finalizeGeneratedDocxStructure(
containers.editableContainerTables.size > 0,
usesEvenAndOddPages
);
normalizePandocAlertParagraphStyles(body, documentLayout);
disableAutomaticCharacterSpacing(document);
const final = finalSection(body);
applyMeasuredTextBlockLineBreaks(document, documentLayout);
@@ -3160,6 +3413,9 @@ export function finalizeGeneratedDocxStructure(
);
applyMeasuredTextBlockAlignments(document, documentLayout);
applyDirectCharacterStyleTokens(document, tokens, documentLayout);
applyMeasuredEmojiRunColors(document, documentLayout);
stabilizeInlineCodeParagraphLineRules(document);
stabilizeTableMathParagraphAlignment(document);
const mediaDrawingCount = normalizeMediaParagraphs(
document,
tokens,
+25 -3
View File
@@ -103,6 +103,12 @@ export interface PandocDocxConverterOptions {
referenceCacheSize?: number;
}
export interface PandocDocxConversionDiagnostics {
outcome: string;
exitCode: number | null;
stderr: string;
}
export class PandocDocxConversionError extends Error {
constructor(
readonly code: Extract<
@@ -113,11 +119,26 @@ export class PandocDocxConversionError extends Error {
| "DOCX_OUTPUT_INVALID"
>,
message: string,
options?: ErrorOptions
options?: ErrorOptions & {
diagnostics?: PandocDocxConversionDiagnostics;
}
) {
super(message, options);
this.name = "PandocDocxConversionError";
this.diagnostics = options?.diagnostics;
}
readonly diagnostics: PandocDocxConversionDiagnostics | undefined;
}
function processDiagnostics(
result: Awaited<ReturnType<PandocProcessRunner>>
): PandocDocxConversionDiagnostics {
return {
outcome: result.outcome,
exitCode: result.exitCode,
stderr: result.stderr.trim().slice(-16 * 1024)
};
}
function ensureOutputLimit(value: number | undefined) {
@@ -172,7 +193,7 @@ function pandocArguments(paths: {
return [
paths.markdown,
"--from",
"markdown+yaml_metadata_block+pipe_tables+footnotes+task_lists+tex_math_dollars-raw_html",
"commonmark_x-yaml_metadata_block+pipe_tables+footnotes+task_lists+tex_math_dollars+raw_html",
"--to",
"docx",
"--standalone",
@@ -351,7 +372,8 @@ export class PandocDocxConverter {
processResult.outcome === "not-found"
? "DOCX_RUNTIME_NOT_FOUND"
: "DOCX_GENERATION_FAILED",
"Pandoc 未能生成 DOCX"
"Pandoc 未能生成 DOCX",
{ diagnostics: processDiagnostics(processResult) }
);
}
+2 -2
View File
@@ -2,8 +2,8 @@ import {
DOCX_MEDIA_CSS_DPI,
MAXIMUM_DOCX_MEDIA_BYTES,
MAXIMUM_DOCX_MEDIA_EDGE_PIXELS,
MAXIMUM_DOCX_MEDIA_COUNT,
MAXIMUM_DOCX_MEDIA_PIXELS,
MAXIMUM_DOCX_RESOURCE_COUNT,
MAXIMUM_DOCX_TOTAL_MEDIA_BYTES,
type DocxMediaKind,
type DocxDocumentLayoutPlan,
@@ -101,7 +101,7 @@ export function preparePandocMedia(
if (media.echartsErrors.length || media.mermaidErrors.length) {
throw new Error("DOCX 图表渲染存在错误,已中止转换");
}
if (media.resources.length > MAXIMUM_DOCX_RESOURCE_COUNT) {
if (media.resources.length > MAXIMUM_DOCX_MEDIA_COUNT) {
throw new Error("DOCX 媒体数量超过限制");
}
@@ -139,6 +139,19 @@ export function transformSettingsXml(
) {
const document = parseXmlPart(content, "word/settings.xml");
const settings = document.documentElement!;
const compatibility =
firstDirectChild(settings, WORD_NAMESPACE, "compat") ??
appendElement(settings, WORD_NAMESPACE, "w:compat");
removeDirectChildren(
compatibility,
WORD_NAMESPACE,
"doNotExpandShiftReturn"
);
appendElement(
compatibility,
WORD_NAMESPACE,
"w:doNotExpandShiftReturn"
);
removeDirectChildren(
settings,
WORD_NAMESPACE,
+36 -12
View File
@@ -467,8 +467,13 @@ function applyTokenParagraphStyle(
(token.paddingPt?.right ?? 0) > 0 ||
(token.borders?.right?.widthPt ?? 0) > 0
? (token.rightIndentPt ?? 0) +
(token.paddingPt?.right ?? 0) +
(token.borders?.right?.widthPt ?? 0)
// 带边框代码块通过 w:pBdr/w:space 表达 CSS 右内边距。
// 若再把 padding/border 写进 w:indWord/WPS 会重复扣减
// 内容宽度,使本可容纳的等宽代码行在末尾额外回流。
(horizontalPaddingThroughBorderSpace && token.borders?.right
? 0
: (token.paddingPt?.right ?? 0) +
(token.borders?.right?.widthPt ?? 0))
: undefined;
if (
token.firstLineIndentPt !== undefined ||
@@ -1191,12 +1196,23 @@ function applyTokenStyles(
binding.styleId === "ImageCaption"
? { ...entry.style, alignment: "center" as const }
: entry.style;
applyTokenParagraphStyle(
ensureDirectElement(
target,
const paragraphProperties = ensureDirectElement(
target,
WORD_NAMESPACE,
"w:pPr"
);
// SourceCode 的主题令牌来自浏览器完整计算样式。背景透明时,
// normalizer 会省略 backgroundColor;这里必须先清除基础模板的
// 兜底底纹,否则模板灰底会泄漏成主题代码块的整段背景。
if (binding.styleId === "SourceCode") {
removeDirectChildren(
paragraphProperties,
WORD_NAMESPACE,
"w:pPr"
),
"shd"
);
}
applyTokenParagraphStyle(
paragraphProperties,
paragraphToken,
fallbackFontSizeForSlot(slot, fallback),
binding.styleId === "SourceCode"
@@ -1205,12 +1221,20 @@ function applyTokenStyles(
const runToken = binding.styleId === "SourceCode"
? slots.get("code-block-text")?.style ?? entry.style
: entry.style;
applyTokenRunStyle(
ensureDirectElement(
target,
const runProperties = ensureDirectElement(
target,
WORD_NAMESPACE,
"w:rPr"
);
if (binding.styleId === "SourceCode") {
removeDirectChildren(
runProperties,
WORD_NAMESPACE,
"w:rPr"
),
"shd"
);
}
applyTokenRunStyle(
runProperties,
runToken,
fallbackFontsForSlot(slot, fallback),
binding.styleId === "SourceCode" ||
+11 -3
View File
@@ -210,17 +210,25 @@ const pandocSyntaxSlots: Readonly<
export function resolvePandocSyntaxStyleSlot(
styleId: string,
text = ""
text = "",
context: { htmlTagName?: boolean; objectPropertyKey?: boolean } = {}
): DocxStyleSlotName | "code-block-text" | undefined {
if (!(PANDOC_SYNTAX_STYLE_IDS as readonly string[]).includes(styleId)) {
return undefined;
}
if (context.htmlTagName) {
return "code-token-name";
}
if (context.objectPropertyKey) {
return "code-token-attribute";
}
if (
styleId === "NormalTok" ||
styleId === "OtherTok" ||
(styleId === "FunctionTok" && /^[\p{P}\p{S}\s]+$/u.test(text))
((styleId === "FunctionTok" || styleId === "DataTypeTok") &&
/^[\p{P}\p{S}\s]+$/u.test(text))
) {
return styleId === "FunctionTok"
return styleId === "FunctionTok" || styleId === "DataTypeTok"
? "code-token-punctuation"
: "code-block-text";
}