Skip to content

Commit c852c01

Browse files
committed
Handle missing canvas context in SvgWrapper
Guard SVG text measurement when canvas 2D contexts are unavailable in headless environments and add a regression test for the fallback path.
1 parent a35b621 commit c852c01

2 files changed

Lines changed: 88 additions & 0 deletions

File tree

src/SvgWrapper.js

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -835,6 +835,10 @@ export default class SvgWrapper {
835835
static measureText(text, fontSize, fontFamily, lineHeight = 0.9) {
836836
const element = document.createElement('canvas');
837837
const ctx = element.getContext('2d');
838+
if (!ctx) {
839+
return SvgWrapper.estimateTextSize(text, fontSize, lineHeight);
840+
}
841+
838842
ctx.font = `${fontSize}pt ${fontFamily}`;
839843
let textMetrics = ctx.measureText(text);
840844

@@ -845,6 +849,33 @@ export default class SvgWrapper {
845849
};
846850
}
847851

852+
static estimateTextSize(text, fontSize, lineHeight = 0.9) {
853+
let width = 0;
854+
855+
for (const char of String(text)) {
856+
if (char === ' ') {
857+
width += fontSize * 0.35;
858+
}
859+
else if (/[A-Z]/.test(char)) {
860+
width += fontSize * 0.68;
861+
}
862+
else if (/[a-z]/.test(char)) {
863+
width += fontSize * 0.56;
864+
}
865+
else if (/[0-9]/.test(char)) {
866+
width += fontSize * 0.55;
867+
}
868+
else {
869+
width += fontSize * 0.45;
870+
}
871+
}
872+
873+
return {
874+
width,
875+
height: fontSize * 2 * lineHeight,
876+
};
877+
}
878+
848879
/**
849880
* Convert an SVG to a canvas. Warning: This happens async!
850881
*

test/unit/SvgWrapper.test.js

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import {afterEach, describe, expect, it} from 'vitest';
2+
import {JSDOM} from 'jsdom';
3+
import Parser from '../../src/Parser.js';
4+
import SvgDrawer from '../../src/SvgDrawer.js';
5+
import SvgWrapper from '../../src/SvgWrapper.js';
6+
7+
let restoreGetContext = null;
8+
9+
function setupDom() {
10+
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>');
11+
global.document = dom.window.document;
12+
global.window = dom.window;
13+
return dom;
14+
}
15+
16+
function disableCanvasContext(dom) {
17+
const proto = dom.window.HTMLCanvasElement.prototype;
18+
const original = proto.getContext;
19+
proto.getContext = () => null;
20+
restoreGetContext = () => {
21+
proto.getContext = original;
22+
};
23+
}
24+
25+
afterEach(() => {
26+
if (restoreGetContext) {
27+
restoreGetContext();
28+
restoreGetContext = null;
29+
}
30+
});
31+
32+
describe('SvgWrapper', () => {
33+
it('falls back to estimated text size when canvas is unavailable', () => {
34+
const dom = setupDom();
35+
disableCanvasContext(dom);
36+
37+
const dims = SvgWrapper.measureText('CH3', 11, 'Helvetica');
38+
39+
expect(dims.width).toBeGreaterThan(0);
40+
expect(dims.height).toBeGreaterThan(0);
41+
});
42+
43+
it('renders SVG without canvas text metrics support', () => {
44+
const dom = setupDom();
45+
disableCanvasContext(dom);
46+
47+
const svg = dom.window.document.createElementNS('http://www.w3.org/2000/svg', 'svg');
48+
svg.setAttributeNS(null, 'id', 'test-svg');
49+
dom.window.document.body.appendChild(svg);
50+
51+
const tree = Parser.parse('N[C@@H](C)C(=O)O');
52+
const drawer = new SvgDrawer({isomeric: true});
53+
54+
expect(() => drawer.draw(tree, svg, 'light', false)).not.toThrow();
55+
expect(drawer.preprocessor.graph.vertices.length).toBeGreaterThan(0);
56+
});
57+
});

0 commit comments

Comments
 (0)