Skip to content

Commit d2cf42a

Browse files
sebgodclaude
andcommitted
Add COLRv1 paint tree renderer, switch to SharpAstro.FreeType 3.2.13
COLRv1 color emoji rendering via paint tree walking: - PaintColrLayers, PaintGlyph, PaintSolid, PaintColrGlyph, PaintComposite - Radial gradient with color stop interpolation and coordinate scaling - Affine transforms via System.Numerics.Matrix3x2 (compose, inverse for gradients) - Tested with Noto COLRv1 emoji and BabelStone Xiangqi COLR v0 Also: RGBAColor32.Lerp/WithAlpha, RgbaImage.BlendPixelAt, 30 tests total. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent c23c921 commit d2cf42a

16 files changed

Lines changed: 554 additions & 6 deletions

.github/workflows/dotnet.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ on:
77
branches: [ main ]
88

99
env:
10-
VERSION_PREFIX: 1.2.${{ github.run_number }}
10+
VERSION_PREFIX: 1.3.${{ github.run_number }}
1111
VERSION_REV: ${{ github.run_attempt }}
1212
VERSION_HASH: +${{ github.sha }}
1313
BUILD_CONF: Release
93.9 KB
Binary file not shown.
0 Bytes
Binary file not shown.
125 KB
Binary file not shown.

src/DIR.Lib.Tests/Fonts/Merida.ttf

41.6 KB
Binary file not shown.
4.76 MB
Binary file not shown.
10.2 MB
Binary file not shown.
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
using Shouldly;
2+
3+
namespace DIR.Lib.Tests;
4+
5+
public class RGBAColor32Tests
6+
{
7+
[Fact]
8+
public void Lerp_AtZero_ReturnsFirst()
9+
{
10+
var a = new RGBAColor32(255, 0, 0, 255);
11+
var b = new RGBAColor32(0, 0, 255, 255);
12+
RGBAColor32.Lerp(a, b, 0f).ShouldBe(a);
13+
}
14+
15+
[Fact]
16+
public void Lerp_AtOne_ReturnsSecond()
17+
{
18+
var a = new RGBAColor32(255, 0, 0, 255);
19+
var b = new RGBAColor32(0, 0, 255, 255);
20+
RGBAColor32.Lerp(a, b, 1f).ShouldBe(b);
21+
}
22+
23+
[Fact]
24+
public void Lerp_AtHalf_ReturnsMidpoint()
25+
{
26+
var a = new RGBAColor32(0, 0, 0, 255);
27+
var b = new RGBAColor32(200, 100, 50, 255);
28+
var mid = RGBAColor32.Lerp(a, b, 0.5f);
29+
mid.Red.ShouldBe((byte)100);
30+
mid.Green.ShouldBe((byte)50);
31+
mid.Blue.ShouldBe((byte)25);
32+
mid.Alpha.ShouldBe((byte)255);
33+
}
34+
35+
[Fact]
36+
public void WithAlpha_FullMask_PreservesAlpha()
37+
{
38+
var color = new RGBAColor32(255, 0, 0, 200);
39+
var result = color.WithAlpha(255);
40+
result.Alpha.ShouldBe((byte)200);
41+
result.Red.ShouldBe((byte)255);
42+
}
43+
44+
[Fact]
45+
public void WithAlpha_HalfMask_HalvesAlpha()
46+
{
47+
var color = new RGBAColor32(255, 0, 0, 255);
48+
var result = color.WithAlpha(128);
49+
result.Alpha.ShouldBeInRange((byte)127, (byte)129);
50+
}
51+
52+
[Fact]
53+
public void WithAlpha_ZeroMask_ReturnsZeroAlpha()
54+
{
55+
var color = new RGBAColor32(255, 0, 0, 255);
56+
color.WithAlpha(0).Alpha.ShouldBe((byte)0);
57+
}
58+
59+
[Fact]
60+
public void Luminance_White()
61+
{
62+
new RGBAColor32(255, 255, 255, 255).Luminance.ShouldBe((byte)255);
63+
}
64+
65+
[Fact]
66+
public void Luminance_Black()
67+
{
68+
new RGBAColor32(0, 0, 0, 255).Luminance.ShouldBe((byte)0);
69+
}
70+
}

src/DIR.Lib.Tests/RenderAcceptanceTests.cs

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,51 @@ public void ColorGlyph_IsColored_Flag()
100100
glyph.IsColored.ShouldBeTrue("Xiangqi glyph should be a color glyph");
101101
}
102102

103+
[Fact]
104+
public void RenderColorGlyphs_NotoEmoji()
105+
{
106+
var emojiFont = Path.Combine(AppContext.BaseDirectory, "Fonts", "Noto-COLRv1.ttf");
107+
if (!File.Exists(emojiFont))
108+
return;
109+
110+
var img = CreateGridImage(300, 80, gridSpacing: 20);
111+
112+
// 😀🎉🌍🔥 — common emoji
113+
RenderColorText(img, "\U0001F600\U0001F389\U0001F30D\U0001F525", emojiFont, 48f, 10, 5);
114+
115+
CompareBaseline(img, "color_noto_emoji.bmp");
116+
}
117+
118+
[Fact]
119+
public void ColorGlyph_NotoEmoji_IsColored()
120+
{
121+
var emojiFont = Path.Combine(AppContext.BaseDirectory, "Fonts", "Noto-COLRv1.ttf");
122+
if (!File.Exists(emojiFont))
123+
return;
124+
125+
var glyph = _rasterizer.RasterizeGlyph(emojiFont, 48f, new Rune(0x1F600)); // 😀
126+
glyph.Width.ShouldBeGreaterThan(0);
127+
glyph.IsColored.ShouldBeTrue("Noto COLRv1 emoji should be a color glyph");
128+
}
129+
130+
[Fact]
131+
public void RenderText_ChessPieces()
132+
{
133+
var meridaFont = Path.Combine(AppContext.BaseDirectory, "Fonts", "Merida.ttf");
134+
if (!File.Exists(meridaFont))
135+
return;
136+
137+
var img = CreateGridImage(400, 80, gridSpacing: 20);
138+
139+
// Chess pieces: ♔♕♖♗♘♙ (white) ♚♛♜♝♞♟ (black)
140+
RenderText(img, "\u2654\u2655\u2656\u2657\u2658\u2659", meridaFont, 48f,
141+
new RGBAColor32(255, 255, 255, 255), 10, 5);
142+
RenderText(img, "\u265A\u265B\u265C\u265D\u265E\u265F", meridaFont, 48f,
143+
new RGBAColor32(40, 40, 40, 255), 10, 40);
144+
145+
CompareBaseline(img, "text_chess_pieces.bmp");
146+
}
147+
103148
[Fact]
104149
public void GrayscaleGlyph_IsNotColored()
105150
{

src/DIR.Lib.Tests/RgbaImageTests.cs

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,42 @@ public void BlitRgba_TransparentPixelsPreserveBackground()
130130
GetPixel(img, 1, 0).ShouldBe(bg); // unchanged
131131
}
132132

133+
[Fact]
134+
public void BlendPixelAt_OpaqueOnBlack()
135+
{
136+
var img = new RgbaImage(4, 4);
137+
img.Clear(new RGBAColor32(0, 0, 0, 255));
138+
var red = new RGBAColor32(255, 0, 0, 255);
139+
140+
img.BlendPixelAt(1, 1, red);
141+
142+
GetPixel(img, 1, 1).ShouldBe(red);
143+
}
144+
145+
[Fact]
146+
public void BlendPixelAt_SemiTransparent()
147+
{
148+
var img = new RgbaImage(2, 2);
149+
img.Clear(new RGBAColor32(0, 0, 255, 255)); // blue
150+
var semiRed = new RGBAColor32(255, 0, 0, 128);
151+
152+
img.BlendPixelAt(0, 0, semiRed);
153+
154+
var pixel = GetPixel(img, 0, 0);
155+
pixel.Red.ShouldBeGreaterThan((byte)0);
156+
pixel.Blue.ShouldBeGreaterThan((byte)0);
157+
pixel.Alpha.ShouldBe((byte)255);
158+
}
159+
160+
[Fact]
161+
public void BlendPixelAt_OutOfBounds_NoThrow()
162+
{
163+
var img = new RgbaImage(2, 2);
164+
img.BlendPixelAt(-1, 0, new RGBAColor32(255, 0, 0, 255));
165+
img.BlendPixelAt(0, 5, new RGBAColor32(255, 0, 0, 255));
166+
// Should not throw
167+
}
168+
133169
[Fact]
134170
public void Resize_AllocatesNewBuffer()
135171
{

0 commit comments

Comments
 (0)