Files
MorphDoc/packages/preview-engine/src/paged-document-runtime.ts
T
SkyJourney 64445322eb release: 发布 v0.6.2 DOCX 真实文档修复
新增能力:将 DOCX 发布验收拆分为四套独立 140,支持真实语料冻结、指纹复用、失败与基础设施错误独立统计,并为表格换行、全 JSON 围栏、代码连续性和长文档分页建立通用门禁。

问题修复:冻结 Paged.js 分片前的逻辑表格列轨并传递打印几何,统一 Markdown 表格换行、代码、段落与 OOXML 翻译;改进 PDF 文本流排序、语义块映射、颜色与栅格比较,消除窄字符重叠和跨行范围符号误报。

兼容与部署:版本统一为 0.6.2;正式 Docker 镜像内置固定 Chromium、Pandoc 3.9.0.2 和 Serif/Sans/Mono 字体;Desktop NSIS 与 ZIP 继续直接内置字体,无需系统字体安装。

验证结果:合成基线与长庆严格 280/280,M4N 140/140;健康数据残余误报 6/115(5.22%),均核查为重复表头自动对齐/取样误报且基础设施错误为 0。全项目测试、类型检查、生产构建和 git diff --check 通过;正式 Docker、NSIS、ZIP、离线镜像、部署包、清单及 SHA-256 均已生成并校验。
2026-08-26 10:50:20 +08:00

1192 lines
33 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type {
PagedDocumentRenderResult,
PagedDocumentTimings
} from "@md-to-pdf/core";
import {
Previewer,
type PagedBreakToken
} from "pagedjs";
import {
createNodeSignature,
findCommonPrefixLength,
mountContinuousRenderStage
} from "./continuous-preview.js";
import { prepareCodeBlockPagination } from "./code-block-pagination.js";
import { fitOversizedEChartsToPage } from "./echarts-page-fit.js";
import {
fitDocumentImagesToPage,
prepareDocumentImageBlocks
} from "./document-image-fit.js";
import {
assignPagedSourceReferences,
createIncrementalPaginationPlan,
type IncrementalPaginationPlan
} from "./incremental-pagination.js";
import {
applyMediaBackfillCandidates,
findMediaBackfillCandidates,
getMediaBackfillIdsInDocumentOrder,
prepareMediaBackfillBlocks
} from "./media-page-backfill.js";
import { createMermaidSiteConfig } from "./mermaid-config.js";
import {
fitOversizedMermaidToPage,
getPageContentDimensions
} from "./mermaid-page-fit.js";
import { renderMermaidDefinitions } from "./mermaid-renderer.js";
import { replaceMermaidSvgWithImages } from "./mermaid-static-image.js";
import type { MermaidOutputMode } from "./mermaid-static-image.js";
import { enablePrintMediaForPreview } from "./preview-styles.js";
import {
forcePagedTableRowsToNextPage,
missingPagedTableRowIds,
stabilizePagedTableColumns
} from "./paged-table-handler.js";
import {
buildPagedMediaCss,
continuousDocumentGeometryCss,
documentBaseCss,
documentGeometryCss,
documentInteractionCss,
type PagedPreviewPayload
} from "./paged-preview.js";
import { constrainSemanticCoversToPage } from "./semantic-cover-fit.js";
import type { PagedRenderTarget } from "./paged-render-target.js";
import { preparePdfDocumentLinks } from "./pdf-document-links.js";
import { classifyPagedPages } from "./paged-page-sequence.js";
import { applyPagedPageDecorations } from "./paged-page-decorations.js";
export type {
PagedDocumentRenderResult,
PagedDocumentTimings
} from "@md-to-pdf/core";
export interface PagedDocumentRenderOptions {
target: PagedRenderTarget;
shouldContinue?: () => boolean;
mermaidOutput?: MermaidOutputMode;
allowIncompleteTableGeometry?: boolean;
}
export interface ContinuousDocumentRenderOptions {
shouldContinue?: () => boolean;
geometryCss?: string;
themeMedia?: "preview" | "print";
}
export interface PreviewEngineStyles {
highlightCss: string;
katexCss: string;
echartsCss: string;
}
export function protectFittableInlineCodeParagraphs(
root: ParentNode,
pageContentHeightPx: number
) {
if (!Number.isFinite(pageContentHeightPx) || pageContentHeightPx <= 0) {
return 0;
}
let protectedCount = 0;
for (const paragraph of root.querySelectorAll<HTMLElement>(
"#write > p.md-inline-code-paragraph"
)) {
const height = paragraph.getBoundingClientRect().height;
if (!(height > 0 && height <= pageContentHeightPx)) {
continue;
}
paragraph.style.setProperty("break-inside", "avoid", "important");
paragraph.style.setProperty(
"page-break-inside",
"avoid",
"important"
);
paragraph.dataset.mdtpInlineCodeKeepWhole = "true";
protectedCount += 1;
}
return protectedCount;
}
export const DEFAULT_IMAGE_READY_TIMEOUT_MS = 10_000;
function waitForImage(
image: HTMLImageElement,
timeoutMs: number
) {
if (image.complete) {
return Promise.resolve();
}
return new Promise<void>((resolve) => {
let settled = false;
const finish = () => {
if (settled) {
return;
}
settled = true;
clearTimeout(timeout);
image.removeEventListener("load", finish);
image.removeEventListener("error", finish);
resolve();
};
const timeout = window.setTimeout(finish, timeoutMs);
// 先监听事件,再检查 complete 和调用 decode,避免 Electron 中图片
// 已经失败后才注册 error 监听器而永久等待。
image.addEventListener("load", finish, { once: true });
image.addEventListener("error", finish, { once: true });
if (image.complete) {
finish();
return;
}
void image.decode().then(finish, () => {
// decode 失败时仍允许尚未到达的 load/error 事件完成等待;若事件
// 已经发生,complete 会立即结束。最终由有界超时兜底。
if (image.complete) {
finish();
}
});
});
}
export function waitForImages(
container: ParentNode,
timeoutMs = DEFAULT_IMAGE_READY_TIMEOUT_MS
) {
const images = Array.from(container.querySelectorAll("img"));
return Promise.all(images.map((image) => waitForImage(image, timeoutMs)));
}
function stylesheet(
documentRef: Document,
css: string,
name: string
) {
return {
[`${documentRef.location.href}#${name}`]: css
};
}
function mountMeasurementContainer(
documentRef: Document,
content: DocumentFragment,
payload: PagedPreviewPayload,
target: PagedRenderTarget,
styles: PreviewEngineStyles
) {
const pageContent = getPageContentDimensions(payload.exportConfig);
const style = documentRef.createElement("style");
const themeCss =
target === "preview"
? enablePrintMediaForPreview(payload.themeCss)
: payload.themeCss;
style.dataset.pagedMeasurementStyle = "true";
style.textContent = [
documentBaseCss,
styles.highlightCss,
styles.katexCss,
styles.echartsCss,
themeCss,
documentInteractionCss,
documentGeometryCss
].join("\n");
const host = documentRef.createElement("div");
host.dataset.pagedMeasurementHost = "true";
host.style.cssText = [
"position: fixed",
"left: -100000px",
"top: 0",
`width: ${pageContent.width}px`,
"height: auto",
"opacity: 0",
"pointer-events: none",
"contain: layout style paint"
].join(";");
documentRef.head.append(style);
host.append(content);
documentRef.body.append(host);
return {
host,
restore() {
content.append(...Array.from(host.childNodes));
host.remove();
style.remove();
}
};
}
function removeTrailingPdfPageBreak(container: ParentNode) {
const pages = Array.from(
container.querySelectorAll<HTMLElement>(".pagedjs_page")
);
pages
.at(-1)
?.style.setProperty("break-after", "auto", "important");
}
const PAGED_SPLIT_BLOCK_SELECTOR = [
"h1", "h2", "h3", "h4", "h5", "h6", "p", "ul", "ol", "li",
"blockquote", "table", "thead", "tbody", "tfoot", "tr", "th", "td",
"figure", "figcaption", "pre", "section", "article", "div"
].join(",");
export function removeInheritedPagedSplitJustification(
container: ParentNode
) {
let count = 0;
for (const element of Array.from(
container.querySelectorAll<HTMLElement>(
'[data-align-last-split-element="justify"]'
)
)) {
if (
!Array.from(element.children).some((child) =>
child.matches(PAGED_SPLIT_BLOCK_SELECTOR)
)
) {
continue;
}
element.removeAttribute("data-align-last-split-element");
count += 1;
}
return count;
}
function createPagedRenderIdentity(
payload: PagedPreviewPayload,
options: PagedDocumentRenderOptions
) {
return JSON.stringify({
target: options.target,
mermaidOutput: options.mermaidOutput,
themeCss: payload.themeCss,
exportConfig: payload.exportConfig,
semanticDocument: payload.semanticDocument,
metadata: payload.metadata,
features: payload.features
});
}
function getArticle(container: ParentNode) {
return (
container.querySelector<HTMLElement>("#write") ?? undefined
);
}
function replacePreparedPrefix(
nextArticle: HTMLElement,
previousArticle: HTMLElement,
prefixLength: number
) {
const nextNodes = Array.from(nextArticle.childNodes);
const previousNodes = Array.from(previousArticle.childNodes);
for (let index = 0; index < prefixLength; index += 1) {
const nextNode = nextNodes[index];
const previousNode = previousNodes[index];
if (!nextNode || !previousNode) {
break;
}
nextNode.replaceWith(previousNode.cloneNode(true));
}
}
function collectRenderErrors(
container: ParentNode,
selector: string
) {
return Array.from(
container.querySelectorAll<HTMLElement>(selector)
).map(
(element, index) =>
`图表 ${index + 1}${
element.textContent?.trim() || "未知错误"
}`
);
}
export class PagedDocumentRuntime {
private activePreviewer: Previewer | undefined;
private pagedIdentity = "";
private pagedLastResult:
| PagedDocumentRenderResult
| undefined;
private pagedReferenceSequence = 0;
private pagedSignatures: string[] = [];
private mermaidRenderSequence = 0;
private mermaidPromise:
| Promise<(typeof import("mermaid"))["default"]>
| undefined;
private echartsPromise:
| Promise<
typeof import("@md-to-pdf/markdown-echarts/browser")
>
| undefined;
private continuousSignatures: string[] = [];
private continuousIdentity = "";
private continuousStyle: HTMLStyleElement | undefined;
constructor(
private readonly root: HTMLElement,
private readonly styles: PreviewEngineStyles
) {}
private disposeActivePreviewer() {
if (!this.activePreviewer) {
return;
}
this.activePreviewer.chunker.destroy();
this.activePreviewer.polisher.destroy();
this.activePreviewer = undefined;
}
private resetPagedState() {
this.pagedIdentity = "";
this.pagedLastResult = undefined;
this.pagedSignatures = [];
}
destroy() {
this.disposeActivePreviewer();
this.resetPagedState();
}
private createPagedReference() {
this.pagedReferenceSequence += 1;
return `md-paged-${this.pagedReferenceSequence}`;
}
private async continuePagination(
previewer: Previewer,
plan: Pick<
IncrementalPaginationPlan,
"invalidationPageIndex" | "startToken"
>
) {
const chunker = previewer.chunker;
chunker.rendered = false;
chunker.removePages(plan.invalidationPageIndex);
await chunker.loadFonts();
const rendered = await chunker.render(
chunker.source,
plan.startToken as PagedBreakToken | undefined
);
if (rendered.canceled) {
throw new Error("Paged.js 增量分页被中断");
}
chunker.rendered = true;
chunker.pagesArea.style.setProperty(
"--pagedjs-page-count",
String(chunker.total)
);
await chunker.hooks.afterRendered.trigger(
chunker.pages,
chunker
);
return chunker.total;
}
private async continueMediaBackfill(
previewer: Previewer,
shouldContinue: () => boolean
) {
const source = previewer.chunker.source;
const mediaIds = getMediaBackfillIdsInDocumentOrder(source);
let candidates = new Map(
findMediaBackfillCandidates(this.root).map((candidate) => [
candidate.id,
candidate
])
);
for (const mediaId of mediaIds) {
if (!shouldContinue()) {
break;
}
const candidate = candidates.get(mediaId);
const sourceBlock = Array.from(
source.querySelectorAll<HTMLElement>(
"[data-media-backfill-id]"
)
).find(
(element) =>
element.dataset.mediaBackfillId === mediaId
);
if (
!candidate ||
!sourceBlock ||
sourceBlock.dataset.mediaBackfilled === "true"
) {
continue;
}
const pageIndex = previewer.chunker.pages.findIndex(
(page) =>
Array.from(
page.element.querySelectorAll<HTMLElement>(
"[data-media-backfill-id]"
)
).some(
(element) =>
element.dataset.mediaBackfillId === mediaId
)
);
if (
pageIndex < 0 ||
applyMediaBackfillCandidates(source, [candidate]) === 0
) {
continue;
}
const invalidationPageIndex = Math.max(0, pageIndex - 1);
await this.continuePagination(previewer, {
invalidationPageIndex,
startToken:
previewer.chunker.pages[invalidationPageIndex]
?.startToken
});
candidates = new Map(
findMediaBackfillCandidates(this.root).map(
(nextCandidate) => [
nextCandidate.id,
nextCandidate
]
)
);
}
return previewer.chunker.total;
}
private resetContinuousState() {
this.continuousSignatures = [];
this.continuousIdentity = "";
this.continuousStyle?.remove();
this.continuousStyle = undefined;
}
private applyContinuousStyles(
documentRef: Document,
payload: PagedPreviewPayload,
geometryCss = "",
themeMedia: ContinuousDocumentRenderOptions["themeMedia"] = "preview"
) {
const style =
this.continuousStyle ?? documentRef.createElement("style");
style.dataset.continuousPreviewStyle = "true";
style.textContent = [
documentBaseCss,
this.styles.highlightCss,
this.styles.katexCss,
this.styles.echartsCss,
themeMedia === "preview"
? enablePrintMediaForPreview(payload.themeCss)
: payload.themeCss,
documentInteractionCss,
continuousDocumentGeometryCss,
geometryCss
].join("\n");
if (!style.isConnected) {
documentRef.head.append(style);
}
this.continuousStyle = style;
}
private loadMermaid() {
if (!this.mermaidPromise) {
this.mermaidPromise = Promise.all([
import("mermaid"),
import("@mermaid-js/layout-elk")
]).then(
([{ default: mermaid }, { default: elkLayouts }]) => {
mermaid.registerLayoutLoaders(elkLayouts);
return mermaid;
}
);
}
return this.mermaidPromise;
}
private loadECharts() {
if (!this.echartsPromise) {
this.echartsPromise = import(
"@md-to-pdf/markdown-echarts/browser"
);
}
return this.echartsPromise;
}
private async renderECharts(
container: ParentNode,
payload: PagedPreviewPayload,
concurrency = 2
) {
if (!payload.features.includes("echarts")) {
return [];
}
const { renderEChartsBlocks } = await this.loadECharts();
const outcomes = await renderEChartsBlocks(container, {
outputMode: "inline-svg",
concurrency,
timeoutMs: 5_000
});
return outcomes.flatMap((outcome, index) =>
outcome.success
? []
: [
`图表 ${index + 1}${
outcome.error?.message ?? "未知错误"
}`
]
);
}
private async replaceEChartsSvgWithImages(container: ParentNode) {
const { freezeEChartsSvg } = await this.loadECharts();
const figures = Array.from(
container.querySelectorAll<HTMLElement>(
".md-echarts:not(.md-echarts-error)"
)
);
for (const figure of figures) {
const host = figure.querySelector<HTMLElement>(
".md-echarts-host"
);
const svg = host?.querySelector<SVGSVGElement>("svg");
if (!host || !svg) {
continue;
}
const label =
host.getAttribute("aria-label") || "ECharts 图表";
host.replaceChildren(
freezeEChartsSvg(host, svg, "svg-image", label)
);
figure.dataset.echartsRendered = "svg-image";
}
await waitForImages(container);
}
private async renderMermaid(
container: DocumentFragment,
payload: PagedPreviewPayload
) {
if (!payload.features.includes("mermaid")) {
return [];
}
const nodes = Array.from(
container.querySelectorAll<HTMLElement>(
".mermaid[data-mermaid-pending]"
)
);
if (nodes.length === 0) {
return [];
}
const mermaid = await this.loadMermaid();
mermaid.initialize(
createMermaidSiteConfig(payload.exportConfig.mermaid)
);
const outcomes = await renderMermaidDefinitions(
nodes.map((node) => node.textContent ?? ""),
async (definition) => {
this.mermaidRenderSequence += 1;
return mermaid.render(
`mermaid-paged-document-${this.mermaidRenderSequence}`,
definition
);
}
);
const errors: string[] = [];
for (const [index, outcome] of outcomes.entries()) {
const node = nodes[index];
if (!node) {
continue;
}
node.removeAttribute("data-mermaid-pending");
if (outcome.success) {
node.innerHTML = outcome.svg;
outcome.bindFunctions?.(node);
continue;
}
const message =
outcome.error instanceof Error
? outcome.error.message
: "未知错误";
node.classList.add("mermaid-error");
node.textContent =
`Mermaid 图表 ${index + 1} 渲染失败:${message}`;
errors.push(`图表 ${index + 1}${message}`);
}
return errors;
}
async render(
payload: PagedPreviewPayload,
options: PagedDocumentRenderOptions
): Promise<PagedDocumentRenderResult | undefined> {
const totalStartedAt = performance.now();
const shouldContinue = options.shouldContinue ?? (() => true);
if (!shouldContinue()) {
return undefined;
}
const setupStartedAt = performance.now();
const documentRef = this.root.ownerDocument;
this.resetContinuousState();
documentRef.documentElement.dataset.renderTarget = options.target;
documentRef.documentElement.dataset.previewLayout = "paged";
documentRef.documentElement.lang =
payload.metadata.language || "zh-CN";
documentRef.title =
payload.metadata.title || "Markdown 分页文档";
const template = documentRef.createElement("template");
template.innerHTML = payload.articleHtml;
let content = template.content;
const nextArticle = getArticle(content);
if (!nextArticle) {
throw new Error("分页预览缺少 #write 文档容器");
}
if (options.target === "pdf") {
preparePdfDocumentLinks(nextArticle);
}
const nextSignatures = Array.from(
nextArticle.childNodes
).map(createNodeSignature);
const pagedIdentity = createPagedRenderIdentity(
payload,
options
);
const cachedSource = this.activePreviewer?.chunker.source;
const cachedArticle = cachedSource
? getArticle(cachedSource)
: undefined;
const prefixLength =
options.target === "preview" &&
cachedArticle &&
pagedIdentity === this.pagedIdentity
? findCommonPrefixLength(
this.pagedSignatures,
nextSignatures
)
: 0;
if (
this.pagedLastResult &&
prefixLength === this.pagedSignatures.length &&
prefixLength === nextSignatures.length
) {
return this.pagedLastResult;
}
const incrementalPlan =
cachedArticle && this.activePreviewer
? createIncrementalPaginationPlan(
prefixLength,
this.pagedSignatures.length,
Array.from(cachedArticle.childNodes),
this.activePreviewer.chunker.pages
)
: undefined;
const incrementalPreviewer = incrementalPlan
? this.activePreviewer
: undefined;
if (incrementalPlan && cachedArticle) {
replacePreparedPrefix(
nextArticle,
cachedArticle,
incrementalPlan.prefixLength
);
} else {
this.disposeActivePreviewer();
this.resetPagedState();
this.root.replaceChildren();
}
prepareCodeBlockPagination(nextArticle);
const setupMs = performance.now() - setupStartedAt;
const mermaidStartedAt = performance.now();
let mermaidErrors = await this.renderMermaid(content, payload);
const mermaidMs = performance.now() - mermaidStartedAt;
let echartsErrors: string[] = [];
let echartsMs = 0;
let echartsFitMs = 0;
let mermaidFitMs = 0;
let mermaidConversionMs = 0;
if (
payload.features.includes("echarts") ||
content.querySelector(".mermaid svg") ||
content.querySelector("img.md-document-image") ||
content.querySelector("table") ||
content.querySelector('[data-semantic-region="cover"]')
) {
const measurement = mountMeasurementContainer(
documentRef,
content,
payload,
options.target,
this.styles
);
try {
await documentRef.fonts.ready;
protectFittableInlineCodeParagraphs(
measurement.host,
getPageContentDimensions(payload.exportConfig).height
);
stabilizePagedTableColumns(
measurement.host,
getPageContentDimensions(payload.exportConfig).height
);
constrainSemanticCoversToPage(
measurement.host,
getPageContentDimensions(payload.exportConfig).height
);
const echartsStartedAt = performance.now();
echartsErrors = await this.renderECharts(
measurement.host,
payload
);
echartsMs = performance.now() - echartsStartedAt;
await waitForImages(measurement.host);
prepareDocumentImageBlocks(measurement.host);
prepareMediaBackfillBlocks(measurement.host);
fitDocumentImagesToPage(
measurement.host,
payload.exportConfig,
{ prepareBlocks: false }
);
const echartsFitStartedAt = performance.now();
fitOversizedEChartsToPage(
measurement.host,
payload.exportConfig
);
await this.replaceEChartsSvgWithImages(measurement.host);
echartsFitMs =
performance.now() - echartsFitStartedAt;
const mermaidFitStartedAt = performance.now();
fitOversizedMermaidToPage(
measurement.host,
payload.exportConfig
);
mermaidFitMs = performance.now() - mermaidFitStartedAt;
const mermaidConversionStartedAt = performance.now();
if (options.mermaidOutput === "svg-image") {
replaceMermaidSvgWithImages(measurement.host);
await waitForImages(measurement.host);
}
mermaidConversionMs =
performance.now() - mermaidConversionStartedAt;
} finally {
measurement.restore();
}
}
if (incrementalPlan) {
mermaidErrors = collectRenderErrors(
content,
".mermaid-error"
);
echartsErrors = collectRenderErrors(
content,
".md-echarts-error"
);
}
if (!shouldContinue()) {
return undefined;
}
const resourceWaitStartedAt = performance.now();
await documentRef.fonts.ready;
await waitForImages(content);
const resourceWaitMs = performance.now() - resourceWaitStartedAt;
const paginationStartedAt = performance.now();
const stylesheets = [
stylesheet(documentRef, documentBaseCss, "document-base"),
stylesheet(
documentRef,
this.styles.highlightCss,
"highlight"
),
stylesheet(documentRef, this.styles.katexCss, "katex"),
stylesheet(documentRef, this.styles.echartsCss, "echarts"),
stylesheet(documentRef, payload.themeCss, "theme"),
stylesheet(
documentRef,
documentInteractionCss,
"document-interaction"
),
stylesheet(
documentRef,
documentGeometryCss,
"document-geometry"
),
stylesheet(
documentRef,
buildPagedMediaCss(payload.exportConfig, payload),
"paged-media"
)
];
const paginateFully = async (sourceContent: DocumentFragment) => {
const repaginationSource = sourceContent.cloneNode(
true
) as DocumentFragment;
let previewer = new Previewer();
this.activePreviewer = previewer;
let flow = await previewer.preview(
sourceContent,
stylesheets,
this.root
);
const maximumTableRowRecoveryAttempts = 3;
for (
let attempt = 0;
attempt < maximumTableRowRecoveryAttempts;
attempt += 1
) {
const missingRowIds = missingPagedTableRowIds(
repaginationSource,
this.root
);
if (missingRowIds.length === 0) {
break;
}
const recoveredCount = forcePagedTableRowsToNextPage(
repaginationSource,
missingRowIds
);
if (recoveredCount !== missingRowIds.length) {
throw new Error(
`分页表格行恢复标记不完整:缺失 ${missingRowIds.length} 行,` +
`仅定位 ${recoveredCount} 行`
);
}
previewer.chunker.destroy();
previewer.polisher.destroy();
previewer = new Previewer();
this.activePreviewer = previewer;
flow = await previewer.preview(
repaginationSource.cloneNode(true) as DocumentFragment,
stylesheets,
this.root
);
}
const unresolvedTableRowIds = missingPagedTableRowIds(
repaginationSource,
this.root
);
if (
unresolvedTableRowIds.length > 0 &&
!options.allowIncompleteTableGeometry
) {
throw new Error(
`分页后表格正文行缺失:${unresolvedTableRowIds.join(", ")}`
);
}
const mediaIds = getMediaBackfillIdsInDocumentOrder(
repaginationSource
);
let mediaCandidates = new Map(
findMediaBackfillCandidates(this.root).map(
(candidate) => [candidate.id, candidate]
)
);
for (const mediaId of mediaIds) {
if (!shouldContinue()) {
break;
}
const candidate = mediaCandidates.get(mediaId);
if (
!candidate ||
applyMediaBackfillCandidates(repaginationSource, [
candidate
]) === 0
) {
continue;
}
previewer.chunker.destroy();
previewer.polisher.destroy();
previewer = new Previewer();
this.activePreviewer = previewer;
flow = await previewer.preview(
repaginationSource.cloneNode(
true
) as DocumentFragment,
stylesheets,
this.root
);
mediaCandidates = new Map(
findMediaBackfillCandidates(this.root).map(
(nextCandidate) => [
nextCandidate.id,
nextCandidate
]
)
);
}
return flow.total;
};
let pageCount: number;
if (incrementalPlan && incrementalPreviewer) {
const fallbackContent = content.cloneNode(
true
) as DocumentFragment;
try {
const source = incrementalPreviewer.chunker.source;
const sourceArticle = getArticle(source);
const preparedArticle = getArticle(content);
if (!sourceArticle || !preparedArticle) {
throw new Error("分页缓存缺少 #write 文档容器");
}
const sourceNodes = Array.from(
sourceArticle.childNodes
);
for (
let index = sourceNodes.length - 1;
index >= incrementalPlan.prefixLength;
index -= 1
) {
sourceNodes[index]?.remove();
}
const preparedNodes = Array.from(
preparedArticle.childNodes
);
for (const node of preparedNodes.slice(
incrementalPlan.prefixLength
)) {
assignPagedSourceReferences(
node,
() => this.createPagedReference()
);
sourceArticle.append(node);
}
await incrementalPreviewer.chunker.hooks.afterParsed.trigger(
source,
incrementalPreviewer.chunker
);
pageCount = await this.continuePagination(
incrementalPreviewer,
incrementalPlan
);
pageCount = await this.continueMediaBackfill(
incrementalPreviewer,
shouldContinue
);
} catch {
this.disposeActivePreviewer();
this.resetPagedState();
this.root.replaceChildren();
content = fallbackContent;
pageCount = await paginateFully(content);
}
} else {
pageCount = await paginateFully(content);
}
const paginationMs = performance.now() - paginationStartedAt;
if (!shouldContinue()) {
this.disposeActivePreviewer();
this.resetPagedState();
this.root.replaceChildren();
return undefined;
}
const finalizeStartedAt = performance.now();
removeInheritedPagedSplitJustification(this.root);
const pageSequence = classifyPagedPages(this.root, payload);
pageCount = pageSequence.physicalPageCount;
applyPagedPageDecorations(this.root, payload, pageSequence);
if (options.target === "pdf") {
removeTrailingPdfPageBreak(this.root);
}
const finalizeMs = performance.now() - finalizeStartedAt;
const result: PagedDocumentRenderResult = {
pageCount,
echartsErrors,
mermaidErrors,
timings: {
setupMs,
echartsMs,
echartsFitMs,
mermaidMs,
mermaidFitMs,
mermaidConversionMs,
resourceWaitMs,
paginationMs,
finalizeMs,
totalMs: performance.now() - totalStartedAt
}
};
if (options.target === "preview") {
this.pagedIdentity = pagedIdentity;
this.pagedSignatures = nextSignatures;
this.pagedLastResult = result;
} else {
this.resetPagedState();
}
return result;
}
async renderContinuous(
payload: PagedPreviewPayload,
options: ContinuousDocumentRenderOptions = {}
): Promise<PagedDocumentRenderResult | undefined> {
const totalStartedAt = performance.now();
const shouldContinue = options.shouldContinue ?? (() => true);
if (!shouldContinue()) {
return undefined;
}
const setupStartedAt = performance.now();
const documentRef = this.root.ownerDocument;
this.destroy();
documentRef.documentElement.dataset.renderTarget = "preview";
documentRef.documentElement.dataset.previewLayout = "continuous";
documentRef.documentElement.lang =
payload.metadata.language || "zh-CN";
documentRef.title =
payload.metadata.title || "Markdown 连续预览";
this.applyContinuousStyles(
documentRef,
payload,
options.geometryCss,
options.themeMedia
);
const template = documentRef.createElement("template");
template.innerHTML = payload.articleHtml;
const nextArticle =
template.content.querySelector<HTMLElement>("#write");
if (!nextArticle) {
throw new Error("连续预览缺少 #write 文档容器");
}
const nextNodes = Array.from(nextArticle.childNodes);
const nextSignatures = nextNodes.map(createNodeSignature);
const identity = JSON.stringify({
themeCss: payload.themeCss,
mermaid: payload.exportConfig.mermaid,
geometryCss: options.geometryCss,
themeMedia: options.themeMedia ?? "preview"
});
const currentArticle =
this.root.querySelector<HTMLElement>(":scope > #write");
const prefixLength =
currentArticle && identity === this.continuousIdentity
? findCommonPrefixLength(
this.continuousSignatures,
nextSignatures
)
: 0;
const suffix = documentRef.createDocumentFragment();
for (const node of nextNodes.slice(prefixLength)) {
suffix.append(node);
}
const setupMs = performance.now() - setupStartedAt;
const mermaidStartedAt = performance.now();
const mermaidErrors = await this.renderMermaid(suffix, payload);
const mermaidMs = performance.now() - mermaidStartedAt;
const resourceWaitStartedAt = performance.now();
await documentRef.fonts.ready;
const stage = mountContinuousRenderStage(
this.root,
nextArticle,
suffix
);
let renderedSuffix: DocumentFragment;
let echartsErrors: string[];
let echartsMs: number;
let resourceWaitMs: number;
try {
stage.article.getBoundingClientRect();
const echartsStartedAt = performance.now();
echartsErrors = await this.renderECharts(
stage.article,
payload,
1
);
echartsMs = performance.now() - echartsStartedAt;
await waitForImages(stage.article);
resourceWaitMs =
performance.now() - resourceWaitStartedAt;
if (!shouldContinue()) {
return undefined;
}
renderedSuffix = stage.takeContent();
} finally {
stage.dispose();
}
const finalizeStartedAt = performance.now();
let article = currentArticle;
if (!article || prefixLength === 0) {
article = nextArticle.cloneNode(false) as HTMLElement;
this.root.replaceChildren(article);
} else {
for (
let index = article.childNodes.length - 1;
index >= prefixLength;
index -= 1
) {
article.childNodes[index]?.remove();
}
}
article.append(renderedSuffix);
this.continuousSignatures = nextSignatures;
this.continuousIdentity = identity;
const finalizeMs = performance.now() - finalizeStartedAt;
return {
pageCount: 0,
echartsErrors,
mermaidErrors,
timings: {
setupMs,
echartsMs,
echartsFitMs: 0,
mermaidMs,
mermaidFitMs: 0,
mermaidConversionMs: 0,
resourceWaitMs,
paginationMs: 0,
finalizeMs,
totalMs: performance.now() - totalStartedAt
}
};
}
}
declare global {
interface Window {
__mdToPdfRender?: (
payload: PagedPreviewPayload,
target?: PagedRenderTarget
) => Promise<PagedDocumentRenderResult>;
}
}