Skip to content

Commit 3eb331a

Browse files
authored
fix(export): scope MathJax PNG rasterization and harden PDF export (#2707)
Follow-up to #2701. - Scope convertAllSvgToPng to 'mjx-container svg' so only MathJax output is rasterized. Other inline SVGs (Mermaid/Graphviz/Flowchart/WaveDrom) stay vector and avoid the Mermaid foreignObject canvas taint that made toDataURL() throw and hang the export. - Harden the per-equation onload path with try/catch/finally so a single failure always decrements the pending counter and can never strand the pdfRenderReady signal. - Preserve on-screen color by inlining computed currentColor; scale the raster by devicePixelRatio (min 2x) for crisper output. - Add a bounded 30s wait around pdfRenderReady so a missing callback falls through to printToPdf instead of freezing the export; bind the connection to a context object. - Strip accidental UTF-8 BOM from webviewexporter.cpp and exportdialog2.cpp.
1 parent 5718c52 commit 3eb331a

3 files changed

Lines changed: 87 additions & 43 deletions

File tree

src/data/extra/web/js/mathjax.js

Lines changed: 71 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -172,54 +172,86 @@ class MathJaxRenderer extends VxWorker {
172172
this.finishWork();
173173
}
174174

175+
// Rasterize MathJax's SVG output to PNG before PDF export.
176+
//
177+
// Qt WebEngine's printToPdf renders MathJax's <use>-referenced SVG glyphs
178+
// with the wrong font, so each equation is flattened to a PNG first (issue
179+
// #2681). The scope is deliberately limited to MathJax containers: other
180+
// inline SVGs (Mermaid, Graphviz, Flowchart, WaveDrom, ...) must stay
181+
// vector, and some of them (e.g. Mermaid's <foreignObject>) taint the
182+
// canvas, which would make toDataURL() throw.
175183
convertAllSvgToPng() {
176-
177-
let svgs = this.vxcore.contentContainer.querySelectorAll('svg');
184+
let container = this.vxcore.contentContainer;
185+
let svgs = container ? container.querySelectorAll('mjx-container svg') : [];
178186
if (svgs.length == 0) {
179187
window.vxMarkdownAdapter.onPdfRenderReady();
180188
return;
181189
}
182190

183191
let pending = svgs.length;
192+
// Runs exactly once per SVG so a failure on one equation can never
193+
// strand the counter and hang the export.
194+
let finalizeOne = function () {
195+
if (--pending == 0) {
196+
window.vxMarkdownAdapter.onPdfRenderReady();
197+
}
198+
};
199+
200+
// At least 2x, higher on Hi-DPI displays, to keep the raster crisp.
201+
let scale = Math.max(2, window.devicePixelRatio || 1);
184202
svgs.forEach(function (svg) {
185-
let serializer = new XMLSerializer();
186-
let svgStr = serializer.serializeToString(svg);
187-
let canvas = document.createElement('canvas');
188-
let bbox = svg.getBoundingClientRect();
189-
let scale = 2;
190-
canvas.width = bbox.width * scale;
191-
canvas.height = bbox.height * scale;
192-
let ctx = canvas.getContext('2d');
193-
194-
let img = new Image();
195-
let svgBlob = new Blob([svgStr], { type: 'image/svg+xml;charset=utf-8' });
196-
let url = URL.createObjectURL(svgBlob);
197-
img.onload = function () {
198-
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
199-
URL.revokeObjectURL(url);
200-
let pngDataUrl = canvas.toDataURL('image/png');
201-
202-
let pngImg = document.createElement('img');
203-
pngImg.src = pngDataUrl;
204-
pngImg.style.width = bbox.width + 'px';
205-
pngImg.style.height = bbox.height + 'px';
206-
pngImg.setAttribute('data-math-png', 'true');
207-
208-
svg.parentNode.replaceChild(pngImg, svg);
209-
210-
pending--;
211-
if (pending == 0) {
212-
window.vxMarkdownAdapter.onPdfRenderReady();
213-
}
214-
};
215-
img.onerror = function () {
216-
URL.revokeObjectURL(url);
217-
pending--;
218-
if (pending == 0) {
219-
window.vxMarkdownAdapter.onPdfRenderReady();
203+
let url = null;
204+
try {
205+
// Inline the resolved color so `fill: currentColor` keeps the
206+
// on-screen color once the SVG is loaded as a standalone image.
207+
svg.style.color = window.getComputedStyle(svg).color;
208+
let svgStr = new XMLSerializer().serializeToString(svg);
209+
210+
let bbox = svg.getBoundingClientRect();
211+
let width = Math.max(1, Math.ceil(bbox.width));
212+
let height = Math.max(1, Math.ceil(bbox.height));
213+
214+
let canvas = document.createElement('canvas');
215+
canvas.width = width * scale;
216+
canvas.height = height * scale;
217+
let ctx = canvas.getContext('2d');
218+
219+
let svgBlob = new Blob([svgStr], { type: 'image/svg+xml;charset=utf-8' });
220+
url = URL.createObjectURL(svgBlob);
221+
222+
let img = new Image();
223+
img.onload = function () {
224+
try {
225+
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
226+
227+
let pngImg = document.createElement('img');
228+
pngImg.src = canvas.toDataURL('image/png');
229+
pngImg.style.width = width + 'px';
230+
pngImg.style.height = height + 'px';
231+
pngImg.setAttribute('data-math-png', 'true');
232+
233+
if (svg.parentNode) {
234+
svg.parentNode.replaceChild(pngImg, svg);
235+
}
236+
} catch (err) {
237+
console.error('failed to rasterize MathJax SVG', err);
238+
} finally {
239+
URL.revokeObjectURL(url);
240+
finalizeOne();
241+
}
242+
};
243+
img.onerror = function () {
244+
URL.revokeObjectURL(url);
245+
finalizeOne();
246+
};
247+
img.src = url;
248+
} catch (err) {
249+
console.error('failed to prepare MathJax SVG for rasterization', err);
250+
if (url) {
251+
URL.revokeObjectURL(url);
220252
}
221-
};
222-
img.src = url;
253+
finalizeOne();
254+
}
223255
});
224256
}
225257

src/export/webviewexporter.cpp

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1-
#include "webviewexporter.h"
1+
#include "webviewexporter.h"
22

33
#include <QDir>
4+
#include <QElapsedTimer>
45
#include <QFileInfo>
56
#include <QProcess>
67
#include <QRegularExpression>
@@ -653,20 +654,31 @@ bool WebViewExporter::doExportPdf(const ExportPdfOption &p_pdfOption, const QStr
653654
ExportState state = ExportState::Busy;
654655

655656
bool pdfRenderReady = false;
656-
QMetaObject::Connection conn = connect(m_viewer->adapter(), &MarkdownViewerAdapter::pdfRenderReady,
657-
[&pdfRenderReady]() { pdfRenderReady = true; });
657+
QMetaObject::Connection conn =
658+
connect(m_viewer->adapter(), &MarkdownViewerAdapter::pdfRenderReady, this,
659+
[&pdfRenderReady]() { pdfRenderReady = true; });
658660

659661
m_viewer->page()->runJavaScript(
660662
"if (typeof vxcore !== 'undefined' && vxcore.getWorker('mathjax')) {"
661663
" vxcore.getWorker('mathjax').convertAllSvgToPng();"
662664
"} else { window.vxMarkdownAdapter.onPdfRenderReady(); }");
663665

666+
// Bounded wait: if the render-ready signal never arrives (JS bridge not
667+
// ready, an uncaught error in the page, ...), fall through to printToPdf
668+
// instead of freezing the export. Worst case the math stays as SVG.
669+
const qint64 c_pdfRenderReadyTimeoutMs = 30000;
670+
QElapsedTimer renderTimer;
671+
renderTimer.start();
664672
while (!pdfRenderReady) {
665673
Utils::sleepWait(100);
666674
if (m_askedToStop) {
667675
disconnect(conn);
668676
return false;
669677
}
678+
if (renderTimer.hasExpired(c_pdfRenderReadyTimeoutMs)) {
679+
qWarning() << "timed out waiting for PDF math rasterization; proceeding";
680+
break;
681+
}
670682
}
671683
disconnect(conn);
672684

src/widgets/dialogs/exportdialog2.cpp

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
#include "exportdialog2.h"
1+
#include "exportdialog2.h"
22

33
#include <QCheckBox>
44
#include <QComboBox>

0 commit comments

Comments
 (0)