case study

When the browser and the laser disagree

A tool I built for RAYFILM around 2015, still in production. Customers design a sheet of labels in the browser, and the file that browser produces is the file the cutting machine uses. Everything below follows from that one constraint.

Client
RAYFILM
Live since
around 2015
Stack
SVG.js, jQuery
Published
September 2026

RAYFILM sells label sheets, most of them from a catalogue of fixed sizes. Not every label someone needs is in the catalogue, and AnyLabels is the answer to the ones that aren't: any dimension in millimetres, any layout, with customers setting the gutters and margins themselves. You design the sheet in your browser at anylabels.eu, and what you see is what gets cut.

That last part is where the difficulty starts. The SVG the browser produces goes straight to the cutting machine. There is no step in between: nobody opens the file in Illustrator to tidy it up, nothing converts it into some other format along the way, and nothing smooths over a small error. The file that leaves the browser is the path the laser follows. So a shape that is half a millimetre out on screen is half a millimetre out on the sheet, and a sheet that is out by half a millimetre is scrap.

All of it is SVG.js and jQuery. No framework, because in 2015 that was simply what you had.

Why I couldn't just trust the browser

The obvious approach is to trust it. Draw the shape, ask the browser how big it is, use that number.

The trouble is that the answer wasn't always right. Some shapes came out at the size the customer asked for and some came out slightly wrong, and Chrome was the one that differed.

I never found out why, and not for lack of looking. getBBox has a long history of disagreeing between engines, and there is no shortage of candidate explanations: transforms, rotation, whether a stroke counts towards the box, elements measured before they are rendered. I worked through them. Transforms and rotation I could rule out outright, because nothing in a shape definition carries either. The shapes are stored as plain coordinates, and no transform is applied anywhere between storage and measurement. Several of the other explanations fell away the same way. I could see which shapes were wrong and by exactly how much. I never found what the broken ones had in common.

What I could see was where the wrong number came from. Measuring a single element, one circle or one path, gave the right answer everywhere. Asking for the bounding box of the group those elements sat in was what came back wrong. The disagreement was about the composite, not the parts.

There was also nothing to hard-code. The shapes that came out wrong were not wrong by a consistent amount, and each one was off by its own margin. So there was no single correction factor to find by testing and bake in, and no version of this that ends with a number in the source. Whatever the fix was, it had to be worked out per shape, at the moment that shape was built.

For a while I considered moving the rendering to the server. That would have taken the browser out of the argument entirely: one renderer, under my control, producing the same file every time.

It would also have taken the product away. The builder is worth using because the sheet redraws as you change it. Adjust a width by two millimetres and you see straight away how many labels now fit on the page, and whether the layout still works at all. Sending every change to a server and waiting for the result to come back is a different and worse product.

One job that became two

It was meant to be one job. Draw the sheet correctly, and send that same drawing to the laser. That is the whole appeal of building it in SVG: what the customer is looking at and what gets cut are one object, not a preview and an export that have to be kept in step.

Chrome made it two. Getting it right on the page and getting it right in the file stopped being the same thing, and I was responsible for both.

The fixed point in all of it is the customer's own numbers. They type a width and a height in millimetres. That is what they ordered and that is what has to come off the machine, whatever the browser computes along the way.

A shape isn't stored as one drawable object. It is stored as a handful of ordinary SVG primitives (a circle, a path, a line), each with the coordinates and attributes needed to draw that one element correctly. There are around five hundred shapes, all admin-authored for now. So the first job, every time a shape is used, is to walk those pieces and work out the box that contains all of them together.

That storage format turned out to be the way out. Because a shape is a list of individual elements rather than one composite object, I could go through them one at a time, ask each one how far it reached horizontally and vertically, and build the bounding box up myself. No single complicated measurement to trust or distrust: just the furthest edge in each direction, accumulated across pieces the browser measures correctly.

This is the workaround, in full, and it earns being shown rather than described. The obvious design is to draw the pieces into a group and ask that group for its bounding box. It would not have worked: the group's measurement is exactly the thing in question, and scaling against an unreliable number doesn't correct anything. What the code below does instead is get a number worth trusting by never asking the group how big it is:

js
// The box I trust. Every piece is measured on its own, and the results are
// combined by hand. The group is never measured as a whole.
var shapeBBox = { x: 0, y: 0, width: 0, height: 0, scale: 1 };
 
measuringGroup.children().forEach(function (item) {
    var box = item.bbox(),           // one primitive, on its own: reliable
        width = box.width + box.x,   // how far it reaches horizontally
        height = box.height + box.y; // and vertically
 
    // Grow shapeBBox to the smallest box containing every piece seen so far.
    if (shapeBBox.x === 0 || shapeBBox.x > box.x) shapeBBox.x = box.x;
    if (shapeBBox.y === 0 || shapeBBox.y > box.y) shapeBBox.y = box.y;
    if (shapeBBox.width < width) shapeBBox.width = width;
    if (shapeBBox.height < height) shapeBBox.height = height;
 
    // Only now does the piece move into the symbol: it is already measured.
    shapeSymbol.add(item);
});
 
// The accumulated far edges become an actual width and height.
shapeBBox.width -= shapeBBox.x;
shapeBBox.height -= shapeBBox.y;
// shapeBBox is now the shape's real size, assembled from measurements the
// browser gets right, never from the group measurement it gets wrong.
 
// browserBBox is that unreliable measurement, taken anyway: the symbol still
// needs a viewBox to draw with, right or wrong.
var browserBBox = svgDocument.use(shapeSymbol).bbox();
 
// Keep both. shapeBBox is what the shape actually is; browserBBox is what the
// browser will draw with. The difference between them is the correction.
shapeSymbol.remember('shapeBBox', shapeBBox).remember('browserBBox', browserBBox);
shapeSymbol.attr('viewBox', `${browserBBox.x} ${browserBBox.y} ${browserBBox.width} ${browserBBox.height}`);

browserBBox is never compared against the customer's millimetres directly, because that would just be scaling against the same unreliable number under a different name. It only ever gets compared against shapeBBox, to find out by how much the browser is wrong, so that the difference can be divided back out later.

Both boxes are in the shape's own drawing units, not in millimetres yet. A shape might be authored a hundred units wide while the customer wants it forty-five millimetres wide. Millimetres only enter with the customer's input.

remember() is SVG.js attaching your own data to an element and handing it back later. The whole design leans on that small feature, because these numbers have to survive from the moment a shape is built to the moment an order is placed, which can be a long time and several hundred user actions later.

The trustworthy number, shapeBBox, is the one compared with what the customer actually asked for:

js
shapeBBox.scale = shapeBBox.width / userInput.width;

That ratio is design units per millimetre, and everything downstream is built on it: the size the shape is drawn at, the second dimension when the customer only gave one, and the outline being drawn at a consistent weight whatever size the label is.

What the screen gets

A sheet can hold hundreds of labels, and every one of them is the same shape. So the shape is defined once and referenced, not copied: one <symbol> in <defs>, and a <use> for every position on the sheet. Every copy is identical by construction, because there is only one definition for them to be identical to, and the browser has far fewer nodes to deal with each time a changed dimension redraws the whole sheet.

Each of those references gets the customer's millimetres, and one line does the correcting:

js
use.move(x, y).scale(browserBBox.width / shapeBBox.width, browserBBox.height / shapeBBox.height);

That is the line that fixed the display in Chrome, and Chrome is why it exists at all. But nothing in it names a browser, and nothing in it is a number I arrived at by testing. Both boxes are measured when the shape is built, on whatever browser the customer happens to be using, so a browser that measures the group correctly produces a ratio of one and this line does nothing at all. The ones that get it wrong are corrected by exactly their own error. It was written for a Chrome quirk and it never had to know that, which is the reason it still works in browsers that didn't exist when I wrote it.

The screen takes one other liberty worth mentioning: every shape is drawn with a 0.3 mm contour, purely so the preview looks clean and consistent rather than like a set of bare mathematical paths. It has nothing to do with the cut. For the machine it is a problem, which is the next section.

What the machine gets

So the order is not the thing on screen. It is built from a clone of the live document, and then a short list of changes turns a drawing meant for a person into a file meant for a machine.

The interesting one is the stroke.

SVG gives you no way to say which side of a line its thickness falls on. A stroke is centred on the path, half of it inside the outline and half outside, and the property that would have let you choose was dropped from SVG 2 into a separate module that no browser ever implemented. It is as unavailable now as it was then. So the 0.3 mm contour that makes the preview look clean also makes the shape 0.15 mm bigger on every side, which is 0.3 mm across the label. On a preview that is invisible. On a machine working in millimetres it is not.

Setting the width to 0.0001 instead of to zero is the whole trick, and it is one line, applied to every element of every shape in the exported copy:

js
SVG.adopt(element).stroke({ width: 0.0001, color: element.getAttribute('stroke') });

That is far too small to move the geometry by anything the machine could resolve, and it is still a stroke, so the laser still registers the line and cuts it.

The colour survives, and it has to, because the colour is how the file tells the machine what sort of cut this is. There are about five: black for a straight cut through, and a handful of others for perforations and half cuts, matched to settings on the laser itself. So one line throws the width away and keeps the colour, which is exactly right: the width was presentation, and the colour is instruction.

Every <use> is then flattened into real geometry, because cutter software won't necessarily resolve a reference, and the sheet is declared in millimetres rather than pixels. There is also one smaller correction in there: on screen the symbol has its aspect ratio forced off, and the machine needs the shape's real proportions back. The value it had before the workaround is the one kept on the shape itself, which is why it had to be stored at the time rather than worked out again later.

The exported file also carries a short reference line at the sheet origin:

js
controlLine = svgClone.line(0, 0, 10, 0)
    .fill('none')
    .stroke(strokes[0])
    .id('control-line');

Ten millimetres, at a known corner, in the first cut colour. It is a calibration mark: the machine uses it to establish where the cut begins.

The hundred small things that keep the two in step

Most of the work in a system like this isn't the one clever fix. It is the long tail of small corrections that keep the preview honest and the file correct, each of which is obvious once found and invisible until something comes out wrong.

Scaling a shape scales its contour along with it. A 20 mm label and a 200 mm one would be drawn with visibly different line weights on screen, even though the weight on screen has no bearing on what gets cut. So the stroke is counter-scaled to keep the preview eye-pleasing at any size:

js
function scaleStroke(shapeSymbol) {
    var shapeBBox = shapeSymbol.remember('shapeBBox'),
        strokeWidth = 0.3 * shapeBBox.scale;
 
    shapeSymbol.each(function () {
        this.stroke({ width: strokeWidth });
    });
}

0.3 isn't a tuning value. It is 0.3 millimetres, the width the contour should appear at, and multiplying it by shapeBBox.scale converts that physical measurement into the shape's own coordinate space. The contour then looks the same weight at every label size.

None of this reaches the machine. The export drops every stroke to a hairline before the file leaves the browser.

The excerpts above are real code, trimmed to the lines that carry the argument and with variables renamed to say what they hold: shapeBBox is itemsBox in the codebase, browserBBox is groupBox, and userInput is layout.size.

What it cost

The layout engine is one large file, and it should be three.

The bigger cost is that there are two outputs at all. Once it was clear the screen and the machine needed different files, the only thing left to control was how much of the code produced both. So the export isn't a second rendering path: it is built from a clone of the live document, the same shapes positioned by the same layout code with the same numbers, and then a short, explicit list of changes applied at the end. One source of truth, with the divergence pushed as late as it will go. That is not as good as one output, but it is the closest thing available once the browser has decided otherwise.

And I never did find out what was wrong with those shapes. I am not going to pretend that is satisfying. It is simply that a fix which doesn't depend on the diagnosis turned out to be worth more than the diagnosis would have been.

How it aged

It went live around 2015. It is still running, still cutting, and I haven't touched the precision code in ten years.

I am rewriting the platform now, on .NET 9 and Next.js. Most of the original code goes, not because it was wrong but because there are frameworks now that it would be a mistake not to use. jQuery goes with it, and so does the scaffolding I wrote to fake modules in a language that didn't have them yet. Both were answers to problems that no longer exist.

What survives is the design underneath. Where the boundaries sit, which piece is responsible for what, how the whole thing is divided up: that carries over to TypeScript more or less intact, because it was never really about the language. Most of the rewrite has been expressing the same decisions in a stack that supports them properly.

SVG.js did stay. It stayed because it does the thing this application is actually about, putting exact geometry on a page, and that problem hasn't changed since 2015. When I pick dependencies now, that is the test: is this solving the problem itself, or only making the code around it more comfortable to write. The comfortable ones don't survive a rewrite.

That rewrite is the part I find interesting now. Until now I had been using AI for maintaining existing projects and for building my own private, still-unfinished projects; this is the first customer project I have built from scratch this way. Whether rules I have been applying for twenty-five years survive that is a different story, and it is the one I want to write next.

Working on something with constraints like these?

Precision, legacy systems, one senior developer covering what used to take a team: this is the kind of problem I like being handed.

Tell me about your project

← More case studies