feat: 实现 DOCX 媒体预处理与跨端 PNG 捕获

This commit is contained in:
SkyJourney
2026-07-30 13:21:18 +08:00
parent 7831bc23f0
commit fe5d67fb6d
20 changed files with 1294 additions and 16 deletions
+11
View File
@@ -10,12 +10,14 @@ Web Server 与 Electron Desktop 共用的应用服务层,组合
```text
src/
application-service.ts 渲染、主题和资源用例入口
docx-media-service.ts DOCX 媒体尺寸计算、PNG 校验与清单组装
image-resources.ts 本地、Base64 与受限远程图片处理
theme-registry.ts 内置及自定义主题扫描、校验与缓存
index.ts 公共导出入口
tests/
application-service.test.ts
bundled-themes.test.ts
docx-media-service.test.ts
image-resources.test.ts
```
@@ -32,6 +34,10 @@ const service = createApplicationService({
const themes = await service.listThemes();
const document = await service.render(request);
const preparedDocx = await service.prepareDocxExport(docxRequest);
const preparedMedia = await prepareDocxMedia(
preparedDocx,
platformCaptureAdapter
);
```
Web 端通过 `apps/server` 的 HTTP API 调用;桌面端在主进程中创建同一
@@ -42,6 +48,11 @@ Desktop 才会以 Markdown 所在目录为边界解析相对资源。
源请求、安全渲染文档、主题清单和主题 CSS。它不调用 Pandoc;后续 DOCX
引擎只消费该准备结果,避免 Server 与 Desktop 重复实现文档准备逻辑。
`prepareDocxMedia()` 根据纸张方向、尺寸、主题默认页边距和用户配置计算
内容区,调用平台捕获适配器,并校验捕获计划、PNG 签名、像素尺寸、单图
大小、总大小和媒体 ID。最终按文档顺序输出稳定的 `media-001.png`
清单;它不依赖 Playwright 或 Electron,平台代码只负责 Chromium 捕获。
内置主题来自仓库 `themes/`,当前名称为 Typora Github、
Typora Pixyll、Typora whitey 和 Typora Clean。额外主题从平台传入的
本地主题根目录扫描;与内置主题 ID 冲突时以内置主题为准。
@@ -0,0 +1,195 @@
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
): 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
): Promise<PreparedDocxMedia> {
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
});
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
};
}
+9
View File
@@ -21,6 +21,15 @@ export {
type MarkdownImageResource,
type ResolvedMarkdownImages
} from "./image-resources.js";
export {
getDocxMediaRenderDimensions,
prepareDocxMedia,
type DocxMediaCapture,
type DocxMediaCaptureAdapter,
type DocxMediaCaptureOutput,
type DocxMediaCaptureRequest,
type DocxMediaRenderDimensions
} from "./docx-media-service.js";
export {
createThemeRegistry,
type ThemeRecord,
@@ -0,0 +1,173 @@
import { describe, expect, it } from "vitest";
import {
defaultExportConfig,
type DocxMediaCapturePlan,
type ThemeManifest
} from "@md-to-pdf/core";
import {
getDocxMediaRenderDimensions,
prepareDocxMedia,
type DocxMediaCaptureAdapter,
type PreparedDocxExport
} from "../src/index.js";
function createPng(width: number, height: number) {
const content = new Uint8Array(24);
content.set([
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a
]);
content.set([0x49, 0x48, 0x44, 0x52], 12);
const view = new DataView(content.buffer);
view.setUint32(16, width);
view.setUint32(20, height);
return content;
}
const manifest: ThemeManifest = {
manifestVersion: 1,
id: "test-theme",
name: "测试主题",
version: "1.0.0",
description: "测试",
author: "测试",
license: "内部许可",
entry: "theme.css",
domPreset: "typora",
defaultFontSize: "16px",
supportedFeatures: ["mermaid", "echarts"],
category: "general",
compatibleProfiles: [],
pageDefaults: {
margins: {
top: "20mm",
right: "20mm",
bottom: "20mm",
left: "20mm"
}
},
bundled: true
};
const prepared: PreparedDocxExport = {
request: {
markdown: "# 文档",
fileName: "报告.md",
language: "zh-CN",
resources: [],
exportConfig: {
...defaultExportConfig,
themeId: manifest.id
}
},
document: {
rendererVersion: 1,
articleHtml: '<article id="write"></article>',
bodyHtml: "",
metadata: {
title: "文档",
author: "",
subject: "",
keywords: [],
language: "zh-CN"
},
features: [],
warnings: ["原始图片已降级"]
},
theme: {
manifest,
source: "bundled",
css: "#write { color: black; }"
}
};
function createPlan(): DocxMediaCapturePlan {
return {
targets: [
{
id: "docx-media-1",
kind: "echarts",
ordinal: 1,
kindOrdinal: 1,
altText: "收入趋势",
caption: "年度收入",
displayWidthPx: 320,
displayHeightPx: 160,
captureX: 0,
captureY: 0,
captureWidthPx: 320,
captureHeightPx: 160,
rasterScale: 3.125
}
],
echartsErrors: [],
mermaidErrors: []
};
}
describe("DOCX 媒体预处理服务", () => {
it("按主题页边距计算媒体内容区域", () => {
const dimensions = getDocxMediaRenderDimensions(prepared);
expect(dimensions.contentWidthPx).toBeCloseTo(
((210 - 40) * 96) / 25.4
);
expect(dimensions.contentHeightPx).toBeCloseTo(
((297 - 40) * 96) / 25.4
);
});
it("校验并输出稳定 PNG 媒体清单", async () => {
const adapter: DocxMediaCaptureAdapter = {
capture: async ({ dimensions, payload }) => {
expect(dimensions.contentWidthPx).toBeGreaterThan(600);
expect(payload.articleHtml).toContain('id="write"');
return {
plan: createPlan(),
captures: [
{
id: "docx-media-1",
png: createPng(1000, 500)
}
]
};
}
};
const result = await prepareDocxMedia(prepared, adapter);
expect(result.resources[0]).toMatchObject({
id: "docx-media-1",
fileName: "media-001.png",
contentType: "image/png",
pixelWidth: 1000,
pixelHeight: 500,
caption: "年度收入"
});
expect(result.warnings).toEqual(["原始图片已降级"]);
expect(result.totalBytes).toBe(24);
});
it("拒绝伪 PNG、缺失捕获和尺寸不一致", async () => {
const capture = async (
png: Uint8Array,
includeCapture = true
) =>
prepareDocxMedia(prepared, {
capture: async () => ({
plan: createPlan(),
captures: includeCapture
? [{ id: "docx-media-1", png }]
: []
})
});
await expect(capture(new Uint8Array(24))).rejects.toThrow(
"不是有效 PNG"
);
await expect(
capture(createPng(1000, 500), false)
).rejects.toThrow("不匹配");
await expect(capture(createPng(990, 500))).rejects.toThrow(
"尺寸与捕获计划不一致"
);
});
});