Repository navigation
Releases: mgilbir/forme
Release list
forme 0.12.0
Compose reports what a document says about itself, so a backend can fill a
file's metadata without parsing the HTML a second time. From PR #962.
Document metadata (layout, #962):
- Composed.Document is a DocumentInfo, read from the parse Compose already
did and set whether or not the document was refused. - Title and HasTitle are document.title: the first title element's text,
with ASCII whitespace stripped and collapsed. HasTitle tells a document
with no title from one whose title is empty. - Language and HasLanguage are the root element's language, by the rule
layout hyphenates, cases and matches :lang() by: lang, xml:lang in
XHTML, then a Content-Language pragma. HasLanguage tells lang="" from
no declaration. - Direction is the root's dir: "ltr", "rtl", or empty.
- Meta is every in the head, in document order, with
the name lowercased and the element's Source. It stops at 256 entries,
with a limit finding. - Values are the document's after HTML's own normalisation; validating a
language tag or cutting a title is the backend's job.
Verified on main at e133f71: CI green in all three jobs, the reftest
ratchet at 5996 of 6253, and the API changes since v0.11.0 additions only.
forme 0.11.0
A font finding now says, as data, which face would clear it, so a caller
can supply one and lay the document out again. A segment break, an aligned
line and a shrink-wrapped float are set as their text is, across element
boundaries. Shaping is faster on CFF, COLR and morx faces. From PRs #941
to #960.
Go: go.mod names Go 1.27, up from 1.26, and builds with go1.27.3. A module
importing forme needs Go 1.27.
Font findings as data (layout, #959, #960):
- Finding.Font is a *FontNeed on every finding a different face would
clear. It holds Families (the names to supply, exactly as FontSet.Face
is called with them), Request (weight, width and style), Text (the
characters to cover), Features (OpenType tags, or "MATH") and Used (the
face set instead). Composed.FontNeeds merges them into a retry list:
supply a face for each need and Compose again. - It is carried by no-face, font-fallback, font-substituted, glyph-missing
for text and emphasis marks, caps-synthesised, the font-variant findings
where a face lacks the feature, math-fallback, an unreadable MATH table,
and an @font-face rule that loaded no font. - no-face names the families the box asked for and points at its element,
once per face to supply, instead of quoting "serif" once per document. - Two findings that need different faces stay two: two family lists that
share their first forty characters were merged into one. A bold and a
regular box asking for the same missing family are two findings, and
the message says "(weight 700)".
Text layout (layout, paragraph, #944, #946 to #949):
- A segment break at the end of an element is decided by the character
after it: "漢\n字" sets no space between the ideographs. - An image or inline-block beside a break is the character beside it, in
both directions, and a collapsed space is not carried across one. - A run of formatting characters such as U+061C takes no letter-spacing
of its own at the end of a line. A float around "ع" was
2px narrower than its text. - An aligned or right-to-left line hangs the letter-spacing of the letter
drawn furthest right. Lines ending in Arabic, a formatting mark or a
right-to-left run were set up to 8px off their edge.
Shaping (shape, #950 to #954):
- A trak table whose interpolation is not a number, and a NaN point size,
track as HarfBuzz does, on every architecture. - A format 3 context rule that cannot start at a glyph is refused before
it is matched: 27% to 35% faster shaping on Source Sans 3 and Source
Serif 4, identical output over 3,884 fonts. - Whether a glyph can start a morx subtable is remembered per face, within
a bound: 5% to 12% faster on fonts with a morx table. - A COLR, CFF, CFF2 or VARC face's cached ink is read without its lock:
ink measurement on eight goroutines is up to 95% faster.
- The subset fuzz oracle reads a renumbered subset at each glyph's new
index. The two crashers it caught on 2026-10-18 and 2026-10-25 are kept
as seeds.
Verified on main at 3714ec6: CI green in all three jobs, the reftest
ratchet at 5996 of 6253, and the API changes since v0.10.0 additions only.
forme 0.10.0
Untrusted fonts are read within their size wherever offsets alias, AAT
machines are charged as HarfBuzz charges them, CSS gains revert, the math
functions and a first transform, and the coverage check stops shaping.
From PRs #925 to #938.
Untrusted fonts (font, shape, #925 to #928, #934, #938):
- A name table, a variation store, an ltag table and a collection's
directory are read within their file's size however their records point
at the same bytes. Before, a 76 KB font made Load allocate 250 MB, a
56 KB variation store 99 MB, and a 33 KB collection a 10 MB program. - font.SFNTTables refuses a directory whose tables add up to more than
twice the file. Load and LoadInstance say why such a font is refused,
rather than "not an sfnt". - CollectionFaces is linear in the collection, however its faces share a
directory or a name table. - kerx attachment by points, morx and kerx subtables tried, trak tables
and the morx rewind are charged to the run. A one-character run that
took 3.4 s now takes 0.2 ms, and a trak that does not fit its table is
not applied, as HarfBuzz's sanitizer refuses it. - A run whose morx, mort or kerx spends HarfBuzz's allowance is refused
where HarfBuzz gives up on it: bounded calls and ComposeContext return
ErrRunLimit. An unbounded ShapeGlyphs returns what HarfBuzz leaves in
the buffer, and Compose reports it. - A bitmap strike is chosen from an index built once per face.
Performance (shape, layout, paragraph, #930, #933, #934):
- The coverage check counts missing glyphs without shaping
(Face.MissingGlyphs). Layout is up to eight times faster: a page of
Devanagari in Noto Sans from 1.1 s to 0.14 s, Arabic from 128 ms to
43 ms, Latin from 69 ms to 42 ms. - A face's Used() holds only glyphs a backend drew, not glyphs a coverage
probe touched, so a face that was asked and not chosen embeds nothing. - Colour glyphs are classified without drawing and counted once per
glyph; a face reads its MATH table and table directory once; a morx
run's glyph set and output are kept on the face (18% faster on real
morx fonts, 61% fewer bytes).
CSS (style, layout, #929, #931, #932, #935 to #937):
- revert and revert-layer roll the cascade back as CSS Cascade 5 says,
rather than reading as unset. A layered author rule now beats a
presentational hint. - min(), max(), clamp(), round(), mod(), rem() and the other math
functions are evaluated wherever calc() is, including against a
percentage's basis (style.LengthMath, Length.HasPercent). - display: contents computes to none on replaced elements, form controls
and MathML, and to block on the root, as CSS Display 3 says. - transform and transform-origin: a transform that keeps rectangles
rectangles is drawn with the existing ops. Options.TransformGroups,
off by default, draws every 2D transform through layout.TransformGroup,
which a backend must implement before turning it on. 3D transforms,
the root and table parts are still reported. - A length too large to hold is the largest length, written literally or
in a math function, rather than a declaration lost silently. - @page margin and size take the CSS-wide keywords.
Verified on 7c539d4: gofmt, go vet and go test ./... with every corpus,
and without; the race detector on CI; 6253 reftests, 5996 passed cleanly,
66 with something unsupported, 191 failed, unchanged since 0.9.0. apidiff
against 0.9.0 reports additions only.
forme 0.9.0
Bounded shaping that admits ordinary text and documents, layout under a context and one budget, and bitmap strikes a caller can list and choose from. From #915 to #918 and #923.
Bounded shaping is charged for the work it does (#916, #919)
- A bounded run is charged for each subtable, ligature and rule tried and each glyph a match walks over, rather than each subtable's size at every position.
- A byte of Latin now costs about 47 units, Devanagari about 300 and Noto Nastaliq about 350, against about 460,000 and 8 million before. The 64M default admits
MaxInputBytesof any of them with about fifty times to spare. - A font's runaway rules are still charged in full, and unbounded shaping is as fast as it was.
One budget for a document (#917, #920, #923, #924)
layout.ComposeContext(ctx, in, opts, limits)composes a document with every run it shapes or measures, in any of its faces, charged to oneshape.ShapingBudget. It fails with the context's error or one wrappingErrRunLimitand a zeroComposed, never with a composition that stopped part way.Composed.ShapingWorkis what it charged.Composeis unchanged.shape.ShapingBudget(NewShapingBudget,Bound,Run) shares one budget across any number of faces a caller owns.- A budget's zero fields are a document's defaults, which grow with what it shapes: no bound on a run's length (layout shapes a paragraph as one run), glyphs of 32,768 or 64 per byte of the longest run, and 64M units of work plus 1,024 per byte. A field the caller sets is a fixed bound.
Bitmap strikes (#915, #918, #921, #922)
PaintOptions.Bitmapspaints a glyph of a face with outlines from its EBDT or bdat strike, as a face with none is painted: for a writer of a font licensed for bitmap embedding only.GlyphColourForisGlyphColourfor given paint options. A face with outlines is still measured from them.Face.Strikeslists a face's CBDT, sbix and EBDT or bdat strikes, smallest first.Face.StrikeImageis a glyph's image in exactly one of them.Image.Strikesays which strike a painted image is from.
forme 0.8.0
Three additions for setting Word documents and old bitmap fonts, from #909 to #911: a face's Windows line metrics, a placement for each line's text within its line box, and the monochrome and greyscale bitmap strikes of a face with no outlines.
Descriptor states usWinAscent and usWinDescent (#910, #912)
WinAscentandWinDescentare OS/2'susWinAscentandusWinDescent, in font units and both positive, withMetricWinMetricssaying the font states them.- Word sets a single-spaced line by them:
WinAscent + WinDescenttall, its baselineWinAscentbelow the top. - An instance has them as MVAR's
hclaandhcldmove them, checked against HarfBuzz at every location.
Per-line placement of a line's text (#911, #913)
-
-forme-line-placement: auto | top | bottom | <number [0,1]> | <percentage>, which is not CSS, moves each line's baseline within a line box whose height CSS decided:value baseline Word line rule autoCSS half-leading — tophighest content-area top at the line box's top multiple bottomlowest content-area bottom at the line box's bottom at least 0.8/80%0.8 of the line box's height down exactly -
It is applied per line, so a paragraph of mixed sizes is placed by each line's own tallest text. It inherits and is read from the block; a line set down the page is left in the middle.
-
paragraph.LinePlacementandStrut.Placementcarry it.
A face with no outlines is painted from its bitmap strikes (#909, #914)
- EBDT and Apple's bdat: EBLC/bloc index formats 1–5, image formats 1, 2, 5, 6 and 7, composites in formats 8 and 9, bit depths 1, 2, 4 and 8. An Apple bitmap-only font (bloc and bdat under a
bhed) now loads, where it was refused. A face with outlines is still drawn from them, whatever strikes it carries. ImageMaskis such a glyph's image: a byte of coverage a pixel, painted inImage.Color, the foreground.ColourMaskisGlyphColour's answer for it.Image.Exactsays a bitmap glyph's strike is the size asked for, for EBDT, CBDT and sbix alike, so a caller drawing only exact bitmaps can tell.- Every glyph of every strike of two faces built for it matches FreeType 2.14, and reading each glyph is bounded by a work budget.
forme 0.7.1
Shaping a run no longer allocates for its working memory: from #907, filed against 0.7.0, whose cluster merging and AAT positioning added one or two allocations to every run.
A run's working memory is the face's (#907, #908)
- What a run is shaped in and is done with before it is returned is kept on the face and reused by the next run: its characters and offsets, the normalised text, the grapheme continuation and drop flags, the positioning record and attachment sums, the buffer the substitutions edit the run in (which was a new one per lookup), and the run's counters. A clone does not share it.
- A multiple substitution reads its glyphs from the font rather than copying them, and a string with nothing right-to-left in it skips the bidi run listing.
- A face's scratch is as large as the longest run it has shaped, about 10 bytes a glyph, for as long as the face is kept.
ShapeGlyphs on a warm face:
| 0.6.0 | 0.7.0 | 0.7.1 | |
|---|---|---|---|
| short labels | 8.0 µs, 19 allocs | 7.2 µs, 21 | 6.5 µs, 1 |
| 120-char sentence | 81 µs, 21 | 84 µs, 24 | 77 µs, 1 |
| combining marks | 11 µs, 22 | 11.9 µs, 24 | 10.8 µs, 1 |
| Arabic | 31 µs, 71 | 39.6 µs, 73 | 29.4 µs, 12 |
The one allocation left is the glyphs returned. Shaping output is unchanged.
bidi
RunCharactersIntoisRunCharacterswriting into arrays the caller keeps.- The algorithm's isolating run sequences are built with arrays indexed by position rather than maps. Unicode's conformance suites pass in full.
forme 0.7.0
Colour glyphs painted, emoji fonts of every kind loaded and shaped, and
clusters as HarfBuzz forms them. Closes #884 and #886 to #889.
Painting (shape):
- Face.PaintGlyph paints a glyph through a Painter, HarfBuzz's
hb_paint_funcs_t in Go: a COLRv1 paint graph or COLRv0 layers coloured
from a CPAL palette, with PaletteOverrides; an SVG glyph's document; a
CBDT or sbix PNG at the strike for PaintOptions.PPEM; or a glyph's
outline in the foreground. Face.GlyphColour says which. A COLR glyph
past its bounds is refused whole with ErrPaintLimit. Painting matches
hb_font_paint_glyph call for call over the colour fixtures and Noto Color
Emoji's COLRv1 and CBDT builds.
Loading:
- A font whose glyphs are only bitmaps (CBDT, sbix, EBDT, no outlines)
loads; Face.BitmapOnly says so, and Subset refuses it. - LoadCollection, LoadCollectionInstance and CollectionFaces read a face
of a .ttc/.otc, its tables shared with the collection, and of a WOFF 2
collection. WOFF 2 collections are checked against Google's encoder and
decoder and against allsorts; a loca not directly after its glyf is now
refused, as §5.5 requires.
AAT (shape):
- morx, and mort where there is no morx, as HarfBuzz and CoreText apply
them: all five subtable kinds, features a document asks for mapped
through feat, chains keyed to the run's language through ltag. Every
case of the text-rendering tests' MORX suite matches HarfBuzz; Apple
Color Emoji's sequences are one glyph each. - kerx, all five formats, with ankr and contour points as FreeType numbers
them for glyf and CFF; and trak, at the size the text is set at
(Features.PointSize, Face.FeaturesAt), wired through layout.
Clusters:
- Glyph.Cluster now merges as HarfBuzz's default cluster level does: a
grapheme is one cluster, and ligatures, deletions and reordering merge
theirs. Every shaping oracle compares clusters. GroupAdvances now takes
the group's text rather than its length.
Fixes found by fuzzing and review:
- Used no longer lists a glyph past the font's end; a morx run is capped
at HarfBuzz's maximum length; the CFF written from a CFF2 font keeps
only CFF's Private DICT operators and drops hints a reader would mis-size;
a solid fill is charged for no colour stops, so flags of thousands of
paints are painted. - The fuzz targets minimize an input in 200 tries.
Also: Features.PointSize, segment.ExtendedPictographic, ImageSVG,
ColourSVG. Bounded shaping (ShapeGlyphsBounded) covers the AAT paths.
Known gaps: Myanmar cluster merging is untested for want of a font; no
independent WOFF 2 encoder writes collections; Source Serif 4 Variable cut
away from its default still draws up to about 4 units off HarfBuzz.
The whole suite with every corpus, and go test -race ./..., passed on CI
for each pull request in this release, and the CSS Working Group reftests
are at their baseline: 5,996 of 6,253 pass with nothing unsupported.
Pre-1.0. This adds exported API and changes GroupAdvances's signature and
what Glyph.Cluster holds.
forme 0.6.0
Bounded shaping through a clone the caller already owns, at the allocation
cost of unbounded shaping. Closes #882, filed against 0.5.0.
Face.ShapeGlyphsBounded is ShapeGlyphsContext on the receiver, for a
caller that keeps its own clones, one per goroutine:
- It makes no clone. The glyphs it shapes are recorded on the receiver, as
ShapeGlyphsrecords them, for the caller to merge. A run that fails may
have recorded some before it stopped. - Its budget is kept on the face and reset each call, so the bounds
allocate nothing per run.Clonedoes not share it. - The limits and failures are those of
ShapeGlyphsContext, which is now
this on a fresh clone. - A face already under a budget, such as the one
WithShapingLimitshands
out, refuses it.
The context is now asked every 64 work charges, not at each one, and still
at every boundary between phases. Each ask was an interface call, and a
lock for a cancellable context.
On 14-character NotoSans labels with a cancellable context (medians):
unbounded 8.5 µs 2,784 B 19 allocs
ShapeGlyphsContext, 0.5.0 11.2 µs 4,287 B 26 allocs
ShapeGlyphsContext, now 10.0 µs 4,286 B 26 allocs
ShapeGlyphsBounded on a clone 9.3 µs 2,784 B 19 allocs
The whole suite with every corpus, and go test -race ./..., passed on CI
for the pull request in this release.
Pre-1.0. This adds exported API (ShapeGlyphsBounded) and removes none.
forme 0.5.0
Bounded, cancellable shaping for callers that shape untrusted text, and a
shaped run that no longer leaves most of its glyph buffer behind as garbage.
Face.ShapeGlyphsContext shapes one run under a context and RunLimits:
- The limits are input bytes, live glyphs and charged lookup work. The
defaults are 4096 bytes, 32768 glyphs and 64 million work units. - Cancellation is checked between shaping phases and lookup steps.
Font parsing and Unicode preprocessing keep their own bounds and are
not interrupted inside a phase. - The run is shaped through a private clone, so the receiver's used-glyph
record is unchanged and concurrent calls are safe. - On any failure the result is zero; there are never partial glyphs.
Failures are cancellation,ErrRunLimitfor spent work, glyph growth or
lookup recursion, and a limit reading the layout tables the run was
shaped with ran into. Limits other calls ran into on the same face do
not count. RunResult.Workis the work charged, for a caller to deduct from a
document-wide budget.
Face.WithShapingLimits runs a callback, such as paragraph breaking, with
one budget shared across every measurement and shaping call it makes on the
face it is handed. Each run is checked against the input limit. A face kept
past the call shapes without limits.
The unbounded entry points are unchanged in what they return. The checks
cost them about 2.5% on a cold 30-character Latin label.
shapeGlyphsIn and shapeByCode now size a run's glyph buffer once, by
its runes, rather than growing it one doubling at a time. Output is
unchanged. On aster's BenchmarkTextLong, as reported in #877, this took
421 to 243 ms/op and 376.7 to 185.6 MB/op.
fonttest.GPOSExtensionLookups now builds on 32-bit targets. Its sentinel
constant overflowed int there.
The whole suite with every corpus, and go test -race ./..., passed on CI
for each pull request in this release.
Pre-1.0. This adds exported API (ShapeGlyphsContext, WithShapingLimits,
RunInput, RunLimits, RunResult, ErrRunLimit) and removes none.
forme 0.4.3
Two fixes to the shape package, both from issues filed against 0.4.2: a
bold face with no OS/2 table now states its weight, and a font whose
feature list asks for far more lookups than it holds no longer takes
seconds in shape.Load.
A face with no weight class takes its weight from its bold bit (#873, #871)
Without OS/2, Descriptor.Weight was 0 and undeclared, even though
head.macStyle bit 0 marks the face bold. The italic bit next to it was
already read.
- The weight now comes from the bold bit of whichever field the style is
read from: fsSelection bit 5 when OS/2 reaches offset 64, otherwise
macStyle bit 0. Bold gives 700, anything else 400, and the weight is
marked declared. - It applies only where OS/2 does not state usWeightClass. usWeightClass
still wins wherever it is present. - With OS/2 removed, Liberation Sans Bold and BoldItalic now read 700 and
Regular 400, all declared. With OS/2 in place, nothing changes. - Layout is unaffected: font matching reads
@font-faceweights, not
Descriptor.Weight.
A feature list is read within its table's size (#874, #872)
A spliced font's GPOS FeatureList had 12,663 records under 4,334 tags,
pointing at about 5,100 overlapping lists. Reading it meant reading 165
million lookup indices to keep 8.4 million. Every list lay within the
table, so the 65,535-per-list cap refused none of them, and n records on
one list of n cost n².
- What a table's features may name between them is now bounded by the
table's size: half its length in indices, plus one maximal list (65,535). - Past that allowance, the features already covered are kept whole and in
order, and the rest name nothing.Face.LayoutLimitsreports it, as it
does for the other bounds. - Real fonts are well below the bound: across 159 fonts and 231 GSUB and
GPOS tables, the most any table asks is 0.07 indices per byte. - On the font from the issue,
shape.Loaddrops from 2.8 s to 12 ms.
Over 1,600 mutated copies of Liberation Sans Regular, the worst case
goes from 4.1 s to 22 ms. The intact font is unchanged at 0.4 ms.
CI no longer fetches the W3C licence page (#875, #876)
THIRD_PARTY_NOTICES quotes the W3C Software and Document License from a
copy now kept in testdata/w3c-licence/. The page has no versioned URL,
and it changed twice in two days with W3C's site while the licence on it
stayed the same, failing every CI job. Nothing in the library changes.
Where it stands
Unchanged from 0.4.2, as measured on each of the two fixes:
5,996 of the CSS Working Group's 6,253 reftests pass with nothing
unsupported reported in either document; 66 pass with something
unsupported, and 191 fail
The whole suite with every corpus, and go test -race ./..., passed on CI
for each pull request in this release.
Pre-1.0. This adds no exported API and removes none.