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",