RenCrafted DXF drawings can freeze a browser tab. Charge work when it is allocated, slice drawing by time, and size each step by its real cost.
I run a DXF viewer that works entirely in the browser. DXF is the text format CAD programs use to exchange drawings. The parser (from dxf-viewer; MIT and MPL 2.0) runs in a Web Worker, the drawing is built there and handed to the page, and the page draws it on a canvas. The file never goes to a server.
The files people open are drawings someone else sent them. A DXF file (the text kind) is just text, so anyone can write any drawing by hand. I wanted a crafted drawing to be unable to freeze or crash the tab. The part that took far longer than I expected was deciding what to count when setting a limit. This post is about that.
DXF lets you define a group of entities as a block and place it any number of times with INSERT. An INSERT inside a block nests. A MINSERT places a block as a whole grid in one go.
Make a block B0 of 10 lines, a block B1 that inserts B0 ten times, B2 that inserts B1 ten times, and so on to B7, then insert B7 once. The file is 5.5 KB. Expanded naively, it is 10^8 lines: one hundred million.
A few of the samples from my tests (all written from scratch by a script):
| Sample | File | Built naively |
|---|---|---|
| Blocks of 10 lines nested 7 deep, 10 inserts each | 5.5 KB | 100 million lines |
| A 20,000-character MTEXT in a 100 × 100 array | 21 KB | 200 million characters |
| An MTEXT line of 1,000 colour-changing runs, 14 × 14 | 7.5 KB | 196,000 <text> in the print SVG |
| 1,000 long lines, 40 × 40 | 78 KB | 1.6 million lines across the screen |
| A self-intersecting 5,000-vertex star fill, 30 × 30 | 115 KB | 900 fills taking about 0.9 s each at full screen |
The builder (in the Worker) had a work budget from the start. It counts the coordinates, fills and text that enter the drawing; when the budget runs out it stops, and the page says "Too many objects: stopped after drawing about N lines, texts and other pieces."
At first it counted what was accepted into the drawing: the points of lines that would be drawn, the characters of text. A natural design. But every review round found one more path that did work without paying for it, five rounds in a row. Here are the paths, with a sample that hits each:
%%u, wrapped in braces). No characters are accepted, but parsing them costs time and memory.1e999 (which parses to Infinity), inside a block. The cache that builds a block's points once and reuses them was building points even for coordinates that could not be used.The five were in different places but had the same shape: the moment work was done and the moment it was paid for were different. With "pay on acceptance" or "ask first without paying", a copy made and then refused, an intermediate array, or a cache will always find a way to do work for free.
So there is now one place that pays, and it pays at the moment of allocation:
/**
* The only charge: n units for what is about to be allocated, charged now,
* whatever becomes of it (refused or discarded later, it is not refunded).
* Past the budget it refuses, and from then on everything stops.
*/
alloc(n: number): boolean {
if (this.stop) return false;
if (!(this.work + n <= this.limits.maxWork)) {
this.stop = true;
return false;
}
this.work += n;
return true;
}
Producers whose point count depends on the input (bulges, splines, hatch edges and patterns) count the points first, alloc() them, then make them. A block's entities are built once (and paid for then); each INSERT's copy is paid for before it is written. If a copy turns out unusable after it was written, it is dropped and the charge stays.
Entities whose coordinates cannot be read (1e999, NaN, beyond 1e15, scale 0, absurd angles) are skipped at the entrance without making anything, and counted as "unreadable coordinates".
Weights follow what is stored: a coordinate pair is one unit; a text is 32 units plus its runs, lines and characters. Looking at an entity or expanding a block is also one unit (that one is for time, not memory, and it closes the first path). The budget is 8 million units. The 100-million-line sample now stops after 2.48 million lines (1.6 s from reading to building, in Node), draws what it has, and says it stopped.
With the builder bounded, I set limits on the page: at most 4,096 draw batches (layer × colour × linetype), a print SVG of at most 64 MB and 200,000 elements, a layer menu of at most 300 rows. Review routed around these too.
1,000 long lines, 40 × 40 (78 KB). 1.6 million lines fit in the budget, in a single batch. Handed to one canvas stroke(), they freeze the page. As an experiment, stroking all 1.6 million in one stroke() on a 1,200 × 800 canvas in Chromium took 6.3 s at a width just under one pixel (0.999); at 1.25 device pixels (the normal width on a 2× screen) it had not finished after 170 s.
An MTEXT line of 1,000 colour runs, 14 × 14 (7.5 KB). The print SVG is 26 MB with 196,000 <text>, under both 64 MB and 200,000 elements. Just displaying that SVG in Chromium took the renderer from 81 MB to 1.9 GB.
Both limits counted the wrong thing. I wanted to bound time, and counted segments. I wanted to bound memory, and counted elements and characters.
Bound time with time. The drawing is cut into chunks with fixed upper limits (1,024 segments of lines, 256 arcs, and so on). A draw strokes chunk after chunk until about 20 ms have passed, then continues on the next animation frame. A zoom, pan or layer toggle drops the draw in progress and starts over. Stroking the same 1.6 million lines 1,024 at a time took at most 24 ms per chunk even at 1.25 px (27.5 s in all).
The viewer adds two more things. In a batch of very many long lines, lines that coincide to a pixel on screen are drawn once (with this drawing fitted to a 1,200 × 800 view, 960,000 of the 1.6 million). And a heavy drawing (more than 20,000 segments and arcs on the shown layers) is drawn no wider than one pixel, even on a 2× screen. Opened in the viewer, this drawing takes 4–10 s to finish and 16–30 s to redraw after a zoom, with main-thread stalls of at most about 0.2 s (0.14–0.21 s over several runs), and the page stays usable.
Bound memory with memory. The print SVG is built while estimating the renderer's memory, with weights per element kind measured in Chromium:
// Measured from the renderer's memory when printing an A3 sheet to PDF (above an empty sheet)
export const PRINT_COST = { element: 1500, text: 9600, char: 150, position: 1300, byte: 13 };
export const MAX_PRINT_BYTES = 400 * 1024 * 1024;
Past 400 MB of estimate it stops and says the drawing is too large to convert for printing. The largest print SVG among the real drawings I have is 13 MB.
Time slicing was not the end. Fill chunks were cut at "at most 1,024 loops".
Filling one self-intersecting star of 5,000 vertices (every edge spans the whole shape) across a 1,200 × 800 canvas takes 0.85 s in Chromium with the nonzero rule, which the viewer uses for a single loop (2.1 s with even-odd). A 20,000-vertex star takes 11.7 s with even-odd. Meanwhile 2,000 small circles fill in 4 ms. The number of loops says almost nothing about the cost. The 900 stars of the 30 × 30 grid fit in one step, and in review that step froze the page for over 300 s.
My working model is that the cost is decided by how many edges cross each row of the screen. Assuming each row's crossings have to be sorted, n crossings cost about n log n. I count that as scan work, and checked the model against measurements (with even-odd, the 5,000-vertex star is about 30 million units and 2.1 s, the 20,000-vertex star about 140 million units and 11.7 s: 72 ns and 83 ns per unit, close enough):
// Scan work of filling (or clipping by) loops: for each screen row crossed by n edges,
// n·log₂n, plus one per edge. k is the view scale; ty and H place the rows on screen.
function scanUnits(c: ArrayLike<number>, loops: [number, number][], k: number, ty: number, H: number): number {
const rows = new Int32Array(H + 2); // edges crossing each row, as a difference array
let edges = 0;
const rowOf = (v: number) => Math.min(Math.max(ty - c[v * 2 + 1] * k, 0), H);
for (const [s, e] of loops) {
for (let v = s; v < e; v++) {
let y0 = rowOf(v), y1 = rowOf(v + 1 < e ? v + 1 : s);
if (y0 > y1) [y0, y1] = [y1, y0];
rows[Math.ceil(y0)]++;
rows[Math.ceil(y1)]--;
edges++;
}
}
let n = 0, units = edges;
for (let r = 0; r <= H; r++) {
n += rows[r];
if (n > 0) units += n * Math.log2(Math.max(2, n));
}
return units;
}
(The real one skips the array when few rows are crossed, among other details.)
Measured in Chromium across fill and clip, both fill rules, at 1× and 2×, one unit took 5–138 ns. Going by the slowest, a step is 200,000 units (about 30 ms), and any single fill over 2 million units (about 0.3 s) is drawn as its outline instead, with a note under the drawing: "Filled shapes drawn as their outline only, too heavy to fill: N". Among the 34 real drawings I have, none were outlined.
Slicing by time still leaves the same hole if the size of a step is a count. The cap on a step should be an estimate of the operation's real work, calibrated by measurement.
At first, the time slicing measured the wrong thing:
const t0 = performance.now();
while (performance.now() - t0 < 20) drawNextChunk(); // "drew for 20 ms"
Canvas calls are recorded when you make them and rasterised later, all at once. Stroking long lines 1,024 at a time, 429 chunks (440,000 lines) were "drawn" within 20 ms, and reading back a single pixel afterwards took 1.7 s. Recording raced ahead; the rasterising arrived at the end of the frame.
So after each step, the renderer reads one pixel, which makes that step's drawing actually happen, and only then checks the clock:
while (performance.now() - t0 < SLICE_MS) {
const r = pass.next(); // draw one step
this.ctx.getImageData(0, 0, 1, 1); // make it happen now; time is measured up to here
if (r.done) { /* finished */ return; }
}
requestAnimationFrame(() => this.slice(pass));
If the bounding breaks, a crafted sample hangs the test run itself. So each build runs in a process of its own, and the test checks things like the time (8 s), the peak memory (1 GB), the size handed to the page (200 MB) and that it stayed within budget. There are 44 such samples now.
One more trap here: The child was started with spawnSync('npx', ['tsx', ...], { timeout }), and a timeout killed only npx. The tsx and node under it lost their parent and kept burning CPU for hours. Now node is started directly (process.execPath with --import tsx), and a timeout is SIGKILL.
The browser test sets a 20 ms setInterval on the page and records the longest gap between ticks while opening, zooming and toggling a layer; over 600 ms fails. Measured again for this post, on the long lines, the heavy-print MTEXT, the 900 stars and 1.6 million distinct large circles: the longest stall was 116–214 ms at both 1× and 2×.
The viewer runs entirely in the browser: zoom, pan, toggle layers, save a PNG, and print fitted to one A4 or A3 sheet (or save it as a PDF from the print dialog). Files are never sent to a server.
If you find a drawing that freezes it, or an ordinary drawing that gets the "Too many objects" or "outline only" note, I would like to hear about it. Every sample here was written by me, so real drawings surely have shapes I have not met yet.
This article was written with the help of AI (Claude). The code and details come from the actual implementation and were checked before publishing.