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,
|
isPagedPreviewRenderRequest,
|
||||||
PAGED_PREVIEW_MESSAGE_SCOPE,
|
PAGED_PREVIEW_MESSAGE_SCOPE,
|
||||||
PagedDocumentRuntime,
|
PagedDocumentRuntime,
|
||||||
|
renderDocxMediaCapturePlan,
|
||||||
resolveMermaidOutputMode,
|
resolveMermaidOutputMode,
|
||||||
resolvePagedRenderTarget,
|
resolvePagedRenderTarget,
|
||||||
type PagedPreviewFrameMessage,
|
type PagedPreviewFrameMessage,
|
||||||
@@ -137,6 +138,13 @@ window.__mdToPdfRender = async (
|
|||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
};
|
};
|
||||||
|
window.__mdToPdfRenderDocxMedia = (payload, dimensions) =>
|
||||||
|
renderDocxMediaCapturePlan(
|
||||||
|
runtime,
|
||||||
|
previewRoot,
|
||||||
|
payload,
|
||||||
|
dimensions
|
||||||
|
);
|
||||||
document.documentElement.dataset.runtimeReady = "true";
|
document.documentElement.dataset.runtimeReady = "true";
|
||||||
|
|
||||||
const desktopPdfBridge = window.__mdToPdfDesktopPdf;
|
const desktopPdfBridge = window.__mdToPdfDesktopPdf;
|
||||||
|
|||||||
Vendored
+8
@@ -1,6 +1,7 @@
|
|||||||
/// <reference types="vite/client" />
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
|
DocxMediaCapturePlan,
|
||||||
PagedDocumentPayload,
|
PagedDocumentPayload,
|
||||||
PagedDocumentRenderResult,
|
PagedDocumentRenderResult,
|
||||||
RenderedMarkdownDocument,
|
RenderedMarkdownDocument,
|
||||||
@@ -96,5 +97,12 @@ declare global {
|
|||||||
interface Window {
|
interface Window {
|
||||||
mdToPdfDesktop?: DesktopApplicationBridge;
|
mdToPdfDesktop?: DesktopApplicationBridge;
|
||||||
__mdToPdfDesktopPdf?: DesktopPdfRuntimeBridge;
|
__mdToPdfDesktopPdf?: DesktopPdfRuntimeBridge;
|
||||||
|
__mdToPdfRenderDocxMedia?: (
|
||||||
|
payload: PagedDocumentPayload,
|
||||||
|
dimensions: {
|
||||||
|
contentWidthPx: number;
|
||||||
|
contentHeightPx: number;
|
||||||
|
}
|
||||||
|
) => Promise<DocxMediaCapturePlan>;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+38
-2
@@ -91,6 +91,13 @@ H1~H6、加粗、斜体、删除线、行内代码、代码块、引用、三
|
|||||||
同步。外部文档替换会重置历史,单次工具栏操作形成一个 CodeMirror
|
同步。外部文档替换会重置历史,单次工具栏操作形成一个 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. 已完成
|
||||||
|
|
||||||
### 2.1 项目骨架
|
### 2.1 项目骨架
|
||||||
@@ -568,8 +575,36 @@ H1~H6、加粗、斜体、删除线、行内代码、代码块、引用、三
|
|||||||
关闭时对未保存内容进行确认;另存为成功后当前窗口追踪新文件路径,
|
关闭时对未保存内容进行确认;另存为成功后当前窗口追踪新文件路径,
|
||||||
Web 端不显示或拦截“另存为”。
|
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. 已执行验证
|
## 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 完整工作区及最终代码块修复执行:
|
2026-07-29 对 v0.5.1 完整工作区及最终代码块修复执行:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
@@ -974,8 +1009,9 @@ ECharts 第二阶段浏览器与 PDF 验证结果:
|
|||||||
分发方式;
|
分发方式;
|
||||||
- 阶段 3:已实现 DOCX 共享模型和跨端导出协议;
|
- 阶段 3:已实现 DOCX 共享模型和跨端导出协议;
|
||||||
- 阶段 4:已实现 CodeMirror 6 与可扩展基础 Markdown 工具栏;
|
- 阶段 4:已实现 CodeMirror 6 与可扩展基础 Markdown 工具栏;
|
||||||
- 阶段 5~8:依次实现资源预处理、动态 reference.docx、Pandoc 转换服务
|
- 阶段 5:已实现 DOCX 资源预处理和跨端 Chromium PNG 捕获适配器;
|
||||||
以及 Web/Desktop 导出交互;
|
- 阶段 6~8:依次实现动态 reference.docx、Pandoc 转换服务以及
|
||||||
|
Web/Desktop 导出交互;
|
||||||
- 阶段 9~10:完成自动化和 Word/WPS 互操作验收,再构建正式发布产物。
|
- 阶段 9~10:完成自动化和 Word/WPS 互操作验收,再构建正式发布产物。
|
||||||
|
|
||||||
每个阶段验收通过后创建一个独立提交,再进入下一阶段。当前阶段不得混入
|
每个阶段验收通过后创建一个独立提交,再进入下一阶段。当前阶段不得混入
|
||||||
|
|||||||
@@ -198,10 +198,13 @@ Desktop 只提供平台运行时、传输与保存能力。
|
|||||||
|
|
||||||
### 4.5 媒体策略
|
### 4.5 媒体策略
|
||||||
|
|
||||||
- 普通 PNG、JPEG 等兼容图片保持原格式或安全规范化;
|
- 普通图片、SVG、Mermaid 和 ECharts 在 DOCX 媒体准备阶段统一输出 PNG;
|
||||||
- SVG 默认转换为 PNG,不直接嵌入 DOCX;
|
- SVG 不直接嵌入 DOCX,避免 Word 与 WPS 的兼容差异;
|
||||||
- Mermaid 和 ECharts 使用 PNG;
|
- 默认按 300 DPI,即 96 CSS DPI 的 3.125 倍捕获;
|
||||||
- PNG 按足够高的像素尺寸生成,DOCX 中使用纸张内容区对应的物理尺寸;
|
- 单边最长 4096px、单图最多 1600 万像素、单图最多 8 MiB、单次导出
|
||||||
|
媒体总量最多 30 MiB;
|
||||||
|
- DOCX 中使用纸张内容区对应的物理尺寸,不使用 PNG 像素尺寸直接决定
|
||||||
|
文档显示尺寸;
|
||||||
- 保持纵横比,不得超出可用内容宽高;
|
- 保持纵横比,不得超出可用内容宽高;
|
||||||
- 图表渲染必须等待字体、图片和图表资源完成;
|
- 图表渲染必须等待字体、图片和图表资源完成;
|
||||||
- 图表错误产生局部、可理解的导出错误,不输出损坏 DOCX。
|
- 图表错误产生局部、可理解的导出错误,不输出损坏 DOCX。
|
||||||
|
|||||||
@@ -10,12 +10,14 @@ Web Server 与 Electron Desktop 共用的应用服务层,组合
|
|||||||
```text
|
```text
|
||||||
src/
|
src/
|
||||||
application-service.ts 渲染、主题和资源用例入口
|
application-service.ts 渲染、主题和资源用例入口
|
||||||
|
docx-media-service.ts DOCX 媒体尺寸计算、PNG 校验与清单组装
|
||||||
image-resources.ts 本地、Base64 与受限远程图片处理
|
image-resources.ts 本地、Base64 与受限远程图片处理
|
||||||
theme-registry.ts 内置及自定义主题扫描、校验与缓存
|
theme-registry.ts 内置及自定义主题扫描、校验与缓存
|
||||||
index.ts 公共导出入口
|
index.ts 公共导出入口
|
||||||
tests/
|
tests/
|
||||||
application-service.test.ts
|
application-service.test.ts
|
||||||
bundled-themes.test.ts
|
bundled-themes.test.ts
|
||||||
|
docx-media-service.test.ts
|
||||||
image-resources.test.ts
|
image-resources.test.ts
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -32,6 +34,10 @@ const service = createApplicationService({
|
|||||||
const themes = await service.listThemes();
|
const themes = await service.listThemes();
|
||||||
const document = await service.render(request);
|
const document = await service.render(request);
|
||||||
const preparedDocx = await service.prepareDocxExport(docxRequest);
|
const preparedDocx = await service.prepareDocxExport(docxRequest);
|
||||||
|
const preparedMedia = await prepareDocxMedia(
|
||||||
|
preparedDocx,
|
||||||
|
platformCaptureAdapter
|
||||||
|
);
|
||||||
```
|
```
|
||||||
|
|
||||||
Web 端通过 `apps/server` 的 HTTP API 调用;桌面端在主进程中创建同一
|
Web 端通过 `apps/server` 的 HTTP API 调用;桌面端在主进程中创建同一
|
||||||
@@ -42,6 +48,11 @@ Desktop 才会以 Markdown 所在目录为边界解析相对资源。
|
|||||||
源请求、安全渲染文档、主题清单和主题 CSS。它不调用 Pandoc;后续 DOCX
|
源请求、安全渲染文档、主题清单和主题 CSS。它不调用 Pandoc;后续 DOCX
|
||||||
引擎只消费该准备结果,避免 Server 与 Desktop 重复实现文档准备逻辑。
|
引擎只消费该准备结果,避免 Server 与 Desktop 重复实现文档准备逻辑。
|
||||||
|
|
||||||
|
`prepareDocxMedia()` 根据纸张方向、尺寸、主题默认页边距和用户配置计算
|
||||||
|
内容区,调用平台捕获适配器,并校验捕获计划、PNG 签名、像素尺寸、单图
|
||||||
|
大小、总大小和媒体 ID。最终按文档顺序输出稳定的 `media-001.png`
|
||||||
|
清单;它不依赖 Playwright 或 Electron,平台代码只负责 Chromium 捕获。
|
||||||
|
|
||||||
内置主题来自仓库 `themes/`,当前名称为 Typora Github、
|
内置主题来自仓库 `themes/`,当前名称为 Typora Github、
|
||||||
Typora Pixyll、Typora whitey 和 Typora Clean。额外主题从平台传入的
|
Typora Pixyll、Typora whitey 和 Typora Clean。额外主题从平台传入的
|
||||||
本地主题根目录扫描;与内置主题 ID 冲突时以内置主题为准。
|
本地主题根目录扫描;与内置主题 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 MarkdownImageResource,
|
||||||
type ResolvedMarkdownImages
|
type ResolvedMarkdownImages
|
||||||
} from "./image-resources.js";
|
} from "./image-resources.js";
|
||||||
|
export {
|
||||||
|
getDocxMediaRenderDimensions,
|
||||||
|
prepareDocxMedia,
|
||||||
|
type DocxMediaCapture,
|
||||||
|
type DocxMediaCaptureAdapter,
|
||||||
|
type DocxMediaCaptureOutput,
|
||||||
|
type DocxMediaCaptureRequest,
|
||||||
|
type DocxMediaRenderDimensions
|
||||||
|
} from "./docx-media-service.js";
|
||||||
export {
|
export {
|
||||||
createThemeRegistry,
|
createThemeRegistry,
|
||||||
type ThemeRecord,
|
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 使用的请求、
|
DOCX 协议固定 Pandoc `3.9.0.2`,并统一定义跨 HTTP/IPC 使用的请求、
|
||||||
capability、错误码、结果、诊断和耗时类型。该包只描述数据协议,不启动
|
capability、错误码、结果、诊断、耗时、媒体捕获计划和 PNG 资源清单。
|
||||||
Pandoc 或读写临时文件。
|
媒体协议固定 300 DPI 目标倍率,并限制边长、总像素、单文件大小和总量。
|
||||||
|
该包只描述数据协议,不启动 Pandoc、浏览器或读写临时文件。
|
||||||
|
|
||||||
`classifyDocumentLink()` 只负责稳定分类,不执行平台动作。Web 根据分类
|
`classifyDocumentLink()` 只负责稳定分类,不执行平台动作。Web 根据分类
|
||||||
处理锚点和网络链接;Desktop 决定是否调用浏览器、系统程序或打开新的
|
处理锚点和网络链接;Desktop 决定是否调用浏览器、系统程序或打开新的
|
||||||
|
|||||||
@@ -8,6 +8,14 @@ export const DOCX_FILE_EXTENSION = ".docx";
|
|||||||
export const MAXIMUM_DOCX_MARKDOWN_LENGTH = 1_500_000;
|
export const MAXIMUM_DOCX_MARKDOWN_LENGTH = 1_500_000;
|
||||||
export const MAXIMUM_DOCX_FILE_NAME_LENGTH = 500;
|
export const MAXIMUM_DOCX_FILE_NAME_LENGTH = 500;
|
||||||
export const MAXIMUM_DOCX_RESOURCE_COUNT = 50;
|
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 const documentExportFormatSchema = z.enum(["pdf", "docx"]);
|
||||||
export type DocumentExportFormat = z.infer<
|
export type DocumentExportFormat = z.infer<
|
||||||
@@ -79,6 +87,67 @@ export const docxCapabilitySchema = z.discriminatedUnion("status", [
|
|||||||
|
|
||||||
export type DocxCapability = z.infer<typeof docxCapabilitySchema>;
|
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([
|
export const docxExportErrorCodeSchema = z.enum([
|
||||||
"INVALID_DOCX_REQUEST",
|
"INVALID_DOCX_REQUEST",
|
||||||
"INVALID_EXPORT_CONFIG",
|
"INVALID_EXPORT_CONFIG",
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
DOCX_MIME_TYPE,
|
DOCX_MIME_TYPE,
|
||||||
|
DOCX_MEDIA_RASTER_SCALE,
|
||||||
DOCX_PANDOC_VERSION,
|
DOCX_PANDOC_VERSION,
|
||||||
|
MAXIMUM_DOCX_MEDIA_EDGE_PIXELS,
|
||||||
MAXIMUM_DOCX_MARKDOWN_LENGTH,
|
MAXIMUM_DOCX_MARKDOWN_LENGTH,
|
||||||
createDocxFileName,
|
createDocxFileName,
|
||||||
defaultExportConfig,
|
defaultExportConfig,
|
||||||
docxCapabilitySchema,
|
docxCapabilitySchema,
|
||||||
|
docxMediaCapturePlanSchema,
|
||||||
docxExportErrorResponseSchema,
|
docxExportErrorResponseSchema,
|
||||||
docxExportRequestSchema
|
docxExportRequestSchema
|
||||||
} from "../src/index.js";
|
} from "../src/index.js";
|
||||||
@@ -111,4 +114,54 @@ describe("DOCX 共享协议", () => {
|
|||||||
retryable: false
|
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-document-runtime.ts 连续与分页渲染主流程
|
||||||
├── paged-preview.ts 分页载荷、消息协议与页面 CSS
|
├── paged-preview.ts 分页载荷、消息协议与页面 CSS
|
||||||
├── continuous-preview.ts 无分页 DOM 更新与稳定节点复用
|
├── continuous-preview.ts 无分页 DOM 更新与稳定节点复用
|
||||||
|
├── docx-media-runtime.ts DOCX 连续媒体舞台与 PNG 捕获计划
|
||||||
├── incremental-pagination.ts 修改边界、稳定前缀与后缀分页缓存
|
├── incremental-pagination.ts 修改边界、稳定前缀与后缀分页缓存
|
||||||
├── paged-table-handler.ts 跨页表格表头处理
|
├── paged-table-handler.ts 跨页表格表头处理
|
||||||
├── media-page-backfill.ts 图片与图表按文档顺序回填
|
├── media-page-backfill.ts 图片与图表按文档顺序回填
|
||||||
@@ -45,6 +46,16 @@ const result = await runtime.render(payload, {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const continuous = await runtime.renderContinuous(payload);
|
const continuous = await runtime.renderContinuous(payload);
|
||||||
|
|
||||||
|
const mediaPlan = await renderDocxMediaCapturePlan(
|
||||||
|
runtime,
|
||||||
|
root,
|
||||||
|
payload,
|
||||||
|
{
|
||||||
|
contentWidthPx,
|
||||||
|
contentHeightPx
|
||||||
|
}
|
||||||
|
);
|
||||||
```
|
```
|
||||||
|
|
||||||
`payload` 使用 `@md-to-pdf/core` 中的 `PagedDocumentPayload`。主题 CSS、
|
`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 链接
|
## PDF 链接
|
||||||
|
|
||||||
`preparePdfDocumentLinks()` 仅在 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 "./diagram-page-fit.js";
|
||||||
export * from "./continuous-preview.js";
|
export * from "./continuous-preview.js";
|
||||||
export * from "./document-image-fit.js";
|
export * from "./document-image-fit.js";
|
||||||
|
export * from "./docx-media-runtime.js";
|
||||||
export * from "./echarts-page-fit.js";
|
export * from "./echarts-page-fit.js";
|
||||||
export * from "./incremental-pagination.js";
|
export * from "./incremental-pagination.js";
|
||||||
export * from "./media-page-backfill.js";
|
export * from "./media-page-backfill.js";
|
||||||
|
|||||||
@@ -63,6 +63,11 @@ export interface PagedDocumentRenderOptions {
|
|||||||
mermaidOutput?: MermaidOutputMode;
|
mermaidOutput?: MermaidOutputMode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ContinuousDocumentRenderOptions {
|
||||||
|
shouldContinue?: () => boolean;
|
||||||
|
geometryCss?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface PreviewEngineStyles {
|
export interface PreviewEngineStyles {
|
||||||
highlightCss: string;
|
highlightCss: string;
|
||||||
katexCss: string;
|
katexCss: string;
|
||||||
@@ -415,7 +420,8 @@ export class PagedDocumentRuntime {
|
|||||||
|
|
||||||
private applyContinuousStyles(
|
private applyContinuousStyles(
|
||||||
documentRef: Document,
|
documentRef: Document,
|
||||||
payload: PagedPreviewPayload
|
payload: PagedPreviewPayload,
|
||||||
|
geometryCss = ""
|
||||||
) {
|
) {
|
||||||
const style =
|
const style =
|
||||||
this.continuousStyle ?? documentRef.createElement("style");
|
this.continuousStyle ?? documentRef.createElement("style");
|
||||||
@@ -427,7 +433,8 @@ export class PagedDocumentRuntime {
|
|||||||
this.styles.echartsCss,
|
this.styles.echartsCss,
|
||||||
enablePrintMediaForPreview(payload.themeCss),
|
enablePrintMediaForPreview(payload.themeCss),
|
||||||
documentInteractionCss,
|
documentInteractionCss,
|
||||||
continuousDocumentGeometryCss
|
continuousDocumentGeometryCss,
|
||||||
|
geometryCss
|
||||||
].join("\n");
|
].join("\n");
|
||||||
if (!style.isConnected) {
|
if (!style.isConnected) {
|
||||||
documentRef.head.append(style);
|
documentRef.head.append(style);
|
||||||
@@ -930,10 +937,7 @@ export class PagedDocumentRuntime {
|
|||||||
|
|
||||||
async renderContinuous(
|
async renderContinuous(
|
||||||
payload: PagedPreviewPayload,
|
payload: PagedPreviewPayload,
|
||||||
options: Pick<
|
options: ContinuousDocumentRenderOptions = {}
|
||||||
PagedDocumentRenderOptions,
|
|
||||||
"shouldContinue"
|
|
||||||
> = {}
|
|
||||||
): Promise<PagedDocumentRenderResult | undefined> {
|
): Promise<PagedDocumentRenderResult | undefined> {
|
||||||
const totalStartedAt = performance.now();
|
const totalStartedAt = performance.now();
|
||||||
const shouldContinue = options.shouldContinue ?? (() => true);
|
const shouldContinue = options.shouldContinue ?? (() => true);
|
||||||
@@ -950,7 +954,11 @@ export class PagedDocumentRuntime {
|
|||||||
payload.metadata.language || "zh-CN";
|
payload.metadata.language || "zh-CN";
|
||||||
documentRef.title =
|
documentRef.title =
|
||||||
payload.metadata.title || "Markdown 连续预览";
|
payload.metadata.title || "Markdown 连续预览";
|
||||||
this.applyContinuousStyles(documentRef, payload);
|
this.applyContinuousStyles(
|
||||||
|
documentRef,
|
||||||
|
payload,
|
||||||
|
options.geometryCss
|
||||||
|
);
|
||||||
|
|
||||||
const template = documentRef.createElement("template");
|
const template = documentRef.createElement("template");
|
||||||
template.innerHTML = payload.articleHtml;
|
template.innerHTML = payload.articleHtml;
|
||||||
@@ -963,7 +971,8 @@ export class PagedDocumentRuntime {
|
|||||||
const nextSignatures = nextNodes.map(createNodeSignature);
|
const nextSignatures = nextNodes.map(createNodeSignature);
|
||||||
const identity = JSON.stringify({
|
const identity = JSON.stringify({
|
||||||
themeCss: payload.themeCss,
|
themeCss: payload.themeCss,
|
||||||
mermaid: payload.exportConfig.mermaid
|
mermaid: payload.exportConfig.mermaid,
|
||||||
|
geometryCss: options.geometryCss
|
||||||
});
|
});
|
||||||
const currentArticle =
|
const currentArticle =
|
||||||
this.root.querySelector<HTMLElement>(":scope > #write");
|
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