diff --git a/README.md b/README.md index 5b1dd11..b602136 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,8 @@ - 表格、任务列表、脚注、代码高亮、KaTeX 和 Mermaid; - iframe 隔离的真实分页预览、双向滚动同步与主题切换; - Chromium PDF 下载、精确 PDF.js 预览与导出性能观测; -- 五种纸张、页边距、页眉、页码和浏览器配置缓存; +- 五种纸张、页边距、页眉、页码、Mermaid 风格和浏览器配置缓存; +- 快速预览与精确预览共用的 50%~400% 显示缩放; - 内置 Typora 风格主题,以及仅供本机使用的三套白色 Typora 默认主题导入工具; - 前端、Fastify、Nginx 和 Playwright Chromium 的 AIO 容器部署。 @@ -39,22 +40,49 @@ npm run dev 编辑区和分页预览使用双向比例滚动同步。在任意一侧滚动时,另一侧会跟随到相同的全文阅读进度,方便定位并修改较长文档。 -## Mermaid 长标签 +预览工具栏支持滑块和手动百分比输入。缩放比例只改变浏览器显示,不改变纸张尺寸、分页结果或最终 PDF,并会单独保存在浏览器中。 -流程图节点标签的默认最大宽度为 320px。单个 Mermaid 区块可以通过 config frontmatter 覆盖: +## Mermaid 配置与样式 + +导出设置可以统一控制整篇文档的 Mermaid 布局、主题、外观和字体: + +- 布局:Dagre、ELK; +- 主题:Mermaid 11.16.0 提供的 Default、Base、Dark、Forest、Neutral、Neo 和 Redux 系列; +- 外观:Classic、Hand-drawn、Neo; +- 字体:任意本地 CSS 字体族。 + +未设置时使用 `dagre + default + classic`。单个 Mermaid 区块的 `config` Front Matter 优先于全局导出设置: ````markdown ```mermaid --- config: + layout: elk + theme: base + look: handDrawn + fontFamily: Microsoft YaHei + themeVariables: + primaryColor: "#dbeafe" + primaryBorderColor: "#2563eb" flowchart: wrappingWidth: 360 --- flowchart TB + classDef important fill:#fee2e2,stroke:#dc2626 A["较长的节点名称
第二行说明"] --> B["下一个节点"] + style A fill:#dcfce7,stroke:#16a34a + class B important ``` ```` +`style`、`classDef`、`linkStyle`、`themeVariables` 和图表专属配置均由 Mermaid 官方渲染器处理。原始 `themeCSS`、`securityLevel` 和资源限制属于安全配置,不能由 Markdown 代码块覆盖。 + +ELK 使用官方 `@mermaid-js/layout-elk`,只有图表实际请求 ELK 时才加载核心布局代码。 + +### Mermaid 长标签 + +流程图节点标签的默认最大宽度为 320px,可以通过上例中的 `flowchart.wrappingWidth` 调整。 + 需要自动换行时可以使用 Mermaid Markdown String: ````markdown diff --git a/apps/web/package.json b/apps/web/package.json index ccc580d..a25a9db 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -11,9 +11,10 @@ }, "dependencies": { "@md-to-pdf/core": "0.1.0", + "@mermaid-js/layout-elk": "0.2.2", "highlight.js": "^11.11.1", "katex": "^0.16.28", - "mermaid": "^11.12.3", + "mermaid": "11.16.0", "pagedjs": "^0.4.3", "pdfjs-dist": "6.1.200", "react": "^19.2.0", diff --git a/apps/web/preview-frame.html b/apps/web/preview-frame.html index 9fb11b7..cf81e5a 100644 --- a/apps/web/preview-frame.html +++ b/apps/web/preview-frame.html @@ -7,6 +7,11 @@ content="default-src 'none'; script-src 'self'; style-src 'unsafe-inline'; font-src 'self' data:; img-src 'self' data:; connect-src 'self';" /> + 分页预览 diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 4119874..cceccdb 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -11,9 +11,11 @@ import { } from "react"; import { getPaperDimensionsMm, + millimetersToCssPixels, type ExportConfig } from "@md-to-pdf/core"; import { ExportSettingsDrawer } from "./ExportSettingsDrawer"; +import { PreviewZoomControl } from "./PreviewZoomControl"; import { loadExportConfig, resetExportConfig, @@ -37,6 +39,10 @@ import { type PdfExportRequest } from "./pdf-export"; import { getSyncedScrollTop } from "./scroll-sync"; +import { + loadPreviewZoom, + savePreviewZoom +} from "./preview-zoom"; const PrecisePdfPreview = lazy(async () => { const module = await import("./PrecisePdfPreview"); @@ -122,6 +128,7 @@ export function App() { const [exportingPdf, setExportingPdf] = useState(false); const [previewMode, setPreviewMode] = useState("quick"); + const [previewZoom, setPreviewZoom] = useState(loadPreviewZoom); const [pdfCache, setPdfCache] = useState(); const [previewFrameReady, setPreviewFrameReady] = useState(false); const [previewPagination, setPreviewPagination] = useState({ @@ -151,9 +158,20 @@ export function App() { exportConfig.paper.format, exportConfig.paper.orientation ); + const previewScale = previewZoom / 100; + const naturalPaperWidth = + millimetersToCssPixels(paperDimensions.width) + + PREVIEW_SCROLLBAR_WIDTH_PX; + const scaledPaperWidth = naturalPaperWidth * previewScale; const paperStyle = { - width: `calc(${paperDimensions.width}mm + ${PREVIEW_SCROLLBAR_WIDTH_PX}px)`, - minWidth: `calc(${paperDimensions.width}mm + ${PREVIEW_SCROLLBAR_WIDTH_PX}px)` + width: `${scaledPaperWidth}px`, + minWidth: `${scaledPaperWidth}px` + } satisfies CSSProperties; + const previewFrameStyle = { + width: `${100 / previewScale}%`, + height: `${100 / previewScale}%`, + transform: `scale(${previewScale})`, + transformOrigin: "top left" } satisfies CSSProperties; const previewPayload = useMemo( () => @@ -236,6 +254,15 @@ export function App() { } }, [exportConfig]); + useEffect(() => { + savePreviewZoom(previewZoom); + const frame = window.requestAnimationFrame(() => { + synchronizeScroll("editor"); + updatePreviewCurrentPage(); + }); + return () => window.cancelAnimationFrame(frame); + }, [previewZoom]); + useEffect(() => { if (pdfCache && pdfCache.key !== pdfCacheKey) { setPdfCache(undefined); @@ -715,6 +742,12 @@ export function App() { +
+ +
diff --git a/apps/web/src/ExportSettingsDrawer.tsx b/apps/web/src/ExportSettingsDrawer.tsx index b64d82e..cda91da 100644 --- a/apps/web/src/ExportSettingsDrawer.tsx +++ b/apps/web/src/ExportSettingsDrawer.tsx @@ -1,10 +1,14 @@ import { getPaperDimensionsMm, paperFormatLabels, + supportedMermaidLayouts, + supportedMermaidLooks, + supportedMermaidThemes, supportedPaperFormats, type ExportConfig, type FooterConfig, - type HeaderConfig + type HeaderConfig, + type MermaidExportConfig } from "@md-to-pdf/core"; import { useEffect, useState } from "react"; @@ -30,6 +34,48 @@ const pageNumberFormatLabels: Record = { custom: "自定义模板" }; +const mermaidLayoutLabels: Record< + MermaidExportConfig["layout"], + string +> = { + dagre: "Dagre", + elk: "ELK" +}; + +const mermaidThemeLabels: Record< + MermaidExportConfig["theme"], + string +> = { + default: "Default", + base: "Base(可定制基础主题)", + dark: "Dark", + forest: "Forest", + neutral: "Neutral", + neo: "Neo", + "neo-dark": "Neo Dark", + redux: "Redux", + "redux-dark": "Redux Dark", + "redux-color": "Redux Color", + "redux-dark-color": "Redux Dark Color" +}; + +const mermaidLookLabels: Record< + MermaidExportConfig["look"], + string +> = { + classic: "Classic", + handDrawn: "Hand-drawn", + neo: "Neo" +}; + +const mermaidFontFamilies = [ + "Segoe UI, Microsoft YaHei, sans-serif", + "Microsoft YaHei, sans-serif", + "Arial, Helvetica, sans-serif", + "Georgia, Times New Roman, serif", + "Consolas, Microsoft YaHei, monospace" +] as const; + function millimeters(value: string) { return Number.parseFloat(value.replace(/mm$/i, "")); } @@ -99,6 +145,54 @@ function LengthInput({ ); } +function MermaidFontInput({ + value, + onChange +}: { + value: string; + onChange: (value: string) => void; +}) { + const [draft, setDraft] = useState(value); + + useEffect(() => { + setDraft(value); + }, [value]); + + function commit() { + const normalized = draft.trim(); + if (!normalized) { + setDraft(value); + return; + } + setDraft(normalized); + onChange(normalized); + } + + return ( + + ); +} + function updateHeaderSlot( header: HeaderConfig, slot: "left" | "center" | "right", @@ -193,6 +287,16 @@ export function ExportSettingsDrawer({ }); } + function updateMermaid(mermaid: Partial) { + onChange({ + ...config, + mermaid: { + ...config.mermaid, + ...mermaid + } + }); + } + return (
+
+ ); +} diff --git a/apps/web/src/export-settings.ts b/apps/web/src/export-settings.ts index d5ad070..6b76a12 100644 --- a/apps/web/src/export-settings.ts +++ b/apps/web/src/export-settings.ts @@ -4,7 +4,9 @@ import { type ExportConfig } from "@md-to-pdf/core"; -export const EXPORT_CONFIG_STORAGE_KEY = "md-to-pdf.export-config.v2"; +export const EXPORT_CONFIG_STORAGE_KEY = "md-to-pdf.export-config.v3"; +export const LEGACY_EXPORT_CONFIG_STORAGE_KEY = + "md-to-pdf.export-config.v2"; type SettingsStorage = Pick; @@ -18,7 +20,19 @@ export function parseStoredExportConfig(rawValue: string | null): ExportConfig { } try { - const parsed = exportConfigSchema.safeParse(JSON.parse(rawValue)); + const value: unknown = JSON.parse(rawValue); + const migratedValue = + typeof value === "object" && + value !== null && + "version" in value && + value.version === 2 + ? { + ...value, + version: defaultExportConfig.version, + mermaid: structuredClone(defaultExportConfig.mermaid) + } + : value; + const parsed = exportConfigSchema.safeParse(migratedValue); return parsed.success ? parsed.data : cloneDefaultExportConfig(); } catch { return cloneDefaultExportConfig(); @@ -28,7 +42,17 @@ export function parseStoredExportConfig(rawValue: string | null): ExportConfig { export function loadExportConfig( storage: SettingsStorage = window.localStorage ) { - return parseStoredExportConfig(storage.getItem(EXPORT_CONFIG_STORAGE_KEY)); + const currentValue = storage.getItem(EXPORT_CONFIG_STORAGE_KEY); + if (currentValue) { + return parseStoredExportConfig(currentValue); + } + + const legacyValue = storage.getItem(LEGACY_EXPORT_CONFIG_STORAGE_KEY); + const config = parseStoredExportConfig(legacyValue); + if (legacyValue) { + storage.setItem(EXPORT_CONFIG_STORAGE_KEY, JSON.stringify(config)); + } + return config; } export function saveExportConfig( diff --git a/apps/web/src/mermaid-config.ts b/apps/web/src/mermaid-config.ts index 96918f1..3a6683f 100644 --- a/apps/web/src/mermaid-config.ts +++ b/apps/web/src/mermaid-config.ts @@ -1,3 +1,4 @@ +import type { MermaidExportConfig } from "@md-to-pdf/core"; import type { MermaidConfig } from "mermaid"; export const MERMAID_SECURE_CONFIG_KEYS = [ @@ -10,13 +11,16 @@ export const MERMAID_SECURE_CONFIG_KEYS = [ "themeCSS" ]; -export function createMermaidSiteConfig(): MermaidConfig { +export function createMermaidSiteConfig( + config: MermaidExportConfig +): MermaidConfig { return { startOnLoad: false, securityLevel: "strict", - theme: "default", - look: "classic", - fontFamily: "Segoe UI, Microsoft YaHei, sans-serif", + layout: config.layout, + theme: config.theme, + look: config.look, + fontFamily: config.fontFamily, flowchart: { wrappingWidth: 320 }, diff --git a/apps/web/src/paged-document-runtime.ts b/apps/web/src/paged-document-runtime.ts index 68a5cc6..8e7c470 100644 --- a/apps/web/src/paged-document-runtime.ts +++ b/apps/web/src/paged-document-runtime.ts @@ -131,9 +131,12 @@ export class PagedDocumentRuntime { private loadMermaid() { if (!this.mermaidPromise) { - this.mermaidPromise = import("mermaid").then( - ({ default: mermaid }) => { - mermaid.initialize(createMermaidSiteConfig()); + this.mermaidPromise = Promise.all([ + import("mermaid"), + import("@mermaid-js/layout-elk") + ]).then( + ([{ default: mermaid }, { default: elkLayouts }]) => { + mermaid.registerLayoutLoaders(elkLayouts); return mermaid; } ); @@ -160,6 +163,9 @@ export class PagedDocumentRuntime { } const mermaid = await this.loadMermaid(); + mermaid.initialize( + createMermaidSiteConfig(payload.exportConfig.mermaid) + ); const outcomes = await renderMermaidDefinitions( nodes.map((node) => node.textContent ?? ""), async (definition) => { diff --git a/apps/web/src/paged-preview-frame.ts b/apps/web/src/paged-preview-frame.ts index ce41d20..277a3c9 100644 --- a/apps/web/src/paged-preview-frame.ts +++ b/apps/web/src/paged-preview-frame.ts @@ -7,6 +7,10 @@ import { } from "./paged-preview"; import { resolvePagedRenderTarget } from "./paged-render-target"; import { resolveMermaidOutputMode } from "./mermaid-static-image"; +import { + beginPreviewFrameRender, + completePreviewFrameRender +} from "./preview-frame-visibility"; function getPreviewRoot() { const element = document.querySelector("#preview-root"); @@ -37,27 +41,34 @@ async function renderPagedPreview( return; } + beginPreviewFrameRender(document.documentElement, requestId); post({ scope: PAGED_PREVIEW_MESSAGE_SCOPE, type: "rendering", requestId }); - const result = await runtime.render(payload, { - target: renderTarget, - mermaidOutput, - shouldContinue: () => requestId === latestRequestId - }); - if (!result) { - return; - } + try { + const result = await runtime.render(payload, { + target: renderTarget, + mermaidOutput, + shouldContinue: () => requestId === latestRequestId + }); + if (!result) { + return; + } - post({ - scope: PAGED_PREVIEW_MESSAGE_SCOPE, - type: "rendered", - requestId, - ...result - }); + post({ + scope: PAGED_PREVIEW_MESSAGE_SCOPE, + type: "rendered", + requestId, + ...result + }); + } finally { + if (requestId === latestRequestId) { + completePreviewFrameRender(document.documentElement, requestId); + } + } } window.__mdToPdfRender = async ( diff --git a/apps/web/src/pdf-page-geometry.ts b/apps/web/src/pdf-page-geometry.ts index 5e0d487..8be05bf 100644 --- a/apps/web/src/pdf-page-geometry.ts +++ b/apps/web/src/pdf-page-geometry.ts @@ -5,11 +5,12 @@ export const PDF_PAGE_CSS_SCALE = export function getPdfPageCssDimensions( widthPoints: number, - heightPoints: number + heightPoints: number, + displayScale = 1 ) { return { - width: widthPoints * PDF_PAGE_CSS_SCALE, - height: heightPoints * PDF_PAGE_CSS_SCALE + width: widthPoints * PDF_PAGE_CSS_SCALE * displayScale, + height: heightPoints * PDF_PAGE_CSS_SCALE * displayScale }; } diff --git a/apps/web/src/preview-frame-visibility.ts b/apps/web/src/preview-frame-visibility.ts new file mode 100644 index 0000000..72f035d --- /dev/null +++ b/apps/web/src/preview-frame-visibility.ts @@ -0,0 +1,24 @@ +interface PreviewFrameVisibilityTarget { + dataset: DOMStringMap; +} + +export function beginPreviewFrameRender( + target: PreviewFrameVisibilityTarget, + requestId: number +) { + target.dataset.rendering = "true"; + target.dataset.renderingRequestId = String(requestId); +} + +export function completePreviewFrameRender( + target: PreviewFrameVisibilityTarget, + requestId: number +) { + if (target.dataset.renderingRequestId !== String(requestId)) { + return false; + } + + delete target.dataset.rendering; + delete target.dataset.renderingRequestId; + return true; +} diff --git a/apps/web/src/preview-zoom.ts b/apps/web/src/preview-zoom.ts new file mode 100644 index 0000000..c719f04 --- /dev/null +++ b/apps/web/src/preview-zoom.ts @@ -0,0 +1,54 @@ +export const PREVIEW_ZOOM_STORAGE_KEY = "md-to-pdf.preview-zoom.v1"; +export const DEFAULT_PREVIEW_ZOOM = 100; +export const MINIMUM_PREVIEW_ZOOM = 50; +export const MAXIMUM_PREVIEW_ZOOM = 400; +export const PREVIEW_ZOOM_SLIDER_STEP = 5; + +type PreviewZoomStorage = Pick; + +export function normalizePreviewZoom(value: number) { + if (!Number.isFinite(value)) { + return DEFAULT_PREVIEW_ZOOM; + } + return Math.min( + MAXIMUM_PREVIEW_ZOOM, + Math.max(MINIMUM_PREVIEW_ZOOM, Math.round(value)) + ); +} + +export function parsePreviewZoom( + value: string | null, + fallback = DEFAULT_PREVIEW_ZOOM +) { + if (!value?.trim()) { + return normalizePreviewZoom(fallback); + } + const parsed = Number(value); + return Number.isFinite(parsed) + ? normalizePreviewZoom(parsed) + : normalizePreviewZoom(fallback); +} + +export function loadPreviewZoom( + storage: PreviewZoomStorage = window.localStorage +) { + try { + return parsePreviewZoom(storage.getItem(PREVIEW_ZOOM_STORAGE_KEY)); + } catch { + return DEFAULT_PREVIEW_ZOOM; + } +} + +export function savePreviewZoom( + value: number, + storage: PreviewZoomStorage = window.localStorage +) { + try { + storage.setItem( + PREVIEW_ZOOM_STORAGE_KEY, + String(normalizePreviewZoom(value)) + ); + } catch { + // 预览缩放是非关键偏好,浏览器禁用缓存时保持当前会话值即可。 + } +} diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index dd37dbd..29575f2 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -176,6 +176,78 @@ h1 { max-width: 68%; } +.preview-toolbar { + display: flex; + flex: none; + min-height: 44px; + align-items: center; + justify-content: flex-end; + padding: 6px 20px; + border-bottom: 1px solid #d9dedb; + background: rgb(255 255 255 / 58%); +} + +.preview-zoom-control { + display: flex; + align-items: center; + gap: 9px; + color: #5d7168; + font-size: 0.7rem; + font-variant-numeric: tabular-nums; +} + +.preview-zoom-slider { + width: 150px; + accent-color: #4f8a6f; +} + +.preview-zoom-number { + display: flex; + width: 68px; + height: 30px; + align-items: center; + border: 1px solid #c9d4ce; + border-radius: 7px; + background: #fff; +} + +.preview-zoom-number:focus-within { + border-color: #74a88f; + box-shadow: 0 0 0 3px rgb(116 168 143 / 16%); +} + +.preview-zoom-number input { + min-width: 0; + width: 100%; + height: 28px; + padding: 0 2px 0 8px; + border: 0; + outline: 0; + background: transparent; + color: #356c55; + font: inherit; + font-weight: 700; + text-align: right; +} + +.preview-zoom-number > span:last-child { + padding-right: 7px; + color: #7a8580; +} + +.preview-zoom-reset { + min-height: 30px; + padding: 0 8px; + border-color: #c9d4ce; + background: #f8fbf9; + color: #4f695e; + font-size: 0.68rem; +} + +.preview-zoom-reset:disabled { + color: #9ba49f; +} + .preview-page-indicator { flex: none; color: #5d7168; @@ -319,7 +391,7 @@ textarea:focus { display: flex; min-width: 100%; flex-direction: column; - align-items: center; + align-items: safe center; gap: 24px; padding: 34px 30px; } @@ -327,7 +399,6 @@ textarea:focus { .precise-pdf-page { position: relative; flex: none; - max-width: 100%; overflow: hidden; background: #fff; box-shadow: 0 18px 48px rgb(37 49 43 / 16%); @@ -775,6 +846,20 @@ textarea:focus { flex-wrap: wrap; } + .preview-toolbar { + justify-content: center; + padding-inline: 10px; + } + + .preview-zoom-control { + width: 100%; + } + + .preview-zoom-slider { + flex: 1; + width: auto; + } + .theme-control select { max-width: 150px; } diff --git a/apps/web/tests/export-settings.test.ts b/apps/web/tests/export-settings.test.ts index 585c6de..565e0f9 100644 --- a/apps/web/tests/export-settings.test.ts +++ b/apps/web/tests/export-settings.test.ts @@ -2,24 +2,32 @@ import { describe, expect, it } from "vitest"; import { defaultExportConfig } from "@md-to-pdf/core"; import { EXPORT_CONFIG_STORAGE_KEY, + LEGACY_EXPORT_CONFIG_STORAGE_KEY, loadExportConfig, parseStoredExportConfig, saveExportConfig } from "../src/export-settings"; -function createMemoryStorage(initialValue: string | null = null) { - let value = initialValue; +function createMemoryStorage( + initialValue: string | null = null, + legacyValue: string | null = null +) { + const values = new Map(); + if (initialValue) { + values.set(EXPORT_CONFIG_STORAGE_KEY, initialValue); + } + if (legacyValue) { + values.set(LEGACY_EXPORT_CONFIG_STORAGE_KEY, legacyValue); + } return { getItem(key: string) { - return key === EXPORT_CONFIG_STORAGE_KEY ? value : null; + return values.get(key) ?? null; }, setItem(key: string, nextValue: string) { - if (key === EXPORT_CONFIG_STORAGE_KEY) { - value = nextValue; - } + values.set(key, nextValue); }, read() { - return value; + return values.get(EXPORT_CONFIG_STORAGE_KEY) ?? null; } }; } @@ -44,6 +52,28 @@ describe("导出设置缓存", () => { ).toEqual(defaultExportConfig); }); + it("将 v2 缓存迁移为 v3 并保留已有设置", () => { + const legacyConfig = { + ...defaultExportConfig, + version: 2, + mermaid: undefined, + paper: { + ...defaultExportConfig.paper, + format: "A5" + } + }; + const storage = createMemoryStorage( + null, + JSON.stringify(legacyConfig) + ); + + const migrated = loadExportConfig(storage); + expect(migrated.version).toBe(3); + expect(migrated.paper.format).toBe("A5"); + expect(migrated.mermaid).toEqual(defaultExportConfig.mermaid); + expect(storage.read()).toContain('"version":3'); + }); + it("保存并恢复经过校验的配置", () => { const storage = createMemoryStorage(); const config = { diff --git a/apps/web/tests/mermaid-config.test.ts b/apps/web/tests/mermaid-config.test.ts index 9c0ca44..29f10a4 100644 --- a/apps/web/tests/mermaid-config.test.ts +++ b/apps/web/tests/mermaid-config.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import { defaultExportConfig } from "@md-to-pdf/core"; import { createMermaidSiteConfig, MERMAID_SECURE_CONFIG_KEYS @@ -6,7 +7,7 @@ import { describe("Mermaid 站点配置", () => { it("默认使用 default 主题和 classic 外观", () => { - const config = createMermaidSiteConfig(); + const config = createMermaidSiteConfig(defaultExportConfig.mermaid); expect(config.theme).toBe("default"); expect(config.look).toBe("classic"); expect(config.securityLevel).toBe("strict"); @@ -29,9 +30,25 @@ describe("Mermaid 站点配置", () => { }); it("每次创建独立的 secure 配置数组", () => { - const first = createMermaidSiteConfig(); - const second = createMermaidSiteConfig(); + const first = createMermaidSiteConfig(defaultExportConfig.mermaid); + const second = createMermaidSiteConfig(defaultExportConfig.mermaid); expect(first.secure).toEqual(second.secure); expect(first.secure).not.toBe(second.secure); }); + + it("应用导出配置中的布局、主题、外观和字体", () => { + const config = createMermaidSiteConfig({ + layout: "elk", + theme: "forest", + look: "handDrawn", + fontFamily: "Microsoft YaHei, sans-serif" + }); + + expect(config).toMatchObject({ + layout: "elk", + theme: "forest", + look: "handDrawn", + fontFamily: "Microsoft YaHei, sans-serif" + }); + }); }); diff --git a/apps/web/tests/pdf-page-geometry.test.ts b/apps/web/tests/pdf-page-geometry.test.ts index 5cd06d3..c877a51 100644 --- a/apps/web/tests/pdf-page-geometry.test.ts +++ b/apps/web/tests/pdf-page-geometry.test.ts @@ -16,6 +16,15 @@ describe("精确 PDF 页面尺寸", () => { }); }); + it("按预览缩放比例调整 PDF 页面显示尺寸", () => { + expect( + getPdfPageCssDimensions(595.275591, 841.889764, 1.5) + ).toEqual({ + width: expect.closeTo(1190.551182, 5), + height: expect.closeTo(1683.779528, 5) + }); + }); + it("结合显示宽度和设备像素比计算 Canvas 比例", () => { expect(getPdfCanvasRenderScale(600, 800, 2)).toBeCloseTo( 8 / 3 diff --git a/apps/web/tests/preview-frame-visibility.test.ts b/apps/web/tests/preview-frame-visibility.test.ts new file mode 100644 index 0000000..accb8ff --- /dev/null +++ b/apps/web/tests/preview-frame-visibility.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; +import { + beginPreviewFrameRender, + completePreviewFrameRender +} from "../src/preview-frame-visibility"; + +function createTarget() { + return { + dataset: {} as DOMStringMap + }; +} + +describe("分页 iframe 渲染可见性", () => { + it("渲染期间隐藏,当前请求完成后恢复显示", () => { + const target = createTarget(); + + beginPreviewFrameRender(target, 12); + expect(target.dataset).toMatchObject({ + rendering: "true", + renderingRequestId: "12" + }); + + expect(completePreviewFrameRender(target, 12)).toBe(true); + expect(target.dataset.rendering).toBeUndefined(); + expect(target.dataset.renderingRequestId).toBeUndefined(); + }); + + it("旧请求完成时不会提前显示新请求的半成品", () => { + const target = createTarget(); + + beginPreviewFrameRender(target, 12); + beginPreviewFrameRender(target, 13); + + expect(completePreviewFrameRender(target, 12)).toBe(false); + expect(target.dataset).toMatchObject({ + rendering: "true", + renderingRequestId: "13" + }); + }); +}); diff --git a/apps/web/tests/preview-zoom.test.ts b/apps/web/tests/preview-zoom.test.ts new file mode 100644 index 0000000..6fade5a --- /dev/null +++ b/apps/web/tests/preview-zoom.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "vitest"; +import { + DEFAULT_PREVIEW_ZOOM, + MAXIMUM_PREVIEW_ZOOM, + MINIMUM_PREVIEW_ZOOM, + PREVIEW_ZOOM_STORAGE_KEY, + loadPreviewZoom, + normalizePreviewZoom, + parsePreviewZoom, + savePreviewZoom +} from "../src/preview-zoom"; + +function createMemoryStorage(initialValue: string | null = null) { + let value = initialValue; + return { + getItem(key: string) { + return key === PREVIEW_ZOOM_STORAGE_KEY ? value : null; + }, + setItem(key: string, nextValue: string) { + if (key === PREVIEW_ZOOM_STORAGE_KEY) { + value = nextValue; + } + }, + read() { + return value; + } + }; +} + +describe("预览缩放", () => { + it("默认使用 100%,并限制在 50% 到 400%", () => { + expect(parsePreviewZoom(null)).toBe(DEFAULT_PREVIEW_ZOOM); + expect(normalizePreviewZoom(20)).toBe(MINIMUM_PREVIEW_ZOOM); + expect(normalizePreviewZoom(450)).toBe(MAXIMUM_PREVIEW_ZOOM); + expect(normalizePreviewZoom(124.6)).toBe(125); + }); + + it("无效手动输入回退到当前缩放值", () => { + expect(parsePreviewZoom("", 135)).toBe(135); + expect(parsePreviewZoom("not-a-number", 135)).toBe(135); + }); + + it("保存并恢复浏览器缩放偏好", () => { + const storage = createMemoryStorage(); + savePreviewZoom(145, storage); + expect(storage.read()).toBe("145"); + expect(loadPreviewZoom(storage)).toBe(145); + }); +}); diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index 224d651..4e8ab8a 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -29,7 +29,7 @@ fa07472 feat: 实现网页实时预览 ec48bce chore: 初始化项目骨架 ``` -最新阶段已完成 Chromium PDF 导出、精确预览、PDF.js 渲染缓冲和 Mermaid 分页性能优化。 +最新阶段已完成 Mermaid 全局导出配置、ELK 布局、代码块配置覆盖、重绘隐藏和两种预览模式共用的显示缩放。 ## 2. 已完成 @@ -46,7 +46,7 @@ ec48bce chore: 初始化项目骨架 `packages/core/src/export-config.ts` 已包含: -- 配置版本号; +- 版本 3 配置模型; - A3、A4、A5、Letter、Legal、Tabloid 和自定义纸张; - 横向、纵向; - 四边页边距; @@ -54,6 +54,7 @@ ec48bce chore: 初始化项目骨架 - 页码位置和格式; - PDF 元数据; - 打印背景、缩放和一级标题分页选项; +- Mermaid 布局、主题、外观和字体; - Zod 数据校验; - 默认 A4 技术文档预设。 @@ -127,7 +128,7 @@ ec48bce chore: 初始化项目骨架 提交 `52cf816` 已完成: -- 导出配置升级到版本 2; +- 导出配置升级到版本 3,并兼容迁移版本 2 浏览器缓存; - 纸张收敛为 A3、A4、A5、US-Letter 和 US-Legal; - 默认 A4 纵向和 16mm 四边页边距; - 统一 96 CSS px/in、72 PDF pt/in 和 25.4mm/in 的物理单位换算; @@ -136,6 +137,7 @@ ec48bce chore: 初始化项目骨架 - 页眉左中右内容和变量; - 五种页码样式、对齐和起始页码; - Mermaid 官方配置 frontmatter、默认主题与错误隔离; +- Mermaid 全局 Dagre/ELK 布局、官方主题、外观和字体设置; - Markdown 表格对齐属性保留和自适应列宽; - 纸张尺寸和页边距实时预览。 @@ -190,6 +192,23 @@ ec48bce chore: 初始化项目骨架 - 增加容器健康检查和统一进程退出处理。 - Nginx 显式以 JavaScript MIME 类型提供 PDF.js `.mjs` Worker。 +### 3.6 Mermaid 配置与预览缩放 + +- Mermaid 固定为 `11.16.0`,ELK 使用官方 + `@mermaid-js/layout-elk@0.2.2` 并按需加载核心布局代码。 +- 全局导出设置支持 Dagre、ELK、官方主题、Classic、Hand-drawn、Neo + 外观和自定义本地字体。 +- 单图 `config` Front Matter 可以覆盖全局布局、主题、外观、字体、 + `themeVariables` 和图表专属配置。 +- `style`、`classDef` 和 `linkStyle` 保持 Mermaid 原生语义。 +- `themeCSS`、严格安全模式和资源限制不能由 Markdown 覆盖。 +- Mermaid 与 Paged.js 计算期间隐藏分页 iframe,避免显示官方渲染器的 + 临时测量节点。 +- 快速预览和精确预览共用 50%~400% 显示缩放,支持滑块、手动输入、 + 100% 重置和独立浏览器缓存。 +- 缩放不进入导出配置,不改变分页或 PDF;精确预览会按显示宽度重绘 + 邻近 Canvas,保持文字清晰。 + ## 4. 已执行验证 2026-07-26 在当前完整工作区成功执行: @@ -203,16 +222,21 @@ git diff --check 结果: -- 共享配置测试:6 项通过; +- 共享配置测试:8 项通过; - 渲染器测试:7 项通过; -- 前端测试:47 项通过; -- 后端测试:17 项通过; +- 前端测试:55 项通过; +- 后端测试:23 项通过; - 全项目类型检查通过; - 生产构建通过; - `git diff --check` 通过。 已使用浏览器插件完成视觉验证: +- 预览缩放在 50%、100%、150% 和 400% 下均按比例显示,快速与精确 + 预览的分页结果不受缩放影响; +- 包含 ELK、Base 主题、Hand-drawn 外观、自定义字体、 + `themeVariables`、`style` 和 `classDef` 的 Mermaid 样例在快速预览 + 与精确预览中均成功渲染; - 65 页附件的快速预览生成 64 页,67 个 Mermaid 全部使用静态 SVG; - 同一附件的精确预览和最终 PDF 均为 65 页; - 精确预览快速跳转至第 46、47、65 页,无黑色 Canvas、空白页或渲染错误; @@ -259,6 +283,7 @@ git diff --check - `apps/web/src/mermaid-static-image.ts` 负责锁定 Mermaid 盒模型并转换为静态矢量 SVG 图片。 - `apps/web/src/paged-table-handler.ts` 负责跨页表格表头处理。 - `apps/web/src/PrecisePdfPreview.tsx` 和 `pdf-render-queue.ts` 负责 PDF.js 精确预览和 Canvas 生命周期。 +- `apps/web/src/preview-zoom.ts` 和 `PreviewZoomControl.tsx` 负责预览缩放缓存、边界和控制界面。 - `apps/server/src/pdf-engine.ts` 负责 Chromium 生命周期、并发、网络限制、分页调用和 PDF 输出。 - `apps/web/src/ExportSettingsDrawer.tsx` 是导出设置界面。 - `apps/web/src/export-settings.ts` 负责版本化浏览器缓存。 diff --git a/package-lock.json b/package-lock.json index dff8860..81b9fd3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -39,9 +39,10 @@ "version": "0.1.0", "dependencies": { "@md-to-pdf/core": "0.1.0", + "@mermaid-js/layout-elk": "0.2.2", "highlight.js": "^11.11.1", "katex": "^0.16.28", - "mermaid": "^11.12.3", + "mermaid": "11.16.0", "pagedjs": "^0.4.3", "pdfjs-dist": "6.1.200", "react": "^19.2.0", @@ -1145,6 +1146,19 @@ } } }, + "node_modules/@mermaid-js/layout-elk": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@mermaid-js/layout-elk/-/layout-elk-0.2.2.tgz", + "integrity": "sha512-vnH3gtqfhyBiRVKNpT8iDENTw18q/OF0GF/SfYfHN43KZpu+6eZDEOMHTfNYAkpmUWJNgtRQFIS6BTc7vH/DYQ==", + "license": "MIT", + "dependencies": { + "d3": "^7.9.0", + "elkjs": "^0.9.3" + }, + "peerDependencies": { + "mermaid": "^11.0.2" + } + }, "node_modules/@mermaid-js/parser": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-1.2.0.tgz", @@ -3333,6 +3347,12 @@ "dev": true, "license": "ISC" }, + "node_modules/elkjs": { + "version": "0.9.3", + "resolved": "https://registry.npmjs.org/elkjs/-/elkjs-0.9.3.tgz", + "integrity": "sha512-f/ZeWvW/BCXbhGEf1Ujp29EASo/lk1FDnETgNKwJrsVvGZhUWCZyg3xLJjAsxfOmt8KjswHmI5EwCQcPMpOYhQ==", + "license": "EPL-2.0" + }, "node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", diff --git a/packages/core/src/export-config.ts b/packages/core/src/export-config.ts index 3c686fb..f300988 100644 --- a/packages/core/src/export-config.ts +++ b/packages/core/src/export-config.ts @@ -1,10 +1,30 @@ import { z } from "zod"; -export const EXPORT_CONFIG_VERSION = 2; +export const EXPORT_CONFIG_VERSION = 3; export const CSS_PIXELS_PER_INCH = 96; export const PDF_POINTS_PER_INCH = 72; export const MILLIMETERS_PER_INCH = 25.4; +export const supportedMermaidLayouts = ["dagre", "elk"] as const; +export const supportedMermaidThemes = [ + "default", + "base", + "dark", + "forest", + "neutral", + "neo", + "neo-dark", + "redux", + "redux-dark", + "redux-color", + "redux-dark-color" +] as const; +export const supportedMermaidLooks = [ + "classic", + "handDrawn", + "neo" +] as const; + export const supportedPaperFormats = [ "A3", "A4", @@ -147,10 +167,18 @@ const paperSchema = z } }); +export const mermaidConfigSchema = z.object({ + layout: z.enum(supportedMermaidLayouts), + theme: z.enum(supportedMermaidThemes), + look: z.enum(supportedMermaidLooks), + fontFamily: z.string().trim().min(1).max(300) +}); + export const exportConfigSchema = z.object({ version: z.literal(EXPORT_CONFIG_VERSION), name: z.string().min(1).max(100), themeId: z.string().min(1), + mermaid: mermaidConfigSchema, paper: paperSchema, header: headerSchema, footer: footerSchema, @@ -169,6 +197,7 @@ export const exportConfigSchema = z.object({ }); export type ExportConfig = z.infer; +export type MermaidExportConfig = ExportConfig["mermaid"]; export type PaperFormat = ExportConfig["paper"]["format"]; export type PaperOrientation = ExportConfig["paper"]["orientation"]; export type HeaderConfig = ExportConfig["header"]; @@ -193,6 +222,12 @@ export const defaultExportConfig: ExportConfig = { version: EXPORT_CONFIG_VERSION, name: "默认 A4 文档", themeId: "typora-github", + mermaid: { + layout: "dagre", + theme: "default", + look: "classic", + fontFamily: "Segoe UI, Microsoft YaHei, sans-serif" + }, paper: { format: "A4", orientation: "portrait", diff --git a/packages/core/tests/export-config.test.ts b/packages/core/tests/export-config.test.ts index 44c09fc..a786a43 100644 --- a/packages/core/tests/export-config.test.ts +++ b/packages/core/tests/export-config.test.ts @@ -7,6 +7,9 @@ import { millimetersToCssPixels, millimetersToPdfPoints, paperDimensionsMm, + supportedMermaidLayouts, + supportedMermaidLooks, + supportedMermaidThemes, supportedPaperFormats } from "../src/export-config.js"; @@ -42,6 +45,43 @@ describe("导出配置", () => { expect(exportConfigSchema.safeParse(defaultExportConfig).success).toBe(true); }); + it("提供 Mermaid 官方布局、主题、外观和字体默认值", () => { + expect(supportedMermaidLayouts).toEqual(["dagre", "elk"]); + expect(supportedMermaidLooks).toEqual([ + "classic", + "handDrawn", + "neo" + ]); + expect(supportedMermaidThemes).toContain("redux-dark-color"); + expect(defaultExportConfig.mermaid).toEqual({ + layout: "dagre", + theme: "default", + look: "classic", + fontFamily: "Segoe UI, Microsoft YaHei, sans-serif" + }); + }); + + it("拒绝 Mermaid 未支持的导出配置", () => { + expect( + exportConfigSchema.safeParse({ + ...defaultExportConfig, + mermaid: { + ...defaultExportConfig.mermaid, + layout: "unknown" + } + }).success + ).toBe(false); + expect( + exportConfigSchema.safeParse({ + ...defaultExportConfig, + mermaid: { + ...defaultExportConfig.mermaid, + fontFamily: "" + } + }).success + ).toBe(false); + }); + it("使用统一的 96 CSS px/in 和 72 PDF pt/in 换算物理尺寸", () => { expect(millimetersToCssPixels(25.4)).toBeCloseTo(96); expect(cssPixelsToMillimeters(96)).toBeCloseTo(25.4); diff --git a/packages/renderer/tests/render-markdown.test.ts b/packages/renderer/tests/render-markdown.test.ts index 3f52e1f..6cd15b1 100644 --- a/packages/renderer/tests/render-markdown.test.ts +++ b/packages/renderer/tests/render-markdown.test.ts @@ -95,21 +95,34 @@ flowchart LR --- title: Frontmatter 示例 config: + layout: elk theme: forest look: handDrawn + fontFamily: Microsoft YaHei + themeVariables: + primaryColor: "#dbeafe" flowchart: curve: basis --- flowchart LR + classDef important fill:#fee2e2,stroke:#dc2626 A[开始] --> B[结束] + style A fill:#dcfce7,stroke:#16a34a + class B important \`\`\` `); expect(result.bodyHtml).toContain("title: Frontmatter 示例"); + expect(result.bodyHtml).toContain("layout: elk"); expect(result.bodyHtml).toContain("theme: forest"); expect(result.bodyHtml).toContain("look: handDrawn"); + expect(result.bodyHtml).toContain("fontFamily: Microsoft YaHei"); + expect(result.bodyHtml).toContain("primaryColor:"); expect(result.bodyHtml).toContain("curve: basis"); expect(result.bodyHtml).toContain("flowchart LR"); + expect(result.bodyHtml).toContain("classDef important"); + expect(result.bodyHtml).toContain("style A fill:"); + expect(result.bodyHtml).toContain("class B important"); }); it("阻止原始 HTML 和危险链接形成可执行内容", () => {