feat: 实现 DOCX 媒体预处理与跨端 PNG 捕获
This commit is contained in:
@@ -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
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user