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
160 changes: 160 additions & 0 deletions desktop-app/extensions/pdf-exporter/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
const http = require("http");
const fs = require("fs");
const path = require("path");
const { execFile } = require("child_process");

const PORT_FILE = path.join(__dirname, "..", "..", ".pdf_exporter_port");

// Helper to find Chrome or Edge installation path
function findChromeOrEdge() {
const platform = process.platform;
let paths = [];

if (platform === "win32") {
const localAppData = process.env.LOCALAPPDATA || "";
const programFiles = process.env.PROGRAMFILES || "C:\\Program Files";
const programFilesX86 = process.env["PROGRAMFILES(X86)"] || "C:\\Program Files (x86)";

paths = [
path.join(programFiles, "Google", "Chrome", "Application", "chrome.exe"),
path.join(programFilesX86, "Google", "Chrome", "Application", "chrome.exe"),
path.join(localAppData, "Google", "Chrome", "Application", "chrome.exe"),
path.join(programFilesX86, "Microsoft", "Edge", "Application", "msedge.exe"),
path.join(programFiles, "Microsoft", "Edge", "Application", "msedge.exe"),
];
} else if (platform === "darwin") {
paths = [
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
];
} else {
// Linux
paths = [
"/usr/bin/google-chrome",
"/usr/bin/chrome",
"/usr/bin/chromium",
"/usr/bin/chromium-browser",
];
}

for (const p of paths) {
if (fs.existsSync(p)) {
return p;
}
}
return null;
}

// Write the temp HTML file, execute Chrome/Edge to generate PDF, and delete temp HTML
function generatePdf(html, outputPath, callback) {
const chromePath = findChromeOrEdge();
if (!chromePath) {
return callback(new Error("Chromium-compatible browser (Chrome or Edge) not found. Please install Chrome or Edge."));
}

const tempHtmlPath = path.join(__dirname, `temp_export_${Date.now()}.html`);

// Write html content to temp file
fs.writeFile(tempHtmlPath, html, "utf8", (err) => {
if (err) return callback(err);

const args = [
"--headless",
"--disable-gpu",
"--no-pdf-header-footer",
`--print-to-pdf=${outputPath}`,
tempHtmlPath
];

execFile(chromePath, args, (execErr, stdout, stderr) => {
// Clean up temp HTML file
fs.unlink(tempHtmlPath, () => {});

if (execErr) {
return callback(new Error(`Browser execution failed: ${execErr.message}\nStderr: ${stderr}`));
}
callback(null);
});
});
}

// Start HTTP server on a random port
const server = http.createServer((req, res) => {
// Set CORS headers
res.setHeader("Access-Control-Allow-Origin", "*");
res.setHeader("Access-Control-Allow-Methods", "POST, OPTIONS");
res.setHeader("Access-Control-Allow-Headers", "Content-Type");

if (req.method === "OPTIONS") {
res.writeHead(204);
res.end();
return;
}

if (req.method === "POST" && req.url === "/export") {
let body = "";
req.on("data", chunk => {
body += chunk.toString();
});

req.on("end", () => {
try {
const payload = JSON.parse(body);
const { html, outputPath } = payload;

if (!html || !outputPath) {
res.writeHead(400, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "Missing required fields: html and outputPath" }));
return;
}

generatePdf(html, outputPath, (err) => {
if (err) {
console.error("PDF generation error:", err);
res.writeHead(500, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: err.message }));
} else {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ success: true }));
}
});
} catch (parseErr) {
res.writeHead(400, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "Invalid JSON payload" }));
}
});
} else {
res.writeHead(444);
res.end();
}
});

// Bind to random port
server.listen(0, "127.0.0.1", () => {
const address = server.address();
const port = address.port;
console.log(`PDF Exporter Extension listening on port ${port}`);

// Write port to file
try {
fs.writeFileSync(PORT_FILE, String(port), "utf8");
console.log(`Port written to ${PORT_FILE}`);
} catch (err) {
console.error(`Failed to write port file: ${err.message}`);
}
});

// Clean up port file on exit
function cleanup() {
try {
if (fs.existsSync(PORT_FILE)) {
fs.unlinkSync(PORT_FILE);
console.log("Port file cleaned up.");
}
} catch (e) {}
process.exit();
}

process.on("SIGINT", cleanup);
process.on("SIGTERM", cleanup);
process.on("exit", cleanup);
9 changes: 8 additions & 1 deletion desktop-app/neutralino.config.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,14 @@
"os.open",
"os.setTray",
"filesystem.readFile",
"filesystem.writeFile"
"filesystem.writeFile",
"extensions.*"
],
"extensions": [
{
"id": "com.markdownviewer.pdfexporter",
"command": "node extensions/pdf-exporter/index.js"
}
],
"globalVariables": {},
"modes": {
Expand Down
39 changes: 39 additions & 0 deletions desktop-app/resources/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -750,6 +750,45 @@ <h3 class="modal-section-title">Open-source credits</h3>
</div>
</div>

<!-- PDF Export Modal -->
<div id="pdf-export-modal" class="reset-modal-overlay modal-overlay" role="dialog" aria-modal="true" aria-labelledby="pdf-export-modal-title" aria-hidden="true" style="display:none;">
<div class="reset-modal-box reset-modal-box--wide modal-box">
<div class="modal-header">
<p id="pdf-export-modal-title" class="reset-modal-message">PDF Export Settings</p>
<button type="button" class="modal-close-btn" id="pdf-export-modal-close-icon" aria-label="Close export dialog">
<i class="bi bi-x-lg"></i>
</button>
</div>
<div class="modal-body">
<p class="share-modal-description">Select your preferred PDF export engine:</p>
<div class="share-mode-cards">
<label class="share-mode-card is-selected" id="pdf-card-print" for="pdf-engine-print">
<input type="radio" id="pdf-engine-print" name="pdf-engine" value="print" checked />
<span class="share-card-icon"><i class="bi bi-printer"></i></span>
<span class="share-card-body">
<span class="share-card-title">Browser Print (Recommended)</span>
<span class="share-card-desc">Searchable vector text, hyperlinks, vector SVGs, and browser-native pagination.</span>
</span>
<span class="share-card-check"><i class="bi bi-check-lg"></i></span>
</label>
<label class="share-mode-card" id="pdf-card-legacy" for="pdf-engine-legacy">
<input type="radio" id="pdf-engine-legacy" name="pdf-engine" value="legacy" />
<span class="share-card-icon"><i class="bi bi-image"></i></span>
<span class="share-card-body">
<span class="share-card-title">Legacy Raster PDF</span>
<span class="share-card-desc">Slices screenshots of the rendered page. Recommended only for offline/compatibility fallback.</span>
</span>
<span class="share-card-check"><i class="bi bi-check-lg"></i></span>
</label>
</div>
</div>
<div class="reset-modal-actions">
<button class="reset-modal-btn reset-modal-cancel" id="pdf-export-modal-close">Cancel</button>
<button class="reset-modal-btn reset-modal-confirm" id="pdf-export-modal-confirm">Export</button>
</div>
</div>
</div>

<!-- GitHub Import Modal -->
<div id="github-import-modal" class="reset-modal-overlay" role="dialog" aria-modal="true" aria-labelledby="github-import-title" aria-hidden="true" style="display:none;">
<div class="reset-modal-box">
Expand Down
36 changes: 35 additions & 1 deletion desktop-app/resources/js/preview-worker.js
Original file line number Diff line number Diff line change
Expand Up @@ -343,7 +343,13 @@ function configureMarked() {

function ensureLibraries(urls) {
if (!librariesLoaded) {
importScripts(urls.marked, urls.highlight);
const scripts = [];
if (urls.marked) scripts.push(urls.marked);
if (urls.highlight) scripts.push(urls.highlight);
if (urls.purify) scripts.push(urls.purify);
if (scripts.length > 0) {
importScripts(...scripts);
}
librariesLoaded = true;
}
configureMarked();
Expand Down Expand Up @@ -461,6 +467,34 @@ function renderSegmentedMarkdown(markdown, options) {

self.onmessage = function(event) {
const data = event.data || {};
if (data.type === "render-full") {
try {
const options = data.options || {};
ensureLibraries(options.libraryUrls || {});
mermaidIdCounter = 0;
const html = marked.parse(data.markdown || "");
let sanitized = html;
if (typeof DOMPurify !== "undefined") {
sanitized = 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']
});
}
self.postMessage({
type: "render-full-result",
requestId: data.requestId,
html: sanitized
});
} catch (error) {
self.postMessage({
type: "render-full-error",
requestId: data.requestId,
error: error && error.message ? error.message : "Full worker render failed."
});
}
return;
}

if (data.type !== "render") return;

try {
Expand Down
Loading
Loading