diff --git a/.reviewmark.yaml b/.reviewmark.yaml index 32ed6522..cd8a21d3 100644 --- a/.reviewmark.yaml +++ b/.reviewmark.yaml @@ -378,6 +378,46 @@ 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/Fonts/TrueTypeFontRealFontIntegrationTests.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..da4f7f0b --- /dev/null +++ b/docs/design/canvas-net/fonts/true-type-font.md @@ -0,0 +1,335 @@ +### 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. 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 + +###### 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 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 + +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..d64ec3b5 --- /dev/null +++ b/docs/reqstream/canvas-net/fonts.yaml @@ -0,0 +1,102 @@ +--- +# 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-GlyphOutlineRejectsExcessivePoints + - 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..631c2a1c --- /dev/null +++ b/docs/reqstream/canvas-net/fonts/true-type-font.yaml @@ -0,0 +1,393 @@ +--- +# 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 + - GlyfLocaReader_CompositeGlyph_ScaledComponentOffset_TransformsTranslation + + - 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-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 + 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..8282b0f5 --- /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/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/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..f5c3892c --- /dev/null +++ b/docs/verification/canvas-net/fonts.md @@ -0,0 +1,67 @@ +## 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 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 + +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..323bd6c6 --- /dev/null +++ b/docs/verification/canvas-net/fonts/true-type-font.md @@ -0,0 +1,287 @@ +### 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 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 +on real-world glyph data, complementing (rather than replacing) the synthetic-fixture coverage +above. + +#### 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`, +`GlyfLocaReader_CompositeGlyph_ScaledComponentOffset_TransformsTranslation` + +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 + +**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-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` + +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 new file mode 100644 index 00000000..d49abc6e --- /dev/null +++ b/src/DemaConsulting.CanvasNet/Fonts/CmapTable.cs @@ -0,0 +1,507 @@ +// 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 unwidened +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 +{ + /// + /// 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. + /// + 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 || (long)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)); + + 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, subtableEnd.Value) + : TryParseFormat4(data, candidate.SubtableOffset, subtableEnd.Value); + + if (lookup != null) + { + return new CmapTable(lookup); + } + } + + 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. + /// + /// 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 ((long)offset + 14 > tableEnd) + { + return null; + } + + 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) + { + return null; + } + + // 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 offsetChain = ComputeFormat4OffsetChain(offset, segCountX2, tableEnd); + if (offsetChain == null) + { + return null; + } + + var (endCodeOffset, startCodeOffset, idDeltaOffset, idRangeOffsetOffset, glyphIdArrayOffset) = offsetChain.Value; + + 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); + } + + // Validate the required format-4 segment ordering: each segment's startCode must not + // 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]) + { + return null; + } + + if (i > 0 && (endCodes[i - 1] >= endCodes[i] || startCodes[i] <= endCodes[i - 1])) + { + return null; + } + } + + if (endCodes[segCount - 1] != 0xFFFF || startCodes[segCount - 1] != 0xFFFF) + { + return null; + } + + 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 = ComputeFormat4GlyphIndexAddress( + idRangeOffsetOffset, i, idRangeOffsets[i], codepoint, startCodes[i], glyphIdArrayOffset, tableEnd); + if (glyphIndexAddress == null) + { + return 0; + } + + var glyphId = SfntContainer.ReadUInt16(data, (int)glyphIndexAddress.Value); + return glyphId == 0 ? 0 : (glyphId + idDeltas[i]) & 0xFFFF; + } + + return 0; + }; + } + + /// + /// 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. + /// + private static Func? TryParseFormat12(byte[] data, int offset, int tableEnd) + { + if ((long)offset + 16 > tableEnd) + { + return null; + } + + 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]; + 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); + } + + // 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; + } + + // 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 => + { + 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 + { + // 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; + } + } + + return 0; + }; + } +} diff --git a/src/DemaConsulting.CanvasNet/Fonts/GlyfLocaReader.cs b/src/DemaConsulting.CanvasNet/Fonts/GlyfLocaReader.cs new file mode 100644 index 00000000..13dd3d8e --- /dev/null +++ b/src/DemaConsulting.CanvasNet/Fonts/GlyfLocaReader.cs @@ -0,0 +1,590 @@ +// 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; + +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. +/// +/// +/// 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 +{ + /// + /// 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; + + /// + /// 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; + 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; + 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, 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) + { + if (glyphIndex < 0 || glyphIndex >= GlyphCount) + { + throw new ArgumentOutOfRangeException(nameof(glyphIndex), glyphIndex, "Glyph index is out of range."); + } + + var totalComponents = 0; + var totalPoints = 0; + return DecodeGlyph(glyphIndex, 0, ref totalComponents, ref totalPoints); + } + + /// + /// 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, ref int totalPoints) + { + 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, ref totalPoints); + } + + if (numberOfContours == -1) + { + 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 , charging its point + /// count against before allocating any per-point arrays. + /// + private Path DecodeSimpleGlyph(int glyphOffset, int glyphLength, int numberOfContours, ref int totalPoints) + { + 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; + + // 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; + 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++]; + 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; + } + } + } + + 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, ref int totalPoints) + { + 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, 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 + // 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, ref totalPoints); + 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, 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, 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)); + 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..dac350a2 --- /dev/null +++ b/src/DemaConsulting.CanvasNet/Fonts/HmtxHheaReader.cs @@ -0,0 +1,115 @@ +// 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; + +/// +/// 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..048b6c71 --- /dev/null +++ b/src/DemaConsulting.CanvasNet/Fonts/KernTable.cs @@ -0,0 +1,218 @@ +// 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 unwidened +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 ((long)pos + 6 > tableEnd) + { + return Empty; + } + + var subtableLength = SfntContainer.ReadUInt16(data, pos + 2); + var coverage = SfntContainer.ReadUInt16(data, pos + 4); + var subtableEnd = ComputeSubtableEnd(pos, subtableLength, tableEnd); + if (subtableEnd == null) + { + 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.Value); + if (pairs != null) + { + return new KernTable(pairs); + } + + return Empty; + } + + 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. + /// + /// 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 ((long)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..52a816a5 --- /dev/null +++ b/src/DemaConsulting.CanvasNet/Fonts/NamespaceDoc.cs @@ -0,0 +1,22 @@ +// 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; + +/// +/// 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..cf796b91 --- /dev/null +++ b/src/DemaConsulting.CanvasNet/Fonts/SfntContainer.cs @@ -0,0 +1,196 @@ +// 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; + +/// +/// 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..ff134c48 --- /dev/null +++ b/src/DemaConsulting.CanvasNet/Fonts/TrueTypeFont.cs @@ -0,0 +1,318 @@ +// 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; + +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. + /// 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) + { + var glyphIndex = _cmap.GetGlyphIndex(codepoint); + return glyphIndex < 0 || glyphIndex >= GlyphCount ? 0 : glyphIndex; + } + + /// + /// 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), 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) + { + 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, 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) + { + // 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 + /// . + /// + 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."); + } + + 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); + 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); + } +} diff --git a/test/DemaConsulting.CanvasNet.Tests/CanvasNetTests.cs b/test/DemaConsulting.CanvasNet.Tests/CanvasNetTests.cs index f3090b3c..e636a999 100644 --- a/test/DemaConsulting.CanvasNet.Tests/CanvasNetTests.cs +++ b/test/DemaConsulting.CanvasNet.Tests/CanvasNetTests.cs @@ -1,8 +1,13 @@ +// 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; using DemaConsulting.CanvasNet.Drawing; +using DemaConsulting.CanvasNet.Fonts; using DemaConsulting.CanvasNet.Geometry; +using DemaConsulting.CanvasNet.Tests.TestSupport; namespace DemaConsulting.CanvasNet.Tests; @@ -395,4 +400,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/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 00000000..134d225f Binary files /dev/null and b/test/DemaConsulting.CanvasNet.Tests/FontFixtures/OpenSans-Regular.ttf differ diff --git a/test/DemaConsulting.CanvasNet.Tests/FontFixtures/OpenSans.LICENSE b/test/DemaConsulting.CanvasNet.Tests/FontFixtures/OpenSans.LICENSE new file mode 100644 index 00000000..cb7002a8 --- /dev/null +++ b/test/DemaConsulting.CanvasNet.Tests/FontFixtures/OpenSans.LICENSE @@ -0,0 +1,93 @@ +Copyright 2020 The Open Sans Project Authors (https://github.com/googlefonts/opensans) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/test/DemaConsulting.CanvasNet.Tests/FontFixtures/README.md b/test/DemaConsulting.CanvasNet.Tests/FontFixtures/README.md new file mode 100644 index 00000000..5a76bc1a --- /dev/null +++ b/test/DemaConsulting.CanvasNet.Tests/FontFixtures/README.md @@ -0,0 +1,13 @@ +# Font Test Fixtures + +`OpenSans-Regular.ttf` is the real, production "Open Sans" TrueType font (Regular weight), +sourced from the [Open Sans](https://www.opensans.com/) project and licensed under the +[SIL Open Font License, Version 1.1](https://openfontlicense.org/) - see `OpenSans.LICENSE` +(the license's full text, as required for redistribution) alongside it in this folder. + +It is used only by real-world integration tests that exercise `TrueTypeFont` against an actual +production font file, complementing the hand-rolled synthetic fixtures built by +`TestSupport/SyntheticFontBuilder` for `Fonts` unit tests. The OFL permits embedding/bundling and +redistributing the font with software; it may not be sold standalone, and a modified version may +not use the "Open Sans" name - neither restriction applies to this repository's use of the font +as an unmodified, bundled test fixture. diff --git a/test/DemaConsulting.CanvasNet.Tests/Fonts/CmapTableTests.cs b/test/DemaConsulting.CanvasNet.Tests/Fonts/CmapTableTests.cs new file mode 100644 index 00000000..20a5d58a --- /dev/null +++ b/test/DemaConsulting.CanvasNet.Tests/Fonts/CmapTableTests.cs @@ -0,0 +1,813 @@ +// 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 unrejected +using System.Reflection; +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() + { + // 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)); + } + + /// + /// Proves that CmapTable Format4 UnmappedCodepoint ReturnsZero. + /// + [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)); + } + + /// + /// Proves that CmapTable Format12 SupplementaryPlaneLookup ReturnsMappedGlyphIndex. + /// + [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)); + } + + /// + /// Proves that CmapTable Format12 GroupRange ReturnsOffsetGlyphIndex. + /// + [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)); + } + + /// + /// Proves that CmapTable Platform3Encoding10Format12 HighestPriority ReturnsMappedGlyphIndex. + /// + [Fact] + public void CmapTable_Platform3Encoding10Format12_HighestPriority_ReturnsMappedGlyphIndex() + { + // 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)); + } + + /// + /// Proves that CmapTable NoSupportedSubtable GetGlyphIndexReturnsZero. + /// + [Fact] + public void CmapTable_NoSupportedSubtable_GetGlyphIndexReturnsZero() + { + // 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)); + } + + /// + /// Proves that CmapTable Empty 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)); + } + + /// + /// Proves that CmapTable TruncatedTable 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)); + } + + /// + /// Proves that CmapTable MalformedSubtableOffset 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 + 0, 1, // numTables + 0, 3, // platformId + 0, 1, // encodingId + }; + 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)); + } + + /// + /// 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 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 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 - 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, 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, 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(); + 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 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. + /// + [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. + /// + /// + /// 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 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) so the helper returns null + Assert.Null(result); + } + + /// + /// 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 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 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. + /// + [Fact] + public void CmapTable_UnrecognizedSubtableFormat_GetGlyphIndexReturnsZero() + { + // 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 + 0, 1, // numTables + 0, 3, // platformId + 0, 1, // encodingId + }; + 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)); + } + + /// + /// 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: 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 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`), 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. + var offset = int.MaxValue - chainConstant + 1000; + const int tableEnd = 1000; // any small, unrelated subtable bound + + var method = typeof(CmapTable).GetMethod("ComputeFormat4OffsetChain", BindingFlags.NonPublic | BindingFlags.Static); + Assert.NotNull(method); + + // 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 would incorrectly pass the bounds guard) so the helper returns null + Assert.Null(result); + } + + /// + /// 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)); + } +} diff --git a/test/DemaConsulting.CanvasNet.Tests/Fonts/GlyfLocaReaderTests.cs b/test/DemaConsulting.CanvasNet.Tests/Fonts/GlyfLocaReaderTests.cs new file mode 100644 index 00000000..4005b4b5 --- /dev/null +++ b/test/DemaConsulting.CanvasNet.Tests/Fonts/GlyfLocaReaderTests.cs @@ -0,0 +1,607 @@ +// 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; + +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() + { + // 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); + 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() + { + // 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)); + } + + /// + /// Proves that GlyfLocaReader SimpleGlyph MixedOnAndOffCurve ProducesQuadraticToRealOnCurvePoint. + /// + [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); + } + + /// + /// 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. + /// + [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); + } + + /// + /// Proves that GlyfLocaReader ZeroLengthGlyph ProducesEmptyPath. + /// + [Fact] + public void GlyfLocaReader_ZeroLengthGlyph_ProducesEmptyPath() + { + // 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); + } + + /// + /// Proves that GlyfLocaReader LongLocaFormat DecodesSameAsShortFormat. + /// + [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); + } + + /// + /// Proves that GlyfLocaReader GetGlyphOutline NegativeGlyphIndex ThrowsArgumentOutOfRangeException. + /// + [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)); + } + + /// + /// Proves that GlyfLocaReader GetGlyphOutline GlyphIndexTooLarge ThrowsArgumentOutOfRangeException. + /// + [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)); + } + + /// + /// Proves that GlyfLocaReader CompositeGlyph SingleComponent ProducesTranslatedOutline. + /// + [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)] + ]); + var composite = SyntheticFontBuilder.CompositeGlyph( + [ + 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); + } + + /// + /// Proves that GlyfLocaReader CompositeGlyph ScaledComponent ScalesOutline. + /// + [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)] + ]); + var composite = SyntheticFontBuilder.CompositeGlyph( + [ + 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); + Assert.Equal(15f, lineTo.EndPoint.X, 3); + } + + /// + /// Proves that GlyfLocaReader CompositeGlyph TwoByTwoTransform TransformsOutline. + /// + [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)] + ]); + 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]; + Assert.Equal(0f, lineTo.EndPoint.X, 2); + 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. + /// + [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)] + ]); + var inner = SyntheticFontBuilder.CompositeGlyph( + [ + new SyntheticFontBuilder.CompositeComponent(0, 5, 5) + ]); + var outer = SyntheticFontBuilder.CompositeGlyph( + [ + 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); + } + + /// + /// Proves that GlyfLocaReader CompositeGlyph MultipleComponents ProducesMultipleSubpaths. + /// + [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)] + ]); + var composite = SyntheticFontBuilder.CompositeGlyph( + [ + new SyntheticFontBuilder.CompositeComponent(0, 0, 0), + 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); + } + + /// + /// Proves that GlyfLocaReader CompositeGlyph PointMatchedComponent ThrowsInvalidDataException. + /// + [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)] + ]); + var composite = SyntheticFontBuilder.CompositeGlyph( + [ + 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)); + } + + /// + /// Proves that GlyfLocaReader CompositeGlyph OutOfRangeComponentGlyphIndex ThrowsInvalidDataException. + /// + [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)); + } + + /// + /// Proves that GlyfLocaReader CompositeGlyph SelfReferentialCycle ThrowsInvalidDataExceptionViaDepthCap. + /// + [Fact] + public void GlyfLocaReader_CompositeGlyph_SelfReferentialCycle_ThrowsInvalidDataExceptionViaDepthCap() + { + // 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); + } + + /// + /// Proves that GlyfLocaReader CompositeGlyph ExponentialBlowUp ThrowsInvalidDataExceptionViaComponentCap. + /// + [Fact] + public void GlyfLocaReader_CompositeGlyph_ExponentialBlowUp_ThrowsInvalidDataExceptionViaComponentCap() + { + // 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) => + 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) + + // 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); + } + + /// + /// 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. + /// + [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)); + } + + /// + /// Proves that GlyfLocaReader Parse NonMonotonicLocaEntries ThrowsInvalidDataException. + /// + [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 + 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); + + // Act/Assert: parsing the non-monotonic loca table throws + 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() + { + // 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)); + } + + /// + /// Proves that GlyfLocaReader TruncatedGlyphHeader 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)); + } + + /// + /// Proves that GlyfLocaReader InvalidContourCount 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++) + { + SyntheticFontBuilder.WriteInt16(buf, 0); + } + + 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 new file mode 100644 index 00000000..63794254 --- /dev/null +++ b/test/DemaConsulting.CanvasNet.Tests/Fonts/HmtxHheaReaderTests.cs @@ -0,0 +1,118 @@ +// 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; + +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() + { + // 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); + 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() + { + // 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)); + } + + /// + /// Proves that HmtxHheaReader Parse TruncatedHhea ThrowsInvalidDataException. + /// + [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)); + } + + /// + /// Proves that HmtxHheaReader Parse NumOfLongHorMetricsZero 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)); + } + + /// + /// Proves that HmtxHheaReader Parse NumOfLongHorMetricsExceedsNumGlyphs ThrowsInvalidDataException. + /// + [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)); + } + + /// + /// Proves that HmtxHheaReader Parse TruncatedHmtx ThrowsInvalidDataException. + /// + [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 new file mode 100644 index 00000000..730d9631 --- /dev/null +++ b/test/DemaConsulting.CanvasNet.Tests/Fonts/KernTableTests.cs @@ -0,0 +1,254 @@ +// 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 System.Reflection; +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() + { + // 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)); + } + + /// + /// Proves that KernTable Format0 UnknownPair ReturnsZero. + /// + [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)); + } + + /// + /// Proves that KernTable Empty GetKerningReturnsZero. + /// + [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)); + } + + /// + /// Proves that KernTable NoPairs ReturnsZero. + /// + [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)); + } + + /// + /// Proves that KernTable ZeroLengthTable IsTolerant 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)); + } + + /// + /// Proves that KernTable TruncatedTable 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)); + } + + /// + /// Proves that KernTable MalformedPairCount 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 + 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); + + // 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)); + } + + /// + /// Proves that KernTable UnsupportedFormat2Subtable 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 + SyntheticFontBuilder.WriteUInt16(buf, 0); // subtable version + 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)); + } + + /// + /// 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 `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 pos = (int)(overflowSum - subtableLength); + const int tableEnd = 20; // any small, unrelated "enclosing table" bound + + var method = typeof(KernTable).GetMethod("ComputeSubtableEnd", BindingFlags.NonPublic | BindingFlags.Static); + Assert.NotNull(method); + + // 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 be misread as in-bounds) so the helper returns null + Assert.Null(result); + } + + /// + /// 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. + /// + [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 + SyntheticFontBuilder.WriteUInt16(buf, 0); // subtable version + 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 new file mode 100644 index 00000000..6df6551b --- /dev/null +++ b/test/DemaConsulting.CanvasNet.Tests/Fonts/SfntContainerTests.cs @@ -0,0 +1,262 @@ +// 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; + +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() + { + // 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 _)); + Assert.False(container.TryGetTable("nope", out _)); + } + + /// + /// Proves that SfntContainer Parse MissingRequiredTable ThrowsInvalidDataException. + /// + [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")); + } + + /// + /// Proves that SfntContainer Parse TruncatedOffsetTable 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)); + } + + /// + /// Proves that SfntContainer Parse TruncatedTableDirectory 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 + SyntheticFontBuilder.WriteUInt16(buf, 0); + SyntheticFontBuilder.WriteUInt16(buf, 0); + SyntheticFontBuilder.WriteUInt16(buf, 0); + + // Act/Assert: parsing the truncated table directory throws + Assert.Throws(() => SfntContainer.Parse([.. buf])); + } + + /// + /// Proves that SfntContainer Parse OttoVersion ThrowsInvalidDataException. + /// + [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); + } + + /// + /// Proves that SfntContainer Parse MacTrueVersion Succeeds. + /// + [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 _)); + } + + /// + /// Proves that SfntContainer Parse UnrecognizedVersion ThrowsInvalidDataException. + /// + [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)); + } + + /// + /// Proves that SfntContainer Parse TableDirectoryEntryOverflowsUint ThrowsInvalidDataException. + /// + [Fact] + public void SfntContainer_Parse_TableDirectoryEntryOverflowsUint_ThrowsInvalidDataException() + { + // 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 + // 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 + + // Act/Assert: parsing the overflowing table directory entry throws + Assert.Throws(() => SfntContainer.Parse([.. buf])); + } + + /// + /// Proves that SfntContainer Parse TableDirectoryEntryOutOfBounds ThrowsInvalidDataException. + /// + [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); + 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); + + // Act/Assert: parsing the out-of-bounds table directory entry throws + 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) + { + // 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); + } + + /// + /// Proves that SfntContainer ReadInt16 BigEndianRegardlessOfHostEndianness ReturnsExpectedValue. + /// + [Theory] + [InlineData(short.MinValue)] + [InlineData((short)-1)] + [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); + } + + /// + /// Proves that SfntContainer ReadUInt32 BigEndianRegardlessOfHostEndianness ReturnsExpectedValue. + /// + [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); + } + + /// + /// Proves that SfntContainer ReadF2Dot14 ReturnsExpectedValue. + /// + [Theory] + [InlineData(1.0f)] + [InlineData(-1.0f)] + [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/TrueTypeFontRealFontIntegrationTests.cs b/test/DemaConsulting.CanvasNet.Tests/Fonts/TrueTypeFontRealFontIntegrationTests.cs new file mode 100644 index 00000000..b7b93979 --- /dev/null +++ b/test/DemaConsulting.CanvasNet.Tests/Fonts/TrueTypeFontRealFontIntegrationTests.cs @@ -0,0 +1,147 @@ +// 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.Drawing; +using DemaConsulting.CanvasNet.Fonts; +using DemaConsulting.CanvasNet.Geometry; +using GlyphPath = DemaConsulting.CanvasNet.Geometry.Path; + +namespace DemaConsulting.CanvasNet.Tests.Fonts; + +/// +/// 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(); + } +} diff --git a/test/DemaConsulting.CanvasNet.Tests/Fonts/TrueTypeFontTests.cs b/test/DemaConsulting.CanvasNet.Tests/Fonts/TrueTypeFontTests.cs new file mode 100644 index 00000000..b534711f --- /dev/null +++ b/test/DemaConsulting.CanvasNet.Tests/Fonts/TrueTypeFontTests.cs @@ -0,0 +1,441 @@ +// 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; + +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() + { + // 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); + Assert.Equal(50, font.LineGap); + Assert.Equal(2, font.GlyphCount); + } + + /// + /// Proves that TrueTypeFont Load EndToEnd GlyphIndexOutlineAdvanceAndKerning. + /// + [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); + Assert.Equal(42, kerning); + } + + /// + /// Proves that TrueTypeFont Load Path ReadsFromFile. + /// + [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 + { + File.Delete(path); + } + } + + /// + /// Proves that TrueTypeFont Load NullStream ThrowsArgumentNullException. + /// + [Fact] + public void TrueTypeFont_Load_NullStream_ThrowsArgumentNullException() + { + // Arrange/Act/Assert: loading from a null stream throws + Assert.Throws(() => TrueTypeFont.Load((Stream)null!)); + } + + /// + /// Proves that TrueTypeFont Load NullPath ThrowsArgumentNullException. + /// + [Fact] + public void TrueTypeFont_Load_NullPath_ThrowsArgumentNullException() + { + // Arrange/Act/Assert: loading from a null path throws + Assert.Throws(() => TrueTypeFont.Load((string)null!)); + } + + /// + /// Proves that TrueTypeFont Load EmptyPath ThrowsArgumentException. + /// + [Fact] + public void TrueTypeFont_Load_EmptyPath_ThrowsArgumentException() + { + // Arrange/Act/Assert: loading from an empty path throws + Assert.Throws(() => TrueTypeFont.Load(string.Empty)); + } + + /// + /// Proves that TrueTypeFont Load MissingRequiredTable ThrowsInvalidDataException. + /// + [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))); + } + + /// + /// Proves that TrueTypeFont Load OttoFont 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))); + } + + /// + /// Proves that TrueTypeFont Load MaxpVersion05 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)) + .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(); + + // Act/Assert: loading the font with the unsupported maxp version throws + 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. + /// + [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)) + .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(); + + // Act/Assert: loading the font with zero unitsPerEm throws + Assert.Throws(() => TrueTypeFont.Load(new MemoryStream(data))); + } + + /// + /// Proves that TrueTypeFont Load InvalidIndexToLocFormat 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 + .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(); + + // Act/Assert: loading the font with the invalid indexToLocFormat throws + Assert.Throws(() => TrueTypeFont.Load(new MemoryStream(data))); + } + + /// + /// Proves that TrueTypeFont Load NoCmapOrKern FallsBackGracefully. + /// + [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)) + .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(); + + // 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)); + } + + /// + /// Proves that TrueTypeFont GetGlyphOutline NegativeGlyphIndex ThrowsArgumentOutOfRangeException. + /// + [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)); + } + + /// + /// Proves that TrueTypeFont GetGlyphOutline GlyphIndexTooLarge ThrowsArgumentOutOfRangeException. + /// + [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)); + } + + /// + /// Proves that TrueTypeFont GetAdvanceWidth GlyphIndexTooLarge ThrowsArgumentOutOfRangeException. + /// + [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)); + } + + /// + /// Proves that TrueTypeFont GetKerning OutOfRangeGlyphIndex NeverThrows. + /// + [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); + } + + /// + /// 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. + /// + [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); + } + + /// + /// 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. + /// + [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))); + } +} diff --git a/test/DemaConsulting.CanvasNet.Tests/TestSupport/SyntheticFontBuilder.cs b/test/DemaConsulting.CanvasNet.Tests/TestSupport/SyntheticFontBuilder.cs new file mode 100644 index 00000000..8b4a1ac6 --- /dev/null +++ b/test/DemaConsulting.CanvasNet.Tests/TestSupport/SyntheticFontBuilder.cs @@ -0,0 +1,587 @@ +// 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; + +/// +/// 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 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); // 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]; + } + + /// + /// 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]; + } + + /// + /// 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 + /// (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, + bool ScaledComponentOffset = false, + bool UnscaledComponentOffset = 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; + } + + 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); + 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, 16 + segCount * 8); // length: declared subtable length, matching the encoding below + 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, (uint)(16 + groups.Count * 12)); // length: declared subtable length, matching the encoding below + 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]; + } +}