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.
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.
1. Code map
+| File | Holds | Depends 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
| Finding | Measured |
|---|---|
| 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().
| Form | Example | Note |
|---|---|---|
hex3 / hex6 | #ABC, #AABBCC | Alpha forms are read and preserved |
rgb / rgba | rgb(1,2,3) | Percentages and the space-separated form included |
hsl / hsla | hsl(200 50% 40%) | — |
named | red | All 148 CSS names, not the 44 the old scan listed |
no-paint | none, transparent | Found and counted, never rewritten |
reference | url(#grad) | Points at something else in the file |
unresolvable | currentColor, inherit | The 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.
| Removed | Detail |
|---|---|
<script>, <foreignObject>, <handler> | The whole element |
on* attributes | Matched case-insensitively, on the root as well as every descendant |
javascript: in href / xlink:href | Whitespace 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.
| Status | Count | Means |
|---|---|---|
measured | 3 | Read off the parsed document or off the rewrite as it happens |
computed | 7 | A published formula this page implements in full |
choice | 3 | This tool's own decision, standing in for a judgement |
absent | 2 | Named 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
deepStrictEqualto host arrays. The core is loaded undervm, so everything it returns belongs to another realm. Compare by value;sv-reference.test.jshassameListfor it.