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
@@ -8,11 +8,13 @@ import type {
PdfEditableContentLine,
PdfParagraphLayoutComparison,
PdfParagraphLayoutObservation,
PdfPointBounds,
VisualDiffIssue,
VisualPageSemanticExpectation,
} from "./types.js";
const LINE_BREAK_CHARACTER_TOLERANCE = 1;
const BODY_REFLOW_BREAK_CHARACTER_TOLERANCE = 3;
const LINE_X_TOLERANCE_PT = 2;
const COVER_LINE_X_TOLERANCE_PT = 12;
const LINE_HEIGHT_TOLERANCE_PT = 1;
@@ -26,6 +28,17 @@ interface MatchCursor {
characterIndex: number;
}
interface MatchedCharacterRange {
start: number;
end: number;
}
interface MatchedCharacterPosition {
lineIndex: number;
characterIndex: number;
character: string;
}
function average(values: readonly number[]): number | undefined {
if (values.length === 0) {
return undefined;
@@ -33,6 +46,113 @@ function average(values: readonly number[]): number | undefined {
return values.reduce((sum, value) => sum + value, 0) / values.length;
}
function unionItemBounds(
items: readonly { bounds: PdfPointBounds }[],
): PdfPointBounds | undefined {
if (items.length === 0) {
return undefined;
}
const left = Math.min(...items.map((item) => item.bounds.x));
const top = Math.min(...items.map((item) => item.bounds.y));
const right = Math.max(
...items.map((item) => item.bounds.x + item.bounds.width),
);
const bottom = Math.max(
...items.map((item) => item.bounds.y + item.bounds.height),
);
return {
x: left,
y: top,
width: Math.max(0, right - left),
height: Math.max(0, bottom - top),
};
}
function characterAdvanceWeight(character: string): number {
if (/^[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}]$/u.test(character)) {
return 1;
}
if (/^[]$/u.test(character)) {
return 1;
}
if (/^[A-Za-z0-9]$/u.test(character)) {
return 0.55;
}
if (/^[.,:;!'"`·]$/u.test(character)) {
return 0.28;
}
if (/^[-/\\]$/u.test(character)) {
return 0.4;
}
return 0.6;
}
function weightedCharacterRatio(
characters: readonly string[],
offset: number,
): number {
const weights = characters.map(characterAdvanceWeight);
const total = weights.reduce((sum, weight) => sum + weight, 0);
if (total <= 0) {
return offset / Math.max(1, characters.length);
}
return weights
.slice(0, offset)
.reduce((sum, weight) => sum + weight, 0) / total;
}
function matchedLineItems(
line: PdfEditableContentLine["line"],
normalizedLineText: string,
range: MatchedCharacterRange | undefined,
) {
if (!range || line.items.length === 0) {
return { bounds: line.bounds, items: line.items };
}
const selected = [];
let searchFrom = 0;
for (const item of line.items) {
const text = normalizePdfEditableText(item.normalizedText);
if (!text) {
continue;
}
const start = normalizedLineText.indexOf(text, searchFrom);
if (start < 0) {
continue;
}
const end = start + Array.from(text).length;
searchFrom = end;
const overlapStart = Math.max(start, range.start);
const overlapEnd = Math.min(end, range.end);
if (overlapStart >= overlapEnd) {
continue;
}
const characters = Array.from(text);
const leftRatio = weightedCharacterRatio(
characters,
overlapStart - start,
);
const rightRatio = weightedCharacterRatio(
characters,
overlapEnd - start,
);
selected.push({
item,
bounds: {
...item.bounds,
x: item.bounds.x + item.bounds.width * leftRatio,
width: item.bounds.width * (rightRatio - leftRatio),
},
});
}
return {
bounds: unionItemBounds(selected) ?? line.bounds,
items: selected.length > 0
? selected.map((entry) => entry.item)
: line.items,
};
}
function advanceCursor(
cursor: MatchCursor,
lineCharacters: readonly string[][],
@@ -48,42 +168,88 @@ function advanceCursor(
return cursor.lineIndex < lineCharacters.length;
}
function matchContiguousCharacters(
start: MatchCursor,
expectedCharacters: readonly string[],
lineCharacters: readonly string[][],
) {
const cursor = { ...start };
const positions: MatchedCharacterPosition[] = [];
for (const expectedCharacter of expectedCharacters) {
const actualCharacter =
lineCharacters[cursor.lineIndex]?.[cursor.characterIndex];
if (actualCharacter !== expectedCharacter) {
break;
}
positions.push({
lineIndex: cursor.lineIndex,
characterIndex: cursor.characterIndex,
character: expectedCharacter,
});
advanceCursor(cursor, lineCharacters);
}
return { cursor, positions };
}
function observeParagraph(
expectation: EditableParagraphExpectation,
lines: readonly PdfEditableContentLine[],
lineCharacters: readonly string[][],
cursor: MatchCursor,
): PdfParagraphLayoutObservation {
const localCursor: MatchCursor = { ...cursor };
const expectedCharacters = Array.from(
normalizePdfEditableText(expectation.text),
);
const matchedByLine = new Map<number, string[]>();
let matchedCharacterCount = 0;
for (const expectedCharacter of expectedCharacters) {
let found = false;
while (localCursor.lineIndex < lineCharacters.length) {
const actualCharacter =
lineCharacters[localCursor.lineIndex]?.[localCursor.characterIndex];
if (actualCharacter === expectedCharacter) {
const matches = matchedByLine.get(localCursor.lineIndex) ?? [];
matches.push(expectedCharacter);
matchedByLine.set(localCursor.lineIndex, matches);
matchedCharacterCount += 1;
advanceCursor(localCursor, lineCharacters);
found = true;
break;
const matchedRanges = new Map<number, MatchedCharacterRange>();
let matchedPositions: MatchedCharacterPosition[] = [];
let matchedCursor: MatchCursor = { ...cursor };
let matched = expectedCharacters.length === 0;
const searchCursor: MatchCursor = { ...cursor };
while (!matched && searchCursor.lineIndex < lineCharacters.length) {
if (
lineCharacters[searchCursor.lineIndex]?.[
searchCursor.characterIndex
] === expectedCharacters[0]
) {
const attempt = matchContiguousCharacters(
searchCursor,
expectedCharacters,
lineCharacters,
);
if (attempt.positions.length > matchedPositions.length) {
matchedPositions = attempt.positions;
}
if (!advanceCursor(localCursor, lineCharacters)) {
if (attempt.positions.length === expectedCharacters.length) {
matched = true;
matchedPositions = attempt.positions;
matchedCursor = attempt.cursor;
break;
}
}
if (!found) {
if (!advanceCursor(searchCursor, lineCharacters)) {
break;
}
}
for (const position of matchedPositions) {
const matches = matchedByLine.get(position.lineIndex) ?? [];
matches.push(position.character);
matchedByLine.set(position.lineIndex, matches);
const range = matchedRanges.get(position.lineIndex);
matchedRanges.set(position.lineIndex, {
start: Math.min(
range?.start ?? position.characterIndex,
position.characterIndex,
),
end: Math.max(
range?.end ?? position.characterIndex + 1,
position.characterIndex + 1,
),
});
}
const matchedCharacterCount = matchedPositions.length;
const matchedLineIndexes = [...matchedByLine.keys()];
const matchedLines = matchedLineIndexes.flatMap((index) =>
lines[index] ? [lines[index]] : [],
@@ -113,21 +279,27 @@ function observeParagraph(
Array.from(lines[index]?.normalizedText ?? "").length ===
(matchedByLine.get(index)?.length ?? 0),
);
const visualLines = matchedLines.map((line) => ({
pageNumber: line.pageNumber,
bounds: line.line.bounds,
fontFamilies: [
const visualLines = matchedLines.map((line, index) => {
const matched = matchedLineItems(
line.line,
line.normalizedText,
matchedRanges.get(matchedLineIndexes[index]!),
);
return {
pageNumber: line.pageNumber,
bounds: matched.bounds,
fontFamilies: [
...new Set(
line.line.items.flatMap((item) =>
matched.items.flatMap((item) =>
item.fontFamily ? [item.fontFamily] : [],
),
),
].sort(),
}));
const matched = matchedCharacterCount === expectedCharacters.length;
};
});
if (matched) {
cursor.lineIndex = localCursor.lineIndex;
cursor.characterIndex = localCursor.characterIndex;
cursor.lineIndex = matchedCursor.lineIndex;
cursor.characterIndex = matchedCursor.characterIndex;
}
return {
matched,
@@ -139,15 +311,15 @@ function observeParagraph(
lineBreakOffsets,
...(firstLine
? {
firstLineXPt: firstLine.line.bounds.x,
firstLineXPt: visualLines[0]!.bounds.x,
firstLineBaselineYPt: firstLine.line.baselineY,
}
: {}),
...(average(matchedLines.map((line) => line.line.bounds.width)) === undefined
...(average(visualLines.map((line) => line.bounds.width)) === undefined
? {}
: {
maximumLineWidthPt: Math.max(
...matchedLines.map((line) => line.line.bounds.width),
...visualLines.map((line) => line.bounds.width),
),
}),
...(averageLineHeightPt === undefined
@@ -176,6 +348,14 @@ function trailingLineCharacterCount(
return Array.from(observation.lineTexts.at(-1) ?? "").length;
}
function allowsBodyTextReflow(expectation: EditableParagraphExpectation) {
return expectation.section === "body" && [
"paragraph",
"list-item",
"block-quote",
].includes(expectation.blockKind ?? "paragraph");
}
function isBodyNearBoundaryLineCountDifference(
expectation: EditableParagraphExpectation,
baseline: PdfParagraphLayoutObservation,
@@ -271,14 +451,21 @@ function compareParagraph(
),
);
if (maximumBreakDelta > LINE_BREAK_CHARACTER_TOLERANCE) {
const toleratedReflow =
allowsBodyTextReflow(expectation) &&
maximumBreakDelta <= BODY_REFLOW_BREAK_CHARACTER_TOLERANCE;
issues.push({
code: "PARAGRAPH_LINE_BREAK_MISMATCH",
severity: "failure",
message: `${expectation.index + 1} 个段落的行内换行边界偏差 ${maximumBreakDelta} 个字符`,
severity: toleratedReflow ? "warning" : "failure",
message: toleratedReflow
? `${expectation.index + 1} 个正文块发生允许的跨引擎行内重排,边界偏差 ${maximumBreakDelta} 个字符`
: `${expectation.index + 1} 个段落的行内换行边界偏差 ${maximumBreakDelta} 个字符`,
details: {
paragraphIndex: expectation.index,
maximumBreakDelta,
tolerance: LINE_BREAK_CHARACTER_TOLERANCE,
reflowTolerance: BODY_REFLOW_BREAK_CHARACTER_TOLERANCE,
toleratedReflow,
baselineBreaks: baseline.lineBreakOffsets.join(","),
candidateBreaks: candidate.lineBreakOffsets.join(","),
},