A cross-platform UI framework for .NET
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

330 lines
12 KiB

using System;
using System.Collections.Generic;
using System.Reflection;
using Avalonia.Media;
using Avalonia.Media.Fonts.Tables;
using Avalonia.Media.Fonts.Tables.Glyf;
using Avalonia.Platform;
using Xunit;
namespace Avalonia.Base.UnitTests.Media.Fonts.Tables
{
/// <summary>
/// Exercises the composite point-matching path (ARGS_ARE_XY_VALUES clear) using a hand-built
/// 'glyf'/'loca' blob. Real-world fonts that use point matching are rare, so the synthetic
/// fixture is the only reliable way to cover this branch.
/// </summary>
public class GlyfTablePointMatchingTests
{
// Glyph layout in the synthetic font:
// glyph 0: 100x100 square at the origin -> the "base"
// glyph 1: 10x10 square at the origin -> the point-matched "accent"
// glyph 2: composite { base by x/y offset (0,0), accent by point matching }
private const int BaseGlyph = 0;
private const int AccentGlyph = 1;
private const int CompositeGlyph = 2;
[Fact]
public void PointMatchedComposite_Aligns_Component_Point_Onto_Parent_Point()
{
// Match the accent's point 0 (its local origin) onto the base's point 2 (100, 100).
var glyf = BuildGlyf(parentPoint: 2, componentPoint: 0);
var context = new FigureRecordingContext();
Assert.True(glyf.TryBuildGlyphGeometry(CompositeGlyph, Matrix.Identity, context));
// One contour from the base, one from the accent.
Assert.Equal(2, context.Figures.Count);
Assert.True(context.AllClosed);
// The base contour is emitted first, unmoved.
Assert.True(Contains(context.Figures[0], 0, 0));
Assert.True(Contains(context.Figures[0], 100, 100));
// The accent contour is translated by (parentPoint - componentPoint) = (100,100) - (0,0).
// Its figure therefore starts where the matched parent point is.
Assert.True(Close(context.Figures[1][0], 100, 100));
// The translated accent reaches (110, 110); those coordinates are impossible for the
// base (which spans 0..100) so they prove the component actually moved.
Assert.True(Contains(context.AllPoints, 110, 110));
Assert.True(Contains(context.AllPoints, 110, 100));
Assert.True(Contains(context.AllPoints, 100, 110));
// If point matching were (incorrectly) treated as a zero offset, the accent would stay
// at the origin and its far corner (10, 10) would appear instead.
Assert.False(Contains(context.AllPoints, 10, 10));
}
[Fact]
public void PointMatchedComposite_Matching_Different_Parent_Point_Moves_Accent_There()
{
// Match the accent's origin onto the base's point 1 (100, 0) instead.
var glyf = BuildGlyf(parentPoint: 1, componentPoint: 0);
var context = new FigureRecordingContext();
Assert.True(glyf.TryBuildGlyphGeometry(CompositeGlyph, Matrix.Identity, context));
Assert.Equal(2, context.Figures.Count);
Assert.True(Close(context.Figures[1][0], 100, 0));
Assert.True(Contains(context.AllPoints, 110, 10));
}
[Fact]
public void PointMatchedComposite_Out_Of_Range_Component_Point_Bails_Out()
{
// The accent only has 4 points (0..3); point 99 is out of range.
var glyf = BuildGlyf(parentPoint: 2, componentPoint: 99);
var context = new FigureRecordingContext();
Assert.False(glyf.TryBuildGlyphGeometry(CompositeGlyph, Matrix.Identity, context));
// Nothing is emitted: the materialised outline is discarded before reaching the context.
Assert.Empty(context.Figures);
}
[Fact]
public void PointMatchedComposite_Out_Of_Range_Parent_Point_Bails_Out()
{
// The base contributes 4 parent points (0..3); point 50 is out of range.
var glyf = BuildGlyf(parentPoint: 50, componentPoint: 0);
var context = new FigureRecordingContext();
Assert.False(glyf.TryBuildGlyphGeometry(CompositeGlyph, Matrix.Identity, context));
Assert.Empty(context.Figures);
}
// --- synthetic font construction -------------------------------------------------------
private static GlyfTable BuildGlyf(byte parentPoint, byte componentPoint)
{
var glyph0 = PadToEven(BuildSimpleSquare(100));
var glyph1 = PadToEven(BuildSimpleSquare(10));
var glyph2 = PadToEven(BuildPointMatchedComposite(parentPoint, componentPoint));
var glyf = new List<byte>();
glyf.AddRange(glyph0);
glyf.AddRange(glyph1);
glyf.AddRange(glyph2);
// Short 'loca' stores offset / 2 (which is why glyphs are padded to an even length).
var offsets = new[]
{
0,
glyph0.Length,
glyph0.Length + glyph1.Length,
glyph0.Length + glyph1.Length + glyph2.Length
};
var loca = new List<byte>();
foreach (var offset in offsets)
{
WriteU16(loca, (ushort)(offset / 2));
}
return CreateGlyfTable(glyf.ToArray(), loca.ToArray(), glyphCount: 3);
}
/// <summary>
/// A simple, single-contour square with on-curve corners (0,0), (size,0), (size,size),
/// (0,size), encoded with int16 coordinate deltas.
/// </summary>
private static byte[] BuildSimpleSquare(short size)
{
var data = new List<byte>();
// Glyph header.
WriteI16(data, 1); // numberOfContours (> 0 => simple)
WriteI16(data, 0); // xMin
WriteI16(data, 0); // yMin
WriteI16(data, size); // xMax
WriteI16(data, size); // yMax
// Simple glyph body.
WriteU16(data, 3); // endPtsOfContours[0] => 4 points
WriteU16(data, 0); // instructionLength
// Flags: 4 on-curve points, coordinates encoded as int16 deltas
// (XShortVector/YShortVector clear, *IsSame* clear).
for (var i = 0; i < 4; i++)
{
data.Add((byte)GlyphFlag.OnCurvePoint);
}
// X deltas: 0, +size, 0, -size.
WriteI16(data, 0);
WriteI16(data, size);
WriteI16(data, 0);
WriteI16(data, (short)-size);
// Y deltas: 0, 0, +size, 0.
WriteI16(data, 0);
WriteI16(data, 0);
WriteI16(data, size);
WriteI16(data, 0);
return data.ToArray();
}
/// <summary>
/// A composite glyph with two components: the base placed by an x/y offset of (0,0), and the
/// accent placed by point matching (ARGS_ARE_XY_VALUES clear).
/// </summary>
private static byte[] BuildPointMatchedComposite(byte parentPoint, byte componentPoint)
{
var data = new List<byte>();
// Glyph header (numberOfContours < 0 => composite).
WriteI16(data, -1);
WriteI16(data, 0);
WriteI16(data, 0);
WriteI16(data, 110);
WriteI16(data, 110);
// Component 0: the base, placed by x/y offset (0,0). Byte args.
WriteU16(data, (ushort)(CompositeFlags.ArgsAreXYValues | CompositeFlags.MoreComponents));
WriteU16(data, BaseGlyph);
data.Add(0); // arg1 = x offset 0
data.Add(0); // arg2 = y offset 0
// Component 1: the accent, placed by point matching. Byte args, no more components.
WriteU16(data, 0); // flags: ArgsAreWords clear, ArgsAreXYValues clear, MoreComponents clear
WriteU16(data, AccentGlyph);
data.Add(parentPoint); // arg1 = point of the already-assembled glyph
data.Add(componentPoint); // arg2 = point of this component
return data.ToArray();
}
private static byte[] PadToEven(byte[] data)
{
if ((data.Length & 1) == 0)
{
return data;
}
var padded = new byte[data.Length + 1];
Array.Copy(data, padded, data.Length);
return padded;
}
private static void WriteU16(List<byte> data, ushort value)
{
data.Add((byte)(value >> 8));
data.Add((byte)(value & 0xFF));
}
private static void WriteI16(List<byte> data, short value) => WriteU16(data, (ushort)value);
/// <summary>
/// Builds a <see cref="GlyfTable"/> directly from raw 'glyf'/'loca' bytes via its internal
/// constructors, bypassing the full font-load path (which would require a complete TTF).
/// </summary>
private static GlyfTable CreateGlyfTable(byte[] glyfData, byte[] locaData, int glyphCount)
{
var locaCtor = typeof(LocaTable).GetConstructor(
BindingFlags.Instance | BindingFlags.NonPublic,
binder: null,
new[] { typeof(ReadOnlyMemory<byte>), typeof(int), typeof(bool) },
modifiers: null);
Assert.NotNull(locaCtor);
var loca = locaCtor!.Invoke(new object[] { (ReadOnlyMemory<byte>)locaData, glyphCount, /* isShortFormat */ true });
var glyfCtor = typeof(GlyfTable).GetConstructor(
BindingFlags.Instance | BindingFlags.NonPublic,
binder: null,
new[] { typeof(ReadOnlyMemory<byte>), typeof(LocaTable) },
modifiers: null);
Assert.NotNull(glyfCtor);
return (GlyfTable)glyfCtor!.Invoke(new[] { (ReadOnlyMemory<byte>)glyfData, loca! });
}
private static bool Close(Point point, double x, double y)
=> Math.Abs(point.X - x) < 0.001 && Math.Abs(point.Y - y) < 0.001;
private static bool Contains(IEnumerable<Point> points, double x, double y)
{
foreach (var point in points)
{
if (Close(point, x, y))
{
return true;
}
}
return false;
}
private sealed class FigureRecordingContext : IGeometryContext
{
private List<Point>? _current;
public List<List<Point>> Figures { get; } = new();
public List<Point> AllPoints { get; } = new();
public bool AllClosed { get; private set; } = true;
public void BeginFigure(Point startPoint, bool isFilled = true)
{
_current = new List<Point> { startPoint };
Figures.Add(_current);
AllPoints.Add(startPoint);
}
public void LineTo(Point point, bool isStroked = true)
{
_current?.Add(point);
AllPoints.Add(point);
}
public void QuadraticBezierTo(Point controlPoint, Point endPoint, bool isStroked = true)
{
_current?.Add(controlPoint);
_current?.Add(endPoint);
AllPoints.Add(controlPoint);
AllPoints.Add(endPoint);
}
public void CubicBezierTo(Point controlPoint1, Point controlPoint2, Point endPoint, bool isStroked = true)
{
_current?.Add(controlPoint1);
_current?.Add(controlPoint2);
_current?.Add(endPoint);
AllPoints.Add(controlPoint1);
AllPoints.Add(controlPoint2);
AllPoints.Add(endPoint);
}
public void ArcTo(Point point, Size size, double rotationAngle, bool isLargeArc,
SweepDirection sweepDirection, bool isStroked = true)
{
_current?.Add(point);
AllPoints.Add(point);
}
public void EndFigure(bool isClosed)
{
if (!isClosed)
{
AllClosed = false;
}
_current = null;
}
public void SetFillRule(FillRule fillRule) { }
public void Dispose() { }
}
}
}