Slice

The conformance test suite, in plain English

Every case in the Slice conformance corpus, and why it is there

This describes every test in the corpus and why it is there. It is generated from the cases themselves, so it cannot fall out of step with them.

What this suite is for

Slice takes a variable font and gives you a smaller one. There are two programs that do this: the original PyQt5 application by Source Foundry, and this reimplementation. The suite exists to say, precisely and testably, what such a program should do — and then to hold both of them to it.

The cases are data, not code. The same case runs against both programs, and a single shared checker evaluates the result, so neither program marks its own homework and no test can be quietly written in a way that flatters one of them.

Where the two disagree, the corpus is the arbiter, not either program. A case the original fails is adjudicated on evidence: either the test is wrong and gets fixed, or the original has a defect and this implementation must not copy it.

The shape of a test

Each case names a fixture font, some editor input, and a list of things that must be true of the result — or, for a bad input, the refusal that must come back. Each also carries the behavioural claims it covers, from the behaviour map, and a rationale saying why that is the right answer.

Every case is labelled with where its authority comes from:

label meaning cases
spec the OpenType specification requires it 86
fonttools inherited from fontTools, which the original delegates to 108
slice-ui the application’s own contract 74
judgement a defensible design choice with no external authority 34

There are 302 cases in total, across 6 areas. 17 of them require removing overlaps, which the original never had; it is expected to fail those, and that is not counted against it.

Contents

axis-syntax

What happens to the text someone types into an axis cell. This is the tool’s entire input language — a blank cell, a number, or a min:max range — so it is also where a mistyped entry either gets caught or turns into a font nobody asked for.

Covers claims: A2, B1, B10, B11, B12, B13, B2, B3, B4, B5, B6, B7, B8, B9, C1, C2, C3, C4, F1, F2, F3, G1, G2.

axis.blank-cell-leaves-axis-alone

An empty cell leaves that axis untouched and variable

The editor’s contract is that a cell is a request, so no text is no request: the axis must survive with the extent fvar already declared for it. Anything else would mean the user cannot pin one axis of a five-axis font without also restating the other four, and would make an untouched editor destructive. The four untouched axes are checked for their full original extents, not merely for their presence, because an implementation that silently narrowed an axis to its default location would otherwise pass.

covers B1, B2 · authority: slice-ui · fixture: recursive-vf

axis.mixed-pins-and-ranges

Pins, ranges and blank cells combine in one job

Each cell is an independent request, so a job that pins one axis, restricts another and leaves three alone must apply all three readings in the same pass and preserve fvar order in what survives — the OpenType specification gives axis order meaning, since gvar, avar and the instance coordinate arrays are all indexed positionally against it. This is the shape of a realistic slice and the only case here that exercises the dispatch of B2 across several rows at once, which is where a parser that keeps state between rows would go wrong. The pinned axis must be gone, the restricted axis narrowed with its default intact, and the untouched axes unchanged.

covers B1, B2, B5, G2 · authority: slice-ui · fixture: recursive-vf

axis.pin-above-axis-maximum

A pin above the axis maximum is refused, not clamped

This is B10, the weak suspect, and the argument turns on an asymmetry between pins and ranges. A range is an intersection: asking for 0:2000 on a 100-900 axis describes a design space that, intersected with what the font has, is exactly the whole axis, so clamping it gives the user what they asked for and B9 rightly leaves it alone. A pin is not an intersection but an assertion that a specific location exists; wght=1200 on a 100-900 axis names a location the font does not define, there is no reading under which the user meant 900, and clamping produces a static font whose outlines and whose usWeightClass both silently differ from the request. Against that stands the real counter-argument, which is that clamping is fontTools’ deliberate documented policy in AxisTriple.limitRangeAndPopulateDefaults and that a library caller relies on it — but that policy is written for a programmatic caller, whereas an editor cell is human input and validating human input before acting on it is the editor’s job. My recommendation is therefore to refuse; if adjudication prefers the softer verdict, the honest encoding is success plus the proposed warns check, never silent success.

covers B10 · authority: judgement · fixture: centred-default · expects a refusal mentioning wght, 1200, axis

axis.pin-at-axis-extremes

Pins exactly at an axis minimum and maximum are valid locations

The fvar minValue and maxValue are inclusive bounds on the coordinates an axis may take, so the endpoints are locations the font defines and must be accepted. This case exists to fence the out-of-range rule encoded in axis.pin-above-axis-maximum: a bounds check written with the wrong comparison operator would reject the two most commonly requested locations in the whole design space, since “the heaviest weight” and “the full slant” are what most users of a variable font actually want to slice out.

covers B3, B10 · authority: spec · fixture: recursive-vf

axis.pin-below-axis-minimum

A pin below the axis minimum is refused, not clamped

The same adjudication as axis.pin-above-axis-maximum, on the low side, which is a distinct branch in any bounds check and a distinct mistake in practice: wght on this fixture starts at 300, and 100 is what a user types who is thinking of the CSS weight scale or of a different family in the same superfamily. Clamping turns that misunderstanding into a font pinned at 300 with usWeightClass 300 and no indication that the request was altered. The original does not check bounds at all and is expected to fail this case.

covers B10 · authority: judgement · fixture: recursive-vf · expects a refusal mentioning wght, 100, axis

axis.pin-decimal

A decimal pin lands at the fractional location, not at a rounded one

OpenType stores axis coordinates as Fixed 16.16, which carries roughly four decimal digits of fraction, so 0.25 is exactly representable and there is no licence to round it to an integer. Fractional coordinates are ordinary in practice: registered axes such as slnt and Google Fonts registry axes such as CRSV are routinely used at intermediate positions, and CRSV here has a fractional default of 0.5 to begin with. Comparing outlines against the source drawn at CRSV 0.25 verifies the parsed value reached the instancer intact rather than merely that some pin happened.

covers B2, B3 · authority: spec · fixture: recursive-vf

axis.pin-decimal-comma-is-refused

A comma decimal separator is refused rather than truncated

400,5 is how a large part of the world writes four hundred and a half, and it is precisely the input a tool must not guess at: reading it as 400, as 4005, or as 400.5 are all defensible readings and all of them silently produce a font at a location the user did not verify. B4’s contract — refuse the cell and name the axis — is the right answer because it puts the ambiguity back in front of the person who can resolve it. The original already refuses this, since float() does, and the case is here to keep a more permissive number parser from being introduced as a convenience.

covers B3, B4 · authority: slice-ui · fixture: centred-default · expects a refusal mentioning wght, not a valid

axis.pin-inf-is-refused

A pin of “inf” is refused

Fixed 16.16 spans roughly -32768 to +32767.99998 and has no encoding for infinity, so an infinite coordinate cannot be written to fvar, to gvar, or to an avar segment map. Whatever a downstream conversion does with it — saturate, wrap, or raise — is an accident rather than the user’s request. The original accepts this input for the same reason it accepts NaN, and is expected to fail this case; B11’s proposed verdict is to reject non-finite values, and infinity is the other half of that set.

covers B3, B11 · authority: spec · fixture: recursive-vf · expects a refusal mentioning wght, inf

axis.pin-integer-with-trailing-zero

“400.0” and “400” name the same location

The corpus deliberately treats "400" and "400.0" as different inputs because they are different characters; this case establishes that they are nonetheless the same request. Nothing in Fixed 16.16 distinguishes them, and the read-only column of the editor itself displays defaults with a trailing .0, so a user copying the displayed value back into the edit cell is typing the form under test here. An implementation whose numeric grammar required either the presence or the absence of a fractional part would fail one of these two spellings.

covers B3 · authority: slice-ui · fixture: centred-default

axis.pin-lone-minus-sign-is-refused

A lone minus sign is refused

A half-typed value is the most likely thing to be sitting in a cell when a user reaches for the Slice button, and it denotes no location at all. The interesting risk is not that a tool accepts it as a number but that a lenient parser reads the sign and no digits as zero: on this fixture zero is inside the slnt range and is also its default, so the job would succeed and produce a font that looks entirely reasonable while ignoring what was typed. Refusing is the only reading that cannot silently be wrong.

covers B3, B4 · authority: slice-ui · fixture: recursive-vf · expects a refusal mentioning slnt, not a valid

axis.pin-nan-is-refused

A pin of “nan” is refused

OpenType axis coordinates are Fixed 16.16 — a scaled integer format with no bit pattern reserved for NaN — so there is no such thing as an axis pinned to NaN and no way to write the request out. Worse, the value does not stop at the coordinate: normalizing NaN yields NaN, and every delta scaled by that normalized location becomes NaN, so a single mistyped cell can poison every outline in the font while the save still appears to succeed. The original accepts this input because its pin parser is bare float(), and it is expected to fail this case; that is the defect B11 records. The refusal must name the axis so the user can find the cell.

covers B3, B11 · authority: spec · fixture: recursive-vf · expects a refusal mentioning CRSV, nan

axis.pin-negative-decimal

A negative decimal pin is accepted and lands at the negative location

The OpenType axis registry defines slnt as degrees of counter-clockwise rotation with negative values for the usual forward lean, so negative coordinates are not an edge case on this axis but the only useful half of it. Fixed 16.16 is signed, so -7.5 is exactly representable. A parser that mistook the leading minus for a separator, or that dropped the sign, would still produce a plausible-looking font, so the case is checked against the source outlines at slnt -7.5 rather than against the axis list alone.

covers B2, B3 · authority: spec · fixture: recursive-vf

axis.pin-negative-infinity-word-is-refused

A pin of “-Infinity” is refused, in any spelling or case

float() accepts inf, INF, Infinity and -Infinity interchangeably, so an implementation that rejects only the literal three-letter lowercase spelling has closed one door of several into the same unrepresentable value. This case is not a duplicate of axis.pin-inf-is-refused: it pins down that the rejection is a property of the value, not a blocklisted string, and it exercises the negative side, which is the side a slnt user would reach for. Fixed 16.16 has no representation for either signed infinity.

covers B3, B11 · authority: spec · fixture: recursive-vf · expects a refusal mentioning slnt, infinity

axis.pin-non-numeric-word-is-refused

A style name typed into an axis cell is refused

Axis cells take user-space coordinates, not style names: fvar has no mapping from the word “bold” to a location, and the mapping that does exist — the named-instance table — is not what this editor edits. Guessing would be actively harmful because a font’s own named instances may place “Bold” anywhere on the axis. The message must contain the axis tag so that a user with five rows in front of them knows which cell to fix, which is the whole point of B4’s wording.

covers B3, B4, F2 · authority: slice-ui · fixture: recursive-vf · expects a refusal mentioning wght, not a valid

axis.pin-plain-integer

A plain integer is a pin, and the pinned axis leaves the design space

A cell with no colon names a single location, and an axis at a single location is no longer a variation axis: the OpenType specification requires an fvar axis record to describe a range of user-space values, so an axis fixed at one value has nothing left to declare and must be removed along with the variation data indexed by it. Pinning the only axis therefore yields a static font. usWeightClass is checked because varLib.set_default_weight_width_slant derives it from the pinned wght location, which makes it cheap evidence that the number the parser produced was 700 and not merely some number.

covers B2, B3, G1 · authority: slice-ui · fixture: centred-default

axis.pin-scientific-notation

Exponent notation is a finite number and is accepted

B11’s proposed verdict is narrow and deliberate: reject non-finite values, not reject everything float() happens to accept. 1e3 denotes 1000 unambiguously in every numeric grammar in common use, it is exactly representable in Fixed 16.16, and refusing it would buy nothing. The verified consequence is usWeightClass 1000, which distinguishes a correct parse from the failure mode this input actually invites, namely reading only the leading 1. Note the deliberate asymmetry with axis.range-scientific-notation-is-refused: the pin grammar is float(), the range grammar is the B5 regular expression, and only the latter has no production for an exponent.

covers B3, B11 · authority: judgement · fixture: recursive-vf

axis.pin-surrounded-by-whitespace

A pin padded with spaces is accepted as the trimmed number

Leading and trailing whitespace is an artefact of typing and of pasting values out of a specimen or a spreadsheet, never a statement about the design space, so it must be trimmed rather than treated as corruption. The original already behaves this way for pins because float() strips surrounding whitespace, and B11 records that as a property of the parser rather than as a defect. Fixing it here as required behaviour matters because the corrected B12 rule (“the whole cell must parse”) is otherwise easy to implement as a regex anchored on the raw cell, which would reject this input and every pasted value like it.

covers B3, B11 · authority: slice-ui · fixture: centred-default

axis.pin-underscore-separator-is-refused

A Python-style digit separator is not a number

float("1_000") returns 1000.0 because underscores are permitted in Python numeric literals, which is an implementation detail of the language the original was written in and not a numeric syntax any user of a font tool expects or any specification recognizes. Requiring every future implementation to reproduce it would be reproducing a leak rather than a behaviour, and refusing it costs a user nothing they cannot express as 1000. The original is expected to fail this case; the divergence is small, deliberate, and of the same character as B11’s.

covers B3, B4 · authority: judgement · fixture: recursive-vf · expects a refusal mentioning wght, 1_000

axis.range-basic

A colon makes the cell a restricted range, and the axis survives narrowed

The colon is the sole discriminator between the two things a cell can mean (B2), and a range must keep the axis in fvar with the requested bounds rather than pinning it. Named instances are checked to sit inside the new extent because the OpenType specification requires an fvar instance’s coordinates to lie within each axis’s declared range, so any instance the narrowing left outside would make the output non-conformant. The default is required to stay at 400: a Level 3 restriction moves the bounds and nothing else.

covers B2, B5, G2 · authority: slice-ui · fixture: centred-default

axis.range-cell-padded-with-whitespace

A range padded with leading and trailing whitespace is accepted

This case exists to say precisely what B12’s corrected rule means, because the obvious implementation of it gets this wrong. The B5 expression begins at start with no leading \s*, so switching re.search for re.fullmatch on the raw cell would reject a leading space while still accepting a trailing one — an arbitrary distinction the user cannot see. The rule is therefore: trim the cell, then require the remainder to parse in its entirety. That also keeps the range path consistent with the pin path, which tolerates surrounding whitespace (B11) and must continue to.

covers B5, B12 · authority: slice-ui · fixture: centred-default

axis.range-collapsed-to-a-point

A range whose bounds are equal is a pin

A range with minimum equal to maximum admits exactly one coordinate, and an fvar axis that admits one coordinate is not an axis: fontTools recognizes this explicitly, treating a triple whose bounds coincide as a pinned location and dropping the axis from fvar. Writing the axis out with min = default = max would be legal but pointless and would leave gvar deltas for a variation that can never vary. The slnt default of 0 is used so that the collapsed point is a location the axis genuinely contains, keeping this case about the collapse and not about bounds.

covers B5, B6, G2 · authority: fonttools · fixture: recursive-vf

axis.range-embedded-in-garbage-is-refused

A range buried in surrounding text is refused, not extracted

This is B12. re.search finds the grammar anywhere in the cell, so xyz200:700zzz is accepted and the tool acts on a substring the user did not intend as their whole request. That is inconsistent with B4, under which any other cell the parser cannot make sense of is refused outright, and it hides exactly the typos a validator exists to catch. The corrected rule is that the trimmed cell must parse in its entirety; the original is expected to fail this case by succeeding.

covers B12 · authority: slice-ui · fixture: centred-default · expects a refusal mentioning wght, not a valid

axis.range-equal-to-full-extent

A range identical to the axis’s own extent is accepted, not refused as a no-op

The B13 refusal is worded as though it detected an unchanged design space, but what it actually tests is that at least one cell is non-empty, and that weaker test is the right one to keep. Deciding whether a request truly narrows anything would mean comparing the requested space against the font’s after clamping, which would refuse 300:1000 here while accepting the equivalent 200:1000, an inconsistency the user cannot predict. There is also legitimate work in a no-op slice, since the instancer still runs its optimizations. The obligation is only that the output remains a valid variable font with its design space intact.

covers B5, B13, F3 · authority: judgement · fixture: recursive-vf

axis.range-excluding-default-is-refused

A range that does not contain the axis default is refused

An fvar axis record must satisfy minValue <= defaultValue <= maxValue, so a range of 400:700 on an axis whose default is 300 cannot be written without also moving the default — a different and larger operation (Level 4 sub-spacing) that changes what the font renders when no variation settings are applied, and that requires renormalizing every delta and every avar segment about the new origin. Honouring the range while leaving the default outside it would produce a malformed font; honouring it by moving the default would silently change the font’s identity. Refusing, and naming both the range and the default in the message, is the correct answer for a tool that does not implement Level 4.

covers B7 · authority: spec · fixture: recursive-vf · expects a refusal mentioning wght, default

axis.range-excluding-negative-default-is-refused

The default-containment rule holds when the default is the upper bound

Asking for -15:-5 on slnt excludes the default of 0, and this is the shape of the request a user most plausibly makes by mistake — “give me only the slanted part of the font” — precisely because the upright default feels like something they are discarding rather than something the format requires them to keep. A containment check written as min <= default <= max handles it, but one written for axes whose default is interior, or one comparing absolute values, does not. The refusal is required for the same reason as the previous case: the output would otherwise have defaultValue outside [minValue, maxValue].

covers B7 · authority: spec · fixture: recursive-vf · expects a refusal mentioning slnt, default

axis.range-exponent-bound-reads-as-its-value

An exponent in a range bound is read as its value, never truncated to the mantissa

This is the most damaging instance of B12 the fixture admits. 300:1e3 means 300 to 1000 to anyone who writes it, but re.search matches 300:1, sorts it to 1:300, finds the default of 300 inside, and accepts it; clamping then collapses the axis to 300:300 and the weight axis disappears from the font entirely. The user asked to keep the whole weight range and received a static weight, with no message. The corpus requires the bound to be read as 1000: fontTools’ parseLimits reads wght=300:1e3 as (300, 1000), and it is fontTools’ instancer that Slice hands the result to, so a limit string a user learned from fonttools varLib.instancer --help must not mean something different here. The check is that the weight axis survives with its full extent; had 1e3 been read as 1 the axis would have been pinned away, which is exactly the failure being guarded against.

covers B12 · authority: judgement · fixture: recursive-vf

axis.range-exponent-bounds-on-both-sides

Exponents on both bounds give the range they spell, not the mantissas

The companion to the 300:1e3 case, with a range that does not coincide with the axis extent, so the bounds themselves are observable rather than only the axis’s survival. 3e2:7e2 must yield a weight axis running 300 to 700. The original reads it as neither: re.search scans forward until the pattern matches anywhere, finds 2:7 inside 3e2:7e2, and refuses the cell because 2 to 7 does not contain the default of 300. Measured, not inferred – it reports “The wght range 2.0:7.0 does not include the default axis value (300.0)”. Success with the right extent therefore separates the correct reading from both the truncating one and the scanning one.

covers B12 · authority: judgement · fixture: recursive-vf

axis.range-leading-dot-bound-reads-as-a-fraction

A bound written with a leading decimal point is read as a fraction, never as its digits

The original’s range regex requires a digit before the decimal point, so .25 is not a bound it can produce – but re.search skips the dot and matches 25:1, sorts it to 1:25, finds that the CRSV default of 0.5 is not inside, and refuses with “The CRSV range 1.0:25.0 does not include the default axis value (0.5)” – naming back to the user a range they never typed. On a bound the regex happens to accept it does worse and reads the digits alone. Either way the user’s .25 is not what gets used. The corpus requires the bound to be read as a quarter, because that is what it means everywhere else: fontTools’ own parseLimits – the parser for the instancer Slice drives – reads wght=.5:900 as (0.5, 900), and so do strtod, Python’s float() and JSON. Slice’s pin parser already reads .25 as a quarter, so refusing it one character later in the same cell would be an inconsistency the user cannot predict. Misreading it as 25 is the defect; reading it correctly, or refusing it outright, are the two defensible answers, and agreement with the reference implementation decides between them.

covers B12 · authority: judgement · fixture: recursive-vf

axis.range-missing-lower-bound-is-refused

A range with nothing before the colon is refused

The mirror of the previous case, and a distinct code path: with re.search the missing bound is a failure to match at all, whereas a hand-written splitter is likely to produce an empty first field and hand it to a number parser. Either way the answer is the same, because :400 names no lower bound and the tool must not guess one. Keeping both halves of the grammar fixed by cases is what stops an implementation from anchoring only one end.

covers B5, B4 · authority: slice-ui · fixture: centred-default · expects a refusal mentioning wght, not a valid

axis.range-missing-upper-bound-is-refused

A range with nothing after the colon is refused

The B5 grammar requires a number on both sides of the colon, and there is no defensible completion of 400: — reading the missing bound as the axis maximum would be inventing half the request, and reading it as equal to the start would silently turn a range into a pin. This is the state a cell is in mid-typing, so the tool must be able to say what is wrong with it rather than acting on it. The message names the axis, matching B4’s contract for every other unparseable cell.

covers B5, B4 · authority: slice-ui · fixture: centred-default · expects a refusal mentioning wght, not a valid

axis.range-negative-bounds-reversed

A negative range typed in reverse order is sorted correctly

Sorting has to be numeric, not lexicographic and not “first number is the minimum”: on a negative axis the user’s natural reading order is often from the default towards the extreme, so 0:-10 is how someone describes discarding the steep end. A comparison done on the parsed strings, or a sort that mishandles the sign, would produce minValue 0 and maxValue -10 and an fvar record the OpenType specification forbids. The expected extent is identical to axis.range-with-default-at-maximum, which is the point.

covers B5, B6 · authority: slice-ui · fixture: recursive-vf

axis.range-non-numeric-bounds-are-refused

Words either side of a colon are refused

min:max is what a user types who has read the column header Min : Max [Default] as a template to fill in rather than as a description, so it is a realistic entry and not a contrived one. It also exercises B2’s dispatch: the cell contains a colon, so it must be judged by the range grammar and refused there, rather than falling through to the pin parser and producing a message about a single numeric value that would not help. The refusal must name the axis.

covers B2, B5, B4 · authority: slice-ui · fixture: centred-default · expects a refusal mentioning wght, not a valid

axis.range-overshooting-extent-is-clamped

A range that overshoots one end of the axis is clamped to it

A restriction is an intersection with what the font already has, so asking for 200:700 on an axis that starts at 300 describes exactly the design space 300:700 — the user has lost nothing and been told nothing untrue. This is fontTools’ documented behaviour in AxisTriple.limitRangeAndPopulateDefaults, and unlike the pin case (B10) there is no divergence between the request and the result to warn about. Note the deliberate contrast with axis.pin-below-axis-minimum, which refuses the same number on the same axis: the corpus treats out-of-extent bounds as benign for ranges and as an error for pins, and the rationale there sets out why.

covers B9 · authority: fonttools · fixture: recursive-vf

axis.range-reversed-is-sorted

A range typed high-to-low means the same as low-to-high

A range denotes a set of coordinates, and the fvar axis record has one slot for the smaller bound and one for the larger, so the order in which the user typed the two numbers carries no information that the format can store. Sorting is the only reading that does not throw input away; the alternatives are refusing a request whose meaning is unambiguous, or writing minValue greater than maxValue, which is malformed. The resulting extent is required to be identical to axis.range-basic, which is the whole content of the claim.

covers B5, B6 · authority: slice-ui · fixture: centred-default

axis.range-spaces-around-colon

Spaces around the colon are permitted

The B5 grammar writes the separator as \s*:\s* deliberately, and the editor’s own read-only column displays extents in exactly this spaced form (100.0 : 900.0 [400.0]), so a user who copies what the tool showed them and edits a number is typing this input. Refusing it would mean the tool cannot read back its own display. The check is that the spacing changes nothing about the resulting extent.

covers B5 · authority: slice-ui · fixture: centred-default

axis.range-wholly-outside-extent-is-refused

A range entirely outside the axis is judged as typed, not as clamped

1000:2000 on a 100-900 axis is out of range and also excludes the default, and the order of the two checks decides the outcome: validate the literal request and it is refused for excluding the default of 400, but clamp first and it collapses to 900:900, which contains no default either but looks to a naive check like a pin. The user’s request must be judged as typed, because the message they receive has to describe the cell they can see. This also fixes the boundary of B9’s leniency: clamping is a courtesy applied to ranges that genuinely overlap the axis, not a licence to reinterpret a request that has nothing in common with it.

covers B7, B9 · authority: spec · fixture: centred-default · expects a refusal mentioning wght, default

axis.range-with-default-at-maximum

A range is valid when the default sits on its upper bound

Recursive’s slnt runs -15 to 0 with the default at 0, so its default is the upper bound and its useful values are all negative — the mirror image of the previous case and a second inclusive boundary that an off-by-one validator would get wrong. Restricting it to -10:0 keeps the upright default and discards the steepest third of the slant, which is a real editorial request. Together with axis.range-with-default-at-minimum this fixes both ends of the inclusive containment rule.

covers B5, B7 · authority: spec · fixture: recursive-vf

axis.range-with-default-at-minimum

A range is valid when the default sits on its lower bound

Recursive’s wght has its default equal to its minimum, which is a common shape for an axis whose regular weight is its lightest (and the shape the single-axis-min-default fixture exists to isolate). The Level 3 requirement is that the restricted range contain the default, and containment is inclusive: the OpenType specification requires only that minValue <= defaultValue <= maxValue. A validator written with a strict comparison would reject every narrowing of an axis of this shape, which is most of what a user would want to do with this font.

covers B5, B7 · authority: spec · fixture: recursive-vf

axis.range-with-new-default-is-refused

A trailing [default] group asking to move the default is refused, not discarded

This is B8. The original parses [700], understands it as a request to relocate the default, and then throws it away, returning a font whose default is still 300 with nothing said — so the program demonstrably knows what was asked and declines to admit it cannot do it. The user’s request is not met and their font is not what they asked for. Moving the default is Level 4 sub-spacing, which fontTools’ instancer has since gained support for through its min:default:max triple limits, so an implementation may legitimately implement it; what it may not do is accept the syntax and ignore the meaning. The original is expected to fail this case, and the message must name both the axis and the default so the user knows which part of the cell was refused.

covers B8 · authority: slice-ui · fixture: recursive-vf · expects a refusal mentioning wght, default

axis.range-with-three-parts-is-refused

A cell with two colons is refused rather than truncated to the first range

200:700:900 is not noise but a coherent request in another notation: it is exactly fontTools’ own min:default:max triple syntax for Level 4 sub-spacing, so a user coming from fonttools varLib.instancer will type it. Under re.search the original quietly keeps 200:700 and discards the rest, delivering a font with a different default from the one requested — the same silent reinterpretation as B8, reached by a different route. Refusing is required until the syntax is actually supported; the original is expected to fail this case.

covers B12 · authority: slice-ui · fixture: centred-default · expects a refusal mentioning wght, not a valid

axis.range-with-trailing-words-is-refused

A range followed by stray text is refused

400:700 garbage is the shape B12 takes in practice — a note, a unit, or a fragment of a previous edit left after the numbers — and re.search accepts it silently. The trailing text may well be the user’s own annotation contradicting the numbers, and the tool has no way to know. Requiring the whole trimmed cell to parse refuses it and asks; this is the same rule as axis.range-embedded-in-garbage-is-refused applied where the noise follows rather than surrounds, which is the case a fix anchored only at the start of the string would still get wrong. The original is expected to fail this case.

covers B12 · authority: slice-ui · fixture: centred-default · expects a refusal mentioning wght, not a valid

axis.range-with-unchanged-default-is-honoured

A [default] group naming the default the axis already has is satisfiable, and is honoured

B8’s defect is accepting a request the tool cannot satisfy; it is not the bracket syntax itself. Here the user restricts wght to 200:700 and states that the default is 400, which is what fvar already says, so the resulting font matches the request exactly and there is nothing left unhonoured. Refusing would be pedantry with a cost, since a user documenting the full intended triple in one cell — the natural thing to do after reading the Min : Max [Default] column header — would be turned away for asking for what they were already getting. An implementation that refuses every bracket group unconditionally will fail this case; that is the adjudication this case is here to force, and the deciding question is whether the output can satisfy the request as written.

covers B8, B5 · authority: judgement · fixture: centred-default

editor.axis-names-prefer-the-registries

Each row is labelled with the registered or registry name for its tag

A four-character tag is not self-explanatory to most users, and the two authorities that give it a name are the OpenType design-variation axis tag registry, which fixes “Weight” for wght and “Slant” for slnt, and the Google Fonts axis registry, which fixes “Monospace”, “Casual” and “Cursive” for MONO, CASL and CRSV. Preferring those over the font’s own name entry (C4) means the label is stable across every font that uses the tag, so a user reads the same word for the same axis regardless of who compiled the font — which is the entire value of a registered tag. The font’s own axis name remains the fallback for tags in neither registry. Note that this fixture cannot fully separate the two sources, since Recursive’s own axis names agree with the registries; a fixture whose name table disagrees would be needed to prove the precedence rather than the outcome.

covers C3, C4 · authority: slice-ui · fixture: recursive-vf

editor.rows-follow-fvar-order-and-show-extents

The editor shows one row per fvar axis, in order, with that axis’s extent

Axis order in fvar is meaningful — gvar tuple coordinates, avar segment maps and instance coordinate arrays are all indexed positionally against it — so displaying the rows in that order is what lets a user check the editor against any other tool’s view of the same font. The read-only column exists to state, per axis, what the font actually permits, so it must carry the fvar minimum, maximum and default and no derived or rounded substitute; it is the only thing telling the user which values their cell may contain. Five rows are required for this five-axis font, which also fixes that the editor’s fixed five-row scaffold is replaced on load rather than padded out.

covers C1, C2 · authority: slice-ui · fixture: recursive-vf

editor.single-axis-font-shows-one-row

A one-axis font shows exactly one row

The original’s model is built around a five-row scaffold that is replaced when a font loads, so the number of rows shown must come from fvar and not from that scaffold: a one-axis font that displayed four empty rows would invite entries against axes the font does not have. This fixture’s off-centre default of 400 within 100-900 also pins down that the third number in the read-only column is the default and not, say, the midpoint of the extent, which a two-axis font with a centred default could not distinguish.

covers C1, C2 · authority: slice-ui · fixture: centred-default

editor.static-font-is-refused-on-load

A font with no fvar is refused at load, not silently accepted as a font with no axes

fvar is what makes a font variable, and a font without one has no design space to build a sub-space from – there is nothing the tool could do to it but copy it. The failure mode being guarded against is not a crash but a plausible-looking success: an implementation that treats a missing fvar as ‘a font with zero axes’ presents an empty Axis Editor and then writes out a copy, and the user has no way to tell that from a slice that happened to change nothing. Both programs refuse, and the message must name fvar so the reason is discoverable rather than merely stated.

covers A2 · authority: slice-ui · fixture: static-ttf · expects a refusal mentioning fvar

slice.all-cells-blank-is-refused

With every cell blank the job is refused

Every blank cell means “leave this axis alone” (B1), so all cells blank means the user has described the font they already have. Producing a copy would be a silent no-op that a user could easily mistake for a successful slice, and the tool has enough information to say so before opening a save dialog. The refusal must explain what to do next rather than merely reporting failure, which is why the message is required to say that at least one axis must be given a location or a range.

covers B13, F3, B1 · authority: slice-ui · fixture: recursive-vf · expects a refusal mentioning at least one, axis

slice.first-invalid-cell-is-reported

With several bad cells the message names at least the first one in fvar order

When two cells are unparseable the user needs a message that corresponds to something on screen, and the rows are displayed in fvar order (C1), so the first offender in that order is the one they will look at first. Requiring the message to contain wght — the earlier of the two bad rows here — is satisfied both by a tool that reports the first error and by one that reports all of them, and excludes only a tool that picks an arbitrary row, which is the behaviour that leaves a user staring at a cell that looks fine. This is judgement: nothing external settles the reporting order, but it must be predictable.

covers F2, B4, C1 · authority: judgement · fixture: recursive-vf · expects a refusal mentioning wght

slice.invalid-cell-aborts-before-any-output

One bad cell abandons the whole job, including the valid cells

Validation covers the whole form before any work starts, so a valid MONO pin alongside an unparseable CRSV cell must produce no font at all rather than a font sliced on the cells that happened to parse. Partial application would be the worst outcome available: the user receives a file that looks like a successful slice but silently omits one of their requests. The corpus’s definition of an error outcome already forbids a written-out font, which is exactly the property under test here; the message must name CRSV so the user knows which of the five rows to fix.

covers F2, B4 · authority: slice-ui · fixture: recursive-vf · expects a refusal mentioning CRSV, not a valid

slice.no-font-loaded-is-refused

Slicing with no font loaded refuses and does nothing

There is nothing to slice and, more to the point, nothing to validate the axis values against: the axis rows, their extents and their defaults all come from fvar, so with no font loaded even a well-formed 400 cannot be checked or applied. The tool must say what is missing and stop, and must not write a file or raise an unhandled error. The message is required to mention the font so it identifies what to do next.

covers F1 · authority: slice-ui · fixture: recursive-vf · expects a refusal mentioning font

slice.only-whitespace-cells-is-refused-as-blank

Cells holding only whitespace still count as an empty request

The consequence of the previous case at the level of the whole job: if whitespace trims to blank, then a form containing nothing but whitespace is a blank form and must be refused with B13’s message, telling the user to define an axis. The distinction matters because both implementations refuse this input and only the message separates them — the original quotes the whitespace back as an invalid axis value, which describes a problem the user cannot see, while the required message describes what is actually missing.

covers B13, F3 · authority: judgement · fixture: recursive-vf · expects a refusal mentioning at least one, axis

slice.whitespace-only-cell-counts-as-blank

A cell holding only spaces is treated as untouched

A cell containing two spaces is indistinguishable on screen from an empty one, so the two must mean the same thing; a user cannot debug a message about a value they cannot see. This follows directly from the trim-then-parse rule that axis.pin-surrounded-by-whitespace and axis.range-cell-padded-with-whitespace establish — trimming this cell leaves the empty string, which is B1’s “leave the axis alone”. The original instead reaches float(" ") and refuses the job with a message quoting whitespace, so it is expected to fail this case; the divergence is small and deliberate, and the alternative reading (refuse, because stray input is worth reporting) is defensible but loses on the fact that the user cannot see what they are being told about.

covers B1, B13 · authority: judgement · fixture: recursive-vf

bitflags

OS/2.fsSelection and head.macStyle: the bits that tell software whether a font is regular, bold or italic. Six of them are editable; the rest must survive untouched.

Covers claims: A5, E1, E2, E3, E4, E5, G1, G2, G7, H1.

bitflags.all-six-cleared-preserves-unexposed

Clearing all six checkboxes clears only those six

This is the worst case for bit preservation: every checkbox the editor owns is explicitly unticked, so a correct implementation must clear precisely bits 0, 5, 6 and 8 of fsSelection and bits 0 and 1 of macStyle, and nothing else. The fixture’s 0xC0 must become 0x80 = 128, keeping bit 7 USE_TYPO_METRICS, and macStyle must stay 0. If an implementation ever writes 0 to fsSelection here it has thrown away the OS/2 specification’s vertical-metrics opt-in along with the style bits.

covers E1, E2, E3 · authority: spec · fixture: recursive-vf

bitflags.all-six-set-preserves-unexposed

Setting all six checkboxes ORs in only those six

Setting every exposed bit must be a pure OR against the font’s existing value: 0xC0 with bits 0, 5, 6 and 8 added is 0x1E1 = 481, and macStyle 0 with bits 0 and 1 added is 3. Bit 7 USE_TYPO_METRICS must survive, and no bit the editor does not name may appear. The result is maximally self-contradictory – the OS/2 specification makes bit 6 REGULAR mutually exclusive with bits 0 ITALIC and 5 BOLD – and the corpus position is that this is written as asked and reported as a warning, not refused; see bitflags.contradiction-not-refused.

covers E1, E2, E3, E5 · authority: spec · fixture: recursive-vf

bitflags.bits-survive-full-instancing

Bits set by the user survive a full static instantiation

Instancing rewrites OS/2 (usWeightClass, usWidthClass, xAvgCharWidth) and head, so the order of operations matters: the original edits the bits after instantiateVariableFont returns, and any implementation must do the same or apply the edit to the final tables. With every axis pinned the output is a static font, and the requested fsSelection 0xA0 = 160 and macStyle 1 must be what is on disk. If the bits were applied before instancing and fontTools rewrote the field, this case would catch it.

covers E3, G1 · authority: slice-ui · fixture: recursive-vf

bitflags.bits-survive-partial-instancing

Bits set by the user survive a restricted-range slice

A partial instance keeps fvar and takes a different code path through fontTools than a full pin, but OS/2 and head are edited the same way afterwards, so the user’s bits must come out identical. Restricting wght to 300:700 keeps all five axes and must leave fsSelection at 0x1C0 = 448 – the fixture’s bits 6 and 7 plus the requested bit 8 WWS. Asserting the axis count alongside the bits shows the two edits are independent.

covers E3, G2 · authority: slice-ui · fixture: recursive-vf

bitflags.bold-bit-does-not-change-usweightclass

Setting BOLD does not move OS/2.usWeightClass

OS/2.usWeightClass is a numeric weight on the 1-1000 scale and fsSelection bit 5 BOLD is a style-map flag; the OS/2 specification defines them separately and nothing in it licenses deriving one from the other. fontTools sets usWeightClass from the pinned wght location, so pinning wght at 400 must give 400 even though the user has declared the instance Bold. Conflating them would silently overwrite a value the slicing operation computed from the design space with a guess derived from a checkbox.

covers E3, G7 · authority: spec · fixture: recursive-vf

bitflags.bold-bit-on-light-weight-is-allowed

BOLD may be declared on a light instance without altering usWeightClass

Pinning wght at the fixture’s default of 300 gives usWeightClass 300, and the user may still want that instance to be the Bold member of a two-style family – a legitimate arrangement for a light display family, and one the OS/2 specification does not forbid, since it nowhere ties bit 5 to a usWeightClass threshold. The tool must therefore write fsSelection 0xA0 = 160 and macStyle 1 while leaving usWeightClass at 300, and must not treat the pairing as an error. This is a bound on how far consistency checking may go: it covers the relationships the specifications actually state, not plausible-looking ones.

covers E3, E5, G7 · authority: judgement · fixture: recursive-vf

bitflags.consistent-bold-italic-pair

A coherent Bold Italic declaration sets all four style bits

Bold Italic is the fourth RIBBI style and the only one that legitimately sets two bits in each field: fsSelection bits 0 and 5, bit 6 clear, and macStyle bits 0 and 1. That is 0xA1 = 161 and 3, with bit 7 USE_TYPO_METRICS preserved. It is worth a case of its own because 0xA1 and the contradictory 0xE1 of bitflags.contradiction-not-refused differ by one bit, and an implementation that confuses mutual exclusion with a blanket ban on multiple bits would reject or mangle this conformant combination.

covers E1, E2, E3, E5 · authority: spec · fixture: recursive-vf

bitflags.consistent-bold-pair

A coherent Bold declaration sets both fields and clears REGULAR

This is what a correct Bold declaration looks like under both specifications at once: fsSelection bit 5 BOLD set, bit 6 REGULAR cleared because the OS/2 specification makes it mutually exclusive with bit 5, and head.macStyle bit 0 Bold set because the head specification requires the two to agree. The result must be fsSelection 0xA0 = 160, retaining bit 7 USE_TYPO_METRICS, and macStyle 1. The case matters as the negative control for E5: a conformant combination must not draw a warning, or the warning carries no information.

covers E1, E2, E3, E5 · authority: spec · fixture: recursive-vf

bitflags.consistent-italic-pair

A coherent Italic declaration sets both fields and clears REGULAR

Pinning slnt at -15 produces an oblique instance, and fontTools sets post.italicAngle from the pinned location but never the style bits, so declaring the instance italic is the user’s job. The conformant declaration is fsSelection bit 0 ITALIC set with bit 6 REGULAR cleared (mutual exclusion, OS/2) and head.macStyle bit 1 Italic set (agreement, head), giving 0x81 = 129 and 2. Like the Bold pair, this is a negative control for the E5 warning.

covers E1, E2, E3, E5 · authority: spec · fixture: recursive-vf

bitflags.contradiction-fsselection-bold-without-macstyle-bold

fsSelection BOLD without macStyle BOLD is written as asked

The head specification requires macStyle bits 0 Bold and 1 Italic to be consistent with OS/2.fsSelection bits 5 BOLD and 0 ITALIC; the two fields are duplicate declarations read by different platforms, and disagreement means an application picks a different style depending on which field it trusts. Ticking fsSelection bit 5 while leaving macStyle bit 0 unticked produces exactly that split – fsSelection 0xA0 = 160 with macStyle 0 – and the corpus requires it to be written, with a warning, rather than auto-mirrored. Auto-mirroring would be a second silent rewrite of the user’s request, of the same species as the E4 defect.

covers E5 · authority: judgement · fixture: recursive-vf

bitflags.contradiction-macstyle-italic-without-fsselection

macStyle ITALIC without fsSelection ITALIC is written as asked

This is the mirror of the previous case, in the direction the head specification names: macStyle bit 1 Italic set while OS/2.fsSelection bit 0 ITALIC stays clear. The two fields must agree, so the output (macStyle 2, fsSelection 192 with bit 0 clear) is non-conformant and the user should be told. It is still written, because the editor’s contract is that a ticked box means that bit, and because a font being repaired may legitimately pass through this state between edits.

covers E5 · authority: judgement · fixture: recursive-vf

bitflags.contradiction-not-refused

A wholly contradictory bit combination is a warning, never a refusal

Setting ITALIC, BOLD and REGULAR at once violates the OS/2 specification’s mutual exclusion twice over, and is the strongest case anyone could make for refusing the job. The corpus deliberately requires success, with fsSelection 0xE1 = 225 and macStyle 3, because refusing would make the tool unable to produce a font it can read: OS/2 permits these bits to be written and real fonts in the wild already carry contradictory combinations, so a hard error would block the very repair the operator came to make. The distinguishing check – that the tool said something – is the proposed warns check; without it this case only establishes that the job is not refused.

covers E5 · authority: judgement · fixture: recursive-vf

bitflags.contradiction-regular-and-bold

REGULAR together with BOLD is written as asked

The OS/2 specification states that fsSelection bit 6 REGULAR is mutually exclusive with bits 0 ITALIC and 5 BOLD – if bit 6 is set, bits 0 and 5 must be clear – so 0xE0, with bits 5 and 6 both set, is non-conformant. The corpus nevertheless requires the job to succeed and the bits to be written exactly as requested (224), because the operator may be mid-way through repairing a font and a tool that silently drops half of a two-checkbox edit is worse than one that obeys. The obligation the tool does have is to say so; that assertion needs the proposed warns check and is listed against this case in _proposed_checks.

covers E5 · authority: judgement · fixture: recursive-vf

bitflags.contradiction-regular-and-italic

REGULAR together with ITALIC is written as asked

Bit 6 REGULAR (“Glyphs are in the standard weight/style for the font”) and bit 0 ITALIC (“Font contains italic or oblique glyphs”) are contradictory statements about the same glyphs, and the OS/2 specification makes them mutually exclusive. The requested value 0xC1 = 193 must still be written rather than silently sanitised, on the same reasoning as the REGULAR+BOLD case: the tool does not know which of the two the user meant, and guessing would produce a font that differs from the one asked for with no record of the substitution. A warning is the correct channel; see _proposed_checks.

covers E5 · authority: judgement · fixture: recursive-vf

bitflags.fsselection-bit0-clear

Unticking OS/2.fsSelection bit 0 leaves ITALIC clear

Clearing a bit that is already clear must be a no-op on the whole field, not an excuse to rewrite it. The fixture is Recursive Sans Linear Light, an upright face whose fsSelection is 0xC0, so bit 0 ITALIC is already 0 and the OS/2 specification’s description of bit 0 (“Font contains italic or oblique glyphs, otherwise they are upright”) is already truthful. The output must therefore still be 0xC0 = 192, with bits 6 REGULAR and 7 USE_TYPO_METRICS untouched. This is the idempotence half of E3.

covers E1, E3 · authority: slice-ui · fixture: recursive-vf

bitflags.fsselection-bit0-set

Ticking OS/2.fsSelection bit 0 sets ITALIC

The OpenType OS/2 specification defines fsSelection bit 0 ITALIC as “Font contains italic or oblique glyphs, otherwise they are upright”, so the bit is a declaration about the glyphs and is exactly the kind of thing a slicing tool must let the operator restate after pinning a slant axis. The editor exposes this checkbox (E1), so ticking it must set that one bit and leave the rest of the 16-bit field as the font had it. The fixture’s fsSelection is 0xC0 (bits 6 and 7), so the correct result is 0xC1 = 193. The output is spec-inconsistent because bit 6 REGULAR remains set alongside bit 0; that is deliberate here to keep this case about the single checkbox, and the contradiction itself is adjudicated by the bitflags.contradiction-* cases.

covers E1, E3 · authority: slice-ui · fixture: recursive-vf

bitflags.fsselection-bit5-clear

Unticking OS/2.fsSelection bit 5 leaves BOLD clear

The fixture is a Light face with usWeightClass 300 and fsSelection 0xC0, so bit 5 BOLD is already 0, which is what the OS/2 specification’s “Glyphs are emboldened” requires of a font whose glyphs are not emboldened. An unticked checkbox for an already-clear bit must leave the field at 192; producing any other value would mean the editor is writing state it invented rather than state the user expressed.

covers E1, E3 · authority: slice-ui · fixture: recursive-vf

bitflags.fsselection-bit5-set

Ticking OS/2.fsSelection bit 5 sets BOLD

The OS/2 specification defines fsSelection bit 5 BOLD as “Glyphs are emboldened”. A user who has just pinned a variable weight axis at its heavy end is the canonical reason this checkbox exists, since instancing sets usWeightClass from the pinned location but never touches the style bits. Ticking bit 5 must OR 0x20 into the existing 0xC0, giving 0xE0 = 224, and must not disturb bit 7 USE_TYPO_METRICS, which the editor does not expose.

covers E1, E3 · authority: slice-ui · fixture: recursive-vf

bitflags.fsselection-bit6-clear

Unticking OS/2.fsSelection bit 6 clears REGULAR, and only that bit

Clearing REGULAR is a legitimate request when the operator is about to declare the sliced instance Bold or Italic instead, since the OS/2 specification makes bit 6 mutually exclusive with bits 0 and 5. What must not happen is collateral damage: the fixture’s 0xC0 must become 0x80 = 128, retaining bit 7 USE_TYPO_METRICS, which the editor does not expose. Note that 128 is also what the original writes for an untouched editor, for the wrong reason (E4) – here it is correct because the user asked for it.

covers E1, E3 · authority: slice-ui · fixture: recursive-vf

bitflags.fsselection-bit6-set

Ticking OS/2.fsSelection bit 6 sets REGULAR

The OS/2 specification defines fsSelection bit 6 REGULAR as “Glyphs are in the standard weight/style for the font”, and this fixture already declares itself Regular: its fsSelection is 0xC0, bits 6 and 7. A ticked bit-6 checkbox therefore asks for the state the font already has, and the output must still be 192. This case pins down that setting a bit is an assignment to 1, not a toggle; a toggle implementation would clear bit 6 here and produce 128.

covers E1, E3 · authority: slice-ui · fixture: recursive-vf

bitflags.fsselection-bit8-clear

Unticking OS/2.fsSelection bit 8 leaves WWS clear

The fixture does not set bit 8, so it makes no WWS claim, and an unticked checkbox must leave that so: the field stays 192. The OS/2 specification ties bit 8 to whether name IDs 21 and 22 are needed, so a tool that spuriously set it would be asserting something about the name table it has not checked. Clearing an already-clear high bit must not smear into the low byte either.

covers E1, E3 · authority: slice-ui · fixture: recursive-vf

bitflags.fsselection-bit8-set

Ticking OS/2.fsSelection bit 8 sets WWS

The OS/2 specification defines fsSelection bit 8 WWS as meaning the font “has ‘name’ table strings consistent with a weight/width/slope family without requiring use of name IDs 21 and 22”. Slicing changes exactly the thing that claim is about – the name records and the surviving design space – so the operator must be able to assert or retract it. Setting bit 8 on the fixture’s 0xC0 must give 0x1C0 = 448, which also proves the editor handles a bit above the low byte correctly.

covers E1, E3 · authority: slice-ui · fixture: recursive-vf

bitflags.italic-bit-does-not-change-italicangle

Setting ITALIC does not move post.italicAngle

post.italicAngle is a measured angle in degrees counter-clockwise from vertical, which fontTools derives from the pinned slnt location; fsSelection bit 0 and macStyle bit 1 are style flags. Pinning slnt at 0 must leave italicAngle at 0 even when the user declares the instance italic, because an upright face declared italic is a name-and-style-map decision and inventing a slant angle for it would misreport the outlines. The same independence argument as the BOLD/usWeightClass pair, in the other table.

covers E3, G7 · authority: spec · fixture: recursive-vf

bitflags.macstyle-bit0-clear

Unticking head.macStyle bit 0 leaves BOLD clear

The fixture’s head.macStyle is 0, so bit 0 Bold is already clear and an unticked checkbox must leave the whole field at 0. The head specification requires macStyle bits 7 to 15 to be 0 and gives bits 2 to 6 specific meanings (underline, outline, shadow, condensed, extended), so a write that produced anything but 0 would be asserting styles the font does not have.

covers E2, E3 · authority: slice-ui · fixture: recursive-vf

bitflags.macstyle-bit0-set

Ticking head.macStyle bit 0 sets BOLD without touching OS/2

The OpenType head specification defines macStyle bit 0 as “Bold (if set to 1)” and reserves bits 7 to 15, which “must be set to 0”. The fixture’s macStyle is 0, so ticking the bit-0 checkbox must produce exactly 1: no reserved bit invented, and bit 1 ITALIC left alone. It must also leave OS/2.fsSelection at 192, because macStyle and fsSelection are separate fields edited by separate checkboxes and a head edit is not licence to rewrite OS/2.

covers E2, E3 · authority: slice-ui · fixture: recursive-vf

bitflags.macstyle-bit1-clear

Unticking head.macStyle bit 1 leaves ITALIC clear

The fixture is upright with macStyle 0 and post.italicAngle 0, so bit 1 Italic is correctly clear and must stay clear. Per the head specification bit 1 means “Italic (if set to 1)”, a claim about the glyphs, and nothing in an unticked checkbox authorises changing it. The field must remain exactly 0.

covers E2, E3 · authority: slice-ui · fixture: recursive-vf

bitflags.macstyle-bit1-set

Ticking head.macStyle bit 1 sets ITALIC

The head specification defines macStyle bit 1 as “Italic (if set to 1)”. Ticking it on a font whose macStyle is 0 must give exactly 2, leaving bit 0 Bold clear and the reserved bits at 0. This is the head-table counterpart of fsSelection bit 0, and the two are required to agree; the agreement itself is the subject of the bitflags.consistent-* and bitflags.contradiction-* cases.

covers E2, E3 · authority: slice-ui · fixture: recursive-vf

bitflags.macstyle-edit-does-not-disturb-fsselection

Editing head.macStyle alone leaves the untouched OS/2 checkboxes alone

A user who ticks only the head.macStyle Italic box has said nothing about OS/2.fsSelection, so the four fsSelection checkboxes are still showing whatever the font contains and must be written back unchanged: 192, with bit 6 REGULAR intact. The original collects all six checkbox states on every slice regardless of which the user touched, so it writes fsSelection 128 here and destroys the REGULAR declaration as a side effect of a head edit; it is expected to fail. The resulting output is also inconsistent under the head specification’s requirement that macStyle bits 0 and 1 agree with fsSelection bits 5 and 0 – deliberately so, since this case is about preservation and bitflags.contradiction-macstyle-italic-without-fsselection covers the warning.

covers A5, E3, E4 · authority: spec · fixture: recursive-vf

bitflags.slnt-pin-does-not-set-italic-bits

Pinning a slant axis does not set the ITALIC bits by itself

fontTools’ set_default_weight_width_slant writes post.italicAngle from the pinned slnt location – here -15 – but only setRibbiBits touches fsSelection and macStyle, and instantiateVariableFont calls it solely when updateFontNames is true, which Slice does not pass. Style bits are therefore the user’s declaration, never an inference from geometry, which is right: an oblique instance is not automatically the family’s Italic style, and the OS/2 specification ties bit 0 to a claim about the font rather than to an angle. With the editor untouched the output must keep fsSelection 192 and macStyle 0 while italicAngle becomes -15.

covers E4, G7 · authority: fonttools · fixture: recursive-vf

bitflags.two-axis-clear-is-absolute

An unticked box means 0 regardless of what the font had

The mirror of the previous case, and the one that matters more, since clearing is how a user retracts a claim the font makes wrongly – a Regular-marked face being sliced into a Bold, say. Every one of the six exposed bits must read 0 afterwards, on a fixture whose starting value this case does not assume. Bits the editor does not expose are out of scope here and are pinned down on recursive-vf, where their initial values are known.

covers E1, E2, E3 · authority: slice-ui · fixture: two-axis

bitflags.two-axis-set-is-absolute

A ticked box means 1 regardless of what the font had

The editor’s contract is assignment, not toggling: a ticked box means the bit is 1 in the output whatever it was in the input. Asserting only bit_set on a second fixture, whose fsSelection this case does not depend on, separates that contract from the recursive-vf cases that hard-code 0xC0. An implementation that XORed the checkbox state against the font’s value would pass some recursive-vf cases by luck and fail here.

covers E1, E2, E3 · authority: slice-ui · fixture: two-axis

bitflags.unexposed-bit7-survives-a-clear

USE_TYPO_METRICS survives clearing an exposed bit

The dangerous direction for an unexposed bit is a clear, because an implementation that assembles fsSelection from the six checkboxes rather than editing the font’s value will zero everything it does not know about. Clearing bit 6 REGULAR must yield exactly 0x80 = 128: bit 7 USE_TYPO_METRICS still set, bits 0, 5 and 8 still clear. The OS/2 specification treats bit 7 as vertical-metrics policy that is independent of style classification, so no style edit may disturb it.

covers E3 · authority: spec · fixture: recursive-vf

bitflags.unexposed-bit7-survives-a-set

USE_TYPO_METRICS survives setting an exposed bit, in a static instance

The fixture has fsSelection bit 7 USE_TYPO_METRICS set, and the OS/2 specification says that when it is set “it is strongly recommended that applications use OS/2.sTypoAscender - OS/2.sTypoDescender + OS/2.sTypoLineGap as the default line spacing”. Dropping it silently changes line spacing in every application that honours it, so it is a layout regression, not a cosmetic one. The editor does not expose bit 7, so setting bit 5 BOLD must leave it set: 0xC0 becomes 0xE0 = 224. Pinning every axis at once makes the point on the static-instance path, where the most table rewriting happens.

covers E3, G1 · authority: spec · fixture: recursive-vf

bitflags.untouched-editor-preserves-bits-in-static-instance

An untouched editor preserves the REGULAR bit in a fully pinned instance

Pinning every axis is the commonest use of the tool and produces the font most likely to be installed and style-linked, so it is exactly where losing fsSelection bit 6 REGULAR does the most damage. fontTools’ instantiateVariableFont does not touch fsSelection or macStyle unless updateFontNames is passed, which the original does not pass, so any change seen here comes from the bit editor and not from instancing. The output must still be 192 with macStyle 0. The original writes 128 and is expected to fail.

covers A5, E4, G1 · authority: spec · fixture: recursive-vf

bitflags.untouched-editor-preserves-bits-into-woff2

Bit preservation is a property of the font, not of the container

OS/2 and head are ordinary sfnt tables that WOFF2 transports unchanged, so the container the user asks for must have no bearing on the style bits. Writing the same untouched-editor job to WOFF2 must still yield fsSelection 192 with bit 6 REGULAR set. This guards against an implementation that reconstructs OS/2 on the compression path, and it separates a container bug from a bit-editor bug when the previous two cases fail.

covers A5, E4, H1 · authority: spec · fixture: recursive-vf

bitflags.untouched-editor-preserves-fsselection

An untouched Bit Flag Editor changes no bit at all

This is the corrected form of the E4/A5 defect and the single most important case in this area. The fixture’s OS/2.fsSelection is 0xC0: bit 6 REGULAR, which the OS/2 specification describes as “Glyphs are in the standard weight/style for the font”, plus bit 7 USE_TYPO_METRICS. A user who slices an axis and never opens the bit editor has expressed no opinion about style classification, so the output must still read 192. The original unchecks all six boxes on load and writes that state, producing 128 – a font that declares itself neither Regular nor Bold nor Italic, which leaves style-linking and the RIBBI style-map ambiguous: Windows and layout engines that pick a family member by the RIBBI bits have nothing to match, and the face can fall out of its own family grouping. The original is expected to fail this case.

covers A5, E4 · authority: spec · fixture: recursive-vf

bitflags.usweightclass-700-does-not-set-bold-bit

A heavy pinned weight does not set the BOLD bits by itself

The converse of the previous case: pinning wght at 700 makes usWeightClass 700, but fsSelection bit 5 and macStyle bit 0 are declarations about the font’s role in its family and only the user can make them. Many families ship a 700 face that is the family’s Bold and many ship one that is a named weight in a larger set, and the OS/2 specification gives no rule for telling them apart from the number. With the editor untouched the output must keep fsSelection 192 and macStyle 0.

covers A5, E4, G7 · authority: fonttools · fixture: recursive-vf

names

The nine name records the editor exposes, and what happens to the ones it does not. A font’s names are how an operating system groups it into a family, so losing one is not cosmetic.

Covers claims: D1, D2, D3, D4, D5, D6, G1, G10, G2, G4, G5, G7.

names.d1.nine-editable-records-are-written

All nine editable rows reach the output as 3/1/1033 records

The editor exposes exactly nine name IDs — 1, 2, 3, 4, 6, 16, 17, 21, 22 — and the name specification says of them that “the key information for this table for Microsoft platforms relates to the use of name IDs 1, 2, 4, 16 and 17” and that “fonts should include all of these strings for the broadest application compatibility”. A slicer that offered a row and then dropped what was typed into it would be worse than not offering the row. This is the baseline for the whole area: nine cells in, nine records out, each holding exactly the typed string.

covers D1, D2, D3, D4 · authority: slice-ui · fixture: recursive-vf

names.d1.nine-rows-on-a-font-that-already-has-them

Editing all nine rows overwrites the records the font already had

The previous case creates records that were absent; this one replaces records that were present, which is a different path in any implementation that treats 16, 17, 21 and 22 as optional. Name ID 17 “must be unique within a particular typographic family”, so an implementation that appended a second 3/1/1033 record instead of replacing the first would leave the table with two competing typographic subfamily names. Exactly one record per key must come out, which is why the case also counts them: two records for ID 17, the edited English one and the untouched German one.

covers D1, D4 · authority: slice-ui · fixture: named-instances

names.d2.editor-reads-the-english-record

With the editor untouched the English strings are written back, not the German ones

The fixture’s German name ID 1 deliberately differs from its English one, so this case can tell which record an implementation loaded into its cells. Since the editor reads 3/1/1033 and always writes IDs 1, 2, 3, 4 and 6 back, an untouched slice must reproduce the English strings; had it read 3/1/1031 it would write “FixtureNamedInstances Deutsch” into the English record and silently rename the font for English users. The specification’s warning that applications “might display incorrect strings … if strings for the current platform are not included” is exactly why the localisations must not be conflated.

covers D2, D3 · authority: slice-ui · fixture: named-instances

names.d2.macintosh-records-survive-a-slice

A font with Macintosh platform records keeps every one of them

Platform 1 records are a legitimate part of the table — the specification lists platform ID 1 as valid in the name table and notes that “many newer platforms can use strings intended for different platforms if a font does not include strings for that platform”. Nothing about narrowing a design space invalidates a Macintosh string, so all of the fixture’s Mac records must be present and unchanged after a full pin with the name editor untouched. The encoding check matters because “strings for the Macintosh platform (platform ID 1) use platform-specific single- or double-byte encodings”: a tool that rewrote them as UTF-16BE would leave records no consumer can read.

covers D2, G10 · authority: spec · fixture: named-instances

names.d2.no-record-is-invented-on-another-platform

Creating name ID 16 creates one record, not one per platform

two-axis has Macintosh and Windows English records for IDs 1 to 6 but no ID 16 anywhere, so typing a typographic family name must produce exactly one new record, on the key the editor owns. An implementation that helpfully mirrored the edit onto platform 1 would be inventing data, and would have to encode arbitrary user text in a “platform-specific single- or double-byte encoding” that in general cannot represent it. The set of platform keys in the output must match the set in the input.

covers D2, D4 · authority: slice-ui · fixture: two-axis

names.d2.non-english-windows-records-survive-a-slice

German Windows records are preserved when the English ones are edited

The name table exists to hold “multilingual strings”, and the specification’s whole design is that “later, the font can be localized and the strings translated or added”. A localisation is not derivable from the English string and cannot be regenerated, so losing one is unrecoverable. Editing the English family and subfamily must leave all four German records exactly as they were, including the two that carry U+00FC and U+00DC — characters that survive a UTF-16BE round trip but not a naive Latin-1 or ASCII re-encode, which is why the case also pins the encoding the specification demands for platform 3.

covers D2 · authority: spec · fixture: named-instances

names.d2.only-windows-english-is-written

Editing name ID 1 changes the 3/1/1033 record and no other

The editor’s contract is one localisation: Windows, Unicode BMP, English (US). The specification blesses that key — “when building a Unicode font for Windows, the platform ID should be 3 and the encoding ID should be 1” — but it also treats every other record as an independent string in its own language and encoding, and a tool cannot translate the user’s text into German or re-encode it for Mac Roman. Writing the edit anywhere else would corrupt or mistranslate those records, so the Macintosh and German name ID 1 records must come out exactly as they went in, and there must still be three of them.

covers D2 · authority: slice-ui · fixture: named-instances

names.d2.records-remain-sorted

The output name table is still in the order the specification requires

The specification is explicit: “name records must be sorted first by platform ID, then by platform-specific encoding ID, then by language ID, and then by name ID.” Editing the table both adds and removes records, and an implementation that appends new ones to the end of the array, or leaves a hole where a removed one was, produces a table that violates that requirement even though every string in it is correct. The case is run on an input that both adds records (17 on the Mac platform is absent, 16 and 17 are rewritten) and removes them (21 and 22 are cleared).

covers D2, D4 · authority: spec · fixture: named-instances

names.d3.blank-mandatory-cells-still-write-records

Clearing rows 1, 2, 3, 4 and 6 leaves five empty records, not five absent ones

Name IDs 1, 2, 4 and 6 are the strings every platform looks up first, and the specification says fonts “should include all of these strings for the broadest application compatibility”. Deleting them would leave a font that names itself nowhere; writing whatever the user typed, including nothing, at least keeps the required records present and makes the emptiness visible to a validator rather than turning it into a missing record that consumers paper over differently. Verified with fontTools 4.62.1 that a zero-length 3/1/1033 record survives a save and reload as a present, empty string.

covers D3 · authority: slice-ui · fixture: two-axis

names.d3.cleared-family-is-empty-not-absent

A cleared family-name cell yields a present record whose string is empty

This is the sharp edge of the empty-versus-absent distinction, isolated on the single most important record. Name ID 1 is “the name the user sees” in the specification’s own worked example, and the rule for IDs 1 to 6 is the opposite of the rule for 16 to 22: the same empty cell means “write an empty string” here and “delete the record” there. A checker must be able to tell the two states apart, so the case asserts both that the record exists and that it is empty.

covers D3, D4 · authority: slice-ui · fixture: recursive-vf

names.d3.names-are-not-regenerated-from-the-pinned-location

Slicing to a different weight does not rewrite the family and subfamily names

fontTools can regenerate name records from STAT axis values, but only when asked: instantiateVariableFont’s updateFontNames defaults to False and the original does not pass it. Verified with fontTools 4.62.1 that pinning wght=1000 leaves name ID 1 saying “Recursive Sans Linear Light” while OS/2.usWeightClass becomes 1000. That mismatch is exactly why a Name Editor exists, and resolving it must stay the user’s decision: the specification’s four-style rule means the right name ID 1 for an ExtraBlack cut is a judgement about family grouping — “the Font Family name for the Arial Black font is Arial Black” — not something a slicer can derive.

covers D3, G7 · authority: fonttools · fixture: recursive-vf

names.d3.untouched-editor-preserves-the-five

An untouched name editor writes the font’s own strings back unchanged

Because the five mandatory rows are loaded from the font and then always written, doing nothing must be a no-op on those records. Any drift here — a trailing space, a re-encoding, a value picked up from another platform — would mean the editor cannot safely be left alone, which is the property nearly every other case in this area rests on. The expected strings are the ones read out of the fixture with fontTools 4.62.1.

covers D3, D5 · authority: slice-ui · fixture: recursive-vf

names.d4.blank-optional-row-on-a-font-without-it

Blank rows 16, 17, 21 and 22 on a font that never had them produce nothing

two-axis has none of the four optional records. Blank cells must therefore be a no-op: not a deletion of something absent, and above all not the creation of four empty records. An empty ID 16 would be worse than none at all, because “if name ID 16 is absent, then name ID 1 is considered to be the typographic family name” — the fallback only fires when the record is genuinely absent.

covers D4 · authority: slice-ui · fixture: two-axis

names.d4.clearing-16-does-not-touch-other-languages

Deleting the typographic family name removes the 3/1/1033 record only

The editor’s scope is one localisation, so its deletions are scoped the same way and the German name ID 16 must survive. The result is a table whose English typographic family name is absent while its German one is present — internally odd, but the alternative is a tool that silently deletes localisations the user never saw and cannot restore. Recording the consequence here makes it a deliberate part of the contract rather than an accident, and gives the D5 adjudication the full picture of what scoped deletion produces.

covers D4, D2 · authority: slice-ui · fixture: named-instances

names.d4.clearing-16-removes-only-16

Clearing row 16 deletes the typographic family record and leaves 17, 21 and 22

For IDs 16, 17, 21 and 22 an empty cell means “this font should not have this record”, and the specification makes absence a defined state with a defined fallback: “if name ID 16 is absent, then name ID 1 is considered to be the typographic family name.” An empty string would instead advertise a typographic family called nothing, which no fallback rescues. The other three records must be untouched, because the user cleared one cell and not four.

covers D4 · authority: slice-ui · fixture: named-instances

names.d4.clearing-21-and-22-removes-both

Clearing the two WWS rows deletes both records and leaves 16 and 17

IDs 21 and 22 are conditional by design: they are “used to provide a WWS-conformant family name in case the entries for IDs 16 and 17 do not conform to the WWS model”, and the specification adds that “if bit 8 of the OS/2 fsSelection field is set, a WWS Family Name entry should not be needed and should not be included”. A user who slices a font down to a WWS-conformant cut therefore has a real reason to delete them, and the tool must delete rather than blank: an empty ID 21 would still count as “an entry for this ID is included”, which the specification says must correlate with fsSelection bit 8 being clear.

covers D4 · authority: spec · fixture: named-instances

names.d4.clearing-all-four-optional-rows

Clearing 16, 17, 21 and 22 removes all four when the user asks for it

This is the output the original produces for every font it touches, but here it is produced because the user deliberately emptied four cells, which makes it correct rather than accidental. The distinction is the whole of D5: the same bytes are right when requested and wrong when imposed. Keeping this case next to the D5 cases makes the corpus say plainly that the deletion mechanism is fine and only its default is not.

covers D4 · authority: slice-ui · fixture: named-instances

names.d4.empty-versus-absent-in-one-output

The same empty cell leaves an empty record at ID 4 and no record at ID 21

This case exists to make the empty-versus-absent distinction impossible to miss: two cells are cleared in one run and the outcomes are opposite. ID 4 stays because the full font name is one of the strings the specification says fonts “should include … for the broadest application compatibility”; ID 21 goes because it is conditional and its absence is a defined state. An implementation that handles the empty string uniformly will fail exactly one of these two checks, which is the diagnosis the case is built to deliver.

covers D3, D4 · authority: slice-ui · fixture: named-instances

names.d4.typographic-names-written-when-non-empty

Non-empty rows 16 and 17 create records on a font that had none

recursive-vf has no name ID 16 or 17 on any platform, so this exercises creation rather than replacement. The specification recommends exactly this for a sliced family member: “for extended typographic families that includes fonts other than the four basic styles … it is strongly recommended that name IDs 16 and 17 be used in fonts to create an extended, typographic grouping.” A user pinning one cut out of a five-axis variable font is producing precisely such a member, so the editor must be able to add these records — and must not add 21 and 22, which were left blank.

covers D4 · authority: spec · fixture: recursive-vf

names.d4.whitespace-is-not-empty

A row containing a single space is written, not treated as cleared

The boundary between “write it” and “delete it” is an exact comparison against the empty string, so a cell holding one space falls on the write side. Nothing in the name specification forbids a string of whitespace, and the two candidate behaviours differ materially — one output has an ID 16 record and the other does not — so the corpus has to say which. Trimming would be a silent reinterpretation of what the user typed, the same fault the corpus rejects in B8; if a future version wants to refuse whitespace-only input it should refuse it out loud, not guess.

covers D4 · authority: judgement · fixture: named-instances

names.d5.editing-one-row-does-not-strip-the-other-four

Editing the family name leaves the typographic and WWS names alone

This is the shape the defect takes in ordinary use: the user changes one string and four unrelated records vanish. Under the corrected behaviour the four optional rows were loaded with the font’s own values, so writing them back is a no-op and only name ID 1 changes. The specification’s worked examples for extended families set IDs 1, 2, 16, 17, 21 and 22 together as one coherent set; a tool that lets a user retitle a font while silently discarding four of the six is not editing that set, it is truncating it. The original is expected to fail this case.

covers D5, D3, D4 · authority: spec · fixture: named-instances

names.d5.loaded-value-can-still-be-replaced

Loading 16 and 17 into the editor does not make them read-only

The fix for D5 is to load all nine rows rather than five; a careless fix that loaded them but then refused to overwrite would trade one defect for another. Name ID 17 “must be unique within a particular typographic family”, so a user slicing a new cut out of a family very often does have to change it. Loading and editing must compose: the loaded value appears in the cell, the typed value wins, and the German record is not dragged along with it.

covers D5, D4, D1 · authority: slice-ui · fixture: named-instances

names.d5.no-typographic-names-are-invented

A font with no 16, 17, 21 or 22 does not acquire them

Loading all nine rows means loading four empty ones when the font has no such records, and the correct output then has no such records either. The specification’s Times New Roman example shows a font that legitimately omits all four — “Preferred Family. No name string present, since it is the same as name ID 1” — so inventing them, for instance by copying ID 1 into ID 16, would add claims the font never made. recursive-vf is exactly such a font: its table holds only IDs 0 to 6 and 269 to 414, verified with fontTools 4.62.1.

covers D5, D4 · authority: slice-ui · fixture: recursive-vf

names.d5.other-language-typographic-names-are-not-the-fix

The German typographic names survive, which is what makes the English loss inconsistent

The original’s deletion is scoped to 3/1/1033, so a font carrying typographic names in more than one language comes out with the English pair missing and the German pair intact. That is diagnostic rather than mitigating: the specification treats the localisations as translations of one logical string, so a table asserting a German typographic family and no English one is self-contradictory, and consumers that fall back across languages may show German to English users. Both implementations should pass this case; it exists so the D5 adjudication sees the whole of what the original produces.

covers D5, D2 · authority: slice-ui · fixture: named-instances

names.d5.typographic-names-survive-a-partial-slice

A partial slice that leaves a variable font also keeps 16, 17, 21 and 22

The defect is in the name editor, not the instancer, so it applies just as much when the output is still variable — and the stakes are higher there, because the specification observes that “variable fonts can include a large number of named instances, each of which will use a shared typographic family name (name ID 16)”. Pinning wdth leaves five wght instances in fvar, and deleting ID 16 would leave all five without the family name they are meant to share. The original is expected to fail this case.

covers D5, G2 · authority: spec · fixture: named-instances

names.d5.untouched-editor-preserves-typographic-names

Slicing without touching the name editor keeps name IDs 16 and 17

This is the corrected behaviour for the corpus’s largest suspected defect: the original never loads IDs 16, 17, 21 or 22 into the editor, so all four cells are blank, and D4’s rule then deletes all four from every font it touches. The specification makes those records load-bearing for a fixture like this one, which has eight named instances: name ID 1 “should be shared among at most four fonts that differ only in weight or style (italic/oblique)”, whereas name ID 16’s “typographic family grouping doesn’t impose any constraints on the number of faces within it”, and “if name ID 16 is absent, then name ID 1 is considered to be the typographic family name”. Delete 16 and 17 from a family with more than four styles and every face falls back on the four-style ID 1 / ID 2 grouping, so the fifth and later styles can only be style-linked by being split into separate families — which is why the specification warns that applications assuming that grouping “are likely to provide a poor user experience with variable fonts”. The original is expected to fail this case.

covers D5, D4, D1 · authority: spec · fixture: named-instances

names.d5.untouched-editor-preserves-wws-names

Slicing without touching the name editor keeps name IDs 21 and 22

The same defect strips the WWS pair. IDs 21 and 22 are there for a face whose ID 17 “includes qualifiers for some attribute other than weight, width or slope”, and the specification ties them to a bit: “if an entry for this ID is included, bit 8 should not be set”. Deleting ID 21 while leaving fsSelection bit 8 clear leaves a font that is neither declared WWS-conformant nor annotated with a WWS name, so a layout engine has nothing to group it by. The original is expected to fail this case.

covers D5, D4 · authority: spec · fixture: named-instances

names.d6.version-and-other-non-editable-records-are-preserved

Name ID 5 is read-only, and records outside the nine are carried through

The editor reads name ID 5 only to show a version in the status bar; there is no row for it, so the string must survive, and so must every other name ID outside the nine. The specification requires ID 5 to “begin with the pattern Version .” and notes that “the first such match in the string can be used by installation software to compare font versions”, so blanking or bumping it would change how installers treat the file. ID 0 is the vendor’s copyright notice, which no font tool has any business rewriting. The case edits five rows the editor does own, to show that doing so perturbs neither.

covers D6, D1 · authority: slice-ui · fixture: recursive-vf

names.g10.axis-name-survives-its-own-axis-being-pinned

The Weight axis name record survives even though wght was pinned away

This is the subtlety that makes “drop unused” the wrong summary. fvar is gone, so nothing in fvar references name ID 271 Weight any more — but STAT keeps its design axis records in full, and each of those records must carry an AxisNameID, so 271 is still referenced and must stay. Verified with fontTools 4.62.1: all five axis-name records, 269 to 273, survive a full pin of recursive-vf. An implementation that pruned by walking fvar alone would delete records STAT still points at, leaving a STAT table with dangling name IDs.

covers G10, G5 · authority: fonttools · fixture: recursive-vf

names.g10.deleting-a-name-does-not-break-a-surviving-reference

After a partial slice every fvar and STAT name reference still resolves

Pruning guards against orphaned records; this checks the opposite fault, a live reference to a record that is gone. It is reachable in practice, and this fixture shows how: its default instance record uses subfamilyNameID 2, which the fvar specification explicitly permits — “values of 2 or 17 can be used” — so an instance can point at a name ID the name editor owns, and clearing row 17 on a font that used 17 there would leave fvar pointing at nothing. The case slices partially so that fvar and STAT both survive, and clears the two WWS rows, which nothing references, to establish that a legitimate deletion leaves every reference intact.

covers G10, D4 · authority: spec · fixture: named-instances

names.g10.editing-does-not-resurrect-pruned-records

Editing the name table does not bring back instance names the slice removed

The complement of the exact-set case: an implementation that rebuilt the name table from the source font in order to apply the user’s edits would restore the 128 instance-name records pruning had just removed, inflating the output and orphaning every one of them. The edits must be applied to the sliced table in place. The surviving set is the one verified with fontTools 4.62.1 for this location, and it must not grow because two cells were typed into.

covers G10, D3 · authority: fonttools · fixture: recursive-vf

names.g10.exact-surviving-name-ids-after-a-full-pin

Exactly sixteen name IDs remain after pinning Recursive to Sans Linear Regular

The pruning rule is not “drop what nothing references” but “drop the IDs that fvar or STAT referenced before and no longer do”, applied only above 255. Verified with fontTools 4.62.1 at MONO=0, CASL=0, wght=400, slnt=0, CRSV=0.5: the survivors are exactly IDs 0 to 6, the five STAT design-axis names 269 to 273, and the four STAT axis values that match the pinned location — 402 Sans, 404 Linear, 406 Regular, 413 Upright. Stating the whole set rather than a few members is what lets the case catch over-pruning and under-pruning with one assertion.

covers G10, G5 · authority: fonttools · fixture: recursive-vf

names.g10.exact-surviving-name-ids-on-named-instances

Pinning wght=700 keeps only the Bold and Normal value names

The same rule on the fixture the roster designates for name pruning, where the numbering is small enough to read. Verified with fontTools 4.62.1: pinning wght=700, wdth=100 leaves IDs 1 to 6, the two axis names 256 and 257, the STAT weight value 263 Bold, and the STAT width value 274 Normal — which survives despite being flagged elidable, because elision affects how a name is displayed, not whether the axis value matches. All eight instance names and their PostScript names go, as do the Condensed and Expanded value names at 273 and 275.

covers G10, G4, G5 · authority: fonttools · fixture: named-instances

names.g10.full-pin-can-prune-nothing-at-all

A font with no named instances loses no name records when every axis is pinned

two-axis declares both axes but no fvar instances, so the only name IDs above 255 are the two axis names, and STAT keeps its design axis records in full (G5) with their AxisNameID intact. Verified with fontTools 4.62.1: pinning wght=400, wdth=100 removes fvar and removes not one name record. This is the counterexample to reading G10 as “a full pin strips the name table” — what gets pruned depends entirely on what STAT still points at.

covers G10, G1, G5 · authority: fonttools · fixture: two-axis

names.g10.instance-names-pruned-on-full-pin

Pinning every axis drops the name records that only named fvar instances

With no fvar left, the 64 instance subfamily and PostScript name records reference nothing, and the specification reserves “name IDs 256 to 32767, inclusive … for font-specific names”, so these strings are private to the font and worthless once their referrer is gone. Verified with fontTools 4.62.1: pinning MONO=0, CASL=0, wght=400, slnt=0, CRSV=0.5 removes IDs 274 through 401 apart from those still reachable from STAT. The user-facing records at IDs 1 to 6 are untouched by pruning.

covers G10, G1 · authority: fonttools · fixture: recursive-vf

names.g10.layout-feature-label-survives

A stylistic-set UI label is not pruned, even though fvar and STAT never referenced it

This is the case that shows the rule is not “drop every name ID nothing in fvar or STAT points at”. The specification reserves “name IDs 256 to 32767, inclusive … for font-specific names such as those referenced by a font’s layout features”, and a GSUB FeatureParams UINameID is exactly such a reference; fontTools prunes only IDs that were variation-referenced before the operation and are not after, so a label that was never in that set is never a candidate. Deleting it would leave the ss01 feature pointing at nothing and strip the name a user interface shows for the feature. Note that the listed no_dangling_name_ids check would wrongly fail this output, which is why name_references_resolve is proposed above and used here instead.

covers G10 · authority: spec · fixture: named-instances

names.g10.partial-slice-keeps-the-instances-it-still-has

A partial slice prunes only the instance names whose instances went away

Pinning MONO, CASL, slnt and CRSV while leaving wght free reduces Recursive’s 64 named instances to the 8 upright Sans Linear ones, and only the other 56 may lose their names. Verified with fontTools 4.62.1: IDs 338/339, 346/347 and 394/395 — Sans Linear Light, Sans Linear and Sans Linear ExtraBlack with their PostScript names — survive, while the Mono (274) and Sans Casual (342) instance names are gone. The surviving fvar records must still resolve, since the specification requires an instance’s subfamilyNameID to name “entries in the name table”; recursive-vf carries no layout-feature labels, so on this fixture the stronger statement holds too, that every ID above 255 is reachable from fvar or STAT.

covers G10, G4, G2 · authority: fonttools · fixture: recursive-vf

names.g10.pruning-happens-before-the-name-edits

Records written by the editor are not pruned by the instancer that ran before them

Slicing runs first and the name edits second, so nothing the user typed can be swept away by pruning; the ordering matters because a tool that edited first and instanced second would lose any newly written ID above 255. All nine editable IDs are 255 or below and so are safe either way, but the case fixes the observable end state: after a full pin the edited strings are present and the pruned instance names are still gone. Verified with fontTools 4.62.1 at MONO=1, CASL=1, wght=700, slnt=-15, CRSV=1, where the surviving IDs above 255 are 269 to 273, 403, 409 and 414.

covers G10, D3, D4 · authority: slice-ui · fixture: recursive-vf

names.g10.reserved-name-ids-are-never-pruned

Name IDs 255 and below are outside the pruning rule entirely

The specification partitions the number space: “name IDs 26 to 255, inclusive, are reserved for future standard names. Name IDs 256 to 32767, inclusive, are reserved for font-specific names such as those referenced by a font’s layout features.” Only the second range can be private to fvar or STAT, so only it can be pruned; a standard name ID means the same thing to every consumer whether or not a table points at it. Verified with fontTools 4.62.1: adding IDs 16, 17, 21 and 22 to recursive-vf and then pinning every axis leaves all four in place, along with 0 and 5 — so any loss of those records is attributable to the name editor and not to the instancer.

covers G10, D4 · authority: spec · fixture: recursive-vf

names.g10.stat-axis-values-outside-the-slice-lose-their-names

STAT axis values dropped by the slice take their name records with them

STAT axis values whose coordinates fall outside the new limits are dropped (G5), and their ValueNameID records then have no referrer left, so they go too. Verified with fontTools 4.62.1: pinning wght=400 keeps 406 Regular and removes 405 Light, 411 Black and 412 ExtraBlack, while pinning CASL=0 keeps 404 Linear and removes 403 Mono. Keeping them would leave orphaned strings in the file; dropping the wrong ones would leave STAT pointing at a name record that is not there.

covers G10, G5 · authority: fonttools · fixture: recursive-vf

names.postscript.invalid-value-is-not-silently-repaired

A PostScript name containing a space is written unchanged rather than mangled

A space is outside the printable-ASCII subset the specification allows for name ID 6, so this input is not conformant — but the editor performs no validation, and of the two available behaviours, “write it as typed” and “quietly rewrite it”, the second is strictly worse: the user ships a name they never chose and never saw. Refusing the job would also be defensible, and if the project adopts that it should be recorded as a new claim in the behaviour map rather than arrived at by one implementation guessing. Until then the contract is verbatim, and complaining about the value is a validator’s job.

covers D1, D3 · authority: judgement · fixture: two-axis

names.postscript.written-verbatim

A valid PostScript name is written exactly as typed

Name ID 6 is the most constrained of the nine: “when translated to ASCII, the name string must be no longer than 63 characters and restricted to the printable ASCII subset, codes 33 to 126, except for the 10 characters ‘[’, ‘]’, ‘(’, ‘)’, ‘{’, ‘}’, ‘<’, ‘>’, ‘/’, ‘%’”. A conforming value must therefore pass through untouched — no case folding, no truncation, no normalisation — because the string “is used to invoke a PostScript language font that corresponds to this OpenType font” and any alteration breaks that lookup.

covers D1, D3 · authority: spec · fixture: two-axis

names.unicode.astral-plane-round-trips

Characters outside the BMP survive as UTF-16 surrogate pairs

Windows encoding ID 1 is named “Unicode BMP”, but the storage format is UTF-16BE, which encodes supplementary characters as surrogate pairs; verified with fontTools 4.62.1, U+1D11E and U+1F600 are stored as d834 dd1e and d83d de00 and decode back unchanged. What the case tests is that the tool neither truncates at the BMP boundary nor splits a pair, either of which would leave lone surrogates in the string storage and a string no decoder can recover. Whether a font ought to carry such a name is a separate question from whether an editor may silently corrupt what the user typed.

covers D2, D3 · authority: spec · fixture: two-axis

names.unicode.non-ascii-does-not-leak-onto-the-mac-platform

A non-ASCII edit is not mirrored onto a Macintosh Roman record

This is where the single-localisation rule earns its keep. Macintosh platform strings “use platform-specific single- or double-byte encodings according to the specified encoding ID”, and Mac Roman cannot represent most of this string, so any implementation that mirrored the edit onto 1/0/0 would have to corrupt it or fail. The Windows record takes the new text; the Mac record keeps its old text, still in mac_roman.

covers D2 · authority: spec · fixture: named-instances

names.unicode.non-ascii-round-trips

Non-ASCII text typed into a name cell comes back unchanged

For the key the editor writes, the specification is unambiguous: outside the code-page encodings 3, 4 and 5, “all string data for platform 3 must be encoded in UTF-16BE”, which covers the whole Basic Multilingual Plane, so Latin accents, Greek and CJK are all representable and must survive. Verified with fontTools 4.62.1: setting this string on 3/1/1033, saving and reloading returns it unchanged, stored as UTF-16BE. A tool that encoded to Latin-1 or fell back to ASCII would mangle names for most of the world’s scripts.

covers D2, D3 · authority: spec · fixture: two-axis

outlines-containers

The glyphs themselves, the removal of overlapping contours, and the file formats the result can be written in.

Covers claims: A3, G12, G13, G14, G2, G8, H1, H2.

cff.advances-match-source

A CFF2 instance has the advances the source has at that location

CFF2 has no gvar-style per-glyph metrics, so advances come from hmtx plus HVAR, and fontTools notes in the instancer that HVAR handling for CFF2 is incomplete (# TODO(anthrotype) Add support for HVAR/VVAR in CFF2, varLib/instancer/__init__.py:1103), with the metrics updated by a separate path (:1124). That makes this the CFF branch’s most likely silent failure: correct outlines at wrong widths. Advances are integers in hmtx, so half a unit is the whole rounding budget.

covers G13 · authority: fonttools · fixture: cff2-vf

cff.extension-does-not-convert-outlines

Typing .ttf for a CFF font does not invent TrueType outlines

Converting cubic CFF charstrings to quadratic glyf contours is a lossy re-drawing with its own error tolerance, and it is not what H1 describes: the extension selects the container, and .ttf and .otf are the same container. Silently producing approximated outlines because of a filename would be exactly the ‘silent mangling’ the corpus rejects — the user asked for a container, not a redraw. The output must remain a CFF-flavoured sfnt with no glyf table, and if an implementation believes the filename must be honoured, the only acceptable alternative is refusing the job, not converting.

covers H1, A3 · authority: judgement · fixture: cff2-vf

cff.partial-instance-stays-variable

Restricting an axis on a CFF2 font leaves a variable CFF2 font

CFF2 carries its variation data in the charstrings’ blend operators and the table’s own ItemVariationStore, so narrowing an axis means rewriting blends and re-tenting the store rather than deleting a gvar. An implementation that handles the fully-pinned case by throwing the variation data away will produce a font that is either static when the user asked for variable, or variable with a store that no longer matches its blends. Requiring fvar and CFF2 to both survive, and the outlines to be right at three sampled weights, is what distinguishes real partial instancing from deletion. The fixture’s wght axis is 400/400/900 – the default sits at the minimum, as it does in Recursive – so 400:700 contains the default as B7 requires, and the restricted axis comes out 400/400/700.

covers G2, G13, A3 · authority: fonttools · fixture: cff2-vf

cff.remove-overlaps-preserves-filled-region

Overlap removal on a CFF font preserves the filled region

CFF is the format where overlap removal is not merely an optimization: the Type 2 charstring specification says that overlapping subpaths are not permitted and that outlines must be non-overlapping for correct rendering, and PostScript interpreters fill CFF outlines with the non-zero rule but were never designed for self-intersection. fontTools reflects this by removing overlaps as part of any CFF2-to-CFF downgrade, ‘as CFF does not support overlaps but CFF2 does’ (varLib/instancer/__init__.py). The invariant is the same as for glyf — the filled region must not move — but the tolerance is tighter at half a unit, because CFF charstrings store fractional coordinates and the merged path is not forced onto the integer grid (fontTools uses noRound for the CFF path in ttLib/removeOverlaps.py). The original never removes overlaps (G14) and is expected to fail.

covers G14 · authority: spec · fixture: cff2-vf · the original does not have this feature

cff.static-instance-is-valid

A CFF2 variable font pinned on every axis yields a valid static CFF font

A tool asked to do something it cannot must refuse clearly rather than write a mangled font — the adjudicated verdict on B8 in this project — and for CFF2 the mangled outcome is concrete: charstrings still carrying blend operators in a font whose variation store has been removed, which the OpenType CFF2 specification makes meaningless because blend is defined only against an ItemVariationStore. But refusal is not required here, because fontTools 4.62.1 does implement CFF2 instancing: instantiateCFF2 (varLib/instancer/__init__.py:649) is dispatched whenever "CFF2" in varfont (:1747), so the original, which delegates wholesale to instantiateVariableFont, is expected to succeed. By the corpus rule that a case the original passes this implementation must also pass, the expectation is success with correct outlines; an implementation that genuinely cannot do this must emit an error naming CFF2, never a font.

covers A3, G13 · authority: fonttools · fixture: cff2-vf

cff.woff-container-wraps-cff-sfnt

A CFF font can be written into a WOFF container

WOFF wraps any sfnt, whatever outline format it holds; the WOFF 1.0 specification is explicit that the format is outline-agnostic and preserves the sfntVersion of the wrapped font. So the choice of container and the choice of outline format are independent, and this case is the one that proves an implementation has not conflated ‘WOFF’ with ‘compressed TrueType’. The outlines must survive the wrapper exactly, since WOFF adds nothing but compression.

covers H1, A3 · authority: spec · fixture: cff2-vf

container.composites-survive-woff2

Composite glyphs survive the WOFF2 glyf transform intact

The WOFF2 glyf transform encodes composite glyph data in its own stream, separate from the simple-glyph point streams, so composites are the part of the transform most likely to be reconstructed wrongly. A decoder that loses a component’s scale or its ARGS_ARE_XY_VALUES flag produces a glyph that still parses and still has the right component count but draws in the wrong place, which only an outline comparison catches. The fixture’s two-deep nesting means an error in the inner composite propagates and is visible in the outer one too.

covers A3, H1 · authority: spec · fixture: composites

container.read-otf

An OTF (CFF-flavoured sfnt) input is accepted

A3 claims all four of TrueType, CFF, WOFF and WOFF2 are readable because TTFont reads all four, and the open dialog offers .otf. Accepting .otf in the file filter and then failing on the file would be a broken promise made by the interface itself. cff2-vf is the roster’s only CFF-flavoured sfnt, so it is the fixture that exercises the OTTO branch of the reader; this case asserts only that the font is read and produces a font, with the CFF-specific questions handled in the cff.* cases.

covers A3 · authority: fonttools · fixture: cff2-vf

container.read-woff

A WOFF input is read and sliced like the sfnt it wraps

WOFF is defined as a lossless repackaging of an sfnt: the WOFF 1.0 specification states that the format ‘is a simple repackaging of the table-based sfnt structure’ and that decoding reproduces the original tables byte for byte. A tool that accepts .woff in its open dialog (dialogs.py:44) therefore has no excuse for behaving differently on it, and the outlines of a slice from the WOFF must be the same as from the TTF. This is the input half of A3; the output half is tested separately.

covers A3, H1 · authority: fonttools · fixture: recursive-vf-woff

container.read-woff2

A WOFF2 input is read and sliced like the sfnt it wraps

WOFF2 applies a reversible transform to glyf and loca before compressing, so unlike WOFF it does not simply hold the original bytes; the WOFF2 specification requires the transform to be exactly invertible and the reconstructed tables to be equivalent. That makes reading WOFF2 the strongest test that the input decoder is faithful, because a bug in the glyf reconstruction shows up directly as wrong outlines. If the outlines from the WOFF2 input match the source at the same location, the transform was reversed correctly.

covers A3, H1 · authority: fonttools · fixture: recursive-vf-woff2

container.ttf-in-otf-out

Typing .otf for a TrueType font does not convert the outlines

.ttf and .otf name the same container — a bare sfnt — and differ only in convention about which outline format is inside. Converting quadratic glyf curves to CFF cubics is a lossy re-drawing of every glyph that the user did not ask for, and the OpenType specification treats the outline format as a property of the font, not of the filename. So the honest reading of H1 is that the extension selects the container, and .ttf versus .otf selects nothing: the output must still be a glyf-flavoured sfnt.

covers H1 · authority: judgement · fixture: recursive-vf

container.ttf-in-ttf-out

A TrueType input saved with a .ttf name is a bare sfnt

This is the baseline every other container case is compared against, and the only combination on which the original and the corrected behaviour are certain to agree. sfnt_flavor must be truetype: an sfnt with sfntVersion 0x00010000 and a glyf table, no WOFF wrapper. It also fixes the reference output that the WOFF and WOFF2 round-trip cases assert equality against, so its id is load-bearing and must not be renamed.

covers A3, H1 · authority: fonttools · fixture: recursive-vf

container.ttf-in-woff-out

Typing .woff for a TrueType input must produce a WOFF

H1 states the output container follows the extension the user types, and a user who types .woff is asking for a web font. The original fails this case. TTFont.save does not look at the filename at all: it passes self.flavor to SFNTWriter (ttFont.py:411), and self.flavor was copied from the reader when the input was opened (ttFont.py:318), so a TTF input always writes a bare sfnt no matter what it is called. Verified with fontTools 4.62.1: slicing Recursive-VF.subset.ttf to o.woff yields a file whose flavor is None. Shipping an sfnt named .woff is a real defect, because a server sending it as font/woff produces a font a browser rejects.

covers H1 · authority: slice-ui · fixture: recursive-vf

container.ttf-in-woff2-out

Typing .woff2 for a TrueType input must produce a WOFF2

Same contract as the WOFF case and the same defect in the original: verified with fontTools 4.62.1, saving a TTF-read font to o.woff2 produces a bare sfnt of exactly the size of the sfnt, with flavor None. The original fails this case. WOFF2 is where it matters most in practice, since it is the only container most sites actually deploy, and a mislabelled sfnt is unusable there. The check is sfnt_flavor = woff2 together with an outline check, because a container that is right but whose glyf reconstruction is wrong is no better than the wrong container.

covers H1 · authority: slice-ui · fixture: recursive-vf

container.woff-in-ttf-out

A WOFF input saved with a .ttf name must produce a bare sfnt

The mirror image of the previous two, and the direction in which the original’s behaviour is most surprising. The original fails this case: because the flavor is inherited from the reader, a WOFF input always writes a WOFF, so a user who opens a .woff and saves as .ttf gets a WOFF file with a .ttf name — verified with fontTools 4.62.1, flavor woff, 1628 bytes. A desktop font manager asked to install that file will reject it. H1 says the extension decides, and the extension here says sfnt.

covers A3, H1 · authority: slice-ui · fixture: recursive-vf-woff

container.woff-in-woff-out

A WOFF input saved as .woff stays a WOFF

The one WOFF-output combination the original gets right, and it gets it right by accident: the flavor is inherited from the input rather than chosen from the extension, and here the two happen to agree. Including it makes the failure pattern of the four cross-container cases interpretable — a runner that fails all of ttf-in-woff-out, woff-in-ttf-out and their WOFF2 twins but passes this one is inheriting the flavor, not ignoring containers entirely. The outline check is carried through so that a WOFF-to-WOFF path is not exempt from G13.

covers A3, H1 · authority: fonttools · fixture: recursive-vf-woff

container.woff-input-roundtrips-to-ttf-input

Slicing the WOFF fixture gives the same result as slicing the TTF fixture

recursive-vf and recursive-vf-woff are the same font in two containers, so the losslessness of WOFF means slicing either with the same settings must give the same font. This is the input-side counterpart of the output round-trip and it catches a different fault: a reader that loses a table WOFF happens to store differently, or that reorders the glyph set. Because both sides go through the identical instancing, the comparison is exact and needs no tolerance.

covers A3, H1 · authority: spec · fixture: recursive-vf-woff

container.woff-output-roundtrips-to-ttf-output

The same slice written as WOFF holds the same tables and outlines as written as TTF

WOFF is specified as a lossless container: the WOFF 1.0 specification requires that decoding reproduce the sfnt table data exactly, and that the format add no capability beyond compression and metadata. So two runs of the identical job differing only in the typed extension must yield the same table inventory, the same glyph order and byte-identical outlines once the wrapper is removed. This is a much stronger invariant than checking each output against the source independently, because it admits no rounding slack at all: the two outputs come from the same instanced font.

covers H1 · authority: spec · fixture: recursive-vf

container.woff-preserves-hinting

Wrapping a hinted font in WOFF does not drop its hinting tables

WOFF stores whatever tables the sfnt had, with no table-specific knowledge, so prep, fpgm and cvt must survive the wrapper untouched. The reason to test it explicitly is that hinting tables are the ones an over-eager ‘web optimization’ step is most tempted to strip, and stripping them changes rendering on every Windows machine using the ClearType v1 path. Slicing is not subsetting, and nothing about choosing a web container authorizes discarding a font’s rasterization program.

covers H1 · authority: spec · fixture: hinted

container.woff2-in-ttf-out

A WOFF2 input saved with a .ttf name must produce a bare sfnt

The original fails this case for the same inherited-flavor reason: a WOFF2 input saved to o.ttf comes back with flavor woff2, verified with fontTools 4.62.1. Decoding WOFF2 and re-encoding it as WOFF2 also silently re-runs the glyf transform, so this case additionally confirms that a full decode-to-sfnt path exists and is exercised rather than the input bytes being passed through. The outline check is what proves the decode was faithful and not merely a repackaging.

covers A3, H1 · authority: slice-ui · fixture: recursive-vf-woff2

container.woff2-in-woff2-out

A WOFF2 input saved as .woff2 stays a WOFF2

The WOFF2 twin of the previous case, and the one that puts the full WOFF2 decode/encode cycle under an outline check: the input transform is reversed on read and re-applied on write, so a bug in either direction moves coordinates. Because the WOFF2 glyf transform is specified as exactly invertible, there is no error budget on the container itself; the 0.5 tolerance here is entirely the instancing rounding allowance, not a container allowance. Any outline error above that is a codec bug, not quantization.

covers A3, H1 · authority: fonttools · fixture: recursive-vf-woff2

container.woff2-input-roundtrips-to-ttf-input

Slicing the WOFF2 fixture gives the same result as slicing the TTF fixture

The WOFF2 input path reconstructs glyf and loca from a transformed representation, so this is the case that proves the reconstruction is exact rather than merely plausible. Comparing against the TTF-input result rather than against the variable source removes the instancing rounding from the comparison entirely, leaving only the codec under test. A WOFF2 decoder that gets composite glyph flags or the overlap-simple bit wrong will show up here and nowhere else in the corpus.

covers A3, H1 · authority: spec · fixture: recursive-vf-woff2

container.woff2-output-roundtrips-to-ttf-output

The same slice written as WOFF2 holds the same tables and outlines as written as TTF

WOFF2 compresses harder and transforms glyf/loca, but the WOFF2 specification still requires the decoded font to be functionally identical to the input sfnt, with the transform exactly reversible. Equality against the TTF output is therefore the right expectation and admits no tolerance: if a coordinate differs by one unit, the transform is not being reversed correctly. This is the check that would catch a WOFF2 encoder that quietly drops a table it thinks is redundant, which byte-size or parse checks would not.

covers H1 · authority: spec · fixture: recursive-vf

container.woff2-preserves-glyph-names

A WOFF2 round trip keeps the post table’s glyph names

WOFF2 defines an optional transform for the post version 2.0 table’s name storage, and a decoder that mishandles it silently renames glyphs to glyph00001 style placeholders. Glyph names are not cosmetic: they are how GSUB rules, this corpus’s own contour_count and substitutes checks, and any downstream tooling identify glyphs. This fixture’s post is format 2.0 with the names .notdef, a and a.italic, so the names surviving is directly observable.

covers A3, H1 · authority: spec · fixture: recursive-vf-woff2

container.zopfli-is-not-a-correctness-property

WOFF compression strength is deliberately not tested

H2 records that the original sets sfnt.USE_ZOPFLI = True before saving (instanceworker.py:69), but zopfli emits an ordinary DEFLATE stream: the WOFF 1.0 specification requires only that each table be compressed with zlib, and a decoder cannot tell which encoder produced the bytes. The only observable difference is file size, and asserting a size would fail any implementation that used a different but equally valid deflate library, which is a test of the toolchain rather than of the font. This case therefore covers H2 by asserting what actually matters — that the WOFF decodes to the right font — and records that no size or encoder assertion belongs in this corpus.

covers H2 · authority: judgement · fixture: recursive-vf

outlines.advances-match-source-at-interior

Advance widths in a static instance match the source’s advances at that location

An outline that is right with an advance that is wrong still ruins the text: spacing is as much a part of the instance as the shape. Advances at a location come from hmtx plus the HVAR item variation store, and this fixture has an HVAR, so an implementation that applies gvar but forgets HVAR produces correct glyphs at the wrong widths. hmtx.advanceWidth is uint16 (OpenType hmtx), so half a unit of rounding is again the natural bound; on this fixture fontTools reproduces the reference advances exactly, so 0.5 is generous.

covers G13 · authority: fonttools · fixture: recursive-vf

outlines.advances-match-source-at-mono-extreme

Advance widths match at the MONO=1 corner, where they change the most

Recursive’s MONO axis exists to change advance widths, so MONO=1 is the location where an HVAR mistake produces the largest visible error and the easiest one to diagnose. At a corner every region scalar is 0 or 1, so the reference advance is an exact integer and no rounding allowance is needed. Checking advances at both an interior point and a corner separates ‘HVAR ignored entirely’ from ‘HVAR interpolated wrongly’.

covers G13 · authority: fonttools · fixture: recursive-vf

outlines.composites-advances-match

Composite glyph advances are not disturbed by instancing

A composite may take its advance from a component via the USE_MY_METRICS flag (OpenType glyf), so an implementation that rebuilds composites, reorders components, or decomposes them can move the advance without touching a single outline coordinate. Advances are exact integers in hmtx, and instancing has no reason to change them beyond applying HVAR, so the reference is exact to within the same half-unit rounding as any other advance. Checking this separately from the outline check is what distinguishes ‘the shape moved’ from ‘the shape is right but sits in the wrong box’.

covers G13 · authority: fonttools · fixture: composites

outlines.composites-static-matches

Composite glyphs instance to the right outlines, including a nested one

A composite’s shape depends on its components’ outlines and on its own component offsets, both of which vary, so it is the one glyph kind where two independent delta paths must agree. Nesting two deep means a rounding error in the inner composite’s offset is inherited by the outer one, so the error budget compounds: each level rounds its offset to int16 (OpenType glyf, composite glyph description, ARG_1_AND_2_ARE_WORDS), and a scaled component multiplies its base glyph’s own rounding. That is why this case uses the check’s default tolerance of 1.0 rather than the 0.5 used for simple contours: half a unit per level, two levels.

covers G13 · authority: fonttools · fixture: composites

outlines.contour-structure-preserved

Instancing does not change how many contours a glyph has

Variation deltas move points; they cannot add, remove or merge contours, because gvar deltas are indexed by point number against a fixed point set (OpenType gvar, ‘the number of deltas must match the number of points in the glyph’). So a static instance must have exactly the contour and point structure of the source glyph. a in this fixture has two contours, an outer and a counter, and an instance that reports any other number has rebuilt the glyph rather than interpolated it.

covers G13 · authority: spec · fixture: recursive-vf

outlines.hinted-static-matches

Instancing a hinted font does not move its outlines

Hinting is a program that runs at rasterization time and never alters the stored coordinates, so the presence of prep, fpgm, cvt and glyph instructions must make no difference at all to the outlines an instance carries. An implementation that decompiles and recompiles glyphs in order to preserve or strip instructions is exactly the kind that perturbs coordinates in passing, and this case catches that. Tolerance is 0.5, unchanged from the unhinted case, because nothing about hinting justifies a larger error budget.

covers G13 · authority: fonttools · fixture: hinted

outlines.partial-instance-matches-across

A partially instanced font still interpolates correctly across the surviving axis

When some axes are pinned and one is left, the output is still variable and G13 has to hold at every location in the reduced space, not just at its default. This is strictly harder than the static case: the gvar tuples for the pinned axes must be folded into the base outlines and into the surviving axis’s deltas, and an implementation that gets the default right can still get the ends of the surviving axis wrong. Sampling the retained range at its minimum, middle and maximum catches exactly that failure. The tolerance is 0.5 for the same int16 reason, applied to the interpolated result at each sample.

covers G2, G13 · authority: fonttools · fixture: recursive-vf

outlines.static-matches-source-at-default

A static instance pinned at every axis default has the variable font’s default outlines

The default location is the one place where no interpolation happens at all: the glyf table already holds those coordinates and every gvar delta scales to zero, so the instance must reproduce them exactly. Any deviation here is not rounding, it is a bug in delta application or in the normalization of the requested location onto the axis. This is the cheapest possible check of G13 and the one whose failure localizes the fault fastest, which is why it is worth having separately from the interior-location cases.

covers G13 · authority: fonttools · fixture: recursive-vf

outlines.static-matches-source-at-extremes

A static instance pinned at the axis extremes matches the source rendered there

At a corner of the design space every axis normalizes to exactly +/-1, so each region’s scalar is 1 or 0 and the deltas are applied undivided; the arithmetic is exact and the result must be integer-identical to the source. Corners are also where a sign error or a swapped peak/intermediate tuple shows up most loudly, because that is where the deltas are largest. A tolerance of zero is therefore defensible here and is deliberately stricter than the interior case.

covers G13 · authority: fonttools · fixture: recursive-vf

outlines.static-matches-source-at-fractional

A pin at a fractional user coordinate matches the source rendered there

A user coordinate that is not a round number exercises the full chain: float() parsing, normalization to F2Dot14, the avar segment map, region scalars, and one final rounding to int16. Locations like wght=653.7 are exactly where an implementation that normalizes with the wrong precision, or that rounds the normalized coordinate to F2Dot14 at the wrong moment, drifts away from the reference. Half a unit is still the right bound, for the same int16 reason as the interior case, and it holds empirically on this fixture at this location.

covers G13 · authority: fonttools · fixture: recursive-vf

outlines.static-matches-source-at-interior

A static instance pinned inside the design space matches the source rendered there

G13 is the whole point of instancing: the sliced font must draw what a variation-aware renderer draws for the source at the same location, or the slice has silently changed the design. The tolerance is half a unit because glyf coordinates are int16 (OpenType glyf, simple glyph description), so a renderer’s floating-point interpolated position must be rounded exactly once to reach the static instance and can differ by no more than 0.5 units. Measured against fontTools 4.62.1 on this fixture the largest disagreement over all glyphs at wght=650 and other interior locations is 0.4998 units, so 0.5 is tight but achievable; anything above it is a real disagreement about the shape, not quantization.

covers G13 · authority: fonttools · fixture: recursive-vf

outlines.two-axis-partial-matches-across

Pinning one of two axes leaves the other interpolating correctly

Folding a pinned axis into a two-dimensional delta set is the smallest case in which intermediate-region tuples have to be re-tented onto a lower-dimensional space, and it is where an off-by-one in region indexing shows up without any avar or feature-variation noise. If the surviving axis interpolates correctly at its minimum, default and maximum after the fold, the reduction preserved the design; if it is right only at the default, the deltas were dropped rather than folded. The tolerance is 0.5, the int16 quantization bound.

covers G2, G13 · authority: fonttools · fixture: two-axis

outlines.two-axis-static-matches

A minimal two-axis font instances to the right outlines

recursive-vf has an avar, feature variations and five axes, so a failure there has many possible causes; two-axis is the control, with simple contours, two axes and nothing else, so a failure can only be delta application. Having the same property tested on both a minimal and a realistic fixture is what makes a G13 failure diagnosable rather than merely visible. The fixture is specified to carry wght 100-900 default 400 and wdth 75-125 default 100, and this case pins an interior point on both.

covers G13 · authority: fonttools · fixture: two-axis

overlap.advances-are-not-changed

Overlap removal does not change any advance width

Overlap removal is an operation on outlines; advance width is metrics, and nothing about merging two contours tells you anything about how much room the glyph should occupy. fontTools is explicit about the boundary: after replacing a glyph it rewrites only the left side bearing to match the new xMin, leaving the width alone — hmtxTable[glyphName] = (width, glyph.xMin) (ttLib/removeOverlaps.py). An implementation that recomputes advances from the merged bounding box would reflow every line of text set in the font. The original never removes overlaps (G14) and is expected to fail.

covers G14 · authority: fonttools · fixture: overlapping · the original does not have this feature

overlap.clean-glyph-is-left-alone

A glyph with no overlaps is not rebuilt

Passing a clean glyph through a boolean engine costs something and gains nothing: the engine renumbers points, may re-fit curves through rounded intersection points that were never needed, and can convert an exact quadratic into an approximation. fontTools takes exactly this position – removeTTGlyphOverlaps compares the simplified path with the original and replaces the glyph only if not _same_path(path, path2) (ttLib/removeOverlaps.py). So clean must come out of a removal run with the same points, in the same order, as the same job produces with the feature off. The comparison is against that control run rather than against the source font, because instancing to wght=400 moves every outline on its own, and it is scoped to clean because bars in the same font is required to change – overlap.two-bars-merge-into-one-contour asserts exactly that. Point-level rather than drawn-outline equality is the point: a rebuilt glyph can draw the same shape with different points, and that is the cost this case exists to forbid. The original never removes overlaps (G14) and is expected to fail.

covers G14 · authority: judgement · fixture: overlapping · the original does not have this feature

overlap.control-same-job-without-removal

The same slice with overlap removal off, as a control for what removal changes

Not an obligation of its own but the baseline the removal cases measure against. It runs exactly the job overlap.clean-glyph-is-left-alone runs, with the one checkbox off, so that any difference between the two outputs is attributable to overlap removal and nothing else. Comparing a removal run against the source font cannot do that job, because instancing to wght=400 changes the outlines by itself. Every implementation is expected to pass this, including the original, which is simply doing an ordinary slice.

covers G14 · authority: judgement · fixture: overlapping

overlap.counter-inside-a-counter-survives

A circled letter keeps all four levels of its nesting

A circled ‘o’ nests fill inside hole inside fill inside hole: ring outer, ring counter, letter outer, letter counter. This is the case that rules out the naive algorithm of unioning the outward-wound contours and subtracting the inward-wound ones, which is correct only to depth two and turns the letter inside the ring into a hole at depth three. The non-zero winding rule counts crossings rather than alternating, so the right implementation is winding-aware at arbitrary depth. The original never removes overlaps (G14) and is expected to fail; a naive new implementation is expected to fail here while passing the plain-counter case, which is exactly why both exist.

covers G14 · authority: spec · fixture: overlapping · the original does not have this feature

overlap.counter-survives

The counter of an ‘o’ is still a hole after overlap removal

TrueType fills with the non-zero winding rule (OpenType glyf), under which a counter is a contour wound opposite to the outer contour and therefore subtracts. A boolean-union implementation that treats every contour as an additive region fills counters in and destroys ‘o’, ‘e’, ‘a’, ‘B’ and ‘p’ throughout the font — a failure that is invisible to any outline-coordinate comparison but obvious to filled_region_matches. Two contours must remain and the hole must remain a hole. The original never removes overlaps (G14) and is expected to fail.

covers G14 · authority: spec · fixture: overlapping · the original does not have this feature

overlap.filled-region-preserved-font-wide

Overlap removal changes no glyph’s filled region anywhere in the font

This is the defining property of the feature and the reason filled_region_matches exists: the outline is supposed to change, so comparing coordinates would reject every correct implementation, while comparing the set of points inside the glyph accepts exactly the correct ones. The tolerance is one unit because the merged boundary passes through intersection points computed in floating point and then rounded to the integer grid, so the boundary can move by up to half a unit at a vertex and slightly more where a curve is refitted through rounded points. Anything beyond a unit is a change of shape, not of representation. The original never removes overlaps (G14) and is expected to fail.

covers G14 · authority: judgement · fixture: overlapping · the original does not have this feature

overlap.fixture-actually-overlaps

Without overlap removal the overlapping fixture really does self-intersect

Every case in this section asserts that overlaps are gone, and all of them would pass vacuously against a fixture that never had any. This case is the control: with remove_overlaps off, no_self_intersections must fail, which is asserted here with not. It also records the pre-removal state that the untouched-glyph and advance-preservation cases compare against, so its id is load-bearing.

covers G12, G14 · authority: fonttools · fixture: overlapping

overlap.hinting-is-dropped

Removing overlaps drops the font’s hinting

TrueType instructions address points by index (SRP0, MDAP, IUP and the rest all take point numbers), and the CVT program’s assumptions about which point is which stop holding the moment contours are merged and points renumbered. Running a stale program against a new point set does not degrade gracefully; it moves the wrong points and distorts the glyph at small sizes. fontTools takes the same view and drops hinting from the whole font by default, noting that ‘removing overlaps invalidates the hinting’ and that leaving only some glyphs hinted ‘would look weird’ (ttLib/removeOverlaps.py). The original never removes overlaps (G14) and is expected to fail.

covers G14 · authority: judgement · fixture: hinted · the original does not have this feature

overlap.hinting-kept-when-not-removing

Instancing alone does not drop hinting

The previous case’s justification for dropping hinting is specific to renumbered points, so it must not leak into the ordinary path: instancing moves points but does not add, remove or reorder them, so instructions remain valid and must be kept. Without this case an implementation could ‘pass’ the hinting-dropped case by never shipping hinting at all, which would silently degrade every font the tool touches. Together the two cases say precisely when hinting may be discarded and when it may not.

covers G13, G14 · authority: fonttools · fixture: hinted

overlap.metrics-tables-recalculated

head and maxp describe the merged outlines, not the originals

Merging contours changes point counts and can change the bounding box, and both are recorded in tables that renderers trust without re-deriving: head.xMin/yMin/xMax/yMax and maxp.maxPoints/maxContours (OpenType head, maxp). A rasterizer that allocates a point buffer from maxp.maxPoints will overflow or truncate if the value is stale. So the recalculation G8 already requires on save has to happen after removal, not before it. The original never removes overlaps (G14) and is expected to fail this case.

covers G8, G14 · authority: spec · fixture: overlapping · the original does not have this feature

overlap.no-overlap-flags-after-removal

A font with overlaps removed does not claim to have overlaps

OVERLAP_SIMPLE and OVERLAP_COMPOUND tell a rasterizer ‘this glyph self-overlaps, composite it before filling’ (OpenType glyf, simple glyph flags), which on some rasterizers is a slower path and on others changes anti-aliasing at the seams. Setting them on a font whose overlaps have just been removed is a false statement about the outlines and gives up the performance the removal was for. fontTools treats the two as mutually exclusive branches of the same if, setting flags only under KEEP_AND_SET_FLAGS and never under REMOVE (varLib/instancer/__init__.py). The original never removes overlaps (G14): it always takes the flag-setting branch (G12) and is expected to fail.

covers G12, G14 · authority: spec · fixture: overlapping · the original does not have this feature

overlap.no-self-intersections-font-wide

No glyph in the output has a contour crossing itself or another

The point of the feature is to produce outlines usable by consumers that cannot handle overlaps — PostScript printers, the CFF outline format, and pre-composition renderers that stroke rather than fill. A per-glyph pass that resolves the glyphs the test happens to name but leaves others intersecting delivers none of that, so the property has to be asserted over the whole font. Paired with the control case, which asserts the same font does self-intersect before removal, this is a real before/after and not a tautology. The original never removes overlaps (G14) and is expected to fail.

covers G14 · authority: judgement · fixture: overlapping · the original does not have this feature

overlap.non-overlapping-composite-is-not-decomposed

A composite whose components do not overlap keeps its components

Composites exist to share outline data; decomposing them for no reason multiplies a font’s glyf size by the number of times each base glyph is reused, which for an accented Latin font is substantial. Since there is no overlap to resolve, decomposing buys nothing and costs both size and the USE_MY_METRICS relationship between the composite and its base. fontTools decomposes only when componentsOverlap returns true, and that restraint is the correct behaviour, not an implementation detail. The original never removes overlaps (G14) and is expected to fail.

covers G14 · authority: judgement · fixture: composites · the original does not have this feature

overlap.output-in-woff2-is-still-clean

Overlap removal and a WOFF2 container compose without interfering

The WOFF2 glyf transform re-encodes point flags and coordinates, and removal has just rewritten every merged glyph’s point set, so this pairing is where an ordering bug — transforming before merging, or recomputing loca from stale data — would surface. There is no reason the two features should interact, and asserting that they do not is cheaper than debugging it later. The filled region and the absence of self-intersections must both survive the round trip through the container. The original never removes overlaps (G14) and is expected to fail.

covers G14, H1 · authority: judgement · fixture: overlapping · the original does not have this feature

overlap.overlapping-components-are-merged

A composite whose components overlap is decomposed and merged

A composite’s overlap lives between its components, not inside any one of them, so it cannot be resolved without flattening the glyph into contours first; fontTools decomposes exactly and only in that case, testing componentsOverlap before replacing the glyph (ttLib/removeOverlaps.py). The result must be a simple glyph, because a composite by definition re-instantiates its base glyphs and cannot express the merged boundary. The filled region is again the invariant: decomposition plus merge changes the description completely while the picture stays put. The original never removes overlaps (G14) and is expected to fail.

covers G14 · authority: judgement · fixture: composites · the original does not have this feature

overlap.removal-does-not-change-glyph-count

Overlap removal neither adds nor removes glyphs

Glyph IDs are the currency of cmap, GSUB, GPOS and hmtx; inserting or dropping a glyph renumbers all of them and breaks every reference in the font. Decomposing a composite is a change to one glyph’s description, not to the glyph inventory, so the count must be unchanged even where composites are flattened. This is the invariant that catches an implementation that, say, appends merged results as new glyphs rather than replacing them in place. The original never removes overlaps (G14) and is expected to fail.

covers G14 · authority: spec · fixture: overlapping · the original does not have this feature

overlap.requires-a-static-font

Overlap removal on a still-variable output must be refused, not ignored

gvar deltas are addressed by point number against a fixed point set (OpenType gvar: the delta count must match the glyph’s point count), and merging contours renumbers and re-counts the points, so every delta in the font would then refer to a different point. There is no way to remove overlaps from a font that is still variable and keep it correct; fontTools agrees and simply skips the work, guarding it with if 'fvar' not in varfont and documenting that ‘the overlap parameter only has effect when generating full static instances’ (varLib/instancer/__init__.py). Silently doing nothing when the user ticked the box is the same defect shape as B8, whose adjudicated verdict in this project is that an unsupported request must be refused rather than quietly reinterpreted, so the correct behaviour is a clear error. The original has no such option at all (G14) and fails this case for a different reason.

covers G14, G2 · authority: judgement · fixture: recursive-vf · the original does not have this feature · expects a refusal mentioning overlap, static

overlap.self-intersecting-contour-resolved

A contour that crosses itself is resolved without changing the filled region

A single contour that crosses itself — a figure-eight or a bowtie — has no ‘other contour’ to union with, so an implementation built only around pairwise contour booleans has nothing to do and leaves it alone. Under non-zero winding a bowtie fills both lobes and a figure-eight cancels its inner region, so the correct result depends on the crossing’s orientation and cannot be guessed. Requiring no_self_intersections on the output while requiring the filled region to be unchanged pins down both halves of the answer. The original never removes overlaps (G14) and is expected to fail.

covers G14 · authority: judgement · fixture: overlapping · the original does not have this feature

overlap.two-bars-merge-into-one-contour

Two crossing bars become a single contour with the same filled region

Two crossing rectangles under the non-zero winding rule fill a plus sign; removing the overlap must produce the outline of that plus sign, which is one contour of twelve points, not two rectangles. This is the simplest possible statement of what overlap removal means, and the contour count is the observable that distinguishes ‘merged’ from ‘left alone but flagged’. The filled region must be identical because the rendered result was already the plus sign — overlap removal changes the description, never the picture. The original never removes overlaps (G14): it passes no overlap argument to instantiateVariableFont, so it is expected to fail this case.

covers G14 · authority: judgement · fixture: overlapping · the original does not have this feature

partial-instancing

Narrowing the design space while the font stays variable — fewer axes, or the same axes over smaller ranges. Harder than pinning, because the variation data has to be rewritten to say the same thing about a smaller space rather than simply discarded.

Covers claims: B1, B2, B3, B5, B6, B7, B9, C1, G1, G10, G13, G2, G3, G4, G5, G6, G8.

partial.advances-still-vary-across-a-restricted-range

Advance widths still vary across a narrowed axis, not frozen at the default

A variable font’s hmtx holds only the default master’s advances; the variation lives in HVAR, or for a glyf font in the phantom points gvar carries at the end of every glyph. An implementation that narrows an axis and drops one of those without rescaling the other produces a font that still looks right at its default location and has frozen widths everywhere else – text set at any other weight or width then mis-spaces, with outlines that are perfectly correct. Nothing else in the corpus can see that: every other advance check samples a single location, and a frozen font passes at whichever location happens to be its default. two-axis is the fixture because its masters really do differ in advance – H runs 372 at wdth=50, 600 at 100 and 960 at 200 – so the three samples here separate a font whose widths still interpolate from one whose widths are constant.

covers G2, G13 · authority: fonttools · fixture: two-axis

partial.avar-renormalized-across-restricted-range

avar is renormalized onto the narrowed extent, so weights still land where they did

After restricting wght to 300:700, the value 700 normalizes to +1 where it used to normalize to about +0.571; if avar’s map were carried over unchanged, every weight in the surviving range would be evaluated at the wrong point of the segment map and the outlines would be wrong everywhere except the default. G3 says fontTools rewrites the map, and measurement confirms it: the wght map becomes {-1: -1, 0: 0, 0.25006103515625: 0.48614501953125, 0.5: 0.657470703125, 0.75006103515625: 0.8287353515625, 1: 1}. The observable consequence — and the reason this is checked with outlines rather than table contents — is that the sliced font drawn at wght 550 must match the source drawn at wght 550; measured maximum coordinate deviation across 300, 350, 400, 550 and 700 was 0.5 units, comfortably inside the tolerance.

covers G3, G13 · authority: fonttools · fixture: recursive-vf

partial.avar-survives-with-its-axis

avar is kept when an axis that has a segment map survives

avar’s segment maps are what make Recursive’s wght axis perceptually even; dropping the table would silently change every interpolated weight, because the normalized coordinate a renderer computes from fvar would no longer be corrected. A partial slice that keeps wght must therefore keep wght’s map. Measured: the output still has an avar, carrying the wght map alone once the other four axes are pinned — the pinned axes’ maps go with their axes, because avar maps are positional against fvar’s axis order.

covers G2, G3 · authority: fonttools · fixture: recursive-vf

partial.axis-order-preserved-first-and-last

Surviving axes keep their original fvar order (MONO, CRSV)

The companion to the previous case, chosen so that the two survivors are the first and last of the five, with three removed from between them. If a program rebuilt fvar by iterating the user’s edits rather than the font’s axis list, this is the case that would come out as CRSV, MONO — and every positional reference into fvar would then point at the wrong axis. Measured: fontTools yields MONO then CRSV.

covers C1, G2 · authority: fonttools · fixture: recursive-vf

partial.axis-order-preserved-first-and-third

Surviving axes keep their original fvar order (MONO, wght)

fvar’s axis order is not cosmetic: it is the index space that avar segment maps, gvar tuple regions, item variation store regions, named-instance coordinate arrays and GSUB/GPOS condition tables all address axes by. Removing axes must therefore compact the list in place rather than reorder it, and the surviving axes must appear in the order the source font declared them. Recursive-VF declares MONO, CASL, wght, slnt, CRSV; pinning CASL, slnt and CRSV must leave MONO before wght, which is what fontTools produces.

covers C1, G2 · authority: fonttools · fixture: recursive-vf

partial.axis-order-preserved-restricted-plus-whole

Order holds when one survivor is restricted and the other is left whole

B1 says an empty cell leaves an axis alone, and B2 says a cell with a colon restricts it; the two kinds of survivor must end up in the same list, ordered by the source fvar and not by which kind they are. wght is Recursive’s third axis and CRSV its fifth, so a correct output declares wght first even though CRSV was the axis the user did not touch. Measured: wght 300/300/700 then CRSV 0/0.5/1.

covers B1, B2, C1, G2 · authority: fonttools · fixture: recursive-vf

partial.centred-default-asymmetric-restriction

Trimming only the upper half leaves the lower half’s mapping alone

100:650 leaves the lower half of the centred axis exactly as it was and shrinks only the upper half, so the two halves must be rescaled by different factors — 1 and 500/250. This is the sharpest test of independent renormalization: a program using one span for the whole axis would either distort the untouched half or leave the trimmed half unscaled, and either error is invisible at the default. Sampling below, at and above the default is what makes both failures observable.

covers B7, G2, G13 · authority: fonttools · fixture: centred-default

partial.centred-default-lower-half-only

A centred axis trimmed to its default upward stays variable downward

100:400 keeps the default at the range maximum, which B7 admits, and leaves an axis that varies only below its default. The positive normalized half is then empty, so every gvar tuple whose peak is positive on this axis becomes unreachable and must be dropped while the negative-side deltas are preserved untouched. Getting this wrong shows up as the light end of the axis losing its variation or as leftover deltas that can never fire.

covers B7, G2, G13 · authority: fonttools · fixture: centred-default

partial.centred-default-range-above-default-refused

On a centred axis, a range above the default is refused

The centred-default fixture runs 100/400/900, so 500:900 asks for the upper part of the axis with the default left outside it. Level 3 cannot move a default, so this must be refused even though the request is a perfectly sensible thing to want — ‘give me only the bold half’ is a Level 4 operation. The fixture matters because a centred default is where an implementation that only ever tested min-default axes would be most likely to let this through.

covers B7 · authority: slice-ui · fixture: centred-default · expects a refusal mentioning wght, default, 400

partial.centred-default-symmetric-restriction

A centred default keeps its position when both halves are trimmed

Normalization treats the two sides of a default independently: on a 100/400/900 axis, 250 is halfway down and normalizes to -0.5 while 650 is halfway up and normalizes to +0.5, even though 150 user units separate the first pair and 250 the second. Restricting to 250:650 rescales each half by a different factor — the lower by 300/150 and the upper by 500/250 — so a program that normalizes with a single span would be right at the default and wrong everywhere else. Verified with fontTools on a purpose-built 100/400/900 font: fvar comes out 250/400/650 and the sliced outlines match the source at 250, 300, 400, 500 and 650 to within 0.01 units.

covers B7, G2, G13 · authority: fonttools · fixture: centred-default

partial.centred-default-upper-half-only

A centred axis trimmed to its default downward stays variable upward

The mirror of the previous case: 400:900 keeps the default at the range minimum and leaves only the upper half. Together the two cases show that neither half is privileged — an implementation that special-cased ‘default equals minimum’ as the only degenerate shape, which is what Recursive’s wght would teach it, would fail one of them. The outlines are sampled at both ends and the midpoint so that a collapsed or unscaled half cannot pass.

covers B7, G2, G13 · authority: fonttools · fixture: centred-default

partial.composites-survive-a-restricted-range

Composite glyphs interpolate correctly across a restricted range

A composite glyph’s gvar deltas move its component offsets rather than its points, and a nested composite resolves through two levels of that. Rescaling those deltas onto a narrowed axis is the same arithmetic as for simple contours but a different code path, and an implementation can easily get one right and the other wrong – the error appears only where components are placed, so a check on the base glyphs alone would miss it. The outlines are compared after component resolution, which is what a renderer sees, at both ends of the restricted range and in the middle, because a mis-scaled delta can still be correct at one endpoint. composites carries a single wght axis running 400/400/900, so 400:700 is the narrowing available on it; the axis must survive with gvar intact, since pinning it would test a different code path entirely.

covers G2, G13 · authority: fonttools · fixture: composites

partial.default-at-top-of-restricted-range

A range whose maximum is the axis default is accepted (the inclusive boundary)

B7 requires the restricted range to contain the default; containment is inclusive at both ends, and the original’s own test is default < min or default > max, so a default sitting exactly on the range maximum passes. This is the positive counterpart to the B7 refusals: without it an implementation could satisfy B7 by rejecting every range that does not start at the default. The result is an axis that varies only downward from its default, perfectly expressible in fvar since minValue, defaultValue and maxValue are three independent Fixed values with only min <= default <= max required. Measured with fontTools: CRSV comes out as 0/0.5/0.5 and the font is still variable.

covers B7, G2 · authority: fonttools · fixture: recursive-vf

partial.degenerate-range-is-a-pin

A range of zero width behaves as a pin and removes the axis

0:0 parses as a range under B2 and passes B7 because slnt’s default is 0, but a range containing exactly one location is not a design space — there is nothing left to interpolate. An fvar record with min = default = max would advertise an axis a renderer can do nothing with, so collapsing it to a pin is the only coherent outcome. Measured: with the other four axes pinned as well, fontTools removes fvar entirely and the output is static, which is the correct reading and worth pinning down because a naive implementation would instead emit a zero-width axis.

covers B2, B7, G1, G2 · authority: fonttools · fixture: recursive-vf

partial.featurevars-all-records-die-but-axes-survive

When every condition dies, no substitution is left behind

MONO and wght both survive here, and MONO is one of the axes the source conditions test, but CRSV is pinned to its default so no record can fire. The requirement is that nothing conditional remains active and no condition indexes a missing axis. This case deliberately does not assert no_feature_variations: fontTools 4.62.1 leaves an empty FeatureVariations table behind in exactly this shape — zero records, still written to the file — where it removes the table outright when no conditioned axis survives. That is harmless and spec-legal, so failing the case over it would be scoring a wart rather than a defect; feature_variation_count is proposed above so the corpus can state equals: 0 explicitly.

covers G6 · authority: fonttools · fixture: recursive-vf

partial.featurevars-condition-outside-restricted-range-dropped

Restricting the axis below a condition’s threshold removes the condition

The cursive substitution needs CRSV at design 0.9 or above. Restricting CRSV to 0:0.75 makes that unreachable, so the record must go for the same reason as the pinned-to-default case: it can never fire, and it indexes an axis whose range no longer contains its trigger. This is the range counterpart to partial.featurevars-unreachable-condition-dropped and shows that reachability is judged against the new extents rather than against the new default alone. Measured: no records survive, the ‘rvrn’ feature is gone, and no substitution remains.

covers G6, G2 · authority: fonttools · fixture: recursive-vf

partial.featurevars-condition-renormalized-to-restricted-range

A surviving condition’s threshold is renormalized onto the narrowed axis

Condition tables store normalized coordinates, so narrowing an axis moves every threshold on it. Recursive’s cursive condition fires at normalized 0.79998779296875, which on the full CRSV axis is design 0.9; restricting CRSV to 0:0.95 rescales the upper half so that same design value 0.9 now normalizes to about 0.8888512138885122, and fontTools rewrites the condition to that. Leaving the raw 0.8 would move the switchover from design 0.9 to design 0.9 x 0.8 / 0.8888…, changing where the italic substitution kicks in — a silent behavioural change with no visible cause.

covers G6, G3 · authority: fonttools · fixture: recursive-vf

partial.featurevars-folded-into-feature-list

A condition set that holds at the new default is folded into the feature itself

Recursive’s ‘rvrn’ feature has no lookups of its own; the substitution a -> a.italic reaches it only through a FeatureVariations record conditioned on CRSV being at or above about 0.9 normalized. Pinning CRSV to 1 makes that condition true everywhere in the remaining design space, so the condition has nothing left to decide and the substitution must become unconditional — leaving it in a FeatureVariations table would make correctness depend on an axis that no longer exists. Measured: after slicing, ‘rvrn’ runs one lookup, that lookup maps a to a.italic, and the GSUB has no FeatureVariations at all.

covers G6 · authority: fonttools · fixture: recursive-vf

partial.featurevars-renumbered-to-new-axis-index

A surviving condition is renumbered to the surviving axis’s new index

ConditionTable format 1 addresses its axis by index into fvar, not by tag. CRSV is Recursive’s fifth axis, index 4; when the other four are pinned it becomes the only axis, index 0, and every condition that mentions it must be rewritten or it will read whatever axis now occupies slot 4 — which is nothing, making the font malformed. Measured: fontTools emits four condition sets, all on axis index 0, and feature_variation_axes_valid is the check that would catch the stale index.

covers G6, G2 · authority: fonttools · fixture: recursive-vf

partial.featurevars-renumbered-to-second-position

Renumbering is positional, not a blanket reset to zero

The previous case would also pass if a program simply set every condition’s axis index to 0. Here wght survives alongside CRSV, so CRSV moves from index 4 to index 1 and the conditions must follow it there. Measured: fontTools emits conditions on axis index 1, the position CRSV occupies in the two-axis fvar [wght, CRSV]. This is also the case where a condition on a pinned axis must vanish rather than be renumbered: the MONO and slnt conditions in the source records are evaluated against their pins and either satisfied — and removed from the condition set — or fatal to the whole record.

covers G6, G2 · authority: fonttools · fixture: recursive-vf

partial.featurevars-unreachable-condition-dropped

A condition set that cannot hold anywhere in the new space is dropped

Pinning CRSV to its default of 0.5 puts the axis at normalized 0, below the ~0.8 threshold the cursive condition needs, and the axis can no longer move. The substitution is therefore unreachable and must be removed rather than left behind pointing at an axis index that no longer exists — a stale condition table is not merely dead weight, it is a dangling index into fvar. Measured: the whole ‘rvrn’ feature disappears from the feature list, the lookup list is emptied, and GSUB carries no FeatureVariations; an absent feature runs zero lookups, hence the count of 0.

covers G6 · authority: fonttools · fixture: recursive-vf

partial.fvar-holds-only-surviving-axis

Pinning four of five axes leaves an fvar with just the fifth

The OpenType fvar table enumerates the axes a font actually varies on; an axis pinned to a single location no longer varies, so leaving its record in fvar would advertise a design space the font cannot render. fontTools 4.62.1 rewrites fvar to the surviving axes only, and every table that indexes axes by position — avar, gvar’s tuple regions, the item variation stores — is rewritten against that new list. Verified against the fixture: pinning MONO, CASL, slnt and CRSV leaves exactly one axis record, wght, at its original 300/300/1000.

covers B2, B3, G2 · authority: fonttools · fixture: recursive-vf

partial.fvar-narrowed-extent

A restricted range narrows the surviving axis’s fvar extent

This is the whole point of Level 3 sub-spacing: the axis stays, but its declared minimum and maximum shrink to what the user asked for. fvar’s minValue and maxValue are what a layout engine clamps user requests against, so if they were left at the original 300/1000 the font would claim weights it no longer carries deltas for. Measured with fontTools 4.62.1: wght becomes 300/300/700, and the default is untouched because a Level 3 slice cannot move it (B7).

covers B2, G2 · authority: fonttools · fixture: recursive-vf

partial.fvar-narrowed-on-two-axes

Two surviving axes are each narrowed independently

Restricting one axis must not disturb another: each axis’s extent is its own, and the sub-spacing solver works axis by axis. If narrowing wght perturbed MONO’s record — or vice versa — the design space would no longer be the rectangle the user asked for. Measured: MONO 0/0/0.5 and wght 300/300/700, both surviving, both narrowed exactly as typed.

covers B2, G2 · authority: fonttools · fixture: recursive-vf

partial.gpos-untouched-by-feature-variation-resolution

GPOS keeps its features and gains no feature variations

Recursive’s GPOS has a single ‘kern’ feature with one lookup and no FeatureVariations. Resolving GSUB’s conditions must not disturb it: kerning still applies across the whole surviving range, and a program that rebuilt both layout tables through the same code path could plausibly drop the feature when it found nothing conditional to fold in. Measured: ‘kern’ still runs one lookup and GPOS still has no FeatureVariations after slicing.

covers G6 · authority: fonttools · fixture: recursive-vf

partial.instance-name-records-pruned

Name records for dropped named instances are pruned

Recursive-VF carries 128 name records naming its 64 instances. When every instance is dropped, those records name nothing; the OpenType name specification allocates IDs 256 and above precisely for fvar and STAT to point at, so a record in that range with no referrer is dead data that inflates the file and confuses tools that enumerate styles. Measured: pinning slnt to 0 and CRSV to 1 removes all 64 instances and every name ID from 274 to 401 disappears, leaving no name ID above 255 unreferenced.

covers G10, G4 · authority: fonttools · fixture: recursive-vf

partial.instances-across-two-surviving-axes

With two axes surviving, the instance set is the product that remains reachable

Recursive has eight weights times two MONO settings times two CASL settings times two upright/italic pairs. Pinning CASL to 0 and CRSV to 0.5 with slnt at 0 leaves the eight Linear upright weights in each of the two MONO settings, so sixteen instances survive with two coordinates each. This checks that filtering is done per instance against the whole new design space rather than per axis, which would over- or under-count.

covers G4 · authority: fonttools · fixture: recursive-vf

partial.instances-inside-kept-with-pinned-coords-removed

Instances inside the new space survive, carrying only the surviving axes’ coordinates

An InstanceRecord’s coordinate array is positional: it holds exactly axisCount values, in fvar’s axis order. Once four axes are removed, a surviving instance must be rewritten to one value or its record is unreadable — this is a format requirement, not a tidiness one. Measured: pinning MONO 0, CASL 0, slnt 0 and CRSV 0.5 keeps the eight Sans Linear upright weights, each reduced to a single wght coordinate of 300, 400, 500, 600, 700, 800, 900 or 1000.

covers G4, G2 · authority: fonttools · fixture: recursive-vf

partial.instances-narrowed-by-restricted-range

A restricted range drops the instances that fall outside it

The rule is the same for a pin and for a range: an instance survives if and only if its location is inside the new design space on every axis. Restricting wght to 300:700 leaves the Light, Regular, Medium, SemiBold and Bold entries and removes ExtraBold, Black and ExtraBlack, which now sit past the declared maximum. named_instances_within_extent is the property that must never be violated — an instance outside its own font’s declared range is malformed — and the count pins down that fontTools drops rather than clamps them.

covers G4, G2 · authority: fonttools · fixture: recursive-vf

partial.instances-not-clamped-to-new-maximum

An instance past the new maximum is dropped, not moved onto it

Restricting wght to 300:650 puts the Bold instance, at 700, outside the range while leaving Light, Regular, Medium and SemiBold inside. Clamping Bold to 650 would produce a font naming a weight it does not have, and would collide with SemiBold’s own entry as ranges narrow further; dropping it loses a menu entry but never lies. Measured: four instances remain, and B9’s clamping of user-typed range bounds does not extend to instance coordinates.

covers G4 · authority: fonttools · fixture: recursive-vf

partial.instances-outside-new-space-dropped

Named instances that fall outside the new design space are removed

An fvar named instance is a promise that the font can be rendered at that location. Once an axis is pinned, any instance whose coordinate on that axis differs from the pin cannot be reached, and keeping it would leave a menu entry that resolves to something other than what it names. Recursive’s 64 instances all sit at either slnt 0 with CRSV 0.5, or slnt -15 with CRSV 1; pinning slnt to 0 and CRSV to 1 satisfies neither pair, and fontTools drops all 64 — a font with an fvar and no named instances, which is legal and correct.

covers G4 · authority: fonttools · fixture: recursive-vf

partial.min-default-axis-range-must-start-at-the-minimum

On an axis whose default is its minimum, no range may start above the minimum

The single-axis-min-default fixture has the shape the roster describes as Recursive’s wght: default equal to the axis minimum. That shape makes the Level 3 rule maximally restrictive — the only legal ranges are those starting at the minimum, so the axis can be trimmed from the top but never from the bottom. This is worth its own case because it is the shape where a user is most likely to be surprised, and the refusal message has to explain the constraint rather than merely report a rejection.

covers B7 · authority: slice-ui · fixture: single-axis-min-default · expects a refusal mentioning wght, default

partial.name-table-after-restriction-is-exactly-what-is-referenced

The whole name table after a restriction is the referenced set and nothing more

The individual pruning cases each check one record; this one bounds the entire table, so an implementation cannot pass them all while leaking some other orphan. The set was measured from fontTools’ output for this exact input: IDs 0 to 6 from the source font’s own metadata, 269 to 273 for the five STAT design axes, ten records for the five surviving instances and their PostScript names, and 402 and 404 to 409 and 413 for the surviving STAT axis values. Anything outside that set is an orphan; name_ids_subset_of states it as an upper bound so that a future fixture change that legitimately adds a record does not silently pass.

covers G10, G4, G5 · authority: fonttools · fixture: recursive-vf

partial.named-instances-fixture-stays-consistent

The named-instances fixture comes out with every instance inside its declared extents

The roster’s named-instances fixture exists to stress this path with more instances than Recursive’s regular grid. Whatever survives, two invariants hold unconditionally: every remaining InstanceRecord must sit inside every declared axis range, because the OpenType specification defines an instance as a position in the font’s own design space, and no name record may be left pointing at an instance that no longer exists. No count is asserted here because the roster does not fix how many instances the fixture has.

covers G4, G10 · authority: spec · fixture: named-instances

partial.negative-axis-restricted-range

An axis that runs entirely below its default restricts correctly

Recursive’s slnt runs -15 to 0 with the default at 0, so every point on it normalizes to a negative number and the whole axis lives in the lower half of the normalized space. The range regular expression in B5 explicitly admits a leading minus, and restricting to -10:0 must rescale that lower half alone: normalized -1 now means -10 rather than -15. Measured: fvar becomes -10/0/0, and slnt’s avar map is rewritten from its original five points to {-1: -1, -0.7020263671875: -0.7025146484375, 0: 0, 1: 1}, which is the original mapping resampled onto the new -10..0 span.

covers B5, B7, G2, G3 · authority: fonttools · fixture: recursive-vf

partial.no-avar-fixture-does-not-gain-one

A font without avar does not acquire one

Renormalization is a rewrite of an existing mapping, not the creation of one. A font with no avar has the identity mapping by definition, and the identity resampled onto any sub-range is still the identity, so there is nothing to record; emitting an avar full of identity segments would add bytes and a new opportunity for rounding error for no benefit. The OpenType specification lists avar as optional precisely because the identity case needs no table. Confirmed by construction with fontTools on a purpose-built avar-less single-axis font: no avar appears in the output.

covers G2, G3 · authority: fonttools · fixture: no-avar

partial.no-avar-outlines-across-surviving-range

Without avar the mapping is pure, and the outlines prove it end to end

This is the control for the avar cases: with no segment map in play, any outline mismatch across the surviving range is a gvar or normalization error rather than an avar error, so the two failure modes can be told apart. The fixture is described in the roster as two-axis without avar, so pinning wdth leaves wght varying over its whole declared range. Locations are given as the fixture’s declared minimum, default and maximum rather than as literals, since the roster does not fix its extents.

covers G2, G13 · authority: fonttools · fixture: no-avar

partial.outlines-correct-across-two-restricted-axes

Outlines are correct across the whole surviving rectangle, not just at its default

This is the property most likely to be silently wrong, because every structural check in this file can pass on a font whose deltas were rescaled incorrectly. With two axes restricted, the surviving design space is a rectangle and the sampled locations cover its corners, its edges and an interior point, so an error in either axis’s rescaling or in the interaction between them shows up. The reference is the source fixture drawn at the same location, which is the only definition of correct that does not privilege either implementation.

covers G2, G3, G13 · authority: fonttools · fixture: recursive-vf

partial.outlines-correct-across-whole-surviving-axis

An axis left whole still interpolates correctly after its neighbours are pinned

Pinning four axes rewrites every gvar tuple: regions that mentioned a pinned axis are either evaluated into the default outline or discarded, and the surviving one-dimensional deltas must reproduce the source exactly along wght. The default location is the one point where this is guaranteed to look right even if the rewrite is wrong, so the check samples the ends and the interior. Measured with fontTools: maximum coordinate deviation from the source across 300, 400, 700 and 1000 stays well under one unit.

covers B1, G2, G13 · authority: fonttools · fixture: recursive-vf

partial.output-is-a-well-formed-variable-font

A partial slice is a font a renderer can load and vary

Everything else in this file checks one field at a time; this case checks that the whole is a font. A variable TrueType font needs fvar and, to vary its outlines, gvar, and the OpenType specification requires head’s bounding box, maxp’s maxima and hhea’s extremes to describe the glyf data actually present. A partial slice rewrites glyf and gvar together, so these summary tables have to be recomputed from the result rather than carried over from the source.

covers G2, G8, G13 · authority: spec · fixture: recursive-vf

partial.pinned-off-default-keeps-variable-kerning

Kerning stays right when one axis is pinned off its default and another is narrowed

A GDEF item variation store re-tents cleanly as long as every other axis stays at its default, because deltas are measured from the default master and the residual at the new default location is then zero. Pinning an axis away from its default breaks that: the kerning at wdth=50 differs from the kerning at wdth=100 by exactly that residual, and it has to be written back into the GPOS value records and anchors that address the store. This case existed first as a recorded gap – the implementation refused the job while fontTools did it – and the interior sample is what holds the fix honest: a store re-tented without its residual is still exactly right at the default and wrong everywhere else, which no single-location check would notice.

covers G2, G13 · authority: fonttools · fixture: gdef-varstore

partial.range-above-default-refused

A restricted range entirely above the axis default is refused

Level 3 sub-spacing can narrow an axis but cannot move where its default sits, because the default location is the one the glyf outlines themselves encode; every gvar delta is measured from it. Asking for wght 400:700 on an axis whose default is 300 would require rebuilding the default instance at 400, which is Level 4 work the compiler does not do. Refusing is therefore the honest answer, and the message must name the axis and the default so the user can see what to type instead.

covers B7 · authority: slice-ui · fixture: recursive-vf · expects a refusal mentioning wght, default, 300

partial.range-below-default-refused

A restricted range entirely below the axis default is refused

The same rule from the other side: wght 100:250 does not contain the default of 300, so it is refused. This case also fixes the interaction with B9, which records that out-of-extent bounds are not checked by the editor and are clamped later by fontTools — the containment test must run on what the user typed, before any clamping, or 100:250 would be clamped to 300:300 and quietly accepted as a pin. Refusing is correct because the user asked for a design space the font cannot provide.

covers B7, B9 · authority: slice-ui · fixture: recursive-vf · expects a refusal mentioning wght, default, 300

partial.range-equal-to-full-extent-is-a-no-op

A range identical to the axis’s own extent changes nothing

Asking for 300:1000 on an axis that already runs 300 to 1000 is a request the tool must accept — B13 only refuses a job in which every cell is empty, and this cell is not — but it removes nothing from the design space, so nothing may be lost. This is the control case for the whole area: it separates ‘the program restricts correctly’ from ‘the program damages the font whenever it touches it’. Measured: all five axes survive with their original extents, all 64 named instances survive, and avar is still present.

covers B1, B2, G2, G3, G4 · authority: fonttools · fixture: recursive-vf

partial.range-missing-default-on-centred-axis-refused

The rule applies to an axis whose default is in the middle

CRSV runs 0 to 1 with its default at 0.5, so both 0.6:1 and 0:0.4 exclude the default while sitting entirely inside the axis’s own extent — no clamping is involved and the request looks superficially reasonable. It is still refused, for the same reason: a Level 3 slice cannot relocate the default, and moving CRSV’s default from 0.5 to 0.6 would mean recomputing every glyph’s default outline. The message must name CRSV and its default so the refusal is actionable.

covers B7 · authority: slice-ui · fixture: recursive-vf · expects a refusal mentioning crsv, default, 0.5

partial.range-with-default-at-its-minimum-accepted

A range whose minimum is the default is accepted

Containment is inclusive, so a default sitting exactly on the range minimum satisfies the Level 3 rule — the default location is inside the requested space and does not have to move. This is the common case for Recursive, whose wght default of 300 is also its minimum, and refusing it would make the axis unrestrictable at all. The positive case is needed alongside the refusals so that an implementation cannot pass B7 by rejecting every range.

covers B7, G2 · authority: slice-ui · fixture: recursive-vf

partial.reversed-range-equals-sorted-range

700:300 slices the same design space as 300:700

B6 records that the range parser sorts its two bounds, so the order they were typed in carries no meaning. That is the defensible reading: a range is a set, and the OpenType axis record has a minValue and a maxValue rather than a first and a second. The expectation here is byte-for-byte the same design space as partial.fvar-narrowed-extent, which is the point — the two inputs must not be distinguishable in the output.

covers B6, B2, G2 · authority: slice-ui · fixture: recursive-vf

partial.reversed-range-still-checked-against-default

A reversed range is sorted first and then checked for the default

B6 sorts the bounds and B7 then tests containment, so the two rules have to compose: 700:400 becomes 400:700, which still excludes wght’s default of 300 and is still refused. Checking containment before sorting would compare 300 against a range whose minimum is 700 and maximum is 400 and could reach either verdict by accident. The order of the two rules is therefore load-bearing and this case pins it.

covers B6, B7 · authority: slice-ui · fixture: recursive-vf · expects a refusal mentioning wght, default, 300

partial.stat-format4-value-dropped-when-unreachable

A format 4 STAT value survives only if every axis it names is still reachable at that point

Recursive’s ‘Upright’ and ‘Italic’ are format 4 records, each naming a combination — Upright is slnt 0 with CRSV 0.5, Italic is slnt -15 with CRSV 1. A format 4 record is an all-or-nothing conjunction, so it survives only when every one of its AxisValueRecords is still reachable. Measured: pinning slnt to 0 and CRSV to 0.5 keeps Upright (name ID 413) and drops Italic (414); pinning CRSV to 1 with slnt still at 0 drops both, since neither combination is satisfiable — which is the case checked here.

covers G5, G10 · authority: fonttools · fixture: recursive-vf

partial.stat-format4-value-kept-when-reachable

The format 4 value the pin actually selects is kept

The other side of the previous case, so that its expectation cannot be satisfied by a program that simply deletes all format 4 records. Pinning slnt to 0 and CRSV to 0.5 lands exactly on the Upright combination, which remains true of every location the sliced font can render, so it must survive; Italic, which requires slnt -15, cannot. Measured: name ID 413 present, 414 absent.

covers G5, G10 · authority: fonttools · fixture: recursive-vf

partial.stat-keeps-records-for-pinned-axes

STAT keeps a design axis record for axes the slice removed

This is the claim in G5 worth confirming rather than assuming, and fontTools 4.62.1 does confirm it: slicing Recursive-VF down to wght alone leaves all five STAT design axis records — MONO, CASL, wght, slnt, CRSV — with AxisOrdering 0 to 4 unchanged. It is the right answer, because the sliced font is still a member of the Recursive family and STAT’s job is to say where in that family it sits: a Sans Linear Upright slice is distinguished from its Mono and Casual siblings precisely by the axes it no longer varies on. The observable consequence is that the axis name records 269 to 273 all survive even though only 271 is referenced from fvar, so this case reads them as the proxy for the STAT records that point at them; a direct stat_design_axis_tags check is proposed above.

covers G5, G10 · authority: fonttools · fixture: recursive-vf

partial.stat-survives-partial-instancing

STAT is kept when the output is still variable

STAT is what lets an operating system place the font inside its family, and the OpenType specification makes it required for variable fonts. A slice that is still variable is still a family member, so removing STAT would leave a conformance hole that no other table fills. G5 says fontTools keeps it, and measurement confirms the table is present in every partial output tried.

covers G5 · authority: fonttools · fixture: recursive-vf

partial.stat-value-exactly-at-new-maximum-is-kept

A STAT value sitting exactly on the new maximum survives; one just past it does not

The boundary is inclusive, because an axis whose maximum is 650 can be rendered at 650. Bold sits at wght 700: with the range 300:700 it is reachable and kept (name ID 409 present, as the previous case shows), and with 300:650 it is not and is dropped. Testing the two sides of the same boundary is what distinguishes a correct inclusive comparison from an off-by-one, and measurement confirms fontTools drops Bold at 300:650 while keeping SemiBold at 600.

covers G5, G10 · authority: fonttools · fixture: recursive-vf

partial.stat-values-for-pinned-axis-dropped

STAT values on a pinned axis are reduced to the one the pin selects

The design axis record for MONO stays (see partial.stat-keeps-records-for-pinned-axes) but its two axis values do not both stay: pinning MONO to 0 makes the font a Sans, so ‘Sans’ at MONO 0 remains true and ‘Mono’ at MONO 1 becomes unreachable. Keeping both would make the font claim two mutually exclusive styles at once. Measured: name ID 402 ‘Sans’ survives and 403 ‘Mono’ is pruned.

covers G5, G10 · authority: fonttools · fixture: recursive-vf

partial.stat-values-outside-restricted-range-dropped

STAT axis values beyond the new maximum are dropped

A STAT axis value names a point on an axis; once the axis no longer reaches that point, the name describes nothing the font can render and a style-matching engine would offer a style that does not exist. Restricting wght to 300:700 must therefore drop ExtraBold, Black and ExtraBlack while keeping Light through Bold. Measured with fontTools: exactly that, and because G10 then prunes the now-unreferenced name records, the dropped values are observable as the absence of name IDs 410, 411 and 412.

covers G5, G10 · authority: fonttools · fixture: recursive-vf

partial.surviving-instance-names-retained

Name records for instances that survive are retained

Pruning must be driven by what is still referenced, not by a blanket deletion of high name IDs — the previous case would pass either way. Here eight instances survive, so their subfamily and PostScript name records must survive with them: 338 ‘Sans Linear Light’ and 346 ‘Sans Linear’ stay, while 274 ‘Mono Linear Light’ and 340 ‘Sans Linear Light Italic’ go with their dropped instances. Measured against fontTools, with no dangling IDs left in either direction.

covers G10, G4 · authority: fonttools · fixture: recursive-vf

partial.two-axis-fixture-restricted-outlines

The minimal two-axis fixture slices to one axis with correct outlines

Recursive is a real font with avar, feature variations and a five-axis space; when a case on it fails there are several possible causes. The two-axis fixture is the same operation with everything else removed, so a failure here isolates the core of partial instancing — rewriting fvar and rescaling gvar — from every other mechanism. It is the case to look at first when the Recursive cases go red.

covers G2, G13 · authority: fonttools · fixture: two-axis

partial.variable-kerning-survives-a-restricted-range

Variable kerning still interpolates after an axis is narrowed

A font’s variable kerning lives in a GDEF item variation store that GPOS reaches into by delta-set index. Narrowing an axis without re-tenting that store leaves the regions describing a design space the font no longer has, and the failure is quiet: the kerning is still exactly right at the default location and wrong everywhere else, so text set at any other weight mis-spaces while every outline is perfect. No outline check can see it, and no check that samples a single location can either – which is why the sampled locations here include an interior one. Measured against fontTools 4.62.1 on this fixture at wght 400, 550 and 700, the pair values are -40/-64/-88 for A/V and -60/-87/-114 for T/A; an implementation that dropped the store would report the 400 column at all three.

covers G2, G13 · authority: fonttools · fixture: gdef-varstore

partial.vertical-metrics-still-vary-across-a-restricted-range

Ascender, x-height and the underline still move across a narrowed axis

MVAR is what makes the font-wide metrics – ascender, descender, x-height, cap height, underline – move with weight or optical size. Narrowing an axis has to re-tent its item variation store, exactly as HVAR and GDEF do, and bake the residual at the new default into the OS/2 and post fields each record names. This implementation used to apply the table at the new default and then drop it, which left every metric correct at that one location and frozen everywhere else: a font narrowed to wght 300:700 kept its 300 x-height at 700. fontTools keeps the table, so the original passes. Measured on this fixture, x-height runs 526 / 534 / 538 across the three sampled weights and the underline -205 / -188 / -179; an implementation that dropped MVAR would report the first column three times, which is why the samples are not all at the default.

covers G2, G13 · authority: fonttools · fixture: recursive-vf

static-instancing

Pinning every axis, which is what most people come to Slice for: one weight, frozen, ready to hand to a design application. The output has no axes left, so everything that described variation has to go, and everything that described the font’s identity has to be brought up to date.

Covers claims: A1, A3, B3, G1, G10, G11, G12, G13, G3, G5, G6, G7, G8, G9.

static.avar2.axes-that-move-each-other-are-honoured

An avar version 2 font instances at the location its axes actually resolve to

avar version 1 warps each axis independently through its own segment map. Version 2 adds an item variation store on top, so an axis’s final coordinate can depend on where the other axes are – optical size pulling on weight, a width axis changing what a grade means. Ignoring that store does not produce a refusal or a crash; it produces a font instanced at the wrong location, silently. Measured on this fixture before the store was implemented, the outlines sat 173 font units from what fontTools draws at the same place, which is a visibly different letter. The tolerance here is zero because every axis is pinned, so the arithmetic is exact and there is nothing to round. Note that partial instancing of an avar2 font is refused, by this implementation and by fontTools alike, which raises NotImplementedError for it.

covers G3, G13 · authority: fonttools · fixture: avar2

static.composites.maxp-and-bbox

Composite maxp fields and bounding boxes are recalculated through the component tree

maxCompositePoints, maxCompositeContours, maxComponentElements and maxComponentDepth are defined by the OpenType specification over the flattened component tree, and a composite’s own bounding box has to be derived from its components after their offsets have been changed by gvar - a rasteriser that trusts a stale composite bbox will clip. The roster fixture nests one composite two deep, so maxComponentDepth must be 2. Verified in fontTools 4.62.1 on an equivalent purpose-built font: maxp.recalc walked the nesting to depth 2 and the head box picked up the accent lifted by a nested component’s offset.

covers G8 · authority: spec · fixture: composites

static.composites.outlines-match-source

Composite outlines at the pinned location match the source drawn there

For a composite, gvar deltas move component offsets rather than points, which is a separate code path from simple-glyph interpolation and the one most likely to be got wrong - an off-by-one in the phantom-point count silently shifts an accent. Drawing the instance and the source at the same location and comparing the resulting paths tests the composite after resolution, so it catches both a wrong offset and a wrongly interpolated component. Verified in fontTools 4.62.1: a component offset of 200,0 with a delta of 30,10 came out at 230,10 in the instance, matching the source evaluated at that location.

covers G13 · authority: fonttools · fixture: composites

static.composites.survive-as-composites

Composite glyphs are not decomposed by static instancing

Decomposition is a lossless-looking change that is not free: it multiplies the point count, defeats the hinting a composite inherits from its components, and grows the file, so instancing must not do it as a side effect. gvar deltas for a composite apply to component offsets, and applying them requires nothing more than rewriting those offsets, so there is no technical reason to flatten. Verified in fontTools 4.62.1 on a purpose-built composite variable font: at the default overlap mode both a plain and a two-deep nested composite came through the instancer still composite, with their component offsets moved by the deltas (200,0 became 230,10).

covers G1, G13 · authority: fonttools · fixture: composites

static.dsig-deleted

DSIG is deleted from a static instance

DSIG is a digital signature over the font’s own bytes; instancing rewrites glyf, hmtx, head, maxp, OS/2 and the table directory, so any signature carried over is guaranteed invalid, and the OpenType specification is explicit that the signature covers the whole font file. Shipping an invalid signature is worse than shipping none: Windows treats a broken DSIG as a tampered font. Verified in fontTools 4.62.1, where instantiateVariableFont deletes DSIG unconditionally before it touches anything else. The fixture is with-dsig rather than recursive-vf, which carries no signature and left this case asserting the absence of something that was never there.

covers G11 · authority: spec · fixture: with-dsig

static.gdef-varstore.kerning-applied-at-the-pinned-location

Variable kerning is resolved at the pinned location, not left at the default’s

Variable kerning and anchors live in a GDEF item variation store that GPOS value records reach into through VariationIndex device tables. Pinning every axis has to add each device’s delta at the pinned location into the value beside it, then delete the devices and the store, as fontTools’ instantiateOTL does. Copying both tables through untouched fails quietly: with no fvar a shaper has no coordinates to evaluate the store at, so every outline is right and the text is set with the default master’s kerning. A user found this in Google Sans Flex Condensed Black, where 20,251 of 110,224 character pairs were placed wrongly, by up to 394 units (tools/kerning-compare.py). The location is off the default on both axes and interior on both, so neither the default master nor a single extreme master can satisfy it by accident. Measured in fontTools 4.62.1 at wght 650, wdth 75: A/V kerns -95, V/A -57 and T/A -122, against -40, -20 and -60 at the default; and fontTools removes GDEF altogether, because the store was all it held.

covers G1, G13 · authority: fonttools · fixture: gdef-varstore

static.head.bbox-recalculated-at-default

The head bounding box is recalculated at the default location

The OpenType head specification defines xMin/yMin/xMax/yMax as the union of all glyph bounding boxes; applications use it to size caches and to compute a fallback line box, so a box inherited from the variable font describes the wrong font. recursive-vf’s source head box is -275,-330,2380,1125, left over from the unsubsetted family. Verified in fontTools 4.62.1: after pinning every axis at the fvar default the box is 75,-10,565,536, the union of the two non-empty glyphs, recomputed by maxp.recalc during save.

covers G8 · authority: spec · fixture: recursive-vf

static.head.bbox-recalculated-at-extreme

The head bounding box tracks the instanced outlines at an extreme location

The bounding box is not a property of the font file but of the instance, and the whole point of recalculating it is that different locations give different answers - at MONO=1 CASL=1 wght=1000 slnt=-15 CRSV=1 the slanted, heaviest outlines reach left of the origin. Asserting a second, different box is what distinguishes genuine recalculation from a lucky constant. Verified in fontTools 4.62.1: the box is -23,-11,596,562 at that location, against 75,-10,565,536 at the default.

covers G8, G13 · authority: spec · fixture: recursive-vf

static.head.flags-bit1-recalculated

head flags bit 1 is recomputed from the final left side bearings

head.flags bit 1 asserts that every glyph’s left side bearing equals its xMin, and the OpenType specification lets a rasteriser take shortcuts on that promise; instancing moves outlines and rewrites hmtx, so the promise has to be re-tested rather than inherited. Verified in fontTools 4.62.1: maxp.recalc sets the bit when every non-empty glyph has lsb == xMin and clears it otherwise, and for a fully pinned recursive-vf head.flags is 3 - bit 0 (baseline at y=0) from the source plus the recomputed bit 1. The whole field is asserted because the check vocabulary’s bit_set covers only fsSelection and macStyle.

covers G8 · authority: spec · fixture: recursive-vf

static.hhea.extremes-recalculated-at-default

hhea’s extremes are recalculated from the instanced hmtx and glyf

The OpenType hhea specification defines advanceWidthMax, minLeftSideBearing, minRightSideBearing and xMaxExtent as maxima and minima over the font’s actual horizontal metrics, and layout engines use xMaxExtent to decide how far a glyph may paint outside its advance. recursive-vf inherits 2400/-275/-289/2380 from the unsubsetted family, which describes glyphs this font does not contain. Verified in fontTools 4.62.1: at the default location the output reads 600/75/35/565, and numberOfHMetrics collapses to 1 because all three advances are 600.

covers G8 · authority: spec · fixture: recursive-vf

static.hhea.extremes-recalculated-at-extreme

hhea’s extremes differ at a different pinned location

As with the head box, a single location cannot tell a recalculated value from a coincidence. At the heaviest slanted corner of recursive-vf’s design space the left side bearing goes negative while the advance stays 600, so three of the four extremes change and one does not - exactly the pattern that catches a partial recalculation. Verified in fontTools 4.62.1: 600/-23/4/596 at MONO=1 CASL=1 wght=1000 slnt=-15 CRSV=1.

covers G8, G13 · authority: spec · fixture: recursive-vf

static.hinting.control-tables-preserved

fpgm, prep and cvt survive static instancing

The control-value program, the font program and the control value table are not variation data: they are the font’s hinting, and nothing about pinning an axis makes them invalid. Deleting them would silently unhint the instance, which changes rendering at small sizes on every rasteriser that runs the interpreter. Verified in fontTools 4.62.1 on a purpose-built hinted variable font: fpgm, prep and cvt all came through a full pin byte for byte. Where a cvar is present its deltas are folded into cvt and only cvar is removed - a cvt entry of 700 became 730 - which is the same ‘apply, then drop’ rule as MVAR.

covers G1, G8 · authority: fonttools · fixture: hinted

static.hinting.glyph-programs-preserved

Per-glyph instructions survive static instancing unchanged

TrueType instructions address points by index, and instancing changes coordinates but never the number or order of points, so the programs remain meaningful and must be kept. They are not re-generated for the instance either: the hints were authored against the default outlines, and at another location they will be doing approximately, not exactly, the right thing - a known limitation of instancing that the corpus records rather than pretends away. Verified in fontTools 4.62.1: a six-byte glyph program survived instancing and save byte for byte.

covers G1, G8 · authority: fonttools · fixture: hinted

static.hinting.maxp-instruction-limits-cover-the-programs

maxp’s instruction limits still cover the programs the instance carries

maxp.maxSizeOfInstructions sizes the interpreter’s instruction buffer, and the OpenType specification requires it to be at least the largest glyph program in the font. This is the one part of maxp that instancing does not recompute: fontTools’ maxp.recalc documents and implements ‘recalculate … most other maxp values except for the TT instructions values’, and a deliberately falsified maxSizeOfInstructions of 1 in front of a six-byte program survived a full pin and save unchanged in fontTools 4.62.1. So the claim in G8 that maxp is recalculated is true of the point and contour maxima and false of the instruction maxima; the property that must hold either way is coverage, which is what this case asserts.

covers G8 · authority: spec · fixture: hinted

static.italicangle.fractional-slnt

A fractional slnt pin is carried into post.italicAngle without rounding

Unlike usWeightClass, post.italicAngle is a Fixed 16.16 field, so it can hold the fractional coordinate exactly and there is no reason to round it; the caret slope a renderer derives from it would visibly disagree with the outlines if it were rounded to a whole degree. Verified in fontTools 4.62.1: pinning slnt=-7.5 gives italicAngle exactly -7.5, and slnt=-0.5 gives -0.5. The contrast with the wght cases is deliberate - two registered axes, two different numeric contracts, both spelled out.

covers G7, B3 · authority: spec · fixture: recursive-vf

static.italicangle.from-slnt-extreme

post.italicAngle follows a slnt pin

The OpenType specification defines the registered slnt axis in counter-clockwise degrees from the vertical, and post.italicAngle in exactly the same units and sign convention, so an oblique instance whose italicAngle is left at 0 lies to every application that uses it - synthetic italic, underline slanting and caret placement all read it. recursive-vf’s source post.italicAngle is 0.0, so a pin at the slnt minimum must change it. Verified in fontTools 4.62.1: pinning slnt=-15 gives italicAngle -15.0.

covers G7 · authority: spec · fixture: recursive-vf

static.italicangle.upright-stays-zero

Pinning slnt at 0 leaves post.italicAngle at zero

The rule has to be idempotent at the upright end or every non-slanted instance of a slnt-bearing family acquires a spurious angle. slnt=0 is the fvar default here, and the OpenType specification’s convention makes 0 degrees the upright value for both slnt and italicAngle, so the correct output is the value the font already had. Verified in fontTools 4.62.1: italicAngle is 0.0 after pinning slnt=0.

covers G7 · authority: spec · fixture: recursive-vf

static.load.ttf-accepted

A TrueType variable font is accepted and sliced

A1 makes acceptance of an input exactly the question ‘can fontTools.ttLib.TTFont open it’, with no format sniffing of the tool’s own. This case is the positive half of that contract: a well-formed sfnt with an fvar table must be opened, sliced and written without complaint. It is the floor every other case in this area stands on, so it is worth failing separately rather than as a confusing side effect of some later expectation.

covers A1, A3, G1 · authority: slice-ui · fixture: recursive-vf

static.load.woff-input

A WOFF input slices to the same static instance as the TTF

A3 rests on TTFont dispatching on the container, so a WOFF input must be decompressed and instanced exactly as its uncompressed twin: WOFF is a lossless repackaging of the same sfnt tables, and the OpenType/WOFF specification requires the decoded tables to be byte-recoverable. Verified in fontTools 4.62.1: slicing Recursive-VF.subset.woff and Recursive-VF.subset.ttf at the same location produced identical glyph coordinates, hmtx, head bbox, maxp and table set. Any divergence would mean the container, not the design space, changed the outlines.

covers A1, A3, G1, G13 · authority: fonttools · fixture: recursive-vf-woff

static.load.woff2-input

A WOFF2 input slices to the same static instance as the TTF

WOFF2 applies a transform to glyf and loca, so unlike WOFF it is not a byte-for-byte repackaging and is the container most likely to perturb outlines; the WOFF2 specification nevertheless requires the transform to reconstruct the original glyph outlines exactly. Verified in fontTools 4.62.1: Recursive-VF.subset.woff2 sliced at MONO=0 CASL=1 wght=700 produced glyph coordinates, hmtx, head bbox and maxp identical to the TTF sliced at the same location. This is the case that would catch a reconstruction bug being mistaken for an instancing bug.

covers A1, A3, G1, G13 · authority: fonttools · fixture: recursive-vf-woff2

static.maxp.recalculated-from-final-outlines

maxp is recalculated from the instanced outlines, not carried over

maxp declares the buffers a TrueType rasteriser must allocate before it touches a glyph, so the OpenType specification requires its maxima to describe the glyf table actually present; a stale maxp is a correctness problem, not a size problem, because an under-reported maxPoints leaves the interpreter’s point array short. recursive-vf as committed carries maxp values inherited from the unsubsetted family - maxPoints 392, maxContours 22, maxCompositePoints 291, maxComponentElements 4, maxComponentDepth 1 - none of which describe its three glyphs. Verified in fontTools 4.62.1: after a full pin and save, maxp reads 102/2/0/0/0/0, recomputed by maxp.recalc during compile.

covers G8 · authority: spec · fixture: recursive-vf

static.mvar.applied-then-deleted

MVAR deltas are baked into the metrics tables before MVAR is dropped

MVAR carries deltas for named metrics in OS/2, post, hhea and vhea; the OpenType specification’s whole design is that the static values in those tables are the default-instance values, and an instance at another location must have the deltas applied or it silently ships the default’s underline, strikeout and x-height. Dropping the table without applying it is the failure mode this case exists to catch - the table set would look right and the numbers would be wrong. Verified in fontTools 4.62.1: at wght=1000 with the other four axes at default, post.underlinePosition moves from -205 to -145, underlineThickness from 45 to 150, OS/2.sxHeight from 526 to 550, yStrikeoutPosition from 284 to 314 and yStrikeoutSize from 45 to 80, and MVAR is gone.

covers G1, G8 · authority: spec · fixture: recursive-vf

static.mvar.default-location-leaves-metrics-alone

At the default location MVAR removal changes no metric

The OpenType specification defines the values stored in OS/2, post and hhea as those of the default instance, so applying MVAR at the default must be a no-op; a case that only ever checks a moved value cannot tell ‘applied correctly’ from ‘applied twice’ or ‘applied with the wrong sign’. Pairing this with static.mvar.applied-then-deleted brackets the arithmetic from both sides. Verified in fontTools 4.62.1: at the fvar default the five metrics that move at wght=1000 read exactly the source values 526, 284, 45, -205 and 45.

covers G1, G8 · authority: spec · fixture: recursive-vf

static.os2.xavgcharwidth-recalculated

OS/2.xAvgCharWidth is recalculated for the instance

For OS/2 version 3 and later the specification defines xAvgCharWidth as the arithmetic mean of the advance widths of all non-zero-width glyphs, so it is a function of hmtx and must be recomputed once instancing has resolved the advances. recursive-vf declares 628, a figure inherited from the full family; its three surviving glyphs all advance 600. Verified in fontTools 4.62.1: instantiateVariableFont calls OS/2.recalcAvgCharWidth and the output reads 600 at every location tested.

covers G9 · authority: spec · fixture: recursive-vf

static.outlines.coordinates-finite-and-contours-intact

Instanced outlines are finite and keep their contour structure

glyf coordinates are 16-bit integers, so there is no representation for a non-finite value; a NaN reaching the outline pipeline - from a NaN pin, or from a division by a zero-width delta region - either raises on compile or writes a nonsense integer, and this check names that failure directly rather than letting it surface as a mismatched outline. Contour count is a structural invariant of interpolation: deltas move points, they never add, remove or merge contours, so recursive-vf’s ‘a’ must still have exactly two. Verified in fontTools 4.62.1 at every location tested.

covers G13, G8 · authority: spec · fixture: recursive-vf

static.outlines.match-source-at-avar-bent-interior

Outlines at an interior location match, with avar and fractional coordinates in play

Interior locations are the ones avar actually changes: recursive-vf’s wght segment map moves normalized 0.2857 to 0.3400, so an implementation that skipped avar would produce outlines that are subtly too light or too heavy, in a way that neither the default nor the extremes would reveal. Pairing that with a fractional slnt coordinate also exercises interpolation between delta regions rather than landing on a master. This is the case that distinguishes ‘applies the deltas’ from ‘applies the deltas at the right coordinate’.

covers G13, G3 · authority: fonttools · fixture: recursive-vf

static.outlines.match-source-at-default

Outlines at the default location match the source drawn at the default

This is the identity case for G13: pinning every axis at its fvar default must reproduce the default instance exactly, because the OpenType specification defines glyf as holding the default-instance outlines and all deltas at the default are zero. Any deviation means deltas were applied where none should have been, or the normalization of the default coordinate is not landing on zero. Advances are checked alongside because hmtx and the phantom points are resolved by the same machinery.

covers G13 · authority: fonttools · fixture: recursive-vf

static.outlines.match-source-at-extreme

Outlines at the far corner of the design space match the source drawn there

The corner where all five axes are at an extreme is where every delta set contributes at full scale and where the region-scalar products are largest, so a sign error or a mis-ordered axis shows up here at maximum amplitude. It is also the location whose outlines differ most from the stored default, which makes the comparison meaningful rather than nearly trivial. Verified in fontTools 4.62.1 that this location produces materially different outlines - the head box moves from 75,-10,565,536 to -23,-11,596,562.

covers G13 · authority: fonttools · fixture: recursive-vf

static.outlines.single-axis-at-maximum

The same one-axis font instances at the top of its range

The companion to the previous case: with the default at the minimum, the maximum is the only location where deltas apply at full scale, so it is the only place the delta arithmetic is exercised at all on this fixture. Pinning there must both interpolate the outlines and rewrite usWeightClass, which for a wght axis the OpenType specification puts on the same scale as the coordinate. Between the two cases the fixture’s entire design space is covered.

covers G7, G13 · authority: fonttools · fixture: single-axis-min-default

static.outlines.single-axis-default-at-minimum

An axis whose default equals its minimum instances correctly at that default

When the default sits at the axis minimum the negative half of the normalized range is empty, so every delta region has a zero-width lower side; an implementation that divides by the region width without guarding it produces a division by zero or a NaN exactly here. This is the shape Recursive’s own wght axis has, which makes it worth isolating in a one-axis fixture where nothing else can mask it. The correct answer is the plain one: pinning at the default reproduces the stored default outlines and leaves a static font.

covers G1, G13 · authority: fonttools · fixture: single-axis-min-default

static.overlap.composites-flagged

OVERLAP_COMPOUND is set on composites of a static instance

A composite whose components overlap - the accent of a precomposed letter crossing its base is the everyday case - needs the same non-zero fill promise, and the OpenType glyf specification puts it in a different flag, OVERLAP_COMPOUND (0x400), carried on a component rather than a point. Verified in fontTools 4.62.1 (setMacOverlapFlags): the flag is set on the first component only, not on every component, for both the plain and the two-deep nested composite in a purpose-built test font. A checker that demanded it on all components would wrongly fail conformant output.

covers G12 · authority: fonttools · fixture: composites

static.overlap.simple-glyphs-flagged

OVERLAP_SIMPLE is set on every simple glyph of a static instance

Variable fonts are normally drawn with overlapping contours, and the OpenType glyf specification’s OVERLAP_SIMPLE flag on the first point tells a rasteriser to fill with the non-zero rule; Apple’s rasterisers honour it and, without it, an interpolated instance can render with holes where strokes cross. Setting it unconditionally is what fontTools does at the default overlap mode and what the original therefore inherits, and it is safe because a non-overlapping outline fills identically under either rule. Verified in fontTools 4.62.1: after a full pin, both contoured glyphs of recursive-vf have flag 0x40 on point 0, while the empty .notdef gets nothing because it has no points.

covers G12 · authority: fonttools · fixture: recursive-vf

static.pin-all.dead-feature-variation-dropped

A feature variation that cannot hold at the pinned location leaves no lookups behind

The mirror of the previous case: at the fvar default nothing in recursive-vf’s rvrn condition set is satisfied, so the italic substitution must not fire in the instance. If the lookups were folded in unconditionally the upright instance would shape ‘a’ as ‘a.italic’ - a visible, silent corruption. Verified in fontTools 4.62.1: at the default location the output GSUB has no FeatureVariations, an empty LookupList, and no rvrn feature record at all.

covers G1, G6 · authority: fonttools · fixture: recursive-vf

static.pin-all.drops-avar

Pinning every axis removes avar

avar exists only to remap the normalized value of an fvar axis, and the OpenType specification requires its axis segment maps to correspond one-to-one, in order, with the fvar axes. A static font has no fvar axes, so an avar would have to be either empty or dangling; either is malformed. Verified in fontTools 4.62.1: avar is absent from the output of a full pin of recursive-vf, whose source avar carries a non-trivial eight-point map on wght and a steep segment on slnt.

covers G1, G3 · authority: spec · fixture: recursive-vf

static.pin-all.drops-fvar-and-gvar

Pinning every axis removes fvar and gvar

The OpenType specification defines fvar as the table that declares a font’s variation axes and gvar as the table of glyph deltas indexed by them; an fvar with zero axes is not a legal record, and gvar deltas with nothing to interpolate against are unreachable data. Once every axis has a fixed coordinate the deltas have been consumed into the outlines, so keeping them would both bloat the file and let a renderer apply them twice. Verified in fontTools 4.62.1: both tables are absent after instantiateVariableFont with all five Recursive axes pinned.

covers G1 · authority: spec · fixture: recursive-vf

static.pin-all.drops-metrics-variation-stores

Pinning every axis removes HVAR, VVAR and MVAR

HVAR, VVAR and MVAR are all item variation stores whose delta-set indices are resolved against a normalized coordinate vector; with no fvar there is no coordinate vector, so per the OpenType specification those tables cannot be interpreted and must not be present. Their content is not discarded but baked in first - see static.mvar.applied-then-deleted - so removing them loses nothing. Verified in fontTools 4.62.1: the output table set after a full pin of recursive-vf contains none of the three. The VVAR expectation is inert for this fixture, which has no vertical metrics, and is stated so that a fixture that gains them is covered.

covers G1 · authority: spec · fixture: recursive-vf

static.pin-all.feature-variation-folded-in

A feature variation that holds at the pinned location becomes an ordinary feature

FeatureVariations condition sets are expressed in normalized axis coordinates and are indexed by fvar axis order, so the OpenType specification gives them no meaning in a font without fvar; leaving one behind in a static instance is malformed. A condition that is satisfied at the pinned location is not simply discarded, though - its substitutions are what the user asked for and must survive as a plain feature. Verified in fontTools 4.62.1: pinning recursive-vf at slnt=-15, CRSV=1 leaves GSUB with no FeatureVariations, one lookup, and rvrn mapping a to a.italic.

covers G1, G6 · authority: spec · fixture: recursive-vf

static.pin-all.keeps-glyph-set

Instancing does not subset the glyph set, the layout tables or the hinting tables

Instancing narrows the design space, not the character set, and it is a different operation from subsetting; it must not quietly do both, nor drop tables it merely fails to recognise. This matters concretely for recursive-vf, where a.italic is reachable only through a feature-variation substitution: at the default location nothing substitutes it any more, yet dropping the glyph would change what a shaper can produce through other means and would renumber every glyph ID. The gasp and prep expectations guard the same property from the other side - a table-allowlist implementation that kept only the tables the corpus names elsewhere would silently unhint the output. Verified in fontTools 4.62.1: numGlyphs stays 3 and cmap, GDEF, GPOS, GSUB, gasp and prep are all still present after a full pin.

covers G1 · authority: fonttools · fixture: recursive-vf

static.pin-all.keeps-stat

STAT survives a full pin

STAT is the one variation-related table that is not about interpolation: the OpenType specification requires it in all variable fonts and recommends it in every static font that is part of a variable family, because it is what tells the shell where this instance sits in the family’s style space. Removing it alongside fvar would turn a well-described instance into an orphan. Verified in fontTools 4.62.1: after a full pin the STAT table is present, its DesignAxisRecord still lists all five Recursive axes, and only the axis values that do not hold at the pinned location have been dropped.

covers G1, G5 · authority: spec · fixture: recursive-vf

static.pin-all.no-dangling-name-ids

Axis and instance name records left behind are exactly the ones STAT still uses

The OpenType name specification reserves IDs 256 and above for font-specific uses referenced from other tables; once fvar is gone, an ID that nothing points at is dead weight that no shell can ever display. The interesting part is that a full pin does not strip all of them: STAT keeps its design axis records, so the five axis nameIDs stay referenced and must survive. Verified in fontTools 4.62.1: a fully pinned recursive-vf retains nameIDs 269-273 (the axis names, referenced by STAT DesignAxisRecord) plus the surviving STAT axis-value names, and drops the sixty-four instance subfamily names that fvar used to own.

covers G1, G5, G10 · authority: spec · fixture: recursive-vf

static.usweightclass.centred-default-extreme

usWeightClass follows the pin on an axis whose default is centred

centred-default has wght 100/400/900, so a pin at the maximum is two-thirds of the way up a design space whose two halves normalize at different rates; the weight class is nevertheless read straight off the user-space coordinate and owes nothing to normalization. This is the shape of axis on which an implementation that computed the weight class from the normalized value would be caught out, because 900 normalizes to 1.0 while 250 and 650 both normalize to half. The OpenType specification defines usWeightClass on the same scale as the wght coordinate itself.

covers G7, G1 · authority: spec · fixture: centred-default

static.usweightclass.centred-default-minimum

usWeightClass follows a pin below the centred default

The lower half of centred-default’s wght axis spans 100 to 400 while the upper spans 400 to 900, so the two sides normalize at different rates and a value below the default is the case an implementation is most likely to mishandle. As above, the OpenType specification puts usWeightClass on the wght axis’s own scale, so the answer is simply the pinned number. Keeping both halves as separate cases means a sign or direction error shows up as one failure rather than none.

covers G7, G1 · authority: spec · fixture: centred-default

static.usweightclass.from-pin-interior

usWeightClass follows a wght pin between the axis extremes

The same rule at an interior coordinate, which is where it actually bites: recursive-vf’s wght axis has its default at the minimum (300), so every instance except the lightest needs usWeightClass rewritten. A Regular sliced out of this family must declare 400, or every OS style-matcher will file it as Light. Verified in fontTools 4.62.1: usWeightClass is 400 after pinning wght=400.

covers G7 · authority: spec · fixture: recursive-vf

static.usweightclass.from-pin-max

usWeightClass follows a wght pin at the axis maximum

The OpenType specification says the registered wght axis and OS/2.usWeightClass express the same quantity on the same 1-1000 scale, and that an instance’s usWeightClass should match its wght coordinate; a font left declaring the variable font’s default weight would sort and substitute as the wrong weight everywhere. recursive-vf’s source usWeightClass is 300, so a pin at 1000 must change it. Verified in fontTools 4.62.1 (varLib.set_default_weight_width_slant): the output reads 1000.

covers G7 · authority: spec · fixture: recursive-vf

static.usweightclass.is-userspace-not-avar-mapped

usWeightClass is the coordinate the user typed, not the avar-mapped one

avar remaps normalized coordinates to redistribute interpolation; it says nothing about what the design-space label means. The OpenType specification defines usWeightClass in user-space weight units, so it must be read off the pin before normalization - recursive-vf’s avar bends wght hard (normalized 0.4286 maps to 0.4286 but 0.2857 maps to 0.3400), and taking the mapped value would produce a weight class that no user asked for. Verified in fontTools 4.62.1: pinning wght=600, a coordinate that avar demonstrably moves, still yields usWeightClass 600.

covers G7, G3 · authority: spec · fixture: recursive-vf

static.usweightclass.rounds-fractional-pin

A fractional wght pin rounds to an integer usWeightClass

fvar axis coordinates are Fixed 16.16 and can legitimately be fractional, but OS/2.usWeightClass is a uint16, so some rounding rule must be applied and it must be stated rather than left to whatever the language’s float-to-int does. fontTools uses otRound, which is round-half-up: 350.5 goes to 351, where Python’s own round() and any round-half-to-even implementation would give 350. That difference is why the value probed here is a half-way one - it is the only kind of input on which the two conventions disagree. Verified in fontTools 4.62.1.

covers G7, B3 · authority: fonttools · fixture: recursive-vf

static.uswidthclass.between-nodes-rounds-up

wdth=70 gives usWidthClass 3, not 2

70 lies between the Extra-condensed node at 62.5 and the Condensed node at 75, so the piecewise-linear map gives 2.6, which rounds to 3; an implementation that floored, or that treated the map as a lookup of the nearest lower node, would answer 2. The OpenType specification names only the nine anchors and says nothing about values between them, so this is fontTools’ documented rule rather than a spec requirement, and it is what the original inherits. Verified in fontTools 4.62.1. Requires two-axis’s wdth axis to include 70.

covers G7 · authority: fonttools · fixture: two-axis

static.uswidthclass.normal

wdth=100 gives usWidthClass 5

The OpenType specification tabulates usWidthClass 1-9 against nine named widths from Ultra-condensed to Ultra-expanded, and pairs class 5 (Medium/normal) with a wdth of 100%; the registered wdth axis is defined as a percentage of normal width, so 100 must map to 5. Verified in fontTools 4.62.1 against WDTH_VALUE_TO_OS2_WIDTH_CLASS. This case and the five that follow require the two-axis fixture’s wdth axis to span the full registered 50-200 range with default 100, because fontTools clamps a pin to the axis extent before mapping and a narrower fixture would silently test a different value.

covers G7 · authority: spec · fixture: two-axis

static.uswidthclass.ultra-condensed

wdth=50 gives usWidthClass 1

50% is the OpenType specification’s Ultra-condensed anchor and the bottom of the registered wdth range, so it maps to class 1; it is also the endpoint that a clamping bug would move, since fontTools clamps the value into 50..200 before mapping. Verified in fontTools 4.62.1: piecewiseLinearMap over the nine registered nodes gives 1 at 50, and any pin below 50 clamps to 1 as well. Requires two-axis’s wdth axis to reach 50.

covers G7 · authority: spec · fixture: two-axis

static.uswidthclass.untouched-without-wdth-axis

A font with no wdth axis keeps the usWidthClass it had

The rewrite is keyed on the axis being present in the pinned location, not on the field existing: recursive-vf has MONO, CASL, wght, slnt and CRSV but no wdth, so there is no width information to derive and the font’s own declaration must stand. An implementation that reset usWidthClass to a default of 5 whenever it wrote an instance would be wrong for any condensed family, and would be invisible here only by coincidence. Verified in fontTools 4.62.1: usWidthClass is 5 before and after, because set_default_weight_width_slant never enters the wdth branch.

covers G7 · authority: fonttools · fixture: recursive-vf

static.uswidthclass.upper-segment-is-not-linear

wdth=120 gives usWidthClass 7, showing the map is piecewise and not linear in wdth

A naive linear map from 50..200 onto 1..9 would put 120 at about 4.7; the real map is piecewise over nine unevenly spaced nodes, and between 112.5 (class 6) and 125 (class 7) the value 120 gives 6.6, which rounds to 7. This is the single best case for catching an implementation that reimplemented the mapping as arithmetic instead of a table. Verified in fontTools 4.62.1. Requires two-axis’s wdth axis to include 120.

covers G7 · authority: fonttools · fixture: two-axis

static.uswidthclass.wide-segment-half-way

wdth=175 gives usWidthClass 9

The last two nodes are 150 (class 8) and 200 (class 9), fifty units apart where the lower segments are 12.5 apart, so 175 sits exactly half way and the map yields 8.5; otRound is round-half-up, so the answer is 9 rather than the 8 that round-half-to-even would give. This is the only half-way point in the whole map, which makes it the case that pins down the rounding rule for widths. Verified in fontTools 4.62.1. Requires two-axis’s wdth axis to reach 175.

covers G7 · authority: fonttools · fixture: two-axis

Coverage

The behaviour map declares 61 claims. 10 of them describe the running application rather than the font it produces and cannot be reached by a headless corpus; of the 51 that remain, 51 have at least one case.

Claims out of scope for this corpus, and what each one is:

Testing these means driving the Qt application through a display, which is a different harness from this one. They are recorded in the behaviour map so that a reimplementation knows they exist, not because the corpus checks them.

claim cases statement
A1 3 A file is accepted as a font if fontTools.ttLib.TTFont can open
A2 1 A font is treated as variable if and only if it has an fvar
A3 19 TrueType, CFF, WOFF and WOFF2 inputs are all readable, because
A5 5 Loading a font resets all six bit-flag checkboxes to unchecked,
B1 7 An empty cell means “leave this axis alone”: the axis keeps its
B2 14 A cell containing : is a restricted range. Anything else
B3 17 A pin is parsed with Python’s float()
B4 9 A pin that float() rejects raises
B5 15 A range is parsed with the regular expression
B6 5 A reversed range is sorted, so 800:400 and 400:800 mean the
B7 19 A restricted range must contain the axis’s original default value,
B8 2 A trailing [default] group parses and is then discarded. The
B9 3 A range whose bounds fall outside the axis’s own extent is
B10 3 A pin outside the axis extent is likewise not checked, and is
B11 5 pin parser is float(), which accepts nan, inf, -inf, 1e3 and
B12 7 range parser uses re.search, not re.fullmatch
B13 4 If every cell is empty, the Slice button refuses the job: “You
C1 6 One row per fvar axis, in fvar order, labelled with the axis
C2 2 The read-only column shows {min} : {max} [{default}] using the
C3 1 The row tooltip is a human-readable axis name: the registered name
C4 1 The axis-name lookup consults the font’s name table only as a
D1 7 Nine rows, for nameIDs 1, 2, 3, 4, 6, 16, 17, 21, 22, labelled
D2 12 Only the Windows / Unicode BMP / English (US) record — platform 3,
D3 14 nameIDs 1, 2, 3, 4 and 6 are always written to the output, even
D4 21 nameIDs 16, 17, 21 and 22 are written when the cell is non-empty
D5 8 editor loads nameIDs 1, 2, 3, 4 and 6 from the font. It never loads 16, 17,
D6 1 nameID 5 is read for the status bar’s version display but is not
E1 15 Four OS/2.fsSelection checkboxes: bit 0 ITALIC, bit 5 BOLD,
E2 11 Two head.macStyle checkboxes: bit 0 BOLD, bit 1 ITALIC
E3 27 Each checkbox sets its bit when ticked and clears it when unticked
E4 6 a font clears all six checkboxes (A5), and slicing then writes that
E5 10 editor permits contradictory combinations — REGULAR together with BOLD, or
F1 1 With no font loaded, the status bar reads “Requires a font path”
F2 3 Axis entries are validated before the save dialog opens; a parse
F3 3 The job is refused if it does not narrow the design space (B13)
G1 27 Pinning every axis yields a static font: no fvar, no gvar, no
G2 43 Pinning some axes and restricting others yields a variable font
G3 11 avar segment maps are renormalized onto the new extents
G4 12 Named instances that fall outside the new design space are dropped
G5 15 STAT is kept; its axis values outside the new limits are
G6 10 GSUB/GPOS feature variations are resolved: a condition set that
G7 22 OS/2.usWeightClass, OS/2.usWidthClass and post.italicAngle
G8 15 maxp’s point, contour and composite maxima, the head bounding
G9 1 OS/2.xAvgCharWidth is recalculated
G10 24 Name records that existed only to name axes and instances the
G11 1 DSIG is deleted, because it signs bytes that no longer exist
G12 4 At the default overlap mode (KEEP_AND_SET_FLAGS), a static
G13 46 Outlines at the requested location match what a renderer produces
G14 20 original never removes overlaps. instantiateVariableFont accepts
H1 21 output container is the input’s container, whatever the user names the
H2 1 WOFF output is compressed with zopfli, because the worker sets