Choose how the exported image should be styled.
diff --git a/script.js b/script.js
index e57b02ad..cd37af7b 100644
--- a/script.js
+++ b/script.js
@@ -2039,6 +2039,34 @@ document.addEventListener("DOMContentLoaded", async function () {
}
}
+ async function clearLegacyWorkspaceStateForReset() {
+ const resetKeys = new Set(DOCUMENT_STORAGE_KEYS);
+ const storageAreas = [localStorage, sessionStorage];
+ storageAreas.forEach(function(storage) {
+ try {
+ for (let index = storage.length - 1; index >= 0; index -= 1) {
+ const key = storage.key(index);
+ if (!key) continue;
+ if (
+ resetKeys.has(key) ||
+ key.startsWith(DIRTY_DOCUMENT_JOURNAL_PREFIX) ||
+ key.startsWith(SECRET_DIRTY_DOCUMENT_JOURNAL_PREFIX)
+ ) {
+ storage.removeItem(key);
+ }
+ }
+ } catch (_) {}
+ });
+
+ if (!isNeutralinoRuntimeAvailable()) return;
+ for (const key of resetKeys) {
+ try {
+ if (Neutralino.storage.removeData) await Neutralino.storage.removeData(key);
+ else await Neutralino.storage.setData(key, '');
+ } catch (_) {}
+ }
+ }
+
async function importWorkspaceBackup(input) {
closeStorageSettings();
showImportProgress(100, {
@@ -2189,9 +2217,9 @@ document.addEventListener("DOMContentLoaded", async function () {
let _lastMermaidTheme = null;
let _mermaidThemeReinitTimeout = null;
let _themeTransitionTimeout = null;
- const initMermaid = (forceReinit) => {
+ const initMermaid = (forceReinit, themeOverride) => {
if (typeof mermaid === 'undefined') return; // PERF-002: Not loaded yet
- const currentTheme = document.documentElement.getAttribute("data-theme");
+ const currentTheme = themeOverride || document.documentElement.getAttribute("data-theme");
const mermaidTheme = currentTheme === "dark" ? "dark" : "default";
// Skip re-initialization if theme hasn't changed (PERF-005)
@@ -3683,7 +3711,7 @@ document.addEventListener("DOMContentLoaded", async function () {
return { markmap, root: transformed.root, options };
}
- async function renderMarkmapIntoElement(node, source, compact) {
+ async function renderMarkmapIntoElement(node, source, compact, exportCapture) {
const { markmap, root, options } = await buildMarkmap(source);
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
const { width, height } = getMarkmapViewportSize(node, root, compact);
@@ -3704,16 +3732,17 @@ document.addEventListener("DOMContentLoaded", async function () {
svg.style.maxHeight = 'min(70vh, 900px)';
}
node.replaceChildren(svg);
+ const shouldFreezeLayout = compact || exportCapture;
const markmapOptions = Object.assign({
zoom: false,
pan: false,
autoFit: false
- }, options, compact ? { duration: 0 } : {});
+ }, options, shouldFreezeLayout ? { duration: 0 } : {});
// Avoid Markmap.create() which fires setData() in a detached promise chain.
// Instead, construct manually and await setData() so rendering is guaranteed
// to complete before we measure bounds or serialize the SVG.
const instance = new markmap.Markmap(svg, markmapOptions);
- if (compact) {
+ if (shouldFreezeLayout) {
// Override D3 transitions to apply attributes synchronously
instance.transition = (sel) => sel;
}
@@ -3729,15 +3758,27 @@ document.addEventListener("DOMContentLoaded", async function () {
const rect = instance.state.rect;
const treeWidth = rect.x2 - rect.x1;
const treeHeight = rect.y2 - rect.y1;
- if (compact) {
+ if (shouldFreezeLayout) {
const pad = 16;
- svg.setAttribute('viewBox', `${rect.x1 - pad} ${rect.y1 - pad} ${treeWidth + pad * 2} ${treeHeight + pad * 2}`);
- svg.setAttribute('width', String(treeWidth + pad * 2));
- svg.setAttribute('height', String(treeHeight + pad * 2));
+ const fittedWidth = Math.max(1, treeWidth + pad * 2);
+ const fittedHeight = Math.max(1, treeHeight + pad * 2);
+ svg.setAttribute('viewBox', `${rect.x1 - pad} ${rect.y1 - pad} ${fittedWidth} ${fittedHeight}`);
+ svg.setAttribute('width', String(fittedWidth));
+ svg.setAttribute('height', String(fittedHeight));
svg.style.width = '100%';
- svg.style.height = '100%';
- svg.style.minHeight = '0';
- svg.style.maxHeight = '100%';
+ if (compact) {
+ svg.style.height = '100%';
+ svg.style.minHeight = '0';
+ svg.style.maxHeight = '100%';
+ } else {
+ const fittedDisplayHeight = Math.max(220, Math.min(620, Math.round(width * fittedHeight / fittedWidth)));
+ node.style.setProperty('--markmap-height', `${fittedDisplayHeight}px`);
+ svg.style.setProperty('--markmap-height', `${fittedDisplayHeight}px`);
+ svg.style.height = `${fittedDisplayHeight}px`;
+ svg.style.minHeight = `${fittedDisplayHeight}px`;
+ svg.style.maxHeight = 'none';
+ svg.dataset.exportFitted = 'true';
+ }
} else {
const computedHeight = Math.max(200, Math.ceil(treeHeight) + 40);
svg.setAttribute('height', String(computedHeight));
@@ -3747,13 +3788,15 @@ document.addEventListener("DOMContentLoaded", async function () {
svg.style.minHeight = `${computedHeight}px`;
}
}
- fitMarkmap();
- setTimeout(fitMarkmap, 120);
- setTimeout(fitMarkmap, 500);
- svg.querySelectorAll('image, img').forEach(image => {
- image.addEventListener('load', fitMarkmap, { once: true });
- image.addEventListener('error', fitMarkmap, { once: true });
- });
+ if (!exportCapture) {
+ fitMarkmap();
+ setTimeout(fitMarkmap, 120);
+ setTimeout(fitMarkmap, 500);
+ svg.querySelectorAll('image, img').forEach(image => {
+ image.addEventListener('load', fitMarkmap, { once: true });
+ image.addEventListener('error', fitMarkmap, { once: true });
+ });
+ }
normalizeDiagramSvg(node, 'markmap', source);
return svg;
}
@@ -3967,7 +4010,7 @@ document.addEventListener("DOMContentLoaded", async function () {
setDiagramRenderState(container, 'loading', `Rendering ${REMOTE_DIAGRAM_ENGINES[engine].label}…`);
try {
if (engine === 'markmap') {
- await renderMarkmapIntoElement(node, source, false);
+ await renderMarkmapIntoElement(node, source, false, Boolean(context && context.exportCapture));
if (!isPreviewRenderContextCurrent(context) || !document.body.contains(node)) return;
setDiagramRenderState(container, 'ready');
mountDiagramViewer(container, engine);
@@ -4001,25 +4044,26 @@ document.addEventListener("DOMContentLoaded", async function () {
['.kroki-diagram', null]
];
const renderAdapterTargets = function() {
- if (!isPreviewRenderContextCurrent(context)) return;
+ if (!isPreviewRenderContextCurrent(context)) return Promise.resolve([]);
+ const renderPromises = [];
adapterTargets.forEach(([selector, fixedEngine]) => {
queryPreviewRoots(roots, selector).forEach(node => {
const engine = fixedEngine || node.closest('[data-diagram-engine]')?.dataset.diagramEngine;
if (engine && REMOTE_DIAGRAM_ENGINES[engine]) {
- renderRemoteDiagramNode(node, engine, context);
+ renderPromises.push(renderRemoteDiagramNode(node, engine, context));
}
});
});
+ return Promise.all(renderPromises);
};
if (typeof pako === 'undefined') {
- loadDiagramLibrary(CDN.pako).then(renderAdapterTargets).catch(error => {
+ return loadDiagramLibrary(CDN.pako).then(renderAdapterTargets).catch(error => {
console.warn('Failed to load diagram encoder; POST fallbacks remain available', error);
- renderAdapterTargets();
+ return renderAdapterTargets();
});
- } else {
- renderAdapterTargets();
}
+ return renderAdapterTargets();
}
function typesetMathJaxTargets(mathTargets, context) {
@@ -12528,10 +12572,13 @@ document.addEventListener("DOMContentLoaded", async function () {
try {
await Promise.all([
workspacePersistenceChain.catch(function() {}),
- secretWorkspaceSaveChain.catch(function() {})
+ secretWorkspaceSaveChain.catch(function() {}),
+ secretDirtyJournalChain.catch(function() {}),
+ organizationPersistenceChain.catch(function() {})
]);
updateImportProgress(25, 100, 'Clearing workspace settings…');
await replaceApplicationPreferences({});
+ await clearLegacyWorkspaceStateForReset();
updateImportProgress(55, 100, 'Permanently deleting workspace files…');
resetStarted = true;
await workspaceStorage.resetAllData();
@@ -13327,7 +13374,7 @@ document.addEventListener("DOMContentLoaded", async function () {
const renderLoadedNodes = function(forceInit) {
if (!isPreviewRenderContextCurrent(context)) return Promise.resolve([]);
- initMermaid(forceInit);
+ initMermaid(forceInit, context && context.theme);
return renderMermaidNodeList(nodes, context, toolbarRoot)
.catch(function(error) {
if (!isPreviewRenderContextCurrent(context)) return [];
@@ -13436,22 +13483,21 @@ document.addEventListener("DOMContentLoaded", async function () {
function renderAbcNodes(roots, context) {
const abcNodes = queryPreviewRoots(roots, '.abc-notation');
- if (abcNodes.length === 0) return;
+ if (abcNodes.length === 0) return Promise.resolve([]);
const renderNodes = function() {
- if (!isPreviewRenderContextCurrent(context)) return;
+ if (!isPreviewRenderContextCurrent(context)) return [];
abcNodes.forEach(function(node) {
- setTimeout(function() {
- renderAbcNotationNode(node, context);
- }, 0);
+ renderAbcNotationNode(node, context);
});
+ return abcNodes;
};
const loadAndRender = function() {
- loadDiagramLibrary(CDN.abcjs).then(function() {
- if (!isPreviewRenderContextCurrent(context)) return;
- renderNodes();
+ return loadDiagramLibrary(CDN.abcjs).then(function() {
+ if (!isPreviewRenderContextCurrent(context)) return [];
+ return renderNodes();
}).catch(function(error) {
- if (!isPreviewRenderContextCurrent(context)) return;
+ if (!isPreviewRenderContextCurrent(context)) return [];
console.warn('Failed to load abcjs:', error);
abcNodes.forEach(function(node) {
const container = node.closest('.abc-container');
@@ -13464,14 +13510,14 @@ document.addEventListener("DOMContentLoaded", async function () {
);
}
});
+ return [];
});
};
if (typeof ABCJS === 'undefined') {
- loadAndRender();
- } else {
- renderNodes();
+ return loadAndRender();
}
+ return Promise.resolve(renderNodes());
}
function disposeStlView(viewId) {
@@ -13493,7 +13539,7 @@ document.addEventListener("DOMContentLoaded", async function () {
if (view.controls) {
view.controls.dispose();
}
- if (view.scene) {
+ if (view.scene && typeof view.scene.traverse === 'function') {
view.scene.traverse(node => {
if (node.geometry) {
node.geometry.dispose();
@@ -13598,7 +13644,7 @@ document.addEventListener("DOMContentLoaded", async function () {
node._leafletMap = map;
observeMapNodeSize(node, map);
- const currentTheme = document.documentElement.getAttribute("data-theme") || 'light';
+ const currentTheme = (context && context.theme) || document.documentElement.getAttribute("data-theme") || 'light';
let tileUrl;
let tileAttribution = '©
OpenStreetMap contributors';
@@ -13612,7 +13658,8 @@ document.addEventListener("DOMContentLoaded", async function () {
L.tileLayer(tileUrl, {
attribution: tileAttribution,
- maxZoom: 19
+ maxZoom: 19,
+ crossOrigin: context && context.exportCapture ? true : false
}).addTo(map);
const geojsonLayer = L.geoJSON(geojsonData, {
@@ -13656,12 +13703,13 @@ document.addEventListener("DOMContentLoaded", async function () {
function renderMapNodes(roots, context) {
const geojsonNodes = queryPreviewRoots(roots, '.geojson-map');
const topojsonNodes = queryPreviewRoots(roots, '.topojson-map');
- if (geojsonNodes.length === 0 && topojsonNodes.length === 0) return;
+ if (geojsonNodes.length === 0 && topojsonNodes.length === 0) return Promise.resolve([]);
const renderAll = function() {
- if (!isPreviewRenderContextCurrent(context)) return;
+ if (!isPreviewRenderContextCurrent(context)) return [];
geojsonNodes.forEach(function(node) { renderMapNode(node, false, context); });
topojsonNodes.forEach(function(node) { renderMapNode(node, true, context); });
+ return geojsonNodes.concat(topojsonNodes);
};
const loadPromises = [];
if (typeof L === 'undefined') {
@@ -13677,18 +13725,18 @@ document.addEventListener("DOMContentLoaded", async function () {
}
if (loadPromises.length === 0) {
- renderAll();
- return;
+ return Promise.resolve(renderAll());
}
- Promise.all(loadPromises).then(function() {
- renderAll();
+ return Promise.all(loadPromises).then(function() {
+ return renderAll();
}).catch(function(error) {
- if (!isPreviewRenderContextCurrent(context)) return;
+ if (!isPreviewRenderContextCurrent(context)) return [];
console.warn('Failed to load map libraries:', error);
geojsonNodes.concat(topojsonNodes).forEach(function(node) {
const container = node.closest('.geojson-container') || node.closest('.topojson-container');
if (container) container.classList.remove('is-loading');
});
+ return [];
});
}
@@ -13723,7 +13771,7 @@ document.addEventListener("DOMContentLoaded", async function () {
}
}
- function renderStlInContainer(container, code, viewId) {
+ function renderStlInContainer(container, code, viewId, themeOverride) {
validateStlSource(code);
const width = container.clientWidth || 400;
const height = container.clientHeight || 400;
@@ -13787,7 +13835,7 @@ document.addEventListener("DOMContentLoaded", async function () {
const maxDim = Math.max(size.x, size.y, size.z);
// Add grid helper (underneath the model, matching the theme)
- const currentTheme = document.documentElement.getAttribute("data-theme") || 'light';
+ const currentTheme = themeOverride || document.documentElement.getAttribute("data-theme") || 'light';
const gridColorCenter = currentTheme === 'dark' ? 0x888888 : 0xaaaaaa;
const gridColor = currentTheme === 'dark' ? 0x333742 : 0xcccccc;
@@ -14061,7 +14109,7 @@ document.addEventListener("DOMContentLoaded", async function () {
try {
node.innerHTML = '';
- const view = renderStlInContainer(node, decodedCode, nodeId);
+ const view = renderStlInContainer(node, decodedCode, nodeId, context && context.theme);
if (container) container.classList.remove('is-loading');
@@ -14076,11 +14124,12 @@ document.addEventListener("DOMContentLoaded", async function () {
function renderStlNodes(roots, context) {
const stlNodes = queryPreviewRoots(roots, '.stl-viewer');
- if (stlNodes.length === 0) return;
+ if (stlNodes.length === 0) return Promise.resolve([]);
const renderAll = function() {
- if (!isPreviewRenderContextCurrent(context)) return;
+ if (!isPreviewRenderContextCurrent(context)) return [];
stlNodes.forEach(function(node) { renderStlNode(node, context); });
+ return stlNodes;
};
const loadLoaderAndControls = function() {
if (!isPreviewRenderContextCurrent(context)) return Promise.resolve();
@@ -14097,18 +14146,19 @@ document.addEventListener("DOMContentLoaded", async function () {
? loadScript(CDN.three)
: Promise.resolve();
- threeReady.then(function() {
+ return threeReady.then(function() {
if (!isPreviewRenderContextCurrent(context)) return null;
return loadLoaderAndControls();
}).then(function() {
- renderAll();
+ return renderAll();
}).catch(function(error) {
- if (!isPreviewRenderContextCurrent(context)) return;
+ if (!isPreviewRenderContextCurrent(context)) return [];
console.warn('Failed to load Three.js libraries:', error);
stlNodes.forEach(function(node) {
const container = node.closest('.stl-container');
if (container) container.classList.remove('is-loading');
});
+ return [];
});
}
@@ -14346,23 +14396,22 @@ ${selector} .arrowheadPath {
async function prepareBrowserPrintExport(exportTheme = 'light') {
const root = document.documentElement;
- const previousTheme = root.getAttribute('data-theme') || initialTheme || 'light';
const previousPrintExport = root.getAttribute('data-browser-print-export');
- const targetTheme = exportTheme === 'dark' ? 'dark' : 'light';
- const shouldChangeTheme = previousTheme !== targetTheme;
-
- root.setAttribute('data-browser-print-export', targetTheme);
- if (shouldChangeTheme) {
- root.setAttribute('data-theme', targetTheme);
- }
-
- await renderThemeSensitivePreviewContentForPrint();
+ const targetTheme = getExportTheme(exportTheme);
+ let printSnapshot = null;
- if (document.fonts && document.fonts.ready) {
- await withBrowserPrintTimeout(document.fonts.ready.catch(() => null), 1500);
+ try {
+ printSnapshot = await createExportRenderElement(markdownEditor.value, targetTheme, 'print');
+ printSnapshot.classList.add('browser-print-export-snapshot');
+ printSnapshot.setAttribute('aria-hidden', 'true');
+ root.setAttribute('data-browser-print-export', targetTheme);
+ await waitForBrowserPrintFrame();
+ } catch (error) {
+ if (printSnapshot) disposeExportRenderElement(printSnapshot);
+ if (previousPrintExport === null) root.removeAttribute('data-browser-print-export');
+ else root.setAttribute('data-browser-print-export', previousPrintExport);
+ throw error;
}
- await withBrowserPrintTimeout(waitForAllImages(markdownPreview), 2500);
- await waitForBrowserPrintFrame();
return function restoreBrowserPrintExport() {
if (previousPrintExport === null) {
@@ -14370,13 +14419,7 @@ ${selector} .arrowheadPath {
} else {
root.setAttribute('data-browser-print-export', previousPrintExport);
}
-
- if (shouldChangeTheme) {
- root.setAttribute('data-theme', previousTheme);
- renderThemeSensitivePreviewContentForPrint().catch(function(error) {
- console.warn('Preview theme restoration after print failed:', error);
- });
- }
+ disposeExportRenderElement(printSnapshot);
};
}
@@ -24164,7 +24207,7 @@ ${selector} .arrowheadPath {
window.auditedTempElement = state.tempElement;
console.log("Skipped tempElement removal for audit");
} else {
- state.tempElement.parentNode.removeChild(state.tempElement);
+ disposeExportRenderElement(state.tempElement);
}
}
if (state.overlay && state.overlay.parentNode) {
@@ -24986,6 +25029,119 @@ ${selector} .arrowheadPath {
return Promise.all(promises);
}
+ function getExportTheme(theme) {
+ return theme === 'dark' ? 'dark' : 'light';
+ }
+
+ function buildExportHtml(markdown) {
+ const { frontmatter, body } = parseFrontmatter(markdown);
+ const tableHtml = frontmatter ? renderFrontmatterTable(frontmatter) : '';
+ const referenceData = extractReferenceDefinitions(body);
+ const html = tableHtml + marked.parse(referenceData.cleanedMarkdown);
+ return {
+ html: DOMPurify.sanitize(html, {
+ ADD_TAGS: ['mjx-container', 'svg', 'path', 'g', 'marker', 'defs', 'pattern', 'clipPath', 'input', 'video', 'source'],
+ ADD_ATTR: ['id', 'class', 'style', 'align', 'viewBox', 'd', 'fill', 'stroke', 'transform', 'marker-end', 'marker-start', 'type', 'checked', 'disabled', 'data-original-code', 'aria-label', 'controls', 'preload', 'playsinline'],
+ ALLOWED_URI_REGEXP: SAFE_MARKDOWN_URI_REGEXP
+ }),
+ referenceData
+ };
+ }
+
+ function removeExportOnlyControls(root) {
+ root.querySelectorAll(
+ '.diagram-status, .diagram-toolbar, .mermaid-toolbar, .abc-toolbar, ' +
+ '.plantuml-toolbar, .d2-toolbar, .graphviz-toolbar, .stl-toolbar, .review-target-actions'
+ ).forEach(element => element.remove());
+ root.querySelectorAll('.is-loading').forEach(element => element.classList.remove('is-loading'));
+ }
+
+ function disposeExportRenderElement(root) {
+ if (!root) return;
+ root.querySelectorAll('.geojson-map, .topojson-map').forEach(disposeMapNode);
+ root.querySelectorAll('.stl-viewer[id]').forEach(node => disposeStlView(node.id));
+ clearMathJaxPreviewState(root);
+ if (root.parentNode) root.parentNode.removeChild(root);
+ }
+
+ async function waitForExportWork(state, promise) {
+ return state ? runPdfAbortable(state, promise) : promise;
+ }
+
+ async function renderExportRichContent(root, markdown, exportTheme, state) {
+ const theme = getExportTheme(exportTheme);
+ const context = {
+ theme,
+ exportCapture: true,
+ isCurrent() {
+ return root.isConnected && !(state && state.signal.aborted);
+ }
+ };
+
+ const mermaidNodes = Array.from(root.querySelectorAll('.mermaid'));
+ if (mermaidNodes.length > 0) {
+ await waitForExportWork(state, renderMermaidNodes(mermaidNodes, context, root));
+ }
+
+ await waitForExportWork(state, Promise.all([
+ renderAbcNodes([root], context),
+ renderMapNodes([root], context),
+ renderStlNodes([root], context),
+ renderRemoteDiagramNodes([root], context)
+ ]));
+
+ if (markdownLikelyContainsMath(markdown)) {
+ await waitForExportWork(state, ensureMathJaxReady());
+ if (window.MathJax && typeof MathJax.typesetPromise === 'function') {
+ await waitForExportWork(state, MathJax.typesetPromise([root]));
+ }
+ }
+
+ root.querySelectorAll('mjx-assistive-mml, script[type*="math"], script[type*="tex"]').forEach(element => element.remove());
+ removeExportOnlyControls(root);
+
+ await waitForExportWork(state, Promise.all([
+ waitForAllImages(root),
+ document.fonts ? document.fonts.ready : Promise.resolve()
+ ]));
+ await waitForBrowserPrintFrame();
+ }
+
+ async function createExportRenderElement(markdown, exportTheme, mode, state) {
+ const theme = getExportTheme(exportTheme);
+ const { html, referenceData } = buildExportHtml(markdown);
+ const root = document.createElement('article');
+ root.className = `markdown-body pdf-export theme-${theme} export-render-root`;
+ root.setAttribute('data-theme', theme);
+ root.setAttribute('data-export-render-mode', mode);
+ root.innerHTML = html;
+ root.style.boxSizing = 'border-box';
+ root.style.position = 'fixed';
+ root.style.left = '-12000px';
+ root.style.top = '0';
+ root.style.margin = '0';
+ root.style.width = mode === 'pdf' ? '210mm' : '1000px';
+ root.style.padding = mode === 'image' ? '40px' : mode === 'print' ? '45px' : '0';
+ root.style.fontSize = mode === 'pdf' ? '14px' : '16px';
+ root.style.backgroundColor = theme === 'dark' ? '#0d1117' : '#ffffff';
+ root.style.color = theme === 'dark' ? '#c9d1d9' : '#24292e';
+ document.body.appendChild(root);
+ if (state) state.tempElement = root;
+
+ applyReferencePreviewLinks(root, referenceData.definitions);
+ enhanceGitHubAlerts(root);
+
+ try {
+ await renderExportRichContent(root, markdown, theme, state);
+ return root;
+ } catch (error) {
+ if (!state || !window.keepTempElementForAudit) {
+ disposeExportRenderElement(root);
+ }
+ throw error;
+ }
+ }
+
// ============================================
// End Oversized Graphics Scaling Functions
// ============================================
@@ -25093,220 +25249,14 @@ ${selector} .arrowheadPath {
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', 'video', 'source'],
- ADD_ATTR: ['id', 'class', 'style', 'align', 'viewBox', 'd', 'fill', 'stroke', 'transform', 'marker-end', 'marker-start', 'type', 'checked', 'disabled', 'data-original-code', 'aria-label', 'controls', 'preload', 'playsinline'],
- ALLOWED_URI_REGEXP: SAFE_MARKDOWN_URI_REGEXP
- });
throwIfPdfExportAborted(progressState.signal);
- updatePdfProgress(progressState, 24, "Preparing document");
+ updatePdfProgress(progressState, 24, "Rendering document content");
await waitForPdfFrame(progressState);
- const tempElement = document.createElement("div");
- progressState.tempElement = tempElement;
const isDarkTheme = selectedTheme === "dark";
- tempElement.className = "markdown-body pdf-export " + (isDarkTheme ? "theme-dark" : "theme-light");
- tempElement.setAttribute('data-theme', isDarkTheme ? "dark" : "light");
- tempElement.innerHTML = sanitizedHtml;
- enhanceGitHubAlerts(tempElement);
- tempElement.style.padding = "0px";
- tempElement.style.width = "210mm";
- tempElement.style.margin = "0 auto";
- tempElement.style.fontSize = "14px";
- tempElement.style.position = "fixed";
- tempElement.style.left = "-9999px";
- tempElement.style.top = "0";
-
- tempElement.style.backgroundColor = isDarkTheme ? "#0d1117" : "#ffffff";
- tempElement.style.color = isDarkTheme ? "#c9d1d9" : "#24292e";
-
- document.body.appendChild(tempElement);
+ const tempElement = await createExportRenderElement(markdown, selectedTheme, 'pdf', progressState);
await waitForPdfFrame(progressState);
- const mermaidNodes = tempElement.querySelectorAll('.mermaid');
- if (mermaidNodes.length > 0) {
- updatePdfProgress(progressState, 34, "Rendering diagrams");
- try {
- if (typeof mermaid === 'undefined') {
- await runPdfAbortable(progressState, loadScript(CDN.mermaid));
- }
- throwIfPdfExportAborted(progressState.signal);
-
- mermaidNodes.forEach(node => {
- const rawCode = node.getAttribute('data-original-code');
- if (rawCode) {
- try { node.textContent = decodeURIComponent(rawCode); }
- catch (_) { node.textContent = rawCode; }
- }
- node.removeAttribute('data-processed');
- });
-
- if (typeof mermaid !== 'undefined') {
- mermaid.initialize({
- startOnLoad: false,
- theme: isDarkTheme ? 'dark' : 'default',
- securityLevel: 'strict',
- flowchart: { useMaxWidth: true, htmlLabels: true },
- fontSize: 16,
- gantt: { useWidth: 1200 }
- });
- }
- await runPdfAbortable(progressState, mermaid.init(undefined, mermaidNodes));
- tempElement.querySelectorAll('.diagram-status, .diagram-toolbar, .mermaid-toolbar').forEach(el => el.remove());
- tempElement.querySelectorAll('.mermaid-container.is-loading, .diagram-viewer.is-loading').forEach(container => {
- container.classList.remove('is-loading');
- });
-
- // Convert all rendered Mermaid SVGs inside tempElement to
![]()
tags with data URI sources
- const compiledMermaids = tempElement.querySelectorAll('.mermaid-container, .diagram-viewer[data-diagram-engine="mermaid"]');
- compiledMermaids.forEach(container => {
- const svgElement = container.querySelector('svg');
- if (svgElement) {
- const rect = svgElement.getBoundingClientRect();
- const width = rect.width || svgElement.clientWidth || parseFloat(svgElement.getAttribute('width')) || 600;
- const height = rect.height || svgElement.clientHeight || parseFloat(svgElement.getAttribute('height')) || 400;
-
- const clonedSvg = svgElement.cloneNode(true);
- clonedSvg.setAttribute('width', width);
- clonedSvg.setAttribute('height', height);
- if (!clonedSvg.getAttribute('viewBox')) {
- clonedSvg.setAttribute('viewBox', `0 0 ${width} ${height}`);
- }
- clonedSvg.style.width = `${width}px`;
- clonedSvg.style.height = `${height}px`;
-
- const svgString = new XMLSerializer().serializeToString(clonedSvg);
- const svgBase64 = btoa(unescape(encodeURIComponent(svgString)));
-
- const img = document.createElement('img');
- img.className = 'mermaid-img';
- if (svgElement.id) img.id = svgElement.id + '-img';
- img.src = 'data:image/svg+xml;base64,' + svgBase64;
-
- img.style.width = `${width}px`;
- img.style.height = `${height}px`;
- img.style.maxWidth = '100%';
- img.style.display = 'block';
- img.style.margin = '0 auto';
-
- img.dataset.originalWidth = String(width);
- img.dataset.originalHeight = String(height);
-
- container.innerHTML = '';
- container.appendChild(img);
- }
- });
- } catch (mermaidError) {
- if (mermaidError instanceof PdfExportCancelledError) throw mermaidError;
- console.warn("Mermaid rendering issue:", mermaidError);
- tempElement.querySelectorAll('.mermaid-container.is-loading, .diagram-viewer.is-loading').forEach(container => {
- container.classList.remove('is-loading');
- });
- }
- tempElement.querySelectorAll('.diagram-status, .diagram-toolbar, .mermaid-toolbar').forEach(el => el.remove());
- throwIfPdfExportAborted(progressState.signal);
- await waitForPdfFrame(progressState);
- }
-
- const abcNodes = tempElement.querySelectorAll('.abc-notation');
- if (abcNodes.length > 0) {
- updatePdfProgress(progressState, 40, "Rendering music notation");
- try {
- if (typeof ABCJS === 'undefined') {
- await runPdfAbortable(progressState, loadScript(CDN.abcjs));
- }
- throwIfPdfExportAborted(progressState.signal);
-
- abcNodes.forEach(node => {
- const abcCode = decodeURIComponent(node.getAttribute('data-original-code') || '');
- if (abcCode) {
- ABCJS.renderAbc(node.id, abcCode, { responsive: 'resize' });
- }
- });
-
- tempElement.querySelectorAll('.abc-container.is-loading').forEach(container => {
- container.classList.remove('is-loading');
- });
-
- // Convert all rendered ABC SVGs inside tempElement to
![]()
tags with data URI sources
- const compiledAbcs = tempElement.querySelectorAll('.abc-container');
- compiledAbcs.forEach(container => {
- const svgElement = container.querySelector('svg');
- if (svgElement) {
- const rect = svgElement.getBoundingClientRect();
- const width = rect.width || svgElement.clientWidth || parseFloat(svgElement.getAttribute('width')) || 600;
- const height = rect.height || svgElement.clientHeight || parseFloat(svgElement.getAttribute('height')) || 400;
-
- const clonedSvg = svgElement.cloneNode(true);
- clonedSvg.setAttribute('width', width);
- clonedSvg.setAttribute('height', height);
- if (!clonedSvg.getAttribute('viewBox')) {
- clonedSvg.setAttribute('viewBox', `0 0 ${width} ${height}`);
- }
- clonedSvg.style.width = `${width}px`;
- clonedSvg.style.height = `${height}px`;
-
- const svgString = new XMLSerializer().serializeToString(clonedSvg);
- const svgBase64 = btoa(unescape(encodeURIComponent(svgString)));
-
- const img = document.createElement('img');
- img.className = 'abc-img';
- img.src = 'data:image/svg+xml;base64,' + svgBase64;
-
- img.style.width = `${width}px`;
- img.style.height = `${height}px`;
- img.style.maxWidth = '100%';
- img.style.display = 'block';
- img.style.margin = '0 auto';
-
- img.dataset.originalWidth = String(width);
- img.dataset.originalHeight = String(height);
-
- container.innerHTML = '';
- container.appendChild(img);
- }
- });
- } catch (abcError) {
- if (abcError instanceof PdfExportCancelledError) throw abcError;
- console.warn("ABC rendering issue:", abcError);
- tempElement.querySelectorAll('.abc-container.is-loading').forEach(container => {
- container.classList.remove('is-loading');
- });
- }
- throwIfPdfExportAborted(progressState.signal);
- await waitForPdfFrame(progressState);
- }
-
- if (window.MathJax && markdownLikelyContainsMath(markdown)) {
- updatePdfProgress(progressState, 44, "Rendering math");
- try {
- await runPdfAbortable(progressState, MathJax.typesetPromise([tempElement]));
- } catch (mathJaxError) {
- if (mathJaxError instanceof PdfExportCancelledError) throw mathJaxError;
- console.warn("MathJax rendering issue:", mathJaxError);
- }
- 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());
- }
-
tempElement.querySelectorAll('.diagram-status, .diagram-toolbar, .mermaid-toolbar, .abc-toolbar').forEach(el => el.remove());
await waitForPdfFrame(progressState);
fitExportElementToContent(tempElement);
@@ -25498,184 +25448,14 @@ ${selector} .arrowheadPath {
updatePdfProgress(progressState, 25, "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', 'video', 'source'],
- ADD_ATTR: ['id', 'class', 'style', 'align', 'viewBox', 'd', 'fill', 'stroke', 'transform', 'marker-end', 'marker-start', 'type', 'checked', 'disabled', 'data-original-code', 'aria-label', 'controls', 'preload', 'playsinline'],
- ALLOWED_URI_REGEXP: SAFE_MARKDOWN_URI_REGEXP
- });
throwIfPdfExportAborted(progressState.signal);
- updatePdfProgress(progressState, 40, "Preparing document");
+ updatePdfProgress(progressState, 40, "Rendering document content");
await waitForPdfFrame(progressState);
- const tempElement = document.createElement("div");
- progressState.tempElement = tempElement;
const isDarkTheme = exportTheme === "dark";
- tempElement.className = "markdown-body pdf-export " + (isDarkTheme ? "theme-dark" : "theme-light");
- tempElement.setAttribute('data-theme', isDarkTheme ? "dark" : "light");
- tempElement.innerHTML = sanitizedHtml;
- enhanceGitHubAlerts(tempElement);
- tempElement.style.padding = "40px"; // Give some padding for PNG
- tempElement.style.width = "1000px";
- tempElement.style.margin = "0 auto";
- tempElement.style.fontSize = "16px";
- tempElement.style.position = "fixed";
- tempElement.style.left = "-9999px";
- tempElement.style.top = "0";
-
- tempElement.style.backgroundColor = isDarkTheme ? "#0d1117" : "#ffffff";
- tempElement.style.color = isDarkTheme ? "#c9d1d9" : "#24292e";
-
- document.body.appendChild(tempElement);
+ const tempElement = await createExportRenderElement(markdown, exportTheme, 'image', progressState);
await waitForPdfFrame(progressState);
- const mermaidNodes = tempElement.querySelectorAll('.mermaid');
- if (mermaidNodes.length > 0) {
- updatePdfProgress(progressState, 50, "Rendering diagrams");
- try {
- if (typeof mermaid === 'undefined') {
- await runPdfAbortable(progressState, loadScript(CDN.mermaid));
- }
- throwIfPdfExportAborted(progressState.signal);
-
- mermaidNodes.forEach(node => {
- const rawCode = node.getAttribute('data-original-code');
- if (rawCode) {
- try { node.textContent = decodeURIComponent(rawCode); }
- catch (_) { node.textContent = rawCode; }
- }
- node.removeAttribute('data-processed');
- });
-
- if (typeof mermaid !== 'undefined') {
- mermaid.initialize({
- startOnLoad: false,
- theme: isDarkTheme ? 'dark' : 'default',
- securityLevel: 'strict',
- flowchart: { useMaxWidth: true, htmlLabels: true },
- fontSize: 16,
- gantt: { useWidth: 1200 }
- });
- }
- await runPdfAbortable(progressState, mermaid.init(undefined, mermaidNodes));
- tempElement.querySelectorAll('.diagram-status, .diagram-toolbar, .mermaid-toolbar').forEach(el => el.remove());
- tempElement.querySelectorAll('.mermaid-container.is-loading, .diagram-viewer.is-loading').forEach(container => container.classList.remove('is-loading'));
-
- const compiledMermaids = tempElement.querySelectorAll('.mermaid-container, .diagram-viewer[data-diagram-engine="mermaid"]');
- compiledMermaids.forEach(container => {
- const svgElement = container.querySelector('svg');
- if (svgElement) {
- const rect = svgElement.getBoundingClientRect();
- const width = rect.width || svgElement.clientWidth || parseFloat(svgElement.getAttribute('width')) || 600;
- const height = rect.height || svgElement.clientHeight || parseFloat(svgElement.getAttribute('height')) || 400;
- const clonedSvg = svgElement.cloneNode(true);
- clonedSvg.setAttribute('width', width);
- clonedSvg.setAttribute('height', height);
- if (!clonedSvg.getAttribute('viewBox')) {
- clonedSvg.setAttribute('viewBox', `0 0 ${width} ${height}`);
- }
- clonedSvg.style.width = `${width}px`;
- clonedSvg.style.height = `${height}px`;
- const svgString = new XMLSerializer().serializeToString(clonedSvg);
- const svgBase64 = btoa(unescape(encodeURIComponent(svgString)));
- const img = document.createElement('img');
- img.className = 'mermaid-img';
- if (svgElement.id) img.id = svgElement.id + '-img';
- img.src = 'data:image/svg+xml;base64,' + svgBase64;
- img.style.width = `${width}px`;
- img.style.height = `${height}px`;
- img.style.maxWidth = '100%';
- img.style.display = 'block';
- img.style.margin = '0 auto';
- container.innerHTML = '';
- container.appendChild(img);
- }
- });
- } catch (e) {
- if (e instanceof PdfExportCancelledError) throw e;
- console.warn("Mermaid issue:", e);
- tempElement.querySelectorAll('.mermaid-container.is-loading, .diagram-viewer.is-loading').forEach(container => container.classList.remove('is-loading'));
- }
- tempElement.querySelectorAll('.diagram-status, .diagram-toolbar, .mermaid-toolbar').forEach(el => el.remove());
- throwIfPdfExportAborted(progressState.signal);
- await waitForPdfFrame(progressState);
- }
-
- const abcNodes = tempElement.querySelectorAll('.abc-notation');
- if (abcNodes.length > 0) {
- updatePdfProgress(progressState, 60, "Rendering music notation");
- try {
- if (typeof ABCJS === 'undefined') {
- await runPdfAbortable(progressState, loadScript(CDN.abcjs));
- }
- throwIfPdfExportAborted(progressState.signal);
- abcNodes.forEach(node => {
- const abcCode = decodeURIComponent(node.getAttribute('data-original-code') || '');
- if (abcCode) ABCJS.renderAbc(node.id, abcCode, { responsive: 'resize' });
- });
- tempElement.querySelectorAll('.diagram-status, .diagram-toolbar, .abc-toolbar').forEach(el => el.remove());
- tempElement.querySelectorAll('.abc-container.is-loading, .diagram-viewer.is-loading').forEach(container => container.classList.remove('is-loading'));
-
- const compiledAbcs = tempElement.querySelectorAll('.abc-container, .diagram-viewer[data-diagram-engine="abc"]');
- compiledAbcs.forEach(container => {
- const svgElement = container.querySelector('svg');
- if (svgElement) {
- const rect = svgElement.getBoundingClientRect();
- const width = rect.width || svgElement.clientWidth || parseFloat(svgElement.getAttribute('width')) || 600;
- const height = rect.height || svgElement.clientHeight || parseFloat(svgElement.getAttribute('height')) || 400;
- const clonedSvg = svgElement.cloneNode(true);
- clonedSvg.setAttribute('width', width);
- clonedSvg.setAttribute('height', height);
- if (!clonedSvg.getAttribute('viewBox')) {
- clonedSvg.setAttribute('viewBox', `0 0 ${width} ${height}`);
- }
- const svgString = new XMLSerializer().serializeToString(clonedSvg);
- const svgBase64 = btoa(unescape(encodeURIComponent(svgString)));
- const img = document.createElement('img');
- img.className = 'abc-img';
- if (svgElement.id) img.id = svgElement.id + '-img';
- img.src = 'data:image/svg+xml;base64,' + svgBase64;
- img.style.width = `${width}px`;
- img.style.height = `${height}px`;
- img.style.maxWidth = '100%';
- img.style.display = 'block';
- img.style.margin = '0 auto';
- container.innerHTML = '';
- container.appendChild(img);
- }
- });
- } catch (e) {
- if (e instanceof PdfExportCancelledError) throw e;
- console.warn("ABC rendering issue:", e);
- }
- tempElement.querySelectorAll('.diagram-status, .diagram-toolbar, .abc-toolbar').forEach(el => el.remove());
- throwIfPdfExportAborted(progressState.signal);
- await waitForPdfFrame(progressState);
- }
-
- if (window.MathJax && markdownLikelyContainsMath(markdown)) {
- updatePdfProgress(progressState, 70, "Rendering math");
- try {
- await runPdfAbortable(progressState, MathJax.typesetPromise([tempElement]));
- } catch (e) {
- if (e instanceof PdfExportCancelledError) throw e;
- console.warn("MathJax rendering issue:", e);
- }
- throwIfPdfExportAborted(progressState.signal);
- 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();
- });
- const mathScripts = tempElement.querySelectorAll('script[type*="math"], script[type*="tex"]');
- mathScripts.forEach(el => el.remove());
- }
-
tempElement.querySelectorAll('.diagram-status, .diagram-toolbar, .mermaid-toolbar, .abc-toolbar').forEach(el => el.remove());
await waitForPdfFrame(progressState);
fitExportElementToContent(tempElement);
diff --git a/styles.css b/styles.css
index 4edb7249..9b382a6c 100644
--- a/styles.css
+++ b/styles.css
@@ -4949,6 +4949,54 @@ a:focus {
--color-border-muted: #21262d !important;
}
+/* Keep exported SVG diagrams readable in the chosen export theme, independent
+ of the theme currently used by the application shell. */
+.pdf-export.theme-light .plantuml-diagram > svg,
+.pdf-export.theme-light .graphviz-diagram > svg,
+.pdf-export.theme-light .d2-diagram > svg,
+.pdf-export.theme-light .kroki-diagram > svg,
+.pdf-export.theme-light .abc-notation > svg,
+.pdf-export.theme-light .diagram-surface > img {
+ filter: none !important;
+}
+
+.pdf-export.theme-dark .plantuml-diagram > svg,
+.pdf-export.theme-dark .graphviz-diagram > svg,
+.pdf-export.theme-dark .d2-diagram > svg:not([data-diagram-native-dark="true"]),
+.pdf-export.theme-dark .kroki-diagram > svg {
+ filter: invert(0.88) hue-rotate(180deg) !important;
+}
+
+.pdf-export.theme-dark .d2-diagram > svg[data-diagram-native-dark="true"],
+.pdf-export.theme-dark .markmap-diagram > svg {
+ filter: none !important;
+}
+
+.pdf-export .abc-notation svg {
+ color: var(--color-fg-default) !important;
+ background: transparent !important;
+}
+
+.pdf-export .abc-notation svg path,
+.pdf-export .abc-notation svg text {
+ fill: currentColor !important;
+}
+
+.pdf-export .abc-notation svg text {
+ stroke: none !important;
+}
+
+.pdf-export .abc-notation svg .abcjs-staff,
+.pdf-export .abc-notation svg .abcjs-staff-extra,
+.pdf-export .abc-notation svg .abcjs-bar,
+.pdf-export .abc-notation svg .abcjs-ledger,
+.pdf-export .abc-notation svg .abcjs-stem,
+.pdf-export .abc-notation svg .abcjs-beam,
+.pdf-export .abc-notation svg .abcjs-slur,
+.pdf-export .abc-notation svg .abcjs-tie {
+ stroke: currentColor !important;
+}
+
/* Hide toolbar and status indicators in export captures */
.pdf-export .diagram-status,
.pdf-export .diagram-toolbar,
@@ -8146,64 +8194,246 @@ a:focus {
opacity: 1;
}
-/* Compact Modern Export Theme Selector */
-.export-theme-toggle {
+/* Equal PDF export method cards */
+.pdf-export-method-cards {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
- gap: 3px;
- padding: 3px;
- border: 1px solid var(--border-color);
- border-radius: 6px;
- background: var(--button-bg);
- box-shadow: inset 0 1px 1px rgba(31, 35, 40, 0.03);
+ align-items: stretch;
+ gap: 8px;
}
-.export-theme-option {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- gap: 6px;
- min-height: 32px;
- padding: 6px 12px;
- border: 1px solid transparent;
- border-radius: 4px;
- background: transparent;
- color: var(--text-secondary);
+.pdf-export-method-card {
+ position: relative;
+ display: grid;
+ grid-template-columns: 16px minmax(0, 1fr);
+ align-items: start;
+ min-width: 0;
+ min-height: 96px;
+ gap: 10px;
+ padding: 12px;
+ border: 1px solid var(--border-color);
+ border-radius: 10px;
+ background: var(--bg-color);
+ color: var(--text-color);
cursor: pointer;
- font-size: 12px;
- font-weight: 500;
- line-height: 1.2;
- transition: background-color 150ms ease, border-color 150ms ease, color 150ms ease, box-shadow 150ms ease;
+ transition: border-color 150ms ease, background-color 150ms ease, box-shadow 150ms ease;
user-select: none;
}
-.export-theme-option input[type="radio"] {
+.pdf-export-method-card:hover {
+ border-color: color-mix(in srgb, var(--accent-color) 48%, var(--border-color));
+ background: color-mix(in srgb, var(--accent-color) 3%, var(--bg-color));
+}
+
+.pdf-export-method-card:has(input:focus-visible) {
+ outline: 2px solid var(--accent-color);
+ outline-offset: 2px;
+}
+
+.pdf-export-method-card.is-selected {
+ border-color: var(--accent-color);
+ background: color-mix(in srgb, var(--accent-color) 7%, var(--bg-color));
+ box-shadow: none;
+}
+
+.pdf-export-method-card > input[type="radio"] {
position: absolute;
+ width: 1px;
+ height: 1px;
opacity: 0;
- width: 0;
- height: 0;
pointer-events: none;
}
-.export-theme-option:hover:not(.is-active) {
- background: var(--button-hover);
+.pdf-export-method-radio {
+ position: relative;
+ width: 16px;
+ height: 16px;
+ margin-top: 1px;
+ border: 1.5px solid var(--border-color);
+ border-radius: 50%;
+ background: var(--bg-color);
+ transition: border-color 150ms ease, background-color 150ms ease;
+}
+
+.pdf-export-method-radio::after {
+ position: absolute;
+ inset: 5px;
+ border-radius: 50%;
+ background: #ffffff;
+ content: "";
+ opacity: 0;
+ transform: scale(0.5);
+ transition: opacity 150ms ease, transform 150ms ease;
+}
+
+.pdf-export-method-card.is-selected .pdf-export-method-radio {
+ border-color: var(--accent-color);
+ background: var(--accent-color);
+}
+
+.pdf-export-method-card.is-selected .pdf-export-method-radio::after {
+ opacity: 1;
+ transform: scale(1);
+}
+
+.pdf-export-method-content {
+ display: flex;
+ min-width: 0;
+ flex-direction: column;
+ gap: 8px;
+}
+
+.pdf-export-method-heading {
+ display: flex;
+ align-items: center;
+ min-height: 18px;
+ gap: 4px;
+ white-space: nowrap;
+}
+
+.pdf-export-method-title {
+ flex: 0 0 auto;
color: var(--text-color);
+ font-size: var(--ui-font-lg);
+ font-weight: 650;
+ line-height: 1.3;
+}
+
+.pdf-export-method-badge {
+ flex: 0 0 auto;
+ padding: 2px 4px;
+ border-radius: 999px;
+ background: color-mix(in srgb, var(--accent-color) 10%, transparent);
+ color: var(--accent-color);
+ font-size: var(--ui-icon-size-xs);
+ font-weight: 600;
+ line-height: 1.3;
+}
+
+.pdf-export-method-description {
+ min-height: calc(2 * 1.45em);
+ color: var(--text-secondary);
+ font-size: var(--ui-font-sm);
+ font-weight: 400;
+ line-height: 1.45;
}
-.export-theme-option.is-active,
-.export-theme-option.is-selected {
+@media (max-width: 479.98px) {
+ .pdf-export-method-cards {
+ grid-template-columns: 1fr;
+ }
+
+ .pdf-export-method-card {
+ min-height: 0;
+ }
+
+ .pdf-export-method-description {
+ min-height: 0;
+ }
+}
+
+/* Compact export theme selector; extends the shared segmented-control UI. */
+.export-appearance-setting {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ padding: 10px 12px;
+ border: 1px solid var(--border-color);
+ border-radius: 10px;
background: var(--bg-color);
+ box-shadow: 0 1px 2px color-mix(in srgb, var(--text-color) 6%, transparent);
+}
+
+.export-appearance-label,
+.export-appearance-memory {
+ display: inline-flex;
+ align-items: center;
+}
+
+.export-appearance-label {
+ gap: 8px;
+ min-width: 0;
+}
+
+.export-appearance-title {
color: var(--text-color);
- font-weight: 600;
+ font-size: var(--ui-font-lg);
+ font-weight: 650;
+}
+
+.export-appearance-memory {
+ gap: 4px;
+ padding: 3px 7px;
+ border: 1px solid color-mix(in srgb, var(--success-color) 18%, transparent);
+ border-radius: 999px;
+ background: color-mix(in srgb, var(--success-color) 10%, transparent);
+ color: var(--success-color);
+ font-size: var(--ui-font-sm);
+ font-weight: 500;
+ line-height: 1.2;
+ white-space: nowrap;
+}
+
+.export-appearance-memory i {
+ font-size: var(--ui-icon-size-sm);
+ line-height: 1;
+}
+
+.export-theme-toggle {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ width: 132px;
+ flex: 0 0 auto;
border-color: var(--border-color);
- box-shadow: 0 1px 2px rgba(31, 35, 40, 0.12);
+ border-radius: 9px;
+ background: var(--segmented-bg);
+ box-shadow: inset 0 1px 2px color-mix(in srgb, var(--text-color) 7%, transparent);
+}
+
+.export-theme-option {
+ min-height: 32px;
+ gap: 7px;
+ padding: 5px 9px;
+ border-radius: 7px;
+ font-size: var(--ui-font-md);
+}
+
+.export-theme-option.is-active {
+ border-color: color-mix(in srgb, var(--text-color) 8%, transparent);
+ background: var(--segmented-surface);
+ box-shadow: var(--segmented-shadow);
+}
+
+.export-theme-option:has(input:focus-visible) {
+ outline: 2px solid var(--accent-color);
+ outline-offset: 2px;
+}
+
+.export-theme-option input[type="radio"] {
+ position: absolute;
+ opacity: 0;
+ width: 0;
+ height: 0;
+ pointer-events: none;
}
.export-theme-option i {
- font-size: 14px;
+ font-size: var(--ui-icon-size-md);
line-height: 1;
}
+@media (max-width: 359.98px) {
+ .export-appearance-setting {
+ align-items: stretch;
+ flex-direction: column;
+ gap: 8px;
+ }
+
+ .export-theme-toggle {
+ width: 100%;
+ }
+}
+
#share-modal .share-mode-cards {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -12985,6 +13215,23 @@ html[data-theme="dark"] .mobile-settings-toggle.is-active .settings-switch {
max-width: 100% !important;
}
+ html[data-browser-print-export] .app-container {
+ display: none !important;
+ }
+
+ html[data-browser-print-export] .browser-print-export-snapshot {
+ display: block !important;
+ position: static !important;
+ inset: auto !important;
+ width: 100% !important;
+ min-height: 100vh !important;
+ max-width: 100% !important;
+ margin: 0 !important;
+ padding: 15mm 20mm !important;
+ overflow: visible !important;
+ box-sizing: border-box !important;
+ }
+
html:not([data-browser-print-export="dark"]),
html:not([data-browser-print-export="dark"]) body,
html:not([data-browser-print-export="dark"]) .preview-pane {
@@ -13278,69 +13525,69 @@ html[data-theme="dark"] .mobile-settings-toggle.is-active .settings-switch {
color: var(--color-fg-default) !important;
}
- .frontmatter-table tr:nth-child(odd) th,
- .frontmatter-table tr:nth-child(odd) td,
- .frontmatter-table tr:nth-child(even) th,
- .frontmatter-table tr:nth-child(even) td {
+ html:not([data-browser-print-export="dark"]) .frontmatter-table tr:nth-child(odd) th,
+ html:not([data-browser-print-export="dark"]) .frontmatter-table tr:nth-child(odd) td,
+ html:not([data-browser-print-export="dark"]) .frontmatter-table tr:nth-child(even) th,
+ html:not([data-browser-print-export="dark"]) .frontmatter-table tr:nth-child(even) td {
background-color: #ffffff !important;
}
- .frontmatter-table tr:nth-child(2n) th,
- .frontmatter-table tr:nth-child(2n) td {
+ html:not([data-browser-print-export="dark"]) .frontmatter-table tr:nth-child(2n) th,
+ html:not([data-browser-print-export="dark"]) .frontmatter-table tr:nth-child(2n) td {
background-color: #f6f8fa !important;
}
- .fm-complex,
- .fm-tag {
+ html:not([data-browser-print-export="dark"]) .fm-complex,
+ html:not([data-browser-print-export="dark"]) .fm-tag {
background: #f6f8fa !important;
border-color: var(--color-border-default) !important;
color: var(--color-fg-default) !important;
}
- .markdown-body table tr,
- [data-theme="dark"] .markdown-body table tr {
+ html:not([data-browser-print-export="dark"]) .markdown-body table tr,
+ html:not([data-browser-print-export="dark"]) [data-theme="dark"] .markdown-body table tr {
background-color: #ffffff !important;
color: var(--color-fg-default) !important;
}
- .markdown-body table tr:nth-child(2n),
- [data-theme="dark"] .markdown-body table tr:nth-child(2n) {
+ html:not([data-browser-print-export="dark"]) .markdown-body table tr:nth-child(2n),
+ html:not([data-browser-print-export="dark"]) [data-theme="dark"] .markdown-body table tr:nth-child(2n) {
background-color: #f6f8fa !important;
}
- .markdown-body table th,
- .markdown-body table td,
- [data-theme="dark"] .markdown-body table th,
- [data-theme="dark"] .markdown-body table td {
+ html:not([data-browser-print-export="dark"]) .markdown-body table th,
+ html:not([data-browser-print-export="dark"]) .markdown-body table td,
+ html:not([data-browser-print-export="dark"]) [data-theme="dark"] .markdown-body table th,
+ html:not([data-browser-print-export="dark"]) [data-theme="dark"] .markdown-body table td {
border-color: var(--color-border-default) !important;
color: var(--color-fg-default) !important;
}
- .markdown-body .markdown-alert-note {
+ html:not([data-browser-print-export="dark"]) .markdown-body .markdown-alert-note {
color: #0969da !important;
border-left-color: #0969da !important;
background-color: #ddf4ff !important;
}
- .markdown-body .markdown-alert-tip {
+ html:not([data-browser-print-export="dark"]) .markdown-body .markdown-alert-tip {
color: #1a7f37 !important;
border-left-color: #1a7f37 !important;
background-color: #dafbe1 !important;
}
- .markdown-body .markdown-alert-important {
+ html:not([data-browser-print-export="dark"]) .markdown-body .markdown-alert-important {
color: #8250df !important;
border-left-color: #8250df !important;
background-color: #fbefff !important;
}
- .markdown-body .markdown-alert-warning {
+ html:not([data-browser-print-export="dark"]) .markdown-body .markdown-alert-warning {
color: #9a6700 !important;
border-left-color: #9a6700 !important;
background-color: #fff8c5 !important;
}
- .markdown-body .markdown-alert-caution {
+ html:not([data-browser-print-export="dark"]) .markdown-body .markdown-alert-caution {
color: #cf222e !important;
border-left-color: #cf222e !important;
background-color: #ffebe9 !important;
@@ -13350,89 +13597,89 @@ html[data-theme="dark"] .mobile-settings-toggle.is-active .settings-switch {
color: var(--color-fg-default) !important;
}
- .mermaid-container,
- .abc-container,
- .geojson-container,
- .topojson-container,
- .stl-container,
- .plantuml-container,
- .d2-container,
- .graphviz-container,
- .diagram-viewer,
- .diagram-surface,
- .diagram-status {
+ html:not([data-browser-print-export="dark"]) .mermaid-container,
+ html:not([data-browser-print-export="dark"]) .abc-container,
+ html:not([data-browser-print-export="dark"]) .geojson-container,
+ html:not([data-browser-print-export="dark"]) .topojson-container,
+ html:not([data-browser-print-export="dark"]) .stl-container,
+ html:not([data-browser-print-export="dark"]) .plantuml-container,
+ html:not([data-browser-print-export="dark"]) .d2-container,
+ html:not([data-browser-print-export="dark"]) .graphviz-container,
+ html:not([data-browser-print-export="dark"]) .diagram-viewer,
+ html:not([data-browser-print-export="dark"]) .diagram-surface,
+ html:not([data-browser-print-export="dark"]) .diagram-status {
background: #ffffff !important;
border-color: var(--color-border-default) !important;
color: var(--color-fg-default) !important;
}
- .diagram-status,
- .diagram-viewer.is-error>.diagram-status {
+ html:not([data-browser-print-export="dark"]) .diagram-status,
+ html:not([data-browser-print-export="dark"]) .diagram-viewer.is-error>.diagram-status {
background: #f6f8fa !important;
}
- .markdown-body svg,
- .markdown-body img,
- .plantuml-diagram img,
- .plantuml-img,
- .d2-diagram img,
- .d2-img,
- .graphviz-diagram img,
- .graphviz-img,
- .plantuml-diagram>svg,
- .graphviz-diagram>svg,
- .d2-diagram>svg,
- .diagram-surface svg {
+ html:not([data-browser-print-export="dark"]) .markdown-body svg,
+ html:not([data-browser-print-export="dark"]) .markdown-body img,
+ html:not([data-browser-print-export="dark"]) .plantuml-diagram img,
+ html:not([data-browser-print-export="dark"]) .plantuml-img,
+ html:not([data-browser-print-export="dark"]) .d2-diagram img,
+ html:not([data-browser-print-export="dark"]) .d2-img,
+ html:not([data-browser-print-export="dark"]) .graphviz-diagram img,
+ html:not([data-browser-print-export="dark"]) .graphviz-img,
+ html:not([data-browser-print-export="dark"]) .plantuml-diagram>svg,
+ html:not([data-browser-print-export="dark"]) .graphviz-diagram>svg,
+ html:not([data-browser-print-export="dark"]) .d2-diagram>svg,
+ html:not([data-browser-print-export="dark"]) .diagram-surface svg {
filter: none !important;
background: transparent !important;
}
- .markdown-body .mermaid svg {
+ html:not([data-browser-print-export="dark"]) .markdown-body .mermaid svg {
background: #ffffff !important;
color: #24292f !important;
}
- .markdown-body .mermaid text,
- .markdown-body .mermaid tspan,
- .markdown-body .mermaid .nodeLabel,
- .markdown-body .mermaid .edgeLabel,
- .markdown-body .mermaid .label,
- .markdown-body .mermaid span {
+ html:not([data-browser-print-export="dark"]) .markdown-body .mermaid text,
+ html:not([data-browser-print-export="dark"]) .markdown-body .mermaid tspan,
+ html:not([data-browser-print-export="dark"]) .markdown-body .mermaid .nodeLabel,
+ html:not([data-browser-print-export="dark"]) .markdown-body .mermaid .edgeLabel,
+ html:not([data-browser-print-export="dark"]) .markdown-body .mermaid .label,
+ html:not([data-browser-print-export="dark"]) .markdown-body .mermaid span {
color: #24292f !important;
fill: #24292f !important;
}
- .markdown-body .mermaid .node rect,
- .markdown-body .mermaid .node circle,
- .markdown-body .mermaid .node ellipse,
- .markdown-body .mermaid .node polygon,
- .markdown-body .mermaid .actor,
- .markdown-body .mermaid .note,
- .markdown-body .mermaid .labelBox {
+ html:not([data-browser-print-export="dark"]) .markdown-body .mermaid .node rect,
+ html:not([data-browser-print-export="dark"]) .markdown-body .mermaid .node circle,
+ html:not([data-browser-print-export="dark"]) .markdown-body .mermaid .node ellipse,
+ html:not([data-browser-print-export="dark"]) .markdown-body .mermaid .node polygon,
+ html:not([data-browser-print-export="dark"]) .markdown-body .mermaid .actor,
+ html:not([data-browser-print-export="dark"]) .markdown-body .mermaid .note,
+ html:not([data-browser-print-export="dark"]) .markdown-body .mermaid .labelBox {
fill: #f6f8fa !important;
stroke: #8c959f !important;
}
- .markdown-body .mermaid .edgeLabel,
- .markdown-body .mermaid .cluster rect {
+ html:not([data-browser-print-export="dark"]) .markdown-body .mermaid .edgeLabel,
+ html:not([data-browser-print-export="dark"]) .markdown-body .mermaid .cluster rect {
background-color: #ffffff !important;
fill: #ffffff !important;
color: #24292f !important;
}
- .markdown-body .mermaid .flowchart-link,
- .markdown-body .mermaid .edgePath .path,
- .markdown-body .mermaid .messageLine0,
- .markdown-body .mermaid .messageLine1,
- .markdown-body .mermaid .loopLine,
- .markdown-body .mermaid .activation0,
- .markdown-body .mermaid .activation1,
- .markdown-body .mermaid .activation2 {
+ html:not([data-browser-print-export="dark"]) .markdown-body .mermaid .flowchart-link,
+ html:not([data-browser-print-export="dark"]) .markdown-body .mermaid .edgePath .path,
+ html:not([data-browser-print-export="dark"]) .markdown-body .mermaid .messageLine0,
+ html:not([data-browser-print-export="dark"]) .markdown-body .mermaid .messageLine1,
+ html:not([data-browser-print-export="dark"]) .markdown-body .mermaid .loopLine,
+ html:not([data-browser-print-export="dark"]) .markdown-body .mermaid .activation0,
+ html:not([data-browser-print-export="dark"]) .markdown-body .mermaid .activation1,
+ html:not([data-browser-print-export="dark"]) .markdown-body .mermaid .activation2 {
stroke: #57606a !important;
}
- .markdown-body .mermaid marker path,
- .markdown-body .mermaid .arrowheadPath {
+ html:not([data-browser-print-export="dark"]) .markdown-body .mermaid marker path,
+ html:not([data-browser-print-export="dark"]) .markdown-body .mermaid .arrowheadPath {
fill: #57606a !important;
stroke: #57606a !important;
}
@@ -13444,14 +13691,14 @@ html[data-theme="dark"] .mobile-settings-toggle.is-active .settings-switch {
color: var(--color-fg-default) !important;
}
- .stl-viewer canvas,
- .geojson-map,
- .topojson-map {
+ html:not([data-browser-print-export="dark"]) .stl-viewer canvas,
+ html:not([data-browser-print-export="dark"]) .geojson-map,
+ html:not([data-browser-print-export="dark"]) .topojson-map {
background: #ffffff !important;
}
- body,
- html {
+ html:not([data-browser-print-export="dark"]),
+ html:not([data-browser-print-export="dark"]) body {
height: auto !important;
overflow: visible !important;
background: white !important;
diff --git a/tests/e2e/export.spec.js b/tests/e2e/export.spec.js
index a0bee044..bc3dcf2f 100644
--- a/tests/e2e/export.spec.js
+++ b/tests/e2e/export.spec.js
@@ -3,10 +3,14 @@ const {
fixture,
openApp,
setEditorContent,
+ stubRemoteDiagramServices,
+ stubLazyRendererLibraries,
stubExportLibraries
} = require('../helpers/app');
test.beforeEach(async ({ page }) => {
+ await stubRemoteDiagramServices(page);
+ await stubLazyRendererLibraries(page);
await stubExportLibraries(page);
await openApp(page);
await setEditorContent(page, await fixture('export.md'));
@@ -22,6 +26,118 @@ test.beforeEach(async ({ page }) => {
});
});
+const allDiagramMarkdown = `# Exported diagrams
+
+\`\`\`mermaid
+flowchart LR
+ A --> B
+\`\`\`
+
+\`\`\`abc
+X:1
+T:Export score
+M:4/4
+K:C
+CDEF GABc|
+\`\`\`
+
+\`\`\`plantuml
+@startuml
+Alice -> Bob: Hello
+@enduml
+\`\`\`
+
+\`\`\`d2
+client -> server
+\`\`\`
+
+\`\`\`graphviz
+digraph G { A -> B }
+\`\`\`
+
+\`\`\`vega-lite
+{"mark":"bar","data":{"values":[{"x":"A","y":1}]},"encoding":{"x":{"field":"x"},"y":{"field":"y"}}}
+\`\`\`
+
+\`\`\`wavedrom
+{"signal":[{"name":"clk","wave":"p..."}]}
+\`\`\`
+
+\`\`\`markmap
+# Root
+## Branch
+\`\`\`
+
+\`\`\`geojson
+{"type":"FeatureCollection","features":[]}
+\`\`\`
+
+\`\`\`topojson
+{"type":"Topology","objects":{"sample":{}},"arcs":[]}
+\`\`\`
+
+\`\`\`stl
+solid triangle
+facet normal 0 0 1
+outer loop
+vertex 0 0 0
+vertex 1 0 0
+vertex 0 1 0
+endloop
+endfacet
+endsolid triangle
+\`\`\``;
+
+async function installMapStubs(page) {
+ await page.evaluate(() => {
+ window.L = {
+ map(node) {
+ const pane = document.createElement('div');
+ pane.className = 'leaflet-map-pane';
+ pane.textContent = 'Rendered map';
+ node.appendChild(pane);
+ return {
+ remove() { pane.remove(); },
+ fitBounds() {},
+ setView() {},
+ invalidateSize() {},
+ eachLayer() {},
+ attributionControl: {
+ addAttribution() {},
+ removeAttribution() {}
+ }
+ };
+ },
+ tileLayer() { return { addTo() { return this; } }; },
+ geoJSON() {
+ return {
+ addTo() { return this; },
+ getBounds() { return { isValid() { return false; } }; }
+ };
+ }
+ };
+ window.topojson = {
+ feature() { return { type: 'FeatureCollection', features: [] }; }
+ };
+ });
+}
+
+const renderedDiagramExpectation = {
+ theme: 'dark',
+ mermaid: true,
+ abc: true,
+ plantuml: true,
+ d2: true,
+ graphviz: true,
+ vegalite: true,
+ wavedrom: true,
+ markmap: true,
+ geojson: true,
+ topojson: true,
+ stl: true,
+ loadingCount: 0
+};
+
test('exports Markdown as a download', async ({ page }) => {
await page.locator('#export-md').dispatchEvent('click');
@@ -51,6 +167,120 @@ test('browser PDF export opens options and triggers window.print for vector mode
await expect.poll(() => page.evaluate(() => window.__printCalled)).toBeGreaterThan(0);
});
+test('vector PDF renders every diagram off-screen without changing the app theme', async ({ page }) => {
+ await installMapStubs(page);
+ await setEditorContent(page, allDiagramMarkdown);
+ await page.evaluate(() => {
+ document.documentElement.setAttribute('data-theme', 'light');
+ window.__vectorExportState = null;
+ window.print = () => {
+ const root = document.querySelector('.browser-print-export-snapshot');
+ window.__vectorExportState = {
+ appTheme: document.documentElement.getAttribute('data-theme'),
+ exportTheme: document.documentElement.getAttribute('data-browser-print-export'),
+ snapshot: {
+ theme: root?.getAttribute('data-theme'),
+ mermaid: Boolean(root?.querySelector('.mermaid svg')),
+ abc: Boolean(root?.querySelector('.abc-notation svg')),
+ plantuml: Boolean(root?.querySelector('.plantuml-diagram svg')),
+ d2: Boolean(root?.querySelector('.d2-diagram svg')),
+ graphviz: Boolean(root?.querySelector('.graphviz-diagram svg')),
+ vegalite: Boolean(root?.querySelector('[data-diagram-engine="vegalite"] svg')),
+ wavedrom: Boolean(root?.querySelector('[data-diagram-engine="wavedrom"] svg')),
+ markmap: Boolean(root?.querySelector('.markmap-diagram svg')),
+ geojson: Boolean(root?.querySelector('.geojson-map .leaflet-map-pane')),
+ topojson: Boolean(root?.querySelector('.topojson-map .leaflet-map-pane')),
+ stl: Boolean(root?.querySelector('.stl-viewer canvas')),
+ loadingCount: root?.querySelectorAll('.is-loading, .diagram-status').length
+ }
+ };
+ window.dispatchEvent(new Event('afterprint'));
+ };
+ });
+
+ await page.locator('#export-pdf').dispatchEvent('click');
+ await page.locator('#pdf-export-theme-card-dark').click();
+ await page.locator('#pdf-export-confirm').click();
+
+ await expect.poll(() => page.evaluate(() => window.__vectorExportState), { timeout: 20_000 }).toMatchObject({
+ appTheme: 'light',
+ exportTheme: 'dark',
+ snapshot: renderedDiagramExpectation
+ });
+ await expect.poll(() => page.evaluate(() => ({
+ appTheme: document.documentElement.getAttribute('data-theme'),
+ exportTheme: document.documentElement.getAttribute('data-browser-print-export'),
+ snapshots: document.querySelectorAll('.browser-print-export-snapshot').length
+ }))).toEqual({ appTheme: 'light', exportTheme: null, snapshots: 0 });
+});
+
+test('dark vector PDF keeps tables and diagrams dark in print media', async ({ page }) => {
+ await setEditorContent(page, `---
+title: Dark PDF
+description: Print colors should stay dark
+tags:
+ - export
+ - diagrams
+---
+
+# Dark export
+
+\`\`\`mermaid
+flowchart LR
+ A --> B
+\`\`\`
+
+\`\`\`plantuml
+@startuml
+Alice -> Bob: Hello
+@enduml
+\`\`\``);
+ await page.evaluate(() => {
+ document.documentElement.setAttribute('data-theme', 'light');
+ window.print = () => { window.__darkPrintPrepared = true; };
+ });
+
+ await page.locator('#export-pdf').dispatchEvent('click');
+ await page.locator('#pdf-export-theme-card-dark').click();
+ await page.locator('#pdf-export-confirm').click();
+ await expect.poll(() => page.evaluate(() => window.__darkPrintPrepared), { timeout: 20_000 }).toBe(true);
+ await page.emulateMedia({ media: 'print' });
+
+ const printStyles = await page.locator('.browser-print-export-snapshot').evaluate(root => {
+ const style = element => getComputedStyle(element);
+ const frontmatterCell = root.querySelector('.frontmatter-table td');
+ const mermaidSvg = root.querySelector('.mermaid svg');
+ const mermaidRect = root.querySelector('.mermaid svg rect');
+ const mermaidText = root.querySelector('.mermaid svg text');
+ const plantUmlSvg = root.querySelector('.plantuml-diagram svg');
+ return {
+ rootBackground: style(root).backgroundColor,
+ cellBackground: style(frontmatterCell).backgroundColor,
+ mermaidBackground: style(mermaidSvg).backgroundColor,
+ mermaidNodeFill: style(mermaidRect).fill,
+ mermaidTextFill: style(mermaidText).fill,
+ plantUmlFilter: style(plantUmlSvg).filter,
+ appDisplay: style(document.querySelector('.app-container')).display,
+ appTheme: document.documentElement.getAttribute('data-theme')
+ };
+ });
+
+ expect(printStyles).toEqual({
+ rootBackground: 'rgb(13, 17, 23)',
+ cellBackground: 'rgb(22, 27, 34)',
+ mermaidBackground: 'rgba(0, 0, 0, 0)',
+ mermaidNodeFill: 'rgb(31, 35, 40)',
+ mermaidTextFill: 'rgb(201, 209, 217)',
+ plantUmlFilter: 'invert(0.88) hue-rotate(180deg)',
+ appDisplay: 'none',
+ appTheme: 'light'
+ });
+
+ await page.evaluate(() => window.dispatchEvent(new Event('afterprint')));
+ await page.emulateMedia({ media: 'screen' });
+ await expect(page.locator('.browser-print-export-snapshot')).toHaveCount(0);
+});
+
test('PNG export produces an image download with a mocked local canvas renderer', async ({ page }) => {
await page.locator('#export-png').dispatchEvent('click');
await expect(page.locator('#png-export-modal')).toHaveClass(/is-visible/);
@@ -62,11 +292,67 @@ test('PNG export produces an image download with a mocked local canvas renderer'
});
});
+test('PNG and raster PDF captures contain every rendered diagram', async ({ page }) => {
+ await installMapStubs(page);
+ await setEditorContent(page, allDiagramMarkdown);
+ await page.evaluate(() => {
+ window.__diagramCaptureStates = [];
+ window.html2canvas = async root => {
+ window.__diagramCaptureStates.push({
+ theme: root.getAttribute('data-theme'),
+ mermaid: Boolean(root.querySelector('.mermaid svg')),
+ abc: Boolean(root.querySelector('.abc-notation svg')),
+ plantuml: Boolean(root.querySelector('.plantuml-diagram svg')),
+ d2: Boolean(root.querySelector('.d2-diagram svg')),
+ graphviz: Boolean(root.querySelector('.graphviz-diagram svg')),
+ vegalite: Boolean(root.querySelector('[data-diagram-engine="vegalite"] svg')),
+ wavedrom: Boolean(root.querySelector('[data-diagram-engine="wavedrom"] svg')),
+ markmap: Boolean(root.querySelector('.markmap-diagram svg')),
+ geojson: Boolean(root.querySelector('.geojson-map .leaflet-map-pane')),
+ topojson: Boolean(root.querySelector('.topojson-map .leaflet-map-pane')),
+ stl: Boolean(root.querySelector('.stl-viewer canvas')),
+ loadingCount: root.querySelectorAll('.is-loading, .diagram-status').length
+ });
+ const canvas = document.createElement('canvas');
+ canvas.width = 1200;
+ canvas.height = 900;
+ return canvas;
+ };
+ });
+
+ await page.locator('#export-png').dispatchEvent('click');
+ await page.locator('#png-export-theme-card-dark').click();
+ await page.locator('#png-export-confirm').click();
+ await expect.poll(() => page.evaluate(() => window.__savedFiles.some(file => file.name.endsWith('.png'))), { timeout: 20_000 }).toBe(true);
+
+ await page.locator('#export-pdf').dispatchEvent('click');
+ await page.locator('#pdf-export-card-raster').click();
+ await page.locator('#pdf-export-theme-card-dark').click();
+ await page.locator('#pdf-export-confirm').click();
+ await expect.poll(() => page.evaluate(() => window.__savedFiles.some(file => file.name.endsWith('.pdf'))), { timeout: 20_000 }).toBe(true);
+
+ await expect.poll(() => page.evaluate(() => window.__diagramCaptureStates), { timeout: 20_000 }).toEqual([
+ renderedDiagramExpectation,
+ renderedDiagramExpectation
+ ]);
+});
+
test('export modals use compact segmented theme toggles and restore previous selections', async ({ page }) => {
// Test HTML Export Modal
await page.locator('#export-html').dispatchEvent('click');
await expect(page.locator('#html-export-modal')).toHaveClass(/is-visible/);
await expect(page.locator('#html-export-modal .export-theme-toggle')).toBeVisible();
+ await expect.poll(() => page.locator('#html-export-modal .export-appearance-setting').evaluate(element => element.scrollWidth <= element.clientWidth)).toBe(true);
+ const htmlExportLayout = await page.locator('#html-export-modal').evaluate(modal => {
+ const box = modal.querySelector('.reset-modal-box');
+ const appearance = modal.querySelector('.export-appearance-setting');
+ const toggle = modal.querySelector('.export-theme-toggle');
+ return {
+ modalWidth: Math.round(box.getBoundingClientRect().width),
+ appearanceDirection: getComputedStyle(appearance).flexDirection,
+ toggleWidth: Math.round(toggle.getBoundingClientRect().width)
+ };
+ });
await page.locator('#html-export-theme-card-dark').click();
await expect(page.locator('#html-export-theme-dark')).toBeChecked();
await page.locator('#html-export-cancel').click();
@@ -82,6 +368,94 @@ test('export modals use compact segmented theme toggles and restore previous sel
await page.locator('#export-pdf').dispatchEvent('click');
await expect(page.locator('#pdf-export-modal')).toHaveClass(/is-visible/);
await expect(page.locator('#pdf-export-modal .export-theme-toggle')).toBeVisible();
+ const pdfAppearanceLayout = await page.evaluate(() => {
+ const modalBox = document.querySelector('#pdf-export-modal .reset-modal-box');
+ const methodCards = document.querySelector('#pdf-export-modal .pdf-export-method-cards');
+ const vectorCard = document.querySelector('#pdf-export-card-vector');
+ const rasterCard = document.querySelector('#pdf-export-card-raster');
+ const appearance = document.querySelector('#pdf-export-modal .export-appearance-setting');
+ const toggle = document.querySelector('#pdf-export-modal .export-theme-toggle');
+ const memory = document.querySelector('#pdf-export-modal .export-appearance-memory');
+ const rasterRect = rasterCard.getBoundingClientRect();
+ const appearanceRect = appearance.getBoundingClientRect();
+ const appearanceStyle = getComputedStyle(appearance);
+ const toggleStyle = getComputedStyle(toggle);
+ const memoryStyle = getComputedStyle(memory);
+ const vectorTitle = vectorCard.querySelector('.pdf-export-method-title');
+ const rasterTitle = rasterCard.querySelector('.pdf-export-method-title');
+ const recommendedBadge = vectorCard.querySelector('.pdf-export-method-badge');
+ const vectorDescription = vectorCard.querySelector('.pdf-export-method-description');
+ const rasterDescription = rasterCard.querySelector('.pdf-export-method-description');
+ const methodRadio = vectorCard.querySelector('.pdf-export-method-radio');
+ const successColorProbe = document.createElement('span');
+ successColorProbe.style.color = 'var(--success-color)';
+ document.body.appendChild(successColorProbe);
+ const successColor = getComputedStyle(successColorProbe).color;
+ successColorProbe.remove();
+ return {
+ modalWidth: Math.round(modalBox.getBoundingClientRect().width),
+ gap: Math.round(appearanceRect.top - rasterRect.bottom),
+ appearanceDirection: appearanceStyle.flexDirection,
+ appearanceBorderRadius: appearanceStyle.borderRadius,
+ appearancePaddingTop: appearanceStyle.paddingTop,
+ appearanceBackground: appearanceStyle.backgroundColor,
+ methodColumns: getComputedStyle(methodCards).gridTemplateColumns.split(' ').length,
+ vectorCardWidth: Math.round(vectorCard.getBoundingClientRect().width),
+ rasterCardWidth: Math.round(rasterRect.width),
+ vectorCardHeight: Math.round(vectorCard.getBoundingClientRect().height),
+ rasterCardHeight: Math.round(rasterRect.height),
+ methodRadioWidth: Math.round(methodRadio.getBoundingClientRect().width),
+ selectedCardShadow: getComputedStyle(vectorCard).boxShadow,
+ methodIconCount: methodCards.querySelectorAll('.pdf-export-method-icon').length,
+ vectorTitle: vectorTitle.textContent.trim(),
+ rasterTitle: rasterTitle.textContent.trim(),
+ vectorTitleFont: getComputedStyle(vectorTitle).fontSize,
+ rasterTitleFont: getComputedStyle(rasterTitle).fontSize,
+ vectorDescriptionFont: getComputedStyle(vectorDescription).fontSize,
+ rasterDescriptionFont: getComputedStyle(rasterDescription).fontSize,
+ vectorDescription: vectorDescription.textContent.trim(),
+ rasterDescription: rasterDescription.textContent.trim(),
+ badgeText: recommendedBadge.textContent.trim(),
+ badgeFitsCard: recommendedBadge.getBoundingClientRect().right <= vectorCard.getBoundingClientRect().right - 8,
+ toggleWidth: Math.round(toggle.getBoundingClientRect().width),
+ toggleBorderColor: toggleStyle.borderColor,
+ appBorderColor: getComputedStyle(rasterCard).borderColor,
+ memoryText: memory.textContent.trim(),
+ memoryColor: memoryStyle.color,
+ memoryFontSize: memoryStyle.fontSize,
+ memoryBackground: memoryStyle.backgroundColor,
+ memoryBorderRadius: memoryStyle.borderRadius,
+ successColor
+ };
+ });
+ expect(pdfAppearanceLayout.gap).toBeLessThanOrEqual(12);
+ expect(htmlExportLayout.modalWidth).toBe(pdfAppearanceLayout.modalWidth);
+ expect(htmlExportLayout.appearanceDirection).toBe(pdfAppearanceLayout.appearanceDirection);
+ expect(htmlExportLayout.toggleWidth).toBe(pdfAppearanceLayout.toggleWidth);
+ expect(pdfAppearanceLayout.appearanceBorderRadius).toBe('10px');
+ expect(pdfAppearanceLayout.appearancePaddingTop).toBe('10px');
+ expect(pdfAppearanceLayout.appearanceBackground).not.toBe('rgba(0, 0, 0, 0)');
+ expect(pdfAppearanceLayout.methodColumns).toBe(2);
+ expect(pdfAppearanceLayout.vectorCardWidth).toBe(pdfAppearanceLayout.rasterCardWidth);
+ expect(pdfAppearanceLayout.vectorCardHeight).toBe(pdfAppearanceLayout.rasterCardHeight);
+ expect(pdfAppearanceLayout.methodRadioWidth).toBe(16);
+ expect(pdfAppearanceLayout.selectedCardShadow).toBe('none');
+ expect(pdfAppearanceLayout.methodIconCount).toBe(0);
+ expect(pdfAppearanceLayout.vectorTitle).toBe('Browser Print');
+ expect(pdfAppearanceLayout.rasterTitle).toBe('Precision PDF');
+ expect(pdfAppearanceLayout.vectorTitleFont).toBe(pdfAppearanceLayout.rasterTitleFont);
+ expect(pdfAppearanceLayout.vectorDescriptionFont).toBe(pdfAppearanceLayout.rasterDescriptionFont);
+ expect(pdfAppearanceLayout.vectorDescription).toBe('Fast export using your browser print dialog.');
+ expect(pdfAppearanceLayout.rasterDescription).toBe('Keeps tables, diagrams, and equations together.');
+ expect(pdfAppearanceLayout.badgeText).toBe('Recommended');
+ expect(pdfAppearanceLayout.badgeFitsCard).toBe(true);
+ expect(pdfAppearanceLayout.toggleWidth).toBe(132);
+ expect(pdfAppearanceLayout.toggleBorderColor).toBe(pdfAppearanceLayout.appBorderColor);
+ expect(pdfAppearanceLayout.memoryText).toBe('Remembered');
+ expect(pdfAppearanceLayout.memoryColor).toBe(pdfAppearanceLayout.successColor);
+ expect(pdfAppearanceLayout.memoryFontSize).toBe('11px');
+ expect(pdfAppearanceLayout.memoryBackground).not.toBe('rgba(0, 0, 0, 0)');
+ expect(pdfAppearanceLayout.memoryBorderRadius).toBe('999px');
await page.locator('#pdf-export-theme-card-dark').click();
await expect(page.locator('#pdf-export-theme-dark')).toBeChecked();
await page.locator('#pdf-export-cancel').click();
@@ -97,6 +471,20 @@ test('export modals use compact segmented theme toggles and restore previous sel
await page.locator('#export-png').dispatchEvent('click');
await expect(page.locator('#png-export-modal')).toHaveClass(/is-visible/);
await expect(page.locator('#png-export-modal .export-theme-toggle')).toBeVisible();
+ await expect.poll(() => page.locator('#png-export-modal .export-appearance-setting').evaluate(element => element.scrollWidth <= element.clientWidth)).toBe(true);
+ const pngExportLayout = await page.locator('#png-export-modal').evaluate(modal => {
+ const box = modal.querySelector('.reset-modal-box');
+ const appearance = modal.querySelector('.export-appearance-setting');
+ const toggle = modal.querySelector('.export-theme-toggle');
+ return {
+ modalWidth: Math.round(box.getBoundingClientRect().width),
+ appearanceDirection: getComputedStyle(appearance).flexDirection,
+ toggleWidth: Math.round(toggle.getBoundingClientRect().width)
+ };
+ });
+ expect(pngExportLayout.modalWidth).toBe(pdfAppearanceLayout.modalWidth);
+ expect(pngExportLayout.appearanceDirection).toBe(pdfAppearanceLayout.appearanceDirection);
+ expect(pngExportLayout.toggleWidth).toBe(pdfAppearanceLayout.toggleWidth);
await page.locator('#png-export-theme-card-dark').click();
await expect(page.locator('#png-export-theme-dark')).toBeChecked();
await page.locator('#png-export-cancel').click();
diff --git a/tests/e2e/markmap-export.spec.js b/tests/e2e/markmap-export.spec.js
new file mode 100644
index 00000000..7f83a09f
--- /dev/null
+++ b/tests/e2e/markmap-export.spec.js
@@ -0,0 +1,109 @@
+const fs = require('node:fs/promises');
+const path = require('node:path');
+const { test, expect } = require('@playwright/test');
+const { openApp, setEditorContent, stubExportLibraries } = require('../helpers/app');
+
+const rootDir = path.resolve(__dirname, '../..');
+const markmapAssets = [
+ ['https://cdn.jsdelivr.net/npm/d3@7/dist/d3.min.js', 'd3.min.js'],
+ ['https://cdn.jsdelivr.net/npm/markmap-lib@0.18.12/dist/browser/index.iife.js', 'markmap-lib.iife.js'],
+ ['https://cdn.jsdelivr.net/npm/markmap-view@0.18.12/dist/browser/index.js', 'markmap-view.js']
+];
+
+test('real Markmap trees remain readable in vector PDF print layout', async ({ page }) => {
+ await stubExportLibraries(page);
+ for (const [url, filename] of markmapAssets) {
+ await page.route(url, async route => {
+ await route.fulfill({
+ contentType: 'application/javascript',
+ body: await fs.readFile(path.join(rootDir, 'desktop-app', 'resources', 'libs', filename))
+ });
+ });
+ }
+ await openApp(page);
+ await setEditorContent(page, `# Markmap Rendering Test Suite
+
+## Basic Mind Map
+
+\`\`\`markmap
+# Product
+## Research
+### Users
+### Market
+## Build
+### Web app
+### API
+## Release
+### Documentation
+### Monitoring
+\`\`\`
+
+## Deep Hierarchy
+
+\`\`\`markmap
+# Platform
+## Frontend
+### Components
+#### Buttons
+#### Dialogs
+## Backend
+### API
+#### Authentication
+#### Storage
+\`\`\`
+
+## Large Software Architecture
+
+\`\`\`markmap
+# Architecture
+## Clients
+### Web
+### Mobile
+## Services
+### Gateway
+### Accounts
+### Documents
+## Data
+### Database
+### Cache
+### Object Storage
+\`\`\``);
+ await expect(page.locator('#markdown-preview .markmap-diagram svg').first()).toBeVisible({ timeout: 20_000 });
+ await page.evaluate(() => {
+ window.print = () => { window.__realMarkmapPrintPrepared = true; };
+ });
+
+ await page.locator('#export-pdf').dispatchEvent('click');
+ await page.locator('#pdf-export-theme-card-dark').click();
+ await page.locator('#pdf-export-confirm').click();
+ await expect.poll(() => page.evaluate(() => window.__realMarkmapPrintPrepared), { timeout: 20_000 }).toBe(true);
+ await page.emulateMedia({ media: 'print' });
+
+ const layouts = await page.locator('.browser-print-export-snapshot .markmap-diagram svg').evaluateAll(svgs => {
+ return svgs.map(svg => {
+ const svgRect = svg.getBoundingClientRect();
+ const contentRect = svg.querySelector('g')?.getBoundingClientRect();
+ return {
+ fitted: svg.dataset.exportFitted,
+ width: svgRect.width,
+ height: svgRect.height,
+ contentWidthRatio: contentRect && svgRect.width ? contentRect.width / svgRect.width : 0,
+ contentHeightRatio: contentRect && svgRect.height ? contentRect.height / svgRect.height : 0,
+ viewBox: svg.getAttribute('viewBox')
+ };
+ });
+ });
+
+ expect(layouts).toHaveLength(3);
+ layouts.forEach(layout => {
+ expect(layout.fitted).toBe('true');
+ expect(layout.width).toBeGreaterThan(300);
+ expect(layout.height).toBeGreaterThanOrEqual(220);
+ expect(layout.height).toBeLessThanOrEqual(620);
+ expect(Math.max(layout.contentWidthRatio, layout.contentHeightRatio)).toBeGreaterThan(0.55);
+ expect(layout.viewBox).toBeTruthy();
+ });
+
+ await page.evaluate(() => window.dispatchEvent(new Event('afterprint')));
+ await page.emulateMedia({ media: 'screen' });
+});
diff --git a/tests/e2e/responsive-storage.spec.js b/tests/e2e/responsive-storage.spec.js
index 9436a2b1..3c2cd1df 100644
--- a/tests/e2e/responsive-storage.spec.js
+++ b/tests/e2e/responsive-storage.spec.js
@@ -174,6 +174,11 @@ test('reset workspace permanently deletes documents and blocks repeated clicks',
await openApp(page);
await setEditorContent(page, '# Delete On Reset\n\nThis document must be removed.');
await expect.poll(async () => JSON.stringify(await storedDocuments(page))).toContain('Delete On Reset');
+ await page.locator('#sidebar-new-folder').click();
+ await page.locator('#document-name-modal-input').fill('Delete Folder On Reset');
+ await page.locator('#document-name-modal-confirm').click();
+ await expect(page.locator('.document-tree-row[data-tree-type="folder"]')).toContainText('Delete Folder On Reset');
+ await expect.poll(() => page.evaluate(() => localStorage.getItem('markdownViewerDocumentOrganization'))).toContain('Delete Folder On Reset');
await page.evaluate(async () => {
localStorage.setItem('find-replace-docked', 'true');
const storage = new window.MarkdownWorkspaceStorage();
@@ -210,6 +215,11 @@ test('reset workspace permanently deletes documents and blocks repeated clicks',
await expect.poll(async () => JSON.stringify(await storedDocuments(page)), {
timeout: 15_000
}).not.toContain('Delete On Reset');
+ await expect(page.locator('.document-tree-row[data-tree-type="folder"]')).toHaveCount(0);
+ await expect.poll(() => page.evaluate(() => {
+ const organization = JSON.parse(localStorage.getItem('markdownViewerDocumentOrganization') || '{}');
+ return Array.isArray(organization.folders) ? organization.folders.length : 0;
+ })).toBe(0);
await expect.poll(() => page.evaluate(() => localStorage.getItem('find-replace-docked'))).toBeNull();
await expect.poll(() => page.evaluate(async () => {
const storage = new window.MarkdownWorkspaceStorage();
diff --git a/tests/helpers/app.js b/tests/helpers/app.js
index 07d67c15..f31a45d9 100644
--- a/tests/helpers/app.js
+++ b/tests/helpers/app.js
@@ -227,10 +227,14 @@ async function stubLazyRendererLibraries(page) {
};
window.mermaid = window.mermaid || {
- initialize() {},
+ __theme: 'default',
+ initialize(options = {}) { this.__theme = options.theme || 'default'; },
render(id) {
+ const isDark = this.__theme === 'dark';
+ const nodeFill = isDark ? '#1f2328' : '#ddf4ff';
+ const textFill = isDark ? '#c9d1d9' : '#24292f';
return Promise.resolve({
- svg: '
'
+ svg: '
'
});
}
};
@@ -346,10 +350,14 @@ async function stubLazyRendererLibraries(page) {
contentType: 'application/javascript',
body: `
window.mermaid = {
- initialize() {},
+ __theme: 'default',
+ initialize(options = {}) { this.__theme = options.theme || 'default'; },
render(id, source) {
+ const isDark = this.__theme === 'dark';
+ const nodeFill = isDark ? '#1f2328' : '#ddf4ff';
+ const textFill = isDark ? '#c9d1d9' : '#24292f';
return Promise.resolve({
- svg: '
'
+ svg: '
'
});
}
};
@@ -523,6 +531,27 @@ async function stubExportLibraries(page) {
ctx.fillText('PNG export test', 20, 40);
return canvas;
};
+ window.jspdf = {
+ jsPDF: class {
+ constructor() {
+ this.internal = {
+ pageSize: {
+ getWidth() { return 210; },
+ getHeight() { return 297; }
+ }
+ };
+ }
+ addPage() {}
+ setFillColor() {}
+ rect() {}
+ addImage() {}
+ save(name) {
+ if (Array.isArray(window.__savedFiles)) {
+ window.__savedFiles.push({ name, type: 'application/pdf', size: 1 });
+ }
+ }
+ }
+ };
});
}