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");