feat: 实现 DOCX 媒体预处理与跨端 PNG 捕获
This commit is contained in:
@@ -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