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

This commit is contained in:
SkyJourney
2026-07-30 13:21:18 +08:00
parent 7831bc23f0
commit fe5d67fb6d
20 changed files with 1294 additions and 16 deletions
+11
View File
@@ -10,12 +10,14 @@ Web Server 与 Electron Desktop 共用的应用服务层,组合
```text
src/
application-service.ts 渲染、主题和资源用例入口
docx-media-service.ts DOCX 媒体尺寸计算、PNG 校验与清单组装
image-resources.ts 本地、Base64 与受限远程图片处理
theme-registry.ts 内置及自定义主题扫描、校验与缓存
index.ts 公共导出入口
tests/
application-service.test.ts
bundled-themes.test.ts
docx-media-service.test.ts
image-resources.test.ts
```
@@ -32,6 +34,10 @@ const service = createApplicationService({
const themes = await service.listThemes();
const document = await service.render(request);
const preparedDocx = await service.prepareDocxExport(docxRequest);
const preparedMedia = await prepareDocxMedia(
preparedDocx,
platformCaptureAdapter
);
```
Web 端通过 `apps/server` 的 HTTP API 调用;桌面端在主进程中创建同一
@@ -42,6 +48,11 @@ Desktop 才会以 Markdown 所在目录为边界解析相对资源。
源请求、安全渲染文档、主题清单和主题 CSS。它不调用 Pandoc;后续 DOCX
引擎只消费该准备结果,避免 Server 与 Desktop 重复实现文档准备逻辑。
`prepareDocxMedia()` 根据纸张方向、尺寸、主题默认页边距和用户配置计算
内容区,调用平台捕获适配器,并校验捕获计划、PNG 签名、像素尺寸、单图
大小、总大小和媒体 ID。最终按文档顺序输出稳定的 `media-001.png`
清单;它不依赖 Playwright 或 Electron,平台代码只负责 Chromium 捕获。
内置主题来自仓库 `themes/`,当前名称为 Typora Github、
Typora Pixyll、Typora whitey 和 Typora Clean。额外主题从平台传入的
本地主题根目录扫描;与内置主题 ID 冲突时以内置主题为准。
@@ -0,0 +1,195 @@
import {
MAXIMUM_DOCX_MEDIA_BYTES,
MAXIMUM_DOCX_MEDIA_EDGE_PIXELS,
MAXIMUM_DOCX_MEDIA_PIXELS,
MAXIMUM_DOCX_TOTAL_MEDIA_BYTES,
createPagedDocumentPayload,
docxMediaCapturePlanSchema,
getPaperDimensionsMm,
lengthToMillimeters,
millimetersToCssPixels,
resolvePageMargins,
type DocxMediaCapturePlan,
type DocxPngMediaResource,
type PagedDocumentPayload,
type PreparedDocxMedia
} from "@md-to-pdf/core";
import type { PreparedDocxExport } from "./application-service.js";
export interface DocxMediaRenderDimensions {
contentWidthPx: number;
contentHeightPx: number;
}
export interface DocxMediaCaptureRequest {
payload: PagedDocumentPayload;
dimensions: DocxMediaRenderDimensions;
}
export interface DocxMediaCapture {
id: string;
png: Uint8Array;
}
export interface DocxMediaCaptureOutput {
plan: unknown;
captures: DocxMediaCapture[];
}
export interface DocxMediaCaptureAdapter {
capture(
request: DocxMediaCaptureRequest
): Promise<DocxMediaCaptureOutput>;
}
const pngSignature = [
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a
] as const;
function readPngDimensions(content: Uint8Array) {
if (
content.byteLength < 24 ||
pngSignature.some((value, index) => content[index] !== value) ||
String.fromCharCode(...content.slice(12, 16)) !== "IHDR"
) {
throw new Error("DOCX 媒体捕获结果不是有效 PNG");
}
const view = new DataView(
content.buffer,
content.byteOffset,
content.byteLength
);
const width = view.getUint32(16);
const height = view.getUint32(20);
if (
width < 1 ||
height < 1 ||
width > MAXIMUM_DOCX_MEDIA_EDGE_PIXELS ||
height > MAXIMUM_DOCX_MEDIA_EDGE_PIXELS ||
width * height > MAXIMUM_DOCX_MEDIA_PIXELS
) {
throw new Error("DOCX PNG 媒体像素尺寸超过限制");
}
return { width, height };
}
export function getDocxMediaRenderDimensions(
prepared: PreparedDocxExport
) {
const paper = prepared.request.exportConfig.paper;
const dimensions = getPaperDimensionsMm(
paper.format,
paper.orientation
);
const margins = resolvePageMargins(
paper,
prepared.theme.manifest.pageDefaults?.margins
);
const contentWidthMm =
dimensions.width -
lengthToMillimeters(margins.left) -
lengthToMillimeters(margins.right);
const contentHeightMm =
dimensions.height -
lengthToMillimeters(margins.top) -
lengthToMillimeters(margins.bottom);
return {
contentWidthPx: millimetersToCssPixels(contentWidthMm),
contentHeightPx: millimetersToCssPixels(contentHeightMm)
};
}
function validateCaptures(
plan: DocxMediaCapturePlan,
captures: DocxMediaCapture[]
) {
const byId = new Map<string, Uint8Array>();
let totalBytes = 0;
for (const capture of captures) {
if (byId.has(capture.id)) {
throw new Error(`DOCX 媒体 ${capture.id} 重复`);
}
if (capture.png.byteLength > MAXIMUM_DOCX_MEDIA_BYTES) {
throw new Error(`DOCX 媒体 ${capture.id} 超过单文件大小限制`);
}
totalBytes += capture.png.byteLength;
if (totalBytes > MAXIMUM_DOCX_TOTAL_MEDIA_BYTES) {
throw new Error("DOCX PNG 媒体总大小超过限制");
}
byId.set(capture.id, capture.png);
}
if (
byId.size !== plan.targets.length ||
plan.targets.some((target) => !byId.has(target.id))
) {
throw new Error("DOCX 媒体捕获结果与渲染计划不匹配");
}
const resources = plan.targets.map(
(target): DocxPngMediaResource => {
const content = byId.get(target.id)!;
const { width, height } = readPngDimensions(content);
const expectedWidth = Math.round(
target.captureWidthPx * target.rasterScale
);
const expectedHeight = Math.round(
target.captureHeightPx * target.rasterScale
);
if (
Math.abs(width - expectedWidth) > 2 ||
Math.abs(height - expectedHeight) > 2
) {
throw new Error(
`DOCX 媒体 ${target.id} 的 PNG 尺寸与捕获计划不一致`
);
}
return {
...target,
fileName: `media-${String(target.ordinal).padStart(
3,
"0"
)}.png`,
contentType: "image/png",
content,
pixelWidth: width,
pixelHeight: height
};
}
);
return { resources, totalBytes };
}
export async function prepareDocxMedia(
prepared: PreparedDocxExport,
adapter: DocxMediaCaptureAdapter
): Promise<PreparedDocxMedia> {
const dimensions = getDocxMediaRenderDimensions(prepared);
const output = await adapter.capture({
payload: createPagedDocumentPayload({
document: prepared.document,
fileName: prepared.request.fileName,
themeCss: prepared.theme.css,
exportConfig: prepared.request.exportConfig
}),
dimensions
});
const parsedPlan = docxMediaCapturePlanSchema.safeParse(output.plan);
if (!parsedPlan.success) {
throw new Error("DOCX 媒体捕获计划无效");
}
const { resources, totalBytes } = validateCaptures(
parsedPlan.data,
output.captures
);
return {
resources,
echartsErrors: parsedPlan.data.echartsErrors,
mermaidErrors: parsedPlan.data.mermaidErrors,
warnings: [
...prepared.document.warnings,
...parsedPlan.data.echartsErrors,
...parsedPlan.data.mermaidErrors
],
totalBytes
};
}
+9
View File
@@ -21,6 +21,15 @@ export {
type MarkdownImageResource,
type ResolvedMarkdownImages
} from "./image-resources.js";
export {
getDocxMediaRenderDimensions,
prepareDocxMedia,
type DocxMediaCapture,
type DocxMediaCaptureAdapter,
type DocxMediaCaptureOutput,
type DocxMediaCaptureRequest,
type DocxMediaRenderDimensions
} from "./docx-media-service.js";
export {
createThemeRegistry,
type ThemeRecord,
@@ -0,0 +1,173 @@
import { describe, expect, it } from "vitest";
import {
defaultExportConfig,
type DocxMediaCapturePlan,
type ThemeManifest
} from "@md-to-pdf/core";
import {
getDocxMediaRenderDimensions,
prepareDocxMedia,
type DocxMediaCaptureAdapter,
type PreparedDocxExport
} from "../src/index.js";
function createPng(width: number, height: number) {
const content = new Uint8Array(24);
content.set([
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a
]);
content.set([0x49, 0x48, 0x44, 0x52], 12);
const view = new DataView(content.buffer);
view.setUint32(16, width);
view.setUint32(20, height);
return content;
}
const manifest: ThemeManifest = {
manifestVersion: 1,
id: "test-theme",
name: "测试主题",
version: "1.0.0",
description: "测试",
author: "测试",
license: "内部许可",
entry: "theme.css",
domPreset: "typora",
defaultFontSize: "16px",
supportedFeatures: ["mermaid", "echarts"],
category: "general",
compatibleProfiles: [],
pageDefaults: {
margins: {
top: "20mm",
right: "20mm",
bottom: "20mm",
left: "20mm"
}
},
bundled: true
};
const prepared: PreparedDocxExport = {
request: {
markdown: "# 文档",
fileName: "报告.md",
language: "zh-CN",
resources: [],
exportConfig: {
...defaultExportConfig,
themeId: manifest.id
}
},
document: {
rendererVersion: 1,
articleHtml: '<article id="write"></article>',
bodyHtml: "",
metadata: {
title: "文档",
author: "",
subject: "",
keywords: [],
language: "zh-CN"
},
features: [],
warnings: ["原始图片已降级"]
},
theme: {
manifest,
source: "bundled",
css: "#write { color: black; }"
}
};
function createPlan(): DocxMediaCapturePlan {
return {
targets: [
{
id: "docx-media-1",
kind: "echarts",
ordinal: 1,
kindOrdinal: 1,
altText: "收入趋势",
caption: "年度收入",
displayWidthPx: 320,
displayHeightPx: 160,
captureX: 0,
captureY: 0,
captureWidthPx: 320,
captureHeightPx: 160,
rasterScale: 3.125
}
],
echartsErrors: [],
mermaidErrors: []
};
}
describe("DOCX 媒体预处理服务", () => {
it("按主题页边距计算媒体内容区域", () => {
const dimensions = getDocxMediaRenderDimensions(prepared);
expect(dimensions.contentWidthPx).toBeCloseTo(
((210 - 40) * 96) / 25.4
);
expect(dimensions.contentHeightPx).toBeCloseTo(
((297 - 40) * 96) / 25.4
);
});
it("校验并输出稳定 PNG 媒体清单", async () => {
const adapter: DocxMediaCaptureAdapter = {
capture: async ({ dimensions, payload }) => {
expect(dimensions.contentWidthPx).toBeGreaterThan(600);
expect(payload.articleHtml).toContain('id="write"');
return {
plan: createPlan(),
captures: [
{
id: "docx-media-1",
png: createPng(1000, 500)
}
]
};
}
};
const result = await prepareDocxMedia(prepared, adapter);
expect(result.resources[0]).toMatchObject({
id: "docx-media-1",
fileName: "media-001.png",
contentType: "image/png",
pixelWidth: 1000,
pixelHeight: 500,
caption: "年度收入"
});
expect(result.warnings).toEqual(["原始图片已降级"]);
expect(result.totalBytes).toBe(24);
});
it("拒绝伪 PNG、缺失捕获和尺寸不一致", async () => {
const capture = async (
png: Uint8Array,
includeCapture = true
) =>
prepareDocxMedia(prepared, {
capture: async () => ({
plan: createPlan(),
captures: includeCapture
? [{ id: "docx-media-1", png }]
: []
})
});
await expect(capture(new Uint8Array(24))).rejects.toThrow(
"不是有效 PNG"
);
await expect(
capture(createPng(1000, 500), false)
).rejects.toThrow("不匹配");
await expect(capture(createPng(990, 500))).rejects.toThrow(
"尺寸与捕获计划不一致"
);
});
});
+3 -2
View File
@@ -48,8 +48,9 @@ const payload = createPagedDocumentPayload({
```
DOCX 协议固定 Pandoc `3.9.0.2`,并统一定义跨 HTTP/IPC 使用的请求、
capability、错误码、结果、诊断耗时类型。该包只描述数据协议,不启动
Pandoc 或读写临时文件
capability、错误码、结果、诊断耗时、媒体捕获计划和 PNG 资源清单。
媒体协议固定 300 DPI 目标倍率,并限制边长、总像素、单文件大小和总量
该包只描述数据协议,不启动 Pandoc、浏览器或读写临时文件。
`classifyDocumentLink()` 只负责稳定分类,不执行平台动作。Web 根据分类
处理锚点和网络链接;Desktop 决定是否调用浏览器、系统程序或打开新的
+69
View File
@@ -8,6 +8,14 @@ export const DOCX_FILE_EXTENSION = ".docx";
export const MAXIMUM_DOCX_MARKDOWN_LENGTH = 1_500_000;
export const MAXIMUM_DOCX_FILE_NAME_LENGTH = 500;
export const MAXIMUM_DOCX_RESOURCE_COUNT = 50;
export const DOCX_MEDIA_RASTER_DPI = 300;
export const DOCX_MEDIA_CSS_DPI = 96;
export const DOCX_MEDIA_RASTER_SCALE =
DOCX_MEDIA_RASTER_DPI / DOCX_MEDIA_CSS_DPI;
export const MAXIMUM_DOCX_MEDIA_EDGE_PIXELS = 4_096;
export const MAXIMUM_DOCX_MEDIA_PIXELS = 16_000_000;
export const MAXIMUM_DOCX_MEDIA_BYTES = 8 * 1024 * 1024;
export const MAXIMUM_DOCX_TOTAL_MEDIA_BYTES = 30 * 1024 * 1024;
export const documentExportFormatSchema = z.enum(["pdf", "docx"]);
export type DocumentExportFormat = z.infer<
@@ -79,6 +87,67 @@ export const docxCapabilitySchema = z.discriminatedUnion("status", [
export type DocxCapability = z.infer<typeof docxCapabilitySchema>;
export const docxMediaKindSchema = z.enum([
"image",
"mermaid",
"echarts"
]);
export type DocxMediaKind = z.infer<typeof docxMediaKindSchema>;
export const docxMediaCaptureTargetSchema = z.object({
id: z.string().regex(/^docx-media-\d+$/u),
kind: docxMediaKindSchema,
ordinal: z.number().int().min(1).max(MAXIMUM_DOCX_RESOURCE_COUNT),
kindOrdinal: z
.number()
.int()
.min(1)
.max(MAXIMUM_DOCX_RESOURCE_COUNT),
altText: z.string().max(1_000),
caption: z.string().max(1_000).optional(),
displayWidthPx: z.number().positive().max(10_000),
displayHeightPx: z.number().positive().max(10_000),
captureX: z.number().int().nonnegative().max(100_000),
captureY: z.number().int().nonnegative().max(100_000),
captureWidthPx: z.number().int().positive().max(10_000),
captureHeightPx: z.number().int().positive().max(10_000),
rasterScale: z.number().positive().max(DOCX_MEDIA_RASTER_SCALE)
});
export type DocxMediaCaptureTarget = z.infer<
typeof docxMediaCaptureTargetSchema
>;
export const docxMediaCapturePlanSchema = z.object({
targets: z
.array(docxMediaCaptureTargetSchema)
.max(MAXIMUM_DOCX_RESOURCE_COUNT),
echartsErrors: z.array(z.string().max(1_000)),
mermaidErrors: z.array(z.string().max(1_000))
});
export type DocxMediaCapturePlan = z.infer<
typeof docxMediaCapturePlanSchema
>;
export interface DocxPngMediaResource
extends DocxMediaCaptureTarget {
fileName: string;
contentType: "image/png";
content: Uint8Array;
pixelWidth: number;
pixelHeight: number;
}
export interface PreparedDocxMedia {
resources: DocxPngMediaResource[];
echartsErrors: string[];
mermaidErrors: string[];
warnings: string[];
totalBytes: number;
}
export const docxExportErrorCodeSchema = z.enum([
"INVALID_DOCX_REQUEST",
"INVALID_EXPORT_CONFIG",
+53
View File
@@ -1,11 +1,14 @@
import { describe, expect, it } from "vitest";
import {
DOCX_MIME_TYPE,
DOCX_MEDIA_RASTER_SCALE,
DOCX_PANDOC_VERSION,
MAXIMUM_DOCX_MEDIA_EDGE_PIXELS,
MAXIMUM_DOCX_MARKDOWN_LENGTH,
createDocxFileName,
defaultExportConfig,
docxCapabilitySchema,
docxMediaCapturePlanSchema,
docxExportErrorResponseSchema,
docxExportRequestSchema
} from "../src/index.js";
@@ -111,4 +114,54 @@ describe("DOCX 共享协议", () => {
retryable: false
});
});
it("校验 DOCX PNG 媒体捕获计划", () => {
expect(DOCX_MEDIA_RASTER_SCALE).toBe(3.125);
expect(MAXIMUM_DOCX_MEDIA_EDGE_PIXELS).toBe(4096);
expect(
docxMediaCapturePlanSchema.parse({
targets: [
{
id: "docx-media-1",
kind: "mermaid",
ordinal: 1,
kindOrdinal: 1,
altText: "流程图",
caption: "处理流程",
displayWidthPx: 640,
displayHeightPx: 320,
captureX: 20,
captureY: 120,
captureWidthPx: 640,
captureHeightPx: 320,
rasterScale: 3.125
}
],
echartsErrors: [],
mermaidErrors: []
}).targets
).toHaveLength(1);
expect(
docxMediaCapturePlanSchema.safeParse({
targets: [
{
id: "../越界",
kind: "image",
ordinal: 1,
kindOrdinal: 1,
altText: "",
displayWidthPx: 100,
displayHeightPx: 100,
captureX: 0,
captureY: 0,
captureWidthPx: 100,
captureHeightPx: 100,
rasterScale: 3.125
}
],
echartsErrors: [],
mermaidErrors: []
}).success
).toBe(false);
});
});
+22
View File
@@ -12,6 +12,7 @@ src/
├── paged-document-runtime.ts 连续与分页渲染主流程
├── paged-preview.ts 分页载荷、消息协议与页面 CSS
├── continuous-preview.ts 无分页 DOM 更新与稳定节点复用
├── docx-media-runtime.ts DOCX 连续媒体舞台与 PNG 捕获计划
├── incremental-pagination.ts 修改边界、稳定前缀与后缀分页缓存
├── paged-table-handler.ts 跨页表格表头处理
├── media-page-backfill.ts 图片与图表按文档顺序回填
@@ -45,6 +46,16 @@ const result = await runtime.render(payload, {
});
const continuous = await runtime.renderContinuous(payload);
const mediaPlan = await renderDocxMediaCapturePlan(
runtime,
root,
payload,
{
contentWidthPx,
contentHeightPx
}
);
```
`payload` 使用 `@md-to-pdf/core` 中的 `PagedDocumentPayload`。主题 CSS、
@@ -69,6 +80,17 @@ const continuous = await runtime.renderContinuous(payload);
得到基准高度;上一页空白与媒体需求接近时,才尝试只缩放媒体主体回填,
标题始终保持自然尺寸。
## DOCX 媒体舞台
`renderDocxMediaCapturePlan()` 复用连续渲染流程,等待字体、图片、
Mermaid 和 ECharts 完成后,按 DOM 顺序标记普通图片、Mermaid SVG 与
ECharts SVG。运行时将媒体限制在纸张内容区内,并返回整数外包围捕获框、
显示尺寸、替代文本、图注和目标位图倍率。
目标倍率默认为 3.125300 DPI),并受 4096px 单边和 1600 万像素上限
约束。运行时只生成平台无关的捕获计划;Server 使用 Playwright Chromium
Desktop 使用 Electron Chromium 输出 PNG。
## PDF 链接
`preparePdfDocumentLinks()` 仅在 PDF 目标中将本地链接编码为
@@ -0,0 +1,210 @@
import {
DOCX_MEDIA_RASTER_SCALE,
MAXIMUM_DOCX_MEDIA_EDGE_PIXELS,
MAXIMUM_DOCX_MEDIA_PIXELS,
MAXIMUM_DOCX_RESOURCE_COUNT,
type DocxMediaCapturePlan,
type DocxMediaCaptureTarget,
type DocxMediaKind,
type PagedDocumentPayload
} from "@md-to-pdf/core";
import { PagedDocumentRuntime } from "./paged-document-runtime.js";
export interface DocxMediaRenderDimensions {
contentWidthPx: number;
contentHeightPx: number;
}
declare global {
interface Window {
__mdToPdfRenderDocxMedia?: (
payload: PagedDocumentPayload,
dimensions: DocxMediaRenderDimensions
) => Promise<DocxMediaCapturePlan>;
}
}
function finitePositive(value: number, fallback: number) {
return Number.isFinite(value) && value > 0 ? value : fallback;
}
function getMediaKind(element: Element): DocxMediaKind {
if (element.matches("img.md-document-image")) {
return "image";
}
if (element.closest(".md-echarts")) {
return "echarts";
}
return "mermaid";
}
function getCaption(element: Element) {
return (
element
.closest("figure")
?.querySelector("figcaption")
?.textContent?.trim() || undefined
);
}
function getAltText(
element: Element,
kind: DocxMediaKind,
kindOrdinal: number
) {
if (element instanceof HTMLImageElement) {
return element.alt.trim() || `图片 ${kindOrdinal}`;
}
const labelled =
element.getAttribute("aria-label")?.trim() ||
element
.closest<HTMLElement>("[aria-label]")
?.getAttribute("aria-label")
?.trim();
if (labelled) {
return labelled;
}
return kind === "echarts"
? `ECharts 图表 ${kindOrdinal}`
: `Mermaid 图表 ${kindOrdinal}`;
}
function fitElementToContent(
element: HTMLElement | SVGSVGElement,
dimensions: DocxMediaRenderDimensions
) {
const initial = element.getBoundingClientRect();
const width = finitePositive(initial.width, dimensions.contentWidthPx);
const height = finitePositive(
initial.height,
Math.min(dimensions.contentHeightPx, width * 0.75)
);
const fitScale = Math.min(
1,
dimensions.contentWidthPx / width,
dimensions.contentHeightPx / height
);
if (fitScale < 1) {
element.style.width = `${width * fitScale}px`;
element.style.height = `${height * fitScale}px`;
element.style.maxWidth = "none";
element.style.maxHeight = "none";
}
return element.getBoundingClientRect();
}
function getRasterScale(width: number, height: number) {
return Math.min(
DOCX_MEDIA_RASTER_SCALE,
MAXIMUM_DOCX_MEDIA_EDGE_PIXELS / width,
MAXIMUM_DOCX_MEDIA_EDGE_PIXELS / height,
Math.sqrt(MAXIMUM_DOCX_MEDIA_PIXELS / (width * height))
);
}
function createGeometryCss(dimensions: DocxMediaRenderDimensions) {
return `
#preview-root {
width: ${dimensions.contentWidthPx}px !important;
padding: 0 !important;
}
#write {
width: ${dimensions.contentWidthPx}px !important;
padding: 0 !important;
box-shadow: none !important;
}
`;
}
export function collectDocxMediaCaptureTargets(
root: ParentNode,
dimensions: DocxMediaRenderDimensions
) {
const article = root.querySelector<HTMLElement>("#write");
if (!article) {
throw new Error("DOCX 媒体舞台缺少 #write 文档容器");
}
const candidates = Array.from(
article.querySelectorAll<HTMLElement | SVGSVGElement>(
[
"img.md-document-image",
".mermaid:not(.mermaid-error) svg",
".md-echarts:not(.md-echarts-error) .md-echarts-host svg"
].join(",")
)
);
if (candidates.length > MAXIMUM_DOCX_RESOURCE_COUNT) {
throw new Error(
`DOCX 媒体数量不能超过 ${MAXIMUM_DOCX_RESOURCE_COUNT}`
);
}
const kindOrdinals: Record<DocxMediaKind, number> = {
image: 0,
mermaid: 0,
echarts: 0
};
return candidates.map((element, index): DocxMediaCaptureTarget => {
const kind = getMediaKind(element);
kindOrdinals[kind] += 1;
const kindOrdinal = kindOrdinals[kind];
const rect = fitElementToContent(element, dimensions);
const width = finitePositive(rect.width, 1);
const height = finitePositive(rect.height, 1);
const captureX = Math.max(
0,
Math.floor(rect.left + window.scrollX)
);
const captureY = Math.max(
0,
Math.floor(rect.top + window.scrollY)
);
const captureWidth = Math.max(
1,
Math.ceil(rect.right + window.scrollX) - captureX
);
const captureHeight = Math.max(
1,
Math.ceil(rect.bottom + window.scrollY) - captureY
);
const id = `docx-media-${index + 1}`;
element.dataset.docxMediaId = id;
return {
id,
kind,
ordinal: index + 1,
kindOrdinal,
altText: getAltText(element, kind, kindOrdinal),
...(getCaption(element)
? { caption: getCaption(element) }
: {}),
displayWidthPx: width,
displayHeightPx: height,
captureX,
captureY,
captureWidthPx: captureWidth,
captureHeightPx: captureHeight,
rasterScale: getRasterScale(captureWidth, captureHeight)
};
});
}
export async function renderDocxMediaCapturePlan(
runtime: PagedDocumentRuntime,
root: HTMLElement,
payload: PagedDocumentPayload,
dimensions: DocxMediaRenderDimensions
): Promise<DocxMediaCapturePlan> {
const renderResult = await runtime.renderContinuous(payload, {
geometryCss: createGeometryCss(dimensions)
});
if (!renderResult) {
throw new Error("DOCX 媒体渲染已取消");
}
return {
targets: collectDocxMediaCaptureTargets(root, dimensions),
echartsErrors: renderResult.echartsErrors,
mermaidErrors: renderResult.mermaidErrors
};
}
+1
View File
@@ -1,6 +1,7 @@
export * from "./diagram-page-fit.js";
export * from "./continuous-preview.js";
export * from "./document-image-fit.js";
export * from "./docx-media-runtime.js";
export * from "./echarts-page-fit.js";
export * from "./incremental-pagination.js";
export * from "./media-page-backfill.js";
@@ -63,6 +63,11 @@ export interface PagedDocumentRenderOptions {
mermaidOutput?: MermaidOutputMode;
}
export interface ContinuousDocumentRenderOptions {
shouldContinue?: () => boolean;
geometryCss?: string;
}
export interface PreviewEngineStyles {
highlightCss: string;
katexCss: string;
@@ -415,7 +420,8 @@ export class PagedDocumentRuntime {
private applyContinuousStyles(
documentRef: Document,
payload: PagedPreviewPayload
payload: PagedPreviewPayload,
geometryCss = ""
) {
const style =
this.continuousStyle ?? documentRef.createElement("style");
@@ -427,7 +433,8 @@ export class PagedDocumentRuntime {
this.styles.echartsCss,
enablePrintMediaForPreview(payload.themeCss),
documentInteractionCss,
continuousDocumentGeometryCss
continuousDocumentGeometryCss,
geometryCss
].join("\n");
if (!style.isConnected) {
documentRef.head.append(style);
@@ -930,10 +937,7 @@ export class PagedDocumentRuntime {
async renderContinuous(
payload: PagedPreviewPayload,
options: Pick<
PagedDocumentRenderOptions,
"shouldContinue"
> = {}
options: ContinuousDocumentRenderOptions = {}
): Promise<PagedDocumentRenderResult | undefined> {
const totalStartedAt = performance.now();
const shouldContinue = options.shouldContinue ?? (() => true);
@@ -950,7 +954,11 @@ export class PagedDocumentRuntime {
payload.metadata.language || "zh-CN";
documentRef.title =
payload.metadata.title || "Markdown 连续预览";
this.applyContinuousStyles(documentRef, payload);
this.applyContinuousStyles(
documentRef,
payload,
options.geometryCss
);
const template = documentRef.createElement("template");
template.innerHTML = payload.articleHtml;
@@ -963,7 +971,8 @@ export class PagedDocumentRuntime {
const nextSignatures = nextNodes.map(createNodeSignature);
const identity = JSON.stringify({
themeCss: payload.themeCss,
mermaid: payload.exportConfig.mermaid
mermaid: payload.exportConfig.mermaid,
geometryCss: options.geometryCss
});
const currentArticle =
this.root.querySelector<HTMLElement>(":scope > #write");
@@ -0,0 +1,124 @@
// @vitest-environment happy-dom
import { beforeEach, describe, expect, it } from "vitest";
import { collectDocxMediaCaptureTargets } from "../src/index.js";
describe("DOCX 媒体捕获计划", () => {
beforeEach(() => {
document.body.innerHTML = "";
window.scrollTo(0, 0);
});
it("按文档顺序标记图片、Mermaid 和 ECharts", () => {
document.body.innerHTML = `
<article id="write">
<figure class="md-document-image-block">
<img class="md-document-image" alt="架构截图">
<figcaption>系统架构</figcaption>
</figure>
<div class="mermaid"><svg aria-label="处理流程"></svg></div>
<figure class="md-echarts">
<div class="md-echarts-host" aria-label="年度收入">
<svg></svg>
</div>
<figcaption>收入趋势</figcaption>
</figure>
</article>
`;
const media = Array.from(
document.querySelectorAll<HTMLElement | SVGSVGElement>(
"img, svg"
)
);
media.forEach((element, index) => {
element.getBoundingClientRect = () =>
({
x: 10,
y: 20 + index * 100,
left: 10,
top: 20 + index * 100,
right: 650,
bottom: 380 + index * 100,
width: 640,
height: 360,
toJSON: () => ({})
}) as DOMRect;
});
const targets = collectDocxMediaCaptureTargets(document, {
contentWidthPx: 700,
contentHeightPx: 900
});
expect(
targets.map(
({ id, kind, kindOrdinal, altText, caption }) => ({
id,
kind,
kindOrdinal,
altText,
caption
})
)
).toEqual([
{
id: "docx-media-1",
kind: "image",
kindOrdinal: 1,
altText: "架构截图",
caption: "系统架构"
},
{
id: "docx-media-2",
kind: "mermaid",
kindOrdinal: 1,
altText: "处理流程",
caption: undefined
},
{
id: "docx-media-3",
kind: "echarts",
kindOrdinal: 1,
altText: "年度收入",
caption: "收入趋势"
}
]);
expect(
media.map((element) => element.dataset.docxMediaId)
).toEqual(["docx-media-1", "docx-media-2", "docx-media-3"]);
expect(targets[0]?.rasterScale).toBe(3.125);
});
it("限制超大媒体的显示尺寸和 PNG 像素规模", () => {
document.body.innerHTML = `
<article id="write">
<img class="md-document-image" alt="">
</article>
`;
const image = document.querySelector("img")!;
image.getBoundingClientRect = () =>
({
x: 0,
y: 0,
left: 0,
top: 0,
right: image.style.width ? 800 : 4000,
bottom: image.style.width ? 600 : 3000,
width: image.style.width ? 800 : 4000,
height: image.style.width ? 600 : 3000,
toJSON: () => ({})
}) as DOMRect;
const [target] = collectDocxMediaCaptureTargets(document, {
contentWidthPx: 800,
contentHeightPx: 900
});
expect(target?.displayWidthPx).toBeLessThanOrEqual(800);
expect(
(target?.captureWidthPx ?? 0) *
(target?.rasterScale ?? 0)
).toBeLessThanOrEqual(4096);
expect(target?.altText).toBe("图片 1");
});
});