Slice

Adjudication: every case the original fails, and why

Every case the original Slice fails, and the measurement behind each verdict

The corpus in tests/suite/ is written against what a Slice-like program should do, not against what either implementation happens to do. So when the original fails a case there are only two possibilities, and the difference matters:

This document records the verdict for every case in both categories. Nothing here is argued from reading the source alone: each verdict names a measurement and the artefact that produces it, so it can be re-run.

Scoreboard

Against the 298-case corpus, 17 of which need overlap removal that the original never had and which are therefore not counted against it:

  passed failed needs a feature it lacks
the original 231 50 17
this implementation 298 0 0

An earlier revision of this file recorded seven failures here, all CFF: this implementation refused CFF outlines and the original, delegating to fontTools’ instantiateCFF2, did not, which was the one place the original scored better. That gap is now closed — CFF2 is instanced rather than refused — and closing it turned up the tenth wrong test, below.

Reproduce with:

tests/suite/run.py --verbose          # both

Part 1 — tests that were wrong

Ten cases were unpassable, or were measuring something other than what they claimed. Every one of them was caught by a correct implementation failing it, which is the argument for running the corpus against two programs rather than one.

The checker could not see composite glyphs

checker.py::_draw used a plain RecordingPen, which records addComponent verbatim. A composite glyph therefore arrived as a list of glyph names and transforms with no geometry in it, flattened to zero contours, and every point in the glyph read as outside it. Two cases reported “1125 sampled points changed fill state” on fonts that were correct; the same blindness handed the coordinate rounder a string and raised TypeError.

Measured: with a DecomposingRecordingPen, the identical check on the identical fonts reports 10626 points in agreement.

Affected overlap.overlapping-components-are-merged, overlap.non-overlapping-composite-is-not-decomposed. Fixed in a3090e7.

An absent feature was not allowed to run zero lookups

feature_lookup_count returned failure whenever the named feature was missing, even for a case asserting equals: 0. Four feature-variation cases each documented, in their own rationale, that fontTools deletes the rvrn record once its lookups are resolved away — and then demanded that the record be present and empty. The original failed all four for doing exactly what those rationales called correct.

Affected static.pin-all.dead-feature-variation-dropped, partial.featurevars-unreachable-condition-dropped, partial.featurevars-condition-outside-restricted-range-dropped, partial.featurevars-all-records-die-but-axes-survive. Fixed in the commit above this file.

Outlines were compared against a float interpolation

outlines_match_source_at drew its reference through getGlyphSet(location=…), which interpolates in floating point. A font cannot: glyf stores integers, so a pinned instance holds 225 where the interpolation says 224.5. outlines.static-matches-source-at-extremes sets a tolerance of zero deliberately — at a corner of the design space every scalar is 1 or 0 and the delta arithmetic is exact — and that deliberate strictness was unsatisfiable by any correct program, because half a unit of rounding is not a claim about instancing.

The reference is now instantiateVariableFont, compiled and reloaded. Compiling matters: the instancer leaves interpolated coordinates as Python floats in memory and rounds only when the table is written.

tests/suite/probes/probe-outline-tolerance.py measures the gap directly, as the worst coordinate disagreement over every point of every glyph between a saved instance and an interpolation of the source at the same location:

wght=300,  every axis at an end     coord 0.0000
wght=653.7                          coord 0.4973
MONO=0.5 CASL=0.5 wght=500 slnt=-7  coord 0.4998
MONO=1 wght=300 slnt=-15 CRSV=0     coord 0.0000
CASL=1 wght=1000 CRSV=1             coord 0.0000

which is the case’s own premise, measured: at a corner of the design space the deltas are applied undivided and the disagreement is exactly nothing, while in the interior it is one int16 rounding and never more. Zero tolerance at a corner is not merely satisfiable, it is the right requirement — and 0.5 is the right one everywhere else.

Fixed in 5d8c8fa.

An axis extent was compared for exact float equality

fvar stores axis values as 16.16 Fixed. 0.95 is not representable in it and reads back as 0.9499969482421875, so partial.featurevars-condition-renormalized-to-restricted-range could not be satisfied by anything. Comparison now allows one ulp of that type.

Fixed in 35e060c.

A case asserted two contradictory things about one font

overlap.clean-glyph-is-left-alone enabled overlap removal and then asserted outlines_match_source_at across the whole font, which requires bars to be unchanged — contradicting overlap.two-bars-merge-into-one-contour on the same fixture, and ignoring that instancing to wght=400 moves every outline by itself.

Its own rationale described the right check: comparison against the same job with the feature switched off. That control case now exists, and the assertion is scoped to clean and made point-level, which is what “not rebuilt” means — a rebuilt glyph can draw the same shape with different points, and that is the cost the case exists to forbid.

Fixed in a3090e7.

A case named an axis its fixture has never had

partial.composites-survive-a-restricted-range pinned wdth on a fixture with only wght, pinned rather than restricted despite its title, and listed check locations at wght: 100 on an axis that starts at 400. It could not run at all.

Re-encoded on the axis the fixture has. It immediately found a real defect in this implementation: composite bounding boxes were being written as (0, 0, 0, 0). Fixed in 76e9295.

A fixture shipped malformed

hinted.ttf declared maxp.maxSizeOfInstructions = 0 in front of a five-byte glyph program. Nothing in the varLib build path computes that field. So static.hinting.maxp-instruction-limits-cover-the-programs was unpassable by any implementation that correctly leaves the instruction maxima alone — which is precisely what that case’s own rationale requires, citing fontTools’ maxp.recalc documenting that it recomputes “most other maxp values except for the TT instructions values”.

Fixed in the fixture builder, where the defect was, rather than in the case. Commit b6e3a81.

Two cases required a refusal where the correct answer was a correct reading

axis.range-scientific-notation-is-refused and axis.range-leading-dot-bound-is-refused demanded that 300:1e3 and .5:900 be rejected, on the grounds that the original’s range grammar — a regular expression — has no production for an exponent or a leading decimal point. Both rationales explicitly left the permissive reading open: “extending the range grammar to accept exponents would also be acceptable and would need its own case, but silently reading 1e3 as 1 is not.”

Measured against fontTools 4.62.1, whose instancer is what Slice hands its plan to and whose --axis syntax users learn:

parseLimits(['wght=300:1e3'])   ->  {'wght': (300, None, 1000)}
parseLimits(['wght=.5:900'])    ->  {'wght': (0.5, None, 900)}

A limit string cannot mean 300-to-1000 in fonttools varLib.instancer and be a syntax error here. Both cases keep their subject and change their obligation from “refuse” to “read correctly”, which is the stronger check: each is now built so the two readings are distinguishable. Commit d1d2c0d.

filled_region_matches drew the reference at the wrong location

Every case using this check writes its reference location as reference.source_at, which is what tests/suite/README.md documents. checker.py read reference.location, found nothing, and fell back to {} — so the source was drawn at its default instance rather than at the location the case named.

That was invisible for four of the five cases: they all use the overlapping fixture and pin wght at 400, which is its default, so the two readings coincide. The fifth, cff.remove-overlaps-preserves-filled-region, pins the cff2-vf fixture at wght=700, and there the check compared a correctly merged bold outline against an unmerged regular one and reported 2019 sampled points as having changed fill state.

Measured: reading source_at, the same output against the same fixture reports zero mismatched points in all four glyphs, on the checker’s own 41 x 37 grid with its own half-unit edge margin. The independent Rust check overlap_removal_preserves_shape::the_filled_region_survives_overlap_removal_on_cff2 agrees, over a 61 x 61 grid.

Affected cff.remove-overlaps-preserves-filled-region; location is still accepted as a spelling so a case written either way means the same thing.

Part 2 — defects in the original

The remaining 50 failures reduce to four root causes. None is a matter of taste; each produces a font that misrepresents itself.

B10 / B11 / B12 — the axis cell is matched, not parsed (15 cases)

DesignAxisModel.axis_range_regex is applied with re.search, not re.fullmatch, and the pin path is a bare float(). Together these mean the cell is scanned for something that looks like a number and the rest is discarded.

Measured, with tests/suite/probe-original.py axis:

typed what the original does
wght=300:1e3 accepted as 1:300, clamped to 300:300, the weight axis is deleted — a static Light font, no message
CRSV=.25:1 read as 25:1; refused with “The CRSV range 1.0:25.0 does not include the default axis value (0.5)” — a range the user never typed
wght=3e2:7e2 re.search scans to 2:7 inside it and refuses against that
wght=inf accepted; float('inf') clamps to the axis maximum
wght=nan refused, but with the debug print “Ooops… v=nan, triple=(300.0, 300.0, 1000.0)”
wght=4_00 accepted as 400 — Python literal syntax leaking into a text field
abc 300:700 xyz accepted; the surrounding text is ignored
300:700:900 accepted as 300:700
300:700[500] accepted, and the [500] silently dropped
a pin outside the axis extent accepted and clamped

The first row is the serious one: a user asking to keep the whole weight range receives a font with no weight axis and is told nothing.

Verdict: defect. The tests stand.

Two further cases in this area, slice.whitespace-only-cell-counts-as-blank and slice.only-whitespace-cells-is-refused-as-blank, are a smaller matter: a cell holding two spaces reaches float(" ") and the job is refused with a message quoting whitespace back at the user. A cell containing spaces is indistinguishable on screen from an empty one, so the message describes a problem nobody can see. Both cases are labelled judgement and say so.

E4 — loading a font clears the bit-flag checkboxes (15 cases)

MainWindow.load_font clears all six bit checkboxes without reading the font’s current values, and the worker then writes whatever the boxes say. So opening a font and slicing it without touching that panel clears every exposed bit.

Measured, with tests/suite/probe-original.py --enrich --both roundtrip --axis wght=400:

               usWeightClass       fsSelection          macStyle
input                    300  0000000011000000  0000000000000000
original                 400  0000000010000000  0000000000000000
ours                     400  0000000011000000  0000000000000000

Bit 6 (USE_TYPO_METRICS) is cleared. Bit 7 (WWS) survives — and that asymmetry is the proof of the mechanism: bit 7 is not one of the six the editor exposes, so nothing overwrites it. A font that declared USE_TYPO_METRICS comes out of a no-op slice without it, which changes line spacing in every application that honours the flag.

Verdict: defect. The tests stand.

D5 — the four typographic and WWS name records are deleted (8 cases)

FontNameModel.load_font fills rows for nameIDs 1, 2, 3, 4 and 6 only. Rows 16, 17, 21 and 22 stay empty, and edit_name_table deletes optional records whose row is empty. So a font carrying typographic or WWS family names loses all four on any slice.

Measured, same probe run as above (the --enrich flag adds the four records first, because the stock fixture has none and their loss is otherwise invisible):

                 name 16       name 17       name 21       name 22
input            present       present       present       present
original            GONE          GONE          GONE          GONE
ours             present       present       present       present

nameID 16/17 are how a family with more than four styles tells an operating system which styles belong together. Losing them collapses a large family into a pile of unrelated four-member groups in every font menu.

Verdict: defect. The tests stand.

H1 — the output container is inherited from the input, not from the extension (12 cases)

TTFont.save never looks at the filename. 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). The extension the user types is not consulted in either direction:

tests/suite/probes/probe-output-container.py runs the whole matrix:

Recursive-VF.subset.ttf    -> .woff    result flavor: None    size 2048
Recursive-VF.subset.woff   -> .ttf     result flavor: woff    size 1628
Recursive-VF.subset.woff2  -> .ttf     result flavor: woff2   size 1052
Recursive-VF.subset.ttf    -> .woff2   result flavor: None    size 2048

A desktop font manager asked to install the second will reject it; a web server handed the first will serve an uncompressed font with a .woff content type. This is the claim the behaviour map originally recorded backwards — H1 was written as “the extension decides” from reading the UI, and the measurement corrected it.

Verdict: defect. The tests stand.

Part 3 — what the original does that the corpus does not count against it

17 cases need overlap removal. The original passes no overlap argument to instantiateVariableFont and never claimed to; run.py reports these separately as “needs a feature it does not have” rather than as failures. Adding the feature is the reason this reimplementation exists.

Re-running any of this

tests/suite/run.py                      # both programs, whole corpus
tests/suite/run.py --runner original --verbose
tests/suite/probe-original.py axis wght -- 400 inf nan 1e3 '300:700[500]'
tests/suite/probe-original.py --enrich --both roundtrip --axis wght=400

tests/suite/README.md describes the corpus layout; docs/test-suite.md describes every case in plain English; docs/original-behaviour.md is the numbered behaviour map the covers fields point at.