diff --git a/desktop-app/resources/js/script.js b/desktop-app/resources/js/script.js
index ee7fc2c..a436fbd 100644
--- a/desktop-app/resources/js/script.js
+++ b/desktop-app/resources/js/script.js
@@ -25,15 +25,20 @@ document.addEventListener("DOMContentLoaded", function () {
});
}
+ const IS_DESKTOP_RUNTIME = typeof Neutralino !== 'undefined';
+ function resolveOptionalAssetUrl(localFilename, remoteUrl) {
+ return IS_DESKTOP_RUNTIME ? `/libs/${localFilename}` : remoteUrl;
+ }
+
// CDN URLs for lazy-loaded libraries
const CDN = {
- mermaid: 'https://cdn.jsdelivr.net/npm/mermaid@11.15.0/dist/mermaid.min.js',
- mathjax: 'https://cdnjs.cloudflare.com/ajax/libs/mathjax/3.2.2/es5/tex-mml-chtml.min.js',
- jspdf: 'https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js',
- html2canvas: 'https://cdnjs.cloudflare.com/ajax/libs/html2canvas/1.4.1/html2canvas.min.js',
- pako: 'https://cdnjs.cloudflare.com/ajax/libs/pako/2.1.0/pako.min.js',
- joypixels: 'https://cdn.jsdelivr.net/npm/emoji-toolkit@9.0.1/lib/js/joypixels.min.js',
- joypixels_css: 'https://cdn.jsdelivr.net/npm/emoji-toolkit@9.0.1/extras/css/joypixels.min.css'
+ mermaid: resolveOptionalAssetUrl('mermaid.min.js', 'https://cdn.jsdelivr.net/npm/mermaid@11.15.0/dist/mermaid.min.js'),
+ mathjax: resolveOptionalAssetUrl('tex-mml-chtml.min.js', 'https://cdnjs.cloudflare.com/ajax/libs/mathjax/3.2.2/es5/tex-mml-chtml.min.js'),
+ jspdf: resolveOptionalAssetUrl('jspdf.umd.min.js', 'https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js'),
+ html2canvas: resolveOptionalAssetUrl('html2canvas.min.js', 'https://cdnjs.cloudflare.com/ajax/libs/html2canvas/1.4.1/html2canvas.min.js'),
+ pako: resolveOptionalAssetUrl('pako.min.js', 'https://cdnjs.cloudflare.com/ajax/libs/pako/2.1.0/pako.min.js'),
+ joypixels: resolveOptionalAssetUrl('joypixels.min.js', 'https://cdn.jsdelivr.net/npm/emoji-toolkit@9.0.1/lib/js/joypixels.min.js'),
+ joypixels_css: resolveOptionalAssetUrl('joypixels.min.css', 'https://cdn.jsdelivr.net/npm/emoji-toolkit@9.0.1/extras/css/joypixels.min.css')
};
let markdownRenderTimeout = null;
@@ -426,6 +431,21 @@ document.addEventListener("DOMContentLoaded", function () {
return fallbackUrl;
}
+ function getLoadedStylesheetUrl(needle, fallbackUrl) {
+ const links = document.querySelectorAll('link[rel="stylesheet"], link[rel="preload"][as="style"]');
+ for (let i = 0; i < links.length; i += 1) {
+ const href = links[i].getAttribute("href") || "";
+ if (href.includes(needle)) {
+ try {
+ return new URL(href, window.location.href).toString();
+ } catch (e) {
+ return href;
+ }
+ }
+ }
+ return fallbackUrl;
+ }
+
function getPreviewWorkerUrl() {
const scripts = document.getElementsByTagName("script");
let scriptUrl = "";
@@ -6971,6 +6991,351 @@ document.addEventListener("DOMContentLoaded", function () {
return /(^|[^\\])\$\$|\\\[|\\\(|(^|[^\\])\$[^$\n]+\$/.test(markdown);
}
+ const PDF_EXPORT_SANITIZE_OPTIONS = {
+ ADD_TAGS: ['mjx-container', 'svg', 'path', 'g', 'marker', 'defs', 'pattern', 'clipPath', 'input'],
+ ADD_ATTR: [
+ 'id', 'class', 'style', 'align', 'viewBox', 'd', 'fill', 'stroke', 'transform',
+ 'marker-end', 'marker-start', 'type', 'checked', 'disabled', 'data-original-code',
+ 'xmlns', 'role', 'aria-hidden'
+ ],
+ ALLOWED_URI_REGEXP: PREVIEW_SANITIZE_OPTIONS.ALLOWED_URI_REGEXP
+ };
+
+ function buildPdfExportContentElement(markdown) {
+ const { frontmatter, body } = parseFrontmatter(markdown);
+ const tableHtml = frontmatter ? renderFrontmatterTable(frontmatter) : '';
+ const referenceData = extractReferenceDefinitions(body);
+ const markdownWithPageBreaks = referenceData.cleanedMarkdown.replace(
+ //gi,
+ '
'
+ );
+ const html = tableHtml + marked.parse(markdownWithPageBreaks);
+ const sanitizedHtml = DOMPurify.sanitize(html, PDF_EXPORT_SANITIZE_OPTIONS);
+ const container = document.createElement("div");
+ container.innerHTML = sanitizedHtml;
+ applyReferencePreviewLinks(container, referenceData.definitions);
+ enhanceGitHubAlerts(container);
+ return container;
+ }
+
+ function configureMathJaxForPdfExport() {
+ if (window.MathJax && typeof window.MathJax.typesetPromise === 'function') {
+ return Promise.resolve();
+ }
+
+ if (!window.MathJax || typeof window.MathJax.typesetPromise !== 'function') {
+ window.MathJax = {
+ loader: { load: ['[tex]/ams', '[tex]/boldsymbol'] },
+ options: {
+ a11y: { inTabOrder: false }
+ },
+ tex: {
+ inlineMath: [['$', '$'], ['\\(', '\\)']],
+ displayMath: [['$$', '$$'], ['\\[', '\\]']],
+ processEscapes: true,
+ packages: { '[+]': ['ams', 'boldsymbol'] }
+ }
+ };
+ }
+
+ return loadScript(CDN.mathjax);
+ }
+
+ function removePdfMathAssistiveElements(container) {
+ container.querySelectorAll('mjx-assistive-mml, script[type*="math"], script[type*="tex"]').forEach(el => {
+ el.remove();
+ });
+ container.querySelectorAll('mjx-container[tabindex="0"]').forEach(mjx => {
+ mjx.removeAttribute('tabindex');
+ });
+ }
+
+ function waitForPdfImages(container, state, timeoutMs = 8000) {
+ const images = Array.from(container.querySelectorAll('img'));
+ const pending = images.filter(img => !img.complete || img.naturalWidth === 0);
+ if (pending.length === 0) return Promise.resolve();
+
+ const timeout = new Promise(resolve => setTimeout(resolve, timeoutMs));
+ const imageWork = Promise.all(pending.map(img => {
+ if (img.decode) {
+ return img.decode().catch(() => undefined);
+ }
+ return new Promise(resolve => {
+ const finish = () => resolve();
+ img.addEventListener('load', finish, { once: true });
+ img.addEventListener('error', finish, { once: true });
+ });
+ }));
+
+ return runPdfAbortable(state, Promise.race([imageWork, timeout]));
+ }
+
+ async function renderPdfDynamicContent(container, markdown, state) {
+ const mermaidNodes = container.querySelectorAll('.mermaid');
+ if (mermaidNodes.length > 0) {
+ updatePdfProgress(state, 30, "Rendering diagrams");
+ if (typeof mermaid === 'undefined') {
+ await runPdfAbortable(state, loadScript(CDN.mermaid));
+ }
+ throwIfPdfExportAborted(state.signal);
+ initMermaid(true);
+ await runPdfAbortable(state, Promise.resolve(mermaid.init(undefined, mermaidNodes)));
+ container.querySelectorAll('.mermaid-container.is-loading').forEach(el => {
+ el.classList.remove('is-loading');
+ });
+ await waitForPdfFrame(state);
+ }
+
+ if (markdownLikelyContainsMath(markdown)) {
+ updatePdfProgress(state, 42, "Rendering math");
+ await runPdfAbortable(state, configureMathJaxForPdfExport());
+ if (window.MathJax && typeof window.MathJax.typesetPromise === 'function') {
+ await runPdfAbortable(state, MathJax.typesetPromise([container]));
+ }
+ removePdfMathAssistiveElements(container);
+ await waitForPdfFrame(state);
+ }
+
+ updatePdfProgress(state, 52, "Loading images");
+ await waitForPdfImages(container, state);
+ }
+
+ function getPdfPrintStylesheetLinks() {
+ const githubCss = getLoadedStylesheetUrl(
+ 'github-markdown',
+ 'https://cdnjs.cloudflare.com/ajax/libs/github-markdown-css/5.3.0/github-markdown.min.css'
+ );
+ const appCss = getLoadedStylesheetUrl('styles.css', new URL('styles.css', window.location.href).toString());
+ return [githubCss, appCss]
+ .filter(Boolean)
+ .map(href => ``)
+ .join('\n');
+ }
+
+ function buildPdfPrintCss(isDarkTheme) {
+ const pageBg = isDarkTheme ? '#0d1117' : '#ffffff';
+ const pageFg = isDarkTheme ? '#c9d1d9' : '#24292f';
+ return `
+ @page {
+ size: A4;
+ margin: 15mm;
+ }
+
+ html,
+ body {
+ background: ${pageBg};
+ color: ${pageFg};
+ }
+
+ body.pdf-print-document {
+ margin: 0;
+ -webkit-print-color-adjust: exact;
+ print-color-adjust: exact;
+ }
+
+ .pdf-print-document .markdown-body {
+ box-sizing: border-box;
+ width: 100%;
+ max-width: none;
+ min-width: 0;
+ margin: 0;
+ padding: 0;
+ background: ${pageBg};
+ color: ${pageFg};
+ font-size: 14px;
+ }
+
+ .pdf-print-document img,
+ .pdf-print-document svg,
+ .pdf-print-document pre,
+ .pdf-print-document table,
+ .pdf-print-document blockquote,
+ .pdf-print-document mjx-container,
+ .pdf-print-document .math-block,
+ .pdf-print-document .mermaid-container,
+ .pdf-print-document .markdown-alert,
+ .pdf-print-document .frontmatter-table {
+ break-inside: avoid;
+ page-break-inside: avoid;
+ }
+
+ .pdf-print-document h1,
+ .pdf-print-document h2,
+ .pdf-print-document h3,
+ .pdf-print-document h4,
+ .pdf-print-document h5,
+ .pdf-print-document h6 {
+ break-after: avoid;
+ page-break-after: avoid;
+ }
+
+ .pdf-print-document table {
+ width: 100%;
+ border-collapse: collapse;
+ }
+
+ .pdf-print-document thead {
+ display: table-header-group;
+ }
+
+ .pdf-print-document tr {
+ break-inside: avoid;
+ page-break-inside: avoid;
+ }
+
+ .pdf-print-document pre {
+ white-space: pre-wrap;
+ overflow-wrap: anywhere;
+ }
+
+ .pdf-print-document code {
+ white-space: pre-wrap;
+ }
+
+ .pdf-print-document img {
+ max-width: 100%;
+ height: auto;
+ }
+
+ .pdf-print-document .mermaid-container svg {
+ max-width: 100%;
+ height: auto;
+ }
+
+ .pdf-print-document .pdf-page-break {
+ break-before: page;
+ page-break-before: always;
+ height: 0;
+ }
+
+ @media print {
+ html,
+ body {
+ width: auto;
+ min-height: auto;
+ }
+ }
+ `;
+ }
+
+ function buildPdfPrintDocumentHtml(contentHtml) {
+ const isDarkTheme = document.documentElement.getAttribute("data-theme") === "dark";
+ const direction = markdownEditor ? (markdownEditor.getAttribute("dir") || document.documentElement.getAttribute("dir") || "ltr") : "ltr";
+ return `
+
+
+
+
+
+ Markdown PDF Export
+ ${getPdfPrintStylesheetLinks()}
+
+
+
+
+ ${contentHtml}
+
+
+`;
+ }
+
+ function waitForPrintDocumentReady(printDocument, state) {
+ const styleLinks = Array.from(printDocument.querySelectorAll('link[rel="stylesheet"]'));
+ const styleReady = Promise.all(styleLinks.map(link => {
+ if (link.sheet) return Promise.resolve();
+ return new Promise(resolve => {
+ const finish = () => resolve();
+ link.addEventListener('load', finish, { once: true });
+ link.addEventListener('error', finish, { once: true });
+ setTimeout(finish, 3000);
+ });
+ }));
+
+ const fontReady = printDocument.fonts && printDocument.fonts.ready
+ ? printDocument.fonts.ready.catch(() => undefined)
+ : Promise.resolve();
+
+ return runPdfAbortable(state, Promise.all([styleReady, fontReady, waitForPdfImages(printDocument.body, state, 5000)]));
+ }
+
+ function schedulePdfPrintFrameCleanup(frame) {
+ let cleaned = false;
+ const cleanup = () => {
+ if (cleaned) return;
+ cleaned = true;
+ if (frame && frame.parentNode) {
+ frame.parentNode.removeChild(frame);
+ }
+ };
+
+ try {
+ frame.contentWindow.addEventListener('afterprint', cleanup, { once: true });
+ } catch (e) {
+ // Some embedded browsers do not expose afterprint on iframe windows.
+ }
+ setTimeout(cleanup, 30000);
+ }
+
+ async function openPdfPrintDocument(contentHtml, state) {
+ updatePdfProgress(state, 72, "Preparing print document");
+ const frame = document.createElement('iframe');
+ frame.setAttribute('title', 'PDF export print document');
+ frame.style.position = 'fixed';
+ frame.style.right = '0';
+ frame.style.bottom = '0';
+ frame.style.width = '0';
+ frame.style.height = '0';
+ frame.style.border = '0';
+ frame.style.opacity = '0';
+ frame.style.pointerEvents = 'none';
+ frame.setAttribute('aria-hidden', 'true');
+ document.body.appendChild(frame);
+
+ const printDocument = frame.contentDocument || frame.contentWindow.document;
+ printDocument.open();
+ printDocument.write(buildPdfPrintDocumentHtml(contentHtml));
+ printDocument.close();
+
+ await waitForPrintDocumentReady(printDocument, state);
+ throwIfPdfExportAborted(state.signal);
+
+ if (!frame.contentWindow || typeof frame.contentWindow.print !== 'function') {
+ throw new Error("Browser print is unavailable.");
+ }
+
+ updatePdfProgress(state, 96, "Opening PDF dialog");
+ frame.contentWindow.focus();
+ frame.contentWindow.print();
+ schedulePdfPrintFrameCleanup(frame);
+ }
+
+ async function exportPdfViaPrint(progressState) {
+ updatePdfProgress(progressState, 12, "Preparing document");
+ await waitForPdfFrame(progressState);
+
+ const markdown = markdownEditor.value;
+ const contentElement = buildPdfExportContentElement(markdown);
+ contentElement.className = "markdown-body pdf-export pdf-print-source";
+ contentElement.style.padding = "20px";
+ contentElement.style.width = "210mm";
+ contentElement.style.margin = "0 auto";
+ contentElement.style.fontSize = "14px";
+ contentElement.style.position = "fixed";
+ contentElement.style.left = "-9999px";
+ contentElement.style.top = "0";
+ contentElement.style.backgroundColor = document.documentElement.getAttribute("data-theme") === "dark" ? "#0d1117" : "#ffffff";
+ contentElement.style.color = document.documentElement.getAttribute("data-theme") === "dark" ? "#c9d1d9" : "#24292e";
+ progressState.tempElement = contentElement;
+ document.body.appendChild(contentElement);
+
+ await waitForPdfFrame(progressState);
+ await renderPdfDynamicContent(contentElement, markdown, progressState);
+ await waitForPdfFrame(progressState);
+
+ updatePdfProgress(progressState, 64, "Paginating");
+ await openPdfPrintDocument(contentElement.innerHTML, progressState);
+ }
+
function choosePdfCanvasScale(element) {
const pixelArea = element.offsetWidth * element.scrollHeight;
if (pixelArea > 14000000) return 1.25;
@@ -7470,6 +7835,24 @@ document.addEventListener("DOMContentLoaded", function () {
progressState.overlay.querySelector(".pdf-progress-cancel")?.focus();
try {
+ try {
+ await exportPdfViaPrint(progressState);
+ throwIfPdfExportAborted(progressState.signal);
+ updatePdfProgress(progressState, 100, "Complete");
+ return;
+ } catch (printExportError) {
+ if (printExportError instanceof PdfExportCancelledError || progressState.signal.aborted) {
+ throw printExportError;
+ }
+ console.warn("Fast PDF print export failed; using compatibility exporter:", printExportError);
+ if (progressState.tempElement && progressState.tempElement.parentNode) {
+ progressState.tempElement.parentNode.removeChild(progressState.tempElement);
+ progressState.tempElement = null;
+ }
+ updatePdfProgress(progressState, 8, "Using compatibility exporter");
+ await waitForPdfFrame(progressState);
+ }
+
// PERF-002: Lazy-load PDF libraries on first export
if (typeof jspdf === 'undefined' || typeof html2canvas === 'undefined') {
updatePdfProgress(progressState, 8, "Loading PDF libraries");
@@ -7480,20 +7863,13 @@ document.addEventListener("DOMContentLoaded", function () {
updatePdfProgress(progressState, 15, "Parsing markdown");
await waitForPdfFrame(progressState);
const markdown = markdownEditor.value;
- const html = marked.parse(markdown);
- const sanitizedHtml = DOMPurify.sanitize(html, {
- ADD_TAGS: ['mjx-container', 'svg', 'path', 'g', 'marker', 'defs', 'pattern', 'clipPath', 'input'],
- ADD_ATTR: ['id', 'class', 'style', 'align', 'viewBox', 'd', 'fill', 'stroke', 'transform', 'marker-end', 'marker-start', 'type', 'checked', 'disabled', 'data-original-code']
- });
+ const tempElement = buildPdfExportContentElement(markdown);
throwIfPdfExportAborted(progressState.signal);
updatePdfProgress(progressState, 24, "Preparing document");
await waitForPdfFrame(progressState);
- const tempElement = document.createElement("div");
progressState.tempElement = tempElement;
tempElement.className = "markdown-body pdf-export";
- tempElement.innerHTML = sanitizedHtml;
- enhanceGitHubAlerts(tempElement);
tempElement.style.padding = "20px";
tempElement.style.width = "210mm";
tempElement.style.margin = "0 auto";
@@ -7533,9 +7909,10 @@ document.addEventListener("DOMContentLoaded", function () {
await waitForPdfFrame(progressState);
}
- if (window.MathJax && markdownLikelyContainsMath(markdown)) {
+ if (markdownLikelyContainsMath(markdown)) {
updatePdfProgress(progressState, 44, "Rendering math");
try {
+ await runPdfAbortable(progressState, configureMathJaxForPdfExport());
await runPdfAbortable(progressState, MathJax.typesetPromise([tempElement]));
} catch (mathJaxError) {
if (mathJaxError instanceof PdfExportCancelledError) throw mathJaxError;
@@ -7543,25 +7920,11 @@ document.addEventListener("DOMContentLoaded", function () {
}
throwIfPdfExportAborted(progressState.signal);
- // Hide MathJax assistive elements that cause duplicate text in PDF
- // These are screen reader elements that html2canvas captures as visible
- // Use multiple CSS properties to ensure html2canvas doesn't render them
- const assistiveElements = tempElement.querySelectorAll('mjx-assistive-mml');
- assistiveElements.forEach(el => {
- el.style.display = 'none';
- el.style.visibility = 'hidden';
- el.style.position = 'absolute';
- el.style.width = '0';
- el.style.height = '0';
- el.style.overflow = 'hidden';
- el.remove(); // Remove entirely from DOM
- });
-
- // Also hide any MathJax script elements that might contain source
- const mathScripts = tempElement.querySelectorAll('script[type*="math"], script[type*="tex"]');
- mathScripts.forEach(el => el.remove());
+ removePdfMathAssistiveElements(tempElement);
}
+ updatePdfProgress(progressState, 50, "Loading images");
+ await waitForPdfImages(tempElement, progressState);
await waitForPdfFrame(progressState);
fitExportElementToContent(tempElement);
await waitForPdfFrame(progressState);
diff --git a/desktop-app/resources/styles.css b/desktop-app/resources/styles.css
index f080608..0fd4c82 100644
--- a/desktop-app/resources/styles.css
+++ b/desktop-app/resources/styles.css
@@ -1554,6 +1554,88 @@ a:focus {
[data-theme="dark"] .pdf-export table td[rowspan] {
background-color: var(--table-bg, #161b22) !important;
}
+
+/* ========================================
+ FAST PDF PRINT DOCUMENT
+ ======================================== */
+
+body.pdf-print-document {
+ margin: 0;
+ -webkit-print-color-adjust: exact;
+ print-color-adjust: exact;
+}
+
+body.pdf-print-document .markdown-body {
+ box-sizing: border-box;
+ width: 100%;
+ max-width: none;
+ min-width: 0;
+ margin: 0;
+ padding: 0;
+}
+
+body.pdf-print-document img,
+body.pdf-print-document svg,
+body.pdf-print-document pre,
+body.pdf-print-document table,
+body.pdf-print-document blockquote,
+body.pdf-print-document mjx-container,
+body.pdf-print-document .math-block,
+body.pdf-print-document .mermaid-container,
+body.pdf-print-document .markdown-alert,
+body.pdf-print-document .frontmatter-table {
+ break-inside: avoid;
+ page-break-inside: avoid;
+}
+
+body.pdf-print-document h1,
+body.pdf-print-document h2,
+body.pdf-print-document h3,
+body.pdf-print-document h4,
+body.pdf-print-document h5,
+body.pdf-print-document h6 {
+ break-after: avoid;
+ page-break-after: avoid;
+}
+
+body.pdf-print-document thead {
+ display: table-header-group;
+}
+
+body.pdf-print-document tr {
+ break-inside: avoid;
+ page-break-inside: avoid;
+}
+
+body.pdf-print-document pre,
+body.pdf-print-document code {
+ white-space: pre-wrap;
+ overflow-wrap: anywhere;
+}
+
+body.pdf-print-document img,
+body.pdf-print-document .mermaid-container svg {
+ max-width: 100%;
+ height: auto;
+}
+
+body.pdf-print-document .pdf-page-break {
+ break-before: page;
+ page-break-before: always;
+ height: 0;
+}
+
+@media print {
+ @page {
+ size: A4;
+ margin: 15mm;
+ }
+
+ body.pdf-print-document {
+ background: var(--preview-bg, #ffffff);
+ color: var(--preview-text-color, #24292e);
+ }
+}
/* ========================================
MERMAID DIAGRAM TOOLBAR
diff --git a/script.js b/script.js
index ee7fc2c..a436fbd 100644
--- a/script.js
+++ b/script.js
@@ -25,15 +25,20 @@ document.addEventListener("DOMContentLoaded", function () {
});
}
+ const IS_DESKTOP_RUNTIME = typeof Neutralino !== 'undefined';
+ function resolveOptionalAssetUrl(localFilename, remoteUrl) {
+ return IS_DESKTOP_RUNTIME ? `/libs/${localFilename}` : remoteUrl;
+ }
+
// CDN URLs for lazy-loaded libraries
const CDN = {
- mermaid: 'https://cdn.jsdelivr.net/npm/mermaid@11.15.0/dist/mermaid.min.js',
- mathjax: 'https://cdnjs.cloudflare.com/ajax/libs/mathjax/3.2.2/es5/tex-mml-chtml.min.js',
- jspdf: 'https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js',
- html2canvas: 'https://cdnjs.cloudflare.com/ajax/libs/html2canvas/1.4.1/html2canvas.min.js',
- pako: 'https://cdnjs.cloudflare.com/ajax/libs/pako/2.1.0/pako.min.js',
- joypixels: 'https://cdn.jsdelivr.net/npm/emoji-toolkit@9.0.1/lib/js/joypixels.min.js',
- joypixels_css: 'https://cdn.jsdelivr.net/npm/emoji-toolkit@9.0.1/extras/css/joypixels.min.css'
+ mermaid: resolveOptionalAssetUrl('mermaid.min.js', 'https://cdn.jsdelivr.net/npm/mermaid@11.15.0/dist/mermaid.min.js'),
+ mathjax: resolveOptionalAssetUrl('tex-mml-chtml.min.js', 'https://cdnjs.cloudflare.com/ajax/libs/mathjax/3.2.2/es5/tex-mml-chtml.min.js'),
+ jspdf: resolveOptionalAssetUrl('jspdf.umd.min.js', 'https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js'),
+ html2canvas: resolveOptionalAssetUrl('html2canvas.min.js', 'https://cdnjs.cloudflare.com/ajax/libs/html2canvas/1.4.1/html2canvas.min.js'),
+ pako: resolveOptionalAssetUrl('pako.min.js', 'https://cdnjs.cloudflare.com/ajax/libs/pako/2.1.0/pako.min.js'),
+ joypixels: resolveOptionalAssetUrl('joypixels.min.js', 'https://cdn.jsdelivr.net/npm/emoji-toolkit@9.0.1/lib/js/joypixels.min.js'),
+ joypixels_css: resolveOptionalAssetUrl('joypixels.min.css', 'https://cdn.jsdelivr.net/npm/emoji-toolkit@9.0.1/extras/css/joypixels.min.css')
};
let markdownRenderTimeout = null;
@@ -426,6 +431,21 @@ document.addEventListener("DOMContentLoaded", function () {
return fallbackUrl;
}
+ function getLoadedStylesheetUrl(needle, fallbackUrl) {
+ const links = document.querySelectorAll('link[rel="stylesheet"], link[rel="preload"][as="style"]');
+ for (let i = 0; i < links.length; i += 1) {
+ const href = links[i].getAttribute("href") || "";
+ if (href.includes(needle)) {
+ try {
+ return new URL(href, window.location.href).toString();
+ } catch (e) {
+ return href;
+ }
+ }
+ }
+ return fallbackUrl;
+ }
+
function getPreviewWorkerUrl() {
const scripts = document.getElementsByTagName("script");
let scriptUrl = "";
@@ -6971,6 +6991,351 @@ document.addEventListener("DOMContentLoaded", function () {
return /(^|[^\\])\$\$|\\\[|\\\(|(^|[^\\])\$[^$\n]+\$/.test(markdown);
}
+ const PDF_EXPORT_SANITIZE_OPTIONS = {
+ ADD_TAGS: ['mjx-container', 'svg', 'path', 'g', 'marker', 'defs', 'pattern', 'clipPath', 'input'],
+ ADD_ATTR: [
+ 'id', 'class', 'style', 'align', 'viewBox', 'd', 'fill', 'stroke', 'transform',
+ 'marker-end', 'marker-start', 'type', 'checked', 'disabled', 'data-original-code',
+ 'xmlns', 'role', 'aria-hidden'
+ ],
+ ALLOWED_URI_REGEXP: PREVIEW_SANITIZE_OPTIONS.ALLOWED_URI_REGEXP
+ };
+
+ function buildPdfExportContentElement(markdown) {
+ const { frontmatter, body } = parseFrontmatter(markdown);
+ const tableHtml = frontmatter ? renderFrontmatterTable(frontmatter) : '';
+ const referenceData = extractReferenceDefinitions(body);
+ const markdownWithPageBreaks = referenceData.cleanedMarkdown.replace(
+ //gi,
+ ''
+ );
+ const html = tableHtml + marked.parse(markdownWithPageBreaks);
+ const sanitizedHtml = DOMPurify.sanitize(html, PDF_EXPORT_SANITIZE_OPTIONS);
+ const container = document.createElement("div");
+ container.innerHTML = sanitizedHtml;
+ applyReferencePreviewLinks(container, referenceData.definitions);
+ enhanceGitHubAlerts(container);
+ return container;
+ }
+
+ function configureMathJaxForPdfExport() {
+ if (window.MathJax && typeof window.MathJax.typesetPromise === 'function') {
+ return Promise.resolve();
+ }
+
+ if (!window.MathJax || typeof window.MathJax.typesetPromise !== 'function') {
+ window.MathJax = {
+ loader: { load: ['[tex]/ams', '[tex]/boldsymbol'] },
+ options: {
+ a11y: { inTabOrder: false }
+ },
+ tex: {
+ inlineMath: [['$', '$'], ['\\(', '\\)']],
+ displayMath: [['$$', '$$'], ['\\[', '\\]']],
+ processEscapes: true,
+ packages: { '[+]': ['ams', 'boldsymbol'] }
+ }
+ };
+ }
+
+ return loadScript(CDN.mathjax);
+ }
+
+ function removePdfMathAssistiveElements(container) {
+ container.querySelectorAll('mjx-assistive-mml, script[type*="math"], script[type*="tex"]').forEach(el => {
+ el.remove();
+ });
+ container.querySelectorAll('mjx-container[tabindex="0"]').forEach(mjx => {
+ mjx.removeAttribute('tabindex');
+ });
+ }
+
+ function waitForPdfImages(container, state, timeoutMs = 8000) {
+ const images = Array.from(container.querySelectorAll('img'));
+ const pending = images.filter(img => !img.complete || img.naturalWidth === 0);
+ if (pending.length === 0) return Promise.resolve();
+
+ const timeout = new Promise(resolve => setTimeout(resolve, timeoutMs));
+ const imageWork = Promise.all(pending.map(img => {
+ if (img.decode) {
+ return img.decode().catch(() => undefined);
+ }
+ return new Promise(resolve => {
+ const finish = () => resolve();
+ img.addEventListener('load', finish, { once: true });
+ img.addEventListener('error', finish, { once: true });
+ });
+ }));
+
+ return runPdfAbortable(state, Promise.race([imageWork, timeout]));
+ }
+
+ async function renderPdfDynamicContent(container, markdown, state) {
+ const mermaidNodes = container.querySelectorAll('.mermaid');
+ if (mermaidNodes.length > 0) {
+ updatePdfProgress(state, 30, "Rendering diagrams");
+ if (typeof mermaid === 'undefined') {
+ await runPdfAbortable(state, loadScript(CDN.mermaid));
+ }
+ throwIfPdfExportAborted(state.signal);
+ initMermaid(true);
+ await runPdfAbortable(state, Promise.resolve(mermaid.init(undefined, mermaidNodes)));
+ container.querySelectorAll('.mermaid-container.is-loading').forEach(el => {
+ el.classList.remove('is-loading');
+ });
+ await waitForPdfFrame(state);
+ }
+
+ if (markdownLikelyContainsMath(markdown)) {
+ updatePdfProgress(state, 42, "Rendering math");
+ await runPdfAbortable(state, configureMathJaxForPdfExport());
+ if (window.MathJax && typeof window.MathJax.typesetPromise === 'function') {
+ await runPdfAbortable(state, MathJax.typesetPromise([container]));
+ }
+ removePdfMathAssistiveElements(container);
+ await waitForPdfFrame(state);
+ }
+
+ updatePdfProgress(state, 52, "Loading images");
+ await waitForPdfImages(container, state);
+ }
+
+ function getPdfPrintStylesheetLinks() {
+ const githubCss = getLoadedStylesheetUrl(
+ 'github-markdown',
+ 'https://cdnjs.cloudflare.com/ajax/libs/github-markdown-css/5.3.0/github-markdown.min.css'
+ );
+ const appCss = getLoadedStylesheetUrl('styles.css', new URL('styles.css', window.location.href).toString());
+ return [githubCss, appCss]
+ .filter(Boolean)
+ .map(href => ``)
+ .join('\n');
+ }
+
+ function buildPdfPrintCss(isDarkTheme) {
+ const pageBg = isDarkTheme ? '#0d1117' : '#ffffff';
+ const pageFg = isDarkTheme ? '#c9d1d9' : '#24292f';
+ return `
+ @page {
+ size: A4;
+ margin: 15mm;
+ }
+
+ html,
+ body {
+ background: ${pageBg};
+ color: ${pageFg};
+ }
+
+ body.pdf-print-document {
+ margin: 0;
+ -webkit-print-color-adjust: exact;
+ print-color-adjust: exact;
+ }
+
+ .pdf-print-document .markdown-body {
+ box-sizing: border-box;
+ width: 100%;
+ max-width: none;
+ min-width: 0;
+ margin: 0;
+ padding: 0;
+ background: ${pageBg};
+ color: ${pageFg};
+ font-size: 14px;
+ }
+
+ .pdf-print-document img,
+ .pdf-print-document svg,
+ .pdf-print-document pre,
+ .pdf-print-document table,
+ .pdf-print-document blockquote,
+ .pdf-print-document mjx-container,
+ .pdf-print-document .math-block,
+ .pdf-print-document .mermaid-container,
+ .pdf-print-document .markdown-alert,
+ .pdf-print-document .frontmatter-table {
+ break-inside: avoid;
+ page-break-inside: avoid;
+ }
+
+ .pdf-print-document h1,
+ .pdf-print-document h2,
+ .pdf-print-document h3,
+ .pdf-print-document h4,
+ .pdf-print-document h5,
+ .pdf-print-document h6 {
+ break-after: avoid;
+ page-break-after: avoid;
+ }
+
+ .pdf-print-document table {
+ width: 100%;
+ border-collapse: collapse;
+ }
+
+ .pdf-print-document thead {
+ display: table-header-group;
+ }
+
+ .pdf-print-document tr {
+ break-inside: avoid;
+ page-break-inside: avoid;
+ }
+
+ .pdf-print-document pre {
+ white-space: pre-wrap;
+ overflow-wrap: anywhere;
+ }
+
+ .pdf-print-document code {
+ white-space: pre-wrap;
+ }
+
+ .pdf-print-document img {
+ max-width: 100%;
+ height: auto;
+ }
+
+ .pdf-print-document .mermaid-container svg {
+ max-width: 100%;
+ height: auto;
+ }
+
+ .pdf-print-document .pdf-page-break {
+ break-before: page;
+ page-break-before: always;
+ height: 0;
+ }
+
+ @media print {
+ html,
+ body {
+ width: auto;
+ min-height: auto;
+ }
+ }
+ `;
+ }
+
+ function buildPdfPrintDocumentHtml(contentHtml) {
+ const isDarkTheme = document.documentElement.getAttribute("data-theme") === "dark";
+ const direction = markdownEditor ? (markdownEditor.getAttribute("dir") || document.documentElement.getAttribute("dir") || "ltr") : "ltr";
+ return `
+
+
+
+
+
+ Markdown PDF Export
+ ${getPdfPrintStylesheetLinks()}
+
+
+
+
+ ${contentHtml}
+
+
+`;
+ }
+
+ function waitForPrintDocumentReady(printDocument, state) {
+ const styleLinks = Array.from(printDocument.querySelectorAll('link[rel="stylesheet"]'));
+ const styleReady = Promise.all(styleLinks.map(link => {
+ if (link.sheet) return Promise.resolve();
+ return new Promise(resolve => {
+ const finish = () => resolve();
+ link.addEventListener('load', finish, { once: true });
+ link.addEventListener('error', finish, { once: true });
+ setTimeout(finish, 3000);
+ });
+ }));
+
+ const fontReady = printDocument.fonts && printDocument.fonts.ready
+ ? printDocument.fonts.ready.catch(() => undefined)
+ : Promise.resolve();
+
+ return runPdfAbortable(state, Promise.all([styleReady, fontReady, waitForPdfImages(printDocument.body, state, 5000)]));
+ }
+
+ function schedulePdfPrintFrameCleanup(frame) {
+ let cleaned = false;
+ const cleanup = () => {
+ if (cleaned) return;
+ cleaned = true;
+ if (frame && frame.parentNode) {
+ frame.parentNode.removeChild(frame);
+ }
+ };
+
+ try {
+ frame.contentWindow.addEventListener('afterprint', cleanup, { once: true });
+ } catch (e) {
+ // Some embedded browsers do not expose afterprint on iframe windows.
+ }
+ setTimeout(cleanup, 30000);
+ }
+
+ async function openPdfPrintDocument(contentHtml, state) {
+ updatePdfProgress(state, 72, "Preparing print document");
+ const frame = document.createElement('iframe');
+ frame.setAttribute('title', 'PDF export print document');
+ frame.style.position = 'fixed';
+ frame.style.right = '0';
+ frame.style.bottom = '0';
+ frame.style.width = '0';
+ frame.style.height = '0';
+ frame.style.border = '0';
+ frame.style.opacity = '0';
+ frame.style.pointerEvents = 'none';
+ frame.setAttribute('aria-hidden', 'true');
+ document.body.appendChild(frame);
+
+ const printDocument = frame.contentDocument || frame.contentWindow.document;
+ printDocument.open();
+ printDocument.write(buildPdfPrintDocumentHtml(contentHtml));
+ printDocument.close();
+
+ await waitForPrintDocumentReady(printDocument, state);
+ throwIfPdfExportAborted(state.signal);
+
+ if (!frame.contentWindow || typeof frame.contentWindow.print !== 'function') {
+ throw new Error("Browser print is unavailable.");
+ }
+
+ updatePdfProgress(state, 96, "Opening PDF dialog");
+ frame.contentWindow.focus();
+ frame.contentWindow.print();
+ schedulePdfPrintFrameCleanup(frame);
+ }
+
+ async function exportPdfViaPrint(progressState) {
+ updatePdfProgress(progressState, 12, "Preparing document");
+ await waitForPdfFrame(progressState);
+
+ const markdown = markdownEditor.value;
+ const contentElement = buildPdfExportContentElement(markdown);
+ contentElement.className = "markdown-body pdf-export pdf-print-source";
+ contentElement.style.padding = "20px";
+ contentElement.style.width = "210mm";
+ contentElement.style.margin = "0 auto";
+ contentElement.style.fontSize = "14px";
+ contentElement.style.position = "fixed";
+ contentElement.style.left = "-9999px";
+ contentElement.style.top = "0";
+ contentElement.style.backgroundColor = document.documentElement.getAttribute("data-theme") === "dark" ? "#0d1117" : "#ffffff";
+ contentElement.style.color = document.documentElement.getAttribute("data-theme") === "dark" ? "#c9d1d9" : "#24292e";
+ progressState.tempElement = contentElement;
+ document.body.appendChild(contentElement);
+
+ await waitForPdfFrame(progressState);
+ await renderPdfDynamicContent(contentElement, markdown, progressState);
+ await waitForPdfFrame(progressState);
+
+ updatePdfProgress(progressState, 64, "Paginating");
+ await openPdfPrintDocument(contentElement.innerHTML, progressState);
+ }
+
function choosePdfCanvasScale(element) {
const pixelArea = element.offsetWidth * element.scrollHeight;
if (pixelArea > 14000000) return 1.25;
@@ -7470,6 +7835,24 @@ document.addEventListener("DOMContentLoaded", function () {
progressState.overlay.querySelector(".pdf-progress-cancel")?.focus();
try {
+ try {
+ await exportPdfViaPrint(progressState);
+ throwIfPdfExportAborted(progressState.signal);
+ updatePdfProgress(progressState, 100, "Complete");
+ return;
+ } catch (printExportError) {
+ if (printExportError instanceof PdfExportCancelledError || progressState.signal.aborted) {
+ throw printExportError;
+ }
+ console.warn("Fast PDF print export failed; using compatibility exporter:", printExportError);
+ if (progressState.tempElement && progressState.tempElement.parentNode) {
+ progressState.tempElement.parentNode.removeChild(progressState.tempElement);
+ progressState.tempElement = null;
+ }
+ updatePdfProgress(progressState, 8, "Using compatibility exporter");
+ await waitForPdfFrame(progressState);
+ }
+
// PERF-002: Lazy-load PDF libraries on first export
if (typeof jspdf === 'undefined' || typeof html2canvas === 'undefined') {
updatePdfProgress(progressState, 8, "Loading PDF libraries");
@@ -7480,20 +7863,13 @@ document.addEventListener("DOMContentLoaded", function () {
updatePdfProgress(progressState, 15, "Parsing markdown");
await waitForPdfFrame(progressState);
const markdown = markdownEditor.value;
- const html = marked.parse(markdown);
- const sanitizedHtml = DOMPurify.sanitize(html, {
- ADD_TAGS: ['mjx-container', 'svg', 'path', 'g', 'marker', 'defs', 'pattern', 'clipPath', 'input'],
- ADD_ATTR: ['id', 'class', 'style', 'align', 'viewBox', 'd', 'fill', 'stroke', 'transform', 'marker-end', 'marker-start', 'type', 'checked', 'disabled', 'data-original-code']
- });
+ const tempElement = buildPdfExportContentElement(markdown);
throwIfPdfExportAborted(progressState.signal);
updatePdfProgress(progressState, 24, "Preparing document");
await waitForPdfFrame(progressState);
- const tempElement = document.createElement("div");
progressState.tempElement = tempElement;
tempElement.className = "markdown-body pdf-export";
- tempElement.innerHTML = sanitizedHtml;
- enhanceGitHubAlerts(tempElement);
tempElement.style.padding = "20px";
tempElement.style.width = "210mm";
tempElement.style.margin = "0 auto";
@@ -7533,9 +7909,10 @@ document.addEventListener("DOMContentLoaded", function () {
await waitForPdfFrame(progressState);
}
- if (window.MathJax && markdownLikelyContainsMath(markdown)) {
+ if (markdownLikelyContainsMath(markdown)) {
updatePdfProgress(progressState, 44, "Rendering math");
try {
+ await runPdfAbortable(progressState, configureMathJaxForPdfExport());
await runPdfAbortable(progressState, MathJax.typesetPromise([tempElement]));
} catch (mathJaxError) {
if (mathJaxError instanceof PdfExportCancelledError) throw mathJaxError;
@@ -7543,25 +7920,11 @@ document.addEventListener("DOMContentLoaded", function () {
}
throwIfPdfExportAborted(progressState.signal);
- // Hide MathJax assistive elements that cause duplicate text in PDF
- // These are screen reader elements that html2canvas captures as visible
- // Use multiple CSS properties to ensure html2canvas doesn't render them
- const assistiveElements = tempElement.querySelectorAll('mjx-assistive-mml');
- assistiveElements.forEach(el => {
- el.style.display = 'none';
- el.style.visibility = 'hidden';
- el.style.position = 'absolute';
- el.style.width = '0';
- el.style.height = '0';
- el.style.overflow = 'hidden';
- el.remove(); // Remove entirely from DOM
- });
-
- // Also hide any MathJax script elements that might contain source
- const mathScripts = tempElement.querySelectorAll('script[type*="math"], script[type*="tex"]');
- mathScripts.forEach(el => el.remove());
+ removePdfMathAssistiveElements(tempElement);
}
+ updatePdfProgress(progressState, 50, "Loading images");
+ await waitForPdfImages(tempElement, progressState);
await waitForPdfFrame(progressState);
fitExportElementToContent(tempElement);
await waitForPdfFrame(progressState);
diff --git a/styles.css b/styles.css
index f080608..0fd4c82 100644
--- a/styles.css
+++ b/styles.css
@@ -1554,6 +1554,88 @@ a:focus {
[data-theme="dark"] .pdf-export table td[rowspan] {
background-color: var(--table-bg, #161b22) !important;
}
+
+/* ========================================
+ FAST PDF PRINT DOCUMENT
+ ======================================== */
+
+body.pdf-print-document {
+ margin: 0;
+ -webkit-print-color-adjust: exact;
+ print-color-adjust: exact;
+}
+
+body.pdf-print-document .markdown-body {
+ box-sizing: border-box;
+ width: 100%;
+ max-width: none;
+ min-width: 0;
+ margin: 0;
+ padding: 0;
+}
+
+body.pdf-print-document img,
+body.pdf-print-document svg,
+body.pdf-print-document pre,
+body.pdf-print-document table,
+body.pdf-print-document blockquote,
+body.pdf-print-document mjx-container,
+body.pdf-print-document .math-block,
+body.pdf-print-document .mermaid-container,
+body.pdf-print-document .markdown-alert,
+body.pdf-print-document .frontmatter-table {
+ break-inside: avoid;
+ page-break-inside: avoid;
+}
+
+body.pdf-print-document h1,
+body.pdf-print-document h2,
+body.pdf-print-document h3,
+body.pdf-print-document h4,
+body.pdf-print-document h5,
+body.pdf-print-document h6 {
+ break-after: avoid;
+ page-break-after: avoid;
+}
+
+body.pdf-print-document thead {
+ display: table-header-group;
+}
+
+body.pdf-print-document tr {
+ break-inside: avoid;
+ page-break-inside: avoid;
+}
+
+body.pdf-print-document pre,
+body.pdf-print-document code {
+ white-space: pre-wrap;
+ overflow-wrap: anywhere;
+}
+
+body.pdf-print-document img,
+body.pdf-print-document .mermaid-container svg {
+ max-width: 100%;
+ height: auto;
+}
+
+body.pdf-print-document .pdf-page-break {
+ break-before: page;
+ page-break-before: always;
+ height: 0;
+}
+
+@media print {
+ @page {
+ size: A4;
+ margin: 15mm;
+ }
+
+ body.pdf-print-document {
+ background: var(--preview-bg, #ffffff);
+ color: var(--preview-text-color, #24292e);
+ }
+}
/* ========================================
MERMAID DIAGRAM TOOLBAR