建立 Chromium、Word 与 WPS 的可编辑内容、段落几何、页眉页脚、封面、媒体和逐页视觉比较链路。 支持封面独立分节、正文页码重启、主题页边距与横向纸张,并将自然分页差异降为诊断项。 修正代码块内边距和折叠表格单元格边框映射,14 套主题生产矩阵与六组布局视觉矩阵通过。
240 lines
6.3 KiB
TypeScript
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
|
|
};
|
|
}
|