Files
MorphDoc/packages/application/src/docx-media-service.ts
T
SkyJourney 2c5c1bd317 release: 发布 v0.6.1 DOCX 视觉一致性修复
新增通用 CSS 到 OOXML 翻译修复,统一字体、字距、精确行距、段落、列表、表格、引用、代码块与行内代码连续性,不引入按主题 ID 分支。

新增 MdTP Mono 并统一 Serif、Sans、Mono 三字体包的 Chromium 与 DOCX 使用链;字体声明、嵌入部件和 Word/WPS 实际采用均进入硬门禁。

重建封面整页及正文语义块视觉差分,14 套主题、纵横两个方向、五组页边距共 140 个真实场景全部通过,阻断失败和诊断失败均为零。

源码服务、Docker Web API 与实际安装 Desktop 的 red-briefing 导出均包含 5 个字体部件;Word/WPS 原生渲染和逐页复核通过。修复 Docker 构建上下文与运行层复用软链接,并完善 v0.6.1 版本、发行说明和发布归集。

验证:npm test(116 个文件、616 项测试)、npm run typecheck、npm run build、git diff --check 全部通过。Desktop 安装器与 ZIP、Docker v0.6.1 镜像已生成;Windows 产物仍为未签名内部发行。
2026-08-04 10:30:44 +08:00

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: [] }
};
}