新增能力:将 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 均已生成并校验。
277 lines
7.5 KiB
TypeScript
277 lines
7.5 KiB
TypeScript
import { z } from "zod";
|
|
import { createDocxStyleProbeMarkup } from "./probe.js";
|
|
import {
|
|
docxThemeStyleSnapshotSchema,
|
|
type DocxComputedStyle,
|
|
type DocxThemeStyleSnapshot
|
|
} from "./snapshot.js";
|
|
import { DOCX_STYLE_SLOTS } from "./slots.js";
|
|
|
|
const DOCX_STYLE_PROBE_WIDTH_PX = 794;
|
|
const DOCX_STYLE_PROBE_HEIGHT_PX = 1123;
|
|
const DOCX_STYLE_PROBE_CONTENT_WIDTH_PX = 640;
|
|
|
|
export const docxThemeStyleCaptureRequestSchema = z.object({
|
|
themeId: z
|
|
.string()
|
|
.regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/u),
|
|
themeFingerprint: z.string().regex(/^[a-f0-9]{64}$/u),
|
|
themeCss: z.string().max(4 * 1024 * 1024),
|
|
baseUrl: z.string().url().max(2048)
|
|
});
|
|
|
|
export type DocxThemeStyleCaptureRequest = z.infer<
|
|
typeof docxThemeStyleCaptureRequestSchema
|
|
>;
|
|
|
|
export interface DocxThemeStyleCaptureAdapter {
|
|
readonly themeStyleBaseUrl: string;
|
|
captureThemeStyle(
|
|
request: DocxThemeStyleCaptureRequest,
|
|
signal?: AbortSignal
|
|
): Promise<DocxThemeStyleSnapshot>;
|
|
}
|
|
|
|
const runtimeCaptureSchema = z.object({
|
|
rootFontSizePx: z.number().positive().max(200),
|
|
viewport: z.object({
|
|
widthPx: z.number().int().positive().max(10000),
|
|
heightPx: z.number().int().positive().max(10000),
|
|
deviceScaleFactor: z.number().positive().max(10)
|
|
}),
|
|
slots: z.array(
|
|
z.object({
|
|
slot: z.string(),
|
|
matched: z.boolean(),
|
|
computed: z.record(z.string(), z.string()).optional()
|
|
})
|
|
)
|
|
});
|
|
|
|
const computedProperties = [
|
|
"fontFamily",
|
|
"fontSize",
|
|
"fontWeight",
|
|
"fontStyle",
|
|
"color",
|
|
"backgroundColor",
|
|
"backgroundImage",
|
|
"lineHeight",
|
|
"letterSpacing",
|
|
"textAlign",
|
|
"direction",
|
|
"textIndent",
|
|
"textDecorationLine",
|
|
"marginTop",
|
|
"marginRight",
|
|
"marginBottom",
|
|
"marginLeft",
|
|
"paddingTop",
|
|
"paddingRight",
|
|
"paddingBottom",
|
|
"paddingLeft",
|
|
"borderTop",
|
|
"borderRight",
|
|
"borderBottom",
|
|
"borderLeft",
|
|
"width",
|
|
"maxWidth",
|
|
"minWidth",
|
|
"minHeight",
|
|
"height",
|
|
"breakBefore",
|
|
"breakAfter",
|
|
"breakInside",
|
|
"display",
|
|
"flexDirection",
|
|
"alignItems",
|
|
"alignSelf",
|
|
"justifyContent",
|
|
"outline",
|
|
"outlineOffset"
|
|
] as const satisfies readonly (keyof DocxComputedStyle)[];
|
|
|
|
const baselineCss = `
|
|
html,
|
|
body {
|
|
margin: 0;
|
|
padding: 0;
|
|
}
|
|
|
|
* {
|
|
box-sizing: border-box;
|
|
}
|
|
|
|
#write {
|
|
width: ${DOCX_STYLE_PROBE_CONTENT_WIDTH_PX}px;
|
|
margin: 0;
|
|
padding: 0;
|
|
}
|
|
|
|
#write pre.md-fences > code,
|
|
#write .md-code-pagination-chunk > code {
|
|
display: block;
|
|
min-width: 0;
|
|
max-width: 100%;
|
|
padding: 0;
|
|
padding-left: 8px;
|
|
border: 0;
|
|
border-radius: 0;
|
|
background: transparent;
|
|
box-shadow: none;
|
|
font-size: inherit;
|
|
font-weight: inherit;
|
|
line-height: inherit;
|
|
}
|
|
`;
|
|
|
|
export function createDocxThemeStyleCaptureScript(
|
|
input: DocxThemeStyleCaptureRequest
|
|
): string {
|
|
const request = docxThemeStyleCaptureRequestSchema.parse(input);
|
|
const payload = JSON.stringify({
|
|
request,
|
|
markup: createDocxStyleProbeMarkup(),
|
|
slots: DOCX_STYLE_SLOTS,
|
|
properties: computedProperties,
|
|
baselineCss,
|
|
viewport: {
|
|
width: DOCX_STYLE_PROBE_WIDTH_PX,
|
|
height: DOCX_STYLE_PROBE_HEIGHT_PX
|
|
}
|
|
})
|
|
.replaceAll("<", "\\u003c")
|
|
.replaceAll(">", "\\u003e")
|
|
.replaceAll("\u2028", "\\u2028")
|
|
.replaceAll("\u2029", "\\u2029");
|
|
return `(async () => {
|
|
const payload = ${payload};
|
|
const frame = document.createElement("iframe");
|
|
frame.setAttribute("aria-hidden", "true");
|
|
frame.style.cssText =
|
|
"position:fixed;left:-32000px;top:-32000px;" +
|
|
"width:" + payload.viewport.width + "px;" +
|
|
"height:" + payload.viewport.height + "px;" +
|
|
"border:0;visibility:hidden;pointer-events:none;";
|
|
document.body.appendChild(frame);
|
|
try {
|
|
const probeDocument = frame.contentDocument;
|
|
const probeWindow = frame.contentWindow;
|
|
if (!probeDocument || !probeWindow) {
|
|
throw new Error("DOCX 主题样式探针 iframe 不可用");
|
|
}
|
|
probeDocument.open();
|
|
probeDocument.write(
|
|
"<!doctype html><html><head></head><body></body></html>"
|
|
);
|
|
probeDocument.close();
|
|
const base = probeDocument.createElement("base");
|
|
base.href = payload.request.baseUrl;
|
|
probeDocument.head.appendChild(base);
|
|
const baseline = probeDocument.createElement("style");
|
|
baseline.dataset.docxProbeStyle = "baseline";
|
|
baseline.textContent = payload.baselineCss;
|
|
probeDocument.head.appendChild(baseline);
|
|
const theme = probeDocument.createElement("style");
|
|
theme.dataset.docxProbeStyle = "theme";
|
|
theme.textContent = payload.request.themeCss;
|
|
probeDocument.head.appendChild(theme);
|
|
probeDocument.body.innerHTML = payload.markup;
|
|
const fontReady = probeDocument.fonts
|
|
? probeDocument.fonts.ready
|
|
: Promise.resolve();
|
|
await Promise.race([
|
|
fontReady,
|
|
new Promise((_, reject) => {
|
|
setTimeout(
|
|
() => reject(new Error("DOCX 主题字体等待超时")),
|
|
10000
|
|
);
|
|
})
|
|
]);
|
|
await new Promise((resolve) =>
|
|
probeWindow.requestAnimationFrame(() =>
|
|
probeWindow.requestAnimationFrame(resolve)
|
|
)
|
|
);
|
|
const slots = payload.slots.map((definition) => {
|
|
const element = probeDocument.querySelector(
|
|
definition.selector
|
|
);
|
|
if (!element) {
|
|
return {
|
|
slot: definition.name,
|
|
matched: false
|
|
};
|
|
}
|
|
const style = probeWindow.getComputedStyle(element);
|
|
const computed = {};
|
|
for (const property of payload.properties) {
|
|
computed[property] = style[property];
|
|
}
|
|
const contentElement = element.firstElementChild;
|
|
if (contentElement) {
|
|
computed.contentPaddingLeft =
|
|
probeWindow.getComputedStyle(contentElement).paddingLeft;
|
|
}
|
|
const containingBlock = element.parentElement;
|
|
if (containingBlock) {
|
|
const containingStyle = probeWindow.getComputedStyle(
|
|
containingBlock
|
|
);
|
|
const containingRect = containingBlock.getBoundingClientRect();
|
|
const horizontalInsets = [
|
|
containingStyle.borderLeftWidth,
|
|
containingStyle.borderRightWidth,
|
|
containingStyle.paddingLeft,
|
|
containingStyle.paddingRight
|
|
].reduce(
|
|
(total, value) => total + (Number.parseFloat(value) || 0),
|
|
0
|
|
);
|
|
computed.containingBlockWidth =
|
|
Math.max(0, containingRect.width - horizontalInsets) + "px";
|
|
}
|
|
return {
|
|
slot: definition.name,
|
|
matched: true,
|
|
computed
|
|
};
|
|
});
|
|
const rootFontSize = Number.parseFloat(
|
|
probeWindow.getComputedStyle(
|
|
probeDocument.documentElement
|
|
).fontSize
|
|
);
|
|
return {
|
|
rootFontSizePx: rootFontSize,
|
|
viewport: {
|
|
widthPx: payload.viewport.width,
|
|
heightPx: payload.viewport.height,
|
|
deviceScaleFactor: probeWindow.devicePixelRatio || 1
|
|
},
|
|
slots
|
|
};
|
|
} finally {
|
|
frame.remove();
|
|
}
|
|
})()`;
|
|
}
|
|
|
|
export function parseDocxThemeStyleRuntimeCapture(
|
|
requestInput: DocxThemeStyleCaptureRequest,
|
|
captureInput: unknown
|
|
): DocxThemeStyleSnapshot {
|
|
const request =
|
|
docxThemeStyleCaptureRequestSchema.parse(requestInput);
|
|
const capture = runtimeCaptureSchema.parse(captureInput);
|
|
return docxThemeStyleSnapshotSchema.parse({
|
|
schemaVersion: 1,
|
|
themeId: request.themeId,
|
|
themeFingerprint: request.themeFingerprint,
|
|
viewport: capture.viewport,
|
|
rootFontSizePx: capture.rootFontSizePx,
|
|
slots: capture.slots
|
|
});
|
|
}
|