diff --git a/next/src/components/export-menu.tsx b/next/src/components/export-menu.tsx
index 3131d7e..7924205 100644
--- a/next/src/components/export-menu.tsx
+++ b/next/src/components/export-menu.tsx
@@ -11,6 +11,7 @@ import {
downloadIframeAsImage,
} from "@/lib/export/image";
import { downloadHtml } from "@/lib/export/download";
+import { exportIframeAsPdf } from "@/lib/export/pdf";
import { extractHtml } from "@/lib/extract-html";
import { parseDeck } from "@/lib/deck";
import {
@@ -99,6 +100,9 @@ export function ExportMenu({ iframeRef }: ExportMenuProps) {
{ id: "download-png", label: t("export.action.downloadPng"), emoji: "🖼️", fn: wrap(t("export.toast.imgSaved"), async () => {
if (!iframeRef.current) throw new Error(t("export.error.previewNotReady")); await downloadIframeAsImage(iframeRef.current);
}) },
+ { id: "download-pdf", label: t("export.action.downloadPdf"), emoji: "📄", fn: wrap(t("export.toast.pdfSaved"), async () => {
+ if (!iframeRef.current) throw new Error(t("export.error.previewNotReady")); exportIframeAsPdf(iframeRef.current);
+ }) },
],
},
...(deck.isDeck
diff --git a/next/src/lib/export/pdf.ts b/next/src/lib/export/pdf.ts
new file mode 100644
index 0000000..62d9a29
--- /dev/null
+++ b/next/src/lib/export/pdf.ts
@@ -0,0 +1,78 @@
+"use client";
+
+/**
+ * Export the iframe contents as a PDF via the browser's native print dialog.
+ *
+ * Opens the HTML in a new window styled for A4 printing, then triggers
+ * "Save as PDF". This gives vector text, proper CSS page breaks, and
+ * correct pagination — much better than rasterizing to a PNG first.
+ */
+export function exportIframeAsPdf(
+ iframe: HTMLIFrameElement,
+ title = "html-anything",
+): void {
+ const doc = iframe.contentDocument;
+ if (!doc) throw new Error("preview not ready");
+
+ const headMatch = /
]*>([\s\S]*?)<\/head>/i.exec(doc.documentElement.outerHTML);
+ const head = headMatch ? headMatch[1] : "";
+ const body = doc.body.innerHTML;
+
+ const w = window.open("", "_blank");
+ if (!w) throw new Error("popup blocked — allow popups to print/PDF");
+
+ w.document.open();
+ w.document.write(`
+
+
+
+${escapeHtml(title)}
+${head}
+
+
+
+${body}
+
+
+`);
+ w.document.close();
+}
+
+function escapeHtml(s: string): string {
+ return s.replace(/[&<>"']/g, (c) => ({
+ "&": "&",
+ "<": "<",
+ ">": ">",
+ '"': """,
+ "'": "'",
+ })[c]!);
+}
diff --git a/next/src/lib/i18n.ts b/next/src/lib/i18n.ts
index 7b0036e..a1c6dae 100644
--- a/next/src/lib/i18n.ts
+++ b/next/src/lib/i18n.ts
@@ -285,6 +285,7 @@ export interface Dict {
"export.action.text": string;
"export.action.downloadHtml": string;
"export.action.downloadPng": string;
+ "export.action.downloadPdf": string;
"export.action.deckPdf": string;
"export.action.deckPngZip": string;
"export.action.deckPptx": string;
@@ -296,6 +297,7 @@ export interface Dict {
"export.toast.text": string;
"export.toast.htmlSaved": string;
"export.toast.imgSaved": string;
+ "export.toast.pdfSaved": string;
"export.toast.deckPdf": string;
"export.toast.deckPngZip": string;
"export.toast.deckPptx": string;
@@ -649,6 +651,7 @@ const en: Dict = {
"export.action.text": "Plain text",
"export.action.downloadHtml": ".html single file",
"export.action.downloadPng": ".png hi-res image",
+ "export.action.downloadPdf": ".pdf document",
"export.action.deckPdf": "PDF · all slides (print)",
"export.action.deckPngZip": "PNG · per-slide (.zip)",
"export.action.deckPptx": ".pptx · PowerPoint",
@@ -660,6 +663,7 @@ const en: Dict = {
"export.toast.text": "Text copied",
"export.toast.htmlSaved": "HTML downloaded",
"export.toast.imgSaved": "Image downloaded",
+ "export.toast.pdfSaved": "PDF downloaded",
"export.toast.deckPdf": "Print dialog opened — pick “Save as PDF”",
"export.toast.deckPngZip": "Slide PNGs zipped",
"export.toast.deckPptx": "PPTX downloaded",
@@ -1008,6 +1012,7 @@ const zhCN: Dict = {
"export.action.text": "纯文本",
"export.action.downloadHtml": ".html 单文件",
"export.action.downloadPng": ".png 高清图",
+ "export.action.downloadPdf": ".pdf 文档",
"export.action.deckPdf": "PDF · 全部幻灯片 (打印)",
"export.action.deckPngZip": "PNG · 每页一张 (.zip)",
"export.action.deckPptx": ".pptx · PowerPoint",
@@ -1019,6 +1024,7 @@ const zhCN: Dict = {
"export.toast.text": "已复制文本",
"export.toast.htmlSaved": "已下载 HTML",
"export.toast.imgSaved": "已下载图片",
+ "export.toast.pdfSaved": "已下载 PDF",
"export.toast.deckPdf": "已打开打印窗口 — 选「另存为 PDF」",
"export.toast.deckPngZip": "已打包 PNG ZIP",
"export.toast.deckPptx": "已下载 PPTX",