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
@@ -0,0 +1,79 @@
import type { WebContents } from "electron";
import type {
DocxMediaCaptureAdapter,
DocxMediaCaptureRequest
} from "@md-to-pdf/application";
import type { DocxMediaCapturePlan } from "@md-to-pdf/core";
interface CaptureScreenshotResult {
data: string;
}
function createRenderScript(request: DocxMediaCaptureRequest) {
const serialized = JSON.stringify(request);
return `(async () => {
const request = ${serialized};
const renderer = window.__mdToPdfRenderDocxMedia;
if (!renderer) {
throw new Error("DOCX 媒体渲染运行时不可用");
}
return renderer(request.payload, request.dimensions);
})()`;
}
export async function captureDocxMediaWithElectronWebContents(
webContents: WebContents,
request: DocxMediaCaptureRequest
) {
const plan = (await webContents.executeJavaScript(
createRenderScript(request),
true
)) as DocxMediaCapturePlan;
const ownsDebugger = !webContents.debugger.isAttached();
if (ownsDebugger) {
webContents.debugger.attach("1.3");
}
try {
const captures = [];
for (const target of plan.targets) {
const result = (await webContents.debugger.sendCommand(
"Page.captureScreenshot",
{
format: "png",
fromSurface: true,
captureBeyondViewport: true,
clip: {
x: target.captureX,
y: target.captureY,
width: target.captureWidthPx,
height: target.captureHeightPx,
scale: target.rasterScale
}
}
)) as CaptureScreenshotResult;
captures.push({
id: target.id,
png: Buffer.from(result.data, "base64")
});
}
return { plan, captures };
} finally {
if (ownsDebugger && webContents.debugger.isAttached()) {
webContents.debugger.detach();
}
}
}
export class ElectronDocxMediaCaptureAdapter
implements DocxMediaCaptureAdapter
{
constructor(private readonly webContents: WebContents) {}
capture(request: DocxMediaCaptureRequest) {
return captureDocxMediaWithElectronWebContents(
this.webContents,
request
);
}
}
@@ -0,0 +1,98 @@
import { describe, expect, it, vi } from "vitest";
import type { WebContents } from "electron";
import {
createPagedDocumentPayload,
defaultExportConfig,
type DocxMediaCapturePlan
} from "@md-to-pdf/core";
import { captureDocxMediaWithElectronWebContents } from "../src/electron-docx-media-capture.js";
const plan: DocxMediaCapturePlan = {
targets: [
{
id: "docx-media-1",
kind: "image",
ordinal: 1,
kindOrdinal: 1,
altText: "图片",
displayWidthPx: 200,
displayHeightPx: 100,
captureX: 12,
captureY: 24,
captureWidthPx: 200,
captureHeightPx: 100,
rasterScale: 3.125
}
],
echartsErrors: [],
mermaidErrors: []
};
const request = {
payload: createPagedDocumentPayload({
document: {
rendererVersion: 1,
articleHtml: '<article id="write"></article>',
bodyHtml: "",
metadata: {
title: "",
author: "",
subject: "",
keywords: [],
language: "zh-CN"
},
features: [],
warnings: []
},
fileName: "test.md",
themeCss: "",
exportConfig: defaultExportConfig
}),
dimensions: {
contentWidthPx: 600,
contentHeightPx: 900
}
};
describe("Electron DOCX 媒体捕获", () => {
it("复用隐藏 Chromium 并在捕获后释放调试会话", async () => {
let attached = false;
const attach = vi.fn(() => {
attached = true;
});
const detach = vi.fn(() => {
attached = false;
});
const sendCommand = vi.fn(async () => ({
data: Buffer.from("png").toString("base64")
}));
const webContents = {
executeJavaScript: vi.fn(async () => plan),
debugger: {
isAttached: vi.fn(() => attached),
attach,
detach,
sendCommand
}
} as unknown as WebContents;
const result = await captureDocxMediaWithElectronWebContents(
webContents,
request
);
expect(result.captures[0]).toEqual({
id: "docx-media-1",
png: Buffer.from("png")
});
expect(attach).toHaveBeenCalledWith("1.3");
expect(sendCommand).toHaveBeenCalledWith(
"Page.captureScreenshot",
expect.objectContaining({
format: "png",
clip: expect.objectContaining({ scale: 3.125 })
})
);
expect(detach).toHaveBeenCalledOnce();
});
});
@@ -0,0 +1,75 @@
import type { Page } from "playwright";
import type {
DocxMediaCaptureAdapter,
DocxMediaCaptureRequest
} from "@md-to-pdf/application";
import type { DocxMediaCapturePlan } from "@md-to-pdf/core";
interface CaptureScreenshotResult {
data: string;
}
async function renderCapturePlan(
page: Page,
request: DocxMediaCaptureRequest
) {
return page.evaluate(
async ({ payload, dimensions }): Promise<DocxMediaCapturePlan> => {
const renderer = (
window as typeof window & {
__mdToPdfRenderDocxMedia?: (
renderPayload: typeof payload,
renderDimensions: typeof dimensions
) => Promise<DocxMediaCapturePlan>;
}
).__mdToPdfRenderDocxMedia;
if (!renderer) {
throw new Error("DOCX 媒体渲染运行时不可用");
}
return renderer(payload, dimensions);
},
request
);
}
export async function captureDocxMediaWithPlaywrightPage(
page: Page,
request: DocxMediaCaptureRequest
) {
const plan = await renderCapturePlan(page, request);
const session = await page.context().newCDPSession(page);
try {
const captures = [];
for (const target of plan.targets) {
const result = (await session.send("Page.captureScreenshot", {
format: "png",
fromSurface: true,
captureBeyondViewport: true,
clip: {
x: target.captureX,
y: target.captureY,
width: target.captureWidthPx,
height: target.captureHeightPx,
scale: target.rasterScale
}
})) as CaptureScreenshotResult;
captures.push({
id: target.id,
png: Buffer.from(result.data, "base64")
});
}
return { plan, captures };
} finally {
await session.detach();
}
}
export class PlaywrightDocxMediaCaptureAdapter
implements DocxMediaCaptureAdapter
{
constructor(private readonly page: Page) {}
capture(request: DocxMediaCaptureRequest) {
return captureDocxMediaWithPlaywrightPage(this.page, request);
}
}
@@ -0,0 +1,94 @@
import { describe, expect, it, vi } from "vitest";
import type { Page } from "playwright";
import {
createPagedDocumentPayload,
defaultExportConfig,
type DocxMediaCapturePlan
} from "@md-to-pdf/core";
import { captureDocxMediaWithPlaywrightPage } from "../src/playwright-docx-media-capture.js";
const plan: DocxMediaCapturePlan = {
targets: [
{
id: "docx-media-1",
kind: "echarts",
ordinal: 1,
kindOrdinal: 1,
altText: "图表",
displayWidthPx: 320,
displayHeightPx: 180,
captureX: 10,
captureY: 20,
captureWidthPx: 320,
captureHeightPx: 180,
rasterScale: 3.125
}
],
echartsErrors: [],
mermaidErrors: []
};
const request = {
payload: createPagedDocumentPayload({
document: {
rendererVersion: 1,
articleHtml: '<article id="write"></article>',
bodyHtml: "",
metadata: {
title: "",
author: "",
subject: "",
keywords: [],
language: "zh-CN"
},
features: [],
warnings: []
},
fileName: "test.md",
themeCss: "",
exportConfig: defaultExportConfig
}),
dimensions: {
contentWidthPx: 600,
contentHeightPx: 900
}
};
describe("Playwright DOCX 媒体捕获", () => {
it("按运行时计划以目标倍率捕获 PNG", async () => {
const detach = vi.fn(async () => undefined);
const send = vi.fn(async () => ({
data: Buffer.from("png").toString("base64")
}));
const page = {
evaluate: vi.fn(async () => plan),
context: () => ({
newCDPSession: vi.fn(async () => ({ send, detach }))
})
} as unknown as Page;
const result = await captureDocxMediaWithPlaywrightPage(
page,
request
);
expect(result.plan).toBe(plan);
expect(result.captures[0]).toEqual({
id: "docx-media-1",
png: Buffer.from("png")
});
expect(send).toHaveBeenCalledWith("Page.captureScreenshot", {
format: "png",
fromSurface: true,
captureBeyondViewport: true,
clip: {
x: 10,
y: 20,
width: 320,
height: 180,
scale: 3.125
}
});
expect(detach).toHaveBeenCalledOnce();
});
});
+8
View File
@@ -3,6 +3,7 @@ import {
isPagedPreviewRenderRequest,
PAGED_PREVIEW_MESSAGE_SCOPE,
PagedDocumentRuntime,
renderDocxMediaCapturePlan,
resolveMermaidOutputMode,
resolvePagedRenderTarget,
type PagedPreviewFrameMessage,
@@ -137,6 +138,13 @@ window.__mdToPdfRender = async (
}
return result;
};
window.__mdToPdfRenderDocxMedia = (payload, dimensions) =>
renderDocxMediaCapturePlan(
runtime,
previewRoot,
payload,
dimensions
);
document.documentElement.dataset.runtimeReady = "true";
const desktopPdfBridge = window.__mdToPdfDesktopPdf;
+8
View File
@@ -1,6 +1,7 @@
/// <reference types="vite/client" />
import type {
DocxMediaCapturePlan,
PagedDocumentPayload,
PagedDocumentRenderResult,
RenderedMarkdownDocument,
@@ -96,5 +97,12 @@ declare global {
interface Window {
mdToPdfDesktop?: DesktopApplicationBridge;
__mdToPdfDesktopPdf?: DesktopPdfRuntimeBridge;
__mdToPdfRenderDocxMedia?: (
payload: PagedDocumentPayload,
dimensions: {
contentWidthPx: number;
contentHeightPx: number;
}
) => Promise<DocxMediaCapturePlan>;
}
}
+38 -2
View File
@@ -91,6 +91,13 @@ H1~H6、加粗、斜体、删除线、行内代码、代码块、引用、三
同步。外部文档替换会重置历史,单次工具栏操作形成一个 CodeMirror
事务。
`v0.6.0` 阶段 5 已完成 DOCX 资源预处理管线:复用连续预览运行时等待
图片、Mermaid、ECharts 和字体,按纸张内容区生成稳定媒体捕获计划;
Server 使用 Playwright Chromium、Desktop 使用 Electron Chromium
统一将所有媒体捕获为 PNG。目标为 300 DPI,并限制 4096px 单边、1600
万像素、8 MiB 单图和 30 MiB 总量。本阶段只提供共享服务和平台适配器,
尚未新增 DOCX HTTP 路由、应用 IPC、Pandoc 调用或导出按钮。
## 2. 已完成
### 2.1 项目骨架
@@ -568,8 +575,36 @@ H1H6、加粗、斜体、删除线、行内代码、代码块、引用、三
关闭时对未保存内容进行确认;另存为成功后当前窗口追踪新文件路径,
Web 端不显示或拦截“另存为”。
### 3.21 v0.6.0 DOCX 资源预处理
- `packages/core` 新增图片、Mermaid、ECharts 的媒体类型、捕获计划、
PNG 资源清单和跨端限制常量;
- `packages/preview-engine` 复用连续文档运行时建立 DOCX 媒体舞台,
等待图片、字体和图表完成后按 DOM 顺序生成捕获目标;
- 媒体按纸张方向、纸张尺寸、主题默认页边距和用户页边距限制显示宽高;
- 普通图片、SVG、Mermaid 和 ECharts 统一输出 PNG,默认 300 DPI
超大媒体会按 4096px 单边和 1600 万像素上限降低倍率;
- 捕获框使用覆盖元素边界的整数坐标,避免 Chromium 对小数坐标缩放时
裁切边缘或产生不可预测的位图尺寸;
- `packages/application` 校验捕获计划、媒体 ID、PNG 签名、IHDR 尺寸、
单图大小、总大小和计划匹配关系,并生成稳定文件名;
- Server 的 Playwright 与 Desktop 的 Electron 适配器均使用 Chromium
DevTools 截图协议,不引入 Sharp、Python 或其他原生图片依赖;
- 真实 Chromium 冒烟已将输入 SVG 图片、Mermaid 和 ECharts 依次输出为
753×378、809×222、2103×591 PNG,图表错误均为 0。
## 4. 已执行验证
2026-07-30 对 v0.6.0 阶段 5 执行:
- 完整工作区 61 个测试文件、308 项测试全部通过;
- Markdown ECharts、Core、Renderer、Application、Preview Engine、
Web、Server 和 Desktop 类型检查全部通过;
- Web Runtime、Server 和 Desktop 生产构建通过;
- 真实 Playwright Chromium 使用 SVG 图片、Mermaid 和 ECharts 完成
300 DPI PNG 捕获,媒体顺序、尺寸、格式和错误清单验证通过;
- `git diff --check` 通过,仅有 Git 对 Windows 工作区换行转换的提示。
2026-07-29 对 v0.5.1 完整工作区及最终代码块修复执行:
```text
@@ -974,8 +1009,9 @@ ECharts 第二阶段浏览器与 PDF 验证结果:
分发方式;
- 阶段 3:已实现 DOCX 共享模型和跨端导出协议;
- 阶段 4:已实现 CodeMirror 6 与可扩展基础 Markdown 工具栏;
- 阶段 5~8:依次实现资源预处理、动态 reference.docx、Pandoc 转换服务
以及 Web/Desktop 导出交互;
- 阶段 5:已实现 DOCX 资源预处理和跨端 Chromium PNG 捕获适配器;
- 阶段 68:依次实现动态 reference.docx、Pandoc 转换服务以及
Web/Desktop 导出交互;
- 阶段 910:完成自动化和 Word/WPS 互操作验收,再构建正式发布产物。
每个阶段验收通过后创建一个独立提交,再进入下一阶段。当前阶段不得混入
+7 -4
View File
@@ -198,10 +198,13 @@ Desktop 只提供平台运行时、传输与保存能力。
### 4.5 媒体策略
- 普通 PNG、JPEG 等兼容图片保持原格式或安全规范化
- SVG 默认转换为 PNG不直接嵌入 DOCX
- Mermaid 和 ECharts 使用 PNG
- PNG 按足够高的像素尺寸生成,DOCX 中使用纸张内容区对应的物理尺寸;
- 普通图片、SVG、Mermaid 和 ECharts 在 DOCX 媒体准备阶段统一输出 PNG
- SVG 不直接嵌入 DOCX,避免 Word 与 WPS 的兼容差异
- 默认按 300 DPI,即 96 CSS DPI 的 3.125 倍捕获
- 单边最长 4096px、单图最多 1600 万像素、单图最多 8 MiB、单次导出
媒体总量最多 30 MiB
- DOCX 中使用纸张内容区对应的物理尺寸,不使用 PNG 像素尺寸直接决定
文档显示尺寸;
- 保持纵横比,不得超出可用内容宽高;
- 图表渲染必须等待字体、图片和图表资源完成;
- 图表错误产生局部、可理解的导出错误,不输出损坏 DOCX。
+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(
"尺寸与捕获计划不一致"
);
});
});
+3 -2
View File
@@ -48,8 +48,9 @@ const payload = createPagedDocumentPayload({
```
DOCX 协议固定 Pandoc `3.9.0.2`,并统一定义跨 HTTP/IPC 使用的请求、
capability、错误码、结果、诊断耗时类型。该包只描述数据协议,不启动
Pandoc 或读写临时文件
capability、错误码、结果、诊断耗时、媒体捕获计划和 PNG 资源清单。
媒体协议固定 300 DPI 目标倍率,并限制边长、总像素、单文件大小和总量
该包只描述数据协议,不启动 Pandoc、浏览器或读写临时文件。
`classifyDocumentLink()` 只负责稳定分类,不执行平台动作。Web 根据分类
处理锚点和网络链接;Desktop 决定是否调用浏览器、系统程序或打开新的
+69
View File
@@ -8,6 +8,14 @@ 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 DOCX_MEDIA_RASTER_DPI = 300;
export const DOCX_MEDIA_CSS_DPI = 96;
export const DOCX_MEDIA_RASTER_SCALE =
DOCX_MEDIA_RASTER_DPI / DOCX_MEDIA_CSS_DPI;
export const MAXIMUM_DOCX_MEDIA_EDGE_PIXELS = 4_096;
export const MAXIMUM_DOCX_MEDIA_PIXELS = 16_000_000;
export const MAXIMUM_DOCX_MEDIA_BYTES = 8 * 1024 * 1024;
export const MAXIMUM_DOCX_TOTAL_MEDIA_BYTES = 30 * 1024 * 1024;
export const documentExportFormatSchema = z.enum(["pdf", "docx"]);
export type DocumentExportFormat = z.infer<
@@ -79,6 +87,67 @@ export const docxCapabilitySchema = z.discriminatedUnion("status", [
export type DocxCapability = z.infer<typeof docxCapabilitySchema>;
export const docxMediaKindSchema = z.enum([
"image",
"mermaid",
"echarts"
]);
export type DocxMediaKind = z.infer<typeof docxMediaKindSchema>;
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),
kindOrdinal: z
.number()
.int()
.min(1)
.max(MAXIMUM_DOCX_RESOURCE_COUNT),
altText: z.string().max(1_000),
caption: z.string().max(1_000).optional(),
displayWidthPx: z.number().positive().max(10_000),
displayHeightPx: z.number().positive().max(10_000),
captureX: z.number().int().nonnegative().max(100_000),
captureY: z.number().int().nonnegative().max(100_000),
captureWidthPx: z.number().int().positive().max(10_000),
captureHeightPx: z.number().int().positive().max(10_000),
rasterScale: z.number().positive().max(DOCX_MEDIA_RASTER_SCALE)
});
export type DocxMediaCaptureTarget = z.infer<
typeof docxMediaCaptureTargetSchema
>;
export const docxMediaCapturePlanSchema = z.object({
targets: z
.array(docxMediaCaptureTargetSchema)
.max(MAXIMUM_DOCX_RESOURCE_COUNT),
echartsErrors: z.array(z.string().max(1_000)),
mermaidErrors: z.array(z.string().max(1_000))
});
export type DocxMediaCapturePlan = z.infer<
typeof docxMediaCapturePlanSchema
>;
export interface DocxPngMediaResource
extends DocxMediaCaptureTarget {
fileName: string;
contentType: "image/png";
content: Uint8Array;
pixelWidth: number;
pixelHeight: number;
}
export interface PreparedDocxMedia {
resources: DocxPngMediaResource[];
echartsErrors: string[];
mermaidErrors: string[];
warnings: string[];
totalBytes: number;
}
export const docxExportErrorCodeSchema = z.enum([
"INVALID_DOCX_REQUEST",
"INVALID_EXPORT_CONFIG",
+53
View File
@@ -1,11 +1,14 @@
import { describe, expect, it } from "vitest";
import {
DOCX_MIME_TYPE,
DOCX_MEDIA_RASTER_SCALE,
DOCX_PANDOC_VERSION,
MAXIMUM_DOCX_MEDIA_EDGE_PIXELS,
MAXIMUM_DOCX_MARKDOWN_LENGTH,
createDocxFileName,
defaultExportConfig,
docxCapabilitySchema,
docxMediaCapturePlanSchema,
docxExportErrorResponseSchema,
docxExportRequestSchema
} from "../src/index.js";
@@ -111,4 +114,54 @@ describe("DOCX 共享协议", () => {
retryable: false
});
});
it("校验 DOCX PNG 媒体捕获计划", () => {
expect(DOCX_MEDIA_RASTER_SCALE).toBe(3.125);
expect(MAXIMUM_DOCX_MEDIA_EDGE_PIXELS).toBe(4096);
expect(
docxMediaCapturePlanSchema.parse({
targets: [
{
id: "docx-media-1",
kind: "mermaid",
ordinal: 1,
kindOrdinal: 1,
altText: "流程图",
caption: "处理流程",
displayWidthPx: 640,
displayHeightPx: 320,
captureX: 20,
captureY: 120,
captureWidthPx: 640,
captureHeightPx: 320,
rasterScale: 3.125
}
],
echartsErrors: [],
mermaidErrors: []
}).targets
).toHaveLength(1);
expect(
docxMediaCapturePlanSchema.safeParse({
targets: [
{
id: "../越界",
kind: "image",
ordinal: 1,
kindOrdinal: 1,
altText: "",
displayWidthPx: 100,
displayHeightPx: 100,
captureX: 0,
captureY: 0,
captureWidthPx: 100,
captureHeightPx: 100,
rasterScale: 3.125
}
],
echartsErrors: [],
mermaidErrors: []
}).success
).toBe(false);
});
});
+22
View File
@@ -12,6 +12,7 @@ src/
├── paged-document-runtime.ts 连续与分页渲染主流程
├── paged-preview.ts 分页载荷、消息协议与页面 CSS
├── continuous-preview.ts 无分页 DOM 更新与稳定节点复用
├── docx-media-runtime.ts DOCX 连续媒体舞台与 PNG 捕获计划
├── incremental-pagination.ts 修改边界、稳定前缀与后缀分页缓存
├── paged-table-handler.ts 跨页表格表头处理
├── media-page-backfill.ts 图片与图表按文档顺序回填
@@ -45,6 +46,16 @@ const result = await runtime.render(payload, {
});
const continuous = await runtime.renderContinuous(payload);
const mediaPlan = await renderDocxMediaCapturePlan(
runtime,
root,
payload,
{
contentWidthPx,
contentHeightPx
}
);
```
`payload` 使用 `@md-to-pdf/core` 中的 `PagedDocumentPayload`。主题 CSS、
@@ -69,6 +80,17 @@ const continuous = await runtime.renderContinuous(payload);
得到基准高度;上一页空白与媒体需求接近时,才尝试只缩放媒体主体回填,
标题始终保持自然尺寸。
## DOCX 媒体舞台
`renderDocxMediaCapturePlan()` 复用连续渲染流程,等待字体、图片、
Mermaid 和 ECharts 完成后,按 DOM 顺序标记普通图片、Mermaid SVG 与
ECharts SVG。运行时将媒体限制在纸张内容区内,并返回整数外包围捕获框、
显示尺寸、替代文本、图注和目标位图倍率。
目标倍率默认为 3.125300 DPI),并受 4096px 单边和 1600 万像素上限
约束。运行时只生成平台无关的捕获计划;Server 使用 Playwright Chromium
Desktop 使用 Electron Chromium 输出 PNG。
## PDF 链接
`preparePdfDocumentLinks()` 仅在 PDF 目标中将本地链接编码为
@@ -0,0 +1,210 @@
import {
DOCX_MEDIA_RASTER_SCALE,
MAXIMUM_DOCX_MEDIA_EDGE_PIXELS,
MAXIMUM_DOCX_MEDIA_PIXELS,
MAXIMUM_DOCX_RESOURCE_COUNT,
type DocxMediaCapturePlan,
type DocxMediaCaptureTarget,
type DocxMediaKind,
type PagedDocumentPayload
} from "@md-to-pdf/core";
import { PagedDocumentRuntime } from "./paged-document-runtime.js";
export interface DocxMediaRenderDimensions {
contentWidthPx: number;
contentHeightPx: number;
}
declare global {
interface Window {
__mdToPdfRenderDocxMedia?: (
payload: PagedDocumentPayload,
dimensions: DocxMediaRenderDimensions
) => Promise<DocxMediaCapturePlan>;
}
}
function finitePositive(value: number, fallback: number) {
return Number.isFinite(value) && value > 0 ? value : fallback;
}
function getMediaKind(element: Element): DocxMediaKind {
if (element.matches("img.md-document-image")) {
return "image";
}
if (element.closest(".md-echarts")) {
return "echarts";
}
return "mermaid";
}
function getCaption(element: Element) {
return (
element
.closest("figure")
?.querySelector("figcaption")
?.textContent?.trim() || undefined
);
}
function getAltText(
element: Element,
kind: DocxMediaKind,
kindOrdinal: number
) {
if (element instanceof HTMLImageElement) {
return element.alt.trim() || `图片 ${kindOrdinal}`;
}
const labelled =
element.getAttribute("aria-label")?.trim() ||
element
.closest<HTMLElement>("[aria-label]")
?.getAttribute("aria-label")
?.trim();
if (labelled) {
return labelled;
}
return kind === "echarts"
? `ECharts 图表 ${kindOrdinal}`
: `Mermaid 图表 ${kindOrdinal}`;
}
function fitElementToContent(
element: HTMLElement | SVGSVGElement,
dimensions: DocxMediaRenderDimensions
) {
const initial = element.getBoundingClientRect();
const width = finitePositive(initial.width, dimensions.contentWidthPx);
const height = finitePositive(
initial.height,
Math.min(dimensions.contentHeightPx, width * 0.75)
);
const fitScale = Math.min(
1,
dimensions.contentWidthPx / width,
dimensions.contentHeightPx / height
);
if (fitScale < 1) {
element.style.width = `${width * fitScale}px`;
element.style.height = `${height * fitScale}px`;
element.style.maxWidth = "none";
element.style.maxHeight = "none";
}
return element.getBoundingClientRect();
}
function getRasterScale(width: number, height: number) {
return Math.min(
DOCX_MEDIA_RASTER_SCALE,
MAXIMUM_DOCX_MEDIA_EDGE_PIXELS / width,
MAXIMUM_DOCX_MEDIA_EDGE_PIXELS / height,
Math.sqrt(MAXIMUM_DOCX_MEDIA_PIXELS / (width * height))
);
}
function createGeometryCss(dimensions: DocxMediaRenderDimensions) {
return `
#preview-root {
width: ${dimensions.contentWidthPx}px !important;
padding: 0 !important;
}
#write {
width: ${dimensions.contentWidthPx}px !important;
padding: 0 !important;
box-shadow: none !important;
}
`;
}
export function collectDocxMediaCaptureTargets(
root: ParentNode,
dimensions: DocxMediaRenderDimensions
) {
const article = root.querySelector<HTMLElement>("#write");
if (!article) {
throw new Error("DOCX 媒体舞台缺少 #write 文档容器");
}
const candidates = Array.from(
article.querySelectorAll<HTMLElement | SVGSVGElement>(
[
"img.md-document-image",
".mermaid:not(.mermaid-error) svg",
".md-echarts:not(.md-echarts-error) .md-echarts-host svg"
].join(",")
)
);
if (candidates.length > MAXIMUM_DOCX_RESOURCE_COUNT) {
throw new Error(
`DOCX 媒体数量不能超过 ${MAXIMUM_DOCX_RESOURCE_COUNT}`
);
}
const kindOrdinals: Record<DocxMediaKind, number> = {
image: 0,
mermaid: 0,
echarts: 0
};
return candidates.map((element, index): DocxMediaCaptureTarget => {
const kind = getMediaKind(element);
kindOrdinals[kind] += 1;
const kindOrdinal = kindOrdinals[kind];
const rect = fitElementToContent(element, dimensions);
const width = finitePositive(rect.width, 1);
const height = finitePositive(rect.height, 1);
const captureX = Math.max(
0,
Math.floor(rect.left + window.scrollX)
);
const captureY = Math.max(
0,
Math.floor(rect.top + window.scrollY)
);
const captureWidth = Math.max(
1,
Math.ceil(rect.right + window.scrollX) - captureX
);
const captureHeight = Math.max(
1,
Math.ceil(rect.bottom + window.scrollY) - captureY
);
const id = `docx-media-${index + 1}`;
element.dataset.docxMediaId = id;
return {
id,
kind,
ordinal: index + 1,
kindOrdinal,
altText: getAltText(element, kind, kindOrdinal),
...(getCaption(element)
? { caption: getCaption(element) }
: {}),
displayWidthPx: width,
displayHeightPx: height,
captureX,
captureY,
captureWidthPx: captureWidth,
captureHeightPx: captureHeight,
rasterScale: getRasterScale(captureWidth, captureHeight)
};
});
}
export async function renderDocxMediaCapturePlan(
runtime: PagedDocumentRuntime,
root: HTMLElement,
payload: PagedDocumentPayload,
dimensions: DocxMediaRenderDimensions
): Promise<DocxMediaCapturePlan> {
const renderResult = await runtime.renderContinuous(payload, {
geometryCss: createGeometryCss(dimensions)
});
if (!renderResult) {
throw new Error("DOCX 媒体渲染已取消");
}
return {
targets: collectDocxMediaCaptureTargets(root, dimensions),
echartsErrors: renderResult.echartsErrors,
mermaidErrors: renderResult.mermaidErrors
};
}
+1
View File
@@ -1,6 +1,7 @@
export * from "./diagram-page-fit.js";
export * from "./continuous-preview.js";
export * from "./document-image-fit.js";
export * from "./docx-media-runtime.js";
export * from "./echarts-page-fit.js";
export * from "./incremental-pagination.js";
export * from "./media-page-backfill.js";
@@ -63,6 +63,11 @@ export interface PagedDocumentRenderOptions {
mermaidOutput?: MermaidOutputMode;
}
export interface ContinuousDocumentRenderOptions {
shouldContinue?: () => boolean;
geometryCss?: string;
}
export interface PreviewEngineStyles {
highlightCss: string;
katexCss: string;
@@ -415,7 +420,8 @@ export class PagedDocumentRuntime {
private applyContinuousStyles(
documentRef: Document,
payload: PagedPreviewPayload
payload: PagedPreviewPayload,
geometryCss = ""
) {
const style =
this.continuousStyle ?? documentRef.createElement("style");
@@ -427,7 +433,8 @@ export class PagedDocumentRuntime {
this.styles.echartsCss,
enablePrintMediaForPreview(payload.themeCss),
documentInteractionCss,
continuousDocumentGeometryCss
continuousDocumentGeometryCss,
geometryCss
].join("\n");
if (!style.isConnected) {
documentRef.head.append(style);
@@ -930,10 +937,7 @@ export class PagedDocumentRuntime {
async renderContinuous(
payload: PagedPreviewPayload,
options: Pick<
PagedDocumentRenderOptions,
"shouldContinue"
> = {}
options: ContinuousDocumentRenderOptions = {}
): Promise<PagedDocumentRenderResult | undefined> {
const totalStartedAt = performance.now();
const shouldContinue = options.shouldContinue ?? (() => true);
@@ -950,7 +954,11 @@ export class PagedDocumentRuntime {
payload.metadata.language || "zh-CN";
documentRef.title =
payload.metadata.title || "Markdown 连续预览";
this.applyContinuousStyles(documentRef, payload);
this.applyContinuousStyles(
documentRef,
payload,
options.geometryCss
);
const template = documentRef.createElement("template");
template.innerHTML = payload.articleHtml;
@@ -963,7 +971,8 @@ export class PagedDocumentRuntime {
const nextSignatures = nextNodes.map(createNodeSignature);
const identity = JSON.stringify({
themeCss: payload.themeCss,
mermaid: payload.exportConfig.mermaid
mermaid: payload.exportConfig.mermaid,
geometryCss: options.geometryCss
});
const currentArticle =
this.root.querySelector<HTMLElement>(":scope > #write");
@@ -0,0 +1,124 @@
// @vitest-environment happy-dom
import { beforeEach, describe, expect, it } from "vitest";
import { collectDocxMediaCaptureTargets } from "../src/index.js";
describe("DOCX 媒体捕获计划", () => {
beforeEach(() => {
document.body.innerHTML = "";
window.scrollTo(0, 0);
});
it("按文档顺序标记图片、Mermaid 和 ECharts", () => {
document.body.innerHTML = `
<article id="write">
<figure class="md-document-image-block">
<img class="md-document-image" alt="架构截图">
<figcaption>系统架构</figcaption>
</figure>
<div class="mermaid"><svg aria-label="处理流程"></svg></div>
<figure class="md-echarts">
<div class="md-echarts-host" aria-label="年度收入">
<svg></svg>
</div>
<figcaption>收入趋势</figcaption>
</figure>
</article>
`;
const media = Array.from(
document.querySelectorAll<HTMLElement | SVGSVGElement>(
"img, svg"
)
);
media.forEach((element, index) => {
element.getBoundingClientRect = () =>
({
x: 10,
y: 20 + index * 100,
left: 10,
top: 20 + index * 100,
right: 650,
bottom: 380 + index * 100,
width: 640,
height: 360,
toJSON: () => ({})
}) as DOMRect;
});
const targets = collectDocxMediaCaptureTargets(document, {
contentWidthPx: 700,
contentHeightPx: 900
});
expect(
targets.map(
({ id, kind, kindOrdinal, altText, caption }) => ({
id,
kind,
kindOrdinal,
altText,
caption
})
)
).toEqual([
{
id: "docx-media-1",
kind: "image",
kindOrdinal: 1,
altText: "架构截图",
caption: "系统架构"
},
{
id: "docx-media-2",
kind: "mermaid",
kindOrdinal: 1,
altText: "处理流程",
caption: undefined
},
{
id: "docx-media-3",
kind: "echarts",
kindOrdinal: 1,
altText: "年度收入",
caption: "收入趋势"
}
]);
expect(
media.map((element) => element.dataset.docxMediaId)
).toEqual(["docx-media-1", "docx-media-2", "docx-media-3"]);
expect(targets[0]?.rasterScale).toBe(3.125);
});
it("限制超大媒体的显示尺寸和 PNG 像素规模", () => {
document.body.innerHTML = `
<article id="write">
<img class="md-document-image" alt="">
</article>
`;
const image = document.querySelector("img")!;
image.getBoundingClientRect = () =>
({
x: 0,
y: 0,
left: 0,
top: 0,
right: image.style.width ? 800 : 4000,
bottom: image.style.width ? 600 : 3000,
width: image.style.width ? 800 : 4000,
height: image.style.width ? 600 : 3000,
toJSON: () => ({})
}) as DOMRect;
const [target] = collectDocxMediaCaptureTargets(document, {
contentWidthPx: 800,
contentHeightPx: 900
});
expect(target?.displayWidthPx).toBeLessThanOrEqual(800);
expect(
(target?.captureWidthPx ?? 0) *
(target?.rasterScale ?? 0)
).toBeLessThanOrEqual(4096);
expect(target?.altText).toBe("图片 1");
});
});