release: 发布 v0.5.0
新增共享 Preview Engine,统一 Web 连续预览、快速分页、Playwright PDF 与 Electron PDF;实现稳定前缀复用和修改位置后的增量分页,保留媒体块按文档顺序串行回填与单次重排。 完善跨端链接与桌面文档工作流:Web 受控处理锚点和 HTTP/HTTPS 外链;Desktop 支持本地路径、file URI、系统协议、多窗口、同文件单例、Markdown 当前或新窗口打开,以及聚焦时外部文件变化提示。 统一四套内置主题名称并默认使用 Typora Github;修复连续预览双滚动条、ECharts 尺寸、PDF 本地链接、围栏代码块 Typora DOM 与重复行内样式;桌面发行链强制完整重建内嵌 Web,避免安装包携带陈旧资源。 发布 Web/Compose 与 Windows NSIS/ZIP:镜像 yixiong/md-to-pdf:v0.5.0 已健康部署;NSIS SHA-256 为 60992D1FDCA513F46346C78478537EB4159D8C0E76B41ECF3CDC25BE77707D92,ZIP SHA-256 为 D74F82293FB67126E583546CBA894569EFC9A0B1B6343FAC648CCC95F1D188D8,本机安装版已升级至 v0.5.0。 验证:全项目 238 项测试通过,类型检查、生产构建和 git diff --check 通过;Web 快速/连续/精确预览、Compose、Desktop 多窗口、窗口状态、文件关联、链接与代码块均完成真实环境验收。
This commit is contained in:
@@ -0,0 +1,41 @@
|
||||
export function findCommonPrefixLength(
|
||||
previous: readonly string[],
|
||||
next: readonly string[]
|
||||
) {
|
||||
const maximum = Math.min(previous.length, next.length);
|
||||
let index = 0;
|
||||
while (index < maximum && previous[index] === next[index]) {
|
||||
index += 1;
|
||||
}
|
||||
return index;
|
||||
}
|
||||
|
||||
export function createNodeSignature(node: Node) {
|
||||
if (node instanceof Element) {
|
||||
return `element:${node.outerHTML}`;
|
||||
}
|
||||
return `node:${node.nodeType}:${node.textContent ?? ""}`;
|
||||
}
|
||||
|
||||
export function mountContinuousRenderStage(
|
||||
root: HTMLElement,
|
||||
articleTemplate: HTMLElement,
|
||||
content: DocumentFragment
|
||||
) {
|
||||
const article = articleTemplate.cloneNode(false) as HTMLElement;
|
||||
article.dataset.continuousRenderStage = "true";
|
||||
article.append(content);
|
||||
root.append(article);
|
||||
|
||||
return {
|
||||
article,
|
||||
takeContent() {
|
||||
const rendered = article.ownerDocument.createDocumentFragment();
|
||||
rendered.append(...Array.from(article.childNodes));
|
||||
return rendered;
|
||||
},
|
||||
dispose() {
|
||||
article.remove();
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
import {
|
||||
getPaperDimensionsMm,
|
||||
lengthToMillimeters,
|
||||
millimetersToCssPixels,
|
||||
type ExportConfig
|
||||
} from "@md-to-pdf/core";
|
||||
|
||||
export interface DiagramDimensions {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface DiagramPageFit extends DiagramDimensions {
|
||||
scaled: boolean;
|
||||
}
|
||||
|
||||
export interface SvgDiagramFitOptions {
|
||||
containerSelector: string;
|
||||
svgSelector?: string;
|
||||
resolvePageContent?: (
|
||||
container: HTMLElement,
|
||||
pageContent: DiagramDimensions
|
||||
) => DiagramDimensions;
|
||||
onFit?: (
|
||||
container: HTMLElement,
|
||||
svg: SVGSVGElement,
|
||||
fit: DiagramPageFit
|
||||
) => void;
|
||||
}
|
||||
|
||||
const PAGE_FIT_EPSILON_PX = 1;
|
||||
|
||||
function parsePixelValue(value: string) {
|
||||
const parsed = Number.parseFloat(value);
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
}
|
||||
|
||||
export function getPrecedingMediaTitleHeight(
|
||||
element: HTMLElement
|
||||
) {
|
||||
const title = element.previousElementSibling;
|
||||
if (
|
||||
!(title instanceof HTMLElement) ||
|
||||
!/^H[1-6]$/.test(title.tagName)
|
||||
) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const style = getComputedStyle(title);
|
||||
title.style.setProperty("break-after", "avoid-page", "important");
|
||||
title.style.setProperty(
|
||||
"page-break-after",
|
||||
"avoid",
|
||||
"important"
|
||||
);
|
||||
return (
|
||||
title.getBoundingClientRect().height +
|
||||
parsePixelValue(style.marginTop) +
|
||||
parsePixelValue(style.marginBottom)
|
||||
);
|
||||
}
|
||||
|
||||
export function ensurePageBreakAfter(element: HTMLElement) {
|
||||
const existingMarker = element.nextElementSibling;
|
||||
if (
|
||||
existingMarker instanceof HTMLElement &&
|
||||
existingMarker.dataset.mediaPageBreak === "true"
|
||||
) {
|
||||
return existingMarker;
|
||||
}
|
||||
const marker = element.ownerDocument.createElement("div");
|
||||
marker.dataset.mediaPageBreak = "true";
|
||||
marker.setAttribute("aria-hidden", "true");
|
||||
marker.style.setProperty("height", "0", "important");
|
||||
marker.style.setProperty("break-before", "page", "important");
|
||||
marker.style.setProperty(
|
||||
"page-break-before",
|
||||
"always",
|
||||
"important"
|
||||
);
|
||||
element.after(marker);
|
||||
return marker;
|
||||
}
|
||||
|
||||
export function parseSvgViewBox(
|
||||
value: string | null
|
||||
): DiagramDimensions | undefined {
|
||||
if (!value) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const values = value
|
||||
.trim()
|
||||
.split(/[\s,]+/)
|
||||
.map((part) => Number(part));
|
||||
const [, , width = Number.NaN, height = Number.NaN] = values;
|
||||
|
||||
if (
|
||||
values.length !== 4 ||
|
||||
!Number.isFinite(width) ||
|
||||
!Number.isFinite(height) ||
|
||||
width <= 0 ||
|
||||
height <= 0
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return { width, height };
|
||||
}
|
||||
|
||||
function parseSvgDimensions(
|
||||
svg: SVGSVGElement
|
||||
): DiagramDimensions | undefined {
|
||||
const width = Number.parseFloat(svg.getAttribute("width") ?? "");
|
||||
const height = Number.parseFloat(
|
||||
svg.getAttribute("height") ?? ""
|
||||
);
|
||||
if (
|
||||
!Number.isFinite(width) ||
|
||||
!Number.isFinite(height) ||
|
||||
width <= 0 ||
|
||||
height <= 0
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return { width, height };
|
||||
}
|
||||
|
||||
export function getPageContentDimensions(
|
||||
config: ExportConfig
|
||||
): DiagramDimensions {
|
||||
const paper = getPaperDimensionsMm(
|
||||
config.paper.format,
|
||||
config.paper.orientation
|
||||
);
|
||||
const width =
|
||||
paper.width -
|
||||
lengthToMillimeters(config.paper.margins.left) -
|
||||
lengthToMillimeters(config.paper.margins.right);
|
||||
const height =
|
||||
paper.height -
|
||||
lengthToMillimeters(config.paper.margins.top) -
|
||||
lengthToMillimeters(config.paper.margins.bottom);
|
||||
|
||||
return {
|
||||
width: millimetersToCssPixels(width),
|
||||
height: millimetersToCssPixels(height)
|
||||
};
|
||||
}
|
||||
|
||||
export function calculateDiagramPageFit(
|
||||
intrinsic: DiagramDimensions,
|
||||
pageContent: DiagramDimensions
|
||||
): DiagramPageFit {
|
||||
const heightAfterWidthFit =
|
||||
(pageContent.width * intrinsic.height) / intrinsic.width;
|
||||
|
||||
if (heightAfterWidthFit <= pageContent.height) {
|
||||
return {
|
||||
width: pageContent.width,
|
||||
height: heightAfterWidthFit,
|
||||
scaled: false
|
||||
};
|
||||
}
|
||||
|
||||
const height = Math.max(
|
||||
0,
|
||||
pageContent.height - PAGE_FIT_EPSILON_PX
|
||||
);
|
||||
return {
|
||||
width: (height * intrinsic.width) / intrinsic.height,
|
||||
height,
|
||||
scaled: true
|
||||
};
|
||||
}
|
||||
|
||||
export function fitSvgDiagramsToPage(
|
||||
root: ParentNode,
|
||||
config: ExportConfig,
|
||||
options: SvgDiagramFitOptions
|
||||
) {
|
||||
const pageContent = getPageContentDimensions(config);
|
||||
const containers = Array.from(
|
||||
root.querySelectorAll<HTMLElement>(options.containerSelector)
|
||||
);
|
||||
|
||||
for (const container of containers) {
|
||||
const svg = container.querySelector<SVGSVGElement>(
|
||||
options.svgSelector ?? "svg"
|
||||
);
|
||||
const intrinsic = svg
|
||||
? parseSvgViewBox(svg.getAttribute("viewBox")) ??
|
||||
parseSvgDimensions(svg)
|
||||
: undefined;
|
||||
if (!svg || !intrinsic) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const resolvedPageContent =
|
||||
options.resolvePageContent?.(container, pageContent) ??
|
||||
pageContent;
|
||||
const availablePageContent = {
|
||||
width: resolvedPageContent.width,
|
||||
height: Math.max(
|
||||
0,
|
||||
resolvedPageContent.height -
|
||||
getPrecedingMediaTitleHeight(container)
|
||||
)
|
||||
};
|
||||
const fit = calculateDiagramPageFit(
|
||||
intrinsic,
|
||||
availablePageContent
|
||||
);
|
||||
if (!fit.scaled) {
|
||||
continue;
|
||||
}
|
||||
|
||||
container.dataset.pageHeightFitted = "true";
|
||||
container.style.setProperty("margin-block", "0", "important");
|
||||
container.style.setProperty("break-after", "page", "important");
|
||||
container.style.setProperty(
|
||||
"page-break-after",
|
||||
"always",
|
||||
"important"
|
||||
);
|
||||
ensurePageBreakAfter(container);
|
||||
if (!svg.getAttribute("viewBox")) {
|
||||
svg.setAttribute(
|
||||
"viewBox",
|
||||
`0 0 ${intrinsic.width} ${intrinsic.height}`
|
||||
);
|
||||
}
|
||||
svg.setAttribute("preserveAspectRatio", "xMidYMid meet");
|
||||
svg.style.setProperty("display", "block");
|
||||
svg.style.setProperty("width", `${fit.width}px`, "important");
|
||||
svg.style.setProperty("height", `${fit.height}px`, "important");
|
||||
svg.style.setProperty("max-width", "100%", "important");
|
||||
svg.style.setProperty(
|
||||
"max-height",
|
||||
`${fit.height}px`,
|
||||
"important"
|
||||
);
|
||||
options.onFit?.(container, svg, fit);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import type { ExportConfig } from "@md-to-pdf/core";
|
||||
import {
|
||||
calculateDiagramPageFit,
|
||||
ensurePageBreakAfter,
|
||||
getPageContentDimensions,
|
||||
getPrecedingMediaTitleHeight
|
||||
} from "./diagram-page-fit.js";
|
||||
|
||||
function getImageBlock(image: HTMLImageElement) {
|
||||
const parent = image.parentElement;
|
||||
if (parent?.classList.contains("md-document-image-block")) {
|
||||
return parent;
|
||||
}
|
||||
if (
|
||||
parent?.tagName === "P" &&
|
||||
Array.from(parent.childNodes).every(
|
||||
(node) =>
|
||||
node === image ||
|
||||
(node.nodeType === Node.TEXT_NODE && !node.textContent?.trim())
|
||||
)
|
||||
) {
|
||||
parent.classList.add("md-document-image-block");
|
||||
return parent;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function prepareDocumentImageBlocks(root: ParentNode) {
|
||||
const images = Array.from(
|
||||
root.querySelectorAll<HTMLImageElement>("img.md-document-image")
|
||||
);
|
||||
let prepared = 0;
|
||||
|
||||
for (const image of images) {
|
||||
if (getImageBlock(image)) {
|
||||
prepared += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return prepared;
|
||||
}
|
||||
|
||||
export function fitDocumentImagesToPage(
|
||||
root: ParentNode,
|
||||
config: ExportConfig,
|
||||
options: { prepareBlocks?: boolean } = {}
|
||||
) {
|
||||
const pageContent = getPageContentDimensions(config);
|
||||
if (options.prepareBlocks !== false) {
|
||||
prepareDocumentImageBlocks(root);
|
||||
}
|
||||
const images = Array.from(
|
||||
root.querySelectorAll<HTMLImageElement>("img.md-document-image")
|
||||
);
|
||||
|
||||
for (const image of images) {
|
||||
const block = getImageBlock(image);
|
||||
const width = image.naturalWidth;
|
||||
const height = image.naturalHeight;
|
||||
if (width <= 0 || height <= 0) {
|
||||
continue;
|
||||
}
|
||||
const imageHeight = image.getBoundingClientRect().height;
|
||||
const blockHeight = block?.getBoundingClientRect().height ?? imageHeight;
|
||||
const nonImageHeight = Math.max(0, blockHeight - imageHeight);
|
||||
const titleHeight = block
|
||||
? getPrecedingMediaTitleHeight(block)
|
||||
: 0;
|
||||
const fit = calculateDiagramPageFit(
|
||||
{ width, height },
|
||||
{
|
||||
width: pageContent.width,
|
||||
height: Math.max(
|
||||
0,
|
||||
pageContent.height - nonImageHeight - titleHeight
|
||||
)
|
||||
}
|
||||
);
|
||||
if (!fit.scaled) {
|
||||
continue;
|
||||
}
|
||||
if (block?.classList.contains("md-document-image-block")) {
|
||||
block.dataset.pageHeightFitted = "true";
|
||||
block.style.setProperty("margin-block", "0", "important");
|
||||
block.style.setProperty("break-after", "page", "important");
|
||||
block.style.setProperty(
|
||||
"page-break-after",
|
||||
"always",
|
||||
"important"
|
||||
);
|
||||
ensurePageBreakAfter(block);
|
||||
}
|
||||
image.dataset.pageHeightFitted = "true";
|
||||
image.style.setProperty("display", "block");
|
||||
image.style.setProperty("width", `${fit.width}px`, "important");
|
||||
image.style.setProperty("height", `${fit.height}px`, "important");
|
||||
image.style.setProperty("max-width", "100%", "important");
|
||||
image.style.setProperty(
|
||||
"max-height",
|
||||
`${fit.height}px`,
|
||||
"important"
|
||||
);
|
||||
image.style.setProperty("object-fit", "contain");
|
||||
image.style.setProperty("margin-inline", "auto");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { ExportConfig } from "@md-to-pdf/core";
|
||||
import { fitSvgDiagramsToPage } from "./diagram-page-fit.js";
|
||||
|
||||
export function fitOversizedEChartsToPage(
|
||||
container: ParentNode,
|
||||
config: ExportConfig
|
||||
) {
|
||||
fitSvgDiagramsToPage(container, config, {
|
||||
containerSelector: ".md-echarts",
|
||||
svgSelector: ".md-echarts-host svg",
|
||||
resolvePageContent(figure, pageContent) {
|
||||
const host = figure.querySelector<HTMLElement>(
|
||||
".md-echarts-host"
|
||||
);
|
||||
const figureHeight = figure.getBoundingClientRect().height;
|
||||
const hostHeight = host?.getBoundingClientRect().height ?? 0;
|
||||
const nonChartHeight = Math.max(
|
||||
0,
|
||||
figureHeight - hostHeight
|
||||
);
|
||||
return {
|
||||
width: pageContent.width,
|
||||
height: Math.max(0, pageContent.height - nonChartHeight)
|
||||
};
|
||||
},
|
||||
onFit(figure, _svg, fit) {
|
||||
const host = figure.querySelector<HTMLElement>(
|
||||
".md-echarts-host"
|
||||
);
|
||||
if (!host) {
|
||||
return;
|
||||
}
|
||||
host.style.setProperty(
|
||||
"width",
|
||||
`${fit.width}px`,
|
||||
"important"
|
||||
);
|
||||
host.style.setProperty(
|
||||
"height",
|
||||
`${fit.height}px`,
|
||||
"important"
|
||||
);
|
||||
host.style.setProperty("margin-inline", "auto", "important");
|
||||
host.style.removeProperty("aspect-ratio");
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
export interface PagedBreakTokenLike {
|
||||
node?: Node;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
export interface PagedPageLike {
|
||||
element: HTMLElement;
|
||||
startToken?: PagedBreakTokenLike;
|
||||
}
|
||||
|
||||
export interface IncrementalPaginationPlan {
|
||||
prefixLength: number;
|
||||
invalidationPageIndex: number;
|
||||
startToken: PagedBreakTokenLike | undefined;
|
||||
}
|
||||
|
||||
function getNodeReference(node: Node | undefined) {
|
||||
if (!node) {
|
||||
return undefined;
|
||||
}
|
||||
if (node instanceof HTMLElement && node.dataset.ref) {
|
||||
return node.dataset.ref;
|
||||
}
|
||||
return node.parentElement?.dataset.ref;
|
||||
}
|
||||
|
||||
function pageContainsReference(
|
||||
page: PagedPageLike,
|
||||
reference: string
|
||||
) {
|
||||
return Array.from(
|
||||
page.element.querySelectorAll<HTMLElement>("[data-ref]")
|
||||
).some((element) => element.dataset.ref === reference);
|
||||
}
|
||||
|
||||
export function findSourceNodePageIndex(
|
||||
pages: readonly PagedPageLike[],
|
||||
sourceNode: Node | undefined
|
||||
) {
|
||||
const reference = getNodeReference(sourceNode);
|
||||
if (!reference) {
|
||||
return undefined;
|
||||
}
|
||||
const pageIndex = pages.findIndex((page) =>
|
||||
pageContainsReference(page, reference)
|
||||
);
|
||||
return pageIndex >= 0 ? pageIndex : undefined;
|
||||
}
|
||||
|
||||
export function createIncrementalPaginationPlan(
|
||||
prefixLength: number,
|
||||
previousNodeCount: number,
|
||||
sourceNodes: readonly Node[],
|
||||
pages: readonly PagedPageLike[]
|
||||
): IncrementalPaginationPlan | undefined {
|
||||
if (
|
||||
prefixLength <= 0 ||
|
||||
pages.length === 0 ||
|
||||
prefixLength > sourceNodes.length ||
|
||||
prefixLength > previousNodeCount
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const boundaryNode =
|
||||
sourceNodes[prefixLength] ??
|
||||
sourceNodes[prefixLength - 1];
|
||||
const boundaryPageIndex =
|
||||
findSourceNodePageIndex(pages, boundaryNode);
|
||||
if (boundaryPageIndex === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// 媒体块允许回填到上一页,因此变化边界必须额外向前回退一页。
|
||||
const invalidationPageIndex = Math.max(
|
||||
0,
|
||||
boundaryPageIndex - 1
|
||||
);
|
||||
return {
|
||||
prefixLength,
|
||||
invalidationPageIndex,
|
||||
startToken: pages[invalidationPageIndex]?.startToken
|
||||
};
|
||||
}
|
||||
|
||||
export function assignPagedSourceReferences(
|
||||
root: Node,
|
||||
createReference: () => string
|
||||
) {
|
||||
const elements: Element[] = [];
|
||||
if (root instanceof Element) {
|
||||
elements.push(root);
|
||||
}
|
||||
const queryRoot = root as Node & {
|
||||
querySelectorAll?: ParentNode["querySelectorAll"];
|
||||
};
|
||||
if (typeof queryRoot.querySelectorAll === "function") {
|
||||
elements.push(...Array.from(queryRoot.querySelectorAll("*")));
|
||||
}
|
||||
|
||||
for (const element of elements) {
|
||||
if (!element.hasAttribute("data-ref")) {
|
||||
element.setAttribute("data-ref", createReference());
|
||||
}
|
||||
if (element.id) {
|
||||
element.setAttribute("data-id", element.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
export * from "./diagram-page-fit.js";
|
||||
export * from "./continuous-preview.js";
|
||||
export * from "./document-image-fit.js";
|
||||
export * from "./echarts-page-fit.js";
|
||||
export * from "./incremental-pagination.js";
|
||||
export * from "./media-page-backfill.js";
|
||||
export * from "./mermaid-config.js";
|
||||
export * from "./mermaid-page-fit.js";
|
||||
export * from "./mermaid-renderer.js";
|
||||
export * from "./mermaid-static-image.js";
|
||||
export * from "./paged-break-token.js";
|
||||
export * from "./paged-document-runtime.js";
|
||||
export * from "./paged-preview.js";
|
||||
export * from "./paged-render-target.js";
|
||||
export * from "./pdf-document-links.js";
|
||||
export * from "./preview-styles.js";
|
||||
@@ -0,0 +1,397 @@
|
||||
export type MediaBackfillKind = "image" | "mermaid" | "echarts";
|
||||
|
||||
export interface MediaBackfillGeometry {
|
||||
remainingHeight: number;
|
||||
fixedHeight: number;
|
||||
baselineVisualHeight: number;
|
||||
}
|
||||
|
||||
export interface MediaBackfillCandidate {
|
||||
id: string;
|
||||
scale: number;
|
||||
}
|
||||
|
||||
export interface MediaBackfillOptions {
|
||||
minimumScale?: number;
|
||||
safetyGapPx?: number;
|
||||
}
|
||||
|
||||
const MEDIA_BLOCK_SELECTOR = [
|
||||
".md-document-image-block",
|
||||
".mermaid:not(.mermaid-error)",
|
||||
".md-echarts:not(.md-echarts-error)"
|
||||
].join(", ");
|
||||
const DEFAULT_MINIMUM_SCALE = 0.88;
|
||||
const DEFAULT_SAFETY_GAP_PX = 2;
|
||||
const SCALE_EPSILON = 0.001;
|
||||
|
||||
function finitePositive(value: string | undefined) {
|
||||
const parsed = Number.parseFloat(value ?? "");
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined;
|
||||
}
|
||||
|
||||
function getMediaKind(block: Element): MediaBackfillKind | undefined {
|
||||
if (block.classList.contains("md-document-image-block")) {
|
||||
return "image";
|
||||
}
|
||||
if (block.classList.contains("mermaid")) {
|
||||
return "mermaid";
|
||||
}
|
||||
if (block.classList.contains("md-echarts")) {
|
||||
return "echarts";
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function getMediaVisual(
|
||||
block: Element,
|
||||
kind: MediaBackfillKind
|
||||
): HTMLElement | SVGSVGElement | undefined {
|
||||
if (kind === "image") {
|
||||
return (
|
||||
block.querySelector<HTMLImageElement>("img.md-document-image") ??
|
||||
undefined
|
||||
);
|
||||
}
|
||||
if (kind === "echarts") {
|
||||
return (
|
||||
block.querySelector<HTMLElement>(".md-echarts-host") ??
|
||||
undefined
|
||||
);
|
||||
}
|
||||
return (
|
||||
block.querySelector<SVGSVGElement>("svg") ??
|
||||
block.querySelector<HTMLImageElement>("img") ??
|
||||
undefined
|
||||
);
|
||||
}
|
||||
|
||||
function parsePixelValue(value: string) {
|
||||
const parsed = Number.parseFloat(value);
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
}
|
||||
|
||||
function getOuterBottom(element: HTMLElement) {
|
||||
const rect = element.getBoundingClientRect();
|
||||
return (
|
||||
rect.bottom +
|
||||
parsePixelValue(getComputedStyle(element).marginBottom)
|
||||
);
|
||||
}
|
||||
|
||||
function getOuterTop(element: HTMLElement) {
|
||||
const rect = element.getBoundingClientRect();
|
||||
return (
|
||||
rect.top -
|
||||
parsePixelValue(getComputedStyle(element).marginTop)
|
||||
);
|
||||
}
|
||||
|
||||
function getMediaGroupTop(
|
||||
block: HTMLElement,
|
||||
pageContentTop: number
|
||||
) {
|
||||
const title = block.previousElementSibling;
|
||||
if (
|
||||
title instanceof HTMLElement &&
|
||||
/^H[1-6]$/.test(title.tagName)
|
||||
) {
|
||||
return Math.max(
|
||||
pageContentTop,
|
||||
getOuterTop(title)
|
||||
);
|
||||
}
|
||||
return Math.max(
|
||||
pageContentTop,
|
||||
getOuterTop(block)
|
||||
);
|
||||
}
|
||||
|
||||
function getLastContentBottom(pageContent: HTMLElement) {
|
||||
const contentRect = pageContent.getBoundingClientRect();
|
||||
const leafElements = Array.from(
|
||||
pageContent.querySelectorAll<HTMLElement>("[data-ref]")
|
||||
).filter(
|
||||
(element) => !element.querySelector("[data-ref]")
|
||||
);
|
||||
let bottom = contentRect.top;
|
||||
|
||||
for (const element of leafElements) {
|
||||
const rect = element.getBoundingClientRect();
|
||||
if (
|
||||
rect.width <= 0 ||
|
||||
rect.height <= 0 ||
|
||||
rect.bottom <= contentRect.top ||
|
||||
rect.top >= contentRect.bottom
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
bottom = Math.max(
|
||||
bottom,
|
||||
Math.min(getOuterBottom(element), contentRect.bottom)
|
||||
);
|
||||
}
|
||||
return bottom;
|
||||
}
|
||||
|
||||
function hasForcedBreakBefore(block: HTMLElement) {
|
||||
const title = block.previousElementSibling;
|
||||
const elements = [
|
||||
block,
|
||||
title instanceof HTMLElement && /^H[1-6]$/.test(title.tagName)
|
||||
? title
|
||||
: undefined
|
||||
].filter((element): element is HTMLElement => Boolean(element));
|
||||
return elements.some((element) => {
|
||||
const style = getComputedStyle(element);
|
||||
return (
|
||||
style.breakBefore === "page" ||
|
||||
style.pageBreakBefore === "always"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export function calculateMediaBackfillScale(
|
||||
geometry: MediaBackfillGeometry,
|
||||
options: MediaBackfillOptions = {}
|
||||
) {
|
||||
const minimumScale =
|
||||
options.minimumScale ?? DEFAULT_MINIMUM_SCALE;
|
||||
const safetyGapPx =
|
||||
options.safetyGapPx ?? DEFAULT_SAFETY_GAP_PX;
|
||||
if (
|
||||
!Number.isFinite(geometry.remainingHeight) ||
|
||||
!Number.isFinite(geometry.fixedHeight) ||
|
||||
!Number.isFinite(geometry.baselineVisualHeight) ||
|
||||
geometry.remainingHeight <= 0 ||
|
||||
geometry.fixedHeight < 0 ||
|
||||
geometry.baselineVisualHeight <= 0 ||
|
||||
!Number.isFinite(minimumScale) ||
|
||||
minimumScale <= 0 ||
|
||||
minimumScale >= 1 ||
|
||||
!Number.isFinite(safetyGapPx) ||
|
||||
safetyGapPx < 0
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const availableVisualHeight =
|
||||
geometry.remainingHeight -
|
||||
geometry.fixedHeight -
|
||||
safetyGapPx;
|
||||
const scale =
|
||||
availableVisualHeight / geometry.baselineVisualHeight;
|
||||
if (
|
||||
scale < minimumScale ||
|
||||
scale >= 1 - SCALE_EPSILON
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return scale;
|
||||
}
|
||||
|
||||
export function prepareMediaBackfillBlocks(root: ParentNode) {
|
||||
const blocks = Array.from(
|
||||
root.querySelectorAll<HTMLElement>(MEDIA_BLOCK_SELECTOR)
|
||||
);
|
||||
let prepared = 0;
|
||||
|
||||
for (const [index, block] of blocks.entries()) {
|
||||
const kind = getMediaKind(block);
|
||||
const visual = kind ? getMediaVisual(block, kind) : undefined;
|
||||
const rect = visual?.getBoundingClientRect();
|
||||
if (
|
||||
!kind ||
|
||||
!visual ||
|
||||
!rect ||
|
||||
rect.width <= 0 ||
|
||||
rect.height <= 0
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
block.dataset.mediaBackfillId = `media-${index + 1}`;
|
||||
block.dataset.mediaBackfillKind = kind;
|
||||
block.dataset.mediaBaselineWidth = String(rect.width);
|
||||
block.dataset.mediaBaselineHeight = String(rect.height);
|
||||
prepared += 1;
|
||||
}
|
||||
|
||||
return prepared;
|
||||
}
|
||||
|
||||
export function getMediaBackfillIdsInDocumentOrder(
|
||||
root: ParentNode
|
||||
) {
|
||||
return Array.from(
|
||||
root.querySelectorAll<HTMLElement>(
|
||||
"[data-media-backfill-id]"
|
||||
)
|
||||
).flatMap((block) =>
|
||||
block.dataset.mediaBackfillId
|
||||
? [block.dataset.mediaBackfillId]
|
||||
: []
|
||||
);
|
||||
}
|
||||
|
||||
export function findMediaBackfillCandidates(
|
||||
root: ParentNode,
|
||||
options: MediaBackfillOptions = {}
|
||||
) {
|
||||
const pages = Array.from(
|
||||
root.querySelectorAll<HTMLElement>(".pagedjs_page")
|
||||
);
|
||||
const candidates: MediaBackfillCandidate[] = [];
|
||||
const candidateIds = new Set<string>();
|
||||
|
||||
for (let pageIndex = 1; pageIndex < pages.length; pageIndex += 1) {
|
||||
const previousPage = pages[pageIndex - 1];
|
||||
const currentPage = pages[pageIndex];
|
||||
const previousContent =
|
||||
previousPage?.querySelector<HTMLElement>(
|
||||
".pagedjs_page_content"
|
||||
);
|
||||
const currentContent =
|
||||
currentPage?.querySelector<HTMLElement>(
|
||||
".pagedjs_page_content"
|
||||
);
|
||||
if (!previousContent || !currentContent) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const mediaBlocks = Array.from(
|
||||
currentContent.querySelectorAll<HTMLElement>(
|
||||
"[data-media-backfill-id]"
|
||||
)
|
||||
).sort(
|
||||
(left, right) =>
|
||||
left.getBoundingClientRect().top -
|
||||
right.getBoundingClientRect().top
|
||||
);
|
||||
const block = mediaBlocks[0];
|
||||
if (!block) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const kind = getMediaKind(block);
|
||||
const visual = kind ? getMediaVisual(block, kind) : undefined;
|
||||
const id = block.dataset.mediaBackfillId;
|
||||
const baselineVisualHeight = finitePositive(
|
||||
block.dataset.mediaBaselineHeight
|
||||
);
|
||||
if (!kind || !visual || !id || !baselineVisualHeight) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (hasForcedBreakBefore(block)) {
|
||||
continue;
|
||||
}
|
||||
const currentContentRect = currentContent.getBoundingClientRect();
|
||||
const visualHeight = visual.getBoundingClientRect().height;
|
||||
const mediaGroupTop = getMediaGroupTop(
|
||||
block,
|
||||
currentContentRect.top
|
||||
);
|
||||
const fixedHeight = Math.max(
|
||||
0,
|
||||
getOuterBottom(block) -
|
||||
mediaGroupTop -
|
||||
visualHeight
|
||||
);
|
||||
|
||||
const previousContentRect =
|
||||
previousContent.getBoundingClientRect();
|
||||
const previousFlowBottom =
|
||||
getLastContentBottom(previousContent);
|
||||
const remainingHeight = Math.max(
|
||||
0,
|
||||
previousContentRect.bottom - previousFlowBottom
|
||||
);
|
||||
const scale = calculateMediaBackfillScale(
|
||||
{
|
||||
remainingHeight,
|
||||
fixedHeight,
|
||||
baselineVisualHeight
|
||||
},
|
||||
options
|
||||
);
|
||||
if (scale === undefined || candidateIds.has(id)) {
|
||||
continue;
|
||||
}
|
||||
candidateIds.add(id);
|
||||
candidates.push({ id, scale });
|
||||
}
|
||||
|
||||
return candidates;
|
||||
}
|
||||
|
||||
function setVisualSize(
|
||||
visual: HTMLElement | SVGSVGElement,
|
||||
width: number,
|
||||
height: number
|
||||
) {
|
||||
visual.style.setProperty("display", "block");
|
||||
visual.style.setProperty("width", `${width}px`, "important");
|
||||
visual.style.setProperty("height", `${height}px`, "important");
|
||||
visual.style.setProperty("max-width", "100%", "important");
|
||||
visual.style.setProperty("max-height", `${height}px`, "important");
|
||||
visual.style.setProperty("margin-inline", "auto", "important");
|
||||
}
|
||||
|
||||
export function applyMediaBackfillCandidates(
|
||||
source: ParentNode,
|
||||
candidates: MediaBackfillCandidate[]
|
||||
) {
|
||||
let applied = 0;
|
||||
for (const candidate of candidates) {
|
||||
const block = Array.from(
|
||||
source.querySelectorAll<HTMLElement>(
|
||||
"[data-media-backfill-id]"
|
||||
)
|
||||
).find(
|
||||
(element) =>
|
||||
element.dataset.mediaBackfillId === candidate.id
|
||||
);
|
||||
const kind = block ? getMediaKind(block) : undefined;
|
||||
const baselineWidth = finitePositive(
|
||||
block?.dataset.mediaBaselineWidth
|
||||
);
|
||||
const baselineHeight = finitePositive(
|
||||
block?.dataset.mediaBaselineHeight
|
||||
);
|
||||
const visual =
|
||||
block && kind ? getMediaVisual(block, kind) : undefined;
|
||||
if (
|
||||
!block ||
|
||||
!kind ||
|
||||
!visual ||
|
||||
!baselineWidth ||
|
||||
!baselineHeight ||
|
||||
!Number.isFinite(candidate.scale) ||
|
||||
candidate.scale <= 0 ||
|
||||
candidate.scale >= 1
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const width = baselineWidth * candidate.scale;
|
||||
const height = baselineHeight * candidate.scale;
|
||||
block.dataset.mediaBackfilled = "true";
|
||||
block.dataset.mediaBackfillScale = String(candidate.scale);
|
||||
setVisualSize(visual, width, height);
|
||||
|
||||
if (kind === "image") {
|
||||
visual.style.setProperty("object-fit", "contain");
|
||||
} else if (kind === "echarts") {
|
||||
const svg = block.querySelector<SVGSVGElement>(
|
||||
".md-echarts-host svg"
|
||||
);
|
||||
if (svg) {
|
||||
setVisualSize(svg, width, height);
|
||||
}
|
||||
visual.style.removeProperty("aspect-ratio");
|
||||
}
|
||||
applied += 1;
|
||||
}
|
||||
return applied;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { MermaidExportConfig } from "@md-to-pdf/core";
|
||||
import type { MermaidConfig } from "mermaid";
|
||||
|
||||
export const MERMAID_SECURE_CONFIG_KEYS = [
|
||||
"secure",
|
||||
"securityLevel",
|
||||
"startOnLoad",
|
||||
"maxTextSize",
|
||||
"maxEdges",
|
||||
"suppressErrorRendering",
|
||||
"themeCSS"
|
||||
];
|
||||
|
||||
export function createMermaidSiteConfig(
|
||||
config: MermaidExportConfig
|
||||
): MermaidConfig {
|
||||
return {
|
||||
startOnLoad: false,
|
||||
securityLevel: "strict",
|
||||
layout: config.layout,
|
||||
theme: config.theme,
|
||||
look: config.look,
|
||||
fontFamily: config.fontFamily,
|
||||
flowchart: {
|
||||
wrappingWidth: 320
|
||||
},
|
||||
maxTextSize: 50_000,
|
||||
maxEdges: 500,
|
||||
suppressErrorRendering: true,
|
||||
secure: [...MERMAID_SECURE_CONFIG_KEYS]
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { ExportConfig } from "@md-to-pdf/core";
|
||||
import {
|
||||
calculateDiagramPageFit,
|
||||
fitSvgDiagramsToPage,
|
||||
getPageContentDimensions,
|
||||
parseSvgViewBox,
|
||||
type DiagramDimensions,
|
||||
type DiagramPageFit
|
||||
} from "./diagram-page-fit.js";
|
||||
|
||||
export type MermaidDimensions = DiagramDimensions;
|
||||
export type MermaidPageFit = DiagramPageFit;
|
||||
export {
|
||||
getPageContentDimensions,
|
||||
parseSvgViewBox
|
||||
};
|
||||
export const calculateMermaidPageFit = calculateDiagramPageFit;
|
||||
|
||||
export function fitOversizedMermaidToPage(
|
||||
container: ParentNode,
|
||||
config: ExportConfig
|
||||
) {
|
||||
fitSvgDiagramsToPage(container, config, {
|
||||
containerSelector: ".mermaid"
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
export interface MermaidSvgResult {
|
||||
svg: string;
|
||||
bindFunctions?: (element: Element) => void;
|
||||
}
|
||||
|
||||
export type MermaidRenderOutcome =
|
||||
| ({ success: true } & MermaidSvgResult)
|
||||
| {
|
||||
success: false;
|
||||
error: unknown;
|
||||
};
|
||||
|
||||
export async function renderMermaidDefinitions(
|
||||
definitions: string[],
|
||||
renderDefinition: (
|
||||
definition: string,
|
||||
index: number
|
||||
) => Promise<MermaidSvgResult>
|
||||
): Promise<MermaidRenderOutcome[]> {
|
||||
const outcomes: MermaidRenderOutcome[] = [];
|
||||
|
||||
for (const [index, definition] of definitions.entries()) {
|
||||
try {
|
||||
outcomes.push({
|
||||
success: true,
|
||||
...(await renderDefinition(definition, index))
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
outcomes.push({
|
||||
success: false,
|
||||
error
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return outcomes;
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
export function createSvgDataUrl(svgMarkup: string) {
|
||||
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svgMarkup)}`;
|
||||
}
|
||||
|
||||
export interface MermaidBoxSize {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export type MermaidOutputMode = "inline-svg" | "svg-image";
|
||||
|
||||
export function resolveMermaidOutputMode(
|
||||
search: string
|
||||
): MermaidOutputMode {
|
||||
return new URLSearchParams(search).get("mermaid-output") ===
|
||||
"inline-svg"
|
||||
? "inline-svg"
|
||||
: "svg-image";
|
||||
}
|
||||
|
||||
export function getLockedMermaidBoxSize(
|
||||
size: MermaidBoxSize
|
||||
): MermaidBoxSize | undefined {
|
||||
if (
|
||||
!Number.isFinite(size.width) ||
|
||||
!Number.isFinite(size.height) ||
|
||||
size.width <= 0 ||
|
||||
size.height <= 0
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return size;
|
||||
}
|
||||
|
||||
export function getMermaidImageAlt(
|
||||
ariaLabel: string | null,
|
||||
title: string | null
|
||||
) {
|
||||
return ariaLabel?.trim() || title?.trim() || "Mermaid 图表";
|
||||
}
|
||||
|
||||
export function replaceMermaidSvgWithImages(container: ParentNode) {
|
||||
const diagrams = Array.from(
|
||||
container.querySelectorAll<HTMLElement>(".mermaid")
|
||||
);
|
||||
|
||||
for (const diagram of diagrams) {
|
||||
const svg = diagram.querySelector<SVGSVGElement>("svg");
|
||||
if (!svg) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const documentWindow = svg.ownerDocument.defaultView;
|
||||
const svgRect = svg.getBoundingClientRect();
|
||||
const diagramRect = diagram.getBoundingClientRect();
|
||||
const computedStyle = documentWindow?.getComputedStyle(svg);
|
||||
const lockedSvgSize = getLockedMermaidBoxSize(svgRect);
|
||||
const lockedDiagramSize = getLockedMermaidBoxSize(diagramRect);
|
||||
const image = svg.ownerDocument.createElement("img");
|
||||
image.className = "mermaid-svg-image";
|
||||
image.alt = getMermaidImageAlt(
|
||||
svg.getAttribute("aria-label"),
|
||||
svg.querySelector("title")?.textContent ?? null
|
||||
);
|
||||
image.src = createSvgDataUrl(
|
||||
new XMLSerializer().serializeToString(svg)
|
||||
);
|
||||
image.style.cssText = svg.style.cssText;
|
||||
if (computedStyle?.display) {
|
||||
image.style.setProperty("display", computedStyle.display, "important");
|
||||
}
|
||||
if (computedStyle?.verticalAlign) {
|
||||
image.style.setProperty(
|
||||
"vertical-align",
|
||||
computedStyle.verticalAlign,
|
||||
"important"
|
||||
);
|
||||
}
|
||||
if (lockedSvgSize) {
|
||||
const width = `${lockedSvgSize.width}px`;
|
||||
const height = `${lockedSvgSize.height}px`;
|
||||
image.style.setProperty("width", width, "important");
|
||||
image.style.setProperty("height", height, "important");
|
||||
image.style.setProperty("max-width", width, "important");
|
||||
image.style.setProperty("max-height", height, "important");
|
||||
image.setAttribute("width", String(lockedSvgSize.width));
|
||||
image.setAttribute("height", String(lockedSvgSize.height));
|
||||
}
|
||||
if (lockedDiagramSize) {
|
||||
diagram.style.setProperty("box-sizing", "border-box", "important");
|
||||
diagram.style.setProperty(
|
||||
"height",
|
||||
`${lockedDiagramSize.height}px`,
|
||||
"important"
|
||||
);
|
||||
}
|
||||
svg.replaceWith(image);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
const ELEMENT_NODE_TYPE = 1;
|
||||
|
||||
export function resolveBreakTokenElement(
|
||||
node: Node | undefined
|
||||
): Element | undefined {
|
||||
if (!node) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (node.nodeType === ELEMENT_NODE_TYPE) {
|
||||
return node as Element;
|
||||
}
|
||||
|
||||
return node.parentElement ?? undefined;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,569 @@
|
||||
import {
|
||||
getPaperDimensionsMm,
|
||||
type ExportConfig,
|
||||
type MarkdownDocumentMetadata,
|
||||
type PagedDocumentPayload
|
||||
} from "@md-to-pdf/core";
|
||||
export const PAGED_PREVIEW_MESSAGE_SCOPE = "md-to-pdf:paged-preview";
|
||||
|
||||
export type PreviewMetadata = MarkdownDocumentMetadata;
|
||||
export type PagedPreviewPayload = PagedDocumentPayload;
|
||||
|
||||
export interface PagedPreviewRenderRequest {
|
||||
scope: typeof PAGED_PREVIEW_MESSAGE_SCOPE;
|
||||
type: "render";
|
||||
requestId: number;
|
||||
layout: "paged" | "continuous";
|
||||
payload: PagedPreviewPayload;
|
||||
}
|
||||
|
||||
export type PagedPreviewFrameMessage =
|
||||
| {
|
||||
scope: typeof PAGED_PREVIEW_MESSAGE_SCOPE;
|
||||
type: "ready";
|
||||
}
|
||||
| {
|
||||
scope: typeof PAGED_PREVIEW_MESSAGE_SCOPE;
|
||||
type: "link";
|
||||
href: string;
|
||||
}
|
||||
| {
|
||||
scope: typeof PAGED_PREVIEW_MESSAGE_SCOPE;
|
||||
type: "rendering";
|
||||
requestId: number;
|
||||
layout: "paged" | "continuous";
|
||||
}
|
||||
| {
|
||||
scope: typeof PAGED_PREVIEW_MESSAGE_SCOPE;
|
||||
type: "rendered";
|
||||
requestId: number;
|
||||
layout: "paged" | "continuous";
|
||||
pageCount: number;
|
||||
contentHeight: number;
|
||||
echartsErrors: string[];
|
||||
mermaidErrors: string[];
|
||||
}
|
||||
| {
|
||||
scope: typeof PAGED_PREVIEW_MESSAGE_SCOPE;
|
||||
type: "error";
|
||||
requestId: number;
|
||||
message: string;
|
||||
};
|
||||
|
||||
export const documentBaseCss = `
|
||||
:root {
|
||||
color-scheme: light;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
img,
|
||||
svg {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
#write pre.md-fences > code {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
font-weight: inherit;
|
||||
line-height: inherit;
|
||||
}
|
||||
|
||||
.md-document-image-block {
|
||||
break-inside: avoid;
|
||||
page-break-inside: avoid;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.md-document-image-block > .md-document-image {
|
||||
display: block;
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
.md-document-image-caption {
|
||||
margin-top: 0.55em;
|
||||
color: #64748b;
|
||||
font-size: 0.875em;
|
||||
line-height: 1.5;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
#write table {
|
||||
width: 100%;
|
||||
table-layout: auto;
|
||||
}
|
||||
|
||||
#write th,
|
||||
#write td {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.mermaid {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin: 1.5em 0;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.mermaid-error {
|
||||
display: block;
|
||||
padding: 0.85em 1em;
|
||||
border: 1px solid #dc2626;
|
||||
color: #991b1b;
|
||||
background: #fef2f2;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.md-echarts {
|
||||
break-inside: avoid;
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
`;
|
||||
|
||||
export const documentGeometryCss = `
|
||||
html,
|
||||
body,
|
||||
#preview-root {
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
#write {
|
||||
width: 100% !important;
|
||||
max-width: none !important;
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
`;
|
||||
|
||||
export const documentInteractionCss = `
|
||||
:where(#write a[href]) {
|
||||
color: var(--md-link-color, var(--md-accent, #0969da));
|
||||
text-decoration-line: underline;
|
||||
text-decoration-thickness: 0.08em;
|
||||
text-underline-offset: 0.16em;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
:where(#write a[href]:hover) {
|
||||
color: var(--md-link-hover-color, #0550ae);
|
||||
}
|
||||
|
||||
:where(#write a[href]:focus-visible) {
|
||||
border-radius: 2px;
|
||||
outline: 2px solid currentColor;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
`;
|
||||
|
||||
export const continuousDocumentGeometryCss = `
|
||||
html,
|
||||
body,
|
||||
#preview-root {
|
||||
height: auto !important;
|
||||
min-height: 0 !important;
|
||||
overflow: hidden !important;
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
#preview-root {
|
||||
padding: 34px 0;
|
||||
}
|
||||
|
||||
#write {
|
||||
width: 100% !important;
|
||||
max-width: none !important;
|
||||
height: auto !important;
|
||||
min-height: 0 !important;
|
||||
margin: 0 !important;
|
||||
padding: 16mm !important;
|
||||
background: #fff;
|
||||
box-shadow: 0 3px 16px rgb(34 43 38 / 16%);
|
||||
}
|
||||
|
||||
#write[data-continuous-render-stage="true"] {
|
||||
position: fixed !important;
|
||||
inset: 0 auto auto 0 !important;
|
||||
z-index: -1 !important;
|
||||
visibility: hidden !important;
|
||||
pointer-events: none !important;
|
||||
}
|
||||
`;
|
||||
|
||||
function cssString(value: string) {
|
||||
return JSON.stringify(value)
|
||||
.replace(/\u2028/g, "\\2028 ")
|
||||
.replace(/\u2029/g, "\\2029 ");
|
||||
}
|
||||
|
||||
function resolveHeaderContent(
|
||||
template: string,
|
||||
payload: Pick<PagedPreviewPayload, "fileName" | "metadata">
|
||||
) {
|
||||
const values: Record<string, string> = {
|
||||
title: payload.metadata.title,
|
||||
author: payload.metadata.author,
|
||||
filename: payload.fileName
|
||||
};
|
||||
|
||||
return template.replace(
|
||||
/\$\{(title|author|filename)\}/g,
|
||||
(_, name: string) => values[name] ?? ""
|
||||
);
|
||||
}
|
||||
|
||||
export function formatPageNumber(
|
||||
config: ExportConfig["footer"],
|
||||
pageIndex: number,
|
||||
totalPages: number
|
||||
) {
|
||||
const page = config.startFrom + pageIndex;
|
||||
|
||||
if (config.format === "page") {
|
||||
return String(page);
|
||||
}
|
||||
if (config.format === "page-total") {
|
||||
return `${page} / ${totalPages}`;
|
||||
}
|
||||
if (config.format === "chinese-page-total") {
|
||||
return `第 ${page} 页 / 共 ${totalPages} 页`;
|
||||
}
|
||||
if (config.format === "dash-page") {
|
||||
return `- ${page} -`;
|
||||
}
|
||||
|
||||
return (config.template || "${page} / ${pages}")
|
||||
.replace(/\$\{page\}/g, String(page))
|
||||
.replace(/\$\{pages\}/g, String(totalPages));
|
||||
}
|
||||
|
||||
function marginBox(
|
||||
position: "top" | "bottom",
|
||||
alignment: "left" | "center" | "right",
|
||||
content: string,
|
||||
options: {
|
||||
color: string;
|
||||
fontSize: string;
|
||||
height: string;
|
||||
showDivider: boolean;
|
||||
}
|
||||
) {
|
||||
const border =
|
||||
options.showDivider && position === "top"
|
||||
? "border-bottom: 0.2mm solid currentColor;"
|
||||
: options.showDivider
|
||||
? "border-top: 0.2mm solid currentColor;"
|
||||
: "";
|
||||
const verticalAlignment = position === "top" ? "bottom" : "top";
|
||||
|
||||
return `
|
||||
@${position}-${alignment} {
|
||||
content: ${content};
|
||||
height: ${options.height};
|
||||
color: ${options.color};
|
||||
font-family: inherit;
|
||||
font-size: ${options.fontSize};
|
||||
font-weight: 400;
|
||||
line-height: 1.25;
|
||||
text-align: ${alignment};
|
||||
vertical-align: ${verticalAlignment};
|
||||
${border}
|
||||
}`;
|
||||
}
|
||||
|
||||
function buildHeaderCss(
|
||||
config: ExportConfig,
|
||||
payload: Pick<PagedPreviewPayload, "fileName" | "metadata">
|
||||
) {
|
||||
if (!config.header.enabled) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const options = {
|
||||
color: config.header.color,
|
||||
fontSize: config.header.fontSize,
|
||||
height: config.header.height,
|
||||
showDivider: config.header.showDivider
|
||||
};
|
||||
|
||||
return (["left", "center", "right"] as const)
|
||||
.map((alignment) => {
|
||||
const slot = config.header[alignment];
|
||||
const value = slot.enabled
|
||||
? resolveHeaderContent(slot.content, payload)
|
||||
: "";
|
||||
return marginBox(
|
||||
"top",
|
||||
alignment,
|
||||
cssString(value),
|
||||
options
|
||||
);
|
||||
})
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function buildFooterCss(config: ExportConfig) {
|
||||
if (!config.footer.enabled) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const options = {
|
||||
color: config.footer.color,
|
||||
fontSize: config.footer.fontSize,
|
||||
height: config.footer.height,
|
||||
showDivider: config.footer.showDivider
|
||||
};
|
||||
|
||||
return (["left", "center", "right"] as const)
|
||||
.map((alignment) =>
|
||||
marginBox(
|
||||
"bottom",
|
||||
alignment,
|
||||
alignment === config.footer.alignment
|
||||
? cssString("")
|
||||
: cssString(""),
|
||||
options
|
||||
)
|
||||
)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
export function buildPagedMediaCss(
|
||||
config: ExportConfig,
|
||||
payload: Pick<PagedPreviewPayload, "fileName" | "metadata">
|
||||
) {
|
||||
const dimensions = getPaperDimensionsMm(
|
||||
config.paper.format,
|
||||
config.paper.orientation
|
||||
);
|
||||
|
||||
return `
|
||||
@page {
|
||||
size: ${dimensions.width}mm ${dimensions.height}mm;
|
||||
margin: ${config.paper.margins.top} ${config.paper.margins.right}
|
||||
${config.paper.margins.bottom} ${config.paper.margins.left};
|
||||
${buildHeaderCss(config, payload)}
|
||||
${buildFooterCss(config)}
|
||||
}
|
||||
|
||||
#write p,
|
||||
#write li {
|
||||
orphans: 3;
|
||||
widows: 3;
|
||||
}
|
||||
|
||||
#write h1,
|
||||
#write h2,
|
||||
#write h3,
|
||||
#write h4,
|
||||
#write h5,
|
||||
#write h6 {
|
||||
break-after: avoid;
|
||||
break-inside: avoid;
|
||||
}
|
||||
|
||||
#write thead {
|
||||
display: table-header-group;
|
||||
}
|
||||
|
||||
#write tfoot {
|
||||
display: table-footer-group;
|
||||
}
|
||||
|
||||
#write tr,
|
||||
#write pre,
|
||||
#write blockquote,
|
||||
#write .katex-display,
|
||||
#write .mermaid,
|
||||
#write .md-echarts {
|
||||
break-inside: avoid;
|
||||
}
|
||||
|
||||
#write table[data-empty-split-table="true"] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#write img,
|
||||
#write svg {
|
||||
break-inside: avoid;
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
|
||||
#write .md-document-image-block {
|
||||
break-inside: avoid;
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
|
||||
${
|
||||
config.print.pageBreakBeforeH1
|
||||
? `#write h1:not(:first-child) {
|
||||
break-before: page;
|
||||
}`
|
||||
: ""
|
||||
}
|
||||
|
||||
.pagedjs_pages {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
width: max-content;
|
||||
min-width: 100%;
|
||||
margin: 0 auto;
|
||||
padding: 34px 0;
|
||||
}
|
||||
|
||||
html[data-render-target="preview"] {
|
||||
height: 100%;
|
||||
overflow-x: hidden;
|
||||
overflow-y: hidden;
|
||||
}
|
||||
|
||||
html[data-render-target="preview"] body {
|
||||
min-height: 100%;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.pagedjs_page {
|
||||
flex: none;
|
||||
margin: 0 !important;
|
||||
background: #fff;
|
||||
box-shadow: 0 18px 48px rgb(37 49 43 / 16%);
|
||||
}
|
||||
|
||||
html[data-render-target="pdf"],
|
||||
html[data-render-target="pdf"] body {
|
||||
height: auto !important;
|
||||
min-height: 0 !important;
|
||||
overflow: visible !important;
|
||||
background: #fff !important;
|
||||
}
|
||||
|
||||
html[data-render-target="pdf"] .pagedjs_pages {
|
||||
display: block;
|
||||
width: auto;
|
||||
min-width: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
html[data-render-target="pdf"] .pagedjs_page {
|
||||
box-shadow: none;
|
||||
break-after: page;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
export function createPagedPreviewRenderRequest(
|
||||
requestId: number,
|
||||
payload: PagedPreviewPayload,
|
||||
layout: "paged" | "continuous" = "paged"
|
||||
): PagedPreviewRenderRequest {
|
||||
return {
|
||||
scope: PAGED_PREVIEW_MESSAGE_SCOPE,
|
||||
type: "render",
|
||||
requestId,
|
||||
layout,
|
||||
payload
|
||||
};
|
||||
}
|
||||
|
||||
export function isPagedPreviewRenderRequest(
|
||||
value: unknown
|
||||
): value is PagedPreviewRenderRequest {
|
||||
if (!value || typeof value !== "object") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const candidate = value as Partial<PagedPreviewRenderRequest>;
|
||||
return (
|
||||
candidate.scope === PAGED_PREVIEW_MESSAGE_SCOPE &&
|
||||
candidate.type === "render" &&
|
||||
Number.isInteger(candidate.requestId) &&
|
||||
(candidate.layout === "paged" ||
|
||||
candidate.layout === "continuous") &&
|
||||
Boolean(candidate.payload) &&
|
||||
typeof candidate.payload?.articleHtml === "string" &&
|
||||
typeof candidate.payload?.themeCss === "string"
|
||||
);
|
||||
}
|
||||
|
||||
export function isPagedPreviewFrameMessage(
|
||||
value: unknown
|
||||
): value is PagedPreviewFrameMessage {
|
||||
if (!value || typeof value !== "object") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const candidate = value as Partial<PagedPreviewFrameMessage>;
|
||||
if (
|
||||
candidate.scope !== PAGED_PREVIEW_MESSAGE_SCOPE ||
|
||||
!["ready", "link", "rendering", "rendered", "error"].includes(
|
||||
candidate.type ?? ""
|
||||
)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (candidate.type === "ready") {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (candidate.type === "link") {
|
||||
return (
|
||||
typeof candidate.href === "string" &&
|
||||
candidate.href.trim().length > 0
|
||||
);
|
||||
}
|
||||
|
||||
if (candidate.type === "rendering") {
|
||||
return (
|
||||
Number.isInteger(candidate.requestId) &&
|
||||
(candidate.layout === "paged" ||
|
||||
candidate.layout === "continuous")
|
||||
);
|
||||
}
|
||||
|
||||
if (candidate.type === "error") {
|
||||
return (
|
||||
Number.isInteger(candidate.requestId) &&
|
||||
typeof candidate.message === "string"
|
||||
);
|
||||
}
|
||||
|
||||
if (candidate.type !== "rendered") {
|
||||
return false;
|
||||
}
|
||||
const rendered = candidate as Partial<
|
||||
Extract<PagedPreviewFrameMessage, { type: "rendered" }>
|
||||
>;
|
||||
return (
|
||||
Number.isInteger(rendered.requestId) &&
|
||||
(rendered.layout === "paged" ||
|
||||
rendered.layout === "continuous") &&
|
||||
Number.isInteger(rendered.pageCount) &&
|
||||
typeof rendered.contentHeight === "number" &&
|
||||
Number.isFinite(rendered.contentHeight) &&
|
||||
rendered.contentHeight > 0 &&
|
||||
Array.isArray(rendered.echartsErrors) &&
|
||||
Array.isArray(rendered.mermaidErrors)
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export type PagedRenderTarget = "preview" | "pdf";
|
||||
|
||||
export function resolvePagedRenderTarget(
|
||||
search: string
|
||||
): PagedRenderTarget {
|
||||
const target = new URLSearchParams(search).get("target");
|
||||
return target === "pdf" ? "pdf" : "preview";
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import { Handler, registerHandlers } from "pagedjs";
|
||||
import { resolveBreakTokenElement } from "./paged-break-token.js";
|
||||
|
||||
interface PagedBreakToken {
|
||||
node?: Node;
|
||||
}
|
||||
|
||||
interface PagedChunker {
|
||||
source: ParentNode;
|
||||
}
|
||||
|
||||
interface PagedHandlerContext {
|
||||
chunker: PagedChunker;
|
||||
}
|
||||
|
||||
function elementAncestors(
|
||||
element: Element,
|
||||
selector: string
|
||||
) {
|
||||
const ancestors: Element[] = [];
|
||||
let current = element.parentElement;
|
||||
|
||||
while (current) {
|
||||
if (current.matches(selector)) {
|
||||
ancestors.unshift(current);
|
||||
}
|
||||
current = current.parentElement;
|
||||
}
|
||||
|
||||
return ancestors;
|
||||
}
|
||||
|
||||
class RepeatTableHeadersHandler extends Handler {
|
||||
declare chunker: PagedChunker;
|
||||
private splitTableRefs: string[] = [];
|
||||
|
||||
constructor(
|
||||
chunker: unknown,
|
||||
polisher: unknown,
|
||||
caller: unknown
|
||||
) {
|
||||
super(chunker, polisher, caller);
|
||||
}
|
||||
|
||||
afterPageLayout(
|
||||
pageElement: HTMLElement,
|
||||
_page: unknown,
|
||||
breakToken?: PagedBreakToken
|
||||
) {
|
||||
this.splitTableRefs = [];
|
||||
const element = resolveBreakTokenElement(breakToken?.node);
|
||||
if (!element) {
|
||||
return;
|
||||
}
|
||||
|
||||
const tables = elementAncestors(element, "table");
|
||||
if (element.matches("table")) {
|
||||
tables.push(element);
|
||||
}
|
||||
|
||||
this.splitTableRefs = Array.from(
|
||||
new Set(
|
||||
tables
|
||||
.map((table) => table.getAttribute("data-ref"))
|
||||
.filter((ref): ref is string => Boolean(ref))
|
||||
)
|
||||
);
|
||||
|
||||
for (const ref of this.splitTableRefs) {
|
||||
const renderedTable =
|
||||
pageElement.querySelector<HTMLElement>(
|
||||
`table[data-ref="${CSS.escape(ref)}"]`
|
||||
);
|
||||
if (!renderedTable?.querySelector("tbody > tr")) {
|
||||
renderedTable?.setAttribute(
|
||||
"data-empty-split-table",
|
||||
"true"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
layout(rendered: HTMLElement) {
|
||||
for (const ref of this.splitTableRefs) {
|
||||
const renderedTable =
|
||||
rendered.querySelector<HTMLTableElement>(
|
||||
`table[data-ref="${CSS.escape(ref)}"]`
|
||||
);
|
||||
if (
|
||||
!renderedTable ||
|
||||
renderedTable.hasAttribute("data-repeated-header")
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const sourceTable =
|
||||
this.chunker.source.querySelector<HTMLTableElement>(
|
||||
`table[data-ref="${CSS.escape(ref)}"]`
|
||||
);
|
||||
if (!sourceTable) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const firstChild = renderedTable.firstChild;
|
||||
for (const colgroup of sourceTable.querySelectorAll("colgroup")) {
|
||||
renderedTable.insertBefore(
|
||||
colgroup.cloneNode(true),
|
||||
firstChild
|
||||
);
|
||||
}
|
||||
|
||||
if (!renderedTable.querySelector("thead")) {
|
||||
const sourceHeader = sourceTable.querySelector("thead");
|
||||
if (sourceHeader) {
|
||||
renderedTable.insertBefore(
|
||||
sourceHeader.cloneNode(true),
|
||||
renderedTable.firstChild
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
renderedTable.setAttribute("data-repeated-header", "true");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handlerRegistration = globalThis as typeof globalThis & {
|
||||
__mdToPdfTableHandlerRegistered?: boolean;
|
||||
};
|
||||
|
||||
if (!handlerRegistration.__mdToPdfTableHandlerRegistered) {
|
||||
registerHandlers(RepeatTableHeadersHandler);
|
||||
handlerRegistration.__mdToPdfTableHandlerRegistered = true;
|
||||
}
|
||||
|
||||
export { RepeatTableHeadersHandler };
|
||||
+78
@@ -0,0 +1,78 @@
|
||||
declare module "pagedjs" {
|
||||
export interface PagedBreakToken {
|
||||
node?: Node;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
export interface PagedPage {
|
||||
element: HTMLElement;
|
||||
startToken?: PagedBreakToken;
|
||||
endToken?: PagedBreakToken;
|
||||
destroy(): void;
|
||||
}
|
||||
|
||||
export interface PagedFlow {
|
||||
total: number;
|
||||
pages: PagedPage[];
|
||||
performance: number;
|
||||
size: {
|
||||
width: { value: number; unit: string };
|
||||
height: { value: number; unit: string };
|
||||
format?: string;
|
||||
orientation?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export type PagedStylesheet = string | Record<string, string>;
|
||||
|
||||
export class Previewer {
|
||||
chunker: {
|
||||
source: DocumentFragment;
|
||||
pages: PagedPage[];
|
||||
total: number;
|
||||
rendered: boolean;
|
||||
pagesArea: HTMLElement;
|
||||
hooks: {
|
||||
afterParsed: {
|
||||
trigger(
|
||||
parsed: DocumentFragment,
|
||||
chunker: unknown
|
||||
): Promise<void>;
|
||||
};
|
||||
afterRendered: {
|
||||
trigger(
|
||||
pages: PagedPage[],
|
||||
chunker: unknown
|
||||
): Promise<void>;
|
||||
};
|
||||
};
|
||||
loadFonts(): Promise<void>;
|
||||
removePages(fromIndex?: number): void;
|
||||
render(
|
||||
parsed: DocumentFragment,
|
||||
startAt?: PagedBreakToken
|
||||
): Promise<{ done: boolean; canceled?: boolean }>;
|
||||
destroy(): void;
|
||||
};
|
||||
polisher: {
|
||||
destroy(): void;
|
||||
};
|
||||
preview(
|
||||
content?: HTMLElement | DocumentFragment | string,
|
||||
stylesheets?: PagedStylesheet[],
|
||||
renderTo?: HTMLElement | string
|
||||
): Promise<PagedFlow>;
|
||||
}
|
||||
|
||||
export class Handler {
|
||||
constructor(
|
||||
chunker: unknown,
|
||||
polisher: unknown,
|
||||
caller: unknown
|
||||
);
|
||||
}
|
||||
|
||||
export function registerHandlers(
|
||||
...handlers: Array<typeof Handler>
|
||||
): void;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { encodeLocalDocumentLinkForPdf } from "@md-to-pdf/core";
|
||||
|
||||
export function preparePdfDocumentLinks(root: ParentNode) {
|
||||
let rewritten = 0;
|
||||
for (const link of root.querySelectorAll<HTMLAnchorElement>(
|
||||
"a[href]"
|
||||
)) {
|
||||
const href = link.getAttribute("href");
|
||||
if (!href) {
|
||||
continue;
|
||||
}
|
||||
const encoded = encodeLocalDocumentLinkForPdf(href);
|
||||
if (!encoded) {
|
||||
continue;
|
||||
}
|
||||
link.setAttribute("href", encoded);
|
||||
rewritten += 1;
|
||||
}
|
||||
return rewritten;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
const printMediaPattern =
|
||||
/@media(\s+)(only\s+)?print(?=\s*(?:\{|and\b|,))/gi;
|
||||
|
||||
export function enablePrintMediaForPreview(css: string) {
|
||||
return css.replace(
|
||||
printMediaPattern,
|
||||
(_match, whitespace: string, qualifier: string | undefined) =>
|
||||
`@media${whitespace}${qualifier ?? ""}screen`
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user