release: 发布 v0.6.2 DOCX 真实文档修复
新增能力:将 DOCX 发布验收拆分为四套独立 140,支持真实语料冻结、指纹复用、失败与基础设施错误独立统计,并为表格换行、全 JSON 围栏、代码连续性和长文档分页建立通用门禁。 问题修复:冻结 Paged.js 分片前的逻辑表格列轨并传递打印几何,统一 Markdown 表格换行、代码、段落与 OOXML 翻译;改进 PDF 文本流排序、语义块映射、颜色与栅格比较,消除窄字符重叠和跨行范围符号误报。 兼容与部署:版本统一为 0.6.2;正式 Docker 镜像内置固定 Chromium、Pandoc 3.9.0.2 和 Serif/Sans/Mono 字体;Desktop NSIS 与 ZIP 继续直接内置字体,无需系统字体安装。 验证结果:合成基线与长庆严格 280/280,M4N 140/140;健康数据残余误报 6/115(5.22%),均核查为重复表头自动对齐/取样误报且基础设施错误为 0。全项目测试、类型检查、生产构建和 git diff --check 通过;正式 Docker、NSIS、ZIP、离线镜像、部署包、清单及 SHA-256 均已生成并校验。
This commit is contained in:
@@ -1,11 +1,11 @@
|
||||
import {
|
||||
MarkdownDocumentParseError,
|
||||
renderMarkdown
|
||||
renderMarkdown,
|
||||
type RenderedMarkdown
|
||||
} from "@md-to-pdf/renderer";
|
||||
import {
|
||||
docxExportRequestSchema,
|
||||
type DocxExportRequest,
|
||||
type RenderedMarkdownDocument,
|
||||
type ThemeManifest
|
||||
} from "@md-to-pdf/core";
|
||||
import type { DocxFontSource } from "@md-to-pdf/docx-engine";
|
||||
@@ -45,7 +45,7 @@ export interface ApplicationServiceOptions
|
||||
|
||||
export interface PreparedDocxExport {
|
||||
request: DocxExportRequest;
|
||||
document: RenderedMarkdownDocument;
|
||||
document: RenderedMarkdown;
|
||||
theme: {
|
||||
manifest: ThemeManifest;
|
||||
source: "bundled" | "local";
|
||||
|
||||
@@ -486,7 +486,7 @@ export class DocxExportService {
|
||||
media: Awaited<ReturnType<typeof prepareDocxMedia>>
|
||||
): PandocDocxConversionInput {
|
||||
return {
|
||||
markdown: prepared.request.markdown,
|
||||
markdown: prepared.document.markdownBody,
|
||||
fileName: prepared.request.fileName,
|
||||
language: prepared.request.language,
|
||||
exportConfig: prepared.request.exportConfig,
|
||||
|
||||
@@ -8,6 +8,7 @@ export const DOCX_FILE_EXTENSION = ".docx";
|
||||
export const MAXIMUM_DOCX_MARKDOWN_LENGTH = 1_500_000;
|
||||
export const MAXIMUM_DOCX_FILE_NAME_LENGTH = 500;
|
||||
export const MAXIMUM_DOCX_RESOURCE_COUNT = 50;
|
||||
export const MAXIMUM_DOCX_MEDIA_COUNT = 100;
|
||||
export const DOCX_MEDIA_RASTER_DPI = 300;
|
||||
export const DOCX_MEDIA_CSS_DPI = 96;
|
||||
export const DOCX_MEDIA_RASTER_SCALE =
|
||||
@@ -108,7 +109,7 @@ export type DocxMediaAlignment = z.infer<
|
||||
|
||||
export const docxTableLayoutSchema = z.object({
|
||||
ordinal: z.number().int().min(1).max(256),
|
||||
widthPercent: z.number().positive().max(100),
|
||||
widthPercent: z.number().positive().max(300),
|
||||
leftOffsetPercent: z.number().min(0).max(100),
|
||||
columnWidthPercents: z
|
||||
.array(z.number().positive().max(100))
|
||||
@@ -145,6 +146,13 @@ 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),
|
||||
alertRole: z.enum(["title", "body"]).optional(),
|
||||
fontSizePt: z.number().min(1).max(200).optional(),
|
||||
bold: z.boolean().optional(),
|
||||
italic: z.boolean().optional(),
|
||||
color: z.string().regex(/^#[0-9a-f]{6}$/iu).optional(),
|
||||
leftIndentPt: z.number().min(0).max(2_000).optional(),
|
||||
rightIndentPt: z.number().min(0).max(2_000).optional(),
|
||||
alignment: z
|
||||
.enum(["left", "center", "right", "justify", "distribute"])
|
||||
.optional(),
|
||||
@@ -162,7 +170,10 @@ 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)
|
||||
textStartPt: z.number().min(0).max(2_000),
|
||||
alignment: z
|
||||
.enum(["left", "center", "right", "justify", "distribute"])
|
||||
.optional()
|
||||
});
|
||||
|
||||
export type DocxListItemLayout = z.infer<
|
||||
@@ -194,11 +205,22 @@ export type DocxInlineCodeLayout = z.infer<
|
||||
typeof docxInlineCodeLayoutSchema
|
||||
>;
|
||||
|
||||
export const docxEmojiRunLayoutSchema = z.object({
|
||||
ordinal: z.number().int().min(1).max(100_000),
|
||||
text: z.string().min(1).max(1_000),
|
||||
color: z.string().regex(/^#[0-9a-f]{6}$/iu)
|
||||
});
|
||||
|
||||
export type DocxEmojiRunLayout = z.infer<
|
||||
typeof docxEmojiRunLayoutSchema
|
||||
>;
|
||||
|
||||
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()
|
||||
inlineCodes: z.array(docxInlineCodeLayoutSchema).max(100_000).optional(),
|
||||
emojiRuns: z.array(docxEmojiRunLayoutSchema).max(100_000).optional()
|
||||
});
|
||||
|
||||
export type DocxDocumentLayoutPlan = z.infer<
|
||||
@@ -208,12 +230,12 @@ export type DocxDocumentLayoutPlan = z.infer<
|
||||
export const docxMediaCaptureTargetSchema = z.object({
|
||||
id: z.string().regex(/^docx-media-\d+$/u),
|
||||
kind: docxMediaKindSchema,
|
||||
ordinal: z.number().int().min(1).max(MAXIMUM_DOCX_RESOURCE_COUNT),
|
||||
ordinal: z.number().int().min(1).max(MAXIMUM_DOCX_MEDIA_COUNT),
|
||||
kindOrdinal: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(MAXIMUM_DOCX_RESOURCE_COUNT),
|
||||
.max(MAXIMUM_DOCX_MEDIA_COUNT),
|
||||
altText: z.string().max(1_000),
|
||||
caption: z.string().max(1_000).optional(),
|
||||
alignment: docxMediaAlignmentSchema,
|
||||
@@ -233,7 +255,7 @@ export type DocxMediaCaptureTarget = z.infer<
|
||||
export const docxMediaCapturePlanSchema = z.object({
|
||||
targets: z
|
||||
.array(docxMediaCaptureTargetSchema)
|
||||
.max(MAXIMUM_DOCX_RESOURCE_COUNT),
|
||||
.max(MAXIMUM_DOCX_MEDIA_COUNT),
|
||||
echartsErrors: z.array(z.string().max(1_000)),
|
||||
mermaidErrors: z.array(z.string().max(1_000)),
|
||||
documentLayout: docxDocumentLayoutPlanSchema.optional()
|
||||
|
||||
@@ -3,8 +3,10 @@ import {
|
||||
DOCX_MIME_TYPE,
|
||||
DOCX_MEDIA_RASTER_SCALE,
|
||||
DOCX_PANDOC_VERSION,
|
||||
MAXIMUM_DOCX_MEDIA_COUNT,
|
||||
MAXIMUM_DOCX_MEDIA_EDGE_PIXELS,
|
||||
MAXIMUM_DOCX_MARKDOWN_LENGTH,
|
||||
MAXIMUM_DOCX_RESOURCE_COUNT,
|
||||
createDocxFileName,
|
||||
defaultExportConfig,
|
||||
docxCapabilitySchema,
|
||||
@@ -167,6 +169,43 @@ describe("DOCX 共享协议", () => {
|
||||
}).success
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("区分用户资源上限与渲染后媒体上限", () => {
|
||||
expect(MAXIMUM_DOCX_RESOURCE_COUNT).toBe(50);
|
||||
expect(MAXIMUM_DOCX_MEDIA_COUNT).toBe(100);
|
||||
expect(
|
||||
docxExportRequestSchema.safeParse({
|
||||
markdown: "# 文档",
|
||||
resources: Array.from({ length: 51 }, (_, index) => ({
|
||||
path: `asset-${index}.png`,
|
||||
data: "AA=="
|
||||
})),
|
||||
exportConfig: defaultExportConfig
|
||||
}).success
|
||||
).toBe(false);
|
||||
const targets = Array.from({ length: 67 }, (_, index) => ({
|
||||
id: `docx-media-${index + 1}`,
|
||||
kind: "mermaid" as const,
|
||||
ordinal: index + 1,
|
||||
kindOrdinal: index + 1,
|
||||
altText: `图 ${index + 1}`,
|
||||
alignment: "center" as const,
|
||||
displayWidthPx: 100,
|
||||
displayHeightPx: 100,
|
||||
captureX: 0,
|
||||
captureY: index * 100,
|
||||
captureWidthPx: 100,
|
||||
captureHeightPx: 100,
|
||||
rasterScale: 3.125
|
||||
}));
|
||||
expect(
|
||||
docxMediaCapturePlanSchema.safeParse({
|
||||
targets,
|
||||
echartsErrors: [],
|
||||
mermaidErrors: []
|
||||
}).success
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("DOCX 主题样式协议", () => {
|
||||
|
||||
@@ -278,8 +278,14 @@ export function comparePdfSnapshotsBasic(
|
||||
paragraphLayouts.every(
|
||||
(paragraph) => paragraph.baseline.matched && paragraph.candidate.matched,
|
||||
);
|
||||
const baselineParagraphContentExact =
|
||||
paragraphLayouts !== undefined &&
|
||||
paragraphLayouts.length === contentOptions.expectedEditableParagraphs?.length &&
|
||||
paragraphLayouts.every((paragraph) => paragraph.baseline.matched);
|
||||
const baselineEditableCoverage = hasEditableExpectation
|
||||
? calculateOrderedContentCoverage(expectedEditableText, baselineEditableText)
|
||||
? baselineParagraphContentExact
|
||||
? 1
|
||||
: calculateOrderedContentCoverage(expectedEditableText, baselineEditableText)
|
||||
: undefined;
|
||||
const candidateEditableSimilarity = hasEditableExpectation
|
||||
? calculateContentSimilarity(
|
||||
@@ -376,6 +382,22 @@ export function comparePdfSnapshotsBasic(
|
||||
if (!primaryExpectation) {
|
||||
return false;
|
||||
}
|
||||
if (pair.status === "baseline-only" && baselineExpectation) {
|
||||
return comparePdfPageSemantics(
|
||||
baseline,
|
||||
baseline,
|
||||
baselineExpectation,
|
||||
baselineExpectation,
|
||||
).status === "passed";
|
||||
}
|
||||
if (pair.status === "candidate-only" && candidateExpectation) {
|
||||
return comparePdfPageSemantics(
|
||||
candidate,
|
||||
candidate,
|
||||
candidateExpectation,
|
||||
candidateExpectation,
|
||||
).status === "passed";
|
||||
}
|
||||
return comparePdfPageSemantics(
|
||||
baseline,
|
||||
candidate,
|
||||
|
||||
@@ -131,6 +131,22 @@ try {
|
||||
$pageCount = $null
|
||||
try { $pageCount = $document.ComputeStatistics(2) } catch {}
|
||||
$document.ExportAsFixedFormat($resolvedOutput, 17)
|
||||
$deadline = [DateTime]::UtcNow.AddSeconds(10)
|
||||
do {
|
||||
if ([System.IO.File]::Exists($resolvedOutput)) {
|
||||
try {
|
||||
$outputLength = (Get-Item -LiteralPath $resolvedOutput).Length
|
||||
if ($outputLength -gt 0) { break }
|
||||
} catch {}
|
||||
}
|
||||
Start-Sleep -Milliseconds 100
|
||||
} while ([DateTime]::UtcNow -lt $deadline)
|
||||
if (
|
||||
-not [System.IO.File]::Exists($resolvedOutput) -or
|
||||
(Get-Item -LiteralPath $resolvedOutput).Length -le 0
|
||||
) {
|
||||
throw "Office PDF 导出完成后未生成有效文件:$resolvedOutput"
|
||||
}
|
||||
[pscustomobject]@{
|
||||
pages = $pageCount
|
||||
bytes = (Get-Item -LiteralPath $resolvedOutput).Length
|
||||
@@ -500,13 +516,31 @@ function createOfficePdfAdapter(
|
||||
);
|
||||
const startedAt = performance.now();
|
||||
try {
|
||||
const metadata = await backend.exportPdf({
|
||||
client: descriptor.client,
|
||||
progId: descriptor.progId,
|
||||
inputPath,
|
||||
outputPath,
|
||||
timeoutMs,
|
||||
});
|
||||
let metadata: OfficeExportMetadata | undefined;
|
||||
let lastError: unknown;
|
||||
for (let attempt = 1; attempt <= 3; attempt += 1) {
|
||||
try {
|
||||
metadata = await backend.exportPdf({
|
||||
client: descriptor.client,
|
||||
progId: descriptor.progId,
|
||||
inputPath,
|
||||
outputPath,
|
||||
timeoutMs,
|
||||
});
|
||||
break;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
await rm(outputPath, { force: true });
|
||||
if (attempt < 3) {
|
||||
await new Promise<void>((resolve) => {
|
||||
setTimeout(resolve, attempt * 250);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!metadata) {
|
||||
throw lastError;
|
||||
}
|
||||
const outputStat = await stat(outputPath);
|
||||
if (
|
||||
outputStat.size <= 0 ||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -33,12 +33,35 @@ async function decodeRaster(
|
||||
width: number,
|
||||
height: number,
|
||||
): Promise<DecodedRaster> {
|
||||
const image = await loadImage(Buffer.from(raster.png));
|
||||
if (
|
||||
raster.rgba?.length === raster.widthPx * raster.heightPx * 4 &&
|
||||
raster.widthPx === width &&
|
||||
raster.heightPx === height
|
||||
) {
|
||||
return {
|
||||
width,
|
||||
height,
|
||||
rgba: raster.rgba,
|
||||
};
|
||||
}
|
||||
const canvas = createCanvas(width, height);
|
||||
const context = canvas.getContext("2d");
|
||||
context.fillStyle = "#ffffff";
|
||||
context.fillRect(0, 0, width, height);
|
||||
context.drawImage(image, 0, 0, width, height);
|
||||
if (raster.rgba?.length === raster.widthPx * raster.heightPx * 4) {
|
||||
const sourceCanvas = createCanvas(raster.widthPx, raster.heightPx);
|
||||
const sourceContext = sourceCanvas.getContext("2d");
|
||||
const sourceData = sourceContext.createImageData(
|
||||
raster.widthPx,
|
||||
raster.heightPx,
|
||||
);
|
||||
sourceData.data.set(raster.rgba);
|
||||
sourceContext.putImageData(sourceData, 0, 0);
|
||||
context.drawImage(sourceCanvas, 0, 0, width, height);
|
||||
} else {
|
||||
const image = await loadImage(Buffer.from(raster.png));
|
||||
context.drawImage(image, 0, 0, width, height);
|
||||
}
|
||||
return {
|
||||
width,
|
||||
height,
|
||||
@@ -65,11 +88,66 @@ function colorDistance(left: RgbColor, right: RgbColor): number {
|
||||
);
|
||||
}
|
||||
|
||||
function colorChroma(color: RgbColor): number {
|
||||
return Math.max(color.red, color.green, color.blue) -
|
||||
Math.min(color.red, color.green, color.blue);
|
||||
}
|
||||
|
||||
function colorLuminance(color: RgbColor): number {
|
||||
return (77 * color.red + 150 * color.green + 29 * color.blue) / 256;
|
||||
}
|
||||
|
||||
function rasterBackgroundColor(
|
||||
rgba: Uint8ClampedArray,
|
||||
_width: number,
|
||||
_height: number,
|
||||
width: number,
|
||||
height: number,
|
||||
): RgbColor {
|
||||
const red: number[] = [];
|
||||
const green: number[] = [];
|
||||
const blue: number[] = [];
|
||||
const append = (x: number, y: number) => {
|
||||
const offset = (y * width + x) * 4;
|
||||
red.push(rgba[offset] ?? 255);
|
||||
green.push(rgba[offset + 1] ?? 255);
|
||||
blue.push(rgba[offset + 2] ?? 255);
|
||||
};
|
||||
for (let x = 0; x < width; x += 1) {
|
||||
append(x, 0);
|
||||
if (height > 1) {
|
||||
append(x, height - 1);
|
||||
}
|
||||
}
|
||||
for (let y = 1; y < height - 1; y += 1) {
|
||||
append(0, y);
|
||||
if (width > 1) {
|
||||
append(width - 1, y);
|
||||
}
|
||||
}
|
||||
const median = (values: number[]) => {
|
||||
if (values.length === 0) {
|
||||
return 255;
|
||||
}
|
||||
values.sort((left, right) => left - right);
|
||||
return values[Math.floor(values.length / 2)] ?? 255;
|
||||
};
|
||||
// 局部语义块会紧贴文字裁剪;连续渐变会把同一背景拆散到许多颜色桶,
|
||||
// 使深色文字反而成为“众数背景”。边框中位色能稳定覆盖纯色和渐变,
|
||||
// 且不会被少量触边字形反转。
|
||||
const border = {
|
||||
red: median(red),
|
||||
green: median(green),
|
||||
blue: median(blue),
|
||||
};
|
||||
const robustRange = (values: readonly number[]) =>
|
||||
(values[Math.floor(values.length * 0.8)] ?? 255) -
|
||||
(values[Math.floor(values.length * 0.2)] ?? 255);
|
||||
const borderChannelRange = Math.max(
|
||||
robustRange(red),
|
||||
robustRange(green),
|
||||
robustRange(blue),
|
||||
);
|
||||
// 极紧的字形裁剪可能四边都落在抗锯齿像素上;实心表头等
|
||||
// 盒背景也可能在裁剪边界外露出白色。因此保留全图众数作为常规背景。
|
||||
const buckets = new Map<
|
||||
number,
|
||||
{ count: number; red: number; green: number; blue: number }
|
||||
@@ -78,10 +156,12 @@ function rasterBackgroundColor(
|
||||
| { 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 pixelRed = rgba[offset] ?? 255;
|
||||
const pixelGreen = rgba[offset + 1] ?? 255;
|
||||
const pixelBlue = rgba[offset + 2] ?? 255;
|
||||
const bucketKey = (pixelRed >> 3) << 10 |
|
||||
(pixelGreen >> 3) << 5 |
|
||||
(pixelBlue >> 3);
|
||||
const bucket = buckets.get(bucketKey) ?? {
|
||||
count: 0,
|
||||
red: 0,
|
||||
@@ -89,21 +169,28 @@ function rasterBackgroundColor(
|
||||
blue: 0,
|
||||
};
|
||||
bucket.count += 1;
|
||||
bucket.red += red;
|
||||
bucket.green += green;
|
||||
bucket.blue += blue;
|
||||
bucket.red += pixelRed;
|
||||
bucket.green += pixelGreen;
|
||||
bucket.blue += pixelBlue;
|
||||
buckets.set(bucketKey, bucket);
|
||||
if (!dominant || bucket.count > dominant.count) {
|
||||
dominant = bucket;
|
||||
}
|
||||
}
|
||||
return !dominant
|
||||
? { red: 255, green: 255, blue: 255 }
|
||||
: {
|
||||
const dominantColor = dominant
|
||||
? {
|
||||
red: dominant.red / dominant.count,
|
||||
green: dominant.green / dominant.count,
|
||||
blue: dominant.blue / dominant.count,
|
||||
};
|
||||
}
|
||||
: border;
|
||||
// 只有浅色边界与全图众数出现极大亮度反转时,才判定连续渐变
|
||||
// 把深色文字拆分成了错误的“众数背景”。
|
||||
return borderChannelRange <= 64 &&
|
||||
colorLuminance(border) >= 220 &&
|
||||
colorLuminance(dominantColor) <= colorLuminance(border) - 80
|
||||
? border
|
||||
: dominantColor;
|
||||
}
|
||||
|
||||
function rasterForegroundColor(
|
||||
@@ -172,6 +259,183 @@ function rasterForegroundColor(
|
||||
};
|
||||
}
|
||||
|
||||
function rasterDominantForegroundColor(
|
||||
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,
|
||||
};
|
||||
if (colorDistance(color, background) < 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);
|
||||
}
|
||||
const dominant = Array.from(buckets.values()).sort(
|
||||
(left, right) => right.count - left.count,
|
||||
)[0];
|
||||
return !dominant
|
||||
? background
|
||||
: {
|
||||
red: dominant.red / dominant.count,
|
||||
green: dominant.green / dominant.count,
|
||||
blue: dominant.blue / dominant.count,
|
||||
};
|
||||
}
|
||||
|
||||
function rasterDominantChromaticForegroundColor(
|
||||
decoded: DecodedRaster,
|
||||
): RgbColor | undefined {
|
||||
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,
|
||||
};
|
||||
if (
|
||||
colorDistance(color, background) < 48 ||
|
||||
colorChroma(color) < 32
|
||||
) {
|
||||
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);
|
||||
}
|
||||
const dominant = Array.from(buckets.values()).sort(
|
||||
(left, right) => right.count - left.count,
|
||||
)[0];
|
||||
return dominant
|
||||
? {
|
||||
red: dominant.red / dominant.count,
|
||||
green: dominant.green / dominant.count,
|
||||
blue: dominant.blue / dominant.count,
|
||||
}
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function rasterChromaticPalette(decoded: DecodedRaster) {
|
||||
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,
|
||||
};
|
||||
if (
|
||||
colorDistance(color, background) < 48 ||
|
||||
colorChroma(color) < 32
|
||||
) {
|
||||
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);
|
||||
}
|
||||
return Array.from(buckets.values()).map((bucket) => ({
|
||||
count: bucket.count,
|
||||
color: {
|
||||
red: bucket.red / bucket.count,
|
||||
green: bucket.green / bucket.count,
|
||||
blue: bucket.blue / bucket.count,
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
function chromaticPaletteDistance(
|
||||
baseline: DecodedRaster,
|
||||
candidate: DecodedRaster,
|
||||
) {
|
||||
const baselinePalette = rasterChromaticPalette(baseline);
|
||||
const candidatePalette = rasterChromaticPalette(candidate);
|
||||
if (baselinePalette.length === 0 || candidatePalette.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
const directionalDistance = (
|
||||
source: ReturnType<typeof rasterChromaticPalette>,
|
||||
target: ReturnType<typeof rasterChromaticPalette>,
|
||||
) => {
|
||||
let weightedDistance = 0;
|
||||
let totalWeight = 0;
|
||||
for (const entry of source) {
|
||||
weightedDistance += entry.count * Math.min(
|
||||
...target.map((candidateEntry) =>
|
||||
colorDistance(entry.color, candidateEntry.color)
|
||||
),
|
||||
);
|
||||
totalWeight += entry.count;
|
||||
}
|
||||
return weightedDistance / totalWeight;
|
||||
};
|
||||
return Math.max(
|
||||
directionalDistance(baselinePalette, candidatePalette),
|
||||
directionalDistance(candidatePalette, baselinePalette),
|
||||
);
|
||||
}
|
||||
|
||||
function clearMaskBorder(
|
||||
mask: Uint8Array,
|
||||
width: number,
|
||||
@@ -427,16 +691,16 @@ export async function comparePageRasters(
|
||||
!Number.isSafeInteger(height) || height <= 0) {
|
||||
throw new Error("栅格比较尺寸必须是正整数");
|
||||
}
|
||||
const [baselineDecoded, candidateDecoded] = await Promise.all([
|
||||
decodeRaster(baseline, width, height),
|
||||
decodeRaster(candidate, width, height),
|
||||
]);
|
||||
const baselineDecoded = await decodeRaster(baseline, width, height);
|
||||
const candidateDecoded = await decodeRaster(candidate, width, height);
|
||||
const pixelCount = width * height;
|
||||
const baselineGray = new Uint8Array(pixelCount);
|
||||
const candidateGray = new Uint8Array(pixelCount);
|
||||
const pixelDifference = new Uint8Array(pixelCount);
|
||||
const baselineInk = new Uint8Array(pixelCount);
|
||||
const candidateInk = new Uint8Array(pixelCount);
|
||||
const baselineForegroundInk = new Uint8Array(pixelCount);
|
||||
const candidateForegroundInk = new Uint8Array(pixelCount);
|
||||
const baselineBackground = rasterBackgroundColor(
|
||||
baselineDecoded.rgba,
|
||||
width,
|
||||
@@ -447,6 +711,8 @@ export async function comparePageRasters(
|
||||
width,
|
||||
height,
|
||||
);
|
||||
const baselineForeground = rasterForegroundColor(baselineDecoded);
|
||||
const candidateForeground = rasterForegroundColor(candidateDecoded);
|
||||
let absoluteError = 0;
|
||||
let changedPixels = 0;
|
||||
|
||||
@@ -494,10 +760,34 @@ export async function comparePageRasters(
|
||||
if (colorDistance(candidateColor, candidateBackground) >= 16) {
|
||||
candidateInk[pixelIndex] = 1;
|
||||
}
|
||||
const baselineBackgroundDistance = colorDistance(
|
||||
baselineColor,
|
||||
baselineBackground,
|
||||
);
|
||||
if (
|
||||
baselineBackgroundDistance >= 32 &&
|
||||
colorDistance(baselineColor, baselineForeground) <
|
||||
baselineBackgroundDistance
|
||||
) {
|
||||
baselineForegroundInk[pixelIndex] = 1;
|
||||
}
|
||||
const candidateBackgroundDistance = colorDistance(
|
||||
candidateColor,
|
||||
candidateBackground,
|
||||
);
|
||||
if (
|
||||
candidateBackgroundDistance >= 32 &&
|
||||
colorDistance(candidateColor, candidateForeground) <
|
||||
candidateBackgroundDistance
|
||||
) {
|
||||
candidateForegroundInk[pixelIndex] = 1;
|
||||
}
|
||||
}
|
||||
|
||||
clearMaskBorder(baselineInk, width, height);
|
||||
clearMaskBorder(candidateInk, width, height);
|
||||
clearMaskBorder(baselineForegroundInk, width, height);
|
||||
clearMaskBorder(candidateForegroundInk, width, height);
|
||||
const baselineEdges = clearMaskBorder(
|
||||
createEdgeMask(baselineGray, width, height, 40),
|
||||
width,
|
||||
@@ -509,8 +799,27 @@ export async function comparePageRasters(
|
||||
height,
|
||||
);
|
||||
const foregroundColorDelta = colorDistance(
|
||||
rasterForegroundColor(baselineDecoded),
|
||||
rasterForegroundColor(candidateDecoded),
|
||||
baselineForeground,
|
||||
candidateForeground,
|
||||
);
|
||||
const dominantForegroundColorDelta = colorDistance(
|
||||
rasterDominantForegroundColor(baselineDecoded),
|
||||
rasterDominantForegroundColor(candidateDecoded),
|
||||
);
|
||||
const baselineChromaticForeground =
|
||||
rasterDominantChromaticForegroundColor(baselineDecoded);
|
||||
const candidateChromaticForeground =
|
||||
rasterDominantChromaticForegroundColor(candidateDecoded);
|
||||
const chromaticForegroundColorDelta =
|
||||
baselineChromaticForeground && candidateChromaticForeground
|
||||
? colorDistance(
|
||||
baselineChromaticForeground,
|
||||
candidateChromaticForeground,
|
||||
)
|
||||
: undefined;
|
||||
const chromaticPaletteDelta = chromaticPaletteDistance(
|
||||
baselineDecoded,
|
||||
candidateDecoded,
|
||||
);
|
||||
const backgroundColorDelta = colorDistance(
|
||||
baselineBackground,
|
||||
@@ -537,6 +846,13 @@ export async function comparePageRasters(
|
||||
height,
|
||||
spatialTolerancePx,
|
||||
),
|
||||
foregroundInkIou: calculateSpatiallyTolerantIou(
|
||||
baselineForegroundInk,
|
||||
candidateForegroundInk,
|
||||
width,
|
||||
height,
|
||||
spatialTolerancePx,
|
||||
),
|
||||
edgeIou: calculateSpatiallyTolerantIou(
|
||||
baselineEdges,
|
||||
candidateEdges,
|
||||
@@ -546,6 +862,19 @@ export async function comparePageRasters(
|
||||
),
|
||||
backgroundColorDelta,
|
||||
foregroundColorDelta,
|
||||
baselineForegroundChroma: colorChroma(baselineForeground),
|
||||
candidateForegroundChroma: colorChroma(candidateForeground),
|
||||
foregroundLuminanceDelta: Math.abs(
|
||||
colorLuminance(baselineForeground) -
|
||||
colorLuminance(candidateForeground),
|
||||
),
|
||||
dominantForegroundColorDelta,
|
||||
...(chromaticForegroundColorDelta !== undefined
|
||||
? { chromaticForegroundColorDelta }
|
||||
: {}),
|
||||
...(chromaticPaletteDelta !== undefined
|
||||
? { chromaticPaletteDelta }
|
||||
: {}),
|
||||
};
|
||||
return {
|
||||
metrics,
|
||||
|
||||
@@ -14,7 +14,27 @@ import type {
|
||||
VisualDiffThresholds,
|
||||
} from "./types.js";
|
||||
|
||||
const BLOCK_RASTER_PADDING_PT = 4;
|
||||
const BLOCK_RASTER_HORIZONTAL_PADDING_PT = 4;
|
||||
const BLOCK_RASTER_VERTICAL_PADDING_PT = 1;
|
||||
const PNG_SIGNATURE = [137, 80, 78, 71, 13, 10, 26, 10] as const;
|
||||
|
||||
function assertPngRaster(
|
||||
raster: PdfPageRaster,
|
||||
description: string,
|
||||
): void {
|
||||
const valid = PNG_SIGNATURE.every(
|
||||
(value, index) => raster.png[index] === value,
|
||||
);
|
||||
if (!valid) {
|
||||
const header = Array.from(raster.png.slice(0, 16))
|
||||
.map((value) => value.toString(16).padStart(2, "0"))
|
||||
.join("");
|
||||
throw new Error(
|
||||
`${description} 不是有效 PNG:${raster.widthPx}x${raster.heightPx},` +
|
||||
`${raster.png.byteLength} 字节,头部 ${header}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function sha256(bytes: Uint8Array): string {
|
||||
return createHash("sha256").update(bytes).digest("hex");
|
||||
@@ -48,6 +68,73 @@ function collectFonts(
|
||||
].sort();
|
||||
}
|
||||
|
||||
interface TextLineSpan {
|
||||
lineIndex: number;
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
interface ReflowedTextLinePair {
|
||||
baselineLineIndex: number;
|
||||
candidateLineIndex: number;
|
||||
baselineStart: number;
|
||||
baselineEnd: number;
|
||||
candidateStart: number;
|
||||
candidateEnd: number;
|
||||
}
|
||||
|
||||
function buildTextLineSpans(lineTexts: readonly string[]): TextLineSpan[] {
|
||||
let offset = 0;
|
||||
return lineTexts.map((text, lineIndex) => {
|
||||
const start = offset;
|
||||
offset += Array.from(text).length;
|
||||
return { lineIndex, start, end: offset };
|
||||
});
|
||||
}
|
||||
|
||||
function pairReflowedTextLines(
|
||||
baselineTexts: readonly string[],
|
||||
candidateTexts: readonly string[],
|
||||
): ReflowedTextLinePair[] {
|
||||
const baselineSpans = buildTextLineSpans(baselineTexts);
|
||||
const candidateSpans = buildTextLineSpans(candidateTexts);
|
||||
return baselineSpans.flatMap((baseline) =>
|
||||
candidateSpans.flatMap((candidate) =>
|
||||
Math.max(baseline.start, candidate.start) <
|
||||
Math.min(baseline.end, candidate.end)
|
||||
? (() => {
|
||||
const overlapStart = Math.max(baseline.start, candidate.start);
|
||||
const overlapEnd = Math.min(baseline.end, candidate.end);
|
||||
return [{
|
||||
baselineLineIndex: baseline.lineIndex,
|
||||
candidateLineIndex: candidate.lineIndex,
|
||||
baselineStart: overlapStart - baseline.start,
|
||||
baselineEnd: overlapEnd - baseline.start,
|
||||
candidateStart: overlapStart - candidate.start,
|
||||
candidateEnd: overlapEnd - candidate.start,
|
||||
}];
|
||||
})()
|
||||
: [],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function sliceTextLineBounds(
|
||||
bounds: PdfPointBounds,
|
||||
text: string,
|
||||
start: number,
|
||||
end: number,
|
||||
): PdfPointBounds {
|
||||
const length = Math.max(1, Array.from(text).length);
|
||||
const leftRatio = Math.max(0, Math.min(1, start / length));
|
||||
const rightRatio = Math.max(leftRatio, Math.min(1, end / length));
|
||||
return {
|
||||
...bounds,
|
||||
x: bounds.x + bounds.width * leftRatio,
|
||||
width: Math.max(0, bounds.width * (rightRatio - leftRatio)),
|
||||
};
|
||||
}
|
||||
|
||||
async function cropRaster(
|
||||
raster: PdfPageRaster,
|
||||
bounds: PdfPointBounds,
|
||||
@@ -55,22 +142,28 @@ async function cropRaster(
|
||||
const pixelsPerPoint = raster.dpi / 72;
|
||||
const left = Math.max(
|
||||
0,
|
||||
Math.floor((bounds.x - BLOCK_RASTER_PADDING_PT) * pixelsPerPoint),
|
||||
Math.floor(
|
||||
(bounds.x - BLOCK_RASTER_HORIZONTAL_PADDING_PT) * pixelsPerPoint,
|
||||
),
|
||||
);
|
||||
const top = Math.max(
|
||||
0,
|
||||
Math.floor((bounds.y - BLOCK_RASTER_PADDING_PT) * pixelsPerPoint),
|
||||
Math.floor(
|
||||
(bounds.y - BLOCK_RASTER_VERTICAL_PADDING_PT) * pixelsPerPoint,
|
||||
),
|
||||
);
|
||||
const right = Math.min(
|
||||
raster.widthPx,
|
||||
Math.ceil(
|
||||
(bounds.x + bounds.width + BLOCK_RASTER_PADDING_PT) * pixelsPerPoint,
|
||||
(bounds.x + bounds.width + BLOCK_RASTER_HORIZONTAL_PADDING_PT) *
|
||||
pixelsPerPoint,
|
||||
),
|
||||
);
|
||||
const bottom = Math.min(
|
||||
raster.heightPx,
|
||||
Math.ceil(
|
||||
(bounds.y + bounds.height + BLOCK_RASTER_PADDING_PT) * pixelsPerPoint,
|
||||
(bounds.y + bounds.height + BLOCK_RASTER_VERTICAL_PADDING_PT) *
|
||||
pixelsPerPoint,
|
||||
),
|
||||
);
|
||||
const widthPx = Math.max(1, right - left);
|
||||
@@ -91,6 +184,7 @@ async function cropRaster(
|
||||
widthPx,
|
||||
heightPx,
|
||||
);
|
||||
const rgba = context.getImageData(0, 0, widthPx, heightPx).data;
|
||||
const png = Uint8Array.from(canvas.toBuffer("image/png"));
|
||||
return {
|
||||
widthPx,
|
||||
@@ -98,6 +192,7 @@ async function cropRaster(
|
||||
dpi: raster.dpi,
|
||||
sha256: sha256(png),
|
||||
png,
|
||||
rgba,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -109,12 +204,25 @@ async function padRaster(
|
||||
if (raster.widthPx === widthPx && raster.heightPx === heightPx) {
|
||||
return raster;
|
||||
}
|
||||
const source = await loadImage(Buffer.from(raster.png));
|
||||
const canvas = createCanvas(widthPx, heightPx);
|
||||
const context = canvas.getContext("2d");
|
||||
context.fillStyle = "#ffffff";
|
||||
context.fillRect(0, 0, widthPx, heightPx);
|
||||
context.drawImage(source, 0, 0);
|
||||
if (raster.rgba?.length === raster.widthPx * raster.heightPx * 4) {
|
||||
const sourceCanvas = createCanvas(raster.widthPx, raster.heightPx);
|
||||
const sourceContext = sourceCanvas.getContext("2d");
|
||||
const sourceData = sourceContext.createImageData(
|
||||
raster.widthPx,
|
||||
raster.heightPx,
|
||||
);
|
||||
sourceData.data.set(raster.rgba);
|
||||
sourceContext.putImageData(sourceData, 0, 0);
|
||||
context.drawImage(sourceCanvas, 0, 0);
|
||||
} else {
|
||||
const source = await loadImage(Buffer.from(raster.png));
|
||||
context.drawImage(source, 0, 0);
|
||||
}
|
||||
const rgba = context.getImageData(0, 0, widthPx, heightPx).data;
|
||||
const png = Uint8Array.from(canvas.toBuffer("image/png"));
|
||||
return {
|
||||
widthPx,
|
||||
@@ -122,6 +230,7 @@ async function padRaster(
|
||||
dpi: raster.dpi,
|
||||
sha256: sha256(png),
|
||||
png,
|
||||
rgba,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -129,6 +238,7 @@ function localRasterIssues(
|
||||
paragraphIndex: number,
|
||||
lineIndex: number,
|
||||
blockKind: string | undefined,
|
||||
hasInlineCode: boolean,
|
||||
metrics: NonNullable<PdfSemanticBlockVisualLineComparison["metrics"]>,
|
||||
thresholds: VisualDiffThresholds,
|
||||
textReflow: boolean,
|
||||
@@ -136,9 +246,12 @@ function localRasterIssues(
|
||||
const shapeFailed =
|
||||
metrics.inkIou < thresholds.minInkIou ||
|
||||
metrics.edgeIou < thresholds.minEdgeIou;
|
||||
const neutralTinyGlyphAntialiasEquivalent =
|
||||
isNeutralTinyGlyphAntialiasEquivalent(metrics);
|
||||
const colorFailed =
|
||||
metrics.backgroundColorDelta > thresholds.maxBackgroundColorDelta ||
|
||||
metrics.foregroundColorDelta > thresholds.maxForegroundColorDelta;
|
||||
(metrics.foregroundColorDelta > thresholds.maxForegroundColorDelta &&
|
||||
!neutralTinyGlyphAntialiasEquivalent);
|
||||
const rawPixelsFailed =
|
||||
metrics.meanAbsoluteError > thresholds.maxMeanAbsoluteError ||
|
||||
metrics.changedPixelRatio > thresholds.maxChangedPixelRatio;
|
||||
@@ -150,8 +263,10 @@ function localRasterIssues(
|
||||
metrics,
|
||||
textReflow,
|
||||
blockKind,
|
||||
hasInlineCode,
|
||||
);
|
||||
const failed = colorFailed || (!textReflow && (
|
||||
const failed = (colorFailed && !crossEngineRasterEquivalent) ||
|
||||
(!textReflow && (
|
||||
(shapeFailed && !crossEngineRasterEquivalent) ||
|
||||
(rawPixelsFailed && !antialiasEquivalent && !crossEngineRasterEquivalent)
|
||||
));
|
||||
@@ -174,12 +289,81 @@ function localRasterIssues(
|
||||
foregroundColorDelta: metrics.foregroundColorDelta,
|
||||
antialiasEquivalent,
|
||||
crossEngineRasterEquivalent,
|
||||
neutralTinyGlyphAntialiasEquivalent,
|
||||
textReflow,
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function isNeutralTinyGlyphAntialiasEquivalent(
|
||||
metrics: NonNullable<PdfSemanticBlockVisualLineComparison["metrics"]>,
|
||||
) {
|
||||
// 极细的中性标点(例如表格占位长横线)在 WPS 中可能只保留一层
|
||||
// 灰阶覆盖,前景色估算因此会比 Chromium/Word 明显变亮。只有局部
|
||||
// 几何、墨迹和边缘完全同拓扑,且整体与背景误差都很低时才视作同一
|
||||
// 字形的抗锯齿差异;真实着色、轮廓或背景变化仍继续阻断。
|
||||
return Math.max(
|
||||
metrics.baselineWidthPx,
|
||||
metrics.candidateWidthPx,
|
||||
) <= 64 &&
|
||||
Math.max(
|
||||
metrics.baselineHeightPx,
|
||||
metrics.candidateHeightPx,
|
||||
) <= 48 &&
|
||||
metrics.inkIou >= 0.995 &&
|
||||
(metrics.foregroundInkIou ?? metrics.inkIou) >= 0.995 &&
|
||||
metrics.edgeIou >= 0.995 &&
|
||||
metrics.meanAbsoluteError <= 16 &&
|
||||
metrics.backgroundColorDelta <= 12 &&
|
||||
(metrics.baselineForegroundChroma ?? Number.POSITIVE_INFINITY) <= 12 &&
|
||||
(metrics.candidateForegroundChroma ?? Number.POSITIVE_INFINITY) <= 12 &&
|
||||
(metrics.foregroundLuminanceDelta ?? Number.POSITIVE_INFINITY) <= 120;
|
||||
}
|
||||
|
||||
function isNeutralForegroundToneEquivalent(
|
||||
metrics: NonNullable<PdfSemanticBlockVisualLineComparison["metrics"]>,
|
||||
) {
|
||||
return (metrics.baselineForegroundChroma ?? Number.POSITIVE_INFINITY) <= 2 &&
|
||||
(metrics.candidateForegroundChroma ?? Number.POSITIVE_INFINITY) <= 2 &&
|
||||
(metrics.foregroundLuminanceDelta ?? Number.POSITIVE_INFINITY) <= 5;
|
||||
}
|
||||
|
||||
function isChromaticPaletteEquivalent(
|
||||
metrics: NonNullable<PdfSemanticBlockVisualLineComparison["metrics"]>,
|
||||
) {
|
||||
return metrics.inkIou >= 0.995 &&
|
||||
metrics.edgeIou >= 0.995 &&
|
||||
(metrics.baselineForegroundChroma ?? 0) >= 80 &&
|
||||
(metrics.candidateForegroundChroma ?? 0) >= 80 &&
|
||||
(metrics.dominantForegroundColorDelta ?? Number.POSITIVE_INFINITY) <= 4;
|
||||
}
|
||||
|
||||
function isSyntaxPaletteEquivalent(
|
||||
metrics: NonNullable<PdfSemanticBlockVisualLineComparison["metrics"]>,
|
||||
blockKind?: string,
|
||||
) {
|
||||
if (blockKind !== "code-block") {
|
||||
return false;
|
||||
}
|
||||
const widthDelta = Math.abs(
|
||||
metrics.baselineWidthPx - metrics.candidateWidthPx,
|
||||
);
|
||||
const heightDelta = Math.abs(
|
||||
metrics.baselineHeightPx - metrics.candidateHeightPx,
|
||||
);
|
||||
return widthDelta <= Math.max(
|
||||
4,
|
||||
Math.ceil(metrics.baselineWidthPx * 0.015),
|
||||
) &&
|
||||
heightDelta <= 1 &&
|
||||
metrics.inkIou >= 0.985 &&
|
||||
(metrics.foregroundInkIou ?? 0) >= 0.995 &&
|
||||
metrics.edgeIou >= 0.995 &&
|
||||
metrics.backgroundColorDelta <= 4 &&
|
||||
(metrics.chromaticPaletteDelta ?? Number.POSITIVE_INFINITY) <= 2;
|
||||
}
|
||||
|
||||
/**
|
||||
* Word、WPS 与 Chromium 会用不同的灰阶覆盖率栅格化同一套嵌入字形。
|
||||
* 只有轮廓拓扑、颜色和局部几何同时近等时,才把较大的原始像素差视为
|
||||
@@ -189,6 +373,7 @@ export function isCrossEngineRasterEquivalent(
|
||||
metrics: NonNullable<PdfSemanticBlockVisualLineComparison["metrics"]>,
|
||||
textReflow: boolean,
|
||||
blockKind?: string,
|
||||
hasInlineCode = false,
|
||||
): boolean {
|
||||
if (textReflow) {
|
||||
return false;
|
||||
@@ -215,10 +400,27 @@ export function isCrossEngineRasterEquivalent(
|
||||
Math.max(metrics.baselineWidthPx, metrics.candidateWidthPx) <= 48 &&
|
||||
widthDelta <= 2 &&
|
||||
heightDelta <= 1;
|
||||
const neutralTinyGlyphAntialiasEquivalent =
|
||||
isNeutralTinyGlyphAntialiasEquivalent(metrics);
|
||||
const colorEquivalent =
|
||||
metrics.backgroundColorDelta <= 4 &&
|
||||
metrics.foregroundColorDelta <=
|
||||
(blockKind === "list-item" ? 4.5 : 4);
|
||||
neutralTinyGlyphAntialiasEquivalent ||
|
||||
(
|
||||
metrics.backgroundColorDelta <= 4 &&
|
||||
(metrics.foregroundColorDelta <=
|
||||
(blockKind === "list-item" ? 4.5 : 4) ||
|
||||
isNeutralForegroundToneEquivalent(metrics) ||
|
||||
isChromaticPaletteEquivalent(metrics) ||
|
||||
isSyntaxPaletteEquivalent(metrics, blockKind))
|
||||
);
|
||||
const inlineCodeShadingEquivalent =
|
||||
hasInlineCode &&
|
||||
widthDelta <= Math.max(3, Math.ceil(metrics.baselineWidthPx * 0.015)) &&
|
||||
heightDelta <= 2 &&
|
||||
metrics.edgeIou >= 0.995 &&
|
||||
(metrics.inkIou >= 0.9 ||
|
||||
(metrics.foregroundInkIou ?? 0) >= 0.98) &&
|
||||
metrics.foregroundColorDelta <= 3.25 &&
|
||||
metrics.backgroundColorDelta <= 12;
|
||||
const topologyEquivalent =
|
||||
(preciseGeometryEquivalent &&
|
||||
metrics.inkIou >= 0.99 &&
|
||||
@@ -247,7 +449,8 @@ export function isCrossEngineRasterEquivalent(
|
||||
(preciseGeometryEquivalent &&
|
||||
metrics.edgeIou >= 0.97 &&
|
||||
metrics.inkIou >= 0.97);
|
||||
return colorEquivalent && topologyEquivalent;
|
||||
return inlineCodeShadingEquivalent ||
|
||||
(colorEquivalent && topologyEquivalent);
|
||||
}
|
||||
|
||||
export async function comparePdfSemanticBlockVisuals(
|
||||
@@ -270,14 +473,41 @@ export async function comparePdfSemanticBlockVisuals(
|
||||
"paragraph",
|
||||
"list-item",
|
||||
"block-quote",
|
||||
"code-block",
|
||||
].includes(paragraph.expectation.blockKind ?? "paragraph");
|
||||
const comparableLineCount = Math.min(
|
||||
paragraph.baseline.visualLines.length,
|
||||
paragraph.candidate.visualLines.length,
|
||||
);
|
||||
const hasUsableVisualMapping = (layout: typeof paragraph.baseline) => {
|
||||
if (layout.matched) {
|
||||
return true;
|
||||
}
|
||||
const expected = layout.expectedCharacterCount ?? 0;
|
||||
const matched = layout.matchedCharacterCount ?? 0;
|
||||
const coverage = expected === 0 ? 0 : matched / expected;
|
||||
const mathCharacterIndexes = new Set(
|
||||
paragraph.expectation.mathCharacterIndexes ?? [],
|
||||
);
|
||||
const missingTailIsMath =
|
||||
matched < expected &&
|
||||
Array.from(
|
||||
{ length: expected - matched },
|
||||
(_, offset) => matched + offset,
|
||||
).every((index) => mathCharacterIndexes.has(index));
|
||||
return layout.visualLines.length > 0 &&
|
||||
((expected >= 10 && expected - matched === 1 && coverage >= 0.9) ||
|
||||
(missingTailIsMath && coverage >= 0.8));
|
||||
};
|
||||
const equalLineSegmentation =
|
||||
paragraph.baseline.visualLines.length ===
|
||||
paragraph.candidate.visualLines.length &&
|
||||
paragraph.baseline.lineTexts.every(
|
||||
(text, index) => text === paragraph.candidate.lineTexts[index],
|
||||
);
|
||||
if (
|
||||
!paragraph.baseline.matched ||
|
||||
!paragraph.candidate.matched ||
|
||||
!hasUsableVisualMapping(paragraph.baseline) ||
|
||||
!hasUsableVisualMapping(paragraph.candidate) ||
|
||||
comparableLineCount === 0
|
||||
) {
|
||||
issues.push({
|
||||
@@ -286,10 +516,7 @@ export async function comparePdfSemanticBlockVisuals(
|
||||
message: `第 ${paragraph.expectation.index + 1} 个语义块缺少可比较的局部视觉观测`,
|
||||
details: { paragraphIndex: paragraph.expectation.index },
|
||||
});
|
||||
} else if (
|
||||
paragraph.baseline.visualLines.length ===
|
||||
paragraph.candidate.visualLines.length
|
||||
) {
|
||||
} else if (equalLineSegmentation) {
|
||||
for (let lineIndex = 0; lineIndex < comparableLineCount; lineIndex += 1) {
|
||||
const baselineLine = paragraph.baseline.visualLines[lineIndex];
|
||||
const candidateLine = paragraph.candidate.visualLines[lineIndex];
|
||||
@@ -327,41 +554,67 @@ export async function comparePdfSemanticBlockVisuals(
|
||||
cropRaster(baselinePage.raster, baselineLine.bounds),
|
||||
cropRaster(candidatePage.raster, candidateLine.bounds),
|
||||
]);
|
||||
assertPngRaster(
|
||||
baselineCrop,
|
||||
`第 ${paragraph.expectation.index + 1} 个语义块第 ${lineIndex + 1} 行基线裁剪`,
|
||||
);
|
||||
assertPngRaster(
|
||||
candidateCrop,
|
||||
`第 ${paragraph.expectation.index + 1} 个语义块第 ${lineIndex + 1} 行候选裁剪`,
|
||||
);
|
||||
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 cropWidthRatio = Math.min(
|
||||
baselineCrop.widthPx,
|
||||
candidateCrop.widthPx,
|
||||
) / Math.max(baselineCrop.widthPx, candidateCrop.widthPx);
|
||||
const normalizeFlowGeometry = flowTextBlock &&
|
||||
!textReflow &&
|
||||
cropWidthRatio >= 0.75;
|
||||
let result;
|
||||
try {
|
||||
result = normalizeFlowGeometry
|
||||
? 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,
|
||||
);
|
||||
})();
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`第 ${paragraph.expectation.index + 1} 个语义块第 ${lineIndex + 1} 行栅格比较失败:` +
|
||||
`基线 ${baselineCrop.widthPx}x${baselineCrop.heightPx}/${baselineCrop.png.byteLength},` +
|
||||
`候选 ${candidateCrop.widthPx}x${candidateCrop.heightPx}/${candidateCrop.png.byteLength};` +
|
||||
`${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
const lineIssues = localRasterIssues(
|
||||
paragraph.expectation.index,
|
||||
lineIndex,
|
||||
paragraph.expectation.blockKind,
|
||||
paragraph.expectation.hasInlineCode === true,
|
||||
result.metrics,
|
||||
thresholds,
|
||||
textReflow,
|
||||
@@ -376,6 +629,139 @@ export async function comparePdfSemanticBlockVisuals(
|
||||
issues: lineIssues,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const reflowPairs = pairReflowedTextLines(
|
||||
paragraph.baseline.lineTexts,
|
||||
paragraph.candidate.lineTexts,
|
||||
);
|
||||
const sliceByCharacterOverlap =
|
||||
paragraph.baseline.visualLines.length ===
|
||||
paragraph.candidate.visualLines.length;
|
||||
if (reflowPairs.length === 0) {
|
||||
issues.push({
|
||||
code: "SEMANTIC_BLOCK_VISUAL_UNAVAILABLE",
|
||||
severity: "failure",
|
||||
message: `第 ${paragraph.expectation.index + 1} 个语义块无法按文本区间配对跨引擎视觉行`,
|
||||
details: { paragraphIndex: paragraph.expectation.index },
|
||||
});
|
||||
}
|
||||
for (let pairIndex = 0; pairIndex < reflowPairs.length; pairIndex += 1) {
|
||||
const pair = reflowPairs[pairIndex]!;
|
||||
const baselineLine =
|
||||
paragraph.baseline.visualLines[pair.baselineLineIndex];
|
||||
const candidateLine =
|
||||
paragraph.candidate.visualLines[pair.candidateLineIndex];
|
||||
const baselinePage = baselineLine
|
||||
? baseline.pages[baselineLine.pageNumber - 1]
|
||||
: undefined;
|
||||
const candidatePage = candidateLine
|
||||
? candidate.pages[candidateLine.pageNumber - 1]
|
||||
: undefined;
|
||||
if (
|
||||
!baselineLine ||
|
||||
!candidateLine ||
|
||||
!baselinePage?.raster ||
|
||||
!candidatePage?.raster
|
||||
) {
|
||||
const unavailable: VisualDiffIssue = {
|
||||
code: "SEMANTIC_BLOCK_VISUAL_UNAVAILABLE",
|
||||
severity: "failure",
|
||||
message: `第 ${paragraph.expectation.index + 1} 个语义块的跨引擎重排行缺少栅格`,
|
||||
details: {
|
||||
paragraphIndex: paragraph.expectation.index,
|
||||
lineIndex: pairIndex,
|
||||
},
|
||||
};
|
||||
issues.push(unavailable);
|
||||
lines.push({
|
||||
lineIndex: pairIndex,
|
||||
baselinePageNumber: baselineLine?.pageNumber ?? -1,
|
||||
candidatePageNumber: candidateLine?.pageNumber ?? -1,
|
||||
issues: [unavailable],
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const baselineBounds = sliceByCharacterOverlap
|
||||
? sliceTextLineBounds(
|
||||
baselineLine.bounds,
|
||||
paragraph.baseline.lineTexts[pair.baselineLineIndex] ?? "",
|
||||
pair.baselineStart,
|
||||
pair.baselineEnd,
|
||||
)
|
||||
: baselineLine.bounds;
|
||||
const candidateBounds = sliceByCharacterOverlap
|
||||
? sliceTextLineBounds(
|
||||
candidateLine.bounds,
|
||||
paragraph.candidate.lineTexts[pair.candidateLineIndex] ?? "",
|
||||
pair.candidateStart,
|
||||
pair.candidateEnd,
|
||||
)
|
||||
: candidateLine.bounds;
|
||||
const [baselineCrop, candidateCrop] = await Promise.all([
|
||||
cropRaster(baselinePage.raster, baselineBounds),
|
||||
cropRaster(candidatePage.raster, candidateBounds),
|
||||
]);
|
||||
assertPngRaster(
|
||||
baselineCrop,
|
||||
`第 ${paragraph.expectation.index + 1} 个语义块重排行基线裁剪`,
|
||||
);
|
||||
assertPngRaster(
|
||||
candidateCrop,
|
||||
`第 ${paragraph.expectation.index + 1} 个语义块重排行候选裁剪`,
|
||||
);
|
||||
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 reflowIssue: VisualDiffIssue = {
|
||||
code: "PARAGRAPH_LINE_BREAK_MISMATCH",
|
||||
severity: "warning",
|
||||
message: `第 ${paragraph.expectation.index + 1} 个语义块发生允许的跨引擎流式换行`,
|
||||
details: {
|
||||
paragraphIndex: paragraph.expectation.index,
|
||||
lineIndex: pairIndex,
|
||||
baselineLineIndex: pair.baselineLineIndex,
|
||||
candidateLineIndex: pair.candidateLineIndex,
|
||||
textReflow: true,
|
||||
},
|
||||
};
|
||||
const lineIssues = [
|
||||
reflowIssue,
|
||||
...localRasterIssues(
|
||||
paragraph.expectation.index,
|
||||
pairIndex,
|
||||
paragraph.expectation.blockKind,
|
||||
paragraph.expectation.hasInlineCode === true,
|
||||
result.metrics,
|
||||
thresholds,
|
||||
true,
|
||||
),
|
||||
];
|
||||
issues.push(...lineIssues);
|
||||
lines.push({
|
||||
lineIndex: pairIndex,
|
||||
baselinePageNumber: baselineLine.pageNumber,
|
||||
candidatePageNumber: candidateLine.pageNumber,
|
||||
metrics: result.metrics,
|
||||
...(lineIssues.some((issue) => issue.severity === "failure")
|
||||
? { artifacts: result.artifacts }
|
||||
: {}),
|
||||
issues: lineIssues,
|
||||
});
|
||||
}
|
||||
}
|
||||
comparisons.push({
|
||||
expectation: paragraph.expectation,
|
||||
|
||||
@@ -7,7 +7,7 @@ import type {
|
||||
VisualPageSemanticExpectation,
|
||||
} from "./types.js";
|
||||
|
||||
const ZERO_WIDTH_AND_CONTROL = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f\u200b-\u200d\u2060\ufeff]/gu;
|
||||
const ZERO_WIDTH_AND_CONTROL = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f\u200b-\u200d\u2060\ufe0e\ufe0f\ufeff]/gu;
|
||||
const CJK_RADICAL_VARIANTS = new Map([
|
||||
["⺠", "民"],
|
||||
["⻅", "见"],
|
||||
@@ -16,6 +16,10 @@ const CJK_RADICAL_VARIANTS = new Map([
|
||||
["⻔", "门"],
|
||||
["⻚", "页"],
|
||||
["⻛", "风"],
|
||||
["⻝", "食"],
|
||||
["⻣", "骨"],
|
||||
["⻋", "车"],
|
||||
["⻬", "齐"],
|
||||
]);
|
||||
const PAGE_NUMBER_PATTERNS = [
|
||||
/^(?:[-—–]\s*)?\d+(?:\s*[//]\s*\d+)?(?:\s*[-—–])?$/u,
|
||||
@@ -30,6 +34,16 @@ export function normalizePdfText(value: string): string {
|
||||
.replace(/[\u2e80-\u2eff]/gu, (character) =>
|
||||
CJK_RADICAL_VARIANTS.get(character) ?? character
|
||||
)
|
||||
// Chromium、Word 与 WPS 的 PDF 文本层会以不同方式保留弯引号;
|
||||
// 统一为 ASCII 只用于可编辑文本定位,不改变栅格视觉比较。
|
||||
.replace(/[“”]/gu, '"')
|
||||
.replace(/[‘’]/gu, "'")
|
||||
// Chromium 的部分 CJK 字体把 Markdown em dash 提取为 horizontal bar;
|
||||
// 二者在此仅作为语义定位字符归一,不影响后续栅格视觉比较。
|
||||
.replace(/―/gu, "—")
|
||||
// Chromium 的部分 CJK 字体会把数字区间中的 en dash 提取为两个
|
||||
// ASCII hyphen。只归一数字/百分比区间,避免改写代码中的普通 `--`。
|
||||
.replace(/(?<=[\d%])\s*--(?:\s*(?=\d)|\s*$)/gu, "–")
|
||||
.replace(ZERO_WIDTH_AND_CONTROL, "")
|
||||
.replace(/\u00a0/gu, " ")
|
||||
.replace(/[ \t]+/gu, " ")
|
||||
@@ -133,6 +147,63 @@ function median(values: readonly number[]): number {
|
||||
return ((sorted[middle - 1] ?? value) + value) / 2;
|
||||
}
|
||||
|
||||
function mergeInlineScriptGroups(
|
||||
sourceGroups: readonly PdfTextItemSnapshot[][],
|
||||
typicalHeight: number,
|
||||
): PdfTextItemSnapshot[][] {
|
||||
const groups = sourceGroups.map((group) => [...group]);
|
||||
for (let index = 0; index < groups.length; index += 1) {
|
||||
const group = groups[index];
|
||||
if (!group || group.length === 0) {
|
||||
continue;
|
||||
}
|
||||
const groupHeight = median(group.map((item) => item.bounds.height));
|
||||
if (groupHeight > typicalHeight * 0.9) {
|
||||
continue;
|
||||
}
|
||||
const groupBounds = unionBounds(group);
|
||||
for (const adjacentIndex of [index - 1, index + 1]) {
|
||||
const adjacent = groups[adjacentIndex];
|
||||
if (!adjacent || adjacent.length === 0) {
|
||||
continue;
|
||||
}
|
||||
const adjacentHeight = median(
|
||||
adjacent.map((item) => item.bounds.height),
|
||||
);
|
||||
if (adjacentHeight < groupHeight / 0.9) {
|
||||
continue;
|
||||
}
|
||||
const adjacentBounds = unionBounds(adjacent);
|
||||
const verticalOverlap = Math.min(
|
||||
groupBounds.y + groupBounds.height,
|
||||
adjacentBounds.y + adjacentBounds.height,
|
||||
) - Math.max(groupBounds.y, adjacentBounds.y);
|
||||
const horizontallyContained =
|
||||
groupBounds.x >= adjacentBounds.x - typicalHeight * 0.5 &&
|
||||
groupBounds.x + groupBounds.width <=
|
||||
adjacentBounds.x + adjacentBounds.width + typicalHeight * 0.5;
|
||||
// Chromium 会把行末公式按较小字号单独提取;公式片段可能从正文
|
||||
// 最后一个字符的右缘开始,并向右超出正文组,因此不能只用“被正文
|
||||
// 水平包含”判断同行。仅在两组真实垂直重叠且小字号组紧贴较大字号
|
||||
// 组右缘时合并,避免把下一视觉行或相邻表格单元格误并入当前行。
|
||||
const adjacentRight = adjacentBounds.x + adjacentBounds.width;
|
||||
const touchesAdjacentRight =
|
||||
groupBounds.x >= adjacentBounds.x &&
|
||||
Math.abs(groupBounds.x - adjacentRight) <= typicalHeight * 0.5;
|
||||
if (
|
||||
verticalOverlap <= 0 ||
|
||||
(!horizontallyContained && !touchesAdjacentRight)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
adjacent.push(...group);
|
||||
groups[index] = [];
|
||||
break;
|
||||
}
|
||||
}
|
||||
return groups.filter((group) => group.length > 0);
|
||||
}
|
||||
|
||||
function joinLineItems(items: readonly PdfTextItemSnapshot[]): string {
|
||||
let result = "";
|
||||
let previous: PdfTextItemSnapshot | undefined;
|
||||
@@ -181,6 +252,9 @@ export function aggregatePdfTextLines(
|
||||
sourceItems: readonly PdfTextItemSnapshot[],
|
||||
pageHeightPt: number,
|
||||
): PdfTextLineSnapshot[] {
|
||||
const sourceOrder = new Map(
|
||||
sourceItems.map((item, index) => [item, index] as const),
|
||||
);
|
||||
const items = sourceItems
|
||||
.filter((item) => item.normalizedText.length > 0)
|
||||
.sort(
|
||||
@@ -188,11 +262,14 @@ export function aggregatePdfTextLines(
|
||||
left.bounds.y - right.bounds.y || left.bounds.x - right.bounds.x,
|
||||
);
|
||||
const typicalHeight = median(items.map((item) => item.bounds.height));
|
||||
const baselineTolerance = Math.max(1.5, typicalHeight * 0.15);
|
||||
const groups: PdfTextItemSnapshot[][] = [];
|
||||
// Office/WPS 会让同一视觉行内不同字体或单元格的基线产生约 2pt
|
||||
// 浮动;按 15% 聚合会把这些片段错误拆行并改变阅读顺序。
|
||||
// 20% 仍远小于正常行距,同时能覆盖常见的 10–12pt 字号偏差。
|
||||
const baselineTolerance = Math.max(2, typicalHeight * 0.2);
|
||||
const initialGroups: PdfTextItemSnapshot[][] = [];
|
||||
|
||||
for (const item of items) {
|
||||
const lastGroup = groups.at(-1);
|
||||
const lastGroup = initialGroups.at(-1);
|
||||
const lastBaseline = lastGroup
|
||||
? median(lastGroup.map((entry) => entry.baselineY))
|
||||
: undefined;
|
||||
@@ -203,13 +280,27 @@ export function aggregatePdfTextLines(
|
||||
) {
|
||||
lastGroup.push(item);
|
||||
} else {
|
||||
groups.push([item]);
|
||||
initialGroups.push([item]);
|
||||
}
|
||||
}
|
||||
|
||||
const groups = mergeInlineScriptGroups(initialGroups, typicalHeight);
|
||||
|
||||
return groups.map((group) => {
|
||||
const sortedItems = [...group].sort(
|
||||
(left, right) => left.bounds.x - right.bounds.x,
|
||||
(left, right) => {
|
||||
const leftRight = left.bounds.x + left.bounds.width;
|
||||
const rightRight = right.bounds.x + right.bounds.width;
|
||||
const horizontallyOverlapping =
|
||||
left.bounds.x < rightRight && right.bounds.x < leftRight;
|
||||
// PDF.js 的文本流保留字符阅读顺序,但不同字体的窄标点可能与
|
||||
// 相邻全角字符发生水平重叠。此时单纯按 x 排序会把闭引号移到
|
||||
// 左括号之后,破坏语义块定位;只对真实重叠片段保留文本流顺序,
|
||||
// 其余片段仍按几何位置排序,兼容 Office 表格等非阅读序文本流。
|
||||
return horizontallyOverlapping
|
||||
? (sourceOrder.get(left) ?? 0) - (sourceOrder.get(right) ?? 0)
|
||||
: left.bounds.x - right.bounds.x;
|
||||
},
|
||||
);
|
||||
const bounds = unionBounds(sortedItems);
|
||||
const normalizedText = joinLineItems(sortedItems);
|
||||
|
||||
@@ -71,6 +71,12 @@ export type EditableParagraphBlockKind =
|
||||
export interface EditableParagraphExpectation {
|
||||
index: number;
|
||||
text: string;
|
||||
mathCharacterIndexes?: number[];
|
||||
hardBreakSegments?: string[];
|
||||
hasInlineCode?: boolean;
|
||||
tableGroupId?: string;
|
||||
tableRowIndex?: number;
|
||||
tableColumnIndex?: number;
|
||||
styleId?: string;
|
||||
role: EditableParagraphRole;
|
||||
blockKind?: EditableParagraphBlockKind;
|
||||
@@ -144,6 +150,7 @@ export interface PdfPageRaster {
|
||||
dpi: number;
|
||||
sha256: string;
|
||||
png: Uint8Array;
|
||||
rgba?: Uint8ClampedArray;
|
||||
}
|
||||
|
||||
export interface PdfPageSnapshot {
|
||||
@@ -287,9 +294,16 @@ export interface PdfRasterDiffMetrics {
|
||||
meanAbsoluteError: number;
|
||||
changedPixelRatio: number;
|
||||
inkIou: number;
|
||||
foregroundInkIou: number;
|
||||
edgeIou: number;
|
||||
backgroundColorDelta: number;
|
||||
foregroundColorDelta: number;
|
||||
baselineForegroundChroma?: number;
|
||||
candidateForegroundChroma?: number;
|
||||
foregroundLuminanceDelta?: number;
|
||||
dominantForegroundColorDelta?: number;
|
||||
chromaticForegroundColorDelta?: number;
|
||||
chromaticPaletteDelta?: number;
|
||||
}
|
||||
|
||||
export interface ComparePageRasterOptions {
|
||||
|
||||
@@ -121,6 +121,37 @@ describe("Office PDF 适配器", () => {
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("Office 偶发未生成 PDF 时清理残留并有界重试", async () => {
|
||||
const directory = await mkdtemp(
|
||||
join(tmpdir(), "visual-diff-office-retry-test-"),
|
||||
);
|
||||
const docxPath = join(directory, "fixture.docx");
|
||||
await writeFile(docxPath, "fixture");
|
||||
let attempts = 0;
|
||||
const backend: OfficeAutomationBackend = {
|
||||
probe: async () => true,
|
||||
exportPdf: async (options) => {
|
||||
attempts += 1;
|
||||
if (attempts === 1) {
|
||||
await writeFile(options.outputPath, "partial");
|
||||
throw new Error("Office 未生成有效 PDF");
|
||||
}
|
||||
const pdf = createMinimalPdf("retry");
|
||||
await writeFile(options.outputPath, pdf);
|
||||
return { bytes: pdf.byteLength, pageCount: 1 };
|
||||
},
|
||||
};
|
||||
try {
|
||||
const result = await createWordPdfAdapter({ backend }).generate({
|
||||
docxPath,
|
||||
});
|
||||
expect(result.pageCount).toBe(1);
|
||||
expect(attempts).toBe(2);
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("PDF 适配器视觉编排", () => {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -52,6 +52,7 @@ describe("PDF 栅格差异", () => {
|
||||
meanAbsoluteError: 0,
|
||||
changedPixelRatio: 0,
|
||||
inkIou: 1,
|
||||
foregroundInkIou: 1,
|
||||
edgeIou: 1,
|
||||
backgroundColorDelta: 0,
|
||||
foregroundColorDelta: 0,
|
||||
@@ -88,6 +89,96 @@ describe("PDF 栅格差异", () => {
|
||||
expect(sameShape.metrics.foregroundColorDelta).toBeLessThan(1);
|
||||
});
|
||||
|
||||
it("双背景占比翻转时仍独立量化核心字形拓扑", async () => {
|
||||
const inlineCodeRaster = (fullHeightShading: boolean) => {
|
||||
const canvas = createCanvas(216, 25);
|
||||
const context = canvas.getContext("2d");
|
||||
context.fillStyle = "#ffffff";
|
||||
context.fillRect(0, 0, 216, 25);
|
||||
context.fillStyle = "#f3f3f3";
|
||||
context.fillRect(28, fullHeightShading ? 0 : 5, 120, fullHeightShading ? 25 : 16);
|
||||
context.fillStyle = "#333333";
|
||||
context.fillRect(34, 10, 80, 4);
|
||||
const png = Uint8Array.from(canvas.toBuffer("image/png"));
|
||||
return {
|
||||
widthPx: 216,
|
||||
heightPx: 25,
|
||||
dpi: 144,
|
||||
sha256: createHash("sha256").update(png).digest("hex"),
|
||||
png,
|
||||
} satisfies PdfPageRaster;
|
||||
};
|
||||
const result = await comparePageRasters(
|
||||
inlineCodeRaster(false),
|
||||
inlineCodeRaster(true),
|
||||
);
|
||||
|
||||
expect(result.metrics.backgroundColorDelta).toBeGreaterThan(10);
|
||||
expect(result.metrics.foregroundInkIou).toBe(1);
|
||||
});
|
||||
|
||||
it("渐变背景不会把深色文字误判为背景", async () => {
|
||||
const gradientRaster = (gradient: boolean) => {
|
||||
const canvas = createCanvas(160, 40);
|
||||
const context = canvas.getContext("2d");
|
||||
if (gradient) {
|
||||
const fill = context.createLinearGradient(0, 0, 160, 0);
|
||||
fill.addColorStop(0, "#edf5fa");
|
||||
fill.addColorStop(1, "#ffffff");
|
||||
context.fillStyle = fill;
|
||||
} else {
|
||||
context.fillStyle = "#f6fafc";
|
||||
}
|
||||
context.fillRect(0, 0, 160, 40);
|
||||
context.fillStyle = "#0b3557";
|
||||
context.fillRect(4, 4, 148, 32);
|
||||
const png = Uint8Array.from(canvas.toBuffer("image/png"));
|
||||
return {
|
||||
widthPx: 160,
|
||||
heightPx: 40,
|
||||
dpi: 144,
|
||||
sha256: createHash("sha256").update(png).digest("hex"),
|
||||
png,
|
||||
} satisfies PdfPageRaster;
|
||||
};
|
||||
|
||||
const result = await comparePageRasters(
|
||||
gradientRaster(true),
|
||||
gradientRaster(false),
|
||||
);
|
||||
expect(result.metrics.foregroundColorDelta).toBeLessThan(1);
|
||||
expect(result.metrics.backgroundColorDelta).toBeLessThan(8);
|
||||
expect(result.metrics.inkIou).toBeGreaterThan(0.95);
|
||||
});
|
||||
|
||||
it("裁剪跨过深色盒边界时不会把外部白色误判为盒背景", async () => {
|
||||
const boxedRaster = (exposeOutside: boolean) => {
|
||||
const canvas = createCanvas(80, 40);
|
||||
const context = canvas.getContext("2d");
|
||||
context.fillStyle = "#ffffff";
|
||||
context.fillRect(0, 0, 80, 40);
|
||||
context.fillStyle = "#464646";
|
||||
context.fillRect(0, 0, exposeOutside ? 68 : 80, exposeOutside ? 32 : 40);
|
||||
context.fillStyle = "#ffffff";
|
||||
context.fillRect(18, 10, 32, 16);
|
||||
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 result = await comparePageRasters(
|
||||
boxedRaster(true),
|
||||
boxedRaster(false),
|
||||
);
|
||||
expect(result.metrics.backgroundColorDelta).toBeLessThan(1);
|
||||
expect(result.metrics.foregroundColorDelta).toBeLessThan(1);
|
||||
});
|
||||
|
||||
it("量化位移并生成稳定叠加图与热力图", async () => {
|
||||
const result = await comparePageRasters(raster(10), raster(20), {
|
||||
spatialTolerancePx: 0,
|
||||
|
||||
@@ -6,6 +6,7 @@ import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
createPdfVisualDiffReport,
|
||||
getBlockingVisualDiffIssues,
|
||||
isCrossEngineRasterEquivalent,
|
||||
renderPdfVisualDiffHtml,
|
||||
serializePdfVisualDiffJson,
|
||||
type PdfDocumentSnapshot,
|
||||
@@ -84,6 +85,27 @@ function antialiasBlockRaster(edgeColor: string): PdfPageRaster {
|
||||
};
|
||||
}
|
||||
|
||||
function adjacentLineRaster(includePreviousLine: boolean): PdfPageRaster {
|
||||
const canvas = createCanvas(40, 50);
|
||||
const context = canvas.getContext("2d");
|
||||
context.fillStyle = "#ffffff";
|
||||
context.fillRect(0, 0, 40, 50);
|
||||
if (includePreviousLine) {
|
||||
context.fillStyle = "#111111";
|
||||
context.fillRect(8, 12, 20, 3);
|
||||
}
|
||||
context.fillStyle = "#111111";
|
||||
context.fillRect(8, 20, 20, 4);
|
||||
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,
|
||||
@@ -561,6 +583,58 @@ describe("PDF 视觉差异报告", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("正文页数不同时分别验证未配对页面自身的页码语义", async () => {
|
||||
const pageNumber = (text: string): PdfTextLineSnapshot => ({
|
||||
text,
|
||||
normalizedText: text,
|
||||
bounds: { x: 9, y: 22, width: 2, height: 1 },
|
||||
baselineY: 23,
|
||||
role: "page-number",
|
||||
items: [],
|
||||
});
|
||||
const baseline = flowSnapshot(
|
||||
"baseline",
|
||||
[
|
||||
[contentLine("甲", 8), pageNumber("1 / 2")],
|
||||
[contentLine("乙丙", 8), pageNumber("2 / 2")],
|
||||
],
|
||||
"#111111",
|
||||
);
|
||||
const candidate = flowSnapshot(
|
||||
"candidate",
|
||||
[[
|
||||
contentLine("甲", 8),
|
||||
contentLine("乙丙", 12),
|
||||
pageNumber("1 / 1"),
|
||||
]],
|
||||
"#cc0000",
|
||||
);
|
||||
const pageSemantics = (count: number) => bodySemantics(count).map(
|
||||
(item, index) => ({
|
||||
...item,
|
||||
footerVisible: true,
|
||||
footerAlignment: "center" as const,
|
||||
pageNumberText: `${index + 1} / ${count}`,
|
||||
}),
|
||||
);
|
||||
const report = await createPdfVisualDiffReport(baseline, candidate, {
|
||||
expectedEditableText: "甲乙丙",
|
||||
expectedEditableParagraphs: flowParagraphs,
|
||||
baselinePageSemantics: pageSemantics(2),
|
||||
candidatePageSemantics: pageSemantics(1),
|
||||
});
|
||||
|
||||
expect(report.status).toBe("warning");
|
||||
expect(report.basic.bodyFlow).toMatchObject({
|
||||
legal: true,
|
||||
baselineBodyPageCount: 2,
|
||||
candidateBodyPageCount: 1,
|
||||
});
|
||||
expect(report.issues.map((issue) => issue.code)).toContain(
|
||||
"BODY_FLOW_PAGE_COUNT_DIFFERENCE",
|
||||
);
|
||||
});
|
||||
|
||||
it("新增正文页缺少预期页码时不得认定为合法分页流动", async () => {
|
||||
const pageNumber = (text: string): PdfTextLineSnapshot => ({
|
||||
text,
|
||||
@@ -664,6 +738,71 @@ describe("PDF 视觉差异报告", () => {
|
||||
.toMatchObject({ inkIou: 1, edgeIou: 1 });
|
||||
});
|
||||
|
||||
it("局部视觉裁剪不得把目标行上方的相邻行墨迹纳入比较", async () => {
|
||||
const targetLine = contentLine("乙", 10);
|
||||
targetLine.bounds = { x: 4, y: 10, width: 10, height: 2 };
|
||||
const baseline = flowSnapshot(
|
||||
"baseline",
|
||||
[[targetLine]],
|
||||
"#111111",
|
||||
);
|
||||
const candidate = flowSnapshot(
|
||||
"candidate",
|
||||
[[targetLine]],
|
||||
"#111111",
|
||||
);
|
||||
baseline.pages[0]!.raster = adjacentLineRaster(true);
|
||||
candidate.pages[0]!.raster = adjacentLineRaster(false);
|
||||
const report = await createPdfVisualDiffReport(baseline, candidate, {
|
||||
expectedEditableText: "乙",
|
||||
expectedEditableParagraphs: [{
|
||||
index: 0,
|
||||
text: "乙",
|
||||
role: "body",
|
||||
section: "body",
|
||||
blockKind: "table-cell",
|
||||
}],
|
||||
pageSemantics: bodySemantics(1),
|
||||
});
|
||||
|
||||
expect(report.issues.map((issue) => issue.code)).not.toContain(
|
||||
"SEMANTIC_BLOCK_RASTER_MISMATCH",
|
||||
);
|
||||
});
|
||||
|
||||
it("仅对带行内代码结构语义的混排允许 WPS 底纹栅格混合差异", () => {
|
||||
const metrics = {
|
||||
baselineWidthPx: 236,
|
||||
baselineHeightPx: 24,
|
||||
candidateWidthPx: 233,
|
||||
candidateHeightPx: 26,
|
||||
comparedWidthPx: 236,
|
||||
comparedHeightPx: 26,
|
||||
dimensionsMatch: false,
|
||||
geometryNormalized: true,
|
||||
spatialTolerancePx: 4,
|
||||
meanAbsoluteError: 22.1,
|
||||
changedPixelRatio: 0.449,
|
||||
inkIou: 0.908,
|
||||
edgeIou: 1,
|
||||
backgroundColorDelta: 11.38,
|
||||
foregroundColorDelta: 2.6,
|
||||
};
|
||||
|
||||
expect(isCrossEngineRasterEquivalent(
|
||||
metrics,
|
||||
false,
|
||||
"paragraph",
|
||||
true,
|
||||
)).toBe(true);
|
||||
expect(isCrossEngineRasterEquivalent(
|
||||
metrics,
|
||||
false,
|
||||
"paragraph",
|
||||
false,
|
||||
)).toBe(false);
|
||||
});
|
||||
|
||||
it("缺少语义块证据时不得把正文整页栅格自动降级", async () => {
|
||||
const report = await createPdfVisualDiffReport(
|
||||
snapshot("baseline", raster("#111111")),
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createCanvas } from "@napi-rs/canvas";
|
||||
|
||||
import {
|
||||
comparePdfSemanticBlockVisuals,
|
||||
isCrossEngineRasterEquivalent,
|
||||
type PdfRasterMetrics,
|
||||
} from "../src/index.js";
|
||||
@@ -28,7 +30,289 @@ function metrics(
|
||||
};
|
||||
}
|
||||
|
||||
function whitePageRaster() {
|
||||
const canvas = createCanvas(400, 400);
|
||||
const context = canvas.getContext("2d");
|
||||
context.fillStyle = "#fff";
|
||||
context.fillRect(0, 0, 400, 400);
|
||||
context.fillStyle = "#111";
|
||||
context.fillRect(40, 40, 120, 12);
|
||||
context.fillRect(40, 80, 120, 12);
|
||||
const rgba = context.getImageData(0, 0, 400, 400).data;
|
||||
return {
|
||||
widthPx: 400,
|
||||
heightPx: 400,
|
||||
dpi: 72,
|
||||
sha256: "test",
|
||||
png: Uint8Array.from(canvas.toBuffer("image/png")),
|
||||
rgba,
|
||||
};
|
||||
}
|
||||
|
||||
function blankPageRaster() {
|
||||
const canvas = createCanvas(400, 400);
|
||||
const context = canvas.getContext("2d");
|
||||
context.fillStyle = "#fff";
|
||||
context.fillRect(0, 0, 400, 400);
|
||||
const rgba = context.getImageData(0, 0, 400, 400).data;
|
||||
return {
|
||||
widthPx: 400,
|
||||
heightPx: 400,
|
||||
dpi: 72,
|
||||
sha256: "blank",
|
||||
png: Uint8Array.from(canvas.toBuffer("image/png")),
|
||||
rgba,
|
||||
};
|
||||
}
|
||||
|
||||
describe("跨引擎字形栅格等价", () => {
|
||||
it("缺少局部视觉行时必须阻断而不能静默通过", async () => {
|
||||
const [comparison] = await comparePdfSemanticBlockVisuals(
|
||||
{ pages: [] } as never,
|
||||
{ pages: [] } as never,
|
||||
[{
|
||||
expectation: { index: 39, section: "body", blockKind: "table-cell" },
|
||||
baseline: { matched: true, visualLines: [], lineTexts: [] },
|
||||
candidate: { matched: true, visualLines: [], lineTexts: [] },
|
||||
}] as never,
|
||||
{} as never,
|
||||
);
|
||||
|
||||
expect(comparison?.status).toBe("failed");
|
||||
expect(comparison?.issues).toEqual([
|
||||
expect.objectContaining({
|
||||
code: "SEMANTIC_BLOCK_VISUAL_UNAVAILABLE",
|
||||
severity: "failure",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("跨引擎换行数不同时按字符区间配对视觉行而不是返回空观测", async () => {
|
||||
const page = {
|
||||
pageNumber: 1,
|
||||
widthPt: 400,
|
||||
heightPt: 400,
|
||||
lines: [],
|
||||
raster: whitePageRaster(),
|
||||
};
|
||||
const visualLine = (y: number) => ({
|
||||
pageNumber: 1,
|
||||
bounds: { x: 40, y, width: 120, height: 12 },
|
||||
fontFamilies: ["Test Sans"],
|
||||
});
|
||||
const [comparison] = await comparePdfSemanticBlockVisuals(
|
||||
{ pages: [page] } as never,
|
||||
{ pages: [page] } as never,
|
||||
[{
|
||||
expectation: {
|
||||
index: 7,
|
||||
section: "body",
|
||||
blockKind: "table-cell",
|
||||
},
|
||||
baseline: {
|
||||
matched: true,
|
||||
visualLines: [visualLine(40), visualLine(80)],
|
||||
lineTexts: ["甲乙", "丙丁"],
|
||||
},
|
||||
candidate: {
|
||||
matched: true,
|
||||
visualLines: [visualLine(40)],
|
||||
lineTexts: ["甲乙丙丁"],
|
||||
},
|
||||
}] as never,
|
||||
{
|
||||
pixelDifferenceThreshold: 8,
|
||||
spatialTolerancePx: 4,
|
||||
minInkIou: 0.75,
|
||||
minEdgeIou: 0.75,
|
||||
maxBackgroundColorDelta: 16,
|
||||
maxForegroundColorDelta: 16,
|
||||
maxMeanAbsoluteError: 20,
|
||||
maxChangedPixelRatio: 0.35,
|
||||
minAntialiasEquivalentInkIou: 0.9,
|
||||
minAntialiasEquivalentEdgeIou: 0.9,
|
||||
} as never,
|
||||
);
|
||||
|
||||
expect(comparison?.status).toBe("warning");
|
||||
expect(comparison?.lines).toHaveLength(2);
|
||||
expect(comparison?.issues).not.toContainEqual(
|
||||
expect.objectContaining({ code: "SEMANTIC_BLOCK_VISUAL_UNAVAILABLE" }),
|
||||
);
|
||||
expect(comparison?.lines.every((line) =>
|
||||
line.issues.some((issue) => issue.details?.textReflow === true)
|
||||
)).toBe(true);
|
||||
});
|
||||
|
||||
it("PDF 文本提取仅遗漏长文本末尾单字时仍比较已有视觉行", async () => {
|
||||
const page = {
|
||||
pageNumber: 1,
|
||||
widthPt: 400,
|
||||
heightPt: 400,
|
||||
lines: [],
|
||||
raster: blankPageRaster(),
|
||||
};
|
||||
const visualLine = (y: number) => ({
|
||||
pageNumber: 1,
|
||||
bounds: { x: 40, y, width: 108, height: 12 },
|
||||
fontFamilies: ["Test Sans"],
|
||||
});
|
||||
const [comparison] = await comparePdfSemanticBlockVisuals(
|
||||
{ pages: [page] } as never,
|
||||
{ pages: [page] } as never,
|
||||
[{
|
||||
expectation: {
|
||||
index: 118,
|
||||
section: "body",
|
||||
blockKind: "table-header",
|
||||
},
|
||||
baseline: {
|
||||
matched: true,
|
||||
matchedCharacterCount: 10,
|
||||
expectedCharacterCount: 10,
|
||||
visualLines: [visualLine(40), visualLine(70)],
|
||||
lineTexts: ["高血压预测模型目标", "值"],
|
||||
},
|
||||
candidate: {
|
||||
matched: false,
|
||||
matchedCharacterCount: 9,
|
||||
expectedCharacterCount: 10,
|
||||
visualLines: [visualLine(40)],
|
||||
lineTexts: ["高血压预测模型目标"],
|
||||
},
|
||||
}] as never,
|
||||
{
|
||||
pixelDifferenceThreshold: 8,
|
||||
spatialTolerancePx: 4,
|
||||
minInkIou: 0.75,
|
||||
minEdgeIou: 0.75,
|
||||
maxBackgroundColorDelta: 16,
|
||||
maxForegroundColorDelta: 16,
|
||||
maxMeanAbsoluteError: 20,
|
||||
maxChangedPixelRatio: 0.35,
|
||||
minAntialiasEquivalentInkIou: 0.9,
|
||||
minAntialiasEquivalentEdgeIou: 0.9,
|
||||
} as never,
|
||||
);
|
||||
|
||||
expect(comparison?.lines).toHaveLength(1);
|
||||
expect(comparison?.issues).not.toContainEqual(
|
||||
expect.objectContaining({ code: "SEMANTIC_BLOCK_VISUAL_UNAVAILABLE" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("表格末尾数学字符未进入 PDF 文本层时仍比较已有视觉行", async () => {
|
||||
const page = {
|
||||
pageNumber: 1,
|
||||
widthPt: 400,
|
||||
heightPt: 400,
|
||||
lines: [],
|
||||
raster: blankPageRaster(),
|
||||
};
|
||||
const visualLine = {
|
||||
pageNumber: 1,
|
||||
bounds: { x: 40, y: 40, width: 108, height: 12 },
|
||||
fontFamilies: ["Test Serif"],
|
||||
};
|
||||
const [comparison] = await comparePdfSemanticBlockVisuals(
|
||||
{ pages: [page] } as never,
|
||||
{ pages: [page] } as never,
|
||||
[{
|
||||
expectation: {
|
||||
index: 71,
|
||||
section: "body",
|
||||
blockKind: "table-cell",
|
||||
mathCharacterIndexes: [3, 4, 5, 6, 11, 12, 13],
|
||||
},
|
||||
baseline: {
|
||||
matched: false,
|
||||
matchedCharacterCount: 12,
|
||||
expectedCharacterCount: 14,
|
||||
visualLines: [visualLine],
|
||||
lineTexts: ["收缩压≥140或舒张压≥"],
|
||||
},
|
||||
candidate: {
|
||||
matched: true,
|
||||
matchedCharacterCount: 14,
|
||||
expectedCharacterCount: 14,
|
||||
visualLines: [visualLine],
|
||||
lineTexts: ["收缩压≥140或舒张压≥90"],
|
||||
},
|
||||
}] as never,
|
||||
{
|
||||
pixelDifferenceThreshold: 8,
|
||||
spatialTolerancePx: 4,
|
||||
minInkIou: 0.75,
|
||||
minEdgeIou: 0.75,
|
||||
maxBackgroundColorDelta: 16,
|
||||
maxForegroundColorDelta: 16,
|
||||
maxMeanAbsoluteError: 20,
|
||||
maxChangedPixelRatio: 0.35,
|
||||
minAntialiasEquivalentInkIou: 0.9,
|
||||
minAntialiasEquivalentEdgeIou: 0.9,
|
||||
} as never,
|
||||
);
|
||||
|
||||
expect(comparison?.lines).toHaveLength(1);
|
||||
expect(comparison?.issues).not.toContainEqual(
|
||||
expect.objectContaining({ code: "SEMANTIC_BLOCK_VISUAL_UNAVAILABLE" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("视觉行数量相同但字符分段不同时按重叠字符区间比较", async () => {
|
||||
const page = {
|
||||
pageNumber: 1,
|
||||
widthPt: 400,
|
||||
heightPt: 400,
|
||||
lines: [],
|
||||
raster: blankPageRaster(),
|
||||
};
|
||||
const visualLine = (y: number, width: number) => ({
|
||||
pageNumber: 1,
|
||||
bounds: { x: 40, y, width, height: 12 },
|
||||
fontFamilies: ["Test Sans"],
|
||||
});
|
||||
const [comparison] = await comparePdfSemanticBlockVisuals(
|
||||
{ pages: [page] } as never,
|
||||
{ pages: [page] } as never,
|
||||
[{
|
||||
expectation: {
|
||||
index: 117,
|
||||
section: "body",
|
||||
blockKind: "table-header",
|
||||
},
|
||||
baseline: {
|
||||
matched: true,
|
||||
visualLines: [visualLine(40, 20), visualLine(70, 40), visualLine(100, 40)],
|
||||
lineTexts: ["记", "录/", "计算"],
|
||||
},
|
||||
candidate: {
|
||||
matched: true,
|
||||
visualLines: [visualLine(40, 40), visualLine(70, 40), visualLine(100, 20)],
|
||||
lineTexts: ["记录", "/计", "算"],
|
||||
},
|
||||
}] as never,
|
||||
{
|
||||
pixelDifferenceThreshold: 8,
|
||||
spatialTolerancePx: 4,
|
||||
minInkIou: 0.75,
|
||||
minEdgeIou: 0.75,
|
||||
maxBackgroundColorDelta: 16,
|
||||
maxForegroundColorDelta: 16,
|
||||
maxMeanAbsoluteError: 20,
|
||||
maxChangedPixelRatio: 0.35,
|
||||
minAntialiasEquivalentInkIou: 0.9,
|
||||
minAntialiasEquivalentEdgeIou: 0.9,
|
||||
} as never,
|
||||
);
|
||||
|
||||
expect(comparison?.status).toBe("warning");
|
||||
expect(comparison?.lines).toHaveLength(5);
|
||||
expect(comparison?.issues).not.toContainEqual(
|
||||
expect.objectContaining({ code: "ELEMENT_COLOR_MISMATCH" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("接受轮廓、颜色和几何一致的灰阶抗锯齿差异", () => {
|
||||
expect(isCrossEngineRasterEquivalent(metrics(), false)).toBe(true);
|
||||
expect(
|
||||
@@ -41,6 +325,71 @@ describe("跨引擎字形栅格等价", () => {
|
||||
false,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(
|
||||
metrics({
|
||||
baselineWidthPx: 56,
|
||||
candidateWidthPx: 56,
|
||||
baselineHeightPx: 37,
|
||||
candidateHeightPx: 37,
|
||||
comparedWidthPx: 56,
|
||||
comparedHeightPx: 37,
|
||||
inkIou: 1,
|
||||
edgeIou: 1,
|
||||
foregroundColorDelta: 17,
|
||||
baselineForegroundChroma: 0,
|
||||
candidateForegroundChroma: 0,
|
||||
foregroundLuminanceDelta: 17,
|
||||
}),
|
||||
false,
|
||||
"code-block",
|
||||
),
|
||||
).toBe(true);
|
||||
const neutralSingleStrokeMetrics = metrics({
|
||||
baselineWidthPx: 37,
|
||||
candidateWidthPx: 37,
|
||||
baselineHeightPx: 27,
|
||||
candidateHeightPx: 27,
|
||||
comparedWidthPx: 37,
|
||||
comparedHeightPx: 27,
|
||||
meanAbsoluteError: 12.78,
|
||||
inkIou: 1,
|
||||
foregroundInkIou: 1,
|
||||
edgeIou: 1,
|
||||
backgroundColorDelta: 10.54,
|
||||
foregroundColorDelta: 108.69,
|
||||
baselineForegroundChroma: 10,
|
||||
candidateForegroundChroma: 5,
|
||||
foregroundLuminanceDelta: 109.27,
|
||||
});
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(
|
||||
neutralSingleStrokeMetrics,
|
||||
false,
|
||||
"table-cell",
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(
|
||||
{ ...neutralSingleStrokeMetrics, meanAbsoluteError: 16.01 },
|
||||
false,
|
||||
"table-cell",
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(
|
||||
{ ...neutralSingleStrokeMetrics, candidateForegroundChroma: 12.01 },
|
||||
false,
|
||||
"table-cell",
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(
|
||||
{ ...neutralSingleStrokeMetrics, edgeIou: 0.994 },
|
||||
false,
|
||||
"table-cell",
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(
|
||||
metrics({ inkIou: 1, edgeIou: 0.946 }),
|
||||
@@ -97,6 +446,26 @@ describe("跨引擎字形栅格等价", () => {
|
||||
"list-item",
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(
|
||||
metrics({
|
||||
baselineWidthPx: 262,
|
||||
candidateWidthPx: 272,
|
||||
baselineHeightPx: 30,
|
||||
candidateHeightPx: 31,
|
||||
comparedWidthPx: 262,
|
||||
comparedHeightPx: 30,
|
||||
inkIou: 0.9792,
|
||||
edgeIou: 0.9869,
|
||||
foregroundColorDelta: 4.63,
|
||||
baselineForegroundChroma: 0,
|
||||
candidateForegroundChroma: 0,
|
||||
foregroundLuminanceDelta: 4.63,
|
||||
}),
|
||||
false,
|
||||
"list-item",
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(
|
||||
metrics({
|
||||
@@ -120,6 +489,147 @@ describe("跨引擎字形栅格等价", () => {
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("仅在高彩度字形轮廓与前景主色均一致时接受调色差异", () => {
|
||||
const paletteMetrics = metrics({
|
||||
inkIou: 1,
|
||||
edgeIou: 1,
|
||||
foregroundColorDelta: 35,
|
||||
baselineForegroundChroma: 141,
|
||||
candidateForegroundChroma: 176,
|
||||
dominantForegroundColorDelta: 0.5,
|
||||
});
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(paletteMetrics, false, "table-cell"),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(
|
||||
{ ...paletteMetrics, dominantForegroundColorDelta: 4.1 },
|
||||
false,
|
||||
"table-cell",
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(
|
||||
{ ...paletteMetrics, edgeIou: 0.994 },
|
||||
false,
|
||||
"table-cell",
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(
|
||||
{ ...paletteMetrics, baselineForegroundChroma: 79 },
|
||||
false,
|
||||
"table-cell",
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("多色代码行仅在语法主色和对应墨迹位置均一致时通过", () => {
|
||||
const syntaxMetrics = metrics({
|
||||
baselineWidthPx: 407,
|
||||
candidateWidthPx: 403,
|
||||
baselineHeightPx: 22,
|
||||
candidateHeightPx: 23,
|
||||
inkIou: 0.988,
|
||||
foregroundInkIou: 1,
|
||||
edgeIou: 1,
|
||||
foregroundColorDelta: 92,
|
||||
chromaticPaletteDelta: 0.8,
|
||||
});
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(syntaxMetrics, false, "code-block"),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(
|
||||
{ ...syntaxMetrics, chromaticPaletteDelta: 2.1 },
|
||||
false,
|
||||
"code-block",
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(
|
||||
{ ...syntaxMetrics, foregroundInkIou: 0.994 },
|
||||
false,
|
||||
"code-block",
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(syntaxMetrics, false, "paragraph"),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("仅在行内代码轮廓和底纹严格一致时接受微小前景色差", () => {
|
||||
const inlineCodeMetrics = metrics({
|
||||
baselineWidthPx: 236,
|
||||
candidateWidthPx: 234,
|
||||
baselineHeightPx: 24,
|
||||
candidateHeightPx: 25,
|
||||
comparedWidthPx: 236,
|
||||
comparedHeightPx: 25,
|
||||
inkIou: 0.91759,
|
||||
edgeIou: 1,
|
||||
backgroundColorDelta: 11.317,
|
||||
foregroundColorDelta: 3.158,
|
||||
});
|
||||
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(
|
||||
inlineCodeMetrics,
|
||||
false,
|
||||
"paragraph",
|
||||
true,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(
|
||||
{ ...inlineCodeMetrics, foregroundColorDelta: 3.26 },
|
||||
false,
|
||||
"paragraph",
|
||||
true,
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(
|
||||
{ ...inlineCodeMetrics, backgroundColorDelta: 12.01 },
|
||||
false,
|
||||
"paragraph",
|
||||
true,
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(
|
||||
{ ...inlineCodeMetrics, edgeIou: 0.994 },
|
||||
false,
|
||||
"paragraph",
|
||||
true,
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(
|
||||
{
|
||||
...inlineCodeMetrics,
|
||||
inkIou: 0.88,
|
||||
foregroundInkIou: 0.99,
|
||||
},
|
||||
false,
|
||||
"table-cell",
|
||||
true,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(
|
||||
{
|
||||
...inlineCodeMetrics,
|
||||
inkIou: 0.88,
|
||||
foregroundInkIou: 0.97,
|
||||
},
|
||||
false,
|
||||
"table-cell",
|
||||
true,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("拒绝回流、颜色、几何或轮廓变化", () => {
|
||||
expect(isCrossEngineRasterEquivalent(metrics(), true)).toBe(false);
|
||||
expect(
|
||||
@@ -169,6 +679,42 @@ describe("跨引擎字形栅格等价", () => {
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(metrics({ edgeIou: 0.96 }), false),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(
|
||||
metrics({
|
||||
baselineWidthPx: 262,
|
||||
candidateWidthPx: 272,
|
||||
baselineHeightPx: 30,
|
||||
candidateHeightPx: 31,
|
||||
inkIou: 0.9792,
|
||||
edgeIou: 0.9869,
|
||||
foregroundColorDelta: 4.63,
|
||||
baselineForegroundChroma: 2.01,
|
||||
candidateForegroundChroma: 0,
|
||||
foregroundLuminanceDelta: 4.63,
|
||||
}),
|
||||
false,
|
||||
"list-item",
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(
|
||||
metrics({
|
||||
baselineWidthPx: 262,
|
||||
candidateWidthPx: 272,
|
||||
baselineHeightPx: 30,
|
||||
candidateHeightPx: 31,
|
||||
inkIou: 0.9792,
|
||||
edgeIou: 0.9869,
|
||||
foregroundColorDelta: 5.01,
|
||||
baselineForegroundChroma: 0,
|
||||
candidateForegroundChroma: 0,
|
||||
foregroundLuminanceDelta: 5.01,
|
||||
}),
|
||||
false,
|
||||
"list-item",
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(
|
||||
metrics({
|
||||
|
||||
@@ -40,9 +40,88 @@ describe("PDF 文本行聚合", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("合并 Office 同一视觉行内小于字体高度两成的基线漂移", () => {
|
||||
const left = item("源系统", 10, 100, 30, 10);
|
||||
const middle = item("nut_pickup_order.meal_type", 42, 101.8, 140, 10);
|
||||
const right = item("原值", 184, 100, 20, 10);
|
||||
const lines = aggregatePdfTextLines([left, middle, right], 842);
|
||||
|
||||
expect(lines).toHaveLength(1);
|
||||
expect(lines[0]?.normalizedText).toBe(
|
||||
"源系统nut_pickup_order.meal_type原值",
|
||||
);
|
||||
});
|
||||
|
||||
it("将同行公式的上标片段按横坐标并回正文", () => {
|
||||
const lines = aggregatePdfTextLines(
|
||||
[
|
||||
item("减因子", 10, 100, 42, 12),
|
||||
item("e", 53, 97, 8, 15),
|
||||
item("−λΔt", 62, 96, 24, 10),
|
||||
item("的动态权重", 87, 100, 70, 12),
|
||||
],
|
||||
842,
|
||||
);
|
||||
|
||||
expect(lines).toHaveLength(1);
|
||||
expect(lines[0]?.normalizedText).toBe("减因子e−λΔt的动态权重");
|
||||
});
|
||||
|
||||
it("将超出正文右缘但紧邻行末的公式片段并回正文", () => {
|
||||
const lines = aggregatePdfTextLines(
|
||||
[
|
||||
item("性、记忆重要程度、时效性衰减权重(须支持基于时间衰减因子 e", 150, 469.5, 346, 12),
|
||||
item("−λΔt", 495.7, 466.1, 26, 10.2),
|
||||
item("下一视觉行", 150, 489.7, 60, 12),
|
||||
],
|
||||
842,
|
||||
);
|
||||
|
||||
expect(lines.map((line) => line.normalizedText)).toEqual([
|
||||
"性、记忆重要程度、时效性衰减权重(须支持基于时间衰减因子 e−λΔt",
|
||||
"下一视觉行",
|
||||
]);
|
||||
});
|
||||
|
||||
it("将字体 ToUnicode 中的传统户部件归一为简体字符", () => {
|
||||
expect(normalizePdfText("戶⼾")).toBe("户户");
|
||||
expect(normalizePdfText("⻅⻆⻓⻔⻚⻛")).toBe("见角长门页风");
|
||||
expect(normalizePdfText("⻅⻆⻓⻔⻚⻛⻝⻣⻋⻬")).toBe(
|
||||
"见角长门页风食骨车齐",
|
||||
);
|
||||
});
|
||||
|
||||
it("统一 Chromium 与 Office PDF 文本层的中英文弯引号", () => {
|
||||
expect(normalizePdfText("“记录”与‘计算’")).toBe('"记录"与\'计算\'');
|
||||
});
|
||||
|
||||
it("保留与全角括号重叠的窄引号文本流顺序", () => {
|
||||
const lines = aggregatePdfTextLines(
|
||||
[
|
||||
item("主观定性判断", 532.3, 151.5, 58.5),
|
||||
item("”", 590.8, 151.5, 3.4),
|
||||
item("(如", 589.3, 151.5, 19.5),
|
||||
item("“", 608.8, 151.5, 3.4),
|
||||
item("高心率占比", 612.2, 151.5, 48.8),
|
||||
],
|
||||
595,
|
||||
);
|
||||
|
||||
expect(lines).toHaveLength(1);
|
||||
expect(lines[0]?.normalizedText).toBe('主观定性判断"(如"高心率占比');
|
||||
});
|
||||
|
||||
it("忽略 Emoji 文本层可选的变体选择符", () => {
|
||||
expect(normalizePdfText("⚠️ 提示")).toBe("⚠ 提示");
|
||||
});
|
||||
|
||||
it("统一 CJK 字体文本层的 em dash 与 horizontal bar", () => {
|
||||
expect(normalizePdfText("仓库―仓区")).toBe("仓库—仓区");
|
||||
});
|
||||
|
||||
it("统一数字区间中被 Chromium 提取为双连字符的 en dash", () => {
|
||||
expect(normalizePdfText("10% -- 20%")).toBe("10%–20%");
|
||||
expect(normalizePdfText("10% --")).toBe("10%–");
|
||||
expect(normalizePdfText("命令 --flag")).toBe("命令 --flag");
|
||||
});
|
||||
|
||||
it("从可编辑正文契约中移除 Word 自动列表装饰符", () => {
|
||||
|
||||
@@ -98,6 +98,64 @@ local function replace_code_block(block)
|
||||
return pandoc.Para { image }
|
||||
end
|
||||
|
||||
local function preserve_raw_html_as_text(inline)
|
||||
if inline.format == "html" then
|
||||
return pandoc.Str(inline.text)
|
||||
end
|
||||
return inline
|
||||
end
|
||||
|
||||
local function preserve_raw_html_block_as_text(block)
|
||||
if block.format == "html" then
|
||||
return pandoc.Para { pandoc.Str(block.text) }
|
||||
end
|
||||
return block
|
||||
end
|
||||
|
||||
local function replace_table_breaks(table_block)
|
||||
return table_block:walk {
|
||||
RawInline = function(inline)
|
||||
if inline.format ~= "html" then
|
||||
return inline
|
||||
end
|
||||
local normalized = string.lower(inline.text)
|
||||
if normalized == "<br>"
|
||||
or normalized == "<br/>"
|
||||
or normalized == "<br />" then
|
||||
return pandoc.LineBreak()
|
||||
end
|
||||
return inline
|
||||
end
|
||||
}
|
||||
end
|
||||
|
||||
local function normalize_task_list(list)
|
||||
for _, item in ipairs(list.content) do
|
||||
local block = item[1]
|
||||
if block ~= nil and (block.t == "Plain" or block.t == "Para") then
|
||||
local inlines = block.content
|
||||
local first = inlines[1]
|
||||
if first ~= nil and first.t == "Str" then
|
||||
local marker = string.lower(first.text)
|
||||
if marker == "[x]" then
|
||||
first.text = "☒"
|
||||
elseif marker == "[" then
|
||||
local second = inlines[2]
|
||||
local third = inlines[3]
|
||||
if second ~= nil and second.t == "Space"
|
||||
and third ~= nil and third.t == "Str"
|
||||
and third.text == "]" then
|
||||
first.text = "☐"
|
||||
inlines:remove(3)
|
||||
inlines:remove(2)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
return list
|
||||
end
|
||||
|
||||
local function append_inlines(target, value)
|
||||
target:extend(pandoc.Inlines(value))
|
||||
end
|
||||
@@ -167,7 +225,10 @@ local function validate_counts()
|
||||
error(
|
||||
"DOCX media map contains unused "
|
||||
.. kind
|
||||
.. " resources"
|
||||
.. " resources: consumed "
|
||||
.. tostring(counters[kind])
|
||||
.. " of "
|
||||
.. tostring(#media_map[kind])
|
||||
)
|
||||
end
|
||||
end
|
||||
@@ -176,8 +237,14 @@ end
|
||||
return {
|
||||
Pandoc = function(document)
|
||||
local transformed = document:walk {
|
||||
Table = replace_table_breaks
|
||||
}
|
||||
transformed = transformed:walk {
|
||||
Image = replace_image,
|
||||
CodeBlock = replace_code_block
|
||||
CodeBlock = replace_code_block,
|
||||
RawInline = preserve_raw_html_as_text,
|
||||
RawBlock = preserve_raw_html_block_as_text,
|
||||
BulletList = normalize_task_list
|
||||
}
|
||||
if structure_plan.titlePolicy.metadataTitle == "suppress" then
|
||||
transformed.meta.title = nil
|
||||
|
||||
@@ -424,7 +424,7 @@ for (const theme of themes) {
|
||||
let conversion;
|
||||
try {
|
||||
conversion = await converter.convert({
|
||||
markdown,
|
||||
markdown: rendered.markdownBody,
|
||||
fileName: `${theme.id}.md`,
|
||||
language: rendered.metadata.language,
|
||||
exportConfig,
|
||||
@@ -433,7 +433,7 @@ for (const theme of themes) {
|
||||
metadata: rendered.metadata,
|
||||
semanticDocument: rendered.semanticDocument,
|
||||
fonts: readThemeFonts(theme),
|
||||
media: createMedia(markdown)
|
||||
media: createMedia(rendered.markdownBody)
|
||||
});
|
||||
} catch (error) {
|
||||
throw new Error(`主题 ${theme.id} 的 DOCX 转换失败`, {
|
||||
|
||||
@@ -44,6 +44,8 @@ type DocxBorderToken = NonNullable<
|
||||
|
||||
const WORDPROCESSING_DRAWING_NAMESPACE =
|
||||
"http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing";
|
||||
const MATH_NAMESPACE =
|
||||
"http://schemas.openxmlformats.org/officeDocument/2006/math";
|
||||
const XML_NAMESPACE = "http://www.w3.org/XML/1998/namespace";
|
||||
// Chromium 预留 6pt;Word/WPS 的分节承载段落还需要额外分页保留量。
|
||||
const COVER_PAGE_BREAK_SAFETY_PT = 6;
|
||||
@@ -2064,9 +2066,10 @@ function applyDirectCharacterStyleTokens(
|
||||
const inlineCodeLayouts = documentLayout.inlineCodes ?? [];
|
||||
let inlineCodeCursor = 0;
|
||||
let count = 0;
|
||||
for (const run of Array.from(
|
||||
const runs = Array.from(
|
||||
document.getElementsByTagNameNS(WORD_NAMESPACE, "r")
|
||||
)) {
|
||||
);
|
||||
for (const [runIndex, run] of runs.entries()) {
|
||||
const properties = firstDirectChild(
|
||||
run,
|
||||
WORD_NAMESPACE,
|
||||
@@ -2099,8 +2102,29 @@ function applyDirectCharacterStyleTokens(
|
||||
const isSourceCode = paragraphStyle
|
||||
? wordAttribute(paragraphStyle, "val") === "SourceCode"
|
||||
: false;
|
||||
const previousRun = runs[runIndex - 1];
|
||||
const nextRun = runs[runIndex + 1];
|
||||
const isHtmlTagName =
|
||||
isSourceCode &&
|
||||
styleId === "KeywordTok" &&
|
||||
previousRun?.parentNode === run.parentNode &&
|
||||
nextRun?.parentNode === run.parentNode &&
|
||||
/^<\/?$/u.test(previousRun.textContent ?? "") &&
|
||||
/^\/?>$/u.test(nextRun.textContent ?? "");
|
||||
const isObjectPropertyKey =
|
||||
isSourceCode &&
|
||||
styleId === "NormalTok" &&
|
||||
nextRun?.parentNode === run.parentNode &&
|
||||
/^\s*:\s*$/u.test(nextRun.textContent ?? "");
|
||||
const syntaxSlot = isSourceCode && styleId
|
||||
? resolvePandocSyntaxStyleSlot(styleId, run.textContent ?? "")
|
||||
? resolvePandocSyntaxStyleSlot(
|
||||
styleId,
|
||||
run.textContent ?? "",
|
||||
{
|
||||
htmlTagName: isHtmlTagName,
|
||||
objectPropertyKey: isObjectPropertyKey
|
||||
}
|
||||
)
|
||||
: undefined;
|
||||
const token = isSourceCode
|
||||
? syntaxSlot
|
||||
@@ -2157,6 +2181,37 @@ function applyDirectCharacterStyleTokens(
|
||||
return count;
|
||||
}
|
||||
|
||||
function applyMeasuredEmojiRunColors(
|
||||
document: XmlDocument,
|
||||
documentLayout: DocxDocumentLayoutPlan
|
||||
) {
|
||||
const measuredRuns = documentLayout.emojiRuns ?? [];
|
||||
let cursor = 0;
|
||||
let count = 0;
|
||||
for (const run of Array.from(
|
||||
document.getElementsByTagNameNS(WORD_NAMESPACE, "r")
|
||||
)) {
|
||||
const text = (run.textContent ?? "").normalize("NFC").trim();
|
||||
if (!text) {
|
||||
continue;
|
||||
}
|
||||
const measuredIndex = measuredRuns.findIndex(
|
||||
(layout, index) => index >= cursor && layout.text === text
|
||||
);
|
||||
if (measuredIndex < 0) {
|
||||
continue;
|
||||
}
|
||||
cursor = measuredIndex + 1;
|
||||
const properties = ensureFirstElement(run, "rPr", "w:rPr");
|
||||
removeDirectChildren(properties, WORD_NAMESPACE, "color");
|
||||
appendElement(properties, WORD_NAMESPACE, "w:color", {
|
||||
"w:val": colorValue(measuredRuns[measuredIndex]!.color)
|
||||
});
|
||||
count += 1;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
function applyTableCellToken(
|
||||
cell: XmlElement,
|
||||
token: DocxSlotStyleToken
|
||||
@@ -2221,15 +2276,24 @@ function applyMeasuredTableGeometry(
|
||||
table: XmlElement,
|
||||
properties: XmlElement,
|
||||
layout: DocxDocumentLayoutPlan["tables"][number],
|
||||
contentWidthTwips: number
|
||||
contentWidthTwips: number,
|
||||
leadingCellInsetTwips: number
|
||||
) {
|
||||
const tableWidthTwips = Math.max(
|
||||
const indentTwips = Math.max(
|
||||
0,
|
||||
Math.round(contentWidthTwips * layout.leftOffsetPercent / 100) +
|
||||
leadingCellInsetTwips
|
||||
);
|
||||
const requestedTableWidthTwips = Math.max(
|
||||
1,
|
||||
Math.round(contentWidthTwips * layout.widthPercent / 100)
|
||||
);
|
||||
const indentTwips = Math.max(
|
||||
0,
|
||||
Math.round(contentWidthTwips * layout.leftOffsetPercent / 100)
|
||||
const tableWidthTwips = Math.max(
|
||||
1,
|
||||
Math.min(
|
||||
requestedTableWidthTwips,
|
||||
Math.max(1, contentWidthTwips - indentTwips)
|
||||
)
|
||||
);
|
||||
const columnWidths = measuredColumnWidths(
|
||||
tableWidthTwips,
|
||||
@@ -2315,7 +2379,11 @@ function applyTables(
|
||||
table,
|
||||
properties,
|
||||
measuredLayout,
|
||||
contentWidthTwips
|
||||
contentWidthTwips,
|
||||
pointsToTwips(Math.max(
|
||||
tableCellToken?.paddingPt?.left ?? 0,
|
||||
tableHeaderToken?.paddingPt?.left ?? 0
|
||||
))
|
||||
);
|
||||
} else {
|
||||
removeDirectChildren(
|
||||
@@ -2427,8 +2495,15 @@ function applyTables(
|
||||
WORD_NAMESPACE,
|
||||
"p"
|
||||
)) {
|
||||
const properties = paragraphProperties(paragraph);
|
||||
const wordWrap = ensureFirstElement(
|
||||
properties,
|
||||
"wordWrap",
|
||||
"w:wordWrap"
|
||||
);
|
||||
setWordAttribute(wordWrap, "val", "1");
|
||||
const spacing = ensureFirstElement(
|
||||
paragraphProperties(paragraph),
|
||||
properties,
|
||||
"spacing",
|
||||
"w:spacing"
|
||||
);
|
||||
@@ -2436,7 +2511,7 @@ function applyTables(
|
||||
setWordAttribute(spacing, "after", "0");
|
||||
for (const property of ["autoSpaceDE", "autoSpaceDN"] as const) {
|
||||
const automaticSpacing = ensureFirstElement(
|
||||
paragraphProperties(paragraph),
|
||||
properties,
|
||||
property,
|
||||
`w:${property}`
|
||||
);
|
||||
@@ -2609,6 +2684,18 @@ function applyMeasuredListItemIndents(
|
||||
String(Math.min(targetLeft, hanging))
|
||||
);
|
||||
}
|
||||
if (measured.alignment) {
|
||||
const alignment = ensureDirectElement(
|
||||
properties,
|
||||
WORD_NAMESPACE,
|
||||
"w:jc"
|
||||
);
|
||||
setWordAttribute(
|
||||
alignment,
|
||||
"val",
|
||||
measured.alignment === "justify" ? "both" : measured.alignment
|
||||
);
|
||||
}
|
||||
appliedCount += 1;
|
||||
}
|
||||
return appliedCount;
|
||||
@@ -2727,6 +2814,33 @@ function measuredLineBreakOffsetsForParagraph(
|
||||
});
|
||||
}
|
||||
|
||||
function paragraphHasInlineCode(paragraph: XmlElement) {
|
||||
return Array.from(
|
||||
paragraph.getElementsByTagNameNS(WORD_NAMESPACE, "rStyle")
|
||||
).some((style) => wordAttribute(style, "val") === "VerbatimChar");
|
||||
}
|
||||
|
||||
function stabilizeInlineCodeParagraphLineRules(document: XmlDocument) {
|
||||
let count = 0;
|
||||
for (const paragraph of Array.from(
|
||||
document.getElementsByTagNameNS(WORD_NAMESPACE, "p")
|
||||
)) {
|
||||
if (!paragraphHasInlineCode(paragraph)) {
|
||||
continue;
|
||||
}
|
||||
const spacing = directChildren(
|
||||
paragraphProperties(paragraph),
|
||||
WORD_NAMESPACE,
|
||||
"spacing"
|
||||
)[0];
|
||||
if (spacing && wordAttribute(spacing, "lineRule") === "exact") {
|
||||
setWordAttribute(spacing, "lineRule", "atLeast");
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
function applyMeasuredTextBlockLineBreaks(
|
||||
document: XmlDocument,
|
||||
documentLayout: DocxDocumentLayoutPlan
|
||||
@@ -2765,7 +2879,11 @@ function applyMeasuredTextBlockLineBreaks(
|
||||
"line",
|
||||
String(pointsToTwips(measured.linePitchPt))
|
||||
);
|
||||
setWordAttribute(spacing, "lineRule", "exact");
|
||||
setWordAttribute(
|
||||
spacing,
|
||||
"lineRule",
|
||||
paragraphHasInlineCode(paragraph) ? "atLeast" : "exact"
|
||||
);
|
||||
}
|
||||
for (const offset of [...measuredLineBreakOffsetsForParagraph(
|
||||
paragraph,
|
||||
@@ -2781,6 +2899,47 @@ function applyMeasuredTextBlockLineBreaks(
|
||||
return appliedCount;
|
||||
}
|
||||
|
||||
function stabilizeTableMathParagraphAlignment(document: XmlDocument) {
|
||||
let count = 0;
|
||||
for (const paragraph of Array.from(
|
||||
document.getElementsByTagNameNS(WORD_NAMESPACE, "p")
|
||||
)) {
|
||||
const parent = paragraph.parentNode;
|
||||
if (
|
||||
!parent ||
|
||||
parent.nodeType !== 1 ||
|
||||
(parent as XmlElement).namespaceURI !== WORD_NAMESPACE ||
|
||||
(parent as XmlElement).localName !== "tc"
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const content = Array.from(paragraph.childNodes).filter(
|
||||
(node): node is XmlElement => node.nodeType === 1
|
||||
).filter(
|
||||
(child) =>
|
||||
!(child.namespaceURI === WORD_NAMESPACE && child.localName === "pPr")
|
||||
);
|
||||
if (
|
||||
content.length === 0 ||
|
||||
!content.every(
|
||||
(child) =>
|
||||
child.namespaceURI === MATH_NAMESPACE &&
|
||||
child.localName === "oMath"
|
||||
)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const run = appendElement(paragraph, WORD_NAMESPACE, "w:r");
|
||||
const properties = appendElement(run, WORD_NAMESPACE, "w:rPr");
|
||||
appendElement(properties, WORD_NAMESPACE, "w:noProof");
|
||||
const text = appendElement(run, WORD_NAMESPACE, "w:t");
|
||||
text.setAttributeNS(XML_NAMESPACE, "xml:space", "preserve");
|
||||
text.textContent = "\u200B";
|
||||
count += 1;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
function pageBreakAfterStyleIds(tokens: DocxThemeTokenSet) {
|
||||
const ids = new Set<string>();
|
||||
for (const entry of tokens.slots) {
|
||||
@@ -3098,6 +3257,99 @@ function disableAutomaticCharacterSpacing(document: XmlDocument) {
|
||||
}
|
||||
}
|
||||
|
||||
const PANDOC_ALERT_LABELS = new Set([
|
||||
"note",
|
||||
"tip",
|
||||
"important",
|
||||
"warning",
|
||||
"caution"
|
||||
]);
|
||||
|
||||
function normalizePandocAlertParagraphStyles(
|
||||
body: XmlElement,
|
||||
documentLayout: DocxDocumentLayoutPlan
|
||||
) {
|
||||
const children = directChildren(body, WORD_NAMESPACE, "p");
|
||||
const measuredAlerts = (documentLayout.textBlocks ?? []).filter(
|
||||
(block) => block.alertRole !== undefined
|
||||
);
|
||||
let measuredCursor = 0;
|
||||
let count = 0;
|
||||
for (const [index, paragraph] of children.entries()) {
|
||||
if (
|
||||
paragraphStyleId(paragraph) !== "FirstParagraph" ||
|
||||
!PANDOC_ALERT_LABELS.has(
|
||||
normalizedLayoutText(paragraphText(paragraph)).toLocaleLowerCase(
|
||||
"en-US"
|
||||
)
|
||||
)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const content = children[index + 1];
|
||||
if (!content || paragraphStyleId(content) !== "BodyText") {
|
||||
continue;
|
||||
}
|
||||
const style = ensureDirectElement(
|
||||
paragraphProperties(content),
|
||||
WORD_NAMESPACE,
|
||||
"w:pStyle"
|
||||
);
|
||||
setWordAttribute(style, "val", "BlockText");
|
||||
for (const [target, role] of [
|
||||
[paragraph, "title"],
|
||||
[content, "body"]
|
||||
] as const) {
|
||||
const text = normalizedLayoutText(paragraphText(target));
|
||||
const relativeMatchIndex = measuredAlerts
|
||||
.slice(measuredCursor)
|
||||
.findIndex(
|
||||
(block) =>
|
||||
block.alertRole === role &&
|
||||
normalizedLayoutText(block.text) === text
|
||||
);
|
||||
if (relativeMatchIndex < 0) {
|
||||
continue;
|
||||
}
|
||||
const measuredIndex = measuredCursor + relativeMatchIndex;
|
||||
const measured = measuredAlerts[measuredIndex]!;
|
||||
measuredCursor = measuredIndex + 1;
|
||||
const properties = paragraphProperties(target);
|
||||
const indentation = ensureDirectElement(
|
||||
properties,
|
||||
WORD_NAMESPACE,
|
||||
"w:ind"
|
||||
);
|
||||
if (measured.leftIndentPt !== undefined) {
|
||||
setWordAttribute(
|
||||
indentation,
|
||||
"left",
|
||||
String(pointsToTwips(measured.leftIndentPt))
|
||||
);
|
||||
}
|
||||
if (measured.rightIndentPt !== undefined) {
|
||||
setWordAttribute(
|
||||
indentation,
|
||||
"right",
|
||||
String(pointsToTwips(measured.rightIndentPt))
|
||||
);
|
||||
}
|
||||
applyDirectRunToken(target, {
|
||||
fontCandidates: [],
|
||||
...(measured.fontSizePt !== undefined
|
||||
? { fontSizePt: measured.fontSizePt }
|
||||
: {}),
|
||||
bold: measured.bold,
|
||||
italic: measured.italic,
|
||||
color: measured.color,
|
||||
letterSpacingPt: measured.letterSpacingPt
|
||||
});
|
||||
}
|
||||
count += 1;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
export function finalizeGeneratedDocxStructure(
|
||||
content: Uint8Array,
|
||||
plan: PandocStructurePlan,
|
||||
@@ -3141,6 +3393,7 @@ export function finalizeGeneratedDocxStructure(
|
||||
containers.editableContainerTables.size > 0,
|
||||
usesEvenAndOddPages
|
||||
);
|
||||
normalizePandocAlertParagraphStyles(body, documentLayout);
|
||||
disableAutomaticCharacterSpacing(document);
|
||||
const final = finalSection(body);
|
||||
applyMeasuredTextBlockLineBreaks(document, documentLayout);
|
||||
@@ -3160,6 +3413,9 @@ export function finalizeGeneratedDocxStructure(
|
||||
);
|
||||
applyMeasuredTextBlockAlignments(document, documentLayout);
|
||||
applyDirectCharacterStyleTokens(document, tokens, documentLayout);
|
||||
applyMeasuredEmojiRunColors(document, documentLayout);
|
||||
stabilizeInlineCodeParagraphLineRules(document);
|
||||
stabilizeTableMathParagraphAlignment(document);
|
||||
const mediaDrawingCount = normalizeMediaParagraphs(
|
||||
document,
|
||||
tokens,
|
||||
|
||||
@@ -103,6 +103,12 @@ export interface PandocDocxConverterOptions {
|
||||
referenceCacheSize?: number;
|
||||
}
|
||||
|
||||
export interface PandocDocxConversionDiagnostics {
|
||||
outcome: string;
|
||||
exitCode: number | null;
|
||||
stderr: string;
|
||||
}
|
||||
|
||||
export class PandocDocxConversionError extends Error {
|
||||
constructor(
|
||||
readonly code: Extract<
|
||||
@@ -113,11 +119,26 @@ export class PandocDocxConversionError extends Error {
|
||||
| "DOCX_OUTPUT_INVALID"
|
||||
>,
|
||||
message: string,
|
||||
options?: ErrorOptions
|
||||
options?: ErrorOptions & {
|
||||
diagnostics?: PandocDocxConversionDiagnostics;
|
||||
}
|
||||
) {
|
||||
super(message, options);
|
||||
this.name = "PandocDocxConversionError";
|
||||
this.diagnostics = options?.diagnostics;
|
||||
}
|
||||
|
||||
readonly diagnostics: PandocDocxConversionDiagnostics | undefined;
|
||||
}
|
||||
|
||||
function processDiagnostics(
|
||||
result: Awaited<ReturnType<PandocProcessRunner>>
|
||||
): PandocDocxConversionDiagnostics {
|
||||
return {
|
||||
outcome: result.outcome,
|
||||
exitCode: result.exitCode,
|
||||
stderr: result.stderr.trim().slice(-16 * 1024)
|
||||
};
|
||||
}
|
||||
|
||||
function ensureOutputLimit(value: number | undefined) {
|
||||
@@ -172,7 +193,7 @@ function pandocArguments(paths: {
|
||||
return [
|
||||
paths.markdown,
|
||||
"--from",
|
||||
"markdown+yaml_metadata_block+pipe_tables+footnotes+task_lists+tex_math_dollars-raw_html",
|
||||
"commonmark_x-yaml_metadata_block+pipe_tables+footnotes+task_lists+tex_math_dollars+raw_html",
|
||||
"--to",
|
||||
"docx",
|
||||
"--standalone",
|
||||
@@ -351,7 +372,8 @@ export class PandocDocxConverter {
|
||||
processResult.outcome === "not-found"
|
||||
? "DOCX_RUNTIME_NOT_FOUND"
|
||||
: "DOCX_GENERATION_FAILED",
|
||||
"Pandoc 未能生成 DOCX"
|
||||
"Pandoc 未能生成 DOCX",
|
||||
{ diagnostics: processDiagnostics(processResult) }
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@ import {
|
||||
DOCX_MEDIA_CSS_DPI,
|
||||
MAXIMUM_DOCX_MEDIA_BYTES,
|
||||
MAXIMUM_DOCX_MEDIA_EDGE_PIXELS,
|
||||
MAXIMUM_DOCX_MEDIA_COUNT,
|
||||
MAXIMUM_DOCX_MEDIA_PIXELS,
|
||||
MAXIMUM_DOCX_RESOURCE_COUNT,
|
||||
MAXIMUM_DOCX_TOTAL_MEDIA_BYTES,
|
||||
type DocxMediaKind,
|
||||
type DocxDocumentLayoutPlan,
|
||||
@@ -101,7 +101,7 @@ export function preparePandocMedia(
|
||||
if (media.echartsErrors.length || media.mermaidErrors.length) {
|
||||
throw new Error("DOCX 图表渲染存在错误,已中止转换");
|
||||
}
|
||||
if (media.resources.length > MAXIMUM_DOCX_RESOURCE_COUNT) {
|
||||
if (media.resources.length > MAXIMUM_DOCX_MEDIA_COUNT) {
|
||||
throw new Error("DOCX 媒体数量超过限制");
|
||||
}
|
||||
|
||||
|
||||
@@ -139,6 +139,19 @@ export function transformSettingsXml(
|
||||
) {
|
||||
const document = parseXmlPart(content, "word/settings.xml");
|
||||
const settings = document.documentElement!;
|
||||
const compatibility =
|
||||
firstDirectChild(settings, WORD_NAMESPACE, "compat") ??
|
||||
appendElement(settings, WORD_NAMESPACE, "w:compat");
|
||||
removeDirectChildren(
|
||||
compatibility,
|
||||
WORD_NAMESPACE,
|
||||
"doNotExpandShiftReturn"
|
||||
);
|
||||
appendElement(
|
||||
compatibility,
|
||||
WORD_NAMESPACE,
|
||||
"w:doNotExpandShiftReturn"
|
||||
);
|
||||
removeDirectChildren(
|
||||
settings,
|
||||
WORD_NAMESPACE,
|
||||
|
||||
@@ -467,8 +467,13 @@ function applyTokenParagraphStyle(
|
||||
(token.paddingPt?.right ?? 0) > 0 ||
|
||||
(token.borders?.right?.widthPt ?? 0) > 0
|
||||
? (token.rightIndentPt ?? 0) +
|
||||
(token.paddingPt?.right ?? 0) +
|
||||
(token.borders?.right?.widthPt ?? 0)
|
||||
// 带边框代码块通过 w:pBdr/w:space 表达 CSS 右内边距。
|
||||
// 若再把 padding/border 写进 w:ind,Word/WPS 会重复扣减
|
||||
// 内容宽度,使本可容纳的等宽代码行在末尾额外回流。
|
||||
(horizontalPaddingThroughBorderSpace && token.borders?.right
|
||||
? 0
|
||||
: (token.paddingPt?.right ?? 0) +
|
||||
(token.borders?.right?.widthPt ?? 0))
|
||||
: undefined;
|
||||
if (
|
||||
token.firstLineIndentPt !== undefined ||
|
||||
@@ -1191,12 +1196,23 @@ function applyTokenStyles(
|
||||
binding.styleId === "ImageCaption"
|
||||
? { ...entry.style, alignment: "center" as const }
|
||||
: entry.style;
|
||||
applyTokenParagraphStyle(
|
||||
ensureDirectElement(
|
||||
target,
|
||||
const paragraphProperties = ensureDirectElement(
|
||||
target,
|
||||
WORD_NAMESPACE,
|
||||
"w:pPr"
|
||||
);
|
||||
// SourceCode 的主题令牌来自浏览器完整计算样式。背景透明时,
|
||||
// normalizer 会省略 backgroundColor;这里必须先清除基础模板的
|
||||
// 兜底底纹,否则模板灰底会泄漏成主题代码块的整段背景。
|
||||
if (binding.styleId === "SourceCode") {
|
||||
removeDirectChildren(
|
||||
paragraphProperties,
|
||||
WORD_NAMESPACE,
|
||||
"w:pPr"
|
||||
),
|
||||
"shd"
|
||||
);
|
||||
}
|
||||
applyTokenParagraphStyle(
|
||||
paragraphProperties,
|
||||
paragraphToken,
|
||||
fallbackFontSizeForSlot(slot, fallback),
|
||||
binding.styleId === "SourceCode"
|
||||
@@ -1205,12 +1221,20 @@ function applyTokenStyles(
|
||||
const runToken = binding.styleId === "SourceCode"
|
||||
? slots.get("code-block-text")?.style ?? entry.style
|
||||
: entry.style;
|
||||
applyTokenRunStyle(
|
||||
ensureDirectElement(
|
||||
target,
|
||||
const runProperties = ensureDirectElement(
|
||||
target,
|
||||
WORD_NAMESPACE,
|
||||
"w:rPr"
|
||||
);
|
||||
if (binding.styleId === "SourceCode") {
|
||||
removeDirectChildren(
|
||||
runProperties,
|
||||
WORD_NAMESPACE,
|
||||
"w:rPr"
|
||||
),
|
||||
"shd"
|
||||
);
|
||||
}
|
||||
applyTokenRunStyle(
|
||||
runProperties,
|
||||
runToken,
|
||||
fallbackFontsForSlot(slot, fallback),
|
||||
binding.styleId === "SourceCode" ||
|
||||
|
||||
@@ -210,17 +210,25 @@ const pandocSyntaxSlots: Readonly<
|
||||
|
||||
export function resolvePandocSyntaxStyleSlot(
|
||||
styleId: string,
|
||||
text = ""
|
||||
text = "",
|
||||
context: { htmlTagName?: boolean; objectPropertyKey?: boolean } = {}
|
||||
): DocxStyleSlotName | "code-block-text" | undefined {
|
||||
if (!(PANDOC_SYNTAX_STYLE_IDS as readonly string[]).includes(styleId)) {
|
||||
return undefined;
|
||||
}
|
||||
if (context.htmlTagName) {
|
||||
return "code-token-name";
|
||||
}
|
||||
if (context.objectPropertyKey) {
|
||||
return "code-token-attribute";
|
||||
}
|
||||
if (
|
||||
styleId === "NormalTok" ||
|
||||
styleId === "OtherTok" ||
|
||||
(styleId === "FunctionTok" && /^[\p{P}\p{S}\s]+$/u.test(text))
|
||||
((styleId === "FunctionTok" || styleId === "DataTypeTok") &&
|
||||
/^[\p{P}\p{S}\s]+$/u.test(text))
|
||||
) {
|
||||
return styleId === "FunctionTok"
|
||||
return styleId === "FunctionTok" || styleId === "DataTypeTok"
|
||||
? "code-token-punctuation"
|
||||
: "code-block-text";
|
||||
}
|
||||
|
||||
@@ -22,6 +22,8 @@ const drawing =
|
||||
"http://schemas.openxmlformats.org/drawingml/2006/main";
|
||||
const picture =
|
||||
"http://schemas.openxmlformats.org/drawingml/2006/picture";
|
||||
const math =
|
||||
"http://schemas.openxmlformats.org/officeDocument/2006/math";
|
||||
|
||||
function generatedFixture() {
|
||||
const baseline = readReferenceDocxPackage(
|
||||
@@ -38,6 +40,14 @@ function generatedFixture() {
|
||||
`<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>` +
|
||||
`<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="SourceCode"/></w:pPr>` +
|
||||
`<w:r><w:rPr><w:rStyle w:val="DataTypeTok"/></w:rPr><w:t><</w:t></w:r>` +
|
||||
`<w:r><w:rPr><w:rStyle w:val="KeywordTok"/></w:rPr><w:t>br</w:t></w:r>` +
|
||||
`<w:r><w:rPr><w:rStyle w:val="DataTypeTok"/></w:rPr><w:t>></w:t></w:r></w:p>` +
|
||||
`<w:p><w:pPr><w:pStyle w:val="SourceCode"/></w:pPr>` +
|
||||
`<w:r><w:rPr><w:rStyle w:val="NormalTok"/></w:rPr><w:t xml:space="preserve"> method</w:t></w:r>` +
|
||||
`<w:r><w:rPr><w:rStyle w:val="OperatorTok"/></w:rPr><w:t>:</w:t></w:r>` +
|
||||
`<w:r><w:rPr><w:rStyle w:val="StringTok"/></w:rPr><w:t xml:space="preserve"> "POST"</w:t></w:r></w:p>` +
|
||||
`<w:p><w:pPr><w:pStyle w:val="FirstParagraph"/></w:pPr><w:r><w:drawing>` +
|
||||
`<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>` +
|
||||
@@ -183,6 +193,46 @@ const tokens: DocxThemeTokenSet = {
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
slot: "code-block-text",
|
||||
source: "computed-css",
|
||||
confidence: "exact",
|
||||
style: {
|
||||
fontCandidates: ["Code Face"],
|
||||
fontSizePt: 9,
|
||||
color: "#24292e"
|
||||
}
|
||||
},
|
||||
{
|
||||
slot: "code-token-punctuation",
|
||||
source: "computed-css",
|
||||
confidence: "exact",
|
||||
style: {
|
||||
fontCandidates: ["Code Face"],
|
||||
fontSizePt: 9,
|
||||
color: "#7a7a7a"
|
||||
}
|
||||
},
|
||||
{
|
||||
slot: "code-token-name",
|
||||
source: "computed-css",
|
||||
confidence: "exact",
|
||||
style: {
|
||||
fontCandidates: ["Code Face"],
|
||||
fontSizePt: 9,
|
||||
color: "#22863a"
|
||||
}
|
||||
},
|
||||
{
|
||||
slot: "code-token-attribute",
|
||||
source: "computed-css",
|
||||
confidence: "exact",
|
||||
style: {
|
||||
fontCandidates: ["Code Face"],
|
||||
fontSizePt: 9,
|
||||
color: "#005cc5"
|
||||
}
|
||||
},
|
||||
{
|
||||
slot: "table-header",
|
||||
source: "computed-css",
|
||||
@@ -292,7 +342,12 @@ describe("生成 DOCX 结构收口", () => {
|
||||
documentXml.match(
|
||||
/<w:spacing[^>]*w:before="0"[^>]*w:after="0"[^>]*w:line="360"[^>]*w:lineRule="exact"/gu
|
||||
)
|
||||
).toHaveLength(2);
|
||||
).toHaveLength(1);
|
||||
expect(
|
||||
documentXml.match(
|
||||
/<w:spacing[^>]*w:before="0"[^>]*w:after="0"[^>]*w:line="360"[^>]*w:lineRule="atLeast"/gu
|
||||
)
|
||||
).toHaveLength(1);
|
||||
expect(documentXml.match(/w:fill="17324D"/gu)).toHaveLength(2);
|
||||
expect(documentXml.match(/w:val="FFFFFF"/gu)).toHaveLength(2);
|
||||
expect(
|
||||
@@ -304,6 +359,12 @@ describe("生成 DOCX 结构收口", () => {
|
||||
expect(tableParagraph).toContain('<w:autoSpaceDE w:val="0"/>');
|
||||
expect(tableParagraph).toContain('<w:autoSpaceDN w:val="0"/>');
|
||||
expect(documentXml).toContain("<w:cantSplit/>");
|
||||
expect(documentXml).toMatch(
|
||||
/<w:p><w:pPr><w:pStyle w:val="SourceCode"\/>[\s\S]*?<w:color w:val="7A7A7A"\/>[\s\S]*?<w:t><<\/w:t>[\s\S]*?<w:color w:val="22863A"\/>[\s\S]*?<w:t>br<\/w:t>[\s\S]*?<w:color w:val="7A7A7A"\/>[\s\S]*?<w:t>><\/w:t>[\s\S]*?<\/w:p>/u
|
||||
);
|
||||
expect(documentXml).toMatch(
|
||||
/<w:p><w:pPr><w:pStyle w:val="SourceCode"\/>[\s\S]*?<w:rStyle w:val="NormalTok"\/>[\s\S]*?<w:color w:val="005CC5"\/>[\s\S]*?<w:t xml:space="preserve"> method<\/w:t>[\s\S]*?<\/w:p>/u
|
||||
);
|
||||
expect(documentXml).toContain(
|
||||
'<w:tab w:val="right" w:pos="9806"/>'
|
||||
);
|
||||
@@ -389,6 +450,98 @@ describe("生成 DOCX 结构收口", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("含行内代码的实测行距允许 Word 扩展行盒", () => {
|
||||
const fixture = readGeneratedDocxPackage(generatedFixture());
|
||||
const documentXml = decoder.decode(
|
||||
fixture.entries.get("word/document.xml")!
|
||||
).replace(
|
||||
"<w:sectPr>",
|
||||
'<w:p><w:r><w:t>前缀</w:t></w:r><w:r><w:rPr><w:rStyle w:val="VerbatimChar"/></w:rPr><w:t>inline</w:t></w:r><w:r><w:t>后缀</w:t></w:r></w:p><w:sectPr>'
|
||||
);
|
||||
fixture.entries.set("word/document.xml", encoder.encode(documentXml));
|
||||
|
||||
const result = finalizeGeneratedDocxStructure(
|
||||
writeGeneratedDocxPackage(fixture.entries),
|
||||
plan,
|
||||
tokens,
|
||||
[],
|
||||
{
|
||||
tables: [],
|
||||
textBlocks: [{
|
||||
ordinal: 1,
|
||||
text: "前缀inline后缀",
|
||||
letterSpacingPt: 0,
|
||||
linePitchPt: 11.15,
|
||||
lineBreakOffsets: []
|
||||
}]
|
||||
}
|
||||
);
|
||||
const outputXml = decoder.decode(
|
||||
readGeneratedDocxPackage(result.content).entries.get(
|
||||
"word/document.xml"
|
||||
)!
|
||||
);
|
||||
|
||||
expect(outputXml).toMatch(
|
||||
/w:line="223" w:lineRule="atLeast"[\s\S]*?<w:t>前缀<\/w:t>[\s\S]*?<w:rStyle w:val="VerbatimChar"\/>/u
|
||||
);
|
||||
});
|
||||
|
||||
it("未匹配实测布局的行内代码段落也不保留 exact 行盒", () => {
|
||||
const fixture = readGeneratedDocxPackage(generatedFixture());
|
||||
const documentXml = decoder.decode(
|
||||
fixture.entries.get("word/document.xml")!
|
||||
).replace(
|
||||
"<w:sectPr>",
|
||||
'<w:p><w:pPr><w:spacing w:line="312" w:lineRule="exact"/></w:pPr><w:r><w:t>前缀</w:t></w:r><w:r><w:rPr><w:rStyle w:val="VerbatimChar"/></w:rPr><w:t>inline</w:t></w:r></w:p><w:sectPr>'
|
||||
);
|
||||
fixture.entries.set("word/document.xml", encoder.encode(documentXml));
|
||||
|
||||
const result = finalizeGeneratedDocxStructure(
|
||||
writeGeneratedDocxPackage(fixture.entries),
|
||||
plan,
|
||||
tokens
|
||||
);
|
||||
const outputXml = decoder.decode(
|
||||
readGeneratedDocxPackage(result.content).entries.get(
|
||||
"word/document.xml"
|
||||
)!
|
||||
);
|
||||
|
||||
expect(outputXml).toMatch(
|
||||
/w:line="312" w:lineRule="atLeast"[\s\S]*?<w:rStyle w:val="VerbatimChar"\/>/u
|
||||
);
|
||||
});
|
||||
|
||||
it("表格纯公式段落加入零宽普通 Run 以服从段落对齐", () => {
|
||||
const fixture = readGeneratedDocxPackage(generatedFixture());
|
||||
const documentXml = decoder.decode(
|
||||
fixture.entries.get("word/document.xml")!
|
||||
).replace(
|
||||
`xmlns:r="${relationships}"`,
|
||||
`xmlns:r="${relationships}" xmlns:m="${math}"`
|
||||
).replace(
|
||||
"<w:p><w:r><w:t>B</w:t></w:r></w:p>",
|
||||
'<w:p><w:pPr><w:jc w:val="left"/></w:pPr><m:oMath><m:r><m:t>≥0.80</m:t></m:r></m:oMath></w:p>'
|
||||
);
|
||||
fixture.entries.set("word/document.xml", encoder.encode(documentXml));
|
||||
|
||||
const result = finalizeGeneratedDocxStructure(
|
||||
writeGeneratedDocxPackage(fixture.entries),
|
||||
plan,
|
||||
tokens
|
||||
);
|
||||
const outputXml = decoder.decode(
|
||||
readGeneratedDocxPackage(result.content).entries.get(
|
||||
"word/document.xml"
|
||||
)!
|
||||
);
|
||||
|
||||
expect(outputXml).toMatch(
|
||||
/<w:jc w:val="left"\/>[\s\S]*?<m:oMath>[\s\S]*?<m:t>≥0\.80<\/m:t>[\s\S]*?<\/m:oMath><w:r><w:rPr><w:noProof\/><\/w:rPr><w:t xml:space="preserve">\u200B<\/w:t><\/w:r>/u
|
||||
);
|
||||
});
|
||||
|
||||
it("为右对齐盒模型保留 CSS 右侧内容内缩", () => {
|
||||
const fixture = readGeneratedDocxPackage(generatedFixture());
|
||||
const documentXml = decoder.decode(
|
||||
@@ -508,6 +661,7 @@ describe("生成 DOCX 结构收口", () => {
|
||||
'<w:tblInd w:w="495" w:type="dxa"/>'
|
||||
);
|
||||
expect(documentXml).toContain('<w:tblLayout w:type="fixed"/>');
|
||||
expect(documentXml).toContain('<w:wordWrap w:val="1"/>');
|
||||
expect(documentXml).toContain('<w:gridCol w:w="2675"/>');
|
||||
expect(documentXml).toContain('<w:gridCol w:w="6240"/>');
|
||||
expect(
|
||||
@@ -525,6 +679,112 @@ describe("生成 DOCX 结构收口", () => {
|
||||
expect(documentXml).toContain('<w:jc w:val="distribute"/>');
|
||||
});
|
||||
|
||||
it("将超出内容盒的实测表格等比收缩到 Word 可用宽度", () => {
|
||||
const result = finalizeGeneratedDocxStructure(
|
||||
generatedFixture(),
|
||||
plan,
|
||||
tokens,
|
||||
[],
|
||||
{
|
||||
tables: [
|
||||
{
|
||||
ordinal: 1,
|
||||
widthPercent: 300,
|
||||
leftOffsetPercent: 0,
|
||||
columnWidthPercents: [40, 10, 10, 10, 10, 20],
|
||||
rows: []
|
||||
}
|
||||
]
|
||||
}
|
||||
);
|
||||
const documentXml = decoder.decode(
|
||||
readGeneratedDocxPackage(result.content).entries.get(
|
||||
"word/document.xml"
|
||||
)!
|
||||
);
|
||||
|
||||
expect(documentXml).toContain(
|
||||
'<w:tblW w:w="9906" w:type="dxa"/>'
|
||||
);
|
||||
expect(documentXml).toContain('<w:gridCol w:w="3962"/>');
|
||||
expect(documentXml).toContain('<w:gridCol w:w="1980"/>');
|
||||
});
|
||||
|
||||
it("按 Chromium 实测 emoji 调色保留可编辑 Unicode 文本", () => {
|
||||
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: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: [],
|
||||
emojiRuns: [
|
||||
{ ordinal: 1, text: "⭐⭐⭐⭐", color: "#e7bf36" }
|
||||
]
|
||||
}
|
||||
);
|
||||
const finalizedXml = decoder.decode(
|
||||
readGeneratedDocxPackage(result.content).entries.get(
|
||||
"word/document.xml"
|
||||
)!
|
||||
);
|
||||
|
||||
expect(finalizedXml).toMatch(
|
||||
/<w:r><w:rPr><w:color w:val="E7BF36"\/><\/w:rPr><w:t>⭐⭐⭐⭐<\/w:t><\/w:r>/u
|
||||
);
|
||||
});
|
||||
|
||||
it("实测表格缩进补偿 Word 以单元格内容而非外边框对齐的行为", () => {
|
||||
const paddedTokens = {
|
||||
...tokens,
|
||||
slots: tokens.slots.map((slot) =>
|
||||
slot.slot === "table-cell"
|
||||
? {
|
||||
...slot,
|
||||
style: {
|
||||
...slot.style,
|
||||
paddingPt: { top: 3, right: 6, bottom: 3, left: 6 }
|
||||
}
|
||||
}
|
||||
: slot
|
||||
)
|
||||
} satisfies DocxThemeTokenSet;
|
||||
const result = finalizeGeneratedDocxStructure(
|
||||
generatedFixture(),
|
||||
plan,
|
||||
paddedTokens,
|
||||
[],
|
||||
{
|
||||
tables: [{
|
||||
ordinal: 1,
|
||||
widthPercent: 90,
|
||||
leftOffsetPercent: 5,
|
||||
columnWidthPercents: [30, 70],
|
||||
rows: []
|
||||
}]
|
||||
}
|
||||
);
|
||||
const documentXml = decoder.decode(
|
||||
readGeneratedDocxPackage(result.content).entries.get(
|
||||
"word/document.xml"
|
||||
)!
|
||||
);
|
||||
|
||||
expect(documentXml).toContain(
|
||||
'<w:tblInd w:w="615" w:type="dxa"/>'
|
||||
);
|
||||
});
|
||||
|
||||
it("按稳定媒体计划规范化内联尺寸、比例锁和对齐", () => {
|
||||
const result = finalizeGeneratedDocxStructure(
|
||||
generatedFixture(),
|
||||
@@ -882,6 +1142,36 @@ describe("生成 DOCX 结构收口", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("将 Pandoc 拆分的标准 Alert 正文恢复为引用块样式", () => {
|
||||
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:pStyle w:val="FirstParagraph"/></w:pPr><w:r><w:t>Caution</w:t></w:r></w:p>` +
|
||||
`<w:p><w:pPr><w:pStyle w:val="BodyText"/></w:pPr><w:r><w:t>警告正文</w:t></w:r></w:p>` +
|
||||
`<w:sectPr>`
|
||||
))
|
||||
);
|
||||
|
||||
const result = finalizeGeneratedDocxStructure(
|
||||
writeGeneratedDocxPackage(entries),
|
||||
plan,
|
||||
tokens
|
||||
);
|
||||
const finalizedXml = decoder.decode(
|
||||
readGeneratedDocxPackage(result.content).entries.get(
|
||||
"word/document.xml"
|
||||
)!
|
||||
);
|
||||
|
||||
expect(finalizedXml).toMatch(
|
||||
/<w:pPr><w:pStyle w:val="BlockText"\/>[\s\S]*?<w:t>警告正文<\/w:t>/u
|
||||
);
|
||||
});
|
||||
|
||||
it("按最终页面内容区重算右置百分比容器缩进", () => {
|
||||
const relativeTokens: DocxThemeTokenSet = {
|
||||
...tokens,
|
||||
|
||||
@@ -127,7 +127,7 @@ describe("Pandoc DOCX 转换器", () => {
|
||||
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"
|
||||
"commonmark_x-yaml_metadata_block+pipe_tables+footnotes+task_lists+tex_math_dollars+raw_html"
|
||||
);
|
||||
expect(
|
||||
options.env?.MD_TO_PDF_DOCX_MEDIA_MAP
|
||||
@@ -219,4 +219,29 @@ describe("Pandoc DOCX 转换器", () => {
|
||||
code: "DOCX_OUTPUT_INVALID"
|
||||
} satisfies Partial<PandocDocxConversionError>);
|
||||
});
|
||||
|
||||
it("进程失败时保留内部诊断但保持稳定的对外错误", async () => {
|
||||
const failedRunner = vi.fn<PandocProcessRunner>(async () => ({
|
||||
outcome: "completed",
|
||||
exitCode: 64,
|
||||
stdout: new Uint8Array(),
|
||||
stderr: "pandoc: 媒体映射失败"
|
||||
}));
|
||||
|
||||
await expect(
|
||||
new PandocDocxConverter({
|
||||
runtime,
|
||||
runner: failedRunner,
|
||||
temporaryRoot
|
||||
}).convert(input())
|
||||
).rejects.toMatchObject({
|
||||
code: "DOCX_GENERATION_FAILED",
|
||||
message: "Pandoc 未能生成 DOCX",
|
||||
diagnostics: {
|
||||
outcome: "completed",
|
||||
exitCode: 64,
|
||||
stderr: "pandoc: 媒体映射失败"
|
||||
}
|
||||
} satisfies Partial<PandocDocxConversionError>);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -404,11 +404,12 @@ describe("动态 reference.docx", () => {
|
||||
const sourceCodeStyle = stylesXml.match(
|
||||
/<w:style\b[^>]*w:styleId="SourceCode"[\s\S]*?<\/w:style>/u
|
||||
)?.[0];
|
||||
expect(sourceCodeStyle).not.toContain("<w:shd");
|
||||
expect(sourceCodeStyle).toContain(
|
||||
'<w:left w:val="single" w:sz="6" w:space="7" w:color="E7EAED"/>'
|
||||
);
|
||||
expect(sourceCodeStyle).toMatch(
|
||||
/<w:ind\b[^>]*w:left="195"[^>]*w:right="75"[^>]*\/>/u
|
||||
/<w:ind\b[^>]*w:left="195"[^>]*w:right="0"[^>]*\/>/u
|
||||
);
|
||||
expect(sourceCodeStyle).toContain('w:ascii="Code Text Face"');
|
||||
expect(stylesXml).toMatch(
|
||||
@@ -451,6 +452,49 @@ describe("动态 reference.docx", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("按代码容器令牌清除或写入 SourceCode 段落底纹", () => {
|
||||
const createWithBackground = (backgroundColor?: string) => {
|
||||
const result = createDynamicReferenceDocx(
|
||||
createBaselineReference(),
|
||||
{
|
||||
...createOptions(defaultExportConfig),
|
||||
themeTokens: themeTokens("f".repeat(64), [
|
||||
{
|
||||
slot: "code-block",
|
||||
source: "computed-css",
|
||||
confidence: "exact",
|
||||
style: {
|
||||
fontCandidates: ["Consolas"],
|
||||
fontSizePt: 10,
|
||||
...(backgroundColor ? { backgroundColor } : {})
|
||||
}
|
||||
},
|
||||
{
|
||||
slot: "code-block-text",
|
||||
source: "computed-css",
|
||||
confidence: "exact",
|
||||
style: {
|
||||
fontCandidates: ["Consolas"],
|
||||
fontSizePt: 10
|
||||
}
|
||||
}
|
||||
])
|
||||
}
|
||||
);
|
||||
const stylesXml = decoder.decode(
|
||||
unzipSync(result.content)["word/styles.xml"]
|
||||
);
|
||||
return stylesXml.match(
|
||||
/<w:style\b[^>]*w:styleId="SourceCode"[\s\S]*?<\/w:style>/u
|
||||
)?.[0];
|
||||
};
|
||||
|
||||
expect(createWithBackground()).not.toContain("<w:shd");
|
||||
expect(createWithBackground("#eef2f6")).toContain(
|
||||
'<w:shd w:val="clear" w:color="auto" w:fill="EEF2F6"/>'
|
||||
);
|
||||
});
|
||||
|
||||
it("生成横向、自定义页边距和奇偶首页页眉页脚", () => {
|
||||
const exportConfig: ExportConfig = {
|
||||
...defaultExportConfig,
|
||||
@@ -506,6 +550,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(settingsXml).toContain("<w:doNotExpandShiftReturn");
|
||||
expect(stylesXml).toContain('<w:kern w:val="2"');
|
||||
expect(headerXml).toContain("年度 <报告>");
|
||||
expect(headerXml).toContain("报告 & 计划.md");
|
||||
|
||||
@@ -89,6 +89,15 @@ describe("DOCX 令牌样式映射", () => {
|
||||
it("将 Pandoc 语法样式映射到引擎级代码语义槽位", () => {
|
||||
expect(resolvePandocSyntaxStyleSlot("DataTypeTok", '"key"'))
|
||||
.toBe("code-token-attribute");
|
||||
expect(resolvePandocSyntaxStyleSlot("DataTypeTok", "<"))
|
||||
.toBe("code-token-punctuation");
|
||||
expect(
|
||||
resolvePandocSyntaxStyleSlot(
|
||||
"KeywordTok",
|
||||
"section",
|
||||
{ htmlTagName: true }
|
||||
)
|
||||
).toBe("code-token-name");
|
||||
expect(resolvePandocSyntaxStyleSlot("StringTok", '"value"'))
|
||||
.toBe("code-token-string");
|
||||
expect(resolvePandocSyntaxStyleSlot("FunctionTok", ":"))
|
||||
@@ -97,6 +106,13 @@ describe("DOCX 令牌样式映射", () => {
|
||||
.toBe("code-token-title");
|
||||
expect(resolvePandocSyntaxStyleSlot("NormalTok", " "))
|
||||
.toBe("code-block-text");
|
||||
expect(
|
||||
resolvePandocSyntaxStyleSlot(
|
||||
"NormalTok",
|
||||
" method",
|
||||
{ objectPropertyKey: true }
|
||||
)
|
||||
).toBe("code-token-attribute");
|
||||
expect(resolvePandocSyntaxStyleSlot("UnknownTok", "x"))
|
||||
.toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -41,6 +41,9 @@ export function readDocxComputedStyle(
|
||||
fontStyle: style.fontStyle,
|
||||
color: style.color,
|
||||
backgroundColor: style.backgroundColor,
|
||||
...(style.backgroundImage
|
||||
? { backgroundImage: style.backgroundImage }
|
||||
: {}),
|
||||
lineHeight: style.lineHeight,
|
||||
letterSpacing: style.letterSpacing,
|
||||
textAlign: style.textAlign,
|
||||
|
||||
@@ -79,6 +79,25 @@ export function parseCssColor(
|
||||
);
|
||||
}
|
||||
|
||||
export function parseCssLinearGradientFallbackColor(
|
||||
input: string
|
||||
): string | undefined {
|
||||
const value = input.trim();
|
||||
if (!/^(?:repeating-)?linear-gradient\(/iu.test(value)) {
|
||||
return undefined;
|
||||
}
|
||||
const colorValues = value.match(
|
||||
/rgba?\([^)]*\)|#[\da-f]{3,8}/giu
|
||||
) ?? [];
|
||||
for (const colorValue of colorValues) {
|
||||
const parsed = parseCssColor(colorValue);
|
||||
if (parsed) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function parseAlpha(value: string | undefined): number | undefined {
|
||||
if (value === undefined) {
|
||||
return undefined;
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
millimetersToPoints,
|
||||
parseCssBorder,
|
||||
parseCssColor,
|
||||
parseCssLinearGradientFallbackColor,
|
||||
parseCssFontFamilies,
|
||||
parseCssLengthToPt,
|
||||
roundDocxValue
|
||||
@@ -292,6 +293,20 @@ function normalizeComputedSlot(
|
||||
);
|
||||
if (background) {
|
||||
style.backgroundColor = background;
|
||||
} else if (computed.backgroundImage) {
|
||||
const gradientBackground = parseCssLinearGradientFallbackColor(
|
||||
computed.backgroundImage
|
||||
);
|
||||
if (gradientBackground) {
|
||||
style.backgroundColor = gradientBackground;
|
||||
diagnostic(context, {
|
||||
severity: "info",
|
||||
code: "layout-approximated",
|
||||
property: "background-image",
|
||||
message: "CSS 线性渐变已使用首个非透明色近似为 Word 段落底纹"
|
||||
});
|
||||
context.approximate = true;
|
||||
}
|
||||
}
|
||||
const letterSpacing = computed.letterSpacing.toLowerCase() === "normal"
|
||||
? 0
|
||||
|
||||
@@ -35,7 +35,7 @@ export function createDocxStyleProbeMarkup(): string {
|
||||
<ul ${attribute("unordered-list")}><li ${attribute("list-item")}>无序列表</li></ul>
|
||||
<ol ${attribute("ordered-list")}><li>有序列表</li></ol>
|
||||
<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>
|
||||
<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> <span class="hljs-name" ${attribute("code-token-name")}>section</span></code></pre>
|
||||
<table ${attribute("table")}>
|
||||
<thead><tr><th ${attribute("table-header")}>表头</th></tr></thead>
|
||||
<tbody><tr><td ${attribute("table-cell")}>单元格</td></tr></tbody>
|
||||
|
||||
@@ -55,6 +55,7 @@ const computedProperties = [
|
||||
"fontStyle",
|
||||
"color",
|
||||
"backgroundColor",
|
||||
"backgroundImage",
|
||||
"lineHeight",
|
||||
"letterSpacing",
|
||||
"textAlign",
|
||||
|
||||
@@ -35,6 +35,7 @@ export const DOCX_STYLE_SLOT_NAMES = [
|
||||
"code-token-operator",
|
||||
"code-token-regexp",
|
||||
"code-token-punctuation",
|
||||
"code-token-name",
|
||||
"table",
|
||||
"table-header",
|
||||
"table-cell",
|
||||
@@ -153,6 +154,7 @@ const kinds: Record<DocxStyleSlotName, DocxStyleSlotKind> = {
|
||||
"code-token-operator": "inline",
|
||||
"code-token-regexp": "inline",
|
||||
"code-token-punctuation": "inline",
|
||||
"code-token-name": "inline",
|
||||
table: "table",
|
||||
"table-header": "table",
|
||||
"table-cell": "table",
|
||||
|
||||
@@ -10,6 +10,7 @@ export const docxComputedStyleSchema = z.object({
|
||||
fontStyle: cssValueSchema,
|
||||
color: cssValueSchema,
|
||||
backgroundColor: cssValueSchema,
|
||||
backgroundImage: cssValueSchema.optional(),
|
||||
lineHeight: cssValueSchema,
|
||||
letterSpacing: cssValueSchema,
|
||||
textAlign: cssValueSchema,
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
millimetersToPoints,
|
||||
parseCssBorder,
|
||||
parseCssColor,
|
||||
parseCssLinearGradientFallbackColor,
|
||||
parseCssFontFamilies,
|
||||
parseCssLengthToPt,
|
||||
resolveDocxThemeTokens,
|
||||
@@ -106,6 +107,12 @@ describe("CSS 到 Word 基础值归一化", () => {
|
||||
expect(parseCssColor("rgba(0, 0, 0, 0)")).toBeUndefined();
|
||||
expect(parseCssColor("rgba(255, 0, 0, 0.5)")).toBe("#ff8080");
|
||||
expect(parseCssColor("#00000080")).toBe("#7f7f7f");
|
||||
expect(parseCssLinearGradientFallbackColor(
|
||||
"linear-gradient(90deg, rgb(239, 246, 255), rgba(0, 0, 0, 0))"
|
||||
)).toBe("#eff6ff");
|
||||
expect(parseCssLinearGradientFallbackColor(
|
||||
"radial-gradient(rgb(239, 246, 255), transparent)"
|
||||
)).toBeUndefined();
|
||||
expect(
|
||||
parseCssFontFamilies(
|
||||
'"Source Han Serif SC", "Microsoft YaHei", serif'
|
||||
@@ -185,6 +192,32 @@ describe("DOCX 主题令牌归一化", () => {
|
||||
expect(tokens.slots).toHaveLength(DOCX_STYLE_SLOT_NAMES.length);
|
||||
});
|
||||
|
||||
it("将线性渐变首个实色近似为 Word 段落底纹", () => {
|
||||
const tokens = resolveDocxThemeTokens({
|
||||
snapshot: createSnapshot({
|
||||
"heading-2": {
|
||||
backgroundColor: "rgba(0, 0, 0, 0)",
|
||||
backgroundImage:
|
||||
"linear-gradient(90deg, rgb(239, 246, 255), rgba(0, 0, 0, 0))"
|
||||
}
|
||||
}),
|
||||
config: {
|
||||
mode: "auto",
|
||||
basePreset: "general",
|
||||
overrides: {},
|
||||
legacyPreset: false
|
||||
}
|
||||
});
|
||||
|
||||
expect(findSlot(tokens, "heading-2").style.backgroundColor)
|
||||
.toBe("#eff6ff");
|
||||
expect(tokens.diagnostics).toContainEqual(expect.objectContaining({
|
||||
slot: "heading-2",
|
||||
code: "layout-approximated",
|
||||
property: "background-image"
|
||||
}));
|
||||
});
|
||||
|
||||
it("按 border-box 文档的内容区宽度归一化全宽表格", () => {
|
||||
const tokens = resolveDocxThemeTokens({
|
||||
snapshot: createSnapshot({
|
||||
|
||||
@@ -3,6 +3,7 @@ const CHUNK_CLASS = "md-code-pagination-chunk";
|
||||
const CHUNK_POSITION_ATTRIBUTE = "data-code-pagination-position";
|
||||
const LINE_GROUP_CLASS = "md-code-line-group";
|
||||
const LINE_CLASS = "md-code-line";
|
||||
const LINE_INDENT_CLASS = "md-code-line-indent";
|
||||
|
||||
function splitNodeIntoLines(node: Node): Node[][] {
|
||||
const documentRef = node.ownerDocument;
|
||||
@@ -47,6 +48,56 @@ function lineHasContent(nodes: Node[]) {
|
||||
return nodes.some((node) => (node.textContent ?? "").length > 0);
|
||||
}
|
||||
|
||||
function stabilizeLeadingWhitespace(
|
||||
documentRef: Document,
|
||||
nodes: Node[]
|
||||
) {
|
||||
let indent = "";
|
||||
let index = 0;
|
||||
for (; index < nodes.length; index += 1) {
|
||||
const node = nodes[index];
|
||||
if (!node || node.nodeType !== node.TEXT_NODE) {
|
||||
break;
|
||||
}
|
||||
const value = node.textContent ?? "";
|
||||
const match = value.match(/^[\t ]+/u);
|
||||
if (!match) {
|
||||
if (value.length === 0) {
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
indent += match[0];
|
||||
const remainder = value.slice(match[0].length);
|
||||
if (remainder) {
|
||||
node.textContent = remainder;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!indent) {
|
||||
return nodes;
|
||||
}
|
||||
const indentElement = documentRef.createElement("span");
|
||||
indentElement.className = LINE_INDENT_CLASS;
|
||||
let columns = 0;
|
||||
for (const character of indent) {
|
||||
columns = character === "\t"
|
||||
? columns + (4 - columns % 4)
|
||||
: columns + 1;
|
||||
}
|
||||
indentElement.dataset.codeIndentColumns = String(columns);
|
||||
indentElement.style.width = `${columns}ch`;
|
||||
indentElement.textContent = indent;
|
||||
return [
|
||||
indentElement,
|
||||
...nodes.slice(index).filter(
|
||||
(node) =>
|
||||
node.nodeType !== node.TEXT_NODE ||
|
||||
(node.textContent ?? "").length > 0
|
||||
)
|
||||
];
|
||||
}
|
||||
|
||||
function createLineGroups(
|
||||
documentRef: Document,
|
||||
lines: Node[][]
|
||||
@@ -68,7 +119,12 @@ function createLineGroups(
|
||||
) {
|
||||
const line = documentRef.createElement("span");
|
||||
line.className = LINE_CLASS;
|
||||
line.append(...(lines[lineIndex] ?? []));
|
||||
line.append(
|
||||
...stabilizeLeadingWhitespace(
|
||||
documentRef,
|
||||
lines[lineIndex] ?? []
|
||||
)
|
||||
);
|
||||
group.append(line);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import {
|
||||
DOCX_MEDIA_RASTER_SCALE,
|
||||
MAXIMUM_DOCX_MEDIA_EDGE_PIXELS,
|
||||
MAXIMUM_DOCX_MEDIA_COUNT,
|
||||
MAXIMUM_DOCX_MEDIA_PIXELS,
|
||||
MAXIMUM_DOCX_RESOURCE_COUNT,
|
||||
type DocxMediaCapturePlan,
|
||||
type DocxMediaCaptureTarget,
|
||||
type DocxMediaAlignment,
|
||||
type DocxMediaKind,
|
||||
type DocxDocumentLayoutPlan,
|
||||
type DocxEmojiRunLayout,
|
||||
type DocxInlineCodeLayout,
|
||||
type DocxListItemLayout,
|
||||
type DocxTableLayout,
|
||||
@@ -15,12 +16,19 @@ import {
|
||||
type PagedDocumentPayload
|
||||
} from "@md-to-pdf/core";
|
||||
import { PagedDocumentRuntime } from "./paged-document-runtime.js";
|
||||
import { stabilizePagedTableColumns } from "./paged-table-handler.js";
|
||||
|
||||
export interface DocxMediaRenderDimensions {
|
||||
contentWidthPx: number;
|
||||
contentHeightPx: number;
|
||||
}
|
||||
|
||||
export type DocxTableGeometry = Pick<
|
||||
DocxTableLayout,
|
||||
"ordinal" | "widthPercent" | "leftOffsetPercent" |
|
||||
"columnWidthPercents"
|
||||
>;
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__mdToPdfRenderDocxMedia?: (
|
||||
@@ -157,6 +165,23 @@ function average(values: readonly number[], fallback: number) {
|
||||
}
|
||||
|
||||
function collectTableColumnWidths(table: HTMLTableElement, tableRect: DOMRect) {
|
||||
const stabilizedColumns = Array.from(
|
||||
table.querySelectorAll<HTMLTableColElement>(
|
||||
":scope > colgroup[data-stabilized-columns=\"true\"] > col"
|
||||
)
|
||||
);
|
||||
const stabilizedWidths = stabilizedColumns.map((column) =>
|
||||
Number(column.dataset.stabilizedWidthPx)
|
||||
);
|
||||
if (
|
||||
stabilizedWidths.length > 0 &&
|
||||
stabilizedWidths.every((width) => Number.isFinite(width) && width > 0)
|
||||
) {
|
||||
// 这组轨道来自分页前的完整自动布局。后续为稳定 Paged.js 克隆
|
||||
// 写入的 col/cell 辅助宽度可能反过来扰动源表二次测量,DOCX 必须
|
||||
// 使用最初与 Chromium 分页基线一致的列轨,而不是辅助样式后的值。
|
||||
return stabilizedWidths;
|
||||
}
|
||||
const rows = Array.from(table.rows);
|
||||
const columnCount = rows.reduce(
|
||||
(maximum, row) =>
|
||||
@@ -255,7 +280,7 @@ function cellAlignment(value: string): "left" | "center" | "right" | "justify" {
|
||||
|
||||
const DOCX_TEXT_BLOCK_SELECTOR =
|
||||
"h1, h2, h3, h4, h5, h6, p, li, th, td, figcaption, " +
|
||||
".doc-classification, .doc-issue-row, .doc-printing-row, " +
|
||||
".md-alert-text, .doc-classification, .doc-issue-row, .doc-printing-row, " +
|
||||
".doc-briefing-meta";
|
||||
|
||||
function textNodeCharacters(root: HTMLElement) {
|
||||
@@ -293,9 +318,19 @@ function characterRect(
|
||||
return rect.width > 0 && rect.height > 0 ? rect : undefined;
|
||||
}
|
||||
|
||||
function hasDistributedLastLine(
|
||||
_element: HTMLElement,
|
||||
computed: CSSStyleDeclaration,
|
||||
_lastLine: readonly DOMRect[]
|
||||
) {
|
||||
const textAlignLast = computed.textAlignLast.trim().toLowerCase();
|
||||
return textAlignLast === "justify" || textAlignLast === "distribute";
|
||||
}
|
||||
|
||||
export function collectDocxTextBlockLayouts(
|
||||
article: HTMLElement
|
||||
): DocxTextBlockLayout[] {
|
||||
const articleRect = article.getBoundingClientRect();
|
||||
const candidates = Array.from(
|
||||
article.querySelectorAll<HTMLElement>(DOCX_TEXT_BLOCK_SELECTOR)
|
||||
).filter((element) => {
|
||||
@@ -350,39 +385,33 @@ export function collectDocxTextBlockLayouts(
|
||||
}
|
||||
const text = output.join("").trim();
|
||||
const computed = getComputedStyle(element);
|
||||
const alert = element.closest<HTMLElement>(".md-alert");
|
||||
const alertRole = element.matches(".md-alert-text")
|
||||
? "title" as const
|
||||
: alert
|
||||
? "body" as const
|
||||
: undefined;
|
||||
const alertRect = alert?.getBoundingClientRect();
|
||||
const alertComputed = alert ? getComputedStyle(alert) : undefined;
|
||||
const alertLeft = alertRect && alertComputed
|
||||
? alertRect.left +
|
||||
Number.parseFloat(alertComputed.borderLeftWidth || "0") +
|
||||
Number.parseFloat(alertComputed.paddingLeft || "0")
|
||||
: undefined;
|
||||
const alertRight = alertRect && alertComputed
|
||||
? alertRect.right -
|
||||
Number.parseFloat(alertComputed.borderRightWidth || "0") -
|
||||
Number.parseFloat(alertComputed.paddingRight || "0")
|
||||
: undefined;
|
||||
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 distributed = hasDistributedLastLine(
|
||||
element,
|
||||
computed,
|
||||
lastLine
|
||||
);
|
||||
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))
|
||||
@@ -401,6 +430,19 @@ export function collectDocxTextBlockLayouts(
|
||||
letterSpacingPt: Number.isFinite(letterSpacingPx)
|
||||
? letterSpacingPx * 0.75
|
||||
: 0,
|
||||
...(alertRole && alertLeft !== undefined && alertRight !== undefined
|
||||
? {
|
||||
alertRole,
|
||||
fontSizePt: cssPixelsToPoints(computed.fontSize),
|
||||
bold: computed.fontWeight === "bold" ||
|
||||
Number.parseInt(computed.fontWeight, 10) >= 600,
|
||||
italic: computed.fontStyle === "italic" ||
|
||||
computed.fontStyle === "oblique",
|
||||
color: cssColor(computed.color) ?? "#000000",
|
||||
leftIndentPt: Math.max(0, (alertLeft - articleRect.left) * 0.75),
|
||||
rightIndentPt: Math.max(0, (articleRect.right - alertRight) * 0.75)
|
||||
}
|
||||
: {}),
|
||||
alignment: distributed
|
||||
? "distribute" as const
|
||||
: cellAlignment(computed.textAlign),
|
||||
@@ -447,6 +489,18 @@ export function collectDocxListItemLayouts(
|
||||
if (!rect) {
|
||||
return [];
|
||||
}
|
||||
const visibleRects = characters.flatMap((character) => {
|
||||
const characterBounds = characterRect(range, character);
|
||||
return characterBounds ? [characterBounds] : [];
|
||||
});
|
||||
const lastLineTop = visibleRects.length > 0
|
||||
? Math.max(...visibleRects.map((bounds) => bounds.top))
|
||||
: 0;
|
||||
const lastLine = visibleRects.filter(
|
||||
(bounds) => Math.abs(bounds.top - lastLineTop) <= 1
|
||||
);
|
||||
const computed = getComputedStyle(item);
|
||||
const distributed = hasDistributedLastLine(item, computed, lastLine);
|
||||
let listDepth = -1;
|
||||
for (
|
||||
let ancestor: Element | null = item.parentElement;
|
||||
@@ -464,7 +518,8 @@ export function collectDocxListItemLayouts(
|
||||
textStartPt: Math.max(
|
||||
0,
|
||||
(rect.left - articleLeft) * 0.75
|
||||
)
|
||||
),
|
||||
...(distributed ? { alignment: "distribute" as const } : {})
|
||||
}];
|
||||
}
|
||||
);
|
||||
@@ -475,6 +530,86 @@ function cssPixelsToPoints(value: string) {
|
||||
return Number.isFinite(pixels) ? pixels * 0.75 : 0;
|
||||
}
|
||||
|
||||
const EMOJI_ONLY_TEXT = /^(?:\p{Extended_Pictographic}|\p{Emoji_Modifier}|\u200d|\ufe0f|\s)+$/u;
|
||||
|
||||
function sampledEmojiColor(text: string, computed: CSSStyleDeclaration) {
|
||||
const canvas = document.createElement("canvas");
|
||||
const context = canvas.getContext("2d", { willReadFrequently: true });
|
||||
if (!context) {
|
||||
return undefined;
|
||||
}
|
||||
context.font = computed.font;
|
||||
const metrics = context.measureText(text);
|
||||
const fontSize = Math.max(12, Number.parseFloat(computed.fontSize) || 16);
|
||||
canvas.width = Math.max(1, Math.ceil(metrics.width + fontSize));
|
||||
canvas.height = Math.max(1, Math.ceil(fontSize * 2));
|
||||
const renderContext = canvas.getContext("2d", { willReadFrequently: true });
|
||||
if (!renderContext) {
|
||||
return undefined;
|
||||
}
|
||||
renderContext.clearRect(0, 0, canvas.width, canvas.height);
|
||||
renderContext.font = computed.font;
|
||||
renderContext.textBaseline = "middle";
|
||||
renderContext.fillStyle = computed.color;
|
||||
renderContext.fillText(text, fontSize / 2, canvas.height / 2);
|
||||
const pixels = renderContext.getImageData(
|
||||
0,
|
||||
0,
|
||||
canvas.width,
|
||||
canvas.height
|
||||
).data;
|
||||
const histogram = new Map<string, number>();
|
||||
for (let index = 0; index < pixels.length; index += 4) {
|
||||
const alpha = pixels[index + 3] ?? 0;
|
||||
if (alpha < 96) {
|
||||
continue;
|
||||
}
|
||||
const red = pixels[index] ?? 0;
|
||||
const green = pixels[index + 1] ?? 0;
|
||||
const blue = pixels[index + 2] ?? 0;
|
||||
if (Math.max(red, green, blue) - Math.min(red, green, blue) < 16) {
|
||||
continue;
|
||||
}
|
||||
const key = [red, green, blue]
|
||||
.map((value) => Math.round(value / 8) * 8)
|
||||
.join(",");
|
||||
histogram.set(key, (histogram.get(key) ?? 0) + alpha);
|
||||
}
|
||||
const dominant = Array.from(histogram.entries()).sort(
|
||||
([, left], [, right]) => right - left
|
||||
)[0]?.[0];
|
||||
if (!dominant) {
|
||||
return undefined;
|
||||
}
|
||||
const channels = dominant.split(",").map(Number);
|
||||
if (channels.length !== 3 || channels.some((value) => !Number.isFinite(value))) {
|
||||
return undefined;
|
||||
}
|
||||
return `#${channels
|
||||
.map((value) => Math.min(255, value).toString(16).padStart(2, "0"))
|
||||
.join("")}`;
|
||||
}
|
||||
|
||||
export function collectDocxEmojiRunLayouts(
|
||||
article: HTMLElement
|
||||
): DocxEmojiRunLayout[] {
|
||||
const walker = document.createTreeWalker(article, NodeFilter.SHOW_TEXT);
|
||||
const layouts: DocxEmojiRunLayout[] = [];
|
||||
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
|
||||
const text = (node.textContent ?? "").normalize("NFC").trim();
|
||||
const parent = node.parentElement;
|
||||
if (!text || !parent || !EMOJI_ONLY_TEXT.test(text)) {
|
||||
continue;
|
||||
}
|
||||
const color = sampledEmojiColor(text, getComputedStyle(parent));
|
||||
if (!color) {
|
||||
continue;
|
||||
}
|
||||
layouts.push({ ordinal: layouts.length + 1, text, color });
|
||||
}
|
||||
return layouts;
|
||||
}
|
||||
|
||||
export function collectDocxInlineCodeLayouts(
|
||||
article: HTMLElement
|
||||
): DocxInlineCodeLayout[] {
|
||||
@@ -545,7 +680,7 @@ export function collectDocxDocumentLayoutPlan(
|
||||
);
|
||||
return {
|
||||
ordinal: index + 1,
|
||||
widthPercent: Math.min(100, (width / contentWidth) * 100),
|
||||
widthPercent: Math.min(300, (width / contentWidth) * 100),
|
||||
leftOffsetPercent: Math.max(
|
||||
0,
|
||||
Math.min(100, ((rect.left - articleRect.left) / contentWidth) * 100)
|
||||
@@ -570,11 +705,112 @@ export function collectDocxDocumentLayoutPlan(
|
||||
}))
|
||||
};
|
||||
});
|
||||
const emojiRuns = collectDocxEmojiRunLayouts(article);
|
||||
return {
|
||||
tables,
|
||||
textBlocks: collectDocxTextBlockLayouts(article),
|
||||
listItems: collectDocxListItemLayouts(article),
|
||||
inlineCodes: collectDocxInlineCodeLayouts(article)
|
||||
inlineCodes: collectDocxInlineCodeLayouts(article),
|
||||
...(emojiRuns.length > 0 ? { emojiRuns } : {})
|
||||
};
|
||||
}
|
||||
|
||||
export function collectPagedDocxTableGeometries(
|
||||
root: ParentNode,
|
||||
dimensions: DocxMediaRenderDimensions
|
||||
): DocxTableGeometry[] {
|
||||
const tablesByReference = new Map<string, HTMLTableElement[]>();
|
||||
let anonymousTableIndex = 0;
|
||||
for (const table of root.querySelectorAll<HTMLTableElement>(
|
||||
".pagedjs_pages table"
|
||||
)) {
|
||||
const reference = table.dataset.ref ??
|
||||
`__anonymous_table_${anonymousTableIndex += 1}`;
|
||||
const tables = tablesByReference.get(reference) ?? [];
|
||||
tables.push(table);
|
||||
tablesByReference.set(reference, tables);
|
||||
}
|
||||
const geometries: DocxTableGeometry[] = [];
|
||||
for (const tables of tablesByReference.values()) {
|
||||
const table = tables.reduce((best, candidate) => {
|
||||
const visibleArea = (element: HTMLTableElement) => {
|
||||
const content = element.closest<HTMLElement>(
|
||||
".pagedjs_area, #write"
|
||||
);
|
||||
const contentRect = content?.getBoundingClientRect();
|
||||
const rect = element.getBoundingClientRect();
|
||||
if (!contentRect) {
|
||||
return Math.max(0, rect.width) * Math.max(0, rect.height);
|
||||
}
|
||||
const width = Math.max(
|
||||
0,
|
||||
Math.min(rect.right, contentRect.right) -
|
||||
Math.max(rect.left, contentRect.left)
|
||||
);
|
||||
const height = Math.max(
|
||||
0,
|
||||
Math.min(rect.bottom, contentRect.bottom) -
|
||||
Math.max(rect.top, contentRect.top)
|
||||
);
|
||||
return width * height;
|
||||
};
|
||||
return visibleArea(candidate) > visibleArea(best)
|
||||
? candidate
|
||||
: best;
|
||||
});
|
||||
const content = table.closest<HTMLElement>(
|
||||
".pagedjs_area, #write"
|
||||
);
|
||||
const contentRect = content?.getBoundingClientRect();
|
||||
const contentWidth = finitePositive(
|
||||
contentRect?.width ?? 0,
|
||||
dimensions.contentWidthPx
|
||||
);
|
||||
const contentLeft = contentRect?.left ?? 0;
|
||||
const rect = table.getBoundingClientRect();
|
||||
const width = finitePositive(rect.width, contentWidth);
|
||||
const columnWidths = collectTableColumnWidths(table, rect);
|
||||
const columnTotal = columnWidths.reduce(
|
||||
(total, value) => total + value,
|
||||
0
|
||||
);
|
||||
geometries.push({
|
||||
ordinal: geometries.length + 1,
|
||||
widthPercent: Math.min(300, (width / contentWidth) * 100),
|
||||
leftOffsetPercent: Math.max(
|
||||
0,
|
||||
Math.min(100, ((rect.left - contentLeft) / contentWidth) * 100)
|
||||
),
|
||||
columnWidthPercents: columnWidths.map(
|
||||
(value) => (value / columnTotal) * 100
|
||||
)
|
||||
});
|
||||
}
|
||||
return geometries;
|
||||
}
|
||||
|
||||
export function mergePagedTableGeometries(
|
||||
layout: DocxDocumentLayoutPlan,
|
||||
pagedGeometries: readonly DocxTableGeometry[]
|
||||
): DocxDocumentLayoutPlan {
|
||||
return {
|
||||
...layout,
|
||||
tables: layout.tables.map((table, index) => {
|
||||
const geometry = pagedGeometries[index];
|
||||
if (
|
||||
!geometry ||
|
||||
geometry.columnWidthPercents.length !==
|
||||
table.columnWidthPercents.length
|
||||
) {
|
||||
return table;
|
||||
}
|
||||
return {
|
||||
...table,
|
||||
widthPercent: geometry.widthPercent,
|
||||
leftOffsetPercent: geometry.leftOffsetPercent,
|
||||
columnWidthPercents: [...geometry.columnWidthPercents]
|
||||
};
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
@@ -595,9 +831,9 @@ export function collectDocxMediaCaptureTargets(
|
||||
].join(",")
|
||||
)
|
||||
);
|
||||
if (candidates.length > MAXIMUM_DOCX_RESOURCE_COUNT) {
|
||||
if (candidates.length > MAXIMUM_DOCX_MEDIA_COUNT) {
|
||||
throw new Error(
|
||||
`DOCX 媒体数量不能超过 ${MAXIMUM_DOCX_RESOURCE_COUNT}`
|
||||
`DOCX 媒体数量不能超过 ${MAXIMUM_DOCX_MEDIA_COUNT}`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -658,6 +894,20 @@ export async function renderDocxMediaCapturePlan(
|
||||
payload: PagedDocumentPayload,
|
||||
dimensions: DocxMediaRenderDimensions
|
||||
): Promise<DocxMediaCapturePlan> {
|
||||
const pagedResult = await runtime.render(payload, {
|
||||
target: "pdf",
|
||||
// DOCX 只消费分页表格的宽度、偏移和列宽比例;完整行结构与样式随后
|
||||
// 从连续 DOM 重新采集。Paged.js 偶发丢失边界行时允许几何降级,
|
||||
// 正常 PDF 渲染仍保持严格的正文行完整性门禁。
|
||||
allowIncompleteTableGeometry: true
|
||||
});
|
||||
if (!pagedResult) {
|
||||
throw new Error("DOCX 分页布局渲染已取消");
|
||||
}
|
||||
const pagedTableGeometries = collectPagedDocxTableGeometries(
|
||||
root,
|
||||
dimensions
|
||||
);
|
||||
const renderResult = await runtime.renderContinuous(payload, {
|
||||
geometryCss: createGeometryCss(dimensions),
|
||||
themeMedia: "print"
|
||||
@@ -665,10 +915,24 @@ export async function renderDocxMediaCapturePlan(
|
||||
if (!renderResult) {
|
||||
throw new Error("DOCX 媒体渲染已取消");
|
||||
}
|
||||
stabilizePagedTableColumns(root, dimensions.contentHeightPx);
|
||||
const continuousLayout = collectDocxDocumentLayoutPlan(
|
||||
root,
|
||||
dimensions
|
||||
);
|
||||
return {
|
||||
targets: collectDocxMediaCaptureTargets(root, dimensions),
|
||||
echartsErrors: renderResult.echartsErrors,
|
||||
mermaidErrors: renderResult.mermaidErrors,
|
||||
documentLayout: collectDocxDocumentLayoutPlan(root, dimensions)
|
||||
echartsErrors: Array.from(new Set([
|
||||
...pagedResult.echartsErrors,
|
||||
...renderResult.echartsErrors
|
||||
])),
|
||||
mermaidErrors: Array.from(new Set([
|
||||
...pagedResult.mermaidErrors,
|
||||
...renderResult.mermaidErrors
|
||||
])),
|
||||
documentLayout: mergePagedTableGeometries(
|
||||
continuousLayout,
|
||||
pagedTableGeometries
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -17,5 +17,6 @@ export * from "./paged-page-sequence.js";
|
||||
export * from "./semantic-cover-fit.js";
|
||||
export * from "./paged-page-decorations.js";
|
||||
export * from "./paged-render-target.js";
|
||||
export * from "./paged-table-handler.js";
|
||||
export * from "./pdf-document-links.js";
|
||||
export * from "./preview-styles.js";
|
||||
|
||||
@@ -37,7 +37,11 @@ import { renderMermaidDefinitions } from "./mermaid-renderer.js";
|
||||
import { replaceMermaidSvgWithImages } from "./mermaid-static-image.js";
|
||||
import type { MermaidOutputMode } from "./mermaid-static-image.js";
|
||||
import { enablePrintMediaForPreview } from "./preview-styles.js";
|
||||
import "./paged-table-handler.js";
|
||||
import {
|
||||
forcePagedTableRowsToNextPage,
|
||||
missingPagedTableRowIds,
|
||||
stabilizePagedTableColumns
|
||||
} from "./paged-table-handler.js";
|
||||
import {
|
||||
buildPagedMediaCss,
|
||||
continuousDocumentGeometryCss,
|
||||
@@ -61,6 +65,7 @@ export interface PagedDocumentRenderOptions {
|
||||
target: PagedRenderTarget;
|
||||
shouldContinue?: () => boolean;
|
||||
mermaidOutput?: MermaidOutputMode;
|
||||
allowIncompleteTableGeometry?: boolean;
|
||||
}
|
||||
|
||||
export interface ContinuousDocumentRenderOptions {
|
||||
@@ -75,6 +80,33 @@ export interface PreviewEngineStyles {
|
||||
echartsCss: string;
|
||||
}
|
||||
|
||||
export function protectFittableInlineCodeParagraphs(
|
||||
root: ParentNode,
|
||||
pageContentHeightPx: number
|
||||
) {
|
||||
if (!Number.isFinite(pageContentHeightPx) || pageContentHeightPx <= 0) {
|
||||
return 0;
|
||||
}
|
||||
let protectedCount = 0;
|
||||
for (const paragraph of root.querySelectorAll<HTMLElement>(
|
||||
"#write > p.md-inline-code-paragraph"
|
||||
)) {
|
||||
const height = paragraph.getBoundingClientRect().height;
|
||||
if (!(height > 0 && height <= pageContentHeightPx)) {
|
||||
continue;
|
||||
}
|
||||
paragraph.style.setProperty("break-inside", "avoid", "important");
|
||||
paragraph.style.setProperty(
|
||||
"page-break-inside",
|
||||
"avoid",
|
||||
"important"
|
||||
);
|
||||
paragraph.dataset.mdtpInlineCodeKeepWhole = "true";
|
||||
protectedCount += 1;
|
||||
}
|
||||
return protectedCount;
|
||||
}
|
||||
|
||||
export const DEFAULT_IMAGE_READY_TIMEOUT_MS = 10_000;
|
||||
|
||||
function waitForImage(
|
||||
@@ -699,6 +731,7 @@ export class PagedDocumentRuntime {
|
||||
payload.features.includes("echarts") ||
|
||||
content.querySelector(".mermaid svg") ||
|
||||
content.querySelector("img.md-document-image") ||
|
||||
content.querySelector("table") ||
|
||||
content.querySelector('[data-semantic-region="cover"]')
|
||||
) {
|
||||
const measurement = mountMeasurementContainer(
|
||||
@@ -710,6 +743,14 @@ export class PagedDocumentRuntime {
|
||||
);
|
||||
try {
|
||||
await documentRef.fonts.ready;
|
||||
protectFittableInlineCodeParagraphs(
|
||||
measurement.host,
|
||||
getPageContentDimensions(payload.exportConfig).height
|
||||
);
|
||||
stabilizePagedTableColumns(
|
||||
measurement.host,
|
||||
getPageContentDimensions(payload.exportConfig).height
|
||||
);
|
||||
constrainSemanticCoversToPage(
|
||||
measurement.host,
|
||||
getPageContentDimensions(payload.exportConfig).height
|
||||
@@ -816,6 +857,51 @@ export class PagedDocumentRuntime {
|
||||
stylesheets,
|
||||
this.root
|
||||
);
|
||||
const maximumTableRowRecoveryAttempts = 3;
|
||||
for (
|
||||
let attempt = 0;
|
||||
attempt < maximumTableRowRecoveryAttempts;
|
||||
attempt += 1
|
||||
) {
|
||||
const missingRowIds = missingPagedTableRowIds(
|
||||
repaginationSource,
|
||||
this.root
|
||||
);
|
||||
if (missingRowIds.length === 0) {
|
||||
break;
|
||||
}
|
||||
const recoveredCount = forcePagedTableRowsToNextPage(
|
||||
repaginationSource,
|
||||
missingRowIds
|
||||
);
|
||||
if (recoveredCount !== missingRowIds.length) {
|
||||
throw new Error(
|
||||
`分页表格行恢复标记不完整:缺失 ${missingRowIds.length} 行,` +
|
||||
`仅定位 ${recoveredCount} 行`
|
||||
);
|
||||
}
|
||||
previewer.chunker.destroy();
|
||||
previewer.polisher.destroy();
|
||||
previewer = new Previewer();
|
||||
this.activePreviewer = previewer;
|
||||
flow = await previewer.preview(
|
||||
repaginationSource.cloneNode(true) as DocumentFragment,
|
||||
stylesheets,
|
||||
this.root
|
||||
);
|
||||
}
|
||||
const unresolvedTableRowIds = missingPagedTableRowIds(
|
||||
repaginationSource,
|
||||
this.root
|
||||
);
|
||||
if (
|
||||
unresolvedTableRowIds.length > 0 &&
|
||||
!options.allowIncompleteTableGeometry
|
||||
) {
|
||||
throw new Error(
|
||||
`分页后表格正文行缺失:${unresolvedTableRowIds.join(", ")}`
|
||||
);
|
||||
}
|
||||
const mediaIds = getMediaBackfillIdsInDocumentOrder(
|
||||
repaginationSource
|
||||
);
|
||||
|
||||
@@ -127,9 +127,21 @@ svg {
|
||||
table-layout: auto;
|
||||
}
|
||||
|
||||
#write table[data-stabilized-columns="true"] {
|
||||
table-layout: fixed !important;
|
||||
}
|
||||
|
||||
#write th,
|
||||
#write td {
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
#write th code,
|
||||
#write td code {
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.mermaid {
|
||||
@@ -421,6 +433,15 @@ ${buildFooterCss(config)}
|
||||
widows: 3;
|
||||
}
|
||||
|
||||
#write > p.md-inline-code-paragraph {
|
||||
orphans: 1;
|
||||
widows: 1;
|
||||
}
|
||||
|
||||
#write li {
|
||||
text-align-last: left;
|
||||
}
|
||||
|
||||
#write h1,
|
||||
#write h2,
|
||||
#write h3,
|
||||
@@ -489,6 +510,11 @@ ${buildFooterCss(config)}
|
||||
word-break: inherit;
|
||||
}
|
||||
|
||||
#write .md-code-line-indent {
|
||||
display: inline-block;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
#write table[data-empty-split-table="true"] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -9,8 +9,352 @@ interface PagedChunker {
|
||||
source: ParentNode;
|
||||
}
|
||||
|
||||
interface PagedHandlerContext {
|
||||
chunker: PagedChunker;
|
||||
function measuredColumnWidths(table: HTMLTableElement) {
|
||||
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
|
||||
);
|
||||
const tableRect = table.getBoundingClientRect();
|
||||
if (columnCount < 1 || tableRect.width <= 0) {
|
||||
return [];
|
||||
}
|
||||
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) =>
|
||||
samples.length > 0
|
||||
? samples.reduce((sum, value) => sum + value, 0) / samples.length
|
||||
: 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 applyColumnWidthWeights(
|
||||
columns: readonly HTMLTableColElement[],
|
||||
weights: readonly number[]
|
||||
) {
|
||||
for (const [index, column] of columns.entries()) {
|
||||
column.style.width = `${Math.max(0, weights[index] ?? 0)}px`;
|
||||
}
|
||||
}
|
||||
|
||||
function applyCellWidthTracks(
|
||||
table: HTMLTableElement,
|
||||
widths: readonly number[]
|
||||
) {
|
||||
for (const row of Array.from(table.rows)) {
|
||||
let columnIndex = 0;
|
||||
for (const cell of Array.from(row.cells)) {
|
||||
const span = Math.max(1, cell.colSpan);
|
||||
const width = widths
|
||||
.slice(columnIndex, columnIndex + span)
|
||||
.reduce((sum, value) => sum + value, 0);
|
||||
if (width > 0) {
|
||||
cell.style.width = `${width}px`;
|
||||
}
|
||||
columnIndex += span;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function stabilizePagedTableColumns(
|
||||
root: ParentNode,
|
||||
pageContentHeightPx?: number
|
||||
) {
|
||||
let stabilizedCount = 0;
|
||||
for (const [tableIndex, table] of Array.from(
|
||||
root.querySelectorAll<HTMLTableElement>("table")
|
||||
).entries()) {
|
||||
let bodyRowIndex = 0;
|
||||
if (pageContentHeightPx && pageContentHeightPx > 0) {
|
||||
for (const row of Array.from(table.rows)) {
|
||||
const rowHeight = row.getBoundingClientRect().height;
|
||||
const keepWhole = rowHeight > 0 && rowHeight <= pageContentHeightPx;
|
||||
if (row.parentElement?.tagName === "TBODY") {
|
||||
row.dataset.mdtpTableRowId ??=
|
||||
`table-${tableIndex + 1}-row-${bodyRowIndex + 1}`;
|
||||
row.dataset.mdtpTableRowKeepWhole = String(keepWhole);
|
||||
bodyRowIndex += 1;
|
||||
}
|
||||
for (const cell of Array.from(row.cells)) {
|
||||
cell.style.setProperty(
|
||||
"break-inside",
|
||||
keepWhole ? "avoid" : "auto",
|
||||
"important"
|
||||
);
|
||||
cell.style.setProperty(
|
||||
"page-break-inside",
|
||||
keepWhole ? "avoid" : "auto",
|
||||
"important"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (
|
||||
table.dataset.stabilizedColumns === "true" ||
|
||||
table.querySelector(":scope > colgroup")
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const widths = measuredColumnWidths(table);
|
||||
const total = widths.reduce((sum, value) => sum + value, 0);
|
||||
if (widths.length === 0 || total <= 0) {
|
||||
continue;
|
||||
}
|
||||
const colgroup = table.ownerDocument.createElement("colgroup");
|
||||
colgroup.dataset.stabilizedColumns = "true";
|
||||
for (const width of widths) {
|
||||
const column = table.ownerDocument.createElement("col");
|
||||
column.dataset.stabilizedWidthPx = String(width);
|
||||
colgroup.append(column);
|
||||
}
|
||||
table.insertBefore(colgroup, table.firstChild);
|
||||
table.dataset.stabilizedColumns = "true";
|
||||
table.style.tableLayout = "fixed";
|
||||
const columns = Array.from(colgroup.children) as HTMLTableColElement[];
|
||||
let weights = widths;
|
||||
applyColumnWidthWeights(columns, weights);
|
||||
for (let iteration = 0; iteration < 3; iteration += 1) {
|
||||
const measured = measuredColumnWidths(table);
|
||||
if (
|
||||
measured.length !== widths.length ||
|
||||
measured.some((width) => width <= 0)
|
||||
) {
|
||||
break;
|
||||
}
|
||||
weights = weights.map(
|
||||
(weight, index) => weight * widths[index]! / measured[index]!
|
||||
);
|
||||
applyColumnWidthWeights(columns, weights);
|
||||
}
|
||||
applyCellWidthTracks(table, widths);
|
||||
stabilizedCount += 1;
|
||||
}
|
||||
return stabilizedCount;
|
||||
}
|
||||
|
||||
export function missingPagedTableRowIds(
|
||||
source: ParentNode,
|
||||
rendered: ParentNode
|
||||
) {
|
||||
const isVisiblePagedRow = (row: HTMLElement) => {
|
||||
const pageArea = row.closest<HTMLElement>(".pagedjs_area");
|
||||
if (!pageArea) {
|
||||
return true;
|
||||
}
|
||||
const rowRect = row.getBoundingClientRect();
|
||||
const areaRect = pageArea.getBoundingClientRect();
|
||||
const intersectionWidth = Math.min(rowRect.right, areaRect.right) -
|
||||
Math.max(rowRect.left, areaRect.left);
|
||||
const intersectionHeight = Math.min(rowRect.bottom, areaRect.bottom) -
|
||||
Math.max(rowRect.top, areaRect.top);
|
||||
return intersectionWidth > 0.5 && intersectionHeight > 0.5;
|
||||
};
|
||||
const renderedIds = new Set(
|
||||
Array.from(rendered.querySelectorAll<HTMLElement>(
|
||||
"[data-mdtp-table-row-id]"
|
||||
)).filter(isVisiblePagedRow)
|
||||
.map((row) => row.dataset.mdtpTableRowId)
|
||||
.filter((id): id is string => Boolean(id))
|
||||
);
|
||||
return Array.from(source.querySelectorAll<HTMLTableRowElement>(
|
||||
'tbody > tr[data-mdtp-table-row-id][data-mdtp-table-row-keep-whole="true"]'
|
||||
)).map((row) => row.dataset.mdtpTableRowId)
|
||||
.filter((id): id is string => Boolean(id))
|
||||
.filter((id) => !renderedIds.has(id));
|
||||
}
|
||||
|
||||
function splitPagedTableBeforeRow(row: HTMLTableRowElement) {
|
||||
const body = row.parentElement as HTMLTableSectionElement | null;
|
||||
const table = row.closest<HTMLTableElement>("table");
|
||||
if (
|
||||
!body ||
|
||||
body.tagName !== "TBODY" ||
|
||||
!table ||
|
||||
body.querySelector(":scope > tr") === row
|
||||
) {
|
||||
return table;
|
||||
}
|
||||
|
||||
const continuation = table.cloneNode(false) as HTMLTableElement;
|
||||
continuation.dataset.mdtpTableRecoveryContinuation = "true";
|
||||
for (const child of Array.from(table.children)) {
|
||||
if (child.tagName === "COLGROUP" || child.tagName === "THEAD") {
|
||||
continuation.append(child.cloneNode(true));
|
||||
}
|
||||
}
|
||||
|
||||
const continuationBody = body.cloneNode(false) as HTMLTableSectionElement;
|
||||
let movingRow: HTMLTableRowElement | null = row;
|
||||
while (movingRow) {
|
||||
const nextRow = movingRow.nextElementSibling as HTMLTableRowElement | null;
|
||||
continuationBody.append(movingRow);
|
||||
movingRow = nextRow;
|
||||
}
|
||||
continuation.append(continuationBody);
|
||||
|
||||
let followingSection = body.nextElementSibling;
|
||||
while (followingSection) {
|
||||
const nextSection = followingSection.nextElementSibling;
|
||||
continuation.append(followingSection);
|
||||
followingSection = nextSection;
|
||||
}
|
||||
table.after(continuation);
|
||||
return continuation;
|
||||
}
|
||||
|
||||
export function forcePagedTableRowsToNextPage(
|
||||
source: ParentNode,
|
||||
rowIds: readonly string[]
|
||||
) {
|
||||
const requested = new Set(rowIds);
|
||||
let count = 0;
|
||||
for (const row of source.querySelectorAll<HTMLTableRowElement>(
|
||||
"tbody > tr[data-mdtp-table-row-id]"
|
||||
)) {
|
||||
const id = row.dataset.mdtpTableRowId;
|
||||
if (!id || !requested.has(id)) {
|
||||
continue;
|
||||
}
|
||||
const table = splitPagedTableBeforeRow(row);
|
||||
let recoveryTarget: HTMLElement = row;
|
||||
if (table?.parentNode) {
|
||||
const previous = table.previousElementSibling as HTMLElement | null;
|
||||
const marker = previous?.dataset.mdtpTableRecoveryMarker === "true"
|
||||
? previous
|
||||
: table.ownerDocument.createElement("div");
|
||||
if (marker !== previous) {
|
||||
marker.dataset.mdtpTableRecoveryMarker = "true";
|
||||
marker.setAttribute("aria-hidden", "true");
|
||||
marker.textContent = "\u00a0";
|
||||
marker.style.setProperty("height", "1px", "important");
|
||||
marker.style.setProperty("line-height", "1px", "important");
|
||||
marker.style.setProperty("margin", "0 0 -1px", "important");
|
||||
marker.style.setProperty("padding", "0", "important");
|
||||
marker.style.setProperty("border", "0", "important");
|
||||
marker.style.setProperty("overflow", "hidden", "important");
|
||||
marker.style.setProperty("opacity", "0", "important");
|
||||
table.parentNode.insertBefore(marker, table);
|
||||
}
|
||||
recoveryTarget = marker;
|
||||
table.dataset.mdtpTableRecovery = "true";
|
||||
}
|
||||
recoveryTarget.style.setProperty("break-before", "page", "important");
|
||||
recoveryTarget.style.setProperty(
|
||||
"page-break-before",
|
||||
"always",
|
||||
"important"
|
||||
);
|
||||
row.dataset.mdtpTableRowRecovery = "true";
|
||||
count += 1;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
export function restorePagedTableStructure(
|
||||
sourceTable: HTMLTableElement,
|
||||
renderedTable: HTMLTableElement
|
||||
) {
|
||||
restorePagedTableColumns(sourceTable, renderedTable);
|
||||
|
||||
if (!renderedTable.querySelector("thead")) {
|
||||
const sourceHeader = sourceTable.querySelector("thead");
|
||||
if (sourceHeader) {
|
||||
const firstNonColumnGroup = Array.from(renderedTable.children).find(
|
||||
(child) => child.tagName !== "COLGROUP"
|
||||
) ?? null;
|
||||
renderedTable.insertBefore(
|
||||
sourceHeader.cloneNode(true),
|
||||
firstNonColumnGroup
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function restorePagedTableColumns(
|
||||
sourceTable: HTMLTableElement,
|
||||
renderedTable: HTMLTableElement
|
||||
) {
|
||||
const sourceGroups = Array.from(
|
||||
sourceTable.querySelectorAll<HTMLTableColElement>(
|
||||
":scope > colgroup"
|
||||
)
|
||||
);
|
||||
if (sourceGroups.length === 0) {
|
||||
return;
|
||||
}
|
||||
const renderedGroups = Array.from(
|
||||
renderedTable.querySelectorAll<HTMLTableColElement>(
|
||||
":scope > colgroup"
|
||||
)
|
||||
);
|
||||
const matchingStructure =
|
||||
renderedGroups.length === sourceGroups.length &&
|
||||
renderedGroups.every(
|
||||
(group, index) =>
|
||||
group.children.length === sourceGroups[index]?.children.length
|
||||
);
|
||||
if (!matchingStructure) {
|
||||
for (const group of renderedGroups) {
|
||||
group.remove();
|
||||
}
|
||||
const firstChild = renderedTable.firstChild;
|
||||
for (const group of sourceGroups) {
|
||||
renderedTable.insertBefore(group.cloneNode(true), firstChild);
|
||||
}
|
||||
} else {
|
||||
for (const [groupIndex, sourceGroup] of sourceGroups.entries()) {
|
||||
const renderedGroup = renderedGroups[groupIndex]!;
|
||||
for (const attribute of Array.from(sourceGroup.attributes)) {
|
||||
renderedGroup.setAttribute(attribute.name, attribute.value);
|
||||
}
|
||||
for (const [columnIndex, sourceColumn] of Array.from(
|
||||
sourceGroup.children
|
||||
).entries()) {
|
||||
const renderedColumn = renderedGroup.children[
|
||||
columnIndex
|
||||
] as HTMLTableColElement;
|
||||
for (const attribute of Array.from(sourceColumn.attributes)) {
|
||||
renderedColumn.setAttribute(attribute.name, attribute.value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (sourceTable.dataset.stabilizedColumns === "true") {
|
||||
renderedTable.dataset.stabilizedColumns = "true";
|
||||
renderedTable.style.tableLayout = "fixed";
|
||||
}
|
||||
}
|
||||
|
||||
function elementAncestors(
|
||||
@@ -48,6 +392,21 @@ class RepeatTableHeadersHandler extends Handler {
|
||||
breakToken?: PagedBreakToken
|
||||
) {
|
||||
this.splitTableRefs = [];
|
||||
for (const renderedTable of pageElement.querySelectorAll<HTMLTableElement>(
|
||||
"table[data-ref]"
|
||||
)) {
|
||||
const ref = renderedTable.getAttribute("data-ref");
|
||||
if (!ref) {
|
||||
continue;
|
||||
}
|
||||
const sourceTable =
|
||||
this.chunker.source.querySelector<HTMLTableElement>(
|
||||
`table[data-ref="${CSS.escape(ref)}"]`
|
||||
);
|
||||
if (sourceTable) {
|
||||
restorePagedTableColumns(sourceTable, renderedTable);
|
||||
}
|
||||
}
|
||||
const element = resolveBreakTokenElement(breakToken?.node);
|
||||
if (!element) {
|
||||
return;
|
||||
@@ -81,6 +440,21 @@ class RepeatTableHeadersHandler extends Handler {
|
||||
}
|
||||
|
||||
layout(rendered: HTMLElement) {
|
||||
for (const renderedTable of rendered.querySelectorAll<HTMLTableElement>(
|
||||
"table[data-ref]"
|
||||
)) {
|
||||
const ref = renderedTable.getAttribute("data-ref");
|
||||
if (!ref) {
|
||||
continue;
|
||||
}
|
||||
const sourceTable =
|
||||
this.chunker.source.querySelector<HTMLTableElement>(
|
||||
`table[data-ref="${CSS.escape(ref)}"]`
|
||||
);
|
||||
if (sourceTable) {
|
||||
restorePagedTableColumns(sourceTable, renderedTable);
|
||||
}
|
||||
}
|
||||
for (const ref of this.splitTableRefs) {
|
||||
const renderedTable =
|
||||
rendered.querySelector<HTMLTableElement>(
|
||||
@@ -101,23 +475,7 @@ class RepeatTableHeadersHandler extends Handler {
|
||||
continue;
|
||||
}
|
||||
|
||||
const firstChild = renderedTable.firstChild;
|
||||
for (const colgroup of sourceTable.querySelectorAll("colgroup")) {
|
||||
renderedTable.insertBefore(
|
||||
colgroup.cloneNode(true),
|
||||
firstChild
|
||||
);
|
||||
}
|
||||
|
||||
if (!renderedTable.querySelector("thead")) {
|
||||
const sourceHeader = sourceTable.querySelector("thead");
|
||||
if (sourceHeader) {
|
||||
renderedTable.insertBefore(
|
||||
sourceHeader.cloneNode(true),
|
||||
renderedTable.firstChild
|
||||
);
|
||||
}
|
||||
}
|
||||
restorePagedTableStructure(sourceTable, renderedTable);
|
||||
|
||||
renderedTable.setAttribute("data-repeated-header", "true");
|
||||
}
|
||||
|
||||
@@ -98,6 +98,32 @@ describe("代码块分页预处理", () => {
|
||||
).toBe("return");
|
||||
});
|
||||
|
||||
it("将高亮代码行首缩进封装为分页稳定节点", () => {
|
||||
const root = createRoot(
|
||||
'<span class="hljs-punctuation">{</span>\n ' +
|
||||
'<span class="hljs-attr">"nested"</span>: true\n' +
|
||||
'<span class="hljs-punctuation">}</span>\n'
|
||||
);
|
||||
|
||||
prepareCodeBlockPagination(root);
|
||||
|
||||
const lines = getLines(root);
|
||||
const indent = lines[1]?.querySelector(
|
||||
":scope > .md-code-line-indent"
|
||||
);
|
||||
expect(lines.map((line) => line.textContent)).toEqual([
|
||||
"{",
|
||||
' "nested": true',
|
||||
"}"
|
||||
]);
|
||||
expect(indent?.textContent).toBe(" ");
|
||||
expect((indent as HTMLElement | null)?.style.width).toBe("2ch");
|
||||
expect((indent as HTMLElement | null)?.dataset.codeIndentColumns)
|
||||
.toBe("2");
|
||||
expect(indent?.nextElementSibling?.classList.contains("hljs-attr"))
|
||||
.toBe(true);
|
||||
});
|
||||
|
||||
it("重复调用不会再次包装已经准备的代码块", () => {
|
||||
const root = createRoot("line-1\nline-2\n");
|
||||
|
||||
|
||||
@@ -2,13 +2,21 @@
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
collectPagedDocxTableGeometries,
|
||||
collectDocxDocumentLayoutPlan,
|
||||
collectDocxInlineCodeLayouts,
|
||||
collectDocxListItemLayouts,
|
||||
collectDocxTextBlockLayouts,
|
||||
collectDocxMediaCaptureTargets,
|
||||
forcePagedTableRowsToNextPage,
|
||||
mergePagedTableGeometries,
|
||||
missingPagedTableRowIds,
|
||||
protectFittableInlineCodeParagraphs,
|
||||
removeInheritedPagedSplitJustification,
|
||||
renderDocxMediaCapturePlan
|
||||
renderDocxMediaCapturePlan,
|
||||
restorePagedTableColumns,
|
||||
restorePagedTableStructure,
|
||||
stabilizePagedTableColumns
|
||||
} from "../src/index.js";
|
||||
|
||||
describe("DOCX 媒体捕获计划", () => {
|
||||
@@ -18,6 +26,10 @@ describe("DOCX 媒体捕获计划", () => {
|
||||
});
|
||||
|
||||
it("连续布局使用原始打印媒体主题 CSS", async () => {
|
||||
const render = vi.fn(async () => ({
|
||||
echartsErrors: [],
|
||||
mermaidErrors: []
|
||||
}));
|
||||
const renderContinuous = vi.fn(async () => ({
|
||||
echartsErrors: [],
|
||||
mermaidErrors: []
|
||||
@@ -26,7 +38,7 @@ describe("DOCX 媒体捕获计划", () => {
|
||||
root.innerHTML = '<article id="write"></article>';
|
||||
|
||||
await renderDocxMediaCapturePlan(
|
||||
{ renderContinuous } as never,
|
||||
{ render, renderContinuous } as never,
|
||||
root,
|
||||
{
|
||||
articleHtml: '<article id="write"></article>',
|
||||
@@ -57,6 +69,155 @@ describe("DOCX 媒体捕获计划", () => {
|
||||
expect.anything(),
|
||||
expect.objectContaining({ themeMedia: "print" })
|
||||
);
|
||||
expect(render).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
{
|
||||
target: "pdf",
|
||||
allowIncompleteTableGeometry: true
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it("仅将能完整放入一页的顶层行内代码段落保持为整体", () => {
|
||||
document.body.innerHTML = `
|
||||
<article id="write">
|
||||
<p id="short" class="md-inline-code-paragraph">短段落</p>
|
||||
<p id="long" class="md-inline-code-paragraph">超长段落</p>
|
||||
<table><tbody><tr><td>
|
||||
<p id="cell" class="md-inline-code-paragraph">单元格段落</p>
|
||||
</td></tr></tbody></table>
|
||||
</article>
|
||||
`;
|
||||
const short = document.querySelector<HTMLElement>("#short")!;
|
||||
const long = document.querySelector<HTMLElement>("#long")!;
|
||||
const cell = document.querySelector<HTMLElement>("#cell")!;
|
||||
short.getBoundingClientRect = () => ({ height: 240 }) as DOMRect;
|
||||
long.getBoundingClientRect = () => ({ height: 1200 }) as DOMRect;
|
||||
cell.getBoundingClientRect = () => ({ height: 120 }) as DOMRect;
|
||||
|
||||
expect(protectFittableInlineCodeParagraphs(document, 900)).toBe(1);
|
||||
expect(short.style.getPropertyValue("break-inside")).toBe("avoid");
|
||||
expect(short.style.getPropertyPriority("break-inside")).toBe("important");
|
||||
expect(short.dataset.mdtpInlineCodeKeepWhole).toBe("true");
|
||||
expect(long.style.getPropertyValue("break-inside")).toBe("");
|
||||
expect(cell.style.getPropertyValue("break-inside")).toBe("");
|
||||
});
|
||||
|
||||
it("按逻辑表格去重采集分页后的真实列轨", () => {
|
||||
document.body.innerHTML = `
|
||||
<main class="pagedjs_pages">
|
||||
<section class="pagedjs_page">
|
||||
<div class="pagedjs_area">
|
||||
<table data-ref="table-a">
|
||||
<colgroup data-stabilized-columns="true">
|
||||
<col data-stabilized-width-px="120">
|
||||
<col data-stabilized-width-px="280">
|
||||
</colgroup>
|
||||
<tbody><tr><td>A</td><td>B</td></tr></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
<section class="pagedjs_page">
|
||||
<div class="pagedjs_area">
|
||||
<table data-ref="table-a">
|
||||
<colgroup data-stabilized-columns="true">
|
||||
<col data-stabilized-width-px="120">
|
||||
<col data-stabilized-width-px="280">
|
||||
</colgroup>
|
||||
<tbody><tr><td>C</td><td>D</td></tr></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
`;
|
||||
const areas = Array.from(
|
||||
document.querySelectorAll<HTMLElement>(".pagedjs_area")
|
||||
);
|
||||
const tables = Array.from(
|
||||
document.querySelectorAll<HTMLTableElement>("table")
|
||||
);
|
||||
areas.forEach((area) => {
|
||||
area.getBoundingClientRect = () => ({
|
||||
left: 100, right: 900, top: 0, bottom: 900,
|
||||
width: 800, height: 900
|
||||
}) as DOMRect;
|
||||
});
|
||||
tables.forEach((table, index) => {
|
||||
table.getBoundingClientRect = () => ({
|
||||
left: index === 0 ? 900 : 140,
|
||||
right: index === 0 ? 1300 : 540,
|
||||
top: 20, bottom: 220,
|
||||
width: 400, height: 200
|
||||
}) as DOMRect;
|
||||
});
|
||||
|
||||
expect(collectPagedDocxTableGeometries(document, {
|
||||
contentWidthPx: 800,
|
||||
contentHeightPx: 900
|
||||
})).toEqual([{
|
||||
ordinal: 1,
|
||||
widthPercent: 50,
|
||||
leftOffsetPercent: 5,
|
||||
columnWidthPercents: [30, 70]
|
||||
}]);
|
||||
});
|
||||
|
||||
it("只覆盖列数一致的分页表格几何并保留连续布局行样式", () => {
|
||||
const row = {
|
||||
cells: [{
|
||||
columnSpan: 1,
|
||||
backgroundColor: "#ffffff",
|
||||
color: "#000000",
|
||||
bold: true,
|
||||
italic: false,
|
||||
alignment: "left" as const
|
||||
}]
|
||||
};
|
||||
const layout = {
|
||||
tables: [
|
||||
{
|
||||
ordinal: 1,
|
||||
widthPercent: 100,
|
||||
leftOffsetPercent: 0,
|
||||
columnWidthPercents: [40, 60],
|
||||
rows: [row]
|
||||
},
|
||||
{
|
||||
ordinal: 2,
|
||||
widthPercent: 100,
|
||||
leftOffsetPercent: 0,
|
||||
columnWidthPercents: [100],
|
||||
rows: [row]
|
||||
}
|
||||
],
|
||||
textBlocks: [],
|
||||
listItems: [],
|
||||
inlineCodes: []
|
||||
};
|
||||
|
||||
const merged = mergePagedTableGeometries(layout, [
|
||||
{
|
||||
ordinal: 1,
|
||||
widthPercent: 90,
|
||||
leftOffsetPercent: 5,
|
||||
columnWidthPercents: [30, 70]
|
||||
},
|
||||
{
|
||||
ordinal: 2,
|
||||
widthPercent: 80,
|
||||
leftOffsetPercent: 10,
|
||||
columnWidthPercents: [20, 80]
|
||||
}
|
||||
]);
|
||||
|
||||
expect(merged.tables[0]).toEqual({
|
||||
...layout.tables[0],
|
||||
widthPercent: 90,
|
||||
leftOffsetPercent: 5,
|
||||
columnWidthPercents: [30, 70]
|
||||
});
|
||||
expect(merged.tables[0]?.rows).toBe(layout.tables[0]?.rows);
|
||||
expect(merged.tables[1]).toBe(layout.tables[1]);
|
||||
});
|
||||
|
||||
it("只移除会把 Paged.js 末行两端对齐继承给子块的容器标记", () => {
|
||||
@@ -334,6 +495,382 @@ describe("DOCX 媒体捕获计划", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("保留主题表格超出文档内容盒的实测宽度", () => {
|
||||
document.body.innerHTML = `
|
||||
<article id="write"><table><tbody><tr><td>内容</td></tr></tbody></table></article>
|
||||
`;
|
||||
const article = document.querySelector<HTMLElement>("#write")!;
|
||||
const table = document.querySelector<HTMLTableElement>("table")!;
|
||||
const cell = document.querySelector<HTMLTableCellElement>("td")!;
|
||||
article.getBoundingClientRect = () => ({
|
||||
left: 100,
|
||||
right: 900,
|
||||
top: 0,
|
||||
bottom: 900,
|
||||
width: 800,
|
||||
height: 900,
|
||||
}) as DOMRect;
|
||||
table.getBoundingClientRect = () => ({
|
||||
left: 100,
|
||||
right: 980,
|
||||
top: 20,
|
||||
bottom: 120,
|
||||
width: 880,
|
||||
height: 100,
|
||||
}) as DOMRect;
|
||||
cell.getBoundingClientRect = table.getBoundingClientRect;
|
||||
|
||||
const layout = collectDocxDocumentLayoutPlan(document, {
|
||||
contentWidthPx: 800,
|
||||
contentHeightPx: 900,
|
||||
});
|
||||
|
||||
expect(layout.tables[0]?.widthPercent).toBeCloseTo(110, 8);
|
||||
expect(layout.tables[0]?.columnWidthPercents).toEqual([100]);
|
||||
});
|
||||
|
||||
it("分页前冻结整表实测列宽供拆分页片段复用", () => {
|
||||
document.body.innerHTML = `
|
||||
<article id="write"><table><tbody><tr><td>A</td><td>B</td></tr></tbody></table></article>
|
||||
`;
|
||||
const table = document.querySelector<HTMLTableElement>("table")!;
|
||||
const cells = Array.from(table.rows[0]!.cells);
|
||||
table.getBoundingClientRect = () => ({
|
||||
left: 100,
|
||||
right: 500,
|
||||
top: 0,
|
||||
bottom: 100,
|
||||
width: 400,
|
||||
height: 100
|
||||
}) as DOMRect;
|
||||
cells[0]!.getBoundingClientRect = () => ({
|
||||
left: 100,
|
||||
right: 220,
|
||||
top: 0,
|
||||
bottom: 100,
|
||||
width: 120,
|
||||
height: 100
|
||||
}) as DOMRect;
|
||||
cells[1]!.getBoundingClientRect = () => ({
|
||||
left: 220,
|
||||
right: 500,
|
||||
top: 0,
|
||||
bottom: 100,
|
||||
width: 280,
|
||||
height: 100
|
||||
}) as DOMRect;
|
||||
|
||||
expect(stabilizePagedTableColumns(document)).toBe(1);
|
||||
expect(table.dataset.stabilizedColumns).toBe("true");
|
||||
expect(table.style.tableLayout).toBe("fixed");
|
||||
expect(Array.from(table.querySelectorAll("col")).map(
|
||||
(column) => column.style.width
|
||||
)).toEqual(["120px", "280px"]);
|
||||
expect(Array.from(table.querySelectorAll("col")).map(
|
||||
(column) => (column as HTMLTableColElement).dataset.stabilizedWidthPx
|
||||
)).toEqual(["120", "280"]);
|
||||
expect(Array.from(table.querySelectorAll("th, td")).map(
|
||||
(cell) => (cell as HTMLElement).style.width
|
||||
)).toEqual(["120px", "280px"]);
|
||||
expect(stabilizePagedTableColumns(document)).toBe(0);
|
||||
});
|
||||
|
||||
it("DOCX 布局采集保持分页前原始列轨而不受辅助样式二次测量影响", () => {
|
||||
document.body.innerHTML = `
|
||||
<article id="write">
|
||||
<table data-stabilized-columns="true">
|
||||
<colgroup data-stabilized-columns="true">
|
||||
<col data-stabilized-width-px="120" style="width: 140px">
|
||||
<col data-stabilized-width-px="280" style="width: 260px">
|
||||
</colgroup>
|
||||
<tbody><tr><td>A</td><td>B</td></tr></tbody>
|
||||
</table>
|
||||
</article>
|
||||
`;
|
||||
const article = document.querySelector<HTMLElement>("#write")!;
|
||||
const table = document.querySelector<HTMLTableElement>("table")!;
|
||||
const cells = Array.from(table.rows[0]!.cells);
|
||||
article.getBoundingClientRect = () => ({
|
||||
left: 100, right: 500, top: 0, bottom: 100,
|
||||
width: 400, height: 100
|
||||
}) as DOMRect;
|
||||
table.getBoundingClientRect = article.getBoundingClientRect;
|
||||
cells[0]!.getBoundingClientRect = () => ({
|
||||
left: 100, right: 240, top: 0, bottom: 100,
|
||||
width: 140, height: 100
|
||||
}) as DOMRect;
|
||||
cells[1]!.getBoundingClientRect = () => ({
|
||||
left: 240, right: 500, top: 0, bottom: 100,
|
||||
width: 260, height: 100
|
||||
}) as DOMRect;
|
||||
|
||||
const layout = collectDocxDocumentLayoutPlan(document, {
|
||||
contentWidthPx: 400,
|
||||
contentHeightPx: 900
|
||||
});
|
||||
|
||||
expect(layout.tables[0]?.columnWidthPercents).toEqual([30, 70]);
|
||||
});
|
||||
|
||||
it("冻结跨列单元格为对应列轨宽度之和", () => {
|
||||
document.body.innerHTML = `
|
||||
<table><tbody>
|
||||
<tr><td colspan="2">跨列</td></tr>
|
||||
<tr><td>甲</td><td>乙</td></tr>
|
||||
</tbody></table>
|
||||
`;
|
||||
const table = document.querySelector("table")!;
|
||||
const cells = Array.from(table.querySelectorAll("td"));
|
||||
table.getBoundingClientRect = () => ({
|
||||
left: 100, right: 500, top: 0, bottom: 100,
|
||||
width: 400, height: 100
|
||||
}) as DOMRect;
|
||||
cells[0]!.getBoundingClientRect = () => ({
|
||||
left: 100, right: 500, top: 0, bottom: 50,
|
||||
width: 400, height: 50
|
||||
}) as DOMRect;
|
||||
cells[1]!.getBoundingClientRect = () => ({
|
||||
left: 100, right: 220, top: 50, bottom: 100,
|
||||
width: 120, height: 50
|
||||
}) as DOMRect;
|
||||
cells[2]!.getBoundingClientRect = () => ({
|
||||
left: 220, right: 500, top: 50, bottom: 100,
|
||||
width: 280, height: 50
|
||||
}) as DOMRect;
|
||||
|
||||
expect(stabilizePagedTableColumns(document)).toBe(1);
|
||||
expect(cells.map((cell) => cell.style.width)).toEqual([
|
||||
"400px", "120px", "280px"
|
||||
]);
|
||||
});
|
||||
|
||||
it("分页片段已有冻结列定义时不重复插入 colgroup", () => {
|
||||
document.body.innerHTML = `
|
||||
<table id="source">
|
||||
<colgroup data-stabilized-columns="true"><col style="width: 30%"><col style="width: 70%"></colgroup>
|
||||
<thead><tr><th>A</th><th>B</th></tr></thead>
|
||||
<tbody><tr><td>甲</td><td>乙</td></tr></tbody>
|
||||
</table>
|
||||
<table id="rendered">
|
||||
<colgroup data-stabilized-columns="true"><col style="width: 30%"><col style="width: 70%"></colgroup>
|
||||
<tbody><tr><td>丙</td><td>丁</td></tr></tbody>
|
||||
</table>
|
||||
`;
|
||||
const source = document.querySelector<HTMLTableElement>("#source")!;
|
||||
const rendered = document.querySelector<HTMLTableElement>("#rendered")!;
|
||||
|
||||
restorePagedTableStructure(source, rendered);
|
||||
|
||||
expect(rendered.querySelectorAll(":scope > colgroup")).toHaveLength(1);
|
||||
expect(rendered.querySelectorAll(":scope > colgroup > col")).toHaveLength(2);
|
||||
expect(rendered.querySelectorAll(":scope > thead")).toHaveLength(1);
|
||||
expect(Array.from(rendered.children).map((child) => child.tagName)).toEqual([
|
||||
"COLGROUP",
|
||||
"THEAD",
|
||||
"TBODY"
|
||||
]);
|
||||
});
|
||||
|
||||
it("分页前仅对可容纳于单页的表格行单元格启用整行保护", () => {
|
||||
document.body.innerHTML = `
|
||||
<table><tbody>
|
||||
<tr id="normal"><td>普通行</td><td>内容</td></tr>
|
||||
<tr id="tall"><td>超高行</td><td>内容</td></tr>
|
||||
</tbody></table>
|
||||
`;
|
||||
const table = document.querySelector<HTMLTableElement>("table")!;
|
||||
const normal = document.querySelector<HTMLTableRowElement>("#normal")!;
|
||||
const tall = document.querySelector<HTMLTableRowElement>("#tall")!;
|
||||
table.getBoundingClientRect = () => ({
|
||||
width: 400, height: 1020, top: 0, right: 400,
|
||||
bottom: 1020, left: 0
|
||||
}) as DOMRect;
|
||||
normal.getBoundingClientRect = () => ({
|
||||
width: 400, height: 120, top: 0, right: 400,
|
||||
bottom: 120, left: 0
|
||||
}) as DOMRect;
|
||||
tall.getBoundingClientRect = () => ({
|
||||
width: 400, height: 900, top: 120, right: 400,
|
||||
bottom: 1020, left: 0
|
||||
}) as DOMRect;
|
||||
Array.from(table.rows).flatMap((row) => Array.from(row.cells))
|
||||
.forEach((cell, index) => {
|
||||
cell.getBoundingClientRect = () => ({
|
||||
width: 200,
|
||||
height: index < 2 ? 120 : 900,
|
||||
top: index < 2 ? 0 : 120,
|
||||
right: index % 2 === 0 ? 200 : 400,
|
||||
bottom: index < 2 ? 120 : 1020,
|
||||
left: index % 2 === 0 ? 0 : 200
|
||||
}) as DOMRect;
|
||||
});
|
||||
|
||||
stabilizePagedTableColumns(document, 800);
|
||||
|
||||
expect(Array.from(normal.cells).map(
|
||||
(cell) => cell.style.getPropertyValue("break-inside")
|
||||
)).toEqual(["avoid", "avoid"]);
|
||||
expect(Array.from(tall.cells).map(
|
||||
(cell) => cell.style.getPropertyValue("break-inside")
|
||||
)).toEqual(["auto", "auto"]);
|
||||
expect(Array.from(normal.cells).every(
|
||||
(cell) => cell.style.getPropertyPriority("break-inside") === "important"
|
||||
)).toBe(true);
|
||||
expect(normal.dataset.mdtpTableRowId).toBe("table-1-row-1");
|
||||
expect(normal.dataset.mdtpTableRowKeepWhole).toBe("true");
|
||||
expect(tall.dataset.mdtpTableRowId).toBe("table-1-row-2");
|
||||
expect(tall.dataset.mdtpTableRowKeepWhole).toBe("false");
|
||||
});
|
||||
|
||||
it("检测分页后缺失的可容纳正文行并施加恢复断点", () => {
|
||||
document.body.innerHTML = `
|
||||
<main id="source"><table><tbody>
|
||||
<tr data-mdtp-table-row-id="row-1" data-mdtp-table-row-keep-whole="true"><td>甲</td></tr>
|
||||
<tr data-mdtp-table-row-id="row-2" data-mdtp-table-row-keep-whole="true"><td>乙</td></tr>
|
||||
<tr data-mdtp-table-row-id="row-tall" data-mdtp-table-row-keep-whole="false"><td>超高</td></tr>
|
||||
</tbody></table></main>
|
||||
<main id="rendered"><table><tbody>
|
||||
<tr data-mdtp-table-row-id="row-2"><td>乙</td></tr>
|
||||
</tbody></table></main>
|
||||
`;
|
||||
const source = document.querySelector("#source")!;
|
||||
const rendered = document.querySelector("#rendered")!;
|
||||
|
||||
expect(missingPagedTableRowIds(source, rendered)).toEqual(["row-1"]);
|
||||
expect(forcePagedTableRowsToNextPage(source, ["row-1"])).toBe(1);
|
||||
const row = source.querySelector<HTMLTableRowElement>(
|
||||
'[data-mdtp-table-row-id="row-1"]'
|
||||
)!;
|
||||
const table = row.closest("table")!;
|
||||
const marker = table.previousElementSibling as HTMLElement;
|
||||
expect(marker.dataset.mdtpTableRecoveryMarker).toBe("true");
|
||||
expect(marker.textContent).toBe("\u00a0");
|
||||
expect(marker.style.getPropertyValue("height")).toBe("1px");
|
||||
expect(marker.style.getPropertyValue("margin-bottom")).toBe("-1px");
|
||||
expect(marker.style.getPropertyValue("opacity")).toBe("0");
|
||||
expect(marker.style.getPropertyValue("break-before")).toBe("page");
|
||||
expect(marker.style.getPropertyPriority("break-before")).toBe("important");
|
||||
expect(table.dataset.mdtpTableRecovery).toBe("true");
|
||||
expect(row.dataset.mdtpTableRowRecovery).toBe("true");
|
||||
expect(forcePagedTableRowsToNextPage(source, ["row-1"])).toBe(1);
|
||||
expect(source.querySelectorAll("[data-mdtp-table-recovery-marker]"))
|
||||
.toHaveLength(1);
|
||||
|
||||
});
|
||||
|
||||
it("在非首行缺失时拆分续表并将恢复断点放到表格外", () => {
|
||||
document.body.innerHTML = `
|
||||
<main id="source"><table id="source-table">
|
||||
<colgroup><col><col></colgroup>
|
||||
<thead><tr><th>甲</th><th>乙</th></tr></thead>
|
||||
<tbody>
|
||||
<tr data-mdtp-table-row-id="row-1"><td>一</td><td>壹</td></tr>
|
||||
<tr data-mdtp-table-row-id="row-2"><td>二</td><td>贰</td></tr>
|
||||
<tr data-mdtp-table-row-id="row-3"><td>三</td><td>叁</td></tr>
|
||||
</tbody>
|
||||
<tfoot><tr><td>尾</td><td>末</td></tr></tfoot>
|
||||
</table></main>
|
||||
`;
|
||||
const source = document.querySelector("#source")!;
|
||||
|
||||
expect(forcePagedTableRowsToNextPage(source, ["row-2"])).toBe(1);
|
||||
|
||||
const tables = source.querySelectorAll<HTMLTableElement>("table");
|
||||
expect(tables).toHaveLength(2);
|
||||
expect(Array.from(tables[0]!.querySelectorAll("tbody > tr")).map(
|
||||
(row) => (row as HTMLElement).dataset.mdtpTableRowId
|
||||
)).toEqual(["row-1"]);
|
||||
expect(Array.from(tables[1]!.querySelectorAll("tbody > tr")).map(
|
||||
(row) => (row as HTMLElement).dataset.mdtpTableRowId
|
||||
)).toEqual(["row-2", "row-3"]);
|
||||
expect(tables[1]!.dataset.mdtpTableRecoveryContinuation).toBe("true");
|
||||
expect(tables[1]!.querySelector("colgroup")).not.toBeNull();
|
||||
expect(tables[1]!.querySelector("thead")).not.toBeNull();
|
||||
expect(tables[0]!.querySelector("tfoot")).toBeNull();
|
||||
expect(tables[1]!.querySelector("tfoot")).not.toBeNull();
|
||||
const marker = tables[1]!.previousElementSibling as HTMLElement;
|
||||
expect(marker.dataset.mdtpTableRecoveryMarker).toBe("true");
|
||||
expect(marker.style.getPropertyValue("break-before")).toBe("page");
|
||||
expect(marker.style.getPropertyPriority("break-before")).toBe("important");
|
||||
});
|
||||
|
||||
it("仅将分页可视区域内真实可见的表格行视为已渲染", () => {
|
||||
document.body.innerHTML = `
|
||||
<main id="source"><table><tbody>
|
||||
<tr data-mdtp-table-row-id="row-clipped" data-mdtp-table-row-keep-whole="true"><td>裁切</td></tr>
|
||||
<tr data-mdtp-table-row-id="row-visible" data-mdtp-table-row-keep-whole="true"><td>可见</td></tr>
|
||||
</tbody></table></main>
|
||||
<main id="rendered">
|
||||
<section class="pagedjs_area" id="page-1">
|
||||
<table><tbody>
|
||||
<tr id="clipped" data-mdtp-table-row-id="row-clipped"><td>裁切</td></tr>
|
||||
<tr id="visible" data-mdtp-table-row-id="row-visible"><td>可见</td></tr>
|
||||
</tbody></table>
|
||||
</section>
|
||||
<section class="pagedjs_area" id="page-2">
|
||||
<table><tbody>
|
||||
<tr id="visible-duplicate" data-mdtp-table-row-id="row-visible"><td>可见副本</td></tr>
|
||||
</tbody></table>
|
||||
</section>
|
||||
</main>
|
||||
`;
|
||||
const rect = (
|
||||
left: number,
|
||||
top: number,
|
||||
right: number,
|
||||
bottom: number
|
||||
) => ({
|
||||
left, top, right, bottom,
|
||||
width: right - left,
|
||||
height: bottom - top,
|
||||
x: left,
|
||||
y: top,
|
||||
toJSON: () => ({})
|
||||
}) as DOMRect;
|
||||
document.querySelector<HTMLElement>("#page-1")!.getBoundingClientRect =
|
||||
() => rect(0, 0, 600, 800);
|
||||
document.querySelector<HTMLElement>("#page-2")!.getBoundingClientRect =
|
||||
() => rect(0, 900, 600, 1700);
|
||||
document.querySelector<HTMLElement>("#clipped")!.getBoundingClientRect =
|
||||
() => rect(0, 810, 600, 850);
|
||||
document.querySelector<HTMLElement>("#visible")!.getBoundingClientRect =
|
||||
() => rect(0, 780, 600, 820);
|
||||
document.querySelector<HTMLElement>("#visible-duplicate")!
|
||||
.getBoundingClientRect = () => rect(0, 920, 600, 960);
|
||||
|
||||
expect(missingPagedTableRowIds(
|
||||
document.querySelector("#source")!,
|
||||
document.querySelector("#rendered")!
|
||||
)).toEqual(["row-clipped"]);
|
||||
});
|
||||
|
||||
it("分页片段同步源表冻结列宽而不重复 colgroup", () => {
|
||||
document.body.innerHTML = `
|
||||
<table id="source" data-stabilized-columns="true">
|
||||
<colgroup data-stabilized-columns="true"><col data-stabilized-width-px="118" style="width: 120px"><col data-stabilized-width-px="282" style="width: 280px"></colgroup>
|
||||
<tbody><tr><td>甲</td><td>乙</td></tr></tbody>
|
||||
</table>
|
||||
<table id="rendered">
|
||||
<colgroup><col style="width: 100px"><col style="width: 300px"></colgroup>
|
||||
<tbody><tr><td>甲</td><td>乙</td></tr></tbody>
|
||||
</table>
|
||||
`;
|
||||
const source = document.querySelector<HTMLTableElement>("#source")!;
|
||||
const rendered = document.querySelector<HTMLTableElement>("#rendered")!;
|
||||
|
||||
restorePagedTableColumns(source, rendered);
|
||||
|
||||
expect(rendered.querySelectorAll(":scope > colgroup")).toHaveLength(1);
|
||||
expect(Array.from(rendered.querySelectorAll("col")).map(
|
||||
(column) => column.style.width
|
||||
)).toEqual(["120px", "280px"]);
|
||||
expect(Array.from(rendered.querySelectorAll("col")).map(
|
||||
(column) => (column as HTMLTableColElement).dataset.stabilizedWidthPx
|
||||
)).toEqual(["118", "282"]);
|
||||
expect(rendered.dataset.stabilizedColumns).toBe("true");
|
||||
expect(rendered.style.tableLayout).toBe("fixed");
|
||||
});
|
||||
|
||||
it("按真实上下文采集行内代码字号、颜色和盒模型", () => {
|
||||
document.body.innerHTML = `
|
||||
<article id="write" style="font-size: 20px">
|
||||
@@ -419,6 +956,42 @@ describe("DOCX 媒体捕获计划", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("不把普通两端对齐列表的自然末行误判为分散对齐", () => {
|
||||
document.body.innerHTML = `
|
||||
<article id="write">
|
||||
<ul><li style="text-align: justify; text-align-last: left">末行保持左对齐</li></ul>
|
||||
</article>
|
||||
`;
|
||||
const article = document.querySelector<HTMLElement>("#write")!;
|
||||
article.getBoundingClientRect = () => ({
|
||||
left: 0,
|
||||
right: 800,
|
||||
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) {
|
||||
return {
|
||||
left: this.startOffset * 80,
|
||||
right: this.startOffset * 80 + 10,
|
||||
top: 20,
|
||||
bottom: 32,
|
||||
width: 10,
|
||||
height: 12
|
||||
} as DOMRect;
|
||||
};
|
||||
try {
|
||||
expect(collectDocxListItemLayouts(article)[0]?.alignment).toBeUndefined();
|
||||
} finally {
|
||||
rangePrototype.getBoundingClientRect = original;
|
||||
}
|
||||
});
|
||||
|
||||
it("采集正文文本块在 Chromium 中的实际行断点", () => {
|
||||
document.body.innerHTML = `
|
||||
<article id="write">
|
||||
|
||||
@@ -40,6 +40,14 @@ const payload: PagedPreviewPayload = {
|
||||
};
|
||||
|
||||
describe("分页预览协议", () => {
|
||||
it("含行内代码的段落避免被 Paged.js 跨页拆分丢失片段", () => {
|
||||
const css = buildPagedMediaCss(defaultExportConfig, payload);
|
||||
|
||||
expect(css).toContain("#write > p.md-inline-code-paragraph");
|
||||
expect(css).toContain("orphans: 1");
|
||||
expect(css).toContain("widows: 1");
|
||||
});
|
||||
|
||||
it("生成真实纸张尺寸和页边距 CSS", () => {
|
||||
const css = buildPagedMediaCss(defaultExportConfig, payload);
|
||||
|
||||
@@ -278,6 +286,12 @@ describe("分页预览协议", () => {
|
||||
expect(documentBaseCss).toContain("white-space: pre-wrap;");
|
||||
expect(documentBaseCss).toContain("overflow-wrap: anywhere;");
|
||||
expect(documentBaseCss).toContain("word-break: break-word;");
|
||||
expect(documentBaseCss).toMatch(
|
||||
/#write th,[\s\S]*#write td \{[\s\S]*word-break: break-word;/u,
|
||||
);
|
||||
expect(documentBaseCss).toMatch(
|
||||
/#write th code,[\s\S]*#write td code \{[\s\S]*white-space: pre-wrap;[\s\S]*overflow-wrap: anywhere;[\s\S]*word-break: break-word;/u,
|
||||
);
|
||||
|
||||
const css = buildPagedMediaCss(defaultExportConfig, payload);
|
||||
expect(css).toContain("#write .md-code-line");
|
||||
|
||||
@@ -23,6 +23,7 @@ import hljs from "highlight.js";
|
||||
import MarkdownIt from "markdown-it";
|
||||
import type { Options as MarkdownItOptions } from "markdown-it";
|
||||
import type Renderer from "markdown-it/lib/renderer.mjs";
|
||||
import type StateInline from "markdown-it/lib/rules_inline/state_inline.mjs";
|
||||
import type Token from "markdown-it/lib/token.mjs";
|
||||
import sanitizeHtml from "sanitize-html";
|
||||
import { renderDocumentStructure } from "./render-document-structure.js";
|
||||
@@ -49,6 +50,7 @@ export interface RenderMarkdownOptions {
|
||||
export interface RenderedMarkdown
|
||||
extends Omit<RenderedMarkdownDocument, "rendererVersion"> {
|
||||
rendererVersion: typeof RENDERER_VERSION;
|
||||
markdownBody: string;
|
||||
}
|
||||
|
||||
const markdownOptions: MarkdownItOptions = {
|
||||
@@ -93,6 +95,104 @@ const markdown = new MarkdownIt(markdownOptions)
|
||||
trust: false
|
||||
});
|
||||
|
||||
const tableBreakMarkupPattern = /^(?:<br>|<br\/>|<br \/>)/iu;
|
||||
|
||||
function tableBreakCandidateRule(
|
||||
state: StateInline,
|
||||
silent: boolean
|
||||
): boolean {
|
||||
const match = state.src.slice(state.pos).match(tableBreakMarkupPattern);
|
||||
if (!match) {
|
||||
return false;
|
||||
}
|
||||
if (!silent) {
|
||||
const token = state.push("table_break_candidate", "", 0);
|
||||
token.content = match[0];
|
||||
}
|
||||
state.pos += match[0].length;
|
||||
return true;
|
||||
}
|
||||
|
||||
markdown.inline.ruler.before(
|
||||
"html_inline",
|
||||
"table_break_candidate",
|
||||
tableBreakCandidateRule
|
||||
);
|
||||
markdown.core.ruler.after("inline", "scope_table_breaks", (state) => {
|
||||
let tableCellDepth = 0;
|
||||
for (const token of state.tokens) {
|
||||
if (token.type === "td_open" || token.type === "th_open") {
|
||||
tableCellDepth += 1;
|
||||
continue;
|
||||
}
|
||||
if (token.type === "td_close" || token.type === "th_close") {
|
||||
tableCellDepth = Math.max(0, tableCellDepth - 1);
|
||||
continue;
|
||||
}
|
||||
if (token.type !== "inline") {
|
||||
continue;
|
||||
}
|
||||
for (const child of token.children ?? []) {
|
||||
if (child.type === "table_break_candidate" && tableCellDepth > 0) {
|
||||
child.type = "table_break";
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
markdown.renderer.rules.table_break_candidate = (tokens, index) =>
|
||||
markdown.utils.escapeHtml(tokens[index]?.content ?? "");
|
||||
markdown.renderer.rules.table_break = () => "<br>";
|
||||
|
||||
const markdownAlertTitles = new Map([
|
||||
["note", "Note"],
|
||||
["tip", "Tip"],
|
||||
["important", "Important"],
|
||||
["warning", "Warning"],
|
||||
["caution", "Caution"]
|
||||
]);
|
||||
|
||||
markdown.core.ruler.after("scope_table_breaks", "github_alerts", (state) => {
|
||||
for (let index = 0; index < state.tokens.length - 2; index += 1) {
|
||||
const blockquote = state.tokens[index];
|
||||
const paragraph = state.tokens[index + 1];
|
||||
const inline = state.tokens[index + 2];
|
||||
if (
|
||||
blockquote?.type !== "blockquote_open" ||
|
||||
paragraph?.type !== "paragraph_open" ||
|
||||
inline?.type !== "inline"
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const [marker, bodyBreak] = inline.children ?? [];
|
||||
const match = marker?.type === "text"
|
||||
? marker.content.match(/^\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\]$/iu)
|
||||
: undefined;
|
||||
if (!marker || !match) {
|
||||
continue;
|
||||
}
|
||||
const alertType = match[1]?.toLowerCase();
|
||||
const title = alertType ? markdownAlertTitles.get(alertType) : undefined;
|
||||
if (!alertType || !title) {
|
||||
continue;
|
||||
}
|
||||
blockquote.attrJoin("class", `md-alert md-alert-${alertType}`);
|
||||
marker.type = "markdown_alert_title";
|
||||
marker.content = title;
|
||||
marker.meta = { alertType };
|
||||
if (bodyBreak?.type === "softbreak") {
|
||||
bodyBreak.type = "markdown_alert_body_break";
|
||||
}
|
||||
}
|
||||
});
|
||||
markdown.renderer.rules.markdown_alert_title = (tokens, index) => {
|
||||
const token = tokens[index];
|
||||
const alertType = typeof token?.meta?.alertType === "string"
|
||||
? token.meta.alertType
|
||||
: "note";
|
||||
return `<span class="md-alert-text md-alert-text-${escapeAttribute(alertType)}">${markdown.utils.escapeHtml(token?.content ?? "")}</span>`;
|
||||
};
|
||||
markdown.renderer.rules.markdown_alert_body_break = () => "</p>\n<p>";
|
||||
|
||||
const defaultValidateLink = markdown.validateLink.bind(markdown);
|
||||
markdown.validateLink = (href) =>
|
||||
defaultValidateLink(href) ||
|
||||
@@ -141,6 +241,13 @@ markdown.renderer.rules.paragraph_open = (
|
||||
if (standaloneImage) {
|
||||
return '<figure class="md-document-image-block">\n';
|
||||
}
|
||||
const inlineToken = tokens[index + 1];
|
||||
if (
|
||||
inlineToken?.type === "inline" &&
|
||||
(inlineToken.children ?? []).some((child) => child.type === "code_inline")
|
||||
) {
|
||||
tokens[index]?.attrJoin("class", "md-inline-code-paragraph");
|
||||
}
|
||||
return defaultParagraphOpenRenderer
|
||||
? defaultParagraphOpenRenderer(
|
||||
tokens,
|
||||
@@ -367,6 +474,7 @@ export function renderMarkdown(
|
||||
|
||||
return {
|
||||
rendererVersion: RENDERER_VERSION,
|
||||
markdownBody: parsed.content,
|
||||
articleHtml,
|
||||
bodyHtml,
|
||||
metadata,
|
||||
|
||||
@@ -6,6 +6,38 @@ import {
|
||||
} from "../src/render-markdown.js";
|
||||
|
||||
describe("renderMarkdown", () => {
|
||||
it("将 GFM Alert 渲染为与 Pandoc 一致的独立标题引用块", () => {
|
||||
const result = renderMarkdown(
|
||||
"> [!CAUTION]\n> **关键约束**:必须执行。",
|
||||
);
|
||||
|
||||
expect(result.bodyHtml).toContain(
|
||||
'<blockquote class="md-alert md-alert-caution">',
|
||||
);
|
||||
expect(result.bodyHtml).toContain(
|
||||
'<p><span class="md-alert-text md-alert-text-caution">Caution</span></p>\n<p><strong>关键约束</strong>:必须执行。</p>',
|
||||
);
|
||||
expect(result.bodyHtml).not.toContain("[!CAUTION]");
|
||||
});
|
||||
|
||||
it("为含行内代码的普通段落标记分页保护类", () => {
|
||||
const result = renderMarkdown("段落前 `inline_code` 段落后");
|
||||
|
||||
expect(result.articleHtml).toContain(
|
||||
'<p class="md-inline-code-paragraph">段落前 <code>inline_code</code> 段落后</p>',
|
||||
);
|
||||
});
|
||||
|
||||
it("向下游暴露已剥离文首 Front Matter 的正文 Markdown", () => {
|
||||
const rendered = renderMarkdown(
|
||||
"---\ntitle: 测试标题\n---\n# 正文\n\n---\n\n后续内容"
|
||||
);
|
||||
|
||||
expect(rendered.markdownBody).toBe(
|
||||
"# 正文\n\n---\n\n后续内容"
|
||||
);
|
||||
expect(rendered.metadata.title).toBe("测试标题");
|
||||
});
|
||||
it("渲染常用 Markdown 扩展并识别能力", () => {
|
||||
const result = renderMarkdown(`
|
||||
# 示例文档
|
||||
@@ -80,6 +112,39 @@ const answer = 42;
|
||||
expect(result.bodyHtml).toContain('<td style="text-align:right">C</td>');
|
||||
});
|
||||
|
||||
it("只在 GFM 表格单元格内解释无属性 br 换行", () => {
|
||||
const result = renderMarkdown(`
|
||||
| 场景 | 内容 |
|
||||
| --- | --- |
|
||||
| 标准 | 第一行<br>第二行<BR/>第三行<br />第四行 |
|
||||
|
||||
正文中的 <br>、<br/> 和 <br /> 保持文本。
|
||||
|
||||
| 转义与代码 | 内容 |
|
||||
| --- | --- |
|
||||
| 转义 | \\<br> 与 <br> |
|
||||
| 代码 | \`<br>\` |
|
||||
| 属性 | <br class="unsafe"> |
|
||||
|
||||
\`\`\`html
|
||||
<br>
|
||||
\`\`\`
|
||||
`);
|
||||
|
||||
expect(result.bodyHtml).toContain(
|
||||
"第一行<br />第二行<br />第三行<br />第四行"
|
||||
);
|
||||
expect(result.bodyHtml).toContain(
|
||||
"正文中的 <br>、<br/> 和 <br /> 保持文本"
|
||||
);
|
||||
expect(result.bodyHtml).toContain("<br> 与 <br>");
|
||||
expect(result.bodyHtml).toContain("<code><br></code>");
|
||||
expect(result.bodyHtml).toContain("<br class=\"unsafe\">");
|
||||
expect(result.bodyHtml).toContain(
|
||||
'<pre class="md-fences" lang="html"><code class="language-html">'
|
||||
);
|
||||
});
|
||||
|
||||
it("读取并规范化 Front Matter 元数据", () => {
|
||||
const result = renderMarkdown(`---
|
||||
title: 项目报告
|
||||
|
||||
Reference in New Issue
Block a user