Build and push server image / build-and-push (push) Successful in 2m6s
exportToSvg's font-embedding path (base64 @font-face rules) goes through the browser FontFace API, which jsdom doesn't implement -- that's what crashed fontFacesStylesGenerator after the previous global-shimming fix got past startup. skipInliningFonts avoids that path entirely; resvg already falls back to system fonts for rasterizing regardless, so embedded fonts were never going to affect the final PNG.
139 lines
6.0 KiB
JavaScript
139 lines
6.0 KiB
JavaScript
// Local render sidecar for whiteboard frame mode: turns Excalidraw scene
|
|
// JSON (the format Nextcloud Whiteboard's .whiteboard files use --
|
|
// {"elements", "appState", "files"}, see app/webdav_client.py) into a
|
|
// PNG, using the real Excalidraw export code rather than a hand-rolled
|
|
// reimplementation of its element types/styling/fonts. That's the whole
|
|
// reason this exists as Node rather than more Python: @excalidraw/utils
|
|
// IS the renderer real whiteboards are drawn with, so this reproduces
|
|
// whatever a user actually sees in their whiteboard exactly, and never
|
|
// drifts out of sync with new element types as Excalidraw adds them.
|
|
//
|
|
// Runs as a second process inside the main Python server's own
|
|
// container (see ../Dockerfile installing Node, and ../start.sh
|
|
// launching this in the background before exec'ing uvicorn) -- not a
|
|
// separate deployment, no independent scaling/restart needs, so one
|
|
// container is simpler than a second docker-compose service. Bound to
|
|
// 127.0.0.1 only: reachable from the Python process in the same
|
|
// container, never from outside it, so there's no auth on top of that
|
|
// -- the network boundary IS the access control here.
|
|
//
|
|
// No headless browser (Puppeteer/Playwright) -- jsdom provides just
|
|
// enough of a browser-like global environment for @excalidraw/utils'
|
|
// internal DOM calls (e.g. text measurement) to work, and
|
|
// @resvg/resvg-js (a native Rust SVG rasterizer, no browser process)
|
|
// turns the resulting SVG into the actual PNG.
|
|
|
|
const { JSDOM } = require('jsdom');
|
|
|
|
// @excalidraw/utils is a browser bundle: it references things like
|
|
// `devicePixelRatio` and `location` as bare identifiers, the same way
|
|
// inline <script> code in a real page would resolve them off the global
|
|
// scope -- not as `window.devicePixelRatio`. Copying jsdom's entire
|
|
// `window` onto Node's `global` (not just window/document/navigator) is
|
|
// what makes those bare references resolve at all; without it, the first
|
|
// one touched throws a ReferenceError. `pretendToBeVisual` is what makes
|
|
// jsdom actually populate devicePixelRatio/requestAnimationFrame in the
|
|
// first place -- both are left undefined otherwise.
|
|
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>', { pretendToBeVisual: true });
|
|
for (const key of Object.getOwnPropertyNames(dom.window)) {
|
|
if (key in global) continue;
|
|
try {
|
|
global[key] = dom.window[key];
|
|
} catch {
|
|
// a handful of window properties throw on read outside a real
|
|
// browser (e.g. some storage/permissions getters) -- skip those
|
|
// rather than let one bad property crash startup entirely
|
|
}
|
|
}
|
|
global.window = dom.window;
|
|
global.document = dom.window.document;
|
|
global.navigator = dom.window.navigator;
|
|
|
|
// jsdom doesn't implement matchMedia -- Excalidraw's bundle calls it
|
|
// unconditionally (theme/print-media detection), so without a stub this
|
|
// is the next ReferenceError-shaped crash after the one above.
|
|
if (typeof global.window.matchMedia !== 'function') {
|
|
const stubMatchMedia = () => ({
|
|
matches: false,
|
|
media: '',
|
|
addListener: () => {},
|
|
removeListener: () => {},
|
|
addEventListener: () => {},
|
|
removeEventListener: () => {},
|
|
dispatchEvent: () => false,
|
|
});
|
|
global.window.matchMedia = stubMatchMedia;
|
|
global.matchMedia = stubMatchMedia;
|
|
}
|
|
|
|
const express = require('express');
|
|
const { exportToSvg } = require('@excalidraw/utils');
|
|
const { Resvg } = require('@resvg/resvg-js');
|
|
|
|
const PORT = process.env.RENDER_SERVICE_PORT || 3001;
|
|
const HOST = '127.0.0.1';
|
|
// A whiteboard scene is normally tiny (KB, not MB) -- this is a sanity
|
|
// cap against something going wrong upstream, not a real expected size.
|
|
const MAX_BODY_BYTES = 25 * 1024 * 1024;
|
|
|
|
const app = express();
|
|
app.use(express.json({ limit: MAX_BODY_BYTES }));
|
|
|
|
app.get('/health', (req, res) => {
|
|
res.json({ status: 'ok' });
|
|
});
|
|
|
|
app.post('/render', async (req, res) => {
|
|
const { elements, appState, files, width, height } = req.body || {};
|
|
if (!Array.isArray(elements)) {
|
|
res.status(400).json({ error: 'elements must be an array (a parsed .whiteboard/Excalidraw scene)' });
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const svg = await exportToSvg({
|
|
elements,
|
|
appState: appState || {},
|
|
files: files || {},
|
|
exportPadding: 20,
|
|
// Font embedding (base64 @font-face rules in the SVG's <defs>) goes
|
|
// through the browser's FontFace API, which jsdom doesn't implement
|
|
// and can't be meaningfully polyfilled here -- and we don't need
|
|
// it anyway: resvg (below) already falls back to whatever fonts
|
|
// fontconfig finds (see ../Dockerfile's fonts-dejavu-core), so
|
|
// embedded fonts were never going to make it into the final PNG.
|
|
skipInliningFonts: true,
|
|
});
|
|
// Depending on the installed version, exportToSvg resolves to either
|
|
// an SVGSVGElement (needs serializing) or already a string -- handle
|
|
// both rather than assume, since this isn't runtime-tested against a
|
|
// live install in this environment (no Node available to verify
|
|
// during development, see the server README's whiteboard mode notes).
|
|
const svgString = typeof svg === 'string' ? svg : svg.outerHTML;
|
|
|
|
const targetWidth = Number(width) || undefined;
|
|
const resvg = new Resvg(svgString, {
|
|
fitTo: targetWidth ? { mode: 'width', value: targetWidth } : { mode: 'original' },
|
|
background: 'rgba(255, 255, 255, 1)',
|
|
font: {
|
|
// No bundled Excalidraw font assets (Virgil/Cascadia) in v1 --
|
|
// text renders in whatever fonts fontconfig finds in the image
|
|
// (see ../Dockerfile's fonts-dejavu-core), not pixel-identical
|
|
// to the browser editor's handwriting-style font. Good enough
|
|
// for "what does the board say", not a design-fidelity tool.
|
|
loadSystemFonts: true,
|
|
},
|
|
});
|
|
const pngBuffer = resvg.render().asPng();
|
|
res.set('Content-Type', 'image/png');
|
|
res.send(pngBuffer);
|
|
} catch (err) {
|
|
console.error('Whiteboard render failed:', err);
|
|
res.status(500).json({ error: String((err && err.message) || err) });
|
|
}
|
|
});
|
|
|
|
app.listen(PORT, HOST, () => {
|
|
console.log(`whiteboard-render listening on ${HOST}:${PORT}`);
|
|
});
|