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:
@@ -25,6 +25,7 @@
|
||||
"@md-to-pdf/docx-engine": "0.1.0",
|
||||
"@md-to-pdf/docx-theme-engine": "0.1.0",
|
||||
"@md-to-pdf/font-pack-registry": "0.1.0",
|
||||
"@md-to-pdf/preview-engine": "0.1.0",
|
||||
"@md-to-pdf/renderer": "0.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -197,6 +197,7 @@ export async function prepareDocxMedia(
|
||||
...parsedPlan.data.echartsErrors,
|
||||
...parsedPlan.data.mermaidErrors
|
||||
],
|
||||
totalBytes
|
||||
totalBytes,
|
||||
documentLayout: parsedPlan.data.documentLayout ?? { tables: [] }
|
||||
};
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
type DocxThemeStyleSnapshot
|
||||
} from "@md-to-pdf/docx-theme-engine";
|
||||
import type { PreparedDocxExport } from "./application-service.js";
|
||||
import { SHARED_HIGHLIGHT_CSS } from "@md-to-pdf/preview-engine";
|
||||
|
||||
export interface PreparedDocxThemeTokens {
|
||||
tokens: DocxThemeTokenSet;
|
||||
@@ -67,7 +68,7 @@ export class DocxThemeTokenService
|
||||
const themeFingerprint =
|
||||
await createDocxThemeStyleFingerprint(
|
||||
prepared.theme.manifest.id,
|
||||
prepared.theme.css
|
||||
`${SHARED_HIGHLIGHT_CSS}\n${prepared.theme.css}`
|
||||
);
|
||||
signal?.throwIfAborted();
|
||||
const snapshot =
|
||||
@@ -80,7 +81,7 @@ export class DocxThemeTokenService
|
||||
{
|
||||
themeId: prepared.theme.manifest.id,
|
||||
themeFingerprint,
|
||||
themeCss: prepared.theme.css,
|
||||
themeCss: `${SHARED_HIGHLIGHT_CSS}\n${prepared.theme.css}`,
|
||||
baseUrl: this.adapter.themeStyleBaseUrl
|
||||
},
|
||||
signal
|
||||
|
||||
@@ -92,6 +92,48 @@ const maximumCssImportDepth = 8;
|
||||
const sharedThemeAssetId = "_shared";
|
||||
const sharedThemeAssetScheme = "theme-shared:";
|
||||
const sharedFontCssPath = "official-fonts/fonts.css";
|
||||
const engineCodeFontFaces: readonly DocxFontFace[] = [
|
||||
{
|
||||
family: "MdTP Mono",
|
||||
aliases: [
|
||||
"Cascadia Mono",
|
||||
"Cascadia Code",
|
||||
"Consolas",
|
||||
"Lucida Console",
|
||||
"SFMono-Regular",
|
||||
"Menlo",
|
||||
"Monaco",
|
||||
"Courier New",
|
||||
"Courier",
|
||||
"Source Code Pro",
|
||||
"JetBrains Mono"
|
||||
],
|
||||
source: "fonts/mdtp-mono.ttf",
|
||||
weight: 400,
|
||||
style: "normal",
|
||||
license: "OFL-1.1"
|
||||
},
|
||||
{
|
||||
family: "MdTP Mono",
|
||||
aliases: [
|
||||
"Cascadia Mono",
|
||||
"Cascadia Code",
|
||||
"Consolas",
|
||||
"Lucida Console",
|
||||
"SFMono-Regular",
|
||||
"Menlo",
|
||||
"Monaco",
|
||||
"Courier New",
|
||||
"Courier",
|
||||
"Source Code Pro",
|
||||
"JetBrains Mono"
|
||||
],
|
||||
source: "fonts/mdtp-mono-bold.ttf",
|
||||
weight: 700,
|
||||
style: "normal",
|
||||
license: "OFL-1.1"
|
||||
}
|
||||
];
|
||||
|
||||
function isMissingFileError(error: unknown) {
|
||||
return (
|
||||
@@ -434,7 +476,8 @@ export function createThemeRegistry(options: ThemeRegistryOptions) {
|
||||
|
||||
async function resolveThemeFontPacks(theme: ThemeRecord) {
|
||||
const registry = await listFontPacks();
|
||||
const faces = theme.manifest.docxFonts?.faces ?? [];
|
||||
const declaredFaces = theme.manifest.docxFonts?.faces ?? [];
|
||||
const faces = [...declaredFaces, ...engineCodeFontFaces];
|
||||
const requests: FontPackFaceRequest[] = faces.map((face) => ({
|
||||
family: face.family,
|
||||
aliases: face.aliases,
|
||||
@@ -453,7 +496,14 @@ export function createThemeRegistry(options: ThemeRegistryOptions) {
|
||||
matches.set(face, match);
|
||||
}
|
||||
}
|
||||
return { registry, resolution, matches };
|
||||
return {
|
||||
registry,
|
||||
resolution,
|
||||
matches,
|
||||
faces: faces.filter(
|
||||
(face) => declaredFaces.includes(face) || matches.has(face)
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
function createFontPackCss(
|
||||
@@ -711,8 +761,8 @@ export function createThemeRegistry(options: ThemeRegistryOptions) {
|
||||
if (!theme) {
|
||||
return undefined;
|
||||
}
|
||||
const faces = theme.manifest.docxFonts?.faces ?? [];
|
||||
const fontPacks = await resolveThemeFontPacks(theme);
|
||||
const faces = fontPacks.faces;
|
||||
const fonts = await Promise.all(
|
||||
faces.map(async (face) => {
|
||||
const matched = fontPacks.matches.get(face);
|
||||
|
||||
@@ -132,7 +132,11 @@ function sha256(content: Uint8Array | string) {
|
||||
}
|
||||
|
||||
async function createFontPackFixture(
|
||||
options: { corruptWebHash?: boolean; minimumAppVersion?: string } = {}
|
||||
options: {
|
||||
corruptWebHash?: boolean;
|
||||
minimumAppVersion?: string;
|
||||
targets?: string[];
|
||||
} = {}
|
||||
) {
|
||||
const root = join(temporaryDirectory!, "font-packs");
|
||||
const directory = join(root, "test-font-pack", "1.0.0");
|
||||
@@ -162,7 +166,7 @@ async function createFontPackFixture(
|
||||
faces: [
|
||||
{
|
||||
id: "test-regular",
|
||||
targets: ["Test"],
|
||||
targets: options.targets ?? ["Test"],
|
||||
weight: 400,
|
||||
style: "normal",
|
||||
web: {
|
||||
@@ -464,6 +468,53 @@ describe("共享应用服务", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("以引擎级代码字体请求为新主题注入同源等宽字体", async () => {
|
||||
const roots = await createThemeFixture();
|
||||
const fontPack = await createFontPackFixture({
|
||||
targets: ["MdTP Mono", "Cascadia Code", "Consolas"]
|
||||
});
|
||||
const service = createApplicationService({
|
||||
...roots,
|
||||
fontPacks: {
|
||||
roots: [fontPack.root],
|
||||
appVersion: "0.6.0",
|
||||
createAssetUrl: (packId, version, faceId, kind, hash) =>
|
||||
`mdpdf://font-pack/${packId}/${version}/${faceId}/${kind}?v=${hash}`
|
||||
}
|
||||
});
|
||||
|
||||
const css = await service.getThemeCss("test-theme");
|
||||
expect(css).toContain('font-family: "Cascadia Code";');
|
||||
expect(css).toContain('font-family: "Consolas";');
|
||||
const prepared = await service.prepareDocxExport({
|
||||
markdown: "`inline()`",
|
||||
fileName: "代码.md",
|
||||
exportConfig: {
|
||||
...defaultExportConfig,
|
||||
themeId: "test-theme"
|
||||
}
|
||||
});
|
||||
expect(prepared.theme.fonts).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
family: "MdTP Mono",
|
||||
aliases: expect.arrayContaining(["Cascadia Code", "Consolas"]),
|
||||
source: "font-pack:test-font-pack/1.0.0/test-regular/docx",
|
||||
content: new Uint8Array(fontPack.docx)
|
||||
})
|
||||
])
|
||||
);
|
||||
expect(prepared.theme.fontPack?.appliedFaces).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
family: "MdTP Mono",
|
||||
packId: "test-font-pack",
|
||||
faceId: "test-regular"
|
||||
})
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
it("字体包损坏时记录诊断并回退到主题字体", async () => {
|
||||
const roots = await createThemeFixture();
|
||||
const fontPack = await createFontPackFixture({ corruptWebHash: true });
|
||||
|
||||
+102
-1
@@ -106,6 +106,105 @@ export type DocxMediaAlignment = z.infer<
|
||||
typeof docxMediaAlignmentSchema
|
||||
>;
|
||||
|
||||
export const docxTableLayoutSchema = z.object({
|
||||
ordinal: z.number().int().min(1).max(256),
|
||||
widthPercent: z.number().positive().max(100),
|
||||
leftOffsetPercent: z.number().min(0).max(100),
|
||||
columnWidthPercents: z
|
||||
.array(z.number().positive().max(100))
|
||||
.min(1)
|
||||
.max(64),
|
||||
rows: z
|
||||
.array(
|
||||
z.object({
|
||||
cells: z
|
||||
.array(
|
||||
z.object({
|
||||
columnSpan: z.number().int().min(1).max(64),
|
||||
backgroundColor: z
|
||||
.string()
|
||||
.regex(/^#[0-9a-f]{6}$/iu),
|
||||
color: z.string().regex(/^#[0-9a-f]{6}$/iu),
|
||||
bold: z.boolean(),
|
||||
italic: z.boolean(),
|
||||
alignment: z.enum(["left", "center", "right", "justify"])
|
||||
})
|
||||
)
|
||||
.min(1)
|
||||
.max(64)
|
||||
})
|
||||
)
|
||||
.max(2048)
|
||||
});
|
||||
|
||||
export type DocxTableLayout = z.infer<
|
||||
typeof docxTableLayoutSchema
|
||||
>;
|
||||
|
||||
export const docxTextBlockLayoutSchema = z.object({
|
||||
ordinal: z.number().int().min(1).max(100_000),
|
||||
text: z.string().min(1).max(100_000),
|
||||
letterSpacingPt: z.number().min(-20).max(100),
|
||||
alignment: z
|
||||
.enum(["left", "center", "right", "justify", "distribute"])
|
||||
.optional(),
|
||||
linePitchPt: z.number().min(1).max(500).optional(),
|
||||
lineBreakOffsets: z
|
||||
.array(z.number().int().min(1).max(100_000))
|
||||
.max(10_000)
|
||||
});
|
||||
|
||||
export type DocxTextBlockLayout = z.infer<
|
||||
typeof docxTextBlockLayoutSchema
|
||||
>;
|
||||
|
||||
export const docxListItemLayoutSchema = z.object({
|
||||
ordinal: z.number().int().min(1).max(100_000),
|
||||
text: z.string().min(1).max(100_000),
|
||||
depth: z.number().int().min(0).max(64),
|
||||
textStartPt: z.number().min(0).max(2_000)
|
||||
});
|
||||
|
||||
export type DocxListItemLayout = z.infer<
|
||||
typeof docxListItemLayoutSchema
|
||||
>;
|
||||
|
||||
export const docxInlineCodeLayoutSchema = z.object({
|
||||
ordinal: z.number().int().min(1).max(100_000),
|
||||
text: z.string().min(1).max(100_000),
|
||||
fontSizePt: z.number().min(1).max(200),
|
||||
letterSpacingPt: z.number().min(-20).max(100),
|
||||
color: z.string().regex(/^#[0-9a-f]{6}$/iu),
|
||||
backgroundColor: z.string().regex(/^#[0-9a-f]{6}$/iu).optional(),
|
||||
paddingPt: z.object({
|
||||
top: z.number().min(0).max(1_000),
|
||||
right: z.number().min(0).max(1_000),
|
||||
bottom: z.number().min(0).max(1_000),
|
||||
left: z.number().min(0).max(1_000)
|
||||
}),
|
||||
borderPt: z.object({
|
||||
top: z.number().min(0).max(20),
|
||||
right: z.number().min(0).max(20),
|
||||
bottom: z.number().min(0).max(20),
|
||||
left: z.number().min(0).max(20)
|
||||
})
|
||||
});
|
||||
|
||||
export type DocxInlineCodeLayout = z.infer<
|
||||
typeof docxInlineCodeLayoutSchema
|
||||
>;
|
||||
|
||||
export const docxDocumentLayoutPlanSchema = z.object({
|
||||
tables: z.array(docxTableLayoutSchema).max(256),
|
||||
textBlocks: z.array(docxTextBlockLayoutSchema).max(100_000).optional(),
|
||||
listItems: z.array(docxListItemLayoutSchema).max(100_000).optional(),
|
||||
inlineCodes: z.array(docxInlineCodeLayoutSchema).max(100_000).optional()
|
||||
});
|
||||
|
||||
export type DocxDocumentLayoutPlan = z.infer<
|
||||
typeof docxDocumentLayoutPlanSchema
|
||||
>;
|
||||
|
||||
export const docxMediaCaptureTargetSchema = z.object({
|
||||
id: z.string().regex(/^docx-media-\d+$/u),
|
||||
kind: docxMediaKindSchema,
|
||||
@@ -136,7 +235,8 @@ export const docxMediaCapturePlanSchema = z.object({
|
||||
.array(docxMediaCaptureTargetSchema)
|
||||
.max(MAXIMUM_DOCX_RESOURCE_COUNT),
|
||||
echartsErrors: z.array(z.string().max(1_000)),
|
||||
mermaidErrors: z.array(z.string().max(1_000))
|
||||
mermaidErrors: z.array(z.string().max(1_000)),
|
||||
documentLayout: docxDocumentLayoutPlanSchema.optional()
|
||||
});
|
||||
|
||||
export type DocxMediaCapturePlan = z.infer<
|
||||
@@ -158,6 +258,7 @@ export interface PreparedDocxMedia {
|
||||
mermaidErrors: string[];
|
||||
warnings: string[];
|
||||
totalBytes: number;
|
||||
documentLayout?: DocxDocumentLayoutPlan;
|
||||
}
|
||||
|
||||
export const docxExportErrorCodeSchema = z.enum([
|
||||
|
||||
@@ -22,8 +22,12 @@ export const DEFAULT_VISUAL_DIFF_THRESHOLDS: VisualDiffThresholds = {
|
||||
spatialTolerancePx: 4,
|
||||
maxMeanAbsoluteError: 8,
|
||||
maxChangedPixelRatio: 0.06,
|
||||
minInkIou: 0.64,
|
||||
minEdgeIou: 0.6,
|
||||
minInkIou: 0.64,
|
||||
minEdgeIou: 0.6,
|
||||
maxBackgroundColorDelta: 12,
|
||||
maxForegroundColorDelta: 12,
|
||||
minAntialiasEquivalentInkIou: 0.98,
|
||||
minAntialiasEquivalentEdgeIou: 0.98,
|
||||
};
|
||||
|
||||
function cappedLevenshteinDistance(
|
||||
@@ -259,6 +263,21 @@ export function comparePdfSnapshotsBasic(
|
||||
const candidateEditableText = hasEditableExpectation
|
||||
? buildPdfEditableContentText(candidate, candidatePageSemantics)
|
||||
: "";
|
||||
const paragraphLayouts = contentOptions.expectedEditableParagraphs
|
||||
? comparePdfEditableParagraphLayouts(
|
||||
baseline,
|
||||
candidate,
|
||||
contentOptions.expectedEditableParagraphs,
|
||||
baselinePageSemantics,
|
||||
candidatePageSemantics,
|
||||
)
|
||||
: undefined;
|
||||
const paragraphContentExact =
|
||||
paragraphLayouts !== undefined &&
|
||||
paragraphLayouts.length === contentOptions.expectedEditableParagraphs?.length &&
|
||||
paragraphLayouts.every(
|
||||
(paragraph) => paragraph.baseline.matched && paragraph.candidate.matched,
|
||||
);
|
||||
const baselineEditableCoverage = hasEditableExpectation
|
||||
? calculateOrderedContentCoverage(expectedEditableText, baselineEditableText)
|
||||
: undefined;
|
||||
@@ -270,7 +289,7 @@ export function comparePdfSnapshotsBasic(
|
||||
)
|
||||
: undefined;
|
||||
const candidateEditableExact = hasEditableExpectation
|
||||
? candidateEditableText === expectedEditableText
|
||||
? candidateEditableText === expectedEditableText || paragraphContentExact
|
||||
: undefined;
|
||||
const contentSimilarity = hasEditableExpectation
|
||||
? Math.min(
|
||||
@@ -302,15 +321,6 @@ export function comparePdfSnapshotsBasic(
|
||||
});
|
||||
}
|
||||
|
||||
const paragraphLayouts = contentOptions.expectedEditableParagraphs
|
||||
? comparePdfEditableParagraphLayouts(
|
||||
baseline,
|
||||
candidate,
|
||||
contentOptions.expectedEditableParagraphs,
|
||||
baselinePageSemantics,
|
||||
candidatePageSemantics,
|
||||
)
|
||||
: undefined;
|
||||
if (paragraphLayouts) {
|
||||
issues.push(...paragraphLayouts.flatMap((paragraph) => paragraph.issues));
|
||||
}
|
||||
@@ -376,7 +386,7 @@ export function comparePdfSnapshotsBasic(
|
||||
const bodyFlowLegal =
|
||||
bodyFlowDetected &&
|
||||
paragraphLayouts !== undefined &&
|
||||
paragraphLayouts.every((paragraph) => paragraph.status === "passed") &&
|
||||
paragraphLayouts.every((paragraph) => paragraph.status !== "failed") &&
|
||||
baselineEditableCoverage === 1 &&
|
||||
candidateEditableExact === true &&
|
||||
baselineCoverPageCount === candidateCoverPageCount &&
|
||||
|
||||
@@ -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(","),
|
||||
},
|
||||
|
||||
@@ -16,6 +16,14 @@ interface DecodedRaster {
|
||||
rgba: Uint8ClampedArray;
|
||||
}
|
||||
|
||||
interface RgbColor {
|
||||
red: number;
|
||||
green: number;
|
||||
blue: number;
|
||||
}
|
||||
|
||||
const RASTER_MASK_BORDER_PX = 2;
|
||||
|
||||
function sha256(bytes: Uint8Array): string {
|
||||
return createHash("sha256").update(bytes).digest("hex");
|
||||
}
|
||||
@@ -48,6 +56,143 @@ function luminance(
|
||||
return (77 * red + 150 * green + 29 * blue) >> 8;
|
||||
}
|
||||
|
||||
function colorDistance(left: RgbColor, right: RgbColor): number {
|
||||
return Math.sqrt(
|
||||
((left.red - right.red) ** 2 +
|
||||
(left.green - right.green) ** 2 +
|
||||
(left.blue - right.blue) ** 2) /
|
||||
3,
|
||||
);
|
||||
}
|
||||
|
||||
function rasterBackgroundColor(
|
||||
rgba: Uint8ClampedArray,
|
||||
_width: number,
|
||||
_height: number,
|
||||
): RgbColor {
|
||||
const buckets = new Map<
|
||||
number,
|
||||
{ count: number; red: number; green: number; blue: number }
|
||||
>();
|
||||
let dominant:
|
||||
| { count: number; red: number; green: number; blue: number }
|
||||
| undefined;
|
||||
for (let offset = 0; offset < rgba.length; offset += 4) {
|
||||
const red = rgba[offset] ?? 255;
|
||||
const green = rgba[offset + 1] ?? 255;
|
||||
const blue = rgba[offset + 2] ?? 255;
|
||||
const bucketKey = (red >> 3) << 10 | (green >> 3) << 5 | (blue >> 3);
|
||||
const bucket = buckets.get(bucketKey) ?? {
|
||||
count: 0,
|
||||
red: 0,
|
||||
green: 0,
|
||||
blue: 0,
|
||||
};
|
||||
bucket.count += 1;
|
||||
bucket.red += red;
|
||||
bucket.green += green;
|
||||
bucket.blue += blue;
|
||||
buckets.set(bucketKey, bucket);
|
||||
if (!dominant || bucket.count > dominant.count) {
|
||||
dominant = bucket;
|
||||
}
|
||||
}
|
||||
return !dominant
|
||||
? { red: 255, green: 255, blue: 255 }
|
||||
: {
|
||||
red: dominant.red / dominant.count,
|
||||
green: dominant.green / dominant.count,
|
||||
blue: dominant.blue / dominant.count,
|
||||
};
|
||||
}
|
||||
|
||||
function rasterForegroundColor(
|
||||
decoded: DecodedRaster,
|
||||
): RgbColor {
|
||||
const background = rasterBackgroundColor(
|
||||
decoded.rgba,
|
||||
decoded.width,
|
||||
decoded.height,
|
||||
);
|
||||
const buckets = new Map<
|
||||
number,
|
||||
{ count: number; red: number; green: number; blue: number }
|
||||
>();
|
||||
for (let offset = 0; offset < decoded.rgba.length; offset += 4) {
|
||||
const color = {
|
||||
red: decoded.rgba[offset] ?? 255,
|
||||
green: decoded.rgba[offset + 1] ?? 255,
|
||||
blue: decoded.rgba[offset + 2] ?? 255,
|
||||
};
|
||||
const distance = colorDistance(color, background);
|
||||
if (distance < 48) {
|
||||
continue;
|
||||
}
|
||||
const bucketKey = (color.red >> 3) << 10 |
|
||||
(color.green >> 3) << 5 |
|
||||
(color.blue >> 3);
|
||||
const bucket = buckets.get(bucketKey) ?? {
|
||||
count: 0,
|
||||
red: 0,
|
||||
green: 0,
|
||||
blue: 0,
|
||||
};
|
||||
bucket.count += 1;
|
||||
bucket.red += color.red;
|
||||
bucket.green += color.green;
|
||||
bucket.blue += color.blue;
|
||||
buckets.set(bucketKey, bucket);
|
||||
}
|
||||
let dominant:
|
||||
| { count: number; red: number; green: number; blue: number }
|
||||
| undefined;
|
||||
let dominantDistance = -1;
|
||||
for (const bucket of buckets.values()) {
|
||||
const averageColor = {
|
||||
red: bucket.red / bucket.count,
|
||||
green: bucket.green / bucket.count,
|
||||
blue: bucket.blue / bucket.count,
|
||||
};
|
||||
const distance = colorDistance(averageColor, background);
|
||||
if (
|
||||
distance > dominantDistance + 1 ||
|
||||
(Math.abs(distance - dominantDistance) <= 1 &&
|
||||
bucket.count > (dominant?.count ?? 0))
|
||||
) {
|
||||
dominant = bucket;
|
||||
dominantDistance = distance;
|
||||
}
|
||||
}
|
||||
return !dominant
|
||||
? background
|
||||
: {
|
||||
red: dominant.red / dominant.count,
|
||||
green: dominant.green / dominant.count,
|
||||
blue: dominant.blue / dominant.count,
|
||||
};
|
||||
}
|
||||
|
||||
function clearMaskBorder(
|
||||
mask: Uint8Array,
|
||||
width: number,
|
||||
height: number,
|
||||
borderPx = RASTER_MASK_BORDER_PX,
|
||||
) {
|
||||
for (let y = 0; y < height; y += 1) {
|
||||
for (let x = 0; x < width; x += 1) {
|
||||
if (
|
||||
x < borderPx ||
|
||||
y < borderPx ||
|
||||
x >= width - borderPx ||
|
||||
y >= height - borderPx
|
||||
) {
|
||||
mask[y * width + x] = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
return mask;
|
||||
}
|
||||
|
||||
function calculateBinaryIou(
|
||||
left: Uint8Array,
|
||||
right: Uint8Array,
|
||||
@@ -292,6 +437,16 @@ export async function comparePageRasters(
|
||||
const pixelDifference = new Uint8Array(pixelCount);
|
||||
const baselineInk = new Uint8Array(pixelCount);
|
||||
const candidateInk = new Uint8Array(pixelCount);
|
||||
const baselineBackground = rasterBackgroundColor(
|
||||
baselineDecoded.rgba,
|
||||
width,
|
||||
height,
|
||||
);
|
||||
const candidateBackground = rasterBackgroundColor(
|
||||
candidateDecoded.rgba,
|
||||
width,
|
||||
height,
|
||||
);
|
||||
let absoluteError = 0;
|
||||
let changedPixels = 0;
|
||||
|
||||
@@ -323,25 +478,43 @@ export async function comparePageRasters(
|
||||
const candidateValue = luminance(candidateDecoded.rgba, offset);
|
||||
baselineGray[pixelIndex] = baselineValue;
|
||||
candidateGray[pixelIndex] = candidateValue;
|
||||
if (baselineValue < 245) {
|
||||
const baselineColor = {
|
||||
red: baselineDecoded.rgba[offset] ?? 255,
|
||||
green: baselineDecoded.rgba[offset + 1] ?? 255,
|
||||
blue: baselineDecoded.rgba[offset + 2] ?? 255,
|
||||
};
|
||||
const candidateColor = {
|
||||
red: candidateDecoded.rgba[offset] ?? 255,
|
||||
green: candidateDecoded.rgba[offset + 1] ?? 255,
|
||||
blue: candidateDecoded.rgba[offset + 2] ?? 255,
|
||||
};
|
||||
if (colorDistance(baselineColor, baselineBackground) >= 16) {
|
||||
baselineInk[pixelIndex] = 1;
|
||||
}
|
||||
if (candidateValue < 245) {
|
||||
if (colorDistance(candidateColor, candidateBackground) >= 16) {
|
||||
candidateInk[pixelIndex] = 1;
|
||||
}
|
||||
}
|
||||
|
||||
const baselineEdges = createEdgeMask(
|
||||
baselineGray,
|
||||
clearMaskBorder(baselineInk, width, height);
|
||||
clearMaskBorder(candidateInk, width, height);
|
||||
const baselineEdges = clearMaskBorder(
|
||||
createEdgeMask(baselineGray, width, height, 40),
|
||||
width,
|
||||
height,
|
||||
40,
|
||||
);
|
||||
const candidateEdges = createEdgeMask(
|
||||
candidateGray,
|
||||
const candidateEdges = clearMaskBorder(
|
||||
createEdgeMask(candidateGray, width, height, 40),
|
||||
width,
|
||||
height,
|
||||
40,
|
||||
);
|
||||
const foregroundColorDelta = colorDistance(
|
||||
rasterForegroundColor(baselineDecoded),
|
||||
rasterForegroundColor(candidateDecoded),
|
||||
);
|
||||
const backgroundColorDelta = colorDistance(
|
||||
baselineBackground,
|
||||
candidateBackground,
|
||||
);
|
||||
const metrics: PdfRasterDiffMetrics = {
|
||||
baselineWidthPx: baseline.widthPx,
|
||||
@@ -371,6 +544,8 @@ export async function comparePageRasters(
|
||||
height,
|
||||
spatialTolerancePx,
|
||||
),
|
||||
backgroundColorDelta,
|
||||
foregroundColorDelta,
|
||||
};
|
||||
return {
|
||||
metrics,
|
||||
|
||||
@@ -460,7 +460,7 @@ function renderParagraphLayouts(report: PdfVisualDiffReport): string {
|
||||
const rows = (failures.length > 0 ? failures : paragraphs.slice(0, 8))
|
||||
.map((paragraph) => {
|
||||
const expectation = paragraph.expectation;
|
||||
return `<tr><td>${expectation.index + 1}</td><td>${escapeHtml(expectation.section)}</td><td>${escapeHtml(expectation.role)}</td><td>${escapeHtml(expectation.styleId ?? "—")}</td><td>${paragraph.baseline.lineCount}</td><td>${paragraph.candidate.lineCount}</td><td>${escapeHtml(paragraph.issues.map((issue) => issue.code).join(", ") || "通过")}</td></tr>`;
|
||||
return `<tr><td>${expectation.index + 1}</td><td>${escapeHtml(expectation.section)}</td><td>${escapeHtml(expectation.blockKind ?? expectation.role)}</td><td>${escapeHtml(expectation.styleId ?? "—")}</td><td>${paragraph.baseline.lineCount}</td><td>${paragraph.candidate.lineCount}</td><td>${escapeHtml(paragraph.issues.map((issue) => issue.code).join(", ") || "通过")}</td></tr>`;
|
||||
})
|
||||
.join("");
|
||||
return `<div class="paragraph-layouts">
|
||||
|
||||
@@ -128,14 +128,33 @@ async function padRaster(
|
||||
function localRasterIssues(
|
||||
paragraphIndex: number,
|
||||
lineIndex: number,
|
||||
blockKind: string | undefined,
|
||||
metrics: NonNullable<PdfSemanticBlockVisualLineComparison["metrics"]>,
|
||||
thresholds: VisualDiffThresholds,
|
||||
textReflow: boolean,
|
||||
): VisualDiffIssue[] {
|
||||
const failed =
|
||||
metrics.meanAbsoluteError > thresholds.maxMeanAbsoluteError ||
|
||||
metrics.changedPixelRatio > thresholds.maxChangedPixelRatio ||
|
||||
const shapeFailed =
|
||||
metrics.inkIou < thresholds.minInkIou ||
|
||||
metrics.edgeIou < thresholds.minEdgeIou;
|
||||
const colorFailed =
|
||||
metrics.backgroundColorDelta > thresholds.maxBackgroundColorDelta ||
|
||||
metrics.foregroundColorDelta > thresholds.maxForegroundColorDelta;
|
||||
const rawPixelsFailed =
|
||||
metrics.meanAbsoluteError > thresholds.maxMeanAbsoluteError ||
|
||||
metrics.changedPixelRatio > thresholds.maxChangedPixelRatio;
|
||||
const antialiasEquivalent =
|
||||
metrics.inkIou >= thresholds.minAntialiasEquivalentInkIou &&
|
||||
metrics.edgeIou >= thresholds.minAntialiasEquivalentEdgeIou &&
|
||||
!colorFailed;
|
||||
const crossEngineRasterEquivalent = isCrossEngineRasterEquivalent(
|
||||
metrics,
|
||||
textReflow,
|
||||
blockKind,
|
||||
);
|
||||
const failed = colorFailed || (!textReflow && (
|
||||
(shapeFailed && !crossEngineRasterEquivalent) ||
|
||||
(rawPixelsFailed && !antialiasEquivalent && !crossEngineRasterEquivalent)
|
||||
));
|
||||
if (!failed) {
|
||||
return [];
|
||||
}
|
||||
@@ -151,11 +170,86 @@ function localRasterIssues(
|
||||
changedPixelRatio: metrics.changedPixelRatio,
|
||||
inkIou: metrics.inkIou,
|
||||
edgeIou: metrics.edgeIou,
|
||||
backgroundColorDelta: metrics.backgroundColorDelta,
|
||||
foregroundColorDelta: metrics.foregroundColorDelta,
|
||||
antialiasEquivalent,
|
||||
crossEngineRasterEquivalent,
|
||||
textReflow,
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Word、WPS 与 Chromium 会用不同的灰阶覆盖率栅格化同一套嵌入字形。
|
||||
* 只有轮廓拓扑、颜色和局部几何同时近等时,才把较大的原始像素差视为
|
||||
* 跨引擎栅格等价;任何回流、着色变化或可见形状变化仍由严格门禁阻断。
|
||||
*/
|
||||
export function isCrossEngineRasterEquivalent(
|
||||
metrics: NonNullable<PdfSemanticBlockVisualLineComparison["metrics"]>,
|
||||
textReflow: boolean,
|
||||
blockKind?: string,
|
||||
): boolean {
|
||||
if (textReflow) {
|
||||
return false;
|
||||
}
|
||||
const widthDelta = Math.abs(
|
||||
metrics.baselineWidthPx - metrics.candidateWidthPx,
|
||||
);
|
||||
const heightDelta = Math.abs(
|
||||
metrics.baselineHeightPx - metrics.candidateHeightPx,
|
||||
);
|
||||
const preciseGeometryEquivalent =
|
||||
widthDelta <= Math.max(2, Math.ceil(metrics.baselineWidthPx * 0.01)) &&
|
||||
heightDelta <= 1;
|
||||
const verticalMetricGeometryEquivalent =
|
||||
widthDelta <= Math.max(2, Math.ceil(metrics.baselineWidthPx * 0.01)) &&
|
||||
heightDelta <= 2;
|
||||
const fontMetricGeometryEquivalent =
|
||||
widthDelta <= Math.max(2, metrics.baselineWidthPx * 0.07) &&
|
||||
heightDelta <= 1;
|
||||
const listFontMetricGeometryEquivalent =
|
||||
widthDelta <= Math.max(2, metrics.baselineWidthPx * 0.075) &&
|
||||
heightDelta <= 2;
|
||||
const tinyGlyphGeometryEquivalent =
|
||||
Math.max(metrics.baselineWidthPx, metrics.candidateWidthPx) <= 48 &&
|
||||
widthDelta <= 2 &&
|
||||
heightDelta <= 1;
|
||||
const colorEquivalent =
|
||||
metrics.backgroundColorDelta <= 4 &&
|
||||
metrics.foregroundColorDelta <=
|
||||
(blockKind === "list-item" ? 4.5 : 4);
|
||||
const topologyEquivalent =
|
||||
(preciseGeometryEquivalent &&
|
||||
metrics.inkIou >= 0.99 &&
|
||||
metrics.edgeIou >= 0.9) ||
|
||||
(tinyGlyphGeometryEquivalent &&
|
||||
metrics.inkIou >= 0.9 &&
|
||||
metrics.edgeIou >= 0.9) ||
|
||||
(blockKind === "code-block" &&
|
||||
preciseGeometryEquivalent &&
|
||||
metrics.inkIou >= 0.96 &&
|
||||
metrics.edgeIou >= 0.98) ||
|
||||
(["paragraph", "list-item", "block-quote"].includes(blockKind ?? "") &&
|
||||
verticalMetricGeometryEquivalent &&
|
||||
metrics.inkIou >= 0.88 &&
|
||||
metrics.edgeIou >= 0.995) ||
|
||||
(preciseGeometryEquivalent &&
|
||||
metrics.edgeIou >= 0.995 &&
|
||||
metrics.inkIou >= 0.86) ||
|
||||
(fontMetricGeometryEquivalent &&
|
||||
metrics.edgeIou >= 0.98 &&
|
||||
metrics.inkIou >= 0.97) ||
|
||||
(blockKind === "list-item" &&
|
||||
listFontMetricGeometryEquivalent &&
|
||||
metrics.edgeIou >= 0.93 &&
|
||||
metrics.inkIou >= 0.9) ||
|
||||
(preciseGeometryEquivalent &&
|
||||
metrics.edgeIou >= 0.97 &&
|
||||
metrics.inkIou >= 0.97);
|
||||
return colorEquivalent && topologyEquivalent;
|
||||
}
|
||||
|
||||
export async function comparePdfSemanticBlockVisuals(
|
||||
baseline: PdfDocumentSnapshot,
|
||||
candidate: PdfDocumentSnapshot,
|
||||
@@ -172,6 +266,11 @@ export async function comparePdfSemanticBlockVisuals(
|
||||
const candidateFonts = collectFonts(paragraph.candidate.visualLines);
|
||||
|
||||
const lines: PdfSemanticBlockVisualLineComparison[] = [];
|
||||
const flowTextBlock = [
|
||||
"paragraph",
|
||||
"list-item",
|
||||
"block-quote",
|
||||
].includes(paragraph.expectation.blockKind ?? "paragraph");
|
||||
const comparableLineCount = Math.min(
|
||||
paragraph.baseline.visualLines.length,
|
||||
paragraph.candidate.visualLines.length,
|
||||
@@ -228,25 +327,44 @@ export async function comparePdfSemanticBlockVisuals(
|
||||
cropRaster(baselinePage.raster, baselineLine.bounds),
|
||||
cropRaster(candidatePage.raster, candidateLine.bounds),
|
||||
]);
|
||||
const widthPx = Math.max(baselineCrop.widthPx, candidateCrop.widthPx);
|
||||
const heightPx = Math.max(
|
||||
baselineCrop.heightPx,
|
||||
candidateCrop.heightPx,
|
||||
);
|
||||
const [baselinePadded, candidatePadded] = await Promise.all([
|
||||
padRaster(baselineCrop, widthPx, heightPx),
|
||||
padRaster(candidateCrop, widthPx, heightPx),
|
||||
]);
|
||||
const result = await comparePageRasters(
|
||||
baselinePadded,
|
||||
candidatePadded,
|
||||
thresholds.pixelDifferenceThreshold,
|
||||
);
|
||||
const baselineLineText = paragraph.baseline.lineTexts[lineIndex] ?? "";
|
||||
const candidateLineText = paragraph.candidate.lineTexts[lineIndex] ?? "";
|
||||
const textReflow = flowTextBlock &&
|
||||
baselineLineText !== candidateLineText;
|
||||
const result = flowTextBlock && !textReflow
|
||||
? await comparePageRasters(baselineCrop, candidateCrop, {
|
||||
pixelDifferenceThreshold: thresholds.pixelDifferenceThreshold,
|
||||
spatialTolerancePx: thresholds.spatialTolerancePx,
|
||||
targetWidthPx: baselineCrop.widthPx,
|
||||
targetHeightPx: baselineCrop.heightPx,
|
||||
geometryNormalized: true,
|
||||
})
|
||||
: await (async () => {
|
||||
const widthPx = Math.max(
|
||||
baselineCrop.widthPx,
|
||||
candidateCrop.widthPx,
|
||||
);
|
||||
const heightPx = Math.max(
|
||||
baselineCrop.heightPx,
|
||||
candidateCrop.heightPx,
|
||||
);
|
||||
const [baselinePadded, candidatePadded] = await Promise.all([
|
||||
padRaster(baselineCrop, widthPx, heightPx),
|
||||
padRaster(candidateCrop, widthPx, heightPx),
|
||||
]);
|
||||
return comparePageRasters(
|
||||
baselinePadded,
|
||||
candidatePadded,
|
||||
thresholds.pixelDifferenceThreshold,
|
||||
);
|
||||
})();
|
||||
const lineIssues = localRasterIssues(
|
||||
paragraph.expectation.index,
|
||||
lineIndex,
|
||||
paragraph.expectation.blockKind,
|
||||
result.metrics,
|
||||
thresholds,
|
||||
textReflow,
|
||||
);
|
||||
issues.push(...lineIssues);
|
||||
lines.push({
|
||||
|
||||
@@ -10,7 +10,12 @@ import type {
|
||||
const ZERO_WIDTH_AND_CONTROL = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f\u200b-\u200d\u2060\ufeff]/gu;
|
||||
const CJK_RADICAL_VARIANTS = new Map([
|
||||
["⺠", "民"],
|
||||
["⻅", "见"],
|
||||
["⻆", "角"],
|
||||
["⻓", "长"],
|
||||
["⻔", "门"],
|
||||
["⻚", "页"],
|
||||
["⻛", "风"],
|
||||
]);
|
||||
const PAGE_NUMBER_PATTERNS = [
|
||||
/^(?:[-—–]\s*)?\d+(?:\s*[//]\s*\d+)?(?:\s*[-—–])?$/u,
|
||||
|
||||
@@ -56,11 +56,24 @@ export type EditableParagraphRole =
|
||||
| "caption"
|
||||
| "other";
|
||||
|
||||
export type EditableParagraphBlockKind =
|
||||
| "paragraph"
|
||||
| "heading"
|
||||
| "list-item"
|
||||
| "table-header"
|
||||
| "table-cell"
|
||||
| "code-block"
|
||||
| "block-quote"
|
||||
| "caption"
|
||||
| "semantic-region"
|
||||
| "other";
|
||||
|
||||
export interface EditableParagraphExpectation {
|
||||
index: number;
|
||||
text: string;
|
||||
styleId?: string;
|
||||
role: EditableParagraphRole;
|
||||
blockKind?: EditableParagraphBlockKind;
|
||||
section: "cover" | "body";
|
||||
}
|
||||
|
||||
@@ -180,6 +193,10 @@ export interface VisualDiffThresholds {
|
||||
maxChangedPixelRatio: number;
|
||||
minInkIou: number;
|
||||
minEdgeIou: number;
|
||||
maxBackgroundColorDelta: number;
|
||||
maxForegroundColorDelta: number;
|
||||
minAntialiasEquivalentInkIou: number;
|
||||
minAntialiasEquivalentEdgeIou: number;
|
||||
}
|
||||
|
||||
export type VisualDiffIssueSeverity = "warning" | "failure";
|
||||
@@ -271,6 +288,8 @@ export interface PdfRasterDiffMetrics {
|
||||
changedPixelRatio: number;
|
||||
inkIou: number;
|
||||
edgeIou: number;
|
||||
backgroundColorDelta: number;
|
||||
foregroundColorDelta: number;
|
||||
}
|
||||
|
||||
export interface ComparePageRasterOptions {
|
||||
|
||||
@@ -121,6 +121,38 @@ describe("PDF 基础视觉门禁", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("逐段完整映射时忽略 PDF 页码与表格行聚合造成的串联顺序污染", async () => {
|
||||
const baseline = withLines(await snapshot("baseline"), [
|
||||
textLine("门禁 内容", 100),
|
||||
]);
|
||||
const candidate = withLines(await snapshot("candidate"), [
|
||||
textLine("门禁 2 / 2 内容", 100),
|
||||
]);
|
||||
const result = comparePdfSnapshotsBasic(baseline, candidate, {}, {
|
||||
expectedEditableText: "门禁内容",
|
||||
expectedEditableParagraphs: [
|
||||
{
|
||||
index: 0,
|
||||
text: "门禁",
|
||||
role: "body",
|
||||
blockKind: "table-header",
|
||||
section: "body",
|
||||
},
|
||||
{
|
||||
index: 1,
|
||||
text: "内容",
|
||||
role: "body",
|
||||
blockKind: "table-header",
|
||||
section: "body",
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(result.candidateEditableExact).toBe(true);
|
||||
expect(result.issues.map((issue) => issue.code)).not.toContain(
|
||||
"CONTENT_MISMATCH",
|
||||
);
|
||||
});
|
||||
|
||||
it("通过纸张和正文一致的文档", async () => {
|
||||
const baseline = await snapshot("baseline");
|
||||
const candidate = await snapshot("candidate");
|
||||
|
||||
@@ -59,6 +59,95 @@ function expectation(
|
||||
}
|
||||
|
||||
describe("PDF 内容感知段落版式门禁", () => {
|
||||
it("同一物理行中的表格单元格分别保留自己的视觉边界", () => {
|
||||
const tableLine = line("建设内容 主要能力", 220, 320, 72);
|
||||
tableLine.items = [
|
||||
{
|
||||
text: "建设内容",
|
||||
normalizedText: "建设内容",
|
||||
bounds: { x: 72, y: 210, width: 48, height: 12 },
|
||||
baselineY: 220,
|
||||
fontFamily: "Test Serif",
|
||||
hasEol: false,
|
||||
},
|
||||
{
|
||||
text: "主要能力",
|
||||
normalizedText: "主要能力",
|
||||
bounds: { x: 240, y: 210, width: 48, height: 12 },
|
||||
baselineY: 220,
|
||||
fontFamily: "Test Serif",
|
||||
hasEol: true,
|
||||
},
|
||||
];
|
||||
const paragraphs = [
|
||||
expectation("建设内容", { index: 0, blockKind: "table-header" }),
|
||||
expectation("主要能力", { index: 1, blockKind: "table-header" }),
|
||||
];
|
||||
const results = comparePdfEditableParagraphLayouts(
|
||||
snapshot("baseline", [[tableLine]]),
|
||||
snapshot("candidate", [[tableLine]]),
|
||||
paragraphs,
|
||||
);
|
||||
|
||||
expect(results[0]?.baseline.visualLines[0]?.bounds).toEqual(
|
||||
tableLine.items[0]?.bounds,
|
||||
);
|
||||
expect(results[1]?.baseline.visualLines[0]?.bounds).toEqual(
|
||||
tableLine.items[1]?.bounds,
|
||||
);
|
||||
});
|
||||
|
||||
it("按字符类别估算合并文本项中的有序列表正文边界", () => {
|
||||
const listLine = line("3.系统联调", 220, 100, 100);
|
||||
listLine.items = [
|
||||
{
|
||||
text: "3. 系统联调",
|
||||
normalizedText: "3. 系统联调",
|
||||
bounds: { x: 100, y: 210, width: 100, height: 12 },
|
||||
baselineY: 220,
|
||||
fontFamily: "Test Serif",
|
||||
hasEol: true,
|
||||
},
|
||||
];
|
||||
const [result] = comparePdfEditableParagraphLayouts(
|
||||
snapshot("baseline", [[listLine]]),
|
||||
snapshot("candidate", [[listLine]]),
|
||||
[expectation("系统联调", { blockKind: "list-item" })],
|
||||
);
|
||||
|
||||
expect(result?.baseline.visualLines[0]?.bounds.x).toBeCloseTo(117.18, 1);
|
||||
expect(result?.baseline.visualLines[0]?.bounds.width).toBeCloseTo(82.82, 1);
|
||||
});
|
||||
|
||||
it("跳过被无关字符打断的相似文本并匹配后续完整段落", () => {
|
||||
const [result] = comparePdfEditableParagraphLayouts(
|
||||
snapshot("baseline", [[
|
||||
line("甲X乙", 180),
|
||||
line("甲乙", 220),
|
||||
]]),
|
||||
snapshot("candidate", [[
|
||||
line("甲X乙", 180),
|
||||
line("甲乙", 220),
|
||||
]]),
|
||||
[expectation("甲乙")],
|
||||
);
|
||||
|
||||
expect(result?.status).toBe("passed");
|
||||
expect(result?.baseline.firstLineBaselineYPt).toBe(220);
|
||||
expect(result?.candidate.firstLineBaselineYPt).toBe(220);
|
||||
});
|
||||
|
||||
it("连续段落可以自然跨越多个 PDF 视觉行", () => {
|
||||
const [result] = comparePdfEditableParagraphLayouts(
|
||||
snapshot("baseline", [[line("甲乙", 180), line("丙丁", 204)]]),
|
||||
snapshot("candidate", [[line("甲乙", 180), line("丙丁", 204)]]),
|
||||
[expectation("甲乙丙丁")],
|
||||
);
|
||||
|
||||
expect(result?.status).toBe("passed");
|
||||
expect(result?.baseline.lineTexts).toEqual(["甲乙", "丙丁"]);
|
||||
});
|
||||
|
||||
it("允许正文段落整体移动到下一物理页", () => {
|
||||
const baseline = snapshot("baseline", [
|
||||
[line("建立月度调度机制及时协调", 700), line("解决项目中的问题", 728)],
|
||||
@@ -123,6 +212,30 @@ describe("PDF 内容感知段落版式门禁", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("正文块行数不变时允许三个字符以内的流式行内重排", () => {
|
||||
const baseline = snapshot("baseline", [[
|
||||
line("甲乙丙丁", 220),
|
||||
line("戊己庚辛", 244),
|
||||
]]);
|
||||
const candidate = snapshot("candidate", [[
|
||||
line("甲乙丙丁戊己", 220),
|
||||
line("庚辛", 244),
|
||||
]]);
|
||||
const [result] = comparePdfEditableParagraphLayouts(
|
||||
baseline,
|
||||
candidate,
|
||||
[expectation("甲乙丙丁戊己庚辛", { blockKind: "paragraph" })],
|
||||
);
|
||||
|
||||
expect(result?.status).toBe("warning");
|
||||
expect(result?.issues).toContainEqual(
|
||||
expect.objectContaining({
|
||||
code: "PARAGRAPH_LINE_BREAK_MISMATCH",
|
||||
severity: "warning",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("拒绝短正文或非极短末行的行数差异", () => {
|
||||
const text = "项目建设内容需要严格控制质量进度投资安全风险";
|
||||
const baseline = snapshot("baseline", [
|
||||
|
||||
@@ -53,12 +53,41 @@ describe("PDF 栅格差异", () => {
|
||||
changedPixelRatio: 0,
|
||||
inkIou: 1,
|
||||
edgeIou: 1,
|
||||
backgroundColorDelta: 0,
|
||||
foregroundColorDelta: 0,
|
||||
});
|
||||
expect(result.artifacts.overlayPng.subarray(0, 8)).toEqual(
|
||||
new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10]),
|
||||
);
|
||||
});
|
||||
|
||||
it("分别量化块背景色与背景相对的前景形状", async () => {
|
||||
const coloredRaster = (background: string, foreground: string) => {
|
||||
const canvas = createCanvas(80, 40);
|
||||
const context = canvas.getContext("2d");
|
||||
context.fillStyle = background;
|
||||
context.fillRect(0, 0, 80, 40);
|
||||
context.fillStyle = foreground;
|
||||
context.fillRect(20, 10, 40, 20);
|
||||
const png = Uint8Array.from(canvas.toBuffer("image/png"));
|
||||
return {
|
||||
widthPx: 80,
|
||||
heightPx: 40,
|
||||
dpi: 144,
|
||||
sha256: createHash("sha256").update(png).digest("hex"),
|
||||
png,
|
||||
} satisfies PdfPageRaster;
|
||||
};
|
||||
const sameShape = await comparePageRasters(
|
||||
coloredRaster("#f2f2f2", "#202020"),
|
||||
coloredRaster("#ffffff", "#202020"),
|
||||
);
|
||||
|
||||
expect(sameShape.metrics.inkIou).toBe(1);
|
||||
expect(sameShape.metrics.backgroundColorDelta).toBeGreaterThan(10);
|
||||
expect(sameShape.metrics.foregroundColorDelta).toBeLessThan(1);
|
||||
});
|
||||
|
||||
it("量化位移并生成稳定叠加图与热力图", async () => {
|
||||
const result = await comparePageRasters(raster(10), raster(20), {
|
||||
spatialTolerancePx: 0,
|
||||
|
||||
@@ -65,6 +65,25 @@ function blockRaster(color: string): PdfPageRaster {
|
||||
};
|
||||
}
|
||||
|
||||
function antialiasBlockRaster(edgeColor: string): PdfPageRaster {
|
||||
const canvas = createCanvas(40, 50);
|
||||
const context = canvas.getContext("2d");
|
||||
context.fillStyle = "#ffffff";
|
||||
context.fillRect(0, 0, 40, 50);
|
||||
context.fillStyle = edgeColor;
|
||||
context.fillRect(8, 12, 20, 10);
|
||||
context.fillStyle = "#111111";
|
||||
context.fillRect(10, 14, 16, 6);
|
||||
const png = Uint8Array.from(canvas.toBuffer("image/png"));
|
||||
return {
|
||||
widthPx: 40,
|
||||
heightPx: 50,
|
||||
dpi: 144,
|
||||
sha256: createHash("sha256").update(png).digest("hex"),
|
||||
png,
|
||||
};
|
||||
}
|
||||
|
||||
function snapshot(label: string, pageRaster: PdfPageRaster): PdfDocumentSnapshot {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
@@ -422,6 +441,62 @@ describe("PDF 视觉差异报告", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("允许块内轻微换行警告伴随正文自然跨页", async () => {
|
||||
const baseline = flowSnapshot(
|
||||
"baseline",
|
||||
[
|
||||
[contentLine("甲乙丙丁", 8), contentLine("戊己庚辛", 12)],
|
||||
[contentLine("壬癸", 8)],
|
||||
],
|
||||
"#111111",
|
||||
);
|
||||
const candidate = flowSnapshot(
|
||||
"candidate",
|
||||
[
|
||||
[
|
||||
contentLine("甲乙丙丁戊己", 8),
|
||||
contentLine("庚辛", 12),
|
||||
contentLine("壬癸", 16),
|
||||
],
|
||||
[],
|
||||
],
|
||||
"#cc0000",
|
||||
);
|
||||
const paragraphs = [
|
||||
{
|
||||
index: 0,
|
||||
text: "甲乙丙丁戊己庚辛",
|
||||
section: "body",
|
||||
blockKind: "paragraph",
|
||||
},
|
||||
{
|
||||
index: 1,
|
||||
text: "壬癸",
|
||||
section: "body",
|
||||
blockKind: "paragraph",
|
||||
},
|
||||
] as const;
|
||||
const report = await createPdfVisualDiffReport(baseline, candidate, {
|
||||
expectedEditableText: "甲乙丙丁戊己庚辛壬癸",
|
||||
expectedEditableParagraphs: paragraphs,
|
||||
baselinePageSemantics: bodySemantics(2),
|
||||
candidatePageSemantics: bodySemantics(2),
|
||||
});
|
||||
|
||||
expect(report.basic.paragraphLayouts?.[0]).toMatchObject({
|
||||
status: "warning",
|
||||
});
|
||||
expect(report.basic.bodyFlow).toMatchObject({
|
||||
detected: true,
|
||||
legal: true,
|
||||
movedParagraphIndexes: [1],
|
||||
});
|
||||
expect(report.pages.map((page) => page.rasterComparisonMode)).toEqual([
|
||||
"body-flow",
|
||||
"body-flow",
|
||||
]);
|
||||
});
|
||||
|
||||
it("整段跨页同时发生换行变化时由语义块门禁失败", async () => {
|
||||
const baseline = flowSnapshot(
|
||||
"baseline",
|
||||
@@ -563,6 +638,32 @@ describe("PDF 视觉差异报告", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("正文文字字形与前景色一致时忽略跨引擎抗锯齿灰度噪声", async () => {
|
||||
const baseline = flowSnapshot(
|
||||
"baseline",
|
||||
[[contentLine("甲", 8)]],
|
||||
"#111111",
|
||||
);
|
||||
const candidate = flowSnapshot(
|
||||
"candidate",
|
||||
[[contentLine("甲", 8)]],
|
||||
"#111111",
|
||||
);
|
||||
baseline.pages[0]!.raster = antialiasBlockRaster("#999999");
|
||||
candidate.pages[0]!.raster = antialiasBlockRaster("#aaaaaa");
|
||||
const report = await createPdfVisualDiffReport(baseline, candidate, {
|
||||
expectedEditableText: "甲",
|
||||
expectedEditableParagraphs: [flowParagraphs[0]!],
|
||||
pageSemantics: bodySemantics(1),
|
||||
});
|
||||
|
||||
expect(report.issues.map((issue) => issue.code)).not.toContain(
|
||||
"SEMANTIC_BLOCK_RASTER_MISMATCH",
|
||||
);
|
||||
expect(report.basic.semanticBlockVisuals[0]?.lines[0]?.metrics)
|
||||
.toMatchObject({ inkIou: 1, edgeIou: 1 });
|
||||
});
|
||||
|
||||
it("缺少语义块证据时不得把正文整页栅格自动降级", async () => {
|
||||
const report = await createPdfVisualDiffReport(
|
||||
snapshot("baseline", raster("#111111")),
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
isCrossEngineRasterEquivalent,
|
||||
type PdfRasterMetrics,
|
||||
} from "../src/index.js";
|
||||
|
||||
function metrics(
|
||||
overrides: Partial<PdfRasterMetrics> = {},
|
||||
): PdfRasterMetrics {
|
||||
return {
|
||||
baselineWidthPx: 224,
|
||||
baselineHeightPx: 37,
|
||||
candidateWidthPx: 224,
|
||||
candidateHeightPx: 37,
|
||||
comparedWidthPx: 224,
|
||||
comparedHeightPx: 37,
|
||||
dimensionsMatch: true,
|
||||
geometryNormalized: true,
|
||||
spatialTolerancePx: 4,
|
||||
meanAbsoluteError: 14.5,
|
||||
changedPixelRatio: 0.27,
|
||||
inkIou: 0.942,
|
||||
edgeIou: 1,
|
||||
backgroundColorDelta: 0.02,
|
||||
foregroundColorDelta: 0,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("跨引擎字形栅格等价", () => {
|
||||
it("接受轮廓、颜色和几何一致的灰阶抗锯齿差异", () => {
|
||||
expect(isCrossEngineRasterEquivalent(metrics(), false)).toBe(true);
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(
|
||||
metrics({
|
||||
candidateWidthPx: 222,
|
||||
inkIou: 0.88,
|
||||
foregroundColorDelta: 3.5,
|
||||
}),
|
||||
false,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(
|
||||
metrics({ inkIou: 1, edgeIou: 0.946 }),
|
||||
false,
|
||||
"table-header",
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(
|
||||
metrics({ inkIou: 0.969, edgeIou: 0.983 }),
|
||||
false,
|
||||
"code-block",
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(
|
||||
metrics({
|
||||
baselineWidthPx: 38,
|
||||
candidateWidthPx: 38,
|
||||
comparedWidthPx: 38,
|
||||
inkIou: 0.902,
|
||||
edgeIou: 0.904,
|
||||
}),
|
||||
false,
|
||||
"code-block",
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(
|
||||
metrics({
|
||||
baselineWidthPx: 236,
|
||||
candidateWidthPx: 233,
|
||||
baselineHeightPx: 36,
|
||||
candidateHeightPx: 38,
|
||||
inkIou: 0.897,
|
||||
edgeIou: 0.9996,
|
||||
}),
|
||||
false,
|
||||
"paragraph",
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(
|
||||
metrics({
|
||||
baselineWidthPx: 272,
|
||||
candidateWidthPx: 286,
|
||||
baselineHeightPx: 43,
|
||||
candidateHeightPx: 42,
|
||||
inkIou: 0.948,
|
||||
edgeIou: 0.958,
|
||||
foregroundColorDelta: 4.2,
|
||||
}),
|
||||
false,
|
||||
"list-item",
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(
|
||||
metrics({
|
||||
candidateWidthPx: 209,
|
||||
inkIou: 0.974,
|
||||
edgeIou: 0.984,
|
||||
}),
|
||||
false,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(
|
||||
metrics({
|
||||
candidateWidthPx: 208,
|
||||
inkIou: 0.91,
|
||||
edgeIou: 0.94,
|
||||
}),
|
||||
false,
|
||||
"list-item",
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("拒绝回流、颜色、几何或轮廓变化", () => {
|
||||
expect(isCrossEngineRasterEquivalent(metrics(), true)).toBe(false);
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(
|
||||
metrics({ foregroundColorDelta: 4.01 }),
|
||||
false,
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(
|
||||
metrics({ candidateWidthPx: 220 }),
|
||||
false,
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(
|
||||
metrics({
|
||||
candidateWidthPx: 207,
|
||||
inkIou: 0.91,
|
||||
edgeIou: 0.94,
|
||||
}),
|
||||
false,
|
||||
"list-item",
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(
|
||||
metrics({
|
||||
candidateWidthPx: 208,
|
||||
inkIou: 0.974,
|
||||
edgeIou: 0.984,
|
||||
}),
|
||||
false,
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(
|
||||
metrics({
|
||||
candidateWidthPx: 209,
|
||||
inkIou: 0.91,
|
||||
edgeIou: 0.94,
|
||||
}),
|
||||
false,
|
||||
"paragraph",
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(metrics({ edgeIou: 0.96 }), false),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(
|
||||
metrics({
|
||||
baselineWidthPx: 38,
|
||||
candidateWidthPx: 38,
|
||||
comparedWidthPx: 38,
|
||||
inkIou: 0.89,
|
||||
edgeIou: 0.904,
|
||||
}),
|
||||
false,
|
||||
"code-block",
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(
|
||||
metrics({
|
||||
baselineHeightPx: 37,
|
||||
candidateHeightPx: 40,
|
||||
inkIou: 0.95,
|
||||
edgeIou: 1,
|
||||
}),
|
||||
false,
|
||||
"paragraph",
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -42,6 +42,7 @@ describe("PDF 文本行聚合", () => {
|
||||
|
||||
it("将字体 ToUnicode 中的传统户部件归一为简体字符", () => {
|
||||
expect(normalizePdfText("戶⼾")).toBe("户户");
|
||||
expect(normalizePdfText("⻅⻆⻓⻔⻚⻛")).toBe("见角长门页风");
|
||||
});
|
||||
|
||||
it("从可编辑正文契约中移除 Word 自动列表装饰符", () => {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -172,7 +172,7 @@ function pandocArguments(paths: {
|
||||
return [
|
||||
paths.markdown,
|
||||
"--from",
|
||||
"markdown+yaml_metadata_block+pipe_tables+footnotes+task_lists+tex_math_dollars",
|
||||
"markdown+yaml_metadata_block+pipe_tables+footnotes+task_lists+tex_math_dollars-raw_html",
|
||||
"--to",
|
||||
"docx",
|
||||
"--standalone",
|
||||
@@ -375,7 +375,8 @@ export class PandocDocxConverter {
|
||||
pandocDocx,
|
||||
structurePlan,
|
||||
input.themeTokens,
|
||||
preparedMedia.layout
|
||||
preparedMedia.layout,
|
||||
preparedMedia.documentLayout
|
||||
);
|
||||
docx = embedFontsInGeneratedDocx(
|
||||
finalized.content,
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
MAXIMUM_DOCX_RESOURCE_COUNT,
|
||||
MAXIMUM_DOCX_TOTAL_MEDIA_BYTES,
|
||||
type DocxMediaKind,
|
||||
type DocxDocumentLayoutPlan,
|
||||
type PreparedDocxMedia
|
||||
} from "@md-to-pdf/core";
|
||||
|
||||
@@ -47,6 +48,7 @@ export interface PreparedPandocMedia {
|
||||
map: PandocMediaMap;
|
||||
files: PandocMediaFile[];
|
||||
layout: PandocMediaLayoutItem[];
|
||||
documentLayout: DocxDocumentLayoutPlan;
|
||||
}
|
||||
|
||||
export const DOCX_MEDIA_BINDING_PREFIX = "mdtp-media:";
|
||||
@@ -184,5 +186,10 @@ export function preparePandocMedia(
|
||||
if (media.totalBytes !== totalBytes) {
|
||||
throw new Error("DOCX 媒体总大小声明不一致");
|
||||
}
|
||||
return { map, files, layout };
|
||||
return {
|
||||
map,
|
||||
files,
|
||||
layout,
|
||||
documentLayout: media.documentLayout ?? { tables: [] }
|
||||
};
|
||||
}
|
||||
|
||||
@@ -69,9 +69,17 @@ const containerRoles = new Set<SemanticDocumentRole>([
|
||||
"tender-cover"
|
||||
]);
|
||||
|
||||
const semanticRoleSlotAliases: Readonly<
|
||||
Partial<Record<SemanticDocumentRole, DocxStyleSlotName>>
|
||||
> = {};
|
||||
|
||||
function slotName(
|
||||
role: SemanticDocumentRole
|
||||
): DocxStyleSlotName | undefined {
|
||||
const alias = semanticRoleSlotAliases[role];
|
||||
if (alias) {
|
||||
return alias;
|
||||
}
|
||||
return Object.prototype.hasOwnProperty.call(
|
||||
DOCX_SLOT_WORD_STYLE_BINDINGS,
|
||||
role
|
||||
|
||||
@@ -7,8 +7,10 @@ import type {
|
||||
import type { ResolvedDocxThemeStyle } from "./style-presets.js";
|
||||
import {
|
||||
DOCX_SLOT_WORD_STYLE_BINDINGS,
|
||||
PANDOC_SYNTAX_STYLE_IDS,
|
||||
collectDocxTokenFonts,
|
||||
createDocxTokenSlotMap,
|
||||
resolvePandocSyntaxStyleSlot,
|
||||
resolveTokenFonts
|
||||
} from "./token-style-map.js";
|
||||
import {
|
||||
@@ -64,7 +66,8 @@ function setRunStyle(
|
||||
"iCs",
|
||||
"u",
|
||||
"shd",
|
||||
"spacing"
|
||||
"spacing",
|
||||
"kern"
|
||||
);
|
||||
if (options.fonts) {
|
||||
setFonts(parent, options.fonts);
|
||||
@@ -106,6 +109,9 @@ function setRunStyle(
|
||||
"w:val": wordCharacterSpacingTwips(options.sizePt)
|
||||
});
|
||||
}
|
||||
appendElement(parent, WORD_NAMESPACE, "w:kern", {
|
||||
"w:val": "2"
|
||||
});
|
||||
}
|
||||
|
||||
function setParagraphSpacing(
|
||||
@@ -200,8 +206,16 @@ function fallbackFontsForSlot(
|
||||
if (slot.startsWith("heading-") || slot.endsWith("-title")) {
|
||||
return fallback.headings.fonts;
|
||||
}
|
||||
if (slot === "inline-code" || slot === "code-block") {
|
||||
return fallback.code.fonts;
|
||||
if (
|
||||
slot === "inline-code" ||
|
||||
slot === "code-block" ||
|
||||
slot === "code-block-text" ||
|
||||
slot.startsWith("code-token-")
|
||||
) {
|
||||
return {
|
||||
...fallback.code.fonts,
|
||||
eastAsia: fallback.body.fonts.eastAsia
|
||||
};
|
||||
}
|
||||
if (
|
||||
slot === "table" ||
|
||||
@@ -232,7 +246,12 @@ function fallbackFontSizeForSlot(
|
||||
if (slot.endsWith("-title")) {
|
||||
return fallback.headings.sizesPt[0];
|
||||
}
|
||||
if (slot === "inline-code" || slot === "code-block") {
|
||||
if (
|
||||
slot === "inline-code" ||
|
||||
slot === "code-block" ||
|
||||
slot === "code-block-text" ||
|
||||
slot.startsWith("code-token-")
|
||||
) {
|
||||
return fallback.code.sizePt;
|
||||
}
|
||||
if (
|
||||
@@ -251,10 +270,17 @@ function fallbackFontSizeForSlot(
|
||||
function applyTokenRunStyle(
|
||||
parent: XmlElement,
|
||||
token: DocxSlotStyleToken,
|
||||
fallbackFonts: DocxFontFamily
|
||||
fallbackFonts: DocxFontFamily,
|
||||
preserveFallbackEastAsia = false
|
||||
) {
|
||||
removeDirectChildren(parent, WORD_NAMESPACE, "kern");
|
||||
appendElement(parent, WORD_NAMESPACE, "w:kern", {
|
||||
"w:val": "2"
|
||||
});
|
||||
if (token.fontCandidates.length) {
|
||||
setFonts(parent, resolveTokenFonts(token, fallbackFonts));
|
||||
setFonts(parent, resolveTokenFonts(token, fallbackFonts, {
|
||||
preserveFallbackEastAsia
|
||||
}));
|
||||
}
|
||||
if (token.fontSizePt !== undefined) {
|
||||
removeDirectChildren(parent, WORD_NAMESPACE, "sz", "szCs");
|
||||
@@ -1176,17 +1202,77 @@ function applyTokenStyles(
|
||||
binding.styleId === "SourceCode"
|
||||
);
|
||||
}
|
||||
const runToken = binding.styleId === "SourceCode"
|
||||
? slots.get("code-block-text")?.style ?? entry.style
|
||||
: entry.style;
|
||||
applyTokenRunStyle(
|
||||
ensureDirectElement(
|
||||
target,
|
||||
WORD_NAMESPACE,
|
||||
"w:rPr"
|
||||
),
|
||||
entry.style,
|
||||
fallbackFontsForSlot(slot, fallback)
|
||||
runToken,
|
||||
fallbackFontsForSlot(slot, fallback),
|
||||
binding.styleId === "SourceCode" ||
|
||||
slot === "inline-code" ||
|
||||
slot === "code-block" ||
|
||||
slot === "code-block-text" ||
|
||||
slot.startsWith("code-token-")
|
||||
);
|
||||
}
|
||||
}
|
||||
const codeTextToken =
|
||||
slots.get("code-block-text")?.style ??
|
||||
slots.get("code-block")?.style;
|
||||
if (codeTextToken) {
|
||||
const baseStyleId = "MdSourceCodeChar";
|
||||
const baseStyle = ensureStyle(
|
||||
styles,
|
||||
baseStyleId,
|
||||
"character",
|
||||
"BodyTextChar"
|
||||
);
|
||||
applyTokenRunStyle(
|
||||
ensureDirectElement(baseStyle, WORD_NAMESPACE, "w:rPr"),
|
||||
codeTextToken,
|
||||
{
|
||||
...fallback.code.fonts,
|
||||
eastAsia: fallback.body.fonts.eastAsia
|
||||
},
|
||||
true
|
||||
);
|
||||
for (const styleId of PANDOC_SYNTAX_STYLE_IDS) {
|
||||
const target = ensureStyle(
|
||||
styles,
|
||||
styleId,
|
||||
"character",
|
||||
baseStyleId
|
||||
);
|
||||
const basedOn = firstDirectChild(
|
||||
target,
|
||||
WORD_NAMESPACE,
|
||||
"basedOn"
|
||||
);
|
||||
if (basedOn) {
|
||||
setWordAttribute(basedOn, "val", baseStyleId);
|
||||
}
|
||||
const syntaxSlot = resolvePandocSyntaxStyleSlot(styleId);
|
||||
const syntaxToken = syntaxSlot
|
||||
? slots.get(syntaxSlot)?.style
|
||||
: undefined;
|
||||
if (syntaxToken) {
|
||||
applyTokenRunStyle(
|
||||
ensureDirectElement(target, WORD_NAMESPACE, "w:rPr"),
|
||||
syntaxToken,
|
||||
{
|
||||
...fallback.code.fonts,
|
||||
eastAsia: fallback.body.fonts.eastAsia
|
||||
},
|
||||
true
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
applyTableTokenStyles(styles, slots, fallback);
|
||||
}
|
||||
|
||||
|
||||
@@ -110,6 +110,7 @@ export const DOCX_SLOT_WORD_STYLE_BINDINGS: Readonly<
|
||||
paragraph("MdOfficialPrintingRow")
|
||||
],
|
||||
"briefing-masthead": [paragraph("MdBriefingMasthead")],
|
||||
"briefing-masthead-text": [paragraph("MdBriefingMastheadText")],
|
||||
"briefing-meta": [paragraph("MdBriefingMeta")],
|
||||
"briefing-title": [paragraph("MdBriefingTitle")],
|
||||
"briefing-contact": [paragraph("MdBriefingContact")],
|
||||
@@ -141,6 +142,93 @@ export const DOCX_SLOT_WORD_STYLE_BINDINGS: Readonly<
|
||||
"tender-date": [paragraph("MdTenderDate")]
|
||||
};
|
||||
|
||||
export const PANDOC_SYNTAX_STYLE_IDS = [
|
||||
"AlertTok",
|
||||
"AnnotationTok",
|
||||
"AttributeTok",
|
||||
"BaseNTok",
|
||||
"BuiltInTok",
|
||||
"CharTok",
|
||||
"CommentTok",
|
||||
"CommentVarTok",
|
||||
"ConstantTok",
|
||||
"ControlFlowTok",
|
||||
"DataTypeTok",
|
||||
"DecValTok",
|
||||
"DocumentationTok",
|
||||
"ErrorTok",
|
||||
"ExtensionTok",
|
||||
"FloatTok",
|
||||
"FunctionTok",
|
||||
"ImportTok",
|
||||
"InformationTok",
|
||||
"KeywordTok",
|
||||
"NormalTok",
|
||||
"OperatorTok",
|
||||
"OtherTok",
|
||||
"PreprocessorTok",
|
||||
"SpecialCharTok",
|
||||
"SpecialStringTok",
|
||||
"StringTok",
|
||||
"VariableTok",
|
||||
"VerbatimStringTok",
|
||||
"WarningTok"
|
||||
] as const;
|
||||
|
||||
const pandocSyntaxSlots: Readonly<
|
||||
Partial<Record<(typeof PANDOC_SYNTAX_STYLE_IDS)[number], DocxStyleSlotName>>
|
||||
> = {
|
||||
AlertTok: "code-token-keyword",
|
||||
AnnotationTok: "code-token-comment",
|
||||
AttributeTok: "code-token-attribute",
|
||||
BaseNTok: "code-token-number",
|
||||
BuiltInTok: "code-token-built-in",
|
||||
CharTok: "code-token-string",
|
||||
CommentTok: "code-token-comment",
|
||||
CommentVarTok: "code-token-comment",
|
||||
ConstantTok: "code-token-literal",
|
||||
ControlFlowTok: "code-token-keyword",
|
||||
DataTypeTok: "code-token-attribute",
|
||||
DecValTok: "code-token-number",
|
||||
DocumentationTok: "code-token-comment",
|
||||
ErrorTok: "code-token-keyword",
|
||||
ExtensionTok: "code-token-built-in",
|
||||
FloatTok: "code-token-number",
|
||||
FunctionTok: "code-token-title",
|
||||
ImportTok: "code-token-keyword",
|
||||
InformationTok: "code-token-comment",
|
||||
KeywordTok: "code-token-keyword",
|
||||
OperatorTok: "code-token-operator",
|
||||
PreprocessorTok: "code-token-meta",
|
||||
SpecialCharTok: "code-token-string",
|
||||
SpecialStringTok: "code-token-string",
|
||||
StringTok: "code-token-string",
|
||||
VariableTok: "code-token-variable",
|
||||
VerbatimStringTok: "code-token-string",
|
||||
WarningTok: "code-token-keyword"
|
||||
};
|
||||
|
||||
export function resolvePandocSyntaxStyleSlot(
|
||||
styleId: string,
|
||||
text = ""
|
||||
): DocxStyleSlotName | "code-block-text" | undefined {
|
||||
if (!(PANDOC_SYNTAX_STYLE_IDS as readonly string[]).includes(styleId)) {
|
||||
return undefined;
|
||||
}
|
||||
if (
|
||||
styleId === "NormalTok" ||
|
||||
styleId === "OtherTok" ||
|
||||
(styleId === "FunctionTok" && /^[\p{P}\p{S}\s]+$/u.test(text))
|
||||
) {
|
||||
return styleId === "FunctionTok"
|
||||
? "code-token-punctuation"
|
||||
: "code-block-text";
|
||||
}
|
||||
return pandocSyntaxSlots[
|
||||
styleId as (typeof PANDOC_SYNTAX_STYLE_IDS)[number]
|
||||
] ?? "code-block-text";
|
||||
}
|
||||
|
||||
const eastAsiaFontPattern =
|
||||
/(?:fandol|yahei|simsun|simhei|fangsong|kaiti|dengxian|cjk|source\s+han|pingfang|heiti|songti|宋|黑|仿宋|楷|等线)/iu;
|
||||
const genericFontNames = new Set([
|
||||
@@ -154,7 +242,8 @@ const genericFontNames = new Set([
|
||||
|
||||
export function resolveTokenFonts(
|
||||
token: DocxSlotStyleToken | undefined,
|
||||
fallback: DocxFontFamily
|
||||
fallback: DocxFontFamily,
|
||||
options: { preserveFallbackEastAsia?: boolean } = {}
|
||||
): DocxFontFamily {
|
||||
const candidates = (token?.fontCandidates ?? []).filter(
|
||||
(candidate) =>
|
||||
@@ -166,7 +255,9 @@ export function resolveTokenFonts(
|
||||
const eastAsia =
|
||||
candidates.find((candidate) =>
|
||||
eastAsiaFontPattern.test(candidate)
|
||||
) ?? candidates[0]!;
|
||||
) ?? (options.preserveFallbackEastAsia
|
||||
? fallback.eastAsia
|
||||
: candidates[0]!);
|
||||
const latin =
|
||||
candidates.find(
|
||||
(candidate) => !eastAsiaFontPattern.test(candidate)
|
||||
|
||||
@@ -33,7 +33,7 @@ function generatedFixture() {
|
||||
encoder.encode(
|
||||
`<w:document xmlns:w="${word}" xmlns:r="${relationships}"><w:body>` +
|
||||
`<w:p><w:r><w:t>CONTAINER_START</w:t></w:r></w:p>` +
|
||||
`<w:p><w:pPr><w:pStyle w:val="MdOfficialIssueRow"/></w:pPr><w:r><w:t>发文字号</w:t></w:r><w:r><w:tab/></w:r><w:r><w:t>签发人</w:t></w:r></w:p>` +
|
||||
`<w:p><w:pPr><w:pStyle w:val="MdOfficialIssueRow"/></w:pPr><w:r><w:t>期号</w:t></w:r><w:r><w:tab/></w:r><w:r><w:t>发布单位</w:t></w:r><w:r><w:tab/></w:r><w:r><w:t>签发人</w:t></w:r><w:r><w:tab/></w:r><w:r><w:t>日期</w:t></w:r></w:p>` +
|
||||
`<w:p><w:pPr><w:pStyle w:val="MdTenderTitle"/></w:pPr><w:r><w:t>可编辑封面</w:t></w:r></w:p>` +
|
||||
`<w:p><w:r><w:t>CONTAINER_END</w:t></w:r></w:p>` +
|
||||
`<w:p><w:r><w:t>SECTION_BREAK</w:t></w:r></w:p>` +
|
||||
@@ -42,7 +42,7 @@ function generatedFixture() {
|
||||
`<wp:inline xmlns:wp="${wordprocessingDrawing}"><wp:extent cx="100" cy="200"/><wp:docPr id="1" name="Picture" descr="Mermaid 图表 1" title="mdtp-media:docx-media-1"/>` +
|
||||
`<a:graphic xmlns:a="${drawing}"><a:graphicData uri="${picture}"><pic:pic xmlns:pic="${picture}"><pic:nvPicPr><pic:cNvPr id="0" name="image.png"/></pic:nvPicPr><pic:blipFill><a:blip r:embed="rIdImage"/><a:stretch><a:fillRect/></a:stretch></pic:blipFill><pic:spPr><a:xfrm rot="60000" flipH="1"><a:off x="0" y="0"/><a:ext cx="100" cy="200"/></a:xfrm></pic:spPr></pic:pic></a:graphicData></a:graphic>` +
|
||||
`</wp:inline></w:drawing></w:r></w:p>` +
|
||||
`<w:tbl><w:tblPr><w:tblW w:w="0" w:type="auto"/></w:tblPr><w:tblGrid><w:gridCol w:w="1000"/><w:gridCol w:w="2000"/></w:tblGrid><w:tr><w:trPr><w:tblHeader/></w:trPr><w:tc><w:tcPr/><w:p><w:r><w:t>A</w:t></w:r></w:p></w:tc><w:tc><w:tcPr/><w:p><w:r><w:t>B</w:t></w:r></w:p></w:tc></w:tr><w:tr><w:tc><w:tcPr/><w:p><w:r><w:t>C</w:t></w:r></w:p></w:tc><w:tc><w:tcPr/><w:p><w:r><w:t>D</w:t></w:r></w:p></w:tc></w:tr></w:tbl>` +
|
||||
`<w:tbl><w:tblPr><w:tblW w:w="0" w:type="auto"/></w:tblPr><w:tblGrid><w:gridCol w:w="1000"/><w:gridCol w:w="2000"/></w:tblGrid><w:tr><w:trPr><w:tblHeader/></w:trPr><w:tc><w:tcPr/><w:p><w:r><w:t>A</w:t></w:r></w:p></w:tc><w:tc><w:tcPr/><w:p><w:r><w:t>B</w:t></w:r></w:p></w:tc></w:tr><w:tr><w:tc><w:tcPr/><w:p><w:r><w:t>C</w:t></w:r></w:p></w:tc><w:tc><w:tcPr/><w:p><w:r><w:t>单元格前</w:t></w:r><w:r><w:rPr><w:rStyle w:val="VerbatimChar"/></w:rPr><w:t>mdtp_ic_c</w:t></w:r><w:r><w:t>单元格后</w:t></w:r></w:p></w:tc></w:tr></w:tbl>` +
|
||||
`<w:p><w:pPr><w:pStyle w:val="MdOfficialSignatureDate"/></w:pPr><w:r><w:t>2026年7月29日</w:t></w:r></w:p>` +
|
||||
`<w:sectPr><w:footerReference w:type="default" r:id="rIdFooter"/><w:pgSz w:w="11906" w:h="16838"/><w:pgMar w:top="1000" w:right="1000" w:bottom="1000" w:left="1000"/><w:pgNumType w:start="5"/><w:titlePg/></w:sectPr>` +
|
||||
`</w:body></w:document>`
|
||||
@@ -78,7 +78,7 @@ const plan: PandocStructurePlan = {
|
||||
kind: "paragraph",
|
||||
styleId: "MdOfficialIssueRow",
|
||||
layout: "space-between",
|
||||
segments: ["发文字号", "签发人"],
|
||||
segments: ["期号", "发布单位", "签发人", "日期"],
|
||||
separator: "tab"
|
||||
}
|
||||
]
|
||||
@@ -161,11 +161,28 @@ const tokens: DocxThemeTokenSet = {
|
||||
source: "computed-css",
|
||||
confidence: "exact",
|
||||
style: {
|
||||
fontCandidates: [],
|
||||
fontCandidates: ["Body Face"],
|
||||
fontSizePt: 10,
|
||||
lineSpacing: 1.8
|
||||
}
|
||||
},
|
||||
{
|
||||
slot: "inline-code",
|
||||
source: "computed-css",
|
||||
confidence: "exact",
|
||||
style: {
|
||||
fontCandidates: ["Code Face"],
|
||||
fontSizePt: 9,
|
||||
color: "#112233",
|
||||
backgroundColor: "#f5f5f5",
|
||||
paddingPt: {
|
||||
top: 3,
|
||||
right: 6,
|
||||
bottom: 3,
|
||||
left: 6
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
slot: "table-header",
|
||||
source: "computed-css",
|
||||
@@ -213,7 +230,18 @@ describe("生成 DOCX 结构收口", () => {
|
||||
const result = finalizeGeneratedDocxStructure(
|
||||
generatedFixture(),
|
||||
plan,
|
||||
tokens
|
||||
tokens,
|
||||
[],
|
||||
{
|
||||
tables: [],
|
||||
textBlocks: [{
|
||||
ordinal: 1,
|
||||
text: "期号发布单位签发人日期",
|
||||
letterSpacingPt: 0,
|
||||
linePitchPt: 17.25,
|
||||
lineBreakOffsets: [6]
|
||||
}]
|
||||
}
|
||||
);
|
||||
const entries = readGeneratedDocxPackage(
|
||||
result.content
|
||||
@@ -237,6 +265,9 @@ describe("生成 DOCX 结构收口", () => {
|
||||
expect(documentXml).toContain(
|
||||
'<w:vAlign w:val="center"/>'
|
||||
);
|
||||
expect(documentXml).toContain(
|
||||
'w:line="345" w:lineRule="exact"'
|
||||
);
|
||||
expect(documentXml).toContain(
|
||||
'<w:pgNumType w:start="1"/>'
|
||||
);
|
||||
@@ -276,9 +307,11 @@ describe("生成 DOCX 结构收口", () => {
|
||||
expect(documentXml).toContain(
|
||||
'<w:tab w:val="right" w:pos="9806"/>'
|
||||
);
|
||||
expect(documentXml).not.toContain('<w:tab w:val="center"');
|
||||
expect(documentXml).toContain("<w:br/>");
|
||||
expect(documentXml).toContain('w:fill="F5F5F5"');
|
||||
expect(documentXml).toContain(
|
||||
'<w:spacing w:before="140"/>'
|
||||
'w:spacing w:before="140"'
|
||||
);
|
||||
expect(documentXml).toContain(
|
||||
'<w:spacing w:after="1020"/>'
|
||||
@@ -318,6 +351,180 @@ describe("生成 DOCX 结构收口", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("将 Chromium 避头尾规则留下的单个汉字尾行翻译为显式换行", () => {
|
||||
const fixture = readGeneratedDocxPackage(generatedFixture());
|
||||
const documentXml = decoder.decode(
|
||||
fixture.entries.get("word/document.xml")!
|
||||
).replace(
|
||||
'<w:p><w:pPr><w:pStyle w:val="Heading1"/></w:pPr><w:r><w:t>正文标题</w:t></w:r></w:p>',
|
||||
'<w:p><w:pPr><w:pStyle w:val="Heading1"/></w:pPr><w:r><w:t>正文标题</w:t></w:r></w:p>' +
|
||||
'<w:p><w:pPr><w:pStyle w:val="FirstParagraph"/><w:jc w:val="both"/></w:pPr><w:r><w:t>浏览器末行保留项目。</w:t></w:r></w:p>'
|
||||
);
|
||||
fixture.entries.set("word/document.xml", encoder.encode(documentXml));
|
||||
|
||||
const result = finalizeGeneratedDocxStructure(
|
||||
writeGeneratedDocxPackage(fixture.entries),
|
||||
plan,
|
||||
tokens,
|
||||
[],
|
||||
{
|
||||
tables: [],
|
||||
textBlocks: [{
|
||||
ordinal: 1,
|
||||
text: "浏览器末行保留项目。",
|
||||
letterSpacingPt: 0,
|
||||
alignment: "justify",
|
||||
lineBreakOffsets: [8]
|
||||
}]
|
||||
}
|
||||
);
|
||||
const outputXml = decoder.decode(
|
||||
readGeneratedDocxPackage(result.content).entries.get(
|
||||
"word/document.xml"
|
||||
)!
|
||||
);
|
||||
|
||||
expect(outputXml).toContain(
|
||||
'<w:t>浏览器末行保留项</w:t><w:br/><w:t>目。</w:t>'
|
||||
);
|
||||
});
|
||||
|
||||
it("为右对齐盒模型保留 CSS 右侧内容内缩", () => {
|
||||
const fixture = readGeneratedDocxPackage(generatedFixture());
|
||||
const documentXml = decoder.decode(
|
||||
fixture.entries.get("word/document.xml")!
|
||||
).replace(
|
||||
'<w:p><w:pPr><w:pStyle w:val="MdTenderTitle"/></w:pPr><w:r><w:t>可编辑封面</w:t></w:r></w:p>',
|
||||
'<w:p><w:pPr><w:pStyle w:val="MdTenderCopyMark"/></w:pPr><w:r><w:t>正本</w:t></w:r></w:p>'
|
||||
);
|
||||
fixture.entries.set("word/document.xml", encoder.encode(documentXml));
|
||||
const copyMarkTokens: DocxThemeTokenSet = {
|
||||
...tokens,
|
||||
slots: [
|
||||
...tokens.slots.map((entry) =>
|
||||
entry.slot === "tender-cover"
|
||||
? {
|
||||
...entry,
|
||||
style: {
|
||||
...entry.style,
|
||||
minimumHeightPt: 500,
|
||||
verticalAlignment: "center" as const
|
||||
}
|
||||
}
|
||||
: entry
|
||||
),
|
||||
{
|
||||
slot: "tender-copy-mark",
|
||||
source: "computed-css",
|
||||
confidence: "exact",
|
||||
style: {
|
||||
fontCandidates: [],
|
||||
fontSizePt: 13,
|
||||
selfAlignment: "right",
|
||||
paddingPt: { top: 3, right: 11.34, bottom: 3, left: 11.34 },
|
||||
borders: {
|
||||
right: { widthPt: 1.13, style: "single", color: "#222222" }
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
const result = finalizeGeneratedDocxStructure(
|
||||
writeGeneratedDocxPackage(fixture.entries),
|
||||
plan,
|
||||
copyMarkTokens
|
||||
);
|
||||
const outputXml = decoder.decode(
|
||||
readGeneratedDocxPackage(result.content).entries.get(
|
||||
"word/document.xml"
|
||||
)!
|
||||
);
|
||||
|
||||
expect(outputXml).toMatch(
|
||||
/<w:pStyle w:val="MdTenderCopyMark"\/>[\s\S]*?<w:t>正本<\/w:t>[\s\S]*?<w:sz w:val="25"\/>[\s\S]*?<w:t xml:space="preserve">\u00a0\u00a0<\/w:t>/u
|
||||
);
|
||||
});
|
||||
|
||||
it("按 Chromium 实测比例写入固定 DXA 表格网格和单元格宽度", () => {
|
||||
const result = finalizeGeneratedDocxStructure(
|
||||
generatedFixture(),
|
||||
plan,
|
||||
tokens,
|
||||
[],
|
||||
{
|
||||
tables: [
|
||||
{
|
||||
ordinal: 1,
|
||||
widthPercent: 90,
|
||||
leftOffsetPercent: 5,
|
||||
columnWidthPercents: [30, 70],
|
||||
rows: []
|
||||
}
|
||||
],
|
||||
textBlocks: [
|
||||
{
|
||||
ordinal: 1,
|
||||
text: "单元格前mdtp_ic_c单元格后",
|
||||
letterSpacingPt: 0,
|
||||
alignment: "distribute",
|
||||
lineBreakOffsets: []
|
||||
}
|
||||
],
|
||||
inlineCodes: [
|
||||
{
|
||||
ordinal: 1,
|
||||
text: "mdtp_ic_c",
|
||||
fontSizePt: 8,
|
||||
letterSpacingPt: 0.2,
|
||||
color: "#334455",
|
||||
backgroundColor: "#abcdef",
|
||||
paddingPt: {
|
||||
top: 1,
|
||||
right: 2,
|
||||
bottom: 1,
|
||||
left: 2
|
||||
},
|
||||
borderPt: {
|
||||
top: 0.5,
|
||||
right: 0.5,
|
||||
bottom: 0.5,
|
||||
left: 0.5
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
);
|
||||
const documentXml = decoder.decode(
|
||||
readGeneratedDocxPackage(result.content).entries.get(
|
||||
"word/document.xml"
|
||||
)!
|
||||
);
|
||||
|
||||
expect(documentXml).toContain(
|
||||
'<w:tblW w:w="8915" w:type="dxa"/>'
|
||||
);
|
||||
expect(documentXml).toContain(
|
||||
'<w:tblInd w:w="495" w:type="dxa"/>'
|
||||
);
|
||||
expect(documentXml).toContain('<w:tblLayout w:type="fixed"/>');
|
||||
expect(documentXml).toContain('<w:gridCol w:w="2675"/>');
|
||||
expect(documentXml).toContain('<w:gridCol w:w="6240"/>');
|
||||
expect(
|
||||
documentXml.match(/<w:tcW w:w="2675" w:type="dxa"\/>/gu)
|
||||
).toHaveLength(2);
|
||||
expect(
|
||||
documentXml.match(/<w:tcW w:w="6240" w:type="dxa"\/>/gu)
|
||||
).toHaveLength(2);
|
||||
expect(documentXml).toMatch(
|
||||
/<w:r><w:rPr><w:rStyle w:val="VerbatimChar"\/><w:rFonts[^>]*w:ascii="Code Face"[^>]*\/>[\s\S]*?<w:color w:val="334455"\/>[\s\S]*?<w:sz w:val="16"\/>[\s\S]*?<w:bdr[^>]*w:sz="20"[^>]*w:space="0"[^>]*w:color="ABCDEF"[^>]*\/>[\s\S]*?<w:shd[^>]*w:fill="ABCDEF"[^>]*\/>[\s\S]*?<w:t>mdtp_ic_c<\/w:t><\/w:r>/u
|
||||
);
|
||||
expect(documentXml).toMatch(
|
||||
/<w:r><w:rPr><w:rFonts[^>]*w:ascii="Body Face"[^>]*\/>[\s\S]*?<w:t>单元格前<\/w:t><\/w:r>/u
|
||||
);
|
||||
expect(documentXml).toContain('<w:jc w:val="distribute"/>');
|
||||
});
|
||||
|
||||
it("按稳定媒体计划规范化内联尺寸、比例锁和对齐", () => {
|
||||
const result = finalizeGeneratedDocxStructure(
|
||||
generatedFixture(),
|
||||
@@ -574,7 +781,7 @@ describe("生成 DOCX 结构收口", () => {
|
||||
expect(documentXml.match(/<w:trHeight\b/gu)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("将主题项目符号位置与 Word 悬挂缩进合并", () => {
|
||||
it("将主题列表文字起点映射为 Word 悬挂缩进", () => {
|
||||
const source = readGeneratedDocxPackage(generatedFixture());
|
||||
const entries = new Map(source.entries);
|
||||
entries.set(
|
||||
@@ -612,10 +819,66 @@ describe("生成 DOCX 结构收口", () => {
|
||||
);
|
||||
|
||||
expect(numberingXml).toContain(
|
||||
'<w:ind w:left="960" w:hanging="360"/>'
|
||||
'<w:ind w:left="600" w:hanging="360"/>'
|
||||
);
|
||||
expect(numberingXml).toContain(
|
||||
'<w:ind w:left="1680" w:hanging="360"/>'
|
||||
'<w:ind w:left="1320" w:hanging="360"/>'
|
||||
);
|
||||
});
|
||||
|
||||
it("将 Chromium 实测列表项文字起点映射为段落直接缩进", () => {
|
||||
const source = readGeneratedDocxPackage(generatedFixture());
|
||||
const entries = new Map(source.entries);
|
||||
const documentXml = decoder.decode(entries.get("word/document.xml")!);
|
||||
entries.set(
|
||||
"word/document.xml",
|
||||
encoder.encode(documentXml.replace(
|
||||
"<w:sectPr>",
|
||||
`<w:p><w:pPr><w:numPr><w:ilvl w:val="0"/><w:numId w:val="1"/></w:numPr></w:pPr><w:r><w:t>一级列表</w:t></w:r></w:p>` +
|
||||
`<w:p><w:pPr><w:numPr><w:ilvl w:val="1"/><w:numId w:val="1"/></w:numPr></w:pPr><w:r><w:t>二级</w:t></w:r><w:r><w:rPr><w:rStyle w:val="VerbatimChar"/></w:rPr><w:t>代码</w:t></w:r></w:p>` +
|
||||
`<w:sectPr>`
|
||||
))
|
||||
);
|
||||
entries.set(
|
||||
"word/numbering.xml",
|
||||
encoder.encode(
|
||||
`<w:numbering xmlns:w="${word}"><w:abstractNum w:abstractNumId="1"><w:lvl w:ilvl="0"><w:pPr><w:ind w:left="720" w:hanging="360"/></w:pPr></w:lvl><w:lvl w:ilvl="1"><w:pPr><w:ind w:left="1440" w:hanging="360"/></w:pPr></w:lvl></w:abstractNum><w:num w:numId="1"><w:abstractNumId w:val="1"/></w:num></w:numbering>`
|
||||
)
|
||||
);
|
||||
const result = finalizeGeneratedDocxStructure(
|
||||
writeGeneratedDocxPackage(entries),
|
||||
plan,
|
||||
tokens,
|
||||
[],
|
||||
{
|
||||
tables: [],
|
||||
listItems: [
|
||||
{
|
||||
ordinal: 1,
|
||||
text: "一级列表",
|
||||
depth: 0,
|
||||
textStartPt: 10
|
||||
},
|
||||
{
|
||||
ordinal: 2,
|
||||
text: "二级代码",
|
||||
depth: 1,
|
||||
textStartPt: 25
|
||||
}
|
||||
]
|
||||
}
|
||||
);
|
||||
const finalizedXml = decoder.decode(
|
||||
readGeneratedDocxPackage(result.content).entries.get(
|
||||
"word/document.xml"
|
||||
)!
|
||||
);
|
||||
|
||||
expect(finalizedXml).toMatch(
|
||||
/<w:numPr>[\s\S]*?<w:ind w:left="200" w:hanging="200"\/>[\s\S]*?<w:t>一级列表<\/w:t>/u
|
||||
);
|
||||
expect(finalizedXml).toMatch(
|
||||
/<w:numPr>[\s\S]*?<w:ind w:left="500" w:hanging="360"\/>[\s\S]*?<w:t>二级<\/w:t>[\s\S]*?<w:t>代码<\/w:t>/u
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -149,7 +149,7 @@ describe("DOCX 字体包写入", () => {
|
||||
writeGeneratedDocxPackage(invalidEntries)
|
||||
)
|
||||
).toThrow("字体元数据与嵌入字形不匹配");
|
||||
});
|
||||
}, 15_000);
|
||||
|
||||
it("没有字体时保持原始 DOCX 字节不变", () => {
|
||||
const input = createTestBaselineReference();
|
||||
|
||||
@@ -126,6 +126,9 @@ describe("Pandoc DOCX 转换器", () => {
|
||||
expect(arguments_).toContain("--lua-filter");
|
||||
expect(arguments_).toContain("--data-dir");
|
||||
expect(arguments_).toContain("--resource-path");
|
||||
expect(argumentAfter(arguments_, "--from")).toBe(
|
||||
"markdown+yaml_metadata_block+pipe_tables+footnotes+task_lists+tex_math_dollars-raw_html"
|
||||
);
|
||||
expect(
|
||||
options.env?.MD_TO_PDF_DOCX_MEDIA_MAP
|
||||
).toContain("media-map.json");
|
||||
|
||||
@@ -66,6 +66,36 @@ function paragraphSegments(block: PandocStructureBlock): string[] {
|
||||
}
|
||||
|
||||
describe("Pandoc 语义结构投影", () => {
|
||||
it("将简报抬头文字映射到父级抬头样式", () => {
|
||||
const model: SemanticDocumentModel = {
|
||||
schemaVersion: 1,
|
||||
profile: "briefing",
|
||||
titlePolicy: {
|
||||
metadataTitle: "suppress",
|
||||
firstBodyHeading: "keep"
|
||||
},
|
||||
regions: [{
|
||||
kind: "prefix",
|
||||
nodes: [{
|
||||
kind: "group",
|
||||
role: "briefing-masthead",
|
||||
children: [text("briefing-masthead-text", "工作简报")]
|
||||
}]
|
||||
}]
|
||||
};
|
||||
const plan = createPandocStructurePlan(model, tokens(), {
|
||||
markerSeed: "briefing"
|
||||
});
|
||||
expect(plan.prefix[0]).toMatchObject({
|
||||
kind: "container",
|
||||
blocks: [{
|
||||
kind: "paragraph",
|
||||
styleId: "MdBriefingMastheadText",
|
||||
segments: ["工作简报"]
|
||||
}]
|
||||
});
|
||||
});
|
||||
|
||||
it("将公文分组投影为固定 Word 样式和可编辑段落", () => {
|
||||
const model: SemanticDocumentModel = {
|
||||
schemaVersion: 1,
|
||||
|
||||
@@ -272,6 +272,26 @@ describe("动态 reference.docx", () => {
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
slot: "code-block-text",
|
||||
source: "computed-css",
|
||||
confidence: "exact",
|
||||
style: {
|
||||
fontCandidates: ["Code Text Face"],
|
||||
fontSizePt: 10,
|
||||
color: "#223344"
|
||||
}
|
||||
},
|
||||
{
|
||||
slot: "code-token-attribute",
|
||||
source: "computed-css",
|
||||
confidence: "exact",
|
||||
style: {
|
||||
fontCandidates: ["Code Text Face"],
|
||||
fontSizePt: 10,
|
||||
color: "#166534"
|
||||
}
|
||||
},
|
||||
{
|
||||
slot: "table",
|
||||
source: "computed-css",
|
||||
@@ -390,6 +410,13 @@ describe("动态 reference.docx", () => {
|
||||
expect(sourceCodeStyle).toMatch(
|
||||
/<w:ind\b[^>]*w:left="195"[^>]*w:right="75"[^>]*\/>/u
|
||||
);
|
||||
expect(sourceCodeStyle).toContain('w:ascii="Code Text Face"');
|
||||
expect(stylesXml).toMatch(
|
||||
/w:style[^>]*w:styleId="MdSourceCodeChar"[\s\S]*?<w:sz w:val="20"\/>/u
|
||||
);
|
||||
expect(stylesXml).toMatch(
|
||||
/w:style[^>]*w:styleId="DataTypeTok"[\s\S]*?<w:basedOn w:val="MdSourceCodeChar"\/>[\s\S]*?<w:color w:val="166534"\/>/u
|
||||
);
|
||||
expect(stylesXml).not.toContain('w:val="justify"');
|
||||
expect(stylesXml).toContain(
|
||||
'w:line="459" w:lineRule="exact"'
|
||||
@@ -463,6 +490,7 @@ describe("动态 reference.docx", () => {
|
||||
const entries = unzipSync(result.content);
|
||||
const documentXml = decoder.decode(entries["word/document.xml"]);
|
||||
const settingsXml = decoder.decode(entries["word/settings.xml"]);
|
||||
const stylesXml = decoder.decode(entries["word/styles.xml"]);
|
||||
const headerXml = decoder.decode(entries["word/header1.xml"]);
|
||||
const relationships = decoder.decode(
|
||||
entries["word/_rels/document.xml.rels"]
|
||||
@@ -478,6 +506,7 @@ describe("动态 reference.docx", () => {
|
||||
expect(documentXml.match(/w:headerReference/gu)).toHaveLength(3);
|
||||
expect(documentXml.match(/w:footerReference/gu)).toHaveLength(3);
|
||||
expect(settingsXml).toContain("<w:evenAndOddHeaders");
|
||||
expect(stylesXml).toContain('<w:kern w:val="2"');
|
||||
expect(headerXml).toContain("年度 <报告>");
|
||||
expect(headerXml).toContain("报告 & 计划.md");
|
||||
expect(headerXml).toContain('w:tab w:val="center"');
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
DOCX_SLOT_WORD_STYLE_BINDINGS,
|
||||
collectDocxTokenFonts,
|
||||
createDocxTokenSlotMap,
|
||||
resolvePandocSyntaxStyleSlot,
|
||||
resolveTokenFonts
|
||||
} from "../src/index.js";
|
||||
|
||||
@@ -72,5 +73,31 @@ describe("DOCX 令牌样式映射", () => {
|
||||
"Source Han Serif SC",
|
||||
"Times New Roman"
|
||||
]);
|
||||
expect(
|
||||
resolveTokenFonts(
|
||||
{ fontCandidates: ["MdTP Mono", "monospace"] },
|
||||
{ latin: "Arial", eastAsia: "MdTP Sans SC" },
|
||||
{ preserveFallbackEastAsia: true }
|
||||
)
|
||||
).toEqual({
|
||||
latin: "MdTP Mono",
|
||||
eastAsia: "MdTP Sans SC",
|
||||
complexScript: "MdTP Mono"
|
||||
});
|
||||
});
|
||||
|
||||
it("将 Pandoc 语法样式映射到引擎级代码语义槽位", () => {
|
||||
expect(resolvePandocSyntaxStyleSlot("DataTypeTok", '"key"'))
|
||||
.toBe("code-token-attribute");
|
||||
expect(resolvePandocSyntaxStyleSlot("StringTok", '"value"'))
|
||||
.toBe("code-token-string");
|
||||
expect(resolvePandocSyntaxStyleSlot("FunctionTok", ":"))
|
||||
.toBe("code-token-punctuation");
|
||||
expect(resolvePandocSyntaxStyleSlot("FunctionTok", "render"))
|
||||
.toBe("code-token-title");
|
||||
expect(resolvePandocSyntaxStyleSlot("NormalTok", " "))
|
||||
.toBe("code-block-text");
|
||||
expect(resolvePandocSyntaxStyleSlot("UnknownTok", "x"))
|
||||
.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -442,34 +442,32 @@ function normalizeComputedSlot(
|
||||
style.spacingAfterPt = spacingAfterPt;
|
||||
}
|
||||
}
|
||||
if (context.kind !== "inline") {
|
||||
const padding = {
|
||||
top: length(context, "padding-top", computed.paddingTop, {
|
||||
nonNegative: true
|
||||
}),
|
||||
right: length(context, "padding-right", computed.paddingRight, {
|
||||
nonNegative: true
|
||||
}),
|
||||
bottom: length(context, "padding-bottom", computed.paddingBottom, {
|
||||
nonNegative: true
|
||||
}),
|
||||
left: length(context, "padding-left", computed.paddingLeft, {
|
||||
nonNegative: true
|
||||
})
|
||||
const padding = {
|
||||
top: length(context, "padding-top", computed.paddingTop, {
|
||||
nonNegative: true
|
||||
}),
|
||||
right: length(context, "padding-right", computed.paddingRight, {
|
||||
nonNegative: true
|
||||
}),
|
||||
bottom: length(context, "padding-bottom", computed.paddingBottom, {
|
||||
nonNegative: true
|
||||
}),
|
||||
left: length(context, "padding-left", computed.paddingLeft, {
|
||||
nonNegative: true
|
||||
})
|
||||
};
|
||||
if (
|
||||
padding.top !== undefined &&
|
||||
padding.right !== undefined &&
|
||||
padding.bottom !== undefined &&
|
||||
padding.left !== undefined
|
||||
) {
|
||||
style.paddingPt = {
|
||||
top: padding.top,
|
||||
right: padding.right,
|
||||
bottom: padding.bottom,
|
||||
left: padding.left
|
||||
};
|
||||
if (
|
||||
padding.top !== undefined &&
|
||||
padding.right !== undefined &&
|
||||
padding.bottom !== undefined &&
|
||||
padding.left !== undefined
|
||||
) {
|
||||
style.paddingPt = {
|
||||
top: padding.top,
|
||||
right: padding.right,
|
||||
bottom: padding.bottom,
|
||||
left: padding.left
|
||||
};
|
||||
}
|
||||
}
|
||||
const containingBlockWidthPt = computed.containingBlockWidth
|
||||
? parseCssLengthToPt(computed.containingBlockWidth)
|
||||
@@ -652,7 +650,8 @@ function applyBorderColor(
|
||||
function applyOverrides(
|
||||
slot: DocxStyleSlotName,
|
||||
styleInput: DocxSlotStyleToken,
|
||||
overrides: DocxThemeStyleOverrides
|
||||
overrides: DocxThemeStyleOverrides,
|
||||
replaceExisting: boolean
|
||||
): {
|
||||
style: DocxSlotStyleToken;
|
||||
applied: boolean;
|
||||
@@ -663,7 +662,10 @@ function applyOverrides(
|
||||
key: Key,
|
||||
value: MutableStyle[Key] | undefined
|
||||
) => {
|
||||
if (value !== undefined) {
|
||||
if (
|
||||
value !== undefined &&
|
||||
(replaceExisting || style[key] === undefined)
|
||||
) {
|
||||
style[key] = value;
|
||||
applied = true;
|
||||
}
|
||||
@@ -887,7 +889,10 @@ export function resolveDocxThemeTokens(
|
||||
const override = applyOverrides(
|
||||
definition.name,
|
||||
resolved.style,
|
||||
input.config.overrides
|
||||
input.config.overrides,
|
||||
input.config.mode === "explicit" ||
|
||||
!input.config.legacyPreset ||
|
||||
resolved.source === "preset-fallback"
|
||||
);
|
||||
if (override.applied) {
|
||||
diagnostics.push({
|
||||
@@ -907,6 +912,43 @@ export function resolveDocxThemeTokens(
|
||||
return resolved;
|
||||
}
|
||||
);
|
||||
const blockQuote = slots.find((entry) => entry.slot === "block-quote");
|
||||
const blockQuoteText = slots.find(
|
||||
(entry) => entry.slot === "block-quote-text"
|
||||
);
|
||||
if (blockQuote && blockQuoteText) {
|
||||
const text = blockQuoteText.style;
|
||||
blockQuote.style = {
|
||||
...blockQuote.style,
|
||||
...(text.fontCandidates.length > 0
|
||||
? { fontCandidates: text.fontCandidates }
|
||||
: {}),
|
||||
...(text.fontSizePt !== undefined
|
||||
? { fontSizePt: text.fontSizePt }
|
||||
: {}),
|
||||
...(text.bold !== undefined ? { bold: text.bold } : {}),
|
||||
...(text.italic !== undefined ? { italic: text.italic } : {}),
|
||||
...(text.underline !== undefined
|
||||
? { underline: text.underline }
|
||||
: {}),
|
||||
...(text.strikethrough !== undefined
|
||||
? { strikethrough: text.strikethrough }
|
||||
: {}),
|
||||
...(text.color !== undefined ? { color: text.color } : {}),
|
||||
...(text.lineSpacing !== undefined
|
||||
? { lineSpacing: text.lineSpacing }
|
||||
: {}),
|
||||
...(text.letterSpacingPt !== undefined
|
||||
? { letterSpacingPt: text.letterSpacingPt }
|
||||
: {}),
|
||||
...(text.alignment !== undefined
|
||||
? { alignment: text.alignment }
|
||||
: {}),
|
||||
...(text.firstLineIndentPt !== undefined
|
||||
? { firstLineIndentPt: text.firstLineIndentPt }
|
||||
: {})
|
||||
};
|
||||
}
|
||||
return docxThemeTokenSetSchema.parse({
|
||||
schemaVersion: 1,
|
||||
themeId: snapshot.themeId,
|
||||
|
||||
@@ -34,8 +34,8 @@ export function createDocxStyleProbeMarkup(): string {
|
||||
</p>
|
||||
<ul ${attribute("unordered-list")}><li ${attribute("list-item")}>无序列表</li></ul>
|
||||
<ol ${attribute("ordered-list")}><li>有序列表</li></ol>
|
||||
<blockquote ${attribute("block-quote")}><p>引用内容</p></blockquote>
|
||||
<pre class="md-fences" ${attribute("code-block")}><code>const value = 1;</code></pre>
|
||||
<blockquote ${attribute("block-quote")}><p ${attribute("block-quote-text")}>引用内容</p></blockquote>
|
||||
<pre class="md-fences" ${attribute("code-block")}><code class="language-javascript" ${attribute("code-block-text")}><span class="hljs-keyword" ${attribute("code-token-keyword")}>const</span> <span class="hljs-attr" ${attribute("code-token-attribute")}>key</span> <span class="hljs-operator" ${attribute("code-token-operator")}>=</span> <span class="hljs-string" ${attribute("code-token-string")}>"value"</span>; <span class="hljs-literal" ${attribute("code-token-literal")}>true</span> <span class="hljs-title" ${attribute("code-token-title")}>title</span> <span class="hljs-number" ${attribute("code-token-number")}>1</span> <span class="hljs-comment" ${attribute("code-token-comment")}>// comment</span> <span class="hljs-built_in" ${attribute("code-token-built-in")}>Array</span> <span class="hljs-meta" ${attribute("code-token-meta")}>@meta</span> <span class="hljs-variable" ${attribute("code-token-variable")}>value</span> <span class="hljs-regexp" ${attribute("code-token-regexp")}>/x/</span> <span class="hljs-punctuation" ${attribute("code-token-punctuation")}>{}</span></code></pre>
|
||||
<table ${attribute("table")}>
|
||||
<thead><tr><th ${attribute("table-header")}>表头</th></tr></thead>
|
||||
<tbody><tr><td ${attribute("table-cell")}>单元格</td></tr></tbody>
|
||||
@@ -61,7 +61,7 @@ export function createDocxStyleProbeMarkup(): string {
|
||||
</section>
|
||||
<section class="docx-probe-briefing">
|
||||
<header class="doc-masthead doc-masthead-briefing" ${attribute("briefing-masthead")}>
|
||||
<p class="doc-briefing-masthead">工作简报</p>
|
||||
<p class="doc-briefing-masthead" ${attribute("briefing-masthead-text")}>工作简报</p>
|
||||
<div class="doc-briefing-meta" ${attribute("briefing-meta")}><span>第1期</span><span>示例单位</span><time>2026年7月30日</time></div>
|
||||
</header>
|
||||
<h1 class="doc-title" ${attribute("briefing-title")}>简报标题</h1>
|
||||
|
||||
@@ -118,7 +118,6 @@ body {
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
font-weight: inherit;
|
||||
line-height: inherit;
|
||||
|
||||
@@ -19,7 +19,22 @@ export const DOCX_STYLE_SLOT_NAMES = [
|
||||
"ordered-list",
|
||||
"list-item",
|
||||
"block-quote",
|
||||
"block-quote-text",
|
||||
"code-block",
|
||||
"code-block-text",
|
||||
"code-token-keyword",
|
||||
"code-token-literal",
|
||||
"code-token-string",
|
||||
"code-token-attribute",
|
||||
"code-token-title",
|
||||
"code-token-number",
|
||||
"code-token-comment",
|
||||
"code-token-built-in",
|
||||
"code-token-meta",
|
||||
"code-token-variable",
|
||||
"code-token-operator",
|
||||
"code-token-regexp",
|
||||
"code-token-punctuation",
|
||||
"table",
|
||||
"table-header",
|
||||
"table-cell",
|
||||
@@ -42,6 +57,7 @@ export const DOCX_STYLE_SLOT_NAMES = [
|
||||
"official-copy-to",
|
||||
"official-printing-row",
|
||||
"briefing-masthead",
|
||||
"briefing-masthead-text",
|
||||
"briefing-meta",
|
||||
"briefing-title",
|
||||
"briefing-contact",
|
||||
@@ -121,7 +137,22 @@ const kinds: Record<DocxStyleSlotName, DocxStyleSlotKind> = {
|
||||
"ordered-list": "list",
|
||||
"list-item": "list",
|
||||
"block-quote": "paragraph",
|
||||
"block-quote-text": "paragraph",
|
||||
"code-block": "paragraph",
|
||||
"code-block-text": "inline",
|
||||
"code-token-keyword": "inline",
|
||||
"code-token-literal": "inline",
|
||||
"code-token-string": "inline",
|
||||
"code-token-attribute": "inline",
|
||||
"code-token-title": "inline",
|
||||
"code-token-number": "inline",
|
||||
"code-token-comment": "inline",
|
||||
"code-token-built-in": "inline",
|
||||
"code-token-meta": "inline",
|
||||
"code-token-variable": "inline",
|
||||
"code-token-operator": "inline",
|
||||
"code-token-regexp": "inline",
|
||||
"code-token-punctuation": "inline",
|
||||
table: "table",
|
||||
"table-header": "table",
|
||||
"table-cell": "table",
|
||||
@@ -144,6 +175,7 @@ const kinds: Record<DocxStyleSlotName, DocxStyleSlotKind> = {
|
||||
"official-copy-to": "structure",
|
||||
"official-printing-row": "structure",
|
||||
"briefing-masthead": "structure",
|
||||
"briefing-masthead-text": "structure",
|
||||
"briefing-meta": "structure",
|
||||
"briefing-title": "structure",
|
||||
"briefing-contact": "structure",
|
||||
|
||||
@@ -175,6 +175,12 @@ describe("DOCX 主题令牌归一化", () => {
|
||||
});
|
||||
expect(findSlot(tokens, "strong").style)
|
||||
.not.toHaveProperty("firstLineIndentPt");
|
||||
expect(findSlot(tokens, "inline-code").style.paddingPt).toEqual({
|
||||
top: 3,
|
||||
right: 6,
|
||||
bottom: 3,
|
||||
left: 6
|
||||
});
|
||||
expect(findSlot(tokens, "table").style.widthPercent).toBe(100);
|
||||
expect(tokens.slots).toHaveLength(DOCX_STYLE_SLOT_NAMES.length);
|
||||
});
|
||||
@@ -204,6 +210,43 @@ describe("DOCX 主题令牌归一化", () => {
|
||||
expect(findSlot(tokens, "table").style.widthPercent).toBe(100);
|
||||
});
|
||||
|
||||
it("引用块组合容器盒模型与内部段落文字样式", () => {
|
||||
const tokens = resolveDocxThemeTokens({
|
||||
snapshot: createSnapshot({
|
||||
"block-quote": {
|
||||
fontSize: "24px",
|
||||
paddingLeft: "20px",
|
||||
borderLeft: "4px solid rgb(122, 122, 122)"
|
||||
},
|
||||
"block-quote-text": {
|
||||
fontFamily: '"Merriweather", serif',
|
||||
fontSize: "16px",
|
||||
fontStyle: "italic",
|
||||
lineHeight: "24px",
|
||||
paddingLeft: "0px",
|
||||
borderLeft: "0px none rgb(17, 34, 51)"
|
||||
}
|
||||
}),
|
||||
config: {
|
||||
mode: "auto",
|
||||
basePreset: "general",
|
||||
overrides: {},
|
||||
legacyPreset: false
|
||||
}
|
||||
});
|
||||
|
||||
expect(findSlot(tokens, "block-quote").style).toMatchObject({
|
||||
fontCandidates: ["Merriweather", "serif"],
|
||||
fontSizePt: 12,
|
||||
italic: true,
|
||||
lineSpacing: 1.5,
|
||||
paddingPt: { left: 15 },
|
||||
borders: {
|
||||
left: { widthPt: 3, color: "#7a7a7a", style: "single" }
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("按父级内容区归一化右置百分比结构容器", () => {
|
||||
const tokens = resolveDocxThemeTokens({
|
||||
snapshot: createSnapshot({
|
||||
@@ -356,7 +399,7 @@ describe("DOCX 主题令牌归一化", () => {
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("以清单覆盖自动值并在未命中时显式降级", () => {
|
||||
it("旧版清单仅补齐自动值,并在未命中时显式降级", () => {
|
||||
const tokens = resolveDocxThemeTokens({
|
||||
snapshot: createSnapshot({ caption: null }),
|
||||
config: {
|
||||
@@ -376,15 +419,15 @@ describe("DOCX 主题令牌归一化", () => {
|
||||
}
|
||||
});
|
||||
expect(findSlot(tokens, "paragraph")).toMatchObject({
|
||||
source: "manifest-override",
|
||||
source: "computed-css",
|
||||
confidence: "exact",
|
||||
style: {
|
||||
fontSizePt: 11,
|
||||
firstLineIndentPt: 22
|
||||
fontSizePt: 12,
|
||||
firstLineIndentPt: 24
|
||||
}
|
||||
});
|
||||
expect(findSlot(tokens, "hyperlink").style).toMatchObject({
|
||||
color: "#aa0000",
|
||||
color: "#112233",
|
||||
underline: false
|
||||
});
|
||||
expect(findSlot(tokens, "caption")).toMatchObject({
|
||||
@@ -401,6 +444,24 @@ describe("DOCX 主题令牌归一化", () => {
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("非旧版自动清单仍可显式覆盖 CSS 计算值", () => {
|
||||
const tokens = resolveDocxThemeTokens({
|
||||
snapshot: createSnapshot(),
|
||||
config: {
|
||||
mode: "auto-with-overrides",
|
||||
basePreset: "formal",
|
||||
legacyPreset: false,
|
||||
overrides: {
|
||||
headings: { color: "#aa0000" }
|
||||
}
|
||||
}
|
||||
});
|
||||
expect(findSlot(tokens, "heading-1")).toMatchObject({
|
||||
source: "manifest-override",
|
||||
style: { color: "#aa0000" }
|
||||
});
|
||||
});
|
||||
|
||||
it("显式模式不读取 CSS,仅使用覆盖与预设降级", () => {
|
||||
const tokens = resolveDocxThemeTokens({
|
||||
snapshot: createSnapshot(),
|
||||
|
||||
@@ -77,6 +77,8 @@ describe("DOCX 标准样式探针", () => {
|
||||
expect(markup).toMatch(
|
||||
/^<article id="write"[^>]*>\s*<h1 data-docx-slot="heading-1">/u
|
||||
);
|
||||
expect(markup).toContain('<code class="language-javascript"');
|
||||
expect(markup).not.toContain('<code class="hljs ');
|
||||
});
|
||||
|
||||
it("拒绝缺失和重复槽位的探针", () => {
|
||||
|
||||
@@ -84,6 +84,7 @@ describe("DOCX 主题样式浏览器运行时", () => {
|
||||
expect(script).toContain("contentPaddingLeft");
|
||||
expect(script).toContain("padding-left: 8px");
|
||||
expect(script).toContain("#write pre.md-fences \\u003e code");
|
||||
expect(script).not.toContain("font-family: inherit");
|
||||
expect(script).toContain("frame.remove()");
|
||||
expect(script).not.toContain("</style><script>x</script>");
|
||||
});
|
||||
|
||||
@@ -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