Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions next/src/components/export-menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
78 changes: 78 additions & 0 deletions next/src/lib/export/pdf.ts
Original file line number Diff line number Diff line change
@@ -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 = /<head[^>]*>([\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(`<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>${escapeHtml(title)}</title>
${head}
<style>
@page { size: A4; margin: 0; }
html, body {
margin: 0; padding: 15mm;
font-size: 12pt; line-height: 1.6;
color: #000; background: #fff;
}
img { max-width: 100%; height: auto; }
table { page-break-inside: avoid; }
pre, blockquote { page-break-inside: avoid; }
h1, h2, h3, h4, h5, h6 {
page-break-after: avoid;
page-break-inside: avoid;
}
@media screen {
body {
max-width: 210mm; margin: 0 auto;
padding: 15mm; box-shadow: 0 0 40px rgba(0,0,0,.12);
background: #f5f5f5;
}
}
</style>
</head>
<body>
${body}
<script>
function ready(cb) {
if (document.readyState === 'complete') return cb();
window.addEventListener('load', cb, { once: true });
}
ready(function () {
setTimeout(function () { window.focus(); window.print(); }, 600);
});
</script>
</body>
</html>`);
w.document.close();
}

function escapeHtml(s: string): string {
return s.replace(/[&<>"']/g, (c) => ({
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#39;",
})[c]!);
}
6 changes: 6 additions & 0 deletions next/src/lib/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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",
Expand All @@ -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",
Expand Down Expand Up @@ -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",
Expand All @@ -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",
Expand Down