Skip to content
Closed
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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,14 @@
All notable code changes to **Markdown Viewer** are documented here.
Non-code commits (documentation, planning, README-only updates) are excluded.

## Unreleased

- **PDF Export Engine:** Replaced full-document html2canvas/jsPDF rasterization with an isolated browser paged-media pipeline. Export now waits for fonts, images, Mermaid, and MathJax; preserves searchable text and vector content; applies semantic page-break rules; and avoids per-page PNG encoding and full-document canvas memory growth in web and desktop builds.
- **Validation:** Added focused PDF engine unit coverage and synchronized the new export module into the Neutralino resource bundle.
- **Date:** 2026-06-06

---

## v3.7.3

- **Description:** Delivered critical rendering, export, and editor reliability fixes across the application.
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -190,7 +190,7 @@ Explore the full documentation on the wiki:
- <a href="https://mermaid.js.org/" target="_blank" rel="noopener noreferrer">Mermaid</a>
- <a href="https://github.com/cure53/DOMPurify" target="_blank" rel="noopener noreferrer">DOMPurify</a>
- <a href="https://github.com/eligrey/FileSaver.js" target="_blank" rel="noopener noreferrer">FileSaver.js</a>
- <a href="https://github.com/niklasvh/html2canvas" target="_blank" rel="noopener noreferrer">html2canvas</a> + <a href="https://www.npmjs.com/package/jspdf" target="_blank" rel="noopener noreferrer">jsPDF</a>
- Standards-based browser paged-media printing for vector, searchable PDF output
- <a href="https://www.joypixels.com/" target="_blank" rel="noopener noreferrer">JoyPixels</a>

---
Expand Down
5 changes: 4 additions & 1 deletion desktop-app/prepare.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
/**
* prepare.js — Build script for the Neutralinojs desktop app.
*
* Copies shared browser-version files (script.js, styles.css, assets/)
* Copies shared browser-version files (script.js, pdf-export.js, styles.css, assets/)
* from the repo root into desktop-app/resources/, downloads all remote CDN
* libraries locally for 100% offline capabilities, validates their cryptographic
* integrity using SRI hashes (SHA-384), and generates a Neutralinojs-compatible index.html.
Expand Down Expand Up @@ -45,6 +45,8 @@ function copyDirSync(src, dest, excludePatterns) {
// Copy shared assets
fs.copyFileSync(path.join(ROOT_DIR, "script.js"), path.join(jsDest, "script.js"));
console.log("✓ Copied script.js → resources/js/script.js");
fs.copyFileSync(path.join(ROOT_DIR, "pdf-export.js"), path.join(jsDest, "pdf-export.js"));
console.log("✓ Copied pdf-export.js → resources/js/pdf-export.js");

fs.copyFileSync(path.join(ROOT_DIR, "preview-worker.js"), path.join(jsDest, "preview-worker.js"));
console.log("Copied preview-worker.js to resources/js/preview-worker.js");
Expand Down Expand Up @@ -204,6 +206,7 @@ async function prepareOfflineDependencies() {
// Fix relative assets
html = html.replace(/href="assets\//g, 'href="/assets/');
html = html.replace(/href="styles\.css"/g, 'href="/styles.css"');
html = html.replace(/src="pdf-export\.js"/g, 'src="/js/pdf-export.js"');

// PERF-034: Strip web-specific SEO tags, canonical, hreflang, preconnect, manifest and JSON-LD structured data for desktop build
html = html.replace(/<!-- DNS Prefetch & Preconnect CDN Origins to Warm Up Latency -->[\s\S]*?<!-- PERF-015:/i, '<!-- PERF-015:');
Expand Down
3 changes: 2 additions & 1 deletion desktop-app/resources/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
<script src="/libs/highlight.min.js" integrity="sha384-F/bZzf7p3Joyp5psL90p/p89AZJsndkSoGwRpXcZhleCWhd8SnRuoYo4d0yirjJp" crossorigin="anonymous" defer></script>
<script src="/libs/purify.min.js" integrity="sha384-3HPB1XT51W3gGRxAmZ+qbZwRpRlFQL632y8x+adAqCr4Wp3TaWwCLSTAJJKbyWEK" crossorigin="anonymous" defer></script>
<script src="/libs/FileSaver.min.js" integrity="sha384-PlRSzpewlarQuj5alIadXwjNUX+2eNMKwr0f07ShWYLy8B6TjEbm7ZlcN/ScSbwy" crossorigin="anonymous" defer></script>
<!-- PERF-002: MathJax, Mermaid, JoyPixels, jsPDF, html2canvas, pako are now lazy-loaded by script.js on first use -->
<!-- PERF-002: MathJax, Mermaid, JoyPixels, and pako are lazy-loaded by script.js on first use -->
<script src="/libs/js-yaml.min.js" integrity="sha384-+pxiN6T7yvpryuJmE1gM9PX7yQit15auDb+ZwwvJOd/4be2Cie5/IuVXgQb/S9du" crossorigin="anonymous" defer></script>
</head>
<body>
Expand Down Expand Up @@ -1026,6 +1026,7 @@ <h3 class="modal-section-title">Open-source credits</h3>
<script src="/libs/bootstrap.bundle.min.js" integrity="sha384-C6RzsynM9kWDrMNeT87bh95OGNyZPhcTNXj1NW7RuBCsyN/o0jlpcV8Qyq46cDfL" crossorigin="anonymous"></script>
<script src="/js/neutralino.js"></script>
<script src="/js/main.js"></script>
<script src="/js/pdf-export.js"></script>
<script src="/js/script.js"></script>
<!-- Screen reader dynamic accessibility announcer -->
<div id="app-accessibility-announcer" class="visually-hidden" aria-live="polite" aria-atomic="true"></div>
Expand Down
312 changes: 312 additions & 0 deletions desktop-app/resources/js/pdf-export.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,312 @@
(function (root, factory) {
const api = factory();
if (typeof module === "object" && module.exports) module.exports = api;
if (root) root.PdfPrintEngine = api;
})(typeof globalThis !== "undefined" ? globalThis : this, function () {
"use strict";

const DEFAULT_OPTIONS = Object.freeze({
pageSize: "A4",
margin: "15mm",
imageTimeoutMs: 15000,
layoutTimeoutMs: 3000,
cleanupTimeoutMs: 60000
});

class PdfExportCancelledError extends Error {
constructor() {
super("PDF export cancelled.");
this.name = "PdfExportCancelledError";
}
}

function throwIfAborted(signal) {
if (signal && signal.aborted) throw new PdfExportCancelledError();
}

function escapeHtml(value) {
return String(value)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}

function normalizeCssLength(value, fallback) {
return /^\d+(?:\.\d+)?(?:mm|cm|in|pt|px)$/.test(String(value || "")) ? String(value) : fallback;
}

function normalizePageSize(value) {
return /^(?:A[3-5]|letter|legal)$/i.test(String(value || "")) ? String(value) : DEFAULT_OPTIONS.pageSize;
}

function createPrintCss(options) {
const settings = Object.assign({}, DEFAULT_OPTIONS, options);
const pageSize = normalizePageSize(settings.pageSize);
const margin = normalizeCssLength(settings.margin, DEFAULT_OPTIONS.margin);
return `
@page { size: ${pageSize}; margin: ${margin}; }
html, body { background: #fff !important; height: auto !important; overflow: visible !important; }
body { margin: 0 !important; print-color-adjust: exact; -webkit-print-color-adjust: exact; }
.pdf-print-document.markdown-body {
box-sizing: border-box !important;
width: auto !important;
max-width: none !important;
min-height: 0 !important;
margin: 0 !important;
padding: 0 !important;
overflow: visible !important;
}
.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; page-break-after: avoid; }
.pdf-print-document p,
.pdf-print-document li { orphans: 3; widows: 3; }
.pdf-print-document img,
.pdf-print-document figure,
.pdf-print-document svg,
.pdf-print-document .mermaid-container,
.pdf-print-document .pdf-keep-together,
.pdf-print-document blockquote { break-inside: avoid-page; page-break-inside: avoid; }
.pdf-print-document img,
.pdf-print-document svg,
.pdf-print-document canvas,
.pdf-print-document .mermaid-container { max-width: 100% !important; height: auto !important; }
.pdf-print-document .mermaid-container svg { display: block; margin-inline: auto; max-height: 247mm; }
.pdf-print-document pre {
white-space: pre-wrap !important;
overflow-wrap: anywhere !important;
overflow: visible !important;
max-height: none !important;
}
.pdf-print-document pre.pdf-keep-together { break-inside: avoid-page; page-break-inside: avoid; }
.pdf-print-document table { width: 100% !important; border-collapse: collapse; break-inside: auto; page-break-inside: auto; }
.pdf-print-document thead { display: table-header-group; }
.pdf-print-document tfoot { display: table-footer-group; }
.pdf-print-document tr { break-inside: avoid-page; page-break-inside: avoid; }
.pdf-print-document th,
.pdf-print-document td { overflow-wrap: anywhere; }
.pdf-print-document a { color: inherit; text-decoration: underline; }
.pdf-print-document .mermaid-toolbar,
.pdf-print-document .copy-code-btn,
.pdf-print-document .sr-only,
.pdf-print-document [aria-hidden="true"] { display: none !important; }
.pdf-print-document .pdf-asset-error {
min-height: 2rem;
outline: 1px dashed #cf222e;
}
`;
}

function nextFrame(win) {
return new Promise(resolve => win.requestAnimationFrame(() => resolve()));
}

function withTimeout(promise, timeoutMs, message) {
let timer;
return Promise.race([
promise,
new Promise((_, reject) => {
timer = setTimeout(() => reject(new Error(message)), timeoutMs);
})
]).finally(() => clearTimeout(timer));
}

async function waitForImages(container, options) {
const settings = Object.assign({}, DEFAULT_OPTIONS, options);
const signal = settings.signal;
const images = Array.from(container.querySelectorAll("img"));
const failures = [];

await Promise.all(images.map(async image => {
throwIfAborted(signal);
if (image.complete && image.naturalWidth > 0) return;
try {
if (typeof image.decode === "function") {
await withTimeout(image.decode(), settings.imageTimeoutMs, `Image timed out: ${image.currentSrc || image.src}`);
} else {
await withTimeout(new Promise((resolve, reject) => {
image.addEventListener("load", resolve, { once: true });
image.addEventListener("error", reject, { once: true });
}), settings.imageTimeoutMs, `Image timed out: ${image.currentSrc || image.src}`);
}
} catch (error) {
image.classList.add("pdf-asset-error");
failures.push({ src: image.currentSrc || image.src || "", message: error.message || "Image failed to load" });
}
}));

throwIfAborted(signal);
return failures;
}

async function waitForStableLayout(element, options) {
const settings = Object.assign({}, DEFAULT_OPTIONS, options);
const signal = settings.signal;
const win = element.ownerDocument.defaultView;
const startedAt = Date.now();
let previous = null;
let stableFrames = 0;

while (Date.now() - startedAt < settings.layoutTimeoutMs) {
throwIfAborted(signal);
await nextFrame(win);
const current = `${element.scrollWidth}:${element.scrollHeight}`;
stableFrames = current === previous ? stableFrames + 1 : 0;
if (stableFrames >= 2) return;
previous = current;
}
}

function markAtomicBlocks(container, printableHeightPx) {
const limit = Number.isFinite(printableHeightPx) ? printableHeightPx : 934;
container.querySelectorAll("pre, blockquote, figure, .mermaid-container").forEach(element => {
if (element.getBoundingClientRect().height <= limit) element.classList.add("pdf-keep-together");
});
}

function collectStyleMarkup(doc) {
return Array.from(doc.querySelectorAll('link[rel="stylesheet"], style'))
.map(node => node.outerHTML)
.join("\n");
}

function buildPrintHtml(config) {
const options = Object.assign({}, DEFAULT_OPTIONS, config.options);
const theme = config.theme === "dark" ? "dark" : "light";
return `<!doctype html>
<html lang="${escapeHtml(config.lang || "en")}" data-theme="${theme}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<base href="${escapeHtml(config.baseUrl)}">
<title>${escapeHtml(config.title || "document")}</title>
${config.styleMarkup || ""}
<style id="pdf-print-layout">${createPrintCss(options)}</style>
</head>
<body><main class="markdown-body pdf-print-document">${config.contentHtml}</main></body>
</html>`;
}

function createPrintFrame(doc) {
const frame = doc.createElement("iframe");
frame.setAttribute("title", "PDF print document");
frame.setAttribute("aria-hidden", "true");
frame.style.position = "fixed";
frame.style.right = "0";
frame.style.bottom = "0";
frame.style.width = "1px";
frame.style.height = "1px";
frame.style.border = "0";
frame.style.opacity = "0";
frame.style.pointerEvents = "none";
return frame;
}

async function loadPrintFrame(frame, html, signal) {
throwIfAborted(signal);
const loaded = new Promise((resolve, reject) => {
frame.addEventListener("load", resolve, { once: true });
frame.addEventListener("error", () => reject(new Error("Unable to prepare the print document.")), { once: true });
});
frame.srcdoc = html;
await loaded;
throwIfAborted(signal);
if (frame.contentDocument && frame.contentDocument.fonts && frame.contentDocument.fonts.ready) {
await frame.contentDocument.fonts.ready;
}
await nextFrame(frame.contentWindow);
await nextFrame(frame.contentWindow);
}

function printFrame(frame, options) {
const settings = Object.assign({}, DEFAULT_OPTIONS, options);
const win = frame.contentWindow;
if (!win || typeof win.print !== "function") throw new Error("Printing is not supported in this environment.");

return new Promise(resolve => {
let settled = false;
let fallbackTimer = null;
const finish = () => {
if (settled) return;
settled = true;
if (fallbackTimer) clearTimeout(fallbackTimer);
win.removeEventListener("afterprint", finish);
resolve();
};
win.addEventListener("afterprint", finish, { once: true });
fallbackTimer = setTimeout(finish, settings.cleanupTimeoutMs);
win.focus();
win.print();
setTimeout(finish, 0);
});
}

async function exportElement(config) {
if (!config || !config.element || !config.element.ownerDocument) {
throw new TypeError("A rendered export element is required.");
}
const element = config.element;
const doc = element.ownerDocument;
const signal = config.signal;
const progress = typeof config.onProgress === "function" ? config.onProgress : function () {};
let frame = null;

try {
throwIfAborted(signal);
progress(55, "Loading images");
const imageFailures = await waitForImages(element, { signal, imageTimeoutMs: config.imageTimeoutMs });
progress(65, "Finalizing layout");
if (doc.fonts && doc.fonts.ready) await doc.fonts.ready;
await waitForStableLayout(element, { signal, layoutTimeoutMs: config.layoutTimeoutMs });
markAtomicBlocks(element, config.printableHeightPx);

progress(75, "Preparing print document");
frame = createPrintFrame(doc);
doc.body.appendChild(frame);
const html = buildPrintHtml({
title: config.title,
lang: doc.documentElement.lang,
theme: config.theme,
baseUrl: doc.baseURI,
styleMarkup: collectStyleMarkup(doc),
contentHtml: element.innerHTML,
options: config.options
});
await loadPrintFrame(frame, html, signal);
const frameImageFailures = await waitForImages(frame.contentDocument.body, {
signal,
imageTimeoutMs: config.imageTimeoutMs
});
imageFailures.push(...frameImageFailures);
await waitForStableLayout(frame.contentDocument.body, {
signal,
layoutTimeoutMs: config.layoutTimeoutMs
});

progress(90, "Opening print dialog");
await printFrame(frame, config.options);
progress(100, "Ready to save");
return { imageFailures };
} finally {
if (frame && frame.parentNode) frame.parentNode.removeChild(frame);
}
}

return Object.freeze({
DEFAULT_OPTIONS,
PdfExportCancelledError,
buildPrintHtml,
createPrintCss,
exportElement,
markAtomicBlocks,
normalizeCssLength,
normalizePageSize,
waitForImages,
waitForStableLayout
});
});
Loading
Loading