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:
@@ -48,6 +48,7 @@ import mdBgRaw from "../doc-entries/md-bg.md";
|
||||
import mdBorderRaw from "../doc-entries/md-border.md";
|
||||
import mdEmbedRaw from "../doc-entries/md-embed.md";
|
||||
import mdDeckRaw from "../doc-entries/md-deck.md";
|
||||
import mdPrintRaw from "../doc-entries/md-print.md";
|
||||
import mdYarnRaw from "../doc-entries/md-yarn-spinner.md";
|
||||
|
||||
const rawDocuments: string[] = [
|
||||
@@ -60,6 +61,7 @@ const rawDocuments: string[] = [
|
||||
mdBorderRaw,
|
||||
mdEmbedRaw,
|
||||
mdDeckRaw,
|
||||
mdPrintRaw,
|
||||
mdYarnRaw,
|
||||
];
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
TbOutlineLink,
|
||||
TbOutlineMapPin,
|
||||
TbOutlinePhoto,
|
||||
TbOutlinePrinter,
|
||||
TbOutlineSparkles,
|
||||
TbOutlineSquare,
|
||||
TbOutlineTable,
|
||||
@@ -34,6 +35,7 @@ const DOC_ICONS: Record<string, IconTypes> = {
|
||||
border: TbOutlineSquare,
|
||||
embed: TbOutlineFileText,
|
||||
deck: TbOutlineCards,
|
||||
print: TbOutlinePrinter,
|
||||
yarn: TbOutlineGitBranch,
|
||||
gm: TbOutlineTheater,
|
||||
player: TbOutlineDice5,
|
||||
|
||||
@@ -8,6 +8,7 @@ import "./md-border";
|
||||
import "./md-font";
|
||||
import "./md-embed";
|
||||
import "./md-deck";
|
||||
import "./md-print";
|
||||
import "./md-yarn-spinner";
|
||||
|
||||
// 导出组件
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { normalizePrintConfig, PRINT_DEFAULTS } from "./config";
|
||||
|
||||
describe("normalizePrintConfig", () => {
|
||||
test("uses defaults", () => {
|
||||
expect(normalizePrintConfig()).toEqual(PRINT_DEFAULTS);
|
||||
});
|
||||
|
||||
test("parses directive strings and clamps values", () => {
|
||||
const config = normalizePrintConfig({
|
||||
columns: "3",
|
||||
margin: "100",
|
||||
gap: "-5",
|
||||
font: "20",
|
||||
pageNumbers: "false",
|
||||
cutMarks: "no",
|
||||
});
|
||||
|
||||
expect(config.columns).toBe(3);
|
||||
expect(config.margin).toBe(40);
|
||||
expect(config.gap).toBe(0);
|
||||
expect(config.font).toBe(16);
|
||||
expect(config.pageNumbers).toBe(false);
|
||||
expect(config.cutMarks).toBe(false);
|
||||
});
|
||||
|
||||
test("clamps columns to 1..4", () => {
|
||||
expect(normalizePrintConfig({ columns: "0" }).columns).toBe(1);
|
||||
expect(normalizePrintConfig({ columns: "9" }).columns).toBe(4);
|
||||
});
|
||||
|
||||
test("accepts YAML data-config numbers and booleans", () => {
|
||||
const config = normalizePrintConfig({
|
||||
columns: 2,
|
||||
margin: 12,
|
||||
gap: 6,
|
||||
font: 10,
|
||||
title: "规则书",
|
||||
pageNumbers: true,
|
||||
cutMarks: false,
|
||||
});
|
||||
|
||||
expect(config).toEqual({
|
||||
columns: 2,
|
||||
margin: 12,
|
||||
gap: 6,
|
||||
font: 10,
|
||||
title: "规则书",
|
||||
pageNumbers: true,
|
||||
cutMarks: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { PrintConfig } from "./types";
|
||||
|
||||
export const PRINT_DEFAULTS: PrintConfig = {
|
||||
columns: 2,
|
||||
margin: 10,
|
||||
gap: 5,
|
||||
font: 9,
|
||||
title: "",
|
||||
pageNumbers: true,
|
||||
cutMarks: true,
|
||||
};
|
||||
|
||||
function toNumber(value: unknown, fallback: number): number {
|
||||
const n = typeof value === "number" ? value : Number(value);
|
||||
return Number.isFinite(n) ? n : fallback;
|
||||
}
|
||||
|
||||
function toBoolean(value: unknown, fallback: boolean): boolean {
|
||||
if (typeof value === "boolean") return value;
|
||||
if (typeof value === "string") {
|
||||
const s = value.trim().toLowerCase();
|
||||
if (["true", "1", "yes", "on"].includes(s)) return true;
|
||||
if (["false", "0", "no", "off"].includes(s)) return false;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize directive attributes / YAML `data-config` into a usable config.
|
||||
* Values may arrive as strings (directive attributes) or numbers/booleans
|
||||
* (YAML `data-config`), so everything is coerced and clamped.
|
||||
*/
|
||||
export function normalizePrintConfig(
|
||||
input: Partial<Record<keyof PrintConfig, unknown>> = {},
|
||||
): PrintConfig {
|
||||
return {
|
||||
columns: Math.round(
|
||||
clamp(toNumber(input.columns, PRINT_DEFAULTS.columns), 1, 4),
|
||||
),
|
||||
margin: clamp(toNumber(input.margin, PRINT_DEFAULTS.margin), 5, 40),
|
||||
gap: clamp(toNumber(input.gap, PRINT_DEFAULTS.gap), 0, 20),
|
||||
font: clamp(toNumber(input.font, PRINT_DEFAULTS.font), 6, 16),
|
||||
title:
|
||||
typeof input.title === "string" ? input.title : PRINT_DEFAULTS.title,
|
||||
pageNumbers: toBoolean(input.pageNumbers, PRINT_DEFAULTS.pageNumbers),
|
||||
cutMarks: toBoolean(input.cutMarks, PRINT_DEFAULTS.cutMarks),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { customElement, noShadowDOM } from "solid-element";
|
||||
import { createSignal, Show } from "solid-js";
|
||||
import { TbOutlineDownload, TbOutlinePrinter } from "solid-icons/tb";
|
||||
import { resolvePath } from "../utils/path";
|
||||
import { normalizePrintConfig } from "./config";
|
||||
import type { PrintProps } from "./types";
|
||||
import { PrintPreview } from "./PrintPreview";
|
||||
|
||||
customElement<PrintProps>(
|
||||
"md-print",
|
||||
{
|
||||
columns: "2",
|
||||
margin: "10",
|
||||
gap: "5",
|
||||
font: "9",
|
||||
title: "",
|
||||
pageNumbers: "true",
|
||||
cutMarks: "true",
|
||||
},
|
||||
(props, { element }) => {
|
||||
noShadowDOM();
|
||||
|
||||
const rawPath = element?.textContent?.trim() || "";
|
||||
if (element) element.textContent = "";
|
||||
|
||||
// Resolve the printed markdown relative to the current article.
|
||||
const articleEl = element?.closest("article[data-src]");
|
||||
const articlePath = articleEl?.getAttribute("data-src") || "";
|
||||
|
||||
const hashIndex = rawPath.indexOf("#");
|
||||
const filePath = hashIndex >= 0 ? rawPath.slice(0, hashIndex) : rawPath;
|
||||
const section = hashIndex >= 0 ? rawPath.slice(hashIndex + 1) : undefined;
|
||||
const resolvedPath = resolvePath(articlePath, filePath);
|
||||
|
||||
// Structured config from a `yaml role=tag` block's `data-config`.
|
||||
let dataConfig: Record<string, unknown> = {};
|
||||
const dataConfigAttr = element?.getAttribute("data-config");
|
||||
if (dataConfigAttr) {
|
||||
try {
|
||||
dataConfig = JSON.parse(dataConfigAttr);
|
||||
} catch (e) {
|
||||
console.error("Invalid data-config on md-print:", e);
|
||||
}
|
||||
}
|
||||
|
||||
const config = normalizePrintConfig({ ...props, ...dataConfig });
|
||||
const [isOpen, setIsOpen] = createSignal(false);
|
||||
|
||||
const displayName = filePath.split("/").pop() || filePath;
|
||||
|
||||
return (
|
||||
<div class="md-print mb-4">
|
||||
<div class="flex items-center gap-3 border border-gray-200 rounded-lg px-4 py-3 bg-linear-to-r from-slate-50 to-white">
|
||||
<TbOutlinePrinter class="text-xl text-gray-500 shrink-0" />
|
||||
<div class="min-w-0">
|
||||
<div class="text-sm font-medium text-gray-800">打印版式</div>
|
||||
<div class="text-xs text-gray-500 truncate">
|
||||
{displayName}
|
||||
{section ? `#${section}` : ""}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1" />
|
||||
<button
|
||||
onClick={() => setIsOpen(true)}
|
||||
class="px-3 py-1.5 rounded text-sm font-medium bg-green-100 text-green-700 hover:bg-green-200"
|
||||
>
|
||||
<TbOutlineDownload class="inline-block align-[-0.125em]" /> 打印预览
|
||||
/ 导出 PDF
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Show when={isOpen()}>
|
||||
<PrintPreview
|
||||
src={resolvedPath}
|
||||
section={section}
|
||||
articlePath={resolvedPath}
|
||||
config={config}
|
||||
onClose={() => setIsOpen(false)}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* @jest-environment jsdom
|
||||
*/
|
||||
|
||||
import { splitTextElementAt } from "./paginate";
|
||||
|
||||
describe("splitTextElementAt", () => {
|
||||
test("splits at a word boundary", () => {
|
||||
const p = document.createElement("p");
|
||||
p.textContent = "one two three four";
|
||||
|
||||
const [head, tail] = splitTextElementAt(p, 2);
|
||||
|
||||
expect(head.textContent).toBe("one two");
|
||||
expect(tail.textContent).toBe(" three four");
|
||||
});
|
||||
|
||||
test("preserves inline markup", () => {
|
||||
const p = document.createElement("p");
|
||||
p.innerHTML = "Hello <strong>brave new</strong> world";
|
||||
|
||||
const [head, tail] = splitTextElementAt(p, 3);
|
||||
|
||||
expect(head.textContent).toBe("Hello brave new");
|
||||
expect(tail.textContent).toBe(" world");
|
||||
expect(head.querySelector("strong")?.textContent).toBe("brave new");
|
||||
});
|
||||
|
||||
test("keeps the element's attributes on both halves", () => {
|
||||
const p = document.createElement("p");
|
||||
p.className = "lead";
|
||||
p.textContent = "alpha beta gamma";
|
||||
|
||||
const [head, tail] = splitTextElementAt(p, 1);
|
||||
|
||||
expect(head.className).toBe("lead");
|
||||
expect(tail.className).toBe("lead");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,629 @@
|
||||
import type { PrintConfig } from "./types";
|
||||
|
||||
/** A5 portrait dimensions. Two of these fill an A4 landscape sheet exactly. */
|
||||
export const PAGE_WIDTH_MM = 148.5;
|
||||
export const PAGE_HEIGHT_MM = 210;
|
||||
|
||||
export interface PaginateOptions extends PrintConfig {
|
||||
/** Path of the markdown being printed, used by nested components. */
|
||||
articlePath: string;
|
||||
}
|
||||
|
||||
export interface PaginateResult {
|
||||
pages: HTMLElement[];
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
export interface SplitResult {
|
||||
head: Node;
|
||||
tail: Node | null;
|
||||
}
|
||||
|
||||
export function mmToPx(mm: number): number {
|
||||
return (mm * 96) / 25.4;
|
||||
}
|
||||
|
||||
export function contentWidthMm(config: PrintConfig): number {
|
||||
return PAGE_WIDTH_MM - config.margin * 2;
|
||||
}
|
||||
|
||||
export function contentHeightMm(config: PrintConfig): number {
|
||||
return PAGE_HEIGHT_MM - config.margin * 2;
|
||||
}
|
||||
|
||||
export function columnWidthMm(config: PrintConfig): number {
|
||||
const width = contentWidthMm(config);
|
||||
return (width - (config.columns - 1) * config.gap) / config.columns;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create one A5 page: an `<article data-src>` (so nested components can
|
||||
* resolve relative paths) containing a fixed-height multi-column flow.
|
||||
*/
|
||||
export function createPageElement(opts: PaginateOptions): {
|
||||
page: HTMLElement;
|
||||
flow: HTMLElement;
|
||||
} {
|
||||
const page = document.createElement("article");
|
||||
page.className = "md-print-page";
|
||||
page.setAttribute("data-src", opts.articlePath);
|
||||
page.style.width = `${PAGE_WIDTH_MM}mm`;
|
||||
page.style.height = `${PAGE_HEIGHT_MM}mm`;
|
||||
page.style.padding = `${opts.margin}mm`;
|
||||
page.style.boxSizing = "border-box";
|
||||
page.style.position = "relative";
|
||||
page.style.overflow = "hidden";
|
||||
page.style.background = "white";
|
||||
page.style.setProperty("--md-print-font-size", `${opts.font}pt`);
|
||||
|
||||
if (opts.title) {
|
||||
const header = document.createElement("div");
|
||||
header.className = "md-print-header";
|
||||
header.textContent = opts.title;
|
||||
page.appendChild(header);
|
||||
}
|
||||
|
||||
const flow = document.createElement("div");
|
||||
flow.className = "md-print-flow md-print-content prose";
|
||||
flow.style.columnCount = String(opts.columns);
|
||||
flow.style.columnGap = `${opts.gap}mm`;
|
||||
flow.style.columnFill = "auto";
|
||||
flow.style.height = "100%";
|
||||
flow.style.width = "100%";
|
||||
page.appendChild(flow);
|
||||
|
||||
if (opts.pageNumbers) {
|
||||
const footer = document.createElement("div");
|
||||
footer.className = "md-print-footer";
|
||||
page.appendChild(footer);
|
||||
}
|
||||
|
||||
return { page, flow };
|
||||
}
|
||||
|
||||
/**
|
||||
* Flow the children of `source` into A5 pages.
|
||||
*
|
||||
* The algorithm is greedy: append a block to the current page and measure.
|
||||
* If it overflows, try to split it at a word / list-item / table-row
|
||||
* boundary so the remaining space is used; otherwise start a new page.
|
||||
* Blocks are moved (not cloned) so custom elements keep their state.
|
||||
*/
|
||||
export function paginate(
|
||||
source: HTMLElement,
|
||||
opts: PaginateOptions,
|
||||
mount?: HTMLElement,
|
||||
): PaginateResult {
|
||||
const pages: HTMLElement[] = [];
|
||||
const warnings: string[] = [];
|
||||
const queue: Node[] = Array.from(source.childNodes).filter(isMeaningfulNode);
|
||||
|
||||
const newPage = () => {
|
||||
const created = createPageElement(opts);
|
||||
// Pages must be in the document for getBoundingClientRect() to work.
|
||||
mount?.appendChild(created.page);
|
||||
pages.push(created.page);
|
||||
return created;
|
||||
};
|
||||
|
||||
// Wrap stray text nodes so they can be measured and moved as blocks.
|
||||
for (let i = 0; i < queue.length; i++) {
|
||||
const node = queue[i];
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
const p = document.createElement("p");
|
||||
p.appendChild(node);
|
||||
queue[i] = p;
|
||||
}
|
||||
}
|
||||
|
||||
let current = newPage();
|
||||
let i = 0;
|
||||
let guard = 0;
|
||||
|
||||
while (i < queue.length) {
|
||||
if (++guard > 100000) {
|
||||
warnings.push("分页循环次数过多,已中止。");
|
||||
break;
|
||||
}
|
||||
|
||||
const node = queue[i];
|
||||
current.flow.appendChild(node);
|
||||
|
||||
if (!overflows(current.flow)) {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
current.flow.removeChild(node as ChildNode);
|
||||
|
||||
const split = splitToFit(node, current.flow);
|
||||
if (split) {
|
||||
current.flow.appendChild(split.head);
|
||||
if (split.tail && hasContent(split.tail)) {
|
||||
queue.splice(i, 1, split.tail);
|
||||
current = newPage();
|
||||
continue;
|
||||
}
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (current.flow.childNodes.length > 0) {
|
||||
// Avoid leaving a heading alone at the bottom of the last column.
|
||||
const heading = takeTrailingHeading(current.flow, opts);
|
||||
if (heading) queue.splice(i, 0, heading);
|
||||
current = newPage();
|
||||
continue;
|
||||
}
|
||||
|
||||
// The block is taller than a whole page and cannot be split.
|
||||
current.flow.appendChild(node);
|
||||
warnings.push(`内容块过高,已允许溢出:${describeNode(node)}`);
|
||||
i++;
|
||||
current = newPage();
|
||||
}
|
||||
|
||||
if (pages.length > 1) {
|
||||
const last = pages[pages.length - 1];
|
||||
const lastFlow = last.querySelector(".md-print-flow");
|
||||
if (lastFlow && !lastFlow.hasChildNodes()) pages.pop();
|
||||
}
|
||||
|
||||
return { pages, warnings };
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll until the element's scroll size has been stable for `quietMs`.
|
||||
* Used to wait for async custom elements (tables, embeds) and images.
|
||||
*/
|
||||
export async function waitForStableSize(
|
||||
el: HTMLElement,
|
||||
quietMs = 300,
|
||||
maxMs = 4000,
|
||||
): Promise<void> {
|
||||
const start = Date.now();
|
||||
let last = "";
|
||||
let lastChange = Date.now();
|
||||
|
||||
while (Date.now() - start < maxMs) {
|
||||
const size = `${el.scrollHeight}x${el.scrollWidth}`;
|
||||
if (size !== last) {
|
||||
last = size;
|
||||
lastChange = Date.now();
|
||||
} else if (Date.now() - lastChange >= quietMs) {
|
||||
return;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Measurement
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* A block overflows when its last fragment extends past the flow's right
|
||||
* (an extra column) or bottom (taller than the column) edge.
|
||||
*/
|
||||
function overflows(flow: HTMLElement): boolean {
|
||||
// Overflow columns extend the scroll width of a fixed-height multicol box.
|
||||
if (flow.scrollWidth > flow.clientWidth + 1) return true;
|
||||
|
||||
const rect = flow.getBoundingClientRect();
|
||||
const last = flow.lastElementChild;
|
||||
if (!last) return false;
|
||||
const lastRect = last.getBoundingClientRect();
|
||||
return lastRect.right > rect.right + 0.5 || lastRect.bottom > rect.bottom + 0.5;
|
||||
}
|
||||
|
||||
function takeTrailingHeading(
|
||||
flow: HTMLElement,
|
||||
opts: PaginateOptions,
|
||||
): HTMLElement | null {
|
||||
const last = flow.lastElementChild;
|
||||
if (!last || !/^H[1-6]$/.test(last.tagName)) return null;
|
||||
if (flow.childNodes.length <= 1) return null;
|
||||
|
||||
const flowRect = flow.getBoundingClientRect();
|
||||
const lastRect = last.getBoundingClientRect();
|
||||
const gapPx = mmToPx(opts.gap);
|
||||
const columnWidthPx =
|
||||
(flowRect.width - (opts.columns - 1) * gapPx) / opts.columns;
|
||||
const lastColumnLeft =
|
||||
flowRect.left + (opts.columns - 1) * (columnWidthPx + gapPx);
|
||||
|
||||
// Only move headings that sit in the final column; a heading in an earlier
|
||||
// column still has content after it on the same page.
|
||||
if (lastRect.left < lastColumnLeft - 0.5) return null;
|
||||
|
||||
flow.removeChild(last);
|
||||
return last as HTMLElement;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Splitting
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const SPLITTABLE_CONTAINERS = new Set([
|
||||
"section",
|
||||
"article",
|
||||
"aside",
|
||||
"details",
|
||||
"figure",
|
||||
]);
|
||||
|
||||
const BLOCK_TAGS = new Set([
|
||||
"p",
|
||||
"div",
|
||||
"ul",
|
||||
"ol",
|
||||
"table",
|
||||
"blockquote",
|
||||
"pre",
|
||||
"h1",
|
||||
"h2",
|
||||
"h3",
|
||||
"h4",
|
||||
"h5",
|
||||
"h6",
|
||||
"section",
|
||||
"article",
|
||||
"aside",
|
||||
"details",
|
||||
"figure",
|
||||
]);
|
||||
|
||||
function splitToFit(node: Node, flow: HTMLElement): SplitResult | null {
|
||||
if (node.nodeType !== Node.ELEMENT_NODE) return null;
|
||||
const el = node as HTMLElement;
|
||||
const tag = el.tagName.toLowerCase();
|
||||
|
||||
if (tag === "p") {
|
||||
return splitTextElementToFit(el, flow);
|
||||
}
|
||||
|
||||
if (tag === "div" || tag === "blockquote" || tag === "li") {
|
||||
return hasBlockChildren(el)
|
||||
? splitContainerToFit(el, flow)
|
||||
: splitTextElementToFit(el, flow);
|
||||
}
|
||||
|
||||
if (tag === "ul" || tag === "ol") {
|
||||
return splitListToFit(el, flow);
|
||||
}
|
||||
|
||||
if (tag === "table") {
|
||||
return splitTableToFit(el, flow);
|
||||
}
|
||||
|
||||
if (SPLITTABLE_CONTAINERS.has(tag)) {
|
||||
return splitContainerToFit(el, flow);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function hasBlockChildren(el: HTMLElement): boolean {
|
||||
return Array.from(el.children).some((child) =>
|
||||
BLOCK_TAGS.has(child.tagName.toLowerCase()),
|
||||
);
|
||||
}
|
||||
|
||||
function splitTextElementToFit(
|
||||
el: HTMLElement,
|
||||
flow: HTMLElement,
|
||||
): SplitResult | null {
|
||||
const total = countWords(el);
|
||||
if (total < 2) return null;
|
||||
|
||||
let lo = 1;
|
||||
let hi = total - 1;
|
||||
let best = 0;
|
||||
|
||||
while (lo <= hi) {
|
||||
const mid = Math.floor((lo + hi) / 2);
|
||||
const [head] = splitTextElementAt(el, mid);
|
||||
flow.appendChild(head);
|
||||
const fits = !overflows(flow);
|
||||
flow.removeChild(head);
|
||||
if (fits) {
|
||||
best = mid;
|
||||
lo = mid + 1;
|
||||
} else {
|
||||
hi = mid - 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (best === 0) return null;
|
||||
|
||||
const [head, tail] = splitTextElementAt(el, best);
|
||||
trimLeadingWhitespace(tail);
|
||||
removeEmptyInline(tail);
|
||||
return { head, tail: hasContent(tail) ? tail : null };
|
||||
}
|
||||
|
||||
/**
|
||||
* Split an element's inline content after `wordCount` words, preserving
|
||||
* inline markup (strong/em/code/links) via a DOM Range.
|
||||
*/
|
||||
export function splitTextElementAt(
|
||||
el: HTMLElement,
|
||||
wordCount: number,
|
||||
): [HTMLElement, HTMLElement] {
|
||||
const head = el.cloneNode(false) as HTMLElement;
|
||||
const tail = el.cloneNode(false) as HTMLElement;
|
||||
const boundary = findWordBoundary(el, wordCount);
|
||||
if (!boundary) {
|
||||
head.appendChild(el.cloneNode(true));
|
||||
return [head, tail];
|
||||
}
|
||||
|
||||
const range = document.createRange();
|
||||
range.setStart(el, 0);
|
||||
range.setEnd(boundary.node, boundary.offset);
|
||||
head.appendChild(range.cloneContents());
|
||||
|
||||
range.setStart(boundary.node, boundary.offset);
|
||||
range.setEnd(el, el.childNodes.length);
|
||||
tail.appendChild(range.cloneContents());
|
||||
|
||||
return [head, tail];
|
||||
}
|
||||
|
||||
function countWords(el: Node): number {
|
||||
const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
|
||||
let count = 0;
|
||||
let node: Node | null;
|
||||
while ((node = walker.nextNode())) {
|
||||
const matches = (node.textContent || "").match(/\S+/g);
|
||||
if (matches) count += matches.length;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
function findWordBoundary(
|
||||
el: Node,
|
||||
wordCount: number,
|
||||
): { node: Text; offset: number } | null {
|
||||
const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
|
||||
let count = 0;
|
||||
let node: Node | null;
|
||||
while ((node = walker.nextNode())) {
|
||||
const text = node.textContent || "";
|
||||
const re = /\S+/g;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = re.exec(text))) {
|
||||
count++;
|
||||
if (count === wordCount) {
|
||||
return { node: node as Text, offset: match.index + match[0].length };
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function splitListToFit(
|
||||
list: HTMLElement,
|
||||
flow: HTMLElement,
|
||||
): SplitResult | null {
|
||||
const items = Array.from(list.children).filter(
|
||||
(child) => child.tagName === "LI",
|
||||
) as HTMLElement[];
|
||||
|
||||
if (items.length === 0) return null;
|
||||
|
||||
if (items.length === 1) {
|
||||
const split = splitToFit(items[0], flow);
|
||||
if (!split) return null;
|
||||
const headList = list.cloneNode(false) as HTMLElement;
|
||||
headList.appendChild(split.head);
|
||||
const tailList = list.cloneNode(false) as HTMLElement;
|
||||
if (split.tail) tailList.appendChild(split.tail);
|
||||
return { head: headList, tail: hasContent(tailList) ? tailList : null };
|
||||
}
|
||||
|
||||
let lo = 1;
|
||||
let hi = items.length - 1;
|
||||
let best = 0;
|
||||
|
||||
while (lo <= hi) {
|
||||
const mid = Math.floor((lo + hi) / 2);
|
||||
const head = list.cloneNode(false) as HTMLElement;
|
||||
for (let i = 0; i < mid; i++) {
|
||||
head.appendChild(items[i].cloneNode(true));
|
||||
}
|
||||
flow.appendChild(head);
|
||||
const fits = !overflows(flow);
|
||||
flow.removeChild(head);
|
||||
if (fits) {
|
||||
best = mid;
|
||||
lo = mid + 1;
|
||||
} else {
|
||||
hi = mid - 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (best === 0) return null;
|
||||
|
||||
const head = list.cloneNode(false) as HTMLElement;
|
||||
const tail = list.cloneNode(false) as HTMLElement;
|
||||
items.forEach((item, index) => {
|
||||
(index < best ? head : tail).appendChild(item.cloneNode(true));
|
||||
});
|
||||
|
||||
return { head, tail: hasContent(tail) ? tail : null };
|
||||
}
|
||||
|
||||
function splitTableToFit(
|
||||
table: HTMLElement,
|
||||
flow: HTMLElement,
|
||||
): SplitResult | null {
|
||||
const tbody = table.querySelector("tbody") || table;
|
||||
const rows = Array.from(tbody.children).filter(
|
||||
(child) => child.tagName === "TR",
|
||||
) as HTMLElement[];
|
||||
|
||||
if (rows.length < 2) return null;
|
||||
|
||||
let lo = 1;
|
||||
let hi = rows.length - 1;
|
||||
let best = 0;
|
||||
|
||||
while (lo <= hi) {
|
||||
const mid = Math.floor((lo + hi) / 2);
|
||||
const head = cloneTableShell(table);
|
||||
const headBody = head.querySelector("tbody")!;
|
||||
for (let i = 0; i < mid; i++) {
|
||||
headBody.appendChild(rows[i].cloneNode(true));
|
||||
}
|
||||
flow.appendChild(head);
|
||||
const fits = !overflows(flow);
|
||||
flow.removeChild(head);
|
||||
if (fits) {
|
||||
best = mid;
|
||||
lo = mid + 1;
|
||||
} else {
|
||||
hi = mid - 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (best === 0) return null;
|
||||
|
||||
const head = cloneTableShell(table);
|
||||
const headBody = head.querySelector("tbody")!;
|
||||
const tail = cloneTableShell(table);
|
||||
const tailBody = tail.querySelector("tbody")!;
|
||||
|
||||
rows.forEach((row, index) => {
|
||||
(index < best ? headBody : tailBody).appendChild(row.cloneNode(true));
|
||||
});
|
||||
|
||||
return { head, tail: hasContent(tail) ? tail : null };
|
||||
}
|
||||
|
||||
function cloneTableShell(table: HTMLElement): HTMLElement {
|
||||
const clone = table.cloneNode(false) as HTMLElement;
|
||||
for (const child of Array.from(table.children)) {
|
||||
const tag = child.tagName;
|
||||
if (tag === "THEAD" || tag === "COLGROUP" || tag === "CAPTION") {
|
||||
clone.appendChild(child.cloneNode(true));
|
||||
}
|
||||
}
|
||||
const tbody = document.createElement("tbody");
|
||||
clone.appendChild(tbody);
|
||||
return clone;
|
||||
}
|
||||
|
||||
function splitContainerToFit(
|
||||
el: HTMLElement,
|
||||
flow: HTMLElement,
|
||||
): SplitResult | null {
|
||||
const children = Array.from(el.childNodes).filter(isMeaningfulNode);
|
||||
if (children.length === 0) return null;
|
||||
|
||||
if (children.length === 1) {
|
||||
const only = children[0];
|
||||
if (only.nodeType === Node.ELEMENT_NODE) {
|
||||
const split = splitToFit(only, flow);
|
||||
if (split) {
|
||||
const head = el.cloneNode(false) as HTMLElement;
|
||||
head.appendChild(split.head);
|
||||
const tail = el.cloneNode(false) as HTMLElement;
|
||||
if (split.tail) tail.appendChild(split.tail);
|
||||
return { head, tail: hasContent(tail) ? tail : null };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
let lo = 1;
|
||||
let hi = children.length - 1;
|
||||
let best = 0;
|
||||
|
||||
while (lo <= hi) {
|
||||
const mid = Math.floor((lo + hi) / 2);
|
||||
const head = el.cloneNode(false) as HTMLElement;
|
||||
for (let i = 0; i < mid; i++) {
|
||||
head.appendChild(children[i].cloneNode(true));
|
||||
}
|
||||
flow.appendChild(head);
|
||||
const fits = !overflows(flow);
|
||||
flow.removeChild(head);
|
||||
if (fits) {
|
||||
best = mid;
|
||||
lo = mid + 1;
|
||||
} else {
|
||||
hi = mid - 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (best === 0) return null;
|
||||
|
||||
const head = el.cloneNode(false) as HTMLElement;
|
||||
const tail = el.cloneNode(false) as HTMLElement;
|
||||
children.forEach((child, index) => {
|
||||
(index < best ? head : tail).appendChild(child.cloneNode(true));
|
||||
});
|
||||
|
||||
return { head, tail: hasContent(tail) ? tail : null };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Small helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function isMeaningfulNode(node: Node): boolean {
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
return !!node.textContent?.trim();
|
||||
}
|
||||
return node.nodeType === Node.ELEMENT_NODE;
|
||||
}
|
||||
|
||||
function trimLeadingWhitespace(el: Node): void {
|
||||
const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
|
||||
let node: Node | null;
|
||||
while ((node = walker.nextNode())) {
|
||||
const text = node.textContent || "";
|
||||
if (!text.trim()) {
|
||||
node.textContent = "";
|
||||
continue;
|
||||
}
|
||||
node.textContent = text.replace(/^\s+/, "");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function removeEmptyInline(el: Element): void {
|
||||
for (const child of Array.from(el.children)) {
|
||||
removeEmptyInline(child);
|
||||
if (
|
||||
child.children.length === 0 &&
|
||||
!child.textContent?.trim() &&
|
||||
!["IMG", "BR", "HR", "SVG"].includes(child.tagName)
|
||||
) {
|
||||
child.remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function hasContent(node: Node): boolean {
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
return !!node.textContent?.trim();
|
||||
}
|
||||
if (node.nodeType !== Node.ELEMENT_NODE) return false;
|
||||
const el = node as HTMLElement;
|
||||
if (["IMG", "BR", "HR", "SVG", "VIDEO", "CANVAS"].includes(el.tagName)) {
|
||||
return true;
|
||||
}
|
||||
if (el.textContent?.trim()) return true;
|
||||
return el.querySelector("img, svg, video, canvas, hr, br") !== null;
|
||||
}
|
||||
|
||||
function describeNode(node: Node): string {
|
||||
if (node.nodeType === Node.ELEMENT_NODE) {
|
||||
const el = node as HTMLElement;
|
||||
const text = (el.textContent || "").trim().slice(0, 40);
|
||||
return `<${el.tagName.toLowerCase()}>${text ? ` ${text}…` : ""}`;
|
||||
}
|
||||
return (node.textContent || "").trim().slice(0, 40) || "文本节点";
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import type { PrintConfig } from "./types";
|
||||
import { createPageElement } from "./paginate";
|
||||
|
||||
function escapeHtml(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a new window with the paginated sheets and trigger the browser's
|
||||
* print dialog. The user can then print or "Save as PDF".
|
||||
*/
|
||||
export async function exportPrintPDF(
|
||||
pages: HTMLElement[],
|
||||
config: PrintConfig,
|
||||
title: string,
|
||||
articlePath: string,
|
||||
): Promise<void> {
|
||||
const printWindow = window.open("", "_blank");
|
||||
if (!printWindow) {
|
||||
throw new Error("无法打开新窗口,请允许弹出窗口");
|
||||
}
|
||||
|
||||
const styles = Array.from(
|
||||
document.querySelectorAll('style, link[rel="stylesheet"]'),
|
||||
)
|
||||
.map((el) => el.outerHTML)
|
||||
.join("\n");
|
||||
|
||||
const blankPage = createPageElement({
|
||||
...config,
|
||||
articlePath,
|
||||
}).page;
|
||||
|
||||
const sheets: string[] = [];
|
||||
for (let i = 0; i < pages.length; i += 2) {
|
||||
const first = pages[i]?.outerHTML ?? blankPage.outerHTML;
|
||||
const second = pages[i + 1]?.outerHTML ?? blankPage.outerHTML;
|
||||
sheets.push(`
|
||||
<div class="md-print-sheet">
|
||||
${first}
|
||||
${second}
|
||||
${config.cutMarks ? '<div class="md-print-cut-line"></div>' : ""}
|
||||
</div>
|
||||
`);
|
||||
}
|
||||
|
||||
printWindow.document.write(`
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>${escapeHtml(title)}</title>
|
||||
${styles}
|
||||
<style>
|
||||
@page { size: A4 landscape; margin: 0; }
|
||||
html, body { margin: 0; padding: 0; background: white; }
|
||||
.md-print-sheet {
|
||||
width: 297mm;
|
||||
height: 210mm;
|
||||
display: flex;
|
||||
position: relative;
|
||||
background: white;
|
||||
page-break-after: always;
|
||||
break-after: page;
|
||||
}
|
||||
.md-print-sheet:last-child {
|
||||
page-break-after: auto;
|
||||
break-after: auto;
|
||||
}
|
||||
.md-print-cut-line {
|
||||
position: absolute;
|
||||
left: 148.5mm;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
border-left: 0.2mm dashed #999;
|
||||
pointer-events: none;
|
||||
}
|
||||
[data-print-hide] { display: none !important; }
|
||||
md-dice, md-link, md-pins, md-font, md-bg, md-border { display: inline; }
|
||||
md-table, md-deck, md-embed, md-print, md-yarn-spinner { display: block; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
${sheets.join("\n")}
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
|
||||
printWindow.document.close();
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
if (printWindow.document.readyState === "complete") {
|
||||
resolve();
|
||||
} else {
|
||||
printWindow.addEventListener("load", () => resolve(), { once: true });
|
||||
}
|
||||
});
|
||||
|
||||
await printWindow.document.fonts.ready;
|
||||
|
||||
await Promise.all(
|
||||
Array.from(printWindow.document.images).map((img) =>
|
||||
img.complete
|
||||
? Promise.resolve()
|
||||
: new Promise<void>((resolve) => {
|
||||
img.addEventListener("load", () => resolve(), { once: true });
|
||||
img.addEventListener("error", () => resolve(), { once: true });
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
printWindow.focus();
|
||||
printWindow.print();
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
export interface PrintConfig {
|
||||
/** Number of columns per A5 page. */
|
||||
columns: number;
|
||||
/** Page margin in millimetres. */
|
||||
margin: number;
|
||||
/** Column gap in millimetres. */
|
||||
gap: number;
|
||||
/** Body font size in points. */
|
||||
font: number;
|
||||
/** Running header text. Empty hides the header. */
|
||||
title: string;
|
||||
/** Show `n / total` in the page footer. */
|
||||
pageNumbers: boolean;
|
||||
/** Draw the centre cut line between the two A5 pages. */
|
||||
cutMarks: boolean;
|
||||
}
|
||||
|
||||
export interface PrintProps {
|
||||
columns?: string | number;
|
||||
margin?: string | number;
|
||||
gap?: string | number;
|
||||
font?: string | number;
|
||||
title?: string;
|
||||
pageNumbers?: string | boolean;
|
||||
cutMarks?: string | boolean;
|
||||
}
|
||||
@@ -172,7 +172,7 @@ customElement(
|
||||
|
||||
return (
|
||||
<div class="ttrpg-table">
|
||||
<div class="flex items-center gap-2 border-b border-gray-200">
|
||||
<div class="flex items-center gap-2 border-b border-gray-200" data-print-hide>
|
||||
<div class="flex flex-col overflow-x-auto">
|
||||
{/* 分组 tabs */}
|
||||
<Show when={hasGroup()}>
|
||||
|
||||
Reference in New Issue
Block a user