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

This commit is contained in:
SkyJourney
2026-07-30 13:21:18 +08:00
parent 7831bc23f0
commit fe5d67fb6d
20 changed files with 1294 additions and 16 deletions
@@ -0,0 +1,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");