feat(md-print): add A4/A5 multi-column print layout

Add an md-print element that paginates markdown into A4 landscape sheets holding two A5 pages, each with configurable columns. The preview modal exports through the browser print dialog.

Paragraphs, lists, and tables split at word, item, and row boundaries; headings are kept with following content.
This commit is contained in:
2026-10-23 11:00:59 +08:00
parent bd7871a800
commit 4a19336445
17 changed files with 1523 additions and 1 deletions
+256
View File
@@ -0,0 +1,256 @@
import {
createEffect,
createResource,
createSignal,
For,
onCleanup,
onMount,
Show,
} from "solid-js";
import { Portal } from "solid-js/web";
import {
TbOutlineDownload,
TbOutlinePrinter,
TbOutlineRefresh,
TbOutlineX,
} from "solid-icons/tb";
import { getIndexedData } from "../../data-loader/file-index";
import { extractSection } from "../../data-loader";
import { parseMarkdown } from "../../markdown";
import type { PrintConfig } from "./types";
import {
columnWidthMm,
createPageElement,
mmToPx,
paginate,
waitForStableSize,
type PaginateOptions,
} from "./paginate";
import { exportPrintPDF } from "./print-export";
export interface PrintPreviewProps {
src: string;
section?: string;
articlePath: string;
config: PrintConfig;
onClose: () => void;
}
async function fetchPrintContent(params: {
src: string;
section?: string;
}): Promise<string> {
const text = await getIndexedData(params.src);
return params.section ? extractSection(text, params.section) : text;
}
export function PrintPreview(props: PrintPreviewProps) {
const [content] = createResource(
() => ({ src: props.src, section: props.section }),
fetchPrintContent,
);
const [pages, setPages] = createSignal<HTMLElement[]>([]);
const [paginating, setPaginating] = createSignal(false);
const [warnings, setWarnings] = createSignal<string[]>([]);
const [error, setError] = createSignal<string | null>(null);
const [scale, setScale] = createSignal(0.8);
const [sourceEl, setSourceEl] = createSignal<HTMLElement>();
const [mountEl, setMountEl] = createSignal<HTMLElement>();
const [sheetEl, setSheetEl] = createSignal<HTMLElement>();
let running = false;
const title = () =>
props.config.title || props.src.split("/").pop() || "打印预览";
const options = (): PaginateOptions => ({
...props.config,
articlePath: props.articlePath,
});
const runPagination = async () => {
const md = content();
const source = sourceEl();
const mount = mountEl();
if (md === undefined || !source || !mount || running) return;
running = true;
setPaginating(true);
setError(null);
setWarnings([]);
setPages([]);
mount.innerHTML = "";
try {
source.innerHTML = parseMarkdown(md, props.articlePath);
await document.fonts.ready;
await waitForStableSize(source, 300, 4000);
const result = paginate(source, options(), mount);
result.pages.forEach((page, index) => {
const footer = page.querySelector(".md-print-footer");
if (footer) footer.textContent = `${index + 1} / ${result.pages.length}`;
});
setPages(result.pages);
setWarnings(result.warnings);
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
running = false;
setPaginating(false);
}
};
createEffect(() => {
if (content() !== undefined && sourceEl() && mountEl()) {
void runPagination();
}
});
createEffect(() => {
const container = sheetEl();
const pageList = pages();
if (!container) return;
container.innerHTML = "";
for (let i = 0; i < pageList.length; i += 2) {
const sheet = document.createElement("div");
sheet.className = "md-print-sheet md-print-sheet--preview";
sheet.appendChild(pageList[i]);
if (pageList[i + 1]) {
sheet.appendChild(pageList[i + 1]);
} else {
sheet.appendChild(createPageElement(options()).page);
}
if (props.config.cutMarks) {
const line = document.createElement("div");
line.className = "md-print-cut-line";
sheet.appendChild(line);
}
const wrapper = document.createElement("div");
wrapper.className = "md-print-sheet-wrapper";
wrapper.appendChild(sheet);
container.appendChild(wrapper);
}
});
onMount(() => {
const container = sheetEl();
if (container) {
const observer = new ResizeObserver(() => {
const available = container.clientWidth;
if (available <= 0) return;
setScale(Math.min(1, available / mmToPx(297)));
});
observer.observe(container);
onCleanup(() => observer.disconnect());
}
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") props.onClose();
};
document.addEventListener("keydown", onKeyDown);
onCleanup(() => document.removeEventListener("keydown", onKeyDown));
});
const handleExport = async () => {
try {
await exportPrintPDF(pages(), props.config, title(), props.articlePath);
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
}
};
return (
<Portal>
<div class="fixed inset-0 bg-black/60 z-60 flex flex-col">
<div class="shrink-0 bg-white border-b border-gray-200 px-4 py-3 flex items-center gap-3">
<TbOutlinePrinter class="text-xl text-gray-600 shrink-0" />
<div class="min-w-0">
<div class="text-sm font-semibold text-gray-900 truncate">
{title()}
</div>
<div class="text-xs text-gray-500">
{pages().length} 页 · A4 横版 · 每页 {props.config.columns} 列
</div>
</div>
<div class="flex-1" />
<button
onClick={() => void runPagination()}
disabled={paginating()}
class="px-3 py-1.5 rounded text-sm font-medium bg-gray-100 text-gray-700 hover:bg-gray-200 disabled:opacity-50"
>
<TbOutlineRefresh class="inline-block align-[-0.125em]" /> 重新分页
</button>
<button
onClick={() => void handleExport()}
disabled={paginating() || pages().length === 0}
class="px-3 py-1.5 rounded text-sm font-medium bg-green-100 text-green-700 hover:bg-green-200 disabled:opacity-50"
>
<TbOutlineDownload class="inline-block align-[-0.125em]" /> 打印 /
导出 PDF
</button>
<button
onClick={props.onClose}
class="text-gray-400 hover:text-gray-600 text-xl leading-none p-1"
title="关闭"
>
<TbOutlineX />
</button>
</div>
<div class="flex-1 overflow-auto bg-gray-100 p-6">
<Show when={content.loading || paginating()}>
<div class="text-center text-gray-500 py-20">正在分页…</div>
</Show>
<Show when={content.error || error()}>
<div class="text-center text-red-500 py-20">
{error() || content.error?.message}
</div>
</Show>
<Show when={warnings().length > 0}>
<div class="max-w-3xl mx-auto mb-4 text-xs text-amber-700 bg-amber-50 border border-amber-200 rounded px-3 py-2">
<For each={warnings()}>{(warning) => <div>· {warning}</div>}</For>
</div>
</Show>
{/* Hidden measuring container: one column wide, same typography. */}
<article
ref={setSourceEl}
data-src={props.articlePath}
class="md-print-content prose"
style={{
width: `${columnWidthMm(props.config)}mm`,
"--md-print-font-size": `${props.config.font}pt`,
position: "absolute",
left: "-99999px",
top: "0",
visibility: "hidden",
"pointer-events": "none",
}}
/>
{/* Hidden mount for pages while they are being measured. */}
<div
ref={setMountEl}
style={{
position: "absolute",
left: "-99999px",
top: "0",
visibility: "hidden",
"pointer-events": "none",
}}
/>
<div
ref={setSheetEl}
style={{ "--md-print-scale": String(scale()) }}
/>
</div>
</div>
</Portal>
);
}