From 0ec875bfa604cb8fb82ae7062f24bc66b0ae9393 Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Tue, 22 Sep 2026 11:18:44 -0400 Subject: [PATCH 01/17] Add Fonts namespace: TrueTypeFont public API and internal SFNT/cmap/glyf/hmtx/kern parsers --- .../Fonts/CmapTable.cs | 284 ++++++++++ .../Fonts/GlyfLocaReader.cs | 511 ++++++++++++++++++ .../Fonts/HmtxHheaReader.cs | 112 ++++ .../Fonts/KernTable.cs | 178 ++++++ .../Fonts/NamespaceDoc.cs | 19 + .../Fonts/SfntContainer.cs | 193 +++++++ .../Fonts/TrueTypeFont.cs | 288 ++++++++++ 7 files changed, 1585 insertions(+) create mode 100644 src/DemaConsulting.CanvasNet/Fonts/CmapTable.cs create mode 100644 src/DemaConsulting.CanvasNet/Fonts/GlyfLocaReader.cs create mode 100644 src/DemaConsulting.CanvasNet/Fonts/HmtxHheaReader.cs create mode 100644 src/DemaConsulting.CanvasNet/Fonts/KernTable.cs create mode 100644 src/DemaConsulting.CanvasNet/Fonts/NamespaceDoc.cs create mode 100644 src/DemaConsulting.CanvasNet/Fonts/SfntContainer.cs create mode 100644 src/DemaConsulting.CanvasNet/Fonts/TrueTypeFont.cs diff --git a/src/DemaConsulting.CanvasNet/Fonts/CmapTable.cs b/src/DemaConsulting.CanvasNet/Fonts/CmapTable.cs new file mode 100644 index 00000000..efc3c535 --- /dev/null +++ b/src/DemaConsulting.CanvasNet/Fonts/CmapTable.cs @@ -0,0 +1,284 @@ +namespace DemaConsulting.CanvasNet.Fonts; + +/// +/// Parses a font's cmap table and resolves Unicode codepoints to glyph indices, using +/// only format 4 (Unicode BMP) and format 12 (Unicode full repertoire) subtables. +/// +/// +/// +/// Subtable selection is priority-ordered, first match wins: (3,10) format 12, +/// (0,4)/(0,6) format 12, (3,1) format 4, (0,3) format 4, then any +/// remaining (0,x) format 4. Format 0/2/6, symbol encoding (3,0), and any +/// non-Unicode platform/encoding pair are never selected. If no supported subtable is present, +/// or the cmap table itself is missing, malformed, or truncated, +/// always returns 0 (.notdef) - this class never throws. +/// +/// +internal sealed class CmapTable +{ + /// + /// A with no usable subtable: always + /// returns 0. Used when the font has no cmap table at all. + /// + public static readonly CmapTable Empty = new(null); + + /// + /// The selected subtable's lookup delegate, or if no supported + /// subtable was found. + /// + private readonly Func? _lookup; + + private CmapTable(Func? lookup) + { + _lookup = lookup; + } + + /// + /// Parses a cmap table and selects the highest-priority supported subtable. + /// + /// The complete font file contents. + /// The offset of the cmap table within . + /// The length of the cmap table. + /// + /// A wrapping the selected subtable, or if the + /// table is too short to contain a header, or no candidate subtable can be parsed + /// successfully. + /// + public static CmapTable Parse(byte[] data, int tableOffset, int tableLength) + { + if (tableLength < 4) + { + return Empty; + } + + var numTables = SfntContainer.ReadUInt16(data, tableOffset + 2); + var candidates = new List<(int Priority, int SubtableOffset, int Format)>(); + + var recordsEnd = checked((long)8 + (long)numTables * 8); + if (recordsEnd > tableLength) + { + return Empty; + } + + for (var i = 0; i < numTables; i++) + { + var recordOffset = tableOffset + 4 + i * 8; + var platformId = SfntContainer.ReadUInt16(data, recordOffset); + var encodingId = SfntContainer.ReadUInt16(data, recordOffset + 2); + var subtableOffset = SfntContainer.ReadInt32(data, recordOffset + 4); + + if (subtableOffset < 0 || subtableOffset + 2 > tableLength) + { + continue; + } + + var absoluteSubtableOffset = tableOffset + subtableOffset; + var format = SfntContainer.ReadUInt16(data, absoluteSubtableOffset); + + var priority = ClassifyPriority(platformId, encodingId, format); + if (priority >= 0) + { + candidates.Add((priority, absoluteSubtableOffset, format)); + } + } + + candidates.Sort((a, b) => a.Priority.CompareTo(b.Priority)); + + foreach (var candidate in candidates) + { + var lookup = candidate.Format == 12 + ? TryParseFormat12(data, candidate.SubtableOffset, tableOffset + tableLength) + : TryParseFormat4(data, candidate.SubtableOffset, tableOffset + tableLength); + + if (lookup != null) + { + return new CmapTable(lookup); + } + } + + return Empty; + } + + /// + /// Looks up the glyph index mapped to a Unicode codepoint. + /// + /// The Unicode codepoint to look up. + /// + /// The mapped glyph index, or 0 (.notdef) if the codepoint is unmapped, or + /// no supported subtable is available. + /// + public int GetGlyphIndex(int codepoint) => _lookup?.Invoke(codepoint) ?? 0; + + /// + /// Classifies a subtable's selection priority (lower is preferred), or -1 if the + /// platform/encoding/format combination is not recognized as a supported subtable. + /// + private static int ClassifyPriority(int platformId, int encodingId, int format) + { + if (platformId == 3 && encodingId == 10 && format == 12) + { + return 0; + } + + if (platformId == 0 && (encodingId == 4 || encodingId == 6) && format == 12) + { + return 1; + } + + if (platformId == 3 && encodingId == 1 && format == 4) + { + return 2; + } + + if (platformId == 0 && encodingId == 3 && format == 4) + { + return 3; + } + + if (platformId == 0 && format == 4) + { + return 4; + } + + return -1; + } + + /// + /// Attempts to parse a format 4 (segment mapping to delta values) subtable, returning a + /// bound lookup delegate, or if the subtable is malformed/truncated. + /// + private static Func? TryParseFormat4(byte[] data, int offset, int tableEnd) + { + if (offset + 14 > tableEnd) + { + return null; + } + + var segCountX2 = SfntContainer.ReadUInt16(data, offset + 6); + var segCount = segCountX2 / 2; + if (segCount == 0) + { + return null; + } + + var endCodeOffset = offset + 14; + var startCodeOffset = endCodeOffset + segCountX2 + 2; // +2 skips reservedPad + var idDeltaOffset = startCodeOffset + segCountX2; + var idRangeOffsetOffset = idDeltaOffset + segCountX2; + var glyphIdArrayOffset = idRangeOffsetOffset + segCountX2; + + if (glyphIdArrayOffset > tableEnd) + { + return null; + } + + var endCodes = new int[segCount]; + var startCodes = new int[segCount]; + var idDeltas = new short[segCount]; + var idRangeOffsets = new ushort[segCount]; + for (var i = 0; i < segCount; i++) + { + endCodes[i] = SfntContainer.ReadUInt16(data, endCodeOffset + i * 2); + startCodes[i] = SfntContainer.ReadUInt16(data, startCodeOffset + i * 2); + idDeltas[i] = SfntContainer.ReadInt16(data, idDeltaOffset + i * 2); + idRangeOffsets[i] = SfntContainer.ReadUInt16(data, idRangeOffsetOffset + i * 2); + } + + return codepoint => + { + if (codepoint is < 0 or > 0xFFFF) + { + return 0; + } + + for (var i = 0; i < segCount; i++) + { + if (codepoint > endCodes[i]) + { + continue; + } + + if (codepoint < startCodes[i]) + { + return 0; + } + + if (idRangeOffsets[i] == 0) + { + return (codepoint + idDeltas[i]) & 0xFFFF; + } + + var glyphIndexAddress = idRangeOffsetOffset + i * 2 + idRangeOffsets[i] + 2 * (codepoint - startCodes[i]); + if (glyphIndexAddress + 2 > tableEnd) + { + return 0; + } + + var glyphId = SfntContainer.ReadUInt16(data, glyphIndexAddress); + return glyphId == 0 ? 0 : (glyphId + idDeltas[i]) & 0xFFFF; + } + + return 0; + }; + } + + /// + /// Attempts to parse a format 12 (segmented coverage) subtable, returning a bound lookup + /// delegate, or if the subtable is malformed/truncated. + /// + private static Func? TryParseFormat12(byte[] data, int offset, int tableEnd) + { + if (offset + 16 > tableEnd) + { + return null; + } + + var numGroups = SfntContainer.ReadUInt32(data, offset + 12); + var groupsEnd = checked((long)offset + 16 + (long)numGroups * 12); + if (groupsEnd > tableEnd) + { + return null; + } + + var starts = new uint[numGroups]; + var ends = new uint[numGroups]; + var startGlyphIds = new uint[numGroups]; + for (var i = 0; i < numGroups; i++) + { + var groupOffset = offset + 16 + i * 12; + starts[i] = SfntContainer.ReadUInt32(data, groupOffset); + ends[i] = SfntContainer.ReadUInt32(data, groupOffset + 4); + startGlyphIds[i] = SfntContainer.ReadUInt32(data, groupOffset + 8); + } + + return codepoint => + { + if (codepoint < 0) + { + return 0; + } + + var code = (uint)codepoint; + var lo = 0; + var hi = starts.Length - 1; + while (lo <= hi) + { + var mid = lo + (hi - lo) / 2; + if (code < starts[mid]) + { + hi = mid - 1; + } + else if (code > ends[mid]) + { + lo = mid + 1; + } + else + { + return (int)(startGlyphIds[mid] + (code - starts[mid])); + } + } + + return 0; + }; + } +} diff --git a/src/DemaConsulting.CanvasNet/Fonts/GlyfLocaReader.cs b/src/DemaConsulting.CanvasNet/Fonts/GlyfLocaReader.cs new file mode 100644 index 00000000..adf7a651 --- /dev/null +++ b/src/DemaConsulting.CanvasNet/Fonts/GlyfLocaReader.cs @@ -0,0 +1,511 @@ +using System.Numerics; +using DemaConsulting.CanvasNet.Geometry; +using Path = DemaConsulting.CanvasNet.Geometry.Path; + +namespace DemaConsulting.CanvasNet.Fonts; + +/// +/// Parses a font's loca (glyph location) table and decodes individual glyph outlines +/// from the glyf table into geometry, including composite glyph +/// resolution. +/// +/// +/// +/// loca is parsed eagerly at construction time (validating it is monotonically +/// non-decreasing and consistent with glyf's length), but no individual glyph's +/// contour data is decoded until is called for that specific +/// index - one corrupt glyph's contour data does not prevent using every other, otherwise +/// well-formed, glyph in the font. +/// +/// +/// Composite glyph resolution is bounded by two independent, deterministic counters (never a +/// timer): a nesting depth cap of and a total resolved component count +/// cap of across the whole +/// call. A depth cap alone would not prevent exponential blow-up from non-cyclic nested +/// composites; the component-count cap is the more important of the two bounds. Point-matched +/// composite components (ARGS_ARE_XY_VALUES clear) are rejected with +/// rather than silently mis-positioned. +/// +/// +internal sealed class GlyfLocaReader +{ + /// + /// The maximum composite glyph nesting depth permitted before + /// is thrown (also bounds true composite cycles, which + /// always eventually exceed this depth). + /// + private const int MaxDepth = 10; + + /// + /// The maximum total number of resolved glyph/component visits permitted across a single + /// call, bounding non-cyclic exponential composite blow-up. + /// + private const int MaxTotalComponents = 5000; + + private const int ArgsAreWords = 0x0001; + private const int ArgsAreXyValues = 0x0002; + private const int WeHaveAScale = 0x0008; + private const int MoreComponents = 0x0020; + private const int WeHaveAnXAndYScale = 0x0040; + private const int WeHaveATwoByTwo = 0x0080; + + private readonly byte[] _data; + private readonly int _glyfOffset; + private readonly int _glyfLength; + private readonly int[] _locaOffsets; + + /// + /// The total number of glyphs described by loca (one less than the number of + /// entries in ). + /// + public int GlyphCount { get; } + + private GlyfLocaReader(byte[] data, int glyfOffset, int glyfLength, int[] locaOffsets, int glyphCount) + { + _data = data; + _glyfOffset = glyfOffset; + _glyfLength = glyfLength; + _locaOffsets = locaOffsets; + GlyphCount = glyphCount; + } + + /// + /// Parses the loca table. + /// + /// The complete font file contents. + /// The offset of the loca table. + /// The length of the loca table. + /// The offset of the glyf table. + /// The length of the glyf table. + /// The font's total glyph count, from maxp. + /// + /// to parse 32-bit (Offset32) loca entries; + /// for 16-bit (Offset16, doubled) entries - selected by head.indexToLocFormat. + /// + /// A new exposing the parsed glyph location table. + /// + /// Thrown when loca is too short for its declared entry count, when any entry + /// exceeds the bounds of the glyf table, or when entries are not monotonically + /// non-decreasing. + /// + public static GlyfLocaReader Parse(byte[] data, int locaOffset, int locaLength, int glyfOffset, int glyfLength, int numGlyphs, bool longFormat) + { + var count = numGlyphs + 1; + var entrySize = longFormat ? 4 : 2; + var required = (long)count * entrySize; + if (required > locaLength) + { + throw new InvalidDataException("The 'loca' table is too short for the declared glyph count."); + } + + var offsets = new int[count]; + for (var i = 0; i < count; i++) + { + long raw = longFormat + ? SfntContainer.ReadUInt32(data, locaOffset + i * 4) + : (long)SfntContainer.ReadUInt16(data, locaOffset + i * 2) * 2; + + if (raw > glyfLength) + { + throw new InvalidDataException("A 'loca' entry exceeds the bounds of the 'glyf' table."); + } + + if (i > 0 && raw < offsets[i - 1]) + { + throw new InvalidDataException("The 'loca' table entries must be monotonically non-decreasing."); + } + + offsets[i] = (int)raw; + } + + return new GlyfLocaReader(data, glyfOffset, glyfLength, offsets, numGlyphs); + } + + /// + /// Decodes a single glyph's outline. + /// + /// The glyph index to decode. + /// + /// The glyph's outline in raw font design units, or for a glyph + /// with no contour data (for example space). + /// + /// + /// Thrown when is outside [0, GlyphCount). + /// + /// + /// Thrown when the glyph's contour data is malformed or truncated, when a composite + /// glyph's nesting depth or total resolved component count exceeds its bound, or when a + /// composite glyph contains a point-matched component or references an out-of-range + /// component glyph index. + /// + public Path GetGlyphOutline(int glyphIndex) + { + if (glyphIndex < 0 || glyphIndex >= GlyphCount) + { + throw new ArgumentOutOfRangeException(nameof(glyphIndex), glyphIndex, "Glyph index is out of range."); + } + + var totalComponents = 0; + return DecodeGlyph(glyphIndex, 0, ref totalComponents); + } + + /// + /// Decodes a single glyph (simple or composite), enforcing the depth and total-component + /// bounds. + /// + private Path DecodeGlyph(int glyphIndex, int depth, ref int totalComponents) + { + if (glyphIndex < 0 || glyphIndex >= GlyphCount) + { + throw new InvalidDataException("Composite glyph references an out-of-range component glyph index."); + } + + if (depth > MaxDepth) + { + throw new InvalidDataException("Composite glyph nesting exceeds the supported depth."); + } + + totalComponents++; + if (totalComponents > MaxTotalComponents) + { + throw new InvalidDataException("Composite glyph resolves to too many total components."); + } + + var start = _locaOffsets[glyphIndex]; + var end = _locaOffsets[glyphIndex + 1]; + if (start == end) + { + return Path.Empty; + } + + var glyphOffset = _glyfOffset + start; + var glyphLength = end - start; + if (glyphLength < 10 || end > _glyfLength) + { + throw new InvalidDataException("Glyph data is too short to contain a glyph header."); + } + + var numberOfContours = SfntContainer.ReadInt16(_data, glyphOffset); + if (numberOfContours >= 0) + { + return DecodeSimpleGlyph(glyphOffset, glyphLength, numberOfContours); + } + + if (numberOfContours == -1) + { + return DecodeCompositeGlyph(glyphOffset, glyphLength, depth + 1, ref totalComponents); + } + + throw new InvalidDataException("Glyph declares an invalid contour count."); + } + + /// + /// Decodes a simple glyph's contour data into a . + /// + private Path DecodeSimpleGlyph(int glyphOffset, int glyphLength, int numberOfContours) + { + var limit = glyphOffset + glyphLength; + var pos = glyphOffset + 10; + + if (numberOfContours == 0) + { + return Path.Empty; + } + + EnsureAvailable(pos, numberOfContours * 2, limit); + var endPts = new int[numberOfContours]; + var previousEnd = -1; + for (var i = 0; i < numberOfContours; i++) + { + var value = SfntContainer.ReadUInt16(_data, pos); + pos += 2; + if (value <= previousEnd) + { + throw new InvalidDataException("Glyph contour end points must be strictly increasing."); + } + + endPts[i] = value; + previousEnd = value; + } + + var numPoints = endPts[^1] + 1; + + EnsureAvailable(pos, 2, limit); + var instructionLength = SfntContainer.ReadUInt16(_data, pos); + pos += 2; + EnsureAvailable(pos, instructionLength, limit); + pos += instructionLength; + + var flags = new byte[numPoints]; + var i2 = 0; + while (i2 < numPoints) + { + EnsureAvailable(pos, 1, limit); + var flag = _data[pos++]; + flags[i2++] = flag; + if ((flag & 0x08) != 0) + { + EnsureAvailable(pos, 1, limit); + var repeatCount = _data[pos++]; + for (var r = 0; r < repeatCount && i2 < numPoints; r++) + { + flags[i2++] = flag; + } + } + } + + var xs = new int[numPoints]; + var x = 0; + for (var i = 0; i < numPoints; i++) + { + var flag = flags[i]; + if ((flag & 0x02) != 0) + { + EnsureAvailable(pos, 1, limit); + var dx = _data[pos++]; + x += (flag & 0x10) != 0 ? dx : -dx; + } + else if ((flag & 0x10) == 0) + { + EnsureAvailable(pos, 2, limit); + x += SfntContainer.ReadInt16(_data, pos); + pos += 2; + } + + xs[i] = x; + } + + var ys = new int[numPoints]; + var y = 0; + for (var i = 0; i < numPoints; i++) + { + var flag = flags[i]; + if ((flag & 0x04) != 0) + { + EnsureAvailable(pos, 1, limit); + var dy = _data[pos++]; + y += (flag & 0x20) != 0 ? dy : -dy; + } + else if ((flag & 0x20) == 0) + { + EnsureAvailable(pos, 2, limit); + y += SfntContainer.ReadInt16(_data, pos); + pos += 2; + } + + ys[i] = y; + } + + var builder = new PathBuilder(); + var contourStart = 0; + for (var c = 0; c < numberOfContours; c++) + { + var contourEnd = endPts[c]; + var points = new List<(Vector2 Point, bool OnCurve)>(contourEnd - contourStart + 1); + for (var p = contourStart; p <= contourEnd; p++) + { + points.Add((new Vector2(xs[p], ys[p]), (flags[p] & 0x01) != 0)); + } + + EmitContour(builder, points); + contourStart = contourEnd + 1; + } + + return builder.Build(); + } + + /// + /// Emits one contour's on-curve/off-curve points into as + /// line/quadratic-Bezier commands, synthesizing the TrueType "implied on-curve midpoint" + /// for consecutive off-curve points and for a contour with no on-curve point at all. + /// + private static void EmitContour(PathBuilder builder, List<(Vector2 Point, bool OnCurve)> points) + { + var n = points.Count; + if (n == 0) + { + return; + } + + var firstOnCurveIndex = points.FindIndex(p => p.OnCurve); + + Vector2 startPoint; + List<(Vector2 Point, bool OnCurve)> ordered; + if (firstOnCurveIndex >= 0) + { + startPoint = points[firstOnCurveIndex].Point; + ordered = new List<(Vector2, bool)>(n); + for (var k = 0; k < n; k++) + { + ordered.Add(points[(firstOnCurveIndex + 1 + k) % n]); + } + } + else + { + startPoint = Midpoint(points[0].Point, points[^1].Point); + ordered = points; + } + + builder.MoveTo(startPoint); + + Vector2? pendingControl = null; + foreach (var (point, onCurve) in ordered) + { + if (onCurve) + { + if (pendingControl.HasValue) + { + builder.QuadraticBezierTo(pendingControl.Value, point); + pendingControl = null; + } + else + { + builder.LineTo(point); + } + } + else + { + if (pendingControl.HasValue) + { + var implied = Midpoint(pendingControl.Value, point); + builder.QuadraticBezierTo(pendingControl.Value, implied); + } + + pendingControl = point; + } + } + + if (pendingControl.HasValue) + { + builder.QuadraticBezierTo(pendingControl.Value, startPoint); + } + + builder.Close(); + } + + private static Vector2 Midpoint(Vector2 a, Vector2 b) => (a + b) / 2f; + + /// + /// Decodes a composite glyph, recursively resolving each component's glyph outline and + /// applying its offset/scale/2x2 transform. + /// + private Path DecodeCompositeGlyph(int glyphOffset, int glyphLength, int depth, ref int totalComponents) + { + var limit = glyphOffset + glyphLength; + var pos = glyphOffset + 10; + var builder = new PathBuilder(); + var hasContent = false; + + bool more; + do + { + EnsureAvailable(pos, 4, limit); + var flags = SfntContainer.ReadUInt16(_data, pos); + var componentGlyphIndex = SfntContainer.ReadUInt16(_data, pos + 2); + pos += 4; + + if ((flags & ArgsAreXyValues) == 0) + { + throw new InvalidDataException("Point-matched composite glyph components are not supported."); + } + + float dx, dy; + if ((flags & ArgsAreWords) != 0) + { + EnsureAvailable(pos, 4, limit); + dx = SfntContainer.ReadInt16(_data, pos); + dy = SfntContainer.ReadInt16(_data, pos + 2); + pos += 4; + } + else + { + EnsureAvailable(pos, 2, limit); + dx = unchecked((sbyte)_data[pos]); + dy = unchecked((sbyte)_data[pos + 1]); + pos += 2; + } + + float a = 1f, b = 0f, c = 0f, d = 1f; + if ((flags & WeHaveAScale) != 0) + { + EnsureAvailable(pos, 2, limit); + a = d = SfntContainer.ReadF2Dot14(_data, pos); + pos += 2; + } + else if ((flags & WeHaveAnXAndYScale) != 0) + { + EnsureAvailable(pos, 4, limit); + a = SfntContainer.ReadF2Dot14(_data, pos); + d = SfntContainer.ReadF2Dot14(_data, pos + 2); + pos += 4; + } + else if ((flags & WeHaveATwoByTwo) != 0) + { + EnsureAvailable(pos, 8, limit); + a = SfntContainer.ReadF2Dot14(_data, pos); + b = SfntContainer.ReadF2Dot14(_data, pos + 2); + c = SfntContainer.ReadF2Dot14(_data, pos + 4); + d = SfntContainer.ReadF2Dot14(_data, pos + 6); + pos += 8; + } + + var componentPath = DecodeGlyph(componentGlyphIndex, depth, ref totalComponents); + AppendTransformed(builder, componentPath, a, b, c, d, dx, dy); + hasContent = true; + + more = (flags & MoreComponents) != 0; + } + while (more); + + return hasContent ? builder.Build() : Path.Empty; + } + + /// + /// Re-issues every subpath of into , + /// applying the given 2x2 matrix (///) + /// and translation (/) to every point. + /// + private static void AppendTransformed(PathBuilder builder, Path source, float a, float b, float c, float d, float dx, float dy) + { + Vector2 Transform(Vector2 p) => new(a * p.X + c * p.Y + dx, b * p.X + d * p.Y + dy); + + foreach (var subpath in source.Subpaths) + { + builder.MoveTo(Transform(subpath.Start)); + foreach (var command in subpath.Commands) + { + switch (command.Type) + { + case PathCommandType.LineTo: + builder.LineTo(Transform(command.EndPoint)); + break; + + case PathCommandType.QuadraticBezierTo: + builder.QuadraticBezierTo(Transform(command.Control1), Transform(command.EndPoint)); + break; + + case PathCommandType.Close: + builder.Close(); + break; + + default: + // Glyph outlines produced by this reader only ever contain LineTo, + // QuadraticBezierTo, and Close commands. + throw new InvalidOperationException("Unexpected path command in a glyph outline."); + } + } + } + } + + /// + /// Validates that bytes starting at lie + /// within , throwing otherwise. + /// + private static void EnsureAvailable(int pos, int count, int limit) + { + if (pos < 0 || count < 0 || (long)pos + count > limit) + { + throw new InvalidDataException("Glyph data is truncated or malformed."); + } + } +} diff --git a/src/DemaConsulting.CanvasNet/Fonts/HmtxHheaReader.cs b/src/DemaConsulting.CanvasNet/Fonts/HmtxHheaReader.cs new file mode 100644 index 00000000..7af73358 --- /dev/null +++ b/src/DemaConsulting.CanvasNet/Fonts/HmtxHheaReader.cs @@ -0,0 +1,112 @@ +namespace DemaConsulting.CanvasNet.Fonts; + +/// +/// Parses a font's hhea (horizontal header) and hmtx (horizontal metrics) +/// tables, and resolves per-glyph advance widths. +/// +/// +/// hmtx's array holds numOfLongHorMetrics (from hhea) +/// (advanceWidth, leftSideBearing) pairs, followed by +/// (numGlyphs - numOfLongHorMetrics) left-side-bearing-only entries; a glyph index at +/// or beyond numOfLongHorMetrics reuses the last explicit advanceWidth +/// entry, per the OpenType hmtx "monospaced tail" convention. +/// +internal sealed class HmtxHheaReader +{ + /// + /// The fixed size, in bytes, of the hhea table. + /// + private const int HheaSize = 36; + + /// + /// The advance widths of every explicit hmtx entry (length numOfLongHorMetrics). + /// A glyph index at or beyond this array's length reuses the last entry. + /// + private readonly int[] _advanceWidths; + + /// + /// The typographic ascender, from hhea.ascender. + /// + public int Ascender { get; } + + /// + /// The typographic descender, from hhea.descender. + /// + public int Descender { get; } + + /// + /// The typographic line gap, from hhea.lineGap. + /// + public int LineGap { get; } + + private HmtxHheaReader(int ascender, int descender, int lineGap, int[] advanceWidths) + { + Ascender = ascender; + Descender = descender; + LineGap = lineGap; + _advanceWidths = advanceWidths; + } + + /// + /// Parses the hhea and hmtx tables. + /// + /// The complete font file contents. + /// The offset of the hhea table. + /// The length of the hhea table. + /// The offset of the hmtx table. + /// The length of the hmtx table. + /// The font's total glyph count, from maxp. + /// A new exposing the parsed metrics. + /// + /// Thrown when hhea is too short, when numOfLongHorMetrics is not in the + /// range (0, numGlyphs], or when hmtx is too short to contain the declared + /// number of entries. + /// + public static HmtxHheaReader Parse(byte[] data, int hheaOffset, int hheaLength, int hmtxOffset, int hmtxLength, int numGlyphs) + { + if (hheaLength < HheaSize) + { + throw new InvalidDataException("The 'hhea' table is too short."); + } + + var ascender = SfntContainer.ReadInt16(data, hheaOffset + 4); + var descender = SfntContainer.ReadInt16(data, hheaOffset + 6); + var lineGap = SfntContainer.ReadInt16(data, hheaOffset + 8); + var numOfLongHorMetrics = SfntContainer.ReadUInt16(data, hheaOffset + 34); + + if (numOfLongHorMetrics == 0 || numOfLongHorMetrics > numGlyphs) + { + throw new InvalidDataException( + $"'hhea.numOfLongHorMetrics' ({numOfLongHorMetrics}) must be in the range (0, {numGlyphs}]."); + } + + var tailCount = numGlyphs - numOfLongHorMetrics; + var requiredBytes = checked((long)numOfLongHorMetrics * 4 + (long)tailCount * 2); + if (requiredBytes > hmtxLength) + { + throw new InvalidDataException("The 'hmtx' table is too short for its declared metric counts."); + } + + var advanceWidths = new int[numOfLongHorMetrics]; + for (var i = 0; i < numOfLongHorMetrics; i++) + { + advanceWidths[i] = SfntContainer.ReadUInt16(data, hmtxOffset + i * 4); + } + + return new HmtxHheaReader(ascender, descender, lineGap, advanceWidths); + } + + /// + /// Looks up a glyph's advance width. + /// + /// + /// The glyph index to look up. Assumed to already be validated as within + /// [0, numGlyphs) by the caller. + /// + /// + /// The glyph's advance width, reusing the last explicit hmtx entry for any glyph + /// index at or beyond numOfLongHorMetrics. + /// + public int GetAdvanceWidth(int glyphIndex) => + glyphIndex < _advanceWidths.Length ? _advanceWidths[glyphIndex] : _advanceWidths[^1]; +} diff --git a/src/DemaConsulting.CanvasNet/Fonts/KernTable.cs b/src/DemaConsulting.CanvasNet/Fonts/KernTable.cs new file mode 100644 index 00000000..3ac207df --- /dev/null +++ b/src/DemaConsulting.CanvasNet/Fonts/KernTable.cs @@ -0,0 +1,178 @@ +namespace DemaConsulting.CanvasNet.Fonts; + +/// +/// Parses a font's kern table and resolves pairwise horizontal kerning adjustments, +/// using only subtable format 0 (ordered pair list). +/// +/// +/// Only the first subtable that is format 0 and carries horizontal-only coverage (coverage +/// bit 0 set, bits 1/2 clear) is used; every other subtable is skipped. A kern table +/// that is absent, empty, uses only unsupported subtable formats/coverage bits, or is +/// internally malformed (a declared pair count that does not fit the table's bounds) is +/// treated as "no kerning data" - this class never throws; always +/// returns 0 in that case. +/// +internal sealed class KernTable +{ + /// + /// A with no usable pairs: always returns + /// 0. Used when the font has no kern table, or none of its subtables qualify. + /// + public static readonly KernTable Empty = new([]); + + /// + /// The selected subtable's pairs, sorted ascending by (Left, Right) for binary search. + /// + private readonly (ushort Left, ushort Right, short Value)[] _pairs; + + private KernTable((ushort, ushort, short)[] pairs) + { + _pairs = pairs; + } + + /// + /// Parses a kern table and selects the first qualifying format-0, horizontal-only + /// subtable. + /// + /// The complete font file contents. + /// The offset of the kern table within . + /// The length of the kern table. + /// + /// A wrapping the selected subtable's pairs, or + /// if the table is malformed, or no qualifying subtable is found. + /// + public static KernTable Parse(byte[] data, int tableOffset, int tableLength) + { + if (tableLength < 4) + { + return Empty; + } + + var version = SfntContainer.ReadUInt16(data, tableOffset); + if (version != 0) + { + // Only the classic ('version 0 header') kern table layout is understood; the + // OpenType 'Apple' version-1 header uses an incompatible layout and is skipped + // entirely rather than being misinterpreted. + return Empty; + } + + var numSubtables = SfntContainer.ReadUInt16(data, tableOffset + 2); + var pos = tableOffset + 4; + var tableEnd = tableOffset + tableLength; + + for (var i = 0; i < numSubtables; i++) + { + if (pos + 6 > tableEnd) + { + return Empty; + } + + var subtableLength = SfntContainer.ReadUInt16(data, pos + 2); + var coverage = SfntContainer.ReadUInt16(data, pos + 4); + var subtableEnd = pos + subtableLength; + if (subtableLength < 6 || subtableEnd > tableEnd) + { + return Empty; + } + + var format = (coverage >> 8) & 0xFF; + var horizontal = (coverage & 0x1) != 0; + var minimum = (coverage & 0x2) != 0; + var crossStream = (coverage & 0x4) != 0; + + if (format == 0 && horizontal && !minimum && !crossStream) + { + var pairs = TryParseFormat0(data, pos + 6, subtableEnd); + if (pairs != null) + { + return new KernTable(pairs); + } + + return Empty; + } + + pos = subtableEnd; + } + + return Empty; + } + + /// + /// Looks up the kerning adjustment for a glyph pair. + /// + /// The left glyph index of the pair. + /// The right glyph index of the pair. + /// + /// The kerning adjustment for the pair, or 0 if no matching pair is present (or no + /// subtable was available at all). + /// + public int GetKerning(int leftGlyphIndex, int rightGlyphIndex) + { + if (leftGlyphIndex is < 0 or > ushort.MaxValue || rightGlyphIndex is < 0 or > ushort.MaxValue) + { + return 0; + } + + var left = (ushort)leftGlyphIndex; + var right = (ushort)rightGlyphIndex; + + var lo = 0; + var hi = _pairs.Length - 1; + while (lo <= hi) + { + var mid = lo + (hi - lo) / 2; + var pair = _pairs[mid]; + var comparison = pair.Left != left ? pair.Left.CompareTo(left) : pair.Right.CompareTo(right); + if (comparison == 0) + { + return pair.Value; + } + + if (comparison < 0) + { + lo = mid + 1; + } + else + { + hi = mid - 1; + } + } + + return 0; + } + + /// + /// Attempts to parse a format 0 subtable body (immediately following the shared 6-byte + /// subtable header), returning its pairs sorted for binary search, or + /// if the declared pair count does not fit the subtable's bounds. + /// + private static (ushort, ushort, short)[]? TryParseFormat0(byte[] data, int bodyOffset, int subtableEnd) + { + if (bodyOffset + 8 > subtableEnd) + { + return null; + } + + var nPairs = SfntContainer.ReadUInt16(data, bodyOffset); + var pairsOffset = bodyOffset + 8; + var requiredBytes = checked((long)nPairs * 6); + if (pairsOffset + requiredBytes > subtableEnd) + { + return null; + } + + var pairs = new (ushort, ushort, short)[nPairs]; + for (var i = 0; i < nPairs; i++) + { + var entryOffset = pairsOffset + i * 6; + var left = SfntContainer.ReadUInt16(data, entryOffset); + var right = SfntContainer.ReadUInt16(data, entryOffset + 2); + var value = SfntContainer.ReadInt16(data, entryOffset + 4); + pairs[i] = (left, right, value); + } + + Array.Sort(pairs, static (a, b) => a.Item1 != b.Item1 ? a.Item1.CompareTo(b.Item1) : a.Item2.CompareTo(b.Item2)); + return pairs; + } +} diff --git a/src/DemaConsulting.CanvasNet/Fonts/NamespaceDoc.cs b/src/DemaConsulting.CanvasNet/Fonts/NamespaceDoc.cs new file mode 100644 index 00000000..ec577439 --- /dev/null +++ b/src/DemaConsulting.CanvasNet/Fonts/NamespaceDoc.cs @@ -0,0 +1,19 @@ +namespace DemaConsulting.CanvasNet.Fonts; + +/// +/// The namespace provides TrueType +/// (glyf-based) SFNT font parsing through : container/table- +/// directory parsing, cmap character-to-glyph mapping, glyf/loca outline +/// extraction (including composite glyphs) into , +/// hmtx/hhea horizontal metrics, and basic (kern format 0) pairwise +/// kerning. CFF/OpenType (OTTO) outlines are out of scope and are rejected with a clear +/// rather than silently mis-parsed. +/// depends only on the +/// namespace: it produces vector geometry and metrics, and has no notion of pixels, surfaces, +/// or rasterization - callers combine its output with their own point size and orientation +/// before feeding it to or +/// . +/// +internal static class NamespaceDoc +{ +} diff --git a/src/DemaConsulting.CanvasNet/Fonts/SfntContainer.cs b/src/DemaConsulting.CanvasNet/Fonts/SfntContainer.cs new file mode 100644 index 00000000..4be146a8 --- /dev/null +++ b/src/DemaConsulting.CanvasNet/Fonts/SfntContainer.cs @@ -0,0 +1,193 @@ +namespace DemaConsulting.CanvasNet.Fonts; + +/// +/// Parses the SFNT offset table and table directory shared by TrueType (glyf-based) and +/// OpenType (CFF-based) font files, and hosts the shared big-endian primitive readers used +/// throughout the namespace. +/// +/// +/// +/// Only the TrueType outline flavor is accepted: sfntVersion must be +/// 0x00010000 or the classic Mac 'true' tag; the CFF/OpenType tag +/// 'OTTO' is explicitly rejected with , since CFF +/// outlines are out of scope for this namespace. +/// +/// +/// Every table directory entry's offset/length pair is validated against the +/// overall file length using arithmetic (never raw +/// addition), so a maliciously large offset or length can never overflow the bounds check +/// itself. +/// +/// +/// SFNT multi-byte fields are always big-endian regardless of host CPU/OS endianness; every +/// primitive reader here composes its result from individual bytes (never +/// or ), matching the convention already +/// established by /. +/// +/// +internal sealed class SfntContainer +{ + /// + /// The sfntVersion value used by TrueType-outline fonts. + /// + private const uint TrueTypeVersion = 0x00010000; + + /// + /// The classic Mac OS sfntVersion tag ('true') used by some TrueType-outline fonts. + /// + private const uint MacTrueVersion = 0x74727565; + + /// + /// The CFF/OpenType sfntVersion tag ('OTTO'), explicitly rejected since CFF + /// outlines are out of scope for this namespace. + /// + private const uint OttoVersion = 0x4F54544F; + + /// + /// The size, in bytes, of the 12-byte SFNT offset table. + /// + private const int OffsetTableSize = 12; + + /// + /// The size, in bytes, of a single table directory entry. + /// + private const int TableDirectoryEntrySize = 16; + + /// + /// Every table's raw byte range, keyed by its 4-character tag. + /// + private readonly Dictionary _tables; + + /// + /// Initializes a new container over an already-validated table directory. + /// + private SfntContainer(Dictionary tables) + { + _tables = tables; + } + + /// + /// Parses the SFNT offset table and table directory from . + /// + /// The complete, in-memory font file contents. + /// A new describing every table's byte range. + /// + /// Thrown when is too short to contain a 12-byte offset table or + /// its declared table directory, when sfntVersion is 'OTTO' (CFF/OpenType) + /// or any other value not recognized as TrueType, or when any table directory entry's + /// offset/length falls outside the bounds of . + /// + public static SfntContainer Parse(byte[] data) + { + if (data.Length < OffsetTableSize) + { + throw new InvalidDataException("Font data is too short to contain an SFNT offset table."); + } + + var version = ReadUInt32(data, 0); + if (version == OttoVersion) + { + throw new InvalidDataException( + "Font uses CFF/OpenType ('OTTO') outlines, which are not supported; only TrueType ('glyf') outlines are supported."); + } + + if (version != TrueTypeVersion && version != MacTrueVersion) + { + throw new InvalidDataException($"Unrecognized SFNT version 0x{version:X8}."); + } + + var numTables = ReadUInt16(data, 4); + var directoryEnd = checked((long)OffsetTableSize + (long)numTables * TableDirectoryEntrySize); + if (directoryEnd > data.Length) + { + throw new InvalidDataException("Font data is too short to contain its declared table directory."); + } + + var tables = new Dictionary(numTables, StringComparer.Ordinal); + for (var i = 0; i < numTables; i++) + { + var entryOffset = OffsetTableSize + i * TableDirectoryEntrySize; + var tag = ReadTag(data, entryOffset); + var tableOffset = ReadUInt32(data, entryOffset + 8); + var tableLength = ReadUInt32(data, entryOffset + 12); + + var end = checked((long)tableOffset + tableLength); + if (end > data.Length) + { + throw new InvalidDataException( + $"Table '{tag}' offset/length ({tableOffset}/{tableLength}) exceeds the font data bounds."); + } + + tables[tag] = ((int)tableOffset, (int)tableLength); + } + + return new SfntContainer(tables); + } + + /// + /// Attempts to look up a table's byte range by tag. + /// + /// The 4-character table tag, for example "glyf". + /// The table's offset and length within the font data, if found. + /// if the table is present; otherwise, . + public bool TryGetTable(string tag, out (int Offset, int Length) range) => _tables.TryGetValue(tag, out range); + + /// + /// Looks up a required table's byte range by tag, throwing if it is absent. + /// + /// The 4-character table tag, for example "glyf". + /// The table's offset and length within the font data. + /// Thrown when the table is not present. + public (int Offset, int Length) RequireTable(string tag) + { + if (!TryGetTable(tag, out var range)) + { + throw new InvalidDataException($"Required table '{tag}' is missing from the font."); + } + + return range; + } + + /// + /// Reads a 4-character ASCII table tag at the given offset. + /// + private static string ReadTag(byte[] data, int offset) => + string.Create(4, (data, offset), static (span, state) => + { + for (var i = 0; i < 4; i++) + { + span[i] = (char)state.data[state.offset + i]; + } + }); + + /// + /// Reads a big-endian, unsigned 16-bit integer at the given offset. + /// + public static ushort ReadUInt16(byte[] data, int offset) => + (ushort)((data[offset] << 8) | data[offset + 1]); + + /// + /// Reads a big-endian, signed 16-bit integer at the given offset. + /// + public static short ReadInt16(byte[] data, int offset) => (short)ReadUInt16(data, offset); + + /// + /// Reads a big-endian, unsigned 32-bit integer at the given offset. + /// + public static uint ReadUInt32(byte[] data, int offset) => + ((uint)data[offset] << 24) | + ((uint)data[offset + 1] << 16) | + ((uint)data[offset + 2] << 8) | + data[offset + 3]; + + /// + /// Reads a big-endian, signed 32-bit integer at the given offset. + /// + public static int ReadInt32(byte[] data, int offset) => (int)ReadUInt32(data, offset); + + /// + /// Reads a big-endian 2.14 fixed-point value (F2Dot14) at the given offset, as used + /// by composite glyph component transforms. + /// + public static float ReadF2Dot14(byte[] data, int offset) => ReadInt16(data, offset) / 16384f; +} diff --git a/src/DemaConsulting.CanvasNet/Fonts/TrueTypeFont.cs b/src/DemaConsulting.CanvasNet/Fonts/TrueTypeFont.cs new file mode 100644 index 00000000..efd17928 --- /dev/null +++ b/src/DemaConsulting.CanvasNet/Fonts/TrueTypeFont.cs @@ -0,0 +1,288 @@ +using DemaConsulting.CanvasNet.Geometry; +using Path = DemaConsulting.CanvasNet.Geometry.Path; + +namespace DemaConsulting.CanvasNet.Fonts; + +/// +/// Provides hand-rolled, dependency-free loading and querying of TrueType (glyf-based) +/// SFNT font files: metrics, character-to-glyph mapping, glyph outline extraction, advance +/// widths, and basic pairwise kerning. +/// +/// +/// +/// TrueTypeFont is the sole public unit of the namespace; it fronts +/// several internal, independently-parsed helpers (, +/// , , , +/// ) exactly as internally parses chunks +/// as one unit, and as fronts / +/// . +/// +/// +/// head, maxp (version 1.0 only - version 0.5 is CFF-oriented and +/// is rejected alongside CFF/OTTO outlines), hhea, hmtx, loca, and +/// glyf are required tables: a missing or structurally malformed one causes +/// / to throw +/// . cmap and kern are optional: many +/// legitimately embedded/subsetted fonts omit cmap entirely, and most fonts have no +/// kern table at all. Absence of cmap means always +/// returns 0 (.notdef); absence of kern means +/// always returns 0. +/// +/// +/// SFNT table checksums (including head.checkSumAdjustment) are not enforced as a +/// load-rejection condition: real-world TrueType fonts commonly carry stale or incorrect +/// checksums after ordinary subsetting/hinting/editing workflows. Structural validation +/// (bounds, overflow, required tables, internal glyph/cmap/kern consistency) protects against +/// malformed input instead. +/// +/// +/// returns a in raw font design units +/// (y-axis increasing upward, per TrueType/OpenType convention) - it is not scaled to +/// a point size and not flipped to a y-down pixel convention. +/// is exposed so callers combine it with their own point size and orientation. +/// +/// +public sealed class TrueTypeFont +{ + private readonly HmtxHheaReader _metrics; + private readonly GlyfLocaReader _glyphs; + private readonly CmapTable _cmap; + private readonly KernTable _kern; + + /// + /// The number of font design units per em, from head.unitsPerEm. + /// + public int UnitsPerEm { get; } + + /// + /// The typographic ascender, in font design units, from hhea.ascender. + /// + public int Ascender { get; } + + /// + /// The typographic descender, in font design units, from hhea.descender. + /// + public int Descender { get; } + + /// + /// The typographic line gap, in font design units, from hhea.lineGap. + /// + public int LineGap { get; } + + /// + /// The total number of glyphs in the font, from maxp.numGlyphs. + /// + public int GlyphCount { get; } + + private TrueTypeFont(int unitsPerEm, int glyphCount, HmtxHheaReader metrics, GlyfLocaReader glyphs, CmapTable cmap, KernTable kern) + { + UnitsPerEm = unitsPerEm; + GlyphCount = glyphCount; + Ascender = metrics.Ascender; + Descender = metrics.Descender; + LineGap = metrics.LineGap; + _metrics = metrics; + _glyphs = glyphs; + _cmap = cmap; + _kern = kern; + } + + /// + /// Loads a from an open, readable stream containing a TrueType + /// (glyf-based) SFNT font file. + /// + /// + /// The stream to read the font from. Reading begins at the stream's current position and + /// consumes the remainder of the stream. + /// + /// A new ready to be queried. + /// Thrown when is null. + /// + /// Thrown when does not contain a valid, supported TrueType + /// font: the SFNT version is not recognized, the font declares CFF/OpenType (OTTO) + /// outlines, a required table (head, maxp version 1.0, hhea, + /// hmtx, loca, glyf) is missing or structurally malformed, a table + /// directory entry's offset/length is out of bounds or would overflow, or the stream is + /// truncated. + /// + /// + /// + /// using var stream = File.OpenRead("font.ttf"); + /// var font = TrueTypeFont.Load(stream); + /// var glyphIndex = font.GetGlyphIndex('A'); + /// var outline = font.GetGlyphOutline(glyphIndex); // in raw font design units + /// + /// + public static TrueTypeFont Load(Stream stream) + { + ArgumentNullException.ThrowIfNull(stream); + + using var memory = new MemoryStream(); + stream.CopyTo(memory); + return LoadFromBytes(memory.ToArray()); + } + + /// + /// Loads a from a TrueType font file at the specified path. + /// + /// The path of the font file to load. Must not be null or empty. + /// A new ready to be queried. + /// Thrown when is null. + /// Thrown when is an empty string. + /// Thrown for the same conditions as . + /// + /// File-system exceptions (for example , + /// , , or + /// ) raised while opening propagate + /// uncaught to the caller. + /// + public static TrueTypeFont Load(string path) + { + ArgumentNullException.ThrowIfNull(path); + if (path.Length == 0) + { + throw new ArgumentException("Path must not be empty.", nameof(path)); + } + + using var stream = new FileStream(path, FileMode.Open, FileAccess.Read); + return Load(stream); + } + + /// + /// Looks up the glyph index mapped to a Unicode codepoint via the font's cmap table. + /// + /// The Unicode codepoint to look up. + /// + /// The mapped glyph index, or 0 (.notdef) if the codepoint is unmapped, or + /// the font has no cmap table, or no supported cmap subtable is present. + /// Never throws. + /// + public int GetGlyphIndex(int codepoint) => _cmap.GetGlyphIndex(codepoint); + + /// + /// Decodes a single glyph's outline. + /// + /// The glyph index to decode, as returned by . + /// + /// The glyph's outline, in raw font design units (see this class's remarks for the + /// coordinate-space contract), or for a glyph with no contour + /// data (for example space). + /// + /// + /// Thrown when is outside [0, GlyphCount). + /// + /// + /// Thrown when the glyph's contour data is malformed or truncated, when a composite + /// glyph's nesting depth (10) or total resolved component count (5000) exceeds its bound, + /// when a composite glyph contains a point-matched component, or when a composite glyph + /// references an out-of-range component glyph index. + /// + public Path GetGlyphOutline(int glyphIndex) + { + ValidateGlyphIndex(glyphIndex); + return _glyphs.GetGlyphOutline(glyphIndex); + } + + /// + /// Looks up a glyph's advance width. + /// + /// The glyph index to look up. + /// + /// The glyph's advance width, in font design units, reusing the last explicit hmtx + /// entry for any glyph index at or beyond hhea.numOfLongHorMetrics. + /// + /// + /// Thrown when is outside [0, GlyphCount). + /// + public int GetAdvanceWidth(int glyphIndex) + { + ValidateGlyphIndex(glyphIndex); + return _metrics.GetAdvanceWidth(glyphIndex); + } + + /// + /// Looks up the kerning adjustment for a glyph pair via the font's kern table. + /// + /// The left glyph index of the pair. + /// The right glyph index of the pair. + /// + /// The kerning adjustment, in font design units, or 0 if no matching pair is + /// present, the font has no kern table, or no qualifying kern subtable is + /// present. Never throws, even for an out-of-range glyph index. + /// + public int GetKerning(int leftGlyphIndex, int rightGlyphIndex) => _kern.GetKerning(leftGlyphIndex, rightGlyphIndex); + + /// + /// Validates a glyph index argument shared by and + /// . + /// + private void ValidateGlyphIndex(int glyphIndex) + { + if (glyphIndex < 0 || glyphIndex >= GlyphCount) + { + throw new ArgumentOutOfRangeException(nameof(glyphIndex), glyphIndex, "Glyph index is out of range."); + } + } + + /// + /// Parses the SFNT container, validates and parses every required table, and parses the + /// optional cmap/kern tables tolerantly. + /// + private static TrueTypeFont LoadFromBytes(byte[] data) + { + var container = SfntContainer.Parse(data); + + var head = container.RequireTable("head"); + var maxp = container.RequireTable("maxp"); + var hhea = container.RequireTable("hhea"); + var hmtx = container.RequireTable("hmtx"); + var loca = container.RequireTable("loca"); + var glyf = container.RequireTable("glyf"); + + const int headSize = 54; + if (head.Length < headSize) + { + throw new InvalidDataException("The 'head' table is too short."); + } + + var unitsPerEm = SfntContainer.ReadUInt16(data, head.Offset + 18); + if (unitsPerEm == 0) + { + throw new InvalidDataException("'head.unitsPerEm' must not be zero."); + } + + var indexToLocFormat = SfntContainer.ReadInt16(data, head.Offset + 50); + if (indexToLocFormat != 0 && indexToLocFormat != 1) + { + throw new InvalidDataException($"'head.indexToLocFormat' has an unsupported value ({indexToLocFormat})."); + } + + const uint maxpVersion10 = 0x00010000; + if (maxp.Length < 6) + { + throw new InvalidDataException("The 'maxp' table is too short."); + } + + var maxpVersion = SfntContainer.ReadUInt32(data, maxp.Offset); + if (maxpVersion != maxpVersion10) + { + throw new InvalidDataException( + "The 'maxp' table version is not 1.0 (0x00010000); CFF-oriented maxp (version 0.5) fonts are not supported."); + } + + var numGlyphs = SfntContainer.ReadUInt16(data, maxp.Offset + 4); + + var metrics = HmtxHheaReader.Parse(data, hhea.Offset, hhea.Length, hmtx.Offset, hmtx.Length, numGlyphs); + var glyphs = GlyfLocaReader.Parse(data, loca.Offset, loca.Length, glyf.Offset, glyf.Length, numGlyphs, indexToLocFormat == 1); + + var cmap = container.TryGetTable("cmap", out var cmapRange) + ? CmapTable.Parse(data, cmapRange.Offset, cmapRange.Length) + : CmapTable.Empty; + + var kern = container.TryGetTable("kern", out var kernRange) + ? KernTable.Parse(data, kernRange.Offset, kernRange.Length) + : KernTable.Empty; + + return new TrueTypeFont(unitsPerEm, numGlyphs, metrics, glyphs, cmap, kern); + } +} From 4be8c0c6deda07b0e4b7d78ad540f8fb091db8c9 Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Tue, 22 Sep 2026 11:32:34 -0400 Subject: [PATCH 02/17] Add Fonts unit tests, synthetic font builder, and system-integration tests --- .../CanvasNetTests.cs | 128 +++++ .../Fonts/CmapTableTests.cs | 158 ++++++ .../Fonts/GlyfLocaReaderTests.cs | 433 +++++++++++++++ .../Fonts/HmtxHheaReaderTests.cs | 101 ++++ .../Fonts/KernTableTests.cs | 145 +++++ .../Fonts/SfntContainerTests.cs | 228 ++++++++ .../Fonts/TrueTypeFontTests.cs | 300 ++++++++++ .../TestSupport/SyntheticFontBuilder.cs | 516 ++++++++++++++++++ 8 files changed, 2009 insertions(+) create mode 100644 test/DemaConsulting.CanvasNet.Tests/Fonts/CmapTableTests.cs create mode 100644 test/DemaConsulting.CanvasNet.Tests/Fonts/GlyfLocaReaderTests.cs create mode 100644 test/DemaConsulting.CanvasNet.Tests/Fonts/HmtxHheaReaderTests.cs create mode 100644 test/DemaConsulting.CanvasNet.Tests/Fonts/KernTableTests.cs create mode 100644 test/DemaConsulting.CanvasNet.Tests/Fonts/SfntContainerTests.cs create mode 100644 test/DemaConsulting.CanvasNet.Tests/Fonts/TrueTypeFontTests.cs create mode 100644 test/DemaConsulting.CanvasNet.Tests/TestSupport/SyntheticFontBuilder.cs diff --git a/test/DemaConsulting.CanvasNet.Tests/CanvasNetTests.cs b/test/DemaConsulting.CanvasNet.Tests/CanvasNetTests.cs index f3090b3c..4059da74 100644 --- a/test/DemaConsulting.CanvasNet.Tests/CanvasNetTests.cs +++ b/test/DemaConsulting.CanvasNet.Tests/CanvasNetTests.cs @@ -2,7 +2,9 @@ using DemaConsulting.CanvasNet.Canvas; using DemaConsulting.CanvasNet.Codecs; using DemaConsulting.CanvasNet.Drawing; +using DemaConsulting.CanvasNet.Fonts; using DemaConsulting.CanvasNet.Geometry; +using DemaConsulting.CanvasNet.Tests.TestSupport; namespace DemaConsulting.CanvasNet.Tests; @@ -395,4 +397,130 @@ public void CanvasNet_SystemIntegration_BuildAndFillTrianglePath_ReturnsExpected var edgePixel = surface[4, 4]; Assert.True(edgePixel.A > 0 && edgePixel.A < 255, $"Expected a fractional alpha, got {edgePixel.A}"); } + + /// + /// Builds a minimal, complete, well-formed synthetic TrueType font with a single simple + /// glyph (a diamond, mixing on-curve and off-curve quadratic points) mapped from codepoint + /// 'A', for the Fonts -> Geometry -> Drawing -> Canvas system-integration tests below. + /// + private static byte[] BuildSyntheticFontWithDiamondGlyph() + { + var glyph = SyntheticFontBuilder.SimpleGlyph( + [ + [(400, 0, true), (800, 400, false), (400, 800, true), (0, 400, false)] + ]); + + return new SyntheticFontBuilder() + .AddTable("head", SyntheticFontBuilder.Head(1000, 0)) + .AddTable("maxp", SyntheticFontBuilder.Maxp(2)) + .AddTable("hhea", SyntheticFontBuilder.Hhea(800, -200, 0, 2)) + .AddTable("hmtx", SyntheticFontBuilder.Hmtx([0, 1000])) + .AddTable("loca", SyntheticFontBuilder.Loca([0, glyph.Length], longFormat: false)) + .AddTable("glyf", glyph) + .AddTable("cmap", SyntheticFontBuilder.CmapFormat4(3, 1, [('A', 1)])) + .Build(); + } + + /// + /// Proves that the system can load a synthetic TrueType font through + /// 's public API, map a codepoint to a glyph index, extract that + /// glyph's outline as a (raw font-design-unit space), and fill it onto a + /// via - exercising the + /// Fonts -> Geometry -> Drawing -> Canvas integration end to end, and confirming + /// non-trivial rendered pixel coverage (an interior pixel is fully opaque, and at least + /// one edge pixel is antialiased to a fractional coverage). + /// + [Fact] + public void CanvasNet_SystemIntegration_LoadFontAndFillGlyphOutline_ReturnsExpectedPixels() + { + // Arrange: load a synthetic font, look up 'A', and extract its glyph outline in raw font + // design units (0..1000, matching this font's UnitsPerEm) + var fontData = BuildSyntheticFontWithDiamondGlyph(); + var font = TrueTypeFont.Load(new MemoryStream(fontData)); + var glyphIndex = font.GetGlyphIndex('A'); + var outline = font.GetGlyphOutline(glyphIndex); + + // Act: scale the raw font-unit outline down to fit a small surface, then fill it through + // the public PathFiller API + var surface = new Surface(10, 10); + const float scale = 10f / 1000f; + var scaledBuilder = new PathBuilder(); + var subpath = Assert.Single(outline.Subpaths); + scaledBuilder.MoveTo(subpath.Start * scale); + foreach (var command in subpath.Commands) + { + switch (command.Type) + { + case PathCommandType.QuadraticBezierTo: + scaledBuilder.QuadraticBezierTo(command.Control1 * scale, command.EndPoint * scale); + break; + case PathCommandType.LineTo: + scaledBuilder.LineTo(command.EndPoint * scale); + break; + case PathCommandType.Close: + scaledBuilder.Close(); + break; + } + } + + var scaledPath = scaledBuilder.Build(); + var color = new Rgba32(0, 128, 255, 255); + PathFiller.Fill(surface, scaledPath, color); + + // Assert: the glyph index resolved correctly, the outline decoded to a single non-trivial + // subpath, the diamond's center is fully covered, and at least one pixel near its edge is + // antialiased to a fractional (neither fully transparent nor fully opaque) coverage + Assert.Equal(1, glyphIndex); + Assert.Equal(3, subpath.Commands.Count); // 2 QuadraticBezierTo (implied on-curve joins) + Close + Assert.Equal(color, surface[4, 4]); + + var coveredPixelCount = 0; + var fractionalCoveragePixelCount = 0; + for (var y = 0; y < surface.Height; y++) + { + for (var x = 0; x < surface.Width; x++) + { + var alpha = surface[x, y].A; + if (alpha > 0) + { + coveredPixelCount++; + } + + if (alpha > 0 && alpha < 255) + { + fractionalCoveragePixelCount++; + } + } + } + + Assert.True(coveredPixelCount > 1, "Expected non-trivial rendered pixel coverage."); + Assert.True(fractionalCoveragePixelCount > 0, "Expected at least one antialiased edge pixel."); + } + + /// + /// Proves that the system reports zero advance-width/kerning-driven pen movement + /// differences between two glyphs of a synthetic font loaded through + /// 's public API consistently with its declared metrics, + /// confirming the Fonts namespace's metrics API integrates correctly end to end (a + /// second, independent system-integration test alongside the glyph-fill test above, per + /// this namespace's companion-artifact plan). + /// + [Fact] + public void CanvasNet_SystemIntegration_LoadFontAndQueryMetrics_ReturnsExpectedValues() + { + // Arrange/Act: load a synthetic font and query its top-level metrics and the glyph's + // advance width through the public API + var fontData = BuildSyntheticFontWithDiamondGlyph(); + var font = TrueTypeFont.Load(new MemoryStream(fontData)); + var glyphIndex = font.GetGlyphIndex('A'); + var advanceWidth = font.GetAdvanceWidth(glyphIndex); + + // Assert: the system's integrated metrics match the synthetic font's declared values + Assert.Equal(1000, font.UnitsPerEm); + Assert.Equal(800, font.Ascender); + Assert.Equal(-200, font.Descender); + Assert.Equal(2, font.GlyphCount); + Assert.Equal(1000, advanceWidth); + Assert.Equal(0, font.GetKerning(0, glyphIndex)); + } } diff --git a/test/DemaConsulting.CanvasNet.Tests/Fonts/CmapTableTests.cs b/test/DemaConsulting.CanvasNet.Tests/Fonts/CmapTableTests.cs new file mode 100644 index 00000000..18042d80 --- /dev/null +++ b/test/DemaConsulting.CanvasNet.Tests/Fonts/CmapTableTests.cs @@ -0,0 +1,158 @@ +using DemaConsulting.CanvasNet.Fonts; +using DemaConsulting.CanvasNet.Tests.TestSupport; + +namespace DemaConsulting.CanvasNet.Tests.Fonts; + +/// +/// Unit tests for . +/// +public class CmapTableTests +{ + /// + /// Proves that CmapTable Format4 BmpLookup ReturnsMappedGlyphIndex. + /// + [Fact] + public void CmapTable_Format4_BmpLookup_ReturnsMappedGlyphIndex() + { + var table = SyntheticFontBuilder.CmapFormat4(3, 1, [(65, 3), (66, 4)]); + + var cmap = CmapTable.Parse(table, 0, table.Length); + + Assert.Equal(3, cmap.GetGlyphIndex(65)); + Assert.Equal(4, cmap.GetGlyphIndex(66)); + } + + /// + /// Proves that CmapTable Format4 UnmappedCodepoint ReturnsZero. + /// + [Fact] + public void CmapTable_Format4_UnmappedCodepoint_ReturnsZero() + { + var table = SyntheticFontBuilder.CmapFormat4(3, 1, [(65, 3)]); + + var cmap = CmapTable.Parse(table, 0, table.Length); + + Assert.Equal(0, cmap.GetGlyphIndex(999)); + } + + /// + /// Proves that CmapTable Format12 SupplementaryPlaneLookup ReturnsMappedGlyphIndex. + /// + [Fact] + public void CmapTable_Format12_SupplementaryPlaneLookup_ReturnsMappedGlyphIndex() + { + var table = SyntheticFontBuilder.CmapFormat12(3, 10, [(0x1F600u, 0x1F600u, 5u)]); + + var cmap = CmapTable.Parse(table, 0, table.Length); + + Assert.Equal(5, cmap.GetGlyphIndex(0x1F600)); + } + + /// + /// Proves that CmapTable Format12 GroupRange ReturnsOffsetGlyphIndex. + /// + [Fact] + public void CmapTable_Format12_GroupRange_ReturnsOffsetGlyphIndex() + { + var table = SyntheticFontBuilder.CmapFormat12(3, 10, [(0x100u, 0x110u, 50u)]); + + var cmap = CmapTable.Parse(table, 0, table.Length); + + Assert.Equal(55, cmap.GetGlyphIndex(0x105)); + Assert.Equal(0, cmap.GetGlyphIndex(0x111)); + } + + /// + /// Proves that CmapTable Platform3Encoding10Format12 HighestPriority ReturnsMappedGlyphIndex. + /// + [Fact] + public void CmapTable_Platform3Encoding10Format12_HighestPriority_ReturnsMappedGlyphIndex() + { + // (3,10) format-12 is priority 0 (the highest-priority subtable this implementation + // recognizes) - confirms it is selected and produces a correct lookup. + var table = SyntheticFontBuilder.CmapFormat12(3, 10, [(65u, 65u, 9u)]); + + var cmap = CmapTable.Parse(table, 0, table.Length); + + Assert.Equal(9, cmap.GetGlyphIndex(65)); + } + + /// + /// Proves that CmapTable NoSupportedSubtable GetGlyphIndexReturnsZero. + /// + [Fact] + public void CmapTable_NoSupportedSubtable_GetGlyphIndexReturnsZero() + { + // Platform/encoding pair (1,0) - classic Mac Roman - is never selected by this + // implementation, regardless of subtable format. + var table = SyntheticFontBuilder.CmapFormat4(1, 0, [(65, 3)]); + + var cmap = CmapTable.Parse(table, 0, table.Length); + + Assert.Equal(0, cmap.GetGlyphIndex(65)); + } + + /// + /// Proves that CmapTable Empty GetGlyphIndexReturnsZero. + /// + [Fact] + public void CmapTable_Empty_GetGlyphIndexReturnsZero() + { + Assert.Equal(0, CmapTable.Empty.GetGlyphIndex(65)); + } + + /// + /// Proves that CmapTable TruncatedTable GetGlyphIndexReturnsZero. + /// + [Fact] + public void CmapTable_TruncatedTable_GetGlyphIndexReturnsZero() + { + var cmap = CmapTable.Parse([0, 0], 0, 2); + + Assert.Equal(0, cmap.GetGlyphIndex(65)); + } + + /// + /// Proves that CmapTable MalformedSubtableOffset GetGlyphIndexReturnsZero. + /// + [Fact] + public void CmapTable_MalformedSubtableOffset_GetGlyphIndexReturnsZero() + { + var buf = new List + { + 0, 0, // version + 0, 1, // numTables + 0, 3, // platformId + 0, 1, // encodingId + }; + SyntheticFontBuilder.WriteUInt32(buf, 100000); // subtable offset far past the table end + + var data = buf.ToArray(); + var cmap = CmapTable.Parse(data, 0, data.Length); + + Assert.Equal(0, cmap.GetGlyphIndex(65)); + } + + /// + /// Proves that CmapTable UnrecognizedSubtableFormat GetGlyphIndexReturnsZero. + /// + [Fact] + public void CmapTable_UnrecognizedSubtableFormat_GetGlyphIndexReturnsZero() + { + // Format 6 (trimmed table mapping) is not understood by this implementation. + var buf = new List + { + 0, 0, // version + 0, 1, // numTables + 0, 3, // platformId + 0, 1, // encodingId + }; + SyntheticFontBuilder.WriteUInt32(buf, 12); // subtable offset + SyntheticFontBuilder.WriteUInt16(buf, 6); // format 6 - unsupported + + var data = buf.ToArray(); + var cmap = CmapTable.Parse(data, 0, data.Length); + + Assert.Equal(0, cmap.GetGlyphIndex(65)); + } +} diff --git a/test/DemaConsulting.CanvasNet.Tests/Fonts/GlyfLocaReaderTests.cs b/test/DemaConsulting.CanvasNet.Tests/Fonts/GlyfLocaReaderTests.cs new file mode 100644 index 00000000..2159157e --- /dev/null +++ b/test/DemaConsulting.CanvasNet.Tests/Fonts/GlyfLocaReaderTests.cs @@ -0,0 +1,433 @@ +using DemaConsulting.CanvasNet.Fonts; +using DemaConsulting.CanvasNet.Geometry; +using DemaConsulting.CanvasNet.Tests.TestSupport; + +namespace DemaConsulting.CanvasNet.Tests.Fonts; + +/// +/// Unit tests for . +/// +public class GlyfLocaReaderTests +{ + /// + /// Packs a loca byte array and a set of already-encoded glyph byte arrays into a + /// single combined buffer, returning a ready-to-parse . + /// + private static GlyfLocaReader BuildReader(IReadOnlyList glyphs, bool longFormat = false) + { + var glyphLengths = glyphs.Select(g => g.Length).ToArray(); + var loca = SyntheticFontBuilder.Loca(glyphLengths, longFormat); + + var buf = new List(); + buf.AddRange(loca); + var glyfOffset = buf.Count; + foreach (var glyph in glyphs) + { + buf.AddRange(glyph); + } + + var data = buf.ToArray(); + return GlyfLocaReader.Parse(data, 0, loca.Length, glyfOffset, data.Length - glyfOffset, glyphs.Count, longFormat); + } + + /// + /// Proves that GlyfLocaReader SimpleGlyph AllOnCurve ProducesLineSegments. + /// + [Fact] + public void GlyfLocaReader_SimpleGlyph_AllOnCurve_ProducesLineSegments() + { + var glyph = SyntheticFontBuilder.SimpleGlyph( + [ + [(0, 0, true), (100, 0, true), (100, 100, true), (0, 100, true)] + ]); + + var reader = BuildReader([glyph]); + var path = reader.GetGlyphOutline(0); + + Assert.Single(path.Subpaths); + var subpath = path.Subpaths[0]; + Assert.Equal(new System.Numerics.Vector2(0, 0), subpath.Start); + Assert.All(subpath.Commands, c => Assert.True(c.Type is PathCommandType.LineTo or PathCommandType.Close)); + } + + /// + /// Proves that GlyfLocaReader SimpleGlyph ConsecutiveOffCurvePoints ProducesImpliedMidpointQuadratics. + /// + [Fact] + public void GlyfLocaReader_SimpleGlyph_ConsecutiveOffCurvePoints_ProducesImpliedMidpointQuadratics() + { + // A four-point "diamond" contour, entirely off-curve, forces the implied-on-curve + // midpoint convention for every segment (including the synthetic contour start). + var glyph = SyntheticFontBuilder.SimpleGlyph( + [ + [(50, 0, false), (100, 50, false), (50, 100, false), (0, 50, false)] + ]); + + var reader = BuildReader([glyph]); + var path = reader.GetGlyphOutline(0); + + var subpath = Assert.Single(path.Subpaths); + Assert.Contains(subpath.Commands, c => c.Type == PathCommandType.QuadraticBezierTo); + Assert.All(subpath.Commands, c => Assert.True(c.Type is PathCommandType.QuadraticBezierTo or PathCommandType.Close)); + } + + /// + /// Proves that GlyfLocaReader SimpleGlyph MixedOnAndOffCurve ProducesQuadraticToRealOnCurvePoint. + /// + [Fact] + public void GlyfLocaReader_SimpleGlyph_MixedOnAndOffCurve_ProducesQuadraticToRealOnCurvePoint() + { + var glyph = SyntheticFontBuilder.SimpleGlyph( + [ + [(0, 0, true), (50, 50, false), (100, 0, true), (50, -50, false)] + ]); + + var reader = BuildReader([glyph]); + var path = reader.GetGlyphOutline(0); + + var subpath = Assert.Single(path.Subpaths); + Assert.Equal(new System.Numerics.Vector2(0, 0), subpath.Start); + Assert.Contains(subpath.Commands, c => c.Type == PathCommandType.QuadraticBezierTo); + } + + /// + /// Proves that GlyfLocaReader EmptyGlyph ZeroContours ProducesEmptyPath. + /// + [Fact] + public void GlyfLocaReader_EmptyGlyph_ZeroContours_ProducesEmptyPath() + { + var glyph = SyntheticFontBuilder.SimpleGlyph(); + + var reader = BuildReader([glyph]); + var path = reader.GetGlyphOutline(0); + + Assert.Empty(path.Subpaths); + } + + /// + /// Proves that GlyfLocaReader ZeroLengthGlyph ProducesEmptyPath. + /// + [Fact] + public void GlyfLocaReader_ZeroLengthGlyph_ProducesEmptyPath() + { + // A loca entry pair with equal offsets (e.g. the classic 'space' glyph with no outline). + var reader = BuildReader([[], SyntheticFontBuilder.SimpleGlyph([[(0, 0, true), (10, 0, true), (10, 10, true)]])]); + + var path = reader.GetGlyphOutline(0); + + Assert.Empty(path.Subpaths); + } + + /// + /// Proves that GlyfLocaReader LongLocaFormat DecodesSameAsShortFormat. + /// + [Fact] + public void GlyfLocaReader_LongLocaFormat_DecodesSameAsShortFormat() + { + var glyph = SyntheticFontBuilder.SimpleGlyph([[(0, 0, true), (10, 0, true), (10, 10, true)]]); + + var reader = BuildReader([glyph], longFormat: true); + var path = reader.GetGlyphOutline(0); + + Assert.Single(path.Subpaths); + } + + /// + /// Proves that GlyfLocaReader GetGlyphOutline NegativeGlyphIndex ThrowsArgumentOutOfRangeException. + /// + [Fact] + public void GlyfLocaReader_GetGlyphOutline_NegativeGlyphIndex_ThrowsArgumentOutOfRangeException() + { + var reader = BuildReader([SyntheticFontBuilder.SimpleGlyph()]); + + Assert.Throws(() => reader.GetGlyphOutline(-1)); + } + + /// + /// Proves that GlyfLocaReader GetGlyphOutline GlyphIndexTooLarge ThrowsArgumentOutOfRangeException. + /// + [Fact] + public void GlyfLocaReader_GetGlyphOutline_GlyphIndexTooLarge_ThrowsArgumentOutOfRangeException() + { + var reader = BuildReader([SyntheticFontBuilder.SimpleGlyph()]); + + Assert.Throws(() => reader.GetGlyphOutline(1)); + } + + /// + /// Proves that GlyfLocaReader CompositeGlyph SingleComponent ProducesTranslatedOutline. + /// + [Fact] + public void GlyfLocaReader_CompositeGlyph_SingleComponent_ProducesTranslatedOutline() + { + var square = SyntheticFontBuilder.SimpleGlyph( + [ + [(0, 0, true), (10, 0, true), (10, 10, true), (0, 10, true)] + ]); + var composite = SyntheticFontBuilder.CompositeGlyph( + [ + new SyntheticFontBuilder.CompositeComponent(0, 100, 200) + ]); + + var reader = BuildReader([square, composite]); + var path = reader.GetGlyphOutline(1); + + var subpath = Assert.Single(path.Subpaths); + Assert.Equal(new System.Numerics.Vector2(100, 200), subpath.Start); + } + + /// + /// Proves that GlyfLocaReader CompositeGlyph ScaledComponent ScalesOutline. + /// + [Fact] + public void GlyfLocaReader_CompositeGlyph_ScaledComponent_ScalesOutline() + { + var square = SyntheticFontBuilder.SimpleGlyph( + [ + [(0, 0, true), (10, 0, true), (10, 10, true), (0, 10, true)] + ]); + var composite = SyntheticFontBuilder.CompositeGlyph( + [ + new SyntheticFontBuilder.CompositeComponent(0, 0, 0, HasScale: true, A: 1.5f, D: 1.5f) + ]); + + var reader = BuildReader([square, composite]); + var path = reader.GetGlyphOutline(1); + + var subpath = Assert.Single(path.Subpaths); + var lineTo = subpath.Commands[0]; + Assert.Equal(PathCommandType.LineTo, lineTo.Type); + Assert.Equal(15f, lineTo.EndPoint.X, 3); + } + + /// + /// Proves that GlyfLocaReader CompositeGlyph TwoByTwoTransform TransformsOutline. + /// + [Fact] + public void GlyfLocaReader_CompositeGlyph_TwoByTwoTransform_TransformsOutline() + { + var square = SyntheticFontBuilder.SimpleGlyph( + [ + [(0, 0, true), (10, 0, true), (10, 10, true), (0, 10, true)] + ]); + + // 90-degree counter-clockwise rotation matrix: a=0, b=1, c=-1, d=0 maps (x, y) -> (-y, x). + var composite = SyntheticFontBuilder.CompositeGlyph( + [ + new SyntheticFontBuilder.CompositeComponent(0, 0, 0, HasTwoByTwo: true, A: 0f, B: 1f, C: -1f, D: 0f) + ]); + + var reader = BuildReader([square, composite]); + var path = reader.GetGlyphOutline(1); + + var subpath = Assert.Single(path.Subpaths); + var lineTo = subpath.Commands[0]; + // The first LineTo draws to the untransformed point (10, 0), which the rotation maps to (0, 10). + Assert.Equal(0f, lineTo.EndPoint.X, 2); + Assert.Equal(10f, lineTo.EndPoint.Y, 2); + } + + /// + /// Proves that GlyfLocaReader CompositeGlyph NestedComposite ResolvesRecursively. + /// + [Fact] + public void GlyfLocaReader_CompositeGlyph_NestedComposite_ResolvesRecursively() + { + var square = SyntheticFontBuilder.SimpleGlyph( + [ + [(0, 0, true), (10, 0, true), (10, 10, true), (0, 10, true)] + ]); + var inner = SyntheticFontBuilder.CompositeGlyph( + [ + new SyntheticFontBuilder.CompositeComponent(0, 5, 5) + ]); + var outer = SyntheticFontBuilder.CompositeGlyph( + [ + new SyntheticFontBuilder.CompositeComponent(1, 100, 100) + ]); + + var reader = BuildReader([square, inner, outer]); + var path = reader.GetGlyphOutline(2); + + var subpath = Assert.Single(path.Subpaths); + Assert.Equal(new System.Numerics.Vector2(105, 105), subpath.Start); + } + + /// + /// Proves that GlyfLocaReader CompositeGlyph MultipleComponents ProducesMultipleSubpaths. + /// + [Fact] + public void GlyfLocaReader_CompositeGlyph_MultipleComponents_ProducesMultipleSubpaths() + { + var square = SyntheticFontBuilder.SimpleGlyph( + [ + [(0, 0, true), (10, 0, true), (10, 10, true), (0, 10, true)] + ]); + var composite = SyntheticFontBuilder.CompositeGlyph( + [ + new SyntheticFontBuilder.CompositeComponent(0, 0, 0), + new SyntheticFontBuilder.CompositeComponent(0, 50, 50) + ]); + + var reader = BuildReader([square, composite]); + var path = reader.GetGlyphOutline(1); + + Assert.Equal(2, path.Subpaths.Count); + } + + /// + /// Proves that GlyfLocaReader CompositeGlyph PointMatchedComponent ThrowsInvalidDataException. + /// + [Fact] + public void GlyfLocaReader_CompositeGlyph_PointMatchedComponent_ThrowsInvalidDataException() + { + var square = SyntheticFontBuilder.SimpleGlyph( + [ + [(0, 0, true), (10, 0, true), (10, 10, true), (0, 10, true)] + ]); + var composite = SyntheticFontBuilder.CompositeGlyph( + [ + new SyntheticFontBuilder.CompositeComponent(0, 0, 0, ArgsAreXyValues: false) + ]); + + var reader = BuildReader([square, composite]); + + Assert.Throws(() => reader.GetGlyphOutline(1)); + } + + /// + /// Proves that GlyfLocaReader CompositeGlyph OutOfRangeComponentGlyphIndex ThrowsInvalidDataException. + /// + [Fact] + public void GlyfLocaReader_CompositeGlyph_OutOfRangeComponentGlyphIndex_ThrowsInvalidDataException() + { + var composite = SyntheticFontBuilder.CompositeGlyph( + [ + new SyntheticFontBuilder.CompositeComponent(99, 0, 0) + ]); + + var reader = BuildReader([composite]); + + Assert.Throws(() => reader.GetGlyphOutline(0)); + } + + /// + /// Proves that GlyfLocaReader CompositeGlyph SelfReferentialCycle ThrowsInvalidDataExceptionViaDepthCap. + /// + [Fact] + public void GlyfLocaReader_CompositeGlyph_SelfReferentialCycle_ThrowsInvalidDataExceptionViaDepthCap() + { + // Glyph 0 is a composite whose only component is itself: recursion depth increases + // without bound until the deterministic depth cap trips - this must never hang. + var selfReferential = SyntheticFontBuilder.CompositeGlyph( + [ + new SyntheticFontBuilder.CompositeComponent(0, 1, 1) + ]); + + var reader = BuildReader([selfReferential]); + + var ex = Assert.Throws(() => reader.GetGlyphOutline(0)); + Assert.Contains("depth", ex.Message, StringComparison.OrdinalIgnoreCase); + } + + /// + /// Proves that GlyfLocaReader CompositeGlyph ExponentialBlowUp ThrowsInvalidDataExceptionViaComponentCap. + /// + [Fact] + public void GlyfLocaReader_CompositeGlyph_ExponentialBlowUp_ThrowsInvalidDataExceptionViaComponentCap() + { + // A 4-level chain, each with 10 components referencing the next level (branching factor + // 10, depth 4 <= MaxDepth): total resolved components is on the order of 10 + 100 + 1000 + // + 10000, far exceeding the 5000 total-component cap, while nesting depth never + // approaches the separate depth cap - proving the component-count cap (not the depth + // cap) is what prevents the hang here. + var leaf = SyntheticFontBuilder.SimpleGlyph([[(0, 0, true), (1, 0, true), (1, 1, true)]]); + + byte[] MakeLevel(int referencedGlyphIndex) => + SyntheticFontBuilder.CompositeGlyph( + Enumerable.Range(0, 10) + .Select(i => new SyntheticFontBuilder.CompositeComponent(referencedGlyphIndex, i, i)) + .ToArray()); + + var level3 = MakeLevel(0); // glyph 1: 10 refs to leaf (glyph 0) + var level2 = MakeLevel(1); // glyph 2: 10 refs to glyph 1 + var level1 = MakeLevel(2); // glyph 3: 10 refs to glyph 2 + var level0 = MakeLevel(3); // glyph 4: 10 refs to glyph 3 (the top-level glyph under test) + + var reader = BuildReader([leaf, level3, level2, level1, level0]); + + var ex = Assert.Throws(() => reader.GetGlyphOutline(4)); + Assert.Contains("too many", ex.Message, StringComparison.OrdinalIgnoreCase); + } + + /// + /// Proves that GlyfLocaReader Parse LocaEntryExceedsGlyfBounds ThrowsInvalidDataException. + /// + [Fact] + public void GlyfLocaReader_Parse_LocaEntryExceedsGlyfBounds_ThrowsInvalidDataException() + { + var loca = SyntheticFontBuilder.Loca([100], longFormat: false); + var data = new byte[loca.Length + 10]; + loca.CopyTo(data, 0); + + Assert.Throws(() => GlyfLocaReader.Parse(data, 0, loca.Length, loca.Length, 10, 1, false)); + } + + /// + /// Proves that GlyfLocaReader Parse NonMonotonicLocaEntries ThrowsInvalidDataException. + /// + [Fact] + public void GlyfLocaReader_Parse_NonMonotonicLocaEntries_ThrowsInvalidDataException() + { + var buf = new List(); + SyntheticFontBuilder.WriteUInt16(buf, 0); // glyph 0: offset 0 + SyntheticFontBuilder.WriteUInt16(buf, 5); // glyph 1 end / glyph 2 start: offset 10 + SyntheticFontBuilder.WriteUInt16(buf, 3); // glyph 2 end: offset 6 - decreases from 10 + var loca = buf.ToArray(); + var data = new byte[loca.Length + 10]; + loca.CopyTo(data, 0); + + Assert.Throws(() => GlyfLocaReader.Parse(data, 0, loca.Length, loca.Length, 10, 2, false)); + } + + /// + /// Proves that GlyfLocaReader Parse TruncatedLoca ThrowsInvalidDataException. + /// + [Fact] + public void GlyfLocaReader_Parse_TruncatedLoca_ThrowsInvalidDataException() + { + var loca = new byte[2]; // needs (numGlyphs + 1) * 2 = 4 bytes for numGlyphs=1 + + Assert.Throws(() => GlyfLocaReader.Parse(loca, 0, loca.Length, 2, 0, 1, false)); + } + + /// + /// Proves that GlyfLocaReader TruncatedGlyphHeader ThrowsInvalidDataException. + /// + [Fact] + public void GlyfLocaReader_TruncatedGlyphHeader_ThrowsInvalidDataException() + { + var reader = BuildReader([new byte[5]]); // shorter than the 10-byte glyph header + + Assert.Throws(() => reader.GetGlyphOutline(0)); + } + + /// + /// Proves that GlyfLocaReader InvalidContourCount ThrowsInvalidDataException. + /// + [Fact] + public void GlyfLocaReader_InvalidContourCount_ThrowsInvalidDataException() + { + var buf = new List(); + SyntheticFontBuilder.WriteInt16(buf, -2); // invalid: only -1 (composite) or >=0 are valid + for (var i = 0; i < 4; i++) + { + SyntheticFontBuilder.WriteInt16(buf, 0); + } + + var glyph = buf.ToArray(); + var reader = BuildReader([glyph]); + + Assert.Throws(() => reader.GetGlyphOutline(0)); + } +} diff --git a/test/DemaConsulting.CanvasNet.Tests/Fonts/HmtxHheaReaderTests.cs b/test/DemaConsulting.CanvasNet.Tests/Fonts/HmtxHheaReaderTests.cs new file mode 100644 index 00000000..ee6ba656 --- /dev/null +++ b/test/DemaConsulting.CanvasNet.Tests/Fonts/HmtxHheaReaderTests.cs @@ -0,0 +1,101 @@ +using DemaConsulting.CanvasNet.Fonts; +using DemaConsulting.CanvasNet.Tests.TestSupport; + +namespace DemaConsulting.CanvasNet.Tests.Fonts; + +/// +/// Unit tests for . +/// +public class HmtxHheaReaderTests +{ + /// + /// Packs an already-encoded hhea byte array and an already-encoded hmtx byte + /// array into a single combined buffer (the two tables are independent regions of the + /// same font file, per the real contract) and parses it. + /// + private static HmtxHheaReader Parse(byte[] hhea, byte[] hmtx, int numGlyphs) + { + var data = new byte[hhea.Length + hmtx.Length]; + hhea.CopyTo(data, 0); + hmtx.CopyTo(data, hhea.Length); + return HmtxHheaReader.Parse(data, 0, hhea.Length, hhea.Length, hmtx.Length, numGlyphs); + } + + /// + /// Proves that HmtxHheaReader Parse ExposesMetrics. + /// + [Fact] + public void HmtxHheaReader_Parse_ExposesMetrics() + { + var hhea = SyntheticFontBuilder.Hhea(2048, -512, 100, 2); + var hmtx = SyntheticFontBuilder.Hmtx([600, 700]); + + var reader = Parse(hhea, hmtx, 2); + + Assert.Equal(2048, reader.Ascender); + Assert.Equal(-512, reader.Descender); + Assert.Equal(100, reader.LineGap); + Assert.Equal(600, reader.GetAdvanceWidth(0)); + Assert.Equal(700, reader.GetAdvanceWidth(1)); + } + + /// + /// Proves that HmtxHheaReader GetAdvanceWidth TailGlyph ReusesLastEntry. + /// + [Fact] + public void HmtxHheaReader_GetAdvanceWidth_TailGlyph_ReusesLastEntry() + { + var hhea = SyntheticFontBuilder.Hhea(2048, -512, 100, 2); + var hmtx = SyntheticFontBuilder.Hmtx([600, 700], tailLsbCount: 3); + + var reader = Parse(hhea, hmtx, 5); + + Assert.Equal(700, reader.GetAdvanceWidth(2)); + Assert.Equal(700, reader.GetAdvanceWidth(4)); + } + + /// + /// Proves that HmtxHheaReader Parse TruncatedHhea ThrowsInvalidDataException. + /// + [Fact] + public void HmtxHheaReader_Parse_TruncatedHhea_ThrowsInvalidDataException() + { + var hhea = new byte[10]; + + Assert.Throws(() => HmtxHheaReader.Parse(hhea, 0, hhea.Length, 0, 100, 2)); + } + + /// + /// Proves that HmtxHheaReader Parse NumOfLongHorMetricsZero ThrowsInvalidDataException. + /// + [Fact] + public void HmtxHheaReader_Parse_NumOfLongHorMetricsZero_ThrowsInvalidDataException() + { + var hhea = SyntheticFontBuilder.Hhea(2048, -512, 100, 0); + + Assert.Throws(() => HmtxHheaReader.Parse(hhea, 0, hhea.Length, 0, 100, 2)); + } + + /// + /// Proves that HmtxHheaReader Parse NumOfLongHorMetricsExceedsNumGlyphs ThrowsInvalidDataException. + /// + [Fact] + public void HmtxHheaReader_Parse_NumOfLongHorMetricsExceedsNumGlyphs_ThrowsInvalidDataException() + { + var hhea = SyntheticFontBuilder.Hhea(2048, -512, 100, 10); + + Assert.Throws(() => HmtxHheaReader.Parse(hhea, 0, hhea.Length, 0, 100, 2)); + } + + /// + /// Proves that HmtxHheaReader Parse TruncatedHmtx ThrowsInvalidDataException. + /// + [Fact] + public void HmtxHheaReader_Parse_TruncatedHmtx_ThrowsInvalidDataException() + { + var hhea = SyntheticFontBuilder.Hhea(2048, -512, 100, 2); + var hmtx = SyntheticFontBuilder.Hmtx([600]); // only 1 entry, but hhea declares 2 + + Assert.Throws(() => Parse(hhea, hmtx, 2)); + } +} diff --git a/test/DemaConsulting.CanvasNet.Tests/Fonts/KernTableTests.cs b/test/DemaConsulting.CanvasNet.Tests/Fonts/KernTableTests.cs new file mode 100644 index 00000000..4d2a9303 --- /dev/null +++ b/test/DemaConsulting.CanvasNet.Tests/Fonts/KernTableTests.cs @@ -0,0 +1,145 @@ +using DemaConsulting.CanvasNet.Fonts; +using DemaConsulting.CanvasNet.Tests.TestSupport; + +namespace DemaConsulting.CanvasNet.Tests.Fonts; + +/// +/// Unit tests for . +/// +public class KernTableTests +{ + /// + /// Proves that KernTable Format0 KnownPair ReturnsValue. + /// + [Fact] + public void KernTable_Format0_KnownPair_ReturnsValue() + { + var table = SyntheticFontBuilder.KernFormat0([(3, 4, 120), (5, 6, -30)]); + + var kern = KernTable.Parse(table, 0, table.Length); + + Assert.Equal(120, kern.GetKerning(3, 4)); + Assert.Equal(-30, kern.GetKerning(5, 6)); + } + + /// + /// Proves that KernTable Format0 UnknownPair ReturnsZero. + /// + [Fact] + public void KernTable_Format0_UnknownPair_ReturnsZero() + { + var table = SyntheticFontBuilder.KernFormat0([(3, 4, 120)]); + + var kern = KernTable.Parse(table, 0, table.Length); + + Assert.Equal(0, kern.GetKerning(4, 3)); + Assert.Equal(0, kern.GetKerning(99, 100)); + } + + /// + /// Proves that KernTable Empty GetKerningReturnsZero. + /// + [Fact] + public void KernTable_Empty_GetKerningReturnsZero() + { + Assert.Equal(0, KernTable.Empty.GetKerning(3, 4)); + } + + /// + /// Proves that KernTable NoPairs ReturnsZero. + /// + [Fact] + public void KernTable_NoPairs_ReturnsZero() + { + var table = SyntheticFontBuilder.KernFormat0([]); + + var kern = KernTable.Parse(table, 0, table.Length); + + Assert.Equal(0, kern.GetKerning(3, 4)); + } + + /// + /// Proves that KernTable ZeroLengthTable IsTolerant ReturnsZero. + /// + [Fact] + public void KernTable_ZeroLengthTable_IsTolerant_ReturnsZero() + { + var kern = KernTable.Parse([], 0, 0); + + Assert.Equal(0, kern.GetKerning(3, 4)); + } + + /// + /// Proves that KernTable TruncatedTable IsTolerant ReturnsZero. + /// + [Fact] + public void KernTable_TruncatedTable_IsTolerant_ReturnsZero() + { + byte[] data = [0, 0, 0, 1, 0]; // version=0, numSubtables=1, then truncated + + var kern = KernTable.Parse(data, 0, data.Length); + + Assert.Equal(0, kern.GetKerning(3, 4)); + } + + /// + /// Proves that KernTable MalformedPairCount IsTolerant ReturnsZero. + /// + [Fact] + public void KernTable_MalformedPairCount_IsTolerant_ReturnsZero() + { + var buf = new List(); + SyntheticFontBuilder.WriteUInt16(buf, 0); // kern table version + SyntheticFontBuilder.WriteUInt16(buf, 1); // nTables + SyntheticFontBuilder.WriteUInt16(buf, 0); // subtable version + SyntheticFontBuilder.WriteUInt16(buf, 14); // subtable length + SyntheticFontBuilder.WriteUInt16(buf, 0x0001); // coverage: format 0, horizontal + SyntheticFontBuilder.WriteUInt16(buf, 0xFFFF); // nPairs: absurdly large, does not fit + SyntheticFontBuilder.WriteUInt16(buf, 0); + SyntheticFontBuilder.WriteUInt16(buf, 0); + SyntheticFontBuilder.WriteUInt16(buf, 0); + + var data = buf.ToArray(); + var kern = KernTable.Parse(data, 0, data.Length); + + Assert.Equal(0, kern.GetKerning(3, 4)); + } + + /// + /// Proves that KernTable UnsupportedFormat2Subtable IsTolerant ReturnsZero. + /// + [Fact] + public void KernTable_UnsupportedFormat2Subtable_IsTolerant_ReturnsZero() + { + var buf = new List(); + SyntheticFontBuilder.WriteUInt16(buf, 0); // kern table version + SyntheticFontBuilder.WriteUInt16(buf, 1); // nTables + SyntheticFontBuilder.WriteUInt16(buf, 0); // subtable version + SyntheticFontBuilder.WriteUInt16(buf, 6); // subtable length (header only) + SyntheticFontBuilder.WriteUInt16(buf, 0x0201); // coverage: format 2, horizontal + + var data = buf.ToArray(); + var kern = KernTable.Parse(data, 0, data.Length); + + Assert.Equal(0, kern.GetKerning(3, 4)); + } + + /// + /// Proves that KernTable CrossStreamCoverage IsSkipped ReturnsZero. + /// + [Fact] + public void KernTable_CrossStreamCoverage_IsSkipped_ReturnsZero() + { + var buf = new List(); + SyntheticFontBuilder.WriteUInt16(buf, 0); // kern table version + SyntheticFontBuilder.WriteUInt16(buf, 1); // nTables + SyntheticFontBuilder.WriteUInt16(buf, 0); // subtable version + SyntheticFontBuilder.WriteUInt16(buf, 6); // subtable length (header only) + SyntheticFontBuilder.WriteUInt16(buf, 0x0005); // coverage: format 0, horizontal + cross-stream + + var data = buf.ToArray(); + var kern = KernTable.Parse(data, 0, data.Length); + + Assert.Equal(0, kern.GetKerning(3, 4)); + } +} diff --git a/test/DemaConsulting.CanvasNet.Tests/Fonts/SfntContainerTests.cs b/test/DemaConsulting.CanvasNet.Tests/Fonts/SfntContainerTests.cs new file mode 100644 index 00000000..f62e2cb5 --- /dev/null +++ b/test/DemaConsulting.CanvasNet.Tests/Fonts/SfntContainerTests.cs @@ -0,0 +1,228 @@ +using DemaConsulting.CanvasNet.Fonts; +using DemaConsulting.CanvasNet.Tests.TestSupport; + +namespace DemaConsulting.CanvasNet.Tests.Fonts; + +/// +/// Unit tests for . +/// +public class SfntContainerTests +{ + private static byte[] BuildMinimalFont() => + new SyntheticFontBuilder() + .AddTable("head", SyntheticFontBuilder.Head(1000, 0)) + .AddTable("glyf", []) + .Build(); + + /// + /// Proves that SfntContainer Parse WellFormedFont ExposesTables. + /// + [Fact] + public void SfntContainer_Parse_WellFormedFont_ExposesTables() + { + var data = BuildMinimalFont(); + + var container = SfntContainer.Parse(data); + + Assert.True(container.TryGetTable("head", out var head)); + Assert.Equal(54, head.Length); + Assert.True(container.TryGetTable("glyf", out _)); + Assert.False(container.TryGetTable("nope", out _)); + } + + /// + /// Proves that SfntContainer Parse MissingRequiredTable ThrowsInvalidDataException. + /// + [Fact] + public void SfntContainer_Parse_MissingRequiredTable_ThrowsInvalidDataException() + { + var data = BuildMinimalFont(); + var container = SfntContainer.Parse(data); + + Assert.Throws(() => container.RequireTable("cmap")); + } + + /// + /// Proves that SfntContainer Parse TruncatedOffsetTable ThrowsInvalidDataException. + /// + [Fact] + public void SfntContainer_Parse_TruncatedOffsetTable_ThrowsInvalidDataException() + { + var data = new byte[8]; + + Assert.Throws(() => SfntContainer.Parse(data)); + } + + /// + /// Proves that SfntContainer Parse TruncatedTableDirectory ThrowsInvalidDataException. + /// + [Fact] + public void SfntContainer_Parse_TruncatedTableDirectory_ThrowsInvalidDataException() + { + var buf = new List(); + SyntheticFontBuilder.WriteUInt32(buf, 0x00010000); + SyntheticFontBuilder.WriteUInt16(buf, 5); // claims 5 tables but has none + SyntheticFontBuilder.WriteUInt16(buf, 0); + SyntheticFontBuilder.WriteUInt16(buf, 0); + SyntheticFontBuilder.WriteUInt16(buf, 0); + + Assert.Throws(() => SfntContainer.Parse([.. buf])); + } + + /// + /// Proves that SfntContainer Parse OttoVersion ThrowsInvalidDataException. + /// + [Fact] + public void SfntContainer_Parse_OttoVersion_ThrowsInvalidDataException() + { + var data = new SyntheticFontBuilder() + .WithSfntVersion(0x4F54544F) // 'OTTO' + .AddTable("head", SyntheticFontBuilder.Head(1000, 0)) + .Build(); + + var ex = Assert.Throws(() => SfntContainer.Parse(data)); + Assert.Contains("OTTO", ex.Message, StringComparison.Ordinal); + } + + /// + /// Proves that SfntContainer Parse MacTrueVersion Succeeds. + /// + [Fact] + public void SfntContainer_Parse_MacTrueVersion_Succeeds() + { + var data = new SyntheticFontBuilder() + .WithSfntVersion(0x74727565) // 'true' + .AddTable("head", SyntheticFontBuilder.Head(1000, 0)) + .Build(); + + var container = SfntContainer.Parse(data); + + Assert.True(container.TryGetTable("head", out _)); + } + + /// + /// Proves that SfntContainer Parse UnrecognizedVersion ThrowsInvalidDataException. + /// + [Fact] + public void SfntContainer_Parse_UnrecognizedVersion_ThrowsInvalidDataException() + { + var data = new SyntheticFontBuilder() + .WithSfntVersion(0x12345678) + .AddTable("head", SyntheticFontBuilder.Head(1000, 0)) + .Build(); + + Assert.Throws(() => SfntContainer.Parse(data)); + } + + /// + /// Proves that SfntContainer Parse TableDirectoryEntryOverflowsUint ThrowsInvalidDataException. + /// + [Fact] + public void SfntContainer_Parse_TableDirectoryEntryOverflowsUint_ThrowsInvalidDataException() + { + // Hand-build an offset table + a single table directory entry whose offset is + // uint.MaxValue: a naive raw-uint "offset + length" bounds check would wrap around + // (4294967295 + 54 mod 2^32 == 53), which could deceptively pass a small data-length + // bounds check despite the offset itself being nonsensical. Computing the bound in + // long/checked arithmetic (never raw uint + uint) must reject this instead. + var buf = new List(); + SyntheticFontBuilder.WriteUInt32(buf, 0x00010000); + SyntheticFontBuilder.WriteUInt16(buf, 1); + SyntheticFontBuilder.WriteUInt16(buf, 0); + SyntheticFontBuilder.WriteUInt16(buf, 0); + SyntheticFontBuilder.WriteUInt16(buf, 0); + + buf.AddRange("head"u8.ToArray()); + SyntheticFontBuilder.WriteUInt32(buf, 0); // checksum + SyntheticFontBuilder.WriteUInt32(buf, 0xFFFFFFFF); // offset = uint.MaxValue + SyntheticFontBuilder.WriteUInt32(buf, 54); // length + + Assert.Throws(() => SfntContainer.Parse([.. buf])); + } + + /// + /// Proves that SfntContainer Parse TableDirectoryEntryOutOfBounds ThrowsInvalidDataException. + /// + [Fact] + public void SfntContainer_Parse_TableDirectoryEntryOutOfBounds_ThrowsInvalidDataException() + { + var buf = new List(); + SyntheticFontBuilder.WriteUInt32(buf, 0x00010000); + SyntheticFontBuilder.WriteUInt16(buf, 1); + SyntheticFontBuilder.WriteUInt16(buf, 0); + SyntheticFontBuilder.WriteUInt16(buf, 0); + SyntheticFontBuilder.WriteUInt16(buf, 0); + + buf.AddRange("head"u8.ToArray()); + SyntheticFontBuilder.WriteUInt32(buf, 0); + SyntheticFontBuilder.WriteUInt32(buf, 1000); // offset well past the actual data length + SyntheticFontBuilder.WriteUInt32(buf, 54); + + Assert.Throws(() => SfntContainer.Parse([.. buf])); + } + + /// + /// Proves that SfntContainer ReadUInt16 BigEndianRegardlessOfHostEndianness ReturnsExpectedValue. + /// + [Theory] + [InlineData((ushort)0)] + [InlineData((ushort)0x1234)] + [InlineData((ushort)0xFFFF)] + public void SfntContainer_ReadUInt16_BigEndianRegardlessOfHostEndianness_ReturnsExpectedValue(ushort value) + { + var buf = new List(); + SyntheticFontBuilder.WriteUInt16(buf, value); + + var result = SfntContainer.ReadUInt16([.. buf], 0); + + Assert.Equal(value, result); + } + + /// + /// Proves that SfntContainer ReadInt16 BigEndianRegardlessOfHostEndianness ReturnsExpectedValue. + /// + [Theory] + [InlineData(short.MinValue)] + [InlineData((short)-1)] + [InlineData(short.MaxValue)] + public void SfntContainer_ReadInt16_BigEndianRegardlessOfHostEndianness_ReturnsExpectedValue(short value) + { + var buf = new List(); + SyntheticFontBuilder.WriteInt16(buf, value); + + var result = SfntContainer.ReadInt16([.. buf], 0); + + Assert.Equal(value, result); + } + + /// + /// Proves that SfntContainer ReadUInt32 BigEndianRegardlessOfHostEndianness ReturnsExpectedValue. + /// + [Fact] + public void SfntContainer_ReadUInt32_BigEndianRegardlessOfHostEndianness_ReturnsExpectedValue() + { + var buf = new List(); + SyntheticFontBuilder.WriteUInt32(buf, 0xDEADBEEF); + + var result = SfntContainer.ReadUInt32([.. buf], 0); + + Assert.Equal(0xDEADBEEFu, result); + } + + /// + /// Proves that SfntContainer ReadF2Dot14 ReturnsExpectedValue. + /// + [Theory] + [InlineData(1.0f)] + [InlineData(-1.0f)] + [InlineData(0.5f)] + public void SfntContainer_ReadF2Dot14_ReturnsExpectedValue(float value) + { + var buf = new List(); + SyntheticFontBuilder.WriteF2Dot14(buf, value); + + var result = SfntContainer.ReadF2Dot14([.. buf], 0); + + Assert.Equal(value, result, 3); + } +} diff --git a/test/DemaConsulting.CanvasNet.Tests/Fonts/TrueTypeFontTests.cs b/test/DemaConsulting.CanvasNet.Tests/Fonts/TrueTypeFontTests.cs new file mode 100644 index 00000000..874ba14f --- /dev/null +++ b/test/DemaConsulting.CanvasNet.Tests/Fonts/TrueTypeFontTests.cs @@ -0,0 +1,300 @@ +using DemaConsulting.CanvasNet.Fonts; +using DemaConsulting.CanvasNet.Tests.TestSupport; + +namespace DemaConsulting.CanvasNet.Tests.Fonts; + +/// +/// Unit tests for . +/// +public class TrueTypeFontTests +{ + /// + /// Builds a minimal, complete, well-formed synthetic font with a single simple glyph (a + /// 10x10 square) mapped from codepoint 'A' (65), one kerning pair, and every required + /// table present. + /// + private static byte[] BuildWellFormedFont() + { + var glyph = SyntheticFontBuilder.SimpleGlyph( + [ + [(0, 0, true), (10, 0, true), (10, 10, true), (0, 10, true)] + ]); + + var cmap = SyntheticFontBuilder.CmapFormat4(3, 1, [(65, 1)]); + var kern = SyntheticFontBuilder.KernFormat0([(0, 1, 42)]); + + return new SyntheticFontBuilder() + .AddTable("head", SyntheticFontBuilder.Head(1000, 0)) + .AddTable("maxp", SyntheticFontBuilder.Maxp(2)) + .AddTable("hhea", SyntheticFontBuilder.Hhea(800, -200, 50, 2)) + .AddTable("hmtx", SyntheticFontBuilder.Hmtx([0, 500])) + .AddTable("loca", SyntheticFontBuilder.Loca([0, glyph.Length], longFormat: false)) + .AddTable("glyf", glyph) + .AddTable("cmap", cmap) + .AddTable("kern", kern) + .Build(); + } + + /// + /// Proves that TrueTypeFont Load WellFormedFont ExposesMetrics. + /// + [Fact] + public void TrueTypeFont_Load_WellFormedFont_ExposesMetrics() + { + var data = BuildWellFormedFont(); + + var font = TrueTypeFont.Load(new MemoryStream(data)); + + Assert.Equal(1000, font.UnitsPerEm); + Assert.Equal(800, font.Ascender); + Assert.Equal(-200, font.Descender); + Assert.Equal(50, font.LineGap); + Assert.Equal(2, font.GlyphCount); + } + + /// + /// Proves that TrueTypeFont Load EndToEnd GlyphIndexOutlineAdvanceAndKerning. + /// + [Fact] + public void TrueTypeFont_Load_EndToEnd_GlyphIndexOutlineAdvanceAndKerning() + { + var data = BuildWellFormedFont(); + + var font = TrueTypeFont.Load(new MemoryStream(data)); + var glyphIndex = font.GetGlyphIndex('A'); + var outline = font.GetGlyphOutline(glyphIndex); + var advance = font.GetAdvanceWidth(glyphIndex); + var kerning = font.GetKerning(0, glyphIndex); + + Assert.Equal(1, glyphIndex); + Assert.Single(outline.Subpaths); + Assert.Equal(500, advance); + Assert.Equal(42, kerning); + } + + /// + /// Proves that TrueTypeFont Load Path ReadsFromFile. + /// + [Fact] + public void TrueTypeFont_Load_Path_ReadsFromFile() + { + var data = BuildWellFormedFont(); + var path = System.IO.Path.GetTempFileName(); + try + { + File.WriteAllBytes(path, data); + + var font = TrueTypeFont.Load(path); + + Assert.Equal(1000, font.UnitsPerEm); + } + finally + { + File.Delete(path); + } + } + + /// + /// Proves that TrueTypeFont Load NullStream ThrowsArgumentNullException. + /// + [Fact] + public void TrueTypeFont_Load_NullStream_ThrowsArgumentNullException() + { + Assert.Throws(() => TrueTypeFont.Load((Stream)null!)); + } + + /// + /// Proves that TrueTypeFont Load NullPath ThrowsArgumentNullException. + /// + [Fact] + public void TrueTypeFont_Load_NullPath_ThrowsArgumentNullException() + { + Assert.Throws(() => TrueTypeFont.Load((string)null!)); + } + + /// + /// Proves that TrueTypeFont Load EmptyPath ThrowsArgumentException. + /// + [Fact] + public void TrueTypeFont_Load_EmptyPath_ThrowsArgumentException() + { + Assert.Throws(() => TrueTypeFont.Load(string.Empty)); + } + + /// + /// Proves that TrueTypeFont Load MissingRequiredTable ThrowsInvalidDataException. + /// + [Fact] + public void TrueTypeFont_Load_MissingRequiredTable_ThrowsInvalidDataException() + { + var data = new SyntheticFontBuilder() + .AddTable("head", SyntheticFontBuilder.Head(1000, 0)) + .Build(); // missing maxp/hhea/hmtx/loca/glyf + + Assert.Throws(() => TrueTypeFont.Load(new MemoryStream(data))); + } + + /// + /// Proves that TrueTypeFont Load OttoFont ThrowsInvalidDataException. + /// + [Fact] + public void TrueTypeFont_Load_OttoFont_ThrowsInvalidDataException() + { + var data = new SyntheticFontBuilder() + .WithSfntVersion(0x4F54544F) + .AddTable("head", SyntheticFontBuilder.Head(1000, 0)) + .Build(); + + Assert.Throws(() => TrueTypeFont.Load(new MemoryStream(data))); + } + + /// + /// Proves that TrueTypeFont Load MaxpVersion05 ThrowsInvalidDataException. + /// + [Fact] + public void TrueTypeFont_Load_MaxpVersion05_ThrowsInvalidDataException() + { + var glyph = SyntheticFontBuilder.SimpleGlyph([[(0, 0, true), (10, 0, true), (10, 10, true)]]); + var data = new SyntheticFontBuilder() + .AddTable("head", SyntheticFontBuilder.Head(1000, 0)) + .AddTable("maxp", SyntheticFontBuilder.Maxp(1, version: 0x00005000)) + .AddTable("hhea", SyntheticFontBuilder.Hhea(800, -200, 50, 1)) + .AddTable("hmtx", SyntheticFontBuilder.Hmtx([500])) + .AddTable("loca", SyntheticFontBuilder.Loca([glyph.Length], longFormat: false)) + .AddTable("glyf", glyph) + .Build(); + + Assert.Throws(() => TrueTypeFont.Load(new MemoryStream(data))); + } + + /// + /// Proves that TrueTypeFont Load ZeroUnitsPerEm ThrowsInvalidDataException. + /// + [Fact] + public void TrueTypeFont_Load_ZeroUnitsPerEm_ThrowsInvalidDataException() + { + var glyph = SyntheticFontBuilder.SimpleGlyph([[(0, 0, true), (10, 0, true), (10, 10, true)]]); + var data = new SyntheticFontBuilder() + .AddTable("head", SyntheticFontBuilder.Head(0, 0)) + .AddTable("maxp", SyntheticFontBuilder.Maxp(1)) + .AddTable("hhea", SyntheticFontBuilder.Hhea(800, -200, 50, 1)) + .AddTable("hmtx", SyntheticFontBuilder.Hmtx([500])) + .AddTable("loca", SyntheticFontBuilder.Loca([glyph.Length], longFormat: false)) + .AddTable("glyf", glyph) + .Build(); + + Assert.Throws(() => TrueTypeFont.Load(new MemoryStream(data))); + } + + /// + /// Proves that TrueTypeFont Load InvalidIndexToLocFormat ThrowsInvalidDataException. + /// + [Fact] + public void TrueTypeFont_Load_InvalidIndexToLocFormat_ThrowsInvalidDataException() + { + var glyph = SyntheticFontBuilder.SimpleGlyph([[(0, 0, true), (10, 0, true), (10, 10, true)]]); + var data = new SyntheticFontBuilder() + .AddTable("head", SyntheticFontBuilder.Head(1000, 2)) // only 0 or 1 are valid + .AddTable("maxp", SyntheticFontBuilder.Maxp(1)) + .AddTable("hhea", SyntheticFontBuilder.Hhea(800, -200, 50, 1)) + .AddTable("hmtx", SyntheticFontBuilder.Hmtx([500])) + .AddTable("loca", SyntheticFontBuilder.Loca([glyph.Length], longFormat: false)) + .AddTable("glyf", glyph) + .Build(); + + Assert.Throws(() => TrueTypeFont.Load(new MemoryStream(data))); + } + + /// + /// Proves that TrueTypeFont Load NoCmapOrKern FallsBackGracefully. + /// + [Fact] + public void TrueTypeFont_Load_NoCmapOrKern_FallsBackGracefully() + { + var glyph = SyntheticFontBuilder.SimpleGlyph([[(0, 0, true), (10, 0, true), (10, 10, true)]]); + var data = new SyntheticFontBuilder() + .AddTable("head", SyntheticFontBuilder.Head(1000, 0)) + .AddTable("maxp", SyntheticFontBuilder.Maxp(1)) + .AddTable("hhea", SyntheticFontBuilder.Hhea(800, -200, 50, 1)) + .AddTable("hmtx", SyntheticFontBuilder.Hmtx([500])) + .AddTable("loca", SyntheticFontBuilder.Loca([glyph.Length], longFormat: false)) + .AddTable("glyf", glyph) + .Build(); + + var font = TrueTypeFont.Load(new MemoryStream(data)); + + Assert.Equal(0, font.GetGlyphIndex('A')); + Assert.Equal(0, font.GetKerning(0, 0)); + } + + /// + /// Proves that TrueTypeFont GetGlyphOutline NegativeGlyphIndex ThrowsArgumentOutOfRangeException. + /// + [Fact] + public void TrueTypeFont_GetGlyphOutline_NegativeGlyphIndex_ThrowsArgumentOutOfRangeException() + { + var font = TrueTypeFont.Load(new MemoryStream(BuildWellFormedFont())); + + Assert.Throws(() => font.GetGlyphOutline(-1)); + } + + /// + /// Proves that TrueTypeFont GetGlyphOutline GlyphIndexTooLarge ThrowsArgumentOutOfRangeException. + /// + [Fact] + public void TrueTypeFont_GetGlyphOutline_GlyphIndexTooLarge_ThrowsArgumentOutOfRangeException() + { + var font = TrueTypeFont.Load(new MemoryStream(BuildWellFormedFont())); + + Assert.Throws(() => font.GetGlyphOutline(font.GlyphCount)); + } + + /// + /// Proves that TrueTypeFont GetAdvanceWidth GlyphIndexTooLarge ThrowsArgumentOutOfRangeException. + /// + [Fact] + public void TrueTypeFont_GetAdvanceWidth_GlyphIndexTooLarge_ThrowsArgumentOutOfRangeException() + { + var font = TrueTypeFont.Load(new MemoryStream(BuildWellFormedFont())); + + Assert.Throws(() => font.GetAdvanceWidth(font.GlyphCount)); + } + + /// + /// Proves that TrueTypeFont GetKerning OutOfRangeGlyphIndex NeverThrows. + /// + [Fact] + public void TrueTypeFont_GetKerning_OutOfRangeGlyphIndex_NeverThrows() + { + var font = TrueTypeFont.Load(new MemoryStream(BuildWellFormedFont())); + + var result = font.GetKerning(-1, 99999); + + Assert.Equal(0, result); + } + + /// + /// Proves that TrueTypeFont GetGlyphIndex UnmappedCodepoint NeverThrows. + /// + [Fact] + public void TrueTypeFont_GetGlyphIndex_UnmappedCodepoint_NeverThrows() + { + var font = TrueTypeFont.Load(new MemoryStream(BuildWellFormedFont())); + + var result = font.GetGlyphIndex(0x10FFFF); + + Assert.Equal(0, result); + } + + /// + /// Proves that TrueTypeFont Load TruncatedStream ThrowsInvalidDataException. + /// + [Fact] + public void TrueTypeFont_Load_TruncatedStream_ThrowsInvalidDataException() + { + var data = BuildWellFormedFont(); + var truncated = data[..(data.Length / 2)]; + + Assert.Throws(() => TrueTypeFont.Load(new MemoryStream(truncated))); + } +} diff --git a/test/DemaConsulting.CanvasNet.Tests/TestSupport/SyntheticFontBuilder.cs b/test/DemaConsulting.CanvasNet.Tests/TestSupport/SyntheticFontBuilder.cs new file mode 100644 index 00000000..15ec9fbe --- /dev/null +++ b/test/DemaConsulting.CanvasNet.Tests/TestSupport/SyntheticFontBuilder.cs @@ -0,0 +1,516 @@ +namespace DemaConsulting.CanvasNet.Tests.TestSupport; + +/// +/// Hand-rolled builder for constructing minimal, well-formed synthetic SFNT/TrueType font +/// byte arrays (and the individual table byte arrays they are built from) for the +/// Fonts namespace's tests, without any third-party font fixture and therefore no font +/// licensing consideration. Also exposes the shared big-endian primitive-writing helpers used +/// to hand-construct deliberately malformed variants directly in each test file, mirroring +/// /'s role as shared test +/// infrastructure for the Codecs tests. +/// +internal sealed class SyntheticFontBuilder +{ + private readonly List<(string Tag, byte[] Data)> _tables = []; + private uint _sfntVersion = 0x00010000; + + /// + /// Overrides the sfntVersion written to the offset table (defaults to + /// 0x00010000, the standard TrueType value). + /// + public SyntheticFontBuilder WithSfntVersion(uint version) + { + _sfntVersion = version; + return this; + } + + /// + /// Adds (or replaces) a table's raw bytes. + /// + public SyntheticFontBuilder AddTable(string tag, byte[] data) + { + _tables.RemoveAll(t => t.Tag == tag); + _tables.Add((tag, data)); + return this; + } + + /// + /// Assembles the offset table, table directory, and every added table's bytes into a + /// complete, well-formed SFNT byte array. + /// + public byte[] Build() + { + var buf = new List(); + WriteUInt32(buf, _sfntVersion); + WriteUInt16(buf, (ushort)_tables.Count); + WriteUInt16(buf, 0); // searchRange + WriteUInt16(buf, 0); // entrySelector + WriteUInt16(buf, 0); // rangeShift + + var directoryStart = buf.Count; + var directorySize = _tables.Count * 16; + var dataStart = directoryStart + directorySize; + + // Reserve directory space, filled in below once every table's absolute offset is known + for (var i = 0; i < directorySize; i++) + { + buf.Add(0); + } + + var offsets = new int[_tables.Count]; + var cursor = dataStart; + for (var i = 0; i < _tables.Count; i++) + { + offsets[i] = cursor; + buf.AddRange(_tables[i].Data); + cursor += _tables[i].Data.Length; + } + + for (var i = 0; i < _tables.Count; i++) + { + var entryOffset = directoryStart + i * 16; + var tag = _tables[i].Tag; + for (var c = 0; c < 4; c++) + { + buf[entryOffset + c] = (byte)(c < tag.Length ? tag[c] : ' '); + } + + WriteUInt32At(buf, entryOffset + 4, 0); // checksum (never validated) + WriteUInt32At(buf, entryOffset + 8, (uint)offsets[i]); + WriteUInt32At(buf, entryOffset + 12, (uint)_tables[i].Data.Length); + } + + return [.. buf]; + } + + /// + /// Appends a big-endian, unsigned 16-bit integer. + /// + public static void WriteUInt16(List buf, int value) + { + buf.Add((byte)((value >> 8) & 0xFF)); + buf.Add((byte)(value & 0xFF)); + } + + /// + /// Appends a big-endian, signed 16-bit integer. + /// + public static void WriteInt16(List buf, int value) => WriteUInt16(buf, value & 0xFFFF); + + /// + /// Appends a big-endian, unsigned 32-bit integer. + /// + public static void WriteUInt32(List buf, uint value) + { + buf.Add((byte)((value >> 24) & 0xFF)); + buf.Add((byte)((value >> 16) & 0xFF)); + buf.Add((byte)((value >> 8) & 0xFF)); + buf.Add((byte)(value & 0xFF)); + } + + /// + /// Appends a big-endian F2Dot14 fixed-point value. + /// + public static void WriteF2Dot14(List buf, float value) => WriteInt16(buf, (int)Math.Round(value * 16384f)); + + /// + /// Overwrites 4 bytes at an absolute index within an in-progress buffer with a big-endian + /// unsigned 32-bit integer - used to backfill table directory fields once every table's + /// final offset is known. + /// + private static void WriteUInt32At(List buf, int index, uint value) + { + buf[index] = (byte)((value >> 24) & 0xFF); + buf[index + 1] = (byte)((value >> 16) & 0xFF); + buf[index + 2] = (byte)((value >> 8) & 0xFF); + buf[index + 3] = (byte)(value & 0xFF); + } + + /// + /// Builds a 54-byte head table. + /// + public static byte[] Head(int unitsPerEm, int indexToLocFormat) + { + var buf = new List(); + WriteUInt32(buf, 0x00010000); // version + WriteUInt32(buf, 0x00010000); // fontRevision + WriteUInt32(buf, 0); // checkSumAdjustment + WriteUInt32(buf, 0x5F0F3CF5); // magicNumber + WriteUInt16(buf, 0); // flags + WriteUInt16(buf, unitsPerEm); + for (var i = 0; i < 16; i++) + { + buf.Add(0); // created/modified (int64 x2 = 16 bytes) + } + + WriteInt16(buf, 0); // xMin + WriteInt16(buf, 0); // yMin + WriteInt16(buf, 0); // xMax + WriteInt16(buf, 0); // yMax + WriteUInt16(buf, 0); // macStyle + WriteUInt16(buf, 0); // lowestRecPPEM + WriteInt16(buf, 0); // fontDirectionHint + WriteInt16(buf, indexToLocFormat); + WriteInt16(buf, 0); // glyphDataFormat + return [.. buf]; + } + + /// + /// Builds a 6-byte maxp table (version 1.0 fixed-size prefix only - sufficient for + /// every field reads). + /// + public static byte[] Maxp(int numGlyphs, uint version = 0x00010000) + { + var buf = new List(); + WriteUInt32(buf, version); + WriteUInt16(buf, numGlyphs); + return [.. buf]; + } + + /// + /// Builds a 36-byte hhea table. + /// + public static byte[] Hhea(int ascender, int descender, int lineGap, int numOfLongHorMetrics) + { + var buf = new List(); + WriteUInt32(buf, 0x00010000); // version + WriteInt16(buf, ascender); + WriteInt16(buf, descender); + WriteInt16(buf, lineGap); + WriteUInt16(buf, 0); // advanceWidthMax + WriteInt16(buf, 0); // minLeftSideBearing + WriteInt16(buf, 0); // minRightSideBearing + WriteInt16(buf, 0); // xMaxExtent + WriteInt16(buf, 0); // caretSlopeRise + WriteInt16(buf, 0); // caretSlopeRun + WriteInt16(buf, 0); // caretOffset + for (var i = 0; i < 4; i++) + { + WriteInt16(buf, 0); // reserved x4 + } + + WriteInt16(buf, 0); // metricDataFormat + WriteUInt16(buf, numOfLongHorMetrics); + return [.. buf]; + } + + /// + /// Builds an hmtx table with one long (advanceWidth, lsb) entry per glyph in + /// , optionally followed by + /// left-side-bearing-only (zero) entries for glyphs beyond numOfLongHorMetrics. + /// + public static byte[] Hmtx(IReadOnlyList advanceWidths, int tailLsbCount = 0) + { + var buf = new List(); + foreach (var advance in advanceWidths) + { + WriteUInt16(buf, advance); + WriteInt16(buf, 0); // lsb + } + + for (var i = 0; i < tailLsbCount; i++) + { + WriteInt16(buf, 0); // lsb-only tail entry + } + + return [.. buf]; + } + + /// + /// Builds a loca table from each glyph's byte length within glyf. + /// + public static byte[] Loca(IReadOnlyList glyphLengths, bool longFormat) + { + var offsets = new int[glyphLengths.Count + 1]; + for (var i = 0; i < glyphLengths.Count; i++) + { + offsets[i + 1] = offsets[i] + glyphLengths[i]; + } + + var buf = new List(); + foreach (var offset in offsets) + { + if (longFormat) + { + WriteUInt32(buf, (uint)offset); + } + else + { + WriteUInt16(buf, offset / 2); + } + } + + return [.. buf]; + } + + /// + /// Builds a simple glyph's contour data from one or more contours, each an ordered list of + /// (X, Y, OnCurve) points. Every coordinate delta is encoded as a full 16-bit value + /// (flag bits for the short-vector/same-value encodings are left clear), which is + /// sufficient to exercise every on-curve/off-curve contour shape the decoder supports. + /// + public static byte[] SimpleGlyph(params (int X, int Y, bool OnCurve)[][] contours) + { + var buf = new List(); + WriteInt16(buf, contours.Length); + WriteInt16(buf, 0); + WriteInt16(buf, 0); + WriteInt16(buf, 0); + WriteInt16(buf, 0); + + var runningEnd = -1; + foreach (var contour in contours) + { + runningEnd += contour.Length; + WriteUInt16(buf, runningEnd); + } + + WriteUInt16(buf, 0); // instructionLength + + var allPoints = contours.SelectMany(c => c).ToArray(); + foreach (var point in allPoints) + { + buf.Add((byte)(point.OnCurve ? 0x01 : 0x00)); + } + + var prevX = 0; + foreach (var point in allPoints) + { + WriteInt16(buf, point.X - prevX); + prevX = point.X; + } + + var prevY = 0; + foreach (var point in allPoints) + { + WriteInt16(buf, point.Y - prevY); + prevY = point.Y; + } + + PadToEvenLength(buf); + return [.. buf]; + } + + /// + /// Appends a single zero pad byte if 's length is odd - real + /// glyf table entries are always padded to an even length, and the short + /// (Offset16) loca format can only represent even byte offsets. + /// + private static void PadToEvenLength(List buf) + { + if (buf.Count % 2 != 0) + { + buf.Add(0); + } + } + + /// + /// Describes one component of a synthetic composite glyph. + /// + public readonly record struct CompositeComponent( + int GlyphIndex, + int Dx, + int Dy, + bool ArgsAreXyValues = true, + float A = 1f, + float B = 0f, + float C = 0f, + float D = 1f, + bool HasScale = false, + bool HasXyScale = false, + bool HasTwoByTwo = false); + + /// + /// Builds a composite glyph (numberOfContours == -1) from an ordered list of + /// components. MORE_COMPONENTS is set automatically on every component but the + /// last, and ARG_1_AND_2_ARE_WORDS is always set (arguments always encoded as + /// 16-bit values). + /// + public static byte[] CompositeGlyph(IReadOnlyList components) + { + var buf = new List(); + WriteInt16(buf, -1); + WriteInt16(buf, 0); + WriteInt16(buf, 0); + WriteInt16(buf, 0); + WriteInt16(buf, 0); + + for (var i = 0; i < components.Count; i++) + { + var component = components[i]; + var flags = 0x0001; // ARG_1_AND_2_ARE_WORDS + if (component.ArgsAreXyValues) + { + flags |= 0x0002; + } + + if (component.HasScale) + { + flags |= 0x0008; + } + + if (i < components.Count - 1) + { + flags |= 0x0020; // MORE_COMPONENTS + } + + if (component.HasXyScale) + { + flags |= 0x0040; + } + + if (component.HasTwoByTwo) + { + flags |= 0x0080; + } + + WriteUInt16(buf, flags); + WriteUInt16(buf, component.GlyphIndex); + WriteInt16(buf, component.Dx); + WriteInt16(buf, component.Dy); + + if (component.HasScale) + { + WriteF2Dot14(buf, component.A); + } + else if (component.HasXyScale) + { + WriteF2Dot14(buf, component.A); + WriteF2Dot14(buf, component.D); + } + else if (component.HasTwoByTwo) + { + WriteF2Dot14(buf, component.A); + WriteF2Dot14(buf, component.B); + WriteF2Dot14(buf, component.C); + WriteF2Dot14(buf, component.D); + } + } + + PadToEvenLength(buf); + return [.. buf]; + } + + /// + /// Builds a cmap table with a single format 4 subtable under the given + /// platform/encoding IDs, mapping each (codepoint, glyphId) pair given. + /// + public static byte[] CmapFormat4(int platformId, int encodingId, IReadOnlyList<(int Codepoint, int GlyphId)> mappings) + { + var subtable = new List(); + var ordered = mappings.OrderBy(m => m.Codepoint).ToList(); + + // One segment per mapped codepoint (simplest possible correct encoding), plus the + // mandatory trailing 0xFFFF terminator segment. + var segCount = ordered.Count + 1; + WriteUInt16(subtable, 4); // format + WriteUInt16(subtable, 0); // length placeholder (not validated by the decoder) + WriteUInt16(subtable, 0); // language + WriteUInt16(subtable, segCount * 2); + WriteUInt16(subtable, 0); // searchRange + WriteUInt16(subtable, 0); // entrySelector + WriteUInt16(subtable, 0); // rangeShift + + foreach (var (codepoint, _) in ordered) + { + WriteUInt16(subtable, codepoint); + } + + WriteUInt16(subtable, 0xFFFF); + + WriteUInt16(subtable, 0); // reservedPad + + foreach (var (codepoint, _) in ordered) + { + WriteUInt16(subtable, codepoint); + } + + WriteUInt16(subtable, 0xFFFF); + + foreach (var (codepoint, glyphId) in ordered) + { + WriteInt16(subtable, glyphId - codepoint); + } + + WriteInt16(subtable, 1); // terminator segment idDelta (endCode==startCode==0xFFFF -> glyph 0) + + for (var i = 0; i < segCount; i++) + { + WriteUInt16(subtable, 0); // idRangeOffset (always 0: glyph id comes from idDelta) + } + + return WrapSingleSubtableCmap(platformId, encodingId, [.. subtable]); + } + + /// + /// Builds a cmap table with a single format 12 subtable under the given + /// platform/encoding IDs, mapping each contiguous (startCharCode, endCharCode, + /// startGlyphId) group given. + /// + public static byte[] CmapFormat12(int platformId, int encodingId, IReadOnlyList<(uint Start, uint End, uint StartGlyphId)> groups) + { + var subtable = new List(); + WriteUInt16(subtable, 12); // format + WriteUInt16(subtable, 0); // reserved + WriteUInt32(subtable, 0); // length placeholder + WriteUInt32(subtable, 0); // language + WriteUInt32(subtable, (uint)groups.Count); + + foreach (var (start, end, startGlyphId) in groups) + { + WriteUInt32(subtable, start); + WriteUInt32(subtable, end); + WriteUInt32(subtable, startGlyphId); + } + + return WrapSingleSubtableCmap(platformId, encodingId, [.. subtable]); + } + + /// + /// Wraps a single already-encoded subtable's bytes in a minimal cmap table header + /// (one encoding record). + /// + private static byte[] WrapSingleSubtableCmap(int platformId, int encodingId, byte[] subtableBytes) + { + var buf = new List(); + WriteUInt16(buf, 0); // version + WriteUInt16(buf, 1); // numTables + WriteUInt16(buf, platformId); + WriteUInt16(buf, encodingId); + WriteUInt32(buf, 12); // subtable offset (immediately after this one 12-byte header) + buf.AddRange(subtableBytes); + return [.. buf]; + } + + /// + /// Builds a kern table with a single format 0, horizontal-only subtable containing + /// the given sorted (left, right, value) pairs. + /// + public static byte[] KernFormat0(IReadOnlyList<(int Left, int Right, int Value)> pairs) + { + var body = new List(); + WriteUInt16(body, pairs.Count); + WriteUInt16(body, 0); // searchRange + WriteUInt16(body, 0); // entrySelector + WriteUInt16(body, 0); // rangeShift + foreach (var (left, right, value) in pairs) + { + WriteUInt16(body, left); + WriteUInt16(body, right); + WriteInt16(body, value); + } + + var subtableLength = 6 + body.Count; + var subtable = new List(); + WriteUInt16(subtable, 0); // subtable version + WriteUInt16(subtable, subtableLength); + WriteUInt16(subtable, 0x0001); // coverage: format 0 (high byte), horizontal (bit0) + subtable.AddRange(body); + + var buf = new List(); + WriteUInt16(buf, 0); // kern table version + WriteUInt16(buf, 1); // nTables + buf.AddRange(subtable); + return [.. buf]; + } +} From ae9ee811fac84f453a092da1555961385e09c838 Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Tue, 22 Sep 2026 11:59:42 -0400 Subject: [PATCH 03/17] Add Fonts companion documentation Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .reviewmark.yaml | 39 ++ README.md | 53 +++ docs/design/canvas-net.md | 147 +++++-- docs/design/canvas-net/drawing.md | 4 +- docs/design/canvas-net/drawing/path-filler.md | 18 +- docs/design/canvas-net/fonts.md | 50 +++ .../design/canvas-net/fonts/true-type-font.md | 325 +++++++++++++++ docs/design/introduction.md | 109 ++--- docs/reqstream/canvas-net.yaml | 68 ++++ docs/reqstream/canvas-net/fonts.yaml | 101 +++++ .../canvas-net/fonts/true-type-font.yaml | 379 ++++++++++++++++++ docs/sysml2/model/canvas-net.sysml | 3 +- docs/sysml2/model/canvas-net/drawing.sysml | 2 +- docs/sysml2/model/canvas-net/fonts.sysml | 12 + .../canvas-net/fonts/true-type-font.sysml | 11 + docs/sysml2/views/design-views.sysml | 4 + docs/user_guide/introduction.md | 96 +++++ docs/verification/canvas-net.md | 27 +- docs/verification/canvas-net/fonts.md | 60 +++ .../canvas-net/fonts/true-type-font.md | 270 +++++++++++++ docs/verification/introduction.md | 8 + requirements.yaml | 2 + .../Fonts/CmapTable.cs | 3 + .../Fonts/GlyfLocaReader.cs | 3 + .../Fonts/HmtxHheaReader.cs | 3 + .../Fonts/KernTable.cs | 3 + .../Fonts/NamespaceDoc.cs | 3 + .../Fonts/SfntContainer.cs | 3 + .../Fonts/TrueTypeFont.cs | 3 + .../CanvasNetTests.cs | 3 + .../Fonts/CmapTableTests.cs | 3 + .../Fonts/GlyfLocaReaderTests.cs | 3 + .../Fonts/HmtxHheaReaderTests.cs | 3 + .../Fonts/KernTableTests.cs | 3 + .../Fonts/SfntContainerTests.cs | 3 + .../Fonts/TrueTypeFontTests.cs | 3 + .../TestSupport/SyntheticFontBuilder.cs | 3 + 37 files changed, 1731 insertions(+), 102 deletions(-) create mode 100644 docs/design/canvas-net/fonts.md create mode 100644 docs/design/canvas-net/fonts/true-type-font.md create mode 100644 docs/reqstream/canvas-net/fonts.yaml create mode 100644 docs/reqstream/canvas-net/fonts/true-type-font.yaml create mode 100644 docs/sysml2/model/canvas-net/fonts.sysml create mode 100644 docs/sysml2/model/canvas-net/fonts/true-type-font.sysml create mode 100644 docs/verification/canvas-net/fonts.md create mode 100644 docs/verification/canvas-net/fonts/true-type-font.md diff --git a/.reviewmark.yaml b/.reviewmark.yaml index 32ed6522..b7f819b4 100644 --- a/.reviewmark.yaml +++ b/.reviewmark.yaml @@ -378,6 +378,45 @@ reviews: - "test/DemaConsulting.CanvasNet.Tests/Drawing/ScanlineRasterizerTests.cs" # gradient rasterization tests - "test/DemaConsulting.CanvasNet.Tests/Canvas/SurfaceTests.cs" # per-pixel-color CompositeOverSpan tests + # CanvasNet-Fonts Review (one per subsystem) + - id: CanvasNet-Fonts + title: Review that CanvasNet Fonts Subsystem Implementation is Correct + context: + - "docs/design/canvas-net.md" + - "docs/reqstream/canvas-net.yaml" + paths: + - "docs/reqstream/canvas-net/fonts.yaml" # subsystem requirements + - "docs/design/canvas-net/fonts.md" # subsystem design + - "docs/verification/canvas-net/fonts.md" # subsystem verification + - "test/DemaConsulting.CanvasNet.Tests/TestSupport/SyntheticFontBuilder.cs" # shared test helper + + # CanvasNet-Fonts-TrueTypeFont Review (one per unit) + - id: CanvasNet-Fonts-TrueTypeFont + title: Review that CanvasNet Fonts TrueTypeFont Implementation is Correct + context: + - "docs/design/canvas-net.md" + - "docs/reqstream/canvas-net.yaml" + - "docs/design/canvas-net/fonts.md" + - "docs/reqstream/canvas-net/fonts.yaml" + paths: + - "docs/reqstream/canvas-net/fonts/true-type-font.yaml" # unit requirements + - "docs/design/canvas-net/fonts/true-type-font.md" # unit design + - "docs/verification/canvas-net/fonts/true-type-font.md" # unit verification + - "src/DemaConsulting.CanvasNet/Fonts/TrueTypeFont.cs" # unit source + - "src/DemaConsulting.CanvasNet/Fonts/SfntContainer.cs" # internal helper source + - "src/DemaConsulting.CanvasNet/Fonts/CmapTable.cs" # internal helper source + - "src/DemaConsulting.CanvasNet/Fonts/GlyfLocaReader.cs" # internal helper source + - "src/DemaConsulting.CanvasNet/Fonts/HmtxHheaReader.cs" # internal helper source + - "src/DemaConsulting.CanvasNet/Fonts/KernTable.cs" # internal helper source + - "src/DemaConsulting.CanvasNet/Fonts/NamespaceDoc.cs" # Fonts namespace documentation + - "test/DemaConsulting.CanvasNet.Tests/Fonts/SfntContainerTests.cs" # unit tests + - "test/DemaConsulting.CanvasNet.Tests/Fonts/CmapTableTests.cs" # unit tests + - "test/DemaConsulting.CanvasNet.Tests/Fonts/GlyfLocaReaderTests.cs" # unit tests + - "test/DemaConsulting.CanvasNet.Tests/Fonts/HmtxHheaReaderTests.cs" # unit tests + - "test/DemaConsulting.CanvasNet.Tests/Fonts/KernTableTests.cs" # unit tests + - "test/DemaConsulting.CanvasNet.Tests/Fonts/TrueTypeFontTests.cs" # unit tests + - "test/DemaConsulting.CanvasNet.Tests/TestSupport/SyntheticFontBuilder.cs" # shared test helper + # OTS-BuildMark Review (one per OTS item) - id: OTS-BuildMark title: Review that BuildMark Provides Required Functionality diff --git a/README.md b/README.md index 72cab967..464c510c 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,6 @@ # CanvasNet + @@ -37,6 +38,8 @@ image operations using `Span`, and supports independent-copy cropping for loa fillable outline geometry and render them through the same antialiased fill pipeline - 🌅 **Gradient Paint** - Fill a path with a linear or two-circle radial gradient color ramp, with pad/reflect/repeat spread and premultiplied-alpha color interpolation +- 🔤 **TrueType Fonts** - Load glyph-based TrueType SFNT fonts, map Unicode codepoints to glyph + indices, extract glyph outlines as `Geometry.Path`, and query advance widths and basic kerning - ⚡ **Span-Based** - Fast, allocation-conscious row and pixel access - 🔄 **Multi-Target** - Supports .NET 8, 9, and 10 - 📦 **NuGet Ready** - Easy integration via NuGet package @@ -161,6 +164,56 @@ var gradient = new LinearGradient( PathFiller.Fill(canvas, rectangle, gradient, FillRule.NonZero, 1f); // red-to-blue ramp ``` +Loading a TrueType font and filling a glyph outline: + +```csharp +using DemaConsulting.CanvasNet.Canvas; +using DemaConsulting.CanvasNet.Drawing; +using DemaConsulting.CanvasNet.Fonts; +using DemaConsulting.CanvasNet.Geometry; +using System.Numerics; + +static Path TransformGlyph(Path glyph, float scale, float baselineY) +{ + var builder = new PathBuilder(); + + Vector2 ToCanvas(Vector2 point) => new(point.X * scale, baselineY - point.Y * scale); + + foreach (var subpath in glyph.Subpaths) + { + builder.MoveTo(ToCanvas(subpath.Start)); + foreach (var command in subpath.Commands) + { + switch (command.Type) + { + case PathCommandType.LineTo: + builder.LineTo(ToCanvas(command.EndPoint)); + break; + case PathCommandType.QuadraticBezierTo: + builder.QuadraticBezierTo( + ToCanvas(command.Control1), + ToCanvas(command.EndPoint)); + break; + case PathCommandType.Close: + builder.Close(); + break; + } + } + } + + return builder.Build(); +} + +var font = TrueTypeFont.Load("font.ttf"); +var glyphIndex = font.GetGlyphIndex('A'); +var glyphOutline = font.GetGlyphOutline(glyphIndex); // raw font-design-unit coordinates (Y up) +var scale = 48f / font.UnitsPerEm; +var canvasOutline = TransformGlyph(glyphOutline, scale, baselineY: 56f); + +var surface = new Surface(64, 64); +PathFiller.Fill(surface, canvasOutline, new Rgba32(20, 120, 255, 255)); +``` + ## Building ```pwsh diff --git a/docs/design/canvas-net.md b/docs/design/canvas-net.md index 2b7f03ca..03c86e85 100644 --- a/docs/design/canvas-net.md +++ b/docs/design/canvas-net.md @@ -4,10 +4,13 @@ This document provides the system-level design for CanvasNet. ![CanvasNet Structure](CanvasNetView.svg) + + + ## Architecture CanvasNet is a .NET library providing a canvas-based drawing and rendering API, following -DEMA Consulting best practices. The system consists of four implemented subsystems: +DEMA Consulting best practices. The system consists of five implemented subsystems: - **Canvas subsystem** (namespace `DemaConsulting.CanvasNet.Canvas`, folder `src/DemaConsulting.CanvasNet/Canvas/`): the pixel-buffer primitives on which all other @@ -47,8 +50,15 @@ DEMA Consulting best practices. The system consists of four implemented subsyste `BezierFlattening`/`SvgArcConverter` units; neither `Canvas` nor `Geometry` depends on `Drawing`. `Geometry` is deliberately distinct from `Drawing`: `Geometry` describes shape geometry (paths, bounds, curve math) with no notion of pixels, color, or rasterization, while - `Drawing` turns that geometry into pixels. Fonts are reserved for a later phase. - See _Drawing Subsystem Design_ (`drawing.md`). + `Drawing` turns that geometry into pixels. See _Drawing Subsystem Design_ (`drawing.md`). +- **Fonts subsystem** (namespace `DemaConsulting.CanvasNet.Fonts`, folder + `src/DemaConsulting.CanvasNet/Fonts/`, flat — no further nesting): TrueType (`glyf`-based) + SFNT font loading through the `TrueTypeFont` unit, which fronts the internal `SfntContainer`/ + `CmapTable`/`GlyfLocaReader`/`HmtxHheaReader`/`KernTable` helpers documented inline. `Fonts` + depends only on the `Geometry` subsystem (`Path`, `PathBuilder`, and path-command semantics) + because it produces vector outlines and scalar metrics, not pixels. It does not depend on + `Canvas`, `Drawing`, or `Codecs`; callers combine its output with `Drawing` and `Canvas` when + they want rendered text. See _Fonts Subsystem Design_ (`fonts.md`). The `Codecs` subsystem depends on the `Canvas` subsystem's `Surface` unit (constructing surfaces and reading/writing rows via `Surface.GetRowSpanBytes`); the `Canvas` subsystem has no dependency @@ -92,6 +102,16 @@ path through `PathFiller.Fill`. See _Drawing Subsystem Design_ (`drawing.md`), _ Design_ (`drawing/path-filler.md`), and _PathStroker Unit Design_ (`drawing/path-stroker.md`) for full detail. +The `Fonts` subsystem's single public unit collaborates with its internal helpers as follows. +`TrueTypeFont.Load` delegates to `SfntContainer` for SFNT offset-table and table-directory +parsing, validates the required `head`/`maxp`/`hhea`/`hmtx`/`loca`/`glyf` tables, then delegates +to `HmtxHheaReader` for top-level metrics and advance widths, `GlyfLocaReader` for eager `loca` +parsing plus lazy glyph decoding, `CmapTable` for Unicode codepoint lookup, and `KernTable` for +basic pairwise kerning. `GetGlyphOutline` returns `Geometry.Path` in raw font-design-unit space, +so callers who want pixels scale and flip that path before rendering it through `Drawing`. See +_Fonts Subsystem Design_ (`fonts.md`) and _TrueTypeFont Unit Design_ +(`fonts/true-type-font.md`) for full detail. + ## External Interfaces The system exposes the following public API to external consumers: @@ -212,38 +232,64 @@ The system exposes the following public API to external consumers: `Path.Empty` when the stroke contributes no visible area. Throws `ArgumentNullException` for a null `path`/`style`, and `ArgumentOutOfRangeException` for a non-finite or non-positive `flattenTolerance`. - -| Interface | Direction | Format | Constraints | -| -------------------------------- | ---------------- | ------------------------------ | ----------------------------- | -| `Surface(int, int)` | Inbound | Constructor call | `width`, `height` in 1-8192 | -| `Surface[int, int]` | Inbound/Outbound | Indexer get/set | `x`, `y` within bounds | -| `Surface.GetRowSpanBytes(int)` | Outbound | `Span` return | `y` within bounds | -| `Surface.GetRowSpan(int)` | Outbound | `Span` return | `y` within bounds | -| `Surface.Crop(int,int,int,int)` | Inbound/Outbound | Method call / `Surface` return | Region within source bounds | -| `Surface.PremultiplyAlpha()` | Inbound | Method call | None | -| `Surface.UnpremultiplyAlpha()` | Inbound | Method call | None | -| `Surface.CompositeOver(Surface)` | Inbound | Method call | Equal dimensions, non-null | -| `Surface.CompositeOver(Rgba32)` | Inbound | Method call | None | -| `Surface.CompositeOverSpan(...)` | Inbound | Method call | `y`, `x`+run within bounds | -| `BmpCodec.Load(...)` | Inbound/Outbound | Method call / `Surface` return | Valid BMP stream or path | -| `BmpCodec.Save(...)` | Inbound | Method call | `surface` non-null | -| `PngCodec.Load(...)` | Inbound/Outbound | Method call / `Surface` return | Valid PNG stream or path | -| `PngCodec.Save(...)` | Inbound | Method call | `surface` non-null | -| `TiffCodec.Load(...)` | Inbound/Outbound | Method call / `Surface` return | Valid TIFF stream or path | -| `TiffCodec.Save(...)` | Inbound | Method call | `surface` non-null | -| `JpegCodec.Load(...)` | Inbound/Outbound | Method call / `Surface` return | Valid JPEG stream or path | -| `JpegCodec.Save(...)` | Inbound | Method call | `surface` non-null | -| `Rect.Union(...)` | Inbound/Outbound | Method call / `Rect` return | None | -| `Rect.Intersect(...)` | Inbound/Outbound | Method call / `Rect` return | None | -| `Rect.Transform(Matrix3x2)` | Inbound/Outbound | Method call / `Rect` return | None | -| `PathBuilder.*To(...)` | Inbound/Outbound | Method call / `this` return | Called after `MoveTo` | -| `PathBuilder.Build()` | Outbound | Method call / `Path` return | None | -| `Path.GetBounds(float)` | Outbound | Method call / `Rect` return | None | -| `BezierFlattening.Flatten*(...)` | Inbound/Outbound | Method call / list append | `tolerance` greater than zero | -| `SvgArcConverter.ToBeziers(...)` | Inbound/Outbound | Method call / list append | None | -| `PathFiller.Fill(...)` | Inbound | Method call | Non-null; fillRule ok; tol>0 | -| `StrokeStyle(...)` | Inbound | Constructor call | Width > 0; valid style data | -| `PathStroker.Stroke(...)` | Inbound/Outbound | Method call / `Path` return | Non-null; tol > 0 | +- **TrueTypeFont.Load(Stream)** / **TrueTypeFont.Load(string path)**: Loads a glyph-based + TrueType SFNT font from a stream or file path, exposing its top-level metrics and the query + methods below. Throws `ArgumentNullException` for a null `stream`/`path`, `ArgumentException` + for an empty `path`, and `InvalidDataException` for malformed, truncated, or unsupported font + data. +- **TrueTypeFont.UnitsPerEm** / **Ascender** / **Descender** / **LineGap** / **GlyphCount**: + Read-only metrics properties exposing the loaded font's top-level scalar data. +- **TrueTypeFont.GetGlyphIndex(int codepoint)**: Returns the glyph index mapped from the Unicode + `codepoint`, or `0` (`.notdef`) if the codepoint is unmapped or the font has no usable `cmap`. + Never throws. +- **TrueTypeFont.GetGlyphOutline(int glyphIndex)**: Returns the glyph's outline as a + `Geometry.Path` in raw font design units. Throws `ArgumentOutOfRangeException` for an invalid + glyph index and `InvalidDataException` for malformed or unsupported glyph data. +- **TrueTypeFont.GetAdvanceWidth(int glyphIndex)**: Returns the glyph's horizontal advance width, + reusing the final explicit `hmtx` entry for the monospaced tail. Throws + `ArgumentOutOfRangeException` for an invalid glyph index. +- **TrueTypeFont.GetKerning(int leftGlyphIndex, int rightGlyphIndex)**: Returns the pairwise + kerning adjustment for the glyph pair, or `0` when no pair or no usable `kern` table exists. + Never throws. + + +| Interface | Direction | Format | Constraints | +| ----------------------------------- | ---------------- | ------------------------------ | ----------------------------- | +| `Surface(int, int)` | Inbound | Constructor call | `width`, `height` in 1-8192 | +| `Surface[int, int]` | Inbound/Outbound | Indexer get/set | `x`, `y` within bounds | +| `Surface.GetRowSpanBytes(int)` | Outbound | `Span` return | `y` within bounds | +| `Surface.GetRowSpan(int)` | Outbound | `Span` return | `y` within bounds | +| `Surface.Crop(int,int,int,int)` | Inbound/Outbound | Method call / `Surface` return | Region within source bounds | +| `Surface.PremultiplyAlpha()` | Inbound | Method call | None | +| `Surface.UnpremultiplyAlpha()` | Inbound | Method call | None | +| `Surface.CompositeOver(Surface)` | Inbound | Method call | Equal dimensions, non-null | +| `Surface.CompositeOver(Rgba32)` | Inbound | Method call | None | +| `Surface.CompositeOverSpan(...)` | Inbound | Method call | `y`, `x`+run within bounds | +| `BmpCodec.Load(...)` | Inbound/Outbound | Method call / `Surface` return | Valid BMP stream or path | +| `BmpCodec.Save(...)` | Inbound | Method call | `surface` non-null | +| `PngCodec.Load(...)` | Inbound/Outbound | Method call / `Surface` return | Valid PNG stream or path | +| `PngCodec.Save(...)` | Inbound | Method call | `surface` non-null | +| `TiffCodec.Load(...)` | Inbound/Outbound | Method call / `Surface` return | Valid TIFF stream or path | +| `TiffCodec.Save(...)` | Inbound | Method call | `surface` non-null | +| `JpegCodec.Load(...)` | Inbound/Outbound | Method call / `Surface` return | Valid JPEG stream or path | +| `JpegCodec.Save(...)` | Inbound | Method call | `surface` non-null | +| `Rect.Union(...)` | Inbound/Outbound | Method call / `Rect` return | None | +| `Rect.Intersect(...)` | Inbound/Outbound | Method call / `Rect` return | None | +| `Rect.Transform(Matrix3x2)` | Inbound/Outbound | Method call / `Rect` return | None | +| `PathBuilder.*To(...)` | Inbound/Outbound | Method call / `this` return | Called after `MoveTo` | +| `PathBuilder.Build()` | Outbound | Method call / `Path` return | None | +| `Path.GetBounds(float)` | Outbound | Method call / `Rect` return | None | +| `BezierFlattening.Flatten*(...)` | Inbound/Outbound | Method call / list append | `tolerance` greater than zero | +| `SvgArcConverter.ToBeziers(...)` | Inbound/Outbound | Method call / list append | None | +| `PathFiller.Fill(...)` | Inbound | Method call | Non-null; fillRule ok; tol>0 | +| `StrokeStyle(...)` | Inbound | Constructor call | Width > 0; valid style data | +| `PathStroker.Stroke(...)` | Inbound/Outbound | Method call / `Path` return | Non-null; tol > 0 | +| `TrueTypeFont.Load(...)` | Inbound/Outbound | Method / `TrueTypeFont` | Valid TrueType stream or path | +| `TrueTypeFont.GetGlyphIndex(int)` | Outbound | Method / `int` | Any Unicode codepoint | +| `TrueTypeFont.GetGlyphOutline(int)` | Outbound | Method / `Path` | `glyphIndex` within range | +| `TrueTypeFont.GetAdvanceWidth(int)` | Outbound | Method / `int` | `glyphIndex` within range | +| `TrueTypeFont.GetKerning(int,int)` | Outbound | Method / `int` | Any glyph indices | + ## Dependencies @@ -266,9 +312,12 @@ runtime NuGet dependencies: `PathFiller`, `FillRule`, `EdgeFlattener`, `Scanline `StrokeOutliner` are implemented entirely against `System.Numerics.Vector2`, in-box `List`/ array types, and the existing `Geometry` and `Canvas` subsystem APIs (`Path`, `PathBuilder`, `BezierFlattening`, `SvgArcConverter`, `Surface.CompositeOverSpan`) — no new package reference -was added to the project file. The -following OTS -items are used for building and verifying this system (not consumed at runtime); see +was added to the project file. The `Fonts` subsystem likewise introduces zero new runtime NuGet +dependencies: `TrueTypeFont`, `SfntContainer`, `CmapTable`, `GlyfLocaReader`, `HmtxHheaReader`, +and `KernTable` are implemented entirely against `System.IO`, in-box array/list types, and the +existing `Geometry` subsystem's `Path`/`PathBuilder` abstractions, with no dependency on +`Canvas`, `Drawing`, or `Codecs`. The following OTS items are used for building and verifying +this system (not consumed at runtime); see _OTS Integration Design_ (`docs/design/ots.md`) and each item's dedicated design document for details: @@ -418,6 +467,18 @@ measures (IEC 62304 §5.3.3). pixels into the destination `Surface` 4. **Output**: A new `Surface` containing the decoded pixels +**TrueType font load and glyph-query path:** + +1. **Input**: A font stream or file path, followed by caller-supplied codepoints or glyph indices +2. **Validation**: `TrueTypeFont.Load` rejects null/empty arguments and malformed, truncated, or + unsupported SFNT data with the documented exception contract +3. **Processing**: `SfntContainer` validates the table directory, `HmtxHheaReader` parses + metrics, `GlyfLocaReader` parses `loca`, `CmapTable` parses a supported Unicode mapping if + present, and `KernTable` parses a supported horizontal format-0 subtable if present; later + queries decode only the glyph requested +4. **Output**: A `TrueTypeFont` object exposing metrics, codepoint-to-glyph lookup, glyph + outlines, advance widths, and kerning adjustments + **Path stroke conversion path:** 1. **Input**: Method parameters `path`, `style`, and optional `flattenTolerance` @@ -444,11 +505,11 @@ measures (IEC 62304 §5.3.3). The library targets the following frameworks, enabling compatibility across modern, currently supported .NET runtimes: -| Target Framework | Runtime / Environment | -| ---------------- | ------------------------------------------------- | -| `net8.0` | .NET 8 LTS | -| `net9.0` | .NET 9 | -| `net10.0` | .NET 10 | +| Target Framework | Runtime / Environment | +| ---------------- | --------------------- | +| `net8.0` | .NET 8 LTS | +| `net9.0` | .NET 9 | +| `net10.0` | .NET 10 | The library is supported on the following operating systems: diff --git a/docs/design/canvas-net/drawing.md b/docs/design/canvas-net/drawing.md index d9a70b42..673b036e 100644 --- a/docs/design/canvas-net/drawing.md +++ b/docs/design/canvas-net/drawing.md @@ -23,7 +23,9 @@ curve math) with no notion of pixels, color, or rasterization; `Drawing` is wher is turned into pixels. `Drawing` depends on both `Canvas` (`Surface`, `Surface.CompositeOverSpan`) and `Geometry` (`Path`, `PathBuilder`, `BezierFlattening`, `SvgArcConverter`); neither `Canvas` nor `Geometry` has any dependency on -`Drawing`. Fonts/text rendering remain reserved for a later phase. +`Drawing`. Callers may combine `Drawing` with the `Fonts` subsystem's `TrueTypeFont` output when +they want text rendering, but `Drawing` itself remains a general-purpose path rasterizer with no +font-specific logic. ### Units diff --git a/docs/design/canvas-net/drawing/path-filler.md b/docs/design/canvas-net/drawing/path-filler.md index 2a88940f..8c2f1251 100644 --- a/docs/design/canvas-net/drawing/path-filler.md +++ b/docs/design/canvas-net/drawing/path-filler.md @@ -14,14 +14,16 @@ them has any independent behavior beyond supporting `PathFiller.Fill`. `PathFiller` turns vector path geometry into rendered pixels: it flattens every subpath's lines, curves, and arcs into a closed polygon, then rasterizes those polygons with analytically computed antialiased pixel coverage, compositing the result directly onto a `Surface` using its existing -Porter-Duff "over" blend pipeline. It supports both `FillRule.NonZero` and `FillRule.EvenOdd` -winding resolution (matching SVG/CSS `fill-rule` semantics), correctly renders holes via nested, -counter-wound subpaths, and treats every subpath as implicitly closed regardless of whether the -path explicitly called `Close`. Alongside the original solid-color `Fill` overload, `PathFiller` -also exposes a gradient-paint `Fill(Surface, Path, Gradient, FillRule, float)` overload that -shares every bit of this unit's flattening, clip-bounds, and validation logic - see _GradientPaint -Unit Design_ (`gradient-paint.md`) for the gradient-specific evaluation algorithm it delegates to. -Fonts remain out of scope for this unit and reserved for a later phase. +Porter-Duff "over" blend pipeline. It supports both `FillRule.NonZero` and +`FillRule.EvenOdd` winding resolution (matching SVG/CSS `fill-rule` semantics), correctly +renders holes via nested, counter-wound subpaths, and treats +every subpath as implicitly closed regardless of whether the path explicitly called `Close`. +Alongside the original solid-color `Fill` overload, `PathFiller` also exposes a gradient-paint +`Fill(Surface, Path, Gradient, FillRule, float)` overload that shares every bit of this unit's +flattening, clip-bounds, and validation logic - see _GradientPaint Unit Design_ +(`gradient-paint.md`) for the gradient-specific evaluation algorithm it delegates to. Font parsing +remains outside this unit: callers supply ordinary `Geometry.Path` instances whether they came +from handwritten geometry or from `Fonts.TrueTypeFont`. ### Coordinate Convention diff --git a/docs/design/canvas-net/fonts.md b/docs/design/canvas-net/fonts.md new file mode 100644 index 00000000..1232c2e8 --- /dev/null +++ b/docs/design/canvas-net/fonts.md @@ -0,0 +1,50 @@ +## Fonts + + +![Fonts Structure](FontsView.svg) + + + +The `Fonts` subsystem is the fifth software subsystem in CanvasNet. It groups the single public +`TrueTypeFont` unit that loads glyph-based TrueType SFNT fonts, maps Unicode codepoints to glyph +indices, extracts glyph outlines as `Geometry.Path` geometry, and reports horizontal metrics and +basic kerning. Internally, `TrueTypeFont` fronts `SfntContainer`, `CmapTable`, +`GlyfLocaReader`, `HmtxHheaReader`, and `KernTable` exactly as `PathFiller` fronts +`EdgeFlattener` and `ScanlineRasterizer`: one public entry point coordinating several +independently testable helpers. + +### Purpose + +The `Fonts` subsystem provides dependency-free parsing and querying of TrueType (`glyf`-based) +SFNT font files. Its responsibility ends at vector geometry and scalar metrics: it loads font +structure, resolves codepoints to glyph indices, decodes glyph contours into +`DemaConsulting.CanvasNet.Geometry.Path`, reports advance widths, and returns pairwise kerning +adjustments from classic `kern` format-0 subtables when present. CFF/OpenType (`OTTO`) fonts, +text layout, shaping, hint execution, point-size scaling, and pixel rendering are all outside +this subsystem's boundary. + +### Units + +- **TrueTypeFont** — the sole public unit of the subsystem. It exposes `Load(Stream)` / + `Load(string)` plus query methods for codepoint mapping, glyph outlines, advance widths, and + kerning. Its unit design documents the internal helpers inline because none has an independent + public contract beyond supporting `TrueTypeFont`; see _TrueTypeFont Unit Design_ + (`fonts/true-type-font.md`) + +### Dependencies + +The `Fonts` subsystem depends only on the `Geometry` subsystem, specifically its `Path`, +`PathBuilder`, and `PathCommand` abstractions. `TrueTypeFont.GetGlyphOutline` returns raw vector +geometry in font design units, and the internal glyph decoder issues only `MoveTo`, `LineTo`, +`QuadraticBezierTo`, and `Close` commands through `PathBuilder`. The subsystem does **not** +depend on `Canvas` or `Drawing` directly: it neither allocates surfaces nor rasterizes pixels. +The system-integration test that fills a glyph outline proves callers can combine `Fonts` output +with `Drawing.PathFiller` and `Canvas.Surface`, but that collaboration happens outside the +subsystem itself. + +### Callers + +`TrueTypeFont` is a public API entry point, called directly by consumers of the CanvasNet +package. Within this repository, the subsystem is exercised by dedicated `Fonts` unit tests and +by the two system-integration tests in `CanvasNetTests.cs` that load a synthetic font, query +metrics, obtain an outline, and render that outline through `PathFiller` onto a `Surface`. diff --git a/docs/design/canvas-net/fonts/true-type-font.md b/docs/design/canvas-net/fonts/true-type-font.md new file mode 100644 index 00000000..4b0b93b2 --- /dev/null +++ b/docs/design/canvas-net/fonts/true-type-font.md @@ -0,0 +1,325 @@ +### TrueTypeFont + +![Fonts Structure](FontsView.svg) + + + + +The `TrueTypeFont` class is the sole public software unit in the `Fonts` subsystem. It provides +hand-rolled loading and querying of glyph-based TrueType SFNT fonts, while documenting the +supporting internal `SfntContainer`, `CmapTable`, `GlyfLocaReader`, `HmtxHheaReader`, and +`KernTable` helpers inline because none has any independent public behavior beyond supporting this +unit. + +#### Purpose + +`TrueTypeFont` lets callers load a TrueType font from a stream or file path, inspect top-level +metrics (`UnitsPerEm`, `Ascender`, `Descender`, `LineGap`, `GlyphCount`), map Unicode codepoints +to glyph indices, extract glyph outlines as `DemaConsulting.CanvasNet.Geometry.Path`, query +horizontal advance widths, and read pairwise kerning adjustments from a classic `kern` format-0 +subtable when present. The class deliberately stops at raw font-design-unit geometry and scalar +metrics: it does not perform text shaping, line layout, point-size scaling, hint execution, or +pixel rendering. + +#### Data Model + +All parsed SFNT structures are read directly from the in-memory font byte array using explicit +big-endian byte composition. `TrueTypeFont` stores only immutable parsed state: +`HmtxHheaReader` for horizontal metrics, `GlyfLocaReader` for eager `loca` parsing plus lazy +glyph decoding, `CmapTable` for codepoint mapping, and `KernTable` for pair lookups. + +##### SFNT Offset Table (12 bytes, big-endian) + +| Offset | Size | Field | Use | +| ------ | ---- | --------------- | ------------------------------------------------ | +| 0 | 4 | `sfntVersion` | Accept `0x00010000` or `'true'`; reject `'OTTO'` | +| 4 | 2 | `numTables` | Number of directory entries | +| 6 | 2 | `searchRange` | Read but not otherwise interpreted | +| 8 | 2 | `entrySelector` | Read but not otherwise interpreted | +| 10 | 2 | `rangeShift` | Read but not otherwise interpreted | + +##### SFNT Table Directory Entry (16 bytes, big-endian) + +| Offset | Size | Field | Use | +| ------ | ---- | ---------- | ----------------------------------------------------------------------- | +| 0 | 4 | `tag` | Names `head`, `maxp`, `hhea`, `hmtx`, `loca`, `glyf`, `cmap`, or `kern` | +| 4 | 4 | `checksum` | Recorded but never enforced | +| 8 | 4 | `offset` | Bounds-checked in `long` arithmetic | +| 12 | 4 | `length` | Bounds-checked in `long` arithmetic | + +##### `head` Table Fields Used (54-byte minimum) + +| Offset | Size | Field | Use | +| ------ | ---- | ------------------ | ------------------------------------------------ | +| 0 | 4 | `version` | Present in the table prefix | +| 18 | 2 | `unitsPerEm` | Exposed as `UnitsPerEm`; zero is rejected | +| 50 | 2 | `indexToLocFormat` | Selects short (`0`) or long (`1`) `loca` parsing | + +##### `maxp` Table Fields Used (6-byte prefix, version 1.0 only) + +| Offset | Size | Field | Use in This Unit | +| ------ | ---- | ----------- | ------------------------------------------------------ | +| 0 | 4 | `version` | Must be `0x00010000`; version `0x00005000` is rejected | +| 4 | 2 | `numGlyphs` | Exposed as `GlyphCount` and used to size `loca`/`hmtx` | + +##### `hhea` Table Fields Used (36 bytes) + +| Offset | Size | Field | Use in This Unit | +| ------ | ---- | --------------------- | ---------------------------------------------- | +| 4 | 2 | `ascender` | Exposed as `Ascender` | +| 6 | 2 | `descender` | Exposed as `Descender` | +| 8 | 2 | `lineGap` | Exposed as `LineGap` | +| 34 | 2 | `numOfLongHorMetrics` | Sizes the explicit `hmtx` advance-width prefix | + +##### `hmtx` Table Layout + +| Region | Element Size | Meaning | +| ----------------------------------- | ------------ | ---------------------------------------------------- | +| First `numOfLongHorMetrics` entries | 4 bytes each | `advanceWidth` + `leftSideBearing` | +| Remaining tail entries | 2 bytes each | `leftSideBearing` only; reuse the last advance width | + +`HmtxHheaReader` stores only the explicit advance-width array because `TrueTypeFont` exposes +advance widths, not left-side bearings. + +##### `loca` Table Layout + +| Format | Entry Size | Stored Value | Resolved Glyph Offset | +| ------------------------------- | ---------- | ------------ | --------------------- | +| Short (`indexToLocFormat == 0`) | 2 bytes | `Offset16` | `Offset16 * 2` | +| Long (`indexToLocFormat == 1`) | 4 bytes | `Offset32` | `Offset32` | + +`GlyfLocaReader` parses `numGlyphs + 1` entries eagerly, validates they are monotonically +non-decreasing, and rejects any entry that exceeds the `glyf` table length. + +##### `glyf` Glyph Header (10 bytes) + +| Offset | Size | Field | Use | +| ------ | ---- | ------------------ | ----------------------------------------------------- | +| 0 | 2 | `numberOfContours` | `>= 0` means simple glyph; `-1` means composite glyph | +| 2 | 2 | `xMin` | Present in the header | +| 4 | 2 | `yMin` | Present in the header | +| 6 | 2 | `xMax` | Present in the header | +| 8 | 2 | `yMax` | Present in the header | + +##### Simple Glyph Body Layout + +| Region | Layout | Use | +| ---------------------------------- | --------------------------- | --------------------------------------------- | +| `endPtsOfContours` | `numberOfContours` x uint16 | Inclusive final point index for each contour | +| `instructionLength` + instructions | uint16 + byte array | Skipped; hint execution is out of scope | +| Flags | run-length encoded bytes | On-curve bits plus short/vector encoding bits | +| X coordinates | delta-encoded bytes/shorts | Reconstructed to absolute X coordinates | +| Y coordinates | delta-encoded bytes/shorts | Reconstructed to absolute Y coordinates | + +The decoded point stream is converted directly to `PathBuilder` commands. Consecutive off-curve +points synthesize the implied on-curve midpoint required by the TrueType contour convention. + +##### Composite Glyph Component Layout + + +| Region | Layout | Use in This Unit | +| ------------------ | ---------------------------------------- | ------------------------------------------------------------- | +| Component header | `flags` (uint16) + `glyphIndex` (uint16) | Identifies the referenced glyph and transform encoding | +| Arguments | 2 or 4 bytes | Must be XY offsets; point-matched components are rejected | +| Optional transform | 2, 4, or 8 bytes | Supports one scale, separate X/Y scales, or a full 2x2 matrix | + + +Supported component transforms are translation, uniform scale, independent X/Y scale, and full +2x2 matrix transforms using F2Dot14 fixed-point values. + +##### `cmap` Subtables Used + +###### Format 4 (segment mapping to delta values) + +| Offset | Size | Field | Use in This Unit | +| ------ | ---- | ----------------- | --------------------------------------------- | +| 0 | 2 | `format` | Must be `4` | +| 2 | 2 | `length` | Used only for bounds-truncated parsing | +| 6 | 2 | `segCountX2` | Sizes the segment arrays | +| 14 | var | `endCode[]` | Segment upper bounds | +| ... | 2 | `reservedPad` | Skipped | +| ... | var | `startCode[]` | Segment lower bounds | +| ... | var | `idDelta[]` | Applied directly when `idRangeOffset` is zero | +| ... | var | `idRangeOffset[]` | Indirect lookup into `glyphIdArray` | +| ... | var | `glyphIdArray[]` | Optional per-codepoint glyph IDs | + +###### Format 12 (segmented coverage) + +| Offset | Size | Field | Use in This Unit | +| ------ | ---------------- | ----------- | --------------------------------------------------------- | +| 0 | 2 | `format` | Must be `12` | +| 4 | 4 | `length` | Used only for bounds-truncated parsing | +| 12 | 4 | `numGroups` | Sizes the group array | +| 16 | `12 * numGroups` | `groups` | Stores `startCharCode`, `endCharCode`, and `startGlyphId` | + +`CmapTable` chooses exactly one supported subtable using this priority order: `(3,10)` +format 12, `(0,4)`/`(0,6)` format 12, `(3,1)` format 4, `(0,3)` format 4, then any remaining +`(0,x)` format 4. + +##### `kern` Table Layout Used + +###### Version-0 `kern` Table Header + +| Offset | Size | Field | Use in This Unit | +| ------ | ---- | --------- | ----------------------------------------------- | +| 0 | 2 | `version` | Must be `0`; any other header layout is skipped | +| 2 | 2 | `nTables` | Number of subtables to inspect | + +###### Format-0 Subtable Header and Body + +| Offset | Size | Field | Use in This Unit | +| ------ | ------------ | --------------- | ---------------------------------------------------- | +| 0 | 2 | `version` | Present; not otherwise interpreted | +| 2 | 2 | `length` | Bounds-checks the subtable | +| 4 | 2 | `coverage` | Must indicate format 0, horizontal, non-cross-stream | +| 6 | 2 | `nPairs` | Sizes the pair array | +| 8 | 2 | `searchRange` | Present; not otherwise interpreted | +| 10 | 2 | `entrySelector` | Present; not otherwise interpreted | +| 12 | 2 | `rangeShift` | Present; not otherwise interpreted | +| 14 | `6 * nPairs` | `pairs` | Sorted `(left, right, value)` entries for search | + +#### Key Methods + +##### Load(Stream stream) + +Copies the source stream into memory, parses the SFNT container, validates every required table, +parses `head`, `maxp`, `hhea`, and `hmtx`, eagerly parses `loca`, and parses `cmap` / `kern` +leniently when present. + +**Throws:** + +- `ArgumentNullException` — `stream` is null +- `InvalidDataException` — the SFNT version is unrecognized, the font declares CFF/OpenType + (`OTTO`) outlines, a required table is missing or malformed, a table directory entry is out of + bounds or would overflow ordinary arithmetic, or the stream is truncated + +##### Load(string path) + +Opens `path` as a read-only `FileStream` and delegates to `Load(Stream)`. + +**Throws:** + +- `ArgumentNullException` — `path` is null +- `ArgumentException` — `path` is an empty string +- `InvalidDataException` — see `Load(Stream)` +- Underlying file-system exceptions propagate uncaught + +##### GetGlyphIndex(int codepoint) + +Uses the selected `cmap` subtable to map a Unicode codepoint to a glyph index. + +**Throws:** + +- Never throws; returns `0` (`.notdef`) for an unmapped codepoint, for a missing `cmap` table, + or when no supported `cmap` subtable can be parsed successfully + +##### GetGlyphOutline(int glyphIndex) + +Validates `glyphIndex`, then asks `GlyfLocaReader` to lazily decode that glyph's outline. Simple +glyphs become `LineTo` / `QuadraticBezierTo` commands; composite glyphs recursively reuse other +glyphs' decoded outlines after applying the component transform. + +**Throws:** + +- `ArgumentOutOfRangeException` — `glyphIndex` is outside `[0, GlyphCount)` +- `InvalidDataException` — the glyph data is malformed or truncated, a composite glyph exceeds + the nesting-depth or total-component bound, a point-matched component is encountered, or a + component glyph index is out of range + +##### GetAdvanceWidth(int glyphIndex) + +Validates `glyphIndex`, then returns the glyph's horizontal advance width from `hmtx`, reusing +the last explicit advance-width entry for any tail glyph beyond `numOfLongHorMetrics`. + +**Throws:** + +- `ArgumentOutOfRangeException` — `glyphIndex` is outside `[0, GlyphCount)` + +##### GetKerning(int leftGlyphIndex, int rightGlyphIndex) + +Binary-searches the selected `kern` format-0 pair array. + +**Throws:** + +- Never throws; returns `0` for a missing pair, for a missing or unusable `kern` table, and even + for an out-of-range glyph index supplied only for kerning lookup + +##### Internal Helper Roles + +- `SfntContainer` parses the offset table and table directory, enforces overflow-safe bounds + checks, and hosts the shared big-endian primitive readers +- `CmapTable` selects one supported format-4 or format-12 subtable and exposes a lookup delegate +- `GlyfLocaReader` eagerly parses `loca`, then lazily decodes simple and composite glyphs +- `HmtxHheaReader` parses top-level typographic metrics and advance widths +- `KernTable` tolerantly parses the first qualifying horizontal format-0 subtable, if any + +#### Error Handling + +##### Public Exception Contract + + +| Member | Exception | Condition | +| ---------------------- | ----------------------------- | --------------------------------------------------------------------- | +| `Load(Stream)` | `ArgumentNullException` | `stream` is null | +| `Load(Stream)` | `InvalidDataException` | Bad SFNT version, bad required table, bad table bounds, or truncation | +| `Load(string)` | `ArgumentNullException` | `path` is null | +| `Load(string)` | `ArgumentException` | `path` is empty | +| `Load(string)` | `InvalidDataException` | Same font-structure failures as `Load(Stream)` | +| `GetGlyphIndex(int)` | none | Unmapped codepoints and unusable `cmap` data return `0` | +| `GetGlyphOutline(int)` | `ArgumentOutOfRangeException` | `glyphIndex` is outside `[0, GlyphCount)` | +| `GetGlyphOutline(int)` | `InvalidDataException` | Malformed/truncated glyph data or rejected composite-glyph feature | +| `GetAdvanceWidth(int)` | `ArgumentOutOfRangeException` | `glyphIndex` is outside `[0, GlyphCount)` | +| `GetKerning(int, int)` | none | Missing pair/table or bad kerning data returns `0` | + + +##### Required vs. Optional Tables + +`head`, `maxp`, `hhea`, `hmtx`, `loca`, and `glyf` are required because `TrueTypeFont` cannot +report metrics or decode outlines without them. By contrast, `cmap` and `kern` are intentionally +lenient. A missing or unusable `cmap` still leaves the font usable by glyph index directly, so +`GetGlyphIndex` degrades to returning `0`. A missing or malformed `kern` table does not prevent +metrics or outlines from being queried, so `GetKerning` degrades to returning `0`. + +##### Overflow-Safe Structural Validation + +Every table-directory bound is computed in `long` arithmetic with `checked` addition. +`SfntContainer` never performs a raw `uint + uint` end calculation, so malicious offsets and +lengths cannot overflow the validation logic itself. `GlyfLocaReader` applies the same posture to +`loca` sizing and per-glyph `glyf` bounds. + +##### Big-Endian Primitive Reading + +SFNT multi-byte fields are always big-endian regardless of host CPU endianness. `SfntContainer` +therefore composes every ushort, short, uint, int, and F2Dot14 value from individual bytes. +`BitConverter` and `BinaryPrimitives` are deliberately avoided so the parsing logic does not rely +on machine endianness or hidden span helpers. + +##### Composite Glyph Bounds + +`GlyfLocaReader` enforces two deterministic limits during composite decoding: maximum nesting +depth 10 and maximum total resolved component visits 5000. The depth limit catches true cycles +and pathological nesting, but depth alone would not stop a non-cyclic composite tree from +exploding exponentially. The total-component limit is therefore the stronger bound: it caps total +work even when every reference chain is acyclic. + +##### Quadratic Contour Conversion + +TrueType simple glyphs are natively quadratic, so `TrueTypeFont` converts them directly to +`PathBuilder.QuadraticBezierTo` rather than flattening or converting them to cubic curves. +Consecutive off-curve points synthesize the implied on-curve midpoint required by the format, and +a contour that begins off-curve is closed through an implied on-curve start point built from the +first and last control points. + +#### Dependencies + +`TrueTypeFont` depends only on the `Geometry` subsystem: `Path` as the public outline return +type, `PathBuilder` as the construction mechanism for decoded contours, and `PathCommand` +semantics as the command set a caller later walks if it needs to transform the returned outline. +The unit has no dependency on `Canvas`, `Drawing`, or any runtime NuGet package. + +#### Callers + +`TrueTypeFont` is called directly by consumers of CanvasNet. Within this repository, its +immediate callers are the `Fonts` unit tests and the two `CanvasNetTests.cs` system-integration +tests that scale and flip the returned outline before filling it through `Drawing.PathFiller` +onto a `Canvas.Surface`. diff --git a/docs/design/introduction.md b/docs/design/introduction.md index f94def92..a2eefb82 100644 --- a/docs/design/introduction.md +++ b/docs/design/introduction.md @@ -1,5 +1,7 @@ # Introduction + + This document provides the detailed design for CanvasNet, a .NET library providing a canvas-based drawing and rendering API. @@ -37,14 +39,15 @@ software items, specifically: `SvgArcConverter` unit (SVG-style elliptical arc to Bezier conversion) - **Drawing (Subsystem)** — An antialiased scanline-coverage fill rasterizer for closed `Geometry.Path` geometry with solid-color or gradient paint: the `PathFiller` unit (a public - static `Fill` - entry point, covering the supporting `FillRule` enum and the internal - `EdgeFlattener`/`ScanlineRasterizer` helpers inline), the `PathStroker` unit (a public - static `Stroke` entry point, covering the supporting `LineCap`/`LineJoin`/`StrokeStyle` types - and the internal `StrokePathFlattener`/`DashSplitter`/`StrokeOutliner` helpers inline), and the + static `Fill` entry point, covering the supporting `FillRule` enum and the internal + `EdgeFlattener`/`ScanlineRasterizer` helpers inline), the `PathStroker` unit (a public static + `Stroke` entry point, covering the supporting `LineCap`/`LineJoin`/`StrokeStyle` types and the + internal `StrokePathFlattener`/`DashSplitter`/`StrokeOutliner` helpers inline), and the `GradientPaint` unit (the public `Gradient`/`LinearGradient`/`RadialGradient`/`GradientStop`/ - `GradientSpread` types and the internal `GradientEvaluator` helper). - Fonts are reserved for a later phase. + `GradientSpread` types and the internal `GradientEvaluator` helper) +- **Fonts (Subsystem)** — TrueType (`glyf`-based) SFNT font support: the `TrueTypeFont` unit and + its internal `SfntContainer`/`CmapTable`/`GlyfLocaReader`/`HmtxHheaReader`/`KernTable` helpers, + producing `Geometry.Path` glyph outlines plus metrics and kerning The following OTS items are also covered: @@ -79,20 +82,24 @@ diagram or the prose below. ![Software Structure](SoftwareStructureView.svg) -CanvasNet is organized into four subsystems under the system level: the `Canvas` subsystem -(the `Surface` and `Rgba32` units, namespace `DemaConsulting.CanvasNet.Canvas`), the `Codecs` subsystem -(the `BmpCodec`, `PngCodec`, `TiffCodec`, and `JpegCodec` units, namespace `DemaConsulting.CanvasNet.Codecs`, -flat — no further nesting), the `Geometry` subsystem (the `Rect`, `Path`, `BezierFlattening`, and -`SvgArcConverter` units, namespace `DemaConsulting.CanvasNet.Geometry`, flat — no further nesting), -and the `Drawing` subsystem (the `PathFiller` unit, covering the supporting `FillRule` enum and -the internal `EdgeFlattener`/`ScanlineRasterizer` helpers inline, the `PathStroker` unit, -covering the supporting `LineCap`/`LineJoin`/`StrokeStyle` types and the internal +CanvasNet is organized into five subsystems under the system level: the `Canvas` subsystem +(the `Surface` and `Rgba32` units, namespace `DemaConsulting.CanvasNet.Canvas`), the `Codecs` +subsystem (the `BmpCodec`, `PngCodec`, `TiffCodec`, and `JpegCodec` units, namespace +`DemaConsulting.CanvasNet.Codecs`, flat — no further nesting), the `Geometry` subsystem (the +`Rect`, `Path`, `BezierFlattening`, and `SvgArcConverter` units, namespace +`DemaConsulting.CanvasNet.Geometry`, flat — no further nesting), the `Drawing` subsystem (the +`PathFiller` unit, covering the supporting `FillRule` enum and the internal +`EdgeFlattener`/`ScanlineRasterizer` helpers inline, the `PathStroker` unit, covering the +supporting `LineCap`/`LineJoin`/`StrokeStyle` types and the internal `StrokePathFlattener`/`DashSplitter`/`StrokeOutliner` helpers inline, and the `GradientPaint` unit, covering the public `Gradient`/`LinearGradient`/`RadialGradient`/`GradientStop`/ `GradientSpread` types and the internal `GradientEvaluator` helper inline, namespace -`DemaConsulting.CanvasNet.Drawing`, flat — no further nesting). As additional functionality is -added, further subsystems and nested subsystems would organize related units and provide -architectural boundaries with well-defined interfaces and responsibilities. +`DemaConsulting.CanvasNet.Drawing`, flat — no further nesting), and the `Fonts` subsystem (the +`TrueTypeFont` unit, covering the internal `SfntContainer`/`CmapTable`/`GlyfLocaReader`/ +`HmtxHheaReader`/`KernTable` helpers inline, namespace `DemaConsulting.CanvasNet.Fonts`, flat — +no further nesting). As additional functionality is added, further subsystems and nested +subsystems would organize related units and provide architectural boundaries with well-defined +interfaces and responsibilities. ## Folder Layout @@ -111,38 +118,46 @@ src/DemaConsulting.CanvasNet/ │ ├── TiffCodec.cs — 8-bit RGB/RGBA/Grayscale, strip-based TIFF loader/saver │ ├── JpegCodec.cs — Baseline/progressive JPEG loader and baseline JPEG saver │ └── NamespaceDoc.cs — Namespace-level XML documentation -├── Geometry/ - ├── Rect.cs — Axis-aligned bounding rectangle (position plus size) - ├── PathCommandType.cs — Enumeration of path drawing command kinds - ├── PathCommand.cs — Tagged-union path drawing command value - ├── Subpath.cs — One independent contour of a path - ├── Path.cs — Immutable vector path (ordered collection of subpaths) - ├── PathBuilder.cs — Mutable, fluent builder that produces a Path - ├── BezierFlattening.cs — Adaptive quadratic/cubic Bezier curve flattening - ├── SvgArcConverter.cs — SVG-style elliptical arc to cubic Bezier conversion - └── NamespaceDoc.cs — Namespace-level XML documentation -└── Drawing/ - ├── FillRule.cs — Nonzero/even-odd fill-rule enumeration - ├── EdgeFlattener.cs — Converts a Path's subpaths into closed polygons - ├── ScanlineRasterizer.cs — Analytic coverage-accumulation scanline rasterizer - ├── PathFiller.cs — Public entry point: fills a Path onto a Surface - ├── LineCap.cs — Stroke end-cap enumeration - ├── LineJoin.cs — Stroke corner-join enumeration - ├── StrokeStyle.cs — Immutable stroke-style configuration snapshot - ├── StrokePathFlattener.cs — Flattens subpaths while preserving open/closed state - ├── DashSplitter.cs — Applies dash-array and dash-offset semantics - ├── StrokeOutliner.cs — Converts stroked polylines into outline polygons - ├── PathStroker.cs — Public entry point: strokes a Path into outline geometry - ├── GradientSpread.cs — Gradient repeat-beyond-extent mode enumeration - ├── GradientStop.cs — Single offset/color stop within a gradient - ├── Gradient.cs — Abstract base for gradient paint definitions - ├── LinearGradient.cs — Gradient paint that varies along a straight axis - ├── RadialGradient.cs — Gradient paint that varies radially from a center point - ├── GradientEvaluator.cs — Resolves a gradient definition to a color at a point +├── Drawing/ +│ ├── FillRule.cs — Nonzero/even-odd fill-rule enumeration +│ ├── EdgeFlattener.cs — Converts a Path's subpaths into closed polygons +│ ├── ScanlineRasterizer.cs — Analytic coverage-accumulation scanline rasterizer +│ ├── PathFiller.cs — Public entry point: fills a Path onto a Surface +│ ├── LineCap.cs — Stroke end-cap enumeration +│ ├── LineJoin.cs — Stroke corner-join enumeration +│ ├── StrokeStyle.cs — Immutable stroke-style configuration snapshot +│ ├── StrokePathFlattener.cs — Flattens subpaths while preserving open/closed state +│ ├── DashSplitter.cs — Applies dash-array and dash-offset semantics +│ ├── StrokeOutliner.cs — Converts stroked polylines into outline polygons +│ ├── PathStroker.cs — Public entry point: strokes a Path into outline geometry +│ ├── GradientSpread.cs — Gradient repeat-beyond-extent mode enumeration +│ ├── GradientStop.cs — Single offset/color stop within a gradient +│ ├── Gradient.cs — Abstract base for gradient paint definitions +│ ├── LinearGradient.cs — Gradient paint that varies along a straight axis +│ ├── RadialGradient.cs — Gradient paint that varies radially from a center point +│ ├── GradientEvaluator.cs — Resolves a gradient definition to a color at a point +│ └── NamespaceDoc.cs — Namespace-level XML documentation +├── Fonts/ +│ ├── TrueTypeFont.cs — Public TrueType font loader/query entry point +│ ├── SfntContainer.cs — SFNT offset-table and directory parser +│ ├── CmapTable.cs — Unicode codepoint-to-glyph-index lookup +│ ├── GlyfLocaReader.cs — Glyph location parsing and outline decoding +│ ├── HmtxHheaReader.cs — Horizontal metrics parsing and advance-width lookup +│ ├── KernTable.cs — Format-0 horizontal kerning lookup +│ └── NamespaceDoc.cs — Namespace-level XML documentation +└── Geometry/ + ├── Rect.cs — Axis-aligned bounding rectangle (position plus size) + ├── PathCommandType.cs — Enumeration of path drawing command kinds + ├── PathCommand.cs — Tagged-union path drawing command value + ├── Subpath.cs — One independent contour of a path + ├── Path.cs — Immutable vector path (ordered collection of subpaths) + ├── PathBuilder.cs — Mutable, fluent builder that produces a Path + ├── BezierFlattening.cs — Adaptive quadratic/cubic Bezier curve flattening + ├── SvgArcConverter.cs — SVG-style elliptical arc to cubic Bezier conversion └── NamespaceDoc.cs — Namespace-level XML documentation ``` -This four-subsystem folder structure reflects the small number of subsystems in the system +This five-subsystem folder structure reflects the small number of subsystems in the system today. As the system grows with additional subsystems and units, the folder structure will expand further to mirror the software architecture. `Canvas/Surface.cs` also gained a `CompositeOverSpan` method used internally by `Drawing/PathFiller.cs`, and the `Drawing` diff --git a/docs/reqstream/canvas-net.yaml b/docs/reqstream/canvas-net.yaml index dd765685..63233d0c 100644 --- a/docs/reqstream/canvas-net.yaml +++ b/docs/reqstream/canvas-net.yaml @@ -3,6 +3,8 @@ # # These requirements describe the externally visible behavior of the library # from the perspective of its consumers, without specifying implementation details. +# cspell:ignore glyf sfnt cmap loca hmtx hhea maxp notdef +# cspell:ignore codepoint codepoints subtable subtables subsetted renderable formedness sections: - title: Canvas API @@ -879,3 +881,69 @@ sections: - PathFiller_Fill_Gradient_NullPaint_ThrowsArgumentNullException - PathFiller_Fill_Gradient_NonPositiveFlattenTolerance_ThrowsArgumentOutOfRangeException - PathFiller_Fill_Gradient_UndefinedFillRule_ThrowsArgumentOutOfRangeException + + - title: Fonts API + requirements: + - id: CanvasNet-Lib-FontsLoad + title: >- + The library shall load a TrueType font from a stream or a file path, exposing its + metrics, and validating its input arguments and structural well-formedness with a + predictable exception contract. + tags: [system] + justification: | + Callers need a reliable, self-contained way to load a TrueType font file before + extracting any glyph geometry or metrics from it, with clear failure behavior for + invalid arguments or malformed/unsupported font data. + children: + - CanvasNet-Fonts-Load + tests: + - CanvasNet_SystemIntegration_LoadFontAndQueryMetrics_ReturnsExpectedValues + + - id: CanvasNet-Lib-FontsGlyphMapping + title: >- + The library shall map a Unicode codepoint, including codepoints outside the Basic + Multilingual Plane, to a glyph index, falling back to the conventional "missing glyph" + index without throwing when the codepoint is unmapped or the font has no usable + character map. + tags: [system] + justification: | + Callers rendering text need every codepoint resolved to a glyph index before any + outline can be extracted, including the common cases of unsupported characters or a + font with no (or only a partially supported) character map. + children: + - CanvasNet-Fonts-GlyphMapping + tests: + - CanvasNet_SystemIntegration_LoadFontAndFillGlyphOutline_ReturnsExpectedPixels + + - id: CanvasNet-Lib-FontsGlyphOutlineExtraction + title: >- + The library shall extract a glyph's outline as vector path geometry, including simple + glyphs, empty glyphs, and composite glyphs built from transformed (and possibly nested) + component glyphs, never hanging or crashing on a malformed or malicious composite-glyph + cycle, excessive composite blow-up, point-matched component, or otherwise malformed + glyph data. + tags: [system] + justification: | + Callers need renderable outline geometry for every glyph shape a TrueType font may + define, while a malformed or malicious font's glyph data must never be able to hang or + crash the calling process. + children: + - CanvasNet-Fonts-GlyphOutlineExtraction + tests: + - CanvasNet_SystemIntegration_LoadFontAndFillGlyphOutline_ReturnsExpectedPixels + + - id: CanvasNet-Lib-FontsMetrics + title: >- + The library shall report each glyph's horizontal advance width and the pairwise + kerning adjustment between two glyphs, validating the glyph index argument to the + advance-width query and always returning a well-defined "no adjustment" value for + kerning, including when the font has no kerning table at all. + tags: [system] + justification: | + Callers laying out successive glyphs along a line need each glyph's advance width and + any font-defined pairwise kerning adjustment, with kerning always safely queryable + regardless of whether the font actually defines any. + children: + - CanvasNet-Fonts-Metrics + tests: + - CanvasNet_SystemIntegration_LoadFontAndQueryMetrics_ReturnsExpectedValues diff --git a/docs/reqstream/canvas-net/fonts.yaml b/docs/reqstream/canvas-net/fonts.yaml new file mode 100644 index 00000000..022f4c0a --- /dev/null +++ b/docs/reqstream/canvas-net/fonts.yaml @@ -0,0 +1,101 @@ +--- +# Software Subsystem Requirements for Fonts +# +# These requirements decompose the CanvasNet system's TrueType font support into the Fonts +# subsystem's own responsibility: loading a TrueType (glyf-based) SFNT font, mapping Unicode +# codepoints to glyph indices, extracting glyph outlines as DemaConsulting.CanvasNet.Geometry.Path +# geometry, and reporting metrics/advance widths/kerning (the TrueTypeFont unit). Each requirement +# here links down to the unit requirements that implement it. +# cspell:ignore glyf sfnt codepoint codepoints formedness renderable + +sections: + - title: CanvasNet Requirements + sections: + - title: Fonts Requirements + requirements: + - id: CanvasNet-Fonts-Load + title: >- + The Fonts subsystem shall load a TrueType font from a stream or a file path, + exposing its metrics, and validating its input arguments and structural well- + formedness with a predictable exception contract. + justification: | + Callers need a reliable, self-contained way to load a TrueType font file before + extracting any glyph geometry or metrics from it, with clear failure behavior for + invalid arguments or malformed/unsupported font data. + children: + - CanvasNet-Fonts-TrueTypeFont-LoadStream + - CanvasNet-Fonts-TrueTypeFont-LoadPath + - CanvasNet-Fonts-TrueTypeFont-LoadNullStream + - CanvasNet-Fonts-TrueTypeFont-LoadNullPath + - CanvasNet-Fonts-TrueTypeFont-LoadEmptyPath + - CanvasNet-Fonts-TrueTypeFont-RejectUnrecognizedVersion + - CanvasNet-Fonts-TrueTypeFont-RejectCffOutlines + - CanvasNet-Fonts-TrueTypeFont-RejectMissingRequiredTable + - CanvasNet-Fonts-TrueTypeFont-RejectUnsupportedGlyphOutlineVersion + - CanvasNet-Fonts-TrueTypeFont-RejectInvalidUnitsPerEm + - CanvasNet-Fonts-TrueTypeFont-RejectInvalidGlyphLocationFormat + - CanvasNet-Fonts-TrueTypeFont-RejectOutOfBoundsTable + - CanvasNet-Fonts-TrueTypeFont-RejectTruncatedStream + tests: + - CanvasNet_SystemIntegration_LoadFontAndQueryMetrics_ReturnsExpectedValues + + - id: CanvasNet-Fonts-GlyphMapping + title: >- + The Fonts subsystem shall map a Unicode codepoint (including codepoints outside the + Basic Multilingual Plane) to a glyph index, falling back to the conventional + "missing glyph" index without throwing when the codepoint is unmapped or the font + has no usable character map. + justification: | + Callers rendering text need every codepoint resolved to a glyph index before any + outline can be extracted, including the common cases of unsupported characters or a + font with no (or only a partially supported) character map. + children: + - CanvasNet-Fonts-TrueTypeFont-GlyphIndexBasicMultilingualPlane + - CanvasNet-Fonts-TrueTypeFont-GlyphIndexSupplementaryPlane + - CanvasNet-Fonts-TrueTypeFont-GlyphIndexUnmappedFallback + - CanvasNet-Fonts-TrueTypeFont-GlyphIndexNoCharacterMap + tests: + - CanvasNet_SystemIntegration_LoadFontAndFillGlyphOutline_ReturnsExpectedPixels + + - id: CanvasNet-Fonts-GlyphOutlineExtraction + title: >- + The Fonts subsystem shall extract a glyph's outline as vector path geometry, + including simple glyphs, empty glyphs, and composite glyphs built from + transformed (and possibly nested) component glyphs, rejecting composite-glyph + cycles, excessive composite blow-up, point-matched components, and malformed + glyph data. + justification: | + Callers need renderable outline geometry for every glyph shape a TrueType font may + define, while a malformed or malicious font's glyph data must never be able to hang + or crash the calling process. + children: + - CanvasNet-Fonts-TrueTypeFont-GlyphOutlineSimple + - CanvasNet-Fonts-TrueTypeFont-GlyphOutlineEmpty + - CanvasNet-Fonts-TrueTypeFont-GlyphOutlineComposite + - CanvasNet-Fonts-TrueTypeFont-GlyphOutlineNestedComposite + - CanvasNet-Fonts-TrueTypeFont-GlyphOutlineRejectsCircularComposite + - CanvasNet-Fonts-TrueTypeFont-GlyphOutlineRejectsExcessiveComposite + - CanvasNet-Fonts-TrueTypeFont-GlyphOutlineRejectsPointMatchedComponent + - CanvasNet-Fonts-TrueTypeFont-GlyphOutlineRejectsMalformedGlyphData + - CanvasNet-Fonts-TrueTypeFont-GlyphIndexValidation + tests: + - CanvasNet_SystemIntegration_LoadFontAndFillGlyphOutline_ReturnsExpectedPixels + + - id: CanvasNet-Fonts-Metrics + title: >- + The Fonts subsystem shall report each glyph's horizontal advance width and the + pairwise kerning adjustment between two glyphs, validating the glyph index argument + to the advance-width query and always returning a well-defined "no adjustment" + value for kerning, including when the font has no kerning table at all. + justification: | + Callers laying out successive glyphs along a line need each glyph's advance width + and any font-defined pairwise kerning adjustment, with kerning always safely + queryable regardless of whether the font actually defines any. + children: + - CanvasNet-Fonts-TrueTypeFont-AdvanceWidth + - CanvasNet-Fonts-TrueTypeFont-AdvanceWidthValidation + - CanvasNet-Fonts-TrueTypeFont-KerningLookup + - CanvasNet-Fonts-TrueTypeFont-KerningMissOrAbsentFallback + - CanvasNet-Fonts-TrueTypeFont-KerningMalformedTableTolerant + tests: + - CanvasNet_SystemIntegration_LoadFontAndQueryMetrics_ReturnsExpectedValues diff --git a/docs/reqstream/canvas-net/fonts/true-type-font.yaml b/docs/reqstream/canvas-net/fonts/true-type-font.yaml new file mode 100644 index 00000000..c70145c0 --- /dev/null +++ b/docs/reqstream/canvas-net/fonts/true-type-font.yaml @@ -0,0 +1,379 @@ +--- +# Software Unit Requirements for TrueTypeFont +# +# These requirements describe the design-level behavior of the TrueTypeFont unit. +# cspell:ignore glyf sfnt cmap loca hmtx hhea maxp notdef +# cspell:ignore codepoint codepoints subtable subtables subsetted renderable + +sections: + - title: CanvasNet Requirements + sections: + - title: Fonts Requirements + sections: + - title: TrueTypeFont Requirements + requirements: + - id: CanvasNet-Fonts-TrueTypeFont-LoadStream + title: >- + The TrueTypeFont class shall load a TrueType font from a stream and expose its + UnitsPerEm, Ascender, Descender, LineGap, and GlyphCount metrics. + justification: | + Callers need to load a font and inspect its top-level metrics before laying out or + rendering any text. + tests: + - TrueTypeFont_Load_WellFormedFont_ExposesMetrics + + - id: CanvasNet-Fonts-TrueTypeFont-LoadPath + title: The TrueTypeFont class shall load a TrueType font given a file path. + justification: | + Callers commonly have font files stored on disk and need a convenient way to load + them without manually opening a stream. + tests: + - TrueTypeFont_Load_Path_ReadsFromFile + + - id: CanvasNet-Fonts-TrueTypeFont-LoadNullStream + title: The TrueTypeFont class shall reject a null stream argument to Load with ArgumentNullException. + justification: | + A null stream has no data to read; rejecting it immediately prevents a + NullReferenceException deeper in the call stack. + tests: + - TrueTypeFont_Load_NullStream_ThrowsArgumentNullException + + - id: CanvasNet-Fonts-TrueTypeFont-LoadNullPath + title: The TrueTypeFont class shall reject a null path argument to Load with ArgumentNullException. + justification: | + A null path cannot identify a source file; rejecting it immediately prevents a + NullReferenceException deeper in the call stack. + tests: + - TrueTypeFont_Load_NullPath_ThrowsArgumentNullException + + - id: CanvasNet-Fonts-TrueTypeFont-LoadEmptyPath + title: The TrueTypeFont class shall reject an empty path argument to Load with ArgumentException. + justification: | + An empty path cannot identify a source file; rejecting it immediately prevents an + obscure file-system error. + tests: + - TrueTypeFont_Load_EmptyPath_ThrowsArgumentException + + - id: CanvasNet-Fonts-TrueTypeFont-RejectUnrecognizedVersion + title: >- + The TrueTypeFont class shall reject a font whose declared format version is not + recognized as TrueType with InvalidDataException. + justification: | + Data whose declared version is not a supported TrueType variant is not a font this + class can interpret; failing clearly here prevents misreading arbitrary data as a + table directory. + tests: + - SfntContainer_Parse_UnrecognizedVersion_ThrowsInvalidDataException + + - id: CanvasNet-Fonts-TrueTypeFont-RejectCffOutlines + title: >- + The TrueTypeFont class shall reject a font that declares CFF/OpenType outlines with + InvalidDataException. + justification: | + CFF-based outlines use an entirely different, unsupported outline representation; + failing clearly here prevents silently producing empty or incorrect glyph outlines. + tests: + - TrueTypeFont_Load_OttoFont_ThrowsInvalidDataException + - SfntContainer_Parse_OttoVersion_ThrowsInvalidDataException + + - id: CanvasNet-Fonts-TrueTypeFont-RejectMissingRequiredTable + title: >- + The TrueTypeFont class shall reject a font missing any required table (head, maxp, + hhea, hmtx, loca, or glyf) with InvalidDataException. + justification: | + Every required table supplies metrics or glyph data this class must read; a font + missing one cannot be queried correctly, so failing at load time avoids returning an + object in a partially usable state. + tests: + - TrueTypeFont_Load_MissingRequiredTable_ThrowsInvalidDataException + + - id: CanvasNet-Fonts-TrueTypeFont-RejectUnsupportedGlyphOutlineVersion + title: >- + The TrueTypeFont class shall reject a font whose glyph-count table uses an + unsupported version with InvalidDataException. + justification: | + A glyph-count table using an unsupported version does not describe TrueType-style + outline data in the layout this class expects; failing clearly here prevents reading + fields at the wrong byte offsets. + tests: + - TrueTypeFont_Load_MaxpVersion05_ThrowsInvalidDataException + + - id: CanvasNet-Fonts-TrueTypeFont-RejectInvalidUnitsPerEm + title: >- + The TrueTypeFont class shall reject a font declaring zero units per em with + InvalidDataException. + justification: | + A zero units-per-em value cannot be used to scale glyph outlines to any point size; + failing clearly here prevents a caller from later dividing by zero. + tests: + - TrueTypeFont_Load_ZeroUnitsPerEm_ThrowsInvalidDataException + + - id: CanvasNet-Fonts-TrueTypeFont-RejectInvalidGlyphLocationFormat + title: >- + The TrueTypeFont class shall reject a font declaring an unsupported glyph-location + table format with InvalidDataException. + justification: | + Only the two documented glyph-location table formats have a defined byte layout; + failing clearly here prevents misinterpreting glyph offsets under an unrecognized + layout. + tests: + - TrueTypeFont_Load_InvalidIndexToLocFormat_ThrowsInvalidDataException + + - id: CanvasNet-Fonts-TrueTypeFont-RejectOutOfBoundsTable + title: >- + The TrueTypeFont class shall reject a font whose table directory declares a table + offset or length outside the bounds of the font data, including combinations that + would overflow ordinary arithmetic, with InvalidDataException. + justification: | + A table directory entry pointing outside the font data (or whose offset and length + would overflow) could otherwise cause an out-of-bounds read; failing clearly here + protects against malformed or malicious font files. + tests: + - SfntContainer_Parse_TableDirectoryEntryOverflowsUint_ThrowsInvalidDataException + - SfntContainer_Parse_TableDirectoryEntryOutOfBounds_ThrowsInvalidDataException + + - id: CanvasNet-Fonts-TrueTypeFont-RejectTruncatedStream + title: >- + The TrueTypeFont class shall reject a font whose stream ends before its declared + table directory or table data has been fully read with InvalidDataException. + justification: | + A truncated font stream cannot supply complete table data; failing clearly here + prevents returning a font object with partially read or missing data. + tests: + - TrueTypeFont_Load_TruncatedStream_ThrowsInvalidDataException + - SfntContainer_Parse_TruncatedOffsetTable_ThrowsInvalidDataException + - SfntContainer_Parse_TruncatedTableDirectory_ThrowsInvalidDataException + + - id: CanvasNet-Fonts-TrueTypeFont-GlyphIndexBasicMultilingualPlane + title: >- + The TrueTypeFont class shall map a Basic Multilingual Plane Unicode codepoint to its + glyph index via GetGlyphIndex when the font's character map supports it. + justification: | + Callers most commonly need to render text made up of Basic Multilingual Plane + codepoints (for example Latin text), so this is the primary character-to-glyph + lookup path. + tests: + - CmapTable_Format4_BmpLookup_ReturnsMappedGlyphIndex + - TrueTypeFont_Load_EndToEnd_GlyphIndexOutlineAdvanceAndKerning + + - id: CanvasNet-Fonts-TrueTypeFont-GlyphIndexSupplementaryPlane + title: >- + The TrueTypeFont class shall map a Unicode codepoint outside the Basic Multilingual + Plane to its glyph index via GetGlyphIndex when the font's character map supports it. + justification: | + Callers rendering emoji or other supplementary-plane characters need those + codepoints resolved to glyph indices, not just codepoints within the Basic + Multilingual Plane. + tests: + - CmapTable_Format12_SupplementaryPlaneLookup_ReturnsMappedGlyphIndex + - CmapTable_Format12_GroupRange_ReturnsOffsetGlyphIndex + + - id: CanvasNet-Fonts-TrueTypeFont-GlyphIndexUnmappedFallback + title: >- + The TrueTypeFont class shall return glyph index 0 from GetGlyphIndex for a codepoint + the font's character map does not map, without throwing. + justification: | + An unmapped codepoint is a normal occurrence (for example a character the font does + not support); returning the conventional "missing glyph" index lets a caller render a + fallback rather than crash. + tests: + - CmapTable_Format4_UnmappedCodepoint_ReturnsZero + - TrueTypeFont_GetGlyphIndex_UnmappedCodepoint_NeverThrows + + - id: CanvasNet-Fonts-TrueTypeFont-GlyphIndexNoCharacterMap + title: >- + The TrueTypeFont class shall return glyph index 0 from GetGlyphIndex for every + codepoint when the font has no usable character map, without throwing. + justification: | + Some legitimately embedded or subsetted fonts omit a character map entirely, or use + only unsupported mapping schemes; degrading gracefully rather than rejecting the font + outright lets a caller still use the font by glyph index directly. + tests: + - CmapTable_NoSupportedSubtable_GetGlyphIndexReturnsZero + - CmapTable_Empty_GetGlyphIndexReturnsZero + - TrueTypeFont_Load_NoCmapOrKern_FallsBackGracefully + + - id: CanvasNet-Fonts-TrueTypeFont-GlyphOutlineSimple + title: >- + The TrueTypeFont class shall decode a simple glyph's outline via GetGlyphOutline as + a Path made up of line segments and quadratic Bezier curves, in raw font design + units. + justification: | + Callers need a renderable outline for the most common glyph shape (a directly + described contour), expressed using the curve types the Geometry namespace already + supports natively. + tests: + - GlyfLocaReader_SimpleGlyph_AllOnCurve_ProducesLineSegments + - GlyfLocaReader_SimpleGlyph_ConsecutiveOffCurvePoints_ProducesImpliedMidpointQuadratics + - GlyfLocaReader_SimpleGlyph_MixedOnAndOffCurve_ProducesQuadraticToRealOnCurvePoint + + - id: CanvasNet-Fonts-TrueTypeFont-GlyphOutlineEmpty + title: >- + The TrueTypeFont class shall return an empty Path from GetGlyphOutline for a glyph + with no contour data, such as the space character. + justification: | + A glyph with no visible marks (for example space) is a normal, valid glyph; a caller + filling its outline should simply render nothing rather than receive an error. + tests: + - GlyfLocaReader_EmptyGlyph_ZeroContours_ProducesEmptyPath + - GlyfLocaReader_ZeroLengthGlyph_ProducesEmptyPath + + - id: CanvasNet-Fonts-TrueTypeFont-GlyphOutlineComposite + title: >- + The TrueTypeFont class shall decode a composite glyph's outline via GetGlyphOutline + by resolving and transforming (translating, scaling, and/or rotating/skewing via a + 2x2 matrix) each of its referenced component glyphs. + justification: | + Many accented and compound characters are defined by referencing and positioning + simpler glyphs rather than repeating their outlines; supporting this is required to + render such characters correctly. + tests: + - GlyfLocaReader_CompositeGlyph_SingleComponent_ProducesTranslatedOutline + - GlyfLocaReader_CompositeGlyph_ScaledComponent_ScalesOutline + - GlyfLocaReader_CompositeGlyph_TwoByTwoTransform_TransformsOutline + - GlyfLocaReader_CompositeGlyph_MultipleComponents_ProducesMultipleSubpaths + + - id: CanvasNet-Fonts-TrueTypeFont-GlyphOutlineNestedComposite + title: >- + The TrueTypeFont class shall decode a composite glyph via GetGlyphOutline whose own + components are themselves composite glyphs, resolving the full nesting correctly. + justification: | + Some fonts build compound characters from components that are themselves composite, + not just simple, glyphs; correctly resolving this nesting is required for those + characters to render. + tests: + - GlyfLocaReader_CompositeGlyph_NestedComposite_ResolvesRecursively + + - id: CanvasNet-Fonts-TrueTypeFont-GlyphOutlineRejectsCircularComposite + title: >- + The TrueTypeFont class shall reject a composite glyph that references itself, + directly or indirectly, with InvalidDataException rather than hanging or crashing. + justification: | + A malformed or malicious font could define a composite glyph that references itself; + detecting and rejecting this deterministically prevents an unbounded recursion from + hanging or crashing the calling process. + tests: + - GlyfLocaReader_CompositeGlyph_SelfReferentialCycle_ThrowsInvalidDataExceptionViaDepthCap + + - id: CanvasNet-Fonts-TrueTypeFont-GlyphOutlineRejectsExcessiveComposite + title: >- + The TrueTypeFont class shall reject a composite glyph that would resolve to an + excessive total number of components with InvalidDataException rather than hanging + or crashing. + justification: | + A malformed or malicious font could define composite glyphs that reference each + other in a way that multiplies out to an enormous number of components without ever + forming a literal cycle; bounding the total resolved component count prevents this + from hanging or crashing the calling process. + tests: + - GlyfLocaReader_CompositeGlyph_ExponentialBlowUp_ThrowsInvalidDataExceptionViaComponentCap + + - id: CanvasNet-Fonts-TrueTypeFont-GlyphOutlineRejectsPointMatchedComponent + title: >- + The TrueTypeFont class shall reject a composite glyph component that positions + itself by matching points rather than by an explicit offset, with + InvalidDataException. + justification: | + Point-matched component positioning is not supported by this class; rejecting it + clearly prevents silently mis-positioning the component instead of producing an + incorrect but seemingly valid outline. + tests: + - GlyfLocaReader_CompositeGlyph_PointMatchedComponent_ThrowsInvalidDataException + + - id: CanvasNet-Fonts-TrueTypeFont-GlyphOutlineRejectsMalformedGlyphData + title: >- + The TrueTypeFont class shall reject malformed or truncated glyph outline data, + including an out-of-range referenced component glyph index and an invalid declared + contour count, with InvalidDataException. + justification: | + Malformed glyph data cannot be decoded into a correct outline; failing clearly here + prevents producing a garbled or partially decoded outline that a caller could + mistake for valid glyph geometry. + tests: + - GlyfLocaReader_CompositeGlyph_OutOfRangeComponentGlyphIndex_ThrowsInvalidDataException + - GlyfLocaReader_TruncatedGlyphHeader_ThrowsInvalidDataException + - GlyfLocaReader_InvalidContourCount_ThrowsInvalidDataException + - GlyfLocaReader_Parse_LocaEntryExceedsGlyfBounds_ThrowsInvalidDataException + - GlyfLocaReader_Parse_NonMonotonicLocaEntries_ThrowsInvalidDataException + - GlyfLocaReader_Parse_TruncatedLoca_ThrowsInvalidDataException + + - id: CanvasNet-Fonts-TrueTypeFont-GlyphIndexValidation + title: >- + The TrueTypeFont class shall reject an out-of-range glyph index argument to + GetGlyphOutline with ArgumentOutOfRangeException. + justification: | + An out-of-range glyph index does not identify any glyph in the font; rejecting it + immediately prevents an out-of-bounds read deeper in the call stack. + tests: + - TrueTypeFont_GetGlyphOutline_NegativeGlyphIndex_ThrowsArgumentOutOfRangeException + - TrueTypeFont_GetGlyphOutline_GlyphIndexTooLarge_ThrowsArgumentOutOfRangeException + - GlyfLocaReader_GetGlyphOutline_NegativeGlyphIndex_ThrowsArgumentOutOfRangeException + - GlyfLocaReader_GetGlyphOutline_GlyphIndexTooLarge_ThrowsArgumentOutOfRangeException + + - id: CanvasNet-Fonts-TrueTypeFont-AdvanceWidth + title: >- + The TrueTypeFont class shall report a glyph's horizontal advance width via + GetAdvanceWidth, reusing the font's last explicit advance-width entry for any glyph + index beyond the font's declared count of explicit entries. + justification: | + Callers need each glyph's advance width to lay out successive glyphs along a line; + many fonts declare explicit advance widths only up to a certain glyph index and + expect the last one reused for the remainder, per the format's own convention. + tests: + - HmtxHheaReader_Parse_ExposesMetrics + - HmtxHheaReader_GetAdvanceWidth_TailGlyph_ReusesLastEntry + - TrueTypeFont_Load_EndToEnd_GlyphIndexOutlineAdvanceAndKerning + + - id: CanvasNet-Fonts-TrueTypeFont-AdvanceWidthValidation + title: >- + The TrueTypeFont class shall reject an out-of-range glyph index argument to + GetAdvanceWidth with ArgumentOutOfRangeException. + justification: | + An out-of-range glyph index does not identify any glyph in the font; rejecting it + immediately prevents an out-of-bounds read deeper in the call stack. + tests: + - TrueTypeFont_GetAdvanceWidth_GlyphIndexTooLarge_ThrowsArgumentOutOfRangeException + + - id: CanvasNet-Fonts-TrueTypeFont-KerningLookup + title: >- + The TrueTypeFont class shall report the pairwise kerning adjustment between two + glyphs via GetKerning when the font's kerning table defines one for that pair. + justification: | + Callers positioning successive glyphs need any pair-specific spacing adjustment the + font designer defined, beyond each glyph's own advance width, to reproduce the + font's intended letter spacing. + tests: + - KernTable_Format0_KnownPair_ReturnsValue + - TrueTypeFont_Load_EndToEnd_GlyphIndexOutlineAdvanceAndKerning + + - id: CanvasNet-Fonts-TrueTypeFont-KerningMissOrAbsentFallback + title: >- + The TrueTypeFont class shall return 0 from GetKerning, without throwing, for a glyph + pair the font's kerning table does not define, for any glyph index (including + out-of-range indices), and when the font has no kerning table at all. + justification: | + Most glyph pairs have no special kerning adjustment, and many fonts have no kerning + table at all; returning a well-defined "no adjustment" value lets a caller always + call GetKerning unconditionally without special-casing its absence, and never risk a + crash from an out-of-range index used purely for a kerning lookup. + tests: + - KernTable_Format0_UnknownPair_ReturnsZero + - KernTable_Empty_GetKerningReturnsZero + - TrueTypeFont_GetKerning_OutOfRangeGlyphIndex_NeverThrows + + - id: CanvasNet-Fonts-TrueTypeFont-KerningMalformedTableTolerant + title: >- + The TrueTypeFont class shall return 0 from GetKerning, without throwing, when the + font's kerning table is malformed, uses only unsupported subtable formats, or is + otherwise unusable. + justification: | + A malformed or unsupported kerning table does not prevent the rest of the font from + being used correctly; failing the entire load over an optional, best-effort table + would be excessively strict. + tests: + - KernTable_NoPairs_ReturnsZero + - KernTable_ZeroLengthTable_IsTolerant_ReturnsZero + - KernTable_TruncatedTable_IsTolerant_ReturnsZero + - KernTable_MalformedPairCount_IsTolerant_ReturnsZero + - KernTable_UnsupportedFormat2Subtable_IsTolerant_ReturnsZero + - KernTable_CrossStreamCoverage_IsSkipped_ReturnsZero diff --git a/docs/sysml2/model/canvas-net.sysml b/docs/sysml2/model/canvas-net.sysml index eed46d6b..cc69c8e4 100644 --- a/docs/sysml2/model/canvas-net.sysml +++ b/docs/sysml2/model/canvas-net.sysml @@ -12,11 +12,12 @@ package CanvasNet { comment designRef /* Design: docs/design/canvas-net.md */ comment verificationRef /* Verification: docs/verification/canvas-net.md */ comment reqRef /* Requirements: docs/reqstream/canvas-net.yaml */ - comment /* The Drawing subsystem renders Geometry.Path geometry onto a Canvas.Surface through PathFiller (direct fill, solid color or GradientPaint), PathStroker (stroke-to-fill conversion), and GradientPaint (linear/radial gradient evaluation); fonts are reserved for a later phase. */ + comment /* The Drawing subsystem renders Geometry.Path geometry onto a Canvas.Surface through PathFiller (direct fill, solid color or GradientPaint), PathStroker (stroke-to-fill conversion), and GradientPaint (linear/radial gradient evaluation), while the Fonts subsystem loads glyph-based TrueType fonts and produces Geometry.Path glyph outlines plus metrics and kerning for callers that want text rendering. */ part canvas : Canvas; part codecs : Codecs; part geometry : Geometry; part drawing : Drawing; + part fonts : Fonts; } } diff --git a/docs/sysml2/model/canvas-net/drawing.sysml b/docs/sysml2/model/canvas-net/drawing.sysml index 606e0bfe..315b9ec7 100644 --- a/docs/sysml2/model/canvas-net/drawing.sysml +++ b/docs/sysml2/model/canvas-net/drawing.sysml @@ -1,6 +1,6 @@ package CanvasNet { part def Drawing { - doc /* Antialiased vector-rendering subsystem for Geometry.Path geometry, supporting solid-color and gradient paint. Provides PathFiller (direct path filling, solid color or GradientPaint), PathStroker (stroke-to-fill conversion with standard cap/join/dash styling), and GradientPaint (linear/radial gradient definition and evaluation), consuming Canvas.Surface and Geometry.Path; neither Canvas nor Geometry depends on Drawing. Fonts remain reserved for a later phase. */ + doc /* Antialiased vector-rendering subsystem for Geometry.Path geometry, supporting solid-color and gradient paint. Provides PathFiller (direct path filling, solid color or GradientPaint), PathStroker (stroke-to-fill conversion with standard cap/join/dash styling), and GradientPaint (linear/radial gradient definition and evaluation), consuming Canvas.Surface and Geometry.Path; neither Canvas nor Geometry depends on Drawing, although callers may combine Drawing with Fonts-produced glyph paths. */ comment designRef /* Design: docs/design/canvas-net/drawing.md */ comment verificationRef /* Verification: docs/verification/canvas-net/drawing.md */ diff --git a/docs/sysml2/model/canvas-net/fonts.sysml b/docs/sysml2/model/canvas-net/fonts.sysml new file mode 100644 index 00000000..d5c09664 --- /dev/null +++ b/docs/sysml2/model/canvas-net/fonts.sysml @@ -0,0 +1,12 @@ +package CanvasNet { + part def Fonts { + doc /* TrueType font-loading subsystem exposing a single public TrueTypeFont unit. It loads glyph-based SFNT fonts, maps Unicode codepoints to glyph indices, extracts glyph outlines as Geometry.Path geometry, and reports horizontal metrics and basic format-0 kerning. Fonts depends only on Geometry; callers combine its output with Drawing and Canvas if they want rendered pixels. */ + + comment testRef /* Test: test/DemaConsulting.CanvasNet.Tests/CanvasNetTests.cs, test/DemaConsulting.CanvasNet.Tests/TestSupport/SyntheticFontBuilder.cs */ + comment designRef /* Design: docs/design/canvas-net/fonts.md */ + comment verificationRef /* Verification: docs/verification/canvas-net/fonts.md */ + comment reqRef /* Requirements: docs/reqstream/canvas-net/fonts.yaml */ + + part trueTypeFont : TrueTypeFont; + } +} diff --git a/docs/sysml2/model/canvas-net/fonts/true-type-font.sysml b/docs/sysml2/model/canvas-net/fonts/true-type-font.sysml new file mode 100644 index 00000000..e6b32a15 --- /dev/null +++ b/docs/sysml2/model/canvas-net/fonts/true-type-font.sysml @@ -0,0 +1,11 @@ +package CanvasNet { + part def TrueTypeFont { + doc /* Public TrueType font loader/query unit fronting SFNT container parsing, cmap lookup, glyf/loca outline decoding, hhea/hmtx metrics, and tolerant format-0 kern lookup. */ + + comment sourceRef /* Source: src/DemaConsulting.CanvasNet/Fonts/TrueTypeFont.cs, src/DemaConsulting.CanvasNet/Fonts/SfntContainer.cs, src/DemaConsulting.CanvasNet/Fonts/CmapTable.cs, src/DemaConsulting.CanvasNet/Fonts/GlyfLocaReader.cs, src/DemaConsulting.CanvasNet/Fonts/HmtxHheaReader.cs, src/DemaConsulting.CanvasNet/Fonts/KernTable.cs, src/DemaConsulting.CanvasNet/Fonts/NamespaceDoc.cs */ + comment testRef /* Test: test/DemaConsulting.CanvasNet.Tests/Fonts/SfntContainerTests.cs, test/DemaConsulting.CanvasNet.Tests/Fonts/CmapTableTests.cs, test/DemaConsulting.CanvasNet.Tests/Fonts/GlyfLocaReaderTests.cs, test/DemaConsulting.CanvasNet.Tests/Fonts/HmtxHheaReaderTests.cs, test/DemaConsulting.CanvasNet.Tests/Fonts/KernTableTests.cs, test/DemaConsulting.CanvasNet.Tests/Fonts/TrueTypeFontTests.cs, test/DemaConsulting.CanvasNet.Tests/TestSupport/SyntheticFontBuilder.cs */ + comment designRef /* Design: docs/design/canvas-net/fonts/true-type-font.md */ + comment verificationRef /* Verification: docs/verification/canvas-net/fonts/true-type-font.md */ + comment reqRef /* Requirements: docs/reqstream/canvas-net/fonts/true-type-font.yaml */ + } +} diff --git a/docs/sysml2/views/design-views.sysml b/docs/sysml2/views/design-views.sysml index 2ff524e1..847bd18d 100644 --- a/docs/sysml2/views/design-views.sysml +++ b/docs/sysml2/views/design-views.sysml @@ -27,4 +27,8 @@ package CanvasNet { view DrawingView { expose Drawing; } + + view FontsView { + expose Fonts; + } } diff --git a/docs/user_guide/introduction.md b/docs/user_guide/introduction.md index e57c085c..619fff9b 100644 --- a/docs/user_guide/introduction.md +++ b/docs/user_guide/introduction.md @@ -1,5 +1,7 @@ # Introduction + + ## Purpose This document is the user guide for CanvasNet, a .NET library providing a @@ -674,6 +676,49 @@ Saves a `Surface` to a file as a JPEG image, overwriting any existing file at `p - `ArgumentException`: Thrown when `path` is an empty string. - `ArgumentOutOfRangeException`: Thrown when `quality` is less than 1 or greater than 100. +### TrueTypeFont + +The `TrueTypeFont` class loads glyph-based TrueType (`glyf`-based) SFNT fonts and exposes raw +font-design-unit outlines, metrics, advance widths, and basic pairwise kerning. + +```csharp +public sealed class TrueTypeFont +{ + public static TrueTypeFont Load(Stream stream); + public static TrueTypeFont Load(string path); + + public int UnitsPerEm { get; } + public int Ascender { get; } + public int Descender { get; } + public int LineGap { get; } + public int GlyphCount { get; } + + public int GetGlyphIndex(int codepoint); + public Path GetGlyphOutline(int glyphIndex); + public int GetAdvanceWidth(int glyphIndex); + public int GetKerning(int leftGlyphIndex, int rightGlyphIndex); +} +``` + +`GetGlyphOutline` returns `Geometry.Path` in raw font-design-unit coordinates with Y increasing +upward, per the TrueType convention. Callers typically scale that path by the desired point size +and flip Y before rendering it through `PathFiller` or `PathStroker`. + +```csharp +var font = TrueTypeFont.Load("font.ttf"); +var glyphIndex = font.GetGlyphIndex('A'); +var outline = font.GetGlyphOutline(glyphIndex); +var advanceWidth = font.GetAdvanceWidth(glyphIndex); +``` + +**Exceptions:** + +- `ArgumentNullException`: Thrown when `Load` receives a null stream or path. +- `ArgumentException`: Thrown when `Load(string)` receives an empty path. +- `InvalidDataException`: Thrown when the font data is malformed, truncated, or unsupported. +- `ArgumentOutOfRangeException`: Thrown when `GetGlyphOutline` or `GetAdvanceWidth` receives an + out-of-range glyph index. + ### PathFiller The `PathFiller` static class fills a closed `Geometry.Path` with a solid color onto a `Surface`, @@ -1199,6 +1244,57 @@ PathFiller.Fill(canvas, square, gradient); Console.WriteLine(canvas[32, 32].A); // Output: 255 (at the gradient's center) ``` +## Example 12: Loading a TrueType Font and Filling a Glyph Outline + +```csharp +using DemaConsulting.CanvasNet.Canvas; +using DemaConsulting.CanvasNet.Drawing; +using DemaConsulting.CanvasNet.Fonts; +using DemaConsulting.CanvasNet.Geometry; +using System.Numerics; + +static Path TransformGlyph(Path glyph, float scale, float baselineY) +{ + var builder = new PathBuilder(); + + Vector2 ToCanvas(Vector2 point) => new(point.X * scale, baselineY - point.Y * scale); + + foreach (var subpath in glyph.Subpaths) + { + builder.MoveTo(ToCanvas(subpath.Start)); + foreach (var command in subpath.Commands) + { + switch (command.Type) + { + case PathCommandType.LineTo: + builder.LineTo(ToCanvas(command.EndPoint)); + break; + case PathCommandType.QuadraticBezierTo: + builder.QuadraticBezierTo( + ToCanvas(command.Control1), + ToCanvas(command.EndPoint)); + break; + case PathCommandType.Close: + builder.Close(); + break; + } + } + } + + return builder.Build(); +} + +var font = TrueTypeFont.Load("font.ttf"); +var glyphIndex = font.GetGlyphIndex('A'); +var glyphOutline = font.GetGlyphOutline(glyphIndex); +var scale = 48f / font.UnitsPerEm; +var canvasOutline = TransformGlyph(glyphOutline, scale, baselineY: 56f); + +var surface = new Surface(64, 64); +PathFiller.Fill(surface, canvasOutline, new Rgba32(20, 120, 255, 255)); +Console.WriteLine(font.GetAdvanceWidth(glyphIndex)); +``` + # References - [REF-1] Continuous Compliance Methodology () diff --git a/docs/verification/canvas-net.md b/docs/verification/canvas-net.md index 1ee2d5c4..31410afa 100644 --- a/docs/verification/canvas-net.md +++ b/docs/verification/canvas-net.md @@ -1,5 +1,8 @@ # System Verification Design + + + This document describes the system-level verification strategy for CanvasNet. ## Verification Approach @@ -186,8 +189,30 @@ the collaboration between `PathBuilder`/`Path` (`Geometry`), `PathFiller` (`Draw `Surface`/`Surface.CompositeOverSpan` (`Canvas`) together, rather than any one subsystem in isolation. +### Integration: Load a Font and Fill a Glyph Outline Returns Expected Pixels + +**Test**: `CanvasNet_SystemIntegration_LoadFontAndFillGlyphOutline_ReturnsExpectedPixels` + +Exercises end-to-end system behavior across the `Fonts`, `Geometry`, `Drawing`, and `Canvas` +subsystems together: loads a synthetic TrueType font, maps codepoint `'A'` to a glyph index, +extracts the glyph outline as `Geometry.Path`, scales and flips the outline into canvas +coordinates, and fills it through `PathFiller` onto a `Surface`. Asserts the mapped glyph index +is correct, the outline is non-trivial, the glyph interior renders fully opaque, and at least one +edge pixel has fractional alpha, confirming the system integrates font parsing with vector +rasterization successfully. + +### Integration: Load a Font and Query Metrics Returns Expected Values + +**Test**: `CanvasNet_SystemIntegration_LoadFontAndQueryMetrics_ReturnsExpectedValues` + +Exercises end-to-end system behavior for the `Fonts` subsystem's metric APIs: loads the same +synthetic TrueType font through `TrueTypeFont.Load`, then asserts `UnitsPerEm`, `Ascender`, +`Descender`, `GlyphCount`, `GetAdvanceWidth`, and `GetKerning` match the font's declared table +values. This scenario proves the system exposes font-level scalar metrics independently of glyph +rendering. + ## Acceptance Criteria -A system-level test run passes when all fourteen scenarios above pass without error or exception +A system-level test run passes when all sixteen scenarios above pass without error or exception beyond those explicitly asserted. Any unexpected exception, wrong exception type, or wrong return value constitutes a failure. diff --git a/docs/verification/canvas-net/fonts.md b/docs/verification/canvas-net/fonts.md new file mode 100644 index 00000000..c1408a79 --- /dev/null +++ b/docs/verification/canvas-net/fonts.md @@ -0,0 +1,60 @@ +## Fonts Subsystem Verification Design + + + + +This document describes the subsystem-level verification strategy for the `Fonts` subsystem (the +`TrueTypeFont` unit). + +### Verification Approach + +The `Fonts` subsystem is verified through the `TrueTypeFont` unit tests under +`test/DemaConsulting.CanvasNet.Tests/Fonts/` and through the two system-integration tests in +`CanvasNetTests.cs`. No dedicated subsystem test file exists. Instead, subsystem-level +requirements reuse the unit tests for parser and decoding behavior and reuse the system tests for +the integrated collaboration between `Fonts`, `Geometry`, `Drawing`, and `Canvas`. + +### Test Environment + +- **Framework**: xUnit v3 running under the .NET SDK +- **Execution**: `dotnet test` invoked by `build.ps1` and the CI pipeline +- **Mocking**: None required; the subsystem uses only in-house byte-array fixtures and in-process + calls to other CanvasNet public APIs +- **Fixtures**: Synthetic fonts are constructed in memory; no third-party font files are used + +### Acceptance Criteria + +The `Fonts` subsystem's verification passes when every `Fonts` unit test named in the +`TrueTypeFont` unit verification design passes, and when both `CanvasNet_SystemIntegration_*` +font scenarios pass without error or unexpected exception. + +### Test Scenarios + +#### CanvasNet-Fonts-Load: Loading a TrueType Font Exposes Metrics and Enforces the Declared Exception Contract + +**Test**: `CanvasNet_SystemIntegration_LoadFontAndQueryMetrics_ReturnsExpectedValues` + +Loads a synthetic font through `TrueTypeFont.Load`, asserts the top-level metrics exposed by the +public API, and proves the subsystem's load/query path is operational end to end. + +#### CanvasNet-Fonts-GlyphMapping: Unicode Codepoints Resolve to Glyph Indices with Predictable Fallback + +**Test**: `CanvasNet_SystemIntegration_LoadFontAndFillGlyphOutline_ReturnsExpectedPixels` + +Loads a synthetic font, maps codepoint `'A'` to a glyph index, and proves the mapped glyph can +be used immediately for outline extraction and rendering. + +#### CanvasNet-Fonts-GlyphOutlineExtraction: Decoded Glyph Geometry Interoperates with Geometry and Drawing + +**Test**: `CanvasNet_SystemIntegration_LoadFontAndFillGlyphOutline_ReturnsExpectedPixels` + +Extracts a glyph outline as `Geometry.Path`, scales and flips that path into canvas coordinates, +and fills it through `PathFiller` onto a `Surface`, confirming the subsystem returns renderable +vector geometry rather than opaque internal data. + +#### CanvasNet-Fonts-Metrics: Advance Width and Kerning Remain Queryable Through the Public API + +**Test**: `CanvasNet_SystemIntegration_LoadFontAndQueryMetrics_ReturnsExpectedValues` + +After loading the same synthetic font, queries `GetAdvanceWidth` and `GetKerning` and asserts +the returned values match the font's declared metric data. diff --git a/docs/verification/canvas-net/fonts/true-type-font.md b/docs/verification/canvas-net/fonts/true-type-font.md new file mode 100644 index 00000000..710677c2 --- /dev/null +++ b/docs/verification/canvas-net/fonts/true-type-font.md @@ -0,0 +1,270 @@ +### TrueTypeFont Unit Verification Design + + + +This document describes the unit-level verification strategy for the `TrueTypeFont` class and its +supporting internal helpers `SfntContainer`, `CmapTable`, `GlyfLocaReader`, `HmtxHheaReader`, and +`KernTable`. + +#### Verification Approach + +The `TrueTypeFont` unit is verified through focused unit tests that exercise each helper directly +via `InternalsVisibleTo`, plus end-to-end `TrueTypeFont` tests that load a complete synthetic +font and drive the public API. Every fixture font is assembled in memory by the shared +`SyntheticFontBuilder` helper under `test/DemaConsulting.CanvasNet.Tests/TestSupport/`; no +third-party font files are used, so the tests avoid fixture licensing concerns while still +covering both well-formed and deliberately malformed SFNT structures. + +#### Test Environment + +- **Framework**: xUnit v3 running under the .NET SDK +- **Execution**: `dotnet test` invoked by `build.ps1` and the CI pipeline +- **Mocking**: None required; every dependency is either an in-house helper or raw byte arrays +- **Isolation**: Each test constructs its own font bytes or writes its own temporary file path + +#### Acceptance Criteria + +A unit test run passes when every scenario below passes without error or unexpected exception, and +when every named test method listed for each requirement ID passes across every target framework. + +#### Test Scenarios + +##### CanvasNet-Fonts-TrueTypeFont-LoadStream: Load from Stream Exposes Top-Level Metrics + +**Tests**: `TrueTypeFont_Load_WellFormedFont_ExposesMetrics` + +Builds a complete synthetic font in memory, loads it from a `MemoryStream`, and asserts the +public metrics properties mirror the table data. + +##### CanvasNet-Fonts-TrueTypeFont-LoadPath: Load from File Path Opens and Parses the Font + +**Tests**: `TrueTypeFont_Load_Path_ReadsFromFile` + +Writes a complete synthetic font to a temporary file, loads it through `Load(string)`, and +asserts the loaded object exposes the expected metrics. + +##### CanvasNet-Fonts-TrueTypeFont-LoadNullStream: Null Stream Is Rejected + +**Tests**: `TrueTypeFont_Load_NullStream_ThrowsArgumentNullException` + +Calls `Load(Stream)` with `null` and asserts `ArgumentNullException` is thrown immediately. + +##### CanvasNet-Fonts-TrueTypeFont-LoadNullPath: Null Path Is Rejected + +**Tests**: `TrueTypeFont_Load_NullPath_ThrowsArgumentNullException` + +Calls `Load(string)` with `null` and asserts `ArgumentNullException` is thrown immediately. + +##### CanvasNet-Fonts-TrueTypeFont-LoadEmptyPath: Empty Path Is Rejected + +**Tests**: `TrueTypeFont_Load_EmptyPath_ThrowsArgumentException` + +Calls `Load(string)` with an empty path and asserts `ArgumentException` is thrown. + +##### CanvasNet-Fonts-TrueTypeFont-RejectUnrecognizedVersion: Unsupported SFNT Versions Fail Fast + +**Tests**: `SfntContainer_Parse_UnrecognizedVersion_ThrowsInvalidDataException` + +Builds an otherwise minimal font with an arbitrary unrecognized `sfntVersion` and asserts +`InvalidDataException` is thrown before any table parsing proceeds. + +##### CanvasNet-Fonts-TrueTypeFont-RejectCffOutlines: OTTO/CFF Fonts Are Rejected + +**Tests**: `TrueTypeFont_Load_OttoFont_ThrowsInvalidDataException`, +`SfntContainer_Parse_OttoVersion_ThrowsInvalidDataException` + +Exercises both the public `Load` path and the container parser directly with an `OTTO` font and +asserts both reject the unsupported outline flavor. + +##### CanvasNet-Fonts-TrueTypeFont-RejectMissingRequiredTable: Missing Required Tables Fail Load + +**Tests**: `TrueTypeFont_Load_MissingRequiredTable_ThrowsInvalidDataException` + +Builds a font that omits required tables and asserts `Load` throws `InvalidDataException`. + +##### CanvasNet-Fonts-TrueTypeFont-RejectUnsupportedGlyphOutlineVersion: Unsupported `maxp` Versions Fail Load + +**Tests**: `TrueTypeFont_Load_MaxpVersion05_ThrowsInvalidDataException` + +Builds a font whose `maxp.version` is `0x00005000` and asserts `Load` rejects it. + +##### CanvasNet-Fonts-TrueTypeFont-RejectInvalidUnitsPerEm: Zero Units-Per-Em Is Rejected + +**Tests**: `TrueTypeFont_Load_ZeroUnitsPerEm_ThrowsInvalidDataException` + +Builds a font with `head.unitsPerEm == 0` and asserts `Load` rejects it. + +##### CanvasNet-Fonts-TrueTypeFont-RejectInvalidGlyphLocationFormat: Unsupported `loca` Formats Are Rejected + +**Tests**: `TrueTypeFont_Load_InvalidIndexToLocFormat_ThrowsInvalidDataException` + +Builds a font with an invalid `head.indexToLocFormat` value and asserts `Load` rejects it. + +##### CanvasNet-Fonts-TrueTypeFont-RejectOutOfBoundsTable: Table Directory Bounds Are Overflow-Safe + +**Tests**: `SfntContainer_Parse_TableDirectoryEntryOverflowsUint_ThrowsInvalidDataException`, +`SfntContainer_Parse_TableDirectoryEntryOutOfBounds_ThrowsInvalidDataException` + +Constructs directory entries that either overflow ordinary arithmetic or point past the file end +and asserts the parser rejects both cases. + +##### CanvasNet-Fonts-TrueTypeFont-RejectTruncatedStream: Truncation Is Rejected at Every Relevant Layer + +**Tests**: `TrueTypeFont_Load_TruncatedStream_ThrowsInvalidDataException`, +`SfntContainer_Parse_TruncatedOffsetTable_ThrowsInvalidDataException`, +`SfntContainer_Parse_TruncatedTableDirectory_ThrowsInvalidDataException` + +Exercises a truncated overall font, a truncated offset table, and a truncated directory, asserting +each failure is surfaced as `InvalidDataException`. + +##### CanvasNet-Fonts-TrueTypeFont-GlyphIndexBasicMultilingualPlane: BMP Codepoints Resolve Through Format 4 + +**Tests**: `CmapTable_Format4_BmpLookup_ReturnsMappedGlyphIndex`, +`TrueTypeFont_Load_EndToEnd_GlyphIndexOutlineAdvanceAndKerning` + +Verifies a BMP codepoint maps correctly in isolation and again through the fully loaded public +API. + +##### CanvasNet-Fonts-TrueTypeFont-GlyphIndexSupplementaryPlane: Supplementary-Plane Codepoints Resolve Through Format 12 + +**Tests**: `CmapTable_Format12_SupplementaryPlaneLookup_ReturnsMappedGlyphIndex`, +`CmapTable_Format12_GroupRange_ReturnsOffsetGlyphIndex` + +Verifies a single supplementary-plane codepoint and a contiguous format-12 range map to the +expected glyph indices. + +##### CanvasNet-Fonts-TrueTypeFont-GlyphIndexUnmappedFallback: Unmapped Codepoints Return `.notdef` + +**Tests**: `CmapTable_Format4_UnmappedCodepoint_ReturnsZero`, +`TrueTypeFont_GetGlyphIndex_UnmappedCodepoint_NeverThrows` + +Asserts both the raw `cmap` helper and the public API return glyph index `0` for unmapped +codepoints without throwing. + +##### CanvasNet-Fonts-TrueTypeFont-GlyphIndexNoCharacterMap: Missing or Unsupported `cmap` Data Falls Back Cleanly + +**Tests**: `CmapTable_NoSupportedSubtable_GetGlyphIndexReturnsZero`, +`CmapTable_Empty_GetGlyphIndexReturnsZero`, +`TrueTypeFont_Load_NoCmapOrKern_FallsBackGracefully` + +Covers the absence of a usable `cmap` table at the helper and end-to-end levels, asserting +lookup falls back to `0` rather than failing load. + +##### CanvasNet-Fonts-TrueTypeFont-GlyphOutlineSimple: Simple Glyphs Decode to Lines and Quadratics + +**Tests**: `GlyfLocaReader_SimpleGlyph_AllOnCurve_ProducesLineSegments`, +`GlyfLocaReader_SimpleGlyph_ConsecutiveOffCurvePoints_ProducesImpliedMidpointQuadratics`, +`GlyfLocaReader_SimpleGlyph_MixedOnAndOffCurve_ProducesQuadraticToRealOnCurvePoint` + +Covers all-on-curve contours, consecutive off-curve points, and mixed on/off-curve contours, +asserting the decoded `Path` uses the expected command shapes. + +##### CanvasNet-Fonts-TrueTypeFont-GlyphOutlineEmpty: Empty Glyphs Return `Path.Empty` + +**Tests**: `GlyfLocaReader_EmptyGlyph_ZeroContours_ProducesEmptyPath`, +`GlyfLocaReader_ZeroLengthGlyph_ProducesEmptyPath` + +Asserts both a zero-contour simple glyph and a zero-length `loca` entry decode as an empty path. + +##### CanvasNet-Fonts-TrueTypeFont-GlyphOutlineComposite: Composite Glyph Components Are Transformed Correctly + +**Tests**: `GlyfLocaReader_CompositeGlyph_SingleComponent_ProducesTranslatedOutline`, +`GlyfLocaReader_CompositeGlyph_ScaledComponent_ScalesOutline`, +`GlyfLocaReader_CompositeGlyph_TwoByTwoTransform_TransformsOutline`, +`GlyfLocaReader_CompositeGlyph_MultipleComponents_ProducesMultipleSubpaths` + +Verifies translation, uniform scaling, full 2x2 transforms, and multiple components within a +single composite glyph. + +##### CanvasNet-Fonts-TrueTypeFont-GlyphOutlineNestedComposite: Nested Composites Resolve Recursively + +**Tests**: `GlyfLocaReader_CompositeGlyph_NestedComposite_ResolvesRecursively` + +Builds a composite glyph that references another composite glyph and asserts the final outline is +resolved with both transforms applied. + +##### CanvasNet-Fonts-TrueTypeFont-GlyphOutlineRejectsCircularComposite: Cycles Are Rejected Deterministically + +**Tests**: `GlyfLocaReader_CompositeGlyph_SelfReferentialCycle_ThrowsInvalidDataExceptionViaDepthCap` + +Creates a self-referential composite glyph and asserts the deterministic depth cap rejects it +instead of hanging. + +##### CanvasNet-Fonts-TrueTypeFont-GlyphOutlineRejectsExcessiveComposite: Exponential Composite Blow-Up Is Rejected + +**Tests**: `GlyfLocaReader_CompositeGlyph_ExponentialBlowUp_ThrowsInvalidDataExceptionViaComponentCap` + +Constructs an acyclic but explosively branching composite graph and asserts the total-component +cap rejects it. + +##### CanvasNet-Fonts-TrueTypeFont-GlyphOutlineRejectsPointMatchedComponent: Point-Matched Components Are Rejected + +**Tests**: `GlyfLocaReader_CompositeGlyph_PointMatchedComponent_ThrowsInvalidDataException` + +Builds a composite glyph that uses point matching rather than XY offsets and asserts decoding +fails with `InvalidDataException`. + +##### CanvasNet-Fonts-TrueTypeFont-GlyphOutlineRejectsMalformedGlyphData: Malformed Glyph Bytes Are Rejected + +**Tests**: `GlyfLocaReader_CompositeGlyph_OutOfRangeComponentGlyphIndex_ThrowsInvalidDataException`, +`GlyfLocaReader_TruncatedGlyphHeader_ThrowsInvalidDataException`, +`GlyfLocaReader_InvalidContourCount_ThrowsInvalidDataException`, +`GlyfLocaReader_Parse_LocaEntryExceedsGlyfBounds_ThrowsInvalidDataException`, +`GlyfLocaReader_Parse_NonMonotonicLocaEntries_ThrowsInvalidDataException`, +`GlyfLocaReader_Parse_TruncatedLoca_ThrowsInvalidDataException` + +Covers out-of-range referenced glyphs, truncated glyph bytes, invalid contour counts, and invalid +`loca` tables, asserting each fails with `InvalidDataException`. + +##### CanvasNet-Fonts-TrueTypeFont-GlyphIndexValidation: `GetGlyphOutline` Rejects Out-of-Range Glyph Indices + +**Tests**: `TrueTypeFont_GetGlyphOutline_NegativeGlyphIndex_ThrowsArgumentOutOfRangeException`, +`TrueTypeFont_GetGlyphOutline_GlyphIndexTooLarge_ThrowsArgumentOutOfRangeException`, +`GlyfLocaReader_GetGlyphOutline_NegativeGlyphIndex_ThrowsArgumentOutOfRangeException`, +`GlyfLocaReader_GetGlyphOutline_GlyphIndexTooLarge_ThrowsArgumentOutOfRangeException` + +Exercises both the public API and the helper directly, asserting out-of-range outline requests are +rejected before any glyph bytes are read. + +##### CanvasNet-Fonts-TrueTypeFont-AdvanceWidth: Horizontal Advance Widths Follow `hmtx` Semantics + +**Tests**: `HmtxHheaReader_Parse_ExposesMetrics`, +`HmtxHheaReader_GetAdvanceWidth_TailGlyph_ReusesLastEntry`, +`TrueTypeFont_Load_EndToEnd_GlyphIndexOutlineAdvanceAndKerning` + +Verifies direct `hhea` / `hmtx` parsing, the last-entry tail reuse rule, and the same behavior +through the public `TrueTypeFont` API. + +##### CanvasNet-Fonts-TrueTypeFont-AdvanceWidthValidation: `GetAdvanceWidth` Rejects Out-of-Range Glyph Indices + +**Tests**: `TrueTypeFont_GetAdvanceWidth_GlyphIndexTooLarge_ThrowsArgumentOutOfRangeException` + +Calls `GetAdvanceWidth` with an invalid glyph index and asserts `ArgumentOutOfRangeException` is +thrown. + +##### CanvasNet-Fonts-TrueTypeFont-KerningLookup: Known Kerning Pairs Are Returned + +**Tests**: `KernTable_Format0_KnownPair_ReturnsValue`, +`TrueTypeFont_Load_EndToEnd_GlyphIndexOutlineAdvanceAndKerning` + +Verifies a known format-0 pair in isolation and through the full `TrueTypeFont` load/query path. + +##### CanvasNet-Fonts-TrueTypeFont-KerningMissOrAbsentFallback: Missing Kerning Data Returns Zero + +**Tests**: `KernTable_Format0_UnknownPair_ReturnsZero`, +`KernTable_Empty_GetKerningReturnsZero`, +`TrueTypeFont_GetKerning_OutOfRangeGlyphIndex_NeverThrows` + +Asserts missing pairs, missing tables, and out-of-range glyph indices supplied only for kerning +lookups all return `0` without throwing. + +##### CanvasNet-Fonts-TrueTypeFont-KerningMalformedTableTolerant: Malformed or Unsupported `kern` Data Is Tolerated + +**Tests**: `KernTable_NoPairs_ReturnsZero`, `KernTable_ZeroLengthTable_IsTolerant_ReturnsZero`, +`KernTable_TruncatedTable_IsTolerant_ReturnsZero`, +`KernTable_MalformedPairCount_IsTolerant_ReturnsZero`, +`KernTable_UnsupportedFormat2Subtable_IsTolerant_ReturnsZero`, +`KernTable_CrossStreamCoverage_IsSkipped_ReturnsZero` + +Exercises empty, truncated, malformed, unsupported-format, and cross-stream kerning tables, +asserting the parser degrades to "no kerning data" rather than failing load. diff --git a/docs/verification/introduction.md b/docs/verification/introduction.md index 4dbeb3a1..e52460e9 100644 --- a/docs/verification/introduction.md +++ b/docs/verification/introduction.md @@ -1,5 +1,7 @@ # Introduction + + This document provides the verification design for CanvasNet, a .NET library providing a canvas-based drawing and rendering API. @@ -28,6 +30,12 @@ constituent software items, specifically: 32-bit RGBA pixel buffer with span-based row access) and the `Rgba32` unit - **Codecs (Subsystem)** — Image format codecs: `BmpCodec`, `PngCodec`, `TiffCodec`, and `JpegCodec`, each converting to and from a `Surface` pixel buffer +- **Geometry (Subsystem)** — Vector-geometry primitives: `Rect`, `Path`, `BezierFlattening`, and + `SvgArcConverter` +- **Drawing (Subsystem)** — Vector rasterization and stroke conversion: `PathFiller`, + `PathStroker`, and `GradientPaint` +- **Fonts (Subsystem)** — TrueType (`glyf`-based) SFNT font loading and glyph-outline extraction: + `TrueTypeFont` The following OTS items are also covered: diff --git a/requirements.yaml b/requirements.yaml index 47617840..f77dd1a3 100644 --- a/requirements.yaml +++ b/requirements.yaml @@ -25,6 +25,8 @@ includes: - docs/reqstream/canvas-net/drawing/path-filler.yaml - docs/reqstream/canvas-net/drawing/path-stroker.yaml - docs/reqstream/canvas-net/drawing/gradient-paint.yaml + - docs/reqstream/canvas-net/fonts.yaml + - docs/reqstream/canvas-net/fonts/true-type-font.yaml - docs/reqstream/canvas-net/platform-requirements.yaml - docs/reqstream/ots/xunit.yaml - docs/reqstream/ots/reqstream.yaml diff --git a/src/DemaConsulting.CanvasNet/Fonts/CmapTable.cs b/src/DemaConsulting.CanvasNet/Fonts/CmapTable.cs index efc3c535..0e061a7b 100644 --- a/src/DemaConsulting.CanvasNet/Fonts/CmapTable.cs +++ b/src/DemaConsulting.CanvasNet/Fonts/CmapTable.cs @@ -1,3 +1,6 @@ +// cspell:ignore SFNT Sfnt sfnt glyf Glyf cmap Cmap loca Loca hmtx Hmtx hhea Hhea +// cspell:ignore maxp Maxp notdef codepoint codepoints subtable subtables subsetted +// cspell:ignore subsetting PPEM OTTO namespace DemaConsulting.CanvasNet.Fonts; /// diff --git a/src/DemaConsulting.CanvasNet/Fonts/GlyfLocaReader.cs b/src/DemaConsulting.CanvasNet/Fonts/GlyfLocaReader.cs index adf7a651..a57f5d3f 100644 --- a/src/DemaConsulting.CanvasNet/Fonts/GlyfLocaReader.cs +++ b/src/DemaConsulting.CanvasNet/Fonts/GlyfLocaReader.cs @@ -1,3 +1,6 @@ +// cspell:ignore SFNT Sfnt sfnt glyf Glyf cmap Cmap loca Loca hmtx Hmtx hhea Hhea +// cspell:ignore maxp Maxp notdef codepoint codepoints subtable subtables subsetted +// cspell:ignore subsetting PPEM OTTO using System.Numerics; using DemaConsulting.CanvasNet.Geometry; using Path = DemaConsulting.CanvasNet.Geometry.Path; diff --git a/src/DemaConsulting.CanvasNet/Fonts/HmtxHheaReader.cs b/src/DemaConsulting.CanvasNet/Fonts/HmtxHheaReader.cs index 7af73358..dac350a2 100644 --- a/src/DemaConsulting.CanvasNet/Fonts/HmtxHheaReader.cs +++ b/src/DemaConsulting.CanvasNet/Fonts/HmtxHheaReader.cs @@ -1,3 +1,6 @@ +// cspell:ignore SFNT Sfnt sfnt glyf Glyf cmap Cmap loca Loca hmtx Hmtx hhea Hhea +// cspell:ignore maxp Maxp notdef codepoint codepoints subtable subtables subsetted +// cspell:ignore subsetting PPEM OTTO namespace DemaConsulting.CanvasNet.Fonts; /// diff --git a/src/DemaConsulting.CanvasNet/Fonts/KernTable.cs b/src/DemaConsulting.CanvasNet/Fonts/KernTable.cs index 3ac207df..d239f967 100644 --- a/src/DemaConsulting.CanvasNet/Fonts/KernTable.cs +++ b/src/DemaConsulting.CanvasNet/Fonts/KernTable.cs @@ -1,3 +1,6 @@ +// cspell:ignore SFNT Sfnt sfnt glyf Glyf cmap Cmap loca Loca hmtx Hmtx hhea Hhea +// cspell:ignore maxp Maxp notdef codepoint codepoints subtable subtables subsetted +// cspell:ignore subsetting PPEM OTTO namespace DemaConsulting.CanvasNet.Fonts; /// diff --git a/src/DemaConsulting.CanvasNet/Fonts/NamespaceDoc.cs b/src/DemaConsulting.CanvasNet/Fonts/NamespaceDoc.cs index ec577439..52a816a5 100644 --- a/src/DemaConsulting.CanvasNet/Fonts/NamespaceDoc.cs +++ b/src/DemaConsulting.CanvasNet/Fonts/NamespaceDoc.cs @@ -1,3 +1,6 @@ +// cspell:ignore SFNT Sfnt sfnt glyf Glyf cmap Cmap loca Loca hmtx Hmtx hhea Hhea +// cspell:ignore maxp Maxp notdef codepoint codepoints subtable subtables subsetted +// cspell:ignore subsetting PPEM OTTO namespace DemaConsulting.CanvasNet.Fonts; /// diff --git a/src/DemaConsulting.CanvasNet/Fonts/SfntContainer.cs b/src/DemaConsulting.CanvasNet/Fonts/SfntContainer.cs index 4be146a8..cf796b91 100644 --- a/src/DemaConsulting.CanvasNet/Fonts/SfntContainer.cs +++ b/src/DemaConsulting.CanvasNet/Fonts/SfntContainer.cs @@ -1,3 +1,6 @@ +// cspell:ignore SFNT Sfnt sfnt glyf Glyf cmap Cmap loca Loca hmtx Hmtx hhea Hhea +// cspell:ignore maxp Maxp notdef codepoint codepoints subtable subtables subsetted +// cspell:ignore subsetting PPEM OTTO namespace DemaConsulting.CanvasNet.Fonts; /// diff --git a/src/DemaConsulting.CanvasNet/Fonts/TrueTypeFont.cs b/src/DemaConsulting.CanvasNet/Fonts/TrueTypeFont.cs index efd17928..c96ab448 100644 --- a/src/DemaConsulting.CanvasNet/Fonts/TrueTypeFont.cs +++ b/src/DemaConsulting.CanvasNet/Fonts/TrueTypeFont.cs @@ -1,3 +1,6 @@ +// cspell:ignore SFNT Sfnt sfnt glyf Glyf cmap Cmap loca Loca hmtx Hmtx hhea Hhea +// cspell:ignore maxp Maxp notdef codepoint codepoints subtable subtables subsetted +// cspell:ignore subsetting PPEM OTTO using DemaConsulting.CanvasNet.Geometry; using Path = DemaConsulting.CanvasNet.Geometry.Path; diff --git a/test/DemaConsulting.CanvasNet.Tests/CanvasNetTests.cs b/test/DemaConsulting.CanvasNet.Tests/CanvasNetTests.cs index 4059da74..e636a999 100644 --- a/test/DemaConsulting.CanvasNet.Tests/CanvasNetTests.cs +++ b/test/DemaConsulting.CanvasNet.Tests/CanvasNetTests.cs @@ -1,3 +1,6 @@ +// cspell:ignore SFNT Sfnt sfnt glyf Glyf cmap Cmap loca Loca hmtx Hmtx hhea Hhea +// cspell:ignore maxp Maxp notdef codepoint codepoints subtable subtables subsetted +// cspell:ignore subsetting PPEM OTTO using System.Numerics; using DemaConsulting.CanvasNet.Canvas; using DemaConsulting.CanvasNet.Codecs; diff --git a/test/DemaConsulting.CanvasNet.Tests/Fonts/CmapTableTests.cs b/test/DemaConsulting.CanvasNet.Tests/Fonts/CmapTableTests.cs index 18042d80..34acaefd 100644 --- a/test/DemaConsulting.CanvasNet.Tests/Fonts/CmapTableTests.cs +++ b/test/DemaConsulting.CanvasNet.Tests/Fonts/CmapTableTests.cs @@ -1,3 +1,6 @@ +// cspell:ignore SFNT Sfnt sfnt glyf Glyf cmap Cmap loca Loca hmtx Hmtx hhea Hhea +// cspell:ignore maxp Maxp notdef codepoint codepoints subtable subtables subsetted +// cspell:ignore subsetting PPEM OTTO using DemaConsulting.CanvasNet.Fonts; using DemaConsulting.CanvasNet.Tests.TestSupport; diff --git a/test/DemaConsulting.CanvasNet.Tests/Fonts/GlyfLocaReaderTests.cs b/test/DemaConsulting.CanvasNet.Tests/Fonts/GlyfLocaReaderTests.cs index 2159157e..305415f5 100644 --- a/test/DemaConsulting.CanvasNet.Tests/Fonts/GlyfLocaReaderTests.cs +++ b/test/DemaConsulting.CanvasNet.Tests/Fonts/GlyfLocaReaderTests.cs @@ -1,3 +1,6 @@ +// cspell:ignore SFNT Sfnt sfnt glyf Glyf cmap Cmap loca Loca hmtx Hmtx hhea Hhea +// cspell:ignore maxp Maxp notdef codepoint codepoints subtable subtables subsetted +// cspell:ignore subsetting PPEM OTTO using DemaConsulting.CanvasNet.Fonts; using DemaConsulting.CanvasNet.Geometry; using DemaConsulting.CanvasNet.Tests.TestSupport; diff --git a/test/DemaConsulting.CanvasNet.Tests/Fonts/HmtxHheaReaderTests.cs b/test/DemaConsulting.CanvasNet.Tests/Fonts/HmtxHheaReaderTests.cs index ee6ba656..94c57e95 100644 --- a/test/DemaConsulting.CanvasNet.Tests/Fonts/HmtxHheaReaderTests.cs +++ b/test/DemaConsulting.CanvasNet.Tests/Fonts/HmtxHheaReaderTests.cs @@ -1,3 +1,6 @@ +// cspell:ignore SFNT Sfnt sfnt glyf Glyf cmap Cmap loca Loca hmtx Hmtx hhea Hhea +// cspell:ignore maxp Maxp notdef codepoint codepoints subtable subtables subsetted +// cspell:ignore subsetting PPEM OTTO using DemaConsulting.CanvasNet.Fonts; using DemaConsulting.CanvasNet.Tests.TestSupport; diff --git a/test/DemaConsulting.CanvasNet.Tests/Fonts/KernTableTests.cs b/test/DemaConsulting.CanvasNet.Tests/Fonts/KernTableTests.cs index 4d2a9303..6873968c 100644 --- a/test/DemaConsulting.CanvasNet.Tests/Fonts/KernTableTests.cs +++ b/test/DemaConsulting.CanvasNet.Tests/Fonts/KernTableTests.cs @@ -1,3 +1,6 @@ +// cspell:ignore SFNT Sfnt sfnt glyf Glyf cmap Cmap loca Loca hmtx Hmtx hhea Hhea +// cspell:ignore maxp Maxp notdef codepoint codepoints subtable subtables subsetted +// cspell:ignore subsetting PPEM OTTO using DemaConsulting.CanvasNet.Fonts; using DemaConsulting.CanvasNet.Tests.TestSupport; diff --git a/test/DemaConsulting.CanvasNet.Tests/Fonts/SfntContainerTests.cs b/test/DemaConsulting.CanvasNet.Tests/Fonts/SfntContainerTests.cs index f62e2cb5..160c5cf5 100644 --- a/test/DemaConsulting.CanvasNet.Tests/Fonts/SfntContainerTests.cs +++ b/test/DemaConsulting.CanvasNet.Tests/Fonts/SfntContainerTests.cs @@ -1,3 +1,6 @@ +// cspell:ignore SFNT Sfnt sfnt glyf Glyf cmap Cmap loca Loca hmtx Hmtx hhea Hhea +// cspell:ignore maxp Maxp notdef codepoint codepoints subtable subtables subsetted +// cspell:ignore subsetting PPEM OTTO using DemaConsulting.CanvasNet.Fonts; using DemaConsulting.CanvasNet.Tests.TestSupport; diff --git a/test/DemaConsulting.CanvasNet.Tests/Fonts/TrueTypeFontTests.cs b/test/DemaConsulting.CanvasNet.Tests/Fonts/TrueTypeFontTests.cs index 874ba14f..a9fa0525 100644 --- a/test/DemaConsulting.CanvasNet.Tests/Fonts/TrueTypeFontTests.cs +++ b/test/DemaConsulting.CanvasNet.Tests/Fonts/TrueTypeFontTests.cs @@ -1,3 +1,6 @@ +// cspell:ignore SFNT Sfnt sfnt glyf Glyf cmap Cmap loca Loca hmtx Hmtx hhea Hhea +// cspell:ignore maxp Maxp notdef codepoint codepoints subtable subtables subsetted +// cspell:ignore subsetting PPEM OTTO using DemaConsulting.CanvasNet.Fonts; using DemaConsulting.CanvasNet.Tests.TestSupport; diff --git a/test/DemaConsulting.CanvasNet.Tests/TestSupport/SyntheticFontBuilder.cs b/test/DemaConsulting.CanvasNet.Tests/TestSupport/SyntheticFontBuilder.cs index 15ec9fbe..551c9f63 100644 --- a/test/DemaConsulting.CanvasNet.Tests/TestSupport/SyntheticFontBuilder.cs +++ b/test/DemaConsulting.CanvasNet.Tests/TestSupport/SyntheticFontBuilder.cs @@ -1,3 +1,6 @@ +// cspell:ignore SFNT Sfnt sfnt glyf Glyf cmap Cmap loca Loca hmtx Hmtx hhea Hhea +// cspell:ignore maxp Maxp notdef codepoint codepoints subtable subtables subsetted +// cspell:ignore subsetting PPEM OTTO namespace DemaConsulting.CanvasNet.Tests.TestSupport; /// From 9d4eeb67d0f2c824ceae501a71ed3d98f7a52a64 Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Tue, 22 Sep 2026 12:19:51 -0400 Subject: [PATCH 04/17] Add AAA phase comments to Fonts unit tests per quality review --- .../Fonts/CmapTableTests.cs | 36 ++++++-- .../Fonts/GlyfLocaReaderTests.cs | 83 ++++++++++++++++--- .../Fonts/HmtxHheaReaderTests.cs | 14 ++++ .../Fonts/KernTableTests.cs | 25 ++++++ .../Fonts/SfntContainerTests.cs | 33 +++++++- .../Fonts/TrueTypeFontTests.cs | 39 +++++++++ 6 files changed, 211 insertions(+), 19 deletions(-) diff --git a/test/DemaConsulting.CanvasNet.Tests/Fonts/CmapTableTests.cs b/test/DemaConsulting.CanvasNet.Tests/Fonts/CmapTableTests.cs index 34acaefd..3dbab35d 100644 --- a/test/DemaConsulting.CanvasNet.Tests/Fonts/CmapTableTests.cs +++ b/test/DemaConsulting.CanvasNet.Tests/Fonts/CmapTableTests.cs @@ -17,10 +17,13 @@ public class CmapTableTests [Fact] public void CmapTable_Format4_BmpLookup_ReturnsMappedGlyphIndex() { + // Arrange: build a format-4 cmap subtable mapping two BMP codepoints var table = SyntheticFontBuilder.CmapFormat4(3, 1, [(65, 3), (66, 4)]); + // Act: parse the cmap table var cmap = CmapTable.Parse(table, 0, table.Length); + // Assert: both codepoints resolve to their mapped glyph indices Assert.Equal(3, cmap.GetGlyphIndex(65)); Assert.Equal(4, cmap.GetGlyphIndex(66)); } @@ -31,10 +34,13 @@ public void CmapTable_Format4_BmpLookup_ReturnsMappedGlyphIndex() [Fact] public void CmapTable_Format4_UnmappedCodepoint_ReturnsZero() { + // Arrange: build a format-4 cmap subtable mapping a single codepoint var table = SyntheticFontBuilder.CmapFormat4(3, 1, [(65, 3)]); + // Act: parse the cmap table var cmap = CmapTable.Parse(table, 0, table.Length); + // Assert: a codepoint absent from the table resolves to glyph index zero Assert.Equal(0, cmap.GetGlyphIndex(999)); } @@ -44,10 +50,13 @@ public void CmapTable_Format4_UnmappedCodepoint_ReturnsZero() [Fact] public void CmapTable_Format12_SupplementaryPlaneLookup_ReturnsMappedGlyphIndex() { + // Arrange: build a format-12 cmap subtable mapping a single supplementary-plane codepoint var table = SyntheticFontBuilder.CmapFormat12(3, 10, [(0x1F600u, 0x1F600u, 5u)]); + // Act: parse the cmap table var cmap = CmapTable.Parse(table, 0, table.Length); + // Assert: the supplementary-plane codepoint resolves to its mapped glyph index Assert.Equal(5, cmap.GetGlyphIndex(0x1F600)); } @@ -57,10 +66,13 @@ public void CmapTable_Format12_SupplementaryPlaneLookup_ReturnsMappedGlyphIndex( [Fact] public void CmapTable_Format12_GroupRange_ReturnsOffsetGlyphIndex() { + // Arrange: build a format-12 cmap subtable with a single codepoint-range group var table = SyntheticFontBuilder.CmapFormat12(3, 10, [(0x100u, 0x110u, 50u)]); + // Act: parse the cmap table var cmap = CmapTable.Parse(table, 0, table.Length); + // Assert: a codepoint within the range resolves with the group's offset, outside resolves to zero Assert.Equal(55, cmap.GetGlyphIndex(0x105)); Assert.Equal(0, cmap.GetGlyphIndex(0x111)); } @@ -71,12 +83,14 @@ public void CmapTable_Format12_GroupRange_ReturnsOffsetGlyphIndex() [Fact] public void CmapTable_Platform3Encoding10Format12_HighestPriority_ReturnsMappedGlyphIndex() { - // (3,10) format-12 is priority 0 (the highest-priority subtable this implementation - // recognizes) - confirms it is selected and produces a correct lookup. + // Arrange: build a (3,10) format-12 cmap subtable - priority 0, the highest-priority + // subtable this implementation recognizes - to confirm it is selected var table = SyntheticFontBuilder.CmapFormat12(3, 10, [(65u, 65u, 9u)]); + // Act: parse the cmap table var cmap = CmapTable.Parse(table, 0, table.Length); + // Assert: the highest-priority subtable is selected and produces a correct lookup Assert.Equal(9, cmap.GetGlyphIndex(65)); } @@ -86,12 +100,14 @@ public void CmapTable_Platform3Encoding10Format12_HighestPriority_ReturnsMappedG [Fact] public void CmapTable_NoSupportedSubtable_GetGlyphIndexReturnsZero() { - // Platform/encoding pair (1,0) - classic Mac Roman - is never selected by this - // implementation, regardless of subtable format. + // Arrange: build a cmap subtable with platform/encoding pair (1,0) - classic Mac Roman - + // which is never selected by this implementation, regardless of subtable format var table = SyntheticFontBuilder.CmapFormat4(1, 0, [(65, 3)]); + // Act: parse the cmap table var cmap = CmapTable.Parse(table, 0, table.Length); + // Assert: with no supported subtable selected, lookups resolve to glyph index zero Assert.Equal(0, cmap.GetGlyphIndex(65)); } @@ -101,6 +117,8 @@ public void CmapTable_NoSupportedSubtable_GetGlyphIndexReturnsZero() [Fact] public void CmapTable_Empty_GetGlyphIndexReturnsZero() { + // Arrange/Act: use the empty cmap table singleton + // Assert: any lookup on the empty table resolves to glyph index zero Assert.Equal(0, CmapTable.Empty.GetGlyphIndex(65)); } @@ -110,8 +128,10 @@ public void CmapTable_Empty_GetGlyphIndexReturnsZero() [Fact] public void CmapTable_TruncatedTable_GetGlyphIndexReturnsZero() { + // Arrange/Act: parse a truncated table too short to contain a header var cmap = CmapTable.Parse([0, 0], 0, 2); + // Assert: parsing tolerates the truncation and lookups resolve to glyph index zero Assert.Equal(0, cmap.GetGlyphIndex(65)); } @@ -121,6 +141,7 @@ public void CmapTable_TruncatedTable_GetGlyphIndexReturnsZero() [Fact] public void CmapTable_MalformedSubtableOffset_GetGlyphIndexReturnsZero() { + // Arrange: build a cmap header whose single subtable offset points far past the table end var buf = new List { 0, 0, // version @@ -130,9 +151,11 @@ public void CmapTable_MalformedSubtableOffset_GetGlyphIndexReturnsZero() }; SyntheticFontBuilder.WriteUInt32(buf, 100000); // subtable offset far past the table end + // Act: parse the cmap table with the malformed subtable offset var data = buf.ToArray(); var cmap = CmapTable.Parse(data, 0, data.Length); + // Assert: the malformed offset is tolerated and lookups resolve to glyph index zero Assert.Equal(0, cmap.GetGlyphIndex(65)); } @@ -142,7 +165,8 @@ public void CmapTable_MalformedSubtableOffset_GetGlyphIndexReturnsZero() [Fact] public void CmapTable_UnrecognizedSubtableFormat_GetGlyphIndexReturnsZero() { - // Format 6 (trimmed table mapping) is not understood by this implementation. + // Arrange: build a cmap subtable using format 6 (trimmed table mapping), which is not + // understood by this implementation var buf = new List { 0, 0, // version @@ -153,9 +177,11 @@ public void CmapTable_UnrecognizedSubtableFormat_GetGlyphIndexReturnsZero() SyntheticFontBuilder.WriteUInt32(buf, 12); // subtable offset SyntheticFontBuilder.WriteUInt16(buf, 6); // format 6 - unsupported + // Act: parse the cmap table with the unrecognized subtable format var data = buf.ToArray(); var cmap = CmapTable.Parse(data, 0, data.Length); + // Assert: the unrecognized format is ignored and lookups resolve to glyph index zero Assert.Equal(0, cmap.GetGlyphIndex(65)); } } diff --git a/test/DemaConsulting.CanvasNet.Tests/Fonts/GlyfLocaReaderTests.cs b/test/DemaConsulting.CanvasNet.Tests/Fonts/GlyfLocaReaderTests.cs index 305415f5..b6af2d0b 100644 --- a/test/DemaConsulting.CanvasNet.Tests/Fonts/GlyfLocaReaderTests.cs +++ b/test/DemaConsulting.CanvasNet.Tests/Fonts/GlyfLocaReaderTests.cs @@ -39,14 +39,17 @@ private static GlyfLocaReader BuildReader(IReadOnlyList glyphs, bool lon [Fact] public void GlyfLocaReader_SimpleGlyph_AllOnCurve_ProducesLineSegments() { + // Arrange: build a simple glyph with a single all-on-curve square contour var glyph = SyntheticFontBuilder.SimpleGlyph( [ [(0, 0, true), (100, 0, true), (100, 100, true), (0, 100, true)] ]); + // Act: build the reader and get the glyph outline var reader = BuildReader([glyph]); var path = reader.GetGlyphOutline(0); + // Assert: a single subpath of straight line segments is produced Assert.Single(path.Subpaths); var subpath = path.Subpaths[0]; Assert.Equal(new System.Numerics.Vector2(0, 0), subpath.Start); @@ -59,16 +62,19 @@ public void GlyfLocaReader_SimpleGlyph_AllOnCurve_ProducesLineSegments() [Fact] public void GlyfLocaReader_SimpleGlyph_ConsecutiveOffCurvePoints_ProducesImpliedMidpointQuadratics() { - // A four-point "diamond" contour, entirely off-curve, forces the implied-on-curve - // midpoint convention for every segment (including the synthetic contour start). + // Arrange: a four-point "diamond" contour, entirely off-curve, forces the + // implied-on-curve midpoint convention for every segment (including the synthetic + // contour start) var glyph = SyntheticFontBuilder.SimpleGlyph( [ [(50, 0, false), (100, 50, false), (50, 100, false), (0, 50, false)] ]); + // Act: build the reader and get the glyph outline var reader = BuildReader([glyph]); var path = reader.GetGlyphOutline(0); + // Assert: a single subpath entirely made of quadratic bezier segments is produced var subpath = Assert.Single(path.Subpaths); Assert.Contains(subpath.Commands, c => c.Type == PathCommandType.QuadraticBezierTo); Assert.All(subpath.Commands, c => Assert.True(c.Type is PathCommandType.QuadraticBezierTo or PathCommandType.Close)); @@ -80,14 +86,17 @@ public void GlyfLocaReader_SimpleGlyph_ConsecutiveOffCurvePoints_ProducesImplied [Fact] public void GlyfLocaReader_SimpleGlyph_MixedOnAndOffCurve_ProducesQuadraticToRealOnCurvePoint() { + // Arrange: build a simple glyph alternating on-curve and off-curve points var glyph = SyntheticFontBuilder.SimpleGlyph( [ [(0, 0, true), (50, 50, false), (100, 0, true), (50, -50, false)] ]); + // Act: build the reader and get the glyph outline var reader = BuildReader([glyph]); var path = reader.GetGlyphOutline(0); + // Assert: the subpath starts at the real on-curve point and contains quadratic segments var subpath = Assert.Single(path.Subpaths); Assert.Equal(new System.Numerics.Vector2(0, 0), subpath.Start); Assert.Contains(subpath.Commands, c => c.Type == PathCommandType.QuadraticBezierTo); @@ -99,11 +108,14 @@ public void GlyfLocaReader_SimpleGlyph_MixedOnAndOffCurve_ProducesQuadraticToRea [Fact] public void GlyfLocaReader_EmptyGlyph_ZeroContours_ProducesEmptyPath() { + // Arrange: build a simple glyph with zero contours var glyph = SyntheticFontBuilder.SimpleGlyph(); + // Act: build the reader and get the glyph outline var reader = BuildReader([glyph]); var path = reader.GetGlyphOutline(0); + // Assert: the resulting path has no subpaths Assert.Empty(path.Subpaths); } @@ -113,11 +125,14 @@ public void GlyfLocaReader_EmptyGlyph_ZeroContours_ProducesEmptyPath() [Fact] public void GlyfLocaReader_ZeroLengthGlyph_ProducesEmptyPath() { - // A loca entry pair with equal offsets (e.g. the classic 'space' glyph with no outline). + // Arrange: a loca entry pair with equal offsets (e.g. the classic 'space' glyph with no + // outline) alongside a normal glyph var reader = BuildReader([[], SyntheticFontBuilder.SimpleGlyph([[(0, 0, true), (10, 0, true), (10, 10, true)]])]); + // Act: get the outline of the zero-length glyph var path = reader.GetGlyphOutline(0); + // Assert: the resulting path has no subpaths Assert.Empty(path.Subpaths); } @@ -127,11 +142,14 @@ public void GlyfLocaReader_ZeroLengthGlyph_ProducesEmptyPath() [Fact] public void GlyfLocaReader_LongLocaFormat_DecodesSameAsShortFormat() { + // Arrange: build a simple glyph and a reader using the long loca format var glyph = SyntheticFontBuilder.SimpleGlyph([[(0, 0, true), (10, 0, true), (10, 10, true)]]); + // Act: build the reader with long-format loca and get the glyph outline var reader = BuildReader([glyph], longFormat: true); var path = reader.GetGlyphOutline(0); + // Assert: the outline decodes correctly, same as with the short format Assert.Single(path.Subpaths); } @@ -141,8 +159,10 @@ public void GlyfLocaReader_LongLocaFormat_DecodesSameAsShortFormat() [Fact] public void GlyfLocaReader_GetGlyphOutline_NegativeGlyphIndex_ThrowsArgumentOutOfRangeException() { + // Arrange: build a reader over a single glyph var reader = BuildReader([SyntheticFontBuilder.SimpleGlyph()]); + // Act/Assert: requesting a negative glyph index throws Assert.Throws(() => reader.GetGlyphOutline(-1)); } @@ -152,8 +172,10 @@ public void GlyfLocaReader_GetGlyphOutline_NegativeGlyphIndex_ThrowsArgumentOutO [Fact] public void GlyfLocaReader_GetGlyphOutline_GlyphIndexTooLarge_ThrowsArgumentOutOfRangeException() { + // Arrange: build a reader over a single glyph var reader = BuildReader([SyntheticFontBuilder.SimpleGlyph()]); + // Act/Assert: requesting a glyph index at/beyond the glyph count throws Assert.Throws(() => reader.GetGlyphOutline(1)); } @@ -163,6 +185,7 @@ public void GlyfLocaReader_GetGlyphOutline_GlyphIndexTooLarge_ThrowsArgumentOutO [Fact] public void GlyfLocaReader_CompositeGlyph_SingleComponent_ProducesTranslatedOutline() { + // Arrange: build a square glyph and a composite referencing it with a translation var square = SyntheticFontBuilder.SimpleGlyph( [ [(0, 0, true), (10, 0, true), (10, 10, true), (0, 10, true)] @@ -172,9 +195,11 @@ public void GlyfLocaReader_CompositeGlyph_SingleComponent_ProducesTranslatedOutl new SyntheticFontBuilder.CompositeComponent(0, 100, 200) ]); + // Act: build the reader and get the composite glyph outline var reader = BuildReader([square, composite]); var path = reader.GetGlyphOutline(1); + // Assert: the outline is translated by the component's dx/dy offset var subpath = Assert.Single(path.Subpaths); Assert.Equal(new System.Numerics.Vector2(100, 200), subpath.Start); } @@ -185,6 +210,7 @@ public void GlyfLocaReader_CompositeGlyph_SingleComponent_ProducesTranslatedOutl [Fact] public void GlyfLocaReader_CompositeGlyph_ScaledComponent_ScalesOutline() { + // Arrange: build a square glyph and a composite referencing it with a uniform scale var square = SyntheticFontBuilder.SimpleGlyph( [ [(0, 0, true), (10, 0, true), (10, 10, true), (0, 10, true)] @@ -194,9 +220,11 @@ public void GlyfLocaReader_CompositeGlyph_ScaledComponent_ScalesOutline() new SyntheticFontBuilder.CompositeComponent(0, 0, 0, HasScale: true, A: 1.5f, D: 1.5f) ]); + // Act: build the reader and get the composite glyph outline var reader = BuildReader([square, composite]); var path = reader.GetGlyphOutline(1); + // Assert: the outline coordinates are scaled by the component's scale factor var subpath = Assert.Single(path.Subpaths); var lineTo = subpath.Commands[0]; Assert.Equal(PathCommandType.LineTo, lineTo.Type); @@ -209,23 +237,24 @@ public void GlyfLocaReader_CompositeGlyph_ScaledComponent_ScalesOutline() [Fact] public void GlyfLocaReader_CompositeGlyph_TwoByTwoTransform_TransformsOutline() { + // Arrange: build a square glyph and a composite applying a 90-degree counter-clockwise + // rotation matrix: a=0, b=1, c=-1, d=0 maps (x, y) -> (-y, x) var square = SyntheticFontBuilder.SimpleGlyph( [ [(0, 0, true), (10, 0, true), (10, 10, true), (0, 10, true)] ]); - - // 90-degree counter-clockwise rotation matrix: a=0, b=1, c=-1, d=0 maps (x, y) -> (-y, x). var composite = SyntheticFontBuilder.CompositeGlyph( [ new SyntheticFontBuilder.CompositeComponent(0, 0, 0, HasTwoByTwo: true, A: 0f, B: 1f, C: -1f, D: 0f) ]); + // Act: build the reader and get the composite glyph outline var reader = BuildReader([square, composite]); var path = reader.GetGlyphOutline(1); + // Assert: the first LineTo draws to the untransformed point (10, 0), which the rotation maps to (0, 10) var subpath = Assert.Single(path.Subpaths); var lineTo = subpath.Commands[0]; - // The first LineTo draws to the untransformed point (10, 0), which the rotation maps to (0, 10). Assert.Equal(0f, lineTo.EndPoint.X, 2); Assert.Equal(10f, lineTo.EndPoint.Y, 2); } @@ -236,6 +265,8 @@ public void GlyfLocaReader_CompositeGlyph_TwoByTwoTransform_TransformsOutline() [Fact] public void GlyfLocaReader_CompositeGlyph_NestedComposite_ResolvesRecursively() { + // Arrange: build a square glyph, an inner composite referencing it, and an outer + // composite referencing the inner composite var square = SyntheticFontBuilder.SimpleGlyph( [ [(0, 0, true), (10, 0, true), (10, 10, true), (0, 10, true)] @@ -249,9 +280,11 @@ public void GlyfLocaReader_CompositeGlyph_NestedComposite_ResolvesRecursively() new SyntheticFontBuilder.CompositeComponent(1, 100, 100) ]); + // Act: build the reader and get the outer composite glyph outline var reader = BuildReader([square, inner, outer]); var path = reader.GetGlyphOutline(2); + // Assert: both levels of translation are combined into the final outline var subpath = Assert.Single(path.Subpaths); Assert.Equal(new System.Numerics.Vector2(105, 105), subpath.Start); } @@ -262,6 +295,7 @@ public void GlyfLocaReader_CompositeGlyph_NestedComposite_ResolvesRecursively() [Fact] public void GlyfLocaReader_CompositeGlyph_MultipleComponents_ProducesMultipleSubpaths() { + // Arrange: build a square glyph and a composite referencing it twice at different offsets var square = SyntheticFontBuilder.SimpleGlyph( [ [(0, 0, true), (10, 0, true), (10, 10, true), (0, 10, true)] @@ -272,9 +306,11 @@ public void GlyfLocaReader_CompositeGlyph_MultipleComponents_ProducesMultipleSub new SyntheticFontBuilder.CompositeComponent(0, 50, 50) ]); + // Act: build the reader and get the composite glyph outline var reader = BuildReader([square, composite]); var path = reader.GetGlyphOutline(1); + // Assert: each component contributes its own subpath Assert.Equal(2, path.Subpaths.Count); } @@ -284,6 +320,8 @@ public void GlyfLocaReader_CompositeGlyph_MultipleComponents_ProducesMultipleSub [Fact] public void GlyfLocaReader_CompositeGlyph_PointMatchedComponent_ThrowsInvalidDataException() { + // Arrange: build a square glyph and a composite component using point-matching + // (unsupported) instead of x/y offsets var square = SyntheticFontBuilder.SimpleGlyph( [ [(0, 0, true), (10, 0, true), (10, 10, true), (0, 10, true)] @@ -293,8 +331,10 @@ public void GlyfLocaReader_CompositeGlyph_PointMatchedComponent_ThrowsInvalidDat new SyntheticFontBuilder.CompositeComponent(0, 0, 0, ArgsAreXyValues: false) ]); + // Act var reader = BuildReader([square, composite]); + // Assert: resolving the point-matched component throws Assert.Throws(() => reader.GetGlyphOutline(1)); } @@ -304,13 +344,16 @@ public void GlyfLocaReader_CompositeGlyph_PointMatchedComponent_ThrowsInvalidDat [Fact] public void GlyfLocaReader_CompositeGlyph_OutOfRangeComponentGlyphIndex_ThrowsInvalidDataException() { + // Arrange: build a composite whose sole component references a nonexistent glyph index var composite = SyntheticFontBuilder.CompositeGlyph( [ new SyntheticFontBuilder.CompositeComponent(99, 0, 0) ]); + // Act var reader = BuildReader([composite]); + // Assert: resolving the out-of-range component glyph index throws Assert.Throws(() => reader.GetGlyphOutline(0)); } @@ -320,15 +363,17 @@ public void GlyfLocaReader_CompositeGlyph_OutOfRangeComponentGlyphIndex_ThrowsIn [Fact] public void GlyfLocaReader_CompositeGlyph_SelfReferentialCycle_ThrowsInvalidDataExceptionViaDepthCap() { - // Glyph 0 is a composite whose only component is itself: recursion depth increases - // without bound until the deterministic depth cap trips - this must never hang. + // Arrange: glyph 0 is a composite whose only component is itself: recursion depth + // increases without bound until the deterministic depth cap trips - this must never hang var selfReferential = SyntheticFontBuilder.CompositeGlyph( [ new SyntheticFontBuilder.CompositeComponent(0, 1, 1) ]); + // Act var reader = BuildReader([selfReferential]); + // Assert: the depth cap trips and the message identifies the depth-cap failure var ex = Assert.Throws(() => reader.GetGlyphOutline(0)); Assert.Contains("depth", ex.Message, StringComparison.OrdinalIgnoreCase); } @@ -339,11 +384,11 @@ public void GlyfLocaReader_CompositeGlyph_SelfReferentialCycle_ThrowsInvalidData [Fact] public void GlyfLocaReader_CompositeGlyph_ExponentialBlowUp_ThrowsInvalidDataExceptionViaComponentCap() { - // A 4-level chain, each with 10 components referencing the next level (branching factor - // 10, depth 4 <= MaxDepth): total resolved components is on the order of 10 + 100 + 1000 - // + 10000, far exceeding the 5000 total-component cap, while nesting depth never - // approaches the separate depth cap - proving the component-count cap (not the depth - // cap) is what prevents the hang here. + // Arrange: a 4-level chain, each with 10 components referencing the next level + // (branching factor 10, depth 4 <= MaxDepth): total resolved components is on the order + // of 10 + 100 + 1000 + 10000, far exceeding the 5000 total-component cap, while nesting + // depth never approaches the separate depth cap - proving the component-count cap (not + // the depth cap) is what prevents the hang here var leaf = SyntheticFontBuilder.SimpleGlyph([[(0, 0, true), (1, 0, true), (1, 1, true)]]); byte[] MakeLevel(int referencedGlyphIndex) => @@ -357,8 +402,10 @@ byte[] MakeLevel(int referencedGlyphIndex) => var level1 = MakeLevel(2); // glyph 3: 10 refs to glyph 2 var level0 = MakeLevel(3); // glyph 4: 10 refs to glyph 3 (the top-level glyph under test) + // Act var reader = BuildReader([leaf, level3, level2, level1, level0]); + // Assert: the component-count cap trips and the message identifies the count-cap failure var ex = Assert.Throws(() => reader.GetGlyphOutline(4)); Assert.Contains("too many", ex.Message, StringComparison.OrdinalIgnoreCase); } @@ -369,10 +416,12 @@ byte[] MakeLevel(int referencedGlyphIndex) => [Fact] public void GlyfLocaReader_Parse_LocaEntryExceedsGlyfBounds_ThrowsInvalidDataException() { + // Arrange: build a loca table whose single glyph length exceeds the supplied glyf region var loca = SyntheticFontBuilder.Loca([100], longFormat: false); var data = new byte[loca.Length + 10]; loca.CopyTo(data, 0); + // Act/Assert: parsing with the out-of-bounds loca entry throws Assert.Throws(() => GlyfLocaReader.Parse(data, 0, loca.Length, loca.Length, 10, 1, false)); } @@ -382,6 +431,7 @@ public void GlyfLocaReader_Parse_LocaEntryExceedsGlyfBounds_ThrowsInvalidDataExc [Fact] public void GlyfLocaReader_Parse_NonMonotonicLocaEntries_ThrowsInvalidDataException() { + // Arrange: build a loca table whose entries decrease instead of monotonically increasing var buf = new List(); SyntheticFontBuilder.WriteUInt16(buf, 0); // glyph 0: offset 0 SyntheticFontBuilder.WriteUInt16(buf, 5); // glyph 1 end / glyph 2 start: offset 10 @@ -390,6 +440,7 @@ public void GlyfLocaReader_Parse_NonMonotonicLocaEntries_ThrowsInvalidDataExcept var data = new byte[loca.Length + 10]; loca.CopyTo(data, 0); + // Act/Assert: parsing the non-monotonic loca table throws Assert.Throws(() => GlyfLocaReader.Parse(data, 0, loca.Length, loca.Length, 10, 2, false)); } @@ -399,8 +450,10 @@ public void GlyfLocaReader_Parse_NonMonotonicLocaEntries_ThrowsInvalidDataExcept [Fact] public void GlyfLocaReader_Parse_TruncatedLoca_ThrowsInvalidDataException() { + // Arrange: build a loca buffer shorter than the (numGlyphs + 1) * 2 = 4 bytes required var loca = new byte[2]; // needs (numGlyphs + 1) * 2 = 4 bytes for numGlyphs=1 + // Act/Assert: parsing the truncated loca table throws Assert.Throws(() => GlyfLocaReader.Parse(loca, 0, loca.Length, 2, 0, 1, false)); } @@ -410,8 +463,10 @@ public void GlyfLocaReader_Parse_TruncatedLoca_ThrowsInvalidDataException() [Fact] public void GlyfLocaReader_TruncatedGlyphHeader_ThrowsInvalidDataException() { + // Arrange: build a reader over a glyph shorter than the 10-byte glyph header var reader = BuildReader([new byte[5]]); // shorter than the 10-byte glyph header + // Act/Assert: reading the truncated glyph header throws Assert.Throws(() => reader.GetGlyphOutline(0)); } @@ -421,6 +476,7 @@ public void GlyfLocaReader_TruncatedGlyphHeader_ThrowsInvalidDataException() [Fact] public void GlyfLocaReader_InvalidContourCount_ThrowsInvalidDataException() { + // Arrange: build a glyph header with an invalid contour count (only -1 or >=0 are valid) var buf = new List(); SyntheticFontBuilder.WriteInt16(buf, -2); // invalid: only -1 (composite) or >=0 are valid for (var i = 0; i < 4; i++) @@ -431,6 +487,7 @@ public void GlyfLocaReader_InvalidContourCount_ThrowsInvalidDataException() var glyph = buf.ToArray(); var reader = BuildReader([glyph]); + // Act/Assert: reading the glyph with the invalid contour count throws Assert.Throws(() => reader.GetGlyphOutline(0)); } } diff --git a/test/DemaConsulting.CanvasNet.Tests/Fonts/HmtxHheaReaderTests.cs b/test/DemaConsulting.CanvasNet.Tests/Fonts/HmtxHheaReaderTests.cs index 94c57e95..63794254 100644 --- a/test/DemaConsulting.CanvasNet.Tests/Fonts/HmtxHheaReaderTests.cs +++ b/test/DemaConsulting.CanvasNet.Tests/Fonts/HmtxHheaReaderTests.cs @@ -30,11 +30,14 @@ private static HmtxHheaReader Parse(byte[] hhea, byte[] hmtx, int numGlyphs) [Fact] public void HmtxHheaReader_Parse_ExposesMetrics() { + // Arrange: build hhea/hmtx tables for two glyphs with distinct advance widths var hhea = SyntheticFontBuilder.Hhea(2048, -512, 100, 2); var hmtx = SyntheticFontBuilder.Hmtx([600, 700]); + // Act: parse the combined hhea/hmtx tables var reader = Parse(hhea, hmtx, 2); + // Assert: the ascender/descender/line gap and each glyph's advance width are exposed Assert.Equal(2048, reader.Ascender); Assert.Equal(-512, reader.Descender); Assert.Equal(100, reader.LineGap); @@ -48,11 +51,14 @@ public void HmtxHheaReader_Parse_ExposesMetrics() [Fact] public void HmtxHheaReader_GetAdvanceWidth_TailGlyph_ReusesLastEntry() { + // Arrange: build hmtx with two width entries followed by trailing left-side-bearing-only entries var hhea = SyntheticFontBuilder.Hhea(2048, -512, 100, 2); var hmtx = SyntheticFontBuilder.Hmtx([600, 700], tailLsbCount: 3); + // Act: parse the combined hhea/hmtx tables var reader = Parse(hhea, hmtx, 5); + // Assert: glyphs beyond the last width entry reuse that last entry's advance width Assert.Equal(700, reader.GetAdvanceWidth(2)); Assert.Equal(700, reader.GetAdvanceWidth(4)); } @@ -63,8 +69,10 @@ public void HmtxHheaReader_GetAdvanceWidth_TailGlyph_ReusesLastEntry() [Fact] public void HmtxHheaReader_Parse_TruncatedHhea_ThrowsInvalidDataException() { + // Arrange: build an hhea buffer too short to contain a valid header var hhea = new byte[10]; + // Act/Assert: parsing the truncated hhea table throws Assert.Throws(() => HmtxHheaReader.Parse(hhea, 0, hhea.Length, 0, 100, 2)); } @@ -74,8 +82,10 @@ public void HmtxHheaReader_Parse_TruncatedHhea_ThrowsInvalidDataException() [Fact] public void HmtxHheaReader_Parse_NumOfLongHorMetricsZero_ThrowsInvalidDataException() { + // Arrange: build an hhea table declaring zero long horizontal metrics entries var hhea = SyntheticFontBuilder.Hhea(2048, -512, 100, 0); + // Act/Assert: parsing with zero numOfLongHorMetrics throws Assert.Throws(() => HmtxHheaReader.Parse(hhea, 0, hhea.Length, 0, 100, 2)); } @@ -85,8 +95,10 @@ public void HmtxHheaReader_Parse_NumOfLongHorMetricsZero_ThrowsInvalidDataExcept [Fact] public void HmtxHheaReader_Parse_NumOfLongHorMetricsExceedsNumGlyphs_ThrowsInvalidDataException() { + // Arrange: build an hhea table declaring more long horizontal metrics than glyphs exist var hhea = SyntheticFontBuilder.Hhea(2048, -512, 100, 10); + // Act/Assert: parsing with numOfLongHorMetrics exceeding numGlyphs throws Assert.Throws(() => HmtxHheaReader.Parse(hhea, 0, hhea.Length, 0, 100, 2)); } @@ -96,9 +108,11 @@ public void HmtxHheaReader_Parse_NumOfLongHorMetricsExceedsNumGlyphs_ThrowsInval [Fact] public void HmtxHheaReader_Parse_TruncatedHmtx_ThrowsInvalidDataException() { + // Arrange: build an hmtx table with fewer entries than the hhea table declares var hhea = SyntheticFontBuilder.Hhea(2048, -512, 100, 2); var hmtx = SyntheticFontBuilder.Hmtx([600]); // only 1 entry, but hhea declares 2 + // Act/Assert: parsing the truncated hmtx table throws Assert.Throws(() => Parse(hhea, hmtx, 2)); } } diff --git a/test/DemaConsulting.CanvasNet.Tests/Fonts/KernTableTests.cs b/test/DemaConsulting.CanvasNet.Tests/Fonts/KernTableTests.cs index 6873968c..64a9bdee 100644 --- a/test/DemaConsulting.CanvasNet.Tests/Fonts/KernTableTests.cs +++ b/test/DemaConsulting.CanvasNet.Tests/Fonts/KernTableTests.cs @@ -17,10 +17,13 @@ public class KernTableTests [Fact] public void KernTable_Format0_KnownPair_ReturnsValue() { + // Arrange: build a format-0 kern subtable with two known glyph pairs var table = SyntheticFontBuilder.KernFormat0([(3, 4, 120), (5, 6, -30)]); + // Act: parse the kern table var kern = KernTable.Parse(table, 0, table.Length); + // Assert: both pairs resolve to their configured kerning values Assert.Equal(120, kern.GetKerning(3, 4)); Assert.Equal(-30, kern.GetKerning(5, 6)); } @@ -31,10 +34,13 @@ public void KernTable_Format0_KnownPair_ReturnsValue() [Fact] public void KernTable_Format0_UnknownPair_ReturnsZero() { + // Arrange: build a format-0 kern subtable with a single known glyph pair var table = SyntheticFontBuilder.KernFormat0([(3, 4, 120)]); + // Act: parse the kern table var kern = KernTable.Parse(table, 0, table.Length); + // Assert: pairs absent from the table (including a swapped-order pair) resolve to zero Assert.Equal(0, kern.GetKerning(4, 3)); Assert.Equal(0, kern.GetKerning(99, 100)); } @@ -45,6 +51,8 @@ public void KernTable_Format0_UnknownPair_ReturnsZero() [Fact] public void KernTable_Empty_GetKerningReturnsZero() { + // Arrange/Act: use the empty kern table singleton + // Assert: any lookup on the empty table resolves to zero Assert.Equal(0, KernTable.Empty.GetKerning(3, 4)); } @@ -54,10 +62,13 @@ public void KernTable_Empty_GetKerningReturnsZero() [Fact] public void KernTable_NoPairs_ReturnsZero() { + // Arrange: build a format-0 kern subtable with zero pairs var table = SyntheticFontBuilder.KernFormat0([]); + // Act: parse the kern table var kern = KernTable.Parse(table, 0, table.Length); + // Assert: with no pairs defined, any lookup resolves to zero Assert.Equal(0, kern.GetKerning(3, 4)); } @@ -67,8 +78,10 @@ public void KernTable_NoPairs_ReturnsZero() [Fact] public void KernTable_ZeroLengthTable_IsTolerant_ReturnsZero() { + // Arrange/Act: parse a zero-length kern table var kern = KernTable.Parse([], 0, 0); + // Assert: the empty table is tolerated and lookups resolve to zero Assert.Equal(0, kern.GetKerning(3, 4)); } @@ -78,10 +91,13 @@ public void KernTable_ZeroLengthTable_IsTolerant_ReturnsZero() [Fact] public void KernTable_TruncatedTable_IsTolerant_ReturnsZero() { + // Arrange: build a kern table truncated right after declaring one subtable byte[] data = [0, 0, 0, 1, 0]; // version=0, numSubtables=1, then truncated + // Act: parse the truncated kern table var kern = KernTable.Parse(data, 0, data.Length); + // Assert: the truncation is tolerated and lookups resolve to zero Assert.Equal(0, kern.GetKerning(3, 4)); } @@ -91,6 +107,7 @@ public void KernTable_TruncatedTable_IsTolerant_ReturnsZero() [Fact] public void KernTable_MalformedPairCount_IsTolerant_ReturnsZero() { + // Arrange: build a format-0 kern subtable declaring an nPairs value too large to fit var buf = new List(); SyntheticFontBuilder.WriteUInt16(buf, 0); // kern table version SyntheticFontBuilder.WriteUInt16(buf, 1); // nTables @@ -102,9 +119,11 @@ public void KernTable_MalformedPairCount_IsTolerant_ReturnsZero() SyntheticFontBuilder.WriteUInt16(buf, 0); SyntheticFontBuilder.WriteUInt16(buf, 0); + // Act: parse the kern table with the malformed pair count var data = buf.ToArray(); var kern = KernTable.Parse(data, 0, data.Length); + // Assert: the malformed pair count is tolerated and lookups resolve to zero Assert.Equal(0, kern.GetKerning(3, 4)); } @@ -114,6 +133,7 @@ public void KernTable_MalformedPairCount_IsTolerant_ReturnsZero() [Fact] public void KernTable_UnsupportedFormat2Subtable_IsTolerant_ReturnsZero() { + // Arrange: build a kern subtable declaring the unsupported format-2 coverage var buf = new List(); SyntheticFontBuilder.WriteUInt16(buf, 0); // kern table version SyntheticFontBuilder.WriteUInt16(buf, 1); // nTables @@ -121,9 +141,11 @@ public void KernTable_UnsupportedFormat2Subtable_IsTolerant_ReturnsZero() SyntheticFontBuilder.WriteUInt16(buf, 6); // subtable length (header only) SyntheticFontBuilder.WriteUInt16(buf, 0x0201); // coverage: format 2, horizontal + // Act: parse the kern table with the unsupported subtable format var data = buf.ToArray(); var kern = KernTable.Parse(data, 0, data.Length); + // Assert: the unsupported format is ignored and lookups resolve to zero Assert.Equal(0, kern.GetKerning(3, 4)); } @@ -133,6 +155,7 @@ public void KernTable_UnsupportedFormat2Subtable_IsTolerant_ReturnsZero() [Fact] public void KernTable_CrossStreamCoverage_IsSkipped_ReturnsZero() { + // Arrange: build a kern subtable declaring the cross-stream coverage bit var buf = new List(); SyntheticFontBuilder.WriteUInt16(buf, 0); // kern table version SyntheticFontBuilder.WriteUInt16(buf, 1); // nTables @@ -140,9 +163,11 @@ public void KernTable_CrossStreamCoverage_IsSkipped_ReturnsZero() SyntheticFontBuilder.WriteUInt16(buf, 6); // subtable length (header only) SyntheticFontBuilder.WriteUInt16(buf, 0x0005); // coverage: format 0, horizontal + cross-stream + // Act: parse the kern table with the cross-stream subtable var data = buf.ToArray(); var kern = KernTable.Parse(data, 0, data.Length); + // Assert: the cross-stream subtable is skipped and lookups resolve to zero Assert.Equal(0, kern.GetKerning(3, 4)); } } diff --git a/test/DemaConsulting.CanvasNet.Tests/Fonts/SfntContainerTests.cs b/test/DemaConsulting.CanvasNet.Tests/Fonts/SfntContainerTests.cs index 160c5cf5..6df6551b 100644 --- a/test/DemaConsulting.CanvasNet.Tests/Fonts/SfntContainerTests.cs +++ b/test/DemaConsulting.CanvasNet.Tests/Fonts/SfntContainerTests.cs @@ -23,10 +23,13 @@ private static byte[] BuildMinimalFont() => [Fact] public void SfntContainer_Parse_WellFormedFont_ExposesTables() { + // Arrange: build a minimal well-formed SFNT font with head and glyf tables var data = BuildMinimalFont(); + // Act: parse the font data var container = SfntContainer.Parse(data); + // Assert: known tables are exposed with correct lengths, unknown tables are not Assert.True(container.TryGetTable("head", out var head)); Assert.Equal(54, head.Length); Assert.True(container.TryGetTable("glyf", out _)); @@ -39,9 +42,11 @@ public void SfntContainer_Parse_WellFormedFont_ExposesTables() [Fact] public void SfntContainer_Parse_MissingRequiredTable_ThrowsInvalidDataException() { + // Arrange: parse a font that has no cmap table var data = BuildMinimalFont(); var container = SfntContainer.Parse(data); + // Act/Assert: requiring the missing table throws Assert.Throws(() => container.RequireTable("cmap")); } @@ -51,8 +56,10 @@ public void SfntContainer_Parse_MissingRequiredTable_ThrowsInvalidDataException( [Fact] public void SfntContainer_Parse_TruncatedOffsetTable_ThrowsInvalidDataException() { + // Arrange: build data too short to contain a valid SFNT offset table var data = new byte[8]; + // Act/Assert: parsing the truncated data throws Assert.Throws(() => SfntContainer.Parse(data)); } @@ -62,6 +69,7 @@ public void SfntContainer_Parse_TruncatedOffsetTable_ThrowsInvalidDataException( [Fact] public void SfntContainer_Parse_TruncatedTableDirectory_ThrowsInvalidDataException() { + // Arrange: build an offset table claiming 5 table directory entries but supplying none var buf = new List(); SyntheticFontBuilder.WriteUInt32(buf, 0x00010000); SyntheticFontBuilder.WriteUInt16(buf, 5); // claims 5 tables but has none @@ -69,6 +77,7 @@ public void SfntContainer_Parse_TruncatedTableDirectory_ThrowsInvalidDataExcepti SyntheticFontBuilder.WriteUInt16(buf, 0); SyntheticFontBuilder.WriteUInt16(buf, 0); + // Act/Assert: parsing the truncated table directory throws Assert.Throws(() => SfntContainer.Parse([.. buf])); } @@ -78,11 +87,13 @@ public void SfntContainer_Parse_TruncatedTableDirectory_ThrowsInvalidDataExcepti [Fact] public void SfntContainer_Parse_OttoVersion_ThrowsInvalidDataException() { + // Arrange: build a font with the unsupported 'OTTO' (CFF-flavored) sfnt version tag var data = new SyntheticFontBuilder() .WithSfntVersion(0x4F54544F) // 'OTTO' .AddTable("head", SyntheticFontBuilder.Head(1000, 0)) .Build(); + // Act/Assert: parsing throws, and the message identifies the unsupported version var ex = Assert.Throws(() => SfntContainer.Parse(data)); Assert.Contains("OTTO", ex.Message, StringComparison.Ordinal); } @@ -93,13 +104,16 @@ public void SfntContainer_Parse_OttoVersion_ThrowsInvalidDataException() [Fact] public void SfntContainer_Parse_MacTrueVersion_Succeeds() { + // Arrange: build a font using the legacy Mac 'true' sfnt version tag var data = new SyntheticFontBuilder() .WithSfntVersion(0x74727565) // 'true' .AddTable("head", SyntheticFontBuilder.Head(1000, 0)) .Build(); + // Act: parse the font data var container = SfntContainer.Parse(data); + // Assert: parsing succeeds and the head table is exposed Assert.True(container.TryGetTable("head", out _)); } @@ -109,11 +123,13 @@ public void SfntContainer_Parse_MacTrueVersion_Succeeds() [Fact] public void SfntContainer_Parse_UnrecognizedVersion_ThrowsInvalidDataException() { + // Arrange: build a font with an sfnt version tag not recognized by this implementation var data = new SyntheticFontBuilder() .WithSfntVersion(0x12345678) .AddTable("head", SyntheticFontBuilder.Head(1000, 0)) .Build(); + // Act/Assert: parsing the unrecognized version throws Assert.Throws(() => SfntContainer.Parse(data)); } @@ -123,7 +139,7 @@ public void SfntContainer_Parse_UnrecognizedVersion_ThrowsInvalidDataException() [Fact] public void SfntContainer_Parse_TableDirectoryEntryOverflowsUint_ThrowsInvalidDataException() { - // Hand-build an offset table + a single table directory entry whose offset is + // Arrange: hand-build an offset table + a single table directory entry whose offset is // uint.MaxValue: a naive raw-uint "offset + length" bounds check would wrap around // (4294967295 + 54 mod 2^32 == 53), which could deceptively pass a small data-length // bounds check despite the offset itself being nonsensical. Computing the bound in @@ -140,6 +156,7 @@ public void SfntContainer_Parse_TableDirectoryEntryOverflowsUint_ThrowsInvalidDa SyntheticFontBuilder.WriteUInt32(buf, 0xFFFFFFFF); // offset = uint.MaxValue SyntheticFontBuilder.WriteUInt32(buf, 54); // length + // Act/Assert: parsing the overflowing table directory entry throws Assert.Throws(() => SfntContainer.Parse([.. buf])); } @@ -149,6 +166,7 @@ public void SfntContainer_Parse_TableDirectoryEntryOverflowsUint_ThrowsInvalidDa [Fact] public void SfntContainer_Parse_TableDirectoryEntryOutOfBounds_ThrowsInvalidDataException() { + // Arrange: build a table directory entry whose offset lies well past the actual data var buf = new List(); SyntheticFontBuilder.WriteUInt32(buf, 0x00010000); SyntheticFontBuilder.WriteUInt16(buf, 1); @@ -161,6 +179,7 @@ public void SfntContainer_Parse_TableDirectoryEntryOutOfBounds_ThrowsInvalidData SyntheticFontBuilder.WriteUInt32(buf, 1000); // offset well past the actual data length SyntheticFontBuilder.WriteUInt32(buf, 54); + // Act/Assert: parsing the out-of-bounds table directory entry throws Assert.Throws(() => SfntContainer.Parse([.. buf])); } @@ -173,11 +192,14 @@ public void SfntContainer_Parse_TableDirectoryEntryOutOfBounds_ThrowsInvalidData [InlineData((ushort)0xFFFF)] public void SfntContainer_ReadUInt16_BigEndianRegardlessOfHostEndianness_ReturnsExpectedValue(ushort value) { + // Arrange: write the value as big-endian bytes var buf = new List(); SyntheticFontBuilder.WriteUInt16(buf, value); + // Act: read the value back as a big-endian UInt16 var result = SfntContainer.ReadUInt16([.. buf], 0); + // Assert: the read value matches the original regardless of host endianness Assert.Equal(value, result); } @@ -190,11 +212,14 @@ public void SfntContainer_ReadUInt16_BigEndianRegardlessOfHostEndianness_Returns [InlineData(short.MaxValue)] public void SfntContainer_ReadInt16_BigEndianRegardlessOfHostEndianness_ReturnsExpectedValue(short value) { + // Arrange: write the value as big-endian bytes var buf = new List(); SyntheticFontBuilder.WriteInt16(buf, value); + // Act: read the value back as a big-endian Int16 var result = SfntContainer.ReadInt16([.. buf], 0); + // Assert: the read value matches the original regardless of host endianness Assert.Equal(value, result); } @@ -204,11 +229,14 @@ public void SfntContainer_ReadInt16_BigEndianRegardlessOfHostEndianness_ReturnsE [Fact] public void SfntContainer_ReadUInt32_BigEndianRegardlessOfHostEndianness_ReturnsExpectedValue() { + // Arrange: write a value as big-endian bytes var buf = new List(); SyntheticFontBuilder.WriteUInt32(buf, 0xDEADBEEF); + // Act: read the value back as a big-endian UInt32 var result = SfntContainer.ReadUInt32([.. buf], 0); + // Assert: the read value matches the original regardless of host endianness Assert.Equal(0xDEADBEEFu, result); } @@ -221,11 +249,14 @@ public void SfntContainer_ReadUInt32_BigEndianRegardlessOfHostEndianness_Returns [InlineData(0.5f)] public void SfntContainer_ReadF2Dot14_ReturnsExpectedValue(float value) { + // Arrange: write the value as an F2Dot14 fixed-point encoding var buf = new List(); SyntheticFontBuilder.WriteF2Dot14(buf, value); + // Act: read the value back as F2Dot14 var result = SfntContainer.ReadF2Dot14([.. buf], 0); + // Assert: the read value matches the original within F2Dot14 precision Assert.Equal(value, result, 3); } } diff --git a/test/DemaConsulting.CanvasNet.Tests/Fonts/TrueTypeFontTests.cs b/test/DemaConsulting.CanvasNet.Tests/Fonts/TrueTypeFontTests.cs index a9fa0525..2e34aa3e 100644 --- a/test/DemaConsulting.CanvasNet.Tests/Fonts/TrueTypeFontTests.cs +++ b/test/DemaConsulting.CanvasNet.Tests/Fonts/TrueTypeFontTests.cs @@ -44,10 +44,13 @@ private static byte[] BuildWellFormedFont() [Fact] public void TrueTypeFont_Load_WellFormedFont_ExposesMetrics() { + // Arrange: build a complete, well-formed synthetic font var data = BuildWellFormedFont(); + // Act: load the font var font = TrueTypeFont.Load(new MemoryStream(data)); + // Assert: font-level metrics are exposed as configured Assert.Equal(1000, font.UnitsPerEm); Assert.Equal(800, font.Ascender); Assert.Equal(-200, font.Descender); @@ -61,14 +64,17 @@ public void TrueTypeFont_Load_WellFormedFont_ExposesMetrics() [Fact] public void TrueTypeFont_Load_EndToEnd_GlyphIndexOutlineAdvanceAndKerning() { + // Arrange: build a complete, well-formed synthetic font var data = BuildWellFormedFont(); + // Act: load the font and query glyph index, outline, advance width, and kerning var font = TrueTypeFont.Load(new MemoryStream(data)); var glyphIndex = font.GetGlyphIndex('A'); var outline = font.GetGlyphOutline(glyphIndex); var advance = font.GetAdvanceWidth(glyphIndex); var kerning = font.GetKerning(0, glyphIndex); + // Assert: every stage of the end-to-end pipeline returns the expected values Assert.Equal(1, glyphIndex); Assert.Single(outline.Subpaths); Assert.Equal(500, advance); @@ -81,14 +87,17 @@ public void TrueTypeFont_Load_EndToEnd_GlyphIndexOutlineAdvanceAndKerning() [Fact] public void TrueTypeFont_Load_Path_ReadsFromFile() { + // Arrange: write a well-formed font to a temporary file var data = BuildWellFormedFont(); var path = System.IO.Path.GetTempFileName(); try { File.WriteAllBytes(path, data); + // Act: load the font from the file path var font = TrueTypeFont.Load(path); + // Assert: the font loads correctly from the file Assert.Equal(1000, font.UnitsPerEm); } finally @@ -103,6 +112,7 @@ public void TrueTypeFont_Load_Path_ReadsFromFile() [Fact] public void TrueTypeFont_Load_NullStream_ThrowsArgumentNullException() { + // Arrange/Act/Assert: loading from a null stream throws Assert.Throws(() => TrueTypeFont.Load((Stream)null!)); } @@ -112,6 +122,7 @@ public void TrueTypeFont_Load_NullStream_ThrowsArgumentNullException() [Fact] public void TrueTypeFont_Load_NullPath_ThrowsArgumentNullException() { + // Arrange/Act/Assert: loading from a null path throws Assert.Throws(() => TrueTypeFont.Load((string)null!)); } @@ -121,6 +132,7 @@ public void TrueTypeFont_Load_NullPath_ThrowsArgumentNullException() [Fact] public void TrueTypeFont_Load_EmptyPath_ThrowsArgumentException() { + // Arrange/Act/Assert: loading from an empty path throws Assert.Throws(() => TrueTypeFont.Load(string.Empty)); } @@ -130,10 +142,12 @@ public void TrueTypeFont_Load_EmptyPath_ThrowsArgumentException() [Fact] public void TrueTypeFont_Load_MissingRequiredTable_ThrowsInvalidDataException() { + // Arrange: build a font missing maxp/hhea/hmtx/loca/glyf var data = new SyntheticFontBuilder() .AddTable("head", SyntheticFontBuilder.Head(1000, 0)) .Build(); // missing maxp/hhea/hmtx/loca/glyf + // Act/Assert: loading the font with missing required tables throws Assert.Throws(() => TrueTypeFont.Load(new MemoryStream(data))); } @@ -143,11 +157,13 @@ public void TrueTypeFont_Load_MissingRequiredTable_ThrowsInvalidDataException() [Fact] public void TrueTypeFont_Load_OttoFont_ThrowsInvalidDataException() { + // Arrange: build a font with the unsupported 'OTTO' (CFF-flavored) sfnt version tag var data = new SyntheticFontBuilder() .WithSfntVersion(0x4F54544F) .AddTable("head", SyntheticFontBuilder.Head(1000, 0)) .Build(); + // Act/Assert: loading the OTTO font throws Assert.Throws(() => TrueTypeFont.Load(new MemoryStream(data))); } @@ -157,6 +173,7 @@ public void TrueTypeFont_Load_OttoFont_ThrowsInvalidDataException() [Fact] public void TrueTypeFont_Load_MaxpVersion05_ThrowsInvalidDataException() { + // Arrange: build a font whose maxp table declares the unsupported version 0.5 (CFF-only) var glyph = SyntheticFontBuilder.SimpleGlyph([[(0, 0, true), (10, 0, true), (10, 10, true)]]); var data = new SyntheticFontBuilder() .AddTable("head", SyntheticFontBuilder.Head(1000, 0)) @@ -167,6 +184,7 @@ public void TrueTypeFont_Load_MaxpVersion05_ThrowsInvalidDataException() .AddTable("glyf", glyph) .Build(); + // Act/Assert: loading the font with the unsupported maxp version throws Assert.Throws(() => TrueTypeFont.Load(new MemoryStream(data))); } @@ -176,6 +194,7 @@ public void TrueTypeFont_Load_MaxpVersion05_ThrowsInvalidDataException() [Fact] public void TrueTypeFont_Load_ZeroUnitsPerEm_ThrowsInvalidDataException() { + // Arrange: build a font whose head table declares zero unitsPerEm var glyph = SyntheticFontBuilder.SimpleGlyph([[(0, 0, true), (10, 0, true), (10, 10, true)]]); var data = new SyntheticFontBuilder() .AddTable("head", SyntheticFontBuilder.Head(0, 0)) @@ -186,6 +205,7 @@ public void TrueTypeFont_Load_ZeroUnitsPerEm_ThrowsInvalidDataException() .AddTable("glyf", glyph) .Build(); + // Act/Assert: loading the font with zero unitsPerEm throws Assert.Throws(() => TrueTypeFont.Load(new MemoryStream(data))); } @@ -195,6 +215,7 @@ public void TrueTypeFont_Load_ZeroUnitsPerEm_ThrowsInvalidDataException() [Fact] public void TrueTypeFont_Load_InvalidIndexToLocFormat_ThrowsInvalidDataException() { + // Arrange: build a font whose head table declares an invalid indexToLocFormat (only 0 or 1 are valid) var glyph = SyntheticFontBuilder.SimpleGlyph([[(0, 0, true), (10, 0, true), (10, 10, true)]]); var data = new SyntheticFontBuilder() .AddTable("head", SyntheticFontBuilder.Head(1000, 2)) // only 0 or 1 are valid @@ -205,6 +226,7 @@ public void TrueTypeFont_Load_InvalidIndexToLocFormat_ThrowsInvalidDataException .AddTable("glyf", glyph) .Build(); + // Act/Assert: loading the font with the invalid indexToLocFormat throws Assert.Throws(() => TrueTypeFont.Load(new MemoryStream(data))); } @@ -214,6 +236,7 @@ public void TrueTypeFont_Load_InvalidIndexToLocFormat_ThrowsInvalidDataException [Fact] public void TrueTypeFont_Load_NoCmapOrKern_FallsBackGracefully() { + // Arrange: build a font with no cmap or kern tables var glyph = SyntheticFontBuilder.SimpleGlyph([[(0, 0, true), (10, 0, true), (10, 10, true)]]); var data = new SyntheticFontBuilder() .AddTable("head", SyntheticFontBuilder.Head(1000, 0)) @@ -224,8 +247,10 @@ public void TrueTypeFont_Load_NoCmapOrKern_FallsBackGracefully() .AddTable("glyf", glyph) .Build(); + // Act: load the font and query codepoint lookup and kerning var font = TrueTypeFont.Load(new MemoryStream(data)); + // Assert: missing optional tables fall back gracefully to default values Assert.Equal(0, font.GetGlyphIndex('A')); Assert.Equal(0, font.GetKerning(0, 0)); } @@ -236,8 +261,10 @@ public void TrueTypeFont_Load_NoCmapOrKern_FallsBackGracefully() [Fact] public void TrueTypeFont_GetGlyphOutline_NegativeGlyphIndex_ThrowsArgumentOutOfRangeException() { + // Arrange: load a well-formed font var font = TrueTypeFont.Load(new MemoryStream(BuildWellFormedFont())); + // Act/Assert: requesting a negative glyph index throws Assert.Throws(() => font.GetGlyphOutline(-1)); } @@ -247,8 +274,10 @@ public void TrueTypeFont_GetGlyphOutline_NegativeGlyphIndex_ThrowsArgumentOutOfR [Fact] public void TrueTypeFont_GetGlyphOutline_GlyphIndexTooLarge_ThrowsArgumentOutOfRangeException() { + // Arrange: load a well-formed font var font = TrueTypeFont.Load(new MemoryStream(BuildWellFormedFont())); + // Act/Assert: requesting a glyph index at/beyond the glyph count throws Assert.Throws(() => font.GetGlyphOutline(font.GlyphCount)); } @@ -258,8 +287,10 @@ public void TrueTypeFont_GetGlyphOutline_GlyphIndexTooLarge_ThrowsArgumentOutOfR [Fact] public void TrueTypeFont_GetAdvanceWidth_GlyphIndexTooLarge_ThrowsArgumentOutOfRangeException() { + // Arrange: load a well-formed font var font = TrueTypeFont.Load(new MemoryStream(BuildWellFormedFont())); + // Act/Assert: requesting the advance width of an out-of-range glyph index throws Assert.Throws(() => font.GetAdvanceWidth(font.GlyphCount)); } @@ -269,10 +300,13 @@ public void TrueTypeFont_GetAdvanceWidth_GlyphIndexTooLarge_ThrowsArgumentOutOfR [Fact] public void TrueTypeFont_GetKerning_OutOfRangeGlyphIndex_NeverThrows() { + // Arrange: load a well-formed font var font = TrueTypeFont.Load(new MemoryStream(BuildWellFormedFont())); + // Act: query kerning with out-of-range glyph indices var result = font.GetKerning(-1, 99999); + // Assert: out-of-range glyph indices resolve to zero rather than throwing Assert.Equal(0, result); } @@ -282,10 +316,13 @@ public void TrueTypeFont_GetKerning_OutOfRangeGlyphIndex_NeverThrows() [Fact] public void TrueTypeFont_GetGlyphIndex_UnmappedCodepoint_NeverThrows() { + // Arrange: load a well-formed font var font = TrueTypeFont.Load(new MemoryStream(BuildWellFormedFont())); + // Act: query the glyph index for a codepoint absent from the cmap var result = font.GetGlyphIndex(0x10FFFF); + // Assert: the unmapped codepoint resolves to glyph index zero rather than throwing Assert.Equal(0, result); } @@ -295,9 +332,11 @@ public void TrueTypeFont_GetGlyphIndex_UnmappedCodepoint_NeverThrows() [Fact] public void TrueTypeFont_Load_TruncatedStream_ThrowsInvalidDataException() { + // Arrange: build a well-formed font, then truncate it to half its length var data = BuildWellFormedFont(); var truncated = data[..(data.Length / 2)]; + // Act/Assert: loading the truncated stream throws Assert.Throws(() => TrueTypeFont.Load(new MemoryStream(truncated))); } } From ee7bc66dc257b4d1558b3cd583d5cd394dac9059 Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Tue, 22 Sep 2026 12:55:18 -0400 Subject: [PATCH 05/17] Fix PR review findings: cmap offset overflow, glyf flag-repeat clip, cmap glyph clamp - CmapTable.cs: use overflow-safe (long) arithmetic for the subtable-offset bounds check in Parse (an offset near int.MaxValue previously wrapped around and passed the check, leading to an IndexOutOfRangeException downstream), and apply the same overflow-safe pattern to the equivalent entry-point bounds checks in TryParseFormat4/TryParseFormat12. - GlyfLocaReader.cs: reject a simple-glyph flag repeat count that exceeds the remaining point slots with InvalidDataException instead of silently clipping it to the remaining count, matching this class's documented malformed-input contract. - TrueTypeFont.cs: clamp GetGlyphIndex's result to 0 when the cmap subtable maps a codepoint to a glyph index outside [0, GlyphCount), so callers can always safely pass its result straight to GetGlyphOutline; updated the XML doc comment to make this guarantee explicit. - Added/extended unit tests reproducing each malformed-input scenario using SyntheticFontBuilder synthetic fonts (no third-party font fixtures). --- .../Fonts/CmapTable.cs | 6 +-- .../Fonts/GlyfLocaReader.cs | 7 ++- .../Fonts/TrueTypeFont.cs | 9 +++- .../Fonts/CmapTableTests.cs | 32 ++++++++++++ .../Fonts/GlyfLocaReaderTests.cs | 50 +++++++++++++++++++ .../Fonts/TrueTypeFontTests.cs | 35 +++++++++++++ 6 files changed, 133 insertions(+), 6 deletions(-) diff --git a/src/DemaConsulting.CanvasNet/Fonts/CmapTable.cs b/src/DemaConsulting.CanvasNet/Fonts/CmapTable.cs index 0e061a7b..f158533d 100644 --- a/src/DemaConsulting.CanvasNet/Fonts/CmapTable.cs +++ b/src/DemaConsulting.CanvasNet/Fonts/CmapTable.cs @@ -70,7 +70,7 @@ public static CmapTable Parse(byte[] data, int tableOffset, int tableLength) var encodingId = SfntContainer.ReadUInt16(data, recordOffset + 2); var subtableOffset = SfntContainer.ReadInt32(data, recordOffset + 4); - if (subtableOffset < 0 || subtableOffset + 2 > tableLength) + if (subtableOffset < 0 || (long)subtableOffset + 2 > tableLength) { continue; } @@ -152,7 +152,7 @@ private static int ClassifyPriority(int platformId, int encodingId, int format) /// private static Func? TryParseFormat4(byte[] data, int offset, int tableEnd) { - if (offset + 14 > tableEnd) + if ((long)offset + 14 > tableEnd) { return null; } @@ -231,7 +231,7 @@ private static int ClassifyPriority(int platformId, int encodingId, int format) /// private static Func? TryParseFormat12(byte[] data, int offset, int tableEnd) { - if (offset + 16 > tableEnd) + if ((long)offset + 16 > tableEnd) { return null; } diff --git a/src/DemaConsulting.CanvasNet/Fonts/GlyfLocaReader.cs b/src/DemaConsulting.CanvasNet/Fonts/GlyfLocaReader.cs index a57f5d3f..dc124bc4 100644 --- a/src/DemaConsulting.CanvasNet/Fonts/GlyfLocaReader.cs +++ b/src/DemaConsulting.CanvasNet/Fonts/GlyfLocaReader.cs @@ -250,7 +250,12 @@ private Path DecodeSimpleGlyph(int glyphOffset, int glyphLength, int numberOfCon { EnsureAvailable(pos, 1, limit); var repeatCount = _data[pos++]; - for (var r = 0; r < repeatCount && i2 < numPoints; r++) + if (repeatCount > numPoints - i2) + { + throw new InvalidDataException("Glyph flag repeat count exceeds the declared point count."); + } + + for (var r = 0; r < repeatCount; r++) { flags[i2++] = flag; } diff --git a/src/DemaConsulting.CanvasNet/Fonts/TrueTypeFont.cs b/src/DemaConsulting.CanvasNet/Fonts/TrueTypeFont.cs index c96ab448..44a16a49 100644 --- a/src/DemaConsulting.CanvasNet/Fonts/TrueTypeFont.cs +++ b/src/DemaConsulting.CanvasNet/Fonts/TrueTypeFont.cs @@ -158,9 +158,14 @@ public static TrueTypeFont Load(string path) /// /// The mapped glyph index, or 0 (.notdef) if the codepoint is unmapped, or /// the font has no cmap table, or no supported cmap subtable is present. - /// Never throws. + /// Always in [0, GlyphCount) - a malformed cmap subtable mapping a codepoint + /// to a glyph index outside that range is clamped to 0. Never throws. /// - public int GetGlyphIndex(int codepoint) => _cmap.GetGlyphIndex(codepoint); + public int GetGlyphIndex(int codepoint) + { + var glyphIndex = _cmap.GetGlyphIndex(codepoint); + return glyphIndex < 0 || glyphIndex >= GlyphCount ? 0 : glyphIndex; + } /// /// Decodes a single glyph's outline. diff --git a/test/DemaConsulting.CanvasNet.Tests/Fonts/CmapTableTests.cs b/test/DemaConsulting.CanvasNet.Tests/Fonts/CmapTableTests.cs index 3dbab35d..4f2bdff3 100644 --- a/test/DemaConsulting.CanvasNet.Tests/Fonts/CmapTableTests.cs +++ b/test/DemaConsulting.CanvasNet.Tests/Fonts/CmapTableTests.cs @@ -159,6 +159,38 @@ public void CmapTable_MalformedSubtableOffset_GetGlyphIndexReturnsZero() Assert.Equal(0, cmap.GetGlyphIndex(65)); } + /// + /// Proves that CmapTable SubtableOffsetNearIntMaxValue GetGlyphIndexReturnsZero. + /// + [Fact] + public void CmapTable_SubtableOffsetNearIntMaxValue_GetGlyphIndexReturnsZero() + { + // Arrange: build a cmap header whose single subtable offset is near int.MaxValue, so that + // a naive `subtableOffset + 2 > tableLength` bounds check would wrap around to a negative + // value and incorrectly pass. The buffer is padded past the single 8-byte encoding record + // (to at least `recordsEnd` = 8 + numTables * 8 = 16 bytes) so that `Parse` actually + // reaches the per-record subtableOffset bounds check inside the loop, rather than + // returning `Empty` early from the `recordsEnd > tableLength` guard before that check is + // ever exercised. + var buf = new List + { + 0, 0, // version + 0, 1, // numTables + 0, 3, // platformId + 0, 1, // encodingId + }; + SyntheticFontBuilder.WriteUInt32(buf, 0x7FFFFFFF); // subtable offset near int.MaxValue + buf.AddRange(new byte[4]); // padding so tableLength (16) reaches recordsEnd (16) + + // Act: parse the cmap table with the overflow-prone subtable offset + var data = buf.ToArray(); + var cmap = CmapTable.Parse(data, 0, data.Length); + + // Assert: the malformed offset is rejected (not wrapped-around-accepted) and lookups + // resolve to glyph index zero, without throwing + Assert.Equal(0, cmap.GetGlyphIndex(65)); + } + /// /// Proves that CmapTable UnrecognizedSubtableFormat GetGlyphIndexReturnsZero. /// diff --git a/test/DemaConsulting.CanvasNet.Tests/Fonts/GlyfLocaReaderTests.cs b/test/DemaConsulting.CanvasNet.Tests/Fonts/GlyfLocaReaderTests.cs index b6af2d0b..7e6eec8c 100644 --- a/test/DemaConsulting.CanvasNet.Tests/Fonts/GlyfLocaReaderTests.cs +++ b/test/DemaConsulting.CanvasNet.Tests/Fonts/GlyfLocaReaderTests.cs @@ -102,6 +102,56 @@ public void GlyfLocaReader_SimpleGlyph_MixedOnAndOffCurve_ProducesQuadraticToRea Assert.Contains(subpath.Commands, c => c.Type == PathCommandType.QuadraticBezierTo); } + /// + /// Proves that GlyfLocaReader SimpleGlyph FlagRepeatCountExceedsRemainingPoints + /// ThrowsInvalidDataException. + /// + [Fact] + public void GlyfLocaReader_SimpleGlyph_FlagRepeatCountExceedsRemainingPoints_ThrowsInvalidDataException() + { + // Arrange: hand-build a simple glyph with a single 4-point contour whose first flag byte + // declares a repeat count (10) far exceeding the number of remaining point slots (3) - + // malformed input that must be rejected rather than silently clipped. Sufficient valid + // x/y coordinate bytes for all 4 points are appended so that, on the old/pre-fix code + // (which silently clips the repeat loop to `numPoints`), decoding would fully and + // successfully complete with no truncation - ensuring any throw can only originate from + // the repeat-count validation itself, not from running out of coordinate data. + var buf = new List(); + SyntheticFontBuilder.WriteInt16(buf, 1); // numberOfContours + SyntheticFontBuilder.WriteInt16(buf, 0); // xMin + SyntheticFontBuilder.WriteInt16(buf, 0); // yMin + SyntheticFontBuilder.WriteInt16(buf, 0); // xMax + SyntheticFontBuilder.WriteInt16(buf, 0); // yMax + SyntheticFontBuilder.WriteUInt16(buf, 3); // endPts[0] -> numPoints = 4 + SyntheticFontBuilder.WriteUInt16(buf, 0); // instructionLength + buf.Add(0x08); // flag: REPEAT_FLAG set (neither X/Y-short nor X/Y-same bits set) + buf.Add(10); // repeatCount: far exceeds the 3 remaining point slots + + // Trailing coordinate data: 4 points, each needing a 2-byte x delta followed (after all + // x deltas) by a 2-byte y delta, per the flag byte above - enough for the old/pre-fix + // code to decode the whole glyph without hitting a truncation-caused throw. + for (var p = 0; p < 4; p++) + { + SyntheticFontBuilder.WriteInt16(buf, 10); // x delta + } + + for (var p = 0; p < 4; p++) + { + SyntheticFontBuilder.WriteInt16(buf, 10); // y delta + } + + var glyph = buf.ToArray(); + + // Act: build the reader over the malformed glyph + var reader = BuildReader([glyph]); + + // Assert: decoding the malformed glyph throws specifically because of the repeat-count + // validation - not merely any InvalidDataException, which could also arise from + // truncated coordinate data + var exception = Assert.Throws(() => reader.GetGlyphOutline(0)); + Assert.Contains("repeat count", exception.Message, StringComparison.OrdinalIgnoreCase); + } + /// /// Proves that GlyfLocaReader EmptyGlyph ZeroContours ProducesEmptyPath. /// diff --git a/test/DemaConsulting.CanvasNet.Tests/Fonts/TrueTypeFontTests.cs b/test/DemaConsulting.CanvasNet.Tests/Fonts/TrueTypeFontTests.cs index 2e34aa3e..d40c9e6d 100644 --- a/test/DemaConsulting.CanvasNet.Tests/Fonts/TrueTypeFontTests.cs +++ b/test/DemaConsulting.CanvasNet.Tests/Fonts/TrueTypeFontTests.cs @@ -326,6 +326,41 @@ public void TrueTypeFont_GetGlyphIndex_UnmappedCodepoint_NeverThrows() Assert.Equal(0, result); } + /// + /// Proves that TrueTypeFont GetGlyphIndex MalformedCmapOutOfRangeGlyphId ClampsToZero. + /// + [Fact] + public void TrueTypeFont_GetGlyphIndex_MalformedCmapOutOfRangeGlyphId_ClampsToZero() + { + // Arrange: build a well-formed font (two glyphs, GlyphCount == 2) whose cmap maps + // codepoint 'A' to glyph index 999 - a malformed mapping outside [0, GlyphCount) + var glyph = SyntheticFontBuilder.SimpleGlyph( + [ + [(0, 0, true), (10, 0, true), (10, 10, true), (0, 10, true)] + ]); + + var cmap = SyntheticFontBuilder.CmapFormat4(3, 1, [(65, 999)]); + + var data = new SyntheticFontBuilder() + .AddTable("head", SyntheticFontBuilder.Head(1000, 0)) + .AddTable("maxp", SyntheticFontBuilder.Maxp(2)) + .AddTable("hhea", SyntheticFontBuilder.Hhea(800, -200, 50, 2)) + .AddTable("hmtx", SyntheticFontBuilder.Hmtx([0, 500])) + .AddTable("loca", SyntheticFontBuilder.Loca([0, glyph.Length], longFormat: false)) + .AddTable("glyf", glyph) + .AddTable("cmap", cmap) + .Build(); + + // Act: load the font and resolve the malformed-mapped codepoint + var font = TrueTypeFont.Load(new MemoryStream(data)); + var glyphIndex = font.GetGlyphIndex(65); + + // Assert: the out-of-range mapped glyph index is clamped to zero rather than propagated, + // so it never breaks GetGlyphOutline's [0, GlyphCount) contract + Assert.Equal(0, glyphIndex); + font.GetGlyphOutline(glyphIndex); // must not throw + } + /// /// Proves that TrueTypeFont Load TruncatedStream ThrowsInvalidDataException. /// From 30917e7c29fa30a815b6e5b0a1e6a46f78c964d5 Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Tue, 22 Sep 2026 13:38:42 -0400 Subject: [PATCH 06/17] Fix 4 PR review findings: kern/cmap overflow and validation gaps, composite glyph offset scaling - KernTable.Parse: widen the subtable end-position arithmetic (`pos + 6` and `pos + subtableLength`) to `long` before comparing against `tableEnd`, matching the overflow-safe pattern already used elsewhere in this file and in SfntContainer/CmapTable. Prevents a wrapped (negative) subtable end from bypassing the bounds check and later driving a corrupted `pos` into an out-of-bounds read. - CmapTable.Parse: bound each candidate format-4/format-12 subtable by its own declared `length` field (not just the enclosing `cmap` table's end), via a new `ComputeSubtableEnd` helper. Previously a subtable could read past its own declared length into a following subtable's bytes and return a bogus (but structurally passable) mapping instead of being rejected. - CmapTable.TryParseFormat4: reject an odd `segCountX2` outright instead of silently truncating `segCount` via integer division, which previously misaligned the parallel endCode/startCode/idDelta/idRangeOffset arrays and could still produce a bogus (rather than failing) lookup. - GlyfLocaReader.DecodeCompositeGlyph: honor the `SCALED_COMPONENT_OFFSET` / `UNSCALED_COMPONENT_OFFSET` composite glyph flags. When a component sets `SCALED_COMPONENT_OFFSET` (and not `UNSCALED_COMPONENT_OFFSET`), its dx/dy translation is now transformed through the component's own scale/2x2 matrix before being applied, instead of always being applied unscaled. Added SyntheticFontBuilder support for the new composite glyph flags, corrected CmapFormat4/CmapFormat12 to write their subtables' real declared length (no longer a placeholder now that CmapTable enforces it), and added regression tests for each of the four fixes - each verified to fail against the pre-fix code and pass against the post-fix code. Updated the composite-glyph design doc, reqstream, and verification docs for the newly-observable SCALED_COMPONENT_OFFSET behavior. --- .../design/canvas-net/fonts/true-type-font.md | 6 +- .../canvas-net/fonts/true-type-font.yaml | 1 + .../canvas-net/fonts/true-type-font.md | 8 +- .../Fonts/CmapTable.cs | 57 +++++++++- .../Fonts/GlyfLocaReader.cs | 17 +++ .../Fonts/KernTable.cs | 8 +- .../Fonts/CmapTableTests.cs | 106 ++++++++++++++++++ .../Fonts/GlyfLocaReaderTests.cs | 33 ++++++ .../Fonts/KernTableTests.cs | 45 ++++++++ .../TestSupport/SyntheticFontBuilder.cs | 18 ++- 10 files changed, 287 insertions(+), 12 deletions(-) diff --git a/docs/design/canvas-net/fonts/true-type-font.md b/docs/design/canvas-net/fonts/true-type-font.md index 4b0b93b2..e392c60a 100644 --- a/docs/design/canvas-net/fonts/true-type-font.md +++ b/docs/design/canvas-net/fonts/true-type-font.md @@ -125,7 +125,11 @@ points synthesize the implied on-curve midpoint required by the TrueType contour Supported component transforms are translation, uniform scale, independent X/Y scale, and full -2x2 matrix transforms using F2Dot14 fixed-point values. +2x2 matrix transforms using F2Dot14 fixed-point values. When a component's `flags` set +`SCALED_COMPONENT_OFFSET` (and not `UNSCALED_COMPONENT_OFFSET`), the component's dx/dy +translation is itself transformed through that component's scale/2x2 matrix before being applied, +rather than applied unscaled; `UNSCALED_COMPONENT_OFFSET` takes precedence if both flags are set, +and the default (neither flag set) is unscaled. ##### `cmap` Subtables Used diff --git a/docs/reqstream/canvas-net/fonts/true-type-font.yaml b/docs/reqstream/canvas-net/fonts/true-type-font.yaml index c70145c0..9a940cd9 100644 --- a/docs/reqstream/canvas-net/fonts/true-type-font.yaml +++ b/docs/reqstream/canvas-net/fonts/true-type-font.yaml @@ -232,6 +232,7 @@ sections: - GlyfLocaReader_CompositeGlyph_ScaledComponent_ScalesOutline - GlyfLocaReader_CompositeGlyph_TwoByTwoTransform_TransformsOutline - GlyfLocaReader_CompositeGlyph_MultipleComponents_ProducesMultipleSubpaths + - GlyfLocaReader_CompositeGlyph_ScaledComponentOffset_TransformsTranslation - id: CanvasNet-Fonts-TrueTypeFont-GlyphOutlineNestedComposite title: >- diff --git a/docs/verification/canvas-net/fonts/true-type-font.md b/docs/verification/canvas-net/fonts/true-type-font.md index 710677c2..678e1239 100644 --- a/docs/verification/canvas-net/fonts/true-type-font.md +++ b/docs/verification/canvas-net/fonts/true-type-font.md @@ -171,10 +171,12 @@ Asserts both a zero-contour simple glyph and a zero-length `loca` entry decode a **Tests**: `GlyfLocaReader_CompositeGlyph_SingleComponent_ProducesTranslatedOutline`, `GlyfLocaReader_CompositeGlyph_ScaledComponent_ScalesOutline`, `GlyfLocaReader_CompositeGlyph_TwoByTwoTransform_TransformsOutline`, -`GlyfLocaReader_CompositeGlyph_MultipleComponents_ProducesMultipleSubpaths` +`GlyfLocaReader_CompositeGlyph_MultipleComponents_ProducesMultipleSubpaths`, +`GlyfLocaReader_CompositeGlyph_ScaledComponentOffset_TransformsTranslation` -Verifies translation, uniform scaling, full 2x2 transforms, and multiple components within a -single composite glyph. +Verifies translation, uniform scaling, full 2x2 transforms, multiple components within a single +composite glyph, and that `SCALED_COMPONENT_OFFSET` transforms a component's dx/dy translation +through its own scale/2x2 matrix rather than applying it unscaled. ##### CanvasNet-Fonts-TrueTypeFont-GlyphOutlineNestedComposite: Nested Composites Resolve Recursively diff --git a/src/DemaConsulting.CanvasNet/Fonts/CmapTable.cs b/src/DemaConsulting.CanvasNet/Fonts/CmapTable.cs index f158533d..d04302f8 100644 --- a/src/DemaConsulting.CanvasNet/Fonts/CmapTable.cs +++ b/src/DemaConsulting.CanvasNet/Fonts/CmapTable.cs @@ -1,6 +1,7 @@ // cspell:ignore SFNT Sfnt sfnt glyf Glyf cmap Cmap loca Loca hmtx Hmtx hhea Hhea // cspell:ignore maxp Maxp notdef codepoint codepoints subtable subtables subsetted // cspell:ignore subsetting PPEM OTTO +// cspell:ignore misalign namespace DemaConsulting.CanvasNet.Fonts; /// @@ -87,11 +88,18 @@ public static CmapTable Parse(byte[] data, int tableOffset, int tableLength) candidates.Sort((a, b) => a.Priority.CompareTo(b.Priority)); + var enclosingTableEnd = tableOffset + tableLength; foreach (var candidate in candidates) { + var subtableEnd = ComputeSubtableEnd(data, candidate.SubtableOffset, candidate.Format, enclosingTableEnd); + if (subtableEnd == null) + { + continue; + } + var lookup = candidate.Format == 12 - ? TryParseFormat12(data, candidate.SubtableOffset, tableOffset + tableLength) - : TryParseFormat4(data, candidate.SubtableOffset, tableOffset + tableLength); + ? TryParseFormat12(data, candidate.SubtableOffset, subtableEnd.Value) + : TryParseFormat4(data, candidate.SubtableOffset, subtableEnd.Value); if (lookup != null) { @@ -102,6 +110,43 @@ public static CmapTable Parse(byte[] data, int tableOffset, int tableLength) return Empty; } + /// + /// Computes the effective end offset for a candidate subtable, bounded by both the + /// enclosing cmap table's end and the subtable's own declared length field + /// (offset +2, uint16, for format 4; offset +4, uint32, for format 12), so that a subtable + /// cannot read past its own declared length into a following subtable's bytes. + /// + /// + /// The effective end offset, or if the declared length field is not + /// itself readable within the enclosing table, or the declared length does not fit within + /// the enclosing table's bounds. + /// + private static int? ComputeSubtableEnd(byte[] data, int subtableOffset, int format, int enclosingTableEnd) + { + long declaredLength; + if (format == 12) + { + if ((long)subtableOffset + 8 > enclosingTableEnd) + { + return null; + } + + declaredLength = SfntContainer.ReadUInt32(data, subtableOffset + 4); + } + else + { + if ((long)subtableOffset + 4 > enclosingTableEnd) + { + return null; + } + + declaredLength = SfntContainer.ReadUInt16(data, subtableOffset + 2); + } + + var subtableEnd = checked((long)subtableOffset + declaredLength); + return subtableEnd > enclosingTableEnd ? null : (int)subtableEnd; + } + /// /// Looks up the glyph index mapped to a Unicode codepoint. /// @@ -158,6 +203,14 @@ private static int ClassifyPriority(int platformId, int encodingId, int format) } var segCountX2 = SfntContainer.ReadUInt16(data, offset + 6); + if (segCountX2 % 2 != 0) + { + // segCountX2 must be an even byte count (segCount * 2); an odd value is malformed and + // would otherwise silently misalign the parallel endCode/startCode/idDelta/idRangeOffset + // arrays via truncating integer division. + return null; + } + var segCount = segCountX2 / 2; if (segCount == 0) { diff --git a/src/DemaConsulting.CanvasNet/Fonts/GlyfLocaReader.cs b/src/DemaConsulting.CanvasNet/Fonts/GlyfLocaReader.cs index dc124bc4..48643f6b 100644 --- a/src/DemaConsulting.CanvasNet/Fonts/GlyfLocaReader.cs +++ b/src/DemaConsulting.CanvasNet/Fonts/GlyfLocaReader.cs @@ -1,6 +1,7 @@ // cspell:ignore SFNT Sfnt sfnt glyf Glyf cmap Cmap loca Loca hmtx Hmtx hhea Hhea // cspell:ignore maxp Maxp notdef codepoint codepoints subtable subtables subsetted // cspell:ignore subsetting PPEM OTTO +// cspell:ignore HarfBuzz using System.Numerics; using DemaConsulting.CanvasNet.Geometry; using Path = DemaConsulting.CanvasNet.Geometry.Path; @@ -51,6 +52,8 @@ internal sealed class GlyfLocaReader private const int MoreComponents = 0x0020; private const int WeHaveAnXAndYScale = 0x0040; private const int WeHaveATwoByTwo = 0x0080; + private const int ScaledComponentOffset = 0x0800; + private const int UnscaledComponentOffset = 0x1000; private readonly byte[] _data; private readonly int _glyfOffset; @@ -458,6 +461,20 @@ private Path DecodeCompositeGlyph(int glyphOffset, int glyphLength, int depth, r } var componentPath = DecodeGlyph(componentGlyphIndex, depth, ref totalComponents); + + // Per the OpenType/TrueType 'glyf' spec, SCALED_COMPONENT_OFFSET requests that the + // component's translation be transformed through its own scale/2x2 matrix before + // being applied, while UNSCALED_COMPONENT_OFFSET forces the untransformed offset; if + // both flags are set, UNSCALED_COMPONENT_OFFSET takes precedence (matching FreeType's + // and HarfBuzz's behavior). The default, when neither flag is set, is unscaled. + if ((flags & UnscaledComponentOffset) == 0 && (flags & ScaledComponentOffset) != 0) + { + var scaledDx = a * dx + c * dy; + var scaledDy = b * dx + d * dy; + dx = scaledDx; + dy = scaledDy; + } + AppendTransformed(builder, componentPath, a, b, c, d, dx, dy); hasContent = true; diff --git a/src/DemaConsulting.CanvasNet/Fonts/KernTable.cs b/src/DemaConsulting.CanvasNet/Fonts/KernTable.cs index d239f967..ce48da1b 100644 --- a/src/DemaConsulting.CanvasNet/Fonts/KernTable.cs +++ b/src/DemaConsulting.CanvasNet/Fonts/KernTable.cs @@ -66,19 +66,21 @@ public static KernTable Parse(byte[] data, int tableOffset, int tableLength) for (var i = 0; i < numSubtables; i++) { - if (pos + 6 > tableEnd) + if ((long)pos + 6 > tableEnd) { return Empty; } var subtableLength = SfntContainer.ReadUInt16(data, pos + 2); var coverage = SfntContainer.ReadUInt16(data, pos + 4); - var subtableEnd = pos + subtableLength; - if (subtableLength < 6 || subtableEnd > tableEnd) + var subtableEndLong = (long)pos + subtableLength; + if (subtableLength < 6 || subtableEndLong > tableEnd) { return Empty; } + var subtableEnd = (int)subtableEndLong; + var format = (coverage >> 8) & 0xFF; var horizontal = (coverage & 0x1) != 0; var minimum = (coverage & 0x2) != 0; diff --git a/test/DemaConsulting.CanvasNet.Tests/Fonts/CmapTableTests.cs b/test/DemaConsulting.CanvasNet.Tests/Fonts/CmapTableTests.cs index 4f2bdff3..6ba09865 100644 --- a/test/DemaConsulting.CanvasNet.Tests/Fonts/CmapTableTests.cs +++ b/test/DemaConsulting.CanvasNet.Tests/Fonts/CmapTableTests.cs @@ -1,6 +1,7 @@ // cspell:ignore SFNT Sfnt sfnt glyf Glyf cmap Cmap loca Loca hmtx Hmtx hhea Hhea // cspell:ignore maxp Maxp notdef codepoint codepoints subtable subtables subsetted // cspell:ignore subsetting PPEM OTTO +// cspell:ignore misaligns using DemaConsulting.CanvasNet.Fonts; using DemaConsulting.CanvasNet.Tests.TestSupport; @@ -191,6 +192,111 @@ public void CmapTable_SubtableOffsetNearIntMaxValue_GetGlyphIndexReturnsZero() Assert.Equal(0, cmap.GetGlyphIndex(65)); } + /// + /// Proves that CmapTable Format4 DeclaredLengthShorterThanEnclosingTable IsRejected. + /// + [Fact] + public void CmapTable_Format4_DeclaredLengthShorterThanEnclosingTable_IsRejected() + { + // Arrange: build a cmap with a single (3,1) format-4 subtable whose own declared `length` + // field (20) is deliberately shorter than the bytes actually needed to complete parsing + // (32 bytes: header + endCodes/startCodes/idDelta/idRangeOffset arrays for segCount=2). + // The extra bytes beyond the declared length are physically present in the buffer (as + // they would be for a genuine following subtable packed immediately afterward) and are + // populated with a distinguishing, deliberately "foreign" mapping - codepoint 65 maps to + // 999 - so that a decoder which is only bounded by the *enclosing* cmap table (and + // ignores this subtable's own shorter declared length) can be proven to have read past + // the subtable's own bounds. + var buf = new List + { + 0, 0, // cmap version + 0, 1, // numTables + 0, 3, // platformId + 0, 1, // encodingId + }; + SyntheticFontBuilder.WriteUInt32(buf, 12); // subtable offset: immediately after this 12-byte header + + SyntheticFontBuilder.WriteUInt16(buf, 4); // format + SyntheticFontBuilder.WriteUInt16(buf, 20); // declared length: covers only the header + endCodes + reservedPad + SyntheticFontBuilder.WriteUInt16(buf, 0); // language + SyntheticFontBuilder.WriteUInt16(buf, 4); // segCountX2 (segCount = 2: one real segment + terminator) + SyntheticFontBuilder.WriteUInt16(buf, 0); // searchRange + SyntheticFontBuilder.WriteUInt16(buf, 0); // entrySelector + SyntheticFontBuilder.WriteUInt16(buf, 0); // rangeShift + SyntheticFontBuilder.WriteUInt16(buf, 65); // endCodes[0] + SyntheticFontBuilder.WriteUInt16(buf, 0xFFFF); // endCodes[1] (terminator) + SyntheticFontBuilder.WriteUInt16(buf, 0); // reservedPad - declared subtable length (20) ends here + + // Bytes beyond the declared length (20): a "foreign" startCodes/idDelta/idRangeOffset + // region that a correct decoder must not treat as belonging to this subtable. + SyntheticFontBuilder.WriteUInt16(buf, 65); // "foreign" startCodes[0] + SyntheticFontBuilder.WriteUInt16(buf, 0xFFFF); // "foreign" startCodes[1] + SyntheticFontBuilder.WriteUInt16(buf, 934); // "foreign" idDeltas[0]: (65 + 934) & 0xFFFF == 999 + SyntheticFontBuilder.WriteUInt16(buf, 1); // "foreign" idDeltas[1] + SyntheticFontBuilder.WriteUInt16(buf, 0); // "foreign" idRangeOffsets[0] + SyntheticFontBuilder.WriteUInt16(buf, 0); // "foreign" idRangeOffsets[1] + + // Act: parse the cmap table + var data = buf.ToArray(); + var cmap = CmapTable.Parse(data, 0, data.Length); + + // Assert: the subtable's own declared length is enforced - it cannot borrow the + // "foreign" bytes beyond it - so parsing is rejected and the lookup resolves to zero + // rather than the "foreign" bogus glyph index (999) those bytes would otherwise produce + Assert.Equal(0, cmap.GetGlyphIndex(65)); + } + + /// + /// Proves that CmapTable Format4 OddSegCountX2 IsRejected. + /// + [Fact] + public void CmapTable_Format4_OddSegCountX2_IsRejected() + { + // Arrange: build a cmap with a single (3,1) format-4 subtable declaring an odd + // segCountX2 (5), which the format-4 spec requires to always be even (segCount * 2). A + // decoder that only truncates `segCount = segCountX2 / 2` (yielding 2) while still using + // the untruncated odd segCountX2 to compute the startCode/idDelta/idRangeOffset array + // base offsets misaligns those parallel arrays by one byte, and can still produce a + // "successful" (but bogus) lookup rather than failing safely. + var buf = new List + { + 0, 0, // cmap version + 0, 1, // numTables + 0, 3, // platformId + 0, 1, // encodingId + }; + SyntheticFontBuilder.WriteUInt32(buf, 12); // subtable offset + + SyntheticFontBuilder.WriteUInt16(buf, 4); // format + SyntheticFontBuilder.WriteUInt16(buf, 36); // declared length (covers the full crafted subtable below) + SyntheticFontBuilder.WriteUInt16(buf, 0); // language + SyntheticFontBuilder.WriteUInt16(buf, 5); // segCountX2: odd - malformed + SyntheticFontBuilder.WriteUInt16(buf, 0); // searchRange + SyntheticFontBuilder.WriteUInt16(buf, 0); // entrySelector + SyntheticFontBuilder.WriteUInt16(buf, 0); // rangeShift + SyntheticFontBuilder.WriteUInt16(buf, 65); // endCodes[0] + SyntheticFontBuilder.WriteUInt16(buf, 0); // endCodes[1] (unused by this test's lookup path) + buf.AddRange(new byte[3]); // unread gap before the (misaligned) startCodeOffset + SyntheticFontBuilder.WriteUInt16(buf, 0); // startCodes[0] + SyntheticFontBuilder.WriteUInt16(buf, 0); // startCodes[1] (unused) + buf.Add(0); // unread gap before the (misaligned) idDeltaOffset + SyntheticFontBuilder.WriteUInt16(buf, 500); // idDeltas[0]: (65 + 500) & 0xFFFF == 565 + SyntheticFontBuilder.WriteUInt16(buf, 0); // idDeltas[1] (unused) + buf.Add(0); // unread gap before the (misaligned) idRangeOffsetOffset + SyntheticFontBuilder.WriteUInt16(buf, 0); // idRangeOffsets[0] + SyntheticFontBuilder.WriteUInt16(buf, 0); // idRangeOffsets[1] (unused) + buf.Add(0); // pad out to the declared 36-byte subtable length + + // Act: parse the cmap table with the malformed odd segCountX2 + var data = buf.ToArray(); + var cmap = CmapTable.Parse(data, 0, data.Length); + + // Assert: the odd segCountX2 is rejected outright rather than tolerated via truncating + // division, so the lookup resolves to zero rather than the misaligned bogus glyph index + // (565) that reading through the shifted arrays would otherwise produce + Assert.Equal(0, cmap.GetGlyphIndex(65)); + } + /// /// Proves that CmapTable UnrecognizedSubtableFormat GetGlyphIndexReturnsZero. /// diff --git a/test/DemaConsulting.CanvasNet.Tests/Fonts/GlyfLocaReaderTests.cs b/test/DemaConsulting.CanvasNet.Tests/Fonts/GlyfLocaReaderTests.cs index 7e6eec8c..263f7329 100644 --- a/test/DemaConsulting.CanvasNet.Tests/Fonts/GlyfLocaReaderTests.cs +++ b/test/DemaConsulting.CanvasNet.Tests/Fonts/GlyfLocaReaderTests.cs @@ -309,6 +309,39 @@ public void GlyfLocaReader_CompositeGlyph_TwoByTwoTransform_TransformsOutline() Assert.Equal(10f, lineTo.EndPoint.Y, 2); } + /// + /// Proves that GlyfLocaReader CompositeGlyph ScaledComponentOffset TransformsTranslation. + /// + [Fact] + public void GlyfLocaReader_CompositeGlyph_ScaledComponentOffset_TransformsTranslation() + { + // Arrange: build a square glyph and a composite applying a 90-degree counter-clockwise + // rotation matrix (a=0, b=1, c=-1, d=0) together with SCALED_COMPONENT_OFFSET and a + // dx/dy translation of (10, 0). Per the OpenType/TrueType spec, SCALED_COMPONENT_OFFSET + // requires the translation itself to be transformed through the component's own matrix + // before being applied: (dx', dy') = (a*dx + c*dy, b*dx + d*dy) = (0*10 + -1*0, 1*10 + 0*0) + // = (0, 10) - not the untransformed (10, 0) that unscaled-offset semantics would apply. + var square = SyntheticFontBuilder.SimpleGlyph( + [ + [(0, 0, true), (10, 0, true), (10, 10, true), (0, 10, true)] + ]); + var composite = SyntheticFontBuilder.CompositeGlyph( + [ + new SyntheticFontBuilder.CompositeComponent( + 0, 10, 0, HasTwoByTwo: true, A: 0f, B: 1f, C: -1f, D: 0f, ScaledComponentOffset: true) + ]); + + // Act: build the reader and get the composite glyph outline + var reader = BuildReader([square, composite]); + var path = reader.GetGlyphOutline(1); + + // Assert: the outline's start point is the component's own matrix applied to the + // translation - (0, 10) - rather than the unscaled (10, 0) offset + var subpath = Assert.Single(path.Subpaths); + Assert.Equal(0f, subpath.Start.X, 2); + Assert.Equal(10f, subpath.Start.Y, 2); + } + /// /// Proves that GlyfLocaReader CompositeGlyph NestedComposite ResolvesRecursively. /// diff --git a/test/DemaConsulting.CanvasNet.Tests/Fonts/KernTableTests.cs b/test/DemaConsulting.CanvasNet.Tests/Fonts/KernTableTests.cs index 64a9bdee..76c502d3 100644 --- a/test/DemaConsulting.CanvasNet.Tests/Fonts/KernTableTests.cs +++ b/test/DemaConsulting.CanvasNet.Tests/Fonts/KernTableTests.cs @@ -1,6 +1,7 @@ // cspell:ignore SFNT Sfnt sfnt glyf Glyf cmap Cmap loca Loca hmtx Hmtx hhea Hhea // cspell:ignore maxp Maxp notdef codepoint codepoints subtable subtables subsetted // cspell:ignore subsetting PPEM OTTO +// cspell:ignore unwidened using DemaConsulting.CanvasNet.Fonts; using DemaConsulting.CanvasNet.Tests.TestSupport; @@ -149,6 +150,50 @@ public void KernTable_UnsupportedFormat2Subtable_IsTolerant_ReturnsZero() Assert.Equal(0, kern.GetKerning(3, 4)); } + /// + /// Proves that KernTable SubtableLengthOverflowsPosition IsTolerant ReturnsZero. + /// + [Fact] + public void KernTable_SubtableLengthOverflowsPosition_IsTolerant_ReturnsZero() + { + // Arrange: choose a huge table offset such that, computed with unwidened 32-bit `int` + // arithmetic, `pos + subtableLength` (pos = tableOffset + 4, subtableLength = 0xFFFF, the + // maximum ushort) wraps past int.MaxValue to a large negative value. That wrapped value + // would incorrectly pass the `subtableEnd > tableEnd` bounds guard, and - because the + // first subtable's coverage below is deliberately unsupported (format 2), so the loop + // advances `pos` to that corrupted, wrapped value - drive a subsequent read to a deeply + // out-of-bounds (negative) array index on the second declared subtable. + const int subtableLength = 0xFFFF; + const long overflowSum = (long)int.MaxValue + 1; // wraps to int.MinValue in 32-bit arithmetic + var tableOffset = (int)(overflowSum - 4 - subtableLength); + const int tableLength = 20; + + var data = new byte[tableOffset + tableLength]; + WriteUInt16BigEndian(data, tableOffset, 0); // kern table version + WriteUInt16BigEndian(data, tableOffset + 2, 2); // numSubtables: a second subtable is declared + WriteUInt16BigEndian(data, tableOffset + 4, 0); // subtable version (unused) + WriteUInt16BigEndian(data, tableOffset + 6, subtableLength); // subtable length: overflow-prone + WriteUInt16BigEndian(data, tableOffset + 8, 0x0201); // coverage: format 2 (unsupported), horizontal + + // Act: parse the kern table with the crafted overflow-prone subtable length + var kern = KernTable.Parse(data, tableOffset, tableLength); + + // Assert: the overflowing subtable length is rejected (rather than wrapping into a + // corrupted position that would read out of bounds) and lookups resolve to zero + Assert.Equal(0, kern.GetKerning(3, 4)); + } + + /// + /// Writes a big-endian, unsigned 16-bit integer directly into a raw byte array at the + /// given offset (bypasses 's List<byte>-based + /// helpers, which are impractical for the near-2GB sparse array this test requires). + /// + private static void WriteUInt16BigEndian(byte[] data, int offset, int value) + { + data[offset] = (byte)((value >> 8) & 0xFF); + data[offset + 1] = (byte)(value & 0xFF); + } + /// /// Proves that KernTable CrossStreamCoverage IsSkipped ReturnsZero. /// diff --git a/test/DemaConsulting.CanvasNet.Tests/TestSupport/SyntheticFontBuilder.cs b/test/DemaConsulting.CanvasNet.Tests/TestSupport/SyntheticFontBuilder.cs index 551c9f63..4b5b5e99 100644 --- a/test/DemaConsulting.CanvasNet.Tests/TestSupport/SyntheticFontBuilder.cs +++ b/test/DemaConsulting.CanvasNet.Tests/TestSupport/SyntheticFontBuilder.cs @@ -321,7 +321,9 @@ public readonly record struct CompositeComponent( float D = 1f, bool HasScale = false, bool HasXyScale = false, - bool HasTwoByTwo = false); + bool HasTwoByTwo = false, + bool ScaledComponentOffset = false, + bool UnscaledComponentOffset = false); /// /// Builds a composite glyph (numberOfContours == -1) from an ordered list of @@ -367,6 +369,16 @@ public static byte[] CompositeGlyph(IReadOnlyList components flags |= 0x0080; } + if (component.ScaledComponentOffset) + { + flags |= 0x0800; // SCALED_COMPONENT_OFFSET + } + + if (component.UnscaledComponentOffset) + { + flags |= 0x1000; // UNSCALED_COMPONENT_OFFSET + } + WriteUInt16(buf, flags); WriteUInt16(buf, component.GlyphIndex); WriteInt16(buf, component.Dx); @@ -407,7 +419,7 @@ public static byte[] CmapFormat4(int platformId, int encodingId, IReadOnlyList<( // mandatory trailing 0xFFFF terminator segment. var segCount = ordered.Count + 1; WriteUInt16(subtable, 4); // format - WriteUInt16(subtable, 0); // length placeholder (not validated by the decoder) + WriteUInt16(subtable, 16 + segCount * 8); // length: declared subtable length, matching the encoding below WriteUInt16(subtable, 0); // language WriteUInt16(subtable, segCount * 2); WriteUInt16(subtable, 0); // searchRange @@ -455,7 +467,7 @@ public static byte[] CmapFormat12(int platformId, int encodingId, IReadOnlyList< var subtable = new List(); WriteUInt16(subtable, 12); // format WriteUInt16(subtable, 0); // reserved - WriteUInt32(subtable, 0); // length placeholder + WriteUInt32(subtable, (uint)(16 + groups.Count * 12)); // length: declared subtable length, matching the encoding below WriteUInt32(subtable, 0); // language WriteUInt32(subtable, (uint)groups.Count); From efc3f771e6085dffee6e4e8f5889b0a840e77ff1 Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Tue, 22 Sep 2026 14:12:24 -0400 Subject: [PATCH 07/17] Require full 32-byte maxp table for version 1.0 fonts A version-1.0 'maxp' table only had its 6-byte version+numGlyphs prefix validated, so a font declaring version 1.0 but truncated to 6 bytes was incorrectly accepted instead of throwing InvalidDataException per this class's documented structural- validation contract. A version-1.0 maxp table is exactly 32 bytes per the OpenType/TrueType spec; require that length once the version field confirms 1.0. Also updates SyntheticFontBuilder's maxp fixture to emit a full, valid 32-byte version-1.0 table (placeholder values for the fields this library never reads) instead of the previously under-sized 6-byte form, and adds a regression test proving a truncated version-1.0 maxp table is rejected (confirmed to fail against the pre-fix code and pass post-fix). --- .../Fonts/TrueTypeFont.cs | 6 +++++ .../Fonts/TrueTypeFontTests.cs | 27 +++++++++++++++++++ .../TestSupport/SyntheticFontBuilder.cs | 23 +++++++++++++--- 3 files changed, 52 insertions(+), 4 deletions(-) diff --git a/src/DemaConsulting.CanvasNet/Fonts/TrueTypeFont.cs b/src/DemaConsulting.CanvasNet/Fonts/TrueTypeFont.cs index 44a16a49..39a3cc81 100644 --- a/src/DemaConsulting.CanvasNet/Fonts/TrueTypeFont.cs +++ b/src/DemaConsulting.CanvasNet/Fonts/TrueTypeFont.cs @@ -278,6 +278,12 @@ private static TrueTypeFont LoadFromBytes(byte[] data) "The 'maxp' table version is not 1.0 (0x00010000); CFF-oriented maxp (version 0.5) fonts are not supported."); } + const int maxpVersion10Size = 32; + if (maxp.Length < maxpVersion10Size) + { + throw new InvalidDataException("The 'maxp' table (version 1.0) is too short; expected 32 bytes."); + } + var numGlyphs = SfntContainer.ReadUInt16(data, maxp.Offset + 4); var metrics = HmtxHheaReader.Parse(data, hhea.Offset, hhea.Length, hmtx.Offset, hmtx.Length, numGlyphs); diff --git a/test/DemaConsulting.CanvasNet.Tests/Fonts/TrueTypeFontTests.cs b/test/DemaConsulting.CanvasNet.Tests/Fonts/TrueTypeFontTests.cs index d40c9e6d..41505863 100644 --- a/test/DemaConsulting.CanvasNet.Tests/Fonts/TrueTypeFontTests.cs +++ b/test/DemaConsulting.CanvasNet.Tests/Fonts/TrueTypeFontTests.cs @@ -188,6 +188,33 @@ public void TrueTypeFont_Load_MaxpVersion05_ThrowsInvalidDataException() Assert.Throws(() => TrueTypeFont.Load(new MemoryStream(data))); } + /// + /// Proves that TrueTypeFont Load TruncatedMaxpVersion10 ThrowsInvalidDataException. + /// + [Fact] + public void TrueTypeFont_Load_TruncatedMaxpVersion10_ThrowsInvalidDataException() + { + // Arrange: build a font whose maxp table declares version 1.0 but supplies only the + // 6-byte version+numGlyphs prefix, well short of the 32 bytes a version 1.0 maxp table + // is required to contain + var truncatedMaxp = new List(); + SyntheticFontBuilder.WriteUInt32(truncatedMaxp, 0x00010000); // version 1.0 + SyntheticFontBuilder.WriteUInt16(truncatedMaxp, 1); // numGlyphs + + var glyph = SyntheticFontBuilder.SimpleGlyph([[(0, 0, true), (10, 0, true), (10, 10, true)]]); + var data = new SyntheticFontBuilder() + .AddTable("head", SyntheticFontBuilder.Head(1000, 0)) + .AddTable("maxp", [.. truncatedMaxp]) + .AddTable("hhea", SyntheticFontBuilder.Hhea(800, -200, 50, 1)) + .AddTable("hmtx", SyntheticFontBuilder.Hmtx([500])) + .AddTable("loca", SyntheticFontBuilder.Loca([glyph.Length], longFormat: false)) + .AddTable("glyf", glyph) + .Build(); + + // Act/Assert: loading the font with the truncated version 1.0 maxp table throws + Assert.Throws(() => TrueTypeFont.Load(new MemoryStream(data))); + } + /// /// Proves that TrueTypeFont Load ZeroUnitsPerEm ThrowsInvalidDataException. /// diff --git a/test/DemaConsulting.CanvasNet.Tests/TestSupport/SyntheticFontBuilder.cs b/test/DemaConsulting.CanvasNet.Tests/TestSupport/SyntheticFontBuilder.cs index 4b5b5e99..1207059a 100644 --- a/test/DemaConsulting.CanvasNet.Tests/TestSupport/SyntheticFontBuilder.cs +++ b/test/DemaConsulting.CanvasNet.Tests/TestSupport/SyntheticFontBuilder.cs @@ -159,14 +159,29 @@ public static byte[] Head(int unitsPerEm, int indexToLocFormat) } /// - /// Builds a 6-byte maxp table (version 1.0 fixed-size prefix only - sufficient for - /// every field reads). + /// Builds a full 32-byte maxp table (the required length for version 1.0 + /// per the OpenType/TrueType spec). Every field beyond version/numGlyphs - + /// the only two fields reads - + /// is filled with a plausible placeholder value; this library never interprets them. /// public static byte[] Maxp(int numGlyphs, uint version = 0x00010000) { var buf = new List(); - WriteUInt32(buf, version); - WriteUInt16(buf, numGlyphs); + WriteUInt32(buf, version); // version + WriteUInt16(buf, numGlyphs); // numGlyphs + WriteUInt16(buf, 0); // maxPoints + WriteUInt16(buf, 0); // maxContours + WriteUInt16(buf, 0); // maxCompositePoints + WriteUInt16(buf, 0); // maxCompositeContours + WriteUInt16(buf, 1); // maxZones + WriteUInt16(buf, 0); // maxTwilightPoints + WriteUInt16(buf, 0); // maxStorage + WriteUInt16(buf, 0); // maxFunctionDefs + WriteUInt16(buf, 0); // maxInstructionDefs + WriteUInt16(buf, 0); // maxStackElements + WriteUInt16(buf, 0); // maxSizeOfInstructions + WriteUInt16(buf, 0); // maxComponentElements + WriteUInt16(buf, 0); // maxComponentDepth return [.. buf]; } From 906febc0fe16c3377150cfb363c059d689663711 Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Tue, 22 Sep 2026 14:17:06 -0400 Subject: [PATCH 08/17] Add Open Sans real-font rendering integration test (SIL OFL 1.1 licensed) Relocates the staged OpenSans-Regular.ttf and OpenSans-OFL.txt fixtures from the repo root into a new test/.../FontFixtures/ folder (mirroring the existing PngSuite/TiffFixtures/JpegFixtures content-item convention), renaming the license file to OpenSans.LICENSE to match PngSuite.LICENSE's naming, and adding a README.md documenting provenance and the SIL OFL 1.1 terms. Wires FontFixtures\** into the test project as CopyToOutputDirectory content, then adds a genuine integration test (TrueTypeFontRealFontIntegrationTests) that loads the real font via TrueTypeFont.Load, resolves a real glyph index for 'A', extracts its real outline, transforms it from font design units (y-up) into pixel space (y-down, scaled/margined to fit a 128x128 Surface), fills it via PathFiller, and asserts on the actual rendered pixels: ink is present within the glyph's transformed bounding box, and the canvas's far corners remain fully transparent. Also checks a real positive advance width and that a self-kerning lookup does not throw. No reqstream/design/verification artifacts are touched - this is a test-only addition proving existing documented behavior. --- .../DemaConsulting.CanvasNet.Tests.csproj | 8 + .../FontFixtures/OpenSans-Regular.ttf | Bin 0 -> 130832 bytes .../FontFixtures/OpenSans.LICENSE | 93 +++++++++++ .../FontFixtures/README.md | 13 ++ .../TrueTypeFontRealFontIntegrationTests.cs | 147 ++++++++++++++++++ 5 files changed, 261 insertions(+) create mode 100644 test/DemaConsulting.CanvasNet.Tests/FontFixtures/OpenSans-Regular.ttf create mode 100644 test/DemaConsulting.CanvasNet.Tests/FontFixtures/OpenSans.LICENSE create mode 100644 test/DemaConsulting.CanvasNet.Tests/FontFixtures/README.md create mode 100644 test/DemaConsulting.CanvasNet.Tests/Fonts/TrueTypeFontRealFontIntegrationTests.cs diff --git a/test/DemaConsulting.CanvasNet.Tests/DemaConsulting.CanvasNet.Tests.csproj b/test/DemaConsulting.CanvasNet.Tests/DemaConsulting.CanvasNet.Tests.csproj index 1c13d6dc..7c2f2f85 100644 --- a/test/DemaConsulting.CanvasNet.Tests/DemaConsulting.CanvasNet.Tests.csproj +++ b/test/DemaConsulting.CanvasNet.Tests/DemaConsulting.CanvasNet.Tests.csproj @@ -85,4 +85,12 @@ + + + + PreserveNewest + + + diff --git a/test/DemaConsulting.CanvasNet.Tests/FontFixtures/OpenSans-Regular.ttf b/test/DemaConsulting.CanvasNet.Tests/FontFixtures/OpenSans-Regular.ttf new file mode 100644 index 0000000000000000000000000000000000000000..134d225f663c8fb7710d09df9cfbe5664c5bd4c3 GIT binary patch literal 130832 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�%(1$HKu5J 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 n&{-|ffSrWb2}>v|6Tw#Cb;6e +/// Real-world integration test that loads the actual "Open Sans" TrueType font (see +/// FontFixtures\README.md for provenance and SIL OFL 1.1 licensing) and renders one of +/// its glyphs end-to-end through and the Drawing pipeline +/// (). This proves the two subsystems genuinely compose against +/// production font data, complementing the hand-rolled synthetic fixtures every other +/// Fonts test builds via . +/// +public class TrueTypeFontRealFontIntegrationTests +{ + /// + /// The path to the real "Open Sans" TrueType font, copied to the test output directory by + /// the test project's FontFixtures\** content item. + /// + private static string FontPath => System.IO.Path.Combine(AppContext.BaseDirectory, "FontFixtures", "OpenSans-Regular.ttf"); + + /// + /// Proves that a real glyph loaded from a real production font, when transformed into + /// pixel space and filled onto a via , + /// produces actual visible ink within its expected bounding-box region while leaving the + /// canvas's far corners fully transparent - not merely "no exception was thrown". + /// + [Fact] + public void TrueTypeFont_RealOpenSansFont_RendersGlyphOutlineAsVisibleInk() + { + // Arrange: load the real font from disk and resolve capital 'A' to a real glyph index + var font = TrueTypeFont.Load(FontPath); + var glyphIndex = font.GetGlyphIndex('A'); + Assert.NotEqual(0, glyphIndex); // must be a real mapped glyph, not .notdef + + // Act: extract the glyph's real outline (font design units, y-axis up), query its + // advance width, and confirm a self-kerning lookup does not throw + var outline = font.GetGlyphOutline(glyphIndex); + var advance = font.GetAdvanceWidth(glyphIndex); + var kerning = font.GetKerning(glyphIndex, glyphIndex); + + // Assert: the outline is real contour data, and metrics are sane for a real font + Assert.NotEmpty(outline.Subpaths); + Assert.True(advance > 0, "A real 'A' glyph must have a positive advance width."); + _ = kerning; // GetKerning never throws; this call itself is the assertion + + // Arrange: transform the glyph outline from font design units (y-up) into pixel space + // (y-down), scaled to fit within a small square canvas with a comfortable margin + const int canvasSize = 128; + const float margin = 16f; + + var rawBounds = outline.GetBounds(0.25f); + Assert.False(rawBounds.IsEmpty); + + var maxExtent = Math.Max(rawBounds.Width, rawBounds.Height); + var scale = (canvasSize - 2 * margin) / maxExtent; + var transformed = TransformGlyphToPixelSpace(outline, rawBounds, scale, margin); + + // Act: fill the transformed glyph outline onto a fresh (fully transparent) surface + var surface = new Surface(canvasSize, canvasSize); + PathFiller.Fill(surface, transformed, new Rgba32(0, 0, 0, 255)); + + // Assert: at least one pixel within the transformed glyph's own bounding box was + // actually painted (non-transparent) - proving real ink was drawn, not just that no + // exception occurred + var pixelBounds = transformed.GetBounds(0.25f); + Assert.False(pixelBounds.IsEmpty); + + var minX = Math.Max(0, (int)Math.Floor(pixelBounds.Left)); + var maxX = Math.Min(canvasSize - 1, (int)Math.Ceiling(pixelBounds.Right)); + var minY = Math.Max(0, (int)Math.Floor(pixelBounds.Top)); + var maxY = Math.Min(canvasSize - 1, (int)Math.Ceiling(pixelBounds.Bottom)); + + var foundInk = false; + for (var y = minY; y <= maxY && !foundInk; y++) + { + for (var x = minX; x <= maxX; x++) + { + if (surface[x, y].A > 0) + { + foundInk = true; + break; + } + } + } + + Assert.True(foundInk, "Expected at least one filled (non-transparent) pixel within the glyph's transformed bounding box."); + + // Assert: the far corners of the canvas, well outside the margin-inset glyph bounding + // box, remain fully transparent background + Assert.Equal(0, surface[0, 0].A); + Assert.Equal(0, surface[canvasSize - 1, 0].A); + Assert.Equal(0, surface[0, canvasSize - 1].A); + Assert.Equal(0, surface[canvasSize - 1, canvasSize - 1].A); + } + + /// + /// Re-issues every subpath of into a new , + /// mapping each point from font design-unit space (y-axis up, origin at 's + /// top-left in that space) into pixel space (y-axis down), scaled by + /// and inset by pixels on every side. + /// + private static GlyphPath TransformGlyphToPixelSpace(GlyphPath outline, Rect bounds, float scale, float margin) + { + Vector2 Map(Vector2 p) => new( + (p.X - bounds.Left) * scale + margin, + (bounds.Bottom - p.Y) * scale + margin); + + var builder = new PathBuilder(); + foreach (var subpath in outline.Subpaths) + { + builder.MoveTo(Map(subpath.Start)); + foreach (var command in subpath.Commands) + { + switch (command.Type) + { + case PathCommandType.LineTo: + builder.LineTo(Map(command.EndPoint)); + break; + + case PathCommandType.QuadraticBezierTo: + builder.QuadraticBezierTo(Map(command.Control1), Map(command.EndPoint)); + break; + + case PathCommandType.Close: + builder.Close(); + break; + + default: + // TrueTypeFont glyph outlines only ever contain LineTo, QuadraticBezierTo, + // and Close commands (see GlyfLocaReader) - matching that reader's own + // AppendTransformed helper. + throw new InvalidOperationException("Unexpected path command in a glyph outline."); + } + } + } + + return builder.Build(); + } +} From 162b878b34e3ff195b1188d5ed3d245b902170a7 Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Tue, 22 Sep 2026 14:49:57 -0400 Subject: [PATCH 09/17] Fix cmap/kern parser validation gaps and integer overflows (PR review round 2) - CmapTable format-4: reject subtables whose segments are not strictly increasing/non-overlapping by endCode, whose startCode exceeds endCode, or whose final segment is not the mandatory 0xFFFF terminator. - CmapTable format-4: widen the indirect idRangeOffset glyph-index address computation to long arithmetic and reject addresses that point backward (before glyphIdArray) or past the subtable end, instead of only bounds- checking the upper side with unwidened int arithmetic. - CmapTable format-12: reject subtables whose groups are not strictly increasing/non-overlapping by (startCharCode, endCharCode). - KernTable format-0: widen the body-offset bounds check to long arithmetic to match the other overflow-safe checks already in this file. - Add regression tests for all four fixes (CmapTableTests, KernTableTests), each verified to fail against the pre-fix code and pass post-fix. --- .../Fonts/CmapTable.cs | 46 ++- .../Fonts/KernTable.cs | 2 +- .../Fonts/CmapTableTests.cs | 312 +++++++++++++++++- .../Fonts/KernTableTests.cs | 40 +++ 4 files changed, 395 insertions(+), 5 deletions(-) diff --git a/src/DemaConsulting.CanvasNet/Fonts/CmapTable.cs b/src/DemaConsulting.CanvasNet/Fonts/CmapTable.cs index d04302f8..0b01f6b8 100644 --- a/src/DemaConsulting.CanvasNet/Fonts/CmapTable.cs +++ b/src/DemaConsulting.CanvasNet/Fonts/CmapTable.cs @@ -240,6 +240,29 @@ private static int ClassifyPriority(int platformId, int encodingId, int format) idRangeOffsets[i] = SfntContainer.ReadUInt16(data, idRangeOffsetOffset + i * 2); } + // Validate the required format-4 segment ordering: each segment's startCode must not + // exceed its endCode, segments must be strictly increasing (and therefore non-overlapping) + // by endCode, and the final segment must be the mandatory 0xFFFF terminator. A malformed + // subtable violating any of this is rejected so the linear-scan lookup below cannot return + // a nonzero glyph index derived from garbage data. + for (var i = 0; i < segCount; i++) + { + if (startCodes[i] > endCodes[i]) + { + return null; + } + + if (i > 0 && endCodes[i - 1] >= endCodes[i]) + { + return null; + } + } + + if (endCodes[segCount - 1] != 0xFFFF) + { + return null; + } + return codepoint => { if (codepoint is < 0 or > 0xFFFF) @@ -264,13 +287,13 @@ private static int ClassifyPriority(int platformId, int encodingId, int format) return (codepoint + idDeltas[i]) & 0xFFFF; } - var glyphIndexAddress = idRangeOffsetOffset + i * 2 + idRangeOffsets[i] + 2 * (codepoint - startCodes[i]); - if (glyphIndexAddress + 2 > tableEnd) + var glyphIndexAddress = (long)idRangeOffsetOffset + i * 2 + idRangeOffsets[i] + 2L * (codepoint - startCodes[i]); + if (glyphIndexAddress < glyphIdArrayOffset || glyphIndexAddress + 2 > tableEnd) { return 0; } - var glyphId = SfntContainer.ReadUInt16(data, glyphIndexAddress); + var glyphId = SfntContainer.ReadUInt16(data, (int)glyphIndexAddress); return glyphId == 0 ? 0 : (glyphId + idDeltas[i]) & 0xFFFF; } @@ -307,6 +330,23 @@ private static int ClassifyPriority(int platformId, int encodingId, int format) startGlyphIds[i] = SfntContainer.ReadUInt32(data, groupOffset + 8); } + // Validate the required format-12 group ordering: each group's startCharCode must not + // exceed its endCharCode, and groups must be strictly increasing (and therefore + // non-overlapping) by charCode range, as required by the spec and assumed by the binary + // search lookup below. + for (var i = 0; i < starts.Length; i++) + { + if (starts[i] > ends[i]) + { + return null; + } + + if (i > 0 && ends[i - 1] >= starts[i]) + { + return null; + } + } + return codepoint => { if (codepoint < 0) diff --git a/src/DemaConsulting.CanvasNet/Fonts/KernTable.cs b/src/DemaConsulting.CanvasNet/Fonts/KernTable.cs index ce48da1b..ed52082a 100644 --- a/src/DemaConsulting.CanvasNet/Fonts/KernTable.cs +++ b/src/DemaConsulting.CanvasNet/Fonts/KernTable.cs @@ -154,7 +154,7 @@ public int GetKerning(int leftGlyphIndex, int rightGlyphIndex) /// private static (ushort, ushort, short)[]? TryParseFormat0(byte[] data, int bodyOffset, int subtableEnd) { - if (bodyOffset + 8 > subtableEnd) + if ((long)bodyOffset + 8 > subtableEnd) { return null; } diff --git a/test/DemaConsulting.CanvasNet.Tests/Fonts/CmapTableTests.cs b/test/DemaConsulting.CanvasNet.Tests/Fonts/CmapTableTests.cs index 6ba09865..5406e755 100644 --- a/test/DemaConsulting.CanvasNet.Tests/Fonts/CmapTableTests.cs +++ b/test/DemaConsulting.CanvasNet.Tests/Fonts/CmapTableTests.cs @@ -1,7 +1,7 @@ // cspell:ignore SFNT Sfnt sfnt glyf Glyf cmap Cmap loca Loca hmtx Hmtx hhea Hhea // cspell:ignore maxp Maxp notdef codepoint codepoints subtable subtables subsetted // cspell:ignore subsetting PPEM OTTO -// cspell:ignore misaligns +// cspell:ignore misaligns unwidened using DemaConsulting.CanvasNet.Fonts; using DemaConsulting.CanvasNet.Tests.TestSupport; @@ -297,6 +297,316 @@ public void CmapTable_Format4_OddSegCountX2_IsRejected() Assert.Equal(0, cmap.GetGlyphIndex(65)); } + /// + /// Proves that CmapTable Format4 SegmentStartCodeExceedsEndCode IsRejected. + /// + [Fact] + public void CmapTable_Format4_SegmentStartCodeExceedsEndCode_IsRejected() + { + // Arrange: build a cmap with two encoding records: a higher-priority (3,1) format-4 + // subtable whose only real segment has startCode (50) greater than endCode (10) - which + // the format-4 spec forbids - and a lower-priority, otherwise-unreachable (0,3) format-4 + // subtable that validly maps codepoint 200 to glyph 7. Because subtable selection is + // priority-ordered and stops at the first successfully-parsed candidate, a decoder that + // fails to reject the malformed higher-priority subtable will select it (even though it + // never actually maps codepoint 200), silently starving the valid fallback subtable of a + // chance to be tried - rather than rejecting the malformed one and falling through to the + // valid mapping, as required by the documented malformed-input contract. + var buf = new List + { + 0, 0, // cmap version + 0, 2, // numTables + 0, 3, // platformId (record 0: higher priority) + 0, 1, // encodingId + }; + SyntheticFontBuilder.WriteUInt32(buf, 20); // record 0 subtable offset + SyntheticFontBuilder.WriteUInt16(buf, 0); // platformId (record 1: lower priority) + SyntheticFontBuilder.WriteUInt16(buf, 3); // encodingId + SyntheticFontBuilder.WriteUInt32(buf, 52); // record 1 subtable offset (20 + 32, after subtable 0) + + // Subtable 0 (offset 20): malformed - segment 0 has startCode (50) > endCode (10) + SyntheticFontBuilder.WriteUInt16(buf, 4); // format + SyntheticFontBuilder.WriteUInt16(buf, 32); // declared length (segCount = 2) + SyntheticFontBuilder.WriteUInt16(buf, 0); // language + SyntheticFontBuilder.WriteUInt16(buf, 4); // segCountX2 (segCount = 2) + SyntheticFontBuilder.WriteUInt16(buf, 0); // searchRange + SyntheticFontBuilder.WriteUInt16(buf, 0); // entrySelector + SyntheticFontBuilder.WriteUInt16(buf, 0); // rangeShift + SyntheticFontBuilder.WriteUInt16(buf, 10); // endCodes[0] + SyntheticFontBuilder.WriteUInt16(buf, 0xFFFF); // endCodes[1] (terminator) + SyntheticFontBuilder.WriteUInt16(buf, 0); // reservedPad + SyntheticFontBuilder.WriteUInt16(buf, 50); // startCodes[0]: exceeds endCodes[0] (10) - malformed + SyntheticFontBuilder.WriteUInt16(buf, 0xFFFF); // startCodes[1] + SyntheticFontBuilder.WriteUInt16(buf, 1); // idDeltas[0] + SyntheticFontBuilder.WriteUInt16(buf, 1); // idDeltas[1] + SyntheticFontBuilder.WriteUInt16(buf, 0); // idRangeOffsets[0] + SyntheticFontBuilder.WriteUInt16(buf, 0); // idRangeOffsets[1] + + // Subtable 1 (offset 52): valid - maps codepoint 200 to glyph 7 + SyntheticFontBuilder.WriteUInt16(buf, 4); // format + SyntheticFontBuilder.WriteUInt16(buf, 24); // declared length (segCount = 1) + SyntheticFontBuilder.WriteUInt16(buf, 0); // language + SyntheticFontBuilder.WriteUInt16(buf, 2); // segCountX2 (segCount = 1) + SyntheticFontBuilder.WriteUInt16(buf, 0); // searchRange + SyntheticFontBuilder.WriteUInt16(buf, 0); // entrySelector + SyntheticFontBuilder.WriteUInt16(buf, 0); // rangeShift + SyntheticFontBuilder.WriteUInt16(buf, 0xFFFF); // endCodes[0] (terminator: the whole BMP, one segment) + SyntheticFontBuilder.WriteUInt16(buf, 0); // reservedPad + SyntheticFontBuilder.WriteUInt16(buf, 200); // startCodes[0] + SyntheticFontBuilder.WriteUInt16(buf, 7 - 200); // idDeltas[0]: (200 + (7-200)) & 0xFFFF == 7 + SyntheticFontBuilder.WriteUInt16(buf, 0); // idRangeOffsets[0] + + // Act: parse the cmap table + var data = buf.ToArray(); + var cmap = CmapTable.Parse(data, 0, data.Length); + + // Assert: the malformed, higher-priority subtable is rejected, so the valid, + // lower-priority fallback subtable is used and correctly maps codepoint 200 to glyph 7 + Assert.Equal(7, cmap.GetGlyphIndex(200)); + } + + /// + /// Proves that CmapTable Format4 SegmentsNotStrictlyIncreasing IsRejected. + /// + [Fact] + public void CmapTable_Format4_SegmentsNotStrictlyIncreasing_IsRejected() + { + // Arrange: build a (3,1) format-4 subtable with three segments whose endCodes are not + // strictly increasing (100, then 50 - out of order), followed by the mandatory 0xFFFF + // terminator segment. + var buf = new List + { + 0, 0, // cmap version + 0, 1, // numTables + 0, 3, // platformId + 0, 1, // encodingId + }; + SyntheticFontBuilder.WriteUInt32(buf, 12); // subtable offset + + SyntheticFontBuilder.WriteUInt16(buf, 4); // format + SyntheticFontBuilder.WriteUInt16(buf, 40); // declared length (segCount = 3) + SyntheticFontBuilder.WriteUInt16(buf, 0); // language + SyntheticFontBuilder.WriteUInt16(buf, 6); // segCountX2 (segCount = 3) + SyntheticFontBuilder.WriteUInt16(buf, 0); // searchRange + SyntheticFontBuilder.WriteUInt16(buf, 0); // entrySelector + SyntheticFontBuilder.WriteUInt16(buf, 0); // rangeShift + SyntheticFontBuilder.WriteUInt16(buf, 100); // endCodes[0] + SyntheticFontBuilder.WriteUInt16(buf, 50); // endCodes[1]: not greater than endCodes[0] - malformed + SyntheticFontBuilder.WriteUInt16(buf, 0xFFFF); // endCodes[2] (terminator) + SyntheticFontBuilder.WriteUInt16(buf, 0); // reservedPad + SyntheticFontBuilder.WriteUInt16(buf, 0); // startCodes[0] + SyntheticFontBuilder.WriteUInt16(buf, 0); // startCodes[1] + SyntheticFontBuilder.WriteUInt16(buf, 0xFFFF); // startCodes[2] + SyntheticFontBuilder.WriteUInt16(buf, 0); // idDeltas[0] + SyntheticFontBuilder.WriteUInt16(buf, 0); // idDeltas[1] + SyntheticFontBuilder.WriteUInt16(buf, 1); // idDeltas[2] + SyntheticFontBuilder.WriteUInt16(buf, 0); // idRangeOffsets[0] + SyntheticFontBuilder.WriteUInt16(buf, 0); // idRangeOffsets[1] + SyntheticFontBuilder.WriteUInt16(buf, 0); // idRangeOffsets[2] + + // Act: parse the cmap table with the out-of-order segments + var data = buf.ToArray(); + var cmap = CmapTable.Parse(data, 0, data.Length); + + // Assert: the out-of-order segments are rejected and lookups resolve to glyph index zero + Assert.Equal(0, cmap.GetGlyphIndex(60)); + } + + /// + /// Proves that CmapTable Format4 MissingTerminatorSegment IsRejected. + /// + [Fact] + public void CmapTable_Format4_MissingTerminatorSegment_IsRejected() + { + // Arrange: build a (3,1) format-4 subtable with a single, otherwise well-formed segment + // whose endCode (100) is not the mandatory 0xFFFF terminator value. + var buf = new List + { + 0, 0, // cmap version + 0, 1, // numTables + 0, 3, // platformId + 0, 1, // encodingId + }; + SyntheticFontBuilder.WriteUInt32(buf, 12); // subtable offset + + SyntheticFontBuilder.WriteUInt16(buf, 4); // format + SyntheticFontBuilder.WriteUInt16(buf, 24); // declared length (segCount = 1) + SyntheticFontBuilder.WriteUInt16(buf, 0); // language + SyntheticFontBuilder.WriteUInt16(buf, 2); // segCountX2 (segCount = 1) + SyntheticFontBuilder.WriteUInt16(buf, 0); // searchRange + SyntheticFontBuilder.WriteUInt16(buf, 0); // entrySelector + SyntheticFontBuilder.WriteUInt16(buf, 0); // rangeShift + SyntheticFontBuilder.WriteUInt16(buf, 100); // endCodes[0]: not the mandatory 0xFFFF terminator + SyntheticFontBuilder.WriteUInt16(buf, 0); // reservedPad + SyntheticFontBuilder.WriteUInt16(buf, 0); // startCodes[0] + SyntheticFontBuilder.WriteUInt16(buf, 5); // idDeltas[0] + SyntheticFontBuilder.WriteUInt16(buf, 0); // idRangeOffsets[0] + + // Act: parse the cmap table missing its mandatory terminator segment + var data = buf.ToArray(); + var cmap = CmapTable.Parse(data, 0, data.Length); + + // Assert: the missing terminator is rejected and lookups resolve to glyph index zero + Assert.Equal(0, cmap.GetGlyphIndex(10)); + } + + /// + /// Proves that CmapTable Format4 IdRangeOffsetPointsBackward ReturnsZero. + /// + [Fact] + public void CmapTable_Format4_IdRangeOffsetPointsBackward_ReturnsZero() + { + // Arrange: build a (3,1) format-4 subtable, segCount = 2 (one real segment + terminator), + // whose real segment's idRangeOffset is deliberately crafted to point backward - into the + // subtable's own idRangeOffset array bytes rather than forward into glyphIdArray. A + // correct decoder must reject this rather than reading and returning whatever + // (unrelated/attacker-controlled) 16-bit value happens to live at that backward address. + var buf = new List + { + 0, 0, // cmap version + 0, 1, // numTables + 0, 3, // platformId + 0, 1, // encodingId + }; + SyntheticFontBuilder.WriteUInt32(buf, 12); // subtable offset + + SyntheticFontBuilder.WriteUInt16(buf, 4); // format + SyntheticFontBuilder.WriteUInt16(buf, 32); // declared length (segCount = 2) + SyntheticFontBuilder.WriteUInt16(buf, 0); // language + SyntheticFontBuilder.WriteUInt16(buf, 4); // segCountX2 (segCount = 2) + SyntheticFontBuilder.WriteUInt16(buf, 0); // searchRange + SyntheticFontBuilder.WriteUInt16(buf, 0); // entrySelector + SyntheticFontBuilder.WriteUInt16(buf, 0); // rangeShift + SyntheticFontBuilder.WriteUInt16(buf, 10); // endCodes[0] + SyntheticFontBuilder.WriteUInt16(buf, 0xFFFF); // endCodes[1] (terminator) + SyntheticFontBuilder.WriteUInt16(buf, 0); // reservedPad + SyntheticFontBuilder.WriteUInt16(buf, 10); // startCodes[0] + SyntheticFontBuilder.WriteUInt16(buf, 0xFFFF); // startCodes[1] + SyntheticFontBuilder.WriteUInt16(buf, 0); // idDeltas[0] + SyntheticFontBuilder.WriteUInt16(buf, 1); // idDeltas[1] + + // idRangeOffsetOffset is at buf position 14 + 2*4 + 2 + 4 + 4 = 32 (relative to subtable + // start at absolute offset 12), i.e. absolute offset 44. idRangeOffsets[0] here is set to + // 2, which - per the spec formula - computes to + // idRangeOffsetOffset + 0*2 + 2 + 2*(10-10) = 46, pointing at idRangeOffsets[1] itself + // (still inside the idRangeOffset array, before glyphIdArrayOffset), not forward into a + // (nonexistent) glyphIdArray. + SyntheticFontBuilder.WriteUInt16(buf, 2); // idRangeOffsets[0]: points backward, not forward + SyntheticFontBuilder.WriteUInt16(buf, 0xBEEF); // idRangeOffsets[1] slot: "bogus" value at the backward address + + // Act: parse the cmap table with the backward-pointing idRangeOffset + var data = buf.ToArray(); + var cmap = CmapTable.Parse(data, 0, data.Length); + + // Assert: the backward-pointing idRangeOffset is rejected and the lookup resolves to + // glyph index zero rather than the bogus 0xBEEF-derived value + Assert.Equal(0, cmap.GetGlyphIndex(10)); + } + + /// + /// Proves that CmapTable Format4 GlyphIndexAddressArithmeticOverflow ReturnsZero. + /// + [Fact] + public void CmapTable_Format4_GlyphIndexAddressArithmeticOverflow_ReturnsZero() + { + // Arrange: choose a subtable position (near the top of what a real byte array can + // address, per Array.MaxLength) large enough that computing + // `idRangeOffsetOffset + i * 2 + idRangeOffsets[i] + 2 * (codepoint - startCodes[i])` + // with unwidened 32-bit `int` arithmetic wraps past `int.MaxValue` to a negative value, + // which would incorrectly pass the `glyphIndexAddress + 2 > tableEnd` bounds guard and + // then be used directly as a negative array index. The margin is provided by + // `idRangeOffsets[0]` (its 0xFFFF maximum), mirroring the technique used by + // KernTable_SubtableLengthOverflowsPosition_IsTolerant_ReturnsZero. Note that only + // the *positions* need to be this large; the declared cmap `tableLength` (and hence the + // portion of the array that is actually populated/read on the corrected, non-overflowing + // path) stays tiny, so the backing array itself only needs to be a little over + // `idRangeOffsetOffset` bytes - not anywhere near `int.MaxValue` bytes twice over. + const int segCount = 2; + const int segCountX2 = segCount * 2; + const int cmapHeaderSize = 12; + + // Field layout (relative to subtableOffset): format/length/language/segCountX2/ + // searchRange/entrySelector/rangeShift = 14 bytes, then endCodes(segCountX2) + + // reservedPad(2) + startCodes(segCountX2) + idDeltas(segCountX2) = idRangeOffsetOffset. + var idRangeOffsetOffset = Array.MaxLength - 30; + var subtableOffset = idRangeOffsetOffset - (14 + segCountX2 + 2 + segCountX2 + segCountX2); + var tableOffset = subtableOffset - cmapHeaderSize; + Assert.True(tableOffset >= 0, "test setup: tableOffset must be non-negative"); + + var declaredLength = subtableOffset - tableOffset + 14 + segCountX2 + 2 + segCountX2 + segCountX2 + segCountX2; + var data = new byte[tableOffset + declaredLength]; + + void WriteU16(int offset, int value) + { + data[offset] = (byte)((value >> 8) & 0xFF); + data[offset + 1] = (byte)(value & 0xFF); + } + + void WriteU32(int offset, long value) + { + data[offset] = (byte)((value >> 24) & 0xFF); + data[offset + 1] = (byte)((value >> 16) & 0xFF); + data[offset + 2] = (byte)((value >> 8) & 0xFF); + data[offset + 3] = (byte)(value & 0xFF); + } + + WriteU16(tableOffset, 0); // cmap version + WriteU16(tableOffset + 2, 1); // numTables + WriteU16(tableOffset + 4, 3); // platformId + WriteU16(tableOffset + 6, 1); // encodingId + WriteU32(tableOffset + 8, subtableOffset - tableOffset); // subtable offset, relative to cmap table start + + WriteU16(subtableOffset, 4); // format + WriteU16(subtableOffset + 2, declaredLength - (subtableOffset - tableOffset)); // declared length + WriteU16(subtableOffset + 4, 0); // language + WriteU16(subtableOffset + 6, segCountX2); + WriteU16(subtableOffset + 8, 0); // searchRange + WriteU16(subtableOffset + 10, 0); // entrySelector + WriteU16(subtableOffset + 12, 0); // rangeShift + + var endCodeOffset = subtableOffset + 14; + WriteU16(endCodeOffset, 0); // endCodes[0]: codepoint 0 maps via this segment + WriteU16(endCodeOffset + 2, 0xFFFF); // endCodes[1] (terminator) + + var startCodeOffset = endCodeOffset + segCountX2 + 2; + WriteU16(startCodeOffset, 0); // startCodes[0] + WriteU16(startCodeOffset + 2, 0xFFFF); // startCodes[1] + + var idDeltaOffset = startCodeOffset + segCountX2; + WriteU16(idDeltaOffset, 0); // idDeltas[0] + WriteU16(idDeltaOffset + 2, 1); // idDeltas[1] + + Assert.Equal(idRangeOffsetOffset, idDeltaOffset + segCountX2); + WriteU16(idRangeOffsetOffset, 0xFFFF); // idRangeOffsets[0]: maximal, to maximize the overflow margin + WriteU16(idRangeOffsetOffset + 2, 0); // idRangeOffsets[1] + + // Act: parse the cmap table and look up codepoint 0, which resolves to segment 0's + // indirect (idRangeOffset != 0) lookup path + var cmap = CmapTable.Parse(data, tableOffset, declaredLength); + + // Assert: the overflow-prone address computation is rejected (rather than wrapping to a + // negative value that would be misread as in-bounds) and the lookup resolves to zero + Assert.Equal(0, cmap.GetGlyphIndex(0)); + } + + /// + /// Proves that CmapTable Format12 GroupsOverlap IsRejected. + /// + [Fact] + public void CmapTable_Format12_GroupsOverlap_IsRejected() + { + // Arrange: build a format-12 cmap subtable with two groups whose charCode ranges overlap + // (group 0 covers 0x00-0x10, group 1 covers 0x08-0x20), which the spec forbids (groups + // must be sorted, non-overlapping, by startCharCode). + var table = SyntheticFontBuilder.CmapFormat12(3, 10, [(0x00u, 0x10u, 1u), (0x08u, 0x20u, 100u)]); + + // Act: parse the cmap table with the overlapping groups + var cmap = CmapTable.Parse(table, 0, table.Length); + + // Assert: the overlapping groups are rejected and lookups resolve to glyph index zero + Assert.Equal(0, cmap.GetGlyphIndex(0x09)); + } + /// /// Proves that CmapTable UnrecognizedSubtableFormat GetGlyphIndexReturnsZero. /// diff --git a/test/DemaConsulting.CanvasNet.Tests/Fonts/KernTableTests.cs b/test/DemaConsulting.CanvasNet.Tests/Fonts/KernTableTests.cs index 76c502d3..2a5c7b44 100644 --- a/test/DemaConsulting.CanvasNet.Tests/Fonts/KernTableTests.cs +++ b/test/DemaConsulting.CanvasNet.Tests/Fonts/KernTableTests.cs @@ -2,6 +2,7 @@ // cspell:ignore maxp Maxp notdef codepoint codepoints subtable subtables subsetted // cspell:ignore subsetting PPEM OTTO // cspell:ignore unwidened +using System.Reflection; using DemaConsulting.CanvasNet.Fonts; using DemaConsulting.CanvasNet.Tests.TestSupport; @@ -194,6 +195,45 @@ private static void WriteUInt16BigEndian(byte[] data, int offset, int value) data[offset + 1] = (byte)(value & 0xFF); } + /// + /// Proves that KernTable Format0 BodyOffsetArithmeticOverflow IsRejected. + /// + /// + /// A genuine `bodyOffset + 8` 32-bit overflow cannot be reached through the public + /// API with a real backing array: any `bodyOffset` value + /// large enough to overflow when adding a small constant like 8 would itself have to + /// exceed (a .NET byte array cannot be indexed that far), + /// and every earlier bounds check in is already + /// long-widened, so a corrupted/oversized `bodyOffset` can never actually arrive at the + /// format-0 body parser. This test therefore exercises the private + /// TryParseFormat0 helper directly via reflection, to prove its own arithmetic is + /// safe in isolation (defense-in-depth), independent of whether current callers can reach + /// the overflow-prone input. + /// + [Fact] + public void KernTable_Format0_BodyOffsetArithmeticOverflow_IsRejected() + { + // Arrange: a bodyOffset chosen so that `bodyOffset + 8`, computed with unwidened 32-bit + // `int` arithmetic, wraps past int.MaxValue to a negative value - which would incorrectly + // pass an unwidened `bodyOffset + 8 > subtableEnd` bounds guard - paired with a small, + // deliberately undersized backing array (so that, pre-fix, the wrongly-accepted bodyOffset + // drives an out-of-bounds read that throws, rather than being safely rejected). + const int bodyOffset = int.MaxValue - 3; + const int subtableEnd = 100; + var data = new byte[16]; + + var method = typeof(KernTable).GetMethod("TryParseFormat0", BindingFlags.NonPublic | BindingFlags.Static); + Assert.NotNull(method); + + // Act: invoke the format-0 body parser directly with the overflow-prone bodyOffset + var result = method.Invoke(null, [data, bodyOffset, subtableEnd]); + + // Assert: the overflow-prone bodyOffset is rejected (rather than wrapping to a small + // value that would be misread as in-bounds and drive an out-of-bounds read) so parsing + // returns null + Assert.Null(result); + } + /// /// Proves that KernTable CrossStreamCoverage IsSkipped ReturnsZero. /// From 15a89b50440f085901c89015c0546146b0915595 Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Tue, 22 Sep 2026 14:50:02 -0400 Subject: [PATCH 10/17] Reflect TrueTypeFontRealFontIntegrationTests in traceability artifacts - .reviewmark.yaml: add the missing test file to the Fonts unit review set. - true-type-font.sysml: add the missing test file to the unit's testRef. - fonts.md / fonts/true-type-font.md: correct the verification environment description - one integration test now loads a real, licensed (SIL OFL 1.1) OpenSans-Regular.ttf production font fixture to prove end-to-end composition with the Drawing pipeline on real-world glyph data; all other Fonts tests remain synthetic-fixture-based. --- .reviewmark.yaml | 1 + .../model/canvas-net/fonts/true-type-font.sysml | 2 +- docs/verification/canvas-net/fonts.md | 9 ++++++++- .../canvas-net/fonts/true-type-font.md | 14 ++++++++++---- 4 files changed, 20 insertions(+), 6 deletions(-) diff --git a/.reviewmark.yaml b/.reviewmark.yaml index b7f819b4..cd8a21d3 100644 --- a/.reviewmark.yaml +++ b/.reviewmark.yaml @@ -415,6 +415,7 @@ reviews: - "test/DemaConsulting.CanvasNet.Tests/Fonts/HmtxHheaReaderTests.cs" # unit tests - "test/DemaConsulting.CanvasNet.Tests/Fonts/KernTableTests.cs" # unit tests - "test/DemaConsulting.CanvasNet.Tests/Fonts/TrueTypeFontTests.cs" # unit tests + - "test/DemaConsulting.CanvasNet.Tests/Fonts/TrueTypeFontRealFontIntegrationTests.cs" # unit tests - "test/DemaConsulting.CanvasNet.Tests/TestSupport/SyntheticFontBuilder.cs" # shared test helper # OTS-BuildMark Review (one per OTS item) diff --git a/docs/sysml2/model/canvas-net/fonts/true-type-font.sysml b/docs/sysml2/model/canvas-net/fonts/true-type-font.sysml index e6b32a15..8282b0f5 100644 --- a/docs/sysml2/model/canvas-net/fonts/true-type-font.sysml +++ b/docs/sysml2/model/canvas-net/fonts/true-type-font.sysml @@ -3,7 +3,7 @@ package CanvasNet { doc /* Public TrueType font loader/query unit fronting SFNT container parsing, cmap lookup, glyf/loca outline decoding, hhea/hmtx metrics, and tolerant format-0 kern lookup. */ comment sourceRef /* Source: src/DemaConsulting.CanvasNet/Fonts/TrueTypeFont.cs, src/DemaConsulting.CanvasNet/Fonts/SfntContainer.cs, src/DemaConsulting.CanvasNet/Fonts/CmapTable.cs, src/DemaConsulting.CanvasNet/Fonts/GlyfLocaReader.cs, src/DemaConsulting.CanvasNet/Fonts/HmtxHheaReader.cs, src/DemaConsulting.CanvasNet/Fonts/KernTable.cs, src/DemaConsulting.CanvasNet/Fonts/NamespaceDoc.cs */ - comment testRef /* Test: test/DemaConsulting.CanvasNet.Tests/Fonts/SfntContainerTests.cs, test/DemaConsulting.CanvasNet.Tests/Fonts/CmapTableTests.cs, test/DemaConsulting.CanvasNet.Tests/Fonts/GlyfLocaReaderTests.cs, test/DemaConsulting.CanvasNet.Tests/Fonts/HmtxHheaReaderTests.cs, test/DemaConsulting.CanvasNet.Tests/Fonts/KernTableTests.cs, test/DemaConsulting.CanvasNet.Tests/Fonts/TrueTypeFontTests.cs, test/DemaConsulting.CanvasNet.Tests/TestSupport/SyntheticFontBuilder.cs */ + comment testRef /* Test: test/DemaConsulting.CanvasNet.Tests/Fonts/SfntContainerTests.cs, test/DemaConsulting.CanvasNet.Tests/Fonts/CmapTableTests.cs, test/DemaConsulting.CanvasNet.Tests/Fonts/GlyfLocaReaderTests.cs, test/DemaConsulting.CanvasNet.Tests/Fonts/HmtxHheaReaderTests.cs, test/DemaConsulting.CanvasNet.Tests/Fonts/KernTableTests.cs, test/DemaConsulting.CanvasNet.Tests/Fonts/TrueTypeFontTests.cs, test/DemaConsulting.CanvasNet.Tests/Fonts/TrueTypeFontRealFontIntegrationTests.cs, test/DemaConsulting.CanvasNet.Tests/TestSupport/SyntheticFontBuilder.cs */ comment designRef /* Design: docs/design/canvas-net/fonts/true-type-font.md */ comment verificationRef /* Verification: docs/verification/canvas-net/fonts/true-type-font.md */ comment reqRef /* Requirements: docs/reqstream/canvas-net/fonts/true-type-font.yaml */ diff --git a/docs/verification/canvas-net/fonts.md b/docs/verification/canvas-net/fonts.md index c1408a79..f5c3892c 100644 --- a/docs/verification/canvas-net/fonts.md +++ b/docs/verification/canvas-net/fonts.md @@ -20,7 +20,14 @@ the integrated collaboration between `Fonts`, `Geometry`, `Drawing`, and `Canvas - **Execution**: `dotnet test` invoked by `build.ps1` and the CI pipeline - **Mocking**: None required; the subsystem uses only in-house byte-array fixtures and in-process calls to other CanvasNet public APIs -- **Fixtures**: Synthetic fonts are constructed in memory; no third-party font files are used +- **Fixtures**: Synthetic fonts are constructed in memory for all `TrueTypeFont` unit tests. One + additional integration test, + `TrueTypeFontRealFontIntegrationTests.TrueTypeFont_RealOpenSansFont_RendersGlyphOutlineAsVisibleInk`, + loads the real, licensed (SIL OFL 1.1) "Open Sans" production font fixture + (`test/DemaConsulting.CanvasNet.Tests/FontFixtures/OpenSans-Regular.ttf`, attributed per the + accompanying `OpenSans.LICENSE`) specifically to prove end-to-end composition between `Fonts` + and the `Drawing` pipeline on real-world glyph data; every other `Fonts` test remains + synthetic-fixture-based ### Acceptance Criteria diff --git a/docs/verification/canvas-net/fonts/true-type-font.md b/docs/verification/canvas-net/fonts/true-type-font.md index 678e1239..22b82b23 100644 --- a/docs/verification/canvas-net/fonts/true-type-font.md +++ b/docs/verification/canvas-net/fonts/true-type-font.md @@ -10,10 +10,16 @@ supporting internal helpers `SfntContainer`, `CmapTable`, `GlyfLocaReader`, `Hmt The `TrueTypeFont` unit is verified through focused unit tests that exercise each helper directly via `InternalsVisibleTo`, plus end-to-end `TrueTypeFont` tests that load a complete synthetic -font and drive the public API. Every fixture font is assembled in memory by the shared -`SyntheticFontBuilder` helper under `test/DemaConsulting.CanvasNet.Tests/TestSupport/`; no -third-party font files are used, so the tests avoid fixture licensing concerns while still -covering both well-formed and deliberately malformed SFNT structures. +font and drive the public API. Every fixture font used by these tests is assembled in memory by +the shared `SyntheticFontBuilder` helper under `test/DemaConsulting.CanvasNet.Tests/TestSupport/`; +no third-party font files are used, so the tests avoid fixture licensing concerns while still +covering both well-formed and deliberately malformed SFNT structures. One additional integration +test, `TrueTypeFontRealFontIntegrationTests.TrueTypeFont_RealOpenSansFont_RendersGlyphOutlineAsVisibleInk`, +loads the real, licensed (SIL OFL 1.1) "Open Sans" production font fixture +(`test/DemaConsulting.CanvasNet.Tests/FontFixtures/OpenSans-Regular.ttf`, attributed per the +accompanying `OpenSans.LICENSE`) to prove the unit genuinely composes with the `Drawing` pipeline +on real-world glyph data, complementing (rather than replacing) the synthetic-fixture coverage +above. #### Test Environment From 3fcbdd787601e2c762bef7851a5d71bd41a343a8 Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Tue, 22 Sep 2026 14:58:55 -0400 Subject: [PATCH 11/17] Fix format-4 cmap terminator and overlap validation gaps - Require both startCode and endCode of the final format-4 segment to be 0xFFFF, per the OpenType spec's mandatory terminator sentinel (previously only endCode was checked, letting a spec-violating 'catch-all' segment such as startCode=0, endCode=0xFFFF through). - Add an explicit non-overlap check for format-4 segments: each segment's startCode must be greater than the previous segment's endCode. The prior comment incorrectly claimed strictly-increasing endCode alone implied non-overlap; it does not (e.g. (0,10) followed by (5,20) passes a naive strictly-increasing-endCode check yet overlaps). Comment updated to describe the now-explicit non-overlap guarantee. - Add regression tests covering both gaps (rejecting a non-0xFFFF terminator startCode, and rejecting overlapping-but-endCode-increasing segments), and fix an existing test whose synthetic fallback subtable relied on the same previously-unenforced terminator requirement. --- .../Fonts/CmapTable.cs | 13 +-- .../Fonts/CmapTableTests.cs | 102 +++++++++++++++++- 2 files changed, 105 insertions(+), 10 deletions(-) diff --git a/src/DemaConsulting.CanvasNet/Fonts/CmapTable.cs b/src/DemaConsulting.CanvasNet/Fonts/CmapTable.cs index 0b01f6b8..2a39de93 100644 --- a/src/DemaConsulting.CanvasNet/Fonts/CmapTable.cs +++ b/src/DemaConsulting.CanvasNet/Fonts/CmapTable.cs @@ -241,10 +241,11 @@ private static int ClassifyPriority(int platformId, int encodingId, int format) } // Validate the required format-4 segment ordering: each segment's startCode must not - // exceed its endCode, segments must be strictly increasing (and therefore non-overlapping) - // by endCode, and the final segment must be the mandatory 0xFFFF terminator. A malformed - // subtable violating any of this is rejected so the linear-scan lookup below cannot return - // a nonzero glyph index derived from garbage data. + // exceed its endCode, segments must be strictly increasing by endCode and explicitly + // non-overlapping (each segment's startCode must be greater than the previous segment's + // endCode), and the final segment must be the mandatory 0xFFFF/0xFFFF terminator. A + // malformed subtable violating any of this is rejected so the linear-scan lookup below + // cannot return a nonzero glyph index derived from garbage data. for (var i = 0; i < segCount; i++) { if (startCodes[i] > endCodes[i]) @@ -252,13 +253,13 @@ private static int ClassifyPriority(int platformId, int encodingId, int format) return null; } - if (i > 0 && endCodes[i - 1] >= endCodes[i]) + if (i > 0 && (endCodes[i - 1] >= endCodes[i] || startCodes[i] <= endCodes[i - 1])) { return null; } } - if (endCodes[segCount - 1] != 0xFFFF) + if (endCodes[segCount - 1] != 0xFFFF || startCodes[segCount - 1] != 0xFFFF) { return null; } diff --git a/test/DemaConsulting.CanvasNet.Tests/Fonts/CmapTableTests.cs b/test/DemaConsulting.CanvasNet.Tests/Fonts/CmapTableTests.cs index 5406e755..e036f79c 100644 --- a/test/DemaConsulting.CanvasNet.Tests/Fonts/CmapTableTests.cs +++ b/test/DemaConsulting.CanvasNet.Tests/Fonts/CmapTableTests.cs @@ -342,19 +342,24 @@ public void CmapTable_Format4_SegmentStartCodeExceedsEndCode_IsRejected() SyntheticFontBuilder.WriteUInt16(buf, 0); // idRangeOffsets[0] SyntheticFontBuilder.WriteUInt16(buf, 0); // idRangeOffsets[1] - // Subtable 1 (offset 52): valid - maps codepoint 200 to glyph 7 + // Subtable 1 (offset 52): valid - one real segment mapping codepoint 200 to glyph 7, + // followed by the mandatory 0xFFFF/0xFFFF terminator segment SyntheticFontBuilder.WriteUInt16(buf, 4); // format - SyntheticFontBuilder.WriteUInt16(buf, 24); // declared length (segCount = 1) + SyntheticFontBuilder.WriteUInt16(buf, 32); // declared length (segCount = 2) SyntheticFontBuilder.WriteUInt16(buf, 0); // language - SyntheticFontBuilder.WriteUInt16(buf, 2); // segCountX2 (segCount = 1) + SyntheticFontBuilder.WriteUInt16(buf, 4); // segCountX2 (segCount = 2) SyntheticFontBuilder.WriteUInt16(buf, 0); // searchRange SyntheticFontBuilder.WriteUInt16(buf, 0); // entrySelector SyntheticFontBuilder.WriteUInt16(buf, 0); // rangeShift - SyntheticFontBuilder.WriteUInt16(buf, 0xFFFF); // endCodes[0] (terminator: the whole BMP, one segment) + SyntheticFontBuilder.WriteUInt16(buf, 200); // endCodes[0] + SyntheticFontBuilder.WriteUInt16(buf, 0xFFFF); // endCodes[1] (terminator) SyntheticFontBuilder.WriteUInt16(buf, 0); // reservedPad SyntheticFontBuilder.WriteUInt16(buf, 200); // startCodes[0] + SyntheticFontBuilder.WriteUInt16(buf, 0xFFFF); // startCodes[1] (terminator) SyntheticFontBuilder.WriteUInt16(buf, 7 - 200); // idDeltas[0]: (200 + (7-200)) & 0xFFFF == 7 + SyntheticFontBuilder.WriteUInt16(buf, 1); // idDeltas[1] SyntheticFontBuilder.WriteUInt16(buf, 0); // idRangeOffsets[0] + SyntheticFontBuilder.WriteUInt16(buf, 0); // idRangeOffsets[1] // Act: parse the cmap table var data = buf.ToArray(); @@ -450,6 +455,95 @@ public void CmapTable_Format4_MissingTerminatorSegment_IsRejected() Assert.Equal(0, cmap.GetGlyphIndex(10)); } + /// + /// Proves that CmapTable Format4 TerminatorStartCodeNotFFFF IsRejected. + /// + [Fact] + public void CmapTable_Format4_TerminatorStartCodeNotFFFF_IsRejected() + { + // Arrange: build a (3,1) format-4 subtable with a single segment whose endCode is the + // mandatory 0xFFFF terminator value, but whose startCode is 0 rather than the also-mandatory + // 0xFFFF - a spec-violating "catch-all" segment (matching every codepoint 0..0xFFFF) rather + // than the required 0xFFFF/0xFFFF sentinel. + var buf = new List + { + 0, 0, // cmap version + 0, 1, // numTables + 0, 3, // platformId + 0, 1, // encodingId + }; + SyntheticFontBuilder.WriteUInt32(buf, 12); // subtable offset + + SyntheticFontBuilder.WriteUInt16(buf, 4); // format + SyntheticFontBuilder.WriteUInt16(buf, 24); // declared length (segCount = 1) + SyntheticFontBuilder.WriteUInt16(buf, 0); // language + SyntheticFontBuilder.WriteUInt16(buf, 2); // segCountX2 (segCount = 1) + SyntheticFontBuilder.WriteUInt16(buf, 0); // searchRange + SyntheticFontBuilder.WriteUInt16(buf, 0); // entrySelector + SyntheticFontBuilder.WriteUInt16(buf, 0); // rangeShift + SyntheticFontBuilder.WriteUInt16(buf, 0xFFFF); // endCodes[0]: the mandatory terminator value + SyntheticFontBuilder.WriteUInt16(buf, 0); // reservedPad + SyntheticFontBuilder.WriteUInt16(buf, 0); // startCodes[0]: not the mandatory 0xFFFF terminator value + SyntheticFontBuilder.WriteUInt16(buf, 5); // idDeltas[0] + SyntheticFontBuilder.WriteUInt16(buf, 0); // idRangeOffsets[0] + + // Act: parse the cmap table whose terminator segment has a non-0xFFFF startCode + var data = buf.ToArray(); + var cmap = CmapTable.Parse(data, 0, data.Length); + + // Assert: the spec-violating catch-all segment is rejected and lookups resolve to glyph + // index zero + Assert.Equal(0, cmap.GetGlyphIndex(10)); + } + + /// + /// Proves that CmapTable Format4 OverlappingSegmentsWithIncreasingEndCode IsRejected. + /// + [Fact] + public void CmapTable_Format4_OverlappingSegmentsWithIncreasingEndCode_IsRejected() + { + // Arrange: build a (3,1) format-4 subtable with two segments whose endCodes are strictly + // increasing (10, then 20 - passing a naive strictly-increasing-endCode check) but whose + // codepoint ranges nonetheless overlap: segment 0 covers 0..10 and segment 1 covers 5..20, + // so codepoint 5..10 is covered by both. Followed by the mandatory 0xFFFF terminator. + var buf = new List + { + 0, 0, // cmap version + 0, 1, // numTables + 0, 3, // platformId + 0, 1, // encodingId + }; + SyntheticFontBuilder.WriteUInt32(buf, 12); // subtable offset + + SyntheticFontBuilder.WriteUInt16(buf, 4); // format + SyntheticFontBuilder.WriteUInt16(buf, 40); // declared length (segCount = 3) + SyntheticFontBuilder.WriteUInt16(buf, 0); // language + SyntheticFontBuilder.WriteUInt16(buf, 6); // segCountX2 (segCount = 3) + SyntheticFontBuilder.WriteUInt16(buf, 0); // searchRange + SyntheticFontBuilder.WriteUInt16(buf, 0); // entrySelector + SyntheticFontBuilder.WriteUInt16(buf, 0); // rangeShift + SyntheticFontBuilder.WriteUInt16(buf, 10); // endCodes[0] + SyntheticFontBuilder.WriteUInt16(buf, 20); // endCodes[1]: greater than endCodes[0], but overlapping + SyntheticFontBuilder.WriteUInt16(buf, 0xFFFF); // endCodes[2] (terminator) + SyntheticFontBuilder.WriteUInt16(buf, 0); // reservedPad + SyntheticFontBuilder.WriteUInt16(buf, 0); // startCodes[0] + SyntheticFontBuilder.WriteUInt16(buf, 5); // startCodes[1]: <= endCodes[0] - overlapping range + SyntheticFontBuilder.WriteUInt16(buf, 0xFFFF); // startCodes[2] + SyntheticFontBuilder.WriteUInt16(buf, 0); // idDeltas[0] + SyntheticFontBuilder.WriteUInt16(buf, 0); // idDeltas[1] + SyntheticFontBuilder.WriteUInt16(buf, 1); // idDeltas[2] + SyntheticFontBuilder.WriteUInt16(buf, 0); // idRangeOffsets[0] + SyntheticFontBuilder.WriteUInt16(buf, 0); // idRangeOffsets[1] + SyntheticFontBuilder.WriteUInt16(buf, 0); // idRangeOffsets[2] + + // Act: parse the cmap table with overlapping segments + var data = buf.ToArray(); + var cmap = CmapTable.Parse(data, 0, data.Length); + + // Assert: the overlapping segments are rejected and lookups resolve to glyph index zero + Assert.Equal(0, cmap.GetGlyphIndex(7)); + } + /// /// Proves that CmapTable Format4 IdRangeOffsetPointsBackward ReturnsZero. /// From 3fbe02e9b748fd0df5834b409ac0e0f31a9b3903 Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Tue, 22 Sep 2026 15:27:43 -0400 Subject: [PATCH 12/17] fix: address cloud PR review findings in cmap format-4/12 parsing and docs - CmapTable.TryParseFormat4: compute the endCode/startCode/idDelta/ idRangeOffset/glyphIdArray offset chain in long arithmetic before the bounds check, so a malformed subtable offset combined with a large segCountX2 can no longer overflow 32-bit int addition, wrap past the glyphIdArrayOffset > tableEnd guard, and trigger an IndexOutOfRangeException or an out-of-bounds read in the array population loop. - CmapTable format-12 lookup delegate: compute startGlyphIds[mid] + (code - starts[mid]) in long arithmetic and reject results that would not fit in a non-negative int, so a malformed group can no longer silently wrap to a small, plausible-looking (but bogus) glyph index. - docs/verification/canvas-net/fonts/true-type-font.md: scope the "no third-party font files are used" claim to the synthetic-fixture coverage only, removing the self-contradiction with the following sentences describing the real Open Sans integration test fixture. - Add CmapTableTests regression coverage for both offset-chain overflow and format-12 glyph-id wraparound, each verified to fail pre-fix and pass post-fix. --- .../canvas-net/fonts/true-type-font.md | 11 ++- .../Fonts/CmapTable.cs | 37 ++++++-- .../Fonts/CmapTableTests.cs | 91 +++++++++++++++++++ 3 files changed, 126 insertions(+), 13 deletions(-) diff --git a/docs/verification/canvas-net/fonts/true-type-font.md b/docs/verification/canvas-net/fonts/true-type-font.md index 22b82b23..eb046f2a 100644 --- a/docs/verification/canvas-net/fonts/true-type-font.md +++ b/docs/verification/canvas-net/fonts/true-type-font.md @@ -10,11 +10,12 @@ supporting internal helpers `SfntContainer`, `CmapTable`, `GlyfLocaReader`, `Hmt The `TrueTypeFont` unit is verified through focused unit tests that exercise each helper directly via `InternalsVisibleTo`, plus end-to-end `TrueTypeFont` tests that load a complete synthetic -font and drive the public API. Every fixture font used by these tests is assembled in memory by -the shared `SyntheticFontBuilder` helper under `test/DemaConsulting.CanvasNet.Tests/TestSupport/`; -no third-party font files are used, so the tests avoid fixture licensing concerns while still -covering both well-formed and deliberately malformed SFNT structures. One additional integration -test, `TrueTypeFontRealFontIntegrationTests.TrueTypeFont_RealOpenSansFont_RendersGlyphOutlineAsVisibleInk`, +font and drive the public API. Every fixture font used by this synthetic-fixture coverage is +assembled in memory by the shared `SyntheticFontBuilder` helper under +`test/DemaConsulting.CanvasNet.Tests/TestSupport/`; no third-party font files are used for this +portion of the suite, so it avoids fixture licensing concerns while still covering both +well-formed and deliberately malformed SFNT structures. One additional integration test, +`TrueTypeFontRealFontIntegrationTests.TrueTypeFont_RealOpenSansFont_RendersGlyphOutlineAsVisibleInk`, loads the real, licensed (SIL OFL 1.1) "Open Sans" production font fixture (`test/DemaConsulting.CanvasNet.Tests/FontFixtures/OpenSans-Regular.ttf`, attributed per the accompanying `OpenSans.LICENSE`) to prove the unit genuinely composes with the `Drawing` pipeline diff --git a/src/DemaConsulting.CanvasNet/Fonts/CmapTable.cs b/src/DemaConsulting.CanvasNet/Fonts/CmapTable.cs index 2a39de93..efbf04d7 100644 --- a/src/DemaConsulting.CanvasNet/Fonts/CmapTable.cs +++ b/src/DemaConsulting.CanvasNet/Fonts/CmapTable.cs @@ -217,17 +217,30 @@ private static int ClassifyPriority(int platformId, int encodingId, int format) return null; } - var endCodeOffset = offset + 14; - var startCodeOffset = endCodeOffset + segCountX2 + 2; // +2 skips reservedPad - var idDeltaOffset = startCodeOffset + segCountX2; - var idRangeOffsetOffset = idDeltaOffset + segCountX2; - var glyphIdArrayOffset = idRangeOffsetOffset + segCountX2; - - if (glyphIdArrayOffset > tableEnd) + // Compute the offset chain in `long` arithmetic rather than `int` - `offset` combined + // with a maximal `segCountX2` (up to 65534) can otherwise overflow 32-bit `int` addition + // and wrap into an unrelated or negative value, which would make the bounds check below + // incorrectly pass and allow the array-population loop to read outside the declared + // subtable (or throw `IndexOutOfRangeException`), violating the never-throw contract. + var endCodeOffsetLong = (long)offset + 14; + var startCodeOffsetLong = endCodeOffsetLong + segCountX2 + 2; // +2 skips reservedPad + var idDeltaOffsetLong = startCodeOffsetLong + segCountX2; + var idRangeOffsetOffsetLong = idDeltaOffsetLong + segCountX2; + var glyphIdArrayOffsetLong = idRangeOffsetOffsetLong + segCountX2; + + if (glyphIdArrayOffsetLong > tableEnd) { return null; } + // Every offset in the chain is now confirmed to be within [offset, tableEnd], and + // `tableEnd` is an `int`, so each value fits safely back into an `int`. + var endCodeOffset = (int)endCodeOffsetLong; + var startCodeOffset = (int)startCodeOffsetLong; + var idDeltaOffset = (int)idDeltaOffsetLong; + var idRangeOffsetOffset = (int)idRangeOffsetOffsetLong; + var glyphIdArrayOffset = (int)glyphIdArrayOffsetLong; + var endCodes = new int[segCount]; var startCodes = new int[segCount]; var idDeltas = new short[segCount]; @@ -371,7 +384,15 @@ private static int ClassifyPriority(int platformId, int encodingId, int format) } else { - return (int)(startGlyphIds[mid] + (code - starts[mid])); + // Compute the sum in `long` arithmetic rather than `uint` - `startGlyphIds[mid]` + // combined with a large `code - starts[mid]` offset can otherwise wrap modulo + // 2^32, silently producing a small, plausible-looking (but bogus) glyph index + // instead of failing. Real fonts never have anywhere near `int.MaxValue` + // glyphs, so any result that would not fit in a non-negative `int` (i.e. would + // have wrapped, or would itself become negative when cast) is rejected + // outright rather than truncated. + var glyphId = (long)startGlyphIds[mid] + (code - starts[mid]); + return glyphId > int.MaxValue ? 0 : (int)glyphId; } } diff --git a/test/DemaConsulting.CanvasNet.Tests/Fonts/CmapTableTests.cs b/test/DemaConsulting.CanvasNet.Tests/Fonts/CmapTableTests.cs index e036f79c..2e334c8b 100644 --- a/test/DemaConsulting.CanvasNet.Tests/Fonts/CmapTableTests.cs +++ b/test/DemaConsulting.CanvasNet.Tests/Fonts/CmapTableTests.cs @@ -726,4 +726,95 @@ public void CmapTable_UnrecognizedSubtableFormat_GetGlyphIndexReturnsZero() // Assert: the unrecognized format is ignored and lookups resolve to glyph index zero Assert.Equal(0, cmap.GetGlyphIndex(65)); } + + /// + /// Proves that CmapTable Format4 OffsetChainArithmeticOverflow ReturnsZero. + /// + [Fact] + public void CmapTable_Format4_OffsetChainArithmeticOverflow_ReturnsZero() + { + // Arrange: place a format-4 subtable at a position (`offset`) so large, combined with the + // maximal even `segCountX2` (65534), that computing + // `glyphIdArrayOffset = offset + 16 + 4 * segCountX2` with unwidened 32-bit `int` + // arithmetic overflows past `int.MaxValue` and wraps to a negative value. A decoder that + // only widens *some* of this offset chain (or none of it) would then incorrectly pass the + // `glyphIdArrayOffset > tableEnd` bounds guard (a negative value is never greater than a + // huge positive `tableEnd`), and go on to read the segment arrays via offsets that run + // straight past the small, tightly-sized backing array - throwing + // `IndexOutOfRangeException` - rather than rejecting the subtable outright. The declared + // subtable `length` (16) and enclosing `tableLength` are both kept just large enough to + // cover the 14-byte subtable header (so the early `offset + 14 > tableEnd` guard passes + // and `segCountX2` is actually read), while the backing array itself is sized to match - + // not anywhere near the full `offset + glyphIdArrayOffset` span - so only `offset` itself, + // not the array, needs to approach `int.MaxValue`. + const int segCountX2 = 65534; // maximum valid (even) segCountX2 + const int chainConstant = 16 + 4 * segCountX2; // offset -> glyphIdArrayOffset delta + + // Chosen so `offset + chainConstant` exceeds `int.MaxValue` by a comfortable margin, + // while `offset` itself remains a valid, addressable position for a byte[] (well under + // Array.MaxLength). + var offset = int.MaxValue - chainConstant + 1000; + var declaredSubtableLength = 16; // covers only the 14-byte subtable header + var tableLength = offset + declaredSubtableLength; + + var data = new byte[tableLength]; + + void WriteU16(int position, int value) + { + data[position] = (byte)((value >> 8) & 0xFF); + data[position + 1] = (byte)(value & 0xFF); + } + + void WriteU32(int position, long value) + { + data[position] = (byte)((value >> 24) & 0xFF); + data[position + 1] = (byte)((value >> 16) & 0xFF); + data[position + 2] = (byte)((value >> 8) & 0xFF); + data[position + 3] = (byte)(value & 0xFF); + } + + WriteU16(0, 0); // cmap version + WriteU16(2, 1); // numTables + WriteU16(4, 3); // platformId + WriteU16(6, 1); // encodingId + WriteU32(8, offset); // subtable offset, relative to cmap table start (tableOffset == 0) + + WriteU16(offset, 4); // format + WriteU16(offset + 2, declaredSubtableLength); // declared length: header only + WriteU16(offset + 4, 0); // language + WriteU16(offset + 6, segCountX2); + WriteU16(offset + 8, 0); // searchRange + WriteU16(offset + 10, 0); // entrySelector + WriteU16(offset + 12, 0); // rangeShift + + // Act: parse the cmap table and look up any codepoint + var cmap = CmapTable.Parse(data, 0, tableLength); + + // Assert: the overflow-prone offset chain is rejected (rather than wrapping to a value + // that incorrectly passes the bounds guard) and the lookup resolves to zero, without + // throwing + Assert.Equal(0, cmap.GetGlyphIndex(0)); + } + + /// + /// Proves that CmapTable Format12 StartGlyphIdPlusOffsetOverflowsUInt32 ReturnsZero. + /// + [Fact] + public void CmapTable_Format12_StartGlyphIdPlusOffsetOverflowsUInt32_ReturnsZero() + { + // Arrange: build a format-12 cmap subtable with a single group whose startGlyphId is + // uint.MaxValue. Looking up a codepoint two above the group's startCharCode computes + // `startGlyphIds[mid] + (code - starts[mid])` = uint.MaxValue + 2, which wraps modulo + // 2^32 to the small, plausible-looking (but bogus) glyph index 1 unless the sum is + // computed in wider (non-wrapping) arithmetic. + var table = SyntheticFontBuilder.CmapFormat12(3, 10, [(0u, 5u, uint.MaxValue)]); + + // Act: parse the cmap table and look up a codepoint whose offset from the group's start + // would overflow uint32 arithmetic + var cmap = CmapTable.Parse(table, 0, table.Length); + + // Assert: the would-be-wrapped result is rejected rather than silently truncated to a + // plausible-looking small glyph index + Assert.Equal(0, cmap.GetGlyphIndex(2)); + } } From fa68e4e27939dbfa9ec35284e1b32bb0a149b4b8 Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Tue, 22 Sep 2026 15:54:15 -0400 Subject: [PATCH 13/17] fix: cap format-12 cmap numGroups to prevent memory-exhaustion DoS TryParseFormat12 allocated three parallel uint[] arrays sized directly by an attacker-controlled numGroups after only an enclosing-table bounds check, letting a bounds-valid but adversarial format-12 cmap subtable (declaring a near-maximal numGroups paired with a suitably large but internally-consistent declared table/file size) exhaust process memory. Add MaxFormat12Groups (65536), checked before any further bounds checking or allocation, following the same explicit-resource-cap philosophy already used by GlyfLocaReader's MaxDepth/MaxTotalComponents. Real fonts - even CJK-heavy ones covering the full Unicode codepoint space - need at most a few thousand groups, so 65536 is generous headroom while keeping worst-case allocation to a few hundred KB. Kept the three parallel uint[] arrays (starts/ends/startGlyphIds) rather than combining them into one struct array: with numGroups now capped, the total byte footprint is the same order of magnitude either way, and the difference is only per-array object overhead - not a meaningful part of the DoS surface - so combining them was judged not worth the added code churn/risk. Adds a regression test proving a subtable declaring numGroups one above the cap (with real, non-overlapping group data making it otherwise bounds-valid) is rejected; verified the test fails without the cap and passes with it. --- .../Fonts/CmapTable.cs | 31 +++++++++++++++++ .../Fonts/CmapTableTests.cs | 33 +++++++++++++++++++ 2 files changed, 64 insertions(+) diff --git a/src/DemaConsulting.CanvasNet/Fonts/CmapTable.cs b/src/DemaConsulting.CanvasNet/Fonts/CmapTable.cs index efbf04d7..ec48eba1 100644 --- a/src/DemaConsulting.CanvasNet/Fonts/CmapTable.cs +++ b/src/DemaConsulting.CanvasNet/Fonts/CmapTable.cs @@ -20,6 +20,23 @@ namespace DemaConsulting.CanvasNet.Fonts; /// internal sealed class CmapTable { + /// + /// The maximum numGroups permitted in a format 12 subtable before it is rejected as + /// malformed. A bounds-valid (per the enclosing table/declared-length checks) but + /// adversarial numGroups is otherwise used directly to allocate parallel arrays, + /// letting an untrusted font declare a near- group count and + /// exhaust process memory even though the subtable is nominally "valid". Real fonts - + /// even CJK-heavy ones covering the entire Unicode codepoint space (~1.1 million + /// codepoints) - need at most a few thousand groups, since each group compresses a + /// contiguous character-code range down to one entry; 65536 groups is already vastly more + /// than any legitimate font would need, while remaining small enough that allocating a few + /// parallel arrays of that size (a few hundred KB at most) is trivial. This mirrors + /// 's MaxDepth/MaxTotalComponents caps, which + /// bound composite glyph resolution against adversarial input independent of whether it is + /// otherwise structurally valid. + /// + private const int MaxFormat12Groups = 65536; + /// /// A with no usable subtable: always /// returns 0. Used when the font has no cmap table at all. @@ -327,12 +344,26 @@ private static int ClassifyPriority(int platformId, int encodingId, int format) } var numGroups = SfntContainer.ReadUInt32(data, offset + 12); + if (numGroups > MaxFormat12Groups) + { + // Reject before doing any further bounds checking or allocation: an adversarial + // numGroups can otherwise pass the groupsEnd/tableEnd check below (by pairing it with + // a suitably large, but still internally-consistent, declared table/file size) and + // reach the array allocations, exhausting process memory. See MaxFormat12Groups. + return null; + } + var groupsEnd = checked((long)offset + 16 + (long)numGroups * 12); if (groupsEnd > tableEnd) { return null; } + // Kept as three parallel arrays rather than one array of a combined struct: with + // numGroups now bounded by MaxFormat12Groups, the total data footprint is at most a few + // hundred KB either way - the difference is only per-array object overhead (tens of + // bytes), not a meaningful DoS surface - so combining them would add code churn/risk here + // for no real memory-safety benefit. var starts = new uint[numGroups]; var ends = new uint[numGroups]; var startGlyphIds = new uint[numGroups]; diff --git a/test/DemaConsulting.CanvasNet.Tests/Fonts/CmapTableTests.cs b/test/DemaConsulting.CanvasNet.Tests/Fonts/CmapTableTests.cs index 2e334c8b..e0bc3058 100644 --- a/test/DemaConsulting.CanvasNet.Tests/Fonts/CmapTableTests.cs +++ b/test/DemaConsulting.CanvasNet.Tests/Fonts/CmapTableTests.cs @@ -701,6 +701,39 @@ public void CmapTable_Format12_GroupsOverlap_IsRejected() Assert.Equal(0, cmap.GetGlyphIndex(0x09)); } + /// + /// Proves that CmapTable Format12 NumGroupsExceedsCap IsRejected. + /// + [Fact] + public void CmapTable_Format12_NumGroupsExceedsCap_IsRejected() + { + // Arrange: build a format-12 cmap subtable declaring a numGroups just one above the + // implementation's MaxFormat12Groups cap (65536), with real, non-overlapping group data + // for every declared group so the subtable is fully bounds-valid (groupsEnd is within the + // declared/enclosing table length) - i.e. adversarial only in *count*, not truncation. + // This still requires well under 1 MB of backing data (65537 groups * 12 bytes), not the + // multi-GB buffer a real attack would need, since the point is only to prove the cap + // rejects an otherwise-valid subtable before it reaches the array allocations. + const int overCapGroupCount = 65536 + 1; // MaxFormat12Groups + 1 + var groups = new (uint Start, uint End, uint StartGlyphId)[overCapGroupCount]; + for (var i = 0; i < overCapGroupCount; i++) + { + var start = (uint)(i * 2); + groups[i] = (start, start, (uint)(i + 1)); // non-zero startGlyphId so an accepted + // (unrejected) subtable would resolve + // codepoint 0 to a non-zero glyph index + } + + var table = SyntheticFontBuilder.CmapFormat12(3, 10, groups); + + // Act: parse the cmap table with the over-cap (but otherwise well-formed) numGroups + var cmap = CmapTable.Parse(table, 0, table.Length); + + // Assert: the subtable is rejected outright (no supported subtable found, falling back to + // glyph 0) rather than being accepted and resolving codepoint 0 to glyph 1 + Assert.Equal(0, cmap.GetGlyphIndex(0)); + } + /// /// Proves that CmapTable UnrecognizedSubtableFormat GetGlyphIndexReturnsZero. /// From daea497aa17f6a3bf4881336de3fd44b30f0e09d Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Tue, 22 Sep 2026 16:01:51 -0400 Subject: [PATCH 14/17] Add missing cspell:ignore word (unrejected) --- test/DemaConsulting.CanvasNet.Tests/Fonts/CmapTableTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/DemaConsulting.CanvasNet.Tests/Fonts/CmapTableTests.cs b/test/DemaConsulting.CanvasNet.Tests/Fonts/CmapTableTests.cs index e0bc3058..2d5f3b8f 100644 --- a/test/DemaConsulting.CanvasNet.Tests/Fonts/CmapTableTests.cs +++ b/test/DemaConsulting.CanvasNet.Tests/Fonts/CmapTableTests.cs @@ -1,7 +1,7 @@ // cspell:ignore SFNT Sfnt sfnt glyf Glyf cmap Cmap loca Loca hmtx Hmtx hhea Hhea // cspell:ignore maxp Maxp notdef codepoint codepoints subtable subtables subsetted // cspell:ignore subsetting PPEM OTTO -// cspell:ignore misaligns unwidened +// cspell:ignore misaligns unwidened unrejected using DemaConsulting.CanvasNet.Fonts; using DemaConsulting.CanvasNet.Tests.TestSupport; From 83db519cdb50174c09ec0fce3e1e772dab5f35d6 Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Tue, 22 Sep 2026 16:56:52 -0400 Subject: [PATCH 15/17] Bound total resolved point/command count in composite glyph decoding Prevents a large simple glyph referenced many times via composite components from producing hundreds of millions of copied path commands from a tiny font file (memory/CPU exhaustion DoS). Adds a MaxTotalPoints budget alongside the existing depth/component-count caps, charged before allocation/copy at both the simple-glyph decode site and the composite AppendTransformed copy site. --- .../Fonts/GlyfLocaReader.cs | 86 +++++++++++++++---- .../Fonts/GlyfLocaReaderTests.cs | 31 +++++++ .../TestSupport/SyntheticFontBuilder.cs | 41 +++++++++ 3 files changed, 142 insertions(+), 16 deletions(-) diff --git a/src/DemaConsulting.CanvasNet/Fonts/GlyfLocaReader.cs b/src/DemaConsulting.CanvasNet/Fonts/GlyfLocaReader.cs index 48643f6b..13dd3d8e 100644 --- a/src/DemaConsulting.CanvasNet/Fonts/GlyfLocaReader.cs +++ b/src/DemaConsulting.CanvasNet/Fonts/GlyfLocaReader.cs @@ -30,6 +30,18 @@ namespace DemaConsulting.CanvasNet.Fonts; /// composite components (ARGS_ARE_XY_VALUES clear) are rejected with /// rather than silently mis-positioned. /// +/// +/// The component-count cap alone does not bound the amount of geometry each visit produces: +/// a single simple glyph can cheaply encode tens of thousands of points (TrueType's flag +/// repeat-count and "same as previous" coordinate omission mean a ~65535-point contour can be +/// represented in only a few hundred bytes), and a malicious composite glyph could reference +/// it thousands of times before the component-count cap trips, copying hundreds of millions of +/// path commands via AppendTransformed and exhausting memory/CPU. A third counter, a +/// total resolved point/command budget of across the whole +/// call, is charged as soon as the point/command count for a +/// step is known - before the corresponding points/commands are allocated or copied - so it +/// is effective against that amplification even though the component-count cap is not. +/// /// internal sealed class GlyfLocaReader { @@ -46,6 +58,18 @@ internal sealed class GlyfLocaReader /// private const int MaxTotalComponents = 5000; + /// + /// The maximum total number of resolved outline points/commands permitted across a single + /// call, bounding the amount of geometry produced (rather + /// than merely the number of glyph/component visits + /// bounds). Without this cap, a single large simple glyph (cheaply encodable with tens of + /// thousands of points via flag repeat-counts and coordinate omission) referenced many + /// times by composite glyphs could still produce hundreds of millions of path commands + /// before the component-count cap trips. 200,000 is far beyond any real font's glyph + /// complexity, but small enough to keep worst-case memory/CPU bounded. + /// + private const int MaxTotalPoints = 200_000; + private const int ArgsAreWords = 0x0001; private const int ArgsAreXyValues = 0x0002; private const int WeHaveAScale = 0x0008; @@ -140,9 +164,9 @@ public static GlyfLocaReader Parse(byte[] data, int locaOffset, int locaLength, /// /// /// Thrown when the glyph's contour data is malformed or truncated, when a composite - /// glyph's nesting depth or total resolved component count exceeds its bound, or when a - /// composite glyph contains a point-matched component or references an out-of-range - /// component glyph index. + /// glyph's nesting depth, total resolved component count, or total resolved point/command + /// count exceeds its bound, or when a composite glyph contains a point-matched component + /// or references an out-of-range component glyph index. /// public Path GetGlyphOutline(int glyphIndex) { @@ -152,14 +176,15 @@ public Path GetGlyphOutline(int glyphIndex) } var totalComponents = 0; - return DecodeGlyph(glyphIndex, 0, ref totalComponents); + var totalPoints = 0; + return DecodeGlyph(glyphIndex, 0, ref totalComponents, ref totalPoints); } /// - /// Decodes a single glyph (simple or composite), enforcing the depth and total-component - /// bounds. + /// Decodes a single glyph (simple or composite), enforcing the depth, total-component, and + /// total-point/command bounds. /// - private Path DecodeGlyph(int glyphIndex, int depth, ref int totalComponents) + private Path DecodeGlyph(int glyphIndex, int depth, ref int totalComponents, ref int totalPoints) { if (glyphIndex < 0 || glyphIndex >= GlyphCount) { @@ -194,21 +219,22 @@ private Path DecodeGlyph(int glyphIndex, int depth, ref int totalComponents) var numberOfContours = SfntContainer.ReadInt16(_data, glyphOffset); if (numberOfContours >= 0) { - return DecodeSimpleGlyph(glyphOffset, glyphLength, numberOfContours); + return DecodeSimpleGlyph(glyphOffset, glyphLength, numberOfContours, ref totalPoints); } if (numberOfContours == -1) { - return DecodeCompositeGlyph(glyphOffset, glyphLength, depth + 1, ref totalComponents); + return DecodeCompositeGlyph(glyphOffset, glyphLength, depth + 1, ref totalComponents, ref totalPoints); } throw new InvalidDataException("Glyph declares an invalid contour count."); } /// - /// Decodes a simple glyph's contour data into a . + /// Decodes a simple glyph's contour data into a , charging its point + /// count against before allocating any per-point arrays. /// - private Path DecodeSimpleGlyph(int glyphOffset, int glyphLength, int numberOfContours) + private Path DecodeSimpleGlyph(int glyphOffset, int glyphLength, int numberOfContours, ref int totalPoints) { var limit = glyphOffset + glyphLength; var pos = glyphOffset + 10; @@ -236,6 +262,15 @@ private Path DecodeSimpleGlyph(int glyphOffset, int glyphLength, int numberOfCon var numPoints = endPts[^1] + 1; + // Charge the budget before allocating the flags/xs/ys arrays below - a crafted glyph + // can declare a huge point count in only a few header bytes, and the cap must trip + // before that count drives any allocation, not after the geometry has already been built. + totalPoints += numPoints; + if (totalPoints > MaxTotalPoints) + { + throw new InvalidDataException("Glyph outline resolves to too many total points."); + } + EnsureAvailable(pos, 2, limit); var instructionLength = SfntContainer.ReadUInt16(_data, pos); pos += 2; @@ -400,7 +435,7 @@ private static void EmitContour(PathBuilder builder, List<(Vector2 Point, bool O /// Decodes a composite glyph, recursively resolving each component's glyph outline and /// applying its offset/scale/2x2 transform. /// - private Path DecodeCompositeGlyph(int glyphOffset, int glyphLength, int depth, ref int totalComponents) + private Path DecodeCompositeGlyph(int glyphOffset, int glyphLength, int depth, ref int totalComponents, ref int totalPoints) { var limit = glyphOffset + glyphLength; var pos = glyphOffset + 10; @@ -460,7 +495,7 @@ private Path DecodeCompositeGlyph(int glyphOffset, int glyphLength, int depth, r pos += 8; } - var componentPath = DecodeGlyph(componentGlyphIndex, depth, ref totalComponents); + var componentPath = DecodeGlyph(componentGlyphIndex, depth, ref totalComponents, ref totalPoints); // Per the OpenType/TrueType 'glyf' spec, SCALED_COMPONENT_OFFSET requests that the // component's translation be transformed through its own scale/2x2 matrix before @@ -475,7 +510,7 @@ private Path DecodeCompositeGlyph(int glyphOffset, int glyphLength, int depth, r dy = scaledDy; } - AppendTransformed(builder, componentPath, a, b, c, d, dx, dy); + AppendTransformed(builder, componentPath, a, b, c, d, dx, dy, ref totalPoints); hasContent = true; more = (flags & MoreComponents) != 0; @@ -488,12 +523,31 @@ private Path DecodeCompositeGlyph(int glyphOffset, int glyphLength, int depth, r /// /// Re-issues every subpath of into , /// applying the given 2x2 matrix (///) - /// and translation (/) to every point. + /// and translation (/) to every point, charging + /// the number of points/commands about to be copied against + /// before performing any copy - resolving a component's outline is cheap (it is already + /// built), but re-emitting it into the composite's builder is the step that actually + /// multiplies a large component's geometry by every reference to it, so the budget must be + /// checked here rather than only when the component was first decoded. /// - private static void AppendTransformed(PathBuilder builder, Path source, float a, float b, float c, float d, float dx, float dy) + private static void AppendTransformed(PathBuilder builder, Path source, float a, float b, float c, float d, float dx, float dy, ref int totalPoints) { Vector2 Transform(Vector2 p) => new(a * p.X + c * p.Y + dx, b * p.X + d * p.Y + dy); + // Count the points/commands (one MoveTo plus every command) this copy is about to + // produce, and charge the budget before copying a single one of them. + var pointsToCopy = 0; + foreach (var subpath in source.Subpaths) + { + pointsToCopy += 1 + subpath.Commands.Count; + } + + totalPoints += pointsToCopy; + if (totalPoints > MaxTotalPoints) + { + throw new InvalidDataException("Glyph outline resolves to too many total points."); + } + foreach (var subpath in source.Subpaths) { builder.MoveTo(Transform(subpath.Start)); diff --git a/test/DemaConsulting.CanvasNet.Tests/Fonts/GlyfLocaReaderTests.cs b/test/DemaConsulting.CanvasNet.Tests/Fonts/GlyfLocaReaderTests.cs index 263f7329..4005b4b5 100644 --- a/test/DemaConsulting.CanvasNet.Tests/Fonts/GlyfLocaReaderTests.cs +++ b/test/DemaConsulting.CanvasNet.Tests/Fonts/GlyfLocaReaderTests.cs @@ -493,6 +493,37 @@ byte[] MakeLevel(int referencedGlyphIndex) => Assert.Contains("too many", ex.Message, StringComparison.OrdinalIgnoreCase); } + /// + /// Proves that GlyfLocaReader CompositeGlyph LargeSimpleGlyphReferencedTwice ThrowsInvalidDataExceptionViaPointBudget. + /// + [Fact] + public void GlyfLocaReader_CompositeGlyph_LargeSimpleGlyphReferencedTwice_ThrowsInvalidDataExceptionViaPointBudget() + { + // Arrange: a single simple glyph with 50,000 points, compactly encoded (flag repeat-count + // and "same as previous" coordinate omission mean no coordinate bytes are needed at all, + // so the whole glyph is only a couple of hundred bytes) - this is the exact amplification + // the total-point budget defends against: a tiny file can still describe a huge outline. + // Referencing it just twice from one composite already exceeds the 200,000-point budget + // (each reference charges ~50,000 points when the component is decoded, plus a further + // ~50,000 points/commands when its geometry is copied into the composite), while the + // total resolved *component* count (3: the composite plus its two component visits) stays + // far below the separate 5,000 total-component cap - proving the point/command budget (not + // the component-count cap) is what prevents the amplification here. + var leaf = SyntheticFontBuilder.LargeSimpleGlyph(50_000); + var composite = SyntheticFontBuilder.CompositeGlyph( + [ + new SyntheticFontBuilder.CompositeComponent(0, 0, 0), + new SyntheticFontBuilder.CompositeComponent(0, 50, 50) + ]); + + // Act + var reader = BuildReader([leaf, composite]); + + // Assert: the total-point budget trips and the message identifies the point-budget failure + var ex = Assert.Throws(() => reader.GetGlyphOutline(1)); + Assert.Contains("points", ex.Message, StringComparison.OrdinalIgnoreCase); + } + /// /// Proves that GlyfLocaReader Parse LocaEntryExceedsGlyfBounds ThrowsInvalidDataException. /// diff --git a/test/DemaConsulting.CanvasNet.Tests/TestSupport/SyntheticFontBuilder.cs b/test/DemaConsulting.CanvasNet.Tests/TestSupport/SyntheticFontBuilder.cs index 1207059a..8b4a1ac6 100644 --- a/test/DemaConsulting.CanvasNet.Tests/TestSupport/SyntheticFontBuilder.cs +++ b/test/DemaConsulting.CanvasNet.Tests/TestSupport/SyntheticFontBuilder.cs @@ -309,6 +309,47 @@ public static byte[] SimpleGlyph(params (int X, int Y, bool OnCurve)[][] contour return [.. buf]; } + /// + /// Builds a single-contour simple glyph with on-curve points + /// compactly encoded via TrueType's flag repeat-count and "same as previous" coordinate + /// omission, so every point after the first shares flag byte 0x31 + /// (ON_CURVE_POINT | X_IS_SAME_OR_POSITIVE_X_SHORT_VECTOR | Y_IS_SAME_OR_POSITIVE_Y_SHORT_VECTOR) + /// and requires no coordinate bytes at all - used to prove that a huge point count is + /// representable in only a few hundred bytes, matching the exact amplification the + /// total-point budget defends against. + /// + public static byte[] LargeSimpleGlyph(int numPoints) + { + var buf = new List(); + WriteInt16(buf, 1); // numberOfContours + WriteInt16(buf, 0); + WriteInt16(buf, 0); + WriteInt16(buf, 0); + WriteInt16(buf, 0); + WriteUInt16(buf, numPoints - 1); // endPtsOfContours[0] + WriteUInt16(buf, 0); // instructionLength + + // Every point after the first is described purely by a flag byte, never coordinate + // bytes: TrueType lets a flag mark a point's X and Y as unchanged from the previous + // point, and lets one flag byte represent a run of identical flags via a repeat count. + // This block emits the minimum number of flag/repeat-count byte pairs needed to describe + // all requested points; a repeat count may legitimately be zero, meaning a run of one. + const byte flag = 0x01 | 0x10 | 0x20 | 0x08; // ON_CURVE | X_SAME | Y_SAME | REPEAT_FLAG + var remaining = numPoints; + while (remaining > 0) + { + var repeatCount = Math.Min(remaining - 1, 255); + buf.Add(flag); + buf.Add((byte)repeatCount); + remaining -= 1 + repeatCount; + } + + // Deliberately no X/Y coordinate section follows: every point's flag marks its + // coordinates as identical to the preceding point, so none are ever encoded. + PadToEvenLength(buf); + return [.. buf]; + } + /// /// Appends a single zero pad byte if 's length is odd - real /// glyf table entries are always padded to an even length, and the short From 96196502a3984d22ea29a62afd5089256f582b18 Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Tue, 22 Sep 2026 16:57:02 -0400 Subject: [PATCH 16/17] Reject out-of-range format-12 code points and kerning glyph indices - CmapTable: reject format-12 groups whose endCharCode exceeds U+10FFFF, so malformed cmap data cannot resolve a glyph for an invalid code point. - TrueTypeFont.GetKerning: validate both glyph indices against GlyphCount before delegating to KernTable, so a kern pair that is a valid ushort but out of range for this font's glyph count returns 0 instead of a stale/incorrect adjustment, while preserving the never-throws contract. - Rework the CmapTable format-4 and KernTable subtable-position overflow regression tests to exercise the extracted arithmetic helpers directly via reflection instead of allocating near-2GB byte arrays to simulate huge file offsets. - Correct the design doc's composite-glyph-bounds description and add the missing requirement/verification entry for the new total point/command budget. --- .../design/canvas-net/fonts/true-type-font.md | 16 +- .../canvas-net/fonts/true-type-font.yaml | 13 ++ .../canvas-net/fonts/true-type-font.md | 8 + .../Fonts/CmapTable.cs | 110 +++++++-- .../Fonts/KernTable.cs | 49 +++- .../Fonts/TrueTypeFont.cs | 28 ++- .../Fonts/CmapTableTests.cs | 214 +++++++----------- .../Fonts/KernTableTests.cs | 56 +++-- .../Fonts/TrueTypeFontTests.cs | 37 +++ 9 files changed, 338 insertions(+), 193 deletions(-) diff --git a/docs/design/canvas-net/fonts/true-type-font.md b/docs/design/canvas-net/fonts/true-type-font.md index e392c60a..da4f7f0b 100644 --- a/docs/design/canvas-net/fonts/true-type-font.md +++ b/docs/design/canvas-net/fonts/true-type-font.md @@ -300,11 +300,17 @@ on machine endianness or hidden span helpers. ##### Composite Glyph Bounds -`GlyfLocaReader` enforces two deterministic limits during composite decoding: maximum nesting -depth 10 and maximum total resolved component visits 5000. The depth limit catches true cycles -and pathological nesting, but depth alone would not stop a non-cyclic composite tree from -exploding exponentially. The total-component limit is therefore the stronger bound: it caps total -work even when every reference chain is acyclic. +`GlyfLocaReader` enforces three deterministic limits during composite decoding: maximum nesting +depth 10, maximum total resolved component visits 5000, and a total resolved point/command budget +of 200,000 charged across a single `GetGlyphOutline` call. The depth limit catches true cycles and +pathological nesting, but depth alone would not stop a non-cyclic composite tree from exploding +exponentially. The total-component limit bounds that exponential blow-up, but it counts only +component *visits*, not the amount of geometry each visit produces - a single large simple glyph +can cheaply encode tens of thousands of points, and a composite glyph referencing it many times +(well under the component-visit cap) could still copy hundreds of millions of path commands. The +total-point/command budget is the bound that actually caps this amplification: it is charged as +soon as the point/command count for a step is known, before the corresponding points/commands are +allocated or copied, so it remains effective even when the component-count cap alone is not. ##### Quadratic Contour Conversion diff --git a/docs/reqstream/canvas-net/fonts/true-type-font.yaml b/docs/reqstream/canvas-net/fonts/true-type-font.yaml index 9a940cd9..631c2a1c 100644 --- a/docs/reqstream/canvas-net/fonts/true-type-font.yaml +++ b/docs/reqstream/canvas-net/fonts/true-type-font.yaml @@ -269,6 +269,19 @@ sections: tests: - GlyfLocaReader_CompositeGlyph_ExponentialBlowUp_ThrowsInvalidDataExceptionViaComponentCap + - id: CanvasNet-Fonts-TrueTypeFont-GlyphOutlineRejectsExcessivePoints + title: >- + The TrueTypeFont class shall reject a composite glyph that would resolve to an + excessive total number of outline points/commands with InvalidDataException + rather than hanging or crashing. + justification: | + A malformed or malicious font could define a composite glyph that references a + single large simple glyph many times, without the reference count alone exceeding + the total-component bound; bounding the total resolved point/command count + prevents this amplified geometry from hanging or crashing the calling process. + tests: + - GlyfLocaReader_CompositeGlyph_LargeSimpleGlyphReferencedTwice_ThrowsInvalidDataExceptionViaPointBudget + - id: CanvasNet-Fonts-TrueTypeFont-GlyphOutlineRejectsPointMatchedComponent title: >- The TrueTypeFont class shall reject a composite glyph component that positions diff --git a/docs/verification/canvas-net/fonts/true-type-font.md b/docs/verification/canvas-net/fonts/true-type-font.md index eb046f2a..323bd6c6 100644 --- a/docs/verification/canvas-net/fonts/true-type-font.md +++ b/docs/verification/canvas-net/fonts/true-type-font.md @@ -206,6 +206,14 @@ instead of hanging. Constructs an acyclic but explosively branching composite graph and asserts the total-component cap rejects it. +##### CanvasNet-Fonts-TrueTypeFont-GlyphOutlineRejectsExcessivePoints: Excessive Total Point/Command Count Is Rejected + +**Tests**: `GlyfLocaReader_CompositeGlyph_LargeSimpleGlyphReferencedTwice_ThrowsInvalidDataExceptionViaPointBudget` + +Builds a composite glyph that references the same large simple glyph enough times to exceed the +total point/command budget while staying well under the total-component cap, and asserts the +point/command budget rejects it. + ##### CanvasNet-Fonts-TrueTypeFont-GlyphOutlineRejectsPointMatchedComponent: Point-Matched Components Are Rejected **Tests**: `GlyfLocaReader_CompositeGlyph_PointMatchedComponent_ThrowsInvalidDataException` diff --git a/src/DemaConsulting.CanvasNet/Fonts/CmapTable.cs b/src/DemaConsulting.CanvasNet/Fonts/CmapTable.cs index ec48eba1..d49abc6e 100644 --- a/src/DemaConsulting.CanvasNet/Fonts/CmapTable.cs +++ b/src/DemaConsulting.CanvasNet/Fonts/CmapTable.cs @@ -1,7 +1,7 @@ // cspell:ignore SFNT Sfnt sfnt glyf Glyf cmap Cmap loca Loca hmtx Hmtx hhea Hhea // cspell:ignore maxp Maxp notdef codepoint codepoints subtable subtables subsetted // cspell:ignore subsetting PPEM OTTO -// cspell:ignore misalign +// cspell:ignore misalign unwidened namespace DemaConsulting.CanvasNet.Fonts; /// @@ -239,24 +239,13 @@ private static int ClassifyPriority(int platformId, int encodingId, int format) // and wrap into an unrelated or negative value, which would make the bounds check below // incorrectly pass and allow the array-population loop to read outside the declared // subtable (or throw `IndexOutOfRangeException`), violating the never-throw contract. - var endCodeOffsetLong = (long)offset + 14; - var startCodeOffsetLong = endCodeOffsetLong + segCountX2 + 2; // +2 skips reservedPad - var idDeltaOffsetLong = startCodeOffsetLong + segCountX2; - var idRangeOffsetOffsetLong = idDeltaOffsetLong + segCountX2; - var glyphIdArrayOffsetLong = idRangeOffsetOffsetLong + segCountX2; - - if (glyphIdArrayOffsetLong > tableEnd) + var offsetChain = ComputeFormat4OffsetChain(offset, segCountX2, tableEnd); + if (offsetChain == null) { return null; } - // Every offset in the chain is now confirmed to be within [offset, tableEnd], and - // `tableEnd` is an `int`, so each value fits safely back into an `int`. - var endCodeOffset = (int)endCodeOffsetLong; - var startCodeOffset = (int)startCodeOffsetLong; - var idDeltaOffset = (int)idDeltaOffsetLong; - var idRangeOffsetOffset = (int)idRangeOffsetOffsetLong; - var glyphIdArrayOffset = (int)glyphIdArrayOffsetLong; + var (endCodeOffset, startCodeOffset, idDeltaOffset, idRangeOffsetOffset, glyphIdArrayOffset) = offsetChain.Value; var endCodes = new int[segCount]; var startCodes = new int[segCount]; @@ -318,13 +307,14 @@ private static int ClassifyPriority(int platformId, int encodingId, int format) return (codepoint + idDeltas[i]) & 0xFFFF; } - var glyphIndexAddress = (long)idRangeOffsetOffset + i * 2 + idRangeOffsets[i] + 2L * (codepoint - startCodes[i]); - if (glyphIndexAddress < glyphIdArrayOffset || glyphIndexAddress + 2 > tableEnd) + var glyphIndexAddress = ComputeFormat4GlyphIndexAddress( + idRangeOffsetOffset, i, idRangeOffsets[i], codepoint, startCodes[i], glyphIdArrayOffset, tableEnd); + if (glyphIndexAddress == null) { return 0; } - var glyphId = SfntContainer.ReadUInt16(data, (int)glyphIndexAddress); + var glyphId = SfntContainer.ReadUInt16(data, (int)glyphIndexAddress.Value); return glyphId == 0 ? 0 : (glyphId + idDeltas[i]) & 0xFFFF; } @@ -332,6 +322,81 @@ private static int ClassifyPriority(int platformId, int encodingId, int format) }; } + /// + /// Computes the format-4 offset chain (endCode/startCode/idDelta/idRangeOffset/glyphIdArray + /// positions), validating that every position in the chain fits within + /// . + /// + /// + /// Isolated as its own pure-arithmetic helper so the chain computation can be exercised + /// directly against adversarial / + /// combinations without needing a backing byte array anywhere near the sizes those + /// positions describe - the same technique uses for its + /// subtable-position arithmetic. The addition is computed in + /// arithmetic specifically because combined with a maximal + /// (up to 65534) can overflow 32-bit + /// addition and wrap into a value that would incorrectly pass an unwidened bounds guard. + /// + /// The subtable's start position within the font data. + /// The subtable's declared segCountX2 field. + /// The subtable's effective end position. + /// + /// The computed (EndCodeOffset, StartCodeOffset, IdDeltaOffset, IdRangeOffsetOffset, + /// GlyphIdArrayOffset) tuple, or if the final + /// GlyphIdArrayOffset exceeds . + /// + private static (int EndCodeOffset, int StartCodeOffset, int IdDeltaOffset, int IdRangeOffsetOffset, int GlyphIdArrayOffset)? + ComputeFormat4OffsetChain(int offset, int segCountX2, int tableEnd) + { + var endCodeOffsetLong = (long)offset + 14; + var startCodeOffsetLong = endCodeOffsetLong + segCountX2 + 2; // +2 skips reservedPad + var idDeltaOffsetLong = startCodeOffsetLong + segCountX2; + var idRangeOffsetOffsetLong = idDeltaOffsetLong + segCountX2; + var glyphIdArrayOffsetLong = idRangeOffsetOffsetLong + segCountX2; + + if (glyphIdArrayOffsetLong > tableEnd) + { + return null; + } + + // Every offset in the chain is now confirmed to be within [offset, tableEnd], and + // `tableEnd` is an `int`, so each value fits safely back into an `int`. + return ((int)endCodeOffsetLong, (int)startCodeOffsetLong, (int)idDeltaOffsetLong, (int)idRangeOffsetOffsetLong, (int)glyphIdArrayOffsetLong); + } + + /// + /// Computes a format-4 segment's indirect glyph-index address + /// (idRangeOffsetOffset + segmentIndex * 2 + idRangeOffset + 2 * (codepoint - startCode)), + /// validating that it falls within [glyphIdArrayOffset, tableEnd). + /// + /// + /// Isolated as its own pure-arithmetic helper (mirroring ) + /// so it can be exercised directly against adversarial position/offset combinations + /// without needing a backing byte array anywhere near the sizes those positions describe. + /// The computation is performed in arithmetic because + /// combined with a maximal + /// (0xFFFF) and codepoint delta can overflow 32-bit + /// addition and wrap to a negative value, which would incorrectly + /// pass an unwidened bounds guard and then be used directly as a negative array index. + /// + /// The position of the subtable's idRangeOffset array. + /// The index of the segment being resolved. + /// The segment's declared idRangeOffset value. + /// The codepoint being looked up. + /// The segment's startCode value. + /// The position of the subtable's glyphIdArray. + /// The subtable's effective end position. + /// + /// The computed glyph-index address, or if it falls outside + /// [glyphIdArrayOffset, tableEnd) (accounting for the 2-byte glyph ID read). + /// + private static long? ComputeFormat4GlyphIndexAddress( + int idRangeOffsetOffset, int segmentIndex, int idRangeOffset, int codepoint, int startCode, int glyphIdArrayOffset, int tableEnd) + { + var glyphIndexAddress = (long)idRangeOffsetOffset + segmentIndex * 2 + idRangeOffset + 2L * (codepoint - startCode); + return glyphIndexAddress < glyphIdArrayOffset || glyphIndexAddress + 2 > tableEnd ? null : glyphIndexAddress; + } + /// /// Attempts to parse a format 12 (segmented coverage) subtable, returning a bound lookup /// delegate, or if the subtable is malformed/truncated. @@ -390,6 +455,15 @@ private static int ClassifyPriority(int platformId, int encodingId, int format) { return null; } + + // Unicode has no codepoints above U+10FFFF (the maximum value encodable by UTF-16 + // surrogate pairs and the hard ceiling set by the Unicode standard). A group whose + // endCharCode exceeds this is malformed - real fonts never declare coverage beyond + // it - so reject it here rather than accepting an out-of-repertoire range. + if (ends[i] > 0x10FFFF) + { + return null; + } } return codepoint => diff --git a/src/DemaConsulting.CanvasNet/Fonts/KernTable.cs b/src/DemaConsulting.CanvasNet/Fonts/KernTable.cs index ed52082a..048b6c71 100644 --- a/src/DemaConsulting.CanvasNet/Fonts/KernTable.cs +++ b/src/DemaConsulting.CanvasNet/Fonts/KernTable.cs @@ -1,6 +1,6 @@ // cspell:ignore SFNT Sfnt sfnt glyf Glyf cmap Cmap loca Loca hmtx Hmtx hhea Hhea // cspell:ignore maxp Maxp notdef codepoint codepoints subtable subtables subsetted -// cspell:ignore subsetting PPEM OTTO +// cspell:ignore subsetting PPEM OTTO unwidened namespace DemaConsulting.CanvasNet.Fonts; /// @@ -73,14 +73,12 @@ public static KernTable Parse(byte[] data, int tableOffset, int tableLength) var subtableLength = SfntContainer.ReadUInt16(data, pos + 2); var coverage = SfntContainer.ReadUInt16(data, pos + 4); - var subtableEndLong = (long)pos + subtableLength; - if (subtableLength < 6 || subtableEndLong > tableEnd) + var subtableEnd = ComputeSubtableEnd(pos, subtableLength, tableEnd); + if (subtableEnd == null) { return Empty; } - var subtableEnd = (int)subtableEndLong; - var format = (coverage >> 8) & 0xFF; var horizontal = (coverage & 0x1) != 0; var minimum = (coverage & 0x2) != 0; @@ -88,7 +86,7 @@ public static KernTable Parse(byte[] data, int tableOffset, int tableLength) if (format == 0 && horizontal && !minimum && !crossStream) { - var pairs = TryParseFormat0(data, pos + 6, subtableEnd); + var pairs = TryParseFormat0(data, pos + 6, subtableEnd.Value); if (pairs != null) { return new KernTable(pairs); @@ -97,12 +95,49 @@ public static KernTable Parse(byte[] data, int tableOffset, int tableLength) return Empty; } - pos = subtableEnd; + pos = subtableEnd.Value; } return Empty; } + /// + /// Computes a subtable's effective end position ( + + /// ), validating that the declared length is at least + /// large enough to hold the shared 6-byte subtable header and that the resulting end + /// position fits within the enclosing kern table. + /// + /// + /// Isolated as its own pure-arithmetic helper (mirroring 's + /// ComputeSubtableEnd) so the subtable-walking loop's position arithmetic can be + /// exercised directly against adversarial / + /// combinations without needing a backing byte array anywhere near the sizes those + /// positions describe. The addition is computed in arithmetic + /// specifically because combined with a maximal + /// (0xFFFF, the largest ushort) can overflow + /// 32-bit addition and wrap to a value that would incorrectly pass + /// an unwidened bounds guard, letting the walking loop advance pos to a corrupted, + /// out-of-bounds position for the next subtable. + /// + /// The subtable's start position within data. + /// The subtable's declared length field. + /// The enclosing kern table's end position within data. + /// + /// The subtable's end position, or if + /// is too small to hold the shared header, or the computed end position exceeds + /// . + /// + private static int? ComputeSubtableEnd(int pos, int subtableLength, int tableEnd) + { + if (subtableLength < 6) + { + return null; + } + + var subtableEndLong = (long)pos + subtableLength; + return subtableEndLong > tableEnd ? null : (int)subtableEndLong; + } + /// /// Looks up the kerning adjustment for a glyph pair. /// diff --git a/src/DemaConsulting.CanvasNet/Fonts/TrueTypeFont.cs b/src/DemaConsulting.CanvasNet/Fonts/TrueTypeFont.cs index 39a3cc81..ff134c48 100644 --- a/src/DemaConsulting.CanvasNet/Fonts/TrueTypeFont.cs +++ b/src/DemaConsulting.CanvasNet/Fonts/TrueTypeFont.cs @@ -181,9 +181,10 @@ public int GetGlyphIndex(int codepoint) /// /// /// Thrown when the glyph's contour data is malformed or truncated, when a composite - /// glyph's nesting depth (10) or total resolved component count (5000) exceeds its bound, - /// when a composite glyph contains a point-matched component, or when a composite glyph - /// references an out-of-range component glyph index. + /// glyph's nesting depth (10), total resolved component count (5000), or total resolved + /// point/command count (200,000) exceeds its bound, when a composite glyph contains a + /// point-matched component, or when a composite glyph references an out-of-range + /// component glyph index. /// public Path GetGlyphOutline(int glyphIndex) { @@ -215,10 +216,25 @@ public int GetAdvanceWidth(int glyphIndex) /// The right glyph index of the pair. /// /// The kerning adjustment, in font design units, or 0 if no matching pair is - /// present, the font has no kern table, or no qualifying kern subtable is - /// present. Never throws, even for an out-of-range glyph index. + /// present, the font has no kern table, no qualifying kern subtable is + /// present, or either glyph index is outside [0, GlyphCount) for this font. Never + /// throws, even for an out-of-range glyph index. /// - public int GetKerning(int leftGlyphIndex, int rightGlyphIndex) => _kern.GetKerning(leftGlyphIndex, rightGlyphIndex); + public int GetKerning(int leftGlyphIndex, int rightGlyphIndex) + { + // KernTable.GetKerning only rejects indices outside [0, ushort.MaxValue] - it has no + // notion of this particular font's GlyphCount, since it is parsed and validated + // independently of maxp.numGlyphs. Without this check, a glyph index that is a valid + // ushort but at/beyond this font's GlyphCount could still match a stale/bogus pair + // recorded in the kern table, returning a nonzero adjustment for a glyph that does not + // exist in this font. + if (leftGlyphIndex < 0 || leftGlyphIndex >= GlyphCount || rightGlyphIndex < 0 || rightGlyphIndex >= GlyphCount) + { + return 0; + } + + return _kern.GetKerning(leftGlyphIndex, rightGlyphIndex); + } /// /// Validates a glyph index argument shared by and diff --git a/test/DemaConsulting.CanvasNet.Tests/Fonts/CmapTableTests.cs b/test/DemaConsulting.CanvasNet.Tests/Fonts/CmapTableTests.cs index 2d5f3b8f..20a5d58a 100644 --- a/test/DemaConsulting.CanvasNet.Tests/Fonts/CmapTableTests.cs +++ b/test/DemaConsulting.CanvasNet.Tests/Fonts/CmapTableTests.cs @@ -2,6 +2,7 @@ // cspell:ignore maxp Maxp notdef codepoint codepoints subtable subtables subsetted // cspell:ignore subsetting PPEM OTTO // cspell:ignore misaligns unwidened unrejected +using System.Reflection; using DemaConsulting.CanvasNet.Fonts; using DemaConsulting.CanvasNet.Tests.TestSupport; @@ -600,87 +601,50 @@ public void CmapTable_Format4_IdRangeOffsetPointsBackward_ReturnsZero() /// /// Proves that CmapTable Format4 GlyphIndexAddressArithmeticOverflow ReturnsZero. /// + /// + /// This test formerly allocated a backing byte array roughly 2 GB in size (sized to + /// `tableOffset + declaredLength` with the subtable positioned near + /// ) purely to reach the format-4 indirect glyph-index + /// address computation with adversarial positions, risking exhausting memory or exceeding + /// the runtime's max object size before the code under test even ran - the same problem + /// already solved for KernTable's subtable-position arithmetic by + /// KernTable_Format0_BodyOffsetArithmeticOverflow_IsRejected. The overflow-prone + /// arithmetic itself lived only inside TryParseFormat4's returned lookup closure, + /// so it has been extracted (behavior-preserving) into the private static + /// ComputeFormat4GlyphIndexAddress(int, int, int, int, int, int, int) helper, which + /// takes only plain positions/values - no backing array at all is + /// needed to exercise it directly via reflection. + /// [Fact] public void CmapTable_Format4_GlyphIndexAddressArithmeticOverflow_ReturnsZero() { - // Arrange: choose a subtable position (near the top of what a real byte array can - // address, per Array.MaxLength) large enough that computing - // `idRangeOffsetOffset + i * 2 + idRangeOffsets[i] + 2 * (codepoint - startCodes[i])` - // with unwidened 32-bit `int` arithmetic wraps past `int.MaxValue` to a negative value, - // which would incorrectly pass the `glyphIndexAddress + 2 > tableEnd` bounds guard and - // then be used directly as a negative array index. The margin is provided by - // `idRangeOffsets[0]` (its 0xFFFF maximum), mirroring the technique used by - // KernTable_SubtableLengthOverflowsPosition_IsTolerant_ReturnsZero. Note that only - // the *positions* need to be this large; the declared cmap `tableLength` (and hence the - // portion of the array that is actually populated/read on the corrected, non-overflowing - // path) stays tiny, so the backing array itself only needs to be a little over - // `idRangeOffsetOffset` bytes - not anywhere near `int.MaxValue` bytes twice over. - const int segCount = 2; - const int segCountX2 = segCount * 2; - const int cmapHeaderSize = 12; - - // Field layout (relative to subtableOffset): format/length/language/segCountX2/ - // searchRange/entrySelector/rangeShift = 14 bytes, then endCodes(segCountX2) + - // reservedPad(2) + startCodes(segCountX2) + idDeltas(segCountX2) = idRangeOffsetOffset. - var idRangeOffsetOffset = Array.MaxLength - 30; - var subtableOffset = idRangeOffsetOffset - (14 + segCountX2 + 2 + segCountX2 + segCountX2); - var tableOffset = subtableOffset - cmapHeaderSize; - Assert.True(tableOffset >= 0, "test setup: tableOffset must be non-negative"); - - var declaredLength = subtableOffset - tableOffset + 14 + segCountX2 + 2 + segCountX2 + segCountX2 + segCountX2; - var data = new byte[tableOffset + declaredLength]; - - void WriteU16(int offset, int value) - { - data[offset] = (byte)((value >> 8) & 0xFF); - data[offset + 1] = (byte)(value & 0xFF); - } - - void WriteU32(int offset, long value) - { - data[offset] = (byte)((value >> 24) & 0xFF); - data[offset + 1] = (byte)((value >> 16) & 0xFF); - data[offset + 2] = (byte)((value >> 8) & 0xFF); - data[offset + 3] = (byte)(value & 0xFF); - } - - WriteU16(tableOffset, 0); // cmap version - WriteU16(tableOffset + 2, 1); // numTables - WriteU16(tableOffset + 4, 3); // platformId - WriteU16(tableOffset + 6, 1); // encodingId - WriteU32(tableOffset + 8, subtableOffset - tableOffset); // subtable offset, relative to cmap table start - - WriteU16(subtableOffset, 4); // format - WriteU16(subtableOffset + 2, declaredLength - (subtableOffset - tableOffset)); // declared length - WriteU16(subtableOffset + 4, 0); // language - WriteU16(subtableOffset + 6, segCountX2); - WriteU16(subtableOffset + 8, 0); // searchRange - WriteU16(subtableOffset + 10, 0); // entrySelector - WriteU16(subtableOffset + 12, 0); // rangeShift - - var endCodeOffset = subtableOffset + 14; - WriteU16(endCodeOffset, 0); // endCodes[0]: codepoint 0 maps via this segment - WriteU16(endCodeOffset + 2, 0xFFFF); // endCodes[1] (terminator) - - var startCodeOffset = endCodeOffset + segCountX2 + 2; - WriteU16(startCodeOffset, 0); // startCodes[0] - WriteU16(startCodeOffset + 2, 0xFFFF); // startCodes[1] - - var idDeltaOffset = startCodeOffset + segCountX2; - WriteU16(idDeltaOffset, 0); // idDeltas[0] - WriteU16(idDeltaOffset + 2, 1); // idDeltas[1] - - Assert.Equal(idRangeOffsetOffset, idDeltaOffset + segCountX2); - WriteU16(idRangeOffsetOffset, 0xFFFF); // idRangeOffsets[0]: maximal, to maximize the overflow margin - WriteU16(idRangeOffsetOffset + 2, 0); // idRangeOffsets[1] - - // Act: parse the cmap table and look up codepoint 0, which resolves to segment 0's - // indirect (idRangeOffset != 0) lookup path - var cmap = CmapTable.Parse(data, tableOffset, declaredLength); + // Arrange: choose an `idRangeOffsetOffset`/`idRangeOffset` pair whose sum, computed with + // unwidened 32-bit `int` arithmetic, wraps past `int.MaxValue` to a large negative value + // close to `int.MinValue`. Pairing that wrap with a `glyphIdArrayOffset` of `int.MinValue` + // and a `tableEnd` of `int.MaxValue` means the wrapped negative address is numerically + // *greater than* `glyphIdArrayOffset` and its `+2` is numerically *less than* `tableEnd` - + // so an unwidened implementation would incorrectly treat both bounds guards as satisfied + // and return the wrapped negative address for direct use as an array index, rather than + // rejecting it as the correctly-widened `long` computation does (the true sum exceeds + // `int.MaxValue` and therefore also exceeds `tableEnd`). + const int idRangeOffsetOffset = int.MaxValue - 10; + const int segmentIndex = 0; + const int idRangeOffset = 20; + const int codepoint = 0; + const int startCode = 0; + const int glyphIdArrayOffset = int.MinValue; + const int tableEnd = int.MaxValue; + + var method = typeof(CmapTable).GetMethod("ComputeFormat4GlyphIndexAddress", BindingFlags.NonPublic | BindingFlags.Static); + Assert.NotNull(method); + + // Act: invoke the glyph-index-address helper directly with the overflow-prone inputs + var result = method.Invoke( + null, [idRangeOffsetOffset, segmentIndex, idRangeOffset, codepoint, startCode, glyphIdArrayOffset, tableEnd]); // Assert: the overflow-prone address computation is rejected (rather than wrapping to a - // negative value that would be misread as in-bounds) and the lookup resolves to zero - Assert.Equal(0, cmap.GetGlyphIndex(0)); + // negative value that would be misread as in-bounds) so the helper returns null + Assert.Null(result); } /// @@ -734,6 +698,26 @@ public void CmapTable_Format12_NumGroupsExceedsCap_IsRejected() Assert.Equal(0, cmap.GetGlyphIndex(0)); } + /// + /// Proves that CmapTable Format12 GroupEndCodeExceedsMaxUnicodeCodepoint IsRejected. + /// + [Fact] + public void CmapTable_Format12_GroupEndCodeExceedsMaxUnicodeCodepoint_IsRejected() + { + // Arrange: build a format-12 cmap subtable with a single group whose endCharCode + // (0x110000) is one past U+10FFFF, the highest codepoint the Unicode standard defines + // (and the highest a UTF-16 surrogate pair can encode). No legitimate font declares + // coverage beyond this, so the group is malformed. + var table = SyntheticFontBuilder.CmapFormat12(3, 10, [(0x110000u, 0x110000u, 5u)]); + + // Act: parse the cmap table with the out-of-range group + var cmap = CmapTable.Parse(table, 0, table.Length); + + // Assert: the out-of-range group is rejected and the lookup resolves to the documented + // malformed-data fallback of glyph index zero, rather than the configured glyph index 5 + Assert.Equal(0, cmap.GetGlyphIndex(0x110000)); + } + /// /// Proves that CmapTable UnrecognizedSubtableFormat GetGlyphIndexReturnsZero. /// @@ -763,70 +747,46 @@ public void CmapTable_UnrecognizedSubtableFormat_GetGlyphIndexReturnsZero() /// /// Proves that CmapTable Format4 OffsetChainArithmeticOverflow ReturnsZero. /// + /// + /// This test formerly allocated a backing byte array roughly 2 GB in size + /// (`new byte[tableLength]` with `tableLength = offset + declaredSubtableLength` and + /// `offset` chosen near `int.MaxValue`), risking exhausting memory or exceeding the + /// runtime's max object size before the code under test even ran - the same problem + /// already solved for KernTable's subtable-position arithmetic by + /// KernTable_Format0_BodyOffsetArithmeticOverflow_IsRejected. The overflow-prone + /// offset-chain arithmetic itself lived inline in TryParseFormat4 rather than in an + /// isolated helper, so it has been extracted (behavior-preserving) into the private + /// static ComputeFormat4OffsetChain(int, int, int) helper, which takes only plain + /// positions/values - no backing array at all is needed to exercise + /// it directly via reflection. + /// [Fact] public void CmapTable_Format4_OffsetChainArithmeticOverflow_ReturnsZero() { - // Arrange: place a format-4 subtable at a position (`offset`) so large, combined with the - // maximal even `segCountX2` (65534), that computing + // Arrange: choose a subtable position (`offset`) so large, combined with the maximal even + // `segCountX2` (65534), that computing // `glyphIdArrayOffset = offset + 16 + 4 * segCountX2` with unwidened 32-bit `int` - // arithmetic overflows past `int.MaxValue` and wraps to a negative value. A decoder that - // only widens *some* of this offset chain (or none of it) would then incorrectly pass the + // arithmetic overflows past `int.MaxValue` and wraps to a negative value. A helper that + // only widens *some* of this offset chain (or none of it) would then incorrectly pass a // `glyphIdArrayOffset > tableEnd` bounds guard (a negative value is never greater than a - // huge positive `tableEnd`), and go on to read the segment arrays via offsets that run - // straight past the small, tightly-sized backing array - throwing - // `IndexOutOfRangeException` - rather than rejecting the subtable outright. The declared - // subtable `length` (16) and enclosing `tableLength` are both kept just large enough to - // cover the 14-byte subtable header (so the early `offset + 14 > tableEnd` guard passes - // and `segCountX2` is actually read), while the backing array itself is sized to match - - // not anywhere near the full `offset + glyphIdArrayOffset` span - so only `offset` itself, - // not the array, needs to approach `int.MaxValue`. + // huge positive `tableEnd`), producing corrupted array-index positions rather than + // rejecting the subtable outright. const int segCountX2 = 65534; // maximum valid (even) segCountX2 const int chainConstant = 16 + 4 * segCountX2; // offset -> glyphIdArrayOffset delta - // Chosen so `offset + chainConstant` exceeds `int.MaxValue` by a comfortable margin, - // while `offset` itself remains a valid, addressable position for a byte[] (well under - // Array.MaxLength). + // Chosen so `offset + chainConstant` exceeds `int.MaxValue` by a comfortable margin. var offset = int.MaxValue - chainConstant + 1000; - var declaredSubtableLength = 16; // covers only the 14-byte subtable header - var tableLength = offset + declaredSubtableLength; + const int tableEnd = 1000; // any small, unrelated subtable bound - var data = new byte[tableLength]; + var method = typeof(CmapTable).GetMethod("ComputeFormat4OffsetChain", BindingFlags.NonPublic | BindingFlags.Static); + Assert.NotNull(method); - void WriteU16(int position, int value) - { - data[position] = (byte)((value >> 8) & 0xFF); - data[position + 1] = (byte)(value & 0xFF); - } - - void WriteU32(int position, long value) - { - data[position] = (byte)((value >> 24) & 0xFF); - data[position + 1] = (byte)((value >> 16) & 0xFF); - data[position + 2] = (byte)((value >> 8) & 0xFF); - data[position + 3] = (byte)(value & 0xFF); - } - - WriteU16(0, 0); // cmap version - WriteU16(2, 1); // numTables - WriteU16(4, 3); // platformId - WriteU16(6, 1); // encodingId - WriteU32(8, offset); // subtable offset, relative to cmap table start (tableOffset == 0) - - WriteU16(offset, 4); // format - WriteU16(offset + 2, declaredSubtableLength); // declared length: header only - WriteU16(offset + 4, 0); // language - WriteU16(offset + 6, segCountX2); - WriteU16(offset + 8, 0); // searchRange - WriteU16(offset + 10, 0); // entrySelector - WriteU16(offset + 12, 0); // rangeShift - - // Act: parse the cmap table and look up any codepoint - var cmap = CmapTable.Parse(data, 0, tableLength); + // Act: invoke the offset-chain helper directly with the overflow-prone offset/segCountX2 + var result = method.Invoke(null, [offset, segCountX2, tableEnd]); // Assert: the overflow-prone offset chain is rejected (rather than wrapping to a value - // that incorrectly passes the bounds guard) and the lookup resolves to zero, without - // throwing - Assert.Equal(0, cmap.GetGlyphIndex(0)); + // that would incorrectly pass the bounds guard) so the helper returns null + Assert.Null(result); } /// diff --git a/test/DemaConsulting.CanvasNet.Tests/Fonts/KernTableTests.cs b/test/DemaConsulting.CanvasNet.Tests/Fonts/KernTableTests.cs index 2a5c7b44..730d9631 100644 --- a/test/DemaConsulting.CanvasNet.Tests/Fonts/KernTableTests.cs +++ b/test/DemaConsulting.CanvasNet.Tests/Fonts/KernTableTests.cs @@ -154,45 +154,41 @@ public void KernTable_UnsupportedFormat2Subtable_IsTolerant_ReturnsZero() /// /// Proves that KernTable SubtableLengthOverflowsPosition IsTolerant ReturnsZero. /// + /// + /// This test formerly allocated a ~2 GB backing byte array (`tableOffset + tableLength` + /// with `tableOffset` chosen near `int.MaxValue`) purely to reach the subtable-walking + /// loop's position arithmetic in with an adversarial + /// position, risking exhausting memory or exceeding the runtime's max object size before + /// the code under test even ran - mirroring the same problem already solved for + /// TryParseFormat0 by . + /// The overflow-prone position arithmetic itself was inline in Parse's + /// subtable-walking loop rather than in an isolated helper, so it has been extracted + /// (behavior-preserving) into the private static ComputeSubtableEnd(int, int, int) + /// helper, which takes only plain `int` positions/lengths - no backing array at all is + /// needed to exercise it directly via reflection. + /// [Fact] public void KernTable_SubtableLengthOverflowsPosition_IsTolerant_ReturnsZero() { - // Arrange: choose a huge table offset such that, computed with unwidened 32-bit `int` - // arithmetic, `pos + subtableLength` (pos = tableOffset + 4, subtableLength = 0xFFFF, the - // maximum ushort) wraps past int.MaxValue to a large negative value. That wrapped value - // would incorrectly pass the `subtableEnd > tableEnd` bounds guard, and - because the - // first subtable's coverage below is deliberately unsupported (format 2), so the loop - // advances `pos` to that corrupted, wrapped value - drive a subsequent read to a deeply - // out-of-bounds (negative) array index on the second declared subtable. + // Arrange: choose a huge `pos` such that, computed with unwidened 32-bit `int` + // arithmetic, `pos + subtableLength` (subtableLength = 0xFFFF, the maximum ushort) wraps + // past int.MaxValue to a large negative value. That wrapped value would incorrectly pass + // an unwidened `subtableEnd > tableEnd` bounds guard, letting the subtable-walking loop + // advance to a corrupted, deeply out-of-bounds position for a subsequent subtable. const int subtableLength = 0xFFFF; const long overflowSum = (long)int.MaxValue + 1; // wraps to int.MinValue in 32-bit arithmetic - var tableOffset = (int)(overflowSum - 4 - subtableLength); - const int tableLength = 20; + var pos = (int)(overflowSum - subtableLength); + const int tableEnd = 20; // any small, unrelated "enclosing table" bound - var data = new byte[tableOffset + tableLength]; - WriteUInt16BigEndian(data, tableOffset, 0); // kern table version - WriteUInt16BigEndian(data, tableOffset + 2, 2); // numSubtables: a second subtable is declared - WriteUInt16BigEndian(data, tableOffset + 4, 0); // subtable version (unused) - WriteUInt16BigEndian(data, tableOffset + 6, subtableLength); // subtable length: overflow-prone - WriteUInt16BigEndian(data, tableOffset + 8, 0x0201); // coverage: format 2 (unsupported), horizontal + var method = typeof(KernTable).GetMethod("ComputeSubtableEnd", BindingFlags.NonPublic | BindingFlags.Static); + Assert.NotNull(method); - // Act: parse the kern table with the crafted overflow-prone subtable length - var kern = KernTable.Parse(data, tableOffset, tableLength); + // Act: invoke the position-arithmetic helper directly with the overflow-prone pos/length + var result = method.Invoke(null, [pos, subtableLength, tableEnd]); // Assert: the overflowing subtable length is rejected (rather than wrapping into a - // corrupted position that would read out of bounds) and lookups resolve to zero - Assert.Equal(0, kern.GetKerning(3, 4)); - } - - /// - /// Writes a big-endian, unsigned 16-bit integer directly into a raw byte array at the - /// given offset (bypasses 's List<byte>-based - /// helpers, which are impractical for the near-2GB sparse array this test requires). - /// - private static void WriteUInt16BigEndian(byte[] data, int offset, int value) - { - data[offset] = (byte)((value >> 8) & 0xFF); - data[offset + 1] = (byte)(value & 0xFF); + // corrupted position that would be misread as in-bounds) so the helper returns null + Assert.Null(result); } /// diff --git a/test/DemaConsulting.CanvasNet.Tests/Fonts/TrueTypeFontTests.cs b/test/DemaConsulting.CanvasNet.Tests/Fonts/TrueTypeFontTests.cs index 41505863..b534711f 100644 --- a/test/DemaConsulting.CanvasNet.Tests/Fonts/TrueTypeFontTests.cs +++ b/test/DemaConsulting.CanvasNet.Tests/Fonts/TrueTypeFontTests.cs @@ -337,6 +337,43 @@ public void TrueTypeFont_GetKerning_OutOfRangeGlyphIndex_NeverThrows() Assert.Equal(0, result); } + /// + /// Proves that TrueTypeFont GetKerning GlyphIndexValidUshortButBeyondGlyphCount ReturnsZero. + /// + [Fact] + public void TrueTypeFont_GetKerning_GlyphIndexValidUshortButBeyondGlyphCount_ReturnsZero() + { + // Arrange: build a font with a small GlyphCount (2) whose kern table nonetheless carries + // a pair (100, 101) - both values are valid ushort glyph indices (so KernTable itself, + // which only bounds against ushort.MaxValue, would happily resolve them), but neither + // glyph exists in this particular font. + var glyph = SyntheticFontBuilder.SimpleGlyph( + [ + [(0, 0, true), (10, 0, true), (10, 10, true), (0, 10, true)] + ]); + var kern = SyntheticFontBuilder.KernFormat0([(100, 101, 42)]); + + var data = new SyntheticFontBuilder() + .AddTable("head", SyntheticFontBuilder.Head(1000, 0)) + .AddTable("maxp", SyntheticFontBuilder.Maxp(2)) + .AddTable("hhea", SyntheticFontBuilder.Hhea(800, -200, 50, 2)) + .AddTable("hmtx", SyntheticFontBuilder.Hmtx([0, 500])) + .AddTable("loca", SyntheticFontBuilder.Loca([0, glyph.Length], longFormat: false)) + .AddTable("glyf", glyph) + .AddTable("kern", kern) + .Build(); + + var font = TrueTypeFont.Load(new MemoryStream(data)); + + // Act: query kerning for the pair recorded in the kern table + var result = font.GetKerning(100, 101); + + // Assert: even though the pair is present in the kern table and both indices are valid + // ushort values, neither glyph exists in this font (GlyphCount is 2), so the result is + // zero rather than the recorded kerning value of 42 + Assert.Equal(0, result); + } + /// /// Proves that TrueTypeFont GetGlyphIndex UnmappedCodepoint NeverThrows. /// From baa7c67488468c3b27f0682d62178c78cfb42133 Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Tue, 22 Sep 2026 17:11:50 -0400 Subject: [PATCH 17/17] Link new point-budget requirement into GlyphOutlineExtraction children The requirement added for the total point/command budget was not referenced from its parent's children list, leaving it unreachable from the quality/system-tagged root and failing reqstream's orphaned-requirement check. --- docs/reqstream/canvas-net/fonts.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/reqstream/canvas-net/fonts.yaml b/docs/reqstream/canvas-net/fonts.yaml index 022f4c0a..d64ec3b5 100644 --- a/docs/reqstream/canvas-net/fonts.yaml +++ b/docs/reqstream/canvas-net/fonts.yaml @@ -75,6 +75,7 @@ sections: - CanvasNet-Fonts-TrueTypeFont-GlyphOutlineNestedComposite - CanvasNet-Fonts-TrueTypeFont-GlyphOutlineRejectsCircularComposite - CanvasNet-Fonts-TrueTypeFont-GlyphOutlineRejectsExcessiveComposite + - CanvasNet-Fonts-TrueTypeFont-GlyphOutlineRejectsExcessivePoints - CanvasNet-Fonts-TrueTypeFont-GlyphOutlineRejectsPointMatchedComponent - CanvasNet-Fonts-TrueTypeFont-GlyphOutlineRejectsMalformedGlyphData - CanvasNet-Fonts-TrueTypeFont-GlyphIndexValidation