Files
MorphDoc/packages/preview-engine/src/docx-media-runtime.ts
T
SkyJourney 58f87cc19f feat: 完成 DOCX R4 元素级视觉门禁
建立 Chromium、Word 与 WPS 的可编辑内容、段落几何、页眉页脚、封面、媒体和逐页视觉比较链路。

支持封面独立分节、正文页码重启、主题页边距与横向纸张,并将自然分页差异降为诊断项。

修正代码块内边距和折叠表格单元格边框映射,14 套主题生产矩阵与六组布局视觉矩阵通过。
2026-08-02 03:27:11 +08:00

240 lines
6.3 KiB
TypeScript

import {
DOCX_MEDIA_RASTER_SCALE,
MAXIMUM_DOCX_MEDIA_EDGE_PIXELS,
MAXIMUM_DOCX_MEDIA_PIXELS,
MAXIMUM_DOCX_RESOURCE_COUNT,
type DocxMediaCapturePlan,
type DocxMediaCaptureTarget,
type DocxMediaAlignment,
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 getMediaAlignment(
article: HTMLElement,
rect: DOMRect
): DocxMediaAlignment {
const articleRect = article.getBoundingClientRect();
const articleWidth = finitePositive(articleRect.width, rect.width);
if (rect.width >= articleWidth - 2) {
return "center";
}
const leftScore = Math.abs(rect.left - articleRect.left);
const centerScore = Math.abs(
(rect.left + rect.right) / 2 -
(articleRect.left + articleRect.right) / 2
);
const rightScore = Math.abs(articleRect.right - rect.right);
const minimumScore = Math.min(
leftScore,
centerScore,
rightScore
);
if (centerScore <= minimumScore + 2) {
return "center";
}
return leftScore <= rightScore ? "left" : "right";
}
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) }
: {}),
alignment: getMediaAlignment(article, rect),
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
};
}