feat: 实现 DOCX 媒体预处理与跨端 PNG 捕获
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
Vendored
+8
@@ -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
@@ -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 @@ H1~H6、加粗、斜体、删除线、行内代码、代码块、引用、三
|
||||
关闭时对未保存内容进行确认;另存为成功后当前窗口追踪新文件路径,
|
||||
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 捕获适配器;
|
||||
- 阶段 6~8:依次实现动态 reference.docx、Pandoc 转换服务以及
|
||||
Web/Desktop 导出交互;
|
||||
- 阶段 9~10:完成自动化和 Word/WPS 互操作验收,再构建正式发布产物。
|
||||
|
||||
每个阶段验收通过后创建一个独立提交,再进入下一阶段。当前阶段不得混入
|
||||
|
||||
@@ -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。
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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(
|
||||
"尺寸与捕获计划不一致"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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 决定是否调用浏览器、系统程序或打开新的
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.125(300 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,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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user