[WIP] [Text] Add support for color glyph drawing#21407
Open
Gillibald wants to merge 3 commits into
Open
Conversation
Introduces shared utility types that subsequent PRs will use to implement GetGlyphOutline (glyf table) and GetGlyphDrawing (COLR v0/v1) on GlyphTypeface: - ObjectPool<T>: thread-safe object pool used by Decyclers - Decycler<T> / CycleGuard<T> / DecyclerException: generic cycle-detection and depth-limiting utility for recursive font-table traversal (composite glyphs, paint graphs) - FontVariationSettings: parameter type for the upcoming GetGlyphOutline / GetGlyphDrawing overloads - IGlyphDrawing / GlyphDrawingType: return-type contract for color glyph drawings (outline, color layers, SVG, bitmap) - CharacterToGlyphMapDictionary: lightweight, allocation-free IReadOnlyDictionary<int, ushort> view over the cmap, plus CharacterToGlyphMap.AsReadOnlyDictionary() to expose it No behavior change; types are not yet consumed in this PR.
Implements vector outline retrieval for TrueType glyphs:
- LocaTable: parses the 'loca' table (short/long offset variants)
to map glyph IDs to glyf offsets
- GlyfTable: parses the 'glyf' table, supporting simple and
composite glyphs, and builds geometry into an IGeometryContext
- SimpleGlyph / CompositeGlyph / GlyphDescriptor / GlyphComponent /
GlyphFlag / CompositeFlags: data model for the parsed glyph
records
- GlyphDecycler: pooled cycle/depth guard for composite-glyph
recursion (derives from the shared Decycler<int>)
GlyphTypeface now eagerly loads the glyf table when a head table is
present and exposes:
Geometry? GetGlyphOutline(ushort glyphId, Matrix transform,
FontVariationSettings? variation = null)
Returns null when the font has no outline data for the glyph or the
glyph index is out of range.
Implements color glyph rendering for fonts that ship a COLR/CPAL table
pair, including the v1 paint-graph format:
- ColrTable / CpalTable: COLR (color layers + v1 base-glyph records,
clip boxes, paint offsets) and CPAL (color palette) parsers
- Paint / PaintParser / PaintResolver / PaintTraverser: COLR v1 paint
graph parsing, variation resolution, and visitor-based traversal
- ColorGlyphV1Painter: IColorPainter implementation that emits
DrawingContext calls (solid, linear/radial/sweep gradients via
GradientBrushHelper, composite modes, transforms, clipping)
- ColrContext / PaintDecycler / ResolvedPaint / IColorPainter /
PaintParsingHelpers / CompositeMode: supporting types
- DeltaSet / DeltaSetIndexMap / ItemVariationStore: shared
variation-store parsing used by COLR v1 variable paint records
GlyphTypeface now loads COLR/CPAL alongside the glyf table and
exposes:
IGlyphDrawing? GetGlyphDrawing(ushort glyphId,
FontVariationSettings? variation = null)
Returns a ColorGlyphV1Drawing for COLR v1 records, a layer-based
ColorGlyphDrawing for COLR v0, or null for outline-only glyphs
(callers should fall back to GetGlyphOutline).
Includes unit tests for DeltaSetIndexMap and ColrTable, plus a small
test font (test_glyphs-glyf_colr_1_no_cliplist.ttf) used by the COLR
tests.
3 tasks
Contributor
There was a problem hiding this comment.
Pull request overview
This PR introduces initial infrastructure and a public API for rendering OpenType color fonts (COLR v0 layer lists and COLR v1 paint graphs) via GlyphTypeface.GetGlyphDrawing, alongside new font-table parsing for glyf/loca and COLR/CPAL/variations.
Changes:
- Added COLR/CPAL parsing and COLR v1 paint graph parsing/resolution/traversal with a
DrawingContextpainter. - Added
GlyphTypeface.GetGlyphDrawingand supporting drawing abstractions (IGlyphDrawing,GlyphDrawingType,FontVariationSettings). - Added new glyf/loca readers and cycle-detection/pooling infrastructure used by outline and color rendering paths.
Reviewed changes
Copilot reviewed 36 out of 36 changed files in this pull request and generated 11 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/Avalonia.Skia.UnitTests/Media/ColrTableTests.cs | Adds Skia-level tests exercising COLR/CPAL loading and COLR v1 traversal/bounds diagnostics. |
| tests/Avalonia.Base.UnitTests/Media/Fonts/Tables/Colr/DeltaSetIndexMapTests.cs | Adds unit tests for DeltaSetIndexMap parsing and lookup. |
| src/Avalonia.Base/Utilities/ObjectPool.cs | Adds a thread-safe bounded object pool used by decyclers. |
| src/Avalonia.Base/Media/IGlyphDrawing.cs | Introduces glyph drawing abstraction used by the new API. |
| src/Avalonia.Base/Media/GlyphTypeface.cs | Caches glyf/COLR/CPAL tables; adds GetGlyphDrawing and outline/cached glyf rendering; implements COLR v0/v1 drawing objects. |
| src/Avalonia.Base/Media/GlyphDrawingType.cs | Adds enum describing glyph rendering format. |
| src/Avalonia.Base/Media/FontVariationSettings.cs | Adds record for variation/palette/bitmap settings (intended for COLR v1 variations). |
| src/Avalonia.Base/Media/Fonts/Tables/LocaTable.cs | Adds non-allocating loca reader for glyf offsets. |
| src/Avalonia.Base/Media/Fonts/Tables/Glyf/SimpleGlyph.cs | Adds parser for TrueType simple glyph outlines using pooled buffers. |
| src/Avalonia.Base/Media/Fonts/Tables/Glyf/GlyphFlag.cs | Adds glyph point flag definitions. |
| src/Avalonia.Base/Media/Fonts/Tables/Glyf/GlyphDescriptor.cs | Adds glyph header/bounds reader and accessors for simple/composite glyph data. |
| src/Avalonia.Base/Media/Fonts/Tables/Glyf/GlyphDecycler.cs | Adds pooled decycler for composite glyph recursion/cycle protection. |
| src/Avalonia.Base/Media/Fonts/Tables/Glyf/GlyphComponent.cs | Adds composite glyph component struct. |
| src/Avalonia.Base/Media/Fonts/Tables/Glyf/GlyfTable.cs | Adds on-demand glyf reader + geometry builder (simple/composite). |
| src/Avalonia.Base/Media/Fonts/Tables/Glyf/CompositeGlyph.cs | Adds composite glyph parser using pooled buffers. |
| src/Avalonia.Base/Media/Fonts/Tables/Glyf/CompositeFlags.cs | Adds composite glyph flag definitions. |
| src/Avalonia.Base/Media/Fonts/Tables/Decycler.cs | Adds generic cycle/depth guard used by glyf and COLR v1 paint parsing. |
| src/Avalonia.Base/Media/Fonts/Tables/Colr/ResolvedPaint.cs | Defines resolved paint node types used after applying variations/normalization. |
| src/Avalonia.Base/Media/Fonts/Tables/Colr/PaintTraverser.cs | Adds traversal that calls an IColorPainter over resolved paint nodes. |
| src/Avalonia.Base/Media/Fonts/Tables/Colr/PaintResolver.cs | Resolves raw COLR v1 paints into ResolvedPaint and normalizes gradients/transforms. |
| src/Avalonia.Base/Media/Fonts/Tables/Colr/PaintParsingHelpers.cs | Adds helpers for parsing COLR v1 paint/gradient data. |
| src/Avalonia.Base/Media/Fonts/Tables/Colr/PaintParser.cs | Adds dispatcher for COLR v1 paint formats 1–32. |
| src/Avalonia.Base/Media/Fonts/Tables/Colr/PaintDecycler.cs | Adds pooled decycler for paint graph recursion/cycle protection. |
| src/Avalonia.Base/Media/Fonts/Tables/Colr/Paint.cs | Adds raw COLR v1 paint node definitions and parsers. |
| src/Avalonia.Base/Media/Fonts/Tables/Colr/ItemVariationStore.cs | Adds ItemVariationStore reader for COLR v1 variable records. |
| src/Avalonia.Base/Media/Fonts/Tables/Colr/IColorPainter.cs | Defines painter interface used by COLR v1 traversal. |
| src/Avalonia.Base/Media/Fonts/Tables/Colr/GradientBrushHelper.cs | Adds helper for mapping COLR gradients to Avalonia brushes. |
| src/Avalonia.Base/Media/Fonts/Tables/Colr/DeltaSetIndexMap.cs | Adds DeltaSetIndexMap reader for mapping var indices to delta sets. |
| src/Avalonia.Base/Media/Fonts/Tables/Colr/DeltaSet.cs | Adds delta-set ref struct for reading word/byte deltas. |
| src/Avalonia.Base/Media/Fonts/Tables/Colr/CpalTable.cs | Adds CPAL palette reader. |
| src/Avalonia.Base/Media/Fonts/Tables/Colr/CompositeMode.cs | Adds COLR v1 composite mode enum. |
| src/Avalonia.Base/Media/Fonts/Tables/Colr/ColrTable.cs | Adds COLR v0/v1 table reader, layer access, clip boxes, and variation mapping. |
| src/Avalonia.Base/Media/Fonts/Tables/Colr/ColrContext.cs | Adds context used by paint parsing/resolution (glyph typeface, palette, variation deltas). |
| src/Avalonia.Base/Media/Fonts/Tables/Colr/ColorGlyphV1Painter.cs | Implements IColorPainter that renders resolved paints to DrawingContext. |
| src/Avalonia.Base/Media/Fonts/Tables/Cmap/CharacterToGlyphMapDictionary.cs | Adds dictionary wrapper for cmap mappings. |
| src/Avalonia.Base/Media/Fonts/Tables/Cmap/CharacterToGlyphMap.cs | Exposes cmap as IReadOnlyDictionary via the new wrapper. |
Comments suppressed due to low confidence (4)
src/Avalonia.Base/Media/Fonts/Tables/Colr/Paint.cs:427
- Same decycler lifetime issue as Glyph.TryParse: ColrGlyph.TryParse calls PaintDecycler.Enter(glyphId) but does not dispose the CycleGuard, and multiple early returns occur before the manual Exit(glyphId). This can permanently “poison” the decycler state and cause spurious cycle/depth failures. Capture the guard and dispose it deterministically (and remove the manual Exit call).
var glyphId = BinaryPrimitives.ReadUInt16BigEndian(span.Slice(1));
decycler.Enter(glyphId);
if (!context.ColrTable.TryGetBaseGlyphV1Record((ushort)glyphId, out var v1Record))
{
return false;
}
var absolutePaintOffset = context.ColrTable.GetAbsolutePaintOffset(v1Record.PaintOffset);
if (absolutePaintOffset >= context.ColrData.Length)
{
return false;
}
if (!PaintParser.TryParse(context.ColrData.Span, absolutePaintOffset, in context, in decycler, out var innerPaint))
{
return false;
}
decycler.Exit(glyphId);
src/Avalonia.Base/Media/Fonts/Tables/Colr/DeltaSetIndexMap.cs:197
- Same truncation issue for the innerIndex decoding: 3- and 4-byte inner values are cast to ushort, losing high bits. If the implementation intends to only support 16-bit indices, it should reject entryFormat sizes > 2 (or reject values > ushort.MaxValue) rather than truncating.
// Read inner index (comes after outer)
var innerSpan = entrySpan.Slice(outerSizeBytes);
switch (innerSizeBytes)
{
case 1:
innerIndex = innerSpan[0];
break;
case 2:
innerIndex = BinaryPrimitives.ReadUInt16BigEndian(innerSpan);
break;
case 3:
innerIndex = (ushort)((innerSpan[0] << 16) | (innerSpan[1] << 8) | innerSpan[2]);
break;
case 4:
innerIndex = (ushort)BinaryPrimitives.ReadUInt32BigEndian(innerSpan);
break;
default:
src/Avalonia.Base/Media/GlyphTypeface.cs:612
- Same off-by-one issue as GetGlyphDrawing: GetGlyphOutline should reject
glyphId == GlyphCountas out of range (valid IDs are 0..GlyphCount-1). UseglyphId >= GlyphCountto avoid out-of-range accesses.
src/Avalonia.Base/Media/GlyphTypeface.cs:971 - ColorGlyphV1Drawing.Draw rents/returns a PaintDecycler but never uses it during traversal. This is dead code and adds allocations/Interlocked overhead on every draw. Remove it, or if cycle protection is intended during traversal, thread the decycler into the traversal/parsing logic where it is actually consulted.
Comment on lines
+370
to
+386
| var subPaintOffset = paintOffset + PaintParsingHelpers.ReadOffset24(span.Slice(1)); | ||
| var glyphId = BinaryPrimitives.ReadUInt16BigEndian(span.Slice(4)); | ||
|
|
||
| decycler.Enter(glyphId); | ||
|
|
||
| if (subPaintOffset >= context.ColrData.Length) | ||
| { | ||
| return false; | ||
| } | ||
|
|
||
| if (!PaintParser.TryParse(context.ColrData.Span, subPaintOffset, in context, in decycler, out var innerPaint)) | ||
| { | ||
| return false; | ||
| } | ||
|
|
||
| decycler.Exit(glyphId); | ||
|
|
Comment on lines
+60
to
+75
| /// <summary> | ||
| /// Gets the word deltas (16-bit signed integers) as a ReadOnlySpan. | ||
| /// </summary> | ||
| public ReadOnlySpan<short> WordDeltas | ||
| { | ||
| get | ||
| { | ||
| if (_wordDeltaCount == 0) | ||
| { | ||
| return ReadOnlySpan<short>.Empty; | ||
| } | ||
|
|
||
| var wordBytes = _data.Slice(0, _wordDeltaCount * 2); | ||
| return MemoryMarshal.Cast<byte, short>(wordBytes); | ||
| } | ||
| } |
Comment on lines
+160
to
+198
| var entrySpan = span.Slice(entryOffset, entrySize); | ||
|
|
||
| // Read outer index (comes first) | ||
| switch (outerSizeBytes) | ||
| { | ||
| case 1: | ||
| outerIndex = entrySpan[0]; | ||
| break; | ||
| case 2: | ||
| outerIndex = BinaryPrimitives.ReadUInt16BigEndian(entrySpan); | ||
| break; | ||
| case 3: | ||
| outerIndex = (ushort)((entrySpan[0] << 16) | (entrySpan[1] << 8) | entrySpan[2]); | ||
| break; | ||
| case 4: | ||
| outerIndex = (ushort)BinaryPrimitives.ReadUInt32BigEndian(entrySpan); | ||
| break; | ||
| default: | ||
| return false; | ||
| } | ||
|
|
||
| // Read inner index (comes after outer) | ||
| var innerSpan = entrySpan.Slice(outerSizeBytes); | ||
| switch (innerSizeBytes) | ||
| { | ||
| case 1: | ||
| innerIndex = innerSpan[0]; | ||
| break; | ||
| case 2: | ||
| innerIndex = BinaryPrimitives.ReadUInt16BigEndian(innerSpan); | ||
| break; | ||
| case 3: | ||
| innerIndex = (ushort)((innerSpan[0] << 16) | (innerSpan[1] << 8) | innerSpan[2]); | ||
| break; | ||
| case 4: | ||
| innerIndex = (ushort)BinaryPrimitives.ReadUInt32BigEndian(innerSpan); | ||
| break; | ||
| default: | ||
| return false; |
Comment on lines
+550
to
+557
| // OpenType 1.9.1 adds a shift to ease 0-360 degree specification | ||
| var startAngleDeg = startAngle * 180.0 + 180.0; | ||
| var endAngleDeg = endAngle * 180.0 + 180.0; | ||
|
|
||
| // Convert from counter-clockwise to clockwise | ||
| startAngleDeg = 360.0 - startAngleDeg; | ||
| endAngleDeg = 360.0 - endAngleDeg; | ||
|
|
Comment on lines
+101
to
+108
| // OpenType 1.9.1 adds a shift to ease 0-360 degree specification | ||
| var startAngleDeg = startAngle * 180.0 + 180.0; | ||
| var endAngleDeg = endAngle * 180.0 + 180.0; | ||
|
|
||
| // Convert from counter-clockwise to clockwise | ||
| startAngleDeg = 360.0 - startAngleDeg; | ||
| endAngleDeg = 360.0 - endAngleDeg; | ||
|
|
Comment on lines
+567
to
+587
| public IGlyphDrawing? GetGlyphDrawing(ushort glyphId, FontVariationSettings? variation = null) | ||
| { | ||
| if (glyphId > GlyphCount) | ||
| { | ||
| return null; | ||
| } | ||
|
|
||
| // Try COLR v1 first | ||
| if (_colrTable != null && _cpalTable != null && _colrTable.HasV1Data) | ||
| { | ||
| if (_colrTable.TryGetBaseGlyphV1Record(glyphId, out var record)) | ||
| { | ||
| return new ColorGlyphV1Drawing(this, _colrTable, _cpalTable, glyphId, record); | ||
| } | ||
| } | ||
|
|
||
| // Fallback to COLR v0 | ||
| if (_colrTable != null && _cpalTable != null && _colrTable.HasColorLayers(glyphId)) | ||
| { | ||
| return new ColorGlyphDrawing(this, _colrTable, _cpalTable, glyphId); | ||
| } |
Comment on lines
+928
to
+945
| var decycler = PaintDecycler.Rent(); | ||
|
|
||
| try | ||
| { | ||
| if (glyphTypeface.TryGetBaseGlyphV1Paint(_context, record, out _paint)) | ||
| { | ||
| if (_context.ColrTable.TryGetClipBox(_glyphId, out var clipRect)) | ||
| { | ||
| // COLR v1 paint graphs operate in font-space coordinates (Y-up). | ||
| _bounds = clipRect.TransformToAABB(Matrix.CreateScale(1, -1)); | ||
| } | ||
| } | ||
| } | ||
| finally | ||
| { | ||
| PaintDecycler.Return(decycler); | ||
|
|
||
| } |
Comment on lines
+19
to
+28
| var typeface = new Typeface("resm:Avalonia.Skia.UnitTests.Assets?assembly=Avalonia.Skia.UnitTests#Noto Color Emoji"); | ||
|
|
||
| // Try to get glyph typeface - may or may not have COLR | ||
| if (FontManager.Current.TryGetGlyphTypeface(typeface, out var glyphTypeface)) | ||
| { | ||
| Assert.True(ColrTable.TryLoad(glyphTypeface, out var colrTable)); | ||
|
|
||
| Assert.True(colrTable.Version <= 1); | ||
| Assert.True(colrTable.BaseGlyphCount >= 0); | ||
| } |
Comment on lines
+148
to
+152
|
|
||
| var longWordCount = wordDeltaCount; | ||
| var shortDeltaCount = regionIndexCount - wordDeltaCount; | ||
| var deltaSetSize = (longWordCount * 2) + shortDeltaCount; | ||
|
|
Comment on lines
+114
to
+134
| var numStops = BinaryPrimitives.ReadUInt16BigEndian(span.Slice(1)); | ||
|
|
||
| // Validate numStops is reasonable | ||
| // Gradients with more than 256 stops are likely corrupt data | ||
| if (numStops == 0) | ||
| { | ||
| return false; | ||
| } | ||
|
|
||
| // ColorStop is 6 bytes, VarColorStop is 10 bytes (each has varIndexBase) | ||
| int stopSize = isVarColorLine ? 10 : 6; | ||
|
|
||
| // Ensure we have enough data for all stops | ||
| var requiredLength = 3 + (numStops * stopSize); | ||
| if (span.Length < requiredLength) | ||
| { | ||
| return false; | ||
| } | ||
|
|
||
| var tempStops = new Immutable.ImmutableGradientStop[numStops]; | ||
|
|
|
You can test this PR using the following package version. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What does the pull request do?
Adds support for color fonts — both the layer-based COLR v0 format and the paint-graph–based COLR v1 format — via a new public API on
GlyphTypeface:The returned
IGlyphDrawingknows how to render itself into aDrawingContext, so callers can mix color emoji and other COLR-based glyphs with the regular outline path (GetGlyphOutline) without caring which format the font uses internally.This PR is stacked on the GetGlyphOutline PR (which is itself stacked on the font-parsing-infrastructure PR). Both must merge first. COLR v1 base layers ultimately fall through to
GetGlyphOutlineto fetch the actual contour geometry, so the dependency is real, not just organizational.What is the updated/expected behavior with this PR?
GlyphTypeface.GetGlyphDrawing(glyphId)returns:ColorGlyphV1Drawingwhen the font has a COLR table v1 and the glyph has aBaseGlyphV1Record,ColorGlyphDrawing(v0 layer list) when v1 is not available but v0 layers exist,nullfor outline-only glyphs (callers should fall back toGetGlyphOutline).The returned object exposes
Bounds(in drawing-space) andDraw(DrawingContext, Point). The translation/transform handling and the font-space Y-flip are taken care of inside the drawing object, so callers just pick an origin.The
FontVariationSettings? variationparameter is wired intoColrContextand used by the COLR v1 paint resolver for variable color records (PaintVarSolid,PaintVarLinearGradient, etc.) when a variation store is present.How was the solution implemented (if it's not obvious)?
All new code lives in
src/Avalonia.Base/Media/Fonts/Tables/Colr/:ColrTable— COLR v0/v1 reader. ExposesGetLayers(glyphId),TryGetBaseGlyphRecord,TryGetBaseGlyphV1Record,TryGetClipBox, and the absolute paint-offset resolver used by the v1 paint parser.CpalTable— CPAL palette reader withTryGetColor(paletteIndex, entryIndex, out Color).Paint/PaintParser/PaintResolver/PaintTraverser— the COLR v1 paint graph. Parsing produces aPainttree from the raw COLR data; the resolver applies variations from theItemVariationStore; the traverser walks the resolved tree calling into anIColorPainter.ColorGlyphV1Painter—IColorPainterimplementation that emitsDrawingContextcalls: solid fills, linear/radial/sweep gradients (viaGradientBrushHelper), composite/blend modes (CompositeMode), transforms, and clip pushes.PaintDecycler— pooledDecycler<ushort>(from the infra PR) that guards against cycles and a depth limit inPaintColrLayers/PaintColrGlyphreferences.DeltaSet/DeltaSetIndexMap/ItemVariationStore— OpenType variation-store parsing used by COLR v1 variable records.Notes for reviewers:
Drawmethod onColorGlyphV1Drawingpushes aMatrix.CreateScale(1, -1) * Matrix.CreateTranslation(origin)before traversing the paint graph, so the painter itself does not need to think about Y direction.ColorGlyphDrawing) is implemented in terms ofGlyphTypeface.GetGlyphOutline— each layer record is just a glyph ID + palette index, and the layer is drawn as aSolidColorBrushfill over that outline. This is why this PR has a hard dependency on the GetGlyphOutline PR._colrTableand_cpalTableare loaded once in theGlyphTypefaceconstructor when theheadtable is present, alongside theglyfload from the previous PR.Checklist
Breaking changes
None. New public API; existing behavior is unchanged.
Obsoletions / Deprecations
None.
Fixed issues