新增通用 CSS 到 OOXML 翻译修复,统一字体、字距、精确行距、段落、列表、表格、引用、代码块与行内代码连续性,不引入按主题 ID 分支。 新增 MdTP Mono 并统一 Serif、Sans、Mono 三字体包的 Chromium 与 DOCX 使用链;字体声明、嵌入部件和 Word/WPS 实际采用均进入硬门禁。 重建封面整页及正文语义块视觉差分,14 套主题、纵横两个方向、五组页边距共 140 个真实场景全部通过,阻断失败和诊断失败均为零。 源码服务、Docker Web API 与实际安装 Desktop 的 red-briefing 导出均包含 5 个字体部件;Word/WPS 原生渲染和逐页复核通过。修复 Docker 构建上下文与运行层复用软链接,并完善 v0.6.1 版本、发行说明和发布归集。 验证:npm test(116 个文件、616 项测试)、npm run typecheck、npm run build、git diff --check 全部通过。Desktop 安装器与 ZIP、Docker v0.6.1 镜像已生成;Windows 产物仍为未签名内部发行。
204 lines
5.4 KiB
TypeScript
204 lines
5.4 KiB
TypeScript
import {
|
|
MAXIMUM_DOCX_MEDIA_BYTES,
|
|
MAXIMUM_DOCX_MEDIA_EDGE_PIXELS,
|
|
MAXIMUM_DOCX_MEDIA_PIXELS,
|
|
MAXIMUM_DOCX_TOTAL_MEDIA_BYTES,
|
|
createPagedDocumentPayload,
|
|
docxMediaCapturePlanSchema,
|
|
getPaperDimensionsMm,
|
|
lengthToMillimeters,
|
|
millimetersToCssPixels,
|
|
resolvePageMargins,
|
|
type DocxMediaCapturePlan,
|
|
type DocxPngMediaResource,
|
|
type PagedDocumentPayload,
|
|
type PreparedDocxMedia
|
|
} from "@md-to-pdf/core";
|
|
import type { PreparedDocxExport } from "./application-service.js";
|
|
|
|
export interface DocxMediaRenderDimensions {
|
|
contentWidthPx: number;
|
|
contentHeightPx: number;
|
|
}
|
|
|
|
export interface DocxMediaCaptureRequest {
|
|
payload: PagedDocumentPayload;
|
|
dimensions: DocxMediaRenderDimensions;
|
|
}
|
|
|
|
export interface DocxMediaCapture {
|
|
id: string;
|
|
png: Uint8Array;
|
|
}
|
|
|
|
export interface DocxMediaCaptureOutput {
|
|
plan: unknown;
|
|
captures: DocxMediaCapture[];
|
|
}
|
|
|
|
export interface DocxMediaCaptureAdapter {
|
|
capture(
|
|
request: DocxMediaCaptureRequest,
|
|
signal?: AbortSignal
|
|
): Promise<DocxMediaCaptureOutput>;
|
|
}
|
|
|
|
const pngSignature = [
|
|
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a
|
|
] as const;
|
|
|
|
function readPngDimensions(content: Uint8Array) {
|
|
if (
|
|
content.byteLength < 24 ||
|
|
pngSignature.some((value, index) => content[index] !== value) ||
|
|
String.fromCharCode(...content.slice(12, 16)) !== "IHDR"
|
|
) {
|
|
throw new Error("DOCX 媒体捕获结果不是有效 PNG");
|
|
}
|
|
const view = new DataView(
|
|
content.buffer,
|
|
content.byteOffset,
|
|
content.byteLength
|
|
);
|
|
const width = view.getUint32(16);
|
|
const height = view.getUint32(20);
|
|
if (
|
|
width < 1 ||
|
|
height < 1 ||
|
|
width > MAXIMUM_DOCX_MEDIA_EDGE_PIXELS ||
|
|
height > MAXIMUM_DOCX_MEDIA_EDGE_PIXELS ||
|
|
width * height > MAXIMUM_DOCX_MEDIA_PIXELS
|
|
) {
|
|
throw new Error("DOCX PNG 媒体像素尺寸超过限制");
|
|
}
|
|
return { width, height };
|
|
}
|
|
|
|
export function getDocxMediaRenderDimensions(
|
|
prepared: PreparedDocxExport
|
|
) {
|
|
const paper = prepared.request.exportConfig.paper;
|
|
const dimensions = getPaperDimensionsMm(
|
|
paper.format,
|
|
paper.orientation
|
|
);
|
|
const margins = resolvePageMargins(
|
|
paper,
|
|
prepared.theme.manifest.pageDefaults?.margins
|
|
);
|
|
const contentWidthMm =
|
|
dimensions.width -
|
|
lengthToMillimeters(margins.left) -
|
|
lengthToMillimeters(margins.right);
|
|
const contentHeightMm =
|
|
dimensions.height -
|
|
lengthToMillimeters(margins.top) -
|
|
lengthToMillimeters(margins.bottom);
|
|
return {
|
|
contentWidthPx: millimetersToCssPixels(contentWidthMm),
|
|
contentHeightPx: millimetersToCssPixels(contentHeightMm)
|
|
};
|
|
}
|
|
|
|
function validateCaptures(
|
|
plan: DocxMediaCapturePlan,
|
|
captures: DocxMediaCapture[]
|
|
) {
|
|
const byId = new Map<string, Uint8Array>();
|
|
let totalBytes = 0;
|
|
for (const capture of captures) {
|
|
if (byId.has(capture.id)) {
|
|
throw new Error(`DOCX 媒体 ${capture.id} 重复`);
|
|
}
|
|
if (capture.png.byteLength > MAXIMUM_DOCX_MEDIA_BYTES) {
|
|
throw new Error(`DOCX 媒体 ${capture.id} 超过单文件大小限制`);
|
|
}
|
|
totalBytes += capture.png.byteLength;
|
|
if (totalBytes > MAXIMUM_DOCX_TOTAL_MEDIA_BYTES) {
|
|
throw new Error("DOCX PNG 媒体总大小超过限制");
|
|
}
|
|
byId.set(capture.id, capture.png);
|
|
}
|
|
if (
|
|
byId.size !== plan.targets.length ||
|
|
plan.targets.some((target) => !byId.has(target.id))
|
|
) {
|
|
throw new Error("DOCX 媒体捕获结果与渲染计划不匹配");
|
|
}
|
|
|
|
const resources = plan.targets.map(
|
|
(target): DocxPngMediaResource => {
|
|
const content = byId.get(target.id)!;
|
|
const { width, height } = readPngDimensions(content);
|
|
const expectedWidth = Math.round(
|
|
target.captureWidthPx * target.rasterScale
|
|
);
|
|
const expectedHeight = Math.round(
|
|
target.captureHeightPx * target.rasterScale
|
|
);
|
|
if (
|
|
Math.abs(width - expectedWidth) > 2 ||
|
|
Math.abs(height - expectedHeight) > 2
|
|
) {
|
|
throw new Error(
|
|
`DOCX 媒体 ${target.id} 的 PNG 尺寸与捕获计划不一致`
|
|
);
|
|
}
|
|
return {
|
|
...target,
|
|
fileName: `media-${String(target.ordinal).padStart(
|
|
3,
|
|
"0"
|
|
)}.png`,
|
|
contentType: "image/png",
|
|
content,
|
|
pixelWidth: width,
|
|
pixelHeight: height
|
|
};
|
|
}
|
|
);
|
|
return { resources, totalBytes };
|
|
}
|
|
|
|
export async function prepareDocxMedia(
|
|
prepared: PreparedDocxExport,
|
|
adapter: DocxMediaCaptureAdapter,
|
|
signal?: AbortSignal
|
|
): Promise<PreparedDocxMedia> {
|
|
signal?.throwIfAborted();
|
|
const dimensions = getDocxMediaRenderDimensions(prepared);
|
|
const output = await adapter.capture(
|
|
{
|
|
payload: createPagedDocumentPayload({
|
|
document: prepared.document,
|
|
fileName: prepared.request.fileName,
|
|
themeCss: prepared.theme.css,
|
|
exportConfig: prepared.request.exportConfig
|
|
}),
|
|
dimensions
|
|
},
|
|
signal
|
|
);
|
|
signal?.throwIfAborted();
|
|
const parsedPlan = docxMediaCapturePlanSchema.safeParse(output.plan);
|
|
if (!parsedPlan.success) {
|
|
throw new Error("DOCX 媒体捕获计划无效");
|
|
}
|
|
const { resources, totalBytes } = validateCaptures(
|
|
parsedPlan.data,
|
|
output.captures
|
|
);
|
|
return {
|
|
resources,
|
|
echartsErrors: parsedPlan.data.echartsErrors,
|
|
mermaidErrors: parsedPlan.data.mermaidErrors,
|
|
warnings: [
|
|
...prepared.document.warnings,
|
|
...parsedPlan.data.echartsErrors,
|
|
...parsedPlan.data.mermaidErrors
|
|
],
|
|
totalBytes,
|
|
documentLayout: parsedPlan.data.documentLayout ?? { tables: [] }
|
|
};
|
|
}
|