Skip to main content
Auric Artisan · Documentation

SVG Recolor Developer Reference

Maintain the SVG Recolor bench: a rewrite planned entirely from the original file so two colours can trade places, a parser that returns nothing rather than guessing black, a count that is the number of things that will change, and the sanitiser that makes a stranger’s file safe to look at.

Published: June 7, 2026 Updated: June 7, 2026 Category: Reference Author: Chirag Bansal
Back to Documentation Auric Artisan Home

Overview

The SVG Recolor bench is a self-contained browser runtime. It parses an SVG, finds every place a colour is written, rewrites those places, and reports how many of them actually changed. Nothing is uploaded and no network call is made after the page loads.

This rebuild is unlike the three before it. Most of the arithmetic was already right, and that was established by measurement before anything was touched — CIEDE2000 against the published pairs, OKLab against Ottosson’s reference values, WCAG 2 exact, the sRGB round trip exact. What was wrong was everything that happened to a colour after it had been found: the tool reported changes it had not made.

Four of those were separate defects, each reproduced on the shipped page before it was replaced. Two colours could not trade places. A colour written as a name was listed, offered and never touched. A three-digit source came back a different colour. And the count beside each colour ranked authoring style rather than use.

The arithmetic and the rewrite live in js/tool/sv/sv-core.js, which touches no DOM beyond five node accessors and is what the test suite runs. The provenance of every figure lives in js/tool/sv/sv-sources.js. The page itself holds no numbers.

Table of contents

  1. Code map
  2. What was found, and how
  3. Why the rewrite is planned before it is applied
  4. Reading a colour, and refusing to guess
  5. What the count used to mean
  6. Making a stranger's file safe to look at
  7. The register
  8. Tests, and proving them

1. Code map

+
FileHoldsDepends on
js/tool/sv/sv-core.js Parsing, the occurrence walk, the plan, the rewrite, the sanitiser, and all the colour arithmetic. Exposes window.AASVCore. Nothing. Five DOM accessors are called on whatever nodes it is handed — querySelectorAll, getAttribute, setAttribute, removeAttribute, removeChild — so the tests drive it with a stand-in document.
js/tool/sv/sv-sources.js The register: 15 entries saying where each figure comes from, and the six corrections with the reading each used to give. Exposes window.AASVSources. Nothing.
js/tool/svg-recolor-views.js Every table and panel the page renders: the mapping table, the three evidence tables, the contrast and closest-pair tables, the register rail, the corrections, the nine citations and the how-it-works steps. AASVCore, AASVSources.
js/tool/svg-recolor.js State, loading, the artwork preview, the colour list, the six tabs and the exports. Exposes window.AASVGRecolour. All three above.
css/svg-recolour-shell.css The pinned-dark deck and everything specific to this bench. Loaded after css/anz-shell.css, so it has the last word. —
tests/colorimetry/sv-*.js 82 tests. sv-harness.js loads both shipped files under vm and provides the stand-in document. The shipped files, by path. Not a copy of their logic.

The core never reaches for the document it is working on. Everything it needs arrives as an argument, which is what lets the whole rewrite be tested without a browser — and what caught the one thing the stand-in could not do: when the sanitiser was added, the tests failed on a missing removeChild rather than passing against a fake richer than the thing under test.

2. What was found, and how

+

Every figure below was produced by running the shipped engine, not by reading it. Where a function sat inside a closure it was lifted out by source text; where it could only be reached through the page, the page was driven in a browser.

What passed

  • CIEDE2000, against the published pairs from Sharma, Wu & Dalal (2005) — worst disagreement 0.00004.
  • OKLab and OKLCH, against Ottosson’s own reference values.
  • The sRGB transfer function, both directions, exact.
  • WCAG 2 contrast: black on white 21.00, #767676 on white 4.54.
  • The occurrence walk itself. It found every place it claimed to find. What was wrong was what happened to those places next.

What did not

FindingMeasured
Two colours could not trade places One red rect and one blue rect with their targets swapped: both came back #FF0000. Artwork with two colours in it came back with one.
A colour written as a name was never changed fill="red" set to #00AA00: the exported file still read fill="red". The page reported the change anyway.
A three-digit source came back a different colour Source #ABC, target #123456: the file got fill="#135", which a browser reads as #113355.
The usage count was inflated, by different amounts One use of one colour printed ×2 as a plain attribute, ×2 in a style attribute, ×2 in a <style> block, ×3 on a <text> element and ×4 as a gradient stop — and three rects sharing one fill printed ×6.
The APCA column was always exactly zero Swept over 7,396 grey-on-grey pairs: not one returned anything other than zero.
“Perceptual grouping” was not perceptual #7F7F7F and #7F7F80, one byte apart, scored 120.6 against a real difference of 0.61. Over 680 three-colour comparisons the two metrics disagreed on 24.4%.

3. Why the rewrite is planned before it is applied

+

The engine this replaces walked the mapping and applied each entry to the file as text, one after another. The second replacement then also caught everything the first had just written. Every red became blue; then every blue — including the blue that had just been red — became red.

planRewrite(occurrences, mapping, opts) reads only the original values and returns one entry per occurrence saying what is there and what will replace it. applyPlan(plan) then writes them. No entry can depend on another having been applied, which is the whole property, and it is asserted directly rather than only through its consequences:

plan.forEach(function (p) {
  if (!p.write) return;
  assert.equal(p.from, p.occurrence.hex.toLowerCase(),
    "a plan entry must name the colour that was there before any writing");
});

A cycle of any length survives this, not just a two-way swap; the test suite rotates three colours to make sure the property is the general one.

One ordering detail matters inside applyPlan. Declarations inside a <style> element are rewritten by string index, so they are applied back to front — a replacement of a different length would otherwise move every index after it.

4. Reading a colour, and refusing to guess

+

parseColorValue(raw) returns a colour and the form it was written in, or null. The form is what makes an honest rewrite possible: a name can be written back as a name, an rgb() as an rgb().

FormExampleNote
hex3 / hex6#ABC, #AABBCCAlpha forms are read and preserved
rgb / rgbargb(1,2,3)Percentages and the space-separated form included
hsl / hslahsl(200 50% 40%)—
namedredAll 148 CSS names, not the 44 the old scan listed
no-paintnone, transparentFound and counted, never rewritten
referenceurl(#grad)Points at something else in the file
unresolvablecurrentColor, inheritThe value lives outside this file

The parser it replaces fell through to setting a canvas fillStyle and reading the pixel back, which silently returns opaque black for any string the canvas cannot parse — so an unreadable value became a real, writable black. Its separate raw-text scan listed only 44 of the 148 named colours, with deepink for deeppink and darkturquoise twice.

Writing it back

formatColor(hex, opts) takes style: "hex" | "keep" | "name". A three-digit shorthand is the one form that cannot always be honoured: only a doubled hex has one, which is 1 colour in 4,096. The old code built a shorthand from characters 1, 3 and 5 of the target regardless, so it was wrong for almost every colour anyone would pick. The test states the property rather than the case:

for (const target of ["#123456", "#4a90d9", "#ff8801"]) {
  const out = C.formatColor(target, { style: "hex" });
  assert.equal(C.parseColorValue(out).hex, target);
}

5. What the count used to mean

+

collectOccurrences(root) returns one entry per place a colour is written: eight paint attributes, one declaration inside a style attribute, or one declaration inside a <style> element. summarise() groups them into one row per distinct colour with an honest count, and planRewrite() works from the same list. That is what makes the count mean something: it is the number of things that will change.

The count it replaces came from a DOM walk plus a second sweep over the raw file text, with extra branches for <text> elements and gradient stops on top of both. The inflation scaled rather than being an offset anyone could subtract, so the ranking it produced was a ranking of authoring style.

The report ties the three numbers together, and the tie is the point: written + unchanged + skipped === occurrences. A report whose parts do not add up to its total is a summary.

6. Making a stranger's file safe to look at

+

An SVG is a document, not a picture. It can carry <script>, a <foreignObject> holding arbitrary HTML, event-handler attributes and javascript: links — and the whole point of this tool is opening a file somebody sent you.

The preview never assigns markup to innerHTML. It parses with DOMParser, runs stripActiveContent(root) over the parsed copy, and imports the result with importNode. What was removed is reported on the page rather than quietly discarded, because a tool that silently changes your file is the thing this whole rebuild is about.

RemovedDetail
<script>, <foreignObject>, <handler>The whole element
on* attributesMatched case-insensitively, on the root as well as every descendant
javascript: in href / xlink:hrefWhitespace and control characters are stripped before the comparison, because a browser ignores them inside a scheme

Two things it deliberately does not do. It does not touch any paint value, so the artwork that draws is the same artwork — a test asserts the colours and counts are identical before and after. And it does not change the export: what leaves the page is rewritten from your file, script included, because it is your file.

It is not a general-purpose sanitiser and does not claim to be. It is the specific list of things that execute.

7. The register

+

js/tool/sv/sv-sources.js holds 15 entries, one per figure the page prints, each with a status. The counts on the page are derived from the register by counts() — the chips, the tab badge and the caption all read the same object, so a new entry cannot disagree with the number beside it.

StatusCountMeans
measured3Read off the parsed document or off the rewrite as it happens
computed7A published formula this page implements in full
choice3This tool's own decision, standing in for a judgement
absent2Named on the page and not held

computed is the status that has to be earned: a formula counts as computed only if all of it is here. The APCA figure sat in exactly that gap before this rebuild — the right citation, the right exponents, and the clip applied to a quantity that never reaches it.

The two absent entries are what currentColor resolves to, and colour inside embedded raster art. Both are named on the page with what it would take to hold them, which is more useful than leaving a reader to discover the gap.

8. Tests, and proving them

+
node --test --test-timeout=120000 "tests/colorimetry/sv-*.test.js"

82 tests. The first four groups are the four findings written as tests that would have failed before the rebuild; the rest are the arithmetic, the sanitiser, and the register.

A suite that passes proves nothing about a suite. Each one here is proved by injecting the defect it is supposed to catch — including the six original findings, put back exactly — and checking the suite goes red. 99 mutants, 98 caught.

The single survivor is an equivalent mutant, and it is recorded as such with the measurement that proves it rather than assumed: removing the "none" early return from simulateCVD changes nothing, because CVD_MATRICES["none"] is undefined and the guard two lines below returns the colour unchanged anyway.

Two traps worth keeping

  • Pure red cannot test a linear-light matrix. Red is (1, 0, 0) both encoded and linear, so a simulation that skips the transfer function returns the same answer as one that does not. The first version of that test passed while the defect lived; it now uses a mid-tone and asserts the two candidates differ before asserting which is right.
  • Cross-realm arrays are not deepStrictEqual to host arrays. The core is loaded under vm, so everything it returns belongs to another realm. Compare by value; sv-reference.test.js has sameList for it.