Skip to content

[WIP] [Text] Add support for color glyph drawing#21407

Open
Gillibald wants to merge 3 commits into
AvaloniaUI:masterfrom
Gillibald:pr3/glyph-colorv1
Open

[WIP] [Text] Add support for color glyph drawing#21407
Gillibald wants to merge 3 commits into
AvaloniaUI:masterfrom
Gillibald:pr3/glyph-colorv1

Conversation

@Gillibald
Copy link
Copy Markdown
Contributor

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:

public IGlyphDrawing? GetGlyphDrawing(ushort glyphId,
                                      FontVariationSettings? variation = null);

The returned IGlyphDrawing knows how to render itself into a DrawingContext, 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 GetGlyphOutline to 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:

  • a ColorGlyphV1Drawing when the font has a COLR table v1 and the glyph has a BaseGlyphV1Record,
  • a ColorGlyphDrawing (v0 layer list) when v1 is not available but v0 layers exist,
  • null for outline-only glyphs (callers should fall back to GetGlyphOutline).

The returned object exposes Bounds (in drawing-space) and Draw(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? variation parameter is wired into ColrContext and 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. Exposes GetLayers(glyphId), TryGetBaseGlyphRecord, TryGetBaseGlyphV1Record, TryGetClipBox, and the absolute paint-offset resolver used by the v1 paint parser.
  • CpalTable — CPAL palette reader with TryGetColor(paletteIndex, entryIndex, out Color).
  • Paint / PaintParser / PaintResolver / PaintTraverser — the COLR v1 paint graph. Parsing produces a Paint tree from the raw COLR data; the resolver applies variations from the ItemVariationStore; the traverser walks the resolved tree calling into an IColorPainter.
  • ColorGlyphV1PainterIColorPainter implementation that emits DrawingContext calls: solid fills, linear/radial/sweep gradients (via GradientBrushHelper), composite/blend modes (CompositeMode), transforms, and clip pushes.
  • PaintDecycler — pooled Decycler<ushort> (from the infra PR) that guards against cycles and a depth limit in PaintColrLayers / PaintColrGlyph references.
  • DeltaSet / DeltaSetIndexMap / ItemVariationStore — OpenType variation-store parsing used by COLR v1 variable records.

Notes for reviewers:

  • COLR v1 paint graphs operate in font-space (Y-up). The Draw method on ColorGlyphV1Drawing pushes a Matrix.CreateScale(1, -1) * Matrix.CreateTranslation(origin) before traversing the paint graph, so the painter itself does not need to think about Y direction.
  • COLR v0 (ColorGlyphDrawing) is implemented in terms of GlyphTypeface.GetGlyphOutline — each layer record is just a glyph ID + palette index, and the layer is drawn as a SolidColorBrush fill over that outline. This is why this PR has a hard dependency on the GetGlyphOutline PR.
  • _colrTable and _cpalTable are loaded once in the GlyphTypeface constructor when the head table is present, alongside the glyf load from the previous PR.

Checklist

Breaking changes

None. New public API; existing behavior is unchanged.

Obsoletions / Deprecations

None.

Fixed issues

Gillibald added 3 commits May 21, 2026 10:38
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.
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 DrawingContext painter.
  • Added GlyphTypeface.GetGlyphDrawing and 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 == GlyphCount as out of range (valid IDs are 0..GlyphCount-1). Use glyphId >= GlyphCount to 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];

@Gillibald Gillibald changed the title [WIP] [Text] Add support for color fonts [WIP] [Text] Add support for color glyph drawing May 21, 2026
@avaloniaui-bot
Copy link
Copy Markdown

You can test this PR using the following package version. 12.1.999-cibuild0065667-alpha. (feed url: https://nuget-feed-all.avaloniaui.net/v3/index.json) [PRBUILDID]

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants