- {window.mdToPdfDesktop ? (
+
+
- ) : (
- <>
-
-
- >
- )}
+ {moreMenuOpen ? (
+
+ ) : null}
+
+
diff --git a/apps/web/src/diagram-page-fit.ts b/apps/web/src/diagram-page-fit.ts
index f281904..5ed3fff 100644
--- a/apps/web/src/diagram-page-fit.ts
+++ b/apps/web/src/diagram-page-fit.ts
@@ -30,6 +30,58 @@ export interface SvgDiagramFitOptions {
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 {
@@ -144,9 +196,17 @@ export function fitSvgDiagramsToPage(
continue;
}
- const availablePageContent =
+ 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
@@ -157,6 +217,13 @@ export function fitSvgDiagramsToPage(
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",
diff --git a/apps/web/src/document-file.ts b/apps/web/src/document-file.ts
new file mode 100644
index 0000000..11a75e6
--- /dev/null
+++ b/apps/web/src/document-file.ts
@@ -0,0 +1,110 @@
+const fallbackBaseName = "未命名文档";
+const windowsReservedName =
+ /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])$/iu;
+
+function stripMarkdownFormatting(value: string) {
+ return value
+ .replace(/!\[([^\]]*)\]\([^)]*\)/gu, "$1")
+ .replace(/\[([^\]]+)\]\([^)]*\)/gu, "$1")
+ .replace(/<\/?[A-Za-z][^>]*>/gu, " ")
+ .replace(/[`*_~]/gu, "")
+ .replace(/^\s{0,3}(?:>\s*|[-+*]\s+|\d+[.)]\s+)/u, "")
+ .trim();
+}
+
+export function legalizeMarkdownBaseName(value: string) {
+ const normalized = stripMarkdownFormatting(value)
+ .normalize("NFKC")
+ .replace(/[^\p{L}\p{N}\s_-]+/gu, " ")
+ .replace(/\s+/gu, " ")
+ .trim()
+ .replace(/[ .]+$/u, "");
+ const shortened = Array.from(normalized).slice(0, 100).join("").trim();
+ if (!shortened) {
+ return fallbackBaseName;
+ }
+ return windowsReservedName.test(shortened)
+ ? `文档 ${shortened}`
+ : shortened;
+}
+
+export function deriveMarkdownBaseName(markdown: string) {
+ const lines = markdown.replace(/^\uFEFF/u, "").split(/\r?\n/u);
+ let inFrontMatter = lines[0]?.trim() === "---";
+ let inFence = false;
+ let fenceMarker = "";
+ let fenceLength = 0;
+ let firstBodyLine = "";
+
+ for (let index = inFrontMatter ? 1 : 0; index < lines.length; index += 1) {
+ const line = lines[index] ?? "";
+ const trimmed = line.trim();
+ if (inFrontMatter) {
+ if (trimmed === "---" || trimmed === "...") {
+ inFrontMatter = false;
+ }
+ continue;
+ }
+
+ if (inFence) {
+ const closingFence = trimmed.match(/^(`+|~+)\s*$/u)?.[1];
+ if (
+ closingFence?.[0] === fenceMarker &&
+ closingFence.length >= fenceLength
+ ) {
+ inFence = false;
+ fenceMarker = "";
+ fenceLength = 0;
+ }
+ continue;
+ }
+
+ const fence = trimmed.match(/^(`{3,}|~{3,})/u)?.[1];
+ if (fence) {
+ if (!inFence) {
+ inFence = true;
+ fenceMarker = fence[0] ?? "";
+ fenceLength = fence.length;
+ }
+ continue;
+ }
+ if (inFence || !trimmed) {
+ continue;
+ }
+
+ const heading = line.match(/^\s{0,3}#\s+(.+?)\s*#*\s*$/u)?.[1];
+ if (heading) {
+ return legalizeMarkdownBaseName(heading);
+ }
+ if (!firstBodyLine) {
+ firstBodyLine = line;
+ }
+ }
+
+ return legalizeMarkdownBaseName(firstBodyLine);
+}
+
+export function createMarkdownFileName(markdown: string) {
+ return `${deriveMarkdownBaseName(markdown)}.md`;
+}
+
+export function ensureMarkdownFileName(fileName: string, markdown: string) {
+ const trimmed = fileName.trim();
+ if (!trimmed) {
+ return createMarkdownFileName(markdown);
+ }
+ return /\.(?:md|markdown)$/iu.test(trimmed)
+ ? trimmed
+ : `${trimmed}.md`;
+}
+
+export function downloadMarkdown(markdown: string, fileName: string) {
+ const url = URL.createObjectURL(
+ new Blob([markdown], { type: "text/markdown;charset=utf-8" })
+ );
+ const anchor = document.createElement("a");
+ anchor.href = url;
+ anchor.download = fileName;
+ anchor.click();
+ window.setTimeout(() => URL.revokeObjectURL(url), 0);
+}
diff --git a/apps/web/src/document-image-fit.ts b/apps/web/src/document-image-fit.ts
index 329d5c3..0a8630b 100644
--- a/apps/web/src/document-image-fit.ts
+++ b/apps/web/src/document-image-fit.ts
@@ -1,7 +1,9 @@
import type { ExportConfig } from "@md-to-pdf/core";
import {
calculateDiagramPageFit,
- getPageContentDimensions
+ ensurePageBreakAfter,
+ getPageContentDimensions,
+ getPrecedingMediaTitleHeight
} from "./diagram-page-fit";
function getImageBlock(image: HTMLImageElement) {
@@ -23,11 +25,30 @@ function getImageBlock(image: HTMLImageElement) {
return undefined;
}
+export function prepareDocumentImageBlocks(root: ParentNode) {
+ const images = Array.from(
+ root.querySelectorAll
("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
+ config: ExportConfig,
+ options: { prepareBlocks?: boolean } = {}
) {
const pageContent = getPageContentDimensions(config);
+ if (options.prepareBlocks !== false) {
+ prepareDocumentImageBlocks(root);
+ }
const images = Array.from(
root.querySelectorAll("img.md-document-image")
);
@@ -42,11 +63,17 @@ export function fitDocumentImagesToPage(
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)
+ height: Math.max(
+ 0,
+ pageContent.height - nonImageHeight - titleHeight
+ )
}
);
if (!fit.scaled) {
@@ -55,6 +82,13 @@ export function fitDocumentImagesToPage(
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");
diff --git a/apps/web/src/media-page-backfill.ts b/apps/web/src/media-page-backfill.ts
new file mode 100644
index 0000000..802ae5d
--- /dev/null
+++ b/apps/web/src/media-page-backfill.ts
@@ -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("img.md-document-image") ??
+ undefined
+ );
+ }
+ if (kind === "echarts") {
+ return (
+ block.querySelector(".md-echarts-host") ??
+ undefined
+ );
+ }
+ return (
+ block.querySelector("svg") ??
+ block.querySelector("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("[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(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(
+ "[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(".pagedjs_page")
+ );
+ const candidates: MediaBackfillCandidate[] = [];
+ const candidateIds = new Set();
+
+ for (let pageIndex = 1; pageIndex < pages.length; pageIndex += 1) {
+ const previousPage = pages[pageIndex - 1];
+ const currentPage = pages[pageIndex];
+ const previousContent =
+ previousPage?.querySelector(
+ ".pagedjs_page_content"
+ );
+ const currentContent =
+ currentPage?.querySelector(
+ ".pagedjs_page_content"
+ );
+ if (!previousContent || !currentContent) {
+ continue;
+ }
+
+ const mediaBlocks = Array.from(
+ currentContent.querySelectorAll(
+ "[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(
+ "[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(
+ ".md-echarts-host svg"
+ );
+ if (svg) {
+ setVisualSize(svg, width, height);
+ }
+ visual.style.removeProperty("aspect-ratio");
+ }
+ applied += 1;
+ }
+ return applied;
+}
diff --git a/apps/web/src/paged-document-runtime.ts b/apps/web/src/paged-document-runtime.ts
index 50c7895..e73096b 100644
--- a/apps/web/src/paged-document-runtime.ts
+++ b/apps/web/src/paged-document-runtime.ts
@@ -7,7 +7,16 @@ import type {
} from "@md-to-pdf/core";
import { Previewer } from "pagedjs";
import { fitOversizedEChartsToPage } from "./echarts-page-fit";
-import { fitDocumentImagesToPage } from "./document-image-fit";
+import {
+ fitDocumentImagesToPage,
+ prepareDocumentImageBlocks
+} from "./document-image-fit";
+import {
+ applyMediaBackfillCandidates,
+ findMediaBackfillCandidates,
+ getMediaBackfillIdsInDocumentOrder,
+ prepareMediaBackfillBlocks
+} from "./media-page-backfill";
import { createMermaidSiteConfig } from "./mermaid-config";
import {
fitOversizedMermaidToPage,
@@ -232,6 +241,31 @@ export class PagedDocumentRuntime {
);
}
+ private async replaceEChartsSvgWithImages(container: ParentNode) {
+ const { freezeEChartsSvg } = await this.loadECharts();
+ const figures = Array.from(
+ container.querySelectorAll(
+ ".md-echarts:not(.md-echarts-error)"
+ )
+ );
+ for (const figure of figures) {
+ const host = figure.querySelector(
+ ".md-echarts-host"
+ );
+ const svg = host?.querySelector("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
@@ -346,9 +380,12 @@ export class PagedDocumentRuntime {
);
echartsMs = performance.now() - echartsStartedAt;
await waitForImages(measurement.host);
+ prepareDocumentImageBlocks(measurement.host);
+ prepareMediaBackfillBlocks(measurement.host);
fitDocumentImagesToPage(
measurement.host,
- payload.exportConfig
+ payload.exportConfig,
+ { prepareBlocks: false }
);
const echartsFitStartedAt = performance.now();
@@ -356,6 +393,7 @@ export class PagedDocumentRuntime {
measurement.host,
payload.exportConfig
);
+ await this.replaceEChartsSvgWithImages(measurement.host);
echartsFitMs =
performance.now() - echartsFitStartedAt;
@@ -388,29 +426,74 @@ export class PagedDocumentRuntime {
const resourceWaitMs = performance.now() - resourceWaitStartedAt;
const paginationStartedAt = performance.now();
- const previewer = new Previewer();
+ const repaginationSource = content.cloneNode(
+ true
+ ) as DocumentFragment;
+ const stylesheets = [
+ stylesheet(documentRef, documentBaseCss, "document-base"),
+ stylesheet(documentRef, highlightCss, "highlight"),
+ stylesheet(documentRef, katexCss, "katex"),
+ stylesheet(documentRef, echartsCss, "echarts"),
+ stylesheet(documentRef, payload.themeCss, "theme"),
+ stylesheet(
+ documentRef,
+ documentGeometryCss,
+ "document-geometry"
+ ),
+ stylesheet(
+ documentRef,
+ buildPagedMediaCss(payload.exportConfig, payload),
+ "paged-media"
+ )
+ ];
+ let previewer = new Previewer();
this.activePreviewer = previewer;
- const flow = await previewer.preview(
+ let flow = await previewer.preview(
content,
- [
- stylesheet(documentRef, documentBaseCss, "document-base"),
- stylesheet(documentRef, highlightCss, "highlight"),
- stylesheet(documentRef, katexCss, "katex"),
- stylesheet(documentRef, echartsCss, "echarts"),
- stylesheet(documentRef, payload.themeCss, "theme"),
- stylesheet(
- documentRef,
- documentGeometryCss,
- "document-geometry"
- ),
- stylesheet(
- documentRef,
- buildPagedMediaCss(payload.exportConfig, payload),
- "paged-media"
- )
- ],
+ stylesheets,
this.root
);
+ 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
+ ]
+ )
+ );
+ }
const paginationMs = performance.now() - paginationStartedAt;
if (!shouldContinue()) {
diff --git a/apps/web/src/sample-markdown.ts b/apps/web/src/sample-markdown.ts
new file mode 100644
index 0000000..aa908be
--- /dev/null
+++ b/apps/web/src/sample-markdown.ts
@@ -0,0 +1,194 @@
+const wideImageBase64 =
+ "iVBORw0KGgoAAAANSUhEUgAABkAAAAOEAQMAAADDg2/hAAAABlBMVEVPRuUOpntYb4NEAAAB+klEQVR42u3bAQEAMAiAMPuXvjm4WwMCMAMAAAAAAAAAAAAAAHHvE0KECBEiRIgQIUKECBEiRIgQIUKECBEiRIgQIUKECBEiRIgQIUKECBEiRIgQIUKECBEi5G4IAAAAAAAAAAAAAADUuRWECBEiRIgQIUKECBEiRIgQIUKECBEiRIgQIUKECBEiRIgQIUKECBEiRIgQIUKECBEiREg9BAAAAAAAAAAAAAAA6twKQoQIESJEiBAhQoQIESJEiBAhQoQIESJEiBAhQoQIESJEiBAhQoQIESJEiBAhQoQIESKkHgIAAAAAAAAAAAAAAHVuBSFChAgRIkSIECFChAgRIkSIECFChAgRIkSIECFChAgRIkSIECFChAgRIkSIECFChAgRUg8BAAAAAAAAAAAAAIA6t4IQIUKECBEiRIgQIUKECBEiRIgQIUKECBEiRIgQIUKECBEiRIgQIUKECBEiRIgQIUKECKmHAAAAAAAAAAAAAABAnVtBiBAhQoQIESJEiBAhQoQIESJEiBAhQoQIESJEiBAhQoQIESJEiBAhQoQIESJEiBAhQoTUQwAAAAAAAAAAAAAAoM6tIESIECFChAgRIkSIECFChAgRIkSIECFChAgRIkSIECFChAgRIkSIECFChAgRIkSIECFC6iEAAAAAAAAAAAAAABC3woXq+yBn0OwAAAAASUVORK5CYII=";
+const portraitImageBase64 =
+ "iVBORw0KGgoAAAANSUhEUgAAA4QAAAcIAQMAAABevZ3IAAAABlBMVEUPdm5e6tS6V5BUAAAB3ElEQVR42u3NMQ0AAAgDsPk3DQ52cpDWQBMAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACo5prRaDQajUaj0Wg0Go1Go9FoNBqNRqPRaDQajUaj0Wg0Go1Go9FoNBqNRqPRaDQajUaj0Wg0Go1Go9FoNBqNRqPRaDQajUaj0Wg0Go1Go9FoNBqNRqPRaDQajUaj0Wg0Go1Go9FoNBqNRqPRaDQajUaj0Wg0Go1Go9FoNBqNRqPRaDQajUaj0Wg0Go1Go9FoNBqNRqPRaDQajUaj0Wg0Go1Go9FoNBqNRqPRaDQajUaj0Wg0Go1Go9FoNBqNRqPRaDQajUaj0Wg0Go1Go9FoNBqNRqPRaDQajUaj0Wg0Go1Go9FoNBqNRqPRaDQajUaj0Wg0Go1Go9FoNBqNRqPRaDQajUaj0Wg0Go1Go9FoNBqNRqPRaDQajUaj0Wg0Go1Go9FoNBqNRqPRaDQajUaj0Wg0Go1Go9FoNBqNRqPRaDQajUaj0Wg0Go1Go9FoNBqNRqPRaDQajUaj0Wg0fhoXJUddU5DUXZIAAAAASUVORK5CYII=";
+const boundaryImageBase64 =
+ "iVBORw0KGgoAAAANSUhEUgAABLAAAANwAQMAAAAFoAG2AAAABlBMVEXCQQz7vyQwzQKmAAACUElEQVR42u3aoREAQAgDQZr8JqnyDXhkxOJwqzNXb67nMv7CwsLCwsLCwsLCwsLCwsLCwsI6sbI4+2NhYWFhYWFhYWFhYWFhYWFhYd1Y1kAsLCwsLCwsLCwsLCwsLCwsLP2WkRILCwsLCwsLCwsLCwsLCwsLS79lpMTCwsLCwsLCwsLCwsLCwsLC0m9hYWFhYWFhYWFhYWFhYWFhYem3rIFYWFhYWFhYWFhYWFhYWFhYWPotIyUWFhYWFhYWFhYWFhYWFhYWln4LCwsLCwsLCwsLCwsLCwsLCwtLv4WFhYWFhYWFhYWFhYWFhYWFpd+yBmJhYWFhYWFhYWFhYWFhYWFh6beMlFhYWFhYWFhYWFhYWFhYWFhY+i0sLCwsLCwsLCwsLCwsLCwsLCz9FhYWFhYWFhYWFhYWFhYWFhaWfstIiYWFhYWFhYWFhYWFhYWFhYWl3zJSYmFhYWFhYWFhYWFhYWFhYWHpt7CwsLCwsLCwsLCwsLCwsLCw9FvWQCwsLCwsLCwsLCwsLCwsLCws/ZaREgsLCwsLCwsLCwsLCwsLCwtLv4WFhYWFhYWFhYWFhYWFhYWFhaXfwsLCwsLCwsLCwsLCwsLCwsLSb1kDsbCwsLCwsLCwsLCwsLCwsLD0W0ZKLCwsLCwsLCwsLCwsLCwsLCz9FhYWFhYWFhYWFhYWFhYWFhYWln4LCwsLCwsLCwsLCwsLCwsLC0u/ZaTEwsLCwsLCwsLCwsLCwsLCwtJvGSmxsLCwsLCwsLCwsLCwsLCwsPRbWFhYWFhYWFiRrA8Rs81BfKlBmwAAAABJRU5ErkJggg==";
+
+export const sampleMarkdown = `---
+title: Markdown PDF 媒体分页示例
+author: 内网文档团队
+keywords:
+ - Markdown
+ - PDF
+ - 媒体分页
+---
+
+# Markdown PDF 媒体分页示例
+
+这份文档集中展示常用排版、不同尺寸图片、Mermaid 与 ECharts 的渲染和分页效果。
+
+## 基础排版
+
+- [x] Markdown 实时预览
+- [x] 图片、Mermaid 与 ECharts
+- [x] 快速预览和精确 PDF 预览
+- [ ] 根据实际文档继续扩展主题
+
+| 能力 | 示例用途 |
+| --- | --- |
+| 表格与任务列表 | 验证主题基础样式 |
+| 数学公式 | 验证字体和基线 |
+| 媒体块 | 验证限宽、缩放与分页 |
+
+行内公式:$E = mc^2$。
+
+> 预览与 PDF 共用相同的渲染结果、主题 CSS 和分页结构。
+
+## 网络图片
+
+下面只保留一张网络图片,用于验证远程资源获取与离线失败提示。
+
+
+
+## Base64 图片块
+
+### 横向图片:限宽基准
+
+1600 × 900 的横图会先按正文宽度等比例收缩,收缩后的高度才是分页算法使用的基准高度。
+
+![Base64 横向图片 1600×900][base64-wide]
+
+### 边界图片:上一页空白回填
+
+这一段用于形成稳定的分页边界。分页算法会保留标题、图注和间距的原始尺寸,只计算图片主体还能使用的高度。如果主体只需轻微缩小,并且缩放率仍在允许范围内,就尝试回填到上一页空白区域。
+
+为了覆盖长文档场景,这里继续补充少量正文,使媒体块靠近默认 A4 页面底部。媒体元素必须按照源文档顺序串行处理,前一个媒体完成重排以后,后一个媒体才基于新的版面重新计算。
+
+![Base64 边界图片 1200×880][base64-boundary]
+
+### 纵向图片:单页高度适配
+
+900 × 1800 的纵图在限宽后仍可能超过单页内容区,此时应继续等比例缩小并完整放在一页内。
+
+![Base64 纵向图片 900×1800][base64-portrait]
+
+## Mermaid 尺寸示例
+
+### 横向流程图:限宽
+
+\`\`\`mermaid
+flowchart LR
+ A["Markdown"] --> B["统一 HTML"]
+ B --> C["主题 CSS"]
+ C --> D["网页预览"]
+ C --> E["PDF 导出"]
+\`\`\`
+
+### 纵向流程图:高度和分页
+
+\`\`\`mermaid
+flowchart TB
+ A["读取文档"] --> B["解析 Front Matter"]
+ B --> C["渲染 Markdown"]
+ C --> D["渲染 Mermaid"]
+ D --> E["渲染 ECharts"]
+ E --> F["等待图片与字体"]
+ F --> G["首次分页"]
+ G --> H["串行检查媒体块"]
+ H --> I["必要时重新分页"]
+ I --> J["生成 PDF"]
+\`\`\`
+
+## ECharts 尺寸示例
+
+### 紧凑图表:40mm
+
+\`\`\`echarts
+caption: 紧凑柱状图(40mm)
+height: 40mm
+option:
+ xAxis:
+ type: category
+ data: [研发, 产品, 设计, 运营]
+ yAxis:
+ type: value
+ series:
+ - type: bar
+ data: [28, 18, 15, 22]
+\`\`\`
+
+### 标准图表:58mm
+
+\`\`\`echarts
+caption: 产品能力树(58mm)
+height: 58mm
+option:
+ series:
+ - type: tree
+ orient: LR
+ label:
+ position: left
+ data:
+ - name: 文档平台
+ children:
+ - name: Markdown
+ children:
+ - name: Mermaid
+ - name: ECharts
+ - name: PDF
+ children:
+ - name: 快速预览
+ - name: 精确预览
+\`\`\`
+
+大型图表的 caption 保持原尺寸,仅缩放图表主体。
+
+### 大型图表:85mm
+
+\`\`\`echarts
+caption: 模块工作量占比(85mm)
+height: 85mm
+option:
+ series:
+ - type: treemap
+ roam: false
+ nodeClick: false
+ breadcrumb:
+ show: false
+ label:
+ show: true
+ formatter: "{b}"
+ data:
+ - name: 渲染
+ children:
+ - { name: Markdown, value: 32 }
+ - { name: ECharts, value: 24 }
+ - { name: Mermaid, value: 18 }
+ - name: 导出
+ children:
+ - { name: PDF, value: 28 }
+ - { name: 主题, value: 12 }
+\`\`\`
+
+## 串行媒体块
+
+下面连续放置图片、Mermaid 和 ECharts,用于观察长文档中多个媒体元素是否始终按源文档顺序计算。
+
+![Base64 串行边界图片][base64-boundary]
+
+\`\`\`mermaid
+flowchart LR
+ A["图片完成"] --> B["Mermaid 计算"]
+ B --> C["ECharts 计算"]
+\`\`\`
+
+\`\`\`echarts
+caption: 串行媒体收尾图(58mm)
+height: 58mm
+option:
+ series:
+ - type: pie
+ radius: [35%, 70%]
+ data:
+ - { name: 图片, value: 36 }
+ - { name: Mermaid, value: 28 }
+ - { name: ECharts, value: 36 }
+\`\`\`
+
+> 所有媒体仅在额外缩放不超过阈值时回填;否则保持完整媒体块并移动到下一页。
+
+[base64-wide]: data:image/png;base64,${wideImageBase64}
+[base64-portrait]: data:image/png;base64,${portraitImageBase64}
+[base64-boundary]: data:image/png;base64,${boundaryImageBase64}
+`;
diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css
index a9cac79..7fef0ce 100644
--- a/apps/web/src/styles.css
+++ b/apps/web/src/styles.css
@@ -24,8 +24,7 @@ textarea {
font: inherit;
}
-button,
-.file-button {
+button {
min-height: 40px;
padding: 0 16px;
border: 1px solid #b8c2bd;
@@ -102,13 +101,31 @@ button:disabled {
border-left: 1px solid #d4dad7;
}
-.document-summary > strong {
+.document-name-row {
+ display: flex;
+ align-items: center;
+ gap: 7px;
+ min-width: 0;
+}
+
+.document-name-row > strong {
overflow: hidden;
font-size: 0.82rem;
text-overflow: ellipsis;
white-space: nowrap;
}
+.unsaved-badge {
+ flex: none;
+ padding: 1px 6px;
+ border: 1px solid #d9bd86;
+ border-radius: 999px;
+ background: #fff7e7;
+ color: #8a5a13;
+ font-size: 0.62rem;
+ font-weight: 800;
+}
+
.document-summary .render-status {
max-width: 100%;
margin-top: 3px;
@@ -138,17 +155,37 @@ h1 {
gap: 10px;
}
-.file-button {
- display: inline-flex;
- align-items: center;
+.more-menu {
+ position: relative;
}
-.file-button input {
+.more-menu-popover {
position: absolute;
- width: 1px;
- height: 1px;
- overflow: hidden;
- clip: rect(0 0 0 0);
+ z-index: 30;
+ top: calc(100% + 8px);
+ right: 0;
+ display: flex;
+ min-width: 220px;
+ padding: 6px;
+ border: 1px solid #c8d1cc;
+ border-radius: 10px;
+ background: #fff;
+ box-shadow: 0 12px 30px rgb(38 58 50 / 18%);
+ flex-direction: column;
+}
+
+.more-menu-popover button {
+ width: 100%;
+ min-height: 36px;
+ padding: 0 11px;
+ border: 0;
+ background: transparent;
+ text-align: left;
+}
+
+.more-menu-popover button:hover,
+.more-menu-popover button:focus-visible {
+ background: #edf3f0;
}
.editor-layout {
diff --git a/apps/web/src/use-theme-resources.ts b/apps/web/src/use-theme-resources.ts
index 446af27..9141c31 100644
--- a/apps/web/src/use-theme-resources.ts
+++ b/apps/web/src/use-theme-resources.ts
@@ -9,7 +9,11 @@ export type { ThemeSummary } from "./application-backend";
export function useThemeResources(
themeId: string,
- onThemesLoaded: (themes: ThemeSummary[]) => void
+ onThemesLoaded: (
+ themes: ThemeSummary[],
+ refreshKey: number
+ ) => void,
+ refreshKey = 0
) {
const [themes, setThemes] = useState([]);
const [themeCss, setThemeCss] = useState("");
@@ -23,7 +27,7 @@ export function useThemeResources(
.then(({ themes: availableThemes }) => {
setThemes(availableThemes);
setCatalogError("");
- onThemesLoaded(availableThemes);
+ onThemesLoaded(availableThemes, refreshKey);
})
.catch((reason: unknown) => {
if (!controller.signal.aborted) {
@@ -34,7 +38,7 @@ export function useThemeResources(
});
return () => controller.abort();
- }, [onThemesLoaded]);
+ }, [onThemesLoaded, refreshKey]);
useEffect(() => {
const controller = new AbortController();
@@ -52,7 +56,7 @@ export function useThemeResources(
});
return () => controller.abort();
- }, [themeId]);
+ }, [refreshKey, themeId]);
return {
error: catalogError || cssError,
diff --git a/apps/web/src/vite-env.d.ts b/apps/web/src/vite-env.d.ts
index 1da88e4..153b483 100644
--- a/apps/web/src/vite-env.d.ts
+++ b/apps/web/src/vite-env.d.ts
@@ -24,6 +24,22 @@ declare global {
}
| undefined
>;
+ consumePendingMarkdown(): Promise<
+ | {
+ markdown: string;
+ fileName: string;
+ }
+ | undefined
+ >;
+ discardPendingMarkdown(): Promise;
+ onMarkdownOpened(listener: () => void): () => void;
+ startNewMarkdown(): Promise;
+ saveMarkdown(
+ fileName: string,
+ markdown: string
+ ): Promise<{ fileName: string } | undefined>;
+ openThemeDirectory(): Promise;
+ refreshThemes(): Promise;
renderMarkdown(input: {
markdown: string;
language: string;
diff --git a/apps/web/tests/app-version.test.ts b/apps/web/tests/app-version.test.ts
index 52416d0..ae1cb1f 100644
--- a/apps/web/tests/app-version.test.ts
+++ b/apps/web/tests/app-version.test.ts
@@ -6,7 +6,7 @@ import {
describe("应用版本", () => {
it("从统一构建版本生成标题徽标", () => {
- expect(APP_VERSION).toBe("0.4.4");
- expect(APP_VERSION_LABEL).toBe("v0.4.4");
+ expect(APP_VERSION).toBe("0.4.5");
+ expect(APP_VERSION_LABEL).toBe("v0.4.5");
});
});
diff --git a/apps/web/tests/document-file.test.ts b/apps/web/tests/document-file.test.ts
new file mode 100644
index 0000000..c8b65b8
--- /dev/null
+++ b/apps/web/tests/document-file.test.ts
@@ -0,0 +1,62 @@
+import { describe, expect, it } from "vitest";
+import {
+ createMarkdownFileName,
+ deriveMarkdownBaseName,
+ ensureMarkdownFileName,
+ legalizeMarkdownBaseName
+} from "../src/document-file";
+
+describe("Markdown 默认文件名", () => {
+ it("优先使用 Front Matter 与代码围栏之外的一级标题", () => {
+ expect(
+ deriveMarkdownBaseName(`---
+title: 元数据标题
+---
+第一行正文
+
+\`\`\`markdown
+# 围栏标题
+\`\`\`
+
+# 2026 年研发周报:第 31 周`)
+ ).toBe("2026 年研发周报 第 31 周");
+ });
+
+ it("没有一级标题时使用第一行非空正文", () => {
+ expect(
+ createMarkdownFileName(
+ "\n\n> **本周完成** [API 重构](https://example.com)\n后续内容"
+ )
+ ).toBe("本周完成 API 重构.md");
+ });
+
+ it("四字符围栏不会被较短的同类围栏提前关闭", () => {
+ expect(
+ deriveMarkdownBaseName(`\`\`\`\`markdown
+# 围栏内标题
+\`\`\`
+# 仍在围栏内
+\`\`\`\`
+# 正文标题`)
+ ).toBe("正文标题");
+ });
+
+ it("清理非法字符并规避 Windows 保留名称", () => {
+ expect(legalizeMarkdownBaseName('季度总结:<测试> / "版本"')).toBe(
+ "季度总结 测试 版本"
+ );
+ expect(legalizeMarkdownBaseName("CON")).toBe("文档 CON");
+ });
+
+ it("空文档使用回退文件名并限制名称长度", () => {
+ expect(createMarkdownFileName(" \n")).toBe("未命名文档.md");
+ expect(legalizeMarkdownBaseName("文".repeat(150))).toHaveLength(100);
+ });
+
+ it("保留已有 Markdown 扩展名", () => {
+ expect(ensureMarkdownFileName("报告.markdown", "# 报告")).toBe(
+ "报告.markdown"
+ );
+ expect(ensureMarkdownFileName("报告", "# 报告")).toBe("报告.md");
+ });
+});
diff --git a/apps/web/tests/echarts-page-fit.test.ts b/apps/web/tests/echarts-page-fit.test.ts
index 6255e6c..6cdddb9 100644
--- a/apps/web/tests/echarts-page-fit.test.ts
+++ b/apps/web/tests/echarts-page-fit.test.ts
@@ -8,6 +8,7 @@ describe("ECharts 单页高度适配", () => {
it("将超高 SVG 和宿主同步缩放到单页内容区", () => {
const root = document.createElement("div");
root.innerHTML = `
+ 大型图表
@@ -15,22 +16,43 @@ describe("ECharts 单页高度适配", () => {
`;
- fitOversizedEChartsToPage(root, defaultExportConfig);
-
const figure = root.querySelector
(".md-echarts");
+ const title = root.querySelector("h2");
const host = root.querySelector(
".md-echarts-host"
);
const svg = root.querySelector("svg");
+ if (title) {
+ title.getBoundingClientRect = () => ({
+ x: 0,
+ y: 0,
+ top: 0,
+ left: 0,
+ right: 700,
+ bottom: 40,
+ width: 700,
+ height: 40,
+ toJSON: () => ({})
+ });
+ }
+ fitOversizedEChartsToPage(root, defaultExportConfig);
+
expect(figure?.dataset.pageHeightFitted).toBe("true");
+ expect(title?.style.breakAfter).toBe("avoid-page");
+ expect(figure?.style.breakAfter).toBe("page");
+ expect(figure?.style.pageBreakAfter).toBe("always");
+ expect(figure?.nextElementSibling).toHaveProperty(
+ "dataset.mediaPageBreak",
+ "true"
+ );
expect(Number.parseFloat(host?.style.height ?? "0")).toBeCloseTo(
- 1000.5748,
+ 960.5748,
3
);
expect(host?.style.aspectRatio).toBe("");
expect(svg?.getAttribute("viewBox")).toBe("0 0 1000 2000");
expect(Number.parseFloat(svg?.style.height ?? "0")).toBeCloseTo(
- 1000.5748,
+ 960.5748,
3
);
});
diff --git a/apps/web/tests/media-page-backfill.test.ts b/apps/web/tests/media-page-backfill.test.ts
new file mode 100644
index 0000000..60613b2
--- /dev/null
+++ b/apps/web/tests/media-page-backfill.test.ts
@@ -0,0 +1,329 @@
+// @vitest-environment happy-dom
+
+import { describe, expect, it } from "vitest";
+import {
+ applyMediaBackfillCandidates,
+ calculateMediaBackfillScale,
+ findMediaBackfillCandidates,
+ getMediaBackfillIdsInDocumentOrder,
+ prepareMediaBackfillBlocks
+} from "../src/media-page-backfill";
+import { getPrecedingMediaTitleHeight } from "../src/diagram-page-fit";
+
+function rect(width: number, height: number) {
+ return {
+ x: 0,
+ y: 0,
+ top: 0,
+ left: 0,
+ right: width,
+ bottom: height,
+ width,
+ height,
+ toJSON: () => ({})
+ };
+}
+
+function positionedRect(
+ top: number,
+ width: number,
+ height: number
+) {
+ return {
+ ...rect(width, height),
+ y: top,
+ top,
+ bottom: top + height
+ };
+}
+
+describe("媒体分页空白回填", () => {
+ it("全页适配时保留前置标题高度并避免标题孤行", () => {
+ const root = document.createElement("div");
+ root.innerHTML = `
+ 媒体标题
+
+ `;
+ const title = root.querySelector("h2")!;
+ const block = root.querySelector(".mermaid")!;
+ title.getBoundingClientRect = () =>
+ positionedRect(100, 600, 40);
+
+ expect(getPrecedingMediaTitleHeight(block)).toBe(40);
+ expect(title.style.getPropertyValue("break-after")).toBe(
+ "avoid-page"
+ );
+ expect(title.style.getPropertyPriority("break-after")).toBe(
+ "important"
+ );
+ });
+
+ it("按限宽后的基准高度计算额外缩放率", () => {
+ expect(
+ calculateMediaBackfillScale({
+ remainingHeight: 420,
+ fixedHeight: 30,
+ baselineVisualHeight: 430
+ })
+ ).toBeCloseTo(388 / 430, 6);
+ });
+
+ it("缩小超过阈值或无需缩小时不回填", () => {
+ expect(
+ calculateMediaBackfillScale({
+ remainingHeight: 300,
+ fixedHeight: 30,
+ baselineVisualHeight: 430
+ })
+ ).toBeUndefined();
+ expect(
+ calculateMediaBackfillScale({
+ remainingHeight: 500,
+ fixedHeight: 30,
+ baselineVisualHeight: 430
+ })
+ ).toBeUndefined();
+ });
+
+ it("记录图片限宽后的实际基准尺寸", () => {
+ const root = document.createElement("div");
+ root.innerHTML = `
+
+
+ 标题
+
+ `;
+ const image = root.querySelector("img")!;
+ image.getBoundingClientRect = () => rect(640, 480);
+
+ expect(prepareMediaBackfillBlocks(root)).toBe(1);
+ const figure = root.querySelector("figure")!;
+ expect(figure.dataset.mediaBackfillKind).toBe("image");
+ expect(figure.dataset.mediaBaselineWidth).toBe("640");
+ expect(figure.dataset.mediaBaselineHeight).toBe("480");
+ });
+
+ it("保持媒体元素在源文档中的串行处理顺序", () => {
+ const root = document.createElement("div");
+ root.innerHTML = `
+
+
+
+ `;
+
+ expect(getMediaBackfillIdsInDocumentOrder(root)).toEqual([
+ "media-2",
+ "media-1"
+ ]);
+ });
+
+ it("根据上一页剩余区域生成一次回填候选", () => {
+ const root = document.createElement("div");
+ root.innerHTML = `
+
+
+
+
图片标题
+
+
+
+
+
+ `;
+ const pageContents = root.querySelectorAll(
+ ".pagedjs_page_content"
+ );
+ const previousFlow =
+ pageContents[0]!.firstElementChild as HTMLElement;
+ const title = root.querySelector("h2")!;
+ const block = root.querySelector("figure")!;
+ const image = root.querySelector("img")!;
+ pageContents[0]!.getBoundingClientRect = () =>
+ positionedRect(100, 700, 1_000);
+ previousFlow.getBoundingClientRect = () =>
+ positionedRect(100, 700, 550);
+ pageContents[1]!.getBoundingClientRect = () =>
+ positionedRect(100, 700, 1_000);
+ title.getBoundingClientRect = () =>
+ positionedRect(100, 600, 40);
+ block.getBoundingClientRect = () =>
+ positionedRect(150, 600, 330);
+ image.getBoundingClientRect = () =>
+ positionedRect(150, 600, 300);
+
+ expect(findMediaBackfillCandidates(root)).toEqual([
+ { id: "media-1", scale: 0.92 }
+ ]);
+ });
+
+ it("显式分页的媒体块不会回填到上一页", () => {
+ const root = document.createElement("div");
+ root.innerHTML = `
+
+
+
+
+ 图片标题
+
+
+
+
+
+
+ `;
+ const pageContents = root.querySelectorAll(
+ ".pagedjs_page_content"
+ );
+ const title = root.querySelector("h2")!;
+ const block = root.querySelector("figure")!;
+ const image = root.querySelector("img")!;
+ pageContents[0]!.getBoundingClientRect = () =>
+ positionedRect(100, 700, 1_000);
+ pageContents[1]!.getBoundingClientRect = () =>
+ positionedRect(100, 700, 1_000);
+ title.getBoundingClientRect = () =>
+ positionedRect(100, 600, 40);
+ block.getBoundingClientRect = () =>
+ positionedRect(150, 600, 330);
+ image.getBoundingClientRect = () =>
+ positionedRect(150, 600, 300);
+
+ expect(findMediaBackfillCandidates(root)).toEqual([]);
+ });
+
+ it("忽略媒体之前其他内容的显式分页规则", () => {
+ const root = document.createElement("div");
+ root.innerHTML = `
+
+
+
+
+ 本页章节
+
+
图片标题
+
+
+
+
+
+ `;
+ const pageContents = root.querySelectorAll(
+ ".pagedjs_page_content"
+ );
+ const previousFlow = root.querySelector(
+ "[data-ref='previous-content']"
+ )!;
+ const earlierHeading = root.querySelector("h1")!;
+ const title = root.querySelector("h2")!;
+ const block = root.querySelector("figure")!;
+ const image = root.querySelector("img")!;
+ pageContents[0]!.getBoundingClientRect = () =>
+ positionedRect(100, 700, 1_000);
+ previousFlow.getBoundingClientRect = () =>
+ positionedRect(100, 700, 550);
+ pageContents[1]!.getBoundingClientRect = () =>
+ positionedRect(100, 700, 1_000);
+ earlierHeading.getBoundingClientRect = () =>
+ positionedRect(100, 600, 40);
+ title.getBoundingClientRect = () =>
+ positionedRect(150, 600, 40);
+ block.getBoundingClientRect = () =>
+ positionedRect(200, 600, 330);
+ image.getBoundingClientRect = () =>
+ positionedRect(200, 600, 300);
+
+ expect(findMediaBackfillCandidates(root)).toEqual([
+ { id: "media-1", scale: 0.92 }
+ ]);
+ });
+
+ it("将候选缩放应用到图片和 ECharts 主体", () => {
+ const root = document.createElement("div");
+ root.innerHTML = `
+ 图片标题保持原尺寸
+
+
+
+
+
+
+
+
+
+ `;
+
+ expect(
+ applyMediaBackfillCandidates(root, [
+ { id: "media-1", scale: 0.9 },
+ { id: "media-2", scale: 0.88 },
+ { id: "media-3", scale: 0.9 }
+ ])
+ ).toBe(3);
+ const title = root.querySelector("h2")!;
+ const image = root.querySelector(
+ "img.md-document-image"
+ )!;
+ const host = root.querySelector(".md-echarts-host")!;
+ const svg = root.querySelector("svg")!;
+ const mermaidSvg = root.querySelector(
+ ".mermaid svg"
+ )!;
+ expect(title.getAttribute("style")).toBeNull();
+ expect(image.style.width).toBe("720px");
+ expect(image.style.height).toBe("540px");
+ expect(host.style.width).toBe("616px");
+ expect(host.style.height).toBe("440px");
+ expect(svg.style.width).toBe("616px");
+ expect(svg.style.height).toBe("440px");
+ expect(mermaidSvg.style.width).toBe("540px");
+ expect(mermaidSvg.style.height).toBe("360px");
+ });
+});
diff --git a/apps/web/tests/sample-markdown.test.ts b/apps/web/tests/sample-markdown.test.ts
new file mode 100644
index 0000000..e71895b
--- /dev/null
+++ b/apps/web/tests/sample-markdown.test.ts
@@ -0,0 +1,81 @@
+import { inflateSync } from "node:zlib";
+import { describe, expect, it } from "vitest";
+import { sampleMarkdown } from "../src/sample-markdown";
+
+function readPngDimensions(base64: string) {
+ const png = Buffer.from(base64, "base64");
+ expect(png.subarray(0, 8)).toEqual(
+ Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])
+ );
+ const width = png.readUInt32BE(16);
+ const height = png.readUInt32BE(20);
+ const bitDepth = png[24] ?? 0;
+ const colorType = png[25] ?? 0;
+ const samplesPerPixel =
+ colorType === 0 || colorType === 3
+ ? 1
+ : colorType === 2
+ ? 3
+ : colorType === 4
+ ? 2
+ : 4;
+ const idatParts: Buffer[] = [];
+ let offset = 8;
+ while (offset + 12 <= png.length) {
+ const length = png.readUInt32BE(offset);
+ const type = png.toString("ascii", offset + 4, offset + 8);
+ if (type === "IDAT") {
+ idatParts.push(
+ png.subarray(offset + 8, offset + 8 + length)
+ );
+ }
+ offset += 12 + length;
+ }
+ const decoded = inflateSync(Buffer.concat(idatParts));
+ const rowBytes = Math.ceil(
+ (width * bitDepth * samplesPerPixel) / 8
+ );
+ expect(decoded.length).toBe((rowBytes + 1) * height);
+ return { width, height };
+}
+
+describe("默认示例 Markdown", () => {
+ it("仅保留一张网络图片并提供三种 Base64 图片尺寸", () => {
+ expect(
+ sampleMarkdown.match(/https?:\/\/[^\s)]+/gu)
+ ).toHaveLength(1);
+ expect(
+ sampleMarkdown.match(/data:image\/png;base64,/gu)
+ ).toHaveLength(3);
+ expect(sampleMarkdown).toContain("1600×900");
+ expect(sampleMarkdown).toContain("900×1800");
+ expect(sampleMarkdown).toContain("1200×880");
+ });
+
+ it("所有内嵌 PNG 均可完整解压且尺寸符合描述", () => {
+ const images = Array.from(
+ sampleMarkdown.matchAll(
+ /data:image\/png;base64,([A-Za-z0-9+/=]+)/gu
+ ),
+ (match) => readPngDimensions(match[1] ?? "")
+ );
+ expect(images).toEqual([
+ { width: 1600, height: 900 },
+ { width: 900, height: 1800 },
+ { width: 1200, height: 880 }
+ ]);
+ });
+
+ it("覆盖 Mermaid、ECharts 分档和串行媒体场景", () => {
+ expect(
+ sampleMarkdown.match(/```mermaid/gu)
+ ).toHaveLength(3);
+ expect(
+ sampleMarkdown.match(/```echarts/gu)
+ ).toHaveLength(4);
+ expect(sampleMarkdown).toContain("height: 40mm");
+ expect(sampleMarkdown).toContain("height: 58mm");
+ expect(sampleMarkdown).toContain("height: 85mm");
+ expect(sampleMarkdown).toContain("## 串行媒体块");
+ });
+});
diff --git a/deploy/README.md b/deploy/README.md
index 396a04c..58717c6 100644
--- a/deploy/README.md
+++ b/deploy/README.md
@@ -37,7 +37,7 @@ docker compose -f deploy\compose.yaml down
可以直接指定版本化镜像名称构建:
```powershell
-$env:MD_TO_PDF_IMAGE = 'yixiong/md-to-pdf:v0.4.1'
+$env:MD_TO_PDF_IMAGE = 'yixiong/md-to-pdf:v0.4.5'
docker compose -f deploy\compose.yaml build
```
@@ -47,13 +47,13 @@ docker compose -f deploy\compose.yaml build
已经保留同版本系列的已验证镜像,可在内网或软件源较慢时复用其运行层:
```powershell
-$env:MD_TO_PDF_IMAGE = 'yixiong/md-to-pdf:v0.4.1'
+$env:MD_TO_PDF_IMAGE = 'yixiong/md-to-pdf:v0.4.5'
$env:MD_TO_PDF_RUNTIME_BASE_IMAGE = 'yixiong/md-to-pdf:v0.3.1'
$env:MD_TO_PDF_REUSE_PLAYWRIGHT_RUNTIME = '1'
docker compose -f deploy\compose.yaml build
```
-复用模式会校验基础镜像中存在 Chromium,再覆盖 v0.4.1 应用代码和生产
+复用模式会校验基础镜像中存在 Chromium,再覆盖 v0.4.5 应用代码和生产
依赖。正式跨机器构建仍建议使用默认完整路径。
## 主题挂载
diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md
index 32e2dd9..7f9c81e 100644
--- a/docs/PROGRESS.md
+++ b/docs/PROGRESS.md
@@ -1,6 +1,6 @@
# Markdown PDF 导出器进度
-最后更新:2026-07-27
+最后更新:2026-07-28
## 1. 当前概况
@@ -44,6 +44,11 @@ Web/Compose 发布仍保持 `v0.4.1`。
`v0.4.4` 仅精简 Electron 桌面发行包的语言资源,保留简体中文和英文;
Web/Compose 发布仍保持 `v0.4.1`。
+`v0.4.5` 同步更新 Web 与 Desktop:新增新建/保存 Markdown、桌面文件
+关联、窗口状态恢复、自定义主题目录和收纳式“更多”菜单;Web 明确取消
+本地素材目录。图片、Mermaid 与 ECharts 的分页算法统一为按文档顺序
+串行计算的媒体块回填,每个媒体元素至多重排一次,标题保持原尺寸。
+
## 2. 已完成
### 2.1 项目骨架
@@ -437,9 +442,37 @@ Web/Compose 发布仍保持 `v0.4.1`。
- 本版本只重新生成桌面安装包和免安装 ZIP;Web/Compose 发布继续保持
`v0.4.1`。
+### 3.18 v0.4.5 文档工作流与媒体分页
+
+- Web 与 Desktop 均支持新建 Markdown;未保存时使用“未命名文档”状态,
+ 不显示伪造源文件名,并允许直接导出 PDF。
+- Web 保存使用浏览器下载;Desktop 使用原生另存为。默认文件名优先取
+ 第一个一级标题,没有一级标题时取首行可见文本,并清理 Windows 非法
+ 字符、保留名和末尾空格或句点。
+- “打开 Markdown”继续作为顶部独立强操作;新建、保存及桌面自定义主题
+ 目录收纳到“更多”菜单,Web 不再显示本地素材目录入口。
+- Windows 安装包注册 `.md` 和 `.markdown` 文件关联;系统打开文件会
+ 转交给现有单实例窗口并自动加载。
+- Desktop 监听窗口位置、尺寸和最大化变化,延迟写入完整状态;退出前
+ 刷新待写状态。下次启动按当前显示器工作区限制尺寸并修正坐标,避免
+ 多显示器断开后窗口落在屏幕外。
+- Desktop 会建立自定义主题目录,“更多”菜单可直接在资源管理器打开;
+ 目录允许留空,放入合法主题后由共享主题注册器读取。
+- 图片、Mermaid 和 ECharts 统一视为带标题的媒体块。分页按 DOM 文档
+ 顺序串行处理,每个媒体元素只允许一次回填或缩放重排。
+- 图片基准高度按原尺寸先收缩到内容限宽后计算;媒体块只有在上一页空白
+ 与所需空间比例达到阈值时才尝试回填。缩放只作用于图片或图表内容,
+ 标题保持原字号和自然高度。
+- ECharts 在分页前冻结为 SVG 图片,三类媒体共享回填决策与边界检查;
+ 无法安全回填时仍移至下一页,保证顺序、不跨页且不覆盖后续内容。
+- 默认示例仅保留一张网络图片,并新增有效 Base64 横图、不同尺寸图片、
+ Mermaid 和 ECharts,用于观察媒体分页、标题配对和渲染效果。
+- 根 README、Desktop README、四个工作区包 README、进度文档和发布
+ 规范随版本同步更新。
+
## 4. 已执行验证
-2026-07-27 在当前完整工作区成功执行:
+2026-07-28 在当前完整工作区成功执行:
```text
npm test
@@ -452,16 +485,45 @@ git diff --check
- ECharts 围栏测试:36 项通过;
- 共享配置测试:9 项通过;
-- 渲染器测试:10 项通过;
-- 应用服务测试:5 项通过;
-- 前端测试:67 项通过;
+- 渲染器测试:12 项通过;
+- 应用服务测试:9 项通过;
+- 前端测试:87 项通过;
- 后端测试:29 项通过;
-- 桌面端测试:7 项通过;
-- 全项目共 171 项测试通过;
+- 桌面端测试:20 项通过;
+- 全项目共 202 项测试通过;
- 全项目类型检查通过;
- 生产构建通过;
- `git diff --check` 通过。
+`v0.4.5` 双端与媒体分页验收结果:
+
+- 正式镜像 `yixiong/md-to-pdf:v0.4.5` 已完成多阶段构建,镜像内容 ID
+ 为 `sha256:0703c3762cd0e4e551ce44732b064a938c2095eb331a22e28861a148fe520354`;
+ Compose 已强制替换为正式镜像并健康运行在 `http://localhost:8080`。
+- 正式镜像 PDF 冒烟请求返回 HTTP 200,生成 1 页、96,661 字节 PDF,
+ Mermaid 与 ECharts 错误数均为 0。
+- 默认示例在 Web 快速预览与精确预览中均为 10 页;12 个媒体块均只
+ 出现一次,全部标题正确配对,无重叠、越界、解码或图表错误。
+- 容器生成 `output/pdf/v0.4.5-media-pagination-rc.pdf`,共 10 页、
+ 928,803 字节;Mermaid 与 ECharts 错误数均为 0,逐页视觉检查通过。
+- Desktop 新建、输入、默认标题文件名保存、原生 PDF 导出均通过;
+ `output/桌面保存验收.md` 内容正确,Desktop README 导出 PDF 为 5 页。
+- 从第二实例系统打开 `README.md` 时保持单窗口并自动加载;自定义主题
+ 目录能够打开且可识别已有主题。
+- 窗口最大化变化约 2 秒后写入状态;重启恢复最大化。窗口位置与尺寸
+ 测试覆盖屏幕越界修正、最小尺寸、原子写入和损坏状态回退。
+- 正式 NSIS 安装包为 `md-to-pdf-0.4.5-x86_64-Setup.exe`,
+ 94,998,489 字节,SHA-256 为
+ `9CBD6362E15AA745185805E753D5A7749434E0E142E6CE0F66463E6A19468F1D`。
+- 免安装包为 `md-to-pdf-0.4.5-x86_64.zip`,132,477,245 字节,
+ SHA-256 为
+ `97B2D44E72F626A3396E5AB5B330FFB7F9BECAF945A1707808C876940A82E850`。
+- 正式目录版主程序版本为 `0.4.5`,只包含 `zh-CN` 和 `en-US` 语言包,
+ Authenticode 状态为未签名,启动后应用与渲染进程均正常响应。
+- 本机当前用户安装已从 `0.4.3` 原位升级到 `0.4.5`;卸载注册信息、
+ 桌面与开始菜单快捷方式、`.md` 和 `.markdown` 打开注册均验证通过,
+ 安装版应用启动正常。
+
Electron 桌面端第一阶段验证结果:
- Electron 43.2.0 应用窗口成功加载现有 React/Vite 界面,标题旁正确显示
@@ -678,6 +740,10 @@ ECharts 第二阶段浏览器与 PDF 验证结果:
## 5. 当前注意事项
- `output/` 可能包含本地 PDF 验证产物,已被 Git 忽略,不得提交。
+- 已确认分页预览 iframe 内点击超链接会在 iframe 内执行导航,可能将当前
+ 预览替换为空白页。该问题不阻塞 `v0.4.5`,计划在 `v0.4.6` 统一处理:
+ 区分文档内锚点与外部链接、阻止危险协议,并为外部链接使用受控的新窗口
+ 或系统浏览器打开策略。
- `apps/web/src/App.tsx` 负责分页 iframe 生命周期和父页面消息处理。
- `apps/web/src/use-markdown-render.ts` 负责 Markdown 防抖请求、取消和
渲染错误状态。
@@ -720,12 +786,22 @@ ECharts 第二阶段浏览器与 PDF 验证结果:
目录读取;远程图片仅保留有界内存缓存。
- 桌面端已通过进程内共享服务实现离线 Markdown 渲染、主题读取和 PDF
导出,不依赖 Fastify 或本地 HTTP Server。
-- v0.4.0 AIO 容器已经通过 Docker Desktop 构建、端到端和浏览器验证,
- 当前 Compose 容器保持运行。
+- `v0.4.5` AIO 正式镜像已经通过 Docker Desktop 构建、PDF 冒烟和
+ Compose 健康检查,当前 Compose 容器保持运行。
## 6. 推荐接手顺序
-### 阶段一:导出配置扩展
+### 阶段一:v0.4.6 超链接导航
+
+- 在快速预览 iframe 中统一拦截链接点击,避免外部页面替换分页预览;
+- 文档内 `#anchor` 保持在当前预览中定位,并兼容分页后的标题锚点;
+- `http:`、`https:`、`mailto:` 等允许协议通过受控新窗口或桌面系统
+ 浏览器打开;
+- 拒绝 `javascript:`、`file:`、未知协议及未授权本地资源;
+- Web 与 Desktop 共用链接分类规则,各平台只实现打开动作适配;
+- 增加快速预览、精确预览和 Desktop 的链接行为测试。
+
+### 阶段二:导出配置扩展
- Front Matter 元数据覆盖;
- 浏览器本地配置预设;
diff --git a/package-lock.json b/package-lock.json
index 21fb93c..465dcae 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "md-to-pdf",
- "version": "0.4.4",
+ "version": "0.4.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "md-to-pdf",
- "version": "0.4.4",
+ "version": "0.4.5",
"workspaces": [
"apps/*",
"packages/*"
@@ -21,7 +21,7 @@
},
"apps/desktop": {
"name": "@md-to-pdf/desktop",
- "version": "0.4.4",
+ "version": "0.4.5",
"devDependencies": {
"@md-to-pdf/application": "0.4.1",
"@types/node": "^24.10.1",
@@ -3486,7 +3486,6 @@
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
"integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
"license": "MIT",
- "peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
@@ -5296,6 +5295,7 @@
"integrity": "sha512-O3zJUFUYHJKgzPqioHxfxzBzlSC1eXCSr79gMSBKBP5AgjjpmrydMsMLotEg9fAJF36vdUncb+4ndRNxoPdlSQ==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"app-builder-lib": "26.15.3",
"builder-util": "26.15.3",
diff --git a/package.json b/package.json
index 220b7a0..ba71055 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "md-to-pdf",
- "version": "0.4.4",
+ "version": "0.4.5",
"private": true,
"description": "可配置、可主题化的 Markdown PDF 导出工具",
"workspaces": [
diff --git a/packages/application/README.md b/packages/application/README.md
new file mode 100644
index 0000000..9fcd80c
--- /dev/null
+++ b/packages/application/README.md
@@ -0,0 +1,46 @@
+# @md-to-pdf/application
+
+Web Server 与 Electron Desktop 共用的应用服务层,组合
+`@md-to-pdf/core` 和 `@md-to-pdf/renderer`,统一处理 Markdown 渲染、
+主题发现、主题资源和图片资源。Fastify 与 Electron IPC 只负责传输和
+平台能力适配。
+
+## 目录结构
+
+```text
+src/
+ application-service.ts 渲染、主题和资源用例入口
+ image-resources.ts 本地、Base64 与受限远程图片处理
+ theme-registry.ts 内置及自定义主题扫描、校验与缓存
+ index.ts 公共导出入口
+tests/
+ application-service.test.ts
+ image-resources.test.ts
+```
+
+## 使用
+
+```ts
+import { createApplicationService } from "@md-to-pdf/application";
+
+const service = createApplicationService({
+ bundledRoot,
+ localRoot
+});
+
+const themes = await service.listThemes();
+const document = await service.render(request);
+```
+
+Web 端通过 `apps/server` 的 HTTP API 调用;桌面端在主进程中创建同一
+服务,并通过受限 IPC 暴露给渲染进程。Web 默认不接收本地素材目录,
+Desktop 才会以 Markdown 所在目录为边界解析相对资源。
+
+## 开发与验证
+
+```powershell
+npm run dev -w @md-to-pdf/application
+npm run test -w @md-to-pdf/application
+npm run typecheck -w @md-to-pdf/application
+npm run build -w @md-to-pdf/application
+```
diff --git a/packages/core/README.md b/packages/core/README.md
new file mode 100644
index 0000000..ec5495c
--- /dev/null
+++ b/packages/core/README.md
@@ -0,0 +1,47 @@
+# @md-to-pdf/core
+
+跨 Web、Server、Desktop 和渲染器共享的纯数据模型与校验规则。该包不
+依赖浏览器、Electron 或 Fastify,负责保证各运行端使用同一份协议。
+
+## 目录结构
+
+```text
+src/
+ document.ts Markdown 文档、分页载荷、结果与耗时模型
+ export-config.ts 纸张、边距、页眉页脚、页码与图表配置
+ theme.ts 主题清单、主题能力与 CSS 载荷模型
+ index.ts 公共导出入口
+tests/
+ document.test.ts
+ export-config.test.ts
+```
+
+## 使用
+
+```ts
+import {
+ createPagedDocumentPayload,
+ defaultExportConfig,
+ exportConfigSchema
+} from "@md-to-pdf/core";
+
+const config = exportConfigSchema.parse(defaultExportConfig);
+const payload = createPagedDocumentPayload({
+ document,
+ fileName: "example.md",
+ themeCss,
+ exportConfig: config
+});
+```
+
+新增跨端字段时,应先在这里定义类型、默认值、Zod 校验和兼容迁移,再由
+各适配层消费,避免 Web 与 Desktop 分别维护协议。
+
+## 开发与验证
+
+```powershell
+npm run dev -w @md-to-pdf/core
+npm run test -w @md-to-pdf/core
+npm run typecheck -w @md-to-pdf/core
+npm run build -w @md-to-pdf/core
+```
diff --git a/packages/markdown-echarts/README.md b/packages/markdown-echarts/README.md
index 53f6f62..2f6e866 100644
--- a/packages/markdown-echarts/README.md
+++ b/packages/markdown-echarts/README.md
@@ -6,6 +6,24 @@
该包不依赖 React、Paged.js 或特定 PDF 引擎。宿主可以将生成的
HTML 用于网页预览,也可以在图表完成渲染后交给 Chromium 输出 PDF。
+## 目录结构
+
+```text
+src/
+ markdown-it-plugin.ts ECharts YAML 围栏与错误占位
+ parse-yaml.ts 受限 YAML 解析
+ protocol.ts 围栏协议与基础校验
+ security-policy.ts URL、函数、原型和规模安全策略
+ semantic-validator.ts 系列数据与坐标系语义校验
+ dom-contract.ts 宿主与浏览器运行时共用的 DOM 协议
+ browser.ts 浏览器入口
+ browser/ ECharts 按需加载、渲染和 SVG 冻结
+ index.ts Markdown 侧公共导出
+styles/
+ echarts.css 图表、标题和错误占位样式
+tests/ 解析、安全、语义、插件和真实 SVG 测试
+```
+
## 基本语法
````markdown
@@ -289,6 +307,15 @@ Chromium `page.pdf()`。
3. 注册该系列的语义验证器;
4. 增加有效、缺少数据、错误数据结构和真实 SVG 渲染测试。
+## 开发与验证
+
+```powershell
+npm run dev -w @md-to-pdf/markdown-echarts
+npm run test -w @md-to-pdf/markdown-echarts
+npm run typecheck -w @md-to-pdf/markdown-echarts
+npm run build -w @md-to-pdf/markdown-echarts
+```
+
参考:
- [ECharts 配置参考](https://echarts.apache.org/en/option.html)
diff --git a/packages/renderer/README.md b/packages/renderer/README.md
new file mode 100644
index 0000000..413c56d
--- /dev/null
+++ b/packages/renderer/README.md
@@ -0,0 +1,46 @@
+# @md-to-pdf/renderer
+
+将 Markdown 转换为统一、安全的 `#write` HTML 文档。Web 预览、服务端
+PDF 和桌面端 PDF 均通过应用服务复用本包,不维护第二套 Markdown 解析
+逻辑。
+
+## 目录结构
+
+```text
+src/
+ render-markdown.ts Front Matter、Markdown 扩展、安全过滤与元数据
+ index.ts 公共导出入口
+tests/
+ render-markdown.test.ts
+```
+
+## 支持能力
+
+- 标题锚点、表格、任务列表、脚注和代码高亮;
+- KaTeX 数学公式;
+- Mermaid 安全占位;
+- `@md-to-pdf/markdown-echarts` ECharts YAML 围栏;
+- 图片标题 DOM 与资源引用收集;
+- `sanitize-html` 白名单过滤和统一 `` 输出。
+
+## 使用
+
+```ts
+import { renderMarkdown } from "@md-to-pdf/renderer";
+
+const result = renderMarkdown("# 示例", {
+ language: "zh-CN"
+});
+```
+
+实际应用优先通过 `@md-to-pdf/application` 调用,以便同时完成主题和图片
+资源处理。
+
+## 开发与验证
+
+```powershell
+npm run dev -w @md-to-pdf/renderer
+npm run test -w @md-to-pdf/renderer
+npm run typecheck -w @md-to-pdf/renderer
+npm run build -w @md-to-pdf/renderer
+```