feat: 完善导出设置与打印预览

This commit is contained in:
SkyJourney
2026-07-26 01:36:51 +08:00
parent ec14f7970a
commit 52cf816683
20 changed files with 1552 additions and 146 deletions
+3 -1
View File
@@ -6,6 +6,7 @@
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "tsc -b && vite build", "build": "tsc -b && vite build",
"test": "vitest run",
"typecheck": "tsc -b --pretty false" "typecheck": "tsc -b --pretty false"
}, },
"dependencies": { "dependencies": {
@@ -20,6 +21,7 @@
"@types/react": "^19.2.7", "@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1", "@vitejs/plugin-react": "^5.1.1",
"vite": "^7.2.4" "vite": "^7.2.4",
"vitest": "^4.1.10"
} }
} }
+149 -30
View File
@@ -1,12 +1,26 @@
import { import {
type CSSProperties,
type ChangeEvent, type ChangeEvent,
useEffect, useEffect,
useMemo, useMemo,
useRef, useRef,
useState useState
} from "react"; } from "react";
import {
getPaperDimensionsMm,
type ExportConfig
} from "@md-to-pdf/core";
import highlightCss from "highlight.js/styles/github.css?inline"; import highlightCss from "highlight.js/styles/github.css?inline";
import katexCss from "katex/dist/katex.min.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 { interface RenderedMarkdown {
articleHtml: string; articleHtml: string;
@@ -89,12 +103,48 @@ svg {
max-width: 100%; max-width: 100%;
} }
#write table {
width: 100%;
table-layout: auto;
}
#write th,
#write td {
overflow-wrap: anywhere;
}
.mermaid { .mermaid {
display: flex; display: flex;
justify-content: center; justify-content: center;
margin: 1.5em 0; margin: 1.5em 0;
overflow: auto; 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; let mermaidPromise: Promise<(typeof import("mermaid"))["default"]> | undefined;
@@ -103,12 +153,7 @@ let mermaidRenderSequence = 0;
async function loadMermaid() { async function loadMermaid() {
if (!mermaidPromise) { if (!mermaidPromise) {
mermaidPromise = import("mermaid").then(({ default: mermaid }) => { mermaidPromise = import("mermaid").then(({ default: mermaid }) => {
mermaid.initialize({ mermaid.initialize(createMermaidSiteConfig());
startOnLoad: false,
securityLevel: "strict",
theme: "neutral",
fontFamily: "Segoe UI, Microsoft YaHei, sans-serif"
});
return mermaid; return mermaid;
}); });
} }
@@ -132,6 +177,8 @@ function buildPreviewDocument(
result: RenderedMarkdown, result: RenderedMarkdown,
themeCss: string themeCss: string
) { ) {
const previewThemeCss = enablePrintMediaForPreview(themeCss);
return `<!doctype html> return `<!doctype html>
<html lang="${escapeHtml(result.metadata.language || "zh-CN")}"> <html lang="${escapeHtml(result.metadata.language || "zh-CN")}">
<head> <head>
@@ -145,7 +192,8 @@ function buildPreviewDocument(
<style>${escapeStyleContent(previewBaseCss)}</style> <style>${escapeStyleContent(previewBaseCss)}</style>
<style>${escapeStyleContent(highlightCss)}</style> <style>${escapeStyleContent(highlightCss)}</style>
<style>${escapeStyleContent(katexCss)}</style> <style>${escapeStyleContent(katexCss)}</style>
<style>${escapeStyleContent(themeCss)}</style> <style>${escapeStyleContent(previewThemeCss)}</style>
<style>${escapeStyleContent(previewGeometryCss)}</style>
</head> </head>
<body> <body>
${result.articleHtml} ${result.articleHtml}
@@ -158,8 +206,10 @@ export function App() {
const [fileName, setFileName] = useState("示例文档.md"); const [fileName, setFileName] = useState("示例文档.md");
const [result, setResult] = useState<RenderedMarkdown | null>(null); const [result, setResult] = useState<RenderedMarkdown | null>(null);
const [themes, setThemes] = useState<ThemeSummary[]>([]); const [themes, setThemes] = useState<ThemeSummary[]>([]);
const [themeId, setThemeId] = useState("typora-like"); const [exportConfig, setExportConfig] =
useState<ExportConfig>(loadExportConfig);
const [themeCss, setThemeCss] = useState(""); const [themeCss, setThemeCss] = useState("");
const [settingsOpen, setSettingsOpen] = useState(false);
const [status, setStatus] = useState("正在准备预览…"); const [status, setStatus] = useState("正在准备预览…");
const [renderError, setRenderError] = useState(""); const [renderError, setRenderError] = useState("");
const [themeError, setThemeError] = useState(""); const [themeError, setThemeError] = useState("");
@@ -167,7 +217,21 @@ export function App() {
const previewFrameRef = useRef<HTMLIFrameElement>(null); const previewFrameRef = useRef<HTMLIFrameElement>(null);
const error = renderError || themeError || mermaidError; const error = renderError || themeError || mermaidError;
const themeId = exportConfig.themeId;
const selectedTheme = themes.find((theme) => theme.id === 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( const previewDocument = useMemo(
() => () =>
result && themeCss ? buildPreviewDocument(result, themeCss) : undefined, result && themeCss ? buildPreviewDocument(result, themeCss) : undefined,
@@ -188,13 +252,22 @@ export function App() {
}) })
.then(({ themes: availableThemes }) => { .then(({ themes: availableThemes }) => {
setThemes(availableThemes); setThemes(availableThemes);
const preferredTheme = setExportConfig((currentConfig) => {
availableThemes.find((theme) => theme.id === "typora-github") ?? if (
availableThemes.find((theme) => theme.id === "typora-like") ?? availableThemes.some(
availableThemes[0]; (theme) => theme.id === currentConfig.themeId
if (preferredTheme) { )
setThemeId(preferredTheme.id); ) {
} 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) => { .catch((reason: unknown) => {
if (!controller.signal.aborted) { if (!controller.signal.aborted) {
@@ -207,6 +280,14 @@ export function App() {
return () => controller.abort(); return () => controller.abort();
}, []); }, []);
useEffect(() => {
try {
saveExportConfig(exportConfig);
} catch {
setRenderError("无法保存导出设置,当前设置仅在本次页面中有效");
}
}, [exportConfig]);
useEffect(() => { useEffect(() => {
const controller = new AbortController(); const controller = new AbortController();
setThemeCss(""); setThemeCss("");
@@ -306,16 +387,42 @@ export function App() {
if (nodes.length > 0) { if (nodes.length > 0) {
try { try {
const mermaid = await loadMermaid(); const mermaid = await loadMermaid();
for (const node of nodes) { const outcomes = await renderMermaidDefinitions(
const definition = node.textContent ?? ""; nodes.map((node) => node.textContent ?? ""),
mermaidRenderSequence += 1; async (definition) => {
const { svg, bindFunctions } = await mermaid.render( mermaidRenderSequence += 1;
`mermaid-preview-${mermaidRenderSequence}`, return mermaid.render(
definition `mermaid-preview-${mermaidRenderSequence}`,
); definition
node.innerHTML = svg; );
}
);
const errors: string[] = [];
for (const [index, outcome] of outcomes.entries()) {
const node = nodes[index];
if (!node) {
continue;
}
node.removeAttribute("data-mermaid-pending"); 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) { } catch (reason: unknown) {
setMermaidError( setMermaidError(
@@ -362,6 +469,9 @@ export function App() {
onChange={handleFileChange} onChange={handleFileChange}
/> />
</label> </label>
<button type="button" onClick={() => setSettingsOpen(true)}>
</button>
<button type="button" disabled title="下一阶段实现"> <button type="button" disabled title="下一阶段实现">
PDF PDF
</button> </button>
@@ -398,7 +508,12 @@ export function App() {
<select <select
aria-label="预览主题" aria-label="预览主题"
value={themeId} value={themeId}
onChange={(event) => setThemeId(event.target.value)} onChange={(event) =>
setExportConfig((currentConfig) => ({
...currentConfig,
themeId: event.target.value
}))
}
> >
{themes.length === 0 ? ( {themes.length === 0 ? (
<option value={themeId}></option> <option value={themeId}></option>
@@ -416,11 +531,7 @@ export function App() {
</label> </label>
</div> </div>
<div className="preview-scroll"> <div className="preview-scroll">
<div <div className="paper" style={paperStyle}>
className={`paper${
selectedTheme?.source === "local" ? " is-local-theme" : ""
}`}
>
{previewDocument ? ( {previewDocument ? (
<iframe <iframe
ref={previewFrameRef} ref={previewFrameRef}
@@ -437,6 +548,14 @@ export function App() {
</div> </div>
</section> </section>
</section> </section>
{settingsOpen ? (
<ExportSettingsDrawer
config={exportConfig}
onChange={setExportConfig}
onClose={() => setSettingsOpen(false)}
onReset={() => setExportConfig(resetExportConfig())}
/>
) : null}
</main> </main>
); );
} }
+489
View File
@@ -0,0 +1,489 @@
import {
getPaperDimensionsMm,
paperFormatLabels,
supportedPaperFormats,
type ExportConfig,
type FooterConfig,
type HeaderConfig
} from "@md-to-pdf/core";
import { useEffect, useState } from "react";
interface ExportSettingsDrawerProps {
config: ExportConfig;
onChange: (config: ExportConfig) => void;
onClose: () => void;
onReset: () => void;
}
interface LengthInputProps {
label: string;
value: string;
maximum: number;
onChange: (value: string) => void;
}
const pageNumberFormatLabels: Record<FooterConfig["format"], string> = {
page: "1",
"page-total": "1 / 10",
"chinese-page-total": "第 1 页 / 共 10 页",
"dash-page": "- 1 -",
custom: "自定义模板"
};
function millimeters(value: string) {
return Number.parseFloat(value.replace(/mm$/i, ""));
}
function fitMarginPair(
first: number,
second: number,
available: number
) {
const total = first + second;
if (total <= available || total === 0) {
return [first, second] as const;
}
const scale = available / total;
const scaledFirst = Math.floor(first * scale * 10) / 10;
return [
scaledFirst,
Math.floor((available - scaledFirst) * 10) / 10
] as const;
}
function LengthInput({
label,
value,
maximum,
onChange
}: LengthInputProps) {
const [draft, setDraft] = useState(String(millimeters(value)));
useEffect(() => {
setDraft(String(millimeters(value)));
}, [value]);
function commit() {
const number = Number.parseFloat(draft);
if (!Number.isFinite(number)) {
setDraft(String(millimeters(value)));
return;
}
const normalized = Math.min(Math.max(number, 0), maximum);
const rounded = Math.round(normalized * 10) / 10;
setDraft(String(rounded));
onChange(`${rounded}mm`);
}
return (
<label className="setting-field">
<span>{label}</span>
<span className="length-input">
<input
type="number"
min="0"
max={maximum}
step="0.1"
value={draft}
onBlur={commit}
onChange={(event) => setDraft(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.currentTarget.blur();
}
}}
/>
<span>mm</span>
</span>
</label>
);
}
function updateHeaderSlot(
header: HeaderConfig,
slot: "left" | "center" | "right",
value: Partial<HeaderConfig[typeof slot]>
) {
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<ExportConfig["paper"]>) {
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<HeaderConfig>) {
onChange({
...config,
header: {
...config.header,
...header
}
});
}
function updateFooter(footer: Partial<FooterConfig>) {
onChange({
...config,
footer: {
...config.footer,
...footer
}
});
}
return (
<div className="settings-layer">
<button
type="button"
className="settings-backdrop"
aria-label="关闭导出设置"
onClick={onClose}
/>
<aside
className="settings-drawer"
aria-label="导出设置"
aria-modal="true"
role="dialog"
>
<div className="settings-heading">
<div>
<span className="panel-kicker"> PDF </span>
<h2></h2>
</div>
<button
type="button"
className="icon-button"
aria-label="关闭导出设置"
onClick={onClose}
>
×
</button>
</div>
<div className="settings-content">
<section className="settings-section">
<h3></h3>
<label className="setting-field">
<span></span>
<select
value={config.paper.format}
onChange={(event) =>
updatePaper({
format: event.target
.value as ExportConfig["paper"]["format"]
})
}
>
{supportedPaperFormats.map((format) => (
<option key={format} value={format}>
{paperFormatLabels[format]}
</option>
))}
</select>
</label>
<fieldset className="segmented-control">
<legend></legend>
<label>
<input
type="radio"
name="paper-orientation"
checked={config.paper.orientation === "portrait"}
onChange={() => updatePaper({ orientation: "portrait" })}
/>
</label>
<label>
<input
type="radio"
name="paper-orientation"
checked={config.paper.orientation === "landscape"}
onChange={() => updatePaper({ orientation: "landscape" })}
/>
</label>
</fieldset>
<p className="setting-hint">
{dimensions.width} × {dimensions.height}mm
</p>
</section>
<section className="settings-section">
<h3></h3>
<div className="margin-grid">
<LengthInput
label="上"
value={margins.top}
maximum={
dimensions.height - millimeters(margins.bottom) - verticalGap
}
onChange={(value) => updateMargin("top", value)}
/>
<LengthInput
label="右"
value={margins.right}
maximum={
dimensions.width - millimeters(margins.left) - horizontalGap
}
onChange={(value) => updateMargin("right", value)}
/>
<LengthInput
label="下"
value={margins.bottom}
maximum={
dimensions.height - millimeters(margins.top) - verticalGap
}
onChange={(value) => updateMargin("bottom", value)}
/>
<LengthInput
label="左"
value={margins.left}
maximum={
dimensions.width - millimeters(margins.right) - horizontalGap
}
onChange={(value) => updateMargin("left", value)}
/>
</div>
</section>
<section className="settings-section">
<div className="setting-section-title">
<h3></h3>
<label className="switch-control">
<input
type="checkbox"
checked={config.header.enabled}
onChange={(event) =>
updateHeader({ enabled: event.target.checked })
}
/>
<span>{config.header.enabled ? "开启" : "关闭"}</span>
</label>
</div>
<p className="setting-hint">
{"${title}"}{"${author}"} {"${filename}"}
</p>
{(["left", "center", "right"] as const).map((slot) => {
const labels = { left: "左侧", center: "中间", right: "右侧" };
return (
<div className="header-slot" key={slot}>
<label className="switch-control compact">
<input
type="checkbox"
checked={config.header[slot].enabled}
disabled={!config.header.enabled}
onChange={(event) =>
updateHeader(
updateHeaderSlot(config.header, slot, {
enabled: event.target.checked
})
)
}
/>
<span>{labels[slot]}</span>
</label>
<input
type="text"
value={config.header[slot].content}
disabled={
!config.header.enabled ||
!config.header[slot].enabled
}
placeholder={`页眉${labels[slot]}内容`}
onChange={(event) =>
updateHeader(
updateHeaderSlot(config.header, slot, {
content: event.target.value
})
)
}
/>
</div>
);
})}
<label className="switch-control divider-control">
<input
type="checkbox"
checked={config.header.showDivider}
disabled={!config.header.enabled}
onChange={(event) =>
updateHeader({ showDivider: event.target.checked })
}
/>
<span>线</span>
</label>
</section>
<section className="settings-section">
<div className="setting-section-title">
<h3></h3>
<label className="switch-control">
<input
type="checkbox"
checked={config.footer.enabled}
onChange={(event) =>
updateFooter({ enabled: event.target.checked })
}
/>
<span>{config.footer.enabled ? "开启" : "关闭"}</span>
</label>
</div>
<label className="setting-field">
<span></span>
<select
value={config.footer.format}
disabled={!config.footer.enabled}
onChange={(event) =>
updateFooter({
format: event.target.value as FooterConfig["format"]
})
}
>
{Object.entries(pageNumberFormatLabels).map(
([format, label]) => (
<option key={format} value={format}>
{label}
</option>
)
)}
</select>
</label>
{config.footer.format === "custom" ? (
<label className="setting-field stacked">
<span></span>
<input
type="text"
value={config.footer.template ?? ""}
disabled={!config.footer.enabled}
placeholder="${page} / ${pages}"
onChange={(event) =>
updateFooter({ template: event.target.value })
}
/>
</label>
) : null}
<label className="setting-field">
<span></span>
<select
value={config.footer.alignment}
disabled={!config.footer.enabled}
onChange={(event) =>
updateFooter({
alignment: event.target
.value as FooterConfig["alignment"]
})
}
>
<option value="left"></option>
<option value="center"></option>
<option value="right"></option>
</select>
</label>
<label className="setting-field">
<span></span>
<input
type="number"
min="1"
step="1"
value={config.footer.startFrom}
disabled={!config.footer.enabled}
onChange={(event) =>
updateFooter({
startFrom: Math.max(
1,
Number.parseInt(event.target.value, 10) || 1
)
})
}
/>
</label>
<label className="switch-control divider-control">
<input
type="checkbox"
checked={config.footer.showDivider}
disabled={!config.footer.enabled}
onChange={(event) =>
updateFooter({ showDivider: event.target.checked })
}
/>
<span>线</span>
</label>
<p className="setting-hint">
</p>
</section>
</div>
<div className="settings-actions">
<button type="button" className="secondary-button" onClick={onReset}>
</button>
<button type="button" onClick={onClose}>
</button>
</div>
</aside>
</div>
);
}
+44
View File
@@ -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<Storage, "getItem" | "setItem">;
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();
}
+25
View File
@@ -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]
};
}
+37
View File
@@ -0,0 +1,37 @@
export interface MermaidSvgResult {
svg: string;
bindFunctions?: (element: Element) => void;
}
export type MermaidRenderOutcome =
| ({ success: true } & MermaidSvgResult)
| {
success: false;
error: unknown;
};
export async function renderMermaidDefinitions(
definitions: string[],
renderDefinition: (
definition: string,
index: number
) => Promise<MermaidSvgResult>
): Promise<MermaidRenderOutcome[]> {
const outcomes: MermaidRenderOutcome[] = [];
for (const [index, definition] of definitions.entries()) {
try {
outcomes.push({
success: true,
...(await renderDefinition(definition, index))
});
} catch (error: unknown) {
outcomes.push({
success: false,
error
});
}
}
return outcomes;
}
+10
View File
@@ -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`
);
}
+279 -14
View File
@@ -80,6 +80,7 @@ h1 {
.topbar-actions { .topbar-actions {
display: flex; display: flex;
flex-wrap: wrap;
gap: 10px; gap: 10px;
} }
@@ -247,19 +248,11 @@ textarea:focus {
} }
.paper { .paper {
width: 210mm;
min-width: 210mm;
min-height: 297mm;
margin: 0 auto; margin: 0 auto;
padding: 20mm 18mm;
background: #fff; background: #fff;
box-shadow: 0 18px 48px rgb(37 49 43 / 16%); box-shadow: 0 18px 48px rgb(37 49 43 / 16%);
} }
.paper.is-local-theme {
padding: 8mm;
}
.preview-frame { .preview-frame {
display: block; display: block;
width: 100%; width: 100%;
@@ -276,6 +269,277 @@ textarea:focus {
font-size: 0.9rem; 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) { @media (max-width: 900px) {
.topbar { .topbar {
align-items: flex-start; align-items: flex-start;
@@ -305,12 +569,7 @@ textarea:focus {
} }
.paper { .paper {
min-height: auto; margin: 0 auto;
padding: 14mm 10mm;
}
.paper.is-local-theme {
padding: 8mm;
} }
.theme-control { .theme-control {
@@ -321,3 +580,9 @@ textarea:focus {
max-width: 150px; max-width: 150px;
} }
} }
@media (max-width: 520px) {
.settings-drawer {
width: 100vw;
}
}
+62
View File
@@ -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);
});
});
+36
View File
@@ -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);
});
});
+26
View File
@@ -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: "<svg>第二张图</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: "<svg>第二张图</svg>"
});
});
});
+37
View File
@@ -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);
});
});
+76 -35
View File
@@ -19,12 +19,13 @@ main
已有提交: 已有提交:
```text ```text
ec14f79 feat: 扩展本地主题兼容能力
fa07472 feat: 实现网页实时预览 fa07472 feat: 实现网页实时预览
7224dfd feat: 实现 Markdown 渲染核心 7224dfd feat: 实现 Markdown 渲染核心
ec48bce chore: 初始化项目骨架 ec48bce chore: 初始化项目骨架
``` ```
当前工作区存在未提交修改,主要是主题基础 CSS 组合、安全的 CSS `@import` 展开、三套本地 Typora 默认主题导入和主题开发文档。接手时必须保留并审查这些修改,不要重置工作区。 当前工作区存在未提交修改,主要是导出配置版本 2、导出设置抽屉、浏览器缓存和实时纸张尺寸及页边距预览。接手时必须保留并审查这些修改,不要重置工作区。
## 2. 已完成 ## 2. 已完成
@@ -105,34 +106,62 @@ ec48bce chore: 初始化项目骨架
- 桌面双栏与移动端上下布局; - 桌面双栏与移动端上下布局;
- 5 项渲染器测试和 4 项后端测试。 - 5 项渲染器测试和 4 项后端测试。
### 2.6 主题兼容增强
提交 `ec14f79` 已完成:
- 主题清单可选基础 CSS
- 基础 CSS、主体 CSS、打印 CSS 固定组合顺序;
- 安全的相对 CSS `@import` 展开和资源 URL 重写;
- CSS 导入深度、循环、外部 URL 和越界路径检查;
- GitHub、Pixyll 和 Whitey 三套本地 Typora 白色主题导入;
- 可恢复替换和默认防覆盖;
- 主题开发指南;
- 后端测试增至 6 项。
## 3. 当前未提交工作 ## 3. 当前未提交工作
以下内容已写入工作区,但尚未提交: 以下内容已写入工作区,但尚未提交:
### 3.1 主题清单与服务端主题注册 ### 3.1 导出配置版本 2
- 主题清单新增可选 `base` 字段 - 纸张只保留 A3、A4、A5、US-Letter 和 US-Legal
- CSS 按基础 CSS、主体 CSS、打印 CSS 的顺序组合,预览与未来 PDF 可复用同一结果 - 固定纸张尺寸分别为 297×420、210×297、148×210、216×279 和 216×356mm
- 支持带引号或 `url()` 写法的相对 `@import` - 默认 A4 纵向,四边页边距均为 16mm
- 限制导入深度为 8 层,并检测循环引用 - 统一采用 96 CSS px/in、72 PDF pt/in 和 25.4mm/in 的物理单位换算
- 阻止外部 URL、绝对路径和 `..` 越界导入 - 保留横向和纵向
- 按每个 CSS 文件所在目录重写相对字体和图片 URL - 页脚模型收敛为页码设置,支持五种页码样式、对齐和起始页码
- 增加基础 CSS 组合、相对导入、循环导入和外部导入测试,后端测试增至 6 项 - 校验页边距之和必须为正文保留正尺寸区域
- 增加 6 项共享配置测试。
### 3.2 本地 Typora 默认主题导入 ### 3.2 导出设置和浏览器缓存
- 导入器从本机 Typora 安装目录读取 `resources/style` - 增加响应式右侧导出设置抽屉
- 一次导入 GitHub、Pixyll 和 Whitey 三套适合打印的白色默认主题 - 支持五种纸张、方向和四边页边距
- 每套本地主题包含独立的 Typora 基础 CSS、主题 CSS 和所需资源 - 已提供页眉左中右内容、页脚页码样式、位置、起始页码和分隔线控件
- 主题版本从 Typora `resources/package.json` 读取;当前本机版本为 1.14.7 - 配置和主题选择写入版本化 `localStorage`,不缓存 Markdown 正文
- 默认拒绝覆盖已有主题;`--replace` 使用暂存目录并将旧副本备份到 `.local/theme-backups/<时间戳>` - 缓存缺失、损坏或版本过期时恢复默认配置
- 当前三套主题及替换操作产生的备份均位于被 Git 忽略的 `.local`,不得提交或发布 - 切换到更小纸张时自动等比例收敛过大的旧页边距
- 增加 3 项浏览器缓存测试和 3 项打印媒体预览测试。
### 3.3 文档及依赖 ### 3.3 实时纸张预览
- README 已更新三套本地 Typora 主题的导入和替换说明 - 纸张尺寸、方向和四边页边距实时作用于预览
- 新增 `docs/THEMES.md`,说明主题目录、清单字段、CSS 加载顺序、`#write` DOM、相对资源、安全限制和自定义主题流程 - 删除本地主题专用的 8mm 外边距特例
- `npm run theme:import-typora` 保持为本地导入入口 - 在主题 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. 已执行验证 ## 4. 已执行验证
@@ -147,7 +176,9 @@ git diff --check
结果: 结果:
- 渲染器测试:5 项通过; - 共享配置测试:6 项通过;
- 渲染器测试:6 项通过;
- 前端测试:10 项通过;
- 后端测试:6 项通过; - 后端测试:6 项通过;
- 全项目类型检查通过; - 全项目类型检查通过;
- 生产构建通过; - 生产构建通过;
@@ -157,6 +188,17 @@ git diff --check
已使用浏览器插件完成视觉验证: 已使用浏览器插件完成视觉验证:
- 导出设置抽屉桌面布局正常;
- 尺寸选择器只包含五种目标纸张;
- 默认 A4 纵向及 16mm 四边距正确;
- A5 横向和 12.5mm 上边距实时反映到纸张;
- GitHub 主题的 `#write` 宽度、最大宽度、内外边距已由共享几何层接管;
- 刷新页面后纸张、方向、页边距、页眉和页码配置可以恢复;
- 恢复默认功能正常;
- 浏览器控制台无警告或错误。
此前主题阶段已使用浏览器插件完成视觉验证:
- 导入并打开 `tmp/数据中台项目周报_2026_W30.md` - 导入并打开 `tmp/数据中台项目周报_2026_W30.md`
- 参考 `tmp/数据中台项目周报_2026_W30.pdf` 的 Typora 输出; - 参考 `tmp/数据中台项目周报_2026_W30.pdf` 的 Typora 输出;
- GitHub、Pixyll 和 Whitey 三套保留的本地主题均可正常切换; - GitHub、Pixyll 和 Whitey 三套保留的本地主题均可正常切换;
@@ -172,6 +214,9 @@ git diff --check
- 工作区不是干净状态,禁止重置。 - 工作区不是干净状态,禁止重置。
- `apps/web/src/App.tsx` 是当前预览实现的主要文件。 - `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/app.ts` 是新增的可测试 Fastify 应用。
- `apps/server/src/theme-registry.ts` 负责内置和本地主题发现、CSS 处理及资源安全。 - `apps/server/src/theme-registry.ts` 负责内置和本地主题发现、CSS 处理及资源安全。
- `.local/themes/typora-*` 是用户本机副本,已被 Git 忽略,不得提交。 - `.local/themes/typora-*` 是用户本机副本,已被 Git 忽略,不得提交。
@@ -184,17 +229,20 @@ git diff --check
## 6. 推荐接手顺序 ## 6. 推荐接手顺序
### 阶段一:提交主题兼容增强 ### 阶段一:完成分页预览
网页实时预览已在 `fa07472` 提交。当前主题基础 CSS、三套本地 Typora 主题导入、主题开发文档、全量验证和视觉检查均已完成。用户确认后创建独立提交 导出设置、缓存和实时纸张几何已经完成。下一步按已确认设计引入 Paged.js,并实现
```text - 逐页预览;
feat: 扩展本地主题兼容能力 - 页眉左中右内容及变量替换;
``` - 页脚页码、总页数、对齐和起始页码;
- Mermaid 整体换页和超高图按页面正文高度等比例缩放;
- 字体、图片和 Mermaid 完成后再分页;
- 预览与未来 PDF 共用分页 HTML 和 CSS。
### 阶段二:实现真实 PDF ### 阶段二:实现真实 PDF
开始编码前必须先给出具体设计和详细任务清单,等待用户确认。设计至少覆盖 分页预览完成并提交后再实现
1. 增加 Playwright 和固定版本 Chromium。 1. 增加 Playwright 和固定版本 Chromium。
2. 抽取可复用的完整 HTML 文档组装器。 2. 抽取可复用的完整 HTML 文档组装器。
@@ -204,15 +252,8 @@ feat: 扩展本地主题兼容能力
6. 前端启用“导出 PDF”按钮并下载文件。 6. 前端启用“导出 PDF”按钮并下载文件。
7. 增加 PDF 接口与端到端测试。 7. 增加 PDF 接口与端到端测试。
### 阶段三:导出配置界面 ### 阶段三:导出配置扩展
- 纸张尺寸;
- 横向或纵向;
- 四边页边距;
- 页眉页脚开关;
- 左中右内容区域;
- 当前页和总页数;
- 多种页码预设;
- Front Matter 元数据覆盖; - Front Matter 元数据覆盖;
- 浏览器本地配置预设; - 浏览器本地配置预设;
- 配置 JSON 导入和导出。 - 配置 JSON 导入和导出。
+5 -1
View File
@@ -48,7 +48,8 @@
"@types/react": "^19.2.7", "@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1", "@vitejs/plugin-react": "^5.1.1",
"vite": "^7.2.4" "vite": "^7.2.4",
"vitest": "^4.1.10"
} }
}, },
"apps/web/node_modules/katex": { "apps/web/node_modules/katex": {
@@ -4958,6 +4959,9 @@
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"zod": "^4.1.13" "zod": "^4.1.13"
},
"devDependencies": {
"vitest": "^4.1.10"
} }
}, },
"packages/renderer": { "packages/renderer": {
+1 -1
View File
@@ -15,7 +15,7 @@
"dev:server": "npm run dev -w @md-to-pdf/server", "dev:server": "npm run dev -w @md-to-pdf/server",
"dev:web": "npm run dev -w @md-to-pdf/web", "dev:web": "npm run dev -w @md-to-pdf/web",
"theme:import-typora": "node scripts/import-typora-theme.mjs", "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" "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": { "engines": {
+4
View File
@@ -17,9 +17,13 @@
"scripts": { "scripts": {
"dev": "tsc -p tsconfig.json --watch --preserveWatchOutput", "dev": "tsc -p tsconfig.json --watch --preserveWatchOutput",
"build": "tsc -p tsconfig.json", "build": "tsc -p tsconfig.json",
"test": "vitest run",
"typecheck": "tsc -p tsconfig.json --noEmit --pretty false" "typecheck": "tsc -p tsconfig.json --noEmit --pretty false"
}, },
"dependencies": { "dependencies": {
"zod": "^4.1.13" "zod": "^4.1.13"
},
"devDependencies": {
"vitest": "^4.1.10"
} }
} }
+125 -62
View File
@@ -1,15 +1,16 @@
import { z } from "zod"; 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 = [ export const supportedPaperFormats = [
"A3", "A3",
"A4", "A4",
"A5", "A5",
"Letter", "Letter",
"Legal", "Legal"
"Tabloid",
"Custom"
] as const; ] as const;
export const paperFormatLabels: Record< export const paperFormatLabels: Record<
@@ -19,16 +20,52 @@ export const paperFormatLabels: Record<
A3: "A3", A3: "A3",
A4: "A4", A4: "A4",
A5: "A5", A5: "A5",
Letter: "Letter", Letter: "US-Letter",
Legal: "Legal", Legal: "US-Legal"
Tabloid: "Tabloid", };
Custom: "自定义"
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 const lengthSchema = z
.string() .string()
.regex(/^\d+(?:\.\d+)?(?:mm|cm|in)$/, "长度必须包含 mm、cm 或 in 单位"); .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({ const marginSchema = z.object({
top: lengthSchema, top: lengthSchema,
right: lengthSchema, right: lengthSchema,
@@ -36,40 +73,22 @@ const marginSchema = z.object({
left: lengthSchema left: lengthSchema
}); });
const headerFooterSlotSchema = z.object({ const headerSlotSchema = z.object({
enabled: z.boolean(), enabled: z.boolean(),
content: z.string().max(500) content: z.string().max(500)
}); });
const headerFooterSchema = z.object({ const headerSchema = z.object({
enabled: z.boolean(), enabled: z.boolean(),
height: lengthSchema, height: lengthSchema,
showDivider: z.boolean(), showDivider: z.boolean(),
fontSize: lengthSchema, fontSize: lengthSchema,
color: z.string(), color: z.string(),
left: headerFooterSlotSchema, left: headerSlotSchema,
center: headerFooterSlotSchema, center: headerSlotSchema,
right: headerFooterSlotSchema 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([ export const pageNumberFormatSchema = z.enum([
"page", "page",
"page-total", "page-total",
@@ -78,21 +97,63 @@ export const pageNumberFormatSchema = z.enum([
"custom" "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({ export const exportConfigSchema = z.object({
version: z.literal(EXPORT_CONFIG_VERSION), version: z.literal(EXPORT_CONFIG_VERSION),
name: z.string().min(1).max(100), name: z.string().min(1).max(100),
themeId: z.string().min(1), themeId: z.string().min(1),
paper: paperSchema, paper: paperSchema,
header: headerFooterSchema, header: headerSchema,
footer: headerFooterSchema, footer: footerSchema,
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()
}),
metadata: z.object({ metadata: z.object({
title: z.string().max(300), title: z.string().max(300),
author: z.string().max(300), author: z.string().max(300),
@@ -109,25 +170,37 @@ export const exportConfigSchema = z.object({
export type ExportConfig = z.infer<typeof exportConfigSchema>; export type ExportConfig = z.infer<typeof exportConfigSchema>;
export type PaperFormat = ExportConfig["paper"]["format"]; 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, enabled: false,
content: "" content: ""
} as const; } as const;
export const defaultExportConfig: ExportConfig = { export const defaultExportConfig: ExportConfig = {
version: EXPORT_CONFIG_VERSION, version: EXPORT_CONFIG_VERSION,
name: "默认 A4 技术文档", name: "默认 A4 文档",
themeId: "typora-like", themeId: "typora-github",
paper: { paper: {
format: "A4", format: "A4",
orientation: "portrait", orientation: "portrait",
margins: { margins: {
top: "20mm", top: "16mm",
right: "18mm", right: "16mm",
bottom: "20mm", bottom: "16mm",
left: "18mm" left: "16mm"
} }
}, },
header: { header: {
@@ -136,9 +209,9 @@ export const defaultExportConfig: ExportConfig = {
showDivider: false, showDivider: false,
fontSize: "3mm", fontSize: "3mm",
color: "#6b7280", color: "#6b7280",
left: disabledSlot, left: disabledHeaderSlot,
center: disabledSlot, center: disabledHeaderSlot,
right: disabledSlot right: disabledHeaderSlot
}, },
footer: { footer: {
enabled: true, enabled: true,
@@ -146,16 +219,6 @@ export const defaultExportConfig: ExportConfig = {
showDivider: false, showDivider: false,
fontSize: "3mm", fontSize: "3mm",
color: "#6b7280", color: "#6b7280",
left: disabledSlot,
center: {
enabled: true,
content: "${page} / ${pages}"
},
right: disabledSlot
},
pageNumber: {
enabled: true,
position: "footer",
alignment: "center", alignment: "center",
format: "page-total", format: "page-total",
startFrom: 1 startFrom: 1
+97
View File
@@ -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);
});
});
+9 -2
View File
@@ -78,6 +78,7 @@ const markdown = new MarkdownIt(markdownOptions)
const defaultFenceRenderer = markdown.renderer.rules.fence; const defaultFenceRenderer = markdown.renderer.rules.fence;
const lengthStylePattern = /^-?\d+(?:\.\d+)?(?:em|ex|px|%)$/u; const lengthStylePattern = /^-?\d+(?:\.\d+)?(?:em|ex|px|%)$/u;
const tableTextAlignStylePattern = /^(?:left|center|right)$/u;
markdown.renderer.rules.fence = ( markdown.renderer.rules.fence = (
tokens, tokens,
@@ -131,8 +132,8 @@ const safeHtmlOptions: sanitizeHtml.IOptions = {
li: ["class", "value"], li: ["class", "value"],
ol: ["class", "start"], ol: ["class", "start"],
span: ["class", "style"], span: ["class", "style"],
td: ["colspan", "rowspan"], td: ["colspan", "rowspan", "style"],
th: ["colspan", "rowspan", "scope"] th: ["colspan", "rowspan", "scope", "style"]
}, },
allowedSchemes: ["http", "https", "mailto"], allowedSchemes: ["http", "https", "mailto"],
allowedSchemesByTag: { allowedSchemesByTag: {
@@ -149,6 +150,12 @@ const safeHtmlOptions: sanitizeHtml.IOptions = {
"padding-left": [lengthStylePattern], "padding-left": [lengthStylePattern],
"vertical-align": [lengthStylePattern], "vertical-align": [lengthStylePattern],
"border-bottom-width": [lengthStylePattern] "border-bottom-width": [lengthStylePattern]
},
td: {
"text-align": [tableTextAlignStylePattern]
},
th: {
"text-align": [tableTextAlignStylePattern]
} }
}, },
transformTags: { transformTags: {
@@ -33,6 +33,21 @@ const answer = 42;
); );
}); });
it("保留 Markdown 表格的列对齐语义", () => {
const result = renderMarkdown(`
| 左对齐 | 居中 | 右对齐 |
| :--- | :---: | ---: |
| A | B | C |
`);
expect(result.bodyHtml).toContain('<th style="text-align:left">左对齐</th>');
expect(result.bodyHtml).toContain('<th style="text-align:center">居中</th>');
expect(result.bodyHtml).toContain('<th style="text-align:right">右对齐</th>');
expect(result.bodyHtml).toContain('<td style="text-align:left">A</td>');
expect(result.bodyHtml).toContain('<td style="text-align:center">B</td>');
expect(result.bodyHtml).toContain('<td style="text-align:right">C</td>');
});
it("读取并规范化 Front Matter 元数据", () => { it("读取并规范化 Front Matter 元数据", () => {
const result = renderMarkdown(`--- const result = renderMarkdown(`---
title: 项目报告 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 和危险链接形成可执行内容", () => { it("阻止原始 HTML 和危险链接形成可执行内容", () => {
const result = renderMarkdown(` const result = renderMarkdown(`
<script>alert("xss")</script> <script>alert("xss")</script>