feat: 完善导出设置与打印预览
This commit is contained in:
@@ -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
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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]
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
export interface MermaidSvgResult {
|
||||||
|
svg: string;
|
||||||
|
bindFunctions?: (element: Element) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MermaidRenderOutcome =
|
||||||
|
| ({ success: true } & MermaidSvgResult)
|
||||||
|
| {
|
||||||
|
success: false;
|
||||||
|
error: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function renderMermaidDefinitions(
|
||||||
|
definitions: string[],
|
||||||
|
renderDefinition: (
|
||||||
|
definition: string,
|
||||||
|
index: number
|
||||||
|
) => Promise<MermaidSvgResult>
|
||||||
|
): Promise<MermaidRenderOutcome[]> {
|
||||||
|
const outcomes: MermaidRenderOutcome[] = [];
|
||||||
|
|
||||||
|
for (const [index, definition] of definitions.entries()) {
|
||||||
|
try {
|
||||||
|
outcomes.push({
|
||||||
|
success: true,
|
||||||
|
...(await renderDefinition(definition, index))
|
||||||
|
});
|
||||||
|
} catch (error: unknown) {
|
||||||
|
outcomes.push({
|
||||||
|
success: false,
|
||||||
|
error
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return outcomes;
|
||||||
|
}
|
||||||
@@ -0,0 +1,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
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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>"
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
@@ -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 导入和导出。
|
||||||
|
|||||||
Generated
+5
-1
@@ -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
@@ -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": {
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user