diff --git a/src/SixLabors.Fonts/BigEndianBinaryReader.cs b/src/SixLabors.Fonts/BigEndianBinaryReader.cs
index b880498ee..4d62a9f43 100644
--- a/src/SixLabors.Fonts/BigEndianBinaryReader.cs
+++ b/src/SixLabors.Fonts/BigEndianBinaryReader.cs
@@ -9,52 +9,86 @@
namespace SixLabors.Fonts;
///
+///
/// A binary reader that reads in big-endian format.
+///
+///
+/// This reader captures the stream position at construction time as startOfStream.
+/// All offset values read from OpenType tables (via ,
+/// , etc.) are raw values relative to wherever the spec says
+/// they originate (typically the start of the containing table).
+///
+///
+/// When seeking with using , the
+/// startOfStream is automatically added to the supplied offset. This means
+/// table-relative offsets can be passed directly to without manually
+/// adding the table's absolute position. Do not add the table start
+/// yourself — that would double-count and land at the wrong position.
+///
+///
+/// In contrast, . always returns the
+/// absolute position within the underlying stream and is unaffected by
+/// startOfStream.
+///
///
[DebuggerDisplay("Start: {StartOfStream}, Position: {BaseStream.Position}")]
internal sealed class BigEndianBinaryReader : IDisposable
{
///
- /// Buffer used for temporary storage before conversion into primitives
+ /// Buffer used for temporary storage before conversion into primitives.
///
private readonly byte[] buffer = new byte[16];
-
- private readonly long startOfStream;
private readonly bool leaveOpen;
///
/// Initializes a new instance of the class.
- /// Constructs a new binary reader with the given bit converter, reading
- /// to the given stream, using the given encoding.
+ /// The current position of is captured as startOfStream
+ /// and used as the origin for all subsequent calls with
+ /// .
///
- /// Stream to read data from
- /// if set to true [leave open].
+ /// Stream to read data from.
+ /// If , the stream is not disposed when this reader is disposed.
public BigEndianBinaryReader(Stream stream, bool leaveOpen)
{
this.BaseStream = stream;
- this.startOfStream = stream.Position;
+ this.StartOfStream = stream.Position;
this.leaveOpen = leaveOpen;
}
///
/// Gets the underlying stream of the EndianBinaryReader.
+ /// Note that on this stream is always the
+ /// absolute position and is not adjusted by
+ /// startOfStream. Avoid using BaseStream.Position to compute
+ /// offsets for — use raw offsets from ,
+ /// , etc. instead.
///
public Stream BaseStream { get; }
+ ///
+ /// Gets the absolute stream position captured at construction time.
+ /// This is the origin for all seeks.
+ ///
+ public long StartOfStream { get; }
+
///
/// Seeks within the stream.
+ /// When is , startOfStream
+ /// is automatically added to , so callers should pass
+ /// table-relative offsets directly (e.g. values read from
+ /// or ). Do not add the table's absolute
+ /// position — that would double-count.
///
- /// Offset to seek to.
- /// Origin of seek operation. If SeekOrigin.Begin, the offset will be set to the start of stream position.
+ /// Offset to seek to, relative to .
+ /// Origin of seek operation.
public void Seek(long offset, SeekOrigin origin)
{
- // If SeekOrigin.Begin, the offset will be set to the start of stream position.
if (origin == SeekOrigin.Begin)
{
- offset += this.startOfStream;
+ offset += this.StartOfStream;
}
- this.BaseStream.Seek(offset, origin);
+ _ = this.BaseStream.Seek(offset, origin);
}
///
@@ -67,10 +101,15 @@ public byte ReadByte()
return this.buffer[0];
}
+ ///
+ /// Reads a single byte from the stream and reinterprets it as the specified enum type.
+ ///
+ /// The enum type whose underlying type must be a single byte.
+ /// The enum value.
public TEnum ReadByte()
where TEnum : struct, Enum
{
- TryConvert(this.ReadByte(), out TEnum value);
+ _ = TryConvert(this.ReadByte(), out TEnum value);
return value;
}
@@ -84,6 +123,11 @@ public sbyte ReadSByte()
return unchecked((sbyte)this.buffer[0]);
}
+ ///
+ /// Reads a 2.14 fixed-point number from the stream.
+ /// 2 bytes are read and divided by 16384 to produce a value in the range [-2, +2).
+ ///
+ /// The fixed-point value as a .
public float ReadF2Dot14()
{
const float f2Dot14ToFloat = 16384F;
@@ -102,10 +146,15 @@ public short ReadInt16()
return BinaryPrimitives.ReadInt16BigEndian(this.buffer);
}
+ ///
+ /// Reads a 16-bit integer from the stream and reinterprets it as the specified enum type.
+ ///
+ /// The enum type whose underlying type must be 16 bits.
+ /// The enum value.
public TEnum ReadInt16()
where TEnum : struct, Enum
{
- TryConvert(this.ReadUInt16(), out TEnum value);
+ _ = TryConvert(this.ReadUInt16(), out TEnum value);
return value;
}
@@ -115,6 +164,11 @@ public TEnum ReadInt16()
/// A 16-bit signed integer read from the stream, interpreted as an FWORD value.
public short ReadFWORD() => this.ReadInt16();
+ ///
+ /// Reads an array of FWORD (signed 16-bit) values from the stream.
+ ///
+ /// The number of values to read.
+ /// An array of 16-bit signed integers.
public short[] ReadFWORDArray(int length) => this.ReadInt16Array(length);
///
@@ -171,25 +225,31 @@ public ushort ReadUInt16()
///
/// Reads a 16-bit unsigned integer from the stream representing an offset position.
- /// 2 bytes are read.
+ /// 2 bytes are read. The returned value is the raw offset as stored in the font file
+ /// (typically relative to the start of the containing table). Pass it directly to
+ /// with — do not add the table's
+ /// absolute position.
///
/// The 16-bit unsigned integer read.
public ushort ReadOffset16() => this.ReadUInt16();
+ ///
+ /// Reads a 16-bit unsigned integer from the stream and reinterprets it as the specified enum type.
+ ///
+ /// The enum type whose underlying type must be 16 bits.
+ /// The enum value.
public TEnum ReadUInt16()
where TEnum : struct, Enum
{
- TryConvert(this.ReadUInt16(), out TEnum value);
+ _ = TryConvert(this.ReadUInt16(), out TEnum value);
return value;
}
///
- /// Reads array of 16-bit unsigned integers from the stream.
+ /// Reads an array of 16-bit unsigned integers from the stream.
///
- /// The length.
- ///
- /// The 16-bit unsigned integer read.
- ///
+ /// The number of values to read.
+ /// An array of 16-bit unsigned integers.
public ushort[] ReadUInt16Array(int length)
{
ushort[] data = new ushort[length];
@@ -214,12 +274,10 @@ public void ReadUInt16Array(Span buffer)
}
///
- /// Reads array or 32-bit unsigned integers from the stream.
+ /// Reads an array of 32-bit unsigned integers from the stream.
///
- /// The length.
- ///
- /// The 32-bit unsigned integer read.
- ///
+ /// The number of values to read.
+ /// An array of 32-bit unsigned integers.
public uint[] ReadUInt32Array(int length)
{
uint[] data = new uint[length];
@@ -231,6 +289,11 @@ public uint[] ReadUInt32Array(int length)
return data;
}
+ ///
+ /// Reads an array of 8-bit unsigned integers (bytes) from the stream.
+ ///
+ /// The number of bytes to read.
+ /// A byte array of the requested length.
public byte[] ReadUInt8Array(int length)
{
byte[] data = new byte[length];
@@ -241,12 +304,10 @@ public byte[] ReadUInt8Array(int length)
}
///
- /// Reads array of 16-bit unsigned integers from the stream.
+ /// Reads an array of 16-bit signed integers from the stream.
///
- /// The length.
- ///
- /// The 16-bit signed integer read.
- ///
+ /// The number of values to read.
+ /// An array of 16-bit signed integers.
public short[] ReadInt16Array(int length)
{
short[] data = new short[length];
@@ -292,6 +353,14 @@ public uint ReadUInt24()
return (uint)((highByte << 16) | this.ReadUInt16());
}
+ ///
+ /// Reads a 24-bit unsigned integer from the stream representing an offset position.
+ /// 3 bytes are read. The returned value is the raw offset as stored in the font file
+ /// (typically relative to the start of the containing table). Pass it directly to
+ /// with — do not add the table's
+ /// absolute position.
+ ///
+ /// The 24-bit unsigned integer read.
public uint ReadOffset24() => this.ReadUInt24();
///
@@ -308,7 +377,10 @@ public uint ReadUInt32()
///
/// Reads a 32-bit unsigned integer from the stream representing an offset position.
- /// 4 bytes are read.
+ /// 4 bytes are read. The returned value is the raw offset as stored in the font file
+ /// (typically relative to the start of the containing table). Pass it directly to
+ /// with — do not add the table's
+ /// absolute position.
///
/// The 32-bit unsigned integer read.
public uint ReadOffset32() => this.ReadUInt32();
@@ -360,9 +432,9 @@ public string ReadString(int bytesToRead, Encoding encoding)
}
///
- /// Reads the uint32 string.
+ /// Reads a 4-byte OpenType tag from the stream as a UTF-8 string.
///
- /// a 4 character long UTF8 encoded string.
+ /// A 4-character string representing the tag (e.g. "glyf", "GPOS").
public string ReadTag()
{
this.ReadInternal(this.buffer, 4);
@@ -371,11 +443,15 @@ public string ReadTag()
}
///
- /// Reads an offset consuming the given nuber of bytes.
+ /// Reads an offset consuming the given number of bytes (1–4).
+ /// The returned value is the raw offset as stored in the font file
+ /// (typically relative to the start of the containing table). Pass it directly to
+ /// with — do not add the table's
+ /// absolute position.
///
- /// The offset size in bytes.
+ /// The offset size in bytes (1, 2, 3, or 4).
/// The 32-bit signed integer representing the offset.
- /// Size is not in range.
+ /// Thrown when is not 1–4.
public int ReadOffset(int size)
=> size switch
{
@@ -409,6 +485,7 @@ private void ReadInternal(byte[] data, int size)
}
}
+ ///
public void Dispose()
{
if (!this.leaveOpen)
diff --git a/src/SixLabors.Fonts/Buffer{T}.cs b/src/SixLabors.Fonts/Buffer{T}.cs
index b9a0b1af1..d2ecfcc15 100644
--- a/src/SixLabors.Fonts/Buffer{T}.cs
+++ b/src/SixLabors.Fonts/Buffer{T}.cs
@@ -19,6 +19,11 @@ internal ref struct Buffer
private bool isDisposed;
public Buffer(int length)
+ : this(length, clear: false)
+ {
+ }
+
+ public Buffer(int length, bool clear)
{
Guard.MustBeGreaterThanOrEqualTo(length, 0, nameof(length));
int itemSizeBytes = Unsafe.SizeOf();
@@ -30,6 +35,11 @@ public Buffer(int length)
this.Memory = manager.Memory[..this.length];
this.span = this.Memory.Span;
+ if (clear)
+ {
+ this.span.Clear();
+ }
+
this.isDisposed = false;
}
diff --git a/src/SixLabors.Fonts/FileFontMetrics.cs b/src/SixLabors.Fonts/FileFontMetrics.cs
index 77f2c27fa..e32572d41 100644
--- a/src/SixLabors.Fonts/FileFontMetrics.cs
+++ b/src/SixLabors.Fonts/FileFontMetrics.cs
@@ -5,6 +5,7 @@
using System.Numerics;
using SixLabors.Fonts.Tables;
using SixLabors.Fonts.Tables.AdvancedTypographic;
+using SixLabors.Fonts.Tables.AdvancedTypographic.Variations;
using SixLabors.Fonts.Unicode;
namespace SixLabors.Fonts;
@@ -44,6 +45,11 @@ internal FileFontMetrics(FontDescription description, string path, long offset)
///
public string Path { get; }
+ ///
+ /// Gets the underlying that this file-backed instance delegates to.
+ ///
+ internal StreamFontMetrics StreamFontMetrics => this.fontMetrics.Value;
+
///
public override ushort UnitsPerEm => this.fontMetrics.Value.UnitsPerEm;
@@ -119,6 +125,10 @@ internal override bool TryGetGlyphClass(ushort glyphId, [NotNullWhen(true)] out
internal override bool TryGetMarkAttachmentClass(ushort glyphId, [NotNullWhen(true)] out GlyphClassDef? markAttachmentClass)
=> this.fontMetrics.Value.TryGetMarkAttachmentClass(glyphId, out markAttachmentClass);
+ ///
+ public override bool TryGetVariationAxes(out VariationAxis[]? variationAxes)
+ => this.fontMetrics.Value.TryGetVariationAxes(out variationAxes);
+
///
internal override bool IsInMarkFilteringSet(ushort markGlyphSetIndex, ushort glyphId)
=> this.fontMetrics.Value.IsInMarkFilteringSet(markGlyphSetIndex, glyphId);
@@ -163,6 +173,14 @@ internal override bool TryGetKerningOffset(ushort currentId, ushort nextId, out
internal override void UpdatePositions(GlyphPositioningCollection collection)
=> this.fontMetrics.Value.UpdatePositions(collection);
+ ///
+ internal override float GetGDefVariationDelta(uint packedVariationIndex)
+ => this.fontMetrics.Value.GetGDefVariationDelta(packedVariationIndex);
+
+ ///
+ internal override ReadOnlySpan GetNormalizedCoordinates()
+ => this.fontMetrics.Value.GetNormalizedCoordinates();
+
///
/// Reads a from the specified stream.
///
diff --git a/src/SixLabors.Fonts/Font.cs b/src/SixLabors.Fonts/Font.cs
index 570493043..477592ca7 100644
--- a/src/SixLabors.Fonts/Font.cs
+++ b/src/SixLabors.Fonts/Font.cs
@@ -13,6 +13,7 @@ namespace SixLabors.Fonts;
///
public sealed class Font
{
+ private readonly FontVariation[] variations;
private readonly Lazy metrics;
private readonly Lazy fontName;
@@ -42,6 +43,7 @@ public Font(FontFamily family, float size, FontStyle style)
this.Family = family;
this.RequestedStyle = style;
this.Size = size;
+ this.variations = [];
this.metrics = new Lazy(this.LoadInstanceInternal, true);
this.fontName = new Lazy(this.LoadFontName, true);
}
@@ -77,6 +79,24 @@ public Font(Font prototype, float size)
{
}
+ ///
+ /// Initializes a new instance of the class with the specified variation axis settings.
+ ///
+ /// The prototype font providing family, size, and style.
+ /// The variation axis settings to apply.
+ public Font(Font prototype, params FontVariation[] variations)
+ {
+ Guard.NotNull(prototype, nameof(prototype));
+ Guard.NotNull(variations, nameof(variations));
+
+ this.Family = prototype.Family;
+ this.RequestedStyle = prototype.RequestedStyle;
+ this.Size = prototype.Size;
+ this.variations = variations;
+ this.metrics = new Lazy(this.LoadInstanceInternal, true);
+ this.fontName = new Lazy(this.LoadFontName, true);
+ }
+
///
/// Gets the family.
///
@@ -108,6 +128,11 @@ public Font(Font prototype, float size)
///
public bool IsItalic => (this.FontMetrics.Description.Style & FontStyle.Italic) == FontStyle.Italic;
+ ///
+ /// Gets the variation axis settings applied to this font.
+ ///
+ public ReadOnlySpan Variations => this.variations;
+
///
/// Gets the requested style.
///
@@ -278,6 +303,33 @@ private string LoadFontName()
=> this.metrics.Value?.Description.FontName(this.Family.Culture) ?? string.Empty;
private FontMetrics? LoadInstanceInternal()
+ {
+ FontMetrics? metrics = this.ResolveBaseMetrics();
+ if (metrics is null)
+ {
+ return null;
+ }
+
+ // If variations are specified and the base metrics supports them, create a variation instance.
+ if (this.variations.Length > 0)
+ {
+ StreamFontMetrics? streamMetrics = metrics switch
+ {
+ StreamFontMetrics s => s,
+ FileFontMetrics f => f.StreamFontMetrics,
+ _ => null
+ };
+
+ if (streamMetrics is not null)
+ {
+ return streamMetrics.CreateVariationInstance(this.variations);
+ }
+ }
+
+ return metrics;
+ }
+
+ private FontMetrics? ResolveBaseMetrics()
{
if (this.Family.TryGetMetrics(this.RequestedStyle, out FontMetrics? metrics))
{
diff --git a/src/SixLabors.Fonts/FontFamily.cs b/src/SixLabors.Fonts/FontFamily.cs
index 6ccf655b5..59e8db439 100644
--- a/src/SixLabors.Fonts/FontFamily.cs
+++ b/src/SixLabors.Fonts/FontFamily.cs
@@ -94,6 +94,43 @@ public readonly Font CreateFont(float size, FontStyle style)
return new Font(this, size, style);
}
+ ///
+ /// Create a new instance of the for the named font family with regular styling
+ /// and the specified variation axis settings.
+ ///
+ /// The size of the font in PT units.
+ /// The variation axis settings to apply.
+ /// The new .
+ public readonly Font CreateFont(float size, params FontVariation[] variations)
+ {
+ if (this == default)
+ {
+ FontsThrowHelper.ThrowDefaultInstance();
+ }
+
+ Font baseFont = new(this, size);
+ return variations.Length > 0 ? new Font(baseFont, variations) : baseFont;
+ }
+
+ ///
+ /// Create a new instance of the for the named font family with the specified
+ /// style and variation axis settings.
+ ///
+ /// The size of the font in PT units.
+ /// The font style.
+ /// The variation axis settings to apply.
+ /// The new .
+ public readonly Font CreateFont(float size, FontStyle style, params FontVariation[] variations)
+ {
+ if (this == default)
+ {
+ FontsThrowHelper.ThrowDefaultInstance();
+ }
+
+ Font baseFont = new(this, size, style);
+ return variations.Length > 0 ? new Font(baseFont, variations) : baseFont;
+ }
+
///
/// Gets the collection of that are currently available.
///
diff --git a/src/SixLabors.Fonts/FontMetrics.cs b/src/SixLabors.Fonts/FontMetrics.cs
index 2e95e649c..85e897fe8 100644
--- a/src/SixLabors.Fonts/FontMetrics.cs
+++ b/src/SixLabors.Fonts/FontMetrics.cs
@@ -4,6 +4,7 @@
using System.Diagnostics.CodeAnalysis;
using System.Numerics;
using SixLabors.Fonts.Tables.AdvancedTypographic;
+using SixLabors.Fonts.Tables.AdvancedTypographic.Variations;
using SixLabors.Fonts.Unicode;
namespace SixLabors.Fonts;
@@ -172,6 +173,14 @@ internal FontMetrics()
/// true, if the mark attachment class could be retrieved.
internal abstract bool TryGetMarkAttachmentClass(ushort glyphId, [NotNullWhen(true)] out GlyphClassDef? markAttachmentClass);
+ ///
+ /// Tries to get the variation axes that this font supports.
+ /// The font needs to have a fvar table.
+ ///
+ /// An array with Variation axes.
+ /// True, if fvar table is present.
+ public abstract bool TryGetVariationAxes(out VariationAxis[]? variationAxes);
+
///
/// Returns a value indicating whether the specified glyph is in the given mark filtering set.
/// The font needs to have a GDEF table defined.
@@ -267,4 +276,23 @@ internal abstract GlyphMetrics GetGlyphMetrics(
///
/// The glyph positioning collection.
internal abstract void UpdatePositions(GlyphPositioningCollection collection);
+
+ ///
+ /// Computes a GPOS/GSUB variation delta for the given packed VariationIndex.
+ /// The delta is computed using the GDEF ItemVariationStore and the current
+ /// variation coordinates from the GlyphVariationProcessor.
+ ///
+ ///
+ /// The packed VariationIndex: (outerIndex << 16) | innerIndex.
+ /// A value of 0 returns 0.
+ ///
+ /// The delta value in design units, or 0 if no variation data is available.
+ internal abstract float GetGDefVariationDelta(uint packedVariationIndex);
+
+ ///
+ /// Gets the normalized variation coordinates for this font instance.
+ /// Returns an empty span for non-variable fonts or fonts at default coordinates.
+ ///
+ /// The normalized coordinates, or an empty span.
+ internal abstract ReadOnlySpan GetNormalizedCoordinates();
}
diff --git a/src/SixLabors.Fonts/FontReader.cs b/src/SixLabors.Fonts/FontReader.cs
index 2c27ef409..2e75ccbd5 100644
--- a/src/SixLabors.Fonts/FontReader.cs
+++ b/src/SixLabors.Fonts/FontReader.cs
@@ -141,18 +141,16 @@ public FontReader(Stream stream)
{
return (TTableType)table;
}
- else
+
+ TTableType? loadedTable = this.loader.Load(this);
+ if (loadedTable is null)
{
- TTableType? loadedTable = this.loader.Load(this);
- if (loadedTable is null)
- {
- return null;
- }
-
- table = loadedTable;
- this.loadedTables.Add(typeof(TTableType), loadedTable);
+ return null;
}
+ table = loadedTable;
+ this.loadedTables.Add(typeof(TTableType), loadedTable);
+
return (TTableType)table;
}
diff --git a/src/SixLabors.Fonts/FontVariation.cs b/src/SixLabors.Fonts/FontVariation.cs
new file mode 100644
index 000000000..4659a09c4
--- /dev/null
+++ b/src/SixLabors.Fonts/FontVariation.cs
@@ -0,0 +1,45 @@
+// Copyright (c) Six Labors.
+// Licensed under the Six Labors Split License.
+
+using System.Diagnostics;
+
+namespace SixLabors.Fonts;
+
+///
+/// Represents a single variation axis setting for a variable font,
+/// consisting of a four-character tag and a value.
+///
+///
+/// Follows CSS font-variation-settings semantics.
+/// Values are clamped to the axis range defined in the font's fvar table.
+///
+[DebuggerDisplay("Tag: {Tag}, Value: {Value}")]
+public readonly struct FontVariation
+{
+ ///
+ /// Initializes a new instance of the struct.
+ ///
+ /// The four-character axis tag (e.g. "wght", "wdth", "opsz").
+ /// The axis value in design-space units.
+ public FontVariation(string tag, float value)
+ {
+ Guard.NotNullOrWhiteSpace(tag, nameof(tag));
+ if (tag.Length != 4)
+ {
+ throw new ArgumentException("Variation axis tag must be exactly 4 characters.", nameof(tag));
+ }
+
+ this.Tag = tag;
+ this.Value = value;
+ }
+
+ ///
+ /// Gets the four-character axis tag identifying the design variation.
+ ///
+ public string Tag { get; }
+
+ ///
+ /// Gets the axis value in design-space units.
+ ///
+ public float Value { get; }
+}
diff --git a/src/SixLabors.Fonts/KnownVariationAxes.cs b/src/SixLabors.Fonts/KnownVariationAxes.cs
new file mode 100644
index 000000000..0a724b51d
--- /dev/null
+++ b/src/SixLabors.Fonts/KnownVariationAxes.cs
@@ -0,0 +1,48 @@
+// Copyright (c) Six Labors.
+// Licensed under the Six Labors Split License.
+
+namespace SixLabors.Fonts;
+
+///
+/// Defines the registered design-variation axis tags for variable fonts.
+/// These tags are used with to control font design axes.
+///
+///
+public static class KnownVariationAxes
+{
+ ///
+ /// Italic axis ('ital'). Controls the italic angle of the font.
+ /// Value range: 0 (upright) to 1 (italic).
+ ///
+ ///
+ public const string Italic = "ital";
+
+ ///
+ /// Optical size axis ('opsz'). Adjusts the design for a specific text size in points.
+ /// Typical range: 6 to 144. Larger values optimize for display use; smaller for body text.
+ ///
+ ///
+ public const string OpticalSize = "opsz";
+
+ ///
+ /// Slant axis ('slnt'). Controls the slant angle of upright glyphs in degrees.
+ /// Typical range: -90 to 90. Negative values slant to the right (the common direction).
+ ///
+ ///
+ public const string Slant = "slnt";
+
+ ///
+ /// Width axis ('wdth'). Controls the relative width of the font as a percentage of normal.
+ /// Typical range: 75 (condensed) to 125 (expanded). 100 represents the normal width.
+ ///
+ ///
+ public const string Width = "wdth";
+
+ ///
+ /// Weight axis ('wght'). Controls the weight (boldness) of the font.
+ /// Range: 1 to 1000. Common values: 100 (Thin), 300 (Light), 400 (Regular),
+ /// 700 (Bold), 900 (Black).
+ ///
+ ///
+ public const string Weight = "wght";
+}
diff --git a/src/SixLabors.Fonts/StreamFontMetrics.Cff.cs b/src/SixLabors.Fonts/StreamFontMetrics.Cff.cs
index 7348a2e6f..c37ef9f76 100644
--- a/src/SixLabors.Fonts/StreamFontMetrics.Cff.cs
+++ b/src/SixLabors.Fonts/StreamFontMetrics.Cff.cs
@@ -1,8 +1,10 @@
// Copyright (c) Six Labors.
// Licensed under the Six Labors Split License.
+using System.Numerics;
using SixLabors.Fonts.Rendering;
using SixLabors.Fonts.Tables.AdvancedTypographic;
+using SixLabors.Fonts.Tables.AdvancedTypographic.Variations;
using SixLabors.Fonts.Tables.Cff;
using SixLabors.Fonts.Tables.General;
using SixLabors.Fonts.Tables.General.Colr;
@@ -22,8 +24,8 @@ internal partial class StreamFontMetrics
private static StreamFontMetrics LoadCompactFont(FontReader reader)
{
// Load using recommended order for best performance.
- // https://www.microsoft.com/typography/otspec/recom.htm#TableOrdering
- // 'head', 'hhea', 'maxp', OS/2, 'name', 'cmap', 'post', 'CFF '
+ // https://learn.microsoft.com/en-gb/typography/opentype/spec/recom#optimized-table-ordering
+ // 'head', 'hhea', 'maxp', OS/2, 'name', 'cmap', 'post', 'CFF ' / 'CFF2'
HeadTable head = reader.GetTable();
HorizontalHeadTable hhea = reader.GetTable();
MaximumProfileTable maxp = reader.GetTable();
@@ -31,7 +33,6 @@ private static StreamFontMetrics LoadCompactFont(FontReader reader)
NameTable name = reader.GetTable();
CMapTable cmap = reader.GetTable();
PostTable post = reader.GetTable();
-
ICffTable? cff = reader.TryGetTable() ?? (ICffTable?)reader.TryGetTable();
// TODO: VORG
@@ -51,9 +52,28 @@ private static StreamFontMetrics LoadCompactFont(FontReader reader)
ColrTable? colr = reader.TryGetTable();
CpalTable? cpal = reader.TryGetTable();
-
SvgTable? svg = reader.TryGetTable();
+ // Variations related tables.
+ FVarTable? fVar = reader.TryGetTable();
+ AVarTable? aVar = reader.TryGetTable();
+ GVarTable? gVar = reader.TryGetTable();
+ HVarTable? hVar = reader.TryGetTable();
+ VVarTable? vVar = reader.TryGetTable();
+ MVarTable? mVar = reader.TryGetTable();
+
+ GlyphVariationProcessor? glyphVariationProcessor = null;
+ if (cff?.ItemVariationStore != null)
+ {
+ if (fVar is null)
+ {
+ throw new InvalidFontFileException("missing fvar table required for glyph variations processing");
+ }
+
+ // TODO: The docs say that hvar and vvar can be used for CFF fonts so how do we determine when to use them?
+ glyphVariationProcessor = new GlyphVariationProcessor(cff.ItemVariationStore, fVar, aVar, gVar, hVar, vVar, mVar);
+ }
+
CompactFontTables tables = new(cmap, head, hhea, htmx, maxp, name, os2, post, cff!)
{
Kern = kern,
@@ -64,10 +84,16 @@ private static StreamFontMetrics LoadCompactFont(FontReader reader)
GPos = gPos,
Colr = colr,
Cpal = cpal,
+ FVar = fVar,
+ AVar = aVar,
+ GVar = gVar,
+ HVar = hVar,
+ VVar = vVar,
+ MVar = mVar,
Svg = svg
};
- return new StreamFontMetrics(tables);
+ return new StreamFontMetrics(tables, glyphVariationProcessor);
}
private GlyphMetrics CreateCffGlyphMetrics(
@@ -85,12 +111,33 @@ private GlyphMetrics CreateCffGlyphMetrics(
ICffTable cff = tables.Cff;
HorizontalMetricsTable htmx = tables.Htmx;
VerticalMetricsTable? vtmx = tables.Vmtx;
+ FVarTable? fVar = tables.FVar;
+ AVarTable? aVar = tables.AVar;
+ GVarTable? gVar = tables.GVar;
CffGlyphData vector = cff.GetGlyph(glyphId);
+ vector.FVar = fVar;
+ vector.AVar = aVar;
+ vector.GVar = gVar;
Bounds bounds = vector.GetBounds();
+
+ // Apply the CFF FontMatrix to transform bounds from charstring space to design units.
+ if (vector.FontMatrix is double[] fm)
+ {
+ float upm = this.UnitsPerEm;
+ Vector2 fmScale = new((float)(fm[0] * upm), (float)(fm[3] * upm));
+ bounds = new Bounds(bounds.Min * fmScale, bounds.Max * fmScale);
+ }
+
ushort advanceWidth = htmx.GetAdvancedWidth(glyphId);
short lsb = htmx.GetLeftSideBearing(glyphId);
+ // Apply HVAR advance width adjustment if available.
+ if (this.GlyphVariationProcessor is not null)
+ {
+ advanceWidth = (ushort)(advanceWidth + MathF.Round(this.GlyphVariationProcessor.AdvanceAdjustment(glyphId)));
+ }
+
IMetricsHeader metrics = isVerticalLayout ? this.VerticalMetrics : this.HorizontalMetrics;
ushort advancedHeight = (ushort)(metrics.Ascender - metrics.Descender);
short tsb = (short)(metrics.Ascender - bounds.Max.Y);
@@ -100,6 +147,12 @@ private GlyphMetrics CreateCffGlyphMetrics(
tsb = vtmx.GetTopSideBearing(glyphId);
}
+ // Apply VVAR advance height adjustment if available.
+ if (this.GlyphVariationProcessor is not null)
+ {
+ advancedHeight = (ushort)(advancedHeight + MathF.Round(this.GlyphVariationProcessor.VerticalAdvanceAdjustment(glyphId)));
+ }
+
// TODO: Support CFF based COLR glyphs.
// This requires parsing the CFF charstrings to extract the glyph vectors.
SvgTable? svg = tables.Svg;
diff --git a/src/SixLabors.Fonts/StreamFontMetrics.TrueType.cs b/src/SixLabors.Fonts/StreamFontMetrics.TrueType.cs
index 43d166ad2..8d039bc4c 100644
--- a/src/SixLabors.Fonts/StreamFontMetrics.TrueType.cs
+++ b/src/SixLabors.Fonts/StreamFontMetrics.TrueType.cs
@@ -4,6 +4,7 @@
using System.Numerics;
using SixLabors.Fonts.Rendering;
using SixLabors.Fonts.Tables.AdvancedTypographic;
+using SixLabors.Fonts.Tables.AdvancedTypographic.Variations;
using SixLabors.Fonts.Tables.General;
using SixLabors.Fonts.Tables.General.Colr;
using SixLabors.Fonts.Tables.General.Kern;
@@ -67,7 +68,15 @@ internal void ApplyTrueTypeHinting(HintingMode hintingMode, GlyphMetrics metrics
CvtTable? cvt = tables.Cvt;
PrepTable? prep = tables.Prep;
float hintingScaleFactor = pixelSize / this.UnitsPerEm;
- interpreter.SetControlValueTable(cvt?.ControlValues, hintingScaleFactor, pixelSize, prep?.Instructions);
+
+ // Apply cvar deltas to CVT values for variable fonts before hinting.
+ short[]? cvtValues = cvt?.ControlValues;
+ if (cvtValues is not null && this.GlyphVariationProcessor is not null)
+ {
+ cvtValues = this.GlyphVariationProcessor.ApplyCvtDeltas(cvtValues) ?? cvtValues;
+ }
+
+ interpreter.SetControlValueTable(cvtValues, hintingScaleFactor, pixelSize, prep?.Instructions);
Bounds bounds = glyphVector.Bounds;
@@ -87,7 +96,7 @@ internal void ApplyTrueTypeHinting(HintingMode hintingMode, GlyphMetrics metrics
private static StreamFontMetrics LoadTrueTypeFont(FontReader reader)
{
// Load using recommended order for best performance.
- // https://www.microsoft.com/typography/otspec/recom.htm#TableOrdering
+ // https://learn.microsoft.com/en-gb/typography/opentype/spec/recom#optimized-table-ordering
// 'head', 'hhea', 'maxp', OS/2, 'hmtx', LTSH, VDMX, 'hdmx', 'cmap', 'fpgm', 'prep', 'cvt ', 'loca', 'glyf', 'kern', 'name', 'post', 'gasp', PCLT, DSIG
HeadTable head = reader.GetTable();
HorizontalHeadTable hhea = reader.GetTable();
@@ -115,6 +124,20 @@ private static StreamFontMetrics LoadTrueTypeFont(FontReader reader)
GSubTable? gSub = reader.TryGetTable();
GPosTable? gPos = reader.TryGetTable();
+ FVarTable? fvar = reader.TryGetTable();
+ AVarTable? avar = reader.TryGetTable();
+ GVarTable? gvar = reader.TryGetTable();
+ HVarTable? hvar = reader.TryGetTable();
+ VVarTable? vvar = reader.TryGetTable();
+ MVarTable? mvar = reader.TryGetTable();
+
+ // cvar depends on axisCount from fvar, so it cannot be auto-loaded via TryGetTable.
+ CVarTable? cvar = null;
+ if (fvar is not null)
+ {
+ cvar = CVarTable.Load(reader, fvar.AxisCount);
+ }
+
ColrTable? colr = reader.TryGetTable();
CpalTable? cpal = reader.TryGetTable();
@@ -133,10 +156,26 @@ private static StreamFontMetrics LoadTrueTypeFont(FontReader reader)
GPos = gPos,
Colr = colr,
Cpal = cpal,
- Svg = svg
+ Fvar = fvar,
+ Gvar = gvar,
+ Hvar = hvar,
+ Vvar = vvar,
+ Mvar = mvar,
+ Avar = avar,
+ Svg = svg,
+ Cvar = cvar
};
- return new StreamFontMetrics(tables);
+ GlyphVariationProcessor? glyphVariationProcessor = null;
+ if (fvar != null)
+ {
+ // Use the item variation store from HVAR or VVAR if available (for metrics variations).
+ // A variable font may have gvar without HVAR/VVAR (using phantom points for metrics instead).
+ ItemVariationStore? itemVariationStore = hvar?.ItemVariationStore ?? vvar?.ItemVariationStore;
+ glyphVariationProcessor = new GlyphVariationProcessor(itemVariationStore, fvar, avar, gvar, hvar, vvar, mvar, cvar);
+ }
+
+ return new StreamFontMetrics(tables, glyphVariationProcessor);
}
private GlyphMetrics CreateTrueTypeGlyphMetrics(
@@ -157,11 +196,25 @@ private GlyphMetrics CreateTrueTypeGlyphMetrics(
GlyphVector vector = glyf.GetGlyph(glyphId);
+ // Apply gvar deltas to the glyph outline if a variation processor is present.
+ // Clone first so we don't mutate the shared glyph cache.
+ if (this.GlyphVariationProcessor is not null)
+ {
+ vector = GlyphVector.DeepClone(vector);
+ this.GlyphVariationProcessor.TransformPoints(glyphId, ref vector);
+ }
+
Bounds bounds = vector.Bounds;
ushort advanceWidth = htmx.GetAdvancedWidth(glyphId);
short lsb = htmx.GetLeftSideBearing(glyphId);
+ // Apply HVAR advance width adjustment if available.
+ if (this.GlyphVariationProcessor is not null)
+ {
+ advanceWidth = (ushort)(advanceWidth + MathF.Round(this.GlyphVariationProcessor.AdvanceAdjustment(glyphId)));
+ }
+
IMetricsHeader metrics = isVerticalLayout ? this.VerticalMetrics : this.HorizontalMetrics;
ushort advancedHeight = (ushort)(metrics.Ascender - metrics.Descender);
short tsb = (short)(metrics.Ascender - bounds.Max.Y);
@@ -171,11 +224,17 @@ private GlyphMetrics CreateTrueTypeGlyphMetrics(
tsb = vtmx.GetTopSideBearing(glyphId);
}
+ // Apply VVAR advance height adjustment if available.
+ if (this.GlyphVariationProcessor is not null)
+ {
+ advancedHeight = (ushort)(advancedHeight + MathF.Round(this.GlyphVariationProcessor.VerticalAdvanceAdjustment(glyphId)));
+ }
+
ColrTable? colr = tables.Colr;
if ((colorSupport & ColorFontSupport.ColrV1) == ColorFontSupport.ColrV1 && colr?.ContainsColorV1Glyph(glyphId) == true)
{
CpalTable? cpal = tables.Cpal;
- ColrV1GlyphSource glyphSource = new(colr, cpal, i => glyf.GetGlyph(i));
+ ColrV1GlyphSource glyphSource = new(colr, cpal, i => glyf.GetGlyph(i), this.GlyphVariationProcessor);
return new PaintedGlyphMetrics(
this,
diff --git a/src/SixLabors.Fonts/StreamFontMetrics.cs b/src/SixLabors.Fonts/StreamFontMetrics.cs
index f65c8e75f..a5fcf3134 100644
--- a/src/SixLabors.Fonts/StreamFontMetrics.cs
+++ b/src/SixLabors.Fonts/StreamFontMetrics.cs
@@ -3,9 +3,11 @@
using System.Collections.Concurrent;
using System.Diagnostics.CodeAnalysis;
+using System.Globalization;
using System.Numerics;
using SixLabors.Fonts.Tables;
using SixLabors.Fonts.Tables.AdvancedTypographic;
+using SixLabors.Fonts.Tables.AdvancedTypographic.Variations;
using SixLabors.Fonts.Tables.Cff;
using SixLabors.Fonts.Tables.General;
using SixLabors.Fonts.Tables.General.Kern;
@@ -55,11 +57,13 @@ internal partial class StreamFontMetrics : FontMetrics
/// Initializes a new instance of the class.
///
/// The True Type font tables.
- internal StreamFontMetrics(TrueTypeFontTables tables)
+ /// An optional glyph variation processor for handling variable fonts.
+ internal StreamFontMetrics(TrueTypeFontTables tables, GlyphVariationProcessor? glyphVariationProcessor = null)
{
this.trueTypeFontTables = tables;
this.outlineType = OutlineType.TrueType;
this.description = new FontDescription(tables.Name, tables.Os2, tables.Head);
+ this.GlyphVariationProcessor = glyphVariationProcessor;
this.glyphIdCache = new();
this.codePointCache = new();
this.glyphCache = new();
@@ -75,11 +79,13 @@ internal StreamFontMetrics(TrueTypeFontTables tables)
/// Initializes a new instance of the class.
///
/// The Compact Font tables.
- internal StreamFontMetrics(CompactFontTables tables)
+ /// An optional glyph variation processor for handling variable fonts.
+ internal StreamFontMetrics(CompactFontTables tables, GlyphVariationProcessor? glyphVariationProcessor = null)
{
this.compactFontTables = tables;
this.outlineType = OutlineType.CFF;
this.description = new FontDescription(tables.Name, tables.Os2, tables.Head);
+ this.GlyphVariationProcessor = glyphVariationProcessor;
this.glyphIdCache = new();
this.codePointCache = new();
this.glyphCache = new();
@@ -89,8 +95,60 @@ internal StreamFontMetrics(CompactFontTables tables)
this.verticalMetrics = metrics.VerticalMetrics;
}
+ ///
+ /// Initializes a new instance of the class as a variation instance,
+ /// sharing variation-independent caches from the base instance.
+ /// Only the glyph cache (which depends on variation coordinates) is fresh.
+ ///
+ private StreamFontMetrics(
+ TrueTypeFontTables tables,
+ GlyphVariationProcessor processor,
+ ConcurrentDictionary<(int CodePoint, int NextCodePoint), (bool Success, ushort GlyphId, bool SkipNextCodePoint)> sharedGlyphIdCache,
+ ConcurrentDictionary sharedCodePointCache)
+ {
+ this.trueTypeFontTables = tables;
+ this.outlineType = OutlineType.TrueType;
+ this.description = new FontDescription(tables.Name, tables.Os2, tables.Head);
+ this.GlyphVariationProcessor = processor;
+ this.glyphIdCache = sharedGlyphIdCache;
+ this.codePointCache = sharedCodePointCache;
+ this.glyphCache = new();
+
+ (HorizontalMetrics HorizontalMetrics, VerticalMetrics VerticalMetrics) metrics = this.Initialize(tables);
+ this.horizontalMetrics = metrics.HorizontalMetrics;
+ this.verticalMetrics = metrics.VerticalMetrics;
+
+ this.interpreterPool = new ObjectPool(new TrueTypeInterpreterPooledObjectPolicy(this));
+ }
+
+ ///
+ /// Initializes a new instance of the class as a variation instance,
+ /// sharing variation-independent caches from the base instance.
+ /// Only the glyph cache (which depends on variation coordinates) is fresh.
+ ///
+ private StreamFontMetrics(
+ CompactFontTables tables,
+ GlyphVariationProcessor processor,
+ ConcurrentDictionary<(int CodePoint, int NextCodePoint), (bool Success, ushort GlyphId, bool SkipNextCodePoint)> sharedGlyphIdCache,
+ ConcurrentDictionary sharedCodePointCache)
+ {
+ this.compactFontTables = tables;
+ this.outlineType = OutlineType.CFF;
+ this.description = new FontDescription(tables.Name, tables.Os2, tables.Head);
+ this.GlyphVariationProcessor = processor;
+ this.glyphIdCache = sharedGlyphIdCache;
+ this.codePointCache = sharedCodePointCache;
+ this.glyphCache = new();
+
+ (HorizontalMetrics HorizontalMetrics, VerticalMetrics VerticalMetrics) metrics = this.Initialize(tables);
+ this.horizontalMetrics = metrics.HorizontalMetrics;
+ this.verticalMetrics = metrics.VerticalMetrics;
+ }
+
public HeadTable.HeadFlags HeadFlags { get; private set; }
+ public GlyphVariationProcessor? GlyphVariationProcessor { get; private set; }
+
///
public override FontDescription Description => this.description;
@@ -212,6 +270,36 @@ internal override bool TryGetMarkAttachmentClass(ushort glyphId, [NotNullWhen(tr
return gdef is not null && gdef.TryGetMarkAttachmentClass(glyphId, out markAttachmentClass);
}
+ ///
+ public override bool TryGetVariationAxes(out VariationAxis[]? variationAxes)
+ {
+ FVarTable? fvar = this.trueTypeFontTables?.Fvar ?? this.compactFontTables?.FVar;
+ Tables.General.Name.NameTable? names = this.trueTypeFontTables?.Name ?? this.compactFontTables?.Name;
+
+ if (fvar == null)
+ {
+ variationAxes = [];
+ return false;
+ }
+
+ variationAxes = new VariationAxis[fvar.Axes.Length];
+ for (int i = 0; i < fvar.Axes.Length; i++)
+ {
+ VariationAxisRecord axis = fvar.Axes[i];
+ string name = names != null ? names.GetNameById(CultureInfo.InvariantCulture, axis.AxisNameId) : string.Empty;
+ variationAxes[i] = new VariationAxis()
+ {
+ Tag = axis.Tag,
+ Min = axis.MinValue,
+ Max = axis.MaxValue,
+ Default = axis.DefaultValue,
+ Name = name
+ };
+ }
+
+ return true;
+ }
+
///
internal override bool IsInMarkFilteringSet(ushort markGlyphSetIndex, ushort glyphId)
{
@@ -344,6 +432,113 @@ internal override void UpdatePositions(GlyphPositioningCollection collection)
}
}
+ ///
+ internal override float GetGDefVariationDelta(uint packedVariationIndex)
+ {
+ if (packedVariationIndex == 0 || this.GlyphVariationProcessor is null)
+ {
+ return 0;
+ }
+
+ GlyphDefinitionTable? gdef = this.outlineType == OutlineType.TrueType
+ ? this.trueTypeFontTables!.Gdef
+ : this.compactFontTables!.Gdef;
+
+ if (gdef?.ItemVariationStore is null)
+ {
+ return 0;
+ }
+
+ // The packed index encodes two uint16 values:
+ // - Upper 16 bits: outer index (selects the ItemVariationData subtable)
+ // - Lower 16 bits: inner index (selects the DeltaSet within that subtable)
+ int outerIndex = (int)(packedVariationIndex >> 16);
+ int innerIndex = (int)(packedVariationIndex & 0xFFFF);
+ return this.GlyphVariationProcessor.Delta(gdef.ItemVariationStore, outerIndex, innerIndex);
+ }
+
+ ///
+ internal override ReadOnlySpan GetNormalizedCoordinates()
+ => this.GlyphVariationProcessor is not null
+ ? this.GlyphVariationProcessor.NormalizedCoordinates
+ : [];
+
+ ///
+ /// Creates a new instance that shares all immutable table data
+ /// with this instance but uses a new initialized
+ /// to the specified variation axis settings.
+ ///
+ /// The variation axis settings to apply.
+ /// A new configured for the requested variation.
+ internal StreamFontMetrics CreateVariationInstance(FontVariation[] variations)
+ {
+ FVarTable? fvar = this.outlineType == OutlineType.TrueType
+ ? this.trueTypeFontTables?.Fvar
+ : this.compactFontTables?.FVar;
+
+ if (fvar is null)
+ {
+ // Not a variable font; return this instance unchanged.
+ return this;
+ }
+
+ // Map FontVariation tags to user coordinate array (indexed by fvar axis order).
+ // Start with default axis values so unspecified axes remain at their defaults.
+ float[] userCoordinates = new float[fvar.AxisCount];
+ for (int i = 0; i < fvar.AxisCount; i++)
+ {
+ userCoordinates[i] = fvar.Axes[i].DefaultValue;
+ }
+
+ for (int v = 0; v < variations.Length; v++)
+ {
+ FontVariation variation = variations[v];
+ for (int i = 0; i < fvar.AxisCount; i++)
+ {
+ if (string.Equals(fvar.Axes[i].Tag, variation.Tag, StringComparison.Ordinal))
+ {
+ userCoordinates[i] = variation.Value;
+ break;
+ }
+ }
+ }
+
+ // Create a new processor with the user coordinates. Shares all table references.
+ if (this.outlineType == OutlineType.TrueType)
+ {
+ TrueTypeFontTables tables = this.trueTypeFontTables!;
+ ItemVariationStore? itemVariationStore = tables.Hvar?.ItemVariationStore ?? tables.Vvar?.ItemVariationStore;
+ GlyphVariationProcessor processor = new(
+ itemVariationStore,
+ fvar,
+ tables.Avar,
+ tables.Gvar,
+ tables.Hvar,
+ tables.Vvar,
+ tables.Mvar,
+ tables.Cvar,
+ userCoordinates);
+
+ return new StreamFontMetrics(tables, processor, this.glyphIdCache, this.codePointCache);
+ }
+ else
+ {
+ CompactFontTables tables = this.compactFontTables!;
+ ItemVariationStore? itemVariationStore = tables.Cff.ItemVariationStore;
+ GlyphVariationProcessor processor = new(
+ itemVariationStore,
+ fvar,
+ tables.AVar,
+ tables.GVar,
+ tables.HVar,
+ tables.VVar,
+ tables.MVar,
+ userCoordinates: userCoordinates);
+
+ return new StreamFontMetrics(tables, processor, this.glyphIdCache, this.codePointCache);
+ }
+ }
+
///
/// Reads a from the specified stream.
///
@@ -386,10 +581,8 @@ internal static StreamFontMetrics LoadFont(FontReader reader)
{
return LoadTrueTypeFont(reader);
}
- else
- {
- return LoadCompactFont(reader);
- }
+
+ return LoadCompactFont(reader);
}
private (HorizontalMetrics HorizontalMetrics, VerticalMetrics VerticalMetrics) Initialize(T tables)
@@ -420,6 +613,13 @@ internal static StreamFontMetrics LoadFont(FontReader reader)
HorizontalMetrics horizontalMetrics = InitializeHorizontalMetrics(hhea, vhea, os2);
VerticalMetrics verticalMetrics = InitializeVerticalMetrics(horizontalMetrics, vhea);
+
+ // Apply MVAR deltas for the current variation coordinates.
+ if (this.GlyphVariationProcessor is not null)
+ {
+ this.ApplyMVarDeltas(horizontalMetrics, verticalMetrics);
+ }
+
return (horizontalMetrics, verticalMetrics);
}
@@ -523,6 +723,53 @@ private static VerticalMetrics InitializeVerticalMetrics(HorizontalMetrics metri
return verticalMetrics;
}
+ ///
+ /// Applies MVAR (Metrics Variations) deltas to all font-wide metrics.
+ /// MVAR adjusts global metrics (ascender, descender, line gap, strikeout, underline, etc.)
+ /// based on the current variation coordinates.
+ ///
+ ///
+ private void ApplyMVarDeltas(HorizontalMetrics horizontalMetrics, VerticalMetrics verticalMetrics)
+ {
+ GlyphVariationProcessor processor = this.GlyphVariationProcessor!;
+
+ // MVAR tags are 4-byte big-endian ASCII values.
+ // Horizontal metrics from OS/2 or hhea.
+ horizontalMetrics.Ascender += (short)MathF.Round(processor.GetMVarDelta(MVarTag.HorizontalAscender));
+ horizontalMetrics.Descender += (short)MathF.Round(processor.GetMVarDelta(MVarTag.HorizontalDescender));
+ horizontalMetrics.LineGap += (short)MathF.Round(processor.GetMVarDelta(MVarTag.HorizontalLineGap));
+ horizontalMetrics.LineHeight = (short)(horizontalMetrics.Ascender - horizontalMetrics.Descender + horizontalMetrics.LineGap);
+
+ // Vertical metrics from vhea.
+ if (!verticalMetrics.Synthesized)
+ {
+ verticalMetrics.Ascender += (short)MathF.Round(processor.GetMVarDelta(MVarTag.VerticalAscender));
+ verticalMetrics.Descender += (short)MathF.Round(processor.GetMVarDelta(MVarTag.VerticalDescender));
+ verticalMetrics.LineGap += (short)MathF.Round(processor.GetMVarDelta(MVarTag.VerticalLineGap));
+ verticalMetrics.LineHeight = (short)(verticalMetrics.Ascender - verticalMetrics.Descender + verticalMetrics.LineGap);
+ }
+
+ // OS/2 subscript metrics.
+ this.subscriptXSize += (short)MathF.Round(processor.GetMVarDelta(MVarTag.SubscriptXSize));
+ this.subscriptYSize += (short)MathF.Round(processor.GetMVarDelta(MVarTag.SubscriptYSize));
+ this.subscriptXOffset += (short)MathF.Round(processor.GetMVarDelta(MVarTag.SubscriptXOffset));
+ this.subscriptYOffset += (short)MathF.Round(processor.GetMVarDelta(MVarTag.SubscriptYOffset));
+
+ // OS/2 superscript metrics.
+ this.superscriptXSize += (short)MathF.Round(processor.GetMVarDelta(MVarTag.SuperscriptXSize));
+ this.superscriptYSize += (short)MathF.Round(processor.GetMVarDelta(MVarTag.SuperscriptYSize));
+ this.superscriptXOffset += (short)MathF.Round(processor.GetMVarDelta(MVarTag.SuperscriptXOffset));
+ this.superscriptYOffset += (short)MathF.Round(processor.GetMVarDelta(MVarTag.SuperscriptYOffset));
+
+ // OS/2 strikeout metrics.
+ this.strikeoutSize += (short)MathF.Round(processor.GetMVarDelta(MVarTag.StrikeoutSize));
+ this.strikeoutPosition += (short)MathF.Round(processor.GetMVarDelta(MVarTag.StrikeoutPosition));
+
+ // post underline metrics.
+ this.underlinePosition += (short)MathF.Round(processor.GetMVarDelta(MVarTag.UnderlinePosition));
+ this.underlineThickness += (short)MathF.Round(processor.GetMVarDelta(MVarTag.UnderlineThickness));
+ }
+
///
/// Reads a from the specified stream.
///
diff --git a/src/SixLabors.Fonts/Tables/AdvancedTypographic/AdvancedTypographicUtils.cs b/src/SixLabors.Fonts/Tables/AdvancedTypographic/AdvancedTypographicUtils.cs
index fdad14a3c..903999c36 100644
--- a/src/SixLabors.Fonts/Tables/AdvancedTypographic/AdvancedTypographicUtils.cs
+++ b/src/SixLabors.Fonts/Tables/AdvancedTypographic/AdvancedTypographicUtils.cs
@@ -319,6 +319,7 @@ public static void ApplyAnchor(
}
public static void ApplyPosition(
+ FontMetrics fontMetrics,
GlyphPositioningCollection collection,
int index,
ValueRecord record,
@@ -329,6 +330,16 @@ public static void ApplyPosition(
current.Bounds.Height += record.YAdvance;
current.Bounds.X += record.XPlacement;
current.Bounds.Y += record.YPlacement;
+
+ // Apply variation deltas from VariationIndex tables (variable fonts).
+ if (record.HasVariation)
+ {
+ current.Bounds.X += (short)MathF.Round(fontMetrics.GetGDefVariationDelta(record.XPlacementVariation));
+ current.Bounds.Y += (short)MathF.Round(fontMetrics.GetGDefVariationDelta(record.YPlacementVariation));
+ current.Bounds.Width += (short)MathF.Round(fontMetrics.GetGDefVariationDelta(record.XAdvanceVariation));
+ current.Bounds.Height += (short)MathF.Round(fontMetrics.GetGDefVariationDelta(record.YAdvanceVariation));
+ }
+
current.AppliedFeatures.Add(feature);
}
diff --git a/src/SixLabors.Fonts/Tables/AdvancedTypographic/FeatureVariationsTable.cs b/src/SixLabors.Fonts/Tables/AdvancedTypographic/FeatureVariationsTable.cs
new file mode 100644
index 000000000..fe87670dd
--- /dev/null
+++ b/src/SixLabors.Fonts/Tables/AdvancedTypographic/FeatureVariationsTable.cs
@@ -0,0 +1,329 @@
+// Copyright (c) Six Labors.
+// Licensed under the Six Labors Split License.
+
+namespace SixLabors.Fonts.Tables.AdvancedTypographic;
+
+///
+/// The FeatureVariations table is used in variable fonts to provide alternate sets of
+/// feature table lookups for different regions of the variation space.
+/// Shared by both GPOS and GSUB tables (version 1.1).
+///
+///
+internal sealed class FeatureVariationsTable
+{
+ private FeatureVariationsTable(FeatureVariationRecord[] records)
+ => this.Records = records;
+
+ public FeatureVariationRecord[] Records { get; }
+
+ ///
+ /// Loads the FeatureVariations table.
+ ///
+ /// The big endian binary reader.
+ /// Absolute offset to the beginning of the FeatureVariations table.
+ /// The FeatureListTable, used to resolve feature tags for substitutions.
+ /// The FeatureVariationsTable, or null if the offset is 0.
+ public static FeatureVariationsTable? Load(BigEndianBinaryReader reader, long offset, FeatureListTable featureList)
+ {
+ if (offset == 0)
+ {
+ return null;
+ }
+
+ // FeatureVariations table
+ // +----------+------------------------------------------------------+---------------------------------------------------------------+
+ // | Type | Name | Description |
+ // +==========+======================================================+===============================================================+
+ // | uint16 | majorVersion | Major version — set to 1 |
+ // +----------+------------------------------------------------------+---------------------------------------------------------------+
+ // | uint16 | minorVersion | Minor version — set to 0 |
+ // +----------+------------------------------------------------------+---------------------------------------------------------------+
+ // | uint32 | featureVariationRecordCount | Number of FeatureVariationRecords |
+ // +----------+------------------------------------------------------+---------------------------------------------------------------+
+ // | FeatureVariationRecord | featureVariationRecords[count] | Array of FeatureVariationRecords |
+ // +----------+------------------------------------------------------+---------------------------------------------------------------+
+ reader.Seek(offset, SeekOrigin.Begin);
+
+ ushort majorVersion = reader.ReadUInt16();
+ ushort minorVersion = reader.ReadUInt16();
+ uint recordCount = reader.ReadUInt32();
+
+ // Read all record offsets first, then load data to avoid excessive seeking.
+ int count = (int)recordCount;
+ using Buffer conditionSetOffsetsBuffer = new(count);
+ using Buffer substitutionOffsetsBuffer = new(count);
+ Span conditionSetOffsets = conditionSetOffsetsBuffer.GetSpan();
+ Span substitutionOffsets = substitutionOffsetsBuffer.GetSpan();
+ for (int i = 0; i < count; i++)
+ {
+ conditionSetOffsets[i] = reader.ReadOffset32();
+ substitutionOffsets[i] = reader.ReadOffset32();
+ }
+
+ FeatureVariationRecord[] records = new FeatureVariationRecord[count];
+ for (int i = 0; i < count; i++)
+ {
+ ConditionSetTable conditionSet = ConditionSetTable.Load(reader, offset + conditionSetOffsets[i]);
+ FeatureTableSubstitutionRecord[] substitutions = LoadFeatureTableSubstitution(reader, offset + substitutionOffsets[i], featureList);
+ records[i] = new FeatureVariationRecord(conditionSet, substitutions);
+ }
+
+ return new FeatureVariationsTable(records);
+ }
+
+ ///
+ /// Finds the first matching whose conditions are satisfied
+ /// by the given normalized coordinates, and returns its feature substitutions.
+ /// Returns null if no record matches or no variation coordinates are available.
+ ///
+ /// The normalized variation coordinates.
+ /// The matching substitution records, or null.
+ public FeatureTableSubstitutionRecord[]? FindMatchingSubstitutions(ReadOnlySpan normalizedCoords)
+ {
+ if (normalizedCoords.IsEmpty)
+ {
+ return null;
+ }
+
+ for (int i = 0; i < this.Records.Length; i++)
+ {
+ if (this.Records[i].ConditionSet.Evaluate(normalizedCoords))
+ {
+ return this.Records[i].Substitutions;
+ }
+ }
+
+ return null;
+ }
+
+ private static FeatureTableSubstitutionRecord[] LoadFeatureTableSubstitution(
+ BigEndianBinaryReader reader,
+ long offset,
+ FeatureListTable featureList)
+ {
+ // FeatureTableSubstitution table
+ // +----------+------------------------------------------------------+---------------------------------------------------------------+
+ // | Type | Name | Description |
+ // +==========+======================================================+===============================================================+
+ // | uint16 | majorVersion | Major version — set to 1 |
+ // +----------+------------------------------------------------------+---------------------------------------------------------------+
+ // | uint16 | minorVersion | Minor version — set to 0 |
+ // +----------+------------------------------------------------------+---------------------------------------------------------------+
+ // | uint16 | substitutionCount | Number of FeatureTableSubstitutionRecords |
+ // +----------+------------------------------------------------------+---------------------------------------------------------------+
+ // | FeatureTableSubstitutionRecord | substitutions[count] | Array of records |
+ // +----------+------------------------------------------------------+---------------------------------------------------------------+
+ reader.Seek(offset, SeekOrigin.Begin);
+
+ ushort majorVersion = reader.ReadUInt16();
+ ushort minorVersion = reader.ReadUInt16();
+ ushort substitutionCount = reader.ReadUInt16();
+
+ // Read record headers (featureIndex + offset pairs).
+ using Buffer featureIndicesBuffer = new(substitutionCount);
+ using Buffer featureTableOffsetsBuffer = new(substitutionCount);
+ Span featureIndices = featureIndicesBuffer.GetSpan();
+ Span featureTableOffsets = featureTableOffsetsBuffer.GetSpan();
+ for (int i = 0; i < substitutionCount; i++)
+ {
+ featureIndices[i] = reader.ReadUInt16();
+ featureTableOffsets[i] = reader.ReadOffset32();
+ }
+
+ // Load each alternate Feature table.
+ FeatureTableSubstitutionRecord[] records = new FeatureTableSubstitutionRecord[substitutionCount];
+ for (int i = 0; i < substitutionCount; i++)
+ {
+ ushort featureIndex = featureIndices[i];
+
+ // Resolve the original feature tag from the FeatureList so the substitute
+ // carries the same tag.
+ Tag featureTag = featureIndex < featureList.FeatureTables.Length
+ ? featureList.FeatureTables[featureIndex].FeatureTag
+ : default;
+
+ FeatureTable alternateFeatureTable = FeatureTable.Load(featureTag, reader, offset + featureTableOffsets[i]);
+ records[i] = new FeatureTableSubstitutionRecord(featureIndex, alternateFeatureTable);
+ }
+
+ return records;
+ }
+}
+
+///
+/// A set of conditions that must all be true for a FeatureVariationRecord to match.
+///
+internal sealed class ConditionSetTable
+{
+ private ConditionSetTable(ConditionTable[] conditions)
+ => this.Conditions = conditions;
+
+ public ConditionTable[] Conditions { get; }
+
+ public static ConditionSetTable Load(BigEndianBinaryReader reader, long offset)
+ {
+ // ConditionSet table
+ // +----------+----------------------------+------------------------------------------+
+ // | Type | Name | Description |
+ // +==========+============================+==========================================+
+ // | uint16 | conditionCount | Number of conditions |
+ // +----------+----------------------------+------------------------------------------+
+ // | Offset32 | conditionOffsets[count] | Offsets to Condition tables, from |
+ // | | | beginning of ConditionSet table |
+ // +----------+----------------------------+------------------------------------------+
+ reader.Seek(offset, SeekOrigin.Begin);
+
+ ushort conditionCount = reader.ReadUInt16();
+ using Buffer conditionOffsetsBuffer = new(conditionCount);
+ Span conditionOffsets = conditionOffsetsBuffer.GetSpan();
+ for (int i = 0; i < conditionCount; i++)
+ {
+ conditionOffsets[i] = reader.ReadOffset32();
+ }
+
+ ConditionTable[] conditions = new ConditionTable[conditionCount];
+ for (int i = 0; i < conditionCount; i++)
+ {
+ conditions[i] = ConditionTable.Load(reader, offset + conditionOffsets[i]);
+ }
+
+ return new ConditionSetTable(conditions);
+ }
+
+ ///
+ /// Evaluates whether all conditions in this set are satisfied by the given normalized coordinates.
+ ///
+ /// The normalized variation coordinates.
+ /// True if all conditions match.
+ public bool Evaluate(ReadOnlySpan normalizedCoords)
+ {
+ for (int i = 0; i < this.Conditions.Length; i++)
+ {
+ if (!this.Conditions[i].Evaluate(normalizedCoords))
+ {
+ return false;
+ }
+ }
+
+ return true;
+ }
+}
+
+#pragma warning disable SA1201 // Elements should appear in the correct order
+
+///
+/// A single record in the FeatureVariations table, pairing a condition set with
+/// a set of feature table substitutions.
+///
+internal readonly struct FeatureVariationRecord
+{
+ public FeatureVariationRecord(ConditionSetTable conditionSet, FeatureTableSubstitutionRecord[] substitutions)
+ {
+ this.ConditionSet = conditionSet;
+ this.Substitutions = substitutions;
+ }
+
+ public ConditionSetTable ConditionSet { get; }
+
+ public FeatureTableSubstitutionRecord[] Substitutions { get; }
+}
+
+///
+/// A substitution record that maps a feature index to an alternate Feature table.
+///
+internal readonly struct FeatureTableSubstitutionRecord
+{
+ public FeatureTableSubstitutionRecord(ushort featureIndex, FeatureTable alternateFeatureTable)
+ {
+ this.FeatureIndex = featureIndex;
+ this.AlternateFeatureTable = alternateFeatureTable;
+ }
+
+ ///
+ /// Gets the index into the FeatureList of the feature being substituted.
+ ///
+ public ushort FeatureIndex { get; }
+
+ ///
+ /// Gets the alternate Feature table to use in place of the original.
+ ///
+ public FeatureTable AlternateFeatureTable { get; }
+}
+
+///
+/// A condition that checks whether a normalized coordinate for a specific axis
+/// falls within a given range.
+///
+internal readonly struct ConditionTable
+{
+ public ConditionTable(ushort axisIndex, float filterRangeMinValue, float filterRangeMaxValue)
+ {
+ this.AxisIndex = axisIndex;
+ this.FilterRangeMinValue = filterRangeMinValue;
+ this.FilterRangeMaxValue = filterRangeMaxValue;
+ }
+
+ ///
+ /// Gets the index of the variation axis (into fvar axes array).
+ ///
+ public ushort AxisIndex { get; }
+
+ ///
+ /// Gets the minimum normalized coordinate value for the condition to be true.
+ ///
+ public float FilterRangeMinValue { get; }
+
+ ///
+ /// Gets the maximum normalized coordinate value for the condition to be true.
+ ///
+ public float FilterRangeMaxValue { get; }
+
+ public static ConditionTable Load(BigEndianBinaryReader reader, long offset)
+ {
+ // Condition table, Format 1 (ConditionAxisRange)
+ // +----------+----------------------------+------------------------------------------+
+ // | Type | Name | Description |
+ // +==========+============================+==========================================+
+ // | uint16 | format | Format = 1 |
+ // +----------+----------------------------+------------------------------------------+
+ // | uint16 | axisIndex | Index of variation axis |
+ // +----------+----------------------------+------------------------------------------+
+ // | F2DOT14 | filterRangeMinValue | Minimum normalized coordinate value |
+ // +----------+----------------------------+------------------------------------------+
+ // | F2DOT14 | filterRangeMaxValue | Maximum normalized coordinate value |
+ // +----------+----------------------------+------------------------------------------+
+ reader.Seek(offset, SeekOrigin.Begin);
+
+ ushort format = reader.ReadUInt16();
+
+ // Only Format 1 is defined.
+ if (format != 1)
+ {
+ return default;
+ }
+
+ ushort axisIndex = reader.ReadUInt16();
+ float filterRangeMinValue = reader.ReadF2Dot14();
+ float filterRangeMaxValue = reader.ReadF2Dot14();
+
+ return new ConditionTable(axisIndex, filterRangeMinValue, filterRangeMaxValue);
+ }
+
+ ///
+ /// Evaluates whether the given normalized coordinates satisfy this condition.
+ ///
+ /// The normalized variation coordinates.
+ /// True if the coordinate for this axis is within the filter range.
+ public bool Evaluate(ReadOnlySpan normalizedCoords)
+ {
+ if (this.AxisIndex >= normalizedCoords.Length)
+ {
+ return false;
+ }
+
+ float coord = normalizedCoords[this.AxisIndex];
+ return coord >= this.FilterRangeMinValue && coord <= this.FilterRangeMaxValue;
+ }
+}
+
+#pragma warning restore SA1201
diff --git a/src/SixLabors.Fonts/Tables/AdvancedTypographic/GPos/AnchorTable.cs b/src/SixLabors.Fonts/Tables/AdvancedTypographic/GPos/AnchorTable.cs
index a628a8eef..08c6137a8 100644
--- a/src/SixLabors.Fonts/Tables/AdvancedTypographic/GPos/AnchorTable.cs
+++ b/src/SixLabors.Fonts/Tables/AdvancedTypographic/GPos/AnchorTable.cs
@@ -49,7 +49,7 @@ public static AnchorTable Load(BigEndianBinaryReader reader, long offset)
{
1 => AnchorFormat1.Load(reader),
2 => AnchorFormat2.Load(reader),
- 3 => AnchorFormat3.Load(reader),
+ 3 => AnchorFormat3.LoadFormat3(reader, offset),
// Harfbuzz (Anchor.hh) treats this as an empty table and does not throw..
// NotoSans Regular can trigger this. See https://github.com/SixLabors/Fonts/issues/417
@@ -138,20 +138,26 @@ public override AnchorXY GetAnchor(FontMetrics fontMetrics, GlyphShapingData dat
internal sealed class AnchorFormat3 : AnchorTable
{
- // TODO: actually use the xDeviceOffset.
- private readonly ushort xDeviceOffset;
+ private const ushort VariationIndexFormat = 0x8000;
- // TODO: actually use the yDeviceOffset.
- private readonly ushort yDeviceOffset;
+ ///
+ /// Packed VariationIndex for X: (outerIndex << 16) | innerIndex. 0 = none.
+ ///
+ private readonly uint xVariation;
- public AnchorFormat3(short xCoordinate, short yCoordinate, ushort xDeviceOffset, ushort yDeviceOffset)
+ ///
+ /// Packed VariationIndex for Y: (outerIndex << 16) | innerIndex. 0 = none.
+ ///
+ private readonly uint yVariation;
+
+ public AnchorFormat3(short xCoordinate, short yCoordinate, uint xVariation, uint yVariation)
: base(xCoordinate, yCoordinate)
{
- this.xDeviceOffset = xDeviceOffset;
- this.yDeviceOffset = yDeviceOffset;
+ this.xVariation = xVariation;
+ this.yVariation = yVariation;
}
- public static AnchorFormat3 Load(BigEndianBinaryReader reader)
+ public static AnchorFormat3 LoadFormat3(BigEndianBinaryReader reader, long anchorBase)
{
// +--------------+------------------------+-----------------------------------------------------------+
// | Type | Name | Description |
@@ -162,8 +168,6 @@ public static AnchorFormat3 Load(BigEndianBinaryReader reader)
// +--------------+------------------------+-----------------------------------------------------------+
// | int16 | yCoordinate | Vertical value, in design units. |
// +--------------+------------------------+-----------------------------------------------------------+
- // | uint16 + anchorPoint | Index to glyph contour point. +
- // +--------------+------------------------+-----------------------------------------------------------+
// | Offset16 | xDeviceOffset + Offset to Device table (non-variable font) / |
// | | | VariationIndex table (variable font) for X coordinate, |
// | | | from beginning of Anchor table (may be NULL) |
@@ -176,11 +180,55 @@ public static AnchorFormat3 Load(BigEndianBinaryReader reader)
short yCoordinate = reader.ReadInt16();
ushort xDeviceOffset = reader.ReadOffset16();
ushort yDeviceOffset = reader.ReadOffset16();
- return new AnchorFormat3(xCoordinate, yCoordinate, xDeviceOffset, yDeviceOffset);
+
+ uint xVariation = ResolveVariationIndex(reader, anchorBase, xDeviceOffset);
+ uint yVariation = ResolveVariationIndex(reader, anchorBase, yDeviceOffset);
+
+ return new AnchorFormat3(xCoordinate, yCoordinate, xVariation, yVariation);
}
public override AnchorXY GetAnchor(FontMetrics fontMetrics, GlyphShapingData data, GlyphPositioningCollection collection)
- => new(this.XCoordinate, this.YCoordinate);
+ {
+ short x = this.XCoordinate;
+ short y = this.YCoordinate;
+
+ if (this.xVariation != 0)
+ {
+ x += (short)MathF.Round(fontMetrics.GetGDefVariationDelta(this.xVariation));
+ }
+
+ if (this.yVariation != 0)
+ {
+ y += (short)MathF.Round(fontMetrics.GetGDefVariationDelta(this.yVariation));
+ }
+
+ return new(x, y);
+ }
+
+ private static uint ResolveVariationIndex(BigEndianBinaryReader reader, long anchorBase, ushort deviceOffset)
+ {
+ if (deviceOffset == 0)
+ {
+ return 0;
+ }
+
+ long savedPosition = reader.BaseStream.Position;
+ reader.BaseStream.Position = anchorBase + deviceOffset;
+
+ ushort first = reader.ReadUInt16();
+ ushort second = reader.ReadUInt16();
+ ushort format = reader.ReadUInt16();
+
+ reader.BaseStream.Position = savedPosition;
+
+ if (format == VariationIndexFormat)
+ {
+ return ((uint)first << 16) | second;
+ }
+
+ // TODO: Device table (per-ppem adjustments) — not yet implemented.
+ return 0;
+ }
}
internal sealed class EmptyAnchorTable : AnchorTable
diff --git a/src/SixLabors.Fonts/Tables/AdvancedTypographic/GPos/Class1Record.cs b/src/SixLabors.Fonts/Tables/AdvancedTypographic/GPos/Class1Record.cs
index 27cbd260a..19cc5ee08 100644
--- a/src/SixLabors.Fonts/Tables/AdvancedTypographic/GPos/Class1Record.cs
+++ b/src/SixLabors.Fonts/Tables/AdvancedTypographic/GPos/Class1Record.cs
@@ -9,7 +9,7 @@ internal sealed class Class1Record
public Class2Record[] Class2Records { get; }
- public static Class1Record Load(BigEndianBinaryReader reader, int class2Count, ValueFormat valueFormat1, ValueFormat valueFormat2)
+ public static Class1Record Load(BigEndianBinaryReader reader, int class2Count, ValueFormat valueFormat1, ValueFormat valueFormat2, long parentBase = -1)
{
// +--------------+----------------------------+---------------------------------------------+
// | Type | Name | Description |
@@ -20,7 +20,7 @@ public static Class1Record Load(BigEndianBinaryReader reader, int class2Count, V
var class2Records = new Class2Record[class2Count];
for (int i = 0; i < class2Records.Length; i++)
{
- class2Records[i] = new Class2Record(reader, valueFormat1, valueFormat2);
+ class2Records[i] = new Class2Record(reader, valueFormat1, valueFormat2, parentBase);
}
return new Class1Record(class2Records);
diff --git a/src/SixLabors.Fonts/Tables/AdvancedTypographic/GPos/Class2Record.cs b/src/SixLabors.Fonts/Tables/AdvancedTypographic/GPos/Class2Record.cs
index c1de2e011..e28c70a17 100644
--- a/src/SixLabors.Fonts/Tables/AdvancedTypographic/GPos/Class2Record.cs
+++ b/src/SixLabors.Fonts/Tables/AdvancedTypographic/GPos/Class2Record.cs
@@ -17,10 +17,11 @@ internal readonly struct Class2Record
/// The big endian binary reader.
/// The value format for value record 1.
/// The value format for value record 2.
- public Class2Record(BigEndianBinaryReader reader, ValueFormat valueFormat1, ValueFormat valueFormat2)
+ /// The absolute stream position of the parent table for resolving device offsets.
+ public Class2Record(BigEndianBinaryReader reader, ValueFormat valueFormat1, ValueFormat valueFormat2, long parentBase = -1)
{
- this.ValueRecord1 = new ValueRecord(reader, valueFormat1);
- this.ValueRecord2 = new ValueRecord(reader, valueFormat2);
+ this.ValueRecord1 = new ValueRecord(reader, valueFormat1, parentBase);
+ this.ValueRecord2 = new ValueRecord(reader, valueFormat2, parentBase);
}
///
diff --git a/src/SixLabors.Fonts/Tables/AdvancedTypographic/GPos/LookupType1SubTable.cs b/src/SixLabors.Fonts/Tables/AdvancedTypographic/GPos/LookupType1SubTable.cs
index d9f1cf861..85dc4e932 100644
--- a/src/SixLabors.Fonts/Tables/AdvancedTypographic/GPos/LookupType1SubTable.cs
+++ b/src/SixLabors.Fonts/Tables/AdvancedTypographic/GPos/LookupType1SubTable.cs
@@ -56,7 +56,7 @@ public static LookupType1Format1SubTable Load(BigEndianBinaryReader reader, long
// +-------------+----------------+-----------------------------------------------+
ushort coverageOffset = reader.ReadOffset16();
ValueFormat valueFormat = reader.ReadUInt16();
- ValueRecord valueRecord = new(reader, valueFormat);
+ ValueRecord valueRecord = new(reader, valueFormat, offset);
CoverageTable coverageTable = CoverageTable.Load(reader, offset + coverageOffset);
@@ -81,7 +81,7 @@ public override bool TryUpdatePosition(
if (coverage > -1)
{
ValueRecord record = this.valueRecord;
- AdvancedTypographicUtils.ApplyPosition(collection, index, record, feature);
+ AdvancedTypographicUtils.ApplyPosition(fontMetrics, collection, index, record, feature);
return true;
}
@@ -126,7 +126,7 @@ public static LookupType1Format2SubTable Load(BigEndianBinaryReader reader, long
ValueRecord[] valueRecords = new ValueRecord[valueCount];
for (int i = 0; i < valueCount; i++)
{
- valueRecords[i] = new ValueRecord(reader, valueFormat);
+ valueRecords[i] = new ValueRecord(reader, valueFormat, offset);
}
CoverageTable coverageTable = CoverageTable.Load(reader, offset + coverageOffset);
@@ -152,7 +152,7 @@ public override bool TryUpdatePosition(
if (coverage > -1)
{
ValueRecord record = this.valueRecords[coverage];
- AdvancedTypographicUtils.ApplyPosition(collection, index, record, feature);
+ AdvancedTypographicUtils.ApplyPosition(fontMetrics, collection, index, record, feature);
return true;
}
diff --git a/src/SixLabors.Fonts/Tables/AdvancedTypographic/GPos/LookupType2SubTable.cs b/src/SixLabors.Fonts/Tables/AdvancedTypographic/GPos/LookupType2SubTable.cs
index b69522ffb..5c56d21ae 100644
--- a/src/SixLabors.Fonts/Tables/AdvancedTypographic/GPos/LookupType2SubTable.cs
+++ b/src/SixLabors.Fonts/Tables/AdvancedTypographic/GPos/LookupType2SubTable.cs
@@ -77,7 +77,8 @@ public static LookupType2Format1SubTable Load(BigEndianBinaryReader reader, long
for (int i = 0; i < pairSetCount; i++)
{
reader.Seek(offset + pairSetOffsets[i], SeekOrigin.Begin);
- pairSets[i] = PairSetTable.Load(reader, offset + pairSetOffsets[i], valueFormat1, valueFormat2);
+ long pairSetBase = offset + pairSetOffsets[i];
+ pairSets[i] = PairSetTable.Load(reader, pairSetBase, valueFormat1, valueFormat2);
}
CoverageTable coverageTable = CoverageTable.Load(reader, offset + coverageOffset);
@@ -117,10 +118,10 @@ public override bool TryUpdatePosition(
if (pairSet.TryGetPairValueRecord(glyphId2, out PairValueRecord pairValueRecord))
{
ValueRecord record1 = pairValueRecord.ValueRecord1;
- AdvancedTypographicUtils.ApplyPosition(collection, index, record1, feature);
+ AdvancedTypographicUtils.ApplyPosition(fontMetrics, collection, index, record1, feature);
ValueRecord record2 = pairValueRecord.ValueRecord2;
- AdvancedTypographicUtils.ApplyPosition(collection, index + 1, record2, feature);
+ AdvancedTypographicUtils.ApplyPosition(fontMetrics, collection, index + 1, record2, feature);
return true;
}
@@ -151,7 +152,7 @@ public static PairSetTable Load(BigEndianBinaryReader reader, long offset, Value
PairValueRecord[] pairValueRecords = new PairValueRecord[pairValueCount];
for (int i = 0; i < pairValueRecords.Length; i++)
{
- pairValueRecords[i] = new PairValueRecord(reader, valueFormat1, valueFormat2);
+ pairValueRecords[i] = new PairValueRecord(reader, valueFormat1, valueFormat2, offset);
}
return new PairSetTable(pairValueRecords);
@@ -241,7 +242,7 @@ public static LookupType2Format2SubTable Load(BigEndianBinaryReader reader, long
Class1Record[] class1Records = new Class1Record[class1Count];
for (int i = 0; i < class1Records.Length; i++)
{
- class1Records[i] = Class1Record.Load(reader, class2Count, valueFormat1, valueFormat2);
+ class1Records[i] = Class1Record.Load(reader, class2Count, valueFormat1, valueFormat2, offset);
}
CoverageTable coverageTable = CoverageTable.Load(reader, offset + coverageOffset);
@@ -286,10 +287,10 @@ public override bool TryUpdatePosition(
Class2Record class2Record = class1Record.Class2Records[classDef2];
ValueRecord record1 = class2Record.ValueRecord1;
- AdvancedTypographicUtils.ApplyPosition(collection, index, record1, feature);
+ AdvancedTypographicUtils.ApplyPosition(fontMetrics, collection, index, record1, feature);
ValueRecord record2 = class2Record.ValueRecord2;
- AdvancedTypographicUtils.ApplyPosition(collection, index + 1, record2, feature);
+ AdvancedTypographicUtils.ApplyPosition(fontMetrics, collection, index + 1, record2, feature);
return true;
}
diff --git a/src/SixLabors.Fonts/Tables/AdvancedTypographic/GPos/PairValueRecord.cs b/src/SixLabors.Fonts/Tables/AdvancedTypographic/GPos/PairValueRecord.cs
index 42f8ff796..be2dba593 100644
--- a/src/SixLabors.Fonts/Tables/AdvancedTypographic/GPos/PairValueRecord.cs
+++ b/src/SixLabors.Fonts/Tables/AdvancedTypographic/GPos/PairValueRecord.cs
@@ -15,7 +15,8 @@ internal readonly struct PairValueRecord
/// The big endian binary reader.
/// The types of data in valueRecord1 — for the first glyph in the pair (may be zero).
/// The types of data in valueRecord2 — for the first glyph in the pair (may be zero).
- public PairValueRecord(BigEndianBinaryReader reader, ValueFormat valueFormat1, ValueFormat valueFormat2)
+ /// The absolute stream position of the parent table for resolving device offsets.
+ public PairValueRecord(BigEndianBinaryReader reader, ValueFormat valueFormat1, ValueFormat valueFormat2, long parentBase = -1)
{
// +--------------+------------------+--------------------------------------------------------------------------------------+
// | Type | Name | Description |
@@ -27,8 +28,8 @@ public PairValueRecord(BigEndianBinaryReader reader, ValueFormat valueFormat1, V
// | ValueRecord | valueRecord2 | Positioning data for the second glyph in the pair. |
// +--------------+------------------+--------------------------------------------------------------------------------------+
this.SecondGlyph = reader.ReadUInt16();
- this.ValueRecord1 = new ValueRecord(reader, valueFormat1);
- this.ValueRecord2 = new ValueRecord(reader, valueFormat2);
+ this.ValueRecord1 = new ValueRecord(reader, valueFormat1, parentBase);
+ this.ValueRecord2 = new ValueRecord(reader, valueFormat2, parentBase);
}
///
diff --git a/src/SixLabors.Fonts/Tables/AdvancedTypographic/GPos/ValueRecord.cs b/src/SixLabors.Fonts/Tables/AdvancedTypographic/GPos/ValueRecord.cs
index b9244a629..8d1821d5c 100644
--- a/src/SixLabors.Fonts/Tables/AdvancedTypographic/GPos/ValueRecord.cs
+++ b/src/SixLabors.Fonts/Tables/AdvancedTypographic/GPos/ValueRecord.cs
@@ -11,12 +11,34 @@ namespace SixLabors.Fonts.Tables.AdvancedTypographic.GPos;
///
internal readonly struct ValueRecord
{
+ ///
+ /// The deltaFormat value used by VariationIndex tables (as opposed to Device tables).
+ ///
+ private const ushort VariationIndexFormat = 0x8000;
+
///
/// Initializes a new instance of the struct.
///
/// The big endian binary reader.
/// Defines the types of data in the ValueRecord.
public ValueRecord(BigEndianBinaryReader reader, ValueFormat valueFormat)
+ : this(reader, valueFormat, -1)
+ {
+ }
+
+ ///
+ /// Initializes a new instance of the struct.
+ /// When is non-negative, device offsets are resolved to
+ /// VariationIndex (outerIndex, innerIndex) pairs for use with variable fonts.
+ ///
+ /// The big endian binary reader.
+ /// Defines the types of data in the ValueRecord.
+ ///
+ /// The absolute stream position of the immediate parent table (SinglePos subtable,
+ /// PairPosFormat2 subtable, or PairSet table). Device offsets are relative to this position.
+ /// Pass -1 to skip VariationIndex resolution.
+ ///
+ public ValueRecord(BigEndianBinaryReader reader, ValueFormat valueFormat, long parentBase)
{
// +----------+------------------+--------------------------------------------------------------------------------------+
// | Type | Name | Description |
@@ -64,10 +86,22 @@ public ValueRecord(BigEndianBinaryReader reader, ValueFormat valueFormat)
this.YPlacement = (valueFormat & ValueFormat.YPlacement) != 0 ? reader.ReadInt16() : (short)0;
this.XAdvance = (valueFormat & ValueFormat.XAdvance) != 0 ? reader.ReadInt16() : (short)0;
this.YAdvance = (valueFormat & ValueFormat.YAdvance) != 0 ? reader.ReadInt16() : (short)0;
- this.XPlacementDeviceOffset = (valueFormat & ValueFormat.XPlacementDevice) != 0 ? reader.ReadInt16() : (short)0;
- this.YPlacementDeviceOffset = (valueFormat & ValueFormat.YPlacementDevice) != 0 ? reader.ReadInt16() : (short)0;
- this.XAdvanceDeviceOffset = (valueFormat & ValueFormat.XAdvanceDevice) != 0 ? reader.ReadInt16() : (short)0;
- this.YAdvanceDeviceOffset = (valueFormat & ValueFormat.YAdvanceDevice) != 0 ? reader.ReadInt16() : (short)0;
+
+ short xPlaDevOff = (valueFormat & ValueFormat.XPlacementDevice) != 0 ? reader.ReadInt16() : (short)0;
+ short yPlaDevOff = (valueFormat & ValueFormat.YPlacementDevice) != 0 ? reader.ReadInt16() : (short)0;
+ short xAdvDevOff = (valueFormat & ValueFormat.XAdvanceDevice) != 0 ? reader.ReadInt16() : (short)0;
+ short yAdvDevOff = (valueFormat & ValueFormat.YAdvanceDevice) != 0 ? reader.ReadInt16() : (short)0;
+
+ // Resolve device offsets to VariationIndex tables when the parent base is known.
+ if (parentBase >= 0 && ((ushort)xPlaDevOff | (ushort)yPlaDevOff | (ushort)xAdvDevOff | (ushort)yAdvDevOff) != 0)
+ {
+ long savedPosition = reader.BaseStream.Position;
+ this.XPlacementVariation = ResolveVariationIndex(reader, parentBase, xPlaDevOff);
+ this.YPlacementVariation = ResolveVariationIndex(reader, parentBase, yPlaDevOff);
+ this.XAdvanceVariation = ResolveVariationIndex(reader, parentBase, xAdvDevOff);
+ this.YAdvanceVariation = ResolveVariationIndex(reader, parentBase, yAdvDevOff);
+ reader.BaseStream.Position = savedPosition;
+ }
}
public short XPlacement { get; }
@@ -78,11 +112,66 @@ public ValueRecord(BigEndianBinaryReader reader, ValueFormat valueFormat)
public short YAdvance { get; }
- public short XPlacementDeviceOffset { get; }
+ ///
+ /// Gets the packed VariationIndex for horizontal placement: (outerIndex << 16) | innerIndex.
+ /// Zero means no variation data.
+ ///
+ public uint XPlacementVariation { get; }
- public short YPlacementDeviceOffset { get; }
+ ///
+ /// Gets the packed VariationIndex for vertical placement: (outerIndex << 16) | innerIndex.
+ /// Zero means no variation data.
+ ///
+ public uint YPlacementVariation { get; }
- public short XAdvanceDeviceOffset { get; }
+ ///
+ /// Gets the packed VariationIndex for horizontal advance: (outerIndex << 16) | innerIndex.
+ /// Zero means no variation data.
+ ///
+ public uint XAdvanceVariation { get; }
+
+ ///
+ /// Gets the packed VariationIndex for vertical advance: (outerIndex << 16) | innerIndex.
+ /// Zero means no variation data.
+ ///
+ public uint YAdvanceVariation { get; }
+
+ ///
+ /// Gets a value indicating whether this record has any variation data.
+ ///
+ public bool HasVariation
+ => (this.XPlacementVariation | this.YPlacementVariation | this.XAdvanceVariation | this.YAdvanceVariation) != 0;
- public short YAdvanceDeviceOffset { get; }
+ ///
+ /// Reads a Device/VariationIndex table at the given offset and returns a packed VariationIndex
+ /// (outerIndex << 16 | innerIndex) if it is a VariationIndex table (deltaFormat == 0x8000),
+ /// or 0 if null, a Device table, or invalid.
+ ///
+ private static uint ResolveVariationIndex(BigEndianBinaryReader reader, long parentBase, short deviceOffset)
+ {
+ if (deviceOffset == 0)
+ {
+ return 0;
+ }
+
+ // Device offsets are relative to the parent table base.
+ // Use absolute positioning to avoid BigEndianBinaryReader.Seek startOfStream rebasing.
+ reader.BaseStream.Position = parentBase + (ushort)deviceOffset;
+
+ // VariationIndex table (reuses the Device table format):
+ // uint16 deltaSetOuterIndex
+ // uint16 deltaSetInnerIndex
+ // uint16 deltaFormat (0x8000 for VariationIndex, 1/2/3 for Device)
+ ushort first = reader.ReadUInt16();
+ ushort second = reader.ReadUInt16();
+ ushort format = reader.ReadUInt16();
+
+ if (format == VariationIndexFormat)
+ {
+ return ((uint)first << 16) | second;
+ }
+
+ // TODO: Device table (per-ppem pixel adjustments for non-variable fonts) — not yet implemented.
+ return 0;
+ }
}
diff --git a/src/SixLabors.Fonts/Tables/AdvancedTypographic/GPosTable.cs b/src/SixLabors.Fonts/Tables/AdvancedTypographic/GPosTable.cs
index fe147ec7c..852add068 100644
--- a/src/SixLabors.Fonts/Tables/AdvancedTypographic/GPosTable.cs
+++ b/src/SixLabors.Fonts/Tables/AdvancedTypographic/GPosTable.cs
@@ -21,11 +21,12 @@ internal class GPosTable : Table
internal const string TableName = "GPOS";
- public GPosTable(ScriptList? scriptList, FeatureListTable featureList, LookupListTable lookupList)
+ public GPosTable(ScriptList? scriptList, FeatureListTable featureList, LookupListTable lookupList, FeatureVariationsTable? featureVariations = null)
{
this.ScriptList = scriptList;
this.FeatureList = featureList;
this.LookupList = lookupList;
+ this.FeatureVariations = featureVariations;
}
public ScriptList? ScriptList { get; }
@@ -34,6 +35,8 @@ public GPosTable(ScriptList? scriptList, FeatureListTable featureList, LookupLis
public LookupListTable LookupList { get; }
+ public FeatureVariationsTable? FeatureVariations { get; }
+
public static GPosTable? Load(FontReader fontReader)
{
if (!fontReader.TryGetReaderAtTablePosition(TableName, out BigEndianBinaryReader? binaryReader))
@@ -95,8 +98,11 @@ internal static GPosTable Load(BigEndianBinaryReader reader)
LookupListTable lookupList = LookupListTable.Load(reader, lookupListOffset);
- // TODO: Feature Variations.
- return new GPosTable(scriptList, featureList, lookupList);
+ FeatureVariationsTable? featureVariations = featureVariationsOffset != 0
+ ? FeatureVariationsTable.Load(reader, featureVariationsOffset, featureList)
+ : null;
+
+ return new GPosTable(scriptList, featureList, lookupList, featureVariations);
}
public bool TryUpdatePositions(FontMetrics fontMetrics, GlyphPositioningCollection collection, out bool kerned)
@@ -171,7 +177,7 @@ current is not ScriptClass.Common and not ScriptClass.Unknown and not ScriptClas
stage.PreProcessFeature(collection, index, count);
Tag featureTag = stage.FeatureTag;
- if (this.TryGetFeatureLookups(in featureTag, current, out List<(Tag Feature, ushort Index, LookupTable LookupTable)>? lookups))
+ if (this.TryGetFeatureLookups(fontMetrics, in featureTag, current, out List<(Tag Feature, ushort Index, LookupTable LookupTable)>? lookups))
{
// Apply features in order.
foreach ((Tag Feature, ushort Index, LookupTable LookupTable) featureLookup in lookups)
@@ -226,6 +232,7 @@ current is not ScriptClass.Common and not ScriptClass.Unknown and not ScriptClas
}
private bool TryGetFeatureLookups(
+ FontMetrics fontMetrics,
in Tag stageFeature,
ScriptClass script,
[NotNullWhen(true)] out List<(Tag Feature, ushort Index, LookupTable LookupTable)>? value)
@@ -236,6 +243,10 @@ private bool TryGetFeatureLookups(
return false;
}
+ // Resolve feature substitutions from FeatureVariations (variable fonts).
+ FeatureTableSubstitutionRecord[]? substitutions = this.FeatureVariations
+ ?.FindMatchingSubstitutions(fontMetrics.GetNormalizedCoordinates());
+
ScriptListTable scriptListTable = this.ScriptList.Default();
Tag[] tags = UnicodeScriptTagMap.Instance[script];
for (int i = 0; i < tags.Length; i++)
@@ -250,11 +261,11 @@ private bool TryGetFeatureLookups(
LangSysTable? defaultLangSysTable = scriptListTable.DefaultLangSysTable;
if (defaultLangSysTable != null)
{
- value = this.GetFeatureLookups(stageFeature, defaultLangSysTable);
+ value = this.GetFeatureLookups(stageFeature, substitutions, defaultLangSysTable);
return value.Count > 0;
}
- value = this.GetFeatureLookups(stageFeature, scriptListTable.LangSysTables);
+ value = this.GetFeatureLookups(stageFeature, substitutions, scriptListTable.LangSysTables);
return value.Count > 0;
}
@@ -277,7 +288,10 @@ private Tag GetUnicodeScriptTag(ScriptClass script)
return default;
}
- private List<(Tag Feature, ushort Index, LookupTable LookupTable)> GetFeatureLookups(in Tag stageFeature, params LangSysTable[] langSysTables)
+ private List<(Tag Feature, ushort Index, LookupTable LookupTable)> GetFeatureLookups(
+ in Tag stageFeature,
+ FeatureTableSubstitutionRecord[]? substitutions,
+ params LangSysTable[] langSysTables)
{
List<(Tag Feature, ushort Index, LookupTable LookupTable)> lookups = [];
for (int i = 0; i < langSysTables.Length; i++)
@@ -285,7 +299,8 @@ private Tag GetUnicodeScriptTag(ScriptClass script)
ushort[] featureIndices = langSysTables[i].FeatureIndices;
for (int j = 0; j < featureIndices.Length; j++)
{
- FeatureTable featureTable = this.FeatureList.FeatureTables[featureIndices[j]];
+ ushort featureIndex = featureIndices[j];
+ FeatureTable featureTable = ResolveFeatureTable(this.FeatureList, featureIndex, substitutions);
Tag feature = featureTable.FeatureTag;
if (stageFeature != feature)
@@ -307,6 +322,25 @@ private Tag GetUnicodeScriptTag(ScriptClass script)
return lookups;
}
+ private static FeatureTable ResolveFeatureTable(
+ FeatureListTable featureList,
+ ushort featureIndex,
+ FeatureTableSubstitutionRecord[]? substitutions)
+ {
+ if (substitutions is not null)
+ {
+ for (int i = 0; i < substitutions.Length; i++)
+ {
+ if (substitutions[i].FeatureIndex == featureIndex)
+ {
+ return substitutions[i].AlternateFeatureTable;
+ }
+ }
+ }
+
+ return featureList.FeatureTables[featureIndex];
+ }
+
private ScriptClass GetScriptClass(ScriptClass current)
{
if (current is ScriptClass.Common or ScriptClass.Unknown or ScriptClass.Inherited)
diff --git a/src/SixLabors.Fonts/Tables/AdvancedTypographic/GSubTable.cs b/src/SixLabors.Fonts/Tables/AdvancedTypographic/GSubTable.cs
index 11181e4a8..695962066 100644
--- a/src/SixLabors.Fonts/Tables/AdvancedTypographic/GSubTable.cs
+++ b/src/SixLabors.Fonts/Tables/AdvancedTypographic/GSubTable.cs
@@ -17,11 +17,12 @@ internal class GSubTable : Table
{
internal const string TableName = "GSUB";
- public GSubTable(ScriptList? scriptList, FeatureListTable featureList, LookupListTable lookupList)
+ public GSubTable(ScriptList? scriptList, FeatureListTable featureList, LookupListTable lookupList, FeatureVariationsTable? featureVariations = null)
{
this.ScriptList = scriptList;
this.FeatureList = featureList;
this.LookupList = lookupList;
+ this.FeatureVariations = featureVariations;
}
public ScriptList? ScriptList { get; }
@@ -30,6 +31,8 @@ public GSubTable(ScriptList? scriptList, FeatureListTable featureList, LookupLis
public LookupListTable LookupList { get; }
+ public FeatureVariationsTable? FeatureVariations { get; }
+
public static GSubTable? Load(FontReader fontReader)
{
if (!fontReader.TryGetReaderAtTablePosition(TableName, out BigEndianBinaryReader? binaryReader))
@@ -91,8 +94,11 @@ internal static GSubTable Load(BigEndianBinaryReader reader)
LookupListTable lookupList = LookupListTable.Load(reader, lookupListOffset);
- // TODO: Feature Variations.
- return new GSubTable(scriptList, featureList, lookupList);
+ FeatureVariationsTable? featureVariations = featureVariationsOffset != 0
+ ? FeatureVariationsTable.Load(reader, featureVariationsOffset, featureList)
+ : null;
+
+ return new GSubTable(scriptList, featureList, lookupList, featureVariations);
}
public void ApplySubstitution(FontMetrics fontMetrics, GlyphSubstitutionCollection collection)
@@ -202,7 +208,7 @@ internal void ApplyFeature(
int maxOperationsCount,
ref int currentOperations)
{
- if (this.TryGetFeatureLookups(in featureTag, current, out List<(Tag Feature, ushort Index, LookupTable LookupTable)>? lookups))
+ if (this.TryGetFeatureLookups(fontMetrics, in featureTag, current, out List<(Tag Feature, ushort Index, LookupTable LookupTable)>? lookups))
{
// Apply features in order.
foreach ((Tag Feature, ushort Index, LookupTable LookupTable) featureLookup in lookups)
@@ -239,6 +245,7 @@ internal void ApplyFeature(
}
internal bool TryGetFeatureLookups(
+ FontMetrics fontMetrics,
in Tag stageFeature,
ScriptClass script,
[NotNullWhen(true)] out List<(Tag Feature, ushort Index, LookupTable LookupTable)>? value)
@@ -249,6 +256,10 @@ internal bool TryGetFeatureLookups(
return false;
}
+ // Resolve feature substitutions from FeatureVariations (variable fonts).
+ FeatureTableSubstitutionRecord[]? substitutions = this.FeatureVariations
+ ?.FindMatchingSubstitutions(fontMetrics.GetNormalizedCoordinates());
+
ScriptListTable scriptListTable = this.ScriptList.Default();
Tag[] tags = UnicodeScriptTagMap.Instance[script];
for (int i = 0; i < tags.Length; i++)
@@ -263,11 +274,11 @@ internal bool TryGetFeatureLookups(
LangSysTable? defaultLangSysTable = scriptListTable.DefaultLangSysTable;
if (defaultLangSysTable != null)
{
- value = this.GetFeatureLookups(stageFeature, defaultLangSysTable);
+ value = this.GetFeatureLookups(stageFeature, substitutions, defaultLangSysTable);
return value.Count > 0;
}
- value = this.GetFeatureLookups(stageFeature, scriptListTable.LangSysTables);
+ value = this.GetFeatureLookups(stageFeature, substitutions, scriptListTable.LangSysTables);
return value.Count > 0;
}
@@ -290,7 +301,10 @@ private Tag GetUnicodeScriptTag(ScriptClass script)
return default;
}
- private List<(Tag Feature, ushort Index, LookupTable LookupTable)> GetFeatureLookups(in Tag stageFeature, params LangSysTable[] langSysTables)
+ private List<(Tag Feature, ushort Index, LookupTable LookupTable)> GetFeatureLookups(
+ in Tag stageFeature,
+ FeatureTableSubstitutionRecord[]? substitutions,
+ params LangSysTable[] langSysTables)
{
List<(Tag Feature, ushort Index, LookupTable LookupTable)> lookups = [];
for (int i = 0; i < langSysTables.Length; i++)
@@ -298,7 +312,8 @@ private Tag GetUnicodeScriptTag(ScriptClass script)
ushort[] featureIndices = langSysTables[i].FeatureIndices;
for (int j = 0; j < featureIndices.Length; j++)
{
- FeatureTable featureTable = this.FeatureList.FeatureTables[featureIndices[j]];
+ ushort featureIndex = featureIndices[j];
+ FeatureTable featureTable = ResolveFeatureTable(this.FeatureList, featureIndex, substitutions);
Tag feature = featureTable.FeatureTag;
if (stageFeature != feature)
@@ -320,6 +335,25 @@ private Tag GetUnicodeScriptTag(ScriptClass script)
return lookups;
}
+ private static FeatureTable ResolveFeatureTable(
+ FeatureListTable featureList,
+ ushort featureIndex,
+ FeatureTableSubstitutionRecord[]? substitutions)
+ {
+ if (substitutions is not null)
+ {
+ for (int i = 0; i < substitutions.Length; i++)
+ {
+ if (substitutions[i].FeatureIndex == featureIndex)
+ {
+ return substitutions[i].AlternateFeatureTable;
+ }
+ }
+ }
+
+ return featureList.FeatureTables[featureIndex];
+ }
+
private ScriptClass GetScriptClass(ScriptClass current)
{
if (current is ScriptClass.Common or ScriptClass.Unknown or ScriptClass.Inherited)
diff --git a/src/SixLabors.Fonts/Tables/AdvancedTypographic/GlyphDefinitionTable.cs b/src/SixLabors.Fonts/Tables/AdvancedTypographic/GlyphDefinitionTable.cs
index c4cd4b78f..410641fe5 100644
--- a/src/SixLabors.Fonts/Tables/AdvancedTypographic/GlyphDefinitionTable.cs
+++ b/src/SixLabors.Fonts/Tables/AdvancedTypographic/GlyphDefinitionTable.cs
@@ -2,6 +2,7 @@
// Licensed under the Six Labors Split License.
using System.Diagnostics.CodeAnalysis;
+using SixLabors.Fonts.Tables.AdvancedTypographic.Variations;
namespace SixLabors.Fonts.Tables.AdvancedTypographic;
@@ -26,6 +27,8 @@ internal sealed class GlyphDefinitionTable : Table
public MarkGlyphSetsTable? MarkGlyphSetsTable { get; private set; }
+ public ItemVariationStore? ItemVariationStore { get; private set; }
+
public static GlyphDefinitionTable? Load(FontReader reader)
{
if (!reader.TryGetReaderAtTablePosition(TableName, out BigEndianBinaryReader? binaryReader))
@@ -154,14 +157,20 @@ public static GlyphDefinitionTable Load(BigEndianBinaryReader reader)
ClassDefinitionTable.TryLoad(reader, markAttachClassDefOffset, out ClassDefinitionTable? markAttachmentClassDef);
MarkGlyphSetsTable? markGlyphSetsTable = markGlyphSetsDefOffset is 0 ? null : MarkGlyphSetsTable.Load(reader, markGlyphSetsDefOffset);
- // TODO: read itemVarStore.
+ ItemVariationStore? itemVariationStore = null;
+ if (itemVarStoreOffset != 0)
+ {
+ itemVariationStore = ItemVariationStore.Load(reader, itemVarStoreOffset);
+ }
+
return new GlyphDefinitionTable()
{
GlyphClassDefinition = classDefinitionTable,
AttachmentListTable = attachmentListTable,
LigatureCaretList = ligatureCaretList,
MarkAttachmentClassDef = markAttachmentClassDef,
- MarkGlyphSetsTable = markGlyphSetsTable
+ MarkGlyphSetsTable = markGlyphSetsTable,
+ ItemVariationStore = itemVariationStore
};
}
}
diff --git a/src/SixLabors.Fonts/Tables/AdvancedTypographic/Shapers/IndicShaper.cs b/src/SixLabors.Fonts/Tables/AdvancedTypographic/Shapers/IndicShaper.cs
index 59e839f74..5a56d3c02 100644
--- a/src/SixLabors.Fonts/Tables/AdvancedTypographic/Shapers/IndicShaper.cs
+++ b/src/SixLabors.Fonts/Tables/AdvancedTypographic/Shapers/IndicShaper.cs
@@ -351,7 +351,7 @@ private void InitialReorder(IGlyphShapingCollection collection, int index, int c
// base consonants.
if (start + 3 <= end &&
indicConfiguration.RephPosition != Positions.Ra_To_Become_Reph &&
- gSubTable?.TryGetFeatureLookups(in RphfTag, this.ScriptClass, out _) == true &&
+ gSubTable?.TryGetFeatureLookups(fontMetrics, in RphfTag, this.ScriptClass, out _) == true &&
((indicConfiguration.RephMode == RephMode.Implicit && !IsJoiner(substitutionCollection[start + 2])) ||
(indicConfiguration.RephMode == RephMode.Explicit && substitutionCollection[start + 2].IndicShapingEngineInfo?.Category == Categories.ZWJ)))
{
@@ -740,7 +740,7 @@ private void InitialReorder(IGlyphShapingCollection collection, int index, int c
const int prefLen = 2;
if (basePosition + prefLen < end &&
- gSubTable?.TryGetFeatureLookups(in PrefTag, this.ScriptClass, out _) == true)
+ gSubTable?.TryGetFeatureLookups(fontMetrics, in PrefTag, this.ScriptClass, out _) == true)
{
// Find a Halant,Ra sequence and mark it for pre-base reordering processing.
for (int i = basePosition + 1; i + prefLen - 1 < end; i++)
@@ -759,7 +759,7 @@ private void InitialReorder(IGlyphShapingCollection collection, int index, int c
// This allows distinguishing the following cases with MS Khmer fonts:
// U+1784,U+17D2,U+179A,U+17D2,U+1782
// U+1784,U+17D2,U+1782,U+17D2,U+179A
- if (gSubTable.TryGetFeatureLookups(in CfarTag, this.ScriptClass, out _))
+ if (gSubTable.TryGetFeatureLookups(fontMetrics, in CfarTag, this.ScriptClass, out _))
{
while (i < end)
{
@@ -921,7 +921,7 @@ private void FinalReorder(IGlyphShapingCollection collection, int index, int cou
// applied (see below), the shaping engine performs some final glyph
// reordering before applying all the remaining font features to the entire
// cluster.
- bool tryPref = gSubTable?.TryGetFeatureLookups(in PrefTag, this.ScriptClass, out _) == true;
+ bool tryPref = gSubTable?.TryGetFeatureLookups(fontMetrics, in PrefTag, this.ScriptClass, out _) == true;
// Find base consonant again.
int basePosition = start;
diff --git a/src/SixLabors.Fonts/Tables/AdvancedTypographic/Variations/AVarTable.cs b/src/SixLabors.Fonts/Tables/AdvancedTypographic/Variations/AVarTable.cs
new file mode 100644
index 000000000..6d08fd70a
--- /dev/null
+++ b/src/SixLabors.Fonts/Tables/AdvancedTypographic/Variations/AVarTable.cs
@@ -0,0 +1,73 @@
+// Copyright (c) Six Labors.
+// Licensed under the Six Labors Split License.
+
+namespace SixLabors.Fonts.Tables.AdvancedTypographic.Variations;
+
+///
+/// Implements reading the Font Variations Table `avar`.
+///
+///
+internal class AVarTable : Table
+{
+ internal const string TableName = "avar";
+
+ public AVarTable(uint axisCount, SegmentMapRecord[] segmentMaps)
+ {
+ this.AxisCount = axisCount;
+ this.SegmentMaps = segmentMaps;
+ }
+
+ public uint AxisCount { get; }
+
+ public SegmentMapRecord[] SegmentMaps { get; }
+
+ public static AVarTable? Load(FontReader reader)
+ {
+ if (!reader.TryGetReaderAtTablePosition(TableName, out BigEndianBinaryReader? binaryReader))
+ {
+ return null;
+ }
+
+ using (binaryReader)
+ {
+ return Load(binaryReader);
+ }
+ }
+
+ public static AVarTable Load(BigEndianBinaryReader reader)
+ {
+ // VariationsTable `avar`
+ // +-----------------+----------------------------------------+-------------------------------------------------------------------------+
+ // | Type | Name | Description |
+ // +=================+========================================+=========================================================================+
+ // | uint16 | majorVersion | Major version number of the font variations table — set to 1. |
+ // +-----------------+----------------------------------------+-------------------------------------------------------------------------+
+ // | uint16 | minorVersion | Minor version number of the font variations table — set to 0. |
+ // +-----------------+----------------------------------------+-------------------------------------------------------------------------+
+ // | uint16 | (reserved) | This field is permanently reserved. Set to zero. |
+ // +-----------------+----------------------------------------+-------------------------------------------------------------------------+
+ // | uint16 | axisCount | The number of variation axes in the font |
+ // | | | (the number of records in the axes array). |
+ // +-----------------+----------------------------------------+-------------------------------------------------------------------------+
+ // | SegmentMaps | axisSegmentMaps[axisCount] | The segment maps array — one segment map for each axis, in the order of |
+ // | | | axes specified in the 'fvar' table. |
+ // +-----------------+----------------------------------------+-------------------------------------------------------------------------+
+ ushort major = reader.ReadUInt16();
+ ushort minor = reader.ReadUInt16();
+ ushort reserved = reader.ReadUInt16();
+ ushort axisCount = reader.ReadUInt16();
+
+ if (major != 1)
+ {
+ throw new NotSupportedException("Only version 1 of avar table is supported");
+ }
+
+ var segmentMaps = new SegmentMapRecord[axisCount];
+ for (int i = 0; i < axisCount; i++)
+ {
+ segmentMaps[i] = SegmentMapRecord.Load(reader);
+ }
+
+ return new AVarTable(axisCount, segmentMaps);
+ }
+}
diff --git a/src/SixLabors.Fonts/Tables/AdvancedTypographic/Variations/AxisValueMapRecord.cs b/src/SixLabors.Fonts/Tables/AdvancedTypographic/Variations/AxisValueMapRecord.cs
new file mode 100644
index 000000000..5de18f43e
--- /dev/null
+++ b/src/SixLabors.Fonts/Tables/AdvancedTypographic/Variations/AxisValueMapRecord.cs
@@ -0,0 +1,33 @@
+// Copyright (c) Six Labors.
+// Licensed under the Six Labors Split License.
+
+namespace SixLabors.Fonts.Tables.AdvancedTypographic.Variations;
+
+internal class AxisValueMapRecord
+{
+ public AxisValueMapRecord(float fromCoordinate, float toCoordinate)
+ {
+ this.FromCoordinate = fromCoordinate;
+ this.ToCoordinate = toCoordinate;
+ }
+
+ public float FromCoordinate { get; }
+
+ public float ToCoordinate { get; }
+
+ public static AxisValueMapRecord Load(BigEndianBinaryReader reader)
+ {
+ // AxisValueMapRecord
+ // +-----------------+----------------------------------------+-------------------------------------------------------------------------+
+ // | Type | Name | Description |
+ // +=================+========================================+=========================================================================+
+ // | F2DOT14 | fromCoordinate | A normalized coordinate value obtained using default normalization. |
+ // +-----------------+----------------------------------------+-------------------------------------------------------------------------+
+ // | F2DOT14 | toCoordinate | The modified, normalized coordinate value. |
+ // +-----------------+----------------------------------------+-------------------------------------------------------------------------+
+ float fromCoordinate = reader.ReadF2Dot14();
+ float toCoordinate = reader.ReadF2Dot14();
+
+ return new AxisValueMapRecord(fromCoordinate, toCoordinate);
+ }
+}
diff --git a/src/SixLabors.Fonts/Tables/AdvancedTypographic/Variations/CVarTable.cs b/src/SixLabors.Fonts/Tables/AdvancedTypographic/Variations/CVarTable.cs
new file mode 100644
index 000000000..61fa39670
--- /dev/null
+++ b/src/SixLabors.Fonts/Tables/AdvancedTypographic/Variations/CVarTable.cs
@@ -0,0 +1,190 @@
+// Copyright (c) Six Labors.
+// Licensed under the Six Labors Split License.
+
+namespace SixLabors.Fonts.Tables.AdvancedTypographic.Variations;
+
+///
+/// Implements reading the CVT Variations table cvar.
+/// The cvar table provides variation data for the Control Value Table (CVT)
+/// used by TrueType hinting instructions. It uses the same Tuple Variation Store
+/// format as gvar, but with a single dimension of deltas (CVT values rather than X/Y coordinates).
+///
+///
+internal class CVarTable : Table
+{
+ internal const string TableName = "cvar";
+
+ public CVarTable(CVarTupleVariation[] tupleVariations)
+ => this.TupleVariations = tupleVariations;
+
+ ///
+ /// Gets the tuple variations containing CVT deltas.
+ ///
+ public CVarTupleVariation[] TupleVariations { get; }
+
+ ///
+ /// Loads the cvar table from the font reader.
+ /// The axis count must be known from the fvar table before loading cvar.
+ ///
+ /// The font reader.
+ /// The number of variation axes from fvar.
+ /// The loaded cvar table, or null if not present.
+ public static CVarTable? Load(FontReader reader, int axisCount)
+ {
+ if (!reader.TryGetReaderAtTablePosition(TableName, out BigEndianBinaryReader? binaryReader))
+ {
+ return null;
+ }
+
+ using (binaryReader)
+ {
+ return Load(binaryReader, axisCount);
+ }
+ }
+
+ public static CVarTable Load(BigEndianBinaryReader reader, int axisCount)
+ {
+ // cvar — CVT Variations Table
+ // The cvar table uses the Tuple Variation Store format.
+ // +--------------------------+-------------------------------------------+--------------------------------------------------------------+
+ // | Type | Name | Description |
+ // +==========================+===========================================+==============================================================+
+ // | uint16 | majorVersion | Major version — set to 1. |
+ // +--------------------------+-------------------------------------------+--------------------------------------------------------------+
+ // | uint16 | minorVersion | Minor version — set to 0. |
+ // +--------------------------+-------------------------------------------+--------------------------------------------------------------+
+ // | uint16 | tupleVariationCount | Packed field: high 4 bits are flags, |
+ // | | | low 12 bits are the number of tuple variation tables. |
+ // +--------------------------+-------------------------------------------+--------------------------------------------------------------+
+ // | Offset16 | dataOffset | Offset from the start of the cvar table to the |
+ // | | | serialized data. |
+ // +--------------------------+-------------------------------------------+--------------------------------------------------------------+
+ // | TupleVariation | tupleVariationHeaders[tupleVariationCount]| Array of tuple variation headers. |
+ // +--------------------------+-------------------------------------------+--------------------------------------------------------------+
+ ushort majorVersion = reader.ReadUInt16();
+ ushort minorVersion = reader.ReadUInt16();
+
+ if (majorVersion != 1)
+ {
+ throw new NotSupportedException("Only version 1 of cvar table is supported");
+ }
+
+ ushort tupleVariationCount = reader.ReadUInt16();
+ bool hasSharedPointNumbers = (tupleVariationCount & GlyphVariationData.SharedPointNumbersMask) != 0;
+ int tupleCount = tupleVariationCount & GlyphVariationData.CountMask;
+ ushort dataOffset = reader.ReadOffset16();
+
+ // Read all tuple variation headers.
+ TupleVariation[] tupleVariations = new TupleVariation[tupleCount];
+ for (int i = 0; i < tupleCount; i++)
+ {
+ tupleVariations[i] = TupleVariation.Load(reader, axisCount);
+ }
+
+ // Seek to the serialized data.
+ reader.Seek(dataOffset, SeekOrigin.Begin);
+
+ // If shared point numbers flag is set, decode them from the start of the serialized data.
+ ushort[]? sharedPointNumbers = null;
+ if (hasSharedPointNumbers)
+ {
+ sharedPointNumbers = GlyphVariationData.DecodePackedPoints(reader);
+ }
+
+ // Decode each tuple's serialized data.
+ // Unlike gvar, cvar has only one set of deltas per tuple (CVT value adjustments).
+ CVarTupleVariation[] cvarTuples = new CVarTupleVariation[tupleCount];
+ for (int i = 0; i < tupleCount; i++)
+ {
+ TupleVariation header = tupleVariations[i];
+ long tupleDataStart = reader.BaseStream.Position;
+
+ // Determine which CVT indices this tuple applies to.
+ ushort[]? pointNumbers;
+ if (header.HasPrivatePointNumbers)
+ {
+ pointNumbers = GlyphVariationData.DecodePackedPoints(reader);
+ }
+ else
+ {
+ pointNumbers = sharedPointNumbers;
+ }
+
+ int nPoints = pointNumbers is { Length: > 0 } ? pointNumbers.Length : 0;
+
+ short[]? deltas = null;
+ if (nPoints > 0)
+ {
+ // cvar has only one set of deltas (not X/Y pairs like gvar).
+ deltas = GlyphVariationData.DecodePackedDeltas(reader, nPoints);
+ }
+ else
+ {
+ // All CVT entries are referenced. Store raw bytes for deferred decoding.
+ long bytesConsumed = reader.BaseStream.Position - tupleDataStart;
+ int remaining = header.VariationDataSize - (int)bytesConsumed;
+ if (remaining > 0)
+ {
+ cvarTuples[i] = new CVarTupleVariation(header, pointNumbers, null, reader.ReadBytes(remaining));
+ continue;
+ }
+ }
+
+ // Skip any remaining bytes for this tuple.
+ long consumed = reader.BaseStream.Position - tupleDataStart;
+ int skip = header.VariationDataSize - (int)consumed;
+ if (skip > 0)
+ {
+ reader.BaseStream.Position += skip;
+ }
+
+ cvarTuples[i] = new CVarTupleVariation(header, pointNumbers, deltas, null);
+ }
+
+ return new CVarTable(cvarTuples);
+ }
+}
+
+///
+/// Represents a single tuple variation for the cvar table with its CVT index references and deltas.
+/// Unlike gvar's which has X/Y delta pairs,
+/// cvar tuples have a single set of deltas for CVT values.
+///
+internal class CVarTupleVariation
+{
+ public CVarTupleVariation(
+ TupleVariation tupleVariation,
+ ushort[]? pointNumbers,
+ short[]? deltas,
+ byte[]? rawDeltaData)
+ {
+ this.TupleVariation = tupleVariation;
+ this.PointNumbers = pointNumbers;
+ this.Deltas = deltas;
+ this.RawDeltaData = rawDeltaData;
+ }
+
+ ///
+ /// Gets the tuple variation header containing peak coordinates and flags.
+ ///
+ public TupleVariation TupleVariation { get; }
+
+ ///
+ /// Gets the CVT indices this tuple applies to.
+ /// An empty array means all CVT entries are referenced.
+ ///
+ public ushort[]? PointNumbers { get; }
+
+ ///
+ /// Gets the CVT deltas for the referenced entries.
+ /// Null when deltas apply to all CVT entries and were deferred (see ).
+ ///
+ public short[]? Deltas { get; }
+
+ ///
+ /// Gets the raw serialized delta data for deferred decoding.
+ /// Used when point numbers indicate "all CVT entries" and the actual count
+ /// is not known until the CVT table size is available.
+ ///
+ public byte[]? RawDeltaData { get; }
+}
diff --git a/src/SixLabors.Fonts/Tables/AdvancedTypographic/Variations/DeltaSet.cs b/src/SixLabors.Fonts/Tables/AdvancedTypographic/Variations/DeltaSet.cs
new file mode 100644
index 000000000..6529b3d89
--- /dev/null
+++ b/src/SixLabors.Fonts/Tables/AdvancedTypographic/Variations/DeltaSet.cs
@@ -0,0 +1,42 @@
+// Copyright (c) Six Labors.
+// Licensed under the Six Labors Split License.
+
+namespace SixLabors.Fonts.Tables.AdvancedTypographic.Variations;
+
+internal class DeltaSet
+{
+ public DeltaSet(BigEndianBinaryReader reader, int wordDeltas, bool longWords, ushort regionIndexCount)
+ {
+ this.ShortDeltas = new int[wordDeltas];
+ for (int i = 0; i < wordDeltas; i++)
+ {
+ this.ShortDeltas[i] = longWords ? reader.ReadInt32() : reader.ReadInt16();
+ }
+
+ int remaining = regionIndexCount - wordDeltas;
+ this.RegionDeltas = new short[remaining];
+ for (int i = 0; i < remaining; i++)
+ {
+ this.RegionDeltas[i] = longWords ? reader.ReadInt16() : reader.ReadSByte();
+ }
+
+ this.Deltas = new int[this.RegionDeltas.Length + this.ShortDeltas.Length];
+ int offset = 0;
+
+ for (int i = 0; i < this.ShortDeltas.Length; i++)
+ {
+ this.Deltas[offset++] = this.ShortDeltas[i];
+ }
+
+ for (int i = 0; i < this.RegionDeltas.Length; i++)
+ {
+ this.Deltas[offset++] = this.RegionDeltas[i];
+ }
+ }
+
+ public short[] RegionDeltas { get; }
+
+ public int[] ShortDeltas { get; }
+
+ public int[] Deltas { get; }
+}
diff --git a/src/SixLabors.Fonts/Tables/AdvancedTypographic/Variations/DeltaSetIndexMap.cs b/src/SixLabors.Fonts/Tables/AdvancedTypographic/Variations/DeltaSetIndexMap.cs
new file mode 100644
index 000000000..f124599c8
--- /dev/null
+++ b/src/SixLabors.Fonts/Tables/AdvancedTypographic/Variations/DeltaSetIndexMap.cs
@@ -0,0 +1,74 @@
+// Copyright (c) Six Labors.
+// Licensed under the Six Labors Split License.
+
+namespace SixLabors.Fonts.Tables.AdvancedTypographic.Variations;
+
+internal class DeltaSetIndexMap
+{
+ private const int InnerIndexBitCountMask = 0x0F;
+
+ private const int MapEntrySizeMask = 0x30;
+
+ public DeltaSetIndexMap(int outerIndex, int innerIndex)
+ {
+ this.OuterIndex = outerIndex;
+ this.InnerIndex = innerIndex;
+ }
+
+ public int OuterIndex { get; }
+
+ public int InnerIndex { get; }
+
+ public static DeltaSetIndexMap[]? Load(BigEndianBinaryReader reader, long offset)
+ {
+ // This can be null if the offset is zero.
+ if (offset == 0)
+ {
+ return null;
+ }
+
+ // DeltaSetIndexMap.
+ // +-----------------+----------------------------------------+-----------------------------------------------------------------------------------+
+ // | Type | Name | Description |
+ // +=================+========================================+===================================================================================+
+ // | uint8 | format | DeltaSetIndexMap format. Either 0 or 1 |
+ // +-----------------+----------------------------------------+-----------------------------------------------------------------------------------+
+ // | uint8 | entryFormat | A packed field that describes the compressed representation of delta-set indices. |
+ // +-----------------+----------------------------------------+-----------------------------------------------------------------------------------+
+ // | uint16 or uin32 | mapCount | The number of mapping entries. uint16 for format0, uint32 for format 1 |
+ // +-----------------+----------------------------------------+-----------------------------------------------------------------------------------+
+ // | uint8 | mapData[variable] | The delta-set index mapping data. |
+ // +-----------------+----------------------------------------+-----------------------------------------------------------------------------------+
+ reader.Seek(offset, SeekOrigin.Begin);
+ byte format = reader.ReadUInt8();
+ byte entryFormat = reader.ReadUInt8();
+
+ if (format is not (0 or 1))
+ {
+ throw new NotSupportedException("Only format 0 or 1 of DeltaSetIndexMap is supported");
+ }
+
+ // Format 0 uses uint16 for mapCount, format 1 uses uint32.
+ int mapCount = format == 0 ? reader.ReadUInt16() : (int)reader.ReadUInt32();
+
+ int entrySize = ((entryFormat & MapEntrySizeMask) >> 4) + 1;
+ int innerBitCount = (entryFormat & InnerIndexBitCountMask) + 1;
+ int innerIndexMask = (1 << innerBitCount) - 1;
+
+ DeltaSetIndexMap[] deltaSetIndexMaps = new DeltaSetIndexMap[mapCount];
+ for (int i = 0; i < mapCount; i++)
+ {
+ int entry = entrySize switch
+ {
+ 1 => reader.ReadByte(),
+ 2 => (reader.ReadByte() << 8) | reader.ReadByte(),
+ 3 => (reader.ReadByte() << 16) | (reader.ReadByte() << 8) | reader.ReadByte(),
+ 4 => (reader.ReadByte() << 24) | (reader.ReadByte() << 16) | (reader.ReadByte() << 8) | reader.ReadByte(),
+ _ => throw new NotSupportedException("unsupported delta set index map"),
+ };
+ deltaSetIndexMaps[i] = new DeltaSetIndexMap(entry >> innerBitCount, entry & innerIndexMask);
+ }
+
+ return deltaSetIndexMaps;
+ }
+}
diff --git a/src/SixLabors.Fonts/Tables/AdvancedTypographic/Variations/FVarTable.cs b/src/SixLabors.Fonts/Tables/AdvancedTypographic/Variations/FVarTable.cs
new file mode 100644
index 000000000..d52bd89f9
--- /dev/null
+++ b/src/SixLabors.Fonts/Tables/AdvancedTypographic/Variations/FVarTable.cs
@@ -0,0 +1,98 @@
+// Copyright (c) Six Labors.
+// Licensed under the Six Labors Split License.
+
+namespace SixLabors.Fonts.Tables.AdvancedTypographic.Variations;
+
+///
+/// Implements reading the Font Variations Table `fvar`.
+///
+///
+internal class FVarTable : Table
+{
+ internal const string TableName = "fvar";
+
+ public FVarTable(ushort axisCount, VariationAxisRecord[] axes, InstanceRecord[] instances)
+ {
+ this.AxisCount = axisCount;
+ this.Axes = axes;
+ this.Instances = instances;
+ }
+
+ public ushort AxisCount { get; }
+
+ public VariationAxisRecord[] Axes { get; }
+
+ public InstanceRecord[] Instances { get; }
+
+ public static FVarTable? Load(FontReader reader)
+ {
+ if (!reader.TryGetReaderAtTablePosition(TableName, out BigEndianBinaryReader? binaryReader))
+ {
+ return null;
+ }
+
+ using (binaryReader)
+ {
+ return Load(binaryReader);
+ }
+ }
+
+ public static FVarTable Load(BigEndianBinaryReader reader)
+ {
+ // VariationsTable `fvar`
+ // +-----------------+----------------------------------------+----------------------------------------------------------------+
+ // | Type | Name | Description |
+ // +=================+========================================+================================================================+
+ // | uint16 | majorVersion | Major version number of the font variations table — set to 1. |
+ // +-----------------+----------------------------------------+----------------------------------------------------------------+
+ // | uint16 | minorVersion | Minor version number of the font variations table — set to 0. |
+ // +-----------------+----------------------------------------+----------------------------------------------------------------+
+ // | Offset16 | axesArrayOffset | Offset in bytes from the beginning of the table to the start |
+ // | | | of the VariationAxisRecord array. |
+ // +-----------------+----------------------------------------+----------------------------------------------------------------+
+ // | uint16 | (reserved) | This field is permanently reserved. Set to 2. |
+ // +-----------------+----------------------------------------+----------------------------------------------------------------+
+ // | uint16 | axisCount | The number of variation axes in the font |
+ // | | | (the number of records in the axes array). |
+ // +-----------------+----------------------------------------+----------------------------------------------------------------+
+ // | uint16 | axisSize | The size in bytes of each VariationAxisRecord |
+ // | | | — set to 20 (0x0014) for this version. |
+ // +-----------------+----------------------------------------+----------------------------------------------------------------+
+ // | uint16 | instanceCount | The number of named instances defined in the font |
+ // | | | (the number of records in the instances array). |
+ // +-----------------+----------------------------------------+----------------------------------------------------------------+
+ // | uint16 | instanceSize | The size in bytes of each InstanceRecord |
+ // | | | — set to either axisCount * sizeof(Fixed) + 4, |
+ // | | | or to axisCount * sizeof(Fixed) + 6. |
+ // +-----------------+----------------------------------------+----------------------------------------------------------------+
+ long startOffset = reader.BaseStream.Position;
+ ushort major = reader.ReadUInt16();
+ ushort minor = reader.ReadUInt16();
+ ushort axesArrayOffset = reader.ReadOffset16();
+ ushort reserved = reader.ReadUInt16();
+ ushort axisCount = reader.ReadUInt16();
+ ushort axisSize = reader.ReadUInt16();
+ ushort instanceCount = reader.ReadUInt16();
+ ushort instanceSize = reader.ReadUInt16();
+
+ if (major != 1)
+ {
+ throw new NotSupportedException("Only version 1 of fvar table is supported");
+ }
+
+ VariationAxisRecord[] axesArray = new VariationAxisRecord[axisCount];
+ for (int i = 0; i < axisCount; i++)
+ {
+ axesArray[i] = VariationAxisRecord.Load(reader, axesArrayOffset + (axisSize * i));
+ }
+
+ InstanceRecord[] instances = new InstanceRecord[instanceCount];
+ long instancesOffset = reader.BaseStream.Position - startOffset;
+ for (int i = 0; i < instanceCount; i++)
+ {
+ instances[i] = InstanceRecord.Load(reader, instancesOffset + (i * instanceSize), axisCount);
+ }
+
+ return new FVarTable(axisCount, axesArray, instances);
+ }
+}
diff --git a/src/SixLabors.Fonts/Tables/AdvancedTypographic/Variations/GVarTable.cs b/src/SixLabors.Fonts/Tables/AdvancedTypographic/Variations/GVarTable.cs
new file mode 100644
index 000000000..41cb615a0
--- /dev/null
+++ b/src/SixLabors.Fonts/Tables/AdvancedTypographic/Variations/GVarTable.cs
@@ -0,0 +1,160 @@
+// Copyright (c) Six Labors.
+// Licensed under the Six Labors Split License.
+
+namespace SixLabors.Fonts.Tables.AdvancedTypographic.Variations;
+
+///
+/// Implements reading the Font Variations Table `gvar`.
+///
+///
+internal class GVarTable : Table
+{
+ internal const string TableName = "gvar";
+
+ public GVarTable(ushort axisCount, ushort glyphCount, float[,] sharedTuples, GlyphVariationData[] glyphVariations)
+ {
+ this.AxisCount = axisCount;
+ this.GlyphCount = glyphCount;
+ this.SharedTuples = sharedTuples;
+ this.GlyphVariations = glyphVariations;
+ }
+
+ public ushort AxisCount { get; }
+
+ public ushort GlyphCount { get; }
+
+ public float[,] SharedTuples { get; }
+
+ public GlyphVariationData[] GlyphVariations { get; }
+
+ public static GVarTable? Load(FontReader reader)
+ {
+ if (!reader.TryGetReaderAtTablePosition(TableName, out BigEndianBinaryReader? binaryReader, out TableHeader? header))
+ {
+ return null;
+ }
+
+ using (binaryReader)
+ {
+ return Load(binaryReader, header);
+ }
+ }
+
+ public static GVarTable Load(BigEndianBinaryReader reader, TableHeader header)
+ {
+ // VariationsTable `gvar`
+ // +-----------------+----------------------------------------+-------------------------------------------------------------------------+
+ // | Type | Name | Description |
+ // +=================+========================================+=========================================================================+
+ // | uint16 | majorVersion | Major version number of the font variations table — set to 1. |
+ // +-----------------+----------------------------------------+-------------------------------------------------------------------------+
+ // | uint16 | minorVersion | Minor version number of the font variations table — set to 0. |
+ // +-----------------+----------------------------------------+-------------------------------------------------------------------------+
+ // | uint16 | axisCount | The number of variation axes in the font |
+ // | | | (the number of records in the axes array). |
+ // +-----------------+----------------------------------------+-------------------------------------------------------------------------+
+ // | uint16 | sharedTupleCount | The number of shared tuple records. Shared tuple records can |
+ // | | | be referenced within glyph variation data tables for multiple glyphs, |
+ // | | | as opposed to other tuple records stored directly within a glyph |
+ // | | | variation data table. |
+ // +-----------------+----------------------------------------+-------------------------------------------------------------------------+
+ // | Offset32 | sharedTuplesOffset | Offset from the start of this table to the shared tuple records. |
+ // +-----------------+----------------------------------------+-------------------------------------------------------------------------+
+ // | uint16 | glyphCount | The number of glyphs in this font. This must match the number of glyphs |
+ // | | | stored elsewhere in the font. |
+ // +-----------------+----------------------------------------+-------------------------------------------------------------------------+
+ // | uint16 | flags | Bit-field that gives the format of the offset array that follows. |
+ // | | | If bit 0 is clear, the offsets are uint16; if bit 0 is set, |
+ // | | | the offsets are uint32. |
+ // +-----------------+----------------------------------------+-------------------------------------------------------------------------+
+ // | Offset32 | glyphVariationDataArrayOffset | Offset from the start of this table to the array of GlyphVariationData |
+ // | | | tables. |
+ // +-----------------+----------------------------------------+-------------------------------------------------------------------------+
+ // | Offset16 or | glyphVariationDataOffsets[glyphCount+1]| Offsets from the start of the GlyphVariationData array to each |
+ // | Offset32 | | GlyphVariationData table. |
+ // +-----------------+----------------------------------------+-------------------------------------------------------------------------+
+ uint gvarTableLength = header.Length;
+ ushort major = reader.ReadUInt16();
+ ushort minor = reader.ReadUInt16();
+ ushort axisCount = reader.ReadUInt16();
+ ushort sharedTupleCount = reader.ReadUInt16();
+ uint sharedTuplesOffset = reader.ReadOffset32();
+ ushort glyphCount = reader.ReadUInt16();
+ ushort flags = reader.ReadUInt16();
+ bool is32BitOffset = (flags & 1) == 1;
+ uint glyphVariationDataArrayOffset = reader.ReadOffset32();
+
+ if (major != 1)
+ {
+ throw new NotSupportedException("Only version 1 of gvar table is supported");
+ }
+
+ // Read glyphVariationDataOffsets[glyphCount + 1] immediately after the header,
+ // as required by the spec and as done by FreeType.
+ int offsetCount = glyphCount + 1;
+ uint[] glyphVariationOffsets = new uint[offsetCount];
+
+ for (int i = 0; i < offsetCount; i++)
+ {
+ // If offsets are 16-bit, values are stored in units of 2 bytes.
+ glyphVariationOffsets[i] = is32BitOffset
+ ? reader.ReadUInt32()
+ : (uint)(reader.ReadUInt16() * 2);
+ }
+
+ // Shared tuple records
+ float[,] sharedTuples = new float[sharedTupleCount, axisCount];
+
+ if (sharedTupleCount > 0 && axisCount > 0)
+ {
+ long tuplesPos = sharedTuplesOffset;
+ long tuplesLimit = glyphVariationDataArrayOffset;
+ long bytesPerTuple = (long)axisCount * 2;
+ long bytesAvailable = tuplesLimit - tuplesPos;
+
+ long maxTuples = bytesAvailable > 0
+ ? bytesAvailable / bytesPerTuple
+ : 0;
+
+ int tuplesToRead = (int)Math.Min(sharedTupleCount, maxTuples);
+
+ reader.Seek(tuplesPos, SeekOrigin.Begin);
+
+ for (int i = 0; i < tuplesToRead; i++)
+ {
+ for (int j = 0; j < axisCount; j++)
+ {
+ sharedTuples[i, j] = reader.ReadF2Dot14();
+ }
+ }
+
+ // Any remaining tuples default to 0.0F.
+ }
+
+ // GlyphVariationData tables
+ long glyphDataBase = glyphVariationDataArrayOffset;
+ GlyphVariationData[] glyphVariations = new GlyphVariationData[glyphCount];
+
+ // Reader is positioned at table start
+ long gvarEnd = gvarTableLength;
+
+ GlyphVariationData empty = new([]);
+ for (int i = 0; i < glyphCount; i++)
+ {
+ long start = glyphDataBase + glyphVariationOffsets[i];
+ long end = glyphDataBase + glyphVariationOffsets[i + 1]; // spec gives glyphCount+1 offsets
+
+ // Validate range (must be within table and non-decreasing).
+ // Equal offsets mean the glyph has no variation data.
+ if (start == end || start < glyphDataBase || end < start || end > gvarEnd || start + 2 > gvarEnd)
+ {
+ glyphVariations[i] = empty;
+ continue;
+ }
+
+ glyphVariations[i] = GlyphVariationData.Load(reader, start, axisCount);
+ }
+
+ return new GVarTable(axisCount, glyphCount, sharedTuples, glyphVariations);
+ }
+}
diff --git a/src/SixLabors.Fonts/Tables/AdvancedTypographic/Variations/GlyphVariationData.cs b/src/SixLabors.Fonts/Tables/AdvancedTypographic/Variations/GlyphVariationData.cs
new file mode 100644
index 000000000..13ce85d9f
--- /dev/null
+++ b/src/SixLabors.Fonts/Tables/AdvancedTypographic/Variations/GlyphVariationData.cs
@@ -0,0 +1,304 @@
+// Copyright (c) Six Labors.
+// Licensed under the Six Labors Split License.
+
+namespace SixLabors.Fonts.Tables.AdvancedTypographic.Variations;
+
+///
+/// Implements loading glyph variation data structure.
+///
+///
+internal class GlyphVariationData
+{
+ ///
+ /// Mask for the low bits to give the number of tuple variation tables.
+ ///
+ internal const int CountMask = 0x0FFF;
+
+ ///
+ /// Flag indicating that some or all tuple variation tables reference a shared set of "point" numbers.
+ /// These shared numbers are represented as packed point number data at the start of the serialized data.
+ ///
+ internal const int SharedPointNumbersMask = 0x8000;
+
+ ///
+ /// Flag indicating that packed deltas are zero and omitted. Lower 6 bits give run count - 1.
+ ///
+ private const int DeltasAreZero = 0x80;
+
+ ///
+ /// Flag indicating that packed deltas are 16-bit (int16). Lower 6 bits give run count - 1.
+ /// If neither nor is set, deltas are 8-bit (int8).
+ ///
+ private const int DeltasAreWords = 0x40;
+
+ ///
+ /// Mask for the lower 6 bits of a delta run header, giving run count - 1.
+ ///
+ private const int DeltaRunCountMask = 0x3F;
+
+ ///
+ /// Flag in the first byte of packed point numbers indicating that point numbers are 16-bit.
+ ///
+ private const int PointsAreWords = 0x80;
+
+ ///
+ /// Mask for the lower 7 bits of a point run header, giving run count - 1.
+ ///
+ private const int PointRunCountMask = 0x7F;
+
+ public GlyphVariationData(TupleVariationHeader[] tupleHeaders)
+ => this.TupleHeaders = tupleHeaders;
+
+ ///
+ /// Gets the tuple variation headers with their decoded point indices and deltas.
+ ///
+ public TupleVariationHeader[] TupleHeaders { get; }
+
+ ///
+ /// Gets a value indicating whether this glyph has any variation data.
+ ///
+ public bool HasData => this.TupleHeaders.Length > 0;
+
+ public static GlyphVariationData Load(BigEndianBinaryReader reader, long offset, int axisCount)
+ {
+ // GlyphVariationData
+ // +----------------------+-------------------------------------------+------------------------------------------------------------------------------+
+ // | Type | Name | Description |
+ // +======================+===========================================+==============================================================================+
+ // | uint16 | tupleVariationCount | A packed field. The high 4 bits are flags, |
+ // | | | and the low 12 bits are the number of tuple variation tables for this glyph. |
+ // | | | The count can be any number between 1 and 4095. |
+ // +----------------------+-------------------------------------------+------------------------------------------------------------------------------+
+ // | Offset16 | dataOffset | Offset from the start of the GlyphVariationData table to the serialized data.|
+ // +----------------------+-------------------------------------------+------------------------------------------------------------------------------+
+ // | TupleVariation | tupleVariationHeaders[tupleVariationCount]| Array of tuple variation headers. |
+ // +----------------------+-------------------------------------------+------------------------------------------------------------------------------+
+ // NOTE: 'offset' is relative to the start of the gvar table.
+ reader.Seek(offset, SeekOrigin.Begin);
+ ushort tupleVariationCount = reader.ReadUInt16();
+ bool hasSharedPointNumbers = (tupleVariationCount & SharedPointNumbersMask) != 0;
+ int tupleCount = tupleVariationCount & CountMask;
+
+ // Spec: dataOffset is Offset16 (always 16-bit), independent of the gvar offset array format.
+ // This offset is relative to the start of this GlyphVariationData table.
+ ushort serializedDataOffset = reader.ReadOffset16();
+
+ // Read all tuple variation headers first (they come before the serialized data).
+ TupleVariation[] tupleVariations = new TupleVariation[tupleCount];
+ for (int i = 0; i < tupleCount; i++)
+ {
+ tupleVariations[i] = TupleVariation.Load(reader, axisCount);
+ }
+
+ // Now read the serialized data that follows the headers.
+ long serializedDataPos = offset + serializedDataOffset;
+ reader.Seek(serializedDataPos, SeekOrigin.Begin);
+
+ // If shared point numbers flag is set, decode them from the start of the serialized data.
+ ushort[]? sharedPointNumbers = null;
+ if (hasSharedPointNumbers)
+ {
+ sharedPointNumbers = DecodePackedPoints(reader);
+ }
+
+ // Decode each tuple's serialized data (point numbers and deltas).
+ TupleVariationHeader[] tupleHeaders = new TupleVariationHeader[tupleCount];
+ for (int i = 0; i < tupleCount; i++)
+ {
+ TupleVariation header = tupleVariations[i];
+ long tupleDataStart = reader.BaseStream.Position;
+
+ // Determine which point numbers this tuple uses.
+ ushort[]? pointNumbers;
+ if (header.HasPrivatePointNumbers)
+ {
+ pointNumbers = DecodePackedPoints(reader);
+ }
+ else
+ {
+ pointNumbers = sharedPointNumbers;
+ }
+
+ // The number of deltas to decode depends on whether specific points are referenced.
+ // If pointNumbers is empty (length 0), deltas apply to all points and the count
+ // is determined by the caller (TransformPoints). We use VariationDataSize to bound reading.
+ int nPoints = pointNumbers is { Length: > 0 } ? pointNumbers.Length : 0;
+
+ short[]? deltasX = null;
+ short[]? deltasY = null;
+ if (nPoints > 0)
+ {
+ deltasX = DecodePackedDeltas(reader, nPoints);
+ deltasY = DecodePackedDeltas(reader, nPoints);
+ }
+ else
+ {
+ // When no explicit points are specified, we need to read all remaining data
+ // for this tuple. The deltas apply to all glyph points + 4 phantom points.
+ // We cannot know the point count here, so we store the raw bytes and decode later.
+ // However, the simpler approach used by fontkit is to decode based on the remaining
+ // bytes in this tuple's data block. We'll defer full decoding to TransformPoints
+ // by storing the raw data range.
+ long bytesConsumed = reader.BaseStream.Position - tupleDataStart;
+ int remaining = header.VariationDataSize - (int)bytesConsumed;
+ if (remaining > 0)
+ {
+ // Store raw bytes for deferred decoding when we know the point count.
+ tupleHeaders[i] = new TupleVariationHeader(header, pointNumbers, null, null, reader.ReadBytes(remaining));
+ continue;
+ }
+ }
+
+ // Skip any remaining bytes for this tuple that we haven't consumed.
+ long consumed = reader.BaseStream.Position - tupleDataStart;
+ int skip = header.VariationDataSize - (int)consumed;
+ if (skip > 0)
+ {
+ reader.BaseStream.Position += skip;
+ }
+
+ tupleHeaders[i] = new TupleVariationHeader(header, pointNumbers, deltasX, deltasY, null);
+ }
+
+ return new GlyphVariationData(tupleHeaders);
+ }
+
+ ///
+ /// Decodes packed point numbers from the serialized data.
+ ///
+ /// The binary reader positioned at the packed point data.
+ ///
+ /// An array of absolute point indices, or an empty array if all points are referenced.
+ ///
+ ///
+ internal static ushort[] DecodePackedPoints(BigEndianBinaryReader reader)
+ {
+ // First byte determines the count of points.
+ byte firstByte = reader.ReadByte();
+ int count;
+ if ((firstByte & PointsAreWords) != 0)
+ {
+ // High bit set: count is ((firstByte & 0x7F) << 8) | nextByte.
+ count = ((firstByte & PointRunCountMask) << 8) | reader.ReadByte();
+ }
+ else
+ {
+ count = firstByte;
+ }
+
+ // A count of 0 means "all points" — return empty array as sentinel.
+ if (count == 0)
+ {
+ return [];
+ }
+
+ // Read run-length encoded point number deltas.
+ ushort[] points = new ushort[count];
+ int i = 0;
+ while (i < count)
+ {
+ byte runHeader = reader.ReadByte();
+ bool runPointsAreWords = (runHeader & PointsAreWords) != 0;
+ int runCount = (runHeader & PointRunCountMask) + 1;
+
+ ushort accumulator = i > 0 ? points[i - 1] : (ushort)0;
+ for (int j = 0; j < runCount && i < count; j++, i++)
+ {
+ ushort delta = runPointsAreWords ? reader.ReadUInt16() : reader.ReadByte();
+ accumulator += delta;
+ points[i] = accumulator;
+ }
+ }
+
+ return points;
+ }
+
+ ///
+ /// Decodes packed delta values from the serialized data.
+ ///
+ /// The binary reader positioned at the packed delta data.
+ /// The number of delta values to decode.
+ /// An array of decoded delta values.
+ ///
+ internal static short[] DecodePackedDeltas(BigEndianBinaryReader reader, int count)
+ {
+ short[] deltas = new short[count];
+ int i = 0;
+ while (i < count)
+ {
+ byte runHeader = reader.ReadByte();
+ bool areZero = (runHeader & DeltasAreZero) != 0;
+ bool areWords = (runHeader & DeltasAreWords) != 0;
+ int runCount = (runHeader & DeltaRunCountMask) + 1;
+
+ for (int j = 0; j < runCount && i < count; j++, i++)
+ {
+ if (areZero)
+ {
+ deltas[i] = 0;
+ }
+ else if (areWords)
+ {
+ deltas[i] = reader.ReadInt16();
+ }
+ else
+ {
+ deltas[i] = (short)(sbyte)reader.ReadByte();
+ }
+ }
+ }
+
+ return deltas;
+ }
+}
+
+///
+/// Represents a fully decoded tuple variation header with its associated point indices and delta values.
+///
+internal class TupleVariationHeader
+{
+ public TupleVariationHeader(
+ TupleVariation tupleVariation,
+ ushort[]? pointNumbers,
+ short[]? deltasX,
+ short[]? deltasY,
+ byte[]? rawDeltaData)
+ {
+ this.TupleVariation = tupleVariation;
+ this.PointNumbers = pointNumbers;
+ this.DeltasX = deltasX;
+ this.DeltasY = deltasY;
+ this.RawDeltaData = rawDeltaData;
+ }
+
+ ///
+ /// Gets the tuple variation header containing peak coordinates and flags.
+ ///
+ public TupleVariation TupleVariation { get; }
+
+ ///
+ /// Gets the point indices this tuple applies to.
+ /// An empty array means all points are referenced.
+ /// Null means no point data was available.
+ ///
+ public ushort[]? PointNumbers { get; }
+
+ ///
+ /// Gets the X coordinate deltas for the referenced points.
+ /// Null when deltas apply to all points and were deferred (see ).
+ ///
+ public short[]? DeltasX { get; }
+
+ ///
+ /// Gets the Y coordinate deltas for the referenced points.
+ /// Null when deltas apply to all points and were deferred (see ).
+ ///
+ public short[]? DeltasY { get; }
+
+ ///
+ /// Gets the raw serialized delta data for deferred decoding.
+ /// This is used when point numbers indicate "all points" and the actual point count
+ /// is not known until is called.
+ ///
+ public byte[]? RawDeltaData { get; }
+}
diff --git a/src/SixLabors.Fonts/Tables/AdvancedTypographic/Variations/GlyphVariationProcessor.cs b/src/SixLabors.Fonts/Tables/AdvancedTypographic/Variations/GlyphVariationProcessor.cs
new file mode 100644
index 000000000..704b96f52
--- /dev/null
+++ b/src/SixLabors.Fonts/Tables/AdvancedTypographic/Variations/GlyphVariationProcessor.cs
@@ -0,0 +1,1028 @@
+// Copyright (c) Six Labors.
+// Licensed under the Six Labors Split License.
+
+using System.Collections.Concurrent;
+using System.Numerics;
+using SixLabors.Fonts.Tables.TrueType.Glyphs;
+
+namespace SixLabors.Fonts.Tables.AdvancedTypographic.Variations;
+
+///
+///
+/// This class transforms TrueType glyphs according to the data from
+/// the OpenType variation tables (fvar, gvar, avar, HVAR, VVAR).
+/// These tables allow infinite adjustments to glyph weight, width, slant,
+/// and optical size without the designer needing to specify every exact style.
+///
+/// Implementation is based on fontkit:
+/// Docs for the item variations:
+///
+internal class GlyphVariationProcessor
+{
+ private readonly ItemVariationStore? itemStore;
+
+ private readonly FVarTable fvar;
+
+ private readonly AVarTable? avar;
+
+ private readonly GVarTable? gVar;
+
+ private readonly HVarTable? hVar;
+
+ private readonly VVarTable? vVar;
+
+ private readonly MVarTable? mVar;
+
+ private readonly CVarTable? cVar;
+
+ private readonly float[] normalizedCoords;
+
+ private readonly ConcurrentDictionary blendVectors;
+
+ ///
+ /// Cached CVT values with cvar deltas applied, keyed by the base CVT array.
+ /// Computed once per unique base CVT since the result depends only on
+ /// normalized coordinates and the base values, not on the glyph.
+ ///
+ private readonly ConcurrentDictionary cvtCache = new(ReferenceEqualityComparer.Instance);
+
+ public GlyphVariationProcessor(
+ ItemVariationStore? itemStore,
+ FVarTable fVar,
+ AVarTable? aVar = null,
+ GVarTable? gVar = null,
+ HVarTable? hVar = null,
+ VVarTable? vVar = null,
+ MVarTable? mVar = null,
+ CVarTable? cVar = null,
+ float[]? userCoordinates = null)
+ {
+ DebugGuard.NotNull(fVar, nameof(fVar));
+
+ this.itemStore = itemStore;
+ this.fvar = fVar;
+ this.avar = aVar;
+ this.gVar = gVar;
+ this.hVar = hVar;
+ this.vVar = vVar;
+ this.mVar = mVar;
+ this.cVar = cVar;
+ this.normalizedCoords = this.NormalizeCoords(userCoordinates);
+ this.blendVectors = new();
+ }
+
+ ///
+ /// Gets the normalized variation coordinates for this processor instance.
+ /// Used by FeatureVariations condition evaluation.
+ ///
+ internal ReadOnlySpan NormalizedCoordinates => this.normalizedCoords;
+
+ ///
+ /// Transforms glyph outline points by applying gvar variation deltas.
+ ///
+ /// The glyph identifier.
+ /// The glyph vector whose control points will be modified in-place.
+ public void TransformPoints(ushort glyphId, ref GlyphVector glyphPoints)
+ {
+ if (this.gVar is null)
+ {
+ return;
+ }
+
+ if (glyphId >= this.gVar.GlyphCount)
+ {
+ return;
+ }
+
+ GlyphVariationData variationData = this.gVar.GlyphVariations[glyphId];
+ if (!variationData.HasData)
+ {
+ return;
+ }
+
+ if (glyphPoints.IsComposite && glyphPoints.CompositeComponents is not null)
+ {
+ this.TransformCompositePoints(variationData, ref glyphPoints);
+ }
+ else
+ {
+ this.TransformSimplePoints(variationData, ref glyphPoints);
+ }
+ }
+
+ private void TransformSimplePoints(GlyphVariationData variationData, ref GlyphVector glyphPoints)
+ {
+ IList controlPoints = glyphPoints.ControlPoints;
+ int pointCount = controlPoints.Count;
+
+ // gvar encodes deltas for outline points + 4 phantom points (LSB, advance width,
+ // TSB, advance height). We must decode all of them so X/Y delta streams stay aligned,
+ // even though we only apply deltas to the outline points.
+ const int PhantomPointCount = 4;
+ int totalPointCount = pointCount + PhantomPointCount;
+
+ // Clone the original points for IUP reference (interpolation needs unmodified originals).
+ GlyphVector originPoints = GlyphVector.DeepClone(glyphPoints);
+ IList origPoints = originPoints.ControlPoints;
+
+ foreach (TupleVariationHeader tupleHeader in variationData.TupleHeaders)
+ {
+ float factor = this.ResolveTupleFactor(tupleHeader);
+ if (factor == 0)
+ {
+ continue;
+ }
+
+ // Resolve point numbers and deltas.
+ ushort[]? pointNumbers = tupleHeader.PointNumbers;
+ short[]? deltasX = tupleHeader.DeltasX;
+ short[]? deltasY = tupleHeader.DeltasY;
+
+ // If deltas were deferred (all-points case), decode them now that we know the point count.
+ // Use totalPointCount (outline + phantom) so Y deltas start at the correct stream offset.
+ if (deltasX is null && tupleHeader.RawDeltaData is not null)
+ {
+ DecodeAllPointDeltas(tupleHeader.RawDeltaData, totalPointCount, out deltasX, out deltasY);
+ }
+
+ if (deltasX is null || deltasY is null)
+ {
+ continue;
+ }
+
+ bool allPoints = pointNumbers is null or { Length: 0 };
+
+ if (allPoints)
+ {
+ // Deltas apply to all points directly. Only apply to outline points (skip phantom).
+ int deltaCount = Math.Min(deltasX.Length, pointCount);
+ for (int i = 0; i < deltaCount; i++)
+ {
+ ControlPoint cp = controlPoints[i];
+ cp.Point.X += MathF.Round(deltasX[i] * factor);
+ cp.Point.Y += MathF.Round(deltasY[i] * factor);
+ controlPoints[i] = cp;
+ }
+ }
+ else
+ {
+ // Deltas apply to specific points only; interpolate the rest.
+ using Buffer adjustXBuf = new(pointCount, clear: true);
+ using Buffer adjustYBuf = new(pointCount, clear: true);
+ using Buffer hasDeltaBuf = new(pointCount, clear: true);
+ Span adjustX = adjustXBuf.GetSpan();
+ Span adjustY = adjustYBuf.GetSpan();
+ Span hasDelta = hasDeltaBuf.GetSpan();
+
+ for (int i = 0; i < pointNumbers!.Length && i < deltasX.Length; i++)
+ {
+ int ptIdx = pointNumbers[i];
+ if (ptIdx < pointCount)
+ {
+ hasDelta[ptIdx] = 1;
+
+ // Round before IUP interpolation to match fontkit / FreeType behavior.
+ // IUP references rounded absolute positions, so rounding must happen first.
+ adjustX[ptIdx] = MathF.Round(deltasX[i] * factor);
+ adjustY[ptIdx] = MathF.Round(deltasY[i] * factor);
+ }
+ }
+
+ // Interpolate unreferenced points.
+ InterpolateMissingDeltas(
+ controlPoints,
+ origPoints,
+ glyphPoints.EndPoints,
+ adjustX,
+ adjustY,
+ hasDelta);
+
+ // Apply the accumulated deltas (already rounded for explicit points,
+ // IUP-interpolated for implicit points).
+ for (int i = 0; i < pointCount; i++)
+ {
+ ControlPoint cp = controlPoints[i];
+ cp.Point.X += adjustX[i];
+ cp.Point.Y += adjustY[i];
+ controlPoints[i] = cp;
+ }
+ }
+ }
+
+ // Recalculate bounds from the transformed points.
+ glyphPoints.Bounds = CalculateBounds(controlPoints);
+ }
+
+ ///
+ /// Transforms a composite glyph by applying gvar deltas to component offsets.
+ /// For composite glyphs, gvar stores deltas for a synthetic point array:
+ /// one point per component (at the component's offset) plus 4 phantom points.
+ /// After applying deltas, the offset changes are propagated to all assembled
+ /// outline points belonging to each component.
+ ///
+ private void TransformCompositePoints(GlyphVariationData variationData, ref GlyphVector glyphPoints)
+ {
+ CompositeComponent[] components = glyphPoints.CompositeComponents!;
+ int componentCount = components.Length;
+
+ // gvar "point count" for composites = number of components + 4 phantom points.
+ int syntheticPointCount = componentCount + 4;
+
+ // Build synthetic points from component offsets.
+ using Buffer synXBuf = new(syntheticPointCount, clear: true);
+ using Buffer synYBuf = new(syntheticPointCount, clear: true);
+ Span synX = synXBuf.GetSpan();
+ Span synY = synYBuf.GetSpan();
+
+ for (int i = 0; i < componentCount; i++)
+ {
+ synX[i] = components[i].Dx;
+ synY[i] = components[i].Dy;
+ }
+
+ // Phantom points (LSB, advance width, TSB, advance height) are initialized to 0
+ // and will receive deltas from gvar if present.
+
+ // Apply each tuple's deltas to the synthetic points.
+ foreach (TupleVariationHeader tupleHeader in variationData.TupleHeaders)
+ {
+ float factor = this.ResolveTupleFactor(tupleHeader);
+ if (factor == 0)
+ {
+ continue;
+ }
+
+ ushort[]? pointNumbers = tupleHeader.PointNumbers;
+ short[]? deltasX = tupleHeader.DeltasX;
+ short[]? deltasY = tupleHeader.DeltasY;
+
+ if (deltasX is null && tupleHeader.RawDeltaData is not null)
+ {
+ DecodeAllPointDeltas(tupleHeader.RawDeltaData, syntheticPointCount, out deltasX, out deltasY);
+ }
+
+ if (deltasX is null || deltasY is null)
+ {
+ continue;
+ }
+
+ bool allPoints = pointNumbers is null or { Length: 0 };
+
+ if (allPoints)
+ {
+ int deltaCount = Math.Min(deltasX.Length, syntheticPointCount);
+ for (int i = 0; i < deltaCount; i++)
+ {
+ synX[i] += deltasX[i] * factor;
+ synY[i] += deltasY[i] * factor;
+ }
+ }
+ else
+ {
+ for (int i = 0; i < pointNumbers!.Length && i < deltasX.Length; i++)
+ {
+ int ptIdx = pointNumbers[i];
+ if (ptIdx < syntheticPointCount)
+ {
+ synX[ptIdx] += deltasX[i] * factor;
+ synY[ptIdx] += deltasY[i] * factor;
+ }
+ }
+ }
+ }
+
+ // Propagate offset changes to assembled outline points.
+ IList controlPoints = glyphPoints.ControlPoints;
+ int pointOffset = 0;
+ for (int c = 0; c < componentCount; c++)
+ {
+ float deltaX = MathF.Round(synX[c] - components[c].Dx);
+ float deltaY = MathF.Round(synY[c] - components[c].Dy);
+
+ if (deltaX != 0 || deltaY != 0)
+ {
+ int end = pointOffset + components[c].PointCount;
+ for (int p = pointOffset; p < end && p < controlPoints.Count; p++)
+ {
+ ControlPoint cp = controlPoints[p];
+ cp.Point.X += deltaX;
+ cp.Point.Y += deltaY;
+ controlPoints[p] = cp;
+ }
+ }
+
+ pointOffset += components[c].PointCount;
+ }
+
+ // Recalculate bounds from the transformed points.
+ glyphPoints.Bounds = CalculateBounds(controlPoints);
+ }
+
+ ///
+ /// Gets the horizontal advance width adjustment for the given glyph from the HVAR table.
+ /// Returns 0 if no HVAR table is present.
+ ///
+ /// The glyph identifier.
+ /// The advance width delta value.
+ public float AdvanceAdjustment(int glyphId)
+ {
+ if (this.hVar is null)
+ {
+ return 0;
+ }
+
+ return this.GetMetricDelta(glyphId, this.hVar.AdvanceWidthMapping, this.hVar.ItemVariationStore);
+ }
+
+ ///
+ /// Gets the vertical advance height adjustment for the given glyph from the VVAR table.
+ /// Returns 0 if no VVAR table is present.
+ ///
+ /// The glyph identifier.
+ /// The advance height delta value.
+ public float VerticalAdvanceAdjustment(int glyphId)
+ {
+ if (this.vVar is null)
+ {
+ return 0;
+ }
+
+ return this.GetMetricDelta(glyphId, this.vVar.AdvanceWidthMapping, this.vVar.ItemVariationStore);
+ }
+
+ ///
+ /// Gets the delta adjustment for a global font metric from the MVAR table.
+ /// Returns 0 if no MVAR table is present or the tag is not found.
+ ///
+ /// The MVAR metric tag (e.g. 'hasc', 'hdsc').
+ /// The metric delta value.
+ public float GetMVarDelta(Tag tag)
+ {
+ if (this.mVar is null)
+ {
+ return 0;
+ }
+
+ if (!this.mVar.TryGetIndices(tag, out ushort outerIndex, out ushort innerIndex))
+ {
+ return 0;
+ }
+
+ return this.ComputeDelta(this.mVar.ItemVariationStore, outerIndex, innerIndex);
+ }
+
+ ///
+ /// Applies cvar (CVT Variations) deltas to the base CVT values.
+ /// The result is computed once and cached, since cvar deltas depend only on
+ /// normalized axis coordinates, not on the glyph being processed.
+ /// Returns an adjusted copy of the CVT values with variation deltas applied,
+ /// or null if there is no cvar data.
+ ///
+ /// The base CVT values from the cvt table.
+ /// The varied CVT values, or null if no cvar table is present.
+ public short[]? ApplyCvtDeltas(short[] baseCvt)
+ {
+ if (this.cVar is null || this.cVar.TupleVariations.Length == 0)
+ {
+ return null;
+ }
+
+ return this.cvtCache.GetOrAdd(baseCvt, this.ComputeCvtDeltas);
+ }
+
+ private short[] ComputeCvtDeltas(short[] baseCvt)
+ {
+ // Work on a copy so we don't modify the original CVT values.
+ short[] varied = new short[baseCvt.Length];
+ Array.Copy(baseCvt, varied, baseCvt.Length);
+
+ foreach (CVarTupleVariation cvarTuple in this.cVar!.TupleVariations)
+ {
+ TupleVariation tuple = cvarTuple.TupleVariation;
+
+ // cvar always has embedded peak coordinates (per spec).
+ float[]? peakCoords = tuple.EmbeddedPeak;
+ if (peakCoords is null)
+ {
+ continue;
+ }
+
+ float factor = this.TupleFactor(
+ tuple.IsIntermediateRegion,
+ peakCoords,
+ tuple.IntermediateStartRegion,
+ tuple.IntermediateEndRegion);
+
+ if (factor == 0)
+ {
+ continue;
+ }
+
+ short[]? deltas = cvarTuple.Deltas;
+ ushort[]? pointNumbers = cvarTuple.PointNumbers;
+
+ // Handle deferred decoding for "all points" case.
+ if (deltas is null && cvarTuple.RawDeltaData is not null)
+ {
+ using MemoryStream ms = new(cvarTuple.RawDeltaData);
+ using BigEndianBinaryReader deltaReader = new(ms, false);
+ deltas = GlyphVariationData.DecodePackedDeltas(deltaReader, baseCvt.Length);
+ }
+
+ if (deltas is null)
+ {
+ continue;
+ }
+
+ bool allPoints = pointNumbers is null or { Length: 0 };
+ if (allPoints)
+ {
+ // Deltas apply to all CVT entries.
+ int count = Math.Min(deltas.Length, varied.Length);
+ for (int i = 0; i < count; i++)
+ {
+ varied[i] += (short)MathF.Round(deltas[i] * factor);
+ }
+ }
+ else
+ {
+ // Deltas apply to specific CVT indices.
+ for (int i = 0; i < pointNumbers!.Length && i < deltas.Length; i++)
+ {
+ int idx = pointNumbers[i];
+ if (idx < varied.Length)
+ {
+ varied[idx] += (short)MathF.Round(deltas[i] * factor);
+ }
+ }
+ }
+ }
+
+ return varied;
+ }
+
+ ///
+ /// Computes the blend vector for the given outer index in the item variation store.
+ /// Used by the CFF2 blend operator.
+ ///
+ /// The outer index into the item variation store.
+ /// An array of blend scalars, one per region.
+ public float[] BlendVector(int outerIndex)
+ {
+ if (this.itemStore is null)
+ {
+ return [];
+ }
+
+ return this.GetOrComputeBlendVector(this.itemStore, outerIndex);
+ }
+
+ ///
+ /// Computes the delta adjustment for a specific item in the item variation store.
+ ///
+ /// The outer index.
+ /// The inner index.
+ /// The delta value.
+ internal float Delta(int outerIndex, int innerIndex)
+ {
+ if (this.itemStore is null)
+ {
+ return 0;
+ }
+
+ return this.ComputeDelta(this.itemStore, outerIndex, innerIndex);
+ }
+
+ ///
+ /// Computes the delta adjustment for a specific item using an external ItemVariationStore.
+ /// Used for GDEF-based variation deltas in GPOS/GSUB device tables.
+ ///
+ /// The external ItemVariationStore (e.g. from GDEF).
+ /// The outer index.
+ /// The inner index.
+ /// The delta value.
+ internal float Delta(ItemVariationStore store, int outerIndex, int innerIndex)
+ => this.ComputeDelta(store, outerIndex, innerIndex);
+
+ ///
+ /// Computes a delta from a given ItemVariationStore using cached blend vectors.
+ /// Shared by HVAR, VVAR, MVAR, and CFF2 delta lookups.
+ ///
+ private float ComputeDelta(ItemVariationStore store, int outerIndex, int innerIndex)
+ {
+ if (outerIndex >= store.ItemVariations.Length)
+ {
+ return 0;
+ }
+
+ ItemVariationData variationData = store.ItemVariations[outerIndex];
+ if (innerIndex >= variationData.DeltaSets.Length)
+ {
+ return 0;
+ }
+
+ DeltaSet deltaSet = variationData.DeltaSets[innerIndex];
+ float[] blendVector = this.GetOrComputeBlendVector(store, outerIndex);
+ float netAdjustment = 0;
+ for (int master = 0; master < variationData.RegionIndexes.Length; master++)
+ {
+ netAdjustment += deltaSet.Deltas[master] * blendVector[master];
+ }
+
+ return netAdjustment;
+ }
+
+ ///
+ /// Gets or computes the blend vector for a given outer index in the specified ItemVariationStore.
+ /// Results are cached by ItemVariationData instance.
+ ///
+ private float[] GetOrComputeBlendVector(ItemVariationStore store, int outerIndex)
+ {
+ ItemVariationData variationData = store.ItemVariations[outerIndex];
+ return this.blendVectors.GetOrAdd(variationData, _ => this.ComputeBlendVector(store, variationData));
+ }
+
+ private float[] ComputeBlendVector(ItemVariationStore store, ItemVariationData variationData)
+ {
+ float[] blendVector = new float[variationData.RegionIndexes.Length];
+ for (int i = 0; i < variationData.RegionIndexes.Length; i++)
+ {
+ float scalar = 1.0f;
+ ushort regionIndex = variationData.RegionIndexes[i];
+ RegionAxisCoordinates[] axes = store.VariationRegionList.VariationRegions[regionIndex];
+
+ for (int j = 0; j < axes.Length; j++)
+ {
+ RegionAxisCoordinates axis = axes[j];
+
+ float axisScalar;
+ if (axis.StartCoord > axis.PeakCoord || axis.PeakCoord > axis.EndCoord)
+ {
+ axisScalar = 1;
+ }
+ else if (axis.StartCoord < 0 && axis.EndCoord > 0 && axis.PeakCoord != 0)
+ {
+ axisScalar = 1;
+ }
+ else if (axis.PeakCoord == 0)
+ {
+ axisScalar = 1;
+ }
+ else if (this.normalizedCoords[j] < axis.StartCoord || this.normalizedCoords[j] > axis.EndCoord)
+ {
+ axisScalar = 0;
+ }
+ else
+ {
+ if (this.normalizedCoords[j] == axis.PeakCoord)
+ {
+ axisScalar = 1;
+ }
+ else if (this.normalizedCoords[j] < axis.PeakCoord)
+ {
+ axisScalar = (this.normalizedCoords[j] - axis.StartCoord) /
+ (axis.PeakCoord - axis.StartCoord);
+ }
+ else
+ {
+ axisScalar = (axis.EndCoord - this.normalizedCoords[j]) /
+ (axis.EndCoord - axis.PeakCoord);
+ }
+ }
+
+ scalar *= axisScalar;
+ }
+
+ blendVector[i] = scalar;
+ }
+
+ return blendVector;
+ }
+
+ ///
+ /// Resolves peak coordinates and computes the tuple factor for a given tuple header.
+ /// Shared helper used by both simple and composite glyph variation paths.
+ ///
+ /// The tuple variation header.
+ /// The blending factor, or 0 if the tuple should be skipped.
+ private float ResolveTupleFactor(TupleVariationHeader tupleHeader)
+ {
+ TupleVariation tuple = tupleHeader.TupleVariation;
+
+ // Resolve peak coordinates: either embedded or from shared tuples.
+ float[]? peakCoords = tuple.EmbeddedPeak;
+ if (peakCoords is null)
+ {
+ int sharedIdx = tuple.SharedTupleIndex;
+ if (sharedIdx >= this.gVar!.SharedTuples.GetLength(0))
+ {
+ return 0;
+ }
+
+ peakCoords = new float[this.gVar.AxisCount];
+ for (int a = 0; a < this.gVar.AxisCount; a++)
+ {
+ peakCoords[a] = this.gVar.SharedTuples[sharedIdx, a];
+ }
+ }
+
+ return this.TupleFactor(
+ tuple.IsIntermediateRegion,
+ peakCoords,
+ tuple.IntermediateStartRegion,
+ tuple.IntermediateEndRegion);
+ }
+
+ ///
+ /// Calculates the blending factor for a gvar tuple variation based on normalized coordinates.
+ ///
+ /// Whether this is an intermediate tuple with explicit start/end bounds.
+ /// The peak coordinates for this tuple.
+ /// The start coordinates (only for intermediate tuples).
+ /// The end coordinates (only for intermediate tuples).
+ /// A scalar factor in the range [0, 1] indicating how much this tuple contributes.
+ private float TupleFactor(bool isIntermediate, float[] peakCoords, float[]? startCoords, float[]? endCoords)
+ {
+ float factor = 1.0f;
+
+ for (int i = 0; i < this.normalizedCoords.Length && i < peakCoords.Length; i++)
+ {
+ if (peakCoords[i] == 0)
+ {
+ // This axis doesn't affect this tuple.
+ continue;
+ }
+
+ if (this.normalizedCoords[i] == 0)
+ {
+ // Normalized coordinate is at default; this tuple has no effect.
+ return 0;
+ }
+
+ if (!isIntermediate)
+ {
+ // Non-intermediate tuple: simple linear interpolation.
+ // The valid range is between 0 and the peak coordinate.
+ float minVal = MathF.Min(0, peakCoords[i]);
+ float maxVal = MathF.Max(0, peakCoords[i]);
+
+ if (this.normalizedCoords[i] < minVal || this.normalizedCoords[i] > maxVal)
+ {
+ return 0;
+ }
+
+ factor *= this.normalizedCoords[i] / peakCoords[i];
+ }
+ else
+ {
+ // Intermediate tuple: piecewise linear between start → peak → end.
+ if (this.normalizedCoords[i] < startCoords![i] || this.normalizedCoords[i] > endCoords![i])
+ {
+ return 0;
+ }
+
+ if (this.normalizedCoords[i] < peakCoords[i])
+ {
+ factor *= (this.normalizedCoords[i] - startCoords[i]) /
+ (peakCoords[i] - startCoords[i]);
+ }
+ else if (this.normalizedCoords[i] > peakCoords[i])
+ {
+ factor *= (endCoords![i] - this.normalizedCoords[i]) /
+ (endCoords[i] - peakCoords[i]);
+ }
+
+ // If exactly at peak, factor contribution is 1 (no change).
+ }
+ }
+
+ return factor;
+ }
+
+ ///
+ /// Normalizes axis coordinates to the [-1, 1] range and applies avar remapping if present.
+ ///
+ ///
+ /// Optional user-specified axis values in design space (e.g. weight=700).
+ /// If null, default axis values are used.
+ ///
+ /// An array of normalized coordinates for each axis.
+ private float[] NormalizeCoords(float[]? userCoordinates)
+ {
+ int axisCount = this.fvar.AxisCount;
+
+ // Use Buffer for temporary coords to avoid heap allocation.
+ using Buffer coordsBuf = new(axisCount);
+ Span coords = coordsBuf.GetSpan();
+
+ // Use user coordinates if provided, otherwise use defaults.
+ for (int i = 0; i < axisCount; i++)
+ {
+ VariationAxisRecord axis = this.fvar.Axes[i];
+ if (userCoordinates is not null && i < userCoordinates.Length)
+ {
+ // Clamp to valid axis range.
+ coords[i] = Math.Clamp(userCoordinates[i], axis.MinValue, axis.MaxValue);
+ }
+ else
+ {
+ coords[i] = axis.DefaultValue;
+ }
+ }
+
+ // The default mapping is linear along each axis, in two segments:
+ // from the minValue to defaultValue, and from defaultValue to maxValue.
+ float[] normalized = new float[axisCount];
+ for (int i = 0; i < axisCount; i++)
+ {
+ VariationAxisRecord axis = this.fvar.Axes[i];
+ if (coords[i] < axis.DefaultValue)
+ {
+ float denominator = axis.DefaultValue - axis.MinValue;
+ normalized[i] = denominator > 0
+ ? (coords[i] - axis.DefaultValue) / denominator
+ : 0;
+ }
+ else
+ {
+ float denominator = axis.MaxValue - axis.DefaultValue;
+ normalized[i] = denominator > 0
+ ? (coords[i] - axis.DefaultValue) / denominator
+ : 0;
+ }
+ }
+
+ // If there is an avar table, the normalized value is remapped
+ // by interpolating between the two nearest mapped values.
+ if (this.avar is not null)
+ {
+ int segmentCount = Math.Min(this.avar.SegmentMaps.Length, axisCount);
+ for (int i = 0; i < segmentCount; i++)
+ {
+ SegmentMapRecord segment = this.avar.SegmentMaps[i];
+ for (int j = 0; j < segment.AxisValueMap.Length; j++)
+ {
+ AxisValueMapRecord pair = segment.AxisValueMap[j];
+ if (j >= 1 && normalized[i] < pair.FromCoordinate)
+ {
+ AxisValueMapRecord prev = segment.AxisValueMap[j - 1];
+ float fromDelta = pair.FromCoordinate - prev.FromCoordinate;
+ if (fromDelta > 0)
+ {
+ normalized[i] = (((normalized[i] - prev.FromCoordinate) * (pair.ToCoordinate - prev.ToCoordinate)) /
+ fromDelta) + prev.ToCoordinate;
+ }
+
+ break;
+ }
+ }
+ }
+ }
+
+ return normalized;
+ }
+
+ private float GetMetricDelta(int glyphId, DeltaSetIndexMap[]? mapping, ItemVariationStore store)
+ {
+ int outerIndex;
+ int innerIndex;
+ if (mapping is { Length: > 0 })
+ {
+ int idx = Math.Min(glyphId, mapping.Length - 1);
+ outerIndex = mapping[idx].OuterIndex;
+ innerIndex = mapping[idx].InnerIndex;
+ }
+ else
+ {
+ outerIndex = 0;
+ innerIndex = glyphId;
+ }
+
+ return this.ComputeDelta(store, outerIndex, innerIndex);
+ }
+
+ ///
+ /// Decodes deferred delta data for the all-points case.
+ ///
+ private static void DecodeAllPointDeltas(byte[] rawData, int pointCount, out short[]? deltasX, out short[]? deltasY)
+ {
+ using MemoryStream ms = new(rawData);
+ using BigEndianBinaryReader reader = new(ms, false);
+ deltasX = GlyphVariationData.DecodePackedDeltas(reader, pointCount);
+ deltasY = GlyphVariationData.DecodePackedDeltas(reader, pointCount);
+ }
+
+ ///
+ /// Interpolates deltas for points that don't have explicit delta values.
+ /// Processes each contour independently.
+ ///
+ private static void InterpolateMissingDeltas(
+ IList points,
+ IList origPoints,
+ IReadOnlyList endPoints,
+ Span adjustX,
+ Span adjustY,
+ Span hasDelta)
+ {
+ if (points.Count == 0 || endPoints.Count == 0)
+ {
+ return;
+ }
+
+ int contourStart = 0;
+ for (int c = 0; c < endPoints.Count; c++)
+ {
+ int contourEnd = endPoints[c];
+
+ // Find first point with a delta in this contour.
+ int firstDelta = -1;
+ for (int p = contourStart; p <= contourEnd; p++)
+ {
+ if (hasDelta[p] != 0)
+ {
+ firstDelta = p;
+ break;
+ }
+ }
+
+ if (firstDelta < 0)
+ {
+ // No deltas in this contour, skip.
+ contourStart = contourEnd + 1;
+ continue;
+ }
+
+ int curDelta = firstDelta;
+ int p2 = firstDelta + 1;
+ while (p2 <= contourEnd)
+ {
+ if (hasDelta[p2] != 0)
+ {
+ // Interpolate the gap between curDelta and p2.
+ DeltaInterpolate(curDelta + 1, p2 - 1, curDelta, p2, origPoints, adjustX, adjustY);
+ curDelta = p2;
+ }
+
+ p2++;
+ }
+
+ if (curDelta == firstDelta)
+ {
+ // Only one delta point in this contour: shift all other points by the same amount.
+ DeltaShift(contourStart, contourEnd, curDelta, adjustX, adjustY);
+ }
+ else
+ {
+ // Interpolate remaining points that wrap around the contour boundary.
+ // Points after the last delta point to end of contour, and start of contour to first delta.
+ DeltaInterpolate(curDelta + 1, contourEnd, curDelta, firstDelta, origPoints, adjustX, adjustY);
+ if (firstDelta > contourStart)
+ {
+ DeltaInterpolate(contourStart, firstDelta - 1, curDelta, firstDelta, origPoints, adjustX, adjustY);
+ }
+ }
+
+ contourStart = contourEnd + 1;
+ }
+ }
+
+ ///
+ /// Interpolates delta values for points between two reference points.
+ /// Handles X and Y independently using linear interpolation with clamping.
+ ///
+ private static void DeltaInterpolate(
+ int p1,
+ int p2,
+ int ref1,
+ int ref2,
+ IList origPoints,
+ Span adjustX,
+ Span adjustY)
+ {
+ if (p1 > p2)
+ {
+ return;
+ }
+
+ // Process X axis.
+ InterpolateAxis(p1, p2, ref1, ref2, origPoints, adjustX, isX: true);
+
+ // Process Y axis.
+ InterpolateAxis(p1, p2, ref1, ref2, origPoints, adjustY, isX: false);
+ }
+
+ private static void InterpolateAxis(
+ int p1,
+ int p2,
+ int ref1,
+ int ref2,
+ IList origPoints,
+ Span adjust,
+ bool isX)
+ {
+ float in1 = isX ? origPoints[ref1].Point.X : origPoints[ref1].Point.Y;
+ float in2 = isX ? origPoints[ref2].Point.X : origPoints[ref2].Point.Y;
+ float out1 = in1 + adjust[ref1];
+ float out2 = in2 + adjust[ref2];
+
+ // Ensure in1 <= in2 for interpolation.
+ if (in1 > in2)
+ {
+ (in1, in2) = (in2, in1);
+ (out1, out2) = (out2, out1);
+ }
+
+ // Per the OpenType spec / FreeType: if the two reference points have the same
+ // input coordinate but different output coordinates, the inferred delta is zero.
+ if (in1 == in2 && out1 != out2)
+ {
+ return;
+ }
+
+ float scale = in1 == in2 ? 0 : (out2 - out1) / (in2 - in1);
+
+ for (int p = p1; p <= p2; p++)
+ {
+ float inVal = isX ? origPoints[p].Point.X : origPoints[p].Point.Y;
+
+ float outVal;
+ if (inVal <= in1)
+ {
+ outVal = inVal + (out1 - in1);
+ }
+ else if (inVal >= in2)
+ {
+ outVal = inVal + (out2 - in2);
+ }
+ else
+ {
+ outVal = out1 + ((inVal - in1) * scale);
+ }
+
+ adjust[p] = outVal - inVal;
+ }
+ }
+
+ ///
+ /// Shifts all points in a contour range by the same delta as the reference point.
+ /// Used when only one point in a contour has an explicit delta.
+ ///
+ private static void DeltaShift(int p1, int p2, int refPoint, Span adjustX, Span adjustY)
+ {
+ float deltaX = adjustX[refPoint];
+ float deltaY = adjustY[refPoint];
+
+ if (deltaX == 0 && deltaY == 0)
+ {
+ return;
+ }
+
+ for (int p = p1; p <= p2; p++)
+ {
+ if (p != refPoint)
+ {
+ adjustX[p] = deltaX;
+ adjustY[p] = deltaY;
+ }
+ }
+ }
+
+ private static Bounds CalculateBounds(IList points)
+ {
+ if (points.Count == 0)
+ {
+ return default;
+ }
+
+ float minX = float.MaxValue;
+ float minY = float.MaxValue;
+ float maxX = float.MinValue;
+ float maxY = float.MinValue;
+
+ for (int i = 0; i < points.Count; i++)
+ {
+ Vector2 pt = points[i].Point;
+ if (pt.X < minX)
+ {
+ minX = pt.X;
+ }
+
+ if (pt.Y < minY)
+ {
+ minY = pt.Y;
+ }
+
+ if (pt.X > maxX)
+ {
+ maxX = pt.X;
+ }
+
+ if (pt.Y > maxY)
+ {
+ maxY = pt.Y;
+ }
+ }
+
+ return new Bounds(minX, minY, maxX, maxY);
+ }
+}
diff --git a/src/SixLabors.Fonts/Tables/AdvancedTypographic/Variations/HVarTable.cs b/src/SixLabors.Fonts/Tables/AdvancedTypographic/Variations/HVarTable.cs
new file mode 100644
index 000000000..240fe6bcb
--- /dev/null
+++ b/src/SixLabors.Fonts/Tables/AdvancedTypographic/Variations/HVarTable.cs
@@ -0,0 +1,91 @@
+// Copyright (c) Six Labors.
+// Licensed under the Six Labors Split License.
+
+namespace SixLabors.Fonts.Tables.AdvancedTypographic.Variations;
+
+///
+/// Implements reading the font variations table `HVAR`.
+/// The HVAR table is used in variable fonts to provide variations for horizontal glyph metrics values.
+/// This can be used to provide variation data for advance widths in the 'hmtx' table.
+///
+///
+internal class HVarTable : Table
+{
+ internal const string TableName = "HVAR";
+
+ public HVarTable(
+ ItemVariationStore itemVariationStore,
+ DeltaSetIndexMap[]? advanceWidthMapping,
+ DeltaSetIndexMap[]? lsbMapping,
+ DeltaSetIndexMap[]? rsbMapping)
+ {
+ this.ItemVariationStore = itemVariationStore;
+ this.AdvanceWidthMapping = advanceWidthMapping;
+ this.LsbMapping = lsbMapping;
+ this.RsbMapping = rsbMapping;
+ }
+
+ public ItemVariationStore ItemVariationStore { get; }
+
+ public DeltaSetIndexMap[]? AdvanceWidthMapping { get; }
+
+ public DeltaSetIndexMap[]? LsbMapping { get; }
+
+ public DeltaSetIndexMap[]? RsbMapping { get; }
+
+ public static HVarTable? Load(FontReader reader)
+ {
+ if (!reader.TryGetReaderAtTablePosition(TableName, out BigEndianBinaryReader? binaryReader))
+ {
+ return null;
+ }
+
+ using (binaryReader)
+ {
+ return Load(binaryReader);
+ }
+ }
+
+ public static HVarTable Load(BigEndianBinaryReader reader)
+ {
+ // Horizontal metrics variations table
+ // +--------------------------+----------------------------------------+-------------------------------------------------------------------------+
+ // | Type | Name | Description |
+ // +==========================+========================================+=========================================================================+
+ // | uint16 | majorVersion | Major version number of the font variations table — set to 1. |
+ // +--------------------------+----------------------------------------+-------------------------------------------------------------------------+
+ // | uint16 | minorVersion | Minor version number of the font variations table — set to 0. |
+ // +--------------------------+----------------------------------------+-------------------------------------------------------------------------+
+ // | Offset32 | itemVariationStoreOffset | Offset in bytes from the start of this table to the |
+ // | | | item variation store table. |
+ // +--------------------------+----------------------------------------+-------------------------------------------------------------------------+
+ // | Offset32 | advanceWidthMappingOffset | Offset in bytes from the start of this table to the delta-set index |
+ // | | | mapping for advance widths (may be NULL). |
+ // +--------------------------+----------------------------------------+-------------------------------------------------------------------------+
+ // | Offset32 | lsbMappingOffset | Offset in bytes from the start of this table to the delta-set index |
+ // | | | mapping for left side bearings (may be NULL). |
+ // +--------------------------+----------------------------------------+-------------------------------------------------------------------------+
+ // | Offset32 | rsbMappingOffset | Offset in bytes from the start of this table to the delta-set index |
+ // | | | mapping for right side bearings (may be NULL). |
+ // +--------------------------+----------------------------------------+-------------------------------------------------------------------------+
+ ushort major = reader.ReadUInt16();
+ ushort minor = reader.ReadUInt16();
+ uint itemVariationStoreOffset = reader.ReadOffset32();
+ uint advanceWidthMappingOffset = reader.ReadOffset32();
+ uint lsbMappingOffset = reader.ReadOffset32();
+ uint rsbMappingOffset = reader.ReadOffset32();
+
+ if (major != 1)
+ {
+ throw new NotSupportedException("Only version 1 of hvar table is supported");
+ }
+
+ ItemVariationStore itemVariationStore = ItemVariationStore.Load(reader, itemVariationStoreOffset);
+
+ DeltaSetIndexMap[]? advanceWidthMapping = DeltaSetIndexMap.Load(reader, advanceWidthMappingOffset);
+ DeltaSetIndexMap[]? lsbMapping = DeltaSetIndexMap.Load(reader, lsbMappingOffset);
+ DeltaSetIndexMap[]? rsbMapping = DeltaSetIndexMap.Load(reader, rsbMappingOffset);
+
+ return new HVarTable(itemVariationStore, advanceWidthMapping, lsbMapping, rsbMapping);
+ }
+}
diff --git a/src/SixLabors.Fonts/Tables/AdvancedTypographic/Variations/InstanceRecord.cs b/src/SixLabors.Fonts/Tables/AdvancedTypographic/Variations/InstanceRecord.cs
new file mode 100644
index 000000000..0dd241ca5
--- /dev/null
+++ b/src/SixLabors.Fonts/Tables/AdvancedTypographic/Variations/InstanceRecord.cs
@@ -0,0 +1,58 @@
+// Copyright (c) Six Labors.
+// Licensed under the Six Labors Split License.
+
+using System.IO;
+
+namespace SixLabors.Fonts.Tables.AdvancedTypographic.Variations;
+
+///
+/// Defines a InstanceRecord.
+///
+///
+internal class InstanceRecord
+{
+ public InstanceRecord(ushort subfamilyNameId, ushort postScriptNameId, float[] coordinates)
+ {
+ this.SubfamilyNameId = subfamilyNameId;
+ this.PostScriptNameId = postScriptNameId;
+ this.Coordinates = coordinates;
+ }
+
+ public ushort SubfamilyNameId { get; }
+
+ public ushort PostScriptNameId { get; }
+
+ public float[] Coordinates { get; }
+
+ public static InstanceRecord Load(BigEndianBinaryReader reader, long offset, ushort axisCount)
+ {
+ // InstanceRecord
+ // +-----------------+----------------------------------------+----------------------------------------------------------------+
+ // | Type | Name | Description |
+ // +=================+========================================+================================================================+
+ // | uint16 | subfamilyNameID | The name ID for entries in the 'name' table that provide |
+ // | | | subfamily names for this instance. |
+ // +-----------------+----------------------------------------+----------------------------------------------------------------+
+ // | uint16 | flags | Reserved for future use — set to 0. |
+ // +-----------------+----------------------------------------+----------------------------------------------------------------+
+ // | UserTuple | coordinates | The coordinates array for this instance. |
+ // +-----------------+----------------------------------------+----------------------------------------------------------------+
+ // | uint16 | postScriptNameID | Optional. The name ID for entries in the 'name' table that |
+ // | | | provide PostScript names for this instance. |
+ // +-----------------+----------------------------------------+----------------------------------------------------------------+
+ reader.Seek(offset, SeekOrigin.Begin);
+
+ ushort subfamilyNameId = reader.ReadUInt16();
+ ushort flags = reader.ReadUInt16();
+
+ float[] coordinates = new float[axisCount];
+ for (int i = 0; i < axisCount; i++)
+ {
+ coordinates[i] = reader.ReadFixed();
+ }
+
+ ushort postScriptNameId = reader.ReadUInt16();
+
+ return new InstanceRecord(subfamilyNameId, postScriptNameId, coordinates);
+ }
+}
diff --git a/src/SixLabors.Fonts/Tables/AdvancedTypographic/Variations/ItemVariationData.cs b/src/SixLabors.Fonts/Tables/AdvancedTypographic/Variations/ItemVariationData.cs
new file mode 100644
index 000000000..9e0d85cf7
--- /dev/null
+++ b/src/SixLabors.Fonts/Tables/AdvancedTypographic/Variations/ItemVariationData.cs
@@ -0,0 +1,87 @@
+// Copyright (c) Six Labors.
+// Licensed under the Six Labors Split License.
+
+using System;
+using System.Diagnostics;
+using System.IO;
+
+namespace SixLabors.Fonts.Tables.AdvancedTypographic.Variations;
+
+///
+/// Item variation data, docs:
+///
+[DebuggerDisplay("ItemCount: {ItemCount}, WordDeltaCount: {WordDeltaCount}, RegionIndexCount: {RegionIndexes.Length}")]
+internal sealed class ItemVariationData
+{
+ ///
+ /// Count of "word" deltas.
+ ///
+ private const int WordDeltaCountMask = 0x7FFF;
+
+ ///
+ /// Flag indicating that "word" deltas are long (int32).
+ ///
+ private const int LongWordsMask = 0x8000;
+
+ private ItemVariationData(ushort itemCount, ushort wordDeltaCount, ushort[] regionIndices, DeltaSet[] deltaSets)
+ {
+ this.ItemCount = itemCount;
+ this.WordDeltaCount = wordDeltaCount;
+ this.RegionIndexes = regionIndices;
+ this.DeltaSets = deltaSets;
+ }
+
+ public ushort ItemCount { get; }
+
+ public ushort WordDeltaCount { get; }
+
+ public ushort[] RegionIndexes { get; }
+
+ public DeltaSet[] DeltaSets { get; }
+
+ public static ItemVariationData Load(BigEndianBinaryReader reader, long offset)
+ {
+ // ItemVariationData
+ // +-----------------+----------------------------------------+----------------------------------------------------------------+
+ // | Type | Name | Description |
+ // +=================+========================================+================================================================+
+ // | uint16 | itemCount | The number of delta sets for distinct items. |
+ // +-----------------+----------------------------------------+----------------------------------------------------------------+
+ // | uint16 | wordDeltaCount | A packed field: the high bit is a flag. |
+ // +-----------------+----------------------------------------+----------------------------------------------------------------+
+ // + uint16 | regionIndexCount | The number of variation regions referenced. |
+ // +-----------------+----------------------------------------+----------------------------------------------------------------+
+ // + uint16 | regionIndexes[regionIndexCount] | Array of indices into the variation region list for |
+ // + | | the regions referenced by this item variation data table. |
+ // +-----------------+----------------------------------------+----------------------------------------------------------------+
+ // + DeltaSet | deltaSets[itemCount] | Delta-set rows. |
+ // +-----------------+----------------------------------------+----------------------------------------------------------------+
+ reader.Seek(offset, SeekOrigin.Begin);
+ ushort itemCount = reader.ReadUInt16();
+ ushort wordDeltaCount = reader.ReadUInt16();
+ ushort regionIndexCount = reader.ReadUInt16();
+ ushort[] regionIndexes = new ushort[regionIndexCount];
+ for (int i = 0; i < regionIndexCount; i++)
+ {
+ regionIndexes[i] = reader.ReadUInt16();
+ }
+
+ // The deltaSets array represents a logical two-dimensional table of delta values with itemCount rows and regionIndexCount columns.
+ // Logically, each DeltaSet record has regionIndexCount number of elements. The elements are represented using long and short types.
+ // These are either int16 and int8, or int32 and int16, according to whether the LONG_WORDS flag is set.
+ // The delta array has a sequence of deltas using the long type followed by a sequence of deltas using the short type.
+ bool longWords = (wordDeltaCount & LongWordsMask) != 0;
+ int wordDeltas = wordDeltaCount & WordDeltaCountMask;
+ var deltaSets = new DeltaSet[itemCount];
+ for (int i = 0; i < itemCount; i++)
+ {
+ var deltaSet = new DeltaSet(reader, wordDeltas, longWords, regionIndexCount);
+ deltaSets[i] = deltaSet;
+ }
+
+ return new ItemVariationData(itemCount, wordDeltaCount, regionIndexes, deltaSets);
+ }
+
+ ///
+ public override int GetHashCode() => HashCode.Combine(this.ItemCount, this.WordDeltaCount, this.RegionIndexes);
+}
diff --git a/src/SixLabors.Fonts/Tables/AdvancedTypographic/Variations/ItemVariationStore.cs b/src/SixLabors.Fonts/Tables/AdvancedTypographic/Variations/ItemVariationStore.cs
new file mode 100644
index 000000000..88660cd4d
--- /dev/null
+++ b/src/SixLabors.Fonts/Tables/AdvancedTypographic/Variations/ItemVariationStore.cs
@@ -0,0 +1,74 @@
+// Copyright (c) Six Labors.
+// Licensed under the Six Labors Split License.
+
+namespace SixLabors.Fonts.Tables.AdvancedTypographic.Variations;
+
+///
+/// Implements reading the item variation store, which is used in most glyph variation data.
+///
+///
+internal class ItemVariationStore
+{
+ public ItemVariationStore(VariationRegionList variationRegionList, ItemVariationData[] itemVariations)
+ {
+ this.VariationRegionList = variationRegionList;
+ this.ItemVariations = itemVariations;
+ }
+
+ public VariationRegionList VariationRegionList { get; }
+
+ public ItemVariationData[] ItemVariations { get; }
+
+ public static ItemVariationStore Load(BigEndianBinaryReader reader, long offset, long? length = null)
+ {
+ // ItemVariationStore
+ // +--------------------------+--------------------------------------------------+-------------------------------------------------------------------------+
+ // | Type | Name | Description |
+ // +==========================+==================================================+=========================================================================+
+ // | uint16 | format | Format — set to 1 |
+ // +--------------------------+--------------------------------------------------+-------------------------------------------------------------------------+
+ // | Offset32 | variationRegionListOffset | Offset in bytes from the start of the item variation store |
+ // | | | to the variation region list. |
+ // +--------------------------+--------------------------------------------------+-------------------------------------------------------------------------+
+ // | uint16 | itemVariationDataCount | The number of item variation data subtables. |
+ // +--------------------------+--------------------------------------------------+-------------------------------------------------------------------------+
+ // | Offset32 | itemVariationDataOffsets[itemVariationDataCount] | Offsets in bytes from the start of the item variation store |
+ // | | | to each item variation data subtable. |
+ // +--------------------------+--------------------------------------------------+-------------------------------------------------------------------------+
+ reader.Seek(offset, SeekOrigin.Begin);
+
+ ushort format = reader.ReadUInt16();
+ if (format != 1)
+ {
+ throw new InvalidFontFileException($"Invalid value for variation Store Format {format}. Should be '1'.");
+ }
+
+ uint variationRegionListOffset = reader.ReadOffset32();
+ ushort itemVariationDataCount = reader.ReadUInt16();
+
+ if (length.HasValue && variationRegionListOffset > length)
+ {
+ throw new InvalidFontFileException("Invalid variation region list offset");
+ }
+
+ ItemVariationData[] itemVariations = new ItemVariationData[itemVariationDataCount];
+ long itemVariationsOffset = reader.BaseStream.Position;
+ for (int i = 0; i < itemVariationDataCount; i++)
+ {
+ uint variationDataOffset = reader.ReadOffset32();
+ itemVariationsOffset += 4;
+ if (length.HasValue && offset + variationDataOffset >= length)
+ {
+ throw new InvalidFontFileException("Bad offset to variation data subtable");
+ }
+
+ itemVariations[i] = ItemVariationData.Load(reader, offset + variationDataOffset);
+
+ reader.BaseStream.Position = itemVariationsOffset;
+ }
+
+ VariationRegionList variationRegionList = VariationRegionList.Load(reader, offset + variationRegionListOffset);
+
+ return new ItemVariationStore(variationRegionList, itemVariations);
+ }
+}
diff --git a/src/SixLabors.Fonts/Tables/AdvancedTypographic/Variations/MVarTable.cs b/src/SixLabors.Fonts/Tables/AdvancedTypographic/Variations/MVarTable.cs
new file mode 100644
index 000000000..9a0705e51
--- /dev/null
+++ b/src/SixLabors.Fonts/Tables/AdvancedTypographic/Variations/MVarTable.cs
@@ -0,0 +1,136 @@
+// Copyright (c) Six Labors.
+// Licensed under the Six Labors Split License.
+
+namespace SixLabors.Fonts.Tables.AdvancedTypographic.Variations;
+
+///
+/// Implements reading the font variations table `MVAR`.
+/// The MVAR table is used in variable fonts to provide variations for global font metric values
+/// such as ascender, descender, line gap, caret metrics, and other font-wide measurements.
+///
+///
+internal class MVarTable : Table
+{
+ internal const string TableName = "MVAR";
+
+ public MVarTable(ItemVariationStore itemVariationStore, MetricValueRecord[] valueRecords)
+ {
+ this.ItemVariationStore = itemVariationStore;
+ this.ValueRecords = valueRecords;
+ }
+
+ public ItemVariationStore ItemVariationStore { get; }
+
+ ///
+ /// Gets the array of metric value records, sorted by tag for binary search.
+ ///
+ public MetricValueRecord[] ValueRecords { get; }
+
+ public static MVarTable? Load(FontReader reader)
+ {
+ if (!reader.TryGetReaderAtTablePosition(TableName, out BigEndianBinaryReader? binaryReader))
+ {
+ return null;
+ }
+
+ using (binaryReader)
+ {
+ return Load(binaryReader);
+ }
+ }
+
+ public static MVarTable Load(BigEndianBinaryReader reader)
+ {
+ // MVAR — Metrics Variations Table
+ // +--------------------------+------------------------------------------+----------------------------------------------------+
+ // | Type | Name | Description |
+ // +==========================+==========================================+====================================================+
+ // | uint16 | majorVersion | Major version — set to 1. |
+ // +--------------------------+------------------------------------------+----------------------------------------------------+
+ // | uint16 | minorVersion | Minor version — set to 0. |
+ // +--------------------------+------------------------------------------+----------------------------------------------------+
+ // | uint16 | reserved | Not used; set to 0. |
+ // +--------------------------+------------------------------------------+----------------------------------------------------+
+ // | uint16 | valueRecordSize | Size in bytes of each value record. |
+ // +--------------------------+------------------------------------------+----------------------------------------------------+
+ // | uint16 | valueRecordCount | Number of value records. |
+ // +--------------------------+------------------------------------------+----------------------------------------------------+
+ // | Offset16 | itemVariationStoreOffset | Offset to ItemVariationStore. |
+ // +--------------------------+------------------------------------------+----------------------------------------------------+
+ // | ValueRecord[] | valueRecords[valueRecordCount] | Array of value records. |
+ // +--------------------------+------------------------------------------+----------------------------------------------------+
+ ushort majorVersion = reader.ReadUInt16();
+ ushort minorVersion = reader.ReadUInt16();
+ ushort reserved = reader.ReadUInt16();
+ ushort valueRecordSize = reader.ReadUInt16();
+ ushort valueRecordCount = reader.ReadUInt16();
+ ushort itemVariationStoreOffset = reader.ReadOffset16();
+
+ if (majorVersion != 1)
+ {
+ throw new NotSupportedException("Only version 1 of MVAR table is supported");
+ }
+
+ // Read the value records. Each is typically 8 bytes (Tag + outerIndex + innerIndex).
+ MetricValueRecord[] valueRecords = new MetricValueRecord[valueRecordCount];
+ for (int i = 0; i < valueRecordCount; i++)
+ {
+ long recordStart = reader.BaseStream.Position;
+ uint tag = reader.ReadUInt32();
+ ushort outerIndex = reader.ReadUInt16();
+ ushort innerIndex = reader.ReadUInt16();
+ valueRecords[i] = new MetricValueRecord(tag, outerIndex, innerIndex);
+
+ // Skip any extra bytes if valueRecordSize > 8 (future compatibility).
+ long consumed = reader.BaseStream.Position - recordStart;
+ if (consumed < valueRecordSize)
+ {
+ reader.BaseStream.Position += valueRecordSize - consumed;
+ }
+ }
+
+ // Load the ItemVariationStore.
+ ItemVariationStore itemVariationStore = ItemVariationStore.Load(reader, itemVariationStoreOffset);
+
+ return new MVarTable(itemVariationStore, valueRecords);
+ }
+
+ ///
+ /// Finds the value record for the given tag using binary search.
+ /// Returns true if found, with the outer and inner indices set.
+ ///
+ /// The 4-byte metric tag to look up.
+ /// The outer index into the ItemVariationStore.
+ /// The inner index into the ItemVariationStore.
+ /// True if the tag was found; false otherwise.
+ public bool TryGetIndices(Tag tag, out ushort outerIndex, out ushort innerIndex)
+ {
+ // ValueRecords are sorted by tag per the spec, so binary search is valid.
+ int lo = 0;
+ int hi = this.ValueRecords.Length - 1;
+ while (lo <= hi)
+ {
+ int mid = lo + ((hi - lo) >> 1);
+ Tag midTag = this.ValueRecords[mid].Tag;
+ if (midTag == tag)
+ {
+ outerIndex = this.ValueRecords[mid].DeltaSetOuterIndex;
+ innerIndex = this.ValueRecords[mid].DeltaSetInnerIndex;
+ return true;
+ }
+
+ if (midTag.Value < tag.Value)
+ {
+ lo = mid + 1;
+ }
+ else
+ {
+ hi = mid - 1;
+ }
+ }
+
+ outerIndex = 0;
+ innerIndex = 0;
+ return false;
+ }
+}
diff --git a/src/SixLabors.Fonts/Tables/AdvancedTypographic/Variations/MVarTag.cs b/src/SixLabors.Fonts/Tables/AdvancedTypographic/Variations/MVarTag.cs
new file mode 100644
index 000000000..4e09908c8
--- /dev/null
+++ b/src/SixLabors.Fonts/Tables/AdvancedTypographic/Variations/MVarTag.cs
@@ -0,0 +1,115 @@
+// Copyright (c) Six Labors.
+// Licensed under the Six Labors Split License.
+
+namespace SixLabors.Fonts.Tables.AdvancedTypographic.Variations;
+
+///
+/// Defines the tags used by the MVAR table to identify font-wide metrics
+/// that can be varied in a variable font.
+/// Each tag maps to a specific field in the OS/2, hhea, vhea, or post tables.
+///
+///
+internal static class MVarTag
+{
+ // Horizontal metrics (OS/2 typo or hhea).
+
+ /// OS/2.sTypoAscender / hhea.ascender ('hasc').
+ public static readonly Tag HorizontalAscender = Tag.Parse("hasc");
+
+ /// OS/2.sTypoDescender / hhea.descender ('hdsc').
+ public static readonly Tag HorizontalDescender = Tag.Parse("hdsc");
+
+ /// OS/2.sTypoLineGap / hhea.lineGap ('hlgp').
+ public static readonly Tag HorizontalLineGap = Tag.Parse("hlgp");
+
+ /// OS/2.usWinAscent ('hcla').
+ public static readonly Tag HorizontalClippingAscent = Tag.Parse("hcla");
+
+ /// OS/2.usWinDescent ('hcld').
+ public static readonly Tag HorizontalClippingDescent = Tag.Parse("hcld");
+
+ // Vertical metrics (vhea).
+
+ /// vhea.ascent ('vasc').
+ public static readonly Tag VerticalAscender = Tag.Parse("vasc");
+
+ /// vhea.descent ('vdsc').
+ public static readonly Tag VerticalDescender = Tag.Parse("vdsc");
+
+ /// vhea.lineGap ('vlgp').
+ public static readonly Tag VerticalLineGap = Tag.Parse("vlgp");
+
+ // OS/2 subscript metrics.
+
+ /// OS/2.ySubscriptXSize ('sbxs').
+ public static readonly Tag SubscriptXSize = Tag.Parse("sbxs");
+
+ /// OS/2.ySubscriptYSize ('sbys').
+ public static readonly Tag SubscriptYSize = Tag.Parse("sbys");
+
+ /// OS/2.ySubscriptXOffset ('sbxo').
+ public static readonly Tag SubscriptXOffset = Tag.Parse("sbxo");
+
+ /// OS/2.ySubscriptYOffset ('sbyo').
+ public static readonly Tag SubscriptYOffset = Tag.Parse("sbyo");
+
+ // OS/2 superscript metrics.
+
+ /// OS/2.ySuperscriptXSize ('spxs').
+ public static readonly Tag SuperscriptXSize = Tag.Parse("spxs");
+
+ /// OS/2.ySuperscriptYSize ('spys').
+ public static readonly Tag SuperscriptYSize = Tag.Parse("spys");
+
+ /// OS/2.ySuperscriptXOffset ('spxo').
+ public static readonly Tag SuperscriptXOffset = Tag.Parse("spxo");
+
+ /// OS/2.ySuperscriptYOffset ('spyo').
+ public static readonly Tag SuperscriptYOffset = Tag.Parse("spyo");
+
+ // OS/2 strikeout metrics.
+
+ /// OS/2.yStrikeoutSize ('strs').
+ public static readonly Tag StrikeoutSize = Tag.Parse("strs");
+
+ /// OS/2.yStrikeoutPosition ('stro').
+ public static readonly Tag StrikeoutPosition = Tag.Parse("stro");
+
+ // post underline metrics.
+
+ /// post.underlineThickness ('unds').
+ public static readonly Tag UnderlineThickness = Tag.Parse("unds");
+
+ /// post.underlinePosition ('undo').
+ public static readonly Tag UnderlinePosition = Tag.Parse("undo");
+
+ // OS/2 miscellaneous metrics.
+
+ /// OS/2.sxHeight ('xhgt').
+ public static readonly Tag XHeight = Tag.Parse("xhgt");
+
+ /// OS/2.sCapHeight ('cpht').
+ public static readonly Tag CapHeight = Tag.Parse("cpht");
+
+ // hhea caret metrics.
+
+ /// hhea.caretSlopeRise ('hcrn').
+ public static readonly Tag HorizontalCaretRise = Tag.Parse("hcrn");
+
+ /// hhea.caretSlopeRun ('hcrs').
+ public static readonly Tag HorizontalCaretRun = Tag.Parse("hcrs");
+
+ /// hhea.caretOffset ('hcof').
+ public static readonly Tag HorizontalCaretOffset = Tag.Parse("hcof");
+
+ // vhea caret metrics.
+
+ /// vhea.caretSlopeRise ('vcrn').
+ public static readonly Tag VerticalCaretRise = Tag.Parse("vcrn");
+
+ /// vhea.caretSlopeRun ('vcrs').
+ public static readonly Tag VerticalCaretRun = Tag.Parse("vcrs");
+
+ /// vhea.caretOffset ('vcof').
+ public static readonly Tag VerticalCaretOffset = Tag.Parse("vcof");
+}
diff --git a/src/SixLabors.Fonts/Tables/AdvancedTypographic/Variations/MetricValueRecord.cs b/src/SixLabors.Fonts/Tables/AdvancedTypographic/Variations/MetricValueRecord.cs
new file mode 100644
index 000000000..d228a5d49
--- /dev/null
+++ b/src/SixLabors.Fonts/Tables/AdvancedTypographic/Variations/MetricValueRecord.cs
@@ -0,0 +1,32 @@
+// Copyright (c) Six Labors.
+// Licensed under the Six Labors Split License.
+
+namespace SixLabors.Fonts.Tables.AdvancedTypographic.Variations;
+
+///
+/// A single MVAR value record mapping a metric tag to a delta-set index.
+///
+internal readonly struct MetricValueRecord
+{
+ public MetricValueRecord(Tag tag, ushort deltaSetOuterIndex, ushort deltaSetInnerIndex)
+ {
+ this.Tag = tag;
+ this.DeltaSetOuterIndex = deltaSetOuterIndex;
+ this.DeltaSetInnerIndex = deltaSetInnerIndex;
+ }
+
+ ///
+ /// Gets the four-byte tag identifying the metric (e.g. 'hasc', 'hdsc').
+ ///
+ public Tag Tag { get; }
+
+ ///
+ /// Gets the outer index into the ItemVariationStore.
+ ///
+ public ushort DeltaSetOuterIndex { get; }
+
+ ///
+ /// Gets the inner index into the ItemVariationStore.
+ ///
+ public ushort DeltaSetInnerIndex { get; }
+}
diff --git a/src/SixLabors.Fonts/Tables/AdvancedTypographic/Variations/RegionAxisCoordinates.cs b/src/SixLabors.Fonts/Tables/AdvancedTypographic/Variations/RegionAxisCoordinates.cs
new file mode 100644
index 000000000..6bcd5eaf1
--- /dev/null
+++ b/src/SixLabors.Fonts/Tables/AdvancedTypographic/Variations/RegionAxisCoordinates.cs
@@ -0,0 +1,32 @@
+// Copyright (c) Six Labors.
+// Licensed under the Six Labors Split License.
+
+using System.Diagnostics;
+
+namespace SixLabors.Fonts.Tables.AdvancedTypographic.Variations;
+
+///
+/// Each RegionAxisCoordinates record provides coordinate values for a region along a single axis.
+/// The three values must all be within the range -1.0 to +1.0. startCoord must be less than or equal to peakCoord,
+/// and peakCoord must be less than or equal to endCoord. The three values must be either all non-positive or all non-negative with one possible exception:
+/// if peakCoord is zero, then startCoord can be negative or 0 while endCoord can be positive or zero.
+///
+///
+[DebuggerDisplay("StartCoord: {StartCoord}, PeakCoord: {PeakCoord}, EndCoord: {EndCoord}")]
+public readonly struct RegionAxisCoordinates
+{
+ ///
+ /// Gets the region start coordinate value for the current axis.
+ ///
+ public float StartCoord { get; init; }
+
+ ///
+ /// Gets the region peak coordinate value for the current axis.
+ ///
+ public float PeakCoord { get; init; }
+
+ ///
+ /// Gets the region end coordinate value for the current axis.
+ ///
+ public float EndCoord { get; init; }
+}
diff --git a/src/SixLabors.Fonts/Tables/AdvancedTypographic/Variations/SegmentMapRecord.cs b/src/SixLabors.Fonts/Tables/AdvancedTypographic/Variations/SegmentMapRecord.cs
new file mode 100644
index 000000000..a29d1144c
--- /dev/null
+++ b/src/SixLabors.Fonts/Tables/AdvancedTypographic/Variations/SegmentMapRecord.cs
@@ -0,0 +1,31 @@
+// Copyright (c) Six Labors.
+// Licensed under the Six Labors Split License.
+
+namespace SixLabors.Fonts.Tables.AdvancedTypographic.Variations;
+
+internal class SegmentMapRecord
+{
+ public SegmentMapRecord(AxisValueMapRecord[] axisValueMap) => this.AxisValueMap = axisValueMap;
+
+ public AxisValueMapRecord[] AxisValueMap { get; }
+
+ public static SegmentMapRecord Load(BigEndianBinaryReader reader)
+ {
+ // SegmentMapRecord
+ // +-----------------+----------------------------------------+-------------------------------------------------------------------------+
+ // | Type | Name | Description |
+ // +=================+========================================+=========================================================================+
+ // | uint16 | positionMapCount | The number of correspondence pairs for this axis. |
+ // +-----------------+----------------------------------------+-------------------------------------------------------------------------+
+ // | AxisValueMap | axisValueMaps[positionMapCount] | The array of axis value map records for this axis. |
+ // +-----------------+----------------------------------------+-------------------------------------------------------------------------+
+ ushort positionMapCount = reader.ReadUInt16();
+ var axisValueMap = new AxisValueMapRecord[positionMapCount];
+ for (int i = 0; i < positionMapCount; i++)
+ {
+ axisValueMap[i] = AxisValueMapRecord.Load(reader);
+ }
+
+ return new SegmentMapRecord(axisValueMap);
+ }
+}
diff --git a/src/SixLabors.Fonts/Tables/AdvancedTypographic/Variations/TupleVariation.cs b/src/SixLabors.Fonts/Tables/AdvancedTypographic/Variations/TupleVariation.cs
new file mode 100644
index 000000000..2abf540ee
--- /dev/null
+++ b/src/SixLabors.Fonts/Tables/AdvancedTypographic/Variations/TupleVariation.cs
@@ -0,0 +1,138 @@
+// Copyright (c) Six Labors.
+// Licensed under the Six Labors Split License.
+
+namespace SixLabors.Fonts.Tables.AdvancedTypographic.Variations;
+
+internal class TupleVariation
+{
+ ///
+ /// Flag indicating that this tuple variation header includes an embedded peak tuple record, immediately after the tupleIndex field.
+ /// If set, the low 12 bits of the tupleIndex value are ignored.
+ /// Note that this must always be set within the 'cvar' table.
+ ///
+ internal const int EmbeddedPeakTupleMask = 0x8000;
+
+ ///
+ /// Flag indicating that this tuple variation table applies to an intermediate region within the variation space.
+ /// If set, the header includes the two intermediate-region, start and end tuple records, immediately after the peak tuple record (if present).
+ ///
+ internal const int IntermediateRegionMask = 0x4000;
+
+ ///
+ /// Flag indicating that the serialized data for this tuple variation table includes packed "point" number data.
+ /// If set, this tuple variation table uses that number data; if clear, this tuple variation table uses shared number
+ /// data found at the start of the serialized data for this glyph variation data or 'cvar' table.
+ ///
+ internal const int PrivatePointNumbersMask = 0x2000;
+
+ ///
+ /// Mask for the low 12 bits to give the shared tuple records index.
+ ///
+ internal const int TupleIndexMask = 0x0FFF;
+
+ public TupleVariation(
+ int axisCount,
+ ushort variationDataSize,
+ ushort tupleIndex,
+ float[]? embeddedPeak,
+ float[]? intermediateStartRegion,
+ float[]? intermediateEndRegion)
+ {
+ this.AxisCount = axisCount;
+ this.VariationDataSize = variationDataSize;
+ this.TupleIndex = tupleIndex;
+ this.EmbeddedPeak = embeddedPeak;
+ this.IntermediateStartRegion = intermediateStartRegion;
+ this.IntermediateEndRegion = intermediateEndRegion;
+ }
+
+ public int AxisCount { get; }
+
+ ///
+ /// Gets the size in bytes of the serialized data for this tuple variation table.
+ ///
+ public ushort VariationDataSize { get; }
+
+ ///
+ /// Gets the packed tuple index field containing flags (high 4 bits) and shared tuple records index (low 12 bits).
+ ///
+ public ushort TupleIndex { get; }
+
+ ///
+ /// Gets the shared tuple records index (low 12 bits of ).
+ /// Used to look up peak coordinates from when no embedded peak is present.
+ ///
+ public int SharedTupleIndex => this.TupleIndex & TupleIndexMask;
+
+ ///
+ /// Gets a value indicating whether this tuple has private point numbers in its serialized data.
+ ///
+ public bool HasPrivatePointNumbers => (this.TupleIndex & PrivatePointNumbersMask) != 0;
+
+ ///
+ /// Gets a value indicating whether this tuple has an intermediate region (start/end coordinates).
+ ///
+ public bool IsIntermediateRegion => (this.TupleIndex & IntermediateRegionMask) != 0;
+
+ public float[]? EmbeddedPeak { get; }
+
+ public float[]? IntermediateStartRegion { get; }
+
+ public float[]? IntermediateEndRegion { get; }
+
+ public static TupleVariation Load(BigEndianBinaryReader reader, int axisCount)
+ {
+ // TupleVariation
+ // +----------------------+-------------------------------------------+------------------------------------------------------------------------------+
+ // | Type | Name | Description |
+ // +======================+===========================================+==============================================================================+
+ // | uint16 | variationDataSize | The size in bytes of the serialized data for this tuple variation table. |
+ // +----------------------+-------------------------------------------+------------------------------------------------------------------------------+
+ // | uint16 | tupleIndex | A packed field. The high 4 bits are flags. |
+ // | | | The low 12 bits are an index into a shared tuple records array. |
+ // +----------------------+-------------------------------------------+------------------------------------------------------------------------------+
+ // | Tuple | peakTuple | Peak tuple record for this tuple variation table — |
+ // | | | optional, determined by flags in the tupleIndex value. |
+ // +----------------------+-------------------------------------------+------------------------------------------------------------------------------+
+ // | Tuple | intermediateStartTuple | Intermediate start tuple record for this tuple variation table — |
+ // | | | optional, determined by flags in the tupleIndex value. |
+ // +----------------------+-------------------------------------------+------------------------------------------------------------------------------+
+ // | Tuple | intermediateEndTuple | Intermediate end tuple record for this tuple variation table — |
+ // | | | optional, determined by flags in the tupleIndex value. |
+ // +----------------------+-------------------------------------------+------------------------------------------------------------------------------+
+ ushort variationDataSize = reader.ReadUInt16();
+ ushort tupleIndex = reader.ReadUInt16();
+
+ bool hasEmbeddedPeakTuple = (tupleIndex & EmbeddedPeakTupleMask) != 0;
+ bool hasIntermediateRegion = (tupleIndex & IntermediateRegionMask) != 0;
+
+ float[]? embeddedPeak = null;
+ if (hasEmbeddedPeakTuple)
+ {
+ embeddedPeak = new float[axisCount];
+ for (int i = 0; i < axisCount; i++)
+ {
+ embeddedPeak[i] = reader.ReadF2Dot14();
+ }
+ }
+
+ float[]? intermediateStartRegion = null;
+ float[]? intermediateEndRegion = null;
+ if (hasIntermediateRegion)
+ {
+ intermediateStartRegion = new float[axisCount];
+ for (int i = 0; i < axisCount; i++)
+ {
+ intermediateStartRegion[i] = reader.ReadF2Dot14();
+ }
+
+ intermediateEndRegion = new float[axisCount];
+ for (int i = 0; i < axisCount; i++)
+ {
+ intermediateEndRegion[i] = reader.ReadF2Dot14();
+ }
+ }
+
+ return new TupleVariation(axisCount, variationDataSize, tupleIndex, embeddedPeak, intermediateStartRegion, intermediateEndRegion);
+ }
+}
diff --git a/src/SixLabors.Fonts/Tables/AdvancedTypographic/Variations/VVarTable.cs b/src/SixLabors.Fonts/Tables/AdvancedTypographic/Variations/VVarTable.cs
new file mode 100644
index 000000000..15d896cf2
--- /dev/null
+++ b/src/SixLabors.Fonts/Tables/AdvancedTypographic/Variations/VVarTable.cs
@@ -0,0 +1,100 @@
+// Copyright (c) Six Labors.
+// Licensed under the Six Labors Split License.
+
+namespace SixLabors.Fonts.Tables.AdvancedTypographic.Variations;
+
+///
+/// Implements reading the font variations table `VVAR`.
+/// The VVAR table is used in variable fonts to provide variations for vertical glyph metrics values.
+/// This can be used to provide variation data for advance heights in the 'vmtx' table.
+///
+///
+internal class VVarTable : Table
+{
+ internal const string TableName = "VVAR";
+
+ public VVarTable(
+ ItemVariationStore itemVariationStore,
+ DeltaSetIndexMap[]? advanceWidthMapping,
+ DeltaSetIndexMap[]? tsbMapping,
+ DeltaSetIndexMap[]? bsbMapping,
+ DeltaSetIndexMap[]? vOrgMapping)
+ {
+ this.ItemVariationStore = itemVariationStore;
+ this.AdvanceWidthMapping = advanceWidthMapping;
+ this.TsbMapping = tsbMapping;
+ this.BsbMapping = bsbMapping;
+ this.VOrgMapping = vOrgMapping;
+ }
+
+ public ItemVariationStore ItemVariationStore { get; }
+
+ public DeltaSetIndexMap[]? AdvanceWidthMapping { get; }
+
+ public DeltaSetIndexMap[]? TsbMapping { get; }
+
+ public DeltaSetIndexMap[]? BsbMapping { get; }
+
+ public DeltaSetIndexMap[]? VOrgMapping { get; }
+
+ public static VVarTable? Load(FontReader reader)
+ {
+ if (!reader.TryGetReaderAtTablePosition(TableName, out BigEndianBinaryReader? binaryReader))
+ {
+ return null;
+ }
+
+ using (binaryReader)
+ {
+ return Load(binaryReader);
+ }
+ }
+
+ public static VVarTable Load(BigEndianBinaryReader reader)
+ {
+ // Horizontal metrics variations table
+ // +--------------------------+----------------------------------------+-------------------------------------------------------------------------+
+ // | Type | Name | Description |
+ // +==========================+========================================+=========================================================================+
+ // | uint16 | majorVersion | Major version number of the font variations table — set to 1. |
+ // +--------------------------+----------------------------------------+-------------------------------------------------------------------------+
+ // | uint16 | minorVersion | Minor version number of the font variations table — set to 0. |
+ // +--------------------------+----------------------------------------+-------------------------------------------------------------------------+
+ // | Offset32 | itemVariationStoreOffset | Offset in bytes from the start of this table to the |
+ // | | | item variation store table. |
+ // +--------------------------+----------------------------------------+-------------------------------------------------------------------------+
+ // | Offset32 | advanceHeightMappingOffset | Offset in bytes from the start of this table to the delta-set index |
+ // | | | mapping for advance heights (may be NULL). |
+ // +--------------------------+----------------------------------------+-------------------------------------------------------------------------+
+ // | Offset32 | tsbMappingOffset | Offset in bytes from the start of this table to the delta-set index |
+ // | | | mapping for top side bearings (may be NULL). |
+ // +--------------------------+----------------------------------------+-------------------------------------------------------------------------+
+ // | Offset32 | bsbMappingOffset | Offset in bytes from the start of this table to the delta-set index |
+ // | | | mapping for bottom side bearings (may be NULL). |
+ // +--------------------------+----------------------------------------+-------------------------------------------------------------------------+
+ // | Offset32 | vOrgMappingOffset | Offset in bytes from the start of this table to the delta-set index |
+ // | | | mapping for Y coordinates of vertical origins (may be NULL). |
+ // +--------------------------+----------------------------------------+-------------------------------------------------------------------------+
+ ushort major = reader.ReadUInt16();
+ ushort minor = reader.ReadUInt16();
+ uint itemVariationStoreOffset = reader.ReadOffset32();
+ uint advanceHeightMappingOffset = reader.ReadOffset32();
+ uint tsbMappingOffset = reader.ReadOffset32();
+ uint bsbMappingOffset = reader.ReadOffset32();
+ uint vOrgMappingOffset = reader.ReadOffset32();
+
+ if (major != 1)
+ {
+ throw new NotSupportedException("Only version 1 of hvar table is supported");
+ }
+
+ ItemVariationStore itemVariationStore = ItemVariationStore.Load(reader, itemVariationStoreOffset);
+
+ DeltaSetIndexMap[]? advanceHeightMapping = DeltaSetIndexMap.Load(reader, advanceHeightMappingOffset);
+ DeltaSetIndexMap[]? tsbMapping = DeltaSetIndexMap.Load(reader, tsbMappingOffset);
+ DeltaSetIndexMap[]? bsbMapping = DeltaSetIndexMap.Load(reader, bsbMappingOffset);
+ DeltaSetIndexMap[]? vOrgMapping = DeltaSetIndexMap.Load(reader, vOrgMappingOffset);
+
+ return new VVarTable(itemVariationStore, advanceHeightMapping, tsbMapping, bsbMapping, vOrgMapping);
+ }
+}
diff --git a/src/SixLabors.Fonts/Tables/AdvancedTypographic/Variations/VariationAxis.cs b/src/SixLabors.Fonts/Tables/AdvancedTypographic/Variations/VariationAxis.cs
new file mode 100644
index 000000000..44aa8a576
--- /dev/null
+++ b/src/SixLabors.Fonts/Tables/AdvancedTypographic/Variations/VariationAxis.cs
@@ -0,0 +1,38 @@
+// Copyright (c) Six Labors.
+// Licensed under the Six Labors Split License.
+
+using System.Diagnostics;
+
+namespace SixLabors.Fonts.Tables.AdvancedTypographic.Variations;
+
+///
+///
+///
+[DebuggerDisplay("Name: {Name}, Tag: {Tag}, Min: {Min}, Max: {Max}, Default: {Default}")]
+public readonly struct VariationAxis
+{
+ ///
+ /// Gets the name of the axes.
+ ///
+ public string Name { get; init; }
+
+ ///
+ /// Gets tag identifying the design variation for the axis.
+ ///
+ public string Tag { get; init; }
+
+ ///
+ /// Gets the minimum coordinate value for the axis.
+ ///
+ public float Min { get; init; }
+
+ ///
+ /// Gets the maximum coordinate value for the axis.
+ ///
+ public float Max { get; init; }
+
+ ///
+ /// Gets the default coordinate value for the axis.
+ ///
+ public float Default { get; init; }
+}
diff --git a/src/SixLabors.Fonts/Tables/AdvancedTypographic/Variations/VariationAxisRecord.cs b/src/SixLabors.Fonts/Tables/AdvancedTypographic/Variations/VariationAxisRecord.cs
new file mode 100644
index 000000000..1d9c62957
--- /dev/null
+++ b/src/SixLabors.Fonts/Tables/AdvancedTypographic/Variations/VariationAxisRecord.cs
@@ -0,0 +1,68 @@
+// Copyright (c) Six Labors.
+// Licensed under the Six Labors Split License.
+
+using System.Diagnostics;
+using System.IO;
+
+namespace SixLabors.Fonts.Tables.AdvancedTypographic.Variations;
+
+///
+/// Defines a VariationAxisRecord.
+///
+///
+[DebuggerDisplay("Tag: {Tag}, MinValue: {MinValue}, MaxValue: {MaxValue}, DefaultValue: {DefaultValue}, AxisNameId: {AxisNameId}")]
+internal class VariationAxisRecord
+{
+ internal VariationAxisRecord(string tag, float minValue, float defaultValue, float maxValue, ushort flags, ushort axisNameId)
+ {
+ this.Tag = tag;
+ this.MinValue = minValue;
+ this.MaxValue = maxValue;
+ this.DefaultValue = defaultValue;
+ this.Flags = flags;
+ this.AxisNameId = axisNameId;
+ }
+
+ public string Tag { get; }
+
+ public float MinValue { get; }
+
+ public float DefaultValue { get; }
+
+ public float MaxValue { get; }
+
+ public ushort Flags { get; }
+
+ public ushort AxisNameId { get; }
+
+ public static VariationAxisRecord Load(BigEndianBinaryReader reader, long offset)
+ {
+ // VariationAxisRecord
+ // +-----------------+----------------------------------------+----------------------------------------------------------------+
+ // | Type | Name | Description |
+ // +=================+========================================+================================================================+
+ // | Tag | axisTag | Tag identifying the design variation for the axis. |
+ // +-----------------+----------------------------------------+----------------------------------------------------------------+
+ // | Fixed | minValue | The minimum coordinate value for the axis. |
+ // +-----------------+----------------------------------------+----------------------------------------------------------------+
+ // | Fixed | defaultValue | The default coordinate value for the axis. |
+ // +-----------------+----------------------------------------+----------------------------------------------------------------+
+ // | Fixed | maxValue | The maximum coordinate value for the axis. |
+ // +-----------------+----------------------------------------+----------------------------------------------------------------+
+ // | uint16 | flags | Axis qualifiers — see details below. |
+ // +-----------------+----------------------------------------+----------------------------------------------------------------+
+ // | uint16 | axisNameID | The name ID for entries in the 'name' table that provide |
+ // | | | a display name for this axis. |
+ // +-----------------+----------------------------------------+----------------------------------------------------------------+
+ reader.Seek(offset, SeekOrigin.Begin);
+
+ string tag = reader.ReadTag();
+ float minValue = reader.ReadFixed();
+ float defaultValue = reader.ReadFixed();
+ float maxValue = reader.ReadFixed();
+ ushort flags = reader.ReadUInt16();
+ ushort axisNameID = reader.ReadUInt16();
+
+ return new VariationAxisRecord(tag, minValue, defaultValue, maxValue, flags, axisNameID);
+ }
+}
diff --git a/src/SixLabors.Fonts/Tables/AdvancedTypographic/Variations/VariationRegionList.cs b/src/SixLabors.Fonts/Tables/AdvancedTypographic/Variations/VariationRegionList.cs
new file mode 100644
index 000000000..18b058c5d
--- /dev/null
+++ b/src/SixLabors.Fonts/Tables/AdvancedTypographic/Variations/VariationRegionList.cs
@@ -0,0 +1,88 @@
+// Copyright (c) Six Labors.
+// Licensed under the Six Labors Split License.
+
+using System;
+using System.Diagnostics;
+using System.IO;
+
+namespace SixLabors.Fonts.Tables.AdvancedTypographic.Variations;
+
+///
+/// Variation data is comprised of delta adjustment values that have effect over particular regions within the font’s variation space.
+/// In a tuple variation store (described earlier in this chapter), the deltas are organized into groupings by region of applicability, with each grouping associated with a given region.
+/// In contrast, the item variation store format organizes deltas into groupings by the target items to which they apply, with each grouping having deltas for several regions.
+/// Accordingly, the item variation store uses different formats for describing the regions in which a set of deltas apply.
+///
+///
+[DebuggerDisplay("AxisCount: {AxisCount}, RegionCount: {RegionCount}")]
+internal class VariationRegionList
+{
+ public static readonly VariationRegionList EmptyVariationRegionList = new(0, 0, new[] { Array.Empty() });
+
+ private VariationRegionList(ushort axisCount, ushort regionCount, RegionAxisCoordinates[][] variationRegions)
+ {
+ this.AxisCount = axisCount;
+ this.RegionCount = regionCount;
+ this.VariationRegions = variationRegions;
+ }
+
+ public ushort AxisCount { get; }
+
+ public ushort RegionCount { get; }
+
+ public RegionAxisCoordinates[][] VariationRegions { get; }
+
+ public static VariationRegionList Load(BigEndianBinaryReader reader, long offset)
+ {
+ // VariationRegionList
+ // +-----------------+----------------------------------------+----------------------------------------------------------------+
+ // | Type | Name | Description |
+ // +=================+========================================+================================================================+
+ // | uint16 | axisCount | The number of variation axes for this font. |
+ // | | | This must be the same number as axisCount in the 'fvar' table. |
+ // +-----------------+----------------------------------------+----------------------------------------------------------------+
+ // | uint16 | regionCount | The number of variation region tables in the variation region |
+ // | | | list. Must be less than 32,768. |
+ // +-----------------+----------------------------------------+----------------------------------------------------------------+
+ // + VariationRegion | variationRegions[regionCount] | Array of variation regions. |
+ // +-----------------+----------------------------------------+----------------------------------------------------------------+
+ reader.Seek(offset, SeekOrigin.Begin);
+ ushort axisCount = reader.ReadUInt16();
+ ushort regionCount = reader.ReadUInt16();
+ var variationRegions = new RegionAxisCoordinates[regionCount][];
+ for (int i = 0; i < regionCount; i++)
+ {
+ variationRegions[i] = new RegionAxisCoordinates[axisCount];
+ for (int j = 0; j < axisCount; j++)
+ {
+ float startCoord = reader.ReadF2Dot14();
+ float peakCoord = reader.ReadF2Dot14();
+ float endCoord = reader.ReadF2Dot14();
+
+ if (startCoord > peakCoord || peakCoord > endCoord)
+ {
+ throw new InvalidFontFileException("Region axis coordinates out of order");
+ }
+
+ if (startCoord < -0x4000 || endCoord > 0x4000)
+ {
+ throw new InvalidFontFileException("Region axis coordinate out of range");
+ }
+
+ if ((peakCoord < 0 && endCoord > 0) || (peakCoord > 0 && startCoord < 0))
+ {
+ throw new InvalidFontFileException("Invalid region axis coordinates");
+ }
+
+ variationRegions[i][j] = new RegionAxisCoordinates()
+ {
+ StartCoord = startCoord,
+ PeakCoord = peakCoord,
+ EndCoord = endCoord
+ };
+ }
+ }
+
+ return new VariationRegionList(axisCount, regionCount, variationRegions);
+ }
+}
diff --git a/src/SixLabors.Fonts/Tables/Cff/CffParser.cs b/src/SixLabors.Fonts/Tables/Cff/Cff1Parser.cs
similarity index 61%
rename from src/SixLabors.Fonts/Tables/Cff/CffParser.cs
rename to src/SixLabors.Fonts/Tables/Cff/Cff1Parser.cs
index d0701d8b9..5032af9da 100644
--- a/src/SixLabors.Fonts/Tables/Cff/CffParser.cs
+++ b/src/SixLabors.Fonts/Tables/Cff/Cff1Parser.cs
@@ -1,8 +1,6 @@
// Copyright (c) Six Labors.
// Licensed under the Six Labors Split License.
-using System.Diagnostics.CodeAnalysis;
-using System.Globalization;
using System.Text;
namespace SixLabors.Fonts.Tables.Cff;
@@ -11,14 +9,13 @@ namespace SixLabors.Fonts.Tables.Cff;
/// Parses a Compact Font Format (CFF) font program as described in The Compact Font Format specification (Adobe Technical Note #5176).
/// A CFF font may contain multiple fonts and achieves compression by sharing details between fonts in the set.
///
-internal class CffParser
+internal class Cff1Parser : CffParserBase
{
///
/// Latin 1 Encoding: ISO 8859-1 is a single-byte encoding that can represent the first 256 Unicode characters.
///
private static readonly Encoding Iso88591 = Encoding.GetEncoding("ISO-8859-1");
- private readonly StringBuilder pooledStringBuilder = new();
private long offset;
private int charStringsOffset;
private int charsetOffset;
@@ -32,15 +29,15 @@ public CffFont Load(BigEndianBinaryReader reader, long offset)
string fontName = ReadNameIndex(reader);
- List? dataDicEntries = this.ReadTopDICTIndex(reader);
+ List dataDicEntries = this.ReadTopDictIndex(reader);
string[] stringIndex = ReadStringIndex(reader);
CffTopDictionary topDictionary = this.ResolveTopDictInfo(dataDicEntries, stringIndex);
byte[][] globalSubrRawBuffers = ReadGlobalSubrIndex(reader);
- this.ReadFDSelect(reader, topDictionary.CidFontInfo);
- FontDict[] fontDicts = this.ReadFDArray(reader, topDictionary.CidFontInfo);
+ ReadFdSelect(reader, this.offset, topDictionary.CidFontInfo);
+ FontDict[] fontDicts = this.ReadFdArray(reader, this.offset, topDictionary.CidFontInfo.FDArray);
CffPrivateDictionary? privateDictionary = this.ReadPrivateDict(reader);
CffGlyphData[] glyphs = this.ReadCharStringsIndex(reader, topDictionary, globalSubrRawBuffers, fontDicts, privateDictionary);
@@ -64,7 +61,7 @@ private static string ReadNameIndex(BigEndianBinaryReader reader)
return reader.ReadString(offset.Length, Iso88591);
}
- private List ReadTopDICTIndex(BigEndianBinaryReader reader)
+ private List ReadTopDictIndex(BigEndianBinaryReader reader)
{
// 8. Top DICT INDEX
// This contains the top - level DICTs of all the fonts in the FontSet
@@ -88,20 +85,20 @@ private List ReadTopDICTIndex(BigEndianBinaryReader reader)
// been grouped together with the Top DICT operators for
// simplicity.The keys from the FontInfo dict are indicated in the
// Default, notes column of Table 9)
- return this.ReadDICTData(reader, offsets[0].Length);
+ return this.ReadDictData(reader, offsets[0].Length);
}
private static string[] ReadStringIndex(BigEndianBinaryReader reader)
{
if (!TryReadIndexDataOffsets(reader, out CffIndexOffset[]? offsets))
{
- return Array.Empty();
+ return [];
}
string[] stringIndex = new string[offsets.Length];
// Allow reusing the same buffer for shorter reads.
- using Buffer buffer = new Buffer(512);
+ using Buffer buffer = new(512);
Span bufferSpan = buffer.GetSpan();
for (int i = 0; i < offsets.Length; ++i)
@@ -109,7 +106,7 @@ private static string[] ReadStringIndex(BigEndianBinaryReader reader)
int length = offsets[i].Length;
if (length < bufferSpan.Length)
{
- Span slice = bufferSpan.Slice(0, length);
+ Span slice = bufferSpan[..length];
int actualRead = reader.BaseStream.Read(slice);
if (actualRead != length)
{
@@ -184,13 +181,13 @@ private CffTopDictionary ResolveTopDictInfo(List entries, strin
metrics.UnderlineThickness = entry.Operands[0].RealNumValue;
break;
case "FontBBox":
- metrics.FontBBox = new double[]
- {
+ metrics.FontBBox =
+ [
entry.Operands[0].RealNumValue,
entry.Operands[1].RealNumValue,
entry.Operands[2].RealNumValue,
entry.Operands[3].RealNumValue
- };
+ ];
break;
case "CharStrings":
this.charStringsOffset = (int)entry.Operands[0].RealNumValue;
@@ -421,119 +418,6 @@ private static void ReadCharsetsFormat2(BigEndianBinaryReader reader, string[] s
}
}
- private void ReadFDSelect(BigEndianBinaryReader reader, CidFontInfo cidFontInfo)
- {
- if (cidFontInfo.FDSelect == 0)
- {
- return;
- }
-
- reader.BaseStream.Position = this.offset + cidFontInfo.FDSelect;
- switch (reader.ReadByte())
- {
- case 0:
- cidFontInfo.FdSelectFormat = 0;
- for (int i = 0; i < cidFontInfo.CIDFountCount; i++)
- {
- cidFontInfo.FdSelectMap[i] = reader.ReadByte();
- }
-
- break;
-
- case 3:
- cidFontInfo.FdSelectFormat = 3;
- ushort nRanges = reader.ReadUInt16();
- FDRange3[] ranges = new FDRange3[nRanges + 1];
-
- cidFontInfo.FdSelectFormat = 3;
- cidFontInfo.FdRanges = ranges;
- for (int i = 0; i < nRanges; ++i)
- {
- ranges[i] = new FDRange3(reader.ReadUInt16(), reader.ReadByte());
- }
-
- ranges[nRanges] = new FDRange3(reader.ReadUInt16(), 0); // sentinel
- break;
-
- default:
- throw new NotSupportedException("Only FD Select format 0 and 3 are supported");
- }
- }
-
- private FontDict[] ReadFDArray(BigEndianBinaryReader reader, CidFontInfo cidFontInfo)
- {
- if (cidFontInfo.FDArray == 0)
- {
- return Array.Empty();
- }
-
- reader.BaseStream.Position = this.offset + cidFontInfo.FDArray;
-
- if (!TryReadIndexDataOffsets(reader, out CffIndexOffset[]? offsets))
- {
- return Array.Empty();
- }
-
- FontDict[] fontDicts = new FontDict[offsets.Length];
- for (int i = 0; i < fontDicts.Length; ++i)
- {
- // read DICT data
- List dic = this.ReadDICTData(reader, offsets[i].Length);
-
- // translate
- int offset = 0;
- int size = 0;
- int name = 0;
-
- foreach (CffDataDicEntry entry in dic)
- {
- switch (entry.Operator.Name)
- {
- default:
- throw new NotSupportedException();
- case "FontName":
- name = (int)entry.Operands[0].RealNumValue;
- break;
- case "Private": // private dic
- size = (int)entry.Operands[0].RealNumValue;
- offset = (int)entry.Operands[1].RealNumValue;
- break;
- }
- }
-
- fontDicts[i] = new FontDict(name, size, offset);
- }
-
- foreach (FontDict fdict in fontDicts)
- {
- reader.BaseStream.Position = this.offset + fdict.PrivateDicOffset;
-
- List dicData = this.ReadDICTData(reader, fdict.PrivateDicSize);
-
- if (dicData.Count > 0)
- {
- // Interpret the values of private dict
- foreach (CffDataDicEntry dicEntry in dicData)
- {
- switch (dicEntry.Operator.Name)
- {
- case "Subrs":
- int localSubrsOffset = (int)dicEntry.Operands[0].RealNumValue;
- reader.BaseStream.Position = this.offset + fdict.PrivateDicOffset + localSubrsOffset;
- fdict.LocalSubr = ReadSubrBuffer(reader);
- break;
-
- case "defaultWidthX":
- case "nominalWidthX":
- break;
- }
- }
- }
- }
-
- return fontDicts;
- }
-
private CffGlyphData[] ReadCharStringsIndex(
BigEndianBinaryReader reader,
CffTopDictionary topDictionary,
@@ -601,9 +485,10 @@ private CffGlyphData[] ReadCharStringsIndex(
glyphs[i] = new CffGlyphData(
(ushort)i,
globalSubrBuffers,
- localSubBuffer ?? Array.Empty(),
+ localSubBuffer ?? [],
privateDictionary?.NominalWidthX ?? 0,
- charstringsBuffer);
+ charstringsBuffer,
+ 1);
}
return glyphs;
@@ -677,8 +562,8 @@ private static void ReadFormat1Encoding(BigEndianBinaryReader reader)
}
reader.BaseStream.Position = this.offset + this.privateDICTOffset;
- List dicData = this.ReadDICTData(reader, this.privateDICTLength);
- byte[][] localSubrRawBuffers = Array.Empty();
+ List dicData = this.ReadDictData(reader, this.privateDICTLength);
+ byte[][] localSubrRawBuffers = [];
int defaultWidthX = 0;
int nominalWidthX = 0;
@@ -708,296 +593,4 @@ private static void ReadFormat1Encoding(BigEndianBinaryReader reader)
return new CffPrivateDictionary(localSubrRawBuffers, defaultWidthX, nominalWidthX);
}
-
- private static byte[][] ReadSubrBuffer(BigEndianBinaryReader reader)
- {
- if (!TryReadIndexDataOffsets(reader, out CffIndexOffset[]? offsets))
- {
- return Array.Empty();
- }
-
- byte[][] rawBufferList = new byte[offsets.Length][];
-
- for (int i = 0; i < rawBufferList.Length; ++i)
- {
- CffIndexOffset offset = offsets[i];
- rawBufferList[i] = reader.ReadBytes(offset.Length);
- }
-
- return rawBufferList;
- }
-
- private List ReadDICTData(BigEndianBinaryReader reader, int length)
- {
- // 4. DICT Data
-
- // Font dictionary data comprising key-value pairs is represented
- // in a compact tokenized format that is similar to that used to
- // represent Type 1 charstrings.
-
- // Dictionary keys are encoded as 1- or 2-byte operators and dictionary values are encoded as
- // variable-size numeric operands that represent either integer or
- // real values.
-
- //-----------------------------
- // A DICT is simply a sequence of
- // operand(s)/operator bytes concatenated together.
- int maxIndex = (int)(reader.BaseStream.Position + length);
- List dicData = new();
- while (reader.BaseStream.Position < maxIndex)
- {
- CffDataDicEntry dicEntry = this.ReadEntry(reader);
- dicData.Add(dicEntry);
- }
-
- return dicData;
- }
-
- private CffDataDicEntry ReadEntry(BigEndianBinaryReader reader)
- {
- List operands = new();
-
- //-----------------------------
- // An operator is preceded by the operand(s) that
- // specify its value.
- //--------------------------------
-
- //-----------------------------
- // Operators and operands may be distinguished by inspection of
- // their first byte:
- // 0–21 specify operators and
- // 28, 29, 30, and 32–254 specify operands(numbers).
- // Byte values 22–27, 31, and 255 are reserved.
-
- // An operator may be preceded by up to a maximum of 48 operands
- CFFOperator? @operator;
- while (true)
- {
- byte b0 = reader.ReadByte();
-
- if (b0 is >= 0 and <= 21)
- {
- // operators
- @operator = ReadOperator(reader, b0);
- break; // **break after found operator
- }
- else if (b0 is 28 or 29)
- {
- int num = ReadIntegerNumber(reader, b0);
- operands.Add(new CffOperand(num, OperandKind.IntNumber));
- }
- else if (b0 == 30)
- {
- double num = this.ReadRealNumber(reader);
- operands.Add(new CffOperand(num, OperandKind.RealNumber));
- }
- else if (b0 is >= 32 and <= 254)
- {
- int num = ReadIntegerNumber(reader, b0);
- operands.Add(new CffOperand(num, OperandKind.IntNumber));
- }
- else
- {
- throw new NotSupportedException("invalid DICT data b0 byte: " + b0);
- }
- }
-
- // I'm fairly confident that the operator can never be null.
- return new CffDataDicEntry(@operator!, operands.ToArray());
- }
-
- private static CFFOperator ReadOperator(BigEndianBinaryReader reader, byte b0)
- {
- // read operator key
- byte b1 = 0;
- if (b0 == 12)
- {
- // 2 bytes
- b1 = reader.ReadByte();
- }
-
- // get registered operator by its key
- return CFFOperator.GetOperatorByKey(b0, b1);
- }
-
- private double ReadRealNumber(BigEndianBinaryReader reader)
- {
- // from https://typekit.files.wordpress.com/2013/05/5176.cff.pdf
- // A real number operand is provided in addition to integer
- // operands.This operand begins with a byte value of 30 followed
- // by a variable-length sequence of bytes.Each byte is composed
- // of two 4 - bit nibbles asdefined in Table 5.
-
- // The first nibble of a
- // pair is stored in the most significant 4 bits of a byte and the
- // second nibble of a pair is stored in the least significant 4 bits of a byte
- StringBuilder sb = this.pooledStringBuilder;
- sb.Clear(); // reset
-
- bool done = false;
- bool exponentMissing = false;
- while (!done)
- {
- int b = reader.ReadByte();
-
- int nb_0 = (b >> 4) & 0xf;
- int nb_1 = b & 0xf;
-
- for (int i = 0; !done && i < 2; ++i)
- {
- int nibble = (i == 0) ? nb_0 : nb_1;
-
- switch (nibble)
- {
- case 0x0:
- case 0x1:
- case 0x2:
- case 0x3:
- case 0x4:
- case 0x5:
- case 0x6:
- case 0x7:
- case 0x8:
- case 0x9:
- sb.Append(nibble);
- exponentMissing = false;
- break;
- case 0xa:
- sb.Append('.');
- break;
- case 0xb:
- sb.Append('E');
- exponentMissing = true;
- break;
- case 0xc:
- sb.Append("E-");
- exponentMissing = true;
- break;
- case 0xd:
- break;
- case 0xe:
- sb.Append('-');
- break;
- case 0xf:
- done = true;
- break;
- default:
- throw new FontException("IllegalArgumentException");
- }
- }
- }
-
- if (exponentMissing)
- {
- // the exponent is missing, just append "0" to avoid an exception
- // not sure if 0 is the correct value, but it seems to fit
- // see PDFBOX-1522
- sb.Append('0');
- }
-
- if (sb.Length == 0)
- {
- return 0d;
- }
-
- if (!double.TryParse(
- sb.ToString(),
- NumberStyles.Number | NumberStyles.AllowExponent,
- CultureInfo.InvariantCulture,
- out double value))
- {
- throw new NotSupportedException();
- }
-
- return value;
- }
-
- private static int ReadIntegerNumber(BigEndianBinaryReader reader, byte b0)
- {
- if (b0 == 28)
- {
- return reader.ReadInt16();
- }
- else if (b0 == 29)
- {
- return reader.ReadInt32();
- }
- else if (b0 is >= 32 and <= 246)
- {
- return b0 - 139;
- }
- else if (b0 is >= 247 and <= 250)
- {
- int b1 = reader.ReadByte();
- return ((b0 - 247) * 256) + b1 + 108;
- }
- else if (b0 is >= 251 and <= 254)
- {
- int b1 = reader.ReadByte();
- return (-(b0 - 251) * 256) - b1 - 108;
- }
- else
- {
- throw new InvalidFontFileException("Invalid DICT data b0 byte: " + b0);
- }
- }
-
- private static bool TryReadIndexDataOffsets(BigEndianBinaryReader reader, [NotNullWhen(true)] out CffIndexOffset[]? value)
- {
- // INDEX Data
- // An INDEX is an array of variable-sized objects.It comprises a
- // header, an offset array, and object data.
- // The offset array specifies offsets within the object data.
- // An object is retrieved by
- // indexing the offset array and fetching the object at the
- // specified offset.
- // The object’s length can be determined by subtracting its offset
- // from the next offset in the offset array.
- // An additional offset is added at the end of the offset array so the
- // length of the last object may be determined.
- // The INDEX format is shown in Table 7
-
- // Table 7 INDEX Format
- // Type Name Description
- // Card16 count Number of objects stored in INDEX
- // OffSize offSize Offset array element size
- // Offset offset[count + 1] Offset array(from byte preceding object data)
- // Card8 data[] Object data
-
- // Offsets in the offset array are relative to the byte that precedes
- // the object data. Therefore the first element of the offset array
- // is always 1. (This ensures that every object has a corresponding
- // offset which is always nonzero and permits the efficient
- // implementation of dynamic object loading.)
-
- // An empty INDEX is represented by a count field with a 0 value
- // and no additional fields.Thus, the total size of an empty INDEX
- // is 2 bytes.
-
- // Note 2
- // An INDEX may be skipped by jumping to the offset specified by the last
- // element of the offset array
- ushort count = reader.ReadUInt16();
- if (count == 0)
- {
- value = null;
- return false;
- }
-
- int offSize = reader.ReadByte();
- int[] offsets = new int[count + 1];
- CffIndexOffset[] indexElems = new CffIndexOffset[count];
- for (int i = 0; i <= count; ++i)
- {
- offsets[i] = reader.ReadOffset(offSize);
- }
-
- for (int i = 0; i < count; ++i)
- {
- indexElems[i] = new CffIndexOffset(offsets[i], offsets[i + 1] - offsets[i]);
- }
-
- value = indexElems;
- return true;
- }
}
diff --git a/src/SixLabors.Fonts/Tables/Cff/Cff1Table.cs b/src/SixLabors.Fonts/Tables/Cff/Cff1Table.cs
index 8fd4f8f9a..613a4921b 100644
--- a/src/SixLabors.Fonts/Tables/Cff/Cff1Table.cs
+++ b/src/SixLabors.Fonts/Tables/Cff/Cff1Table.cs
@@ -1,8 +1,14 @@
// Copyright (c) Six Labors.
// Licensed under the Six Labors Split License.
+using SixLabors.Fonts.Tables.AdvancedTypographic.Variations;
+
namespace SixLabors.Fonts.Tables.Cff;
+///
+/// Represents the Compact Font Format (CFF) version 1 table.
+///
+///
internal sealed class Cff1Table : Table, ICffTable
{
internal const string TableName = "CFF "; // 4 chars
@@ -13,6 +19,8 @@ internal sealed class Cff1Table : Table, ICffTable
public int GlyphCount => this.glyphs.Length;
+ public ItemVariationStore? ItemVariationStore => null;
+
public CffGlyphData GetGlyph(int index)
=> this.glyphs[index];
@@ -52,7 +60,7 @@ public static Cff1Table Load(BigEndianBinaryReader reader)
switch (major)
{
case 1:
- CffParser parser = new();
+ Cff1Parser parser = new();
return new(parser.Load(reader, position));
default:
diff --git a/src/SixLabors.Fonts/Tables/Cff/Cff2Font.cs b/src/SixLabors.Fonts/Tables/Cff/Cff2Font.cs
new file mode 100644
index 000000000..c792fc7ae
--- /dev/null
+++ b/src/SixLabors.Fonts/Tables/Cff/Cff2Font.cs
@@ -0,0 +1,20 @@
+// Copyright (c) Six Labors.
+// Licensed under the Six Labors Split License.
+
+using SixLabors.Fonts.Tables.AdvancedTypographic.Variations;
+
+namespace SixLabors.Fonts.Tables.Cff;
+
+///
+/// Represents a parsed CFF2 font with an associated Item Variation Store for font variations.
+///
+internal class Cff2Font : CffFont
+{
+ public Cff2Font(string name, CffTopDictionary metrics, CffGlyphData[] glyphs, ItemVariationStore itemVariationStore)
+ : base(name, metrics, glyphs) => this.ItemVariationStore = itemVariationStore;
+
+ ///
+ /// Gets or sets the Item Variation Store used for CFF2 blend interpolation.
+ ///
+ public ItemVariationStore ItemVariationStore { get; set; }
+}
diff --git a/src/SixLabors.Fonts/Tables/Cff/Cff2Parser.cs b/src/SixLabors.Fonts/Tables/Cff/Cff2Parser.cs
new file mode 100644
index 000000000..bb9e16994
--- /dev/null
+++ b/src/SixLabors.Fonts/Tables/Cff/Cff2Parser.cs
@@ -0,0 +1,215 @@
+// Copyright (c) Six Labors.
+// Licensed under the Six Labors Split License.
+
+using SixLabors.Fonts.Tables.AdvancedTypographic.Variations;
+
+namespace SixLabors.Fonts.Tables.Cff;
+
+///
+/// Parses a Compact Font Format (CFF) version 2 font program.
+///
+///
+internal class Cff2Parser : CffParserBase
+{
+ private static readonly ItemVariationStore EmptyItemVariationStoreTable = new(VariationRegionList.EmptyVariationRegionList, []);
+
+ private long offset;
+
+ private double[]? fontMatrix;
+ private int charStringIndexOffset;
+ private int variationStoreOffset;
+ private int? fdArrayOffset;
+ private int? fdSelectOffset;
+ private ItemVariationStore? itemVariationStore;
+
+ public Cff2Font Load(BigEndianBinaryReader reader, byte hdrSize, ushort topDictLength, string fontName, long offset)
+ {
+ this.offset = offset;
+ reader.Seek(hdrSize, SeekOrigin.Begin);
+
+ this.ReadTopDictData(reader, topDictLength);
+ reader.Seek(hdrSize + topDictLength, SeekOrigin.Begin);
+
+ CidFontInfo cidFontInfo = new()
+ {
+ FDArray = this.fdArrayOffset.GetValueOrDefault(),
+ FDSelect = this.fdSelectOffset.GetValueOrDefault(),
+ };
+
+ byte[][] globalSubrRawBuffers = ReadSubrBuffer(reader, cff2: true);
+
+ // The Item Variation Store is optional. When present, its offset is
+ // relative to the start of the CFF2 table.
+ if (this.variationStoreOffset > 0)
+ {
+ reader.Seek(this.variationStoreOffset, SeekOrigin.Begin);
+ ushort variationStoreLength = reader.ReadUInt16();
+ this.itemVariationStore = variationStoreLength == 0
+ ? EmptyItemVariationStoreTable
+ : ItemVariationStore.Load(reader, this.variationStoreOffset + 2);
+ }
+ else
+ {
+ this.itemVariationStore = EmptyItemVariationStoreTable;
+ }
+
+ if (this.fdSelectOffset.HasValue)
+ {
+ ReadFdSelect(reader, this.offset, cidFontInfo);
+ }
+
+ CffIndexOffset[] charStringOffsets = this.ReadCharStringIndex(reader);
+ byte[][] charStringBuffers = ReadCharStringBuffers(reader, charStringOffsets);
+
+ int fdArrayOffset = this.fdArrayOffset.GetValueOrDefault();
+ FontDict[] fontDicts = this.ReadFdArray(reader, this.offset, fdArrayOffset, cff2: true);
+ CffTopDictionary topDictionary = new()
+ {
+ CidFontInfo = cidFontInfo,
+ FontMatrix = this.fontMatrix ?? [0.001, 0, 0, 0.001, 0, 0]
+ };
+
+ CffPrivateDictionary privateDictionary = fontDicts.Length > 0
+ ? new(fontDicts[0].LocalSubr, 0, 0)
+ : new([], 0, 0);
+ int glyphCount = charStringOffsets.Length;
+ CffGlyphData[] glyphs = this.ReadCharStringsIndex(topDictionary, globalSubrRawBuffers, fontDicts, privateDictionary, charStringBuffers, glyphCount);
+
+ return new(fontName, topDictionary, glyphs, this.itemVariationStore);
+ }
+
+ private void ReadTopDictData(BigEndianBinaryReader reader, ushort topDictLength)
+ {
+ long startPosition = reader.BaseStream.Position;
+ long maxPosition = startPosition + topDictLength;
+ while (reader.BaseStream.Position < maxPosition)
+ {
+ CffDataDicEntry dataDicEntry = this.ReadEntry(reader);
+ switch (dataDicEntry.Operator.Name)
+ {
+ case "FontMatrix":
+ this.fontMatrix = new double[dataDicEntry.Operands.Length];
+ for (int i = 0; i < dataDicEntry.Operands.Length; i++)
+ {
+ this.fontMatrix[i] = dataDicEntry.Operands[i].RealNumValue;
+ }
+
+ break;
+ case "CharStrings":
+ this.charStringIndexOffset = (int)dataDicEntry.Operands[0].RealNumValue;
+ break;
+ case "FDArray":
+ this.fdArrayOffset = (int)dataDicEntry.Operands[0].RealNumValue;
+ break;
+ case "FDSelect":
+ this.fdSelectOffset = (int)dataDicEntry.Operands[0].RealNumValue;
+ break;
+ case "vstore":
+ this.variationStoreOffset = (int)dataDicEntry.Operands[0].RealNumValue;
+ break;
+ default:
+ throw new InvalidFontFileException("Error parsing TopDictData.");
+ }
+ }
+ }
+
+ private CffIndexOffset[] ReadCharStringIndex(BigEndianBinaryReader reader)
+ {
+ reader.BaseStream.Position = this.offset + this.charStringIndexOffset;
+ if (!TryReadIndexDataOffsets(reader, out CffIndexOffset[]? offsets, cff2: true))
+ {
+ throw new InvalidFontFileException("No glyph data found.");
+ }
+
+ return offsets;
+ }
+
+ private static byte[][] ReadCharStringBuffers(BigEndianBinaryReader reader, CffIndexOffset[] offsets)
+ {
+ int glyphCount = offsets.Length;
+ byte[][] charStringBuffers = new byte[offsets.Length][];
+ for (int i = 0; i < glyphCount; ++i)
+ {
+ CffIndexOffset cffIndexOffset = offsets[i];
+ charStringBuffers[i] = reader.ReadBytes(cffIndexOffset.Length);
+ }
+
+ return charStringBuffers;
+ }
+
+ private CffGlyphData[] ReadCharStringsIndex(
+ CffTopDictionary topDictionary,
+ byte[][] globalSubrBuffers,
+ FontDict[] fontDicts,
+ CffPrivateDictionary? privateDictionary,
+ byte[][] charStringBuffers,
+ int glyphCount)
+ {
+ // 14. CharStrings INDEX
+
+ // This contains the charstrings of all the glyphs in a font stored in
+ // an INDEX structure.
+
+ // Charstring objects contained within this
+ // INDEX are accessed by GID.
+
+ // The first charstring(GID 0) must be
+ // the.notdef glyph.
+
+ // The number of glyphs available in a font may
+ // be determined from the count field in the INDEX.
+
+ //
+
+ // The format of the charstring data, and therefore the method of
+ // interpretation, is specified by the
+ // CharstringType operator in the Top DICT.
+
+ // The CharstringType operator has a default value
+ // of 2 indicating the Type 2 charstring format which was designed
+ // in conjunction with CFF.
+
+ // Type 1 charstrings are documented in
+ // the “Adobe Type 1 Font Format” published by Addison - Wesley.
+
+ // Type 2 charstrings are described in Adobe Technical Note #5177:
+ // “Type 2 Charstring Format.” Other charstring types may also be
+ // supported by this method.
+ CffGlyphData[] glyphs = new CffGlyphData[glyphCount];
+ byte[][]? localSubBuffer = privateDictionary?.LocalSubrRawBuffers;
+
+ // Is the font a CID font?
+ FDRangeProvider fdRangeProvider = new(topDictionary.CidFontInfo);
+ bool isCidFont = topDictionary.CidFontInfo.FdRanges.Length > 0;
+ int vsIndex = fontDicts.Length > 0 ? fontDicts[0].VsIndex : 0;
+ for (int i = 0; i < glyphCount; ++i)
+ {
+ byte[] charstringsBuffer = charStringBuffers[i];
+
+ // Now we can parse the raw glyph instructions
+ // Select proper local private dict.
+ if (isCidFont)
+ {
+ fdRangeProvider.SetCurrentGlyphIndex((ushort)i);
+ int fdIndex = fdRangeProvider.SelectedFDArray;
+ localSubBuffer = fontDicts[fdIndex].LocalSubr;
+ vsIndex = fontDicts[fdIndex].VsIndex;
+ }
+
+ glyphs[i] = new CffGlyphData(
+ (ushort)i,
+ globalSubrBuffers,
+ localSubBuffer ?? [],
+ privateDictionary?.NominalWidthX ?? 0,
+ charstringsBuffer,
+ 2,
+ this.itemVariationStore,
+ vsIndex)
+ {
+ FontMatrix = topDictionary.FontMatrix
+ };
+ }
+
+ return glyphs;
+ }
+}
diff --git a/src/SixLabors.Fonts/Tables/Cff/Cff2Table.cs b/src/SixLabors.Fonts/Tables/Cff/Cff2Table.cs
index 355d1ffcc..8722f800d 100644
--- a/src/SixLabors.Fonts/Tables/Cff/Cff2Table.cs
+++ b/src/SixLabors.Fonts/Tables/Cff/Cff2Table.cs
@@ -1,18 +1,33 @@
// Copyright (c) Six Labors.
// Licensed under the Six Labors Split License.
+using System.Globalization;
+using SixLabors.Fonts.Tables.AdvancedTypographic.Variations;
+using SixLabors.Fonts.Tables.General.Name;
+using SixLabors.Fonts.WellKnownIds;
+
namespace SixLabors.Fonts.Tables.Cff;
+///
+/// Represents the Compact Font Format (CFF) version 2 table.
+///
+///
internal sealed class Cff2Table : Table, ICffTable
{
internal const string TableName = "CFF2";
private readonly CffGlyphData[] glyphs;
- public Cff2Table(CffFont cff1Font) => this.glyphs = cff1Font.Glyphs;
+ public Cff2Table(CffFont cffFont, ItemVariationStore itemVariationStore)
+ {
+ this.glyphs = cffFont.Glyphs;
+ this.ItemVariationStore = itemVariationStore;
+ }
public int GlyphCount => this.glyphs.Length;
+ public ItemVariationStore ItemVariationStore { get; }
+
public CffGlyphData GetGlyph(int index)
=> this.glyphs[index];
@@ -23,11 +38,32 @@ public CffGlyphData GetGlyph(int index)
return null;
}
+ NameTable nameTable = fontReader.GetTable();
+ string fontName = nameTable.GetNameById(CultureInfo.InvariantCulture, KnownNameIds.PostscriptName);
+
using (binaryReader)
{
- return Load(binaryReader);
+ return Load(binaryReader, fontName);
}
}
- public static Cff2Table Load(BigEndianBinaryReader reader) => throw new NotSupportedException("CFF2 Fonts are not currently supported.");
+ public static Cff2Table Load(BigEndianBinaryReader reader, string fontName)
+ {
+ long position = reader.BaseStream.Position;
+ byte major = reader.ReadUInt8();
+ byte minor = reader.ReadUInt8();
+ byte hdrSize = reader.ReadUInt8();
+ ushort topDictLength = reader.ReadUInt16();
+
+ switch (major)
+ {
+ case 2:
+ Cff2Parser parser = new();
+ Cff2Font cffFont = parser.Load(reader, hdrSize, topDictLength, fontName, position);
+ return new(cffFont, cffFont.ItemVariationStore);
+
+ default:
+ throw new NotSupportedException("CFF version 2 is expected");
+ }
+ }
}
diff --git a/src/SixLabors.Fonts/Tables/Cff/CffDataDicEntry.cs b/src/SixLabors.Fonts/Tables/Cff/CffDataDicEntry.cs
index bad5b79b1..4509b2b5b 100644
--- a/src/SixLabors.Fonts/Tables/Cff/CffDataDicEntry.cs
+++ b/src/SixLabors.Fonts/Tables/Cff/CffDataDicEntry.cs
@@ -7,6 +7,10 @@
namespace SixLabors.Fonts.Tables.Cff;
+///
+/// Represents a single key-value entry parsed from a CFF DICT structure.
+/// The key is a and the value is an array of .
+///
internal class CffDataDicEntry
{
public CffDataDicEntry(CFFOperator @operator, CffOperand[] operands)
@@ -15,8 +19,14 @@ public CffDataDicEntry(CFFOperator @operator, CffOperand[] operands)
this.Operands = operands;
}
+ ///
+ /// Gets the DICT operator that identifies this entry.
+ ///
public CFFOperator Operator { get; }
+ ///
+ /// Gets the operand values associated with this operator.
+ ///
public CffOperand[] Operands { get; }
#if DEBUG
diff --git a/src/SixLabors.Fonts/Tables/Cff/CffEvaluationEngine.cs b/src/SixLabors.Fonts/Tables/Cff/CffEvaluationEngine.cs
index dabdda4ce..1fcb8ab8c 100644
--- a/src/SixLabors.Fonts/Tables/Cff/CffEvaluationEngine.cs
+++ b/src/SixLabors.Fonts/Tables/Cff/CffEvaluationEngine.cs
@@ -4,6 +4,7 @@
using System.Numerics;
using System.Runtime.CompilerServices;
using SixLabors.Fonts.Rendering;
+using SixLabors.Fonts.Tables.AdvancedTypographic.Variations;
namespace SixLabors.Fonts.Tables.Cff;
@@ -14,7 +15,7 @@ namespace SixLabors.Fonts.Tables.Cff;
///
///
/// A Type 2 charstring program is a sequence of unsigned 8-bit bytes that encode numbers and operators.
-/// The byte value specifies a operator, a number, or subsequent bytes that are to be interpreted in a specific manner
+/// The byte value specifies a operator, a number, or subsequent bytes that are to be interpreted in a specific manner.
///
internal ref struct CffEvaluationEngine
{
@@ -33,12 +34,20 @@ internal ref struct CffEvaluationEngine
private readonly int localBias;
private readonly Dictionary trans;
private bool isDisposed;
+ private readonly int version;
+ private readonly GlyphVariationProcessor? glyphVariationProcessor;
+ private int vsIndex;
public CffEvaluationEngine(
ReadOnlySpan charStrings,
ReadOnlySpan globalSubrBuffers,
ReadOnlySpan localSubrBuffers,
- int nominalWidthX)
+ int nominalWidthX,
+ int version,
+ ItemVariationStore? itemVariationStore = null,
+ FVarTable? fVar = null,
+ AVarTable? aVar = null,
+ int vsIndex = 0)
{
this.transforming = default;
this.charStrings = charStrings;
@@ -48,7 +57,7 @@ public CffEvaluationEngine(
this.globalBias = CalculateBias(this.globalSubrBuffers.Length);
this.localBias = CalculateBias(this.localSubrBuffers.Length);
- this.trans = new();
+ this.trans = [];
this.x = 0;
this.y = 0;
@@ -56,6 +65,20 @@ public CffEvaluationEngine(
this.nStems = 0;
this.stack = new(50);
this.isDisposed = false;
+ this.version = version;
+ this.glyphVariationProcessor = null;
+
+ if (itemVariationStore != null)
+ {
+ if (fVar is null)
+ {
+ throw new InvalidFontFileException("missing fVar table required for glyph variations processing");
+ }
+
+ this.glyphVariationProcessor = new GlyphVariationProcessor(itemVariationStore, fVar, aVar);
+ }
+
+ this.vsIndex = vsIndex;
}
public Bounds GetBounds()
@@ -197,12 +220,20 @@ private void Parse(ReadOnlySpan buffer)
case Type2Operator1.Return:
- // TODO: CFF2
+ if (this.version >= 2)
+ {
+ break;
+ }
+
return;
case Type2Operator1.Endchar:
- // TODO: CFF2
+ if (this.version >= 2)
+ {
+ break;
+ }
+
if (this.stack.Length > 0)
{
this.CheckWidth();
@@ -216,14 +247,49 @@ private void Parse(ReadOnlySpan buffer)
endCharEncountered = true;
break;
- case Type2Operator1.Reserved15_:
+ case Type2Operator1.VsIndex:
+ if (this.version < 2)
+ {
+ throw new NotSupportedException("blend operator is not supported in CFF v1");
+ }
- // TODO: CFF2
+ this.vsIndex = (int)this.stack.Pop();
break;
- case Type2Operator1.Reserved16_:
+ case Type2Operator1.Blend:
+ if (this.version < 2)
+ {
+ throw new NotSupportedException("blend operator is not supported in CFF v1");
+ }
+
+ if (this.glyphVariationProcessor is null)
+ {
+ throw new NotSupportedException("blend operator in non-variation font");
+ }
+
+ float[] blendVector = this.glyphVariationProcessor.BlendVector(this.vsIndex);
+ float numBlends = this.stack.Pop();
+ float numOperands = numBlends * blendVector.Length;
+ int delta = this.stack.Length - (int)numOperands;
+ int basis = delta - (int)numBlends;
+
+ for (int i = 0; i < numBlends; i++)
+ {
+ float sum = this.stack[basis + i];
+ for (int j = 0; j < blendVector.Length; j++)
+ {
+ sum += blendVector[j] * this.stack[delta++];
+ }
+
+ this.stack[basis + i] = sum;
+ }
+
+ while (numOperands-- > 0)
+ {
+ this.stack.Pop();
+ }
- // TODO: CFF2
break;
+
case Type2Operator1.Hintmask:
case Type2Operator1.Cntrmask:
diff --git a/src/SixLabors.Fonts/Tables/Cff/CffFont.cs b/src/SixLabors.Fonts/Tables/Cff/CffFont.cs
index 9be4c5306..c5fb9a46c 100644
--- a/src/SixLabors.Fonts/Tables/Cff/CffFont.cs
+++ b/src/SixLabors.Fonts/Tables/Cff/CffFont.cs
@@ -3,6 +3,9 @@
namespace SixLabors.Fonts.Tables.Cff;
+///
+/// Represents a parsed CFF font containing the top-level dictionary and glyph data.
+///
internal class CffFont
{
public CffFont(string name, CffTopDictionary metrics, CffGlyphData[] glyphs)
@@ -12,9 +15,18 @@ public CffFont(string name, CffTopDictionary metrics, CffGlyphData[] glyphs)
this.Glyphs = glyphs;
}
+ ///
+ /// Gets or sets the PostScript font name.
+ ///
public string FontName { get; set; }
+ ///
+ /// Gets or sets the Top DICT data containing font-wide metrics and properties.
+ ///
public CffTopDictionary Metrics { get; set; }
+ ///
+ /// Gets the array of glyph data parsed from the CharStrings INDEX.
+ ///
public CffGlyphData[] Glyphs { get; }
}
diff --git a/src/SixLabors.Fonts/Tables/Cff/CffGlyphData.cs b/src/SixLabors.Fonts/Tables/Cff/CffGlyphData.cs
index 1bb5bc54e..92e9d090b 100644
--- a/src/SixLabors.Fonts/Tables/Cff/CffGlyphData.cs
+++ b/src/SixLabors.Fonts/Tables/Cff/CffGlyphData.cs
@@ -3,6 +3,7 @@
using System.Numerics;
using SixLabors.Fonts.Rendering;
+using SixLabors.Fonts.Tables.AdvancedTypographic.Variations;
namespace SixLabors.Fonts.Tables.Cff;
@@ -12,45 +13,77 @@ internal struct CffGlyphData
private readonly byte[][] localSubrBuffers;
private readonly byte[] charStrings;
private readonly int nominalWidthX;
+ private readonly int version;
+ private readonly ItemVariationStore? itemVariationStore;
+ private readonly int vsIndex;
public CffGlyphData(
ushort glyphIndex,
byte[][] globalSubrBuffers,
byte[][] localSubrBuffers,
int nominalWidthX,
- byte[] charStrings)
+ byte[] charStrings,
+ int version,
+ ItemVariationStore? itemVariationStore = null,
+ int vsIndex = 0)
{
this.GlyphIndex = glyphIndex;
this.globalSubrBuffers = globalSubrBuffers;
this.localSubrBuffers = localSubrBuffers;
this.nominalWidthX = nominalWidthX;
this.charStrings = charStrings;
+ this.version = version;
+ this.itemVariationStore = itemVariationStore;
+ this.vsIndex = vsIndex;
this.GlyphName = null;
+
+ // Variations tables are only present for CFF2 format.
+ this.FVar = null;
+ this.AVar = null;
+ this.GVar = null;
}
- public readonly ushort GlyphIndex { get; }
+ public ushort GlyphIndex { get; }
public string? GlyphName { get; set; }
+ public FVarTable? FVar { get; set; }
+
+ public AVarTable? AVar { get; set; }
+
+ public GVarTable? GVar { get; set; }
+
+ public double[]? FontMatrix { get; set; }
+
public readonly Bounds GetBounds()
{
- using var engine = new CffEvaluationEngine(
+ using CffEvaluationEngine engine = new(
this.charStrings,
this.globalSubrBuffers,
this.localSubrBuffers,
- this.nominalWidthX);
+ this.nominalWidthX,
+ this.version,
+ this.itemVariationStore,
+ this.FVar,
+ this.AVar,
+ this.vsIndex);
return engine.GetBounds();
}
public readonly void RenderTo(IGlyphRenderer renderer, Vector2 origin, Vector2 scale, Vector2 offset, Matrix3x2 transform)
{
- using var engine = new CffEvaluationEngine(
+ using CffEvaluationEngine engine = new(
this.charStrings,
this.globalSubrBuffers,
this.localSubrBuffers,
- this.nominalWidthX);
+ this.nominalWidthX,
+ this.version,
+ this.itemVariationStore,
+ this.FVar,
+ this.AVar,
+ this.vsIndex);
engine.RenderTo(renderer, origin, scale, offset, transform);
}
diff --git a/src/SixLabors.Fonts/Tables/Cff/CffGlyphMetrics.cs b/src/SixLabors.Fonts/Tables/Cff/CffGlyphMetrics.cs
index ca952eaf5..52e4d6464 100644
--- a/src/SixLabors.Fonts/Tables/Cff/CffGlyphMetrics.cs
+++ b/src/SixLabors.Fonts/Tables/Cff/CffGlyphMetrics.cs
@@ -127,6 +127,16 @@ internal override void RenderTo(
if (!UnicodeUtility.ShouldRenderWhiteSpaceOnly(this.CodePoint))
{
Vector2 scale = new Vector2(scaledPPEM) / this.ScaleFactor;
+
+ // Apply the CFF FontMatrix to convert charstring coordinates to design units.
+ // The normalized FontMatrix (fontMatrix * unitsPerEM) is identity for the default
+ // [0.001, 0, 0, 0.001, 0, 0] with upm=1000.
+ if (this.glyphData.FontMatrix is double[] fm)
+ {
+ float upm = this.UnitsPerEm;
+ scale *= new Vector2((float)(fm[0] * upm), (float)(fm[3] * upm));
+ }
+
Vector2 scaledOffset = this.Offset * scale;
this.glyphData.RenderTo(renderer, renderLocation, scale, scaledOffset, rotation);
}
diff --git a/src/SixLabors.Fonts/Tables/Cff/CffIndexOffset.cs b/src/SixLabors.Fonts/Tables/Cff/CffIndexOffset.cs
index 039f5baa4..7fcdec240 100644
--- a/src/SixLabors.Fonts/Tables/Cff/CffIndexOffset.cs
+++ b/src/SixLabors.Fonts/Tables/Cff/CffIndexOffset.cs
@@ -3,15 +3,18 @@
namespace SixLabors.Fonts.Tables.Cff;
+///
+/// Represents the position and length of an element within a CFF INDEX structure.
+///
internal readonly struct CffIndexOffset
{
///
- /// The starting offset
+ /// The starting offset of the element within the INDEX data.
///
public readonly int Start;
///
- /// The length
+ /// The length in bytes of the element.
///
public readonly int Length;
diff --git a/src/SixLabors.Fonts/Tables/Cff/CffOperand.cs b/src/SixLabors.Fonts/Tables/Cff/CffOperand.cs
index acfa8a6d6..0016184c9 100644
--- a/src/SixLabors.Fonts/Tables/Cff/CffOperand.cs
+++ b/src/SixLabors.Fonts/Tables/Cff/CffOperand.cs
@@ -7,6 +7,10 @@
namespace SixLabors.Fonts.Tables.Cff;
+///
+/// Represents a numeric operand value from a CFF DICT entry.
+/// Operands can be integers or real numbers as encoded in the DICT data.
+///
internal readonly struct CffOperand
{
public CffOperand(double number, OperandKind kind)
@@ -15,8 +19,14 @@ public CffOperand(double number, OperandKind kind)
this.RealNumValue = number;
}
+ ///
+ /// Gets the kind of this operand (integer or real number).
+ ///
public readonly OperandKind Kind { get; }
+ ///
+ /// Gets the numeric value of this operand.
+ ///
public readonly double RealNumValue { get; }
#if DEBUG
diff --git a/src/SixLabors.Fonts/Tables/Cff/CffOperator.cs b/src/SixLabors.Fonts/Tables/Cff/CffOperator.cs
index 0f9514d83..de31b5195 100644
--- a/src/SixLabors.Fonts/Tables/Cff/CffOperator.cs
+++ b/src/SixLabors.Fonts/Tables/Cff/CffOperator.cs
@@ -3,25 +3,38 @@
namespace SixLabors.Fonts.Tables.Cff;
+///
+/// Represents a CFF DICT operator with its name and operand kind.
+/// Operators are registered in a static dictionary keyed by their byte encoding
+/// and looked up during DICT parsing.
+///
+///
internal sealed class CFFOperator
{
- private static readonly Lazy> RegisteredOperators = new(() => CreateDictionary(), true);
- private readonly byte b0;
- private readonly byte b1;
- private readonly OperatorOperandKind operatorOperandKind;
-
- // b0 the first byte of a two byte value
- // b1 the second byte of a two byte value
- private CFFOperator(string name, byte b0, byte b1, OperatorOperandKind operatorOperandKind)
+ private static readonly Lazy> RegisteredOperators = new(CreateDictionary, true);
+
+ private CFFOperator(string name, OperatorOperandKind operandKind)
{
- this.b0 = b0;
- this.b1 = b1;
this.Name = name;
- this.operatorOperandKind = operatorOperandKind;
+ this.OperandKind = operandKind;
}
+ ///
+ /// Gets the name of the operator (e.g. "CharStrings", "FontMatrix", "Private").
+ ///
public string Name { get; }
+ ///
+ /// Gets the expected operand format for this operator.
+ ///
+ public OperatorOperandKind OperandKind { get; }
+
+ ///
+ /// Looks up a registered CFF operator by its one- or two-byte encoding.
+ ///
+ /// The first byte of the operator.
+ /// The second byte (0 for single-byte operators, or 12 prefix byte value).
+ /// The matching , or if not found.
public static CFFOperator GetOperatorByKey(byte b0, byte b1)
{
RegisteredOperators.Value.TryGetValue((b1 << 8) | b0, out CFFOperator? found);
@@ -30,7 +43,7 @@ public static CFFOperator GetOperatorByKey(byte b0, byte b1)
private static Dictionary CreateDictionary()
{
- Dictionary dictionary = new();
+ Dictionary dictionary = [];
// Table 9: Top DICT Operator Entries
Register(dictionary, 0, "version", OperatorOperandKind.SID);
@@ -84,24 +97,29 @@ private static Dictionary CreateDictionary()
Register(dictionary, 12, 13, "StemSnapV", OperatorOperandKind.Delta);
Register(dictionary, 12, 14, "ForceBold", OperatorOperandKind.Boolean);
- // reserved 12 15//https://typekit.files.wordpress.com/2013/05/5176.cff.pdf
- // reserved 12 16//https://typekit.files.wordpress.com/2013/05/5176.cff.pdf
- Register(dictionary, 12, 17, "LanguageGroup", OperatorOperandKind.Number); // https://typekit.files.wordpress.com/2013/05/5176.cff.pdf
- Register(dictionary, 12, 18, "ExpansionFactor", OperatorOperandKind.Number); // https://typekit.files.wordpress.com/2013/05/5176.cff.pdf
- Register(dictionary, 12, 19, "initialRandomSeed", OperatorOperandKind.Number); // https://typekit.files.wordpress.com/2013/05/5176.cff.pdf
+ // reserved 12 15
+ // reserved 12 16
+ Register(dictionary, 12, 17, "LanguageGroup", OperatorOperandKind.Number);
+ Register(dictionary, 12, 18, "ExpansionFactor", OperatorOperandKind.Number);
+ Register(dictionary, 12, 19, "initialRandomSeed", OperatorOperandKind.Number);
Register(dictionary, 19, "Subrs", OperatorOperandKind.Number);
Register(dictionary, 20, "defaultWidthX", OperatorOperandKind.Number);
Register(dictionary, 21, "nominalWidthX", OperatorOperandKind.Number);
+ // CFF2 operators
+ Register(dictionary, 22, "vsindex", OperatorOperandKind.Number);
+ Register(dictionary, 23, "blend", OperatorOperandKind.Number);
+ Register(dictionary, 24, "vstore", OperatorOperandKind.Number);
+
return dictionary;
}
- private static void Register(Dictionary dictionary, byte b0, byte b1, string operatorName, OperatorOperandKind opopKind)
- => dictionary.Add((b1 << 8) | b0, new CFFOperator(operatorName, b0, b1, opopKind));
+ private static void Register(Dictionary dictionary, byte b0, byte b1, string name, OperatorOperandKind operandKind)
+ => dictionary.Add((b1 << 8) | b0, new CFFOperator(name, operandKind));
- private static void Register(Dictionary dictionary, byte b0, string operatorName, OperatorOperandKind opopKind)
- => dictionary.Add(b0, new CFFOperator(operatorName, b0, 0, opopKind));
+ private static void Register(Dictionary dictionary, byte b0, string name, OperatorOperandKind operandKind)
+ => dictionary.Add(b0, new CFFOperator(name, operandKind));
#if DEBUG
public override string ToString() => this.Name;
diff --git a/src/SixLabors.Fonts/Tables/Cff/CffParserBase.cs b/src/SixLabors.Fonts/Tables/Cff/CffParserBase.cs
new file mode 100644
index 000000000..362542512
--- /dev/null
+++ b/src/SixLabors.Fonts/Tables/Cff/CffParserBase.cs
@@ -0,0 +1,450 @@
+// Copyright (c) Six Labors.
+// Licensed under the Six Labors Split License.
+
+using System.Diagnostics.CodeAnalysis;
+using System.Globalization;
+using System.Text;
+
+namespace SixLabors.Fonts.Tables.Cff;
+
+///
+/// Base class for CFF1 and CFF2 parsers providing shared DICT parsing,
+/// INDEX reading, FDSelect, and subroutine loading functionality.
+///
+internal abstract class CffParserBase
+{
+ private readonly StringBuilder pooledStringBuilder = new();
+
+ protected static void ReadFdSelect(BigEndianBinaryReader reader, long offset, CidFontInfo cidFontInfo)
+ {
+ if (cidFontInfo.FDSelect is 0)
+ {
+ return;
+ }
+
+ reader.BaseStream.Position = offset + cidFontInfo.FDSelect;
+ switch (reader.ReadByte())
+ {
+ case 0:
+ {
+ cidFontInfo.FdSelectFormat = 0;
+ for (int i = 0; i < cidFontInfo.CIDFountCount; i++)
+ {
+ cidFontInfo.FdSelectMap[i] = reader.ReadByte();
+ }
+
+ break;
+ }
+
+ case 3:
+ {
+ cidFontInfo.FdSelectFormat = 3;
+ ushort nRanges = reader.ReadUInt16();
+ FDRange[] ranges = new FDRange[nRanges + 1];
+
+ cidFontInfo.FdSelectFormat = 3;
+ cidFontInfo.FdRanges = ranges;
+ for (int i = 0; i < nRanges; ++i)
+ {
+ ranges[i] = new FDRange(reader.ReadUInt16(), reader.ReadByte());
+ }
+
+ ranges[nRanges] = new FDRange(reader.ReadUInt16(), 0); // sentinel
+ break;
+ }
+
+ case 4:
+ {
+ cidFontInfo.FdSelectFormat = 4;
+ uint nRanges = reader.ReadUInt32();
+ FDRange[] ranges = new FDRange[nRanges + 1];
+
+ cidFontInfo.FdSelectFormat = 3;
+ cidFontInfo.FdRanges = ranges;
+ for (int i = 0; i < nRanges; ++i)
+ {
+ ranges[i] = new FDRange(reader.ReadUInt32(), reader.ReadUInt16());
+ }
+
+ ranges[nRanges] = new FDRange(reader.ReadUInt32(), 0); // sentinel
+ break;
+ }
+
+ default:
+ throw new NotSupportedException("Only FD Select format 0, 3 and 4 are supported");
+ }
+ }
+
+ protected FontDict[] ReadFdArray(BigEndianBinaryReader reader, long offset, long fdArrayOffset, bool cff2 = false)
+ {
+ if (fdArrayOffset is 0)
+ {
+ return [];
+ }
+
+ reader.BaseStream.Position = offset + fdArrayOffset;
+
+ if (!TryReadIndexDataOffsets(reader, out CffIndexOffset[]? offsets, cff2))
+ {
+ return [];
+ }
+
+ FontDict[] fontDicts = new FontDict[offsets.Length];
+ for (int i = 0; i < fontDicts.Length; ++i)
+ {
+ // Read DICT data.
+ List dic = this.ReadDictData(reader, offsets[i].Length);
+
+ // translate
+ int fontDictsOffset = 0;
+ int size = 0;
+ int name = 0;
+
+ foreach (CffDataDicEntry entry in dic)
+ {
+ switch (entry.Operator.Name)
+ {
+ default:
+ throw new NotSupportedException();
+ case "FontName":
+ name = (int)entry.Operands[0].RealNumValue;
+ break;
+ case "Private": // private dic
+ size = (int)entry.Operands[0].RealNumValue;
+ fontDictsOffset = (int)entry.Operands[1].RealNumValue;
+ break;
+ }
+ }
+
+ fontDicts[i] = new FontDict(name, size, fontDictsOffset);
+ }
+
+ foreach (FontDict fdict in fontDicts)
+ {
+ reader.BaseStream.Position = offset + fdict.PrivateDicOffset;
+
+ List dicData = this.ReadDictData(reader, fdict.PrivateDicSize);
+
+ if (dicData.Count > 0)
+ {
+ // Interpret the values of private dict.
+ foreach (CffDataDicEntry dicEntry in dicData)
+ {
+ switch (dicEntry.Operator.Name)
+ {
+ case "Subrs":
+ int localSubrsOffset = (int)dicEntry.Operands[0].RealNumValue;
+ reader.BaseStream.Position = offset + fdict.PrivateDicOffset + localSubrsOffset;
+ fdict.LocalSubr = ReadSubrBuffer(reader, cff2);
+ break;
+
+ case "vsindex":
+ fdict.VsIndex = (int)dicEntry.Operands[0].RealNumValue;
+ break;
+
+ case "defaultWidthX":
+ case "nominalWidthX":
+ break;
+ }
+ }
+ }
+ }
+
+ return fontDicts;
+ }
+
+ protected CffDataDicEntry ReadEntry(BigEndianBinaryReader reader)
+ {
+ List operands = new();
+
+ //-----------------------------
+ // An operator is preceded by the operand(s) that
+ // specify its value.
+ //--------------------------------
+
+ //-----------------------------
+ // Operators and operands may be distinguished by inspection of
+ // their first byte:
+ // 0–21 specify operators and
+ // 28, 29, 30, and 32–254 specify operands(numbers).
+ // Byte values 22–27, 31, and 255 are reserved.
+
+ // An operator may be preceded by up to a maximum of 48 operands
+ CFFOperator? @operator;
+ while (true)
+ {
+ byte b0 = reader.ReadUInt8();
+
+ if (b0 is >= 0 and <= 24)
+ {
+ // operators
+ @operator = ReadOperator(reader, b0);
+ break; // **break after found operator
+ }
+ else if (b0 is 28 or 29)
+ {
+ int num = ReadIntegerNumber(reader, b0);
+ operands.Add(new CffOperand(num, OperandKind.IntNumber));
+ }
+ else if (b0 == 30)
+ {
+ double num = this.ReadRealNumber(reader);
+ operands.Add(new CffOperand(num, OperandKind.RealNumber));
+ }
+ else if (b0 is >= 32 and <= 254)
+ {
+ int num = ReadIntegerNumber(reader, b0);
+ operands.Add(new CffOperand(num, OperandKind.IntNumber));
+ }
+ else
+ {
+ throw new NotSupportedException("invalid DICT data b0 byte: " + b0);
+ }
+ }
+
+ // I'm fairly confident that the operator can never be null.
+ return new CffDataDicEntry(@operator!, operands.ToArray());
+ }
+
+ protected static bool TryReadIndexDataOffsets(BigEndianBinaryReader reader, [NotNullWhen(true)] out CffIndexOffset[]? value, bool cff2 = false)
+ {
+ // INDEX Data
+ // An INDEX is an array of variable-sized objects.It comprises a
+ // header, an offset array, and object data.
+ // The offset array specifies offsets within the object data.
+ // An object is retrieved by
+ // indexing the offset array and fetching the object at the
+ // specified offset.
+ // The object’s length can be determined by subtracting its offset
+ // from the next offset in the offset array.
+ // An additional offset is added at the end of the offset array so the
+ // length of the last object may be determined.
+ // The INDEX format is shown in Table 7
+
+ // Table 7 INDEX Format
+ // Type Name Description
+ // Card16 count Number of objects stored in INDEX
+ // OffSize offSize Offset array element size
+ // Offset offset[count + 1] Offset array(from byte preceding object data)
+ // Card8 data[] Object data
+
+ // Offsets in the offset array are relative to the byte that precedes
+ // the object data. Therefore the first element of the offset array
+ // is always 1. (This ensures that every object has a corresponding
+ // offset which is always nonzero and permits the efficient
+ // implementation of dynamic object loading.)
+
+ // An empty INDEX is represented by a count field with a 0 value
+ // and no additional fields.Thus, the total size of an empty INDEX
+ // is 2 bytes.
+
+ // Note 2
+ // An INDEX may be skipped by jumping to the offset specified by the last
+ // element of the offset array
+ // CFF2 uses a 32-bit count; CFF1 uses 16-bit.
+ uint count = cff2 ? reader.ReadUInt32() : reader.ReadUInt16();
+ if (count == 0)
+ {
+ value = null;
+ return false;
+ }
+
+ int offSize = reader.ReadByte();
+ int[] offsets = new int[count + 1];
+ CffIndexOffset[] indexElems = new CffIndexOffset[count];
+ for (int i = 0; i <= count; ++i)
+ {
+ offsets[i] = reader.ReadOffset(offSize);
+ }
+
+ for (int i = 0; i < count; ++i)
+ {
+ indexElems[i] = new CffIndexOffset(offsets[i], offsets[i + 1] - offsets[i]);
+ }
+
+ value = indexElems;
+ return true;
+ }
+
+ protected static byte[][] ReadSubrBuffer(BigEndianBinaryReader reader, bool cff2 = false)
+ {
+ if (!TryReadIndexDataOffsets(reader, out CffIndexOffset[]? offsets, cff2))
+ {
+ return [];
+ }
+
+ byte[][] rawBufferList = new byte[offsets.Length][];
+
+ for (int i = 0; i < rawBufferList.Length; ++i)
+ {
+ CffIndexOffset offset = offsets[i];
+ rawBufferList[i] = reader.ReadBytes(offset.Length);
+ }
+
+ return rawBufferList;
+ }
+
+ protected List ReadDictData(BigEndianBinaryReader reader, int length)
+ {
+ // 4. DICT Data
+
+ // Font dictionary data comprising key-value pairs is represented
+ // in a compact tokenized format that is similar to that used to
+ // represent Type 1 charstrings.
+
+ // Dictionary keys are encoded as 1- or 2-byte operators and dictionary values are encoded as
+ // variable-size numeric operands that represent either integer or
+ // real values.
+
+ //-----------------------------
+ // A DICT is simply a sequence of
+ // operand(s)/operator bytes concatenated together.
+ int maxIndex = (int)(reader.BaseStream.Position + length);
+ List dicData = new();
+ while (reader.BaseStream.Position < maxIndex)
+ {
+ CffDataDicEntry dicEntry = this.ReadEntry(reader);
+ dicData.Add(dicEntry);
+ }
+
+ return dicData;
+ }
+
+ private static CFFOperator ReadOperator(BigEndianBinaryReader reader, byte b0)
+ {
+ // Read operator key.
+ byte b1 = 0;
+ if (b0 == 12)
+ {
+ // 2 bytes
+ b1 = reader.ReadUInt8();
+ }
+
+ // Get registered operator by its key.
+ return CFFOperator.GetOperatorByKey(b0, b1);
+ }
+
+ private double ReadRealNumber(BigEndianBinaryReader reader)
+ {
+ // from https://typekit.files.wordpress.com/2013/05/5176.cff.pdf
+ // A real number operand is provided in addition to integer
+ // operands.This operand begins with a byte value of 30 followed
+ // by a variable-length sequence of bytes.Each byte is composed
+ // of two 4 - bit nibbles asdefined in Table 5.
+
+ // The first nibble of a
+ // pair is stored in the most significant 4 bits of a byte and the
+ // second nibble of a pair is stored in the least significant 4 bits of a byte
+ StringBuilder sb = this.pooledStringBuilder;
+ sb.Clear(); // reset
+
+ bool done = false;
+ bool exponentMissing = false;
+ while (!done)
+ {
+ int b = reader.ReadByte();
+
+ int nb_0 = (b >> 4) & 0xf;
+ int nb_1 = b & 0xf;
+
+ for (int i = 0; !done && i < 2; ++i)
+ {
+ int nibble = (i == 0) ? nb_0 : nb_1;
+
+ switch (nibble)
+ {
+ case 0x0:
+ case 0x1:
+ case 0x2:
+ case 0x3:
+ case 0x4:
+ case 0x5:
+ case 0x6:
+ case 0x7:
+ case 0x8:
+ case 0x9:
+ sb.Append(nibble);
+ exponentMissing = false;
+ break;
+ case 0xa:
+ sb.Append('.');
+ break;
+ case 0xb:
+ sb.Append('E');
+ exponentMissing = true;
+ break;
+ case 0xc:
+ sb.Append("E-");
+ exponentMissing = true;
+ break;
+ case 0xd:
+ break;
+ case 0xe:
+ sb.Append('-');
+ break;
+ case 0xf:
+ done = true;
+ break;
+ default:
+ throw new FontException("Unable to read real number.");
+ }
+ }
+ }
+
+ if (exponentMissing)
+ {
+ // the exponent is missing, just append "0" to avoid an exception
+ // not sure if 0 is the correct value, but it seems to fit
+ // see PDFBOX-1522
+ sb.Append('0');
+ }
+
+ if (sb.Length == 0)
+ {
+ return 0d;
+ }
+
+ if (!double.TryParse(
+ sb.ToString(),
+ NumberStyles.Number | NumberStyles.AllowExponent,
+ CultureInfo.InvariantCulture,
+ out double value))
+ {
+ throw new NotSupportedException();
+ }
+
+ return value;
+ }
+
+ private static int ReadIntegerNumber(BigEndianBinaryReader reader, byte b0)
+ {
+ if (b0 == 28)
+ {
+ return reader.ReadInt16();
+ }
+
+ if (b0 == 29)
+ {
+ return reader.ReadInt32();
+ }
+
+ if (b0 is >= 32 and <= 246)
+ {
+ return b0 - 139;
+ }
+
+ if (b0 is >= 247 and <= 250)
+ {
+ int b1 = reader.ReadByte();
+ return ((b0 - 247) * 256) + b1 + 108;
+ }
+
+ if (b0 is >= 251 and <= 254)
+ {
+ int b1 = reader.ReadByte();
+ return (-(b0 - 251) * 256) - b1 - 108;
+ }
+
+ throw new InvalidFontFileException("Invalid DICT data b0 byte: " + b0);
+ }
+}
diff --git a/src/SixLabors.Fonts/Tables/Cff/CffPrivateDictionary.cs b/src/SixLabors.Fonts/Tables/Cff/CffPrivateDictionary.cs
index b5af0e956..ad5f3cc65 100644
--- a/src/SixLabors.Fonts/Tables/Cff/CffPrivateDictionary.cs
+++ b/src/SixLabors.Fonts/Tables/Cff/CffPrivateDictionary.cs
@@ -3,18 +3,31 @@
namespace SixLabors.Fonts.Tables.Cff;
+///
+/// Represents data from a CFF Private DICT, which contains font-level hinting
+/// values and local subroutine references.
+///
internal class CffPrivateDictionary
{
- public CffPrivateDictionary(byte[][] localSubrRawBuffers, int defaultWidthX, int nominalWidthX)
+ public CffPrivateDictionary(byte[][]? localSubrRawBuffers, int defaultWidthX, int nominalWidthX)
{
this.LocalSubrRawBuffers = localSubrRawBuffers;
this.DefaultWidthX = defaultWidthX;
this.NominalWidthX = nominalWidthX;
}
- public byte[][] LocalSubrRawBuffers { get; set; }
+ ///
+ /// Gets or sets the local subroutine raw byte buffers referenced by the Private DICT.
+ ///
+ public byte[][]? LocalSubrRawBuffers { get; set; }
+ ///
+ /// Gets or sets the default width for glyphs that do not specify a width in the charstring.
+ ///
public int DefaultWidthX { get; set; }
+ ///
+ /// Gets or sets the nominal width used as a bias for charstring width values.
+ ///
public int NominalWidthX { get; set; }
}
diff --git a/src/SixLabors.Fonts/Tables/Cff/CffTopDictionary.cs b/src/SixLabors.Fonts/Tables/Cff/CffTopDictionary.cs
index dd6dba73b..8bf3caa9e 100644
--- a/src/SixLabors.Fonts/Tables/Cff/CffTopDictionary.cs
+++ b/src/SixLabors.Fonts/Tables/Cff/CffTopDictionary.cs
@@ -3,27 +3,67 @@
namespace SixLabors.Fonts.Tables.Cff;
+///
+/// Represents the Top DICT data from a CFF or CFF2 font, containing font-wide
+/// metadata such as name strings, bounding box, underline metrics, and the FontMatrix.
+///
internal class CffTopDictionary
{
public CffTopDictionary() => this.CidFontInfo = new();
+ ///
+ /// Gets or sets the font version string (SID).
+ ///
public string? Version { get; set; }
+ ///
+ /// Gets or sets the font notice/trademark string (SID).
+ ///
public string? Notice { get; set; }
+ ///
+ /// Gets or sets the font copyright string (SID).
+ ///
public string? CopyRight { get; set; }
+ ///
+ /// Gets or sets the font full name string (SID).
+ ///
public string? FullName { get; set; }
+ ///
+ /// Gets or sets the font family name string (SID).
+ ///
public string? FamilyName { get; set; }
+ ///
+ /// Gets or sets the font weight string (SID), e.g. "Bold".
+ ///
public string? Weight { get; set; }
+ ///
+ /// Gets or sets the underline position in design units.
+ ///
public double UnderlinePosition { get; set; }
+ ///
+ /// Gets or sets the underline thickness in design units.
+ ///
public double UnderlineThickness { get; set; }
- public double[] FontBBox { get; set; } = Array.Empty();
+ ///
+ /// Gets or sets the font bounding box [xMin, yMin, xMax, yMax] in design units.
+ ///
+ public double[] FontBBox { get; set; } = [];
+ ///
+ /// Gets or sets the font matrix that transforms charstring coordinates to user space.
+ /// Default is [0.001, 0, 0, 0.001, 0, 0] which maps 1000 charstring units to 1 user-space unit.
+ ///
+ public double[] FontMatrix { get; set; } = [0.001, 0, 0, 0.001, 0, 0];
+
+ ///
+ /// Gets or sets the CIDFont-specific information (ROS, FDSelect, FDArray).
+ ///
public CidFontInfo CidFontInfo { get; set; }
}
diff --git a/src/SixLabors.Fonts/Tables/Cff/CidFontInfo.cs b/src/SixLabors.Fonts/Tables/Cff/CidFontInfo.cs
index bb2576319..918ab0286 100644
--- a/src/SixLabors.Fonts/Tables/Cff/CidFontInfo.cs
+++ b/src/SixLabors.Fonts/Tables/Cff/CidFontInfo.cs
@@ -3,28 +3,59 @@
namespace SixLabors.Fonts.Tables.Cff;
+///
+/// Contains CIDFont-specific information from the Top DICT of a CFF CIDFont.
+///
+///
internal class CidFontInfo
{
+ ///
+ /// Gets or sets the CIDFont Registry string from the ROS operator.
+ ///
public string? ROS_Register { get; set; }
+ ///
+ /// Gets or sets the CIDFont Ordering string from the ROS operator.
+ ///
public string? ROS_Ordering { get; set; }
+ ///
+ /// Gets or sets the CIDFont Supplement value from the ROS operator.
+ ///
public string? ROS_Supplement { get; set; }
+ ///
+ /// Gets or sets the CIDFont version number.
+ ///
public double CIDFontVersion { get; set; }
+ ///
+ /// Gets or sets the number of CIDs in the font (CIDCount operator).
+ ///
public int CIDFountCount { get; set; }
+ ///
+ /// Gets or sets the offset to the FDSelect structure that maps glyphs to Font DICTs.
+ ///
public int FDSelect { get; set; }
+ ///
+ /// Gets or sets the offset to the Font DICT (FDArray) INDEX.
+ ///
public int FDArray { get; set; }
+ ///
+ /// Gets or sets the FDSelect format (0, 3, or 4).
+ ///
public int FdSelectFormat { get; set; }
- public FDRange3[] FdRanges { get; set; } = Array.Empty();
+ ///
+ /// Gets or sets the parsed FDSelect ranges for format 3/4.
+ ///
+ public FDRange[] FdRanges { get; set; } = [];
///
- /// Gets or sets the fd select map, which maps glyph # to font #.
+ /// Gets or sets the FDSelect map for format 0, mapping glyph index to Font DICT index.
///
- public Dictionary FdSelectMap { get; set; } = new();
+ public Dictionary FdSelectMap { get; set; } = [];
}
diff --git a/src/SixLabors.Fonts/Tables/Cff/CompactFontTables.cs b/src/SixLabors.Fonts/Tables/Cff/CompactFontTables.cs
index ad3a439f4..09e8b4998 100644
--- a/src/SixLabors.Fonts/Tables/Cff/CompactFontTables.cs
+++ b/src/SixLabors.Fonts/Tables/Cff/CompactFontTables.cs
@@ -2,6 +2,7 @@
// Licensed under the Six Labors Split License.
using SixLabors.Fonts.Tables.AdvancedTypographic;
+using SixLabors.Fonts.Tables.AdvancedTypographic.Variations;
using SixLabors.Fonts.Tables.General;
using SixLabors.Fonts.Tables.General.Colr;
using SixLabors.Fonts.Tables.General.Kern;
@@ -11,6 +12,9 @@
namespace SixLabors.Fonts.Tables.Cff;
+///
+/// Contains the collection of OpenType tables required for fonts with CFF or CFF2 outlines.
+///
internal sealed class CompactFontTables : IFontTables
{
public CompactFontTables(
@@ -67,6 +71,18 @@ public CompactFontTables(
public VerticalMetricsTable? Vmtx { get; set; }
+ public FVarTable? FVar { get; set; }
+
+ public AVarTable? AVar { get; set; }
+
+ public GVarTable? GVar { get; set; }
+
+ public HVarTable? HVar { get; set; }
+
+ public VVarTable? VVar { get; set; }
+
+ public MVarTable? MVar { get; set; }
+
public SvgTable? Svg { get; set; }
// Tables Related to CFF Outlines
diff --git a/src/SixLabors.Fonts/Tables/Cff/FDRange3.cs b/src/SixLabors.Fonts/Tables/Cff/FDRange.cs
similarity index 53%
rename from src/SixLabors.Fonts/Tables/Cff/FDRange3.cs
rename to src/SixLabors.Fonts/Tables/Cff/FDRange.cs
index 22d162156..b5fc1f0d7 100644
--- a/src/SixLabors.Fonts/Tables/Cff/FDRange3.cs
+++ b/src/SixLabors.Fonts/Tables/Cff/FDRange.cs
@@ -6,23 +6,29 @@ namespace SixLabors.Fonts.Tables.Cff;
///
/// Represents an element in an font dictionary array.
///
-internal readonly struct FDRange3
+internal readonly struct FDRange
{
- public FDRange3(ushort first, byte fontDictionary)
+ public FDRange(ushort first, byte fontDictionary)
+ {
+ this.First = first;
+ this.FontDictionary = fontDictionary;
+ }
+
+ public FDRange(uint first, ushort fontDictionary)
{
this.First = first;
this.FontDictionary = fontDictionary;
}
///
- /// Gets the first glyph index in range
+ /// Gets the first glyph index in range.
///
- public ushort First { get; }
+ public uint First { get; }
///
- /// Gets the font dictionary index for all glyphs in range
+ /// Gets the font dictionary index for all glyphs in range.
///
- public byte FontDictionary { get; }
+ public ushort FontDictionary { get; }
public override string ToString() => $"First {this.First}, Dictionary {this.FontDictionary}.";
}
diff --git a/src/SixLabors.Fonts/Tables/Cff/FDRangeProvider.cs b/src/SixLabors.Fonts/Tables/Cff/FDRangeProvider.cs
index 20037afff..1b2adcdbc 100644
--- a/src/SixLabors.Fonts/Tables/Cff/FDRangeProvider.cs
+++ b/src/SixLabors.Fonts/Tables/Cff/FDRangeProvider.cs
@@ -3,15 +3,18 @@
namespace SixLabors.Fonts.Tables.Cff;
+///
+/// Resolves the Font DICT index for a given glyph using the FDSelect data from a CIDFont.
+/// Supports FDSelect format 0 (per-glyph map) and formats 3/4 (range-based).
+///
internal struct FDRangeProvider
{
- // helper class
private readonly int format;
- private readonly FDRange3[] ranges;
+ private readonly FDRange[] ranges;
private readonly Dictionary fdSelectMap;
- private ushort currentGlyphIndex;
- private ushort endGlyphIndexMax;
- private FDRange3 currentRange;
+ private uint currentGlyphIndex;
+ private uint endGlyphIndexMax;
+ private FDRange currentRange;
private int currentSelectedRangeIndex;
public FDRangeProvider(CidFontInfo cidFontInfo)
@@ -37,7 +40,7 @@ public FDRangeProvider(CidFontInfo cidFontInfo)
this.SelectedFDArray = 0;
}
- public byte SelectedFDArray { get; private set; }
+ public ushort SelectedFDArray { get; private set; }
public void SetCurrentGlyphIndex(ushort index)
{
@@ -48,6 +51,7 @@ public void SetCurrentGlyphIndex(ushort index)
break;
case 3:
+ case 4:
// Find proper range for selected index.
if (index >= this.currentRange.First && index < this.endGlyphIndexMax)
{
diff --git a/src/SixLabors.Fonts/Tables/Cff/FontDict.cs b/src/SixLabors.Fonts/Tables/Cff/FontDict.cs
index d636349f8..9c520864d 100644
--- a/src/SixLabors.Fonts/Tables/Cff/FontDict.cs
+++ b/src/SixLabors.Fonts/Tables/Cff/FontDict.cs
@@ -3,6 +3,10 @@
namespace SixLabors.Fonts.Tables.Cff;
+///
+/// Represents a Font DICT entry from the FDArray in a CIDFont.
+/// Each Font DICT contains a reference to its own Private DICT and local subroutines.
+///
internal class FontDict
{
public FontDict(int name, int dictSize, int dictOffset)
@@ -12,11 +16,28 @@ public FontDict(int name, int dictSize, int dictOffset)
this.PrivateDicOffset = dictOffset;
}
+ ///
+ /// Gets or sets the Font DICT name SID.
+ ///
public int FontName { get; set; }
+ ///
+ /// Gets the size in bytes of the associated Private DICT.
+ ///
public int PrivateDicSize { get; }
+ ///
+ /// Gets the offset to the associated Private DICT.
+ ///
public int PrivateDicOffset { get; }
+ ///
+ /// Gets or sets the local subroutine buffers from this Font DICT's Private DICT.
+ ///
public byte[][]? LocalSubr { get; set; }
+
+ ///
+ /// Gets or sets the variation store index (CFF2 vsindex operator) for this Font DICT.
+ ///
+ public int VsIndex { get; set; }
}
diff --git a/src/SixLabors.Fonts/Tables/Cff/GlyphNameMap.cs b/src/SixLabors.Fonts/Tables/Cff/GlyphNameMap.cs
deleted file mode 100644
index 86b7bafd3..000000000
--- a/src/SixLabors.Fonts/Tables/Cff/GlyphNameMap.cs
+++ /dev/null
@@ -1,17 +0,0 @@
-// Copyright (c) Six Labors.
-// Licensed under the Six Labors Split License.
-
-namespace SixLabors.Fonts.Tables.Cff;
-
-internal readonly struct GlyphNameMap
-{
- public readonly ushort GlyphIndex;
-
- public readonly string GlyphName;
-
- public GlyphNameMap(ushort glyphIndex, string glyphName)
- {
- this.GlyphIndex = glyphIndex;
- this.GlyphName = glyphName;
- }
-}
diff --git a/src/SixLabors.Fonts/Tables/Cff/ICffTable.cs b/src/SixLabors.Fonts/Tables/Cff/ICffTable.cs
index cdce0e4d0..0e2a7e454 100644
--- a/src/SixLabors.Fonts/Tables/Cff/ICffTable.cs
+++ b/src/SixLabors.Fonts/Tables/Cff/ICffTable.cs
@@ -1,6 +1,8 @@
// Copyright (c) Six Labors.
// Licensed under the Six Labors Split License.
+using SixLabors.Fonts.Tables.AdvancedTypographic.Variations;
+
namespace SixLabors.Fonts.Tables.Cff;
///
@@ -11,7 +13,16 @@ internal interface ICffTable
///
/// Gets the number of glyphs in the table.
///
- int GlyphCount
+ public int GlyphCount
+ {
+ get;
+ }
+
+ ///
+ /// Gets the item variation store.
+ ///
+ /// The item variation store. If CFF1, there is no variations and null will be returned instead.
+ public ItemVariationStore? ItemVariationStore
{
get;
}
@@ -21,5 +32,5 @@ int GlyphCount
///
/// The glyph index.
/// The .
- CffGlyphData GetGlyph(int index);
+ public CffGlyphData GetGlyph(int index);
}
diff --git a/src/SixLabors.Fonts/Tables/Cff/OperandKind.cs b/src/SixLabors.Fonts/Tables/Cff/OperandKind.cs
index b8d24f041..85f652bb4 100644
--- a/src/SixLabors.Fonts/Tables/Cff/OperandKind.cs
+++ b/src/SixLabors.Fonts/Tables/Cff/OperandKind.cs
@@ -3,8 +3,18 @@
namespace SixLabors.Fonts.Tables.Cff;
+///
+/// Identifies whether a CFF DICT operand was encoded as an integer or a real number.
+///
internal enum OperandKind
{
+ ///
+ /// An integer operand (encoded as 1-5 bytes in the DICT data).
+ ///
IntNumber,
+
+ ///
+ /// A real number operand (encoded as a nibble-based BCD sequence).
+ ///
RealNumber
}
diff --git a/src/SixLabors.Fonts/Tables/Cff/OperatorOperandKind.cs b/src/SixLabors.Fonts/Tables/Cff/OperatorOperandKind.cs
index 6d062ca08..dc70141d6 100644
--- a/src/SixLabors.Fonts/Tables/Cff/OperatorOperandKind.cs
+++ b/src/SixLabors.Fonts/Tables/Cff/OperatorOperandKind.cs
@@ -3,15 +3,45 @@
namespace SixLabors.Fonts.Tables.Cff;
+///
+/// Defines the operand interpretation for a CFF DICT operator.
+/// Used to describe how operands on the DICT stack should be decoded.
+///
+///
internal enum OperatorOperandKind
{
+ ///
+ /// A string identifier referencing the String INDEX.
+ ///
SID,
+
+ ///
+ /// A boolean value (0 or 1).
+ ///
Boolean,
+
+ ///
+ /// A single numeric value (integer or real).
+ ///
Number,
+
+ ///
+ /// An array of numeric values.
+ ///
Array,
+
+ ///
+ /// A delta-encoded array of numeric values.
+ ///
Delta,
- // Compound
+ ///
+ /// Two numeric values (e.g. Private DICT size and offset).
+ ///
NumberNumber,
+
+ ///
+ /// Two SIDs followed by a number (e.g. ROS: Registry, Ordering, Supplement).
+ ///
SID_SID_Number,
}
diff --git a/src/SixLabors.Fonts/Tables/Cff/SimpleBinaryReader.cs b/src/SixLabors.Fonts/Tables/Cff/SimpleBinaryReader.cs
index b05b02fe6..2f93f08ca 100644
--- a/src/SixLabors.Fonts/Tables/Cff/SimpleBinaryReader.cs
+++ b/src/SixLabors.Fonts/Tables/Cff/SimpleBinaryReader.cs
@@ -3,6 +3,10 @@
namespace SixLabors.Fonts.Tables.Cff;
+///
+/// A lightweight big-endian binary reader over a buffer,
+/// used for reading Type 2 charstring data without allocations.
+///
internal ref struct SimpleBinaryReader
{
private readonly ReadOnlySpan buffer;
diff --git a/src/SixLabors.Fonts/Tables/Cff/Type2Operator1.cs b/src/SixLabors.Fonts/Tables/Cff/Type2Operator1.cs
index bd96c03d2..380340983 100644
--- a/src/SixLabors.Fonts/Tables/Cff/Type2Operator1.cs
+++ b/src/SixLabors.Fonts/Tables/Cff/Type2Operator1.cs
@@ -3,9 +3,12 @@
namespace SixLabors.Fonts.Tables.Cff;
+///
+/// Single-byte Type 2 charstring operators (byte values 0-31).
+///
+///
internal enum Type2Operator1 : byte
{
- // Appendix A Type 2 Charstring Command Codes
Reserved0_ = 0,
Hstem, // 1
Reserved2_, // 2
@@ -21,8 +24,8 @@ internal enum Type2Operator1 : byte
Escape, // 12
Reserved13_,
Endchar, // 14
- Reserved15_,
- Reserved16_,
+ VsIndex,
+ Blend,
Reserved17_,
Hstemhm, // 18
Hintmask, // 19
diff --git a/src/SixLabors.Fonts/Tables/Cff/Type2Operator2.cs b/src/SixLabors.Fonts/Tables/Cff/Type2Operator2.cs
index a8760cc5d..1147e6f59 100644
--- a/src/SixLabors.Fonts/Tables/Cff/Type2Operator2.cs
+++ b/src/SixLabors.Fonts/Tables/Cff/Type2Operator2.cs
@@ -3,9 +3,12 @@
namespace SixLabors.Fonts.Tables.Cff;
+///
+/// Two-byte Type 2 charstring operators (preceded by the escape byte 12).
+///
+///
internal enum Type2Operator2 : byte
{
- // Two-byte Type 2 Operators
Reserved0_ = 0,
Reserved1_,
Reserved2_,
diff --git a/src/SixLabors.Fonts/Tables/General/Colr/ClipBox.cs b/src/SixLabors.Fonts/Tables/General/Colr/ClipBox.cs
index 9cb0b60a1..df87bea11 100644
--- a/src/SixLabors.Fonts/Tables/General/Colr/ClipBox.cs
+++ b/src/SixLabors.Fonts/Tables/General/Colr/ClipBox.cs
@@ -1,10 +1,12 @@
// Copyright (c) Six Labors.
// Licensed under the Six Labors Split License.
+using SixLabors.Fonts.Tables.AdvancedTypographic.Variations;
+
namespace SixLabors.Fonts.Tables.General.Colr;
// Abstract ClipBox subtable (format-dispatched).
internal abstract class ClipBox
{
- public abstract Bounds GetBounds(IVariationResolver? varResolver);
+ public abstract Bounds GetBounds(ColrTable colr, GlyphVariationProcessor? processor);
}
diff --git a/src/SixLabors.Fonts/Tables/General/Colr/ClipBoxFormat1.cs b/src/SixLabors.Fonts/Tables/General/Colr/ClipBoxFormat1.cs
index 136c58ee5..6c61a765e 100644
--- a/src/SixLabors.Fonts/Tables/General/Colr/ClipBoxFormat1.cs
+++ b/src/SixLabors.Fonts/Tables/General/Colr/ClipBoxFormat1.cs
@@ -1,6 +1,8 @@
// Copyright (c) Six Labors.
// Licensed under the Six Labors Split License.
+using SixLabors.Fonts.Tables.AdvancedTypographic.Variations;
+
namespace SixLabors.Fonts.Tables.General.Colr;
// Format 1: int16 edges.
@@ -19,6 +21,6 @@ public ClipBoxFormat1(short xMin, short yMin, short xMax, short yMax)
this.yMax = yMax;
}
- public override Bounds GetBounds(IVariationResolver? varResolver)
+ public override Bounds GetBounds(ColrTable colr, GlyphVariationProcessor? processor)
=> new(this.xMin, this.yMin, this.xMax, this.yMax);
}
diff --git a/src/SixLabors.Fonts/Tables/General/Colr/ClipBoxFormat2.cs b/src/SixLabors.Fonts/Tables/General/Colr/ClipBoxFormat2.cs
index 6e341819a..86a7bebac 100644
--- a/src/SixLabors.Fonts/Tables/General/Colr/ClipBoxFormat2.cs
+++ b/src/SixLabors.Fonts/Tables/General/Colr/ClipBoxFormat2.cs
@@ -1,6 +1,8 @@
// Copyright (c) Six Labors.
// Licensed under the Six Labors Split License.
+using SixLabors.Fonts.Tables.AdvancedTypographic.Variations;
+
namespace SixLabors.Fonts.Tables.General.Colr;
// Format 2: int16 edges + varIndex per edge.
@@ -21,12 +23,12 @@ public ClipBoxFormat2(short xMin, short yMin, short xMax, short yMax, uint varIn
this.varIndexBase = varIndexBase;
}
- public override Bounds GetBounds(IVariationResolver? varResolver)
+ public override Bounds GetBounds(ColrTable colr, GlyphVariationProcessor? processor)
{
- float dx0 = varResolver?.ResolveDelta(this.varIndexBase + 0u) ?? 0f;
- float dy0 = varResolver?.ResolveDelta(this.varIndexBase + 1u) ?? 0f;
- float dx1 = varResolver?.ResolveDelta(this.varIndexBase + 2u) ?? 0f;
- float dy1 = varResolver?.ResolveDelta(this.varIndexBase + 3u) ?? 0f;
+ float dx0 = colr.ResolveDelta(processor, this.varIndexBase + 0u);
+ float dy0 = colr.ResolveDelta(processor, this.varIndexBase + 1u);
+ float dx1 = colr.ResolveDelta(processor, this.varIndexBase + 2u);
+ float dy1 = colr.ResolveDelta(processor, this.varIndexBase + 3u);
float xMin = this.xMin + dx0;
float yMin = this.yMin + dy0;
diff --git a/src/SixLabors.Fonts/Tables/General/Colr/ClipList.cs b/src/SixLabors.Fonts/Tables/General/Colr/ClipList.cs
index 019444590..0bc71dbc8 100644
--- a/src/SixLabors.Fonts/Tables/General/Colr/ClipList.cs
+++ b/src/SixLabors.Fonts/Tables/General/Colr/ClipList.cs
@@ -2,6 +2,7 @@
// Licensed under the Six Labors Split License.
using System.Diagnostics.CodeAnalysis;
+using SixLabors.Fonts.Tables.AdvancedTypographic.Variations;
namespace SixLabors.Fonts.Tables.General.Colr;
@@ -74,7 +75,11 @@ public ClipList(ClipRecord[] records, ClipBox?[] boxes)
return new ClipList(records, boxes);
}
- public bool TryGetClipBox(ushort glyphId, IVariationResolver? varResolver, [NotNullWhen(true)] out Bounds? bounds)
+ public bool TryGetClipBox(
+ ushort glyphId,
+ ColrTable colr,
+ GlyphVariationProcessor? processor,
+ [NotNullWhen(true)] out Bounds? bounds)
{
int lo = 0;
int hi = this.Records.Length - 1;
@@ -103,7 +108,7 @@ public bool TryGetClipBox(ushort glyphId, IVariationResolver? varResolver, [NotN
return false;
}
- bounds = box.GetBounds(varResolver);
+ bounds = box.GetBounds(colr, processor);
return true;
}
diff --git a/src/SixLabors.Fonts/Tables/General/Colr/ColrGlyphSourceBase.cs b/src/SixLabors.Fonts/Tables/General/Colr/ColrGlyphSourceBase.cs
index 299bc6748..db4794e61 100644
--- a/src/SixLabors.Fonts/Tables/General/Colr/ColrGlyphSourceBase.cs
+++ b/src/SixLabors.Fonts/Tables/General/Colr/ColrGlyphSourceBase.cs
@@ -4,6 +4,7 @@
using System.Numerics;
using System.Runtime.CompilerServices;
using SixLabors.Fonts.Rendering;
+using SixLabors.Fonts.Tables.AdvancedTypographic.Variations;
using SixLabors.Fonts.Tables.TrueType.Glyphs;
namespace SixLabors.Fonts.Tables.General.Colr;
@@ -56,15 +57,19 @@ public ColrGlyphSourceBase(ColrTable colr, CpalTable? cpal, FuncThe affine matrix in document space.
/// The active composite mode to apply to leaf paints, or null for default.
/// Optional CPAL palette for color resolution.
+ /// The COLR table for variation delta resolution.
+ /// The glyph variation processor, or null for non-variable fonts.
/// Collector for emitted leaf paints.
protected static void FlattenPaint(
Paint node,
Matrix3x2 transform,
CompositeMode mode,
CpalTable? cpal,
+ ColrTable colr,
+ GlyphVariationProcessor? processor,
List outLeaves)
{
- // The input not will only be a paintable leaf here, as upstream resolution
+ // The input node will only be a paintable leaf here, as upstream resolution
// should have eliminated glyph/colr-glyph nodes and flattened composites.
switch (node)
{
@@ -95,6 +100,33 @@ protected static void FlattenPaint(
return;
}
+ case PaintVarSolid pvs:
+ {
+ float alpha = pvs.Alpha + colr.ResolveDelta(processor, pvs.VarIndexBase + 0u);
+
+ if (pvs.PaletteIndex == 0xFFFF)
+ {
+ outLeaves.Add(new SolidPaint
+ {
+ Color = new GlyphColor(0, 0, 0, 0),
+ Opacity = 1F,
+ Transform = transform,
+ CompositeMode = mode
+ });
+ return;
+ }
+
+ GlyphColor color = ResolveColor(cpal, pvs.PaletteIndex, alpha);
+ outLeaves.Add(new SolidPaint
+ {
+ Color = color,
+ Opacity = 1F,
+ Transform = transform,
+ CompositeMode = mode
+ });
+ return;
+ }
+
case PaintLinearGradient pl:
{
GradientStop[] stops = ResolveStops(pl.ColorLine, cpal);
@@ -115,13 +147,14 @@ protected static void FlattenPaint(
case PaintVarLinearGradient vpl:
{
- GradientStop[] stops = ResolveStops(vpl.ColorLine, cpal);
+ uint vib = vpl.VarIndexBase;
+ GradientStop[] stops = ResolveStops(vpl.ColorLine, cpal, colr, processor);
outLeaves.Add(new LinearGradientPaint
{
Units = GradientUnits.UserSpaceOnUse,
- P0 = new Vector2(vpl.X0, vpl.Y0),
- P1 = new Vector2(vpl.X1, vpl.Y1),
- P2 = new Vector2(vpl.X2, vpl.Y2),
+ P0 = new Vector2(vpl.X0 + colr.ResolveDelta(processor, vib + 0u), vpl.Y0 + colr.ResolveDelta(processor, vib + 1u)),
+ P1 = new Vector2(vpl.X1 + colr.ResolveDelta(processor, vib + 2u), vpl.Y1 + colr.ResolveDelta(processor, vib + 3u)),
+ P2 = new Vector2(vpl.X2 + colr.ResolveDelta(processor, vib + 4u), vpl.Y2 + colr.ResolveDelta(processor, vib + 5u)),
Spread = MapSpread(vpl.ColorLine.Extend),
Stops = stops,
Opacity = 1F,
@@ -152,14 +185,15 @@ protected static void FlattenPaint(
case PaintVarRadialGradient vpr:
{
- GradientStop[] stops = ResolveStops(vpr.ColorLine, cpal);
+ uint vib = vpr.VarIndexBase;
+ GradientStop[] stops = ResolveStops(vpr.ColorLine, cpal, colr, processor);
outLeaves.Add(new RadialGradientPaint
{
Units = GradientUnits.UserSpaceOnUse,
- Center0 = new Vector2(vpr.X0, vpr.Y0),
- Radius0 = vpr.Radius0,
- Center1 = new Vector2(vpr.X1, vpr.Y1),
- Radius1 = vpr.Radius1,
+ Center0 = new Vector2(vpr.X0 + colr.ResolveDelta(processor, vib + 0u), vpr.Y0 + colr.ResolveDelta(processor, vib + 1u)),
+ Radius0 = (ushort)(vpr.Radius0 + colr.ResolveDelta(processor, vib + 2u)),
+ Center1 = new Vector2(vpr.X1 + colr.ResolveDelta(processor, vib + 3u), vpr.Y1 + colr.ResolveDelta(processor, vib + 4u)),
+ Radius1 = (ushort)(vpr.Radius1 + colr.ResolveDelta(processor, vib + 5u)),
Spread = MapSpread(vpr.ColorLine.Extend),
Stops = stops,
Opacity = 1F,
@@ -177,7 +211,7 @@ protected static void FlattenPaint(
Units = GradientUnits.UserSpaceOnUse,
Center = new Vector2(sw.CenterX, sw.CenterY),
- // Spec says: add 1.0 and multiply by 180° to retrieve counter-clockwise degrees.
+ // Spec says: add 1.0 and multiply by 180 to retrieve counter-clockwise degrees.
StartAngle = (sw.StartAngle + 1F) * 180F,
EndAngle = (sw.EndAngle + 1F) * 180F,
Spread = MapSpread(sw.ColorLine.Extend),
@@ -191,13 +225,16 @@ protected static void FlattenPaint(
case PaintVarSweepGradient vsw:
{
- GradientStop[] stops = ResolveStops(vsw.ColorLine, cpal);
+ uint vib = vsw.VarIndexBase;
+ GradientStop[] stops = ResolveStops(vsw.ColorLine, cpal, colr, processor);
+ float startAngle = vsw.StartAngle + colr.ResolveDelta(processor, vib + 2u);
+ float endAngle = vsw.EndAngle + colr.ResolveDelta(processor, vib + 3u);
outLeaves.Add(new SweepGradientPaint
{
Units = GradientUnits.UserSpaceOnUse,
- Center = new Vector2(vsw.CenterX, vsw.CenterY),
- StartAngle = (vsw.StartAngle + 1F) * 180F,
- EndAngle = (vsw.EndAngle + 1F) * 180F,
+ Center = new Vector2(vsw.CenterX + colr.ResolveDelta(processor, vib + 0u), vsw.CenterY + colr.ResolveDelta(processor, vib + 1u)),
+ StartAngle = (startAngle + 1F) * 180F,
+ EndAngle = (endAngle + 1F) * 180F,
Spread = MapSpread(vsw.ColorLine.Extend),
Stops = stops,
Opacity = 1F,
@@ -344,14 +381,17 @@ private static GradientStop[] ResolveStops(ColorLine line, CpalTable? cpal)
}
///
- /// Resolves a color line into concrete gradient stops. Offsets are clamped to [0,1].
+ /// Resolves a variable color line into concrete gradient stops with variation deltas applied.
+ /// Offsets are clamped to [0,1].
/// 0xFFFF palette indices are treated as transparent here (foreground color handled by text color elsewhere).
///
- /// The color line.
+ /// The variable color line.
/// The CPAL table, or null if not present.
+ /// The COLR table for delta resolution.
+ /// The glyph variation processor, or null.
/// The resolved gradient stops.
[MethodImpl(MethodImplOptions.AggressiveInlining)]
- private static GradientStop[] ResolveStops(VarColorLine line, CpalTable? cpal)
+ private static GradientStop[] ResolveStops(VarColorLine line, CpalTable? cpal, ColrTable colr, GlyphVariationProcessor? processor)
{
VarColorStop[] src = line.Stops;
GradientStop[] stops = new GradientStop[src.Length];
@@ -360,11 +400,15 @@ private static GradientStop[] ResolveStops(VarColorLine line, CpalTable? cpal)
{
ref readonly VarColorStop s = ref src[i];
+ // Per spec: VarColorStop has varIndexBase with offsets +0 = stopOffset, +1 = alpha.
+ float stopOffset = s.StopOffset + colr.ResolveDelta(processor, s.VarIndexBase + 0u);
+ float alpha = s.Alpha + colr.ResolveDelta(processor, s.VarIndexBase + 1u);
+
GlyphColor c = s.PaletteIndex == 0xFFFF
? new GlyphColor(0, 0, 0, 0) // transparent placeholder; renderer can blend with foreground
- : ResolveColor(cpal, s.PaletteIndex, s.Alpha);
+ : ResolveColor(cpal, s.PaletteIndex, alpha);
- float offset = Math.Clamp(s.StopOffset, 0F, 1F);
+ float offset = Math.Clamp(stopOffset, 0F, 1F);
stops[i] = new GradientStop(offset, c);
}
diff --git a/src/SixLabors.Fonts/Tables/General/Colr/ColrTable.cs b/src/SixLabors.Fonts/Tables/General/Colr/ColrTable.cs
index 3ce87216a..8cb3d2bbb 100644
--- a/src/SixLabors.Fonts/Tables/General/Colr/ColrTable.cs
+++ b/src/SixLabors.Fonts/Tables/General/Colr/ColrTable.cs
@@ -6,6 +6,7 @@
using System.Numerics;
using System.Runtime.CompilerServices;
using SixLabors.Fonts.Rendering;
+using SixLabors.Fonts.Tables.AdvancedTypographic.Variations;
namespace SixLabors.Fonts.Tables.General.Colr;
@@ -22,13 +23,17 @@ internal class ColrTable : Table
private readonly LayerList? layerList;
private readonly ClipList? clipList;
+ // Variation data (nullable if not present)
+ private readonly ItemVariationStore? itemVariationStore;
+ private readonly DeltaSetIndexMap[]? deltaSetIndexMap;
+
// Caches (offset -> resolved object)
private readonly Dictionary? paintCache;
public ColrTable(
BaseGlyphRecord[] glyphRecords,
LayerRecord[] layers)
- : this(glyphRecords, layers, null, null, null, null, 0)
+ : this(glyphRecords, layers, null, null, null, null, null, null, 0)
{
}
@@ -38,6 +43,8 @@ public ColrTable(
BaseGlyphList? baseGlyphList,
LayerList? layerList,
ClipList? clipList,
+ ItemVariationStore? itemVariationStore,
+ DeltaSetIndexMap[]? deltaSetIndexMap,
Dictionary? paintCache = null,
int version = 1)
{
@@ -46,12 +53,46 @@ public ColrTable(
this.baseGlyphList = baseGlyphList;
this.layerList = layerList;
this.clipList = clipList;
+ this.itemVariationStore = itemVariationStore;
+ this.deltaSetIndexMap = deltaSetIndexMap;
this.paintCache = paintCache;
this.Version = version;
}
public int Version { get; }
+ ///
+ /// Resolves a variation delta for a given variable index using the COLR table's
+ /// own ItemVariationStore and optional DeltaSetIndexMap.
+ ///
+ /// The glyph variation processor (null for non-variable fonts).
+ /// The variable index (VarIndexBase + field offset).
+ /// The delta value, or 0 if no variation data is available.
+ internal float ResolveDelta(GlyphVariationProcessor? processor, uint varIdx)
+ {
+ if (processor is null || this.itemVariationStore is null)
+ {
+ return 0;
+ }
+
+ int outer;
+ int inner;
+ if (this.deltaSetIndexMap is not null && varIdx < (uint)this.deltaSetIndexMap.Length)
+ {
+ DeltaSetIndexMap mapping = this.deltaSetIndexMap[varIdx];
+ outer = mapping.OuterIndex;
+ inner = mapping.InnerIndex;
+ }
+ else
+ {
+ // Implicit mapping: upper 16 bits = outer, lower 16 bits = inner.
+ outer = (int)(varIdx >> 16);
+ inner = (int)(varIdx & 0xFFFF);
+ }
+
+ return processor.Delta(this.itemVariationStore, outer, inner);
+ }
+
public static ColrTable? Load(FontReader fontReader)
{
if (!fontReader.TryGetReaderAtTablePosition(TableName, out BigEndianBinaryReader? binaryReader))
@@ -148,13 +189,17 @@ internal bool TryGetColrV0Layers(ushort glyph, out Span records)
/// Attempts to resolve and retrieve the list of color glyph layers for the specified glyph ID.
///
/// The identifier of the glyph for which to resolve color layers.
+ /// The glyph variation processor, or null for non-variable fonts.
///
/// When this method returns, contains a list of resolved glyph layers if the operation succeeds; otherwise,
/// . This parameter is passed uninitialized.
///
/// if the color glyph layers were successfully resolved; otherwise, .
///
- internal bool TryGetColrV1Layers(ushort glyphId, [NotNullWhen(true)] out List? layers)
+ internal bool TryGetColrV1Layers(
+ ushort glyphId,
+ GlyphVariationProcessor? processor,
+ [NotNullWhen(true)] out List? layers)
{
layers = null;
@@ -176,7 +221,7 @@ internal bool TryGetColrV1Layers(ushort glyphId, [NotNullWhen(true)] out List acc = [];
- this.FlattenPaintToLayers(root, null, Matrix3x2.Identity, CompositeMode.SrcOver, acc);
+ this.FlattenPaintToLayers(root, null, Matrix3x2.Identity, CompositeMode.SrcOver, processor, acc);
// 3) If nothing emitted, the graph did not bind any geometry (no PaintGlyph/ColrGlyph reached).
if (acc.Count == 0)
@@ -206,12 +251,14 @@ internal bool TryGetColrV1Layers(ushort glyphId, [NotNullWhen(true)] out List
/// Accumulated transform.
/// Accumulated composite mode.
+ /// The glyph variation processor, or null for non-variable fonts.
/// Accumulator for resolved layers.
private void FlattenPaintToLayers(
Paint node,
ushort? currentGlyphId,
Matrix3x2 transform,
CompositeMode compositeMode,
+ GlyphVariationProcessor? processor,
List outLayers)
{
switch (node)
@@ -237,7 +284,7 @@ private void FlattenPaintToLayers(
if (this.paintCache!.TryGetValue(off, out Paint? child) && child is not null)
{
- this.FlattenPaintToLayers(child, currentGlyphId, transform, compositeMode, outLayers);
+ this.FlattenPaintToLayers(child, currentGlyphId, transform, compositeMode, processor, outLayers);
}
}
@@ -250,7 +297,7 @@ private void FlattenPaintToLayers(
if (this.TryGetRootPaintOffset(pcg.GlyphId, out uint off) && off != 0
&& this.paintCache!.TryGetValue(off, out Paint? colrRoot) && colrRoot is not null)
{
- this.FlattenPaintToLayers(colrRoot, pcg.GlyphId, transform, compositeMode, outLayers);
+ this.FlattenPaintToLayers(colrRoot, pcg.GlyphId, transform, compositeMode, processor, outLayers);
}
return;
@@ -259,7 +306,7 @@ private void FlattenPaintToLayers(
case PaintGlyph pg:
{
// Bind geometry to the specified glyph id and recurse into its child paint.
- this.FlattenPaintToLayers(pg.Child, pg.GlyphId, transform, compositeMode, outLayers);
+ this.FlattenPaintToLayers(pg.Child, pg.GlyphId, transform, compositeMode, processor, outLayers);
return;
}
@@ -268,71 +315,97 @@ private void FlattenPaintToLayers(
// ---------------------------
case PaintTransform pt:
{
- transform *= ToMatrix(pt.Transform);
- this.FlattenPaintToLayers(pt.Child, currentGlyphId, transform, compositeMode, outLayers);
+ Affine2x3 a = pt.Transform;
+ transform *= new Matrix3x2(a.Xx, a.Yx, a.Xy, a.Yy, a.Dx, a.Dy);
+ this.FlattenPaintToLayers(pt.Child, currentGlyphId, transform, compositeMode, processor, outLayers);
return;
}
case PaintVarTransform pvt:
{
- transform *= ToMatrix(pvt.Transform);
- this.FlattenPaintToLayers(pvt.Child, currentGlyphId, transform, compositeMode, outLayers);
+ VarAffine2x3 a = pvt.Transform;
+ uint vib = a.VarIndexBase;
+ float xx = a.Xx + this.ResolveDelta(processor, vib + 0u);
+ float yx = a.Yx + this.ResolveDelta(processor, vib + 1u);
+ float xy = a.Xy + this.ResolveDelta(processor, vib + 2u);
+ float yy = a.Yy + this.ResolveDelta(processor, vib + 3u);
+ float dx = a.Dx + this.ResolveDelta(processor, vib + 4u);
+ float dy = a.Dy + this.ResolveDelta(processor, vib + 5u);
+ transform *= new Matrix3x2(xx, yx, xy, yy, dx, dy);
+ this.FlattenPaintToLayers(pvt.Child, currentGlyphId, transform, compositeMode, processor, outLayers);
return;
}
case PaintTranslate t:
{
transform *= Matrix3x2.CreateTranslation(t.Dx, t.Dy);
- this.FlattenPaintToLayers(t.Child, currentGlyphId, transform, compositeMode, outLayers);
+ this.FlattenPaintToLayers(t.Child, currentGlyphId, transform, compositeMode, processor, outLayers);
return;
}
case PaintVarTranslate vt:
{
- transform *= Matrix3x2.CreateTranslation(vt.Dx, vt.Dy);
- this.FlattenPaintToLayers(vt.Child, currentGlyphId, transform, compositeMode, outLayers);
+ float dx = vt.Dx + this.ResolveDelta(processor, vt.VarIndexBase + 0u);
+ float dy = vt.Dy + this.ResolveDelta(processor, vt.VarIndexBase + 1u);
+ transform *= Matrix3x2.CreateTranslation(dx, dy);
+ this.FlattenPaintToLayers(vt.Child, currentGlyphId, transform, compositeMode, processor, outLayers);
return;
}
case PaintScale s:
{
transform *= BuildScale(s.ScaleX, s.ScaleY, s.AroundCenter, s.CenterX, s.CenterY);
- this.FlattenPaintToLayers(s.Child, currentGlyphId, transform, compositeMode, outLayers);
+ this.FlattenPaintToLayers(s.Child, currentGlyphId, transform, compositeMode, processor, outLayers);
return;
}
case PaintVarScale vs:
{
- transform *= BuildScale(vs.ScaleX, vs.ScaleY, vs.AroundCenter, vs.CenterX, vs.CenterY);
- this.FlattenPaintToLayers(vs.Child, currentGlyphId, transform, compositeMode, outLayers);
+ uint vib = vs.VarIndexBase;
+ float sx = vs.ScaleX + this.ResolveDelta(processor, vib + 0u);
+ float sy = vs.Uniform ? sx : vs.ScaleY + this.ResolveDelta(processor, vib + 1u);
+ int centerOffset = vs.Uniform ? 1 : 2;
+ float cx = vs.AroundCenter ? vs.CenterX + this.ResolveDelta(processor, vib + (uint)centerOffset) : 0;
+ float cy = vs.AroundCenter ? vs.CenterY + this.ResolveDelta(processor, vib + (uint)centerOffset + 1u) : 0;
+ transform *= BuildScale(sx, sy, vs.AroundCenter, cx, cy);
+ this.FlattenPaintToLayers(vs.Child, currentGlyphId, transform, compositeMode, processor, outLayers);
return;
}
case PaintRotate r:
{
transform *= BuildRotate(r.Angle, r.AroundCenter, r.CenterX, r.CenterY);
- this.FlattenPaintToLayers(r.Child, currentGlyphId, transform, compositeMode, outLayers);
+ this.FlattenPaintToLayers(r.Child, currentGlyphId, transform, compositeMode, processor, outLayers);
return;
}
case PaintVarRotate vr:
{
- transform *= BuildRotate(vr.Angle, vr.AroundCenter, vr.CenterX, vr.CenterY);
- this.FlattenPaintToLayers(vr.Child, currentGlyphId, transform, compositeMode, outLayers);
+ uint vib = vr.VarIndexBase;
+ float angle = vr.Angle + this.ResolveDelta(processor, vib + 0u);
+ float cx = vr.AroundCenter ? vr.CenterX + this.ResolveDelta(processor, vib + 1u) : 0;
+ float cy = vr.AroundCenter ? vr.CenterY + this.ResolveDelta(processor, vib + 2u) : 0;
+ transform *= BuildRotate(angle, vr.AroundCenter, cx, cy);
+ this.FlattenPaintToLayers(vr.Child, currentGlyphId, transform, compositeMode, processor, outLayers);
return;
}
case PaintSkew k:
{
transform *= BuildSkew(k.XSkew, k.YSkew, k.AroundCenter, k.CenterX, k.CenterY);
- this.FlattenPaintToLayers(k.Child, currentGlyphId, transform, compositeMode, outLayers);
+ this.FlattenPaintToLayers(k.Child, currentGlyphId, transform, compositeMode, processor, outLayers);
return;
}
case PaintVarSkew vk:
{
- transform *= BuildSkew(vk.XSkew, vk.YSkew, vk.AroundCenter, vk.CenterX, vk.CenterY);
- this.FlattenPaintToLayers(vk.Child, currentGlyphId, transform, compositeMode, outLayers);
+ uint vib = vk.VarIndexBase;
+ float xSkew = vk.XSkew + this.ResolveDelta(processor, vib + 0u);
+ float ySkew = vk.YSkew + this.ResolveDelta(processor, vib + 1u);
+ float cx = vk.AroundCenter ? vk.CenterX + this.ResolveDelta(processor, vib + 2u) : 0;
+ float cy = vk.AroundCenter ? vk.CenterY + this.ResolveDelta(processor, vib + 3u) : 0;
+ transform *= BuildSkew(xSkew, ySkew, vk.AroundCenter, cx, cy);
+ this.FlattenPaintToLayers(vk.Child, currentGlyphId, transform, compositeMode, processor, outLayers);
return;
}
@@ -341,8 +414,8 @@ private void FlattenPaintToLayers(
compositeMode = MapCompositeMode(comp.CompositeMode);
// Backdrop first, then Source. Both inherit the current glyph id.
- this.FlattenPaintToLayers(comp.Backdrop, currentGlyphId, transform, compositeMode, outLayers);
- this.FlattenPaintToLayers(comp.Source, currentGlyphId, transform, compositeMode, outLayers);
+ this.FlattenPaintToLayers(comp.Backdrop, currentGlyphId, transform, compositeMode, processor, outLayers);
+ this.FlattenPaintToLayers(comp.Source, currentGlyphId, transform, compositeMode, processor, outLayers);
return;
}
@@ -350,6 +423,7 @@ private void FlattenPaintToLayers(
// Leaves: emit only if bound
// ---------------------------
case PaintSolid:
+ case PaintVarSolid:
case PaintLinearGradient:
case PaintVarLinearGradient:
case PaintRadialGradient:
@@ -360,7 +434,7 @@ private void FlattenPaintToLayers(
// Only emit if we have an active glyph id (i.e., we are inside a PaintGlyph/ColrGlyph branch).
if (currentGlyphId.HasValue)
{
- _ = this.TryGetClipBox(currentGlyphId.Value, out Bounds? clip);
+ _ = this.TryGetClipBox(currentGlyphId.Value, processor, out Bounds? clip);
outLayers.Add(new ResolvedGlyphLayer(currentGlyphId.Value, node, transform, compositeMode, clip));
}
@@ -375,42 +449,6 @@ private void FlattenPaintToLayers(
}
}
- ///
- /// Maps an optional fixed 2×3 affine to .
- /// Layout:
- /// [ xx xy dx ]
- /// [ yx yy dy ]
- ///
- [MethodImpl(MethodImplOptions.AggressiveInlining)]
- private static Matrix3x2 ToMatrix(Affine2x3? affine)
- {
- if (affine.HasValue)
- {
- Affine2x3 a = affine.Value;
- return new Matrix3x2(a.Xx, a.Yx, a.Xy, a.Yy, a.Dx, a.Dy); // (M11, M12, M21, M22, M31, M32)
- }
-
- return Matrix3x2.Identity;
- }
-
- ///
- /// Maps an optional variable 2×3 affine to .
- /// Layout:
- /// [ xx xy dx ]
- /// [ yx yy dy ]
- ///
- [MethodImpl(MethodImplOptions.AggressiveInlining)]
- private static Matrix3x2 ToMatrix(VarAffine2x3? varAffine)
- {
- if (varAffine.HasValue)
- {
- VarAffine2x3 v = varAffine.Value;
- return new Matrix3x2(v.Xx, v.Yx, v.Xy, v.Yy, v.Dx, v.Dy);
- }
-
- return Matrix3x2.Identity;
- }
-
///
/// Builds a scale matrix, optionally around a center.
///
@@ -571,7 +609,7 @@ private ReadOnlySpan GetLayerPaintOffsets(int first, int count)
return offsets.Slice(first, len);
}
- private bool TryGetClipBox(ushort glyphId, out Bounds? bounds)
+ private bool TryGetClipBox(ushort glyphId, GlyphVariationProcessor? processor, out Bounds? bounds)
{
if (this.clipList is null)
{
@@ -579,8 +617,7 @@ private bool TryGetClipBox(ushort glyphId, out Bounds? bounds)
return false;
}
- // TODO: support variation resolver
- return this.clipList.TryGetClipBox(glyphId, null, out bounds);
+ return this.clipList.TryGetClipBox(glyphId, this, processor, out bounds);
}
public static ColrTable Load(BigEndianBinaryReader reader)
@@ -669,14 +706,18 @@ public static ColrTable Load(BigEndianBinaryReader reader)
layerList = LayerList.Load(reader, layerListOffset);
clipList = ClipList.Load(reader, clipListOffset);
- // varIndexMapOffset / itemVariationStoreOffset are parsed elsewhere if/when needed.
- _ = varIndexMapOffset;
- _ = itemVariationStoreOffset;
-
paintCache = LoadPaintRoots(reader, baseGlyphList, layerList);
}
- return new ColrTable(glyphs, layerRecs, baseGlyphList, layerList, clipList, paintCache, 1);
+ ItemVariationStore? itemVariationStore = itemVariationStoreOffset != 0
+ ? ItemVariationStore.Load(reader, itemVariationStoreOffset)
+ : null;
+
+ DeltaSetIndexMap[]? deltaSetIndexMap = varIndexMapOffset != 0
+ ? DeltaSetIndexMap.Load(reader, varIndexMapOffset)
+ : null;
+
+ return new ColrTable(glyphs, layerRecs, baseGlyphList, layerList, clipList, itemVariationStore, deltaSetIndexMap, paintCache, 1);
}
private static Dictionary LoadPaintRoots(
diff --git a/src/SixLabors.Fonts/Tables/General/Colr/ColrV0GlyphSource.cs b/src/SixLabors.Fonts/Tables/General/Colr/ColrV0GlyphSource.cs
index 3e64261c6..d6301a4a9 100644
--- a/src/SixLabors.Fonts/Tables/General/Colr/ColrV0GlyphSource.cs
+++ b/src/SixLabors.Fonts/Tables/General/Colr/ColrV0GlyphSource.cs
@@ -14,7 +14,7 @@ namespace SixLabors.Fonts.Tables.General.Colr;
///
internal sealed class ColrV0GlyphSource : ColrGlyphSourceBase
{
- private static readonly ConcurrentDictionary CachedGlyphs = [];
+ private readonly ConcurrentDictionary cachedGlyphs = [];
///
/// Initializes a new instance of the class.
@@ -30,7 +30,7 @@ public ColrV0GlyphSource(ColrTable colr, CpalTable? cpal, Func
public override bool TryGetPaintedGlyph(ushort glyphId, out PaintedGlyph glyph, out PaintedCanvasMetadata canvas)
{
- (PaintedGlyph Glyph, PaintedCanvasMetadata Canvas) result = CachedGlyphs.GetOrAdd(glyphId, id =>
+ (PaintedGlyph Glyph, PaintedCanvasMetadata Canvas) result = this.cachedGlyphs.GetOrAdd(glyphId, id =>
{
if (this.Colr.TryGetColrV0Layers(id, out Span resolved))
{
@@ -50,7 +50,7 @@ public override bool TryGetPaintedGlyph(ushort glyphId, out PaintedGlyph glyph,
// Flatten paint graph: attach composite mode to leaves.
List leafPaints = [];
PaintSolid paint = new() { PaletteIndex = rl.PaletteIndex, Alpha = 1, Format = 2 };
- FlattenPaint(paint, Matrix3x2.Identity, CompositeMode.SrcOver, this.Cpal, leafPaints);
+ FlattenPaint(paint, Matrix3x2.Identity, CompositeMode.SrcOver, this.Cpal, this.Colr, null, leafPaints);
// Emit one layer per leaf paint.
for (int p = 0; p < leafPaints.Count; p++)
diff --git a/src/SixLabors.Fonts/Tables/General/Colr/ColrV1GlyphSource.cs b/src/SixLabors.Fonts/Tables/General/Colr/ColrV1GlyphSource.cs
index 385c7ea20..006f62c84 100644
--- a/src/SixLabors.Fonts/Tables/General/Colr/ColrV1GlyphSource.cs
+++ b/src/SixLabors.Fonts/Tables/General/Colr/ColrV1GlyphSource.cs
@@ -4,6 +4,7 @@
using System.Collections.Concurrent;
using System.Numerics;
using SixLabors.Fonts.Rendering;
+using SixLabors.Fonts.Tables.AdvancedTypographic.Variations;
using SixLabors.Fonts.Tables.TrueType.Glyphs;
namespace SixLabors.Fonts.Tables.General.Colr;
@@ -14,7 +15,9 @@ namespace SixLabors.Fonts.Tables.General.Colr;
///
internal sealed class ColrV1GlyphSource : ColrGlyphSourceBase
{
- private static readonly ConcurrentDictionary CachedGlyphs = [];
+ private readonly ConcurrentDictionary cachedGlyphs = [];
+
+ private readonly GlyphVariationProcessor? processor;
///
/// Initializes a new instance of the class.
@@ -22,17 +25,17 @@ internal sealed class ColrV1GlyphSource : ColrGlyphSourceBase
/// The COLR table.
/// The CPAL table, or null if not present.
/// Delegate that loads a glyph outline for the given glyph id.
- public ColrV1GlyphSource(ColrTable colr, CpalTable? cpal, Func glyphLoader)
+ /// The glyph variation processor for variable fonts, or null.
+ public ColrV1GlyphSource(ColrTable colr, CpalTable? cpal, Func glyphLoader, GlyphVariationProcessor? processor = null)
: base(colr, cpal, glyphLoader)
- {
- }
+ => this.processor = processor;
///
public override bool TryGetPaintedGlyph(ushort glyphId, out PaintedGlyph glyph, out PaintedCanvasMetadata canvas)
{
- (PaintedGlyph Glyph, PaintedCanvasMetadata Canvas) result = CachedGlyphs.GetOrAdd(glyphId, _ =>
+ (PaintedGlyph Glyph, PaintedCanvasMetadata Canvas) result = this.cachedGlyphs.GetOrAdd(glyphId, _ =>
{
- if (this.Colr.TryGetColrV1Layers(glyphId, out List? resolved))
+ if (this.Colr.TryGetColrV1Layers(glyphId, this.processor, out List? resolved))
{
List layers = new(resolved.Count);
for (int i = 0; i < resolved.Count; i++)
@@ -49,7 +52,7 @@ public override bool TryGetPaintedGlyph(ushort glyphId, out PaintedGlyph glyph,
// Flatten paint graph: accumulate wrapper transforms; attach composite mode to leaves.
List leafPaints = [];
- FlattenPaint(rl.Paint, rl.Transform, rl.CompositeMode, this.Cpal, leafPaints);
+ FlattenPaint(rl.Paint, rl.Transform, rl.CompositeMode, this.Cpal, this.Colr, this.processor, leafPaints);
// Emit one layer per leaf paint.
Bounds? clip = rl.ClipBox;
diff --git a/src/SixLabors.Fonts/Tables/General/Colr/IVariationResolver.cs b/src/SixLabors.Fonts/Tables/General/Colr/IVariationResolver.cs
deleted file mode 100644
index 7abdb679d..000000000
--- a/src/SixLabors.Fonts/Tables/General/Colr/IVariationResolver.cs
+++ /dev/null
@@ -1,18 +0,0 @@
-// Copyright (c) Six Labors.
-// Licensed under the Six Labors Split License.
-
-#pragma warning disable SA1201 // Elements should appear in the correct order
-namespace SixLabors.Fonts.Tables.General.Colr;
-
-///
-/// Provides a mechanism to resolve variation index deltas.
-///
-internal interface IVariationResolver
-{
- ///
- /// Calculates the resolved delta value for the specified variable index.
- ///
- /// The zero-based index of the variable for which to resolve the delta value.
- /// The resolved delta value as a floating-point number for the specified variable index.
- public float ResolveDelta(uint varIndex);
-}
diff --git a/src/SixLabors.Fonts/Tables/General/HeadTable.cs b/src/SixLabors.Fonts/Tables/General/HeadTable.cs
index 178643c6c..b87237a85 100644
--- a/src/SixLabors.Fonts/Tables/General/HeadTable.cs
+++ b/src/SixLabors.Fonts/Tables/General/HeadTable.cs
@@ -1,6 +1,8 @@
// Copyright (c) Six Labors.
// Licensed under the Six Labors Split License.
+using System;
+
namespace SixLabors.Fonts.Tables.General;
internal class HeadTable : Table
@@ -149,7 +151,7 @@ public static HeadTable Load(BigEndianBinaryReader reader)
// Bit 5: Condensed(if set to 1)
// Bit 6: Extended(if set to 1)
// Bits 7–15: Reserved(set to 0).
- // uint16 |lowestRecPPEM | Smallest readable size in pixels.
+ // uint16 | lowestRecPPEM | Smallest readable size in pixels.
// int16 | fontDirectionHint | Deprecated(Set to 2).
// 0: Fully mixed directional glyphs;
// 1: Only strongly left to right;
diff --git a/src/SixLabors.Fonts/Tables/General/Svg/SvgGlyphSource.cs b/src/SixLabors.Fonts/Tables/General/Svg/SvgGlyphSource.cs
index 8e6c6e08b..f60218339 100644
--- a/src/SixLabors.Fonts/Tables/General/Svg/SvgGlyphSource.cs
+++ b/src/SixLabors.Fonts/Tables/General/Svg/SvgGlyphSource.cs
@@ -21,8 +21,8 @@ namespace SixLabors.Fonts.Tables.General.Svg;
internal sealed class SvgGlyphSource : IPaintedGlyphSource
{
private readonly SvgTable svgTable;
- private static readonly Dictionary DocCache = [];
- private static readonly ConcurrentDictionary CachedGlyphs = [];
+ private readonly Dictionary docCache = [];
+ private readonly ConcurrentDictionary cachedGlyphs = [];
private sealed class ParsedDoc
{
@@ -40,7 +40,7 @@ private sealed class ParsedDoc
///
public bool TryGetPaintedGlyph(ushort glyphId, out PaintedGlyph glyph, out PaintedCanvasMetadata canvas)
{
- (PaintedGlyph Glyph, PaintedCanvasMetadata Canvas) result = CachedGlyphs.GetOrAdd(glyphId, gid =>
+ (PaintedGlyph Glyph, PaintedCanvasMetadata Canvas) result = this.cachedGlyphs.GetOrAdd(glyphId, gid =>
{
if (this.TryGetParsedDoc(gid, out ParsedDoc? parsed))
{
@@ -88,7 +88,7 @@ private bool TryGetParsedDoc(ushort glyphId, [NotNullWhen(true)] out ParsedDoc?
return false;
}
- if (DocCache.TryGetValue(glyphId, out parsed))
+ if (this.docCache.TryGetValue(glyphId, out parsed))
{
return true;
}
@@ -124,7 +124,7 @@ private bool TryGetParsedDoc(ushort glyphId, [NotNullWhen(true)] out ParsedDoc?
IdMap = idMap
};
- DocCache[glyphId] = parsed;
+ this.docCache[glyphId] = parsed;
return true;
}
}
diff --git a/src/SixLabors.Fonts/Tables/TableLoader.cs b/src/SixLabors.Fonts/Tables/TableLoader.cs
index ac36429b7..e7ce32bdd 100644
--- a/src/SixLabors.Fonts/Tables/TableLoader.cs
+++ b/src/SixLabors.Fonts/Tables/TableLoader.cs
@@ -2,6 +2,7 @@
// Licensed under the Six Labors Split License.
using SixLabors.Fonts.Tables.AdvancedTypographic;
+using SixLabors.Fonts.Tables.AdvancedTypographic.Variations;
using SixLabors.Fonts.Tables.Cff;
using SixLabors.Fonts.Tables.General;
using SixLabors.Fonts.Tables.General.Colr;
@@ -17,9 +18,9 @@ namespace SixLabors.Fonts.Tables;
internal class TableLoader
{
- private readonly Dictionary> loaders = new();
- private readonly Dictionary types = new();
- private readonly Dictionary> typesLoaders = new();
+ private readonly Dictionary> loaders = [];
+ private readonly Dictionary types = [];
+ private readonly Dictionary> typesLoaders = [];
public TableLoader()
{
@@ -47,6 +48,13 @@ public TableLoader()
this.Register(PostTable.TableName, PostTable.Load);
this.Register(Cff1Table.TableName, Cff1Table.Load);
this.Register(Cff2Table.TableName, Cff2Table.Load);
+ this.Register(AVarTable.TableName, AVarTable.Load);
+ this.Register(GVarTable.TableName, GVarTable.Load);
+ this.Register(FVarTable.TableName, FVarTable.Load);
+ this.Register(HVarTable.TableName, HVarTable.Load);
+ this.Register(VVarTable.TableName, VVarTable.Load);
+ this.Register(MVarTable.TableName, MVarTable.Load);
+ this.Register(CVarTable.TableName, _ => null);
this.Register(SvgTable.TableName, SvgTable.Load);
}
diff --git a/src/SixLabors.Fonts/Tables/TrueType/Glyphs/CompositeComponent.cs b/src/SixLabors.Fonts/Tables/TrueType/Glyphs/CompositeComponent.cs
new file mode 100644
index 000000000..5fe7d9c0c
--- /dev/null
+++ b/src/SixLabors.Fonts/Tables/TrueType/Glyphs/CompositeComponent.cs
@@ -0,0 +1,35 @@
+// Copyright (c) Six Labors.
+// Licensed under the Six Labors Split License.
+
+namespace SixLabors.Fonts.Tables.TrueType.Glyphs;
+
+///
+/// Stores the original component offset and point count for a single component
+/// within a composite glyph. Used during gvar variation processing to apply
+/// per-component offset deltas to the assembled outline.
+///
+internal readonly struct CompositeComponent
+{
+ public CompositeComponent(float dx, float dy, int pointCount)
+ {
+ this.Dx = dx;
+ this.Dy = dy;
+ this.PointCount = pointCount;
+ }
+
+ ///
+ /// Gets the original X offset of this component (before variation).
+ ///
+ public float Dx { get; }
+
+ ///
+ /// Gets the original Y offset of this component (before variation).
+ ///
+ public float Dy { get; }
+
+ ///
+ /// Gets the number of control points contributed by this component
+ /// to the assembled composite glyph.
+ ///
+ public int PointCount { get; }
+}
diff --git a/src/SixLabors.Fonts/Tables/TrueType/Glyphs/CompositeGlyphLoader.cs b/src/SixLabors.Fonts/Tables/TrueType/Glyphs/CompositeGlyphLoader.cs
index 8285f5523..54e033d92 100644
--- a/src/SixLabors.Fonts/Tables/TrueType/Glyphs/CompositeGlyphLoader.cs
+++ b/src/SixLabors.Fonts/Tables/TrueType/Glyphs/CompositeGlyphLoader.cs
@@ -22,6 +22,7 @@ public override GlyphVector CreateGlyph(GlyphTable table)
{
List controlPoints = [];
List endPoints = [];
+ CompositeComponent[] components = new CompositeComponent[this.composites.Length];
for (int i = 0; i < this.composites.Length; i++)
{
Composite composite = this.composites[i];
@@ -29,6 +30,12 @@ public override GlyphVector CreateGlyph(GlyphTable table)
GlyphVector.TransformInPlace(ref clone, composite.Transformation);
ushort endPointOffset = (ushort)controlPoints.Count;
+ // Store original component offset and point count for gvar processing.
+ components[i] = new CompositeComponent(
+ composite.Transformation.Translation.X,
+ composite.Transformation.Translation.Y,
+ clone.ControlPoints.Count);
+
controlPoints.AddRange(clone.ControlPoints);
foreach (ushort p in clone.EndPoints)
{
@@ -36,7 +43,10 @@ public override GlyphVector CreateGlyph(GlyphTable table)
}
}
- return new(controlPoints, endPoints, this.bounds, this.instructions, true);
+ return new(controlPoints, endPoints, this.bounds, this.instructions, true)
+ {
+ CompositeComponents = components
+ };
}
public static CompositeGlyphLoader LoadCompositeGlyph(BigEndianBinaryReader reader, in Bounds bounds)
diff --git a/src/SixLabors.Fonts/Tables/TrueType/Glyphs/GlyphLoader.cs b/src/SixLabors.Fonts/Tables/TrueType/Glyphs/GlyphLoader.cs
index 3175284c4..001b23ad8 100644
--- a/src/SixLabors.Fonts/Tables/TrueType/Glyphs/GlyphLoader.cs
+++ b/src/SixLabors.Fonts/Tables/TrueType/Glyphs/GlyphLoader.cs
@@ -16,9 +16,7 @@ public static GlyphLoader Load(BigEndianBinaryReader reader)
{
return SimpleGlyphLoader.LoadSimpleGlyph(reader, contoursCount, bounds);
}
- else
- {
- return CompositeGlyphLoader.LoadCompositeGlyph(reader, bounds);
- }
+
+ return CompositeGlyphLoader.LoadCompositeGlyph(reader, bounds);
}
}
diff --git a/src/SixLabors.Fonts/Tables/TrueType/Glyphs/GlyphTable.cs b/src/SixLabors.Fonts/Tables/TrueType/Glyphs/GlyphTable.cs
index 0ac0ec355..779d2c527 100644
--- a/src/SixLabors.Fonts/Tables/TrueType/Glyphs/GlyphTable.cs
+++ b/src/SixLabors.Fonts/Tables/TrueType/Glyphs/GlyphTable.cs
@@ -43,7 +43,11 @@ public static GlyphTable Load(FontReader reader)
return Load(binaryReader, reader.TableFormat, locations, in fallbackEmptyBounds);
}
- public static GlyphTable Load(BigEndianBinaryReader reader, TableFormat format, uint[] locations, in Bounds fallbackEmptyBounds)
+ public static GlyphTable Load(
+ BigEndianBinaryReader reader,
+ TableFormat format,
+ uint[] locations,
+ in Bounds fallbackEmptyBounds)
{
EmptyGlyphLoader empty = new(fallbackEmptyBounds);
int entryCount = locations.Length;
diff --git a/src/SixLabors.Fonts/Tables/TrueType/Glyphs/GlyphVector.cs b/src/SixLabors.Fonts/Tables/TrueType/Glyphs/GlyphVector.cs
index efca7c72e..e53316c69 100644
--- a/src/SixLabors.Fonts/Tables/TrueType/Glyphs/GlyphVector.cs
+++ b/src/SixLabors.Fonts/Tables/TrueType/Glyphs/GlyphVector.cs
@@ -36,6 +36,15 @@ internal GlyphVector(
public Bounds Bounds { get; set; }
+ ///
+ /// Gets or sets the composite component information used for gvar variation processing.
+ /// Each entry stores the original component offset and the number of control points
+ /// contributed by that component, so that TransformPoints can apply per-component
+ /// offset deltas to the assembled outline.
+ /// Null for simple (non-composite) glyphs.
+ ///
+ public CompositeComponent[]? CompositeComponents { get; set; }
+
public static GlyphVector Empty(Bounds bounds = default)
=> new(Array.Empty(), Array.Empty(), bounds, Array.Empty(), false);
@@ -111,7 +120,12 @@ public static GlyphVector DeepClone(GlyphVector src)
List controlPoints = [.. src.ControlPoints];
List endPoints = [.. src.EndPoints];
- return new(controlPoints, endPoints, src.Bounds, src.Instructions, src.IsComposite);
+ return new(controlPoints, endPoints, src.Bounds, src.Instructions, src.IsComposite)
+ {
+ CompositeComponents = src.CompositeComponents is not null
+ ? [.. src.CompositeComponents]
+ : null
+ };
}
///
diff --git a/src/SixLabors.Fonts/Tables/TrueType/Glyphs/SimpleGlyphLoader.cs b/src/SixLabors.Fonts/Tables/TrueType/Glyphs/SimpleGlyphLoader.cs
index 3031155c2..bb8a13059 100644
--- a/src/SixLabors.Fonts/Tables/TrueType/Glyphs/SimpleGlyphLoader.cs
+++ b/src/SixLabors.Fonts/Tables/TrueType/Glyphs/SimpleGlyphLoader.cs
@@ -1,10 +1,15 @@
// Copyright (c) Six Labors.
// Licensed under the Six Labors Split License.
+using System;
using System.Numerics;
namespace SixLabors.Fonts.Tables.TrueType.Glyphs;
+///
+/// Implements loading Simple Glyph Description which is part of the `glyph`table.
+///
+///
internal class SimpleGlyphLoader : GlyphLoader
{
private readonly ControlPoint[] controlPoints;
@@ -31,12 +36,46 @@ public SimpleGlyphLoader(Bounds bounds)
[Flags]
private enum Flags : byte
{
+ ///
+ /// The point is is off the curve.
+ ///
ControlPoint = 0,
+
+ ///
+ /// The point is on the curve.
+ ///
OnCurve = 1,
+
+ ///
+ /// If set, the corresponding x-coordinate is 1 byte long. If not set, 2 bytes.
+ ///
XByte = 2,
+
+ ///
+ /// If set, the corresponding y-coordinate is 1 byte long. If not set, 2 bytes.
+ ///
YByte = 4,
+
+ ///
+ /// f set, the next byte specifies the number of additional times this set of flags is to be repeated.
+ /// In this way, the number of flags listed can be smaller than the number of points in a character.
+ ///
Repeat = 8,
+
+ ///
+ /// This flag has two meanings, depending on how the x-Short Vector flag is set.
+ /// If x-Short Vector is set, this bit describes the sign of the value, with 1 equalling positive and 0 negative.
+ /// If the x-Short Vector bit is not set and this bit is set, then the current x-coordinate is the same as the previous x-coordinate.
+ /// If the x-Short Vector bit is not set and this bit is also not set, the current x-coordinate is a signed 16-bit delta vector.
+ ///
XSignOrSame = 16,
+
+ ///
+ /// This flag has two meanings, depending on how the y-Short Vector flag is set.
+ /// If y-Short Vector is set, this bit describes the sign of the value, with 1 equalling positive and 0 negative.
+ /// If the y-Short Vector bit is not set and this bit is set, then the current y-coordinate is the same as the previous y-coordinate.
+ /// If the y-Short Vector bit is not set and this bit is also not set, the current y-coordinate is a signed 16-bit delta vector.
+ ///
YSignOrSame = 32
}
@@ -50,12 +89,25 @@ public static GlyphLoader LoadSimpleGlyph(BigEndianBinaryReader reader, short co
return new SimpleGlyphLoader(bounds);
}
- // uint16 | endPtsOfContours[n] | Array of last points of each contour; n is the number of contours.
- // uint16 | instructionLength | Total number of bytes for instructions.
- // uint8 | instructions[n] | Array of instructions for each glyph; n is the number of instructions.
- // uint8 | flags[n] | Array of flags for each coordinate in outline; n is the number of flags.
- // uint8 or int16 | xCoordinates[ ] | First coordinates relative to(0, 0); others are relative to previous point.
- // uint8 or int16 | yCoordinates[] | First coordinates relative to (0, 0); others are relative to previous point.
+ // +-----------------+----------------------------------------+--------------------------------------------------------------------+
+ // | Type | Name | Description |
+ // +=================+========================================+====================================================================+
+ // | uint16 | endPtsOfContours[n] | Array of last points of each contour; n is the number of contours. |
+ // +-----------------+----------------------------------------+--------------------------------------------------------------------+
+ // | uint16 | instructionLength | Total number of bytes for instructions. |
+ // +-----------------+----------------------------------------+--------------------------------------------------------------------+
+ // | uint8 | instructions[n] | Array of instructions for each glyph; |
+ // | | | n is the number of instructions. |
+ // +-----------------+----------------------------------------+--------------------------------------------------------------------+
+ // | uint8 | flags[n] | Array of flags for each coordinate in outline; |
+ // | | | n is the number of flags. |
+ // +-----------------+----------------------------------------+--------------------------------------------------------------------+
+ // | uint8 or int16 | xCoordinates[] | First coordinates relative to(0, 0); |
+ // | | | others are relative to previous point. |
+ // +-----------------+----------------------------------------+--------------------------------------------------------------------+
+ // | uint8 or int16 | yCoordinates[] | First coordinates relative to (0, 0); |
+ // | | | others are relative to previous point. |
+ // +-----------------+----------------------------------------+--------------------------------------------------------------------+
ushort[] endPoints = reader.ReadUInt16Array(count);
ushort instructionSize = reader.ReadUInt16();
diff --git a/src/SixLabors.Fonts/Tables/TrueType/TrueTypeFontTables.cs b/src/SixLabors.Fonts/Tables/TrueType/TrueTypeFontTables.cs
index ef458b816..1f55983b6 100644
--- a/src/SixLabors.Fonts/Tables/TrueType/TrueTypeFontTables.cs
+++ b/src/SixLabors.Fonts/Tables/TrueType/TrueTypeFontTables.cs
@@ -2,6 +2,7 @@
// Licensed under the Six Labors Split License.
using SixLabors.Fonts.Tables.AdvancedTypographic;
+using SixLabors.Fonts.Tables.AdvancedTypographic.Variations;
using SixLabors.Fonts.Tables.General;
using SixLabors.Fonts.Tables.General.Colr;
using SixLabors.Fonts.Tables.General.Kern;
@@ -98,4 +99,18 @@ public TrueTypeFontTables(
public IndexLocationTable Loca { get; set; }
public PrepTable? Prep { get; set; }
+
+ public FVarTable? Fvar { get; set; }
+
+ public AVarTable? Avar { get; set; }
+
+ public GVarTable? Gvar { get; set; }
+
+ public HVarTable? Hvar { get; set; }
+
+ public VVarTable? Vvar { get; set; }
+
+ public MVarTable? Mvar { get; set; }
+
+ public CVarTable? Cvar { get; set; }
}
diff --git a/tests/Images/ReferenceOutput/VisualTest_AdobeVFPrototype_GVar_WeightVariations_-Black--900.png b/tests/Images/ReferenceOutput/VisualTest_AdobeVFPrototype_GVar_WeightVariations_-Black--900.png
new file mode 100644
index 000000000..3b841027d
--- /dev/null
+++ b/tests/Images/ReferenceOutput/VisualTest_AdobeVFPrototype_GVar_WeightVariations_-Black--900.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:d8c74f578b04fad68daa6b567b9006558c13ee883e9505b2b388e32727603e24
+size 4429
diff --git a/tests/Images/ReferenceOutput/VisualTest_AdobeVFPrototype_GVar_WeightVariations_-Light--200.png b/tests/Images/ReferenceOutput/VisualTest_AdobeVFPrototype_GVar_WeightVariations_-Light--200.png
new file mode 100644
index 000000000..af762d56d
--- /dev/null
+++ b/tests/Images/ReferenceOutput/VisualTest_AdobeVFPrototype_GVar_WeightVariations_-Light--200.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:2ce9acdb1a8e3a583e2e9223beeb8f3d59eec33e76e9f5db0e7b2ab39fe9e9be
+size 4136
diff --git a/tests/Images/ReferenceOutput/VisualTest_NotoEmoji_WeightVariations_-Bold--700.png b/tests/Images/ReferenceOutput/VisualTest_NotoEmoji_WeightVariations_-Bold--700.png
new file mode 100644
index 000000000..d4ca0a2d7
--- /dev/null
+++ b/tests/Images/ReferenceOutput/VisualTest_NotoEmoji_WeightVariations_-Bold--700.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:4e5f6b9ece6cda272ddbcb3d7acc1172931d10f417920ce24afe64230fed7cd0
+size 6911
diff --git a/tests/Images/ReferenceOutput/VisualTest_NotoEmoji_WeightVariations_-Light--300.png b/tests/Images/ReferenceOutput/VisualTest_NotoEmoji_WeightVariations_-Light--300.png
new file mode 100644
index 000000000..57fbfe27a
--- /dev/null
+++ b/tests/Images/ReferenceOutput/VisualTest_NotoEmoji_WeightVariations_-Light--300.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:f12793e2c74169bd5dec6c8feaf01eb489e6177481152a298c069d205c031901
+size 5627
diff --git a/tests/Images/ReferenceOutput/VisualTest_NotoEmoji_WeightVariations_-Regular--400.png b/tests/Images/ReferenceOutput/VisualTest_NotoEmoji_WeightVariations_-Regular--400.png
new file mode 100644
index 000000000..241727e9e
--- /dev/null
+++ b/tests/Images/ReferenceOutput/VisualTest_NotoEmoji_WeightVariations_-Regular--400.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:bcb5f62a3d098c7f433dde15f516a7836b93d429cc1eec816047bd508600770b
+size 6554
diff --git a/tests/Images/ReferenceOutput/VisualTest_RobotoFlex_MultipleAxes-.png b/tests/Images/ReferenceOutput/VisualTest_RobotoFlex_MultipleAxes-.png
new file mode 100644
index 000000000..7420cef73
--- /dev/null
+++ b/tests/Images/ReferenceOutput/VisualTest_RobotoFlex_MultipleAxes-.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:55238a70d48adfbc4bb85519c9d9a43aae72c4e5cc6778207af237a3c9c0ce61
+size 5653
diff --git a/tests/Images/ReferenceOutput/VisualTest_RobotoFlex_WeightVariations_-Bold--700.png b/tests/Images/ReferenceOutput/VisualTest_RobotoFlex_WeightVariations_-Bold--700.png
new file mode 100644
index 000000000..81f54081d
--- /dev/null
+++ b/tests/Images/ReferenceOutput/VisualTest_RobotoFlex_WeightVariations_-Bold--700.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:bc04e4221a45d4b8bb0661e55cfb256e7d2f47c3fd6617aa9bf53ccf90cc62c2
+size 9753
diff --git a/tests/Images/ReferenceOutput/VisualTest_RobotoFlex_WeightVariations_-Heavy--1000.png b/tests/Images/ReferenceOutput/VisualTest_RobotoFlex_WeightVariations_-Heavy--1000.png
new file mode 100644
index 000000000..0c9bae2f3
--- /dev/null
+++ b/tests/Images/ReferenceOutput/VisualTest_RobotoFlex_WeightVariations_-Heavy--1000.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:365d3205cede04a1f2fa7b63da3e5e4fd8cbba8bbff28431f87d26a3206fa1e1
+size 9699
diff --git a/tests/Images/ReferenceOutput/VisualTest_RobotoFlex_WeightVariations_-Regular--400.png b/tests/Images/ReferenceOutput/VisualTest_RobotoFlex_WeightVariations_-Regular--400.png
new file mode 100644
index 000000000..51fa0e07e
--- /dev/null
+++ b/tests/Images/ReferenceOutput/VisualTest_RobotoFlex_WeightVariations_-Regular--400.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:5f514b8d62d1e530bfd2209dacebaeab52770edc89704fb8208b0720d3230303
+size 9911
diff --git a/tests/Images/ReferenceOutput/VisualTest_RobotoFlex_WeightVariations_-Thin--100.png b/tests/Images/ReferenceOutput/VisualTest_RobotoFlex_WeightVariations_-Thin--100.png
new file mode 100644
index 000000000..a48083053
--- /dev/null
+++ b/tests/Images/ReferenceOutput/VisualTest_RobotoFlex_WeightVariations_-Thin--100.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:db1c60a4340efba804b7b3796ceebf579b12014b99dcad006ec0922f24732ba9
+size 9248
diff --git a/tests/Images/ReferenceOutput/VisualTest_VotoSerif_CVar_WeightVariations_-Heavy--194-None.png b/tests/Images/ReferenceOutput/VisualTest_VotoSerif_CVar_WeightVariations_-Heavy--194-None.png
new file mode 100644
index 000000000..ce25bde40
--- /dev/null
+++ b/tests/Images/ReferenceOutput/VisualTest_VotoSerif_CVar_WeightVariations_-Heavy--194-None.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:afacbc6b658b621c6d4dd338e09545eeac9d35975c71c6675714e6c7e1d822f3
+size 2367
diff --git a/tests/Images/ReferenceOutput/VisualTest_VotoSerif_CVar_WeightVariations_-Heavy--194-Standard.png b/tests/Images/ReferenceOutput/VisualTest_VotoSerif_CVar_WeightVariations_-Heavy--194-Standard.png
new file mode 100644
index 000000000..ce25bde40
--- /dev/null
+++ b/tests/Images/ReferenceOutput/VisualTest_VotoSerif_CVar_WeightVariations_-Heavy--194-Standard.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:afacbc6b658b621c6d4dd338e09545eeac9d35975c71c6675714e6c7e1d822f3
+size 2367
diff --git a/tests/Images/ReferenceOutput/VisualTest_VotoSerif_CVar_WeightVariations_-Light--28-None.png b/tests/Images/ReferenceOutput/VisualTest_VotoSerif_CVar_WeightVariations_-Light--28-None.png
new file mode 100644
index 000000000..ba410c269
--- /dev/null
+++ b/tests/Images/ReferenceOutput/VisualTest_VotoSerif_CVar_WeightVariations_-Light--28-None.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:f5c9a4219e0b49b547e9470e3b610bc4e1abe54139f1e5197097b26c1c1461bc
+size 2168
diff --git a/tests/Images/ReferenceOutput/VisualTest_VotoSerif_CVar_WeightVariations_-Light--28-Standard.png b/tests/Images/ReferenceOutput/VisualTest_VotoSerif_CVar_WeightVariations_-Light--28-Standard.png
new file mode 100644
index 000000000..ba410c269
--- /dev/null
+++ b/tests/Images/ReferenceOutput/VisualTest_VotoSerif_CVar_WeightVariations_-Light--28-Standard.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:f5c9a4219e0b49b547e9470e3b610bc4e1abe54139f1e5197097b26c1c1461bc
+size 2168
diff --git a/tests/Images/ReferenceOutput/VisualTest_VotoSerif_CVar_WeightVariations_-Regular--94-None.png b/tests/Images/ReferenceOutput/VisualTest_VotoSerif_CVar_WeightVariations_-Regular--94-None.png
new file mode 100644
index 000000000..960e9f745
--- /dev/null
+++ b/tests/Images/ReferenceOutput/VisualTest_VotoSerif_CVar_WeightVariations_-Regular--94-None.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:17ba57973e3d238940f4596a624be2d3c84ccd83cb43aeeb96f7c96c6829a5e2
+size 2378
diff --git a/tests/Images/ReferenceOutput/VisualTest_VotoSerif_CVar_WeightVariations_-Regular--94-Standard.png b/tests/Images/ReferenceOutput/VisualTest_VotoSerif_CVar_WeightVariations_-Regular--94-Standard.png
new file mode 100644
index 000000000..960e9f745
--- /dev/null
+++ b/tests/Images/ReferenceOutput/VisualTest_VotoSerif_CVar_WeightVariations_-Regular--94-Standard.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:17ba57973e3d238940f4596a624be2d3c84ccd83cb43aeeb96f7c96c6829a5e2
+size 2378
diff --git a/tests/SixLabors.Fonts.Tests/Fonts/AdobeVFPrototype-Subset.otf b/tests/SixLabors.Fonts.Tests/Fonts/AdobeVFPrototype-Subset.otf
new file mode 100644
index 000000000..5cc7279fc
Binary files /dev/null and b/tests/SixLabors.Fonts.Tests/Fonts/AdobeVFPrototype-Subset.otf differ
diff --git a/tests/SixLabors.Fonts.Tests/Fonts/AdobeVFPrototype.ttf b/tests/SixLabors.Fonts.Tests/Fonts/AdobeVFPrototype.ttf
new file mode 100644
index 000000000..5a1c1f599
Binary files /dev/null and b/tests/SixLabors.Fonts.Tests/Fonts/AdobeVFPrototype.ttf differ
diff --git a/tests/SixLabors.Fonts.Tests/Fonts/Mada-VF.ttf b/tests/SixLabors.Fonts.Tests/Fonts/Mada-VF.ttf
new file mode 100644
index 000000000..e6f44045c
Binary files /dev/null and b/tests/SixLabors.Fonts.Tests/Fonts/Mada-VF.ttf differ
diff --git a/tests/SixLabors.Fonts.Tests/Fonts/NotoEmoji-VariableFont_wght.ttf b/tests/SixLabors.Fonts.Tests/Fonts/NotoEmoji-VariableFont_wght.ttf
new file mode 100644
index 000000000..4c7d78e4f
Binary files /dev/null and b/tests/SixLabors.Fonts.Tests/Fonts/NotoEmoji-VariableFont_wght.ttf differ
diff --git a/tests/SixLabors.Fonts.Tests/Fonts/RobotoFlex.ttf b/tests/SixLabors.Fonts.Tests/Fonts/RobotoFlex.ttf
new file mode 100644
index 000000000..f7cda4783
Binary files /dev/null and b/tests/SixLabors.Fonts.Tests/Fonts/RobotoFlex.ttf differ
diff --git a/tests/SixLabors.Fonts.Tests/Fonts/TestGVARFour.ttf b/tests/SixLabors.Fonts.Tests/Fonts/TestGVARFour.ttf
new file mode 100644
index 000000000..3524f3741
Binary files /dev/null and b/tests/SixLabors.Fonts.Tests/Fonts/TestGVARFour.ttf differ
diff --git a/tests/SixLabors.Fonts.Tests/Fonts/TestGVAROne.ttf b/tests/SixLabors.Fonts.Tests/Fonts/TestGVAROne.ttf
new file mode 100644
index 000000000..17a481b6f
Binary files /dev/null and b/tests/SixLabors.Fonts.Tests/Fonts/TestGVAROne.ttf differ
diff --git a/tests/SixLabors.Fonts.Tests/Fonts/TestGVARThree.ttf b/tests/SixLabors.Fonts.Tests/Fonts/TestGVARThree.ttf
new file mode 100644
index 000000000..17f5013dc
Binary files /dev/null and b/tests/SixLabors.Fonts.Tests/Fonts/TestGVARThree.ttf differ
diff --git a/tests/SixLabors.Fonts.Tests/Fonts/TestGVARTwo.ttf b/tests/SixLabors.Fonts.Tests/Fonts/TestGVARTwo.ttf
new file mode 100644
index 000000000..6061a166d
Binary files /dev/null and b/tests/SixLabors.Fonts.Tests/Fonts/TestGVARTwo.ttf differ
diff --git a/tests/SixLabors.Fonts.Tests/Fonts/TestHVARTwo.ttf b/tests/SixLabors.Fonts.Tests/Fonts/TestHVARTwo.ttf
new file mode 100644
index 000000000..2e81f94cb
Binary files /dev/null and b/tests/SixLabors.Fonts.Tests/Fonts/TestHVARTwo.ttf differ
diff --git a/tests/SixLabors.Fonts.Tests/Fonts/VotoSerifGX-IUP-gvar-cvar.ttf b/tests/SixLabors.Fonts.Tests/Fonts/VotoSerifGX-IUP-gvar-cvar.ttf
new file mode 100644
index 000000000..e5231bb2f
Binary files /dev/null and b/tests/SixLabors.Fonts.Tests/Fonts/VotoSerifGX-IUP-gvar-cvar.ttf differ
diff --git a/tests/SixLabors.Fonts.Tests/Fonts/VotoSerifGX-IUP-gvar-cvar_noshared.ttf b/tests/SixLabors.Fonts.Tests/Fonts/VotoSerifGX-IUP-gvar-cvar_noshared.ttf
new file mode 100644
index 000000000..61c954602
Binary files /dev/null and b/tests/SixLabors.Fonts.Tests/Fonts/VotoSerifGX-IUP-gvar-cvar_noshared.ttf differ
diff --git a/tests/SixLabors.Fonts.Tests/Tables/Variations/VariationFontTests.cs b/tests/SixLabors.Fonts.Tests/Tables/Variations/VariationFontTests.cs
new file mode 100644
index 000000000..c2d9458ae
--- /dev/null
+++ b/tests/SixLabors.Fonts.Tests/Tables/Variations/VariationFontTests.cs
@@ -0,0 +1,765 @@
+// Copyright (c) Six Labors.
+// Licensed under the Six Labors Split License.
+
+using SixLabors.Fonts.Rendering;
+using SixLabors.Fonts.Tables.AdvancedTypographic.Variations;
+using SixLabors.Fonts.Unicode;
+
+namespace SixLabors.Fonts.Tests.Tables.Variations;
+
+///
+/// Tests for variable font support including gvar, HVAR, CFF2 blend, and MVAR.
+/// Test cases ported from fontkit (https://github.com/foliojs/fontkit).
+///
+public class VariationFontTests
+{
+ [Fact]
+ public void FontVariation_ValidTag_CreatesInstance()
+ {
+ FontVariation variation = new("wght", 700);
+ Assert.Equal("wght", variation.Tag);
+ Assert.Equal(700, variation.Value);
+ }
+
+ [Fact]
+ public void FontVariation_InvalidTagLength_ThrowsArgumentException()
+ {
+ Assert.Throws(() => new FontVariation("wg", 700));
+ Assert.Throws(() => new FontVariation("weight", 700));
+ }
+
+ [Fact]
+ public void FontVariation_NullTag_ThrowsArgumentException()
+ => Assert.ThrowsAny(() => new FontVariation(null!, 700));
+
+ [Fact]
+ public void CanCreateFontWithVariations()
+ {
+ FontFamily family = new FontCollection().Add(TestFonts.RobotoFlex);
+ Font baseFont = family.CreateFont(12);
+
+ Font variedFont = new(baseFont, new FontVariation("wght", 700));
+
+ Assert.Single(variedFont.Variations.ToArray());
+ Assert.Equal("wght", variedFont.Variations[0].Tag);
+ Assert.Equal(700, variedFont.Variations[0].Value);
+ }
+
+ [Fact]
+ public void CanCreateFontWithVariationsViaFontFamily()
+ {
+ FontFamily family = new FontCollection().Add(TestFonts.RobotoFlex);
+ Font variedFont = family.CreateFont(12, new FontVariation("wght", 700));
+
+ Assert.Single(variedFont.Variations.ToArray());
+ }
+
+ [Fact]
+ public void BaseFontHasEmptyVariations()
+ {
+ FontFamily family = new FontCollection().Add(TestFonts.RobotoFlex);
+ Font baseFont = family.CreateFont(12);
+
+ Assert.True(baseFont.Variations.IsEmpty);
+ }
+
+ [Fact]
+ public void VariationsDoNotAffectNonVariableFont()
+ {
+ // OpenSans is not a variable font; variations should be silently ignored.
+ FontFamily family = new FontCollection().Add(TestFonts.OpenSansFile);
+ Font baseFont = family.CreateFont(12);
+ Font variedFont = new(baseFont, new FontVariation("wght", 700));
+
+ // Both should resolve to the same metrics since it's not variable.
+ Assert.Equal(baseFont.FontMetrics.UnitsPerEm, variedFont.FontMetrics.UnitsPerEm);
+ }
+
+ [Fact]
+ public void CanLoadVariationAxes_RobotoFlex()
+ {
+ FontFamily family = new FontCollection().Add(TestFonts.RobotoFlex);
+ Font font = family.CreateFont(12);
+
+ Assert.True(font.FontMetrics.TryGetVariationAxes(out VariationAxis[]? axes));
+ Assert.Equal(13, axes!.Length);
+
+ Assert.Equal("wght", axes[0].Tag);
+ Assert.Equal(100, axes[0].Min);
+ Assert.Equal(1000, axes[0].Max);
+ Assert.Equal(400, axes[0].Default);
+ }
+
+ [Fact]
+ public void CanLoadVariationAxes_AdobeVFPrototype()
+ {
+ FontFamily family = new FontCollection().Add(TestFonts.AdobeVFPrototype);
+ Font font = family.CreateFont(12);
+
+ Assert.True(font.FontMetrics.TryGetVariationAxes(out VariationAxis[]? axes));
+ Assert.Equal(2, axes!.Length);
+
+ Assert.Equal("wght", axes[0].Tag);
+ Assert.Equal(200, axes[0].Min);
+ Assert.Equal(900, axes[0].Max);
+
+ Assert.Equal("CNTR", axes[1].Tag);
+ }
+
+ [Fact]
+ public void GVar_VariedGlyphDiffersFromDefault()
+ {
+ // Verify that applying a weight variation actually changes glyph outlines.
+ FontFamily family = new FontCollection().Add(TestFonts.TestGVAROne);
+ Font defaultFont = family.CreateFont(12);
+ Font variedFont = family.CreateFont(12, new FontVariation("wght", 300));
+
+ // Get glyph metrics for '彌' at default and varied weights.
+ CodePoint cp = new('彌');
+
+ Assert.True(defaultFont.TryGetGlyphs(cp, out Glyph? defaultGlyph));
+ Assert.True(variedFont.TryGetGlyphs(cp, out Glyph? variedGlyph));
+
+ // The bounds should differ between default and weight=300.
+ Assert.NotEqual(
+ defaultGlyph.Value.GlyphMetrics.Bounds,
+ variedGlyph.Value.GlyphMetrics.Bounds);
+ }
+
+ [Theory]
+ [InlineData("TestGVAROne")]
+ [InlineData("TestGVARTwo")]
+ [InlineData("TestGVARThree")]
+ public void GVar_AllPointShareModes_ProduceSameResult(string fontName)
+ {
+ // fontkit tests: all three TestGVAR fonts should produce identical results
+ // at wght=300 for "彌" — they differ only in how points are shared in gvar.
+ string fontPath = fontName switch
+ {
+ "TestGVAROne" => TestFonts.TestGVAROne,
+ "TestGVARTwo" => TestFonts.TestGVARTwo,
+ "TestGVARThree" => TestFonts.TestGVARThree,
+ _ => throw new ArgumentException(fontName)
+ };
+
+ FontFamily family = new FontCollection().Add(fontPath);
+ Font variedFont = family.CreateFont(12, new FontVariation("wght", 300));
+
+ CodePoint cp = new('彌');
+ Assert.True(variedFont.TryGetGlyphs(cp, out Glyph? glyph));
+
+ // All three fonts should produce glyph metrics at this variation.
+ Assert.NotNull(glyph);
+ }
+
+ [Fact]
+ public void GVar_AllThreeFontsProduceIdenticalBounds()
+ {
+ // All three TestGVAR fonts encode the same variation data differently.
+ // They should produce identical glyph bounds at the same axis value.
+ Font font1 = new FontCollection().Add(TestFonts.TestGVAROne).CreateFont(12, new FontVariation("wght", 300));
+ Font font2 = new FontCollection().Add(TestFonts.TestGVARTwo).CreateFont(12, new FontVariation("wght", 300));
+ Font font3 = new FontCollection().Add(TestFonts.TestGVARThree).CreateFont(12, new FontVariation("wght", 300));
+
+ CodePoint cp = new('彌');
+ font1.TryGetGlyphs(cp, out Glyph? g1);
+ font2.TryGetGlyphs(cp, out Glyph? g2);
+ font3.TryGetGlyphs(cp, out Glyph? g3);
+
+ // Bounds should be identical across all three encoding modes.
+ Assert.Equal(g1.Value.GlyphMetrics.Width, g2.Value.GlyphMetrics.Width);
+ Assert.Equal(g1.Value.GlyphMetrics.Width, g3.Value.GlyphMetrics.Width);
+ Assert.Equal(g1.Value.GlyphMetrics.Height, g2.Value.GlyphMetrics.Height);
+ Assert.Equal(g1.Value.GlyphMetrics.Height, g3.Value.GlyphMetrics.Height);
+ }
+
+ [Fact]
+ public void HVAR_AdvanceWidthVariesWithWeight()
+ {
+ // fontkit: TestGVARFour at wght=150, glyph 'O' should have advanceWidth=706
+ FontFamily family = new FontCollection().Add(TestFonts.TestGVARFour);
+ Font variedFont = family.CreateFont(12, new FontVariation("wght", 150));
+
+ CodePoint cp = new('O');
+ Assert.True(variedFont.TryGetGlyphs(cp, out Glyph? glyph));
+
+ Assert.Equal(706, glyph.Value.GlyphMetrics.AdvanceWidth);
+ }
+
+ [Fact]
+ public void HVAR_FallsBackToLastEntry()
+ {
+ // fontkit: TestHVARTwo at wght=400, glyph 'A' should have advanceWidth=584
+ FontFamily family = new FontCollection().Add(TestFonts.TestHVARTwo);
+ Font variedFont = family.CreateFont(12, new FontVariation("wght", 400));
+
+ CodePoint cp = new('A');
+ Assert.True(variedFont.TryGetGlyphs(cp, out Glyph? glyph));
+
+ Assert.Equal(584, glyph.Value.GlyphMetrics.AdvanceWidth);
+ }
+
+ [Fact]
+ public void HVAR_DefaultWeightPreservesOriginalWidth()
+ {
+ // fontkit: TestGVARFour at default wght (1000), glyph 'O' advanceWidth=700
+ // At default axis value the HVAR delta should be zero.
+ FontFamily family = new FontCollection().Add(TestFonts.TestGVARFour);
+ Font defaultFont = family.CreateFont(12);
+
+ Assert.True(defaultFont.FontMetrics.TryGetVariationAxes(out VariationAxis[]? axes));
+ VariationAxis wghtAxis = Assert.Single(axes!, a => a.Tag == "wght");
+
+ Font variedFont = family.CreateFont(12, new FontVariation("wght", wghtAxis.Default));
+
+ CodePoint cp = new('O');
+ defaultFont.TryGetGlyphs(cp, out Glyph? defaultGlyph);
+ variedFont.TryGetGlyphs(cp, out Glyph? variedGlyph);
+
+ Assert.Equal(defaultGlyph.Value.GlyphMetrics.AdvanceWidth, variedGlyph.Value.GlyphMetrics.AdvanceWidth);
+ }
+
+ [Fact]
+ public void HVAR_AdvanceWidthAtSpecificWeight()
+ {
+ // fontkit: TestGVARFour at wght=150, glyph 'O' should have advanceWidth=706
+ FontFamily family = new FontCollection().Add(TestFonts.TestGVARFour);
+ Font variedFont = family.CreateFont(12, new FontVariation("wght", 150));
+
+ CodePoint cp = new('O');
+ Assert.True(variedFont.TryGetGlyphs(cp, out Glyph? glyph));
+
+ Assert.Equal(706, glyph.Value.GlyphMetrics.AdvanceWidth);
+ }
+
+ [Fact]
+ public void GVar_AdobeVFPrototype_VariedGlyphDiffersFromDefault()
+ {
+ FontFamily family = new FontCollection().Add(TestFonts.AdobeVFPrototype);
+ Font defaultFont = family.CreateFont(12);
+ Font lightFont = family.CreateFont(12, new FontVariation("wght", 200));
+ Font boldFont = family.CreateFont(12, new FontVariation("wght", 900));
+
+ CodePoint cp = new('A');
+ defaultFont.TryGetGlyphs(cp, out Glyph? defaultGlyph);
+ lightFont.TryGetGlyphs(cp, out Glyph? lightGlyph);
+ boldFont.TryGetGlyphs(cp, out Glyph? boldGlyph);
+
+ // Bold should be wider than default, light should be narrower.
+ Assert.True(boldGlyph.Value.GlyphMetrics.AdvanceWidth >= defaultGlyph.Value.GlyphMetrics.AdvanceWidth);
+ }
+
+ [Fact]
+ public void GVar_AdobeVFPrototype_DifferentWeightsProduceDifferentBounds()
+ {
+ FontFamily family = new FontCollection().Add(TestFonts.AdobeVFPrototype);
+ Font font200 = family.CreateFont(12, new FontVariation("wght", 200));
+ Font font900 = family.CreateFont(12, new FontVariation("wght", 900));
+
+ CodePoint cp = new('A');
+ font200.TryGetGlyphs(cp, out Glyph? g200);
+ font900.TryGetGlyphs(cp, out Glyph? g900);
+
+ // Bounds should differ between light and heavy weights.
+ Assert.NotEqual(g200.Value.GlyphMetrics.Width, g900.Value.GlyphMetrics.Width);
+ }
+
+ [Fact]
+ public void GVar_AdobeVFPrototype_GSUB_SubstitutesGlyphAtHeavyWeight()
+ {
+ // fontkit: AdobeVFPrototype at wght=900, '$' substitutes to 'dollar.nostroke' (glyphId 2).
+ // GSUB FeatureVariations activate alternate glyphs based on axis values.
+ // Must use TextRenderer to trigger GSUB.
+ FontFamily family = new FontCollection().Add(TestFonts.AdobeVFPrototype);
+ Font defaultFont = family.CreateFont(12);
+ Font heavyFont = family.CreateFont(12, new FontVariation("wght", 900));
+
+ GlyphRenderer defaultRenderer = new();
+ TextRenderer.RenderTextTo(defaultRenderer, "$", new TextOptions(defaultFont));
+
+ GlyphRenderer heavyRenderer = new();
+ TextRenderer.RenderTextTo(heavyRenderer, "$", new TextOptions(heavyFont));
+
+ // The GSUB substitution should produce a different glyph ID at wght=900.
+ Assert.NotEqual(defaultRenderer.GlyphKeys[0].GlyphId, heavyRenderer.GlyphKeys[0].GlyphId);
+ }
+
+ [Fact]
+ public void CFF2_CanLoadFont()
+ {
+ // AdobeVFPrototype-Subset.otf is the only CFF2 font in the test suite.
+ // It contains 3 glyphs: .notdef, '$' (glyph 1), and 'dollar.nostroke' (glyph 2).
+ FontFamily family = new FontCollection().Add(TestFonts.AdobeVFPrototypeSubset);
+ Font font = family.CreateFont(12);
+
+ Assert.NotNull(font.FontMetrics);
+ }
+
+ [Fact]
+ public void CFF2_CanLoadVariationAxes()
+ {
+ FontFamily family = new FontCollection().Add(TestFonts.AdobeVFPrototypeSubset);
+ Font font = family.CreateFont(12);
+
+ Assert.True(font.FontMetrics.TryGetVariationAxes(out VariationAxis[]? axes));
+ Assert.Equal(2, axes!.Length);
+ Assert.Equal("wght", axes[0].Tag);
+ }
+
+ [Fact]
+ public void CFF2_CanCreateFontWithVariation()
+ {
+ FontFamily family = new FontCollection().Add(TestFonts.AdobeVFPrototypeSubset);
+ Font variedFont = family.CreateFont(12, new FontVariation("wght", 900));
+
+ Assert.Single(variedFont.Variations.ToArray());
+ }
+
+ [Fact]
+ public void CFF2_RendersGlyphAtDefaultWeight()
+ {
+ FontFamily family = new FontCollection().Add(TestFonts.AdobeVFPrototypeSubset);
+ Font font = family.CreateFont(48);
+
+ GlyphRenderer renderer = new();
+ TextRenderer.RenderTextTo(renderer, "$", new TextOptions(font));
+
+ Assert.NotEmpty(renderer.GlyphKeys);
+ Assert.NotEmpty(renderer.ControlPoints);
+ }
+
+ [Fact]
+ public void CFF2_RendersGlyphAtVariedWeight()
+ {
+ FontFamily family = new FontCollection().Add(TestFonts.AdobeVFPrototypeSubset);
+ Font font = family.CreateFont(48, new FontVariation("wght", 900));
+
+ GlyphRenderer renderer = new();
+ TextRenderer.RenderTextTo(renderer, "$", new TextOptions(font));
+
+ Assert.NotEmpty(renderer.GlyphKeys);
+ Assert.NotEmpty(renderer.ControlPoints);
+ }
+
+ [Fact]
+ public void CFF2_MultipleWeightsRenderSuccessfully()
+ {
+ // Verify the CFF2 parser handles charstrings at multiple weight values.
+ FontFamily family = new FontCollection().Add(TestFonts.AdobeVFPrototypeSubset);
+ Font lightFont = family.CreateFont(48, new FontVariation("wght", 0));
+ Font heavyFont = family.CreateFont(48, new FontVariation("wght", 900));
+
+ GlyphRenderer lightRenderer = new();
+ TextRenderer.RenderTextTo(lightRenderer, "$", new TextOptions(lightFont));
+
+ GlyphRenderer heavyRenderer = new();
+ TextRenderer.RenderTextTo(heavyRenderer, "$", new TextOptions(heavyFont));
+
+ Assert.NotEmpty(lightRenderer.ControlPoints);
+ Assert.NotEmpty(heavyRenderer.ControlPoints);
+ }
+
+ [Fact]
+ public void MVAR_MetricsVaryWithAxisValues()
+ {
+ // RobotoFlex has MVAR table. Global metrics should change with weight.
+ FontFamily family = new FontCollection().Add(TestFonts.RobotoFlex);
+ Font defaultFont = family.CreateFont(12);
+ Font heavyFont = family.CreateFont(12, new FontVariation("wght", 1000));
+
+ // Ascender/descender may change with MVAR.
+ // At minimum, the font should load successfully with both axis values.
+ Assert.NotNull(defaultFont.FontMetrics);
+ Assert.NotNull(heavyFont.FontMetrics);
+
+ // UnitsPerEm should remain the same (not affected by MVAR).
+ Assert.Equal(defaultFont.FontMetrics.UnitsPerEm, heavyFont.FontMetrics.UnitsPerEm);
+ }
+
+ [Fact]
+ public void GPOS_MarkAnchorPositionsVaryWithWeight()
+ {
+ // fontkit: Mada-VF at wght=900, layout 'ف', positions[0] xOffset≈639, yOffset≈542.
+ // The mark positioning should differ between default and heavy weights.
+ FontFamily family = new FontCollection().Add(TestFonts.MadaVF);
+ Font defaultFont = family.CreateFont(72);
+ Font heavyFont = family.CreateFont(72, new FontVariation("wght", 900));
+
+ GlyphRenderer defaultRenderer = new();
+ TextRenderer.RenderTextTo(defaultRenderer, "\u0641", new TextOptions(defaultFont));
+
+ GlyphRenderer heavyRenderer = new();
+ TextRenderer.RenderTextTo(heavyRenderer, "\u0641", new TextOptions(heavyFont));
+
+ // Both should render, and the glyph bounds should differ due to
+ // GPOS mark anchor adjustments varying with weight.
+ Assert.NotEmpty(defaultRenderer.GlyphRects);
+ Assert.NotEmpty(heavyRenderer.GlyphRects);
+ Assert.NotEqual(defaultRenderer.GlyphRects[0], heavyRenderer.GlyphRects[0]);
+ }
+
+ [Fact]
+ public void MultipleVariationInstances_DoNotInterfere()
+ {
+ // Create two different variation instances from the same base font.
+ // They should produce different results without corrupting each other.
+ FontFamily family = new FontCollection().Add(TestFonts.TestGVARFour);
+ Font lightFont = family.CreateFont(12, new FontVariation("wght", 150));
+ Font heavyFont = family.CreateFont(12, new FontVariation("wght", 900));
+
+ CodePoint cp = new('O');
+ lightFont.TryGetGlyphs(cp, out Glyph? lightGlyph);
+ heavyFont.TryGetGlyphs(cp, out Glyph? heavyGlyph);
+
+ // Both should succeed.
+ Assert.NotNull(lightGlyph);
+ Assert.NotNull(heavyGlyph);
+
+ // They should produce different advance widths.
+ Assert.NotEqual(
+ lightGlyph.Value.GlyphMetrics.AdvanceWidth,
+ heavyGlyph.Value.GlyphMetrics.AdvanceWidth);
+ }
+
+ [Fact]
+ public void VariationInstance_DoesNotCorruptBaseFont()
+ {
+ // Get a glyph from the default font, then create a variation,
+ // then get the same glyph from the default font again.
+ // The default font should not be affected.
+ FontFamily family = new FontCollection().Add(TestFonts.TestGVARFour);
+ Font defaultFont = family.CreateFont(12);
+
+ CodePoint cp = new('O');
+ defaultFont.TryGetGlyphs(cp, out Glyph? before);
+ ushort widthBefore = before.Value.GlyphMetrics.AdvanceWidth;
+
+ // Create and use a variation instance.
+ Font variedFont = family.CreateFont(12, new FontVariation("wght", 150));
+ variedFont.TryGetGlyphs(cp, out _);
+
+ // Default font should still produce the same width.
+ defaultFont.TryGetGlyphs(cp, out Glyph? after);
+ Assert.Equal(widthBefore, after.Value.GlyphMetrics.AdvanceWidth);
+ }
+
+ [Fact]
+ public void TextMeasurer_AdvanceChangesWithVariation()
+ {
+ FontFamily family = new FontCollection().Add(TestFonts.RobotoFlex);
+ Font thinFont = family.CreateFont(72, new FontVariation("wght", 100));
+ Font heavyFont = family.CreateFont(72, new FontVariation("wght", 1000));
+
+ TextOptions thinOptions = new(thinFont);
+ TextOptions heavyOptions = new(heavyFont);
+
+ FontRectangle thinAdvance = TextMeasurer.MeasureAdvance("Hello", thinOptions);
+ FontRectangle heavyAdvance = TextMeasurer.MeasureAdvance("Hello", heavyOptions);
+
+ // Heavy weight should produce a wider advance than thin.
+ Assert.True(
+ heavyAdvance.Width > thinAdvance.Width,
+ $"Heavy advance ({heavyAdvance.Width}) should be wider than thin ({thinAdvance.Width})");
+ }
+
+ [Fact]
+ public void TextMeasurer_MultipleAxesWork()
+ {
+ FontFamily family = new FontCollection().Add(TestFonts.RobotoFlex);
+ Font font = family.CreateFont(
+ 72,
+ new FontVariation("wght", 700),
+ new FontVariation("wdth", 75));
+
+ TextOptions options = new(font);
+ FontRectangle advance = TextMeasurer.MeasureAdvance("Test", options);
+
+ // Should produce a valid non-zero measurement.
+ Assert.True(advance.Width > 0);
+ Assert.True(advance.Height > 0);
+ }
+
+ [Fact]
+ public void Renderer_VariedFontProducesGlyphs()
+ {
+ FontFamily family = new FontCollection().Add(TestFonts.TestGVAROne);
+ Font variedFont = family.CreateFont(12, new FontVariation("wght", 300));
+
+ GlyphRenderer renderer = new();
+ TextRenderer.RenderTextTo(renderer, "彌", new TextOptions(variedFont));
+
+ Assert.NotEmpty(renderer.GlyphKeys);
+ Assert.NotEmpty(renderer.GlyphRects);
+ }
+
+ [Fact]
+ public void Renderer_DifferentVariationsProduceDifferentControlPoints()
+ {
+ FontFamily family = new FontCollection().Add(TestFonts.TestGVAROne);
+ Font defaultFont = family.CreateFont(72);
+ Font variedFont = family.CreateFont(72, new FontVariation("wght", 300));
+
+ GlyphRenderer defaultRenderer = new();
+ TextRenderer.RenderTextTo(defaultRenderer, "彌", new TextOptions(defaultFont));
+
+ GlyphRenderer variedRenderer = new();
+ TextRenderer.RenderTextTo(variedRenderer, "彌", new TextOptions(variedFont));
+
+ // Both should produce control points, but they should differ.
+ Assert.NotEmpty(defaultRenderer.ControlPoints);
+ Assert.NotEmpty(variedRenderer.ControlPoints);
+
+ // At least some control points should differ between the two variations.
+ bool anyDifference = false;
+ int count = Math.Min(defaultRenderer.ControlPoints.Count, variedRenderer.ControlPoints.Count);
+ for (int i = 0; i < count; i++)
+ {
+ if (defaultRenderer.ControlPoints[i] != variedRenderer.ControlPoints[i])
+ {
+ anyDifference = true;
+ break;
+ }
+ }
+
+ Assert.True(anyDifference, "Control points should differ between default and varied glyphs");
+ }
+
+ [Fact]
+ public void Renderer_GVar_AdobeVFPrototype_VariedFontProducesGlyphs()
+ {
+ FontFamily family = new FontCollection().Add(TestFonts.AdobeVFPrototype);
+ Font variedFont = family.CreateFont(12, new FontVariation("wght", 900));
+
+ GlyphRenderer renderer = new();
+ TextRenderer.RenderTextTo(renderer, "A", new TextOptions(variedFont));
+
+ Assert.NotEmpty(renderer.GlyphKeys);
+ }
+
+ [Fact]
+ public void NotoSansHK_VariableWeight_LoadsSuccessfully()
+ {
+ FontFamily family = new FontCollection().Add(TestFonts.NotoSansHKVariableFontWght);
+ Font thinFont = family.CreateFont(12, new FontVariation("wght", 100));
+ Font boldFont = family.CreateFont(12, new FontVariation("wght", 900));
+
+ Assert.NotNull(thinFont.FontMetrics);
+ Assert.NotNull(boldFont.FontMetrics);
+ }
+
+ [Fact]
+ public void NotoEmoji_GVar_OutlinesVaryWithWeight()
+ {
+ // Noto Emoji is a TrueType variable font (gvar/HVAR) with a weight axis (300–700, default 400).
+ // Advance width stays constant at 2600 across weights, but glyph outlines change.
+ // Verified against fontkit: star U+2B50 glyphId=184, advance=2600 at all weights,
+ // light bbox={233,-320,2367,1720}, bold bbox={203,-350,2397,1750}.
+ FontFamily family = new FontCollection().Add(TestFonts.NotoEmojiVariableFont);
+ Font lightFont = family.CreateFont(12, new FontVariation("wght", 300));
+ Font boldFont = family.CreateFont(12, new FontVariation("wght", 700));
+
+ // Advance width should be constant across weights.
+ CodePoint cp = new(0x2B50);
+ Assert.True(lightFont.TryGetGlyphs(cp, out Glyph? lightGlyph));
+ Assert.True(boldFont.TryGetGlyphs(cp, out Glyph? boldGlyph));
+ Assert.Equal(2600, lightGlyph.Value.GlyphMetrics.AdvanceWidth);
+ Assert.Equal(2600, boldGlyph.Value.GlyphMetrics.AdvanceWidth);
+
+ // Render both and verify outlines differ.
+ GlyphRenderer lightRenderer = new();
+ TextRenderer.RenderTextTo(lightRenderer, "\u2B50", new TextOptions(lightFont));
+
+ GlyphRenderer boldRenderer = new();
+ TextRenderer.RenderTextTo(boldRenderer, "\u2B50", new TextOptions(boldFont));
+
+ Assert.NotEmpty(lightRenderer.ControlPoints);
+ Assert.NotEmpty(boldRenderer.ControlPoints);
+
+ // Control points should differ between light and bold weights.
+ bool anyDifference = false;
+ int count = Math.Min(lightRenderer.ControlPoints.Count, boldRenderer.ControlPoints.Count);
+ for (int i = 0; i < count; i++)
+ {
+ if (lightRenderer.ControlPoints[i] != boldRenderer.ControlPoints[i])
+ {
+ anyDifference = true;
+ break;
+ }
+ }
+
+ Assert.True(anyDifference, "Glyph outlines should differ between light and bold weights");
+ }
+
+ [Theory]
+ [InlineData(300, "Light")]
+ [InlineData(400, "Regular")]
+ [InlineData(700, "Bold")]
+ public void VisualTest_NotoEmoji_WeightVariations(float weight, string label)
+ {
+ FontFamily family = new FontCollection().Add(TestFonts.NotoEmojiVariableFont);
+ Font font = family.CreateFont(48, new FontVariation("wght", weight));
+
+ TextOptions options = new(font);
+
+ TextLayoutTestUtilities.TestLayout(
+ "\u2B50\u263A\u2764\u270C",
+ options,
+ properties: [label, weight]);
+ }
+
+ [Theory]
+ [InlineData(100, "Thin")]
+ [InlineData(400, "Regular")]
+ [InlineData(700, "Bold")]
+ [InlineData(1000, "Heavy")]
+ public void VisualTest_RobotoFlex_WeightVariations(float weight, string label)
+ {
+ FontFamily family = new FontCollection().Add(TestFonts.RobotoFlex);
+ Font font = family.CreateFont(36, new FontVariation("wght", weight));
+
+ TextOptions options = new(font);
+
+ TextLayoutTestUtilities.TestLayout(
+ "The quick brown fox jumps over the lazy dog.",
+ options,
+ properties: [label, weight]);
+ }
+
+ [Theory]
+ [InlineData(200, "Light")]
+ [InlineData(900, "Black")]
+ public void VisualTest_AdobeVFPrototype_GVar_WeightVariations(float weight, string label)
+ {
+ FontFamily family = new FontCollection().Add(TestFonts.AdobeVFPrototype);
+ Font font = family.CreateFont(48, new FontVariation("wght", weight));
+
+ TextOptions options = new(font);
+
+ TextLayoutTestUtilities.TestLayout(
+ "ABCDEFGH",
+ options,
+ properties: [label, weight]);
+ }
+
+ [Fact]
+ public void VisualTest_RobotoFlex_MultipleAxes()
+ {
+ FontFamily family = new FontCollection().Add(TestFonts.RobotoFlex);
+ Font font = family.CreateFont(
+ 36,
+ new FontVariation("wght", 700),
+ new FontVariation("wdth", 75));
+
+ TextOptions options = new(font);
+
+ TextLayoutTestUtilities.TestLayout(
+ "Multiple variation axes",
+ options);
+ }
+
+ [Fact]
+ public void CVar_CanLoadFontWithCvarTable()
+ {
+ // VotoSerif has cvar, cvt, fpgm, prep, fvar, gvar tables.
+ // Axes: wght (28–194, default 94), wdth (70–100), opsz (12–72).
+ FontFamily family = new FontCollection().Add(TestFonts.VotoSerifCvar);
+ Font font = family.CreateFont(12);
+
+ Assert.NotNull(font.FontMetrics);
+ Assert.True(font.FontMetrics.TryGetVariationAxes(out VariationAxis[]? axes));
+ Assert.Equal(3, axes!.Length);
+ Assert.Equal("wght", axes[0].Tag);
+ Assert.Equal("wdth", axes[1].Tag);
+ Assert.Equal("opsz", axes[2].Tag);
+ }
+
+ [Fact]
+ public void CVar_NoShared_CanLoadFontWithCvarTable()
+ {
+ // Same font but cvar uses no shared point numbers.
+ FontFamily family = new FontCollection().Add(TestFonts.VotoSerifCvarNoShared);
+ Font font = family.CreateFont(12);
+
+ Assert.NotNull(font.FontMetrics);
+ Assert.True(font.FontMetrics.TryGetVariationAxes(out VariationAxis[]? axes));
+ Assert.Equal(3, axes!.Length);
+ }
+
+ [Fact]
+ public void CVar_HintedRenderingWithVariation()
+ {
+ // Exercise the cvar code path: hinting enabled + variation applied.
+ // cvar deltas modify CVT values before TrueType hinting instructions run.
+ FontFamily family = new FontCollection().Add(TestFonts.VotoSerifCvar);
+ Font defaultFont = family.CreateFont(48);
+ Font variedFont = family.CreateFont(48, new FontVariation("wght", 194));
+
+ TextOptions defaultOptions = new(defaultFont) { HintingMode = HintingMode.Standard };
+ TextOptions variedOptions = new(variedFont) { HintingMode = HintingMode.Standard };
+
+ GlyphRenderer defaultRenderer = new();
+ TextRenderer.RenderTextTo(defaultRenderer, "hono", defaultOptions);
+
+ GlyphRenderer variedRenderer = new();
+ TextRenderer.RenderTextTo(variedRenderer, "hono", variedOptions);
+
+ Assert.NotEmpty(defaultRenderer.ControlPoints);
+ Assert.NotEmpty(variedRenderer.ControlPoints);
+ }
+
+ [Fact]
+ public void CVar_NoShared_HintedRenderingWithVariation()
+ {
+ // Same test with the no-shared-points variant of the cvar font.
+ FontFamily family = new FontCollection().Add(TestFonts.VotoSerifCvarNoShared);
+ Font variedFont = family.CreateFont(48, new FontVariation("wght", 194));
+
+ TextOptions options = new(variedFont) { HintingMode = HintingMode.Standard };
+
+ GlyphRenderer renderer = new();
+ TextRenderer.RenderTextTo(renderer, "hono", options);
+
+ Assert.NotEmpty(renderer.ControlPoints);
+ }
+
+ [Fact]
+ public void CVar_HintedRenderingAtSmallSize()
+ {
+ // At small sizes, TrueType hinting with cvar-adjusted CVT values
+ // has a greater effect on grid-fitting. Verify both paths render successfully.
+ FontFamily family = new FontCollection().Add(TestFonts.VotoSerifCvar);
+ Font font = family.CreateFont(12, new FontVariation("wght", 28));
+
+ TextOptions hintedOptions = new(font) { HintingMode = HintingMode.Standard };
+ TextOptions unhintedOptions = new(font) { HintingMode = HintingMode.None };
+
+ GlyphRenderer hintedRenderer = new();
+ TextRenderer.RenderTextTo(hintedRenderer, "hono", hintedOptions);
+
+ GlyphRenderer unhintedRenderer = new();
+ TextRenderer.RenderTextTo(unhintedRenderer, "hono", unhintedOptions);
+
+ Assert.NotEmpty(hintedRenderer.ControlPoints);
+ Assert.NotEmpty(unhintedRenderer.ControlPoints);
+ }
+
+ [Theory]
+ [InlineData(28, "Light", HintingMode.None)]
+ [InlineData(28, "Light", HintingMode.Standard)]
+ [InlineData(94, "Regular", HintingMode.None)]
+ [InlineData(94, "Regular", HintingMode.Standard)]
+ [InlineData(194, "Heavy", HintingMode.None)]
+ [InlineData(194, "Heavy", HintingMode.Standard)]
+ public void VisualTest_VotoSerif_CVar_WeightVariations(float weight, string label, HintingMode hintingMode)
+ {
+ FontFamily family = new FontCollection().Add(TestFonts.VotoSerifCvar);
+ Font font = family.CreateFont(48, new FontVariation("wght", weight));
+
+ TextOptions options = new(font) { HintingMode = hintingMode };
+
+ TextLayoutTestUtilities.TestLayout(
+ "hono",
+ options,
+ properties: [label, weight, hintingMode]);
+ }
+}
diff --git a/tests/SixLabors.Fonts.Tests/Tables/Variations/VariationsTests.cs b/tests/SixLabors.Fonts.Tests/Tables/Variations/VariationsTests.cs
new file mode 100644
index 000000000..ab731b13f
--- /dev/null
+++ b/tests/SixLabors.Fonts.Tests/Tables/Variations/VariationsTests.cs
@@ -0,0 +1,123 @@
+// Copyright (c) Six Labors.
+// Licensed under the Six Labors Split License.
+
+using SixLabors.Fonts.Tables.AdvancedTypographic.Variations;
+
+namespace SixLabors.Fonts.Tests.Tables.Variations;
+
+public class VariationsTests
+{
+ private static readonly FontCollection TestFontCollection = new();
+ private static readonly Font RobotoFlexTTF = CreateFont(TestFonts.RobotoFlex);
+ private static readonly Font AdobeVFPrototype = CreateFont(TestFonts.AdobeVFPrototype);
+
+ private static Font CreateFont(string testFont)
+ {
+ FontFamily family = TestFontCollection.Add(testFont);
+ return family.CreateFont(12);
+ }
+
+ [Fact]
+ public void CanLoadVariationTables_RobotoFlex()
+ {
+ Assert.True(RobotoFlexTTF.FontMetrics.TryGetVariationAxes(out VariationAxis[] variationAxes));
+ Assert.Equal(13, variationAxes.Length);
+
+ Assert.Equal("wght", variationAxes[0].Name);
+ Assert.Equal("wght", variationAxes[0].Tag);
+ Assert.Equal(100, variationAxes[0].Min);
+ Assert.Equal(1000, variationAxes[0].Max);
+ Assert.Equal(400, variationAxes[0].Default);
+
+ Assert.Equal("wdth", variationAxes[1].Name);
+ Assert.Equal("wdth", variationAxes[1].Tag);
+ Assert.Equal(25, variationAxes[1].Min);
+ Assert.Equal(151, variationAxes[1].Max);
+ Assert.Equal(100, variationAxes[1].Default);
+
+ Assert.Equal("opsz", variationAxes[2].Name);
+ Assert.Equal("opsz", variationAxes[2].Tag);
+ Assert.Equal(8, variationAxes[2].Min);
+ Assert.Equal(144, variationAxes[2].Max);
+ Assert.Equal(14, variationAxes[2].Default);
+
+ Assert.Equal("GRAD", variationAxes[3].Name);
+ Assert.Equal("GRAD", variationAxes[3].Tag);
+ Assert.Equal(-200, variationAxes[3].Min);
+ Assert.Equal(150, variationAxes[3].Max);
+ Assert.Equal(0, variationAxes[3].Default);
+
+ Assert.Equal("slnt", variationAxes[4].Name);
+ Assert.Equal("slnt", variationAxes[4].Tag);
+ Assert.Equal(-10, variationAxes[4].Min);
+ Assert.Equal(0, variationAxes[4].Max);
+ Assert.Equal(0, variationAxes[4].Default);
+
+ Assert.Equal("XTRA", variationAxes[5].Name);
+ Assert.Equal("XTRA", variationAxes[5].Tag);
+ Assert.Equal(323, variationAxes[5].Min);
+ Assert.Equal(603, variationAxes[5].Max);
+ Assert.Equal(468, variationAxes[5].Default);
+
+ Assert.Equal("XOPQ", variationAxes[6].Name);
+ Assert.Equal("XOPQ", variationAxes[6].Tag);
+ Assert.Equal(27, variationAxes[6].Min);
+ Assert.Equal(175, variationAxes[6].Max);
+ Assert.Equal(96, variationAxes[6].Default);
+
+ Assert.Equal("YOPQ", variationAxes[7].Name);
+ Assert.Equal("YOPQ", variationAxes[7].Tag);
+ Assert.Equal(25, variationAxes[7].Min);
+ Assert.Equal(135, variationAxes[7].Max);
+ Assert.Equal(79, variationAxes[7].Default);
+
+ Assert.Equal("YTLC", variationAxes[8].Name);
+ Assert.Equal("YTLC", variationAxes[8].Tag);
+ Assert.Equal(416, variationAxes[8].Min);
+ Assert.Equal(570, variationAxes[8].Max);
+ Assert.Equal(514, variationAxes[8].Default);
+
+ Assert.Equal("YTUC", variationAxes[9].Name);
+ Assert.Equal("YTUC", variationAxes[9].Tag);
+ Assert.Equal(528, variationAxes[9].Min);
+ Assert.Equal(760, variationAxes[9].Max);
+ Assert.Equal(712, variationAxes[9].Default);
+
+ Assert.Equal("YTAS", variationAxes[10].Name);
+ Assert.Equal("YTAS", variationAxes[10].Tag);
+ Assert.Equal(649, variationAxes[10].Min);
+ Assert.Equal(854, variationAxes[10].Max);
+ Assert.Equal(750, variationAxes[10].Default);
+
+ Assert.Equal("YTDE", variationAxes[11].Name);
+ Assert.Equal("YTDE", variationAxes[11].Tag);
+ Assert.Equal(-305, variationAxes[11].Min);
+ Assert.Equal(-98, variationAxes[11].Max);
+ Assert.Equal(-203, variationAxes[11].Default);
+
+ Assert.Equal("YTFI", variationAxes[12].Name);
+ Assert.Equal("YTFI", variationAxes[12].Tag);
+ Assert.Equal(560, variationAxes[12].Min);
+ Assert.Equal(788, variationAxes[12].Max);
+ Assert.Equal(738, variationAxes[12].Default);
+ }
+
+ [Fact]
+ public void CanLoadVariationTables_AdobeVFPrototype()
+ {
+ Assert.True(AdobeVFPrototype.FontMetrics.TryGetVariationAxes(out VariationAxis[] variationAxes));
+ Assert.Equal(2, variationAxes.Length);
+
+ Assert.Equal("Weight", variationAxes[0].Name);
+ Assert.Equal("wght", variationAxes[0].Tag);
+ Assert.Equal(200, variationAxes[0].Min);
+ Assert.Equal(900, variationAxes[0].Max);
+ Assert.Equal(389.344, Math.Round(variationAxes[0].Default, 3));
+
+ Assert.Equal("Contrast", variationAxes[1].Name);
+ Assert.Equal("CNTR", variationAxes[1].Tag);
+ Assert.Equal(0, variationAxes[1].Min);
+ Assert.Equal(100, variationAxes[1].Max);
+ Assert.Equal(0, variationAxes[1].Default);
+ }
+}
diff --git a/tests/SixLabors.Fonts.Tests/TestFonts.cs b/tests/SixLabors.Fonts.Tests/TestFonts.cs
index ffa610728..af9b592c9 100644
--- a/tests/SixLabors.Fonts.Tests/TestFonts.cs
+++ b/tests/SixLabors.Fonts.Tests/TestFonts.cs
@@ -1,8 +1,13 @@
// Copyright (c) Six Labors.
// Licensed under the Six Labors Split License.
+using System;
using System.Collections.Concurrent;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
using System.Reflection;
+using Xunit;
namespace SixLabors.Fonts.Tests;
@@ -197,6 +202,73 @@ public static class TestFonts
public static string RobotoRegular => GetFullPath("Roboto-Regular.ttf");
+ ///
+ /// Gets the RobotoFlex variable font with CFF2 outlines and multiple variation axes
+ /// including weight, width, slant, and optical size.
+ ///
+ public static string RobotoFlex => GetFullPath("RobotoFlex.ttf");
+
+ ///
+ /// Gets the Adobe Variable Font Prototype with TrueType/gvar outlines and weight/contrast variations.
+ /// From .
+ ///
+ public static string AdobeVFPrototype => GetFullPath("AdobeVFPrototype.ttf");
+
+ ///
+ /// Gets a subset of AdobeVFPrototype with CFF2 variations and GSUB feature variations.
+ /// From fontkit test data.
+ ///
+ public static string AdobeVFPrototypeSubset => GetFullPath("AdobeVFPrototype-Subset.otf");
+
+ ///
+ /// Gets a gvar test font using shared all-points deltas. From fontkit test data.
+ ///
+ public static string TestGVAROne => GetFullPath("TestGVAROne.ttf");
+
+ ///
+ /// Gets a gvar test font using shared enumerated-points deltas. From fontkit test data.
+ ///
+ public static string TestGVARTwo => GetFullPath("TestGVARTwo.ttf");
+
+ ///
+ /// Gets a gvar test font using no shared points. From fontkit test data.
+ ///
+ public static string TestGVARThree => GetFullPath("TestGVARThree.ttf");
+
+ ///
+ /// Gets a gvar test font with two axes (cntr and wght) and an HVAR table. From fontkit test data.
+ ///
+ public static string TestGVARFour => GetFullPath("TestGVARFour.ttf");
+
+ ///
+ /// Gets a test font with HVAR fallback (DeltaSetIndexMap) behavior. From fontkit test data.
+ ///
+ public static string TestHVARTwo => GetFullPath("TestHVARTwo.ttf");
+
+ ///
+ /// Gets the Mada variable font (Arabic) with GPOS mark anchor variations. From fontkit test data.
+ ///
+ public static string MadaVF => GetFullPath("Mada-VF.ttf");
+
+ ///
+ /// Gets the Noto Emoji variable font with a weight axis (300–700) using gvar/HVAR.
+ ///
+ public static string NotoEmojiVariableFont => GetFullPath("NotoEmoji-VariableFont_wght.ttf");
+
+ ///
+ /// Gets VotoSerif variable font with cvar table using shared points.
+ /// From fonttools test data. Axes: wght (28–194), wdth (70–100), opsz (12–72).
+ /// Contains 5 glyphs: .notdef, space (U+0020), 'h' (U+0068), 'n' (U+006E), 'o' (U+006F).
+ ///
+ public static string VotoSerifCvar => GetFullPath("VotoSerifGX-IUP-gvar-cvar.ttf");
+
+ ///
+ /// Gets VotoSerif variable font with cvar table without shared points.
+ /// From fonttools test data. Axes: wght (28–194), wdth (70–100), opsz (12–72).
+ /// Contains 5 glyphs: .notdef, space (U+0020), 'h' (U+0068), 'n' (U+006E), 'o' (U+006F).
+ ///
+ public static string VotoSerifCvarNoShared => GetFullPath("VotoSerifGX-IUP-gvar-cvar_noshared.ttf");
+
public static string SimpleTrueTypeCollection => GetFullPath("Sample.ttc");
public static string WhitneyBookFile => GetFullPath("whitney-book.ttf");