From 52cf81668398e2f7e1a7da8fa0ce3a39e4bf1bb3 Mon Sep 17 00:00:00 2001 From: SkyJourney Date: Sun, 26 Jul 2026 01:36:51 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=8C=E5=96=84=E5=AF=BC=E5=87=BA?= =?UTF-8?q?=E8=AE=BE=E7=BD=AE=E4=B8=8E=E6=89=93=E5=8D=B0=E9=A2=84=E8=A7=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/package.json | 4 +- apps/web/src/App.tsx | 179 +++++-- apps/web/src/ExportSettingsDrawer.tsx | 489 ++++++++++++++++++ apps/web/src/export-settings.ts | 44 ++ apps/web/src/mermaid-config.ts | 25 + apps/web/src/mermaid-renderer.ts | 37 ++ apps/web/src/preview-styles.ts | 10 + apps/web/src/styles.css | 293 ++++++++++- apps/web/tests/export-settings.test.ts | 62 +++ apps/web/tests/mermaid-config.test.ts | 36 ++ apps/web/tests/mermaid-renderer.test.ts | 26 + apps/web/tests/preview-styles.test.ts | 37 ++ docs/PROGRESS.md | 111 ++-- package-lock.json | 6 +- package.json | 2 +- packages/core/package.json | 4 + packages/core/src/export-config.ts | 187 ++++--- packages/core/tests/export-config.test.ts | 97 ++++ packages/renderer/src/render-markdown.ts | 11 +- .../renderer/tests/render-markdown.test.ts | 38 ++ 20 files changed, 1552 insertions(+), 146 deletions(-) create mode 100644 apps/web/src/ExportSettingsDrawer.tsx create mode 100644 apps/web/src/export-settings.ts create mode 100644 apps/web/src/mermaid-config.ts create mode 100644 apps/web/src/mermaid-renderer.ts create mode 100644 apps/web/src/preview-styles.ts create mode 100644 apps/web/tests/export-settings.test.ts create mode 100644 apps/web/tests/mermaid-config.test.ts create mode 100644 apps/web/tests/mermaid-renderer.test.ts create mode 100644 apps/web/tests/preview-styles.test.ts create mode 100644 packages/core/tests/export-config.test.ts diff --git a/apps/web/package.json b/apps/web/package.json index 4278644..2618021 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -6,6 +6,7 @@ "scripts": { "dev": "vite", "build": "tsc -b && vite build", + "test": "vitest run", "typecheck": "tsc -b --pretty false" }, "dependencies": { @@ -20,6 +21,7 @@ "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^5.1.1", - "vite": "^7.2.4" + "vite": "^7.2.4", + "vitest": "^4.1.10" } } diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 22a0a4d..61ee793 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -1,12 +1,26 @@ import { + type CSSProperties, type ChangeEvent, useEffect, useMemo, useRef, useState } from "react"; +import { + getPaperDimensionsMm, + type ExportConfig +} from "@md-to-pdf/core"; import highlightCss from "highlight.js/styles/github.css?inline"; import katexCss from "katex/dist/katex.min.css?inline"; +import { ExportSettingsDrawer } from "./ExportSettingsDrawer"; +import { + loadExportConfig, + resetExportConfig, + saveExportConfig +} from "./export-settings"; +import { createMermaidSiteConfig } from "./mermaid-config"; +import { renderMermaidDefinitions } from "./mermaid-renderer"; +import { enablePrintMediaForPreview } from "./preview-styles"; interface RenderedMarkdown { articleHtml: string; @@ -89,12 +103,48 @@ svg { max-width: 100%; } +#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: auto; } + +.mermaid-error { + display: block; + padding: 0.85em 1em; + border: 1px solid #dc2626; + color: #991b1b; + background: #fef2f2; + white-space: pre-wrap; +} +`; + +const previewGeometryCss = ` +html, +body { + width: 100% !important; + max-width: none !important; + margin: 0 !important; + padding: 0 !important; +} + +#write { + width: 100% !important; + max-width: none !important; + margin: 0 !important; + padding: 0 !important; +} `; let mermaidPromise: Promise<(typeof import("mermaid"))["default"]> | undefined; @@ -103,12 +153,7 @@ let mermaidRenderSequence = 0; async function loadMermaid() { if (!mermaidPromise) { mermaidPromise = import("mermaid").then(({ default: mermaid }) => { - mermaid.initialize({ - startOnLoad: false, - securityLevel: "strict", - theme: "neutral", - fontFamily: "Segoe UI, Microsoft YaHei, sans-serif" - }); + mermaid.initialize(createMermaidSiteConfig()); return mermaid; }); } @@ -132,6 +177,8 @@ function buildPreviewDocument( result: RenderedMarkdown, themeCss: string ) { + const previewThemeCss = enablePrintMediaForPreview(themeCss); + return ` @@ -145,7 +192,8 @@ function buildPreviewDocument( - + + ${result.articleHtml} @@ -158,8 +206,10 @@ export function App() { const [fileName, setFileName] = useState("示例文档.md"); const [result, setResult] = useState(null); const [themes, setThemes] = useState([]); - const [themeId, setThemeId] = useState("typora-like"); + const [exportConfig, setExportConfig] = + useState(loadExportConfig); const [themeCss, setThemeCss] = useState(""); + const [settingsOpen, setSettingsOpen] = useState(false); const [status, setStatus] = useState("正在准备预览…"); const [renderError, setRenderError] = useState(""); const [themeError, setThemeError] = useState(""); @@ -167,7 +217,21 @@ export function App() { const previewFrameRef = useRef(null); const error = renderError || themeError || mermaidError; + const themeId = exportConfig.themeId; const selectedTheme = themes.find((theme) => theme.id === themeId); + const paperDimensions = getPaperDimensionsMm( + exportConfig.paper.format, + exportConfig.paper.orientation + ); + const paperStyle = { + width: `${paperDimensions.width}mm`, + minWidth: `${paperDimensions.width}mm`, + minHeight: `${paperDimensions.height}mm`, + paddingTop: exportConfig.paper.margins.top, + paddingRight: exportConfig.paper.margins.right, + paddingBottom: exportConfig.paper.margins.bottom, + paddingLeft: exportConfig.paper.margins.left + } satisfies CSSProperties; const previewDocument = useMemo( () => result && themeCss ? buildPreviewDocument(result, themeCss) : undefined, @@ -188,13 +252,22 @@ export function App() { }) .then(({ themes: availableThemes }) => { setThemes(availableThemes); - const preferredTheme = - availableThemes.find((theme) => theme.id === "typora-github") ?? - availableThemes.find((theme) => theme.id === "typora-like") ?? - availableThemes[0]; - if (preferredTheme) { - setThemeId(preferredTheme.id); - } + setExportConfig((currentConfig) => { + if ( + availableThemes.some( + (theme) => theme.id === currentConfig.themeId + ) + ) { + return currentConfig; + } + const preferredTheme = + availableThemes.find((theme) => theme.id === "typora-github") ?? + availableThemes.find((theme) => theme.id === "typora-like") ?? + availableThemes[0]; + return preferredTheme + ? { ...currentConfig, themeId: preferredTheme.id } + : currentConfig; + }); }) .catch((reason: unknown) => { if (!controller.signal.aborted) { @@ -207,6 +280,14 @@ export function App() { return () => controller.abort(); }, []); + useEffect(() => { + try { + saveExportConfig(exportConfig); + } catch { + setRenderError("无法保存导出设置,当前设置仅在本次页面中有效"); + } + }, [exportConfig]); + useEffect(() => { const controller = new AbortController(); setThemeCss(""); @@ -306,16 +387,42 @@ export function App() { if (nodes.length > 0) { try { const mermaid = await loadMermaid(); - for (const node of nodes) { - const definition = node.textContent ?? ""; - mermaidRenderSequence += 1; - const { svg, bindFunctions } = await mermaid.render( - `mermaid-preview-${mermaidRenderSequence}`, - definition - ); - node.innerHTML = svg; + const outcomes = await renderMermaidDefinitions( + nodes.map((node) => node.textContent ?? ""), + async (definition) => { + mermaidRenderSequence += 1; + return mermaid.render( + `mermaid-preview-${mermaidRenderSequence}`, + definition + ); + } + ); + const errors: string[] = []; + + for (const [index, outcome] of outcomes.entries()) { + const node = nodes[index]; + if (!node) { + continue; + } + node.removeAttribute("data-mermaid-pending"); - bindFunctions?.(node); + if (outcome.success) { + node.innerHTML = outcome.svg; + outcome.bindFunctions?.(node); + continue; + } + + const message = + outcome.error instanceof Error + ? outcome.error.message + : "未知错误"; + node.classList.add("mermaid-error"); + node.textContent = `Mermaid 图表 ${index + 1} 渲染失败:${message}`; + errors.push(`图表 ${index + 1}:${message}`); + } + + if (errors.length > 0) { + setMermaidError(`Mermaid:${errors.join(";")}`); } } catch (reason: unknown) { setMermaidError( @@ -362,6 +469,9 @@ export function App() { onChange={handleFileChange} /> + @@ -398,7 +508,12 @@ export function App() { setDraft(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.currentTarget.blur(); + } + }} + /> + mm + + + ); +} + +function updateHeaderSlot( + header: HeaderConfig, + slot: "left" | "center" | "right", + value: Partial +) { + return { + ...header, + [slot]: { + ...header[slot], + ...value + } + }; +} + +export function ExportSettingsDrawer({ + config, + onChange, + onClose, + onReset +}: ExportSettingsDrawerProps) { + const dimensions = getPaperDimensionsMm( + config.paper.format, + config.paper.orientation + ); + const margins = config.paper.margins; + const horizontalGap = 1; + const verticalGap = 1; + + function updatePaper(paper: Partial) { + const nextPaper = { + ...config.paper, + ...paper + }; + const nextDimensions = getPaperDimensionsMm( + nextPaper.format, + nextPaper.orientation + ); + const [left, right] = fitMarginPair( + millimeters(nextPaper.margins.left), + millimeters(nextPaper.margins.right), + nextDimensions.width - 1 + ); + const [top, bottom] = fitMarginPair( + millimeters(nextPaper.margins.top), + millimeters(nextPaper.margins.bottom), + nextDimensions.height - 1 + ); + + onChange({ + ...config, + paper: { + ...nextPaper, + margins: { + top: `${top}mm`, + right: `${right}mm`, + bottom: `${bottom}mm`, + left: `${left}mm` + } + } + }); + } + + function updateMargin( + side: keyof ExportConfig["paper"]["margins"], + value: string + ) { + updatePaper({ + margins: { + ...margins, + [side]: value + } + }); + } + + function updateHeader(header: Partial) { + onChange({ + ...config, + header: { + ...config.header, + ...header + } + }); + } + + function updateFooter(footer: Partial) { + onChange({ + ...config, + footer: { + ...config.footer, + ...footer + } + }); + } + + return ( +
+ +
+ +
+
+

纸张

+ +
+ 方向 + + +
+

+ 当前纸张:{dimensions.width} × {dimensions.height}mm +

+
+ +
+

页边距

+
+ updateMargin("top", value)} + /> + updateMargin("right", value)} + /> + updateMargin("bottom", value)} + /> + updateMargin("left", value)} + /> +
+
+ +
+
+

页眉

+ +
+

+ 支持 {"${title}"}、{"${author}"} 和 {"${filename}"}。 +

+ {(["left", "center", "right"] as const).map((slot) => { + const labels = { left: "左侧", center: "中间", right: "右侧" }; + return ( +
+ + + updateHeader( + updateHeaderSlot(config.header, slot, { + content: event.target.value + }) + ) + } + /> +
+ ); + })} + +
+ +
+
+

页脚页码

+ +
+ + {config.footer.format === "custom" ? ( + + ) : null} + + + +

+ 页眉和页码将在下一阶段的分页预览中显示。 +

+
+
+ +
+ + +
+ + + ); +} diff --git a/apps/web/src/export-settings.ts b/apps/web/src/export-settings.ts new file mode 100644 index 0000000..d5ad070 --- /dev/null +++ b/apps/web/src/export-settings.ts @@ -0,0 +1,44 @@ +import { + defaultExportConfig, + exportConfigSchema, + type ExportConfig +} from "@md-to-pdf/core"; + +export const EXPORT_CONFIG_STORAGE_KEY = "md-to-pdf.export-config.v2"; + +type SettingsStorage = Pick; + +function cloneDefaultExportConfig() { + return structuredClone(defaultExportConfig); +} + +export function parseStoredExportConfig(rawValue: string | null): ExportConfig { + if (!rawValue) { + return cloneDefaultExportConfig(); + } + + try { + const parsed = exportConfigSchema.safeParse(JSON.parse(rawValue)); + return parsed.success ? parsed.data : cloneDefaultExportConfig(); + } catch { + return cloneDefaultExportConfig(); + } +} + +export function loadExportConfig( + storage: SettingsStorage = window.localStorage +) { + return parseStoredExportConfig(storage.getItem(EXPORT_CONFIG_STORAGE_KEY)); +} + +export function saveExportConfig( + config: ExportConfig, + storage: SettingsStorage = window.localStorage +) { + const parsed = exportConfigSchema.parse(config); + storage.setItem(EXPORT_CONFIG_STORAGE_KEY, JSON.stringify(parsed)); +} + +export function resetExportConfig() { + return cloneDefaultExportConfig(); +} diff --git a/apps/web/src/mermaid-config.ts b/apps/web/src/mermaid-config.ts new file mode 100644 index 0000000..bc880cb --- /dev/null +++ b/apps/web/src/mermaid-config.ts @@ -0,0 +1,25 @@ +import type { MermaidConfig } from "mermaid"; + +export const MERMAID_SECURE_CONFIG_KEYS = [ + "secure", + "securityLevel", + "startOnLoad", + "maxTextSize", + "maxEdges", + "suppressErrorRendering", + "themeCSS" +]; + +export function createMermaidSiteConfig(): MermaidConfig { + return { + startOnLoad: false, + securityLevel: "strict", + theme: "default", + look: "classic", + fontFamily: "Segoe UI, Microsoft YaHei, sans-serif", + maxTextSize: 50_000, + maxEdges: 500, + suppressErrorRendering: true, + secure: [...MERMAID_SECURE_CONFIG_KEYS] + }; +} diff --git a/apps/web/src/mermaid-renderer.ts b/apps/web/src/mermaid-renderer.ts new file mode 100644 index 0000000..3779a0a --- /dev/null +++ b/apps/web/src/mermaid-renderer.ts @@ -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 +): Promise { + 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; +} diff --git a/apps/web/src/preview-styles.ts b/apps/web/src/preview-styles.ts new file mode 100644 index 0000000..9472a20 --- /dev/null +++ b/apps/web/src/preview-styles.ts @@ -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` + ); +} diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index fe69746..4ad3623 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -80,6 +80,7 @@ h1 { .topbar-actions { display: flex; + flex-wrap: wrap; gap: 10px; } @@ -247,19 +248,11 @@ textarea:focus { } .paper { - width: 210mm; - min-width: 210mm; - min-height: 297mm; margin: 0 auto; - padding: 20mm 18mm; background: #fff; box-shadow: 0 18px 48px rgb(37 49 43 / 16%); } -.paper.is-local-theme { - padding: 8mm; -} - .preview-frame { display: block; width: 100%; @@ -276,6 +269,277 @@ textarea:focus { font-size: 0.9rem; } +.settings-layer { + position: fixed; + z-index: 20; + inset: 0; +} + +.settings-backdrop { + position: absolute; + width: 100%; + min-height: 100%; + padding: 0; + border: 0; + border-radius: 0; + inset: 0; + background: rgb(24 35 30 / 38%); +} + +.settings-drawer { + position: absolute; + display: flex; + flex-direction: column; + width: min(390px, 100vw); + height: 100%; + inset: 0 0 0 auto; + background: #f8faf8; + box-shadow: -20px 0 60px rgb(26 40 34 / 20%); +} + +.settings-heading, +.settings-actions { + display: flex; + flex: none; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 16px 20px; + border-bottom: 1px solid #d9dedb; + background: #fff; +} + +.settings-heading h2 { + margin: 1px 0 0; + color: #25332d; + font-family: Georgia, "Songti SC", serif; + font-size: 1.35rem; + font-weight: 500; +} + +.icon-button { + width: 36px; + min-height: 36px; + padding: 0; + border: 0; + background: transparent; + color: #5f6c66; + font-size: 1.7rem; + line-height: 1; +} + +.settings-content { + flex: 1; + overflow: auto; + padding: 4px 20px 28px; +} + +.settings-section { + padding: 20px 0; + border-bottom: 1px solid #dfe4e1; +} + +.settings-section:last-child { + border-bottom: 0; +} + +.settings-section h3 { + margin: 0 0 14px; + color: #30443a; + font-size: 0.88rem; +} + +.setting-section-title { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + margin-bottom: 14px; +} + +.setting-section-title h3 { + margin: 0; +} + +.setting-field { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + min-height: 42px; + color: #526159; + font-size: 0.78rem; +} + +.setting-field.stacked { + align-items: stretch; + flex-direction: column; + gap: 7px; + padding: 5px 0; +} + +.setting-field > select, +.setting-field > input, +.header-slot > input { + width: 190px; + min-width: 0; + height: 36px; + padding: 0 10px; + border: 1px solid #c9d4ce; + border-radius: 7px; + outline: 0; + background: #fff; + color: #263a32; + font-size: 0.78rem; +} + +.setting-field.stacked > input { + width: 100%; +} + +.setting-field input:focus, +.setting-field select:focus, +.header-slot > input:focus { + border-color: #74a88f; + box-shadow: 0 0 0 3px rgb(116 168 143 / 16%); +} + +.setting-field input:disabled, +.setting-field select:disabled, +.header-slot > input:disabled { + background: #edf0ee; + color: #929b97; +} + +.segmented-control { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; + margin: 8px 0 0; + padding: 0; + border: 0; +} + +.segmented-control legend { + margin-bottom: 8px; + color: #526159; + font-size: 0.78rem; +} + +.segmented-control label { + display: flex; + align-items: center; + justify-content: center; + gap: 7px; + min-height: 36px; + border: 1px solid #c9d4ce; + border-radius: 7px; + background: #fff; + color: #3f554a; + font-size: 0.78rem; + cursor: pointer; +} + +.segmented-control label:has(input:checked) { + border-color: #74a88f; + background: #e9f3ed; + color: #2d654d; + font-weight: 700; +} + +.margin-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px 18px; +} + +.margin-grid .setting-field { + align-items: flex-start; + flex-direction: column; + gap: 5px; +} + +.length-input { + display: flex; + align-items: center; + width: 100%; + height: 36px; + border: 1px solid #c9d4ce; + border-radius: 7px; + background: #fff; +} + +.length-input:focus-within { + border-color: #74a88f; + box-shadow: 0 0 0 3px rgb(116 168 143 / 16%); +} + +.length-input input { + min-width: 0; + width: 100%; + height: 34px; + padding: 0 4px 0 10px; + border: 0; + outline: 0; + background: transparent; + color: #263a32; + font-size: 0.78rem; +} + +.length-input span { + padding-right: 9px; + color: #7a8580; + font-size: 0.7rem; +} + +.switch-control { + display: inline-flex; + align-items: center; + gap: 7px; + color: #526159; + font-size: 0.74rem; + cursor: pointer; +} + +.switch-control.compact { + width: 66px; + flex: none; +} + +.divider-control { + margin-top: 10px; +} + +.header-slot { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 9px; +} + +.header-slot > input { + flex: 1; + width: auto; +} + +.setting-hint { + margin: 7px 0 10px; + color: #7a8580; + font-size: 0.69rem; + line-height: 1.55; +} + +.settings-actions { + justify-content: flex-end; + border-top: 1px solid #d9dedb; + border-bottom: 0; +} + +.secondary-button { + background: #f3f5f4; + color: #58665f; +} + @media (max-width: 900px) { .topbar { align-items: flex-start; @@ -305,12 +569,7 @@ textarea:focus { } .paper { - min-height: auto; - padding: 14mm 10mm; - } - - .paper.is-local-theme { - padding: 8mm; + margin: 0 auto; } .theme-control { @@ -321,3 +580,9 @@ textarea:focus { max-width: 150px; } } + +@media (max-width: 520px) { + .settings-drawer { + width: 100vw; + } +} diff --git a/apps/web/tests/export-settings.test.ts b/apps/web/tests/export-settings.test.ts new file mode 100644 index 0000000..585c6de --- /dev/null +++ b/apps/web/tests/export-settings.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from "vitest"; +import { defaultExportConfig } from "@md-to-pdf/core"; +import { + EXPORT_CONFIG_STORAGE_KEY, + loadExportConfig, + parseStoredExportConfig, + saveExportConfig +} from "../src/export-settings"; + +function createMemoryStorage(initialValue: string | null = null) { + let value = initialValue; + return { + getItem(key: string) { + return key === EXPORT_CONFIG_STORAGE_KEY ? value : null; + }, + setItem(key: string, nextValue: string) { + if (key === EXPORT_CONFIG_STORAGE_KEY) { + value = nextValue; + } + }, + read() { + return value; + } + }; +} + +describe("导出设置缓存", () => { + it("在没有缓存时返回独立的默认配置", () => { + const first = parseStoredExportConfig(null); + const second = parseStoredExportConfig(null); + expect(first).toEqual(defaultExportConfig); + expect(first).not.toBe(second); + }); + + it("缓存损坏或版本过期时恢复默认配置", () => { + expect(parseStoredExportConfig("{")).toEqual(defaultExportConfig); + expect( + parseStoredExportConfig( + JSON.stringify({ + ...defaultExportConfig, + version: 1 + }) + ) + ).toEqual(defaultExportConfig); + }); + + it("保存并恢复经过校验的配置", () => { + const storage = createMemoryStorage(); + const config = { + ...defaultExportConfig, + paper: { + ...defaultExportConfig.paper, + format: "A3" as const, + orientation: "landscape" as const + } + }; + + saveExportConfig(config, storage); + expect(storage.read()).toContain('"format":"A3"'); + expect(loadExportConfig(storage)).toEqual(config); + }); +}); diff --git a/apps/web/tests/mermaid-config.test.ts b/apps/web/tests/mermaid-config.test.ts new file mode 100644 index 0000000..cc2304e --- /dev/null +++ b/apps/web/tests/mermaid-config.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; +import { + createMermaidSiteConfig, + MERMAID_SECURE_CONFIG_KEYS +} from "../src/mermaid-config"; + +describe("Mermaid 站点配置", () => { + it("默认使用 default 主题和 classic 外观", () => { + const config = createMermaidSiteConfig(); + expect(config.theme).toBe("default"); + expect(config.look).toBe("classic"); + expect(config.securityLevel).toBe("strict"); + expect(config.startOnLoad).toBe(false); + }); + + it("锁定安全限制和原始主题 CSS", () => { + expect(MERMAID_SECURE_CONFIG_KEYS).toEqual( + expect.arrayContaining([ + "secure", + "securityLevel", + "startOnLoad", + "maxTextSize", + "maxEdges", + "suppressErrorRendering", + "themeCSS" + ]) + ); + }); + + it("每次创建独立的 secure 配置数组", () => { + const first = createMermaidSiteConfig(); + const second = createMermaidSiteConfig(); + expect(first.secure).toEqual(second.secure); + expect(first.secure).not.toBe(second.secure); + }); +}); diff --git a/apps/web/tests/mermaid-renderer.test.ts b/apps/web/tests/mermaid-renderer.test.ts new file mode 100644 index 0000000..914600a --- /dev/null +++ b/apps/web/tests/mermaid-renderer.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it, vi } from "vitest"; +import { renderMermaidDefinitions } from "../src/mermaid-renderer"; + +describe("Mermaid 独立图表渲染", () => { + it("单个图表失败后继续渲染后续图表", async () => { + const renderDefinition = vi + .fn() + .mockRejectedValueOnce(new Error("语法错误")) + .mockResolvedValueOnce({ svg: "第二张图" }); + + const outcomes = await renderMermaidDefinitions( + ["invalid", "flowchart LR\nA --> B"], + renderDefinition + ); + + expect(renderDefinition).toHaveBeenCalledTimes(2); + expect(outcomes[0]).toMatchObject({ + success: false, + error: expect.any(Error) + }); + expect(outcomes[1]).toEqual({ + success: true, + svg: "第二张图" + }); + }); +}); diff --git a/apps/web/tests/preview-styles.test.ts b/apps/web/tests/preview-styles.test.ts new file mode 100644 index 0000000..afd768d --- /dev/null +++ b/apps/web/tests/preview-styles.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; +import { enablePrintMediaForPreview } from "../src/preview-styles"; + +describe("打印媒体预览", () => { + it("将打印媒体规则转换为屏幕预览规则", () => { + const css = ` +html { font-size: 16px; } +@media print { + html { font-size: 13px; } +} +`; + + expect(enablePrintMediaForPreview(css)).toContain("@media screen {"); + expect(enablePrintMediaForPreview(css)).toContain( + "html { font-size: 13px; }" + ); + }); + + it("支持 only print 和带条件的打印媒体规则", () => { + const css = [ + "@media only print { body { color: black; } }", + "@media print and (color) { body { background: white; } }" + ].join("\n"); + + expect(enablePrintMediaForPreview(css)).toBe( + [ + "@media only screen { body { color: black; } }", + "@media screen and (color) { body { background: white; } }" + ].join("\n") + ); + }); + + it("不改变普通屏幕媒体规则", () => { + const css = "@media screen and (min-width: 800px) { body { margin: 0; } }"; + expect(enablePrintMediaForPreview(css)).toBe(css); + }); +}); diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index 092ee58..5ca01f3 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -19,12 +19,13 @@ main 已有提交: ```text +ec14f79 feat: 扩展本地主题兼容能力 fa07472 feat: 实现网页实时预览 7224dfd feat: 实现 Markdown 渲染核心 ec48bce chore: 初始化项目骨架 ``` -当前工作区存在未提交修改,主要是主题基础 CSS 组合、安全的 CSS `@import` 展开、三套本地 Typora 默认主题导入和主题开发文档。接手时必须保留并审查这些修改,不要重置工作区。 +当前工作区存在未提交修改,主要是导出配置版本 2、导出设置抽屉、浏览器缓存和实时纸张尺寸及页边距预览。接手时必须保留并审查这些修改,不要重置工作区。 ## 2. 已完成 @@ -105,34 +106,62 @@ ec48bce chore: 初始化项目骨架 - 桌面双栏与移动端上下布局; - 5 项渲染器测试和 4 项后端测试。 +### 2.6 主题兼容增强 + +提交 `ec14f79` 已完成: + +- 主题清单可选基础 CSS; +- 基础 CSS、主体 CSS、打印 CSS 固定组合顺序; +- 安全的相对 CSS `@import` 展开和资源 URL 重写; +- CSS 导入深度、循环、外部 URL 和越界路径检查; +- GitHub、Pixyll 和 Whitey 三套本地 Typora 白色主题导入; +- 可恢复替换和默认防覆盖; +- 主题开发指南; +- 后端测试增至 6 项。 + ## 3. 当前未提交工作 以下内容已写入工作区,但尚未提交: -### 3.1 主题清单与服务端主题注册 +### 3.1 导出配置版本 2 -- 主题清单新增可选 `base` 字段。 -- CSS 按基础 CSS、主体 CSS、打印 CSS 的顺序组合,预览与未来 PDF 可复用同一结果。 -- 支持带引号或 `url()` 写法的相对 `@import`。 -- 限制导入深度为 8 层,并检测循环引用。 -- 阻止外部 URL、绝对路径和 `..` 越界导入。 -- 按每个 CSS 文件所在目录重写相对字体和图片 URL。 -- 增加基础 CSS 组合、相对导入、循环导入和外部导入测试,后端测试增至 6 项。 +- 纸张只保留 A3、A4、A5、US-Letter 和 US-Legal。 +- 固定纸张尺寸分别为 297×420、210×297、148×210、216×279 和 216×356mm。 +- 默认 A4 纵向,四边页边距均为 16mm。 +- 统一采用 96 CSS px/in、72 PDF pt/in 和 25.4mm/in 的物理单位换算。 +- 保留横向和纵向。 +- 页脚模型收敛为页码设置,支持五种页码样式、对齐和起始页码。 +- 校验页边距之和必须为正文保留正尺寸区域。 +- 增加 6 项共享配置测试。 -### 3.2 本地 Typora 默认主题导入 +### 3.2 导出设置和浏览器缓存 -- 导入器从本机 Typora 安装目录读取 `resources/style`。 -- 一次导入 GitHub、Pixyll 和 Whitey 三套适合打印的白色默认主题。 -- 每套本地主题包含独立的 Typora 基础 CSS、主题 CSS 和所需资源。 -- 主题版本从 Typora `resources/package.json` 读取;当前本机版本为 1.14.7。 -- 默认拒绝覆盖已有主题;`--replace` 使用暂存目录并将旧副本备份到 `.local/theme-backups/<时间戳>`。 -- 当前三套主题及替换操作产生的备份均位于被 Git 忽略的 `.local`,不得提交或发布。 +- 增加响应式右侧导出设置抽屉。 +- 支持五种纸张、方向和四边页边距。 +- 已提供页眉左中右内容、页脚页码样式、位置、起始页码和分隔线控件。 +- 配置和主题选择写入版本化 `localStorage`,不缓存 Markdown 正文。 +- 缓存缺失、损坏或版本过期时恢复默认配置。 +- 切换到更小纸张时自动等比例收敛过大的旧页边距。 +- 增加 3 项浏览器缓存测试和 3 项打印媒体预览测试。 -### 3.3 文档及依赖 +### 3.3 实时纸张预览 -- README 已更新三套本地 Typora 主题的导入和替换说明。 -- 新增 `docs/THEMES.md`,说明主题目录、清单字段、CSS 加载顺序、`#write` DOM、相对资源、安全限制和自定义主题流程。 -- `npm run theme:import-typora` 保持为本地导入入口。 +- 纸张尺寸、方向和四边页边距实时作用于预览。 +- 删除本地主题专用的 8mm 外边距特例。 +- 在主题 CSS 后追加共享几何 CSS,统一清除 `body` 和 `#write` 的页面宽度、内外边距限制。 +- 网页预览将主题的 `@media print` 规则按原始位置转换为屏幕预览规则,使 Typora GitHub 等主题使用与 PDF 相同的打印字号和行高。 +- 固定逻辑页面后续按 96 CSS px/in 分页,预览显示倍率只缩放外层,不参与正文换行和分页计算。 +- 页眉和页码配置将在下一阶段分页预览中显示。 + +### 3.4 Mermaid 配置与错误隔离 + +- 使用 Mermaid 官方 npm 包,当前锁定安装版本为 11.16.0。 +- 无区块配置时显式使用 `default` 主题和 `classic` 外观。 +- Mermaid 区块内容原样交给官方解析器,支持区块内部的 YAML `config` frontmatter。 +- 允许区块覆盖主题、外观、主题变量、布局及图表专属配置。 +- 固定 `strict` 安全级别、自动启动开关、文本和边数量限制、错误输出策略,并禁止区块注入原始 `themeCSS`。 +- 单个 Mermaid 图表解析或渲染失败时显示局部错误,其余图表继续渲染。 +- 增加 Mermaid 站点配置、单图错误隔离及区块 frontmatter 保留测试。 ## 4. 已执行验证 @@ -147,7 +176,9 @@ git diff --check 结果: -- 渲染器测试:5 项通过; +- 共享配置测试:6 项通过; +- 渲染器测试:6 项通过; +- 前端测试:10 项通过; - 后端测试:6 项通过; - 全项目类型检查通过; - 生产构建通过; @@ -157,6 +188,17 @@ git diff --check 已使用浏览器插件完成视觉验证: +- 导出设置抽屉桌面布局正常; +- 尺寸选择器只包含五种目标纸张; +- 默认 A4 纵向及 16mm 四边距正确; +- A5 横向和 12.5mm 上边距实时反映到纸张; +- GitHub 主题的 `#write` 宽度、最大宽度、内外边距已由共享几何层接管; +- 刷新页面后纸张、方向、页边距、页眉和页码配置可以恢复; +- 恢复默认功能正常; +- 浏览器控制台无警告或错误。 + +此前主题阶段已使用浏览器插件完成视觉验证: + - 导入并打开 `tmp/数据中台项目周报_2026_W30.md`; - 参考 `tmp/数据中台项目周报_2026_W30.pdf` 的 Typora 输出; - GitHub、Pixyll 和 Whitey 三套保留的本地主题均可正常切换; @@ -172,6 +214,9 @@ git diff --check - 工作区不是干净状态,禁止重置。 - `apps/web/src/App.tsx` 是当前预览实现的主要文件。 +- `apps/web/src/ExportSettingsDrawer.tsx` 是导出设置界面。 +- `apps/web/src/export-settings.ts` 负责版本化浏览器缓存。 +- `packages/core/src/export-config.ts` 是纸张、页边距、页眉页脚和页码的共享模型。 - `apps/server/src/app.ts` 是新增的可测试 Fastify 应用。 - `apps/server/src/theme-registry.ts` 负责内置和本地主题发现、CSS 处理及资源安全。 - `.local/themes/typora-*` 是用户本机副本,已被 Git 忽略,不得提交。 @@ -184,17 +229,20 @@ git diff --check ## 6. 推荐接手顺序 -### 阶段一:提交主题兼容增强 +### 阶段一:完成分页预览 -网页实时预览已在 `fa07472` 提交。当前主题基础 CSS、三套本地 Typora 主题导入、主题开发文档、全量验证和视觉检查均已完成。用户确认后创建独立提交: +导出设置、缓存和实时纸张几何已经完成。下一步按已确认设计引入 Paged.js,并实现: -```text -feat: 扩展本地主题兼容能力 -``` +- 逐页预览; +- 页眉左中右内容及变量替换; +- 页脚页码、总页数、对齐和起始页码; +- Mermaid 整体换页和超高图按页面正文高度等比例缩放; +- 字体、图片和 Mermaid 完成后再分页; +- 预览与未来 PDF 共用分页 HTML 和 CSS。 ### 阶段二:实现真实 PDF -开始编码前必须先给出具体设计和详细任务清单,等待用户确认。设计至少覆盖: +分页预览完成并提交后再实现: 1. 增加 Playwright 和固定版本 Chromium。 2. 抽取可复用的完整 HTML 文档组装器。 @@ -204,15 +252,8 @@ feat: 扩展本地主题兼容能力 6. 前端启用“导出 PDF”按钮并下载文件。 7. 增加 PDF 接口与端到端测试。 -### 阶段三:导出配置界面 +### 阶段三:导出配置扩展 -- 纸张尺寸; -- 横向或纵向; -- 四边页边距; -- 页眉页脚开关; -- 左中右内容区域; -- 当前页和总页数; -- 多种页码预设; - Front Matter 元数据覆盖; - 浏览器本地配置预设; - 配置 JSON 导入和导出。 diff --git a/package-lock.json b/package-lock.json index f72dc2d..c8d6551 100644 --- a/package-lock.json +++ b/package-lock.json @@ -48,7 +48,8 @@ "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^5.1.1", - "vite": "^7.2.4" + "vite": "^7.2.4", + "vitest": "^4.1.10" } }, "apps/web/node_modules/katex": { @@ -4958,6 +4959,9 @@ "version": "0.1.0", "dependencies": { "zod": "^4.1.13" + }, + "devDependencies": { + "vitest": "^4.1.10" } }, "packages/renderer": { diff --git a/package.json b/package.json index 15c204f..b374080 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,7 @@ "dev:server": "npm run dev -w @md-to-pdf/server", "dev:web": "npm run dev -w @md-to-pdf/web", "theme:import-typora": "node scripts/import-typora-theme.mjs", - "test": "npm run test -w @md-to-pdf/renderer && npm run test -w @md-to-pdf/server", + "test": "npm run test -w @md-to-pdf/core && npm run test -w @md-to-pdf/renderer && npm run test -w @md-to-pdf/web && npm run test -w @md-to-pdf/server", "typecheck": "npm run typecheck -w @md-to-pdf/core && npm run build -w @md-to-pdf/core && npm run typecheck -w @md-to-pdf/renderer && npm run build -w @md-to-pdf/renderer && npm run typecheck -w @md-to-pdf/web && npm run typecheck -w @md-to-pdf/server" }, "engines": { diff --git a/packages/core/package.json b/packages/core/package.json index b9071dd..217c6c2 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -17,9 +17,13 @@ "scripts": { "dev": "tsc -p tsconfig.json --watch --preserveWatchOutput", "build": "tsc -p tsconfig.json", + "test": "vitest run", "typecheck": "tsc -p tsconfig.json --noEmit --pretty false" }, "dependencies": { "zod": "^4.1.13" + }, + "devDependencies": { + "vitest": "^4.1.10" } } diff --git a/packages/core/src/export-config.ts b/packages/core/src/export-config.ts index 86bd8b3..3c686fb 100644 --- a/packages/core/src/export-config.ts +++ b/packages/core/src/export-config.ts @@ -1,15 +1,16 @@ import { z } from "zod"; -export const EXPORT_CONFIG_VERSION = 1; +export const EXPORT_CONFIG_VERSION = 2; +export const CSS_PIXELS_PER_INCH = 96; +export const PDF_POINTS_PER_INCH = 72; +export const MILLIMETERS_PER_INCH = 25.4; export const supportedPaperFormats = [ "A3", "A4", "A5", "Letter", - "Legal", - "Tabloid", - "Custom" + "Legal" ] as const; export const paperFormatLabels: Record< @@ -19,16 +20,52 @@ export const paperFormatLabels: Record< A3: "A3", A4: "A4", A5: "A5", - Letter: "Letter", - Legal: "Legal", - Tabloid: "Tabloid", - Custom: "自定义" + Letter: "US-Letter", + Legal: "US-Legal" +}; + +export const paperDimensionsMm: Record< + (typeof supportedPaperFormats)[number], + { width: number; height: number } +> = { + A3: { width: 297, height: 420 }, + A4: { width: 210, height: 297 }, + A5: { width: 148, height: 210 }, + Letter: { width: 216, height: 279 }, + Legal: { width: 216, height: 356 } }; const lengthSchema = z .string() .regex(/^\d+(?:\.\d+)?(?:mm|cm|in)$/, "长度必须包含 mm、cm 或 in 单位"); +export function lengthToMillimeters(value: string) { + const number = Number.parseFloat(value); + if (value.endsWith("cm")) { + return number * 10; + } + if (value.endsWith("in")) { + return number * MILLIMETERS_PER_INCH; + } + return number; +} + +export function millimetersToCssPixels(value: number) { + return (value * CSS_PIXELS_PER_INCH) / MILLIMETERS_PER_INCH; +} + +export function cssPixelsToMillimeters(value: number) { + return (value * MILLIMETERS_PER_INCH) / CSS_PIXELS_PER_INCH; +} + +export function cssPixelsToPdfPoints(value: number) { + return (value * PDF_POINTS_PER_INCH) / CSS_PIXELS_PER_INCH; +} + +export function millimetersToPdfPoints(value: number) { + return (value * PDF_POINTS_PER_INCH) / MILLIMETERS_PER_INCH; +} + const marginSchema = z.object({ top: lengthSchema, right: lengthSchema, @@ -36,40 +73,22 @@ const marginSchema = z.object({ left: lengthSchema }); -const headerFooterSlotSchema = z.object({ +const headerSlotSchema = z.object({ enabled: z.boolean(), content: z.string().max(500) }); -const headerFooterSchema = z.object({ +const headerSchema = z.object({ enabled: z.boolean(), height: lengthSchema, showDivider: z.boolean(), fontSize: lengthSchema, color: z.string(), - left: headerFooterSlotSchema, - center: headerFooterSlotSchema, - right: headerFooterSlotSchema + left: headerSlotSchema, + center: headerSlotSchema, + right: headerSlotSchema }); -const paperSchema = z - .object({ - format: z.enum(supportedPaperFormats), - orientation: z.enum(["portrait", "landscape"]), - width: lengthSchema.optional(), - height: lengthSchema.optional(), - margins: marginSchema - }) - .superRefine((paper, context) => { - if (paper.format === "Custom" && (!paper.width || !paper.height)) { - context.addIssue({ - code: "custom", - message: "自定义纸张必须同时指定宽度和高度", - path: ["width"] - }); - } - }); - export const pageNumberFormatSchema = z.enum([ "page", "page-total", @@ -78,21 +97,63 @@ export const pageNumberFormatSchema = z.enum([ "custom" ]); +const footerSchema = z.object({ + enabled: z.boolean(), + height: lengthSchema, + showDivider: z.boolean(), + fontSize: lengthSchema, + color: z.string(), + alignment: z.enum(["left", "center", "right"]), + format: pageNumberFormatSchema, + template: z.string().max(500).optional(), + startFrom: z.number().int().positive() +}); + +const paperSchema = z + .object({ + format: z.enum(supportedPaperFormats), + orientation: z.enum(["portrait", "landscape"]), + margins: marginSchema + }) + .superRefine((paper, context) => { + const sourceDimensions = paperDimensionsMm[paper.format]; + const dimensions = + paper.orientation === "portrait" + ? sourceDimensions + : { + width: sourceDimensions.height, + height: sourceDimensions.width + }; + const horizontalMargins = + lengthToMillimeters(paper.margins.left) + + lengthToMillimeters(paper.margins.right); + const verticalMargins = + lengthToMillimeters(paper.margins.top) + + lengthToMillimeters(paper.margins.bottom); + + if (horizontalMargins >= dimensions.width) { + context.addIssue({ + code: "custom", + message: "左右页边距之和必须小于纸张宽度", + path: ["margins", "right"] + }); + } + if (verticalMargins >= dimensions.height) { + context.addIssue({ + code: "custom", + message: "上下页边距之和必须小于纸张高度", + path: ["margins", "bottom"] + }); + } + }); + export const exportConfigSchema = z.object({ version: z.literal(EXPORT_CONFIG_VERSION), name: z.string().min(1).max(100), themeId: z.string().min(1), paper: paperSchema, - header: headerFooterSchema, - footer: headerFooterSchema, - pageNumber: z.object({ - enabled: z.boolean(), - position: z.enum(["header", "footer"]), - alignment: z.enum(["left", "center", "right"]), - format: pageNumberFormatSchema, - template: z.string().max(500).optional(), - startFrom: z.number().int().positive() - }), + header: headerSchema, + footer: footerSchema, metadata: z.object({ title: z.string().max(300), author: z.string().max(300), @@ -109,25 +170,37 @@ export const exportConfigSchema = z.object({ export type ExportConfig = z.infer; export type PaperFormat = ExportConfig["paper"]["format"]; -export type HeaderFooterConfig = ExportConfig["header"]; +export type PaperOrientation = ExportConfig["paper"]["orientation"]; +export type HeaderConfig = ExportConfig["header"]; +export type FooterConfig = ExportConfig["footer"]; -const disabledSlot = { +export function getPaperDimensionsMm( + format: PaperFormat, + orientation: PaperOrientation +) { + const dimensions = paperDimensionsMm[format]; + return orientation === "portrait" + ? dimensions + : { width: dimensions.height, height: dimensions.width }; +} + +const disabledHeaderSlot = { enabled: false, content: "" } as const; export const defaultExportConfig: ExportConfig = { version: EXPORT_CONFIG_VERSION, - name: "默认 A4 技术文档", - themeId: "typora-like", + name: "默认 A4 文档", + themeId: "typora-github", paper: { format: "A4", orientation: "portrait", margins: { - top: "20mm", - right: "18mm", - bottom: "20mm", - left: "18mm" + top: "16mm", + right: "16mm", + bottom: "16mm", + left: "16mm" } }, header: { @@ -136,9 +209,9 @@ export const defaultExportConfig: ExportConfig = { showDivider: false, fontSize: "3mm", color: "#6b7280", - left: disabledSlot, - center: disabledSlot, - right: disabledSlot + left: disabledHeaderSlot, + center: disabledHeaderSlot, + right: disabledHeaderSlot }, footer: { enabled: true, @@ -146,16 +219,6 @@ export const defaultExportConfig: ExportConfig = { showDivider: false, fontSize: "3mm", color: "#6b7280", - left: disabledSlot, - center: { - enabled: true, - content: "${page} / ${pages}" - }, - right: disabledSlot - }, - pageNumber: { - enabled: true, - position: "footer", alignment: "center", format: "page-total", startFrom: 1 diff --git a/packages/core/tests/export-config.test.ts b/packages/core/tests/export-config.test.ts new file mode 100644 index 0000000..44c09fc --- /dev/null +++ b/packages/core/tests/export-config.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from "vitest"; +import { + cssPixelsToMillimeters, + defaultExportConfig, + exportConfigSchema, + getPaperDimensionsMm, + millimetersToCssPixels, + millimetersToPdfPoints, + paperDimensionsMm, + supportedPaperFormats +} from "../src/export-config.js"; + +describe("导出配置", () => { + it("只保留五种固定纸张尺寸", () => { + expect(supportedPaperFormats).toEqual([ + "A3", + "A4", + "A5", + "Letter", + "Legal" + ]); + expect(paperDimensionsMm).toEqual({ + A3: { width: 297, height: 420 }, + A4: { width: 210, height: 297 }, + A5: { width: 148, height: 210 }, + Letter: { width: 216, height: 279 }, + Legal: { width: 216, height: 356 } + }); + }); + + it("使用 A4 和 16mm 作为默认纸张配置", () => { + expect(defaultExportConfig.paper).toEqual({ + format: "A4", + orientation: "portrait", + margins: { + top: "16mm", + right: "16mm", + bottom: "16mm", + left: "16mm" + } + }); + expect(exportConfigSchema.safeParse(defaultExportConfig).success).toBe(true); + }); + + it("使用统一的 96 CSS px/in 和 72 PDF pt/in 换算物理尺寸", () => { + expect(millimetersToCssPixels(25.4)).toBeCloseTo(96); + expect(cssPixelsToMillimeters(96)).toBeCloseTo(25.4); + expect(millimetersToCssPixels(210)).toBeCloseTo(793.700787); + expect(millimetersToPdfPoints(210)).toBeCloseTo(595.275591); + }); + + it("根据方向交换纸张宽高", () => { + expect(getPaperDimensionsMm("A5", "portrait")).toEqual({ + width: 148, + height: 210 + }); + expect(getPaperDimensionsMm("A5", "landscape")).toEqual({ + width: 210, + height: 148 + }); + }); + + it("拒绝旧配置版本和已移除的纸张类型", () => { + expect( + exportConfigSchema.safeParse({ + ...defaultExportConfig, + version: 1 + }).success + ).toBe(false); + expect( + exportConfigSchema.safeParse({ + ...defaultExportConfig, + paper: { + ...defaultExportConfig.paper, + format: "Tabloid" + } + }).success + ).toBe(false); + }); + + it("拒绝没有正文可用区域的页边距", () => { + expect( + exportConfigSchema.safeParse({ + ...defaultExportConfig, + paper: { + ...defaultExportConfig.paper, + margins: { + top: "149mm", + right: "105mm", + bottom: "149mm", + left: "105mm" + } + } + }).success + ).toBe(false); + }); +}); diff --git a/packages/renderer/src/render-markdown.ts b/packages/renderer/src/render-markdown.ts index b629cef..a0bbbe4 100644 --- a/packages/renderer/src/render-markdown.ts +++ b/packages/renderer/src/render-markdown.ts @@ -78,6 +78,7 @@ const markdown = new MarkdownIt(markdownOptions) const defaultFenceRenderer = markdown.renderer.rules.fence; const lengthStylePattern = /^-?\d+(?:\.\d+)?(?:em|ex|px|%)$/u; +const tableTextAlignStylePattern = /^(?:left|center|right)$/u; markdown.renderer.rules.fence = ( tokens, @@ -131,8 +132,8 @@ const safeHtmlOptions: sanitizeHtml.IOptions = { li: ["class", "value"], ol: ["class", "start"], span: ["class", "style"], - td: ["colspan", "rowspan"], - th: ["colspan", "rowspan", "scope"] + td: ["colspan", "rowspan", "style"], + th: ["colspan", "rowspan", "scope", "style"] }, allowedSchemes: ["http", "https", "mailto"], allowedSchemesByTag: { @@ -149,6 +150,12 @@ const safeHtmlOptions: sanitizeHtml.IOptions = { "padding-left": [lengthStylePattern], "vertical-align": [lengthStylePattern], "border-bottom-width": [lengthStylePattern] + }, + td: { + "text-align": [tableTextAlignStylePattern] + }, + th: { + "text-align": [tableTextAlignStylePattern] } }, transformTags: { diff --git a/packages/renderer/tests/render-markdown.test.ts b/packages/renderer/tests/render-markdown.test.ts index 7e5ae8e..3f52e1f 100644 --- a/packages/renderer/tests/render-markdown.test.ts +++ b/packages/renderer/tests/render-markdown.test.ts @@ -33,6 +33,21 @@ const answer = 42; ); }); + it("保留 Markdown 表格的列对齐语义", () => { + const result = renderMarkdown(` +| 左对齐 | 居中 | 右对齐 | +| :--- | :---: | ---: | +| A | B | C | +`); + + expect(result.bodyHtml).toContain('左对齐'); + expect(result.bodyHtml).toContain('居中'); + expect(result.bodyHtml).toContain('右对齐'); + expect(result.bodyHtml).toContain('A'); + expect(result.bodyHtml).toContain('B'); + expect(result.bodyHtml).toContain('C'); + }); + it("读取并规范化 Front Matter 元数据", () => { const result = renderMarkdown(`--- title: 项目报告 @@ -74,6 +89,29 @@ flowchart LR ); }); + it("完整保留 Mermaid 区块内的 config frontmatter", () => { + const result = renderMarkdown(` +\`\`\`mermaid +--- +title: Frontmatter 示例 +config: + theme: forest + look: handDrawn + flowchart: + curve: basis +--- +flowchart LR + A[开始] --> B[结束] +\`\`\` +`); + + expect(result.bodyHtml).toContain("title: Frontmatter 示例"); + expect(result.bodyHtml).toContain("theme: forest"); + expect(result.bodyHtml).toContain("look: handDrawn"); + expect(result.bodyHtml).toContain("curve: basis"); + expect(result.bodyHtml).toContain("flowchart LR"); + }); + it("阻止原始 HTML 和危险链接形成可执行内容", () => { const result = renderMarkdown(`