feat(renderer): scene 2 text: bundled fonts, measurement before drawing, stated fit rules (P2) #16

Merged
james.maes merged 3 commits from feat/scene2-text into develop 2026-11-12 23:11:04 +00:00
Owner

Package P2 of docs/design/LABEL-LAYOUT-FONTS-PREVIEW.md (work plan B): text for version 2 label scenes. Bundled fonts, measurement before drawing, and a stated rule for a value that does not fit. Builds on the scene 2 core (#14); version 1 is untouched.

What a label author gets

  • Real fonts. Five faces ship inside the renderer, so a label needs no printer font, no installed font and no object storage: qqq-sans, qqq-sans-bold, qqq-condensed-bold, qqq-mono, qqq-mono-bold.
  • Sizes in points or millimetres. "size": "9pt" or "3mm", resolved at the render target (203, 300 or 600 dpi) and not rounded to dots.
  • A text is measured before it is drawn and placed in its box by align and valign. Ink never leaves the box and is never clipped.
  • A stated rule when a value does not fit, chosen per element (table below), with a diagnostic that names the element, what was measured and the limit.
  • maxLength proves the fit when the document is published, so a length refusal is a design-time event and not a failed print.
  • Thermal legibility controls: threshold (heavier strokes), bold faces, warnings for small type, and resolution overrides for either.
  • Rotation (0, 90, 180, 270) and reverse text (white on a black box).

Fit rules as implemented

Step Rule Reported as
1 Lay the value out at size within fit.maxLines (default 1). If it fits, it is never shrunk. nothing
2 Otherwise take the largest size, in steps of 0.25 pt down to fit.shrinkTo, at which it fits. shrinkTo defaults to 70% of size and not below 5 pt (the size itself when the size is under 5 pt). TEXT_SHRUNK (info): chosen size, nominal size, floor
3 If nothing fits down to the floor, apply fit.overflow: refuse (the default) or truncate. TEXT_DOES_NOT_FIT (error): measured width, height, lines and size against the box
Wrap Only with maxLines above 1. At each size a line is broken between words while the text then fits, and at any character when it does not. So a value with no space wraps, and a larger size is preferred to whole words. Never more than maxLines lines. TEXT_WRAPPED (info): lines
Truncate Only with overflow: "truncate", only at the floor size: as many lines as the box holds, the last ending with an ellipsis after as many of its characters as fit with it. The mark is always shown and the result always fits; where not even the mark fits, the text is refused. TEXT_TRUNCATED (warning): characters, characters shown
Length maxLength (1 to 1024). Validation lays out that many of the face's widest character, each taken to reach as far in every direction as the furthest-reaching character of the face, and refuses the scene if they do not fit. Printing refuses a longer value before any layout. The proof never cuts: with overflow: "truncate" a length that is not proven does not refuse the scene but warns at every validation, with the length that is proven. TEXT_DOES_NOT_FIT at validation, VALUE_TOO_LONG at print, FIT_NOT_PROVEN (warning) for a bound element with no maxLength, and with provenCharacters for a cutting element whose maxLength is not proven

Other refusals: VALUE_MISSING (the pointer finds no text, number or true/false), FONT_MISSING_GLYPH (a character the face cannot draw as one glyph, with the count), FONT_UNKNOWN (a face that is not bundled, at the place that names it), RESERVED_FONT_NAME (a supplied asset whose name begins qqq-). Warnings: TEXT_SMALL_FOR_DPI (capitals under 12 dots), TEXT_LIGHT_FACE (a regular weight with capitals under 20 dots). A diagnostic holds ids and numbers only; no value ever enters a diagnostic, a log line or an exception message, and a test puts a marker through every refusal to hold that.

A rendered sample

renderer/src/test/resources/scene2/cases/text-fit.json on 2 x 1 in stock with 2 mm margins, at 203 dpi (golden: scene2/golden/text-fit-203.png; the 300 and 600 dpi goldens are beside it). Thin outlines show each element's box.

Element Scene What was drawn Fit applied
kept LOT 240611, qqq-sans-bold 10 pt, 22 x 5 mm, centred 10 pt, one line, centred within one dot none
right 9.5 kg, qqq-condensed-bold 11 pt, 22 x 5 mm, right and bottom 10.25 pt TEXT_SHRUNK: 11 pt is 41 dots high and the box is 39
shrunk (01)00012345678905(10)L240611A, qqq-mono-bold 9 pt, 44 x 4 mm 6.75 pt, one line TEXT_SHRUNK from 9 pt (floor 6.3 pt)
wrapped 46 characters with no space, qqq-mono 7 pt, 36 x 7.5 mm, maxLines: 2, maxLength: 46 7 pt, two lines of 24 and 22 characters, each centred TEXT_WRAPPED; also TEXT_LIGHT_FACE (capitals 14 dots, regular weight)
cut a 42-character description, qqq-sans 7 pt, 30 x 3.6 mm, overflow: "truncate" A description of the item… TEXT_TRUNCATED: 25 of 42 characters shown

The diagnostic of a refusal, here the design's own example caption (two lines, floor 7 pt, maxLength 48, 94 x 6.5 mm) when validated at 203 dpi:

{ "code": "TEXT_DOES_NOT_FIT", "severity": "ERROR", "element": "caption", "box": [6, 100, 751, 51],
  "measured": { "characters": 48, "heightDots": 59, "lines": 2, "sizeCentipoints": 700, "widthDots": 751 },
  "limit": { "heightDots": 51, "lines": 2, "widthDots": 751 } }

Bundled fonts

Scene name Family and style File (under /fonts in the jar) Version Source SHA-256 Licence
qqq-sans IBM Plex Sans Regular ibm-plex-sans/IBMPlexSans-Regular.ttf release @ibm/plex-sans@1.1.0, font 3.005 https://github.com/IBM/plex/releases/download/%40ibm/plex-sans%401.1.0/ibm-plex-sans.zip 975dcda37d80f038dcd143c22e33ca2d97a0cc5a929aace1c749153b0fe1afa5 SIL OFL 1.1
qqq-sans-bold IBM Plex Sans Bold ibm-plex-sans/IBMPlexSans-Bold.ttf the same release, font 3.005 the same archive 9e6c74a889a700d707613d24548fe4ffa6bc59559a0689d2cf9e133bdcdafb2f SIL OFL 1.1
qqq-condensed-bold IBM Plex Sans Condensed Bold ibm-plex-sans-condensed/IBMPlexSansCondensed-Bold.ttf release @ibm/plex-sans-condensed@2.0.0, font 3.000 https://github.com/IBM/plex/releases/download/%40ibm/plex-sans-condensed%402.0.0/ibm-plex-sans-condensed.zip 723e245902b45a49f8cf602d15891bae08c31309dce7c56d209d10cd471b88c7 SIL OFL 1.1
qqq-mono JetBrains Mono Regular jetbrains-mono/JetBrainsMono-Regular.ttf release v2.304, font 2.304 https://github.com/JetBrains/JetBrainsMono/releases/download/v2.304/JetBrainsMono-2.304.zip a0bf60ef0f83c5ed4d7a75d45838548b1f6873372dfac88f71804491898d138f SIL OFL 1.1
qqq-mono-bold JetBrains Mono Bold jetbrains-mono/JetBrainsMono-Bold.ttf the same release, font 2.304 the same archive 5590990c82e097397517f275f430af4546e1c45cff408bde4255dad142479dcb SIL OFL 1.1

Archive SHA-256: ibm-plex-sans.zip fb365d910566e6d199cc2c15579a7dd9a267128e18431a394ed81f1970c69200, ibm-plex-sans-condensed.zip f86ba73abbeed7cf7690ead863192a87211dfc2b0cef0ac6bf4700533ec5ab9e, JetBrainsMono-2.304.zip 6f6376c6ed2960ea8a963cd7387ec9d76e3f629125bc33d1fdcd7eb7012f7bbf.

How each licence was confirmed. From the release archive itself, not from memory. Each IBM Plex archive's LICENSE.txt and the JetBrains Mono archive's OFL.txt state "This Font Software is licensed under the SIL Open Font License, Version 1.1" and carry the full text. The licence field of every font's name table says the same, and every font's embedding bits (OS/2.fsType) are 0, installable embedding. The licence files ship byte for byte beside the fonts (.gitattributes keeps git from rewriting anything under /fonts), each directory has a SOURCE.md with the release, archive, path and hashes, and /fonts/SHA256SUMS lists the hashes. FaceTest fails the build when a licence, a source note or a hash is missing or a byte changes. The files are unmodified and keep their names; IBM Plex's Reserved Font Name "Plex" is not used for anything derived. The notes are named SOURCE.md because this repository's .gitignore ignores PROVENANCE.md.

The fonts add 531,819 bytes to the renderer jar (1,172,935 uncompressed), which is now 736 KB.

Dependency-licence exception (K22-DEP-02), for the maintainer to file

The SIL OFL 1.1 is not on the K22-DEP-02 allowlist. The exception register is exceptions/register.md in the standards repository, which this pull request does not edit. Proposed entry, with the next free number:

EX-00NN

Unmodified SIL Open Font License 1.1 fonts shipped as data in the document renderer

  • Status: Open upon merge of this record. The maintainer approved the exception and named the set on 2026-11-12.
  • Requirements: K22-DEP-02. The SIL Open Font License 1.1 (SPDX OFL-1.1) is not on the allowlist of licences for dependencies that ship with a product. No vulnerability, provenance, signing or branch-protection requirement is waived.
  • Scope: QRun-IO/qbit-document-generation, module renderer, exactly these unmodified font files shipped as data under /fonts of the renderer JAR, with these SHA-256: IBM Plex Sans Regular 975dcda37d80f038dcd143c22e33ca2d97a0cc5a929aace1c749153b0fe1afa5 and Bold 9e6c74a889a700d707613d24548fe4ffa6bc59559a0689d2cf9e133bdcdafb2f (release @ibm/plex-sans@1.1.0); IBM Plex Sans Condensed Bold 723e245902b45a49f8cf602d15891bae08c31309dce7c56d209d10cd471b88c7 (release @ibm/plex-sans-condensed@2.0.0); JetBrains Mono Regular a0bf60ef0f83c5ed4d7a75d45838548b1f6873372dfac88f71804491898d138f and Bold 5590990c82e097397517f275f430af4546e1c45cff408bde4255dad142479dcb (release v2.304). No other font, version, modified or renamed font, or other licence is covered. A font is data read by the renderer; it is not linked code.
  • Reason: Version 2 label text is rasterised by the renderer so that it can be measured, fitted and previewed truthfully, which a printer's built-in font cannot be. That needs real bold, condensed and monospace faces with clear 0/O and 1/l/I that work with no object storage and no installed font. The OFL expressly permits bundling and redistribution with software. The alternative, restricting the set to fonts under Apache-2.0, gives up the faces chosen for legibility on thermal print.
  • Risk: The OFL's conditions apply to every distribution of the renderer: the copyright and licence notices must accompany the fonts, the fonts may not be sold by themselves, a modified version may not use a Reserved Font Name ("Plex"), and the fonts and their derivatives stay under the OFL. A later change to a file (subsetting, renaming, format conversion) would make it a Modified Version and could breach the Reserved Font Name condition.
  • Compensating controls: The files are taken unmodified from the projects' official release archives; each licence was confirmed from the archive's own licence file, which is shipped byte for byte beside the fonts with a source note (release, archive URL and hash, path, font version); /fonts/SHA256SUMS lists every hash; a test (FaceTest) fails the build if a licence, a source note or a hash is missing or any byte differs; .gitattributes forbids git from rewriting the files; renderer/THIRD-PARTY.md and renderer/NOTICE carry the attribution. The scene names (qqq-sans and so on) name the renderer's faces and are not font names.
  • Expires: Reviewed yearly, next on 2027-11-12, or on any change to a covered file, its version or its licence, whichever occurs first.
  • Owner: the maintainer of QRun-IO/qbit-document-generation.
  • Review date: 2026-11-12.
  • Evidence: the release archives named in renderer/THIRD-PARTY.md and their licence files; renderer/src/main/resources/fonts/*/SOURCE.md; this pull request.

The design's decision 1 proposed that one exception also cover the JetBrains Mono Bold that qbit-printing already ships. This entry is exact for what this pull request verified; that repository's file and hash were not read here and would be added to the scope by its maintainer.

How text is measured and drawn

  • Integer arithmetic. The faces have 1000 units to the em. Advances and outline boxes are read once at a reference size and scaled as units x millipoints x dpi / 72 000 000, so a measurement is the same on every machine and never depends on the font engine at the drawing size.
  • Whole-dot pens. Each character sits at the nearest dot to its exact pen position and is drawn there by the Java rasteriser that version 1 raster text uses, antialiased, then reduced to ink at threshold.
  • A line's box is its pen advance widened to the ink of every character in it, plus two dots on each inked side: the rasteriser fits an outline to the dot grid within 1.4 dots of its exact scale (measured over every character of the five faces). A text whose glyph bitmap or ink would leave the measured box is not clipped, not printed outside it and not printed in part: the one element is refused (TEXT_DOES_NOT_FIT with glyphsOutsideMeasure and dotsOutsideMeasure).
  • One glyph for each character, left to right. No kerning, ligatures, mark placement or bidirectional reordering, as in version 1 raster text.

Determinism across platforms

Same bytes everywhere tested, with no tolerance:

Runtime Result
Linux, Temurin 21.0.9 (maven:3.9.11-eclipse-temurin-21, the CI image) the 18 text golden files (ZPL and PNG at 203, 300 and 600 dpi) were generated here
Windows 11, Temurin 21.0.12 byte-identical ZPL and PNG for all 18
Linux, Temurin 21.0.13 (eclipse-temurin:21-jdk; the version the worker requires) the golden test passes exactly

Before writing the code, a probe hashed glyph metrics, glyph bitmaps and filled outlines for the five faces at ten sizes and three resolutions on Windows and in the CI image: all identical, for the bitmap path (up to 100 dots to the em) and for the outline path above it.

One case depends on more than the scene, stated plainly: a line whose strip is over 8 million dots (type of several hundred points) is drawn in bands of whole rows so that it never needs more grey than that, and the rasteriser may shade a few edge dots at a band's border differently than it would in one piece (4 of 121 thousand ink dots at 203 dpi, 40 of 1.06 million at 600 dpi, for 200 pt type forced into small bands). Where the bands fall is a function of the line's box alone, so the result is still the same on every machine; up to 100 dots to the em banding changes nothing at all, which a test holds row by row.

Tests

  • Goldens at 203, 300 and 600 dpi: every bundled face (text-faces), the fit rules (text-fit), rotation, reverse and a resolution override (text-turned). Exact comparison of ZPL and picture.
  • Measurement equals what is drawn: every character of every face is drawn at seven size and resolution pairs and its ink read back inside its measured line; every character's bitmap box is held to the measured box at thirty more.
  • Property tests of fit (seeded, no clock, 3 seeds x 600 cases): the answer is a layout inside the box or a refusal whose measurement exceeds its limit; a text that fits is never shrunk; the size chosen is the largest that fits and never below the floor; never more lines than allowed; cut only when asked, always with the mark, always fitting; a shorter text never gets a smaller size than a longer one that starts with it.
  • maxLength: a proven length is never refused for any text of the face's characters up to that length (the widest characters, the furthest-reaching ones, random ones, with and without spaces, cut or not); the proof agrees with rendering of the widest character to within one character a line.
  • Ink is never outside the box and never clipped, at every rotation, for 160 generated cases; alignment and centring within one dot.
  • Licence, source note and hash of every bundled face; the face-wide bounds the proof relies on are recomputed from every character of the font files.
  • Mutation campaign extended to text: three text scenes in the corpus, text tokens spliced in, an independent reading of every accepted text element at each resolution. Only a Refusal counts as a refusal.
  • Version 1 corpus unchanged and green.

Build

The CI command, ./mvnw --batch-mode --no-transfer-progress -s <settings> -T1C verify, on head 589b6d4 (the head after the independent review), with no gate skipped:

Where Result
Linux, Temurin 21.0.9, inside maven:3.9.11-eclipse-temurin-21 (the CI image), on a git archive of the head BUILD SUCCESS, all 11 modules, 925 tests, 0 failures, 0 errors, 0 skipped
Windows 11, Temurin 21.0.12, in the worktree, from a clean tree BUILD SUCCESS, all 11 modules, the same 925 tests, 0 failures, 0 errors, 0 skipped

Renderer module: 360 tests. Coverage of the scene 2 packages, the same in both builds: scene2 632 of 632 lines, scene2.text 465 of 465 lines, the placeholders 8 of 8; the build's 100% line gate is met with nothing excluded. Branches of scene2.text: 360 of 371. Of the eleven not taken, six are sides of the checks in TextRaster that count ink outside the measure (a test forces them with lines whose boxes are stated too small, on some sides and not all), and five are in TextElement.paint, which reuses what check composed only for the same box, request and collector and would otherwise compose again: a case the scene walk never produces.

The first head of this pull request, f5acf93, was built the same way on both (916 tests). The branch was rebased onto develop after #15 merged. develop has since gained #17; this branch was not rebased again, because it merges into the new develop without conflict, and on that merged tree the bridge's tests and the scene 2 schema, golden and text element tests pass (353 tests).

The independent review that section 10.8 of the design asks of every package (by an agent that did not write it) has been done. Its verdict was "fix first"; what changed is in the last section.

Where the design and the code differ

  • The design's example scene does not validate. Its caption (two lines, floor 7 pt, maxLength 48) is in a 6.5 mm box; two lines at 7 pt need 59 dots at 203 dpi (53 from rounding the ascent, line pitch and descent up to whole dots, 6 more for an accent above a capital and a deep descender) and the box has 51. With a 7.5 mm box it is proven at all three resolutions. The proof is sound for any value, so it is stricter than a typical value needs: a box of exactly lines x line height is not proven.
  • A face is named by font alone. The merged schema has no weight or style property; the name carries family and weight and the set has no italic.
  • Wrapping was not specified beyond "wrap to at most this many lines". Words first, then any character at the same size; this is what lets an unspaced value wrap and what makes a length provable.
  • RESERVED_FONT_NAME is raised by a text element (with the count), not for a scene as a whole.
  • The members of fit are declared in the schema (changed after the independent review). maxLines, shrinkTo and overflow are under properties of fit with additionalProperties: false; the three names are in ElementFactory.VOCABULARY and in the bridge's SceneFacts.VOCABULARY, which a bridge test holds equal to the schema; and a wrong member is reported at the member (/elements/1/fit/maxLines). The first head described them in the schema's prose and reported everything at /fit.
  • Section 10.2 expected text goldens to need a tolerance on developer machines. They do not.
  • The proof is for the render target of the request. A host that prints one revision at several resolutions should validate at each; that belongs to the bridge and foundation packages.
  • Core seams (two small additions to P1's ElementSource, used by the parser): inherit, which gives an element the scene's defaults for the properties its type reads, and refuse, a coded refusal at a property. defaults was parsed by the core but could not reach an element.
  • The design is updated in section 3.3 ("Decided while building text").

Deliberately left out

  • Uploaded and supplied fonts (P8). Only bundled faces are drawn; a supplied font/ttf asset named by a version 2 text is FONT_UNKNOWN. The seam is in place: RenderInput.reservedAssetNames() and the RESERVED_FONT_NAME refusal.
  • Shaping: kerning, ligatures, combining marks, right-to-left and complex scripts, characters outside the Basic Multilingual Plane. They are refused by count, never drawn wrongly.
  • Explicit line breaks in a value (a line feed is a character the face cannot draw).
  • Warm-up: the worker's warm-up fixture does not draw version 2 text, so the first text of a request loads its face (the first sample label took 240 ms in a cold JVM, later ones 5 to 30 ms). WarmUp is outside this package's files.
  • Page (PDF) CSS families for the same faces (P11), the GS1 human-readable line (P3b), starters and the specimen (P12).
  • The physical print check of section 10.6, which confirms the legibility thresholds on real printers.
  • No container image was built or pushed.

Changes after the independent review

The independent review that section 10.8 of the design asks for was done by an agent that did not write this package. Its verdict was fix first. Every finding and note is closed in one commit, 589b6d4, on top of f5acf93. No golden changed.

1. The maxLength proof never cuts

With overflow: "truncate" the proof was handed a cut block, which counts as fitting, so it proved nothing: a bound element with maxLength: 40 in a box that holds ten worst-case characters validated clean, and every long value was cut at print.

  • The proof now runs with cutting off, always.
  • When it fails and the element did not ask for cutting, the scene is refused as before (TEXT_DOES_NOT_FIT).
  • When it fails and the element did ask for cutting, the scene still validates, because cutting is what its author chose, but every validation carries a standing warning: FIT_NOT_PROVEN with measured.provenCharacters (the longest length proven to fit whole) against limit.characters (the maxLength stated). Fewer characters never fit worse, so that length is found by halving.
  • No new code. FIT_NOT_PROVEN already means "the fit of this bound value is not proven"; it now carries numbers when a length was stated. Foundation's closed FailureCode and the host's lists are unchanged.
  • Tests (TextElementTest.theProofOfALengthNeverCutsAndACuttingElementIsWarnedHowMuchIsProven): the refusal without cutting; the warning with cutting, with the number (7 of 40 in the test's box); that the number is the truth (that length validates clean with and without cutting, one more does not); that a value of the proven length prints whole and a longer one is cut; more lines prove more; a box that holds no character proves 0.

2. The shrink search is bounded

Stepping 0.25 pt from 1000 pt to a 1 pt floor is four thousand sizes, each laid out twice, and every element was composed twice (check, then paint).

Before After
One fit of 1024 characters at 600 dpi, 1000 pt to 1 pt (36 boxes and texts) 180 to 250 ms; at least 15.6 million characters visited 0.13 to 0.2 ms on average in the builds, under 1 ms cold; at most 21 555 characters visited
A scene of 1000 such elements, validated minutes (killed by the worker's 15 s of processor, no refusal named) 200 ms in the CI image, 281 ms on Windows; every element refused by name as TEXT_DOES_NOT_FIT, none for the work
Fits per element per render 2 1
  • Refused at once when one line at the floor size is higher than the box, whatever the text: nothing is searched. A cut tries no line count the box is too low for.
  • Halving, then a short linear check. The size chosen is defined as the one a search of every quarter-point step gives, and is exactly that. A count that errs only toward yes says whether a size could hold the text: lines filled at any character, each held to a width no real line can be under (its advance, or the exact span of its ink plus the rasteriser's allowance less one dot of rounding), and a block held to its lines' ascent, pitches and descent. It never refuses a size that fits, and once it allows a size it allows every smaller one, so the largest size it allows is found by halving and no size above that fits. Only the steps from there down are laid out, as before.
  • Equivalence is tested, not assumed. TextFitTest.theAnswerIsExactlyThatOfTheSearchOfEverySize holds fit to the old linear search, block for block (size, lines, every number; fitted, refused or cut), over 3600 generated cases: texts with and without spaces, the widest and furthest-reaching characters, the proof's worst character, boxes from one dot to a large label, sizes from 1 pt to 1000 pt, any floor, 1 to 64 lines, any line height. The linear search in the test shares only the layout at one size with fit. The same comparison was soaked over 4.3 million further cases with other seeds, weighted to type of 1 to 12 pt where rounding decides: no difference.
  • The layout is cached between check and paint (TextElement.check keeps what it composed for the paint of the same pass).
  • A work budget for the text of one scene, with a named refusal. A character visited while fitting is one step, a character to be drawn 16, and a glyph the scene draws for the first time at a size 4096 (the runtime rasterises it once: 20 to 50 microseconds measured, against 0.25 after). One scene may take 64 million steps, about a second of a worker. The element that passes it, and every text after it, is SCENE_INVALID at the element with measured.textWorkSteps against limit.textWorkSteps, the same shape as the painted-area limit. The count is integer arithmetic, so the refusal is the same on every machine, and it is the same whether the scene is validated or rendered. No scene the parser accepts can exceed it.
  • Tests: TextFitTest.theWorstCaseOfTheShrinkSearchIsHeldToAStatedWork (the reviewer's case: at most 40 times the text visited, where the linear search visits over 10 000 times it; same answer), aBoxLowerThanOneLineAtTheFloorIsRefusedWithoutASearch, fittingEndsWhenItsAllowanceIsPassed; TextElementTest.aThousandOfTheWorstTextsAreRefusedByNameInsideTheWorkOfOneScene (1000 elements, validated and rendered, each refused as TEXT_DOES_NOT_FIT, none for the work, inside 15 s) and aSceneWhoseTextPassesItsWorkIsRefusedByName (the refusal, its numbers, the same validated and rendered, every later text refused too).

3. Notes closed

  • Widest advance over every drawable character. FaceTest took the widest character over inked characters only; a no-break space or an em quad has no ink and still moves the pen. The greatest advance is now taken over every character a face draws, and the widest inked character must have it, so a font update cannot break the proof unseen.
  • Ink outside the measure. The bound is now tested at 1, 2, 300 and 1000 pt as well as 5 to 72 pt, for every character of every face at 203, 300 and 600 dpi (glyph bounds), with every character drawn at 1 and 2 pt and the furthest-reaching ones at 300 and 1000 pt. Nothing strays, on Windows or in the CI image: the furthest any glyph reaches past its exact outline is one dot of the two allowed, at every one of those sizes. TEXT_INK_OUTSIDE_MEASURE is no longer an IllegalStateException that fails the whole render with part of the text already on the label: the dots of a text are gathered first and reach the label only when none strayed; otherwise the one element is refused as TEXT_DOES_NOT_FIT with measured.glyphsOutsideMeasure and measured.dotsOutsideMeasure, and the label holds none of it. A literal is held to the glyph bounds when the scene is validated; a bound value can only be checked when it is known, at render.
  • Legibility at the proven size. A bound element with maxLength is judged, when validated, at the size the proof shrank to (the floor when the proof fails and the element cuts), which is the smallest any value prints at. Without maxLength it is still judged at its stated size, since nothing says how far it will shrink.
  • fit is declared in the schema. maxLines, shrinkTo and overflow are under properties with additionalProperties: false. The three names are in ElementFactory.VOCABULARY and in the bridge's SceneFacts.VOCABULARY in the same change (SceneFactsTest.theVocabularyIsThePropertyNamesOfTheSchema holds them equal: 46 names). A wrong member is reported at the member: /elements/0/fit/maxLines, or /overrides/0/set/<id>/fit/maxLines when an override states fit. A member fit does not have is still reported at /fit, because its name is not one the schema declares.
  • Bound numbers print as the schema says. A number is printed in plain decimal digits: no exponent, no fraction that is only zeros. 1.0E10 prints 10000000000, 2.50 prints 2.5, 12.0 prints 12, 1E-7 prints 0.0000001; an integer prints its digits however long. A number that is not finite is VALUE_MISSING. The schema's description now says exactly this.
  • FreeType and JDK of the goldens are recorded in renderer/src/test/resources/scene2/README.md, beside the goldens (the golden directory itself is held by a test to exactly the case files). Java draws glyphs with the FreeType the JDK itself ships, not the system's: 2.13.3 in Temurin 21.0.9+10 (the CI image, where the goldens were generated), 2.14.3 in Temurin 21.0.12.1+1 (Windows) and in Temurin 21.0.13+9 (the version the worker image requires). The same bytes on all three.

Decisions made here, for the maintainers

  • No diagnostic code was added. The three new refusals and the new warning use codes that exist, told apart by their named numbers. A dedicated code for the work limit or for ink outside the measure would mean a change to foundation's closed FailureCode, the schema and both vocabularies; say so if you want them.
  • Where the scene's work is counted. A text element is given nothing that belongs to its scene except the collector of the render or validation, so the count is kept in the text package against that collector and forgotten with it. A counter on the canvas beside the painted-area count would be the plainer home; it was not added because other packages are changing the core at the same time.
  • Two small changes outside the text package, both needed for the pointer at a member: ElementSource resolves fit/maxLines where fit is stated (element, defaults or override), and ElementFactory.VOCABULARY gains the three names.
  • The limit on glyphs is new behaviour. A scene that draws more than about fifteen thousand different glyph-and-size pairs is now refused where it was slow before. A label of a hundred values in a handful of sizes uses a few percent of the allowance.

Goldens

None changed and none was regenerated. The 18 text files and the 24 layout files are byte-identical to f5acf93; SceneGoldenTest passes on them in both builds above, and the 21 labels were rendered again on Temurin 21.0.13+9 with the same bytes.

Build

The results under "Build" above are for this head, 589b6d4, on both platforms. The version 1 corpus is untouched and green.

🤖 Generated with Claude Code

Package **P2** of `docs/design/LABEL-LAYOUT-FONTS-PREVIEW.md` (work plan B): text for version 2 label scenes. Bundled fonts, measurement before drawing, and a stated rule for a value that does not fit. Builds on the scene 2 core (#14); version 1 is untouched. ## What a label author gets - **Real fonts.** Five faces ship inside the renderer, so a label needs no printer font, no installed font and no object storage: `qqq-sans`, `qqq-sans-bold`, `qqq-condensed-bold`, `qqq-mono`, `qqq-mono-bold`. - **Sizes in points or millimetres.** `"size": "9pt"` or `"3mm"`, resolved at the render target (203, 300 or 600 dpi) and not rounded to dots. - **A text is measured before it is drawn** and placed in its box by `align` and `valign`. Ink never leaves the box and is never clipped. - **A stated rule when a value does not fit**, chosen per element (table below), with a diagnostic that names the element, what was measured and the limit. - **`maxLength`** proves the fit when the document is published, so a length refusal is a design-time event and not a failed print. - **Thermal legibility controls**: `threshold` (heavier strokes), bold faces, warnings for small type, and resolution overrides for either. - **Rotation** (0, 90, 180, 270) and **reverse** text (white on a black box). ## Fit rules as implemented | Step | Rule | Reported as | | --- | --- | --- | | 1 | Lay the value out at `size` within `fit.maxLines` (default 1). If it fits, it is never shrunk. | nothing | | 2 | Otherwise take the **largest** size, in steps of 0.25 pt down to `fit.shrinkTo`, at which it fits. `shrinkTo` defaults to 70% of `size` and not below 5 pt (the size itself when the size is under 5 pt). | `TEXT_SHRUNK` (info): chosen size, nominal size, floor | | 3 | If nothing fits down to the floor, apply `fit.overflow`: `refuse` (the default) or `truncate`. | `TEXT_DOES_NOT_FIT` (error): measured width, height, lines and size against the box | | Wrap | Only with `maxLines` above 1. At each size a line is broken between words while the text then fits, and at any character when it does not. So a value with no space wraps, and a larger size is preferred to whole words. Never more than `maxLines` lines. | `TEXT_WRAPPED` (info): lines | | Truncate | Only with `overflow: "truncate"`, only at the floor size: as many lines as the box holds, the last ending with an ellipsis after as many of its characters as fit with it. The mark is always shown and the result always fits; where not even the mark fits, the text is refused. | `TEXT_TRUNCATED` (warning): characters, characters shown | | Length | `maxLength` (1 to 1024). **Validation** lays out that many of the face's widest character, each taken to reach as far in every direction as the furthest-reaching character of the face, and refuses the scene if they do not fit. **Printing** refuses a longer value before any layout. The proof never cuts: with `overflow: "truncate"` a length that is not proven does not refuse the scene but warns at every validation, with the length that is proven. | `TEXT_DOES_NOT_FIT` at validation, `VALUE_TOO_LONG` at print, `FIT_NOT_PROVEN` (warning) for a bound element with no `maxLength`, and with `provenCharacters` for a cutting element whose `maxLength` is not proven | Other refusals: `VALUE_MISSING` (the pointer finds no text, number or true/false), `FONT_MISSING_GLYPH` (a character the face cannot draw as one glyph, with the count), `FONT_UNKNOWN` (a face that is not bundled, at the place that names it), `RESERVED_FONT_NAME` (a supplied asset whose name begins `qqq-`). Warnings: `TEXT_SMALL_FOR_DPI` (capitals under 12 dots), `TEXT_LIGHT_FACE` (a regular weight with capitals under 20 dots). A diagnostic holds ids and numbers only; no value ever enters a diagnostic, a log line or an exception message, and a test puts a marker through every refusal to hold that. ## A rendered sample `renderer/src/test/resources/scene2/cases/text-fit.json` on 2 x 1 in stock with 2 mm margins, at 203 dpi (golden: `scene2/golden/text-fit-203.png`; the 300 and 600 dpi goldens are beside it). Thin outlines show each element's box. | Element | Scene | What was drawn | Fit applied | | --- | --- | --- | --- | | `kept` | `LOT 240611`, `qqq-sans-bold` 10 pt, 22 x 5 mm, centred | 10 pt, one line, centred within one dot | none | | `right` | `9.5 kg`, `qqq-condensed-bold` 11 pt, 22 x 5 mm, right and bottom | 10.25 pt | `TEXT_SHRUNK`: 11 pt is 41 dots high and the box is 39 | | `shrunk` | `(01)00012345678905(10)L240611A`, `qqq-mono-bold` 9 pt, 44 x 4 mm | 6.75 pt, one line | `TEXT_SHRUNK` from 9 pt (floor 6.3 pt) | | `wrapped` | 46 characters with no space, `qqq-mono` 7 pt, 36 x 7.5 mm, `maxLines: 2`, `maxLength: 46` | 7 pt, two lines of 24 and 22 characters, each centred | `TEXT_WRAPPED`; also `TEXT_LIGHT_FACE` (capitals 14 dots, regular weight) | | `cut` | a 42-character description, `qqq-sans` 7 pt, 30 x 3.6 mm, `overflow: "truncate"` | `A description of the item…` | `TEXT_TRUNCATED`: 25 of 42 characters shown | The diagnostic of a refusal, here the design's own example caption (two lines, floor 7 pt, `maxLength` 48, 94 x 6.5 mm) when validated at 203 dpi: ```json { "code": "TEXT_DOES_NOT_FIT", "severity": "ERROR", "element": "caption", "box": [6, 100, 751, 51], "measured": { "characters": 48, "heightDots": 59, "lines": 2, "sizeCentipoints": 700, "widthDots": 751 }, "limit": { "heightDots": 51, "lines": 2, "widthDots": 751 } } ``` ## Bundled fonts | Scene name | Family and style | File (under `/fonts` in the jar) | Version | Source | SHA-256 | Licence | | --- | --- | --- | --- | --- | --- | --- | | `qqq-sans` | IBM Plex Sans Regular | `ibm-plex-sans/IBMPlexSans-Regular.ttf` | release `@ibm/plex-sans@1.1.0`, font 3.005 | https://github.com/IBM/plex/releases/download/%40ibm/plex-sans%401.1.0/ibm-plex-sans.zip | `975dcda37d80f038dcd143c22e33ca2d97a0cc5a929aace1c749153b0fe1afa5` | SIL OFL 1.1 | | `qqq-sans-bold` | IBM Plex Sans Bold | `ibm-plex-sans/IBMPlexSans-Bold.ttf` | the same release, font 3.005 | the same archive | `9e6c74a889a700d707613d24548fe4ffa6bc59559a0689d2cf9e133bdcdafb2f` | SIL OFL 1.1 | | `qqq-condensed-bold` | IBM Plex Sans Condensed Bold | `ibm-plex-sans-condensed/IBMPlexSansCondensed-Bold.ttf` | release `@ibm/plex-sans-condensed@2.0.0`, font 3.000 | https://github.com/IBM/plex/releases/download/%40ibm/plex-sans-condensed%402.0.0/ibm-plex-sans-condensed.zip | `723e245902b45a49f8cf602d15891bae08c31309dce7c56d209d10cd471b88c7` | SIL OFL 1.1 | | `qqq-mono` | JetBrains Mono Regular | `jetbrains-mono/JetBrainsMono-Regular.ttf` | release `v2.304`, font 2.304 | https://github.com/JetBrains/JetBrainsMono/releases/download/v2.304/JetBrainsMono-2.304.zip | `a0bf60ef0f83c5ed4d7a75d45838548b1f6873372dfac88f71804491898d138f` | SIL OFL 1.1 | | `qqq-mono-bold` | JetBrains Mono Bold | `jetbrains-mono/JetBrainsMono-Bold.ttf` | the same release, font 2.304 | the same archive | `5590990c82e097397517f275f430af4546e1c45cff408bde4255dad142479dcb` | SIL OFL 1.1 | Archive SHA-256: `ibm-plex-sans.zip` `fb365d910566e6d199cc2c15579a7dd9a267128e18431a394ed81f1970c69200`, `ibm-plex-sans-condensed.zip` `f86ba73abbeed7cf7690ead863192a87211dfc2b0cef0ac6bf4700533ec5ab9e`, `JetBrainsMono-2.304.zip` `6f6376c6ed2960ea8a963cd7387ec9d76e3f629125bc33d1fdcd7eb7012f7bbf`. **How each licence was confirmed.** From the release archive itself, not from memory. Each IBM Plex archive's `LICENSE.txt` and the JetBrains Mono archive's `OFL.txt` state "This Font Software is licensed under the SIL Open Font License, Version 1.1" and carry the full text. The licence field of every font's name table says the same, and every font's embedding bits (`OS/2.fsType`) are 0, installable embedding. The licence files ship byte for byte beside the fonts (`.gitattributes` keeps git from rewriting anything under `/fonts`), each directory has a `SOURCE.md` with the release, archive, path and hashes, and `/fonts/SHA256SUMS` lists the hashes. `FaceTest` fails the build when a licence, a source note or a hash is missing or a byte changes. The files are unmodified and keep their names; IBM Plex's Reserved Font Name "Plex" is not used for anything derived. The notes are named `SOURCE.md` because this repository's `.gitignore` ignores `PROVENANCE.md`. The fonts add **531,819 bytes** to the renderer jar (1,172,935 uncompressed), which is now 736 KB. ### Dependency-licence exception (K22-DEP-02), for the maintainer to file The SIL OFL 1.1 is not on the K22-DEP-02 allowlist. The exception register is `exceptions/register.md` in the standards repository, which this pull request does not edit. Proposed entry, with the next free number: > ## EX-00NN > > **Unmodified SIL Open Font License 1.1 fonts shipped as data in the document renderer** > > - **Status:** Open upon merge of this record. The maintainer approved the exception and named the set on 2026-11-12. > - **Requirements:** `K22-DEP-02`. The SIL Open Font License 1.1 (SPDX `OFL-1.1`) is not on the allowlist of licences for dependencies that ship with a product. No vulnerability, provenance, signing or branch-protection requirement is waived. > - **Scope:** `QRun-IO/qbit-document-generation`, module `renderer`, exactly these unmodified font files shipped as data under `/fonts` of the renderer JAR, with these SHA-256: IBM Plex Sans Regular `975dcda37d80f038dcd143c22e33ca2d97a0cc5a929aace1c749153b0fe1afa5` and Bold `9e6c74a889a700d707613d24548fe4ffa6bc59559a0689d2cf9e133bdcdafb2f` (release `@ibm/plex-sans@1.1.0`); IBM Plex Sans Condensed Bold `723e245902b45a49f8cf602d15891bae08c31309dce7c56d209d10cd471b88c7` (release `@ibm/plex-sans-condensed@2.0.0`); JetBrains Mono Regular `a0bf60ef0f83c5ed4d7a75d45838548b1f6873372dfac88f71804491898d138f` and Bold `5590990c82e097397517f275f430af4546e1c45cff408bde4255dad142479dcb` (release `v2.304`). No other font, version, modified or renamed font, or other licence is covered. A font is data read by the renderer; it is not linked code. > - **Reason:** Version 2 label text is rasterised by the renderer so that it can be measured, fitted and previewed truthfully, which a printer's built-in font cannot be. That needs real bold, condensed and monospace faces with clear `0/O` and `1/l/I` that work with no object storage and no installed font. The OFL expressly permits bundling and redistribution with software. The alternative, restricting the set to fonts under Apache-2.0, gives up the faces chosen for legibility on thermal print. > - **Risk:** The OFL's conditions apply to every distribution of the renderer: the copyright and licence notices must accompany the fonts, the fonts may not be sold by themselves, a modified version may not use a Reserved Font Name ("Plex"), and the fonts and their derivatives stay under the OFL. A later change to a file (subsetting, renaming, format conversion) would make it a Modified Version and could breach the Reserved Font Name condition. > - **Compensating controls:** The files are taken unmodified from the projects' official release archives; each licence was confirmed from the archive's own licence file, which is shipped byte for byte beside the fonts with a source note (release, archive URL and hash, path, font version); `/fonts/SHA256SUMS` lists every hash; a test (`FaceTest`) fails the build if a licence, a source note or a hash is missing or any byte differs; `.gitattributes` forbids git from rewriting the files; `renderer/THIRD-PARTY.md` and `renderer/NOTICE` carry the attribution. The scene names (`qqq-sans` and so on) name the renderer's faces and are not font names. > - **Expires:** Reviewed yearly, next on 2027-11-12, or on any change to a covered file, its version or its licence, whichever occurs first. > - **Owner:** the maintainer of `QRun-IO/qbit-document-generation`. > - **Review date:** 2026-11-12. > - **Evidence:** the release archives named in `renderer/THIRD-PARTY.md` and their licence files; `renderer/src/main/resources/fonts/*/SOURCE.md`; this pull request. The design's decision 1 proposed that one exception also cover the JetBrains Mono Bold that `qbit-printing` already ships. This entry is exact for what this pull request verified; that repository's file and hash were not read here and would be added to the scope by its maintainer. ## How text is measured and drawn - **Integer arithmetic.** The faces have 1000 units to the em. Advances and outline boxes are read once at a reference size and scaled as `units x millipoints x dpi / 72 000 000`, so a measurement is the same on every machine and never depends on the font engine at the drawing size. - **Whole-dot pens.** Each character sits at the nearest dot to its exact pen position and is drawn there by the Java rasteriser that version 1 raster text uses, antialiased, then reduced to ink at `threshold`. - **A line's box** is its pen advance widened to the ink of every character in it, plus two dots on each inked side: the rasteriser fits an outline to the dot grid within 1.4 dots of its exact scale (measured over every character of the five faces). A text whose glyph bitmap or ink would leave the measured box is not clipped, not printed outside it and not printed in part: the one element is refused (`TEXT_DOES_NOT_FIT` with `glyphsOutsideMeasure` and `dotsOutsideMeasure`). - **One glyph for each character, left to right.** No kerning, ligatures, mark placement or bidirectional reordering, as in version 1 raster text. ## Determinism across platforms Same bytes everywhere tested, with no tolerance: | Runtime | Result | | --- | --- | | Linux, Temurin 21.0.9 (`maven:3.9.11-eclipse-temurin-21`, the CI image) | the 18 text golden files (ZPL and PNG at 203, 300 and 600 dpi) were generated here | | Windows 11, Temurin 21.0.12 | byte-identical ZPL and PNG for all 18 | | Linux, Temurin 21.0.13 (`eclipse-temurin:21-jdk`; the version the worker requires) | the golden test passes exactly | Before writing the code, a probe hashed glyph metrics, glyph bitmaps and filled outlines for the five faces at ten sizes and three resolutions on Windows and in the CI image: all identical, for the bitmap path (up to 100 dots to the em) and for the outline path above it. One case depends on more than the scene, stated plainly: a line whose strip is over 8 million dots (type of several hundred points) is drawn in bands of whole rows so that it never needs more grey than that, and the rasteriser may shade a few edge dots at a band's border differently than it would in one piece (4 of 121 thousand ink dots at 203 dpi, 40 of 1.06 million at 600 dpi, for 200 pt type forced into small bands). Where the bands fall is a function of the line's box alone, so the result is still the same on every machine; up to 100 dots to the em banding changes nothing at all, which a test holds row by row. ## Tests - **Goldens** at 203, 300 and 600 dpi: every bundled face (`text-faces`), the fit rules (`text-fit`), rotation, reverse and a resolution override (`text-turned`). Exact comparison of ZPL and picture. - **Measurement equals what is drawn**: every character of every face is drawn at seven size and resolution pairs and its ink read back inside its measured line; every character's bitmap box is held to the measured box at thirty more. - **Property tests of fit** (seeded, no clock, 3 seeds x 600 cases): the answer is a layout inside the box or a refusal whose measurement exceeds its limit; a text that fits is never shrunk; the size chosen is the largest that fits and never below the floor; never more lines than allowed; cut only when asked, always with the mark, always fitting; a shorter text never gets a smaller size than a longer one that starts with it. - **`maxLength`**: a proven length is never refused for any text of the face's characters up to that length (the widest characters, the furthest-reaching ones, random ones, with and without spaces, cut or not); the proof agrees with rendering of the widest character to within one character a line. - **Ink is never outside the box and never clipped**, at every rotation, for 160 generated cases; alignment and centring within one dot. - **Licence, source note and hash** of every bundled face; the face-wide bounds the proof relies on are recomputed from every character of the font files. - **Mutation campaign** extended to text: three text scenes in the corpus, text tokens spliced in, an independent reading of every accepted text element at each resolution. Only a `Refusal` counts as a refusal. - **Version 1 corpus** unchanged and green. ## Build The CI command, `./mvnw --batch-mode --no-transfer-progress -s <settings> -T1C verify`, on head `589b6d4` (the head after the independent review), with no gate skipped: | Where | Result | | --- | --- | | Linux, Temurin 21.0.9, inside `maven:3.9.11-eclipse-temurin-21` (the CI image), on a `git archive` of the head | BUILD SUCCESS, all 11 modules, 925 tests, 0 failures, 0 errors, 0 skipped | | Windows 11, Temurin 21.0.12, in the worktree, from a clean tree | BUILD SUCCESS, all 11 modules, the same 925 tests, 0 failures, 0 errors, 0 skipped | Renderer module: 360 tests. Coverage of the scene 2 packages, the same in both builds: `scene2` 632 of 632 lines, `scene2.text` 465 of 465 lines, the placeholders 8 of 8; the build's 100% line gate is met with nothing excluded. Branches of `scene2.text`: 360 of 371. Of the eleven not taken, six are sides of the checks in `TextRaster` that count ink outside the measure (a test forces them with lines whose boxes are stated too small, on some sides and not all), and five are in `TextElement.paint`, which reuses what `check` composed only for the same box, request and collector and would otherwise compose again: a case the scene walk never produces. The first head of this pull request, `f5acf93`, was built the same way on both (916 tests). The branch was rebased onto `develop` after #15 merged. `develop` has since gained #17; this branch was not rebased again, because it merges into the new `develop` without conflict, and on that merged tree the bridge's tests and the scene 2 schema, golden and text element tests pass (353 tests). The independent review that section 10.8 of the design asks of every package (by an agent that did not write it) has been done. Its verdict was "fix first"; what changed is in the last section. ## Where the design and the code differ - **The design's example scene does not validate.** Its caption (two lines, floor 7 pt, `maxLength` 48) is in a 6.5 mm box; two lines at 7 pt need 59 dots at 203 dpi (53 from rounding the ascent, line pitch and descent up to whole dots, 6 more for an accent above a capital and a deep descender) and the box has 51. With a 7.5 mm box it is proven at all three resolutions. The proof is sound for any value, so it is stricter than a typical value needs: a box of exactly `lines x line height` is not proven. - **A face is named by `font` alone.** The merged schema has no weight or style property; the name carries family and weight and the set has no italic. - **Wrapping** was not specified beyond "wrap to at most this many lines". Words first, then any character at the same size; this is what lets an unspaced value wrap and what makes a length provable. - **`RESERVED_FONT_NAME`** is raised by a text element (with the count), not for a scene as a whole. - **The members of `fit` are declared in the schema** (changed after the independent review). `maxLines`, `shrinkTo` and `overflow` are under `properties` of `fit` with `additionalProperties: false`; the three names are in `ElementFactory.VOCABULARY` and in the bridge's `SceneFacts.VOCABULARY`, which a bridge test holds equal to the schema; and a wrong member is reported at the member (`/elements/1/fit/maxLines`). The first head described them in the schema's prose and reported everything at `/fit`. - **Section 10.2** expected text goldens to need a tolerance on developer machines. They do not. - **The proof is for the render target of the request.** A host that prints one revision at several resolutions should validate at each; that belongs to the bridge and foundation packages. - **Core seams** (two small additions to P1's `ElementSource`, used by the parser): `inherit`, which gives an element the scene's `defaults` for the properties its type reads, and `refuse`, a coded refusal at a property. `defaults` was parsed by the core but could not reach an element. - The design is updated in section 3.3 ("Decided while building text"). ## Deliberately left out - **Uploaded and supplied fonts** (P8). Only bundled faces are drawn; a supplied `font/ttf` asset named by a version 2 text is `FONT_UNKNOWN`. The seam is in place: `RenderInput.reservedAssetNames()` and the `RESERVED_FONT_NAME` refusal. - **Shaping**: kerning, ligatures, combining marks, right-to-left and complex scripts, characters outside the Basic Multilingual Plane. They are refused by count, never drawn wrongly. - **Explicit line breaks** in a value (a line feed is a character the face cannot draw). - **Warm-up**: the worker's warm-up fixture does not draw version 2 text, so the first text of a request loads its face (the first sample label took 240 ms in a cold JVM, later ones 5 to 30 ms). `WarmUp` is outside this package's files. - **Page (PDF) CSS families** for the same faces (P11), the GS1 human-readable line (P3b), starters and the specimen (P12). - **The physical print check** of section 10.6, which confirms the legibility thresholds on real printers. - No container image was built or pushed. ## Changes after the independent review The independent review that section 10.8 of the design asks for was done by an agent that did not write this package. Its verdict was **fix first**. Every finding and note is closed in one commit, `589b6d4`, on top of `f5acf93`. No golden changed. ### 1. The `maxLength` proof never cuts With `overflow: "truncate"` the proof was handed a cut block, which counts as fitting, so it proved nothing: a bound element with `maxLength: 40` in a box that holds ten worst-case characters validated clean, and every long value was cut at print. - The proof now runs with cutting off, always. - When it fails and the element did **not** ask for cutting, the scene is refused as before (`TEXT_DOES_NOT_FIT`). - When it fails and the element **did** ask for cutting, the scene still validates, because cutting is what its author chose, but every validation carries a standing warning: `FIT_NOT_PROVEN` with `measured.provenCharacters` (the longest length proven to fit whole) against `limit.characters` (the `maxLength` stated). Fewer characters never fit worse, so that length is found by halving. - **No new code.** `FIT_NOT_PROVEN` already means "the fit of this bound value is not proven"; it now carries numbers when a length was stated. Foundation's closed `FailureCode` and the host's lists are unchanged. - Tests (`TextElementTest.theProofOfALengthNeverCutsAndACuttingElementIsWarnedHowMuchIsProven`): the refusal without cutting; the warning with cutting, with the number (7 of 40 in the test's box); that the number is the truth (that length validates clean with and without cutting, one more does not); that a value of the proven length prints whole and a longer one is cut; more lines prove more; a box that holds no character proves 0. ### 2. The shrink search is bounded Stepping 0.25 pt from 1000 pt to a 1 pt floor is four thousand sizes, each laid out twice, and every element was composed twice (check, then paint). | | Before | After | | --- | --- | --- | | One fit of 1024 characters at 600 dpi, 1000 pt to 1 pt (36 boxes and texts) | 180 to 250 ms; at least 15.6 million characters visited | 0.13 to 0.2 ms on average in the builds, under 1 ms cold; at most 21 555 characters visited | | A scene of 1000 such elements, validated | minutes (killed by the worker's 15 s of processor, no refusal named) | 200 ms in the CI image, 281 ms on Windows; every element refused by name as `TEXT_DOES_NOT_FIT`, none for the work | | Fits per element per render | 2 | 1 | - **Refused at once** when one line at the floor size is higher than the box, whatever the text: nothing is searched. A cut tries no line count the box is too low for. - **Halving, then a short linear check.** The size chosen is *defined* as the one a search of every quarter-point step gives, and is exactly that. A count that errs only toward yes says whether a size could hold the text: lines filled at any character, each held to a width no real line can be under (its advance, or the exact span of its ink plus the rasteriser's allowance less one dot of rounding), and a block held to its lines' ascent, pitches and descent. It never refuses a size that fits, and once it allows a size it allows every smaller one, so the largest size it allows is found by halving and no size above that fits. Only the steps from there down are laid out, as before. - **Equivalence is tested, not assumed.** `TextFitTest.theAnswerIsExactlyThatOfTheSearchOfEverySize` holds `fit` to the old linear search, block for block (size, lines, every number; fitted, refused or cut), over 3600 generated cases: texts with and without spaces, the widest and furthest-reaching characters, the proof's worst character, boxes from one dot to a large label, sizes from 1 pt to 1000 pt, any floor, 1 to 64 lines, any line height. The linear search in the test shares only the layout at one size with `fit`. The same comparison was soaked over 4.3 million further cases with other seeds, weighted to type of 1 to 12 pt where rounding decides: no difference. - **The layout is cached** between check and paint (`TextElement.check` keeps what it composed for the `paint` of the same pass). - **A work budget for the text of one scene**, with a named refusal. A character visited while fitting is one step, a character to be drawn 16, and a glyph the scene draws for the first time at a size 4096 (the runtime rasterises it once: 20 to 50 microseconds measured, against 0.25 after). One scene may take **64 million steps**, about a second of a worker. The element that passes it, and every text after it, is `SCENE_INVALID` at the element with `measured.textWorkSteps` against `limit.textWorkSteps`, the same shape as the painted-area limit. The count is integer arithmetic, so the refusal is the same on every machine, and it is the same whether the scene is validated or rendered. No scene the parser accepts can exceed it. - Tests: `TextFitTest.theWorstCaseOfTheShrinkSearchIsHeldToAStatedWork` (the reviewer's case: at most 40 times the text visited, where the linear search visits over 10 000 times it; same answer), `aBoxLowerThanOneLineAtTheFloorIsRefusedWithoutASearch`, `fittingEndsWhenItsAllowanceIsPassed`; `TextElementTest.aThousandOfTheWorstTextsAreRefusedByNameInsideTheWorkOfOneScene` (1000 elements, validated and rendered, each refused as `TEXT_DOES_NOT_FIT`, none for the work, inside 15 s) and `aSceneWhoseTextPassesItsWorkIsRefusedByName` (the refusal, its numbers, the same validated and rendered, every later text refused too). ### 3. Notes closed - **Widest advance over every drawable character.** `FaceTest` took the widest character over inked characters only; a no-break space or an em quad has no ink and still moves the pen. The greatest advance is now taken over every character a face draws, and the widest inked character must have it, so a font update cannot break the proof unseen. - **Ink outside the measure.** The bound is now tested at 1, 2, 300 and 1000 pt as well as 5 to 72 pt, for every character of every face at 203, 300 and 600 dpi (glyph bounds), with every character drawn at 1 and 2 pt and the furthest-reaching ones at 300 and 1000 pt. Nothing strays, on Windows or in the CI image: the furthest any glyph reaches past its exact outline is one dot of the two allowed, at every one of those sizes. `TEXT_INK_OUTSIDE_MEASURE` is no longer an `IllegalStateException` that fails the whole render with part of the text already on the label: the dots of a text are gathered first and reach the label only when none strayed; otherwise the **one element** is refused as `TEXT_DOES_NOT_FIT` with `measured.glyphsOutsideMeasure` and `measured.dotsOutsideMeasure`, and the label holds none of it. A literal is held to the glyph bounds when the scene is **validated**; a bound value can only be checked when it is known, at render. - **Legibility at the proven size.** A bound element with `maxLength` is judged, when validated, at the size the proof shrank to (the floor when the proof fails and the element cuts), which is the smallest any value prints at. Without `maxLength` it is still judged at its stated size, since nothing says how far it will shrink. - **`fit` is declared in the schema.** `maxLines`, `shrinkTo` and `overflow` are under `properties` with `additionalProperties: false`. The three names are in `ElementFactory.VOCABULARY` and in the bridge's `SceneFacts.VOCABULARY` in the same change (`SceneFactsTest.theVocabularyIsThePropertyNamesOfTheSchema` holds them equal: 46 names). A wrong member is reported at the member: `/elements/0/fit/maxLines`, or `/overrides/0/set/<id>/fit/maxLines` when an override states `fit`. A member `fit` does not have is still reported at `/fit`, because its name is not one the schema declares. - **Bound numbers print as the schema says.** A number is printed in plain decimal digits: no exponent, no fraction that is only zeros. `1.0E10` prints `10000000000`, `2.50` prints `2.5`, `12.0` prints `12`, `1E-7` prints `0.0000001`; an integer prints its digits however long. A number that is not finite is `VALUE_MISSING`. The schema's description now says exactly this. - **FreeType and JDK of the goldens** are recorded in `renderer/src/test/resources/scene2/README.md`, beside the goldens (the golden directory itself is held by a test to exactly the case files). Java draws glyphs with the FreeType the JDK itself ships, not the system's: 2.13.3 in Temurin 21.0.9+10 (the CI image, where the goldens were generated), 2.14.3 in Temurin 21.0.12.1+1 (Windows) and in Temurin 21.0.13+9 (the version the worker image requires). The same bytes on all three. ### Decisions made here, for the maintainers - **No diagnostic code was added.** The three new refusals and the new warning use codes that exist, told apart by their named numbers. A dedicated code for the work limit or for ink outside the measure would mean a change to foundation's closed `FailureCode`, the schema and both vocabularies; say so if you want them. - **Where the scene's work is counted.** A text element is given nothing that belongs to its scene except the collector of the render or validation, so the count is kept in the text package against that collector and forgotten with it. A counter on the canvas beside the painted-area count would be the plainer home; it was not added because other packages are changing the core at the same time. - **Two small changes outside the text package**, both needed for the pointer at a member: `ElementSource` resolves `fit/maxLines` where `fit` is stated (element, defaults or override), and `ElementFactory.VOCABULARY` gains the three names. - **The limit on glyphs is new behaviour.** A scene that draws more than about fifteen thousand different glyph-and-size pairs is now refused where it was slow before. A label of a hundred values in a handful of sizes uses a few percent of the allowance. ### Goldens None changed and none was regenerated. The 18 text files and the 24 layout files are byte-identical to `f5acf93`; `SceneGoldenTest` passes on them in both builds above, and the 21 labels were rendered again on Temurin 21.0.13+9 with the same bytes. ### Build The results under "Build" above are for this head, `589b6d4`, on both platforms. The version 1 corpus is untouched and green. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
james.maes added 2 commits 2026-11-12 19:45:43 +00:00
Version 2 label scenes draw text in faces shipped inside the renderer, so a
label needs no printer font, no installed font and no object storage:

  qqq-sans, qqq-sans-bold   IBM Plex Sans Regular and Bold (@ibm/plex-sans@1.1.0)
  qqq-condensed-bold        IBM Plex Sans Condensed Bold (@ibm/plex-sans-condensed@2.0.0)
  qqq-mono, qqq-mono-bold   JetBrains Mono Regular and Bold (v2.304)

The files are unmodified, taken from each project's official release archive.
Each licence was confirmed as the SIL Open Font License 1.1 from the archive's
own licence file, which is shipped byte for byte beside the fonts with a
provenance note (release, archive URL and hash, path in the archive, font
version). /fonts/SHA256SUMS lists the hashes. git is told never to rewrite
anything under /fonts.

THIRD-PARTY.md and NOTICE record the fonts. The OFL is not on the K22-DEP-02
allowlist; the maintainer approved one recorded exception for unmodified OFL
fonts shipped as data, and the register entry is proposed with the pull request.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
feat(renderer): scene 2 text: bundled faces, measurement before drawing, stated fit rules (P2)
maven / build (push) Successful in 10m16s
build / maven (push) Successful in 10m16s
f5acf93e3d
A version 2 `text` element is now drawn. It replaces the placeholder that
parsed and placed a text and refused it as RENDERER_TOO_OLD.

What a scene can say
- `font` names one of the five bundled faces (or comes from `defaults`); any
  other name is FONT_UNKNOWN. There is no printer font and no installed font.
- `size` is the em as a physical length (1 pt to 1000 pt), not rounded to dots.
- `value` or `valuePath`; `align`/`valign` place the block in the box and each
  line in the block; `lineHeight`, `rotation`, `reverse`, `threshold`.
- `fit`: one line by default, shrunk in quarter-point steps to 70% of the size
  (not below 5 pt), then refused. Opt in to more lines (`maxLines`) and to
  cutting with an ellipsis (`overflow: truncate`).
- `maxLength`: validation proves the longest value fits, so a length refusal
  happens when a scene is published; printing refuses a longer value first.

How it is measured and drawn
- Measurement is integer arithmetic on the face's own numbers (thousandths of
  the em, read once at a reference size), so it is the same on every machine.
  Every character sits at a whole-dot pen position and is drawn there by the
  Java rasteriser that version 1 raster text uses, then reduced to ink at the
  threshold. A line's box is its advance widened to its ink plus two dots a
  side; a render whose ink would leave it fails instead of clipping.
- At each size a line is broken between words while that fits and at any
  character when it does not, which is also what makes a length provable.
- One glyph for each character, left to right: no kerning, ligatures, mark
  placement or bidirectional text. A character that cannot be drawn that way
  is FONT_MISSING_GLYPH with a count.

Diagnostics (ids and numbers only; no value ever enters one)
TEXT_DOES_NOT_FIT, VALUE_MISSING, VALUE_TOO_LONG, FONT_UNKNOWN,
FONT_MISSING_GLYPH, RESERVED_FONT_NAME; TEXT_TRUNCATED, TEXT_SMALL_FOR_DPI,
TEXT_LIGHT_FACE, FIT_NOT_PROVEN; TEXT_SHRUNK, TEXT_WRAPPED.

Core seams added: `ElementSource.inherit` gives an element the scene's
`defaults` for the properties its type reads, and `ElementSource.refuse`
raises a coded refusal at a property (FONT_UNKNOWN at the place that names
the face, in the element, the defaults or an override).

Tests: goldens for the five faces and for fit, rotation and reverse at 203,
300 and 600 dpi, generated in the CI image (Linux, Temurin 21.0.9) and the
same bytes on Windows (21.0.12) and on Linux Temurin 21.0.13; every character
of every face drawn inside its measured line; seeded property tests of fit
(never shrunk when it fits, the largest size that fits, never below the floor,
never more lines than allowed, cut only when asked and always with the mark,
a proven length never refused); licence, provenance and hash of every face;
the version 2 mutation campaign extended to text. The version 1 corpus is
untouched. 100% line coverage of the scene 2 packages.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
james.maes added 1 commit 2026-11-12 21:09:54 +00:00
The proof of `maxLength`
- The proof never cuts. With `overflow: truncate` the proof was given a cut
  block, which counts as fitting, so a bound element whose box held ten
  characters validated clean with `maxLength: 40` and every long value was cut
  at print. The proof now runs with cutting off. When it fails, an element
  that did not ask for cutting is refused as before; one that did still
  validates, since cutting is what its author chose, with the warning
  FIT_NOT_PROVEN at every validation carrying `measured.provenCharacters`,
  the longest length proven to fit whole (found by halving: fewer characters
  never fit worse), against `limit.characters`. No new code: FIT_NOT_PROVEN
  already means that the fit of a bound value is not proven.
- A bound element with `maxLength` is judged for legibility, when validated,
  at the size the proof shrank to (the floor when it fails and the element
  cuts), not at its stated size.

The shrink search
- The size chosen is still exactly the one a search of every quarter-point
  step gives, but that search is no longer made. A count that errs only
  toward yes says whether a size could hold the text: lines filled at any
  character, each held to a width no real line can be under, and a block to
  its lines' ascent, pitches and descent. It never refuses a size that fits
  and, once it allows a size, allows every smaller one, so the largest size it
  allows is found by halving and only the steps from there are laid out. When
  one line at the floor is higher than the box nothing is searched, and a cut
  tries no line count the box is too low for.
- 1024 characters at 600 dpi from 1000 pt to 1 pt took 180 to 250 ms a fit and
  now takes under 1 ms: at most 21 555 characters visited where the search of
  every size visits 15.6 million.
- A text is fitted once: what `check` composed is what `paint` draws.
- The text of one scene may take 64 million steps (a character visited while
  fitting is one, a character to be drawn 16, a glyph the scene draws for the
  first time at a size 4096, since the runtime rasterises it once). The
  element that passes it, and every text after it, is SCENE_INVALID at the
  element with `textWorkSteps`, as the painted-area limit is. The count is
  integer arithmetic: the same on every machine, and the same validated or
  rendered. A thousand of the worst elements validate in well under a second.

Ink outside the measure
- TEXT_INK_OUTSIDE_MEASURE was an IllegalStateException thrown while
  painting, which failed the whole render after part of the text was on the
  label. The dots of a text are now gathered first and reach the label only
  when none strayed; otherwise the one element is refused as
  TEXT_DOES_NOT_FIT with `glyphsOutsideMeasure` and `dotsOutsideMeasure`. A
  literal is held to the glyph bounds when the scene is validated.
- The bound is tested at 1, 2, 300 and 1000 pt as well as from 5 to 72 pt, for
  every character of every face at the three resolutions; nothing strays.

Schema and vocabulary
- `fit` declares `maxLines`, `shrinkTo` and `overflow` under `properties` with
  `additionalProperties: false`. The three names are in the renderer's and the
  host bridge's pointer vocabularies, and a wrong member is reported at the
  member (`/elements/3/fit/maxLines`, or in the override that states `fit`).
- A bound number is printed as the schema says: plain decimal digits, no
  exponent, no fraction that is only zeros (`1.0E10` prints `10000000000`,
  `2.50` prints `2.5`). A number that is not finite is VALUE_MISSING.

Tests
- The widest-advance check covers every character a face draws, inked or not
  (a no-break space advances the pen), so a font update cannot break the proof
  unseen.
- Property: `fit` equals the search of every size, block for block, over 3600
  generated texts, boxes, sizes, floors and line counts.
- The goldens are unchanged. A README beside them records the JDK builds and the
  FreeType each ships.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
james.maes merged commit a147efee88 into develop 2026-11-12 23:11:04 +00:00
Sign in to join this conversation.
No Reviewers
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: QRun-IO/qbit-document-generation#16