diff --git a/.cspell.yaml b/.cspell.yaml index 51347e89..564f05a8 100644 --- a/.cspell.yaml +++ b/.cspell.yaml @@ -17,6 +17,7 @@ words: - Aabb - AABB - Adler + - AESV - AGG - apimark - ApiMark @@ -24,6 +25,24 @@ words: - Beziers - BGRA - Blinn + - closepath + - componentwise + - renderable + - unparseable + - endobj + - endstream + - Flate + - fonttools + - MACROMAN + - mediabox + - MCID + - Parms + - panose + - rasterizing + - startxref + - Startxref + - SYNT + - undecoded - BITMAPCOREHEADER - BITMAPFILEHEADER - BITMAPINFOHEADER @@ -39,6 +58,7 @@ words: - Casteljau - chokepoint - CMYK + - CCITT - coeffs - combinators - CSUM @@ -57,6 +77,7 @@ words: - deinterleaved - desaturated - desaturates + - desyncs - downsamples - DEMA - Dema @@ -79,7 +100,9 @@ words: - IEND - IHDR - Inkscape + - inspectable - interoperating + - JBIG - JFIF - Lambertian - lerp @@ -91,6 +114,7 @@ words: - misdecoded - nameof - nonzero + - NOTAFONT - NOTAGIF - Outliner - Plte @@ -165,6 +189,8 @@ words: - Unstroked - unduplicated - rects + - asciihex + - runlength - letterboxing - Unpremultiply - unioning @@ -202,6 +228,80 @@ words: - stylesheet - Stylesheet - tryparse + - callgsubr + - callsubr + - Charstring + - charstring + - cntrmask + - Endchar + - endchar + - hhcurveto + - hintmask + - hlineto + - hmoveto + - hstem + - hstemhm + - hvcurveto + - lineto + - Lineto + - misalign + - moveto + - Moveto + - rcurveline + - rlinecurve + - rlineto + - rmoveto + - rrcurveto + - Seac + - seac + - Sfnt + - SFNT + - Subr + - Subrs + - ttcf + - vhcurveto + - vlineto + - vmoveto + - vstem + - vstemhm + - vvcurveto + - hflex + - eexec + - Eexec + - hsbw + - Hsbw + - sbw + - dotsection + - Dotsection + - lenIV + - cleartomark + - ncleartomark + - ncurrentfile + - congruential + - codepoints + - charstrings + - ciphertext + - reindexing + - exclam + - quotedbl + - numbersign + - quotesingle + - parenleft + - parenright + - bracketleft + - bracketright + - asciicircum + - braceleft + - braceright + - asciitilde + - nacute + - Nacute + - CharProcs + - Procs + - PRGA + - decryptor + - Decryptor + - keystream # Exclude common build artifacts, dependencies, and vendored third-party code ignorePaths: @@ -219,3 +319,5 @@ ignorePaths: - "**/obj/**" - package-lock.json - "**/PngSuite/**" + - "src/DemaConsulting.CanvasNet/Fonts/BundledFonts/OFL.txt" + - "src/DemaConsulting.CanvasNet/Fonts/BundledFonts/NotoFonts-OFL.txt" diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 5d71e206..1d4805fe 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -229,6 +229,8 @@ jobs: src/DemaConsulting.CanvasNet/bin/Release/*.snupkg src/DemaConsulting.CanvasNet.Svg/bin/Release/*.nupkg src/DemaConsulting.CanvasNet.Svg/bin/Release/*.snupkg + src/DemaConsulting.CanvasNet.Pdf/bin/Release/*.nupkg + src/DemaConsulting.CanvasNet.Pdf/bin/Release/*.snupkg # Runs CodeQL security and quality analysis, gathering results to include # in the code quality report. diff --git a/.reviewmark.yaml b/.reviewmark.yaml index 55f45cbd..6e9965fe 100644 --- a/.reviewmark.yaml +++ b/.reviewmark.yaml @@ -9,6 +9,8 @@ needs-review: - "README.md" # Root README file - "requirements.yaml" # Root requirements file - "**/*.cs" # All C# source and test files + - "**/*.csproj" # All project files (licensing/provenance, embedded resources) + - "**/README.md" # All nested README files (licensing/provenance, fixtures) - "docs/reqstream/**/*.yaml" # Requirements files - "docs/design/**/*.md" # Design documentation files - "docs/verification/**/*.md" # Verification documentation files @@ -437,6 +439,31 @@ reviews: - "test/DemaConsulting.CanvasNet.Tests/Drawing/ScanlineRasterizerTests.cs" # gradient rasterization tests - "test/DemaConsulting.CanvasNet.Tests/Canvas/SurfaceTests.cs" # per-pixel-color CompositeOverSpan tests + # CanvasNet-Drawing-TilePaint Review (one per unit) + - id: CanvasNet-Drawing-TilePaint + title: Review that CanvasNet Drawing TilePaint Implementation is Correct + context: + - "docs/design/canvas-net.md" + - "docs/reqstream/canvas-net.yaml" + - "docs/design/canvas-net/drawing.md" + - "docs/reqstream/canvas-net/drawing.yaml" + - "docs/design/canvas-net/drawing/path-filler.md" + - "docs/reqstream/canvas-net/drawing/path-filler.yaml" + - "docs/design/canvas-net/drawing/gradient-paint.md" + - "docs/reqstream/canvas-net/drawing/gradient-paint.yaml" + paths: + - "docs/reqstream/canvas-net/drawing/tile-paint.yaml" # unit requirements + - "docs/design/canvas-net/drawing/tile-paint.md" # unit design + - "docs/verification/canvas-net/drawing/tile-paint.md" # unit verification + - "src/DemaConsulting.CanvasNet/Drawing/TilePaint.cs" # public tile paint source + - "src/DemaConsulting.CanvasNet/Drawing/TilePaintEvaluator.cs" # internal helper source + - "src/DemaConsulting.CanvasNet/Drawing/PathFiller.cs" # tile Fill overload + - "src/DemaConsulting.CanvasNet/Drawing/ScanlineRasterizer.cs" # tile-aware rasterization + - "test/DemaConsulting.CanvasNet.Tests/Drawing/TilePaintTests.cs" # unit tests + - "test/DemaConsulting.CanvasNet.Tests/Drawing/TilePaintEvaluatorTests.cs" # unit tests + - "test/DemaConsulting.CanvasNet.Tests/Drawing/PathFillerTests.cs" # tile Fill overload tests + - "test/DemaConsulting.CanvasNet.Tests/Drawing/ScanlineRasterizerTests.cs" # tile rasterization tests + # CanvasNet-Fonts Review (one per subsystem) - id: CanvasNet-Fonts title: Review that CanvasNet Fonts Subsystem Implementation is Correct @@ -448,6 +475,7 @@ reviews: - "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 + - "src/DemaConsulting.CanvasNet/Fonts/BundledFonts/README.md" # bundled-font provenance/licensing documentation # CanvasNet-Fonts-TrueTypeFont Review (one per unit) - id: CanvasNet-Fonts-TrueTypeFont @@ -465,17 +493,58 @@ reviews: - "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/IGlyphOutlineSource.cs" # internal outline-backend dispatch abstraction + - "src/DemaConsulting.CanvasNet/Fonts/CffTable.cs" # internal helper source (CFF structural parsing) + - "src/DemaConsulting.CanvasNet/Fonts/CffCharstringInterpreter.cs" # internal helper source (Type 2 interpreter) + - "src/DemaConsulting.CanvasNet/Fonts/Type1Table.cs" # internal helper source (Type 1 structural parsing) + - "src/DemaConsulting.CanvasNet/Fonts/Type1CharstringInterpreter.cs" # internal helper (Type 1 interpreter) + - "src/DemaConsulting.CanvasNet/Fonts/Type1CharstringDecryption.cs" # internal helper (eexec decryption) + - "src/DemaConsulting.CanvasNet/Fonts/Type1PfbReader.cs" # internal helper source (standalone .pfb auto-detect) + - "src/DemaConsulting.CanvasNet/Fonts/Type1PfaReader.cs" # internal helper source (standalone .pfa auto-detect) + - "src/DemaConsulting.CanvasNet/Fonts/Type1StandardGlyphNames.cs" # internal helper (default glyph-name encoding) + - "src/DemaConsulting.CanvasNet/Fonts/CffStandardEncoding.cs" # internal helper (CFF standard encoding table) - "src/DemaConsulting.CanvasNet/Fonts/HmtxHheaReader.cs" # internal helper source - "src/DemaConsulting.CanvasNet/Fonts/KernTable.cs" # internal helper source + - "src/DemaConsulting.CanvasNet/Fonts/NameTable.cs" # internal helper source (name-table resolution) + - "src/DemaConsulting.CanvasNet/Fonts/StyleTable.cs" # internal helper source (style derivation) - "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/CffTableTests.cs" # unit tests + - "test/DemaConsulting.CanvasNet.Tests/Fonts/CffCharstringInterpreterTests.cs" # unit tests + - "test/DemaConsulting.CanvasNet.Tests/Fonts/Type1TableTests.cs" # unit tests + - "test/DemaConsulting.CanvasNet.Tests/Fonts/Type1CharstringInterpreterTests.cs" # unit tests + - "test/DemaConsulting.CanvasNet.Tests/Fonts/Type1PfbReaderTests.cs" # unit tests + - "test/DemaConsulting.CanvasNet.Tests/Fonts/Type1PfaReaderTests.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/NameTableTests.cs" # unit tests + - "test/DemaConsulting.CanvasNet.Tests/Fonts/StyleTableTests.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 + - "test/DemaConsulting.CanvasNet.Tests/FontFixtures/README.md" # fixture provenance/licensing documentation + + # CanvasNet-Fonts-SystemFontCatalog Review (one per unit) + - id: CanvasNet-Fonts-SystemFontCatalog + title: Review that CanvasNet Fonts SystemFontCatalog 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/system-font-catalog.yaml" # unit requirements + - "docs/design/canvas-net/fonts/system-font-catalog.md" # unit design + - "docs/verification/canvas-net/fonts/system-font-catalog.md" # unit verification + - "docs/sysml2/model/canvas-net/fonts/system-font-catalog.sysml" # unit model + - "src/DemaConsulting.CanvasNet/Fonts/SystemFontCatalog.cs" # unit source + - "src/DemaConsulting.CanvasNet/Fonts/BundledFonts/README.md" # bundled-font provenance/licensing documentation + - "src/DemaConsulting.CanvasNet/DemaConsulting.CanvasNet.csproj" # embedded-resource/license packaging + - "test/DemaConsulting.CanvasNet.Tests/Fonts/SystemFontCatalogTests.cs" # unit tests + - "test/DemaConsulting.CanvasNet.Tests/Fonts/SystemFontCatalogRealDiscoveryIntegrationTests.cs" # unit tests + - "test/DemaConsulting.CanvasNet.Tests/Fonts/BundledLiberationFontsTests.cs" # unit tests # CanvasNet-Rendering Review (one per subsystem) - id: CanvasNet-Rendering @@ -611,6 +680,94 @@ reviews: - "test/DemaConsulting.CanvasNet.Svg.Tests/SvgCodecTests.cs" # unit tests - "test/DemaConsulting.CanvasNet.Svg.Tests/SvgFixtureTests.cs" # fixture conformance tests + # CanvasNetPdf-Architecture Review (one per system) + - id: CanvasNetPdf-Architecture + title: Review that CanvasNetPdf Architecture Satisfies Requirements + context: + - "README.md" + - "docs/user_guide/**/*.md" + paths: + - "docs/reqstream/canvas-net-pdf.yaml" # system requirements + - "docs/design/introduction.md" # design introduction + - "docs/design/canvas-net-pdf.md" # system design + - "docs/verification/introduction.md" # verification introduction + - "docs/verification/canvas-net-pdf.md" # system verification + - "test/DemaConsulting.CanvasNet.Pdf.Tests/PdfSystemIntegrationTests.cs" # integration tests + + # CanvasNetPdf-Design Review (one per system) + - id: CanvasNetPdf-Design + title: Review that CanvasNetPdf Design is Consistent and Complete + context: + - "docs/reqstream/canvas-net-pdf.yaml" + paths: + - "docs/reqstream/canvas-net-pdf/platform-requirements.yaml" # platform requirements + - "docs/design/introduction.md" # design introduction + - "docs/design/canvas-net-pdf.md" # system design + - "docs/design/canvas-net-pdf/**/*.md" # all system design documents + + # CanvasNetPdf-Verification Review (one per system) + - id: CanvasNetPdf-Verification + title: Review that CanvasNetPdf Verification is Consistent and Complete + context: + - "docs/reqstream/canvas-net-pdf.yaml" + paths: + - "docs/verification/introduction.md" # verification introduction + - "docs/verification/canvas-net-pdf.md" # system verification + - "docs/verification/canvas-net-pdf/**/*.md" # all verification documents + + # CanvasNetPdf-AllRequirements Review (one per system) + - id: CanvasNetPdf-AllRequirements + title: Review that All CanvasNetPdf Requirements are Complete + context: + - "docs/design/canvas-net-pdf.md" + - "docs/reqstream/canvas-net-pdf.yaml" + paths: + - "docs/reqstream/canvas-net-pdf/**/*.yaml" # all requirements files + + # CanvasNetPdf-PdfDocument Review (one per direct unit) + - id: CanvasNetPdf-PdfDocument + title: Review that CanvasNetPdf PdfDocument Implementation is Correct + context: + - "docs/design/canvas-net-pdf.md" + - "docs/reqstream/canvas-net-pdf.yaml" + paths: + - "docs/reqstream/canvas-net-pdf/pdf-document.yaml" # unit requirements + - "docs/design/canvas-net-pdf/pdf-document.md" # unit design + - "docs/verification/canvas-net-pdf/pdf-document.md" # unit verification + - "docs/sysml2/model/canvas-net-pdf/pdf-document.sysml" # unit SysML2 model + - "src/DemaConsulting.CanvasNet.Pdf/PdfDocument.cs" # phase 1 unit source (public API) + - "src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Tokenizer.cs" # phase 1 unit source (tokenizer) + - "src/DemaConsulting.CanvasNet.Pdf/PdfDocument.ObjectModel.cs" # phase 1 unit source (object model) + - "src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Xref.cs" # phase 1 unit source (xref resolution) + - "src/DemaConsulting.CanvasNet.Pdf/PdfDocument.PageTree.cs" # phase 1 unit source (page-tree traversal) + - "src/DemaConsulting.CanvasNet.Pdf/PdfDocument.ContentStream.cs" # phase 2 unit source (content stream) + - "src/DemaConsulting.CanvasNet.Pdf/PdfDocument.GraphicsState.cs" # phase 2 unit source (graphics state) + - "src/DemaConsulting.CanvasNet.Pdf/PdfDocument.PathOps.cs" # phase 2 unit source (path ops) + - "src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Color.cs" # phase 3 unit source (device color) + - "src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Filters.cs" # phase 3 unit source (filter pipeline) + - "src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Filters.Lzw.cs" # phase 7 unit source (filters) + - "src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Filters.Ascii.cs" # phase 7 unit source (filters) + - "src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Filters.RunLength.cs" # phase 7 unit source (filters) + - "src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Images.cs" # phase 3 unit source (image XObjects) + - "src/DemaConsulting.CanvasNet.Pdf/PdfDocument.CcittFax.cs" # phase 15 unit source (CCITT G4 fax) + - "src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Fonts.cs" # phase 4 unit source (font resolution) + - "src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Fonts.Type0.cs" # phase 9 unit source (composite fonts) + - "src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Fonts.Type1.cs" # phase B unit source (Type 1 simple fonts) + - "src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Fonts.Type3.cs" # Type 3 glyph-procedure fonts + - "src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Fonts.ToUnicode.cs" # phase 10 unit source (ToUnicode CMap) + - "src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Encryption.cs" # encryption/decryption (RC4, AESV2, AESV3 R5/R6) + - "src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Functions.cs" # pattern phase 1 (sampled function) + - "src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Patterns.cs" # pattern phase (shared /Pattern dispatch) + - "src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Patterns.Shading.cs" # pattern phase (shading patterns) + - "src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Patterns.Tiling.cs" # pattern phase (tiling patterns) + - "src/DemaConsulting.CanvasNet.Pdf/PdfDocument.FontFallback.cs" # phase 6 unit source (font fallback) + - "src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Text.cs" # phase 4 unit source (text rendering) + - "src/DemaConsulting.CanvasNet.Pdf/PdfPageInfo.cs" # phase 1 unit source (supporting record struct) + - "src/DemaConsulting.CanvasNet.Pdf/NamespaceDoc.cs" # Pdf namespace documentation + - "test/DemaConsulting.CanvasNet.Pdf.Tests/PdfDocumentTests.cs" # unit tests + - "test/DemaConsulting.CanvasNet.Pdf.Tests/PdfSystemIntegrationTests.cs" # integration tests + - "test/DemaConsulting.CanvasNet.Pdf.Tests/PdfFixtureTests.cs" # fixture conformance tests + # OTS-BuildMark Review (one per OTS item) - id: OTS-BuildMark title: Review that BuildMark Provides Required Functionality diff --git a/CanvasNet.slnx b/CanvasNet.slnx index 166d1c79..229269f3 100644 --- a/CanvasNet.slnx +++ b/CanvasNet.slnx @@ -2,9 +2,11 @@ + + diff --git a/README.md b/README.md index cc23a8aa..f86e6742 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # CanvasNet - + @@ -34,11 +34,23 @@ image operations using `Span`, and supports independent-copy cropping for loa - ๐Ÿ“ **SVG Codec** - Rasterize a common SVG subset, including markers/filters/clip-paths/masks and weight/style-aware font matching, to a surface (ships as the separate `DemaConsulting.CanvasNet.Svg` package) +- ๐Ÿ“„ **PDF Document** - Open a PDF, inspect its page count/size/rotation, and rasterize a page's + path geometry, device color (including shading and tiling pattern fills), image XObjects, and + TrueType text to a surface, automatically substituting a matching system font (or a bundled + Liberation Sans/Serif/Mono font) for text using a non-embedded font (ships as the separate + `DemaConsulting.CanvasNet.Pdf` package) - ๐Ÿ” **Header-Only Probing** - `GetInfo` reads headers without decoding pixels (GIF excepted) - ๐Ÿ–Œ๏ธ **Path Filling** - Antialiased nonzero/even-odd fill of vector paths - ๐Ÿ–Š๏ธ **Stroke-to-Fill** - Convert stroked paths into fillable outlines - ๐ŸŒ… **Gradient Paint** - Linear or radial gradient fills with spread -- ๐Ÿ”ค **TrueType Fonts** - Load fonts, map codepoints, extract glyph outlines +- ๐Ÿงฑ **Tile Paint** - Fill a path by repeating a pre-rendered tile bitmap at a configurable + pattern-space pitch and transform +- ๐Ÿ”ค **TrueType/CFF Fonts** - Load TrueType (`glyf`) or CFF/OpenType (`.otf`) fonts and individual + faces of a TrueType Collection (`.ttc`), map codepoints, extract glyph outlines, and query + name/style metadata (family/subfamily/full/PostScript name, bold/italic/fixed-pitch) +- ๐Ÿ—‚๏ธ **System Font Discovery** - `Fonts.SystemFontCatalog` discovers fonts installed on the host + operating system, best-effort matches a requested family/style against them, and provides a + bundled Liberation Sans/Serif/Mono last-resort fallback font - ๐ŸŽฌ **Rendering** - Transform-aware canvas with text and shape drawing - โšก **Span-Based** - Fast, allocation-conscious pixel and row access - ๐Ÿ”„ **Multi-Target** - Supports .NET 8, 9, and 10 @@ -68,12 +80,25 @@ Or via Package Manager Console: Install-Package DemaConsulting.CanvasNet.Svg ``` +PDF document parsing requires the separate `DemaConsulting.CanvasNet.Pdf` package: + +```bash +dotnet add package DemaConsulting.CanvasNet.Pdf +``` + +Or via Package Manager Console: + +```powershell +Install-Package DemaConsulting.CanvasNet.Pdf +``` + ## Usage ```csharp using DemaConsulting.CanvasNet.Canvas; using DemaConsulting.CanvasNet.Codecs; using DemaConsulting.CanvasNet.Svg; +using DemaConsulting.CanvasNet.Pdf; using System.IO; // Create a surface, set a pixel, and crop an independent copy @@ -109,6 +134,14 @@ Console.WriteLine($"Frames: {gifInfo.FrameCount}"); // Decode/rasterize an SVG into a 256x256 surface using var rasterized = SvgCodec.Load("icon.svg", 256, 256); +// Open a PDF, inspect its (rotation-adjusted) page size, and render it. Render paints the +// page's real content-stream geometry: path fills/strokes with device color, placed image +// XObjects, and text (using the page's embedded font, or an automatically substituted +// system/bundled fallback font when none is embedded). +using var pdfDoc = PdfDocument.Open("document.pdf"); +var pageInfo = pdfDoc.GetPageInfo(0); +using var pdfSurface = pdfDoc.Render(0, pageInfo.Width, pageInfo.Height); + // Triage an untrusted file's header before decoding pixel data var info = PngCodec.GetInfo("untrusted.png"); if (info.Width > Surface.MaxDimension @@ -214,7 +247,7 @@ var gradient = new LinearGradient( PathFiller.Fill(canvas, rectangle, gradient, FillRule.NonZero, 1f); ``` -Loading a TrueType font and filling a glyph outline: +Loading a TrueType (or CFF/OpenType) font and filling a glyph outline: ```csharp using DemaConsulting.CanvasNet.Canvas; @@ -223,7 +256,9 @@ using DemaConsulting.CanvasNet.Fonts; using DemaConsulting.CanvasNet.Geometry; using System.Numerics; -// Convert a glyph outline from font units (Y up) to canvas space (Y down) +// Convert a glyph outline from font units (Y up) to canvas space (Y down). +// glyf-flavored fonts produce QuadraticBezierTo segments; CFF/OpenType (.otf) +// fonts produce CubicBezierTo segments - both are handled here. static Path TransformGlyph(Path glyph, float scale, float baselineY) { var builder = new PathBuilder(); @@ -245,6 +280,12 @@ static Path TransformGlyph(Path glyph, float scale, float baselineY) ToCanvas(command.Control1), ToCanvas(command.EndPoint)); break; + case PathCommandType.CubicBezierTo: + builder.CubicBezierTo( + ToCanvas(command.Control1), + ToCanvas(command.Control2), + ToCanvas(command.EndPoint)); + break; case PathCommandType.Close: builder.Close(); break; @@ -255,7 +296,8 @@ static Path TransformGlyph(Path glyph, float scale, float baselineY) return builder.Build(); } -// Load the font and get glyph 'A' scaled to a 48px em size +// Load the font (accepts .ttf, .otf, or a specific face of a .ttc) and get +// glyph 'A' scaled to a 48px em size var font = TrueTypeFont.Load("font.ttf"); var glyphIndex = font.GetGlyphIndex('A'); var glyphOutline = font.GetGlyphOutline(glyphIndex); @@ -267,6 +309,32 @@ using var surface = new Surface(64, 64); PathFiller.Fill(surface, canvasOutline, new Rgba32(20, 120, 255, 255)); ``` +Selecting a face from a TrueType Collection (`.ttc`): + +```csharp +// Discover how many faces the collection contains +var faceCount = TrueTypeFont.GetFaceCount("collection.ttc"); // e.g. 2 + +// Load a specific face by index (face 0 is used when Load is called without +// an index, matching an ordinary single-face font's default behavior) +var boldFace = TrueTypeFont.Load("collection.ttc", faceIndex: 1); +``` + +Querying a font's name and style metadata: + +```csharp +// Resolve the font's family/subfamily/full/PostScript name from its name table +var nameInfo = font.GetNameInfo(); +Console.WriteLine($"{nameInfo.FamilyName} {nameInfo.SubfamilyName}"); // e.g. "Open Sans Regular" + +// Derived bold/italic/fixed-pitch classification (from OS/2, head.macStyle, and post) +if (font.IsBold || font.IsItalic || font.IsFixedPitch) +{ + Console.WriteLine("Bold: {0}, Italic: {1}, Fixed-pitch: {2}", + font.IsBold, font.IsItalic, font.IsFixedPitch); +} +``` + ## Building ```pwsh @@ -294,6 +362,23 @@ Copyright (c) DEMA Consulting. Licensed under the MIT License. See [LICENSE][lin By contributing to this project, you agree that your contributions will be licensed under the MIT License. +The `DemaConsulting.CanvasNet` package bundles the Liberation Sans, Liberation Serif, and +Liberation Mono TrueType fonts (12 files total) as embedded resources, used as a last-resort +fallback font by `PdfDocument`'s automatic font-substitution feature. These fonts are Copyright +(c) 2012 Red Hat, Inc., licensed under the SIL Open Font License, Version 1.1; see +`src/DemaConsulting.CanvasNet/Fonts/BundledFonts/OFL.txt` and +`src/DemaConsulting.CanvasNet/Fonts/BundledFonts/README.md` for the full license text and +sourcing/provenance details. + +The `DemaConsulting.CanvasNet` package also bundles the Noto Sans, Noto Sans Math, and Noto Sans +Symbols 2 TrueType fonts (`NotoSans-Regular.ttf`, `NotoSansMath-Regular.ttf`, +`NotoSansSymbols2-Regular.ttf`) as embedded resources, used as the dedicated substitute font for +`Symbol`/`ZapfDingbats` text by `PdfDocument`'s automatic font-substitution feature. These fonts +are part of the Noto Project, licensed under the SIL Open Font License, Version 1.1; see +`src/DemaConsulting.CanvasNet/Fonts/BundledFonts/NotoFonts-OFL.txt` and the "Noto Substitute +Fonts" section of `src/DemaConsulting.CanvasNet/Fonts/BundledFonts/README.md` for the full license +text and sourcing/provenance details. + ## Support - [Report a bug or request a feature][link-issues] diff --git a/docs/design/canvas-net-pdf.md b/docs/design/canvas-net-pdf.md new file mode 100644 index 00000000..8939e598 --- /dev/null +++ b/docs/design/canvas-net-pdf.md @@ -0,0 +1,448 @@ +# System Design + + + +This document provides the system-level design for CanvasNetPdf. + +![CanvasNetPdf Structure](CanvasNetPdfView.svg) + + + + +## Architecture + +CanvasNetPdf is a .NET library providing PDF page-rendering support, distributed as its own +NuGet package (`DemaConsulting.CanvasNet.Pdf`, namespace `DemaConsulting.CanvasNet.Pdf`), +independent of, but depending on, the core `CanvasNet` system (its own separate package, +`DemaConsulting.CanvasNet`) โ€” see the Dependencies section below. The system consists of a +single implemented unit: + +- **PdfDocument** (namespace `DemaConsulting.CanvasNet.Pdf`, folder + `src/DemaConsulting.CanvasNet.Pdf/`, flat โ€” no further nesting): a hand-rolled PDF document + parser and page-rendering codec, opening a PDF document once and reporting its page + count/size/rotation, then rasterizing a requested page into a + `DemaConsulting.CanvasNet.Canvas.Surface` pixel buffer. See _PdfDocument Unit Design_ + (`canvas-net-pdf/pdf-document.md`). + +`CanvasNetPdf` is modeled as its own top-level software system (rather than as a further unit of +the `CanvasNet` system's `Codecs` subsystem) because it is distributed as its own +independently-versioned NuGet package โ€” per `software-items.md`'s rule that a software package +contains exactly one software system, a system whose entire content ships in a distinct package +must itself be modeled as a distinct system, not folded into the package it depends on. +`CanvasNetPdf` contains exactly one unit, so no Subsystem tier is interposed between the system +and `PdfDocument`: inserting one here would not separate anything (there is nothing else in the +system to separate it from), mirroring `CanvasNetSvg`'s own single-unit precedent (see +_CanvasNetSvg System Design_, `docs/design/canvas-net-svg.md`). + +**Phased delivery.** `PdfDocument` is being delivered incrementally. Phase 1 implemented document +structure parsing only: cross-reference resolution (in all of PDF's common forms), the +trailer/catalog/page tree, and page count/size/rotation reporting; `Render` exposed its final +call shape (a page index plus a caller-chosen output size) but returned only a correctly sized, +fully transparent `Surface`. Phase 2 added a content-stream interpreter: `Render` tokenizes and +executes a page's `/Contents` path-construction (`m`/`l`/`c`/`v`/`y`/`h`/`re`) and path-painting +(`f`/`F`/`f*`/`S`/`s`/`B`/`B*`/`b`/`b*`/`n`) operators, plus the graphics-state stack +(`q`/`Q`/`cm`/`w`/`J`/`j`/`M`/`d`), painting real path geometry in the correct device-pixel +position for the page's `/MediaBox` origin, effective `/Rotate`, and the caller's requested render +size โ€” but every filled/stroked path painted in solid opaque black, since no color operator was +implemented yet. Phase 3 added real device color (`g`/`G`/`rg`/`RG`/`k`/`K`/`cs`/ +`CS`/`sc`/`SC`/`scn`/`SCN`), a generalized `/Filter`/`/DecodeParms` stream-decoding pipeline +(`FlateDecode` plus PNG/TIFF predictor reversal), and image XObjects (`Do`: `DCTDecode` via the +`CanvasNet` system's `Codecs.JpegCodec`, or raw `DeviceGray`/`DeviceRGB`/`DeviceCMYK` 8-bit +samples, composited through the current transformation matrix). Phase 4 added real text/font +rendering: `BT`/`ET`/`Tc`/`Tw`/`Tz`/`TL`/`Tf`/`Tr`/`Ts`/`Td`/`TD`/`Tm`/`T*`/`Tj`/`'`/`"`/ +`TJ` resolve a simple `/Subtype /TrueType` font from the current page's `/Resources/Font` +dictionary (requiring an embedded `/FontDescriptor/FontFile2`, loaded via the `CanvasNet` +system's `Fonts.TrueTypeFont`), map each shown byte through its `/WinAnsiEncoding`/ +`/MacRomanEncoding` (plus `/Differences`) encoding to a Unicode codepoint, and paint the +resulting glyph outline through the composed text-rendering matrix exactly like any other filled +path. Phase 6 narrowed Phase 4's font-resolution boundary: a `/Subtype /TrueType` font with no +embedded `/FontFile2` is no longer an unconditional failure โ€” it is instead automatically +substituted with the closest-matching font actually installed on the host operating system, or, +when nothing matches, a bundled Liberation Sans/Serif/Mono font (via the `CanvasNet` system's +`Fonts.SystemFontCatalog`), except that a `/BaseFont` of `Symbol`/`ZapfDingbats` (or any font +whose `/FontDescriptor/Flags` declares `Symbolic` without also declaring `Nonsymbolic`) still +fails closed with `Codecs.UnsupportedImageFeatureException`, since a symbol/dingbat glyph set has +no meaningful generic-family equivalent. Phase 7 narrowed Phase 3's stream-filter boundary: a +page's `/Contents` or an image XObject's data may now also be filtered with `LZWDecode`, +`ASCII85Decode`, `ASCIIHexDecode`, or `RunLengthDecode` (individually or composed with +`FlateDecode`'s predictor reversal), in addition to `FlateDecode` itself. Phase 9 added composite +font support: a `/Subtype /Type0` font whose `/Encoding` is the name `/Identity-H` and whose +single-element `/DescendantFonts` array names a `/Subtype /CIDFontType2` font with an embedded +`/FontDescriptor/FontFile2` is now resolved by decoding each shown 2-byte code directly as a CID, +mapping it to a glyph index via the descendant's `/CIDToGIDMap`, and resolving its advance width +from the descendant's `/DW`/`/W` entries โ€” composite fonts have no fallback substitution path, so +a missing embedded font still fails closed. Later phases substantially widened this scope. Phase +12 resolves a `/CIDFontType0` descendant font (a non-CID-keyed CFF program, `/OpenType`-wrapped or +bare) identically to `/CIDFontType2`. Phases B/C/D added simple-font subtypes beyond +`/TrueType`: a `/Subtype /Type1` font with an embedded classic `/FontFile` program or a bare +Type1C `/FontFile3` program is resolved and rendered exactly like an embedded `/TrueType` font, +and a `/Subtype /Type3` font (whose glyphs are themselves small content-stream procedures rather +than an outline font program) is resolved via its own dedicated glyph-painting path that re-enters +the same content-stream interpreter for each glyph procedure. Phase 18 widened `/FontFile3` +dispatch for both `/Type1` and composite `/CIDFontType0` fonts to sniff the stream's own decoded +bytes for a recognized container shape rather than trusting the stream's declared `/Subtype` +name. Phase 13 added `/Subtype /Form` XObject rendering: `Do` decodes and executes a Form's +content stream as a nested, implicitly `q`/`Q`-bracketed execution of the same interpreter. Phase +14 replaced the color-space model to add `/CalRGB`, `/ICCBased` (resolved via its `/N` or +`/Alternate`), and `/Indexed` (a palette lookup over any supported base color space), and +introduced the `/Pattern` color space: axial/radial shading patterns (`/PatternType 2`, +`/ShadingType 2`/`3`) reusing a widened Function evaluator that now also supports +`/FunctionType 2` (exponential interpolation) and `/FunctionType 3` (stitching) functions in +addition to `/FunctionType 0` (sampled); and colored/uncolored tiling patterns (`/PatternType 1`, +each cell rendered through the same nested-execution machinery `/Subtype /Form` XObjects use). +Phase 15 added `CCITTFaxDecode` (Group 4/T.6 MMR only) image-XObject decoding. Phases 16/17 added +decryption of a document encrypted with the PDF `/Filter /Standard` security handler using RC4 +(`/V 1`/`/V 2`), AES-128 (`/V 4`/`/CFM /AESV2`), or AES-256 using the simpler R5 key derivation +(`/V 5`/`/R 5`/`/CFM /AESV3`), authenticating either the empty user password (the default) or an +optional caller-supplied password tried as both the user and the owner password. **Current +limitations**: `/MMType1` (Multiple Master Type 1) fonts remain entirely unsupported and fail +closed; `/Encoding` values other than `/Identity-H` (including `/Identity-V` and predefined CJK +encodings) and a CID-keyed CFF program fail closed too; only the `/WinAnsiEncoding`/ +`/MacRomanEncoding`/`/StandardEncoding` base encodings (plus `/Differences`) are supported (an +unrecognized base encoding fails closed); fill (`Tr 0`), stroke (`Tr 1`), fill+stroke (`Tr 2`), +and invisible (`Tr 3`) text-rendering modes are supported (clip modes `Tr 4`-`7` fail closed); a +Form XObject's `/BBox` is never used to clip its content and its `/Group` (transparency group) +entry is never consulted, though the Form itself renders; `/ShadingType` values other than `2`/`3` +and `/FunctionType 4` (PostScript calculator) functions fail closed, as does the `sh` operator and +general path clipping (`W`/`W*`), both of which are silently skipped rather than rejected; no +`/SMask`/alpha compositing or transparency groups (every decoded image is treated as fully +opaque); no `JPXDecode` filter decoding (fails closed; `CCITTFaxDecode` - Group 4 (T.6 MMR) only - +is supported); `/Separation`/`/DeviceN`/`/CalGray`/`/Lab` color spaces remain unsupported and fail +closed; and an encrypted document using any security handler, crypt-filter method, `/V`/`/R` +combination other than the ones listed above, or whose correct password is not supplied, fails +closed rather than being decrypted. These remain out of scope and are planned for later phases +(see Risk Control Measures below for the complete, currently-thrown `Feature` string taxonomy). + +## External Interfaces + +The system exposes the following public API to external consumers, all on the sealed +`PdfDocument` class: + +- **PdfDocument.Open(Stream stream, string? password = null)** / **PdfDocument.Open(string path, + string? password = null)**: Opens and parses a PDF document exactly once, from a stream or a + file path, buffering the input fully in memory. `Open(Stream, ...)` never takes ownership of + (or disposes) the caller's stream. `Open(string, ...)` opens, reads, and closes its own + internal `FileStream` before returning. The optional `password` is consulted only for an + encrypted (`/Encrypt` present in the trailer) document: when `null` (the default), only the + empty user password is authenticated; when supplied, it is tried first as the user password, + then as the owner password, and the document is opened/decrypted transparently the moment + either authenticates. Throws `ArgumentNullException` for a null `stream`/`path`, + `ArgumentException` for an empty/whitespace-only `path`, `InvalidDataException` for + malformed/unresolvable document structure, and `Codecs.UnsupportedImageFeatureException` for an + encrypted document using an unsupported security handler/crypt-filter method/`/V`/`/R` + combination, or whose correct password was not supplied (see _PdfDocument Unit Design_ for the + complete, distinguishable `Feature` token set). +- **PdfDocument.PageCount**: Reports the document's true resolved page count. Throws + `ObjectDisposedException` once the document has been disposed. +- **PdfDocument.GetPageInfo(int pageIndex)**: Reports the given page's display + (rotation-adjusted) width/height and normalized effective rotation as a `PdfPageInfo`. Throws + `ArgumentOutOfRangeException` for `pageIndex < 0 || pageIndex >= PageCount`, and + `ObjectDisposedException` once disposed. +- **PdfDocument.Render(int pageIndex, int width, int height)**: Returns a `Surface` of the + caller-specified `width`x`height` for the given page, painted with the page's interpreted + content-stream geometry (path construction/painting with real device color, solid fills or + shading/tiling pattern fills, any placed image XObjects decoded through the full `/Filter` + chain โ€” `FlateDecode`, `LZWDecode`, `ASCII85Decode`, `ASCIIHexDecode`, `RunLengthDecode`, + `DCTDecode`, `CCITTFaxDecode` (Group 4 only), individually or composed โ€” any nested + `/Subtype /Form` XObject executed as a nested content stream, and any shown text painted with a + resolved embedded TrueType/Type1/Type1C/Type3/composite font, or an automatically substituted + system/bundled fallback font when a simple TrueType font has none embedded โ€” see _PdfDocument + Unit Design_ for the full operator set and its documented fail-closed boundaries), or a fully + transparent surface when the page declares no `/Contents`. Validates + `pageIndex` the same way as `GetPageInfo`, propagates `Surface`'s own `width`/`height` + validation unwrapped, throws `InvalidDataException` for malformed `/Contents` or a malformed + recognized operator, throws `Codecs.UnsupportedImageFeatureException` for a well-formed but + unsupported color space/stream filter/pattern or shading shape/font subtype or encoding/ + text-rendering mode/symbolic-font-without-embedded-data, and throws `ObjectDisposedException` + once disposed. +- **PdfDocument.Render(int pageIndex, float dpi)**: Convenience overload preserving the page's + own aspect ratio: reads `GetPageInfo(pageIndex)`'s rotation-adjusted point-space width/height, + scales both by `dpi / 72`, rounds to the nearest pixel, and delegates to + `Render(int, int, int)`. Throws `ArgumentOutOfRangeException` for a non-positive/non-finite + `dpi`, in addition to every exception `Render(int, int, int)` itself can throw. +- **PdfDocument.Dispose()**: Idempotent; releases the buffered/parsed document state. No other + public member may be called afterward without throwing `ObjectDisposedException`. + + +| Interface | Direction | Format | Constraints | +| -------------------------------------- | ---------------- | ------------------------------------ | ------------------------------- | +| `PdfDocument.Open(...)` | Inbound/Outbound | Method call / `PdfDocument` return | Valid PDF stream or path; optional `password` for an encrypted document | +| `PdfDocument.PageCount` | Outbound | Property read / `int` return | Not disposed | +| `PdfDocument.GetPageInfo(...)` | Inbound/Outbound | Method call / `PdfPageInfo` return | `0 <= pageIndex < PageCount`; not disposed | +| `PdfDocument.Render(...)` | Inbound/Outbound | Method call / `Surface` return | `0 <= pageIndex < PageCount`; `0 < width, height <= 8192`; not disposed | +| `PdfDocument.Render(int, float)` | Inbound/Outbound | Method call / `Surface` return | `0 <= pageIndex < PageCount`; `dpi` positive and finite; not disposed | + + +See _PdfDocument Unit Design_ (`canvas-net-pdf/pdf-document.md`) for the complete parsing +internals (tokenizer, object model, all three cross-reference forms, the linear-scan fallback, +and page-tree traversal/inheritance) and every method's full parameter and exception detail. + +## Dependencies + +`CanvasNetPdf` depends on the separate `CanvasNet` system (its own package, +`DemaConsulting.CanvasNet`, referenced via a project reference from +`src/DemaConsulting.CanvasNet.Pdf/DemaConsulting.CanvasNet.Pdf.csproj`), specifically: + +- The `Canvas` subsystem's `Surface` unit โ€” the content-rendered destination raster `Render` + returns, and `Rgba32` โ€” the fill/stroke color and decoded image-pixel representation +- The `Codecs` subsystem's shared `UnsupportedImageFeatureException` type โ€” reused, unmodified, + to signal a well-formed-but-unsupported encrypted-document shape, color space, stream filter, + pattern/shading shape, function type, or font subtype/encoding (see Risk Control Measures + below) +- The `Codecs` subsystem's `JpegCodec` unit (new as of Phase 3) โ€” decodes an image XObject's raw + `DCTDecode` (JPEG) bitstream via `JpegCodec.Load(Stream)` directly, without requiring + APP0/JFIF framing +- The `Geometry` subsystem's `PathBuilder`/`Path` โ€” accumulates each content stream's current + path as its path-construction operators are dispatched +- The `Drawing` subsystem's `PathFiller`/`PathStroker`/`StrokeStyle`/`FillRule`/`LineCap`/ + `LineJoin` โ€” rasterizes each finished path onto the destination `Surface` for every + path-painting operator, and, as of Phase 4, every filled glyph outline +- The `Drawing` subsystem's `Gradient`/`LinearGradient`/`RadialGradient`/`GradientStop` and + `TilePaint` types (new as of the `/Pattern` color-space phase) โ€” `PathFiller`'s `Gradient` + fill overload paints an axial/radial shading pattern built from the resolved `/Shading` + function(s); its `TilePaint` fill overload paints a colored/uncolored tiling pattern's + pre-rendered repeating tile cell, sampled per destination pixel through the pattern-to-device + transform +- The `Fonts` subsystem's `TrueTypeFont` unit (new as of Phase 4) โ€” loads an embedded + `/FontFile2` byte stream and resolves each shown codepoint to a glyph index/outline/advance + width, exactly as `CanvasNetSvg`'s own `SvgCodec.Text.cs` already uses it for SVG `` + rendering; the same type's `LoadType1`/`LoadType1C` factory methods (new as of the Type1/Type1C + font phases) load, respectively, an embedded classic PostScript Type 1 `/FontFile` program and + a bare Type1C/CFF `/FontFile3` program, reusing the same glyph-outline representation rather + than introducing a separate font type +- The `Fonts` subsystem's `SystemFontCatalog` unit (new as of Phase 6) โ€” locates the + closest-matching font actually installed on the host operating system (or, when nothing + matches, loads a bundled Liberation Sans/Serif/Mono fallback, or - for `Symbol`/`ZapfDingbats` - + a bundled Noto substitute) when a simple font declares no embedded font program, so that + documents referencing an unembedded standard font still render recognizable glyph shapes + rather than failing closed +- BCL `System.Security.Cryptography.MD5`/`SHA256`/`Aes` (new as of Phase 16, the encryption + phase) โ€” implement the PDF Standard Security Handler's own mandated password-hashing + (ISO 32000-1 Algorithms 2/4/5, ISO 32000-2 Algorithm 2.A) and AES-128/AES-256 stream/string + decryption; RC4 has no BCL equivalent and is hand-rolled, consistent with this codebase's + existing convention for other standard algorithms (e.g. CRC-32/Adler-32/CCITT tables) + +This dependency on `Geometry`/`Drawing` is new as of Phase 2 (Phase 1 introduced no such +dependency โ€” no Phase 1 file constructed a `Path`, rasterized a fill/stroke, or looked up a +glyph). The dependency on `Fonts.TrueTypeFont` is new as of Phase 4, and the dependency on +`Fonts.SystemFontCatalog` is new as of Phase 6: Phases 1-3 implemented no text/font operator and +introduced no such dependency. + +This is an ordinary, same-repository, system-to-system dependency: both `CanvasNet` and +`CanvasNetPdf` are produced by this repository, so it is neither an OTS Software Item (not a +third-party/external-program dependency) nor a Shared Package (that category is scoped to a +package produced by a _different_ repository within the same program). Beyond `CanvasNet`, +`PdfDocument` uses only the .NET base class library's `System.IO.Compression.DeflateStream` +(decompressing `FlateDecode`-filtered object streams), available on every one of CanvasNetPdf's +target frameworks with no new runtime NuGet dependency. `CanvasNetPdf` introduces no new OTS +Software Item beyond those already used to build and verify the `CanvasNet` system (BuildMark, +FileAssert, Pandoc, ReqStream, ReviewMark, SarifMark, SonarMark, VersionMark, WeasyPrint, xUnit) +โ€” see _OTS Integration Design_ (`docs/design/ots.md`). + +## Risk Control Measures + +`PdfDocument.Open` parses an externally-supplied, potentially untrusted PDF document (a binary +format whose cross-reference tables, object offsets, and page-tree references may be malformed, +inconsistent, or adversarially constructed). Risk control for this untrusted-input parsing is +segregated entirely within the single `PdfDocument` unit and falls into two distinct categories, +each with its own consistently-applied behavior: + +- **Tolerated recovery** โ€” a document whose normal cross-reference parsing fails, or resolves to + a trailer that does not describe a valid `/Type /Catalog` root, falls back to a linear scan of + the buffered document for `N G obj` markers to reconstruct an object-offset table directly, + rather than immediately rejecting an otherwise-recoverable document. +- **Rejected reference cycles and malformed structure** โ€” an unbounded page-tree reference cycle + (a `/Kids` entry referencing one of its own ancestors) is explicitly detected and rejected with + a thrown `InvalidDataException`, rather than being tolerated: recursing into a genuine cycle + would otherwise never terminate. Any other unresolvable/malformed structure (an indirect object + that cannot be located even after the linear-scan fallback, an invalid `/Rotate` value that is + not a multiple of 90, and so on) is likewise rejected with `InvalidDataException`. + +An `/Encrypt` key present in the trailer is detected explicitly: `PdfDocument` decrypts every +indirect object's strings and every stream's raw bytes before any other parsing logic observes +them, for a document using the PDF `/Filter /Standard` security handler with RC4 (`/V 1`/`/V 2`), +AES-128 (`/V 4`/`/CFM /AESV2`), or AES-256 using the simpler R5 key derivation (`/V 5`/`/R 5`/ +`/CFM /AESV3`), authenticating either the empty user password (the default) or an optional +caller-supplied password tried as both the user and the owner password. `PdfDocument` never +attempts to interpret the (still-encrypted) bytes of an encrypted document as plaintext content +when authentication fails, or when the document uses a security handler/crypt-filter +method/`/V`/`/R` combination outside this supported set: each such shape fails closed with its +own distinguishable `Codecs.UnsupportedImageFeatureException` immediately upon detection, never +falling back to any tolerant/best-effort decoding. Phase 3 extended this same fail-closed posture +to every well-formed but out-of-scope construct it could then encounter: an unsupported color +space, an unsupported stream filter, and an unsupported image `/BitsPerComponent` are all +rejected with `Codecs.UnsupportedImageFeatureException` rather than being silently skipped or +mis-rendered. Phase 4 extended the same posture to text/font constructs: a font dictionary's +`/MMType1` subtype, an `/Encoding` naming an unrecognized base encoding, and a clip +text-rendering mode (`Tr 4`-`7`) are all likewise rejected with +`Codecs.UnsupportedImageFeatureException`. Phase 6 narrowed (but did not remove) the font-subtype +fail-closed boundary: a `/Subtype /TrueType` font lacking an embedded `/FontFile2` is now resolved +via automatic system/bundled-font substitution rather than rejected outright, except that a +`/BaseFont` of `Symbol`/`ZapfDingbats` (or any font whose `/FontDescriptor/Flags` declares +`Symbolic` without also declaring `Nonsymbolic`) still fails closed with +`Codecs.UnsupportedImageFeatureException` (feature `"pdf-font-symbolic-not-embedded"`), since a +symbol/dingbat glyph set has no meaningful generic-family equivalent and is never substituted +with an unrelated font. Phase 7 extended the supported stream-filter set from `FlateDecode` alone +to also include `LZWDecode`, `ASCII85Decode`, `ASCIIHexDecode`, and `RunLengthDecode`; any filter +other than these five (plus `DCTDecode`/`CCITTFaxDecode` for image XObjects) remains rejected +with `Codecs.UnsupportedImageFeatureException` (`"pdf-filter-{name}"`), and malformed bytes for +any of the supported filters are rejected with `InvalidDataException` (malformed, not merely +unsupported). Phase 9 narrowed the font-subtype fail-closed boundary again: a `/Subtype /Type0` +font is no longer unconditionally rejected โ€” an `/Encoding` other than `/Identity-H` (feature +`"pdf-font-type0-encoding-{name}"`) or a descendant `/Subtype` other than `/CIDFontType2`/ +`/CIDFontType0` (Phase 12 added `/CIDFontType0`, feature `"pdf-font-cidfonttype-{subtype}"`) still +fails closed, and a composite font has no fallback substitution path, so a missing/non-embedded +descendant font program fails closed with `InvalidDataException` rather than +`Codecs.UnsupportedImageFeatureException`. Phases B/C/D narrowed the font-subtype fail-closed +boundary a third time: `/MMType1` is now the only remaining unconditionally rejected font +`/Subtype` โ€” `/Type1` (embedded classic `/FontFile` or bare Type1C `/FontFile3`) and `/Type3` +(procedure-painted glyphs) are both now resolved and rendered. Phase 13 narrowed the Phase 3 +color-space/XObject boundary further: `Do` on a `/Subtype /Form` XObject is no longer rejected โ€” +it decodes and executes the Form's content stream as a nested execution of the same interpreter, +subject to its own documented, non-exception-raising limitation (the Form's `/BBox` is never used +to clip its content and its `/Group` transparency-group entry is never consulted โ€” the Form's +content simply paints unclipped). Phase 14 replaced the color-space model: `/CalRGB`, `/ICCBased` +(resolved via its `/N` or `/Alternate`, feature `"pdf-colorspace-ICCBased"` for an unsupported +shape), and `/Indexed` (a palette lookup over any supported base space) are all now supported, +narrowing the unsupported-color-space set to `Separation`/`DeviceN`/`CalGray`/`Lab` (feature +`"pdf-colorspace-{name}"`); the same phase introduced the `/Pattern` color space and `scn`/`SCN` +pattern operands (an undeclared pattern name, feature `"pdf-pattern-not-declared"`; an +unsupported `/PatternType`, feature `"pdf-pattern-type-{n}"`; a malformed `/Pattern` array shape, +feature `"pdf-colorspace-Pattern"`), axial/radial shading patterns (an unsupported +`/ShadingType`, feature `"pdf-shading-type-{n}"`; an unsupported shading `/ColorSpace`, feature +`"pdf-shading-colorspace-{family}"`), tiling patterns (an oversized rendered tile, feature +`"pdf-pattern-tile-too-large"`), and a Function evaluator widened from `/FunctionType 0` +(sampled) alone to also resolve `/FunctionType 2` (exponential) and `/FunctionType 3` +(stitching); any other `/FunctionType` (including `4`, PostScript calculator) still fails closed +(feature `"pdf-functiontype-{n}"`), as does a multi-input `/FunctionType 0` function (feature +`"pdf-function-multiinput"`) and an unsupported `/BitsPerSample` (feature +`"pdf-function-bitspersample-{n}"`). Phase 15 added `CCITTFaxDecode` (Group 4/T.6 MMR only) image +decoding: a non-negative `/K` (Group 3, feature `"pdf-ccittfax-group3"`), `/EndOfLine true` +(feature `"pdf-ccittfax-endofline"`), or a resolved `/ColorSpace` with more than 1 component +(feature `"pdf-ccittfax-colorspace"`) each still fail closed. Phase 18 widened `/FontFile3` +dispatch for `/Type1` and composite `/CIDFontType0` fonts to sniff the stream's own decoded bytes +for a recognized container shape; only bytes matching neither a recognized SFNT nor bare-CFF +shape still fail closed (feature `"pdf-font-fontfile3-unrecognized-shape"`). Each +currently-thrown `Codecs.UnsupportedImageFeatureException` carries a distinct, +descriptive `Feature` string so a caller (or this repository's own tests) can distinguish exactly +which unsupported construct was encountered โ€” the complete current set is: +`pdf-encrypted-filter-{name}` (a non-`/Standard` security handler), `pdf-encrypted-cfm-{name}` +(an unsupported `/CF/StdCF/CFM`), `pdf-encrypted-crypt-filter-{name}` (an unsupported named +crypt filter), `pdf-encrypted-r6-hardened-hash`, `pdf-encrypted-r-{revision}`, +`pdf-encrypted-v-{version}`, `pdf-encrypted-password-required` (a `null` password when a +non-empty one is genuinely required), `pdf-encrypted-incorrect-password`, +`pdf-encrypted-password-non-ascii` (an R2-R4 password outside ASCII 0-127), +`pdf-colorspace-{name}` (`Separation`/`DeviceN`/`CalGray`/`Lab`), `pdf-colorspace-Pattern`, +`pdf-colorspace-ICCBased`, `pdf-filter-{name}` (`JPXDecode` and any other unrecognized filter), +`pdf-tiff-predictor-bitdepth-{n}`, `pdf-image-bitdepth-{n}`, `pdf-pattern-not-declared`, +`pdf-pattern-type-{n}`, `pdf-pattern-tile-too-large`, `pdf-shading-type-{n}`, +`pdf-shading-colorspace-{family}`, `pdf-functiontype-{n}`, `pdf-function-multiinput`, +`pdf-function-bitspersample-{n}`, `pdf-font-subtype-{subtype}` (`MMType1` only), +`pdf-font-symbolic-not-embedded` (`Symbol`/`ZapfDingbats` descriptor flags without an embedded +font program), `pdf-font-encoding-{name}`, `pdf-font-type0-encoding-{name}` (a `/Type0` +`/Encoding` other than `/Identity-H`), `pdf-font-cidfonttype-{subtype}` (a descendant `/Subtype` +other than `/CIDFontType2`/`/CIDFontType0`), `pdf-font-fontfile3-unrecognized-shape`, +`pdf-font-tounicode-{operator}` and `pdf-font-tounicode-bfrange-array-destination` (an +unsupported `/ToUnicode` CMap construct), `pdf-text-render-mode-{mode}` (clip), +`pdf-ccittfax-group3`, `pdf-ccittfax-endofline`, and `pdf-ccittfax-colorspace`. `/Annots` +(annotations) and AcroForms are simply not +processed at all โ€” page rendering silently ignores `/Annots` rather than throwing โ€” since this is +an unimplemented feature, not a fail-closed scope boundary. No other segregation is required at +the system level: +`CanvasNetPdf` contains exactly one unit, so this risk control is inherently contained within it +(IEC 62304 ยง5.3.3). + +Beyond rejecting out-of-scope constructs, several decoders and interpreter loops also bound a +resource (decoded-output size, stack depth, or expansion factor) that an otherwise well-formed +but adversarially crafted document could otherwise drive unboundedly large, closing a class of +decompression-bomb/resource-exhaustion risks without rejecting any legitimate document; see +_Resource and Input Bounds_ in `PdfDocument`'s own unit design for the complete set of caps and +guards. + +## Data Flow + +**PDF open/parse path:** + +1. **Input**: A PDF document (stream or file path) +2. **Validation**: `Open` rejects a null `stream`/`path` with `ArgumentNullException` and an + empty/whitespace `path` with `ArgumentException` +3. **Processing**: Fully buffers the input into memory, tokenizes and parses its cross-reference + data (classic table, cross-reference stream, object stream, and hybrid combinations, with a + linear-scan fallback), checks the resolved trailer for an `/Encrypt` key, then traverses the + catalog's page tree (`/Pages` โ†’ recursive `/Kids` โ†’ `/Type /Page` leaves), inheriting + `/MediaBox`/`/Rotate` from the nearest ancestor that declares one and computing each page's + already-rotated `PdfPageInfo` +4. **Output**: A new `PdfDocument` instance whose `PageCount`/`GetPageInfo` reflect the fully + resolved page tree โ€” parsed once, reused across every subsequent call on that instance + +**PDF render path:** + +1. **Input**: A `pageIndex` and a caller-requested output `width`/`height` +2. **Validation**: Rejects an out-of-range `pageIndex` with `ArgumentOutOfRangeException` + (identically to `GetPageInfo`); a non-positive `width`/`height`, or a `width`/`height` + exceeding `Surface.MaxDimension` (8192), propagates, unwrapped, as `Surface`'s own + `ArgumentOutOfRangeException` +3. **Processing**: Builds the page's base current transformation matrix from its raw `/MediaBox` + origin, effective `/Rotate`, and the requested `width`/`height`; resolves `/Contents` to fully + decoded bytes (concatenating a multi-stream array with a space separator, and decoding through + its full `/Filter` chain โ€” `FlateDecode`, `LZWDecode`, `ASCII85Decode`, `ASCIIHexDecode`, + `RunLengthDecode`, individually or composed); tokenizes and + dispatches every recognized path-construction/painting, graphics-state, device-color, + image-XObject, and text operator, silently skipping any other keyword โ€” a text-showing + operator (`Tj`/`'`/`"`/`TJ`) resolves each shown byte through the currently selected font's + `/Encoding` to a Unicode codepoint, looks up its glyph outline/advance width via the resolved + font (an embedded `Fonts.TrueTypeFont`, or, when none is embedded, an automatically + substituted system/bundled `Fonts.SystemFontCatalog` fallback), and paints it through the + composed text-rendering matrix + exactly like any other filled path; throws `InvalidDataException` for malformed `/Contents` or + a malformed recognized operator's operand count/type, and throws + `Codecs.UnsupportedImageFeatureException` for a well-formed but unsupported color space, + stream filter, pattern/shading shape, function type, or + font subtype/encoding/symbolic-font-without-embedded-data/text-rendering mode +4. **Output**: A new `Canvas.Surface` of exactly the requested size, painted with the page's + interpreted path geometry, any placed image XObjects and nested Form XObjects, any + pattern/shading-filled paths, and any shown text (or fully transparent, when the page declares + no `/Contents` at all) + +## Design Constraints + +- **Simplicity**: Minimal functionality kept easy to understand and extend +- **Compliance**: All functionality must be traceable to requirements +- **Quality**: Zero warnings, full test coverage, complete documentation +- **Portability**: Compatible across supported .NET platforms +- **One-time parse, reused across calls**: `Open` performs the full parse exactly once; + `PageCount`/`GetPageInfo`/`Render` all read from that already-resolved state rather than + re-parsing on every call โ€” a design property a stateless (static-method-only) API could not + express, and the direct motivation for `PdfDocument` being an instantiable, disposable class + rather than a set of static methods like `SvgCodec` +- **Decode-only**: `PdfDocument` provides no encode/`Save` direction, matching every other + decode-only codec in this repository + +### Platform Support + +The library targets the following frameworks, identical to the `CanvasNet` system it depends on, +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 | + +The library is supported on the following operating systems: + +- **Windows** โ€” primary developer and CI platform +- **Linux** โ€” CI/CD and containerized environments +- **macOS** โ€” developer workstations using Apple platforms + +Portability is achieved by restricting the implementation exclusively to Base Class Library (BCL) +APIs and the `CanvasNet` system's own public API, available across all target frameworks. No +platform-specific native interop, OS-specific APIs, or framework-version-specific features are +used. + +### Integration Patterns + +- **NuGet Packaging**: Standard .NET library packaging and distribution, referencing the + `CanvasNet` package as an ordinary NuGet dependency +- **CI/CD Integration**: Automated build, test, and quality validation +- **Requirements Traceability**: All features linked to passing tests diff --git a/docs/design/canvas-net-pdf/pdf-document.md b/docs/design/canvas-net-pdf/pdf-document.md new file mode 100644 index 00000000..c54e09dd --- /dev/null +++ b/docs/design/canvas-net-pdf/pdf-document.md @@ -0,0 +1,1496 @@ +## PdfDocument + +![CanvasNetPdf Structure](CanvasNetPdfView.svg) + + + + + + + + + + + + + +`PdfDocument` is distributed as the separate `DemaConsulting.CanvasNet.Pdf` NuGet package +(namespace `DemaConsulting.CanvasNet.Pdf`), which references the core +`DemaConsulting.CanvasNet` package. + +The `PdfDocument` class is the sole software unit of the `CanvasNetPdf` system. Its dependencies +are limited to `CanvasNet`'s `Canvas` subsystem (`Surface`, `Rgba32`), `Codecs` subsystem +(`UnsupportedImageFeatureException`, and, as of Phase 3, `JpegCodec`), `Geometry` subsystem +(`PathBuilder`, `Path`), `Drawing` subsystem (`PathFiller`, `PathStroker`, `StrokeStyle`, +`FillRule`, `LineCap`, `LineJoin`), and, as of Phase 4, `Fonts` subsystem (`TrueTypeFont`, and, as +of Phase 6, `SystemFontCatalog`) โ€” see the Dependencies section of _CanvasNetPdf System Design_ +(`../canvas-net-pdf.md`). It provides +hand-rolled parsing of a PDF document's structure (cross-references, trailer, page tree), a +content-stream interpreter (`Render` tokenizes and executes a page's `/Contents` path- +construction/painting and graphics-state operators), real device color, a generalized stream- +filter pipeline, image XObjects (Phase 3), and, as of Phase 4, real embedded-TrueType-font text +rendering: `g`/`G`/`rg`/`RG`/`k`/`K`/`cs`/`CS`/`sc`/`SC`/`scn`/`SCN` set the actual fill/stroke +color a path paints with; a generalized `/Filter`/`/DecodeParms` pipeline (`FlateDecode` plus +PNG/TIFF predictor reversal) decodes any stream, not only a page's own `/Contents`; `Do` decodes +and composites a `/Subtype /Image` XObject (`DCTDecode` via `Codecs.JpegCodec`, or raw +`DeviceGray`/`DeviceRGB`/`DeviceCMYK` 8-bit samples) through the current transformation matrix; +and `BT`/`ET`/`Tc`/`Tw`/`Tz`/`TL`/`Tf`/`Tr`/`Ts`/`Td`/`TD`/`Tm`/`T*`/`Tj`/`'`/`"`/`TJ` resolve a +simple (non-composite) TrueType font declared in the current page's `/Resources/Font` +dictionary, map each shown byte through that font's `/Encoding` to a Unicode codepoint, and paint +the resulting glyph outline (scaled/positioned by the composed text-rendering matrix) via +`Drawing.PathFiller.Fill` with the current fill color, exactly like any other filled path. As of +Phase 6 (this phase), a `/Subtype /TrueType` simple font with an embedded +`/FontDescriptor/FontFile2` still always wins, but a font with no embedded `/FontFile2` is no +longer an unconditional failure: it is instead substituted with the closest-matching font +actually installed on the host operating system, or - when nothing matches - a bundled Liberation +Sans/Serif/Mono font, fully automatically and silently (see _Font Resolution_ and _Font Fallback +Resolution_ below, and `Fonts.SystemFontCatalog`'s own unit design, +`../canvas-net/fonts/system-font-catalog.md`). As of Phase 9, a composite +`/Subtype /Type0`/`/Encoding /Identity-H` font naming a single `/CIDFontType2` descendant font +(with its own embedded `/FontDescriptor/FontFile2`) is also resolved and rendered end to end, +decoding each shown string as 2-byte-per-code (CID) values rather than 1-byte-per-code; as of +Phase 12 (this phase), a `/CIDFontType0` descendant font (with its own embedded, non-CID-keyed- +CFF, `/OpenType`-wrapped `/FontDescriptor/FontFile3`) is resolved identically (see _Composite Font +Resolution_ below). As of Phase B, a `/Subtype /Type1` simple font with an embedded +`/FontDescriptor/FontFile` (a classic PostScript Type 1 program) is also resolved and rendered, +exactly like a `/FontFile2`-embedded `/TrueType` font; as of Phase C (this phase), a `/Subtype +/Type1` simple font with an embedded `/FontDescriptor/FontFile3` whose own `/Subtype` is `Type1C` +(a bare, un-wrapped CFF program) is resolved identically (see _Type 1C Font Resolution_ below); as +of Phase D (this phase), a `/Subtype /Type3` font (whose glyphs are arbitrary content-stream +procedures rather than an outline/CFF program) is also resolved and rendered, via its own +dedicated resolution and glyph-painting path (see _Type 3 Font Resolution_/_Type 3 Glyph Painting_ +below). As of Phase 16 (this phase), a document encrypted with the PDF "Standard" security +handler (`/Filter /Standard`) using RC4 (40 to 128-bit), AES-128 (`/CFM /AESV2`), or AES-256 using +the simpler R5 key derivation (`/CFM /AESV3`/`/R 5`) and an empty user password is also opened and +rendered transparently, with every indirect object's strings and every stream's raw bytes +decrypted before any other parsing logic observes them (see _Encryption (Standard Security +Handler)_ below); every other encrypted-document shape (a non-`/Standard` security handler, +`/R 6`'s "hardened hash" key derivation, a non-`/StdCF` crypt filter, or a document that genuinely +requires a non-empty password) still fails closed exactly as before. As of Phase 18 (this phase), +both `LoadType1CFont` (simple `/Type1` fonts) and `LoadCidFontType0Font` (composite `CIDFontType0` +descendant fonts) resolve a `/FontFile3` stream by sniffing the stream's own decoded bytes for a +recognized SFNT container (`'OTTO'`/`'true'`/`1.0`/`'ttcf'`) or a structurally plausible bare CFF +header, rather than gating on the stream's declared `/Subtype` name: a bare, non-SFNT-wrapped CFF +program is now accepted on a _composite_ `/Type0` font regardless of whether its `/Subtype` reads +`/CIDFontType0C`, `/Type1C`, `/OpenType`, or is absent entirely, and an SFNT-wrapped (`'OTTO'`) CFF +program is likewise now accepted on a _simple_ `/Type1` font regardless of its declared `/Subtype` +โ€” matching PDF 32000-1's own leniency around `/Subtype` versus the stream's actual byte container +shape, and real-world producers that emit one without exactly matching the other. Only a stream +whose bytes match neither recognized shape still fails closed with +`Codecs.UnsupportedImageFeatureException` (see _Type 1C Font Resolution_/_Composite Font +Resolution_ below). +**Phase 4 limitations (narrowed by Phase 6/9/12/B/C/D/18, see +above)**: CID-keyed CFF (`ROS`/`FDArray`/`FDSelect`), non-`/Identity-H` composite `/Encoding`s +(including `/Identity-V` and predefined CJK encodings), and `/MMType1` fonts remain +entirely unsupported and fail closed with `Codecs.UnsupportedImageFeatureException` (CID-keyed CFF +instead surfaces as `InvalidDataException` via `Fonts.CffTable.Parse`'s own existing rejection); +only the `/WinAnsiEncoding` and `/MacRomanEncoding` base encodings (plus `/Differences`) are +supported (an unrecognized base encoding also fails closed); text-rendering modes `0` (fill), `1` +(stroke), `2` (fill, then stroke), and `3` (invisible) are supported (clip modes `4`-`7` fail +closed); and no additional stream filters were added for any of these phases. +**Phase 3 limitations** (narrowed by Phase 7/13 and the `/Pattern` color-space phase, see below): +no transparency groups; shading/tiling pattern fills were added (`/ShadingType 2`/`3`, +`/PatternType 1`/`2` โ€” see _`/Pattern` Color Space (Shading and Tiling Patterns)_ below, reusing +the `/FunctionType 0` sampled-function evaluator originally landed as Phase 1 groundwork, +alongside new `/FunctionType 2`/`3` support), no `JPX` filter decoding (fails closed; +`LZWDecode`/`ASCII85Decode`/`ASCIIHexDecode`/`RunLengthDecode` are supported as of Phase 7, and +`CCITTFaxDecode` - Group 4 (T.6 MMR) only - is supported as of Phase 15, see below), and no +`/SMask`/alpha compositing (every decoded image is treated as fully opaque) โ€” these +remain out of scope for this phase and are silently skipped (any other undefined keyword) or +explicitly rejected (unsupported color spaces/filters/fonts/encodings/render modes), per the +operator/exception taxonomy documented below; a later phase is expected to add transparency +support. As of Phase 13 (see below), `Do` on a `/Subtype /Form` XObject is no longer one of these +rejected constructs: it decodes and executes the Form's content stream, subject to its own +documented Phase 13 limitations (no `/BBox` clipping, no `/Group` transparency-group handling, and +a hard recursion-depth limit of 12). + +### Purpose + +`PdfDocument` lets callers open a PDF document exactly once (from a stream or a file path), +inspect its page count and each page's display size/rotation, and request a render of any given +page into a `Surface` of a caller-chosen pixel size. Unlike the static, no-instance-state codecs +in `CanvasNet.Codecs` and `SvgCodec`, `PdfDocument` is an instantiable, disposable class: parsing +a PDF document's cross-reference data and page tree is comparatively expensive, and a caller +typically wants to call `GetPageInfo`/`Render` for several pages of the _same_ document without +re-parsing it each time โ€” a property a purely static API could not express. + +### Data Model + +- **`_buffer` (`byte[]`)** โ€” the document's entire input, fully buffered in memory at `Open` time + (regardless of whether the original `Stream` was seekable), since resolving an indirect object + requires random access back into arbitrary earlier byte offsets. +- **`_xref` (`Dictionary`, private nested `XrefEntry`)** โ€” the fully resolved + cross-reference table (object number โ†’ either a direct byte offset, or a compressed + stream-number/index-within-stream pair), merged across every classic/stream/hybrid section and + `/Prev` link discovered during `Open`, or rebuilt via the linear-scan fallback. Object number + alone is used as the lookup key (matching by generation number is not implemented โ€” a + documented Phase 1 simplification: real-world and fixture files overwhelmingly use generation + 0 for every object). +- **`_objectCache` (`Dictionary`)** โ€” memoizes each already-resolved indirect + object, so a page referenced from multiple ancestors (or looked up repeatedly across several + `GetPageInfo`/`Render` calls) is only ever parsed/decompressed once per `PdfDocument` instance. +- **`_pages` (`IReadOnlyList`)** โ€” every page's already-rotated + `PdfPageInfo`, computed once during `Open`'s page-tree traversal (see + _PageTree Inheritance_ below), so `GetPageInfo` is an O(1) list lookup rather than a + re-traversal. +- **`_disposed` (`bool`)** โ€” mirrors `Canvas.Surface`'s own disposal field exactly: `false` until + `Dispose()` is called, then permanently `true`; every other public member checks this first via + `ObjectDisposedException.ThrowIf(_disposed, this)`. +- **`_pageDetails`** + (`IReadOnlyList<(PdfObject Node, double X0, double Y0, double BoxWidth, double BoxHeight, PdfObject? Resources)>`) + โ€” the raw (non-rotated, non-swapped) leaf page node, its inherited `/MediaBox`'s origin and + size, and its inherited `/Resources` dictionary (`PdfObject?`, `null` when no ancestor declares + one โ€” added in Phase 3, inherited exactly like `/MediaBox`/`/Rotate`: nearest declaring + ancestor, never merged across ancestors), one entry per page in the same document order as + `_pages`, populated in lock-step by `TraversePageTree` โ€” `Render` needs the raw `/MediaBox` + origin (`_pages`/`PdfPageInfo` only exposes the already-rotation-swapped display width/height) + to correctly derive each page's base CTM (see `BuildBaseCtm` below), and needs `/Resources` to + resolve a `cs`/`CS`/`Do` operator's named color-space/XObject resource. +- **`_gsStack` (`Stack`, nested `GraphicsState` class, `PdfDocument.GraphicsState.cs`)** + โ€” the `q`/`Q` graphics-state stack, and `_gs` (`GraphicsState`) โ€” the current graphics state + (current transformation matrix, line width/cap/join/miter-limit/dash pattern, `FillColor`/ + `StrokeColor`/`FillColorSpace`/`StrokeColorSpace` (Phase 3), and, as of Phase 4, persistent + text state โ€” `Font` (the currently selected `IResolvedFont?`), `FontSize`, `CharSpacing`, + `WordSpacing`, `HorizontalScaling`, `Leading`, `RenderMode`, `TextRise`), both reset at the + start of every `ExecuteContentStream` call. `GraphicsState.Clone()` performs a member-wise copy + (the dash array reference and the resolved `Font` reference are shared, never mutated in + place, so sharing either across a clone is safe) โ€” text state is deliberately part of + `GraphicsState`, not a separate field, since `q`/`Q` must save/restore it exactly like every + other graphics-state parameter (the PDF specification's own rule). +- **`_resources` (`PdfObject?`, `PdfDocument.ContentStream.cs`, added in Phase 3)** โ€” the current + page's resolved `/Resources` dictionary (from `_pageDetails`), reset at the start of every + `ExecuteContentStream` call; consulted by `cs`/`CS` (`/Resources/ColorSpace`) and `Do` + (`/Resources/XObject`) to resolve a named resource. +- **`_pathBuilder` (`Geometry.PathBuilder`), `_currentPoint`/`_subpathStart` (`Vector2`, + untransformed user-space), `_hasOpenSubpath` (`bool`)** (`PdfDocument.PathOps.cs`) โ€” the path + currently under construction by the path-construction operators, and the bookkeeping the `v`/ + `y` curve shorthands and `h`'s closepath need in the same untransformed user-space coordinates + the next operator's own operands arrive in; `_hasOpenSubpath` mirrors `PathBuilder`'s own + internal "can draw" state purely so a malformed "draw before move" content stream can be + rejected with this class's own `InvalidDataException` convention rather than letting + `PathBuilder`'s `InvalidOperationException` escape unwrapped. All three are reset at the start + of every `ExecuteContentStream` call and cleared after every path-painting operator. +- **`_textMatrix`/`_lineMatrix` (`Matrix3x2`, `PdfDocument.Text.cs`, added in Phase 4)** โ€” the + current text-space-to-user-space matrix (`Tm`) and the line matrix `Tj`/`T*`/`TD` measure the + next line's `Td` displacement from. Deliberately fields of `PdfDocument` itself, not + `GraphicsState`: the PDF specification resets both to the identity matrix only at `BT`, and + `q`/`Q` never save/restore them (unlike every other text-state parameter, which does live on + `GraphicsState` โ€” see `_gsStack` above) โ€” a `q`/`Q` pair nested inside a `BT`/`ET` text object + must not perturb the running text position. Both are reset once per `ExecuteContentStream` + call and again by every `BT`. +- **`_fontCache` (`Dictionary`, `PdfDocument.Fonts.cs`, added in Phase + 4, generalized to the `IResolvedFont` abstraction in Phase 9)** โ€” memoizes each font + dictionary's resolved `IResolvedFont` (either a `ResolvedSimpleFont` โ€” embedded + `Fonts.TrueTypeFont`, 256-entry code-to-Unicode-codepoint encoding map, per-code `/Widths` map, + `/MissingWidth` โ€” or, as of Phase 9, a `ResolvedCompositeFont` โ€” descendant-font + `Fonts.TrueTypeFont`, CID-to-glyph-index map, per-CID `/W` map, `/DW` default width, and, as of + Phase 10, a `ToUnicode` code-to-Unicode-codepoint map resolved from the Type0 font dictionary's + own `/ToUnicode` CMap stream when present (`null` otherwise) โ€” resolved-but-unconsumed + groundwork this phase, not yet read anywhere at rendering time) by the font + dictionary `PdfObject`'s own reference identity, so `Tf` re-selecting the same font resource + repeatedly within one `Render` call never re-decodes/re-parses the embedded `FontFile2` bytes + more than once. Reset (cleared) at the start of every `ExecuteContentStream` call โ€” scoped to a + single `Render` call only, per this phase's documented caching contract (a later `Render` call + always re-resolves every font from scratch, trading a small amount of redundant work across + separate calls for never risking a stale reference into a different document's object graph). +- **`_surface` (`Canvas.Surface`, `PdfDocument.ContentStream.cs`)** โ€” the destination surface + every path-painting operator draws onto for the content stream currently being executed. +- **`PdfObject`/`PdfKind`** (internal, `PdfDocument.ObjectModel.cs`) โ€” a small tagged-union + representation of every PDF object kind (`Null`, `Boolean`, `Number`, `LiteralString`/ + `HexString`, `Name`, `Array`, `Dictionary`, `Stream`, `Reference`); not part of the public API + surface, but exposed as `internal` (with `InternalsVisibleTo` the test assembly) so the + tokenizer/object-model parsing logic can be unit tested directly against hand-written token + sequences, not only indirectly through full fixture files. As of Phase 16, `PdfObject` also + carries `ObjectNumber`/`Generation` (`int`, settable, default `-1`/`0`) โ€” stamped onto a + top-level indirect object's already-parsed value by `ParseIndirectObjectAt` after the fact, + since they are not known until the surrounding `N G obj` header has been read โ€” and `Bytes` + (`byte[]`) changed from `private init` to settable, so an encrypted stream/string's ciphertext + can be overwritten in place with its decrypted plaintext without rebuilding the object tree. + **Design decision**: both deliberately break this class's otherwise-uniform `private init` + immutability idiom; this is scoped intentionally to only ever be written by + `ParseIndirectObjectAt`/`DecryptStringsInPlace` (see _Encryption (Standard Security Handler)_ + below), since `_objectCache` shares a single `PdfObject` instance across every caller and + mutating it from anywhere else would alias-corrupt every other holder of the same reference. +- **`_encryptionKey` (`byte[]?`) / `_encryptionCipher` (`EncryptionCipher`, nested enum: `None`/ + `Rc4`/`Aes128`/`Aes256`)** (`PdfDocument.Encryption.cs`, added in Phase 16) โ€” the resolved file + encryption key and crypt method for an encrypted document whose empty user password + successfully authenticated, or `null`/`None` for an unencrypted document (the common case, + checked by `ParseIndirectObjectAt`/`GetStreamRawBytes` before ever attempting a decrypt). Set + exactly once, by `InitializeEncryption`, during construction. +- **`PdfPageInfo`** (public, `readonly record struct`, `PdfPageInfo.cs`) โ€” the resolved, + already-rotated per-page result: `Width`/`Height` (already swapped when the effective + `Rotation` is 90/270) and `Rotation` (the normalized `0`/`90`/`180`/`270` effective rotation). + Documented inline here (per `sysml2-modeling.md`'s "documented inline" convention for small + supporting value types), exactly as `Codecs.ImageInfo` is documented within its owning codecs' + design docs rather than as its own separate unit. **Design decision**: `Rotation` is reported + separately from the already-swapped `Width`/`Height`, even though `Width`/`Height` alone are + sufficient to size a `Render` call, because a later phase's content-stream-to-device + transformation matrix construction needs the effective rotation value itself, not just its + width/height side effect. + +### Key Methods + +- **`Open(Stream stream, string? password = null)`** / **`Open(string path, string? password = + null)`** โ€” `ArgumentNullException.ThrowIfNull` the `stream`/`path` first; `Open(string, ...)` + additionally rejects an empty or whitespace-only `path` with `ArgumentException` (mirroring + `PngCodec.Load(string)`'s exact null-then-empty check order, but checking + `string.IsNullOrWhiteSpace` rather than only length, per this system's request-specific hard + constraint). `Open(Stream, ...)` reads the stream fully into an in-memory buffer without ever + calling `Dispose`/`Close` on the caller's stream. `Open(string, ...)` opens its own internal + `FileStream`, reads it fully, and closes it (via `using`) before parsing โ€” mirroring + `PngCodec.Load(string)`'s open/consume/close pattern exactly โ€” then calls the same shared + private parse implementation the stream overload uses. Parsing runs the tokenizer, object + model, cross-reference resolution (classic/stream/ObjStm/hybrid, with the linear-scan + fallback), `/Encrypt` detection/decryption (see _Encryption (Standard Security Handler)_ + below, Phases 16/17), and page-tree traversal exactly once, producing a fully + resolved `PdfDocument` instance. The optional `password` parameter (added in Phase 17) is + consulted only when the resolved trailer declares an `/Encrypt` key: when `null` (the + default), only the empty user password is authenticated, matching Phase 16's original + encrypted-document support; when supplied, it is tried first as the user password and, if that + fails, as the owner password, so a caller need not know in advance which of the two passwords + it holds โ€” see _Encryption (Standard Security Handler)_ below for the full authentication and + fail-closed detail. +- **`PageCount` (get)** โ€” disposed-check, then returns `_pages.Count`. +- **`GetPageInfo(int pageIndex)`** โ€” disposed-check, then `ArgumentOutOfRangeException` for + `pageIndex < 0 || pageIndex >= PageCount`, then returns `_pages[pageIndex]` (already computed + during `Open`). +- **`Render(int pageIndex, int width, int height)`** โ€” disposed-check and `pageIndex` + range-check identical to `GetPageInfo`, then builds `new Surface(width, height)`, resolves the + page's leaf node, raw `/MediaBox` origin, and inherited `/Resources` via `ResolvePageDetails`, + derives the page's base CTM via `BuildBaseCtm` (see _Content-Stream Interpreter_ below), + resolves the page's `/Contents` bytes via `ResolvePageContentBytes`, executes them via + `ExecuteContentStream` (passing the resolved `/Resources`), and returns the painted surface. + `Surface`'s own constructor supplies the `width`/`height` `ArgumentOutOfRangeException`/ + `MaxDimension` contract; this is deliberately not duplicated here, and the caller-specified + `width`/`height` is used exactly as given โ€” it is never clamped to, or derived from, the page's + own `/MediaBox` size. +- **`Dispose()`** โ€” idempotent (mirrors `Surface`'s exact pattern): `if (_disposed) return;` then + `_disposed = true;`. No finalizer (only managed memory โ€” the buffered bytes and parsed object + model โ€” is held). +- **Tokenizer (`PdfDocument.Tokenizer.cs`)** โ€” a stateless-per-call lexer producing numbers, + literal/hex strings, names, array/dictionary delimiters, comments (skipped), and keyword + tokens, following the PDF specification's own whitespace/delimiter/regular character classes. + Reused unmodified by the content-stream interpreter (a content stream tokenizes with exactly + the same lexical rules as the rest of the document). +- **Object model parser (`PdfDocument.ObjectModel.cs`)** โ€” a recursive-descent parser turning the + tokenizer's output into a `PdfObject` tree (dictionaries, arrays, indirect references, + streams), distinguishing a bare number followed by an object-definition header (`N G obj`) + from an indirect reference (`N G R`) by one token of lookahead. Its `ParseArray`/`ParseValue` + helpers are reused, unmodified, by `ExecuteContentStream` to parse an operand token that begins + an array (content streams never legitimately need a content-stream-specific array parser). +- **Cross-reference resolution (`PdfDocument.Xref.cs`)** โ€” parses a classic `xref`/`trailer` + section, a `/Type /XRef` cross-reference stream (`/W`-width-driven binary field decoding), and + a `/Type /ObjStm` compressed object stream (`FlateDecode`, `System.IO.Compression.DeflateStream` + โ€” mirroring `PngCodec.Zlib.cs`'s established zlib-header/DEFLATE-payload decompression pattern, + except that the trailing Adler-32 checksum is deliberately not validated โ€” several real-world + PDF producers emit a wrong/placeholder checksum over an otherwise well-formed DEFLATE payload, + and other mainstream PDF readers tolerate this), merges a hybrid `/XRefStm` link's entries with its paired classic + section's own entries (a hybrid entry fills in an object number the classic section can only + mark free; classic entries elsewhere are untouched), and walks a `/Prev` incremental-update + chain (an earlier-processed, more recent section's entry always wins). Falls back to a linear + scan of the buffered document for `N G obj` markers when `startxref` is absent/unreadable or + the resolved trailer does not describe a valid `/Type /Catalog` root. **Documented + simplification**: a cross-reference stream's own `/Length` must be a direct integer, not an + indirect reference โ€” resolving an indirect `/Length` would require the very xref table that + stream is helping to build; regular content/object streams parsed after xref resolution + completes may use an indirect `/Length` freely. `GetStreamDecodedBytes` (also defined here) is + reused, unmodified, by `ResolvePageContentBytes` to decode a `/Contents` stream's bytes. +- **Page-tree traversal (`PdfDocument.PageTree.cs`)** โ€” walks `trailer["/Root"]` โ†’ catalog + `/Pages` โ†’ recursively nested `/Kids` arrays, producing an ordered, flattened page list. Each + page's `/MediaBox`/`/Rotate` is inherited from the nearest ancestor that declares one + (defaulting to US Letter `[0 0 612 792]` and rotation `0` when wholly undeclared anywhere in + the ancestry), normalized (`/Rotate` modulo 360, rejecting a non-multiple-of-90 value with + `InvalidDataException`), and used to compute each page's already-rotated `PdfPageInfo` + (swapping `Width`/`Height` at 90/270). A visited-node set (keyed by object number) detects and + rejects an unbounded `/Kids` reference cycle with `InvalidDataException`. `ResolveMediaBox` + additionally returns the box's raw `(X0, Y0)` origin (not just its `Width`/`Height`), threaded + through `TraversePageTree` into `_pageDetails`, since `BuildBaseCtm` needs that origin to + correctly translate a `/MediaBox` that does not start at `(0, 0)`. As of Phase 3, each node's + own `/Resources` (if declared) or the inherited value is likewise threaded through + `TraversePageTree` into `_pageDetails` (nearest-declaring-ancestor inheritance, matching + `/MediaBox`/`/Rotate`'s own pattern, per the PDF specification's own `/Resources` inheritance + rule โ€” never merged across ancestors). + `ResolvePageDetails(int pageIndex)` is `Render`'s own entry point into this data. + +### Encryption (Standard Security Handler) + +Added in Phase 16, `PdfDocument.Encryption.cs` detects an encrypted document and derives its file +encryption key, so every subsequent indirect-object string and stream read can transparently +decrypt its bytes before any of this class's other parsing logic ever sees them. Phase 17 extends +this to accept an optional caller-supplied `password`, threaded from both `Open` overloads and the +private constructor into `InitializeEncryption`: when `password` is `null` (the default), behavior +is byte-for-byte unchanged from Phase 16 (empty-user-password authentication only). When a +non-`null` password is supplied, it is first tried as the **user password** (the same Algorithm +2/4/5 or 2.A authentication path, now parameterized on the real password's encoded/padded bytes +instead of always the empty-password padding constant); if that does not authenticate, the same +supplied string is tried as the **owner password** โ€” ISO 32000-1 Algorithm 3 (R2-R4: recovers the +padded user password from `/O`, then re-derives and re-authenticates a candidate file key) or the +owner-password variant of ISO 32000-2 Algorithm 2.A (R5: recovers the file key directly from +`/OE`). If neither attempt authenticates, `Codecs.UnsupportedImageFeatureException` is thrown with +the new `pdf-encrypted-incorrect-password` feature token (distinguishable from the null-password +`pdf-encrypted-password-required` token, which is unchanged). **Encoding scope boundary**: R2-R4 +passwords are encoded via Latin-1 (โ‰ˆ PDFDocEncoding for the ASCII range) โ€” any character outside +ASCII 0-127 throws `pdf-encrypted-password-non-ascii` rather than silently deriving wrong key +material; R5 passwords are encoded via UTF-8 with no SASLprep/Unicode normalization (an +intentional, documented scope boundary that does not affect ordinary ASCII passwords). Both +encodings truncate to a maximum of 127 bytes before any hashing, per ISO 32000-1/2's own password +truncation rule. + +- **`InitializeEncryption(PdfObject trailer, string? password = null)`** โ€” replaces the previous, + unconditional-throw `CheckForEncryption`. Returns immediately (leaving `_encryptionKey` `null`) + when the trailer has no `/Encrypt` entry. Otherwise resolves (and thereby caches) the + `/Encrypt` dictionary's own object, validates `/Filter` is `/Standard` (else + `Codecs.UnsupportedImageFeatureException`, feature `pdf-encrypted-filter-{name}`), reads + `/V`/`/R`/`/Length`/`/O`/`/U`/`/P`/`/EncryptMetadata` and the trailer's `/ID` first element, + then dispatches on `/V`: `1`/`2` โ†’ RC4 via `InitializeRc4OrAesV2Encryption(โ€ฆ, password)`; `4` โ†’ + validates `/CF/StdCF/CFM` is `/AESV2` (else feature `pdf-encrypted-cfm-{name}`) then the same + RC4/AESV2 initialization path; `5` โ†’ rejects `/R 6` (feature `pdf-encrypted-r6-hardened-hash`) + and any other `/R` (feature `pdf-encrypted-r-{revision}`), validates `/CF/StdCF/CFM` is + `/AESV3`, computes `passwordBytes` (empty, or `EncodeR5PasswordBytes(password)`), tries + `TryComputeFileKeyAlgorithm2A` first and โ€” when `password is not null` and that returns `null` + โ€” falls back to `TryComputeFileKeyAlgorithm2AOwnerPassword` (reading `/OE`), throwing the + appropriate token (`pdf-encrypted-password-required` or `pdf-encrypted-incorrect-password`) if + both fail; any other `/V` fails with feature `pdf-encrypted-v-{version}`. **Ordering invariant + and cache invalidation**: resolving the + `/Encrypt` dictionary happens, and is cached by `GetObject`, before `_encryptionKey` is ever + set, so its own `/O`/`/U`/`/OE`/`/UE` strings are never mistakenly decrypted โ€” this part is + still relied on deliberately, instead of also carrying a redundant "is this the Encrypt + dictionary's own object number" guard field, to avoid dead/speculative code (see + `PdfDocument_Open_Encrypted_EncryptDictionaryStringsAreNeverDecrypted` for the regression test). + However, by the time `InitializeEncryption` runs, _other_ objects may already be cached pre-key + too: the trailer's own `/Root` Catalog (resolved by the constructor's own `IsValidCatalogRoot` + check before `InitializeEncryption` ever runs) and, on the linear-scan fallback path, _every_ + object in the document (resolved by `BuildLinearScanFallback`'s own scan looking for a + `/Type /Catalog` object) โ€” none of those pre-key cache entries' own strings were decrypted + either. Once `_encryptionKey` is established by whichever `/V` branch succeeds, this method + calls its own `InvalidateObjectCacheExceptEncryptDictionary(encryptEntry)` helper, which clears + `_objectCache` entirely except for the `/Encrypt` dictionary's own already-cached entry (kept + exactly as originally cached, pre-key, preserving the invariant above), so every other + previously-cached object is correctly re-resolved โ€” and, this time, decrypted โ€” the next time + anything asks for it (`_xref`, the offset/compressed-entry table itself, is never cleared, only + `_objectCache`, the parsed-value cache, so re-resolution is always possible). See + `PdfDocument_Open_Encrypted_CatalogOwnStringIsDecrypted` for the regression test covering the + Catalog case. +- **`PadPasswordBytes(byte[] passwordBytes)`** โ€” pads/truncates arbitrary password bytes to + exactly 32 bytes using `PasswordPadding`'s own bytes for the remainder; for an empty input this + reproduces `PasswordPadding` verbatim, preserving byte-for-byte null-password behavior. + **`EncodeR2R4PasswordBytes(string password)`** โ€” Latin-1-encodes the password (โ‰ˆ PDFDocEncoding + for ASCII), throwing `Codecs.UnsupportedImageFeatureException` (feature + `pdf-encrypted-password-non-ascii`) if any character is outside ASCII 0-127, then truncates the + encoded bytes to 127. **`EncodeR5PasswordBytes(string password)`** โ€” UTF-8-encodes the password + (no SASLprep/Unicode normalization โ€” an intentional scope boundary) then truncates to 127 bytes. +- **`ComputeFileKeyAlgorithm2(byte[] paddedPasswordBytes, โ€ฆ)`** (ISO 32000-1 Algorithm 2) โ€” takes + an already-padded 32-byte password (`PadPasswordBytes`'s own output for the empty/user-password + case, or Algorithm 3's recovered bytes directly for the owner-password case, which must **not** + be re-padded), MD5-hashes it with `/O`, `/P` (4-byte little-endian signed integer), the document + ID, and (revision โ‰ฅ 4 with `/EncryptMetadata` explicitly `false`) four trailing `0xFF` bytes, + then โ€” for revision โ‰ฅ 3 โ€” re-hashes the first `keyLengthBytes` of the digest 50 more times via + the shared `Rehash50RoundsIfRevisionAtLeast3` helper; the file key is the first `keyLengthBytes` + of the final digest (`/Length` in bits รท 8, defaulting to 5 bytes/40 bits, except `/V 1` which is + always exactly 5 bytes regardless of `/Length`). +- **`TryAuthenticateUserPasswordAlgorithm45`** (ISO 32000-1 Algorithm 4 for revision 2, Algorithm + 5 for revision 3/4) โ€” recomputes the expected `/U` value from the file key (Algorithm 2 alone + never fails; only this comparison can detect a wrong password) and compares it against the + document's actual `/U` (only the first 16 of 32 bytes for revision 3/4, since the trailing 16 + are producer-defined padding), returning `bool` instead of throwing (Algorithm 4/5 always + hashes the literal 32-byte `PasswordPadding` constant, never the actual supplied password, per + ISO 32000-1 ยง7.6.3.4/7.6.3.3 โ€” this comparison is identical regardless of which password role is + being tested). +- **`RecoverPaddedUserPasswordAlgorithm3(byte[] paddedOwnerPasswordBytes, byte[] oBytes, โ€ฆ)`** (ISO + 32000-1 Algorithm 3, decrypt direction, R2-R4 owner-password path only) โ€” MD5-hashes the padded + owner-password guess, re-hashes 50 rounds for revision โ‰ฅ 3 via the shared helper, then RC4- + decrypts `/O`: a single pass for revision 2, or 20 rounds from round 19 down to round 0 (round 0 + using the unmodified owner key, rounds 1-19 using the key XORed with the round number) for + revision 3/4 โ€” returning the recovered 32-byte padded user password, which `InitializeRc4OrAesV2Encryption` + feeds directly (without re-padding) into `ComputeFileKeyAlgorithm2` to derive a candidate file + key, re-verified via `TryAuthenticateUserPasswordAlgorithm45`. +- **`ComputeObjectKeyAlgorithm1`** (ISO 32000-1 Algorithm 1, RC4/AESV2 only) โ€” MD5-hashes the file + key plus the object's 3-byte little-endian object number and 2-byte little-endian generation + number (plus the 4 literal ASCII bytes `sAlT` for AESV2); the per-object key is the first + `min(fileKeyLength + 5, 16)` bytes of that digest. +- **`TryComputeFileKeyAlgorithm2A(byte[] passwordBytes, โ€ฆ)`** (ISO 32000-2 Algorithm 2.A, R5/AESV3 + only, user-password path) โ€” authenticates by comparing `SHA-256(passwordBytes โ€– /U`'s 8-byte + validation salt`)` against `/U`'s own embedded 32-byte hash, returning `null` (instead of + throwing) on a mismatch, otherwise AES-256-CBC-decrypts `/UE` (zero IV, no padding) using + `SHA-256(passwordBytes โ€– /U`'s 8-byte key salt`)` as the key, yielding the 32-byte file + encryption key directly โ€” used as-is for every string/stream, with no further per-object + derivation (unlike RC4/AESV2). +- **`TryComputeFileKeyAlgorithm2AOwnerPassword(byte[] passwordBytes, byte[] oBytes, byte[] oeBytes, byte[] uBytes)`** + (owner-password variant of ISO 32000-2 Algorithm 2.A, R5/AESV3 only) โ€” hashes/encrypts over + `passwordBytes โ€– salt โ€– U` where `U` is the **full 48-byte** `/U` value (not a sub-slice): + compares `SHA-256(passwordBytes โ€– /O`'s 8-byte validation salt โ€– fullU`)` against `/O`'s own + 32-byte hash, returning `null` on a mismatch, otherwise AES-256-CBC-decrypts `/OE` (not `/UE`, + zero IV, no padding) using `SHA-256(passwordBytes โ€– /O`'s 8-byte key salt โ€– fullU`)` as the key, + yielding the 32-byte file encryption key directly. +- **`DecryptStreamBytes`/`DecryptStringsInPlace`** โ€” dispatch on `_encryptionCipher`: RC4 + re-derives the per-object key and XORs; AES-128 derives the per-object key with the `sAlT` + suffix, then AES-128-CBC/PKCS7-decrypts a leading-16-byte-IV-prefixed ciphertext; AES-256 + (R5) uses `_encryptionKey` directly against the same IV-prefixed wire format. Called, + respectively, by `GetStreamRawBytes` (before the generic `/Filter`/`/DecodeParms` pipeline + runs) and `ParseIndirectObjectAt` (recursing every `PdfKind.String` found anywhere within a + freshly-parsed top-level indirect object's value, never recursing into `PdfKind.Reference` + nodes). A compressed (`/Type /ObjStm`-contained) object is never decrypted a second time: it is + reached exclusively via `LoadCompressedObject`, which never calls `DecryptStringsInPlace` โ€” + its strings were already decrypted once, as part of its containing `/Type /ObjStm` stream's own + raw bytes being decrypted by `GetStreamRawBytes` before decompression. +- **`Rc4Transform`** โ€” a from-scratch, hand-rolled RC4 stream cipher (classic KSA/PRGA), since + RC4 is not a built-in .NET primitive; symmetric, so the same implementation both encrypts and + decrypts. + +**Scope boundary**: only the `/Filter /Standard` security handler is supported, and only RC4 +(`/V 1`/`/V 2`), AES-128 (`/V 4`/`/CFM /AESV2`), and AES-256 using the simpler R5 key derivation +(`/V 5`/`/R 5`/`/CFM /AESV3`) are supported. A caller-supplied password (R2-R4: Latin-1/ASCII only; +R5: UTF-8, no SASLprep normalization; both: 127-byte truncation) is tried as the user password +then the owner password, as described above. Every other shape (a non-`/Standard` filter, `/R 6`'s +"hardened hash" key derivation, a crypt filter other than the standard `/StdCF`, a document whose +user/owner password does not match the supplied (or default empty) password, or an R2-4 password +containing a non-ASCII character) fails closed with `Codecs.UnsupportedImageFeatureException` and +its own distinguishable `Feature` token. + +### Content-Stream Interpreter + +- **`BuildBaseCtm(x0, y0, boxWidth, boxHeight, rotation, width, height)` + (`PdfDocument.PageTree.cs`)** โ€” derives the page's base (pre-content-stream) current + transformation matrix as `Translate(-x0, -y0) * RotationFlip(rotation, boxWidth, boxHeight) * + Scale(scaleX, scaleY)` (row-vector composition: a user-space point is first translated by the + raw `/MediaBox` origin, then rotated/flipped, then scaled to the requested render size), where: + - `RotationFlip` is one of four literal matrices mapping PDF user-space (origin bottom-left, + y-axis up) to un-scaled device pixel-space (origin top-left, y-axis down) for the page's + effective rotation: `Rotate0 = (1,0,0,-1,0,boxHeight)`, `Rotate90 = (0,1,1,0,0,0)`, + `Rotate180 = (-1,0,0,1,boxWidth,0)`, `Rotate270 = (0,-1,-1,0,boxHeight,boxWidth)` โ€” confirmed + by hand-checking each case maps `(0,0)`/`(boxWidth,boxHeight)` to the expected device corner. + - `scaleX = width / displayWidth`, `scaleY = height / displayHeight`, where `displayWidth`/ + `displayHeight` are the already rotation-swapped dimensions (matching `PdfPageInfo.Width`/ + `Height`, and mirroring `SvgCodec.Load`'s own independent-X/Y requested-size scaling). +- **`ExecuteContentStream(byte[] contentBytes, Surface surface, Matrix3x2 baseCtm, PdfObject? resources)` + (`PdfDocument.ContentStream.cs`)** โ€” resets `_surface`/`_resources`/`_gsStack`/`_gs`/ + `_pathBuilder`/`_currentPoint`/`_subpathStart`/`_hasOpenSubpath`, then tokenizes + `contentBytes` exactly like the rest of the document, accumulating operands (numbers, names, + strings, arrays) until a `Keyword` token is reached, at which point `DispatchOperator` is + called and the operand list is cleared โ€” content-stream operators are always postfix (operands + first, operator keyword last). +- **`DispatchOperator(string operatorName, List operands)` + (`PdfDocument.ContentStream.cs`)** โ€” a single `switch` over every operator this phase + implements (graphics-state, path-construction, path-painting, device-color, and + image-XObject), silently doing nothing for any other keyword (text, clipping, `gs` + (ExtGState), shading, inline images, and any other operator not yet implemented). +- **`ResolvePageContentBytes(PdfObject pageNode)` (`PdfDocument.ContentStream.cs`)** โ€” resolves + `/Contents`: a single stream is decoded directly via `GetStreamDecodedBytes`; an array of + streams is decoded entry-by-entry and concatenated with a single space byte inserted between + each entry (per the PDF specification's own requirement, so adjacent tokens from different + streams can never merge); a page with no `/Contents` key returns an empty array (rendered as a + blank page, exactly as every page did in Phase 1). +- **Graphics-state operators (`PdfDocument.GraphicsState.cs`)** โ€” `OpPushGraphicsState` + (`q`, pushes a clone of `_gs`), `OpPopGraphicsState` (`Q`, pops into `_gs`, tolerating an empty + stack as a documented no-op leniency distinct from this class's strict operand-count/type + checks), `OpConcatMatrix` (`cm`, right-multiplies the supplied matrix onto `_gs.CurrentTransform` + per the specification: `CTM' = M ร— CTM`, so a point transforms as `P ร— M ร— CTM`, applying the + newest `cm` first), and `OpSetLineWidth`/`OpSetLineCap`/`OpSetLineJoin`/`OpSetMiterLimit`/ + `OpSetDashPattern` (`w`/`J`/`j`/`M`/`d`, storing their operand(s) verbatim on `_gs`). Every + handler validates its operand count/type via a shared `RequireNumbers` helper, throwing + `InvalidDataException` on mismatch. +- **Path-construction operators (`PdfDocument.PathOps.cs`)** โ€” `OpMoveTo`/`OpLineTo`/`OpCurveTo`/ + `OpCurveToV`/`OpCurveToY`/`OpClosePath`/`OpRectangle` (`m`/`l`/`c`/`v`/`y`/`h`/`re`), each + transforming its own operands through the current CTM via a shared `Transform` helper before + calling into `_pathBuilder` (matching `SvgCodec.PathRender.cs`'s established "bake the + transform into each point before construction" pattern, rather than transforming the finished + path afterward) โ€” `v` reuses `_currentPoint` (transformed at draw time, not pre-transformed) as + its first control point, and `y` reuses its own endpoint as its second control point, per the + specification's documented shorthand semantics. `re` always ends its own subpath already closed + (equivalent to `m l l l h`), so a subsequent draw operator without an intervening `m`/`re` + throws `InvalidDataException` via a shared `RequireOpenSubpath` helper. +- **Path-painting operators (`PdfDocument.PathOps.cs`)** โ€” a single shared `PaintCurrentPath` + method parameterized by `fill`/`fillRule`/`stroke`/`closeFirst`, covering all ten operators + (`f`/`F`/`f*`/`S`/`s`/`B`/`B*`/`b`/`b*`/`n`): fills via `Drawing.PathFiller.Fill` with the + current graphics state's `FillColor` and the operator's documented fill rule; strokes via a + shared `PaintStroke` helper (extracted so it is also reusable by `PdfDocument.Text.cs`'s + `ShowGlyph`, for `Tr` modes `1`/`2` - see _Text Rendering_ below), which runs + `Drawing.PathStroker.Stroke` then fills the resulting outline with `StrokeColor`, using a + `Drawing.StrokeStyle` built from the current graphics state's line width/cap/join/miter-limit/ + dash pattern โ€” every `StrokeStyle` field is passed explicitly (the PDF specification's own + default miter limit is `10`, not `StrokeStyle`'s own C# default of `4`). `DeviceScale` + (`sqrt(|det(CTM.Linear)|)`, the CTM with its translation zeroed) converts a user-space line + width/dash length into device pixel-space, with a `MinimumDeviceLineWidth` of one device pixel + implementing the specification's "a line width of 0 renders as the thinnest renderable line" + rule. Every path-painting operator, including `n`, always clears the current path afterward + (`_pathBuilder.Clear()`); the surrounding graphics state is entirely unaffected by that clear, + so a subsequent path in the same content stream still sees the same CTM/stroke style/color. +- **Device color operators (`PdfDocument.Color.cs`, added in Phase 3; color-space model replaced + in Phase 14)** โ€” a private, immutable `PdfColorSpace` class (nested `Family` enum: + `DeviceGray`/`DeviceRGB`/`DeviceCMYK`/`Indexed`, with shared `DeviceGray`/`DeviceRGB`/ + `DeviceCMYK` singleton instances and an `Indexed` factory carrying the base color space, the + highest valid palette index (`Hival`), and the raw, un-normalized palette bytes) tracks + `GraphicsState.FillColorSpace`/`StrokeColorSpace`. `OpSetGrayFill`/`OpSetGrayStroke` (`g`/`G`, + 1 operand), `OpSetRgbFill`/`OpSetRgbStroke` (`rg`/`RG`, 3 operands), and + `OpSetCmykFill`/`OpSetCmykStroke` (`k`/`K`, 4 operands, `R = 255 * (1 - C) * (1 - K)` and the + equivalent formulas for `G`/`B`) each clamp every component into `[0, 1]` (a documented + leniency for a slightly out-of-spec producer's cosmetic overshoot โ€” never rejected) before + converting via a shared `ColorFromComponents` helper (also reused, unmodified, by + `PdfDocument.Images.cs` for raw image samples). `OpSetColorSpaceFill`/`OpSetColorSpaceStroke` + (`cs`/`CS`, exactly 1 name operand) resolve `DeviceGray`/`DeviceRGB`/`DeviceCMYK` directly, or + else look the name up in the current page's `/Resources/ColorSpace` dictionary + (`ResolveColorSpaceByName`), resetting color to opaque black (the PDF specification's own + documented `cs`/`CS` reset rule) โ€” an array-shaped color space is dispatched by + `ResolveColorSpaceValue` to `ResolveIccBasedColorSpace` (`/ICCBased`) or + `ResolveIndexedColorSpace` (`/Indexed`, Phase 14 โ€” see below), or resolved directly to + `DeviceRGB` for `[/CalRGB dict]` (ignoring its own `/WhitePoint`/`/Gamma`/`/Matrix` entries); any + other resolved color space (`Separation`/`DeviceN`/`CalGray`/`Lab`, or an undeclared name) throws + `Codecs.UnsupportedImageFeatureException`, naming the unsupported space; a `/Pattern` array + form is dispatched instead to `PdfColorSpace.Pattern` (see _`/Pattern` Color Space (Shading and + Tiling Patterns)_ below). `OpSetColorFill`/`OpSetColorStroke` (`sc`/`SC`/`scn`/`SCN`) require + exactly `ComponentCount` numeric operands for the current color space when it is not + `/Pattern`; a `/Pattern` color space instead dispatches `scn`/`SCN` (never `sc`/`SC`, which the + specification never pairs with a pattern name) to the dedicated pattern-operand path described + below. Every malformed operand count/type throws `InvalidDataException`, matching every other + operator in this class. + - **`/ICCBased`/`/Indexed` color-space resolution (`PdfDocument.Color.cs`, Phase 14 โ€” this + phase)** โ€” `ResolveIccBasedColorSpace` validates a 2-element `[/ICCBased streamRef]` array + whose 2nd element resolves to a stream (else `Codecs.UnsupportedImageFeatureException` โ€” a + malformed-but-still-rejected-as-unsupported case, not `InvalidDataException`, matching this + color space's own "well-formed PDF, out-of-scope feature" posture), then prefers the stream's + `/Alternate` entry when present and itself resolves (via a recursive `ResolveColorSpaceValue` + call) to a supported color space, deliberately catching only + `Codecs.UnsupportedImageFeatureException` from that recursive call to fall back to `/N`-based + resolution (an `InvalidDataException` from a structurally malformed `/Alternate` is allowed to + propagate unmodified โ€” a different, non-swallowed failure class) โ€” falling back maps the + stream's `/N` (`1`/`3`/`4`) to `DeviceGray`/`DeviceRGB`/`DeviceCMYK`, else throws + `Codecs.UnsupportedImageFeatureException`. `ResolveIndexedColorSpace` validates a 4-element + `[/Indexed baseSpace hival lookup]` array (else `InvalidDataException` โ€” a wrong array shape + is structurally malformed PDF, unlike `/ICCBased`'s own convention), resolves `baseSpace` + recursively via `ResolveColorSpaceValue` (propagating an unsupported base space's own + `Codecs.UnsupportedImageFeatureException` unmodified), validates `/Hival` is a non-negative + number, resolves `lookup` (a PDF string's raw bytes, or a stream decoded via + `GetStreamDecodedBytes`), and validates the resolved palette has at least + `(Hival + 1) * ComponentCount(baseSpace)` bytes (else `InvalidDataException` in each case). + `ComponentCount` reports `1` for `Indexed` (the palette index itself); `ColorFromComponents` + dispatches an `Indexed` color's single raw (not `[0, 1]`-normalized) index component to + `IndexedToColor`, which rounds/clamps the index into `[0, Hival]` (an out-of-range index + clamps to the nearest valid entry rather than being rejected โ€” a documented leniency mirroring + this phase's existing color-component clamping precedent), looks up that palette entry's raw + bytes, normalizes each byte to `[0, 1]`, and recurses into the base color space's own + `ColorFromComponents` โ€” shared identically by both the `sc`/`scn` color operators and an image + XObject's own `/ColorSpace` decoding (see below). +- **Stream filter pipeline (`PdfDocument.Filters.cs`, added in Phase 3; extended in Phase 7)** โ€” + generalizes the Phase 1/2 single-filter `GetStreamDecodedBytes` into an ordered + `/Filter`/`/DecodeParms` pipeline (`ResolveFilterPipeline`), preserving the exact existing + behavior (bare `FlateDecode`, no predictor) for cross-reference streams, object streams, and + page `/Contents` โ€” none of which regress. Each `FlateDecode` step decompresses via the existing + `ZlibDecompress`; each `LZWDecode` step decompresses via `PdfDocument.Filters.Lzw.cs`'s + `DecodeLzw` โ€” a from-scratch PDF-variant LZW decoder (fixed 258-entry initial table, MSB-first + variable 9-12 bit code packing, `/DecodeParms /EarlyChange`-controlled growth timing, mid-stream + Clear-code reinitialization), deliberately reimplemented rather than reused from either + `Codecs/Gif/GifCodec.Lzw.cs` (LSB-first bit packing, no `EarlyChange` concept, GIF-specific + Clear/EOI code values) or `Codecs/Tiff/TiffCodec.Compression.cs` (MSB-first and a fixed + early-change timing, but no `/EarlyChange` toggle) โ€” both were read only as structural + references, per the same "independent reimplementation, cited by name for diffability" pattern + already used for the PNG predictor below. `ASCII85Decode`/`ASCIIHexDecode` decode via + `PdfDocument.Filters.Ascii.cs` (base-85/hex-digit ASCII armoring, per ISO 32000-1/2 + ยง7.4.3/ยง7.4.2), and `RunLengthDecode` decodes via `PdfDocument.Filters.RunLength.cs` (the + PackBits-style scheme of ยง7.4.5). After a `FlateDecode` or `LZWDecode` step only (never after + `ASCII85Decode`/`ASCIIHexDecode`/`RunLengthDecode`, which the PDF specification never pairs with + a predictor), when `/DecodeParms` declares `/Predictor > 1`, the pipeline reverses either a TIFF + predictor (`/Predictor 2` โ€” per-row horizontal-difference reversal, requiring + `/BitsPerComponent 8`) or a PNG predictor (`/Predictor 10`-`15` โ€” an independent + reimplementation, named identically for diffability, of + `Codecs/Png/PngCodec.Filtering.cs`'s `DefilterRow`/`Sub`/`Up`/`Average`/`Paeth`/Paeth-predictor + algorithm, since those methods are `private` in a different assembly and cannot be reused + directly). Any other filter name (including `DCTDecode`/`CCITTFaxDecode`, which `PdfDocument.Images.cs` + always detects and bypasses before calling this pipeline; and `JPXDecode`/`JBIG2Decode`/ + `Crypt`, which remain out of scope) throws + `Codecs.UnsupportedImageFeatureException`; a malformed `/Filter`/`/DecodeParms` shape, an + unrecognized `/Predictor` value, or malformed bytes for any of the five supported filters + (missing EOD marker, invalid/out-of-range code or character, truncated run, or an out-of-range + ASCII85 group value) throws `InvalidDataException`. +- **Image XObjects (`PdfDocument.Images.cs`, added in Phase 3; extended in Phase 15)** โ€” + `OpDrawXObject` (`Do`) + resolves a named XObject from the current page's `/Resources/XObject` dictionary; a + `/Subtype /Form` XObject is dispatched to `OpDrawFormXObject` (see below, added in Phase 13); a + `/Subtype /Image` XObject is decoded (`DecodeImageXObject`) and composited onto `_surface` + through `_gs.CurrentTransform` (`CompositeImageOntoSurface`). `DecodeImageXObject` detects a + bare `/Filter /DCTDecode` image and decodes its raw bytes directly via + `Codecs.JpegCodec.Load(Stream)` (bypassing the general Flate+predictor pipeline entirely, + trusting `JpegCodec`'s own decoded width/height over the PDF `/Width`/`/Height` as a documented + leniency); a bare `/Filter /CCITTFaxDecode` image is likewise detected and dispatched to + `DecodeCcittFaxImageXObject` (new in Phase 15), which resolves `/K`/`/Columns`/`/Rows`/ + `/BlackIs1`/`/EncodedByteAlign`/`/EndOfLine` from the (possibly absent) sole `/DecodeParms` + entry, decodes via `PdfDocument.CcittFax.cs`'s `DecodeCcittFax` (see below), and โ€” mirroring + `DCTDecode`'s own leniency โ€” trusts `/Columns`/the resolved `/Rows` (not `/Width`/`/Height`) as + the authoritative surface dimensions; `CCITTFaxDecode` combined with any other filter throws + `InvalidDataException`, mirroring the equivalent `DCTDecode` check; any other supported case + decodes via `GetStreamDecodedBytes` and interprets the raw + samples per `/ColorSpace` (device spaces, `/ICCBased`, and `/Indexed` โ€” see above, reusing + `ColorFromComponents`; an `/Indexed` sample is passed through `SamplesToColor` as a raw, + un-normalized palette index rather than divided by `255.0` like every other color space) and + `/BitsPerComponent` (`8` only โ€” anything else throws + `Codecs.UnsupportedImageFeatureException`); `/SMask`/`/Mask` are never consulted (every decoded + image is treated as fully opaque, a documented Phase 3 limitation). + `CompositeImageOntoSurface` inverts the CTM (a non-invertible/degenerate CTM silently paints + nothing), computes the device-space bounding box of the transformed unit square, and for every + destination pixel in that box nearest-neighbor-samples the source image (`row = floor((1 - v) * + image.Height)`, since image sample row `0` is the _top_ of the unit square per the PDF + specification's image-space convention, the opposite of user-space's y-up convention) โ€” no + bilinear interpolation, a documented Phase 3 simplification consistent with Phase 2's own + stroke-width simplification precedent. +- **CCITT Group 4 fax decoding (`PdfDocument.CcittFax.cs`, added in Phase 15)** โ€” a from-scratch + ITU-T T.6 decoder, implementing two-dimensional MMR coding only (`/K` must be negative; + non-negative `/K`, i.e. Group 3, and `/EndOfLine true` are rejected up front by the top-level + `DecodeCcittFax` entry point with `Codecs.UnsupportedImageFeatureException`, before any bits are + read). `CcittBitReader` (nested, `internal` rather than `private` specifically so the test + project's `InternalsVisibleTo` can exercise it directly for a table self-consistency test โ€” see + verification) is an MSB-first bit reader with an `AlignToByte` method for `/EncodedByteAlign`. + `ModeCodeTable` holds the nine two-dimensional mode codes (Pass `0001`, Horizontal `001`, + vertical `V0` `1`/`VR1` `011`/`VL1` `010`/`VR2` `000011`/`VL2` `000010`/`VR3` `0000011`/`VL3` + `0000010`) as a prefix-free `(bit length, code value) โ†’ CcittMode` lookup, read bit-by-bit via + `ReadMode` (safe because no valid mode code is a bit-prefix of another, so the first table match + at the shortest accumulated length is always correct โ€” the same reasoning applies to the + run-length tables below). `ModeCodeTable`/`CcittMode`/`ReadMode` are themselves `internal` + rather than `private`, for the same cross-assembly table self-consistency test reason as + `CcittBitReader` above. `WhiteCodeTable`/`BlackCodeTable` (`internal`, for the same + cross-assembly test reason) are built by `BuildRunLengthCodeTable` from literal + (run length, code bits, code length) triples transcribed from the ITU-T T.4 Modified Huffman + terminating-code (`0`-`63`) and makeup-code (`64`-`1728`, plus the extended makeup codes + `1792`-`2560` shared identically between the White and Black tables) tables, cross-checked + against libtiff's long-trusted, independently maintained `t4.h` table values (used only as + transcribed numeric facts from the published ITU-T recommendation, not as copied code); + `ReadVariableLengthCode` (`internal`) reads one such code bit-by-bit, and `ReadRun` chains zero + or more makeup codes (run `โ‰ฅ 64`) followed by exactly one terminating code (run `< 64`) to + assemble a full run length, since a single run can exceed the largest single code's value. + `DecodeCcittRow` implements the per-row two-dimensional reference-line decode loop: starting + from an imaginary all-white line for row `0` (so the first row's changing-element lookups + default to `Columns`), it tracks the coding position `a0` (`-1` before the first element) and + current color, reads one mode code per coding element via `ReadMode`, computes the reference + line's changing elements `(b1, b2)` via `FindB1B2` (the first element on the reference line past + `a0` with color opposite the current coding color, and the one after it), and branches: Pass + mode advances `a0` to `b2` without recording a changing element; Horizontal mode reads two + run-length codes (current color, then the opposite) via `ReadRun`/`ReadVariableLengthCode` and + records two changing elements; each vertical mode (`V0`/`VR1`-`VR3`/`VL1`-`VL3`) computes the + new changing element as `b1 + delta` (`VerticalDelta`, `delta` in `-3`..`+3`), records one + changing element, and toggles color. `PackRow`/`SetBitRange` assemble each row's changing + elements into packed-1-bit-per-pixel bytes of exactly `(Columns + 7) / 8` bytes โ€” the same + `rowBytes` convention `ApplyTiffPredictor`/`ApplyPngPredictor` already use โ€” honoring + `/BlackIs1` (default `false`: packed bit `1` means black, per ISO 32000-1/2 Table 11, the + opposite of this library's black`=0`/white`=max` `DeviceGray` convention, so the packed-bit + polarity is deliberately inverted from `DeviceGray` and only normalized back during expansion); + `ExpandPackedBitsToGrayBytes` then expands each packed row into one 8-bit gray byte (`0` or + `255`) per pixel for the shared `/ColorSpace` pipeline (see above) to consume identically to + every other image XObject's raw samples. A malformed/truncated stream (an unrecognized mode + code, a changing-element search that runs past `Columns`, or running out of bits mid-row) throws + `InvalidDataException` rather than producing a corrupt or out-of-bounds row. +- **Form XObjects (`PdfDocument.Images.cs`, added in Phase 13)** โ€” `OpDrawFormXObject` checks + `_formNestingDepth` against `MaxFormNestingDepth` (`12`, throwing `InvalidDataException` when + reached), reads the Form's optional `/Matrix` via `ReadFormMatrix` (identity when absent, + `InvalidDataException` for a present-but-malformed array) and concatenates it into the CTM + using exactly the same left-multiply convention `OpConcatMatrix` uses for `cm` + (`formMatrix * _gs.CurrentTransform`), resolves the Form's own `/Resources` when present (else + reuses the invoking stream's current `_resources` unchanged), decodes the Form's stream bytes + via the same `GetStreamDecodedBytes` pipeline every other stream uses, and re-enters + `ExecuteOperators` directly (not `ExecuteContentStream`) against a cloned graphics state and a + fresh, empty `_gsStack` โ€” so an unbalanced `q`/`Q` inside the Form can never touch the invoking + stream's own saved states. A `try`/`finally` around the nested call unconditionally restores the + invoking stream's own `_resources`/`_gs`/`_gsStack` (success or exception), implementing an + implicit `q` ... `Q` bracketing around the Form's own CTM/color/font-selection mutations, while + deliberately leaving `_pathBuilder`/`_currentPoint`/`_fontCache` (not part of the PDF graphics- + state stack) untouched, so path-painting/surface side effects performed by the Form's content + persist exactly like any other painting operator's. No `/BBox` clipping and no `/Group` + (transparency group) handling is performed โ€” a documented Phase 13 limitation. +- **Font resolution (`PdfDocument.Fonts.cs`, added in Phase 4, fallback branch rewritten in + Phase 6, dispatch generalized to `IResolvedFont` in Phase 9, widened to `/Type1` in Phase B, + widened to `/Type1` + bare `/FontFile3` Type1C/CFF in Phase C, widened to `/Type3` in Phase D)** + โ€” `ResolveFont(PdfObject fontResource)` looks up (and caches, via `_fontCache`) an + `IResolvedFont`. `BuildResolvedFont` dispatches on `/Subtype`: `/TrueType` and `/Type1` both + build a `ResolvedSimpleFont` via `BuildResolvedSimpleFont`; `/Type0` builds a + `ResolvedCompositeFont` via `BuildResolvedCompositeFont` (see _Composite Font Resolution_ + below); `/Type3` builds a `ResolvedType3Font` via `BuildResolvedType3Font` (see _Type 3 Font + Resolution_ below); any other `/Subtype` (`/MMType1`) throws + `Codecs.UnsupportedImageFeatureException` naming the rejected subtype. `IResolvedFont` exposes + `Font` (the underlying `Fonts.TrueTypeFont`, nullable as of Phase D since a `ResolvedType3Font` + has no such outline-glyph font program at all - `ShowGlyph` branches on the concrete resolved- + font type before ever consulting this property, so the nullability is invisible to every other + resolved-font kind's own non-nullable explicit interface implementation), `CodeByteWidth` (`1` + for a simple font, `2` for a composite `/Identity-H` font - consulted by `ShowText`'s code-decoding loop), and + `Resolve(int code)` (returning the code's glyph index and text-space advance width in one call + - the single entry point `ShowGlyph` uses regardless of which concrete implementation is + active). `BuildResolvedSimpleFont(PdfObject fontDict, string? subtype)` requires + `/FontDescriptor`, then checks embedded-font keys in priority order: when `/FontFile2` resolves + to a stream, that (TrueType-outline) embedded font always wins - decoded via the same + `GetStreamDecodedBytes` every other stream in this class uses, then loaded via + `Fonts.TrueTypeFont.Load(new MemoryStream(decodedBytes))`; else, when `/FontFile` resolves to a + stream, `LoadType1Font` loads the embedded classic PostScript Type 1 program (see _Type 1 Font + Resolution_ below); else, when `subtype == "Type1"` and `/FontFile3` is present (with neither + `/FontFile` nor `/FontFile2`), `LoadType1CFont` loads the embedded bare Type1C/CFF program (see + _Type 1C Font Resolution_ below); only when none of those apply does `BuildResolvedSimpleFont` + call `ResolveFallbackFont` (see _Font Fallback Resolution_ immediately below) instead of failing + closed - reachable for `/Type1` fonts exactly as it already was for `/TrueType` fonts. + `ResolveEncoding` builds a full + 256-entry `int[]` code-to-Unicode-codepoint map: `ApplyBaseEncoding` seeds it from one of three + hand-transcribed 256-entry tables (`WinAnsiEncodingTable`/`MacRomanEncodingTable`/ + `StandardEncodingTable`, the PDF specification's own Appendix D tables), defaulting to + `/WinAnsiEncoding` when `/Encoding` is absent entirely and throwing + `Codecs.UnsupportedImageFeatureException` for any other named base encoding + (`/PDFDocEncoding`/anything else); `ApplyDifferences` then applies + an `/Encoding/Differences` array's `code1 name1 name2 ... code2 name1 ...` run-length overrides, + resolving each glyph name via `StandardGlyphNames` (a ~240-entry Adobe Glyph List subset + covering common ASCII/Latin-1 names) - an unrecognized glyph name is tolerated (that one code is + simply left at whatever the base encoding already assigned it, rather than rejecting the whole + document), while a `/Differences` array beginning with a glyph name before any starting code + number still throws `InvalidDataException`. A reverse of `StandardGlyphNames` + (`CodepointToStandardGlyphName`, first-wins on + collision) is built once and reused by both `LoadType1Font` (as `Fonts.TrueTypeFont.LoadType1`'s + `codepointToGlyphName` argument) and `LoadType1CFont` (as `Fonts.TrueTypeFont.LoadType1C`'s + identical argument - the exact same resolution, never the PDF font dictionary's own + `/Encoding`), rather than introducing a second, separately-maintained glyph-name vocabulary. + `ResolveWidths` builds a sparse `code -> width` + (`/1000`-scaled) map from `/FirstChar`/`/Widths` (missing/malformed entries silently omitted, + not rejected), plus `/FontDescriptor/MissingWidth` (defaulting to `0`, the specification's own + documented default) as the fallback for any code absent from that map. +- **Type 1 font resolution (`PdfDocument.Fonts.Type1.cs`, added in Phase B)** โ€” + `LoadType1Font(PdfObject descriptor)` is `BuildResolvedSimpleFont`'s `/FontFile` loader + counterpart to `LoadCidFontType2Font`/`LoadCidFontType0Font` (see _Composite Font Resolution_ + below): it requires `/FontDescriptor/FontFile` to resolve to a stream + (`InvalidDataException` otherwise), then reads that stream's own `/Length1` + (cleartext-segment byte count) and `/Length2` (`eexec`-encrypted-segment byte count) entries - + deliberately read from the `/FontFile` stream dictionary itself, never from the descriptor that + references it, per PDF 32000-1 ยง9.9 - throwing `InvalidDataException` when either is missing or + does not resolve to a number. The stream is decoded via the same `GetStreamDecodedBytes` every + other embedded font stream in this class uses, then loaded via + `Fonts.TrueTypeFont.LoadType1(new MemoryStream(decodedBytes), length1, length2, + CodepointToStandardGlyphName)` - any exception the core `Fonts` layer itself throws for a + malformed `eexec`-encrypted segment, an unparsable `/CharStrings`/`/Subrs` dictionary, or a + rejected `seac` charstring propagates uncaught, consistent with this class's "embedded fonts + fail closed on any embedded-font problem, no fallback" convention (matching + `LoadCidFontType0Font`'s own documented precedent). **Non-Goals**: a `seac`-based + accented-composite charstring is rejected by the core `Fonts` layer itself (not + re-implemented or caught here); a non-1000-unit-em `/FontMatrix` is not read or honored at all + (a 1000-unit em is assumed, matching every other font format this class resolves); and the + Type 1 program's own built-in `/Encoding` array (if any) embedded in the font program itself is + never consulted - only the PDF font dictionary's own `/Encoding` entry, resolved via + `ResolveEncoding` above, determines which glyph a shown code selects. +- **Type 1C font resolution (`PdfDocument.Fonts.Type1.cs`, added in Phase C; shape-sniffing + dispatch added in Phase 18)** โ€” `LoadType1CFont(PdfObject descriptor)` is + `BuildResolvedSimpleFont`'s `/FontFile3` loader counterpart (reached only when `/FontFile` is + absent, per the priority order above), mirroring `LoadCidFontType0Font`'s own shape-sniffing + precedent (see _Composite Font Resolution_ below): it requires `/FontDescriptor/FontFile3` to + resolve to a stream (`InvalidDataException` otherwise), decodes it via the same + `GetStreamDecodedBytes` every other embedded font stream in this class uses, then sniffs the + decoded bytes themselves - via the shared `SniffFontFile3Shape` helper + (`PdfDocument.Fonts.cs`) - for a recognized SFNT container or a structurally plausible bare CFF + header, never consulting the stream's own declared `/Subtype` name for this decision at all (it + is read only for inclusion in the exception message below). An SFNT-wrapped (for example + `'OTTO'`) CFF program is loaded via `Fonts.TrueTypeFont.Load(new MemoryStream(decodedBytes))`; a + bare, non-SFNT-wrapped CFF program is loaded via + `Fonts.TrueTypeFont.LoadType1C(new MemoryStream(decodedBytes), CodepointToStandardGlyphName)` - + either way, a declared `/Subtype` of `Type1C`, `OpenType`, some other name, or no `/Subtype` key + at all is accepted identically, as long as the bytes themselves match one of the two recognized + shapes. Only bytes matching neither shape are rejected, with + `Codecs.UnsupportedImageFeatureException` naming the stream's declared `/Subtype` (or "none") in + its message. Any exception the core `Fonts` layer itself throws for malformed CFF table data, + including a CID-keyed (`ROS`-declaring) CFF program, propagates uncaught, consistent with this + class's "embedded fonts fail closed on any embedded-font problem, no fallback" convention. + **Non-Goals**: + unlike `LoadType1Font`'s classic Type 1 `seac` operator (still rejected, see above), the CFF/Type + 2 charstring format's own deprecated seac-style 4-operand `endchar` composition form (`adx ady + bchar achar endchar`) _is_ supported by the core `Fonts` layer (`Fonts.CffCharstringInterpreter`) + and therefore renders correctly here too; otherwise identical to `LoadType1Font`'s own Non-Goals + above (the non-1000-unit-em `/FontMatrix` and built-in `/Encoding` scope boundaries), plus the + predefined Expert/ExpertSubset CFF charsets are recognized by the core `Fonts` layer but not + resolved to glyph names (that layer's own documented scope boundary, not re-implemented or + worked around here) - a font relying on either predefined charset to name its glyphs resolves no + glyph for any code via `ResolveEncoding` and therefore paints no visible ink for that code, + without throwing. +- **Type 3 font resolution (`PdfDocument.Fonts.Type3.cs`, added in Phase D)** โ€” + `BuildResolvedType3Font(PdfObject fontDict)` is `BuildResolvedFont`'s `/Type3` dispatch target. + It requires and parses `/FontMatrix` as six numbers (`ReadFontMatrix`; `InvalidDataException` if + absent, non-array, wrong length, or any element non-numeric) and requires `/CharProcs` to + resolve to a dictionary (`InvalidDataException` otherwise) - each entry an indirect reference to + a glyph procedure's own content stream, resolved lazily (at paint time, by + _Type 3 Glyph Painting_ below) rather than eagerly here. `/Encoding` is resolved via + `ResolveType3Encoding`, reusing the same `ParseDifferences` run-length-decoding helper + `ApplyDifferences` itself now delegates to (extracted in this phase so both call sites share one + implementation): an absent, non-dictionary, or `/Differences`-less `/Encoding` leniently + resolves to an empty code-to-glyph-name map (every code paints nothing, not an error) rather + than the simple/composite-font path's own base-encoding-table convention, since a Type 3 font + has no base encoding concept at all - only explicit `/Differences` entries name its glyphs. + `/Widths`/`/FirstChar`/`/LastChar`/`/FontDescriptor/MissingWidth` are resolved with the same + missing-entry leniency `ResolveWidths` already established for simple/composite fonts. + `ResolvedType3Font.Resolve(int code)` is the one place this font kind's width convention + differs from every other resolved font: the `/Widths` entry (glyph-space units) is transformed + through `/FontMatrix` via `Vector2.TransformNormal` (the matrix's linear part only, correctly + ignoring any translation component for a displacement vector) to obtain the text-space advance + width - never divided by `1000`, since a Type 3 font's own `/FontMatrix` is the sole authority + on its glyph-space-to-text-space scale (which need not be `0.001` at all - see + _Type 3 Glyph Painting_'s own highest-risk geometry proof). `/Resources` is read but not + otherwise processed here (optional; its paint-time fallback behavior is also documented in + _Type 3 Glyph Painting_ below). +- **Type 3 glyph painting (`PdfDocument.Fonts.Type3.cs`, added in Phase D)** โ€” + `ShowGlyph` branches early, via a `font is ResolvedType3Font type3Font` pattern-matched check - + performed before ever consulting `IResolvedFont.Font` (which is `null` for this font kind) - to + `PaintType3Glyph(type3Font, code)`, skipping it entirely (while still advancing, via the shared + trailing displacement logic every render mode uses) under render mode `3` (invisible), exactly + like the outline-glyph path's own fill step. `PaintType3Glyph` looks up `code`'s glyph name via + the font's resolved `/Encoding` map, then that name's content-stream reference via + `/CharProcs`; either lookup failing (code unmapped, or glyph name absent from `/CharProcs`) + paints nothing, with no exception - the code still advances by its declared width, since + `Resolve`'s width computation is wholly independent of whether a glyph procedure exists. When + both lookups succeed, the glyph procedure's content stream is decoded via the same + `GetStreamDecodedBytes` every other stream in this class uses, and executed recursively via + `ExecuteOperators` - the exact same re-entrance pattern `OpDrawFormXObject` already established + for Form XObjects (see _Form XObjects_ above): `_resources`/`_gs`/`_gsStack` are saved, `_resources` + is set to the Type 3 font's own `/Resources` when present else left as the invoking content + stream's own (falling back exactly like a `/Subtype /Form` XObject with no `/Resources` of its + own), a cloned `GraphicsState` (inheriting the invoking state's fill color/font/etc.) has its + `CurrentTransform` overridden to the computed glyph matrix, and everything is restored in a + `finally` block regardless of how the glyph procedure's execution completes - so a glyph + procedure's own `cm`/color-operator mutations never leak back into the invoking content + stream's graphics state. The glyph matrix is `FontMatrix * ComputeTextRenderingMatrix()` - + the same left-multiply composition convention used throughout this class - entirely replacing + the outline-glyph path's `1/UnitsPerEm`-scale convention for this font kind (there is no + `UnitsPerEm` at all for a Type 3 font). Recursion is bounded by a dedicated + `_type3NestingDepth`/`MaxType3NestingDepth` (`12`, reset to `0` at the start of each + `ExecuteContentStream` call) - independent of `OpDrawFormXObject`'s own + `_formNestingDepth`/`MaxFormNestingDepth` counter, so a pathological (self-referencing) glyph + procedure and a pathological (self-referencing) Form XObject each fail closed against their own + budget rather than one silently exhausting the other's. The `d0`/`d1` glyph-description + operators are recognized (`OpType3SetWidth`/`OpType3SetWidthAndBBox`, dispatched from + `DispatchOperator` exactly like every other operator) and their operand count/type validated via + the same `RequireNumbers` helper every other fixed-arity operator uses, but their result is + otherwise discarded: this class's authoritative advance width remains the font's own `/Widths` + entry (see _Type 3 Font Resolution_ above), consulted independently of whichever glyph procedure + happens to be painted - a stray `d0`/`d1` outside a glyph procedure is therefore a harmless, + validated no-op. **Non-Goal (deliberate Phase D scope boundary)**: `d1`'s optional + color-operator-suppression behavior (PDF 32000-1 ยง9.6.5.2 permits, but does not require, a + conforming reader to ignore a `d1` glyph procedure's own color-setting operators and always + paint with the invoking text's current fill color instead) is not implemented - a `d1` glyph + procedure's own `rg`/`g`/`k`/`sc`/`scn` operators are honored normally, exactly as they would be + inside any other content stream, rather than suppressed. +- **Composite font resolution (`PdfDocument.Fonts.Type0.cs`, added in Phase 9, extended in + Phase 12, shape-sniffing dispatch added in Phase 18)** โ€” `BuildResolvedCompositeFont` is + `BuildResolvedFont`'s `/Type0` dispatch target. It + requires `/Encoding` to resolve to the name `Identity-H`; any other name (including + `Identity-V`) or non-name kind throws `Codecs.UnsupportedImageFeatureException` (feature + `pdf-font-type0-encoding-{name}`) - no CMap-based or vertical-writing encoding is supported. + `/DescendantFonts` must resolve to a single-element array whose element resolves to a + dictionary (`InvalidDataException` for a missing/malformed array, matching this class's own + "malformed required field" convention); that descendant's `/Subtype` must be `CIDFontType2` or + `CIDFontType0` (`Codecs.UnsupportedImageFeatureException`, feature + `pdf-font-cidfonttype-{subtype}`, for any other value or a missing `/Subtype`). Neither + descendant subtype is ever embedded on a fallback/substitute basis - unlike + `BuildResolvedSimpleFont`, no fallback substitution is ever attempted for a composite font (a + deliberate Non-Goal: composite fonts must embed their descendant font). The descendant's + `/FontDescriptor` is resolved once and dispatched, by subtype, to one of two loader helpers + producing a `(TrueTypeFont Font, Func CidToGid)` pair: + - `LoadCidFontType2Font` (the pre-Phase-12 path, unchanged): requires + `/FontDescriptor/FontFile2` (`InvalidDataException` if missing or non-stream), decodes it via + `GetStreamDecodedBytes`, loads it via `Fonts.TrueTypeFont.Load`, and pairs it with + `ResolveCidToGidMap`'s resolved `/CIDToGIDMap` function (see below). + - `LoadCidFontType0Font` (new in Phase 12; shape-sniffing dispatch added in Phase 18): requires + `/FontDescriptor/FontFile3` (`InvalidDataException` if missing or non-stream); decodes it via + the same `GetStreamDecodedBytes` every other embedded font stream in this class uses, then + sniffs the decoded bytes themselves - via the shared `SniffFontFile3Shape` helper + (`PdfDocument.Fonts.cs`) - for a recognized SFNT container or a structurally plausible bare + CFF header, never consulting the stream's own declared `/Subtype` name for this decision at + all (it is read only for inclusion in the exception message below). An SFNT-wrapped (for + example `'OTTO'`) CFF program is loaded via + `Fonts.TrueTypeFont.Load(new MemoryStream(decodedBytes))`, exactly like the `CIDFontType2` + path - the same `TrueTypeFont` type, no new `Fonts`-subsystem code; a bare, non-SFNT-wrapped + CFF program (for example a `/CIDFontType0C`-declared stream with no SFNT wrapper) is loaded + via `Fonts.TrueTypeFont.LoadType1C(new MemoryStream(decodedBytes), + EmptyCodepointToGlyphName)` (an empty, never-populated codepoint-to-glyph-name map, since a + composite font's CID-to-glyph-index mapping below never consults the font's own cmap or + charset-derived glyph names at all). Either way, a declared `/Subtype` of `CIDFontType0C`, + `OpenType`, `Type1C`, some other name, or no `/Subtype` key at all is accepted identically, as + long as the bytes themselves match one of the two recognized shapes; only bytes matching + neither shape are rejected, with `Codecs.UnsupportedImageFeatureException` (feature + `pdf-font-fontfile3-unrecognized-shape`) naming the stream's declared `/Subtype` (or "none") + in its message. Per PDF 32000-1 ยง9.7.4.2, a non-CID-keyed CFF program uses identity + CID-to-glyph-index, so this path + is paired with `cid => cid` directly (not `ResolveCidToGidMap` - `/CIDToGIDMap` is a + `CIDFontType2`-only key per the specification, and any non-standard occurrence on a + `CIDFontType0` descendant is deliberately ignored, never consulted). If the embedded CFF + program is CID-keyed (`ROS` present in its Top DICT), `Fonts.CffTable.Parse` (invoked + transitively by both `TrueTypeFont.Load` and `TrueTypeFont.LoadType1C`, regardless of + container shape) already rejects it with `InvalidDataException`, which + propagates uncaught here - no new translation code, consistent with this class's "composite + fonts fail closed on any embedded-font problem" convention. CID-keyed CFF support itself + (`FDArray`/`FDSelect`-aware charstring dispatch, a CID-keyed `charset` parser) is a + separately-scoped, not-yet-implemented future phase. + + Regardless of descendant subtype, `ResolveCompositeWidths` + resolves `/DW` (defaulting to `1000`, the specification's own documented default - not `0`, this + is the one place a `TrueType`-family font descriptor's own default differs between the simple- + and composite-font paths) and `/W` (a CID-to-width map, supporting both the `c [w1 w2 ... wn]` + individual-width sub-form and the `cFirst cLast w` range sub-form, disambiguated by the resolved + `PdfKind` - `Array` vs. `Number` - of the element immediately following the leading CID number; + any other shape, or a range form with `cLast < cFirst`, throws `InvalidDataException`) - this + logic has no subtype-specific branch at all. `ResolveCidToGidMap` (called only for a + `CIDFontType2` descendant) resolves `/CIDToGIDMap`: absent or the name `/Identity` + yields the identity function; a stream is decoded (via the same `GetStreamDecodedBytes` every + other stream uses) into a big-endian `uint16`-per-CID lookup table (an odd byte count throws + `InvalidDataException`), with an out-of-range/negative CID mapping to glyph `0`/`.notdef` per + the specification; any other resolved kind throws `InvalidDataException`. + `ResolvedCompositeFont.Resolve(code)` treats `code` directly as the CID (per `/Identity-H`'s own + "code equals CID" identity), maps it to a glyph index via `CidToGid`, and resolves its width from + `CidWidths` (falling back to `DefaultWidth`) - the same `(GlyphIndex, Width)` tuple shape + `ResolvedSimpleFont.Resolve` returns, so `ShowGlyph` never needs a type check; + `ResolvedCompositeFont` itself has no descendant-subtype-specific field or branch - the same + shape serves both `CIDFontType2` and `CIDFontType0` descendants. +- **`/ToUnicode` CMap resolution (`PdfDocument.Fonts.ToUnicode.cs`, added in Phase 10)** โ€” + `ResolveToUnicodeMap(fontDict)` is called from `BuildResolvedCompositeFont` (on the Type0 font + dictionary itself, not the descendant font dictionary) and stores its result on + `ResolvedCompositeFont.ToUnicode`; it is unconsumed groundwork this phase - nothing reads it at + rendering time. It returns `null` when `/ToUnicode` is absent or doesn't resolve to a stream + (the same leniency `ResolveWidths` applies to a missing `/Widths` array), otherwise decodes the + stream (via the same `GetStreamDecodedBytes` every other stream uses) and tokenizes it with a + fresh `PdfTokenizer`, reusing `ParseValue`'s string/hex-string/array parsing primitives rather + than a second, purpose-built CMap tokenizer. `beginbfchar`/`endbfchar` pairs map a single hex- + string source code to a hex-string or literal-text (UTF-16BE) destination's codepoint. + `beginbfrange`/`endbfrange` triples (`srcLo srcHi dst`) support two destination forms: a single + hex/literal-string destination maps consecutive codes across `[srcLo, srcHi]` to consecutive + incrementing codepoints starting at that destination's own codepoint, and an array destination + maps each code individually to its own corresponding array element - a nested array element + (the CIDSystemInfo-style "array of arrays" destination sub-form) is out of scope and throws + `Codecs.UnsupportedImageFeatureException` (feature `pdf-font-tounicode-bfrange-array- + destination`). A destination decoding to more than one UTF-16 code unit (e.g. a ligature) keeps + only its first codepoint - a documented simplification, not a fidelity goal this phase. + `begincodespacerange`/`endcodespacerange` and the CMap's own PostScript resource-management + wrapper keywords (`begin`/`end`/`dict`/`def`/`findresource`/`defineresource`/`pop`/ + `currentdict`/`begincmap`/`endcmap`, and any bare number/name operand appearing alongside them) + are silently skipped - mirroring `ExecuteContentStream`'s own "silently ignore any other + operator" precedent - since none of them affect bfchar/bfrange mapping semantics. A + `usecmap`/`cidrange`/`cidchar` operator, by contrast, is explicitly out of this phase's scope + and throws `Codecs.UnsupportedImageFeatureException` (feature `pdf-font-tounicode-{operator}`) + rather than being silently (and incorrectly) ignored. +- **Font fallback resolution (`PdfDocument.FontFallback.cs`, added in Phase 6; revised to add the + Symbol/ZapfDingbats Noto substitution path)** โ€” + `ResolveFallbackFont(baseFontName, descriptor)` is `BuildResolvedSimpleFont`'s sole entry point into + this file, and returns an ordered, non-empty `IReadOnlyList` rather than a + single font (see `BuildResolvedSimpleFont`'s own entry below for why). A `/BaseFont` of exactly + `Symbol` or `ZapfDingbats` resolves via `ResolveSymbolicNotoFallback`: a bundled Noto substitute + font union, reusing `Fonts.SystemFontCatalog.LoadBundledFallbackCore` (made callable + cross-assembly via a new `InternalsVisibleTo` grant from `DemaConsulting.CanvasNet` to + `DemaConsulting.CanvasNet.Pdf`, rather than duplicating its embedded-resource-loading/caching + logic) - `Symbol` returns a 3-element priority list (`NotoSans-Regular.ttf` for Greek letters and + general symbols, then `NotoSansMath-Regular.ttf` for mathematical operators, then + `NotoSansSymbols2-Regular.ttf` for Private-Use-Area-adjacent/rare symbols), while `ZapfDingbats` + returns a single-element list (`NotoSansSymbols2-Regular.ttf` alone). Any other font whose + `/FontDescriptor/Flags` declares the `Symbolic` bit (bit 3) without also declaring the + `Nonsymbolic` bit (bit 6) still fails closed with `Codecs.UnsupportedImageFeatureException` + (feature `pdf-font-symbolic-not-embedded`) exactly as before - a symbol/dingbat glyph set has no + meaningful generic-family equivalent, so it is never substituted rather than being silently + mis-rendered, and this is now the sole remaining fail-closed case (`Symbol`/`ZapfDingbats` + themselves are checked, and diverted to the Noto substitution path, before this check is ever + reached). Otherwise, + `ResolveFallbackFlavor` classifies the requested serif/fixed-pitch/bold/italic flavor: a + recognized Standard-14 name (the fixed 12-entry `Standard14Flavors` table, covering + Helvetica/Times/Courier's four style variants each - `Symbol`/`ZapfDingbats` deliberately + excluded, since they now resolve via `ResolveSymbolicNotoFallback` before this table is ever + consulted) takes priority and + bypasses the descriptor entirely; otherwise the flavor is derived from `/FontDescriptor/Flags` + bit 1 (`FixedPitch`)/bit 2 (`Serif`), `/FontWeight >= 600` (else a case-insensitive `"Bold"` + substring in `/BaseFont`) for bold, and `/FontDescriptor/Flags` bit 7 (`Italic`) OR a non-zero + `/ItalicAngle` OR a case-insensitive `"Italic"`/`"Oblique"` substring in `/BaseFont` for italic. + `StripFontNameDecoration` then reduces `/BaseFont` to a plain family-name hint by removing a + leading six-uppercase-letter-plus-`+` PDF subset tag and any trailing style-name suffix (for + example `"Arial,BoldItalic"` and `"ABCDEF+Arial-BoldItalic"` both become `"Arial"`), and that + hint plus the resolved flavor are passed to `Fonts.SystemFontCatalog.FindBestMatch`; a match is + loaded (and process-lifetime-cached by `(FilePath, FaceIndex)`, via `LoadFallbackFontFromDisk`) + from disk, while no match falls through to `Fonts.SystemFontCatalog.LoadBundledFallback`'s own + bundled Liberation Sans/Serif/Mono font (which is itself already process-lifetime-cached by + `SystemFontCatalog`). This process-lifetime cache is deliberately broader-scoped than + `_fontCache`'s per-`Render` call scope, since a system or bundled font file's bytes never + change between calls or between documents. + + `BuildResolvedSimpleFont`'s `ResolvedSimpleFont.Fonts` property (plural, renamed from the + earlier single `Font` property) is an ordered, non-empty `IReadOnlyList` - + a single-element list for every pre-existing resolution path (embedded `/FontFile2`, a matched + system font, or the bundled Liberation fallback), and only ever multi-element for the Symbol + Noto-substitute union above. `ResolvedSimpleFont.Resolve(code)` (the sole + `IResolvedFont.Resolve` implementation this concerns) tries each font in `Fonts` in priority + order, returning the first one whose `GetGlyphIndex` for the code's mapped codepoint is + non-zero (its advance width is derived from that winning font), falling back to `Fonts[0]` and + glyph `0`/`.notdef` if none of them cover the codepoint - the composite/union glyph-lookup + mechanism this feature introduces. `BuildResolvedSimpleFont` additionally selects, for a + `/BaseFont` of exactly `Symbol` or `ZapfDingbats` resolved via this fallback path, the matching + `SymbolEncodingTable`/`ZapfDingbatsEncodingTable` as `ResolveEncoding`'s new optional + `defaultBaseTable` parameter (in place of its `WinAnsiEncodingTable` default for every other + font) - an explicit `/Encoding/Differences` array still applies on top, exactly as for any + other font. + +- **Text rendering (`PdfDocument.Text.cs`, added in Phase 4)** โ€” `OpBeginText`/`OpEndText` + (`BT`/`ET`) reset only `_textMatrix`/`_lineMatrix` to the identity matrix (every other text- + state parameter lives on `GraphicsState` and is untouched, per this phase's documented `q`/`Q`- + interaction design โ€” see `_textMatrix`/`_lineMatrix` above). `OpSetCharSpacing`/ + `OpSetWordSpacing`/`OpSetHorizontalScaling`/`OpSetLeading`/`OpSetTextRise` (`Tc`/`Tw`/`Tz`/`TL`/ + `Ts`) store their one operand verbatim; `OpSetFont` (`Tf`, a name then a number) resolves the + named font resource via `ResolveFont` and stores it alongside the requested size; + `OpSetTextRenderMode` (`Tr`) accepts modes `0` (fill, the default), `1` (stroke), `2` + (fill, then stroke), and `3` (invisible โ€” painted with zero-area geometry, i.e. skipped + entirely), throwing `Codecs.UnsupportedImageFeatureException` for the clip modes `4`-`7` (out + of this phase's scope) or `InvalidDataException` for any other numeric value. `OpTextMoveTo`/ + `OpTextMoveToSetLeading`/`OpTextNextLine` (`Td`/`TD`/`T*`) and `OpSetTextMatrix` (`Tm`) + manipulate `_textMatrix`/`_lineMatrix` per the specification's own line-matrix-relative- + displacement (`Td`/`TD`, `TD` additionally setting `Leading = -ty`) versus direct-replacement + (`Tm`) semantics; `T*` is exactly `0 -TL Td`. `OpShowText`/`OpShowTextNextLine`/ + `OpShowTextNextLineWithSpacing`/`OpShowTextArray` (`Tj`/`'`/`"`/`TJ`) each ultimately call + `ShowText`, which throws `InvalidDataException` if no font is currently selected (`Tf` was + never called), then decodes the shown byte string into a sequence of character codes using the + selected font's `IResolvedFont.CodeByteWidth` - one byte per code for a simple font (unchanged + since Phase 4), or, as of Phase 9, two bytes (big-endian) per code for a composite + `/Identity-H` font (`InvalidDataException` if the byte string's length is not a multiple of 2) + - showing each decoded code via `ShowGlyph`: `ShowGlyph` calls the selected font's + `IResolvedFont.Resolve(code)` once (folding in the encoding/glyph-index/advance-width lookup + each concrete `IResolvedFont` implementation documents its own priority for - see _Font + Resolution_/_Composite Font Resolution_ above), looks up the resolved glyph index's outline via + `Fonts.TrueTypeFont.GetGlyphOutline`, computes the text-rendering matrix `Trm = [TfsยทTh, 0, 0, + Tfs, 0, Trise] ร— Tm ร— CTM` (row-vector composition, matching `OpConcatMatrix`'s own convention), + combines it with a `1/UnitsPerEm` glyph-space scale, transforms every glyph outline point + through the result (`AppendTransformedGlyphOutline`, reimplementing - since it is `private` in + a different assembly - the exact glyph-outline-to-`Geometry.Path` re-issuing pattern + `SvgCodec.Text.cs` established), then paints the transformed outline per the current render + mode (skipped entirely for render mode `3`): fills via `Drawing.PathFiller.Fill` with + `_gs.FillColor` for modes `0`/`2` (a known, documented pre-existing simplification: unlike the + stroke step below, this fill does not mirror a `/Pattern` fill color space - out of this + change's scope), then, for modes `1`/`2`, strokes via the same `PaintStroke` helper + `PdfDocument.PathOps.cs`'s path-painting operators use (see below), painting a resolved + `/Pattern` stroke color space's tile colors when one is set. Mode `2`'s fill-then-stroke order + matches the path-painting operators' own `B`/`b` order. `ShowGlyph` then advances `Tm.x` by + `((w0 - Tj/1000) ร— Tfs + Tc + (code == 32 && CodeByteWidth == 1 ? Tw : 0)) ร— Th` per the + specification, where `w0` is `IResolvedFont.Resolve(code)`'s returned advance width (the + `Tj/1000` term only applies within `TJ`'s array form, via `ApplyTextSpaceAdjustment`) - word + spacing is gated on `CodeByteWidth == 1` (as of Phase 9) since the specification's word-spacing + rule addresses only the single-byte code `32` of a simple font, never a composite font's 2-byte + code even when it numerically equals `32`. Every operator validates its operand + count/type via the same `RequireNumbers`/`RequireOperandCount` helpers every other operator + family uses, throwing `InvalidDataException` on mismatch. + +- **Sampled Function Evaluation (`PdfDocument.Functions.cs`, added in Phase 1 of the `/Pattern` + color-space roadmap)** โ€” `ResolveFunction` resolves a `/Function` entry restricted to + `/FunctionType 0` (sampled function) with exactly 1 input (a 2-element `/Domain`) into a + `SampledFunction`, parsing `/Size` (a single element, since this is a 1-input function), + `/BitsPerSample` (`8` or `16` only), `/Encode` (defaulting to `[0, Size[0] - 1]` when absent), + and `/Decode` (defaulting to `/Range` when absent), and decoding the function stream's own bytes + via `GetStreamDecodedBytes` (already filter-aware โ€” no new filter code needed, since every + sampled function this evaluator's motivating real-world PDF declares uses `/FlateDecode`). + `SampledFunction.Evaluate(input)` clamps `input` into `/Domain`, linearly maps it into + sample-index space via `/Encode`, clips the result into `[0, Size - 1]`, linearly interpolates + between the two nearest samples (this evaluator never consults `/Order` โ€” it always behaves as + though `/Order` were `1`, matching every sampled function this evaluator's motivating PDF + declares), reads each output's raw sample value via a generic, bit-width-agnostic big-endian bit + reader (`ReadBits`, shared by both the 8-bit and 16-bit cases), and maps each interpolated raw + value through `/Decode`, clipped to `/Range`. A `/FunctionType` other than `0` throws + `Codecs.UnsupportedImageFeatureException` (feature `pdf-functiontype-{n}`); a multi-input + `/FunctionType 0` function (a `/Domain` with more than 2 elements โ€” the `/DeviceN`/`/Separation` + tint-transform shape, itself already out of scope, see _Content-Stream Interpreter_'s color-space + discussion above) throws the same exception type (feature `pdf-function-multiinput`); a + `/BitsPerSample` other than `8`/`16` likewise throws (feature `pdf-function-bitspersample-{n}`). + This was originally landed as resolved-but-unconsumed groundwork (Phase 1 of the `/Pattern` + color-space roadmap); it is now consumed directly by shading-pattern gradient construction (see + _`/Pattern` Color Space (Shading and Tiling Patterns)_ below), mirroring + `PdfDocument.Fonts.ToUnicode.cs`'s own precedent of landing a narrowly-scoped parser ahead of + the feature that consumes it. +- **Exponential and Stitching Function Evaluation (`PdfDocument.Functions.cs`, added alongside + `/Pattern` color-space support)** โ€” `ResolveFunctionGeneric` is the new, uniform entry point + every pattern/shading caller uses (`ResolveFunction` itself remains, unchanged, as the + `/FunctionType 0`-only path every pre-existing caller still uses): it dispatches `/FunctionType` + `0` to the existing `SampledFunction` (through a shared `IPdfFunction` interface exposing + `Evaluate(double)`/`OutputCount`), `2` to a new `ExponentialFunction` (`/Domain`, `/C0`/`/C1` + defaulting to `[0.0]`/`[1.0]` per spec when absent, and `/N` โ€” always a plain PDF number, never + an array โ€” computing the literal spec formula + `C0[i] + (Math.Pow(clampedInput, N) * (C1[i] - C0[i]))` component-wise), and `3` to a new + `StitchingFunction` (`/Domain`, `/Functions` โ€” each sub-function independently resolved, + recursively, through the same `ResolveFunctionGeneric` dispatcher, so a stitching function + nesting another stitching function is supported automatically rather than specially โ€” bounded by + a new `MaxFunctionRecursionDepth` (`32`) guard on `ResolveFunctionGeneric` itself (checked/ + incremented/decremented exactly like `MaxColorSpaceRecursionDepth`), throwing + `InvalidDataException` when a self-referencing or excessively deep `/Functions` chain would + otherwise recurse until the process' call stack is exhausted โ€” `/Bounds` + โ€” the `k - 1` partition points for `k` sub-functions โ€” and `/Encode` โ€” `2k` elements remapping + each selected sub-domain into its own sub-function's `/Domain` before delegating). `/FunctionType + 4` (PostScript calculator functions) continues to throw + `Codecs.UnsupportedImageFeatureException` (feature `pdf-functiontype-4`) โ€” the exception + message now reads "only /FunctionType 0, 2, and 3 are supported". + +### `/Pattern` Color Space (Shading and Tiling Patterns) + +- **`/Pattern` color-space resolution and `scn`/`SCN` pattern operands (`PdfDocument.Color.cs`, + added alongside `/Pattern` color-space support)** โ€” `PdfColorSpace.Family` gains a `Pattern` + case, with an internal `PatternBase` property holding the optional underlying color space from + the 2-element `[/Pattern baseSpace]` array form (`null` for the bare `/Pattern` name form). + `cs`/`CS` resolve `/Pattern` as a fixed device-style identifier (never looked up as a + `/Resources/ColorSpace` name, matching `/DeviceGray`/`/DeviceRGB`/`/DeviceCMYK`'s own + resolution precedent); an array form with any element count other than 1 or 2 throws + `Codecs.UnsupportedImageFeatureException` (feature `pdf-colorspace-Pattern`). + `OpSetColorFill`/`OpSetColorStroke` (`scn`/`SCN` โ€” the plain `sc`/`SC` operators never carry a + pattern name per spec) branch on the active color space's `Kind == Family.Pattern` _before_ + reaching the existing numeric-component path: the trailing operand must be a `Name` (else + `InvalidDataException`); every operand before it must be numeric, with a count driven _solely_ + by whether the active color space declared a `PatternBase` โ€” exactly `ComponentCount + (PatternBase)` numbers when one was declared (regardless of whether the pattern the name + resolves to actually turns out to be a shading or a tiling pattern), or exactly `0` numbers + otherwise (either shape violated throws `InvalidDataException`). The leading numeric operands, + when present, convert via the existing `ColorFromComponents(PatternBase, ...)` helper into an + "uncolored tint" color, stored alongside the resolved pattern. The resolved pattern name is + looked up in the current page's `/Resources/Pattern` dictionary (`ResolvePattern`, mirroring + `ResolveColorSpaceByName`'s own `/Resources/ColorSpace` lookup precedent exactly), throwing + `Codecs.UnsupportedImageFeatureException` (feature `pdf-pattern-not-declared`) when absent, and + dispatching on `/PatternType` to a tiling-pattern builder (`1`) or shading-pattern builder + (`2`, per PDF-spec numbering), any other value throwing + `Codecs.UnsupportedImageFeatureException` (feature `pdf-pattern-type-{n}`). + `GraphicsState.FillPattern`/`StrokePattern` (new, nullable, shared-not-deep-copied on `Clone()` + exactly like the existing `Font` field) carry the resolved pattern; `FillColor`/`StrokeColor` + are set to an opaque-black placeholder that is documented as never actually painted with, since + `PaintCurrentPath` always branches on `FillColorSpace.Kind == Family.Pattern` before reading + either color field. +- **Shading patterns (`PdfDocument.Patterns.cs`/`PdfDocument.Patterns.Shading.cs`, added + alongside `/Pattern` color-space support)** โ€” a `/PatternType 2` dictionary's `/Shading` + resolves `/ShadingType 2` (axial) or `3` (radial); any other `/ShadingType` throws + `Codecs.UnsupportedImageFeatureException` (feature `pdf-shading-type-{n}`). `/ColorSpace` must + resolve to `DeviceGray`/`DeviceRGB`/`DeviceCMYK` (else feature + `pdf-shading-colorspace-{family}`); `/Function` accepts either a single function or an array of + 1-output-component functions (`ResolveFunctionOrFunctionArray`), each resolved through + `ResolveFunctionGeneric` above. The resolved shading samples its function(s) at 32 evenly spaced + points across `/Domain` (a fixed constant balancing visual smoothness against per-fill + allocation/compute cost โ€” no caller needs more precision than this, mirroring + `SampledFunction`'s own posture), converts each sample through `ColorFromComponents`, and builds + one `Drawing.GradientStop` per sample to construct a `Drawing.LinearGradient` (`ShadingType 2`) + or `Drawing.RadialGradient` (`ShadingType 3`) whose `Transform` is the pattern's own `/Matrix` + composed with the page's initial (pre-`cm`) CTM (`_pageInitialCtm`, captured once per + `ExecuteContentStream` call, per PDF 32000-1 ยง8.7.3.1's default-coordinate-system rule โ€” this is + the one documented limitation when a pattern is resolved from inside a nested Form XObject's own + `/Resources/Pattern`: it still composes against the page's own initial CTM, not the Form's own + default space). **`/Extend` is approximated as `Drawing.GradientSpread.Pad`** regardless of its + actual `[false false]`/`[true true]`/mixed value โ€” a documented, narrower-than-spec + simplification: no existing `GradientSpread` value expresses the spec's true + "paint nothing outside the defining geometry" default, and implementing that exactly would + require a general clipping mechanism (`W`/`W*`) this phase does not add. +- **Tiling patterns (`PdfDocument.Patterns.cs`/`PdfDocument.Patterns.Tiling.cs`, added alongside + `/Pattern` color-space support)** โ€” a `/PatternType 1` stream's `/BBox` (4 numbers), `/XStep`/ + `/YStep` (each required finite and non-zero โ€” `InvalidDataException` otherwise, a malformed, not + merely unsupported, value per spec), optional `/Matrix`, required `/PaintType` (`1` colored / + `2` uncolored โ€” any other value is `InvalidDataException`), own `/Resources` (falling back to + the invoking stream's resources, exactly like `OpDrawFormXObject`), and decoded content bytes + are all resolved up front. Rendering one pattern cell (`RenderTilingPatternCell`) computes the + device-pixel tile-surface size from `|XStep|`/`|YStep|` scaled by the pattern-to-device + transform's own determinant-derived scale factor (`MatrixScale`, the same + `sqrt(|det(matrix.Linear)|)` formula `DeviceScale()` already uses for stroke width, extracted to + a shared static helper so both call sites share one implementation), rejecting a resulting + surface wider or taller than `MaxTileSurfaceDimension` (2048 device pixels) with + `Codecs.UnsupportedImageFeatureException` (feature `pdf-pattern-tile-too-large` โ€” a fail-closed + guard against a pathological/adversarial `/XStep`/`/YStep` combined with an extreme CTM scale). + The cell then renders through the exact same "swap `_surface`/`_resources`/`_gs`/`_gsStack`, + increment `_formNestingDepth` (reusing the existing `MaxFormNestingDepth` guard โ€” not a new, + parallel counter, so combined Form-XObject and tiling-pattern-cell nesting is bounded by the one + existing limit), `ExecuteOperators`, restore everything in a `finally` block" shape + `OpDrawFormXObject` already established โ€” with one necessary addition: the cell's own nested + content-stream execution also saves, resets to a fresh value, and restores the path-building + scalar fields (`_pathBuilder`/`_currentPoint`/`_subpathStart`/`_hasOpenSubpath`), since (unlike + `Do`, which is never invoked mid-path-paint) a tiling-pattern cell can be rendered _from inside_ + an in-progress fill/stroke paint operation, after the host's own path has already been captured + but before it has been cleared โ€” without this extra save/reset/restore, the cell's own path data + would silently accumulate onto the host's still-live path and corrupt both. For `/PaintType 2` + (uncolored) patterns, every color the cell's content stream itself sets is overridden by the + caller-supplied tint before compositing, preserving each pixel's own painted alpha exactly (so a + partially transparent cell stays partially transparent, just recolored). The rendered cell + becomes a `Drawing.TilePaint` (new `Drawing` subsystem unit โ€” see _Introduction_ and + `Drawing/TilePaint.cs`/`Drawing/TilePaintEvaluator.cs`), sampled nearest-neighbor per destination + pixel by `Drawing.PathFiller`'s new `TilePaint` fill overload, mirroring the existing `Gradient` + overload's structure exactly. +- **Scope boundaries (deliberately not implemented this phase)** โ€” `ShadingType` `1`/`4`-`7` + (function-based and mesh shadings), `/FunctionType 4` (PostScript calculator functions), the + `sh` operator, and generic path clipping (`W`/`W*`) all remain unsupported/unchanged (the `sh` + operator and `W`/`W*` both remain silently skipped by `DispatchOperator`'s existing lenient + default case, unchanged by this feature); a `/Pattern` color space nested inside another + `/Pattern`'s own `PatternBase` is out of scope (the `ComponentCount` arm for `Family.Pattern` + throws `InvalidOperationException` as a fail-closed backstop, since no caller is expected to + reach it); deep nested-tiling-pattern recursion is bounded only by the shared, reused + `MaxFormNestingDepth` guard (no dedicated correctness test of deep nested rendering itself, only + that the guard still fires through this new call path). + +### Resource and Input Bounds + +`PdfDocument` parses an externally-supplied, potentially adversarial PDF document, so several of +its decoders and interpreter loops cap a resource (decoded-output size, stack depth, or expansion +factor) that an otherwise well-formed but maliciously crafted input could otherwise drive +unboundedly large, closing a class of decompression-bomb/resource-exhaustion denial-of-service +risks without rejecting any legitimate, real-world PDF document: + +- **`FlateDecode` output cap** โ€” `DecompressFlateStream`'s chunked read loop rejects a decoded + stream once its accumulated output exceeds `FlateMaxOutputBytes` (64 MiB) with + `InvalidDataException`, rather than continuing to inflate an unbounded (or deliberately + self-referential/highly-compressible) `zlib` stream into memory. 64 MiB is generous for any + legitimate PDF image or content stream while still bounding a single decode's worst-case + allocation. +- **`RunLengthDecode` output cap** โ€” each repeat/literal run is checked against the remaining + budget under `RunLengthMaxOutputBytes` (64 MiB) _before_ the run's bytes are appended, throwing + `InvalidDataException` rather than only detecting the overrun after the full (already expanded) + output has been materialized; a single 2-byte repeat run can expand to up to 128 output bytes (a + 64x amplification), so checking the budget first closes the window where a tiny crafted stream + could otherwise force a large allocation before the cap is ever observed. +- **`ASCII85Decode` output cap** โ€” mirrors the `RunLengthDecode` convention exactly: + `Ascii85MaxOutputBytes` (64 MiB) is checked before each decoded group is appended, throwing + `InvalidDataException` once exceeded, bounding a filter whose encoded form is otherwise only + mildly larger than its decoded output but still unbounded in principle. +- **Graphics-state stack depth cap** โ€” `OpPushGraphicsState` (`q`) throws `InvalidDataException` + once the graphics-state stack already holds `MaxGraphicsStateStackDepth` (256) entries, rather + than allowing a content stream with deeply/unboundedly nested `q` operators (never matched by a + corresponding `Q`) to grow the stack without limit. 256 comfortably exceeds any nesting depth a + legitimate content stream's own `q`/`Q`-balanced structure is expected to reach. +- **`/ToUnicode` `bfrange` span cap** โ€” `ParseBfRangeBlock`'s hex-string destination form throws + `InvalidDataException` when a single `bfrange` entry's code span (`srcHi - srcLo + 1`) exceeds + `ToUnicodeMaxBfRangeSpan` (65536 codes), rather than materializing one map entry per code for an + entry whose declared source range is enormous (or malformed to appear so). +- **Compressed object-stream cycle guard** โ€” `GetObject`/`LoadCompressedObject` track the set of + compressed-object numbers currently being resolved in `_compressedObjectResolutionStack`, + throwing `InvalidDataException` when a compressed object's own resolution would re-enter itself + (directly or transitively via another compressed object naming it as its own containing object + stream), rather than recursing indefinitely into a genuine cycle and eventually raising an + `StackOverflowException` that cannot be caught - mirroring the page-tree reference-cycle guard + `TraversePageTree` already applies to `/Kids`. +- **Non-finite numeric operand rejection** โ€” `RequireNumbers` (the shared operand-parsing helper + used by every numeric content-stream operator, including `cm`, path-construction operators, and + color operators) throws `InvalidDataException` when any parsed operand is `NaN` or an infinity, + rejecting a non-finite value before it can propagate into a transform matrix or path coordinate + and corrupt unrelated, subsequently-painted geometry. +- **CCITT dimension/bounds validation ordering** โ€” `DecodeCcittFaxImageXObject` validates a + non-positive (zero or negative) `/Columns`/`/Rows` value, and performs its row/column bounds + check, _before_ invoking the Group 4 (T.6 MMR) decoder, rather than after: validating first + prevents an oversized allocation from being attempted ahead of the existing bounds check ever + running. +- **Xref stream-data-length overflow fix** โ€” `GetStreamRawBytes` computes a compressed object + stream's `StreamDataStart + length` bound using `long` arithmetic rather than `int`, preventing + a maliciously large declared `/Length` from wrapping an `int` sum around to a small or negative + value that would otherwise defeat the subsequent bounds check entirely. +- **Unterminated literal-string rejection** โ€” the tokenizer throws `InvalidDataException` when a + PDF literal string (`(...)`) reaches end-of-file while its paren-nesting depth is still nonzero, + rather than silently treating the truncated remainder of the file as the string's own content. + +Each of these limits is an approximate, deliberately generous bound chosen to comfortably exceed +any legitimate, real-world PDF document's own requirements while still closing the unbounded- +resource attack surface a malicious or malformed document could otherwise exploit; none of them +change behavior for any document within normal real-world limits. + +### Error Handling + +- **Null `stream`/`path` argument to `Open`** โ€” `ArgumentNullException`, thrown directly with a + `nameof(...)` parameter name. +- **Empty/whitespace-only `path` to `Open(string)`** โ€” `ArgumentException`, mirroring + `PngCodec.Load(string)`'s message wording. +- **Malformed/unresolvable document structure** (tokenizer, object model, xref, or page tree, + after the linear-scan fallback is exhausted) โ€” `InvalidDataException`, mirroring `PngCodec`'s + exact convention for malformed data. +- **`/Encrypt` key present in the trailer, but not the `/Standard` security handler** (feature + `pdf-encrypted-filter-{name}`), **a `/CF/StdCF/CFM` other than `/AESV2`/`/AESV3`** (feature + `pdf-encrypted-cfm-{name}`), **`/V 5` with `/R 6`'s "hardened hash" key derivation** (feature + `pdf-encrypted-r6-hardened-hash`), **`/V 5` with any other unsupported `/R`** (feature + `pdf-encrypted-r-{revision}`), **an unsupported `/V`** (feature `pdf-encrypted-v-{version}`), + **a `null` password that fails `/U` (R2-R4) or `/U`'s embedded validation hash (R5) + authentication, i.e. the document genuinely requires a non-empty password** (feature + `pdf-encrypted-password-required`), **a non-`null` password that authenticates as neither the + user nor the owner password** (feature `pdf-encrypted-incorrect-password`), or **a non-`null` + password containing a character outside ASCII 0-127 for an R2-R4 document** (feature + `pdf-encrypted-password-non-ascii`) โ€” `Codecs.UnsupportedImageFeatureException` for each, + thrown directly, each with its own distinguishable `Feature` token (added in Phase 16/17, see + _Encryption (Standard Security Handler)_ above; narrows the previous Phase 1 blanket + `"pdf-encrypted"` rejection of every encrypted document regardless of shape). +- **Out-of-range `pageIndex` to `GetPageInfo`/`Render`** โ€” `ArgumentOutOfRangeException`, thrown + directly. +- **Non-positive/too-large `width`/`height` to `Render`** โ€” `ArgumentOutOfRangeException`, + propagated unwrapped from `Surface`'s own constructor, not duplicated. +- **Malformed `/Contents`** (neither a stream nor an array of streams, or an array entry that + does not itself resolve to a stream) โ€” `InvalidDataException`. +- **Malformed content-stream syntax, or a malformed operand count/type for a recognized + operator** โ€” `InvalidDataException`, matching `PdfDocument.Xref.cs`'s own established + malformed-input convention. An unrecognized operator is never an error (see _Content-Stream + Interpreter_ above); an unbalanced `Q` with no matching prior `q` is likewise tolerated as a + documented no-op, not an error. +- **An unsupported color space** (`cs`/`CS`, or an image XObject's `/ColorSpace`: + `Separation`/`DeviceN`/`CalGray`/`Lab`, an undeclared `/Resources/ColorSpace` name, an + `/ICCBased` stream whose `/N` is not `1`/`3`/`4` with no usable `/Alternate`, an `/Indexed` color + space whose base color space is itself unsupported, a `/Pattern` array form with an element + count other than 1 or 2 (feature `pdf-colorspace-Pattern`), or any other unrecognized value) โ€” + `Codecs.UnsupportedImageFeatureException`. +- **`scn`/`SCN` pattern operands** โ€” a missing trailing `Name` operand, or a leading numeric + operand count that does not exactly match the active color space's declared `PatternBase` + component count (`0` when none was declared) โ€” `InvalidDataException`; an undeclared pattern + name (feature `pdf-pattern-not-declared`) or an unsupported `/PatternType` (anything other than + `1`/`2`, feature `pdf-pattern-type-{n}`) โ€” `Codecs.UnsupportedImageFeatureException`. +- **An unsupported shading pattern** โ€” a `/ShadingType` other than `2`/`3` (feature + `pdf-shading-type-{n}`), or a `/ColorSpace` other than `DeviceGray`/`DeviceRGB`/`DeviceCMYK` + (feature `pdf-shading-colorspace-{family}`) โ€” `Codecs.UnsupportedImageFeatureException`. +- **A pathological tiling pattern** โ€” a `/XStep`/`/YStep` that is zero or non-finite, or a + `/PaintType` other than `1`/`2` โ€” `InvalidDataException`; a resolved device-pixel tile surface + exceeding `MaxTileSurfaceDimension` โ€” `Codecs.UnsupportedImageFeatureException` (feature + `pdf-pattern-tile-too-large`). +- **`ResolveFunction`/`ResolveFunctionGeneric`'s `/Function` entry** โ€” a `/FunctionType` other + than `0`/`2`/`3`, a multi-input `/FunctionType 0` function (a `/Domain` with more than 2 + elements), a `/BitsPerSample` other than `8`/`16`, or a `/FunctionType 4` (PostScript + calculator) function (feature `pdf-functiontype-4`) โ€” `Codecs.UnsupportedImageFeatureException`; + a `/Function` that does not resolve to a stream/dictionary, or a missing/malformed + `/Domain`/`/Range`/`/Size`/`/Encode`/`/Decode`/`/C0`/`/C1`/`/N`/`/Functions`/`/Bounds` entry โ€” + `InvalidDataException` instead (malformed, not merely unsupported). +- **An unsupported stream filter** (anything other than `FlateDecode`, `LZWDecode`, + `ASCII85Decode`, `ASCIIHexDecode`, `RunLengthDecode`, or `DCTDecode`/`CCITTFaxDecode` combined + with another filter) โ€” `Codecs.UnsupportedImageFeatureException`; an unrecognized `/Predictor` + value, a malformed `/Filter`/`/DecodeParms` shape, or malformed bytes for any of the five + supported filters is `InvalidDataException` instead (malformed, not merely unsupported). +- **An unsupported image `/BitsPerComponent`** (anything other than `8`), or a TIFF predictor + combined with a non-`8` `/BitsPerComponent` โ€” `Codecs.UnsupportedImageFeatureException`. +- **`CCITTFaxDecode` with a non-negative `/K` (Group 3), or with `/EndOfLine true`** โ€” + `Codecs.UnsupportedImageFeatureException` (added in Phase 15; this decoder implements Group 4 + (T.6 MMR) only and never scans for EOL/EOFB/RTC bit patterns); **`CCITTFaxDecode` whose + resolved `/ColorSpace` has more than 1 component** โ€” `Codecs.UnsupportedImageFeatureException`; + a malformed/truncated CCITT bit stream (an unrecognized mode code, a changing-element search + that runs past `/Columns`, or running out of bits mid-row) โ€” `InvalidDataException` instead. +- **`Do` on a `/Subtype /Form` XObject whose nesting depth already equals + `MaxFormNestingDepth` (`12`), or whose own `/Matrix` is present but is not an array of exactly + 6 numbers** โ€” `InvalidDataException` (added in Phase 13; see _Image XObjects_/_Form XObjects_ + above). +- **`Do` with a name undeclared in `/Resources/XObject` (or no `/Resources` at all), a + non-stream/missing-`/Subtype` resolved value, or a malformed operand count/type** โ€” + `InvalidDataException` (a malformed content stream, not merely unsupported). +- **A font dictionary whose `/Subtype` is `/MMType1`** โ€” + `Codecs.UnsupportedImageFeatureException` (Multiple Master Type 1 fonts remain + entirely out of scope by design). A `/Subtype /TrueType` or `/Subtype /Type1` font lacking an + embedded `/FontDescriptor/FontFile2`/`/FontFile`/`/FontFile3` no longer reaches this list at all + as of Phase 6 (widened to `/Type1` in Phase B) - it is resolved + via fallback substitution instead (see below). A `/BaseFont` of exactly `Symbol` or + `ZapfDingbats` resolves via a bundled Noto substitute font union instead of reaching this list + either (see _Font fallback resolution_ above); any other font whose `/FontDescriptor/Flags` + declares `Symbolic` without also + declaring `Nonsymbolic` still throws `Codecs.UnsupportedImageFeatureException` (feature + `pdf-font-symbolic-not-embedded`) - a symbol/dingbat glyph set with no bundled substitute has no + meaningful generic-family + equivalent and is never substituted with an unrelated system or bundled font. As of Phase C, a + `/Subtype /Type1` descriptor declaring only `/FontFile3` (neither `/FontFile` nor `/FontFile2`) + is resolved by `LoadType1CFont`: as of Phase 18, the stream's declared `/Subtype` is never + consulted for dispatch at all - only the decoded bytes' own shape is sniffed (an SFNT container + or a bare CFF header, via the shared `SniffFontFile3Shape` helper); only bytes matching neither + recognized shape are rejected with `Codecs.UnsupportedImageFeatureException` (feature + `pdf-font-fontfile3-unrecognized-shape`, reusing the exact feature-key pattern + `LoadCidFontType0Font`'s own shape-sniffing dispatch below already establishes) - an + SFNT-wrapped (`/OpenType`-shaped) CFF stream on a simple `/Type1` font is now supported here, + exactly like the composite `CIDFontType0` path below. A `/FontFile` stream's own + missing or non-numeric `/Length1`/`/Length2` entry, or any exception `Fonts.TrueTypeFont.LoadType1` + itself throws for a malformed embedded Type 1 program, is `InvalidDataException` (no fallback + substitution is ever attempted once an embedded Type 1 program is present, matching the + `CIDFontType2`/`CIDFontType0` "embedded fonts fail closed" precedent); likewise, any exception + `Fonts.TrueTypeFont.Load`/`Fonts.TrueTypeFont.LoadType1C` itself throws for a malformed or + CID-keyed (`ROS`-bearing) embedded CFF program is `InvalidDataException`, propagated uncaught + under the same convention. +- **A `/Subtype /Type3` font dictionary missing (or non-array-of-6-numbers) `/FontMatrix`, or + missing (or non-dictionary) `/CharProcs`** โ€” `InvalidDataException` (added in Phase D; a + malformed/missing required field, not merely unsupported - unlike every other resolved font + kind, a Type 3 font has no fallback-substitution path at all, so either condition fails + closed unconditionally). A Type 3 glyph procedure's recursion exceeding + `MaxType3NestingDepth` (`12`, tracked independently of `MaxFormNestingDepth` - see + _Type 3 Glyph Painting_ above) is `InvalidDataException` as well, mirroring + `OpDrawFormXObject`'s own nesting-depth precedent. An unmapped character code, or a mapped + glyph name absent from `/CharProcs`, is never an exception for a Type 3 font - it paints + nothing but still advances (a deliberate leniency, matching `ResolveWidths`'s own established + "missing optional field" convention, not this class's "malformed required field" convention). +- **A `/Subtype /Type0` font's `/Encoding`, when not the name `Identity-H`** (for example + `Identity-V` or a predefined CJK encoding name) โ€” `Codecs.UnsupportedImageFeatureException` + (feature `pdf-font-type0-encoding-{name}`), as of Phase 9. A `/Type0` font's `/DescendantFonts` + entry, when missing or not a single-element array whose element resolves to a dictionary, is + `InvalidDataException` instead (a malformed required field, not merely unsupported). A + descendant font's `/Subtype`, when neither `CIDFontType2` nor `CIDFontType0` โ€” + `Codecs.UnsupportedImageFeatureException` (feature `pdf-font-cidfonttype-{subtype}`). A + `CIDFontType2` descendant font's missing/non-embedded `/FontDescriptor/FontFile2`, or a + `CIDFontType0` descendant font's missing/non-embedded `/FontDescriptor/FontFile3`, is + `InvalidDataException` (no fallback substitution is ever attempted for a composite font - a + deliberate Phase 9 Non-Goal, unchanged by Phase 12/18). As of Phase 18, a `CIDFontType0` + descendant's `/FontFile3` stream's declared `/Subtype` is never consulted for dispatch at all - + only the decoded bytes' own shape is sniffed (an SFNT container or a bare CFF header, via the + shared `SniffFontFile3Shape` helper); only bytes matching neither recognized shape are rejected + with `Codecs.UnsupportedImageFeatureException` (feature + `pdf-font-fontfile3-unrecognized-shape`); a bare, non-SFNT-wrapped CFF stream (for example + declared `/CIDFontType0C`) whose embedded CFF program is CID-keyed (`ROS` present) is + `InvalidDataException`, surfaced uncaught from `Fonts.CffTable.Parse`'s own existing rejection - + the same rejection an SFNT-wrapped CID-keyed CFF program already triggered before Phase 18. + A `CIDFontType2` descendant font's `/CIDToGIDMap`, when resolving to anything other than the + name `Identity` or a stream (or a stream with an odd byte count), is `InvalidDataException` + (this key is never consulted, and any non-standard occurrence never validated, for a + `CIDFontType0` descendant); a malformed `/W` array shape (not matching either the + `c [w1 w2 ... wn]` or `cFirst cLast w` sub-form, or a range form with `cLast < cFirst`) is + `InvalidDataException` too, regardless of descendant subtype. A composite + font's shown byte string with an odd byte length is `InvalidDataException` (a 2-byte-per-code + string must have an even byte count). +- **A `bfrange` destination array containing a nested array** (the CIDSystemInfo-style "array of + arrays" destination sub-form), as of Phase 10 โ€” `Codecs.UnsupportedImageFeatureException` + (feature `pdf-font-tounicode-bfrange-array-destination`); a `usecmap`/`cidrange`/`cidchar` + operator appearing in a `/ToUnicode` CMap stream is likewise + `Codecs.UnsupportedImageFeatureException` (feature `pdf-font-tounicode-{operator}`). By + contrast, `begincodespacerange`/`endcodespacerange` and the CMap's own PostScript resource- + management wrapper keywords (`begin`/`end`/`dict`/`def`/`findresource`/`defineresource`/`pop`/ + `currentdict`/`begincmap`/`endcmap`) are silently ignored, not rejected - they carry no + bfchar/bfrange mapping semantics of their own. An absent `/ToUnicode` entry resolves to a null + map, matching `ResolveWidths`'s own "missing optional field" leniency. +- **An `/Encoding` naming an unrecognized base encoding** (anything other than + `/WinAnsiEncoding`/`/MacRomanEncoding`/`/StandardEncoding` (the last added in Phase B), or their + dictionary form's `/BaseEncoding`) โ€” `Codecs.UnsupportedImageFeatureException`. +- **`Tr` (text-rendering mode) set to `4`-`7`** (the clip modes) โ€” + `Codecs.UnsupportedImageFeatureException`; any other numeric value outside `0`-`7` is + `InvalidDataException` instead. +- **A text-showing operator (`Tj`/`'`/`"`/`TJ`) with no font currently selected** (`Tf` was never + called), or a malformed operand count/type for any text operator โ€” `InvalidDataException`, + matching every other operator family's own convention. +- **Any public member called after `Dispose()`** โ€” `ObjectDisposedException`, thrown first via + `ObjectDisposedException.ThrowIf(_disposed, this)`, before any other validation. + +### Dependencies + +- `Canvas.Surface` (from the core `CanvasNet` system) โ€” the `Render` return type, every + path-painting operator's paint destination, and a decoded image XObject's own pixel buffer +- `Canvas.Rgba32` (from the core `CanvasNet` system) โ€” the current fill/stroke color and a + decoded image sample's color representation +- `Codecs.UnsupportedImageFeatureException` (from the core `CanvasNet` system) โ€” reused, + unmodified, for `/Encrypt` detection and, as of Phase 3, an unsupported color space/stream + filter/image bit depth; as of Phase 14, also an unsupported pattern type/shading + type/shading color space/function type; as of Phase 16, also each of the five distinct + narrowed encryption rejection reasons documented under _Error Handling_ above +- `Codecs.JpegCodec` (from the core `CanvasNet` system, new as of Phase 3) โ€” `Load(Stream)` + decodes an image XObject's bare `DCTDecode` (JPEG) bitstream directly, without requiring + APP0/JFIF framing +- `Geometry.PathBuilder`/`Path` (from the core `CanvasNet` system) โ€” accumulates the current + path's subpaths/commands as path-construction operators are dispatched +- `Drawing.PathFiller`/`PathStroker`/`StrokeStyle`/`FillRule`/`LineCap`/`LineJoin` (from the core + `CanvasNet` system) โ€” rasterize the current path onto `_surface` for every path-painting + operator, and, as of Phase 4, every filled glyph outline +- `Drawing.Gradient`/`LinearGradient`/`RadialGradient`/`GradientStop`/`GradientSpread` and + `Drawing.TilePaint` (from the core `CanvasNet` system, new as of the Phase 14 `/Pattern` + color-space work) โ€” `PathFiller`'s `Gradient`-typed `Fill` overload paints an axial + (`LinearGradient`) or radial (`RadialGradient`) shading pattern built from the resolved + `/Shading` dictionary's evaluated function and `/Coords`; its `TilePaint`-typed `Fill` overload + paints a colored/uncolored tiling pattern's own pre-rendered repeating tile cell through the + pattern-to-device transform โ€” see _`/Pattern` Color Space (Shading and Tiling Patterns)_ above + for the full construction detail +- `Fonts.TrueTypeFont` (from the core `CanvasNet` system, new as of Phase 4, `LoadType1` added in + Phase A) โ€” loads an embedded `/FontFile2` byte stream and resolves each shown codepoint to a + glyph index/outline/advance width, exactly as `SvgCodec.Text.cs` already uses it for SVG + `` rendering; `LoadType1(Stream, int length1, int length2, IReadOnlyDictionary codepointToGlyphName)` is used as of Phase B by `LoadType1Font` to load an embedded + classic PostScript Type 1 `/FontFile` program, reusing the same `TrueTypeFont` type rather than + a separate font representation; `LoadType1C(Stream stream, IReadOnlyDictionary + codepointToGlyphName)` is used as of Phase C by `LoadType1CFont` to load an embedded bare + Type1C/CFF `/FontFile3` program, again reusing the same `TrueTypeFont` type +- BCL `System.IO.Compression.DeflateStream` โ€” `FlateDecode` decompression of object streams and, + as of Phase 3, any other `FlateDecode`-filtered stream (image XObjects included) +- BCL `System.Numerics.Matrix3x2`/`Vector2` โ€” the current transformation matrix, every + transformed path point, and (as of Phase 3) an image XObject's unit-square-to-device-space + compositing math +- `PdfTokenizer`/`PdfObject`/`ParseValue` (internal, `PdfDocument.Tokenizer.cs`/ + `PdfDocument.ObjectModel.cs`) โ€” reused as of Phase 10 by `ResolveToUnicodeMap` to tokenize and + parse a `/ToUnicode` CMap stream's `bfchar`/`bfrange` operands (hex strings, literal strings, + and arrays); no second, purpose-built CMap tokenizer was introduced. +- BCL `System.Security.Cryptography.MD5`/`SHA256`/`Aes` (new as of Phase 16, + `PdfDocument.Encryption.cs`) โ€” `MD5.HashData`/`SHA256.HashData` implement ISO 32000-1 + Algorithm 2/4/5's and ISO 32000-2 Algorithm 2.A's hash steps exactly as the PDF specification + itself mandates (not a free security choice โ€” see the file's own `S4790` suppression + justification comment); `Aes.Create()` (`CipherMode.CBC`, `PaddingMode.PKCS7`/`None`) decrypts + AESV2/AESV3 strings and streams. RC4 has no BCL equivalent and is hand-rolled (`Rc4Transform`), + mirroring this codebase's existing convention of hand-rolled standard algorithms elsewhere + (e.g. CRC-32/Adler-32/CCITT tables). + +### Callers + +None yet - `PdfDocument` is `CanvasNetPdf`'s sole unit and its system's only public entry point; +no other unit or subsystem in this repository calls into it. diff --git a/docs/design/canvas-net.md b/docs/design/canvas-net.md index 599e5a04..7e9ed5c4 100644 --- a/docs/design/canvas-net.md +++ b/docs/design/canvas-net.md @@ -18,44 +18,56 @@ DEMA Consulting best practices. The system consists of six implemented subsystem span-based row access and independent-copy cropping) and the `Rgba32` unit (a single-pixel value type, documented inline within `Surface`). See _Canvas Subsystem Design_ (`canvas.md`). - **Codecs subsystem** (namespace `DemaConsulting.CanvasNet.Codecs`, folder - `src/DemaConsulting.CanvasNet/Codecs/`, flat โ€” no further nesting): four hand-rolled image + `src/DemaConsulting.CanvasNet/Codecs/`, flat โ€” no further nesting): five hand-rolled image format codecs, each converting to and from a `DemaConsulting.CanvasNet.Canvas.Surface` pixel buffer โ€” `BmpCodec` (uncompressed 24-bit/32-bit Windows BMP), `PngCodec` (saves 8-bit-per-channel Truecolor and Truecolor-with-alpha, non-interlaced PNG; loads every non-interlaced, spec-valid PNG color type/bit depth combination), `TiffCodec` (8-bit-per-sample RGB, RGBA, and Grayscale, strip-based TIFF 6.0 with None/PackBits/LZW/Deflate compression, either byte order), - and `JpegCodec` (a common real-world subset of JPEG: baseline/progressive decode with - 4:4:4/4:2:2/4:2:0 support, baseline 4:2:0 encode). See _Codecs Subsystem Design_ (`codecs.md`). + `JpegCodec` (a common real-world subset of JPEG: baseline/progressive decode with + 4:4:4/4:2:2/4:2:0 support, baseline 4:2:0 encode), and `GifCodec` (decode-only load of a GIF's + first image frame, including LZW decompression and deinterlacing, plus a `GetInfo` that reports + the file's true total frame count without resolving later frames into a `Surface`). See + _Codecs Subsystem Design_ (`codecs.md`). - **Geometry subsystem** (namespace `DemaConsulting.CanvasNet.Geometry`, folder `src/DemaConsulting.CanvasNet/Geometry/`, flat โ€” no further nesting): vector-geometry primitives, built directly on `System.Numerics.Vector2`/`Matrix3x2` โ€” the `Rect` unit (an axis-aligned bounding rectangle with a union-identity `Empty` sentinel), the `Path` unit (an immutable vector path and its fluent `PathBuilder`, together with the supporting `Subpath`, `PathCommand`, and `PathCommandType` types documented inline), the `BezierFlattening` unit - (adaptive quadratic/cubic Bezier curve flattening), and the `SvgArcConverter` unit - (SVG-style elliptical arc to cubic Bezier conversion). `Geometry` has no dependency on `Canvas` + (adaptive quadratic/cubic Bezier curve flattening), the `SvgArcConverter` unit + (SVG-style elliptical arc to cubic Bezier conversion), and the `CornerRoundEffect` unit + (path-level pre-processing that rounds polyline corners into tangent-radius arcs via + `PathBuilder.TangentArcTo`). `Geometry` has no dependency on `Canvas` or `Codecs`, and neither of those subsystems depends on `Geometry`. See _Geometry Subsystem Design_ (`geometry.md`). - **Drawing subsystem** (namespace `DemaConsulting.CanvasNet.Drawing`, folder `src/DemaConsulting.CanvasNet/Drawing/`, flat โ€” no further nesting): vector rendering for `Geometry.Path` geometry through three public entry points โ€” the `PathFiller` unit - (a public static `Fill` entry point supporting both solid-color and gradient paint, with the + (a public static `Fill` entry point supporting solid-color, gradient, and tile paint, with the supporting `FillRule` enum and the internal `EdgeFlattener`/`ScanlineRasterizer` helpers documented inline), the `PathStroker` unit (a public static `Stroke` entry point, with the supporting `LineCap`/`LineJoin`/`StrokeStyle` types and the internal `StrokePathFlattener`/`DashSplitter`/`StrokeOutliner` helpers documented - inline), and the `GradientPaint` unit (the public `Gradient`/`LinearGradient`/`RadialGradient`/ + inline), the `GradientPaint` unit (the public `Gradient`/`LinearGradient`/`RadialGradient`/ `GradientStop`/`GradientSpread` types and the internal `GradientEvaluator` helper that - `PathFiller`'s gradient overload evaluates). `Drawing` consumes both the `Canvas` subsystem's `Surface` unit (via + `PathFiller`'s gradient overload evaluates), and the `TilePaint` unit (a public sealed type + pairing a pre-rendered one-cell tile `Surface` with a pattern-space transform and `XStep`/ + `YStep` pitch, and the internal `TilePaintEvaluator` helper that `PathFiller`'s tile overload + evaluates). `Drawing` consumes both the `Canvas` subsystem's `Surface` unit (via `Surface.CompositeOverSpan`) and the `Geometry` subsystem's `Path`/`PathBuilder`/ `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. 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` + `src/DemaConsulting.CanvasNet/Fonts/`, flat โ€” no further nesting): TrueType (`glyf`-based) and + CFF/OpenType font loading through the `TrueTypeFont` unit, which fronts the internal + `SfntContainer`/`CmapTable`/`GlyfLocaReader`/`CffTable`/`CffCharstringInterpreter`/ + `HmtxHheaReader`/`KernTable` helpers documented inline, and host system font discovery through + the `SystemFontCatalog` unit, which best-effort matches a requested family/style against the + host operating system's installed fonts and provides a bundled Liberation Sans/Serif/Mono + last-resort fallback `TrueTypeFont`. `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 @@ -70,53 +82,75 @@ DEMA Consulting best practices. The system consists of six implemented subsystem 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 -on `Codecs` or on any other subsystem. Within the `Codecs` subsystem, its four units are flat and -mutually independent โ€” none of `BmpCodec`, `PngCodec`, `TiffCodec`, or `JpegCodec` depends on any -other codec. Each codec also exposes a pair of `GetInfo(Stream)`/`GetInfo(string)` overloads +on `Codecs` or on any other subsystem. Within the `Codecs` subsystem, its five units are flat and +mutually independent โ€” none of `BmpCodec`, `PngCodec`, `TiffCodec`, `JpegCodec`, or `GifCodec` +depends on any other codec. Each codec also exposes a pair of `GetInfo(Stream)`/`GetInfo(string)` overloads returning the shared `ImageInfo` record struct (width, height, channel count, and alpha presence) without fully decoding pixel data and without enforcing `Surface.MaxDimension` (now a public -constant, so callers can perform this comparison themselves before ever calling `Load`) โ€” see +constant, so callers can perform this comparison themselves before ever calling `Load`) โ€” except +`GifCodec.GetInfo`, which decodes the first frame's LZW-compressed pixel data to validate +`CanDecode` (but never resolves those pixels into a rendered `Surface`) while scanning, without +decoding, every later frame's block structure purely to report the file's true total frame count +โ€” see _Codecs Subsystem Design_ (`codecs.md`) for the shared `ImageInfo` type and header-only-probing pattern, and _Surface Unit Design_ (`canvas/surface.md`) for `MaxDimension`. See _Surface Unit Design_ (`canvas/surface.md`), _BmpCodec Unit Design_ (`codecs/bmp-codec.md`), _PngCodec Unit Design_ (`codecs/png-codec.md`), -_TiffCodec Unit Design_ (`codecs/tiff-codec.md`), and _JpegCodec Unit Design_ -(`codecs/jpeg-codec.md`) for each unit's internal collaboration. +_TiffCodec Unit Design_ (`codecs/tiff-codec.md`), _JpegCodec Unit Design_ +(`codecs/jpeg-codec.md`), and _GifCodec Unit Design_ (`codecs/gif-codec.md`) for each unit's +internal collaboration. -The `Geometry` subsystem's four units collaborate as follows: `PathBuilder` records raw drawing +The `Geometry` subsystem's five units collaborate as follows: `PathBuilder` records raw drawing commands (including raw, unconverted SVG arc parameters) and produces immutable `Path` snapshots; `Path.GetBounds` is the primary internal consumer of both `BezierFlattening` (to flatten curves when a tighter, tolerance-based bound is requested) and `SvgArcConverter` (to convert any `ArcTo` command to cubic Bezier segments before applying either bounds mode, since arcs carry no control -points of their own). `Rect` has no dependency on the other three units, but is the return type of -`Path.GetBounds` and is used throughout as the common bounding-box representation. See +points of their own). `Rect` has no dependency on the other units, but is the return type of +`Path.GetBounds` and is used throughout as the common bounding-box representation. +`CornerRoundEffect` consumes an existing `Path` and produces a new `Path` with eligible corners +replaced by tangent-radius arcs (via `PathBuilder.TangentArcTo`); it has no dependency on +`BezierFlattening` or `SvgArcConverter`, and none of the other four units depends on it. See _Geometry Subsystem Design_ (`geometry.md`), _Rect Unit Design_ (`geometry/rect.md`), _Path Unit Design_ (`geometry/path.md`), _BezierFlattening Unit Design_ -(`geometry/bezier-flattening.md`), and _SvgArcConverter Unit Design_ -(`geometry/svg-arc-converter.md`) for each unit's internal collaboration. +(`geometry/bezier-flattening.md`), _SvgArcConverter Unit Design_ +(`geometry/svg-arc-converter.md`), and _CornerRoundEffect Unit Design_ +(`geometry/corner-round-effect.md`) for each unit's internal collaboration. -The `Drawing` subsystem's two units collaborate as follows. `PathFiller.Fill` first delegates to +The `Drawing` subsystem's four units collaborate as follows. `PathFiller.Fill` first delegates to the internal `EdgeFlattener` (which converts the target `Geometry.Path`'s subpaths to closed polygons, flattening curves via `Geometry.BezierFlattening` and arcs via `Geometry.SvgArcConverter`), then computes the flattened polygons' bounds and intersects them with the `Canvas.Surface`'s pixel extent (a no-op if the path is empty or the intersection is empty), then delegates to the internal `ScanlineRasterizer` (which rasterizes those polygons into per-row -antialiased coverage and composites each row directly via `Canvas.Surface.CompositeOverSpan`). +antialiased coverage and composites each row directly via `Canvas.Surface.CompositeOverSpan`), +resolving each covered pixel's color either directly from the caller-supplied solid `Rgba32`, from +`GradientPaint`'s internal `GradientEvaluator` (for the gradient overload), or from `TilePaint`'s +internal `TilePaintEvaluator` (for the tile overload). `PathStroker.Stroke` runs earlier in the pipeline: it flattens each source subpath while preserving `Subpath.IsClosed`, applies optional dash-array and dash-offset semantics, offsets the visible polyline segments into closed outline polygons with cap and join geometry, and assembles those polygons into a new `Geometry.Path` via `PathBuilder`; callers render the returned outline -path through `PathFiller.Fill`. See _Drawing Subsystem Design_ (`drawing.md`), _PathFiller Unit -Design_ (`drawing/path-filler.md`), and _PathStroker Unit Design_ (`drawing/path-stroker.md`) for +path through `PathFiller.Fill`. `GradientPaint` and `TilePaint` are both pure paint-description +data types with no dependency on `PathFiller`, `PathStroker`, or each other; `PathFiller` is the +sole consumer of either. See _Drawing Subsystem Design_ (`drawing.md`), _PathFiller Unit +Design_ (`drawing/path-filler.md`), _PathStroker Unit Design_ (`drawing/path-stroker.md`), +_GradientPaint Unit Design_ (`drawing/gradient-paint.md`), and _TilePaint Unit Design_ +(`drawing/tile-paint.md`) for full detail. -The `Fonts` subsystem's single public unit collaborates with its internal helpers as follows. +The `Fonts` subsystem's two public units collaborate with their own 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 +parsing, validates the required `head`/`maxp`/`hhea`/`hmtx`/`loca`/`glyf` tables (or, for a +CFF/OpenType font, delegates to `CffTable`/`CffCharstringInterpreter` for charstring-based outline +decoding instead), 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 +so callers who want pixels scale and flip that path before rendering it through `Drawing`. +`SystemFontCatalog` has no dependency on `TrueTypeFont`'s internal parsing helpers, but +`LoadBundledFallback` constructs a `TrueTypeFont` from one of the library's bundled Liberation +font embedded resources via `TrueTypeFont.Load`, making `TrueTypeFont` the sole unit +`SystemFontCatalog` depends on within this subsystem. See _Fonts Subsystem Design_ (`fonts.md`) and _TrueTypeFont Unit Design_ (`fonts/true-type-font.md`) for full detail. @@ -197,6 +231,14 @@ The system exposes the following public API to external consumers: range 1-100. Throws `ArgumentNullException` for a null `surface`/`stream`/`path`, `ArgumentException` for an empty `path`, and `ArgumentOutOfRangeException` for an out-of-range `quality`. +- **GifCodec.Load(Stream stream)** / **GifCodec.Load(string path)**: Loads a `Surface` from a + GIF stream or file's first image frame (decode-only - no save support). Throws + `ArgumentNullException` for a null `stream`/`path`, `ArgumentException` for an empty `path`, + and `InvalidDataException` for malformed or unsupported GIF data. +- **GifCodec.GetInfo(Stream stream)** / **GifCodec.GetInfo(string path)**: Returns the shared + `ImageInfo` record struct, additionally reporting the file's true total frame count; unlike + every other codec's `GetInfo`, this decodes the first frame's LZW-compressed pixel data to + validate `CanDecode` (but never resolves it into a rendered `Surface`). - **Rect(float x, float y, float width, float height)**: Constructor; an axis-aligned rectangle in position-plus-size form. `Rect.Empty` is a static, publicly readable union-identity sentinel. - **Rect.Union(Rect)** / **Rect.Union(Rect, Rect)**: Returns the smallest rectangle enclosing both @@ -228,6 +270,12 @@ The system exposes the following public API to external consumers: - **SvgArcConverter.ToBeziers(...)**: Appends the cubic Bezier segments equivalent to an SVG-style elliptical arc to a caller-supplied output list, in end-to-end order. Never throws for any SVG-valid input. +- **CornerRoundEffect.Apply(Path, float, float)**: Returns a new `Path` with eligible polyline + corners replaced by tangent-radius arcs of the given radius (flattening tolerance controls arc + smoothness); corners where the radius does not fit the available segment length are left + unrounded. Throws `ArgumentNullException` for a null `path`, and + `ArgumentOutOfRangeException` for a negative or non-finite radius or a non-positive or + non-finite flattening tolerance. - **PathFiller.Fill(Surface, Path, Rgba32, FillRule, float)**: Fills a closed `Path` with a solid color onto a `Surface` using an antialiased scanline-coverage rasterizer, with a fill rule (`FillRule.NonZero` by default, or `FillRule.EvenOdd`) and a curve-flattening tolerance @@ -235,6 +283,14 @@ The system exposes the following public API to external consumers: Throws `ArgumentNullException` for a null `surface`/`path`, and `ArgumentOutOfRangeException` for an undefined `fillRule` value or a non-finite or non-positive `flattenTolerance`. +- **PathFiller.Fill(Surface, Path, Gradient, FillRule, float)**: As above, but resolves each + covered pixel's color from a `GradientPaint` `Gradient` (`LinearGradient` or `RadialGradient`) + instead of a single solid color. Throws the same exceptions as the solid-color overload, plus + `ArgumentNullException` for a null `paint`. +- **PathFiller.Fill(Surface, Path, TilePaint, FillRule, float)**: As above, but resolves each + covered pixel's color by sampling a repeating `TilePaint` tile bitmap at the corresponding + wrapped-around pattern-space offset. Throws the same exceptions as the solid-color overload, + plus `ArgumentNullException` for a null `paint`. - **LineCap** / **LineJoin**: Public enums selecting stroke end-cap and corner-join geometry. - **StrokeStyle(float width, LineCap cap, LineJoin join, float miterLimit, IReadOnlyList? dashArray, float dashOffset)**: Immutable public stroke-style snapshot. Throws `ArgumentOutOfRangeException` for invalid width, @@ -264,44 +320,60 @@ The system exposes the following public API to external consumers: - **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. +- **SystemFontCatalog.Fonts**: Read-only property lazily enumerating the host operating system's + installed fonts on first access (caching the result for the process lifetime) as a list of + `SystemFontInfo` records. Never throws. +- **SystemFontCatalog.FindBestMatch(string familyNameHint, bool bold, bool italic, bool serif, bool fixedPitch)**: + Returns the closest-matching `SystemFontInfo?` for the requested family-name hint and style, or + `null` if neither an exact family match nor a well-known generic-family fallback is found. + Never throws. +- **SystemFontCatalog.LoadBundledFallback(bool serif, bool fixedPitch, bool bold, bool italic)**: + Returns a ready-to-query `TrueTypeFont` loaded from one of the library's twelve embedded + Liberation Sans/Serif/Mono resources, selected by the requested style combination. -| 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 | +| 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 | +| `GifCodec.Load(...)` | Inbound/Outbound | Method call / `Surface` return | Valid GIF stream or path | +| `GifCodec.GetInfo(...)` | Inbound/Outbound | Method call / `ImageInfo` | Valid GIF stream or path | +| `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 | +| `CornerRoundEffect.Apply(...)` | Inbound/Outbound | Method call / `Path` return | Non-null; radius/tol >= 0 | +| `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 | +| `SystemFontCatalog.Fonts` | Outbound | Property / list return | None | +| `SystemFontCatalog.FindBestMatch(...)` | Outbound | Method / `SystemFontInfo?` | None | +| `SystemFontCatalog.LoadBundledFallback(...)` | Outbound | Method / `TrueTypeFont` | None | ## Dependencies @@ -310,28 +382,31 @@ CanvasNet has one runtime NuGet dependency: `System.Numerics.Tensors`, used by t unit's vectorized bulk pixel operations (`PremultiplyAlpha`, `UnpremultiplyAlpha`, `CompositeOver`) for their `TensorPrimitives`-based numeric work โ€” see _Surface Unit Design_ (`canvas/surface.md`) for details. Every other member of `Surface`, and all of `BmpCodec`, -`PngCodec`, `TiffCodec`, and `JpegCodec`, are implemented exclusively against the .NET Base Class +`PngCodec`, `TiffCodec`, `JpegCodec`, and `GifCodec`, are implemented exclusively against the +.NET Base Class Library (`Surface`'s remaining use of `Span` and `MemoryMarshal` are BCL APIs available natively on every target framework; `BmpCodec` uses only `System.IO` types; `PngCodec` and `TiffCodec` additionally use `System.IO.Compression.DeflateStream`; `JpegCodec` additionally uses -`System.Numerics.Vector` for optional SIMD acceleration; all of these are BCL APIs available on +`System.Numerics.Vector` for optional SIMD acceleration; `GifCodec` uses only `System.IO` +types; all of these are BCL APIs available on every target framework, with no additional runtime NuGet package required). The `Geometry` subsystem introduces zero new runtime NuGet dependencies: it is implemented entirely against `System.Numerics.Vector2` and `System.Numerics.Matrix3x2`, which are in-box BCL types available natively on every target framework this library supports (net8.0, net9.0, net10.0) โ€” no custom -point/vector wrapper types were introduced. The `Drawing` subsystem likewise introduces zero new -runtime NuGet dependencies: `PathFiller`, `FillRule`, `EdgeFlattener`, `ScanlineRasterizer`, -`PathStroker`, `LineCap`, `LineJoin`, `StrokeStyle`, `StrokePathFlattener`, `DashSplitter`, and -`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 `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 +point/vector wrapper types were introduced; this includes `CornerRoundEffect`, which is +implemented entirely in terms of `Geometry.Path`/`PathBuilder`. The `Drawing` subsystem likewise introduces zero new +runtime NuGet dependencies: `PathFiller`, `FillRule`, `EdgeFlattener`, `ScanlineRasterizer`, `PathStroker`, `LineCap`, +`LineJoin`, `StrokeStyle`, `StrokePathFlattener`, `DashSplitter`, `StrokeOutliner`, `GradientPaint`'s types, and +`TilePaint` 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 `Fonts` subsystem likewise +introduces zero new runtime NuGet dependencies: `TrueTypeFont`, `SfntContainer`, `CmapTable`, `GlyfLocaReader`, +`CffTable`, `CffCharstringInterpreter`, `HmtxHheaReader`, `KernTable`, and `SystemFontCatalog` 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` (`SystemFontCatalog`'s host-font-directory enumeration uses only +`System.IO`/`System.Environment` BCL APIs, with no platform-specific NuGet package). 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: - **BuildMark** โ€” generates build-notes documentation; see _BuildMark Design_ @@ -485,6 +560,23 @@ measures (IEC 62304 ยง5.3.3). pixels into the destination `Surface` 4. **Output**: A new `Surface` containing the decoded pixels +**GIF load path:** + +1. **Input**: Method parameter `stream`/`path` +2. **Validation**: `Load` rejects a null `stream`/`path` with `ArgumentNullException`, an empty + `path` with `ArgumentException`, and malformed/unsupported GIF data (bad signature, an + unsupported logical-screen/color-table configuration, or a truncated/malformed LZW-compressed + first-frame data stream) with `InvalidDataException` +3. **Processing**: Parses the GIF header, logical screen descriptor, and global/local color + tables, then decodes only the first image frame's LZW-compressed pixel data (deinterlacing it + if the frame declares an interlaced layout), mapping indexed pixels through the applicable + color table (honoring a Graphic Control Extension transparent-color index, if present) directly + into the destination `Surface` +4. **Output**: A new `Surface` containing the decoded first frame's pixels; `GetInfo` additionally + reports the file's true total frame count (by decoding the first frame to validate `CanDecode`, + then scanning, without fully decoding, every subsequent frame's own block structure) without + ever resolving those later frames into a rendered `Surface` + **TrueType font load and glyph-query path:** 1. **Input**: A font stream or file path, followed by caller-supplied codepoints or glyph indices @@ -497,6 +589,56 @@ measures (IEC 62304 ยง5.3.3). 4. **Output**: A `TrueTypeFont` object exposing metrics, codepoint-to-glyph lookup, glyph outlines, advance widths, and kerning adjustments +**System font discovery and fallback path:** + +1. **Input**: `SystemFontCatalog.FindBestMatch`'s `familyNameHint`/`bold`/`italic`/`serif`/ + `fixedPitch` parameters, or `LoadBundledFallback`'s `serif`/`fixedPitch`/`bold`/`italic` + parameters +2. **Validation**: Neither method throws for any input; `FindBestMatch` simply returns `null` when + no match is found at either tier +3. **Processing**: `Fonts` lazily enumerates the host operating system's installed fonts on first + access (platform-specific font directories/registries), caching the result for the process + lifetime; `FindBestMatch` first searches for an exact (case-insensitive) family-name match, then + falls back to a small, hand-maintained list of well-known generic-family names (sans-serif, + serif, or monospace); `LoadBundledFallback` instead selects one of the twelve bundled Liberation + Sans/Serif/Mono TrueType fonts (embedded resources) by the requested style combination and + loads it via `TrueTypeFont.Load` +4. **Output**: `FindBestMatch` returns a `SystemFontInfo?` (family/subfamily name, derived + bold/italic/fixed-pitch style, and the file path/face index needed to load it); + `LoadBundledFallback` returns a ready-to-query `TrueTypeFont` + +**Corner-rounding path:** + +1. **Input**: A source `Path` plus a corner radius (and, for `Geometry.CornerRoundEffect`'s + method, a flattening tolerance) +2. **Validation**: Rejects a null `path` with `ArgumentNullException`, and a negative or + non-finite radius/tolerance with `ArgumentOutOfRangeException` +3. **Processing**: Walks each subpath's vertices, and - where two adjacent segments meet at an + angle sharp enough for the requested radius to fit - replaces the vertex with a + tangent-radius arc via `PathBuilder.TangentArcTo`, leaving vertices where no arc fits (the + radius is too large for the available segment length) unrounded +4. **Output**: A new `Path` with eligible corners replaced by tangent-radius arcs, suitable for + filling through `PathFiller.Fill` exactly like any other `Path` + +**Tile-paint fill path:** + +1. **Input**: Method parameters `surface`, `path`, a `TilePaint` (itself composed of a pre-rendered + tile `Surface`, a `Transform`, and an `XStep`/`YStep` pitch), `fillRule`, and + `flattenTolerance` +2. **Validation**: `PathFiller.Fill`'s tile overload rejects a null `surface`/`path`/`paint` with + `ArgumentNullException`, and an undefined `fillRule` or a non-finite/non-positive + `flattenTolerance` with `ArgumentOutOfRangeException`; `TilePaint`'s constructor itself already + validated its `transform`/`xStep`/`yStep` at construction time +3. **Processing**: Shares `EdgeFlattener`/`ScanlineRasterizer` with the solid-color and gradient + overloads, but resolves each covered pixel's color via `TilePaintEvaluator`, which maps the + pixel's path-space coordinate through the inverse of `TilePaint.Transform` into pattern space, + wraps that pattern-space coordinate into the tile's `[0, XStep) x [0, YStep)` cell (correctly + handling a negative `XStep`/`YStep` via its absolute value), and samples the wrapped coordinate + from `TilePaint.Surface` +4. **Output**: `surface`'s pixels within `path`'s bounds composited with the repeating tile + pattern, via `Surface.CompositeOverSpan`, exactly as the solid-color and gradient overloads + composite their own per-pixel colors + **Path stroke conversion path:** 1. **Input**: Method parameters `path`, `style`, and optional `flattenTolerance` diff --git a/docs/design/canvas-net/canvas.md b/docs/design/canvas-net/canvas.md index 197d3d9d..5cdd9d8a 100644 --- a/docs/design/canvas-net/canvas.md +++ b/docs/design/canvas-net/canvas.md @@ -33,10 +33,10 @@ of CanvasNet's target frameworks. The `Canvas` subsystem's `Surface` unit is a public API entry point, invoked externally by consumers of the CanvasNet package. It is also invoked internally by every unit of the `Codecs` -subsystem (`BmpCodec`, `PngCodec`, `TiffCodec`, `JpegCodec`), each of which constructs a `Surface` -when loading and reads its rows when saving โ€” see _Codecs Subsystem Design_ (`codecs.md`) for -details. It is also invoked internally by the `Drawing` subsystem's `PathFiller` unit, which -composites each rasterized row directly via `Surface.CompositeOverSpan` โ€” see +subsystem (`BmpCodec`, `PngCodec`, `TiffCodec`, `JpegCodec`, `GifCodec`), each of which constructs +a `Surface` when loading and reads its rows when saving โ€” see _Codecs Subsystem Design_ +(`codecs.md`) for details. It is also invoked internally by the `Drawing` subsystem's `PathFiller` +unit, which composites each rasterized row directly via `Surface.CompositeOverSpan` โ€” see _Drawing Subsystem Design_ (`drawing.md`) and _PathFiller Unit Design_ (`drawing/path-filler.md`) for details. The `Canvas` subsystem itself has no dependency on the `Codecs` subsystem or on the `Drawing` subsystem. diff --git a/docs/design/canvas-net/canvas/rgba32.md b/docs/design/canvas-net/canvas/rgba32.md index 75b1e063..19c893b8 100644 --- a/docs/design/canvas-net/canvas/rgba32.md +++ b/docs/design/canvas-net/canvas/rgba32.md @@ -4,7 +4,39 @@ The `Rgba32` struct is the pixel color type used throughout CanvasNet. Beyond it channel layout, this unit design covers the `Parse` and `TryParse` methods that construct an `Rgba32` from a hex color literal string. -#### Parse and TryParse +#### Purpose + +`Rgba32` is a plain, four-byte carrier of RGBA channel values with no additional behavior beyond +value equality and hex-string parsing. Keeping it a minimal, fixed-layout struct lets `Surface` +row buffers be reinterpreted between raw `byte` spans and `Rgba32` spans via +`System.Runtime.InteropServices.MemoryMarshal` with no copying or conversion. Additional pixel +formats (if ever needed) would be introduced as new standalone struct types rather than by +extending this one, keeping this type's memory layout stable. + +#### Data Model + +`Rgba32` is a `[StructLayout(LayoutKind.Sequential)]` struct implementing `IEquatable`, +with four public `byte` fields in declaration order (matching its sequential memory layout): + +- `R` โ€” the red channel value +- `G` โ€” the green channel value +- `B` โ€” the blue channel value +- `A` โ€” the alpha (opacity) channel value + +There are no invariants beyond the field types themselves - every combination of four `byte` +values is a valid `Rgba32`. The constructor `Rgba32(byte r, byte g, byte b, byte a)` sets all four +fields directly. + +#### Key Methods + +##### Equals(Rgba32) / Equals(object?) / GetHashCode / == / != + +Value equality compares all four channels. `GetHashCode` packs the four channels into a single +`int` (`(R << 24) | (G << 16) | (B << 8) | A`) for a fast, allocation-free hash consistent with +`Equals`, without depending on `System.HashCode` (not available on all supported target +frameworks). + +##### Parse and TryParse Two accepted forms: @@ -29,3 +61,23 @@ missing `#`, wrong length, and non-hex characters are all rejected. Both methods share a single private `TryParseCore` implementation so their accept/reject behavior is guaranteed to agree. + +#### Error Handling + +`Parse(string)` throws `ArgumentNullException` on a null argument, and `FormatException` on any +other invalid input (wrong length, missing `#`, or a non-hex character) - the message names the +accepted formats or identifies the invalid character. `TryParse(string?, out Rgba32)` never +throws: it returns `false` and sets `result` to `default` on any invalid input, including `null`. +No other member of this unit validates its input or throws. + +#### Dependencies + +`Rgba32` depends only on `System.Globalization.CultureInfo`/`NumberStyles` (used by `Parse`/ +`TryParse`'s hex-digit decoding) and `System.Runtime.InteropServices.StructLayoutAttribute` from +the .NET Base Class Library. It has no dependency on any other CanvasNet unit. + +#### Callers + +`Rgba32` is a public API entry point, used throughout CanvasNet wherever a pixel color value is +read or written: `Surface` (as the element type of its pixel spans), every `Codecs` unit, and +every `Drawing` and `Fonts`/`Rendering` unit that accepts or returns a fill color. diff --git a/docs/design/canvas-net/canvas/surface.md b/docs/design/canvas-net/canvas/surface.md index 96ade165..d5e93639 100644 --- a/docs/design/canvas-net/canvas/surface.md +++ b/docs/design/canvas-net/canvas/surface.md @@ -1,4 +1,4 @@ -## Surface +### Surface ![Canvas Structure](CanvasView.svg) @@ -7,7 +7,7 @@ The `Surface` class is the second software unit in CanvasNet. It provides a muta vectorized bulk pixel operations, forming the core pixel-storage primitive on which future drawing and codec functionality will build. -### Purpose +#### Purpose `Surface` stores pixel data for a rectangular image in a single contiguous byte array. It exposes single-pixel get/set access, row-level `Span` access (both as raw bytes and as strongly typed @@ -18,7 +18,7 @@ no I/O, and its only runtime dependency beyond the .NET base class library is `System.Numerics.Tensors` (used internally by the bulk pixel operations; see the Dependencies section below). -### Data Model +#### Data Model | Field/Property | Type | Description | | --------------- | ------------------ | -------------------------------------------------------------------- | @@ -52,7 +52,7 @@ application and test code. It is documented here, inline within the `Surface` un its own software unit, because it has no independent behavior beyond being a 4-byte data carrier consumed exclusively by `Surface`. -### Row Storage Layout +#### Row Storage Layout Internally, each row is physically padded up to a multiple of 16 pixels (64 bytes), rather than being packed at exactly `Width * 4` bytes. `_strideBytes` is computed once in the constructor as @@ -79,9 +79,9 @@ through any public accessor. `Crop` and all four codecs (`BmpCodec`, `PngCodec`, `JpegCodec`) are unaffected because they already exclusively use the public, `Width`-scoped `GetRowSpanBytes` accessor rather than assuming a flat, gap-free `Width * Height * 4` buffer. -### Key Methods +#### Key Methods -#### Surface(int width, int height) +##### Surface(int width, int height) Constructs a surface of the given size. Validates `0 < width <= MaxDimension` and `0 < height <= MaxDimension` (`MaxDimension` is a public constant equal to 8192 โ€” see the Data @@ -115,7 +115,7 @@ something is explicitly drawn into them. - `ArgumentOutOfRangeException` โ€” when `height` is less than or equal to zero, or exceeds `MaxDimension` (8192) -#### this[int x, int y] +##### this[int x, int y] Indexer providing single-pixel get/set access. Validates `y` (via `GetRowSpan`) and `x`, throwing `ArgumentOutOfRangeException` naming the offending parameter. @@ -129,7 +129,7 @@ many pixels in the same row should obtain the row span once instead of repeatedl - `ArgumentOutOfRangeException` โ€” when `x` is outside `[0, Width)` - `ArgumentOutOfRangeException` โ€” when `y` is outside `[0, Height)` -#### GetRowSpanBytes(int y) +##### GetRowSpanBytes(int y) Returns a `Span` of length `Width * 4` aliasing row `y`'s raw bytes directly over `_buffer` โ€” no data is copied, so writes through the span are immediately visible through the @@ -140,7 +140,7 @@ indexer and vice versa. The returned length is always exactly `Width * 4`, regar - `ArgumentOutOfRangeException` โ€” when `y` is outside `[0, Height)` -#### GetRowSpan(int y) +##### GetRowSpan(int y) Returns a `Span` of length `Width` by reinterpreting the result of `GetRowSpanBytes` via `MemoryMarshal.Cast`. This is a zero-copy reinterpretation, not a conversion: writes @@ -150,7 +150,7 @@ through the returned span are immediately visible through the indexer and `GetRo - `ArgumentOutOfRangeException` โ€” when `y` is outside `[0, Height)` -#### Crop(int x, int y, int width, int height) +##### Crop(int x, int y, int width, int height) Returns a new, independent `Surface` of size `width` by `height` containing a copy of the requested sub-region. Validates each argument individually: @@ -171,14 +171,14 @@ both simpler and faster than iterating pixel by pixel for contiguous row data. - `ArgumentOutOfRangeException` โ€” for any of the six invalid conditions listed above -#### PremultiplyAlpha() +##### PremultiplyAlpha() Converts this surface's pixel buffer, in place, from straight (unassociated) alpha to premultiplied alpha: for every pixel, each color channel is replaced by `round(channel * alpha / 255)` (round-half-away-from-zero, clamped to `[0, 255]`); the alpha channel is unchanged. Every possible input byte pattern is valid, so this method never throws. -#### UnpremultiplyAlpha() +##### UnpremultiplyAlpha() Converts this surface's pixel buffer, in place, from premultiplied alpha back to straight alpha โ€” the inverse of `PremultiplyAlpha`. For every pixel with non-zero alpha, each color channel is @@ -188,7 +188,7 @@ overshoot 255). For a fully transparent pixel (`alpha == 0`), no color informati recoverable; the defined result is `R = G = B = 0`, matching common raster-graphics convention. Every possible input byte pattern is valid, so this method never throws. -#### CompositeOver(Surface foreground) +##### CompositeOver(Surface foreground) Composites `foreground` "over" this surface using standard Porter-Duff "over" alpha compositing, writing the result back into this surface in place. Both surfaces are assumed to hold straight @@ -211,13 +211,13 @@ Results are converted back to bytes with round-half-away-from-zero, clamped to ` - `ArgumentException` โ€” when `foreground.Width != Width` or `foreground.Height != Height` - `ObjectDisposedException` โ€” when this surface, or `foreground`, has been disposed -#### CompositeOver(Rgba32 color) +##### CompositeOver(Rgba32 color) Composites the constant `color` "over" every pixel of this surface, using the same formula and rounding rule as `CompositeOver(Surface)`, with `color` acting as the foreground at every pixel. Every possible `Rgba32` value is valid, so this method never throws. -#### Clear(Rgba32 color) +##### Clear(Rgba32 color) Overwrites every pixel of this surface with the constant `color`, replacing rather than blending against existing pixel data. Every possible `Rgba32` value is valid, so this method @@ -247,7 +247,7 @@ one pattern buffer are ever allocated/rented regardless of surface height โ€” ev reuses the same already-built pattern via a fixed-size `CopyTo`, rather than repeating the fill/reinterleave work per row. -#### CompositeOverSpan(int y, int x, ReadOnlySpan\ coverage, Rgba32 color) +##### CompositeOverSpan(int y, int x, ReadOnlySpan\ coverage, Rgba32 color) Composites the constant `color` "over" a horizontal run of `coverage.Length` pixels within row `y`, starting at column `x`, using the same Porter-Duff "over" formula and rounding rule as @@ -292,7 +292,7 @@ blend pipeline itself. - `ArgumentOutOfRangeException` โ€” when `y` is outside `[0, Height)` - `ArgumentOutOfRangeException` โ€” when `x` is negative, or `x + coverage.Length` exceeds `Width` -#### CompositeOverSpan(int y, int x, ReadOnlySpan\ coverage, ReadOnlySpan\ colors) +##### CompositeOverSpan(int y, int x, ReadOnlySpan\ coverage, ReadOnlySpan\ colors) Composites an independent foreground color, supplied per pixel via `colors`, "over" a horizontal run of `coverage.Length` pixels within row `y`, starting at column `x`, using the same Porter-Duff @@ -330,7 +330,7 @@ no conditional-select primitive. Since `netstandard2.0` support has been dropped single, unconditional code path shared by `net8.0`, `net9.0`, and `net10.0` โ€” no `#if` target-framework gating is required. -#### Dispose() +##### Dispose() Releases the resources held by this `Surface` and implements `IDisposable`. This method is idempotent: a second (or subsequent) call has no additional effect, guarded by the `_disposed` @@ -372,7 +372,7 @@ implementation the same array could already be rented out to, and actively used returned by either method as invalid once `Dispose()` has been called on the same surface, and must not retain one across a `Dispose()` call. -### Error Handling +#### Error Handling All validation is performed at the start of the constructor, indexer, `GetRowSpanBytes`, `Crop`, `CompositeOver(Surface)`, and `CompositeOverSpan`, using `ArgumentOutOfRangeException`/ @@ -388,7 +388,7 @@ buffer โ€” the indexer (get and set), `GetRowSpanBytes`, `GetRowSpan`, `Crop`, ` `CompositeOverSpan` overloads โ€” throws `ObjectDisposedException` via a guard at entry, before any of the member's own argument validation runs. -### Dependencies +#### Dependencies `Surface` has one NuGet package dependency, `System.Numerics.Tensors` (pinned to a version compatible with `net8.0`), used both at compile time (for its `TensorPrimitives` API surface) and @@ -398,7 +398,7 @@ implemented exclusively against the .NET Base Class Library (`System.Runtime.InteropServices.MemoryMarshal` and `System.Span`), which is available natively on all of CanvasNet's target frameworks. -### Callers +#### Callers `Surface` is a public API entry point: it is invoked externally by consumers of the CanvasNet package. It is also invoked internally by all four in-house codec units (`BmpCodec`, `PngCodec`, @@ -421,7 +421,7 @@ gradient-evaluated color per pixel โ€” see _GradientPaint Unit Design_ (`../drawing/gradient-paint.md`) for details of that dependency. `Surface` itself has no dependency on any codec, on `Drawing`, or on any other unit. -### Internal workspace-reuse overload (allocation-reduction refactor) +#### Internal workspace-reuse overload (allocation-reduction refactor) `ScanlineRasterizer.Fill` calls `CompositeOverSpan` once per rasterized row, and each call would otherwise rent (and, on return, clear and release) three fresh `RowChannelBuffers`/ diff --git a/docs/design/canvas-net/codecs/bmp-codec.md b/docs/design/canvas-net/codecs/bmp-codec.md index 798e8e68..35defd4d 100644 --- a/docs/design/canvas-net/codecs/bmp-codec.md +++ b/docs/design/canvas-net/codecs/bmp-codec.md @@ -1,4 +1,4 @@ -## BmpCodec +### BmpCodec ![Codecs Structure](CodecsView.svg) @@ -7,7 +7,7 @@ dependency on another in-house unit. It provides hand-rolled loading and saving Windows BMP files (BITMAPFILEHEADER + BITMAPINFOHEADER, BI_RGB, 24-bit or 32-bit) to and from `Surface` pixel buffers. -### Purpose +#### Purpose `BmpCodec` lets callers persist a `Surface` as a BMP file (or stream) and load a BMP file (or stream) back into a `Surface`. It is implemented entirely against the .NET base class library's @@ -20,16 +20,16 @@ info-header variants (`BITMAPCOREHEADER`, `BITMAPV4HEADER`/`BITMAPV5HEADER`), an `BmpCodec` is a `static` class: BMP encoding/decoding has no instance state to carry, so a static utility shape was chosen over an object with nothing to construct or configure. -### Data Model +#### Data Model -#### BmpBitDepth enum +##### BmpBitDepth enum | Value | Numeric Value | Description | | ------- | ------------- | -------------------------------------------------------------------- | | `Bit24` | 24 | 8 bits each for blue, green, red. Alpha is dropped when saving. | | `Bit32` | 32 | 8 bits each for blue, green, red, alpha. Alpha is preserved exactly. | -#### BITMAPFILEHEADER (14 bytes, little-endian) +##### BITMAPFILEHEADER (14 bytes, little-endian) | Offset | Size | Field | Value Written | | ------ | ---- | ------------- | ---------------------------------- | @@ -39,7 +39,7 @@ utility shape was chosen over an object with nothing to construct or configure. | 8 | 2 | `bfReserved2` | 0 | | 10 | 4 | `bfOffBits` | 54 (14 + 40; offset to pixel data) | -#### BITMAPINFOHEADER (40 bytes, little-endian) โ€” the only info-header variant supported +##### BITMAPINFOHEADER (40 bytes, little-endian) โ€” the only info-header variant supported | Offset | Size | Field | Value Written | | ------ | ---- | ----------------- | -------------------------------------------- | @@ -59,9 +59,9 @@ All multi-byte header fields are read and written by explicit byte composition ( never `BitConverter` or `BinaryPrimitives`, so behavior is identical regardless of host CPU endianness. -### Key Methods +#### Key Methods -#### Load(Stream stream) +##### Load(Stream stream) Reads a BMP image from an open stream. Validates the `"BM"` signature, that `biSize == 40` (rejecting both `BITMAPCOREHEADER` and V4/V5 headers in one check), that `biCompression == 0` @@ -83,7 +83,7 @@ data. `biHeight` exceeding `Surface.MaxDimension`; the stream ends before all header or pixel data has been read -#### Load(string path) +##### Load(string path) Opens `path` as a read-only `FileStream` and delegates to `Load(Stream)`. @@ -95,7 +95,7 @@ Opens `path` as a read-only `FileStream` and delegates to `Load(Stream)`. - Underlying file-system exceptions (`FileNotFoundException`, `DirectoryNotFoundException`, `UnauthorizedAccessException`, `IOException`) propagate uncaught -#### Save(Surface surface, Stream stream, BmpBitDepth bitDepth = BmpBitDepth.Bit32) +##### Save(Surface surface, Stream stream, BmpBitDepth bitDepth = BmpBitDepth.Bit32) Writes `surface` to `stream` as an uncompressed BMP. Computes the padded row size (`(width * bytesPerPixel + 3) & ~3`) and total pixel-data size, writes the file and info headers, @@ -113,7 +113,7 @@ want a smaller, alpha-free file pass `BmpBitDepth.Bit24` explicitly. - `ArgumentNullException` โ€” `surface` or `stream` is null - `ArgumentOutOfRangeException` โ€” `bitDepth` is not a defined `BmpBitDepth` value -#### Save(Surface surface, string path, BmpBitDepth bitDepth = BmpBitDepth.Bit32) +##### Save(Surface surface, string path, BmpBitDepth bitDepth = BmpBitDepth.Bit32) Creates (or overwrites) `path` as a `FileStream` and delegates to `Save(Surface, Stream, BmpBitDepth)`. @@ -125,7 +125,7 @@ Creates (or overwrites) `path` as a `FileStream` and delegates to `Save(Surface, - Underlying file-system exceptions (`UnauthorizedAccessException`, `DirectoryNotFoundException`, `IOException`) propagate uncaught -#### GetInfo(Stream stream) +##### GetInfo(Stream stream) Reads only the 54-byte BMP header (`BITMAPFILEHEADER` + `BITMAPINFOHEADER`) and returns an `ImageInfo` describing the file, without reading any pixel data. Internally, `Load` and `GetInfo` @@ -144,7 +144,7 @@ header and 4 for a 32-bit header; `HasAlpha` is `false` for 24-bit and `true` fo ends before the 54-byte header has been fully read (same contract as `Load`, except the `Surface.MaxDimension` check is skipped) -#### GetInfo(string path) +##### GetInfo(string path) Opens `path` as a read-only `FileStream` and delegates to `GetInfo(Stream)`. @@ -155,7 +155,7 @@ Opens `path` as a read-only `FileStream` and delegates to `GetInfo(Stream)`. - `InvalidDataException` โ€” see `GetInfo(Stream)` - Underlying file-system exceptions propagate uncaught -### Error Handling +#### Error Handling All argument validation happens at the start of each public method, before any header or pixel data is read or written. `Load` performs incremental format validation as each header field is @@ -165,7 +165,7 @@ anywhere in `BmpCodec` โ€” every validation failure results in an exception that directly to the caller. `Save` never mutates the destination stream/file if an argument validation fails, because all argument checks precede any header write. -### Dependencies +#### Dependencies `BmpCodec` depends on `Surface` (constructing surfaces in `Load` and reading rows via `Surface.GetRowSpanBytes` in `Save`) โ€” this is the first documented inter-unit dependency in @@ -177,7 +177,7 @@ the .NET base class library's `System.IO` namespace (`Stream`, `FileStream`, `InvalidDataException`), available on every one of CanvasNet's target frameworks with no new runtime NuGet dependency. -### Callers +#### Callers `BmpCodec` is a public API entry point invoked directly by consumers of the CanvasNet package; it is not called by any other unit within this system. It calls into `Surface` (see _Dependencies_ diff --git a/docs/design/canvas-net/codecs/gif-codec.md b/docs/design/canvas-net/codecs/gif-codec.md index 2157d8db..c550b5d2 100644 --- a/docs/design/canvas-net/codecs/gif-codec.md +++ b/docs/design/canvas-net/codecs/gif-codec.md @@ -1,4 +1,4 @@ -## GifCodec +### GifCodec ![Codecs Structure](CodecsView.svg) @@ -6,7 +6,7 @@ The `GifCodec` class is the sixth software unit in CanvasNet's `Codecs` subsyste hand-rolled, decode-only loading of a common real-world subset of GIF (GIF87a/GIF89a) files into `Surface` pixel buffers. -### Purpose +#### Purpose `GifCodec` lets callers load a GIF file (or stream) into a `Surface`. It is implemented entirely against the .NET base class library's `System.IO` types, with no third-party GIF or imaging @@ -36,14 +36,14 @@ call that would in fact throw (see *Codecs Subsystem Design*, `../codecs.md`, fo `GifCodec` is a `static` class: GIF decoding has no instance state to carry, so a static utility shape was chosen over an object with nothing to construct or configure. -### Data Model +#### Data Model -#### Signature (6 bytes) +##### Signature (6 bytes) The first 6 bytes of a GIF file must be the ASCII string `"GIF87a"` or `"GIF89a"`; any other value is rejected. -#### Logical Screen Descriptor (7 bytes, little-endian, immediately following the signature) +##### Logical Screen Descriptor (7 bytes, little-endian, immediately following the signature) | Offset | Size | Field | Notes | | ------ | ---- | ---------------------- | ------------------------ | @@ -56,7 +56,7 @@ is rejected. The Packed Fields byte's bit 7 is the global color table flag; bits 4-6 are the color resolution (ignored); bit 3 is the sort flag (ignored); bits 0-2 are the global color table size exponent. -#### Color Table entry (3 bytes per entry โ€” used for both the Global and any Local Color Table) +##### Color Table entry (3 bytes per entry โ€” used for both the Global and any Local Color Table) | Offset | Size | Field | Notes | | ------ | ---- | ----- | ------------------------------ | @@ -66,7 +66,7 @@ The Packed Fields byte's bit 7 is the global color table flag; bits 4-6 are the A color table's entry count is `2 << (packed & 0x07)` (2 to 256 entries). -#### Graphic Control Extension data (4 bytes, following the `0x21 0xF9` introducer/label and a size byte) +##### Graphic Control Extension data (4 bytes, following the `0x21 0xF9` introducer/label and a size byte) | Offset | Size | Field | Notes | | ------ | ---- | ----------------------- | ------------------------------------- | @@ -81,7 +81,7 @@ The Packed Fields byte's bit 0 is the transparency flag; bits 1-3 are the dispos of the extension's data, not byte 1 (byte 1 is the low byte of the ignored, little-endian Delay Time field). `GifCodec` reads it from `data[3]`. -#### Image Descriptor (9 bytes, little-endian, following the `0x2C` Image Separator) +##### Image Descriptor (9 bytes, little-endian, following the `0x2C` Image Separator) | Offset | Size | Field | Notes | | ------ | ---- | ------------- | ------------------------------------------------------ | @@ -98,13 +98,13 @@ Immediately following the Image Descriptor (and any Local Color Table) is a sing LZW-minimum-code-size byte, then the frame's compressed image data as a sub-block chain (see below). -#### Sub-block chain +##### Sub-block chain A sequence of length-prefixed data blocks: each block is preceded by a 1-byte size, and a zero-length size byte terminates the chain. Used for both Extension block payloads and Image Descriptor compressed data. -#### GIF-native LZW code-table state +##### GIF-native LZW code-table state A `List` of dynamically learned code-table entries (mirroring `TiffCodec`'s `DecodeLzw` table shape), a `byte[]?` "previous entry" used for KwKwK reconstruction, and a `codeSize` that @@ -114,9 +114,9 @@ convention), capped at 12 bits. Special code values are declared relative to the `minCodeSize`: Clear code = `1 << minCodeSize`; end-of-information code = Clear code + 1; the first code eligible for table assignment is end-of-information + 1. -### Key Methods +#### Key Methods -#### Load(Stream stream) +##### Load(Stream stream) Reads a GIF image from an open stream. Calls the shared `ReadLogicalScreenDescriptor` helper (see below), validates the canvas width/height are positive and do not exceed @@ -167,7 +167,7 @@ was seen. `MaxTotalSubBlockBytes` (a 64 MiB resource-safety ceiling); the stream ending before all header, color-table, or block data has been read -#### Load(string path) +##### Load(string path) Opens `path` as a read-only `FileStream` and delegates to `Load(Stream)`. @@ -179,7 +179,7 @@ Opens `path` as a read-only `FileStream` and delegates to `Load(Stream)`. - Underlying file-system exceptions (`FileNotFoundException`, `DirectoryNotFoundException`, `UnauthorizedAccessException`, `IOException`) propagate uncaught -#### GetInfo(Stream stream) +##### GetInfo(Stream stream) Calls the shared `ReadLogicalScreenDescriptor` helper, validates the declared width/height are positive (the identical non-positive check `Load` performs, but without `Load`'s additional @@ -261,7 +261,7 @@ present in the input stream. a well-formed container whose first frame's compressed data merely fails to LZW-decode is reported via `CanDecode == false` instead of throwing (see the remarks above). -#### GetInfo(string path) +##### GetInfo(string path) Opens `path` as a read-only `FileStream` and delegates to `GetInfo(Stream)`. @@ -272,7 +272,7 @@ Opens `path` as a read-only `FileStream` and delegates to `GetInfo(Stream)`. - `InvalidDataException` โ€” see `GetInfo(Stream)` - Underlying file-system exceptions propagate uncaught -#### ReadLogicalScreenDescriptor(Stream stream) โ€” shared, private +##### ReadLogicalScreenDescriptor(Stream stream) โ€” shared, private Reads and validates the 6-byte signature and 7-byte Logical Screen Descriptor, returning the decoded width, height, and packed byte. Called identically, as the literal first statement, by @@ -280,7 +280,7 @@ both `GetInfo(Stream)` and `Load(Stream)` โ€” the same parity guarantee document four raster codecs' shared header helpers (see *Codecs Subsystem Design*, `../codecs.md`'s Header-Only Probing section). -#### ReadImageDescriptorHeader(Stream stream) โ€” shared, private +##### ReadImageDescriptorHeader(Stream stream) โ€” shared, private Reads a single Image Descriptor's 9-byte fixed header, any Local Color Table, and the LZW minimum code size byte (with its 2-8 range check), without reading the descriptor's compressed image data @@ -288,7 +288,7 @@ sub-block chain. Called identically by `Load(Stream)`'s `ImageSeparator` case an so both walk an Image Descriptor's fixed-size fields in exactly the same order and with exactly the same validation. -#### ValidateFrameRegionAndResolveColorTable(...) โ€” shared, private +##### ValidateFrameRegionAndResolveColorTable(...) โ€” shared, private Validates that an Image Descriptor's declared size is positive and its placement lies entirely within the logical screen, then resolves its active color table (its own Local Color Table if @@ -297,7 +297,7 @@ identically by `Load(Stream)`'s `ImageSeparator` case and `CountFrames`, so both same per-frame structural validation โ€” not merely to the first frame, whose pixel data is the only one either method ever actually decodes or counts pixels for. -#### CountFrames(Stream stream, int canvasWidth, int canvasHeight, Rgba32[]? globalColorTable) โ€” shared, private +##### CountFrames(Stream stream, int canvasWidth, int canvasHeight, Rgba32[]? globalColorTable) โ€” shared, private Walks a GIF file's blocks, from the current stream position (immediately after any Global Color Table) through and including the Trailer, counting every Image Descriptor encountered, and @@ -317,7 +317,7 @@ accepted โ€” see `GetInfo(Stream)`'s remarks for the full design rationale. Thro Trailer is reached having counted zero Image Descriptors, matching `Load`'s identical "GIF stream contains no Image Descriptor" rejection. -### Error Handling +#### Error Handling All argument validation happens at the start of each public method, before any header, color table, or block data is read. `Load` performs incremental format validation as each block is @@ -325,7 +325,7 @@ parsed, failing at the first invalid field or block with a message naming the ac value or byte found. There is no local recovery or retry logic anywhere in `GifCodec` โ€” every validation failure results in an exception that propagates directly to the caller. -### Dependencies +#### Dependencies `GifCodec` depends on `Surface` (constructing a canvas-sized surface and writing rows via `Surface.GetRowSpan` in `Load`) and the `Codecs` subsystem's shared `ImageInfo` record struct (the @@ -334,7 +334,7 @@ return type of `GetInfo`) โ€” see *Codecs Subsystem Design* (`../codecs.md`). Be `FileStream`, `MemoryStream`, `InvalidDataException`), available on every one of CanvasNet's target frameworks with no new runtime NuGet dependency. -### Callers +#### Callers `GifCodec` is a public API entry point invoked directly by consumers of the CanvasNet package; it is not called by any other unit within this system. It calls into `Surface` (see *Dependencies* diff --git a/docs/design/canvas-net/codecs/jpeg-codec.md b/docs/design/canvas-net/codecs/jpeg-codec.md index 48508864..b77bd51b 100644 --- a/docs/design/canvas-net/codecs/jpeg-codec.md +++ b/docs/design/canvas-net/codecs/jpeg-codec.md @@ -1,4 +1,4 @@ -## JpegCodec +### JpegCodec ![Codecs Structure](CodecsView.svg) @@ -7,7 +7,7 @@ The `JpegCodec` class is the sixth software unit in CanvasNet, and depends on `S common real-world subset of JPEG (ITU-T T.81 / ISO/IEC 10918-1) files to and from `Surface` pixel buffers. -### Purpose +#### Purpose `JpegCodec` lets callers persist a `Surface` as a JPEG file (or stream) and load a JPEG file (or stream) back into a `Surface`. It is implemented entirely against the .NET base class library, with @@ -25,9 +25,9 @@ incorrect pixels. utility shape was chosen over an object with nothing to construct or configure, matching `BmpCodec`, `PngCodec`, and `TiffCodec`. -### Data Model +#### Data Model -#### JPEG marker/segment stream +##### JPEG marker/segment stream | Marker | Name | Written by `Save` | Consumed by `Load` | Notes | | ------ | ---- | ----------------- | ------------------ | ----- | @@ -45,7 +45,7 @@ APPn/COM and other unknown length-prefixed segments are skipped on load. `Save` not emit an APP0/JFIF segment because the codec's decoder does not depend on it and the ITU-T T.81 base JPEG syntax does not require it. -#### SOF and SOS payload fields +##### SOF and SOS payload fields | Segment | Field | Description | | ------- | ----- | ----------- | @@ -60,7 +60,7 @@ base JPEG syntax does not require it. For decoding, the component sampling factors determine each component's MCU-grid block dimensions, chroma upsampling ratio, and plane size before conversion back to RGB. -#### Huffman table construction +##### Huffman table construction Each DHT segment supplies 16 code-length counts followed by a flat symbol list. `JpegCodec` builds canonical Huffman tables from that data using the `minCode`/`maxCode`/`valPtr` structure described @@ -70,7 +70,7 @@ DC/AC tables. No implicit โ€œstandardโ€ table is assumed to exist in the input `Save` uses the ITU-T Annex K default luminance/chrominance DC and AC Huffman tables. Those tables are serialized into the output DHT segments and then reused by the encoder's entropy writer. -#### Quantization tables and zigzag order +##### Quantization tables and zigzag order JPEG coefficients are stored in zigzag order rather than natural 8x8 row-major order. `JpegCodec` maintains the standard 64-entry zigzag mapping (ITU-T T.81 Figure A.6) so that: @@ -83,9 +83,9 @@ maintains the standard 64-entry zigzag mapping (ITU-T T.81 Figure A.6) so that: The encoder starts from the ITU-T Annex K example luminance and chrominance quantization tables, then applies the usual libjpeg-style quality scaling formula for caller-supplied qualities 1-100. -### Key Methods +#### Key Methods -#### Load(Stream stream) +##### Load(Stream stream) Reads a JPEG image from an open stream by buffering the remaining bytes into memory, validating the SOI marker, parsing DQT/DHT/DRI/SOF/SOS segments, and decoding one or more baseline or progressive @@ -111,7 +111,7 @@ assuming any implicit standard tables are present. without a SOF0/SOF2 marker ever being found โ€” the same two ceilings `GetInfo` enforces, described further below -#### Load(string path) +##### Load(string path) Opens `path` as a read-only `FileStream` and delegates to `Load(Stream)`. @@ -127,7 +127,7 @@ path. - Underlying file-system exceptions (`FileNotFoundException`, `DirectoryNotFoundException`, `UnauthorizedAccessException`, `IOException`) propagate uncaught -#### Save(Surface surface, Stream stream, int quality = 90) +##### Save(Surface surface, Stream stream, int quality = 90) Writes `surface` to `stream` as a baseline (SOF0), 3-component YCbCr, 4:2:0 chroma-subsampled JPEG with the requested quality. The encoder converts RGB to YCbCr, pads partial MCUs by edge @@ -147,7 +147,7 @@ by the libjpeg-style quality formula; it never writes progressive scans or APP0/ - `ArgumentNullException` โ€” `surface` or `stream` is null - `ArgumentOutOfRangeException` โ€” `quality` is less than 1 or greater than 100 -#### Save(Surface surface, string path, int quality = 90) +##### Save(Surface surface, string path, int quality = 90) Creates or overwrites `path` as a `FileStream` and delegates to `Save(Surface, Stream, int)`. @@ -163,7 +163,7 @@ stay synchronized across both public entry points. - Underlying file-system exceptions (`UnauthorizedAccessException`, `DirectoryNotFoundException`, `IOException`) propagate uncaught -#### GetInfo(Stream stream) +##### GetInfo(Stream stream) Reports a JPEG's width, height, and component count without ever entropy-decoding scan data (and therefore without requiring an SOS segment, restart markers, or any entropy-coded bytes to be @@ -304,7 +304,7 @@ given byte sequence produces. marker ever being found (same contract as `Load`, except the `Surface.MaxDimension` check is skipped and entropy-coded scan data is never required or read) -#### GetInfo(string path) +##### GetInfo(string path) Opens `path` as a read-only `FileStream` and delegates to `GetInfo(Stream)`. @@ -315,7 +315,7 @@ Opens `path` as a read-only `FileStream` and delegates to `GetInfo(Stream)`. - `InvalidDataException` โ€” see `GetInfo(Stream)` - Underlying file-system exceptions propagate uncaught -### Error Handling +#### Error Handling All argument validation happens at the start of each public method, before any image data is read or written. `Load` validates the marker stream incrementally, failing immediately for unsupported @@ -325,7 +325,7 @@ validation failure results in an exception that propagates directly to the calle `surface`, `stream`/`path`, and `quality` before the encoder writes any bytes, so an invalid call fails fast without producing a partial JPEG. -### Dependencies +#### Dependencies `JpegCodec` depends on `Surface` (constructing surfaces in `Load` and reading rows via `Surface.GetRowSpanBytes` in `Save`), using only `Surface`'s existing public API exactly as @@ -337,7 +337,7 @@ library's `System.IO` namespace (`Stream`, `FileStream`, `MemoryStream`, `InvalidDataException`) and `System.Numerics.Vector` for optional vector acceleration in the IDCT/FDCT dot-product and YCbCr-to-RGB hot paths. No new runtime NuGet package is introduced. -### Conformance Testing +#### Conformance Testing In addition to hand-built malformed-stream tests and synthetic round-trip checks, `JpegCodec` is validated against the `JpegFixtures` corpus (see `test/DemaConsulting.CanvasNet.Tests/JpegFixtures/README.md`): @@ -351,7 +351,7 @@ loading, correct dimensions, and exact `R == G == B` expansion. The in-memory ro `JpegCodecTests.cs` additionally enforce a 15-per-channel tolerance on synthetic quality-90 save then load scenarios. -### Callers +#### Callers `JpegCodec` is a public API entry point invoked directly by consumers of the CanvasNet package; it is not called by any other unit within this system. It calls into `Surface` (see *Dependencies* diff --git a/docs/design/canvas-net/codecs/png-codec.md b/docs/design/canvas-net/codecs/png-codec.md index b2c5a40e..edf05e2d 100644 --- a/docs/design/canvas-net/codecs/png-codec.md +++ b/docs/design/canvas-net/codecs/png-codec.md @@ -1,4 +1,4 @@ -## PngCodec +### PngCodec ![Codecs Structure](CodecsView.svg) @@ -8,7 +8,7 @@ Truecolor or Truecolor-with-alpha, non-interlaced) and hand-rolled loading of ev non-interlaced, spec-valid PNG color type/bit depth combination, to and from `Surface` pixel buffers. -### Purpose +#### Purpose `PngCodec` lets callers persist a `Surface` as a PNG file (or stream) and load a PNG file (or stream) back into a `Surface`. It is implemented entirely against the .NET base class library's @@ -42,22 +42,22 @@ this case before ever calling `Load`. utility shape was chosen over an object with nothing to construct or configure, matching `BmpCodec`'s precedent. -### Data Model +#### Data Model -#### PngColorType enum +##### PngColorType enum | Value | Numeric Value | Description | | ------ | ------------- | -------------------------------------------------------------------- | | `Rgb` | 2 | 8 bits each for red, green, blue. Alpha is dropped when saving. | | `Rgba` | 6 | 8 bits each for red, green, blue, alpha. Alpha is preserved exactly. | -#### PNG signature (8 bytes) +##### PNG signature (8 bytes) | Byte Offset | Value | | ----------- | -------------------------- | | 0-7 | `137 80 78 71 13 10 26 10` | -#### Chunk layout (all multi-byte fields big-endian) +##### Chunk layout (all multi-byte fields big-endian) | Field | Size | Description | | ------ | -------------- | --------------------------------------------------------- | @@ -66,7 +66,7 @@ utility shape was chosen over an object with nothing to construct or configure, | Data | `Length` bytes | The chunk's payload | | CRC-32 | 4 | CRC-32 of `Type` + `Data` (not `Length`) | -#### IHDR chunk (13 bytes) +##### IHDR chunk (13 bytes) | Offset | Size | Field | Value Written (Save) | | ------ | ---- | -------------------- | ---------------------------------------- | @@ -100,7 +100,7 @@ capability): | 4 (Grayscale+alpha) | 8, 16 | | 6 (Truecolor+alpha) | 8, 16 | -#### Zlib wrapper layout (the `IDAT` payload, concatenated across chunks) +##### Zlib wrapper layout (the `IDAT` payload, concatenated across chunks) | Field | Size | Description | | ---------------- | ------------ | ---------------------------------------------------------------------------------- | @@ -108,7 +108,7 @@ capability): | DEFLATE data | variable | Compressed scanline bytes, via `System.IO.Compression.DeflateStream` | | Adler-32 trailer | 4 bytes (BE) | Checksum of the decompressed scanline bytes, hand-computed | -#### Scanline filter types +##### Scanline filter types | Type | Name | Reconstruction (raw byte = filtered byte + ...) | | ---- | ------- | --------------------------------------------------------------- | @@ -132,7 +132,7 @@ All multi-byte PNG fields (chunk length, CRC-32, IHDR width/height, Adler-32 tra and written by explicit byte composition (bit shifting), never `BitConverter` or `BinaryPrimitives`, so behavior is identical regardless of host CPU endianness. -#### PLTE and tRNS chunks (Load only; never written by Save) +##### PLTE and tRNS chunks (Load only; never written by Save) | Chunk | Required when | Payload shape | | ------ | ----------------------------------- | ----------------------------------------------- | @@ -220,9 +220,9 @@ concatenating the later `IDAT` chunk's bytes anyway would risk assembling a corr stream. A payload split across any number of directly consecutive `IDAT` chunks (the common case for streaming encoders) remains fully supported and unaffected by this check. -### Key Methods +#### Key Methods -#### Load(Stream stream) +##### Load(Stream stream) Reads a PNG image from an open stream. Validates the 8-byte PNG signature, then reads chunks until `IEND` is found: each chunk's CRC-32 is validated regardless of type; `IHDR` is parsed and @@ -308,7 +308,7 @@ rationale, including why it derives from `IOException` rather than `InvalidDataE `"png-adam7-interlace"`); this is a distinct type from `InvalidDataException` โ€” see the design decision above -#### Load(string path) +##### Load(string path) Opens `path` as a read-only `FileStream` and delegates to `Load(Stream)`. @@ -320,7 +320,7 @@ Opens `path` as a read-only `FileStream` and delegates to `Load(Stream)`. * Underlying file-system exceptions (`FileNotFoundException`, `DirectoryNotFoundException`, `UnauthorizedAccessException`, `IOException`) propagate uncaught -#### Save(Surface surface, Stream stream, PngColorType colorType = PngColorType.Rgba) +##### Save(Surface surface, Stream stream, PngColorType colorType = PngColorType.Rgba) Writes `surface` to `stream` as a PNG image. Writes the signature and `IHDR` chunk, then builds a single in-memory buffer containing every scanline (a filter-type byte of 0, followed by the @@ -346,7 +346,7 @@ a smaller, alpha-free file pass `PngColorType.Rgb` explicitly. * `ArgumentNullException` โ€” `surface` or `stream` is null * `ArgumentOutOfRangeException` โ€” `colorType` is not a defined `PngColorType` value -#### Save(Surface surface, string path, PngColorType colorType = PngColorType.Rgba) +##### Save(Surface surface, string path, PngColorType colorType = PngColorType.Rgba) Creates (or overwrites) `path` as a `FileStream` and delegates to `Save(Surface, Stream, PngColorType)`. @@ -358,7 +358,7 @@ Creates (or overwrites) `path` as a `FileStream` and delegates to `Save(Surface, * Underlying file-system exceptions (`UnauthorizedAccessException`, `DirectoryNotFoundException`, `IOException`) propagate uncaught -#### GetInfo(Stream stream) +##### GetInfo(Stream stream) Reads only the 8-byte PNG signature and the first (`IHDR`) chunk โ€” never any subsequent chunk, and in particular never any `IDAT` chunk โ€” and returns an `ImageInfo` describing the file. `Load` @@ -525,7 +525,7 @@ fully-resolved 4-channel `Surface` regardless of source color type. contract as `Load`, except the `Surface.MaxDimension` check is skipped and Adam7 interlacing is not rejected โ€” instead reported via the returned `ImageInfo.CanDecode = false`) -#### GetInfo(string path) +##### GetInfo(string path) Opens `path` as a read-only `FileStream` and delegates to `GetInfo(Stream)`. @@ -536,7 +536,7 @@ Opens `path` as a read-only `FileStream` and delegates to `GetInfo(Stream)`. * `InvalidDataException` โ€” see `GetInfo(Stream)` * Underlying file-system exceptions propagate uncaught -### Design Decisions +#### Design Decisions **Sub-byte sample scaling**: for Grayscale bit depths 1, 2, and 4, each sample is scaled to the full 0-255 range as `sample * 255 / ((1 << bitDepth) - 1)` (for example a 4-bit sample of 15 @@ -553,7 +553,7 @@ sample occupies `bitDepth` bits within its row, packed most-significant-bit-firs each byte's high bit, with the final byte of a row zero-padded if `width * bitDepth` is not a multiple of 8. This padding is discarded, never written to any pixel. -### Error Handling +#### Error Handling All argument validation happens at the start of each public method, before any header or pixel data is read or written. `Load` performs incremental format validation as each chunk is read, @@ -564,7 +564,7 @@ the actual invalid value found. There is no local recovery or retry logic anywhe never mutates the destination stream/file if an argument validation fails, because all argument checks precede any byte write. -### Dependencies +#### Dependencies `PngCodec` depends on `Surface` (constructing surfaces in `Load` and reading/writing rows via `Surface.GetRowSpanBytes` in `Save`), using only `Surface`'s existing public API exactly as @@ -579,7 +579,7 @@ every one of CanvasNet's target frameworks with no new runtime NuGet dependency) wrapper (2-byte header, Adler-32 trailer) and every PNG chunk's CRC-32 are computed by hand-rolled algorithms rather than any third-party library. -### Conformance Testing +#### Conformance Testing In addition to the hand-built positive/negative unit tests above, `PngCodec` is validated against the industry-standard [PngSuite](http://www.schaik.com/pngsuite/) conformance corpus (Willem van @@ -598,7 +598,7 @@ and report correct dimensions via `GetInfo`, exactly like the Adam7-interlaced f `CanvasNet-Codecs-PngCodec-PngSuiteUnsupported`, `CanvasNet-Codecs-PngCodec-PngSuiteCorrupt`, and `CanvasNet-Codecs-PngCodec-PngSuiteCorruptAfterIhdr` for the corresponding requirements. -### Callers +#### Callers `PngCodec` is a public API entry point invoked directly by consumers of the CanvasNet package; it is not called by any other unit within this system. It calls into `Surface` (see _Dependencies_ diff --git a/docs/design/canvas-net/codecs/tiff-codec.md b/docs/design/canvas-net/codecs/tiff-codec.md index 4cad2869..8512784d 100644 --- a/docs/design/canvas-net/codecs/tiff-codec.md +++ b/docs/design/canvas-net/codecs/tiff-codec.md @@ -1,4 +1,4 @@ -## TiffCodec +### TiffCodec ![Codecs Structure](CodecsView.svg) @@ -7,7 +7,7 @@ The `TiffCodec` class is the fifth software unit in CanvasNet, and depends on `S TIFF 6.0 files (8-bit-per-sample RGB, RGBA, and Grayscale, Chunky planar configuration, strip-based layout) to and from `Surface` pixel buffers. -### Purpose +#### Purpose `TiffCodec` lets callers persist a `Surface` as a TIFF file (or stream) and load a TIFF file (or stream) back into a `Surface`. It is implemented entirely against the .NET base class library's @@ -25,9 +25,9 @@ silently producing incorrect pixels. static utility shape was chosen over an object with nothing to construct or configure, matching `BmpCodec`'s and `PngCodec`'s precedent. -### Data Model +#### Data Model -#### TiffCompression enum +##### TiffCompression enum | Value | Numeric Value | Description | | ---------- | ------------- | ------------------------------------------------------------- | @@ -36,7 +36,7 @@ static utility shape was chosen over an object with nothing to construct or conf | `Deflate` | 8 | zlib-wrapped DEFLATE, matching `PngCodec`'s own IDAT wrapper. | | `PackBits` | 32773 | Apple/TIFF PackBits run-length encoding (TIFF 6.0 Section 9). | -#### TIFF header (8 bytes) +##### TIFF header (8 bytes) | Offset | Size | Field | Value | | ------ | ---- | ---------------- | --------------------------------------------------- | @@ -44,7 +44,7 @@ static utility shape was chosen over an object with nothing to construct or conf | 2 | 2 | Magic number | 42 | | 4 | 4 | First IFD offset | Byte offset of the (only) Image File Directory read | -#### IFD layout +##### IFD layout | Field | Size | Description | | --------------- | ------------------ | -------------------------------------------------- | @@ -59,7 +59,7 @@ value/offset, and every value read from an external location) are read and writt byte composition using the byte order determined from the file's own byte-order mark, never `BitConverter` or `BinaryPrimitives`, exactly as `BmpCodec`/`PngCodec` do internally. -#### Untrusted-input hardening in tag/value resolution +##### Untrusted-input hardening in tag/value resolution `ReadTagValues` (the single method that turns an `IfdEntry` into a `uint[]` of resolved values, used by both `Load` and `GetInfo`) applies three checks before ever allocating the @@ -88,7 +88,7 @@ without risking a denial-of-service from a crafted file: legitimately scale with image size) are read only by `Load`'s strip-decoding path and are not subject to this cap. -#### Recognized TIFF tags +##### Recognized TIFF tags | Tag Number | Name | Required | Notes | | ---------- | ------------------------- | -------- | -------------------------------------------------- | @@ -111,14 +111,14 @@ The tiled-TIFF rejection (`TileWidth`/`TileLength` presence) lives in the shared `ReadTiffImageInfo` helper, so `Load` and `GetInfo` reject a tiled image identically rather than only when `Load` is used. -#### Horizontal-differencing predictor (Predictor tag = 2) +##### Horizontal-differencing predictor (Predictor tag = 2) For each row, independently per sample plane: `raw[i] = decoded[i] - decoded[i - samplesPerPixel]` for `i >= samplesPerPixel`, with the first `samplesPerPixel` bytes of the row left unchanged. Decoding reverses this with a running sum. The predictor never carries across row boundaries and is applied strictly after decompression (on load) or strictly before compression (on save). -#### Zlib wrapper layout (used only for `TiffCompression.Deflate`) +##### Zlib wrapper layout (used only for `TiffCompression.Deflate`) | Field | Size | Description | | ---------------- | ------------ | ----------------------------------------------------------------- | @@ -130,9 +130,9 @@ This is structurally identical to `PngCodec`'s own `IDAT` zlib wrapper, but reim privately within `TiffCodec` (in `Tiff/TiffCodec.Utils.cs`) rather than shared, since no unit in CanvasNet currently shares internal helper code between codecs. -### Key Methods +#### Key Methods -#### Load(Stream stream) +##### Load(Stream stream) Reads a TIFF image from an open stream (buffered entirely into memory first, since TIFF's IFD and value-array offsets require random access, unlike `BmpCodec`'s and `PngCodec`'s purely sequential @@ -170,7 +170,7 @@ if `Predictor` is 2, and unpacks each row into the destination `Surface`'s rows does not cover the declared image height; or the stream ends before all header, IFD, or strip data has been read -#### Load(string path) +##### Load(string path) Opens `path` as a read-only `FileStream` and delegates to `Load(Stream)`. @@ -182,7 +182,7 @@ Opens `path` as a read-only `FileStream` and delegates to `Load(Stream)`. - Underlying file-system exceptions (`FileNotFoundException`, `DirectoryNotFoundException`, `UnauthorizedAccessException`, `IOException`) propagate uncaught -#### Save(Surface surface, Stream stream, TiffCompression compression = TiffCompression.None) +##### Save(Surface surface, Stream stream, TiffCompression compression = TiffCompression.None) Writes `surface` to `stream` as a little-endian ("II") TIFF file containing a single IFD describing an 8-bit-per-sample, 4-samples-per-pixel (RGBA) RGB image (photometric interpretation @@ -208,7 +208,7 @@ fidelity with no extra effort. - `ArgumentNullException` โ€” `surface` or `stream` is null - `ArgumentOutOfRangeException` โ€” `compression` is not a defined `TiffCompression` value -#### Save(Surface surface, string path, TiffCompression compression = TiffCompression.None) +##### Save(Surface surface, string path, TiffCompression compression = TiffCompression.None) Creates (or overwrites) `path` as a `FileStream` and delegates to `Save(Surface, Stream, TiffCompression)`. @@ -221,7 +221,7 @@ Creates (or overwrites) `path` as a `FileStream` and delegates to - Underlying file-system exceptions (`UnauthorizedAccessException`, `DirectoryNotFoundException`, `IOException`) propagate uncaught -#### GetInfo(Stream stream) +##### GetInfo(Stream stream) Reports a TIFF's width, height, samples-per-pixel-derived channel count, and alpha presence without ever decoding strip/pixel data, by resolving every tag through the exact same validating @@ -296,7 +296,7 @@ count, identically to `Load`. `GetInfo` does not enforce `Surface.MaxDimension` **except** that the `Surface.MaxDimension` check is always skipped (an oversized declared width/height is returned, not rejected) -#### GetInfo(string path) +##### GetInfo(string path) Opens `path` as a read-only `FileStream` and delegates to `GetInfo(Stream)`. Note that a `FileStream` is always seekable, so opening by path always exercises `GetInfo`'s seek-based fast @@ -309,7 +309,7 @@ path rather than its non-seekable buffering fallback. - `InvalidDataException` โ€” see `GetInfo(Stream)` - Underlying file-system exceptions propagate uncaught -### Error Handling +#### Error Handling All argument validation happens at the start of each public method, before any header or pixel data is read or written. `Load` performs incremental format validation as each tag is inspected, @@ -321,7 +321,7 @@ anywhere in `TiffCodec` - every validation failure results in an exception that directly to the caller. `Save` never mutates the destination stream/file if argument validation fails, because all argument checks precede any byte write. -### Dependencies +#### Dependencies `TiffCodec` depends on `Surface` (constructing surfaces in `Load` and reading/writing rows via `Surface.GetRowSpanBytes` in `Save`), using only `Surface`'s existing public API exactly as @@ -334,7 +334,7 @@ every one of CanvasNet's target frameworks with no new runtime NuGet dependency) TIFF-flavor LZW, the horizontal-differencing predictor, and the zlib wrapper (2-byte header, Adler-32 trailer) are all computed by hand-rolled algorithms rather than any third-party library. -### Conformance Testing +#### Conformance Testing In addition to the hand-built positive/negative unit tests above, `TiffCodec` is validated against the `TiffFixtures` corpus (see `test/DemaConsulting.CanvasNet.Tests/TiffFixtures/README.md`): nine @@ -347,7 +347,7 @@ they have no alpha channel); the grayscale fixtures (a lossy conversion relative source) are only checked for successful loading, correct dimensions, and R == G == B per pixel. See `CanvasNet-Codecs-TiffCodec-FixtureSupported` for the corresponding requirement. -### Callers +#### Callers `TiffCodec` is a public API entry point invoked directly by consumers of the CanvasNet package; it is not called by any other unit within this system. It calls into `Surface` (see *Dependencies* diff --git a/docs/design/canvas-net/drawing.md b/docs/design/canvas-net/drawing.md index 673b036e..23814a97 100644 --- a/docs/design/canvas-net/drawing.md +++ b/docs/design/canvas-net/drawing.md @@ -2,22 +2,27 @@ ![Drawing Structure](DrawingView.svg) -The `Drawing` subsystem is the fourth software subsystem in CanvasNet. It provides solid-color -vector rendering for `Geometry.Path` geometry through two public entry points: -direct path filling (`PathFiller`) and stroke-to-fill conversion (`PathStroker`). It consumes +The `Drawing` subsystem is the fourth software subsystem in CanvasNet. It provides solid-color, +gradient, and tile-pattern vector rendering for `Geometry.Path` geometry through three public +entry points: direct path filling (`PathFiller`) and stroke-to-fill conversion (`PathStroker`), +backed by two paint-description sibling units (`GradientPaint` and `TilePaint`). It consumes both the `Canvas` subsystem's `Surface` unit and the `Geometry` subsystem's vector primitives. ### Purpose The `Drawing` subsystem groups the software units responsible for turning vector geometry into -rendered pixels on a `Canvas.Surface`. It currently contains three sibling units: -`PathFiller`, which fills a closed `Geometry.Path` with a single solid color, or with a linear or -radial gradient paint, using an antialiased signed-area/coverage-accumulation scanline algorithm -supporting both nonzero and even-odd fill rules; `PathStroker`, which converts a path stroke -(configured by width, cap, join, miter limit, and optional dashing) into closed outline geometry -that `PathFiller` can render unchanged; and `GradientPaint`, the public +rendered pixels on a `Canvas.Surface`. It currently contains four sibling units: +`PathFiller`, which fills a closed `Geometry.Path` with a single solid color, a linear or +radial gradient paint, or a repeating tile paint, using an antialiased signed-area/coverage- +accumulation scanline algorithm supporting both nonzero and even-odd fill rules; `PathStroker`, +which converts a path stroke (configured by width, cap, join, miter limit, and optional dashing) +into closed outline geometry that `PathFiller` can render unchanged; `GradientPaint`, the public `Gradient`/`LinearGradient`/`RadialGradient`/`GradientStop`/`GradientSpread` types and internal -`GradientEvaluator` helper that `PathFiller`'s gradient overload evaluates per pixel. `Geometry` +`GradientEvaluator` helper that `PathFiller`'s gradient overload evaluates per pixel; and +`TilePaint`, a public type pairing a pre-rendered one-cell tile `Surface` with a pattern-space +transform and `XStep`/`YStep` pitch, together with the internal `TilePaintEvaluator` helper that +`PathFiller`'s tile overload samples per pixel (wrapping around the tile's pitch), analogous to +how `GradientEvaluator` backs the gradient overload. `Geometry` is deliberately distinct from `Drawing`: `Geometry` describes shape geometry (paths, bounds, curve math) with no notion of pixels, color, or rasterization; `Drawing` is where that geometry is turned into pixels. `Drawing` depends on both `Canvas` @@ -30,8 +35,8 @@ font-specific logic. ### Units - **PathFiller** โ€” a public static `Fill` entry point that flattens a `Path`'s subpaths into - polygons and rasterizes them onto a `Surface` with a solid color or a `GradientPaint` gradient, - together with the supporting + polygons and rasterizes them onto a `Surface` with a solid color, a `GradientPaint` gradient, or + a `TilePaint` tile, together with the supporting `FillRule` enum (nonzero/even-odd fill-rule selection) and the internal `EdgeFlattener`/`ScanlineRasterizer` helpers, all documented inline within the `PathFiller` unit design rather than as their own units, because none of them has any independent behavior @@ -46,6 +51,14 @@ font-specific logic. internal `GradientEvaluator` helper that resolves a gradient's color at a point or across a pixel row, consumed by `PathFiller`'s gradient overload; see _GradientPaint Unit Design_ (`drawing/gradient-paint.md`) +- **TilePaint** โ€” a public sealed type pairing a pre-rendered one-cell tile `Surface`, a + `Transform` mapping the tile's own pattern-space coordinates into the caller's path coordinate + space, and a non-zero, finite `XStep`/`YStep` pattern-space pitch (either of which may be + negative, per PDF 32000-1 ยง8.7.3.1's tiling-pattern semantics), together with the internal + `TilePaintEvaluator` helper that resolves a wrapped-around tile sample at a point or across a + pixel row, consumed by `PathFiller`'s tile overload; `TilePaint` does not own the tile + `Surface`'s lifetime - the caller that constructed it remains responsible for disposal; see + _TilePaint Unit Design_ (`drawing/tile-paint.md`) ### Dependencies @@ -57,20 +70,26 @@ reference was introduced. Within the subsystem, `PathFiller.Fill` depends on the `EdgeFlattener` (to convert a `Path`'s subpaths to closed polygons, itself depending on `Geometry.BezierFlattening` and `Geometry.SvgArcConverter` to flatten curves and arcs), the internal `ScanlineRasterizer` (to rasterize those polygons into per-row antialiased coverage and -composite each row via `Canvas.Surface.CompositeOverSpan`), and - for its gradient overload - the -public `Gradient` type and internal `GradientEvaluator` helper. `PathStroker.Stroke` depends on the +composite each row via `Canvas.Surface.CompositeOverSpan`), and - for its gradient/tile overloads + +- the public `Gradient`/`TilePaint` types and their internal `GradientEvaluator`/ +`TilePaintEvaluator` helpers. `PathStroker.Stroke` depends on the internal `StrokePathFlattener` (to flatten each subpath while preserving `Subpath.IsClosed`), `DashSplitter` (to apply dash-array and dash-offset semantics), and `StrokeOutliner` (to offset segments and resolve joins/caps into closed polygons), plus `Geometry.PathBuilder` to assemble the -returned outline path. +returned outline path. `TilePaint` itself depends only on `Canvas.Surface` (the tile bitmap it +wraps) and `System.Numerics.Matrix3x2`; it has no dependency on `PathFiller`, `PathStroker`, or +`GradientPaint`. ### Callers -`PathFiller.Fill` and `PathStroker.Stroke` are public API entry points, invoked externally by -consumers of the CanvasNet package. `PathFiller` is exercised end to end by this subsystem's own -unit tests and by a system-integration test that builds a `Path` via `PathBuilder` and fills it -onto a `Surface` (see `CanvasNetTests.cs`). `PathStroker` is exercised end to end by its own unit -tests, which stroke a path, fill the returned outline path, and inspect the resulting pixels. The +`PathFiller.Fill`, `PathStroker.Stroke`, and `TilePaint`'s constructor are public API entry +points, invoked externally by consumers of the CanvasNet package. `PathFiller` is exercised end to +end by this subsystem's own unit tests and by a system-integration test that builds a `Path` via +`PathBuilder` and fills it onto a `Surface` (see `CanvasNetTests.cs`). `PathStroker` is exercised +end to end by its own unit tests, which stroke a path, fill the returned outline path, and inspect +the resulting pixels. `TilePaint` is constructed and consumed by `PathFiller`'s tile overload, and +is exercised end to end by `PathFiller`'s own unit tests. The `Drawing` subsystem itself has no dependency on `Codecs`; it depends on `Canvas` and `Geometry` as described above. diff --git a/docs/design/canvas-net/drawing/gradient-paint.md b/docs/design/canvas-net/drawing/gradient-paint.md index de87d5b6..507108a1 100644 --- a/docs/design/canvas-net/drawing/gradient-paint.md +++ b/docs/design/canvas-net/drawing/gradient-paint.md @@ -1,6 +1,6 @@ -## GradientPaint +### GradientPaint ![Drawing Structure](DrawingView.svg) @@ -11,7 +11,7 @@ has no independent externally visible behavior of its own: it exists to be consu `PathFiller`'s gradient `Fill(Surface, Path, Gradient, FillRule, float)` overload (see _PathFiller Unit Design_, `path-filler.md`). -### Purpose +#### Purpose `GradientPaint` lets a caller paint a filled path with a smoothly (or sharply) varying color ramp instead of a single solid color - the standard "gradient" capability of every established 2D @@ -24,7 +24,7 @@ gradient's own coordinates into the caller's path coordinate space. Rendering a `PathFiller`/`ScanlineRasterizer`'s existing antialiased scanline-coverage pipeline unchanged; `GradientPaint` only supplies a per-pixel color source in place of a single constant color. -### Data Model +#### Data Model | Type | Description | | --- | --- | @@ -54,9 +54,9 @@ earlier-supplied stop's color is used for gradient parameter values approaching the later-supplied stop's color for values at or beyond it (see `GradientEvaluator.ResolveColor` below). -### Key Methods +#### Key Methods -#### Gradient(stops, spread, transform) (private protected) +##### Gradient(stops, spread, transform) (private protected) Validates and stores the state shared by every gradient subtype: @@ -69,7 +69,7 @@ Validates and stores the state shared by every gradient subtype: legitimately become momentarily singular (for example, mid-animation) without that necessarily being a programming error. -#### LinearGradient(start, end, stops, spread, transform) +##### LinearGradient(start, end, stops, spread, transform) Defines a ramp varying linearly along the vector from `start` to `end` (in gradient-defining coordinates). `start` and `end` must each have finite components; `end` equal to `start` (a @@ -77,7 +77,7 @@ zero-length gradient vector) is accepted at construction - it is a defined degen case (flat-fill with the last stop's color), not a constructor error, since a caller could compose this state programmatically (for example, an animated gradient briefly collapsing to a point). -#### RadialGradient(startCenter, startRadius, endCenter, endRadius, stops, spread, transform) +##### RadialGradient(startCenter, startRadius, endCenter, endRadius, stops, spread, transform) Defines a ramp varying between two independently positioned and sized circles - the general "two-circle" (also called "focal" or "conical") gradient model used by SVG/CSS radial gradients, @@ -102,7 +102,7 @@ preserved exactly as given and validated/stored unchanged. A plain `Matrix3x2 tr parameter cannot distinguish "the caller omitted the argument" from "the caller explicitly passed the all-zero matrix"; the nullable parameter removes that ambiguity without any special-casing. -#### Gradient.WithTransform(transform) (abstract; overridden by LinearGradient/RadialGradient) +##### Gradient.WithTransform(transform) (abstract; overridden by LinearGradient/RadialGradient) Returns a new gradient of the same runtime type and with the same `Stops`/`Spread`/geometry (`Start`/`End` for `LinearGradient`; `StartCenter`/`StartRadius`/`EndCenter`/`EndRadius` for @@ -118,7 +118,7 @@ than remaining fixed in the shape's old, untransformed local frame. `WithTransfo the original instance - it returns a new gradient, preserving `Gradient`'s existing immutability guarantees. -#### GradientEvaluator.CreatePlan / EvaluatePoint / EvaluateRow (internal) +##### GradientEvaluator.CreatePlan / EvaluatePoint / EvaluateRow (internal) `CreatePlan` computes every quantity that depends only on `gradient` itself and not on the point being evaluated - the inverted `Transform`, the linear gradient's direction vector and its squared @@ -228,7 +228,7 @@ the same numerical safeguard `PathStroker`'s `StrokeOutliner` already applies to distance or quadratic coefficient even when the true mathematical result would not, silently producing a degenerate result rather than a visible error. -### Error Handling +#### Error Handling `Gradient`'s `private protected` constructor (invoked by both `LinearGradient` and `RadialGradient`) throws: @@ -248,14 +248,14 @@ producing a degenerate result rather than a visible error. performs no further argument validation of its own beyond null-checking the `gradient` reference itself (`ArgumentNullException`). -### Dependencies +#### Dependencies `GradientPaint` depends on `System.Numerics.Vector2`/`Matrix3x2` (in-box BCL types) and the `Canvas` subsystem's `Rgba32` (for `GradientStop.Color` and `GradientEvaluator`'s output) and `Surface.UnpremultiplyAlpha` convention (matched, not called directly, by `GradientEvaluator.LerpPremultiplied`). It introduces no new runtime NuGet package. -### Callers +#### Callers `GradientPaint`'s public types (`Gradient`, `LinearGradient`, `RadialGradient`, `GradientStop`, `GradientSpread`) are constructed directly by consumers of the CanvasNet package and passed to diff --git a/docs/design/canvas-net/drawing/path-filler.md b/docs/design/canvas-net/drawing/path-filler.md index 8c2f1251..99c36644 100644 --- a/docs/design/canvas-net/drawing/path-filler.md +++ b/docs/design/canvas-net/drawing/path-filler.md @@ -1,15 +1,19 @@ -## PathFiller +### PathFiller ![Drawing Structure](DrawingView.svg) -The `PathFiller` class is the sole software unit in the `Drawing` subsystem. It provides a -single public entry point, `Fill(Surface, Path, Rgba32, FillRule, float)`, that rasterizes a -closed `Geometry.Path` onto a `Canvas.Surface` with a solid color, using an antialiased +The `PathFiller` class is one of four sibling software units in the `Drawing` subsystem +(alongside `PathStroker`, `GradientPaint`, and `TilePaint`). It provides a +single public entry point family, `Fill(Surface, Path, Rgba32, FillRule, float)`, +`Fill(Surface, Path, Gradient, FillRule, float)`, and +`Fill(Surface, Path, TilePaint, FillRule, float)`, that rasterizes a +closed `Geometry.Path` onto a `Canvas.Surface` with a solid color, a linear/radial gradient, or a +repeating tiled pattern, using an antialiased signed-area/coverage-accumulation scanline algorithm. The supporting `FillRule` enum and the internal `EdgeFlattener`/`ScanlineRasterizer` helpers are documented inline here, because none of them has any independent behavior beyond supporting `PathFiller.Fill`. -### Purpose +#### Purpose `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 @@ -21,11 +25,14 @@ every subpath as implicitly closed regardless of whether the path explicitly cal 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 +(`gradient-paint.md`) for the gradient-specific evaluation algorithm it delegates to - and a +tile-paint `Fill(Surface, Path, TilePaint, FillRule, float)` overload that shares the same +flattening, clip-bounds, and validation logic again - see _TilePaint Unit Design_ +(`tile-paint.md`) for the tile-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 +#### Coordinate Convention **This is the first unit in CanvasNet that produces actual rendered pixel output from path-space coordinates, so its coordinate convention is stated explicitly and applies throughout this @@ -36,7 +43,7 @@ falls exactly at the center of pixel `(2, 1)`. This convention is what makes the math well-defined: "how much of pixel `(x, y)`'s unit cell lies inside the filled region" is a precise geometric question once cell boundaries are pinned to integer path coordinates this way. -### Data Model +#### Data Model | Type | Description | | -------------------- | --------------------------------------------------------------------- | @@ -54,9 +61,9 @@ fill state, which is what makes a self-intersecting shape (for example a five-po without lifting the pen, or two overlapping same-wound rectangles) render with alternating filled/unfilled regions rather than a single solid union. -### Key Methods +#### Key Methods -#### PathFiller.Fill(Surface surface, Path path, Rgba32 color, FillRule fillRule, float flattenTolerance) +##### PathFiller.Fill(Surface surface, Path path, Rgba32 color, FillRule fillRule, float flattenTolerance) Fills `path` with the solid `color` onto `surface`, using `fillRule` to resolve overlapping windings and `flattenTolerance` to bound curve-flattening error (see `BezierFlattening`'s own @@ -101,7 +108,7 @@ subsystem grows a dedicated transform concept. zero (matching `BezierFlattening`'s own tolerance-validation convention, extended to also reject `NaN`/`Infinity` explicitly rather than relying on comparison operators alone) -#### PathFiller.Fill(Surface surface, Path path, Gradient paint, FillRule fillRule, float flattenTolerance) +##### PathFiller.Fill(Surface surface, Path path, Gradient paint, FillRule fillRule, float flattenTolerance) Fills `path` onto `surface` with a linear or radial `Gradient` (see _GradientPaint Unit Design_, `gradient-paint.md`) instead of a solid color, evaluated once per pixel and scaled by that pixel's @@ -112,7 +119,19 @@ difference is that `ScanlineRasterizer.Fill`'s gradient-aware overload is invoke solid-color overload, and it additionally rejects a `null` `paint` argument with `ArgumentNullException`. -#### EdgeFlattener.Flatten(Path path, float tolerance) (internal) +##### PathFiller.Fill(Surface surface, Path path, TilePaint paint, FillRule fillRule, float flattenTolerance) + +Fills `path` onto `surface` with a repeating tiled pattern supplied by a `TilePaint` (see +_TilePaint Unit Design_, `tile-paint.md`) instead of a solid color or gradient, evaluated once per +pixel and scaled by that pixel's antialiased fill coverage exactly like the other two overloads. +It shares the same private `TryFlattenForFill`/`ValidateFillArgs` helpers as the solid-color and +gradient overloads for flattening, clip-bounds computation, and `fillRule`/`flattenTolerance` +validation - the only difference is that `ScanlineRasterizer.Fill`'s tile-paint-aware overload is +invoked instead, and it additionally rejects a `null` `paint` argument with +`ArgumentNullException`. `TilePaint` itself does not own the lifetime of the `Surface` it wraps; +this overload neither disposes nor otherwise mutates that `Surface` - it only samples it. + +##### EdgeFlattener.Flatten(Path path, float tolerance) (internal) Converts every one of `path`'s subpaths into a closed polygon (a `List`), returning one polygon per subpath in the same order. For each subpath, walks its `Commands` starting from @@ -147,7 +166,7 @@ fewer than 3) are passed through as-is with no special-casing here - `ScanlineRa independently treats any polygon with fewer than 2 usable edges as contributing zero coverage (see below), so no upstream filtering is required. -#### ScanlineRasterizer.Fill(Surface surface, IReadOnlyList\\> polygons, ...) (internal) +##### ScanlineRasterizer.Fill(Surface surface, IReadOnlyList\\> polygons, ...) (internal) Rasterizes `polygons` (already-flattened, already-closed polygons from `EdgeFlattener`) onto `surface`, restricted to `clipBounds`, using the AGG/FreeType-style **analytic** @@ -261,7 +280,7 @@ the implementation against it, not by automated runtime performance tests - a de decision, since wall-clock assertions are unreliable guards for algorithmic complexity on heterogeneous CI hardware. -### Error Handling +#### Error Handling All argument validation is performed by `PathFiller.Fill` itself, at the very start of the method, before any bounds computation or rasterization begins (see above). `EdgeFlattener` and @@ -269,7 +288,7 @@ method, before any bounds computation or rasterization begins (see above). `Edge `PathFiller.Fill` and perform no further validation of their own; they are only ever reached after `PathFiller.Fill`'s guards and the empty/out-of-bounds no-op check have already passed. -### Dependencies +#### Dependencies `PathFiller` (and its internal `EdgeFlattener`/`ScanlineRasterizer` helpers) depend on `System.Numerics.Vector2` (in-box BCL type), the `Geometry` subsystem's `Path`, `Subpath`, @@ -277,13 +296,15 @@ method, before any bounds computation or rasterization begins (see above). `Edge `Canvas` subsystem's `Surface`, `Rgba32`, and `Surface.CompositeOverSpan` (via `ScanlineRasterizer`; see _Surface Unit Design_, `../canvas/surface.md`, for that method's own documentation). The gradient-paint overload additionally depends on the `Gradient` public type and -the internal `GradientEvaluator` helper (see _GradientPaint Unit Design_, `gradient-paint.md`). No +the internal `GradientEvaluator` helper (see _GradientPaint Unit Design_, `gradient-paint.md`). +The tile-paint overload additionally depends on the `TilePaint` public type and the internal +`TilePaintEvaluator` helper (see _TilePaint Unit Design_, `tile-paint.md`). No new runtime NuGet package is introduced. -### Callers +#### Callers `PathFiller.Fill` is a public API entry point, invoked externally by consumers of the CanvasNet package. It is exercised end to end by this unit's own tests (`PathFillerTests`, -`EdgeFlattenerTests`, `ScanlineRasterizerTests`) and by a system-integration test that builds a -`Path` via `PathBuilder` and fills it onto a `Surface` (see `CanvasNetTests.cs`). `PathFiller` has +`EdgeFlattenerTests`, `ScanlineRasterizerTests`) and by system-integration tests that build a +`Path` via `PathBuilder` and fill it onto a `Surface` (see `CanvasNetTests.cs`). `PathFiller` has no dependency on any consumer, and no other unit in this library depends on `PathFiller`. diff --git a/docs/design/canvas-net/drawing/path-stroker.md b/docs/design/canvas-net/drawing/path-stroker.md index 2415661f..63ad75f7 100644 --- a/docs/design/canvas-net/drawing/path-stroker.md +++ b/docs/design/canvas-net/drawing/path-stroker.md @@ -1,6 +1,6 @@ -## PathStroker +### PathStroker ![Drawing Structure](DrawingView.svg) @@ -9,7 +9,7 @@ geometry that the existing `PathFiller` unit can rasterize unchanged. It also co `StrokeStyle`, `LineCap`, and `LineJoin` types, and the internal `StrokePathFlattener`, `DashSplitter`, and `StrokeOutliner` helpers. -### Purpose +#### Purpose `PathStroker` exists so CanvasNet can render strokes without introducing a second rasterizer. Rather than painting pixels directly, it transforms an input centerline path into one or more @@ -19,7 +19,7 @@ analytic antialiasing and compositing behavior fills already use. This design ke rendering and fill rendering on one coverage-computation path, which reduces implementation duplication and ensures the two public APIs agree on how pixel coverage is resolved. -### Data Model +#### Data Model | Type | Description | | --- | --- | @@ -38,9 +38,9 @@ original list elsewhere. This is important because dash phase and width are visi behavior; a caller must be able to reuse the same `StrokeStyle` instance across many paths and receive identical outline geometry every time. -### Key Methods +#### Key Methods -#### StrokeStyle.StrokeStyle(...) +##### StrokeStyle.StrokeStyle(...) The constructor validates every public styling input at the API boundary, mirroring `PathFiller.Fill`'s own validation conventions: @@ -58,7 +58,7 @@ having `PathStroker` rediscover invalid style data every time a path is stroked. caller-controlled configuration object; rejecting bad values there keeps the conversion pipeline itself focused only on geometry. -#### PathStroker.Stroke(Path path, StrokeStyle style, float flattenTolerance) +##### PathStroker.Stroke(Path path, StrokeStyle style, float flattenTolerance) `Stroke` performs stroke-to-fill conversion in three geometry-only stages, then emits the result as a brand-new `Path`: @@ -111,7 +111,7 @@ The returned path is assembled through `PathBuilder`; `PathStroker` never constr instances directly. Every emitted polygon becomes one closed subpath in the result, and `Path.Empty` is returned when no outline polygon is produced. -#### StrokeOutliner.Outline(...) (internal) +##### StrokeOutliner.Outline(...) (internal) `StrokeOutliner` carries most of the geometry-specific design decisions: @@ -186,7 +186,7 @@ The helper intentionally removes only redundant consecutive duplicate vertices. topologically simplify self-intersections or reorder segments; the contract is to preserve the input path's authored shape, not to reinterpret it. -### Double-Precision Internal Computations +#### Double-Precision Internal Computations Several of `StrokeOutliner`'s and `DashSplitter`'s internal computations are deliberately performed in `double` precision and only narrowed back to `float` (or kept as `double` when only a sign @@ -221,7 +221,7 @@ result - never the literal double-precision magnitude exposed back through the p computing in `double` internally is a pure precision safeguard with no observable effect on ordinary, non-extreme stroke geometry. -#### Bounding Dash-Interval Traversal Iteration Count +##### Bounding Dash-Interval Traversal Iteration Count Computing `DashSplitter`'s dash intervals in `double` (rather than `float`) keeps its ULP (unit in the last place) negligible relative to any dash span at the path lengths this library is intended @@ -281,7 +281,7 @@ pattern length. This fallback shape matches `SvgCodec.RenderStroke`'s own establ this dash pattern -> render as solid stroke" convention for other dash-pattern-specific numeric problems, rather than throwing or silently omitting the stroke. -#### Bounding Retained On-Interval Count +##### Bounding Retained On-Interval Count `MaxOnIntervalIterations` (above) bounds the traversal loop's worst-case *iteration count*, but a cloud-PR-review finding identified that iteration count is a distinct quantity from the loop's @@ -321,7 +321,7 @@ Either budget being exceeded (via pre-flight estimate or in-loop backstop, for e iteration-count or the on-interval-count budget) is reported back to `Split` through the same `out bool` parameter and triggers the same solid-stroke fallback described above. -### Complexity +#### Complexity The total conversion cost is the sum of three bounded passes over the path data. Flattening is the same adaptive-subdivision cost already documented for `BezierFlattening`; dashing is linear in @@ -336,7 +336,7 @@ tests. This mirrors the existing `PathFiller` design decision: wall-clock assert stable proof of algorithmic behavior on heterogeneous CI hardware, while the geometric pass structure and bounded per-vertex/per-arc work are visible directly in the implementation. -### Error Handling +#### Error Handling `PathStroker.Stroke` performs the public API validation: @@ -348,7 +348,7 @@ The internal helpers assume they receive already-validated input from `PathStrok `StrokeStyle` performs its own constructor validation and throws `ArgumentOutOfRangeException` or `ArgumentException` before a style instance is ever created. -### Dependencies +#### Dependencies `PathStroker` depends on the `Geometry` subsystem's `Path`, `Subpath`, `PathCommand`, `PathBuilder`, `BezierFlattening`, and `SvgArcConverter` types, and on `System.Numerics.Vector2` @@ -356,7 +356,7 @@ for the underlying geometry math. It has no direct dependency on `Canvas.Surface not rasterize; callers render its result through `PathFiller`, which in turn depends on the `Canvas` subsystem. No new runtime NuGet package is introduced by this unit. -### Callers +#### Callers `PathStroker.Stroke` is a public API entry point, invoked directly by consumers of the CanvasNet package and indirectly by the README and User Guide examples that demonstrate stroking followed by diff --git a/docs/design/canvas-net/drawing/tile-paint.md b/docs/design/canvas-net/drawing/tile-paint.md new file mode 100644 index 00000000..292d0612 --- /dev/null +++ b/docs/design/canvas-net/drawing/tile-paint.md @@ -0,0 +1,120 @@ + + +### TilePaint + +![Drawing Structure](DrawingView.svg) + +The `TilePaint` unit is the public `TilePaint` type - a pre-rendered one-cell tile `Surface` plus a +device-space `Transform` and a pattern-space `XStep`/`YStep` repeat pitch - and the internal +`TilePaintEvaluator` helper that resolves a tiled pattern's color per pixel by nearest-neighbor +sampling the wrapped-around tile bitmap. It has no independent externally visible behavior of its +own: it exists to be consumed by `PathFiller`'s tile-paint `Fill(Surface, Path, TilePaint, +FillRule, float)` overload (see _PathFiller Unit Design_, `path-filler.md`), mirroring +`GradientPaint`'s own public-type-plus-internal-evaluator shape (see `gradient-paint.md`). + +#### Purpose + +`TilePaint` lets a caller paint a filled path by repeating a small pre-rendered bitmap ("tile") +across the filled region, instead of a single solid color or a smoothly varying gradient ramp - +the standard "tiling pattern" capability used by PDF `PatternType 1` tiling patterns (see +_PdfDocument Unit Design_, `pdf-document.md`, `/Pattern` Color Space section) and equivalent +tiled-brush concepts in other 2D graphics APIs. Unlike `GradientPaint`, which evaluates a +continuous mathematical function per pixel, `TilePaint` samples a caller-supplied, already-rendered +`Surface` - the caller (in practice, `PdfDocument`'s tiling-pattern cell renderer) is responsible +for producing that one-cell bitmap by whatever means its own pattern content requires; `TilePaint` +itself only repeats and samples it. + +#### Data Model + +| Type | Description | +| --- | --- | +| `TilePaint` | Public sealed class: `Surface`, `Transform` (`Matrix3x2`), `XStep`, `YStep`. | +| `TilePaintEvaluator` | Internal static helper: resolves a `TilePaint`'s color per pixel row. | + +`TilePaint` does not own its `Surface`'s lifetime: it does not dispose it, and the caller that +constructed the `TilePaint` remains responsible for disposing the underlying `Surface` once it is +no longer needed - mirroring how `Gradient` similarly owns no disposable resource of its own. +`XStep`/`YStep` may be negative (a legitimate PDF tiling-pattern value, per PDF 32000-1 ยง8.7.3.1, +meaning the tile repeats in the negative pattern-space axis direction) - only zero or a non-finite +value is rejected by the constructor; `TilePaintEvaluator` wraps around using the absolute value of +each step. + +#### Key Methods + +##### TilePaint(surface, transform, xStep, yStep) + +Validates and stores the tile paint's state: + +- `surface` must not be `null`. +- `transform` must have every one of its six components finite. +- `xStep`/`yStep` must each be finite and non-zero; a negative value is accepted (see the Data + Model section above). + +##### TilePaint.WithTransform(transform) + +Returns a new `TilePaint` with the same `Surface`/`XStep`/`YStep`, whose `Transform` is this tile +paint's own existing `Transform` composed with the supplied `transform` (row-vector convention +matching `Gradient.WithTransform`: this tile paint's existing `Transform` is applied first, then +the supplied `transform` is applied on top of that). Never mutates the original instance. + +##### TilePaintEvaluator.CreatePlan / EvaluateRow (internal) + +Mirrors `GradientEvaluator`'s own "per-fill precomputation, per-row evaluation" shape exactly: +`CreatePlan` computes the only per-fill-invariant quantity - the inverse of `TilePaint.Transform` - +exactly once, returning the reusable `TilePaintPlan`; `ScanlineRasterizer`'s tile-aware sweep calls +`CreatePlan` once per fill operation and passes the same plan into every `EvaluateRow` call for +every row of that fill. The per-pixel evaluation pipeline: + +1. **Map the device-space pixel center into pattern space** via the precomputed inverse transform. + If `TilePaint.Transform` was found to be singular (non-invertible) or to produce a non-finite + inverse, the whole affected fill/row is left fully transparent instead - the "Degenerate + Transform" case (see the policy-divergence note below). +2. **Wrap the pattern-space X/Y coordinates** modulo the absolute value of `XStep`/`YStep`, using + floor-mod (never the CLR `%` operator, which is negative for a negative dividend - matching + `GradientEvaluator`'s own documented `Repeat` floor-mod precedent), into the half-open range + `[0, |XStep|) x [0, |YStep|)`. +3. **Map the wrapped pattern-space point into the tile `Surface`'s own pixel coordinate space** by + the ratio of the tile surface's pixel dimensions to `XStep`/`YStep`, clamped into + `[0, Width - 1] x [0, Height - 1]` (guarding a floating-point edge case landing exactly on the + width/height boundary). +4. **Sample the tile `Surface` by nearest-neighbor** at the resolved integer pixel coordinate. + Bilinear interpolation is explicitly out of scope. + +**Degenerate Transform policy - deliberate divergence from `Gradient`.** When +`TilePaint.Transform` is not invertible (or its inverse has a non-finite component), every pixel in +the affected fill is left fully transparent (alpha zero), **not** flat-filled with any color. This +is a deliberate divergence from `Gradient`'s own "Degenerate Transform" policy (which flat-fills +with the last color stop - see `gradient-paint.md`): `TilePaint` has no stop/last-color concept at +all, so there is no equivalent fallback color to flat-fill with. Instead, this mirrors +`GradientEvaluator`'s own separate "no valid root" radial-gradient case, which likewise leaves +affected pixels fully transparent/unpainted. This is an intentional, documented design decision, +not an oversight or an accidental inconsistency with `GradientPaint`. + +#### Error Handling + +`TilePaint`'s constructor throws: + +- `ArgumentNullException` - when `surface` is `null`. +- `ArgumentOutOfRangeException` - when any component of `transform` is not finite, or when + `xStep`/`yStep` is not finite or is zero. + +`TilePaintEvaluator.CreatePlan` throws `ArgumentNullException` when `tile` is `null`; it performs +no further argument validation of its own. + +#### Dependencies + +`TilePaint` depends on `System.Numerics.Vector2`/`Matrix3x2` (in-box BCL types) and the `Canvas` +subsystem's `Surface`/`Rgba32` (for the tile bitmap and its sampled pixel values). It introduces no +new runtime NuGet package. + +#### Callers + +`TilePaint` is constructed directly by consumers of the CanvasNet package - in practice, +`DemaConsulting.CanvasNet.Pdf`'s `PdfDocument` tiling-pattern rendering, which renders a PDF +`PatternType 1` tiling pattern's content stream into a one-cell offscreen `Surface` and wraps it in +a `TilePaint` (see `pdf-document.md`, `/Pattern` Color Space section) - and passed to +`PathFiller.Fill(Surface, Path, TilePaint, FillRule, float)`. The internal `TilePaintEvaluator` +helper is invoked exclusively by `ScanlineRasterizer`'s tile-aware `Fill` overload (see +_PathFiller Unit Design_, `path-filler.md`). The unit is exercised by `TilePaintTests` and +`TilePaintEvaluatorTests`, and indirectly by `PathFillerTests`'/`ScanlineRasterizerTests`' +tile-paint-specific scenarios. diff --git a/docs/design/canvas-net/fonts.md b/docs/design/canvas-net/fonts.md index 1232c2e8..d61a270b 100644 --- a/docs/design/canvas-net/fonts.md +++ b/docs/design/canvas-net/fonts.md @@ -4,47 +4,90 @@ ![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. +The `Fonts` subsystem is the fifth software subsystem in CanvasNet. It groups the `TrueTypeFont` +unit that loads glyph-based TrueType SFNT fonts as well as CFF/OpenType (`OTTO`-flavored) fonts +and individual faces of a TrueType Collection (`ttcf`) container, maps Unicode codepoints to +glyph indices, extracts glyph outlines as `Geometry.Path` geometry, reports horizontal metrics +and basic kerning, and exposes each font's name (`name` table) and derived bold/italic/ +fixed-pitch style metadata. `TrueTypeFont` additionally loads standalone classic PostScript +Type 1 and Type 1C font programs (`LoadType1`/`LoadType1C`), decrypting and decoding their +`eexec`-encrypted Type 1 charstrings (PFB segmented binary or PFA hex-encoded ASCII source) the +same way it decodes `glyf`/CFF outlines, so a caller can load any of the three classic font +program flavors through one unit; and (added this phase, `TrueTypeFont` itself unchanged) the +`SystemFontCatalog` unit, which discovers fonts installed on the host operating system by +directory scan, best-effort matches a requested family name/style against that catalog, and +provides a bundled, always-available Liberation Sans/Serif/Mono last-resort fallback font shipped +as an embedded resource of this assembly. Internally, `TrueTypeFont` fronts `SfntContainer`, +`CmapTable`, `GlyfLocaReader`, `CffTable`, `CffCharstringInterpreter`, `HmtxHheaReader`, +`KernTable`, `NameTable`, and `StyleTable` exactly as `PathFiller` fronts `EdgeFlattener` and +`ScanlineRasterizer`: one public entry point coordinating several independently testable helpers. +`GlyfLocaReader` and `CffTable` both implement a small internal `IGlyphOutlineSource` abstraction +so `TrueTypeFont` dispatches `GetGlyphOutline` to whichever outline flavor the loaded font +actually uses without any type-checking of its own. `SystemFontCatalog` is a much shallower unit +by comparison: it calls `TrueTypeFont` as a client, not as an internal helper, and has no +supporting internal types of its own. ### 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. +and CFF/OpenType (`OTTO`-flavored, Type 2 charstring-based) SFNT font files, including selecting +an individual face out of a TrueType Collection (`ttcf`) container, as well as standalone classic +PostScript Type 1 and Type 1C font programs (PFB/PFA source, `eexec`-encrypted Type 1 +charstrings). 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, returns pairwise kerning adjustments from classic `kern` format-0 subtables when present, +and resolves the font's own name (family/subfamily/full/PostScript name) and bold/italic/ +fixed-pitch style classification from its `name`, `OS/2`, `head`, and `post` tables. Since this +phase, the subsystem also answers "what fonts does this process consider installed, and which +one is the best available substitute for an unavailable font" - `SystemFontCatalog`'s directory +scan, `FindBestMatch` scoring, and bundled-fallback loading - as a format-agnostic capability any +document-format consumer can call. Text layout, shaping, hint execution, point-size scaling, and +pixel rendering remain 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`) +- **TrueTypeFont** โ€” the first public unit of the subsystem. It exposes `Load(Stream)` / + `Load(string)`, the explicit-face-selection overloads `Load(Stream, int)` / `Load(string, int)`, + `LoadType1`/`LoadType1C` for standalone classic PostScript Type 1/Type 1C font programs, + `GetFaceCount(Stream)` / `GetFaceCount(string)`, query methods for codepoint mapping, glyph + outlines, advance widths, and kerning, `GetNameInfo()` for the font's name-table strings, and + `IsBold`/`IsItalic`/`IsFixedPitch` for its derived style classification. 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`) +- **SystemFontCatalog** โ€” the second public unit of the subsystem, added this phase. It exposes + `Fonts` (the lazily-built, process-lifetime-cached OS font catalog), `FindBestMatch` (best-effort + family-name/style matching against that catalog), and `LoadBundledFallback` (the bundled + Liberation Sans/Serif/Mono last resort); see _SystemFontCatalog Unit Design_ + (`fonts/system-font-catalog.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. +`PathBuilder`, and `PathCommand` abstractions (used by `TrueTypeFont`), and, for +`SystemFontCatalog`, on `System.Runtime.InteropServices.RuntimeInformation` to select this +process's per-operating-system font-directory scan roots, and on this assembly's own embedded +`Fonts/BundledFonts/*.ttf` resources. `TrueTypeFont.GetGlyphOutline` returns raw vector +geometry in font design units: the `glyf`-flavored glyph decoder issues only `MoveTo`, `LineTo`, +`QuadraticBezierTo`, and `Close` commands, while the CFF/Type 2 charstring decoder issues only +`MoveTo`, `LineTo`, `CubicBezierTo`, and `Close` commands, both 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`. +`TrueTypeFont` and `SystemFontCatalog` are both public API entry points, called directly by +consumers of the CanvasNet package. Within this repository, the subsystem is exercised by +dedicated `Fonts` unit tests, by real-font integration tests in +`TrueTypeFontRealFontIntegrationTests.cs` (covering real `.ttf`, `.otf`, and locally-assembled +`.ttc` fixtures), by `BundledLiberationFontsTests.cs` (dogfooding all 12 bundled Liberation +files through `TrueTypeFont.Load` itself), by `SystemFontCatalogRealDiscoveryIntegrationTests.cs` +(gated real-filesystem discovery), 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`. `SystemFontCatalog` is additionally called by +`DemaConsulting.CanvasNet.Pdf`'s `PdfDocument` when resolving a non-embedded font. diff --git a/docs/design/canvas-net/fonts/system-font-catalog.md b/docs/design/canvas-net/fonts/system-font-catalog.md new file mode 100644 index 00000000..6ef81be5 --- /dev/null +++ b/docs/design/canvas-net/fonts/system-font-catalog.md @@ -0,0 +1,153 @@ +### SystemFontCatalog + + + + +`SystemFontCatalog` is the second public software unit in the `Fonts` subsystem, added this +phase alongside the unchanged `TrueTypeFont`. It provides directory-scan-only discovery of fonts +installed on the host operating system, best-effort family-name/style matching against that +discovered catalog, and a bundled, always-available Liberation Sans/Serif/Mono last-resort +fallback font shipped as an embedded resource of this assembly. + +#### Purpose + +`SystemFontCatalog` lets a caller (today, `DemaConsulting.CanvasNet.Pdf`'s `PdfDocument`) +substitute a font it needs to render but does not have embedded font bytes for. It answers three +distinct questions: what fonts does this process consider "installed" (`Fonts`), which one of +those best matches a requested family name and bold/italic/serif/fixed-pitch style +(`FindBestMatch`), and, when nothing on the host machine matches at all, which bundled font +should be used instead (`LoadBundledFallback`). The unit is deliberately format-agnostic: it has +no knowledge of any particular document format's font-naming conventions (for example a PDF +subset tag or a Standard-14 name) - a caller is responsible for reducing a format-specific font +reference to a plain family-name hint and a set of style booleans before calling `FindBestMatch`. + +#### Data Model + +- **`SystemFontInfo`** - a `readonly record struct` recording one discovered font file's (or, + for a `.ttc` collection, one discovered face's) identity and metadata: `FamilyName`, + `SubfamilyName`, `Bold`, `Italic`, `FixedPitch` (all resolved via `TrueTypeFont.GetNameInfo()` + and the `IsBold`/`IsItalic`/`IsFixedPitch` properties already exposed by the unchanged + `TrueTypeFont` unit), plus `FilePath` and `FaceIndex` so the exact face can be reloaded later. + `FamilyName` falls back to the font file's own name (without its extension) when the font has + no usable `name` table, so every catalog entry always has a non-null, non-empty family name. +- **`Fonts`** - an `IReadOnlyList` backed by a `Lazy>` + built exactly once per process, on first access, using `LazyThreadSafetyMode.ExecutionAndPublication`. + It is never rescanned afterward: installing or removing a font on the host machine while the + process is running is not detected. +- **Well-known generic-family lists** - three hand-maintained, priority-ordered `string[]` + constants (`GenericSansFamilies`, `GenericSerifFamilies`, `GenericMonospaceFamilies`), each + listing common cross-platform family names (for example `Arial`, `Helvetica`, `Liberation + Sans`, `DejaVu Sans`, `Nimbus Sans`, `Segoe UI` for the sans-serif bucket) most likely to + actually be installed on a given operating system, in the order they are tried. +- **`BundledFontCache`** - a `Dictionary` (guarded by a plain + `private static readonly object` lock, since the core package multi-targets `net8.0` and + therefore cannot rely on the newer `System.Threading.Lock` type) caching every bundled Liberation + style loaded so far by its bundled file name (for example `"LiberationSans-Bold.ttf"`), plus + every bundled Noto substitute font loaded cross-assembly by `DemaConsulting.CanvasNet.Pdf`'s own + symbolic-font fallback (see `LoadBundledFallbackCore` below) - at most 15 entries are ever + created (12 Liberation Sans/Serif/Mono style variants plus 3 Noto substitute fonts), each + created at most once, for the process lifetime. + +#### Key Methods + +##### Fonts + +A property returning `LazyFonts.Value`. Building the catalog scans every well-known font +directory for the current operating system (see Dependencies/Platform Scan Roots below), +recursively enumerating `.ttf`/`.ttc`/`.otf` candidate files (case-insensitive extension match) +and loading every face of every candidate with `TrueTypeFont.GetFaceCount`/`TrueTypeFont.Load`. A +missing or inaccessible directory, an unparseable candidate file, or a single corrupt face within +an otherwise well-formed `.ttc` is silently skipped (catching `InvalidDataException`, +`IOException`, and `UnauthorizedAccessException` at the narrowest possible scope) rather than +aborting the whole scan - an empty result is a valid, expected outcome (for example on a minimal +container image with no fonts installed at all), never an error. + +##### FindBestMatch(familyNameHint, bold, italic, serif, fixedPitch) + +Implements a two-tier scoring algorithm over `Fonts`: + +1. **Tier 1 - exact family match.** Every candidate whose `FamilyName` case-insensitively equals + `familyNameHint` is considered; among them, the one minimizing the count of mismatched + bold/italic flags wins (a tie is broken by first-encountered order). If any exact-family + candidate exists, its best style match is returned immediately - tier 2 is never consulted. +2. **Tier 2 - generic-bucket fallback.** Only when tier 1 finds nothing, the appropriate + generic-family list is selected (`GenericMonospaceFamilies` when `fixedPitch` is + `true` - taking priority over `serif` - otherwise `GenericSerifFamilies` when `serif` is + `true`, otherwise `GenericSansFamilies`), and each name in that list is tried, in order, as an + exact-family search of its own; the first name in the list that actually has at least one + candidate in `Fonts` wins (again resolved to its own best style match). + +Returns `null` when `Fonts` is empty, or genuinely has no exact family match and no +generic-bucket match either. `FindBestMatch` never itself consults `LoadBundledFallback` - +composing "system match, else bundled fallback" is left to the caller. + +##### LoadBundledFallback(serif, fixedPitch, bold, italic) + +Derives a bundled file name of the form `"{Family}-{Style}.ttf"` (`Family` one of +`LiberationSans`/`LiberationSerif`/`LiberationMono`, mirroring `FindBestMatch`'s own +`fixedPitch`-over-`serif` priority; `Style` one of `Regular`/`Bold`/`Italic`/`BoldItalic`), then +loads (or returns the process-lifetime-cached) `TrueTypeFont` for that resource via +`Assembly.GetManifestResourceStream("DemaConsulting.CanvasNet.Fonts.BundledFonts.{fileName}")`. +Never consults `Fonts` or the host operating system's installed fonts at all, so it is available +even when the host machine has no fonts installed whatsoever. + +##### Internal Test-Support Entry Points + +- **`FindBestMatchCore(candidates, ...)`** - the scoring algorithm above, exposed over an + injectable candidate list so its tier-1/tier-2/no-match correctness can be tested + deterministically, without depending on what happens to be installed on the machine running + the tests. +- **`LoadBundledFallbackCore(bundledFileName)`** - the embedded-resource load/cache logic above, + exposed by explicit bundled file name so a test can exercise the missing-resource defensive + guard with a deliberately wrong name, and so a test can dogfood every one of the 15 real bundled + files individually (the 12 Liberation style variants plus the 3 Noto substitute fonts reused + cross-assembly by `DemaConsulting.CanvasNet.Pdf`). +- **`BuildCatalogFromRoots(roots)`** - the directory-scan/tolerance logic above, exposed over an + explicit, caller-supplied set of directory roots so the "missing root"/"unparseable file" + tolerance behavior can be tested against controlled temporary directories, without depending on + the real host machine's fonts. This entry point is a deliberate addition beyond this phase's + original plan text (which specified only an internal scoring entry point), added because it is + necessary to exercise `Fonts`'s own tolerance behavior deterministically. + +All three internal entry points are visible to `DemaConsulting.CanvasNet.Tests` only, via the +existing `InternalsVisibleTo` attribute on the core project. + +#### Error Handling + +`SystemFontCatalog`'s public surface never throws for a normal "nothing matched" or "nothing +installed" outcome - `Fonts` returns an empty list, and `FindBestMatch` returns `null`. The one +exception this unit ever throws is `InvalidOperationException` from `LoadBundledFallback` (via +`LoadBundledFallbackCore`), and only when the expected embedded resource is unexpectedly absent - +a defensive, effectively-unreachable packaging-integrity guard under normal operation, since all +15 bundled files (the 12 Liberation Sans/Serif/Mono style variants `LoadBundledFallback` itself +can request, plus the 3 Noto substitute fonts `DemaConsulting.CanvasNet.Pdf` requests +cross-assembly through the same `LoadBundledFallbackCore` entry point) are guaranteed present at +build time as embedded resources of this assembly. + +#### Dependencies + +`SystemFontCatalog` depends on `TrueTypeFont` (`GetFaceCount`, `Load(path[, faceIndex])`, +`GetNameInfo()`, `IsBold`, `IsItalic`, `IsFixedPitch`) within the same `Fonts` subsystem, and on +`System.Runtime.InteropServices.RuntimeInformation.IsOSPlatform` to select this process's +well-known font-directory scan roots: + +| Operating System | Scan Roots | +| --- | --- | +| Windows | `Environment.SpecialFolder.Fonts`; `%LOCALAPPDATA%\Microsoft\Windows\Fonts` (per-user), when set | +| macOS | `/System/Library/Fonts`; `/Library/Fonts`; `~/Library/Fonts`, when the user profile path is set | +| Linux (and other) | `/usr/share/fonts`; `/usr/local/share/fonts`; `~/.local/share/fonts`, `~/.fonts`, when set | + +No Windows registry access is used anywhere in this unit; discovery is directory-scan-only on +every supported operating system, matching this repository's existing CI OS matrix. +`LoadBundledFallback` additionally depends on this assembly's own embedded `BundledFonts/*.ttf` +resources (see the `Fonts` subsystem's Purpose section and `BundledFonts/README.md` for their +sourcing/licensing provenance). + +#### Callers + +`SystemFontCatalog` is called by `DemaConsulting.CanvasNet.Pdf`'s `PdfDocument` +(`PdfDocument.FontFallback.cs`'s `ResolveFallbackFont`) when resolving a `/Subtype /TrueType` +simple font lacking an embedded `/FontFile2` stream; see _PdfDocument Unit Design_ +(`../../canvas-net-pdf/pdf-document.md`). The unit's public API is intentionally +format-agnostic and generic, so a future non-PDF document-format consumer could call it directly +without any change to this unit. diff --git a/docs/design/canvas-net/fonts/true-type-font.md b/docs/design/canvas-net/fonts/true-type-font.md index da4f7f0b..a69b093d 100644 --- a/docs/design/canvas-net/fonts/true-type-font.md +++ b/docs/design/canvas-net/fonts/true-type-font.md @@ -4,63 +4,121 @@ - -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. + + + + + + + + + + +The `TrueTypeFont` class is the first of two public software units in the `Fonts` subsystem +(alongside `SystemFontCatalog`). It provides +hand-rolled loading and querying of glyph-based TrueType SFNT fonts, CFF/OpenType +(`OTTO`-flavored) fonts, and classic PostScript Type 1 font programs (both as caller-supplied +cleartext/encrypted byte segments and as standalone auto-detected `.pfb`/`.pfa` files), including +selecting an individual face out of a TrueType Collection (`ttcf`) container, while documenting +the supporting internal `SfntContainer`, `CmapTable`, `GlyfLocaReader`, `CffTable`, +`CffCharstringInterpreter`, `Type1Table`, `Type1CharstringInterpreter`, +`Type1CharstringDecryption`, `Type1PfbReader`, `Type1PfaReader`, `Type1StandardGlyphNames`, +`HmtxHheaReader`, `KernTable`, `NameTable`, and `StyleTable` 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. +`TrueTypeFont` lets callers load a TrueType, CFF/OpenType, or classic PostScript Type 1 font from +a stream or file path (optionally selecting a specific face of a TrueType Collection), 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, read pairwise kerning adjustments from a classic `kern` format-0 +subtable when present, and resolve the font's own name (`GetNameInfo()`) and bold/italic/fixed-pitch +style classification (`IsBold`/`IsItalic`/`IsFixedPitch`) from its `name`, `OS/2`, `head`, and +`post` tables. 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. For Type 1 font programs specifically, the class does not support `seac`-composed +accented glyphs (composite glyphs built from two other glyphs plus fixed accent-placement +metrics) - a charstring using `seac` is rejected with `InvalidDataException` rather than being +resolved - and, for standalone `.pfb`/`.pfa` files (which carry no caller-suppliable text +encoding of their own), only a small curated built-in codepoint-to-glyph-name vocabulary covering +common Latin/ASCII characters is available as the default encoding. #### 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. +All parsed SFNT/Type 1 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, an `IGlyphOutlineSource` (`GlyfLocaReader` for eager +`loca` parsing plus lazy glyph decoding, `CffTable` for CFF structural parsing plus lazy Type 2 +charstring decoding, or `Type1Table` for Type 1 font-program structural parsing plus lazy Type 1 +charstring decoding) for outline access, `CmapTable` for codepoint mapping, `KernTable` for pair +lookups, `NameTable` for name-string resolution, and the `IsBold`/`IsItalic`/`IsFixedPitch` +booleans derived once at load time by `StyleTable`. `IGlyphOutlineSource` is a small internal +dispatch abstraction: all three outline backends implement it identically (a `GlyphCount` +property and a `GetGlyphOutline(int)` method), so `TrueTypeFont.GetGlyphOutline` never needs to +know or check which outline flavor the loaded font actually uses. A font loaded via `LoadType1` +or via standalone `.pfb`/`.pfa` auto-detection synthesizes its own `CmapTable` (via +`CmapTable.FromMap`) and `HmtxHheaReader` (via `HmtxHheaReader.FromAdvanceWidths`) directly from +the parsed `Type1Table`'s glyph names/advance widths and the caller's (or the built-in default's) +codepoint-to-glyph-name encoding, rather than reading those tables from any SFNT byte layout, +since a Type 1 font program has no `cmap`/`hmtx` tables of its own; its `NameTable` is +`NameTable.Empty`, its `KernTable` is `KernTable.Empty`, `UnitsPerEm` is fixed at `1000` (the +classic Type 1 convention), and `IsBold`/`IsItalic`/`IsFixedPitch` are all `false`. ##### 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 | +| Offset | Size | Field | Use | +| ------ | ---- | --------------- | ------------------------------------------ | +| 0 | 4 | `sfntVersion` | Accept `0x00010000`, `'true'`, or `'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 | + +`'OTTO'` is accepted only as a *candidate* flavor at this layer: `TrueTypeFont` still requires an +`OTTO`-tagged font to contain a `CFF` table (see Required vs. Optional Tables below), and any +`sfntVersion` that is neither a recognized TrueType version nor `'OTTO'` remains rejected exactly +as before. + +##### `ttcf` TrueType Collection Header (12 + 4 ร— `numFonts` bytes, big-endian) + +| Offset | Size | Field | Use | +| ------ | -------------- | ------------- | ------------------------------------------------------------- | +| 0 | 4 | `ttcTag` | Must be `'ttcf'` for `TryReadTtcHeader` to recognize the file | +| 4 | 4 | `version` | Read but not otherwise interpreted | +| 8 | 4 | `numFonts` | Number of faces; zero is rejected | +| 12 | `4 * numFonts` | `offsetTable` | Each face's own SFNT offset table start, absolute from byte 0 | + +`SfntContainer.TryReadTtcHeader` returns `false` (not an exception) for any file whose first 4 +bytes are not `'ttcf'`, letting `TrueTypeFont` treat that case as "ordinary single-face SFNT" +without an exception-driven control flow. Once the tag *is* recognized as `'ttcf'`, a truncated +header, a truncated per-face offset table, or a declared `numFonts` of zero are all structurally +invalid and throw `InvalidDataException` rather than returning `false`. ##### 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 | +| Offset | Size | Field | Use | +| ------ | ---- | ---------- | ------------------------------------------------------------------------------ | +| 0 | 4 | `tag` | Names `head`, `maxp`, `hhea`, `hmtx`, `loca`, `glyf`, `cmap`, `kern`, or `CFF` | +| 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 | +| Offset | Size | Field | Use | +| ------ | ---- | ------------------ | --------------------------------------------------------------------------- | +| 0 | 4 | `version` | Present in the table prefix | +| 18 | 2 | `unitsPerEm` | Exposed as `UnitsPerEm`; zero is rejected | +| 44 | 2 | `macStyle` | Bits 0/1 (bold/italic) always OR'd into `IsBold`/`IsItalic` by `StyleTable` | +| 50 | 2 | `indexToLocFormat` | Selects short (`0`) or long (`1`) `loca` parsing (glyf-flavored fonts only) | -##### `maxp` Table Fields Used (6-byte prefix, version 1.0 only) +##### `maxp` Table Fields Used (6-byte prefix) -| 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` | +| Offset | Size | Field | Use in This Unit | +| ------ | ---- | ----------- | -------------------------------------------------------------------------- | +| 0 | 4 | `version` | Must be `0x00010000` (glyf-flavored) or `0x00005000` (CFF-flavored) | +| 4 | 2 | `numGlyphs` | Exposed as `GlyphCount`; for CFF fonts must equal `CffTable`'s glyph count | ##### `hhea` Table Fields Used (36 bytes) @@ -79,7 +137,10 @@ glyph decoding, `CmapTable` for codepoint mapping, and `KernTable` for pair look | 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. +advance widths, not left-side bearings. The trailing tail region is therefore optional: because +the reader never reads any of its bytes, a real-world font whose `hmtx` table omits that tail +entirely (rather than padding it out to the full glyph count) still parses successfully - only +the explicit `numOfLongHorMetrics` entries are required to be present. ##### `loca` Table Layout @@ -131,6 +192,220 @@ translation is itself transformed through that component's scale/2x2 matrix befo rather than applied unscaled; `UNSCALED_COMPONENT_OFFSET` takes precedence if both flags are set, and the default (neither flag set) is unscaled. +##### `CFF` Table Structure + + +| Region | Layout | Use in This Unit | +| ----------------- | -------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | +| Header | major/minor version, `hdrSize`, `offSize` | `hdrSize` locates the Name INDEX; unsupported major versions are rejected | +| Name INDEX | CFF INDEX (see below) | Read past but not otherwise interpreted | +| Top DICT INDEX | CFF INDEX of one DICT | Supplies `CharStrings` (op `17`), `Private` (op `18`), `ROS` (op `12 30`), and `charset` (op `15`) operator values | +| String INDEX | CFF INDEX | Captured for glyph-name resolution of SIDs `391` and above (see Charset Resolution below) | +| Global Subr INDEX | CFF INDEX | Global subroutines, addressed by `callgsubr` with bias `32768`/`1131`/`107` by count | +| Private DICT | DICT at the Top DICT's `Private` offset/size | Optional; supplies `Subrs` (op `19`), `defaultWidthX` (op `20`), `nominalWidthX` (op `21`), a Private-DICT-relative Local Subr INDEX offset | +| Local Subr INDEX | CFF INDEX at `Private DICT start + Subrs` | Local subroutines, addressed by `callsubr` with the same bias scheme | +| CharStrings INDEX | CFF INDEX of Type 2 charstring byte arrays | One entry per glyph; `CffTable.GlyphCount` is this INDEX's own count | +| Charset table | Format byte plus per-glyph/per-range SID data, at the Top DICT's `charset` byte offset (when present and greater than `2`) | Builds the glyph-name-to-glyph-index map (see Charset Resolution below) | + + +###### CFF INDEX Structure (used for every INDEX above) + +| Region | Layout | Use | +| ---------- | -------------------------------------- | ---------------------------------------------------- | +| `count` | uint16 | Zero means an empty INDEX (no offset array) | +| `offSize` | uint8 (present only if `count > 0`) | Byte width (1-4) of each offset | +| `offset[]` | `count + 1` entries of `offSize` bytes | 1-based, relative to the byte after the offset array | +| `data` | `offset[count] - 1` bytes | Concatenated variable-length entries | + +###### DICT Encoding (Top DICT and Private DICT) + +Every DICT operand this unit reads is an integer, encoded as a fixed 5-byte sequence (`b0 = 29` +followed by a 4-byte big-endian signed 32-bit value) or one of the shorter single/two/three-byte +integer encodings defined by the CFF specification; DICT real-number operands are not used by any +operator this unit reads. A one- or two-byte operator code follows an operator's operands; +operator `12` is always a two-byte escape (for example `12 30` for `ROS`). + +`CffTable` detects CID-keyed CFF data by the presence of the `ROS` operator (escape operator +`12 30`) in the Top DICT and fails closed with `InvalidDataException`: CID-keyed fonts use an +`FDArray`/`FDSelect`-based per-glyph Private DICT selection this unit does not implement, and +silently applying the single (non-CID) Private DICT lookup this unit does support would +misinterpret glyph data under the wrong scheme. + +###### Charset Resolution (glyph name to glyph index) + +A PDF simple font resolves a character code to a *glyph name* (for example `"A"`), not a glyph +index directly; `CffTable.TryGetGlyphIndex(string, out int)` resolves that name using the Top +DICT's `charset` operator (`15`): + + +| `charset` operator value | Meaning | +| ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Absent, or explicit `0` (ISOAdobe) | Glyph `i`'s SID equals `i` itself, for every glyph `i` from `1` to `GlyphCount - 1` | +| `1` (Expert), `2` (ExpertSubset) | Recognized but not resolved to glyph names: every non-`.notdef` glyph is assigned sentinel SID `-1`, so `TryGetGlyphIndex` returns `false` for every name (never throws) - a deliberate scope boundary, since no caller currently needs Expert-encoding glyph names | +| Greater than `2` | A byte offset (within the CFF table) to a custom charset table, in format `0`, `1`, or `2` below; any other format byte throws `InvalidDataException` | + + + +| Custom charset format | Layout | Decoding | +| ---------------------- | ------------------------------------------------------------------ | -------------------------------------------------------------------------------- | +| `0` | Format byte, then `GlyphCount - 1` 2-byte SIDs | Glyph `i`'s SID is the `(i - 1)`th entry | +| `1` | Format byte, then ranges of (2-byte first SID, 1-byte `nLeft`) | Each range covers `nLeft + 1` consecutive glyphs/SIDs starting at `first SID` | +| `2` | Format byte, then ranges of (2-byte first SID, 2-byte `nLeft`) | Same as format `1`, with a wider `nLeft` for large charsets | + + +Once every glyph's SID is known, its name is resolved via the 391-entry CFF Standard Strings table +(Adobe Technical Note #5176 Appendix A, transcribed verbatim as `CffTable.StandardStrings`) for SID +`0`-`390`, or the font's own String INDEX (`stringIndex[SID - 391]`) for SID `391` and above, +building the combined name-to-glyph-index map `TryGetGlyphIndex` queries. + +###### Type 2 Charstring Structure (`CffCharstringInterpreter`) + +Each CharStrings INDEX entry is itself a sequence of operands (integers or 16.16 fixed-point +numbers, both stack-based) and single- or two-byte operator codes, executed against the same +running-point/current-subpath state a `glyf` glyph decoder would maintain, but limited to exactly +the operator subset in the table below: + + +| Operator(s) | Code(s) | Behavior | +| -------------------------------------- | ------------ | ------------------------------------------------------------------------- | +| `hstem`, `vstem`, `hstemhm`, `vstemhm` | 1, 3, 18, 23 | Accumulate stem-hint count (operand pairs, plus any already on the stack) | +| `vmoveto`, `rlineto`, `hlineto` | 4, 5, 6 | (`vmoveto` also below) `rlineto`/`hlineto` append line segments | +| `vlineto` | 7 | Appends line segments, alternating axis with `hlineto` | +| `rrcurveto`, `hhcurveto`, `vvcurveto` | 8, 27, 26 | Append cubic Bezier segments per operator-specific operand packing | +| `callsubr`, `return` | 10, 11 | Invoke/resume a local subroutine, biased and depth/step bounded | +| `endchar` | 14 | Finishes the outline; 4-operand seac-style composes a base/accent pair | +| `hmoveto` | 22 | Starts a new subpath, horizontal-only offset | +| `vmoveto` | 4 | Starts a new subpath, vertical-only offset | +| `rmoveto` | 21 | Starts a new subpath, general XY offset | +| `hintmask`, `cntrmask` | 19, 20 | Skip `ceil(stemCount / 8)` mask bytes | +| `hvcurveto`, `vhcurveto` | 31, 30 | Append cubic Bezier segments, alternating start/end tangent axis | +| `callgsubr` | 29 | Invoke a global subroutine, biased and depth/step bounded | +| `rcurveline`, `rlinecurve` | 24, 25 | Curve(s) then a trailing line, or line(s) then a trailing curve | +| `hflex`, `flex`, `hflex1`, `flex1` | 12 34-37 | Flex shortcuts: two cubic Bezier segments, forcing final point onto axis | +| `dotsection` | 12 0 | Deprecated no-op; clears the operand stack only | + + +Any operator outside this set - including the remaining two-byte escape operators (arithmetic/ +logical and other Type 1 heritage operators) - is rejected with `InvalidDataException`, +as is a charstring that ends before its declared operand/operator data has been fully read. +`callsubr`/`callgsubr` apply the CFF-standard bias (`107` for a subroutine count under 1240, +`1131` for a count under 33900, otherwise `32768`, computed independently per local/global +subroutine INDEX from that INDEX's own entry count) and are bounded by both a maximum call depth +and a maximum total executed step count, mirroring `GlyfLocaReader`'s composite-glyph bounding +posture. The first stack-clearing operator in a charstring may carry one extra leading "width" +operand (recognized by its odd-numbered-out operand count rather than misread as a coordinate); +`CffCharstringInterpreter.Decode` returns that glyph's resolved advance width alongside its +outline - `nominalWidthX` plus the leading operand's value when present, else `defaultWidthX` - +per the Type 2 Charstring specification's width convention. For an SFNT/OpenType (`OTTO`-tagged) +font loaded via `Load`, this resolved width is superseded by that font's own `hmtx`-based +`TrueTypeFont.GetAdvanceWidth` and otherwise unused; for a bare Type1C/CFF program loaded via +`LoadType1C` (no `hmtx` table at all), `CffTable.GetAdvanceWidth(int glyphIndex)` - itself built on +this same `Decode` call - is the sole advance-width source. + +##### Type 1 Font Program Structure (`Type1Table`) + +A classic PostScript Type 1 font program is not an SFNT font at all: it is a PostScript program +consisting of a cleartext region (the first `length1` bytes) followed by an `eexec`-encrypted +binary region (the next `length2` bytes) containing the font's `/Subrs` and `/CharStrings` +dictionaries, each entry itself further encrypted as a nested "charstring" cipher layer. +`Type1Table.Parse` decrypts the `length2` region with `Type1CharstringDecryption` (`eexec` key +`R0 = 55665`), then scans the decrypted bytes for the literal tokens `/Subrs` and `/CharStrings` +using a token-agnostic binary-blob scanner: rather than requiring specific procedure names for +each entry's own encoding/decoding operators (which vary across font-generation tools - `RD`/`-|` +for reading an entry's raw bytes, `ND`/`|-`/`def` for defining a completed glyph procedure, `NP`/ +`|` for defining a completed subroutine procedure), the scanner locates each entry by its `dup + ` (for `/Subrs`) or `/ ` (for `/CharStrings`) prefix +followed by any single non-whitespace-delimited token, then reads exactly `byte-count` raw bytes +immediately after that token and a single delimiting space, decrypting each entry independently +with `Type1CharstringDecryption` (charstring key `R0 = 4330`) and discarding that entry's own +`lenIV` leading bytes (default `4`, overridable by an explicit `/lenIV` entry appearing before +`/CharStrings` in the decrypted region). A glyph named `.notdef` is always forced to glyph index +`0` regardless of its position in the `/CharStrings` dictionary, matching `glyf`/CFF-flavored +font convention; every other glyph is indexed in first-seen dictionary order. The `/CharStrings` +scan is bounded by that dictionary's own matching closing `end` keyword - tracked via `begin`/ +`end` nesting depth, rather than scanning unconditionally to the end of the decrypted plaintext - +so trailing font-closing PostScript boilerplate some real-world producers emit immediately after +the dictionary closes (for example `end end readonly put noaccess put dup /FontName get exch +definefont pop mark currentfile closefile`) is never misinterpreted as further glyph entries. +`GetAdvanceWidth` +is derived without fully decoding a glyph's outline, by peeking only as far as that glyph's own +leading `hsbw`/`sbw` operator and its width operand. + +###### Type 1 Charstring Structure (`Type1CharstringInterpreter`) + +Each `/CharStrings` (or `/Subrs`) entry is a sequence of integer-only operands (Type 1 has no +16.16 fixed-point encoding; operator byte `255` is instead a plain big-endian 32-bit signed +integer) and single- or two-byte operator codes, interpreted against the same running-point/ +current-subpath state model as `CffCharstringInterpreter`, but over a different, Type-1-specific +operator set and with no `callsubr`/`callgsubr` bias (Type 1 subroutine indices are used directly): + + +| Operator(s) | Code(s) | Behavior | +| --------------------------------------------------- | --------------- | ---------------------------------------------------------------------------------- | +| `hstem`, `vstem`, `hstem3`, `vstem3`, `dotsection` | 1, 3, 12 2, 12 1, 12 0 | Consumed and discarded; this unit performs no hint execution | +| `hsbw` | 13 | Captures the glyph's left side bearing and advance width, sets the initial point | +| `sbw` | 12 7 | As `hsbw`, but with independent X and Y side bearing/advance components | +| `rmoveto`, `hmoveto`, `vmoveto` | 21, 22, 4 | Starts a new subpath at a general/horizontal-only/vertical-only offset | +| `rlineto`, `hlineto`, `vlineto` | 5, 6, 7 | Appends a line segment; `hlineto`/`vlineto` are axis-only | +| `rrcurveto`, `hvcurveto`, `vhcurveto` | 8, 31, 30 | Appends a cubic Bezier segment, `hvcurveto`/`vhcurveto` alternating start/end axis | +| `closepath` | 9 | Closes the current subpath | +| `callsubr`, `return` | 10, 11 | Invoke/resume a local subroutine by direct (unbiased) index, depth/step bounded | +| `div` | 12 12 | Pops the top two stack values regardless of stack depth, pushes their quotient | +| `callothersubr`, `pop`, `setcurrentpoint` | 12 16, 12 17, 12 33 | Implement real flex geometry (othersubr 1/2/0) and hint replacement (othersubr 3), detailed below | +| `endchar` | 14 | Finishes the outline | + + +`seac` (escape operator `12 6`, the legacy accented-composite-glyph operator) is explicitly +rejected with `InvalidDataException` wherever encountered; any other operator outside this set is +likewise rejected with `InvalidDataException`, as is a charstring that ends before its declared +operand/operator data has been fully read. Flex is implemented as real curve geometry rather than +a no-op: `callothersubr` index `1` begins buffering up to seven subsequent `rmoveto` deltas and +marks the interpreter as "flexing" (during which no point is actually committed to the path +yet); index `2` marks an intermediate flex reference point (a no-op beyond continuing to buffer); +index `0` ends flex, requires exactly the three arguments `[flexHeight, finalX, finalY]` and +exactly seven buffered points, discards the first buffered point (an off-curve reference only), +and converts the remaining six into two `CubicBezierTo` calls - matching the real curve a flex +hint is a rendering-quality annotation for, rather than the polyline of straight `rmoveto` deltas +a naive pass-through would otherwise draw. After ending flex, the interpreter pushes `finalY` then +`finalX` onto a small internal PostScript-operand stack so that the charstring's own subsequent +`pop pop setcurrentpoint` sequence observes `[finalX, finalY]` in the order it expects. Hint +replacement (`callothersubr` index `3`) is treated as transparent pass-through: it pops exactly +one argument (a subroutine number) onto the same internal stack, and the charstring's own +subsequent `pop callsubr` sequence retrieves and invokes that subroutine exactly as if no hint +replacement had occurred, since this unit performs no hint execution of its own. + +##### PFB Segmented Binary Structure (`Type1PfbReader`) + +A `.pfb`-framed Type 1 font file wraps its cleartext and `eexec`-encrypted regions (plus a final +PostScript trailer) in a generic sequence of length-prefixed segments, each headed by +`0x80 <4-byte little-endian length>`, where `` is `0x01` (ASCII/cleartext +segment), `0x02` (binary/encrypted segment), or `0x03` (end-of-file marker, carrying no length or +payload). `Type1PfbReader.Read` loops generically over however many segments the file actually +contains (not a hard-coded three-segment assumption): ASCII segments encountered before the first +binary segment are concatenated, header-stripped, into the cleartext region (`length1`); binary +segments are concatenated, header-stripped, into the encrypted region (`length2`); any further +ASCII segment appearing after the encrypted region (the standard zero-fill/`cleartomark` trailer) +is read past and discarded rather than appended to either region; the loop stops at a `0x03` +marker or the end of the input. `TrySniff` recognizes a `.pfb` file by its leading `0x80` marker +byte. `Read` fails closed with `InvalidDataException` on a truncated segment header or payload, an +unrecognized segment type byte, or a file containing no binary (`0x02`) segment at all. + +##### PFA Hex-Encoded ASCII Structure (`Type1PfaReader`) + +A `.pfa`-framed Type 1 font file instead represents its entire content as printable ASCII, +beginning with `%!`, with the `eexec`-encrypted region hex-encoded (two ASCII hex digits per +encrypted byte, tolerating embedded whitespace/newlines within the hex run) immediately following +the literal keyword `eexec`. `Type1PfaReader.Read` locates that keyword, hex-decodes the run of +hex digits and whitespace immediately following it (any other byte ends the run), trims trailing +zero-padding bytes from the decoded result (a common `.pfa` convention before the trailer), and +takes the cleartext region (`length1`) to be the original file bytes from offset zero through the +end of the literal `eexec` keyword - the cleartext region's own content is never reinterpreted by +this reader, only its byte count matters to `Type1Table.Parse`. `TrySniff` recognizes a `.pfa` +file by its leading `%!` bytes. `Read` fails closed with `InvalidDataException` when the `eexec` +keyword is missing, the decoded hex-digit count is odd, a non-hex/non-whitespace byte is +encountered before any hex digit has been read, or zero bytes remain after trimming trailing +zero-padding. + ##### `cmap` Subtables Used ###### Format 4 (segment mapping to delta values) @@ -182,20 +457,79 @@ format 12, `(0,4)`/`(0,6)` format 12, `(3,1)` format 4, `(0,3)` format 4, then a | 12 | 2 | `rangeShift` | Present; not otherwise interpreted | | 14 | `6 * nPairs` | `pairs` | Sorted `(left, right, value)` entries for search | +##### `name` Table Layout Used + +| Offset | Size | Field | Use in This Unit | +| ------ | ------------ | -------------- | ------------------------------------------------------------- | +| 0 | 2 | `format` | Read but not otherwise interpreted (0 and 1 both accepted) | +| 2 | 2 | `count` | Number of name records; sizes the record array | +| 4 | 2 | `stringOffset` | Start of the string storage area, relative to table start | +| 6 | `12 * count` | `nameRecords` | Each record's platform/encoding/language/nameID/length/offset | + +###### Name Record (12 bytes, big-endian) + +| Offset | Size | Field | Use in This Unit | +| ------ | ---- | ------------ | ------------------------------------------------------------------------ | +| 0 | 2 | `platformID` | `3` (Windows) or `1` (Macintosh) are decoded; all others are ignored | +| 2 | 2 | `encodingID` | Windows: `1`/`10` (Unicode BMP/full); Macintosh: `0` (Roman) | +| 4 | 2 | `languageID` | Preferred: Windows `0x0409` (en-US), Macintosh `0` (English) | +| 6 | 2 | `nameID` | Only `1`, `2`, `4`, `6`, `16`, `17` are consumed; all others are skipped | +| 8 | 2 | `length` | String byte length; an out-of-bounds record is skipped, not the table | +| 10 | 2 | `offset` | String byte offset, relative to `stringOffset` | + +`NameTable` resolves each of `FamilyName`/`SubfamilyName`/`FullName`/`PostScriptName` from +whichever qualifying record best matches, in order: a Windows-platform record in the preferred +language, else any Windows-platform record for that nameID; failing that, a Macintosh-platform +record in the preferred language, else any Macintosh-platform record. `FamilyName` and +`SubfamilyName` prefer the typographic nameID (`16`/`17`) over the standard nameID (`1`/`2`) when +both resolve; `FullName` (`4`) and `PostScriptName` (`6`) have no typographic alternate. A +completely absent, truncated, or otherwise unparsable `name` table yields `NameTable.Empty`, whose +resolved strings are all `null` (surfaced as an all-`null` `FontNameInfo`) rather than throwing. + +##### `OS/2` Table Fields Used (partial; version-0 layout) + +| Offset | Size | Field | Use in This Unit | +| ------ | ---- | --------------- | --------------------------------------------------------------------------- | +| 4 | 2 | `usWeightClass` | `IsBold` is also `true` when this is `>= 600`, even if `fsSelection` is not | +| 62 | 2 | `fsSelection` | Bit 0 (italic) and bit 5 (bold) are OR'd into `IsItalic`/`IsBold` | + +`OS/2` is entirely optional. `StyleTable` gates each field independently on the table being long +enough to contain it (`usWeightClass` needs at least 6 bytes; `fsSelection` needs at least 64), so +a present-but-truncated `OS/2` table contributes only the fields it can actually reach, and an +absent `OS/2` table simply leaves `IsBold`/`IsItalic` to whatever `head.macStyle` and `post` +independently indicate. + +##### `post` Table Fields Used (partial; version 1.0/2.0/3.0 header) + +| Offset | Size | Field | Use in This Unit | +| ------ | ---- | -------------- | ----------------------------------------------------------------- | +| 4 | 4 | `italicAngle` | `IsItalic` is also `true` when this fixed-point value is non-zero | +| 12 | 4 | `isFixedPitch` | `IsFixedPitch` is `true` when this is non-zero | + +`post` is entirely optional; `IsFixedPitch` defaults to `false` and is set only when `post` is +present and at least 16 bytes long. `italicAngle` needs at least 8 bytes to be read; a shorter +`post` table simply does not contribute to `IsItalic`. + #### 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. +Copies the source stream into memory. First checks whether the bytes are a standalone Type 1 font +program framed as `.pfb` (`Type1PfbReader.TrySniff`) or `.pfa` (`Type1PfaReader.TrySniff`); if so, +parses it with `Type1Table.Parse` using the built-in `Type1StandardGlyphNames` default encoding +and returns that font directly. Otherwise, parses the SFNT container (transparently selecting +face 0 if the stream begins with a `ttcf` header), validates every required table for the font's +outline flavor, parses `head`, `maxp`, `hhea`, and `hmtx`, eagerly parses `loca` (glyf-flavored) +or the `CFF` table (CFF-flavored), 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 +- `InvalidDataException` โ€” the SFNT version is unrecognized, an `OTTO`-flavored font is missing + its `CFF` table, a required table is missing or malformed, a table directory entry is out of + bounds or would overflow ordinary arithmetic, the stream is truncated, (CFF-flavored only) the + CFF data is CID-keyed, structurally malformed, or its CharStrings count disagrees with `maxp`, + or (recognized as `.pfb`/`.pfa`-framed) the Type 1 font program data is structurally malformed ##### Load(string path) @@ -208,6 +542,83 @@ Opens `path` as a read-only `FileStream` and delegates to `Load(Stream)`. - `InvalidDataException` โ€” see `Load(Stream)` - Underlying file-system exceptions propagate uncaught +##### Load(Stream stream, int faceIndex) + +As `Load(Stream)`, but selects an explicit `faceIndex` rather than defaulting to face 0. If the +stream begins with a `ttcf` header, `faceIndex` is validated against that header's own declared +face count and used to look up that face's own SFNT offset table start; otherwise the file is +treated as a single-face SFNT and only `faceIndex == 0` is accepted. + +**Throws:** + +- `ArgumentNullException` โ€” `stream` is null +- `ArgumentOutOfRangeException` โ€” `faceIndex` is negative, or is not less than the file's own + face count +- `InvalidDataException` โ€” see `Load(Stream)`, plus a truncated or otherwise malformed `ttcf` + header + +##### Load(string path, int faceIndex) + +Opens `path` as a read-only `FileStream` and delegates to `Load(Stream, int)`. + +**Throws:** as `Load(Stream, int)`, plus `ArgumentNullException`/`ArgumentException` for a null +or empty `path` exactly as `Load(string)`. + +##### LoadType1(Stream stream, int length1, int length2, codepointToGlyphName) + +Loads a classic PostScript Type 1 font program from its raw cleartext (`length1` bytes) and +`eexec`-encrypted (`length2` bytes) byte segments, exactly as they would be extracted from a +PDF Type1 font's own `FontFile` stream, together with a caller-supplied codepoint-to-glyph-name +encoding. Copies `stream` into memory, parses it with `Type1Table.Parse(bytes, length1, length2)`, +and builds a `TrueTypeFont` around the resulting `Type1Table` (via the shared internal +`BuildFromType1Table` helper also used by standalone `.pfb`/`.pfa` auto-detection), synthesizing +its `CmapTable` from `codepointToGlyphName` resolved against `Type1Table.TryGetGlyphIndex` and its +`HmtxHheaReader` from every glyph's own `GetAdvanceWidth`. + +**Throws:** + +- `ArgumentNullException` โ€” `stream` or `codepointToGlyphName` is null +- `InvalidDataException` โ€” `length1`/`length2` are negative, exceed the stream's own length, or + the Type 1 font program data itself (the `/Subrs`/`/CharStrings` structure or any glyph's own + charstring, including a charstring using `seac` or another unsupported operator) is malformed + +##### LoadType1C(Stream stream, codepointToGlyphName) + +Loads a bare Type1C/CFF font program (a raw CFF table with no SFNT/OpenType wrapper of its own, as +would be extracted from a PDF Type1 font's own `FontFile3` stream), together with a +caller-supplied codepoint-to-glyph-name encoding - the structural counterpart of `LoadType1` for +fonts whose embedded program is itself CFF-flavored rather than a classic Type 1 program. Copies +`stream` into memory, parses it with `CffTable.Parse(bytes, tableOffset: 0, tableLength: bytes.Length)`, +and builds a `TrueTypeFont` around the resulting `CffTable` (via the dedicated internal +`BuildFromCffTable` helper - deliberately not shared with `BuildFromType1Table`, since +`CffTable.GetAdvanceWidth` returns `double` while `Type1Table.GetAdvanceWidth` returns `int`, +requiring a rounding step this helper owns), synthesizing its `CmapTable` from +`codepointToGlyphName` resolved against `CffTable.TryGetGlyphIndex` and its `HmtxHheaReader` from +every glyph's own `GetAdvanceWidth`, with a fixed 1000-unit em square (`CffTable` carries no +`head`/`unitsPerEm` table of its own). + +**Throws:** + +- `ArgumentNullException` โ€” `stream` or `codepointToGlyphName` is null +- `InvalidDataException` โ€” the CFF table data is malformed (see `CffTable.Parse`), including a + CID-keyed (`ROS`-declaring) CFF program, which `CffTable.Parse`'s existing CID-keyed rejection + surfaces uncaught through this path with no new translation code + +##### GetFaceCount(Stream stream) / GetFaceCount(string path) + +Reads only enough of the stream to distinguish a `ttcf` container from an ordinary SFNT font: the +first 4 bytes (the tag), and - only if that tag is `ttcf` - the header's `numFonts` field. Returns +`1` for an ordinary SFNT font, for a standalone `.pfb`/`.pfa`-framed Type 1 font file (neither of +which begins with the `ttcf` tag), or the container's own declared face count for a `ttcf` file. +Neither overload parses any face's own table directory. + +**Throws:** + +- `ArgumentNullException` โ€” `stream`/`path` is null (`GetFaceCount(string)` also rejects an empty + `path` with `ArgumentException`) +- `InvalidDataException` โ€” the stream is too short to contain a tag, or (for a `ttcf`-tagged + file) the `ttcf` header itself is truncated or malformed + ##### GetGlyphIndex(int codepoint) Uses the selected `cmap` subtable to map a Unicode codepoint to a glyph index. @@ -219,21 +630,32 @@ Uses the selected `cmap` subtable to map a Unicode codepoint to a glyph index. ##### 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. +Validates `glyphIndex`, then asks the loaded font's `IGlyphOutlineSource` (`GlyfLocaReader` or +`CffTable`) to lazily decode that glyph's outline. Glyf-flavored simple glyphs become `LineTo` / +`QuadraticBezierTo` commands and composite glyphs recursively reuse other glyphs' decoded +outlines after applying the component transform; CFF-flavored glyphs become `LineTo` / +`CubicBezierTo` commands decoded by `CffCharstringInterpreter` from that glyph's own Type 2 +charstring. **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 + the nesting-depth or total-component bound, a point-matched component is encountered, a + component glyph index is out of range, or (CFF-flavored) the glyph's charstring uses an operator + outside the supported set, is truncated, exceeds the subroutine call depth/step bound, or + references an out-of-range subroutine index ##### 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`. +the last explicit advance-width entry for any tail glyph beyond `numOfLongHorMetrics`. For a font +loaded via `Load` (SFNT/OpenType, including CFF-flavored `OTTO`), `hmtx` is the font's own parsed +table (even for a CFF-flavored font, the CFF-native width resolved by `CffTable.GetAdvanceWidth` +described under Type 2 Charstring Structure above is unused, superseded by this required `hmtx` +table). For a font loaded via `LoadType1` or `LoadType1C` (neither of which carries its own +`hmtx`), `hmtx` is instead synthesized at load time via `HmtxHheaReader.FromAdvanceWidths` from +every glyph's own `Type1Table.GetAdvanceWidth`/`CffTable.GetAdvanceWidth`. **Throws:** @@ -248,41 +670,114 @@ Binary-searches the selected `kern` format-0 pair array. - 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 +##### GetNameInfo() + +Returns a `FontNameInfo` populated from the loaded font's `name` table, resolving `FamilyName`, +`SubfamilyName`, `FullName`, and `PostScriptName` per the platform/nameID preference order +described under `name` Table Layout Used above. + +**Throws:** + +- Never throws; a member whose corresponding record cannot be resolved (including every member, + for a font with no `name` table at all) is `null` + +##### IsBold / IsItalic / IsFixedPitch + +Read-only properties computed once at load time by `StyleTable` from whichever of `OS/2.fsSelection` +/ `OS/2.usWeightClass`, `head.macStyle`, and `post.italicAngle` / `post.isFixedPitch` are present, +per the derivation rules described under the `OS/2` and `post` table-layout subsections above. + +**Throws:** + +- Never throws; each property degrades to `false` when its underlying optional table(s) are + absent or too short to contain the relevant field + ##### Internal Helper Roles - `SfntContainer` parses the offset table and table directory, enforces overflow-safe bounds - checks, and hosts the shared big-endian primitive readers + checks, hosts the shared big-endian primitive readers, and recognizes/parses `ttcf` collection + headers via `TryReadTtcHeader` - `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 +- `GlyfLocaReader` (an `IGlyphOutlineSource`) eagerly parses `loca`, then lazily decodes simple + and composite `glyf` glyphs +- `CffTable` (an `IGlyphOutlineSource`) parses the CFF Header/INDEXes/Private DICT/charset, then + lazily decodes each glyph's Type 2 charstring via `CffCharstringInterpreter`, exposing + `TryGetGlyphIndex` (glyph-name resolution via the parsed charset) and `GetAdvanceWidth` (the + CFF-native `defaultWidthX`/`nominalWidthX`-resolved width) +- `CffCharstringInterpreter` executes a single glyph's Type 2 charstring bytecode against the + supported operator subset, returning both the decoded outline and the glyph's resolved advance + width +- `Type1Table` (an `IGlyphOutlineSource`) decrypts the `eexec` region, scans it for `/Subrs` and + `/CharStrings` entries in a procedure-name-agnostic way, then lazily decodes each glyph's Type 1 + charstring via `Type1CharstringInterpreter` +- `Type1CharstringInterpreter` executes a single glyph's Type 1 charstring bytecode against the + supported operator subset, including real flex geometry and hint-replacement pass-through +- `Type1CharstringDecryption` implements the shared `eexec`/charstring LCG decryption algorithm + used by both the `eexec` region itself and each individual `/Subrs`/`/CharStrings` entry +- `Type1PfbReader` / `Type1PfaReader` auto-detect and reassemble a standalone `.pfb`/`.pfa`-framed + Type 1 font file into the same cleartext/`length1`/`length2` byte-segment shape `Type1Table.Parse` + and `LoadType1` already consume +- `Type1StandardGlyphNames` supplies the small, curated, built-in codepoint-to-glyph-name + vocabulary used as the default encoding for standalone `.pfb`/`.pfa` auto-detection +- `HmtxHheaReader` parses top-level typographic metrics and advance widths (from `hmtx`, or + synthesized via `FromAdvanceWidths` for a Type 1 font program or a bare Type1C/CFF program) - `KernTable` tolerantly parses the first qualifying horizontal format-0 subtable, if any +- `NameTable` tolerantly parses the `name` table's records and resolves the platform/nameID + preference order into `FamilyName`/`SubfamilyName`/`FullName`/`PostScriptName` +- `StyleTable` derives `IsBold`/`IsItalic`/`IsFixedPitch` from whichever of `OS/2`, `head.macStyle`, + and `post` are present #### 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` | +| Member | Exception | Condition | +| ---------------------- | ------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | +| `Load(Stream)` | `ArgumentNullException` | `stream` is null | +| `Load(Stream)` | `InvalidDataException` | Bad SFNT version, missing required table, truncation, invalid CFF data, or malformed Type 1 data | +| `Load(string)` | `ArgumentNullException` | `path` is null | +| `Load(string)` | `ArgumentException` | `path` is empty | +| `Load(string)` | `InvalidDataException` | Same font-structure failures as `Load(Stream)` | +| `Load(Stream, int)` | `ArgumentNullException` | `stream` is null | +| `Load(Stream, int)` | `ArgumentOutOfRangeException` | `faceIndex` is negative or not less than the file's own face count (always `1` for standalone Type 1) | +| `Load(Stream, int)` | `InvalidDataException` | As `Load(Stream)`, plus a malformed `ttcf` header | +| `Load(string, int)` | `ArgumentNullException`/`ArgumentException` | Null/empty `path`, as `Load(string)` | +| `Load(string, int)` | `ArgumentOutOfRangeException` | As `Load(Stream, int)` | +| `Load(string, int)` | `InvalidDataException` | As `Load(Stream, int)` | +| `LoadType1(...)` | `ArgumentNullException` | `stream` or `codepointToGlyphName` is null | +| `LoadType1(...)` | `InvalidDataException` | `length1`/`length2` negative or exceed stream length, or Type 1 data (including `seac`) is malformed | +| `LoadType1C(...)` | `ArgumentNullException` | `stream` or `codepointToGlyphName` is null | +| `LoadType1C(...)` | `InvalidDataException` | Malformed CFF table data, including a CID-keyed (`ROS`-declaring) CFF program | +| `GetFaceCount(Stream)` | `ArgumentNullException` | `stream` is null | +| `GetFaceCount(Stream)` | `InvalidDataException` | Stream too short for a tag, or a malformed `ttcf` header | +| `GetFaceCount(string)` | `ArgumentNullException`/`ArgumentException` | Null/empty `path` | +| `GetFaceCount(string)` | `InvalidDataException` | As `GetFaceCount(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 or charstring data, or a rejected composite-glyph/CFF-operator feature | +| `GetAdvanceWidth(int)` | `ArgumentOutOfRangeException` | `glyphIndex` is outside `[0, GlyphCount)` | +| `GetKerning(int, int)` | none | Missing pair/table or bad kerning data returns `0` | +| `GetNameInfo()` | none | Unresolvable name members (including every member, absent `name` table) are `null` | +| `IsBold` | none | Degrades to `false` when `OS/2`/`head.macStyle` cannot indicate boldness | +| `IsItalic` | none | Degrades to `false` when `OS/2`/`head.macStyle`/`post` cannot indicate italics | +| `IsFixedPitch` | none | Degrades to `false` when `post` is absent or too short | ##### 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`. +`head`, `maxp`, and `hhea`/`hmtx` are required for every font regardless of outline flavor, +because `TrueTypeFont` cannot report top-level metrics without them. A glyf-flavored font (`maxp` +version `0x00010000`) additionally requires `loca` and `glyf`; a CFF-flavored (`OTTO`-tagged) +font (`maxp` version `0x00005000`) instead requires `CFF` and does **not** require `loca`/`glyf` +at all. By contrast, `cmap`, `kern`, `name`, `OS/2`, and `post` are all intentionally lenient for +either flavor. 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`. Similarly, a missing or malformed +`name`, `OS/2`, or `post` table never prevents the font from loading or being queried for +metrics/outlines: `GetNameInfo()` degrades to `null` members and `IsBold`/`IsItalic`/ +`IsFixedPitch` degrade to `false` for whichever source table is absent or unusable. ##### Overflow-Safe Structural Validation @@ -320,16 +815,75 @@ Consecutive off-curve points synthesize the implied on-curve midpoint required b a contour that begins off-curve is closed through an implied on-curve start point built from the first and last control points. +##### CFF Subroutine Call Bounds + +`CffCharstringInterpreter` enforces the same deterministic-limit posture `GlyfLocaReader` applies +to composite `glyf` glyphs, adapted to Type 2 charstring subroutine calls: a maximum `callsubr`/ +`callgsubr` nesting depth, and a maximum total executed charstring-operator step count charged +across a single glyph's decode. The depth limit catches true self-recursive subroutine cycles; the +total-step budget catches non-cyclic but excessively long or repetitive subroutine call chains +that depth bounding alone would not stop. An out-of-range subroutine index (after bias +adjustment) is rejected immediately with `InvalidDataException` rather than silently clamped, so a +malformed or malicious CFF font cannot redirect execution into unrelated table bytes. + +##### Cubic Contour Conversion + +CFF/Type 2 charstrings are natively cubic, so `TrueTypeFont` (via `CffCharstringInterpreter`) +converts them directly to `PathBuilder.CubicBezierTo` rather than approximating with quadratic +segments - the reverse of the glyf-flavored decoder's natively-quadratic posture. Every subpath +opened by a moveto operator is closed either by an explicit path close or by `endchar`, matching +the fill-rule expectations `Drawing.PathFiller` already applies to `glyf`-flavored outlines. + +##### CFF Seac-Style Accent Composition + +The deprecated 4-operand form of `endchar` (`adx ady bchar achar endchar`, the Type 2 charstring +successor to the original Type 1 `seac` operator) composes an accented glyph from a "base" glyph +(e.g. `A`) and an "accent" glyph (e.g. `grave`), both identified by Adobe StandardEncoding code +rather than glyph index. `CffCharstringInterpreter.Decode` accepts an optional +`resolveStandardEncodedGlyph` callback; when supplied, a 4-operand `endchar` resolves `bchar` and +`achar` through it to obtain each component's own already-decoded outline, translates the accent +outline by `(adx, ady)`, and combines it with the base outline (at its own origin, untranslated) to +form the composite glyph's final outline. The composite's own advance width still follows the +normal width convention (its own leading width operand, or `defaultWidthX` if absent) and never +inherits the base/accent glyphs' own widths. + +`CffTable` supplies this callback (`ResolveSeacComponent`) by mapping `bchar`/`achar` through +`CffStandardEncoding.CodeToGlyphName` to a glyph name, resolving that name to a glyph index in the +same font via `TryGetGlyphIndex`, and recursively decoding that glyph's own charstring - +deliberately *without* passing a resolver of its own. This means a seac component glyph that is +itself (illegally) defined using the seac-style form is rejected with `InvalidDataException` +rather than recursed into, structurally bounding seac composition to a single level without a +separate depth counter. The same exception is thrown when no resolver is supplied at all (the +form is otherwise unreachable), when `bchar`/`achar` is not a valid StandardEncoding code, or when +the resolved glyph name is not present in this font's charset. + +##### Type 1 Subroutine Call Bounds and Fail-Closed Unsupported Operators + +`Type1CharstringInterpreter` applies the same deterministic-limit posture as +`CffCharstringInterpreter`: a maximum `callsubr` nesting depth and a maximum total executed +charstring-operator step count, both charged per single glyph decode, catching self-recursive +subroutine cycles and excessively long/repetitive non-cyclic call chains alike. Unlike Type 2 +charstrings, Type 1 `callsubr` addresses subroutines directly with no bias adjustment. `seac` +(the legacy accented-composite-glyph operator) and every operator outside the supported Type 1 +set are rejected immediately with `InvalidDataException` the moment they are encountered, rather +than being silently skipped or approximated, so a font relying on either cannot produce a +silently-wrong outline. + #### 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. +The unit has no dependency on `Canvas`, `Drawing`, or any runtime NuGet package. This holds +equally for the Type 1 support added by `Type1Table`, `Type1CharstringInterpreter`, +`Type1CharstringDecryption`, `Type1PfbReader`, `Type1PfaReader`, and `Type1StandardGlyphNames`: +all six are pure byte-array/`Geometry` code with no additional dependency of their own. #### 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`. +immediate callers are the `Fonts` unit tests, the real-font integration tests in +`TrueTypeFontRealFontIntegrationTests.cs` (real `.ttf`, real CFF/OpenType `.otf`, and a +locally-assembled multi-face `.ttc`), 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/canvas-net/geometry.md b/docs/design/canvas-net/geometry.md index d3872a65..4ff5232c 100644 --- a/docs/design/canvas-net/geometry.md +++ b/docs/design/canvas-net/geometry.md @@ -14,10 +14,11 @@ The `Geometry` subsystem groups the software units responsible for describing sh paths made of lines, curves, and arcs, and the bounding boxes and flattened polylines derived from them - with no notion of pixels, color, or rasterization. It has no dependency on any other subsystem: the `Canvas` and `Codecs` subsystems have no dependency on `Geometry`, and `Geometry` -has no dependency on either of them. `Geometry` is deliberately distinct from the reserved -`Drawing` subsystem: `Geometry` is where shape geometry is described and measured; the future -`Drawing` subsystem will be where that geometry is turned into pixels on a `Canvas.Surface` -(rasterization, brushes, pens, and stroke/fill styling). +has no dependency on either of them. `Geometry` is deliberately distinct from the `Drawing` +subsystem: `Geometry` is where shape geometry is described and measured; `Drawing` is where that +geometry is turned into pixels on a `Canvas.Surface` (rasterization and solid/gradient/tile-paint +fill, and stroke-to-fill conversion) by consuming `Geometry`'s `Path`/`PathBuilder`, +`BezierFlattening`, and `SvgArcConverter` units - see _Drawing Subsystem Design_ (`drawing.md`). ### Units @@ -54,9 +55,10 @@ used throughout. ### Callers `Rect`, `PathBuilder`/`Path`, `BezierFlattening`, and `SvgArcConverter` are all public API entry -points, invoked externally by consumers of the CanvasNet package. `Geometry` has no runnable -end-to-end example yet within this library, since no rasterizer (the reserved `Drawing` -subsystem) exists yet to consume a `Path` and turn it into pixels on a `Canvas.Surface`; it is -exercised end to end only by this subsystem's own unit and system-integration tests. The -`Geometry` subsystem itself has no dependency on `Canvas`, `Codecs`, or the reserved `Drawing` -subsystem. +points, invoked externally by consumers of the CanvasNet package. The `Drawing` subsystem is the +primary internal caller of this subsystem's output: `Drawing.PathFiller` and `Drawing.PathStroker` +consume `Path`/`PathBuilder` (and, via `Path.GetBounds`, `BezierFlattening` and +`SvgArcConverter` indirectly) to rasterize and stroke-to-fill vector geometry onto a +`Canvas.Surface`; `Geometry.CornerRoundEffect` is in turn consumed by `Drawing.PathFiller` and +`Drawing.PathStroker` callers that want rounded polyline corners before filling or stroking a +path. The `Geometry` subsystem itself has no dependency on `Canvas`, `Codecs`, or `Drawing`. diff --git a/docs/design/canvas-net/geometry/bezier-flattening.md b/docs/design/canvas-net/geometry/bezier-flattening.md index 424a72b2..8102a09a 100644 --- a/docs/design/canvas-net/geometry/bezier-flattening.md +++ b/docs/design/canvas-net/geometry/bezier-flattening.md @@ -1,4 +1,4 @@ -## BezierFlattening +### BezierFlattening ![Geometry Structure](GeometryView.svg) @@ -6,7 +6,7 @@ The `BezierFlattening` class is a software unit in the `Geometry` subsystem. It adaptive, tolerance-driven flattening of quadratic and cubic Bezier curves into polylines, writing the resulting points into a caller-supplied output collection. -### Purpose +#### Purpose Rendering, precise bounds computation, and other consumers of vector geometry frequently need to convert a smooth curve into a sequence of straight-line segments within a controllable accuracy @@ -14,11 +14,20 @@ budget. `BezierFlattening` provides this for both quadratic and cubic Bezier cur recursive adaptive subdivision so flatter regions of a curve receive fewer segments than sharply curved regions, for a given error tolerance. -### Key Methods +#### Data Model -#### FlattenCubic(Vector2 p0, Vector2 p1, Vector2 p2, Vector2 p3, float tolerance, IList\ output) +`BezierFlattening` is a stateless static class with no instance fields. Its only internal state +is the `private const int MaxRecursionDepth = 20` termination bound (see Key Methods below); it +holds no other fields, properties, or invariants. Both entry points take and return plain +`System.Numerics.Vector2` control points and a caller-supplied `IList output` collection -#### FlattenQuadratic(Vector2 p0, Vector2 p1, Vector2 p2, float tolerance, IList\ output) +- there is no other data model to document. + +#### Key Methods + +##### FlattenCubic(Vector2 p0, Vector2 p1, Vector2 p2, Vector2 p3, float tolerance, IList\ output) + +##### FlattenQuadratic(Vector2 p0, Vector2 p1, Vector2 p2, float tolerance, IList\ output) Appends the flattened polyline points for the given cubic (`p0`-`p1`-`p2`-`p3`) or quadratic (`p0`-`p1`-`p2`) Bezier curve to `output`, via recursive adaptive subdivision (de Casteljau @@ -92,17 +101,17 @@ decision above: `MaxRecursionDepth` bounds worst-case output size and guarantees every input, but for the rare pathological input that hits the limit, the tolerance contract does not apply. -### Error Handling +#### Error Handling The single `tolerance <= 0` guard above is this unit's only validation; every other combination of finite `Vector2` control points is accepted. -### Dependencies +#### Dependencies `BezierFlattening` depends only on `System.Numerics.Vector2` (in-box BCL type, no new NuGet package) and `System.Collections.Generic.IList` from the .NET Base Class Library. -### Callers +#### Callers `BezierFlattening` is a public API entry point, invoked externally by consumers of the CanvasNet package. It is also invoked internally by `Path.GetBounds`'s flattening mode (a positive diff --git a/docs/design/canvas-net/geometry/corner-round-effect.md b/docs/design/canvas-net/geometry/corner-round-effect.md index 1392a137..834d83fa 100644 --- a/docs/design/canvas-net/geometry/corner-round-effect.md +++ b/docs/design/canvas-net/geometry/corner-round-effect.md @@ -3,7 +3,7 @@ The `CornerRoundEffect` class is a stateless static class in the `Geometry` subsystem that returns a new `Path` in which polyline corners are replaced by tangent-radius arcs. -#### Rationale for a path-level effect +#### Purpose Corner rounding is applied at the `Path` level (a pre-processing pass on the immutable path) rather than inside the stroking pipeline. Placing it here composes cleanly with downstream @@ -15,7 +15,17 @@ Dashing itself is a post-flatten step inside `PathStroker` (it operates on flatt segments), so the only architecturally valid composition point for corner rounding is before the fill/stroke pipeline. `CornerRoundEffect` occupies that position. -#### Algorithm +#### Data Model + +`CornerRoundEffect` is a stateless static class with no instance fields and no supporting types +of its own. `Apply`'s only parameters are a `Path source` and a `float radius`; its private +helpers (`ComputeWrapAroundCorner`, `ClampRadius`) operate purely on `Vector2` points and +`Subpath`/`PathCommand` values already defined by the `Path` unit. There is no other data model +to document. + +#### Key Methods + +##### Apply(Path source, float radius) For every command in the source path, if command `i` is a `LineTo` and command `i + 1` is also a `LineTo`, the corner is: @@ -49,8 +59,21 @@ Corners involving a curved segment (cubic or quadratic Bezier or arc) are left u This documented policy avoids the derivative-matching logic that would be required to round curve tangents cleanly. -#### Validation +#### Error Handling - `Apply` throws `ArgumentNullException` on a null source path. - `Apply` throws `ArgumentOutOfRangeException` on a negative or non-finite radius. - A zero radius returns the source path unchanged (no allocation). + +#### Dependencies + +`CornerRoundEffect` depends only on the `Geometry` subsystem's own `Path`, `PathBuilder`, +`Subpath`, and `PathCommand`/`PathCommandType` types, and on `System.Numerics.Vector2` from the +.NET Base Class Library. It has no dependency on `Drawing`, `Canvas`, or any other subsystem. + +#### Callers + +`CornerRoundEffect` is a public API entry point, invoked externally by consumers of the +CanvasNet package that want rounded-corner polylines (for example before passing the result to +`Drawing.PathFiller`/`Drawing.PathStroker`). No unit within `Geometry` itself calls +`CornerRoundEffect`. diff --git a/docs/design/canvas-net/geometry/path.md b/docs/design/canvas-net/geometry/path.md index 9a80d44d..ea5197aa 100644 --- a/docs/design/canvas-net/geometry/path.md +++ b/docs/design/canvas-net/geometry/path.md @@ -1,4 +1,4 @@ -## Path / PathBuilder +### Path / PathBuilder ![Geometry Structure](GeometryView.svg) @@ -7,7 +7,7 @@ an immutable vector path: an ordered collection of independent `Subpath` contour ordered sequence of `PathCommand` drawing commands. `PathBuilder` is the mutable, fluent builder that is the only way to construct a `Path`. -### Purpose +#### Purpose `Path` represents an arbitrary vector shape - straight lines, quadratic and cubic Bezier curves, SVG-style elliptical arcs, and closed contours, potentially several disjoint subpaths at once - @@ -17,7 +17,7 @@ drawing commands issued via a fluent (`this`-returning) API and produces `Path` builder instance can be reused across many paths without per-path allocation overhead in a hot rendering loop. -### Data Model +#### Data Model | Type | Description | | ----------------- | ----------------------------------------------------------------------------- | @@ -62,14 +62,14 @@ performed lazily, only when a consumer actually needs it (for example, `Path.Get never needs Bezier segments (for example, a hit-testing implementation with its own arc math) never pays for the conversion. -### Key Methods +#### Key Methods -#### PathBuilder.MoveTo(Vector2 point) +##### PathBuilder.MoveTo(Vector2 point) Starts a new subpath at `point`. If a previous subpath is still open (no `Close` issued), it is committed as-is (its `IsClosed` remains `false`) before the new subpath begins. Always succeeds. -#### PathBuilder.LineTo/QuadraticBezierTo/CubicBezierTo/ArcTo(...) +##### PathBuilder.LineTo/QuadraticBezierTo/CubicBezierTo/ArcTo(...) Appends the corresponding `PathCommand` to the current subpath. @@ -78,7 +78,7 @@ Appends the corresponding `PathCommand` to the current subpath. - `InvalidOperationException` - when called before the first `MoveTo`, or after `Close` without an intervening `MoveTo` (there is no current point to continue from in either case) -#### PathBuilder.Close() +##### PathBuilder.Close() Appends a `Close` command to the current subpath and marks it closed (`IsClosed = true` on the resulting `Subpath`). A `Close` command carries no `EndPoint` of its own - a closed subpath @@ -88,14 +88,14 @@ always returns to its own `Start`. - `InvalidOperationException` - under the same conditions as the drawing commands above -#### PathBuilder.Build() +##### PathBuilder.Build() Returns an immutable `Path` snapshot of every subpath recorded so far (including the currently-open subpath, if any, uncommitted state included). The builder remains usable afterward: further commands (and further `Build()` calls) do not affect a previously returned `Path`. If no commands have ever been issued, returns a `Path` with zero `Subpaths`. -#### PathBuilder.Clear() +##### PathBuilder.Clear() Resets the builder to the same empty state as a freshly constructed instance, so it can be reused to build further, unrelated paths without allocating a new builder instance. The internal @@ -103,12 +103,12 @@ current-commands buffer is reset via its own `Clear()` method rather than being new list, preserving its already-grown capacity across builds - so repeatedly building similarly-sized paths in a loop does not repeatedly reallocate that buffer. -#### Path.Empty +##### Path.Empty A static singleton `Path` with zero `Subpaths`. `Path.Empty.GetBounds()` returns `Rect.Empty`, matching `Rect`'s own union-identity convention. -#### Path.GetBounds(float flattenTolerance = 0) +##### Path.GetBounds(float flattenTolerance = 0) Computes an axis-aligned bounding `Rect` for the whole path, as the `Rect.Union` of every subpath's own contribution. @@ -128,7 +128,7 @@ subpath's own contribution. Never throws. -#### PathCommand.LineTo(end) +##### PathCommand.LineTo(end) A public static factory constructing a standalone `LineTo` command, independent of `PathBuilder`. Unlike every other factory on this type, this one is public: a consumer outside @@ -137,7 +137,7 @@ needs to synthesize a straight-line command of its own - for example, to represe subpath's implicit closing edge, which is never itself recorded as its own `PathCommand` (a `Close` command carries no `EndPoint`). -#### PathCommand.ComputeTangents(start) +##### PathCommand.ComputeTangents(start) Computes this command's outgoing (leaving `start`) and incoming (arriving at `EndPoint`) unit tangent directions. For `LineTo`, both tangents are the normalized direction of travel. For @@ -156,7 +156,7 @@ future format codec package (for example, a PDF, PPTX, or VSDX codec rendering i arrowheads) needing the same tangent/direction computation can call this same public API instead of reimplementing it. -### Error Handling +#### Error Handling `PathBuilder`'s drawing-command guard (`InvalidOperationException` before the first `MoveTo`, or after `Close` without an intervening `MoveTo`) is the only validation this unit performs; every @@ -164,14 +164,14 @@ other input (any finite `Vector2`, any `float` rotation, any `bool` flag combina without further checking. `Path` itself performs no validation - it can only ever be constructed, internally, from a well-formed `PathBuilder` snapshot (or as the `Empty` singleton). -### Dependencies +#### Dependencies `Path`/`PathBuilder` depend only on `System.Numerics.Vector2` (in-box BCL type, no new NuGet package) and, within this subsystem, on `Rect` (the return type of `GetBounds`), `BezierFlattening`, and `SvgArcConverter` (both used internally by `GetBounds`'s flattening mode and by its `ArcTo` handling, respectively). -### Callers +#### Callers `PathBuilder` and `Path` are public API entry points, invoked externally by consumers of the CanvasNet package. `Path` has no dependency on any consumer; within this repository, `Path` is diff --git a/docs/design/canvas-net/geometry/rect.md b/docs/design/canvas-net/geometry/rect.md index 1cf5884f..5678c6ed 100644 --- a/docs/design/canvas-net/geometry/rect.md +++ b/docs/design/canvas-net/geometry/rect.md @@ -1,4 +1,4 @@ -## Rect +### Rect ![Geometry Structure](GeometryView.svg) @@ -7,7 +7,7 @@ axis-aligned bounding rectangle using the position-plus-size convention (`X`, `Y `Height`, all `float`), reusing `System.Numerics.Vector2` for its point/size-valued members and `System.Numerics.Matrix3x2` for its `Transform` operation. -### Purpose +#### Purpose `Rect` is the common bounding-box representation used throughout the `Geometry` subsystem - most notably as the return type of `Path.GetBounds`. It provides computed edge/corner properties, @@ -15,7 +15,7 @@ containment testing, union, intersection, matrix transformation, and value equal carefully chosen `Empty` sentinel that makes folding `Union` over zero or more contributing rectangles always produce the mathematically correct result. -### Data Model +#### Data Model | Field/Property | Type | Description | | -------------------- | ---------------------- | ---------------------------------------------------------- | @@ -30,7 +30,7 @@ rectangles always produce the mathematically correct result. | `BottomRight` | `Vector2` | Computed; `(Right, Bottom)`. | | `Empty` | `static readonly Rect` | The union-identity sentinel (see below). | -### The `Empty` Sentinel +#### The `Empty` Sentinel **Architectural decision**: `Empty` is represented as `X = Y = float.PositiveInfinity`, `Width = Height = float.NegativeInfinity` - a WPF-style "union identity" sentinel - rather than @@ -54,9 +54,9 @@ involving `Empty` (propagating `NaN` through the subsequent `Math.Min`/`Math.Max `float.IsNegativeInfinity(Height)` and return `float.NegativeInfinity` directly in that case, matching the "inverted extent" behavior the rest of this type's design relies on. -### Key Methods +#### Key Methods -#### Contains(Vector2 point) +##### Contains(Vector2 point) Tests whether `point` lies within the rectangle using a half-open interval on both axes: the left/top edges are included, the right/bottom edges are excluded @@ -64,13 +64,13 @@ left/top edges are included, the right/bottom edges are excluded extent (`Left` greater than `Right`) makes every comparison fail for any finite point, so `Empty` correctly contains no points. Never throws. -#### Union(Rect) / Union(Rect, Rect) +##### Union(Rect) / Union(Rect, Rect) Returns the smallest rectangle enclosing both input rectangles, computed as `(min(Left), min(Top))` to `(max(Right), max(Bottom))`. Available as both an instance method (`this.Union(other)`) and a static method (`Rect.Union(a, b)`). Never throws. -#### Intersect(Rect) / Intersect(Rect, Rect) +##### Intersect(Rect) / Intersect(Rect, Rect) Returns the overlapping region of both input rectangles, computed as `(max(Left), max(Top))` to `(min(Right), min(Bottom))`. If the rectangles are disjoint on either axis, including the boundary @@ -79,7 +79,7 @@ case where they merely touch along an edge with no actual overlapping area (`rig non-canonical negative size or a zero-width/zero-height non-`Empty` rectangle. Available as both an instance method and a static method. Never throws. -#### Transform(Matrix3x2 matrix) +##### Transform(Matrix3x2 matrix) Returns the smallest axis-aligned rectangle enclosing this rectangle after applying `matrix`. @@ -98,7 +98,7 @@ instead of `Empty`. `Transform` therefore checks `IsEmpty` first and returns `Em preserving it as an identity/no-op - the same guard philosophy already applied to `Right`/`Bottom` and `Intersect`. -#### Equals(Rect) / Equals(object?) / GetHashCode / == / != +##### Equals(Rect) / Equals(object?) / GetHashCode / == / != Value equality compares the raw bit pattern of each of the four fields (`X`, `Y`, `Width`, `Height`) via `BitConverter.SingleToInt32Bits`, rather than `==`/tolerance-based comparison. This @@ -109,24 +109,24 @@ raw bits (rather than `==`) also avoids a SonarAnalyzer S1244 diagnostic (float the call site, since the intent here is documented exact snapshot equality rather than a numerically fragile result comparison. -#### ToString() +##### ToString() Returns a human-readable string in the form `"{X=.., Y=.., Width=.., Height=..}"`, for diagnostic output. -### Error Handling +#### Error Handling `Rect` never throws from any public member; every input is either arithmetically well-defined (a `float` value, however large, small, `NaN`, or infinite) or produces `Empty` for a would-be-invalid result (a disjoint `Intersect`), rather than raising an exception. -### Dependencies +#### Dependencies `Rect` depends only on `System.Numerics.Vector2` and `System.Numerics.Matrix3x2` (in-box BCL types, no new NuGet package) and `System.BitConverter`/`System.HashCode` from the .NET Base Class Library. -### Callers +#### Callers `Rect` is a public API entry point, invoked externally by consumers of the CanvasNet package. It is also invoked internally by `Path.GetBounds`, which returns a `Rect` computed by folding diff --git a/docs/design/canvas-net/geometry/svg-arc-converter.md b/docs/design/canvas-net/geometry/svg-arc-converter.md index d43c7167..050462d2 100644 --- a/docs/design/canvas-net/geometry/svg-arc-converter.md +++ b/docs/design/canvas-net/geometry/svg-arc-converter.md @@ -1,4 +1,4 @@ -## SvgArcConverter +### SvgArcConverter ![Geometry Structure](GeometryView.svg) @@ -6,7 +6,7 @@ The `SvgArcConverter` class is a software unit in the `Geometry` subsystem. It c SVG-style endpoint-parameterized elliptical arcs into one or more cubic Bezier curves, following the SVG 1.1 Appendix F "Elliptical arc implementation notes" algorithm. -### Purpose +#### Purpose `PathBuilder.ArcTo` always stores the raw SVG arc parameters supplied by the caller, without pre-inspecting or pre-converting them (see _Path Unit Design_, `path.md`). `SvgArcConverter` is @@ -15,9 +15,17 @@ documented degenerate cases, so a consumer that never needs Bezier segments (for hit-testing implementation with its own arc math) never pays for the conversion; conversion happens lazily, only when a consumer actually calls `ToBeziers`. -### Key Method +#### Data Model -#### ToBeziers(...) +`SvgArcConverter` is a stateless static class with no instance fields and no supporting types of +its own - both the input (a `Vector2 start`, `Vector2 radius`, `float rotationDegrees`, two +`bool` flags, and a `Vector2 end`) and output (a caller-supplied +`IList<(Vector2 Control1, Vector2 Control2, Vector2 End)> output` collection of cubic Bezier +segments) are plain BCL/tuple types. There is no other data model to document. + +#### Key Methods + +##### ToBeziers(...) Signature: @@ -69,19 +77,19 @@ the `start`-`end` chord distance, which forces the `lambda > 1` scale-up path an resulting Bezier chain both reaches the declared `end` point and follows the corrected (scaled-up), not the originally requested, ellipse geometry. -### Error Handling +#### Error Handling `ToBeziers` performs no argument validation and never throws; every documented degenerate case (zero-length arc, zero radius) is handled by producing a geometrically sensible result rather than raising an exception, per this unit's contract. -### Dependencies +#### Dependencies `SvgArcConverter` depends only on `System.Numerics.Vector2` (in-box BCL type, no new NuGet package) and `System.MathF`/`System.Collections.Generic.IList` from the .NET Base Class Library. -### Callers +#### Callers `SvgArcConverter` is a public API entry point, invoked externally by consumers of the CanvasNet package. It is also invoked internally by `Path.GetBounds`, to convert any `ArcTo` command to diff --git a/docs/design/canvas-net/rendering/canvas.md b/docs/design/canvas-net/rendering/canvas.md index d20c6398..0b7d03c9 100644 --- a/docs/design/canvas-net/rendering/canvas.md +++ b/docs/design/canvas-net/rendering/canvas.md @@ -3,7 +3,25 @@ The `Canvas` class is the sole stateful unit in the `Rendering` subsystem. It wraps a `Canvas.Surface` and owns an affine transform stack that is baked into every fill or stroke. -#### Responsibilities +#### Purpose + +`Canvas` provides a transform-aware drawing surface: callers push and pop a current 2D affine +transform (`Translate`/`RotateDegrees`/`Save`/`Restore`), and every fill/stroke call bakes that +transform into the geometry (and, for gradient fills, into the paint's coordinate mapping) before +dispatching to the lower-level `Drawing` pipeline. It is a thin wrapper around a caller-owned +`Canvas.Surface`, not an owner of the surface's lifetime. + +#### Data Model + +- `_current` โ€” a `System.Numerics.Matrix3x2` field holding the current affine transform, + initialized to the identity. +- A `Stack` field used for `Save`/`Restore` bookkeeping. +- `CurrentTransform` โ€” a read-only property exposing `_current`. +- A non-owning reference to the wrapped `Canvas.Surface` passed to the constructor. + +#### Key Methods + +##### Responsibilities - Own the current 2D affine transform as a `System.Numerics.Matrix3x2` field `_current`, initialized to the identity. @@ -26,7 +44,7 @@ The `Canvas` class is the sole stateful unit in the `Rendering` subsystem. It wr is what keeps a gradient visually anchored to the shape it paints under a translated or rotated Canvas, instead of the gradient remaining fixed in the shape's old, untransformed local frame. -#### Byte-identical no-transform guarantee +##### Byte-identical no-transform guarantee When `CurrentTransform` is the identity, `FillPath` and `StrokePath` skip the `Path.Transform` call entirely and pass the original `Path` reference straight to the drawing pipeline; the @@ -34,7 +52,7 @@ gradient-paint `FillPath` overload likewise skips `Gradient.WithTransform` and p `Gradient` reference straight through. This preserves the exact bit pattern of pre-existing renders when callers adopt the wrapper. -#### Surface Ownership +##### Surface Ownership `Canvas` holds a non-owning reference to the `Canvas.Surface` passed to its constructor: it never calls `Surface.Dispose()` and does not itself implement `IDisposable`. The caller that @@ -43,10 +61,24 @@ the `Surface` are no longer needed. This is a deliberate design choice โ€” `Canv transform-aware wrapper around a `Surface` a caller already owns, not an owner of that surface's lifetime. -#### Validation +#### Error Handling - The constructor throws `ArgumentNullException` on a null `Surface`. - `FillPath` / `StrokePath` throw `ArgumentNullException` on a null `Path`, `Gradient`, or `StrokeStyle`. - `FillPath` throws `ArgumentOutOfRangeException` on an undefined `FillRule`. - `Restore` throws `InvalidOperationException` when the stack is empty. + +#### Dependencies + +`Canvas` depends on the `Canvas` (pixel-buffer) subsystem's `Surface` and `Rgba32` units, the +`Geometry` subsystem's `Path` unit, and the `Drawing` subsystem's `PathFiller`, `PathStroker`, +`Gradient`, `FillRule`, and `StrokeStyle` units, plus `System.Numerics.Matrix3x2` from the .NET +Base Class Library. + +#### Callers + +`Canvas` is a public API entry point, invoked directly by consumers of the CanvasNet package. +Within this repository, it is additionally used internally by the `Shapes` and `TextRenderer` +units of this same `Rendering` subsystem (see _Shapes Unit Design_, `shapes.md`, and +_TextRenderer Unit Design_, `text-renderer.md`). diff --git a/docs/design/canvas-net/rendering/shapes.md b/docs/design/canvas-net/rendering/shapes.md index 6bc13285..aaac9117 100644 --- a/docs/design/canvas-net/rendering/shapes.md +++ b/docs/design/canvas-net/rendering/shapes.md @@ -4,7 +4,22 @@ The `Shapes` class is a stateless static class of extension methods on `Renderin provide convenience fills and strokes for the three most common shapes: rectangles, rounded rectangles, and circles. -#### Entry points +#### Purpose + +`Shapes` saves callers from hand-building a `Geometry.Path` for the most common primitive shapes. +Every helper builds its path via the corresponding `Geometry.Path` factory and dispatches the +fill or stroke through a given `Rendering.Canvas`, so callers get the same transform-aware +behavior as building the path manually. + +#### Data Model + +`Shapes` is a stateless static class with no instance fields and no supporting types of its own; +every helper takes plain `float` geometry parameters, an `Rgba32` fill color, and (for stroke +helpers) a `Drawing.StrokeStyle`. + +#### Key Methods + +##### Entry points - `FillRect(this Canvas, float x, float y, float width, float height, Rgba32)` โ€” fills an axis-aligned rectangle. @@ -18,7 +33,7 @@ rectangles, and circles. - `StrokeCircle(this Canvas, float centerX, float centerY, float radius, StrokeStyle, Rgba32)` โ€” strokes a circle. -#### Behavior +##### Behavior Every helper builds its path via the corresponding `Geometry.Path` factory (`Path.Rectangle`, `Path.RoundRectangle`, `Path.Circle`) and dispatches the fill or stroke @@ -35,8 +50,19 @@ factory: whenever `radius` itself is non-positive, since a circle has no other dimension to fall back to. -#### Validation +#### Error Handling Every helper throws `ArgumentNullException` on a null `Canvas` argument. Radius clamping happens inside the underlying `Path.RoundRectangle` factory; the helpers themselves do not validate `radius` further. + +#### Dependencies + +`Shapes` depends on this subsystem's own `Canvas` unit, the `Geometry` subsystem's `Path` unit +(`Rectangle`/`RoundRectangle`/`Circle` factories), and the `Canvas` (pixel-buffer) subsystem's +`Rgba32` unit, plus the `Drawing` subsystem's `StrokeStyle` unit for its stroke overloads. + +#### Callers + +`Shapes` is a public API entry point, invoked directly by consumers of the CanvasNet package as +extension methods on `Rendering.Canvas`. No unit within CanvasNet calls `Shapes` internally. diff --git a/docs/design/canvas-net/rendering/text-renderer.md b/docs/design/canvas-net/rendering/text-renderer.md index 720f8709..b7395f03 100644 --- a/docs/design/canvas-net/rendering/text-renderer.md +++ b/docs/design/canvas-net/rendering/text-renderer.md @@ -4,13 +4,22 @@ The `TextRenderer` class is a stateless static class that measures and renders T against a `Rendering.Canvas`. Its two entry points are the static `MeasureText` method and the `DrawText` extension method on `Rendering.Canvas`. -#### Types covered inline +#### Purpose -The subsystem-scoped types `TextAlign` (enum with `Left`, `Center`, `Right`) and `TextMetrics` -(a readonly struct with `Width`, `Ascent`, `Descent` fields) are simple data carriers with no -independently testable behavior and are covered inline in this unit. +`TextRenderer` composes `Fonts.TrueTypeFont` glyph/metric queries with `Rendering.Canvas` fills +to measure and draw strings of text, so callers do not need to hand-write glyph iteration, +advance-width/kerning accumulation, or alignment-offset math themselves. -#### MeasureText +#### Data Model + +`TextRenderer` is a stateless static class with no instance fields. The subsystem-scoped types +`TextAlign` (enum with `Left`, `Center`, `Right`) and `TextMetrics` (a readonly struct with +`Width`, `Ascent`, `Descent` fields) are simple data carriers with no independently testable +behavior and are covered inline in this unit. + +#### Key Methods + +##### MeasureText Given a string, a `TrueTypeFont`, and a pixel `size`, `MeasureText` returns a `TextMetrics` where: @@ -22,7 +31,7 @@ where: `size` scales font design units to pixels by the ratio `size / font.UnitsPerEm`. -#### DrawText +##### DrawText Given a `Rendering.Canvas`, a string, an anchor `(x, y)` at the text baseline, a `TextAlign`, a `TrueTypeFont`, a pixel `size`, and a fill `Rgba32`, `DrawText` walks the string as Unicode @@ -43,8 +52,21 @@ runes and for each glyph: `alignOffset` is `0` for `Left`, `-width / 2` for `Center`, and `-width` for `Right`, where `width` is the pre-measured run width. -#### Validation +#### Error Handling - `MeasureText` and `DrawText` throw `ArgumentNullException` on a null `text` or null `font`. - `MeasureText` throws `ArgumentOutOfRangeException` on a non-finite `size`. - `DrawText` throws `ArgumentOutOfRangeException` on an undefined `TextAlign`. + +#### Dependencies + +`TextRenderer` depends on this subsystem's own `Canvas` unit, the `Fonts` subsystem's +`TrueTypeFont` unit (glyph outlines, advance widths, kerning, and `Ascender`/`Descender`/ +`UnitsPerEm` metrics), the `Geometry` subsystem's `Path` unit, the `Drawing` subsystem's +`PathFiller` unit, and `System.Numerics.Matrix3x2`/`System.Text.Rune` from the .NET Base Class +Library. + +#### Callers + +`TextRenderer` is a public API entry point, invoked directly by consumers of the CanvasNet +package. No unit within CanvasNet calls `TextRenderer` internally. diff --git a/docs/design/introduction.md b/docs/design/introduction.md index 8fed0e61..ea260fbb 100644 --- a/docs/design/introduction.md +++ b/docs/design/introduction.md @@ -1,6 +1,6 @@ # Introduction - + This document provides the detailed design for CanvasNet, a .NET library providing a canvas-based drawing and rendering API. @@ -36,19 +36,34 @@ software items, specifically: subsystem (which covers rasterization built on top of these primitives): the `Rect` unit (axis-aligned bounding rectangle), the `Path` unit (immutable vector path and its `PathBuilder`, covering the supporting `Subpath`, `PathCommand`, and `PathCommandType` types - inline), the `BezierFlattening` unit (adaptive Bezier curve flattening), and the - `SvgArcConverter` unit (SVG-style elliptical arc to Bezier conversion) + inline), the `BezierFlattening` unit (adaptive Bezier curve flattening), the + `SvgArcConverter` unit (SVG-style elliptical arc to Bezier conversion), and the + `CornerRoundEffect` unit (path-level pre-processing that replaces polyline corners with + tangent-radius circular arcs) - **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 + `Geometry.Path` geometry with solid-color, gradient, or tiled-pattern 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), the `GradientPaint` unit (the public `Gradient`/`LinearGradient`/`RadialGradient`/`GradientStop`/ - `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 + `GradientSpread` types and the internal `GradientEvaluator` helper), and (added alongside + `CanvasNetPdf`'s `/Pattern` color-space support) the `TilePaint` unit (the public `TilePaint` + type โ€” a pre-rendered tile `Surface` plus a device-space `Transform` and pattern-space + `XStep`/`YStep` pitch โ€” and the internal `TilePaintEvaluator` helper, mirroring + `GradientPaint`'s own public-type-plus-internal-evaluator shape) +- **Fonts (Subsystem)** โ€” TrueType (`glyf`-based) and CFF/OpenType (`OTTO`-flavored, Type 2 + charstring-based) SFNT font support, including standalone classic PostScript Type 1/Type 1C + font-program loading: the `TrueTypeFont` unit and its internal `SfntContainer`/`CmapTable`/ + `GlyfLocaReader`/`IGlyphOutlineSource`/`CffTable`/`CffCharstringInterpreter`/ + `CffStandardEncoding`/`Type1Table`/`Type1CharstringInterpreter`/`Type1CharstringDecryption`/ + `Type1PfbReader`/`Type1PfaReader`/`Type1StandardGlyphNames`/`HmtxHheaReader`/`KernTable`/ + `NameTable`/`StyleTable` helpers, + producing `Geometry.Path` glyph outlines plus metrics and kerning; and (added Phase 6 of the + `CanvasNetPdf` roadmap) the `SystemFontCatalog` unit, providing directory-scan-only discovery + of fonts installed on the host operating system, best-effort family-name/style matching against + that catalog, and a bundled, always-available Liberation Sans/Serif/Mono last-resort fallback + font shipped as an embedded resource of this assembly - **Rendering (Subsystem)** โ€” higher-level rendering primitives composing `Canvas`, `Geometry`, `Drawing`, and `Fonts`: the transform-aware `Canvas` wrapper, `TextRenderer` (measure and draw TrueType text with alignment and kerning), and `Shapes` (rectangle, rounded rectangle, and @@ -58,6 +73,19 @@ software items, specifically: decodes/rasterizes a subset of SVG vector documents into a `CanvasNet.Canvas.Surface`. `CanvasNetSvg` depends on this `CanvasNet` system's `Canvas`, `Geometry`, `Drawing`, `Fonts`, and `Codecs` subsystems โ€” see _CanvasNetSvg System Design_ (`canvas-net-svg.md`) +- **CanvasNetPdf (System)** โ€” A separate, independently-distributed software system providing + PDF page-rendering support, containing a single unit, `PdfDocument`, which parses a PDF + document's structure (cross-references, trailer, page tree), optionally decrypting a document + encrypted with the PDF `/Standard` security handler (RC4, AES-128, or AES-256), and reports its + page count/size/rotation, then renders each page's content stream end to end: path + construction/painting with real device color (including axial/radial shading and tiling + `/Pattern` fills), image XObjects (decoded through a `FlateDecode`/`LZWDecode`/ + `ASCII85Decode`/`ASCIIHexDecode`/`RunLengthDecode`/`DCTDecode`/`CCITTFaxDecode` filter + pipeline) and nested `/Subtype /Form` XObjects, and text drawn with an embedded TrueType, + Type 1, Type 1C, Type 3, or Type 0/CID-keyed font, or an automatically substituted + system/bundled fallback font when none is embedded. `CanvasNetPdf` depends on this + `CanvasNet` system's `Canvas`, `Geometry`, `Drawing`, `Fonts`, and `Codecs` subsystems โ€” see + _CanvasNetPdf System Design_ (`canvas-net-pdf.md`) The following OTS items are also covered: @@ -69,6 +97,7 @@ The following OTS items are also covered: - **SarifMark** โ€” SARIF report conversion tool - **SonarMark** โ€” SonarCloud quality report tool - **SysML2Tools** โ€” architecture model lint and diagram rendering tool +- **System.Numerics.Tensors** โ€” vectorized bulk pixel-arithmetic runtime library - **VersionMark** โ€” tool-version documentation tool - **WeasyPrint** โ€” HTML-to-PDF conversion tool - **xUnit** โ€” unit-testing framework @@ -102,13 +131,20 @@ namespace `DemaConsulting.CanvasNet.Codecs`, flat โ€” no further nesting), 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` +`StrokePathFlattener`/`DashSplitter`/`StrokeOutliner` helpers inline, the `GradientPaint` unit, covering the public `Gradient`/`LinearGradient`/`RadialGradient`/`GradientStop`/ -`GradientSpread` types and the internal `GradientEvaluator` helper inline, namespace +`GradientSpread` types and the internal `GradientEvaluator` helper inline, and the `TilePaint` +unit, added alongside `CanvasNetPdf`'s `/Pattern` color-space support, covering the public +`TilePaint` type and the internal `TilePaintEvaluator` helper inline, namespace `DemaConsulting.CanvasNet.Drawing`, flat โ€” no further nesting), the `Fonts` subsystem (the `TrueTypeFont` unit, covering the internal `SfntContainer`/`CmapTable`/`GlyfLocaReader`/ -`HmtxHheaReader`/`KernTable` helpers inline, namespace `DemaConsulting.CanvasNet.Fonts`, flat โ€” -no further nesting), and the `Rendering` subsystem (the transform-aware `Canvas` wrapper unit, +`IGlyphOutlineSource`/`CffTable`/`CffCharstringInterpreter`/`CffStandardEncoding`/`Type1Table`/ +`Type1CharstringInterpreter`/`Type1CharstringDecryption`/`Type1PfbReader`/`Type1PfaReader`/ +`Type1StandardGlyphNames`/`HmtxHheaReader`/`KernTable`/`NameTable`/`StyleTable` helpers inline, +and the `SystemFontCatalog` unit, added Phase 6 of +the `CanvasNetPdf` roadmap, with no internal helpers of its own, namespace +`DemaConsulting.CanvasNet.Fonts`, flat โ€” no further nesting), and the `Rendering` subsystem (the +transform-aware `Canvas` wrapper unit, the `TextRenderer` unit, covering the supporting `TextAlign` and `TextMetrics` types inline, and the `Shapes` extension-method unit, namespace `DemaConsulting.CanvasNet.Rendering`, flat โ€” no further nesting). As additional functionality is added, further subsystems and nested @@ -121,6 +157,16 @@ A sibling top-level system, `CanvasNetSvg`, lives in this same repository alongs the `CanvasNet` system's `Canvas`, `Geometry`, `Drawing`, `Fonts`, and `Codecs` subsystems โ€” see the Folder Layout section below and _CanvasNetSvg System Design_ (`canvas-net-svg.md`). +A second sibling top-level system, `CanvasNetPdf`, likewise lives in this same repository +alongside `CanvasNet`: its sole unit, `PdfDocument`, namespace `DemaConsulting.CanvasNet.Pdf`, is +distributed as its own separate NuGet package and depends on the `CanvasNet` system's `Canvas`, +`Geometry`, `Drawing`, `Fonts`, and `Codecs` subsystems (`Render` rasterizes real page-content +geometry, device color (including shading/tiling pattern fills), image XObjects and nested Form +XObjects, and text โ€” TrueType, Type 1, Type 1C, Type 3, or Type 0/CID-keyed โ€” with automatic +font-fallback substitution, optionally decrypting an encrypted document first) โ€” +see the Folder Layout section below and _CanvasNetPdf +System Design_ (`canvas-net-pdf.md`). + ## Folder Layout The source code folder structure mirrors the software structure organization, with file paths @@ -153,6 +199,11 @@ src/DemaConsulting.CanvasNet/ โ”‚ โ”œโ”€โ”€ GifCodec.cs โ€” Decode-only, first-frame-only GIF loader; public API entry โ”‚ โ”‚ point, partial-class implementation continues under `Gif/` โ”‚ โ”œโ”€โ”€ Gif/ โ€” `GifCodec` partial-class implementation files (decode, LZW) +โ”‚ โ”œโ”€โ”€ ImageInfo.cs โ€” Shared `GetInfo` return type (dimensions, channels, alpha, +โ”‚ โ”‚ `CanDecode`, `FrameCount`) common to all five codecs +โ”‚ โ”œโ”€โ”€ UnsupportedImageFeatureException.cs โ€” Thrown by `Load` for a well-formed but +โ”‚ โ”‚ unsupported file feature (for example PNG Adam7 +โ”‚ โ”‚ interlacing), distinct from `InvalidDataException` โ”‚ โ””โ”€โ”€ NamespaceDoc.cs โ€” Namespace-level XML documentation โ”œโ”€โ”€ Drawing/ โ”‚ โ”œโ”€โ”€ FillRule.cs โ€” Nonzero/even-odd fill-rule enumeration @@ -172,15 +223,39 @@ src/DemaConsulting.CanvasNet/ โ”‚ โ”œโ”€โ”€ 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 +โ”‚ โ”œโ”€โ”€ TilePaint.cs โ€” Public tiled-pattern paint: a pre-rendered tile plus transform +โ”‚ โ”œโ”€โ”€ TilePaintEvaluator.cs โ€” Resolves a tile-paint 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 +โ”‚ โ”œโ”€โ”€ TrueTypeFont.cs โ€” Public TrueType/CFF/Type 1 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 +โ”‚ โ”œโ”€โ”€ IGlyphOutlineSource.cs โ€” Common glyph-outline-decoding contract dispatched by TrueTypeFont +โ”‚ โ”œโ”€โ”€ CffTable.cs โ€” CFF structural parsing (Header/INDEXes/Private DICT/charset) plus lazy Type +โ”‚ โ”‚ 2 charstring decoding +โ”‚ โ”œโ”€โ”€ CffCharstringInterpreter.cs โ€” Decodes a Type 2 charstring into glyph outline geometry +โ”‚ โ”œโ”€โ”€ CffStandardEncoding.cs โ€” Adobe StandardEncoding code-to-glyph-name table for seac-style accent +โ”‚ โ”‚ composition +โ”‚ โ”œโ”€โ”€ Type1Table.cs โ€” Classic PostScript Type 1 font-program structural parsing plus lazy Type 1 +โ”‚ โ”‚ charstring decoding +โ”‚ โ”œโ”€โ”€ Type1CharstringInterpreter.cs โ€” Decodes a Type 1 charstring into glyph outline geometry +โ”‚ โ”œโ”€โ”€ Type1CharstringDecryption.cs โ€” Type 1 eexec/charstring stream-cipher decryption +โ”‚ โ”œโ”€โ”€ Type1PfbReader.cs โ€” Reassembles a binary-framed (.pfb) Type 1 font program cleartext/encrypted +โ”‚ โ”‚ segments +โ”‚ โ”œโ”€โ”€ Type1PfaReader.cs โ€” Reassembles an ASCII-framed (.pfa) Type 1 font program cleartext/encrypted +โ”‚ โ”‚ segments +โ”‚ โ”œโ”€โ”€ Type1StandardGlyphNames.cs โ€” Default codepoint-to-glyph-name table for auto-detected standalone Type 1 +โ”‚ โ”‚ fonts +โ”‚ โ”œโ”€โ”€ HmtxHheaReader.cs โ€” Horizontal metrics parsing and advance-width lookup +โ”‚ โ”œโ”€โ”€ KernTable.cs โ€” Format-0 horizontal kerning lookup +โ”‚ โ”œโ”€โ”€ NameTable.cs โ€” Parses the name table and resolves family/subfamily/full/PostScript name +โ”‚ โ”‚ strings +โ”‚ โ”œโ”€โ”€ StyleTable.cs โ€” Derives bold/italic/fixed-pitch style metadata from OS/2/post/head.macStyle +โ”‚ โ”œโ”€โ”€ SystemFontCatalog.cs โ€” OS font discovery, best-effort matching, bundled fallback +โ”‚ โ”œโ”€โ”€ BundledFonts/ โ€” Embedded Liberation Sans/Serif/Mono plus Noto Symbol/ZapfDingbats substitute +โ”‚ โ”‚ .ttf files and OFL.txt license(s) +โ”‚ โ””โ”€โ”€ NamespaceDoc.cs โ€” Namespace-level XML documentation โ”œโ”€โ”€ Geometry/ โ”‚ โ”œโ”€โ”€ Rect.cs โ€” Axis-aligned bounding rectangle (position plus size) โ”‚ โ”œโ”€โ”€ PathCommandType.cs โ€” Enumeration of path drawing command kinds @@ -231,6 +306,72 @@ system's sole unit, `SvgCodec`, depends on the `CanvasNet` system's `Geometry`, `Fonts`, and `Codecs` subsystems (not only `Canvas`) to build and rasterize the vector paths and text it decodes from SVG documents โ€” see _CanvasNetSvg System Design_ (`canvas-net-svg.md`). +`PdfDocument` lives in the second sibling `src/DemaConsulting.CanvasNet.Pdf/` project folder +(namespace `DemaConsulting.CanvasNet.Pdf`, distributed as the separate +`DemaConsulting.CanvasNet.Pdf` NuGet package). Like `SvgCodec`'s own folder, this is the source +folder of a separate, sibling system's sole unit (see _CanvasNetPdf System Design_, +`canvas-net-pdf.md`), a flat structure with no further nesting: + +```text +src/DemaConsulting.CanvasNet.Pdf/ +โ”œโ”€โ”€ PdfDocument.cs โ€” Public API entry point: Open/PageCount/GetPageInfo/ +โ”‚ Render/Dispose; partial-class implementation continues +โ”‚ in every file below +โ”œโ”€โ”€ PdfDocument.Tokenizer.cs โ€” Low-level lexer (numbers/strings/names/delimiters/keywords) +โ”œโ”€โ”€ PdfDocument.ObjectModel.cs โ€” Internal `PdfObject` tagged union + recursive-descent parser +โ”œโ”€โ”€ PdfDocument.Xref.cs โ€” Classic xref+trailer, xref stream, object stream, hybrid `/XRefStm`, +โ”‚ linear-scan fallback; decrypts bytes via the key `PdfDocument.Encryption.cs` +โ”‚ derives +โ”œโ”€โ”€ PdfDocument.PageTree.cs โ€” Catalogโ†’Pagesโ†’Kids traversal, MediaBox/Rotate inheritance, cycle rejection, +โ”‚ `PdfPageInfo` production +โ”œโ”€โ”€ PdfDocument.ContentStream.cs โ€” Content-stream tokenizer/dispatch loop, `/Contents` resolution (single stream +โ”‚ or space-joined array) +โ”œโ”€โ”€ PdfDocument.GraphicsState.cs โ€” Graphics-state stack (`q`/`Q`/`cm`/`w`/`J`/`j`/`M`/`d`), CTM derivation from +โ”‚ `/MediaBox`/`/Rotate` +โ”œโ”€โ”€ PdfDocument.PathOps.cs โ€” Path-construction (`m`/`l`/`c`/`v`/`y`/`h`/`re`) and path-painting +โ”‚ (`f`/`F`/`f*`/`S`/`s`/`B`/`B*`/`b`/`b*`/`n`) +โ”œโ”€โ”€ PdfDocument.Color.cs โ€” Device color operators +โ”‚ (`g`/`G`/`rg`/`RG`/`k`/`K`/`cs`/`CS`/`sc`/`SC`/`scn`/`SCN`) +โ”œโ”€โ”€ PdfDocument.Filters.cs โ€” Generalized `/Filter`/`/DecodeParms` pipeline dispatch, `FlateDecode` plus +โ”‚ PNG/TIFF predictor reversal +โ”œโ”€โ”€ PdfDocument.Filters.Lzw.cs โ€” `LZWDecode` filter (PDF-variant early-change LZW) +โ”œโ”€โ”€ PdfDocument.Filters.Ascii.cs โ€” `ASCII85Decode`/`ASCIIHexDecode` filters +โ”œโ”€โ”€ PdfDocument.Filters.RunLength.cs โ€” `RunLengthDecode` filter (PackBits-style) +โ”œโ”€โ”€ PdfDocument.Images.cs โ€” Image XObjects (`Do`: `DCTDecode` via `Codecs.JpegCodec`, `CCITTFaxDecode` via +โ”‚ `PdfDocument.CcittFax.cs`, or raw `DeviceGray`/`DeviceRGB`/`DeviceCMYK` +โ”‚ samples) and `/Subtype /Form` XObject nested content-stream execution +โ”œโ”€โ”€ PdfDocument.Fonts.cs โ€” Simple `/Subtype /TrueType` font resolution (`/Resources/Font`), `/Encoding` +โ”‚ mapping, `/Widths` +โ”œโ”€โ”€ PdfDocument.Fonts.ToUnicode.cs โ€” Resolves a font's optional `/ToUnicode` CMap stream into a +โ”‚ code-to-Unicode-codepoint map +โ”œโ”€โ”€ PdfDocument.Fonts.Type0.cs โ€” Composite `/Subtype /Type0` (`Identity-H` CID-keyed) font resolution and +โ”‚ CID-to-glyph-index mapping +โ”œโ”€โ”€ PdfDocument.Fonts.Type1.cs โ€” `/Subtype /Type1` classic and bare Type1C `/FontFile3` font resolution +โ”œโ”€โ”€ PdfDocument.Fonts.Type3.cs โ€” `/Subtype /Type3` procedure-font resolution and glyph-procedure painting +โ”œโ”€โ”€ PdfDocument.FontFallback.cs โ€” Standard-14/system-font substitution when no embedded font program is present, +โ”‚ plus a bundled Noto substitute-font union for `Symbol`/`ZapfDingbats` (any +โ”‚ other symbolic font still fails closed) +โ”œโ”€โ”€ PdfDocument.Text.cs โ€” Text-object/text-state operators (`BT`/`ET`/`Tc`/`Tw`/ +โ”‚ `Tz`/`TL`/`Tf`/`Tr`/`Ts`/`Td`/`TD`/`Tm`/`T*`), text-showing +โ”‚ (`Tj`/`'`/`"`/`TJ`) and glyph painting +โ”œโ”€โ”€ PdfDocument.CcittFax.cs โ€” `CCITTFaxDecode` (Group 4/T.6 MMR) image-XObject decoding +โ”œโ”€โ”€ PdfDocument.Encryption.cs โ€” `/Encrypt` detection, RC4/AES-128/AES-256 key derivation, stream/string +โ”‚ decryption +โ”œโ”€โ”€ PdfDocument.Functions.cs โ€” Sampled/exponential/stitching `/FunctionType` evaluation for shading patterns +โ”œโ”€โ”€ PdfDocument.Patterns.cs โ€” Resolved `/Pattern` resource: dispatches to a shading or tiling pattern +โ”œโ”€โ”€ PdfDocument.Patterns.Shading.cs โ€” Axial/radial (`/ShadingType` `2`/`3`) shading-pattern construction and painting +โ”œโ”€โ”€ PdfDocument.Patterns.Tiling.cs โ€” Colored/uncolored tiling-pattern (`/PatternType 1`) cell rendering +โ”œโ”€โ”€ PdfPageInfo.cs โ€” Standalone supporting record struct (resolved page size/rotation) +โ””โ”€โ”€ NamespaceDoc.cs โ€” Namespace-level XML documentation +``` + +`PdfDocument`'s dependencies span the `CanvasNet` system's `Canvas` (`Surface`/`Rgba32`), +`Geometry` (`PathBuilder`/`Path`), `Drawing` (`PathFiller`/`PathStroker`/`StrokeStyle`/ +`Gradient`/`LinearGradient`/`RadialGradient`/`GradientStop`/`GradientSpread`/`TilePaint`), +`Fonts` (`TrueTypeFont`, `SystemFontCatalog`), and `Codecs` (`UnsupportedImageFeatureException`, +`JpegCodec`) subsystems โ€” see _CanvasNetPdf System Design_ (`canvas-net-pdf.md`) for exactly +which file introduced each dependency. + ## Document Conventions Throughout this document: diff --git a/docs/reqstream/canvas-net-pdf.yaml b/docs/reqstream/canvas-net-pdf.yaml new file mode 100644 index 00000000..58c40559 --- /dev/null +++ b/docs/reqstream/canvas-net-pdf.yaml @@ -0,0 +1,357 @@ +--- +# cspell:ignore Noto +# +# Software System Requirements for CanvasNetPdf +# +# These requirements describe the externally visible behavior of the DemaConsulting.CanvasNet.Pdf +# package (namespace DemaConsulting.CanvasNet.Pdf) from the perspective of its consumers, without +# specifying implementation details. +# +# CanvasNetPdf is an independent software system, distributed as its own NuGet package, depending +# on the core CanvasNet system (docs/reqstream/canvas-net.yaml) for its Canvas.Surface pixel +# buffer and Codecs.UnsupportedImageFeatureException type - see docs/design/canvas-net-pdf.md's +# Dependencies section for details. +# +# CanvasNetPdf parses a PDF document's structure (cross-references in all supported forms, +# trailer, page tree with inheritance) and reports page count/size/rotation, then renders each +# page's content stream end to end: path construction/painting, device color, image XObjects, +# embedded-TrueType text (with Standard-14/system-font fallback substitution when no font is +# embedded), and a stream-filter set (FlateDecode/LZWDecode/ASCII85Decode/ASCIIHexDecode/ +# RunLengthDecode) - see docs/design/canvas-net-pdf.md for the full current feature set and its +# documented, still-unsupported-feature boundary. +# +# cspell:ignore xref startxref devicergb Zapf subsetted + +sections: + - title: CanvasNetPdf Requirements + requirements: + - id: CanvasNetPdf-Open + title: >- + The library shall parse a well-formed PDF document exactly once, from either a stream or + a file path, resolving its cross-reference data (classic table, cross-reference stream, + object stream, and hybrid combinations of these, falling back to a linear scan of the + document when normal cross-reference parsing fails or does not resolve to a valid + catalog) and reject a null stream/path, an empty or whitespace-only path, or malformed + document structure per the documented exception contract. + tags: [system] + justification: | + Callers need a single, reliable entry point that turns raw PDF bytes into a usable, + in-memory document regardless of which cross-reference form a given producer used, and + must be able to distinguish a programming error (null/empty argument) from malformed + input (corrupt document structure). + children: + - CanvasNetPdf-PdfDocument-Open + - CanvasNetPdf-PdfDocument-Tokenizer + - CanvasNetPdf-PdfDocument-ObjectModel + - CanvasNetPdf-PdfDocument-ClassicXref + - CanvasNetPdf-PdfDocument-XrefStream + - CanvasNetPdf-PdfDocument-ObjectStream + - CanvasNetPdf-PdfDocument-HybridXref + - CanvasNetPdf-PdfDocument-LinearScanFallback + - CanvasNetPdf-PdfDocument-PageTreeCycleRejection + tests: + - CanvasNetPdf_SystemIntegration_PdfOpen_ReturnsExpectedPageCount + - CanvasNetPdf_SystemIntegration_PdfValidationNull_NullStreamThrowsArgumentNullException + - CanvasNetPdf_SystemIntegration_PdfValidationEmptyPath_EmptyPathThrowsArgumentException + + - id: CanvasNetPdf-PageCount + title: >- + The library shall report the document's true resolved page count after traversing its + page tree (including nested Kids arrays), regardless of which cross-reference form was + used to locate the underlying objects. + tags: [system] + justification: | + Callers need to know how many pages a document has before deciding which pages to + inspect or render. + children: + - CanvasNetPdf-PdfDocument-PageTreeTraversal + tests: + - CanvasNetPdf_SystemIntegration_PdfOpen_ReturnsExpectedPageCount + + - id: CanvasNetPdf-GetPageInfo + title: >- + The library shall report each page's own display (rotation-adjusted) width and height + plus its normalized effective rotation, inheriting an undeclared MediaBox/Rotate from + the nearest ancestor that declares one, and shall reject an out-of-range page index with + ArgumentOutOfRangeException. + tags: [system] + justification: | + Callers need each page's actual displayed dimensions (already accounting for rotation) + to size a render target correctly, without independently re-implementing PDF's + MediaBox/Rotate inheritance and rotation-swap rules themselves. + children: + - CanvasNetPdf-PdfDocument-GetPageInfo + - CanvasNetPdf-PdfDocument-PageTreeInheritance + tests: + - CanvasNetPdf_SystemIntegration_PdfGetPageInfo_ReportsRotatedDimensions + - CanvasNetPdf_SystemIntegration_PdfGetPageInfoValidation_OutOfRangePageIndexThrowsArgumentOutOfRangeException + + - id: CanvasNetPdf-Render + title: >- + The library shall return a correctly width x height-sized Surface for a valid page + index, propagating Surface's own dimension contract unwrapped, and shall rasterize the + page's own content stream (path geometry, device color, image XObjects, and text) onto + that Surface, rejecting a lexically malformed content stream (an operator given the + wrong operand count, or issued out of its required sequence) with InvalidDataException + rather than silently mis-rendering it. + tags: [system] + justification: | + Callers need a Surface-shaped render target sized to their own request (not derived from + the page's MediaBox) that actually reflects the page's real visual content, and must be + able to distinguish a genuinely malformed content stream from a well-formed one that + simply renders as expected. + children: + - CanvasNetPdf-PdfDocument-Render + - CanvasNetPdf-PdfDocument-RenderWithDpi + tests: + - CanvasNetPdf_SystemIntegration_PdfRender_ReturnsBlankSizedSurface + - CanvasNetPdf_SystemIntegration_PdfUnsupportedFormatValidation_MalformedContentStreamThrowsInvalidDataException + + - id: CanvasNetPdf-Dispose + title: >- + The library shall make Dispose idempotent (safe to call more than once) and shall throw + ObjectDisposedException from every other public member once the document has been + disposed. + tags: [system] + justification: | + Callers need standard, predictable IDisposable behavior - matching every other + disposable type in CanvasNet - so that double-disposing (a common pattern with using + declarations and manual disposal both present) never throws. + children: + - CanvasNetPdf-PdfDocument-Dispose + - CanvasNetPdf-PdfDocument-ObjectDisposedException + tests: + - CanvasNetPdf_SystemIntegration_PdfDispose_ObjectDisposedExceptionAfterDispose + + - id: CanvasNetPdf-EncryptDetection + title: >- + The library shall detect an encrypted document (an /Encrypt key present in the trailer) + and, for a supported encryption handler/filter shape (/Filter /Standard RC4 /V 1 or 2, + AES-128 /V 4/CFM AESV2, or AES-256 /V 5/R 5/CFM AESV3) with a correct (or, when none is + required, absent) password, transparently decrypt every indirect object's strings and + every stream's raw bytes before any other parsing logic observes them, so the document + opens and renders exactly as its plaintext equivalent would; it shall instead reject the + document with UnsupportedImageFeatureException (its own distinguishable feature token) + when the encryption handler/filter shape itself is unsupported (for example a non- + /Standard security handler, AES-256 /R 6 "hardened hash" key derivation, or a crypt + filter other than the standard /StdCF), or when a password is required but absent or + does not authenticate as either the user or owner password, rather than silently + mis-parsing still-encrypted bytes as plaintext content. + tags: [system] + justification: | + Real-world encrypted PDFs (most commonly RC4/AES-128/AES-256 with an empty or known + user password) must actually open and render, not merely be detected and rejected; + however, decrypting an unsupported handler/filter shape, or a document whose required + password is missing or wrong, remains out of scope, and silently treating still- + encrypted bytes as plaintext in either case would produce garbage results + indistinguishable from a real (but malformed) document, so callers need a clear, + well-formed-but-unsupported (or incorrect-password) signal instead. + children: + - CanvasNetPdf-PdfDocument-EncryptDetection + - CanvasNetPdf-PdfDocument-EncryptionRc4 + - CanvasNetPdf-PdfDocument-EncryptionAesV2 + - CanvasNetPdf-PdfDocument-EncryptionAesV3R5 + - CanvasNetPdf-PdfDocument-UserPasswordAuthentication + - CanvasNetPdf-PdfDocument-OwnerPasswordAuthentication + - CanvasNetPdf-PdfDocument-IncorrectPasswordRejection + - CanvasNetPdf-PdfDocument-PasswordNonAsciiRejection + tests: + - CanvasNetPdf_SystemIntegration_PdfEncryptDetection_EncryptedTrailerThrowsUnsupportedImageFeatureException + - CanvasNetPdf_SystemIntegration_PdfOpen_EncryptedRc4_CorrectUserPassword_DecryptsAndRendersExpectedPixels + + - id: CanvasNetPdf-ContentStreamInterpreter + title: >- + The library shall dispatch every recognized content-stream operator through a single + interpreter loop that maintains a graphics-state stack (q/Q), derives the current + transformation matrix from the page's base CTM composed with any cm operators and the + page's effective rotation, resolves a page's /Contents (whether a single stream or an + array of streams concatenated with an intervening space), and constructs/paints path + geometry via the path-construction and path-painting operators. + tags: [system] + justification: | + Callers need a page's real drawn geometry to end up in the correct final device-pixel + position regardless of how many nested q/Q save/restore pairs, cm transforms, or /Rotate + value the page declares, and regardless of whether its content is one stream or several. + children: + - CanvasNetPdf-PdfDocument-ContentStreamDispatch + - CanvasNetPdf-PdfDocument-ContentsResolution + - CanvasNetPdf-PdfDocument-GraphicsStateStack + - CanvasNetPdf-PdfDocument-CtmDerivation + - CanvasNetPdf-PdfDocument-PathConstruction + - CanvasNetPdf-PdfDocument-PathPainting + tests: + - CanvasNetPdf_SystemIntegration_PdfRender_FilledRectangleAndStrokedLine_PaintsExpectedPixels + - CanvasNetPdf_SystemIntegration_PdfRender_RotatedPage_MapsGeometryToCorrectPixelPosition + + - id: CanvasNetPdf-DeviceColor + title: >- + The library shall support setting the current fill/stroke color via the device color + operators (rg/g/k and their cs/scn color-space-selecting equivalents) and apply that + color to every subsequently painted path until the next color-setting operator or a + graphics-state restore (Q); it shall also resolve and consume a /FunctionType 0 sampled + function at rendering time, as used by axial/radial shading-pattern fills. + tags: [system] + justification: | + Callers need paths to render in their document-specified color, not merely as an + opaque-black silhouette, for the rendered output to be visually faithful to the source + document. Resolving and consuming a /FunctionType 0 sampled function underpins the + axial/radial shading-pattern-fill feature's own color-at-parametric-position evaluation. + children: + - CanvasNetPdf-PdfDocument-DeviceColorOperators + - CanvasNetPdf-PdfDocument-ColorSpaceOperators + - CanvasNetPdf-PdfDocument-FunctionType0 + - CanvasNetPdf-PdfDocument-FunctionType2And3 + - CanvasNetPdf-PdfDocument-IccBasedColorSpace + - CanvasNetPdf-PdfDocument-IndexedColorSpace + tests: + - CanvasNetPdf_SystemIntegration_PdfRender_ColoredRectangleFill_PaintsExpectedRgbPixels + + - id: CanvasNetPdf-StreamFilters + title: >- + The library shall decode a stream (content stream or image XObject) through its declared + /Filter chain, supporting FlateDecode, LZWDecode, ASCII85Decode, ASCIIHexDecode, and + RunLengthDecode (individually or composed), before interpreting the decoded bytes. + tags: [system] + justification: | + Real-world PDF producers routinely compress or ASCII-armor stream data with any of these + filters; callers need the library to transparently decode whichever filter(s) a given + producer chose, rather than requiring uncompressed input. + children: + - CanvasNetPdf-PdfDocument-FilterPipeline + - CanvasNetPdf-PdfDocument-LzwDecodeFilter + - CanvasNetPdf-PdfDocument-AsciiDecodeFilters + - CanvasNetPdf-PdfDocument-RunLengthDecodeFilter + - CanvasNetPdf-PdfDocument-RunLengthDecodeOutputCap + - CanvasNetPdf-PdfDocument-CcittFaxDecodeFilter + tests: + - CanvasNetPdf_SystemIntegration_PdfRender_LzwDecodeContentStream_PaintsExpectedPixels + - CanvasNetPdf_SystemIntegration_PdfRender_Ascii85DecodeContentStream_PaintsExpectedPixels + - CanvasNetPdf_SystemIntegration_PdfRender_AsciiHexDecodeContentStream_PaintsExpectedPixels + - CanvasNetPdf_SystemIntegration_PdfRender_RunLengthDecodeContentStream_PaintsExpectedPixels + + - id: CanvasNetPdf-ImageXObjects + title: >- + The library shall decode a /Subtype /Image XObject (DeviceRGB, filtered per its declared + /Filter chain) and composite it into the current path/CTM-derived device-space footprint + when placed via the Do operator, per the current graphics state's CTM. + tags: [system] + justification: | + Callers need embedded raster images to actually appear in their document-correct + position/size in the rendered output, not merely be silently skipped. + children: + - CanvasNetPdf-PdfDocument-ImageXObjects + tests: + - CanvasNetPdf_SystemIntegration_PdfRender_ImageXObjectPlacement_CompositesExpectedPixels + + - id: CanvasNetPdf-FormXObjects + title: >- + The library shall execute a /Subtype /Form XObject placed via the Do operator as a + nested content stream, concatenating its optional /Matrix into the current + transformation matrix and using its own /Resources when declared (else the invoking + content stream's /Resources), while leaving the invoking content stream's graphics + state and /Resources unaffected once the Form returns, and shall reject excessive + nested Form XObject recursion rather than overflow the call stack. + tags: [system] + justification: | + Form XObjects are a common PDF construct for reusable nested graphics content; callers + need that content actually rendered at its document-correct, matrix-adjusted position + without corrupting the invoking content stream's own graphics state, and a + pathological (self-referencing) Form must fail closed deterministically. + children: + - CanvasNetPdf-PdfDocument-FormXObjects + tests: + - CanvasNetPdf_SystemIntegration_PdfRender_FormXObject_PaintsNestedContentStream + + - id: CanvasNetPdf-TextRendering + title: >- + The library shall render simple (single-byte-encoded) font text: resolving a page's + referenced fonts (falling back to a matching system or bundled font when no + /FontFile2-embedded font data is present, with Symbol/ZapfDingbats instead resolving via + a dedicated bundled Noto substitute font rather than an unrelated system/bundled font), + applying the font's encoding and advance widths (declared /Widths, or the font's own + metrics when undeclared), maintaining per-text-object state (Tf/Tr/text-positioning + operators), and painting each shown glyph's outline at its correctly positioned/scaled + device location. + tags: [system] + justification: | + Callers need a document's text content to actually appear as recognizable glyph shapes + in their document-correct position, whether or not the document embeds its own font + data, with Symbol/ZapfDingbats resolving via a dedicated Noto substitute font, while + still failing closed (rather than rendering visually wrong substitute glyphs) for every + other symbolic font with no embedded font data. + children: + - CanvasNetPdf-PdfDocument-FontResolution + - CanvasNetPdf-PdfDocument-FontFallback + - CanvasNetPdf-PdfDocument-FontEncoding + - CanvasNetPdf-PdfDocument-FontWidths + - CanvasNetPdf-PdfDocument-TextObjectState + - CanvasNetPdf-PdfDocument-TextStateOperators + - CanvasNetPdf-PdfDocument-Tf + - CanvasNetPdf-PdfDocument-Tr + - CanvasNetPdf-PdfDocument-TextPositioning + - CanvasNetPdf-PdfDocument-TextShowing + - CanvasNetPdf-PdfDocument-TextErrorHandling + - CanvasNetPdf-PdfDocument-Type1FontResolution + - CanvasNetPdf-PdfDocument-Type1CFontResolution + - CanvasNetPdf-PdfDocument-Type3FontResolution + - CanvasNetPdf-PdfDocument-Type3GlyphPainting + - CanvasNetPdf-PdfDocument-Type3GlyphStateIsolation + - CanvasNetPdf-PdfDocument-Type3GlyphNestingDepthGuard + - CanvasNetPdf-PdfDocument-Type3RenderMode3Invisible + - CanvasNetPdf-PdfDocument-Type3D0D1Operators + - CanvasNetPdf-PdfDocument-SymbolZapfDingbatsFallback + tests: + - CanvasNetPdf_SystemIntegration_PdfRender_EmbeddedTrueTypeFontText_PaintsGlyphStrokesNotCounters + - CanvasNetPdf_SystemIntegration_RenderStandard14HelveticaWithoutEmbeddedFont_PaintsVisibleGlyphInk + - CanvasNetPdf_SystemIntegration_RenderSymbolFontWithoutEmbeddedFont_PaintsVisibleGlyphInk + - CanvasNetPdf_SystemIntegration_RenderOtherSymbolicFontWithoutEmbeddedFont_ThrowsUnsupportedImageFeatureException + - CanvasNetPdf_SystemIntegration_RenderEmbeddedType1Font_PaintsExpectedGlyphInk + + - id: CanvasNetPdf-CompositeFontTextRendering + title: >- + The library shall render Type0/Identity-H composite-font (2-byte-encoded CID) text: + resolving a page's referenced Type0/CIDFontType2 composite fonts (requiring an embedded + /FontFile2 on the descendant font, with no fallback substitution path), mapping each + decoded CID to a glyph index via the descendant font's /CIDToGIDMap, and applying the + descendant font's declared /DW/W advance widths, then painting each shown glyph's + outline at its correctly positioned/scaled device location; it shall also resolve (but + not yet consume at rendering time) a composite font's optional /ToUnicode CMap as a + code-to-Unicode-codepoint map. + tags: [system] + justification: | + Type0/Identity-H/CIDFontType2 composite fonts are the encoding real-world PDF producers + (Microsoft Word, LibreOffice, and browser print-to-PDF among them) overwhelmingly emit + for embedded subsetted TrueType text, so callers need this document text to actually + appear as recognizable glyph shapes in their document-correct position, not merely be + rejected as an unsupported font subtype. Resolving /ToUnicode as unconsumed groundwork + unblocks a future text-extraction feature without re-deriving CMap-parsing primitives. + children: + - CanvasNetPdf-PdfDocument-CompositeFontDispatch + - CanvasNetPdf-PdfDocument-CidFontType2FontResolution + - CanvasNetPdf-PdfDocument-CidFontType0FontResolution + - CanvasNetPdf-PdfDocument-CompositeFontWidths + - CanvasNetPdf-PdfDocument-ToUnicodeCMap + tests: + - CanvasNetPdf_SystemIntegration_RenderType0CompositeFont_PaintsExpectedGlyphInk + + - id: CanvasNetPdf-PatternFills + title: >- + The library shall recognize /Pattern as a selectable cs/CS color space and fill/stroke + a path using the named pattern resolved from the current page's /Resources/Pattern + dictionary: a /PatternType 2 (shading) pattern sampling its /Function to paint an + axial (/ShadingType 2) or radial (/ShadingType 3) color gradient, and a /PatternType 1 + (tiling) pattern rendering its own content stream once into an offscreen tile and + repeating it (colored or recolored per its /PaintType) across the painted region. + tags: [system] + justification: | + Real-world PDFs commonly use the /Pattern color space to render smooth-color gradient + backgrounds/vector art (shading patterns) and repeating textures/hatching/decorative + backgrounds (tiling patterns); without this support, every such fill previously failed + closed and the document could not be rendered at all. + children: + - CanvasNetPdf-PdfDocument-PatternColorSpace + - CanvasNetPdf-PdfDocument-ShadingPatternFill + - CanvasNetPdf-PdfDocument-TilingPatternFill + tests: + - CanvasNetPdf_SystemIntegration_AxialShadingPatternFill_PaintsVisiblyVaryingColors + - CanvasNetPdf_SystemIntegration_ColoredTilingPatternFill_PaintsRepeatingTileColors diff --git a/docs/reqstream/canvas-net-pdf/pdf-document.yaml b/docs/reqstream/canvas-net-pdf/pdf-document.yaml new file mode 100644 index 00000000..5a8695fd --- /dev/null +++ b/docs/reqstream/canvas-net-pdf/pdf-document.yaml @@ -0,0 +1,1552 @@ +--- +# Software Unit Requirements for PdfDocument +# +# cspell:ignore xref startxref endobj endstream ObjStm MediaBox Zapf Nonsymbolic subsetted +# cspell:ignore beginbfchar endbfchar beginbfrange endbfrange codepoints begincodespacerange +# cspell:ignore endcodespacerange findresource defineresource currentdict begincmap endcmap +# cspell:ignore bfchar bfrange usecmap cidrange cidchar +# cspell:ignore fontfile stdenc quoteright Quoteright quotesingle hival +# cspell:ignore charsets +# cspell:ignore SASLprep +# cspell:ignore Noto +# cspell:ignore functiontype +# +# These requirements describe the design-level behavior of the PdfDocument unit. +# +# Packaging note: PdfDocument is distributed as the separate DemaConsulting.CanvasNet.Pdf NuGet +# package (namespace DemaConsulting.CanvasNet.Pdf), which references the core +# DemaConsulting.CanvasNet package. PdfDocument is the sole unit of the CanvasNetPdf system. + +sections: + - title: CanvasNetPdf Requirements + sections: + - title: PdfDocument Requirements + requirements: + - id: CanvasNetPdf-PdfDocument-Tokenizer + title: >- + The PdfDocument class shall tokenize a PDF byte stream into numbers (integer and + real, including leading-plus and bare-dot forms), literal strings (including nested + parentheses and backslash/octal escapes), hexadecimal strings (padding an odd digit + count with a trailing zero nibble), names (decoding # xx hash escapes), array and + dictionary delimiters, and the reserved keywords, while skipping %-comments. + justification: | + Every later parsing stage (object model, cross-reference tables/streams, page tree) + depends on a correct, spec-conformant token stream; a tokenizer defect would corrupt + every downstream feature silently. + tests: + - PdfDocument_Tokenizer_Numbers_ParsesIntegerAndRealForms + - PdfDocument_Tokenizer_LiteralString_HandlesNestedParensAndEscapes + - PdfDocument_Tokenizer_HexString_DecodesFullBytes + - PdfDocument_Tokenizer_HexString_PadsOddDigitCountWithTrailingZero + - PdfDocument_Tokenizer_Name_DecodesHashEscapes + - PdfDocument_Tokenizer_Delimiters_RecognizesArrayAndDictionaryBrackets + - PdfDocument_Tokenizer_Comments_AreSkipped + - PdfDocument_Tokenizer_Keywords_AreRecognizedVerbatim + - PdfDocument_Tokenizer_EndOfInput_ReturnsEndOfFileToken + + - id: CanvasNetPdf-PdfDocument-ObjectModel + title: >- + The PdfDocument class shall parse a token stream into its internal object model + (dictionaries with nested entries, arrays of mixed element kinds, and indirect + references), distinguishing a bare number followed by an object-definition header + from an indirect reference. + justification: | + PDF's dictionary/array/reference object model is the structural backbone every + cross-reference and page-tree feature is built on; it must be parsed correctly and + distinctly from a superficially similar bare-number-then-keyword sequence. + tests: + - PdfDocument_ObjectModel_Dictionary_ParsesNestedEntries + - PdfDocument_ObjectModel_Array_ParsesMixedElementTypes + - PdfDocument_ObjectModel_IndirectReference_ParsesObjectAndGenerationNumbers + - PdfDocument_ObjectModel_BareNumber_IsNotMisreadAsReference + + - id: CanvasNetPdf-PdfDocument-ClassicXref + title: >- + The PdfDocument class shall parse a classic xref table and trailer dictionary, + resolving the catalog and every page object it references. + justification: | + The classic xref/trailer form is the original, still-common cross-reference format + and must be supported for broad real-world document compatibility. + tests: + - PdfDocument_Open_ClassicXref_ResolvesRootAndPage + + - id: CanvasNetPdf-PdfDocument-XrefStream + title: >- + The PdfDocument class shall parse a /Type /XRef cross-reference stream (with no + classic table present), decoding its /W width-triplet-driven binary fields, and + resolve the catalog and every page object it references. + justification: | + Modern PDF producers commonly emit cross-reference streams instead of classic + tables; without this support, a large class of real-world documents would fail to + open. + tests: + - PdfDocument_Open_XrefStream_ResolvesRootAndPage + + - id: CanvasNetPdf-PdfDocument-ObjectStream + title: >- + The PdfDocument class shall decompress a /Type /ObjStm object stream (FlateDecode) + and resolve an indirect object compressed within it, using the stream's /N and + /First header fields. + justification: | + Object streams let producers compactly store many small objects (such as page + dictionaries) inside one compressed stream; without support for resolving objects + compressed this way, many modern documents' pages would be unreachable. + tests: + - PdfDocument_Open_ObjectStream_DecompressesAndResolvesCompressedPage + + - id: CanvasNetPdf-PdfDocument-HybridXref + title: >- + The PdfDocument class shall resolve an object described only by a hybrid document's + supplementary /XRefStm cross-reference stream, even though the document's classic + trailer marks that object number as free. + justification: | + Hybrid-reference files let old readers use the classic table while modern readers + use the supplementary stream to reach objects (such as ones compressed in an object + stream) the classic table cannot describe; without this, such documents would appear + to be missing objects a modern reader should be able to find. + tests: + - PdfDocument_Open_HybridXref_ResolvesCompressedEntryViaXRefStm + + - id: CanvasNetPdf-PdfDocument-LinearScanFallback + title: >- + The PdfDocument class shall fall back to a linear scan of the document for "N G obj" + markers, reconstructing an object-offset table directly, when startxref is absent, + unreadable, or the parsed cross-reference chain does not resolve to a valid catalog. + justification: | + Real-world PDF producers occasionally emit documents with a missing or corrupt + startxref/cross-reference section; a linear-scan fallback lets such otherwise + well-formed documents still be opened instead of always failing outright. + tests: + - PdfDocument_Open_MalformedStartxref_FallsBackToLinearScan + + - id: CanvasNetPdf-PdfDocument-PageTreeTraversal + title: >- + The PdfDocument class shall traverse the document's page tree, from the catalog's + /Pages root through recursively nested /Kids arrays, reporting every /Type /Page + leaf found, in document order. + justification: | + Callers need every page in a document reported, regardless of how deeply the + document's own page tree happens to be nested. + tests: + - PdfDocument_PageTree_Traversal_ReportsAllPagesInDocumentOrder + + - id: CanvasNetPdf-PdfDocument-PageTreeInheritance + title: >- + The PdfDocument class shall inherit a page's /MediaBox and /Rotate from the nearest + ancestor page-tree node that declares one (defaulting to US Letter [0 0 612 792] and + rotation 0 when wholly undeclared in the ancestry), and shall swap the reported + width and height when the normalized effective rotation is 90 or 270 degrees. + justification: | + PDF pages routinely omit their own /MediaBox/Rotate, relying on an ancestor /Pages + node's declaration; callers need the correct, already-inherited and rotation-adjusted + size without independently re-implementing this inheritance and swap logic. + tests: + - PdfDocument_PageTree_Inheritance_ExplicitMediaBoxAndNoRotationIsUnchanged + - PdfDocument_PageTree_Inheritance_Rotation90SwapsDisplayedWidthAndHeight + - PdfDocument_PageTree_Inheritance_MediaBoxInheritedFromPagesNodeWhenPageOmitsIt + + - id: CanvasNetPdf-PdfDocument-PageTreeCycleRejection + title: >- + The PdfDocument class shall detect and reject an unbounded page-tree reference cycle + (a /Kids entry referencing one of its own ancestors) with InvalidDataException. + justification: | + A malformed or adversarial document's page tree could otherwise cause an unbounded + traversal loop; rejecting the cycle keeps parsing bounded and predictable. + tests: + - PdfDocument_PageTree_CycleRejection_ThrowsInvalidDataException + + - id: CanvasNetPdf-PdfDocument-EncryptDetection + title: >- + The PdfDocument class shall detect an /Encrypt key in the trailer and throw + UnsupportedImageFeatureException with its own distinguishable feature token (for + example "pdf-encrypted-filter-{name}", "pdf-encrypted-r6-hardened-hash", + "pdf-encrypted-crypt-filter-{name}", "pdf-encrypted-cfm-{name}", + "pdf-encrypted-password-required", "pdf-encrypted-incorrect-password", or + "pdf-encrypted-password-non-ascii") for every encrypted-document shape this phase + does not support: a non-/Standard security handler, AES-256 /R 6 "hardened hash" + key derivation, a crypt filter other than the standard /StdCF, a document that + genuinely requires a non-empty password, a supplied password that authenticates + as neither the user nor the owner password, or an R2-R4 password containing a + non-ASCII character. + justification: | + Treating still-encrypted bytes as plaintext would silently produce meaningless + results; a clear, well-formed-but-unsupported signal, distinguishable per rejection + reason, lets callers diagnose exactly why a given encrypted document cannot be + opened instead of receiving one generic, undifferentiated error. + tests: + - PdfDocument_Open_EncryptedTrailer_ThrowsUnsupportedImageFeatureException + - CanvasNetPdf_SystemIntegration_PdfEncryptDetection_EncryptedTrailerThrowsUnsupportedImageFeatureException + - PdfDocument_Open_EncryptedAesV3_R6_ThrowsUnsupportedImageFeatureException + - PdfDocument_Open_EncryptedRc4_WrongUserPasswordHash_ThrowsUnsupportedImageFeatureException + - PdfDocument_Open_EncryptedAesV3_WrongValidationHash_ThrowsUnsupportedImageFeatureException + + - id: CanvasNetPdf-PdfDocument-EncryptionRc4 + title: >- + The PdfDocument class shall open a /Filter /Standard, /V 1 or /V 2, RC4-encrypted + (40 to 128-bit) document with an empty user password: deriving the file encryption + key via ISO 32000-1 Algorithm 2, authenticating the empty user password against /U + via Algorithm 4 (/R 2) or Algorithm 5 (/R 3/4), and transparently decrypting every + indirect object's strings and every stream's raw bytes via Algorithm 1 before any + other parsing logic observes them. + justification: | + RC4 (40 to 128-bit) with an empty user password is the single most common real-world + encrypted-PDF shape; supporting it lets CanvasNetPdf open and render the vast + majority of permission-restricted PDFs found in the wild without requiring a caller + to supply any password. + tests: + - PdfDocument_Open_EncryptedRc4_40Bit_DecryptsAndRenders + - PdfDocument_Open_EncryptedRc4_128Bit_DecryptsAndRenders + - PdfDocument_Open_Encrypted_EncryptDictionaryStringsAreNeverDecrypted + - PdfDocument_Open_Encrypted_CatalogOwnStringIsDecrypted + - PdfDocument_Open_Encrypted_ObjStmContainedObjects_AreNeverDoubleDecrypted_DocumentedByDesign + + - id: CanvasNetPdf-PdfDocument-EncryptionAesV2 + title: >- + The PdfDocument class shall open a /Filter /Standard, /V 4, /R 4, + /CF/StdCF/CFM /AESV2 (AES-128) encrypted document with an empty user password: + deriving the file encryption key via Algorithm 2, authenticating the empty user + password against /U via Algorithm 5, and transparently decrypting every indirect + object's strings and every stream's raw bytes via Algorithm 1's AES branch (a + per-object key, a leading 16-byte CBC initialization vector, and PKCS#7 padding) + before any other parsing logic observes them. + justification: | + AES-128 (/V 4/AESV2) is a common encrypted-PDF shape produced by modern PDF writers + that have moved past plain RC4; supporting it extends empty-user-password coverage + to this equally common real-world case. + tests: + - PdfDocument_Open_EncryptedAesV2_128Bit_DecryptsAndRenders + + - id: CanvasNetPdf-PdfDocument-EncryptionAesV3R5 + title: >- + The PdfDocument class shall open a /Filter /Standard, /V 5, /R 5, + /CF/StdCF/CFM /AESV3 (AES-256, R5 key derivation) encrypted document with an empty + user password: authenticating the empty user password against /U's validation salt + and unwrapping /UE via ISO 32000-2 Algorithm 2.A, then using the resulting 32-byte + file encryption key directly (with no further per-object key derivation) to decrypt + every indirect object's strings and every stream's raw bytes as a leading 16-byte + CBC initialization vector followed by AES-256/PKCS#7-padded ciphertext. + justification: | + AES-256 using the simpler R5 key derivation is a real-world encrypted-PDF shape + found in the wild; supporting it (while deliberately rejecting /R 6's more complex + "hardened hash" key derivation, which this phase does not implement) extends + empty-user-password coverage to the strongest widely-deployed encryption variant + this phase supports. + tests: + - PdfDocument_Open_EncryptedAesV3_R5_DecryptsAndRenders + + - id: CanvasNetPdf-PdfDocument-UserPasswordAuthentication + title: >- + The PdfDocument class shall accept an optional caller-supplied password (default + null, preserving today's empty-user-password-only behavior byte-for-byte) on both + Open overloads and authenticate it as the user password (Algorithm 2 plus Algorithm + 4/5 for R2-R4, or Algorithm 2.A for R5) when it is the document's correct user + password, decrypting and rendering the document normally, regardless of which + supported cipher (RC4, AESV2, or AESV3/R5) the document uses. + justification: | + Several real-world password-protected PDFs cannot be opened at all without a way to + supply their actual user password; this unblocks that entire class of document + while keeping every existing empty-password caller's behavior unchanged. + tests: + - PdfDocument_Open_EncryptedRc4_CorrectUserPassword_DecryptsAndRenders + - PdfDocument_Open_EncryptedAesV2_CorrectUserPassword_DecryptsAndRenders + - PdfDocument_Open_EncryptedAesV3_CorrectUserPassword_DecryptsAndRenders + + - id: CanvasNetPdf-PdfDocument-OwnerPasswordAuthentication + title: >- + The PdfDocument class shall, when a caller-supplied password does not authenticate + as the user password, next try it as the owner password (ISO 32000-1 Algorithm 3 + for R2-R4, recovering the padded user password from /O; the owner-password variant + of ISO 32000-2 Algorithm 2.A for R5, recovering the file key directly from /OE + using the full 48-byte /U value), decrypting and rendering the document normally + when it is the document's correct owner password. + justification: | + A caller may only know a password-protected PDF's owner password (not its separate + user password); falling back to owner-password authentication lets that caller + still open and render the document instead of being unconditionally rejected. + tests: + - PdfDocument_Open_EncryptedRc4_CorrectOwnerPassword_DecryptsAndRenders + - PdfDocument_Open_EncryptedAesV3_CorrectOwnerPassword_DecryptsAndRenders + + - id: CanvasNetPdf-PdfDocument-IncorrectPasswordRejection + title: >- + The PdfDocument class shall throw UnsupportedImageFeatureException with feature + "pdf-encrypted-incorrect-password" when a caller-supplied password authenticates as + neither the user nor the owner password, rather than silently decrypting with + wrong key material or reporting the null-password "pdf-encrypted-password-required" + feature. + justification: | + Callers need a clear, distinguishable signal that the password they supplied is + simply wrong, not that the document is unsupported for some other reason or that no + password was supplied at all. + tests: + - PdfDocument_Open_Encrypted_IncorrectPassword_ThrowsUnsupportedImageFeatureException + + - id: CanvasNetPdf-PdfDocument-PasswordNonAsciiRejection + title: >- + The PdfDocument class shall throw UnsupportedImageFeatureException with feature + "pdf-encrypted-password-non-ascii" when a caller-supplied password for an R2-R4 + (Latin-1-encoded) encrypted document contains a character outside ASCII (0-127), + rather than silently deriving wrong key material from a truncated or mis-encoded + byte sequence. + justification: | + R2-R4's Latin-1 password encoding has no well-defined behavior for non-ASCII + characters; rejecting them explicitly prevents a caller from being misled by a + silent authentication failure traceable to encoding, not an actually-wrong password. + tests: + - PdfDocument_Open_Encrypted_NonAsciiPassword_ThrowsUnsupportedImageFeatureException + + - id: CanvasNetPdf-PdfDocument-Open + title: >- + The PdfDocument class shall expose Open(Stream) and Open(string) overloads that + fully buffer their input and parse it identically, rejecting a null stream or path + with ArgumentNullException and an empty or whitespace-only path with + ArgumentException, without taking ownership of (or disposing) a caller-supplied + stream. + justification: | + Callers need a predictable, symmetric entry point regardless of whether their PDF + data already lives in a stream or only exists as a file on disk, and must retain + ownership of any stream they pass in themselves. + tests: + - PdfDocument_Open_NullStream_ThrowsArgumentNullException + - PdfDocument_Open_NullPath_ThrowsArgumentNullException + - PdfDocument_Open_EmptyOrWhitespacePath_ThrowsArgumentException + + - id: CanvasNetPdf-PdfDocument-GetPageInfo + title: >- + The PdfDocument class shall reject a GetPageInfo call with a negative page index or + one greater than or equal to PageCount with ArgumentOutOfRangeException. + justification: | + Callers need a clear, standard validation error rather than an out-of-bounds crash + or a silently wrong result when passing an invalid page index. + tests: + - PdfDocument_GetPageInfo_OutOfRangeIndex_ThrowsArgumentOutOfRangeException + + - id: CanvasNetPdf-PdfDocument-Render + title: >- + The PdfDocument class shall return a Surface of the caller-specified width and + height for a valid page index, painting it with the page's interpreted content- + stream geometry (see CanvasNetPdf-PdfDocument-ContentStreamDispatch and its sibling + requirements below) or, when the page declares no /Contents at all, a fully + transparent surface; it shall validate the page index the same way GetPageInfo does, + and propagate Surface's own width/height validation unwrapped rather than + duplicating it. + justification: | + Phase 1 established Render's final call shape (page index plus caller-chosen output + size) so Phase 2's real content-stream rendering could be added without a breaking + API change; callers need a correctly sized, correctly painted result. + tests: + - PdfDocument_Render_ValidPageIndex_ReturnsCorrectlySizedBlankSurface + - PdfDocument_Render_OutOfRangePageIndex_ThrowsArgumentOutOfRangeException + - PdfDocument_Render_InvalidWidth_PropagatesSurfaceArgumentOutOfRangeException + - PdfDocument_ContentStream_NoContents_RendersBlankSurface + + - id: CanvasNetPdf-PdfDocument-RenderWithDpi + title: >- + The PdfDocument class shall provide a Render overload that renders a page at a + caller-specified resolution (in pixels per inch) rather than requiring the caller to + compute an output width/height in pixels themselves, scaling the page's own + rotation-adjusted point-space size by dpi/72, preserving its aspect ratio, and + rounding the result to the nearest pixel before producing the same painted Surface + as the width/height overload; it shall reject a dpi that is not a positive, finite + number, and validate the page index the same way the width/height overload does. + justification: | + Callers frequently think in terms of a target resolution (dots per inch) rather than + an absolute pixel size, and computing a page's correctly aspect-ratio-preserving + pixel dimensions from its point-space size themselves duplicates logic this class + already has one correct, centralized place to provide (see + CanvasNetPdf-PdfDocument-Render, which this overload delegates to). + tests: + - PdfDocument_RenderWithDpi_ScalesPageSizeByDpiOver72 + - PdfDocument_RenderWithDpi_InvalidDpi_ThrowsArgumentOutOfRangeException + - PdfDocument_RenderWithDpi_OutOfRangePageIndex_ThrowsArgumentOutOfRangeException + - PdfDocument_RenderWithDpi_AfterDispose_ThrowsObjectDisposedException + + - id: CanvasNetPdf-PdfDocument-ContentStreamDispatch + title: >- + The PdfDocument class shall tokenize and dispatch a page's resolved content-stream + bytes over a fixed set of recognized path-construction/painting and graphics-state + operators, silently skipping any other keyword (color, text, image, clipping, and + any other operator not yet implemented) rather than throwing, and shall throw + InvalidDataException when a recognized operator's own operand count/type does not + match its documented requirement. + justification: | + Real-world content streams routinely contain operators outside this phase's scope + (color, text, images, clipping); a lenient-toward-the-unrecognized, strict-toward- + the-malformed-recognized dispatch loop lets such streams still render their in-scope + geometry instead of failing outright. + tests: + - PdfDocument_ContentStream_UnknownOperator_IsSkippedWithoutThrowing + - CanvasNetPdf_SystemIntegration_PdfRender_FilledRectangleAndStrokedLine_PaintsExpectedPixels + + - id: CanvasNetPdf-PdfDocument-ContentsResolution + title: >- + The PdfDocument class shall resolve a page's /Contents entry to fully decoded + content-stream bytes whether declared as a single stream or as an array of streams, + concatenating array entries with a single space byte between each so adjacent tokens + from different streams can never merge into one, and shall throw + InvalidDataException when /Contents is neither a stream nor an array of streams, or + when an array entry does not itself resolve to a stream. + justification: | + The PDF specification explicitly requires the space separator between concatenated + /Contents array entries; omitting it can silently corrupt otherwise well-formed + geometry by merging two numeric operands from adjacent streams into one. + tests: + - PdfDocument_ContentStream_ContentsArray_ConcatenatesStreamsWithSpaceSeparator + + - id: CanvasNetPdf-PdfDocument-GraphicsStateStack + title: >- + The PdfDocument class shall maintain a graphics-state stack via the q/Q operators + (tolerating an unbalanced Q with no matching prior q as a no-op), compose the + current transformation matrix via the cm operator by right-multiplying the supplied + matrix onto the current transform, and store the line width/cap/join/miter-limit/ + dash-pattern operands of w/J/j/M/d on the current graphics state, all with + InvalidDataException thrown for a malformed operand count. + justification: | + Nested q/cm/Q blocks are the standard PDF idiom for scoping a coordinate-space or + stroke-style change to one part of a content stream; without a correctly composing, + correctly restoring stack, every subsequently drawn path's position/appearance could + be silently wrong. + tests: + - PdfDocument_GraphicsState_QPushCmThenQPop_RestoresPriorTransform + - PdfDocument_GraphicsState_NestedQQ_ComposesTransformsInOrder + - PdfDocument_GraphicsState_UnbalancedQWithNoMatchingPush_DoesNotThrow + - PdfDocument_GraphicsState_MalformedCmOperandCount_ThrowsInvalidDataException + + - id: CanvasNetPdf-PdfDocument-CtmDerivation + title: >- + The PdfDocument class shall derive each page's base (pre-content-stream) current + transformation matrix from its raw /MediaBox origin, effective /Rotate, and the + caller's requested render width/height, mapping PDF user-space coordinates (origin + bottom-left, y-axis up) to device pixel-space coordinates (origin top-left, y-axis + down) with the correct rotation and independent X/Y scaling to the requested size. + justification: | + An incorrect CTM derivation would silently misplace every rendered page's geometry; + this is most reliably proven with a deliberately asymmetric fixture at a non-zero + rotation, where any rotation-sign or origin-offset error produces a detectably wrong + pixel position rather than a merely-shifted-but-plausible one. + tests: + - CanvasNetPdf_SystemIntegration_PdfRender_RotatedPage_MapsGeometryToCorrectPixelPosition + + - id: CanvasNetPdf-PdfDocument-PathConstruction + title: >- + The PdfDocument class shall build the current path from the m/l/c/v/y/h/re path- + construction operators exactly as the PDF specification documents (v's first control + point defaults to the current point; y's second control point defaults to the + curve's own endpoint; re appends an independently closed rectangular subpath), baking + the current transformation matrix into every emitted point, requiring an already-open + subpath for every operator except m/re, and throwing InvalidDataException for a + malformed operand count or a draw operator issued before the first m/re. + justification: | + Path construction is the geometric foundation every fill/stroke operator paints; + each operator (especially the v/y curve shorthands, whose control-point defaulting + rules are easy to get backwards) must build exactly the curve/line/rectangle the + specification documents. + tests: + - PdfDocument_PathOps_MoveLineRectCurve_BuildExpectedGeometry + - PdfDocument_PathOps_MalformedOperandCount_ThrowsInvalidDataException + - PdfDocument_PathOps_DrawBeforeMoveTo_ThrowsInvalidDataException + + - id: CanvasNetPdf-PdfDocument-PathPainting + title: >- + The PdfDocument class shall paint the current path via the f/F/f*/S/s/B/B*/b/b*/n + path-painting operators (choosing the nonzero or even-odd fill rule as documented, + closing the current subpath first for s/b/b*, and never painting anything for n) + using the current graphics state's fill/stroke color, and shall always clear the + current path afterward while leaving the surrounding graphics state, including its + CTM, stroke style, and color, unaffected by that clear. + justification: | + Callers need every documented fill-rule/stroke/close-first combination and the + path-clearing-but-state-preserving behavior between consecutive painted paths to + behave exactly as the specification requires, or subsequent paths in the same + content stream could silently paint with a stale path or a reset graphics state. + tests: + - PdfDocument_PathOps_FillNonZero_PaintsExpectedPixels + - PdfDocument_PathOps_FillEvenOdd_PaintsExpectedPixels + - PdfDocument_PathOps_Stroke_PaintsExpectedPixels + - PdfDocument_PathOps_CloseAndFillAndStroke_PaintsExpectedPixels + - PdfDocument_PathOps_NoOp_DiscardsPathWithoutPainting + - PdfDocument_PathOps_PaintOperator_ClearsPathButPreservesGraphicsState + + - id: CanvasNetPdf-PdfDocument-Dispose + title: >- + The PdfDocument class shall make Dispose idempotent, safe to call more than once + without throwing. + justification: | + Callers commonly combine a using declaration with manual disposal (or dispose more + than once for other reasons); Dispose must tolerate this without throwing. + tests: + - PdfDocument_Dispose_CalledTwice_DoesNotThrow + + - id: CanvasNetPdf-PdfDocument-ObjectDisposedException + title: >- + The PdfDocument class shall throw ObjectDisposedException from PageCount, + GetPageInfo, and Render once the document has been disposed. + justification: | + Callers must not be able to read stale or released parsed state through a disposed + PdfDocument instance. + tests: + - PdfDocument_PageCount_AfterDispose_ThrowsObjectDisposedException + - PdfDocument_GetPageInfo_AfterDispose_ThrowsObjectDisposedException + - PdfDocument_Render_AfterDispose_ThrowsObjectDisposedException + + - id: CanvasNetPdf-PdfDocument-DeviceColorOperators + title: >- + The PdfDocument class shall implement the g/G/rg/RG/k/K device color operators, + setting the current fill/stroke color from clamped-to-[0,1] gray/RGB/CMYK + components (CMYK converted via R = 255 * (1 - C) * (1 - K), and the equivalent + formulas for G/B), clamping any component outside [0,1] rather than rejecting it, + and throwing InvalidDataException for a malformed operand count or type. + justification: | + Real device color is the direct Phase 3 successor to Phase 2's fixed opaque-black + painting; every filled/stroked path must reflect the color a content stream + actually requests, converted per the PDF specification's own documented formulas. + tests: + - PdfDocument_Color_SetGrayFill_SetsExpectedRgbaColor + - PdfDocument_Color_SetGrayStroke_SetsExpectedRgbaColor + - PdfDocument_Color_SetRgbFill_SetsExpectedRgbaColor + - PdfDocument_Color_SetRgbStroke_SetsExpectedRgbaColor + - PdfDocument_Color_SetCmykFill_ConvertsToExpectedRgbaColor + - PdfDocument_Color_SetCmykStroke_ConvertsToExpectedRgbaColor + - PdfDocument_Color_ComponentValuesOutsideZeroToOne_AreClamped + - PdfDocument_Color_MalformedOperandCount_ThrowsInvalidDataException + + - id: CanvasNetPdf-PdfDocument-ColorSpaceOperators + title: >- + The PdfDocument class shall implement the cs/CS operators, resolving DeviceGray/ + DeviceRGB/DeviceCMYK directly or via the current page's /Resources/ColorSpace + dictionary and resetting color to opaque black, and the sc/SC/scn/SCN operators, + setting color from the current color space's required component count; any other + resolved color space (Separation/DeviceN/CalGray/Lab, or an undeclared name) shall + throw UnsupportedImageFeatureException rather than being silently mis-rendered; a + scn/SCN operand against a /Pattern color space is instead resolved per + CanvasNetPdf-PdfDocument-PatternColorSpace (see also + CanvasNetPdf-PdfDocument-ShadingPatternFill and + CanvasNetPdf-PdfDocument-TilingPatternFill for how the resolved pattern is + ultimately painted). + justification: | + A content stream that selects an unsupported color space must be rejected + explicitly (fail closed), not silently mis-rendered with the wrong number of + components or an incorrect color, so a caller can detect the unsupported feature + rather than receiving a plausible-looking but wrong image; a /Pattern color space + selection, by contrast, is now a fully supported fill/stroke source (shading and + tiling pattern fills), not a fail-closed case. + tests: + - PdfDocument_Color_SetColorSpaceFill_DeviceNames_ResetsColorToBlack + - PdfDocument_Color_SetColorSpaceStroke_DeviceNames_ResetsColorToBlack + - PdfDocument_Color_SetColorFillUsingCurrentColorSpace_Sc_PaintsExpectedColor + - PdfDocument_Color_SetColorStrokeUsingCurrentColorSpace_SC_PaintsExpectedColor + - PdfDocument_Color_Scn_ColoredPatternOperand_NameAlone_ResolvesPattern + - PdfDocument_Color_UnsupportedNamedColorSpace_ThrowsUnsupportedImageFeatureException + - CanvasNetPdf_SystemIntegration_PdfRender_ColoredRectangleFill_PaintsExpectedRgbPixels + + - id: CanvasNetPdf-PdfDocument-IccBasedColorSpace + title: >- + The PdfDocument class shall resolve an /ICCBased color space by preferring its + /Alternate entry when present and itself a supported color space, else mapping its + stream's /N component count (1/3/4) to DeviceGray/DeviceRGB/DeviceCMYK, and shall + throw UnsupportedImageFeatureException when neither /Alternate nor a 1/3/4 /N is + usable. + justification: | + ICC-managed color is out of scope for this phase, but /ICCBased is the most common + way real-world PDF producers declare an otherwise ordinary device color space; + rejecting it outright would needlessly fail closed on a vast class of well-formed, + common PDFs that this phase can render correctly using the device-color + approximation it already implements elsewhere. + tests: + - PdfDocument_Color_IccBasedN3_ResolvesAsDeviceRgb + - PdfDocument_Color_IccBasedWithAlternate_PrefersAlternateOverN + - PdfDocument_Color_IccBasedUnsupportedN_ThrowsUnsupportedImageFeatureException + + - id: CanvasNetPdf-PdfDocument-IndexedColorSpace + title: >- + The PdfDocument class shall resolve an /Indexed color space (base color space, + Hival, and a string- or stream-sourced lookup table) as a single-component palette + index, usable both as an image XObject's /ColorSpace and via cs/CS/sc/scn, clamping + an out-of-range index to the nearest valid (highest) palette entry rather than + rejecting it, and shall throw UnsupportedImageFeatureException when the base color + space is itself unsupported. + justification: | + Indexed-color images (palette-based, akin to GIF/indexed PNG) are common in + real-world PDF content; without /Indexed support such images would fail closed + entirely rather than rendering their already-decodable palette colors. + tests: + - PdfDocument_Images_DoOperator_IndexedColorSpace_PlacesExpectedPaletteColors + - PdfDocument_Color_IndexedColorSpace_Scn_PaintsExpectedPaletteColor + - PdfDocument_Color_IndexedColorSpace_OutOfRangeIndex_ClampsToHighestPaletteEntry + - PdfDocument_Color_IndexedColorSpace_UnsupportedBase_ThrowsUnsupportedImageFeatureException + + - id: CanvasNetPdf-PdfDocument-FilterPipeline + title: >- + The PdfDocument class shall generalize stream decoding into an ordered /Filter/ + /DecodeParms pipeline, supporting FlateDecode and LZWDecode (each optionally + followed by a PNG /Predictor 10-15 or TIFF /Predictor 2 predictor reversal) and + ASCII85Decode/ASCIIHexDecode/RunLengthDecode (which never accept a predictor), + while preserving the existing bare-FlateDecode behavior for cross-reference + streams, object streams, and page /Contents; any other filter name shall throw + UnsupportedImageFeatureException, and a malformed /Filter//DecodeParms shape or an + unrecognized /Predictor value shall throw InvalidDataException. + justification: | + Image XObjects commonly use a PNG or TIFF predictor alongside FlateDecode or + LZWDecode to improve compression; without predictor reversal, such an image would + decode to incorrect pixel data rather than failing closed or decoding correctly, + and every existing Phase 1/2 stream-decoding call site must continue to behave + identically. + tests: + - PdfDocument_Images_FlateDecodePngPredictor_DecodesExpectedPixels + - PdfDocument_Images_FlateDecodeTiffPredictor_DecodesExpectedPixels + - PdfDocument_Images_LzwDecodePngPredictor_DecodesExpectedPixels + - PdfDocument_Images_UnsupportedFilter_ThrowsUnsupportedImageFeatureException + + - id: CanvasNetPdf-PdfDocument-LzwDecodeFilter + title: >- + The PdfDocument class shall implement the PDF-variant LZWDecode filter per ISO + 32000-1/2 section 7.4.4: an initial 258-entry table (codes 0-255 literal, 256 + Clear, 257 EOD), MSB-first variable 9-12 bit code packing, code-width growth timing + controlled by /DecodeParms /EarlyChange (default 1, one code early; 0, one code + later), and mid-stream Clear-code table reinitialization; a missing EOD marker, an + invalid or out-of-range code, or a truncated bit stream shall throw + InvalidDataException rather than silently producing truncated or incorrect output. + justification: | + LZWDecode is a commonly produced PDF stream filter (content streams and image + XObjects alike); an incorrect early-change interpretation, code-width growth + timing, or truncation-tolerant decoder would silently corrupt decoded data rather + than failing closed or decoding correctly, matching ISO 32000-1's own worked Table + 7/Example 2 vector. + tests: + - PdfDocument_Filters_LzwDecode_SpecExampleTable7_DecodesExpectedBytes + - PdfDocument_Filters_LzwDecode_EarlyChangeDefault_GrowsCodeWidthOneCodeEarly + - PdfDocument_Filters_LzwDecode_EarlyChangeZero_GrowsCodeWidthOneCodeLater + - PdfDocument_Filters_LzwDecode_ClearCodeMidStream_ReinitializesTable + - PdfDocument_Filters_LzwDecode_MissingEodCode_ThrowsInvalidDataException + - PdfDocument_Filters_LzwDecode_InvalidCode_ThrowsInvalidDataException + - PdfDocument_Images_DoOperator_DeviceGrayLzwDecode_PlacesExpectedPixels + - CanvasNetPdf_SystemIntegration_PdfRender_LzwDecodeContentStream_PaintsExpectedPixels + + - id: CanvasNetPdf-PdfDocument-AsciiDecodeFilters + title: >- + The PdfDocument class shall implement the ASCII85Decode filter (base-85 groups of + 5 characters decoding to 4 bytes, the z shorthand for an all-zero 4-byte group at a + group boundary only, the final-partial-group n+1-character padding rule, and the + ~> EOD marker) and the ASCIIHexDecode filter (hex-digit pairs decoding to bytes, + ignoring whitespace, the > EOD marker, and an odd trailing digit implicitly padded + with a zero nibble) per ISO 32000-1/2 sections 7.4.2/7.4.3; an invalid character, a + z occurring mid-group, an out-of-range 5-character group value, a final partial + group of exactly one character, or a missing EOD marker shall throw + InvalidDataException. + justification: | + ASCII85Decode and ASCIIHexDecode are the two ASCII-armoring filters PDF producers + commonly layer over a binary compression filter (frequently LZWDecode); decoding + either incorrectly would corrupt every stream layered beneath it, and malformed + input must fail closed rather than silently decode to the wrong bytes. + tests: + - PdfDocument_Filters_Ascii85Decode_ZeroGroup_DecodesToFourZeroBytes + - PdfDocument_Filters_Ascii85Decode_FullGroup_DecodesExpectedBytes + - PdfDocument_Filters_Ascii85Decode_PartialFinalGroup_AppliesPaddingRule + - PdfDocument_Filters_Ascii85Decode_WhitespaceIgnored_DecodesExpectedBytes + - PdfDocument_Filters_Ascii85Decode_ZInMiddleOfGroup_ThrowsInvalidDataException + - PdfDocument_Filters_Ascii85Decode_ValueExceedsRange_ThrowsInvalidDataException + - PdfDocument_Filters_Ascii85Decode_MissingEodMarker_ThrowsInvalidDataException + - PdfDocument_Filters_AsciiHexDecode_HexDigitPairs_DecodesExpectedBytes + - PdfDocument_Filters_AsciiHexDecode_OddTrailingDigit_PadsWithZeroNibble + - PdfDocument_Filters_AsciiHexDecode_WhitespaceIgnored_DecodesExpectedBytes + - PdfDocument_Filters_AsciiHexDecode_InvalidCharacter_ThrowsInvalidDataException + - PdfDocument_Filters_AsciiHexDecode_MissingEodMarker_ThrowsInvalidDataException + - CanvasNetPdf_SystemIntegration_PdfRender_Ascii85DecodeContentStream_PaintsExpectedPixels + - CanvasNetPdf_SystemIntegration_PdfRender_AsciiHexDecodeContentStream_PaintsExpectedPixels + + - id: CanvasNetPdf-PdfDocument-RunLengthDecodeFilter + title: >- + The PdfDocument class shall implement the RunLengthDecode filter per ISO 32000-1/2 + section 7.4.5: a length byte 0-127 copies the following length+1 bytes literally, + a length byte 129-255 repeats the following single byte 257-length times, and a + length byte of 128 denotes EOD; a run requiring more source bytes than remain shall + throw InvalidDataException rather than silently producing truncated output. + justification: | + RunLengthDecode is a simple, commonly used filter for monochrome image data; a + truncated or malformed run must fail closed, not silently decode to the wrong byte + count. + tests: + - PdfDocument_Filters_RunLengthDecode_LiteralRun_CopiesBytesVerbatim + - PdfDocument_Filters_RunLengthDecode_RepeatRun_RepeatsSingleByte + - PdfDocument_Filters_RunLengthDecode_EodMarker_StopsDecoding + - PdfDocument_Filters_RunLengthDecode_TruncatedRun_ThrowsInvalidDataException + - CanvasNetPdf_SystemIntegration_PdfRender_RunLengthDecodeContentStream_PaintsExpectedPixels + + - id: CanvasNetPdf-PdfDocument-RunLengthDecodeOutputCap + title: >- + The PdfDocument class shall throw InvalidDataException when decoding a + RunLengthDecode-filtered stream whose expanded output would exceed a fixed maximum + size, checking the remaining budget before appending each run rather than only + after the full stream has been decoded. + justification: | + Each 2-byte "repeat" run can expand to up to 128 output bytes (a 64x amplification), + so a tiny, crafted RunLengthDecode-filtered stream could otherwise force an enormous + allocation (a decompression-bomb-style denial of service); bounding the decoded + output size closes that attack surface while remaining generous enough for any + legitimate PDF image/content stream. + tests: + - PdfDocument_Filters_RunLengthDecode_OutputExceedsMaxSize_ThrowsInvalidDataException + + - id: CanvasNetPdf-PdfDocument-CcittFaxDecodeFilter + title: >- + The PdfDocument class shall implement the CCITTFaxDecode image filter's ITU-T T.6 + Group 4 (two-dimensional MMR) coding only: a prefix-free two-dimensional mode code + (Pass/Horizontal/vertical V0/VR1-3/VL1-3) per coding element, White/Black Modified + Huffman run-length codes (terminating 0-63, makeup 64-1728, and the shared extended + makeup 1792-2560) per ITU-T T.4, and a reference-line changing-element (b1/b2) + decode loop, honoring /DecodeParms /Columns (default 1728), /Rows (defaulting to + the image's own /Height when 0 or absent), /BlackIs1 (default false, per ISO + 32000-1/2 Table 11's bit-to-color mapping), and /EncodedByteAlign (default false); + /K 0 or greater (Group 3) and /EndOfLine true shall each throw + UnsupportedImageFeatureException, and CCITTFaxDecode combined with any other filter, + or a resolved /ColorSpace with more than 1 component, shall likewise fail closed + (the latter with UnsupportedImageFeatureException, the former with + InvalidDataException). + justification: | + CCITTFaxDecode (Group 4) is a common, highly compact encoding for monochrome + scanned-document images; without it, such images would either fail closed + unnecessarily or (if misinterpreted) silently render incorrect pixel data - the + mode/run-length code tables and reference-line algorithm must be implemented + exactly per the ITU-T recommendations, and remain explicitly scoped to Group 4 only + (Group 3 and explicit /EndOfLine both fail closed rather than being silently + misdecoded). + tests: + - PdfDocument_Images_DoOperator_CcittFaxGroup4_PlacesExpectedPixels + - PdfDocument_Images_DoOperator_CcittFaxBlackIs1Default_MapsZeroBitToBlack + - PdfDocument_Images_DoOperator_CcittFaxBlackIs1True_InvertsPolarity + - PdfDocument_Images_DoOperator_CcittFaxNonByteAlignedColumns_DecodesExpectedRowPadding + - PdfDocument_Images_CcittFaxGroup3K_ThrowsUnsupportedImageFeatureException + - PdfDocument_Images_CcittFaxCombinedWithAnotherFilter_ThrowsInvalidDataException + - PdfDocument_Images_CcittFaxEndOfLineTrue_ThrowsUnsupportedImageFeatureException + - PdfDocument_Filters_CcittFaxRunLengthTables_RoundTripEveryCode + - PdfDocument_Filters_CcittFaxModeCodeTable_RoundTripEveryCode + - PdfDocument_Images_DoOperator_CcittFaxPassModeElement_PlacesExpectedPixels + - PdfDocument_Images_DoOperator_CcittFaxEncodedByteAlignTrue_SkipsRowPaddingBits + + - id: CanvasNetPdf-PdfDocument-ImageXObjects + title: >- + The PdfDocument class shall implement the Do operator for a /Subtype /Image + XObject, decoding a bare /Filter /DCTDecode image via Codecs.JpegCodec.Load or + interpreting raw DeviceGray/DeviceRGB/DeviceCMYK 8-bit samples, and compositing the + decoded image onto the destination surface through the current transformation + matrix (nearest-neighbor sampling, treating every image as fully opaque); an + unsupported /ColorSpace or an unsupported /BitsPerComponent shall throw + UnsupportedImageFeatureException rather than being silently skipped or + mis-rendered, and a malformed operand count/type or an undeclared XObject name + shall throw InvalidDataException. + justification: | + Image XObjects are a common, visually significant PDF content type; callers need + them decoded and correctly positioned, and need every explicitly out-of-scope + construct (unsupported color spaces/bit depths) to fail closed with a + distinguishable exception rather than being silently skipped or mis-rendered. + tests: + - PdfDocument_Images_DoOperator_DeviceGrayFlateDecode_PlacesExpectedPixels + - PdfDocument_Images_DoOperator_DeviceRgbFlateDecode_PlacesExpectedPixels + - PdfDocument_Images_DoOperator_DeviceCmykFlateDecode_PlacesExpectedPixels + - PdfDocument_Images_DoOperator_DctDecodeJpeg_PlacesExpectedPixels + - PdfDocument_Images_UnsupportedBitsPerComponent_ThrowsUnsupportedImageFeatureException + - PdfDocument_Images_UnsupportedColorSpace_ThrowsUnsupportedImageFeatureException + + - id: CanvasNetPdf-PdfDocument-FormXObjects + title: >- + The PdfDocument class shall implement the Do operator for a /Subtype /Form + XObject: decode its content stream via the generic stream-decoding pipeline, + concatenate an optional /Matrix into the current transformation matrix using the + same left-multiply convention as the cm operator, use the Form's own /Resources + when present else fall back to the invoking content stream's /Resources, and + execute the decoded bytes as a nested content stream that inherits the invoking + stream's current graphics state and leaves it (and the invoking stream's + /Resources) unaffected once Do returns, whether successfully or via a thrown + exception; nested Form XObject invocations exceeding a fixed maximum depth shall + throw InvalidDataException rather than overflowing the call stack. + justification: | + Form XObjects (reusable nested content streams) are a common PDF construct for + shared graphics; callers need their content actually rendered at the correct, + matrix-adjusted position, with correct resource scoping and without corrupting the + invoking content stream's own graphics state, and need a pathological + (self-referencing) Form to fail closed deterministically rather than hang or crash. + tests: + - PdfDocument_Images_DoOperator_FormXObject_PaintsNestedContentStream + - PdfDocument_Images_DoOperator_FormXObjectWithMatrix_AppliesMatrixToNestedContent + - PdfDocument_Images_DoOperator_FormXObjectWithoutOwnResources_FallsBackToInvokingResources + - PdfDocument_Images_DoOperator_FormXObjectWithOwnResources_TakesPrecedenceOverInvokingResources + - PdfDocument_Images_DoOperator_NestedFormXObjects_RestoresGraphicsStateAfterReturn + - PdfDocument_Images_DoOperator_FormXObjectExceedsMaxRecursionDepth_ThrowsInvalidDataException + + - id: CanvasNetPdf-PdfDocument-FontResolution + title: >- + The PdfDocument class shall resolve a /Resources/Font entry naming a /Subtype + /TrueType or /Subtype /Type1 simple font, dispatching either subtype to the same + simple-font resolution: an embedded /FontDescriptor/FontFile2 stream (loaded via + Fonts.TrueTypeFont.Load) if present, else an embedded /FontDescriptor/FontFile + classic PostScript Type 1 stream (see CanvasNetPdf-PdfDocument-Type1FontResolution) + if present, else an embedded /FontDescriptor/FontFile3 bare Type1C/CFF stream (see + CanvasNetPdf-PdfDocument-Type1CFontResolution) if present, else system/bundled + font-fallback substitution (see CanvasNetPdf-PdfDocument-FontFallback); a font whose + /Subtype is /MMType1 shall throw UnsupportedImageFeatureException, a /Subtype + /Type0 composite font shall instead be resolved via + CanvasNetPdf-PdfDocument-CompositeFontDispatch, a /Subtype /Type3 font shall + instead be resolved via CanvasNetPdf-PdfDocument-Type3FontResolution, and a Tf + operator naming a font absent from /Resources/Font shall throw + InvalidDataException; caching the resolved font by font-dictionary object identity + for the duration of a single Render call. + justification: | + Multiple Master Type 1 fonts (/MMType1) remain explicitly out of scope and must + fail closed with a distinguishable exception; a non-embedded simple font + (/TrueType or /Type1 alike), however, is common enough in real-world PDFs + (referencing a standard-14 name, or an installed system font, without embedding + it) that silently refusing to render it is a worse outcome than a best-effort + visual substitute - including when the font dictionary declares no + /FontDescriptor at all, as PDF 32000-1 ยง9.6.2.2 explicitly permits for the standard + 14 fonts and several real-world producers emit - and repeatedly selecting the same + font resource within one render must not repeatedly re-decode its embedded font + bytes. + tests: + - PdfDocument_Fonts_UnsupportedSubtype_ThrowsUnsupportedImageFeatureException + - PdfDocument_Fonts_UndefinedFontName_ThrowsInvalidDataException + - PdfDocument_BuildResolvedFont_EmbeddedFontFileTakesPriorityOverFallback + - PdfDocument_Fonts_Type1_DispatchesToSimpleFontResolution_PaintsGlyphInk + - PdfDocument_Fonts_Type1_NoFontFile_ResolvesViaFallback + - PdfDocument_Fonts_SimpleFont_NoFontDescriptorAtAll_ResolvesViaFallback + + - id: CanvasNetPdf-PdfDocument-CompositeFontDispatch + title: >- + The PdfDocument class shall resolve a /Resources/Font entry naming a /Subtype + /Type0 composite font by requiring /Encoding to be the name /Identity-H and + /DescendantFonts to be a single-element array naming a descendant font dictionary, + dispatching the descendant to CIDFontType2 or CIDFontType0 resolution by its own + /Subtype, and decoding each shown string as a sequence of 2-byte codes (each a CID + directly, per /Identity-H); any other /Encoding (including /Identity-V and + predefined CJK encodings) shall throw UnsupportedImageFeatureException, a + missing/malformed /DescendantFonts or an odd-byte-length shown string shall throw + InvalidDataException. + justification: | + Type0/Identity-H composite fonts are the encoding real-world PDF producers + (Microsoft Word, LibreOffice, and browser print-to-PDF among them) overwhelmingly + emit for embedded subsetted text, so continuing to reject every /Type0 font + unconditionally is a significant real-world compatibility gap. Vertical writing + (/Identity-V), predefined CJK encodings, and custom CMap-based /Encoding streams + remain explicitly out of this phase's scope and must fail closed with a + distinguishable exception rather than being silently mis-rendered. + tests: + - PdfDocument_Fonts_Type0_NonIdentityHEncoding_ThrowsUnsupportedImageFeatureException + - PdfDocument_Fonts_Type0_MissingDescendantFonts_ThrowsInvalidDataException + - PdfDocument_Fonts_Type0_DescendantFontsNotSingleElement_ThrowsInvalidDataException + - PdfDocument_ShowText_Type0_OddByteLengthString_ThrowsInvalidDataException + - PdfDocument_ShowText_Type0_TwoByteCodes_ShowsEachGlyphAtCorrectPosition + + - id: CanvasNetPdf-PdfDocument-CidFontType2FontResolution + title: >- + The PdfDocument class shall resolve a /Type0 font's /Subtype /CIDFontType2 + descendant by loading its embedded /FontDescriptor/FontFile2 TrueType-outline + program, mapping each shown CID to a glyph index via the descendant's + /CIDToGIDMap (the identity map when absent or the name /Identity, or an explicit + big-endian uint16-per-CID lookup table for a stream, with an out-of-range CID + mapping to glyph 0/.notdef); a missing/non-embedded /FontFile2 shall throw + InvalidDataException (a composite font has no fallback substitution path, unlike a + simple TrueType font). + justification: | + /CIDFontType2 (TrueType-outline) is the composite-font descendant shape real-world + PDF producers emit when embedding a subset of a TrueType source font, so resolving it and its + /CIDToGIDMap correctly is required for that common case to render correctly rather + than with wrong or missing glyphs. + tests: + - PdfDocument_Fonts_Type0_IdentityHCidFontType2_ResolvesEmbeddedFont + - PdfDocument_Fonts_Type0_CidToGidMapIdentity_UsesCidAsGid + - PdfDocument_Fonts_Type0_CidToGidMapStream_RemapsCidToGid + - PdfDocument_Fonts_Type0_CidToGidMapStream_OutOfRangeCid_MapsToNotdef + - PdfDocument_Fonts_Type0_NoEmbeddedFontFile_ThrowsInvalidDataException + - CanvasNetPdf_SystemIntegration_RenderType0CompositeFont_PaintsExpectedGlyphInk + + - id: CanvasNetPdf-PdfDocument-CidFontType0FontResolution + title: >- + The PdfDocument class shall resolve a /Type0 font's /Subtype /CIDFontType0 + descendant by loading its embedded, non-CID-keyed-CFF /FontDescriptor/FontFile3 + stream - either a recognized SFNT container or a structurally plausible bare CFF + header, sniffed from the stream's own decoded bytes rather than trusted from its + declared /Subtype name - and mapping each shown CID directly to the identical glyph + index (per PDF 32000-1 ยง9.7.4.2, since the supported CFF programs are not + CID-keyed), ignoring any /CIDToGIDMap entry present (not a valid key for this + subtype). A missing/non-embedded /FontFile3 or an unparsable embedded CID-keyed CFF + program shall throw InvalidDataException, and a /FontFile3 stream whose decoded + bytes match neither a recognized SFNT container nor a structurally plausible bare + CFF header shall throw UnsupportedImageFeatureException. + justification: | + /CIDFontType0 (non-CID-keyed-CFF-outline) is the composite-font descendant shape + real-world PDF producers emit when embedding a subset of a CFF/OpenType-CFF source font + interchangeably with /CIDFontType2, so continuing to reject it unconditionally + would be a real-world compatibility gap as significant as the one already closed + for /CIDFontType2. The /FontFile3 stream is resolved by sniffing its own decoded + bytes for a recognized SFNT container or a structurally plausible bare CFF header, + rather than requiring the stream's declared /Subtype to be exactly /OpenType or + /CIDFontType0C, because PDF 32000-1 permits (and real-world producers sometimes + emit) a mismatch between the declared /Subtype name and the font program's actual + byte container shape. CID-keyed CFF (ROS/FDArray/FDSelect) remains a separately- + scoped future phase and must fail closed with a distinguishable exception rather + than being silently mis-rendered. + tests: + - PdfDocument_Fonts_Type0_CidFontType0_NoFontFile3_ThrowsInvalidDataException + - PdfDocument_Fonts_Type0_CidFontType0_OpenTypeCff_ResolvesEmbeddedFont + - PdfDocument_Fonts_Type0_CidFontType0_NonStandardCidToGidMap_IsIgnored + - PdfDocument_Fonts_Type0_CidFontType0_MismatchedSubtype_SfntBytes_ResolvesEmbeddedFont + - PdfDocument_Fonts_Type0_CidFontType0_MissingFontFile3Subtype_ResolvesEmbeddedFont + - PdfDocument_Fonts_Type0_CidFontType0_UnrecognizedFontFile3Bytes_ThrowsUnsupportedImageFeatureException + - PdfDocument_Fonts_Type0_CidFontType0_CidKeyedCff_ThrowsInvalidDataException + - CanvasNetPdf_SystemIntegration_RenderCidFontType0CompositeFont_PaintsExpectedGlyphInk + + - id: CanvasNetPdf-PdfDocument-CompositeFontWidths + title: >- + The PdfDocument class shall resolve a /Type0 font's descendant's advance width from + its /DW entry (defaulting to 1000 when absent) and /W entry (supporting both the + "c [w1 w2 ... wn]" individual-width sub-form and the "cFirst cLast w" range + sub-form), identically regardless of whether the descendant is /CIDFontType2 or + /CIDFontType0; a malformed /W array shall throw InvalidDataException. + justification: | + Correct advance widths are required for correctly-spaced composite-font text + regardless of which descendant-font outline flavor a document uses, and the width- + resolution logic itself is shared, outline-flavor-independent code whose + correctness is a single, independently verifiable behavior. + tests: + - PdfDocument_Fonts_Type0_Widths_DwDefault1000_DeterminesAdvance + - PdfDocument_Fonts_Type0_Widths_WArrayIndividualForm_DeterminesAdvance + - PdfDocument_Fonts_Type0_Widths_WArrayRangeForm_DeterminesAdvance + - PdfDocument_Fonts_Type0_Widths_MalformedWArray_ThrowsInvalidDataException + - PdfDocument_Fonts_Type0_CidFontType0_Widths_WArrayIndividualForm_DeterminesAdvance + + - id: CanvasNetPdf-PdfDocument-Type1FontResolution + title: >- + The PdfDocument class shall resolve a /Subtype /Type1 simple font's embedded + /FontDescriptor/FontFile stream as a classic PostScript Type 1 font program: reading + the stream's own /Length1 (cleartext byte count) and /Length2 (eexec-encrypted byte + count) entries (not the /FontDescriptor's), decoding the stream, and loading it via + Fonts.TrueTypeFont.LoadType1, passing a Unicode-codepoint-to-Adobe-glyph-name + reverse map (built from the same StandardGlyphNames vocabulary + CanvasNetPdf-PdfDocument-FontEncoding's /Differences resolution already uses) as + the codepoint-to-glyph-name argument; a /Subtype /Type1 descriptor declaring only + /FontFile3 (no /FontFile and no /FontFile2) shall resolve via + CanvasNetPdf-PdfDocument-Type1CFontResolution instead, and a missing or non-numeric + /Length1 or /Length2 entry, or any exception propagated from + Fonts.TrueTypeFont.LoadType1 for a malformed embedded Type 1 program, shall throw + InvalidDataException. + justification: | + Classic PostScript Type 1 fonts (declared via /Subtype /Type1 with an embedded + /FontDescriptor/FontFile stream, per PDF 32000-1 ยง9.9) remain common in + PostScript-heritage PDF producers, and rejecting every /Subtype /Type1 font + unconditionally (this phase's pre-existing behavior) is a real-world compatibility + gap comparable to the one CanvasNetPdf-PdfDocument-CompositeFontDispatch already + closed for composite fonts. This phase's scope is deliberately narrower than the + full Type 1 Font Format: a seac-based accented-composite charstring is not + supported (Fonts.TrueTypeFont.LoadType1's own core-layer scope, not re-implemented + here), a non-1000-unit-em /FontMatrix is not supported (a 1000-unit em is assumed, + matching every other supported font format this class resolves), and the Type 1 + program's own built-in /Encoding (if any) embedded in the font program itself is + never consulted - only the PDF font dictionary's own /Encoding entry (resolved via + CanvasNetPdf-PdfDocument-FontEncoding) determines which glyph a code selects. + tests: + - PdfDocument_Fonts_Type1_DispatchesToSimpleFontResolution_PaintsGlyphInk + - PdfDocument_Fonts_Type1_EmbeddedFontFileTakesPriorityOverFallback + - PdfDocument_Fonts_Type1_NoFontFile_ResolvesViaFallback + - PdfDocument_Fonts_Type1_FontFileMissingLength1_ThrowsInvalidDataException + - PdfDocument_Fonts_Type1_FontFileMissingLength2_ThrowsInvalidDataException + - PdfDocument_Fonts_Type1_FontFileNonNumericLength2_ThrowsInvalidDataException + - PdfDocument_Load_TextEmbeddedType1FontFixture_PaintsVisibleGlyphInk + - PdfDocument_Load_TextStandard14Type1NoFontFileFixture_PaintsVisibleSubstituteGlyphInk + - CanvasNetPdf_SystemIntegration_RenderEmbeddedType1Font_PaintsExpectedGlyphInk + + - id: CanvasNetPdf-PdfDocument-Type1CFontResolution + title: >- + The PdfDocument class shall resolve a /Subtype /Type1 simple font's embedded + /FontDescriptor/FontFile3 stream (present whenever /FontFile is absent) as either a + bare Type1C/CFF font program (a raw CFF table with no SFNT/OpenType wrapper of its + own) or an SFNT-wrapped (/OpenType) CFF program, by sniffing the stream's own + decoded bytes for a recognized SFNT container or a structurally plausible bare CFF + header rather than requiring the stream's declared /Subtype to be exactly + /Type1C: a bare CFF program is loaded via Fonts.TrueTypeFont.LoadType1C, passing + the same Unicode-codepoint-to-Adobe-glyph-name reverse map + CanvasNetPdf-PdfDocument-Type1FontResolution's classic /FontFile path already uses; + an SFNT-wrapped CFF program is loaded via Fonts.TrueTypeFont.Load; bytes matching + neither recognized shape shall throw UnsupportedImageFeatureException, regardless + of the stream's own declared /Subtype (including a missing /Subtype). + justification: | + An embedded Type1C/CFF simple font (/FontFile3 on a /Subtype /Type1 descriptor, a + fundamentally different format from the classic /FontFile program + CanvasNetPdf-PdfDocument-Type1FontResolution loads) is common enough in real-world + PDF producers (many embed a bare CFF program rather than a classic Type 1 program + to save space) that rejecting it unconditionally (this phase's immediately-preceding + behavior) is a real-world compatibility gap comparable to the one + CanvasNetPdf-PdfDocument-CidFontType0FontResolution already closed for the composite + /CIDFontType0 CFF-outline path. PDF 32000-1 permits (and real-world producers + sometimes emit) a mismatch between a /FontFile3 stream's declared /Subtype name and + the font program's actual byte container shape - for example an SFNT-wrapped + (/OpenType) CFF program declared on a simple font, or a bare CFF program declared + /OpenType or /CIDFontType0C - so the declared /Subtype is no longer consulted for + dispatch at all; only the stream's own decoded bytes determine which loader runs, + mirroring the shape-sniffing dispatch CanvasNetPdf-PdfDocument-CidFontType0FontResolution + already establishes for the composite /CIDFontType0 path. This phase's scope is + deliberately narrower than the full CFF specification - the predefined Expert and + ExpertSubset charsets are + recognized but not resolved to glyph names (Fonts.CffTable's own documented scope + boundary, not re-implemented here), and a CID-keyed (ROS-declaring) Type1C/CFF + program is rejected (Fonts.CffTable.Parse's existing CID-keyed rejection surfaces + uncaught through this path, with no new translation code). + tests: + - PdfDocument_Fonts_Type1_FontFile3Type1C_ResolvesEmbeddedFont_PaintsGlyphInk + - PdfDocument_Fonts_Type1_MismatchedSubtype_BareCffBytes_ResolvesEmbeddedFont + - PdfDocument_Fonts_Type1_MissingFontFile3Subtype_ResolvesEmbeddedFont + - PdfDocument_Fonts_Type1_UnrecognizedFontFile3Bytes_ThrowsUnsupportedImageFeatureException + + - id: CanvasNetPdf-PdfDocument-Type3FontResolution + title: >- + The PdfDocument class shall resolve a /Resources/Font entry naming a /Subtype + /Type3 font: requiring and parsing its /FontMatrix (six numbers; InvalidDataException + if absent or malformed), requiring its /CharProcs dictionary (mapping glyph names to + indirect content-stream references; InvalidDataException if absent or not a + dictionary), resolving its /Encoding (reusing the same base-encoding/Differences + machinery CanvasNetPdf-PdfDocument-FontResolution's simple-font path uses, with an + absent, non-dictionary, or Differences-less /Encoding leniently resolving to an + empty code-to-glyph-name map rather than an error), and resolving its + /Widths/FirstChar/LastChar (reusing the same leniency conventions + CanvasNetPdf-PdfDocument-FontResolution's simple-font path uses for an out-of-range + or absent entry); unlike every other resolved font kind, a Type 3 font's advance + width for a given code is its /Widths entry transformed through /FontMatrix (not + divided by 1000), and its glyph outline is painted by + CanvasNetPdf-PdfDocument-Type3GlyphPainting rather than any embedded/fallback font + program. + justification: | + Type 3 (procedure-based) fonts - whose glyphs are arbitrary content-stream + procedures rather than outline/CFF programs - are a legitimate, if uncommon, + real-world PDF construct (commonly emitted by older or specialized producers for + bespoke symbol sets); resolving them closes a previously explicit scope boundary + (CanvasNetPdf-PdfDocument-FontResolution rejected every /Subtype /Type3 font + unconditionally before this phase) without disturbing any other resolved font + kind's own behavior, since the resolution and advance-width conventions above are + unique to this font kind and gated behind its own concrete + type (IResolvedFont.Font is widened to a nullable property precisely so a Type 3 + font's absent outline-glyph font program does not force a fabricated placeholder). + tests: + - PdfDocument_Fonts_Type3_MinimalFont_DifferencesAndCharProcs_PaintsGlyphInk + - PdfDocument_Fonts_Type3_Widths_ScaledViaFontMatrix_DeterminesAdvance + - PdfDocument_Fonts_Type3_UnmappedCode_NoDifferencesEntry_PaintsNothingButAdvances + - PdfDocument_Fonts_Type3_MappedGlyphNameAbsentFromCharProcs_PaintsNothingButAdvances + - PdfDocument_Fonts_Type3_MissingFontMatrix_ThrowsInvalidDataException + - PdfDocument_Fonts_Type3_MissingCharProcs_ThrowsInvalidDataException + - PdfDocument_Fonts_Type3_FontMatrixWrongArrayLength_ThrowsInvalidDataException + - PdfDocument_Fonts_Type3_FontMatrixNonNumberEntry_ThrowsInvalidDataException + - PdfDocument_Fonts_Type3_CharProcsNotADictionary_ThrowsInvalidDataException + + - id: CanvasNetPdf-PdfDocument-Type3GlyphPainting + title: >- + The PdfDocument class shall paint a /Subtype /Type3 glyph (resolved per + CanvasNetPdf-PdfDocument-Type3FontResolution) by looking up the shown code's glyph + name via /Encoding and that name's content-stream reference via /CharProcs - + painting nothing (but still advancing the text position by the code's declared + width) when either lookup fails - else decoding the glyph procedure's content + stream via the generic stream-decoding pipeline and executing it against a computed + glyph matrix (the font's /FontMatrix composed with the current text-rendering + matrix, replacing the outline-glyph path's 1/UnitsPerEm scale entirely), using the + Type3 font's own /Resources when present else falling back to the invoking content + stream's /Resources (exactly like a /Subtype /Form XObject with no /Resources of + its own). + justification: | + A Type 3 glyph procedure is itself an arbitrary nested content stream whose geometry + must be placed correctly via the font's own /FontMatrix rather than the outline-font + 1/UnitsPerEm convention, and must be able to use the font's own resources exactly + like a Form XObject does, for the painted glyph shape and any resources it + references to appear in the correct place. + tests: + - PdfDocument_Fonts_Type3_NonDefaultFontMatrix_ScalesGlyphGeometry + - PdfDocument_Fonts_Type3_NoOwnResources_FallsBackToOuterPageResources + - PdfDocument_Fonts_Type3_OwnResourcesTakePrecedenceOverPageResources_UsesFontResources + + - id: CanvasNetPdf-PdfDocument-Type3GlyphStateIsolation + title: >- + The PdfDocument class shall execute a Type3 glyph procedure (see + CanvasNetPdf-PdfDocument-Type3GlyphPainting) in a re-entrant manner, inheriting the invoking + graphics state and leaving it (and the invoking stream's /Resources) unaffected + once the glyph procedure finishes, whether it finishes normally or via a thrown + exception. + justification: | + A glyph procedure's own graphics-state/resource mutations (for example its own + `cm`/color-operator calls) must not leak into the page's own subsequent painting, + exactly as a Form XObject's own re-entrant execution already guarantees - + independently verifiable by painting content immediately after the glyph and + asserting it is unaffected. + tests: + - PdfDocument_Fonts_Type3_GlyphProc_GraphicsStateIsolated_DoesNotLeakOut + + - id: CanvasNetPdf-PdfDocument-Type3GlyphNestingDepthGuard + title: >- + The PdfDocument class shall throw InvalidDataException when nested Type3 + glyph-procedure invocations (see CanvasNetPdf-PdfDocument-Type3GlyphPainting) + exceed a fixed maximum depth, tracked independently of the Form XObject + nesting-depth counter, rather than overflowing the call stack. + justification: | + A pathological (self-referencing) glyph procedure must fail closed deterministically + rather than hang or crash, exactly as the equivalent Form XObject recursion guard + already does. + tests: + - PdfDocument_Fonts_Type3_SelfReferencingGlyphProc_ExceedsMaxNestingDepth_ThrowsInvalidDataException + + - id: CanvasNetPdf-PdfDocument-Type3RenderMode3Invisible + title: >- + The PdfDocument class shall skip Type3 glyph-procedure execution entirely (see + CanvasNetPdf-PdfDocument-Type3GlyphPainting) when the current text render mode is 3 + (invisible), while still advancing the text position by the shown code's declared + width. + justification: | + Render mode 3 is used by PDF producers for invisible text layered over an image + (for example OCR text layers); a Type3 glyph must honor that convention identically + to every other resolved font kind, painting nothing while still reserving its + layout space for subsequent glyphs. + tests: + - PdfDocument_Fonts_Type3_RenderMode3_SkipsGlyphProcedureButStillAdvances + + - id: CanvasNetPdf-PdfDocument-Type3D0D1Operators + title: >- + The PdfDocument class shall recognize the d0 and d1 glyph-description operators + (validating operand count/type) inside and outside a Type3 glyph procedure without + throwing, but discard their operands entirely: a Type 3 font's advance width remains + solely determined by its /Widths entry (see + CanvasNetPdf-PdfDocument-Type3FontResolution), never by a glyph procedure's own + d0/d1 operands, and this phase does not implement d1's optional + color-operator-suppression behavior (a d1 glyph procedure's own color-setting + operators are honored, not ignored, in this phase - a deliberate, narrower scope + boundary). + justification: | + d0/d1 are legal PDF content-stream operators that real-world Type3 glyph procedures + emit; recognizing and validating them (without crashing) while deliberately not + feeding their operands back into layout keeps advance-width resolution single- + sourced from /Widths, documented here as a deliberate, narrower-than-full- + specification scope boundary rather than silently under-implemented. + tests: + - PdfDocument_Fonts_Type3_D0Operator_ParsedButDoesNotAffectAdvanceWidth + - PdfDocument_Fonts_Type3_D1Operator_ParsedButDoesNotAffectAdvanceWidth + - PdfDocument_Fonts_Type3_StrayD0D1OutsideGlyphProc_NoExceptionNoEffect + + - id: CanvasNetPdf-PdfDocument-ToUnicodeCMap + title: >- + The PdfDocument class shall, when resolving a /Subtype /Type0 composite font, + additionally resolve its /ToUnicode entry (when present) as a CMap stream mapping + each shown code to a Unicode codepoint: tokenizing the decoded stream with the + content-stream tokenizer/parser primitives, parsing "beginbfchar"/"endbfchar" code- + to-destination pairs and "beginbfrange"/"endbfrange" srcLo/srcHi/destination + triples (a single hex/literal-string destination mapping consecutive codes to + consecutive incrementing codepoints, or an array-of-hex-strings destination mapping + each code to its own corresponding array element), decoding a multi-codepoint + destination to only its first UTF-16BE codepoint, and silently ignoring + "begincodespacerange"/"endcodespacerange" and the CMap's PostScript resource- + management wrapper keywords ("begin"/"end"/"dict"/"def"/"findresource"/ + "defineresource"/"pop"/"currentdict"/"begincmap"/"endcmap"); an absent /ToUnicode + entry shall resolve to a null map, a "usecmap"/"cidrange"/"cidchar" operator shall + throw UnsupportedImageFeatureException, and a bfrange destination array containing + a nested array (the CIDSystemInfo-style "array of arrays" destination sub-form) + shall likewise throw UnsupportedImageFeatureException. + justification: | + A /ToUnicode CMap is the sole spec-defined mechanism for recovering the Unicode + text a composite font's opaque CID-based codes represent, and resolving it as + unconsumed groundwork this phase (it is not yet wired into rendering) unblocks a + future text-extraction feature and Phase 11's composite-font fallback-substitution + decision without re-deriving CMap-parsing primitives later; the CIDSystemInfo-style + array-of-arrays bfrange destination sub-form and the usecmap/cidrange/cidchar + operators remain explicitly out of this phase's scope and must fail closed with a + distinguishable exception rather than being silently mis-parsed. + tests: + - PdfDocument_Fonts_ToUnicode_Absent_ResolvesNull + - PdfDocument_Fonts_ToUnicode_BfChar_MapsSingleCode + - PdfDocument_Fonts_ToUnicode_BfRangeHexForm_MapsConsecutiveCodes + - PdfDocument_Fonts_ToUnicode_BfRangeArrayForm_MapsEachCodeIndividually + - PdfDocument_Fonts_ToUnicode_UnsupportedArrayDestinationBfRange_ThrowsUnsupportedImageFeatureException + - PdfDocument_Fonts_ToUnicode_UnsupportedOperator_ThrowsUnsupportedImageFeatureException + + - id: CanvasNetPdf-PdfDocument-FontFallback + title: >- + The PdfDocument class shall, for a /Subtype /TrueType simple font lacking an + embedded /FontDescriptor/FontFile2 stream, resolve a substitute font by mapping a + recognized Standard-14 /BaseFont name to its documented serif/fixed-pitch/bold/ + italic flavor, or else deriving that flavor from /FontDescriptor/Flags and + /FontWeight/ItalicAngle, matching that flavor and the (subset-tag- and style- + suffix-stripped) family name against Fonts.SystemFontCatalog.FindBestMatch and, if + no system font matches, against Fonts.SystemFontCatalog.LoadBundledFallback; any + other font whose /FontDescriptor/Flags marks it Symbolic without also marking it + Nonsymbolic shall throw UnsupportedImageFeatureException rather than being + substituted (see CanvasNetPdf-PdfDocument-SymbolZapfDingbatsFallback for the + dedicated /BaseFont Symbol/ZapfDingbats substitution path, the sole exception to + this fail-closed rule), and every resolved fallback TrueTypeFont instance shall be + cached by (file path, face index) for the process lifetime. + justification: | + A symbol/dingbats font's glyphs are not addressed by any standard Latin-text + encoding, so silently substituting an unrelated text font for one would render + wrong, misleading glyph shapes rather than failing visibly; every other non-embedded + simple font, by contrast, can be reasonably approximated by a same-flavor + substitute, and resolving a fallback font requires directory-scanning and parsing + real font files, which must not be repeated for every occurrence of the same + substitute font within or across renders. + tests: + - PdfDocument_BuildResolvedFont_Standard14NoEmbeddedFont_ResolvesViaFallback + - PdfDocument_BuildResolvedFont_NonStandard14FlagsOnlyUnmatchedFamily_ResolvesViaBundledFallback + - PdfDocument_BuildResolvedFont_SymbolicFlagWithoutEmbeddedFont_ThrowsSymbolicNotEmbeddedException + - CanvasNetPdf_SystemIntegration_RenderStandard14HelveticaWithoutEmbeddedFont_PaintsVisibleGlyphInk + - CanvasNetPdf_SystemIntegration_RenderOtherSymbolicFontWithoutEmbeddedFont_ThrowsUnsupportedImageFeatureException + + - id: CanvasNetPdf-PdfDocument-SymbolZapfDingbatsFallback + title: >- + The PdfDocument class shall, for a /Subtype /TrueType simple font whose /BaseFont is + exactly Symbol or ZapfDingbats and which lacks an embedded /FontDescriptor/FontFile2 + stream, resolve glyphs via a bundled Noto substitute font union (Symbol: an ordered + NotoSans-Regular.ttf, NotoSansMath-Regular.ttf, NotoSansSymbols2-Regular.ttf + priority list; ZapfDingbats: NotoSansSymbols2-Regular.ttf alone), trying each font's + cmap in priority order and using the first one whose glyph coverage includes the + code's mapped codepoint (falling back to glyph 0/.notdef when none cover it), using + the Symbol/ZapfDingbats built-in Appendix D encoding (rather than /WinAnsiEncoding) + as the default /Encoding base table unless an explicit /Encoding/Differences array + overrides individual codes. + justification: | + Symbol and ZapfDingbats glyphs have no meaningful generic Latin-text-font + equivalent, so they were previously excluded from font-fallback substitution + entirely (see CanvasNetPdf-PdfDocument-FontFallback) and failed closed; a real, + redistributable, SIL OFL-licensed font whose glyph repertoire actually covers (most + of) the Symbol/ZapfDingbats Adobe/ISO 32000-1 Appendix D character sets can instead + be bundled and substituted directly, closing a previously explicit scope boundary + without compromising the fail-closed policy for every other symbolic font (which + has no such available substitute). + tests: + - SymbolEncodingTable_RepresentativeSample_MapsToKnownCorrectCodepoints + - ZapfDingbatsEncodingTable_RepresentativeSample_MapsToKnownCorrectCodepoints + - PdfDocument_BuildResolvedFont_SymbolFont_ResolvesViaNotoSubstituteWithoutThrowing + - PdfDocument_BuildResolvedFont_ZapfDingbatsFont_ResolvesViaNotoSubstituteWithoutThrowing + - CanvasNetPdf_SystemIntegration_RenderSymbolFontWithoutEmbeddedFont_PaintsVisibleGlyphInk + - CanvasNetPdf_SystemIntegration_NotoSansRegularBundledFont_ResolvesSymbolAlphaToNonzeroGlyphIndex + - BundledFonts_AllThreeNotoVariants_LoadSuccessfullyAndExposeExpectedName + + - id: CanvasNetPdf-PdfDocument-FontEncoding + title: >- + The PdfDocument class shall resolve a font dictionary's /Encoding into a full + 256-entry code-to-Unicode-codepoint map, defaulting to /WinAnsiEncoding when + /Encoding is absent (or, for a /BaseFont Symbol/ZapfDingbats font resolved via the + bundled Noto substitute path, to the Symbol/ZapfDingbats built-in Appendix D + encoding instead - see CanvasNetPdf-PdfDocument-SymbolZapfDingbatsFallback), + supporting /WinAnsiEncoding, /MacRomanEncoding, and /StandardEncoding as named base + encodings (throwing UnsupportedImageFeatureException for any other base encoding + name), and applying an /Encoding/Differences array's code-then-glyph-name + run-length overrides via a standard Adobe-glyph-name-to-Unicode-codepoint mapping, + tolerating an unrecognized glyph name (leaving that code at its base-encoding + mapping) but throwing InvalidDataException for /Differences beginning with a glyph + name before any starting code. + justification: | + WinAnsi and MacRoman are the two base encodings a simple TrueType font's /Encoding + commonly names, StandardEncoding is the PDF specification's own default built-in + encoding for a /Subtype /Type1 font (Appendix D), and /Differences overrides are + the standard mechanism a PDF producer uses to remap individual codes to + nonstandard glyphs; every one of these must resolve to the correct Unicode + codepoint for painted text to match the document's intent. Any other base encoding + must fail closed rather than be silently mis-mapped, but a single unrecognized + /Differences glyph name is not evidence of document corruption - real-world + producers routinely emit producer-specific names this library does not itself + recognize - so that one code is left at its base-encoding mapping instead of + rejecting the whole document. + tests: + - PdfDocument_Fonts_UnrecognizedEncoding_ThrowsUnsupportedImageFeatureException + - PdfDocument_Fonts_DefaultEncoding_IsWinAnsiEncoding + - PdfDocument_Fonts_MacRomanEncoding_DiffersFromWinAnsiEncoding + - PdfDocument_Fonts_Differences_OverridesBaseEncodingCode + - PdfDocument_Fonts_Differences_Absent_LeavesBaseEncodingCodeUnmapped + - PdfDocument_Fonts_Differences_UnrecognizedGlyphName_FallsBackToBaseEncoding + - PdfDocument_Fonts_Differences_FfFfiFflLigatureNames_DoNotThrow + - PdfDocument_Fonts_Differences_NacuteName_DoesNotThrow + - PdfDocument_Fonts_Differences_NameBeforeStartingCode_ThrowsInvalidDataException + - PdfDocument_Fonts_StandardEncoding_DiffersFromWinAnsiEncoding + - PdfDocument_Fonts_StandardEncoding_Absent_DefaultWinAnsiDoesNotPaintQuoteright + + - id: CanvasNetPdf-PdfDocument-FontWidths + title: >- + The PdfDocument class shall resolve each shown code's advance width with a + documented priority: an explicit /FirstChar-/Widths array entry for that code + first, else the font descriptor's /MissingWidth, else (when the font declares + neither) the embedded font's own GetAdvanceWidth/UnitsPerEm metric. + justification: | + A PDF producer may declare per-glyph widths explicitly, fall back to a single + /MissingWidth default, or omit width information entirely and expect the font's own + metrics to be used; painted text must advance by the mathematically correct amount + in every case for subsequent glyphs (and any caller measuring text) to land in the + correct position. The same declared/default-width priority also applies to + composite fonts, using the shown code's CID as the lookup key and /DW as the + default width instead of /MissingWidth - see + CanvasNetPdf-PdfDocument-CompositeFontWidths for its own dedicated width tests. + tests: + - PdfDocument_Fonts_Widths_ExplicitEntry_DeterminesAdvance + - PdfDocument_Fonts_Widths_MissingWidthFallback_DeterminesAdvance + - PdfDocument_Fonts_Widths_FontOwnAdvanceFallback_DeterminesAdvance + + - id: CanvasNetPdf-PdfDocument-TextObjectState + title: >- + The PdfDocument class shall implement BT/ET, resetting only the text matrix (Tm) + and line matrix (Tlm) to identity while leaving every other text-state parameter + (font, size, character/word spacing, horizontal scaling, leading, render mode, text + rise) unaffected as part of the persistent graphics state, so that state is saved + and restored by q/Q exactly like every other graphics-state parameter. + justification: | + The PDF specification defines Tm/Tlm as local to a text object while every other + text parameter persists as ordinary graphics state; painting a second text object + in the same content stream must not silently lose the first text object's font, + size, or other text-state configuration, and a q/Q pair must save/restore text + state exactly like line width or color. + tests: + - PdfDocument_Text_BeginText_ResetsTextMatrixButPreservesFontAndTextState + - PdfDocument_Text_PushPopGraphicsState_RestoresFontSize + + - id: CanvasNetPdf-PdfDocument-TextStateOperators + title: >- + The PdfDocument class shall implement the Tc, Tw, Tz, TL, and Ts text-state + operators, storing character spacing, word spacing (applied only to the + single-byte code 32 of a simple font, never to any code decoded from a composite + font even one numerically equal to 32), horizontal scaling percentage, leading, and + text rise respectively, each affecting subsequent glyph placement/advance as + documented by the PDF specification. + justification: | + These parameters directly control inter-character spacing, word spacing, horizontal + stretch, line-to-line displacement, and vertical baseline offset; each must be + applied with the specification's own exact formula for painted text layout to match + the document's intent. Word spacing in particular must be gated on the selected + font's code-byte-width, not just the decoded code's numeric value, since a + composite /Identity-H font's 2-byte code can numerically equal 32 without being the + single-byte space code the specification's word-spacing rule addresses. + tests: + - PdfDocument_Text_Tc_AddsToGlyphAdvance + - PdfDocument_Text_Tw_AppliesOnlyToCode32 + - PdfDocument_Text_Tz_ScalesHorizontalShapeAndAdvance + - PdfDocument_ShowText_Type0_WordSpacingCode32_IsNotAppliedToCompositeFont + + - id: CanvasNetPdf-PdfDocument-Tf + title: >- + The PdfDocument class shall implement the Tf operator (a font resource name then a + size), resolving the named font resource via font resolution and storing it + alongside the requested size as the currently selected font for subsequent + text-showing operators. + justification: | + Tf is the sole mechanism a content stream uses to select which embedded font and + size subsequent glyphs are painted with; it must correctly resolve the named + resource and reject an undeclared name. + tests: + - PdfDocument_Text_ShowText_PaintsGlyphAtComposedTextRenderingMatrix + + - id: CanvasNetPdf-PdfDocument-Tr + title: >- + The PdfDocument class shall implement the Tr (text-rendering mode) operator, + supporting mode 0 (fill, the default), mode 1 (stroke), mode 2 (fill, then stroke), + and mode 3 (invisible - glyphs still advance the text position but are not painted), + throwing UnsupportedImageFeatureException for the clip modes 4-7, and + InvalidDataException for any other numeric value. + justification: | + Clip text-rendering modes are explicitly out of this phase's scope; a content + stream requesting one must fail closed rather than being silently mis-rendered as a + fill, while invisible text (commonly used for an OCR text layer beneath a scanned + image) must still correctly advance the text position without painting any ink. + Stroke and fill+stroke modes are real-world-prevalent (for example, outlined + headings) and are supported by reusing the existing path-stroking paint logic + against the glyph's own outline. + tests: + - PdfDocument_Text_RenderMode3_Invisible_DoesNotPaintGlyph + - PdfDocument_Text_RenderMode3_Invisible_StillAdvancesTextPosition + - PdfDocument_Text_RenderMode1_Stroke_PaintsStrokedOutlineNotSolidFill + - PdfDocument_Text_RenderMode2_FillAndStroke_PaintsBothFillAndStroke + - PdfDocument_Text_RenderMode1_StrokePattern_PaintsPatternStroke + - PdfDocument_Fonts_Type3_RenderMode1Or2_BehavesLikeRenderMode0 + - PdfDocument_Text_RenderMode_UnsupportedDefinedMode_ThrowsUnsupportedImageFeatureException + - PdfDocument_Text_RenderMode_OutOfDefinedRange_ThrowsInvalidDataException + + - id: CanvasNetPdf-PdfDocument-TextPositioning + title: >- + The PdfDocument class shall implement the Td, TD, Tm, and T* text-positioning + operators, per the PDF specification's line-matrix-relative displacement (Td/TD, TD + additionally setting leading to -ty) versus direct-replacement (Tm) semantics, with + T* equivalent to "0 -TL Td". + justification: | + Correct line/paragraph layout depends entirely on these operators composing the + text and line matrices exactly per specification; an incorrect composition would + silently misplace every subsequent line of text. + tests: + - PdfDocument_Text_Td_OffsetsLineMatrixNotLastTextMatrix + - PdfDocument_Text_TD_SetsLeadingToNegativeTy + - PdfDocument_Text_Tm_ReplacesTextAndLineMatrix + - PdfDocument_Text_TStar_UsesCurrentLeading + + - id: CanvasNetPdf-PdfDocument-TextShowing + title: >- + The PdfDocument class shall implement the Tj, ' (quote), " (double-quote), and TJ + text-showing operators - Tj shows a string as-is; ' performs T* then Tj; " sets Tw + and Tc then performs ' ; and TJ shows an array of strings interleaved with + thousandths-of-text-space position adjustments applied between glyphs - painting + each resolved glyph's outline via the composed text-rendering matrix and + Drawing.PathFiller.Fill with the current fill color (mode 0) or skipping painting + entirely (mode 3), and advancing the text matrix by each glyph's resolved width. + justification: | + These are the operators that actually place visible ink on the page; each must + resolve every shown byte through the selected font's encoding, paint the correct + glyph outline at the correct position, and advance by the mathematically correct + amount, including TJ's per-adjustment position correction. + tests: + - PdfDocument_Text_ShowText_PaintsGlyphAtComposedTextRenderingMatrix + - PdfDocument_Text_TJ_AppliesPositionAdjustments + - PdfDocument_Text_TJ_ArrayEntryNotStringOrNumber_ThrowsInvalidDataException + - PdfDocument_Text_QuoteOperator_MovesToNextLineThenShows + - PdfDocument_Text_DoubleQuoteOperator_SetsSpacingThenMovesAndShows + - CanvasNetPdf_SystemIntegration_PdfRender_EmbeddedTrueTypeFontText_PaintsGlyphStrokesNotCounters + + - id: CanvasNetPdf-PdfDocument-TextErrorHandling + title: >- + The PdfDocument class shall throw InvalidDataException for a text-showing operator + (Tj/'/"/TJ) invoked with no font currently selected, or for a malformed operand + count/type for any text operator. + justification: | + A content stream that attempts to show text before any Tf call, or that supplies a + malformed operand list to a text operator, is malformed input and must be rejected + explicitly rather than silently ignored or causing an unhandled internal exception. + tests: + - PdfDocument_Text_ShowText_NoFontSelected_ThrowsInvalidDataException + - PdfDocument_Text_MalformedOperandCount_ThrowsInvalidDataException + - PdfDocument_Images_DoOperator_UndefinedXObjectName_ThrowsInvalidDataException + - PdfDocument_Images_DoOperator_MalformedOperandCount_ThrowsInvalidDataException + - CanvasNetPdf_SystemIntegration_PdfRender_ImageXObjectPlacement_CompositesExpectedPixels + + - id: CanvasNetPdf-PdfDocument-FunctionType0 + title: >- + The PdfDocument class shall resolve a /Function entry restricted to /FunctionType 0 + (sampled function) with exactly 1 input (a 2-element /Domain) into an Evaluate(input) + method that clamps the input into /Domain, maps it into sample-index space via + /Encode (defaulting to [0, Size[0] - 1] when /Encode is absent), linearly + interpolates between the two nearest samples, and maps each interpolated output + through /Decode (defaulting to /Range when /Decode is absent), clipped to /Range; + 8-bit and 16-bit /BitsPerSample sample widths shall both be supported, decoding the + function stream's bytes via the existing filter-aware stream-decoding helper. A + /FunctionType other than 0, 2, or 3 (e.g. 4), and a multi-input /FunctionType 0 + function (a /Domain with more than 2 elements) shall both throw + UnsupportedImageFeatureException. + justification: | + Axial/radial (/ShadingType 2/3) shading patterns in real-world PDFs sample a + /FunctionType 0 function to derive a gradient's per-position color, and resolving + this evaluator unblocks the shading-pattern-fill phase of the /Pattern color-space + roadmap (see CanvasNetPdf-PdfDocument-ShadingPatternFill) without re-deriving + sampled-function evaluation later; /FunctionType 2 (exponential interpolation) and + 3 (stitching) are now also supported (see + CanvasNetPdf-PdfDocument-FunctionType2And3); /FunctionType 4 (PostScript + calculator) and a multi-input /FunctionType 0 function (the /DeviceN/Separation + tint-transform shape, itself already out of scope) remain explicitly out of scope + and must fail closed with a distinguishable exception rather than being silently + mis-evaluated. + tests: + - PdfDocument_Functions_Type0_InputOutsideDomain_ClampsToBoundary + - PdfDocument_Functions_Type0_EncodeAbsent_DefaultsToZeroToSizeMinusOne + - PdfDocument_Functions_Type0_ExplicitEncode_MapsDomainToCustomSampleIndexRange + - PdfDocument_Functions_Type0_Decode_MapsRawSampleToCustomOutputRange + - PdfDocument_Functions_Type0_NonIntegerSampleIndex_InterpolatesBetweenAdjacentSamples + - PdfDocument_Functions_Type0_EightBitSamples_EvaluatesCorrectly + - PdfDocument_Functions_Type0_SixteenBitSamples_EvaluatesCorrectly + - PdfDocument_Functions_Type0_UnsupportedFunctionType_ThrowsUnsupportedImageFeatureException + - PdfDocument_Functions_Type0_MultiInputDomain_ThrowsUnsupportedImageFeatureException + + - id: CanvasNetPdf-PdfDocument-FunctionType2And3 + title: >- + The PdfDocument class shall evaluate /FunctionType 2 (exponential interpolation: + Domain, C0/C1 defaulting to [0.0]/[1.0] when absent, and N, computing + C0[i] + (input^N * (C1[i] - C0[i])) component-wise after clamping input to Domain) + and /FunctionType 3 (stitching: Domain, Functions - an array of independently + resolved sub-functions of any supported type, including a nested /FunctionType 3 - + Bounds, and Encode) functions through a single, uniform ResolveFunctionGeneric + entry point also used by shading-pattern function resolution; resolution is bounded + by a MaxFunctionRecursionDepth (32) guard, throwing InvalidDataException when a + self-referencing or excessively deep /Functions chain would otherwise recurse + without bound. /FunctionType 4 (PostScript calculator functions) shall continue to + throw UnsupportedImageFeatureException with feature pdf-functiontype-4. + justification: | + Axial/radial shading patterns commonly drive their color ramp with a /FunctionType + 2 exponential function (optionally combined via a /FunctionType 3 stitching + function for multi-segment gradients); without evaluating these function types, + the majority of real-world shading patterns could not be rendered at all. + tests: + - PdfDocument_Functions_Type2_Exponential_EvaluatesExpectedInterpolatedOutput + - PdfDocument_Functions_Type3_Stitching_EvaluatesExpectedSubFunctionOutput + - PdfDocument_Functions_Type4_PostScriptCalculator_ThrowsUnsupportedImageFeatureException + - PdfDocument_Functions_Type3_Stitching_NestingDepthExceeded_ThrowsInvalidDataException + + - id: CanvasNetPdf-PdfDocument-PatternColorSpace + title: >- + The PdfDocument class shall recognize /Pattern (bare name, or a 2-element + [/Pattern baseSpace] array form carrying an underlying color space) as a selectable + cs/CS color space, and shall parse scn/SCN operands against an active /Pattern + color space as zero or more leading numeric components (exactly matching the + declared base color space's component count, or zero when no base space was + declared) followed by a trailing pattern Name operand, resolving the named pattern + from the current page's /Resources/Pattern dictionary. A malformed operand shape + (wrong leading numeric count, or a missing trailing Name) shall throw + InvalidDataException; an undeclared pattern name shall throw + UnsupportedImageFeatureException with feature pdf-pattern-not-declared; an + unsupported /PatternType (other than 1 or 2) shall throw + UnsupportedImageFeatureException with feature pdf-pattern-type-{n}; an array color + space with an element count other than 1 or 2 shall throw + UnsupportedImageFeatureException with feature pdf-colorspace-Pattern. + justification: | + Real-world PDFs commonly use the /Pattern color space to select shading or tiling + pattern fills for backgrounds and vector art; today every such document throws + UnsupportedImageFeatureException (feature pdf-pattern-color) and cannot be rendered + at all. + tests: + - PdfDocument_Color_PatternColorSpace_Cs_ResolvesToPatternFamily + - PdfDocument_Color_PatternColorSpace_ArrayWithBase_ResolvesPatternBase + - PdfDocument_Color_PatternColorSpace_TooManyArrayElements_ThrowsUnsupportedImageFeatureException + - PdfDocument_Color_Scn_ColoredPatternOperand_NameAlone_ResolvesPattern + - PdfDocument_Color_Scn_UncoloredPatternOperand_WrongComponentCount_ThrowsInvalidDataException + - PdfDocument_Color_Scn_PatternOperand_MissingTrailingName_ThrowsInvalidDataException + - PdfDocument_Color_Scn_PatternOperand_UndeclaredPatternName_ThrowsUnsupportedImageFeatureException + - PdfDocument_Color_Scn_PatternOperand_UnsupportedPatternType_ThrowsUnsupportedImageFeatureException + + - id: CanvasNetPdf-PdfDocument-ShadingPatternFill + title: >- + The PdfDocument class shall fill or stroke a path using a /PatternType 2 (shading) + pattern whose /ShadingType is 2 (axial) or 3 (radial), sampling the shading's + /Function (or array of 1-output-component functions) at evenly spaced points across + /Domain, converting each sample through the shading's own /ColorSpace (restricted to + DeviceGray/DeviceRGB/DeviceCMYK), and painting a smoothly varying color gradient + whose geometry is positioned by the pattern's own /Matrix composed with the page's + initial (pre-cm) coordinate transform. An unsupported /ShadingType (other than 2 or + 3) shall throw UnsupportedImageFeatureException with feature + pdf-shading-type-{n}; an unsupported shading /ColorSpace shall throw + UnsupportedImageFeatureException with feature pdf-shading-colorspace-{family}. + /Extend is approximated as an always-padded (never fully transparent) gradient + extent, a documented, narrower-than-spec simplification. + justification: | + Axial and radial shading-pattern fills are a common way real-world PDFs render + smooth-color backgrounds and vector-art gradients; today every such fill throws and + the document cannot be rendered at all. + tests: + - PdfDocument_Patterns_ShadingPattern_AxialFunctionType2_FillsVisiblyVaryingGradient + - PdfDocument_Patterns_ShadingPattern_RadialFunctionType2_FillsVisiblyVaryingGradient + - PdfDocument_Patterns_ShadingPattern_UnsupportedShadingType_ThrowsUnsupportedImageFeatureException + - PdfDocument_Patterns_ShadingPattern_UnsupportedFunctionType4_ThrowsUnsupportedImageFeatureException + - PdfDocument_Patterns_ShadingPattern_FunctionArrayForm_ThreeOneOutputFunctions_BuildsRgbGradient + - CanvasNetPdf_SystemIntegration_AxialShadingPatternFill_PaintsVisiblyVaryingColors + + - id: CanvasNetPdf-PdfDocument-TilingPatternFill + title: >- + The PdfDocument class shall fill or stroke a path using a /PatternType 1 (tiling) + pattern, rendering the pattern's own content stream once into an offscreen tile + surface (sized from /XStep/YStep scaled by the pattern-to-device transform) and + repeating that tile across the painted region, nearest-neighbor sampled. A + /PaintType 1 (colored) pattern paints its own content stream's colors unmodified; a + /PaintType 2 (uncolored) pattern recolors every painted pixel to the scn/SCN-supplied + tint while preserving each pixel's own painted alpha. A zero or non-finite + /XStep/YStep, or a /PaintType other than 1 or 2, shall throw InvalidDataException; + a device-pixel tile surface that would exceed the safety bound shall throw + UnsupportedImageFeatureException with feature pdf-pattern-tile-too-large; nested + tiling-pattern-cell rendering shares the existing Form-XObject nesting-depth guard + and shall throw InvalidDataException when that bound is exceeded. + justification: | + Tiling pattern fills are a common way real-world PDFs render repeating textures, + hatching, and decorative backgrounds; today every such fill throws and the document + cannot be rendered at all. + tests: + - PdfDocument_Patterns_TilingPattern_ColoredPaintType1_FillsRepeatingTile + - PdfDocument_Patterns_TilingPattern_UncoloredPaintType2_AppliesSuppliedTintPreservingAlpha + - PdfDocument_Patterns_TilingPattern_OversizedTileDimensions_ThrowsUnsupportedImageFeatureException + - PdfDocument_Patterns_TilingPattern_ZeroXStep_ThrowsInvalidDataException + - PdfDocument_Patterns_TilingPattern_NestingDepthExceeded_ThrowsInvalidDataException + - CanvasNetPdf_SystemIntegration_ColoredTilingPatternFill_PaintsRepeatingTileColors diff --git a/docs/reqstream/canvas-net-pdf/platform-requirements.yaml b/docs/reqstream/canvas-net-pdf/platform-requirements.yaml new file mode 100644 index 00000000..04c75868 --- /dev/null +++ b/docs/reqstream/canvas-net-pdf/platform-requirements.yaml @@ -0,0 +1,72 @@ +--- +# System-Level Platform Requirements +# +# These requirements specify the platforms and frameworks the CanvasNetPdf package must support. +# They ensure the package works across the required execution environments - identical to the +# CanvasNet system it depends on (see docs/design/canvas-net-pdf.md's Platform Support section). +# +# Test links can include a source filter prefix (e.g. "windows@", "ubuntu@", "net8.0@") to restrict +# which test results count as evidence for a requirement. This is critical for platform and framework +# requirements - removing these filters invalidates the evidence-based proof. +# +# Source filter prefixes: +# windows@TestName - proves the test passed on a Windows platform +# ubuntu@TestName - proves the test passed on a Linux (Ubuntu) platform +# macos@TestName - proves the test passed on a macOS platform +# net8.0@TestName - proves the test passed under the .NET 8 runtime +# net9.0@TestName - proves the test passed under the .NET 9 runtime +# net10.0@TestName - proves the test passed under the .NET 10 runtime + +sections: + - title: Platform Support + requirements: + - id: CanvasNetPdf-Platform-Windows + title: The library shall build and run on Windows platforms. + tags: [system] + justification: | + DEMA Consulting libraries must support Windows as a major development platform. + tests: + # Tests link to "windows" to ensure results come from Windows platform + - "windows@CanvasNetPdf_SystemIntegration_PdfOpen_ReturnsExpectedPageCount" + + - id: CanvasNetPdf-Platform-Linux + title: The library shall build and run on Linux platforms. + tags: [system] + justification: | + DEMA Consulting libraries must support Linux for CI/CD and containerized environments. + tests: + # Tests link to "ubuntu" to ensure results come from Linux platform + - "ubuntu@CanvasNetPdf_SystemIntegration_PdfOpen_ReturnsExpectedPageCount" + + - id: CanvasNetPdf-Platform-MacOS + title: The library shall build and run on macOS platforms. + tags: [system] + justification: | + DEMA Consulting libraries must support macOS for developers using Apple platforms. + tests: + # Tests link to "macos" to ensure results come from macOS platform + - "macos@CanvasNetPdf_SystemIntegration_PdfOpen_ReturnsExpectedPageCount" + + - id: CanvasNetPdf-Platform-Net8 + title: The library shall support .NET 8 runtime. + tags: [system] + justification: | + .NET 8 is an LTS release providing long-term stability for enterprise users. + tests: + - "net8.0@CanvasNetPdf_SystemIntegration_PdfOpen_ReturnsExpectedPageCount" + + - id: CanvasNetPdf-Platform-Net9 + title: The library shall support .NET 9 runtime. + tags: [system] + justification: | + .NET 9 support enables users to leverage the latest .NET features. + tests: + - "net9.0@CanvasNetPdf_SystemIntegration_PdfOpen_ReturnsExpectedPageCount" + + - id: CanvasNetPdf-Platform-Net10 + title: The library shall support .NET 10 runtime. + tags: [system] + justification: | + .NET 10 support ensures the library remains compatible with the latest .NET ecosystem. + tests: + - "net10.0@CanvasNetPdf_SystemIntegration_PdfOpen_ReturnsExpectedPageCount" diff --git a/docs/reqstream/canvas-net.yaml b/docs/reqstream/canvas-net.yaml index 46ee289b..a6051b5a 100644 --- a/docs/reqstream/canvas-net.yaml +++ b/docs/reqstream/canvas-net.yaml @@ -1138,6 +1138,14 @@ sections: - PathStroker_Stroke_NullStrokeStyle_ThrowsArgumentNullException - PathStroker_Stroke_NonPositiveFlattenTolerance_ThrowsArgumentOutOfRangeException + # NOTE (requirements-traceability review): CanvasNet-Lib-DrawingGradientPaint, + # CanvasNet-Lib-DrawingGradientPaintValidation, CanvasNet-Lib-DrawingTilePaint, and + # CanvasNet-Lib-DrawingTilePaintValidation below currently link only to unit tests, with no + # CanvasNet-level system integration test evidence of their own (CanvasNetPdf's tiling- + # pattern-fill and CanvasNetSvg's pattern-rendering system tests exercise this Drawing + # subsystem functionality, but as a different system's own test evidence, not CanvasNet's). + # Adding a dedicated CanvasNet-level system integration test is out of scope for this + # documentation-only traceability pass; this is a known, deliberately-left-open gap. - id: CanvasNet-Lib-DrawingGradientPaint title: >- The library shall fill a closed Path with a linear or radial gradient color ramp, varying @@ -1174,6 +1182,40 @@ sections: - PathFiller_Fill_Gradient_NonPositiveFlattenTolerance_ThrowsArgumentOutOfRangeException - PathFiller_Fill_Gradient_UndefinedFillRule_ThrowsArgumentOutOfRangeException + - id: CanvasNet-Lib-DrawingTilePaint + title: >- + The library shall fill a closed Path with a repeating pre-rendered tile bitmap, + sampling the tile at each pixel according to a caller-supplied transform and + horizontal/vertical repeat pitch. + tags: [system] + justification: | + Repeating a pre-rendered tile bitmap across a filled area is standard tiling-pattern 2D + vector graphics paint functionality alongside solid-color and gradient fill - in + particular, the rendering primitive needed by CanvasNetPdf's PDF `/Pattern` PatternType + 1 (tiling pattern) support; callers need it available directly without hand-rolling + per-pixel tile sampling themselves. + children: + - CanvasNet-Drawing-TilePaint + tests: + - PathFiller_Fill_TilePaint_CheckerboardTile_FillsWithBothTileColors + - EvaluateRow_WrapAroundBeyondOneTile_MatchesFirstTileRepetition + + - id: CanvasNet-Lib-DrawingTilePaintValidation + title: >- + The library shall reject a null surface, path, or tile paint argument to the tile fill + operation with ArgumentNullException. + tags: [system] + justification: | + Tile fill validation must fail exactly as consistently and predictably as solid-color + and gradient fill validation, so callers do not need to learn a third set of + invalid-input rules for the tile-paint overload. + children: + - CanvasNet-Drawing-TilePaintValidation + tests: + - PathFiller_Fill_TilePaint_NullSurface_ThrowsArgumentNullException + - PathFiller_Fill_TilePaint_NullPath_ThrowsArgumentNullException + - PathFiller_Fill_TilePaint_NullPaint_ThrowsArgumentNullException + - title: Fonts API requirements: - id: CanvasNet-Lib-FontsLoad @@ -1240,6 +1282,90 @@ sections: tests: - CanvasNet_SystemIntegration_LoadFontAndQueryMetrics_ReturnsExpectedValues + - id: CanvasNet-Lib-FontsCffOutlineDecoding + title: >- + The library shall load a CFF/OpenType (OTTO-flavored) font and decode its glyph + outlines into the same vector path geometry the library already produces for + glyf-flavored TrueType fonts, so callers can render either flavor's glyphs + interchangeably through one API. + tags: [system] + justification: | + Real-world production fonts (in particular most professionally produced Latin, and + nearly all "Pro"/"Premium" commercial font families) commonly ship as CFF-flavored + OpenType rather than glyf-flavored TrueType; callers need to load and render either + flavor's glyph outlines without caring which one a given font file actually uses. + children: + - CanvasNet-Fonts-CffOutlineDecoding + tests: + - CanvasNet_SystemIntegration_LoadCffOtfFontAndFillGlyphOutline_ReturnsExpectedPixels + + - id: CanvasNet-Lib-FontsType1OutlineDecoding + title: >- + The library shall load a classic PostScript Type 1 font program - either from + caller-supplied cleartext/eexec-encrypted byte segments or auto-detected from a + standalone .pfb/.pfa file - and decode its glyph outlines into the same vector path + geometry the library already produces for glyf/CFF-flavored fonts, so callers can + render Type 1 glyphs interchangeably through the same TrueTypeFont API. + tags: [system] + justification: | + Classic PostScript Type 1 fonts remain common as embedded fonts in legacy PDF + documents and as standalone .pfb/.pfa font files; callers need to load and render + Type 1 glyph outlines without caring which outline flavor a given font file uses. + children: + - CanvasNet-Fonts-Type1OutlineDecoding + tests: + - CanvasNet_SystemIntegration_LoadType1FontAndFillGlyphOutline_ReturnsExpectedPixels + + - id: CanvasNet-Lib-FontsTtcSupport + title: >- + The library shall report how many font faces a file contains and load any one of + them by index, defaulting to the first face when no index is given, so callers can + use a single file that bundles multiple weight/style variants (a TrueType + Collection) exactly like an ordinary single-face font file. + tags: [system] + justification: | + A TrueType Collection bundles multiple font faces (commonly weight/style variants) + inside one file; callers need to discover how many faces a file contains and select + any one of them by index, while an ordinary single-face font file continues to work + exactly as before by default. + children: + - CanvasNet-Fonts-TtcSupport + tests: + - CanvasNet_SystemIntegration_LoadFaceFromTtcContainerByIndex_ReturnsExpectedFace + + - id: CanvasNet-Lib-FontsNameStyleMetadata + title: >- + The library shall expose a loaded font's human-readable name (family, subfamily, + full name, PostScript name) and derived bold/italic/fixed-pitch style, so callers + can label or classify a font without inspecting its raw font tables themselves. + tags: [system] + justification: | + Callers selecting or labeling a loaded font (for example choosing a system fallback + font, or displaying a font picker) need its human-readable name and basic style + classification, resolved consistently regardless of exactly which name/style tables + a real-world font actually supplies. + children: + - CanvasNet-Fonts-NameStyleMetadata + tests: + - CanvasNet_SystemIntegration_LoadFontAndQueryNameAndStyleMetadata_ReturnsExpectedValues + + - id: CanvasNet-Lib-FontsSystemFontDiscovery + title: >- + The library shall discover fonts installed on the host operating system, match a + requested family name and style against them, and provide a bundled last-resort + fallback font, so a caller substituting a non-embedded font always obtains a usable + font even when nothing installed matches or no host fonts are found at all. + tags: [system] + justification: | + A caller resolving a document's non-embedded font needs to know what fonts are + actually installed on the host machine, a best-effort way to match a requested + family/style against them, and a deterministic last resort when nothing matches - + without having to bundle or discover fonts itself. + children: + - CanvasNet-Fonts-SystemFontDiscovery + tests: + - CanvasNet_SystemIntegration_ResolveSystemFontFallback_ReturnsBundledFallbackFont + - title: Rendering API requirements: - id: CanvasNet-Lib-RenderingTransformStack diff --git a/docs/reqstream/canvas-net/drawing.yaml b/docs/reqstream/canvas-net/drawing.yaml index afeb37c4..ae9b8592 100644 --- a/docs/reqstream/canvas-net/drawing.yaml +++ b/docs/reqstream/canvas-net/drawing.yaml @@ -3,9 +3,10 @@ # # These requirements decompose the CanvasNet system's Drawing API responsibilities into the # Drawing subsystem's own responsibility: antialiased scanline-coverage filling of closed -# Geometry.Path geometry with a solid color onto a Canvas.Surface (the PathFiller unit, which -# also covers the supporting FillRule, EdgeFlattener, and ScanlineRasterizer types). Each -# requirement here links down to the unit requirement that implements it. +# Geometry.Path geometry with a solid color, a linear/radial gradient, or a repeating tile bitmap +# onto a Canvas.Surface (the PathFiller unit, which also covers the supporting FillRule, +# EdgeFlattener, and ScanlineRasterizer types, the GradientPaint unit, and the TilePaint unit). +# Each requirement here links down to the unit requirement that implements it. sections: - title: CanvasNet Requirements @@ -198,3 +199,43 @@ sections: - PathFiller_Fill_Gradient_NullPaint_ThrowsArgumentNullException - PathFiller_Fill_Gradient_NonPositiveFlattenTolerance_ThrowsArgumentOutOfRangeException - PathFiller_Fill_Gradient_UndefinedFillRule_ThrowsArgumentOutOfRangeException + + - id: CanvasNet-Drawing-TilePaint + title: >- + The Drawing subsystem shall fill a closed Path with a repeating pre-rendered tile + bitmap onto a Surface, sampling the tile at each pixel according to the tile + paint's transform and horizontal/vertical repeat pitch, while honoring the same + fill rule, antialiasing, curve-flattening, and no-op behavior already established + for solid-color and gradient fills. + justification: | + Repeating a pre-rendered tile bitmap across a filled area is standard tiling-pattern + vector-graphics paint functionality - in particular, the rendering primitive needed + by CanvasNetPdf's PDF `/Pattern` PatternType 1 (tiling pattern) support; callers need + it available directly on a Surface, consistent in every other respect with the + existing solid-color and gradient fills. + children: + - CanvasNet-Drawing-TilePaint-Definition + - CanvasNet-Drawing-TilePaint-WithTransform + - CanvasNet-Drawing-TilePaint-Sampling + - CanvasNet-Drawing-TilePaint-DegenerateTransparent + - CanvasNet-Drawing-TilePaint-Paint + - CanvasNet-Drawing-PathFiller-TileFill + tests: + - PathFiller_Fill_TilePaint_CheckerboardTile_FillsWithBothTileColors + - EvaluateRow_WrapAroundBeyondOneTile_MatchesFirstTileRepetition + + - id: CanvasNet-Drawing-TilePaintValidation + title: >- + The Drawing subsystem shall reject a null Surface, Path, or tile paint argument + with ArgumentNullException when filling a Path with a tile paint. + justification: | + Tile fill validation must fail exactly as consistently and predictably as + solid-color and gradient fill validation, so callers do not need to learn a third + set of invalid-input rules for the tile-paint overload. + children: + - CanvasNet-Drawing-TilePaint-PaintValidation + - CanvasNet-Drawing-PathFiller-TileFillValidation + tests: + - PathFiller_Fill_TilePaint_NullSurface_ThrowsArgumentNullException + - PathFiller_Fill_TilePaint_NullPath_ThrowsArgumentNullException + - PathFiller_Fill_TilePaint_NullPaint_ThrowsArgumentNullException diff --git a/docs/reqstream/canvas-net/drawing/path-filler.yaml b/docs/reqstream/canvas-net/drawing/path-filler.yaml index a5b62a01..6bba2f05 100644 --- a/docs/reqstream/canvas-net/drawing/path-filler.yaml +++ b/docs/reqstream/canvas-net/drawing/path-filler.yaml @@ -284,3 +284,37 @@ sections: - PathFiller_Fill_Gradient_NullPaint_ThrowsArgumentNullException - PathFiller_Fill_Gradient_NonPositiveFlattenTolerance_ThrowsArgumentOutOfRangeException - PathFiller_Fill_Gradient_UndefinedFillRule_ThrowsArgumentOutOfRangeException + + - id: CanvasNet-Drawing-PathFiller-TileFill + title: >- + The PathFiller class shall fill a closed path onto a surface with a repeating + tile paint in place of a solid color, honoring the same fill rule, curve- + flattening tolerance, implicit-subpath-closing, and empty/out-of-bounds no-op + behavior as the solid-color Fill overload. + justification: | + Tile-painted fills are a standard tiling-pattern vector-graphics capability; + sharing the solid-color overload's flattening, clipping, and fill-rule behavior + keeps the paint styles visually and behaviorally consistent, and avoids a second, + divergent geometry pipeline. The tile sampling behavior itself is specified by + the TilePaint unit's own requirements (see tile-paint.yaml). + tests: + - PathFiller_Fill_TilePaint_CheckerboardTile_FillsWithBothTileColors + - PathFiller_Fill_TilePaint_EmptyPath_NoOpLeavesSurfaceUnchanged + - ScanlineRasterizer_Fill_TilePaint_FullCoverageRow_RepeatsAcrossWidth + - ScanlineRasterizer_Fill_TilePaint_NoPolygons_NoOp + + - id: CanvasNet-Drawing-PathFiller-TileFillValidation + title: >- + The PathFiller class shall reject a null surface, path, or tile paint argument + to the tile Fill overload with ArgumentNullException, matching the solid-color + Fill overload's own validation. + justification: | + The tile Fill overload accepts one additional required reference argument (the + tile paint itself) beyond the solid-color overload; a null paint cannot supply + valid tile data and must be rejected as early and consistently as every other + required reference argument. + tests: + - PathFiller_Fill_TilePaint_NullSurface_ThrowsArgumentNullException + - PathFiller_Fill_TilePaint_NullPath_ThrowsArgumentNullException + - PathFiller_Fill_TilePaint_NullPaint_ThrowsArgumentNullException + - ScanlineRasterizer_Fill_TilePaint_NullPaint_ThrowsArgumentNullException diff --git a/docs/reqstream/canvas-net/drawing/tile-paint.yaml b/docs/reqstream/canvas-net/drawing/tile-paint.yaml new file mode 100644 index 00000000..e5a3442a --- /dev/null +++ b/docs/reqstream/canvas-net/drawing/tile-paint.yaml @@ -0,0 +1,123 @@ +--- +# cspell:ignore Rgba +# Software Unit Requirements for TilePaint +# +# These requirements describe the design-level behavior of the TilePaint unit within the Drawing +# subsystem. TilePaint is the public TilePaint type (a pre-rendered one-cell tile Surface plus a +# device-space Transform and a pattern-space XStep/YStep pitch) and the internal +# TilePaintEvaluator helper documented here as one unit, together with the tile-aware +# PathFiller.Fill/ScanlineRasterizer.Fill overloads that let a caller paint a filled path by +# repeating a pre-rendered tile bitmap instead of a single solid color or a gradient ramp. + +sections: + - title: CanvasNet Requirements + sections: + - title: Drawing Requirements + sections: + - title: TilePaint Requirements + requirements: + - id: CanvasNet-Drawing-TilePaint-Definition + title: >- + A tile paint shall be defined by a pre-rendered one-cell tile surface, a + transform mapping the tile's own defining (pattern-space) coordinates into the + caller's path coordinate space, and a horizontal/vertical repeat pitch; the + tile paint shall reject a missing surface, a transform with a non-finite + component, and a non-finite or zero repeat pitch, while accepting a negative + repeat pitch as a legitimate value meaning the tile repeats in the negative + pattern-space axis direction. + justification: | + A tile paint cannot sample any bitmap without a surface to sample, a non-finite + transform component cannot place the tile anywhere sensible in path space, and a + zero or non-finite repeat pitch has no meaningful tile spacing; a negative pitch + is a legitimate PDF tiling-pattern value (PDF 32000-1 ยง8.7.3.1) and must be + accepted, not rejected. + tests: + - TilePaint_Constructor_NullSurface_ThrowsArgumentNullException + - TilePaint_Constructor_TransformWithNaNComponent_ThrowsArgumentOutOfRangeException + - TilePaint_Constructor_TransformWithInfinityComponent_ThrowsArgumentOutOfRangeException + - TilePaint_Constructor_ZeroXStep_ThrowsArgumentOutOfRangeException + - TilePaint_Constructor_ZeroYStep_ThrowsArgumentOutOfRangeException + - TilePaint_Constructor_NonFiniteStep_ThrowsArgumentOutOfRangeException + - TilePaint_Constructor_NegativeSteps_Succeeds + - TilePaint_Constructor_ValidValues_PropertiesRoundTrip + + - id: CanvasNet-Drawing-TilePaint-WithTransform + title: >- + A tile paint shall expose a WithTransform operation returning a new tile paint + with the same surface/repeat pitch, whose transform is this tile paint's own + existing transform composed with the supplied transform (this tile paint's + transform applied first, then the supplied transform), without mutating the + original instance. + justification: | + A caller that composes a tile-painted fill with an outer coordinate-space + transform needs a way to fold that outer transform into the tile paint's own + coordinate mapping, mirroring Gradient.WithTransform's equivalent role for + gradient paints, so the two paint kinds compose identically from a caller's + perspective. + tests: + - TilePaint_WithTransform_ComposesTransformPreservingOtherProperties + + - id: CanvasNet-Drawing-TilePaint-Sampling + title: >- + TilePaintEvaluator shall map a device-space point back into pattern space via + the tile paint's transform, wrap the result modulo the absolute value of the + horizontal/vertical repeat pitch, and sample the tile surface by + nearest-neighbor at the corresponding wrapped tile-pixel coordinate, repeating + identically for every subsequent tile repetition regardless of the sign of the + repeat pitch. + justification: | + Tiling-pattern rendering must reproduce the same tile content at every + repetition across the filled region, including when a caller supplies a negative + repeat pitch, matching PDF tiling-pattern semantics exactly. + tests: + - CreatePlan_NullTile_ThrowsArgumentNullException + - EvaluateRow_IdentityTransform_SamplesExpectedTilePixels + - EvaluateRow_WrapAroundBeyondOneTile_MatchesFirstTileRepetition + - EvaluateRow_NegativeSteps_SamplesUsingAbsoluteValueWraparound + + - id: CanvasNet-Drawing-TilePaint-DegenerateTransparent + title: >- + TilePaintEvaluator shall leave every pixel in the affected fill fully + transparent, rather than throwing or flat-filling with any color, when the tile + paint's transform cannot be inverted - a deliberate divergence from Gradient's + own degenerate-transform flat-fill-with-last-stop-color policy, since a tile + paint has no equivalent fallback color. + justification: | + A tile paint has no stop/last-color concept to flat-fill with, unlike a + gradient; leaving affected pixels transparent mirrors GradientEvaluator's own + separate "no valid root" radial case and keeps rendering well-defined for every + valid tile paint whose transform happens to become singular (for example, + mid-animation). + tests: + - EvaluateRow_NonInvertibleTransform_WritesFullyTransparentRow + + - id: CanvasNet-Drawing-TilePaint-Paint + title: >- + Filling a path with a tile paint shall evaluate the tile paint once per pixel + and scale the result by that pixel's antialiased fill coverage, honoring the + same fill rule, curve-flattening, implicit-subpath-closing, and empty/ + out-of-bounds no-op behavior already established for solid-color and gradient + fills. + justification: | + Tile-painted fills must be visually and behaviorally consistent with + solid-color and gradient fills in every respect except color source, so callers + can switch between paint styles without any other change in rendering behavior. + tests: + - PathFiller_Fill_TilePaint_CheckerboardTile_FillsWithBothTileColors + - PathFiller_Fill_TilePaint_EmptyPath_NoOpLeavesSurfaceUnchanged + - ScanlineRasterizer_Fill_TilePaint_FullCoverageRow_RepeatsAcrossWidth + - ScanlineRasterizer_Fill_TilePaint_NoPolygons_NoOp + + - id: CanvasNet-Drawing-TilePaint-PaintValidation + title: >- + Filling a path with a tile paint shall reject a null surface, path, or tile + paint argument with ArgumentNullException. + justification: | + A null reference argument cannot supply a valid fill target, geometry, or + paint; all such invalid input must fail fast and consistently with the + equivalent solid-color/gradient fill validation. + tests: + - PathFiller_Fill_TilePaint_NullSurface_ThrowsArgumentNullException + - PathFiller_Fill_TilePaint_NullPath_ThrowsArgumentNullException + - PathFiller_Fill_TilePaint_NullPaint_ThrowsArgumentNullException + - ScanlineRasterizer_Fill_TilePaint_NullPaint_ThrowsArgumentNullException diff --git a/docs/reqstream/canvas-net/fonts.yaml b/docs/reqstream/canvas-net/fonts.yaml index d64ec3b5..ea918bb1 100644 --- a/docs/reqstream/canvas-net/fonts.yaml +++ b/docs/reqstream/canvas-net/fonts.yaml @@ -4,9 +4,11 @@ # 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 +# geometry, and reporting metrics/advance widths/kerning (the TrueTypeFont unit). It also covers +# decoding CFF/OpenType (glyf-free, Type 2 charstring) outline data, and loading an individual face +# out of a TrueType Collection (ttcf) container. Each requirement here links down to the unit +# requirements that implement it. +# cspell:ignore glyf sfnt codepoint codepoints formedness renderable charstring charstrings ttcf sections: - title: CanvasNet Requirements @@ -29,7 +31,7 @@ sections: - CanvasNet-Fonts-TrueTypeFont-LoadNullPath - CanvasNet-Fonts-TrueTypeFont-LoadEmptyPath - CanvasNet-Fonts-TrueTypeFont-RejectUnrecognizedVersion - - CanvasNet-Fonts-TrueTypeFont-RejectCffOutlines + - CanvasNet-Fonts-TrueTypeFont-RejectOttoWithoutCffTable - CanvasNet-Fonts-TrueTypeFont-RejectMissingRequiredTable - CanvasNet-Fonts-TrueTypeFont-RejectUnsupportedGlyphOutlineVersion - CanvasNet-Fonts-TrueTypeFont-RejectInvalidUnitsPerEm @@ -100,3 +102,140 @@ sections: - CanvasNet-Fonts-TrueTypeFont-KerningMalformedTableTolerant tests: - CanvasNet_SystemIntegration_LoadFontAndQueryMetrics_ReturnsExpectedValues + + - id: CanvasNet-Fonts-CffOutlineDecoding + title: >- + The Fonts subsystem shall load a CFF/OpenType (OTTO-flavored) font and decode its + glyph outlines from Type 2 charstring bytecode into the same vector path geometry as + a glyf-flavored font, supporting a fixed, well-defined subset of the Type 2 charstring + operator set, rejecting CID-keyed CFF data, and never allowing one corrupt glyph's + charstring to prevent any other glyph in the same font from decoding. + justification: | + Real-world production fonts (in particular most professionally produced Latin, and + nearly all "Pro"/"Premium" commercial font families) commonly ship as CFF-flavored + OpenType rather than glyf-flavored TrueType; callers need the same TrueTypeFont API to + load and render either flavor's glyph outlines interchangeably. + children: + - CanvasNet-Fonts-TrueTypeFont-LoadCffOutlines + - CanvasNet-Fonts-TrueTypeFont-RejectOttoWithoutCffTable + - CanvasNet-Fonts-TrueTypeFont-RejectCffGlyphCountMismatch + - CanvasNet-Fonts-TrueTypeFont-RejectCidKeyedCff + - CanvasNet-Fonts-TrueTypeFont-CffTableParsing + - CanvasNet-Fonts-TrueTypeFont-CffStemHints + - CanvasNet-Fonts-TrueTypeFont-CffMoveTo + - CanvasNet-Fonts-TrueTypeFont-CffLineTo + - CanvasNet-Fonts-TrueTypeFont-CffCurveTo + - CanvasNet-Fonts-TrueTypeFont-CffRCurveLineRLineCurve + - CanvasNet-Fonts-TrueTypeFont-CffFlex + - CanvasNet-Fonts-TrueTypeFont-CffSubroutines + - CanvasNet-Fonts-TrueTypeFont-CffEndChar + - CanvasNet-Fonts-TrueTypeFont-CffUnsupportedOperatorRejection + - CanvasNet-Fonts-TrueTypeFont-CffSeacComposition + - CanvasNet-Fonts-TrueTypeFont-CffCharsetResolution + - CanvasNet-Fonts-TrueTypeFont-CffWidthResolution + - CanvasNet-Fonts-TrueTypeFont-LoadType1C + tests: + - TrueTypeFont_RealSourceSans3OtfFont_RendersCffGlyphOutlineAsVisibleInk + - TrueTypeFont_LoadType1C_WellFormedCff_ExposesGlyphsAndMetrics + + - id: CanvasNet-Fonts-TtcSupport + title: >- + The Fonts subsystem shall report the number of faces in a TrueType Collection (ttcf) + container or an ordinary single-face SFNT font, load a specific face by index from + either kind of file, and default to the first face when no index is given, validating + the face index argument and the container's own header. + justification: | + A TrueType Collection bundles multiple font faces (commonly weight/style variants + sharing glyph outline data) inside one file; callers need to discover how many faces + a file contains and select any one of them by index, while an ordinary single-face + font continues to behave exactly as before by default. + children: + - CanvasNet-Fonts-TrueTypeFont-GetFaceCountPlainSfnt + - CanvasNet-Fonts-TrueTypeFont-GetFaceCountTtc + - CanvasNet-Fonts-TrueTypeFont-LoadTtcDefaultsFaceZero + - CanvasNet-Fonts-TrueTypeFont-LoadTtcExplicitFaceIndex + - CanvasNet-Fonts-TrueTypeFont-RejectFaceIndexOutOfRange + - CanvasNet-Fonts-TrueTypeFont-FaceIndexZeroBehavesAsLoad + - CanvasNet-Fonts-TrueTypeFont-RejectMalformedTtcHeader + tests: + - TrueTypeFont_RealTtcContainer_FaceOne_RendersCffGlyphOutlineAsVisibleInk + - TrueTypeFont_RealTtcContainer_FaceZero_RendersGlyfGlyphOutlineAsVisibleInk + - CanvasNet_SystemIntegration_LoadFaceFromTtcContainerByIndex_ReturnsExpectedFace + + - id: CanvasNet-Fonts-NameStyleMetadata + title: >- + The Fonts subsystem shall expose each loaded font's name-table strings (family, + subfamily, full name, PostScript name) and derived bold/italic/fixed-pitch style + metadata, applying documented OpenType platform-preference and table-fallback + rules, and tolerating absent or malformed optional tables without throwing. + justification: | + Callers selecting or labeling a loaded font (for example choosing a system fallback + font, or displaying a font picker) need its human-readable name and basic style + classification, resolved consistently from whichever name/style tables a real-world + font actually supplies, without the caller re-implementing OpenType's own + platform-preference and table-fallback rules itself. + children: + - CanvasNet-Fonts-TrueTypeFont-GetNameInfoResolvesTypographicNames + - CanvasNet-Fonts-TrueTypeFont-GetNameInfoFallsBackToStandardNames + - CanvasNet-Fonts-TrueTypeFont-GetNameInfoPlatformPreference + - CanvasNet-Fonts-TrueTypeFont-GetNameInfoMacintoshFallback + - CanvasNet-Fonts-TrueTypeFont-GetNameInfoMissingRecordTolerant + - CanvasNet-Fonts-TrueTypeFont-GetNameInfoNoNameTableTolerant + - CanvasNet-Fonts-TrueTypeFont-GetNameInfoMalformedRecordTolerant + - CanvasNet-Fonts-TrueTypeFont-StyleFromOs2FsSelection + - CanvasNet-Fonts-TrueTypeFont-StyleFromOs2WeightClass + - CanvasNet-Fonts-TrueTypeFont-StyleFromPostItalicAngle + - CanvasNet-Fonts-TrueTypeFont-StyleFallsBackToMacStyleWhenOs2Absent + - CanvasNet-Fonts-TrueTypeFont-IsFixedPitchFromPostTable + - CanvasNet-Fonts-TrueTypeFont-IsFixedPitchAbsentPostTableTolerant + tests: + - TrueTypeFont_RealOpenSansFont_ExposesExpectedNameAndStyleMetadata + - TrueTypeFont_RealSourceSans3OtfFont_ExposesExpectedNameAndStyleMetadata + - TrueTypeFont_RealTtcContainer_BothFaces_ExposeExpectedNameAndStyleMetadataIndependently + + - id: CanvasNet-Fonts-Type1OutlineDecoding + title: >- + The Fonts subsystem shall load a classic PostScript Type 1 font program - either + from caller-supplied cleartext/eexec-encrypted byte segments or auto-detected from + a standalone .pfb/.pfa file - and decode its glyph outlines from Type 1 charstring + bytecode into the same vector path geometry as a glyf/CFF-flavored font, supporting + a fixed, well-defined subset of the Type 1 charstring operator set (including real + flex geometry and hint-replacement pass-through), and rejecting seac-based + composite glyphs and malformed/truncated font program data. + justification: | + Classic PostScript Type 1 fonts remain common as embedded fonts in legacy PDF + documents and as standalone .pfb/.pfa font files; callers need the same TrueTypeFont + API to load and render Type 1 glyph outlines interchangeably with the glyf/CFF + flavors this subsystem already supports. + children: + - CanvasNet-Fonts-TrueTypeFont-LoadType1FromSegments + - CanvasNet-Fonts-TrueTypeFont-LoadType1Outlines + tests: + - TrueTypeFont_LoadType1_WellFormedProgram_ExposesGlyphsAndMetrics + - TrueTypeFont_Load_StandalonePfbFile_AutoDetectsAndRoundTrips + - TrueTypeFont_Load_StandalonePfaFile_AutoDetectsAndRoundTrips + + - id: CanvasNet-Fonts-SystemFontDiscovery + title: >- + The Fonts subsystem shall provide directory-scan-only discovery of fonts installed + on the host operating system, best-effort family-name/style matching against that + catalog, and a bundled, always-available Liberation Sans/Serif/Mono last-resort + fallback font (the SystemFontCatalog unit), for use by a caller (today, + DemaConsulting.CanvasNet.Pdf's PdfDocument) substituting a non-embedded font. + justification: | + A caller resolving a document's non-embedded font needs to know what fonts are + actually installed on the host machine, a best-effort way to match a requested + family/style against them, and a deterministic last resort when nothing matches - + all format-agnostic capabilities that belong in the Fonts subsystem alongside + TrueTypeFont, not duplicated per document-format consumer. + children: + - CanvasNet-Fonts-SystemFontCatalog-DirectoryScanDiscovery + - CanvasNet-Fonts-SystemFontCatalog-LazyProcessLifetimeCache + - CanvasNet-Fonts-SystemFontCatalog-ToleratesMissingOrUnparseable + - CanvasNet-Fonts-SystemFontCatalog-FindBestMatchExactFamily + - CanvasNet-Fonts-SystemFontCatalog-FindBestMatchGenericBucket + - CanvasNet-Fonts-SystemFontCatalog-FindBestMatchNoMatch + - CanvasNet-Fonts-SystemFontCatalog-LoadBundledFallback + - CanvasNet-Fonts-SystemFontCatalog-LoadBundledFallbackMissingResourceGuard + tests: + - SystemFontCatalog_RealFilesystem_ScanKnownOsDirectories_DiscoversFontsOrIsGracefullyEmpty diff --git a/docs/reqstream/canvas-net/fonts/system-font-catalog.yaml b/docs/reqstream/canvas-net/fonts/system-font-catalog.yaml new file mode 100644 index 00000000..981821b6 --- /dev/null +++ b/docs/reqstream/canvas-net/fonts/system-font-catalog.yaml @@ -0,0 +1,126 @@ +--- +# Software Unit Requirements for SystemFontCatalog +# +# These requirements describe the design-level behavior of the SystemFontCatalog unit. +# cspell:ignore Dejavu Nimbus Consolas ttcf + +sections: + - title: CanvasNet Requirements + sections: + - title: Fonts Requirements + sections: + - title: SystemFontCatalog Requirements + requirements: + - id: CanvasNet-Fonts-SystemFontCatalog-DirectoryScanDiscovery + title: >- + The SystemFontCatalog type shall discover every font file installed on the host + operating system by directory-scanning a documented set of well-known font + directories (Windows/macOS/Linux, no Windows registry), loading every face of + every candidate .ttf/.ttc/.otf file found. + justification: | + Callers substituting a non-embedded font need to know what fonts are actually + installed on the host machine; a directory scan is portable across every + operating system this library targets without depending on a platform-specific + registry API. + tests: + - SystemFontCatalog_RealFilesystem_ScanKnownOsDirectories_DiscoversFontsOrIsGracefullyEmpty + - SystemFontCatalog_RealFilesystem_KnownPlatformFontFamily_ResolvesToRealFontWhenInstalled + + - id: CanvasNet-Fonts-SystemFontCatalog-LazyProcessLifetimeCache + title: >- + The SystemFontCatalog type shall build its font catalog exactly once per + process, on first access to Fonts, and return the same cached instance on every + subsequent access for the lifetime of the process. + justification: | + Scanning every font directory and parsing every candidate file is expensive; + callers that resolve fallback fonts repeatedly (once per distinct font + dictionary, potentially many times per rendered document) must not pay that cost + more than once per process. + tests: + - SystemFontCatalog_Fonts_IsLazilyBuiltOnceAndCachedForProcessLifetime + + - id: CanvasNet-Fonts-SystemFontCatalog-ToleratesMissingOrUnparseable + title: >- + The SystemFontCatalog type shall silently skip a missing or inaccessible scan + directory and any candidate font file that fails to parse (including a single + corrupt face within an otherwise well-formed collection), never aborting the + whole catalog build or throwing from Fonts itself. + justification: | + A locked-down, missing, or partially corrupt font directory or file is common in + real-world deployments (minimal container images, restrictive file permissions); + callers need every other well-formed font still discovered rather than the + entire catalog failing. + tests: + - SystemFontCatalog_Fonts_MissingScanDirectory_SkippedWithoutThrowing + - SystemFontCatalog_Fonts_UnparseableCandidateFile_SkippedWithoutThrowing + + - id: CanvasNet-Fonts-SystemFontCatalog-FindBestMatchExactFamily + title: >- + The SystemFontCatalog type shall resolve FindBestMatch's tier-1 exact-family-name + match case-insensitively, and among multiple style variants of the same matched + family, prefer the variant minimizing mismatched bold/italic flags. + justification: | + A caller substituting a font whose intended family is actually installed on the + host machine (case aside) should receive that exact family - and the style + variant of it closest to what was requested - rather than an unrelated + generic-bucket font. + tests: + - SystemFontCatalog_FindBestMatch_ExactFamilyNameCaseInsensitive_ReturnsMatch + - SystemFontCatalog_FindBestMatch_ExactFamilyStyleMismatch_PrefersClosestStyleVariant + + - id: CanvasNet-Fonts-SystemFontCatalog-FindBestMatchGenericBucket + title: >- + The SystemFontCatalog type shall, only when no exact family match exists, search + a small hand-maintained list of well-known sans/serif/monospace family names (in + a documented priority order) for the first one actually present in the catalog, + selecting the fixed-pitch bucket over the serif bucket when both are requested. + justification: | + When a document's intended font family is not installed on the host machine, a + generic same-category substitute (a sans-serif font for a sans-serif request, a + monospace font for a fixed-pitch request, and so on) is a far better visual + approximation than an arbitrary unrelated font. + tests: + - SystemFontCatalog_FindBestMatch_NoExactFamily_FallsBackToGenericSansBucket + - SystemFontCatalog_FindBestMatch_NoExactFamily_FallsBackToGenericSerifBucket + - SystemFontCatalog_FindBestMatch_NoExactFamily_FallsBackToGenericFixedPitchBucket + + - id: CanvasNet-Fonts-SystemFontCatalog-FindBestMatchNoMatch + title: >- + The SystemFontCatalog type shall return null from FindBestMatch when the catalog + is empty, or genuinely has no exact family match and no generic-bucket match + either, without ever consulting LoadBundledFallback itself. + justification: | + FindBestMatch is a pure "what does the host operating system have installed" + query; composing "system match, else bundled fallback" is deliberately left to + the caller, which needs a well-defined null result to know when to fall through + to LoadBundledFallback. + tests: + - SystemFontCatalog_FindBestMatch_EmptyCandidateList_ReturnsNull + - SystemFontCatalog_FindBestMatch_NoFamilyAndNoBucketMatch_ReturnsNull + + - id: CanvasNet-Fonts-SystemFontCatalog-LoadBundledFallback + title: >- + The SystemFontCatalog type shall load (and process-lifetime-cache) the + closest-matching bundled Liberation Sans/Serif/Mono style for a requested + serif/fixed-pitch/bold/italic combination from this assembly's embedded + resources, never consulting the host operating system's installed fonts. + justification: | + A caller needs a deterministic, always-available last-resort font when no + system font matches at all (for example a minimal container image with no fonts + installed), so that font substitution never fails outright for an otherwise + unsupported document. + tests: + - BundledFonts_AllTwelveLiberationVariants_LoadSuccessfullyAndExposeExpectedNameAndStyle + + - id: CanvasNet-Fonts-SystemFontCatalog-LoadBundledFallbackMissingResourceGuard + title: >- + The SystemFontCatalog type shall throw InvalidOperationException if the expected + embedded bundled-font resource is unexpectedly absent, as a defensive + packaging-integrity guard. + justification: | + If this assembly's own shipped embedded resources were ever missing or + misnamed, that is a library packaging defect, not a caller-facing "unsupported + document feature" condition, and must surface as a distinct, loud failure rather + than being silently reclassified or swallowed. + tests: + - SystemFontCatalog_LoadBundledFallback_MissingEmbeddedResource_ThrowsInvalidOperationException diff --git a/docs/reqstream/canvas-net/fonts/true-type-font.yaml b/docs/reqstream/canvas-net/fonts/true-type-font.yaml index 631c2a1c..1600605a 100644 --- a/docs/reqstream/canvas-net/fonts/true-type-font.yaml +++ b/docs/reqstream/canvas-net/fonts/true-type-font.yaml @@ -4,6 +4,10 @@ # 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 +# cspell:ignore charsets ISOAdobe isoadobe +# cspell:ignore bchar achar Agrave +# cspell:ignore hflex flex1 hflex1 +# cspell:ignore misparse sections: - title: CanvasNet Requirements @@ -65,13 +69,14 @@ sections: tests: - SfntContainer_Parse_UnrecognizedVersion_ThrowsInvalidDataException - - id: CanvasNet-Fonts-TrueTypeFont-RejectCffOutlines + - id: CanvasNet-Fonts-TrueTypeFont-RejectOttoWithoutCffTable title: >- - The TrueTypeFont class shall reject a font that declares CFF/OpenType outlines with - InvalidDataException. + The TrueTypeFont class shall reject a font that declares CFF/OpenType outlines but + does not contain the required CFF table with InvalidDataException. justification: | - CFF-based outlines use an entirely different, unsupported outline representation; - failing clearly here prevents silently producing empty or incorrect glyph outlines. + An OTTO-flavored font without its required CFF table has no glyph outline data + this class can read under either supported outline representation; failing + clearly here prevents silently producing empty or incorrect glyph outlines. tests: - TrueTypeFont_Load_OttoFont_ThrowsInvalidDataException - SfntContainer_Parse_OttoVersion_ThrowsInvalidDataException @@ -336,6 +341,7 @@ sections: tests: - HmtxHheaReader_Parse_ExposesMetrics - HmtxHheaReader_GetAdvanceWidth_TailGlyph_ReusesLastEntry + - HmtxHheaReader_Parse_MissingTrailingLsbTail_ParsesSuccessfully - TrueTypeFont_Load_EndToEnd_GlyphIndexOutlineAdvanceAndKerning - id: CanvasNet-Fonts-TrueTypeFont-AdvanceWidthValidation @@ -391,3 +397,713 @@ sections: - KernTable_MalformedPairCount_IsTolerant_ReturnsZero - KernTable_UnsupportedFormat2Subtable_IsTolerant_ReturnsZero - KernTable_CrossStreamCoverage_IsSkipped_ReturnsZero + + - id: CanvasNet-Fonts-TrueTypeFont-LoadCffOutlines + title: >- + The TrueTypeFont class shall load a CFF/OpenType (OTTO-flavored) font that + contains a well-formed CFF table, exposing the same UnitsPerEm, Ascender, + Descender, LineGap, GlyphCount, GetGlyphIndex, GetGlyphOutline, GetAdvanceWidth, + and GetKerning behavior as a glyf-flavored font. + justification: | + Callers must be able to use the exact same TrueTypeFont API against either + supported outline flavor without any type-checking or flavor-specific handling of + their own. + tests: + - TrueTypeFont_Load_OttoFontWithCffTable_Succeeds + - SfntContainer_Parse_OttoVersionWithCffTable_Succeeds + - TrueTypeFont_RealSourceSans3OtfFont_RendersCffGlyphOutlineAsVisibleInk + + - id: CanvasNet-Fonts-TrueTypeFont-RejectCffGlyphCountMismatch + title: >- + The TrueTypeFont class shall reject a CFF-flavored font whose CFF CharStrings + INDEX glyph count disagrees with the glyph-count table's declared GlyphCount with + InvalidDataException. + justification: | + A disagreement between the two glyph counts indicates the font's tables are + internally inconsistent; failing clearly here prevents glyph indices resolved + against one count from silently reading past or short of the other table's data. + tests: + - TrueTypeFont_Load_CffGlyphCountMismatchesMaxp_ThrowsInvalidDataException + + - id: CanvasNet-Fonts-TrueTypeFont-RejectCidKeyedCff + title: >- + The TrueTypeFont class shall reject a CID-keyed CFF font (one whose Top DICT + declares a ROS operator) with InvalidDataException. + justification: | + CID-keyed CFF data uses a different (FDArray/FDSelect-based) private-DICT and + subroutine addressing scheme than the name-keyed layout this class supports; + failing clearly here prevents misinterpreting glyph data under the wrong scheme. + tests: + - CffTable_Parse_CidKeyedRos_ThrowsInvalidDataException + + - id: CanvasNet-Fonts-TrueTypeFont-CffTableParsing + title: >- + The CffTable class shall parse a CFF table's Header, Name INDEX, Top DICT INDEX, + String INDEX, Global Subr INDEX, Private DICT, Local Subr INDEX, and CharStrings + INDEX, exposing a glyph count and per-glyph outline access, decoding each glyph + lazily so that one corrupt glyph's charstring does not prevent any other glyph in + the same font from decoding, and rejecting structurally malformed or truncated + CFF data with InvalidDataException. + justification: | + Every one of these CFF structures must be correctly located and decoded before any + glyph outline can be extracted, and a single corrupt or malicious glyph must never + be allowed to prevent every other (otherwise well-formed) glyph in the same font + from being used, mirroring GlyfLocaReader's equivalent per-glyph isolation. + tests: + - CffTable_Parse_WellFormedFont_ExposesGlyphCountAndOutlines + - CffTable_Parse_NoPrivateDict_SucceedsWithNoLocalSubrs + - CffTable_GetGlyphOutline_OutOfRangeIndex_ThrowsArgumentOutOfRangeException + - CffTable_GetGlyphOutline_OneCorruptGlyph_DoesNotBreakOtherGlyphs + - CffTable_Parse_MissingCharStringsOperator_ThrowsInvalidDataException + - CffTable_Parse_UnsupportedMajorVersion_ThrowsInvalidDataException + - CffTable_Parse_TruncatedTable_ThrowsInvalidDataException + - CffTable_Parse_EmptyCharStringsIndex_ThrowsInvalidDataException + + - id: CanvasNet-Fonts-TrueTypeFont-CffStemHints + title: >- + The CffCharstringInterpreter class shall interpret the hstem, vstem, hstemhm, and + vstemhm operators to accumulate the glyph's total stem-hint count, and the + hintmask and cntrmask operators to skip exactly one mask byte per every 8 stems + (rounded up), including stems declared implicitly by operand pairs left on the + stack before the first mask operator. + justification: | + A hintmask/cntrmask operator's variable-length mask byte count depends entirely on + the number of stem hints declared so far; miscounting it would misalign every + subsequent operator's byte offset in the charstring, corrupting the rest of the + glyph's decode. + tests: + - CffCharstringInterpreter_HStem_VStem_AccumulateStemCountForHintMask + - CffCharstringInterpreter_HintMask_ImplicitVStem_CountsTowardMaskBytes + - CffCharstringInterpreter_HintMask_TruncatedMaskBytes_ThrowsInvalidDataException + + - id: CanvasNet-Fonts-TrueTypeFont-CffMoveTo + title: >- + The CffCharstringInterpreter class shall interpret the rmoveto, hmoveto, and + vmoveto operators to start a new subpath at a relative offset from the current + point, correctly consuming an optional leading width operand only on the first + stack-clearing operator in the charstring, and rejecting an operand count that + does not match the operator's expected arity with InvalidDataException. + justification: | + Every subpath in a Type 2 charstring begins with exactly one of these three + moveto operators, and the first one may carry an extra leading glyph-width operand + that must be recognized and discarded rather than misread as a coordinate. + tests: + - CffCharstringInterpreter_RMoveTo_LineTo_Endchar_ProducesClosedContour + - CffCharstringInterpreter_RMoveTo_WithLeadingWidth_IgnoresWidthOperand + - CffCharstringInterpreter_HMoveTo_VMoveTo_MoveAlongSingleAxis + - CffCharstringInterpreter_RMoveTo_WrongOperandCount_ThrowsInvalidDataException + + - id: CanvasNet-Fonts-TrueTypeFont-CffLineTo + title: >- + The CffCharstringInterpreter class shall interpret the rlineto, hlineto, and + vlineto operators to append straight line segments to the current subpath, + alternating axis on each successive operand pair or value for hlineto/vlineto, and + rejecting an operand count incompatible with the operator's expected arity with + InvalidDataException. + justification: | + Straight-line glyph segments are one of the two path segment kinds a Type 2 + charstring can produce; hlineto/vlineto's alternating-axis operand packing must be + decoded correctly to reproduce the intended zig-zag polyline. + tests: + - CffCharstringInterpreter_HLineTo_VLineTo_AlternateAxes + - CffCharstringInterpreter_RLineTo_OddOperandCount_ThrowsInvalidDataException + + - id: CanvasNet-Fonts-TrueTypeFont-CffCurveTo + title: >- + The CffCharstringInterpreter class shall interpret the rrcurveto, hhcurveto, + vvcurveto, hvcurveto, and vhcurveto operators to append cubic Bezier curve + segments to the current subpath, correctly applying each operator's own leading- + operand and alternating-tangent conventions. + justification: | + Curved glyph segments are the other of the two path segment kinds a Type 2 + charstring can produce; each curve operator packs its operands under a distinct + convention that must be decoded exactly to reproduce the intended curve geometry. + tests: + - CffCharstringInterpreter_RRCurveTo_ProducesCubicBezier + - CffCharstringInterpreter_HHCurveTo_VVCurveTo_ProduceCubicBeziers + - CffCharstringInterpreter_HHCurveTo_LeadingOperand_AppliesToFirstCurveOnly + - CffCharstringInterpreter_HVCurveTo_VHCurveTo_AlternateStartTangent + - CffCharstringInterpreter_HVCurveTo_TrailingOperand_SuppliesFinalAxisDelta + + - id: CanvasNet-Fonts-TrueTypeFont-CffRCurveLineRLineCurve + title: >- + The CffCharstringInterpreter class shall interpret the rcurveline operator as + one or more rrcurveto-style curve segments followed by exactly one trailing + relative line, and the rlinecurve operator as one or more relative line + segments followed by exactly one trailing rrcurveto-style curve segment, + rejecting an operand count other than 6n+2 (rcurveline) or 2n+6 (rlinecurve) + for some n >= 1 with InvalidDataException. + justification: | + rcurveline and rlinecurve let a font compactly combine a curve run with a + single trailing line (or vice versa) in one operator rather than two, and are + encountered in real-world CFF fonts; their combined-segment operand packing + must be decoded exactly, including rejecting operand counts that are not of the + required combined form. + tests: + - CffCharstringInterpreter_RCurveLine_OneCurveThenLine_ProducesCurveAndLine + - CffCharstringInterpreter_RCurveLine_TwoCurvesThenLine_ProducesCurvesAndLine + - CffCharstringInterpreter_RCurveLine_WrongOperandCount_OneShort_ThrowsInvalidDataException + - CffCharstringInterpreter_RCurveLine_WrongOperandCount_OneOver_ThrowsInvalidDataException + - CffCharstringInterpreter_RLineCurve_OneLineThenCurve_ProducesLineAndCurve + - CffCharstringInterpreter_RLineCurve_TwoLinesThenCurve_ProducesLinesAndCurve + - CffCharstringInterpreter_RLineCurve_WrongOperandCount_OneShort_ThrowsInvalidDataException + - CffCharstringInterpreter_RLineCurve_WrongOperandCount_OneOver_ThrowsInvalidDataException + + - id: CanvasNet-Fonts-TrueTypeFont-CffFlex + title: >- + The CffCharstringInterpreter class shall interpret the two-byte escape + operators hflex (12 34), flex (12 35), hflex1 (12 36), and flex1 (12 37) as + flex shortcuts producing two cubic Bezier curve segments per their own reduced- + operand convention - including hflex1/flex1's forcing of their final point back + onto the starting axis where an operand is omitted - and the dotsection escape + operator (12 0) as a deprecated no-op that only clears the operand stack, + each rejecting an operand count other than its own exact required count with + InvalidDataException. + justification: | + The flex shortcut operators and the deprecated dotsection operator are part of + the Type 2 charstring operator set and are encountered in real-world CFF fonts; + each flex variant's reduced-operand convention (including the forced-axis + semantics of hflex1/flex1) must be decoded exactly to reproduce the intended + curve geometry, and dotsection must have no effect on the resulting outline. + tests: + - CffCharstringInterpreter_HFlex_ProducesTwoCubicBeziersWithHandComputedCoordinates + - CffCharstringInterpreter_HFlex_WrongOperandCount_OneShort_ThrowsInvalidDataException + - CffCharstringInterpreter_HFlex_WrongOperandCount_OneOver_ThrowsInvalidDataException + - CffCharstringInterpreter_Flex_ProducesTwoCubicBeziersWithHandComputedCoordinates + - CffCharstringInterpreter_Flex_WrongOperandCount_OneShort_ThrowsInvalidDataException + - CffCharstringInterpreter_Flex_WrongOperandCount_OneOver_ThrowsInvalidDataException + - CffCharstringInterpreter_HFlex1_ProducesTwoCubicBeziersWithHandComputedCoordinates + - CffCharstringInterpreter_HFlex1_WrongOperandCount_OneShort_ThrowsInvalidDataException + - CffCharstringInterpreter_HFlex1_WrongOperandCount_OneOver_ThrowsInvalidDataException + - CffCharstringInterpreter_Flex1_DxDominant_ProducesHandComputedCoordinates + - CffCharstringInterpreter_Flex1_DyDominant_ProducesHandComputedCoordinates + - CffCharstringInterpreter_Flex1_WrongOperandCount_OneShort_ThrowsInvalidDataException + - CffCharstringInterpreter_Flex1_WrongOperandCount_OneOver_ThrowsInvalidDataException + - CffCharstringInterpreter_DotSection_IsNoOp_MatchesSameCharstringWithoutDotSection + + - id: CanvasNet-Fonts-TrueTypeFont-CffSubroutines + title: >- + The CffCharstringInterpreter class shall interpret the callsubr and callgsubr + operators to invoke a local or global subroutine respectively (applying the + correct bias of 107, 1131, or 32768 depending on that subroutine INDEX's own + count) and the return operator to resume the calling charstring, bounding + recursion by both call depth and total executed step count, and rejecting an + out-of-range subroutine index with InvalidDataException. + justification: | + Subroutine calls let a font compactly reuse common charstring fragments across + many glyphs; the bias formula must be applied correctly for the interpreter to + resolve the intended subroutine, and both depth and step bounding are required to + prevent a malicious or corrupt font from causing unbounded recursion or looping. + tests: + - CffCharstringInterpreter_CallSubr_AppliesBias_AndReturns + - CffCharstringInterpreter_CallGSubr_AppliesGlobalBias + - CffCharstringInterpreter_CallSubr_OutOfRangeIndex_ThrowsInvalidDataException + - CffCharstringInterpreter_CallSubr_ExceedsMaxDepth_ThrowsInvalidDataException + - CffTable_Parse_WithLocalSubrs_DecodesGlyphUsingCallSubr + - CffTable_Parse_WithGlobalSubrs_DecodesGlyphUsingCallGSubr + + - id: CanvasNet-Fonts-TrueTypeFont-CffEndChar + title: >- + The CffCharstringInterpreter class shall interpret the endchar operator to close + and finish the glyph's outline, producing an empty path for a charstring + consisting only of endchar, and shall reject any unexpected leftover operand + count other than zero or the legacy 4-operand seac-style form with + InvalidDataException. + justification: | + Every charstring must end with endchar; an unexpected leftover operand count + (other than the legacy seac-style form, handled separately) indicates a + malformed charstring and must fail clearly rather than silently produce an + incomplete or incorrect outline. + tests: + - CffCharstringInterpreter_EmptyCharstring_ProducesEmptyPath + - CffCharstringInterpreter_EndChar_UnexpectedOperandCount_ThrowsInvalidDataException + + - id: CanvasNet-Fonts-TrueTypeFont-CffSeacComposition + title: >- + The CffCharstringInterpreter class shall interpret the legacy 4-operand + ("seac-style") form of endchar - adx ady bchar achar endchar - by resolving + bchar and achar as Adobe StandardEncoding codes to glyph names, resolving those + names to glyph indices within the same font, decoding each resolved glyph's own + outline, translating the accent glyph's outline by (adx, ady), and combining it + with the base glyph's untranslated outline to form the composite glyph's final + outline - with the composite's own advance width following the normal width + convention independently of the base/accent glyphs' own widths - and shall + reject with InvalidDataException when no resolver is supplied (including when a + seac component glyph is itself defined using the seac-style form, which is not + supported), when bchar/achar is not a valid StandardEncoding code, or when the + resolved glyph name is not present in this font's charset. + justification: | + The seac-style endchar form is a deprecated but still-encountered mechanism for + composing accented characters (e.g. Agrave) from simpler base and accent glyphs + already present in the same font; supporting it lets such fonts render their + accented glyphs correctly instead of failing outright, while nested seac + composition remains intentionally unsupported to bound recursion. + tests: + - CffCharstringInterpreter_EndChar_SeacStyleFourOperands_NoResolverSupplied_ThrowsInvalidDataException + - CffCharstringInterpreter_EndChar_SeacStyleFourOperands_WithResolver_ComposesBaseAndTranslatedAccent + - CffCharstringInterpreter_EndChar_SeacStyleFourOperands_ResolverOmittedOnComponentDecode_ThrowsInvalidDataException + - CffTable_GetGlyphOutline_SeacStyleEndChar_ComposesBaseAndTranslatedAccent + - CffTable_GetAdvanceWidth_SeacStyleEndChar_UsesOwnWidthNotComponentWidths + - CffTable_GetGlyphOutline_SeacStyleEndChar_UndefinedStandardEncodingCode_ThrowsInvalidDataException + - CffTable_GetGlyphOutline_SeacStyleEndChar_GlyphNameNotInCharset_ThrowsInvalidDataException + - CffTable_GetGlyphOutline_SeacStyleEndChar_ComponentItselfSeacStyle_ThrowsInvalidDataException + - CffStandardEncoding_CodeToGlyphName_HasExpectedLength + - CffStandardEncoding_CodeToGlyphName_SpotChecksKnownCodes + - CffStandardEncoding_CodeToGlyphName_UndefinedControlCodesAreNull + + - id: CanvasNet-Fonts-TrueTypeFont-CffUnsupportedOperatorRejection + title: >- + The CffCharstringInterpreter class shall reject any Type 2 charstring operator + outside its supported operator set and any charstring that ends before its + declared operator or operand data has been fully read, with + InvalidDataException, while correctly decoding every supported + fixed-point and integer operand encoding. + justification: | + A fixed, well-defined operator subset keeps this interpreter's behavior fully + specified and auditable; any operator or operand encoding outside that subset must + fail clearly rather than be silently ignored or misinterpreted, and a truncated + charstring must never be read past its own end. + tests: + - CffCharstringInterpreter_UnsupportedOperator_ThrowsInvalidDataException + - CffCharstringInterpreter_UnsupportedEscapeOperator_ThrowsInvalidDataException + - CffCharstringInterpreter_FixedPointOperand_DecodesCorrectly + - CffCharstringInterpreter_TruncatedCharstring_ThrowsInvalidDataException + + - id: CanvasNet-Fonts-TrueTypeFont-GetFaceCountPlainSfnt + title: >- + The TrueTypeFont class shall report a face count of 1 via GetFaceCount for an + ordinary (non-ttcf) single-face SFNT font, from both a stream and a file path, + without parsing that font's full table directory. + justification: | + Callers need a single, uniform way to discover a file's face count regardless of + whether it is a collection, without paying the cost of a full font load just to + find out there is only one face. + tests: + - TrueTypeFont_GetFaceCount_PlainSfnt_ReturnsOne + + - id: CanvasNet-Fonts-TrueTypeFont-GetFaceCountTtc + title: >- + The TrueTypeFont class shall report the actual number of faces declared by a + ttcf container's header via GetFaceCount, from both a stream and a file path, + parsing only the container's tag and face count rather than its full table + directories. + justification: | + Callers need to discover how many faces a TrueType Collection contains before + deciding which face index (or indices) to load, as cheaply as possible. + tests: + - TrueTypeFont_GetFaceCount_TtcContainer_ReturnsFaceCount + - TrueTypeFont_GetFaceCount_Path_ReadsFromFile + - SfntContainer_TryReadTtcHeader_WellFormedContainer_ReturnsFaceOffsets + - SfntContainer_TryReadTtcHeader_EachFaceIndependentlyParsable + + - id: CanvasNet-Fonts-TrueTypeFont-LoadTtcDefaultsFaceZero + title: >- + The TrueTypeFont class shall transparently select face 0 when Load(Stream) or + Load(string) is given a file whose tag is ttcf. + justification: | + Most callers loading a collection only want its first (typically Regular-weight) + face; defaulting to face 0 lets the existing Load overloads keep working unchanged + against a ttcf file without requiring every caller to learn the new face-index API. + tests: + - TrueTypeFont_Load_TtcContainer_DefaultsToFaceZero + - SfntContainer_TryReadTtcHeader_NonTtcFont_ReturnsFalse + + - id: CanvasNet-Fonts-TrueTypeFont-LoadTtcExplicitFaceIndex + title: >- + The TrueTypeFont class shall load a specific face from a ttcf container given its + zero-based face index via the new Load(Stream, int) and Load(string, int) + overloads. + justification: | + Callers that need a specific non-default face of a collection (for example a Bold + or Italic variant sharing the same container) must be able to select it explicitly + by index. + tests: + - TrueTypeFont_Load_TtcContainer_ExplicitFaceIndex_SelectsThatFace + - TrueTypeFont_Load_TtcContainer_Path_FaceIndex_ReadsFromFile + - TrueTypeFont_RealTtcContainer_FaceOne_RendersCffGlyphOutlineAsVisibleInk + - TrueTypeFont_RealTtcContainer_FaceZero_RendersGlyfGlyphOutlineAsVisibleInk + + - id: CanvasNet-Fonts-TrueTypeFont-RejectFaceIndexOutOfRange + title: >- + The TrueTypeFont class shall reject a faceIndex argument to Load(Stream, int) or + Load(string, int) that is negative, or is not less than the file's own face count + (1 for an ordinary SFNT font, or the ttcf header's declared face count for a + collection), with ArgumentOutOfRangeException. + justification: | + A face index outside the file's actual range does not identify any real face; + rejecting it immediately with a precise exception prevents a caller from silently + receiving the wrong face or an obscure downstream parsing failure. + tests: + - TrueTypeFont_Load_TtcContainer_FaceIndexOutOfRange_ThrowsArgumentOutOfRangeException + - TrueTypeFont_Load_NonTtcFont_FaceIndexOne_ThrowsArgumentOutOfRangeException + - TrueTypeFont_Load_NegativeFaceIndex_ThrowsArgumentOutOfRangeException + + - id: CanvasNet-Fonts-TrueTypeFont-FaceIndexZeroBehavesAsLoad + title: >- + The TrueTypeFont class shall make Load(Stream, 0) and Load(string, 0) against an + ordinary (non-ttcf) font behave identically to the existing Load(Stream) and + Load(string) overloads. + justification: | + Callers that always pass an explicit face index (for example because they do not + know in advance whether a file is a collection) must get exactly the same result + for an ordinary single-face font as they would from the original Load overloads. + tests: + - TrueTypeFont_Load_NonTtcFont_FaceIndexZero_BehavesIdenticallyToLoad + + - id: CanvasNet-Fonts-TrueTypeFont-RejectMalformedTtcHeader + title: >- + The TrueTypeFont class shall reject a file whose ttcf header is truncated, + declares zero fonts, or whose per-face offset table is truncated, with + InvalidDataException. + justification: | + A malformed ttcf header cannot reliably identify any real face's start offset; + failing clearly here prevents reading a table directory from an arbitrary, + attacker- or corruption-controlled byte offset. + tests: + - TrueTypeFont_Load_MalformedTtcHeader_ThrowsInvalidDataException + - SfntContainer_TryReadTtcHeader_TruncatedHeader_ThrowsInvalidDataException + - SfntContainer_TryReadTtcHeader_ZeroFonts_ThrowsInvalidDataException + - SfntContainer_TryReadTtcHeader_TruncatedFaceOffsetTable_ThrowsInvalidDataException + + - id: CanvasNet-Fonts-TrueTypeFont-GetNameInfoResolvesTypographicNames + title: >- + The TrueTypeFont class shall prefer the name table's typographic family + (nameID 16) and typographic subfamily (nameID 17) records over the standard + family (nameID 1) and subfamily (nameID 2) records when both are present, when + resolving GetNameInfo's FamilyName and SubfamilyName. + justification: | + The typographic name records exist specifically to carry the font's preferred + display family/subfamily (for example distinguishing a full weight axis from the + coarser legacy four-style model); callers asking for a font's name should get the + more accurate typographic value whenever the font author has supplied one. + tests: + - TrueTypeFont_GetNameInfo_TypographicNamesPresent_PrefersNameId16And17 + + - id: CanvasNet-Fonts-TrueTypeFont-GetNameInfoFallsBackToStandardNames + title: >- + The TrueTypeFont class shall fall back to the name table's standard family + (nameID 1) and subfamily (nameID 2) records when resolving GetNameInfo's + FamilyName and SubfamilyName if the typographic (nameID 16/17) records are + absent. + justification: | + Most real-world fonts (including every fixture bundled with this repository) only + supply the standard name records; GetNameInfo must still resolve a usable + family/subfamily name for them. + tests: + - TrueTypeFont_GetNameInfo_TypographicNamesAbsent_FallsBackToNameId1And2 + + - id: CanvasNet-Fonts-TrueTypeFont-GetNameInfoPlatformPreference + title: >- + The TrueTypeFont class shall prefer a Windows platform (platformID 3) name + record over a Macintosh platform (platformID 1) name record for the same nameID + when both are present, when resolving GetNameInfo. + justification: | + Windows platform records use Unicode (UTF-16BE) encoding, which unambiguously + round-trips any codepoint, whereas Macintosh platform records use a legacy 8-bit + encoding whose repertoire is far more limited; preferring the Windows record + yields a more faithful string whenever both are available. + tests: + - TrueTypeFont_GetNameInfo_WindowsAndMacintoshRecordsPresent_PrefersWindowsRecord + + - id: CanvasNet-Fonts-TrueTypeFont-GetNameInfoMacintoshFallback + title: >- + The TrueTypeFont class shall resolve GetNameInfo's name strings from a + Macintosh platform (platformID 1, encoding 0, Mac Roman) name record when no + Windows platform record exists for that nameID. + justification: | + Some fonts (particularly older ones) only supply Macintosh platform name records; + GetNameInfo must still decode and expose their strings correctly rather than + treating the font as nameless. + tests: + - TrueTypeFont_GetNameInfo_OnlyMacintoshRecordPresent_ResolvesFromMacintoshRecord + + - id: CanvasNet-Fonts-TrueTypeFont-GetNameInfoMissingRecordTolerant + title: >- + The TrueTypeFont class shall return null for any single FontNameInfo member + (for example PostScriptName) whose corresponding nameID has no resolvable + record in the name table, without affecting resolution of the other members. + justification: | + Not every font supplies every name string (a PostScript name record in + particular is optional); a missing individual record must not prevent the other, + present name strings from being resolved and returned. + tests: + - TrueTypeFont_GetNameInfo_PostScriptNameRecordMissing_ReturnsNullPostScriptName + + - id: CanvasNet-Fonts-TrueTypeFont-GetNameInfoNoNameTableTolerant + title: >- + The TrueTypeFont class shall return a FontNameInfo with every member null, + rather than throwing, when GetNameInfo is called on a font that has no name + table at all. + justification: | + The name table is optional per the OpenType specification; a font's absence of + this table must degrade gracefully to an all-null result rather than being + treated as a load-time or query-time error. + tests: + - TrueTypeFont_GetNameInfo_NoNameTable_ReturnsAllNullFontNameInfo + + - id: CanvasNet-Fonts-TrueTypeFont-GetNameInfoMalformedRecordTolerant + title: >- + The TrueTypeFont class shall ignore an individual name table record whose + declared string offset/length falls outside the table's own bounds, rather than + throwing or discarding the entire name table's other well-formed records. + justification: | + A single corrupted or truncated record must not prevent every other, independently + well-formed record in the same name table from being resolved and returned. + tests: + - TrueTypeFont_GetNameInfo_MalformedNameRecord_IgnoresRecordWithoutThrowing + + - id: CanvasNet-Fonts-TrueTypeFont-StyleFromOs2FsSelection + title: >- + The TrueTypeFont class shall derive IsBold true from the OS/2 table's + fsSelection bold bit (bit 5) and IsItalic true from its italic bit (bit 0) when + the OS/2 table is present and long enough to contain fsSelection. + justification: | + The OS/2 table's fsSelection field is the OpenType specification's primary, + purpose-built source for a font's bold/italic classification; callers need + IsBold/IsItalic to reflect it directly. + tests: + - TrueTypeFont_IsBold_Os2FsSelectionBoldBitSet_ReturnsTrue + - TrueTypeFont_IsItalic_Os2FsSelectionItalicBitSet_ReturnsTrue + + - id: CanvasNet-Fonts-TrueTypeFont-StyleFromOs2WeightClass + title: >- + The TrueTypeFont class shall derive IsBold true from the OS/2 table's + usWeightClass being at least 600 (Semibold or heavier) when fsSelection's own + bold bit is not set. + justification: | + Some fonts convey boldness only through usWeightClass rather than the + fsSelection bold bit; a heavier weight class is itself evidence of a visually + bold font and should also be reflected in IsBold. + tests: + - TrueTypeFont_IsBold_Os2WeightClassAtLeast600_ReturnsTrue + + - id: CanvasNet-Fonts-TrueTypeFont-StyleFromPostItalicAngle + title: >- + The TrueTypeFont class shall derive IsItalic true from the post table's + italicAngle being non-zero when the post table is present and long enough to + contain italicAngle. + justification: | + A non-zero slant angle recorded in the post table is itself direct, independent + evidence that a font is italic/oblique, and should be reflected in IsItalic even + when other sources are silent or absent. + tests: + - TrueTypeFont_IsItalic_PostItalicAngleNonZero_ReturnsTrue + + - id: CanvasNet-Fonts-TrueTypeFont-StyleFallsBackToMacStyleWhenOs2Absent + title: >- + The TrueTypeFont class shall derive IsBold and IsItalic from the head table's + macStyle bits 0 and 1 respectively when the OS/2 table is absent, and shall + always OR the macStyle-derived booleans into IsBold/IsItalic even when OS/2 is + present. + justification: | + The head table's macStyle field is mandatory (head is always present), making it + a reliable fallback when the optional OS/2 table is missing; because either + source can independently indicate a bold or italic font, combining both with OR + avoids a false negative when only one of the two sources reflects the truth. + tests: + - TrueTypeFont_Style_Os2TableAbsent_FallsBackToHeadMacStyle + + - id: CanvasNet-Fonts-TrueTypeFont-IsFixedPitchFromPostTable + title: >- + The TrueTypeFont class shall derive IsFixedPitch true from the post table's + isFixedPitch field being non-zero when the post table is present and long + enough to contain that field. + justification: | + The post table's isFixedPitch field is the OpenType specification's dedicated + source for a font's fixed-pitch (monospace) classification. + tests: + - TrueTypeFont_IsFixedPitch_PostIsFixedPitchNonZero_ReturnsTrue + + - id: CanvasNet-Fonts-TrueTypeFont-IsFixedPitchAbsentPostTableTolerant + title: >- + The TrueTypeFont class shall return IsFixedPitch false, rather than throwing, + when the post table is absent or too short to contain isFixedPitch. + justification: | + The post table is optional per the OpenType specification and has no reliable + alternate source for fixed-pitch classification; a font lacking it must default + to IsFixedPitch false rather than the query failing. + tests: + - TrueTypeFont_IsFixedPitch_PostTableAbsent_ReturnsFalseWithoutThrowing + + - id: CanvasNet-Fonts-TrueTypeFont-LoadType1FromSegments + title: >- + The TrueTypeFont class shall load a classic PostScript Type 1 font program from + its raw cleartext/eexec-encrypted byte segments (a caller-supplied Length1 and + Length2, as typically extracted from a PDF Type1 font's own FontFile stream) and + a caller-supplied codepoint-to-glyph-name encoding, exposing GlyphCount, + GetGlyphIndex, GetGlyphOutline, and GetAdvanceWidth behavior equivalent to a + glyf/CFF-flavored font, and shall reject malformed or truncated Type 1 font + program data, or a glyph charstring using an unsupported operator (including + seac), with InvalidDataException. + justification: | + A PDF Type1 font's embedded FontFile stream carries a Type 1 font program as raw + Length1/Length2-delimited byte segments with no SFNT/OpenType wrapper of its + own; a caller already holding those segments and the font's own text encoding + must be able to load and query it through the same TrueTypeFont API used for + every other supported outline flavor. + tests: + - TrueTypeFont_LoadType1_WellFormedProgram_ExposesGlyphsAndMetrics + - TrueTypeFont_LoadType1_NegativeLength1_ThrowsInvalidDataException + - TrueTypeFont_LoadType1_Length2ExceedsStreamBounds_ThrowsInvalidDataException + - TrueTypeFont_LoadType1_NullStream_ThrowsArgumentNullException + - TrueTypeFont_LoadType1_NullEncoding_ThrowsArgumentNullException + - Type1Table_Parse_WellFormedFont_ExposesGlyphCountAndOutlines + - Type1Table_Parse_NotdefNotFirst_ReindexesToGlyphZero + - Type1Table_GetAdvanceWidth_ReturnsHsbwWidth + - Type1Table_TryGetGlyphIndex_UnknownName_ReturnsFalse + - Type1Table_GetGlyphOutline_OutOfRangeIndex_ThrowsArgumentOutOfRangeException + - Type1Table_Parse_WithLocalSubrs_DecodesGlyphUsingCallSubr + - Type1Table_Parse_VariousProcNameTokens_ScannerIsProcedureNameAgnostic + - Type1Table_Parse_CustomLenIv_DecodesCorrectly + - Type1Table_Parse_SeacOperator_ThrowsInvalidDataException + - Type1Table_Parse_MissingCharStrings_ThrowsInvalidDataException + - Type1Table_Parse_NegativeLength_ThrowsInvalidDataException + - Type1Table_Parse_LengthsExceedFileBounds_ThrowsInvalidDataException + - Type1Table_Parse_EmptyCharStrings_ThrowsInvalidDataException + - Type1Table_Parse_CharStringsLengthNearIntMaxValue_ThrowsInvalidDataException + - Type1Table_Parse_TrailingBoilerplateAfterCharStringsEnd_DoesNotMisparse + - Type1CharstringInterpreter_EmptyCharstring_ProducesEmptyPathAndZeroWidth + - Type1CharstringInterpreter_Hsbw_CapturesWidthAndSideBearing + - Type1CharstringInterpreter_Sbw_CapturesWidthAndBothSideBearings + - Type1CharstringInterpreter_RLineTo_HLineTo_VLineTo_ProduceLines + - Type1CharstringInterpreter_RRCurveTo_ProducesCubicBezier + - Type1CharstringInterpreter_VhCurveTo_HvCurveTo_AlternateStartTangent + - Type1CharstringInterpreter_ClosePath_ClosesSubpath + - Type1CharstringInterpreter_HStem_VStem_Hstem3_Vstem3_Dotsection_ConsumedWithoutError + - Type1CharstringInterpreter_CallSubr_Return_NoBias_DirectIndex + - Type1CharstringInterpreter_CallSubr_OutOfRangeIndex_ThrowsInvalidDataException + - Type1CharstringInterpreter_CallSubr_ExceedsMaxDepth_ThrowsInvalidDataException + - Type1CharstringInterpreter_Div_ResultFeedsSubsequentOperator + - Type1CharstringInterpreter_Flex_EndToEnd_ProducesTwoRealCubicBeziers + - Type1CharstringInterpreter_HintReplacement_OtherSubr3_IsTransparentPassThrough + - Type1CharstringInterpreter_Seac_ThrowsInvalidDataException + - Type1CharstringInterpreter_UnsupportedOperator_ThrowsInvalidDataException + - Type1CharstringInterpreter_UnsupportedEscapeOperator_ThrowsInvalidDataException + - Type1CharstringInterpreter_CallOtherSubr_UnsupportedIndex_ThrowsInvalidDataException + - Type1CharstringInterpreter_RLineTo_WrongOperandCount_ThrowsInvalidDataException + - Type1CharstringInterpreter_FiveByteInteger_DecodesAsPlainInt32 + - Type1CharstringInterpreter_TruncatedCharstring_ThrowsInvalidDataException + + - id: CanvasNet-Fonts-TrueTypeFont-LoadType1Outlines + title: >- + The TrueTypeFont class shall auto-detect a standalone classic PostScript Type 1 + font program framed as either a PFB (binary segment-header) or PFA + (plain-ASCII, hex-encoded encrypted region) file passed to Load, parsing it using + a small, curated, built-in codepoint-to-glyph-name encoding covering common + Latin/ASCII characters as its default encoding, and shall report GetFaceCount as + 1 and reject a face index other than 0 for such a file, and shall continue to + reject any input recognized as neither a supported SFNT/OpenType font nor a + PFB/PFA-framed Type 1 font program with InvalidDataException. + justification: | + A standalone .pfb/.pfa font file (as opposed to a PDF-embedded FontFile stream) + has no caller-supplied Length1/Length2 segmentation or text encoding available; + Load must still be able to recognize and load it directly, exactly as it already + does for SFNT/OpenType/TrueType Collection files, without requiring the caller to + pre-parse the file's own framing or supply an encoding of their own. + tests: + - TrueTypeFont_Load_StandalonePfbFile_AutoDetectsAndRoundTrips + - TrueTypeFont_Load_StandalonePfaFile_AutoDetectsAndRoundTrips + - TrueTypeFont_Load_StandalonePfbFile_Path_ReadsFromFile + - TrueTypeFont_GetFaceCount_StandaloneType1File_ReturnsOne + - TrueTypeFont_Load_StandaloneType1File_FaceIndexOne_ThrowsArgumentOutOfRangeException + - TrueTypeFont_Load_NonType1NonSfntGarbage_ThrowsInvalidDataException + - Type1PfbReader_TrySniff_RecognizesPfbHeader + - Type1PfbReader_TrySniff_NonPfbData_ReturnsFalse + - Type1PfbReader_Read_TwoSegmentFile_ReassemblesLength1AndLength2 + - Type1PfbReader_Read_MultiSegmentFile_ConcatenatesLikeTypedSegments + - Type1PfbReader_Read_TrailingAsciiSegment_DiscardsItAsTrailer + - Type1PfbReader_Read_NoBinarySegment_ThrowsInvalidDataException + - Type1PfbReader_Read_MissingEndOfFileMarker_ThrowsInvalidDataException + - Type1PfbReader_Read_UnrecognizedSegmentType_ThrowsInvalidDataException + - Type1PfbReader_Read_TruncatedHeader_ThrowsInvalidDataException + - Type1PfbReader_Read_TruncatedPayload_ThrowsInvalidDataException + - Type1PfbReader_Read_MissingSegmentMarkerByte_ThrowsInvalidDataException + - Type1PfaReader_TrySniff_RecognizesPercentBangPrefix + - Type1PfaReader_TrySniff_NonPfaData_ReturnsFalse + - Type1PfaReader_Read_WellFormedFile_ReassemblesLength1AndLength2 + - Type1PfaReader_Read_MultiLineWhitespaceWrappedHex_ToleratesEmbeddedWhitespace + - Type1PfaReader_Read_TrailingZeroPadding_IsTrimmed + - Type1PfaReader_Read_MissingEexecKeyword_ThrowsInvalidDataException + - Type1PfaReader_Read_OddHexDigitCount_ThrowsInvalidDataException + - Type1PfaReader_Read_NonHexByteBeforeAnyHexDigit_ThrowsInvalidDataException + - Type1PfaReader_Read_ZeroBytesAfterTrim_ThrowsInvalidDataException + - Type1PfaReader_Read_LowercaseAndUppercaseHexDigits_AreBothAccepted + + - id: CanvasNet-Fonts-TrueTypeFont-CffCharsetResolution + title: >- + The CffTable class shall resolve a CFF Top DICT's charset operator (an absent + operator, or an explicit value of 0, both meaning the predefined ISOAdobe/ + Standard charset, assigning each glyph's SID equal to its own glyph index) to a + glyph-name-to-glyph-index map using the CFF Standard Strings (SID 0-390) and the + font's own String INDEX (SID 391 and above), and shall support a custom charset + table (charset operator value greater than 2, a byte offset into the CFF table) + in formats 0 (a flat array of 2-byte SIDs), 1 (ranges of a 2-byte first SID plus + a 1-byte count of following SIDs), and 2 (the same ranges with a 2-byte count), + rejecting any other format byte with InvalidDataException; the predefined Expert + (charset operator value 1) and ExpertSubset (value 2) charsets are recognized but + not resolved to glyph names (TryGetGlyphIndex returns false for every glyph name + rather than throwing), since no caller currently needs Expert-encoding glyph + names. + justification: | + A PDF simple font's /Encoding maps character codes to glyph names (for example + "A", "space"), not glyph indices directly; the embedded Type1C/CFF program's own + charset is the only way to resolve those names back to glyph indices, mirroring + Type1Table's own name-to-glyph-index resolution for classic Type 1 programs. + tests: + - CffTable_Parse_AbsentCharset_DefaultsToIsoAdobeAndResolvesByName + - CffTable_Parse_PredefinedIsoAdobeCharsetId_MatchesAbsentCharset + - CffTable_Parse_PredefinedExpertOrExpertSubsetCharset_NeverResolvesByNameButNeverThrows + - CffTable_Parse_CustomCharsetFormat0_ResolvesEachGlyphBySid + - CffTable_Parse_CustomCharsetFormat1_ResolvesRangesOfGlyphs + - CffTable_Parse_CustomCharsetFormat2_ResolvesRangesWithTwoByteNLeft + - CffTable_Parse_CustomCharsetUnsupportedFormat_ThrowsInvalidDataException + - CffTable_Parse_CharsetOffsetOutOfBounds_ThrowsInvalidDataException + - CffTable_Parse_CustomStringIndex_ResolvesCustomGlyphNameBySid + - CffTable_StandardStrings_HasExpectedLengthAndSpotCheckedEntries + + - id: CanvasNet-Fonts-TrueTypeFont-CffWidthResolution + title: >- + The CffTable class shall parse a CFF Private DICT's defaultWidthX (operator 20) + and nominalWidthX (operator 21) operators, and the CffCharstringInterpreter class + shall return each glyph's own advance width alongside its outline (the Type 2 + charstring's optional leading width operand, present on the first stack-clearing + operator of a charstring, resolving to nominalWidthX plus that operand's value + when present, else defaultWidthX), exposed as GetAdvanceWidth on CffTable. + justification: | + A glyph's own advance width - not merely its outline - is required metrics data; + the Type 2 charstring format encodes it as an optional leading operand rather + than a separate structure, so decoding it correctly requires surfacing it from + the same charstring interpretation pass that already decodes the outline. + tests: + - CffCharstringInterpreter_Decode_RMoveToWithLeadingWidth_ReturnsNominalWidthXPlusDelta + - CffCharstringInterpreter_Decode_NoLeadingWidth_ReturnsDefaultWidthX + - CffCharstringInterpreter_Decode_HStemWithLeadingWidth_ReturnsNominalWidthXPlusDelta + - CffCharstringInterpreter_Decode_EndCharWithLeadingWidth_ReturnsNominalWidthXPlusDelta + - CffCharstringInterpreter_Decode_DefaultWidthParameters_AreBothZero + - CffTable_GetAdvanceWidth_NoWidthOperand_UsesDefaultWidthX + - CffTable_GetAdvanceWidth_WithWidthOperand_UsesNominalWidthXPlusDelta + - CffTable_GetAdvanceWidth_OutOfRangeIndex_ThrowsArgumentOutOfRangeException + + - id: CanvasNet-Fonts-TrueTypeFont-LoadType1C + title: >- + The TrueTypeFont class shall load a bare Type1C/CFF font program (a raw CFF + table with no SFNT/OpenType wrapper, as embedded via a PDF Type1 font's + FontDescriptor/FontFile3 stream) from a stream and a caller-supplied + codepoint-to-glyph-name encoding, exposing GlyphCount, GetGlyphIndex, + GetGlyphOutline, and GetAdvanceWidth behavior equivalent to every other + supported outline flavor, using a fixed 1000-unit em square, and shall reject + malformed CFF table data, or a CID-keyed (ROS-declaring) CFF program, with + InvalidDataException. + justification: | + A PDF Type1 font's FontDescriptor may declare only a FontFile3 stream holding a + bare Type1C/CFF program with no SFNT/OpenType wrapper of its own; a caller + already holding that stream and the font's own text encoding must be able to + load and query it through the same TrueTypeFont API used for every other + supported outline flavor, mirroring LoadType1's own classic Type 1 program + support. + tests: + - TrueTypeFont_LoadType1C_WellFormedCff_ExposesGlyphsAndMetrics + - TrueTypeFont_LoadType1C_NullStream_ThrowsArgumentNullException + - TrueTypeFont_LoadType1C_NullEncoding_ThrowsArgumentNullException + - TrueTypeFont_LoadType1C_MalformedCff_ThrowsInvalidDataException + - TrueTypeFont_LoadType1C_CidKeyedCff_ThrowsInvalidDataException diff --git a/docs/sysml2/model/canvas-net-pdf.sysml b/docs/sysml2/model/canvas-net-pdf.sysml new file mode 100644 index 00000000..403157fc --- /dev/null +++ b/docs/sysml2/model/canvas-net-pdf.sysml @@ -0,0 +1,19 @@ +package CanvasNet { + part def CanvasNetPdfSystem { + doc /* CanvasNetPdf is a PDF page-rendering system, distributed as the separate DemaConsulting.CanvasNet.Pdf NuGet package, which depends on the core CanvasNet system's Canvas, Codecs, Geometry, Drawing, and (as of Phase 4) Fonts subsystems. Phase 1 parsed document structure (cross-references, trailer, page tree) and reported page count/size/rotation; Phase 2 added a content-stream interpreter so Render tokenizes and executes path-construction/painting and graphics-state operators (solid opaque black only); Phase 3 added real device color, a generalized stream-filter pipeline, and image XObjects; Phase 4 adds real text/font rendering, resolving a simple embedded TrueType font from a page's /Resources/Font dictionary and painting shown glyph outlines through the composed text-rendering matrix. */ + + comment testRef /* Test: test/DemaConsulting.CanvasNet.Pdf.Tests/PdfSystemIntegrationTests.cs */ + comment designRef /* Design: docs/design/canvas-net-pdf.md */ + comment verificationRef /* Verification: docs/verification/canvas-net-pdf.md */ + comment reqRef /* Requirements: docs/reqstream/canvas-net-pdf.yaml */ + comment /* CanvasNetPdf depends on the sibling CanvasNet system's Canvas subsystem (for the Surface pixel buffer Render returns and paints onto), Codecs subsystem (for the shared Codecs.UnsupportedImageFeatureException type used for /Encrypt detection and every other fail-closed case), Geometry subsystem (for PathBuilder/Path, accumulating each content stream's current path, including every painted glyph outline), Drawing subsystem (for PathFiller/PathStroker/StrokeStyle, rasterizing each finished path onto the destination Surface), and, as of Phase 4, Fonts subsystem (for TrueTypeFont, loading each embedded /FontFile2 byte stream and resolving each shown codepoint to a glyph index/outline/advance width) - modeled below as non-owning `dependency` relationships to those existing subsystem part defs (not ordinary `part` usages, which would incorrectly model them as owned children of CanvasNetPdfSystem), since both systems live in this same git repository (though each is distributed as its own separate NuGet package - CanvasNetPdf is not folded into the CanvasNet package) rather than being third-party (OTS) or Shared Package dependencies (both of which are reserved for a package produced by a different repository). The Geometry/Drawing dependencies were added as of Phase 2, at the point they were actually first used by the content-stream interpreter; the Fonts dependency is new as of Phase 4, added at the point it is actually first used by the text-rendering operators. Encrypted documents using a supported security handler/scheme - the standard security handler's RC4 (/V 1/2), AES-128 (/V 4/AESV2), or AES-256 with the simpler R5 key derivation (/V 5/R 5/AESV3) - are decrypted and rendered successfully once authenticated with the correct (or, where permitted, empty/missing) user or owner password; fail-closed Codecs.UnsupportedImageFeatureException behavior applies only to unsupported encryption shapes (a non-/Standard filter, a crypt filter other than /StdCF, /R 6 "hardened hash" key derivation, or any other unsupported /V/R combination) or to an incorrect or missing required password. */ + + part pdfDocument : PdfDocument; + + dependency usesCanvas from CanvasNetPdfSystem to Canvas; + dependency usesCodecs from CanvasNetPdfSystem to Codecs; + dependency usesGeometry from CanvasNetPdfSystem to Geometry; + dependency usesDrawing from CanvasNetPdfSystem to Drawing; + dependency usesFonts from CanvasNetPdfSystem to Fonts; + } +} diff --git a/docs/sysml2/model/canvas-net-pdf/pdf-document.sysml b/docs/sysml2/model/canvas-net-pdf/pdf-document.sysml new file mode 100644 index 00000000..407bebfd --- /dev/null +++ b/docs/sysml2/model/canvas-net-pdf/pdf-document.sysml @@ -0,0 +1,11 @@ +package CanvasNet { + part def PdfDocument { + doc /* PDF document parser and page-rendering codec: parses cross-references, trailer, and page tree, reports page count/size/rotation, and interprets each page's content stream (path construction/painting, graphics-state stack, device color operators, generalized stream filter pipeline, image XObjects, and (Phase 4) simple TrueType font resolution/encoding and text-object/positioning/showing operators, with (Phase 6) automatic system/bundled-Liberation font-fallback substitution for a non-embedded font) to render real path geometry, images, and glyph outlines onto the returned Surface in real device color. */ + + comment sourceRef /* Source: src/DemaConsulting.CanvasNet.Pdf/PdfDocument.cs, src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Tokenizer.cs, src/DemaConsulting.CanvasNet.Pdf/PdfDocument.ObjectModel.cs, src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Xref.cs, src/DemaConsulting.CanvasNet.Pdf/PdfDocument.PageTree.cs, src/DemaConsulting.CanvasNet.Pdf/PdfDocument.ContentStream.cs, src/DemaConsulting.CanvasNet.Pdf/PdfDocument.GraphicsState.cs, src/DemaConsulting.CanvasNet.Pdf/PdfDocument.PathOps.cs, src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Color.cs, src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Filters.cs, src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Filters.Lzw.cs, src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Filters.Ascii.cs, src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Filters.RunLength.cs, src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Images.cs, src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Fonts.cs, src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Fonts.ToUnicode.cs, src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Fonts.Type0.cs, src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Fonts.Type1.cs, src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Fonts.Type3.cs, src/DemaConsulting.CanvasNet.Pdf/PdfDocument.FontFallback.cs, src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Text.cs, src/DemaConsulting.CanvasNet.Pdf/PdfDocument.CcittFax.cs, src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Encryption.cs, src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Functions.cs, src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Patterns.cs, src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Patterns.Shading.cs, src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Patterns.Tiling.cs, src/DemaConsulting.CanvasNet.Pdf/PdfPageInfo.cs, src/DemaConsulting.CanvasNet.Pdf/NamespaceDoc.cs */ + comment testRef /* Test: test/DemaConsulting.CanvasNet.Pdf.Tests/PdfDocumentTests.cs, test/DemaConsulting.CanvasNet.Pdf.Tests/PdfSystemIntegrationTests.cs, test/DemaConsulting.CanvasNet.Pdf.Tests/PdfFixtureTests.cs, test/DemaConsulting.CanvasNet.Pdf.Tests/PdfDocumentEncryptionTests.cs, test/DemaConsulting.CanvasNet.Pdf.Tests/PdfDocumentSymbolicEncodingTests.cs */ + comment designRef /* Design: docs/design/canvas-net-pdf/pdf-document.md */ + comment verificationRef /* Verification: docs/verification/canvas-net-pdf/pdf-document.md */ + comment reqRef /* Requirements: docs/reqstream/canvas-net-pdf/pdf-document.yaml */ + } +} diff --git a/docs/sysml2/model/canvas-net/drawing.sysml b/docs/sysml2/model/canvas-net/drawing.sysml index 315b9ec7..68d424ca 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, although callers may combine Drawing with Fonts-produced glyph paths. */ + doc /* Antialiased vector-rendering subsystem for Geometry.Path geometry, supporting solid-color, gradient, and tiled-pattern paint. Provides PathFiller (direct path filling, solid color, GradientPaint, or TilePaint), PathStroker (stroke-to-fill conversion with standard cap/join/dash styling), GradientPaint (linear/radial gradient definition and evaluation), and TilePaint (pre-rendered tiled-pattern 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 */ @@ -9,5 +9,6 @@ package CanvasNet { part pathFiller : PathFiller; part pathStroker : PathStroker; part gradientPaint : GradientPaint; + part tilePaint : TilePaint; } } diff --git a/docs/sysml2/model/canvas-net/drawing/tile-paint.sysml b/docs/sysml2/model/canvas-net/drawing/tile-paint.sysml new file mode 100644 index 00000000..94f13f48 --- /dev/null +++ b/docs/sysml2/model/canvas-net/drawing/tile-paint.sysml @@ -0,0 +1,11 @@ +package CanvasNet { + part def TilePaint { + doc /* Public TilePaint type (a pre-rendered one-cell tile Surface plus a device-space Transform and pattern-space XStep/YStep pitch) and the internal TilePaintEvaluator helper that resolves a tiled pattern's color per pixel by nearest-neighbor sampling the wrapped-around tile; consumed by PathFiller's tile-paint Fill overload. Mirrors GradientPaint's own public-type-plus-internal-evaluator shape; added alongside CanvasNetPdf's /Pattern color-space tiling-pattern support. */ + + comment sourceRef /* Source: src/DemaConsulting.CanvasNet/Drawing/TilePaint.cs, src/DemaConsulting.CanvasNet/Drawing/TilePaintEvaluator.cs */ + comment testRef /* Test: test/DemaConsulting.CanvasNet.Tests/Drawing/TilePaintTests.cs, test/DemaConsulting.CanvasNet.Tests/Drawing/TilePaintEvaluatorTests.cs */ + comment designRef /* Design: docs/design/canvas-net/drawing/tile-paint.md */ + comment verificationRef /* Verification: docs/verification/canvas-net/drawing/tile-paint.md */ + comment reqRef /* Requirements: docs/reqstream/canvas-net/drawing/tile-paint.yaml */ + } +} diff --git a/docs/sysml2/model/canvas-net/fonts.sysml b/docs/sysml2/model/canvas-net/fonts.sysml index d5c09664..d875fee3 100644 --- a/docs/sysml2/model/canvas-net/fonts.sysml +++ b/docs/sysml2/model/canvas-net/fonts.sysml @@ -1,12 +1,13 @@ 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. */ + doc /* TrueType and CFF/OpenType font-loading subsystem exposing the TrueTypeFont unit (loads glyph-based (glyf) SFNT fonts and CFF/OpenType (OTTO-flavored) fonts, including standalone classic PostScript Type 1/Type 1C font-program loading, selecting an individual face out of a TrueType Collection (ttcf) container, maps Unicode codepoints to glyph indices, extracts glyph outlines as Geometry.Path geometry, and reports horizontal metrics and basic format-0 kerning) and the SystemFontCatalog unit (directory-scan-only discovery of fonts installed on the host operating system, best-effort family-name/style matching, and a bundled Liberation Sans/Serif/Mono last-resort fallback font). 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 testRef /* Test: test/DemaConsulting.CanvasNet.Tests/CanvasNetTests.cs, test/DemaConsulting.CanvasNet.Tests/TestSupport/SyntheticFontBuilder.cs, test/DemaConsulting.CanvasNet.Tests/Fonts/TrueTypeFontRealFontIntegrationTests.cs, test/DemaConsulting.CanvasNet.Tests/Fonts/SystemFontCatalogRealDiscoveryIntegrationTests.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; + part systemFontCatalog : SystemFontCatalog; } } diff --git a/docs/sysml2/model/canvas-net/fonts/system-font-catalog.sysml b/docs/sysml2/model/canvas-net/fonts/system-font-catalog.sysml new file mode 100644 index 00000000..d33c90a9 --- /dev/null +++ b/docs/sysml2/model/canvas-net/fonts/system-font-catalog.sysml @@ -0,0 +1,11 @@ +package CanvasNet { + part def SystemFontCatalog { + doc /* Directory-scan-only discovery of fonts installed on the host operating system (Fonts), best-effort family-name/style matching against that catalog (FindBestMatch), and a bundled, always-available Liberation Sans/Serif/Mono last-resort fallback font shipped as an embedded resource of this assembly (LoadBundledFallback). Format-agnostic: no knowledge of any particular document format's font-naming conventions. Depends on TrueTypeFont within the same Fonts subsystem. */ + + comment sourceRef /* Source: src/DemaConsulting.CanvasNet/Fonts/SystemFontCatalog.cs, src/DemaConsulting.CanvasNet/Fonts/BundledFonts/README.md */ + comment testRef /* Test: test/DemaConsulting.CanvasNet.Tests/Fonts/SystemFontCatalogTests.cs, test/DemaConsulting.CanvasNet.Tests/Fonts/SystemFontCatalogRealDiscoveryIntegrationTests.cs, test/DemaConsulting.CanvasNet.Tests/Fonts/BundledLiberationFontsTests.cs */ + comment designRef /* Design: docs/design/canvas-net/fonts/system-font-catalog.md */ + comment verificationRef /* Verification: docs/verification/canvas-net/fonts/system-font-catalog.md */ + comment reqRef /* Requirements: docs/reqstream/canvas-net/fonts/system-font-catalog.yaml */ + } +} diff --git a/docs/sysml2/model/canvas-net/fonts/true-type-font.sysml b/docs/sysml2/model/canvas-net/fonts/true-type-font.sysml index 8282b0f5..bcc32dee 100644 --- a/docs/sysml2/model/canvas-net/fonts/true-type-font.sysml +++ b/docs/sysml2/model/canvas-net/fonts/true-type-font.sysml @@ -1,9 +1,9 @@ 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. */ + doc /* Public TrueType/CFF/Type 1 font loader/query unit fronting SFNT container parsing (including ttcf collection face selection), cmap lookup, glyf/loca outline decoding, CFF/Type 2 charstring outline decoding, standalone Type 1/PostScript font parsing (PFA/PFB container reading, eexec/charstring decryption, and Type 1 charstring outline decoding with standard glyph name resolution), hhea/hmtx metrics, tolerant format-0 kern lookup, and name/style metadata (family/subfamily/full/PostScript name, bold/italic/fixed-pitch). */ - 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 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/IGlyphOutlineSource.cs, src/DemaConsulting.CanvasNet/Fonts/CffTable.cs, src/DemaConsulting.CanvasNet/Fonts/CffCharstringInterpreter.cs, src/DemaConsulting.CanvasNet/Fonts/CffStandardEncoding.cs, src/DemaConsulting.CanvasNet/Fonts/Type1Table.cs, src/DemaConsulting.CanvasNet/Fonts/Type1CharstringInterpreter.cs, src/DemaConsulting.CanvasNet/Fonts/Type1CharstringDecryption.cs, src/DemaConsulting.CanvasNet/Fonts/Type1PfbReader.cs, src/DemaConsulting.CanvasNet/Fonts/Type1PfaReader.cs, src/DemaConsulting.CanvasNet/Fonts/Type1StandardGlyphNames.cs, src/DemaConsulting.CanvasNet/Fonts/HmtxHheaReader.cs, src/DemaConsulting.CanvasNet/Fonts/KernTable.cs, src/DemaConsulting.CanvasNet/Fonts/NameTable.cs, src/DemaConsulting.CanvasNet/Fonts/StyleTable.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/CffTableTests.cs, test/DemaConsulting.CanvasNet.Tests/Fonts/CffCharstringInterpreterTests.cs, test/DemaConsulting.CanvasNet.Tests/Fonts/CffStandardEncodingTests.cs, test/DemaConsulting.CanvasNet.Tests/Fonts/Type1TableTests.cs, test/DemaConsulting.CanvasNet.Tests/Fonts/Type1CharstringInterpreterTests.cs, test/DemaConsulting.CanvasNet.Tests/Fonts/Type1PfbReaderTests.cs, test/DemaConsulting.CanvasNet.Tests/Fonts/Type1PfaReaderTests.cs, test/DemaConsulting.CanvasNet.Tests/Fonts/HmtxHheaReaderTests.cs, test/DemaConsulting.CanvasNet.Tests/Fonts/KernTableTests.cs, test/DemaConsulting.CanvasNet.Tests/Fonts/NameTableTests.cs, test/DemaConsulting.CanvasNet.Tests/Fonts/StyleTableTests.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/model/canvas-net/geometry.sysml b/docs/sysml2/model/canvas-net/geometry.sysml index db6f2c1d..d49495ca 100644 --- a/docs/sysml2/model/canvas-net/geometry.sysml +++ b/docs/sysml2/model/canvas-net/geometry.sysml @@ -1,6 +1,6 @@ package CanvasNet { part def Geometry { - doc /* Vector-geometry primitives subsystem: axis-aligned bounds (Rect), an immutable vector path and its builder (Path/PathBuilder, covering Subpath/PathCommand/PathCommandType inline), adaptive Bezier flattening (BezierFlattening), and SVG-style elliptical arc conversion (SvgArcConverter). Distinct from the Drawing subsystem, which rasterizes this geometry onto a Canvas.Surface. */ + doc /* Vector-geometry primitives subsystem: axis-aligned bounds (Rect), an immutable vector path and its builder (Path/PathBuilder, covering Subpath/PathCommand/PathCommandType inline), adaptive Bezier flattening (BezierFlattening), SVG-style elliptical arc conversion (SvgArcConverter), and path-level corner rounding (CornerRoundEffect, replacing polyline corners with tangent-radius circular arcs). Distinct from the Drawing subsystem, which rasterizes this geometry onto a Canvas.Surface. */ comment designRef /* Design: docs/design/canvas-net/geometry.md */ comment verificationRef /* Verification: docs/verification/canvas-net/geometry.md */ diff --git a/docs/sysml2/views/design-views.sysml b/docs/sysml2/views/design-views.sysml index 3e457ee3..e0585460 100644 --- a/docs/sysml2/views/design-views.sysml +++ b/docs/sysml2/views/design-views.sysml @@ -16,6 +16,10 @@ package CanvasNet { expose CanvasNetSvgSystem; } + view CanvasNetPdfView { + expose CanvasNetPdfSystem; + } + view CanvasView { expose Canvas; } diff --git a/docs/user_guide/introduction.md b/docs/user_guide/introduction.md index 89a8dd26..838f0300 100644 --- a/docs/user_guide/introduction.md +++ b/docs/user_guide/introduction.md @@ -3,6 +3,9 @@ + + + ## Purpose @@ -1079,48 +1082,344 @@ Parses an SVG file at the specified path and returns an `ImageInfo`. - `ArgumentException`: Thrown when `path` is an empty string. - `InvalidDataException`: Thrown for the same conditions as `GetInfo(Stream)`. +### PdfDocument + +`PdfDocument` is distributed via the separate `DemaConsulting.CanvasNet.Pdf` NuGet package +(namespace `DemaConsulting.CanvasNet.Pdf`), which references the core `DemaConsulting.CanvasNet` +package - see the Installation section of the project README. + +The `PdfDocument` sealed class opens a PDF document, parses its cross-reference table/stream and +page tree, and reports each page's displayed (rotation-adjusted) size and its page count. +`Render` interprets a page's content stream, painting real path geometry, device color (`rg`/ +`g`/`k`/`cs`/`sc` and related operators, including `/Pattern`-color-space shading and tiling +pattern fills/strokes - axial/radial (`/ShadingType 2`/`3`) shading patterns driven by +`/FunctionType 0`/`2`/`3` functions, and colored/uncolored (`/PaintType 1`/`2`) tiling patterns +rendering a repeating tile), placed image XObjects (`Do`), and text shown with a simple TrueType +font (`Tf`/`Td`/`Tj` and the other `BT`/`ET` text operators) onto the returned `Surface`. A font +with an embedded `/FontDescriptor/FontFile2` stream is always used directly; +a font with no embedded font data is instead automatically substituted, fully silently (no new +API, no "fallback occurred" indicator): first with the closest-matching font actually installed +on the host operating system (matched by family name and bold/italic/serif/fixed-pitch style), +and, when nothing on the host machine matches, with a bundled Liberation Sans/Serif/Mono font +that ships with the `DemaConsulting.CanvasNet` package and is therefore always available. A +recognized Standard-14 name (`Helvetica`, `Times-Roman`, `Courier`, and their bold/italic +variants, and so on) is classified by a fixed, built-in table; any other non-embedded font's +style is derived from its `/FontDescriptor` flags/weight/angle. `Symbol` and `ZapfDingbats` +(matched by exact `/BaseFont` name) instead resolve via a dedicated, bundled Noto substitute font: +`Symbol` tries `NotoSans-Regular.ttf` (Greek letters and general symbols), then +`NotoSansMath-Regular.ttf` (mathematical operators), then `NotoSansSymbols2-Regular.ttf` +(Private-Use-Area-adjacent/rare symbols), in that priority order; `ZapfDingbats` uses +`NotoSansSymbols2-Regular.ttf` alone - using the Symbol/ZapfDingbats built-in encoding (PDF +32000-1 Appendix D) rather than treating them like any other Latin-text font. This is a +documented, accepted fidelity limitation, not full glyph coverage: of Symbol's 163 distinct +mapped codepoints, 161 are covered (only U+2329/U+232A are not); of ZapfDingbats' 202 distinct +mapped codepoints, 158 are covered (the circled-digit Dingbats and a handful of others are not). +Any other font whose `/FontDescriptor` marks it as a symbolic, non-Latin-text glyph set is +never substituted this way, since a symbol/dingbat glyph set with no bundled substitute has no +meaningful generic-family +equivalent - it still fails closed with `UnsupportedImageFeatureException`, exactly as a +non-embedded font of any kind did before this fallback behavior existed. A shading pattern's +`/Extend` is approximated as always-padded (never fully transparent outside the defining +geometry), a documented, narrower-than-spec simplification; the `sh` operator and generic path +clipping (`W`/`W*`) remain unsupported and are silently skipped. **Documented scope +boundaries**: `/MMType1` fonts, mesh shadings (`/ShadingType 1`/`4`-`7`), `/FunctionType 4` +(PostScript calculator) functions, the `sh` operator, generic path clipping, transparency groups, +and clip text-rendering modes all fail closed with `UnsupportedImageFeatureException` +rather than being silently skipped or mis-rendered. + +```csharp +using var doc = PdfDocument.Open("file.pdf"); +var info = doc.GetPageInfo(0); +using var surface = doc.Render(0, info.Width, info.Height); +``` + +#### PdfDocument Methods + +##### PdfDocument.Open(Stream stream, string? password = null) + +```csharp +public static PdfDocument Open(Stream stream, string? password = null) +``` + +Reads the entirety of an open, readable stream into an in-memory buffer and parses it into a new +`PdfDocument`. Does not take ownership of, and does not dispose, the caller's `stream`. + +The optional `password` parameter defaults to `null`, which preserves the library's original +empty-user-password-only behavior byte-for-byte. When a non-`null` password is supplied, it is +tried first as the **user password**, then - if that does not authenticate - as the **owner +password** (ISO 32000-1 Algorithm 3 for RC4/AES-128 documents; the owner-password variant of +ISO 32000-2 Algorithm 2.A for AES-256/R5 documents). R2-R4 (RC4/AES-128) passwords are encoded as +Latin-1 (the ASCII range of PDFDocEncoding); R5 (AES-256) passwords are encoded as UTF-8 with no +SASLprep/Unicode normalization applied - both are intentional scope boundaries. Both encodings +truncate the password's encoded bytes to a maximum of 127 bytes before any hashing. + +**Exceptions:** + +- `ArgumentNullException`: Thrown when `stream` is null. +- `InvalidDataException`: Thrown when the stream does not contain valid, supported PDF content + (including when normal cross-reference parsing fails and the linear-scan fallback also cannot + resolve the document catalog). +- `UnsupportedImageFeatureException`: Thrown when the document's trailer declares an `/Encrypt` + entry that this library cannot open: a security handler other than the PDF "Standard" handler, + an AES-256 document using the `/R 6` "hardened hash" key derivation, a crypt filter other than + the standard `/StdCF` filter (RC4, AES-128/`AESV2`, or AES-256-R5/`AESV3`), a document that + requires a password but `password` was not supplied (`null`), or a supplied `password` that + does not authenticate as either the user or the owner password (feature + `pdf-encrypted-incorrect-password`), or - for an R2-R4 document - contains a character outside + ASCII 0-127 (feature `pdf-encrypted-password-non-ascii`). A document encrypted with the Standard + security handler using RC4 (40-128 bit), AES-128, or AES-256 (R5) and an empty user password - + the vast majority of "owner password"/permission-restricted real-world PDFs - opens and renders + normally with `password` left at its default `null`; its permission flags are not enforced (this + library only ever reads for rendering, so copy/print restrictions do not apply). + +##### PdfDocument.Open(string path, string? password = null) + +```csharp +public static PdfDocument Open(string path, string? password = null) +``` + +Opens its own internal `FileStream` for the file at `path`, reads it fully, closes that stream +synchronously within `Open` (mirroring `PngCodec.Load(string)`'s open/consume/close pattern), then +parses the buffered content identically to the `Stream` overload above, with the same `password` +parameter and semantics. + +**Exceptions:** + +- `ArgumentNullException`: Thrown when `path` is null. +- `ArgumentException`: Thrown when `path` is an empty or whitespace-only string. +- `InvalidDataException`: Thrown for the same conditions as `Open(Stream, string?)`. +- `UnsupportedImageFeatureException`: Thrown for the same reasons as `Open(Stream, string?)`. + +##### PdfDocument.PageCount + +```csharp +public int PageCount { get; } +``` + +The total number of pages in the document's page tree. + +**Exceptions:** + +- `ObjectDisposedException`: Thrown when accessed after `Dispose()` has been called. + +##### PdfDocument.GetPageInfo(int pageIndex) + +```csharp +public PdfPageInfo GetPageInfo(int pageIndex) +``` + +Returns the specified page's displayed (rotation-adjusted) width/height, plus its normalized +clockwise rotation (0/90/180/270). Width and height are swapped relative to the page's raw +`/MediaBox` when rotation is 90 or 270. A leaf page inherits `/MediaBox`/`/Rotate` from its +page-tree ancestors when it does not declare its own. + +**Exceptions:** + +- `ArgumentOutOfRangeException`: Thrown when `pageIndex` is negative or `>= PageCount`. +- `ObjectDisposedException`: Thrown when called after `Dispose()` has been called. + +##### PdfDocument.Render(int pageIndex, int width, int height) + +```csharp +public Surface Render(int pageIndex, int width, int height) +``` + +Returns a new `Surface` of the caller-specified `width` x `height` for the given page, painted +with the page's interpreted content-stream geometry (path construction/painting with real device +color, placed image XObjects, and text shown with a resolved TrueType font - embedded when +present, otherwise automatically substituted, see *PdfDocument* above), or a fully transparent +surface when the page declares no `/Contents`. The `width`/`height` used is exactly as given - it +is not clamped or derived from the page's own `/MediaBox` size. + +**Exceptions:** + +- `ArgumentOutOfRangeException`: Thrown when `pageIndex` is negative or `>= PageCount`, or when + `width`/`height` is not a valid `Surface` size (propagates from `new Surface(width, height)`). +- `InvalidDataException`: Thrown for malformed `/Contents`, a malformed recognized operator's + operand count/type, an unresolvable font resource name, or a text-showing operator invoked with + no font selected. +- `UnsupportedImageFeatureException`: Thrown for a well-formed but unsupported color space, + stream filter, font subtype (`/MMType1`; `/TrueType`, `/Type0`, `/Type1`, and `/Type3` are all + supported), an otherwise-symbolic font (other than `Symbol`/`ZapfDingbats`, which resolve via a + bundled Noto substitute instead) with no embedded font data, font encoding, or + text-rendering mode. +- `ObjectDisposedException`: Thrown when called after `Dispose()` has been called. + +##### PdfDocument.Render(int pageIndex, float dpi) + +```csharp +public Surface Render(int pageIndex, float dpi) +``` + +Convenience overload of `Render(int, int, int)` for the common "render at a given resolution" +case: reads `GetPageInfo(pageIndex)`'s rotation-adjusted width/height (in points, 1/72 inch), +scales both by `dpi / 72`, rounds to the nearest pixel, and renders at that size - preserving the +page's own aspect ratio, unlike the three-argument overload. Use `Render(int, int, int)` directly +instead when independent X/Y scaling (non-square pixels, or an exact pixel size regardless of +aspect ratio) is needed. + +**Exceptions:** + +- `ArgumentOutOfRangeException`: Thrown when `pageIndex` is negative or `>= PageCount`, when + `dpi` is not a positive, finite number, or when the computed pixel width/height is not a valid + `Surface` size. +- Also throws every exception `Render(int, int, int)` itself can throw. + +##### PdfDocument.Dispose() + +```csharp +public void Dispose() +``` + +Releases the document's in-memory buffer and parsed state. Idempotent - safe to call more than +once. Every other public member throws `ObjectDisposedException` once called. + ### 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. +The `TrueTypeFont` class loads glyph-based TrueType (`glyf`-based) SFNT fonts and CFF/OpenType +(`OTTO`-flavored, Type 2 charstring-based) fonts - including selecting an individual face out of +a TrueType Collection (`.ttc`) container - and exposes raw font-design-unit outlines, metrics, +advance widths, and basic pairwise kerning through one uniform API regardless of outline flavor. ```csharp public sealed class TrueTypeFont { public static TrueTypeFont Load(Stream stream); public static TrueTypeFont Load(string path); + public static TrueTypeFont Load(Stream stream, int faceIndex); + public static TrueTypeFont Load(string path, int faceIndex); + public static int GetFaceCount(Stream stream); + public static int GetFaceCount(string path); public int UnitsPerEm { get; } public int Ascender { get; } public int Descender { get; } public int LineGap { get; } public int GlyphCount { get; } + public bool IsBold { get; } + public bool IsItalic { get; } + public bool IsFixedPitch { get; } public int GetGlyphIndex(int codepoint); public Path GetGlyphOutline(int glyphIndex); public int GetAdvanceWidth(int glyphIndex); public int GetKerning(int leftGlyphIndex, int rightGlyphIndex); + public FontNameInfo GetNameInfo(); } ``` +`IsBold`/`IsItalic`/`IsFixedPitch` report the font's derived bold/italic/fixed-pitch +classification, resolved from the `OS/2` table's `fsSelection`/`usWeightClass`, the `head` table's +`macStyle`, and the `post` table's `isFixedPitch` flag (whichever of these tables the font +provides). `GetNameInfo` resolves the font's `name`-table strings into a `FontNameInfo` - the +typographic (or, failing that, standard) family and subfamily names, the full name, and the +PostScript name - returning `null` for any field the font's `name` table does not provide, rather +than throwing: + +```csharp +public readonly record struct FontNameInfo( + string? FamilyName, + string? SubfamilyName, + string? FullName, + string? PostScriptName); +``` + `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`. +and flip Y before rendering it through `PathFiller` or `PathStroker`. A glyf-flavored font's +outline uses `LineTo`/`QuadraticBezierTo` segments; a CFF/OpenType font's outline uses +`LineTo`/`CubicBezierTo` segments instead - callers that support both must handle both command +kinds (see Example 12 below). ```csharp -var font = TrueTypeFont.Load("font.ttf"); +var font = TrueTypeFont.Load("font.ttf"); // also accepts a CFF/OpenType .otf file var glyphIndex = font.GetGlyphIndex('A'); var outline = font.GetGlyphOutline(glyphIndex); var advanceWidth = font.GetAdvanceWidth(glyphIndex); ``` +`Load(Stream, int)` / `Load(string, int)` select a specific zero-based face out of a `.ttc` +(TrueType Collection) container; `GetFaceCount` reports how many faces a file contains (`1` for +an ordinary single-face font) without parsing any face's own table directory: + +```csharp +var faceCount = TrueTypeFont.GetFaceCount("collection.ttc"); // e.g. 2 +var secondFace = TrueTypeFont.Load("collection.ttc", faceIndex: 1); +``` + +`Load(Stream)`/`Load(string)` (without an explicit `faceIndex`) transparently default to face 0 +when given a `.ttc` file, so existing callers that only ever loaded single-face fonts continue to +work unchanged against the first face of a collection. + **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. +- `ArgumentNullException`: Thrown when `Load`/`GetFaceCount` receives a null stream or path. +- `ArgumentException`: Thrown when the `string`-path overload of `Load`/`GetFaceCount` receives an + empty path. +- `InvalidDataException`: Thrown when the font data is malformed, truncated, or unsupported + (including CFF-specific failures such as CID-keyed CFF data, and `.ttc`-specific failures such + as a malformed collection header). - `ArgumentOutOfRangeException`: Thrown when `GetGlyphOutline` or `GetAdvanceWidth` receives an - out-of-range glyph index. + out-of-range glyph index, or when `Load(Stream, int)`/`Load(string, int)` receives a `faceIndex` + that is negative or not less than the file's own face count. + +### SystemFontCatalog + +The `SystemFontCatalog` static class discovers fonts installed on the host operating system, +best-effort matches a requested family-name hint and style (bold/italic/serif/fixed-pitch) against +that discovered catalog, and - when no suitable system font can be found - provides a bundled +Liberation Sans/Serif/Mono `TrueTypeFont` as a last-resort fallback. + +```csharp +public static class SystemFontCatalog +{ + public static IReadOnlyList Fonts { get; } + + public static SystemFontInfo? FindBestMatch( + string familyNameHint, + bool bold, + bool italic, + bool serif, + bool fixedPitch); + + public static TrueTypeFont LoadBundledFallback(bool serif, bool fixedPitch, bool bold, bool italic); +} +``` + +`Fonts` lazily enumerates the host operating system's installed fonts (platform-specific font +directories/registries) on first access, caching the result for the process lifetime; each entry +is a `SystemFontInfo` describing the font's family/subfamily name, derived bold/italic/fixed-pitch +style, and the file path/face index needed to load it via `TrueTypeFont.Load`: + +```csharp +public readonly record struct SystemFontInfo( + string FamilyName, + string SubfamilyName, + bool Bold, + bool Italic, + bool FixedPitch, + string FilePath, + int FaceIndex); +``` + +`FindBestMatch` follows a two-tier strategy: it first looks for an exact (case-insensitive) +family-name match against `Fonts`; if none exists, it falls back to searching a small, +hand-maintained list of well-known generic family names (sans-serif, serif, or monospace, +selected by the `serif`/`fixedPitch` hints) for the first one actually present on the host system. +It returns `null` when neither tier finds a match. + +`LoadBundledFallback` selects one of the library's twelve embedded Liberation Sans/Serif/Mono +TrueType fonts (by the requested `serif`/`fixedPitch`/`bold`/`italic` combination) and loads it via +`TrueTypeFont.Load`, guaranteeing a usable font is always available even when the host system has +no suitable installed font (or no accessible font directory at all): + +```csharp +// Best-effort match against installed system fonts, falling back to a bundled font +var match = SystemFontCatalog.FindBestMatch("Helvetica", bold: false, italic: false, serif: false, fixedPitch: false); +var font = match is { } info + ? TrueTypeFont.Load(info.FilePath, info.FaceIndex) + : SystemFontCatalog.LoadBundledFallback(serif: false, fixedPitch: false, bold: false, italic: false); +``` ### PathFiller @@ -1172,6 +1471,29 @@ behavior. **Exceptions:** +- `ArgumentNullException`: Thrown when `surface`, `path`, or `paint` is null. +- `ArgumentOutOfRangeException`: Thrown when `fillRule` is not a defined `FillRule` value, when + `flattenTolerance` is less than or equal to zero, or is not a finite value (NaN or infinity). + +##### PathFiller.Fill(Surface surface, Path path, TilePaint paint, FillRule fillRule, float flattenTolerance) + +```csharp +public static void Fill( + Surface surface, + Path path, + TilePaint paint, + FillRule fillRule = FillRule.NonZero, + float flattenTolerance = 0.25f) +``` + +Fills `path` with `paint` (a repeating tile bitmap) onto `surface`, sampling the tile once per +pixel at its wrapped-around pattern-space offset and scaling the result by that pixel's +antialiased coverage. Shares curve-flattening, clip-bounds, argument validation, `FillRule`, and +empty/out-of-bounds no-op behavior with the solid-color overload above - see `TilePaint` below for +tile-specific behavior. + +**Exceptions:** + - `ArgumentNullException`: Thrown when `surface`, `path`, or `paint` is null. - `ArgumentOutOfRangeException`: Thrown when `fillRule` is not a defined `FillRule` value, when `flattenTolerance` is less than or equal to zero, or is not a finite value (NaN or infinity). @@ -1418,6 +1740,46 @@ explicitly-supplied value - including the all-zero matrix - is preserved exactly when `startRadius`/`endRadius` is not finite or is negative; when `spread` is not a defined `GradientSpread` value; or when any component of `transform` is not finite. +### TilePaint + +`TilePaint` is the tiling-pattern analogue of `GradientPaint`: a resolved, renderable tile paint +pairing a pre-rendered one-cell tile bitmap (a `Surface`) with a `Transform` mapping the tile's +own pattern-space coordinates into the same coordinate space a filled `Path` already uses, and an +`XStep`/`YStep` pitch describing how far apart successive tile repetitions are spaced in +pattern-space units. A caller paints a filled path with a repeating tile via +`PathFiller.Fill(Surface, Path, TilePaint, FillRule, float)` above. + +```csharp +public sealed class TilePaint +{ + public TilePaint(Surface surface, Matrix3x2 transform, float xStep, float yStep); + + public Surface Surface { get; } + public Matrix3x2 Transform { get; } + public float XStep { get; } + public float YStep { get; } + + public TilePaint WithTransform(Matrix3x2 transform); +} +``` + +`TilePaint` does not own `Surface`'s lifetime: it does not dispose it, and the caller that +constructed the `TilePaint` remains responsible for disposing the underlying `Surface` once it is +no longer needed - mirroring how `Gradient` similarly owns no disposable resource of its own. +`XStep`/`YStep` may be negative (a legitimate PDF tiling-pattern value meaning the tile repeats in +the negative pattern-space axis direction) - only zero or a non-finite value is rejected. +`WithTransform` returns a new `TilePaint` with the same `Surface`/`XStep`/`YStep` whose `Transform` +is this tile paint's own `Transform` composed with the supplied transform (this tile paint's +existing `Transform` is applied first, then the supplied transform is applied on top of that) - +the same row-vector composition convention as `Gradient.WithTransform`. + +**Exceptions:** + +- `ArgumentNullException`: Thrown by the constructor when `surface` is null. +- `ArgumentOutOfRangeException`: Thrown by the constructor when any component of `transform` is + not finite, or when `xStep`/`yStep` is not finite or is zero; thrown by `WithTransform` when the + composed transform has a non-finite component. + # Examples ## Example 1: Surface Pixel Access @@ -1704,6 +2066,9 @@ using DemaConsulting.CanvasNet.Fonts; using DemaConsulting.CanvasNet.Geometry; using System.Numerics; +// glyf-flavored (.ttf) fonts produce QuadraticBezierTo segments; CFF/OpenType +// (.otf) fonts produce CubicBezierTo segments instead - both are handled here +// so this helper works for either outline flavor. static Path TransformGlyph(Path glyph, float scale, float baselineY) { var builder = new PathBuilder(); @@ -1725,6 +2090,12 @@ static Path TransformGlyph(Path glyph, float scale, float baselineY) ToCanvas(command.Control1), ToCanvas(command.EndPoint)); break; + case PathCommandType.CubicBezierTo: + builder.CubicBezierTo( + ToCanvas(command.Control1), + ToCanvas(command.Control2), + ToCanvas(command.EndPoint)); + break; case PathCommandType.Close: builder.Close(); break; @@ -1735,7 +2106,7 @@ static Path TransformGlyph(Path glyph, float scale, float baselineY) return builder.Build(); } -var font = TrueTypeFont.Load("font.ttf"); +var font = TrueTypeFont.Load("font.ttf"); // also accepts .otf, or a .ttc via the faceIndex overload var glyphIndex = font.GetGlyphIndex('A'); var glyphOutline = font.GetGlyphOutline(glyphIndex); var scale = 48f / font.UnitsPerEm; diff --git a/docs/verification/canvas-net-pdf.md b/docs/verification/canvas-net-pdf.md new file mode 100644 index 00000000..a544796c --- /dev/null +++ b/docs/verification/canvas-net-pdf.md @@ -0,0 +1,391 @@ +# System Verification Design + + + +This document describes the system-level verification strategy for CanvasNetPdf. + +## Verification Approach + +The CanvasNetPdf system is verified through system-level integration tests that exercise the +library as a whole from the perspective of a consumer. Tests instantiate the library using its +public API and assert on observable outputs, without relying on knowledge of internal +implementation details. No mocking or stubbing is required at the system level โ€” the entire +integrated system is exercised as it would be used by a real caller. + +System tests reside in `PdfSystemIntegrationTests.cs` within the +`DemaConsulting.CanvasNet.Pdf.Tests` project. + +## Test Environment + +- **Framework**: xUnit v3 running under the .NET SDK +- **Execution**: `dotnet test` invoked by `build.ps1` and the CI pipeline +- **Dependencies**: No external services, databases, or network access required +- **Isolation**: Each test method constructs its own test fixtures; no shared state between tests + +## External Interface Simulation + +The system has no external network or service interfaces requiring simulation - no HTTP calls, +databases, or remote services are involved. It does support local file-path-based I/O through its +public API (`Open(string path)` reads a PDF document from a local file path), which system tests +exercise against the hand-authored fixture files in `PdfFixtures/`. System tests call the public +API directly with controlled inputs and verify returned values and thrown exceptions. + +## System-Level Test Scenarios + +### Integration: Pdf Open Returns Expected Page Count + +**Test**: `CanvasNetPdf_SystemIntegration_PdfOpen_ReturnsExpectedPageCount` + +Exercises end-to-end system behavior for `Open`/`PageCount`/`GetPageInfo`/`Render`: opens a +multi-page fixture once through the public API, asserts `PageCount`, then calls `GetPageInfo` for +two different pages and `Render` for two different pages against the *same* opened instance. +Confirms the "parse once, reused across every subsequent call" property โ€” one no stateless +design could express โ€” and serves as the shared platform-proof test referenced by every +`CanvasNetPdf-Platform-*` requirement. + +### Integration: Pdf Get Page Info Reports Rotated Dimensions + +**Test**: `CanvasNetPdf_SystemIntegration_PdfGetPageInfo_ReportsRotatedDimensions` + +Exercises end-to-end system behavior for rotation-adjusted page sizing: opens a fixture +containing a page with a non-zero `/Rotate` value through the public API, then calls +`GetPageInfo`. Asserts the reported `Width`/`Height` are swapped relative to the page's raw +`/MediaBox` and `Rotation` reports the effective normalized value, confirming page-tree +inheritance and rotation normalization integrate correctly with the public API. + +### Integration: Pdf Render Returns Blank Sized Surface + +**Test**: `CanvasNetPdf_SystemIntegration_PdfRender_ReturnsBlankSizedSurface` + +Exercises end-to-end system behavior for `Render`: calls the public `Render` API for a valid page +index and caller-chosen size, against a fixture with no `/Contents`. Asserts the returned +`Surface` has exactly the requested dimensions and every pixel is the default (fully transparent) +value, confirming the documented "a page with no content renders blank" behavior is honored at +the system's own public entry point. + +### Integration: Pdf Render Filled Rectangle And Stroked Line Paints Expected Pixels + +**Test**: `CanvasNetPdf_SystemIntegration_PdfRender_FilledRectangleAndStrokedLine_PaintsExpectedPixels` + +Exercises end-to-end system behavior for the Phase 2 content-stream interpreter: calls the public +`Render` API against a hand-authored fixture containing a filled rectangle and a stroked vertical +line. Asserts specific opaque-black pixels inside the rectangle and along the stroked line, and +specific fully-transparent pixels away from both, confirming real path geometry is rasterized to +the correct device-pixel positions through the system's own public entry point (not merely that +"something non-blank" was painted). + +### Integration: Pdf Render Rotated Page Maps Geometry To Correct Pixel Position + +**Test**: `CanvasNetPdf_SystemIntegration_PdfRender_RotatedPage_MapsGeometryToCorrectPixelPosition` + +Exercises end-to-end system behavior for the base CTM's rotation handling: calls the public +`Render` API against a hand-authored, `/Rotate 90` fixture containing a deliberately asymmetric +filled rectangle, at the fixture's own rotation-swapped display size. Asserts the rectangle's +interior is opaque black at its mathematically correct rotated position, and asserts a specific +pixel where a 270-instead-of-90 rotation-sign regression would incorrectly paint it is untouched, +confirming `/MediaBox`/`/Rotate`-to-device-pixel-space correctness through the system's own public +entry point. + +### Integration: Pdf Validation Null Null Stream Throws Argument Null Exception + +**Test**: `CanvasNetPdf_SystemIntegration_PdfValidationNull_NullStreamThrowsArgumentNullException` + +Exercises end-to-end system behavior for null-argument validation: calls the public `Open` API +with a null stream. Asserts `ArgumentNullException` is thrown, confirming the documented +validation contract is honored at the system's own public entry point, before any parsing is +attempted. + +### Integration: Pdf Dispose Object Disposed Exception After Dispose + +**Test**: `CanvasNetPdf_SystemIntegration_PdfDispose_ObjectDisposedExceptionAfterDispose` + +Exercises end-to-end system behavior for disposal: opens a fixture through the public API, calls +`Dispose()` twice (confirming idempotency), then calls `PageCount`, `GetPageInfo`, and `Render`. +Asserts each throws `ObjectDisposedException`, confirming the documented disposal contract is +honored across every other public member. + +### Integration: Pdf Encrypt Detection Encrypted Trailer Throws Unsupported Image Feature Exception + +**Test**: `CanvasNetPdf_SystemIntegration_PdfEncryptDetection_EncryptedTrailerThrowsUnsupportedImageFeatureException` + +Exercises end-to-end system behavior for `/Encrypt` detection: calls the public `Open` API +against a fixture whose trailer contains an `/Encrypt` key. Asserts +`Codecs.UnsupportedImageFeatureException` is thrown with +`Feature == "pdf-encrypted-filter-Adobe.PubSec"`, confirming an encrypted document is rejected +rather than silently mis-parsed at the system's own public entry point. + +### Integration: Pdf Open Encrypted Rc4 Correct User Password Decrypts And Renders Expected Pixels + +**Test**: `CanvasNetPdf_SystemIntegration_PdfOpen_EncryptedRc4_CorrectUserPassword_DecryptsAndRendersExpectedPixels` + +Exercises end-to-end system behavior for genuinely successful decryption: a synthetic, in-memory +RC4 128-bit (`/V 2`/`/R 3`) encrypted PDF is built with a file key derived from a real, non-empty +user password, then opened through the public `Open(Stream, string?)` API with that correct +password supplied and rendered through the public `Render` API. Asserts the encrypted content +stream's rectangle-fill operator actually painted the expected opaque-black device pixel (and a +pixel outside its footprint remains transparent), confirming the encrypted bytes were genuinely +decrypted to real plaintext content and rendered - not merely that `Open` accepted the password +without throwing. + +### Integration: Pdf Render Colored Rectangle Fill Paints Expected Rgb Pixels + +**Test**: `CanvasNetPdf_SystemIntegration_PdfRender_ColoredRectangleFill_PaintsExpectedRgbPixels` + +Exercises end-to-end system behavior for the Phase 3 device-color operators: calls the public +`Render` API against a hand-authored fixture using `rg` to fill a rectangle opaque red. Asserts a +specific interior pixel is opaque red and a specific exterior pixel remains transparent, +confirming real device color (not merely solid opaque black) is rasterized through the system's +own public entry point. + +### Integration: Pdf Render Image X Object Placement Composites Expected Pixels + +**Test**: `CanvasNetPdf_SystemIntegration_PdfRender_ImageXObjectPlacement_CompositesExpectedPixels` + +Exercises end-to-end system behavior for the Phase 3 image-XObject pipeline: calls the public +`Render` API against a hand-authored fixture placing a 2x2 `DeviceRGB` `FlateDecode` image +XObject via `cm`/`Do`. Asserts the four composited device pixels match the fixture's known source +image quadrants, and a pixel outside the placed image's device-space footprint remains +transparent, confirming stream decoding, sample-to-color conversion, and unit-square-to-device +compositing all integrate correctly through the system's own public entry point. + +### Integration: Pdf Render Form X Object Paints Nested Content Stream + +**Test**: `CanvasNetPdf_SystemIntegration_PdfRender_FormXObject_PaintsNestedContentStream` + +Exercises end-to-end system behavior for the `/Subtype /Form` XObject pipeline: calls the public +`Render` API against a synthetic, in-memory fixture whose page content stream invokes `/Fm0 Do`, +where `/Fm0` is a genuine `/Type /XObject /Subtype /Form` XObject carrying its own nested +rectangle-filling content stream. Asserts the Form's nested content actually painted the expected +opaque-black device pixel (and a pixel outside its footprint remains transparent), confirming the +Form XObject is genuinely executed end-to-end through the system's own public entry point - not +merely that `Do` fails to throw. + +### Integration: Pdf Render Embedded True Type Font Text Paints Glyph Strokes Not Counters + +**Test**: `CanvasNetPdf_SystemIntegration_PdfRender_EmbeddedTrueTypeFontText_PaintsGlyphStrokesNotCounters` + +Exercises end-to-end system behavior for the Phase 4 text-rendering pipeline: calls the public +`Render` API against a hand-authored fixture with a real, embedded (`/FontDescriptor/FontFile2`) +copy of the shared `OpenSans-Regular.ttf` production font, drawing `"HO"` at font size 60 with +the default `/WinAnsiEncoding` via `BT`/`Tf`/`Td`/`Tj`/`ET`. Rather than hardcoding font-specific +pixel numbers, the test independently loads the same real font through +`Fonts.TrueTypeFont` and re-derives the expected device-pixel positions of real glyph ink from +the font's own outline/metrics via the documented text-rendering-matrix formula. Asserts a pixel +inside `'H'`'s left stroke is opaque, a pixel at the exact center of `'O'`'s bounding box (its +hollow counter) is transparent, and the canvas's far corners remain transparent, confirming +font-dictionary resolution, `/Encoding` mapping, glyph-outline transformation, and glyph painting +all integrate correctly through the system's own public entry point with a real font file (not a +hand-rolled synthetic one). + +### Integration: Render Type0 Composite Font Paints Expected Glyph Ink + +**Test**: `CanvasNetPdf_SystemIntegration_RenderType0CompositeFont_PaintsExpectedGlyphInk` + +Exercises end-to-end system behavior for the Phase 9 Type0/Identity-H/CIDFontType2 composite-font +text-rendering pipeline: calls the public `Render` API against a hand-authored fixture with a +real, embedded (descendant `/FontDescriptor/FontFile2`) copy of the shared `OpenSans-Regular.ttf` +production font, a non-identity `/CIDToGIDMap` stream remapping CID 1/2 to the glyph indices of +`'H'`/`'O'`, and an explicit `/W` array declaring their advance widths, drawing the 2-byte +Identity-H codes `0001 0002` at font size 60 via `BT`/`Tf`/`Td`/`Tj`/`ET`. Rather than hardcoding +font-specific pixel numbers, the test independently loads the same real font through +`Fonts.TrueTypeFont` and re-derives the expected device-pixel positions of real glyph ink from +the font's own outline, looked up directly by glyph index (bypassing `cmap` entirely, exactly as +the composite code path does), combined with the fixture's own declared `/W` advance widths +(composite fonts never fall back to the font's own metrics). Asserts a pixel inside `'H'`'s left +stroke is opaque, a pixel at the exact center of `'O'`'s bounding box (its hollow counter) is +transparent, a pixel one full CID-2 advance width past `'O'`'s own origin is transparent, and the +canvas's far corners remain transparent, confirming Type0 font-dictionary resolution, +`/CIDToGIDMap` remapping, `/W`-declared advance widths, 2-byte code decoding, glyph-outline +transformation, and glyph painting all integrate correctly through the system's own public entry +point with a real font file (not a hand-rolled synthetic one). + +### Integration: Render Cid Font Type0 Composite Font Paints Expected Glyph Ink + +**Test**: `CanvasNetPdf_SystemIntegration_RenderCidFontType0CompositeFont_PaintsExpectedGlyphInk` + +Exercises end-to-end system behavior for the Phase 12 `/CIDFontType0` composite-font text- +rendering pipeline: calls the public `Render` API against a hand-authored, entirely synthetic +fixture whose descendant font's `/FontDescriptor/FontFile3` is a synthetic, non-CID-keyed, +`/OpenType`-wrapped CFF program (built via `SyntheticFontBuilder.Cff` - no third-party font +asset), with no `/CIDToGIDMap` declared (identity CID-to-glyph-index is used unconditionally for +this subtype). The content stream draws the 2-byte Identity-H code `0001` (CID 1, resolving to +GID 1, a filled square glyph) at font size 20 on a 100x100 `/MediaBox`. Asserts the glyph's +expected design-space square is painted as real ink at its correctly transformed device-pixel +position, and the canvas's far corners remain transparent, confirming `/CIDFontType0` resolution +and CFF/Type2-charstring outline decoding integrate correctly through the system's own public +entry point. + +### Integration: Render Embedded Type1 Font Paints Expected Glyph Ink + +**Test**: `CanvasNetPdf_SystemIntegration_RenderEmbeddedType1Font_PaintsExpectedGlyphInk` + +Exercises end-to-end system behavior for the Phases B/C/D `/Subtype /Type1` simple-font text- +rendering pipeline: calls the public `Render` API against a hand-authored, entirely synthetic +fixture built via `SyntheticFontBuilder.Type1` (no third-party font asset), whose embedded +classic PostScript `/FontDescriptor/FontFile` program's `A` glyph is a filled square, drawn at +font size 20 on a 100x100 `/MediaBox` - deliberately matching the `/CIDFontType0` composite-font +scenario's own glyph design/placement convention. Asserts the glyph's expected design-space +square is painted as real ink at its correctly transformed device-pixel position, and the +canvas's far corners remain transparent, confirming embedded classic Type 1 font-program +resolution and Type 1 charstring outline decoding integrate correctly through the system's own +public entry point. + +### Integration: Pdf Render Lzw Decode Content Stream Paints Expected Pixels + +**Test**: `CanvasNetPdf_SystemIntegration_PdfRender_LzwDecodeContentStream_PaintsExpectedPixels` + +Exercises end-to-end system behavior for the Phase 7 `LZWDecode` content-stream filter: calls the +public `Render` API against a hand-authored fixture whose `/Contents` stream is PDF-variant +LZW-compressed. Asserts the same interior-opaque-red/exterior-transparent pixel pattern as the +uncompressed device-color fixture, confirming the decoded operator text is parsed and rendered +identically to an uncompressed content stream through the system's own public entry point. + +### Integration: Pdf Render Ascii85 Decode Content Stream Paints Expected Pixels + +**Test**: `CanvasNetPdf_SystemIntegration_PdfRender_Ascii85DecodeContentStream_PaintsExpectedPixels` + +Exercises end-to-end system behavior for the Phase 7 `ASCII85Decode` content-stream filter: calls +the public `Render` API against a hand-authored fixture whose `/Contents` stream is base-85 +armored (terminated by `~>`). Asserts the same interior-opaque-red/exterior-transparent pixel +pattern, confirming `ASCII85Decode` decoding integrates correctly through the system's own public +entry point. + +### Integration: Pdf Render Ascii Hex Decode Content Stream Paints Expected Pixels + +**Test**: `CanvasNetPdf_SystemIntegration_PdfRender_AsciiHexDecodeContentStream_PaintsExpectedPixels` + +Exercises end-to-end system behavior for the Phase 7 `ASCIIHexDecode` content-stream filter: +calls the public `Render` API against a hand-authored fixture whose `/Contents` stream is +hex-digit-pair armored (terminated by `>`). Asserts the same interior-opaque-red/ +exterior-transparent pixel pattern, confirming `ASCIIHexDecode` decoding integrates correctly +through the system's own public entry point. + +### Integration: Pdf Render Run Length Decode Content Stream Paints Expected Pixels + +**Test**: `CanvasNetPdf_SystemIntegration_PdfRender_RunLengthDecodeContentStream_PaintsExpectedPixels` + +Exercises end-to-end system behavior for the Phase 7 `RunLengthDecode` content-stream filter: +calls the public `Render` API against a hand-authored fixture whose `/Contents` stream is a +single PackBits-style literal run. Asserts the same interior-opaque-red/exterior-transparent +pixel pattern, confirming `RunLengthDecode` decoding integrates correctly through the system's +own public entry point. + +### Integration: Render Standard14 Helvetica Without Embedded Font Paints Visible Glyph Ink + +**Test**: `CanvasNetPdf_SystemIntegration_RenderStandard14HelveticaWithoutEmbeddedFont_PaintsVisibleGlyphInk` + +Exercises end-to-end system behavior for the Phase 6 font-fallback substitution path: calls the +public `Render` API against a synthetic, in-memory single-page document declaring a +`/BaseFont /Helvetica` font resource with no embedded `/FontFile2`. Since the actual substitute +font (a matching system font, or the bundled Liberation Sans fallback) genuinely varies across +the Windows/Linux/macOS CI matrix, asserts the strongest platform-independent property: at least +one visibly-painted (non-transparent) pixel appears somewhere on the canvas, confirming the +fallback path genuinely renders a substitute glyph rather than merely not throwing. + +### Integration: Render Symbol Font Without Embedded Font Paints Visible Glyph Ink + +**Test**: `CanvasNetPdf_SystemIntegration_RenderSymbolFontWithoutEmbeddedFont_PaintsVisibleGlyphInk` + +Exercises end-to-end system behavior for the Phase 6 font-fallback substitution path: calls the +public `Render` API against a synthetic, in-memory single-page document declaring a +`/BaseFont /Symbol` font resource with no embedded `/FontFile2` and no `/FontDescriptor` entries +at all (PDF 32000-1 ยง9.6.2.2 permits an entirely absent/empty descriptor). Since Symbol/ +ZapfDingbats no longer fail closed and instead resolve via the bundled Noto substitute font +union, asserts the same platform-independent property as the Standard-14 Helvetica fallback test +above: at least one visibly-painted (non-transparent) pixel appears somewhere on the canvas, +confirming the Noto substitute glyph was actually rendered through the system's own public entry +point. + +### Integration: Noto Sans Regular Bundled Font Resolves Symbol Alpha To Nonzero Glyph Index + +**Test**: `CanvasNetPdf_SystemIntegration_NotoSansRegularBundledFont_ResolvesSymbolAlphaToNonzeroGlyphIndex` + +Proves, via the full production round-trip (`SystemFontCatalog.LoadBundledFallback`'s +embedded-resource-loading path, loading `NotoSans-Regular.ttf` the same way +`PdfDocument.FontFallback.cs`'s `ResolveSymbolicNotoFallback` does for the `Symbol` substitute's +primary font) that Symbol code `0x61` ('alpha', per PDF 32000-1 Appendix D's Symbol encoding, +mapped to Unicode U+03B1) resolves to a nonzero, valid glyph index, confirming the bundled +substitute font genuinely carries a real Greek alpha glyph, not merely that the pipeline +declines to throw. + +### Integration: Render Other Symbolic Font Without Embedded Font Throws Unsupported Image Feature Exception + +**Test**: `CanvasNetPdf_SystemIntegration_RenderOtherSymbolicFontWithoutEmbeddedFont_ThrowsUnsupportedImageFeatureException` + +Regression guard exercising end-to-end system behavior for the Phase 6 font-fallback fail-closed +boundary: calls the public `Render` API against a synthetic, in-memory single-page document +declaring a non-Symbol/ZapfDingbats symbolic font resource (`/FontDescriptor/Flags` declaring the +`Symbolic` bit without also declaring `Nonsymbolic`) with no embedded `/FontFile2`. Asserts +`Codecs.UnsupportedImageFeatureException` is thrown with `Feature == "pdf-font-symbolic-not-embedded"`, +confirming the new Symbol/ZapfDingbats Noto-substitution path did not loosen the fail-closed +policy for any other symbolic font through the system's own public entry point. + +### Integration: Pdf Validation Empty Path Empty Path Throws Argument Exception + +**Test**: `CanvasNetPdf_SystemIntegration_PdfValidationEmptyPath_EmptyPathThrowsArgumentException` + +Exercises end-to-end system behavior for empty-path-argument validation: calls the public +`Open(string)` API with `string.Empty`. Asserts `ArgumentException` is thrown, confirming the +documented validation contract is honored at the system's own public entry point, before any +file access is attempted. + +### Integration: Pdf Unsupported Format Validation Malformed Content Stream Throws Invalid Data Exception + +**Test**: `CanvasNetPdf_SystemIntegration_PdfUnsupportedFormatValidation_MalformedContentStreamThrowsInvalidDataException` + +Exercises end-to-end system behavior for content-stream-level malformed-data validation: calls +the public `Render` API against a hand-authored fixture whose otherwise well-formed +document/xref/page-tree structure has a `/Contents` stream containing a lexically malformed +operator (a `re` given only 2 of its 4 required operands). Asserts `InvalidDataException` is +thrown, confirming a content-stream-level malformation (distinct from the structural +malformations already covered by `malformed-startxref.pdf`/`cyclic-page-tree.pdf`) is rejected at +the system's own public entry point. + +### Integration: Pdf Get Page Info Validation Out Of Range Page Index Throws Argument Out Of Range Exception + +**Test**: `CanvasNetPdf_SystemIntegration_PdfGetPageInfoValidation_OutOfRangePageIndexThrowsArgumentOutOfRangeException` + +Exercises end-to-end system behavior for out-of-range-page-index validation: calls the public +`GetPageInfo` API against a single-page fixture with an out-of-range page index. Asserts +`ArgumentOutOfRangeException` is thrown, confirming the documented validation contract is honored +at the system's own public entry point. + +### Integration: Axial Shading Pattern Fill Paints Visibly Varying Colors + +**Test**: `CanvasNetPdf_SystemIntegration_AxialShadingPatternFill_PaintsVisiblyVaryingColors` + +Exercises end-to-end system behavior for the Phase 14 `/Pattern` color-space axial-shading +pipeline: calls the public `Render` API against a synthetic, in-memory single-page PDF (no binary +fixture) declaring a `/Pattern`-color-space fill driven by a `/ShadingType 2` shading with a +`/FunctionType 2` (exponential interpolation) function interpolating black to white along its +axis. Asserts the painted gradient is near-black at the axis's start coordinate and near-white at +its end coordinate, confirming `/Pattern` color-space resolution, axial-shading coordinate +mapping, and function evaluation all integrate correctly through the system's own public entry +point (not merely a single solid fill color). + +### Integration: Colored Tiling Pattern Fill Paints Repeating Tile Colors + +**Test**: `CanvasNetPdf_SystemIntegration_ColoredTilingPatternFill_PaintsRepeatingTileColors` + +Exercises end-to-end system behavior for the Phase 14 `/Pattern` color-space colored-tiling +pipeline: calls the public `Render` API against a synthetic, in-memory single-page PDF (no binary +fixture) declaring a `/Pattern`-color-space fill driven by a `/PatternType 1`/`/PaintType 1` +10x10 pattern cell (left half opaque red, right half opaque blue) tiled across a 100x100 fill. +Asserts both tile colors appear at multiple repeated tile offsets at their exactly expected +device pixels, confirming tiling-pattern cell execution, `/XStep`/`/YStep` tile-offset repetition, +and the nested-execution machinery shared with `/Subtype /Form` XObjects all integrate correctly +through the system's own public entry point. + +## Acceptance Criteria + +A system-level test run passes when all scenarios above pass without error or exception beyond +those explicitly asserted. Any unexpected exception, wrong exception type, or wrong return value +constitutes a failure. Collectively, these scenarios cover the complete current CanvasNetPdf +feature set: document structure parsing, the content-stream interpreter, device color (including +`/CalRGB`, `/ICCBased`, and `/Indexed` color spaces), image XObjects, `/Subtype /Form` XObjects, +the full `FlateDecode`/`LZWDecode`/`ASCII85Decode`/`ASCIIHexDecode`/`RunLengthDecode`/ +`CCITTFaxDecode`/`DCTDecode` stream-filter and image-decoding set, embedded-TrueType/Type1/Type1C/ +Type3 and Type0/CIDFontType2/CIDFontType0 composite-font text rendering with automatic +system/bundled-fallback substitution, `/Pattern` color-space axial/radial shading and +colored/uncolored tiling pattern fills, RC4/AES-encrypted document decryption, and the documented +validation-contract/fail-closed exception boundaries. diff --git a/docs/verification/canvas-net-pdf/pdf-document.md b/docs/verification/canvas-net-pdf/pdf-document.md new file mode 100644 index 00000000..ee2e7565 --- /dev/null +++ b/docs/verification/canvas-net-pdf/pdf-document.md @@ -0,0 +1,1523 @@ +## PdfDocument Unit Verification Design + + + + + + + + +This document describes the unit-level verification strategy for the `PdfDocument` class. + +`PdfDocument` is distributed as the separate `DemaConsulting.CanvasNet.Pdf` NuGet package +(namespace `DemaConsulting.CanvasNet.Pdf`), which references the core `DemaConsulting.CanvasNet` +package; its unit tests live in the sibling `DemaConsulting.CanvasNet.Pdf.Tests` project. + +### Verification Approach + +The `PdfDocument` unit is verified through unit tests that exercise its tokenizer, object model, +content-stream interpreter, and public API in isolation. Tokenizer and object-model tests +(`PdfDocumentTests.cs`) construct small, hand-written byte sequences directly (using the +`internal`, `InternalsVisibleTo`-exposed `PdfTokenizer`/`PdfObject` types) for controlled, +targeted coverage of individual lexical and structural rules without needing a full, valid PDF +file for every case. Content-stream interpreter tests (graphics-state stack, path-construction, +path-painting) build small, in-memory, single-page PDFs via a private test helper +(`BuildSinglePagePdf`) parameterized by `/MediaBox` size, optional `/Rotate`, and an arbitrary +content-stream string, then call `Render` through the public API and assert specific pixel +colors at specific `Surface` coordinates - every expected coordinate was independently confirmed +against the real rasterizer (not merely hand-derived) before being fixed into an assertion, since +stroke/curve antialiasing and the PDF-to-device y-axis flip make hand derivation error-prone. +Device-color, stream-filter-pipeline, and image-XObject tests (Phase 3) additionally use a +second private test helper, `BuildSinglePagePdfWithResources`, extending the same in-memory +construction with a page `/Resources` dictionary and 0 or more extra indirect objects (color-space +arrays, image-XObject streams) the resources reference by number - used whenever a test needs a +named `/Resources/ColorSpace` or `/Resources/XObject` entry that a bare content-stream string +cannot express. +Cross-reference form, linear-scan fallback, page-tree traversal/inheritance/cycle-rejection, +`/Encrypt` detection, and public-API (`Open`/`PageCount`/`GetPageInfo`/`Render`/`Dispose`) tests +exercise the same hand-authored, byte-exact fixture files under `PdfFixtures/` (see +`PdfFixtures/README.md` for provenance) through the public API only. Font-resolution/encoding/ +text-operator tests (Phase 4) build a minimal synthetic embedded TrueType font in-memory (via the +shared `SyntheticFontBuilder` test helper, linked from `DemaConsulting.CanvasNet.Tests`) whenever +a test only needs controlled glyph outlines/`cmap` mappings; the one required end-to-end, +real-font pixel-level test instead reuses the shared, unmodified `OpenSans-Regular.ttf` fixture +(the same file `DemaConsulting.CanvasNet.Svg.Tests` links in), re-deriving its expected +device-pixel positions from the font's own outline/metrics rather than hardcoded numbers. Because +`PdfDocument`'s dependencies (`Canvas.Surface`, `Codecs.UnsupportedImageFeatureException`, +`Geometry.PathBuilder`, `Drawing.PathFiller`/`PathStroker`, and, as of Phase 4, `Fonts.TrueTypeFont`) +are all sibling in-house types, not external services, no mocking or stubbing is required. Tests +assert on parsed token/object field values, on `PageCount`/`PdfPageInfo` field values, on +`Surface` pixel/dimension values, and on thrown exception types (and, for +`UnsupportedImageFeatureException`, its `Feature` token) - never on "no exception thrown" alone, +so every test can actually fail if the implementation is wrong. Encrypted-document tests +(`PdfDocumentEncryptionTests.cs`, Phase 16/17) build minimal, valid, encrypted single-page PDFs +entirely in-memory using test-only helper methods that independently re-derive the ISO 32000-1 +Algorithm 1/2/3/4/5 and ISO 32000-2 Algorithm 2.A steps directly from the specification text +(a hand-rolled RC4 KSA/PRGA, since RC4 is symmetric and so the same helper both "encrypts" these +fixtures and is what production decryption itself implements, plus `Aes.Create()`-based AES-CBC +encryption) - deliberately not copy-pasted from `PdfDocument.Encryption.cs`'s own implementation, +so that a shared bug could not silently mask itself by passing against its own mirrored mistake. +Most fixtures still use the empty user password (Phase 16's original scope), but Phase 17 added +sibling fixture variants built from real, non-empty, and - for the two owner-password tests - +distinct user/owner passwords, exercising the production try-user-then-owner authentication order +end-to-end rather than only the empty-password shortcut. + +Unit tests reside in `PdfDocumentTests.cs` within the `DemaConsulting.CanvasNet.Pdf.Tests` +project; encryption-specific tests reside in the sibling `PdfDocumentEncryptionTests.cs`. + +### Test Environment + +- **Framework**: xUnit v3 running under the .NET SDK +- **Execution**: `dotnet test` invoked by `build.ps1` and the CI pipeline +- **Mocking**: None required; `PdfDocument`'s dependencies are all in-house types + (`Canvas.Surface`, `Codecs.UnsupportedImageFeatureException`, `Geometry.PathBuilder`, + `Drawing.PathFiller`/`PathStroker`) +- **Isolation**: Each test method builds its own byte sequence, its own in-memory single-page PDF + (content-stream interpreter tests), or opens its own fixture file from `PdfFixtures/`; no shared + state between tests + +### Unit-Level Test Scenarios + +#### CanvasNetPdf-PdfDocument-Tokenizer: Tokenizer Recognizes Every Lexical Construct + +**Tests**: `PdfDocument_Tokenizer_Numbers_ParsesIntegerAndRealForms`, +`PdfDocument_Tokenizer_LiteralString_HandlesNestedParensAndEscapes`, +`PdfDocument_Tokenizer_HexString_DecodesFullBytes`, +`PdfDocument_Tokenizer_HexString_PadsOddDigitCountWithTrailingZero`, +`PdfDocument_Tokenizer_Name_DecodesHashEscapes`, +`PdfDocument_Tokenizer_Delimiters_RecognizesArrayAndDictionaryBrackets`, +`PdfDocument_Tokenizer_Comments_AreSkipped`, `PdfDocument_Tokenizer_Keywords_AreRecognizedVerbatim`, +`PdfDocument_Tokenizer_EndOfInput_ReturnsEndOfFileToken` + +Feeds small, hand-written byte sequences directly to `PdfTokenizer`, asserting the returned +token's `Kind` and decoded value (number, string bytes, name text, or keyword text) for every +lexical construct the tokenizer must recognize, including edge cases (odd hex-digit count +padding, `#xx` name escapes, nested-parenthesis literal strings, comment skipping). + +#### CanvasNetPdf-PdfDocument-ObjectModel: Object Model Parses Dictionaries, Arrays, and References + +**Tests**: `PdfDocument_ObjectModel_Dictionary_ParsesNestedEntries`, +`PdfDocument_ObjectModel_Array_ParsesMixedElementTypes`, +`PdfDocument_ObjectModel_IndirectReference_ParsesObjectAndGenerationNumbers`, +`PdfDocument_ObjectModel_BareNumber_IsNotMisreadAsReference` + +Feeds small, hand-written token sequences to `PdfDocument.ParseValue`, asserting the resulting +`PdfObject` tree's `Kind`/field values for nested dictionaries, mixed-kind arrays, indirect +references, and the bare-number-vs-reference disambiguation. + +#### CanvasNetPdf-PdfDocument-ClassicXref: Classic Xref Table Resolves Root and Page + +**Test**: `PdfDocument_Open_ClassicXref_ResolvesRootAndPage` + +Opens `PdfFixtures/classic-xref-single-page.pdf` (a classic `xref` table + `trailer`) through the +public API. Asserts `PageCount` and the single page's `GetPageInfo` size/rotation match the +fixture's known content. + +#### CanvasNetPdf-PdfDocument-XrefStream: Xref Stream Resolves Root and Page + +**Test**: `PdfDocument_Open_XrefStream_ResolvesRootAndPage` + +Opens `PdfFixtures/xref-stream-single-page.pdf` (a `/Type /XRef` cross-reference stream, no +classic table) through the public API. Asserts `PageCount` and the single page's `GetPageInfo` +size match the fixture's known content. + +#### CanvasNetPdf-PdfDocument-ObjectStream: Object Stream Decompresses and Resolves Compressed Page + +**Test**: `PdfDocument_Open_ObjectStream_DecompressesAndResolvesCompressedPage` + +Opens `PdfFixtures/object-stream.pdf` (a `/Type /ObjStm` compressed-object stream holding the page +dictionary) through the public API. Asserts `PageCount` and the compressed page's `GetPageInfo` +size match the fixture's known content, confirming FlateDecode decompression and per-object +resolution within the stream both work correctly. + +#### CanvasNetPdf-PdfDocument-HybridXref: Hybrid Xref Resolves Compressed Entry via XRefStm + +**Test**: `PdfDocument_Open_HybridXref_ResolvesCompressedEntryViaXRefStm` + +Opens `PdfFixtures/hybrid-xref.pdf` (a classic trailer whose own table marks one object free, plus +an `/XRefStm` link to a supplementary cross-reference stream that alone describes that object) +through the public API. Asserts `PageCount` and the page's `GetPageInfo` size match the fixture's +known content, confirming a hybrid entry correctly overrides a classic "free" marker for the same +object number. + +#### CanvasNetPdf-PdfDocument-LinearScanFallback: Malformed Startxref Falls Back to Linear Scan + +**Test**: `PdfDocument_Open_MalformedStartxref_FallsBackToLinearScan` + +Opens `PdfFixtures/malformed-startxref.pdf` (no `startxref`/`xref`/`trailer` at all) through the +public API. Asserts `PageCount` and the page's `GetPageInfo` size match the fixture's known +content, confirming the linear-scan fallback successfully reconstructs an object-offset table and +locates the catalog without any cross-reference section present. + +#### CanvasNetPdf-PdfDocument-PageTreeTraversal: Page Tree Traversal Reports All Pages + +**Test**: `PdfDocument_PageTree_Traversal_ReportsAllPagesInDocumentOrder` + +Opens `PdfFixtures/multi-page-mixed-mediabox-rotate.pdf` (three pages) through the public API. +Asserts `PageCount` equals 3, confirming every `/Type /Page` leaf in the page tree is reported. + +#### CanvasNetPdf-PdfDocument-PageTreeInheritance: MediaBox/Rotate Inheritance and Rotation Swap + +**Tests**: `PdfDocument_PageTree_Inheritance_ExplicitMediaBoxAndNoRotationIsUnchanged`, +`PdfDocument_PageTree_Inheritance_Rotation90SwapsDisplayedWidthAndHeight`, +`PdfDocument_PageTree_Inheritance_MediaBoxInheritedFromPagesNodeWhenPageOmitsIt` + +Opens `PdfFixtures/multi-page-mixed-mediabox-rotate.pdf` through the public API and calls +`GetPageInfo` for each of its three pages. Asserts: a page with its own explicit `/MediaBox` and +no `/Rotate` reports that box unchanged; a `/Rotate 90` page reports swapped width/height relative +to its raw `/MediaBox`; a page declaring no `/MediaBox` of its own inherits its ancestor `/Pages` +node's box. + +#### CanvasNetPdf-PdfDocument-PageTreeCycleRejection: Page Tree Cycle Throws InvalidDataException + +**Test**: `PdfDocument_PageTree_CycleRejection_ThrowsInvalidDataException` + +Opens `PdfFixtures/cyclic-page-tree.pdf` (a `/Kids` entry referencing an ancestor) through the +public API. Asserts `InvalidDataException` is thrown, confirming the unbounded-cycle guard stops +traversal rather than looping forever. + +#### CanvasNetPdf-PdfDocument-EncryptDetection: Unsupported Encrypted-Document Shapes Throw Distinguishable UnsupportedImageFeatureException + +**Tests**: `PdfDocument_Open_EncryptedTrailer_ThrowsUnsupportedImageFeatureException`, +`CanvasNetPdf_SystemIntegration_PdfEncryptDetection_EncryptedTrailerThrowsUnsupportedImageFeatureException`, +`PdfDocument_Open_EncryptedAesV3_R6_ThrowsUnsupportedImageFeatureException`, +`PdfDocument_Open_EncryptedRc4_WrongUserPasswordHash_ThrowsUnsupportedImageFeatureException`, +`PdfDocument_Open_EncryptedAesV3_WrongValidationHash_ThrowsUnsupportedImageFeatureException` + +Opens `PdfFixtures/encrypted-trailer.pdf` (a trailer containing an `/Encrypt` key whose `/Filter` +is `/Adobe.PubSec`, not `/Standard`) through the public API and asserts +`UnsupportedImageFeatureException` is thrown with `Feature == "pdf-encrypted-filter-Adobe.PubSec"` +(both from `PdfDocumentTests.cs` and, identically, from `PdfSystemIntegrationTests.cs`'s own +end-to-end copy of the same assertion). Separately builds an in-memory `/V 5`/`/R 6` document and +asserts `Feature == "pdf-encrypted-r6-hardened-hash"` (AES-256's "hardened hash" key derivation is +out of scope). Separately builds an in-memory RC4 document with a well-formed `/O` but a +deliberately wrong `/U`, and an in-memory AESV3/R5 document with a deliberately wrong `/U` +validation hash, asserting both throw with `Feature == "pdf-encrypted-password-required"` when no +password is supplied (a real, non-empty password is genuinely required to open either document; +see `CanvasNetPdf-PdfDocument-IncorrectPasswordRejection` and +`CanvasNetPdf-PdfDocument-PasswordNonAsciiRejection` below for the distinguishable +`"pdf-encrypted-incorrect-password"`/`"pdf-encrypted-password-non-ascii"` tokens thrown when a +password *is* supplied but still fails to authenticate, or is malformed). + +#### CanvasNetPdf-PdfDocument-EncryptionRc4: RC4-Encrypted Documents With an Empty User Password Open and Render + +**Tests**: `PdfDocument_Open_EncryptedRc4_40Bit_DecryptsAndRenders`, +`PdfDocument_Open_EncryptedRc4_128Bit_DecryptsAndRenders`, +`PdfDocument_Open_Encrypted_EncryptDictionaryStringsAreNeverDecrypted`, +`PdfDocument_Open_Encrypted_CatalogOwnStringIsDecrypted`, +`PdfDocument_Open_Encrypted_ObjStmContainedObjects_AreNeverDoubleDecrypted_DocumentedByDesign` + +Builds minimal, in-memory, `/Filter /Standard` encrypted single-page PDFs for `/V 1`/`/R 2` +(40-bit) and `/V 2`/`/R 3` (128-bit) RC4, each with a test-computed `/O`/`/U` (the `/R 3` case's +`/U` deliberately carries non-zero, arbitrary trailing padding bytes in its last 16 of 32 bytes, +proving production authentication compares only the first 16) and an RC4-encrypted `/Contents` +stream. Opens each through the public API with no password supplied and asserts `Render` produces +the expected opaque-black/transparent pixels for the plaintext content stream's filled rectangle, +proving the empty user password authenticated and every string/stream decrypted correctly. +`PdfDocument_Open_Encrypted_EncryptDictionaryStringsAreNeverDecrypted` re-asserts the same +RC4 40-bit scenario standalone as a dedicated regression anchor for the invariant that the +`/Encrypt` dictionary's own `/O`/`/U` strings are never mistakenly decrypted (relying on +`InitializeEncryption` resolving, and thereby caching, that object before any encryption key is +ever set - if this ordering were ever broken, the corrupted `/O`/`/U` bytes would make every +positive encryption test, including this one, fail authentication). +`PdfDocument_Open_Encrypted_CatalogOwnStringIsDecrypted` embeds an RC4-encrypted `/Lang (en-US)` +literal string directly in the Catalog's own body (object 1, encrypted with its own per-object +key exactly like every other string in the document), confirms the document still opens and +renders correctly, then - via reflection into the private `GetObject` method - re-fetches the +cached Catalog object and asserts its `/Lang` bytes are the plaintext `"en-US"`, proving that the +Catalog, resolved and cached pre-key by the constructor's own `IsValidCatalogRoot` check before +`InitializeEncryption` ever runs, is still correctly decrypted once `InitializeEncryption`'s own +`InvalidateObjectCacheExceptEncryptDictionary` step discards and forces re-resolution of every +object cached before the key existed (except the `/Encrypt` dictionary's own). +`PdfDocument_Open_Encrypted_ObjStmContainedObjects_AreNeverDoubleDecrypted_DocumentedByDesign` +constructs a synthetic `/Type /XRef` cross-reference-stream-based encrypted PDF whose compressed +object 6 (`<< /Greeting (Hello, Encrypted World!) >>`) lives inside object 7's `/Type /ObjStm` +container, itself RC4-encrypted as a whole (never re-encrypted per contained object), and asserts +both that the document still renders correctly (object 4's own encrypted `/Contents` stream +decrypts correctly) and - via the same reflection technique - that the compressed object's own +`/Greeting` string decompresses/decrypts to the correct plaintext, proving `GetObject`'s two +mutually exclusive code paths (`ParseIndirectObjectAt`, which decrypts strings, for a direct +cross-reference entry; `LoadCompressedObject`, which never does, for a compressed entry, relying +instead on the container stream's own single decrypt pass) produce a correct, single-pass result. + +#### CanvasNetPdf-PdfDocument-EncryptionAesV2: AES-128 (AESV2) Documents With an Empty Password + +**Test**: `PdfDocument_Open_EncryptedAesV2_128Bit_DecryptsAndRenders` + +Builds a minimal, in-memory, `/Filter /Standard`/`/V 4`/`/R 4`/`/CF/StdCF/CFM /AESV2` encrypted +single-page PDF with a test-computed `/O`/`/U` and an AES-128-CBC/PKCS7-encrypted `/Contents` +stream (a per-object key derived via Algorithm 1's `sAlT`-suffixed branch, with a leading 16-byte +initialization vector prepended to the ciphertext). Opens it through the public API with no +password supplied and asserts `Render` produces the expected pixel colors, proving the AES branch +of Algorithm 1 decrypts correctly end-to-end. + +#### CanvasNetPdf-PdfDocument-EncryptionAesV3R5: AES-256 R5 (AESV3) Documents With an Empty Password + +**Test**: `PdfDocument_Open_EncryptedAesV3_R5_DecryptsAndRenders` + +Builds a minimal, in-memory, `/Filter /Standard`/`/V 5`/`/R 5`/`/CF/StdCF/CFM /AESV3` encrypted +single-page PDF with a test-computed `/U` (hash/validation-salt/key-salt) and `/UE` (the raw +32-byte file key, AES-256-CBC-wrapped with a zero initialization vector and no padding under the +password-derived intermediate key) per ISO 32000-2 Algorithm 2.A, and an AES-256-CBC/PKCS7 +encrypted `/Contents` stream using the file key directly (no further per-object derivation, as R5 +specifies). Opens it through the public API with no password supplied and asserts `Render` +produces the expected pixel colors, proving Algorithm 2.A's validation-salt authentication, +`/UE` unwrapping, and direct-file-key stream decryption all work correctly end-to-end. + +#### CanvasNetPdf-PdfDocument-UserPasswordAuthentication: Correct User Password Decrypts and Renders + +**Tests**: `PdfDocument_Open_EncryptedRc4_CorrectUserPassword_DecryptsAndRenders`, +`PdfDocument_Open_EncryptedAesV2_CorrectUserPassword_DecryptsAndRenders`, +`PdfDocument_Open_EncryptedAesV3_CorrectUserPassword_DecryptsAndRenders` + +The three tests build RC4 (`/V 2`/`/R 3`), AES-128 (`/V 4`/`/R 4`/`/CFM /AESV2`), and AES-256 R5 +(`/V 5`/`/R 5`/`/CFM /AESV3`) fixtures whose `/O`/`/U` (or `/U`/`/UE`) are derived from a real, +non-empty password (`"test"`) rather than the empty-password padding constant, then call +`PdfDocument.Open(stream, "test")` and assert `Render` produces the expected pixel colors - +proving the user-password authentication path correctly encodes/pads a real password's bytes +(Latin-1 for R2-R4, UTF-8 for R5) instead of always using the empty-password constant, across +every supported cipher. + +#### CanvasNetPdf-PdfDocument-OwnerPasswordAuthentication: Correct Owner Password Decrypts and Renders + +**Tests**: `PdfDocument_Open_EncryptedRc4_CorrectOwnerPassword_DecryptsAndRenders`, +`PdfDocument_Open_EncryptedAesV3_CorrectOwnerPassword_DecryptsAndRenders` + +`PdfDocument_Open_EncryptedRc4_CorrectOwnerPassword_DecryptsAndRenders` builds an `/R 3` RC4 +fixture whose `/O` is computed from a distinct owner password and a different real user password +(ISO 32000-1 Algorithm 3's encrypt direction) while `/U` and the content stream are derived from +the real user password only; calling `Open(stream, ownerPassword)` necessarily fails the +user-password attempt first (the supplied string is not the real user password), then succeeds +via `RecoverPaddedUserPasswordAlgorithm3` recovering the padded user password from `/O` and +re-deriving/re-authenticating a candidate file key - proving the R2-R4 owner-password recovery +path end-to-end. `PdfDocument_Open_EncryptedAesV3_CorrectOwnerPassword_DecryptsAndRenders` builds +an R5 fixture whose `/U`/`/UE` are derived from an unrelated, empty user password (so the supplied +owner password fails the user-password attempt first) and whose `/O`/`/OE` are built via the +owner-password variant of ISO 32000-2 Algorithm 2.A - hashing/encrypting over +`password โ€– salt โ€– U` where `U` is the full 48-byte `/U` value - proving +`TryComputeFileKeyAlgorithm2AOwnerPassword` recovers the file key directly from `/OE` and that the +document then renders correctly. + +#### CanvasNetPdf-PdfDocument-IncorrectPasswordRejection: Wrong Password Rejected With Distinguishable Feature + +**Test**: `PdfDocument_Open_Encrypted_IncorrectPassword_ThrowsUnsupportedImageFeatureException` + +Builds a well-formed `/R 3` RC4 fixture with real, distinct, correct owner and user passwords, +then calls `Open(stream, "wrong-password")` and asserts `UnsupportedImageFeatureException` is +thrown with `Feature == "pdf-encrypted-incorrect-password"` - proving that a password which +authenticates as neither role is rejected with the new, distinguishable token (not the +null-password `"pdf-encrypted-password-required"` token, and not a silent wrong-key decrypt). + +#### CanvasNetPdf-PdfDocument-PasswordNonAsciiRejection: Non-ASCII R2-R4 Password Rejected Before Authentication + +**Test**: `PdfDocument_Open_Encrypted_NonAsciiPassword_ThrowsUnsupportedImageFeatureException` + +Builds a well-formed `/R 2` RC4 fixture (empty-user-password `/O`/`/U`, irrelevant to this test) +and calls `Open(stream, "caf\u00e9")` (containing `รฉ`, outside ASCII 0-127), asserting +`UnsupportedImageFeatureException` is thrown with `Feature == "pdf-encrypted-password-non-ascii"` + +- proving `EncodeR2R4PasswordBytes` rejects non-ASCII R2-R4 passwords before any RC4/MD5 +authentication work is attempted, rather than silently deriving wrong key material from a +truncated or mis-encoded byte sequence. + +#### CanvasNetPdf-PdfDocument-Open: Open Validates Null and Empty/Whitespace Arguments + +**Tests**: `PdfDocument_Open_NullStream_ThrowsArgumentNullException`, +`PdfDocument_Open_NullPath_ThrowsArgumentNullException`, +`PdfDocument_Open_EmptyOrWhitespacePath_ThrowsArgumentException` + +Calls `Open(Stream)`/`Open(string)` with a null stream, a null path, and an empty/whitespace-only +path. Asserts `ArgumentNullException`/`ArgumentException` respectively, confirming argument +validation precedes any parsing attempt. + +#### CanvasNetPdf-PdfDocument-GetPageInfo: GetPageInfo Validates Page Index Range + +**Test**: `PdfDocument_GetPageInfo_OutOfRangeIndex_ThrowsArgumentOutOfRangeException` +(`[Theory]`: a negative index and an index equal to `PageCount`) + +Opens a single-page fixture and calls `GetPageInfo` with an out-of-range index. Asserts +`ArgumentOutOfRangeException` for both a negative index and one at/beyond `PageCount`. + +#### CanvasNetPdf-PdfDocument-Render: Render Paints Content-Stream Geometry and Validates Arguments + +**Tests**: `PdfDocument_Render_ValidPageIndex_ReturnsCorrectlySizedBlankSurface`, +`PdfDocument_Render_OutOfRangePageIndex_ThrowsArgumentOutOfRangeException`, +`PdfDocument_Render_InvalidWidth_PropagatesSurfaceArgumentOutOfRangeException`, +`PdfDocument_ContentStream_NoContents_RendersBlankSurface` + +Calls `Render` with a valid page index and caller-chosen size against a fixture with no +`/Contents`, asserting the returned `Surface` has exactly the requested dimensions and every +pixel is the default (fully transparent) value. Calls `Render` with an out-of-range page index +and a non-positive width, asserting `ArgumentOutOfRangeException` in both cases (the latter +propagated unwrapped from `Surface`'s own constructor). + +#### CanvasNetPdf-PdfDocument-RenderWithDpi: Render(int, float) Scales Page Size by DPI/72 and Validates Arguments + +**Tests**: `PdfDocument_RenderWithDpi_ScalesPageSizeByDpiOver72`, +`PdfDocument_RenderWithDpi_InvalidDpi_ThrowsArgumentOutOfRangeException`, +`PdfDocument_RenderWithDpi_OutOfRangePageIndex_ThrowsArgumentOutOfRangeException`, +`PdfDocument_RenderWithDpi_AfterDispose_ThrowsObjectDisposedException` + +Calls `Render(int, float)` with several DPI values (`[Theory]`: 72, 36, and 144) against a +single-page fixture and asserts the returned `Surface`'s width/height exactly match the page's +own point-space size scaled by `dpi / 72` and rounded to the nearest pixel, confirming the +overload preserves the page's aspect ratio rather than requiring the caller to compute pixel +dimensions itself. Calls `Render(int, float)` with a non-positive and a non-finite (`NaN`, +`PositiveInfinity`) DPI (`[Theory]`), an out-of-range page index, and after the document has been +disposed, asserting `ArgumentOutOfRangeException` for the first two cases and +`ObjectDisposedException` for the last, matching the validation contract already proven for +`Render(int, int, int)`. + +#### CanvasNetPdf-PdfDocument-ContentStreamDispatch: Unknown Operators Are Skipped, Malformed Recognized Operators Throw + +**Tests**: `PdfDocument_ContentStream_UnknownOperator_IsSkippedWithoutThrowing`, +`CanvasNetPdf_SystemIntegration_PdfRender_FilledRectangleAndStrokedLine_PaintsExpectedPixels` + +Renders a content stream containing an unrecognized operator (`gs`, ExtGState - still out of +scope in Phase 3) immediately followed by a recognized stroke operator, asserting the stroke +still painted its expected pixel (proving the unknown operator was silently skipped rather than +aborting the whole stream). The end-to-end system-integration test independently proves the same +dispatch loop against a real, hand-authored fixture containing both a filled rectangle and a +stroked line, asserting specific opaque-black and transparent pixels at specific coordinates. + +#### CanvasNetPdf-PdfDocument-ContentsResolution: Contents Array Concatenates With a Space Separator + +**Test**: `PdfDocument_ContentStream_ContentsArray_ConcatenatesStreamsWithSpaceSeparator` + +Opens `PdfFixtures/contents-array-two-streams.pdf`, whose `/Contents` is a two-entry array (one +stream holding `"10 10 40"`, the other holding `"40 re f"`) deliberately chosen so an +omitted-separator regression would merge the two `"40"` tokens into `"4040"`, reducing `re`'s +operand count and (in this specific fixture) throwing `InvalidDataException` instead of rendering +the expected filled square. Asserts the expected filled-square pixel is opaque black and a pixel +outside it remains transparent, confirming the required space-separator concatenation. + +#### CanvasNetPdf-PdfDocument-GraphicsStateStack: q/Q/cm Compose and Restore the Current Transform + +**Tests**: `PdfDocument_GraphicsState_QPushCmThenQPop_RestoresPriorTransform`, +`PdfDocument_GraphicsState_NestedQQ_ComposesTransformsInOrder`, +`PdfDocument_GraphicsState_UnbalancedQWithNoMatchingPush_DoesNotThrow`, +`PdfDocument_GraphicsState_MalformedCmOperandCount_ThrowsInvalidDataException` + +Renders a content stream that draws an identical line segment both inside a `q`/`cm`/`Q` block +and after the matching `Q`, asserting the post-`Q` segment lands at its unscaled device position +(proving the CTM was restored, not left scaled). Renders a nested `q`/`cm`/`q`/`cm`/`Q`/`Q` block +whose two `cm`s each scale a different single axis, asserting the drawn segment lands only at the +position both axes' composed scale produces, and explicitly asserting the two single-axis-only +positions a partial-composition bug would instead produce are *not* painted. Renders a stray `Q` +with no matching prior `q`, asserting no exception is thrown (a documented no-op leniency). +Renders a malformed `cm` (wrong operand count), asserting `InvalidDataException`. + +#### CanvasNetPdf-PdfDocument-CtmDerivation: Base CTM Correctly Incorporates MediaBox/Rotate/Requested Size + +**Test**: `CanvasNetPdf_SystemIntegration_PdfRender_RotatedPage_MapsGeometryToCorrectPixelPosition` + +Opens `PdfFixtures/path-construction-rotated-page.pdf` (`/MediaBox [0 0 200 100]`, +`/Rotate 90`, a deliberately asymmetric filled rectangle near the raw MediaBox's bottom-left +corner) and renders it at its rotation-swapped display size (100x200). Asserts the rectangle's +interior is opaque black at its mathematically correct rotated device position, and asserts a +specific pixel where a 270-instead-of-90 rotation-sign regression would incorrectly paint it +instead remains transparent - a fixture and assertion pair specifically designed so a +rotation-sign or origin-offset defect produces a detectably wrong result rather than a merely +shifted-but-still-plausible one. + +#### CanvasNetPdf-PdfDocument-PathConstruction: Path-Construction Operators Build Documented Geometry + +**Tests**: `PdfDocument_PathOps_MoveLineRectCurve_BuildExpectedGeometry`, +`PdfDocument_PathOps_MalformedOperandCount_ThrowsInvalidDataException` (`[Theory]`, one case per +`m`/`l`/`c`/`v`/`y`/`re`), `PdfDocument_PathOps_DrawBeforeMoveTo_ThrowsInvalidDataException` + +Renders, in turn: an explicit `m`/`l`/`h`-built square (filled), a `re`-built rectangle (filled), +a full `c` cubic Bezier (stroked), a `v` shorthand curve (whose first control point defaults to +the current point, stroked), and a `y` shorthand curve (whose second control point defaults to +the endpoint, stroked) - asserting the mathematically exact Bezier-midpoint (or interior/exterior) +pixel each construction is expected to paint. A `[Theory]` exercises a too-few/too-many operand +count for each path-construction operator, asserting `InvalidDataException` in every case. +Renders a bare `l` with no preceding `m`/`re`, asserting `InvalidDataException`. + +#### CanvasNetPdf-PdfDocument-PathPainting: Path-Painting Operators Fill/Stroke/Clear as Documented + +**Tests**: `PdfDocument_PathOps_FillNonZero_PaintsExpectedPixels`, +`PdfDocument_PathOps_FillEvenOdd_PaintsExpectedPixels`, `PdfDocument_PathOps_Stroke_PaintsExpectedPixels`, +`PdfDocument_PathOps_CloseAndFillAndStroke_PaintsExpectedPixels`, +`PdfDocument_PathOps_NoOp_DiscardsPathWithoutPainting`, +`PdfDocument_PathOps_PaintOperator_ClearsPathButPreservesGraphicsState` + +Renders a filled rectangle (`f`), asserting an interior pixel is opaque black and an exterior +pixel remains transparent. Renders two nested, same-winding rectangles (`f*`), asserting the outer +ring is filled but the doubly-covered inner region is left as an even-odd "hole". Renders a +stroked vertical line (`S`), asserting a pixel on the line is opaque black and a pixel beside it +is not. Renders an *open* triangle (no `h`) via `b`, asserting both the filled interior and the +implicit closing edge are painted - directly contrasted, at the same coordinate, against the same +triangle painted with a plain `S` (no closing), which leaves that coordinate untouched. Renders a +rectangle followed by `n`, asserting the entire surface remains fully transparent. Renders two +rectangles filled under the same scaled `cm`, asserting the second path's fill lands only in its +own expected region - proving the first path was cleared after its own `f` rather than +accumulating into the second, while the surrounding CTM was preserved across the clear. + +#### CanvasNetPdf-PdfDocument-Dispose: Dispose Is Idempotent + +**Test**: `PdfDocument_Dispose_CalledTwice_DoesNotThrow` + +Calls `Dispose()` twice on the same instance. Asserts neither call throws. + +#### CanvasNetPdf-PdfDocument-ObjectDisposedException: Every Other Public Member Throws After Dispose + +**Tests**: `PdfDocument_PageCount_AfterDispose_ThrowsObjectDisposedException`, +`PdfDocument_GetPageInfo_AfterDispose_ThrowsObjectDisposedException`, +`PdfDocument_Render_AfterDispose_ThrowsObjectDisposedException` + +Disposes an opened document, then calls `PageCount`, `GetPageInfo`, and `Render`. Asserts each +throws `ObjectDisposedException`. + +#### CanvasNetPdf-PdfDocument-DeviceColorOperators: Device Color Operators Set the Expected Fill/Stroke Color + +**Tests**: `PdfDocument_Color_SetGrayFill_SetsExpectedRgbaColor`, +`PdfDocument_Color_SetGrayStroke_SetsExpectedRgbaColor`, +`PdfDocument_Color_SetRgbFill_SetsExpectedRgbaColor`, +`PdfDocument_Color_SetRgbStroke_SetsExpectedRgbaColor`, +`PdfDocument_Color_SetCmykFill_ConvertsToExpectedRgbaColor`, +`PdfDocument_Color_SetCmykStroke_ConvertsToExpectedRgbaColor`, +`PdfDocument_Color_ComponentValuesOutsideZeroToOne_AreClamped` (`[Theory]`), +`PdfDocument_Color_MalformedOperandCount_ThrowsInvalidDataException` (`[Theory]`, one case per +`g`/`G`/`rg`/`RG`/`k`/`K`/`cs`/`CS`/`sc`/`SC`/`scn`/`SCN`) + +Renders a filled rectangle/stroked line after each of `g`/`G`/`rg`/`RG`/`k`/`K`, asserting the +painted pixel matches the exact expected RGBA conversion (including the CMYK +`R = 255 * (1 - C) * (1 - K)` formula, hand-verified). A `[Theory]` renders `rg` with several +component values outside `[0, 1]`, asserting the painted color matches the clamped (not +rejected) result. A second `[Theory]` renders each operator with a malformed operand count, +asserting `InvalidDataException` in every case. + +#### CanvasNetPdf-PdfDocument-ColorSpaceOperators: cs/CS/sc/scn Track and Apply the Current Color Space + +**Tests**: `PdfDocument_Color_SetColorSpaceFill_DeviceNames_ResetsColorToBlack` (`[Theory]`), +`PdfDocument_Color_SetColorSpaceStroke_DeviceNames_ResetsColorToBlack` (`[Theory]`), +`PdfDocument_Color_SetColorFillUsingCurrentColorSpace_Sc_PaintsExpectedColor`, +`PdfDocument_Color_SetColorStrokeUsingCurrentColorSpace_SC_PaintsExpectedColor`, +`PdfDocument_Color_UnsupportedNamedColorSpace_ThrowsUnsupportedImageFeatureException` (`[Theory]`: +Separation/DeviceN/ICCBased/CalGray/Lab), +`PdfDocument_Color_CalRgbColorSpace_ResolvesToDeviceRgb`, +`CanvasNetPdf_SystemIntegration_PdfRender_ColoredRectangleFill_PaintsExpectedRgbPixels` + +Renders a red-filled rectangle, then a `cs`/`CS` device-name switch (asserted, for all three +device names, to reset color to opaque black), then a second rectangle - proving the reset. Sets +color via `sc`/`SC` against the current (`DeviceRGB`) color space, asserting the expected painted +color. A `[Theory]` selects each unsupported named color +space (declared inline in a `/Resources/ColorSpace` dictionary built via +`BuildSinglePagePdfWithResources`) via `cs`, asserting +`Codecs.UnsupportedImageFeatureException` in every case - including an `/ICCBased` array +referencing object `4` (the page's own content stream, reused as a convenient already-present +stream object), whose dictionary has neither `/N` nor `/Alternate`. The end-to-end +system-integration test independently +proves `rg` painting a real, hand-authored fixture, asserting a specific interior pixel is opaque +red and an exterior pixel remains transparent. `scn`/`SCN` with a trailing pattern name is no +longer a fail-closed case - a `/Pattern` color space resolves and paints the named pattern; see +*CanvasNetPdf-PdfDocument-PatternColorSpace*, *CanvasNetPdf-PdfDocument-ShadingPatternFill*, and +*CanvasNetPdf-PdfDocument-TilingPatternFill* below for that coverage (including the still-rejected +malformed/undeclared/unsupported-pattern-type shapes). + +#### CanvasNetPdf-PdfDocument-IccBasedColorSpace: /ICCBased Resolves via /Alternate or /N, Fails Closed Otherwise + +**Tests**: `PdfDocument_Color_IccBasedN3_ResolvesAsDeviceRgb`, +`PdfDocument_Color_IccBasedWithAlternate_PrefersAlternateOverN`, +`PdfDocument_Color_IccBasedUnsupportedN_ThrowsUnsupportedImageFeatureException` + +Declares `/CS0 [/ICCBased 5 0 R]` in `/Resources/ColorSpace`, where object `5` is a stream whose +dictionary is `<< /N 3 >>` (no `/Alternate`); selects it via `cs` and paints via `scn` with 3 +operands, asserting the painted pixel matches exactly what `DeviceRGB` would paint - proving `/N +3` resolves as `DeviceRGB` (including that `scn`'s own operand-count validation reused +`ComponentCount` correctly for the resolved space, not the array's own shape). Repeats with +object `5`'s dictionary as `<< /N 1 /Alternate /DeviceRGB >>` (an /N that implies `DeviceGray`, +1 component, deliberately contradicting the /Alternate): `scn` with 3 operands succeeds and paints +red, which is only possible if `/Alternate` won over `/N` (had `/N` been used instead, 3 operands +would mismatch the expected 1 and throw `InvalidDataException`), making the precedence +deterministically auditable. Declares object `5`'s dictionary as `<< /N 2 >>` (no `/Alternate`, +and `2` maps to no device color space); asserts `cs` throws +`Codecs.UnsupportedImageFeatureException`. + +#### CanvasNetPdf-PdfDocument-IndexedColorSpace: /Indexed Resolves as a Palette Lookup, Clamps Out-of-Range Indices + +**Tests**: `PdfDocument_Images_DoOperator_IndexedColorSpace_PlacesExpectedPaletteColors`, +`PdfDocument_Color_IndexedColorSpace_Scn_PaintsExpectedPaletteColor`, +`PdfDocument_Color_IndexedColorSpace_OutOfRangeIndex_ClampsToHighestPaletteEntry`, +`PdfDocument_Color_IndexedColorSpace_UnsupportedBase_ThrowsUnsupportedImageFeatureException` + +Places a 2x2 image XObject whose `/ColorSpace` is `[/Indexed /DeviceRGB 2 <...>]` (a 3-entry +red/green/blue palette) with `/BitsPerComponent 8` raw index samples (`0, 1, 2, 2`) via `Do`, +asserting the four composited device pixels match the corresponding palette RGB entries - proving +an image sample is interpreted as a raw, un-normalized palette index (not a `[0, 1]`-divided +component like every other supported `/ColorSpace`). Declares `/CS0 [/Indexed /DeviceRGB 1 <...>]` +(a 2-entry red/green palette) in `/Resources/ColorSpace`, selects it via `cs`, and paints a filled +rectangle via `scn 1` (1 numeric operand, the index), asserting the painted color is the green +palette entry - proving the same `/Indexed` resolution is shared identically between the image +XObject and `cs`/`scn` paths. Repeats with `scn 5` (an index past `Hival = 1`), asserting the +painted color is still the green (highest valid, `Hival`) palette entry rather than throwing. +Declares `/CS0 [/Indexed [/Separation /Spot /DeviceGray 4 0 R] 1 <...>]` (an unsupported base +color space); asserts `cs` throws `Codecs.UnsupportedImageFeatureException`, propagated from the +base color space's own resolution before `/Hival`/the lookup table are ever consulted. + +#### CanvasNetPdf-PdfDocument-FilterPipeline: Filter Pipeline Reverses PNG/TIFF Predictors, Fails Closed Otherwise + +**Tests**: `PdfDocument_Images_FlateDecodePngPredictor_DecodesExpectedPixels`, +`PdfDocument_Images_FlateDecodeTiffPredictor_DecodesExpectedPixels`, +`PdfDocument_Images_LzwDecodePngPredictor_DecodesExpectedPixels`, +`PdfDocument_Images_UnsupportedFilter_ThrowsUnsupportedImageFeatureException` + +Builds a small (2x2 pixel), hand-computed `FlateDecode` stream with a PNG predictor (mixed +`None`/`Up` filter-type rows) and, separately, a TIFF predictor (per-row horizontal-difference +encoding) as an image XObject, rendering it via `Do` and asserting every decoded pixel matches +the value independently hand-derived from the PNG specification's own defilter formulas (not +merely re-deriving the implementation's own output). Repeats the PNG-predictor case with the raw +bytes compressed via `LZWDecode` instead of `FlateDecode` (using a test-only classic-LZW encoder, +never shipped in `src/`), proving predictor reversal is gated on filter name (`FlateDecode` or +`LZWDecode`) rather than merely on `/DecodeParms` presence. Renders an image XObject declaring an +unsupported filter (`/JPXDecode` - still genuinely unsupported, unlike `/LZWDecode`/ +`/CCITTFaxDecode`, both of which this and a later phase respectively implement), asserting +`Codecs.UnsupportedImageFeatureException`. + +#### CanvasNetPdf-PdfDocument-LzwDecodeFilter: LZWDecode Decodes the PDF-Variant Algorithm, Fails Closed on Malformed Input + +**Tests**: `PdfDocument_Filters_LzwDecode_SpecExampleTable7_DecodesExpectedBytes`, +`PdfDocument_Filters_LzwDecode_EarlyChangeDefault_GrowsCodeWidthOneCodeEarly`, +`PdfDocument_Filters_LzwDecode_EarlyChangeZero_GrowsCodeWidthOneCodeLater`, +`PdfDocument_Filters_LzwDecode_ClearCodeMidStream_ReinitializesTable`, +`PdfDocument_Filters_LzwDecode_MissingEodCode_ThrowsInvalidDataException`, +`PdfDocument_Filters_LzwDecode_InvalidCode_ThrowsInvalidDataException`, +`PdfDocument_Images_DoOperator_DeviceGrayLzwDecode_PlacesExpectedPixels`, +`CanvasNetPdf_SystemIntegration_PdfRender_LzwDecodeContentStream_PaintsExpectedPixels` + +Every unit test renders a `width`x1 `DeviceGray` image XObject scaled to exactly fill a +`width`x1 device surface (a 1:1 nearest-neighbor byte-to-pixel mapping), so each decoded byte is +directly inspectable as one device pixel. Decodes the ISO 32000-1/2 section 7.4.4.2 `Table +7`/`EXAMPLE 2` worked vector (`80 0B 60 50 22 0C 0C 85 01`), asserting the exact expected +`"-----A---B"` bytes. Decodes a synthetic (not spec-provided), deterministically generated +320-byte vector - long enough for the dictionary to cross dynamic code 511, the 9-to-10-bit +code-width growth boundary - encoded once with the default (`EarlyChange` absent, meaning `1`) +timing and once with `/EarlyChange 0`, asserting each round-trips correctly with its matching +timing and that decoding the same bytes with the opposite timing throws `InvalidDataException` +(proving the two timings are genuinely different, not merely two names for the same behavior). +Proves a mid-stream Clear code actually reinitializes the dictionary: a hand-packed vector +(`CLEAR, 'A', 'A', 'A', CLEAR, 'B', 260`) asserts `InvalidDataException`, since code `260` (which +would still mean `"AA"` if the table were *not* reset) is not yet present in a properly reset +table. Asserts `InvalidDataException` for a bit stream truncated before an EOD code, and for an +invalid/out-of-range code. Places a plain (unpredicted) `LZWDecode` `DeviceGray` image via `Do`, +asserting the composited pixels. The end-to-end system-integration test independently proves +`LZWDecode` decoding a real page `/Contents` stream, asserting a specific interior pixel of the +resulting filled rectangle is opaque red and an exterior pixel remains transparent. + +#### CanvasNetPdf-PdfDocument-AsciiDecodeFilters: ASCII85Decode/ASCIIHexDecode Decode Per Spec, Fail Closed + +**Tests**: `PdfDocument_Filters_Ascii85Decode_ZeroGroup_DecodesToFourZeroBytes`, +`PdfDocument_Filters_Ascii85Decode_FullGroup_DecodesExpectedBytes`, +`PdfDocument_Filters_Ascii85Decode_PartialFinalGroup_AppliesPaddingRule`, +`PdfDocument_Filters_Ascii85Decode_WhitespaceIgnored_DecodesExpectedBytes`, +`PdfDocument_Filters_Ascii85Decode_ZInMiddleOfGroup_ThrowsInvalidDataException`, +`PdfDocument_Filters_Ascii85Decode_ValueExceedsRange_ThrowsInvalidDataException`, +`PdfDocument_Filters_Ascii85Decode_MissingEodMarker_ThrowsInvalidDataException`, +`PdfDocument_Filters_AsciiHexDecode_HexDigitPairs_DecodesExpectedBytes`, +`PdfDocument_Filters_AsciiHexDecode_OddTrailingDigit_PadsWithZeroNibble`, +`PdfDocument_Filters_AsciiHexDecode_WhitespaceIgnored_DecodesExpectedBytes`, +`PdfDocument_Filters_AsciiHexDecode_InvalidCharacter_ThrowsInvalidDataException`, +`PdfDocument_Filters_AsciiHexDecode_MissingEodMarker_ThrowsInvalidDataException`, +`CanvasNetPdf_SystemIntegration_PdfRender_Ascii85DecodeContentStream_PaintsExpectedPixels`, +`CanvasNetPdf_SystemIntegration_PdfRender_AsciiHexDecodeContentStream_PaintsExpectedPixels` + +Using the same `width`x1 `DeviceGray`-image inspection technique as the LZWDecode tests above, +decodes the `z` all-zero-group shorthand; a full 5-character group and the self-derived (per the +spec's own section 7.4.3 formula, since the ISO 32000-1 text has no isolated numbered ASCII85 +example) `00 01 02 03` โ†’ `!!*-'` vector; the final-partial-group padding rule via the self-derived +2-byte (`4D 61` โ†’ `9jn`) and 3-byte (`4D 61 6E` โ†’ `9jqo`) vectors; and that interspersed PDF +white-space characters are ignored. Asserts `InvalidDataException` for a `z` occurring mid-group, +a 5-character group whose base-85 value exceeds `2^32 - 1` (`uuuuu`, all-`u` digits), and a +missing `~>` EOD marker. Decodes ASCIIHexDecode hex-digit pairs, an odd trailing digit (implicitly +padded with a zero nibble), and interspersed whitespace; asserts `InvalidDataException` for an +invalid character and a missing `>` EOD marker. The two end-to-end system-integration tests +independently prove each filter decoding a real page `/Contents` stream, asserting the expected +filled-rectangle pixels. + +#### CanvasNetPdf-PdfDocument-RunLengthDecodeFilter: RunLengthDecode Implements the PackBits Scheme, Fails Closed on Truncation + +**Tests**: `PdfDocument_Filters_RunLengthDecode_LiteralRun_CopiesBytesVerbatim`, +`PdfDocument_Filters_RunLengthDecode_RepeatRun_RepeatsSingleByte`, +`PdfDocument_Filters_RunLengthDecode_EodMarker_StopsDecoding`, +`PdfDocument_Filters_RunLengthDecode_TruncatedRun_ThrowsInvalidDataException`, +`CanvasNetPdf_SystemIntegration_PdfRender_RunLengthDecodeContentStream_PaintsExpectedPixels` + +Using the same `width`x1 `DeviceGray`-image inspection technique, decodes a literal run (length +byte 0-127 copies the following `length + 1` bytes verbatim) and a repeat run (length byte +129-255 repeats the following single byte `257 - length` times), asserts the `128` EOD length +byte stops decoding and ignores any trailing bytes after it, and asserts `InvalidDataException` +for a literal run that declares more bytes than remain (truncated, no EOD reached). The +end-to-end system-integration test independently proves `RunLengthDecode` decoding a real page +`/Contents` stream, asserting the expected filled-rectangle pixels. + +#### CanvasNetPdf-PdfDocument-RunLengthDecodeOutputCap: Oversized Expansion Fails Closed Before Full Decode + +**Test**: `PdfDocument_Filters_RunLengthDecode_OutputExceedsMaxSize_ThrowsInvalidDataException` + +Builds a RunLengthDecode-filtered stream of crafted repeat runs whose fully-expanded output would +exceed the fixed maximum decoded-output size, asserting `InvalidDataException` is thrown - +proving the output-size budget is checked before appending each run (not only after the entire +stream has already been expanded into memory), closing the decompression-bomb-style resource- +exhaustion attack surface a 64x-amplifying filter like RunLengthDecode would otherwise expose. + +#### CanvasNetPdf-PdfDocument-CcittFaxDecodeFilter: CCITTFaxDecode Implements T.6 Group 4 MMR, Fails Closed Otherwise + +**Tests**: `PdfDocument_Images_DoOperator_CcittFaxGroup4_PlacesExpectedPixels`, +`PdfDocument_Images_DoOperator_CcittFaxBlackIs1Default_MapsZeroBitToBlack`, +`PdfDocument_Images_DoOperator_CcittFaxBlackIs1True_InvertsPolarity`, +`PdfDocument_Images_DoOperator_CcittFaxNonByteAlignedColumns_DecodesExpectedRowPadding`, +`PdfDocument_Images_CcittFaxGroup3K_ThrowsUnsupportedImageFeatureException`, +`PdfDocument_Images_CcittFaxCombinedWithAnotherFilter_ThrowsInvalidDataException`, +`PdfDocument_Images_CcittFaxEndOfLineTrue_ThrowsUnsupportedImageFeatureException`, +`PdfDocument_Filters_CcittFaxRunLengthTables_RoundTripEveryCode`, +`PdfDocument_Filters_CcittFaxModeCodeTable_RoundTripEveryCode`, +`PdfDocument_Images_DoOperator_CcittFaxPassModeElement_PlacesExpectedPixels`, +`PdfDocument_Images_DoOperator_CcittFaxEncodedByteAlignTrue_SkipsRowPaddingBits` + +Uses a small test-only Group 4 (T.6) MMR encoder (parameterized over an arbitrary `bool[,]` pixel +grid, mirroring the existing test-only classic-LZW encoder's precedent) to produce trustworthy +encoded bytes, cross-checked against a hand-derived, bit-level-documented literal encoding of a +known small pattern (see the code comment at the literal byte array's declaration for the full +worked-through reasoning) so that the round-trip tests cannot merely be checking an encoder bug +against a matching decoder bug. `PdfDocument_Images_DoOperator_CcittFaxGroup4_PlacesExpectedPixels` +places a Group-4-encoded image XObject via `cm`/`Do` and asserts the full-page-rendered device +pixels match the known source black/white pattern at specific `(x, y)` coordinates. +`PdfDocument_Images_DoOperator_CcittFaxBlackIs1Default_MapsZeroBitToBlack` and +`..._CcittFaxBlackIs1True_InvertsPolarity` assert the default (`/BlackIs1` absent or `false`) and +explicit `/BlackIs1 true` cases each produce the opposite pixel polarity for the identical encoded +bit stream, proving `/BlackIs1` is honored rather than ignored. +`..._CcittFaxNonByteAlignedColumns_DecodesExpectedRowPadding` uses `/Columns` values (`10`, `20`) +that do not divide evenly into whole bytes, asserting every decoded row is still exactly +`(Columns + 7) / 8` packed bytes and that the unused trailing padding bits of the final byte do +not leak into the next row's pixels. `..._CcittFaxGroup3K_ThrowsUnsupportedImageFeatureException` +asserts a non-negative `/K` (Group 3, including the `/K` default of `0` when `/DecodeParms` omits +it entirely) throws `UnsupportedImageFeatureException` with a clear message rather than being +misdecoded as Group 4. `..._CcittFaxCombinedWithAnotherFilter_ThrowsInvalidDataException` asserts +a `/Filter` array naming `CCITTFaxDecode` alongside another filter throws `InvalidDataException`, +mirroring the equivalent `DCTDecode`-combined-with-another-filter check. +`..._CcittFaxEndOfLineTrue_ThrowsUnsupportedImageFeatureException` asserts an explicit +`/EndOfLine true` is rejected rather than silently ignored (this implementation never scans for +EOL/EOFB/RTC bit patterns at all). `PdfDocument_Filters_CcittFaxRunLengthTables_RoundTripEveryCode` +is an internals-only table self-consistency test (enabled by `InternalsVisibleTo`): for every +(run length, code bits, code length) entry in both the White and Black Modified Huffman +run-length code tables, it writes the code's bits through `CcittBitReader` and asserts +`ReadVariableLengthCode` recovers the exact same run length, proving the prefix-free code tables +are internally consistent (no entry is a prefix of, or shares a prefix with a different length +than, another) independently of whether any single encoded test image happens to exercise that +particular code. `PdfDocument_Filters_CcittFaxModeCodeTable_RoundTripEveryCode` mirrors that same +internals-only table self-consistency pattern for `ModeCodeTable` (also made `internal` for this +purpose): for all nine `(mode code bits, code length)` entries (Pass, Horizontal, V0, VR1-3, +VL1-3), it writes the code's bits through `CcittBitReader` and asserts `ReadMode` recovers the +exact same mode, closing the gap that `VR1`, `VR3`, `VL2`, and `VL3` were otherwise never +exercised by any rendered test image. +`PdfDocument_Images_DoOperator_CcittFaxPassModeElement_PlacesExpectedPixels` uses a dedicated, +hand-derived literal encoded byte array (independently re-verified via a from-scratch Python +re-implementation of `ReadMode`/`FindB1B2`/`DecodeCcittRow`, stepped through to confirm it +actually decodes a `Pass` mode element) for an 8x2 pattern whose second row has no changing +element of its own, forcing the decoder down the `Pass` branch, and asserts the rendered pixels +match the known pattern. `PdfDocument_Images_DoOperator_CcittFaxEncodedByteAlignTrue_SkipsRowPaddingBits` +reuses that same pattern's bit encoding but inserts deliberately non-zero garbage padding bits +between row 0 and row 1 and sets `/EncodedByteAlign true`, asserting the decoder still recovers +the exact same expected pixels - proving `AlignToByte` is actually invoked to skip the garbage +bits before each row, rather than the implementation coincidentally tolerating zero padding only. + +#### CanvasNetPdf-PdfDocument-ImageXObjects: Do Composites Images, Fails Closed on Unsupported Features + +**Tests**: `PdfDocument_Images_DoOperator_DeviceGrayFlateDecode_PlacesExpectedPixels`, +`PdfDocument_Images_DoOperator_DeviceRgbFlateDecode_PlacesExpectedPixels`, +`PdfDocument_Images_DoOperator_DeviceCmykFlateDecode_PlacesExpectedPixels`, +`PdfDocument_Images_DoOperator_DctDecodeJpeg_PlacesExpectedPixels`, +`PdfDocument_Images_UnsupportedBitsPerComponent_ThrowsUnsupportedImageFeatureException`, +`PdfDocument_Images_UnsupportedColorSpace_ThrowsUnsupportedImageFeatureException`, +`PdfDocument_Images_DoOperator_UndefinedXObjectName_ThrowsInvalidDataException`, +`PdfDocument_Images_DoOperator_MalformedOperandCount_ThrowsInvalidDataException`, +`CanvasNetPdf_SystemIntegration_PdfRender_ImageXObjectPlacement_CompositesExpectedPixels` + +Places a small, raw 8-bit `DeviceGray`/`DeviceRGB`/`DeviceCMYK` `FlateDecode` image XObject via +`cm`/`Do`, asserting the four composited device pixels match the image's four known source +pixels (proving both the sample-to-color conversion and the unit-square-to-device-space mapping, +including the PDF image-space row-0-is-top convention). Places a bare `DCTDecode` (JPEG) image +XObject (an 8x8 solid-color surface encoded via `Codecs.JpegCodec.Save` at test-run time, quality +100 - a flat color block's DCT has only a DC coefficient, so the round-trip reproduces it within +a small per-channel tolerance), asserting the decoded/composited pixel matches within that +tolerance. Renders an image XObject with an unsupported `/BitsPerComponent` (`1`) and, separately, +an unsupported `/ColorSpace` (`/Lab`), each asserting +`Codecs.UnsupportedImageFeatureException`. Renders `Do` with a name undeclared in +`/Resources/XObject`, asserting `InvalidDataException`. A `[Theory]` +renders `Do` with a malformed operand count/type, asserting `InvalidDataException` in every case. +The end-to-end system-integration test independently proves the same `Do` compositing against a +real, hand-authored fixture, asserting specific composited pixel colors at specific coordinates +matching the fixture's known 2x2 source image, and a pixel outside the placed image's +device-space footprint remains transparent. + +#### CanvasNetPdf-PdfDocument-FormXObjects: Do Executes Nested Form XObject Content Streams + +**Tests**: `PdfDocument_Images_DoOperator_FormXObject_PaintsNestedContentStream`, +`PdfDocument_Images_DoOperator_FormXObjectWithMatrix_AppliesMatrixToNestedContent`, +`PdfDocument_Images_DoOperator_FormXObjectWithoutOwnResources_FallsBackToInvokingResources`, +`PdfDocument_Images_DoOperator_FormXObjectWithOwnResources_TakesPrecedenceOverInvokingResources`, +`PdfDocument_Images_DoOperator_NestedFormXObjects_RestoresGraphicsStateAfterReturn`, +`PdfDocument_Images_DoOperator_FormXObjectExceedsMaxRecursionDepth_ThrowsInvalidDataException` + +Places a `/Subtype /Form` XObject whose own content stream fills a rectangle, asserting the +rectangle's device footprint is painted and a pixel outside it remains blank - proving the Form's +decoded content bytes are actually executed, not skipped. Places a Form XObject declaring its own +`/Matrix` (a translation), asserting the rectangle lands at the matrix-translated device position +and that its un-translated position is left unpainted - proving `/Matrix` is concatenated into the +CTM using the same left-multiply convention as `cm`. Places a Form XObject with no `/Resources` of +its own that references a `/ColorSpace` name declared only in the invoking page's `/Resources`, +asserting the expected color is painted - proving resource-scope fallback. Places a Form XObject +whose own `/Resources` declares the same color-space name differently (a different +component-count color space) than the invoking page's `/Resources`, asserting the Form's own +definition is used (a wrong-arity `sc` would otherwise throw `InvalidDataException`) - proving +resource-scope precedence. Places a two-level-deep nested Form XObject (an outer Form invoked by +the page, itself invoking an inner Form, with a `cm` scale applied along the way), asserting the +inner Form's content paints at the correctly compounded transform and that the invoking page's own +painting performed after the outer `Do` returns lands at its own normal, unscaled position - +proving the nested CTM mutations do not leak back out. Renders a Form XObject that invokes itself +by name (falling back to the invoking page's `/Resources/XObject`), asserting +`InvalidDataException` once nesting exceeds the maximum supported depth, rather than hanging or +crashing the test process. + +#### CanvasNetPdf-PdfDocument-FontResolution: Font Resolution Dispatches TrueType/Type1 Simple Fonts + +**Tests**: `PdfDocument_Fonts_UnsupportedSubtype_ThrowsUnsupportedImageFeatureException`, +`PdfDocument_Fonts_UndefinedFontName_ThrowsInvalidDataException`, +`PdfDocument_BuildResolvedFont_EmbeddedFontFileTakesPriorityOverFallback`, +`PdfDocument_Fonts_Type1_DispatchesToSimpleFontResolution_PaintsGlyphInk`, +`PdfDocument_Fonts_Type1_NoFontFile_ResolvesViaFallback`, +`PdfDocument_Fonts_SimpleFont_NoFontDescriptorAtAll_ResolvesViaFallback` + +A `[Theory]` builds a `/Resources/Font` dictionary declaring each excluded subtype +(`/MMType1`) in turn and selects it via `Tf`, asserting +`Codecs.UnsupportedImageFeatureException` in every case. Selects a font name absent from +`/Resources/Font` via `Tf`, asserting `InvalidDataException`. Builds a `/Subtype /TrueType` font +dictionary with both an embedded `/FontFile2` stream and a deliberately unmatched `/BaseFont` +name, asserting the embedded synthetic font's own known glyph shape is what gets rendered - not a +fallback font's differently-shaped glyph - proving an embedded font always wins over fallback +substitution. As of Phase 6, a `/Subtype /TrueType` font lacking an embedded `/FontFile2` no +longer fails closed here at all; see `CanvasNetPdf-PdfDocument-FontFallback` below for its +resolution. As of Phase 9, a `/Subtype /Type0` composite font no longer fails closed here either; +see `CanvasNetPdf-PdfDocument-CompositeFontDispatch` immediately below for its own resolution +and remaining fail-closed cases. As of Phase B, `/Subtype /Type1` is no longer listed among the +excluded subtypes at all: a dedicated test proves a `/Type1` +font dictionary dispatches to `BuildResolvedSimpleFont` and paints its embedded Type 1 program's +own glyph ink, and a second proves a `/Type1` font with no `/FontFile` resolves via +`ResolveFallbackFont` exactly like an equivalent `/TrueType` font - see +`CanvasNetPdf-PdfDocument-Type1FontResolution` below for the full embedded-`/FontFile` +resolution and its own fail-closed cases. A further test builds a font dictionary with no +`/FontDescriptor` entry at all (as PDF 32000-1 ยง9.6.2.2 permits for the standard 14 fonts, and +several real-world producers - for example ReportLab - emit), asserting it resolves via +`ResolveFallbackFont` rather than throwing `InvalidDataException`. As of Phase D, `/Subtype +/Type3` is likewise no longer listed among the excluded subtypes (only `/MMType1` remains) - see +`CanvasNetPdf-PdfDocument-Type3FontResolution`/`CanvasNetPdf-PdfDocument-Type3GlyphPainting` +below for its own resolution and glyph-painting tests. + +#### CanvasNetPdf-PdfDocument-CompositeFontDispatch: Type0/Identity-H Dispatch and Encoding Validation + +**Tests**: `PdfDocument_Fonts_Type0_NonIdentityHEncoding_ThrowsUnsupportedImageFeatureException`, +`PdfDocument_Fonts_Type0_MissingDescendantFonts_ThrowsInvalidDataException`, +`PdfDocument_Fonts_Type0_DescendantFontsNotSingleElement_ThrowsInvalidDataException`, +`PdfDocument_ShowText_Type0_OddByteLengthString_ThrowsInvalidDataException`, +`PdfDocument_ShowText_Type0_TwoByteCodes_ShowsEachGlyphAtCorrectPosition` + +A `[Theory]` declares `/Encoding` as an arbitrary other name and, separately, `/Identity-V`, +asserting `Codecs.UnsupportedImageFeatureException` in both cases. Declares a Type0 font +dictionary with no `/DescendantFonts` and, separately, a `/DescendantFonts` array with two +elements, asserting `InvalidDataException` in both cases. Shows an odd-byte-length string +against a composite font via `Tj`, asserting `InvalidDataException`. Shows two 2-byte codes via +`Tj`, asserting each glyph is painted at the position its own declared/default width determines - +proving the shared `/Identity-H` 2-byte decode and descendant-font dispatch is validated and +exercised independently of which descendant font subtype is ultimately resolved. + +#### CanvasNetPdf-PdfDocument-CidFontType2FontResolution: CIDFontType2 TrueType Descendant Resolves via CIDToGIDMap + +**Tests**: `PdfDocument_Fonts_Type0_IdentityHCidFontType2_ResolvesEmbeddedFont`, +`PdfDocument_Fonts_Type0_CidToGidMapIdentity_UsesCidAsGid`, +`PdfDocument_Fonts_Type0_CidToGidMapStream_RemapsCidToGid`, +`PdfDocument_Fonts_Type0_CidToGidMapStream_OutOfRangeCid_MapsToNotdef`, +`PdfDocument_Fonts_Type0_NoEmbeddedFontFile_ThrowsInvalidDataException`, +`CanvasNetPdf_SystemIntegration_RenderType0CompositeFont_PaintsExpectedGlyphInk` + +Builds a `/Subtype /Type0`/`/Encoding /Identity-H` font dictionary naming a single +`/Subtype /CIDFontType2` descendant font with an embedded `/FontFile2`, asserting resolution +succeeds and the embedded font is what gets used. Declares a descendant `/CIDToGIDMap` of the +name `/Identity`, asserting a shown CID maps to the identical glyph index; separately declares a +`/CIDToGIDMap` stream remapping a CID to a different glyph index, asserting the remapped (not +identical) glyph index is used, and that a CID beyond the stream's own table length maps to glyph +`0`/`.notdef`. Declares a descendant font with no embedded `/FontFile2` (still `/CIDFontType2`), +asserting `InvalidDataException` (not `UnsupportedImageFeatureException` - a composite font has +no fallback substitution path). The system-integration test opens a hand-authored fixture +(`text-composite-truetype-identity-h.pdf`) declaring a real embedded Open Sans descendant font +with a deliberately non-identity `/CIDToGIDMap`, independently reloads the same font, and asserts +specific stroke/counter/corner pixels - proving the full 2-byte Identity-H code โ†’ CID โ†’ GID (via +the non-identity `/CIDToGIDMap`) โ†’ TrueType glyph outline โ†’ painted-pixel pipeline end to end. + +#### CanvasNetPdf-PdfDocument-CidFontType0FontResolution: CIDFontType0 CFF Descendant Resolves via Shape Sniffing + +**Tests**: `PdfDocument_Fonts_Type0_CidFontType0_NoFontFile3_ThrowsInvalidDataException`, +`PdfDocument_Fonts_Type0_CidFontType0_OpenTypeCff_ResolvesEmbeddedFont`, +`PdfDocument_Fonts_Type0_CidFontType0_NonStandardCidToGidMap_IsIgnored`, +`PdfDocument_Fonts_Type0_CidFontType0_MismatchedSubtype_SfntBytes_ResolvesEmbeddedFont`, +`PdfDocument_Fonts_Type0_CidFontType0_MissingFontFile3Subtype_ResolvesEmbeddedFont`, +`PdfDocument_Fonts_Type0_CidFontType0_UnrecognizedFontFile3Bytes_ThrowsUnsupportedImageFeatureException`, +`PdfDocument_Fonts_Type0_CidFontType0_CidKeyedCff_ThrowsInvalidDataException`, +`CanvasNetPdf_SystemIntegration_RenderCidFontType0CompositeFont_PaintsExpectedGlyphInk` + +Declares a `/Subtype /CIDFontType0` descendant whose `/FontDescriptor` has only `/FontFile2` (no +`/FontFile3`), asserting `InvalidDataException` (not `UnsupportedImageFeatureException` - the +subtype itself is supported; only the missing embedded font program fails). Builds a synthetic, +non-CID-keyed, `/OpenType`-wrapped CFF `/FontFile3` (via `SyntheticFontBuilder.Cff`) on a +`/CIDFontType0` descendant, asserting resolution succeeds and real glyph ink paints via identity +CID-to-glyph-index; separately declares a non-standard `/CIDToGIDMap /Identity` entry on such a +descendant, asserting it is ignored (the identity map is used regardless, since `/CIDToGIDMap` +is not a valid key for this subtype). `LoadCidFontType0Font` sniffs the `/FontFile3` stream's own +decoded bytes for a recognized SFNT container or a structurally plausible bare CFF header rather +than gating on the stream's declared `/Subtype`: a `[Theory]` builds a well-formed, SFNT-wrapped +(`'OTTO'`) CFF program and declares the `/FontFile3` stream's own `/Subtype` as `/Type1C` and, +separately, `/CIDFontType0C` - a deliberate mismatch between the declared name and the stream's +actual SFNT-wrapped shape - asserting resolution still succeeds and real glyph ink paints; a +further test omits the `/Subtype` key from the `/FontFile3` stream entirely (same SFNT-wrapped +bytes), asserting the same successful resolution (a missing `/Subtype` is no longer treated any +differently than a mismatched one, since the declared name is never consulted for dispatch). A +separate test supplies decoded bytes that are neither a recognized SFNT container nor a +structurally plausible bare CFF header (a short synthetic literal byte sequence, not derived from +any real-world font), asserting `Codecs.UnsupportedImageFeatureException` - the one shape +genuinely still rejected. Builds an `/OpenType`-wrapped `/FontFile3` whose embedded CFF program's +Top DICT declares `ROS` (CID-keyed CFF, via `SyntheticFontBuilder.Cff(..., includeRos: true)`), +asserting `InvalidDataException` (surfaced uncaught from `Fonts.CffTable.Parse`'s own existing +CID-keyed rejection, with no new translation code in the `Pdf` subsystem). The +system-integration test opens an entirely synthetic fixture +(`text-composite-cff-cidfonttype0-identity-h.pdf`) whose `/CIDFontType0` descendant embeds a +synthetic, non-CID-keyed, `/OpenType`-wrapped CFF program with no `/CIDToGIDMap` declared, and +asserts real glyph ink paints at the analytically-known position of its hand-designed square +glyph - proving the full end-to-end pipeline for the `/CIDFontType0` shape; this is the +dedicated `/CIDFontType0` composite-font requirement closing the previously undocumented +`/CIDFontType0` gap. + +#### CanvasNetPdf-PdfDocument-CompositeFontWidths: Shared /DW//W Width Resolution Across Descendant Subtypes + +**Tests**: `PdfDocument_Fonts_Type0_Widths_DwDefault1000_DeterminesAdvance`, +`PdfDocument_Fonts_Type0_Widths_WArrayIndividualForm_DeterminesAdvance`, +`PdfDocument_Fonts_Type0_Widths_WArrayRangeForm_DeterminesAdvance`, +`PdfDocument_Fonts_Type0_Widths_MalformedWArray_ThrowsInvalidDataException`, +`PdfDocument_Fonts_Type0_CidFontType0_Widths_WArrayIndividualForm_DeterminesAdvance` + +Declares a descendant font with no `/W` array, asserting the shown code's advance matches the +default `/DW` of `1000`; separately declares a `/W` array using the `c [w1 w2 ... wn]` +individual-width sub-form and, separately, the `cFirst cLast w` range sub-form, asserting the +shown code's advance matches the declared width in each case; declares a malformed `/W` array +shape, asserting `InvalidDataException`. A further test repeats the individual-width sub-form +assertion against a `/CIDFontType0` descendant - proving the same `/DW`/`/W` width-resolution +logic applies identically regardless of which descendant-font outline flavor is resolved. + +#### CanvasNetPdf-PdfDocument-Type1FontResolution: Embedded Classic Type 1 FontFile Resolves, Fails Closed Otherwise + +**Tests**: `PdfDocument_Fonts_Type1_DispatchesToSimpleFontResolution_PaintsGlyphInk`, +`PdfDocument_Fonts_Type1_EmbeddedFontFileTakesPriorityOverFallback`, +`PdfDocument_Fonts_Type1_NoFontFile_ResolvesViaFallback`, +`PdfDocument_Fonts_Type1_FontFileMissingLength1_ThrowsInvalidDataException`, +`PdfDocument_Fonts_Type1_FontFileMissingLength2_ThrowsInvalidDataException`, +`PdfDocument_Fonts_Type1_FontFileNonNumericLength2_ThrowsInvalidDataException`, +`PdfDocument_Load_TextEmbeddedType1FontFixture_PaintsVisibleGlyphInk`, +`PdfDocument_Load_TextStandard14Type1NoFontFileFixture_PaintsVisibleSubstituteGlyphInk`, +`CanvasNetPdf_SystemIntegration_RenderEmbeddedType1Font_PaintsExpectedGlyphInk` + +Builds a `/Subtype /Type1` font dictionary with an embedded, entirely synthetic, classic +PostScript `/FontDescriptor/FontFile` program (via `SyntheticFontBuilder.Type1`, declaring +`.notdef`/`space`/`A` glyphs, `A` a filled square outline), asserting the embedded font's own +square glyph paints at the position its font-design-space coordinates and text-space +placement determine - proving `LoadType1Font`'s decode/`Fonts.TrueTypeFont.LoadType1`/ +`ResolveEncoding`-via-`CodepointToStandardGlyphName` pipeline resolves end to end. A second test +builds the same embedded program under a deliberately unmatched `/BaseFont` family name, +asserting the embedded font's own known glyph shape (not a fallback font's differently-shaped +glyph) is what gets painted - proving an embedded `/FontFile` always wins over fallback +substitution, exactly like the `/FontFile2` precedent. A `/Subtype /Type1` font dictionary with a +recognized Standard-14 `/BaseFont` and no `/FontFile`/`/FontFile2`/`/FontFile3` at all asserts +resolution succeeds via `ResolveFallbackFont` and paints visible substitute glyph ink, mirroring +the `/TrueType` equivalent in `CanvasNetPdf-PdfDocument-FontFallback`. As of Phase C, a `/Subtype +/Type1` descriptor declaring only `/FontFile3` (no `/FontFile`/`/FontFile2`) no longer fails +closed here at all; see `CanvasNetPdf-PdfDocument-Type1CFontResolution` immediately below for its +own resolution and fail-closed cases. An embedded `/FontFile` stream declaring only `/Length2` (no +`/Length1` at all) asserts +`InvalidDataException`; separately, a `BuildEmbeddedType1FontResources(omitLength2: true)` +stream declaring only `/Length1` asserts `InvalidDataException` too; separately, a stream +declaring a non-numeric `/Length2` value (`/NotANumber`) asserts `InvalidDataException` as well - +proving all three malformed-length cases fail closed, and that both lengths are read from the +`/FontFile` stream's own dictionary (never the descriptor's). Two fixture-conformance tests open +the new hand-authored, entirely synthetic `text-embedded-type1-font.pdf` and +`text-standard14-type1-no-fontfile.pdf` fixtures (see `PdfFixtures\README.md`) and assert visible +painted ink from each. The end-to-end system-integration test opens +`text-embedded-type1-font.pdf` and asserts the analytically-known painted-pixel position of its +hand-designed square glyph (matching `CanvasNetPdf-PdfDocument-CidFontType0FontResolution`'s own +`CIDFontType0` system-integration test's pixel-assertion convention exactly, since both fixtures +share the identical glyph design/placement), plus fully-transparent canvas corners. + +#### CanvasNetPdf-PdfDocument-Type1CFontResolution: Type1C/CFF FontFile3 Resolves via Shape Sniffing, Fails Closed + +**Tests**: `PdfDocument_Fonts_Type1_FontFile3Type1C_ResolvesEmbeddedFont_PaintsGlyphInk`, +`PdfDocument_Fonts_Type1_MismatchedSubtype_BareCffBytes_ResolvesEmbeddedFont`, +`PdfDocument_Fonts_Type1_MissingFontFile3Subtype_ResolvesEmbeddedFont`, +`PdfDocument_Fonts_Type1_UnrecognizedFontFile3Bytes_ThrowsUnsupportedImageFeatureException` + +Builds a `/Subtype /Type1` font dictionary with an embedded, entirely synthetic, bare Type1C/CFF +`/FontDescriptor/FontFile3` stream (via `SyntheticFontBuilder.Cff`, declaring a `/Subtype +/Type1C` stream dictionary, a custom charset mapping a filled-square `A` glyph, and no SFNT/ +OpenType wrapper of any kind), asserting the embedded font's own square glyph paints at the +position its font-design-space coordinates and text-space placement determine - proving +`LoadType1CFont`'s decode/`Fonts.TrueTypeFont.LoadType1C`/`ResolveEncoding`-via- +`CodepointToStandardGlyphName` pipeline resolves end to end, reached only because `/FontFile` is +absent (`LoadType1Font`'s own loader never runs). As of Phase 18, `LoadType1CFont` sniffs the +`/FontFile3` stream's own decoded bytes for a recognized SFNT container or a structurally +plausible bare CFF header rather than gating on the stream's declared `/Subtype`: a `[Theory]` +builds the same well-formed, bare (non-SFNT-wrapped) CFF program and declares the `/FontFile3` +stream's own `/Subtype` as `/OpenType` and, separately, `/CIDFontType0C` - a deliberate mismatch +between the declared name and the stream's actual bare-CFF shape - asserting resolution still +succeeds and the embedded square glyph's own ink paints, exactly like the matching-subtype case +above; a further test omits `/Subtype` from the `/FontFile3` stream entirely (same bare-CFF +bytes), asserting the same successful resolution (a missing `/Subtype` is no longer treated any +differently than a mismatched one, since the declared name is never consulted for dispatch) - +mirroring `CanvasNetPdf-PdfDocument-CidFontType0FontResolution`'s own shape-sniffing tests for +`/CIDFontType0`. A separate test supplies decoded bytes that are neither a recognized SFNT +container nor a structurally plausible bare CFF header (a short synthetic literal byte sequence, +not derived from any real-world font), asserting +`Codecs.UnsupportedImageFeatureException` with `Feature == +"pdf-font-fontfile3-unrecognized-shape"` - the one shape genuinely still rejected, regardless of +the stream's declared `/Subtype`. + +#### CanvasNetPdf-PdfDocument-Type3FontResolution: Type3 Fonts Resolve Required Fields, Fail Closed on Malformed Ones + +**Tests**: `PdfDocument_Fonts_Type3_MinimalFont_DifferencesAndCharProcs_PaintsGlyphInk`, +`PdfDocument_Fonts_Type3_Widths_ScaledViaFontMatrix_DeterminesAdvance`, +`PdfDocument_Fonts_Type3_UnmappedCode_NoDifferencesEntry_PaintsNothingButAdvances`, +`PdfDocument_Fonts_Type3_MappedGlyphNameAbsentFromCharProcs_PaintsNothingButAdvances`, +`PdfDocument_Fonts_Type3_MissingFontMatrix_ThrowsInvalidDataException`, +`PdfDocument_Fonts_Type3_MissingCharProcs_ThrowsInvalidDataException`, +`PdfDocument_Fonts_Type3_FontMatrixWrongArrayLength_ThrowsInvalidDataException`, +`PdfDocument_Fonts_Type3_FontMatrixNonNumberEntry_ThrowsInvalidDataException`, +`PdfDocument_Fonts_Type3_CharProcsNotADictionary_ThrowsInvalidDataException` + +Builds a minimal synthetic `/Subtype /Type3` font (via the new `BuildType3FontResources` helper, +mirroring `BuildEmbeddedType1CFontResources`'s own established pattern) declaring a single glyph +(code `65`/`'A'`, mapped via an inline `/Encoding`/`/Differences` entry to glyph name `/Square`, +whose `/CharProcs` content stream paints a filled design-space rectangle) and asserts `Tj` paints +that rectangle's ink at the device location its default `/FontMatrix` +(`[0.001 0 0 0.001 0 0]`) scales it to - the identical pixel assertion the embedded Type1/ +TrueType font tests already use for the same design-space rectangle, proving `BuildResolvedFont`'s +`/Type3` dispatch and `PaintType3Glyph`'s basic glyph-procedure execution end to end. A second +test shows two glyphs in one `Tj` under a deliberately non-conventional `/FontMatrix` +(`[0.0025 0 0 0.001 0 0]`) and a `/Widths` entry of `1000`, asserting the second glyph's device +position reflects the first glyph's `/FontMatrix`-scaled advance (`1000 * 0.0025 * 20pt = 50` +units) and explicitly asserting the second glyph does *not* appear at the position a +`/1000`-divided-width convention (the simple/composite-font convention) would have produced +instead - proving `ResolvedType3Font.Resolve`'s width computation is genuinely `/FontMatrix`- +scaled, not merely accepting and ignoring `/FontMatrix` in favor of the ordinary `/1000` +convention. Two further tests each show an unmapped glyph (one via a character code entirely +absent from `/Differences`, one via a code mapped to a glyph name absent from `/CharProcs`) +followed by a second, correctly-mapped glyph, asserting no exception is thrown, nothing paints +for the unmapped glyph, and the second glyph's device position still reflects the unmapped code's +own declared `/Widths` advance - proving both missing-glyph leniency cases advance correctly +without painting. Two further tests each build a `/Subtype /Type3` font dictionary missing (one) +`/FontMatrix` or (the other) `/CharProcs` entirely, asserting `InvalidDataException` in both +cases - proving these two required fields fail closed, unlike every other resolved font kind's +embedded-font-absent fallback-substitution path (a Type 3 font has no such fallback at all). A +further `[Theory]` test supplies a `/FontMatrix` array of the wrong length (5 or 7 elements +instead of exactly 6), and a companion test supplies a `/FontMatrix` array whose entries include a +name (`/Foo`) in place of a number, each asserting `InvalidDataException` - distinct from the +"entirely absent" case above, proving `ReadFontMatrix`'s own length and per-entry-type validation +both fail closed independently. A final test supplies `/CharProcs 42` (a number, not a +dictionary), asserting `InvalidDataException` - distinct from the "entirely absent" `/CharProcs` +case above, proving `BuildResolvedType3Font`'s own `charProcs.Kind != PdfKind.Dictionary` check +fails closed. + +#### CanvasNetPdf-PdfDocument-Type3GlyphPainting: Glyph Procedures Paint via FontMatrix and Font-Scoped Resources + +**Tests**: `PdfDocument_Fonts_Type3_NonDefaultFontMatrix_ScalesGlyphGeometry`, +`PdfDocument_Fonts_Type3_NoOwnResources_FallsBackToOuterPageResources`, +`PdfDocument_Fonts_Type3_OwnResourcesTakePrecedenceOverPageResources_UsesFontResources` + +Builds the same single-glyph filled-rectangle `/Subtype /Type3` font as +`CanvasNetPdf-PdfDocument-Type3FontResolution`'s own minimal-font test, but under a deliberately +non-default, anisotropic `/FontMatrix` (`[0.002 0 0 0.0015 0 0]` - different horizontal and +vertical scales, neither of which is the conventional `0.001`), asserting the rectangle paints at +the device location this `/FontMatrix` (empirically verified against the implementation's own +rasterization) scales it to, and explicitly asserting a device pixel that lies inside the region +the rectangle *would* occupy under the conventional (but, for this font, wrong) `0.001` +`/FontMatrix` scale - yet outside this font's own correctly-scaled region - is left blank: the +highest-risk proof in this feature's test suite, per the implementation plan, since it +distinguishes "`/FontMatrix` genuinely consulted for glyph-matrix composition" from "`/FontMatrix` +accepted but silently ignored in favor of a hardcoded assumption". A further test declares a +glyph procedure that invokes a Form XObject (`/Fm0`) declared only in the invoking page's own +`/Resources` (the Type 3 font itself declares no `/Resources` of its own), asserting the Form's +own content paints at the expected glyph-matrix-transformed location - proving `PaintType3Glyph` +falls back to the invoking content stream's own `/Resources` exactly like a `/Subtype /Form` +XObject with no `/Resources` of its own already does. A final test declares both the Type3 font's +own `/Resources` and the invoking page's own `/Resources` with an XObject of the same name +(`/Fm0`) painting different colors, asserting the glyph procedure's `/Fm0 Do` paints the font's +own color (not the page's) - proving `PaintType3Glyph`'s `_resources = font.Resources ?? _resources` +fallback's non-null branch (the font's own `/Resources` taking precedence) is exercised, distinct +from the preceding test's null-branch (fallback) proof. + +#### CanvasNetPdf-PdfDocument-Type3GlyphStateIsolation: Glyph Procedure Graphics State Does Not Leak Out + +**Test**: `PdfDocument_Fonts_Type3_GlyphProc_GraphicsStateIsolated_DoesNotLeakOut` + +Uses a glyph procedure that applies `2 0 0 2 0 0 cm 1 0 0 rg` before painting its own rectangle +(under an identity `/FontMatrix`, so the glyph's own content-stream coordinates map 1:1 onto the +same device space a Form XObject's own BBox-space coordinates would), then asserts the page's +own, separately-painted rectangle - painted immediately after `Tj` returns, in the page's own +default black fill color, at its own normal unscaled position - is unaffected by the glyph +procedure's `cm`/`rg` mutations, mirroring +`PdfDocument_Images_DoOperator_NestedFormXObjects_RestoresGraphicsStateAfterReturn`'s own Form- +XObject state-isolation proof applied to `PaintType3Glyph`'s re-entrant execution instead. + +#### CanvasNetPdf-PdfDocument-Type3GlyphNestingDepthGuard: Self-Referencing Glyph Procedures Fail Closed + +**Test**: `PdfDocument_Fonts_Type3_SelfReferencingGlyphProc_ExceedsMaxNestingDepth_ThrowsInvalidDataException` + +Declares a glyph procedure for code 65 that itself re-shows code 65 via a bare `(A) Tj` (legal, +since the nested graphics-state clone inherits the outer `Tf`-selected font), asserting +`InvalidDataException` once the fixed maximum Type3 glyph-procedure nesting depth is exceeded - +proving the bounded-recursion guard fails closed rather than overflowing the call stack or +hanging. + +#### CanvasNetPdf-PdfDocument-Type3RenderMode3Invisible: Render Mode 3 Skips Painting but Still Advances + +**Test**: `PdfDocument_Fonts_Type3_RenderMode3_SkipsGlyphProcedureButStillAdvances` + +Selects render mode `3` (invisible) via `Tr` before showing two glyphs, then switches back to +render mode `0` before showing a third, asserting no ink paints at either invisible glyph's own +would-be-painted device location while the third, visible glyph paints at the origin fully +advanced by both invisible glyphs' declared widths - proving `ShowGlyph`'s render-mode-3 guard +skips `PaintType3Glyph` entirely while the shared, unconditional advance logic still runs. + +#### CanvasNetPdf-PdfDocument-Type3D0D1Operators: d0/d1 Operands Parsed and Discarded, Never Affect Advance + +**Tests**: `PdfDocument_Fonts_Type3_D0Operator_ParsedButDoesNotAffectAdvanceWidth`, +`PdfDocument_Fonts_Type3_D1Operator_ParsedButDoesNotAffectAdvanceWidth`, +`PdfDocument_Fonts_Type3_StrayD0D1OutsideGlyphProc_NoExceptionNoEffect` + +A test declares a glyph procedure beginning `2000 0 d0` (a `wx` operand wildly different from +the font's own declared `/Widths` entry) before painting, shows the same glyph twice in one +`Tj`, and asserts the second glyph's device position reflects the font's `/Widths`-declared +advance, not a d0-driven one - proving `OpType3SetWidth` validates and discards its operands +without feeding `wx` back into layout. A further test declares a glyph procedure using the `d1` +operator (6 operands: `wx wy llx lly urx ury`) with a `wx` wildly different from the font's own +declared `/Widths` entry, mirroring the existing `d0` test's own structure, and asserts the +second of two shown glyphs lands at the `/Widths`-correct (not `d1`-driven) advance - proving +`d1`'s own 6-operand path is parsed and discarded exactly like `d0`'s. A companion test issues a +stray `d0`/`d1` pair directly in an ordinary, non-Type3 page content stream (no font ever +selected), asserting no exception and that a subsequent filled rectangle paints normally - +proving `ExecuteOperators`' shared `d0`/`d1` dispatch is unconditional, not gated on "currently +inside a Type3 glyph procedure". + +#### CanvasNetPdf-PdfDocument-ToUnicodeCMap: /ToUnicode CMap Resolves to a Code-to-Codepoint Map + +**Tests**: `PdfDocument_Fonts_ToUnicode_Absent_ResolvesNull`, +`PdfDocument_Fonts_ToUnicode_BfChar_MapsSingleCode`, +`PdfDocument_Fonts_ToUnicode_BfRangeHexForm_MapsConsecutiveCodes`, +`PdfDocument_Fonts_ToUnicode_BfRangeArrayForm_MapsEachCodeIndividually`, +`PdfDocument_Fonts_ToUnicode_UnsupportedArrayDestinationBfRange_ThrowsUnsupportedImageFeatureException`, +`PdfDocument_Fonts_ToUnicode_UnsupportedOperator_ThrowsUnsupportedImageFeatureException` + +Builds a font dictionary with no `/ToUnicode` entry at all, asserting `ResolveToUnicodeMap` +resolves a null map. Builds a `/ToUnicode` CMap stream (wrapped in the standard Adobe CMap/ +PostScript resource-management boilerplate, proving that boilerplate is tolerated rather than +merely a bare `bfchar`/`bfrange` block) declaring a single `beginbfchar`/`endbfchar` pair, +asserting the source code resolves to its destination's codepoint. Declares a +`beginbfrange`/`endbfrange` triple with a single hex-string destination, asserting consecutive +codes across the declared `[srcLo, srcHi]` range resolve to consecutive incrementing codepoints +starting at that destination's codepoint. Declares a `beginbfrange`/`endbfrange` triple with an +array-of-hex-strings destination, asserting each code in the range resolves to its own +corresponding array element. Declares a `beginbfrange`/`endbfrange` triple whose destination +array contains a nested array (the out-of-scope CIDSystemInfo-style "array of arrays" +destination sub-form), asserting `Codecs.UnsupportedImageFeatureException` (unlike the in-scope +array-of-hex-strings destination form, which succeeds). A `[Theory]` declares each of the +explicitly out-of-scope `usecmap`/`cidrange`/`cidchar` operators, asserting +`Codecs.UnsupportedImageFeatureException` in every case, rather than those operators being +silently ignored like the CMap's own PostScript resource-management wrapper keywords. + +#### CanvasNetPdf-PdfDocument-FontFallback: Non-Embedded TrueType Fonts Are Substituted, Other Symbolic Fonts Fail Closed + +**Tests**: `PdfDocument_BuildResolvedFont_Standard14NoEmbeddedFont_ResolvesViaFallback`, +`PdfDocument_BuildResolvedFont_NonStandard14FlagsOnlyUnmatchedFamily_ResolvesViaBundledFallback`, +`PdfDocument_BuildResolvedFont_SymbolicFlagWithoutEmbeddedFont_ThrowsSymbolicNotEmbeddedException`, +`CanvasNetPdf_SystemIntegration_RenderStandard14HelveticaWithoutEmbeddedFont_PaintsVisibleGlyphInk`, +`CanvasNetPdf_SystemIntegration_RenderOtherSymbolicFontWithoutEmbeddedFont_ThrowsUnsupportedImageFeatureException` + +Builds a `/Subtype /TrueType` font dictionary with a recognized Standard-14 `/BaseFont` (for +example `Helvetica`) and no `/FontFile2`, asserting `BuildResolvedFont` returns a resolved font +whose underlying `Fonts.TrueTypeFont` is non-null rather than throwing. Builds a font dictionary +with a `/BaseFont` unmatched by any Standard-14 name or any plausible system font, relying only on +`/FontDescriptor/Flags`, asserting resolution still succeeds via the bundled Liberation fallback. +Builds a font +dictionary whose `/FontDescriptor/Flags` declares the `Symbolic` bit without the `Nonsymbolic` +bit (and whose `/BaseFont` is deliberately not `Symbol`/`ZapfDingbats`, so it cannot divert to the +Noto substitution path below), asserting `Codecs.UnsupportedImageFeatureException` with `Feature == +"pdf-font-symbolic-not-embedded"` - the sole remaining fail-closed case this requirement covers, +since `Symbol`/`ZapfDingbats` themselves now resolve via +`CanvasNetPdf-PdfDocument-SymbolZapfDingbatsFallback` below instead. The two +`CanvasNetPdf_SystemIntegration_*` end-to-end tests render a full synthetic single-page PDF whose +font resource has no `/FontFile2`: the Standard-14 Helvetica case asserts at least one non-default +(non-transparent) pixel was painted somewhere in the glyph's expected device-space region +(a weak, OS-independent assertion, since the actual system/bundled font substituted varies by CI +operating system), and the other-symbolic-font case asserts the render throws +`Codecs.UnsupportedImageFeatureException` rather than silently painting an unrelated glyph shape - +a regression guard proving the new Symbol/ZapfDingbats substitution path did not loosen this +fail-closed policy for any other symbolic font. + +#### CanvasNetPdf-PdfDocument-SymbolZapfDingbatsFallback: Symbol/ZapfDingbats Resolve via a Bundled Noto Union + +**Tests**: `SymbolEncodingTable_RepresentativeSample_MapsToKnownCorrectCodepoints`, +`ZapfDingbatsEncodingTable_RepresentativeSample_MapsToKnownCorrectCodepoints`, +`PdfDocument_BuildResolvedFont_SymbolFont_ResolvesViaNotoSubstituteWithoutThrowing`, +`PdfDocument_BuildResolvedFont_ZapfDingbatsFont_ResolvesViaNotoSubstituteWithoutThrowing`, +`CanvasNetPdf_SystemIntegration_RenderSymbolFontWithoutEmbeddedFont_PaintsVisibleGlyphInk`, +`CanvasNetPdf_SystemIntegration_NotoSansRegularBundledFont_ResolvesSymbolAlphaToNonzeroGlyphIndex`, +`BundledFonts_AllThreeNotoVariants_LoadSuccessfullyAndExposeExpectedName` + +Two reflection-based unit tests (`PdfDocumentSymbolicEncodingTests`) spot-check representative, +known-correct entries of the new `SymbolEncodingTable`/`ZapfDingbatsEncodingTable` 256-entry +code-to-Unicode-codepoint tables, rather than re-asserting the entire table verbatim: Symbol's +`alpha` (code `0x61`) to U+03B1, `Alpha` (code `0x41`) to U+0391, the Private-Use-Area-fallback +glyph name `registerserif` (code `0xD2`) to the plain/generic U+00AE rather than a Private-Use- +Area codepoint, and the deliberately unmapped extensible-delimiter-piece glyph name `radicalex` +(code `0x60`) to `0`; ZapfDingbats' `space` (code `0x20`) to U+0020, `a1` (code `0x21`) to U+2701, +and the documented, accepted-fidelity-limitation uncovered case `a120` (circled digit one, code +`0xAC`) to U+2460 (the table itself still defines this mapping - only the bundled substitute font +does not cover this particular codepoint). Two unit-level `PdfDocument_BuildResolvedFont_*` tests +build `/BaseFont /Symbol` and, separately, `/BaseFont /ZapfDingbats` font dictionaries with no +embedded `/FontFile2` and asserting the render no longer throws (replacing the prior fail-closed +tests this requirement moved from `CanvasNetPdf-PdfDocument-FontFallback` above). The +`CanvasNetPdf_SystemIntegration_RenderSymbolFontWithoutEmbeddedFont_PaintsVisibleGlyphInk` +end-to-end test renders a synthetic single-page PDF with a `/BaseFont /Symbol` font resource, no +`/FontFile2`, and no `/FontDescriptor` entries at all (PDF 32000-1 ยง9.6.2.2 permits an entirely +absent/empty descriptor), asserting at least one visibly-painted pixel - proving the fallback +path genuinely renders rather than merely not throwing. The +`CanvasNetPdf_SystemIntegration_NotoSansRegularBundledFont_ResolvesSymbolAlphaToNonzeroGlyphIndex` +test proves the full production round-trip by loading the exact same embedded resource +(`NotoSans-Regular.ttf`) `ResolveSymbolicNotoFallback` itself loads, and asserting Symbol code +`0x61` ('alpha', which the Symbol encoding table maps to Unicode U+03B1) resolves to a nonzero, +valid glyph index via `Fonts.TrueTypeFont.GetGlyphIndex` - proving the bundled substitute font +genuinely carries a real Greek alpha glyph, not merely that the pipeline declines to throw. +Finally, `BundledFonts_AllThreeNotoVariants_LoadSuccessfullyAndExposeExpectedName` +(`BundledNotoFontsTests`, in `DemaConsulting.CanvasNet.Tests`) dogfoods all 3 new bundled Noto +embedded-resource font files through `Fonts.TrueTypeFont.Load` +(via `Fonts.SystemFontCatalog.LoadBundledFallbackCore`, the same embedded-resource-loading path +production code uses), proving each is a genuine, well-formed, loadable TrueType font exposing a +sane, expected family name - mirroring `BundledLiberationFontsTests`'s own theory-per-file +dogfooding shape for the 12 pre-existing Liberation files. + +#### CanvasNetPdf-PdfDocument-FontEncoding: Base Encodings and Differences Overrides Resolve Correctly + +**Tests**: `PdfDocument_Fonts_UnrecognizedEncoding_ThrowsUnsupportedImageFeatureException`, +`PdfDocument_Fonts_DefaultEncoding_IsWinAnsiEncoding`, +`PdfDocument_Fonts_MacRomanEncoding_DiffersFromWinAnsiEncoding`, +`PdfDocument_Fonts_Differences_OverridesBaseEncodingCode`, +`PdfDocument_Fonts_Differences_Absent_LeavesBaseEncodingCodeUnmapped`, +`PdfDocument_Fonts_Differences_UnrecognizedGlyphName_FallsBackToBaseEncoding`, +`PdfDocument_Fonts_Differences_FfFfiFflLigatureNames_DoNotThrow`, +`PdfDocument_Fonts_Differences_NacuteName_DoesNotThrow`, +`PdfDocument_Fonts_Differences_NameBeforeStartingCode_ThrowsInvalidDataException`, +`PdfDocument_Fonts_StandardEncoding_DiffersFromWinAnsiEncoding`, +`PdfDocument_Fonts_StandardEncoding_Absent_DefaultWinAnsiDoesNotPaintQuoteright` + +Selects a font declaring an unrecognized `/Encoding` base-encoding name, asserting +`Codecs.UnsupportedImageFeatureException`. Selects a font with no `/Encoding` key at all and, +separately, a font explicitly declaring `/MacRomanEncoding`, showing the byte code `0xE0` (which +diverges between the two base encodings - `WinAnsiEncoding` maps it to U+00E0, `MacRomanEncoding` +to U+2021) through a synthetic font whose `cmap` maps only U+00E0 to a real glyph, asserting the +default-encoding case paints ink and the MacRoman case does not (resolving to `.notdef`/no +paint) - proving the correct table is actually consulted, not merely that some table exists. +Declares an `/Encoding/Differences` array remapping a code to glyph name `agrave` (U+00E0) and +shows that code through a font whose `cmap` maps only U+00E0, asserting ink is painted where the +base encoding alone would not have resolved to that codepoint; separately shows a code with no +`/Differences` override present, asserting it keeps its base-encoding mapping unchanged. Declares +a `/Differences` array containing an unrecognized glyph name, asserting the affected code is +tolerated and falls back to its base-encoding mapping rather than rejecting the document (ink +still paints via the embedded font's own `cmap` mapping for the unchanged codepoint); a further +test declares a +`/Differences` array naming the `ff`/`ffi`/`ffl` Latin ligature glyphs (found via a real-world +pdfLaTeX Computer Modern font - previously only `fi`/`fl` were present in `StandardGlyphNames`), +asserting no exception; separately declares a +`/Differences` array beginning with a glyph name before any starting code number, asserting +`InvalidDataException` for that malformed array shape too. A further test declares a +`/Differences` array naming the `nacute` (Polish/Czech "ล„") Latin Extended-A accented letter +(found via the same real-world pdfLaTeX Computer Modern font), asserting no exception. As of +Phase B, a font explicitly +declaring `/StandardEncoding` and showing byte code `0x27` (which diverges between the two base +encodings - `StandardEncoding` maps it to U+2019 "quoteright", `WinAnsiEncoding` maps it to +U+0027 "quotesingle" instead) through a synthetic font whose `cmap` maps only U+2019 to a real +glyph, asserts ink is painted; a companion test proves that without an explicit `/Encoding` entry +(the default `/WinAnsiEncoding` applies instead), the same code paints nothing against the same +font - proving `/StandardEncoding` and `/WinAnsiEncoding` genuinely resolve code `0x27` +differently, not merely that some encoding resolved. + +#### CanvasNetPdf-PdfDocument-FontWidths: Advance-Width Resolution Follows the Documented Priority + +**Tests**: `PdfDocument_Fonts_Widths_ExplicitEntry_DeterminesAdvance`, +`PdfDocument_Fonts_Widths_MissingWidthFallback_DeterminesAdvance`, +`PdfDocument_Fonts_Widths_FontOwnAdvanceFallback_DeterminesAdvance` + +Shows two glyphs from a font declaring an explicit `/Widths` entry for the first code, asserting +the second glyph's painted device-x position matches the explicit width, not the font's own +metric. Declares a font whose `/Widths` array omits a shown code but whose `/FontDescriptor` +declares `/MissingWidth`, asserting the second glyph's position matches the `/MissingWidth` +value. Declares a font with neither `/Widths` nor `/MissingWidth` for a shown code, asserting the +second glyph's position matches the embedded font's own `GetAdvanceWidth`/`UnitsPerEm` metric. + +#### CanvasNetPdf-PdfDocument-TextObjectState: BT/ET Reset Only Tm/Tlm; q/Q Save/Restore Text State + +**Tests**: `PdfDocument_Text_BeginText_ResetsTextMatrixButPreservesFontAndTextState`, +`PdfDocument_Text_PushPopGraphicsState_RestoresFontSize` + +Selects a font and size, moves the text position, ends the text object (`ET`), begins a new one +(`BT`) without re-selecting the font, and shows a glyph at a freshly-set position - asserting the +previously selected font/size is still in effect (painted ink appears, proving `Tf` was not lost) +while the text position reset to identity is honored (the glyph appears at the new position, not +offset by the first text object's position). Selects a font size, pushes the graphics state +(`q`), changes the font size, pops it (`Q`), and shows a glyph - asserting the glyph's painted +size matches the original (pre-`q`) font size, proving `q`/`Q` saves/restores text state exactly +like every other graphics-state parameter. + +#### CanvasNetPdf-PdfDocument-TextStateOperators: Tc/Tw/Tz Apply Their Documented Spacing/Scaling Formulas + +**Tests**: `PdfDocument_Text_Tc_AddsToGlyphAdvance`, `PdfDocument_Text_Tw_AppliesOnlyToCode32`, +`PdfDocument_Text_Tz_ScalesHorizontalShapeAndAdvance`, +`PdfDocument_ShowText_Type0_WordSpacingCode32_IsNotAppliedToCompositeFont` + +Sets a nonzero `Tc` and shows two glyphs, asserting the second glyph's device-x position is +offset by the additional character spacing beyond its own advance width. Sets a nonzero `Tw` and +shows a string containing a code-32 (space) byte followed by a non-space byte, asserting only the +byte immediately after the space is offset by the word spacing (a non-space code is never +affected). Sets `Tz` to a value other than the default `100` and shows two glyphs, asserting both +each glyph's own painted horizontal shape (narrower/wider) and its advance are scaled +accordingly, while the vertical shape (unaffected by `Th`) is unchanged. Sets a large `Tw` against +a composite `/Type0`/`/Identity-H` font whose first 2-byte code numerically equals `32`, asserting +the following glyph lands at its ordinary (un-spaced) advance position rather than being displaced +far off-canvas - proving word spacing is gated on the font's code-byte-width, not merely the +decoded code's numeric value. + +#### CanvasNetPdf-PdfDocument-Tf: Tf Resolves the Named Font Resource and Selects Its Size + +**Tests**: `PdfDocument_Text_ShowText_PaintsGlyphAtComposedTextRenderingMatrix` + +Selects a font and size via `Tf`, then shows a glyph and asserts its painted device-pixel +position/size matches the composed text-rendering matrix formula independently re-derived from +the same font's own metrics (see this class's own pixel-math derivation, confirmed empirically +against the real rasterizer before being fixed into every position-dependent test's assertions). + +#### CanvasNetPdf-PdfDocument-Tr: Tr Supports Fill/Stroke/Fill+Stroke/Invisible Modes, Fails Closed for Clip Modes + +**Tests**: `PdfDocument_Text_RenderMode3_Invisible_DoesNotPaintGlyph`, +`PdfDocument_Text_RenderMode3_Invisible_StillAdvancesTextPosition`, +`PdfDocument_Text_RenderMode1_Stroke_PaintsStrokedOutlineNotSolidFill`, +`PdfDocument_Text_RenderMode2_FillAndStroke_PaintsBothFillAndStroke`, +`PdfDocument_Text_RenderMode1_StrokePattern_PaintsPatternStroke`, +`PdfDocument_Fonts_Type3_RenderMode1Or2_BehavesLikeRenderMode0`, +`PdfDocument_Text_RenderMode_UnsupportedDefinedMode_ThrowsUnsupportedImageFeatureException`, +`PdfDocument_Text_RenderMode_OutOfDefinedRange_ThrowsInvalidDataException` + +Sets `Tr 3` (invisible) and shows a glyph, asserting no ink is painted at its expected position. +Sets `Tr 3`, shows a first glyph, then shows a second glyph with `Tr 0` (fill), asserting the +second glyph's position reflects the first (invisible) glyph's own advance - proving invisible +text still moves the text position. Sets `Tr 1` (stroke) with distinct fill/stroke colors and +shows a glyph, asserting a stroke-colored pixel appears along the outline's edge while the +glyph's geometric center (which fill would otherwise paint) remains unpainted - proving mode `1` +strokes without filling. Sets `Tr 2` (fill, then stroke) with distinct fill/stroke colors and +shows a glyph, asserting the center is filled and the edge is stroked on top of the fill, +matching the path-painting operators' own fill-then-stroke paint order. Sets `Tr 1` with a +`/Pattern` stroke color space resolving to a colored tiling pattern and shows a glyph, asserting +the stroked edge takes the pattern's own tile color rather than a flat stroke color - proving the +glyph stroke step shares the path-painting operators' own `/Pattern`-aware stroke-paint logic. A +`[Theory]` shows a Type3 glyph under `Tr 1` and `Tr 2` in turn, asserting its own content-stream +procedure's ink paints identically to `Tr 0` - proving Type3 glyphs (which have no outline and so +no fill/stroke distinction) are unaffected by the new mode-aware outline paint logic. A +`[Theory]` sets `Tr` to each of the defined clip modes (`4`/`5`/`6`/`7`) in turn, asserting +`Codecs.UnsupportedImageFeatureException` in every case; a separate `[Theory]` sets `Tr` to a +value outside the specification's defined `0`-`7` range, asserting `InvalidDataException`. + +#### CanvasNetPdf-PdfDocument-TextPositioning: Td/TD/Tm/T* Compose the Text and Line Matrices Correctly + +**Tests**: `PdfDocument_Text_Td_OffsetsLineMatrixNotLastTextMatrix`, +`PdfDocument_Text_TD_SetsLeadingToNegativeTy`, `PdfDocument_Text_Tm_ReplacesTextAndLineMatrix`, +`PdfDocument_Text_TStar_UsesCurrentLeading` + +Issues two `Td` calls in sequence and shows a glyph after each, asserting the second glyph's +position reflects both displacements measured from the (unmoved) line matrix, not accumulated +relative to the first `Td`'s resulting text matrix. Issues `TD` and shows a glyph, then issues +`T*` (with no intervening `TL`) and shows a second glyph, asserting the second glyph's vertical +position reflects the leading `TD` implicitly set (`Leading = -ty`). Issues `Tm` with an explicit +matrix and shows a glyph, asserting its position matches that matrix directly (a replacement, not +a composition with any prior text/line matrix). Sets `TL` explicitly, issues `T*`, and shows a +glyph, asserting its position matches exactly `0 -TL Td`'s own effect. + +#### CanvasNetPdf-PdfDocument-TextShowing: Tj/'/"/TJ Paint the Correct Glyphs at the Correct Positions + +**Tests**: `PdfDocument_Text_ShowText_PaintsGlyphAtComposedTextRenderingMatrix`, +`PdfDocument_Text_TJ_AppliesPositionAdjustments`, +`PdfDocument_Text_TJ_ArrayEntryNotStringOrNumber_ThrowsInvalidDataException`, +`PdfDocument_Text_QuoteOperator_MovesToNextLineThenShows`, +`PdfDocument_Text_DoubleQuoteOperator_SetsSpacingThenMovesAndShows`, +`CanvasNetPdf_SystemIntegration_PdfRender_EmbeddedTrueTypeFontText_PaintsGlyphStrokesNotCounters` + +Shows a string via `Tj`, asserting the painted glyph's device-pixel position/size matches the +composed text-rendering matrix. Shows a `TJ` array containing a numeric position adjustment +between two strings, asserting the second string's glyphs are shifted by exactly that +adjustment's thousandths-of-text-space amount (scaled by `Tfs`/`Th`), beyond the preceding +glyph's own advance. A `TJ` array containing an element that is neither a string nor a number +asserts `InvalidDataException`. Issues `Td` then `'`, asserting the shown text is positioned +exactly as `T*` followed by `Tj` would place it. Issues `"` with explicit word/character spacing +operands, asserting both spacing parameters are applied to the subsequently shown text exactly as +setting `Tw`/`Tc` then issuing `'` would. The end-to-end system-integration test independently +proves the same glyph-painting pipeline against a real, hand-authored fixture embedding the real +`OpenSans-Regular.ttf` production font, re-deriving expected ink/counter/background pixel +positions from the font's own outline metrics rather than hardcoded numbers. + +#### CanvasNetPdf-PdfDocument-TextErrorHandling: No-Font-Selected and Malformed-Operand Text Operators Fail Closed + +**Tests**: `PdfDocument_Text_ShowText_NoFontSelected_ThrowsInvalidDataException`, +`PdfDocument_Text_MalformedOperandCount_ThrowsInvalidDataException` + +A `[Theory]` shows text via each of `Tj`/`'`/`"` with no preceding `Tf` call, asserting +`InvalidDataException` in every case. A large `[Theory]` supplies a malformed operand count/type +to each text operator in turn (including a trailing garbage-operand `ET`, a wrong-arity `Tf`, and +out-of-range operand counts for `Td`/`Tm`), asserting `InvalidDataException` in every case, +matching every other operator family's own established convention. + +#### CanvasNetPdf-PdfDocument-FunctionType0: /FunctionType 0 Sampled Function Resolves and Evaluates Correctly + +**Tests**: `PdfDocument_Functions_Type0_InputOutsideDomain_ClampsToBoundary`, +`PdfDocument_Functions_Type0_EncodeAbsent_DefaultsToZeroToSizeMinusOne`, +`PdfDocument_Functions_Type0_ExplicitEncode_MapsDomainToCustomSampleIndexRange`, +`PdfDocument_Functions_Type0_Decode_MapsRawSampleToCustomOutputRange`, +`PdfDocument_Functions_Type0_NonIntegerSampleIndex_InterpolatesBetweenAdjacentSamples`, +`PdfDocument_Functions_Type0_EightBitSamples_EvaluatesCorrectly`, +`PdfDocument_Functions_Type0_SixteenBitSamples_EvaluatesCorrectly`, +`PdfDocument_Functions_Type0_UnsupportedFunctionType_ThrowsUnsupportedImageFeatureException`, +`PdfDocument_Functions_Type0_MultiInputDomain_ThrowsUnsupportedImageFeatureException` + +Every test resolves a hand-built `/FunctionType 0` function stream (an indirect object appended to +a minimal single-page document, mirroring the `/ToUnicode` CMap tests' own "build a minimal +document, open it, resolve a reference into it" convention) via `ResolveFunction`, then calls +`SampledFunction.Evaluate` directly (unit-level, not through the content-stream interpreter - this +phase's own `Evaluate` is resolved-but-unconsumed groundwork, see the design documentation). +Asserts an input below/above `/Domain` clamps to the domain boundary before sample-index mapping. +Asserts an absent `/Encode` defaults to `[0, Size - 1]`, correctly mapping a domain midpoint to a +fractional sample index. Asserts an explicit `/Encode` overrides that default, selecting a +different (non-zero-valued) pair of samples than the default would have selected. Asserts +`/Decode` linearly remaps a raw sample value into its own declared range, distinct from (though +still clipped to) a wider `/Range`. Asserts a sample-index position that falls between two samples +linearly interpolates between them. Asserts both 8-bit and 16-bit `/BitsPerSample` widths read +their raw sample values correctly (16-bit via 2-byte big-endian packing). A `[Theory]` resolves a +`/FunctionType` `2`/`3`/`4` function, asserting `Codecs.UnsupportedImageFeatureException` (feature +`pdf-functiontype-{n}`) in every case. Resolves a 2-input `/FunctionType 0` function (a 4-element +`/Domain`), asserting `Codecs.UnsupportedImageFeatureException` (feature +`pdf-function-multiinput`). + +#### CanvasNetPdf-PdfDocument-FunctionType2And3: /FunctionType 2/3 Evaluate Correctly, /FunctionType 4 Fails Closed + +**Tests**: `PdfDocument_Functions_Type2_Exponential_EvaluatesExpectedInterpolatedOutput`, +`PdfDocument_Functions_Type3_Stitching_EvaluatesExpectedSubFunctionOutput`, +`PdfDocument_Functions_Type4_PostScriptCalculator_ThrowsUnsupportedImageFeatureException`, +`PdfDocument_Functions_Type3_Stitching_NestingDepthExceeded_ThrowsInvalidDataException` + +Every test resolves a hand-built function stream via the new `ResolveFunctionGeneric` entry point +(through a new `ResolveTestFunctionGeneric` helper mirroring `ResolveTestFunction`'s own "build a +minimal document, open it, resolve a reference into it" convention), then calls `Evaluate` +directly. A `[Theory]` resolves a `/FunctionType 2` function with `C0=[0]`/`C1=[1]` at both a +linear (`N=1`) and a quadratic (`N=2`) exponent, asserting the literal spec formula's expected +interpolated output at a known input. A `[Theory]` resolves a `/FunctionType 3` stitching +function with 2 `/FunctionType 2` sub-functions split at a single `/Bounds` partition point, +asserting the correct sub-function is selected (and its own `/Encode` remap applied) for inputs +falling in each of the 2 partitions. A final test resolves a `/FunctionType 4` function through +`ResolveFunctionGeneric`, asserting `Codecs.UnsupportedImageFeatureException` (feature +`pdf-functiontype-4`) is still thrown (regression guard - `/FunctionType 4` remains unsupported +even through the new generic resolver). A final regression test resolves a self-referencing +`/FunctionType 3` object (its own `/Functions` array references itself) through +`ResolveFunctionGeneric`, asserting `InvalidDataException` is thrown once the new +`MaxFunctionRecursionDepth` (32) bound is reached, rather than the process' call stack being +exhausted. + +#### CanvasNetPdf-PdfDocument-PatternColorSpace: /Pattern Color Space and scn/SCN Operands, Fail Closed Otherwise + +**Tests**: `PdfDocument_Color_PatternColorSpace_Cs_ResolvesToPatternFamily`, +`PdfDocument_Color_PatternColorSpace_ArrayWithBase_ResolvesPatternBase`, +`PdfDocument_Color_PatternColorSpace_TooManyArrayElements_ThrowsUnsupportedImageFeatureException`, +`PdfDocument_Color_Scn_ColoredPatternOperand_NameAlone_ResolvesPattern`, +`PdfDocument_Color_Scn_UncoloredPatternOperand_WrongComponentCount_ThrowsInvalidDataException`, +`PdfDocument_Color_Scn_PatternOperand_MissingTrailingName_ThrowsInvalidDataException`, +`PdfDocument_Color_Scn_PatternOperand_UndeclaredPatternName_ThrowsUnsupportedImageFeatureException`, +`PdfDocument_Color_Scn_PatternOperand_UnsupportedPatternType_ThrowsUnsupportedImageFeatureException` + +Every test builds a minimal single-page PDF (via `BuildSinglePagePdfWithResources`) with a +content stream selecting the `/Pattern` color space (`cs`, or a named `/CS0` resource entry for +the 2-element array form, since `cs`/`CS` only accept a single `Name` operand - an inline array +is not legal content-stream syntax), then calls `scn` and renders. Asserts bare `/Pattern cs` +resolves without throwing. Asserts a declared `/DeviceRGB` base space accepts exactly 3 leading +numeric operands before the pattern name and resolves successfully. Asserts a 3-element array +color space throws `Codecs.UnsupportedImageFeatureException` (feature `pdf-colorspace-Pattern`). +Asserts a bare pattern name (no base space, 0 leading numeric operands) resolves successfully. +Asserts a declared base space with the wrong leading numeric operand count throws +`InvalidDataException`. Asserts an all-numeric-operand `scn` call (no trailing pattern name) +throws `InvalidDataException`. Asserts an undeclared pattern name throws +`Codecs.UnsupportedImageFeatureException` (feature `pdf-pattern-not-declared`). Asserts a +`/PatternType 3` pattern throws `Codecs.UnsupportedImageFeatureException` (feature +`pdf-pattern-type-3`, a regression guard against any future `/PatternType` other than 1/2). + +#### CanvasNetPdf-PdfDocument-ShadingPatternFill: Axial/Radial Shading Patterns Paint a Gradient, Fail Closed Otherwise + +**Tests**: `PdfDocument_Patterns_ShadingPattern_AxialFunctionType2_FillsVisiblyVaryingGradient`, +`PdfDocument_Patterns_ShadingPattern_RadialFunctionType2_FillsVisiblyVaryingGradient`, +`PdfDocument_Patterns_ShadingPattern_UnsupportedShadingType_ThrowsUnsupportedImageFeatureException`, +`PdfDocument_Patterns_ShadingPattern_UnsupportedFunctionType4_ThrowsUnsupportedImageFeatureException`, +`PdfDocument_Patterns_ShadingPattern_FunctionArrayForm_ThreeOneOutputFunctions_BuildsRgbGradient`, +`CanvasNetPdf_SystemIntegration_AxialShadingPatternFill_PaintsVisiblyVaryingColors` + +Every unit test builds a minimal single-page PDF with a declared `/Pattern` resource +(`/PatternType 2`) and renders a filled rectangle. Asserts an axial (`/ShadingType 2`) pattern +driven by a single `/FunctionType 2` black-to-white function paints near-black at one axis +endpoint and near-white at the other. Asserts a radial (`/ShadingType 3`) pattern with the same +function shape paints a visibly different color at the center versus the edge. A `[Theory]` +asserts `/ShadingType 1`/`4` both throw `Codecs.UnsupportedImageFeatureException` (feature +`pdf-shading-type-{n}`). Asserts a `/Function` entry resolving to a `/FunctionType 4` function +still throws `Codecs.UnsupportedImageFeatureException` (feature `pdf-functiontype-4`) when +reached through a shading pattern, not only through a direct function-resolution call. Asserts +the `/Function [fn0 fn1 fn2]` array-of-1-output-functions form builds a correct RGB gradient. The +system-integration test additionally proves the same axial-gradient fill end-to-end through the +public `Render` API using a fully synthetic, in-memory PDF (no binary fixture), asserting +near-black/near-white at the expected device pixel positions. + +#### CanvasNetPdf-PdfDocument-TilingPatternFill: Colored/Uncolored Tiling Patterns Paint a Tile, Fail Closed Otherwise + +**Tests**: `PdfDocument_Patterns_TilingPattern_ColoredPaintType1_FillsRepeatingTile`, +`PdfDocument_Patterns_TilingPattern_UncoloredPaintType2_AppliesSuppliedTintPreservingAlpha`, +`PdfDocument_Patterns_TilingPattern_OversizedTileDimensions_ThrowsUnsupportedImageFeatureException`, +`PdfDocument_Patterns_TilingPattern_ZeroXStep_ThrowsInvalidDataException`, +`PdfDocument_Patterns_TilingPattern_NestingDepthExceeded_ThrowsInvalidDataException`, +`CanvasNetPdf_SystemIntegration_ColoredTilingPatternFill_PaintsRepeatingTileColors` + +Every unit test builds a minimal single-page PDF with a declared `/Pattern` resource +(`/PatternType 1`) and renders a filled rectangle spanning multiple tile repetitions. Asserts a +`/PaintType 1` (colored) 2-color tile paints both of its own colors at multiple, correctly offset +sample points (proving actual repetition, not merely 1 painted color). Asserts a `/PaintType 2` +(uncolored) tile whose own content paints partial-coverage ink takes the `scn`-supplied tint's +RGB on painted pixels while unpainted/transparent cell regions remain untouched (proving alpha is +preserved, not merely overwritten). Asserts a pathological `/XStep`/`/YStep` combined with an +extreme CTM scale that would allocate a tile surface exceeding `MaxTileSurfaceDimension` throws +`Codecs.UnsupportedImageFeatureException` (feature `pdf-pattern-tile-too-large`). Asserts a zero +`/XStep` throws `InvalidDataException` (malformed, not merely unsupported). Asserts a tiling +pattern whose own content stream nests tiling-pattern cell rendering deeply enough to exceed the +existing (reused, not duplicated) `MaxFormNestingDepth` guard throws `InvalidDataException` (no +dedicated correctness test of deep nested rendering itself - only that the guard still fires +through this new call path). The system-integration test additionally proves the same colored +tiling-pattern fill end-to-end through the public `Render` API using a fully synthetic, in-memory +PDF (no binary fixture), asserting both tile colors appear at multiple expected-offset device +pixel positions. + +## Acceptance Criteria + +A unit-level test run passes when all scenarios above pass without error or exception beyond +those explicitly asserted. Any unexpected exception, wrong exception type, or wrong return/field +value constitutes a failure. diff --git a/docs/verification/canvas-net.md b/docs/verification/canvas-net.md index 00a4ade1..67070255 100644 --- a/docs/verification/canvas-net.md +++ b/docs/verification/canvas-net.md @@ -2,7 +2,6 @@ - This document describes the system-level verification strategy for CanvasNet. ## Verification Approach @@ -85,6 +84,18 @@ per-channel tolerance rather than exact equality because JPEG is lossy; passing channel of the reloaded pixel to remain within the documented tolerance bound while alpha remains opaque. +### Integration: GIF Load Returns Expected Pixel + +**Test**: `CanvasNet_SystemIntegration_GifLoad_ReturnsExpectedPixel` + +Exercises end-to-end system behavior across the `Surface` and `GifCodec` units: decodes a +hand-built, minimal 1x1 GIF89a stream (a Global Color Table, a single literal-code LZW-compressed +Image Descriptor, and the Trailer) via `GifCodec.Load` into a `Surface`. Unlike the BMP/PNG/TIFF/ +JPEG scenarios above, there is no "Save" half to this round-trip, since `GifCodec` is decode-only. +Asserts the decoded `Surface`'s dimensions and single pixel match the color selected by the +stream's Global Color Table index, confirming the system's public GIF load API integrates +correctly with `Surface`. + ### Integration: Composite Color Over Surface Returns Expected Pixel **Test**: `CanvasNet_SystemIntegration_CompositeColorOverSurface_ReturnsExpectedPixel` @@ -157,6 +168,19 @@ single-unit scenario, because it exercises the collaboration between `PathBuilde (including its internal use of `SvgArcConverter` to convert the `ArcTo` command), and `BezierFlattening` together, rather than any one of the four `Geometry` units in isolation. +### Integration: Line-To and Compute Tangents Returns Normalized Direction + +**Test**: `CanvasNet_SystemIntegration_LineToAndComputeTangents_ReturnsNormalizedDirection` + +Exercises end-to-end system behavior of `PathCommand`'s public `LineTo` factory together with its +public `ComputeTangents` method: synthesizes a standalone horizontal line-segment command directly +via `PathCommand.LineTo` (rather than via `PathBuilder`), then computes its outgoing/incoming +tangent directions from a given start point via `PathCommand.ComputeTangents`. Asserts both +tangents equal the same sane, unit-length direction of travel. This scenario proves the shared +building block an external format-codec package (such as a marker/arrowhead renderer) needs to +orient a decoration at a path segment's end, independent of this package's internal `Path`/ +`PathBuilder` construction. + ### Integration: Fill Empty or Out-of-Bounds Path No-Ops, Leaving the Surface Unchanged **Test**: `CanvasNet_SystemIntegration_FillEmptyOrOutOfBoundsPath_NoOpLeavesSurfaceUnchanged` @@ -316,8 +340,74 @@ the expected declared width and height without throwing merely because the leadi exceeds the soft cap, confirming the system upholds the invariant that `GetInfo` never throws for an input `Load` would successfully decode. +### Integration: Load a CFF/OTF Font and Fill a Glyph Outline Returns Expected Pixels + +**Test**: `CanvasNet_SystemIntegration_LoadCffOtfFontAndFillGlyphOutline_ReturnsExpectedPixels` + +Exercises end-to-end system behavior across the `Fonts`, `Geometry`, `Drawing`, and `Canvas` +subsystems for a real CFF/OpenType (OTTO-flavored) production font: loads the real "Source Sans +3" `.otf` fixture, resolves capital `H` to a glyph index, decodes its outline from Type 2 +charstring bytecode, scales and flips it into canvas coordinates, and fills it through +`PathFiller` onto a `Surface`. Asserts real, non-transparent ink is painted while the canvas's +far corners remain fully transparent, confirming the system integrates CFF outline decoding with +vector rasterization end to end, not merely glyf-flavored TrueType outlines. + +### Integration: Load a Type 1 Font and Fill a Glyph Outline Returns Expected Pixels + +**Test**: `CanvasNet_SystemIntegration_LoadType1FontAndFillGlyphOutline_ReturnsExpectedPixels` + +Exercises end-to-end system behavior across the `Fonts`, `Geometry`, `Drawing`, and `Canvas` +subsystems for a classic PostScript Type 1 font program: loads a hand-authored synthetic Type 1 +font (a required `.notdef` glyph plus a single triangular glyph, built from the +`hsbw`/`rmoveto`/`rlineto`/`closepath`/`endchar` Type 1 charstring operator subset this library +supports) through the public `TrueTypeFont.LoadType1` API, decodes the triangular glyph from Type +1 charstring bytecode, and fills it through `PathFiller` onto a `Surface`. Asserts real, +non-transparent ink is painted, confirming the system integrates Type 1 outline decoding with +vector rasterization end to end, alongside the glyf and CFF outline flavors. + +### Integration: Load a Face from a TTC Container by Index Returns Expected Face + +**Test**: `CanvasNet_SystemIntegration_LoadFaceFromTtcContainerByIndex_ReturnsExpectedFace` + +Exercises end-to-end system behavior for the `Fonts` subsystem's multi-face collection support: +reports the face count of a real, locally-assembled 2-face TrueType Collection (`.ttc`) fixture, +loads both faces explicitly by index, and loads the same file with no explicit face index at all. +Asserts the container reports 2 faces, face 0 resolves to "Open Sans", face 1 resolves to "Source +Sans 3", and omitting the face index behaves identically to requesting face 0, confirming the +system's face-count/face-selection API integrates correctly end to end against real `ttcf` +container data. + +### Integration: Load a Font and Query Name and Style Metadata Returns Expected Values + +**Test**: `CanvasNet_SystemIntegration_LoadFontAndQueryNameAndStyleMetadata_ReturnsExpectedValues` + +Exercises end-to-end system behavior for the `Fonts` subsystem's name/style metadata APIs: loads +the real "Open Sans" production font and resolves its family/subfamily/full/PostScript names and +bold/italic/fixed-pitch style. Asserts every value matches this fixture's independently +`fonttools`-confirmed metadata, confirming the system integrates `name`/`OS/2`/`post` table +parsing end to end against real font data. + +### Integration: Resolve System Font Fallback Returns Bundled Fallback Font + +**Test**: `CanvasNet_SystemIntegration_ResolveSystemFontFallback_ReturnsBundledFallbackFont` + +Exercises end-to-end system behavior for the `Fonts` subsystem's bundled last-resort fallback +font: requests the bundled Liberation Sans Regular fallback through +`SystemFontCatalog.LoadBundledFallback` (deliberately not `FindBestMatch`, since what is actually +installed on the host machine running the test varies by CI runner and platform). Asserts a +genuine, well-formed, non-bold/non-italic/non-fixed-pitch font with real glyph data was loaded, +confirming the system's fallback-font path integrates end to end deterministically, even on a CI +environment with zero discoverable OS fonts. + ## Acceptance Criteria -A system-level test run passes when all twenty-four scenarios above pass without error or +A system-level test run passes when all thirty-two scenarios above pass without error or exception beyond those explicitly asserted. Any unexpected exception, wrong exception type, or wrong return value constitutes a failure. + +> **Known coverage gap (not fixed by this documentation pass):** unlike the solid-color +> `PathFiller.Fill` overload, neither the `GradientPaint`-paint nor the `TilePaint`-paint +> `PathFiller.Fill` overloads currently have a dedicated system-level integration test scenario +> in `CanvasNetTests.cs` above, even though both are exercised by this repository's unit tests. +> Closing this gap requires writing new tests and is out of scope for a documentation-only +> change; it is recorded here as a backlog item for a future test-authoring pass. diff --git a/docs/verification/canvas-net/canvas.md b/docs/verification/canvas-net/canvas.md index f149cc5c..8428d4d5 100644 --- a/docs/verification/canvas-net/canvas.md +++ b/docs/verification/canvas-net/canvas.md @@ -23,3 +23,12 @@ externally observable behavior. The `Canvas` subsystem's verification passes when every `Surface` unit test scenario described in _Surface Unit Verification Design_ (`canvas/surface.md`) passes without error or unexpected exception. + +### Test Scenarios + +The `Canvas` subsystem currently contains exactly one unit with externally observable behavior, +so its test scenarios are exactly those named in _Surface Unit Verification Design_ +(`canvas/surface.md`): construction and invariants, indexer and span-accessor boundary conditions, +`Crop` normal/boundary/error paths, `PremultiplyAlpha`/`UnpremultiplyAlpha`/`CompositeOver` +compositing correctness, and `Dispose` idempotency. No subsystem-level test exists beyond this +unit's own coverage. diff --git a/docs/verification/canvas-net/canvas/rgba32.md b/docs/verification/canvas-net/canvas/rgba32.md index e0dd4e10..32a83815 100644 --- a/docs/verification/canvas-net/canvas/rgba32.md +++ b/docs/verification/canvas-net/canvas/rgba32.md @@ -21,6 +21,63 @@ The `Rgba32` `Parse` and `TryParse` methods are verified by - **TryParse**: valid, invalid, and null inputs all behave per contract, with `result` set to `default` on failure. +### 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 test operates purely on `string`/`Rgba32` values +- **Isolation**: Each test method is self-contained, with no shared state between tests + +### 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-Canvas-Rgba32-ParseSixHex: Six-Hex-Digit Strings Parse to RGB With Alpha 255 + +**Tests**: `Rgba32_Parse_6HexUppercase_ReturnsExpectedRgbaWithAlpha255`, +`Rgba32_Parse_6HexLowercase_ReturnsExpectedRgbaWithAlpha255`, +`Rgba32_Parse_6HexMixedCase_ReturnsExpectedRgba` + +Verifies case-insensitive `#RRGGBB` parsing with alpha defaulting to 255. + +#### CanvasNet-Canvas-Rgba32-ParseEightHex: Eight-Hex-Digit Strings Parse to ARGB + +**Tests**: `Rgba32_Parse_8HexUppercase_ReturnsExpectedArgb`, +`Rgba32_Parse_8HexLowercase_ReturnsExpectedArgb` + +Verifies case-insensitive `#AARRGGBB` parsing. + +#### CanvasNet-Canvas-Rgba32-ParseValidation: Invalid Inputs Are Rejected With Specific Error Messages + +**Tests**: `Rgba32_Parse_MissingHash_ThrowsFormatException`, +`Rgba32_Parse_MissingHashPrefixAtValidLength_MessageIdentifiesMissingHash`, +`Rgba32_Parse_3HexShortForm_ThrowsFormatException`, +`Rgba32_Parse_4HexArgbShortForm_ThrowsFormatException`, `Rgba32_Parse_7Hex_ThrowsFormatException`, +`Rgba32_Parse_9Hex_ThrowsFormatException`, `Rgba32_Parse_NonHexCharacter_ThrowsFormatException`, +`Rgba32_Parse_NonHexCharacter_MessageIncludesOffendingCharacter`, +`Rgba32_Parse_Null_ThrowsArgumentNullException`, `Rgba32_Parse_EmptyString_ThrowsFormatException`, +`Rgba32_Parse_ExceptionMessage_ContainsFormatGuidance` + +Verifies missing-hash, short forms (`#RGB`, `#ARGB`), wrong length (7 and 9 hex digits), non-hex +characters, `null`, and an empty string all throw the correct exception type (`FormatException`, +except `ArgumentNullException` for `null`), and that the format-guidance test verifies the +message names the accepted formats. The missing-`#` message specifically names the missing +prefix rather than the generic format message, and the non-hex-character message includes the +actual offending character. + +#### CanvasNet-Canvas-Rgba32-TryParse: TryParse Never Throws and Reports Success/Failure via Its Return Value + +**Tests**: `Rgba32_TryParse_ValidInput_ReturnsTrueAndSetsResult`, +`Rgba32_TryParse_InvalidInput_ReturnsFalseAndSetsResultToDefault`, +`Rgba32_TryParse_Null_ReturnsFalse` + +Verifies valid, invalid, and `null` inputs all behave per contract, with `result` set to +`default` on failure. + ### Traceability Every requirement in `docs/reqstream/canvas-net/canvas/rgba32.yaml` links to one or more of diff --git a/docs/verification/canvas-net/canvas/surface.md b/docs/verification/canvas-net/canvas/surface.md index dccd5001..ffa2ca93 100644 --- a/docs/verification/canvas-net/canvas/surface.md +++ b/docs/verification/canvas-net/canvas/surface.md @@ -1,9 +1,9 @@ -## Surface Unit Verification Design +### Surface Unit Verification Design This document describes the unit-level verification strategy for the `Surface` class (and the supporting `Rgba32` value type). -### Verification Approach +#### Verification Approach The `Surface` unit is verified through unit tests that exercise each public constructor, property, indexer, span accessor, the `Crop` method, and the vectorized bulk pixel operations @@ -18,7 +18,7 @@ premultiply/unpremultiply), not by re-deriving the formula under test. Unit tests reside in `SurfaceTests.cs` within the `DemaConsulting.CanvasNet.Tests` project. -### Test Environment +#### Test Environment - **Framework**: xUnit v3 running under the .NET SDK - **Execution**: `dotnet test` invoked by `build.ps1` and the CI pipeline @@ -26,9 +26,9 @@ Unit tests reside in `SurfaceTests.cs` within the `DemaConsulting.CanvasNet.Test - **Isolation**: Each test method constructs its own `Surface` instance(s); no shared state between tests -### Unit-Level Test Scenarios +#### Unit-Level Test Scenarios -#### CanvasNet-Canvas-Surface-Construction: Constructor Sets Width and Height +##### CanvasNet-Canvas-Surface-Construction: Constructor Sets Width and Height **Tests**: `Surface_Constructor_ValidDimensions_SetsWidthAndHeight`, `Surface_Constructor_WidthAtMaximum_Succeeds`, `Surface_Constructor_HeightAtMaximum_Succeeds` @@ -38,14 +38,14 @@ constructor arguments. Additionally, constructs surfaces with width, and separat maximum permitted dimension (8192) and asserts construction succeeds with the requested dimension. -#### CanvasNet-Canvas-Surface-ZeroInitialized: Constructor Produces an All-Zero Buffer +##### CanvasNet-Canvas-Surface-ZeroInitialized: Constructor Produces an All-Zero Buffer **Test**: `Surface_Constructor_ValidDimensions_BufferIsAllZero` Constructs a `Surface` with no explicit initialization and asserts every pixel in every row equals `default(Rgba32)` (all channels zero), confirming the documented fully transparent default. -#### CanvasNet-Canvas-Surface-InvalidWidth: Constructor Rejects Zero/Negative Width +##### CanvasNet-Canvas-Surface-InvalidWidth: Constructor Rejects Zero/Negative Width **Tests**: `Surface_Constructor_ZeroWidth_ThrowsArgumentOutOfRangeException`, `Surface_Constructor_NegativeWidth_ThrowsArgumentOutOfRangeException` @@ -53,7 +53,7 @@ Constructs a `Surface` with no explicit initialization and asserts every pixel i Attempts to construct a `Surface` with a zero width, and separately with a negative width. Asserts `ArgumentOutOfRangeException` is thrown in both cases. -#### CanvasNet-Canvas-Surface-InvalidHeight: Constructor Rejects Zero/Negative Height +##### CanvasNet-Canvas-Surface-InvalidHeight: Constructor Rejects Zero/Negative Height **Tests**: `Surface_Constructor_ZeroHeight_ThrowsArgumentOutOfRangeException`, `Surface_Constructor_NegativeHeight_ThrowsArgumentOutOfRangeException` @@ -61,21 +61,21 @@ Attempts to construct a `Surface` with a zero width, and separately with a negat Attempts to construct a `Surface` with a zero height, and separately with a negative height. Asserts `ArgumentOutOfRangeException` is thrown in both cases. -#### CanvasNet-Canvas-Surface-WidthExceedsMaximum: Constructor Rejects Width Exceeding the Maximum Dimension +##### CanvasNet-Canvas-Surface-WidthExceedsMaximum: Constructor Rejects Width Exceeding the Maximum Dimension **Test**: `Surface_Constructor_WidthExceedsMaximum_ThrowsArgumentOutOfRangeException` Attempts to construct a `Surface` with a width one greater than the maximum permitted dimension (8193). Asserts `ArgumentOutOfRangeException` is thrown. -#### CanvasNet-Canvas-Surface-HeightExceedsMaximum: Constructor Rejects Height Exceeding the Maximum Dimension +##### CanvasNet-Canvas-Surface-HeightExceedsMaximum: Constructor Rejects Height Exceeding the Maximum Dimension **Test**: `Surface_Constructor_HeightExceedsMaximum_ThrowsArgumentOutOfRangeException` Attempts to construct a `Surface` with a height one greater than the maximum permitted dimension (8193). Asserts `ArgumentOutOfRangeException` is thrown. -#### CanvasNet-Canvas-Surface-MaxDimensionPublic: MaxDimension Is Publicly Accessible +##### CanvasNet-Canvas-Surface-MaxDimensionPublic: MaxDimension Is Publicly Accessible **Test**: `Surface_MaxDimension_IsPubliclyAccessible_Equals8192` @@ -86,7 +86,7 @@ alone cannot distinguish `public` from `internal` here, since the test assembly `InternalsVisibleTo` access to the main assembly, so the reflection-based accessibility check is required to prove the requirement. -#### CanvasNet-Canvas-Surface-PixelGet / CanvasNet-Canvas-Surface-PixelSet: Indexer Set Then Get Round-Trips +##### CanvasNet-Canvas-Surface-PixelGet / CanvasNet-Canvas-Surface-PixelSet: Indexer Set Then Get Round-Trips **Test**: `Surface_Indexer_SetThenGet_ReturnsStoredPixel` @@ -94,7 +94,7 @@ Constructs a `Surface`, stores a distinct `Rgba32` value at a coordinate via the then reads the same coordinate via the indexer getter. Asserts the returned value exactly matches the stored value. -#### CanvasNet-Canvas-Surface-RowSpanBytes: Writes Through the Byte Row Span Are Visible via the Indexer +##### CanvasNet-Canvas-Surface-RowSpanBytes: Writes Through the Byte Row Span Are Visible via the Indexer **Tests**: `Surface_GetRowSpanBytes_WriteToSpan_IndexerReflectsChange`, `Surface_GetRowSpanBytes_BoundaryWidths_ReturnsWidthTimesFourLength` @@ -110,7 +110,7 @@ codec-round-trip regression at these same boundary widths is a system-level scen verification design, `../../canvas-net.md` โ€” because it exercises the `Codecs` โ†’ `Surface` integration boundary rather than `Surface` in isolation.) -#### CanvasNet-Canvas-Surface-RowSpanPixels: Writes Through the Pixel Row Span Are Visible via the Indexer +##### CanvasNet-Canvas-Surface-RowSpanPixels: Writes Through the Pixel Row Span Are Visible via the Indexer **Tests**: `Surface_GetRowSpan_WriteToSpan_IndexerReflectsChange`, `Surface_GetRowSpan_BoundaryWidths_ReturnsWidthLength` @@ -121,7 +121,7 @@ the span aliases the surface's own storage. Additionally, constructs surfaces at internal row-padding boundary widths listed above and asserts `GetRowSpan` always returns a span of exactly `Width` pixels regardless of the internal padding. -#### CanvasNet-Canvas-Surface-Crop: Crop Returns the Expected Pixels +##### CanvasNet-Canvas-Surface-Crop: Crop Returns the Expected Pixels **Tests**: `Surface_Crop_ValidRegion_ReturnsExpectedPixels`, `Surface_Crop_BoundaryWidths_ReturnsExpectedPixels` @@ -132,7 +132,7 @@ corresponding source pixel. Additionally, repeats this at each internal row-padd width, cropping the entire surface, to confirm `Crop` remains byte-exact regardless of the internal padding. -#### CanvasNet-Canvas-Surface-CropIndependent: Cropped Result and Source Do Not Share Storage +##### CanvasNet-Canvas-Surface-CropIndependent: Cropped Result and Source Do Not Share Storage **Tests**: `Surface_Crop_ModifyResult_DoesNotAffectSource`, `Surface_Crop_ModifySource_DoesNotAffectResult` @@ -141,45 +141,45 @@ Crops a surface, then mutates the cropped result and asserts the source is unaff mutates the source after cropping and asserts the previously cropped result is unaffected. Together these confirm `Crop` produces an independent copy in both directions. -#### CanvasNet-Canvas-Surface-CropInvalidX: Crop Rejects Negative X +##### CanvasNet-Canvas-Surface-CropInvalidX: Crop Rejects Negative X **Test**: `Surface_Crop_NegativeX_ThrowsArgumentOutOfRangeException` Calls `Crop` with a negative `x` argument and asserts `ArgumentOutOfRangeException` is thrown. -#### CanvasNet-Canvas-Surface-CropInvalidY: Crop Rejects Negative Y +##### CanvasNet-Canvas-Surface-CropInvalidY: Crop Rejects Negative Y **Test**: `Surface_Crop_NegativeY_ThrowsArgumentOutOfRangeException` Calls `Crop` with a negative `y` argument and asserts `ArgumentOutOfRangeException` is thrown. -#### CanvasNet-Canvas-Surface-CropInvalidWidth: Crop Rejects Zero Width +##### CanvasNet-Canvas-Surface-CropInvalidWidth: Crop Rejects Zero Width **Test**: `Surface_Crop_ZeroWidth_ThrowsArgumentOutOfRangeException` Calls `Crop` with a zero `width` argument and asserts `ArgumentOutOfRangeException` is thrown. -#### CanvasNet-Canvas-Surface-CropInvalidHeight: Crop Rejects Zero Height +##### CanvasNet-Canvas-Surface-CropInvalidHeight: Crop Rejects Zero Height **Test**: `Surface_Crop_ZeroHeight_ThrowsArgumentOutOfRangeException` Calls `Crop` with a zero `height` argument and asserts `ArgumentOutOfRangeException` is thrown. -#### CanvasNet-Canvas-Surface-CropExceedsWidth: Crop Rejects a Region Wider Than the Source +##### CanvasNet-Canvas-Surface-CropExceedsWidth: Crop Rejects a Region Wider Than the Source **Test**: `Surface_Crop_WidthExceedsSourceBounds_ThrowsArgumentOutOfRangeException` Calls `Crop` with `x + width` exceeding the source `Width` and asserts `ArgumentOutOfRangeException` is thrown. -#### CanvasNet-Canvas-Surface-CropExceedsHeight: Crop Rejects a Region Taller Than the Source +##### CanvasNet-Canvas-Surface-CropExceedsHeight: Crop Rejects a Region Taller Than the Source **Test**: `Surface_Crop_HeightExceedsSourceBounds_ThrowsArgumentOutOfRangeException` Calls `Crop` with `y + height` exceeding the source `Height` and asserts `ArgumentOutOfRangeException` is thrown. -#### CanvasNet-Canvas-Surface-PremultiplyAlpha: PremultiplyAlpha Computes Expected Pixels and Round-Trips +##### CanvasNet-Canvas-Surface-PremultiplyAlpha: PremultiplyAlpha Computes Expected Pixels and Round-Trips **Tests**: `Surface_PremultiplyAlpha_VariousValues_ComputesExpectedPixels`, `Surface_PremultiplyThenUnpremultiplyAlpha_PartialAlpha_RoundTripsWithinTolerance`, @@ -198,7 +198,7 @@ above each multiple-of-16 boundary), with a distinct color/alpha pair per pixel asserts every visible pixel across every row matches an independently computed expected value - confirming no row-offset or padding-boundary corruption at non-16-aligned widths. -#### CanvasNet-Canvas-Surface-UnpremultiplyAlpha: UnpremultiplyAlpha Computes Expected Pixels +##### CanvasNet-Canvas-Surface-UnpremultiplyAlpha: UnpremultiplyAlpha Computes Expected Pixels **Tests**: `Surface_UnpremultiplyAlpha_VariousValues_ComputesExpectedPixels`, `Surface_UnpremultiplyAlpha_AlphaZero_ResultIsZeroRgb`, @@ -218,7 +218,7 @@ degenerate `alpha == 0` case out of scope) per pixel position, and asserts every across every row matches an independently computed expected value - confirming no row-offset or padding-boundary corruption at non-16-aligned widths. -#### CanvasNet-Canvas-Surface-CompositeOverSurface: CompositeOver(Surface) Matches Independently Computed Results +##### CanvasNet-Canvas-Surface-CompositeOverSurface: CompositeOver(Surface) Matches Independently Computed Results **Tests**: `Surface_CompositeOverSurface_OpaqueForeground_ReplacesBackground`, `Surface_CompositeOverSurface_TransparentForeground_LeavesBackgroundUnchanged`, @@ -235,14 +235,14 @@ nonzero-alpha background never reaches. Separately, composites two partially tra and asserts the result exactly matches a value independently hand-computed via the Porter-Duff "over" formula (not by re-deriving the same formula under test). -#### CanvasNet-Canvas-Surface-CompositeOverSurfaceNull: CompositeOver(Surface) Rejects a Null Foreground +##### CanvasNet-Canvas-Surface-CompositeOverSurfaceNull: CompositeOver(Surface) Rejects a Null Foreground **Test**: `Surface_CompositeOverSurface_NullForeground_ThrowsArgumentNullException` Calls `CompositeOver` with a `null` foreground surface and asserts `ArgumentNullException` is thrown. -#### CanvasNet-Canvas-Surface-CompositeOverSurfaceDimensionMismatch: CompositeOver(Surface) Rejects a Size Mismatch +##### CanvasNet-Canvas-Surface-CompositeOverSurfaceDimensionMismatch: CompositeOver(Surface) Rejects a Size Mismatch **Tests**: `Surface_CompositeOverSurface_WidthMismatch_ThrowsArgumentException`, `Surface_CompositeOverSurface_HeightMismatch_ThrowsArgumentException` @@ -250,7 +250,7 @@ thrown. Calls `CompositeOver` with a foreground surface whose width differs from this surface's, and separately whose height differs, and asserts `ArgumentException` is thrown in both cases. -#### CanvasNet-Canvas-Surface-CompositeOverColor: CompositeOver(Rgba32) Matches Independently Computed Results +##### CanvasNet-Canvas-Surface-CompositeOverColor: CompositeOver(Rgba32) Matches Independently Computed Results **Tests**: `Surface_CompositeOverColor_OpaqueColor_ReplacesBackground`, `Surface_CompositeOverColor_TransparentColor_LeavesBackgroundUnchanged`, @@ -270,7 +270,7 @@ Separately, composites a constant opaque color over a multi-row, multi-column su every pixel is replaced, confirming the per-row loop is applied uniformly across the whole surface, not just a single pixel. -#### CanvasNet-Canvas-Surface-Clear: Clear Overwrites Every Pixel Without Blending +##### CanvasNet-Canvas-Surface-Clear: Clear Overwrites Every Pixel Without Blending **Tests**: `Surface_Clear_MultiRowSurfaceWithNonAlignedWidth_OverwritesEveryPixel`, `Surface_Clear_AnyRgba32Value_NeverThrowsAndAppliesExactly`, @@ -289,7 +289,7 @@ unchanged. The system-level test additionally exercises `Clear` end-to-end throu `Canvas`/`Surface` API surface and reads a pixel back to confirm the expected color, per `docs/verification/canvas-net.md`'s system-level evidence contract. -#### CanvasNet-Canvas-Surface-CompositeOverSpan: CompositeOverSpan Matches Independently Computed Results +##### CanvasNet-Canvas-Surface-CompositeOverSpan: CompositeOverSpan Matches Independently Computed Results **Tests**: `Surface_CompositeOverSpan_FullCoverage_MatchesCompositeOverColor`, `Surface_CompositeOverSpan_ZeroCoverage_LeavesBackgroundUnchanged`, @@ -311,7 +311,7 @@ calls `CompositeOverSpan` with an empty coverage span starting exactly at `x == this is accepted as a no-op rather than throwing, confirming the boundary case of a zero-length run at the surface's right edge is valid. -#### CanvasNet-Canvas-Surface-CompositeOverSpanZeroCoveragePreservesBytes: Zero/Negative Coverage Preserves Original Bytes +##### CanvasNet-Canvas-Surface-CompositeOverSpanZeroCoveragePreservesBytes: Zero/Negative Coverage Preserves Original Bytes **Tests**: `Surface_CompositeOverSpan_ZeroCoverageOnTransparentPixelWithNonzeroColor_LeavesPixelUnchanged`, `Surface_CompositeOverSpan_NegativeCoverageOnTransparentPixelWithNonzeroColor_LeavesPixelUnchanged`, @@ -328,7 +328,7 @@ mixing a zero-coverage column (over a fully transparent, nonzero-RGB pixel) with column and asserts only the full-coverage column is modified, confirming the byte-restoration step applies independently per column rather than skipping the whole row. -#### CanvasNet-Canvas-Surface-CompositeOverSpanValidation: CompositeOverSpan Rejects Out-of-Range Row/Column Arguments +##### CanvasNet-Canvas-Surface-CompositeOverSpanValidation: CompositeOverSpan Rejects Out-of-Range Row/Column Arguments **Tests**: `Surface_CompositeOverSpan_NegativeY_ThrowsArgumentOutOfRangeException`, `Surface_CompositeOverSpan_YAtHeight_ThrowsArgumentOutOfRangeException`, @@ -341,7 +341,7 @@ Separately, calls `CompositeOverSpan` with a negative `x`, and separately with a whose `x + coverage.Length` exceeds `Width`, and asserts `ArgumentOutOfRangeException` is thrown in both cases. -#### CanvasNet-Canvas-Surface-CompositeOverSpanPerPixelColor: Per-Pixel Colors Match the Constant-Color Overload +##### CanvasNet-Canvas-Surface-CompositeOverSpanPerPixelColor: Per-Pixel Colors Match the Constant-Color Overload **Tests**: `Surface_CompositeOverSpan_PerPixelColors_MatchesConstantColorOverload_WhenAllColorsEqual`, @@ -359,7 +359,7 @@ applied at full coverage, that zero coverage leaves the background unchanged, th `colors`/`coverage` length mismatch throws `ArgumentException`, and that the internal workspace-reusing per-pixel-color overload matches the public overload's output exactly. -#### CanvasNet-Canvas-Surface-Disposal: Dispose Is Idempotent and Blocks Further Use +##### CanvasNet-Canvas-Surface-Disposal: Dispose Is Idempotent and Blocks Further Use **Tests**: `Surface_Dispose_CalledOnce_DoesNotThrow`, `Surface_Dispose_CalledTwice_DoesNotThrow`, `Surface_IndexerGet_AfterDispose_ThrowsObjectDisposedException`, @@ -389,7 +389,7 @@ through either the public or the internal fill entry point. `CompositeOver(Surface)` additionally rejects a disposed `foreground` argument, even when this surface itself has not been disposed. -#### Rgba32 Sanity Checks (no requirement link) +##### Rgba32 Sanity Checks (no requirement link) **Tests**: `Rgba32_FieldAssignment_StoresChannelValues`, `Rgba32_Equals_SameChannelValues_ReturnsTrue` @@ -398,7 +398,7 @@ that two instances with identical channel values compare equal via `Equals` and operators. These sanity tests support the other `Surface` scenarios above (which depend on `Rgba32` equality) but are not independently linked to a requirement. -### Acceptance Criteria +#### Acceptance Criteria A unit test run passes when every requirement-linked scenario above, plus the two `Rgba32` sanity tests and the additional boundary-width regression tests, pass without error or diff --git a/docs/verification/canvas-net/codecs.md b/docs/verification/canvas-net/codecs.md index 36b74da3..df6609f9 100644 --- a/docs/verification/canvas-net/codecs.md +++ b/docs/verification/canvas-net/codecs.md @@ -33,3 +33,13 @@ The `Codecs` subsystem's verification passes when every unit test scenario descr codec unit verification documents under `codecs/`, and every `CanvasNet_SystemIntegration_*` round-trip/load test referenced by the `Codecs` subsystem requirements, pass without error or unexpected exception. + +### Test Scenarios + +The `Codecs` subsystem's test scenarios are those named in each of the five constituent unit +verification documents under `codecs/` (_BmpCodec_, _PngCodec_, _TiffCodec_, _JpegCodec_, and +_GifCodec Unit Verification Design_), covering `Load`/`Save`/`GetInfo` normal-path round-trips, +malformed/truncated-input rejection, and `ImageInfo.CanDecode`/`UnsupportedImageFeatureException` +well-formed-but-unsupported handling, plus the system-level `CanvasNet_SystemIntegration_*` +round-trip (or, for `GifCodec`, load-only) tests that exercise each codec end-to-end against a +`Surface` (see the system verification design, `../canvas-net.md`). diff --git a/docs/verification/canvas-net/codecs/bmp-codec.md b/docs/verification/canvas-net/codecs/bmp-codec.md index 4d561791..408c5042 100644 --- a/docs/verification/canvas-net/codecs/bmp-codec.md +++ b/docs/verification/canvas-net/codecs/bmp-codec.md @@ -1,9 +1,9 @@ -## BmpCodec Unit Verification Design +### BmpCodec Unit Verification Design This document describes the unit-level verification strategy for the `BmpCodec` class (and the supporting `BmpBitDepth` enum). -### Verification Approach +#### Verification Approach The `BmpCodec` unit is verified through unit tests that exercise `Load` and `Save` in isolation, using `MemoryStream` for all in-memory round-trip, padding, and orientation checks, and @@ -15,7 +15,7 @@ exception types. Unit tests reside in `BmpCodecTests.cs` within the `DemaConsulting.CanvasNet.Tests` project. -### Test Environment +#### Test Environment - **Framework**: xUnit v3 running under the .NET SDK - **Execution**: `dotnet test` invoked by `build.ps1` and the CI pipeline @@ -24,9 +24,9 @@ Unit tests reside in `BmpCodecTests.cs` within the `DemaConsulting.CanvasNet.Tes use a uniquely generated temporary file deleted in a `finally` block; no shared state between tests -### Unit-Level Test Scenarios +#### Unit-Level Test Scenarios -#### CanvasNet-Codecs-BmpCodec-SaveLoad24Bit: 24-bit Round-Trip Preserves RGB and Forces Opaque Alpha +##### CanvasNet-Codecs-BmpCodec-SaveLoad24Bit: 24-bit Round-Trip Preserves RGB and Forces Opaque Alpha **Test**: `BmpCodec_SaveThenLoad_24Bit_ReturnsExpectedPixelsWithOpaqueAlpha` @@ -34,14 +34,14 @@ Builds a surface with varied, non-opaque per-pixel values, saves it at `BmpBitDe `MemoryStream`, loads it back, and asserts every pixel's R/G/B values match the source exactly and every pixel's alpha is 255, regardless of the source alpha. -#### CanvasNet-Codecs-BmpCodec-SaveLoad32Bit: 32-bit Round-Trip Preserves RGBA Exactly +##### CanvasNet-Codecs-BmpCodec-SaveLoad32Bit: 32-bit Round-Trip Preserves RGBA Exactly **Test**: `BmpCodec_SaveThenLoad_32Bit_ReturnsExpectedPixelsIncludingAlpha` Builds a surface with varied pixel values, saves it at `BmpBitDepth.Bit32` to a `MemoryStream`, loads it back, and asserts every pixel (including alpha) matches the source exactly. -#### CanvasNet-Codecs-BmpCodec-RowPadding: Rows Are Padded to a Multiple of Four Bytes +##### CanvasNet-Codecs-BmpCodec-RowPadding: Rows Are Padded to a Multiple of Four Bytes **Test**: `BmpCodec_Save_WidthRequiringPadding_ProducesCorrectlyPaddedRows` @@ -50,7 +50,7 @@ next multiple of four) and asserts, directly on the raw saved bytes: the pixel-d length equals `paddedRowBytes * height`, and the three trailing bytes of every row are zero. Also confirms the surface still round-trips correctly through `Load` despite the padding. -#### CanvasNet-Codecs-BmpCodec-BottomUpOrientation: Rows Are Written Bottom-Up +##### CanvasNet-Codecs-BmpCodec-BottomUpOrientation: Rows Are Written Bottom-Up **Test**: `BmpCodec_Save_DistinctTopAndBottomRows_WritesBottomRowFirstInFile` @@ -60,72 +60,72 @@ Builds a 2x2 surface with distinct, known pixel values in its top and bottom row *last* (bottom) row in BGRA order โ€” proving bottom-up orientation independently of the `Load` path, which could otherwise mask a symmetric orientation bug. -#### CanvasNet-Codecs-BmpCodec-LoadFromPath / CanvasNet-Codecs-BmpCodec-SaveToPath: File Path Overloads Round-Trip +##### CanvasNet-Codecs-BmpCodec-LoadFromPath / CanvasNet-Codecs-BmpCodec-SaveToPath: File Path Overloads Round-Trip **Test**: `BmpCodec_Load_FromFilePath_ReturnsExpectedPixels` Saves a surface to a temporary file via `Save(Surface, string, BmpBitDepth)`, loads it back via `Load(string)`, asserts every pixel matches, and deletes the temporary file in a `finally` block. -#### CanvasNet-Codecs-BmpCodec-SaveNullCanvas: Save Rejects a Null Surface +##### CanvasNet-Codecs-BmpCodec-SaveNullCanvas: Save Rejects a Null Surface **Test**: `BmpCodec_Save_NullCanvas_ThrowsArgumentNullException` Calls `Save` with a null `surface` and a valid stream, and asserts `ArgumentNullException` is thrown. -#### CanvasNet-Codecs-BmpCodec-SaveNullStream: Save Rejects a Null Stream +##### CanvasNet-Codecs-BmpCodec-SaveNullStream: Save Rejects a Null Stream **Test**: `BmpCodec_Save_NullStream_ThrowsArgumentNullException` Calls `Save` with a valid surface and a null stream, and asserts `ArgumentNullException` is thrown. -#### CanvasNet-Codecs-BmpCodec-SaveNullPath: Save Rejects a Null Path +##### CanvasNet-Codecs-BmpCodec-SaveNullPath: Save Rejects a Null Path **Test**: `BmpCodec_Save_NullPath_ThrowsArgumentNullException` Calls `Save` with a valid surface and a null path, and asserts `ArgumentNullException` is thrown. -#### CanvasNet-Codecs-BmpCodec-SaveEmptyPath: Save Rejects an Empty Path +##### CanvasNet-Codecs-BmpCodec-SaveEmptyPath: Save Rejects an Empty Path **Test**: `BmpCodec_Save_EmptyPath_ThrowsArgumentException` Calls `Save` with a valid surface and an empty path, and asserts `ArgumentException` is thrown. -#### CanvasNet-Codecs-BmpCodec-SaveInvalidBitDepth: Save Rejects an Undefined Bit Depth +##### CanvasNet-Codecs-BmpCodec-SaveInvalidBitDepth: Save Rejects an Undefined Bit Depth **Test**: `BmpCodec_Save_UndefinedBitDepth_ThrowsArgumentOutOfRangeException` Calls `Save` with a valid surface and stream but an undefined `BmpBitDepth` value cast from an out-of-range integer, and asserts `ArgumentOutOfRangeException` is thrown. -#### CanvasNet-Codecs-BmpCodec-LoadNullStream: Load Rejects a Null Stream +##### CanvasNet-Codecs-BmpCodec-LoadNullStream: Load Rejects a Null Stream **Test**: `BmpCodec_Load_NullStream_ThrowsArgumentNullException` Calls `Load` with a null stream and asserts `ArgumentNullException` is thrown. -#### CanvasNet-Codecs-BmpCodec-LoadNullPath: Load Rejects a Null Path +##### CanvasNet-Codecs-BmpCodec-LoadNullPath: Load Rejects a Null Path **Test**: `BmpCodec_Load_NullPath_ThrowsArgumentNullException` Calls `Load` with a null path and asserts `ArgumentNullException` is thrown. -#### CanvasNet-Codecs-BmpCodec-LoadEmptyPath: Load Rejects an Empty Path +##### CanvasNet-Codecs-BmpCodec-LoadEmptyPath: Load Rejects an Empty Path **Test**: `BmpCodec_Load_EmptyPath_ThrowsArgumentException` Calls `Load` with an empty path and asserts `ArgumentException` is thrown. -#### CanvasNet-Codecs-BmpCodec-LoadBadSignature: Load Rejects a Missing "BM" Signature +##### CanvasNet-Codecs-BmpCodec-LoadBadSignature: Load Rejects a Missing "BM" Signature **Test**: `BmpCodec_Load_BadSignature_ThrowsInvalidDataException` Builds a 14-byte header with an incorrect signature and asserts `Load` throws `InvalidDataException`. -#### CanvasNet-Codecs-BmpCodec-LoadUnsupportedHeaderSize: Load Rejects Non-BITMAPINFOHEADER Sizes +##### CanvasNet-Codecs-BmpCodec-LoadUnsupportedHeaderSize: Load Rejects Non-BITMAPINFOHEADER Sizes **Tests**: `BmpCodec_Load_Bitmapcoreheader12Bytes_ThrowsInvalidDataException`, `BmpCodec_Load_UnsupportedHeaderSizeV4_ThrowsInvalidDataException` @@ -133,26 +133,26 @@ Builds a 14-byte header with an incorrect signature and asserts `Load` throws Builds a header declaring `biSize = 12` (BITMAPCOREHEADER), and separately `biSize = 108` (BITMAPV4HEADER), and asserts `Load` throws `InvalidDataException` for each. -#### CanvasNet-Codecs-BmpCodec-LoadUnsupportedCompression: Load Rejects Non-BI_RGB Compression +##### CanvasNet-Codecs-BmpCodec-LoadUnsupportedCompression: Load Rejects Non-BI_RGB Compression **Test**: `BmpCodec_Load_RleCompression_ThrowsInvalidDataException` Builds a header declaring `biCompression = 1` (BI_RLE8) and asserts `Load` throws `InvalidDataException`. -#### CanvasNet-Codecs-BmpCodec-LoadUnsupportedBitDepth: Load Rejects Palette-Based Bit Depths +##### CanvasNet-Codecs-BmpCodec-LoadUnsupportedBitDepth: Load Rejects Palette-Based Bit Depths **Test**: `BmpCodec_Load_8BitPaletteDepth_ThrowsInvalidDataException` Builds a header declaring `biBitCount = 8` and asserts `Load` throws `InvalidDataException`. -#### CanvasNet-Codecs-BmpCodec-LoadUnsupportedTopDown: Load Rejects Negative Height +##### CanvasNet-Codecs-BmpCodec-LoadUnsupportedTopDown: Load Rejects Negative Height **Test**: `BmpCodec_Load_TopDownNegativeHeight_ThrowsInvalidDataException` Builds a header declaring `biHeight = -1` and asserts `Load` throws `InvalidDataException`. -#### CanvasNet-Codecs-BmpCodec-LoadExceedsMaxDimension: Load Rejects Dimensions Exceeding Surface.MaxDimension +##### CanvasNet-Codecs-BmpCodec-LoadExceedsMaxDimension: Load Rejects Dimensions Exceeding Surface.MaxDimension **Tests**: `BmpCodec_Load_WidthExceedsMaxDimension_ThrowsInvalidDataException`, `BmpCodec_Load_HeightExceedsMaxDimension_ThrowsInvalidDataException` @@ -163,14 +163,14 @@ separately `biHeight` one greater, and asserts `Load` throws `InvalidDataExcepti cases, confirming the dimension check happens before any row/stride arithmetic performed later in `Load`. -#### CanvasNet-Codecs-BmpCodec-LoadTruncatedStream: Load Rejects a Truncated Stream +##### CanvasNet-Codecs-BmpCodec-LoadTruncatedStream: Load Rejects a Truncated Stream **Test**: `BmpCodec_Load_TruncatedStream_ThrowsInvalidDataException` Builds a valid header for a 1x1, 24-bit image but omits the pixel data that should follow it, and asserts `Load` throws `InvalidDataException`. -#### CanvasNet-Codecs-BmpCodec-GetInfo: GetInfo Reports Dimensions/Channels/Alpha Without Decoding Pixels +##### CanvasNet-Codecs-BmpCodec-GetInfo: GetInfo Reports Dimensions/Channels/Alpha Without Decoding Pixels **Tests**: `BmpCodec_GetInfo_24Bit_ReturnsExpectedInfoWithoutAlpha`, `BmpCodec_GetInfo_32Bit_ReturnsExpectedInfoWithAlpha`, `BmpCodec_GetInfo_NeverReadsPixelData`, @@ -185,7 +185,7 @@ succeeds. Proves `GetInfo` does not enforce `Surface.MaxDimension` by building a width without throwing, and then asserting `Load` on the exact same bytes still throws `InvalidDataException`. -#### CanvasNet-Codecs-BmpCodec-GetInfoValidation: GetInfo Rejects Invalid Arguments and Malformed Headers +##### CanvasNet-Codecs-BmpCodec-GetInfoValidation: GetInfo Rejects Invalid Arguments and Malformed Headers **Tests**: `BmpCodec_GetInfo_NullStream_ThrowsArgumentNullException`, `BmpCodec_GetInfo_NullPath_ThrowsArgumentNullException`, @@ -197,7 +197,7 @@ empty path, and `GetInfo(Stream)` with a 14-byte header carrying an incorrect si `ArgumentNullException`, `ArgumentNullException`, `ArgumentException`, and `InvalidDataException` respectively โ€” the same exception contract as the corresponding `Load` scenarios. -### Acceptance Criteria +#### Acceptance Criteria A unit test run passes when all thirty test methods above pass without error or unexpected exception; any unexpected exception type or wrong return/byte value constitutes a failure. diff --git a/docs/verification/canvas-net/codecs/gif-codec.md b/docs/verification/canvas-net/codecs/gif-codec.md index 6a5635f2..905011a9 100644 --- a/docs/verification/canvas-net/codecs/gif-codec.md +++ b/docs/verification/canvas-net/codecs/gif-codec.md @@ -1,8 +1,8 @@ -## GifCodec Unit Verification Design +### GifCodec Unit Verification Design This document describes the unit-level verification strategy for the `GifCodec` class. -### Verification Approach +#### Verification Approach The `GifCodec` unit is verified through two complementary test files. `GifCodecTests.cs` exercises `Load` and `GetInfo` in isolation, using hand-built `MemoryStream` byte sequences (constructed with @@ -18,7 +18,7 @@ decode their first frame without throwing. Because `GifCodec`'s only documented Unit tests reside in `GifCodecTests.cs` and `GifFixtureTests.cs` within the `DemaConsulting.CanvasNet.Tests` project. -### Test Environment +#### Test Environment - **Framework**: xUnit v3 running under the .NET SDK - **Execution**: `dotnet test` invoked by `build.ps1` and the CI pipeline @@ -27,9 +27,9 @@ Unit tests reside in `GifCodecTests.cs` and `GifFixtureTests.cs` within the test reads a read-only file copied to the test output directory by the test project's `GifFixtures\**` content item; no shared state between tests -### Unit-Level Test Scenarios +#### Unit-Level Test Scenarios -#### CanvasNet-Codecs-GifCodec-Load: Load Decodes First-Frame Pixels Matching the Color Table +##### CanvasNet-Codecs-GifCodec-Load: Load Decodes First-Frame Pixels Matching the Color Table **Tests**: `GifCodec_Load_FirstFrame_DecodesExpectedPixels`, `GifCodec_Load_FromFilePath_ReturnsExpectedPixels`, @@ -45,7 +45,7 @@ its raw bytes and asserts multiple sampled pixels (all four corners plus the cen sole Image Descriptor declares its own Local Color Table, asserting the decoded pixels match that Local Color Table's entries exactly. -#### CanvasNet-Codecs-GifCodec-FirstFrameOnly: Multi-Frame GIFs Decode Only the First Frame +##### CanvasNet-Codecs-GifCodec-FirstFrameOnly: Multi-Frame GIFs Decode Only the First Frame **Tests**: `GifCodec_Load_MultiFrame_DecodesFirstFrameOnlyWithoutThrowing`, `GifCodec_Load_AnimatedFixture_DecodesFirstFrameWithoutThrowing` @@ -55,7 +55,7 @@ succeeds without throwing, returning a surface matching the first Image Descript for each of the 3 animated real-world fixtures (each containing 3 frames), asserts `Load` succeeds and returns a surface sized to the Logical Screen Descriptor's declared dimensions. -#### CanvasNet-Codecs-GifCodec-Interlacing: Interlaced Rows Are Restored to Normal Order +##### CanvasNet-Codecs-GifCodec-Interlacing: Interlaced Rows Are Restored to Normal Order **Test**: `GifCodec_Load_InterlacedImage_DeinterlacesCorrectly` @@ -64,7 +64,7 @@ data encodes pixel rows in GIF's four-pass interlaced order (computed as the exa production de-interlacing algorithm), and asserts the decoded surface's rows appear in normal top-to-bottom order matching the pre-interlacing source pixel grid. -#### CanvasNet-Codecs-GifCodec-TransparentColorIndex: Transparent Color Index Is Read From Byte Offset 3 +##### CanvasNet-Codecs-GifCodec-TransparentColorIndex: Transparent Color Index Is Read From Byte Offset 3 **Test**: `GifCodec_Load_TransparentColorIndex_ProducesAlphaZero` @@ -74,7 +74,7 @@ actual transparent color index (offset 3), and whose Image Descriptor's pixel da transparent index. Asserts the decoded pixel at that index has alpha 0, proving the codec reads the transparent index from byte offset 3 and not from a delay-time byte such as offset 1. -#### CanvasNet-Codecs-GifCodec-SubRegionBlit: Image Descriptor Is Blitted at Its Declared Offset +##### CanvasNet-Codecs-GifCodec-SubRegionBlit: Image Descriptor Is Blitted at Its Declared Offset **Test**: `GifCodec_Load_SubRegionImageDescriptor_BlitsAtCorrectOffset` @@ -82,7 +82,7 @@ Builds a hand-crafted GIF whose Logical Screen Descriptor declares a larger canv Image Descriptor, which itself declares a non-zero left/top offset, and asserts the decoded surface's pixels appear at that offset rather than at (0, 0). -#### CanvasNet-Codecs-GifCodec-LoadNullStream / LoadNullPath / LoadEmptyPath: Load Rejects Invalid Arguments +##### CanvasNet-Codecs-GifCodec-LoadNullStream / LoadNullPath / LoadEmptyPath: Load Rejects Invalid Arguments **Tests**: `GifCodec_Load_NullStream_ThrowsArgumentNullException`, `GifCodec_Load_NullPath_ThrowsArgumentNullException`, @@ -92,14 +92,14 @@ Calls `Load(Stream)` with a null stream, `Load(string)` with a null path, and `L an empty path, asserting `ArgumentNullException`, `ArgumentNullException`, and `ArgumentException` respectively. -#### CanvasNet-Codecs-GifCodec-LoadBadSignature: Load Rejects a Missing GIF Signature +##### CanvasNet-Codecs-GifCodec-LoadBadSignature: Load Rejects a Missing GIF Signature **Test**: `GifCodec_Load_BadSignature_ThrowsInvalidDataException` Builds a stream beginning with "NOTAGIF" instead of "GIF87a"/"GIF89a" and asserts `Load` throws `InvalidDataException`. -#### CanvasNet-Codecs-GifCodec-LoadExceedsMaxDimension: Load Rejects Dimensions Exceeding Surface.MaxDimension +##### CanvasNet-Codecs-GifCodec-LoadExceedsMaxDimension: Load Rejects Dimensions Exceeding Surface.MaxDimension **Tests**: `GifCodec_Load_WidthExceedsMaxDimension_ThrowsInvalidDataException`, `GifCodec_Load_HeightExceedsMaxDimension_ThrowsInvalidDataException` @@ -109,7 +109,7 @@ separately height one greater, and asserts `Load` throws `InvalidDataException` `ArgumentOutOfRangeException` that would otherwise escape from `Surface`'s constructor) in both cases. -#### CanvasNet-Codecs-GifCodec-NoColorTable: Load Rejects Missing Color Table +##### CanvasNet-Codecs-GifCodec-NoColorTable: Load Rejects Missing Color Table **Tests**: `GifCodec_Load_NoColorTable_ThrowsInvalidDataException`, `GifCodec_Load_SecondFrameMissingColorTable_ThrowsInvalidDataException` @@ -122,7 +122,7 @@ asserts `Load` still throws `InvalidDataException` even though this codec never frame's pixel data - proving every Image Descriptor's color table is validated, not merely the first. -#### CanvasNet-Codecs-GifCodec-OutOfRangeMinCodeSize: Load Rejects an Out-of-Range Minimum Code Size in Any Frame +##### CanvasNet-Codecs-GifCodec-OutOfRangeMinCodeSize: Load Rejects an Out-of-Range Minimum Code Size in Any Frame **Tests**: `GifCodec_Load_SecondFrameOutOfRangeMinCodeSize_ThrowsInvalidDataException`, `GifCodec_Load_SecondFrameZeroMinCodeSize_ThrowsInvalidDataException` @@ -134,7 +134,7 @@ second frame's pixel data. Separately, repeats the same scenario with a second-f size of 0, proving the 2-8 range is validated for every Image Descriptor, not merely the first frame whose pixel data `DecodeGifLzw` itself range-checks. -#### CanvasNet-Codecs-GifCodec-MalformedGraphicControlExtension: Load Rejects a Wrong-Length GCE +##### CanvasNet-Codecs-GifCodec-MalformedGraphicControlExtension: Load Rejects a Wrong-Length GCE **Tests**: `GifCodec_Load_MalformedGraphicControlExtension_ThrowsInvalidDataException`, `GifCodec_Load_FragmentedGraphicControlExtension_ThrowsInvalidDataException` @@ -147,35 +147,35 @@ the correct total byte count under a naive concatenating reader) and asserts `Lo sub-block followed by the block terminator, per the GIF89a specification, rather than merely checking the reassembled total length. -#### CanvasNet-Codecs-GifCodec-UnexpectedBlockIntroducer: Load Rejects an Unrecognized Block Byte +##### CanvasNet-Codecs-GifCodec-UnexpectedBlockIntroducer: Load Rejects an Unrecognized Block Byte **Test**: `GifCodec_Load_UnexpectedBlockIntroducer_ThrowsInvalidDataException` Builds a stream whose top-level block introducer byte is `0x99` (neither Extension Introducer, Image Descriptor, nor Trailer), and asserts `Load` throws `InvalidDataException`. -#### CanvasNet-Codecs-GifCodec-TrailingDataAfterTrailer: Load Rejects Trailing Bytes +##### CanvasNet-Codecs-GifCodec-TrailingDataAfterTrailer: Load Rejects Trailing Bytes **Test**: `GifCodec_Load_TrailingDataAfterTrailer_ThrowsInvalidDataException` Builds a well-formed single-frame GIF followed by one extra byte after the Trailer, and asserts `Load` throws `InvalidDataException`. -#### CanvasNet-Codecs-GifCodec-NoImageDescriptor: Load Rejects a Stream With No Frame +##### CanvasNet-Codecs-GifCodec-NoImageDescriptor: Load Rejects a Stream With No Frame **Test**: `GifCodec_Load_NoImageDescriptor_ThrowsInvalidDataException` Builds a GIF containing only a Logical Screen Descriptor and Trailer, with no Image Descriptor in between, and asserts `Load` throws `InvalidDataException`. -#### CanvasNet-Codecs-GifCodec-ImageDescriptorOutOfBounds: Load Rejects an Out-of-Bounds Image Descriptor +##### CanvasNet-Codecs-GifCodec-ImageDescriptorOutOfBounds: Load Rejects an Out-of-Bounds Image Descriptor **Test**: `GifCodec_Load_ImageDescriptorOutOfBounds_ThrowsInvalidDataException` Builds an Image Descriptor whose left offset plus width exceeds the Logical Screen Descriptor's canvas width, and asserts `Load` throws `InvalidDataException`. -#### CanvasNet-Codecs-GifCodec-TruncatedStream: Load Rejects Truncated Streams +##### CanvasNet-Codecs-GifCodec-TruncatedStream: Load Rejects Truncated Streams **Tests**: `GifCodec_Load_TruncatedAtSignature_ThrowsInvalidDataException`, `GifCodec_Load_TruncatedAtColorTable_ThrowsInvalidDataException`, @@ -184,7 +184,7 @@ canvas width, and asserts `Load` throws `InvalidDataException`. Truncates a well-formed GIF stream at three distinct points โ€” mid-signature, mid-color-table, and mid-sub-block โ€” and asserts `Load` throws `InvalidDataException` in each case. -#### CanvasNet-Codecs-GifCodec-InvalidLzwCode: Load Rejects an Unresolvable LZW Code +##### CanvasNet-Codecs-GifCodec-InvalidLzwCode: Load Rejects an Unresolvable LZW Code **Test**: `GifCodec_Load_InvalidLzwCode_ThrowsInvalidDataException` @@ -192,14 +192,14 @@ Builds LZW-compressed data containing a code value that is neither Clear Code, E an already-defined table entry, nor the valid next KwKwK code, and asserts `Load` throws `InvalidDataException`. -#### CanvasNet-Codecs-GifCodec-InsufficientLzwOutput: Load Rejects Short LZW Output +##### CanvasNet-Codecs-GifCodec-InsufficientLzwOutput: Load Rejects Short LZW Output **Test**: `GifCodec_Load_InsufficientLzwOutput_ThrowsInvalidDataException` Builds LZW-compressed data that decodes to fewer index values than the Image Descriptor's declared width times height, and asserts `Load` throws `InvalidDataException`. -#### CanvasNet-Codecs-GifCodec-LzwRequiresEoi: Load Rejects Missing EOI and Output-Count Overrun +##### CanvasNet-Codecs-GifCodec-LzwRequiresEoi: Load Rejects Missing EOI and Output-Count Overrun **Tests**: `GifCodec_Load_LzwStreamMissingEoi_ThrowsInvalidDataException`, `GifCodec_Load_LzwStreamOverrunsExpectedCount_ThrowsInvalidDataException` @@ -212,7 +212,7 @@ decode more index values than the declared width times height before any End-of- is read, and asserts `Load` throws `InvalidDataException` immediately rather than silently truncating the excess output. -#### CanvasNet-Codecs-GifCodec-SubBlockBudget: Load Rejects Excessive Total Sub-Block Data +##### CanvasNet-Codecs-GifCodec-SubBlockBudget: Load Rejects Excessive Total Sub-Block Data **Tests**: `GifCodec_Load_ExcessiveSubBlockData_ThrowsInvalidDataException`, `GifCodec_Load_GraphicControlExtensionExceedsRemainingSubBlockBudget_ThrowsInvalidDataException` @@ -227,7 +227,7 @@ proving the dedicated Graphic Control Extension reader also checks the shared re before decrementing it (rather than letting the budget go negative and silently accepting the extension), consistent with `ReadSubBlocks`' enforcement for every other sub-block chain. -#### CanvasNet-Codecs-GifCodec-GetInfo: GetInfo Reports Dimensions/Channels/CanDecode Without Decoding Pixels Into a Surface +##### CanvasNet-Codecs-GifCodec-GetInfo: GetInfo Reports Dimensions/Channels/CanDecode Without Decoding Pixels Into a Surface **Tests**: `GifCodec_GetInfo_ReturnsExpectedDimensionsChannelsAndCanDecode`, `GifCodec_GetInfo_CorruptFirstFrameLzwData_ReportsCanDecodeFalse`, @@ -264,7 +264,7 @@ reported dimensions match `Load`'s resulting surface dimensions, that `CanDecode that `FrameCount` matches each fixture's true frame count (1 for a solid-color fixture, 3 for an animated fixture). -#### CanvasNet-Codecs-GifCodec-FrameCount: GetInfo Reports the True Frame Count +##### CanvasNet-Codecs-GifCodec-FrameCount: GetInfo Reports the True Frame Count **Tests**: `GifCodec_GetInfo_SingleFrame_ReportsFrameCountOne`, `GifCodec_GetInfo_MultiFrame_ReportsCorrectFrameCount`, @@ -277,7 +277,7 @@ merely the Logical Screen Descriptor - to arrive at the correct count. The fixtu above additionally confirms this against real-world encoder output for both single-frame (solid-color) and multi-frame (animated) fixtures. -#### CanvasNet-Codecs-GifCodec-GetInfoFrameValidation: Frame Counting Does Not Weaken Malformed-Frame Rejection +##### CanvasNet-Codecs-GifCodec-GetInfoFrameValidation: Frame Counting Does Not Weaken Malformed-Frame Rejection **Tests**: `GifCodec_GetInfo_SecondFrameMissingColorTable_ThrowsInvalidDataException` @@ -288,7 +288,7 @@ mirroring the identically-named `Load` test - proving that `GetInfo`'s frame-cou the same per-frame structural validation `Load` applies to every frame, not merely the first frame whose pixel data either method actually processes. -#### CanvasNet-Codecs-GifCodec-GetInfoValidation: GetInfo Rejects Invalid Arguments and a Bad Signature +##### CanvasNet-Codecs-GifCodec-GetInfoValidation: GetInfo Rejects Invalid Arguments and a Bad Signature **Tests**: `GifCodec_GetInfo_NullStream_ThrowsArgumentNullException`, `GifCodec_GetInfo_NullPath_ThrowsArgumentNullException`, @@ -300,7 +300,7 @@ empty path, and `GetInfo(Stream)` with a stream carrying an incorrect signature, `ArgumentNullException`, `ArgumentNullException`, `ArgumentException`, and `InvalidDataException` respectively โ€” the same exception contract as the corresponding `Load` scenarios. -#### CanvasNet-Codecs-GifCodec-GetInfoZeroDimension: GetInfo Rejects a Zero-Dimensioned Logical Screen Descriptor +##### CanvasNet-Codecs-GifCodec-GetInfoZeroDimension: GetInfo Rejects a Zero-Dimensioned Logical Screen Descriptor **Tests**: `GifCodec_GetInfo_ZeroWidth_ThrowsInvalidDataException`, `GifCodec_GetInfo_ZeroHeight_ThrowsInvalidDataException` @@ -311,7 +311,7 @@ dimension check `Load` already performs - proving `GetInfo` never reports `CanDe GIF that can never be decoded, while still not enforcing `Surface.MaxDimension` (see `GifCodec_GetInfo_OversizedDimensions_ReturnsRawValue_ButLoadThrows` above, unchanged). -### Acceptance Criteria +#### Acceptance Criteria A unit test run passes when all test methods above (47 in `GifCodecTests.cs` plus 16 fixture-based theory cases in `GifFixtureTests.cs`) pass without error or unexpected exception; any unexpected diff --git a/docs/verification/canvas-net/codecs/jpeg-codec.md b/docs/verification/canvas-net/codecs/jpeg-codec.md index 4cafe272..5f8cfc42 100644 --- a/docs/verification/canvas-net/codecs/jpeg-codec.md +++ b/docs/verification/canvas-net/codecs/jpeg-codec.md @@ -1,8 +1,8 @@ -## JpegCodec Unit Verification Design +### JpegCodec Unit Verification Design This document describes the unit-level verification strategy for the `JpegCodec` class. -### Verification Approach +#### Verification Approach The `JpegCodec` unit is verified through unit tests that exercise `Load` and `Save` in isolation, using `MemoryStream` for all in-memory round-trip and malformed-stream checks. Several tests build @@ -17,7 +17,7 @@ Conformance testing against the `JpegFixtures` corpus (five real-world files gen ImageMagick from the PngSuite corpus) resides separately in `JpegFixtureTests.cs` within the same project (see `JpegFixtures\README.md` for corpus provenance). -### Test Environment +#### Test Environment - **Framework**: xUnit v3 running under the .NET SDK - **Execution**: `dotnet test` invoked by `build.ps1` and the CI pipeline @@ -25,9 +25,9 @@ project (see `JpegFixtures\README.md` for corpus provenance). - **Isolation**: Each test method builds its own `Surface` and/or JPEG byte array; no shared state between tests -### Unit-Level Test Scenarios +#### Unit-Level Test Scenarios -#### CanvasNet-Codecs-JpegCodec-SaveLoadBaseline420: Baseline 4:2:0 Save Then Load Stays Within Tolerance +##### CanvasNet-Codecs-JpegCodec-SaveLoadBaseline420: Baseline 4:2:0 Save Then Load Stays Within Tolerance **Tests**: `JpegCodec_SaveThenLoad_AlignedCanvas_RoundTripsWithinTolerance`, `JpegCodec_SaveThenLoad_OddSizedCanvas_RoundTripsWithinTolerance`, @@ -38,7 +38,7 @@ loads them back, and asserts the decoded dimensions remain correct and the RGB c within the documented per-channel tolerance, including odd image sizes and the documented quality endpoints 1 and 100. -#### CanvasNet-Codecs-JpegCodec-LoadBaseline444: Baseline 4:4:4 Fixtures Decode Within Tolerance +##### CanvasNet-Codecs-JpegCodec-LoadBaseline444: Baseline 4:4:4 Fixtures Decode Within Tolerance **Tests**: `JpegCodec_Load_Fixture_ReturnsCanvasWithExpectedDimensions`, `JpegCodec_Load_ColorFixture_MatchesSourcePngWithinTolerance` @@ -46,7 +46,7 @@ endpoints 1 and 100. Loads the baseline 4:4:4 fixture from `JpegFixtures`, asserting the expected dimensions and that its decoded RGB pixels remain within the documented tolerance of the PngSuite source image. -#### CanvasNet-Codecs-JpegCodec-LoadBaseline422: Baseline 4:2:2 Fixtures Decode Within Tolerance +##### CanvasNet-Codecs-JpegCodec-LoadBaseline422: Baseline 4:2:2 Fixtures Decode Within Tolerance **Tests**: `JpegCodec_Load_Fixture_ReturnsCanvasWithExpectedDimensions`, `JpegCodec_Load_ColorFixture_MatchesSourcePngWithinTolerance` @@ -54,7 +54,7 @@ its decoded RGB pixels remain within the documented tolerance of the PngSuite so Loads the baseline 4:2:2 fixture from `JpegFixtures`, asserting the expected dimensions and that its decoded RGB pixels remain within the documented tolerance of the PngSuite source image. -#### CanvasNet-Codecs-JpegCodec-LoadProgressive: Progressive JPEG Fixtures Decode Within Tolerance +##### CanvasNet-Codecs-JpegCodec-LoadProgressive: Progressive JPEG Fixtures Decode Within Tolerance **Tests**: `JpegCodec_Load_Fixture_ReturnsCanvasWithExpectedDimensions`, `JpegCodec_Load_ColorFixture_MatchesSourcePngWithinTolerance` @@ -63,7 +63,7 @@ Loads the progressive 4:2:0 fixture from `JpegFixtures`, asserting the expected that the completed multi-scan decode remains within the documented tolerance of the PngSuite source image. -#### CanvasNet-Codecs-JpegCodec-LoadGrayscale: Grayscale JPEG Expands to Equal RGB Channels +##### CanvasNet-Codecs-JpegCodec-LoadGrayscale: Grayscale JPEG Expands to Equal RGB Channels **Tests**: `JpegCodec_Load_Fixture_ReturnsCanvasWithExpectedDimensions`, `JpegCodec_Load_GrayscaleFixture_HasEqualRgbChannels` @@ -71,31 +71,31 @@ source image. Loads the grayscale baseline fixture, asserts the expected dimensions, and verifies every decoded pixel satisfies `R == G == B` with alpha forced to 255. -#### CanvasNet-Codecs-JpegCodec-SaveNullCanvas: Save Rejects a Null Surface +##### CanvasNet-Codecs-JpegCodec-SaveNullCanvas: Save Rejects a Null Surface **Test**: `JpegCodec_SaveStream_NullCanvas_ThrowsArgumentNullException` Calls `Save` with a null `surface` and asserts `ArgumentNullException` is thrown. -#### CanvasNet-Codecs-JpegCodec-SaveNullStream: Save Rejects a Null Stream +##### CanvasNet-Codecs-JpegCodec-SaveNullStream: Save Rejects a Null Stream **Test**: `JpegCodec_SaveStream_NullStream_ThrowsArgumentNullException` Calls `Save` with a valid surface and a null stream, and asserts `ArgumentNullException` is thrown. -#### CanvasNet-Codecs-JpegCodec-SaveNullPath: Save Rejects a Null Path +##### CanvasNet-Codecs-JpegCodec-SaveNullPath: Save Rejects a Null Path **Test**: `JpegCodec_SavePath_NullPath_ThrowsArgumentNullException` Calls `Save` with a valid surface and a null path, and asserts `ArgumentNullException` is thrown. -#### CanvasNet-Codecs-JpegCodec-SaveEmptyPath: Save Rejects an Empty Path +##### CanvasNet-Codecs-JpegCodec-SaveEmptyPath: Save Rejects an Empty Path **Test**: `JpegCodec_SavePath_EmptyPath_ThrowsArgumentException` Calls `Save` with a valid surface and an empty path, and asserts `ArgumentException` is thrown. -#### CanvasNet-Codecs-JpegCodec-SaveInvalidQuality: Save Rejects Out-of-Range Quality Values +##### CanvasNet-Codecs-JpegCodec-SaveInvalidQuality: Save Rejects Out-of-Range Quality Values **Tests**: `JpegCodec_SaveStream_QualityBelowRange_ThrowsArgumentOutOfRangeException`, `JpegCodec_SaveStream_QualityAboveRange_ThrowsArgumentOutOfRangeException` @@ -103,32 +103,32 @@ Calls `Save` with a valid surface and an empty path, and asserts `ArgumentExcept Calls `Save` with quality values below 1 and above 100, and asserts `ArgumentOutOfRangeException` is thrown in both cases. -#### CanvasNet-Codecs-JpegCodec-LoadNullStream: Load Rejects a Null Stream +##### CanvasNet-Codecs-JpegCodec-LoadNullStream: Load Rejects a Null Stream **Test**: `JpegCodec_LoadStream_NullStream_ThrowsArgumentNullException` Calls `Load` with a null stream and asserts `ArgumentNullException` is thrown. -#### CanvasNet-Codecs-JpegCodec-LoadNullPath: Load Rejects a Null Path +##### CanvasNet-Codecs-JpegCodec-LoadNullPath: Load Rejects a Null Path **Test**: `JpegCodec_LoadPath_NullPath_ThrowsArgumentNullException` Calls `Load` with a null path and asserts `ArgumentNullException` is thrown. -#### CanvasNet-Codecs-JpegCodec-LoadEmptyPath: Load Rejects an Empty Path +##### CanvasNet-Codecs-JpegCodec-LoadEmptyPath: Load Rejects an Empty Path **Test**: `JpegCodec_LoadPath_EmptyPath_ThrowsArgumentException` Calls `Load` with an empty path and asserts `ArgumentException` is thrown. -#### CanvasNet-Codecs-JpegCodec-RejectMalformedMarker: Load Rejects a Missing SOI Marker +##### CanvasNet-Codecs-JpegCodec-RejectMalformedMarker: Load Rejects a Missing SOI Marker **Test**: `JpegCodec_Load_MissingSoiMarker_ThrowsInvalidDataException` Builds a byte stream that does not begin with SOI and asserts `Load` throws `InvalidDataException`. -#### CanvasNet-Codecs-JpegCodec-RejectUnsupportedSof: Load and GetInfo Reject Unsupported SOF Markers Identically +##### CanvasNet-Codecs-JpegCodec-RejectUnsupportedSof: Load and GetInfo Reject Unsupported SOF Markers Identically **Tests**: `JpegCodec_Load_UnsupportedSofMarker_ThrowsInvalidDataException` (`[Theory]` over three unsupported markers), `JpegCodec_UnsupportedSofMarkerFollowedByValidSof0_BothLoadAndGetInfoThrow` @@ -142,14 +142,14 @@ the historical divergence where `GetInfo`'s marker scan would skip past the unsu (since it wasn't a recognized SOF0/SOF2) and wrongly succeed against the later valid SOF0, using the same shared rejection helper `Load` already relied on. -#### CanvasNet-Codecs-JpegCodec-RejectFourComponent: Load Rejects Four-Component Frames +##### CanvasNet-Codecs-JpegCodec-RejectFourComponent: Load Rejects Four-Component Frames **Test**: `JpegCodec_Load_FourComponentSof0_ThrowsInvalidDataException` Builds a baseline SOF0 stream declaring four components and asserts `Load` throws `InvalidDataException`. -#### CanvasNet-Codecs-JpegCodec-RejectExceedsMaxDimension: Load Rejects Dimensions Exceeding Surface.MaxDimension +##### CanvasNet-Codecs-JpegCodec-RejectExceedsMaxDimension: Load Rejects Dimensions Exceeding Surface.MaxDimension **Tests**: `JpegCodec_Load_WidthExceedsMaxDimension_ThrowsInvalidDataException`, `JpegCodec_Load_HeightExceedsMaxDimension_ThrowsInvalidDataException` @@ -160,7 +160,7 @@ separately a frame height one greater, and asserts `Load` throws `InvalidDataExc cases, confirming the dimension check happens immediately after parsing the SOF segment, before any MCU-grid width/height arithmetic performed while decoding the scan. -#### CanvasNet-Codecs-JpegCodec-RejectMissingSegments: Load Rejects Streams Missing Mandatory Segments +##### CanvasNet-Codecs-JpegCodec-RejectMissingSegments: Load Rejects Streams Missing Mandatory Segments **Tests**: `JpegCodec_Load_MissingSofSegment_ThrowsInvalidDataException`, `JpegCodec_Load_MissingDhtSegment_ThrowsInvalidDataException`, @@ -170,7 +170,7 @@ any MCU-grid width/height arithmetic performed while decoding the scan. Builds streams missing SOF, DHT, DQT, or SOS in turn, and asserts `Load` throws `InvalidDataException` for every missing mandatory segment case. -#### CanvasNet-Codecs-JpegCodec-RejectTruncatedStream: Load Rejects Truncated Header or Entropy Data +##### CanvasNet-Codecs-JpegCodec-RejectTruncatedStream: Load Rejects Truncated Header or Entropy Data **Tests**: `JpegCodec_Load_TruncatedHeader_ThrowsInvalidDataException`, `JpegCodec_Load_TruncatedEntropyData_ThrowsInvalidDataException`, @@ -184,7 +184,7 @@ more payload bytes than are actually present in the stream, and asserts `Load` t `InvalidDataException` (rather than an unhandled `IndexOutOfRangeException`) for each truncated segment payload case. -#### CanvasNet-Codecs-JpegCodec-RestartMarkers: Restart Markers Decode Identically to No Restart Markers +##### CanvasNet-Codecs-JpegCodec-RestartMarkers: Restart Markers Decode Identically to No Restart Markers **Test**: `JpegCodec_Load_WithRestartMarkers_DecodesIdenticallyToWithoutRestartMarkers` @@ -195,7 +195,7 @@ asserts they decode to identical dimensions and pixel bytes, proving the DRI/RST code path (DC-predictor and end-of-block run reset) produces the same result as the equivalent stream without restart markers. -#### CanvasNet-Codecs-JpegCodec-FixtureSupported: JpegFixtures Corpus Loads Successfully +##### CanvasNet-Codecs-JpegCodec-FixtureSupported: JpegFixtures Corpus Loads Successfully **Tests**: `JpegFixtures_SourcePngDimensions_Are32x32`, `JpegCodec_Load_Fixture_ReturnsCanvasWithExpectedDimensions` (`[Theory]` over 5 fixture files), @@ -209,7 +209,7 @@ dimensions; for color fixtures, asserts every decoded pixel matches the PngSuite fixture's documented tolerance (15 for 4:4:4 and 4:2:2, 30 for baseline/progressive 4:2:0); for the grayscale fixture, asserts successful load, correct dimensions, and `R == G == B` per pixel. -#### CanvasNet-Codecs-JpegCodec-SimdMatchesScalar: Vectorized YCbCr Conversion Matches Scalar Reference +##### CanvasNet-Codecs-JpegCodec-SimdMatchesScalar: Vectorized YCbCr Conversion Matches Scalar Reference **Test**: `JpegCodec_ConvertYCbCrRowToRgb_VectorAndScalarRemainder_MatchesScalarReference` @@ -217,7 +217,7 @@ Builds synthetic Y/Cb/Cr rows with both exact-vector and vector-plus-remainder l the scalar and vectorized conversion paths, and asserts the resulting R/G/B byte rows are identical. -#### CanvasNet-Codecs-JpegCodec-GetInfo: GetInfo Reports Dimensions/Components Without Entropy-Decoding +##### CanvasNet-Codecs-JpegCodec-GetInfo: GetInfo Reports Dimensions/Components Without Entropy-Decoding **Tests**: `JpegCodec_GetInfo_Grayscale_ReturnsExpectedInfoWithoutSosOrEntropyData`, `JpegCodec_GetInfo_Color_ReturnsExpectedInfo`, `JpegCodec_GetInfoPath_ReturnsExpectedInfo`, @@ -342,7 +342,7 @@ past the soft cap end-to-end through the public `JpegCodec.GetInfo` entry point manually constructed, padded JPEG byte stream, per `docs/verification/canvas-net.md`'s system-level evidence contract. -#### CanvasNet-Codecs-JpegCodec-GetInfoValidation: GetInfo Rejects Invalid Arguments and Malformed Headers +##### CanvasNet-Codecs-JpegCodec-GetInfoValidation: GetInfo Rejects Invalid Arguments and Malformed Headers **Tests**: `JpegCodec_GetInfoStream_NullStream_ThrowsArgumentNullException`, `JpegCodec_GetInfoPath_NullPath_ThrowsArgumentNullException`, @@ -358,7 +358,7 @@ asserting `ArgumentNullException`, `ArgumentNullException`, `ArgumentException`, `InvalidDataException` (three times) respectively โ€” the same exception contract as the corresponding `Load` scenarios, plus JPEG-specific malformed-ordering cases `Load` also rejects. -### Acceptance Criteria +#### Acceptance Criteria A unit test run passes when all test methods above pass without error or unexpected exception; any unexpected exception type or wrong return/value relationship constitutes a failure. Across diff --git a/docs/verification/canvas-net/codecs/png-codec.md b/docs/verification/canvas-net/codecs/png-codec.md index e9d91e01..61dd3aae 100644 --- a/docs/verification/canvas-net/codecs/png-codec.md +++ b/docs/verification/canvas-net/codecs/png-codec.md @@ -1,9 +1,9 @@ -## PngCodec Unit Verification Design +### PngCodec Unit Verification Design This document describes the unit-level verification strategy for the `PngCodec` class (and the supporting `PngColorType` enum). -### Verification Approach +#### Verification Approach The `PngCodec` unit is verified through unit tests that exercise `Load` and `Save` in isolation, using `MemoryStream` for all in-memory round-trip and negative-path checks, and @@ -20,7 +20,7 @@ Conformance testing against the industry-standard PngSuite corpus resides separa `PngSuiteTests.cs` within the same project (see `PngSuite\PngSuite.README` / `PngSuite\PngSuite.LICENSE` for corpus provenance and licensing). -### Test Environment +#### Test Environment - **Framework**: xUnit v3 running under the .NET SDK - **Execution**: `dotnet test` invoked by `build.ps1` and the CI pipeline @@ -29,9 +29,9 @@ Conformance testing against the industry-standard PngSuite corpus resides separa use a uniquely generated temporary file deleted in a `finally` block; no shared state between tests -### Unit-Level Test Scenarios +#### Unit-Level Test Scenarios -#### CanvasNet-Codecs-PngCodec-SaveLoadRgb: RGB Round-Trip Preserves RGB and Forces Opaque Alpha +##### CanvasNet-Codecs-PngCodec-SaveLoadRgb: RGB Round-Trip Preserves RGB and Forces Opaque Alpha **Test**: `PngCodec_SaveThenLoad_Rgb_ReturnsExpectedPixelsWithOpaqueAlpha` @@ -39,7 +39,7 @@ Builds a surface with varied, non-opaque per-pixel values, saves it at `PngColor `MemoryStream`, loads it back, and asserts every pixel's R/G/B values match the source exactly and every pixel's alpha is 255, regardless of the source alpha. -#### CanvasNet-Codecs-PngCodec-SaveLoadRgba: RGBA Round-Trip Preserves RGBA Exactly +##### CanvasNet-Codecs-PngCodec-SaveLoadRgba: RGBA Round-Trip Preserves RGBA Exactly **Test**: `PngCodec_SaveThenLoad_Rgba_ReturnsExpectedPixelsIncludingAlpha` @@ -48,7 +48,7 @@ Builds a surface with varied pixel values, saves it using the default `PngColorT exactly. This also exercises a correct round-trip Adler-32 checksum (see _CanvasNet-Codecs-PngCodec-AdlerValidation_ below). -#### CanvasNet-Codecs-PngCodec-MultiIdatChunks: IDAT Data Is Concatenated Across Multiple Chunks +##### CanvasNet-Codecs-PngCodec-MultiIdatChunks: IDAT Data Is Concatenated Across Multiple Chunks **Test**: `PngCodec_Load_MultipleIdatChunks_ReturnsExpectedPixels` @@ -56,14 +56,14 @@ Hand-builds a small, valid PNG whose compressed payload is deliberately split in `IDAT` chunks (a size unrelated to the payload's natural size), and asserts that `Load` correctly concatenates them before decompressing, reproducing every expected pixel value. -#### CanvasNet-Codecs-PngCodec-LoadCorruptCrc: Chunk CRC-32 Is Validated +##### CanvasNet-Codecs-PngCodec-LoadCorruptCrc: Chunk CRC-32 Is Validated **Test**: `PngCodec_Load_CorruptChunkCrc_ThrowsInvalidDataException` Saves a surface to a valid PNG, corrupts the final byte of the file (part of the `IEND` chunk's CRC-32), and asserts `Load` throws `InvalidDataException`. -#### CanvasNet-Codecs-PngCodec-AdlerValidation: Zlib Adler-32 Checksum Is Validated +##### CanvasNet-Codecs-PngCodec-AdlerValidation: Zlib Adler-32 Checksum Is Validated **Test**: `PngCodec_Load_CorruptAdlerTrailer_ThrowsInvalidDataException` @@ -72,7 +72,7 @@ Saves a surface to a valid PNG, locates the final `IDAT` chunk, flips the last b independent test-only CRC-32 implementation) so only the Adler-32 check is exercised, and asserts `Load` throws `InvalidDataException`. -#### CanvasNet-Codecs-PngCodec-FilterReconstruction: All Five Standard Filter Types Are Reconstructed +##### CanvasNet-Codecs-PngCodec-FilterReconstruction: All Five Standard Filter Types Are Reconstructed **Tests**: `PngCodec_Load_FilterTypeSub_ReconstructsExpectedPixels`, `PngCodec_Load_FilterTypeUp_ReconstructsExpectedPixels`, @@ -85,72 +85,72 @@ complement of the filter formula to known raw pixel values), and asserts `Load` exact expected pixel values. Filter type 0 (None) is already exercised by every round-trip test, since `Save` always writes filter type 0. -#### CanvasNet-Codecs-PngCodec-LoadFromPath / CanvasNet-Codecs-PngCodec-SaveToPath: File Path Overloads Round-Trip +##### CanvasNet-Codecs-PngCodec-LoadFromPath / CanvasNet-Codecs-PngCodec-SaveToPath: File Path Overloads Round-Trip **Test**: `PngCodec_Load_FromFilePath_ReturnsExpectedPixels` Saves a surface to a temporary file via `Save(Surface, string, PngColorType)`, loads it back via `Load(string)`, asserts every pixel matches, and deletes the temporary file in a `finally` block. -#### CanvasNet-Codecs-PngCodec-SaveNullCanvas: Save Rejects a Null Surface +##### CanvasNet-Codecs-PngCodec-SaveNullCanvas: Save Rejects a Null Surface **Test**: `PngCodec_Save_NullCanvas_ThrowsArgumentNullException` Calls `Save` with a null `surface` and a valid stream, and asserts `ArgumentNullException` is thrown. -#### CanvasNet-Codecs-PngCodec-SaveNullStream: Save Rejects a Null Stream +##### CanvasNet-Codecs-PngCodec-SaveNullStream: Save Rejects a Null Stream **Test**: `PngCodec_Save_NullStream_ThrowsArgumentNullException` Calls `Save` with a valid surface and a null stream, and asserts `ArgumentNullException` is thrown. -#### CanvasNet-Codecs-PngCodec-SaveNullPath: Save Rejects a Null Path +##### CanvasNet-Codecs-PngCodec-SaveNullPath: Save Rejects a Null Path **Test**: `PngCodec_Save_NullPath_ThrowsArgumentNullException` Calls `Save` with a valid surface and a null path, and asserts `ArgumentNullException` is thrown. -#### CanvasNet-Codecs-PngCodec-SaveEmptyPath: Save Rejects an Empty Path +##### CanvasNet-Codecs-PngCodec-SaveEmptyPath: Save Rejects an Empty Path **Test**: `PngCodec_Save_EmptyPath_ThrowsArgumentException` Calls `Save` with a valid surface and an empty path, and asserts `ArgumentException` is thrown. -#### CanvasNet-Codecs-PngCodec-SaveInvalidColorType: Save Rejects an Undefined Color Type +##### CanvasNet-Codecs-PngCodec-SaveInvalidColorType: Save Rejects an Undefined Color Type **Test**: `PngCodec_Save_UndefinedColorType_ThrowsArgumentOutOfRangeException` Calls `Save` with a valid surface and stream but an undefined `PngColorType` value cast from an out-of-range integer, and asserts `ArgumentOutOfRangeException` is thrown. -#### CanvasNet-Codecs-PngCodec-LoadNullStream: Load Rejects a Null Stream +##### CanvasNet-Codecs-PngCodec-LoadNullStream: Load Rejects a Null Stream **Test**: `PngCodec_Load_NullStream_ThrowsArgumentNullException` Calls `Load` with a null stream and asserts `ArgumentNullException` is thrown. -#### CanvasNet-Codecs-PngCodec-LoadNullPath: Load Rejects a Null Path +##### CanvasNet-Codecs-PngCodec-LoadNullPath: Load Rejects a Null Path **Test**: `PngCodec_Load_NullPath_ThrowsArgumentNullException` Calls `Load` with a null path and asserts `ArgumentNullException` is thrown. -#### CanvasNet-Codecs-PngCodec-LoadEmptyPath: Load Rejects an Empty Path +##### CanvasNet-Codecs-PngCodec-LoadEmptyPath: Load Rejects an Empty Path **Test**: `PngCodec_Load_EmptyPath_ThrowsArgumentException` Calls `Load` with an empty path and asserts `ArgumentException` is thrown. -#### CanvasNet-Codecs-PngCodec-LoadBadSignature: Load Rejects a Missing PNG Signature +##### CanvasNet-Codecs-PngCodec-LoadBadSignature: Load Rejects a Missing PNG Signature **Test**: `PngCodec_Load_BadSignature_ThrowsInvalidDataException` Builds 8 zero bytes (not matching the PNG signature) and asserts `Load` throws `InvalidDataException`. -#### CanvasNet-Codecs-PngCodec-LoadGrayscale / -LoadGrayscaleAlpha: Load Decodes Grayscale and Grayscale-with-Alpha PNGs +##### CanvasNet-Codecs-PngCodec-LoadGrayscale / -LoadGrayscaleAlpha: Load Decodes Grayscale and Grayscale-with-Alpha PNGs **Tests**: `PngCodec_Load_Grayscale8Bit_ReturnsExpectedGrayPixels`, `PngCodec_Load_GrayscaleAlpha8Bit_ReturnsExpectedPixels` @@ -159,7 +159,7 @@ Hand-builds an 8-bit Grayscale (color type 0) PNG and separately an 8-bit Graysc (color type 4) PNG with independently pre-computed expected pixel values, and asserts `Load` produces those exact R=G=B(=gray) and alpha values. -#### CanvasNet-Codecs-PngCodec-LoadPalette: Load Decodes Palette PNGs and Requires a PLTE Chunk +##### CanvasNet-Codecs-PngCodec-LoadPalette: Load Decodes Palette PNGs and Requires a PLTE Chunk **Tests**: `PngCodec_Load_Palette8Bit_ResolvesIndicesThroughPlte`, `PngCodec_Load_PaletteWithoutPlte_ThrowsInvalidDataExceptionMentioningPlte`, @@ -171,7 +171,7 @@ each pixel's index to the correct RGB triple. Separately asserts `Load` throws throws `InvalidDataException` when a pixel's palette index is out of range for the supplied `PLTE` chunk. -#### CanvasNet-Codecs-PngCodec-LoadTrnsPalette / -LoadTrnsGrayscale / -LoadTrnsTruecolor: Load Honors tRNS Transparency +##### CanvasNet-Codecs-PngCodec-LoadTrnsPalette / -LoadTrnsGrayscale / -LoadTrnsTruecolor: Load Honors tRNS Transparency **Tests**: `PngCodec_Load_PaletteWithTrns_AppliesPerIndexAlpha`, `PngCodec_Load_GrayscaleWithTrns_MarksExactMatchTransparent`, @@ -188,7 +188,7 @@ only in their low byte (so both share the same post-downshift 8-bit value), and does **not** treat that pixel as transparent โ€” proving the 16-bit `tRNS` comparison happens before downshifting, not after. -#### CanvasNet-Codecs-PngCodec-LoadSubByteDepths: Load Decodes Sub-Byte (1/2/4-bit) Grayscale and Palette PNGs +##### CanvasNet-Codecs-PngCodec-LoadSubByteDepths: Load Decodes Sub-Byte (1/2/4-bit) Grayscale and Palette PNGs **Tests**: `PngCodec_Load_Grayscale1BitDepth_ScalesSamplesAndUnpacksMsbFirst`, `PngCodec_Load_Grayscale2BitDepth_ScalesSamples`, `PngCodec_Load_Grayscale4BitDepth_ScalesSamples`, @@ -200,7 +200,7 @@ edges), and asserts `Load` unpacks each sample and scales it to the full 0-255 r 2-bit Palette PNG and asserts `Load` unpacks each palette index without any scaling (an index selects a palette entry; it is never a sample magnitude). -#### CanvasNet-Codecs-PngCodec-Load16BitDepth: Load Decodes 16-Bit-Per-Sample PNGs by Discarding the Low Byte +##### CanvasNet-Codecs-PngCodec-Load16BitDepth: Load Decodes 16-Bit-Per-Sample PNGs by Discarding the Low Byte **Tests**: `PngCodec_Load_Grayscale16BitDepth_DiscardsLowByte`, `PngCodec_Load_TruecolorAlpha16BitDepth_DiscardsLowByteOfEveryChannel` @@ -209,7 +209,7 @@ Hand-builds a 16-bit Grayscale PNG and separately a 16-bit Truecolor-with-alpha distinct high and low sample bytes, and asserts `Load` produces the expected 8-bit pixel values by discarding each sample's low byte (`value >> 8`). -#### CanvasNet-Codecs-PngCodec-LoadInvalidCombination: Load Rejects Invalid Bit-Depth/Color-Type Combinations +##### CanvasNet-Codecs-PngCodec-LoadInvalidCombination: Load Rejects Invalid Bit-Depth/Color-Type Combinations **Test**: `PngCodec_Load_InvalidBitDepthColorTypeCombination_ThrowsInvalidDataException` (`[Theory]` over multiple invalid combinations: Truecolor at 1/2/4 bits, Palette at 16 bits, Grayscale-with-alpha @@ -219,7 +219,7 @@ Hand-builds a minimal PNG declaring each invalid bit-depth/color-type pairing in `Load` throws `InvalidDataException` for every one โ€” these combinations are invalid per the PNG specification itself, independent of any feature this codec chooses to support. -#### CanvasNet-Codecs-PngCodec-LoadUnsupportedInterlace: Load Rejects Adam7 Interlacing, but GetInfo Still Succeeds +##### CanvasNet-Codecs-PngCodec-LoadUnsupportedInterlace: Load Rejects Adam7 Interlacing, but GetInfo Still Succeeds **Test**: `PngCodec_Load_UnsupportedInterlaceAdam7_ThrowsUnsupportedImageFeatureException` @@ -229,7 +229,7 @@ on the exact same bytes succeeds, reports the correct declared width and height, `CanDecode` as `false` โ€” proving Adam7 interlacing is a decode-capability limitation, not a well-formedness defect. -#### CanvasNet-Codecs-PngCodec-GetInfoCanDecode: GetInfo Reports CanDecode for Adam7-Interlaced and Non-Interlaced PNGs +##### CanvasNet-Codecs-PngCodec-GetInfoCanDecode: GetInfo Reports CanDecode for Adam7-Interlaced and Non-Interlaced PNGs **Tests**: `PngCodec_Load_UnsupportedInterlaceAdam7_ThrowsUnsupportedImageFeatureException`, `PngCodec_GetInfo_NonInterlaced_ReportsCanDecodeTrue` @@ -238,7 +238,7 @@ Confirms `GetInfo` reports `ImageInfo.CanDecode` as `false` for an Adam7-interla test above) and as `true` for a well-formed, non-interlaced PNG, letting a caller detect the one well-formed-but-unsupported PNG feature before calling `Load` at all. -#### CanvasNet-Codecs-PngCodec-LoadExceedsMaxDimension: Load Rejects Dimensions Exceeding Surface.MaxDimension +##### CanvasNet-Codecs-PngCodec-LoadExceedsMaxDimension: Load Rejects Dimensions Exceeding Surface.MaxDimension **Tests**: `PngCodec_Load_WidthExceedsMaxDimension_ThrowsInvalidDataException`, `PngCodec_Load_HeightExceedsMaxDimension_ThrowsInvalidDataException` @@ -249,7 +249,7 @@ separately a height one greater, and asserts `Load` throws `InvalidDataException cases, confirming the dimension check happens before the width-times-channels row-byte-width arithmetic performed elsewhere in `Load`. -#### CanvasNet-Codecs-PngCodec-LoadTruncatedStream: Load Rejects a Truncated Stream +##### CanvasNet-Codecs-PngCodec-LoadTruncatedStream: Load Rejects a Truncated Stream **Test**: `PngCodec_Load_TruncatedStream_ThrowsInvalidDataException` @@ -257,7 +257,7 @@ Builds a valid signature and `IHDR` chunk but omits every chunk that should foll `IEND`), and asserts `Load` throws `InvalidDataException` when the stream ends while looking for the next chunk. -#### CanvasNet-Codecs-PngCodec-LoadChunkBeforeIhdr: Load Rejects Any Chunk Preceding IHDR +##### CanvasNet-Codecs-PngCodec-LoadChunkBeforeIhdr: Load Rejects Any Chunk Preceding IHDR **Tests**: `PngCodec_Load_IendBeforeIhdr_ThrowsInvalidDataExceptionMentioningIhdr`, `PngCodec_Load_AncillaryChunkBeforeIhdr_ThrowsInvalidDataException` @@ -270,7 +270,7 @@ with no `IHDR` chunk present, and asserts `Load` throws the same `InvalidDataExc `IHDR`, proving even a chunk type this codec would otherwise silently skip is still rejected when it appears before the mandatory, always-first `IHDR` chunk. -#### CanvasNet-Codecs-PngCodec-LoadUnrecognizedCriticalChunk: Load Rejects Unrecognized Critical Chunks +##### CanvasNet-Codecs-PngCodec-LoadUnrecognizedCriticalChunk: Load Rejects Unrecognized Critical Chunks **Tests**: `PngCodec_Load_UnrecognizedCriticalChunk_ThrowsInvalidDataException`, `PngCodec_Load_UnrecognizedAncillaryChunk_StillLoadsSuccessfully` @@ -283,7 +283,7 @@ uppercase third byte per the reserved-bit rule) inserted between `IHDR` and `IDA `Load` still decodes the expected pixel successfully, proving the new critical-chunk rejection does not affect the existing ancillary-chunk-skip behavior. -#### CanvasNet-Codecs-PngCodec-LoadMalformedChunkType: Load Rejects a Malformed Chunk Type Code +##### CanvasNet-Codecs-PngCodec-LoadMalformedChunkType: Load Rejects a Malformed Chunk Type Code **Tests**: `PngCodec_Load_ChunkTypeWithNonLetterByte_ThrowsInvalidDataException`, `PngCodec_Load_ChunkTypeWithLowercaseReservedByte_ThrowsInvalidDataException` @@ -295,7 +295,7 @@ its third byte violates the PNG specification's reserved-bit rule), and asserts `InvalidDataException` mentioning "reserved-bit rule" - proving both malformed cases are rejected before the codec's first-byte-only critical/ancillary classification ever sees them. -#### CanvasNet-Codecs-PngCodec-LoadPlteForbiddenForGrayscale: Load Rejects PLTE on Grayscale Color Types +##### CanvasNet-Codecs-PngCodec-LoadPlteForbiddenForGrayscale: Load Rejects PLTE on Grayscale Color Types **Tests**: `PngCodec_Load_PlteOnGrayscale_ThrowsInvalidDataException`, `PngCodec_Load_PlteOnGrayscaleAlpha_ThrowsInvalidDataException` @@ -305,7 +305,7 @@ Grayscale-with-alpha (color type 4) `IHDR` followed by a `PLTE` chunk, and asser `InvalidDataException` in both cases, since grayscale samples are never resolved through a palette. -#### CanvasNet-Codecs-PngCodec-LoadTrnsOrderRequiresPlteForPalette: Load Rejects tRNS Before PLTE +##### CanvasNet-Codecs-PngCodec-LoadTrnsOrderRequiresPlteForPalette: Load Rejects tRNS Before PLTE **Test**: `PngCodec_Load_TrnsBeforePlteForIndexedColor_ThrowsInvalidDataException` @@ -313,7 +313,7 @@ Builds a Palette (color type 3) `IHDR` followed by a `tRNS` chunk and then a `PL reverse of the order the PNG specification requires), and asserts `Load` throws `InvalidDataException` with a message naming `PLTE` as the cause. -#### CanvasNet-Codecs-PngCodec-LoadPlteAfterTrns: Load Rejects PLTE Appearing After tRNS +##### CanvasNet-Codecs-PngCodec-LoadPlteAfterTrns: Load Rejects PLTE Appearing After tRNS **Test**: `PngCodec_Load_PlteAfterTrns_ThrowsInvalidDataException` @@ -323,7 +323,7 @@ mandatory) `IHDR` followed by a `tRNS` chunk and then a `PLTE` chunk, and assert ordering requirement is enforced in both directions and for every color type that can legally carry both chunks, not only the already-covered indexed-color (Palette) tRNS-before-PLTE case. -#### CanvasNet-Codecs-PngCodec-LoadTrnsForbiddenForAlphaColorTypes: Load Rejects tRNS on Alpha Color Types +##### CanvasNet-Codecs-PngCodec-LoadTrnsForbiddenForAlphaColorTypes: Load Rejects tRNS on Alpha Color Types **Tests**: `PngCodec_Load_TrnsOnGrayscaleAlpha_ThrowsInvalidDataException`, `PngCodec_Load_TrnsOnTruecolorAlpha_ThrowsInvalidDataException` @@ -333,7 +333,7 @@ Truecolor-with-alpha (color type 6) `IHDR` followed by a `tRNS` chunk, and asser `InvalidDataException` in both cases, since both color types already carry a full per-pixel alpha channel that leaves nothing for a single-key-color transparency chunk to add. -#### CanvasNet-Codecs-PngCodec-LoadTrnsKeyExceedsBitDepthRange: Load Rejects a tRNS Key Outside the Bit-Depth Range +##### CanvasNet-Codecs-PngCodec-LoadTrnsKeyExceedsBitDepthRange: Load Rejects a tRNS Key Outside the Bit-Depth Range **Tests**: `PngCodec_Load_TrnsGrayscaleKeyExceedsBitDepthRange_ThrowsInvalidDataException`, `PngCodec_Load_TrnsTruecolorKeyExceedsBitDepthRange_ThrowsInvalidDataException` @@ -345,7 +345,7 @@ big-endian value `{0x01, 0x00}`, since tRNS keys are always 2 bytes regardless o asserts `Load` throws `InvalidDataException` in both cases, since a key that can never match any real pixel at the file's declared bit depth is non-conforming. -#### CanvasNet-Codecs-PngCodec-LoadNonConsecutiveIdat: Load Rejects Non-Consecutive IDAT Chunks +##### CanvasNet-Codecs-PngCodec-LoadNonConsecutiveIdat: Load Rejects Non-Consecutive IDAT Chunks **Tests**: `PngCodec_Load_NonConsecutiveIdatChunks_ThrowsInvalidDataException`, `PngCodec_Load_MultipleIdatChunks_ReturnsExpectedPixels` @@ -358,7 +358,7 @@ consecutive `IDAT` chunks (with no other chunk type interleaved) still loads and successfully, since encoders commonly split one image's payload across several small IDAT chunks for streaming purposes. -#### CanvasNet-Codecs-PngCodec-LoadIendNonEmptyPayload: Load Rejects a Non-Empty IEND Payload +##### CanvasNet-Codecs-PngCodec-LoadIendNonEmptyPayload: Load Rejects a Non-Empty IEND Payload **Test**: `PngCodec_Load_IendWithNonEmptyPayload_ThrowsInvalidDataException` @@ -367,7 +367,7 @@ Builds a valid one-pixel Truecolor PNG whose terminating `IEND` chunk declares a not an incidental CRC mismatch), and asserts `Load` throws `InvalidDataException` naming `IEND` as the cause, since the PNG specification defines `IEND` as always carrying zero bytes of data. -#### CanvasNet-Codecs-PngCodec-LoadTrailingDataAfterIend: Load Rejects Data After the IEND Chunk +##### CanvasNet-Codecs-PngCodec-LoadTrailingDataAfterIend: Load Rejects Data After the IEND Chunk **Test**: `PngCodec_Load_TrailingDataAfterIend_ThrowsInvalidDataException` @@ -376,7 +376,7 @@ Builds a complete, valid one-pixel Truecolor PNG (`IHDR` + `IDAT` + a CRC-valid, throws `InvalidDataException` naming `IEND` as the cause, since the PNG specification requires `IEND` to be the final chunk in the datastream. -#### CanvasNet-Codecs-PngCodec-PngSuiteSupported: PngSuite Files Within Scope Load Successfully +##### CanvasNet-Codecs-PngCodec-PngSuiteSupported: PngSuite Files Within Scope Load Successfully **Test**: `PngCodec_Load_PngSuiteSupportedFile_ReturnsCanvas` (`[Theory]` over 126 PngSuite files) @@ -386,7 +386,7 @@ each file's raw IHDR bytes rather than trusted from its filename โ€” covering ev each color type permits (1, 2, 4, 8, or 16 as applicable), and asserts `Load` returns a surface with non-zero width and height, without throwing. -#### CanvasNet-Codecs-PngCodec-PngSuiteUnsupported: Adam7-Interlaced PngSuite Files Rejected by Load; GetInfo Still Succeeds +##### CanvasNet-Codecs-PngCodec-PngSuiteUnsupported: Adam7-Interlaced PngSuite Files Rejected by Load; GetInfo Still Succeeds **Tests**: `PngCodec_Load_PngSuiteUnsupportedFile_ThrowsUnsupportedImageFeatureException`, `PngSuiteUnsupportedFile_GetInfoReturnsCorrectDimensions` (`[Theory]` over 35 PngSuite files) @@ -400,7 +400,7 @@ from each file's raw bytes, not merely asserted positive, which would pass even dimensions were wrong, for example swapped) and `CanDecode` as `false`, since Adam7 interlacing does not affect the declared dimensions and is not itself a well-formedness defect. -#### CanvasNet-Codecs-PngCodec-PngSuiteCorrupt: PngSuite Files Corrupt At/Before IHDR Are Rejected by Both Load and GetInfo +##### CanvasNet-Codecs-PngCodec-PngSuiteCorrupt: PngSuite Files Corrupt At/Before IHDR Are Rejected by Both Load and GetInfo **Tests**: `PngCodec_Load_PngSuiteCorruptFile_ThrowsInvalidDataException`, `PngCodec_GetInfo_PngSuiteCorruptFile_ThrowsInvalidDataException` (`[Theory]` over 12 PngSuite @@ -416,7 +416,7 @@ above, which `GetInfo` still accepts). This deliberately excludes the two files `CanvasNet-Codecs-PngCodec-PngSuiteCorruptAfterIhdr` below, whose corruption lies entirely after a well-formed IHDR chunk and is therefore never encountered by `GetInfo`. -#### CanvasNet-Codecs-PngCodec-PngSuiteCorruptAfterIhdr: Corrupt-After-IHDR Files: Load Rejects, GetInfo Succeeds +##### CanvasNet-Codecs-PngCodec-PngSuiteCorruptAfterIhdr: Corrupt-After-IHDR Files: Load Rejects, GetInfo Succeeds **Test**: `PngCodec_PngSuiteCorruptAfterIhdrFile_GetInfoReturnsCorrectDimensions_ButLoadThrows` (`[Theory]` over 2 PngSuite files) @@ -432,7 +432,7 @@ Adam7-interlaced files in `CanvasNet-Codecs-PngCodec-PngSuiteUnsupported` above files, `Load` rejects both because they are genuinely malformed, not merely because of an unimplemented decode feature. -#### CanvasNet-Codecs-PngCodec-GetInfo: GetInfo Reports Dimensions/Channels/Alpha Without Decoding Pixels +##### CanvasNet-Codecs-PngCodec-GetInfo: GetInfo Reports Dimensions/Channels/Alpha Without Decoding Pixels **Tests**: `PngCodec_GetInfo_Rgb_ReturnsExpectedInfoWithoutAlpha`, `PngCodec_GetInfo_Rgba_ReturnsExpectedInfoWithAlpha`, `PngCodec_GetInfo_NeverReadsPastIhdr`, @@ -457,7 +457,7 @@ before any length-dependent allocation is attempted โ€” once for a non-`IHDR` fi once for an `IHDR` chunk whose declared length is not the mandatory 13 โ€” asserting both a bounded (well under 1 MB) allocation delta and `InvalidDataException` in each case. -#### CanvasNet-Codecs-PngCodec-GetInfoAnyDecodability: GetInfo Succeeds Regardless of Whether Load Can Decode the File +##### CanvasNet-Codecs-PngCodec-GetInfoAnyDecodability: GetInfo Succeeds Regardless of Whether Load Can Decode the File **Tests**: `PngCodec_GetInfo_Grayscale_ReturnsExpectedInfoWithoutAlpha`, `PngCodec_GetInfo_GrayscaleAlpha_ReturnsExpectedInfoWithAlpha`, @@ -478,7 +478,7 @@ and 16-bit Grayscale PNGs and asserts the correct declared width/height are repo color type, and asserts both methods throw `InvalidDataException` symmetrically โ€” proving well-formedness (as opposed to decodability) is enforced identically by both entry points. -#### CanvasNet-Codecs-PngCodec-GetInfoValidation: GetInfo Rejects Invalid Arguments and Malformed Headers +##### CanvasNet-Codecs-PngCodec-GetInfoValidation: GetInfo Rejects Invalid Arguments and Malformed Headers **Tests**: `PngCodec_GetInfo_NullStream_ThrowsArgumentNullException`, `PngCodec_GetInfo_NullPath_ThrowsArgumentNullException`, @@ -494,11 +494,11 @@ scenarios. Also calls `GetInfo(string)` on every deliberately corrupt PngSuite c (see `CanvasNet-Codecs-PngCodec-PngSuiteCorrupt` above) and asserts `InvalidDataException` for every one. -### Acceptance Criteria +#### Acceptance Criteria A unit test run passes when all test methods above pass without error or unexpected exception; any unexpected exception type or wrong return/byte value constitutes a failure. Across -`PngCodecTests.cs` and `PngSuiteTests.cs`, this totals 101 test methods (95 in `PngCodecTests.cs` +`PngCodecTests.cs` and `PngSuiteTests.cs`, this totals 102 test methods (96 in `PngCodecTests.cs` and 6 in `PngSuiteTests.cs`), which expand to a much larger number of executed xUnit test cases when every `[Theory]` data row is included, covering the full 175-file PngSuite conformance corpus. diff --git a/docs/verification/canvas-net/codecs/tiff-codec.md b/docs/verification/canvas-net/codecs/tiff-codec.md index 47471da8..920e8d1c 100644 --- a/docs/verification/canvas-net/codecs/tiff-codec.md +++ b/docs/verification/canvas-net/codecs/tiff-codec.md @@ -1,9 +1,9 @@ -## TiffCodec Unit Verification Design +### TiffCodec Unit Verification Design This document describes the unit-level verification strategy for the `TiffCodec` class (and the supporting `TiffCompression` enum). -### Verification Approach +#### Verification Approach The `TiffCodec` unit is verified through unit tests that exercise `Load` and `Save` in isolation, using `MemoryStream` for all in-memory round-trip and negative-path checks, and @@ -21,7 +21,7 @@ Conformance testing against the `TiffFixtures` corpus (nine real-world files gen ImageMagick from the PngSuite corpus) resides separately in `TiffFixtureTests.cs` within the same project (see `TiffFixtures\README.md` for corpus provenance). -### Test Environment +#### Test Environment - **Framework**: xUnit v3 running under the .NET SDK - **Execution**: `dotnet test` invoked by `build.ps1` and the CI pipeline @@ -30,9 +30,9 @@ project (see `TiffFixtures\README.md` for corpus provenance). use a uniquely generated temporary file deleted in a `finally` block; no shared state between tests -### Unit-Level Test Scenarios +#### Unit-Level Test Scenarios -#### CanvasNet-Codecs-TiffCodec-SaveLoadRgba: RGBA Round-Trip Preserves RGBA Exactly +##### CanvasNet-Codecs-TiffCodec-SaveLoadRgba: RGBA Round-Trip Preserves RGBA Exactly **Test**: `TiffCodec_SaveThenLoad_AllCompressions_RoundTripsExactly` (`[Theory]` over all four `TiffCompression` values) @@ -41,7 +41,7 @@ Builds a surface with varied, non-opaque per-pixel values, saves it with the def representation to a `MemoryStream` for each supported compression method, loads it back, and asserts every pixel (including alpha) matches the source exactly. -#### CanvasNet-Codecs-TiffCodec-LoadRgbForcesOpaqueAlpha: RGB (No Alpha) Load Forces Alpha to 255 +##### CanvasNet-Codecs-TiffCodec-LoadRgbForcesOpaqueAlpha: RGB (No Alpha) Load Forces Alpha to 255 **Tests**: `TiffCodec_Load_LittleEndianRgbSingleStrip_ReturnsExpectedPixels`, `TiffCodec_Load_RgbFixture_MatchesSourcePngWithOpaqueAlpha` @@ -50,7 +50,7 @@ Hand-builds (and separately, loads from the `TiffFixtures` corpus) a 3-samples-p with no `ExtraSamples` tag, and asserts every loaded pixel's R/G/B matches the source exactly and alpha is forced to 255. -#### CanvasNet-Codecs-TiffCodec-LoadGrayscale: Grayscale Load Produces R=G=B With Full Alpha +##### CanvasNet-Codecs-TiffCodec-LoadGrayscale: Grayscale Load Produces R=G=B With Full Alpha **Tests**: `TiffCodec_Load_Grayscale_ReturnsGrayPixelsWithFullAlpha`, `TiffCodec_Load_GrayscaleFixture_HasEqualRgbChannels` @@ -58,7 +58,7 @@ alpha is forced to 255. Hand-builds (and separately, loads from the `TiffFixtures` corpus) a BlackIsZero Grayscale TIFF, and asserts every loaded pixel has R == G == B equal to the gray sample value, with alpha 255. -#### CanvasNet-Codecs-TiffCodec-LoadLittleEndian: Load Supports the "II" Byte-Order Mark +##### CanvasNet-Codecs-TiffCodec-LoadLittleEndian: Load Supports the "II" Byte-Order Mark **Tests**: `TiffCodec_Load_LittleEndianRgbSingleStrip_ReturnsExpectedPixels`, `TiffCodec_Load_Fixture_ReturnsCanvasWithExpectedDimensions` @@ -66,7 +66,7 @@ and asserts every loaded pixel has R == G == B equal to the gray sample value, w Hand-builds (and separately, loads from the `TiffFixtures` corpus) a little-endian TIFF, and asserts it loads with the expected pixel values/dimensions. -#### CanvasNet-Codecs-TiffCodec-LoadBigEndian: Load Supports the "MM" Byte-Order Mark +##### CanvasNet-Codecs-TiffCodec-LoadBigEndian: Load Supports the "MM" Byte-Order Mark **Tests**: `TiffCodec_Load_BigEndianRgbSingleStrip_ReturnsExpectedPixels`, `TiffCodec_Load_Fixture_ReturnsCanvasWithExpectedDimensions` @@ -74,7 +74,7 @@ asserts it loads with the expected pixel values/dimensions. Hand-builds (and separately, loads from the `TiffFixtures` corpus) a big-endian TIFF, and asserts it loads with the expected pixel values/dimensions. -#### CanvasNet-Codecs-TiffCodec-LoadMultiStrip: Multi-Strip Images Are Reassembled Correctly +##### CanvasNet-Codecs-TiffCodec-LoadMultiStrip: Multi-Strip Images Are Reassembled Correctly **Tests**: `TiffCodec_Load_MultiStrip_ReturnsExpectedPixels`, `TiffCodec_Load_MultiStripWithTwoRowsPerStrip_ReturnsExpectedPixels` @@ -82,7 +82,7 @@ it loads with the expected pixel values/dimensions. Hand-builds TIFF images whose pixel data is split across several one-row and multi-row strips, and asserts every pixel is correctly reassembled in row order. -#### CanvasNet-Codecs-TiffCodec-CompressionNone: None Compression Round-Trips Exactly +##### CanvasNet-Codecs-TiffCodec-CompressionNone: None Compression Round-Trips Exactly **Tests**: `TiffCodec_SaveThenLoad_AllCompressions_RoundTripsExactly`, `TiffCodec_Save_WithoutPredictorCompression_OmitsPredictorTag` @@ -90,7 +90,7 @@ and asserts every pixel is correctly reassembled in row order. Saves and loads a surface using `TiffCompression.None`, asserting exact pixel round-trip and that no `Predictor` tag is written. -#### CanvasNet-Codecs-TiffCodec-CompressionPackBits: PackBits Compression Round-Trips Exactly +##### CanvasNet-Codecs-TiffCodec-CompressionPackBits: PackBits Compression Round-Trips Exactly **Tests**: `TiffCodec_SaveThenLoad_AllCompressions_RoundTripsExactly`, `TiffCodec_Load_PackBitsCompressedStrip_DecodesUsingIndependentEncoder`, @@ -99,7 +99,7 @@ no `Predictor` tag is written. Saves and loads a surface using `TiffCompression.PackBits`; separately, decodes a strip built with an independent test-only PackBits encoder, and loads the real-world `rgb_packbits.tiff` fixture. -#### CanvasNet-Codecs-TiffCodec-CompressionLzw: LZW Compression Round-Trips Exactly +##### CanvasNet-Codecs-TiffCodec-CompressionLzw: LZW Compression Round-Trips Exactly **Tests**: `TiffCodec_SaveThenLoad_AllCompressions_RoundTripsExactly`, `TiffCodec_Load_LzwCompressedStrip_DecodesUsingIndependentEncoder`, @@ -113,7 +113,7 @@ independent test-only TIFF-flavor LZW encoder (including a repeating-pattern cas dictionary growth and code-width transitions), and loads the real-world `rgb_lzw.tiff`/ `rgba_lzw.tiff`/`gray_lzw.tiff` fixtures (genuine third-party encoder output). -#### CanvasNet-Codecs-TiffCodec-CompressionDeflate: Deflate Compression Round-Trips Exactly +##### CanvasNet-Codecs-TiffCodec-CompressionDeflate: Deflate Compression Round-Trips Exactly **Tests**: `TiffCodec_SaveThenLoad_AllCompressions_RoundTripsExactly`, `TiffCodec_Load_DeflateCompressedStrip_DecodesUsingIndependentEncoder`, @@ -123,7 +123,7 @@ Saves and loads a surface using `TiffCompression.Deflate`; separately, decodes a an independent test-only zlib/Deflate encoder, and loads the real-world `rgb_deflate.tiff` fixture. -#### CanvasNet-Codecs-TiffCodec-PredictorRoundTrip: Horizontal Predictor Is Applied and Reversed Correctly +##### CanvasNet-Codecs-TiffCodec-PredictorRoundTrip: Horizontal Predictor Is Applied and Reversed Correctly **Tests**: `TiffCodec_Save_WithPredictorCompression_WritesPredictorTagAndIndependentlyDecodesToSameBytes` (`[Theory]` over Lzw/Deflate), `TiffCodec_Load_PredictorWithLzw_DecodesUsingIndependentEncoder`, @@ -134,7 +134,7 @@ independent test-only decoder reproduces the exact original raw pixel bytes; sep builds strips with predictor differencing applied via an independent test-only implementation and asserts `Load` reverses it correctly. -#### CanvasNet-Codecs-TiffCodec-LoadFromPath / CanvasNet-Codecs-TiffCodec-SaveToPath: File Path Overloads Round-Trip +##### CanvasNet-Codecs-TiffCodec-LoadFromPath / CanvasNet-Codecs-TiffCodec-SaveToPath: File Path Overloads Round-Trip **Test**: `TiffCodec_SaveThenLoad_PathRoundTrip_ReturnsExpectedPixel` @@ -142,33 +142,33 @@ Saves a surface to a temporary file via `Save(Surface, string, TiffCompression)` `Load(string)`, asserts the expected pixel matches, and deletes the temporary file in a `finally` block. -#### CanvasNet-Codecs-TiffCodec-SaveNullCanvas: Save Rejects a Null Surface +##### CanvasNet-Codecs-TiffCodec-SaveNullCanvas: Save Rejects a Null Surface **Tests**: `TiffCodec_SaveStream_NullCanvas_ThrowsArgumentNullException`, `TiffCodec_SavePath_NullCanvas_ThrowsArgumentNullException` Calls each `Save` overload with a null `surface`, and asserts `ArgumentNullException` is thrown. -#### CanvasNet-Codecs-TiffCodec-SaveNullStream: Save Rejects a Null Stream +##### CanvasNet-Codecs-TiffCodec-SaveNullStream: Save Rejects a Null Stream **Test**: `TiffCodec_SaveStream_NullStream_ThrowsArgumentNullException` Calls `Save` with a valid surface and a null stream, and asserts `ArgumentNullException` is thrown. -#### CanvasNet-Codecs-TiffCodec-SaveNullPath: Save Rejects a Null Path +##### CanvasNet-Codecs-TiffCodec-SaveNullPath: Save Rejects a Null Path **Test**: `TiffCodec_SavePath_NullPath_ThrowsArgumentNullException` Calls `Save` with a valid surface and a null path, and asserts `ArgumentNullException` is thrown. -#### CanvasNet-Codecs-TiffCodec-SaveEmptyPath: Save Rejects an Empty Path +##### CanvasNet-Codecs-TiffCodec-SaveEmptyPath: Save Rejects an Empty Path **Test**: `TiffCodec_SavePath_EmptyPath_ThrowsArgumentException` Calls `Save` with a valid surface and an empty path, and asserts `ArgumentException` is thrown. -#### CanvasNet-Codecs-TiffCodec-SaveInvalidCompression: Save Rejects an Undefined Compression Value +##### CanvasNet-Codecs-TiffCodec-SaveInvalidCompression: Save Rejects an Undefined Compression Value **Tests**: `TiffCodec_SaveStream_UndefinedCompression_ThrowsArgumentOutOfRangeException`, `TiffCodec_SavePath_UndefinedCompression_ThrowsArgumentOutOfRangeException` @@ -176,39 +176,39 @@ Calls `Save` with a valid surface and an empty path, and asserts `ArgumentExcept Calls each `Save` overload with an undefined `TiffCompression` value cast from an out-of-range integer, and asserts `ArgumentOutOfRangeException` is thrown. -#### CanvasNet-Codecs-TiffCodec-LoadNullStream: Load Rejects a Null Stream +##### CanvasNet-Codecs-TiffCodec-LoadNullStream: Load Rejects a Null Stream **Test**: `TiffCodec_LoadStream_NullStream_ThrowsArgumentNullException` Calls `Load` with a null stream and asserts `ArgumentNullException` is thrown. -#### CanvasNet-Codecs-TiffCodec-LoadNullPath: Load Rejects a Null Path +##### CanvasNet-Codecs-TiffCodec-LoadNullPath: Load Rejects a Null Path **Test**: `TiffCodec_LoadPath_NullPath_ThrowsArgumentNullException` Calls `Load` with a null path and asserts `ArgumentNullException` is thrown. -#### CanvasNet-Codecs-TiffCodec-LoadEmptyPath: Load Rejects an Empty Path +##### CanvasNet-Codecs-TiffCodec-LoadEmptyPath: Load Rejects an Empty Path **Test**: `TiffCodec_LoadPath_EmptyPath_ThrowsArgumentException` Calls `Load` with an empty path and asserts `ArgumentException` is thrown. -#### CanvasNet-Codecs-TiffCodec-LoadBadByteOrder: Load Rejects an Invalid Byte-Order Mark +##### CanvasNet-Codecs-TiffCodec-LoadBadByteOrder: Load Rejects an Invalid Byte-Order Mark **Test**: `TiffCodec_Load_BadByteOrderMark_ThrowsInvalidDataException` Hand-builds a TIFF file and corrupts its byte-order mark to neither `"II"` nor `"MM"`, and asserts `Load` throws `InvalidDataException`. -#### CanvasNet-Codecs-TiffCodec-LoadUnsupportedBitDepth: Load Rejects Unsupported Bit Depths +##### CanvasNet-Codecs-TiffCodec-LoadUnsupportedBitDepth: Load Rejects Unsupported Bit Depths **Test**: `TiffCodec_Load_UnsupportedBitsPerSample_ThrowsInvalidDataException` Hand-builds a TIFF declaring a `BitsPerSample` value of 16, and asserts `Load` throws `InvalidDataException`. -#### CanvasNet-Codecs-TiffCodec-LoadExceedsMaxDimension: Load Rejects Dimensions Exceeding Surface.MaxDimension +##### CanvasNet-Codecs-TiffCodec-LoadExceedsMaxDimension: Load Rejects Dimensions Exceeding Surface.MaxDimension **Tests**: `TiffCodec_Load_WidthExceedsMaxDimension_ThrowsInvalidDataException`, `TiffCodec_Load_HeightExceedsMaxDimension_ThrowsInvalidDataException` @@ -219,35 +219,35 @@ Hand-builds a TIFF declaring an `ImageWidth` value one greater than `Surface.Max `Surface`'s constructor) in both cases, confirming the dimension check happens before the row-byte-width (`width * samplesPerPixel`) arithmetic performed later in `Load`. -#### CanvasNet-Codecs-TiffCodec-LoadUnsupportedPhotometric: Load Rejects Unsupported Photometric Interpretations +##### CanvasNet-Codecs-TiffCodec-LoadUnsupportedPhotometric: Load Rejects Unsupported Photometric Interpretations **Test**: `TiffCodec_Load_UnsupportedPhotometricPalette_ThrowsInvalidDataException` Hand-builds a TIFF declaring photometric interpretation 3 (Palette), and asserts `Load` throws `InvalidDataException`. -#### CanvasNet-Codecs-TiffCodec-LoadUnsupportedCompression: Load Rejects Unsupported Compression Values +##### CanvasNet-Codecs-TiffCodec-LoadUnsupportedCompression: Load Rejects Unsupported Compression Values **Test**: `TiffCodec_Load_UnsupportedCompression_ThrowsInvalidDataException` Hand-builds a TIFF declaring compression value 6 (JPEG, not supported), and asserts `Load` throws `InvalidDataException`. -#### CanvasNet-Codecs-TiffCodec-LoadPlanarConfiguration: Load Rejects Planar Configuration +##### CanvasNet-Codecs-TiffCodec-LoadPlanarConfiguration: Load Rejects Planar Configuration **Test**: `TiffCodec_Load_PlanarConfigurationPlanar_ThrowsInvalidDataException` Hand-builds a TIFF declaring `PlanarConfiguration = 2` (Planar), and asserts `Load` throws `InvalidDataException`. -#### CanvasNet-Codecs-TiffCodec-LoadTiled: Load Rejects Tiled TIFF Images +##### CanvasNet-Codecs-TiffCodec-LoadTiled: Load Rejects Tiled TIFF Images **Test**: `TiffCodec_Load_TiledTiff_ThrowsInvalidDataException` Hand-builds a TIFF declaring `TileWidth`/`TileLength` tags and no strip tags, and asserts `Load` throws `InvalidDataException`. -#### CanvasNet-Codecs-TiffCodec-GetInfoTiledRejection: GetInfo Rejects Tiled TIFF Images Identically to Load +##### CanvasNet-Codecs-TiffCodec-GetInfoTiledRejection: GetInfo Rejects Tiled TIFF Images Identically to Load **Tests**: `TiffCodec_GetInfo_Seekable_TiledTiff_ThrowsInvalidDataException` @@ -257,7 +257,7 @@ tiled-rejection check lives in the shared `ReadTiffImageInfo` helper used by bot `GetInfo`, rather than only in `Load`, closing the gap where `GetInfo` previously reported dimensions for an image `Load` could never actually decode. -#### CanvasNet-Codecs-TiffCodec-LoadMissingMandatoryTag: Load Rejects Files Missing a Mandatory Tag +##### CanvasNet-Codecs-TiffCodec-LoadMissingMandatoryTag: Load Rejects Files Missing a Mandatory Tag **Test**: `TiffCodec_Load_MissingMandatoryTag_ThrowsInvalidDataException` (`[Theory]` over each of the 8 mandatory tags) @@ -266,7 +266,7 @@ Hand-builds a TIFF omitting each mandatory tag (`ImageWidth`, `ImageLength`, `Bi `Compression`, `PhotometricInterpretation`, `StripOffsets`, `RowsPerStrip`, `StripByteCounts`) in turn, and asserts `Load` throws `InvalidDataException` for every case. -#### CanvasNet-Codecs-TiffCodec-LoadTruncatedStream: Load Rejects a Truncated Stream +##### CanvasNet-Codecs-TiffCodec-LoadTruncatedStream: Load Rejects a Truncated Stream **Tests**: `TiffCodec_Load_TruncatedHeader_ThrowsInvalidDataException`, `TiffCodec_Load_TruncatedStripData_ThrowsInvalidDataException` @@ -275,7 +275,7 @@ Builds a stream that ends before the 8-byte header is complete, and separately a data is shorter than the declared image dimensions require, and asserts `Load` throws `InvalidDataException` for both. -#### CanvasNet-Codecs-TiffCodec-TagValueBoundsValidation: Load and GetInfo Reject Zero-Count and Overflowing Tag Arithmetic +##### CanvasNet-Codecs-TiffCodec-TagValueBoundsValidation: Load and GetInfo Reject Zero-Count and Overflowing Tag Arithmetic **Tests**: `TiffCodec_Load_ImageWidthZeroCount_ThrowsInvalidDataExceptionNotIndexOutOfRange`, `TiffCodec_GetInfo_Seekable_ImageWidthZeroCount_ThrowsInvalidDataExceptionNotIndexOutOfRange`, @@ -293,7 +293,7 @@ in the TIFF file format's unsigned 32-bit offset field but not as a .NET array i asserts `Load` and `GetInfo` throw `InvalidDataException` rather than the `OverflowException` that would otherwise escape from an unguarded `checked((int)...)` cast. -#### CanvasNet-Codecs-TiffCodec-FixtureSupported: TiffFixtures Corpus Loads Successfully +##### CanvasNet-Codecs-TiffCodec-FixtureSupported: TiffFixtures Corpus Loads Successfully **Tests**: `TiffFixtures_SourcePngDimensions_Are32x32`, `TiffCodec_Load_Fixture_ReturnsCanvasWithExpectedDimensions` (`[Theory]` over 9 fixture files), @@ -307,7 +307,7 @@ dimensions; for RGB/RGBA fixtures, asserts every decoded pixel matches the corre source exactly (alpha forced to 255 for RGB fixtures); for grayscale fixtures, asserts only successful load, correct dimensions, and R == G == B per pixel. -#### CanvasNet-Codecs-TiffCodec-GetInfo: GetInfo Reports Dimensions/Channels/Alpha Without Decoding Strips +##### CanvasNet-Codecs-TiffCodec-GetInfo: GetInfo Reports Dimensions/Channels/Alpha Without Decoding Strips **Tests**: `TiffCodec_GetInfo_Seekable_Rgb_ReturnsExpectedInfoWithoutAlpha`, `TiffCodec_GetInfo_Seekable_Rgba_ReturnsExpectedInfoWithAlpha`, @@ -357,7 +357,7 @@ exercise the fallback end-to-end through the public `TiffCodec.GetInfo` entry po saved-then-reloaded TIFF file, per `docs/verification/canvas-net.md`'s system-level evidence contract. -#### CanvasNet-Codecs-TiffCodec-TagValueCountUpperBound: GetInfo Rejects an Implausibly Large Tag Count +##### CanvasNet-Codecs-TiffCodec-TagValueCountUpperBound: GetInfo Rejects an Implausibly Large Tag Count **Tests**: `TiffCodec_GetInfo_Seekable_BitsPerSampleCountImplausiblyLarge_ThrowsWithoutLargeAllocation` @@ -369,7 +369,7 @@ reject it. Measures `GC.GetAllocatedBytesForCurrentThread()` immediately before the allocated-bytes delta stays under a small bound (proving the multi-megabyte `uint[]` the count would otherwise require is never allocated). -#### CanvasNet-Codecs-TiffCodec-GetInfoValidation: GetInfo Rejects Invalid Arguments and Malformed Headers +##### CanvasNet-Codecs-TiffCodec-GetInfoValidation: GetInfo Rejects Invalid Arguments and Malformed Headers **Tests**: `TiffCodec_GetInfo_NullStream_ThrowsArgumentNullException`, `TiffCodec_GetInfo_NullPath_ThrowsArgumentNullException`, @@ -382,7 +382,7 @@ asserting `ArgumentNullException`, `ArgumentNullException`, `ArgumentException`, `InvalidDataException` respectively โ€” the same exception contract as the corresponding `Load` scenarios. -### Acceptance Criteria +#### Acceptance Criteria A unit test run passes when all test methods above (including each `[Theory]` case) pass without error or unexpected exception; any unexpected exception type or wrong return/byte value diff --git a/docs/verification/canvas-net/drawing.md b/docs/verification/canvas-net/drawing.md index 3290da35..b2ed682e 100644 --- a/docs/verification/canvas-net/drawing.md +++ b/docs/verification/canvas-net/drawing.md @@ -4,18 +4,21 @@ This document describes the subsystem-level verification strategy for the `Drawi (the `PathFiller` unit, covering the supporting `FillRule` enum and the internal `EdgeFlattener`/`ScanlineRasterizer` helpers; the `PathStroker` unit, covering the supporting `LineCap`/`LineJoin`/`StrokeStyle` types and the internal -`StrokePathFlattener`/`DashSplitter`/`StrokeOutliner` helpers; and the `GradientPaint` unit, +`StrokePathFlattener`/`DashSplitter`/`StrokeOutliner` helpers; the `GradientPaint` unit, covering the public `Gradient`/`LinearGradient`/`RadialGradient`/`GradientStop`/`GradientSpread` -types and the internal `GradientEvaluator` helper). +types and the internal `GradientEvaluator` helper; and the `TilePaint` unit, covering the public +`TilePaint` type and the internal `TilePaintEvaluator` helper). ### Verification Approach The `Drawing` subsystem is verified primarily through its constituent units' own tests: _PathFiller Unit Verification Design_ (`drawing/path-filler.md`), -_PathStroker Unit Verification Design_ (`drawing/path-stroker.md`), and -_GradientPaint Unit Verification Design_ (`drawing/gradient-paint.md`). Those tests exercise both +_PathStroker Unit Verification Design_ (`drawing/path-stroker.md`), +_GradientPaint Unit Verification Design_ (`drawing/gradient-paint.md`), and +_TilePaint Unit Verification Design_ (`drawing/tile-paint.md`). Those tests exercise both public APIs directly, and the internal helper types (`EdgeFlattener`/`ScanlineRasterizer`, -`StrokePathFlattener`/`DashSplitter`/`StrokeOutliner`, and `GradientEvaluator`, respectively) in +`StrokePathFlattener`/`DashSplitter`/`StrokeOutliner`, `GradientEvaluator`, and +`TilePaintEvaluator`, respectively) in isolation where that provides clearer evidence than end-to-end pixel checks alone. Two system-level integration tests additionally exercise the `Geometry`, `Drawing`, and `Canvas` subsystems together end to end (see the system verification design, `../canvas-net.md`): @@ -40,7 +43,18 @@ subsystems together end to end (see the system verification design, `../canvas-n The `Drawing` subsystem's verification passes when every unit test scenario described in _PathFiller Unit Verification Design_ (`drawing/path-filler.md`), -_PathStroker Unit Verification Design_ (`drawing/path-stroker.md`), and -_GradientPaint Unit Verification Design_ (`drawing/gradient-paint.md`) passes without error or +_PathStroker Unit Verification Design_ (`drawing/path-stroker.md`), +_GradientPaint Unit Verification Design_ (`drawing/gradient-paint.md`), and +_TilePaint Unit Verification Design_ (`drawing/tile-paint.md`) passes without error or unexpected exception, and both system-level integration tests described above pass. + +### Test Scenarios + +The `Drawing` subsystem's test scenarios are those named in each of its four constituent unit +verification documents (`drawing/path-filler.md`, `drawing/path-stroker.md`, +`drawing/gradient-paint.md`, `drawing/tile-paint.md`), covering fill-rule/stroke-style normal +paths, degenerate/empty-path boundary conditions, and argument-validation error paths, plus the +two system-level integration scenarios named above: a triangular `Path` filled onto a `Surface` +with fully-covered/untouched/antialiased-edge pixel assertions, and an empty or +entirely-out-of-bounds `Path` filled onto a `Surface` with a no-op (unchanged-pixel) assertion. diff --git a/docs/verification/canvas-net/drawing/gradient-paint.md b/docs/verification/canvas-net/drawing/gradient-paint.md index e3cca420..4122bdae 100644 --- a/docs/verification/canvas-net/drawing/gradient-paint.md +++ b/docs/verification/canvas-net/drawing/gradient-paint.md @@ -1,13 +1,13 @@ -## GradientPaint Unit Verification Design +### GradientPaint Unit Verification Design This document describes the unit-level verification strategy for the `GradientPaint` unit: the public `Gradient`/`LinearGradient`/`RadialGradient`/`GradientStop`/`GradientSpread` types and the internal `GradientEvaluator` helper, plus the gradient-specific scenarios of `PathFiller.Fill`, `ScanlineRasterizer.Fill`, and `Surface.CompositeOverSpan` that consume them. -### Verification Approach +#### Verification Approach The unit is verified through a layered mix of narrow constructor/validation tests, direct algorithmic tests of the internal `GradientEvaluator`, and end-to-end rendering tests that fill a @@ -36,20 +36,20 @@ path with a gradient through the production `PathFiller` pipeline and inspect th No mocks are required. `GradientEvaluator` has no injectable dependencies. -### Test Environment +#### Test Environment - **Framework**: xUnit v3 running under the .NET SDK - **Execution**: `dotnet test` invoked by `build.ps1` and the CI pipeline - **Mocking**: None required -### Acceptance Criteria +#### Acceptance Criteria The unit passes verification when every scenario below passes without unexpected exception and every expected pixel color, alpha, or thrown exception type matches exactly. -### Test Scenarios +#### Test Scenarios -#### Gradient Stop and Gradient/Subtype Construction +##### Gradient Stop and Gradient/Subtype Construction - `GradientStop_Constructor_OffsetOutsideZeroToOne_ThrowsArgumentOutOfRangeException` - `GradientStop_Constructor_NonFiniteOffset_ThrowsArgumentOutOfRangeException` @@ -91,7 +91,7 @@ and `RadialGradient`, both declared in this assembly, may derive from it), and t transform argument resolves to the identity transform while an explicitly-supplied transform - including the all-zero matrix - is preserved exactly as given. -#### GradientEvaluator: Linear and Radial Parameter Math +##### GradientEvaluator: Linear and Radial Parameter Math - `EvaluatePoint_NullGradient_ThrowsArgumentNullException` - `EvaluatePoint_SingleStop_ResolvesToThatColorEverywhere` @@ -138,7 +138,7 @@ exactly on every interpolated circle in the swept family) and assert it resolves boundary-conforming endpoint color per the same `dr`-sign convention established by the spurious-second-root tests, rather than being left transparent. -#### GradientEvaluator: Spread Modes +##### GradientEvaluator: Spread Modes - `EvaluatePoint_Linear_PadSpread_ClampsBeyondEndpoints` - `EvaluatePoint_Linear_RepeatSpread_WrapsNegativeAndPositiveOutOfRange` @@ -149,7 +149,7 @@ parameter to the nearest endpoint color; `Repeat` floor-mods a raw parameter (in one) back into `[0, 1]`; and `Reflect` folds a raw parameter into a period-2 triangle wave, so a raw parameter of `1.5` resolves identically to `0.5`. -#### GradientEvaluator: Stop Ordering and Hard Stops +##### GradientEvaluator: Stop Ordering and Hard Stops - `EvaluatePoint_Linear_UnsortedInputStops_ResolvesInSortedOrder` - `EvaluatePoint_Linear_DuplicateStopOffsets_ProducesHardStepWithoutError` @@ -163,7 +163,7 @@ inconsistent result. The last two tests verify the exact tie-break direction: ev at a shared offset always resolves to the later-supplied stop's color, both for an interior duplicate pair and for a duplicate pair at the gradient's very first offset. -#### GradientEvaluator: Premultiplied-Alpha Interpolation +##### GradientEvaluator: Premultiplied-Alpha Interpolation - `EvaluatePoint_Linear_TransparentAndOpaqueStops_InterpolatesInPremultipliedAlphaSpace` - `EvaluatePoint_Linear_BothStopsFullyTransparent_ResolvesToAllZeroPixel` @@ -175,7 +175,7 @@ fully transparent stops with different RGB values and asserts the result is exac `(0, 0, 0, 0)`, matching `Surface.UnpremultiplyAlpha`'s documented "alpha == 0 implies R = G = B = 0" convention rather than propagating a divide-by-zero artifact. -#### GradientEvaluator: Degenerate-Case Policy +##### GradientEvaluator: Degenerate-Case Policy - `Constructor_NonFiniteTransform_ThrowsArgumentOutOfRangeException` - `EvaluatePoint_SingularTransform_FlatFillsWithLastStopColor` @@ -195,7 +195,7 @@ gradient's genuine "no valid root" region (a point outside every circle the grad sweeps through) resolves to fully transparent (alpha zero) rather than being flat-filled - proving these two superficially similar "nothing to paint" outcomes are correctly distinguished. -#### GradientEvaluator: Extreme-Coordinate Robustness +##### GradientEvaluator: Extreme-Coordinate Robustness - `EvaluatePoint_Linear_ExtremeMagnitudeCoordinates_ResolvesCorrectly` - `EvaluatePoint_Linear_ExtremeScaleInvertibleTransformWithExtremeCoordinates_ProducesFiniteNonNaNColor` @@ -210,7 +210,7 @@ even though every individual coordinate and the true mathematical result remain channels are all finite and non-`NaN` when both an extreme transform scale and extreme input coordinates combine. -#### PathFiller/ScanlineRasterizer: Gradient Fill Shares Behavior with Solid-Color Fill +##### PathFiller/ScanlineRasterizer: Gradient Fill Shares Behavior with Solid-Color Fill - `PathFiller_Fill_Gradient_HorizontalRectangleWithHorizontalLinearGradient_VariesLeftToRight` - `PathFiller_Fill_Gradient_SquareWithRadialGradient_CenterAndCornerMatchExpectedRamp` @@ -239,7 +239,7 @@ per fill operation instead of once per row: it fills a 50-row-tall rectangle wit `GradientEvaluator.EvaluatePoint` calls, proving the once-per-fill plan reuse produces identical output to what per-pixel/per-row evaluation would. -#### PathFiller: Gradient Fill Argument Validation +##### PathFiller: Gradient Fill Argument Validation - `PathFiller_Fill_Gradient_NullSurface_ThrowsArgumentNullException` - `PathFiller_Fill_Gradient_NullPath_ThrowsArgumentNullException` @@ -251,7 +251,7 @@ These tests verify the gradient overload rejects a null surface, path, or gradie argument, and a non-positive/non-finite `flattenTolerance` or undefined `fillRule` argument, matching the solid-color overload's own validation exactly. -#### Surface: Per-Pixel-Color CompositeOverSpan Overload +##### Surface: Per-Pixel-Color CompositeOverSpan Overload - `Surface_CompositeOverSpan_PerPixelColors_MatchesConstantColorOverload_WhenAllColorsEqual` - `Surface_CompositeOverSpan_PerPixelColors_FullCoverage_AppliesEachPixelsOwnColor` @@ -267,7 +267,7 @@ Unit Design_, `../canvas/surface.md`); it applies each pixel's own color scaled own coverage; it rejects a coverage/colors length mismatch; and its internal workspace-reusing counterpart matches its public form exactly. -### Complexity Verification Policy +#### Complexity Verification Policy No algorithmic complexity properties are newly established by this unit beyond those already documented for `ScanlineRasterizer`/`Surface.CompositeOverSpan` (see _PathFiller Unit Design_, diff --git a/docs/verification/canvas-net/drawing/path-filler.md b/docs/verification/canvas-net/drawing/path-filler.md index c0c70ad6..aa4e5a7f 100644 --- a/docs/verification/canvas-net/drawing/path-filler.md +++ b/docs/verification/canvas-net/drawing/path-filler.md @@ -1,9 +1,9 @@ -## PathFiller Unit Verification Design +### PathFiller Unit Verification Design This document describes the unit-level verification strategy for the `PathFiller` class (and the supporting `FillRule` enum and internal `EdgeFlattener`/`ScanlineRasterizer` helpers). -### Verification Approach +#### Verification Approach The `PathFiller` unit is verified through unit tests that exercise `PathFiller.Fill` end to end (constructing a `Path`, filling it onto a `Surface`, and inspecting resulting pixels), plus @@ -21,13 +21,13 @@ Unit tests reside in `PathFillerTests.cs`, `EdgeFlattenerTests.cs`, and `ScanlineRasterizerTests.cs` within the `DemaConsulting.CanvasNet.Tests.Drawing` project namespace. -### Test Environment +#### Test Environment - **Framework**: xUnit v3 running under the .NET SDK - **Execution**: `dotnet test` invoked by `build.ps1` and the CI pipeline - **Mocking**: None required; `PathFiller` has no injectable dependencies -### Hand-Computed Reference Values +#### Hand-Computed Reference Values The following geometric constructions and their exact hand-computed coverage results are used across the scenarios below: @@ -55,9 +55,9 @@ across the scenarios below: `0`) - the standard nested-subpath "hole" construction used, for example, by the letter "O" in vector font outlines. -### Unit-Level Test Scenarios +#### Unit-Level Test Scenarios -#### CanvasNet-Drawing-PathFiller-Fill: End-to-End Fill Composites the Requested Color +##### CanvasNet-Drawing-PathFiller-Fill: End-to-End Fill Composites the Requested Color **Test**: `PathFiller_Fill_FullyCoveredInteriorPixel_ColorAndAlphaMatchOverOracle` @@ -68,7 +68,7 @@ background pixel. Asserts the two results are byte-identical, confirming `PathFi performs the exact same Porter-Duff "over" blend as the existing `CompositeOver` overload for a fully covered pixel, not merely "some" blend. -#### CanvasNet-Drawing-PathFiller-Antialiasing: Antialiased Coverage Matches Hand-Computed Reference Values +##### CanvasNet-Drawing-PathFiller-Antialiasing: Antialiased Coverage Matches Hand-Computed Reference Values **Tests**: `PathFiller_Fill_AxisAlignedRectangleFractionalEdges_InteriorOpaqueEdgesAntialiased`, `PathFiller_Fill_DiagonalTriangle_MatchesHandComputedCoverageGradient`, @@ -91,7 +91,7 @@ for a curved edge, which the straight-edged rectangle/triangle scenarios above d the `EdgeFlattener`-only tests (see below) do not either since they check flattened vertex positions in isolation rather than integrated fill coverage. -#### CanvasNet-Drawing-PathFiller-FillRule: NonZero and EvenOdd Diverge on Self-Overlapping Geometry +##### CanvasNet-Drawing-PathFiller-FillRule: NonZero and EvenOdd Diverge on Self-Overlapping Geometry **Test**: `PathFiller_Fill_OverlappingSameWoundRectangles_NonZeroVsEvenOddDiverge` @@ -100,7 +100,7 @@ with `FillRule.EvenOdd`. Asserts the singly-covered pixel is fully opaque under the doubly-covered overlap pixel is fully opaque under `NonZero` but fully transparent under `EvenOdd`, directly proving the two fill rules resolve overlapping winding differently. -#### CanvasNet-Drawing-PathFiller-Holes: Nested Counter-Wound Subpaths Render a Hole +##### CanvasNet-Drawing-PathFiller-Holes: Nested Counter-Wound Subpaths Render a Hole **Test**: `PathFiller_Fill_NestedCounterWoundSubpaths_RendersHole` @@ -109,7 +109,7 @@ between the outer square and the inner hole is fully opaque at multiple sample p hole's own interior is fully transparent at multiple sample points, confirming winding-based hole resolution across two subpaths within a single `Path`. -#### CanvasNet-Drawing-PathFiller-ImplicitClose: An Explicitly Open Subpath Fills Identically to a Closed One +##### CanvasNet-Drawing-PathFiller-ImplicitClose: An Explicitly Open Subpath Fills Identically to a Closed One **Test**: `PathFiller_Fill_ExplicitlyOpenSubpath_FillsIdenticallyToClosed` @@ -118,7 +118,7 @@ Builds the same triangle twice - once left explicitly open (no `Close` call, `Is asserts every pixel matches exactly between the two surfaces, confirming the implicit-close rule makes the `Close` call observably irrelevant to fill output. -#### CanvasNet-Drawing-PathFiller-EmptyOrOutOfBoundsNoOp: Empty or Out-of-Bounds Paths Are a No-Op +##### CanvasNet-Drawing-PathFiller-EmptyOrOutOfBoundsNoOp: Empty or Out-of-Bounds Paths Are a No-Op **Tests**: `PathFiller_Fill_EmptyPath_NoOpLeavesSurfaceUnchanged`, `PathFiller_Fill_PathFullyOutsideSurfaceBounds_NoOpLeavesSurfaceUnchanged` @@ -128,7 +128,7 @@ completely unmodified. Separately, fills a well-formed rectangle whose bounding outside the surface's pixel extent and asserts the surface is likewise completely unmodified. Neither case throws. -#### CanvasNet-Drawing-PathFiller-NullArguments: Fill Rejects Null Surface/Path +##### CanvasNet-Drawing-PathFiller-NullArguments: Fill Rejects Null Surface/Path **Tests**: `PathFiller_Fill_NullSurface_ThrowsArgumentNullException`, `PathFiller_Fill_NullPath_ThrowsArgumentNullException` @@ -136,7 +136,7 @@ Neither case throws. Calls `PathFiller.Fill` with a `null` surface, and separately with a `null` path, and asserts `ArgumentNullException` is thrown in both cases. -#### CanvasNet-Drawing-PathFiller-NonPositiveTolerance: Fill Rejects a Non-Positive flattenTolerance +##### CanvasNet-Drawing-PathFiller-NonPositiveTolerance: Fill Rejects a Non-Positive flattenTolerance **Test**: `PathFiller_Fill_NonPositiveFlattenTolerance_ThrowsArgumentOutOfRangeException` @@ -144,7 +144,7 @@ Calls `PathFiller.Fill` with `flattenTolerance` equal to `0`, and separately a n separately each of `float.NaN`, `float.PositiveInfinity`, and `float.NegativeInfinity`, and asserts `ArgumentOutOfRangeException` is thrown in every case (`[Theory]`-driven). -#### CanvasNet-Drawing-PathFiller-InvalidFillRule: Fill Rejects an Undefined FillRule Value +##### CanvasNet-Drawing-PathFiller-InvalidFillRule: Fill Rejects an Undefined FillRule Value **Test**: `PathFiller_Fill_UndefinedFillRule_ThrowsArgumentOutOfRangeException` @@ -154,7 +154,7 @@ rejected at the public API boundary rather than silently falling through `ScanlineRasterizer.ResolveCoverage`'s winding-resolution `else` branch and being treated as `FillRule.EvenOdd`. -#### CanvasNet-Drawing-PathFiller-EdgeFlattenerConversion: EdgeFlattener Converts Each Command Type Correctly +##### CanvasNet-Drawing-PathFiller-EdgeFlattenerConversion: EdgeFlattener Converts Each Command Type Correctly **Tests**: `EdgeFlattener_Flatten_LineTo_ConvertsToExpectedPoint`, `EdgeFlattener_Flatten_QuadraticBezierTo_DelegatesToBezierFlattening`, @@ -172,7 +172,7 @@ rather than an independent, potentially divergent implementation. Separately, bu multi-subpath `Path` and asserts the result contains one separate polygon per subpath. Separately, flattens `Path.Empty` and asserts an empty list is returned. -#### CanvasNet-Drawing-PathFiller-EdgeFlattenerImplicitClose: Implicit Close Is Applied Exactly Once +##### CanvasNet-Drawing-PathFiller-EdgeFlattenerImplicitClose: Implicit Close Is Applied Exactly Once **Tests**: `EdgeFlattener_Flatten_OpenSubpath_AppendsImplicitClosingPoint`, `EdgeFlattener_Flatten_AlreadyClosedSubpath_DoesNotDuplicateClosingPoint` @@ -183,7 +183,7 @@ point was appended). Separately, flattens a subpath whose last command's end poi its start, and asserts no duplicate closing point was appended (the polygon's point count matches the walked command count exactly, not one more). -#### CanvasNet-Drawing-PathFiller-ScanlineCoverageMath: Coverage Math Matches a Hand-Computed Sub-Pixel-Offset Reference +##### CanvasNet-Drawing-PathFiller-ScanlineCoverageMath: Coverage Math Matches a Hand-Computed Sub-Pixel-Offset Reference **Test**: `ScanlineRasterizer_Fill_SubPixelOffsetSquare_MatchesHandComputedCoverage` @@ -193,7 +193,7 @@ quarter of its unit cell) directly via `ScanlineRasterizer.Fill`, and asserts ev resulting alpha exactly equals the hand-computed expected value (`255 * 0.25 = 63.75`, which rounds to `64` under round-half-away-from-zero). -#### CanvasNet-Drawing-PathFiller-ScanlineFillRuleResolution: Fill-Rule Resolution Formulas Are Verified Directly +##### CanvasNet-Drawing-PathFiller-ScanlineFillRuleResolution: Fill-Rule Resolution Formulas Are Verified Directly **Tests**: `ScanlineRasterizer_Fill_RawWindingOfTwo_NonZeroClampsEvenOddFoldsToZero`, `ScanlineRasterizer_Fill_SubPixelOffsetDuplicatePolygons_SumsCoincidentContributionsPerCellAlgorithm`, @@ -214,7 +214,7 @@ the total into `[0, 1]`. This is the documented, industry-standard (AGG/FreeType accepted in exchange for the crossing-edge correctness fix below, and both tests exist to detect any accidental regression in that specific, intentional behavior. -#### CanvasNet-Drawing-PathFiller-ScanlineCrossingEdges: Self-Intersecting Polygons Produce Correct Partial Coverage +##### CanvasNet-Drawing-PathFiller-ScanlineCrossingEdges: Self-Intersecting Polygons Produce Correct Partial Coverage **Test**: `ScanlineRasterizer_Fill_BowtieSelfIntersectingPolygon_ProducesCorrectPartialCoverageNotFullFill` @@ -228,7 +228,7 @@ cell-based rewrite fixes: the prior sub-interval/sort-by-x algorithm produced ~1 this exact case, because it assumed edges spanning a sub-interval never change their relative x-order within it - an assumption that crossing/self-intersecting edges violate by construction. -#### CanvasNet-Drawing-PathFiller-ScanlineActiveEdgeList: Active-Edge-List Add/Remove Occurs at the Correct Rows +##### CanvasNet-Drawing-PathFiller-ScanlineActiveEdgeList: Active-Edge-List Add/Remove Occurs at the Correct Rows **Test**: `ScanlineRasterizer_Fill_EdgeStartingAndEndingMidSweep_StopsContributingAtCorrectRows` @@ -238,7 +238,7 @@ after its bottom edge remain completely untouched, while the rectangle's own row filled - confirming each edge is added to, and removed from, the active edge list at exactly its own `TopY`/`BottomY`, not the surface's full height. -#### CanvasNet-Drawing-PathFiller-ScanlineDegenerateInputNoOp: Degenerate Input Is a No-Op +##### CanvasNet-Drawing-PathFiller-ScanlineDegenerateInputNoOp: Degenerate Input Is a No-Op **Tests**: `ScanlineRasterizer_Fill_NoPolygons_NoOp`, `ScanlineRasterizer_Fill_DegeneratePolygon_ContributesZeroCoverage` @@ -246,7 +246,7 @@ Calls `ScanlineRasterizer.Fill` with an empty polygon list, and separately with reduced to two points (fewer than the edges needed to enclose any area), and asserts the surface remains completely unmodified in both cases, without throwing. -#### CanvasNet-Drawing-PathFiller-GradientFill: Gradient Fill Shares Flattening/Clipping/Fill-Rule Behavior +##### CanvasNet-Drawing-PathFiller-GradientFill: Gradient Fill Shares Flattening/Clipping/Fill-Rule Behavior **Tests**: `PathFiller_Fill_Gradient_HorizontalRectangleWithHorizontalLinearGradient_VariesLeftToRight`, `PathFiller_Fill_Gradient_SingleStop_MatchesSolidColorFill`, @@ -269,7 +269,7 @@ fully-opaque pixels - between the solid-color and gradient overloads. Separately _CanvasNet-Drawing-PathFiller-EmptyOrOutOfBoundsNoOp_ above) against the gradient overload, confirming that shared behavior is genuinely shared rather than coincidentally similar. -#### CanvasNet-Drawing-PathFiller-GradientFillValidation: Gradient Fill Validates Its Arguments +##### CanvasNet-Drawing-PathFiller-GradientFillValidation: Gradient Fill Validates Its Arguments **Tests**: `PathFiller_Fill_Gradient_NullSurface_ThrowsArgumentNullException`, `PathFiller_Fill_Gradient_NullPath_ThrowsArgumentNullException`, @@ -283,7 +283,36 @@ non-positive/non-finite `flattenTolerance` and with an undefined `fillRule` valu `ArgumentOutOfRangeException` in each case - matching the solid-color overload's own validation behavior exactly. -### Floating-Point Tolerance +##### CanvasNet-Drawing-PathFiller-TileFill: Tile Fill Shares Flattening/Clipping/Fill-Rule Behavior + +**Tests**: `PathFiller_Fill_TilePaint_CheckerboardTile_FillsWithBothTileColors`, +`PathFiller_Fill_TilePaint_EmptyPath_NoOpLeavesSurfaceUnchanged`, +`ScanlineRasterizer_Fill_TilePaint_FullCoverageRow_RepeatsAcrossWidth`, +`ScanlineRasterizer_Fill_TilePaint_NoPolygons_NoOp` + +Fills a shape with a 2x2 checkerboard tile and asserts both tile colors appear in the filled +output, proving the tile overload samples a genuinely repeating pattern per pixel rather than a +single constant color. Separately, at the internal `ScanlineRasterizer` level, asserts a +fully-covered row repeats the tile pattern across its width exactly as many times as the row is +wide divided by the tile's pattern-space pitch, and that a polygon list with no polygons is a +no-op. Separately, repeats the empty-path no-op scenario already covered for the solid-color +overload (see _CanvasNet-Drawing-PathFiller-EmptyOrOutOfBoundsNoOp_ above) against the tile +overload, confirming that shared behavior is genuinely shared rather than coincidentally similar. + +##### CanvasNet-Drawing-PathFiller-TileFillValidation: Tile Fill Validates Its Arguments + +**Tests**: `PathFiller_Fill_TilePaint_NullSurface_ThrowsArgumentNullException`, +`PathFiller_Fill_TilePaint_NullPath_ThrowsArgumentNullException`, +`PathFiller_Fill_TilePaint_NullPaint_ThrowsArgumentNullException`, +`ScanlineRasterizer_Fill_TilePaint_NullPaint_ThrowsArgumentNullException` + +Calls the tile `Fill` overload with a `null` surface, a `null` path, and a `null` tile paint in +turn, asserting `ArgumentNullException` in each case, and separately asserts the internal +`ScanlineRasterizer` tile-paint entry point itself also rejects a `null` tile paint with +`ArgumentNullException` - matching the solid-color and gradient overloads' own validation +behavior. + +#### Floating-Point Tolerance Every hand-computed coverage value used in most of these tests (`0.25`, `0.5`, `0.75`, and `1.0` covered fractions) resolves, after compositing through `Surface`'s existing @@ -305,6 +334,6 @@ be the same `NearZeroDisplacement` epsilon (`1e-6f`) used internally by `Scanlin classify near-horizontal/near-vertical edges, since the analytic accumulation itself is exact arithmetic up to ordinary floating-point rounding at that scale. -### Acceptance Criteria +#### Acceptance Criteria A unit test run passes when every scenario above passes without error or unexpected exception. diff --git a/docs/verification/canvas-net/drawing/path-stroker.md b/docs/verification/canvas-net/drawing/path-stroker.md index 17bee6b2..9131aace 100644 --- a/docs/verification/canvas-net/drawing/path-stroker.md +++ b/docs/verification/canvas-net/drawing/path-stroker.md @@ -1,10 +1,10 @@ -## PathStroker Unit Verification Design +### PathStroker Unit Verification Design This document describes the unit-level verification strategy for the `PathStroker` class and the supporting `StrokeStyle`, `LineCap`, `LineJoin`, `StrokePathFlattener`, `DashSplitter`, and `StrokeOutliner` types it covers inline. -### Verification Approach +#### Verification Approach The `PathStroker` unit is verified through a mix of end-to-end rendering assertions and narrower internal geometry tests: @@ -24,20 +24,20 @@ internal geometry tests: No mocks are required. The helpers are accessible to the test project through the existing `InternalsVisibleTo` configuration already used elsewhere in the repository. -### Test Environment +#### 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 unit has no injectable dependencies -### Acceptance Criteria +#### Acceptance Criteria The unit passes verification when every scenario below passes without unexpected exception and every expected pixel alpha or geometry assertion matches exactly. -### Test Scenarios +#### Test Scenarios -#### Stroke Conversion and Cap Shapes +##### Stroke Conversion and Cap Shapes - `PathStroker_Stroke_HorizontalLineButtCap_FillsExactRectangleNoExtension` - `PathStroker_Stroke_HorizontalLineRoundCap_FillsRectanglePlusSemicircularEnds` @@ -48,7 +48,7 @@ verify that butt caps stop exactly at the endpoints, square caps extend by half round caps contribute the expected partial-coverage end pixels rather than a rectangular extension. -#### Join Shapes and Miter Limit +##### Join Shapes and Miter Limit - `PathStroker_Stroke_RightAngleCornerMiterJoin_FillsSharpMiteredCorner` - `PathStroker_Stroke_RightAngleCornerRoundJoin_FillsRoundedCorner` @@ -59,7 +59,7 @@ These tests use the same right-angle polyline with width 4 so the corner-only pi clearly between join styles. The miter-limit scenario uses the same path with a deliberately small limit, proving that the rendered result matches the bevel case rather than the unrestricted miter. -#### Closed Contours, Dashing, and Degenerate Subpaths +##### Closed Contours, Dashing, and Degenerate Subpaths - `PathStroker_Stroke_ClosedRectangle_FillsRingLeavingInteriorAndExteriorUnfilled` - `PathStroker_Stroke_OverlappingLineAndPointCapOutlines_FillsOverlapRegionSolid` @@ -77,7 +77,7 @@ region solidly filled rather than as a winding-cancellation hole; dashed lines m visible runs; degenerate subpaths must follow cap semantics exactly; and an empty path must remain a no-op. -#### Concave/Collinear Closed Contours +##### Concave/Collinear Closed Contours - `PathStroker_Stroke_ClosedRectangleBevelJoin_InnerCornerMatchesExactIntersectionNotBevel` - `PathStroker_Stroke_ClosedRectangleRoundJoin_InnerCornerMatchesExactIntersectionNotArc` @@ -97,7 +97,7 @@ along the contour; and that a degenerate closed contour - a 2-point subpath, a 3 whose distinct points are all collinear, or a collinear subpath containing duplicate points - still renders a visible stroke rather than vanishing to nothing. -#### Robustness Against Extreme and Degenerate Numeric Input +##### Robustness Against Extreme and Degenerate Numeric Input - `PathStroker_Stroke_OverflowProneDashArrayWithNegativeOffset_CompletesWithoutHanging` - `DashSplitter_Split_OverflowProneDashArrayWithNegativeOffset_CompletesWithoutHanging` @@ -132,7 +132,7 @@ interval is materialized), and a round join/cap tessellated at extreme geometric than hanging, misclassifying the contour as degenerate, producing `NaN`/`Infinity` coordinates, or materializing an impractical number of retained intervals/segments. -#### Public API Validation +##### Public API Validation - `PathStroker_Stroke_NullPath_ThrowsArgumentNullException` - `PathStroker_Stroke_NullStrokeStyle_ThrowsArgumentNullException` @@ -141,7 +141,7 @@ materializing an impractical number of retained intervals/segments. These tests prove that the public entry point rejects invalid reference and tolerance arguments before attempting any geometry conversion. -#### StrokeStyle Constructor Validation +##### StrokeStyle Constructor Validation - `StrokeStyle_Constructor_NonPositiveWidth_ThrowsArgumentOutOfRangeException` - `StrokeStyle_Constructor_MiterLimitBelowOne_ThrowsArgumentOutOfRangeException` @@ -154,7 +154,7 @@ before attempting any geometry conversion. These tests verify that style validation is front-loaded at construction time and that valid public styling values are preserved exactly. -#### Internal Helper Semantics +##### Internal Helper Semantics - `StrokePathFlattener_Flatten_OpenSubpath_PreservesIsClosedFalse` - `StrokePathFlattener_Flatten_ClosedSubpath_PreservesIsClosedTrueWithNoImplicitDuplicatePoint` @@ -185,7 +185,7 @@ source authoring order, tolerating segments spanning near-extreme float32 coordi overflowing to a degenerate outline, and producing shell rings with opposite winding for `FillRule.NonZero`. -### Complexity Verification Policy +#### Complexity Verification Policy Complexity expectations for dash splitting and outline generation are established in _PathStroker Unit Design_ (`../../../design/canvas-net/drawing/path-stroker.md`) through design diff --git a/docs/verification/canvas-net/drawing/tile-paint.md b/docs/verification/canvas-net/drawing/tile-paint.md new file mode 100644 index 00000000..4518c8da --- /dev/null +++ b/docs/verification/canvas-net/drawing/tile-paint.md @@ -0,0 +1,112 @@ + + +### TilePaint Unit Verification Design + +This document describes the unit-level verification strategy for the `TilePaint` unit: the public +`TilePaint` type and the internal `TilePaintEvaluator` helper, plus the tile-paint-specific +scenarios of `PathFiller.Fill` and `ScanlineRasterizer.Fill` that consume them. + +#### Verification Approach + +The unit is verified through a layered mix of narrow constructor/validation tests, direct +algorithmic tests of the internal `TilePaintEvaluator`, and end-to-end rendering tests that fill a +path with a tile paint through the production `PathFiller` pipeline and inspect the resulting +`Surface` pixels: + +- **Constructor validation** (`TilePaintTests.cs`) verifies rejection of a null surface, a + non-finite transform component, and a non-finite/zero `XStep`/`YStep`, plus round-trip + preservation of valid values (including negative steps) and `WithTransform`'s transform + composition. +- **Direct evaluator tests** (`TilePaintEvaluatorTests.cs`) call the internal + `TilePaintEvaluator.CreatePlan`/`EvaluateRow` methods directly (accessible to the test project + via the existing `InternalsVisibleTo` configuration) to verify identity-transform sampling, + wraparound beyond one tile repetition, negative-step wraparound, and the degenerate-transform + fully-transparent policy, independently of the rasterizer. +- **End-to-end rendering tests** (`PathFillerTests.cs`, `ScanlineRasterizerTests.cs`) fill actual + path geometry with a tile paint through `PathFiller.Fill`/`ScanlineRasterizer.Fill` and assert on + rendered `Surface` pixels, proving the tile-paint overloads share coverage computation and + validation with the pre-existing solid-color/gradient overloads rather than merely resembling + them. + +No mocks are required. `TilePaintEvaluator` has no injectable dependencies. + +#### Test Environment + +- **Framework**: xUnit v3 running under the .NET SDK +- **Execution**: `dotnet test` invoked by `build.ps1` and the CI pipeline +- **Mocking**: None required + +#### Acceptance Criteria + +The unit passes verification when every scenario below passes without unexpected exception and +every expected pixel color, alpha, or thrown exception type matches exactly. + +#### Test Scenarios + +##### TilePaint Construction + +- `TilePaint_Constructor_NullSurface_ThrowsArgumentNullException` +- `TilePaint_Constructor_TransformWithNaNComponent_ThrowsArgumentOutOfRangeException` +- `TilePaint_Constructor_TransformWithInfinityComponent_ThrowsArgumentOutOfRangeException` +- `TilePaint_Constructor_ZeroXStep_ThrowsArgumentOutOfRangeException` +- `TilePaint_Constructor_ZeroYStep_ThrowsArgumentOutOfRangeException` +- `TilePaint_Constructor_NonFiniteStep_ThrowsArgumentOutOfRangeException` +- `TilePaint_Constructor_NegativeSteps_Succeeds` +- `TilePaint_Constructor_ValidValues_PropertiesRoundTrip` +- `TilePaint_WithTransform_ComposesTransformPreservingOtherProperties` + +These tests verify that the constructor rejects a null surface, a non-finite transform component, +and a non-finite or zero `XStep`/`YStep`, while accepting a negative step value (a legitimate PDF +tiling-pattern value meaning the tile repeats in the negative pattern-space axis direction); that +valid values round-trip exactly through the public properties; and that `WithTransform` returns a +new instance whose `Transform` is the original transform composed with the supplied transform, +leaving `Surface`/`XStep`/`YStep` unchanged and the original instance unmutated. + +##### TilePaintEvaluator: Sampling and Wraparound + +- `CreatePlan_NullTile_ThrowsArgumentNullException` +- `EvaluateRow_NonInvertibleTransform_WritesFullyTransparentRow` +- `EvaluateRow_IdentityTransform_SamplesExpectedTilePixels` +- `EvaluateRow_WrapAroundBeyondOneTile_MatchesFirstTileRepetition` +- `EvaluateRow_NegativeSteps_SamplesUsingAbsoluteValueWraparound` + +These tests verify the core per-row sampling pipeline directly: an identity-transform tile paint +samples the expected tile pixel at each evaluated device-space point; a point more than one tile +repetition away from the origin wraps around (via floor-mod) to match the color sampled at the +equivalent point within the first tile repetition, proving the repeating-pattern behavior; a tile +paint with negative `XStep`/`YStep` still wraps correctly using the absolute value of each step; +and a non-invertible (singular) `Transform` causes `EvaluateRow` to write a fully transparent +(alpha zero) pixel for the entire row - the deliberate "Degenerate Transform" policy divergence +from `GradientEvaluator`'s own flat-fill-with-last-stop-color policy (see `tile-paint.md`'s Key +Methods section for the rationale). + +##### PathFiller/ScanlineRasterizer: Tile Paint Fill Shares Behavior with Solid-Color/Gradient Fill + +- `PathFiller_Fill_TilePaint_CheckerboardTile_FillsWithBothTileColors` +- `PathFiller_Fill_TilePaint_NullSurface_ThrowsArgumentNullException` +- `PathFiller_Fill_TilePaint_NullPath_ThrowsArgumentNullException` +- `PathFiller_Fill_TilePaint_NullPaint_ThrowsArgumentNullException` +- `PathFiller_Fill_TilePaint_EmptyPath_NoOpLeavesSurfaceUnchanged` +- `ScanlineRasterizer_Fill_TilePaint_FullCoverageRow_RepeatsAcrossWidth` +- `ScanlineRasterizer_Fill_TilePaint_NoPolygons_NoOp` +- `ScanlineRasterizer_Fill_TilePaint_NullPaint_ThrowsArgumentNullException` + +These tests prove the tile-paint `Fill` overloads on `PathFiller` and `ScanlineRasterizer` produce +a genuinely repeating tile pattern: filling a region larger than a single tile with a 2-color +checkerboard tile produces both of the tile's own colors in the filled result; the overloads +reject a null surface, path, or tile paint argument; and they honor the same empty-input/ +no-polygons no-op behavior already established for the solid-color and gradient overloads. + +#### Complexity Verification Policy + +No algorithmic complexity properties are newly established by this unit beyond those already +documented for `ScanlineRasterizer` (see _PathFiller Unit Design_, `path-filler.md`); +`TilePaintEvaluator` itself performs a fixed, small amount of arithmetic per pixel with no loops +over unbounded input. `TilePaintEvaluator` precomputes the only per-fill-invariant quantity (the +inverted transform) once per fill operation, via `CreatePlan`, rather than once per pixel or once +per row; this is verified functionally, by the identity-transform and wraparound tests producing +exact expected pixel colors across a multi-pixel row, and by the degenerate-transform test +producing a fully transparent row in a single precomputed check rather than per pixel - not by any +timing measurement. There are intentionally **no** timing-based tests, elapsed-time assertions, or +`Stopwatch`-based guards in this unit's automated verification, consistent with every other unit in +this library. diff --git a/docs/verification/canvas-net/fonts.md b/docs/verification/canvas-net/fonts.md index f5c3892c..34dc4ad6 100644 --- a/docs/verification/canvas-net/fonts.md +++ b/docs/verification/canvas-net/fonts.md @@ -2,17 +2,25 @@ - + This document describes the subsystem-level verification strategy for the `Fonts` subsystem (the -`TrueTypeFont` unit). +`TrueTypeFont` unit, including its CFF/OpenType outline decoding and TrueType Collection support, +and, added this phase, the `SystemFontCatalog` unit's OS font discovery, best-effort matching, +and bundled-fallback loading). ### 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/DemaConsulting.CanvasNet.Tests/Fonts/`, through the real-font integration tests in +`TrueTypeFontRealFontIntegrationTests.cs`, through the two system-integration tests in +`CanvasNetTests.cs`, and, added this phase, through the `SystemFontCatalog` unit tests +(`SystemFontCatalogTests.cs`), the gated real-filesystem discovery tests +(`SystemFontCatalogRealDiscoveryIntegrationTests.cs`), and the bundled-Liberation-font dogfooding +tests (`BundledLiberationFontsTests.cs`). No dedicated subsystem test file exists. Instead, +subsystem-level requirements reuse the unit tests for parser/decoding/discovery/matching +behavior, reuse the real-font integration tests for end-to-end behavior against genuine +production font files (`.ttf`, `.otf`, and `.ttc`), and reuse the system tests for the integrated +collaboration between `Fonts`, `Geometry`, `Drawing`, and `Canvas`. ### Test Environment @@ -20,20 +28,44 @@ the integrated collaboration between `Fonts`, `Geometry`, `Drawing`, and `Canvas - **Execution**: `dotnet test` invoked by `build.ps1` and the CI pipeline - **Mocking**: None required; the subsystem uses only in-house byte-array fixtures and in-process calls to other CanvasNet public APIs -- **Fixtures**: Synthetic fonts are constructed in memory 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 +- **Fixtures**: Synthetic fonts (including synthetic CFF/OTTO fonts and a synthetic multi-face + `ttcf` container, built by `TestSupport/SyntheticFontBuilder`) are constructed in memory for + every `TrueTypeFont`/`CffTable`/`CffCharstringInterpreter`/`SfntContainer` unit test. Three + additional real-fixture integration tests in `TrueTypeFontRealFontIntegrationTests.cs` prove + end-to-end composition between `Fonts` and the `Drawing` pipeline on real-world glyph data: + - `TrueTypeFont_RealOpenSansFont_RendersGlyphOutlineAsVisibleInk` loads the real, licensed (SIL + OFL 1.1) "Open Sans" glyf-flavored production font fixture + (`test/DemaConsulting.CanvasNet.Tests/FontFixtures/OpenSans-Regular.ttf`, attributed per the + accompanying `OpenSans.LICENSE`) + - `TrueTypeFont_RealSourceSans3OtfFont_RendersCffGlyphOutlineAsVisibleInk` loads the real, + licensed (SIL OFL 1.1) "Source Sans 3" CFF/OpenType production font fixture + (`test/DemaConsulting.CanvasNet.Tests/FontFixtures/SourceSans3-Regular.otf`, attributed per + the accompanying `SourceSans3.LICENSE`) + - `TrueTypeFont_RealTtcContainer_FaceOne_RendersCffGlyphOutlineAsVisibleInk` and + `TrueTypeFont_RealTtcContainer_FaceZero_RendersGlyfGlyphOutlineAsVisibleInk` load the two + faces of the locally-assembled `OpenSans-SourceSans3.ttc` TrueType Collection fixture (see + `FontFixtures/README.md` for its assembly provenance) + - `TrueTypeFont_RealOpenSansFont_ExposesExpectedNameAndStyleMetadata`, + `TrueTypeFont_RealSourceSans3OtfFont_ExposesExpectedNameAndStyleMetadata`, and + `TrueTypeFont_RealTtcContainer_BothFaces_ExposeExpectedNameAndStyleMetadataIndependently` + assert the exact `GetNameInfo()`/`IsBold`/`IsItalic`/`IsFixedPitch` values against each real + fixture, confirmed directly against these same fixture files with a throwaway `fonttools` + inspection script (see `TrueTypeFontRealFontIntegrationTests.cs`'s class remarks) rather than + guessed + + 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. +`TrueTypeFont` and `SystemFontCatalog` unit verification designs passes, when all seven +real-fixture integration tests in `TrueTypeFontRealFontIntegrationTests.cs` pass, when all three +`CanvasNet_SystemIntegration_*` font scenarios (`LoadFontAndQueryMetrics`, +`LoadFontAndFillGlyphOutline`, and `LoadType1FontAndFillGlyphOutline`) pass without error or +unexpected exception, and +when the `SystemFontCatalog`-specific test files (`SystemFontCatalogTests.cs`, +`SystemFontCatalogRealDiscoveryIntegrationTests.cs`, `BundledLiberationFontsTests.cs`) pass on +every operating system in this repository's CI matrix. ### Test Scenarios @@ -65,3 +97,70 @@ vector geometry rather than opaque internal data. After loading the same synthetic font, queries `GetAdvanceWidth` and `GetKerning` and asserts the returned values match the font's declared metric data. + +#### CanvasNet-Fonts-CffOutlineDecoding: CFF/OpenType Fonts Decode Through the Same Public API + +**Test**: `TrueTypeFont_RealSourceSans3OtfFont_RendersCffGlyphOutlineAsVisibleInk` + +Loads the real "Source Sans 3" CFF/OpenType production font fixture, resolves a flex-operator-free +glyph (capital `H`), decodes its outline through the CFF/Type 2 charstring backend, and fills it +through `PathFiller` onto a `Surface`, confirming actual visible ink is produced from real-world +CFF outline data end to end (not merely that structural parsing succeeded without exception). + +#### CanvasNet-Fonts-Type1OutlineDecoding: Classic PostScript Type 1 Font Programs Decode Through the Same Public API + +**Test**: `CanvasNet_SystemIntegration_LoadType1FontAndFillGlyphOutline_ReturnsExpectedPixels` + +Loads a hand-authored synthetic classic PostScript Type 1 font program through +`TrueTypeFont.LoadType1`, decodes one of its glyphs from Type 1 charstring bytecode, and fills it +through `PathFiller` onto a `Surface`, confirming the subsystem integrates Type 1 outline decoding +with vector rasterization end to end, alongside the glyf and CFF outline flavors. The full +unit-level Type 1/Type1C charstring-interpretation behavior - including caller-supplied +byte-segment loading, standalone `.pfb`/`.pfa` file auto-detection through `Load`, and the +dedicated bare-Type1C/CFF loading entry point - is verified by the `TrueTypeFont` unit's own +verification design (`fonts/true-type-font.md`, requirement IDs +`CanvasNet-Fonts-TrueTypeFont-LoadType1FromSegments`, +`CanvasNet-Fonts-TrueTypeFont-LoadType1Outlines`, and +`CanvasNet-Fonts-TrueTypeFont-LoadType1C`). + +#### CanvasNet-Fonts-TtcSupport: TrueType Collection Faces Are Independently Selectable and Renderable + +**Tests**: `TrueTypeFont_RealTtcContainer_FaceOne_RendersCffGlyphOutlineAsVisibleInk`, +`TrueTypeFont_RealTtcContainer_FaceZero_RendersGlyfGlyphOutlineAsVisibleInk` + +Loads both faces of the locally-assembled `OpenSans-SourceSans3.ttc` fixture via +`TrueTypeFont.Load(string, int)`, confirms `GetFaceCount` reports the container's true face +count, and decodes/renders a glyph from each face, proving both the glyf-flavored face-0 font and +the CFF-flavored face-1 font remain independently usable once selected out of the shared +container. + +#### CanvasNet-Fonts-NameStyleMetadata: Fonts Expose Their Own Name and Derived Style Metadata + +**Tests**: `TrueTypeFont_RealOpenSansFont_ExposesExpectedNameAndStyleMetadata`, +`TrueTypeFont_RealSourceSans3OtfFont_ExposesExpectedNameAndStyleMetadata`, +`TrueTypeFont_RealTtcContainer_BothFaces_ExposeExpectedNameAndStyleMetadataIndependently` + +Loads each real font fixture (the standalone Open Sans `.ttf`, the standalone Source Sans 3 +`.otf`, and both faces of the `.ttc` container independently) and asserts `GetNameInfo()`'s +`FamilyName`/`SubfamilyName`/`FullName`/`PostScriptName` and the `IsBold`/`IsItalic`/ +`IsFixedPitch` properties against the exact real values confirmed against those fixture files via +`fonttools`, proving the subsystem's platform-preference and style-derivation rules resolve +correctly against genuine production font data (not merely synthetic fixtures), and that each +`.ttc` face resolves its own metadata independently of the other. The full unit-level behavior of +`GetNameInfo`/`IsBold`/`IsItalic`/`IsFixedPitch` (platform preference, typographic-name +preference, missing/malformed-record tolerance, OS/2-absent and post-absent fallback) is verified +by the `TrueTypeFont` unit's own verification design (`fonts/true-type-font.md`). + +#### CanvasNet-Fonts-SystemFontDiscovery: OS Font Discovery, Best-Effort Matching, and Bundled Fallback + +**Tests**: `SystemFontCatalog_RealFilesystem_ScanKnownOsDirectories_DiscoversFontsOrIsGracefullyEmpty`, +`SystemFontCatalog_RealFilesystem_KnownPlatformFontFamily_ResolvesToRealFontWhenInstalled`, plus +every test named in the `SystemFontCatalog` unit's own verification design +(`fonts/system-font-catalog.md`) + +Proves, against the real host filesystem, that scanning this operating system's well-known font +directories either discovers at least one real font or gracefully returns an empty catalog (never +throwing), and that a well-known platform font family (for example Arial on Windows, or a common +Linux/macOS default) resolves to a real, loadable font when it happens to be installed. The full +unit-level scoring algorithm, tolerance behavior, laziness/caching, and bundled-fallback +resolution are verified by the `SystemFontCatalog` unit's own verification design. diff --git a/docs/verification/canvas-net/fonts/system-font-catalog.md b/docs/verification/canvas-net/fonts/system-font-catalog.md new file mode 100644 index 00000000..6f019d31 --- /dev/null +++ b/docs/verification/canvas-net/fonts/system-font-catalog.md @@ -0,0 +1,124 @@ +### SystemFontCatalog Unit Verification Design + + + +This document describes the unit-level verification strategy for the `SystemFontCatalog` class. + +#### Verification Approach + +The `SystemFontCatalog` unit is verified through three distinct test files, each covering a +different concern: + +- `SystemFontCatalogTests.cs` exercises the scoring algorithm (`FindBestMatchCore`) against + small, fully-controlled synthetic candidate lists, and the directory-scan tolerance behavior + (`BuildCatalogFromRoots`) against real temporary directories deliberately constructed to be + missing or to contain an unparseable file - both via `InternalsVisibleTo`, so every test is + deterministic regardless of what happens to be installed on the machine running the tests. +- `SystemFontCatalogRealDiscoveryIntegrationTests.cs` runs the real, un-mocked `Fonts` property + and `FindBestMatch` public entry point against the actual host filesystem, proving the + production directory-scan roots and the production `GenericSansFamilies` bucket list actually + work together end to end on a real operating system - written as weak, always-true-or-gracefully- + empty assertions (never skipped) so they remain deterministic across this repository's full + Windows/Linux/macOS CI matrix, per this repository's own testing-principles guidance to + conditionally strengthen an assertion rather than skip a test. +- `BundledLiberationFontsTests.cs` dogfoods every one of the 12 real bundled Liberation `.ttf` + files bundled as embedded resources, loading each directly through + `SystemFontCatalog.LoadBundledFallbackCore` (the same code path `LoadBundledFallback` itself + uses) and asserting each one's `TrueTypeFont.GetNameInfo()`/`IsBold`/`IsItalic`/`IsFixedPitch` + match its expected family/style, proving the bundled files are genuine, valid, parseable + TrueType fonts (not merely present on disk). + +#### 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 (`TrueTypeFont`, + `SyntheticFontBuilder`) or a real temporary directory/file created and cleaned up by the test + itself +- **Isolation**: Each synthetic test builds its own candidate list or its own temporary + directory tree; the real-discovery integration tests read only the real host filesystem and + never write to it + +#### 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 and every operating system in this repository's CI matrix. + +#### Test Scenarios + +##### CanvasNet-Fonts-SystemFontCatalog-DirectoryScanDiscovery: Directory-Scan Discovery Across the Host OS + +**Tests**: `SystemFontCatalog_RealFilesystem_ScanKnownOsDirectories_DiscoversFontsOrIsGracefullyEmpty`, +`SystemFontCatalog_RealFilesystem_KnownPlatformFontFamily_ResolvesToRealFontWhenInstalled` + +Runs `Fonts` and `FindBestMatch` against the real host filesystem's well-known font directories, +asserting either that at least one real font was discovered and every discovered entry has a +non-empty family name and a `.ttf`/`.ttc`/`.otf` file path, or that the catalog is gracefully +empty (never throwing) - and that a well-known platform font family resolves to a real, loadable +font when it happens to be installed, without depending on any particular font actually being +present on the CI runner. + +##### CanvasNet-Fonts-SystemFontCatalog-LazyProcessLifetimeCache: Fonts Is Built Once Per Process and Cached + +**Tests**: `SystemFontCatalog_Fonts_IsLazilyBuiltOnceAndCachedForProcessLifetime` + +Accesses `Fonts` twice in succession and asserts the exact same `IReadOnlyList` +instance (by reference) is returned both times, proving the underlying scan runs at most once +per process. + +##### CanvasNet-Fonts-SystemFontCatalog-ToleratesMissingOrUnparseable: Missing/Unparseable Inputs Are Skipped + +**Tests**: `SystemFontCatalog_Fonts_MissingScanDirectory_SkippedWithoutThrowing`, +`SystemFontCatalog_Fonts_UnparseableCandidateFile_SkippedWithoutThrowing` + +Calls `BuildCatalogFromRoots` with a root pointing at a directory that does not exist, and +separately with a root containing a `.ttf`-named file whose bytes are not a valid font, asserting +in both cases the call returns normally (an empty or otherwise unaffected result) rather than +throwing. + +##### CanvasNet-Fonts-SystemFontCatalog-FindBestMatchExactFamily: Exact Family-Name Matching Is Case-Insensitive and Style-Aware + +**Tests**: `SystemFontCatalog_FindBestMatch_ExactFamilyNameCaseInsensitive_ReturnsMatch`, +`SystemFontCatalog_FindBestMatch_ExactFamilyStyleMismatch_PrefersClosestStyleVariant` + +Builds a small synthetic candidate list containing an exact family-name match under a different +letter case, and separately a family with multiple bold/italic style variants, asserting +`FindBestMatchCore` returns the case-insensitive match and, respectively, the style variant +minimizing mismatched bold/italic flags. + +##### CanvasNet-Fonts-SystemFontCatalog-FindBestMatchGenericBucket: Generic-Bucket Fallback Selects a Well-Known Family + +**Tests**: `SystemFontCatalog_FindBestMatch_NoExactFamily_FallsBackToGenericSansBucket`, +`SystemFontCatalog_FindBestMatch_NoExactFamily_FallsBackToGenericSerifBucket`, +`SystemFontCatalog_FindBestMatch_NoExactFamily_FallsBackToGenericFixedPitchBucket` + +Builds a synthetic candidate list with no exact match for the requested family, but containing +one of each generic bucket's well-known family names, asserting `FindBestMatchCore` returns the +sans/serif/fixed-pitch bucket entry respectively, selecting the fixed-pitch bucket over the serif +bucket when both `serif` and `fixedPitch` are requested together. + +##### CanvasNet-Fonts-SystemFontCatalog-FindBestMatchNoMatch: No Match Returns Null Without Consulting the Bundled Fallback + +**Tests**: `SystemFontCatalog_FindBestMatch_EmptyCandidateList_ReturnsNull`, +`SystemFontCatalog_FindBestMatch_NoFamilyAndNoBucketMatch_ReturnsNull` + +Calls `FindBestMatchCore` with an empty candidate list, and separately with a non-empty list +containing no exact match and no generic-bucket match, asserting `null` is returned in both +cases. + +##### CanvasNet-Fonts-SystemFontCatalog-LoadBundledFallback: Every Bundled Liberation Style Loads Successfully + +**Tests**: `BundledFonts_AllTwelveLiberationVariants_LoadSuccessfullyAndExposeExpectedNameAndStyle` + +A `[Theory]` with 12 `[MemberData]` rows, one per bundled Liberation `.ttf` file, each loading the +file via `LoadBundledFallbackCore` and asserting `TrueTypeFont.GetNameInfo()`'s family name and +the `IsBold`/`IsItalic` properties match the expected values for that file's family/style, +proving every bundled file is a genuine, valid, parseable TrueType font. + +##### CanvasNet-Fonts-SystemFontCatalog-LoadBundledFallbackMissingResourceGuard: Missing Resource Throws + +**Tests**: `SystemFontCatalog_LoadBundledFallback_MissingEmbeddedResource_ThrowsInvalidOperationException` + +Calls `LoadBundledFallbackCore` with a deliberately wrong bundled file name (one guaranteed not to +correspond to any real embedded resource) and asserts `InvalidOperationException` is thrown. diff --git a/docs/verification/canvas-net/fonts/true-type-font.md b/docs/verification/canvas-net/fonts/true-type-font.md index 323bd6c6..0ae2c61b 100644 --- a/docs/verification/canvas-net/fonts/true-type-font.md +++ b/docs/verification/canvas-net/fonts/true-type-font.md @@ -1,26 +1,32 @@ ### 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`. +supporting internal helpers `SfntContainer`, `CmapTable`, `GlyfLocaReader`, `IGlyphOutlineSource`, +`CffTable`, `CffCharstringInterpreter`, `CffStandardEncoding`, `Type1Table`, +`Type1CharstringInterpreter`, `Type1CharstringDecryption`, `Type1PfbReader`, `Type1PfaReader`, +`Type1StandardGlyphNames`, `HmtxHheaReader`, `KernTable`, `NameTable`, and `StyleTable`. #### 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. +font and drive the public API. Every fixture font used by this synthetic-fixture coverage - glyf- +flavored, CFF/OTTO-flavored, and `ttcf`-collection - 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/CFF structures. +Four additional integration tests in `TrueTypeFontRealFontIntegrationTests.cs` load real, +OFL-licensed production font fixtures (`OpenSans-Regular.ttf`, `SourceSans3-Regular.otf`, and the +locally-assembled `OpenSans-SourceSans3.ttc`) to prove the unit genuinely composes with the +`Drawing` pipeline on real-world glyph data - across all three outline-flavor/container +combinations this phase adds - complementing (rather than replacing) the synthetic-fixture +coverage above. #### Test Environment @@ -75,13 +81,15 @@ Calls `Load(string)` with an empty path and asserts `ArgumentException` is throw 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 +##### CanvasNet-Fonts-TrueTypeFont-RejectOttoWithoutCffTable: OTTO Fonts Without a CFF Table 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. +Exercises both the public `Load` path and the container parser directly with an `OTTO`-tagged +font that omits the required `CFF` table, asserting both reject it; an `OTTO`-tagged font that +*does* contain a well-formed `CFF` table is covered separately (see +`CanvasNet-Fonts-TrueTypeFont-LoadCffOutlines` below) and now succeeds. ##### CanvasNet-Fonts-TrueTypeFont-RejectMissingRequiredTable: Missing Required Tables Fail Load @@ -93,7 +101,10 @@ Builds a font that omits required tables and asserts `Load` throws `InvalidDataE **Tests**: `TrueTypeFont_Load_MaxpVersion05_ThrowsInvalidDataException` -Builds a font whose `maxp.version` is `0x00005000` and asserts `Load` rejects it. +Builds a font whose `maxp.version` is `0x00005000` while still declaring itself glyf-flavored +(TrueType-tagged, not `OTTO`) and asserts `Load` rejects it; the same `maxp.version` on a +genuinely `OTTO`-tagged, CFF-backed font is the expected, accepted combination (see +`CanvasNet-Fonts-TrueTypeFont-LoadCffOutlines`). ##### CanvasNet-Fonts-TrueTypeFont-RejectInvalidUnitsPerEm: Zero Units-Per-Em Is Rejected @@ -247,10 +258,13 @@ rejected before any glyph bytes are read. **Tests**: `HmtxHheaReader_Parse_ExposesMetrics`, `HmtxHheaReader_GetAdvanceWidth_TailGlyph_ReusesLastEntry`, +`HmtxHheaReader_Parse_MissingTrailingLsbTail_ParsesSuccessfully`, `TrueTypeFont_Load_EndToEnd_GlyphIndexOutlineAdvanceAndKerning` Verifies direct `hhea` / `hmtx` parsing, the last-entry tail reuse rule, and the same behavior -through the public `TrueTypeFont` API. +through the public `TrueTypeFont` API. Also verifies that an `hmtx` table omitting its trailing +left-side-bearing-only tail entirely (a shape produced by some real-world font subsetting tools) +still parses successfully, since that tail is never read. ##### CanvasNet-Fonts-TrueTypeFont-AdvanceWidthValidation: `GetAdvanceWidth` Rejects Out-of-Range Glyph Indices @@ -285,3 +299,520 @@ lookups all return `0` without throwing. Exercises empty, truncated, malformed, unsupported-format, and cross-stream kerning tables, asserting the parser degrades to "no kerning data" rather than failing load. + +##### CanvasNet-Fonts-TrueTypeFont-LoadCffOutlines: CFF/OpenType Fonts Load Through the Same Public API + +**Tests**: `TrueTypeFont_Load_OttoFontWithCffTable_Succeeds`, +`SfntContainer_Parse_OttoVersionWithCffTable_Succeeds`, +`TrueTypeFont_RealSourceSans3OtfFont_RendersCffGlyphOutlineAsVisibleInk` + +Builds a synthetic `OTTO`-tagged, `CFF`-backed font and asserts both the public `Load` path and +the container parser succeed and expose the expected metrics/outline, then confirms the same +behavior against a real production CFF/OpenType font fixture. + +##### CanvasNet-Fonts-TrueTypeFont-RejectCffGlyphCountMismatch: CFF/`maxp` Glyph Count Disagreement Is Rejected + +**Tests**: `TrueTypeFont_Load_CffGlyphCountMismatchesMaxp_ThrowsInvalidDataException` + +Builds a synthetic CFF font whose CharStrings INDEX count disagrees with `maxp.numGlyphs` and +asserts `Load` rejects it with `InvalidDataException`. + +##### CanvasNet-Fonts-TrueTypeFont-RejectCidKeyedCff: CID-Keyed CFF Data Is Rejected + +**Tests**: `CffTable_Parse_CidKeyedRos_ThrowsInvalidDataException` + +Builds a synthetic CFF Top DICT declaring a `ROS` operator and asserts `CffTable` rejects it with +`InvalidDataException`. + +##### CanvasNet-Fonts-TrueTypeFont-CffTableParsing: CFF Structural Parsing and Per-Glyph Isolation + +**Tests**: `CffTable_Parse_WellFormedFont_ExposesGlyphCountAndOutlines`, +`CffTable_Parse_NoPrivateDict_SucceedsWithNoLocalSubrs`, +`CffTable_GetGlyphOutline_OutOfRangeIndex_ThrowsArgumentOutOfRangeException`, +`CffTable_GetGlyphOutline_OneCorruptGlyph_DoesNotBreakOtherGlyphs`, +`CffTable_Parse_MissingCharStringsOperator_ThrowsInvalidDataException`, +`CffTable_Parse_UnsupportedMajorVersion_ThrowsInvalidDataException`, +`CffTable_Parse_TruncatedTable_ThrowsInvalidDataException`, +`CffTable_Parse_EmptyCharStringsIndex_ThrowsInvalidDataException` + +Covers well-formed parsing (with and without a Private DICT/Local Subr INDEX), out-of-range glyph +index rejection, lazy per-glyph decoding such that one corrupt glyph's charstring does not break +any other glyph in the same font, and rejection of a missing `CharStrings` operator, an +unsupported CFF major version, a truncated table, and an empty CharStrings INDEX. + +##### CanvasNet-Fonts-TrueTypeFont-CffStemHints: Stem Hint Counting and Hint Mask Byte Skipping + +**Tests**: `CffCharstringInterpreter_HStem_VStem_AccumulateStemCountForHintMask`, +`CffCharstringInterpreter_HintMask_ImplicitVStem_CountsTowardMaskBytes`, +`CffCharstringInterpreter_HintMask_TruncatedMaskBytes_ThrowsInvalidDataException` + +Verifies `hstem`/`vstem` accumulate the stem count `hintmask`/`cntrmask` uses to size their mask +byte skip, that operand pairs left on the stack before the first mask operator are counted as an +implicit final `vstem`, and that a truncated mask byte region is rejected. + +##### CanvasNet-Fonts-TrueTypeFont-CffMoveTo: Moveto Operators and the Optional Leading Width Operand + +**Tests**: `CffCharstringInterpreter_RMoveTo_LineTo_Endchar_ProducesClosedContour`, +`CffCharstringInterpreter_RMoveTo_WithLeadingWidth_IgnoresWidthOperand`, +`CffCharstringInterpreter_HMoveTo_VMoveTo_MoveAlongSingleAxis`, +`CffCharstringInterpreter_RMoveTo_WrongOperandCount_ThrowsInvalidDataException` + +Verifies `rmoveto` starts a subpath and closes correctly with a following `lineto`/`endchar`, that +an optional leading width operand on the first stack-clearing operator is recognized and +discarded rather than misread as a coordinate, that `hmoveto`/`vmoveto` move along a single axis, +and that an incompatible operand count is rejected. + +##### CanvasNet-Fonts-TrueTypeFont-CffLineTo: Lineto Operators and Alternating-Axis Packing + +**Tests**: `CffCharstringInterpreter_HLineTo_VLineTo_AlternateAxes`, +`CffCharstringInterpreter_RLineTo_OddOperandCount_ThrowsInvalidDataException` + +Verifies `hlineto`/`vlineto` alternate axis correctly across their operand list and that `rlineto` +rejects an odd operand count. + +##### CanvasNet-Fonts-TrueTypeFont-CffCurveTo: Curve Operators and Their Operand-Packing Conventions + +**Tests**: `CffCharstringInterpreter_RRCurveTo_ProducesCubicBezier`, +`CffCharstringInterpreter_HHCurveTo_VVCurveTo_ProduceCubicBeziers`, +`CffCharstringInterpreter_HHCurveTo_LeadingOperand_AppliesToFirstCurveOnly`, +`CffCharstringInterpreter_HVCurveTo_VHCurveTo_AlternateStartTangent`, +`CffCharstringInterpreter_HVCurveTo_TrailingOperand_SuppliesFinalAxisDelta` + +Verifies `rrcurveto` produces a general cubic Bezier, `hhcurveto`/`vvcurveto` produce +axis-constrained-start cubic Beziers (including a leading cross-axis operand applying only to the +first curve), and `hvcurveto`/`vhcurveto` alternate start tangent per curve including a trailing +operand supplying the final curve's otherwise-implied-zero axis delta. + +##### CanvasNet-Fonts-TrueTypeFont-CffRCurveLineRLineCurve: Combined Curve-and-Line Operators + +**Tests**: `CffCharstringInterpreter_RCurveLine_OneCurveThenLine_ProducesCurveAndLine`, +`CffCharstringInterpreter_RCurveLine_TwoCurvesThenLine_ProducesCurvesAndLine`, +`CffCharstringInterpreter_RCurveLine_WrongOperandCount_OneShort_ThrowsInvalidDataException`, +`CffCharstringInterpreter_RCurveLine_WrongOperandCount_OneOver_ThrowsInvalidDataException`, +`CffCharstringInterpreter_RLineCurve_OneLineThenCurve_ProducesLineAndCurve`, +`CffCharstringInterpreter_RLineCurve_TwoLinesThenCurve_ProducesLinesAndCurve`, +`CffCharstringInterpreter_RLineCurve_WrongOperandCount_OneShort_ThrowsInvalidDataException`, +`CffCharstringInterpreter_RLineCurve_WrongOperandCount_OneOver_ThrowsInvalidDataException` + +Verifies `rcurveline` appends one or more `rrcurveto`-style curves followed by exactly one +trailing line, `rlinecurve` appends one or more relative lines followed by exactly one trailing +`rrcurveto`-style curve (both with one- and two-segment variants), and that each operator rejects +an operand count that is not exactly `6n + 2` (`rcurveline`) or `2n + 6` (`rlinecurve`) for some +`n >= 1`, both one short of and one over the nearest valid count. + +##### CanvasNet-Fonts-TrueTypeFont-CffFlex: Flex Shortcut Operators and `dotsection` + +**Tests**: `CffCharstringInterpreter_HFlex_ProducesTwoCubicBeziersWithHandComputedCoordinates`, +`CffCharstringInterpreter_HFlex_WrongOperandCount_OneShort_ThrowsInvalidDataException`, +`CffCharstringInterpreter_HFlex_WrongOperandCount_OneOver_ThrowsInvalidDataException`, +`CffCharstringInterpreter_Flex_ProducesTwoCubicBeziersWithHandComputedCoordinates`, +`CffCharstringInterpreter_Flex_WrongOperandCount_OneShort_ThrowsInvalidDataException`, +`CffCharstringInterpreter_Flex_WrongOperandCount_OneOver_ThrowsInvalidDataException`, +`CffCharstringInterpreter_HFlex1_ProducesTwoCubicBeziersWithHandComputedCoordinates`, +`CffCharstringInterpreter_HFlex1_WrongOperandCount_OneShort_ThrowsInvalidDataException`, +`CffCharstringInterpreter_HFlex1_WrongOperandCount_OneOver_ThrowsInvalidDataException`, +`CffCharstringInterpreter_Flex1_DxDominant_ProducesHandComputedCoordinates`, +`CffCharstringInterpreter_Flex1_DyDominant_ProducesHandComputedCoordinates`, +`CffCharstringInterpreter_Flex1_WrongOperandCount_OneShort_ThrowsInvalidDataException`, +`CffCharstringInterpreter_Flex1_WrongOperandCount_OneOver_ThrowsInvalidDataException`, +`CffCharstringInterpreter_DotSection_IsNoOp_MatchesSameCharstringWithoutDotSection` + +Verifies the two-byte flex escape operators `hflex`/`flex`/`hflex1`/`flex1` each produce exactly +two cubic Bezier segments at hand-computed coordinates - including `hflex1`/`flex1`'s "force the +final point back onto the starting axis" semantics rather than naive running-delta accumulation - +and that each rejects an operand count other than its own exact required count (one short of and +one over); and that the deprecated `dotsection` escape operator (`12 0`) is a pure no-op, verified +by asserting it produces a path identical to the same charstring with its `dotsection` bytes +omitted. + +##### CanvasNet-Fonts-TrueTypeFont-CffSubroutines: Subroutine Calls, Bias, Recursion, and Depth Bounding + +**Tests**: `CffCharstringInterpreter_CallSubr_AppliesBias_AndReturns`, +`CffCharstringInterpreter_CallGSubr_AppliesGlobalBias`, +`CffCharstringInterpreter_CallSubr_OutOfRangeIndex_ThrowsInvalidDataException`, +`CffCharstringInterpreter_CallSubr_ExceedsMaxDepth_ThrowsInvalidDataException`, +`CffTable_Parse_WithLocalSubrs_DecodesGlyphUsingCallSubr`, +`CffTable_Parse_WithGlobalSubrs_DecodesGlyphUsingCallGSubr` + +Verifies `callsubr`/`callgsubr` apply the correct bias and resume via `return`, that an +out-of-range (post-bias) subroutine index is rejected, that a self-recursive subroutine chain is +rejected once it exceeds the maximum call depth, and that `CffTable` correctly wires both local +and global subroutine INDEXes through to the interpreter end to end. + +##### CanvasNet-Fonts-TrueTypeFont-CffEndChar: `endchar` and Unexpected Operand Counts + +**Tests**: `CffCharstringInterpreter_EmptyCharstring_ProducesEmptyPath`, +`CffCharstringInterpreter_EndChar_UnexpectedOperandCount_ThrowsInvalidDataException` + +Verifies a charstring consisting only of `endchar` decodes as an empty path, and that an +unexpected leftover operand count (other than zero or the legacy seac-style 4-operand form, +verified separately below) is rejected with `InvalidDataException`. + +##### CanvasNet-Fonts-TrueTypeFont-CffSeacComposition: Seac-Style Accent Composition + +**Tests**: `CffCharstringInterpreter_EndChar_SeacStyleFourOperands_NoResolverSupplied_ThrowsInvalidDataException`, +`CffCharstringInterpreter_EndChar_SeacStyleFourOperands_WithResolver_ComposesBaseAndTranslatedAccent`, +`CffCharstringInterpreter_EndChar_SeacStyleFourOperands_ResolverOmittedOnComponentDecode_ThrowsInvalidDataException`, +`CffTable_GetGlyphOutline_SeacStyleEndChar_ComposesBaseAndTranslatedAccent`, +`CffTable_GetAdvanceWidth_SeacStyleEndChar_UsesOwnWidthNotComponentWidths`, +`CffTable_GetGlyphOutline_SeacStyleEndChar_UndefinedStandardEncodingCode_ThrowsInvalidDataException`, +`CffTable_GetGlyphOutline_SeacStyleEndChar_GlyphNameNotInCharset_ThrowsInvalidDataException`, +`CffTable_GetGlyphOutline_SeacStyleEndChar_ComponentItselfSeacStyle_ThrowsInvalidDataException`, +`CffStandardEncoding_CodeToGlyphName_HasExpectedLength`, +`CffStandardEncoding_CodeToGlyphName_SpotChecksKnownCodes`, +`CffStandardEncoding_CodeToGlyphName_UndefinedControlCodesAreNull` + +Verifies the legacy 4-operand seac-style `endchar` form is rejected with `InvalidDataException` +when no resolver callback is supplied (including when a seac component glyph is itself defined +using the seac-style form - nested seac composition is not supported); that, with a resolver +supplied, the base and (translated) accent outlines are correctly composed; that `CffTable` wires +its own `CffStandardEncoding`-backed resolver through to the interpreter end to end, correctly +resolving the composite's outline and advance width (the latter independent of the base/accent +glyphs' own widths); that an undefined StandardEncoding code and a resolved glyph name absent from +the font's charset are both rejected with `InvalidDataException`; and that `CffStandardEncoding`'s +code-to-glyph-name table has the expected 256-entry length, correctly maps a handful of known +codes, and leaves undefined control codes as `null`. + +##### CanvasNet-Fonts-TrueTypeFont-CffUnsupportedOperatorRejection: Unsupported Operators, Escapes, and Truncation + +**Tests**: `CffCharstringInterpreter_UnsupportedOperator_ThrowsInvalidDataException`, +`CffCharstringInterpreter_UnsupportedEscapeOperator_ThrowsInvalidDataException`, +`CffCharstringInterpreter_FixedPointOperand_DecodesCorrectly`, +`CffCharstringInterpreter_TruncatedCharstring_ThrowsInvalidDataException` + +Verifies an operator outside the supported set and a genuinely unsupported two-byte escape +operator are both rejected, that a 16.16 fixed-point operand decodes to the correct value, and +that a charstring +truncated before its declared operand/operator data is fully read is rejected. + +##### CanvasNet-Fonts-TrueTypeFont-GetFaceCountPlainSfnt: `GetFaceCount` Reports 1 for an Ordinary SFNT Font + +**Tests**: `TrueTypeFont_GetFaceCount_PlainSfnt_ReturnsOne` + +Calls `GetFaceCount` against a synthetic ordinary (non-`ttcf`) font and asserts it returns `1`. + +##### CanvasNet-Fonts-TrueTypeFont-GetFaceCountTtc: `GetFaceCount` Reports a `ttcf` Container's True Face Count + +**Tests**: `TrueTypeFont_GetFaceCount_TtcContainer_ReturnsFaceCount`, +`TrueTypeFont_GetFaceCount_Path_ReadsFromFile`, +`SfntContainer_TryReadTtcHeader_WellFormedContainer_ReturnsFaceOffsets`, +`SfntContainer_TryReadTtcHeader_EachFaceIndependentlyParsable` + +Verifies `GetFaceCount` against both a stream and a file path returns the container's own +declared face count, and that `TryReadTtcHeader` correctly resolves each face's own offset such +that each face can be parsed independently. + +##### CanvasNet-Fonts-TrueTypeFont-LoadTtcDefaultsFaceZero: `Load` Defaults to Face 0 for a `ttcf` File + +**Tests**: `TrueTypeFont_Load_TtcContainer_DefaultsToFaceZero`, +`SfntContainer_TryReadTtcHeader_NonTtcFont_ReturnsFalse` + +Verifies `Load(Stream)`/`Load(string)` against a synthetic 2-face `ttcf` container transparently +resolves and loads face 0, and that `TryReadTtcHeader` returns `false` (not an exception) for a +file that is not `ttcf`-tagged. + +##### CanvasNet-Fonts-TrueTypeFont-LoadTtcExplicitFaceIndex: Explicit Face Selection via `Load(..., int)` + +**Tests**: `TrueTypeFont_Load_TtcContainer_ExplicitFaceIndex_SelectsThatFace`, +`TrueTypeFont_Load_TtcContainer_Path_FaceIndex_ReadsFromFile`, +`TrueTypeFont_RealTtcContainer_FaceOne_RendersCffGlyphOutlineAsVisibleInk`, +`TrueTypeFont_RealTtcContainer_FaceZero_RendersGlyfGlyphOutlineAsVisibleInk` + +Verifies both new overloads (stream and path) correctly select and load a non-default face of a +synthetic `ttcf` container, then confirms the same explicit-selection behavior against the real, +locally-assembled two-face `.ttc` fixture for both its glyf-flavored and CFF-flavored faces. + +##### CanvasNet-Fonts-TrueTypeFont-RejectFaceIndexOutOfRange: Out-of-Range Face Indices Are Rejected + +**Tests**: `TrueTypeFont_Load_TtcContainer_FaceIndexOutOfRange_ThrowsArgumentOutOfRangeException`, +`TrueTypeFont_Load_NonTtcFont_FaceIndexOne_ThrowsArgumentOutOfRangeException`, +`TrueTypeFont_Load_NegativeFaceIndex_ThrowsArgumentOutOfRangeException` + +Verifies a face index at or beyond a `ttcf` container's declared face count, a non-zero face +index against an ordinary (non-`ttcf`) font, and a negative face index are all rejected with +`ArgumentOutOfRangeException`. + +##### CanvasNet-Fonts-TrueTypeFont-FaceIndexZeroBehavesAsLoad: `faceIndex = 0` Matches Plain `Load` for Non-Collection Fonts + +**Tests**: `TrueTypeFont_Load_NonTtcFont_FaceIndexZero_BehavesIdenticallyToLoad` + +Loads the same ordinary (non-`ttcf`) synthetic font through both `Load(Stream)` and +`Load(Stream, 0)` and asserts the two results expose identical metrics and outlines. + +##### CanvasNet-Fonts-TrueTypeFont-RejectMalformedTtcHeader: Malformed `ttcf` Headers Are Rejected + +**Tests**: `TrueTypeFont_Load_MalformedTtcHeader_ThrowsInvalidDataException`, +`SfntContainer_TryReadTtcHeader_TruncatedHeader_ThrowsInvalidDataException`, +`SfntContainer_TryReadTtcHeader_ZeroFonts_ThrowsInvalidDataException`, +`SfntContainer_TryReadTtcHeader_TruncatedFaceOffsetTable_ThrowsInvalidDataException` + +Verifies a truncated `ttcf` header, a header declaring zero fonts, and a truncated per-face +offset table are all rejected with `InvalidDataException`, both through the public `Load` path +and directly against `TryReadTtcHeader`. + +##### CanvasNet-Fonts-TrueTypeFont-GetNameInfoResolvesTypographicNames: Typographic Names (16/17) Are Preferred When Present + +**Tests**: `TrueTypeFont_GetNameInfo_TypographicNamesPresent_PrefersNameId16And17` + +Builds a synthetic font whose `name` table carries both the standard (`1`/`2`) and typographic +(`16`/`17`) family/subfamily records with distinct values, and asserts `GetNameInfo()` resolves +`FamilyName`/`SubfamilyName` from the typographic records. + +##### CanvasNet-Fonts-TrueTypeFont-GetNameInfoFallsBackToStandardNames: Standard Names Resolve Without Typographic Records + +**Tests**: `TrueTypeFont_GetNameInfo_TypographicNamesAbsent_FallsBackToNameId1And2` + +Builds a synthetic font whose `name` table carries only the standard (`1`/`2`) family/subfamily +records, and asserts `GetNameInfo()` still resolves `FamilyName`/`SubfamilyName` from them. + +##### CanvasNet-Fonts-TrueTypeFont-GetNameInfoPlatformPreference: Windows-Platform Records Are Preferred Over Macintosh + +**Tests**: `TrueTypeFont_GetNameInfo_WindowsAndMacintoshRecordsPresent_PrefersWindowsRecord` + +Builds a synthetic font whose `name` table carries both a Windows-platform and a +Macintosh-platform record for the same nameID with distinct values, and asserts `GetNameInfo()` +resolves from the Windows-platform record. + +##### CanvasNet-Fonts-TrueTypeFont-GetNameInfoMacintoshFallback: Macintosh Records Resolve Without a Windows Record + +**Tests**: `TrueTypeFont_GetNameInfo_OnlyMacintoshRecordPresent_ResolvesFromMacintoshRecord` + +Builds a synthetic font whose `name` table carries only a Macintosh-platform (Mac Roman) record, +and asserts `GetNameInfo()` correctly decodes and resolves its string. + +##### CanvasNet-Fonts-TrueTypeFont-GetNameInfoMissingRecordTolerant: A Single Missing Record Does Not Affect the Others + +**Tests**: `TrueTypeFont_GetNameInfo_PostScriptNameRecordMissing_ReturnsNullPostScriptName` + +Builds a synthetic font whose `name` table omits the PostScript name (nameID `6`) record, and +asserts `GetNameInfo().PostScriptName` is `null` while `FamilyName`/`SubfamilyName`/`FullName` +still resolve normally. + +##### CanvasNet-Fonts-TrueTypeFont-GetNameInfoNoNameTableTolerant: A Completely Absent `name` Table Yields an All-Null Result + +**Tests**: `TrueTypeFont_GetNameInfo_NoNameTable_ReturnsAllNullFontNameInfo` + +Builds a synthetic font with no `name` table at all, and asserts `GetNameInfo()` returns a +`FontNameInfo` with every member `null` rather than throwing. + +##### CanvasNet-Fonts-TrueTypeFont-GetNameInfoMalformedRecordTolerant: A Malformed Record Is Ignored, Not the Table + +**Tests**: `TrueTypeFont_GetNameInfo_MalformedNameRecord_IgnoresRecordWithoutThrowing` + +Builds a synthetic font whose `name` table contains one record whose declared string +offset/length falls outside the table's own bounds alongside other well-formed records, and +asserts `GetNameInfo()` ignores only the malformed record (without throwing) while the other +records still resolve. + +##### CanvasNet-Fonts-TrueTypeFont-StyleFromOs2FsSelection: `OS/2.fsSelection` Bold/Italic Bits Drive `IsBold`/`IsItalic` + +**Tests**: `TrueTypeFont_IsBold_Os2FsSelectionBoldBitSet_ReturnsTrue`, +`TrueTypeFont_IsItalic_Os2FsSelectionItalicBitSet_ReturnsTrue` + +Builds synthetic fonts with `OS/2.fsSelection`'s bold bit (bit 5) and italic bit (bit 0) each set +independently, and asserts `IsBold`/`IsItalic` reflect them. + +##### CanvasNet-Fonts-TrueTypeFont-StyleFromOs2WeightClass: `OS/2.usWeightClass >= 600` Also Implies `IsBold` + +**Tests**: `TrueTypeFont_IsBold_Os2WeightClassAtLeast600_ReturnsTrue` + +Builds a synthetic font with `OS/2.usWeightClass` set to a Semibold-or-heavier value and +`fsSelection`'s bold bit clear, and asserts `IsBold` is still `true`. + +##### CanvasNet-Fonts-TrueTypeFont-StyleFromPostItalicAngle: A Non-Zero `post.italicAngle` Also Implies `IsItalic` + +**Tests**: `TrueTypeFont_IsItalic_PostItalicAngleNonZero_ReturnsTrue` + +Builds a synthetic font with a non-zero `post.italicAngle` and no other italic indicator set, and +asserts `IsItalic` is still `true`. + +##### CanvasNet-Fonts-TrueTypeFont-StyleFallsBackToMacStyleWhenOs2Absent: `head.macStyle` Drives Style When `OS/2` Is Absent + +**Tests**: `TrueTypeFont_Style_Os2TableAbsent_FallsBackToHeadMacStyle` + +Builds a synthetic font with no `OS/2` table and `head.macStyle`'s bold/italic bits set, and +asserts `IsBold`/`IsItalic` are both `true`, proving the mandatory `head` table alone is +sufficient when the optional `OS/2` table is missing. + +##### CanvasNet-Fonts-TrueTypeFont-IsFixedPitchFromPostTable: `post.isFixedPitch` Drives `IsFixedPitch` + +**Tests**: `TrueTypeFont_IsFixedPitch_PostIsFixedPitchNonZero_ReturnsTrue` + +Builds a synthetic font with `post.isFixedPitch` set non-zero, and asserts `IsFixedPitch` is +`true`. + +##### CanvasNet-Fonts-TrueTypeFont-IsFixedPitchAbsentPostTableTolerant: Defaults to `false` Without a `post` Table + +**Tests**: `TrueTypeFont_IsFixedPitch_PostTableAbsent_ReturnsFalseWithoutThrowing` + +Builds a synthetic font with no `post` table at all, and asserts `IsFixedPitch` is `false` +without throwing. + +##### CanvasNet-Fonts-TrueTypeFont-LoadType1FromSegments: Type 1 Font Programs Load From Caller-Supplied Byte Segments + +**Tests**: `TrueTypeFont_LoadType1_WellFormedProgram_ExposesGlyphsAndMetrics`, +`TrueTypeFont_LoadType1_NegativeLength1_ThrowsInvalidDataException`, +`TrueTypeFont_LoadType1_Length2ExceedsStreamBounds_ThrowsInvalidDataException`, +`TrueTypeFont_LoadType1_NullStream_ThrowsArgumentNullException`, +`TrueTypeFont_LoadType1_NullEncoding_ThrowsArgumentNullException`, +`Type1Table_Parse_WellFormedFont_ExposesGlyphCountAndOutlines`, +`Type1Table_Parse_NotdefNotFirst_ReindexesToGlyphZero`, +`Type1Table_GetAdvanceWidth_ReturnsHsbwWidth`, +`Type1Table_TryGetGlyphIndex_UnknownName_ReturnsFalse`, +`Type1Table_GetGlyphOutline_OutOfRangeIndex_ThrowsArgumentOutOfRangeException`, +`Type1Table_Parse_WithLocalSubrs_DecodesGlyphUsingCallSubr`, +`Type1Table_Parse_VariousProcNameTokens_ScannerIsProcedureNameAgnostic`, +`Type1Table_Parse_CustomLenIv_DecodesCorrectly`, +`Type1Table_Parse_SeacOperator_ThrowsInvalidDataException`, +`Type1Table_Parse_MissingCharStrings_ThrowsInvalidDataException`, +`Type1Table_Parse_NegativeLength_ThrowsInvalidDataException`, +`Type1Table_Parse_LengthsExceedFileBounds_ThrowsInvalidDataException`, +`Type1Table_Parse_EmptyCharStrings_ThrowsInvalidDataException`, +`Type1Table_Parse_CharStringsLengthNearIntMaxValue_ThrowsInvalidDataException`, +`Type1Table_Parse_TrailingBoilerplateAfterCharStringsEnd_DoesNotMisparse`, +`Type1CharstringInterpreter_EmptyCharstring_ProducesEmptyPathAndZeroWidth`, +`Type1CharstringInterpreter_Hsbw_CapturesWidthAndSideBearing`, +`Type1CharstringInterpreter_Sbw_CapturesWidthAndBothSideBearings`, +`Type1CharstringInterpreter_RLineTo_HLineTo_VLineTo_ProduceLines`, +`Type1CharstringInterpreter_RRCurveTo_ProducesCubicBezier`, +`Type1CharstringInterpreter_VhCurveTo_HvCurveTo_AlternateStartTangent`, +`Type1CharstringInterpreter_ClosePath_ClosesSubpath`, +`Type1CharstringInterpreter_HStem_VStem_Hstem3_Vstem3_Dotsection_ConsumedWithoutError`, +`Type1CharstringInterpreter_CallSubr_Return_NoBias_DirectIndex`, +`Type1CharstringInterpreter_CallSubr_OutOfRangeIndex_ThrowsInvalidDataException`, +`Type1CharstringInterpreter_CallSubr_ExceedsMaxDepth_ThrowsInvalidDataException`, +`Type1CharstringInterpreter_Div_ResultFeedsSubsequentOperator`, +`Type1CharstringInterpreter_Flex_EndToEnd_ProducesTwoRealCubicBeziers`, +`Type1CharstringInterpreter_HintReplacement_OtherSubr3_IsTransparentPassThrough`, +`Type1CharstringInterpreter_Seac_ThrowsInvalidDataException`, +`Type1CharstringInterpreter_UnsupportedOperator_ThrowsInvalidDataException`, +`Type1CharstringInterpreter_UnsupportedEscapeOperator_ThrowsInvalidDataException`, +`Type1CharstringInterpreter_CallOtherSubr_UnsupportedIndex_ThrowsInvalidDataException`, +`Type1CharstringInterpreter_RLineTo_WrongOperandCount_ThrowsInvalidDataException`, +`Type1CharstringInterpreter_FiveByteInteger_DecodesAsPlainInt32`, +`Type1CharstringInterpreter_TruncatedCharstring_ThrowsInvalidDataException` + +Exercises `TrueTypeFont.LoadType1` end-to-end against a hand-authored synthetic Type 1 font +program (via `SyntheticFontBuilder.Type1`), asserting `GetGlyphIndex`/`GetGlyphOutline`/ +`GetAdvanceWidth` behave equivalently to the glyf/CFF-flavored paths, and that malformed +`length1`/`length2` values and null arguments are rejected as documented. Separately verifies +`Type1Table`'s own `/Subrs`/`/CharStrings` scanner - including its procedure-name-agnostic +behavior across varied `RD`/`ND`/`NP`/`-|`/`|-` token fixtures, `.notdef`-reindexing, +custom-`lenIV` handling, and its fail-closed structural-malformation paths - including a +`/CharStrings` entry declaring a length near `int.MaxValue`, which must be reported as +`InvalidDataException` rather than an unhandled arithmetic overflow - and +`Type1CharstringInterpreter`'s full opcode set in isolation, including a geometry-asserting +end-to-end flex test that checks actual `Path` point data (not merely the absence of an +exception), a hint-replacement pass-through test, `seac` rejection, and subroutine call-depth +bounding. Also verifies that the `/CharStrings` scanner correctly stops at that dictionary's own +matching closing `end` keyword rather than misparsing trailing font-closing PostScript +boilerplate (`readonly put`/`noaccess put`/`definefont`/`currentfile closefile`) that some +real-world Type 1 producers emit immediately afterward as further glyph entries. + +##### CanvasNet-Fonts-TrueTypeFont-LoadType1Outlines: Standalone `.pfb`/`.pfa` Type 1 Files Auto-Detect Through `Load` + +**Tests**: `TrueTypeFont_Load_StandalonePfbFile_AutoDetectsAndRoundTrips`, +`TrueTypeFont_Load_StandalonePfaFile_AutoDetectsAndRoundTrips`, +`TrueTypeFont_Load_StandalonePfbFile_Path_ReadsFromFile`, +`TrueTypeFont_GetFaceCount_StandaloneType1File_ReturnsOne`, +`TrueTypeFont_Load_StandaloneType1File_FaceIndexOne_ThrowsArgumentOutOfRangeException`, +`TrueTypeFont_Load_NonType1NonSfntGarbage_ThrowsInvalidDataException`, +`Type1PfbReader_TrySniff_RecognizesPfbHeader`, +`Type1PfbReader_TrySniff_NonPfbData_ReturnsFalse`, +`Type1PfbReader_Read_TwoSegmentFile_ReassemblesLength1AndLength2`, +`Type1PfbReader_Read_MultiSegmentFile_ConcatenatesLikeTypedSegments`, +`Type1PfbReader_Read_TrailingAsciiSegment_DiscardsItAsTrailer`, +`Type1PfbReader_Read_NoBinarySegment_ThrowsInvalidDataException`, +`Type1PfbReader_Read_MissingEndOfFileMarker_ThrowsInvalidDataException`, +`Type1PfbReader_Read_UnrecognizedSegmentType_ThrowsInvalidDataException`, +`Type1PfbReader_Read_TruncatedHeader_ThrowsInvalidDataException`, +`Type1PfbReader_Read_TruncatedPayload_ThrowsInvalidDataException`, +`Type1PfbReader_Read_MissingSegmentMarkerByte_ThrowsInvalidDataException`, +`Type1PfaReader_TrySniff_RecognizesPercentBangPrefix`, +`Type1PfaReader_TrySniff_NonPfaData_ReturnsFalse`, +`Type1PfaReader_Read_WellFormedFile_ReassemblesLength1AndLength2`, +`Type1PfaReader_Read_MultiLineWhitespaceWrappedHex_ToleratesEmbeddedWhitespace`, +`Type1PfaReader_Read_TrailingZeroPadding_IsTrimmed`, +`Type1PfaReader_Read_MissingEexecKeyword_ThrowsInvalidDataException`, +`Type1PfaReader_Read_OddHexDigitCount_ThrowsInvalidDataException`, +`Type1PfaReader_Read_NonHexByteBeforeAnyHexDigit_ThrowsInvalidDataException`, +`Type1PfaReader_Read_ZeroBytesAfterTrim_ThrowsInvalidDataException`, +`Type1PfaReader_Read_LowercaseAndUppercaseHexDigits_AreBothAccepted` + +Builds standalone `.pfb` and `.pfa` re-serializations of the same synthetic Type 1 font program +(via `SyntheticFontBuilder.Type1Pfb`/`Type1Pfa`) and asserts `Load(Stream)`/`Load(string)` +auto-detect and round-trip them using the built-in `Type1StandardGlyphNames` default encoding, +that `GetFaceCount` reports `1` and `Load(Stream, 1)` is rejected with +`ArgumentOutOfRangeException`, and that data recognized as neither SFNT/OpenType nor `.pfb`/`.pfa` +still throws `InvalidDataException`. Separately verifies `Type1PfbReader`'s generic +type-tagged-segment loop (two-segment, multi-segment, and trailing-ASCII-trailer cases) and +`Type1PfaReader`'s whitespace-tolerant hex decoding (well-formed, multi-line/whitespace-wrapped, +and trailing-zero-padding cases), and both readers' fail-closed behavior on truncated headers/ +payloads, unrecognized segment types, a missing binary segment or end-of-file marker, a missing +`eexec` keyword, an odd hex-digit count, a non-hex byte before any hex digit, and zero decoded +bytes after trim. + +##### CanvasNet-Fonts-TrueTypeFont-CffCharsetResolution: CFF Charset Resolves Glyph Names to Glyph Indices + +**Tests**: `CffTable_Parse_AbsentCharset_DefaultsToIsoAdobeAndResolvesByName`, +`CffTable_Parse_PredefinedIsoAdobeCharsetId_MatchesAbsentCharset`, +`CffTable_Parse_PredefinedExpertOrExpertSubsetCharset_NeverResolvesByNameButNeverThrows`, +`CffTable_Parse_CustomCharsetFormat0_ResolvesEachGlyphBySid`, +`CffTable_Parse_CustomCharsetFormat1_ResolvesRangesOfGlyphs`, +`CffTable_Parse_CustomCharsetFormat2_ResolvesRangesWithTwoByteNLeft`, +`CffTable_Parse_CustomCharsetUnsupportedFormat_ThrowsInvalidDataException`, +`CffTable_Parse_CharsetOffsetOutOfBounds_ThrowsInvalidDataException`, +`CffTable_Parse_CustomStringIndex_ResolvesCustomGlyphNameBySid`, +`CffTable_StandardStrings_HasExpectedLengthAndSpotCheckedEntries` + +Verifies that an absent `charset` operator and an explicit predefined-ISOAdobe (`0`) value both +resolve every glyph's SID to its own glyph index and that `TryGetGlyphIndex` then resolves a +Standard-Strings glyph name (for example `"space"`) back to the correct glyph index; that the +predefined Expert (`1`) and ExpertSubset (`2`) charset IDs are recognized but deliberately never +resolve any glyph name - `TryGetGlyphIndex` returns `false`, never throws - a documented scope +boundary covered explicitly rather than left implicit; that a custom charset table (a byte offset +greater than `2`) correctly decodes format 0 (flat 2-byte SID array), format 1 (2-byte-first-SID/ +1-byte-count ranges), and format 2 (2-byte-first-SID/2-byte-count ranges) into the expected +per-glyph SID assignments; that an unrecognized format byte and an out-of-bounds charset offset +are both rejected with `InvalidDataException`; that a glyph name resolved via the font's own +String INDEX (SID 391 and above, not only the built-in Standard Strings) round-trips correctly; +and that the transcribed 391-entry CFF Standard Strings table itself has the documented length and +spot-checked entries (including SID `0` = `.notdef`, SID `1` = `space`, and SID `34` = `A`) match +Adobe Technical Note #5176 Appendix A. + +##### CanvasNet-Fonts-TrueTypeFont-CffWidthResolution: CFF Glyph Advance Widths Resolve Through the Type 2 Width Convention + +**Tests**: `CffCharstringInterpreter_Decode_RMoveToWithLeadingWidth_ReturnsNominalWidthXPlusDelta`, +`CffCharstringInterpreter_Decode_NoLeadingWidth_ReturnsDefaultWidthX`, +`CffCharstringInterpreter_Decode_HStemWithLeadingWidth_ReturnsNominalWidthXPlusDelta`, +`CffCharstringInterpreter_Decode_EndCharWithLeadingWidth_ReturnsNominalWidthXPlusDelta`, +`CffCharstringInterpreter_Decode_DefaultWidthParameters_AreBothZero`, +`CffTable_GetAdvanceWidth_NoWidthOperand_UsesDefaultWidthX`, +`CffTable_GetAdvanceWidth_WithWidthOperand_UsesNominalWidthXPlusDelta`, +`CffTable_GetAdvanceWidth_OutOfRangeIndex_ThrowsArgumentOutOfRangeException` + +Exercises `CffCharstringInterpreter.Decode`'s width-returning overload directly across every +stack-clearing operator that can carry the optional leading width operand (`rmoveto`, `hstem`, +`endchar`), asserting the returned width is `nominalWidthX` plus the leading operand's value when +present, and `defaultWidthX` when absent, including the case where both Private DICT width +operators are left at their own zero defaults. Separately verifies `CffTable.GetAdvanceWidth` +correctly surfaces that same per-glyph resolved width end to end for both the with-operand and +without-operand cases, and rejects an out-of-range glyph index with +`ArgumentOutOfRangeException`. + +##### CanvasNet-Fonts-TrueTypeFont-LoadType1C: Bare Type1C/CFF Font Programs Load Through a Dedicated Entry Point + +**Tests**: `TrueTypeFont_LoadType1C_WellFormedCff_ExposesGlyphsAndMetrics`, +`TrueTypeFont_LoadType1C_NullStream_ThrowsArgumentNullException`, +`TrueTypeFont_LoadType1C_NullEncoding_ThrowsArgumentNullException`, +`TrueTypeFont_LoadType1C_MalformedCff_ThrowsInvalidDataException`, +`TrueTypeFont_LoadType1C_CidKeyedCff_ThrowsInvalidDataException` + +Exercises `TrueTypeFont.LoadType1C` end-to-end against a hand-authored synthetic bare-CFF program +(via `SyntheticFontBuilder.Cff`, with a custom charset mapping glyph names to glyph indices and a +caller-supplied codepoint-to-glyph-name encoding), asserting `GlyphCount`/`GetGlyphIndex`/ +`GetGlyphOutline`/`GetAdvanceWidth` behave equivalently to every other supported outline flavor +under a fixed 1000-unit em square; and asserts null `stream`/`codepointToGlyphName` arguments, a +structurally malformed CFF table, and a CID-keyed (`ROS`-declaring) CFF program are all rejected +as documented (the last two both surfacing as `InvalidDataException`, the CID-keyed case via +`CffTable.Parse`'s own existing `ROS` rejection, not a second, separately-implemented check). diff --git a/docs/verification/canvas-net/geometry.md b/docs/verification/canvas-net/geometry.md index b6b65962..b94ad526 100644 --- a/docs/verification/canvas-net/geometry.md +++ b/docs/verification/canvas-net/geometry.md @@ -28,3 +28,14 @@ units collaborate correctly (in particular, that `Path.GetBounds` correctly invo The `Geometry` subsystem's verification passes when every unit test scenario described in each unit's own verification design document passes without error or unexpected exception, and the system-level integration test described above passes. + +### Test Scenarios + +The `Geometry` subsystem's test scenarios are those named in each of its four constituent unit +verification documents (_Rect_, _Path_, _BezierFlattening_, and _SvgArcConverter Unit +Verification Design_ under `geometry/`), covering normal-path construction/transformation, +boundary conditions (empty/degenerate geometry, zero/negative tolerance or radius), and +argument-validation error paths, plus the one system-level integration scenario named above: +building a `Path` via `PathBuilder` with a `MoveTo`/`CubicBezierTo`/`ArcTo`/`Close` sequence, +flattening the cubic segment directly via `BezierFlattening`, and computing the path's bounds +via `Path.GetBounds`. diff --git a/docs/verification/canvas-net/geometry/bezier-flattening.md b/docs/verification/canvas-net/geometry/bezier-flattening.md index 3c78aacb..562a7e24 100644 --- a/docs/verification/canvas-net/geometry/bezier-flattening.md +++ b/docs/verification/canvas-net/geometry/bezier-flattening.md @@ -1,8 +1,8 @@ -## BezierFlattening Unit Verification Design +### BezierFlattening Unit Verification Design This document describes the unit-level verification strategy for the `BezierFlattening` class. -### Verification Approach +#### Verification Approach The `BezierFlattening` unit is verified through unit tests exercising the never-write-start / always-write-end-last chaining convention, a tolerance-convergence property test (densely @@ -31,15 +31,15 @@ guarantee, distinct from the tolerance guarantee, following the well-established Unit tests reside in `BezierFlatteningTests.cs` within the `DemaConsulting.CanvasNet.Tests.Geometry` project namespace. -### Test Environment +#### Test Environment - **Framework**: xUnit v3 running under the .NET SDK - **Execution**: `dotnet test` invoked by `build.ps1` and the CI pipeline - **Mocking**: None required; `BezierFlattening` has no injectable dependencies -### Unit-Level Test Scenarios +#### Unit-Level Test Scenarios -#### CanvasNet-Geometry-BezierFlattening-FlattenCubic / CanvasNet-Geometry-BezierFlattening-FlattenQuadratic: Chaining Convention +##### CanvasNet-Geometry-BezierFlattening-FlattenCubic / -FlattenQuadratic: Chaining Convention **Tests**: `BezierFlattening_FlattenCubic_SimpleCurve_NeverWritesStartAndAlwaysWritesEndLast`, `BezierFlattening_FlattenQuadratic_SimpleCurve_NeverWritesStartAndAlwaysWritesEndLast` @@ -48,7 +48,7 @@ Flattens a simple, non-degenerate cubic (and, separately, quadratic) curve and a output list never contains the curve's start point and its final element exactly equals the curve's end point. -#### CanvasNet-Geometry-BezierFlattening-ToleranceConvergence: Flattened Points Stay Within Tolerance +##### CanvasNet-Geometry-BezierFlattening-ToleranceConvergence: Flattened Points Stay Within Tolerance **Tests**: `BezierFlattening_FlattenCubic_VariousTolerances_SampledCurvePointsWithinTolerance`, `BezierFlattening_FlattenQuadratic_VariousTolerances_SampledCurvePointsWithinTolerance`, @@ -64,14 +64,14 @@ the finite chord segment is not - and asserts the flattener still subdivides and tolerance, guarding against a bug where the flatness test measured distance to the infinite line rather than the finite chord segment. -#### CanvasNet-Geometry-BezierFlattening-MonotonicSegmentCount: Segment Count Is Monotonic +##### CanvasNet-Geometry-BezierFlattening-MonotonicSegmentCount: Segment Count Is Monotonic **Test**: `BezierFlattening_FlattenCubic_IncreasingTolerance_SegmentCountIsMonotonicallyNonIncreasing` Flattens the same cubic curve at a strictly increasing sequence of tolerances and asserts the resulting output point count never increases as tolerance increases. -#### CanvasNet-Geometry-BezierFlattening-DegenerateControlPoints: Degenerate Curves Terminate +##### CanvasNet-Geometry-BezierFlattening-DegenerateControlPoints: Degenerate Curves Terminate **Tests**: `BezierFlattening_FlattenCubic_CoincidentControlPoints_TerminatesWithSinglePoint`, `BezierFlattening_FlattenCubic_CollinearControlPoints_TerminatesWithCollinearPoints` @@ -80,7 +80,7 @@ Flattens a cubic curve whose control points are coincident with an endpoint, and cubic curve whose control points are collinear with the chord between its endpoints, and asserts both terminate promptly (without throwing or hanging) with a geometrically sensible result. -#### CanvasNet-Geometry-BezierFlattening-RecursionDepthSafetyValve: Pathological Input Still Terminates +##### CanvasNet-Geometry-BezierFlattening-RecursionDepthSafetyValve: Pathological Input Still Terminates **Test**: `BezierFlattening_FlattenCubic_PathologicalNonConvergingCurve_TerminatesWithBoundedOutput` @@ -92,13 +92,13 @@ end point. This test proves the documented safety-valve behavior itself (bounded it deliberately does not assert that the tolerance is met, since `MaxRecursionDepth`'s remarks document that the tolerance guarantee does not extend to input that hits this limit. -#### CanvasNet-Geometry-BezierFlattening-NonPositiveTolerance: Non-Positive Tolerance Is Rejected +##### CanvasNet-Geometry-BezierFlattening-NonPositiveTolerance: Non-Positive Tolerance Is Rejected **Test**: `BezierFlattening_Flatten_NonPositiveTolerance_ThrowsArgumentOutOfRangeException` Calls both `FlattenCubic` and `FlattenQuadratic` with a zero and a negative tolerance and asserts `ArgumentOutOfRangeException` is thrown in every case. -### Acceptance Criteria +#### Acceptance Criteria A unit test run passes when every scenario above passes without error or unexpected exception. diff --git a/docs/verification/canvas-net/geometry/corner-round-effect.md b/docs/verification/canvas-net/geometry/corner-round-effect.md index 36f04615..9adf2081 100644 --- a/docs/verification/canvas-net/geometry/corner-round-effect.md +++ b/docs/verification/canvas-net/geometry/corner-round-effect.md @@ -29,6 +29,62 @@ The `CornerRoundEffect` unit is verified by `CornerRoundEffect_Apply_NonFiniteRadius_ThrowsArgumentOutOfRangeException` cover the validation contract. +### 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 test builds a `Path` through the public `PathBuilder` API +- **Isolation**: Each test method constructs its own `Path` fixture, with no shared state between + tests + +### 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-Geometry-CornerRoundEffect-Apply: Polyline Corners Are Replaced by Tangent-Radius Arcs + +**Tests**: `CornerRoundEffect_Apply_SquareRectangle_ProducesCornerArcs`, +`CornerRoundEffect_Apply_ClosedSquare_RoundsAllFourCornersIncludingWrapAroundCorner`, +`CornerRoundEffect_Apply_ClampsRadiusToHalfShorterAdjacentSegment`, +`CornerRoundEffect_Apply_Size10SquareRadius5_AllFourCornersGetSameUnclampedRadius` + +Verifies every polyline corner (a `LineTo` followed by a `LineTo`, including, for a closed +subpath, the wrap-around corner at the subpath's start point and the corner at the last real +vertex before the closing edge) is replaced by a tangent-radius arc, clamped per-corner to half +of the shorter adjacent segment, and that an unclamped radius applies identically to all four +corners of a square. + +#### CanvasNet-Geometry-CornerRoundEffect-ZeroRadius: A Zero Radius Returns the Source Path Unchanged + +**Test**: `CornerRoundEffect_Apply_ZeroRadius_ReturnsSourcePath` + +Verifies the no-op fast path: a zero radius returns the exact source `Path` instance rather than +allocating a redundant copy. + +#### CanvasNet-Geometry-CornerRoundEffect-CurvedCorners: Curved-Segment Corners Are Left Unchanged + +**Tests**: `CornerRoundEffect_Apply_LeavesCurvedCornersUnchanged`, +`CornerRoundEffect_Apply_CubicThenLineThenLine_LeavesCurveAdjacentVertexUnroundedButRoundsLineLineCorner`, +`CornerRoundEffect_Apply_CubicThenLineThenClose_LeavesCurveAdjacentVertexUnrounded` + +Verifies that corners involving a curved segment (cubic or quadratic Bezier, or an arc) are left +as-is, including a `CubicBezierTo -> LineTo -> LineTo` (and `-> Close`) sequence, confirming the +curve-adjacent vertex stays sharp while a genuine line-to-line-to-line corner elsewhere in the +same path is still rounded. + +#### CanvasNet-Geometry-CornerRoundEffect-Validation: Invalid Arguments Are Rejected + +**Tests**: `CornerRoundEffect_Apply_NullSource_ThrowsArgumentNullException`, +`CornerRoundEffect_Apply_NegativeRadius_ThrowsArgumentOutOfRangeException`, +`CornerRoundEffect_Apply_NonFiniteRadius_ThrowsArgumentOutOfRangeException` + +Verifies a `null` source path is rejected with `ArgumentNullException`, and a negative or +non-finite radius is rejected with `ArgumentOutOfRangeException`. + ### Traceability Every requirement in `docs/reqstream/canvas-net/geometry/corner-round-effect.yaml` links to diff --git a/docs/verification/canvas-net/geometry/path.md b/docs/verification/canvas-net/geometry/path.md index 258d5ac2..c2e03a06 100644 --- a/docs/verification/canvas-net/geometry/path.md +++ b/docs/verification/canvas-net/geometry/path.md @@ -1,10 +1,10 @@ -## Path Unit Verification Design +### Path Unit Verification Design This document describes the unit-level verification strategy for the `Path` and `PathBuilder` classes (and their supporting `Subpath` and `PathCommandType` data types, plus `PathCommand`'s own `LineTo` factory and `ComputeTangents` method). -### Verification Approach +#### Verification Approach The `Path`/`PathBuilder` unit is verified through unit tests that exercise every fluent builder method, the `InvalidOperationException` guards, `Build()`'s snapshot semantics, `Clear()`, @@ -17,15 +17,15 @@ flattening-based tighter mode; expected tangent directions are likewise hand-com Unit tests reside in `PathBuilderTests.cs` and `PathCommandTests.cs` within the `DemaConsulting.CanvasNet.Tests.Geometry` project namespace. -### Test Environment +#### Test Environment - **Framework**: xUnit v3 running under the .NET SDK - **Execution**: `dotnet test` invoked by `build.ps1` and the CI pipeline - **Mocking**: None required; `Path`/`PathBuilder` have no injectable dependencies -### Unit-Level Test Scenarios +#### Unit-Level Test Scenarios -#### CanvasNet-Geometry-Path-BuildEveryCommandType / -ArcToStoresRawParameters: Every Command Recorded +##### CanvasNet-Geometry-Path-BuildEveryCommandType / -ArcToStoresRawParameters: Every Command Recorded **Test**: `PathBuilder_Build_EveryCommandType_RecordsCommandsInOrder` @@ -34,7 +34,7 @@ in sequence and asserts `Build()` returns a `Path` whose single `Subpath.Command each command, in order, with every field (including the raw, unconverted SVG arc parameters on the `ArcTo` command) exactly matching the values supplied. -#### CanvasNet-Geometry-Path-MultipleSubpaths: Independent Subpaths +##### CanvasNet-Geometry-Path-MultipleSubpaths: Independent Subpaths **Tests**: `PathBuilder_Build_MultipleSubpaths_PreservesEachIndependently`, `PathBuilder_MoveTo_WhilePreviousSubpathOpen_CommitsPreviousSubpathAsOpen` @@ -44,21 +44,21 @@ Issues two separate `MoveTo`-started subpaths, each with its own distinct comman `MoveTo` while the previous subpath is still open (no `Close`) and asserts the previous subpath is committed with `IsClosed == false`. -#### CanvasNet-Geometry-Path-Close: Close Marks the Subpath Closed +##### CanvasNet-Geometry-Path-Close: Close Marks the Subpath Closed **Test**: `PathBuilder_Close_OpenSubpath_MarksSubpathClosedWithCloseCommand` Issues `Close` on an open subpath and asserts the resulting `Subpath.IsClosed` is `true` and its final command is a `Close` command. -#### CanvasNet-Geometry-Path-MoveToAfterClose: MoveTo After Close Starts a New Subpath +##### CanvasNet-Geometry-Path-MoveToAfterClose: MoveTo After Close Starts a New Subpath **Test**: `PathBuilder_MoveTo_AfterClose_StartsNewSubpath` Issues `Close`, then `MoveTo`, and asserts a new, independent subpath begins at the new `MoveTo` point. -#### CanvasNet-Geometry-Path-GuardBeforeMoveTo: Guards Before the First MoveTo +##### CanvasNet-Geometry-Path-GuardBeforeMoveTo: Guards Before the First MoveTo **Tests**: `PathBuilder_LineTo_BeforeFirstMoveTo_ThrowsInvalidOperationException`, `PathBuilder_EveryDrawingCommand_BeforeFirstMoveTo_ThrowsInvalidOperationException` @@ -68,41 +68,41 @@ Calls `LineTo` on a freshly constructed builder (before any `MoveTo`) and assert (`LineTo`, `QuadraticBezierTo`, `CubicBezierTo`, `ArcTo`, `Close`) and asserts each throws the same exception. -#### CanvasNet-Geometry-Path-GuardAfterCloseWithoutMoveTo: Guards After Close Without an Intervening MoveTo +##### CanvasNet-Geometry-Path-GuardAfterCloseWithoutMoveTo: Guards After Close Without an Intervening MoveTo **Test**: `PathBuilder_LineTo_AfterCloseWithoutMoveTo_ThrowsInvalidOperationException` Calls `Close`, then `LineTo` without an intervening `MoveTo`, and asserts `InvalidOperationException` is thrown. -#### CanvasNet-Geometry-Path-BuildSnapshotIndependence: Build Returns an Independent Snapshot +##### CanvasNet-Geometry-Path-BuildSnapshotIndependence: Build Returns an Independent Snapshot **Test**: `PathBuilder_Build_ThenMutateBuilder_DoesNotAffectPreviouslyBuiltPath` Builds a `Path`, then issues further commands (including a new `MoveTo`/subpath) on the same builder, and asserts the previously built `Path` instance's `Subpaths` are unchanged. -#### CanvasNet-Geometry-Path-BuildEmpty: Build With No Commands Returns No Subpaths +##### CanvasNet-Geometry-Path-BuildEmpty: Build With No Commands Returns No Subpaths **Test**: `PathBuilder_Build_NoCommandsIssued_ReturnsPathWithNoSubpaths` Calls `Build()` on a freshly constructed builder and asserts the returned `Path.Subpaths` is empty. -#### CanvasNet-Geometry-Path-Clear: Clear Resets the Builder +##### CanvasNet-Geometry-Path-Clear: Clear Resets the Builder **Test**: `PathBuilder_Clear_AfterBuildingAPath_ResetsBuilderToEmptyState` Builds a non-trivial `Path`, calls `Clear()`, then calls `Build()` again and asserts the second `Path` has no subpaths, confirming the builder returned to its initial empty state. -#### CanvasNet-Geometry-Path-EmptySingleton: Path.Empty Has No Subpaths and Empty Bounds +##### CanvasNet-Geometry-Path-EmptySingleton: Path.Empty Has No Subpaths and Empty Bounds **Test**: `Path_Empty_HasNoSubpathsAndEmptyBounds` Asserts `Path.Empty.Subpaths` is empty and `Path.Empty.GetBounds()` equals `Rect.Empty`. -#### CanvasNet-Geometry-Path-GetBoundsConservative: Conservative Mode Returns the Control-Point Convex Hull +##### CanvasNet-Geometry-Path-GetBoundsConservative: Conservative Mode Returns the Control-Point Convex Hull **Test**: `Path_GetBounds_ConservativeMode_CubicWithControlPointsOutsideChord_ReturnsControlPointConvexHull` @@ -111,7 +111,7 @@ between its endpoints, calls `GetBounds()` with the default (non-positive) toler the result exactly equals the hand-computed bounding rectangle of all four control points/endpoints (not the tighter true curve bounds). -#### CanvasNet-Geometry-Path-GetBoundsFlattened: Flattening Mode Is Tighter Than Conservative Mode +##### CanvasNet-Geometry-Path-GetBoundsFlattened: Flattening Mode Is Tighter Than Conservative Mode **Test**: `Path_GetBounds_FlattenMode_TighterThanConservativeMode_ForCurveWithWideControlPolygon` @@ -120,14 +120,14 @@ true curve), calls `GetBounds()` both with the default tolerance and with a smal tolerance, and asserts the flattening-mode result is strictly smaller (tighter) than the conservative-mode result while still containing the true curve. -#### CanvasNet-Geometry-Path-GetBoundsMultipleSubpaths: Bounds Union Multiple Subpaths +##### CanvasNet-Geometry-Path-GetBoundsMultipleSubpaths: Bounds Union Multiple Subpaths **Test**: `Path_GetBounds_MultipleSubpaths_UnionsAllSubpaths` Builds a path with two disjoint subpaths at different locations, calls `GetBounds()`, and asserts the result exactly equals the hand-computed union of each subpath's own bounds. -#### CanvasNet-Geometry-Path-Immutability: Subpaths and Commands Resist Downcast Mutation +##### CanvasNet-Geometry-Path-Immutability: Subpaths and Commands Resist Downcast Mutation **Tests**: `Path_Subpaths_DowncastToIList_ThrowsNotSupportedExceptionOnMutation`, `Subpath_Commands_DowncastToIList_ThrowsNotSupportedExceptionOnMutation` @@ -139,7 +139,7 @@ compile-time `IReadOnlyList` view to the mutable `IList` interface that it `Subpath` cannot be mutated in place even by a caller deliberately bypassing the read-only compile-time type. -#### CanvasNet-Geometry-Path-Transform: ArcTo Commands Convert to Bezier Before Transforming +##### CanvasNet-Geometry-Path-Transform: ArcTo Commands Convert to Bezier Before Transforming **Test**: `Path_Transform_ArcTo_ConvertsToTransformedCubicBezierSegments` @@ -149,7 +149,7 @@ happens as part of the transform, matching `SvgArcConverter`'s own untransformed every transformed endpoint/control point equals the untransformed `SvgArcConverter.ToBeziers` output with the same matrix applied. -#### CanvasNet-Geometry-Path-Rectangle / -RoundRectangle: Negative Width or Height Produces an Empty Path +##### CanvasNet-Geometry-Path-Rectangle / -RoundRectangle: Negative Width or Height Produces an Empty Path **Tests**: `Path_Rectangle_WithNegativeSize_ProducesEmptyPath`, `Path_RoundRectangle_WithNegativeSize_ProducesEmptyPath` @@ -159,7 +159,7 @@ Calls `Path.Rectangle`/`Path.RoundRectangle` with a negative `width` and, separa width or height returns an empty path" contract holds for negative values, not only for exactly zero. -#### CanvasNet-Geometry-Path-Circle: Negative Radius Produces an Empty Path +##### CanvasNet-Geometry-Path-Circle: Negative Radius Produces an Empty Path **Test**: `Path_Circle_WithNegativeRadius_ProducesEmptyPath` @@ -167,14 +167,14 @@ Calls `Path.Circle` with a negative `radius` and asserts the result equals `Path confirming the "non-positive radius returns an empty path" contract holds for negative values, not only for exactly zero. -#### CanvasNet-Geometry-PathCommand-PublicLineToFactory: Public LineTo Factory +##### CanvasNet-Geometry-PathCommand-PublicLineToFactory: Public LineTo Factory **Test**: `PathCommand_LineTo_GivenEndPoint_ConstructsLineToCommandWithThatEndPoint` Calls the public `PathCommand.LineTo` factory directly (not through `PathBuilder`) and asserts the resulting command's `Type` is `LineTo` and its `EndPoint` matches the value supplied. -#### CanvasNet-Geometry-PathCommand-ComputeTangents: Tangent Computation Per Command Type +##### CanvasNet-Geometry-PathCommand-ComputeTangents: Tangent Computation Per Command Type **Tests**: `PathCommand_ComputeTangents_LineTo_ReturnsSameNormalizedDirectionForBothTangents`, `PathCommand_ComputeTangents_LineToWithCoincidentStartAndEnd_ReturnsNullTangents`, @@ -196,6 +196,6 @@ toward/away from its control point(s), falling back to the straight start-to-end when every control point coincides with its nearest endpoint; an `ArcTo` and a `Close` both yield `(null, null)` unconditionally. -### Acceptance Criteria +#### Acceptance Criteria A unit test run passes when every scenario above passes without error or unexpected exception. diff --git a/docs/verification/canvas-net/geometry/rect.md b/docs/verification/canvas-net/geometry/rect.md index 8239b037..b51d8ac1 100644 --- a/docs/verification/canvas-net/geometry/rect.md +++ b/docs/verification/canvas-net/geometry/rect.md @@ -1,8 +1,8 @@ -## Rect Unit Verification Design +### Rect Unit Verification Design This document describes the unit-level verification strategy for the `Rect` struct. -### Verification Approach +#### Verification Approach The `Rect` unit is verified through unit tests that exercise its constructor, computed edge/corner properties, `IsEmpty`, `Contains`, `Union`, `Intersect`, `Transform`, value equality, @@ -12,15 +12,15 @@ independently of the implementation, not by re-deriving the same rotation formul Unit tests reside in `RectTests.cs` within the `DemaConsulting.CanvasNet.Tests.Geometry` project namespace. -### Test Environment +#### Test Environment - **Framework**: xUnit v3 running under the .NET SDK - **Execution**: `dotnet test` invoked by `build.ps1` and the CI pipeline - **Mocking**: None required; `Rect` has no injectable dependencies -### Unit-Level Test Scenarios +#### Unit-Level Test Scenarios -#### CanvasNet-Geometry-Rect-Construction: Constructor Sets Properties and Computed Edges +##### CanvasNet-Geometry-Rect-Construction: Constructor Sets Properties and Computed Edges **Test**: `Rect_Constructor_ValidValues_SetsPropertiesAndComputedEdges` @@ -28,35 +28,35 @@ Constructs a `Rect` with known `X`, `Y`, `Width`, `Height` and asserts every com property (`Left`, `Top`, `Right`, `Bottom`, `Location`, `Size`, `TopLeft`, `BottomRight`) equals its hand-computed expected value. -#### CanvasNet-Geometry-Rect-Empty: Union With Empty Is an Identity +##### CanvasNet-Geometry-Rect-Empty: Union With Empty Is an Identity **Test**: `Rect_Union_WithEmpty_ReturnsOtherRectangleUnchanged` Unions `Rect.Empty` with a real rectangle (in both argument orders) and asserts the result exactly equals the real rectangle unchanged, confirming `Empty` is a true union identity. -#### CanvasNet-Geometry-Rect-IsEmpty: IsEmpty Distinguishes Empty From Real Rectangles +##### CanvasNet-Geometry-Rect-IsEmpty: IsEmpty Distinguishes Empty From Real Rectangles **Test**: `Rect_IsEmpty_EmptySentinelVersusRealRectangle_ReturnsExpectedValue` Asserts `Rect.Empty.IsEmpty` is `true`, and that a real rectangle (including one with a zero width or height, but not negative) reports `IsEmpty` as `false`. -#### CanvasNet-Geometry-Rect-Contains: Contains Uses a Half-Open Interval +##### CanvasNet-Geometry-Rect-Contains: Contains Uses a Half-Open Interval **Test**: `Rect_Contains_BoundaryPoints_ReturnsExpectedValue` Tests points at each corner and edge of a rectangle and asserts the half-open containment convention: left/top edges included, right/bottom edges excluded. -#### CanvasNet-Geometry-Rect-Union: Union of Two Overlapping Rectangles +##### CanvasNet-Geometry-Rect-Union: Union of Two Overlapping Rectangles **Test**: `Rect_Union_TwoOverlappingRectangles_ReturnsSmallestEnclosingRectangle` Unions two overlapping rectangles (via both the instance and static overloads) and asserts the result exactly equals the hand-computed smallest enclosing rectangle. -#### CanvasNet-Geometry-Rect-Intersect: Intersect of Overlapping and Disjoint Rectangles +##### CanvasNet-Geometry-Rect-Intersect: Intersect of Overlapping and Disjoint Rectangles **Tests**: `Rect_Intersect_TwoOverlappingRectangles_ReturnsOverlapRegion`, `Rect_Intersect_DisjointRectangles_ReturnsEmpty`, @@ -69,7 +69,7 @@ equals `Rect.Empty`. Two further regression tests intersect rectangles that mere vertical or horizontal edge (zero-width/zero-height overlap) and assert `Empty` is returned rather than a non-`Empty` zero-extent `Rect`, since edge-only contact contains no points. -#### CanvasNet-Geometry-Rect-Transform: Transform Considers All Four Corners +##### CanvasNet-Geometry-Rect-Transform: Transform Considers All Four Corners **Tests**: `Rect_Transform_Translation_OffsetsPosition`, `Rect_Transform_Rotation90DegreesAboutOrigin_ReturnsHandComputedAabb` @@ -80,7 +80,7 @@ matrix about the origin and asserts the result exactly equals the hand-computed bounding box of the four rotated corners - a scenario that would fail under a naive two-corner-only transform implementation. -#### CanvasNet-Geometry-Rect-Equality: Value Equality and ToString +##### CanvasNet-Geometry-Rect-Equality: Value Equality and ToString **Tests**: `Rect_Equals_SameAndDifferentValues_ReturnsExpectedResult`, `Rect_ToString_ReturnsStringContainingFieldValues` @@ -90,6 +90,6 @@ Compares pairs of `Rect` values with identical and with differing field values v `ToString()` on a `Rect` with known field values and asserts the returned string contains each field's value. -### Acceptance Criteria +#### Acceptance Criteria A unit test run passes when every scenario above passes without error or unexpected exception. diff --git a/docs/verification/canvas-net/geometry/svg-arc-converter.md b/docs/verification/canvas-net/geometry/svg-arc-converter.md index bce1be77..cc28e21b 100644 --- a/docs/verification/canvas-net/geometry/svg-arc-converter.md +++ b/docs/verification/canvas-net/geometry/svg-arc-converter.md @@ -1,8 +1,8 @@ -## SvgArcConverter Unit Verification Design +### SvgArcConverter Unit Verification Design This document describes the unit-level verification strategy for the `SvgArcConverter` class. -### Verification Approach +#### Verification Approach The `SvgArcConverter` unit is verified through unit tests exercising both documented degenerate cases, a true semicircle golden scenario, all four `largeArc`/`sweep` flag combinations on a @@ -20,22 +20,22 @@ from the declared start point to the declared end point. Unit tests reside in `SvgArcConverterTests.cs` within the `DemaConsulting.CanvasNet.Tests.Geometry` project namespace. -### Test Environment +#### Test Environment - **Framework**: xUnit v3 running under the .NET SDK - **Execution**: `dotnet test` invoked by `build.ps1` and the CI pipeline - **Mocking**: None required; `SvgArcConverter` has no injectable dependencies -### Unit-Level Test Scenarios +#### Unit-Level Test Scenarios -#### CanvasNet-Geometry-SvgArcConverter-StartEqualsEnd: Zero-Length Arc Emits Nothing +##### CanvasNet-Geometry-SvgArcConverter-StartEqualsEnd: Zero-Length Arc Emits Nothing **Test**: `SvgArcConverter_ToBeziers_StartEqualsEnd_EmitsNoSegments` Calls `ToBeziers` with `start` equal to `end` and asserts the output list remains empty, per the SVG specification's documented zero-length-arc case. -#### CanvasNet-Geometry-SvgArcConverter-ZeroRadius: Zero Radius Emits a Synthetic Straight-Line Cubic +##### CanvasNet-Geometry-SvgArcConverter-ZeroRadius: Zero Radius Emits a Synthetic Straight-Line Cubic **Tests**: `SvgArcConverter_ToBeziers_ZeroXRadius_EmitsSingleStraightLineEquivalentCubic`, `SvgArcConverter_ToBeziers_ZeroYRadius_EmitsSingleStraightLineEquivalentCubic` @@ -44,7 +44,7 @@ Calls `ToBeziers` with a zero x-radius, and separately a zero y-radius, and asse cubic Bezier segment is emitted, whose control points lie exactly at one-third and two-thirds along the `start`-`end` chord and whose end point equals the declared `end`. -#### CanvasNet-Geometry-SvgArcConverter-ToBeziers: Golden Semicircle, All Flag Combinations, and a Rotated Ellipse +##### CanvasNet-Geometry-SvgArcConverter-ToBeziers: Golden Semicircle, All Flag Combinations, and a Rotated Ellipse **Test**: `SvgArcConverter_ToBeziers_Semicircle_ProducesContinuousChainOnExpectedCircle` @@ -80,13 +80,13 @@ rather than the original, too-small radii. This is a regression test: the existi scenarios above all use radii already larger than their chord's half-distance, so none of them previously exercised this correction path. -#### CanvasNet-Geometry-SvgArcConverter-NeverThrows: Never Throws for SVG-Valid Input +##### CanvasNet-Geometry-SvgArcConverter-NeverThrows: Never Throws for SVG-Valid Input Covered by every test above: none of them expects or catches an exception, and each supplies SVG-valid input (including both degenerate cases, every flag combination, and out-of-range radii requiring the scale-up correction), so a passing test run is itself evidence that `ToBeziers` never throws for these inputs. -### Acceptance Criteria +#### Acceptance Criteria A unit test run passes when every scenario above passes without error or unexpected exception. diff --git a/docs/verification/canvas-net/rendering.md b/docs/verification/canvas-net/rendering.md index 9f38a92c..b7ce40b4 100644 --- a/docs/verification/canvas-net/rendering.md +++ b/docs/verification/canvas-net/rendering.md @@ -30,6 +30,30 @@ establishes composition with `Drawing.PathFiller` and `Drawing.PathStroker`. - **Shapes**: fill and stroke output, radius clamping in `FillRoundRect`, and Canvas current-transform propagation are covered in `ShapesTests`. +### Acceptance Criteria + +The `Rendering` subsystem's verification passes when every requirement-tagged test named in the +`Canvas`, `Shapes`, and `TextRenderer` unit verification designs (`rendering/canvas.md`, +`rendering/shapes.md`, `rendering/text-renderer.md`) passes without error or unexpected exception, +on every target framework in this repository's CI matrix. + +### Test Scenarios + +This subsystem has no dedicated subsystem-level test scenarios of its own beyond the unit-level +scenarios it is composed from; subsystem-level requirements reuse the `Canvas`, `TextRenderer`, +and `Shapes` unit tests directly: + +- **Canvas** โ€” transform-stack semantics (Save/Restore/RotateDegrees/Translate), transform- + independent `Clear`, byte-identical no-transform output against direct `Drawing.PathFiller`/ + `Drawing.PathStroker` calls, and observable output under a non-identity transform; see + `rendering/canvas.md` for the complete named-scenario list +- **TextRenderer** โ€” measurement (advance-plus-kerning summation, ascent/descent from font + metrics), all three text alignments, and transform composition under a translated or rotated + canvas; see `rendering/text-renderer.md` for the complete named-scenario list +- **Shapes** โ€” `Rect`/`RoundRect`/`Circle` fill and stroke helpers, radius clamping, and + `Canvas` current-transform propagation; see `rendering/shapes.md` for the complete + named-scenario list + ### Traceability Every requirement in `docs/reqstream/canvas-net/rendering.yaml` and in the three unit diff --git a/docs/verification/canvas-net/rendering/canvas.md b/docs/verification/canvas-net/rendering/canvas.md index b0a9bc1d..15d04229 100644 --- a/docs/verification/canvas-net/rendering/canvas.md +++ b/docs/verification/canvas-net/rendering/canvas.md @@ -28,6 +28,113 @@ The `Canvas` unit is verified by `test/DemaConsulting.CanvasNet.Tests/Rendering/ `Surface` โ€” it disposes the `Surface` externally, then confirms accessing `Canvas.Surface` itself does not throw, proving `Canvas` adds no ownership check of its own. +### 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 test uses only in-process `Surface`/`Path`/`Gradient`/ + `StrokeStyle` public APIs +- **Isolation**: Each test constructs its own `Surface` and `Canvas` fixture, with no shared + state between tests + +### 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-Rendering-Canvas-Save: Save Pushes the Current Transform + +**Test**: `Canvas_Save_ThenRestore_RestoresPreviousTransform` + +Verifies `Save` pushes the current transform onto the transform stack. + +#### CanvasNet-Rendering-Canvas-Clear: Clear Fills the Whole Surface Independent of the Current Transform + +**Tests**: `Canvas_Clear_DelegatesToSurfaceClear_ProducesByteIdenticalOutput`, +`Canvas_Clear_WithNonIdentityTransform_StillFillsWholeSurfaceIdentically` + +Compares `Canvas.Clear`'s output byte-for-byte against calling `Surface.Clear` directly, and +proves a non-identity `CurrentTransform` has no effect on `Clear`'s result, unlike every +fill/stroke member. + +#### CanvasNet-Rendering-Canvas-Restore: Restore Pops the Transform Stack + +**Tests**: `Canvas_Save_ThenRestore_RestoresPreviousTransform`, +`Canvas_Save_NestedSaveRestore_UnwindsInLifoOrder` + +Verifies `Restore` pops the transform stack and replaces the current transform with the popped +value, including LIFO ordering across nested `Save`/`Restore` pairs. + +#### CanvasNet-Rendering-Canvas-RestoreEmpty: Restore on an Empty Stack Throws + +**Test**: `Canvas_Restore_OnEmptyStack_ThrowsInvalidOperationException` + +Verifies `Restore` throws `InvalidOperationException` when called with an empty transform stack. + +#### CanvasNet-Rendering-Canvas-Translate: Translate Prepends a Translation to the Current Transform + +**Tests**: `Canvas_Translate_ThenTranslate_ComposesAdditively`, +`Canvas_FillPath_WithTranslate_ShiftsOutputByExpectedPixels` + +Verifies repeated `Translate` calls compose additively, and that a translated `Canvas` shifts +filled output by the expected number of pixels. + +#### CanvasNet-Rendering-Canvas-RotateDegrees: RotateDegrees Prepends a Degree-Specified Rotation + +**Tests**: `Canvas_RotateDegrees_ThenTranslate_AppliesTranslationInRotatedFrame`, +`Canvas_Translate_ThenRotateDegrees_AppliesRotationInTranslatedFrame` + +Verifies `RotateDegrees` composes with `Translate` in prepend order, matching HTML5 Canvas/Skia +composition semantics regardless of call order. + +#### CanvasNet-Rendering-Canvas-FillPath: FillPath Bakes the Current Transform Into the Filled Path + +**Tests**: `Canvas_FillPath_NoTransform_ProducesByteIdenticalOutputToPathFillerFill`, +`Canvas_FillPath_WithTranslate_ShiftsOutputByExpectedPixels` + +Verifies the solid-color `FillPath` overload bakes the current transform into the path before +delegating to `Drawing.PathFiller.Fill`. + +#### CanvasNet-Rendering-Canvas-FillPathGradient: Gradient FillPath Bakes the Transform Into Path and Gradient Mapping + +**Test**: `Canvas_FillPath_GradientWithRotateAndTranslate_MatchesPreTransformedGradientFill` + +Verifies the gradient-paint `FillPath` overload bakes the current transform into both the path +and the gradient's own coordinate mapping (via `Gradient.WithTransform`), so the gradient +continues to visually follow the filled shape under a translated or rotated `Canvas`. + +#### CanvasNet-Rendering-Canvas-StrokePath: StrokePath Bakes the Current Transform Into the Stroked Path + +**Test**: `Canvas_StrokePath_NoTransform_ProducesByteIdenticalOutputToPathStrokerPlusPathFiller` + +Verifies `StrokePath` bakes the current transform into the path before delegating to +`Drawing.PathStroker.Stroke` plus `Drawing.PathFiller.Fill`. + +#### CanvasNet-Rendering-Canvas-Identity: Identity Transform Matches Direct PathFiller/Stroker Calls Byte-for-Byte + +**Tests**: `Canvas_FillPath_NoTransform_ProducesByteIdenticalOutputToPathFillerFill`, +`Canvas_StrokePath_NoTransform_ProducesByteIdenticalOutputToPathStrokerPlusPathFiller`, +`Canvas_Constructor_WithSurface_HasIdentityTransform` + +Verifies a newly constructed `Canvas` starts at the identity transform, and that filling/stroking +at identity produces byte-identical output to calling `PathFiller`/`PathStroker` directly. + +#### CanvasNet-Rendering-Canvas-Validation: Invalid Arguments Are Rejected + +**Tests**: `Canvas_Constructor_WithNullSurface_ThrowsArgumentNullException`, +`Canvas_FillPath_NullPath_ThrowsArgumentNullException`, +`Canvas_FillPath_NullGradient_ThrowsArgumentNullException`, +`Canvas_StrokePath_NullStyle_ThrowsArgumentNullException`, +`Canvas_FillPath_InvalidFillRule_ThrowsArgumentOutOfRangeException` + +Verifies a `null` `Surface` constructor argument, a `null` `Path` argument to fill or stroke, a +`null` `Gradient` argument to the gradient-paint `FillPath` overload, and a `null` `StrokeStyle` +argument to stroke are all rejected with `ArgumentNullException`, and an invalid fill rule is +rejected with `ArgumentOutOfRangeException`. + ### Traceability Every requirement in `docs/reqstream/canvas-net/rendering/canvas.yaml` links to one or more of diff --git a/docs/verification/canvas-net/rendering/shapes.md b/docs/verification/canvas-net/rendering/shapes.md index 003f874a..1b0a30e0 100644 --- a/docs/verification/canvas-net/rendering/shapes.md +++ b/docs/verification/canvas-net/rendering/shapes.md @@ -18,6 +18,52 @@ The `Shapes` unit is verified by - **Validation**: `Shapes_NullCanvas_ThrowsArgumentNullException` covers the null-Canvas contract across all helpers. +### 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 test uses only in-process `Canvas`/`Surface` public APIs +- **Isolation**: Each test constructs its own `Surface`/`Canvas` fixture, with no shared state + between tests + +### 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-Rendering-Shapes-Rect: FillRect/StrokeRect Draw an Axis-Aligned Rectangle + +**Tests**: `Shapes_FillRect_WithSolidColor_PaintsRegion`, +`Shapes_StrokeRect_WithSolidStyle_PaintsPixels`, `Shapes_FillRect_RespectsCanvasCurrentTransform` + +Verifies `FillRect` and `StrokeRect` paint the expected rectangular region using the `Canvas` +current transform. + +#### CanvasNet-Rendering-Shapes-RoundRect: FillRoundRect/StrokeRoundRect Draw a Rounded Rectangle With a Clamped Radius + +**Tests**: `Shapes_FillRoundRect_RadiusClampedAndPaintsPixels`, +`Shapes_StrokeRoundRect_WithSolidStyle_PaintsPixels`, +`Shapes_StrokeRoundRect_RadiusLargerThanHalfShorterSide_ClampsAndPaintsPixels` + +Verifies `FillRoundRect` and `StrokeRoundRect` paint non-empty output and clamp the requested +corner radius to half of the shorter side whenever the requested radius exceeds that bound. + +#### CanvasNet-Rendering-Shapes-Circle: FillCircle/StrokeCircle Draw a Circle at a Given Center and Radius + +**Tests**: `Shapes_FillCircle_ProducesRoundRegion`, `Shapes_StrokeCircle_ProducesRing` + +Verifies `FillCircle` and `StrokeCircle` paint a round filled region and a ring-shaped stroked +outline respectively. + +#### CanvasNet-Rendering-Shapes-Validation: A Null Canvas Argument Is Rejected + +**Test**: `Shapes_NullCanvas_ThrowsArgumentNullException` + +Verifies every `Shapes` helper rejects a `null` `Canvas` argument with `ArgumentNullException`. + ### Traceability Every requirement in `docs/reqstream/canvas-net/rendering/shapes.yaml` links to one or more of diff --git a/docs/verification/canvas-net/rendering/text-renderer.md b/docs/verification/canvas-net/rendering/text-renderer.md index 08066976..4648a28c 100644 --- a/docs/verification/canvas-net/rendering/text-renderer.md +++ b/docs/verification/canvas-net/rendering/text-renderer.md @@ -25,6 +25,76 @@ The `TextRenderer` unit is verified by - **Validation**: null-text, null-font, non-finite-size, and invalid-align tests cover the validation contract. +### Test Environment + +- **Framework**: xUnit v3 running under the .NET SDK +- **Execution**: `dotnet test` invoked by `build.ps1` and the CI pipeline +- **Mocking**: None required; synthetic fonts are constructed in memory via `TestSupport/ + SyntheticFontBuilder` +- **Isolation**: Each test constructs its own `Surface`/`Canvas`/synthetic-font fixture, with no + shared state between tests + +### 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-Rendering-TextRenderer-Measure: MeasureText Returns Advance-Plus-Kerning Width and Vertical Metrics + +**Tests**: `TextRenderer_MeasureText_EmptyString_ReturnsZeroWidth`, +`TextRenderer_MeasureText_SingleGlyph_ReturnsAdvanceWidthScaledBySize`, +`TextRenderer_MeasureText_MultipleGlyphs_SumsAdvancesAndAppliesKerning`, +`TextRenderer_MeasureText_ReturnsAscentAndDescentFromFontMetrics`, +`TextRenderer_MeasureText_PositiveRawDescender_ReturnsPositiveAbsoluteDescent` + +Verifies width equals the sum of scaled advances plus scaled kerning against a synthetic 2-glyph +font (advance 500, kerning -50, ascender 800, descender -200, unitsPerEm 1000), that ascent/ +descent are derived from the font's own metrics, and that descent is always the absolute value of +the raw descender even against a (non-conformant) font with a positive raw descender. + +#### CanvasNet-Rendering-TextRenderer-Draw: DrawText Renders Each Glyph Outline at the Baseline Anchor + +**Tests**: `TextRenderer_DrawText_LeftAlign_RendersGlyphsWithoutThrowing`, +`TextRenderer_DrawText_UnderTranslatedCanvas_ShiftsGlyphsByTranslation` + +Verifies `DrawText` renders a string on a `Canvas` at a given `(x, y)` baseline anchor without +throwing, and that the rendered glyphs shift with a translated canvas. + +#### CanvasNet-Rendering-TextRenderer-Alignment: Left/Center/Right Alignment Position Glyphs Relative to the Anchor + +**Tests**: `TextRenderer_DrawText_LeftAlign_RendersGlyphsWithoutThrowing`, +`TextRenderer_DrawText_CenterAlign_CentersRunAroundGivenX`, +`TextRenderer_DrawText_RightAlign_PlacesLastGlyphEndAtGivenX` + +Verifies all three `TextAlign` values: a `Left` run starts at the anchor `x`, a `Center` run is +centered on `x`, and a `Right` run ends at `x`. + +#### CanvasNet-Rendering-TextRenderer-TransformComposition: DrawText Composes Canvas and Per-Glyph Pen Transforms + +**Tests**: `TextRenderer_DrawText_UnderTranslatedCanvas_ShiftsGlyphsByTranslation`, +`TextRenderer_DrawText_UnderRotatedCanvas_MatchesManuallyPreTransformedGlyphFill` + +Verifies text moves with the canvas transform under translation, and that text drawn under a +rotated canvas matches a manually pre-transformed glyph fill through the static `PathFiller`, +catching transform-composition-order and y-flip/rotation sign errors that a translation-only test +cannot. + +#### CanvasNet-Rendering-TextRenderer-Validation: Invalid Arguments Are Rejected + +**Tests**: `TextRenderer_MeasureText_NullText_ThrowsArgumentNullException`, +`TextRenderer_MeasureText_NullFont_ThrowsArgumentNullException`, +`TextRenderer_MeasureText_NonFiniteSize_ThrowsArgumentOutOfRangeException`, +`TextRenderer_DrawText_NullText_ThrowsArgumentNullException`, +`TextRenderer_DrawText_NullFont_ThrowsArgumentNullException`, +`TextRenderer_DrawText_InvalidAlign_ThrowsArgumentOutOfRangeException` + +Verifies `null` text or `null` font arguments are rejected with `ArgumentNullException`, a +non-finite size argument is rejected with `ArgumentOutOfRangeException`, and an undefined +`TextAlign` value is rejected with `ArgumentOutOfRangeException`. + ### Traceability Every requirement in `docs/reqstream/canvas-net/rendering/text-renderer.yaml` links to one or diff --git a/docs/verification/introduction.md b/docs/verification/introduction.md index f2dbbc0a..958a79f7 100644 --- a/docs/verification/introduction.md +++ b/docs/verification/introduction.md @@ -28,17 +28,20 @@ constituent software items, specifically: - **CanvasNet (System)** โ€” The complete .NET library system - **Canvas (Subsystem)** โ€” Pixel-buffer primitives: the `Surface` unit (mutable, in-memory 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` +- **Codecs (Subsystem)** โ€” Image format codecs: `BmpCodec`, `PngCodec`, `TiffCodec`, + `JpegCodec`, and `GifCodec`, each converting to and from a `Surface` pixel buffer +- **Geometry (Subsystem)** โ€” Vector-geometry primitives: `Rect`, `Path`, `BezierFlattening`, + `SvgArcConverter`, and `CornerRoundEffect` - **Drawing (Subsystem)** โ€” Vector rasterization and stroke conversion: `PathFiller`, - `PathStroker`, and `GradientPaint` + `PathStroker`, `GradientPaint`, and `TilePaint` - **Fonts (Subsystem)** โ€” TrueType (`glyf`-based) SFNT font loading and glyph-outline extraction: - `TrueTypeFont` + `TrueTypeFont` and `SystemFontCatalog` - **CanvasNetSvg (System)** โ€” A separate, independently-distributed software system providing SVG (Scalable Vector Graphics) rasterization, containing a single unit, `SvgCodec` โ€” see _CanvasNetSvg System Verification_ (`canvas-net-svg.md`) +- **CanvasNetPdf (System)** โ€” A separate, independently-distributed software system providing + PDF page-rendering support, containing a single unit, `PdfDocument` โ€” see + _CanvasNetPdf System Verification_ (`canvas-net-pdf.md`) The following OTS items are also covered: @@ -49,6 +52,8 @@ The following OTS items are also covered: - **ReviewMark** โ€” file review enforcement tool - **SarifMark** โ€” SARIF report conversion tool - **SonarMark** โ€” SonarCloud quality report tool +- **SysML2Tools** โ€” SysML v2 model-to-documentation tool +- **System.Numerics.Tensors** โ€” vectorized bulk pixel-arithmetic runtime library - **VersionMark** โ€” tool-version documentation tool - **WeasyPrint** โ€” HTML-to-PDF conversion tool - **xUnit** โ€” unit-testing framework diff --git a/requirements.yaml b/requirements.yaml index f26fe4d9..ad5ae6a0 100644 --- a/requirements.yaml +++ b/requirements.yaml @@ -28,8 +28,10 @@ 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/drawing/tile-paint.yaml - docs/reqstream/canvas-net/fonts.yaml - docs/reqstream/canvas-net/fonts/true-type-font.yaml + - docs/reqstream/canvas-net/fonts/system-font-catalog.yaml - docs/reqstream/canvas-net/rendering.yaml - docs/reqstream/canvas-net/rendering/canvas.yaml - docs/reqstream/canvas-net/rendering/text-renderer.yaml @@ -38,6 +40,9 @@ includes: - docs/reqstream/canvas-net-svg.yaml - docs/reqstream/canvas-net-svg/svg-codec.yaml - docs/reqstream/canvas-net-svg/platform-requirements.yaml + - docs/reqstream/canvas-net-pdf.yaml + - docs/reqstream/canvas-net-pdf/pdf-document.yaml + - docs/reqstream/canvas-net-pdf/platform-requirements.yaml - docs/reqstream/ots/xunit.yaml - docs/reqstream/ots/reqstream.yaml - docs/reqstream/ots/buildmark.yaml diff --git a/src/DemaConsulting.CanvasNet.Pdf/DemaConsulting.CanvasNet.Pdf.csproj b/src/DemaConsulting.CanvasNet.Pdf/DemaConsulting.CanvasNet.Pdf.csproj new file mode 100644 index 00000000..a69a1843 --- /dev/null +++ b/src/DemaConsulting.CanvasNet.Pdf/DemaConsulting.CanvasNet.Pdf.csproj @@ -0,0 +1,82 @@ + + + + net8.0;net9.0;net10.0 + latest + enable + enable + + + DemaConsulting.CanvasNet.Pdf + 0.0.0 + DEMA Consulting + DEMA Consulting + DemaConsulting.CanvasNet.Pdf provides a decode/rasterize-only PDF page-rendering codec for the CanvasNet canvas-based drawing and rendering API. + MIT + https://github.com/demaconsulting/CanvasNet + https://github.com/demaconsulting/CanvasNet + README.md + Icon.png + canvas;dotnet-library;pdf + Copyright DEMA Consulting + CanvasNet.Pdf + DemaConsulting.CanvasNet.Pdf + + + true + snupkg + true + true + true + + + true + true + true + true + latest + + + true + $(PackageId) + $(Version) + Organization: $(Company) + + + true + + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + + + + + diff --git a/src/DemaConsulting.CanvasNet.Pdf/NamespaceDoc.cs b/src/DemaConsulting.CanvasNet.Pdf/NamespaceDoc.cs new file mode 100644 index 00000000..edda51e7 --- /dev/null +++ b/src/DemaConsulting.CanvasNet.Pdf/NamespaceDoc.cs @@ -0,0 +1,62 @@ +namespace DemaConsulting.CanvasNet.Pdf; + +// cspell:ignore rasterizing rasterize + +/// +/// The namespace provides a restricted, +/// dependency-free, decode/rasterize-only codec for PDF (Portable Document Format) +/// documents: . Unlike the four raster codecs in the core +/// DemaConsulting.CanvasNet package's +/// namespace (which each convert to and from a +/// pixel buffer using only its +/// existing public API), only decodes/rasterizes PDF page content +/// into a - it has no Save +/// method, since rasterizing a paginated vector document is a fundamentally different (and +/// non-invertible) operation from encoding a fixed-size raster format, so it has no +/// encode/save direction either. This namespace is distributed as the separate +/// DemaConsulting.CanvasNet.Pdf NuGet package, which references the core +/// DemaConsulting.CanvasNet package. +/// +/// +/// fully interprets a page's content stream: +/// vector path construction/painting with real device color (DeviceGray/ +/// DeviceRGB/DeviceCMYK, CalRGB, ICC-based, and /Indexed color +/// spaces), text shown with a resolved font - simple /Subtype /TrueType//Type1 +/// fonts, composite /Subtype /Type0 fonts, and procedure-painted /Subtype /Type3 +/// fonts are all supported, with embedded font programs used directly and non-embedded simple +/// fonts automatically substituted with a matching system or bundled font (only +/// /MMType1 is unsupported) - placed raster image XObjects (DCTDecode/ +/// CCITTFaxDecode (Group 4)/raw samples through the full supported /Filter +/// pipeline: FlateDecode, LZWDecode, ASCII85Decode, +/// ASCIIHexDecode, and RunLengthDecode, each with PNG/TIFF predictor reversal +/// where applicable), placed Form XObjects (nested content streams with their own +/// /Matrix//Resources), and /Pattern-color-space shading (axial/radial) +/// and tiling pattern fills/strokes. Opening a document encrypted with the PDF +/// /Filter /Standard security handler (RC4, AES-128, or AES-256/R5) is also supported. +/// See 's own remarks for the complete, current feature list and its +/// documented scope boundaries (for example mesh shadings, /FunctionType 4 +/// PostScript-calculator functions, the sh operator, generic path clipping, +/// transparency groups, and clip text-rendering modes). +/// +/// +/// Rendering every page of a PDF document to a 300 DPI PNG file, using +/// (which preserves each page's own aspect +/// ratio) together with the core DemaConsulting.CanvasNet package's +/// - the separate package that holds +/// and every raster codec, since this +/// namespace only rasterizes into a Surface and has no Save method of its own: +/// +/// using DemaConsulting.CanvasNet.Codecs; +/// using DemaConsulting.CanvasNet.Pdf; +/// +/// using var document = PdfDocument.Open("input.pdf"); +/// for (var pageIndex = 0; pageIndex < document.PageCount; pageIndex++) +/// { +/// using var surface = document.Render(pageIndex, dpi: 300f); +/// PngCodec.Save(surface, $"page-{pageIndex}.png"); +/// } +/// +/// +internal static class NamespaceDoc +{ +} diff --git a/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.CcittFax.cs b/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.CcittFax.cs new file mode 100644 index 00000000..d7e159d6 --- /dev/null +++ b/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.CcittFax.cs @@ -0,0 +1,536 @@ +// cspell:ignore CCITT Makeup ccittfax EOFB +using DemaConsulting.CanvasNet.Codecs; + +namespace DemaConsulting.CanvasNet.Pdf; + +public sealed partial class PdfDocument +{ + /// + /// The ITU-T T.6 two-dimensional mode codes (section 4.2.1): how the next changing + /// element on the current coding line relates to the reference (previous) line's own + /// changing elements. + /// + internal enum CcittMode + { + /// The current color run extends past the reference line's b2 changing element without itself changing color. + Pass, + + /// Two explicit run-length codes (current color, then the opposite color) describe the next two changing elements. + Horizontal, + + /// The next changing element is at the reference line's b1 position (0 offset). + V0, + + /// The next changing element is 1 pixel to the right of b1. + VR1, + + /// The next changing element is 2 pixels to the right of b1. + VR2, + + /// The next changing element is 3 pixels to the right of b1. + VR3, + + /// The next changing element is 1 pixel to the left of b1. + VL1, + + /// The next changing element is 2 pixels to the left of b1. + VL2, + + /// The next changing element is 3 pixels to the left of b1. + VL3, + } + + /// + /// The ITU-T T.6 two-dimensional mode code table: each entry's bit length/value maps to + /// one member. Prefix-free, so can read one + /// bit at a time and stop at the first match. Deliberately + /// (not ), together with and + /// , so a defensive unit test can feed every table entry's own exact + /// bits back through the same decoder the production code path uses - mirroring + /// 's own identical visibility rationale. + /// + internal static readonly Dictionary<(int Length, int Code), CcittMode> ModeCodeTable = new() + { + [(1, 0b1)] = CcittMode.V0, + [(3, 0b001)] = CcittMode.Horizontal, + [(3, 0b011)] = CcittMode.VR1, + [(3, 0b010)] = CcittMode.VL1, + [(4, 0b0001)] = CcittMode.Pass, + [(6, 0b000011)] = CcittMode.VR2, + [(6, 0b000010)] = CcittMode.VL2, + [(7, 0b0000011)] = CcittMode.VR3, + [(7, 0b0000010)] = CcittMode.VL3, + }; + + /// The longest bit length any entry uses. + private const int MaxModeCodeLength = 7; + + /// The longest bit length any White/Black run-length code table entry uses (ITU-T T.4 Table 3b's 13-bit Black codes). + private const int MaxRunLengthCodeLength = 13; + + /// + /// The ITU-T T.4 Table 3 White terminating (run lengths 0-63) and makeup + /// (64-1728) Modified Huffman run-length codes, as (bit length, code + /// value, run length) triples. Transcribed from libtiff's independently published, + /// widely-used TIFFFaxWhiteCodes table (libtiff/t4.h), itself a direct + /// encoding of the ITU-T T.4 recommendation's own published table - used here only as a + /// cross-check source, not copied code (these are the recommendation's own numeric + /// values, not copyrightable expression). + /// + private static readonly (int Length, int Code, int Run)[] WhiteCodeEntries = + [ + (8, 0x35, 0), (6, 0x7, 1), (4, 0x7, 2), (4, 0x8, 3), (4, 0xB, 4), (4, 0xC, 5), (4, 0xE, 6), (4, 0xF, 7), + (5, 0x13, 8), (5, 0x14, 9), (5, 0x7, 10), (5, 0x8, 11), (6, 0x8, 12), (6, 0x3, 13), (6, 0x34, 14), (6, 0x35, 15), + (6, 0x2A, 16), (6, 0x2B, 17), (7, 0x27, 18), (7, 0xC, 19), (7, 0x8, 20), (7, 0x17, 21), (7, 0x3, 22), (7, 0x4, 23), + (7, 0x28, 24), (7, 0x2B, 25), (7, 0x13, 26), (7, 0x24, 27), (7, 0x18, 28), + (8, 0x2, 29), (8, 0x3, 30), (8, 0x1A, 31), (8, 0x1B, 32), (8, 0x12, 33), (8, 0x13, 34), (8, 0x14, 35), (8, 0x15, 36), + (8, 0x16, 37), (8, 0x17, 38), (8, 0x28, 39), (8, 0x29, 40), (8, 0x2A, 41), (8, 0x2B, 42), (8, 0x2C, 43), (8, 0x2D, 44), + (8, 0x4, 45), (8, 0x5, 46), (8, 0xA, 47), (8, 0xB, 48), (8, 0x52, 49), (8, 0x53, 50), (8, 0x54, 51), (8, 0x55, 52), + (8, 0x24, 53), (8, 0x25, 54), (8, 0x58, 55), (8, 0x59, 56), (8, 0x5A, 57), (8, 0x5B, 58), (8, 0x4A, 59), (8, 0x4B, 60), + (8, 0x32, 61), (8, 0x33, 62), (8, 0x34, 63), + (5, 0x1B, 64), (5, 0x12, 128), (6, 0x17, 192), (7, 0x37, 256), (8, 0x36, 320), (8, 0x37, 384), (8, 0x64, 448), (8, 0x65, 512), + (8, 0x68, 576), (8, 0x67, 640), (9, 0xCC, 704), (9, 0xCD, 768), (9, 0xD2, 832), (9, 0xD3, 896), (9, 0xD4, 960), (9, 0xD5, 1024), + (9, 0xD6, 1088), (9, 0xD7, 1152), (9, 0xD8, 1216), (9, 0xD9, 1280), (9, 0xDA, 1344), (9, 0xDB, 1408), (9, 0x98, 1472), (9, 0x99, 1536), + (9, 0x9A, 1600), (6, 0x18, 1664), (9, 0x9B, 1728), + ]; + + /// + /// The ITU-T T.4 Table 2 Black terminating (run lengths 0-63) and makeup + /// (64-1728) Modified Huffman run-length codes - see + /// 's remarks for the transcription source/provenance. + /// + private static readonly (int Length, int Code, int Run)[] BlackCodeEntries = + [ + (10, 0x37, 0), (3, 0x2, 1), (2, 0x3, 2), (2, 0x2, 3), (3, 0x3, 4), (4, 0x3, 5), (4, 0x2, 6), (5, 0x3, 7), + (6, 0x5, 8), (6, 0x4, 9), (7, 0x4, 10), (7, 0x5, 11), (7, 0x7, 12), (8, 0x4, 13), (8, 0x7, 14), (9, 0x18, 15), + (10, 0x17, 16), (10, 0x18, 17), (10, 0x8, 18), (11, 0x67, 19), (11, 0x68, 20), (11, 0x6C, 21), (11, 0x37, 22), (11, 0x28, 23), + (11, 0x17, 24), (11, 0x18, 25), (12, 0xCA, 26), (12, 0xCB, 27), (12, 0xCC, 28), (12, 0xCD, 29), (12, 0x68, 30), (12, 0x69, 31), + (12, 0x6A, 32), (12, 0x6B, 33), (12, 0xD2, 34), (12, 0xD3, 35), (12, 0xD4, 36), (12, 0xD5, 37), (12, 0xD6, 38), (12, 0xD7, 39), + (12, 0x6C, 40), (12, 0x6D, 41), (12, 0xDA, 42), (12, 0xDB, 43), (12, 0x54, 44), (12, 0x55, 45), (12, 0x56, 46), (12, 0x57, 47), + (12, 0x64, 48), (12, 0x65, 49), (12, 0x52, 50), (12, 0x53, 51), (12, 0x24, 52), (12, 0x37, 53), (12, 0x38, 54), (12, 0x27, 55), + (12, 0x28, 56), (12, 0x58, 57), (12, 0x59, 58), (12, 0x2B, 59), (12, 0x2C, 60), (12, 0x5A, 61), (12, 0x66, 62), (12, 0x67, 63), + (10, 0xF, 64), (12, 0xC8, 128), (12, 0xC9, 192), (12, 0x5B, 256), (12, 0x33, 320), (12, 0x34, 384), (12, 0x35, 448), + (13, 0x6C, 512), (13, 0x6D, 576), (13, 0x4A, 640), (13, 0x4B, 704), (13, 0x4C, 768), (13, 0x4D, 832), (13, 0x72, 896), + (13, 0x73, 960), (13, 0x74, 1024), (13, 0x75, 1088), (13, 0x76, 1152), (13, 0x77, 1216), (13, 0x52, 1280), (13, 0x53, 1344), + (13, 0x54, 1408), (13, 0x55, 1472), (13, 0x5A, 1536), (13, 0x5B, 1600), (13, 0x64, 1664), (13, 0x65, 1728), + ]; + + /// + /// The ITU-T T.4 Table 3a/3b extended makeup codes (run lengths 1792-2560), + /// shared verbatim between the White and Black code tables (identical bit patterns for + /// both colors) - see 's remarks for the transcription + /// source/provenance. + /// + private static readonly (int Length, int Code, int Run)[] ExtendedMakeupCodeEntries = + [ + (11, 0x8, 1792), (11, 0xC, 1856), (11, 0xD, 1920), (12, 0x12, 1984), (12, 0x13, 2048), (12, 0x14, 2112), + (12, 0x15, 2176), (12, 0x16, 2240), (12, 0x17, 2304), (12, 0x1C, 2368), (12, 0x1D, 2432), (12, 0x1E, 2496), + (12, 0x1F, 2560), + ]; + + /// + /// The White run-length code table ( merged with the shared + /// ), keyed by (bit length, code value). + /// Deliberately (not ), together with + /// , , and + /// , so a defensive unit test can feed every table + /// entry's own exact bits back through the same generic decoder the production code path + /// uses, independently of whether a given run length happens to appear in any rendered + /// test image. + /// + internal static readonly Dictionary<(int Length, int Code), int> WhiteCodeTable = + BuildRunLengthCodeTable(WhiteCodeEntries, ExtendedMakeupCodeEntries); + + /// The Black run-length code table - see 's remarks. + internal static readonly Dictionary<(int Length, int Code), int> BlackCodeTable = + BuildRunLengthCodeTable(BlackCodeEntries, ExtendedMakeupCodeEntries); + + /// Merges a color's own terminating/makeup entries with the shared extended makeup entries into a lookup dictionary. + private static Dictionary<(int Length, int Code), int> BuildRunLengthCodeTable( + (int Length, int Code, int Run)[] colorEntries, + (int Length, int Code, int Run)[] sharedExtendedEntries) + { + var table = new Dictionary<(int Length, int Code), int>(colorEntries.Length + sharedExtendedEntries.Length); + foreach (var entry in colorEntries) + { + table[(entry.Length, entry.Code)] = entry.Run; + } + + foreach (var entry in sharedExtendedEntries) + { + table[(entry.Length, entry.Code)] = entry.Run; + } + + return table; + } + + /// + /// Decodes a CCITTFaxDecode-filtered image stream: ITU-T T.6 Group 4 (/K + /// negative) Modified Modified READ (MMR) two-dimensional coding only. + /// + /// The raw (pre-CCITTFaxDecode) stream bytes. + /// The resolved /DecodeParms /K value. + /// The resolved /DecodeParms /Columns value (pixels per row). + /// + /// The resolved row count to decode (the already-defaulted-to-the-image's-own-/Height + /// /DecodeParms /Rows value) - authoritative: decoding stops after exactly this + /// many rows regardless of any trailing EOFB/RTC marker bits that may follow + /// in . + /// + /// The resolved /DecodeParms /BlackIs1 value. + /// The resolved /DecodeParms /EncodedByteAlign value. + /// The resolved /DecodeParms /EndOfLine value. + /// + /// x 8-bit gray samples (one byte per + /// pixel, each either 0 or 255), row-major, ready for the shared + /// /ColorSpace pipeline (PdfDocument.Images.cs's SamplesToColor). + /// + /// + /// Each row is decoded two-dimensionally () against the + /// previous row's changing elements (the first row's reference line is the imaginary, + /// entirely-white line ITU-T T.6 itself specifies), packed into + /// (Columns + 7) / 8 bytes per row (matching / + /// 's own rowBytes convention) with + /// polarity already applied via , then + /// expanded into one gray byte per pixel via . + /// + /// + /// Thrown when the bit stream is truncated or contains an invalid/unrecognized mode or + /// run-length code. + /// + /// + /// Thrown when is 0 or greater (Group 3, one- or + /// two-dimensional, is not supported - only Group 4 is), or when + /// is (explicit in-stream EOL + /// codes before every row are not supported). + /// + private static byte[] DecodeCcittFax( + byte[] data, + int k, + int columns, + int rows, + bool blackIs1, + bool encodedByteAlign, + bool endOfLine) + { + if (k >= 0) + { + throw new UnsupportedImageFeatureException( + "pdf-ccittfax-group3", + $"CCITTFaxDecode with /K {k} (Group 3, one- or two-dimensional) is not supported; only Group 4 (/K < 0) is supported."); + } + + if (endOfLine) + { + throw new UnsupportedImageFeatureException( + "pdf-ccittfax-endofline", + "CCITTFaxDecode with /EndOfLine true is not supported."); + } + + var reader = new CcittBitReader(data); + var rowBytes = (columns + 7) / 8; + var packedRows = new byte[rowBytes * rows]; + IReadOnlyList referenceChanges = []; + + for (var row = 0; row < rows; row++) + { + if (encodedByteAlign) + { + reader.AlignToByte(); + } + + var currentChanges = DecodeCcittRow(reader, referenceChanges, columns); + var packedRow = PackRow(currentChanges, columns, blackIs1); + Array.Copy(packedRow, 0, packedRows, row * rowBytes, rowBytes); + referenceChanges = currentChanges; + } + + return ExpandPackedBitsToGrayBytes(packedRows, columns, rows); + } + + /// + /// Decodes a single ITU-T T.6 two-dimensional coding line against + /// (the previous line's changing elements, ascending, + /// or empty for the imaginary all-white first reference line). + /// + /// The decoded line's own changing-element positions, ascending. + /// + /// Thrown when the bit stream is truncated, contains an invalid mode/run-length code, or + /// a single coding line produces an implausible number of changing elements (a malformed- + /// stream safety guard, not a real ITU-T T.6 limit). + /// + private static List DecodeCcittRow(CcittBitReader reader, IReadOnlyList referenceChanges, int columns) + { + var changes = new List(); + var a0 = -1; + var isBlack = false; + var maxChanges = (columns * 2) + 16; + + while (a0 < columns) + { + if (changes.Count > maxChanges) + { + throw new InvalidDataException("Malformed CCITTFaxDecode stream: a coding line produced too many changing elements."); + } + + var mode = ReadMode(reader); + var (b1, b2) = FindB1B2(referenceChanges, a0, isBlack, columns); + + switch (mode) + { + case CcittMode.Pass: + a0 = b2; + break; + + case CcittMode.Horizontal: + var start = a0 < 0 ? 0 : a0; + var run1 = ReadRun(reader, isBlack); + var run2 = ReadRun(reader, !isBlack); + var a1 = start + run1; + var a2 = a1 + run2; + changes.Add(a1); + changes.Add(a2); + a0 = a2; + break; + + default: + var vertical = b1 + VerticalDelta(mode); + changes.Add(vertical); + a0 = vertical; + isBlack = !isBlack; + break; + } + } + + return changes; + } + + /// Maps a vertical member to its b1 pixel offset. + private static int VerticalDelta(CcittMode mode) => mode switch + { + CcittMode.V0 => 0, + CcittMode.VR1 => 1, + CcittMode.VR2 => 2, + CcittMode.VR3 => 3, + CcittMode.VL1 => -1, + CcittMode.VL2 => -2, + CcittMode.VL3 => -3, + _ => throw new InvalidOperationException($"Unreachable: {mode} is not a vertical mode."), + }; + + /// + /// Finds the b1/b2 changing elements on the reference line relative to + /// /, per ITU-T T.6 section 4.2.1: + /// b1 is the first changing element on the reference line to the right of + /// and of opposite color to ; + /// b2 is the next changing element after b1. Either/both default to + /// when no such element exists. + /// + private static (int B1, int B2) FindB1B2( + IReadOnlyList referenceChanges, + int a0, + bool currentIsBlack, + int columns) + { + var idx = 0; + while (idx < referenceChanges.Count && referenceChanges[idx] <= a0) + { + idx++; + } + + // Changing element 0 is where the (imaginary, all-white-before-it) reference line + // transitions from white to black, so even indices are black, odd indices are white. + var colorAtIdxIsBlack = idx % 2 == 0; + if (colorAtIdxIsBlack == currentIsBlack) + { + idx++; + } + + var b1 = idx < referenceChanges.Count ? referenceChanges[idx] : columns; + var b2 = idx + 1 < referenceChanges.Count ? referenceChanges[idx + 1] : columns; + return (b1, b2); + } + + /// Reads one ITU-T T.6 two-dimensional mode code (). + /// + /// Thrown when the bit stream is truncated or no -bit + /// prefix matches a known mode code. + /// + internal static CcittMode ReadMode(CcittBitReader reader) + { + var code = 0; + for (var length = 1; length <= MaxModeCodeLength; length++) + { + code = (code << 1) | reader.ReadBit(); + if (ModeCodeTable.TryGetValue((length, code), out var mode)) + { + return mode; + } + } + + throw new InvalidDataException("Invalid CCITTFaxDecode two-dimensional mode code."); + } + + /// + /// Reads one run-length code's worth of run (a terminating code 0-63, a + /// makeup code 64-1728, or an extended makeup code 1792-2560) + /// from , returning as soon as the accumulated bits match a known + /// code - valid since every entry in both tables is prefix-free. + /// + /// + /// Thrown when the bit stream is truncated or no -bit prefix + /// matches a known code. + /// + internal static int ReadVariableLengthCode( + CcittBitReader reader, + IReadOnlyDictionary<(int Length, int Code), int> table, + int maxLength) + { + var code = 0; + for (var length = 1; length <= maxLength; length++) + { + code = (code << 1) | reader.ReadBit(); + if (table.TryGetValue((length, code), out var run)) + { + return run; + } + } + + throw new InvalidDataException("Invalid CCITTFaxDecode run-length code."); + } + + /// + /// Reads a complete run length for 's color: a chain of 0 or + /// more makeup codes (each >= 64), terminated by exactly one terminating code + /// (< 64), summing every code's run length. + /// + private static int ReadRun(CcittBitReader reader, bool isBlack) + { + var table = isBlack ? BlackCodeTable : WhiteCodeTable; + var total = 0; + int run; + do + { + run = ReadVariableLengthCode(reader, table, MaxRunLengthCodeLength); + total += run; + } + while (run >= 64); + + return total; + } + + /// + /// Packs one decoded coding line's changing elements into (columns + 7) / 8 bytes, + /// MSB-first, applying polarity: per ISO 32000-1/2 Table 11, + /// the default () maps a physically black pixel to bit 0 + /// and white to bit 1; reverses this. + /// + private static byte[] PackRow(IReadOnlyList changes, int columns, bool blackIs1) + { + var packed = new byte[(columns + 7) / 8]; + var pos = 0; + var isBlack = false; + + foreach (var changeRaw in changes) + { + var change = Math.Min(changeRaw, columns); + if (change > pos) + { + if (isBlack == blackIs1) + { + SetBitRange(packed, pos, change); + } + + pos = change; + } + + isBlack = !isBlack; + if (pos >= columns) + { + break; + } + } + + if (pos < columns && isBlack == blackIs1) + { + SetBitRange(packed, pos, columns); + } + + return packed; + } + + /// Sets every bit in [start, end) (MSB-first pixel-to-bit mapping) to 1. + private static void SetBitRange(byte[] packed, int start, int end) + { + for (var x = start; x < end; x++) + { + packed[x / 8] |= (byte)(0x80 >> (x % 8)); + } + } + + /// + /// Expands ( rows of + /// (columns + 7) / 8 packed bits each, 's own output format) + /// into one 8-bit gray sample per pixel (bit 1 -> 255, bit 0 -> + /// 0), row-major. + /// + private static byte[] ExpandPackedBitsToGrayBytes(byte[] packedRows, int columns, int rows) + { + var rowBytes = (columns + 7) / 8; + var output = new byte[columns * rows]; + for (var row = 0; row < rows; row++) + { + var rowOffset = row * rowBytes; + for (var x = 0; x < columns; x++) + { + var packedByte = packedRows[rowOffset + (x / 8)]; + var bit = (packedByte >> (7 - (x % 8))) & 1; + output[(row * columns) + x] = bit == 1 ? (byte)255 : (byte)0; + } + } + + return output; + } + + /// + /// An MSB-first (high-order bit first, per ITU-T T.6's own bit-packing convention), single + /// bit-at-a-time reader over a byte array, with optional byte-boundary alignment for + /// /EncodedByteAlign support. Deliberately (see + /// 's remarks). + /// + internal sealed class CcittBitReader(byte[] data) + { + private int _bytePosition; + private int _bitPosition; + + /// Reads and returns the next single bit (0 or 1). + /// Thrown when the stream is exhausted. + internal int ReadBit() + { + if (_bytePosition >= data.Length) + { + throw new InvalidDataException("Truncated CCITTFaxDecode stream."); + } + + var bit = (data[_bytePosition] >> (7 - _bitPosition)) & 1; + _bitPosition++; + if (_bitPosition == 8) + { + _bitPosition = 0; + _bytePosition++; + } + + return bit; + } + + /// Advances to the start of the next byte when not already at one (a no-op if already byte-aligned). + internal void AlignToByte() + { + if (_bitPosition != 0) + { + _bitPosition = 0; + _bytePosition++; + } + } + } +} diff --git a/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Color.cs b/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Color.cs new file mode 100644 index 00000000..2c5dbb12 --- /dev/null +++ b/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Color.cs @@ -0,0 +1,792 @@ +// cspell:ignore hival +using DemaConsulting.CanvasNet.Canvas; +using DemaConsulting.CanvasNet.Codecs; + +namespace DemaConsulting.CanvasNet.Pdf; + +public sealed partial class PdfDocument +{ + /// + /// The color spaces this phase recognizes for the current fill/stroke color + /// (/) + /// and for an image XObject's /ColorSpace entry (PdfDocument.Images.cs): the + /// three device color spaces, plus /ICCBased (resolved to a device color space via + /// ) and /Indexed (a palette lookup over any + /// of the above, resolved via ). + /// + /// + /// Every other PDF color space (Separation, DeviceN, CalRGB, + /// CalGray, Lab, and patterns) is out of this phase's scope and is rejected + /// with - a well-formed but unsupported + /// color space, not a malformed one. Within the two newly supported color spaces, two + /// further edge cases are likewise rejected with : + /// an /ICCBased stream whose /N is not 1/3/4 and has no + /// usable /Alternate, and an /Indexed color space whose base color space is + /// itself unsupported. + /// + internal sealed class PdfColorSpace + { + /// The distinct families of color space this phase recognizes. + internal enum Family + { + /// A single gray component in [0, 1] (0 = black, 1 = white). + DeviceGray, + + /// Three additive red/green/blue components, each in [0, 1]. + DeviceRGB, + + /// Four subtractive cyan/magenta/yellow/black components, each in [0, 1]. + DeviceCMYK, + + /// + /// A single palette-index component (not normalized to [0, 1] - a raw + /// index, clamped into [0, ]), looked up against + /// and converted via . + /// + Indexed, + + /// + /// The /Pattern color space (PDF 32000-1 ยง8.7.3.3): selects a named + /// /Pattern resource via scn/SCN's trailing name operand, + /// optionally with an underlying tint color space () for + /// an uncolored (/PaintType 2) tiling pattern. + /// is deliberately undefined for this family - see that method's remarks. + /// + Pattern, + } + + /// Gets the family of color space this instance represents. + internal Family Kind { get; private init; } + + /// + /// Gets the base color space, when is . + /// + internal PdfColorSpace? IndexedBase { get; private init; } + + /// + /// Gets the highest valid palette index, when is + /// . + /// + internal int IndexedHival { get; private init; } + + /// + /// Gets the raw (un-normalized) palette bytes, when is + /// : (IndexedHival + 1) * ComponentCount(IndexedBase) + /// bytes, one -component-count-sized entry per palette index. + /// + internal byte[] IndexedPalette { get; private init; } = []; + + /// + /// Gets the underlying tint color space, when is + /// and the pattern color space was declared with a base + /// ([/Pattern baseSpace], for an uncolored tiling pattern); + /// for a colored-only pattern color space (/Pattern alone). + /// + internal PdfColorSpace? PatternBase { get; private init; } + + /// The shared DeviceGray color-space instance. + internal static readonly PdfColorSpace DeviceGray = new() { Kind = Family.DeviceGray }; + + /// The shared DeviceRGB color-space instance. + internal static readonly PdfColorSpace DeviceRGB = new() { Kind = Family.DeviceRGB }; + + /// The shared DeviceCMYK color-space instance. + internal static readonly PdfColorSpace DeviceCMYK = new() { Kind = Family.DeviceCMYK }; + + /// Creates an /Indexed color-space instance. + internal static PdfColorSpace Indexed(PdfColorSpace baseSpace, int hival, byte[] palette) => new() + { + Kind = Family.Indexed, + IndexedBase = baseSpace, + IndexedHival = hival, + IndexedPalette = palette, + }; + + /// + /// Creates a /Pattern color-space instance, optionally with an underlying tint + /// color space (, for a + /// colored-only pattern color space). + /// + internal static PdfColorSpace Pattern(PdfColorSpace? baseSpace) => new() + { + Kind = Family.Pattern, + PatternBase = baseSpace, + }; + } + + /// Handles the g gray operator: sets the fill color/space to DeviceGray. + /// + /// Thrown when does not contain exactly 1 number. + /// + private void OpSetGrayFill(IReadOnlyList operands) => + SetFillColor(PdfColorSpace.DeviceGray, RequireNumbers(operands, "g", 1)); + + /// Handles the G gray operator: sets the stroke color/space to DeviceGray. + /// + /// Thrown when does not contain exactly 1 number. + /// + private void OpSetGrayStroke(IReadOnlyList operands) => + SetStrokeColor(PdfColorSpace.DeviceGray, RequireNumbers(operands, "G", 1)); + + /// Handles the r g b rg operator: sets the fill color/space to DeviceRGB. + /// + /// Thrown when does not contain exactly 3 numbers. + /// + private void OpSetRgbFill(IReadOnlyList operands) => + SetFillColor(PdfColorSpace.DeviceRGB, RequireNumbers(operands, "rg", 3)); + + /// Handles the r g b RG operator: sets the stroke color/space to DeviceRGB. + /// + /// Thrown when does not contain exactly 3 numbers. + /// + private void OpSetRgbStroke(IReadOnlyList operands) => + SetStrokeColor(PdfColorSpace.DeviceRGB, RequireNumbers(operands, "RG", 3)); + + /// Handles the c m y k k operator: sets the fill color/space to DeviceCMYK. + /// + /// Thrown when does not contain exactly 4 numbers. + /// + private void OpSetCmykFill(IReadOnlyList operands) => + SetFillColor(PdfColorSpace.DeviceCMYK, RequireNumbers(operands, "k", 4)); + + /// Handles the c m y k K operator: sets the stroke color/space to DeviceCMYK. + /// + /// Thrown when does not contain exactly 4 numbers. + /// + private void OpSetCmykStroke(IReadOnlyList operands) => + SetStrokeColor(PdfColorSpace.DeviceCMYK, RequireNumbers(operands, "K", 4)); + + /// + /// Handles the /name cs operator: sets the current fill color space, resetting the + /// fill color to the PDF specification's own documented "reset to black" rule. + /// + /// + /// Thrown when is not exactly 1 Name operand. + /// + /// + /// Thrown when the named color space is not supported (see 's + /// own remarks), whether resolved directly or via /Resources/ColorSpace. + /// + private void OpSetColorSpaceFill(IReadOnlyList operands) + { + _gs.FillColorSpace = ResolveColorSpaceOperand(operands, "cs"); + _gs.FillColor = new Rgba32(0, 0, 0, 255); + _gs.FillPattern = null; + } + + /// + /// Handles the /name CS operator: sets the current stroke color space, resetting + /// the stroke color to the PDF specification's own documented "reset to black" rule. + /// + /// + /// Thrown when is not exactly 1 Name operand. + /// + /// + /// Thrown when the named color space is not supported (see 's + /// own remarks), whether resolved directly or via /Resources/ColorSpace. + /// + private void OpSetColorSpaceStroke(IReadOnlyList operands) + { + _gs.StrokeColorSpace = ResolveColorSpaceOperand(operands, "CS"); + _gs.StrokeColor = new Rgba32(0, 0, 0, 255); + _gs.StrokePattern = null; + } + + /// + /// Handles the sc/scn operators: sets the fill color from its current color + /// space's required component count, or - when the current fill color space is + /// - resolves and sets the fill pattern via + /// instead. + /// + /// The operator's accumulated operand stack. + /// The operator name (sc or scn), for exception messages. + /// + /// Thrown when does not contain exactly + /// ComponentCount(_gs.FillColorSpace) numbers, or propagated from + /// for a malformed pattern operand shape. + /// + /// + /// Propagated from for an undeclared or unsupported pattern. + /// + private void OpSetColorFill(IReadOnlyList operands, string operatorName) + { + if (_gs.FillColorSpace.Kind == PdfColorSpace.Family.Pattern) + { + SetFillPattern(_gs.FillColorSpace, ResolvePatternOperand(operands, operatorName, _gs.FillColorSpace)); + return; + } + + SetFillColor(_gs.FillColorSpace, RequireColorComponents(operands, operatorName, _gs.FillColorSpace)); + } + + /// + /// Handles the SC/SCN operators: sets the stroke color from its current + /// color space's required component count, or - when the current stroke color space is + /// - resolves and sets the stroke pattern via + /// instead. + /// + /// The operator's accumulated operand stack. + /// The operator name (SC or SCN), for exception messages. + /// + /// Thrown when does not contain exactly + /// ComponentCount(_gs.StrokeColorSpace) numbers, or propagated from + /// for a malformed pattern operand shape. + /// + /// + /// Propagated from for an undeclared or unsupported pattern. + /// + private void OpSetColorStroke(IReadOnlyList operands, string operatorName) + { + if (_gs.StrokeColorSpace.Kind == PdfColorSpace.Family.Pattern) + { + SetStrokePattern(_gs.StrokeColorSpace, ResolvePatternOperand(operands, operatorName, _gs.StrokeColorSpace)); + return; + } + + SetStrokeColor(_gs.StrokeColorSpace, RequireColorComponents(operands, operatorName, _gs.StrokeColorSpace)); + } + + /// + /// Validates a sc/SC/scn/SCN operand list against a + /// non-Pattern color space, requiring exactly that color space's own component + /// count of numeric operands. + /// + private static double[] RequireColorComponents(IReadOnlyList operands, string operatorName, PdfColorSpace colorSpace) => + RequireNumbers(operands, operatorName, ComponentCount(colorSpace)); + + /// + /// A resolved scn/SCN pattern operand: the resolved pattern itself, plus - + /// for an uncolored (/PaintType 2) tiling pattern whose color space declared an + /// underlying tint base - the tint color converted from the operand's leading numeric + /// components. + /// + private readonly record struct PatternOperand(ResolvedPattern Pattern, Rgba32? UnderlyingTint); + + /// + /// Validates and resolves a scn/SCN pattern-color-space operand list: the + /// trailing operand must be a pattern Name; every operand before it must be + /// numeric, with count exactly ComponentCount(colorSpace.PatternBase) when the + /// resolved pattern itself is an uncolored (/PaintType 2) tiling pattern + /// and declared an underlying tint base, or exactly 0 + /// otherwise. + /// + /// + /// Deliberately does not key the expected leading-operand count off + /// 's own declared base alone: many real-world PDF + /// producers declare a [/Pattern baseSpace] color space (with an underlying tint + /// base) and then use it with colored (/PaintType 1) tiling patterns or + /// shading patterns too, supplying only the pattern name with no leading tint + /// components - which Acrobat (and every other mainstream viewer) tolerates, since a + /// colored pattern/shading pattern never actually needs a tint. Matching that tolerance + /// requires resolving the named pattern first, then deciding the expected + /// component count from the resolved pattern's own + /// (only meaningful for a tiling pattern), not merely from the color space's declared + /// shape. + /// + /// + /// Thrown when the last operand is not a Name, or when the leading numeric operand + /// count does not match the expected shape above. + /// + /// + /// Propagated from for an undeclared or unsupported pattern. + /// + private PatternOperand ResolvePatternOperand(IReadOnlyList operands, string operatorName, PdfColorSpace colorSpace) + { + if (operands.Count == 0 || operands[^1].Kind != PdfKind.Name) + { + throw new InvalidDataException( + $"Operator '{operatorName}' against a /Pattern color space requires a trailing pattern name operand."); + } + + var pattern = ResolvePattern(operands[^1].Text); + var isUncoloredTiling = pattern.Kind == ResolvedPattern.PatternKind.Tiling && pattern.PaintType == 2; + var expectedComponentCount = isUncoloredTiling && colorSpace.PatternBase is not null + ? ComponentCount(colorSpace.PatternBase) + : 0; + + var componentOperands = operands.Take(operands.Count - 1).ToArray(); + if (componentOperands.Length != expectedComponentCount) + { + throw new InvalidDataException( + $"Operator '{operatorName}' against this /Pattern color space requires exactly " + + $"{expectedComponentCount} numeric operand(s) before the pattern name; got {componentOperands.Length}."); + } + + var components = RequireNumbers(componentOperands, operatorName, expectedComponentCount); + var tint = expectedComponentCount == 0 ? (Rgba32?)null : ColorFromComponents(colorSpace.PatternBase!, components); + + return new PatternOperand(pattern, tint); + } + + /// + /// Handles setting the fill pattern (the Pattern-color-space branch of + /// ): sets / + /// , and to + /// 's own resolved tint when present (consulted later by + /// PaintPatternFill as the tint to recolor an uncolored /PaintType 2 tiling + /// pattern's cell with) or a documented opaque-black placeholder otherwise - never + /// actually painted with directly in that case, since + /// branches on 's + /// being before ever reading + /// for anything other than this tint purpose. + /// + private void SetFillPattern(PdfColorSpace colorSpace, PatternOperand operand) + { + _gs.FillColorSpace = colorSpace; + _gs.FillPattern = operand.Pattern; + _gs.FillColor = operand.UnderlyingTint ?? new Rgba32(0, 0, 0, 255); + } + + /// + /// Handles setting the stroke pattern (the Pattern-color-space branch of + /// ) - see 's remarks, which + /// apply identically here against / + /// /. + /// + private void SetStrokePattern(PdfColorSpace colorSpace, PatternOperand operand) + { + _gs.StrokeColorSpace = colorSpace; + _gs.StrokePattern = operand.Pattern; + _gs.StrokeColor = operand.UnderlyingTint ?? new Rgba32(0, 0, 0, 255); + } + + /// Sets the current fill color/space from raw color-component values. + private void SetFillColor(PdfColorSpace colorSpace, IReadOnlyList components) + { + _gs.FillColorSpace = colorSpace; + _gs.FillColor = ColorFromComponents(colorSpace, components); + _gs.FillPattern = null; + } + + /// Sets the current stroke color/space from raw color-component values. + private void SetStrokeColor(PdfColorSpace colorSpace, IReadOnlyList components) + { + _gs.StrokeColorSpace = colorSpace; + _gs.StrokeColor = ColorFromComponents(colorSpace, components); + _gs.StrokePattern = null; + } + + /// + /// Validates a cs/CS operand list (exactly 1 Name operand) and + /// resolves it to a . + /// + /// + /// Thrown when is not exactly 1 Name operand. + /// + private PdfColorSpace ResolveColorSpaceOperand(IReadOnlyList operands, string operatorName) + { + if (operands.Count != 1 || operands[0].Kind != PdfKind.Name) + { + throw new InvalidDataException( + $"Operator '{operatorName}' requires exactly 1 name operand; got {operands.Count}."); + } + + return ResolveColorSpaceByName(operands[0].Text); + } + + /// + /// The maximum number of nested /Resources/ColorSpace name lookups + /// / allow before + /// failing closed. A malformed or adversarial PDF can declare a color-space name that + /// resolves (directly, or via a cycle of several names) back to itself; without this bound + /// such a document would recurse until the process' call stack is exhausted, raising an + /// unrecoverable that crashes the whole process rather + /// than failing this document open/render call alone. + /// + private const int MaxColorSpaceRecursionDepth = 32; + + /// + /// The current color-space name resolution recursion depth, incremented/decremented around + /// every call. + /// + private int _colorSpaceRecursionDepth; + + /// + /// Resolves a color-space name to a : the three device + /// names resolve directly; any other name is looked up in the current page's + /// /Resources/ColorSpace dictionary and the resolved value is classified via + /// . + /// + /// + /// Thrown when the name resolves back to itself (directly or via a cycle of several + /// names), or otherwise nests deeper than . + /// + /// + /// Thrown when the name is not one of the three device names and either cannot be + /// resolved via /Resources/ColorSpace at all, or resolves to an unsupported color + /// space (Separation/DeviceN/CalGray/Lab/anything else, or an + /// /ICCBased//Indexed edge case documented on ) - + /// CalRGB is supported, see 's own remarks. + /// + private PdfColorSpace ResolveColorSpaceByName(string name) + { + switch (name) + { + case "DeviceGray": + return PdfColorSpace.DeviceGray; + case "DeviceRGB": + return PdfColorSpace.DeviceRGB; + case "DeviceCMYK": + return PdfColorSpace.DeviceCMYK; + case "Pattern": + return PdfColorSpace.Pattern(null); + } + + if (_colorSpaceRecursionDepth >= MaxColorSpaceRecursionDepth) + { + throw new InvalidDataException( + $"Color space '{name}' resolution exceeds the maximum supported nesting depth of {MaxColorSpaceRecursionDepth}."); + } + + var colorSpaceDictionary = _resources?.Get("ColorSpace"); + var resolvedDictionary = colorSpaceDictionary is null ? null : Resolve(colorSpaceDictionary); + var entry = resolvedDictionary?.Get(name); + if (entry is null) + { + throw new UnsupportedImageFeatureException( + $"pdf-colorspace-{name}", + $"Color space '{name}' is not declared in /Resources/ColorSpace and is not a device color space."); + } + + _colorSpaceRecursionDepth++; + try + { + return ResolveColorSpaceValue(Resolve(entry)); + } + finally + { + _colorSpaceRecursionDepth--; + } + } + + /// + /// Classifies an already-resolved color-space value (a Name or an Array, + /// for example an image XObject's own /ColorSpace entry) into a + /// . + /// + /// + /// [/CalRGB dict] (PDF 32000-1 ยง8.6.5.3) is mapped directly to + /// , ignoring 's own + /// /WhitePoint//Gamma//Matrix entries entirely - a documented + /// simplification matching this method's own existing /ICCBased precedent + /// ('s own "map to the nearest Device* space, + /// ignore the actual colorimetric transform" posture), rather than performing the real + /// CIE-based chromatic-adaptation/gamma conversion the PDF specification describes. + /// CalGray and Lab remain unsupported (thrown below). + /// + /// + /// Thrown when is neither a Name nor an Array, or + /// propagated from for a malformed /Indexed + /// array. + /// + /// + /// Thrown when the value names/represents a color space other than DeviceGray/ + /// DeviceRGB/DeviceCMYK/ICCBased/Indexed/CalRGB, or + /// propagated from / + /// for the documented edge cases of those two color spaces. + /// + private PdfColorSpace ResolveColorSpaceValue(PdfObject value) + { + if (value.Kind == PdfKind.Name) + { + return ResolveColorSpaceByName(value.Text); + } + + if (value.Kind == PdfKind.Array) + { + var name = value.Items.Count > 0 && Resolve(value.Items[0]).Kind == PdfKind.Name + ? Resolve(value.Items[0]).Text + : "Unknown"; + + switch (name) + { + case "ICCBased": + return ResolveIccBasedColorSpace(value); + case "Indexed": + return ResolveIndexedColorSpace(value); + case "Pattern": + return ResolvePatternColorSpace(value); + case "CalRGB": + return PdfColorSpace.DeviceRGB; + } + + throw new UnsupportedImageFeatureException( + $"pdf-colorspace-{name}", + $"Color space '{name}' is not supported."); + } + + throw new InvalidDataException("Color space value must be a name or an array."); + } + + /// + /// Resolves a [/Pattern] or [/Pattern baseSpace] color-space array + /// (PDF 32000-1 ยง8.7.3.3): a 1-element array is a colored-only pattern color space + /// ( is ); a 2-element array + /// resolves its 2nd element (recursively, via , under + /// the same guard + /// already uses) as the underlying tint color space for an uncolored tiling pattern. + /// + /// + /// Thrown when the recursion depth already equals . + /// + /// + /// Thrown when does not have exactly 1 or 2 elements (feature + /// pdf-colorspace-Pattern), or propagated from + /// when the base color space is itself unsupported. + /// + private PdfColorSpace ResolvePatternColorSpace(PdfObject array) + { + if (array.Items.Count is not (1 or 2)) + { + throw new UnsupportedImageFeatureException( + "pdf-colorspace-Pattern", + "Color space '/Pattern' array must have exactly 1 or 2 elements."); + } + + if (array.Items.Count == 1) + { + return PdfColorSpace.Pattern(null); + } + + if (_colorSpaceRecursionDepth >= MaxColorSpaceRecursionDepth) + { + throw new InvalidDataException( + $"Color space 'Pattern' base resolution exceeds the maximum supported nesting depth of {MaxColorSpaceRecursionDepth}."); + } + + _colorSpaceRecursionDepth++; + try + { + var baseSpace = ResolveColorSpaceValue(Resolve(array.Items[1])); + return PdfColorSpace.Pattern(baseSpace); + } + finally + { + _colorSpaceRecursionDepth--; + } + } + + /// + /// Resolves an /ICCBased color-space array ([/ICCBased streamRef]): prefers + /// the referenced stream's /Alternate entry when present and itself resolves to a + /// supported color space, else maps the stream's /N component count + /// (1/3/4) to DeviceGray/DeviceRGB/DeviceCMYK. + /// + /// + /// An /Alternate whose resolution throws + /// (absent, or itself naming an unsupported color space) is deliberately treated as "no + /// usable /Alternate", falling back to the /N-implied device space instead of + /// propagating - this is the PDF specification's own documented fallback semantics for + /// /ICCBased, not an oversight. A structurally malformed /Alternate (which + /// throws ) is not caught, and propagates unmodified. + /// + /// + /// Thrown when does not have exactly 2 elements, when its 2nd + /// element does not resolve to a stream, or when neither /Alternate nor a 1/3/4 + /// /N is usable. + /// + private PdfColorSpace ResolveIccBasedColorSpace(PdfObject array) + { + if (array.Items.Count != 2) + { + throw new UnsupportedImageFeatureException( + "pdf-colorspace-ICCBased", + "Color space '/ICCBased' requires exactly 2 array elements."); + } + + var stream = Resolve(array.Items[1]); + if (stream.Kind != PdfKind.Stream) + { + throw new UnsupportedImageFeatureException( + "pdf-colorspace-ICCBased", + "Color space '/ICCBased' requires its 2nd element to resolve to a stream."); + } + + var alternate = stream.Get("Alternate"); + if (alternate is not null) + { + try + { + return ResolveColorSpaceValue(Resolve(alternate)); + } + catch (UnsupportedImageFeatureException) + { + // No usable /Alternate - fall through to /N-based resolution. + } + } + + var nEntry = stream.Get("N"); + var resolvedN = nEntry is null ? null : Resolve(nEntry); + var n = resolvedN is { Kind: PdfKind.Number } ? (int)resolvedN.Number : (int?)null; + return n switch + { + 1 => PdfColorSpace.DeviceGray, + 3 => PdfColorSpace.DeviceRGB, + 4 => PdfColorSpace.DeviceCMYK, + _ => throw new UnsupportedImageFeatureException( + "pdf-colorspace-ICCBased", + $"Color space '/ICCBased' has an unsupported /N ({(n is null ? "missing" : n.ToString())}) and no usable /Alternate."), + }; + } + + /// + /// Resolves an /Indexed color-space array + /// ([/Indexed baseSpace hival lookup]): the base color space (resolved recursively + /// via ), the highest valid palette index + /// (/Hival), and the raw palette bytes (lookup, either a PDF string's raw + /// bytes or a stream decoded via ). + /// + /// + /// Thrown when does not have exactly 4 elements, when + /// /Hival is not a non-negative number or exceeds 255, when the lookup + /// table is neither a string nor a stream, or when the resolved palette is shorter than + /// (Hival + 1) * ComponentCount(baseSpace) bytes. + /// + /// + /// Propagated from when the base color space is + /// itself unsupported. + /// + private PdfColorSpace ResolveIndexedColorSpace(PdfObject array) + { + if (array.Items.Count != 4) + { + throw new InvalidDataException("Color space '/Indexed' requires exactly 4 array elements."); + } + + var baseSpace = ResolveColorSpaceValue(Resolve(array.Items[1])); + + var hivalValue = Resolve(array.Items[2]); + if (hivalValue.Kind != PdfKind.Number || hivalValue.Number < 0) + { + throw new InvalidDataException("Color space '/Indexed' /Hival must be a non-negative number."); + } + + var hival = (int)hivalValue.Number; + if (hival > 255) + { + throw new InvalidDataException("Color space '/Indexed' /Hival must not exceed 255."); + } + + var lookup = Resolve(array.Items[3]); + var palette = lookup.Kind switch + { + PdfKind.String => lookup.Bytes, + PdfKind.Stream => GetStreamDecodedBytes(lookup), + _ => throw new InvalidDataException("Color space '/Indexed' lookup table must be a string or a stream."), + }; + + var requiredLength = (hival + 1) * ComponentCount(baseSpace); + if (palette.Length < requiredLength) + { + throw new InvalidDataException( + $"Color space '/Indexed' lookup table has {palette.Length} byte(s); expected at least {requiredLength}."); + } + + return PdfColorSpace.Indexed(baseSpace, hival, palette); + } + + /// Gets the number of numeric color components a color space requires. + /// + /// Thrown when 's family is + /// - /Pattern has no fixed numeric-component count of its own; the dedicated + /// scn/SCN pattern-operand path () must + /// be used instead, which only ever calls this method against + /// colorSpace.PatternBase (never against a + /// space itself) - this throw is therefore a fail-closed backstop for an unreachable + /// call path, not a documented, reachable behavior. + /// + private static int ComponentCount(PdfColorSpace colorSpace) => colorSpace.Kind switch + { + PdfColorSpace.Family.DeviceGray => 1, + PdfColorSpace.Family.DeviceRGB => 3, + PdfColorSpace.Family.DeviceCMYK => 4, + PdfColorSpace.Family.Indexed => 1, + PdfColorSpace.Family.Pattern => throw new InvalidOperationException( + "ComponentCount is not defined for the Pattern color space; use the dedicated scn/SCN pattern-operand path instead."), + _ => throw new InvalidOperationException($"Unreachable: unrecognized color space {colorSpace.Kind}."), + }; + + /// + /// Converts a color space's raw component values (each expected in [0, 1], but + /// clamped rather than rejected when slightly out of range - a documented leniency for + /// real-world producers that occasionally emit a cosmetic overshoot) into an opaque + /// color. + /// + /// + /// DeviceCMYK uses the standard, uncalibrated naive conversion + /// R = 255 * (1 - C) * (1 - K) (and the equivalent formula for G/B + /// from M/Y) - not a color-managed conversion, matching this phase's + /// documented "device color, no color management" scope. Indexed's single + /// component is a raw, un-normalized palette index (not a [0, 1] fraction) - see + /// . + /// + private static Rgba32 ColorFromComponents(PdfColorSpace colorSpace, IReadOnlyList components) => + colorSpace.Kind switch + { + PdfColorSpace.Family.DeviceGray => GrayToColor(components[0]), + PdfColorSpace.Family.DeviceRGB => RgbToColor(components[0], components[1], components[2]), + PdfColorSpace.Family.DeviceCMYK => CmykToColor(components[0], components[1], components[2], components[3]), + PdfColorSpace.Family.Indexed => IndexedToColor(colorSpace, components[0]), + _ => throw new InvalidOperationException($"Unreachable: unrecognized color space {colorSpace.Kind}."), + }; + + private static Rgba32 GrayToColor(double gray) + { + var value = ComponentToByte(gray); + return new Rgba32(value, value, value, 255); + } + + private static Rgba32 RgbToColor(double red, double green, double blue) => + new(ComponentToByte(red), ComponentToByte(green), ComponentToByte(blue), 255); + + private static Rgba32 CmykToColor(double cyan, double magenta, double yellow, double black) + { + var c = Clamp01(cyan); + var m = Clamp01(magenta); + var y = Clamp01(yellow); + var k = Clamp01(black); + + var red = 255.0 * (1 - c) * (1 - k); + var green = 255.0 * (1 - m) * (1 - k); + var blue = 255.0 * (1 - y) * (1 - k); + + return new Rgba32(ToByte(red), ToByte(green), ToByte(blue), 255); + } + + /// + /// Converts an /Indexed color space's single raw palette-index component (not + /// normalized to [0, 1] - a raw index) into an opaque color: + /// rounds and clamps the index into [0, IndexedHival] (rather than rejecting an + /// out-of-range index), looks up that entry's raw bytes in IndexedPalette, + /// normalizes each byte to [0, 1], and recurses into IndexedBase's own + /// . + /// + private static Rgba32 IndexedToColor(PdfColorSpace colorSpace, double rawIndex) + { + var index = Math.Clamp((int)Math.Round(rawIndex), 0, colorSpace.IndexedHival); + var baseSpace = colorSpace.IndexedBase!; + var componentCount = ComponentCount(baseSpace); + var offset = index * componentCount; + + var components = new double[componentCount]; + for (var i = 0; i < componentCount; i++) + { + components[i] = colorSpace.IndexedPalette[offset + i] / 255.0; + } + + return ColorFromComponents(baseSpace, components); + } + + /// Converts a [0, 1] color component (clamped) into a [0, 255] byte value. + private static byte ComponentToByte(double component) => ToByte(Clamp01(component) * 255.0); + + /// Rounds and clamps an already-[0, 255]-scaled value into a byte. + private static byte ToByte(double value0To255) => (byte)Math.Clamp(Math.Round(value0To255), 0, 255); + + /// + /// Clamps a color component into [0, 1] rather than rejecting it - values outside + /// this range are common in slightly-out-of-spec real-world producers; clamping avoids + /// rejecting an otherwise well-formed page over a cosmetic overshoot. + /// + private static double Clamp01(double value) => Math.Clamp(value, 0.0, 1.0); +} diff --git a/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.ContentStream.cs b/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.ContentStream.cs new file mode 100644 index 00000000..95fa7579 --- /dev/null +++ b/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.ContentStream.cs @@ -0,0 +1,446 @@ +using System.Numerics; +using DemaConsulting.CanvasNet.Canvas; +using DemaConsulting.CanvasNet.Drawing; +using DemaConsulting.CanvasNet.Geometry; + +namespace DemaConsulting.CanvasNet.Pdf; + +public sealed partial class PdfDocument +{ + /// + /// The destination surface for the content stream currently being executed by + /// . + /// + private Surface _surface = null!; + + /// + /// The current page's resolved /Resources dictionary (inherited from the nearest + /// ancestor that declares one, exactly like /MediaBox//Rotate), or + /// when no ancestor declares one - used by the cs/CS + /// color-space operators and the Do image-XObject operator to resolve a named + /// /ColorSpace//XObject resource. Reset at the start of every + /// call. + /// + private PdfObject? _resources; + + /// + /// The page's own initial current transformation matrix (the base CTM the top-level + /// render call was invoked with), captured once per top-level + /// call and never mutated by any subsequent cm/Form-XObject-matrix composition - + /// used by PdfDocument.Patterns.cs's PatternToDeviceTransform to anchor a + /// pattern's own /Matrix against the page's default coordinate system, per + /// PDF 32000-1 ยง8.7.3.1 ("the pattern matrix maps pattern space to the default + /// (initial) coordinate system of the page"), rather than against whatever CTM happens to + /// be active when the pattern is actually painted with. + /// + private Matrix3x2 _pageInitialCtm; + + /// + /// Resets every piece of per-render content-stream state (destination surface, resources, + /// graphics state/stack, path-construction state, font cache, text matrices, and the Form + /// XObject nesting-depth counter) and then tokenizes and executes + /// as the page's top-level content stream via + /// , painting recognized path-construction/painting + /// operators onto and ignoring every other operator, starting + /// from as the initial graphics state's current transformation + /// matrix. + /// + /// The already-decoded (filter-applied) content-stream bytes. + /// The destination surface every painting operator draws onto. + /// + /// The initial current transformation matrix, mapping PDF user-space points to device + /// pixel-space points, before any content-stream cm operator is applied. + /// + /// + /// The current page's resolved /Resources dictionary, or + /// when none is declared anywhere in the page's ancestry. + /// + /// + /// This method is the single entry point called once per + /// call. A nested /Subtype /Form XObject (see ) does + /// not call this method again - it re-enters + /// directly, so that only the Form-specific state it explicitly saves/swaps/restores is + /// affected, and every other piece of state reset here is left untouched by nested + /// execution. + /// + /// + /// Thrown when the content stream is not lexically well-formed, or when a recognized + /// operator's operand count/type does not match its documented requirement. + /// + private void ExecuteContentStream(byte[] contentBytes, Surface surface, Matrix3x2 baseCtm, PdfObject? resources) + { + _surface = surface; + _resources = resources; + _gsStack = new Stack(); + _gs = new GraphicsState { CurrentTransform = baseCtm }; + _pageInitialCtm = baseCtm; + _pathBuilder = new PathBuilder(); + _currentPoint = default; + _subpathStart = default; + _hasOpenSubpath = false; + _fontCache = new Dictionary(); + _textMatrix = Matrix3x2.Identity; + _lineMatrix = Matrix3x2.Identity; + _formNestingDepth = 0; + _type3NestingDepth = 0; + _colorSpaceRecursionDepth = 0; + _functionRecursionDepth = 0; + + ExecuteOperators(contentBytes); + } + + /// + /// Tokenizes and executes against the currently active + /// graphics state/resources/surface, dispatching each recognized keyword operator in turn. + /// + /// The already-decoded (filter-applied) content-stream bytes. + /// + /// Re-entrant: calls this method recursively (bounded by + /// ) to execute a nested /Subtype /Form XObject's + /// own content stream against a temporarily swapped-in graphics state/resources, without + /// re-running 's full state reset; PaintType3Glyph + /// (PdfDocument.Fonts.Type3.cs) calls it recursively in exactly the same way + /// (bounded by its own, independent ) to execute a + /// /Subtype /Type3 glyph procedure's own content stream. + /// + /// + /// Thrown when the content stream is not lexically well-formed, or when a recognized + /// operator's operand count/type does not match its documented requirement. + /// + private void ExecuteOperators(byte[] contentBytes) + { + var tokenizer = new PdfTokenizer(contentBytes); + var operands = new List(); + while (true) + { + var token = tokenizer.NextToken(); + if (token.Kind == PdfTokenKind.EndOfFile) + { + break; + } + + switch (token.Kind) + { + case PdfTokenKind.Number: + operands.Add(PdfObject.FromNumber(token.Number)); + break; + + case PdfTokenKind.Name: + operands.Add(PdfObject.FromName(token.Text ?? string.Empty)); + break; + + case PdfTokenKind.LiteralString: + case PdfTokenKind.HexString: + operands.Add(PdfObject.FromString(token.Bytes ?? [])); + break; + + case PdfTokenKind.ArrayStart: + case PdfTokenKind.DictStart: + // Inline dictionary operands occur legitimately in content streams (e.g. the + // BDC/DP marked-content operators' inline "properties" dictionary); parse them + // with the same generic object parser used elsewhere rather than rejecting them. + operands.Add(ParseValue(tokenizer, token)); + break; + + case PdfTokenKind.Keyword: + DispatchOperator(token.Text ?? string.Empty, operands); + operands.Clear(); + break; + + default: + // A content stream never legitimately contains a bare '>>'/']' delimiter or + // any other unrecognized token kind here (well-formed '['/'<<' operands are + // consumed above, alongside their matching close token) - this class's own + // convention (see PdfDocument.Xref.cs) is to throw on truly unparseable + // structure rather than silently continuing. + throw new InvalidDataException( + $"Unexpected token '{token.Kind}' in PDF content stream."); + } + } + } + + /// + /// Dispatches one recognized content-stream keyword operator (per the fixed set this + /// phase implements) against its accumulated operand stack, silently ignoring any other + /// keyword (clipping, ExtGState, shading, inline images, and every other operator not yet + /// implemented). + /// + /// The operator keyword. + /// The operands accumulated since the previous operator. + /// + /// Thrown when a recognized operator's operand count/type does not match its documented + /// requirement. + /// + /// + /// Propagated from (an unsupported font) or + /// (a defined but unsupported text-rendering mode). + /// + private void DispatchOperator(string operatorName, List operands) + { + switch (operatorName) + { + // Graphics-state operators (PdfDocument.GraphicsState.cs). + case "q": + RequireOperandCount(operands, "q", 0); + OpPushGraphicsState(); + break; + case "Q": + RequireOperandCount(operands, "Q", 0); + OpPopGraphicsState(); + break; + case "cm": + OpConcatMatrix(operands); + break; + case "w": + OpSetLineWidth(operands); + break; + case "J": + OpSetLineCap(operands); + break; + case "j": + OpSetLineJoin(operands); + break; + case "M": + OpSetMiterLimit(operands); + break; + case "d": + OpSetDashPattern(operands); + break; + + // Path-construction operators (PdfDocument.PathOps.cs). + case "m": + OpMoveTo(operands); + break; + case "l": + OpLineTo(operands); + break; + case "c": + OpCurveTo(operands); + break; + case "v": + OpCurveToV(operands); + break; + case "y": + OpCurveToY(operands); + break; + case "h": + OpClosePath(operands); + break; + case "re": + OpRectangle(operands); + break; + + // Path-painting operators (PdfDocument.PathOps.cs). + case "f": + case "F": + RequireOperandCount(operands, operatorName, 0); + PaintCurrentPath(fill: true, FillRule.NonZero, stroke: false, closeFirst: false); + break; + case "f*": + RequireOperandCount(operands, "f*", 0); + PaintCurrentPath(fill: true, FillRule.EvenOdd, stroke: false, closeFirst: false); + break; + case "S": + RequireOperandCount(operands, "S", 0); + PaintCurrentPath(fill: false, FillRule.NonZero, stroke: true, closeFirst: false); + break; + case "s": + RequireOperandCount(operands, "s", 0); + PaintCurrentPath(fill: false, FillRule.NonZero, stroke: true, closeFirst: true); + break; + case "B": + RequireOperandCount(operands, "B", 0); + PaintCurrentPath(fill: true, FillRule.NonZero, stroke: true, closeFirst: false); + break; + case "B*": + RequireOperandCount(operands, "B*", 0); + PaintCurrentPath(fill: true, FillRule.EvenOdd, stroke: true, closeFirst: false); + break; + case "b": + RequireOperandCount(operands, "b", 0); + PaintCurrentPath(fill: true, FillRule.NonZero, stroke: true, closeFirst: true); + break; + case "b*": + RequireOperandCount(operands, "b*", 0); + PaintCurrentPath(fill: true, FillRule.EvenOdd, stroke: true, closeFirst: true); + break; + case "n": + RequireOperandCount(operands, "n", 0); + PaintCurrentPath(fill: false, FillRule.NonZero, stroke: false, closeFirst: false); + break; + + // Device color operators (PdfDocument.Color.cs). + case "g": + OpSetGrayFill(operands); + break; + case "G": + OpSetGrayStroke(operands); + break; + case "rg": + OpSetRgbFill(operands); + break; + case "RG": + OpSetRgbStroke(operands); + break; + case "k": + OpSetCmykFill(operands); + break; + case "K": + OpSetCmykStroke(operands); + break; + case "cs": + OpSetColorSpaceFill(operands); + break; + case "CS": + OpSetColorSpaceStroke(operands); + break; + case "sc": + case "scn": + OpSetColorFill(operands, operatorName); + break; + case "SC": + case "SCN": + OpSetColorStroke(operands, operatorName); + break; + + // Image XObject operator (PdfDocument.Images.cs). + case "Do": + OpDrawXObject(operands); + break; + + // Text object operators (PdfDocument.Text.cs). + case "BT": + RequireOperandCount(operands, "BT", 0); + OpBeginText(); + break; + case "ET": + RequireOperandCount(operands, "ET", 0); + OpEndText(); + break; + + // Text-state operators (PdfDocument.Text.cs). + case "Tc": + OpSetCharSpacing(operands); + break; + case "Tw": + OpSetWordSpacing(operands); + break; + case "Tz": + OpSetHorizontalScaling(operands); + break; + case "TL": + OpSetLeading(operands); + break; + case "Tf": + OpSetFont(operands); + break; + case "Tr": + OpSetTextRenderMode(operands); + break; + case "Ts": + OpSetTextRise(operands); + break; + + // Text-positioning operators (PdfDocument.Text.cs). + case "Td": + OpTextMoveTo(operands); + break; + case "TD": + OpTextMoveToSetLeading(operands); + break; + case "Tm": + OpSetTextMatrix(operands); + break; + case "T*": + RequireOperandCount(operands, "T*", 0); + OpTextNextLine(); + break; + + // Text-showing operators (PdfDocument.Text.cs). + case "Tj": + OpShowText(operands); + break; + case "'": + OpShowTextNextLine(operands); + break; + case "\"": + OpShowTextNextLineWithSpacing(operands); + break; + case "TJ": + OpShowTextArray(operands); + break; + + // Type 3 glyph-description operators (PdfDocument.Fonts.Type3.cs) - parsed/validated + // for operand-count/type only; never consulted for layout (see OpType3SetWidth's own + // remarks). + case "d0": + OpType3SetWidth(operands); + break; + case "d1": + OpType3SetWidthAndBBox(operands); + break; + + default: + // Any other keyword (gs, W/W*, sh, BI/ID/EI, Tc/Td/.../TJ's own undefined + // siblings, or any other undefined keyword) is silently skipped - out of this + // phase's scope (shading/patterns, ExtGState, clipping) per this phase's + // documented lenient-consumer posture toward unrecognized operators. + break; + } + } + + /// + /// Resolves a leaf page node's /Contents entry to fully decoded content-stream + /// bytes, whether declared as a single stream or an array of streams (concatenated with a + /// single space byte between each, per the PDF specification's own requirement that a + /// multi-stream /Contents array never allow adjacent tokens from different streams + /// to merge). + /// + /// The already-resolved leaf page dictionary. + /// + /// The concatenated, fully decoded content bytes, or an empty array when the page + /// declares no /Contents at all (a page with no content is valid: a blank page). + /// + /// + /// Thrown when /Contents is neither a stream nor an array of streams, or when an + /// array entry does not resolve to a stream. + /// + private byte[] ResolvePageContentBytes(PdfObject pageNode) + { + var contents = pageNode.Get("Contents"); + if (contents is null) + { + return []; + } + + var resolved = Resolve(contents); + if (resolved.Kind == PdfKind.Stream) + { + return GetStreamDecodedBytes(resolved); + } + + if (resolved.Kind == PdfKind.Array) + { + var parts = new List(); + foreach (var item in resolved.Items) + { + var streamObject = Resolve(item); + if (streamObject.Kind != PdfKind.Stream) + { + throw new InvalidDataException("/Contents array entry does not resolve to a stream."); + } + + if (parts.Count > 0) + { + parts.Add((byte)' '); + } + + parts.AddRange(GetStreamDecodedBytes(streamObject)); + } + + return [.. parts]; + } + + throw new InvalidDataException("/Contents must be a stream or an array of streams."); + } +} diff --git a/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Encryption.cs b/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Encryption.cs new file mode 100644 index 00000000..e5407d54 --- /dev/null +++ b/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Encryption.cs @@ -0,0 +1,1121 @@ +// cspell:ignore AESV StdCF PubSec sAlT SASLprep +using System.Security.Cryptography; +using System.Text; +using DemaConsulting.CanvasNet.Codecs; + +namespace DemaConsulting.CanvasNet.Pdf; + +// S4790 ("Use a stronger hashing algorithm") is suppressed for this entire file: MD5 and RC4 are +// not a free security choice here - they are mandated verbatim by the PDF "Standard" security +// handler's own specification (ISO 32000-1 Algorithms 1/2/4/5) for revisions 2-4, which this file +// exists specifically to interoperate with. A real-world, already-encrypted document's own +// key-derivation algorithm cannot be substituted for a stronger one by this reader. +#pragma warning disable S4790 +public sealed partial class PdfDocument +{ + /// + /// The crypt method this document's strings and streams are encrypted with, once + /// has determined a supported shape and + /// derived a file encryption key. (the default) means + /// the document is not encrypted at all, in which case is + /// always and no decryption is ever attempted. + /// + private enum EncryptionCipher + { + /// The document is not encrypted. + None, + + /// RC4 (/V 1 or /V 2), per-object key derived by Algorithm 1. + Rc4, + + /// AES-128-CBC (/V 4, /CFM /AESV2), per-object key derived by Algorithm 1. + Aes128, + + /// + /// AES-256-CBC (/V 5, /R 5, /CFM /AESV3), using the file + /// encryption key directly for every string/stream (no per-object derivation - see + /// ISO 32000-2 Algorithm 2.A's own remarks). + /// + Aes256, + } + + /// + /// The 32-byte "standard password padding string" ISO 32000-1 Algorithm 2 step (a) + /// requires every password to be padded/truncated against before hashing. For an empty + /// password (the default, null-password path), the padded password is simply this + /// constant, verbatim; see for the general case. + /// + private static readonly byte[] PasswordPadding = + [ + 0x28, 0xBF, 0x4E, 0x5E, 0x4E, 0x75, 0x8A, 0x41, 0x64, 0x00, 0x4E, 0x56, 0xFF, 0xFA, 0x01, 0x08, + 0x2E, 0x2E, 0x00, 0xB6, 0xD0, 0x68, 0x3E, 0x80, 0x2F, 0x0C, 0xA9, 0xFE, 0x64, 0x53, 0x69, 0x7A, + ]; + + /// + /// The 4 literal ASCII bytes sAlT ISO 32000-1 Algorithm 1 appends to the per-object + /// key material specifically when the crypt filter method is AESV2 (never for + /// plain RC4). + /// + private static readonly byte[] AesV2KeySalt = [0x73, 0x41, 0x6C, 0x54]; + + /// + /// Pads/truncates to exactly 32 bytes per ISO 32000-1 + /// Algorithm 2 step (a): the password's own bytes, followed by as many leading bytes of + /// as are needed to reach 32 (or the password's own bytes + /// truncated to 32, if it is already that long or longer). When + /// is empty, this reproduces + /// verbatim - preserving byte-for-byte identical behavior for the null-password path. + /// + /// The already-encoded (and, for a real password, already + /// 127-byte-truncated) password bytes to pad/truncate. + /// The resulting exactly-32-byte padded password. + private static byte[] PadPasswordBytes(byte[] passwordBytes) + { + var padded = new byte[32]; + var copyLength = Math.Min(passwordBytes.Length, 32); + passwordBytes.AsSpan(0, copyLength).CopyTo(padded); + if (copyLength < 32) + { + PasswordPadding.AsSpan(0, 32 - copyLength).CopyTo(padded.AsSpan(copyLength)); + } + + return padded; + } + + /// + /// Encodes for an /R 2-4 document per + /// PDFDocEncoding's ASCII-range subset (ISO 32000-1 7.6.3.3): each character is encoded as + /// a single Latin-1 byte, which is identical to PDFDocEncoding for every character in the + /// ASCII range (0-127). + /// + /// + /// Scope boundary: full PDFDocEncoding (which remaps several bytes in the + /// 128-255 range to specific Unicode characters outside Latin-1's own mapping) is not + /// implemented - a password containing any character outside ASCII therefore cannot be + /// correctly encoded, so this method fails closed with + /// rather than silently producing wrong key + /// material from an incorrect encoding. + /// + /// The caller-supplied password. + /// The password's Latin-1-encoded bytes, truncated to at most 127 bytes. + /// + /// Thrown when contains any character outside ASCII (0-127). + /// + private static byte[] EncodeR2R4PasswordBytes(string password) + { + if (password.Any(c => c > 0x7F)) + { + throw new UnsupportedImageFeatureException( + "pdf-encrypted-password-non-ascii", + "The supplied password contains a character outside ASCII (0-127), which is not supported for an /R 2-4 encrypted document (only PDFDocEncoding's ASCII-range subset is supported)."); + } + + var bytes = Encoding.Latin1.GetBytes(password); + return bytes.Length > 127 ? bytes[..127] : bytes; + } + + /// + /// Encodes for an /R 5 document per ISO 32000-2 + /// Algorithm 2.A step (a): UTF-8, truncated to at most 127 bytes. + /// + /// + /// Scope boundary: SASLprep/Unicode normalization (ISO 32000-2 7.6.4.3.4) + /// is not applied - an ordinary ASCII password is unaffected (UTF-8 and SASLprep agree for + /// every ASCII character), but a password containing combining characters or other + /// normalization-sensitive Unicode may not authenticate against a document produced by a + /// writer that does apply SASLprep. This is an intentional, narrower scope than the full + /// specification, not a defect. + /// + /// The caller-supplied password. + /// The password's UTF-8-encoded bytes, truncated to at most 127 bytes. + private static byte[] EncodeR5PasswordBytes(string password) + { + var bytes = Encoding.UTF8.GetBytes(password); + return bytes.Length > 127 ? bytes[..127] : bytes; + } + + /// + /// The resolved file (or, for AES-256/R5, directly-usable) encryption key, set once by + /// when the trailer declares a supported + /// /Encrypt shape, or left for an unencrypted document. + /// + private byte[]? _encryptionKey; + + /// + /// The crypt method every string/stream is encrypted with, set alongside + /// . Defaults to for an + /// unencrypted document. + /// + private EncryptionCipher _encryptionCipher; + + /// + /// Detects and, when a supported shape is declared, authenticates an encrypted document's + /// user or owner password and derives its file encryption key, so every subsequent + /// indirect-object string and stream read (see PdfDocument.Xref.cs's + /// ParseIndirectObjectAt/GetStreamRawBytes) can transparently decrypt its + /// bytes before any of this class's other parsing logic ever sees them. + /// + /// + /// + /// This method resolves the trailer's own /Encrypt entry via + /// - which both parses and caches that object - before + /// is ever set, so its own /O//U//OE//UE strings are never + /// mistakenly decrypted. By the time this method runs, other objects may already be + /// cached pre-key too - the trailer's own /Root Catalog (resolved by the + /// constructor's own IsValidCatalogRoot check before this method ever runs) and, + /// on the linear-scan fallback path, every object in the document (resolved by + /// BuildLinearScanFallback's own scan) - and none of those pre-key cache entries' + /// strings were ever decrypted either. Once is established + /// below, this method's own + /// step discards every cached object except the /Encrypt dictionary's own, so + /// each one is correctly re-resolved - and, this time, decrypted - the next time + /// anything asks for it. See + /// PdfDocument_Open_Encrypted_CatalogOwnStringIsDecrypted for the regression test + /// covering this. + /// + /// + /// Password authentication: when is + /// , only the empty user password is authenticated - byte-for-byte + /// the same behavior as before this parameter existed. When + /// is non-null, it is tried first as the user password + /// (the same Algorithm 2 + Algorithm 4/5/2.A path, except the password's own encoded + /// bytes are padded/truncated and hashed instead of always the empty-password padding + /// constant); if that does not authenticate, the same supplied password is tried as + /// the owner password instead - ISO 32000-1 Algorithm 3 for /R 2-4 + /// (recovering the padded user password from /O, then deriving and + /// authenticating a candidate file key from it), or the owner-password variant of ISO + /// 32000-2 Algorithm 2.A for /R 5 (validating against, and unwrapping /OE + /// instead of /UE). A password is encoded via Latin-1 (PDFDocEncoding's ASCII + /// subset) for /R 2-4 documents - throwing + /// with feature + /// pdf-encrypted-password-non-ascii for any non-ASCII character, since full + /// PDFDocEncoding is out of this phase's scope - or via UTF-8 (no SASLprep/Unicode + /// normalization, also an intentional scope boundary) for /R 5 documents; either + /// way the encoded bytes are truncated to at most 127 bytes before any hashing. If + /// neither the user nor the owner attempt authenticates, this method throws + /// with feature + /// pdf-encrypted-incorrect-password. + /// + /// + /// Scope boundary: only the /Filter /Standard security handler + /// is supported, and only RC4 (/V 1//V 2), AES-128 (/V 4/ + /// /CFM /AESV2), and AES-256 using the simpler R5 key derivation (/V 5/ + /// /R 5//CFM /AESV3) are supported. Every other shape (a + /// non-/Standard filter, /R 6's "hardened hash" key derivation, a crypt + /// filter other than the standard /StdCF) fails closed with + /// and its own distinguishable + /// token. + /// + /// + /// The document's resolved trailer dictionary. + /// + /// An optional password to authenticate with - see this method's own remarks above for the + /// full user-then-owner attempt order and encoding scope boundaries. + /// (the default) authenticates only the empty user password. + /// + /// + /// Thrown when the /Encrypt dictionary (or a required entry within it, or the + /// trailer's own /ID) is malformed - present but not the shape the specification + /// requires - as opposed to merely declaring an unsupported-but-well-formed feature. + /// + /// + /// Thrown when the document declares a security handler, crypt filter, or revision this + /// phase does not support; when is and + /// the empty user password does not authenticate (feature + /// pdf-encrypted-password-required); when is non-null + /// and authenticates as neither the user nor the owner password (feature + /// pdf-encrypted-incorrect-password); or when is + /// non-null, contains a non-ASCII character, and the document is /R 2-4 + /// (feature pdf-encrypted-password-non-ascii). + /// + private void InitializeEncryption(PdfObject trailer, string? password = null) + { + var encryptEntry = trailer.Get("Encrypt"); + if (encryptEntry is null) + { + return; + } + + // Resolving (and thereby caching) the /Encrypt dictionary's own object now, before + // _encryptionKey is ever assigned, is what keeps its own /O, /U, /OE, /UE strings from + // ever being mistakenly decrypted later - see this method's own remarks above. + var encryptDict = Resolve(encryptEntry); + if (encryptDict.Kind != PdfKind.Dictionary && encryptDict.Kind != PdfKind.Stream) + { + throw new InvalidDataException("Trailer /Encrypt does not resolve to a dictionary."); + } + + var filter = GetNameValue(encryptDict, "Filter") + ?? throw new InvalidDataException("Encrypt dictionary is missing required /Filter."); + if (filter != "Standard") + { + throw new UnsupportedImageFeatureException( + $"pdf-encrypted-filter-{filter}", + $"Encrypted PDF security handler '/Filter /{filter}' is not supported (only /Standard is supported)."); + } + + var version = GetRequiredIntEntry(encryptDict, "V"); + var revision = GetRequiredIntEntry(encryptDict, "R"); + var lengthBits = GetIntEntry(encryptDict, "Length", 40); + var keyLengthBytes = version == 1 ? 5 : lengthBits / 8; + + // /V 5 (AES-256) never uses keyLengthBytes/lengthBits: its 32-byte file key is always + // derived via ISO 32000-2 Algorithm 2.A (see the "case 5" branch below), independent of + // whatever /Length value (conventionally 256 bits, outside the legacy 40-128 bit range) + // the Encrypt dictionary happens to declare - so the ISO 32000-1 ยง7.6.2 range check below + // applies only to the legacy /V 1/2/4 (RC4/AESV2) key-length path that actually consumes it. + if (version != 5 && keyLengthBytes is < 5 or > 16) + { + throw new InvalidDataException( + $"Encrypt dictionary's /Length entry ({lengthBits} bits) must be between 40 and 128 bits inclusive."); + } + + var oBytes = GetRequiredBytesEntry(encryptDict, "O"); + var uBytes = GetRequiredBytesEntry(encryptDict, "U"); + var permissions = GetRequiredIntEntry(encryptDict, "P"); + var encryptMetadata = GetBooleanEntry(encryptDict, "EncryptMetadata", true); + var idBytes = GetDocumentIdBytes(trailer); + + switch (version) + { + case 1: + case 2: + InitializeRc4OrAesV2Encryption( + EncryptionCipher.Rc4, + revision, + keyLengthBytes, + oBytes, + uBytes, + permissions, + encryptMetadata, + idBytes, + password); + break; + + case 4: + ValidateStandardCryptFilterName(encryptDict, "AESV2"); + InitializeRc4OrAesV2Encryption( + EncryptionCipher.Aes128, + revision, + keyLengthBytes, + oBytes, + uBytes, + permissions, + encryptMetadata, + idBytes, + password); + break; + + case 5: + if (revision == 6) + { + throw new UnsupportedImageFeatureException( + "pdf-encrypted-r6-hardened-hash", + "AES-256 /R 6 (ISO 32000-2 Annex C 'hardened hash' key derivation) is not supported; only /R 5 is supported."); + } + + if (revision != 5) + { + throw new UnsupportedImageFeatureException( + $"pdf-encrypted-r-{revision}", + $"Encrypted PDF /V 5 with /R {revision} is not supported."); + } + + ValidateStandardCryptFilterName(encryptDict, "AESV3"); + var ueBytes = GetRequiredBytesEntry(encryptDict, "UE"); + + byte[] r5PasswordBytes = password is null ? [] : EncodeR5PasswordBytes(password); + var r5FileKey = TryComputeFileKeyAlgorithm2A(r5PasswordBytes, uBytes, ueBytes); + if (r5FileKey is null && password is not null) + { + var oeBytes = GetRequiredBytesEntry(encryptDict, "OE"); + r5FileKey = TryComputeFileKeyAlgorithm2AOwnerPassword(r5PasswordBytes, oBytes, oeBytes, uBytes); + } + + if (r5FileKey is null) + { + throw new UnsupportedImageFeatureException( + password is null ? "pdf-encrypted-password-required" : "pdf-encrypted-incorrect-password", + password is null + ? "This encrypted PDF document requires a non-empty user password, which was not supplied." + : "The supplied password does not authenticate as either the user or the owner password for this encrypted PDF document."); + } + + _encryptionKey = r5FileKey; + _encryptionCipher = EncryptionCipher.Aes256; + break; + + default: + throw new UnsupportedImageFeatureException( + $"pdf-encrypted-v-{version}", + $"Encrypted PDF /V {version} is not supported."); + } + + // Every object resolved and cached so far - by BuildLinearScanFallback's scan of every + // object number in the document (fallback path, where encryption cannot be initialized + // until a usable trailer has been recovered), or by this method's own /CF/StdCF lookups + // above - was cached before _encryptionKey existed, so none of those objects' own + // strings were ever decrypted. Discard every cached object except the /Encrypt + // dictionary's own (which + // must never be decrypted - see this method's remarks above) so each is correctly + // re-resolved, and decrypted, the next time anything asks for it. + InvalidateObjectCacheExceptEncryptDictionary(encryptEntry); + } + + /// + /// Clears of every object cached before + /// was established - by BuildLinearScanFallback's scan of every object number in + /// the document (fallback path only; the normal path initializes encryption before + /// resolving /Root), or by this class's own /CF//StdCF lookups - + /// except the /Encrypt + /// dictionary's own object (identified via 's own + /// reference, not a redundant guard field), which must remain exactly as originally + /// cached (pre-key, hence never decrypted) per this file's own + /// /O//U//OE//UE invariant. + /// + /// The trailer's own, unresolved /Encrypt entry. + private void InvalidateObjectCacheExceptEncryptDictionary(PdfObject encryptEntry) + { + if (encryptEntry.Kind == PdfKind.Reference && + _objectCache.TryGetValue(encryptEntry.RefNumber, out var encryptObject)) + { + _objectCache.Clear(); + _objectCache[encryptEntry.RefNumber] = encryptObject; + return; + } + + _objectCache.Clear(); + } + + /// + /// Shared RC4/AES-128 initialization path for /V 1//V 2 (RC4) and /V 4 + /// (AESV2): derives a file key candidate via ISO 32000-1 Algorithm 2 and authenticates it + /// against /U via Algorithm 4 (revision 2) or Algorithm 5 (revision 3/4), first + /// treating as the user password, then - if that does not + /// authenticate and is non-null - recovering the padded user + /// password from /O via ISO 32000-1 Algorithm 3 (treating + /// as the owner password) and re-deriving/re-authenticating a second candidate from that. + /// Throws when neither attempt authenticates. + /// + /// The resolved cipher to use for every subsequent string/stream decrypt. + /// The Encrypt dictionary's /R entry. + /// The file key length in bytes. + /// The Encrypt dictionary's raw /O entry bytes. + /// The Encrypt dictionary's raw /U entry bytes. + /// The Encrypt dictionary's /P entry. + /// The Encrypt dictionary's /EncryptMetadata entry. + /// The trailer's /ID array's first element's raw bytes. + /// + /// An optional password to authenticate with - see 's + /// own remarks for the full user-then-owner attempt order and encoding scope boundaries. + /// + /// + /// Thrown when is and the empty user + /// password does not authenticate (feature pdf-encrypted-password-required), or + /// when is non-null and authenticates as neither the user nor + /// the owner password (feature pdf-encrypted-incorrect-password), or when + /// contains a non-ASCII character (feature + /// pdf-encrypted-password-non-ascii). + /// + private void InitializeRc4OrAesV2Encryption( + EncryptionCipher cipher, + int revision, + int keyLengthBytes, + byte[] oBytes, + byte[] uBytes, + int permissions, + bool encryptMetadata, + byte[] idBytes, + string? password) + { + var paddedPasswordBytes = password is null ? PasswordPadding : PadPasswordBytes(EncodeR2R4PasswordBytes(password)); + + var fileKey = ComputeFileKeyAlgorithm2(paddedPasswordBytes, oBytes, permissions, idBytes, keyLengthBytes, revision, encryptMetadata); + var authenticated = TryAuthenticateUserPasswordAlgorithm45(fileKey, uBytes, idBytes, revision); + + if (!authenticated && password is not null) + { + // The same supplied password did not authenticate as the user password - try it as + // the owner password instead: recover the padded user password from /O (Algorithm 3), + // then re-derive and re-authenticate a candidate file key from it. The recovered + // bytes are already exactly 32 bytes (Algorithm 3's own output) - do not re-pad them. + var recoveredPaddedUserPassword = RecoverPaddedUserPasswordAlgorithm3(paddedPasswordBytes, oBytes, keyLengthBytes, revision); + var ownerFileKey = ComputeFileKeyAlgorithm2(recoveredPaddedUserPassword, oBytes, permissions, idBytes, keyLengthBytes, revision, encryptMetadata); + if (TryAuthenticateUserPasswordAlgorithm45(ownerFileKey, uBytes, idBytes, revision)) + { + fileKey = ownerFileKey; + authenticated = true; + } + } + + if (!authenticated) + { + throw new UnsupportedImageFeatureException( + password is null ? "pdf-encrypted-password-required" : "pdf-encrypted-incorrect-password", + password is null + ? "This encrypted PDF document requires a non-empty user password, which was not supplied." + : "The supplied password does not authenticate as either the user or the owner password for this encrypted PDF document."); + } + + _encryptionKey = fileKey; + _encryptionCipher = cipher; + } + + /// + /// Validates that both /StmF and /StrF name the standard /StdCF crypt + /// filter, and that /CF/StdCF/CFM names - the only + /// crypt-filter shape this phase supports for /V 4//V 5 (a custom-named crypt + /// filter, /Identity, or a mismatched /CFM all fail closed with their own + /// distinguishable token). + /// + /// + /// Thrown when /StmF//StrF name anything other than /StdCF, or + /// /CF/StdCF/CFM names anything other than . + /// + private void ValidateStandardCryptFilterName(PdfObject encryptDict, string expectedCfm) + { + foreach (var key in new[] { "StmF", "StrF" }) + { + var name = GetNameValue(encryptDict, key) ?? "Identity"; + if (name != "StdCF") + { + throw new UnsupportedImageFeatureException( + $"pdf-encrypted-crypt-filter-{name}", + $"Encrypted PDF /{key} crypt filter '/{name}' is not supported (only /StdCF is supported)."); + } + } + + var cryptFiltersEntry = encryptDict.Get("CF") + ?? throw new InvalidDataException("Encrypt dictionary is missing required /CF."); + var cryptFilters = Resolve(cryptFiltersEntry); + var stdCfEntry = cryptFilters.Get("StdCF") + ?? throw new InvalidDataException("Encrypt dictionary is missing required /CF/StdCF."); + var stdCf = Resolve(stdCfEntry); + var cfm = GetNameValue(stdCf, "CFM") + ?? throw new InvalidDataException("Encrypt dictionary's /CF/StdCF is missing required /CFM."); + if (cfm != expectedCfm) + { + throw new UnsupportedImageFeatureException( + $"pdf-encrypted-cfm-{cfm}", + $"Encrypted PDF crypt filter method '/CFM /{cfm}' is not supported (expected /{expectedCfm})."); + } + } + + /// + /// Computes the document's file encryption key per ISO 32000-1 Algorithm 2 steps (b)-(e), + /// given an already-padded 32-byte password (step (a) - see ). + /// + /// + /// Algorithm 2 alone never fails - it always produces a key, regardless of + /// whether is actually correct; only Algorithm + /// 4/5/2.A's separate comparison against /U can detect a wrong password (see + /// ). + /// + /// + /// The already-padded/truncated-to-32-bytes password (step (a)): either + /// verbatim for an empty password, the result of + /// for a real supplied password, or - for the owner-password + /// recovery path - 's own already-32-byte + /// output (which must not be re-padded). + /// + /// The Encrypt dictionary's raw /O entry bytes. + /// The Encrypt dictionary's /P entry, as a signed 32-bit integer. + /// The trailer's /ID array's first element's raw bytes. + /// The file key length in bytes (/Length in bits, divided by 8). + /// The Encrypt dictionary's /R entry. + /// The Encrypt dictionary's /EncryptMetadata entry (default ). + /// The -byte file encryption key. + private static byte[] ComputeFileKeyAlgorithm2( + byte[] paddedPasswordBytes, + byte[] oBytes, + int permissions, + byte[] idBytes, + int keyLengthBytes, + int revision, + bool encryptMetadata) + { + // Step (b)-(c): build the MD5 input (padded password + /O + /P as 4-byte little-endian + // signed integer + the first /ID element's raw bytes + 0xFFFFFFFF when revision >= 4 and + // /EncryptMetadata is explicitly false) and hash it. + using var input = new MemoryStream(); + input.Write(paddedPasswordBytes); + input.Write(oBytes); + input.Write([(byte)permissions, (byte)(permissions >> 8), (byte)(permissions >> 16), (byte)(permissions >> 24)]); + input.Write(idBytes); + if (revision >= 4 && !encryptMetadata) + { + input.Write([0xFF, 0xFF, 0xFF, 0xFF]); + } + + var digest = MD5.HashData(input.ToArray()); + + // Step (d): revision 3 and above additionally re-hashes the first keyLengthBytes of the + // previous digest, 50 times over. + digest = Rehash50RoundsIfRevisionAtLeast3(digest, keyLengthBytes, revision); + + // Step (e): the file encryption key is the first keyLengthBytes of the final digest. + return digest.AsSpan(0, keyLengthBytes).ToArray(); + } + + /// + /// Shared "re-hash the first of + /// 50 times over" step used identically by both ISO 32000-1 Algorithm 2 step (d) (file key + /// derivation) and Algorithm 3 step (c) (owner-password recovery) for revision 3 and above + /// - revision 2 never re-hashes, returning unchanged. + /// + /// The initial MD5 digest. + /// The number of leading bytes of re-hashed each round. + /// The Encrypt dictionary's /R entry. + /// , unchanged for revision 2, or re-hashed 50 times for revision 3 and above. + private static byte[] Rehash50RoundsIfRevisionAtLeast3(byte[] digest, int keyLengthBytes, int revision) + { + if (revision >= 3) + { + for (var i = 0; i < 50; i++) + { + digest = MD5.HashData(digest.AsSpan(0, keyLengthBytes)); + } + } + + return digest; + } + + + /// + /// Checks whether authenticates as the user password for + /// revisions 2-4 by recomputing the expected /U value from it (ISO 32000-1 + /// Algorithm 4 for revision 2, Algorithm 5 for revision 3/4) and comparing it against the + /// document's actual /U entry. + /// + /// + /// + /// Algorithm 4/5 always hashes/encrypts the literal 32-byte padding string + /// (), never the actual supplied password's own bytes - + /// per ISO 32000-1 7.6.3.3/7.6.3.4, the password's own bytes only ever feed into + /// Algorithm 2's file-key derivation (), not into + /// this comparison. This method therefore needs no password-bytes parameter of its + /// own - only the candidate to verify. + /// + /// + /// Per the specification, only the first 16 of /U's 32 bytes are compared for + /// revision 3/4 (the trailing 16 bytes are producer-defined padding, not a + /// deterministic function of the key) - comparing all 32 would reject documents + /// produced by a conforming writer using a different padding convention. + /// + /// + /// The candidate file encryption key to verify. + /// The Encrypt dictionary's raw /U entry bytes. + /// The trailer's /ID array's first element's raw bytes. + /// The Encrypt dictionary's /R entry. + /// + /// when authenticates against + /// ; otherwise. + /// + /// + /// Thrown when is shorter than the specification requires + /// (32 bytes for revision 2, 16 bytes for revision 3/4). + /// + private static bool TryAuthenticateUserPasswordAlgorithm45(byte[] fileKey, byte[] uBytes, byte[] idBytes, int revision) + { + bool authenticated; + if (revision == 2) + { + // Algorithm 4: RC4-encrypt the padding string directly with the file key, and compare + // the full 32 bytes. + if (uBytes.Length < 32) + { + throw new InvalidDataException("Encrypt dictionary's /U entry is too short for revision 2."); + } + + var expected = Rc4Transform(fileKey, PasswordPadding); + authenticated = expected.AsSpan().SequenceEqual(uBytes.AsSpan(0, 32)); + } + else + { + // Algorithm 5: MD5-hash(padding string + ID bytes), RC4-encrypt with the file key, + // then 19 further rounds of RC4 with the key XORed byte-wise with the round number + // (1-19), comparing only the first 16 of /U's 32 bytes. + if (uBytes.Length < 16) + { + throw new InvalidDataException("Encrypt dictionary's /U entry is too short for revision 3/4."); + } + + using var hashInput = new MemoryStream(); + hashInput.Write(PasswordPadding); + hashInput.Write(idBytes); + var result = MD5.HashData(hashInput.ToArray()); + result = Rc4Transform(fileKey, result); + for (var round = 1; round <= 19; round++) + { + var roundKey = new byte[fileKey.Length]; + for (var i = 0; i < fileKey.Length; i++) + { + roundKey[i] = (byte)(fileKey[i] ^ round); + } + + result = Rc4Transform(roundKey, result); + } + + authenticated = result.AsSpan().SequenceEqual(uBytes.AsSpan(0, 16)); + } + + return authenticated; + } + + /// + /// Recovers the padded user password from /O per ISO 32000-1 Algorithm 3's decrypt + /// direction, treating as the padded owner + /// password: MD5-hashes it (re-hashing 50 rounds for revision 3 and above, via the same + /// helper Algorithm 2 step (d) uses) to + /// derive the owner RC4 key, then RC4-decrypts /O with it - a single pass for + /// revision 2, or 20 rounds (round 19 down to round 0, round 0 using the unmodified owner + /// key, each other round's key XORed byte-wise with its own round number) for revision 3/4. + /// + /// The padded (32-byte) candidate owner password. + /// The Encrypt dictionary's raw /O entry bytes (32 bytes). + /// The file key length in bytes. + /// The Encrypt dictionary's /R entry. + /// + /// The recovered 32-byte padded user password - already exactly 32 bytes (Algorithm 3's + /// own output), so callers must feed it directly into + /// without re-padding it via + /// . + /// + private static byte[] RecoverPaddedUserPasswordAlgorithm3(byte[] paddedOwnerPasswordBytes, byte[] oBytes, int keyLengthBytes, int revision) + { + var digest = MD5.HashData(paddedOwnerPasswordBytes); + digest = Rehash50RoundsIfRevisionAtLeast3(digest, keyLengthBytes, revision); + var ownerKey = digest.AsSpan(0, keyLengthBytes).ToArray(); + + var result = oBytes; + if (revision == 2) + { + result = Rc4Transform(ownerKey, result); + } + else + { + for (var round = 19; round >= 0; round--) + { + var roundKey = new byte[ownerKey.Length]; + for (var i = 0; i < ownerKey.Length; i++) + { + roundKey[i] = (byte)(ownerKey[i] ^ round); + } + + result = Rc4Transform(roundKey, result); + } + } + + return result; + } + + /// + /// Computes the file encryption key for AES-256/R5 by trying + /// as the user password (ISO 32000-2 Algorithm 2.A): validates it against /U's + /// embedded validation salt, then - on success - unwraps /UE using a key derived + /// from /U's embedded key salt. + /// + /// + /// The UTF-8-encoded, 127-byte-truncated candidate user password (empty for the + /// null-password path, in which case the hash/intermediate-key computation below reduces + /// to simply SHA-256(salt)). + /// + /// + /// The Encrypt dictionary's 48-byte /U entry: 32 bytes of hash, 8 bytes of + /// validation salt, 8 bytes of key salt. + /// + /// The Encrypt dictionary's 32-byte /UE entry. + /// + /// The 32-byte file encryption key (used directly, with no further per-object derivation) + /// when authenticates against /U's validation + /// salt; when it does not. + /// + /// + /// Thrown when / are not the lengths + /// the specification requires. + /// + private static byte[]? TryComputeFileKeyAlgorithm2A(byte[] passwordBytes, byte[] uBytes, byte[] ueBytes) + { + if (uBytes.Length < 48) + { + throw new InvalidDataException("Encrypt dictionary's /U entry must be at least 48 bytes for /V 5."); + } + + if (ueBytes.Length != 32) + { + throw new InvalidDataException("Encrypt dictionary's /UE entry must be exactly 32 bytes for /V 5."); + } + + var hash = uBytes.AsSpan(0, 32); + var validationSalt = uBytes.AsSpan(32, 8); + var keySalt = uBytes.AsSpan(40, 8); + + // Step 1: SHA-256(password + validationSalt) - authenticate by comparing against /U's + // own embedded hash. + var computedHash = SHA256.HashData([.. passwordBytes, .. validationSalt]); + if (!computedHash.AsSpan().SequenceEqual(hash)) + { + return null; + } + + // Step 2: the intermediate key is SHA-256(password + keySalt). + var intermediateKey = SHA256.HashData([.. passwordBytes, .. keySalt]); + + // Step 3: the file encryption key is AES-256-CBC-decrypt(/UE) using the intermediate key, + // a zero IV, and no padding (/UE decrypts to exactly the raw 32-byte file key). + return DecryptAesCbc(intermediateKey, new byte[16], ueBytes, 0, ueBytes.Length, CipherMode.CBC, PaddingMode.None); + } + + /// + /// Computes the file encryption key for AES-256/R5 by trying + /// as the owner password - the owner-password variant of ISO 32000-2 Algorithm 2.A: the + /// hash/intermediate-key computation is the same shape as + /// 's user-password version, except the salts + /// come from /O instead of /U, the full 48-byte /U value (not a + /// sub-slice) is appended after the salt in both hash inputs, the validation hash is + /// compared against /O's own embedded hash instead of /U's, and /OE + /// (not /UE) is unwrapped to recover the file key. + /// + /// + /// The UTF-8-encoded, 127-byte-truncated candidate owner password. + /// + /// + /// The Encrypt dictionary's 48-byte /O entry: 32 bytes of hash, 8 bytes of + /// validation salt, 8 bytes of key salt. + /// + /// The Encrypt dictionary's 32-byte /OE entry. + /// The Encrypt dictionary's full, raw 48-byte /U entry. + /// + /// The 32-byte file encryption key when authenticates + /// against /O's validation hash; when it does not. + /// + /// + /// Thrown when // + /// are not the lengths the specification requires. + /// + private static byte[]? TryComputeFileKeyAlgorithm2AOwnerPassword(byte[] passwordBytes, byte[] oBytes, byte[] oeBytes, byte[] uBytes) + { + if (oBytes.Length < 48) + { + throw new InvalidDataException("Encrypt dictionary's /O entry must be at least 48 bytes for /V 5."); + } + + if (oeBytes.Length != 32) + { + throw new InvalidDataException("Encrypt dictionary's /OE entry must be exactly 32 bytes for /V 5."); + } + + if (uBytes.Length < 48) + { + throw new InvalidDataException("Encrypt dictionary's /U entry must be at least 48 bytes for /V 5."); + } + + var hash = oBytes.AsSpan(0, 32); + var validationSalt = oBytes.AsSpan(32, 8); + var keySalt = oBytes.AsSpan(40, 8); + var fullU = uBytes.AsSpan(0, 48); + + // Step 1: SHA-256(password + validationSalt + U(48 bytes)) - authenticate by comparing + // against /O's own embedded hash. + var computedHash = SHA256.HashData([.. passwordBytes, .. validationSalt, .. fullU]); + if (!computedHash.AsSpan().SequenceEqual(hash)) + { + return null; + } + + // Step 2: the intermediate key is SHA-256(password + keySalt + U(48 bytes)). + var intermediateKey = SHA256.HashData([.. passwordBytes, .. keySalt, .. fullU]); + + // Step 3: the file encryption key is AES-256-CBC-decrypt(/OE) using the intermediate key, + // a zero IV, and no padding (/OE decrypts to exactly the raw 32-byte file key). + return DecryptAesCbc(intermediateKey, new byte[16], oeBytes, 0, oeBytes.Length, CipherMode.CBC, PaddingMode.None); + } + + /// + /// Computes a per-object encryption key per ISO 32000-1 Algorithm 1, used by RC4 and + /// AES-128/AESV2 (revisions 2-4). AES-256/R5 does not use this - it uses the file + /// encryption key directly for every object (see 's + /// own remarks). + /// + /// The file encryption key from . + /// The owning indirect object's object number. + /// The owning indirect object's generation number. + /// + /// for the AESV2 crypt filter, which additionally appends the + /// literal 4-byte sAlT suffix (); + /// for plain RC4. + /// + /// + /// The per-object key: the first min(fileKey.Length + 5, 16) bytes of + /// MD5(fileKey + objectNumber(3 bytes LE) + generation(2 bytes LE) [+ "sAlT"]). + /// + private static byte[] ComputeObjectKeyAlgorithm1(byte[] fileKey, int objectNumber, int generation, bool isAes) + { + using var input = new MemoryStream(); + input.Write(fileKey); + input.Write([(byte)objectNumber, (byte)(objectNumber >> 8), (byte)(objectNumber >> 16)]); + input.Write([(byte)generation, (byte)(generation >> 8)]); + if (isAes) + { + input.Write(AesV2KeySalt); + } + + var digest = MD5.HashData(input.ToArray()); + var keyLength = Math.Min(fileKey.Length + 5, 16); + return digest.AsSpan(0, keyLength).ToArray(); + } + + /// + /// Decrypts a single stream's raw (still-filtered) bytes, dispatching on + /// . Called by PdfDocument.Xref.cs's + /// GetStreamRawBytes, before the generic /Filter//DecodeParms pipeline + /// ever runs - so every downstream consumer (content-stream execution, object-stream + /// decompression, image-XObject decoding) sees already-plaintext filtered bytes, exactly + /// as it would for an unencrypted document. + /// + /// The stream's raw, still-encrypted bytes. + /// The owning indirect object's object number. + /// The owning indirect object's generation number. + /// + /// Thrown when AES-encrypted data is too short to contain its leading 16-byte IV, or when + /// the AES-CBC/PKCS7 padding is malformed (wrapped from ). + /// + private byte[] DecryptStreamBytes(byte[] data, int objectNumber, int generation) + { + if (data.Length == 0) + { + return data; + } + + switch (_encryptionCipher) + { + case EncryptionCipher.Rc4: + return Rc4Transform(ComputeObjectKeyAlgorithm1(_encryptionKey!, objectNumber, generation, isAes: false), data); + + case EncryptionCipher.Aes128: + { + var objectKey = ComputeObjectKeyAlgorithm1(_encryptionKey!, objectNumber, generation, isAes: true); + return DecryptIvPrefixedAesCbc(objectKey, data); + } + + case EncryptionCipher.Aes256: + return DecryptIvPrefixedAesCbc(_encryptionKey!, data); + + case EncryptionCipher.None: + default: + return data; + } + } + + /// + /// Decrypts strings found anywhere within a freshly-parsed top-level indirect object's + /// value, in place, recursing through (arrays) and + /// (dictionaries/streams) but never through + /// (an indirect reference is resolved, and therefore + /// decrypted if applicable, independently by its own call to ParseIndirectObjectAt + /// - following it here would either double-decrypt an already-cached object or recurse + /// into a different object entirely). + /// + /// The parsed value to decrypt strings within. + /// The owning indirect object's object number. + /// The owning indirect object's generation number. + private void DecryptStringsInPlace(PdfObject value, int objectNumber, int generation) + { + switch (value.Kind) + { + case PdfKind.String: + value.Bytes = DecryptStreamBytes(value.Bytes, objectNumber, generation); + break; + + case PdfKind.Array: + foreach (var item in value.Items) + { + DecryptStringsInPlace(item, objectNumber, generation); + } + + break; + + case PdfKind.Dictionary: + case PdfKind.Stream: + foreach (var entry in value.Entries.Values) + { + DecryptStringsInPlace(entry, objectNumber, generation); + } + + break; + + default: + // Null, Boolean, Number, Name, and Reference values carry no string bytes of + // their own to decrypt. + break; + } + } + + /// + /// Decrypts an {16-byte IV}{AES-CBC/PKCS7-encrypted ciphertext} byte layout - the + /// wire format every AESV2/AESV3-encrypted string/stream uses, regardless of key size. + /// + /// + /// Thrown when is shorter than the 16-byte IV, or the AES-CBC/ + /// PKCS7 padding is malformed. + /// + private static byte[] DecryptIvPrefixedAesCbc(byte[] key, byte[] data) + { + if (data.Length < 16) + { + throw new InvalidDataException("AES-encrypted data is too short to contain its leading 16-byte IV."); + } + + var iv = data.AsSpan(0, 16).ToArray(); + var cipherLength = data.Length - 16; + if (cipherLength == 0) + { + return []; + } + + return DecryptAesCbc(key, iv, data, 16, cipherLength, CipherMode.CBC, PaddingMode.PKCS7); + } + + /// + /// Thin wrapper around for one-shot CBC decryption, wrapping any + /// (for example from malformed PKCS7 padding) into + /// this class's own convention rather than letting an + /// unfamiliar exception type escape to the caller. + /// + /// + /// Thrown when decryption fails (propagated from a caught ). + /// + private static byte[] DecryptAesCbc(byte[] key, byte[] iv, byte[] data, int offset, int length, CipherMode mode, PaddingMode padding) + { + using var aes = Aes.Create(); + try + { + aes.Key = key; + aes.IV = iv; + aes.Mode = mode; + aes.Padding = padding; + using var decryptor = aes.CreateDecryptor(); + return decryptor.TransformFinalBlock(data, offset, length); + } + catch (CryptographicException ex) + { + throw new InvalidDataException("Encrypted PDF stream/string data is malformed (AES-CBC decryption failed).", ex); + } + } + + /// + /// A from-scratch, hand-rolled RC4 stream cipher (classic KSA/PRGA), since RC4 is not + /// available as a built-in primitive in modern .NET. RC4 is symmetric (the same operation + /// both encrypts and decrypts), so this single implementation backs every production + /// decrypt call site and every test-only encrypt-side helper alike. + /// + /// The RC4 key (1-256 bytes). + /// The data to transform. + /// A new array the same length as , XORed with the RC4 keystream. + private static byte[] Rc4Transform(byte[] key, byte[] data) + { + // Key-scheduling algorithm (KSA): build a permutation of 0-255 seeded by the key. + var state = new byte[256]; + for (var i = 0; i < 256; i++) + { + state[i] = (byte)i; + } + + var j = 0; + for (var i = 0; i < 256; i++) + { + j = (j + state[i] + key[i % key.Length]) & 0xFF; + (state[i], state[j]) = (state[j], state[i]); + } + + // Pseudo-random generation algorithm (PRGA): derive a keystream byte per input byte and + // XOR it directly into the output. + var output = new byte[data.Length]; + var x = 0; + j = 0; + for (var n = 0; n < data.Length; n++) + { + x = (x + 1) & 0xFF; + j = (j + state[x]) & 0xFF; + (state[x], state[j]) = (state[j], state[x]); + var keystreamByte = state[(state[x] + state[j]) & 0xFF]; + output[n] = (byte)(data[n] ^ keystreamByte); + } + + return output; + } + + /// Reads a required integer dictionary entry (resolving an indirect reference). + /// Thrown when the entry is absent, or present but not a number. + private int GetRequiredIntEntry(PdfObject dictionary, string key) + { + var entry = dictionary.Get(key) ?? throw new InvalidDataException($"Encrypt dictionary is missing required /{key}."); + var resolved = Resolve(entry); + if (resolved.Kind != PdfKind.Number) + { + throw new InvalidDataException($"Encrypt dictionary's /{key} must be a number."); + } + + return (int)resolved.Number; + } + + /// Reads a required string dictionary entry's raw bytes (resolving an indirect reference). + /// Thrown when the entry is absent, or present but not a string. + private byte[] GetRequiredBytesEntry(PdfObject dictionary, string key) + { + var entry = dictionary.Get(key) ?? throw new InvalidDataException($"Encrypt dictionary is missing required /{key}."); + var resolved = Resolve(entry); + if (resolved.Kind != PdfKind.String) + { + throw new InvalidDataException($"Encrypt dictionary's /{key} must be a string."); + } + + return resolved.Bytes; + } + + /// Reads a boolean dictionary entry (resolving an indirect reference), or a default when absent. + /// Thrown when the entry is present but not a boolean. + private bool GetBooleanEntry(PdfObject dictionary, string key, bool defaultValue) + { + var entry = dictionary.Get(key); + if (entry is null) + { + return defaultValue; + } + + var resolved = Resolve(entry); + if (resolved.Kind != PdfKind.Boolean) + { + throw new InvalidDataException($"Encrypt dictionary's /{key} must be a boolean."); + } + + return resolved.Boolean; + } + + /// + /// Reads the trailer's /ID array's first element's raw bytes, required by ISO + /// 32000-1 Algorithm 2's file-key derivation. + /// + /// + /// Thrown when /ID is absent, not a non-empty array, or its first element does not + /// resolve to a string. + /// + private byte[] GetDocumentIdBytes(PdfObject trailer) + { + var idEntry = trailer.Get("ID") ?? throw new InvalidDataException("Encrypted document trailer is missing required /ID."); + var resolvedId = Resolve(idEntry); + if (resolvedId.Kind != PdfKind.Array || resolvedId.Items.Count == 0) + { + throw new InvalidDataException("Encrypted document trailer's /ID must be a non-empty array."); + } + + var firstId = Resolve(resolvedId.Items[0]); + if (firstId.Kind != PdfKind.String) + { + throw new InvalidDataException("Encrypted document trailer's /ID first element must be a string."); + } + + return firstId.Bytes; + } +} +#pragma warning restore S4790 diff --git a/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Filters.Ascii.cs b/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Filters.Ascii.cs new file mode 100644 index 00000000..e0deb1e9 --- /dev/null +++ b/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Filters.Ascii.cs @@ -0,0 +1,232 @@ +// cspell:ignore Ascii +namespace DemaConsulting.CanvasNet.Pdf; + +public sealed partial class PdfDocument +{ + /// The lowest valid ASCII85Decode data-character code point (!). + private const byte Ascii85MinChar = 0x21; + + /// The highest valid ASCII85Decode data-character code point (u). + private const byte Ascii85MaxChar = 0x75; + + /// + /// The maximum total number of decoded output bytes allows + /// before failing closed, bounding a decompression-bomb-style crafted stream (each single + /// z character expands to 4 output bytes, a 4x amplification) from exhausting + /// memory. 64 MiB is generous for any legitimate PDF image/content stream, matching + /// LzwMaxOutputBytes in PdfDocument.Filters.Lzw.cs. + /// + private const int Ascii85MaxOutputBytes = 64 * 1024 * 1024; + + /// + /// Decodes an ASCII85Decode-filtered stream (ISO 32000-1/2 section 7.4.3): groups + /// of 5 base-85 digit characters (! through u, code points 0x21- + /// 0x75) decode to 4 bytes each, the character z is shorthand for an + /// all-zero 4-byte group when it occurs at a group boundary, a final partial group of + /// n (2-4) characters decodes to n - 1 bytes after padding with the digit + /// value 84 (u), and the two-character sequence ~> marks EOD. All + /// PDF white-space characters are ignored wherever they occur. + /// + /// + /// Thrown when an out-of-range character is encountered, z occurs in the middle of + /// a group, a decoded group's value exceeds 2^32 - 1, a final partial group + /// contains exactly one character, the ~> EOD marker is missing, or the decoded + /// output exceeds . + /// + private static byte[] DecodeAscii85(byte[] data) + { + var output = new List(); + var group = new List(5); + var foundEod = false; + + var i = 0; + while (i < data.Length) + { + var b = data[i]; + + if (IsPdfWhitespace(b)) + { + i++; + continue; + } + + if (b == (byte)'~') + { + i++; + while (i < data.Length && IsPdfWhitespace(data[i])) + { + i++; + } + + if (i >= data.Length || data[i] != (byte)'>') + { + throw new InvalidDataException("ASCII85Decode stream has a malformed ~> EOD marker."); + } + + foundEod = true; + break; + } + + if (b == (byte)'z') + { + if (group.Count != 0) + { + throw new InvalidDataException("ASCII85Decode 'z' shorthand may only occur at a group boundary."); + } + + // Check the predicted post-append size against the cap BEFORE appending - not + // after - so a crafted stream of repeated 'z' characters (each a single input + // byte expanding to 4 output bytes) can never grow the output list past the cap + // even transiently. + if (output.Count + 4 > Ascii85MaxOutputBytes) + { + throw new InvalidDataException( + $"ASCII85Decode output exceeds the maximum supported size of {Ascii85MaxOutputBytes} bytes."); + } + + output.AddRange(new byte[4]); + i++; + continue; + } + + if (b is < Ascii85MinChar or > Ascii85MaxChar) + { + throw new InvalidDataException($"Invalid ASCII85Decode character 0x{b:X2}."); + } + + group.Add((byte)(b - Ascii85MinChar)); + i++; + + if (group.Count == 5) + { + AppendAscii85Group(group, 5, output); + group.Clear(); + } + } + + if (!foundEod) + { + throw new InvalidDataException("ASCII85Decode stream is missing its ~> EOD marker."); + } + + if (group.Count == 1) + { + throw new InvalidDataException("ASCII85Decode final partial group must contain at least 2 characters."); + } + + if (group.Count > 0) + { + var significantChars = group.Count; + while (group.Count < 5) + { + group.Add(84); + } + + AppendAscii85Group(group, significantChars, output); + } + + return [.. output]; + } + + /// Decodes one full 5-character ASCII85 group into up to 4 bytes, appending them to . + /// + /// Thrown when the group's base-85 value exceeds 2^32 - 1, or appending the + /// group's bytes would make exceed . + /// + private static void AppendAscii85Group(IReadOnlyList group, int significantChars, List output) + { + var emitCount = significantChars - 1; + + // Check the predicted post-append size against the cap BEFORE appending - not after - + // so a crafted stream of many full 5-character groups (each emitting up to 4 output + // bytes) can never grow the output list past the cap even transiently. + if (output.Count + emitCount > Ascii85MaxOutputBytes) + { + throw new InvalidDataException( + $"ASCII85Decode output exceeds the maximum supported size of {Ascii85MaxOutputBytes} bytes."); + } + + var value = 0UL; + for (var i = 0; i < 5; i++) + { + value = (value * 85) + group[i]; + } + + if (value > uint.MaxValue) + { + throw new InvalidDataException("ASCII85Decode group value exceeds 2^32 - 1."); + } + + Span bytes = [(byte)(value >> 24), (byte)(value >> 16), (byte)(value >> 8), (byte)value]; + for (var i = 0; i < emitCount; i++) + { + output.Add(bytes[i]); + } + } + + /// + /// Decodes an ASCIIHexDecode-filtered stream (ISO 32000-1/2 section 7.4.2): pairs + /// of hexadecimal digits decode to bytes, all PDF white-space characters are ignored, and + /// a > character marks EOD; an odd trailing digit is implicitly padded with a + /// zero low nibble. + /// + /// + /// Thrown when a non-hexadecimal, non-whitespace character is encountered, or the + /// > EOD marker is missing. + /// + private static byte[] DecodeAsciiHex(byte[] data) + { + var output = new List(); + int? highNibble = null; + var foundEod = false; + + foreach (var b in data) + { + if (b == (byte)'>') + { + foundEod = true; + break; + } + + if (IsPdfWhitespace(b)) + { + continue; + } + + var value = HexDigitValue(b) ?? throw new InvalidDataException($"Invalid ASCIIHexDecode character 0x{b:X2}."); + if (highNibble is null) + { + highNibble = value; + } + else + { + output.Add((byte)((highNibble.Value << 4) | value)); + highNibble = null; + } + } + + if (!foundEod) + { + throw new InvalidDataException("ASCIIHexDecode stream is missing its > EOD marker."); + } + + if (highNibble is not null) + { + output.Add((byte)(highNibble.Value << 4)); + } + + return [.. output]; + } + + /// Returns whether is one of the PDF specification's white-space byte values. + private static bool IsPdfWhitespace(byte b) => b is 0x00 or 0x09 or 0x0A or 0x0C or 0x0D or 0x20; + + /// Returns the numeric value (0-15) of a hexadecimal digit character, or when it is not one. + private static int? HexDigitValue(byte b) => b switch + { + >= (byte)'0' and <= (byte)'9' => b - (byte)'0', + >= (byte)'A' and <= (byte)'F' => b - (byte)'A' + 10, + >= (byte)'a' and <= (byte)'f' => b - (byte)'a' + 10, + _ => null, + }; +} diff --git a/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Filters.Lzw.cs b/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Filters.Lzw.cs new file mode 100644 index 00000000..3e65471c --- /dev/null +++ b/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Filters.Lzw.cs @@ -0,0 +1,191 @@ +// cspell:ignore Lzw +namespace DemaConsulting.CanvasNet.Pdf; + +public sealed partial class PdfDocument +{ + /// The fixed PDF LZWDecode Clear-table code. + private const int LzwClearCode = 256; + + /// The fixed PDF LZWDecode end-of-data code. + private const int LzwEodCode = 257; + + /// The first dynamically assigned PDF LZWDecode table code. + private const int LzwFirstCode = 258; + + /// The maximum number of PDF LZWDecode table entries (12-bit codes). + private const int LzwMaxTableSize = 4096; + + /// + /// The maximum total number of decoded output bytes allows before + /// failing closed, bounding a decompression-bomb-style crafted stream (a tiny compressed + /// input whose table entries chain into an enormous expanded output) from exhausting + /// memory. 64 MiB is generous for any legitimate PDF image/content stream. + /// + private const int LzwMaxOutputBytes = 64 * 1024 * 1024; + + /// + /// Decodes an LZWDecode-filtered stream (ISO 32000-1/2 section 7.4.4): an initial + /// 258-entry table (codes 0-255 literal bytes, 256 Clear, 257 EOD, first dynamically + /// assigned code 258), MSB-first variable 9-12 bit code packing, and code-width growth + /// timing controlled by . + /// + /// + /// This is an independent, from-scratch implementation of the PDF-variant algorithm - it + /// shares no code with (and was only structurally informed by, per the task's hard + /// constraint) Codecs/Gif/GifCodec.Lzw.cs (LSB-first, no early-change concept) or + /// Codecs/Tiff/TiffCodec.Compression.cs (MSB-first, but fixed early-change + /// semantics with no /EarlyChange toggle). A dynamically assigned code's dictionary + /// entry is added the moment its preceding code has been decoded (mirroring the classic + /// LZW decompressor's own well-established "no entry for the very first code after a + /// Clear" asymmetry against the encoder's own "no entry for its final flushed code" - + /// both sides make the same total number of additions, and the encoder's code-width + /// growth threshold is offset from the decoder's own by exactly the resulting one-entry + /// lag; a compliant PDF LZWDecode encoder must apply that same offset for the two sides + /// to remain synchronized, independent of the /EarlyChange value chosen). + /// + /// The raw (pre-LZWDecode) stream bytes. + /// + /// The resolved /DecodeParms /EarlyChange value: (the PDF + /// default) grows the code width one code earlier than . + /// + /// The decoded bytes. + /// + /// Thrown when the bit stream is truncated before an EOD code is reached, when a + /// decoded code is invalid (out of range for the current table state), or when the + /// decoded output exceeds . + /// + private static byte[] DecodeLzw(byte[] data, bool earlyChange) + { + var earlyChangeAmount = earlyChange ? 1 : 0; + var reader = new LzwBitReader(data); + var prefixOf = new int[LzwMaxTableSize]; + var suffixOf = new byte[LzwMaxTableSize]; + + var codeWidth = 9; + var maxCode = (1 << codeWidth) - 1 - earlyChangeAmount; + var nextCode = LzwFirstCode; + var output = new List(); + var sequence = new List(); + int? oldCode = null; + + while (true) + { + var code = reader.ReadCode(codeWidth) ?? throw new InvalidDataException( + "Truncated LZWDecode stream: missing EOD code."); + + if (code == LzwEodCode) + { + break; + } + + if (code == LzwClearCode) + { + codeWidth = 9; + maxCode = (1 << codeWidth) - 1 - earlyChangeAmount; + nextCode = LzwFirstCode; + oldCode = null; + continue; + } + + sequence.Clear(); + if (oldCode is null) + { + if (code >= 256) + { + throw new InvalidDataException($"Invalid LZWDecode code {code} before any table entry exists."); + } + + sequence.Add((byte)code); + } + else if (code < nextCode) + { + ExpandLzwCode(code, prefixOf, suffixOf, sequence); + } + else if (code == nextCode) + { + // The classic "KwKwK" special case: the code being decoded is the one about to + // be added to the table (the previous sequence followed by its own first byte). + ExpandLzwCode(oldCode.Value, prefixOf, suffixOf, sequence); + sequence.Add(sequence[0]); + } + else + { + throw new InvalidDataException($"Invalid LZWDecode code {code}: not yet present in the table."); + } + + output.AddRange(sequence); + if (output.Count > LzwMaxOutputBytes) + { + throw new InvalidDataException( + $"LZWDecode output exceeds the maximum supported size of {LzwMaxOutputBytes} bytes."); + } + + if (oldCode is not null && nextCode < LzwMaxTableSize) + { + prefixOf[nextCode] = oldCode.Value; + suffixOf[nextCode] = sequence[0]; + nextCode++; + + if (nextCode > maxCode && codeWidth < 12) + { + codeWidth++; + maxCode = (1 << codeWidth) - 1 - earlyChangeAmount; + } + } + + oldCode = code; + } + + return [.. output]; + } + + /// Expands a table code back into its full byte sequence, appending it to . + private static void ExpandLzwCode(int code, int[] prefixOf, byte[] suffixOf, List sequence) + { + var stack = new Stack(); + var current = code; + while (current >= LzwFirstCode) + { + stack.Push(suffixOf[current]); + current = prefixOf[current]; + } + + sequence.Add((byte)current); + while (stack.Count > 0) + { + sequence.Add(stack.Pop()); + } + } + + /// An MSB-first (high-order bit first, per ISO 32000-1/2 section 7.4.4.2), variable-width bit reader over a byte array. + private sealed class LzwBitReader(byte[] data) + { + private int _bytePosition; + private int _bitPosition; + + /// Reads the next -bit code, or when the stream is exhausted. + public int? ReadCode(int width) + { + var value = 0; + for (var i = 0; i < width; i++) + { + if (_bytePosition >= data.Length) + { + return null; + } + + var bit = (data[_bytePosition] >> (7 - _bitPosition)) & 1; + value = (value << 1) | bit; + + _bitPosition++; + if (_bitPosition == 8) + { + _bitPosition = 0; + _bytePosition++; + } + } + + return value; + } + } +} diff --git a/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Filters.RunLength.cs b/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Filters.RunLength.cs new file mode 100644 index 00000000..ed702253 --- /dev/null +++ b/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Filters.RunLength.cs @@ -0,0 +1,102 @@ +namespace DemaConsulting.CanvasNet.Pdf; + +public sealed partial class PdfDocument +{ + /// The RunLengthDecode length byte value that marks end-of-data. + private const byte RunLengthEodMarker = 128; + + /// + /// The maximum total number of decoded output bytes allows + /// before failing closed, bounding a decompression-bomb-style crafted stream (each 2-byte + /// "repeat" run can expand to up to 128 output bytes, a 64x amplification) from + /// exhausting memory. 64 MiB is generous for any legitimate PDF image/content stream, + /// matching LzwMaxOutputBytes in PdfDocument.Filters.Lzw.cs. + /// + private const int RunLengthMaxOutputBytes = 64 * 1024 * 1024; + + /// + /// Decodes a RunLengthDecode-filtered stream (ISO 32000-1/2 section 7.4.5, the + /// "PackBits" scheme): each run begins with a length byte - 0-127 copies + /// the following length + 1 bytes literally, 129-255 repeats the + /// single following byte 257 - length times, and 128 marks EOD. + /// + /// + /// Thrown when a literal or repeat run is truncated (not enough bytes remain), the stream + /// ends without an EOD (128) length byte, or the decoded output exceeds + /// . + /// + private static byte[] DecodeRunLength(byte[] data) + { + var output = new List(); + var foundEod = false; + + var i = 0; + while (i < data.Length) + { + var length = data[i++]; + + if (length == RunLengthEodMarker) + { + foundEod = true; + break; + } + + if (length <= 127) + { + var count = length + 1; + if (i + count > data.Length) + { + throw new InvalidDataException("Truncated RunLengthDecode literal run."); + } + + // Preflight the run's size against the REMAINING budget before appending any of + // its bytes - checking only after the run has already been added to `output` + // would still allow a crafted (sequence of) run(s) to materialize an oversized + // allocation before the cap ever fires. + if (count > RunLengthMaxOutputBytes - output.Count) + { + throw new InvalidDataException( + $"RunLengthDecode output exceeds the maximum supported size of {RunLengthMaxOutputBytes} bytes."); + } + + for (var k = 0; k < count; k++) + { + output.Add(data[i + k]); + } + + i += count; + } + else + { + if (i >= data.Length) + { + throw new InvalidDataException("Truncated RunLengthDecode repeat run."); + } + + var repeatByte = data[i++]; + var count = 257 - length; + + // Same preflight-before-append ordering as the literal-run branch above: a + // single repeat run can expand 2 input bytes to up to 128 output bytes, so the + // remaining-budget check must happen before any of those bytes are added. + if (count > RunLengthMaxOutputBytes - output.Count) + { + throw new InvalidDataException( + $"RunLengthDecode output exceeds the maximum supported size of {RunLengthMaxOutputBytes} bytes."); + } + + for (var k = 0; k < count; k++) + { + output.Add(repeatByte); + } + } + } + + if (!foundEod) + { + throw new InvalidDataException("RunLengthDecode stream is missing its EOD length byte (128)."); + } + + return [.. output]; + } +} diff --git a/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Filters.cs b/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Filters.cs new file mode 100644 index 00000000..fa0ed9f6 --- /dev/null +++ b/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Filters.cs @@ -0,0 +1,489 @@ +// cspell:ignore bitdepth reimplementation diffability +using DemaConsulting.CanvasNet.Codecs; + +namespace DemaConsulting.CanvasNet.Pdf; + +public sealed partial class PdfDocument +{ + /// + /// Resolves a stream's /Filter (absent, a single Name, or an Array of + /// names) and its parallel /DecodeParms (absent, a single dictionary/, + /// or an array of dictionaries/, aligned 1:1 with /Filter) + /// into an ordered pipeline description. + /// + /// The stream whose /Filter//DecodeParms to resolve. + /// + /// The ordered filter names (empty when /Filter is absent) and their parallel, + /// possibly- /DecodeParms dictionaries, one per filter name. + /// + /// + /// Thrown when /Filter is neither a name nor an array of names, or when + /// /DecodeParms does not match /Filter's own shape (a single dictionary/ + /// for a bare name, an array of the same length as /Filter + /// for an array). + /// + private (IReadOnlyList Names, IReadOnlyList Parms) ResolveFilterPipeline(PdfObject streamObject) + { + var filterObject = streamObject.Get("Filter"); + if (filterObject is null) + { + return ([], []); + } + + var resolvedFilter = Resolve(filterObject); + List names; + if (resolvedFilter.Kind == PdfKind.Name) + { + names = [resolvedFilter.Text]; + } + else if (resolvedFilter.Kind == PdfKind.Array) + { + names = new List(resolvedFilter.Items.Count); + foreach (var item in resolvedFilter.Items) + { + var resolvedItem = Resolve(item); + if (resolvedItem.Kind != PdfKind.Name) + { + throw new InvalidDataException("/Filter array entries must be names."); + } + + names.Add(resolvedItem.Text); + } + } + else + { + throw new InvalidDataException("Unsupported /Filter value."); + } + + var parms = ResolveDecodeParms(streamObject, resolvedFilter, names.Count); + return (names, parms); + } + + /// Resolves /DecodeParms into a list of length , aligned 1:1 with /Filter. + private List ResolveDecodeParms(PdfObject streamObject, PdfObject resolvedFilter, int filterCount) + { + var parmsObject = streamObject.Get("DecodeParms"); + if (parmsObject is null) + { + return new List(new PdfObject?[filterCount]); + } + + var resolvedParms = Resolve(parmsObject); + if (resolvedFilter.Kind == PdfKind.Name) + { + return [resolvedParms.Kind == PdfKind.Null ? null : resolvedParms]; + } + + if (resolvedParms.Kind != PdfKind.Array) + { + throw new InvalidDataException("/DecodeParms must be an array when /Filter is an array."); + } + + if (resolvedParms.Items.Count != filterCount) + { + throw new InvalidDataException("/DecodeParms array length must match /Filter array length."); + } + + var parms = new List(filterCount); + foreach (var item in resolvedParms.Items) + { + var resolvedItem = Resolve(item); + parms.Add(resolvedItem.Kind == PdfKind.Null ? null : resolvedItem); + } + + return parms; + } + + /// + /// Returns a stream's fully decoded data bytes, applying its ordered /Filter/ + /// /DecodeParms pipeline. + /// + /// + /// Supports FlateDecode and LZWDecode (either optionally followed by a + /// PNG - predictor values 10-15 - or TIFF - predictor value 2 - + /// predictor reversal), plus ASCII85Decode, ASCIIHexDecode, and + /// RunLengthDecode (none of which carry a predictor: PDF only ever declares + /// /Predictor alongside the two image-compression filters). Any other filter name + /// (including DCTDecode and CCITTFaxDecode, both of which + /// PdfDocument.Images.cs always detects and bypasses - decoding each directly via + /// Codecs.JpegCodec/ respectively - before ever calling + /// this method; and JPXDecode, which remains unsupported) is rejected with + /// . This is the same behavior Phase 1/2 + /// already relied on for cross-reference streams, object streams, and page + /// /Contents (all of which only ever use a bare FlateDecode filter with no + /// /DecodeParms), generalized to a full ordered pipeline. + /// + /// + /// Thrown when /Filter//DecodeParms is malformed, when a declared + /// /Predictor value is not 1, 2, or in 10..15, or when a + /// filtered stream's own bytes are malformed for its declared filter (see the individual + /// Decode* methods). + /// + /// + /// Thrown when a filter name other than FlateDecode, LZWDecode, + /// ASCII85Decode, ASCIIHexDecode, or RunLengthDecode is declared, or + /// when a TIFF predictor (/Predictor 2) is combined with a + /// /BitsPerComponent other than 8. + /// + private byte[] GetStreamDecodedBytes(PdfObject streamObject) + { + var raw = GetStreamRawBytes(streamObject); + var (names, parms) = ResolveFilterPipeline(streamObject); + + var data = raw; + for (var i = 0; i < names.Count; i++) + { + var name = names[i]; + var parm = parms[i]; + + data = name switch + { + "FlateDecode" => ZlibDecompress(data), + "LZWDecode" => DecodeLzw(data, parm is null || GetIntEntry(parm, "EarlyChange", 1) != 0), + "ASCII85Decode" => DecodeAscii85(data), + "ASCIIHexDecode" => DecodeAsciiHex(data), + "RunLengthDecode" => DecodeRunLength(data), + _ => throw new UnsupportedImageFeatureException( + $"pdf-filter-{name}", + $"Stream filter '{name}' is not supported in this phase."), + }; + + if (name is "FlateDecode" or "LZWDecode" && parm is not null && GetIntEntry(parm, "Predictor", 1) > 1) + { + data = ApplyPredictor(data, parm); + } + } + + return data; + } + + /// + /// The maximum per-row byte size allows its + /// /Colors//BitsPerComponent//Columns-derived row stride to reach, + /// computed in arithmetic before this cap is enforced so an + /// attacker-controlled /Columns or /Colors cannot overflow the plain + /// arithmetic / + /// perform afterward. int.MaxValue / 2 is far + /// larger than any legitimate PDF row could need while still leaving headroom for the + /// + 1 PNG filter-type-byte stride adjustment to stay within + /// range, matching the buffer-size caps 8d4bc3f established elsewhere in this file. + /// + private const long PredictorMaxRowBytes = int.MaxValue / 2; + + /// + /// Reverses a /DecodeParms-declared PNG (/Predictor 10-15) or + /// TIFF (/Predictor 2) predictor against already-FlateDecode-decompressed + /// bytes. + /// + /// + /// Thrown when /Predictor is not 2 or in 10..15, when + /// /Colors, /BitsPerComponent, or /Columns are not legal PDF values + /// (defense-in-depth: a malformed/negative or zero value here could otherwise divide by + /// zero or underflow downstream row/stride computations), or when the resolved row size + /// exceeds (defense-in-depth: an adversarially large + /// /Colors or /Columns could otherwise overflow the downstream + /// row/stride computations). + /// + /// + /// Thrown when a TIFF predictor (/Predictor 2) is declared with a + /// /BitsPerComponent other than 8. + /// + private byte[] ApplyPredictor(byte[] data, PdfObject parms) + { + var predictor = GetIntEntry(parms, "Predictor", 1); + var colors = GetIntEntry(parms, "Colors", 1); + var bitsPerComponent = GetIntEntry(parms, "BitsPerComponent", 8); + var columns = GetIntEntry(parms, "Columns", 1); + + if (colors < 1) + { + throw new InvalidDataException("Predictor /Colors must be a positive integer."); + } + + if (bitsPerComponent is not (1 or 2 or 4 or 8 or 16)) + { + throw new InvalidDataException("Predictor /BitsPerComponent must be 1, 2, 4, 8, or 16."); + } + + if (columns < 1) + { + throw new InvalidDataException("Predictor /Columns must be a positive integer."); + } + + // Compute the row stride in `long` arithmetic - `/Colors` and `/Columns` are each only + // bounded below (positive) above, so their product with `/BitsPerComponent` could + // otherwise overflow plain `int` arithmetic and wrap around to a small or negative + // value, defeating the positivity checks above and corrupting the downstream + // allocation/stride math in ApplyTiffPredictor/ApplyPngPredictor. + var rowBytesLong = ((long)colors * bitsPerComponent * columns + 7) / 8; + if (rowBytesLong > PredictorMaxRowBytes) + { + throw new InvalidDataException( + $"Predictor row size of {rowBytesLong} bytes exceeds the maximum supported size of " + + $"{PredictorMaxRowBytes} bytes."); + } + + if (predictor == 2) + { + return ApplyTiffPredictor(data, colors, bitsPerComponent, columns); + } + + if (predictor is >= 10 and <= 15) + { + return ApplyPngPredictor(data, colors, bitsPerComponent, columns, predictor); + } + + throw new InvalidDataException($"Unsupported /Predictor value {predictor}."); + } + + /// + /// Reverses a TIFF (/Predictor 2) horizontal-difference predictor: every component + /// byte (after the first pixel's own components) was encoded as its difference from the + /// same component's earlier byte in the same row - no per-row filter-type byte is present + /// (unlike the PNG predictor forms). + /// + /// + /// Thrown when is not 8. + /// + private static byte[] ApplyTiffPredictor(byte[] data, int colors, int bitsPerComponent, int columns) + { + if (bitsPerComponent != 8) + { + throw new UnsupportedImageFeatureException( + $"pdf-tiff-predictor-bitdepth-{bitsPerComponent}", + $"TIFF predictor with {bitsPerComponent}-bit components is not supported."); + } + + var bytesPerPixel = Math.Max(1, colors * bitsPerComponent / 8); + var rowBytes = (colors * bitsPerComponent * columns + 7) / 8; + var result = (byte[])data.Clone(); + var rowCount = rowBytes == 0 ? 0 : result.Length / rowBytes; + for (var row = 0; row < rowCount; row++) + { + var rowStart = row * rowBytes; + for (var i = bytesPerPixel; i < rowBytes; i++) + { + result[rowStart + i] = (byte)(result[rowStart + i] + result[rowStart + i - bytesPerPixel]); + } + } + + return result; + } + + /// + /// Reverses a PNG (/Predictor 10-15) predictor. Only predictor + /// 15 ("optimum") is adaptive per ISO 32000-1 Table 8, with every row prefixed by + /// its own filter-type tag byte (0-4); predictors 10-14 are + /// each a single fixed filter (10 = None through 14 = Paeth) applied to + /// every row uniformly, with no per-row tag byte at all - a row is exactly + /// ' worth of payload bytes. Each row is reconstructed via + /// - an independent reimplementation of + /// Codecs/Png/PngCodec.Filtering.cs's exact algorithm (not reusable across + /// assemblies: those methods are ), named identically for direct + /// diffability against the original. + /// + /// + /// Thrown when 's length is not an exact multiple of the row + /// stride implied by (one tag byte plus rowBytes for + /// predictor 15, or exactly rowBytes for a fixed predictor 10-14) - + /// a partial trailing row would otherwise be silently dropped by integer-division + /// truncation rather than rejected. + /// + private static byte[] ApplyPngPredictor(byte[] data, int colors, int bitsPerComponent, int columns, int predictor) + { + var bpp = Math.Max(1, colors * bitsPerComponent / 8); + var rowBytes = (colors * bitsPerComponent * columns + 7) / 8; + if (rowBytes == 0) + { + return []; + } + + // Predictor 15 is the only adaptive form (a per-row filter-type tag byte); predictors + // 10-14 are each a single fixed filter applied uniformly, with no tag byte present at + // all, per ISO 32000-1 Table 8. + var stride = predictor == 15 ? rowBytes + 1 : rowBytes; + if (data.Length % stride != 0) + { + throw new InvalidDataException( + $"PNG predictor data length ({data.Length}) is not a multiple of the row stride ({stride})."); + } + + var rowCount = data.Length / stride; + var result = new byte[rowCount * rowBytes]; + var previousRow = new byte[rowBytes]; + var fixedFilterType = (byte)(predictor - 10); + + for (var row = 0; row < rowCount; row++) + { + var srcOffset = row * stride; + byte filterType; + ReadOnlySpan filteredRow; + if (predictor == 15) + { + filterType = data[srcOffset]; + filteredRow = data.AsSpan(srcOffset + 1, rowBytes); + } + else + { + filterType = fixedFilterType; + filteredRow = data.AsSpan(srcOffset, rowBytes); + } + + var outputRow = result.AsSpan(row * rowBytes, rowBytes); + DefilterRow(filterType, filteredRow, previousRow, outputRow, bpp); + outputRow.CopyTo(previousRow); + } + + return result; + } + + /// + /// Reconstructs one filtered PNG scanline into its raw (unfiltered) pixel bytes. See + /// Codecs/Png/PngCodec.Filtering.cs's identically named/shaped method - this is an + /// independent reimplementation of the same algorithm. + /// + /// + /// Thrown when is not a value from 0 to 4. + /// + private static void DefilterRow( + byte filterType, + ReadOnlySpan filtered, + ReadOnlySpan previousRow, + Span output, + int bpp) + { + switch (filterType) + { + case 0: // None + filtered.CopyTo(output); + break; + + case 1: // Sub + DefilterSub(filtered, output, bpp); + break; + + case 2: // Up + DefilterUp(filtered, previousRow, output); + break; + + case 3: // Average + DefilterAverage(filtered, previousRow, output, bpp); + break; + + case 4: // Paeth + DefilterPaeth(filtered, previousRow, output, bpp); + break; + + default: + throw new InvalidDataException( + $"Unsupported PNG filter type {filterType}; only filter types 0-4 are supported."); + } + } + + /// Reconstructs a scanline filtered with PNG filter type 1 (Sub). + private static void DefilterSub(ReadOnlySpan filtered, Span output, int bpp) + { + for (var i = 0; i < filtered.Length; i++) + { + int left = i >= bpp ? output[i - bpp] : 0; + output[i] = (byte)(filtered[i] + left); + } + } + + /// Reconstructs a scanline filtered with PNG filter type 2 (Up). + private static void DefilterUp(ReadOnlySpan filtered, ReadOnlySpan previousRow, Span output) + { + for (var i = 0; i < filtered.Length; i++) + { + output[i] = (byte)(filtered[i] + previousRow[i]); + } + } + + /// Reconstructs a scanline filtered with PNG filter type 3 (Average). + private static void DefilterAverage( + ReadOnlySpan filtered, + ReadOnlySpan previousRow, + Span output, + int bpp) + { + for (var i = 0; i < filtered.Length; i++) + { + int left = i >= bpp ? output[i - bpp] : 0; + int up = previousRow[i]; + output[i] = (byte)(filtered[i] + ((left + up) / 2)); + } + } + + /// Reconstructs a scanline filtered with PNG filter type 4 (Paeth). + private static void DefilterPaeth( + ReadOnlySpan filtered, + ReadOnlySpan previousRow, + Span output, + int bpp) + { + for (var i = 0; i < filtered.Length; i++) + { + int left = i >= bpp ? output[i - bpp] : 0; + int up = previousRow[i]; + int upperLeft = i >= bpp ? previousRow[i - bpp] : 0; + output[i] = (byte)(filtered[i] + PaethPredictor(left, up, upperLeft)); + } + } + + /// + /// Computes the PNG Paeth predictor value for a pixel from its left, upper, and + /// upper-left neighbor byte values. + /// + private static int PaethPredictor(int a, int b, int c) + { + var p = a + b - c; + var pa = Math.Abs(p - a); + var pb = Math.Abs(p - b); + var pc = Math.Abs(p - c); + + if (pa <= pb && pa <= pc) + { + return a; + } + + return pb <= pc ? b : c; + } + + /// Reads an integer dictionary entry (resolving an indirect reference), or a default when absent. + /// Thrown when the entry is present but not a number. + private int GetIntEntry(PdfObject dictionary, string key, int defaultValue) + { + var entry = dictionary.Get(key); + if (entry is null) + { + return defaultValue; + } + + var resolved = Resolve(entry); + if (resolved.Kind != PdfKind.Number) + { + throw new InvalidDataException($"/{key} must be a number."); + } + + return (int)resolved.Number; + } + + /// Reads a boolean dictionary entry (resolving an indirect reference), or a default when absent. + /// Thrown when the entry is present but not a boolean. + private bool GetBoolEntry(PdfObject dictionary, string key, bool defaultValue) + { + var entry = dictionary.Get(key); + if (entry is null) + { + return defaultValue; + } + + var resolved = Resolve(entry); + if (resolved.Kind != PdfKind.Boolean) + { + throw new InvalidDataException($"/{key} must be a boolean."); + } + + return resolved.Boolean; + } +} diff --git a/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.FontFallback.cs b/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.FontFallback.cs new file mode 100644 index 00000000..2b0180fe --- /dev/null +++ b/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.FontFallback.cs @@ -0,0 +1,273 @@ +// cspell:ignore Nonsymbolic Oblique Dingbats Zapf Noto +using DemaConsulting.CanvasNet.Codecs; +using DemaConsulting.CanvasNet.Fonts; + +namespace DemaConsulting.CanvasNet.Pdf; + +public sealed partial class PdfDocument +{ + /// + /// Maps every Standard-14 PDF font name to its fixed (Serif, FixedPitch, Bold, Italic) + /// style tuple, used by in preference to (and + /// regardless of) any /FontDescriptor flags/weight/angle a font dictionary may + /// also declare - a Standard-14 name's style is a fixed property of that name, not + /// something a producer's own (possibly absent, possibly wrong) descriptor should + /// override. Symbol and ZapfDingbats are deliberately excluded from this + /// table - they resolve via a dedicated bundled Noto substitute (see + /// 's branch) + /// that bypasses serif/fixed-pitch/bold/italic flavor classification entirely, since this + /// table's style tuple has no meaning for a symbol/dingbat glyph set. + /// + private static readonly IReadOnlyDictionary + Standard14Flavors = new Dictionary(StringComparer.Ordinal) + { + ["Helvetica"] = (false, false, false, false), + ["Helvetica-Bold"] = (false, false, true, false), + ["Helvetica-Oblique"] = (false, false, false, true), + ["Helvetica-BoldOblique"] = (false, false, true, true), + ["Times-Roman"] = (true, false, false, false), + ["Times-Bold"] = (true, false, true, false), + ["Times-Italic"] = (true, false, false, true), + ["Times-BoldItalic"] = (true, false, true, true), + ["Courier"] = (false, true, false, false), + ["Courier-Bold"] = (false, true, true, false), + ["Courier-Oblique"] = (false, true, false, true), + ["Courier-BoldOblique"] = (false, true, true, true), + }; + + /// + /// Caches every loaded from a + /// hit, keyed by the matched font's (FilePath, FaceIndex) - deliberately a + /// process-lifetime cache (unlike 's per- + /// call scope), since a system font file's bytes never change between calls, so + /// re-parsing it from disk on every fallback lookup would reintroduce the "hundreds of + /// files scanned repeatedly" cost 's own laziness + /// already avoids at the discovery layer. + /// + private static readonly Dictionary<(string Path, int FaceIndex), TrueTypeFont> FallbackFontCache = new(); + + /// Guards against concurrent population. + private static readonly object FallbackFontCacheLock = new(); + + /// + /// Resolves the ordered candidate list to substitute for + /// a simple TrueType font with no embedded /FontDescriptor/FontFile2: a + /// Symbol/ZapfDingbats /BaseFont resolves via a dedicated bundled + /// Noto substitute union (); any other font whose + /// /FontDescriptor/Flags declares Symbolic without also declaring + /// Nonsymbolic still fails closed, since such a font's symbol/dingbat glyph set has + /// no meaningful generic-family equivalent; otherwise classifies the font's serif/ + /// fixed-pitch/bold/italic flavor (via the Standard-14 table, or else + /// /FontDescriptor flags/weight/angle/name heuristics), searches the host + /// operating system's installed fonts via , + /// and falls back to a bundled Liberation Sans/Serif/Mono font + /// () when no system font matches. + /// + /// The font dictionary's /BaseFont name. + /// The font dictionary's resolved /FontDescriptor. + /// + /// The ordered, non-empty list of candidate substitute + /// instances - every non-Symbol/ZapfDingbats case returns a single-element + /// list; see for the multi-element case. + /// + /// + /// Thrown when 's /Flags declares Symbolic + /// without also declaring Nonsymbolic for a /BaseFont other than + /// Symbol/ZapfDingbats - such a font's symbol/dingbat glyph set has no + /// meaningful generic-family equivalent and is never substituted with an unrelated system + /// or bundled font. Symbol/ZapfDingbats itself no longer throws this - see + /// . + /// + private IReadOnlyList ResolveFallbackFont(string baseFontName, PdfObject descriptor) + { + if (baseFontName is "Symbol" or "ZapfDingbats") + { + return ResolveSymbolicNotoFallback(baseFontName); + } + + if (IsSymbolicWithoutNonsymbolic(descriptor)) + { + throw new UnsupportedImageFeatureException( + "pdf-font-symbolic-not-embedded", + $"Font '/BaseFont /{baseFontName}' has no embedded /FontDescriptor/FontFile2; " + + "a symbolic font (per /FontDescriptor/Flags) other than Symbol/ZapfDingbats is " + + "never substituted with an unrelated system or bundled font, since its " + + "symbol/dingbat glyph set has no meaningful generic-family equivalent."); + } + + var (serif, fixedPitch, bold, italic) = ResolveFallbackFlavor(baseFontName, descriptor); + var familyNameHint = StripFontNameDecoration(baseFontName); + + var match = SystemFontCatalog.FindBestMatch(familyNameHint, bold, italic, serif, fixedPitch); + if (match is { } found) + { + return [LoadFallbackFontFromDisk(found.FilePath, found.FaceIndex)]; + } + + return [SystemFontCatalog.LoadBundledFallback(serif, fixedPitch, bold, italic)]; + } + + /// + /// The bundled Noto substitute font(s) (see Fonts/BundledFonts/README.md for + /// sourcing/licensing provenance), in a fixed glyph-lookup priority order, used in place + /// of the fail-closed policy every other symbolic font still gets: a Symbol + /// /BaseFont tries NotoSans-Regular.ttf (its Greek-letter and general-symbol + /// glyphs) first, then NotoSansMath-Regular.ttf (mathematical operators), then + /// NotoSansSymbols2-Regular.ttf (Private-Use-Area-adjacent/rare symbols); a + /// ZapfDingbats /BaseFont uses only NotoSansSymbols2-Regular.ttf. See + /// for how a multi-font list is searched (first + /// non-zero wins). + /// + /// Either "Symbol" or "ZapfDingbats". + /// The ordered Noto substitute font list for . + private static IReadOnlyList ResolveSymbolicNotoFallback(string baseFontName) + { + return baseFontName == "Symbol" + ? + [ + SystemFontCatalog.LoadBundledFallbackCore("NotoSans-Regular.ttf"), + SystemFontCatalog.LoadBundledFallbackCore("NotoSansMath-Regular.ttf"), + SystemFontCatalog.LoadBundledFallbackCore("NotoSansSymbols2-Regular.ttf"), + ] + : [SystemFontCatalog.LoadBundledFallbackCore("NotoSansSymbols2-Regular.ttf")]; + } + + /// + /// Loads (or returns the process-lifetime cached) for a + /// specific system font file and face, as matched by . + /// + private static TrueTypeFont LoadFallbackFontFromDisk(string path, int faceIndex) + { + lock (FallbackFontCacheLock) + { + var key = (path, faceIndex); + if (FallbackFontCache.TryGetValue(key, out var cached)) + { + return cached; + } + + var font = TrueTypeFont.Load(path, faceIndex); + FallbackFontCache[key] = font; + return font; + } + } + + /// + /// Determines whether a font dictionary's /FontDescriptor/Flags declares the + /// Symbolic bit (bit 3) without also declaring the Nonsymbolic bit (bit 6) - + /// the PDF specification's own "symbolic, non-Latin-text glyph set" declaration. Absence + /// of both bits (a common real-world producer omission) is treated leniently as + /// Nonsymbolic, not as symbolic. + /// + private bool IsSymbolicWithoutNonsymbolic(PdfObject descriptor) + { + var flags = GetFontDescriptorFlags(descriptor); + if (flags is not { } flagsValue) + { + return false; + } + + const int symbolicBit = 1 << 2; + const int nonsymbolicBit = 1 << 5; + return (flagsValue & symbolicBit) != 0 && (flagsValue & nonsymbolicBit) == 0; + } + + /// + /// Classifies a font's serif/fixed-pitch/bold/italic flavor: a Standard-14 name's fixed + /// table entry (see ) takes priority and bypasses + /// entirely when it matches; otherwise the flavor is derived + /// from /FontDescriptor/Flags bit 1 (FixedPitch) and bit 2 (Serif), + /// /FontWeight >= 600 (else a case-insensitive "Bold" substring in + /// ) for bold, and /FontDescriptor/Flags bit 7 + /// (Italic) OR a non-zero /ItalicAngle OR a case-insensitive "Italic"/ + /// "Oblique" substring in for italic. + /// + private (bool Serif, bool FixedPitch, bool Bold, bool Italic) ResolveFallbackFlavor( + string baseFontName, + PdfObject descriptor) + { + if (Standard14Flavors.TryGetValue(baseFontName, out var standardFlavor)) + { + return standardFlavor; + } + + var flags = GetFontDescriptorFlags(descriptor) ?? 0; + const int fixedPitchBit = 1 << 0; + const int serifBit = 1 << 1; + const int italicBit = 1 << 6; + + var fixedPitch = (flags & fixedPitchBit) != 0; + var serif = (flags & serifBit) != 0; + + var fontWeight = GetFontWeight(descriptor); + var bold = fontWeight is { } weight + ? weight >= 600 + : baseFontName.Contains("Bold", StringComparison.OrdinalIgnoreCase); + + var italicAngle = GetItalicAngle(descriptor); + var italic = (flags & italicBit) != 0 || + (italicAngle is { } angle && angle != 0) || + baseFontName.Contains("Italic", StringComparison.OrdinalIgnoreCase) || + baseFontName.Contains("Oblique", StringComparison.OrdinalIgnoreCase); + + return (serif, fixedPitch, bold, italic); + } + + /// + /// Style-name suffixes (checked longest-first, case-insensitive) stripped from a + /// /BaseFont name (after subset-tag removal) by , + /// leaving just the plain family name - for example "Arial,BoldItalic" or + /// "Arial-BoldItalic" both become "Arial". + /// + private static readonly string[] FontNameStyleSuffixes = + [ + ",BoldItalic", "-BoldItalic", ",BoldOblique", "-BoldOblique", + ",Bold", "-Bold", ",Italic", "-Italic", ",Oblique", "-Oblique", + ]; + + /// + /// Strips a PDF subset tag (six uppercase letters followed by +) and any trailing + /// style-name suffix () from a /BaseFont name, + /// leaving a plain family-name hint suitable for + /// - itself has no knowledge of PDF-specific naming + /// conventions. + /// + private static string StripFontNameDecoration(string baseFontName) + { + var name = baseFontName; + if (name.Length >= 7 && name[6] == '+' && name[..6].All(char.IsAsciiLetterUpper)) + { + name = name[7..]; + } + + var matchedSuffix = FontNameStyleSuffixes.FirstOrDefault( + suffix => name.EndsWith(suffix, StringComparison.OrdinalIgnoreCase)); + if (matchedSuffix is not null) + { + name = name[..^matchedSuffix.Length]; + } + + return name; + } + + /// Reads a font descriptor's /Flags entry, if present and numeric. + private int? GetFontDescriptorFlags(PdfObject descriptor) + { + var entry = descriptor.Get("Flags"); + return entry is not null && Resolve(entry) is { Kind: PdfKind.Number } value ? (int)value.Number : null; + } + + /// Reads a font descriptor's /FontWeight entry, if present and numeric. + private double? GetFontWeight(PdfObject descriptor) + { + var entry = descriptor.Get("FontWeight"); + return entry is not null && Resolve(entry) is { Kind: PdfKind.Number } value ? value.Number : null; + } + + /// Reads a font descriptor's /ItalicAngle entry, if present and numeric. + private double? GetItalicAngle(PdfObject descriptor) + { + var entry = descriptor.Get("ItalicAngle"); + return entry is not null && Resolve(entry) is { Kind: PdfKind.Number } value ? value.Number : null; + } +} diff --git a/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Fonts.ToUnicode.cs b/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Fonts.ToUnicode.cs new file mode 100644 index 00000000..2235d045 --- /dev/null +++ b/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Fonts.ToUnicode.cs @@ -0,0 +1,393 @@ +// cspell:ignore cidrange cidchar usecmap findresource defineresource currentdict endcmap bfrange bfchar endbfrange endbfchar begincmap begincodespacerange endcodespacerange tounicode codepoints codespacerange beginbfchar beginbfrange +using DemaConsulting.CanvasNet.Codecs; + +namespace DemaConsulting.CanvasNet.Pdf; + +public sealed partial class PdfDocument +{ + /// + /// The maximum number of character codes a single bfrange entry's + /// [srcLo, srcHi] span is allowed to expand into before failing closed, bounding a + /// decompression-bomb-style crafted /ToUnicode CMap (a tiny srcLo/srcHi + /// hex-string pair declaring an enormous span) from allocating an enormous + /// of code-to-codepoint entries. 65536 (the full + /// 2-byte code space) is generous for any legitimate CMap, since codes above that require + /// successively larger encodings that a real font's actual code space would never need. + /// + private const int ToUnicodeMaxBfRangeSpan = 65536; + + /// + /// Resolves a font dictionary's optional /ToUnicode CMap stream into a + /// code-to-Unicode-codepoint map, by tokenizing its bfchar/bfrange + /// operators (see /, + /// reused verbatim - no new tokenizer type). + /// + /// + /// This is deliberately a narrow, bfchar/bfrange-only CMap parser, not a + /// general CMap/PostScript interpreter. Every real-world /ToUnicode stream wraps + /// its bfchar/bfrange operators in the Adobe CMap/CID-keyed-font + /// specification's mandatory PostScript resource-management boilerplate (PDF + /// 32000-1:2008 §9.10.3's own worked example): /CIDInit /ProcSet findresource + /// begin ... begincmap ... endcmap CMapName currentdict /CMap defineresource pop end + /// end. Every keyword in that boilerplate (begin/end/dict/ + /// def/findresource/defineresource/pop/currentdict/ + /// begincmap/endcmap), plus begincodespacerange/ + /// endcodespacerange, is silently ignored - it carries no mapping-relevant data, + /// and a blanket fail-closed rule against every non-bfchar/bfrange keyword + /// would reject 100% of real-world /ToUnicode streams. usecmap, + /// cidrange, and cidchar, however, are data-bearing operators this + /// narrow parser does not implement - silently ignoring them could silently produce a + /// wrong/incomplete map, so they instead fail closed with + /// . A bfrange whose array + /// destination contains a nested array (the CIDSystemInfo-style "array of arrays" + /// destination sub-form) is out of scope for the same reason and also fails closed. An + /// empty hex/literal-string destination (<>) - seen from real-world + /// producers such as WeasyPrint to mean "this code has no single-character Unicode + /// equivalent" - is not an unbalanced/malformed block shape, so it is skipped (no mapping + /// is added for the affected code(s)) rather than failing closed; see + /// . + /// + /// The (already-resolved) font dictionary to query. + /// + /// The resolved code-to-Unicode-codepoint map, or when + /// /ToUnicode is absent or does not resolve to a stream (a malformed/absent + /// /ToUnicode is treated leniently as "no map", mirroring + /// 's own documented leniency conventions). + /// + /// + /// Thrown when the CMap stream contains a usecmap, cidrange, or + /// cidchar operator (feature pdf-font-tounicode-{operator}), or a + /// bfrange array destination containing a nested array (feature + /// pdf-font-tounicode-bfrange-array-destination). + /// + /// + /// Thrown when the CMap stream is not lexically well-formed, or contains an unbalanced + /// bfchar/bfrange block shape (for example a source/destination operand + /// that is not a string, or a destination array element that is not a string or array). + /// + internal IReadOnlyDictionary? ResolveToUnicodeMap(PdfObject fontDict) + { + var toUnicodeEntry = fontDict.Get("ToUnicode"); + if (toUnicodeEntry is null) + { + return null; + } + + var toUnicode = Resolve(toUnicodeEntry); + if (toUnicode.Kind != PdfKind.Stream) + { + return null; + } + + var bytes = GetStreamDecodedBytes(toUnicode); + var tokenizer = new PdfTokenizer(bytes); + var map = new Dictionary(); + + while (true) + { + var token = tokenizer.NextToken(); + if (token.Kind == PdfTokenKind.EndOfFile) + { + break; + } + + if (token.Kind != PdfTokenKind.Keyword) + { + // A bare number/string/name/array/dictionary operand outside a recognized + // bfchar/bfrange block (for example the "12 dict begin" preamble's leading + // number, or the /CIDSystemInfo dictionary operand of a "def") carries no + // mapping-relevant data - it is simply discarded, mirroring this method's own + // "silently ignore non-data-bearing CMap structure" posture. + continue; + } + + switch (token.Text) + { + case "beginbfchar": + ParseBfCharBlock(tokenizer, map); + break; + + case "beginbfrange": + ParseBfRangeBlock(tokenizer, map); + break; + + case "begincodespacerange": + SkipCMapBlock(tokenizer, "endcodespacerange"); + break; + + case "usecmap": + case "cidrange": + case "cidchar": + throw new UnsupportedImageFeatureException( + $"pdf-font-tounicode-{token.Text}", + $"/ToUnicode CMap operator '{token.Text}' is not supported; only " + + "bfchar/bfrange (plus the standard CMap PostScript wrapper) are supported."); + + default: + // Every other keyword (begin/end/dict/def/findresource/defineresource/pop/ + // currentdict/begincmap/endcmap/anything else) is the mandatory Adobe CMap + // PostScript wrapper boilerplate (or otherwise non-data-bearing) - silently + // ignored, see this method's own remarks. + break; + } + } + + return map; + } + + /// + /// Parses a beginbfchar ... endbfchar block: each pair is a hex-string + /// source code and a hex-string or literal-string (UTF-16BE) destination. + /// + /// + /// Thrown when the block is unterminated, or a source/destination operand is not a + /// string. + /// + private static void ParseBfCharBlock(PdfTokenizer tokenizer, Dictionary map) + { + while (true) + { + var token = tokenizer.NextToken(); + if (token.Kind == PdfTokenKind.Keyword && token.Text == "endbfchar") + { + return; + } + + if (token.Kind == PdfTokenKind.EndOfFile) + { + throw new InvalidDataException("Unterminated beginbfchar/endbfchar block in /ToUnicode CMap."); + } + + if (token.Kind is not (PdfTokenKind.HexString or PdfTokenKind.LiteralString)) + { + throw new InvalidDataException("A bfchar entry's source code must be a string."); + } + + var code = DecodeBigEndianCode(token.Bytes ?? []); + + var destinationToken = tokenizer.NextToken(); + if (destinationToken.Kind is not (PdfTokenKind.HexString or PdfTokenKind.LiteralString)) + { + throw new InvalidDataException("A bfchar entry's destination must be a string."); + } + + // An empty destination string (e.g. "<0003> <>", seen from real-world producers such + // as WeasyPrint) signals "no single-character Unicode equivalent" for this code - skip + // it rather than treating it as malformed. + if (TryDecodeFirstUtf16CodePoint(destinationToken.Bytes ?? [], out var codepoint)) + { + map[code] = codepoint; + } + } + } + + /// + /// Parses a beginbfrange ... endbfrange block: each triple is a + /// srcLo/srcHi hex-string pair plus a destination that is either a single + /// hex/literal string (consecutive codes map to consecutive incrementing codepoints + /// starting at that destination's codepoint) or an array of per-code hex/literal-string + /// destinations (one element per code in [srcLo, srcHi]). + /// + /// + /// Thrown when the destination array contains a nested array (the out-of-scope + /// CIDSystemInfo-style "array of arrays" destination sub-form). + /// + /// + /// Thrown when the block is unterminated, srcLo/srcHi is not a string, the + /// destination is not a string or array, a destination array element is not a string, or + /// a single-string destination's [srcLo, srcHi] span exceeds + /// . + /// + private static void ParseBfRangeBlock(PdfTokenizer tokenizer, Dictionary map) + { + while (true) + { + var loToken = tokenizer.NextToken(); + if (loToken.Kind == PdfTokenKind.Keyword && loToken.Text == "endbfrange") + { + return; + } + + if (loToken.Kind == PdfTokenKind.EndOfFile) + { + throw new InvalidDataException("Unterminated beginbfrange/endbfrange block in /ToUnicode CMap."); + } + + if (loToken.Kind is not (PdfTokenKind.HexString or PdfTokenKind.LiteralString)) + { + throw new InvalidDataException("A bfrange entry's srcLo must be a string."); + } + + var hiToken = tokenizer.NextToken(); + if (hiToken.Kind is not (PdfTokenKind.HexString or PdfTokenKind.LiteralString)) + { + throw new InvalidDataException("A bfrange entry's srcHi must be a string."); + } + + var srcLo = DecodeBigEndianCode(loToken.Bytes ?? []); + var srcHi = DecodeBigEndianCode(hiToken.Bytes ?? []); + + var destinationToken = tokenizer.NextToken(); + if (destinationToken.Kind is PdfTokenKind.HexString or PdfTokenKind.LiteralString) + { + // A single hex/literal-string destination: consecutive codes map to consecutive + // incrementing codepoints starting at the destination's own first codepoint. An + // empty destination string means the whole range has no Unicode equivalent - skip + // it rather than treating it as malformed. + // + // Reject an oversized span before expanding it - a crafted tiny srcLo/srcHi pair + // declaring an enormous span could otherwise allocate an enormous map even though + // the CMap stream itself is tiny (a decompression-bomb-style attack). The span and + // loop counter are computed in `long` (rather than `int`) because srcLo/srcHi may + // be decoded from up to 4-byte codes - an `int`-only computation could overflow for + // a crafted endpoint near `int.MaxValue`/`int.MinValue`, which would either corrupt + // the span check or make the loop counter wrap and never terminate. + var spanLong = (long)srcHi - srcLo + 1; + if (spanLong is <= 0 or > ToUnicodeMaxBfRangeSpan) + { + throw new InvalidDataException( + $"A bfrange entry's span exceeds the maximum supported size of {ToUnicodeMaxBfRangeSpan} codes."); + } + + if (TryDecodeFirstUtf16CodePoint(destinationToken.Bytes ?? [], out var startCodepoint)) + { + for (var code = (long)srcLo; code <= srcHi; code++) + { + map[(int)code] = startCodepoint + (int)(code - srcLo); + } + } + + continue; + } + + if (destinationToken.Kind == PdfTokenKind.ArrayStart) + { + // An array-of-per-code destinations - one hex/literal-string element per code in + // [srcLo, srcHi]. A nested array element is the out-of-scope CIDSystemInfo-style + // "array of arrays" destination sub-form. + var code = srcLo; + while (true) + { + var elementToken = tokenizer.NextToken(); + if (elementToken.Kind == PdfTokenKind.ArrayEnd) + { + break; + } + + if (elementToken.Kind == PdfTokenKind.EndOfFile) + { + throw new InvalidDataException("Unterminated bfrange destination array in /ToUnicode CMap."); + } + + if (elementToken.Kind == PdfTokenKind.ArrayStart) + { + throw new UnsupportedImageFeatureException( + "pdf-font-tounicode-bfrange-array-destination", + "A bfrange destination array containing a nested array (the " + + "CIDSystemInfo-style destination sub-form) is not supported."); + } + + if (elementToken.Kind is not (PdfTokenKind.HexString or PdfTokenKind.LiteralString)) + { + throw new InvalidDataException("A bfrange destination array element must be a string."); + } + + // An empty destination string means this specific code has no Unicode + // equivalent - skip mapping it rather than treating it as malformed. + if (TryDecodeFirstUtf16CodePoint(elementToken.Bytes ?? [], out var elementCodepoint)) + { + map[code] = elementCodepoint; + } + + code++; + } + + continue; + } + + throw new InvalidDataException("A bfrange entry's destination must be a string or an array."); + } + } + + /// + /// Discards every token from the tokenizer's current position up to and including the + /// given ending keyword, tolerating (but ignoring) any nested content - used for + /// begincodespacerange/endcodespacerange, which carries no + /// bfchar/bfrange-mapping-relevant data. + /// + /// Thrown when the block is unterminated. + private static void SkipCMapBlock(PdfTokenizer tokenizer, string endKeyword) + { + while (true) + { + var token = tokenizer.NextToken(); + if (token.Kind == PdfTokenKind.EndOfFile) + { + throw new InvalidDataException($"Unterminated CMap block (expected '{endKeyword}')."); + } + + if (token.Kind == PdfTokenKind.Keyword && token.Text == endKeyword) + { + return; + } + } + } + + /// + /// Decodes a source-code string's raw bytes as a single big-endian integer (mirroring + /// 's own big-endian decode idiom, generalized to any + /// byte count, since a CMap's own codespacerange may declare a 1-, 2-, or wider + /// byte source-code width). + /// + private static int DecodeBigEndianCode(byte[] bytes) + { + var value = 0; + foreach (var b in bytes) + { + value = (value << 8) | b; + } + + return value; + } + + /// + /// Attempts to decode a destination string's raw bytes as UTF-16BE, returning only its + /// first decoded UTF-16 code unit's codepoint. + /// + /// + /// A bfchar/bfrange destination may legitimately decode to more than one + /// UTF-16 code unit (for example a ligature mapping to a multi-character string) - this + /// is a deliberate, documented simplification (per this phase's scope): only the first + /// codepoint is kept, since is a + /// resolved-but-unconsumed groundwork field this phase, not a full multi-codepoint + /// text-extraction map. + /// + /// + /// (with left at zero) when + /// is empty - real-world producers (for example WeasyPrint) emit + /// an empty destination string (<>) to mean "this code has no single-character + /// Unicode equivalent", which is skipped rather than treated as malformed. Otherwise + /// . + /// + /// + /// Thrown when the destination string is non-empty but has an odd byte count (not a + /// valid UTF-16BE encoding of a whole number of code units). + /// + private static bool TryDecodeFirstUtf16CodePoint(byte[] bytes, out int codepoint) + { + if (bytes.Length == 0) + { + codepoint = 0; + return false; + } + + if (bytes.Length % 2 != 0) + { + throw new InvalidDataException( + "A non-empty bfchar/bfrange destination string must be an even-length UTF-16BE encoding."); + } + + codepoint = (bytes[0] << 8) | bytes[1]; + return true; + } +} diff --git a/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Fonts.Type0.cs b/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Fonts.Type0.cs new file mode 100644 index 00000000..fed9a5b6 --- /dev/null +++ b/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Fonts.Type0.cs @@ -0,0 +1,439 @@ +// cspell:ignore cidfonttype fontfile +using DemaConsulting.CanvasNet.Codecs; +using DemaConsulting.CanvasNet.Fonts; + +namespace DemaConsulting.CanvasNet.Pdf; + +public sealed partial class PdfDocument +{ + /// + /// A composite (two-byte-code) /Subtype /Type0 font, fully resolved from its + /// /Resources/Font dictionary: the loaded descendant-font , + /// its code (CID, since only /Encoding /Identity-H is supported - see + /// ) to glyph-index map, and its CID-to-declared- + /// advance-width table. + /// + /// + /// Instances are built once by and cached by + /// in , exactly like + /// - see that type's own remarks for the cache's scope. + /// The descendant font may be a CIDFontType2 (TrueType-outline, embedded + /// /FontFile2) or a CIDFontType0 (non-CID-keyed-CFF-outline, embedded + /// /FontFile3 - either SFNT-wrapped or a bare, standalone CFF stream, sniffed from + /// the stream's own bytes rather than trusted from its declared /Subtype - see + /// ) font - see + /// for the exact dispatch and each subtype's scope/non-goals. + /// + private sealed class ResolvedCompositeFont : IResolvedFont + { + /// Gets the loaded embedded descendant-font TrueType font. + internal required TrueTypeFont Font { get; init; } + + /// + /// A composite /Identity-H font always decodes exactly two (big-endian) bytes + /// per character code. + /// + int IResolvedFont.CodeByteWidth => 2; + + /// + /// Gets the CID-to-glyph-index map, resolved from the descendant font's + /// /CIDToGIDMap entry (the identity map when absent or /Identity, or an + /// explicit per-CID table for a stream - see ). + /// + internal required Func CidToGid { get; init; } + + /// + /// Gets the descendant font's /DW value (default width, in glyph-space units + /// per 1000), used for any CID absent from . + /// + internal required double DefaultWidth { get; init; } + + /// + /// Gets the CID (since /Encoding /Identity-H maps a code directly to a CID) to + /// declared advance width map (in glyph-space units per 1000), resolved from the + /// descendant font's /W array. + /// + internal required IReadOnlyDictionary CidWidths { get; init; } + + /// + /// Gets the font dictionary's resolved /ToUnicode CMap (see + /// ), or when /ToUnicode + /// is absent or malformed. A resolved-but-unconsumed field as of Phase 10 - nothing + /// reads this at rendering time; it exists purely as groundwork for a future + /// text-extraction feature and Phase 11's composite-font fallback-substitution + /// decision. + /// + internal IReadOnlyDictionary? ToUnicode { get; init; } + + /// + /// Resolves a two-byte /Identity-H code (which, per the PDF specification, is + /// the CID directly - no CMap indirection) to this font and a glyph index via + /// , and its advance width from , + /// falling back to when the CID has no declared width. + /// + public (TrueTypeFont? Font, int GlyphIndex, double Width) Resolve(int code) + { + var glyphIndex = CidToGid(code); + var width = CidWidths.TryGetValue(code, out var declaredWidth) ? declaredWidth : DefaultWidth; + return (Font, glyphIndex, width / 1000.0); + } + } + + /// + /// Builds a from a /Subtype /Type0 font + /// dictionary: requires /Encoding /Identity-H and a single-element + /// /DescendantFonts array whose sole element is either a + /// /Subtype /CIDFontType2 dictionary with an embedded + /// /FontDescriptor/FontFile2 (resolving that descendant's /CIDToGIDMap), or + /// a /Subtype /CIDFontType0 dictionary with an embedded, non-CID-keyed-CFF + /// /FontDescriptor/FontFile3 - either SFNT-wrapped or a bare, standalone CFF + /// stream (see - CID is used directly as glyph index, + /// identity; any non-standard /CIDToGIDMap on such a descendant is ignored), and + /// resolves the descendant's /DW//W entries either way. + /// + /// + /// No fallback substitution is attempted for a composite font with no embedded + /// descendant-font program (unlike 's + /// path) - this is a deliberate, documented Non-Goal of + /// this phase: composite fonts are always required to embed their descendant font. + /// + /// + /// Thrown when /DescendantFonts is missing, does not resolve to a single-element + /// array whose element resolves to a dictionary, when the descendant's + /// /FontDescriptor is missing, when a CIDFontType2 descendant's + /// /FontDescriptor/FontFile2 is missing or does not resolve to a stream, when a + /// CIDFontType0 descendant's /FontDescriptor/FontFile3 is missing or does + /// not resolve to a stream, or when the embedded font program cannot be parsed (for + /// example a CID-keyed CFF program, which rejects). + /// + /// + /// Thrown when /Encoding is not the name Identity-H (for example + /// Identity-V or a predefined CJK encoding), when the descendant font's + /// /Subtype is neither CIDFontType2 nor CIDFontType0, or when a + /// CIDFontType0 descendant's /FontFile3 stream's decoded bytes match + /// neither a recognized SFNT container nor a structurally plausible bare CFF header (see + /// ). + /// + private IResolvedFont BuildResolvedCompositeFont(PdfObject fontDict) + { + var encodingEntry = fontDict.Get("Encoding") + ?? throw new InvalidDataException("Type0 font dictionary is missing required /Encoding."); + var encoding = Resolve(encodingEntry); + if (encoding.Kind != PdfKind.Name || encoding.Text != "Identity-H") + { + var encodingName = encoding.Kind == PdfKind.Name ? encoding.Text : "(non-name)"; + throw new UnsupportedImageFeatureException( + $"pdf-font-type0-encoding-{encodingName}", + $"Type0 font /Encoding '{encodingName}' is not supported; only /Identity-H is " + + "supported (/Identity-V and predefined CJK encodings are not supported)."); + } + + var descendantFontsEntry = fontDict.Get("DescendantFonts") + ?? throw new InvalidDataException("Type0 font dictionary is missing required /DescendantFonts."); + var descendantFonts = Resolve(descendantFontsEntry); + if (descendantFonts.Kind != PdfKind.Array || descendantFonts.Items.Count != 1) + { + throw new InvalidDataException("/DescendantFonts must be an array of exactly one element."); + } + + var descendantFont = Resolve(descendantFonts.Items[0]); + if (descendantFont.Kind != PdfKind.Dictionary && descendantFont.Kind != PdfKind.Stream) + { + throw new InvalidDataException("/DescendantFonts element must resolve to a dictionary."); + } + + var descendantSubtype = GetNameValue(descendantFont, "Subtype"); + if (descendantSubtype != "CIDFontType2" && descendantSubtype != "CIDFontType0") + { + throw new UnsupportedImageFeatureException( + $"pdf-font-cidfonttype-{descendantSubtype ?? "missing"}", + $"Descendant font /Subtype '{descendantSubtype ?? "(missing)"}' is not " + + "supported; only /CIDFontType2 and /CIDFontType0 are supported."); + } + + var descriptorEntry = descendantFont.Get("FontDescriptor") + ?? throw new InvalidDataException("Descendant font dictionary is missing required /FontDescriptor."); + var descriptor = Resolve(descriptorEntry); + + var (font, cidToGid) = descendantSubtype switch + { + "CIDFontType2" => (LoadCidFontType2Font(descriptor), ResolveCidToGidMap(descendantFont)), + _ => (LoadCidFontType0Font(descriptor), (Func)(cid => cid)), + }; + + var (cidWidths, defaultWidth) = ResolveCompositeWidths(descendantFont); + var toUnicode = ResolveToUnicodeMap(fontDict); + + return new ResolvedCompositeFont + { + Font = font, + CidToGid = cidToGid, + DefaultWidth = defaultWidth, + CidWidths = cidWidths, + ToUnicode = toUnicode, + }; + } + + /// + /// Loads a CIDFontType2 descendant's embedded /FontDescriptor/FontFile2 + /// TrueType-outline font program, unchanged from the pre-Phase-12 behavior. + /// + /// + /// Thrown when /FontDescriptor/FontFile2 is missing or does not resolve to a + /// stream. + /// + private TrueTypeFont LoadCidFontType2Font(PdfObject descriptor) + { + var fontFileEntry = descriptor.Get("FontFile2") + ?? throw new InvalidDataException("Descendant font /FontDescriptor is missing required /FontFile2."); + var fontFileStream = Resolve(fontFileEntry); + if (fontFileStream.Kind != PdfKind.Stream) + { + throw new InvalidDataException("/FontDescriptor/FontFile2 does not resolve to a stream."); + } + + var fontBytes = GetStreamDecodedBytes(fontFileStream); + return TrueTypeFont.Load(new MemoryStream(fontBytes)); + } + + /// + /// Loads a CIDFontType0 descendant's embedded /FontDescriptor/FontFile3 + /// font program - either an SFNT-wrapped ('OTTO') CFF program or a bare, + /// standalone (/CIDFontType0C-style) CFF program: decodes the stream via + /// , sniffs its actual byte container shape via + /// (rather than trusting the stream's own declared + /// /Subtype name - see this method's own remarks), and dispatches to + /// (SFNT-wrapped) or + /// (bare CFF, passing + /// since a composite font's CID-to-glyph-index + /// resolution never consults the loaded font's own cmap-equivalent lookup - see + /// below). Per PDF 32000-1 §9.7.4.2, a CID is interpreted directly as a glyph index + /// (identity) for a CIDFontType0 whose CFF program is not CID-keyed - this method + /// does not, and cannot, distinguish that case itself; it is 's + /// own CFF parsing that rejects a CID-keyed (ROS-bearing) CFF program by throwing + /// , which is allowed to propagate uncaught here - + /// consistent with this codebase's "composite fonts fail closed on any embedded-font + /// problem, no fallback" convention. + /// + /// + /// + /// Per PDF 32000-1 §9.6.2.2/Table 126, a CIDFontType0 descendant's + /// /FontFile3 stream is supposed to declare its own /Subtype as + /// OpenType (an SFNT-wrapped CFF stream) or CIDFontType0C (a bare, standalone + /// CFF stream) - but the specification does not actually require that declared name to + /// match the stream's real byte container shape, and real-world producers sometimes emit + /// exactly that mismatch (for example declaring CIDFontType0C while writing an + /// SFNT-wrapped stream, or vice versa). This method therefore never consults the stream's + /// own /Subtype at all for dispatch - it sniffs the actual bytes instead, + /// mirroring 's own identical shape-sniffing precedent + /// (PdfDocument.Fonts.Type1.cs) for a simple font's /FontFile3. Bytes + /// matching neither a recognized SFNT container nor a structurally plausible bare CFF + /// header are rejected with rather than + /// guessed at. + /// + /// + /// A non-standard /CIDToGIDMap key on the CIDFontType0 descendant dictionary + /// (not a valid key for this subtype per the PDF specification) is deliberately never + /// consulted by this method or its caller. + /// + /// + /// + /// Thrown when /FontDescriptor/FontFile3 is missing or does not resolve to a + /// stream, or propagated from / + /// for a malformed or CID-keyed embedded CFF + /// program, or any other malformed embedded SFNT font. + /// + /// + /// Thrown when the /FontFile3 stream's decoded bytes match neither a recognized + /// SFNT container nor a structurally plausible bare CFF header. + /// + private TrueTypeFont LoadCidFontType0Font(PdfObject descriptor) + { + var fontFileEntry = descriptor.Get("FontFile3") + ?? throw new InvalidDataException("Descendant font /FontDescriptor is missing required /FontFile3."); + var fontFileStream = Resolve(fontFileEntry); + if (fontFileStream.Kind != PdfKind.Stream) + { + throw new InvalidDataException("/FontDescriptor/FontFile3 does not resolve to a stream."); + } + + var fontBytes = GetStreamDecodedBytes(fontFileStream); + return SniffFontFile3Shape(fontBytes) switch + { + FontFile3Shape.Sfnt => TrueTypeFont.Load(new MemoryStream(fontBytes)), + FontFile3Shape.BareCff => TrueTypeFont.LoadType1C(new MemoryStream(fontBytes), EmptyCodepointToGlyphName), + _ => throw new UnsupportedImageFeatureException( + "pdf-font-fontfile3-unrecognized-shape", + "/FontFile3 stream's decoded bytes are neither a recognized SFNT container " + + "(TrueType/OpenType/CFF) nor a structurally plausible bare Type1C/CFF program " + + $"(declared /Subtype '{GetNameValue(fontFileStream, "Subtype") ?? "(missing)"}')."), + }; + } + + /// + /// Resolves a CIDFontType2 descendant dictionary's /CIDToGIDMap entry into a + /// CID-to-glyph-index function: the identity map when absent or the name /Identity, + /// or an explicit big-endian uint16-per-CID lookup table when a stream (an + /// out-of-range/negative CID maps to glyph 0/.notdef, per the PDF + /// specification). /CIDToGIDMap is a CIDFontType2-only key per the PDF + /// specification - this method is never called for a CIDFontType0 descendant, + /// which unconditionally uses the identity CID-to-glyph-index map instead (see + /// and 's own + /// remarks). + /// + /// + /// Thrown when /CIDToGIDMap resolves to anything other than the name + /// Identity or a stream, or when a stream's decoded byte count is odd. + /// + private Func ResolveCidToGidMap(PdfObject descendantFont) + { + var cidToGidMapEntry = descendantFont.Get("CIDToGIDMap"); + if (cidToGidMapEntry is null) + { + return cid => cid; + } + + var cidToGidMap = Resolve(cidToGidMapEntry); + if (cidToGidMap.Kind == PdfKind.Name) + { + if (cidToGidMap.Text != "Identity") + { + throw new InvalidDataException( + $"/CIDToGIDMap name '/{cidToGidMap.Text}' is not supported; only /Identity is supported."); + } + + return cid => cid; + } + + if (cidToGidMap.Kind != PdfKind.Stream) + { + throw new InvalidDataException("/CIDToGIDMap must be the name /Identity or a stream."); + } + + var bytes = GetStreamDecodedBytes(cidToGidMap); + if (bytes.Length % 2 != 0) + { + throw new InvalidDataException("/CIDToGIDMap stream byte length must be a multiple of 2."); + } + + var table = new int[bytes.Length / 2]; + for (var i = 0; i < table.Length; i++) + { + table[i] = (bytes[i * 2] << 8) | bytes[(i * 2) + 1]; + } + + return cid => cid >= 0 && cid < table.Length ? table[cid] : 0; + } + + /// + /// The maximum number of CIDs a single /W array cFirst cLast w range-form + /// entry may expand, bounding a crafted range (for example [0 999999999 500]) from + /// looping effectively unbounded and exhausting memory. Generous for any real CID-keyed + /// font, which rarely exceeds the 16-bit CID space in practice for a single contiguous + /// /W range. + /// + private const int MaxCompositeWidthRangeSpan = 65536; + + /// + /// Resolves a descendant CIDFontType2 dictionary's /DW (default width, defaulting + /// to 1000 per the PDF specification when absent) and /W (per-CID declared + /// width overrides, supporting both the c [w1 w2 ... wn] individual-width form and + /// the cFirst cLast w range form) entries. + /// + /// + /// The CID-to-width map (empty when /W is absent or not an array - a documented + /// leniency, since itself falls back to the + /// resolved /DW for any CID missing from this map), and the resolved /DW + /// value (1000 when not declared). + /// + /// + /// Thrown when a /W array's shape does not match either documented sub-form (for + /// example a starting code not followed by an array or a number, a range form whose third + /// element is not a number, a range form whose cLast is less than its + /// cFirst, or a range form spanning more than + /// CIDs). + /// + private (IReadOnlyDictionary CidWidths, double DefaultWidth) ResolveCompositeWidths(PdfObject descendantFont) + { + var defaultWidth = 1000.0; + var dwEntry = descendantFont.Get("DW"); + if (dwEntry is not null && Resolve(dwEntry) is { Kind: PdfKind.Number } dwValue) + { + defaultWidth = dwValue.Number; + } + + var cidWidths = new Dictionary(); + var wEntry = descendantFont.Get("W"); + if (wEntry is not null && Resolve(wEntry) is { Kind: PdfKind.Array } wArray) + { + var items = wArray.Items; + var i = 0; + while (i < items.Count) + { + if (Resolve(items[i]) is not { Kind: PdfKind.Number } firstValue) + { + throw new InvalidDataException("/W array entries must start with a CID number."); + } + + var firstCid = (int)firstValue.Number; + if (i + 1 >= items.Count) + { + throw new InvalidDataException("/W array is missing the widths for its final entry."); + } + + var second = Resolve(items[i + 1]); + if (second.Kind == PdfKind.Array) + { + // c [w1 w2 ... wn] individual-width form. + for (var j = 0; j < second.Items.Count; j++) + { + if (Resolve(second.Items[j]) is { Kind: PdfKind.Number } w) + { + cidWidths[firstCid + j] = w.Number; + } + } + + i += 2; + } + else if (second.Kind == PdfKind.Number) + { + // cFirst cLast w range form. + var cLast = (int)second.Number; + if (i + 2 >= items.Count || Resolve(items[i + 2]) is not { Kind: PdfKind.Number } wValue) + { + throw new InvalidDataException("/W array's cFirst-cLast-w range form must be followed by a width number."); + } + + if (cLast < firstCid) + { + throw new InvalidDataException("/W array's cFirst-cLast-w range form must have cLast >= cFirst."); + } + + if ((long)cLast - firstCid > MaxCompositeWidthRangeSpan) + { + throw new InvalidDataException( + $"/W array's cFirst-cLast-w range form spans more than {MaxCompositeWidthRangeSpan} CIDs."); + } + + // Iterate with a long counter rather than an int one: even with a small, + // already-validated span, a cLast at or near int.MaxValue would make an + // int-typed increment overflow to int.MinValue on the final iteration, which + // would still satisfy the loop's exit condition and hang indefinitely. + for (var cid = (long)firstCid; cid <= cLast; cid++) + { + cidWidths[(int)cid] = wValue.Number; + } + + i += 3; + } + else + { + throw new InvalidDataException( + "/W array entries must be followed by either an array of widths or a cLast/w pair."); + } + } + } + + return (cidWidths, defaultWidth); + } +} diff --git a/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Fonts.Type1.cs b/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Fonts.Type1.cs new file mode 100644 index 00000000..f808bd8d --- /dev/null +++ b/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Fonts.Type1.cs @@ -0,0 +1,148 @@ +// cspell:ignore fontfile +using DemaConsulting.CanvasNet.Codecs; +using DemaConsulting.CanvasNet.Fonts; + +namespace DemaConsulting.CanvasNet.Pdf; + +public sealed partial class PdfDocument +{ + /// + /// Loads a simple font's embedded /FontDescriptor/FontFile classic PostScript + /// Type 1 font program: requires the stream itself (not the font descriptor) to declare + /// numeric /Length1//Length2 entries (the cleartext/encrypted segment byte + /// counts needs), decodes the stream via + /// , and loads it via + /// , passing + /// as the codepoint-to-glyph-name encoding (this class's own existing Adobe-glyph-name + /// vocabulary, reused rather than duplicated - see that field's own remarks). + /// + /// + /// + /// Per PDF 32000-1 §9.9, a Type 1 font program's own /Length1//Length2 + /// byte counts are declared on the /FontFile stream dictionary itself, not on the + /// font descriptor that references it - a subtle, easy-to-get-backwards detail this method + /// deliberately reads from 's resolved /FontFile + /// stream object, never from directly. + /// + /// + /// Any itself + /// throws for a malformed eexec-encrypted segment, an unparsable + /// /CharStrings//Subrs dictionary, or a rejected seac charstring is + /// allowed to propagate uncaught here - consistent with this codebase's "embedded fonts + /// fail closed on any embedded-font problem, no fallback" convention, the same convention + /// LoadCidFontType0Font documents for the composite-font CFF path + /// (PdfDocument.Fonts.Type0.cs). + /// + /// + /// The resolved /FontDescriptor dictionary. + /// The loaded . + /// + /// Thrown when /FontDescriptor/FontFile is missing or does not resolve to a + /// stream, when the /FontFile stream's own /Length1 or /Length2 entry + /// is missing or does not resolve to a number, or propagated from + /// for a malformed embedded Type 1 program. + /// + private TrueTypeFont LoadType1Font(PdfObject descriptor) + { + var fontFileEntry = descriptor.Get("FontFile") + ?? throw new InvalidDataException("Descriptor /FontDescriptor is missing required /FontFile."); + var fontFileStream = Resolve(fontFileEntry); + if (fontFileStream.Kind != PdfKind.Stream) + { + throw new InvalidDataException("/FontDescriptor/FontFile does not resolve to a stream."); + } + + var length1Entry = fontFileStream.Get("Length1") + ?? throw new InvalidDataException("/FontDescriptor/FontFile is missing required /Length1."); + if (Resolve(length1Entry) is not { Kind: PdfKind.Number } length1Value) + { + throw new InvalidDataException("/FontDescriptor/FontFile's /Length1 does not resolve to a number."); + } + + var length2Entry = fontFileStream.Get("Length2") + ?? throw new InvalidDataException("/FontDescriptor/FontFile is missing required /Length2."); + if (Resolve(length2Entry) is not { Kind: PdfKind.Number } length2Value) + { + throw new InvalidDataException("/FontDescriptor/FontFile's /Length2 does not resolve to a number."); + } + + var fontBytes = GetStreamDecodedBytes(fontFileStream); + return TrueTypeFont.LoadType1( + new MemoryStream(fontBytes), (int)length1Value.Number, (int)length2Value.Number, CodepointToStandardGlyphName); + } + + /// + /// Loads a simple font's embedded /FontDescriptor/FontFile3 font program - + /// either a bare Type1C (standalone, non-SFNT-wrapped CFF) program or an SFNT-wrapped + /// ('OTTO') CFF program: decodes the stream via , + /// sniffs its actual byte container shape via (rather + /// than trusting the stream's own declared /Subtype name - see this method's own + /// remarks), and dispatches to (bare CFF) or + /// (SFNT-wrapped) accordingly, passing + /// as the bare-CFF codepoint-to-glyph-name + /// encoding - exactly as does for the classic /FontFile + /// path (see that method's own remarks for why no font-dict-specific /Encoding + /// resolution is needed here either). + /// + /// + /// + /// Per PDF 32000-1 §9.6.2.2/Table 126, a simple /Type1 font's + /// /FontFile3 stream is supposed to declare its own /Subtype as + /// Type1C (a bare CFF stream) or OpenType (an SFNT-wrapped CFF stream) - but + /// the specification does not actually require that declared name to match the stream's + /// real byte container shape, and real-world producers sometimes emit exactly that + /// mismatch (for example declaring OpenType while writing a bare CFF stream, or + /// vice versa). This method therefore never consults the stream's own /Subtype at + /// all for dispatch - it sniffs the actual bytes instead, mirroring + /// 's own identical shape-sniffing precedent + /// (PdfDocument.Fonts.Type0.cs) for a composite font's CIDFontType0 + /// descendant. Bytes matching neither a recognized SFNT container nor a structurally + /// plausible bare CFF header are rejected with + /// rather than guessed at. + /// + /// + /// A CID-keyed (ROS-bearing) CFF program - bare or SFNT-wrapped - is rejected + /// transitively: this method never itself inspects the CFF Top DICT for ROS, but + /// (invoked by both + /// and 's + /// own CFF-outline branch) already does, throwing , + /// which is allowed to propagate uncaught here - the same "embedded fonts fail closed on + /// any embedded-font problem, no fallback" convention + /// documents for its own /FontFile path. + /// + /// + /// The resolved /FontDescriptor dictionary. + /// The loaded . + /// + /// Thrown when /FontDescriptor/FontFile3 is missing or does not resolve to a + /// stream, or propagated from / + /// for a malformed or CID-keyed embedded CFF + /// program, or any other malformed embedded SFNT font. + /// + /// + /// Thrown when the /FontFile3 stream's decoded bytes match neither a recognized + /// SFNT container nor a structurally plausible bare CFF header. + /// + private TrueTypeFont LoadType1CFont(PdfObject descriptor) + { + var fontFileEntry = descriptor.Get("FontFile3") + ?? throw new InvalidDataException("Descriptor /FontDescriptor is missing required /FontFile3."); + var fontFileStream = Resolve(fontFileEntry); + if (fontFileStream.Kind != PdfKind.Stream) + { + throw new InvalidDataException("/FontDescriptor/FontFile3 does not resolve to a stream."); + } + + var fontBytes = GetStreamDecodedBytes(fontFileStream); + return SniffFontFile3Shape(fontBytes) switch + { + FontFile3Shape.Sfnt => TrueTypeFont.Load(new MemoryStream(fontBytes)), + FontFile3Shape.BareCff => TrueTypeFont.LoadType1C(new MemoryStream(fontBytes), CodepointToStandardGlyphName), + _ => throw new UnsupportedImageFeatureException( + "pdf-font-fontfile3-unrecognized-shape", + "/FontFile3 stream's decoded bytes are neither a recognized SFNT container " + + "(TrueType/OpenType/CFF) nor a structurally plausible bare Type1C/CFF program " + + $"(declared /Subtype '{GetNameValue(fontFileStream, "Subtype") ?? "(missing)"}')."), + }; + } +} diff --git a/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Fonts.Type3.cs b/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Fonts.Type3.cs new file mode 100644 index 00000000..1d5a31d8 --- /dev/null +++ b/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Fonts.Type3.cs @@ -0,0 +1,421 @@ +// cspell:ignore charprocs fontmatrix +using System.Numerics; +using DemaConsulting.CanvasNet.Codecs; +using DemaConsulting.CanvasNet.Fonts; + +namespace DemaConsulting.CanvasNet.Pdf; + +public sealed partial class PdfDocument +{ + /// + /// A procedure-painted /Subtype /Type3 font, fully resolved from its + /// /Resources/Font dictionary: its /FontMatrix (glyph-space-to-text-space + /// transform), its code-to-glyph-name /Encoding map, its glyph-name-to-content- + /// stream /CharProcs dictionary, its own optional /Resources (falls back to + /// the invoking page's resources at paint time when absent - see + /// ), and its code-to-declared-advance-width table. + /// + /// + /// Unlike /, a Type 3 + /// font has no outline-glyph font program at all: each glyph is itself a small content + /// stream (a /CharProcs entry) that paints its own ink via ordinary path- + /// construction/painting operators, interpreted by re-entering + /// exactly like a Form XObject's own content stream (see + /// , which mirrors 's own + /// save/restore/recurse pattern). Instances are built once by + /// and cached by in + /// , exactly like . + /// + private sealed class ResolvedType3Font : IResolvedFont + { + /// + /// Gets the font's required /FontMatrix: the glyph-space-to-text-space + /// transform every glyph procedure's path-construction coordinates (and the declared + /// advance width - see ) are mapped through, in place of the + /// fixed 1/1000 (or 1/UnitsPerEm) scale a simple/composite font's own + /// outline uses. + /// + internal required Matrix3x2 FontMatrix { get; init; } + + /// + /// Gets the code (0-255) to glyph-name map, resolved from /Encoding/Differences + /// (see ). A code absent from this map has no mapped + /// glyph name - treats this leniently (paints nothing, + /// still advances by the code's declared width), not as an error. + /// + internal required IReadOnlyDictionary Encoding { get; init; } + + /// + /// Gets the font's resolved, required /CharProcs dictionary, mapping a glyph + /// name (from ) to its own content-stream reference. A glyph + /// name absent from this dictionary is treated identically to an unmapped code (see + /// 's own remarks) - a documented leniency, not an error. + /// + internal required PdfObject CharProcs { get; init; } + + /// + /// Gets the font's own, already-resolved /Resources dictionary, or + /// when absent - falls back to + /// the invoking page/Form's own currently active resources in that case, exactly like + /// a Form XObject with no /Resources of its own (see + /// ). + /// + internal required PdfObject? Resources { get; init; } + + /// + /// Gets the code (0-255) to declared advance width map, in the font's own glyph-space + /// units (mapped through by - never + /// divided by a fixed 1000, unlike ), + /// from the font dictionary's /FirstChar//LastChar//Widths + /// entries (via the same helper + /// uses). + /// + internal required IReadOnlyDictionary Widths { get; init; } + + /// Gets the font's resolved /FontDescriptor/MissingWidth fallback (in the same glyph-space units as ), or 0 when not declared. + internal required double MissingWidth { get; init; } + + /// A Type 3 font is always a simple, single-byte-code font per the PDF specification. + int IResolvedFont.CodeByteWidth => 1; + + /// + /// Resolves 's declared advance width (an explicit + /// entry, else ) from the font's own + /// glyph-space units into text-space units by transforming it, as a displacement + /// vector (via , which applies + /// only 's linear scale/rotation/skew part, never its + /// translation - the mathematically correct operation for an advance width, which is a + /// vector, not a point), through - not divided by a + /// fixed 1000 like , since a Type 3 + /// font's own /FontMatrix (not a fixed convention) is its documented glyph- + /// space-to-text-space mapping for every glyph-space quantity. The returned font is + /// always (a Type 3 font has no outline-glyph font program at + /// all) and the returned glyph index is always 0 - neither is ever consulted by + /// 's own ResolvedType3Font branch - see + /// 's own glyph-name-keyed lookup instead. + /// + public (TrueTypeFont? Font, int GlyphIndex, double Width) Resolve(int code) + { + var glyphSpaceWidth = Widths.TryGetValue(code, out var declaredWidth) ? declaredWidth : MissingWidth; + var textSpaceWidth = Vector2.TransformNormal(new Vector2((float)glyphSpaceWidth, 0f), FontMatrix).X; + return (null, 0, textSpaceWidth); + } + } + + /// + /// The maximum number of nested Type 3 glyph-procedure invocations + /// allows before failing closed - a dedicated counter + /// distinct from /, + /// bounding a Type 3 glyph procedure's own re-entrance boundary (a glyph proc invoking + /// Tj against the same or another Type 3 font) independently of a Form XObject's + /// (crossed via Do) - each policy's own fail-closed message stays unambiguous about + /// which kind of nesting was exceeded, while the combined worst-case recursion depth (12 + /// Type 3 + 12 Form, alternating) remains small and finite. + /// + private const int MaxType3NestingDepth = 12; + + /// + /// The current Type 3 glyph-procedure nesting depth, incremented/decremented around every + /// call. Reset to 0 at the start of every top-level + /// call, alongside . + /// + private int _type3NestingDepth; + + /// + /// Builds a from a /Subtype /Type3 font dictionary: + /// requires and parses /FontMatrix (via ), requires and + /// resolves /CharProcs to a dictionary, resolves /Encoding (via + /// - lenient, see that method's own remarks), + /// resolves /Widths//FirstChar/the descriptor's /MissingWidth via the + /// existing, unmodified helper (the descriptor is resolved + /// from /FontDescriptor when present, else , + /// exactly like 's own pattern - a Type 3 font's + /// /FontDescriptor is optional per the PDF specification), and resolves + /// /Resources (optional, when absent). + /// + /// The font dictionary being resolved. + /// + /// Thrown when /FontMatrix is missing, is not an array of exactly 6 numbers, or + /// contains a non-number (propagated from ), or when + /// /CharProcs is missing or does not resolve to a dictionary. + /// + private IResolvedFont BuildResolvedType3Font(PdfObject fontDict) + { + var fontMatrix = ReadFontMatrix(fontDict); + + var charProcsEntry = fontDict.Get("CharProcs") + ?? throw new InvalidDataException("Type3 font dictionary is missing required /CharProcs."); + var charProcs = Resolve(charProcsEntry); + if (charProcs.Kind != PdfKind.Dictionary) + { + throw new InvalidDataException("/CharProcs must resolve to a dictionary."); + } + + var encoding = ResolveType3Encoding(fontDict.Get("Encoding")); + + var descriptorEntry = fontDict.Get("FontDescriptor"); + var descriptor = descriptorEntry is null ? PdfObject.NullValue : Resolve(descriptorEntry); + var (widths, missingWidth) = ResolveWidths(fontDict, descriptor); + + var resourcesEntry = fontDict.Get("Resources"); + var resources = resourcesEntry is null ? null : Resolve(resourcesEntry); + + return new ResolvedType3Font + { + FontMatrix = fontMatrix, + Encoding = encoding, + CharProcs = charProcs, + Resources = resources, + Widths = widths, + MissingWidth = missingWidth, + }; + } + + /// + /// Reads a /Subtype /Type3 font dictionary's required /FontMatrix entry: an + /// array of exactly 6 numbers, interpreted exactly like the cm operator's 6 + /// operands. Deliberately independently implemented from (not shared with) + /// - /FontMatrix is required (a Form XObject's + /// /Matrix is optional, defaulting to the identity matrix), so the two methods' + /// error-handling shape differs; keeping them separate also confines this new file's own + /// diff, avoiding any risk to the already-tested, unrelated Form-XObject path. + /// + /// The font dictionary being resolved. + /// The font's declared /FontMatrix. + /// + /// Thrown when /FontMatrix is missing, is not an array of exactly 6 numbers, or + /// contains a non-number entry. + /// + private Matrix3x2 ReadFontMatrix(PdfObject fontDict) + { + var matrixEntry = fontDict.Get("FontMatrix") + ?? throw new InvalidDataException("Type3 font dictionary is missing required /FontMatrix."); + + var resolved = Resolve(matrixEntry); + if (resolved.Kind != PdfKind.Array || resolved.Items.Count != 6) + { + throw new InvalidDataException("/FontMatrix must be an array of exactly 6 numbers."); + } + + var values = new double[6]; + for (var i = 0; i < 6; i++) + { + var item = Resolve(resolved.Items[i]); + if (item.Kind != PdfKind.Number) + { + throw new InvalidDataException("/FontMatrix entries must all be numbers."); + } + + values[i] = item.Number; + } + + return new Matrix3x2( + (float)values[0], + (float)values[1], + (float)values[2], + (float)values[3], + (float)values[4], + (float)values[5]); + } + + /// + /// Resolves a Type 3 font dictionary's /Encoding entry into a code-to-glyph-name + /// map (rather than a code-to-Unicode-codepoint map, which + /// builds for a simple font): extracts the dictionary's own /Differences array (if + /// any) and parses it via the shared helper, keeping each + /// glyph name verbatim (never validated against 's Adobe + /// Glyph List subset, unlike 's own policy) - a Type 3 + /// glyph name is a direct, font-specific key into /CharProcs, which may use + /// entirely arbitrary names outside any standard glyph-name vocabulary. + /// + /// + /// The font dictionary's /Encoding entry (expected to be a dictionary bearing + /// /Differences, or when absent). + /// + /// + /// The code-to-glyph-name map - empty when /Encoding is absent entirely, does not + /// resolve to a dictionary, or has no /Differences entry. This is a deliberate + /// leniency (every such code simply paints nothing, see ), + /// not a fail-closed policy - the PDF specification does not explicitly prescribe either + /// choice for this exact case, and this codebase's own established posture (see + /// 's own missing-/Widths leniency) favors graceful + /// degradation here. + /// + /// + /// Propagated from when a resolved /Differences + /// entry is present but is not an array, when an array entry is neither a number nor a + /// name, or when a name entry appears before any starting code. + /// + private IReadOnlyDictionary ResolveType3Encoding(PdfObject? encodingEntry) + { + var map = new Dictionary(); + if (encodingEntry is null) + { + return map; + } + + var resolved = Resolve(encodingEntry); + if (resolved.Kind != PdfKind.Dictionary) + { + return map; + } + + var differencesEntry = resolved.Get("Differences"); + if (differencesEntry is null) + { + return map; + } + + ParseDifferences(Resolve(differencesEntry), (code, name) => map[code] = name); + return map; + } + + /// + /// Paints a single Type 3 glyph procedure for - the Type 3 + /// counterpart of the outline-glyph fill path 's non-Type3 branch + /// performs, called from that same method's early ResolvedType3Font branch (guarded + /// identically by _gs.RenderMode != 3, so an invisible-mode glyph proc never + /// executes at all - parity with the outline path, which likewise skips + /// GetGlyphOutline/fill entirely in that mode). + /// + /// + /// + /// Looks up in 's own + /// ; an unmapped code paints nothing (a + /// documented leniency - the caller's own shared displacement/advance logic in + /// still runs unconditionally, so the text position still + /// advances by the code's declared width). Looks up the resolved glyph name in + /// ; a glyph name absent from + /// /CharProcs is the identical leniency. Otherwise resolves the /CharProcs + /// entry and requires it to be a stream ( if not). + /// + /// + /// The glyph procedure's own path-construction coordinate space is mapped to device + /// pixel-space by composing (applied + /// first/innermost, mapping the glyph proc's own glyph-space coordinates into text + /// space) with (applied second/outermost, + /// mapping text space the rest of the way to device pixel space) - the same + /// left-multiply premultiply convention cm/Trm composition already + /// establishes. This replaces the outline path's own fixed + /// 1/UnitsPerEm (or classic Type 1's 1/1000) scale entirely - a Type 3 + /// font's own /FontMatrix, not a fixed convention, is its documented glyph- + /// space-to-text-space mapping. + /// + /// + /// Executes the decoded glyph procedure bytes by re-entering + /// , following the exact same re-entrance pattern + /// establishes for a Form XObject: a + /// try/finally saves and unconditionally restores + /// //, bracketing the + /// glyph procedure's own CTM/color/font-selection/path-construction-state mutations + /// exactly like an implicit q ... Q - so, in particular, a cm or + /// color-setting operator inside a glyph procedure can never leak back out into the + /// invoking text-showing operator's own graphics state. is + /// set to the Type 3 font's own when + /// present, else falls back to the invoking stream's currently active + /// unchanged - exactly like a Form XObject with no + /// /Resources of its own. The nested graphics state is a clone of the invoking + /// (so the glyph proc inherits the caller's fill color, line style, + /// etc., as its own starting state) with + /// overridden to the composed glyph matrix above, and a fresh, empty + /// (so an unbalanced q/Q inside the glyph proc can + /// never touch the invoking stream's own saved states). + /// + /// + /// Scope boundary (deliberate, named limitation): this phase does + /// not enforce the PDF specification's own d1 "uncolored glyph" color- + /// suppression semantics - a d1 glyph procedure that itself sets a fill/stroke + /// color (via rg/g/k/sc/scn or their stroking + /// counterparts) is honored exactly like ordinary content, painting with its own + /// declared color rather than being forced to reuse the invoking text's own fill + /// color. This is the lighter-weight of the two PDF-specification-permitted options, + /// chosen to bound this phase's scope/risk; a future phase could add true d1 + /// color suppression without altering this phase's public contract. + /// + /// + /// The Type 3 font the glyph belongs to. + /// The character code being shown. + /// + /// Thrown when the current Type 3 nesting depth already equals + /// , when the resolved /CharProcs entry does not + /// resolve to a stream, or propagated from nested execution of the glyph procedure's own + /// content stream. + /// + private void PaintType3Glyph(ResolvedType3Font font, int code) + { + if (!font.Encoding.TryGetValue(code, out var glyphName)) + { + return; + } + + var charProcEntry = font.CharProcs.Get(glyphName); + if (charProcEntry is null) + { + return; + } + + if (_type3NestingDepth >= MaxType3NestingDepth) + { + throw new InvalidDataException( + $"Type3 glyph procedure nesting exceeds the maximum supported depth of {MaxType3NestingDepth}."); + } + + var charProc = Resolve(charProcEntry); + if (charProc.Kind != PdfKind.Stream) + { + throw new InvalidDataException($"/CharProcs entry '/{glyphName}' does not resolve to a stream."); + } + + var glyphMatrix = font.FontMatrix * ComputeTextRenderingMatrix(); + var contentBytes = GetStreamDecodedBytes(charProc); + + var savedResources = _resources; + var savedGs = _gs; + var savedGsStack = _gsStack; + _type3NestingDepth++; + try + { + _resources = font.Resources ?? _resources; + var nestedGs = savedGs.Clone(); + nestedGs.CurrentTransform = glyphMatrix; + _gs = nestedGs; + _gsStack = new Stack(); + ExecuteOperators(contentBytes); + } + finally + { + _type3NestingDepth--; + _resources = savedResources; + _gs = savedGs; + _gsStack = savedGsStack; + } + } + + /// + /// Handles the d0 glyph-width operator (wx wy d0), used inside a "colored" + /// Type 3 glyph procedure to declare its own advance width. Parses and validates its 2 + /// numeric operands (via the existing, shared helper) purely + /// for operand-count/type validation; the operands themselves are discarded and never + /// consulted for layout - /Widths (scaled through /FontMatrix - see + /// ) remains the sole authoritative advance-width + /// source. A stray d0 outside any glyph procedure parses/validates identically and + /// has no further effect - no new validation burden beyond this already-established + /// per-operator operand-count convention. + /// + /// Thrown when the operand count/type does not match (exactly 2 numbers). + private static void OpType3SetWidth(IReadOnlyList operands) => RequireNumbers(operands, "d0", 2); + + /// + /// Handles the d1 glyph-width-and-bounding-box operator + /// (wx wy llx lly urx ury d1), used inside an "uncolored" Type 3 glyph procedure. + /// Parses and validates its 6 numeric operands (via ) purely + /// for operand-count/type validation; like , the operands + /// (including wx/wy and the declared bounding box) are discarded and never + /// consulted for layout or caching - see 's own + /// remarks. This phase does not enforce d1's own "uncolored glyph" color- + /// suppression semantics - see 's own remarks for + /// this deliberate, named scope boundary. + /// + /// Thrown when the operand count/type does not match (exactly 6 numbers). + private static void OpType3SetWidthAndBBox(IReadOnlyList operands) => RequireNumbers(operands, "d1", 6); +} diff --git a/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Fonts.cs b/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Fonts.cs new file mode 100644 index 00000000..69d242ff --- /dev/null +++ b/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Fonts.cs @@ -0,0 +1,1229 @@ +// cspell:ignore WinAnsi WinAnsiEncoding MacRoman MacRomanEncoding notdef Zcaron Ycaron +// cspell:ignore zcaron ycaron dieresis dbase perthousand Lslash lslash guilsingl +// cspell:ignore periodcentered onesuperior twosuperior threesuperior ordfeminine ordmasculine +// cspell:ignore adieresis odieresis udieresis ecircumflex icircumflex ocircumflex ucircumflex +// cspell:ignore ntilde ccedilla eacute egrave igrave ograve ugrave agrave +// cspell:ignore codepoints Aacute Acircumflex Aring Atilde Edieresis Iacute Idieresis +// cspell:ignore Oacute Oslash Otilde Scaron Uacute Yacute Ydieresis aacute acircumflex +// cspell:ignore approxequal aring asciicircum asciitilde atilde braceleft braceright +// cspell:ignore bracketleft bracketright brokenbar caron daggerdbl dotaccent dotlessi +// cspell:ignore edieresis emdash endash exclam exclamdown germandbls greaterequal +// cspell:ignore guillemotleft guillemotright guilsinglleft guilsinglright hungarumlaut +// cspell:ignore iacute idieresis lessequal logicalnot nbspace notequal numbersign oacute +// cspell:ignore ogonek onehalf onequarter oslash otilde parenleft parenright partialdiff +// cspell:ignore plusminus questiondown quotedbl quotedblbase quotedblleft quotedblright +// cspell:ignore quoteleft quoteright quotesinglbase quotesingle scaron threequarters +// cspell:ignore uacute yacute ydieresis Zapf Nonsymbolic fontfile stdenc +// cspell:ignore ZapfDingbats Dingbats registerserif registersans copyrightserif copyrightsans +// cspell:ignore trademarkserif trademarksans radicalex Noto +using DemaConsulting.CanvasNet.Codecs; +using DemaConsulting.CanvasNet.Fonts; + +namespace DemaConsulting.CanvasNet.Pdf; + +public sealed partial class PdfDocument +{ + /// + /// A font resolved from a /Resources/Font entry, abstracting over the two + /// concrete resolution strategies this class supports: + /// (a single-byte-code /Subtype /TrueType font) and + /// (a two-byte-code /Subtype /Type0//Encoding /Identity-H composite font, see + /// PdfDocument.Fonts.Type0.cs), and (as of this phase) ResolvedType3Font (a + /// single-byte-code /Subtype /Type3 procedure-painted font, see + /// PdfDocument.Fonts.Type3.cs). decodes each shown string + /// into a sequence of codes using bytes per code, then resolves + /// each code's glyph index and advance width via - the same entry + /// point regardless of which concrete implementation is active, so + /// never needs to know which one produced the font currently selected via Tf + /// (beyond its own early ResolvedType3Font pattern-match branch - see + /// 's own remarks). + /// + private interface IResolvedFont + { + /// + /// Gets the number of bytes decodes per character code: 1 + /// for a simple or Type 3 font, 2 for a composite /Identity-H font. + /// + int CodeByteWidth { get; } + + /// + /// Resolves a single decoded character code to the loaded + /// whose outline paints ( for a + /// ResolvedType3Font, since a Type 3 font has no outline-glyph font program at + /// all - its glyphs are content-stream procedures, see + /// PdfDocument.Fonts.Type3.cs's PaintType3Glyph - so this component is + /// never consulted for it), the resolved glyph index within that font (meaningless/ + /// unused for a ResolvedType3Font - see that type's own remarks), and the + /// code's advance width in text-space units, already converted from the font's own + /// glyph-space convention (1/1000 em for a simple/composite font, via + /// /FontMatrix for a Type 3 font - see ResolvedType3Font.Resolve's own + /// remarks), not yet scaled by . For a + /// whose holds + /// more than one candidate font (the Symbol/ZapfDingbats Noto- + /// substitute union - see PdfDocument.FontFallback.cs's + /// ResolveSymbolicNotoFallback), the returned font is whichever list entry's + /// first resolves the code's mapped + /// codepoint to a non-zero glyph index, so glyph-outline/advance-width/ + /// lookups always stay bound to the same + /// font instance that produced the returned glyph index. + /// + /// The decoded character code (a byte for a simple or Type 3 font, a 16-bit big-endian value for a composite font). + /// The resolved font (or for a Type 3 font), glyph index, and text-space advance width. + (TrueTypeFont? Font, int GlyphIndex, double Width) Resolve(int code); + } + + /// + /// A simple (single-byte-code) TrueType font, fully resolved from its /Resources/Font + /// dictionary: the loaded , its effective code-to-Unicode- + /// codepoint encoding table, and its code-to-declared-advance-width table. + /// + /// + /// Instances are built once by and cached by + /// in for the lifetime of a single + /// call - see 's own remarks + /// for why the cache is never shared across calls. + /// + private sealed class ResolvedSimpleFont : IResolvedFont + { + /// + /// Gets the ordered, non-empty list of candidate loaded TrueType fonts backing this + /// resolved font: every existing resolution path (embedded /FontFile2/ + /// /FontFile//FontFile3, a Standard-14/system-match, or a bundled- + /// Liberation fallback) produces exactly one element here, resolving identically to + /// this type's pre-union single-Font behavior; only the bundled Noto + /// Symbol/ZapfDingbats substitute path (see + /// PdfDocument.FontFallback.cs's ResolveSymbolicNotoFallback) produces + /// more than one, in a fixed glyph-lookup priority order - see . + /// + internal required IReadOnlyList Fonts { get; init; } + + /// A simple font always decodes exactly one byte per character code. + int IResolvedFont.CodeByteWidth => 1; + + /// + /// Gets the effective code (0-255) to Unicode codepoint map, after applying the + /// font's /Encoding base encoding and any /Differences overrides. A + /// code absent from this map has no mapped codepoint (the PDF specification's + /// "undefined" slot in the base encoding table, never overridden by + /// /Differences) - treats such a code as mapping to + /// Unicode codepoint 0, which almost always resolves to glyph 0 + /// (.notdef) via . + /// + internal required IReadOnlyDictionary Encoding { get; init; } + + /// + /// Gets the code (0-255) to declared advance width map, in glyph-space units per + /// 1000 (text-space thousandths), from the font dictionary's /FirstChar/ + /// /LastChar//Widths entries. A code absent from this map has no + /// explicit declared width - see and + /// 's remarks for the documented fallback priority. + /// + internal required IReadOnlyDictionary Widths { get; init; } + + /// + /// Gets the font's /FontDescriptor/MissingWidth value (in the same + /// per-1000 units as ), or 0 when not declared - the PDF + /// specification's own documented default. + /// + internal required double MissingWidth { get; init; } + + /// + /// Resolves to a font and glyph index via / + /// , and its advance width with the + /// documented priority: an explicit entry first, else + /// (when nonzero), else the winning font's own + /// / + /// metric. When holds more than one candidate (the Symbol/ + /// ZapfDingbats Noto-substitute union), each is tried in list order and the + /// first one whose returns a non-zero + /// glyph index wins; when none cover the codepoint, the first font's glyph 0 + /// (.notdef) is returned instead, exactly matching a single-font resolution's + /// own "no match" behavior. + /// + public (TrueTypeFont? Font, int GlyphIndex, double Width) Resolve(int code) + { + var codepoint = Encoding.TryGetValue(code, out var mapped) ? mapped : 0; + + var winningFont = Fonts[0]; + var glyphIndex = 0; + foreach (var candidate in Fonts) + { + var candidateGlyphIndex = candidate.GetGlyphIndex(codepoint); + if (candidateGlyphIndex != 0) + { + winningFont = candidate; + glyphIndex = candidateGlyphIndex; + break; + } + } + + if (Widths.TryGetValue(code, out var declaredWidth)) + { + return (winningFont, glyphIndex, declaredWidth / 1000.0); + } + + if (MissingWidth != 0) + { + return (winningFont, glyphIndex, MissingWidth / 1000.0); + } + + return (winningFont, glyphIndex, winningFont.GetAdvanceWidth(glyphIndex) / (double)winningFont.UnitsPerEm); + } + } + + /// + /// Caches every font dictionary resolved (via ) by the Tf + /// operator during the content stream currently being executed by + /// , keyed by the resolved (indirect-reference-followed) + /// font dictionary 's own reference identity - + /// never overrides /, + /// so this is exactly the same "same instance in, same instance out" identity + /// 's own _objectCache already guarantees for every + /// indirect reference resolved more than once - a directly-inlined (non-indirect-reference) + /// font dictionary is keyed just as safely, since + /// never re-parses the same source bytes into two different + /// instances within one document parse. + /// + /// + /// Reinitialized (cleared) at the start of every call, + /// alongside / - an explicit, documented + /// "per--call only" cache scope, never shared or reused + /// across separate calls on the same + /// instance (each of which may, in principle, execute a + /// different page's content stream against the same font resource name, so caching a + /// resolved font beyond one call's lifetime could serve a stale/wrong font). + /// + private Dictionary _fontCache = null!; + + /// + /// Handles the Tf operator's font-name lookup: resolves a named font (simple or + /// composite) from the current page's /Resources/Font dictionary, building and + /// caching (in ) an the first time + /// this particular font dictionary object is resolved during the current content-stream + /// execution. + /// + /// The font resource name (without the leading slash). + /// The resolved font. + /// + /// Thrown when is not declared in the current page's + /// /Resources/Font dictionary (or no /Resources exists at all). + /// + /// + /// Propagated from for an unsupported /Subtype, a + /// non-embedded Symbol/ZapfDingbats/symbolic font, an unrecognized + /// /Encoding base encoding, or an unsupported composite-font /Encoding/ + /// descendant /Subtype (see PdfDocument.Fonts.Type0.cs). + /// + private IResolvedFont ResolveFont(string name) + { + var fontsEntry = _resources?.Get("Font"); + var fontsDictionary = fontsEntry is null ? null : Resolve(fontsEntry); + var entry = fontsDictionary?.Get(name); + if (entry is null) + { + throw new InvalidDataException( + $"Undefined font '/{name}' (not declared in the current page's /Resources/Font)."); + } + + var fontDict = Resolve(entry); + if (_fontCache.TryGetValue(fontDict, out var cached)) + { + return cached; + } + + var resolved = BuildResolvedFont(fontDict); + _fontCache[fontDict] = resolved; + return resolved; + } + + /// + /// Builds an from a font dictionary, dispatching on + /// /Subtype: /TrueType and /Type1 both resolve a simple font via + /// (see that method's own remarks for how each + /// subtype's embedded-font-program key differs); /Type0 resolves a composite + /// /Encoding /Identity-H//CIDFontType2 font via + /// (see PdfDocument.Fonts.Type0.cs); + /// /Type3 resolves a procedure-painted font via + /// (see PdfDocument.Fonts.Type3.cs); any other + /// /Subtype (for example MMType1) fails closed. + /// + /// + /// Thrown when /Subtype is not TrueType, Type1, Type0, or + /// Type3 (for example MMType1). + /// + private IResolvedFont BuildResolvedFont(PdfObject fontDict) + { + var subtype = GetNameValue(fontDict, "Subtype"); + return subtype switch + { + "TrueType" or "Type1" => BuildResolvedSimpleFont(fontDict, subtype), + "Type0" => BuildResolvedCompositeFont(fontDict), + "Type3" => BuildResolvedType3Font(fontDict), + _ => throw new UnsupportedImageFeatureException( + $"pdf-font-subtype-{subtype ?? "missing"}", + $"Font /Subtype '{subtype ?? "(missing)"}' is not supported; only simple " + + "/Subtype /TrueType and /Subtype /Type1 fonts, composite /Subtype /Type0 " + + "fonts, and procedure-painted /Subtype /Type3 fonts are supported (MMType1 " + + "fonts are not supported)."), + }; + } + + /// + /// Builds a from a /Subtype /TrueType or + /// /Subtype /Type1 font dictionary: loads the embedded + /// /FontDescriptor/FontFile2 (a TrueType-outline program) if present, or else the + /// embedded /FontDescriptor/FontFile (a classic PostScript Type 1 program, via + /// - see PdfDocument.Fonts.Type1.cs) if present, or else + /// the embedded /FontDescriptor/FontFile3 (a bare Type1C/CFF program, via + /// - see PdfDocument.Fonts.Type1.cs) if present - each + /// always takes priority over any later-checked key or substitute - or else resolves a + /// substitute font via (a Standard-14/system/bundled- + /// Liberation match), and resolves /Encoding and /Widths. + /// + /// The font dictionary being resolved. + /// + /// The font dictionary's own /Subtype name ("TrueType" or "Type1"), + /// consulted only to decide whether a /FontFile3-only descriptor resolves via + /// (a /Type1 font's embedded Type1C/CFF program) or + /// falls back exactly as before /Type1 dispatch existed (an unrelated, pre-existing + /// /TrueType font with a stray /FontFile3). + /// + /// + /// A simple font with no /FontDescriptor at all (permitted by PDF 32000-1 + /// ยง9.6.2.2 for the standard 14 fonts, and leniently accepted here regardless of + /// /BaseFont) or with a /FontDescriptor but no embedded + /// /FontFile2//FontFile//FontFile3 no longer fails closed + /// unconditionally: substitutes the closest-matching + /// system font, or - when no system font matches - a bundled Liberation Sans/Serif/Mono + /// font, fully automatically and silently (no new public API, no "fallback occurred" + /// diagnostics). A /BaseFont of Symbol or ZapfDingbats resolves + /// instead via a bundled Noto substitute font union (see + /// PdfDocument.FontFallback.cs's ResolveSymbolicNotoFallback), and that + /// branch's also defaults to + /// / (ISO 32000-1 + /// Appendix D's own built-in encoding for that font) in place of the usual + /// default - an explicit /Encoding dictionary's + /// /Differences still applies on top, exactly as for any other simple font. Any + /// other symbolic font (one whose /FontDescriptor/Flags declares Symbolic + /// without also declaring Nonsymbolic) with no embedded font program still fails + /// closed, since such a font's glyph set has no meaningful generic-family equivalent - see + /// the class remarks and PdfDocument.FontFallback.cs. A + /// /Subtype /Type1 descriptor's /FontFile3 is only ever attempted via + /// - which itself fails closed with + /// for any /Subtype other than + /// Type1C on that stream (see that method's own remarks) - gated on + /// subtype == "Type1" specifically, so a pre-existing /Subtype /TrueType + /// font with only a stray /FontFile3 is unaffected and continues to resolve via + /// fallback exactly as before this phase. + /// + /// + /// Thrown when /FontDescriptor/FontFile2 does not resolve to a stream, + /// /BaseFont is missing (only consulted on the fallback path), or propagated from + /// / for a malformed embedded Type 1 + /// or Type1C program. + /// + /// + /// Thrown when a symbolic font other than Symbol/ZapfDingbats has no + /// embedded font program (see ), or propagated from + /// when a /Subtype /Type1 descriptor's + /// /FontFile3 stream declares a /Subtype other than Type1C. + /// + private ResolvedSimpleFont BuildResolvedSimpleFont(PdfObject fontDict, string? subtype) + { + // Per PDF 32000-1 ยง9.6.2.2, /FontDescriptor is optional for the standard 14 fonts (and, + // leniently here, for any other non-embedded font too - consistent with this method's + // existing "substitute automatically, regardless of name" fallback policy): a missing + // /FontDescriptor is treated as "no descriptor, no embedded font program", not an error. + var descriptorEntry = fontDict.Get("FontDescriptor"); + var descriptor = descriptorEntry is null ? PdfObject.NullValue : Resolve(descriptorEntry); + + + var fontFile2Entry = descriptor.Get("FontFile2"); + var fontFileEntry = descriptor.Get("FontFile"); + IReadOnlyList fonts; + int[]? defaultBaseTable = null; + if (fontFile2Entry is not null) + { + var fontFileStream = Resolve(fontFile2Entry); + if (fontFileStream.Kind != PdfKind.Stream) + { + throw new InvalidDataException("/FontDescriptor/FontFile2 does not resolve to a stream."); + } + + var fontBytes = GetStreamDecodedBytes(fontFileStream); + fonts = [TrueTypeFont.Load(new MemoryStream(fontBytes))]; + } + else if (fontFileEntry is not null) + { + fonts = [LoadType1Font(descriptor)]; + } + else if (subtype == "Type1" && descriptor.Get("FontFile3") is not null) + { + fonts = [LoadType1CFont(descriptor)]; + } + else + { + var baseFontName = GetNameValue(fontDict, "BaseFont") + ?? throw new InvalidDataException("Font dictionary is missing required /BaseFont."); + fonts = ResolveFallbackFont(baseFontName, descriptor); + defaultBaseTable = baseFontName switch + { + "Symbol" => SymbolEncodingTable, + "ZapfDingbats" => ZapfDingbatsEncodingTable, + _ => null, + }; + } + + var encoding = ResolveEncoding(fontDict.Get("Encoding"), defaultBaseTable); + var (widths, missingWidth) = ResolveWidths(fontDict, descriptor); + + return new ResolvedSimpleFont + { + Fonts = fonts, + Encoding = encoding, + Widths = widths, + MissingWidth = missingWidth, + }; + } + + /// + /// Resolves a font dictionary's /Encoding entry into a full 256-entry code-to- + /// Unicode-codepoint map, applying the named base encoding (defaulting to + /// , or /WinAnsiEncoding when + /// is , when /Encoding is + /// absent) and any /Differences overrides. + /// + /// + /// The font dictionary's /Encoding entry (a name, a dictionary, or + /// when absent). + /// + /// + /// The 256-entry code-to-Unicode-codepoint table to seed the result from when + /// supplies no base encoding name of its own, or + /// (the default for every font other than a bundled-Noto- + /// substituted Symbol/ZapfDingbats) to fall back to + /// - see / + /// and 's own + /// remarks for the one case that passes a non-null value. An explicit /Encoding + /// dictionary's /BaseEncoding name or /Differences array still applies on + /// top of this default exactly as before, regardless of which table seeds it. + /// + /// A code-to-codepoint map covering every code with a defined mapping. + /// + /// Thrown when /Encoding is neither a name nor a dictionary, when + /// /Encoding/BaseEncoding is not a name, when /Encoding/Differences is not + /// an array, when a /Differences array entry is neither a number nor a name, or + /// when a /Differences name appears before any starting code. A /Differences + /// name that is not a recognized glyph name (see 's own + /// remarks for the covered name set) is tolerated, not an error - see + /// 's own remarks. + /// + /// + /// Thrown when the named base encoding is neither /WinAnsiEncoding, + /// /MacRomanEncoding, nor /StandardEncoding. + /// + private IReadOnlyDictionary ResolveEncoding(PdfObject? encodingEntry, int[]? defaultBaseTable = null) + { + var table = (int[])(defaultBaseTable ?? WinAnsiEncodingTable).Clone(); + PdfObject? differences = null; + + if (encodingEntry is not null) + { + var resolved = Resolve(encodingEntry); + switch (resolved.Kind) + { + case PdfKind.Name: + ApplyBaseEncoding(resolved.Text, table); + break; + + case PdfKind.Dictionary: + var baseEncodingEntry = resolved.Get("BaseEncoding"); + if (baseEncodingEntry is not null) + { + var baseEncoding = Resolve(baseEncodingEntry); + if (baseEncoding.Kind != PdfKind.Name) + { + throw new InvalidDataException("/Encoding/BaseEncoding must be a name."); + } + + ApplyBaseEncoding(baseEncoding.Text, table); + } + + differences = resolved.Get("Differences"); + break; + + default: + throw new InvalidDataException("/Encoding must be a name or a dictionary."); + } + } + + if (differences is not null) + { + ApplyDifferences(Resolve(differences), table); + } + + var map = new Dictionary(256); + for (var code = 0; code < 256; code++) + { + if (table[code] != 0) + { + map[code] = table[code]; + } + } + + return map; + } + + /// Applies a named base encoding's 256-entry code-to-codepoint table onto . + /// + /// Thrown when is none of WinAnsiEncoding, + /// MacRomanEncoding, or StandardEncoding. + /// + private static void ApplyBaseEncoding(string baseEncodingName, int[] table) + { + switch (baseEncodingName) + { + case "WinAnsiEncoding": + Array.Copy(WinAnsiEncodingTable, table, 256); + break; + + case "MacRomanEncoding": + Array.Copy(MacRomanEncodingTable, table, 256); + break; + + case "StandardEncoding": + Array.Copy(StandardEncodingTable, table, 256); + break; + + default: + throw new UnsupportedImageFeatureException( + $"pdf-font-encoding-{baseEncodingName}", + $"/Encoding base encoding '/{baseEncodingName}' is not supported; only " + + "/WinAnsiEncoding, /MacRomanEncoding, and /StandardEncoding are supported."); + } + } + + /// + /// Applies a /Differences array's code/glyph-name pairs onto , + /// per the PDF specification's own alternating "a starting code, then zero or more names + /// assigned to consecutive codes from that starting code" grammar - a thin wrapper over + /// that additionally resolves each glyph name to a Unicode + /// codepoint via (a simple/composite font's own + /// /Differences entries name Adobe-Glyph-List glyphs, unlike a Type 3 font's own + /// /CharProcs-keyed glyph names - see ResolveType3Encoding in + /// PdfDocument.Fonts.Type3.cs, which reuses directly + /// without this codepoint-resolution step). + /// + /// + /// A glyph name not recognized by (for example a + /// producer-specific name such as /gXX or /uniXXXX-style name this table + /// does not itself special-case) is tolerated: that one code is simply left at whatever + /// the base encoding already assigned it, rather than rejecting the entire document - a + /// single unrecognized override name is not evidence the document is corrupt, and real-world + /// PDF producers routinely emit such names. 's own "no glyph for this + /// codepoint" fallback (see this class's remarks) still applies to that code if the base + /// encoding also leaves it undefined. + /// + /// + /// Thrown when is not an array, when an array entry is + /// neither a number nor a name, or when a name entry appears before any starting code (or + /// after the code has advanced past 255). + /// + private void ApplyDifferences(PdfObject differences, int[] table) => + ParseDifferences(differences, (code, name) => + { + if (StandardGlyphNames.TryGetValue(name, out var codepoint)) + { + table[code] = codepoint; + } + }); + + /// + /// Parses a /Differences array's code/glyph-name pairs, per the PDF specification's + /// own alternating "a starting code, then zero or more names assigned to consecutive codes + /// from that starting code" grammar, invoking once per + /// code/glyph-name pair in array order. Shared by (a + /// simple/composite font's own codepoint-resolving policy) and ResolveType3Encoding + /// (PdfDocument.Fonts.Type3.cs's own "keep the glyph name verbatim, as a direct + /// /CharProcs key" policy) - the shared state machine (starting-code tracking, + /// operand-kind/range validation) is identical between both callers; only what each caller + /// does with a resolved (code, name) pair differs. + /// + /// The font dictionary's resolved /Encoding/Differences array. + /// Invoked once per (code, glyphName) pair, in array order. + /// + /// Thrown when is not an array, when an array entry is + /// neither a number nor a name, or when a name entry appears before any starting code (or + /// after the code has advanced past 255). + /// + private void ParseDifferences(PdfObject differences, Action assign) + { + if (differences.Kind != PdfKind.Array) + { + throw new InvalidDataException("/Encoding/Differences must be an array."); + } + + var code = -1; + foreach (var item in differences.Items) + { + var resolved = Resolve(item); + if (resolved.Kind == PdfKind.Number) + { + code = (int)resolved.Number; + continue; + } + + if (resolved.Kind != PdfKind.Name) + { + throw new InvalidDataException("/Encoding/Differences array entries must be numbers or names."); + } + + if (code is < 0 or > 255) + { + throw new InvalidDataException( + "/Encoding/Differences glyph name is not preceded by a valid starting code in [0, 255]."); + } + + assign(code, resolved.Text); + code++; + } + } + + /// + /// Resolves a font dictionary's /FirstChar//LastChar//Widths entries + /// into a code-to-declared-width map, plus the font descriptor's /MissingWidth + /// fallback. + /// + /// + /// The code-to-width map (empty when /FirstChar//Widths is absent or + /// malformed - a documented leniency, since + /// itself falls back to and then the font's + /// own metrics for any code missing from this map), and the resolved /MissingWidth + /// value (0 when not declared). + /// + private (IReadOnlyDictionary Widths, double MissingWidth) ResolveWidths(PdfObject fontDict, PdfObject descriptor) + { + var missingWidth = 0.0; + var missingWidthEntry = descriptor.Get("MissingWidth"); + if (missingWidthEntry is not null && Resolve(missingWidthEntry) is { Kind: PdfKind.Number } missingWidthValue) + { + missingWidth = missingWidthValue.Number; + } + + var widths = new Dictionary(); + var firstCharEntry = fontDict.Get("FirstChar"); + var widthsEntry = fontDict.Get("Widths"); + if (firstCharEntry is not null && widthsEntry is not null && + Resolve(firstCharEntry) is { Kind: PdfKind.Number } firstCharValue && + Resolve(widthsEntry) is { Kind: PdfKind.Array } widthsArray) + { + var firstChar = (int)firstCharValue.Number; + for (var i = 0; i < widthsArray.Items.Count; i++) + { + if (Resolve(widthsArray.Items[i]) is { Kind: PdfKind.Number } widthValue) + { + widths[firstChar + i] = widthValue.Number; + } + } + } + + return (widths, missingWidth); + } + + /// + /// Maps a subset of the Adobe Glyph List's standard glyph names to their Unicode + /// codepoints, sufficient to build / + /// and to resolve every common ASCII/Latin-1 + /// /Differences glyph name. + /// + /// + /// This is a deliberately partial subset of the full Adobe Glyph List (not every glyph + /// name ever defined) - a glyph name outside this set encountered in a /Differences + /// array is a malformed/unsupported reference to an undefined name from this + /// implementation's point of view, and throws + /// for it (a fail-closed policy, not a silent + /// mis-mapping to codepoint 0/.notdef). + /// + private static readonly IReadOnlyDictionary StandardGlyphNames = new Dictionary + { + ["A"] = 0x0041, + ["AE"] = 0x00C6, + ["Aacute"] = 0x00C1, + ["Acircumflex"] = 0x00C2, + ["Adieresis"] = 0x00C4, + ["Agrave"] = 0x00C0, + ["Aring"] = 0x00C5, + ["Atilde"] = 0x00C3, + ["B"] = 0x0042, + ["C"] = 0x0043, + ["Ccedilla"] = 0x00C7, + ["D"] = 0x0044, + ["Delta"] = 0x2206, + ["E"] = 0x0045, + ["Eacute"] = 0x00C9, + ["Ecircumflex"] = 0x00CA, + ["Edieresis"] = 0x00CB, + ["Egrave"] = 0x00C8, + ["Eth"] = 0x00D0, + ["Euro"] = 0x20AC, + ["F"] = 0x0046, + ["G"] = 0x0047, + ["H"] = 0x0048, + ["I"] = 0x0049, + ["Iacute"] = 0x00CD, + ["Icircumflex"] = 0x00CE, + ["Idieresis"] = 0x00CF, + ["Igrave"] = 0x00CC, + ["J"] = 0x004A, + ["K"] = 0x004B, + ["L"] = 0x004C, + ["M"] = 0x004D, + ["N"] = 0x004E, + ["Ntilde"] = 0x00D1, + ["O"] = 0x004F, + ["OE"] = 0x0152, + ["Oacute"] = 0x00D3, + ["Ocircumflex"] = 0x00D4, + ["Odieresis"] = 0x00D6, + ["Ograve"] = 0x00D2, + ["Omega"] = 0x03A9, + ["Oslash"] = 0x00D8, + ["Otilde"] = 0x00D5, + ["P"] = 0x0050, + ["Q"] = 0x0051, + ["R"] = 0x0052, + ["S"] = 0x0053, + ["Scaron"] = 0x0160, + ["T"] = 0x0054, + ["Thorn"] = 0x00DE, + ["U"] = 0x0055, + ["Uacute"] = 0x00DA, + ["Ucircumflex"] = 0x00DB, + ["Udieresis"] = 0x00DC, + ["Ugrave"] = 0x00D9, + ["V"] = 0x0056, + ["W"] = 0x0057, + ["X"] = 0x0058, + ["Y"] = 0x0059, + ["Yacute"] = 0x00DD, + ["Ydieresis"] = 0x0178, + ["Z"] = 0x005A, + ["Zcaron"] = 0x017D, + ["a"] = 0x0061, + ["aacute"] = 0x00E1, + ["acircumflex"] = 0x00E2, + ["acute"] = 0x00B4, + ["adieresis"] = 0x00E4, + ["ae"] = 0x00E6, + ["agrave"] = 0x00E0, + ["ampersand"] = 0x0026, + ["apple"] = 0xF8FF, + ["approxequal"] = 0x2248, + ["aring"] = 0x00E5, + ["asciicircum"] = 0x005E, + ["asciitilde"] = 0x007E, + ["asterisk"] = 0x002A, + ["at"] = 0x0040, + ["atilde"] = 0x00E3, + ["b"] = 0x0062, + ["backslash"] = 0x005C, + ["bar"] = 0x007C, + ["braceleft"] = 0x007B, + ["braceright"] = 0x007D, + ["bracketleft"] = 0x005B, + ["bracketright"] = 0x005D, + ["breve"] = 0x02D8, + ["brokenbar"] = 0x00A6, + ["bullet"] = 0x2022, + ["c"] = 0x0063, + ["caron"] = 0x02C7, + ["ccedilla"] = 0x00E7, + ["cedilla"] = 0x00B8, + ["cent"] = 0x00A2, + ["circumflex"] = 0x02C6, + ["colon"] = 0x003A, + ["comma"] = 0x002C, + ["copyright"] = 0x00A9, + ["currency"] = 0x00A4, + ["d"] = 0x0064, + ["dagger"] = 0x2020, + ["daggerdbl"] = 0x2021, + ["degree"] = 0x00B0, + ["dieresis"] = 0x00A8, + ["divide"] = 0x00F7, + ["dollar"] = 0x0024, + ["dotaccent"] = 0x02D9, + ["dotlessi"] = 0x0131, + ["e"] = 0x0065, + ["eacute"] = 0x00E9, + ["ecircumflex"] = 0x00EA, + ["edieresis"] = 0x00EB, + ["egrave"] = 0x00E8, + ["eight"] = 0x0038, + ["ellipsis"] = 0x2026, + ["emdash"] = 0x2014, + ["endash"] = 0x2013, + ["equal"] = 0x003D, + ["eth"] = 0x00F0, + ["exclam"] = 0x0021, + ["exclamdown"] = 0x00A1, + ["f"] = 0x0066, + ["ff"] = 0xFB00, + ["ffi"] = 0xFB03, + ["ffl"] = 0xFB04, + ["fi"] = 0xFB01, + ["five"] = 0x0035, + ["fl"] = 0xFB02, + ["florin"] = 0x0192, + ["four"] = 0x0034, + ["fraction"] = 0x2044, + ["g"] = 0x0067, + ["germandbls"] = 0x00DF, + ["grave"] = 0x0060, + ["greater"] = 0x003E, + ["greaterequal"] = 0x2265, + ["guillemotleft"] = 0x00AB, + ["guillemotright"] = 0x00BB, + ["guilsinglleft"] = 0x2039, + ["guilsinglright"] = 0x203A, + ["h"] = 0x0068, + ["hungarumlaut"] = 0x02DD, + ["hyphen"] = 0x002D, + ["i"] = 0x0069, + ["iacute"] = 0x00ED, + ["icircumflex"] = 0x00EE, + ["idieresis"] = 0x00EF, + ["igrave"] = 0x00EC, + ["infinity"] = 0x221E, + ["integral"] = 0x222B, + ["j"] = 0x006A, + ["k"] = 0x006B, + ["l"] = 0x006C, + ["less"] = 0x003C, + ["lessequal"] = 0x2264, + ["logicalnot"] = 0x00AC, + ["lozenge"] = 0xF8E7, + ["m"] = 0x006D, + ["macron"] = 0x00AF, + ["mu"] = 0x00B5, + ["multiply"] = 0x00D7, + ["n"] = 0x006E, + ["nacute"] = 0x0144, + ["nbspace"] = 0x00A0, + ["nine"] = 0x0039, + ["notequal"] = 0x2260, + ["ntilde"] = 0x00F1, + ["numbersign"] = 0x0023, + ["o"] = 0x006F, + ["oacute"] = 0x00F3, + ["ocircumflex"] = 0x00F4, + ["odieresis"] = 0x00F6, + ["oe"] = 0x0153, + ["ogonek"] = 0x02DB, + ["ograve"] = 0x00F2, + ["one"] = 0x0031, + ["onehalf"] = 0x00BD, + ["onequarter"] = 0x00BC, + ["onesuperior"] = 0x00B9, + ["ordfeminine"] = 0x00AA, + ["ordmasculine"] = 0x00BA, + ["oslash"] = 0x00F8, + ["otilde"] = 0x00F5, + ["p"] = 0x0070, + ["paragraph"] = 0x00B6, + ["parenleft"] = 0x0028, + ["parenright"] = 0x0029, + ["partialdiff"] = 0x2202, + ["percent"] = 0x0025, + ["period"] = 0x002E, + ["periodcentered"] = 0x00B7, + ["perthousand"] = 0x2030, + ["pi"] = 0x03C0, + ["plus"] = 0x002B, + ["plusminus"] = 0x00B1, + ["product"] = 0x220F, + ["q"] = 0x0071, + ["question"] = 0x003F, + ["questiondown"] = 0x00BF, + ["quotedbl"] = 0x0022, + ["quotedblbase"] = 0x201E, + ["quotedblleft"] = 0x201C, + ["quotedblright"] = 0x201D, + ["quoteleft"] = 0x2018, + ["quoteright"] = 0x2019, + ["quotesinglbase"] = 0x201A, + ["quotesingle"] = 0x0027, + ["r"] = 0x0072, + ["radical"] = 0x221A, + ["registered"] = 0x00AE, + ["ring"] = 0x02DA, + ["s"] = 0x0073, + ["scaron"] = 0x0161, + ["section"] = 0x00A7, + ["semicolon"] = 0x003B, + ["seven"] = 0x0037, + ["six"] = 0x0036, + ["slash"] = 0x002F, + ["space"] = 0x0020, + ["sterling"] = 0x00A3, + ["summation"] = 0x2211, + ["t"] = 0x0074, + ["thorn"] = 0x00FE, + ["three"] = 0x0033, + ["threequarters"] = 0x00BE, + ["threesuperior"] = 0x00B3, + ["tilde"] = 0x02DC, + ["trademark"] = 0x2122, + ["two"] = 0x0032, + ["twosuperior"] = 0x00B2, + ["u"] = 0x0075, + ["uacute"] = 0x00FA, + ["ucircumflex"] = 0x00FB, + ["udieresis"] = 0x00FC, + ["ugrave"] = 0x00F9, + ["underscore"] = 0x005F, + ["v"] = 0x0076, + ["w"] = 0x0077, + ["x"] = 0x0078, + ["y"] = 0x0079, + ["yacute"] = 0x00FD, + ["ydieresis"] = 0x00FF, + ["yen"] = 0x00A5, + ["z"] = 0x007A, + ["zcaron"] = 0x017E, + ["zero"] = 0x0030, + }; + + /// + /// The PDF specification (Appendix D) /WinAnsiEncoding base encoding, as a + /// 256-entry code-to-Unicode-codepoint table (0 for an undefined code). + /// + private static readonly int[] WinAnsiEncodingTable = + [ + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0020, 0x0021, 0x0022, 0x0023, 0x0024, 0x0025, 0x0026, 0x0027, + 0x0028, 0x0029, 0x002A, 0x002B, 0x002C, 0x002D, 0x002E, 0x002F, + 0x0030, 0x0031, 0x0032, 0x0033, 0x0034, 0x0035, 0x0036, 0x0037, + 0x0038, 0x0039, 0x003A, 0x003B, 0x003C, 0x003D, 0x003E, 0x003F, + 0x0040, 0x0041, 0x0042, 0x0043, 0x0044, 0x0045, 0x0046, 0x0047, + 0x0048, 0x0049, 0x004A, 0x004B, 0x004C, 0x004D, 0x004E, 0x004F, + 0x0050, 0x0051, 0x0052, 0x0053, 0x0054, 0x0055, 0x0056, 0x0057, + 0x0058, 0x0059, 0x005A, 0x005B, 0x005C, 0x005D, 0x005E, 0x005F, + 0x0060, 0x0061, 0x0062, 0x0063, 0x0064, 0x0065, 0x0066, 0x0067, + 0x0068, 0x0069, 0x006A, 0x006B, 0x006C, 0x006D, 0x006E, 0x006F, + 0x0070, 0x0071, 0x0072, 0x0073, 0x0074, 0x0075, 0x0076, 0x0077, + 0x0078, 0x0079, 0x007A, 0x007B, 0x007C, 0x007D, 0x007E, 0x2022, + 0x20AC, 0x2022, 0x201A, 0x0192, 0x201E, 0x2026, 0x2020, 0x2021, + 0x02C6, 0x2030, 0x0160, 0x2039, 0x0152, 0x2022, 0x017D, 0x2022, + 0x2022, 0x2018, 0x2019, 0x201C, 0x201D, 0x2022, 0x2013, 0x2014, + 0x02DC, 0x2122, 0x0161, 0x203A, 0x0153, 0x2022, 0x017E, 0x0178, + 0x0020, 0x00A1, 0x00A2, 0x00A3, 0x00A4, 0x00A5, 0x00A6, 0x00A7, + 0x00A8, 0x00A9, 0x00AA, 0x00AB, 0x00AC, 0x002D, 0x00AE, 0x00AF, + 0x00B0, 0x00B1, 0x00B2, 0x00B3, 0x00B4, 0x00B5, 0x00B6, 0x00B7, + 0x00B8, 0x00B9, 0x00BA, 0x00BB, 0x00BC, 0x00BD, 0x00BE, 0x00BF, + 0x00C0, 0x00C1, 0x00C2, 0x00C3, 0x00C4, 0x00C5, 0x00C6, 0x00C7, + 0x00C8, 0x00C9, 0x00CA, 0x00CB, 0x00CC, 0x00CD, 0x00CE, 0x00CF, + 0x00D0, 0x00D1, 0x00D2, 0x00D3, 0x00D4, 0x00D5, 0x00D6, 0x00D7, + 0x00D8, 0x00D9, 0x00DA, 0x00DB, 0x00DC, 0x00DD, 0x00DE, 0x00DF, + 0x00E0, 0x00E1, 0x00E2, 0x00E3, 0x00E4, 0x00E5, 0x00E6, 0x00E7, + 0x00E8, 0x00E9, 0x00EA, 0x00EB, 0x00EC, 0x00ED, 0x00EE, 0x00EF, + 0x00F0, 0x00F1, 0x00F2, 0x00F3, 0x00F4, 0x00F5, 0x00F6, 0x00F7, + 0x00F8, 0x00F9, 0x00FA, 0x00FB, 0x00FC, 0x00FD, 0x00FE, 0x00FF, + ]; + + /// + /// The PDF specification (Appendix D) /MacRomanEncoding base encoding, as a + /// 256-entry code-to-Unicode-codepoint table (0 for an undefined code). + /// + private static readonly int[] MacRomanEncodingTable = + [ + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0020, 0x0021, 0x0022, 0x0023, 0x0024, 0x0025, 0x0026, 0x0027, + 0x0028, 0x0029, 0x002A, 0x002B, 0x002C, 0x002D, 0x002E, 0x002F, + 0x0030, 0x0031, 0x0032, 0x0033, 0x0034, 0x0035, 0x0036, 0x0037, + 0x0038, 0x0039, 0x003A, 0x003B, 0x003C, 0x003D, 0x003E, 0x003F, + 0x0040, 0x0041, 0x0042, 0x0043, 0x0044, 0x0045, 0x0046, 0x0047, + 0x0048, 0x0049, 0x004A, 0x004B, 0x004C, 0x004D, 0x004E, 0x004F, + 0x0050, 0x0051, 0x0052, 0x0053, 0x0054, 0x0055, 0x0056, 0x0057, + 0x0058, 0x0059, 0x005A, 0x005B, 0x005C, 0x005D, 0x005E, 0x005F, + 0x0060, 0x0061, 0x0062, 0x0063, 0x0064, 0x0065, 0x0066, 0x0067, + 0x0068, 0x0069, 0x006A, 0x006B, 0x006C, 0x006D, 0x006E, 0x006F, + 0x0070, 0x0071, 0x0072, 0x0073, 0x0074, 0x0075, 0x0076, 0x0077, + 0x0078, 0x0079, 0x007A, 0x007B, 0x007C, 0x007D, 0x007E, 0x0000, + 0x00C4, 0x00C5, 0x00C7, 0x00C9, 0x00D1, 0x00D6, 0x00DC, 0x00E1, + 0x00E0, 0x00E2, 0x00E4, 0x00E3, 0x00E5, 0x00E7, 0x00E9, 0x00E8, + 0x00EA, 0x00EB, 0x00ED, 0x00EC, 0x00EE, 0x00EF, 0x00F1, 0x00F3, + 0x00F2, 0x00F4, 0x00F6, 0x00F5, 0x00FA, 0x00F9, 0x00FB, 0x00FC, + 0x2020, 0x00B0, 0x00A2, 0x00A3, 0x00A7, 0x2022, 0x00B6, 0x00DF, + 0x00AE, 0x00A9, 0x2122, 0x00B4, 0x00A8, 0x2260, 0x00C6, 0x00D8, + 0x221E, 0x00B1, 0x2264, 0x2265, 0x00A5, 0x00B5, 0x2202, 0x2211, + 0x220F, 0x03C0, 0x222B, 0x00AA, 0x00BA, 0x03A9, 0x00E6, 0x00F8, + 0x00BF, 0x00A1, 0x00AC, 0x221A, 0x0192, 0x2248, 0x2206, 0x00AB, + 0x00BB, 0x2026, 0x0020, 0x00C0, 0x00C3, 0x00D5, 0x0152, 0x0153, + 0x2013, 0x2014, 0x201C, 0x201D, 0x2018, 0x2019, 0x00F7, 0xF8E7, + 0x00FF, 0x0178, 0x2044, 0x00A4, 0x2039, 0x203A, 0xFB01, 0xFB02, + 0x2021, 0x00B7, 0x201A, 0x201E, 0x2030, 0x00C2, 0x00CA, 0x00C1, + 0x00CB, 0x00C8, 0x00CD, 0x00CE, 0x00CF, 0x00CC, 0x00D3, 0x00D4, + 0xF8FF, 0x00D2, 0x00DA, 0x00DB, 0x00D9, 0x0131, 0x02C6, 0x02DC, + 0x00AF, 0x02D8, 0x02D9, 0x02DA, 0x00B8, 0x02DD, 0x02DB, 0x02C7, + ]; + + /// + /// The PDF specification (Appendix D) /StandardEncoding base encoding (Adobe's + /// original PostScript font encoding, and PDF's own default for a /Subtype /Type1 + /// font's built-in encoding), as a 256-entry code-to-Unicode-codepoint table (0 for + /// an undefined code) - derived from the Unicode Consortium's own published + /// stdenc.txt cross-reference mapping, the canonical source for this table. + /// + private static readonly int[] StandardEncodingTable = + [ + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0020, 0x0021, 0x0022, 0x0023, 0x0024, 0x0025, 0x0026, 0x2019, + 0x0028, 0x0029, 0x002A, 0x002B, 0x002C, 0x002D, 0x002E, 0x002F, + 0x0030, 0x0031, 0x0032, 0x0033, 0x0034, 0x0035, 0x0036, 0x0037, + 0x0038, 0x0039, 0x003A, 0x003B, 0x003C, 0x003D, 0x003E, 0x003F, + 0x0040, 0x0041, 0x0042, 0x0043, 0x0044, 0x0045, 0x0046, 0x0047, + 0x0048, 0x0049, 0x004A, 0x004B, 0x004C, 0x004D, 0x004E, 0x004F, + 0x0050, 0x0051, 0x0052, 0x0053, 0x0054, 0x0055, 0x0056, 0x0057, + 0x0058, 0x0059, 0x005A, 0x005B, 0x005C, 0x005D, 0x005E, 0x005F, + 0x2018, 0x0061, 0x0062, 0x0063, 0x0064, 0x0065, 0x0066, 0x0067, + 0x0068, 0x0069, 0x006A, 0x006B, 0x006C, 0x006D, 0x006E, 0x006F, + 0x0070, 0x0071, 0x0072, 0x0073, 0x0074, 0x0075, 0x0076, 0x0077, + 0x0078, 0x0079, 0x007A, 0x007B, 0x007C, 0x007D, 0x007E, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x00A1, 0x00A2, 0x00A3, 0x2044, 0x00A5, 0x0192, 0x00A7, + 0x00A4, 0x0027, 0x201C, 0x00AB, 0x2039, 0x203A, 0xFB01, 0xFB02, + 0x0000, 0x2013, 0x2020, 0x2021, 0x00B7, 0x0000, 0x00B6, 0x2022, + 0x201A, 0x201E, 0x201D, 0x00BB, 0x2026, 0x2030, 0x0000, 0x00BF, + 0x0000, 0x0060, 0x00B4, 0x02C6, 0x02DC, 0x00AF, 0x02D8, 0x02D9, + 0x00A8, 0x0000, 0x02DA, 0x00B8, 0x0000, 0x02DD, 0x02DB, 0x02C7, + 0x2014, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x00C6, 0x0000, 0x00AA, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0141, 0x00D8, 0x0152, 0x00BA, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x00E6, 0x0000, 0x0000, 0x0000, 0x0131, 0x0000, 0x0000, + 0x0142, 0x00F8, 0x0153, 0x00DF, 0x0000, 0x0000, 0x0000, 0x0000, + ]; + + /// + /// Implements PDF 32000-1 Appendix D's "Symbol Set and ZapfDingbats Encoding" table (the + /// Symbol column): the built-in encoding of the Standard-14 Symbol font, as + /// a 256-entry code-to-Unicode-codepoint table (0 for an undefined code). Used by + /// as the default base table (in place of + /// ) only for a /BaseFont /Symbol font resolved + /// via the bundled Noto substitute path - see 's own + /// remarks. + /// + /// + /// Six Private-Use-Area-only glyph names (registerserif/registersans, + /// copyrightserif/copyrightsans, trademarkserif/trademarksans) + /// are deliberately mapped to their plain/generic Unicode equivalent (U+00AE, U+00A9, and + /// U+2122 respectively) rather than left unmapped, since a generic substitute font has no + /// reason to carry the Symbol font's own Private-Use-Area glyph variants. Roughly 20 other + /// Private-Use-Area-only glyph names (extensible delimiter pieces, for example + /// radicalex) are deliberately left as 0x0000 (unmapped), since they have no + /// meaningful standalone Unicode equivalent. Of the 163 distinct mapped codepoints this + /// table defines, 161 are covered by the bundled Noto substitute fonts (see + /// Fonts/BundledFonts/README.md) - only U+2329/U+232A (codes 0xE1/0xD1) + /// are not - a documented, accepted fidelity limitation. + /// + private static readonly int[] SymbolEncodingTable = + [ + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0020, 0x0021, 0x2200, 0x0023, 0x2203, 0x0025, 0x0026, 0x220B, + 0x0028, 0x0029, 0x2217, 0x002B, 0x002C, 0x2212, 0x002E, 0x002F, + 0x0030, 0x0031, 0x0032, 0x0033, 0x0034, 0x0035, 0x0036, 0x0037, + 0x0038, 0x0039, 0x003A, 0x003B, 0x003C, 0x003D, 0x003E, 0x003F, + 0x2245, 0x0391, 0x0392, 0x03A7, 0x2206, 0x0395, 0x03A6, 0x0393, + 0x0397, 0x0399, 0x03D1, 0x039A, 0x039B, 0x039C, 0x039D, 0x039F, + 0x03A0, 0x0398, 0x03A1, 0x03A3, 0x03A4, 0x03A5, 0x03C2, 0x2126, + 0x039E, 0x03A8, 0x0396, 0x005B, 0x2234, 0x005D, 0x22A5, 0x005F, + 0x0000, 0x03B1, 0x03B2, 0x03C7, 0x03B4, 0x03B5, 0x03C6, 0x03B3, + 0x03B7, 0x03B9, 0x03D5, 0x03BA, 0x03BB, 0x00B5, 0x03BD, 0x03BF, + 0x03C0, 0x03B8, 0x03C1, 0x03C3, 0x03C4, 0x03C5, 0x03D6, 0x03C9, + 0x03BE, 0x03C8, 0x03B6, 0x007B, 0x007C, 0x007D, 0x223C, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x20AC, 0x03D2, 0x2032, 0x2264, 0x2044, 0x221E, 0x0192, 0x2663, + 0x2666, 0x2665, 0x2660, 0x2194, 0x2190, 0x2191, 0x2192, 0x2193, + 0x00B0, 0x00B1, 0x2033, 0x2265, 0x00D7, 0x221D, 0x2202, 0x2022, + 0x00F7, 0x2260, 0x2261, 0x2248, 0x2026, 0x0000, 0x0000, 0x21B5, + 0x2135, 0x2111, 0x211C, 0x2118, 0x2297, 0x2295, 0x2205, 0x2229, + 0x222A, 0x2283, 0x2287, 0x2284, 0x2282, 0x2286, 0x2208, 0x2209, + 0x2220, 0x2207, 0x00AE, 0x00A9, 0x2122, 0x220F, 0x221A, 0x22C5, + 0x00AC, 0x2227, 0x2228, 0x21D4, 0x21D0, 0x21D1, 0x21D2, 0x21D3, + 0x25CA, 0x2329, 0x00AE, 0x00A9, 0x2122, 0x2211, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x232A, 0x222B, 0x2320, 0x0000, 0x2321, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + ]; + + /// + /// Implements PDF 32000-1 Appendix D's "Symbol Set and ZapfDingbats Encoding" table (the + /// ZapfDingbats column): the built-in encoding of the Standard-14 + /// ZapfDingbats font, as a 256-entry code-to-Unicode-codepoint table (0 for + /// an undefined code). Used by as the default base table (in + /// place of ) only for a /BaseFont /ZapfDingbats + /// font resolved via the bundled Noto substitute path - see + /// 's own remarks. + /// + /// + /// Of the 202 distinct mapped codepoints this table defines, 158 are covered by the + /// bundled NotoSansSymbols2-Regular.ttf substitute font (see + /// Fonts/BundledFonts/README.md) - the circled-digit Dingbats U+2460-U+2469 and + /// U+2776-U+2793, and U+271D/U+271E/U+271F/U+2721, are not - a documented, accepted + /// fidelity limitation. + /// + private static readonly int[] ZapfDingbatsEncodingTable = + [ + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0020, 0x2701, 0x2702, 0x2703, 0x2704, 0x260E, 0x2706, 0x2707, + 0x2708, 0x2709, 0x261B, 0x261E, 0x270C, 0x270D, 0x270E, 0x270F, + 0x2710, 0x2711, 0x2712, 0x2713, 0x2714, 0x2715, 0x2716, 0x2717, + 0x2718, 0x2719, 0x271A, 0x271B, 0x271C, 0x271D, 0x271E, 0x271F, + 0x2720, 0x2721, 0x2722, 0x2723, 0x2724, 0x2725, 0x2726, 0x2727, + 0x2605, 0x2729, 0x272A, 0x272B, 0x272C, 0x272D, 0x272E, 0x272F, + 0x2730, 0x2731, 0x2732, 0x2733, 0x2734, 0x2735, 0x2736, 0x2737, + 0x2738, 0x2739, 0x273A, 0x273B, 0x273C, 0x273D, 0x273E, 0x273F, + 0x2740, 0x2741, 0x2742, 0x2743, 0x2744, 0x2745, 0x2746, 0x2747, + 0x2748, 0x2749, 0x274A, 0x274B, 0x25CF, 0x274D, 0x25A0, 0x274F, + 0x2750, 0x2751, 0x2752, 0x25B2, 0x25BC, 0x25C6, 0x2756, 0x25D7, + 0x2758, 0x2759, 0x275A, 0x275B, 0x275C, 0x275D, 0x275E, 0x0000, + 0x2768, 0x2769, 0x276A, 0x276B, 0x276C, 0x276D, 0x276E, 0x276F, + 0x2770, 0x2771, 0x2772, 0x2773, 0x2774, 0x2775, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x2761, 0x2762, 0x2763, 0x2764, 0x2765, 0x2766, 0x2767, + 0x2663, 0x2666, 0x2665, 0x2660, 0x2460, 0x2461, 0x2462, 0x2463, + 0x2464, 0x2465, 0x2466, 0x2467, 0x2468, 0x2469, 0x2776, 0x2777, + 0x2778, 0x2779, 0x277A, 0x277B, 0x277C, 0x277D, 0x277E, 0x277F, + 0x2780, 0x2781, 0x2782, 0x2783, 0x2784, 0x2785, 0x2786, 0x2787, + 0x2788, 0x2789, 0x278A, 0x278B, 0x278C, 0x278D, 0x278E, 0x278F, + 0x2790, 0x2791, 0x2792, 0x2793, 0x2794, 0x2192, 0x2194, 0x2195, + 0x2798, 0x2799, 0x279A, 0x279B, 0x279C, 0x279D, 0x279E, 0x279F, + 0x27A0, 0x27A1, 0x27A2, 0x27A3, 0x27A4, 0x27A5, 0x27A6, 0x27A7, + 0x27A8, 0x27A9, 0x27AA, 0x27AB, 0x27AC, 0x27AD, 0x27AE, 0x27AF, + 0x0000, 0x27B1, 0x27B2, 0x27B3, 0x27B4, 0x27B5, 0x27B6, 0x27B7, + 0x27B8, 0x27B9, 0x27BA, 0x27BB, 0x27BC, 0x27BD, 0x27BE, 0x0000, + ]; + + /// + /// The reverse mapping (Unicode codepoint to glyph name) built once from + /// , passed as 's + /// codepointToGlyphName argument by (see + /// PdfDocument.Fonts.Type1.cs) - reusing this class's existing Adobe-glyph-name + /// vocabulary rather than introducing a second, separately-maintained glyph-name table. + /// + /// + /// When more than one glyph name in maps to the same + /// codepoint (for example a hypothetical synonym pair), the first one encountered (in + /// 's own declaration order) wins - the same "first-wins + /// on collision" convention Fonts.Type1StandardGlyphNames uses for its own reverse + /// map. + /// + private static readonly IReadOnlyDictionary CodepointToStandardGlyphName = + BuildCodepointToStandardGlyphName(); + + private static IReadOnlyDictionary BuildCodepointToStandardGlyphName() + { + var result = new Dictionary(); + foreach (var (name, codepoint) in StandardGlyphNames) + { + result.TryAdd(codepoint, name); + } + + return result; + } + + /// + /// An empty codepoint-to-glyph-name map, reused wherever a /FontFile3 loader must + /// supply 's required + /// codepointToGlyphName argument but the resulting font's own synthesized + /// cmap-equivalent lookup is never actually consulted - specifically, a composite + /// /Type0 font's CIDFontType0 descendant (see + /// PdfDocument.Fonts.Type0.cs's LoadCidFontType0Font), whose CID-to-glyph- + /// index resolution is always the identity function, bypassing + /// (and therefore this map) entirely - + /// unlike 's own simple-font /FontFile3 path (see + /// PdfDocument.Fonts.Type1.cs), which passes + /// instead, since a simple font's codes are resolved to glyphs by codepoint. + /// + private static readonly IReadOnlyDictionary EmptyCodepointToGlyphName = + new Dictionary(); + + /// + /// The actual byte-level container shape of an embedded /FontFile3 font program, + /// sniffed from the stream's own decoded bytes (see ) + /// rather than trusted from its declared /Subtype name - the PDF specification + /// permits, and real-world producers sometimes emit, a mismatch between the two. + /// + private enum FontFile3Shape + { + /// + /// An SFNT-wrapped font program (TrueType, classic Mac 'true', CFF/OpenType + /// 'OTTO', or a 'ttcf' collection) - see + /// . Loaded via + /// . + /// + Sfnt, + + /// + /// A bare (standalone, non-SFNT-wrapped) CFF font program - see + /// . Loaded via + /// . + /// + BareCff, + } + + /// + /// Sniffs an already-decoded /FontFile3 stream's bytes for their actual container + /// shape - an SFNT wrapper () or a bare CFF + /// header () - used by both + /// (PdfDocument.Fonts.Type1.cs) and + /// (PdfDocument.Fonts.Type0.cs) to dispatch to + /// the matching Fonts.TrueTypeFont loader regardless of the stream's own declared + /// /Subtype name, since the PDF specification permits (and real-world producers + /// sometimes emit) a mismatch between that declared name and the font program's actual + /// byte container shape. + /// + /// The /FontFile3 stream's already-decoded bytes. + /// + /// The sniffed , or when + /// matches neither a recognized SFNT container nor a + /// structurally plausible bare CFF header. + /// + private static FontFile3Shape? SniffFontFile3Shape(byte[] fontBytes) + { + if (SfntContainer.LooksLikeSfnt(fontBytes)) + { + return FontFile3Shape.Sfnt; + } + + if (CffTable.LooksLikeCffHeader(fontBytes)) + { + return FontFile3Shape.BareCff; + } + + return null; + } +} diff --git a/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Functions.cs b/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Functions.cs new file mode 100644 index 00000000..c8fa0985 --- /dev/null +++ b/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Functions.cs @@ -0,0 +1,596 @@ +// cspell:ignore functiontype bitspersample multiinput +using DemaConsulting.CanvasNet.Codecs; + +namespace DemaConsulting.CanvasNet.Pdf; + +public sealed partial class PdfDocument +{ + /// + /// A resolved, single-input, N-output PDF function (any of the 3 supported + /// /FunctionTypes: 0/, 2/ + /// , 3/) - the + /// common shape (PdfDocument.Patterns.Shading.cs) + /// evaluates against a shading pattern's own /Function entry. + /// + internal interface IPdfFunction + { + /// The number of outputs N this function produces. + int OutputCount { get; } + + /// Evaluates this function at , returning one value per output ( elements). + double[] Evaluate(double input); + } + + /// + /// A resolved PDF /FunctionType 0 (sampled) function: 1 input, N outputs, + /// evaluated by clamping the input to /Domain, mapping it into sample-index space + /// via /Encode, linearly interpolating between the two nearest samples (matching + /// this narrow evaluator's implicit /Order 1 assumption - see + /// 's remarks), and mapping each interpolated output through + /// /Decode, clipped to /Range. + /// + /// + /// Consumed by PdfDocument.Patterns.Shading.cs's ResolveFunctionOrFunctionArray + /// (via ) when a shading pattern's own /Function is a + /// /FunctionType 0 function, and directly by callers that only need a sampled + /// function on its own (for example 's own existing callers). + /// + internal sealed class SampledFunction : IPdfFunction + { + /// The single input's valid range (2 elements: [min, max]), values are clamped into this range before evaluation. + internal required double[] Domain { get; init; } + + /// The N outputs' valid ranges (2N elements: [min0, max0, min1, max1, ...]), the final evaluated value of each output is clipped into its own range. + internal required double[] Range { get; init; } + + /// The N outputs' raw-sample-to-output-value linear mapping (2N elements), defaults to when /Decode is absent. + internal required double[] Decode { get; init; } + + /// The single input's domain-to-sample-index linear mapping (2 elements: [0, Size - 1] when /Encode is absent). + internal required double[] Encode { get; init; } + + /// The number of samples along the single input dimension (/Size's one element, since this function has exactly 1 input). + internal required int Size { get; init; } + + /// The number of bits each raw sample value occupies (8 or 16, the only two widths this narrow evaluator supports). + internal required int BitsPerSample { get; init; } + + /// The function stream's fully filter-decoded sample bytes, packed Size sample groups of OutputCount -wide big-endian values each, with no padding between groups or samples (per the PDF specification's own packing rule for a 1-input sampled function). + internal required byte[] Samples { get; init; } + + /// The number of outputs N, derived from 's own length. + internal int OutputCount => Range.Length / 2; + + /// + int IPdfFunction.OutputCount => OutputCount; + + /// + /// Evaluates this sampled function at , returning one value + /// per output ( elements). + /// + /// The single input value; clamped into before evaluation. + /// The evaluated, -mapped, -clipped output values. + /// + /// Thrown when is too short to supply every sample this + /// evaluation needs (a malformed/truncated function stream). + /// + internal double[] Evaluate(double input) + { + var clampedInput = Math.Clamp(input, Math.Min(Domain[0], Domain[1]), Math.Max(Domain[0], Domain[1])); + var encoded = Interpolate(clampedInput, Domain[0], Domain[1], Encode[0], Encode[1]); + var sampleIndex = Math.Clamp(encoded, 0, Size - 1); + + var lowIndex = (int)Math.Floor(sampleIndex); + var highIndex = Math.Min(lowIndex + 1, Size - 1); + var fraction = sampleIndex - lowIndex; + + var maxSampleValue = (double)((1UL << BitsPerSample) - 1); + var outputCount = OutputCount; + var outputs = new double[outputCount]; + for (var outputIndex = 0; outputIndex < outputCount; outputIndex++) + { + var rawLow = ReadSample(lowIndex, outputIndex, outputCount); + var rawHigh = ReadSample(highIndex, outputIndex, outputCount); + var raw = rawLow + (fraction * (rawHigh - rawLow)); + + var decoded = Interpolate( + raw, 0, maxSampleValue, Decode[2 * outputIndex], Decode[(2 * outputIndex) + 1]); + + var rangeMin = Math.Min(Range[2 * outputIndex], Range[(2 * outputIndex) + 1]); + var rangeMax = Math.Max(Range[2 * outputIndex], Range[(2 * outputIndex) + 1]); + outputs[outputIndex] = Math.Clamp(decoded, rangeMin, rangeMax); + } + + return outputs; + } + + /// + double[] IPdfFunction.Evaluate(double input) => Evaluate(input); + + /// + /// Reads a single raw (not yet -mapped) sample value: the + /// 'th -wide big-endian + /// value within the 'th sample group. + /// + /// + /// Thrown when the requested bits extend past the end of , or + /// when the sample-table dimensions overflow during bit-offset computation. + /// + private double ReadSample(int sampleGroupIndex, int outputIndex, int outputCount) + { + long bitOffset; + try + { + bitOffset = checked((((long)sampleGroupIndex * outputCount) + outputIndex) * (long)BitsPerSample); + } + catch (OverflowException ex) + { + throw new InvalidDataException( + "Sampled function sample-table dimensions overflow during bit-offset computation.", ex); + } + + return ReadBits(Samples, bitOffset, BitsPerSample); + } + + /// + /// Reads bits (most-significant bit first, per the PDF + /// specification's own sample-packing convention) starting at bit offset + /// within , generically - + /// supporting any bit width, even though itself is + /// restricted (by ) to 8 or 16. + /// + /// + /// Thrown when the requested bit range extends past the end of . + /// + private static long ReadBits(byte[] data, long bitOffset, int bitCount) + { + var lastBitIndex = bitOffset + bitCount - 1; + if (lastBitIndex / 8 >= data.Length) + { + throw new InvalidDataException( + "Sampled function stream does not contain enough sample bytes for its declared /Size."); + } + + long value = 0; + for (var i = 0; i < bitCount; i++) + { + var bitIndex = bitOffset + i; + var byteIndex = (int)(bitIndex / 8); + var bitInByte = 7 - (int)(bitIndex % 8); + var bit = (long)((data[byteIndex] >> bitInByte) & 1); + value = (value << 1) | bit; + } + + return value; + } + + /// Linearly maps from [xMin, xMax] into [yMin, yMax]. + private static double Interpolate(double value, double xMin, double xMax, double yMin, double yMax) + { + var span = xMax - xMin; + return Math.Abs(span) < double.Epsilon ? yMin : yMin + ((value - xMin) * (yMax - yMin) / span); + } + } + + /// + /// A resolved PDF /FunctionType 2 (exponential interpolation) function: 1 input, + /// N outputs, evaluated as C0 + input^N * (C1 - C0) (PDF 32000-1 ยง7.10.3) + /// after clamping the input value into . + /// + internal sealed class ExponentialFunction : IPdfFunction + { + /// The single input's valid range (2 elements: [min, max]). + internal required double[] Domain { get; init; } + + /// The function's output value(s) when input == 0. Defaults to a single 0.0 element when /C0 is absent. + internal required double[] C0 { get; init; } + + /// The function's output value(s) when input == 1. Defaults to a single 1.0 element when /C1 is absent. + internal required double[] C1 { get; init; } + + /// The interpolation exponent /N. + internal required double N { get; init; } + + /// + public int OutputCount => C0.Length; + + /// + public double[] Evaluate(double input) + { + var clampedInput = Math.Clamp(input, Math.Min(Domain[0], Domain[1]), Math.Max(Domain[0], Domain[1])); + var raised = Math.Pow(clampedInput, N); + + var outputs = new double[OutputCount]; + for (var i = 0; i < outputs.Length; i++) + { + outputs[i] = C0[i] + (raised * (C1[i] - C0[i])); + } + + return outputs; + } + } + + /// + /// A resolved PDF /FunctionType 3 (stitching) function: 1 input, N outputs, + /// partitioning into k sub-domains (' + /// k - 1 interior boundaries) and delegating evaluation to the corresponding + /// sub- in , after remapping the input + /// into that sub-domain's own /Encode-declared range (PDF 32000-1 ยง7.10.4). + /// + internal sealed class StitchingFunction : IPdfFunction + { + /// The single input's valid range (2 elements: [min, max]). + internal required double[] Domain { get; init; } + + /// The k sub-functions, each resolved via . + internal required IReadOnlyList Functions { get; init; } + + /// The k - 1 interior sub-domain boundaries, in increasing order. + internal required double[] Bounds { get; init; } + + /// The 2k-element input-remapping array: sub-domain i maps linearly to [Encode[2i], Encode[2i + 1]]. + internal required double[] Encode { get; init; } + + /// + public int OutputCount => Functions.Count > 0 ? Functions[0].OutputCount : 0; + + /// + public double[] Evaluate(double input) + { + var clampedInput = Math.Clamp(input, Math.Min(Domain[0], Domain[1]), Math.Max(Domain[0], Domain[1])); + + var k = Functions.Count; + var subIndex = 0; + while (subIndex < Bounds.Length && clampedInput >= Bounds[subIndex]) + { + subIndex++; + } + + subIndex = Math.Clamp(subIndex, 0, k - 1); + + var lowBound = subIndex == 0 ? Domain[0] : Bounds[subIndex - 1]; + var highBound = subIndex == k - 1 ? Domain[1] : Bounds[subIndex]; + + var encodedLow = Encode[2 * subIndex]; + var encodedHigh = Encode[(2 * subIndex) + 1]; + + var span = highBound - lowBound; + var encodedInput = Math.Abs(span) < double.Epsilon + ? encodedLow + : encodedLow + ((clampedInput - lowBound) * (encodedHigh - encodedLow) / span); + + return Functions[subIndex].Evaluate(encodedInput); + } + } + + /// + /// The maximum number of nested /Function resolutions + /// allows before failing closed. A malformed or adversarial PDF can declare a + /// /FunctionType 3 stitching function whose own /Functions array references + /// itself (directly, or via a cycle of several functions); without this bound such a + /// document would recurse until the process' call stack is exhausted, raising an + /// unrecoverable that crashes the whole process rather + /// than failing this document open/render call alone. + /// + private const int MaxFunctionRecursionDepth = 32; + + /// + /// The current /Function resolution recursion depth, incremented/decremented around + /// every call. + /// + private int _functionRecursionDepth; + + /// + /// Resolves a /Function entry (/FunctionType 0/2/3, the 3 + /// single-input function types a shading pattern's own /Function entry may use) + /// into the common shape. + /// + /// The (possibly indirect-reference) /Function entry to resolve. + /// The resolved function. + /// + /// Thrown when the resolved value is not a dictionary/stream, or when a required entry is + /// missing or malformed - propagated from the type-specific resolver - or when the + /// function resolves back to itself (directly or via a cycle of several sub-functions), or + /// otherwise nests deeper than . + /// + /// + /// Thrown when /FunctionType is not 0, 2, or 3 (feature + /// pdf-functiontype-{n}), or propagated from the type-specific resolver for a + /// further-unsupported shape (for example a multi-input /FunctionType 0 function). + /// + internal IPdfFunction ResolveFunctionGeneric(PdfObject functionEntry) + { + if (_functionRecursionDepth >= MaxFunctionRecursionDepth) + { + throw new InvalidDataException( + $"/Function resolution exceeds the maximum supported nesting depth of {MaxFunctionRecursionDepth}."); + } + + _functionRecursionDepth++; + try + { + var function = Resolve(functionEntry); + var functionType = RequireIntEntry(function, "FunctionType"); + return functionType switch + { + 0 => ResolveFunction(functionEntry), + 2 => ResolveExponentialFunction(function), + 3 => ResolveStitchingFunction(function), + _ => throw new UnsupportedImageFeatureException( + $"pdf-functiontype-{functionType}", + $"/FunctionType {functionType} is not supported; only /FunctionType 0 (sampled), " + + "2 (exponential), and 3 (stitching) are supported."), + }; + } + finally + { + _functionRecursionDepth--; + } + } + + /// Resolves an already-resolved /FunctionType 2 dictionary into an . + /// + /// Thrown when /Domain is missing/malformed, or /C0//C1 (when + /// present) do not have the same length as one another. + /// + private ExponentialFunction ResolveExponentialFunction(PdfObject function) + { + var domain = RequireNumberArray(function, "Domain"); + if (domain.Length != 2) + { + throw new InvalidDataException("/Domain must have exactly 2 elements for a 1-input function."); + } + + var c0 = ResolveOptionalNumberArray(function, "C0") ?? [0.0]; + var c1 = ResolveOptionalNumberArray(function, "C1") ?? [1.0]; + if (c0.Length != c1.Length) + { + throw new InvalidDataException("/C0 and /C1 must have the same number of elements."); + } + + var n = RequireNumberEntry(function, "N"); + + return new ExponentialFunction + { + Domain = domain, + C0 = c0, + C1 = c1, + N = n, + }; + } + + /// Resolves an already-resolved /FunctionType 3 dictionary into a . + /// + /// Thrown when /Domain//Functions//Bounds//Encode are + /// missing/malformed, or their element counts do not match the PDF specification's own + /// required shape (/Bounds has k - 1 elements and /Encode has + /// 2k elements, for k == /Functions' element count). + /// + /// + /// Propagated from for an unsupported sub-function. + /// + private StitchingFunction ResolveStitchingFunction(PdfObject function) + { + var domain = RequireNumberArray(function, "Domain"); + if (domain.Length != 2) + { + throw new InvalidDataException("/Domain must have exactly 2 elements for a 1-input function."); + } + + var functionsEntry = function.Get("Functions") ?? throw new InvalidDataException("/FunctionType 3 is missing required /Functions."); + var functionsArray = Resolve(functionsEntry); + if (functionsArray.Kind != PdfKind.Array || functionsArray.Items.Count == 0) + { + throw new InvalidDataException("/Functions must be a non-empty array."); + } + + var functions = functionsArray.Items.Select(ResolveFunctionGeneric).ToArray(); + var k = functions.Length; + + var bounds = ResolveOptionalNumberArray(function, "Bounds") ?? []; + if (bounds.Length != k - 1) + { + throw new InvalidDataException($"/Bounds must have exactly {k - 1} element(s) for {k} sub-function(s)."); + } + + var encode = RequireNumberArray(function, "Encode"); + if (encode.Length != 2 * k) + { + throw new InvalidDataException($"/Encode must have exactly {2 * k} element(s) for {k} sub-function(s)."); + } + + return new StitchingFunction + { + Domain = domain, + Functions = functions, + Bounds = bounds, + Encode = encode, + }; + } + + /// Reads a required numeric dictionary entry (resolving an indirect reference). + /// + /// Thrown when the entry is absent or does not resolve to a number. + /// + private double RequireNumberEntry(PdfObject dictionary, string key) + { + var entry = dictionary.Get(key) ?? throw new InvalidDataException($"Dictionary is missing required /{key}."); + var resolved = Resolve(entry); + if (resolved.Kind != PdfKind.Number) + { + throw new InvalidDataException($"/{key} must be a number."); + } + + return resolved.Number; + } + + /// + /// Resolves a /Function entry restricted to /FunctionType 0 (sampled + /// function) into a , decoding its stream bytes via + /// (already filter-aware - no new filter code needed). + /// + /// + /// Only a single-input (/Domain a 2-element array) /FunctionType 0 function + /// is supported - the shape every axial/radial (/ShadingType 2/3) shading + /// function uses. A multi-input /FunctionType 0 function (used for + /// /DeviceN//Separation tint transforms, themselves already out of scope - + /// see PdfDocument.Color.cs) is rejected, as are /FunctionType 2 + /// (exponential interpolation), 3 (stitching), and 4 (PostScript + /// calculator) - none of which occur in this evaluator's own motivating real-world PDF. + /// /Order is never consulted: this evaluator always linearly interpolates between + /// the two nearest samples, matching every sampled function this evaluator's motivating + /// PDF declares (each has /Order absent or 1). + /// + /// + /// The (possibly indirect-reference) /Function entry to resolve. + /// + /// The resolved sampled function. + /// + /// Thrown when the resolved value is not a stream, when a required entry + /// (/Domain, /Range, /Size, /BitsPerSample) is missing or + /// malformed, or when /Size's element is not a finite number (defense-in-depth: a + /// NaN/Infinity value would otherwise reach an unchecked numeric cast). + /// + /// + /// Thrown when /FunctionType is not 0 (feature + /// pdf-functiontype-{n}), when /Domain does not have exactly 2 elements (a + /// multi-input function, feature pdf-function-multiinput), or when + /// /BitsPerSample is not 8 or 16 (feature + /// pdf-function-bitspersample-{n}). + /// + internal SampledFunction ResolveFunction(PdfObject functionEntry) + { + var function = Resolve(functionEntry); + if (function.Kind != PdfKind.Stream) + { + throw new InvalidDataException("/Function must resolve to a stream (only /FunctionType 0 is supported)."); + } + + var functionType = RequireIntEntry(function, "FunctionType"); + if (functionType != 0) + { + throw new UnsupportedImageFeatureException( + $"pdf-functiontype-{functionType}", + $"/FunctionType {functionType} is not supported; only /FunctionType 0 (sampled) is supported."); + } + + var domain = RequireNumberArray(function, "Domain"); + if (domain.Length != 2) + { + throw new UnsupportedImageFeatureException( + "pdf-function-multiinput", + "A /FunctionType 0 function with more than 1 input (a /Domain with more than 2 " + + "elements) is not supported."); + } + + var range = RequireNumberArray(function, "Range"); + if (range.Length == 0 || range.Length % 2 != 0) + { + throw new InvalidDataException("/Range must be a non-empty array of an even number of elements."); + } + + var sizeArray = RequireNumberArray(function, "Size"); + if (sizeArray.Length != 1) + { + throw new InvalidDataException("/Size must have exactly 1 element for a 1-input function."); + } + + // Reject a non-finite /Size element before casting it to int - NaN/Infinity cast to int + // is an unchecked, unspecified-magnitude conversion in C# (not an exception), so a + // crafted function dictionary could otherwise smuggle a garbage `size` value past the + // `size < 1` check below and corrupt downstream sample-index arithmetic. + if (!double.IsFinite(sizeArray[0])) + { + throw new InvalidDataException("/Size must be a finite number."); + } + + var size = (int)sizeArray[0]; + if (size < 1) + { + throw new InvalidDataException("/Size must be a positive integer."); + } + + var bitsPerSample = RequireIntEntry(function, "BitsPerSample"); + if (bitsPerSample is not (8 or 16)) + { + throw new UnsupportedImageFeatureException( + $"pdf-function-bitspersample-{bitsPerSample}", + $"/BitsPerSample {bitsPerSample} is not supported; only 8 and 16 are supported."); + } + + var encode = ResolveOptionalNumberArray(function, "Encode") ?? [0, size - 1]; + if (encode.Length != 2) + { + throw new InvalidDataException("/Encode must have exactly 2 elements for a 1-input function."); + } + + var decode = ResolveOptionalNumberArray(function, "Decode") ?? range; + if (decode.Length != range.Length) + { + throw new InvalidDataException("/Decode must have the same number of elements as /Range."); + } + + var samples = GetStreamDecodedBytes(function); + + return new SampledFunction + { + Domain = domain, + Range = range, + Decode = decode, + Encode = encode, + Size = size, + BitsPerSample = bitsPerSample, + Samples = samples, + }; + } + + /// Reads a required numeric-array dictionary entry (resolving an indirect reference and resolving each element). + /// + /// Thrown when the entry is absent, does not resolve to an array, or any element does not + /// resolve to a number. + /// + private double[] RequireNumberArray(PdfObject dictionary, string key) + { + var entry = dictionary.Get(key) ?? throw new InvalidDataException($"Stream is missing required /{key}."); + return ResolveNumberArray(entry, key); + } + + /// Reads an optional numeric-array dictionary entry, or when absent. + /// + /// Thrown when the entry is present but does not resolve to an array, or any element does + /// not resolve to a number. + /// + private double[]? ResolveOptionalNumberArray(PdfObject dictionary, string key) + { + var entry = dictionary.Get(key); + return entry is null ? null : ResolveNumberArray(entry, key); + } + + /// Resolves an already-looked-up entry into a numeric array, resolving each element. + /// + /// Thrown when the resolved value is not an array, or any element does not resolve to a + /// number. + /// + private double[] ResolveNumberArray(PdfObject entry, string key) + { + var resolved = Resolve(entry); + if (resolved.Kind != PdfKind.Array) + { + throw new InvalidDataException($"/{key} must be an array."); + } + + var result = new double[resolved.Items.Count]; + for (var i = 0; i < resolved.Items.Count; i++) + { + var item = Resolve(resolved.Items[i]); + if (item.Kind != PdfKind.Number) + { + throw new InvalidDataException($"/{key} array elements must all be numbers."); + } + + result[i] = item.Number; + } + + return result; + } +} diff --git a/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.GraphicsState.cs b/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.GraphicsState.cs new file mode 100644 index 00000000..948a3d08 --- /dev/null +++ b/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.GraphicsState.cs @@ -0,0 +1,416 @@ +using System.Numerics; +using DemaConsulting.CanvasNet.Canvas; +using DemaConsulting.CanvasNet.Drawing; + +namespace DemaConsulting.CanvasNet.Pdf; + +public sealed partial class PdfDocument +{ + /// + /// A snapshot of every content-stream graphics parameter the q/Q operators + /// push and pop as a unit, and the cm/w/J/j/M/d + /// operators mutate on the currently active instance. + /// + /// + /// + /// Every field's initial value is the PDF specification's own documented default for + /// a freshly started content stream, not necessarily the corresponding + /// constructor default (most notably + /// : the PDF default is 10, while + /// 's own C# default is 4) - every + /// constructed while painting a stroke must pass + /// every one of these fields explicitly, never relying on + /// 's own defaults. + /// + /// + /// Phase 3 adds real device color state: / + /// (the actual painted color, updated by g/rg/k/sc/scn + /// and their stroking counterparts) and / + /// (which device color space sc/scn's + /// operand count is currently interpreted against). Every field's default matches the + /// PDF specification's own documented initial state for a freshly started content + /// stream: DeviceGray at value 0, i.e. opaque black - identical to Phase + /// 2's fixed-black behavior, so no Phase 2 test regresses. + /// + /// + private sealed class GraphicsState + { + /// + /// Gets or sets the current transformation matrix, mapping PDF user-space points + /// (as they appear in path-construction operands) to device pixel-space points. + /// + internal Matrix3x2 CurrentTransform { get; set; } + + /// + /// Gets or sets the line width, in user-space units (not yet scaled by the CTM). The + /// PDF specification's default is 1. + /// + internal float LineWidth { get; set; } = 1f; + + /// + /// Gets or sets the line cap style. The PDF specification's default is + /// (numeric value 0). + /// + internal LineCap LineCap { get; set; } = LineCap.Butt; + + /// + /// Gets or sets the line join style. The PDF specification's default is + /// (numeric value 0). + /// + internal LineJoin LineJoin { get; set; } = LineJoin.Miter; + + /// + /// Gets or sets the miter limit. The PDF specification's default is 10 - + /// distinct from 's own C# default of 4. + /// + internal float MiterLimit { get; set; } = 10f; + + /// + /// Gets or sets the dash pattern's on/off lengths, in user-space units (not yet + /// scaled by the CTM), or for a solid stroke (the PDF + /// specification's default). + /// + internal IReadOnlyList? DashArray { get; set; } + + /// + /// Gets or sets the dash pattern's phase, in user-space units (not yet scaled by the + /// CTM). The PDF specification's default is 0. + /// + internal float DashPhase { get; set; } + + /// + /// Gets or sets the current fill color. The PDF specification's default is opaque + /// black (DeviceGray value 0). + /// + internal Rgba32 FillColor { get; set; } = new(0, 0, 0, 255); + + /// + /// Gets or sets the current stroke color. The PDF specification's default is opaque + /// black (DeviceGray value 0). + /// + internal Rgba32 StrokeColor { get; set; } = new(0, 0, 0, 255); + + /// + /// Gets or sets the current fill color space, used to determine how many numeric + /// operands sc/scn requires. The PDF specification's default is + /// DeviceGray. + /// + internal PdfColorSpace FillColorSpace { get; set; } = PdfColorSpace.DeviceGray; + + /// + /// Gets or sets the current stroke color space, used to determine how many numeric + /// operands SC/SCN requires. The PDF specification's default is + /// DeviceGray. + /// + internal PdfColorSpace StrokeColorSpace { get; set; } = PdfColorSpace.DeviceGray; + + /// + /// Gets or sets the currently selected fill pattern, resolved by the most recent + /// scn operator against a /Pattern , or + /// when 's + /// is not (set back to + /// by cs/g/rg/k and their fill counterparts). + /// + internal ResolvedPattern? FillPattern { get; set; } + + /// + /// Gets or sets the currently selected stroke pattern - see 's + /// remarks, which apply identically here against . + /// + internal ResolvedPattern? StrokePattern { get; set; } + + /// + /// Gets or sets the currently selected font, resolved by the most recent Tf + /// operator, or when no Tf has been issued yet (in + /// which case a text-showing operator fails closed - see + /// ). Phase 4 adds this and every other field below; + /// every one of them is, per the PDF specification, part of the graphics state + /// proper (saved/restored by q/Q, and persisting across BT/ + /// ET) - a deliberate, documented distinction from + /// /, which + /// are reset by BT and live outside this class entirely (see that field's own + /// remarks). + /// + internal IResolvedFont? Font { get; set; } + + /// Gets or sets the current font size, in unscaled text-space units. The PDF specification defines no default (a Tf is required before text is shown). + internal double FontSize { get; set; } + + /// Gets or sets the character spacing, in unscaled text-space units. The PDF specification's default is 0. + internal double CharSpacing { get; set; } + + /// Gets or sets the word spacing, in unscaled text-space units. The PDF specification's default is 0. + internal double WordSpacing { get; set; } + + /// Gets or sets the horizontal scaling, as a percentage. The PDF specification's default is 100. + internal double HorizontalScaling { get; set; } = 100; + + /// Gets or sets the leading, in unscaled text-space units. The PDF specification's default is 0. + internal double Leading { get; set; } + + /// + /// Gets or sets the text-rendering mode. The PDF specification's default is 0 + /// (fill) - see for which modes this + /// phase actually supports. + /// + internal int RenderMode { get; set; } + + /// Gets or sets the text rise, in unscaled text-space units. The PDF specification's default is 0. + internal double TextRise { get; set; } + + /// + /// Produces an independent copy of this graphics state, for q to push onto the + /// graphics-state stack. + /// + /// A new instance with the same field values. + /// + /// is shared, not deep-copied: it is only ever replaced + /// wholesale by d (never mutated in place), so sharing the same + /// reference across a clone is safe. + /// is likewise shared, not deep-copied: instances are + /// immutable once resolved by . + /// + internal GraphicsState Clone() => new() + { + CurrentTransform = CurrentTransform, + LineWidth = LineWidth, + LineCap = LineCap, + LineJoin = LineJoin, + MiterLimit = MiterLimit, + DashArray = DashArray, + DashPhase = DashPhase, + FillColor = FillColor, + StrokeColor = StrokeColor, + FillColorSpace = FillColorSpace, + StrokeColorSpace = StrokeColorSpace, + FillPattern = FillPattern, + StrokePattern = StrokePattern, + Font = Font, + FontSize = FontSize, + CharSpacing = CharSpacing, + WordSpacing = WordSpacing, + HorizontalScaling = HorizontalScaling, + Leading = Leading, + RenderMode = RenderMode, + TextRise = TextRise, + }; + } + + /// + /// The graphics-state stack pushed/popped by q/Q, for the content stream + /// currently being executed by . Reinitialized fresh at + /// the start of every call; never shared or reused + /// across calls. + /// + private Stack _gsStack = null!; + + /// + /// The currently active graphics state, for the content stream currently being executed + /// by . + /// + private GraphicsState _gs = null!; + + /// + /// The maximum number of entries (q) allows the + /// graphics-state stack to grow to before failing closed, bounding a crafted content + /// stream that issues an enormous run of unmatched q operators (each pushing a + /// cloned instance) from exhausting memory. 256 is generous + /// for any legitimate content stream's save/restore nesting, matching the + /// Max*NestingDepth naming/exception-message convention used for Form XObject, + /// Type3 glyph, and page-tree recursion elsewhere in this class. + /// + private const int MaxGraphicsStateStackDepth = 256; + + /// + /// Handles the q operator: pushes a copy of the current graphics state, so a + /// matching Q can later restore exactly what was active before this q. + /// + /// + /// Thrown when the graphics-state stack already holds + /// entries. + /// + private void OpPushGraphicsState() + { + if (_gsStack.Count >= MaxGraphicsStateStackDepth) + { + throw new InvalidDataException( + $"Graphics state stack depth exceeds the maximum supported depth of {MaxGraphicsStateStackDepth}."); + } + + _gsStack.Push(_gs.Clone()); + } + + /// + /// Handles the Q operator: pops the most recently pushed graphics state and makes + /// it current again. + /// + /// + /// A bare Q with no matching q (an empty stack) is tolerated as a no-op, + /// not an error - this is a deliberate leniency decision (distinct from the strict + /// "malformed operand count/type" throw cases mandated for every implemented operator), + /// matching this phase's general lenient-consumer posture toward structurally unusual but + /// not operand-malformed input. + /// + private void OpPopGraphicsState() + { + if (_gsStack.Count > 0) + { + _gs = _gsStack.Pop(); + } + } + + /// + /// Handles the cm a b c d e f operator: composes a new matrix onto the current + /// transformation matrix by premultiplication (the new matrix is applied first, the + /// previously current transform is applied second/outermost). + /// + /// The operator's accumulated operand stack. Must contain exactly 6 numbers. + /// + /// Thrown when does not contain exactly 6 numbers. + /// + private void OpConcatMatrix(IReadOnlyList operands) + { + var values = RequireNumbers(operands, "cm", 6); + var matrix = new Matrix3x2( + (float)values[0], + (float)values[1], + (float)values[2], + (float)values[3], + (float)values[4], + (float)values[5]); + _gs.CurrentTransform = matrix * _gs.CurrentTransform; + } + + /// Handles the w operator: sets the current line width (raw user-space units). + /// + /// Thrown when does not contain exactly 1 number. + /// + private void OpSetLineWidth(IReadOnlyList operands) => + _gs.LineWidth = (float)RequireNumbers(operands, "w", 1)[0]; + + /// Handles the J operator: sets the current line cap style. + /// + /// Thrown when does not contain exactly 1 number, or when + /// that number is not 0, 1, or 2. + /// + private void OpSetLineCap(IReadOnlyList operands) + { + var code = (int)RequireNumbers(operands, "J", 1)[0]; + _gs.LineCap = code switch + { + 0 => LineCap.Butt, + 1 => LineCap.Round, + 2 => LineCap.Square, + _ => throw new InvalidDataException($"Operator 'J' requires a cap code of 0, 1, or 2; got {code}."), + }; + } + + /// Handles the j operator: sets the current line join style. + /// + /// Thrown when does not contain exactly 1 number, or when + /// that number is not 0, 1, or 2. + /// + private void OpSetLineJoin(IReadOnlyList operands) + { + var code = (int)RequireNumbers(operands, "j", 1)[0]; + _gs.LineJoin = code switch + { + 0 => LineJoin.Miter, + 1 => LineJoin.Round, + 2 => LineJoin.Bevel, + _ => throw new InvalidDataException($"Operator 'j' requires a join code of 0, 1, or 2; got {code}."), + }; + } + + /// Handles the M operator: sets the current miter limit. + /// + /// A value below the PDF/-required minimum of 1 + /// is clamped to 1 rather than rejected - a deliberate, documented leniency for + /// producers that occasionally emit a slightly-under-1 miter limit due to floating-point + /// rounding, distinct from the strict "malformed operand count/type" throw cases. + /// + /// + /// Thrown when does not contain exactly 1 number. + /// + private void OpSetMiterLimit(IReadOnlyList operands) => + _gs.MiterLimit = Math.Max((float)RequireNumbers(operands, "M", 1)[0], 1f); + + /// Handles the d array phase operator: sets the current dash pattern. + /// + /// Thrown when does not contain exactly 2 operands (an array + /// followed by a number), when any array entry is not a finite, non-negative number, or + /// when the phase is not a number. + /// + private void OpSetDashPattern(IReadOnlyList operands) + { + if (operands.Count != 2 || operands[0].Kind != PdfKind.Array || operands[1].Kind != PdfKind.Number) + { + throw new InvalidDataException( + $"Operator 'd' requires exactly 2 operands (an array, then a number); got {operands.Count}."); + } + + var array = operands[0]; + var dashArray = new float[array.Items.Count]; + for (var i = 0; i < array.Items.Count; i++) + { + var item = array.Items[i]; + if (item.Kind != PdfKind.Number || !double.IsFinite(item.Number) || item.Number < 0) + { + throw new InvalidDataException( + "Operator 'd' dash array entries must be finite numbers greater than or equal to zero."); + } + + dashArray[i] = (float)item.Number; + } + + // An empty array ("[] 0 d") is the documented "reset to solid" form. + _gs.DashArray = dashArray.Length == 0 ? null : dashArray; + _gs.DashPhase = (float)operands[1].Number; + } + + /// + /// Validates that contains exactly + /// entries, each of kind and finite, and returns their + /// numeric values in order. + /// + /// The operator's accumulated operand stack. + /// The operator name, for the exception message. + /// The exact number of numeric operands required. + /// The operands' numeric values, in order. + /// + /// Thrown when does not contain exactly + /// entries, when any entry is not of kind , or when any + /// entry's numeric value is not finite (NaN or an infinity). + /// + private static double[] RequireNumbers(IReadOnlyList operands, string operatorName, int count) + { + if (operands.Count != count) + { + throw new InvalidDataException( + $"Operator '{operatorName}' requires exactly {count} operand(s); got {operands.Count}."); + } + + var values = new double[count]; + for (var i = 0; i < count; i++) + { + if (operands[i].Kind != PdfKind.Number) + { + throw new InvalidDataException($"Operator '{operatorName}' requires numeric operands."); + } + + // Reject NaN/Infinity before any caller can use it in a transform or path + // coordinate - a crafted content stream supplying a non-finite operand to, e.g., + // `cm` or a path-construction operator could otherwise propagate NaN/Infinity into + // downstream geometry/rendering code that assumes finite input. + if (!double.IsFinite(operands[i].Number)) + { + throw new InvalidDataException( + $"Operator '{operatorName}' requires finite numeric operands; got {operands[i].Number}."); + } + + values[i] = operands[i].Number; + } + + return values; + } +} diff --git a/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Images.cs b/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Images.cs new file mode 100644 index 00000000..6e01e4eb --- /dev/null +++ b/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Images.cs @@ -0,0 +1,514 @@ +// cspell:ignore xobject bitdepth ccittfax EOFB +using System.Numerics; +using DemaConsulting.CanvasNet.Canvas; +using DemaConsulting.CanvasNet.Codecs; + +namespace DemaConsulting.CanvasNet.Pdf; + +public sealed partial class PdfDocument +{ + /// + /// Handles the /name Do operator: resolves a named XObject from the current page's + /// /Resources/XObject dictionary and, for an image XObject, decodes and composites + /// it onto the destination surface through the current transformation matrix. + /// + /// + /// A /Subtype /Form XObject is decoded and executed as a nested content stream by + /// - see that method's own remarks for exactly what is + /// (and is not) supported. + /// + /// + /// Thrown when is not exactly 1 name operand, when the name + /// is not declared in the current page's /Resources/XObject dictionary (or no + /// /Resources exists at all), when the resolved value is not a stream, when the + /// stream's /Subtype is missing or is neither Image nor Form, or + /// propagated from (recursion-depth exceeded, or a + /// malformed Form /Matrix). + /// + /// + /// Propagated from for an unsupported image feature. + /// + private void OpDrawXObject(IReadOnlyList operands) + { + RequireOperandCount(operands, "Do", 1); + if (operands[0].Kind != PdfKind.Name) + { + throw new InvalidDataException("Operator 'Do' requires a name operand."); + } + + var name = operands[0].Text; + var xObjectDictionary = _resources?.Get("XObject"); + var resolvedDictionary = xObjectDictionary is null ? null : Resolve(xObjectDictionary); + var entry = resolvedDictionary?.Get(name); + if (entry is null) + { + throw new InvalidDataException( + $"Undefined XObject '/{name}' (not declared in the current page's /Resources/XObject)."); + } + + var xObject = Resolve(entry); + if (xObject.Kind != PdfKind.Stream) + { + throw new InvalidDataException($"XObject '/{name}' does not resolve to a stream."); + } + + var subtype = GetNameValue(xObject, "Subtype"); + switch (subtype) + { + case "Form": + OpDrawFormXObject(xObject); + break; + + case "Image": + var image = DecodeImageXObject(xObject); + CompositeImageOntoSurface(image, _gs.CurrentTransform); + break; + + default: + throw new InvalidDataException($"XObject '/{name}' has a missing or unrecognized /Subtype."); + } + } + + /// + /// The maximum number of nested /Subtype /Form XObject invocations + /// allows before failing closed, bounding both pathological + /// (self-referencing) Form content and the native C# call-stack depth this interpreter + /// uses to execute nested content streams. + /// + private const int MaxFormNestingDepth = 12; + + /// + /// The current /Subtype /Form XObject nesting depth, incremented/decremented around + /// every call. Reset to 0 at the start of every + /// top-level call. + /// + private int _formNestingDepth; + + /// + /// Handles a /Subtype /Form XObject resolved by : + /// decodes its content stream and executes it as a nested content stream, implicitly + /// bracketed like q ... Q around the invoking stream's own graphics state + /// and /Resources. + /// + /// The already-resolved /Subtype /Form stream object. + /// + /// + /// The Form's optional /Matrix (read by ; identity + /// when absent) is concatenated into the current transformation matrix using exactly + /// the same left-multiply convention as the cm operator (see + /// ): the Form's matrix is applied first/innermost, the + /// CTM in effect when Do was invoked is applied second/outermost. The Form's + /// own /Resources dictionary is used when present; otherwise the invoking + /// stream's current is used unchanged (resource-scope + /// fallback). + /// + /// + /// The nested content stream inherits the invoking stream's current graphics state + /// (colors, line style, font, etc.) as its own starting state, and a fresh, empty + /// graphics-state stack (so an unbalanced q/Q inside the Form can never + /// touch the invoking stream's own saved states - see 's + /// own documented leniency toward a bare Q). Once the nested execution returns + /// (successfully or via a thrown exception), the invoking stream's own + /// , graphics state, and graphics-state stack are restored + /// exactly as they were before this method ran - mutations made inside the Form (CTM, + /// colors, font selection, etc.) never leak back out, matching an implicit q + /// ... Q bracketing. Path-construction state (, + /// , etc.) and are deliberately + /// not saved/restored: they are not part of the PDF graphics-state stack, and + /// any path-painting/surface side effects performed by the Form's content must persist + /// exactly like any other painting operator's side effects. + /// + /// + /// Phase 13 limitations: no /BBox clipping is applied (the + /// Form's content paints without being clipped to its declared bounding box), and no + /// /Group (transparency group) handling is performed - the Form's content + /// simply paints directly onto the destination surface exactly like the invoking + /// stream's own content. + /// + /// + /// + /// Thrown when the current nesting depth already equals , + /// when /Matrix is present but is not an array of exactly 6 numbers (propagated + /// from ), or propagated from nested execution of the Form's + /// own content stream. + /// + private void OpDrawFormXObject(PdfObject formStream) + { + if (_formNestingDepth >= MaxFormNestingDepth) + { + throw new InvalidDataException( + $"Form XObject nesting exceeds the maximum supported depth of {MaxFormNestingDepth}."); + } + + var formMatrix = ReadFormMatrix(formStream); + var ownResources = formStream.Get("Resources"); + var resolvedFormResources = ownResources is null ? _resources : Resolve(ownResources); + var contentBytes = GetStreamDecodedBytes(formStream); + + var savedResources = _resources; + var savedGs = _gs; + var savedGsStack = _gsStack; + _formNestingDepth++; + try + { + _resources = resolvedFormResources; + var nestedGs = savedGs.Clone(); + nestedGs.CurrentTransform = formMatrix * savedGs.CurrentTransform; + _gs = nestedGs; + _gsStack = new Stack(); + ExecuteOperators(contentBytes); + } + finally + { + _formNestingDepth--; + _resources = savedResources; + _gs = savedGs; + _gsStack = savedGsStack; + } + } + + /// + /// Reads a /Subtype /Form XObject's optional /Matrix entry: the identity + /// matrix when absent, otherwise an array of exactly 6 numbers interpreted exactly like + /// the cm operator's 6 operands. + /// + /// The already-resolved /Subtype /Form stream object. + /// The Form's declared matrix, or when absent. + /// + /// Thrown when /Matrix is present but is not an array of exactly 6 numbers. + /// + private Matrix3x2 ReadFormMatrix(PdfObject formStream) => ReadOptionalMatrix(formStream); + + /// + /// Reads a dictionary's optional /Matrix-style entry: the identity matrix when + /// absent, otherwise an array of exactly 6 numbers interpreted exactly like the + /// cm operator's 6 operands. Shared by (a Form + /// XObject's own /Matrix) and the Pattern-space /Matrix resolution in + /// PdfDocument.Patterns.cs, so both parse the exact same array shape via one + /// implementation. + /// + /// The already-resolved dictionary (or stream) object. + /// The dictionary key to read. Defaults to "Matrix". + /// The declared matrix, or when absent. + /// + /// Thrown when the entry is present but is not an array of exactly 6 numbers. + /// + private Matrix3x2 ReadOptionalMatrix(PdfObject dictionary, string key = "Matrix") + { + var matrixEntry = dictionary.Get(key); + if (matrixEntry is null) + { + return Matrix3x2.Identity; + } + + var resolved = Resolve(matrixEntry); + if (resolved.Kind != PdfKind.Array || resolved.Items.Count != 6) + { + throw new InvalidDataException($"/{key} must be an array of exactly 6 numbers."); + } + + var values = new double[6]; + for (var i = 0; i < 6; i++) + { + var item = Resolve(resolved.Items[i]); + if (item.Kind != PdfKind.Number) + { + throw new InvalidDataException($"/{key} entries must all be numbers."); + } + + values[i] = item.Number; + } + + return new Matrix3x2( + (float)values[0], + (float)values[1], + (float)values[2], + (float)values[3], + (float)values[4], + (float)values[5]); + } + + /// + /// Decodes an image XObject stream into a fully opaque of its + /// declared /Width x /Height. + /// + /// + /// A /Filter /DCTDecode image (and no other filter) is decoded directly via + /// against the stream's raw bytes, trusting + /// the decoded 's own width/height over the PDF /Width/ + /// /Height entries (a documented leniency: a mismatch is tolerated, not rejected). + /// A /Filter /CCITTFaxDecode image (and no other filter) is likewise decoded + /// directly via against the stream's raw bytes: only Group 4 + /// (/DecodeParms /K negative) ITU-T T.6 two-dimensional (MMR) coding is supported - + /// Group 3 (/K 0 or greater) and an explicit /EndOfLine true both + /// fail closed - and the decoded samples are still routed through the general + /// /ColorSpace pipeline below (unlike DCTDecode, which never touches it), + /// using /DecodeParms's own /Columns//Rows (not /Width/ + /// /Height) as the authoritative surface dimensions, mirroring DCTDecode's + /// own "trust the decoder's own dimensions" leniency; /ColorSpace defaults to + /// DeviceGray when absent (CCITT images conventionally omit it), and any resolved + /// color space with more than 1 component is rejected. /EndOfBlock is never + /// consulted - decoding always stops after exactly the resolved row count, so any + /// trailing EOFB/RTC marker bits (or a partial final block truncated before + /// the full row count) are never specially detected or reported. Every other supported + /// case decodes via the general FlateDecode(+predictor) pipeline and interprets + /// the resulting raw samples per /ColorSpace (device color spaces, /ICCBased, + /// and /Indexed - see ) and /BitsPerComponent + /// (8 only). /SMask//Mask are never consulted - every decoded image + /// is treated as fully opaque, a documented Phase 3 limitation. + /// + /// + /// Thrown when /Width//Height is missing, not a number, or not positive, + /// when /ColorSpace is missing (for a non-CCITTFaxDecode image), when + /// DCTDecode is combined with any other filter, when CCITTFaxDecode is + /// combined with any other filter, or propagated from for a + /// malformed/truncated CCITT bit stream. + /// + /// + /// Thrown when /ColorSpace names an unsupported color space, when + /// /BitsPerComponent is not 8 (for a non-DCTDecode/non- + /// CCITTFaxDecode image), when a CCITTFaxDecode image's resolved + /// /ColorSpace has more than 1 component, or propagated from + /// when /DecodeParms /K is 0 or greater + /// (Group 3) or /EndOfLine is . + /// + private Surface DecodeImageXObject(PdfObject stream) + { + var width = RequireIntEntry(stream, "Width"); + var height = RequireIntEntry(stream, "Height"); + if (width <= 0 || height <= 0) + { + throw new InvalidDataException("Image XObject /Width and /Height must be positive integers."); + } + + if (width > Surface.MaxDimension || height > Surface.MaxDimension) + { + throw new InvalidDataException( + $"Image XObject dimensions {width}x{height} exceed the maximum supported size of " + + $"{Surface.MaxDimension}x{Surface.MaxDimension}."); + } + + var (filterNames, filterParms) = ResolveFilterPipeline(stream); + if (filterNames.Count == 1 && filterNames[0] == "DCTDecode") + { + var rawBytes = GetStreamRawBytes(stream); + return JpegCodec.Load(new MemoryStream(rawBytes)); + } + + if (filterNames.Contains("DCTDecode")) + { + throw new InvalidDataException("DCTDecode combined with another filter is not supported."); + } + + if (filterNames.Count == 1 && filterNames[0] == "CCITTFaxDecode") + { + return DecodeCcittFaxImageXObject(stream, filterParms[0], height); + } + + if (filterNames.Contains("CCITTFaxDecode")) + { + throw new InvalidDataException("CCITTFaxDecode combined with another filter is not supported."); + } + + var decoded = GetStreamDecodedBytes(stream); + + var colorSpaceObject = stream.Get("ColorSpace") + ?? throw new InvalidDataException("Image XObject is missing required /ColorSpace."); + var colorSpace = ResolveColorSpaceValue(Resolve(colorSpaceObject)); + + var bitsPerComponent = RequireIntEntry(stream, "BitsPerComponent"); + if (bitsPerComponent != 8) + { + throw new UnsupportedImageFeatureException( + $"pdf-image-bitdepth-{bitsPerComponent}", + $"Image XObjects with {bitsPerComponent}-bit components are not supported; only 8 is supported."); + } + + var componentCount = ComponentCount(colorSpace); + var surface = new Surface(width, height); + var rowBytes = componentCount * width; + for (var y = 0; y < height; y++) + { + var rowOffset = y * rowBytes; + for (var x = 0; x < width; x++) + { + var pixelOffset = rowOffset + (x * componentCount); + surface[x, y] = SamplesToColor(colorSpace, decoded, pixelOffset, componentCount); + } + } + + return surface; + } + + /// + /// Decodes a bare /Filter /CCITTFaxDecode image XObject ('s + /// own dedicated branch): resolves /K//Columns//Rows//BlackIs1/ + /// /EncodedByteAlign//EndOfLine from (the pipeline's + /// sole, possibly- /DecodeParms entry), defaulting + /// /Rows to when absent or 0, then decodes via + /// and composites the result through the shared + /// /ColorSpace pipeline (), using /Columns/the + /// resolved /Rows - not /Width//Height - as the authoritative surface + /// dimensions. + /// + /// + /// Thrown when the resolved /Columns or /Rows is zero, negative, or exceeds + /// , or propagated from for + /// a malformed/truncated CCITT bit stream. + /// + /// + /// Thrown when the resolved /ColorSpace has more than 1 component, or propagated + /// from when /K is 0 or greater or + /// /EndOfLine is . + /// + private Surface DecodeCcittFaxImageXObject(PdfObject stream, PdfObject? parm, int imageHeight) + { + var rawBytes = GetStreamRawBytes(stream); + var k = parm is null ? 0 : GetIntEntry(parm, "K", 0); + var columns = parm is null ? 1728 : GetIntEntry(parm, "Columns", 1728); + var rowsParam = parm is null ? 0 : GetIntEntry(parm, "Rows", 0); + var rows = rowsParam > 0 ? rowsParam : imageHeight; + var blackIs1 = parm is not null && GetBoolEntry(parm, "BlackIs1", false); + var encodedByteAlign = parm is not null && GetBoolEntry(parm, "EncodedByteAlign", false); + var endOfLine = parm is not null && GetBoolEntry(parm, "EndOfLine", false); + + // Reject oversized, zero, or negative /Columns or /Rows before decoding - DecodeCcittFax + // allocates its output buffer (rowBytes * rows) sized directly by these attacker-controlled + // values, so the bounds-check must run before that call (not merely before the later + // `new Surface(...)` call) to prevent a huge, zero-sized, or negative-sized internal + // allocation from ever happening for a crafted image dictionary. + if (columns <= 0 || rows <= 0 || columns > Surface.MaxDimension || rows > Surface.MaxDimension) + { + throw new InvalidDataException( + $"CCITTFaxDecode image dimensions {columns}x{rows} are invalid or exceed the maximum supported " + + $"size of {Surface.MaxDimension}x{Surface.MaxDimension}."); + } + + var decoded = DecodeCcittFax(rawBytes, k, columns, rows, blackIs1, encodedByteAlign, endOfLine); + + var colorSpaceObject = stream.Get("ColorSpace"); + var colorSpace = colorSpaceObject is null + ? PdfColorSpace.DeviceGray + : ResolveColorSpaceValue(Resolve(colorSpaceObject)); + + var componentCount = ComponentCount(colorSpace); + if (componentCount != 1) + { + throw new UnsupportedImageFeatureException( + "pdf-ccittfax-colorspace", + "CCITTFaxDecode images require a single-component /ColorSpace."); + } + + var surface = new Surface(columns, rows); + for (var y = 0; y < rows; y++) + { + var rowOffset = y * columns; + for (var x = 0; x < columns; x++) + { + surface[x, y] = SamplesToColor(colorSpace, decoded, rowOffset + x, 1); + } + } + + return surface; + } + + /// + /// Converts consecutive raw 8-bit samples (each in [0, 255]) + /// starting at into an opaque color, reusing + /// so image decoding and the rg/k/sc + /// color operators share exactly the same color-space conversion formulas. + /// + private static Rgba32 SamplesToColor(PdfColorSpace colorSpace, byte[] decoded, int offset, int count) + { + if (offset < 0 || count < 0 || offset + count > decoded.Length) + { + throw new InvalidDataException("Image XObject sample data is truncated."); + } + + if (colorSpace.Kind == PdfColorSpace.Family.Indexed) + { + // An /Indexed sample is a raw palette index, not a [0, 1]-normalized component - do + // not divide by 255.0 like every other color space below. + return ColorFromComponents(colorSpace, [(double)decoded[offset]]); + } + + var components = new double[count]; + for (var i = 0; i < count; i++) + { + components[i] = decoded[offset + i] / 255.0; + } + + return ColorFromComponents(colorSpace, components); + } + + /// + /// Composites a decoded image onto the destination surface, mapping the image's unit + /// square ([0, 1] x [0, 1]) through onto device space and + /// sampling the nearest source pixel for every destination pixel in the transformed + /// footprint's device-space bounding box. + /// + /// + /// Nearest-neighbor sampling only - no bilinear interpolation - a documented Phase 3 + /// simplification. A non-invertible (degenerate) silently paints + /// nothing, rather than throwing. Per the PDF specification's image-space convention, + /// image sample row 0 is the top of the unit square (the opposite of + /// user-space's y-up convention): row = floor((1 - v) * image.Height). + /// + private void CompositeImageOntoSurface(Surface image, Matrix3x2 ctm) + { + if (!Matrix3x2.Invert(ctm, out var inverse)) + { + return; + } + + var corner00 = Vector2.Transform(new Vector2(0, 0), ctm); + var corner10 = Vector2.Transform(new Vector2(1, 0), ctm); + var corner01 = Vector2.Transform(new Vector2(0, 1), ctm); + var corner11 = Vector2.Transform(new Vector2(1, 1), ctm); + + var minX = Math.Min(Math.Min(corner00.X, corner10.X), Math.Min(corner01.X, corner11.X)); + var maxX = Math.Max(Math.Max(corner00.X, corner10.X), Math.Max(corner01.X, corner11.X)); + var minY = Math.Min(Math.Min(corner00.Y, corner10.Y), Math.Min(corner01.Y, corner11.Y)); + var maxY = Math.Max(Math.Max(corner00.Y, corner10.Y), Math.Max(corner01.Y, corner11.Y)); + + var startX = Math.Max(0, (int)Math.Floor(minX)); + var endX = Math.Min(_surface.Width - 1, (int)Math.Ceiling(maxX)); + var startY = Math.Max(0, (int)Math.Floor(minY)); + var endY = Math.Min(_surface.Height - 1, (int)Math.Ceiling(maxY)); + + for (var y = startY; y <= endY; y++) + { + for (var x = startX; x <= endX; x++) + { + var devicePoint = new Vector2(x + 0.5f, y + 0.5f); + var unitPoint = Vector2.Transform(devicePoint, inverse); + var u = unitPoint.X; + var v = unitPoint.Y; + if (u < 0 || u >= 1 || v < 0 || v >= 1) + { + continue; + } + + var column = Math.Clamp((int)Math.Floor(u * image.Width), 0, image.Width - 1); + var row = Math.Clamp((int)Math.Floor((1 - v) * image.Height), 0, image.Height - 1); + _surface[x, y] = image[column, row]; + } + } + } + + /// Reads a required integer dictionary entry (resolving an indirect reference). + /// + /// Thrown when the entry is absent or does not resolve to a number. + /// + private int RequireIntEntry(PdfObject dictionary, string key) + { + var entry = dictionary.Get(key) ?? throw new InvalidDataException($"Stream is missing required /{key}."); + var resolved = Resolve(entry); + if (resolved.Kind != PdfKind.Number) + { + throw new InvalidDataException($"/{key} must be a number."); + } + + return (int)resolved.Number; + } +} diff --git a/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.ObjectModel.cs b/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.ObjectModel.cs new file mode 100644 index 00000000..ba9040c2 --- /dev/null +++ b/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.ObjectModel.cs @@ -0,0 +1,313 @@ +// cspell:ignore uncatchable +namespace DemaConsulting.CanvasNet.Pdf; + +public sealed partial class PdfDocument +{ + /// + /// The distinct kinds of value a can represent. + /// + internal enum PdfKind + { + /// The PDF null value. + Null, + + /// A PDF boolean (true/false). + Boolean, + + /// A PDF integer or real number. + Number, + + /// A PDF literal or hexadecimal string, decoded to raw bytes. + String, + + /// A PDF name (for example /Type), decoded to text without the leading slash. + Name, + + /// A PDF array. + Array, + + /// A PDF dictionary. + Dictionary, + + /// A PDF stream (a dictionary plus a raw data byte range). + Stream, + + /// An indirect reference (N G R). + Reference, + } + + /// + /// An internal tagged-union representation of a parsed PDF value. This type is not part of + /// this package's public API - it exists purely to support this document's own parsing. + /// + internal sealed class PdfObject + { + /// Gets the kind of value this instance represents. + internal PdfKind Kind { get; private init; } + + /// Gets the boolean value, when is . + internal bool Boolean { get; private init; } + + /// Gets the numeric value, when is . + internal double Number { get; private init; } + + /// + /// Gets or sets the raw string bytes, when is + /// . Settable (rather than private init, unlike + /// every other field on this type) solely so PdfDocument.Encryption.cs's + /// DecryptStringsInPlace can overwrite already-parsed ciphertext with plaintext + /// in place, without rebuilding the surrounding object tree. This setter must never be + /// called from anywhere else - a cached instance is shared by + /// every caller that resolves the same object number, so mutating it outside the + /// one-time, pre-cache-return decrypt step would corrupt every other holder's view of + /// the same object. + /// + internal byte[] Bytes { get; set; } = []; + + /// Gets the name/keyword text, when is . + internal string Text { get; private init; } = string.Empty; + + /// Gets the array elements, when is . + internal List Items { get; private init; } = []; + + /// + /// Gets the dictionary entries (keyed by name text, without the leading slash), when + /// is or . + /// + internal Dictionary Entries { get; private init; } = new(); + + /// Gets the referenced object number, when is . + internal int RefNumber { get; private init; } + + /// Gets the referenced generation number, when is . + internal int RefGeneration { get; private init; } + + /// + /// Gets the buffer offset at which this stream's raw (still-encoded) data begins, when + /// is . + /// + internal int StreamDataStart { get; private init; } + + /// + /// Gets or sets the indirect object number this value was parsed as the top-level + /// value of, or -1 when this value was never parsed as a top-level indirect + /// object (for example a nested dictionary entry, or the single shared + /// instance). Deliberately settable, not private init + /// (breaking this type's otherwise-uniform immutability idiom): it is stamped onto an + /// already-constructed value by PdfDocument.Xref.cs's ParseIndirectObjectAt + /// after ParseValue returns, since the parser itself has no notion of which + /// indirect object it is being invoked for. Used, together with , + /// as the per-object key input to ISO 32000-1 Algorithm 1 when decrypting an encrypted + /// document's streams. + /// + internal int ObjectNumber { get; set; } = -1; + + /// + /// Gets or sets the indirect object's generation number, alongside + /// - see its own remarks for why this is settable rather + /// than private init. + /// + internal int Generation { get; set; } + + /// The single, shared instance. + internal static readonly PdfObject NullValue = new() { Kind = PdfKind.Null }; + + internal static PdfObject FromBoolean(bool value) => new() { Kind = PdfKind.Boolean, Boolean = value }; + + internal static PdfObject FromNumber(double value) => new() { Kind = PdfKind.Number, Number = value }; + + internal static PdfObject FromString(byte[] value) => new() { Kind = PdfKind.String, Bytes = value }; + + internal static PdfObject FromName(string value) => new() { Kind = PdfKind.Name, Text = value }; + + internal static PdfObject FromArray(List items) => new() { Kind = PdfKind.Array, Items = items }; + + internal static PdfObject FromDictionary(Dictionary entries) => + new() { Kind = PdfKind.Dictionary, Entries = entries }; + + internal static PdfObject FromStream(Dictionary entries, int dataStart) => + new() { Kind = PdfKind.Stream, Entries = entries, StreamDataStart = dataStart }; + + internal static PdfObject FromReference(int number, int generation) => + new() { Kind = PdfKind.Reference, RefNumber = number, RefGeneration = generation }; + + /// + /// Gets the dictionary entry with the given key (without the leading slash), or + /// if this is not a dictionary/stream or the key is absent. + /// + /// The entry key, without a leading slash. + /// The entry's value, or . + internal PdfObject? Get(string key) => Entries.TryGetValue(key, out var value) ? value : null; + } + + /// + /// The maximum depth allows nested + /// arrays/dictionaries to descend before failing closed with + /// . A PDF is untrusted input, so an unbounded recursive + /// descent parser risks exhausting the native call stack - which raises an uncatchable + /// - on a deeply/adversarially nested array or + /// dictionary. 64 comfortably exceeds any realistic legitimate document structure while + /// remaining far below a stack-exhausting depth. + /// + private const int MaxObjectNestingDepth = 64; + + /// + /// Parses a single PDF value (of any kind) starting at the tokenizer's current position. + /// + /// The tokenizer to read from. + /// The parsed value. + /// Thrown when the input is not a well-formed value. + internal static PdfObject ParseValue(PdfTokenizer tokenizer) => ParseValue(tokenizer, tokenizer.NextToken(), 0); + + private static PdfObject ParseValue(PdfTokenizer tokenizer, PdfToken token) => ParseValue(tokenizer, token, 0); + + /// + /// Thrown when the input is not a well-formed value, or when + /// exceeds . + /// + private static PdfObject ParseValue(PdfTokenizer tokenizer, PdfToken token, int depth) + { + if (depth > MaxObjectNestingDepth) + { + throw new InvalidDataException( + $"PDF object nesting depth exceeds the maximum supported depth of {MaxObjectNestingDepth}."); + } + + return token.Kind switch + { + PdfTokenKind.Number => ParseNumberOrReference(tokenizer, token), + PdfTokenKind.LiteralString or PdfTokenKind.HexString => PdfObject.FromString(token.Bytes ?? []), + PdfTokenKind.Name => PdfObject.FromName(token.Text ?? string.Empty), + PdfTokenKind.ArrayStart => ParseArray(tokenizer, depth + 1), + PdfTokenKind.DictStart => ParseDictionaryOrStream(tokenizer, depth + 1), + PdfTokenKind.Keyword => ParseKeywordValue(token), + _ => throw new InvalidDataException("Unexpected end of PDF content while parsing a value."), + }; + } + + private static PdfObject ParseKeywordValue(PdfToken token) => token.Text switch + { + "true" => PdfObject.FromBoolean(true), + "false" => PdfObject.FromBoolean(false), + "null" => PdfObject.NullValue, + _ => throw new InvalidDataException($"Unexpected keyword '{token.Text}' in PDF content."), + }; + + /// + /// Disambiguates a bare number from the start of an indirect reference (N G R) by + /// looking ahead up to two further tokens, rewinding the tokenizer if the lookahead does + /// not confirm a reference. + /// + private static PdfObject ParseNumberOrReference(PdfTokenizer tokenizer, PdfToken first) + { + var savedPosition = tokenizer.Position; + var second = tokenizer.NextToken(); + if (second.Kind == PdfTokenKind.Number) + { + var third = tokenizer.NextToken(); + if (third.Kind == PdfTokenKind.Keyword && third.Text == "R") + { + return PdfObject.FromReference((int)first.Number, (int)second.Number); + } + } + + tokenizer.Position = savedPosition; + return PdfObject.FromNumber(first.Number); + } + + private static PdfObject ParseArray(PdfTokenizer tokenizer, int depth) + { + var items = new List(); + while (true) + { + var token = tokenizer.NextToken(); + if (token.Kind == PdfTokenKind.ArrayEnd) + { + break; + } + + if (token.Kind == PdfTokenKind.EndOfFile) + { + throw new InvalidDataException("Unterminated array in PDF content."); + } + + items.Add(ParseValue(tokenizer, token, depth)); + } + + return PdfObject.FromArray(items); + } + + private static Dictionary ParseDictionaryEntries(PdfTokenizer tokenizer, int depth) + { + var entries = new Dictionary(); + while (true) + { + var keyToken = tokenizer.NextToken(); + if (keyToken.Kind == PdfTokenKind.DictEnd) + { + break; + } + + if (keyToken.Kind == PdfTokenKind.EndOfFile) + { + throw new InvalidDataException("Unterminated dictionary in PDF content."); + } + + if (keyToken.Kind != PdfTokenKind.Name) + { + throw new InvalidDataException("Dictionary keys must be names."); + } + + entries[keyToken.Text ?? string.Empty] = ParseValue(tokenizer, tokenizer.NextToken(), depth); + } + + return entries; + } + + /// + /// Parses a dictionary, upgrading it to a if immediately + /// followed by the stream keyword. + /// + /// + /// Locating a stream's data end requires its /Length entry, which may + /// itself be an indirect reference not yet resolvable while the cross-reference table is + /// still being built (see 's remarks for the one + /// documented exception to this) - so only the data's start offset is recorded + /// here; the length is resolved lazily, on first access to the stream's bytes. + /// + private static PdfObject ParseDictionaryOrStream(PdfTokenizer tokenizer, int depth) + { + var entries = ParseDictionaryEntries(tokenizer, depth); + + var savedPosition = tokenizer.Position; + var next = tokenizer.NextToken(); + if (next.Kind == PdfTokenKind.Keyword && next.Text == "stream") + { + SkipStreamLineEnding(tokenizer); + return PdfObject.FromStream(entries, tokenizer.Position); + } + + tokenizer.Position = savedPosition; + return PdfObject.FromDictionary(entries); + } + + /// + /// Skips the end-of-line sequence required immediately after the stream keyword + /// (CRLF or a bare LF; a bare CR is non-conformant but tolerated for + /// robustness), using raw byte access rather than token-level whitespace skipping, since + /// the following binary stream data must not be misinterpreted as further whitespace to + /// skip. + /// + private static void SkipStreamLineEnding(PdfTokenizer tokenizer) + { + if (tokenizer.PeekByte() == (byte)'\r') + { + tokenizer.AdvanceRaw(); + } + + if (tokenizer.PeekByte() == (byte)'\n') + { + tokenizer.AdvanceRaw(); + } + } +} diff --git a/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.PageTree.cs b/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.PageTree.cs new file mode 100644 index 00000000..720227fd --- /dev/null +++ b/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.PageTree.cs @@ -0,0 +1,281 @@ +using System.Numerics; + +namespace DemaConsulting.CanvasNet.Pdf; + +public sealed partial class PdfDocument +{ + /// + /// The PDF specification's own documented default page size (US Letter, in points), used + /// when no /MediaBox is declared anywhere in a page's ancestry. + /// + private const double DefaultMediaBoxWidth = 612; + + private const double DefaultMediaBoxHeight = 792; + + /// + /// Every page's leaf node and raw (pre-rotation-swap) + /// /MediaBox extent, in document order, parallel to - built + /// once, in lock-step with , by . + /// Kept separate from the public shape (rather than added to + /// it) since a page's raw node/box origin is an internal rendering detail, not part of + /// this package's public page-info contract. + /// + private readonly List<(PdfObject Node, double X0, double Y0, double BoxWidth, double BoxHeight, PdfObject? Resources)> _pageDetails = []; + + /// + /// The maximum page-tree nesting depth (/Pages nodes recursing through + /// /Kids) allows before failing closed, bounding + /// a chain of distinct, never-repeated nodes that the cycle-detecting visited + /// set alone cannot catch, and which could otherwise exhaust the native C# call stack. + /// + private const int MaxPageTreeNestingDepth = 32; + + /// + /// The current page-tree recursion depth, incremented/decremented around every + /// call. + /// + private int _pageTreeNestingDepth; + + /// + /// Walks the document catalog's page tree (/Pages and its recursively-nested + /// /Kids), producing an ordered, flattened list of every leaf page's resolved, + /// rotation-adjusted , and populating + /// in lock-step. + /// + /// The document's trailer dictionary. + /// The document's pages, in document order. + /// + /// Thrown when the catalog, page tree, or any page's /MediaBox//Rotate is + /// missing or malformed, or when the page tree contains a reference cycle. + /// + private IReadOnlyList BuildPageList(PdfObject trailer) + { + var rootReference = trailer.Get("Root") ?? throw new InvalidDataException("Trailer is missing /Root."); + var catalog = Resolve(rootReference); + if (catalog.Kind != PdfKind.Dictionary || GetNameValue(catalog, "Type") != "Catalog") + { + throw new InvalidDataException("Document catalog is missing or malformed."); + } + + var pagesReference = catalog.Get("Pages") ?? throw new InvalidDataException("Document catalog is missing /Pages."); + + var pages = new List(); + var visited = new HashSet(); + TraversePageTree(pagesReference, 0, 0, DefaultMediaBoxWidth, DefaultMediaBoxHeight, 0, null, visited, pages); + return pages; + } + + /// + /// Recursively visits a page-tree node (an intermediate /Type /Pages node or a leaf + /// /Type /Page), inheriting /MediaBox, /Rotate, and /Resources + /// from ancestors when not declared locally, and appending a + /// (plus its parallel entry) for every leaf page encountered, + /// in document order. + /// + private void TraversePageTree( + PdfObject nodeReference, + double inheritedX0, + double inheritedY0, + double inheritedWidth, + double inheritedHeight, + int inheritedRotation, + PdfObject? inheritedResources, + HashSet visited, + List pages) + { + if (nodeReference.Kind == PdfKind.Reference && !visited.Add(nodeReference.RefNumber)) + { + throw new InvalidDataException("Page tree contains a reference cycle."); + } + + if (_pageTreeNestingDepth >= MaxPageTreeNestingDepth) + { + throw new InvalidDataException( + $"Page tree nesting exceeds the maximum supported depth of {MaxPageTreeNestingDepth}."); + } + + _pageTreeNestingDepth++; + try + { + var node = Resolve(nodeReference); + if (node.Kind != PdfKind.Dictionary) + { + throw new InvalidDataException("Page tree node is not a dictionary."); + } + + var (x0, y0, width, height) = ResolveMediaBox(node, inheritedX0, inheritedY0, inheritedWidth, inheritedHeight); + var rotation = ResolveRotation(node, inheritedRotation); + var resources = node.Get("Resources") ?? inheritedResources; + + var type = GetNameValue(node, "Type"); + var kids = node.Get("Kids"); + if (type == "Pages" || (type is null && kids is not null)) + { + if (kids is not { Kind: PdfKind.Array }) + { + throw new InvalidDataException("/Pages node is missing a /Kids array."); + } + + foreach (var kid in kids.Items) + { + TraversePageTree(kid, x0, y0, width, height, rotation, resources, visited, pages); + } + } + else + { + var (displayWidth, displayHeight) = rotation is 90 or 270 ? (height, width) : (width, height); + pages.Add(new PdfPageInfo( + (int)Math.Round(displayWidth), + (int)Math.Round(displayHeight), + rotation)); + _pageDetails.Add((node, x0, y0, width, height, resources)); + } + } + finally + { + _pageTreeNestingDepth--; + } + } + + /// + /// Resolves a node's own /MediaBox if declared, otherwise the inherited value from + /// its nearest declaring ancestor (or the catalog-level US Letter default, if none exists + /// anywhere in the ancestry). + /// + /// + /// The raw box's lower-left corner (X0/Y0) and extent + /// (Width/Height), before any rotation-driven width/height swap. + /// + private (double X0, double Y0, double Width, double Height) ResolveMediaBox( + PdfObject node, + double inheritedX0, + double inheritedY0, + double inheritedWidth, + double inheritedHeight) + { + var mediaBox = node.Get("MediaBox"); + if (mediaBox is null) + { + return (inheritedX0, inheritedY0, inheritedWidth, inheritedHeight); + } + + var resolved = Resolve(mediaBox); + if (resolved.Kind != PdfKind.Array || resolved.Items.Count != 4) + { + throw new InvalidDataException("/MediaBox must be an array of four numbers."); + } + + var values = new double[4]; + for (var i = 0; i < 4; i++) + { + var entry = Resolve(resolved.Items[i]); + values[i] = entry.Kind == PdfKind.Number + ? entry.Number + : throw new InvalidDataException("/MediaBox entries must be numbers."); + } + + // A conforming /MediaBox lists its corners as [llx lly urx ury], but this class - like + // most lenient readers - does not require the first pair to already be the lower-left + // corner; the true origin is always the componentwise minimum. + var x0 = Math.Min(values[0], values[2]); + var y0 = Math.Min(values[1], values[3]); + return (x0, y0, Math.Abs(values[2] - values[0]), Math.Abs(values[3] - values[1])); + } + + /// + /// Resolves a node's own /Rotate if declared, otherwise the inherited value from + /// its nearest declaring ancestor (or 0, if none exists anywhere in the ancestry), + /// normalized modulo 360 into {0, 90, 180, 270}. + /// + /// + /// Thrown when a declared /Rotate value is not a multiple of 90 degrees. + /// + private int ResolveRotation(PdfObject node, int inherited) + { + var rotate = node.Get("Rotate"); + if (rotate is null) + { + return inherited; + } + + var resolved = Resolve(rotate); + if (resolved.Kind != PdfKind.Number) + { + throw new InvalidDataException("/Rotate must be a number."); + } + + var value = (int)resolved.Number; + var normalized = ((value % 360) + 360) % 360; + if (normalized % 90 != 0) + { + throw new InvalidDataException("/Rotate must be a multiple of 90 degrees."); + } + + return normalized; + } + + /// + /// Gets the given page's leaf node, raw (pre-rotation-swap) + /// /MediaBox extent, and inherited /Resources dictionary, previously + /// captured by during construction. + /// + /// The zero-based page index. Must already be range-validated by the caller. + /// The page's leaf node, raw /MediaBox extent, and inherited /Resources. + private (PdfObject Node, double X0, double Y0, double BoxWidth, double BoxHeight, PdfObject? Resources) ResolvePageDetails(int pageIndex) => + _pageDetails[pageIndex]; + + /// + /// Builds the initial content-stream current transformation matrix (CTM) for a page, + /// mapping PDF user-space points (relative to the raw, un-rotated /MediaBox) to + /// device pixel-space points (origin top-left, x right, y down, extent + /// x pixels) - see + /// .agent-logs/planning-pdf-document-phase2-9b2e6f41.md's CTM derivation for the + /// full corner-mapping proof this formula was checked against. + /// + /// The raw /MediaBox's lower-left corner X coordinate. + /// The raw /MediaBox's lower-left corner Y coordinate. + /// The raw (pre-rotation-swap) /MediaBox width. + /// The raw (pre-rotation-swap) /MediaBox height. + /// The page's normalized effective rotation: 0, 90, 180, or 270. + /// The caller-requested render width, in pixels. + /// The caller-requested render height, in pixels. + /// The base CTM, before any content-stream cm operator is composed onto it. + /// + /// Thrown when is not one of 0/90/180/270 + /// - unreachable in practice, since already guarantees this, + /// but retained as a defensive guard against a future internal caller passing an + /// unnormalized value. + /// + private static Matrix3x2 BuildBaseCtm( + double x0, + double y0, + double boxWidth, + double boxHeight, + int rotation, + int width, + int height) + { + // Step A: exact-multiples-of-90-degrees rotation+flip, built from literal {0, +-1} + // components rather than Matrix3x2.CreateRotation, to avoid floating-point trig noise + // (cos(90 degrees) ~ 6.12e-17) for an operation that must be exact. Each case also flips + // the PDF's y-up user space into device's y-down pixel space, and translates the rotated + // box into [0, displayWidth] x [0, displayHeight]. + var (rotationFlip, displayWidth, displayHeight) = rotation switch + { + 0 => (new Matrix3x2(1, 0, 0, -1, 0, (float)boxHeight), boxWidth, boxHeight), + 90 => (new Matrix3x2(0, 1, 1, 0, 0, 0), boxHeight, boxWidth), + 180 => (new Matrix3x2(-1, 0, 0, 1, (float)boxWidth, 0), boxWidth, boxHeight), + 270 => (new Matrix3x2(0, -1, -1, 0, (float)boxHeight, (float)boxWidth), boxHeight, boxWidth), + _ => throw new InvalidOperationException($"Unreachable: unnormalized rotation {rotation}."), + }; + + // Step B: independent X/Y stretch mapping the page-point display size to the caller's + // requested pixel size - Render's own documented contract uses width/height exactly as + // given, never clamped or aspect-corrected. + var scaleX = displayWidth == 0 ? 1f : (float)(width / displayWidth); + var scaleY = displayHeight == 0 ? 1f : (float)(height / displayHeight); + var scale = Matrix3x2.CreateScale(scaleX, scaleY); + + return Matrix3x2.CreateTranslation((float)-x0, (float)-y0) * rotationFlip * scale; + } +} diff --git a/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.PathOps.cs b/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.PathOps.cs new file mode 100644 index 00000000..3b2a5d77 --- /dev/null +++ b/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.PathOps.cs @@ -0,0 +1,421 @@ +// cspell:ignore fitz +using System.Numerics; +using DemaConsulting.CanvasNet.Canvas; +using DemaConsulting.CanvasNet.Drawing; +using DemaConsulting.CanvasNet.Geometry; + +namespace DemaConsulting.CanvasNet.Pdf; + +public sealed partial class PdfDocument +{ + /// + /// The minimum device-space (pixel) stroke line width this phase ever paints with, + /// implementing the PDF specification's "a line width of 0 shall be rendered as the + /// thinnest line that can be rendered at device resolution" rule (interpreted here as + /// exactly one device pixel). This also covers any positive but very thin user-space + /// width that would otherwise scale down to zero or a negative/invalid device width. + /// + private const float MinimumDeviceLineWidth = 1f; + + /// + /// The path currently under construction by the path-construction operators, for the + /// content stream currently being executed by . + /// Cleared after every path-painting operator; the surrounding graphics state is + /// unaffected by that clear. + /// + private PathBuilder _pathBuilder = null!; + + /// + /// The current point, in untransformed PDF user-space coordinates (the CTM is + /// applied only when a point is actually baked into ) - tracked + /// separately from whatever point was last baked into the path builder because the + /// v/y curve-shorthand operators and h's closepath both need the + /// current point in the same untransformed space the next operator's own operands arrive + /// in. + /// + private Vector2 _currentPoint; + + /// + /// The point, in untransformed PDF user-space coordinates, the current subpath most + /// recently started at (via m or re) - restored into + /// by h's closepath. + /// + private Vector2 _subpathStart; + + /// + /// once a subpath has been started (via m) and not yet + /// closed (via h, or implicitly by re's own trailing closepath); mirrors + /// 's own internal "can draw" state so a malformed + /// "draw before move" content stream is rejected with + /// (this class's own convention for malformed content-derived input) rather than letting + /// 's escape + /// unwrapped. + /// + private bool _hasOpenSubpath; + + /// Transforms a PDF user-space point into device pixel-space, via the current CTM. + private Vector2 Transform(Vector2 userSpacePoint) => Vector2.Transform(userSpacePoint, _gs.CurrentTransform); + + private Vector2 Transform(double x, double y) => Transform(new Vector2((float)x, (float)y)); + + /// Handles the m x y operator: starts a new subpath at (x, y). + /// + /// Thrown when does not contain exactly 2 numbers. + /// + private void OpMoveTo(IReadOnlyList operands) + { + var values = RequireNumbers(operands, "m", 2); + var point = new Vector2((float)values[0], (float)values[1]); + _pathBuilder.MoveTo(Transform(point)); + _currentPoint = point; + _subpathStart = point; + _hasOpenSubpath = true; + } + + /// Handles the l x y operator: draws a line to (x, y). + /// + /// Thrown when does not contain exactly 2 numbers, or when no + /// subpath is currently open. + /// + private void OpLineTo(IReadOnlyList operands) + { + var values = RequireNumbers(operands, "l", 2); + RequireOpenSubpath("l"); + var point = new Vector2((float)values[0], (float)values[1]); + _pathBuilder.LineTo(Transform(point)); + _currentPoint = point; + } + + /// + /// Handles the c x1 y1 x2 y2 x3 y3 operator: draws a cubic Bezier curve to + /// (x3, y3) with the given control points. + /// + /// + /// Thrown when does not contain exactly 6 numbers, or when no + /// subpath is currently open. + /// + private void OpCurveTo(IReadOnlyList operands) + { + var values = RequireNumbers(operands, "c", 6); + RequireOpenSubpath("c"); + var control1 = new Vector2((float)values[0], (float)values[1]); + var control2 = new Vector2((float)values[2], (float)values[3]); + var end = new Vector2((float)values[4], (float)values[5]); + _pathBuilder.CubicBezierTo(Transform(control1), Transform(control2), Transform(end)); + _currentPoint = end; + } + + /// + /// Handles the v x2 y2 x3 y3 operator: draws a cubic Bezier curve to + /// (x3, y3) whose first control point is the current point. + /// + /// + /// Thrown when does not contain exactly 4 numbers, or when no + /// subpath is currently open. + /// + private void OpCurveToV(IReadOnlyList operands) + { + var values = RequireNumbers(operands, "v", 4); + RequireOpenSubpath("v"); + var control2 = new Vector2((float)values[0], (float)values[1]); + var end = new Vector2((float)values[2], (float)values[3]); + _pathBuilder.CubicBezierTo(Transform(_currentPoint), Transform(control2), Transform(end)); + _currentPoint = end; + } + + /// + /// Handles the y x1 y1 x3 y3 operator: draws a cubic Bezier curve to + /// (x3, y3) whose second control point is the endpoint itself. + /// + /// + /// Thrown when does not contain exactly 4 numbers, or when no + /// subpath is currently open. + /// + private void OpCurveToY(IReadOnlyList operands) + { + var values = RequireNumbers(operands, "y", 4); + RequireOpenSubpath("y"); + var control1 = new Vector2((float)values[0], (float)values[1]); + var end = new Vector2((float)values[2], (float)values[3]); + _pathBuilder.CubicBezierTo(Transform(control1), Transform(end), Transform(end)); + _currentPoint = end; + } + + /// Handles the h operator: closes the current subpath. + /// + /// Per PDF 32000-1:2008 §8.5.2.1, "If the current subpath is already closed, h shall + /// do nothing" - unlike l/c/v/y (which really do require an + /// open subpath to draw from), h issued with no open subpath (for example + /// immediately after a preceding re, which already self-closes - real-world + /// producers such as MuPDF/fitz routinely emit this redundant re h pairing) is + /// therefore a deliberate no-op, not a malformed-content error. + /// + /// + /// Thrown when is not empty. + /// + private void OpClosePath(IReadOnlyList operands) + { + RequireOperandCount(operands, "h", 0); + if (!_hasOpenSubpath) + { + return; + } + + _pathBuilder.Close(); + _currentPoint = _subpathStart; + _hasOpenSubpath = false; + } + + /// + /// Handles the re x y w h operator: appends a closed rectangular subpath, built + /// directly from four transformed corner points plus a closepath, equivalent to + /// m/l/l/l/h but without re-dispatching through those + /// handlers' own operand-stack expectations. + /// + /// + /// Thrown when does not contain exactly 4 numbers. + /// + private void OpRectangle(IReadOnlyList operands) + { + var values = RequireNumbers(operands, "re", 4); + var x = values[0]; + var y = values[1]; + var w = values[2]; + var h = values[3]; + + _pathBuilder.MoveTo(Transform(x, y)); + _pathBuilder.LineTo(Transform(x + w, y)); + _pathBuilder.LineTo(Transform(x + w, y + h)); + _pathBuilder.LineTo(Transform(x, y + h)); + _pathBuilder.Close(); + + // Per spec, 're' always starts a fresh, independently-closed subpath: the current point + // ends at (x, y), and the subpath is already closed (no further draw op may follow + // without an intervening 'm'/'re'). + _currentPoint = new Vector2((float)x, (float)y); + _subpathStart = _currentPoint; + _hasOpenSubpath = false; + } + + /// + /// Paints (and always clears) the current path per one of the ten path-painting + /// operators' documented fill-rule/stroke/close-first combination. + /// + /// if the path is filled. + /// The fill rule to apply, when is . + /// if the path is stroked. + /// + /// to close the current (already device-space-baked) subpath + /// before painting (the s/b/b* operators' documented behavior). + /// + private void PaintCurrentPath(bool fill, FillRule fillRule, bool stroke, bool closeFirst) + { + if (closeFirst && _hasOpenSubpath) + { + _pathBuilder.Close(); + _currentPoint = _subpathStart; + _hasOpenSubpath = false; + } + + var path = _pathBuilder.Build(); + + if (fill) + { + if (_gs.FillColorSpace.Kind == PdfColorSpace.Family.Pattern && _gs.FillPattern is not null) + { + PaintPatternFill(path, fillRule, _gs.FillPattern, _gs.FillColor); + } + else + { + PathFiller.Fill(_surface, path, _gs.FillColor, fillRule); + } + } + + if (stroke) + { + PaintStroke(path); + } + + // Per spec, every path-painting operator (including 'n') always clears the current + // path. The surrounding graphics state is entirely unaffected by this clear. + _pathBuilder.Clear(); + _hasOpenSubpath = false; + } + + /// + /// Strokes with the current graphics state's stroke style + /// (// + /// //dash + /// pattern, all scaled to device space), painting the resulting outline via a resolved + /// /Pattern stroke color space when one is set, or the plain + /// otherwise. + /// + /// + /// Shared by (for the path-painting operators' + /// S/s/B/b/B*/b* stroke step) and by + /// (for Tr text-rendering modes 1/2, which stroke a + /// glyph's own device-space outline using this identical stroke style/paint logic). + /// + /// The already device-space-baked path (or glyph outline) to stroke. + private void PaintStroke(Geometry.Path path) + { + var style = new StrokeStyle( + DeviceLineWidth(), + _gs.LineCap, + _gs.LineJoin, + Math.Max(_gs.MiterLimit, 1f), + ScaledDashArray(), + ScaledDashPhase()); + var outline = PathStroker.Stroke(path, style); + if (_gs.StrokeColorSpace.Kind == PdfColorSpace.Family.Pattern && _gs.StrokePattern is not null) + { + PaintPatternFill(outline, FillRule.NonZero, _gs.StrokePattern, _gs.StrokeColor); + } + else + { + PathFiller.Fill(_surface, outline, _gs.StrokeColor, FillRule.NonZero); + } + } + + /// + /// Paints with a resolved /Pattern (the Pattern-color-space + /// branch of , shared identically by both the fill and + /// stroke paint paths - is the stroke outline, not the original + /// path geometry, when called from the stroke branch). + /// + /// The already device-space-baked path (or stroke outline) to paint. + /// The fill rule to apply when rasterizing . + /// The resolved pattern to paint with. + /// + /// The current graphics state's own / + /// - for an uncolored (/PaintType 2) + /// tiling pattern, this is the tint color set by / + /// from the scn/SCN operand's own leading + /// numeric components; unused for a colored (/PaintType 1) tiling pattern or a + /// shading pattern. + /// + private void PaintPatternFill(Geometry.Path path, FillRule fillRule, ResolvedPattern pattern, Rgba32 currentColor) + { + var patternToDevice = PatternToDeviceTransform(pattern); + + if (pattern.Kind == ResolvedPattern.PatternKind.Shading) + { + var gradient = BuildShadingGradient(pattern, patternToDevice); + PathFiller.Fill(_surface, path, gradient, fillRule); + return; + } + + var tint = pattern.PaintType == 2 ? currentColor : (Rgba32?)null; + var tilePaint = RenderTilingPatternCell(pattern, patternToDevice, tint); + try + { + PathFiller.Fill(_surface, path, tilePaint, fillRule); + } + finally + { + tilePaint.Surface.Dispose(); + } + } + + /// + /// Computes the current CTM's geometric-mean device-space scale factor, used to convert a + /// user-space stroke width/dash length into device pixel-space. + /// + /// + /// A non-uniformly scaled or skewed CTM (whether from the base CTM's own independent + /// X/Y requested-size scaling, or from a user cm) makes "the" stroke width + /// ill-defined in device space; this phase uses the well-established + /// sqrt(|det(CTM.Linear)|) approximation (the same one most simple PDF/SVG + /// renderers use), a documented Phase 2 simplification rather than an exactly anisotropic + /// stroke. + /// + private float DeviceScale() => MatrixScale(_gs.CurrentTransform); + + /// + /// Computes 's own geometric-mean scale factor (sqrt(|det(m)|)), + /// generically for an arbitrary matrix - not necessarily the current CTM. Extracted from + /// (which now simply forwards + /// to this method) so PdfDocument.Patterns.Tiling.cs can reuse the exact same + /// formula against a pattern-to-device transform, which is not generally equal to the + /// current CTM (see PatternToDeviceTransform's own remarks), without duplicating + /// the determinant/sqrt computation. + /// + private static float MatrixScale(Matrix3x2 m) + { + var determinant = (m.M11 * m.M22) - (m.M12 * m.M21); + return MathF.Sqrt(MathF.Abs(determinant)); + } + + /// + /// Computes the device-space (pixel) stroke width for the current graphics state's + /// user-space , applying + /// and the floor. + /// + private float DeviceLineWidth() => MathF.Max(_gs.LineWidth * DeviceScale(), MinimumDeviceLineWidth); + + /// + /// Computes the device-space (pixel) dash array for the current graphics state's + /// user-space , applying . + /// + /// + /// The scaled dash array, or when the current graphics state has + /// no dash pattern (a solid stroke). + /// + private IReadOnlyList? ScaledDashArray() + { + if (_gs.DashArray is null) + { + return null; + } + + var scale = DeviceScale(); + var scaled = new float[_gs.DashArray.Count]; + for (var i = 0; i < scaled.Length; i++) + { + scaled[i] = _gs.DashArray[i] * scale; + } + + return scaled; + } + + /// + /// Computes the device-space (pixel) dash phase for the current graphics state's + /// user-space , applying . + /// + private float ScaledDashPhase() => _gs.DashPhase * DeviceScale(); + + /// + /// Validates that a subpath is currently open, throwing when a path-construction operator + /// that requires one (l/c/v/y; not m/re, which + /// each start their own subpath, nor h, which is a no-op rather than an error when + /// no subpath is open) is issued before the first m/re, or after a + /// preceding h/re without an intervening m/re. + /// + /// Thrown when no subpath is currently open. + private void RequireOpenSubpath(string operatorName) + { + if (!_hasOpenSubpath) + { + throw new InvalidDataException( + $"Operator '{operatorName}' requires an already-open subpath (a preceding 'm' or 're')."); + } + } + + /// + /// Validates that contains exactly + /// entries, for operators (such as the path-painting operators) that require an exact + /// operand count but no specific kind. + /// + /// + /// Thrown when does not contain exactly + /// entries. + /// + private static void RequireOperandCount(IReadOnlyList operands, string operatorName, int count) + { + if (operands.Count != count) + { + throw new InvalidDataException( + $"Operator '{operatorName}' requires exactly {count} operand(s); got {operands.Count}."); + } + } +} diff --git a/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Patterns.Shading.cs b/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Patterns.Shading.cs new file mode 100644 index 00000000..fbec1fb3 --- /dev/null +++ b/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Patterns.Shading.cs @@ -0,0 +1,219 @@ +// cspell:ignore cspace +using System.Numerics; +using DemaConsulting.CanvasNet.Canvas; +using DemaConsulting.CanvasNet.Codecs; +using DemaConsulting.CanvasNet.Drawing; + +namespace DemaConsulting.CanvasNet.Pdf; + +public sealed partial class PdfDocument +{ + /// + /// The number of evenly spaced points across a shading's own function-input domain that + /// samples to build a 's own + /// list. + /// + /// + /// 32 balances visual smoothness against allocation/compute cost per fill - no caller of + /// this evaluator needs more precision than this, matching 's + /// own "no caller needs more precision than this" posture toward its own evaluation + /// granularity. + /// + private const int ShadingGradientSampleCount = 32; + + /// + /// Builds a of + /// from an already-resolved /PatternType 2 pattern dictionary. + /// + /// The already-resolved /PatternType 2 pattern dictionary. + /// + /// Thrown when /Shading, /ColorSpace, /Function, or /Coords is + /// missing or malformed, or propagated from / + /// for their own documented malformed-input + /// cases. + /// + /// + /// Thrown when /ShadingType is not 2 or 3 (feature + /// pdf-shading-type-{n}), when the resolved /ColorSpace's family is not + /// DeviceGray/DeviceRGB/DeviceCMYK (feature + /// pdf-shading-colorspace-{family}), or propagated from + /// / for + /// their own documented unsupported-feature cases. + /// + private ResolvedPattern BuildShadingPattern(PdfObject patternDict) + { + var matrix = ReadOptionalMatrix(patternDict); + var shadingEntry = patternDict.Get("Shading") + ?? throw new InvalidDataException("/PatternType 2 pattern is missing required /Shading."); + var shading = Resolve(shadingEntry); + + var shadingType = RequireIntEntry(shading, "ShadingType"); + if (shadingType is not (2 or 3)) + { + throw new UnsupportedImageFeatureException( + $"pdf-shading-type-{shadingType}", + $"/ShadingType {shadingType} is not supported; only 2 (axial) and 3 (radial) are supported."); + } + + var colorSpaceEntry = shading.Get("ColorSpace") + ?? throw new InvalidDataException("/Shading is missing required /ColorSpace."); + var colorSpace = ResolveColorSpaceValue(Resolve(colorSpaceEntry)); + if (colorSpace.Kind is not ( + PdfColorSpace.Family.DeviceGray or PdfColorSpace.Family.DeviceRGB or PdfColorSpace.Family.DeviceCMYK)) + { + throw new UnsupportedImageFeatureException( + $"pdf-shading-colorspace-{colorSpace.Kind}", + $"Shading /ColorSpace family '{colorSpace.Kind}' is not supported; only DeviceGray, " + + "DeviceRGB, and DeviceCMYK are supported."); + } + + var functionEntry = shading.Get("Function") + ?? throw new InvalidDataException("/Shading is missing required /Function."); + var (domain, evaluate) = ResolveFunctionOrFunctionArray(functionEntry); + + var coords = RequireNumberArray(shading, "Coords"); + var expectedCoordCount = shadingType == 2 ? 4 : 6; + if (coords.Length != expectedCoordCount) + { + throw new InvalidDataException( + $"/Coords must have exactly {expectedCoordCount} elements for /ShadingType {shadingType}."); + } + + return new ResolvedPattern + { + Kind = ResolvedPattern.PatternKind.Shading, + Matrix = matrix, + ShadingType = shadingType, + ShadingColorSpace = colorSpace, + Coords = coords, + Domain = domain, + Evaluate = evaluate, + }; + } + + /// + /// Builds a ( for + /// /ShadingType 2, for /ShadingType 3) from + /// 's resolved shading descriptor, sampling its resolved + /// function(s) at evenly spaced points across + /// and converting each sampled output through + /// via . + /// + /// The resolved shading pattern. Must have of . + /// The pattern-space-to-device-space transform (see ), used as the gradient's own . + /// The constructed gradient, ready to pass to PathFiller.Fill(Surface, Path, Gradient, FillRule, float). + /// + /// /Extend (PDF 32000-1 ยง8.7.4.5.3, defaulting to [false false] - "paint + /// nothing outside the shading's own defining geometry") has no existing + /// equivalent (only / + /// / exist, none of + /// which is "transparent outside"). This method always uses + /// regardless of the shading's own declared + /// /Extend value - a documented, narrower-than-spec approximation (a true + /// "unextended" clip would require a general clipping mechanism, which is out of this + /// phase's scope alongside the W/W* operators). + /// + private static Gradient BuildShadingGradient(ResolvedPattern pattern, Matrix3x2 patternToDevice) + { + var domain = pattern.Domain!; + var evaluate = pattern.Evaluate!; + var colorSpace = pattern.ShadingColorSpace!; + var coords = pattern.Coords!; + + var stops = new GradientStop[ShadingGradientSampleCount]; + for (var i = 0; i < ShadingGradientSampleCount; i++) + { + var t = (float)i / (ShadingGradientSampleCount - 1); + var domainValue = domain[0] + (t * (domain[1] - domain[0])); + var components = evaluate(domainValue); + var color = ColorFromComponents(colorSpace, components); + stops[i] = new GradientStop(t, color); + } + + if (pattern.ShadingType == 2) + { + return new LinearGradient( + new Vector2((float)coords[0], (float)coords[1]), + new Vector2((float)coords[2], (float)coords[3]), + stops, + GradientSpread.Pad, + patternToDevice); + } + + return new RadialGradient( + new Vector2((float)coords[0], (float)coords[1]), + (float)coords[2], + new Vector2((float)coords[3], (float)coords[4]), + (float)coords[5], + stops, + GradientSpread.Pad, + patternToDevice); + } + + /// + /// Resolves a /Function entry in either of its two shading-pattern-legal shapes: a + /// single multi-output function, or an array of 1-output functions (one function per + /// output component) - into a common (Domain, Evaluate) shape + /// samples uniformly, regardless of which shape was + /// declared. + /// + /// The (possibly indirect-reference) /Function entry to resolve. + /// + /// The input domain to sample across (2 elements), and a delegate evaluating the + /// function(s) at a single input value, returning one output value per color component. + /// + /// + /// Thrown when the array form is empty, when any array element is not a 1-output + /// function, or when a single function's own /Domain is present but does not have + /// exactly 2 elements. + /// + /// + /// Propagated from for an unsupported function type. + /// + private (double[] Domain, Func Evaluate) ResolveFunctionOrFunctionArray(PdfObject functionEntry) + { + var resolved = Resolve(functionEntry); + if (resolved.Kind == PdfKind.Array) + { + if (resolved.Items.Count == 0) + { + throw new InvalidDataException("/Function array must not be empty."); + } + + var subFunctions = new IPdfFunction[resolved.Items.Count]; + for (var i = 0; i < subFunctions.Length; i++) + { + var sub = ResolveFunctionGeneric(resolved.Items[i]); + if (sub.OutputCount != 1) + { + throw new InvalidDataException( + "Each /Function array element must be a 1-output function."); + } + + subFunctions[i] = sub; + } + + double[] EvaluateArray(double input) + { + var outputs = new double[subFunctions.Length]; + for (var i = 0; i < subFunctions.Length; i++) + { + outputs[i] = subFunctions[i].Evaluate(input)[0]; + } + + return outputs; + } + + return ([0.0, 1.0], EvaluateArray); + } + + var function = ResolveFunctionGeneric(functionEntry); + var domain = ResolveOptionalNumberArray(resolved, "Domain") ?? [0.0, 1.0]; + if (domain.Length != 2) + { + throw new InvalidDataException("/Domain must have exactly 2 elements for a 1-input function."); + } + + return (domain, function.Evaluate); + } +} diff --git a/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Patterns.Tiling.cs b/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Patterns.Tiling.cs new file mode 100644 index 00000000..9555e60e --- /dev/null +++ b/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Patterns.Tiling.cs @@ -0,0 +1,231 @@ +using System.Numerics; +using DemaConsulting.CanvasNet.Canvas; +using DemaConsulting.CanvasNet.Codecs; +using DemaConsulting.CanvasNet.Drawing; +using DemaConsulting.CanvasNet.Geometry; + +// cspell:ignore bbox unflipped +namespace DemaConsulting.CanvasNet.Pdf; + +public sealed partial class PdfDocument +{ + /// + /// The maximum device-pixel width/height allows a + /// tile surface to be allocated at, before failing closed. Bounds a pathological/adversarial + /// combination of a tiny /XStep//YStep and an extreme pattern-to-device + /// scale factor from attempting a many-gigabyte (or integer-overflowing) + /// allocation - analogous in spirit to 's own "fail + /// closed against pathological/adversarial input" rationale. + /// + private const int MaxTileSurfaceDimension = 2048; + + /// + /// Builds a of + /// from an already-resolved /PatternType 1 pattern stream. + /// + /// The already-resolved /PatternType 1 pattern stream. + /// + /// Thrown when is not a stream, when /BBox is + /// missing or does not have exactly 4 elements, when /XStep//YStep is + /// missing, non-finite, or zero, or when /PaintType is missing or is neither + /// 1 nor 2 - propagated from for a + /// malformed /Matrix. + /// + private ResolvedPattern BuildTilingPattern(PdfObject patternStream) + { + if (patternStream.Kind != PdfKind.Stream) + { + throw new InvalidDataException("/PatternType 1 pattern must resolve to a stream."); + } + + var bbox = RequireNumberArray(patternStream, "BBox"); + if (bbox.Length != 4) + { + throw new InvalidDataException("/BBox must have exactly 4 elements."); + } + + var xStep = RequireNumberEntry(patternStream, "XStep"); + if (!double.IsFinite(xStep) || xStep == 0) + { + throw new InvalidDataException("/XStep must be a finite, non-zero number."); + } + + var yStep = RequireNumberEntry(patternStream, "YStep"); + if (!double.IsFinite(yStep) || yStep == 0) + { + throw new InvalidDataException("/YStep must be a finite, non-zero number."); + } + + var matrix = ReadOptionalMatrix(patternStream); + + var paintType = RequireIntEntry(patternStream, "PaintType"); + if (paintType is not (1 or 2)) + { + throw new InvalidDataException($"/PaintType must be 1 or 2; got {paintType}."); + } + + var ownResources = patternStream.Get("Resources"); + var resolvedResources = ownResources is null ? _resources : Resolve(ownResources); + var contentBytes = GetStreamDecodedBytes(patternStream); + + return new ResolvedPattern + { + Kind = ResolvedPattern.PatternKind.Tiling, + Matrix = matrix, + BBox = bbox, + XStep = (float)xStep, + YStep = (float)yStep, + PaintType = paintType, + Resources = resolvedResources, + ContentBytes = contentBytes, + }; + } + + /// + /// Renders exactly one tile cell of onto a dedicated offscreen + /// , sized in device pixels from 's own + /// /XStep//YStep scaled by 's own + /// , and wraps the rendered surface in a . + /// + /// The resolved tiling pattern. Must have of . + /// The pattern-space-to-device-space transform (see ), used as the returned 's own . + /// + /// For a /PaintType 2 (uncolored) pattern, the tint color to recolor every + /// non-transparent rendered tile pixel's RGB to (preserving that pixel's own alpha); for + /// a /PaintType 1 (colored) pattern, (the cell's own + /// content-declared colors are used unmodified). + /// + /// + /// A wrapping the rendered tile surface. The caller is + /// responsible for disposing once painting completes - + /// see 's own remarks. + /// + /// + /// Renders by swapping /// + /// (saving the invoker's own values first), incrementing the + /// existing counter (reused, not a new counter - bounded + /// by the existing ), and calling + /// against the pattern's own decoded content bytes - + /// structurally identical to , restoring every swapped + /// field in a finally block. + /// + /// + /// Thrown when the current nesting depth already equals , + /// or propagated from nested execution of the pattern's own content stream. + /// + /// + /// Thrown when the computed tile surface dimensions exceed + /// (feature pdf-pattern-tile-too-large). + /// + private TilePaint RenderTilingPatternCell(ResolvedPattern pattern, Matrix3x2 patternToDevice, Rgba32? tint) + { + var scale = MatrixScale(patternToDevice); + var xStepAbs = Math.Abs(pattern.XStep); + var yStepAbs = Math.Abs(pattern.YStep); + + var tileWidth = Math.Max((int)Math.Ceiling(xStepAbs * scale), 1); + var tileHeight = Math.Max((int)Math.Ceiling(yStepAbs * scale), 1); + + if (tileWidth > MaxTileSurfaceDimension || tileHeight > MaxTileSurfaceDimension) + { + throw new UnsupportedImageFeatureException( + "pdf-pattern-tile-too-large", + $"Tiling pattern tile dimensions ({tileWidth}x{tileHeight} device pixels) exceed the " + + $"maximum supported dimension of {MaxTileSurfaceDimension}."); + } + + if (_formNestingDepth >= MaxFormNestingDepth) + { + throw new InvalidDataException( + $"Tiling pattern nesting exceeds the maximum supported depth of {MaxFormNestingDepth}."); + } + + var tileSurface = new Surface(tileWidth, tileHeight); + + // Maps pattern-space BBox-local coordinates (the same coordinates the pattern's own + // content stream draws in) onto the tile surface's own pixel space: translate the BBox's + // lower-left corner to the origin, then scale by the tile surface's pixels-per-pattern- + // space-unit ratio - independently in X/Y, matching the independent X/Y tile sizing + // above. Deliberately never flips Y (unlike the page's own baseCtm): TilePaintEvaluator's + // own sampling math (wrappedY -> ty) performs no flip either, so both sides of the + // pattern-space <-> tile-pixel-space mapping must agree on the same (unflipped) + // convention. + var cellTransform = + Matrix3x2.CreateTranslation(-(float)pattern.BBox![0], -(float)pattern.BBox[1]) * + Matrix3x2.CreateScale(tileWidth / xStepAbs, tileHeight / yStepAbs); + + var savedSurface = _surface; + var savedResources = _resources; + var savedGs = _gs; + var savedGsStack = _gsStack; + var savedPathBuilder = _pathBuilder; + var savedCurrentPoint = _currentPoint; + var savedSubpathStart = _subpathStart; + var savedHasOpenSubpath = _hasOpenSubpath; + _formNestingDepth++; + try + { + _surface = tileSurface; + _resources = pattern.Resources; + _gs = new GraphicsState { CurrentTransform = cellTransform }; + _gsStack = new Stack(); + + // The outer path-painting operator ('f'/'S'/etc.) that led here may already have + // called _pathBuilder.Build() without yet calling _pathBuilder.Clear() (that clear + // only happens after PaintCurrentPath's fill/stroke branches return) - so, unlike + // OpDrawFormXObject (which is only ever invoked via 'Do', never mid-path-paint), this + // nested ExecuteOperators call must not reuse the live outer path-builder state: a + // fresh one is substituted for the duration of the tile's own content stream, and the + // original is restored afterward, so the tile's own 're'/'m'/'l'/... operators cannot + // corrupt the host's own in-flight path. + _pathBuilder = new PathBuilder(); + _currentPoint = default; + _subpathStart = default; + _hasOpenSubpath = false; + ExecuteOperators(pattern.ContentBytes!); + } + finally + { + _formNestingDepth--; + _surface = savedSurface; + _resources = savedResources; + _gs = savedGs; + _gsStack = savedGsStack; + _pathBuilder = savedPathBuilder; + _currentPoint = savedCurrentPoint; + _subpathStart = savedSubpathStart; + _hasOpenSubpath = savedHasOpenSubpath; + } + + if (pattern.PaintType == 2 && tint is { } tintColor) + { + RecolorTileSurface(tileSurface, tintColor); + } + + return new TilePaint(tileSurface, patternToDevice, pattern.XStep, pattern.YStep); + } + + /// + /// Recolors every non-transparent pixel of to + /// 's own RGB, preserving each pixel's own alpha - the documented + /// /PaintType 2 (uncolored tiling pattern) semantic: the cell's own content + /// declares only coverage/shape (via its alpha), and the caller-supplied tint supplies + /// the actual color. + /// + private static void RecolorTileSurface(Surface surface, Rgba32 tint) + { + for (var y = 0; y < surface.Height; y++) + { + for (var x = 0; x < surface.Width; x++) + { + var pixel = surface[x, y]; + if (pixel.A == 0) + { + continue; + } + + surface[x, y] = new Rgba32(tint.R, tint.G, tint.B, pixel.A); + } + } + } +} diff --git a/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Patterns.cs b/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Patterns.cs new file mode 100644 index 00000000..7b8fe2db --- /dev/null +++ b/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Patterns.cs @@ -0,0 +1,137 @@ +// cspell:ignore scn +using System.Numerics; +using DemaConsulting.CanvasNet.Canvas; +using DemaConsulting.CanvasNet.Codecs; + +namespace DemaConsulting.CanvasNet.Pdf; + +public sealed partial class PdfDocument +{ + /// + /// A resolved /Pattern resource (PDF 32000-1 ยง8.7.3): either a /PatternType 2 + /// shading pattern (built by PdfDocument.Patterns.Shading.cs's BuildShadingPattern) + /// or a /PatternType 1 tiling pattern (built by PdfDocument.Patterns.Tiling.cs's + /// BuildTilingPattern), carrying exactly the fields its own needs + /// and leaving the other half /default. + /// + internal sealed class ResolvedPattern + { + /// Distinguishes which half of this pattern's fields are populated. + internal enum PatternKind + { + /// A /PatternType 2 shading pattern - see the Shading*/// fields. + Shading, + + /// A /PatternType 1 tiling pattern - see the ///// fields. + Tiling, + } + + /// Gets which half of this pattern's fields are populated. + internal required PatternKind Kind { get; init; } + + /// + /// Gets the pattern's own /Matrix (identity when absent), mapping pattern-space + /// coordinates into the page's default (initial) coordinate system - see + /// . + /// + internal required Matrix3x2 Matrix { get; init; } + + /// Gets the shading's /ShadingType (2 axial or 3 radial). Only meaningful when is . + internal int ShadingType { get; init; } + + /// Gets the shading's resolved /ColorSpace (restricted to DeviceGray/DeviceRGB/DeviceCMYK). Only meaningful when is . + internal PdfColorSpace? ShadingColorSpace { get; init; } + + /// Gets the shading's /Coords (4 elements for axial, 6 for radial). Only meaningful when is . + internal double[]? Coords { get; init; } + + /// Gets the function input domain to sample across (2 elements). Only meaningful when is . + internal double[]? Domain { get; init; } + + /// Gets the resolved function (or function-array) evaluation delegate. Only meaningful when is . + internal Func? Evaluate { get; init; } + + /// Gets the tiling pattern's /BBox (4 elements: [llx lly urx ury]). Only meaningful when is . + internal double[]? BBox { get; init; } + + /// Gets the tiling pattern's pattern-space horizontal tile pitch. Only meaningful when is . + internal float XStep { get; init; } + + /// Gets the tiling pattern's pattern-space vertical tile pitch. Only meaningful when is . + internal float YStep { get; init; } + + /// Gets the tiling pattern's /PaintType (1 colored or 2 uncolored). Only meaningful when is . + internal int PaintType { get; init; } + + /// Gets the tiling pattern's own resolved /Resources, or the invoking stream's own resources when absent (resource-scope fallback). Only meaningful when is . + internal PdfObject? Resources { get; init; } + + /// Gets the tiling pattern's own decoded content-stream bytes. Only meaningful when is . + internal byte[]? ContentBytes { get; init; } + } + + /// + /// Resolves a named /Pattern resource from the current page's + /// /Resources/Pattern dictionary and dispatches on its /PatternType to + /// BuildShadingPattern/BuildTilingPattern. + /// + /// The pattern resource name (the scn/SCN operand's trailing name operand, without the leading slash). + /// The resolved pattern. + /// + /// Thrown when the resolved value is not a dictionary/stream, when /PatternType is + /// missing/malformed, or propagated from the type-specific builder for malformed pattern + /// content. + /// + /// + /// Thrown when is not declared in the current page's + /// /Resources/Pattern dictionary (feature pdf-pattern-not-declared), or when + /// /PatternType is not 1 or 2 (feature pdf-pattern-type-{n}), + /// or propagated from the type-specific builder for a further-unsupported shape (for + /// example an unsupported /ShadingType). + /// + internal ResolvedPattern ResolvePattern(string name) + { + var patternDictionaryEntry = _resources?.Get("Pattern"); + var resolvedDictionary = patternDictionaryEntry is null ? null : Resolve(patternDictionaryEntry); + var entry = resolvedDictionary?.Get(name); + if (entry is null) + { + throw new UnsupportedImageFeatureException( + "pdf-pattern-not-declared", + $"Undefined pattern '/{name}' (not declared in the current page's /Resources/Pattern)."); + } + + var pattern = Resolve(entry); + var patternType = RequireIntEntry(pattern, "PatternType"); + return patternType switch + { + 1 => BuildTilingPattern(pattern), + 2 => BuildShadingPattern(pattern), + _ => throw new UnsupportedImageFeatureException( + $"pdf-pattern-type-{patternType}", + $"/PatternType {patternType} is not supported; only 1 (tiling) and 2 (shading) are supported."), + }; + } + + /// + /// Computes 's own pattern-space-to-device-space transform. + /// + /// + /// Per PDF 32000-1 ยง8.7.3.1, "the pattern matrix maps pattern space to the default + /// (initial) coordinate system of the page" - not whatever CTM happens to be + /// active when the pattern is actually painted with (which may have been further + /// transformed by any number of intervening cm operators since the page began). + /// This is why this method composes against rather than + /// 's current . + /// + /// Documented limitation: a pattern resolved from inside a nested + /// /Subtype /Form XObject's own /Resources/Pattern dictionary still + /// composes against the top-level page's own , not the + /// Form's own default coordinate system (which the PDF specification's own, more + /// precise reading would require) - this phase does not track a separate + /// per-Form-nesting-level "default CTM", matching this phase's documented + /// Form-XObject scope (no /BBox clipping, no /Group handling either). + /// + /// + private Matrix3x2 PatternToDeviceTransform(ResolvedPattern pattern) => pattern.Matrix * _pageInitialCtm; +} diff --git a/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Text.cs b/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Text.cs new file mode 100644 index 00000000..6001632a --- /dev/null +++ b/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Text.cs @@ -0,0 +1,488 @@ +// cspell:ignore Trm Trise +using System.Numerics; +using DemaConsulting.CanvasNet.Codecs; +using DemaConsulting.CanvasNet.Drawing; +using DemaConsulting.CanvasNet.Geometry; +using Path = DemaConsulting.CanvasNet.Geometry.Path; + +namespace DemaConsulting.CanvasNet.Pdf; + +public sealed partial class PdfDocument +{ + /// + /// The current text matrix, mapping text-space points (as produced by the text-rendering + /// matrix composition - see ) into PDF user space. + /// + /// + /// Per the PDF specification (Table 105), / + /// are not part of the graphics state at all: they are reset to the identity + /// matrix only by BT (see ), never saved/restored by + /// q/Q, and never touched by ET - a documented, deliberate distinction + /// from every field itself owns (, + /// , , + /// , , + /// , , and + /// , all of which the specification explicitly + /// does include in the graphics state, and therefore persist across BT/ + /// ET and are properly saved/restored by q/Q). This is, by a wide + /// margin, the single most common correctness bug in a from-scratch PDF text-rendering + /// implementation, so it is called out explicitly here rather than left implicit. + /// + private Matrix3x2 _textMatrix; + + /// + /// The current line matrix: the text matrix's value at the start of the current line, + /// updated by Td/TD/Tm/T* - see 's own + /// remarks for why this field, too, lives outside . + /// + private Matrix3x2 _lineMatrix; + + /// + /// Handles the BT operator: resets / + /// to the identity matrix. Every other text-state parameter (, + /// etc.) is part of the graphics state and is therefore left untouched - see + /// 's remarks. + /// + private void OpBeginText() + { + _textMatrix = Matrix3x2.Identity; + _lineMatrix = Matrix3x2.Identity; + } + + /// + /// Handles the ET operator. Per the PDF specification, ending a text object has no + /// effect on any persisted state (every text-state graphics-state parameter survives + /// unchanged into the next BT, and / + /// are simply left stale and unused until the next BT resets them) - this handler + /// exists only so ET is recognized and dispatched (with its own operand-count + /// validation) rather than silently ignored as an unrecognized keyword. + /// + private static void OpEndText() + { + // Intentionally a no-op - see remarks. + } + + /// Handles the Tc operator: sets the character spacing, in unscaled text-space units. + /// Thrown when does not contain exactly 1 number. + private void OpSetCharSpacing(IReadOnlyList operands) => + _gs.CharSpacing = RequireNumbers(operands, "Tc", 1)[0]; + + /// Handles the Tw operator: sets the word spacing, in unscaled text-space units. + /// Thrown when does not contain exactly 1 number. + private void OpSetWordSpacing(IReadOnlyList operands) => + _gs.WordSpacing = RequireNumbers(operands, "Tw", 1)[0]; + + /// Handles the Tz operator: sets the horizontal scaling, as a percentage (the PDF specification's default is 100). + /// Thrown when does not contain exactly 1 number. + private void OpSetHorizontalScaling(IReadOnlyList operands) => + _gs.HorizontalScaling = RequireNumbers(operands, "Tz", 1)[0]; + + /// Handles the TL operator: sets the leading, in unscaled text-space units. + /// Thrown when does not contain exactly 1 number. + private void OpSetLeading(IReadOnlyList operands) => + _gs.Leading = RequireNumbers(operands, "TL", 1)[0]; + + /// Handles the Ts operator: sets the text rise, in unscaled text-space units. + /// Thrown when does not contain exactly 1 number. + private void OpSetTextRise(IReadOnlyList operands) => + _gs.TextRise = RequireNumbers(operands, "Ts", 1)[0]; + + /// + /// Handles the /name size Tf operator: resolves and selects the named simple + /// TrueType font (via ) at the given size. + /// + /// + /// Thrown when is not exactly a name followed by a number, or + /// when the font name is undefined (propagated from ). + /// + /// + /// Propagated from / for an + /// unsupported font. + /// + private void OpSetFont(IReadOnlyList operands) + { + if (operands.Count != 2 || operands[0].Kind != PdfKind.Name || operands[1].Kind != PdfKind.Number) + { + throw new InvalidDataException( + $"Operator 'Tf' requires exactly 2 operands (a name, then a number); got {operands.Count}."); + } + + _gs.Font = ResolveFont(operands[0].Text); + _gs.FontSize = operands[1].Number; + } + + /// + /// Handles the Tr operator: sets the text-rendering mode. Modes 0 (fill, the + /// PDF specification's default), 1 (stroke), 2 (fill, then stroke), and + /// 3 (invisible - glyphs are laid out and advance the text position, but nothing is + /// painted) are all supported; the four clipping variants (4-7, which add a + /// glyph outline to the clipping path alongside modes 0-3's own fill/stroke/ + /// invisible behavior) fail closed rather than being silently treated as their non-clipping + /// counterpart. + /// + /// + /// Thrown when does not contain exactly 1 number, or when that + /// number is not one of the PDF specification's seven defined render modes (0-7). + /// + /// + /// Thrown when the mode is a defined but unsupported clipping mode (4-7). + /// + private void OpSetTextRenderMode(IReadOnlyList operands) + { + var mode = (int)RequireNumbers(operands, "Tr", 1)[0]; + switch (mode) + { + case 0: + case 1: + case 2: + case 3: + _gs.RenderMode = mode; + break; + + case 4 or 5 or 6 or 7: + throw new UnsupportedImageFeatureException( + $"pdf-text-render-mode-{mode}", + $"Text-rendering mode {mode} is not supported; only modes 0 (fill), 1 (stroke), " + + "2 (fill+stroke), and 3 (invisible) are supported. Clipping render modes are not " + + "implemented."); + + default: + throw new InvalidDataException( + $"Operator 'Tr' requires a render mode in [0, 7]; got {mode}."); + } + } + + /// Handles the Td tx ty operator: moves to the start of the next line, offset by (tx, ty) from the start of the current line. + /// Thrown when does not contain exactly 2 numbers. + private void OpTextMoveTo(IReadOnlyList operands) + { + var values = RequireNumbers(operands, "Td", 2); + MoveToNextLine(values[0], values[1]); + } + + /// Handles the TD tx ty operator: identical to Td, but also sets the leading to -ty. + /// Thrown when does not contain exactly 2 numbers. + private void OpTextMoveToSetLeading(IReadOnlyList operands) + { + var values = RequireNumbers(operands, "TD", 2); + _gs.Leading = -values[1]; + MoveToNextLine(values[0], values[1]); + } + + /// Shared core of Td/TD: offsets the line matrix by (tx, ty) and makes the text matrix match it. + private void MoveToNextLine(double tx, double ty) + { + var translation = Matrix3x2.CreateTranslation((float)tx, (float)ty); + _lineMatrix = translation * _lineMatrix; + _textMatrix = _lineMatrix; + } + + /// Handles the a b c d e f Tm operator: replaces (does not compose with) both the text matrix and the line matrix. + /// Thrown when does not contain exactly 6 numbers. + private void OpSetTextMatrix(IReadOnlyList operands) + { + var values = RequireNumbers(operands, "Tm", 6); + var matrix = new Matrix3x2( + (float)values[0], (float)values[1], (float)values[2], (float)values[3], (float)values[4], (float)values[5]); + _textMatrix = matrix; + _lineMatrix = matrix; + } + + /// Handles the T* operator: equivalent to 0 -TL Td, moving to the start of the next line using the current leading. + private void OpTextNextLine() => MoveToNextLine(0, -_gs.Leading); + + /// Handles the string Tj operator: shows the given string using the current text state. + /// + /// Thrown when is not exactly 1 string operand, or when no + /// font has been selected via Tf (propagated from ). + /// + private void OpShowText(IReadOnlyList operands) + { + RequireOperandCount(operands, "Tj", 1); + if (operands[0].Kind != PdfKind.String) + { + throw new InvalidDataException("Operator 'Tj' requires a string operand."); + } + + ShowText(operands[0].Bytes); + } + + /// Handles the string ' operator: equivalent to T* string Tj. + /// + /// Thrown when is not exactly 1 string operand, or when no + /// font has been selected via Tf (propagated from ). + /// + private void OpShowTextNextLine(IReadOnlyList operands) + { + RequireOperandCount(operands, "'", 1); + if (operands[0].Kind != PdfKind.String) + { + throw new InvalidDataException("Operator ''' requires a string operand."); + } + + OpTextNextLine(); + ShowText(operands[0].Bytes); + } + + /// Handles the aw ac string " operator: sets the word/character spacing, then is equivalent to T* string Tj. + /// + /// Thrown when is not exactly a number, a number, then a + /// string, or when no font has been selected via Tf (propagated from + /// ). + /// + private void OpShowTextNextLineWithSpacing(IReadOnlyList operands) + { + if (operands.Count != 3 || operands[0].Kind != PdfKind.Number || operands[1].Kind != PdfKind.Number || + operands[2].Kind != PdfKind.String) + { + throw new InvalidDataException( + $"Operator '\"' requires exactly 3 operands (a number, a number, then a string); got {operands.Count}."); + } + + _gs.WordSpacing = operands[0].Number; + _gs.CharSpacing = operands[1].Number; + OpTextNextLine(); + ShowText(operands[2].Bytes); + } + + /// + /// Handles the array TJ operator: shows each string element using the current text + /// state, and pre-advances the text position for each numeric element (a position + /// adjustment, in thousandths of text-space units, subtracted from the pen position - + /// applied without any accompanying glyph). + /// + /// + /// Thrown when is not exactly 1 array operand, when an array + /// entry is neither a string nor a number, or when no font has been selected via + /// Tf (propagated from ). + /// + private void OpShowTextArray(IReadOnlyList operands) + { + RequireOperandCount(operands, "TJ", 1); + if (operands[0].Kind != PdfKind.Array) + { + throw new InvalidDataException("Operator 'TJ' requires an array operand."); + } + + foreach (var item in operands[0].Items) + { + switch (item.Kind) + { + case PdfKind.String: + ShowText(item.Bytes); + break; + + case PdfKind.Number: + ApplyTextSpaceAdjustment(item.Number); + break; + + default: + throw new InvalidDataException("Operator 'TJ' array entries must be strings or numbers."); + } + } + } + + /// + /// Applies a TJ array's numeric position adjustment: a positive value moves the + /// next glyph closer to the previous one (subtracted from the pen advance), per the PDF + /// specification's own documented sign convention. + /// + private void ApplyTextSpaceAdjustment(double adjustmentInThousandths) + { + var horizontalScale = _gs.HorizontalScaling / 100.0; + var displacement = -(adjustmentInThousandths / 1000.0) * _gs.FontSize * horizontalScale; + _textMatrix = Matrix3x2.CreateTranslation((float)displacement, 0f) * _textMatrix; + } + + /// + /// Shared core of every text-showing operator (Tj/'/"/TJ): + /// decodes into character codes using the selected font's + /// (1 byte per code for a simple font, 2 + /// bytes - big-endian - per code for a composite /Identity-H font), showing each + /// decoded code in turn via . + /// + /// + /// Thrown when no font has been selected yet via Tf ( + /// is ), or when a composite font's string byte length is not a + /// multiple of 2. + /// + private void ShowText(byte[] bytes) + { + var font = _gs.Font ?? throw new InvalidDataException( + "A text-showing operator was used before a font was selected via 'Tf'."); + + if (font.CodeByteWidth == 1) + { + foreach (var code in bytes) + { + ShowGlyph(font, code); + } + + return; + } + + if (bytes.Length % 2 != 0) + { + throw new InvalidDataException( + "A Type0 (Identity-H) string's byte length must be a multiple of 2."); + } + + for (var i = 0; i < bytes.Length; i += 2) + { + var code = (bytes[i] << 8) | bytes[i + 1]; + ShowGlyph(font, code); + } + } + + /// + /// Lays out and (unless the current render mode is invisible) paints a single glyph for + /// character code , then advances by the + /// glyph's displacement (per PDF specification section 9.3.3, word spacing is only added + /// for the single-byte code 32 of a simple font - never for any code decoded from + /// a composite font, even one that numerically equals 32, since word spacing + /// "shall not apply to occurrences of the byte value 32 in multiple-byte codes"). Branches + /// early, before using 's resolved font, on whether + /// is a ResolvedType3Font (painted via + /// PaintType3Glyph's own content-stream re-entrance - see + /// PdfDocument.Fonts.Type3.cs; a Type 3 glyph procedure has no outline, so it has no + /// fill/stroke distinction and paints identically for render modes 0-2) or any + /// other concrete implementation (painted via the outline-based path below, mode-aware + /// since the addition of Tr modes 1/2: fill for modes 0/2, + /// then stroke - via the same helper shared with the + /// path-painting operators - for modes 1/2) - the shared trailing + /// displacement/advance logic runs unconditionally either way. + /// + private void ShowGlyph(IResolvedFont font, int code) + { + var (ttf, glyphIndex, w0) = font.Resolve(code); + + if (font is ResolvedType3Font type3Font) + { + // A Type 3 font has no outline-glyph font program at all - its glyphs are content- + // stream procedures, painted via PaintType3Glyph's own Form-XObject-style re-entrant + // execution, never via the font Resolve returns (which is null for a + // ResolvedType3Font - see IResolvedFont.Resolve's own remarks). Render-mode-3 + // (invisible) skips glyph-procedure execution entirely, exactly like the non-Type3 + // branch below skips GetGlyphOutline/fill entirely in that mode. + if (_gs.RenderMode != 3) + { + PaintType3Glyph(type3Font, code); + } + } + else if (_gs.RenderMode != 3) + { + // Every non-Type3 IResolvedFont implementation's Resolve returns a non-null font - + // see ResolvedSimpleFont/ResolvedCompositeFont - so this null-forgiving use is safe. + var resolvedTtf = ttf!; + var outline = resolvedTtf.GetGlyphOutline(glyphIndex); + if (outline.Subpaths.Count > 0) + { + var trm = ComputeTextRenderingMatrix(); + var glyphMatrix = Matrix3x2.CreateScale(1f / resolvedTtf.UnitsPerEm) * trm; + var builder = new PathBuilder(); + AppendTransformedGlyphOutline(builder, outline, glyphMatrix); + var path = builder.Build(); + + if (_gs.RenderMode is 0 or 2) + { + // Known pre-existing simplification, out of scope for this change: unlike + // the glyph-stroke step below (which does mirror a Pattern stroke color + // space, via the shared PaintStroke helper), this glyph fill always uses the + // flat fill color and does not mirror a Pattern fill color space the way the + // ordinary path-painting operators do. + PathFiller.Fill(_surface, path, _gs.FillColor, FillRule.NonZero); + } + + if (_gs.RenderMode is 1 or 2) + { + PaintStroke(path); + } + } + } + + var horizontalScale = _gs.HorizontalScaling / 100.0; + var applyWordSpacing = font.CodeByteWidth == 1 && code == 32; + var displacement = ((w0 * _gs.FontSize) + _gs.CharSpacing + (applyWordSpacing ? _gs.WordSpacing : 0)) * horizontalScale; + _textMatrix = Matrix3x2.CreateTranslation((float)displacement, 0f) * _textMatrix; + } + + /// + /// Computes the text-rendering matrix (Trm), mapping text-space points (the space + /// glyph outlines are placed in after their own 1/UnitsPerEm scale) to device + /// pixel-space points, per PDF specification section 9.4.4: + /// Trm = [Tfs*Th 0 0; 0 Tfs 0; 0 Trise 1] x Tm x CTM. + /// + /// + /// Composed via the same premultiply convention already + /// established for cm (matrix * outerTransform: the left operand is applied + /// first/innermost). Unlike SvgCodec.Text.cs's glyph-placement matrix (which flips + /// the font's Y-up design units into that codec's own Y-down pixel-space convention as + /// part of the same step), no Y-flip is applied here: PDF user space is already Y-up (per + /// specification section 8.3.2.3), matching the font's own Y-up glyph-space convention - + /// the single Y-up-to-Y-down flip already happens exactly once, in BuildBaseCtm's + /// own base CTM, which every glyph's Trm is composed through via + /// . This is a documented, deliberate, + /// pixel-level-tested difference from the SVG codec's own text-rendering matrix, not an + /// oversight. + /// + private Matrix3x2 ComputeTextRenderingMatrix() + { + var horizontalScale = _gs.HorizontalScaling / 100.0; + var parameterMatrix = new Matrix3x2( + (float)(_gs.FontSize * horizontalScale), 0f, + 0f, (float)_gs.FontSize, + 0f, (float)_gs.TextRise); + return parameterMatrix * _textMatrix * _gs.CurrentTransform; + } + + /// + /// Re-issues a glyph outline's subpaths/commands into , mapping + /// every point through - the same "re-issue through a fresh + /// " pattern SvgCodec.PathRender.cs's own + /// AppendTransformedPathInto uses (this package cannot call that method directly, + /// since it is private to the DemaConsulting.CanvasNet.Svg assembly). + /// + /// + /// Thrown for a command - + /// never produces one (TrueType glyph outlines are built only from lines and quadratic + /// Bezier curves), so encountering one indicates an internal defect, not a data-driven + /// condition. + /// + private static void AppendTransformedGlyphOutline(PathBuilder builder, Path outline, Matrix3x2 transform) + { + foreach (var subpath in outline.Subpaths) + { + builder.MoveTo(Vector2.Transform(subpath.Start, transform)); + foreach (var command in subpath.Commands) + { + switch (command.Type) + { + case PathCommandType.LineTo: + builder.LineTo(Vector2.Transform(command.EndPoint, transform)); + break; + + case PathCommandType.QuadraticBezierTo: + builder.QuadraticBezierTo( + Vector2.Transform(command.Control1, transform), + Vector2.Transform(command.EndPoint, transform)); + break; + + case PathCommandType.CubicBezierTo: + builder.CubicBezierTo( + Vector2.Transform(command.Control1, transform), + Vector2.Transform(command.Control2, transform), + Vector2.Transform(command.EndPoint, transform)); + break; + + case PathCommandType.Close: + builder.Close(); + break; + + default: + throw new NotSupportedException( + $"Unsupported path command type '{command.Type}' encountered while transforming a glyph outline."); + } + } + } + } +} diff --git a/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Tokenizer.cs b/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Tokenizer.cs new file mode 100644 index 00000000..4bce64e8 --- /dev/null +++ b/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Tokenizer.cs @@ -0,0 +1,456 @@ +using System.Globalization; +using System.Text; + +namespace DemaConsulting.CanvasNet.Pdf; + +public sealed partial class PdfDocument +{ + /// + /// The distinct kinds of low-level lexical token a can produce. + /// + internal enum PdfTokenKind + { + /// No more tokens remain in the input. + EndOfFile, + + /// An integer or real number, for example 12, -3.5, +.5. + Number, + + /// A parenthesized literal string, for example (Hello). + LiteralString, + + /// A hexadecimal string, for example <48656C6C6F>. + HexString, + + /// A name, for example /Type (the leading slash is not included in ). + Name, + + /// The array-opening delimiter [. + ArrayStart, + + /// The array-closing delimiter ]. + ArrayEnd, + + /// The dictionary-opening delimiter <<. + DictStart, + + /// The dictionary-closing delimiter >>. + DictEnd, + + /// + /// A bare keyword token: obj, endobj, stream, endstream, + /// xref, trailer, startxref, R, true, false, + /// or null. + /// + Keyword, + } + + /// + /// A single lexical token produced by . + /// + /// The token's kind. + /// The decoded numeric value, when is . + /// The decoded raw bytes, when is or . + /// The decoded text, when is or . + internal readonly struct PdfToken(PdfTokenKind kind, double number = 0, byte[]? bytes = null, string? text = null) + { + /// Gets the token's kind. + internal PdfTokenKind Kind { get; } = kind; + + /// Gets the decoded numeric value for a token. + internal double Number { get; } = number; + + /// Gets the decoded raw bytes for a string token. + internal byte[]? Bytes { get; } = bytes; + + /// Gets the decoded text for a name or keyword token. + internal string? Text { get; } = text; + } + + /// + /// A low-level, stateless-per-call lexer over a complete in-memory PDF document buffer. + /// + /// + /// Character classification (whitespace, delimiter, regular) follows the PDF + /// specification's own character classes. Whitespace bytes are: NUL, HT, LF, FF, CR, and + /// space. Delimiter bytes are: ( ) < > [ ] { } / %. Every other byte is + /// "regular" and may appear in a name, keyword, or number. + /// + internal sealed class PdfTokenizer + { + private readonly byte[] _buffer; + + /// + /// Initializes a new instance of the class over the given + /// buffer, starting at position zero. + /// + /// The complete document (or object-stream) bytes to tokenize. + internal PdfTokenizer(byte[] buffer) => _buffer = buffer; + + /// + /// Gets or sets the current zero-based byte offset within the buffer. Callers may + /// save and restore this value to implement bounded lookahead (for example, + /// distinguishing N G R indirect references from a bare number). + /// + internal int Position { get; set; } + + private int Length => _buffer.Length; + + /// + /// Gets the total number of bytes in the underlying buffer, allowing callers to bound + /// a declared repeat count (for example a classic cross-reference subsection's entry + /// count) against the actual remaining input before looping, rather than discovering + /// the input is exhausted one token at a time. + /// + internal int BufferLength => _buffer.Length; + + /// + /// Reads and returns the next token, first skipping any leading whitespace and + /// % comments. + /// + /// The next token, or a token of kind . + /// + /// Thrown when the input contains a character that cannot begin any valid token. + /// + internal PdfToken NextToken() + { + SkipWhitespaceAndComments(); + + if (Position >= Length) + { + return new PdfToken(PdfTokenKind.EndOfFile); + } + + var b = _buffer[Position]; + switch (b) + { + case (byte)'[': + Position++; + return new PdfToken(PdfTokenKind.ArrayStart); + + case (byte)']': + Position++; + return new PdfToken(PdfTokenKind.ArrayEnd); + + case (byte)'/': + return ReadName(); + + case (byte)'(': + return ReadLiteralString(); + + case (byte)'<': + if (Position + 1 < Length && _buffer[Position + 1] == (byte)'<') + { + Position += 2; + return new PdfToken(PdfTokenKind.DictStart); + } + + return ReadHexString(); + + case (byte)'>': + if (Position + 1 < Length && _buffer[Position + 1] == (byte)'>') + { + Position += 2; + return new PdfToken(PdfTokenKind.DictEnd); + } + + throw new InvalidDataException("Unexpected '>' in PDF content."); + + case (byte)'+': + case (byte)'-': + case (byte)'.': + return ReadNumber(); + } + + if (b is >= (byte)'0' and <= (byte)'9') + { + return ReadNumber(); + } + + if (IsRegular(b)) + { + return ReadKeyword(); + } + + throw new InvalidDataException($"Unexpected character 0x{b:X2} in PDF content."); + } + + /// + /// Gets the raw byte at the current position without consuming it, or + /// at end of buffer. + /// + internal byte? PeekByte() => Position < Length ? _buffer[Position] : null; + + /// Advances by one raw byte, bypassing tokenization. + internal void AdvanceRaw() => Position++; + + private void SkipWhitespaceAndComments() + { + while (Position < Length) + { + var b = _buffer[Position]; + if (IsWhitespace(b)) + { + Position++; + continue; + } + + if (b == (byte)'%') + { + while (Position < Length && _buffer[Position] != (byte)'\n' && _buffer[Position] != (byte)'\r') + { + Position++; + } + + continue; + } + + break; + } + } + + private PdfToken ReadName() + { + Position++; // skip the leading '/' + var bytes = new List(); + while (Position < Length && IsRegular(_buffer[Position])) + { + var b = _buffer[Position]; + if (b == (byte)'#' && Position + 2 < Length && IsHexDigit(_buffer[Position + 1]) && IsHexDigit(_buffer[Position + 2])) + { + bytes.Add((byte)((HexValue(_buffer[Position + 1]) << 4) | HexValue(_buffer[Position + 2]))); + Position += 3; + } + else + { + bytes.Add(b); + Position++; + } + } + + return new PdfToken(PdfTokenKind.Name, text: Encoding.Latin1.GetString(bytes.ToArray())); + } + + private PdfToken ReadLiteralString() + { + Position++; // skip the opening '(' + var depth = 1; + var bytes = new List(); + + while (Position < Length) + { + var b = _buffer[Position]; + + if (b == (byte)'\\') + { + Position++; + if (Position >= Length) + { + break; + } + + var escaped = _buffer[Position]; + switch (escaped) + { + case (byte)'n': + bytes.Add((byte)'\n'); + Position++; + break; + case (byte)'r': + bytes.Add((byte)'\r'); + Position++; + break; + case (byte)'t': + bytes.Add((byte)'\t'); + Position++; + break; + case (byte)'b': + bytes.Add(0x08); + Position++; + break; + case (byte)'f': + bytes.Add(0x0C); + Position++; + break; + case (byte)'(': + bytes.Add((byte)'('); + Position++; + break; + case (byte)')': + bytes.Add((byte)')'); + Position++; + break; + case (byte)'\\': + bytes.Add((byte)'\\'); + Position++; + break; + case (byte)'\r': + // Backslash-newline is a line continuation: no byte is emitted. + Position++; + if (Position < Length && _buffer[Position] == (byte)'\n') + { + Position++; + } + + break; + case (byte)'\n': + Position++; + break; + default: + if (escaped is >= (byte)'0' and <= (byte)'7') + { + var value = 0; + var digits = 0; + while (digits < 3 && Position < Length && _buffer[Position] is >= (byte)'0' and <= (byte)'7') + { + value = (value * 8) + (_buffer[Position] - (byte)'0'); + Position++; + digits++; + } + + bytes.Add((byte)(value & 0xFF)); + } + else + { + // An unrecognized escape simply yields the escaped character + // itself, per the PDF specification. + bytes.Add(escaped); + Position++; + } + + break; + } + + continue; + } + + if (b == (byte)'(') + { + depth++; + bytes.Add(b); + Position++; + continue; + } + + if (b == (byte)')') + { + depth--; + Position++; + if (depth == 0) + { + break; + } + + bytes.Add(b); + continue; + } + + bytes.Add(b); + Position++; + } + + // Reaching EOF while depth is still nonzero means the closing ')' for this literal + // string (or one of its nested parenthesized groups) was never found - failing closed + // here, rather than returning a token built from a truncated scan, matches the + // "Unterminated hexadecimal string." convention used by ReadHexString for the same + // kind of malformed, EOF-truncated token. + if (depth != 0) + { + throw new InvalidDataException("Unterminated literal string."); + } + + return new PdfToken(PdfTokenKind.LiteralString, bytes: bytes.ToArray()); + } + + private PdfToken ReadHexString() + { + Position++; // skip the opening '<' + var digits = new List(); + + while (Position < Length && _buffer[Position] != (byte)'>') + { + var b = _buffer[Position]; + if (IsHexDigit(b)) + { + digits.Add(b); + } + else if (!IsWhitespace(b)) + { + throw new InvalidDataException("Invalid character in hexadecimal string."); + } + + Position++; + } + + if (Position >= Length) + { + throw new InvalidDataException("Unterminated hexadecimal string."); + } + + Position++; // skip the closing '>' + + if (digits.Count % 2 != 0) + { + digits.Add((byte)'0'); + } + + var bytes = new byte[digits.Count / 2]; + for (var i = 0; i < bytes.Length; i++) + { + bytes[i] = (byte)((HexValue(digits[2 * i]) << 4) | HexValue(digits[(2 * i) + 1])); + } + + return new PdfToken(PdfTokenKind.HexString, bytes: bytes); + } + + private PdfToken ReadNumber() + { + var start = Position; + if (_buffer[Position] is (byte)'+' or (byte)'-') + { + Position++; + } + + while (Position < Length && (_buffer[Position] is (byte)'.' or (>= (byte)'0' and <= (byte)'9'))) + { + Position++; + } + + var text = Encoding.ASCII.GetString(_buffer, start, Position - start); + if (!double.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture, out var value)) + { + throw new InvalidDataException($"Malformed numeric token '{text}' in PDF content."); + } + + return new PdfToken(PdfTokenKind.Number, number: value); + } + + private PdfToken ReadKeyword() + { + var start = Position; + while (Position < Length && IsRegular(_buffer[Position])) + { + Position++; + } + + return new PdfToken(PdfTokenKind.Keyword, text: Encoding.ASCII.GetString(_buffer, start, Position - start)); + } + + private static bool IsWhitespace(byte b) => b is 0x00 or 0x09 or 0x0A or 0x0C or 0x0D or 0x20; + + private static bool IsDelimiter(byte b) => + b is (byte)'(' or (byte)')' or (byte)'<' or (byte)'>' or (byte)'[' or (byte)']' or (byte)'{' or (byte)'}' or (byte)'/' or (byte)'%'; + + private static bool IsRegular(byte b) => !IsWhitespace(b) && !IsDelimiter(b); + + private static bool IsHexDigit(byte b) => b is (>= (byte)'0' and <= (byte)'9') or (>= (byte)'a' and <= (byte)'f') or (>= (byte)'A' and <= (byte)'F'); + + private static int HexValue(byte b) => b switch + { + >= (byte)'0' and <= (byte)'9' => b - (byte)'0', + >= (byte)'a' and <= (byte)'f' => b - (byte)'a' + 10, + >= (byte)'A' and <= (byte)'F' => b - (byte)'A' + 10, + _ => throw new InvalidDataException("Invalid hexadecimal digit."), + }; + } +} diff --git a/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Xref.cs b/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Xref.cs new file mode 100644 index 00000000..6add9543 --- /dev/null +++ b/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Xref.cs @@ -0,0 +1,822 @@ +// cspell:ignore uncatchable +using System.IO.Compression; + +namespace DemaConsulting.CanvasNet.Pdf; + +public sealed partial class PdfDocument +{ + /// + /// The maximum total number of decoded output bytes allows + /// before failing closed, bounding a decompression-bomb-style crafted zlib stream (a tiny + /// compressed input that expands to an enormous output) from exhausting memory. 64 MiB is + /// generous for any legitimate PDF image/content stream, matching LzwMaxOutputBytes + /// in PdfDocument.Filters.Lzw.cs. + /// + private const int FlateMaxOutputBytes = 64 * 1024 * 1024; + + /// The kind of location a describes. + private enum XrefEntryType + { + /// The object number is on the free list (not currently in use). + Free, + + /// The object's value begins at a direct byte offset within . + Direct, + + /// The object's value is entry number within object-stream . + Compressed, + } + + /// A single resolved cross-reference table entry. + private readonly struct XrefEntry + { + private XrefEntry(XrefEntryType type, long offset, int streamNumber, int indexInStream) + { + Type = type; + Offset = offset; + StreamNumber = streamNumber; + IndexInStream = indexInStream; + } + + internal XrefEntryType Type { get; } + + internal long Offset { get; } + + internal int StreamNumber { get; } + + internal int IndexInStream { get; } + + internal static readonly XrefEntry Free = new(XrefEntryType.Free, 0, 0, 0); + + internal static XrefEntry CreateDirect(long offset) => new(XrefEntryType.Direct, offset, 0, 0); + + internal static XrefEntry CreateCompressed(int streamNumber, int indexInStream) => + new(XrefEntryType.Compressed, 0, streamNumber, indexInStream); + } + + /// + /// Parses the full cross-reference chain starting from the trailing startxref + /// offset, following classic /Prev and hybrid /XRefStm links, and populates + /// with the merged result (an entry from an earlier-processed, more + /// recent section always wins over one discovered later in the chain). + /// + /// The main (most recent) trailer dictionary. + /// + /// Thrown when no startxref keyword, cross-reference section, or trailer can be + /// located, or when the chain contains a cycle. + /// + private PdfObject ParseCrossReferenceChain() + { + var xref = new Dictionary(); + PdfObject? trailer = null; + var visited = new HashSet(); + int? offset = FindStartXrefOffset(); + + while (offset is not null) + { + if (!visited.Add(offset.Value)) + { + throw new InvalidDataException("Cross-reference /Prev chain contains a cycle."); + } + + var section = ParseCrossReferenceSection(offset.Value); + + // Merge this revision's own classic entries with its (optional) hybrid /XRefStm + // entries first, within just this revision: a hybrid entry fills in an object that + // this revision's classic table can only mark as free (old readers cannot see + // compressed objects), but a classic entry for an object number the hybrid stream + // does not describe is left untouched. + var revision = new Dictionary(section.Xref); + if (section.XRefStm is { } hybridOffset && visited.Add(hybridOffset)) + { + var hybrid = ParseCrossReferenceSection(hybridOffset); + foreach (var (number, entry) in hybrid.Xref) + { + if (!revision.TryGetValue(number, out var existing) || existing.Type == XrefEntryType.Free) + { + revision[number] = entry; + } + } + } + + // An entry from an earlier-processed (more recent) revision always wins over one + // discovered later via /Prev, so only fill in object numbers not already known. + foreach (var (number, entry) in revision) + { + xref.TryAdd(number, entry); + } + + trailer ??= section.Trailer; + + offset = section.Prev; + } + + if (trailer is null) + { + throw new InvalidDataException("No trailer dictionary was found."); + } + + _xref = xref; + return trailer; + } + + private readonly record struct XrefSection(Dictionary Xref, PdfObject Trailer, int? Prev, int? XRefStm); + + /// + /// Validates that a byte offset parsed from untrusted PDF input (a startxref + /// value, a trailer's /Prev//XRefStm entry, a classic cross-reference + /// entry's direct offset, or an object stream's /First + per-entry relative + /// offset) is actually within before it is assigned to a + /// 's . Position is a + /// plain, unchecked property, so a negative value would index the underlying buffer + /// negatively on the very next call, leaking an + /// uncatchable-by-callers instead of the + /// this library otherwise consistently uses to signal + /// malformed input. + /// + /// + /// Thrown when is negative or beyond the end of the buffer. + /// + private static void ValidateBufferOffset(long offset, int bufferLength, string description) + { + if (offset < 0 || offset > bufferLength) + { + throw new InvalidDataException($"{description} offset {offset} is outside the bounds of the document."); + } + } + + private XrefSection ParseCrossReferenceSection(int offset) + { + ValidateBufferOffset(offset, _buffer.Length, "Cross-reference section"); + var tokenizer = new PdfTokenizer(_buffer) { Position = offset }; + var savedPosition = tokenizer.Position; + var first = tokenizer.NextToken(); + + if (first.Kind == PdfTokenKind.Keyword && first.Text == "xref") + { + return ParseClassicXrefSection(tokenizer); + } + + tokenizer.Position = savedPosition; + return ParseXrefStreamSection(tokenizer); + } + + /// Parses a classic xref table followed by a trailer dictionary. + private static XrefSection ParseClassicXrefSection(PdfTokenizer tokenizer) + { + var xref = new Dictionary(); + + while (true) + { + var token = tokenizer.NextToken(); + if (token.Kind == PdfTokenKind.Keyword && token.Text == "trailer") + { + break; + } + + if (token.Kind != PdfTokenKind.Number) + { + throw new InvalidDataException("Malformed classic cross-reference table."); + } + + var startNumber = (int)token.Number; + var countToken = tokenizer.NextToken(); + if (countToken.Kind != PdfTokenKind.Number) + { + throw new InvalidDataException("Malformed classic cross-reference subsection header."); + } + + var count = (int)countToken.Number; + + // Each classic cross-reference entry needs at least a few bytes (minimally + // "0 0 n\n"), so a declared count that exceeds the number of bytes remaining in the + // buffer can never be satisfied by real entries. Without this check, a crafted + // subsection header such as "0 2147483647" would make the loop below call + // NextToken() an unbounded number of times - bounded only by how much of the rest of + // the file happens to parse as plausible "offset generation keyword" triplets - + // before finally failing, a CPU-time denial of service proportional to the + // attacker's chosen count rather than the file's real content. + if (count < 0 || count > tokenizer.BufferLength - tokenizer.Position) + { + throw new InvalidDataException("Classic cross-reference subsection declares an invalid entry count."); + } + + for (var i = 0; i < count; i++) + { + if (tokenizer.Position >= tokenizer.BufferLength) + { + throw new InvalidDataException("Classic cross-reference table ended before all declared entries were read."); + } + + var offsetToken = tokenizer.NextToken(); + var generationToken = tokenizer.NextToken(); + var flagToken = tokenizer.NextToken(); + if (offsetToken.Kind != PdfTokenKind.Number || generationToken.Kind != PdfTokenKind.Number || + flagToken.Kind != PdfTokenKind.Keyword) + { + throw new InvalidDataException("Malformed classic cross-reference entry."); + } + + var objectNumber = startNumber + i; + xref.TryAdd( + objectNumber, + flagToken.Text == "n" ? XrefEntry.CreateDirect((long)offsetToken.Number) : XrefEntry.Free); + } + } + + var trailer = ParseValue(tokenizer); + if (trailer.Kind != PdfKind.Dictionary) + { + throw new InvalidDataException("Malformed trailer dictionary."); + } + + return new XrefSection(xref, trailer, GetOptionalInt(trailer, "Prev"), GetOptionalInt(trailer, "XRefStm")); + } + + /// + /// Parses a cross-reference stream (an indirect object of /Type /XRef) at the + /// tokenizer's current position, decoding its binary entry table per its /W field + /// widths and /Index subsection ranges. + /// + private XrefSection ParseXrefStreamSection(PdfTokenizer tokenizer) + { + var numberToken = tokenizer.NextToken(); + var generationToken = tokenizer.NextToken(); + var objToken = tokenizer.NextToken(); + if (numberToken.Kind != PdfTokenKind.Number || generationToken.Kind != PdfTokenKind.Number || + objToken.Kind != PdfTokenKind.Keyword || objToken.Text != "obj") + { + throw new InvalidDataException("Expected an indirect object header for a cross-reference stream."); + } + + var streamObject = ParseValue(tokenizer); + if (streamObject.Kind != PdfKind.Stream || GetNameValue(streamObject, "Type") != "XRef") + { + throw new InvalidDataException("Expected a /Type /XRef cross-reference stream."); + } + + // The cross-reference stream's own /Length must be a direct integer (Phase 1 + // simplification): resolving an indirect /Length here would require the very + // cross-reference table this stream exists to build. + if (streamObject.Get("Length") is not { Kind: PdfKind.Number }) + { + throw new InvalidDataException("Cross-reference stream /Length must be a direct integer."); + } + + var decoded = GetStreamDecodedBytes(streamObject); + + var widthsObject = streamObject.Get("W") ?? throw new InvalidDataException("Cross-reference stream is missing /W."); + if (widthsObject.Kind != PdfKind.Array || widthsObject.Items.Count != 3) + { + throw new InvalidDataException("Cross-reference stream /W must have exactly three entries."); + } + + var widths = widthsObject.Items.Select(item => (int)item.Number).ToArray(); + if (widths.Any(w => w is < 0 or > 8)) + { + throw new InvalidDataException("Cross-reference stream /W entries must be between 0 and 8."); + } + + // A zero total entry width would never advance the read position, so a crafted large + // /Size or /Index range could spin the decode loop below indefinitely without making + // progress. Reject the table up front instead of looping. + if (widths[0] + widths[1] + widths[2] == 0) + { + throw new InvalidDataException("Cross-reference stream /W entries must not all be zero."); + } + + var size = streamObject.Get("Size") is { Kind: PdfKind.Number } sizeObject + ? (int)sizeObject.Number + : throw new InvalidDataException("Cross-reference stream is missing /Size."); + + var ranges = new List<(int Start, int Count)>(); + var indexObject = streamObject.Get("Index"); + if (indexObject is { Kind: PdfKind.Array }) + { + for (var i = 0; i + 1 < indexObject.Items.Count; i += 2) + { + ranges.Add(((int)indexObject.Items[i].Number, (int)indexObject.Items[i + 1].Number)); + } + } + else + { + ranges.Add((0, size)); + } + + var xref = new Dictionary(); + var entryWidth = widths[0] + widths[1] + widths[2]; + var position = 0; + foreach (var (start, count) in ranges) + { + for (var i = 0; i < count; i++) + { + if (position + entryWidth > decoded.Length) + { + throw new InvalidDataException("Cross-reference stream data is truncated."); + } + + var type = widths[0] == 0 ? 1 : (int)ReadBigEndian(decoded, position, widths[0]); + var field2 = ReadBigEndian(decoded, position + widths[0], widths[1]); + var field3 = widths[2] == 0 ? 0UL : ReadBigEndian(decoded, position + widths[0] + widths[1], widths[2]); + position += entryWidth; + + var objectNumber = start + i; + var entry = type switch + { + 0 => XrefEntry.Free, + 1 => XrefEntry.CreateDirect((long)field2), + 2 => XrefEntry.CreateCompressed((int)field2, (int)field3), + _ => throw new InvalidDataException($"Unsupported cross-reference stream entry type {type}."), + }; + + xref.TryAdd(objectNumber, entry); + } + } + + return new XrefSection(xref, streamObject, GetOptionalInt(streamObject, "Prev"), null); + } + + private static int? GetOptionalInt(PdfObject dictionary, string key) => + dictionary.Get(key) is { Kind: PdfKind.Number } number ? (int)number.Number : null; + + private static ulong ReadBigEndian(byte[] buffer, int offset, int width) + { + ulong value = 0; + for (var i = 0; i < width; i++) + { + value = (value << 8) | buffer[offset + i]; + } + + return value; + } + + /// Finds the byte offset recorded after the last startxref keyword in the buffer. + private int FindStartXrefOffset() + { + var marker = "startxref"u8.ToArray(); + var index = LastIndexOf(_buffer, marker); + if (index < 0) + { + throw new InvalidDataException("No 'startxref' keyword found."); + } + + var tokenizer = new PdfTokenizer(_buffer) { Position = index + marker.Length }; + var offsetToken = tokenizer.NextToken(); + if (offsetToken.Kind != PdfTokenKind.Number) + { + throw new InvalidDataException("Malformed 'startxref' offset."); + } + + return (int)offsetToken.Number; + } + + private static int LastIndexOf(byte[] haystack, byte[] needle) + { + for (var i = haystack.Length - needle.Length; i >= 0; i--) + { + var matched = true; + for (var j = 0; j < needle.Length; j++) + { + if (haystack[i + j] != needle[j]) + { + matched = false; + break; + } + } + + if (matched) + { + return i; + } + } + + return -1; + } + + private bool IsValidCatalogRoot(PdfObject trailer) + { + var rootReference = trailer.Get("Root"); + if (rootReference is null) + { + return false; + } + + try + { + var root = Resolve(rootReference); + return root.Kind == PdfKind.Dictionary && GetNameValue(root, "Type") == "Catalog"; + } + catch (InvalidDataException) + { + return false; + } + } + + /// + /// Recovers a usable trailer by scanning the whole buffer for N G obj markers when + /// normal cross-reference/trailer parsing fails or does not resolve to a valid catalog. + /// + /// A trailer dictionary with at least a working /Root entry. + /// + /// Thrown when no document catalog can be located even via this fallback scan. + /// + private PdfObject BuildLinearScanFallback() + { + _xref = ScanObjectOffsets(); + + var explicitTrailer = ScanForTrailerDictionary(); + if (explicitTrailer is not null && IsValidCatalogRoot(explicitTrailer)) + { + return explicitTrailer; + } + + foreach (var number in _xref.Keys.OrderBy(n => n)) + { + PdfObject candidate; + try + { + candidate = GetObject(number); + } + catch (InvalidDataException) + { + continue; + } + + if (candidate.Kind == PdfKind.Dictionary && GetNameValue(candidate, "Type") == "Catalog") + { + return PdfObject.FromDictionary(new Dictionary + { + ["Root"] = PdfObject.FromReference(number, 0), + }); + } + } + + throw new InvalidDataException("Could not locate a document catalog, even via a linear scan."); + } + + /// + /// Scans the entire buffer, token by token, for N G obj markers, recording each + /// object number's most recently seen offset (a later occurrence - for example from an + /// incremental update - overrides an earlier one, matching classic cross-reference table + /// override semantics). + /// + private Dictionary ScanObjectOffsets() + { + var xref = new Dictionary(); + var tokenizer = new PdfTokenizer(_buffer); + + while (true) + { + var startPosition = tokenizer.Position; + var first = tokenizer.NextToken(); + if (first.Kind == PdfTokenKind.EndOfFile) + { + break; + } + + if (first.Kind != PdfTokenKind.Number) + { + continue; + } + + var second = tokenizer.NextToken(); + if (second.Kind != PdfTokenKind.Number) + { + continue; + } + + var third = tokenizer.NextToken(); + if (third.Kind == PdfTokenKind.Keyword && third.Text == "obj") + { + xref[(int)first.Number] = XrefEntry.CreateDirect(startPosition); + } + } + + return xref; + } + + /// Scans the buffer for the last trailer keyword and parses its dictionary. + private PdfObject? ScanForTrailerDictionary() + { + var tokenizer = new PdfTokenizer(_buffer); + PdfObject? last = null; + + while (true) + { + var token = tokenizer.NextToken(); + if (token.Kind == PdfTokenKind.EndOfFile) + { + break; + } + + if (token.Kind != PdfTokenKind.Keyword || token.Text != "trailer") + { + continue; + } + + try + { + var candidate = ParseValue(tokenizer); + if (candidate.Kind == PdfKind.Dictionary) + { + last = candidate; + } + } + catch (InvalidDataException) + { + // Ignore a malformed trailer at this position and keep scanning. + } + } + + return last; + } + + private static string? GetNameValue(PdfObject dictionary, string key) => + dictionary.Get(key) is { Kind: PdfKind.Name } name ? name.Text : null; + + /// Resolves an object, following one level of indirect reference if necessary. + private PdfObject Resolve(PdfObject value) => value.Kind == PdfKind.Reference ? GetObject(value.RefNumber) : value; + + /// + /// Resolves an indirect object by number, using the cached value if this object number has + /// already been resolved. + /// + /// + /// Thrown when the object number is not defined in the cross-reference table (or is on the + /// free list), or its value cannot be parsed. + /// + private PdfObject GetObject(int number) + { + if (_objectCache.TryGetValue(number, out var cached)) + { + return cached; + } + + if (!_xref.TryGetValue(number, out var entry) || entry.Type == XrefEntryType.Free) + { + throw new InvalidDataException($"Indirect object {number} is not defined."); + } + + // Guard against a crafted cross-reference table/object graph that forms a resolution + // cycle - whether via compressed-object containment or via two direct objects whose + // values reference each other (for example a stream's indirect /Length referring back to + // the stream itself) - before either object can be cached. Without this, GetObject would + // recurse indefinitely and crash with an uncatchable StackOverflowException. + if (!_objectResolutionStack.Add(number)) + { + throw new InvalidDataException($"Indirect object {number} contains a resolution cycle."); + } + + try + { + PdfObject result; + if (entry.Type == XrefEntryType.Direct) + { + result = ParseIndirectObjectAt((int)entry.Offset, number); + } + else + { + result = LoadCompressedObject(entry.StreamNumber, entry.IndexInStream, number); + } + + _objectCache[number] = result; + return result; + } + finally + { + _objectResolutionStack.Remove(number); + } + } + + private PdfObject ParseIndirectObjectAt(int offset, int expectedNumber) + { + ValidateBufferOffset(offset, _buffer.Length, $"Indirect object {expectedNumber}"); + var tokenizer = new PdfTokenizer(_buffer) { Position = offset }; + var numberToken = tokenizer.NextToken(); + var generationToken = tokenizer.NextToken(); + var objToken = tokenizer.NextToken(); + + if (numberToken.Kind != PdfTokenKind.Number || generationToken.Kind != PdfTokenKind.Number || + objToken.Kind != PdfTokenKind.Keyword || objToken.Text != "obj") + { + throw new InvalidDataException($"Malformed indirect object header for object {expectedNumber} at offset {offset}."); + } + + var generation = (int)generationToken.Number; + var result = ParseValue(tokenizer); + result.ObjectNumber = expectedNumber; + result.Generation = generation; + + // Decrypt every string found anywhere within this top-level indirect object's value now, + // in place, before it is ever cached/returned - see DecryptStringsInPlace's own remarks + // for why this never mistakenly decrypts the /Encrypt dictionary's own strings. + if (_encryptionKey is not null) + { + DecryptStringsInPlace(result, expectedNumber, generation); + } + + return result; + } + + /// + /// Loads a single compressed object (entry type 2 in a cross-reference stream) out of its + /// containing object stream (/Type /ObjStm), decompressing the whole container the + /// first time any of its contained objects is requested (the decompressed result is not + /// itself cached across multiple compressed-object lookups within the same stream, since + /// each contained object's own parsed is cached individually by + /// ). + /// + private PdfObject LoadCompressedObject(int streamNumber, int indexInStream, int expectedNumber) + { + var container = GetObject(streamNumber); + if (container.Kind != PdfKind.Stream || GetNameValue(container, "Type") != "ObjStm") + { + throw new InvalidDataException($"Object {streamNumber} is not a valid /Type /ObjStm object stream."); + } + + var decoded = GetStreamDecodedBytes(container); + + var count = container.Get("N") is { Kind: PdfKind.Number } countObject + ? (int)countObject.Number + : throw new InvalidDataException("Object stream is missing /N."); + var first = container.Get("First") is { Kind: PdfKind.Number } firstObject + ? (int)firstObject.Number + : throw new InvalidDataException("Object stream is missing /First."); + + if (indexInStream < 0 || indexInStream >= count) + { + throw new InvalidDataException( + $"Compressed object index {indexInStream} is out of range for object stream {streamNumber}."); + } + + // Each header entry needs at least one byte (a single-digit object number, whitespace, + // and a single-digit offset collapse to a minimum of 3 bytes, but 1 byte/entry is used + // here as a conservative lower bound that never rejects a legitimate stream). Without + // this check, a crafted /N far larger than the actual decoded stream would make the loop + // below keep calling NextToken() long after the header's real content has been exhausted, + // reading nothing but zero-cost EndOfFile tokens for up to int.MaxValue iterations. + if (count > decoded.Length) + { + throw new InvalidDataException( + $"Object stream {streamNumber} declares /N {count}, which exceeds its decoded byte length."); + } + + var headerTokenizer = new PdfTokenizer(decoded); + var relativeOffset = -1; + for (var i = 0; i < count; i++) + { + // Stop as soon as the header tokenizer has no bytes left, rather than relying on the + // token-kind check below to eventually notice an EndOfFile token: this fails fast on + // a malformed header with a declared /N that overruns its own content. + if (headerTokenizer.Position >= decoded.Length) + { + throw new InvalidDataException("Object stream header ended before all declared /N entries were read."); + } + + var numberToken = headerTokenizer.NextToken(); + var offsetToken = headerTokenizer.NextToken(); + if (numberToken.Kind != PdfTokenKind.Number || offsetToken.Kind != PdfTokenKind.Number) + { + throw new InvalidDataException("Malformed object stream header."); + } + + if (i == indexInStream) + { + relativeOffset = (int)offsetToken.Number; + if ((int)numberToken.Number != expectedNumber) + { + throw new InvalidDataException( + $"Object stream {streamNumber} entry {indexInStream} does not match expected object number {expectedNumber}."); + } + } + } + + if (relativeOffset < 0) + { + throw new InvalidDataException("Malformed object stream header."); + } + + var bodyTokenizer = new PdfTokenizer(decoded) { Position = ValidatedCompressedObjectOffset(first, relativeOffset, decoded.Length, streamNumber) }; + return ParseValue(bodyTokenizer); + } + + /// + /// Combines an object stream's /First and a compressed object's relative offset + /// using arithmetic (both are attacker-controlled, so their sum + /// could otherwise overflow and wrap to a negative/incorrect value) + /// and validates the result against the decompressed container's actual length before it + /// is used as a position. + /// + private static int ValidatedCompressedObjectOffset(int first, int relativeOffset, int decodedLength, int streamNumber) + { + var combined = (long)first + relativeOffset; + ValidateBufferOffset(combined, decodedLength, $"Object stream {streamNumber} compressed object"); + return (int)combined; + } + + /// + /// Resolves a stream's declared /Length, which may be a direct integer or an + /// indirect reference. + /// + /// + /// Resolving an indirect /Length requires the cross-reference table to already be + /// fully built (via ), so this only succeeds for regular + /// content/object streams accessed after cross-reference parsing has completed. The one + /// exception, documented at its own call site, is a cross-reference stream's own + /// /Length, which this Phase 1 implementation requires to be a direct integer. + /// + private int GetStreamRawLength(PdfObject streamObject) + { + var lengthObject = streamObject.Get("Length") ?? throw new InvalidDataException("Stream object is missing required /Length entry."); + var resolved = Resolve(lengthObject); + if (resolved.Kind != PdfKind.Number) + { + throw new InvalidDataException("Stream /Length must be a number."); + } + + return (int)resolved.Number; + } + + private byte[] GetStreamRawBytes(PdfObject streamObject) + { + var length = GetStreamRawLength(streamObject); + + // Perform the bounds check in `long` arithmetic: with `int` arithmetic, a crafted huge + // /Length value close to int.MaxValue could cause `StreamDataStart + length` to overflow + // and silently wrap to a small (even negative) value, bypassing this check entirely and + // letting an invalid range reach `AsSpan` below, which throws the undocumented + // ArgumentOutOfRangeException instead of the documented InvalidDataException. + var streamEnd = (long)streamObject.StreamDataStart + length; + if (length < 0 || streamObject.StreamDataStart < 0 || streamEnd > _buffer.Length) + { + throw new InvalidDataException("Stream data range is out of bounds."); + } + + var raw = _buffer.AsSpan(streamObject.StreamDataStart, length).ToArray(); + return _encryptionKey is null ? raw : DecryptStreamBytes(raw, streamObject.ObjectNumber, streamObject.Generation); + } + + /// + /// Decompresses a complete zlib stream (2-byte header, DEFLATE-compressed data, 4-byte + /// big-endian Adler-32 trailer), mirroring PngCodec's own established zlib-handling + /// pattern: decodes the raw DEFLATE payload once the 2-byte + /// header and 4-byte trailer have been stripped off. + /// + /// + /// The trailing Adler-32 checksum is deliberately not validated against the decompressed + /// data: several real-world PDF producers emit a wrong (or placeholder) checksum while the + /// DEFLATE payload itself is perfectly well-formed, and other mainstream PDF readers + /// tolerate this and render the stream anyway. Rejecting such streams here would make this + /// library strictly less tolerant than the ecosystem it has to interoperate with, for a + /// mismatch that - unlike a truncated/corrupt DEFLATE payload, which + /// itself already fails on - does not affect the correctness of the bytes actually produced. + /// + /// + /// Thrown when the data is too short to be a valid zlib stream, the header's compression + /// method is not DEFLATE, the DEFLATE payload itself is truncated/corrupt (propagated + /// from ), or the decoded output exceeds + /// . + /// + private static byte[] ZlibDecompress(byte[] zlibData) + { + if (zlibData.Length < 6) + { + throw new InvalidDataException("Zlib stream is too short to be valid."); + } + + var compressionMethod = zlibData[0]; + if ((compressionMethod & 0x0F) != 8) + { + throw new InvalidDataException( + $"Unsupported zlib compression method {compressionMethod & 0x0F}; only DEFLATE (8) is supported."); + } + + var deflateData = zlibData.AsSpan(2, zlibData.Length - 2 - 4).ToArray(); + + using var input = new MemoryStream(deflateData); + using var deflate = new DeflateStream(input, CompressionMode.Decompress); + using var output = new MemoryStream(); + + // Copy in bounded chunks, checking the running total after every chunk, rather than + // calling CopyTo(output) and checking output.Length afterward - a crafted tiny zlib + // stream can expand to gigabytes, and checking only after an unbounded CopyTo would + // already have allocated (and copied) all of that oversized output before the check + // ever ran. Mirrors the DecodeLzw output cap in PdfDocument.Filters.Lzw.cs. + var buffer = new byte[81920]; + long total = 0; + int bytesRead; + while ((bytesRead = deflate.Read(buffer, 0, buffer.Length)) > 0) + { + total += bytesRead; + if (total > FlateMaxOutputBytes) + { + throw new InvalidDataException( + $"FlateDecode output exceeds the maximum supported size of {FlateMaxOutputBytes} bytes."); + } + + output.Write(buffer, 0, bytesRead); + } + + return output.ToArray(); + } +} diff --git a/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.cs b/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.cs new file mode 100644 index 00000000..2e95efb5 --- /dev/null +++ b/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.cs @@ -0,0 +1,498 @@ +// cspell:ignore CCITT Zapf Nonsymbolic Noto uncatchable +using DemaConsulting.CanvasNet.Canvas; +using DemaConsulting.CanvasNet.Codecs; + +namespace DemaConsulting.CanvasNet.Pdf; + +/// +/// Provides read-only access to a PDF (Portable Document Format) document: page count, each +/// page's displayed size and rotation, and fully rendered page content. +/// +/// +/// +/// An instance is created by or +/// , which read and parse the entire document exactly once (the +/// resolved page list and cross-reference data are cached for the lifetime of the +/// instance, so // +/// are cheap, repeatable operations against the same +/// instance). +/// +/// +/// Phase 1 of this package's implementation established document parsing and the +/// page-info API surface. Phase 2 added a content-stream interpreter (path- +/// construction/painting operators and the graphics-state stack), painting every path +/// in solid opaque black. Phase 3 added real device color +/// (g/G/rg/RG/k/K/cs/CS/ +/// sc/SC/scn/SCN), a generalized /Filter/ +/// /DecodeParms stream-decoding pipeline (FlateDecode plus PNG/TIFF +/// predictor reversal), and image XObjects (Do: DCTDecode via +/// , or raw DeviceGray/DeviceRGB/ +/// DeviceCMYK 8-bit samples, composited through the current transformation +/// matrix). +/// +/// +/// Text rendering (added starting with Phase 4 and substantially broadened by later +/// phases) interprets the text object/state/positioning/showing operators +/// (BT/ET, Tc/Tw/Tz/TL/Tf/Tr/ +/// Ts, Td/TD/Tm/T*, and Tj/'/"/ +/// TJ), resolving each named /Resources/Font entry against one of four +/// supported font subtypes: simple /Subtype /TrueType and /Subtype /Type1 +/// fonts (see PdfDocument.Fonts.cs/PdfDocument.Fonts.Type1.cs), composite +/// /Subtype /Type0 fonts with an Identity-H encoding and a CIDFontType2 or +/// CIDFontType0 descendant (see PdfDocument.Fonts.Type0.cs), and +/// procedure-painted /Subtype /Type3 fonts, whose glyph procedures are executed as +/// nested content streams (see PdfDocument.Fonts.Type3.cs) - only /MMType1 +/// (Multiple Master Type 1) fails closed with . +/// Each resolved glyph outline is painted through the composed text-rendering matrix (see +/// PdfDocument.Text.cs). Only the /WinAnsiEncoding//MacRomanEncoding +/// base encodings (plus /Differences overrides) are recognized for a simple font +/// (any other named base /Encoding fails closed), and only fill mode 0 and +/// invisible mode 3 are supported for the text-rendering mode (a stroke/clip +/// mode, 1/2/4-7, fails closed) - all with +/// rather than silently substituting +/// or skipping. A font with an embedded font program (/FontFile//FontFile2/ +/// /FontFile3, as appropriate to its subtype) uses that embedded font; a simple +/// font with no embedded program is, since Phase 6 (see below), automatically substituted +/// with a matching system or bundled font rather than failing closed. +/// (LZWDecode/ASCII85Decode/ASCIIHexDecode/ +/// RunLengthDecode are supported for any stream as of Phase 7; +/// CCITTFaxDecode, Group 4 only, is supported for image XObjects as of +/// Phase 15; only JPXDecode remains unsupported for image XObjects - see +/// Phase 15's own remarks above.) /Pattern-color-space shading (axial/radial, +/// /ShadingType 2/3, driven by /FunctionType 0/2/3 +/// functions) and tiling (/PaintType 1/2) pattern fills/strokes are also +/// fully supported, as are placed Form XObjects (see Phase 13 below); the remaining +/// unsupported features are listed under "Documented scope boundaries" in this package's +/// user guide (mesh shadings, /FunctionType 4 PostScript-calculator functions, +/// the sh operator, generic path clipping, transparency groups, and clip +/// text-rendering modes). Every other keyword not implemented is silently skipped, not +/// an error. A page with no /Contents at all still renders as a fully +/// transparent (blank) , exactly as every page did in Phase 1. +/// +/// +/// Phase 13 (this release) adds /Subtype /Form XObject rendering: Do +/// on a Form XObject decodes its content stream (via the same generic +/// /Filter//DecodeParms pipeline every other stream uses), concatenates +/// its optional /Matrix into the current transformation matrix (the same +/// left-multiply convention the cm operator uses), resolves its own +/// /Resources when present (else falling back to the invoking stream's +/// resources), and executes the decoded bytes as a nested content stream that +/// inherits the invoking stream's current graphics state and is implicitly bracketed +/// like q ... Q (CTM/color/font-selection mutations made inside the +/// Form never leak back out once Do returns, while path-painting/surface side +/// effects persist, exactly like any other painting operator). Phase 13 +/// limitations: the Form's /BBox is never used to clip its content, +/// its /Group (transparency group) entry is never consulted, and a hard +/// recursion-depth limit of 12 nested Form XObject invocations is enforced (a Form +/// that invokes itself, directly or via a longer cycle, beyond that depth throws +/// rather than overflowing the native +/// call stack). +/// +/// +/// Phase 6 adds automatic, silent font substitution for a simple TrueType font with +/// no embedded /FontFile2: is searched +/// for the closest-matching font installed on the host operating system by family +/// name and serif/fixed-pitch/bold/italic style (derived from the Standard-14 name +/// table when /BaseFont is one of the 14 standard names, else from +/// /FontDescriptor flags/weight/angle and the name itself); when no system +/// font matches, a bundled Liberation Sans/Serif/Mono font is used instead as a +/// deterministic last resort (see PdfDocument.FontFallback.cs). This is +/// fully automatic - there is no new public API and no "fallback occurred" +/// diagnostics. Symbol and ZapfDingbats resolve instead via a dedicated +/// bundled Noto substitute font union (see PdfDocument.FontFallback.cs's +/// ResolveSymbolicNotoFallback), using the built-in Symbol/ZapfDingbats Appendix +/// D encoding rather than /WinAnsiEncoding. Any other font whose +/// /FontDescriptor/Flags declares Symbolic without also declaring +/// Nonsymbolic is still the sole remaining exception: with no embedded +/// /FontFile2 it still fails closed with +/// , since its symbol/dingbat glyph set +/// has no meaningful generic-family equivalent. +/// +/// +/// Phase 15 adds CCITTFaxDecode image-XObject support: an +/// image XObject filtered solely with CCITTFaxDecode is decoded by +/// (see PdfDocument.CcittFax.cs) - a from-scratch +/// ITU-T T.6 Group 4 (MMR, two-dimensional) decoder - and its samples are then +/// composited through the same /ColorSpace pipeline every other image XObject +/// uses. Phase 15 scope boundary: only Group 4 (/DecodeParms /K +/// negative) is supported - Group 3 (/K 0 or greater, one- or +/// two-dimensional) and an explicit /EndOfLine true both fail closed with +/// rather than being misinterpreted. +/// The accurate current filter picture, correcting earlier phases' own stale remarks +/// above: FlateDecode/LZWDecode/ASCII85Decode/ +/// ASCIIHexDecode/RunLengthDecode are supported for any stream +/// (cross-reference/object streams, page /Contents, Form XObjects, and image +/// XObjects alike); DCTDecode and CCITTFaxDecode (Group 4 only) are +/// supported specifically for image XObjects; and only JPXDecode (and any other +/// unrecognized filter name) remains entirely unsupported for image XObjects. +/// +/// +/// Phase 16 (this release) adds support for opening a document encrypted with the PDF +/// /Filter /Standard security handler, for the extremely common real-world case +/// of an empty user password (a document that is merely permission- +/// restricted, not actually password-protected to open): RC4 (40/128-bit, /V 1/ +/// /V 2), AES-128 (/V 4, /CFM /AESV2), and AES-256 using the +/// simpler R5 key derivation (/V 5, /R 5, /CFM /AESV3) are all +/// transparently decrypted (see PdfDocument.Encryption.cs's own ISO 32000-1 +/// Algorithm 1/2/4/5, ISO 32000-2 Algorithm 2.A remarks) - a page's content stream, +/// every string, and every other encrypted stream all decrypt before any other +/// parsing logic ever sees their bytes, so the rest of this class needs no awareness +/// that a document was ever encrypted at all. Phase 16 scope boundary: +/// a non-/Standard security handler (for example /Adobe.PubSec), AES-256 +/// /R 6 ("hardened hash" key derivation), a crypt filter other than the +/// standard /StdCF (including /Identity), and a document that genuinely +/// requires a non-empty password (there is no API surface to supply one) all still +/// fail closed with , each with its own +/// distinguishable token. +/// +/// +public sealed partial class PdfDocument : IDisposable +{ + /// + /// The entire document's bytes, buffered once at / + /// time. Every offset recorded in and every + /// parsed from this document indexes into this same array. + /// + private readonly byte[] _buffer; + + /// + /// The resolved cross-reference table: maps an indirect object number to where its value + /// can be found (a direct byte offset in , or a compressed-object + /// location within an object stream). + /// + private Dictionary _xref; + + /// + /// Caches each indirect object's parsed value the first time it is resolved via + /// , so repeated references to the same object number are not + /// re-parsed. + /// + private readonly Dictionary _objectCache = new(); + + /// + /// Tracks the object numbers currently being resolved via + /// (whether direct or compressed), so a crafted cross-reference table that creates a + /// resolution cycle - whether through compressed-object containment (an object stream + /// whose container is itself compressed, possibly inside the same stream) or through two + /// direct objects whose values reference each other (for example a stream's indirect + /// /Length forming a cycle back to the stream itself) - is rejected with an + /// instead of recursing indefinitely into an + /// uncatchable . Mirrors the page-tree cycle guard in + /// TraversePageTree. + /// + private readonly HashSet _objectResolutionStack = new(); + + /// + /// Every page's pre-resolved, rotation-adjusted , in document + /// order, computed once during construction by . + /// + private readonly IReadOnlyList _pages; + + /// + /// Set once has been called; every other public member checks this + /// flag first via 's ThrowIf helper and + /// rejects further use once it is . + /// + private bool _disposed; + + /// + /// Initializes a new instance of the class by parsing the + /// supplied, already fully-buffered document bytes. + /// + /// The complete document bytes. + /// + /// An optional password to authenticate an encrypted document with. When + /// (the default), only the empty user password is authenticated - + /// byte-for-byte the same behavior as before this parameter existed. When non-null, it is + /// tried first as the user password, then - if that does not authenticate - as the owner + /// password; see 's own remarks for + /// the full authentication and password-encoding details. Ignored entirely when the + /// document is not encrypted. + /// + /// + /// Thrown when the document cannot be parsed at all (no cross-reference data or document + /// catalog could be located, even via the linear-scan fallback), or when a required + /// structure (trailer, catalog, page tree) is malformed. + /// + /// + /// Thrown when the document's trailer declares an /Encrypt entry whose security + /// handler, crypt filter, or revision this phase does not support (a non-/Standard + /// security handler, /R 6, a non-/StdCF crypt filter, a genuinely-required + /// password that was not supplied, a supplied password that does not authenticate as + /// either the user or the owner password, or a non-ASCII password supplied for an + /// /R 2-4 document), per the class remarks' Phase 16 scope boundary. + /// + private PdfDocument(byte[] buffer, string? password) + { + _buffer = buffer; + _xref = []; + + PdfObject trailer; + try + { + trailer = ParseCrossReferenceChain(); + + // Establish the file decryption key (if any) before resolving /Root: the catalog, or + // an ancestor in its page tree, may itself live inside a compressed /Type /ObjStm + // object stream that is only readable once the correct key is known. Validating the + // catalog before the key exists would decompress ciphertext as if it were plaintext + // and spuriously fail, triggering the linear-scan fallback below even though the + // document is otherwise well-formed. + InitializeEncryption(trailer, password); + + if (!IsValidCatalogRoot(trailer)) + { + throw new InvalidDataException("Trailer /Root does not resolve to a valid /Catalog."); + } + } + catch (InvalidDataException) + { + // Normal cross-reference parsing failed, the /Encrypt dictionary itself was + // malformed, or /Root resolved to something other than a valid catalog (for example a + // corrupt/missing startxref, or offsets that do not point at real objects) - fall back + // to a linear scan for "N G obj" markers, discarding any partially-cached objects + // resolved against the abandoned cross-reference table or encryption state. + // + // The encryption state itself must also be reset here: InitializeEncryption above may + // already have succeeded and populated _encryptionKey/_encryptionCipher before the + // later catalog-validation check failed. If the linear-scan fallback's recovered + // trailer has no /Encrypt entry of its own, InitializeEncryption below returns + // immediately as a no-op - leaving the stale key from the abandoned attempt in place + // and causing every object parsed from the fallback path to be incorrectly + // "decrypted" with a key that does not actually apply to it. + _objectCache.Clear(); + _encryptionKey = null; + _encryptionCipher = EncryptionCipher.None; + trailer = BuildLinearScanFallback(); + InitializeEncryption(trailer, password); + } + + _pages = BuildPageList(trailer); + } + + /// + /// Opens and parses a PDF document from a stream. + /// + /// + /// The stream to read the document from. The entire stream is read into an in-memory + /// buffer; this method does not take ownership of, and never disposes or closes, + /// . + /// + /// + /// An optional password to authenticate an encrypted document with. When + /// (the default), only the empty user password is authenticated - + /// byte-for-byte the same behavior as before this parameter existed. When non-null, it is + /// tried first as the user password, then as the owner password; see the private + /// constructor's own remarks for the full authentication and password-encoding details. + /// Ignored entirely when the document is not encrypted. + /// + /// A new instance representing the parsed document. + /// Thrown when is null. + /// + /// Thrown when the document cannot be parsed. + /// + /// + /// Thrown for the same encrypted-document conditions documented on the private + /// constructor above. + /// + public static PdfDocument Open(Stream stream, string? password = null) + { + ArgumentNullException.ThrowIfNull(stream); + + using var buffered = new MemoryStream(); + stream.CopyTo(buffered); + return new PdfDocument(buffered.ToArray(), password); + } + + /// + /// Opens and parses a PDF document from a file path. + /// + /// The path of the PDF file to read. + /// + /// An optional password to authenticate an encrypted document with. See + /// 's own remarks for the full authentication and + /// password-encoding details. + /// + /// A new instance representing the parsed document. + /// Thrown when is null. + /// + /// Thrown when is empty or consists only of white space. + /// + /// + /// Thrown when the document cannot be parsed. + /// + /// + /// Thrown for the same reason as . + /// + /// + /// File-system exceptions (for example , + /// , , or + /// ) raised while opening propagate + /// uncaught to the caller. + /// + public static PdfDocument Open(string path, string? password = null) + { + ArgumentNullException.ThrowIfNull(path); + if (string.IsNullOrWhiteSpace(path)) + { + throw new ArgumentException("Path must not be empty.", nameof(path)); + } + + using var stream = new FileStream(path, FileMode.Open, FileAccess.Read); + return Open(stream, password); + } + + /// + /// Gets the total number of pages in the document. + /// + /// Thrown when this document has been disposed. + public int PageCount + { + get + { + ObjectDisposedException.ThrowIf(_disposed, this); + return _pages.Count; + } + } + + /// + /// Gets the specified page's displayed (rotation-adjusted) size and effective rotation. + /// + /// The zero-based index of the page. + /// The page's . + /// + /// Thrown when is negative or greater than or equal to + /// . + /// + /// Thrown when this document has been disposed. + public PdfPageInfo GetPageInfo(int pageIndex) + { + ObjectDisposedException.ThrowIf(_disposed, this); + if (pageIndex < 0 || pageIndex >= _pages.Count) + { + throw new ArgumentOutOfRangeException(nameof(pageIndex), pageIndex, "Page index is out of range."); + } + + return _pages[pageIndex]; + } + + /// + /// Renders the specified page into a new of the given dimensions, + /// tokenizing and executing the page's /Contents content stream (see the + /// class remarks for the recognized operator set and this + /// phase's documented feature limitations). + /// + /// The zero-based index of the page to render. + /// The width of the rendered surface, in pixels. + /// The height of the rendered surface, in pixels. + /// + /// A new of the requested x + /// , painted with the page's interpreted content-stream geometry. + /// A page with no /Contents renders as a fully transparent (blank) surface. + /// + /// + /// Thrown when is negative or greater than or equal to + /// , or when / is + /// outside 's own valid dimension range (propagated, unwrapped, from + /// the constructor). + /// + /// + /// Thrown when /Contents is malformed (neither a stream nor an array of streams, + /// or an array entry that does not resolve to a stream), when the content stream is not + /// lexically well-formed, when a recognized operator's operand count/type does not match + /// its documented requirement, when a /Subtype /Form XObject's own + /// /Matrix is malformed, or when nested /Subtype /Form XObject invocations + /// exceed the maximum supported nesting depth. + /// + /// + /// Thrown when a Tf operator selects a font this library does not support (a + /// non-TrueType simple font, a font with no embedded /FontFile2, or an + /// unrecognized /Encoding base encoding), or when a Tr operator selects a + /// defined but unsupported text-rendering mode. + /// + /// Thrown when this document has been disposed. + public Surface Render(int pageIndex, int width, int height) + { + ObjectDisposedException.ThrowIf(_disposed, this); + if (pageIndex < 0 || pageIndex >= _pages.Count) + { + throw new ArgumentOutOfRangeException(nameof(pageIndex), pageIndex, "Page index is out of range."); + } + + var surface = new Surface(width, height); + var pageInfo = _pages[pageIndex]; + var (pageNode, x0, y0, boxWidth, boxHeight, resources) = ResolvePageDetails(pageIndex); + var baseCtm = BuildBaseCtm(x0, y0, boxWidth, boxHeight, pageInfo.Rotation, width, height); + var contentBytes = ResolvePageContentBytes(pageNode); + var resolvedResources = resources is null ? null : Resolve(resources); + ExecuteContentStream(contentBytes, surface, baseCtm, resolvedResources); + return surface; + } + + /// + /// Renders the specified page at a given resolution, preserving the page's own aspect + /// ratio. + /// + /// + /// A convenience wrapper over for the common case of + /// wanting undistorted, uniformly-scaled output: this overload reads the page's + /// rotation-adjusted / (in + /// points, 1/72 inch) via , scales both by + /// / 72, and rounds to the nearest pixel before delegating to + /// . Callers needing independent X/Y scaling (for + /// example, non-square pixels, or a specific pixel size regardless of aspect ratio) should + /// call directly instead. + /// + /// The zero-based index of the page to render. + /// The resolution to render at, in dots (pixels) per inch. + /// + /// A new sized to the page's aspect ratio at , + /// painted with the page's interpreted content-stream geometry. + /// + /// + /// Thrown when is negative or greater than or equal to + /// , when is not a positive, finite number, + /// or when the computed pixel width/height is outside 's own valid + /// dimension range (propagated, unwrapped, from the + /// constructor). + /// + /// + /// See . + /// + /// + /// See . + /// + /// Thrown when this document has been disposed. + public Surface Render(int pageIndex, float dpi) + { + ObjectDisposedException.ThrowIf(_disposed, this); + if (!float.IsFinite(dpi) || dpi <= 0) + { + throw new ArgumentOutOfRangeException(nameof(dpi), dpi, "DPI must be a positive, finite number."); + } + + var info = GetPageInfo(pageIndex); + var scale = dpi / 72.0; + var width = (int)Math.Round(info.Width * scale, MidpointRounding.AwayFromZero); + var height = (int)Math.Round(info.Height * scale, MidpointRounding.AwayFromZero); + return Render(pageIndex, width, height); + } + + /// + /// Releases the resources held by this . + /// + /// + /// Idempotent: calling this method more than once has no additional effect. After this + /// method has been called, every other public member throws + /// . + /// + public void Dispose() + { + if (_disposed) + { + return; + } + + _disposed = true; + } +} diff --git a/src/DemaConsulting.CanvasNet.Pdf/PdfPageInfo.cs b/src/DemaConsulting.CanvasNet.Pdf/PdfPageInfo.cs new file mode 100644 index 00000000..5272698e --- /dev/null +++ b/src/DemaConsulting.CanvasNet.Pdf/PdfPageInfo.cs @@ -0,0 +1,29 @@ +namespace DemaConsulting.CanvasNet.Pdf; + +/// +/// Reports a single PDF page's displayed (rotation-adjusted) size and effective rotation. +/// +/// +/// The page's displayed width, in points, after the effective has +/// already been applied. This is not necessarily the raw /MediaBox width: +/// when is 90 or 270, the raw /MediaBox width and +/// height are swapped to compute this value, since the page is displayed sideways. +/// +/// +/// The page's displayed height, in points, after the effective has +/// already been applied. This is not necessarily the raw /MediaBox height: +/// when is 90 or 270, the raw /MediaBox width and +/// height are swapped to compute this value, since the page is displayed sideways. +/// +/// +/// The page's normalized, effective, clockwise viewing rotation in degrees: always one of +/// 0, 90, 180, or 270. This is the page's own /Rotate value +/// if declared, otherwise the nearest declaring ancestor's inherited value, otherwise +/// 0 - already normalized modulo 360 - and is exactly the rotation already applied to +/// produce / from the raw /MediaBox. It is +/// exposed separately (rather than only folded into /) +/// for documentation/debugging transparency, and because constructing a page's rendering +/// transform in a later phase requires knowing the rotation angle itself, not just the +/// resulting swapped dimensions. +/// +public readonly record struct PdfPageInfo(int Width, int Height, int Rotation); diff --git a/src/DemaConsulting.CanvasNet/DemaConsulting.CanvasNet.csproj b/src/DemaConsulting.CanvasNet/DemaConsulting.CanvasNet.csproj index 0564c739..07eb7934 100644 --- a/src/DemaConsulting.CanvasNet/DemaConsulting.CanvasNet.csproj +++ b/src/DemaConsulting.CanvasNet/DemaConsulting.CanvasNet.csproj @@ -82,6 +82,11 @@ + + @@ -89,4 +94,28 @@ + + + + + + + + + + + + diff --git a/src/DemaConsulting.CanvasNet/Drawing/NamespaceDoc.cs b/src/DemaConsulting.CanvasNet/Drawing/NamespaceDoc.cs index d72c4845..a5ab1583 100644 --- a/src/DemaConsulting.CanvasNet/Drawing/NamespaceDoc.cs +++ b/src/DemaConsulting.CanvasNet/Drawing/NamespaceDoc.cs @@ -13,15 +13,20 @@ namespace DemaConsulting.CanvasNet.Drawing; /// . paint - either a /// or a , each defined by an /// ordered list of colors and a -/// mode controlling how the gradient repeats beyond its defined extent - is integrated -/// directly into as an alternative to a single solid color. This -/// namespace consumes both +/// mode controlling how the gradient repeats beyond its defined extent - and +/// , which repeats a caller-supplied +/// tile by unconditionally wrapping +/// pattern-space coordinates modulo its / +/// pitch - there is no caller-selectable spread mode analogous +/// to ; tiling always repeats - are both integrated directly into +/// as alternatives to a single solid color. This namespace consumes +/// both /// (for Path, curve flattening, and /// arc conversion) and (for the pixel buffer it /// paints into) - neither of those namespaces depends on this one, so future changes here -/// never ripple back into either foundational namespace. Pattern paint, transform-aware -/// fills, clip regions beyond the surface's own bounds, and font/text rendering remain -/// reserved for later phases. +/// never ripple back into either foundational namespace. Transform-aware fills, clip regions +/// beyond the surface's own bounds, and font/text rendering remain reserved for later +/// phases. /// internal static class NamespaceDoc { diff --git a/src/DemaConsulting.CanvasNet/Drawing/PathFiller.cs b/src/DemaConsulting.CanvasNet/Drawing/PathFiller.cs index 3cb228ef..025db6ce 100644 --- a/src/DemaConsulting.CanvasNet/Drawing/PathFiller.cs +++ b/src/DemaConsulting.CanvasNet/Drawing/PathFiller.cs @@ -140,6 +140,61 @@ public static void Fill( ScanlineRasterizer.Fill(surface, polygons, paint, fillRule, clipBounds); } + /// + /// Fills onto with , + /// a repeating tile bitmap evaluated once per pixel and scaled by that pixel's + /// antialiased fill coverage. + /// + /// The surface to fill into. Must not be . + /// The path to fill. Must not be . + /// The tile paint to sample. Must not be . + /// + /// The rule used to resolve overlapping or self-intersecting geometry. Defaults to + /// . + /// + /// + /// The maximum allowed perpendicular deviation between each curve in + /// and the polyline used to approximate it for filling. Must be greater than zero. + /// Defaults to 0.25f. + /// + /// + /// Thrown when , , or + /// is . + /// + /// + /// Thrown when is not a defined value, + /// or when is not a finite value greater than zero. + /// + /// + /// See 's remarks - this + /// overload shares every bit of flattening, clip-bounds computation, and argument + /// validation the same way; the only difference is which ScanlineRasterizer.Fill + /// entry point (and therefore per-row compositing behavior) is invoked. The shared + /// "every subpath is treated as implicitly closed", "no-op on an empty/out-of-bounds path", + /// and "no transform parameter" documentation on the solid-color + /// overload applies identically + /// here too. + /// + public static void Fill( + Surface surface, + Path path, + TilePaint paint, + FillRule fillRule = FillRule.NonZero, + float flattenTolerance = 0.25f) + { + ArgumentNullException.ThrowIfNull(surface); + ArgumentNullException.ThrowIfNull(path); + ArgumentNullException.ThrowIfNull(paint); + ValidateFillArgs(fillRule, flattenTolerance); + + if (!TryFlattenForFill(surface, path, flattenTolerance, out var polygons, out var clipBounds)) + { + return; + } + + ScanlineRasterizer.Fill(surface, polygons, paint, fillRule, clipBounds); + } + /// /// Validates the / arguments /// shared by every Fill overload. diff --git a/src/DemaConsulting.CanvasNet/Drawing/ScanlineRasterizer.cs b/src/DemaConsulting.CanvasNet/Drawing/ScanlineRasterizer.cs index a0159442..c9d56008 100644 --- a/src/DemaConsulting.CanvasNet/Drawing/ScanlineRasterizer.cs +++ b/src/DemaConsulting.CanvasNet/Drawing/ScanlineRasterizer.cs @@ -207,6 +207,54 @@ internal static void Fill(Surface surface, IReadOnlyList> polygons } } + /// + /// Rasterizes onto , evaluating + /// once per pixel via + /// and compositing the resulting per-pixel colors scaled by each pixel's analytically + /// computed fill coverage, restricted to . + /// + /// The surface to composite into. Must not be null. + /// + /// The closed polygons to fill (typically produced by ). + /// + /// The tile paint to evaluate per pixel. Must not be null. + /// The rule used to resolve overlapping/self-intersecting geometry. + /// + /// The region to rasterize, in the same path-space coordinates as . + /// + /// + /// Shares its whole row-coverage computation with the constant-color and + /// + /// overloads via the shared helper - the only difference here + /// is that the final per-row compositing call first evaluates a per-pixel color row via + /// , against a plan + /// built exactly once for the whole fill operation (via + /// ), not rebuilt on every row. + /// + internal static void Fill(Surface surface, IReadOnlyList> polygons, TilePaint paint, FillRule fillRule, Rect clipBounds) + { + ArgumentNullException.ThrowIfNull(paint); + + var sweep = new CoverageSweep(polygons, fillRule, clipBounds); + if (sweep.IsEmpty) + { + return; + } + + using var compositeWorkspace = new Surface.CompositeSpanWorkspace(sweep.Width); + var rowColors = new Rgba32[sweep.Width]; + + // Built once per fill operation, not once per row - see TilePaintEvaluator's "Per-fill + // precomputation" remarks. + var plan = TilePaintEvaluator.CreatePlan(paint); + + while (sweep.MoveNext(out var y, out var rowCoverage)) + { + TilePaintEvaluator.EvaluateRow(in plan, y, sweep.ClipMinX, sweep.Width, rowColors); + surface.CompositeOverSpan(y, sweep.ClipMinX, rowCoverage, rowColors, compositeWorkspace); + } + } + /// /// Encapsulates the row-coverage computation shared by every Fill overload: /// the edge table build, active-edge tracking, and per-row cover/area diff --git a/src/DemaConsulting.CanvasNet/Drawing/TilePaint.cs b/src/DemaConsulting.CanvasNet/Drawing/TilePaint.cs new file mode 100644 index 00000000..d08125d6 --- /dev/null +++ b/src/DemaConsulting.CanvasNet/Drawing/TilePaint.cs @@ -0,0 +1,136 @@ +using System.Numerics; +using DemaConsulting.CanvasNet.Canvas; + +namespace DemaConsulting.CanvasNet.Drawing; + +/// +/// Represents a resolved, renderable tile paint: a pre-rendered one-cell tile bitmap (a +/// ), a mapping the tile's own defining +/// (pattern-space) coordinates into the same coordinate space a caller's +/// already uses, and an / +/// pitch describing how far apart - in pattern-space units - successive tile repetitions are +/// spaced. +/// +/// +/// +/// is the tiling-pattern analogue of : just as +/// (via ) is evaluated once per pixel to +/// produce a smoothly varying color, (via +/// ) is evaluated once per pixel to sample the repeating tile +/// bitmap at the appropriate wrapped-around pattern-space offset. +/// +/// +/// does not own 's lifetime: it does not dispose +/// it, and the caller that constructed this remains responsible for +/// disposing the underlying once it is no longer needed - mirroring how +/// similarly owns no disposable resource of its own. +/// +/// +/// / may be negative (a legitimate PDF tiling-pattern +/// value, per PDF 32000-1 ยง8.7.3.1, meaning the tile repeats in the negative pattern-space +/// axis direction) - only zero or a non-finite value is rejected by the constructor. Sampling +/// code () is responsible for correctly wrapping around a +/// negative step using its absolute value. +/// +/// +public sealed class TilePaint +{ + /// + /// Initializes a new . + /// + /// + /// The pre-rendered one-cell tile bitmap. Must not be . This + /// instance does not take ownership of - see this class's + /// remarks. + /// + /// + /// The transform mapping the tile's own defining (pattern-space) coordinates into the + /// caller's path coordinate space. Every one of its six components must be finite. + /// + /// + /// The pattern-space horizontal tile pitch. Must be a finite, non-zero value; a negative + /// value is accepted - see this class's remarks. + /// + /// + /// The pattern-space vertical tile pitch. Must be a finite, non-zero value; a negative + /// value is accepted - see this class's remarks. + /// + /// Thrown when is . + /// + /// Thrown when any component of is not finite, or when + /// or is not finite or is zero. + /// + public TilePaint(Surface surface, Matrix3x2 transform, float xStep, float yStep) + { + ArgumentNullException.ThrowIfNull(surface); + + if (!IsFinite(transform)) + { + throw new ArgumentOutOfRangeException( + nameof(transform), transform, "Transform components must all be finite values."); + } + + if (!float.IsFinite(xStep) || xStep == 0f) + { + throw new ArgumentOutOfRangeException(nameof(xStep), xStep, "XStep must be a finite, non-zero value."); + } + + if (!float.IsFinite(yStep) || yStep == 0f) + { + throw new ArgumentOutOfRangeException(nameof(yStep), yStep, "YStep must be a finite, non-zero value."); + } + + Surface = surface; + Transform = transform; + XStep = xStep; + YStep = yStep; + } + + /// + /// Gets the pre-rendered one-cell tile bitmap. This does not own + /// this 's lifetime - see this class's remarks. + /// + public Surface Surface { get; } + + /// + /// Gets the transform mapping the tile's own defining (pattern-space) coordinates into + /// the caller's path coordinate space. + /// + public Matrix3x2 Transform { get; } + + /// + /// Gets the pattern-space horizontal tile pitch. May be negative - see this class's + /// remarks. + /// + public float XStep { get; } + + /// + /// Gets the pattern-space vertical tile pitch. May be negative - see this class's + /// remarks. + /// + public float YStep { get; } + + /// + /// Returns a new with the same , + /// , and , whose is this + /// tile paint's own composed with + /// (row-vector convention, matching : this tile + /// paint's existing is applied first, then + /// is applied on top of that). + /// + /// The additional transform to compose after this tile paint's own . + /// A new instance with the composed transform; the original is unchanged. + /// + /// Thrown when the composed transform has a non-finite component. + /// + public TilePaint WithTransform(Matrix3x2 transform) => + new(Surface, Transform * transform, XStep, YStep); + + /// + /// Determines whether every component of is a finite value. + /// + private static bool IsFinite(Matrix3x2 matrix) => + float.IsFinite(matrix.M11) && float.IsFinite(matrix.M12) && + float.IsFinite(matrix.M21) && float.IsFinite(matrix.M22) && + float.IsFinite(matrix.M31) && float.IsFinite(matrix.M32); +} diff --git a/src/DemaConsulting.CanvasNet/Drawing/TilePaintEvaluator.cs b/src/DemaConsulting.CanvasNet/Drawing/TilePaintEvaluator.cs new file mode 100644 index 00000000..209ebf51 --- /dev/null +++ b/src/DemaConsulting.CanvasNet/Drawing/TilePaintEvaluator.cs @@ -0,0 +1,165 @@ +using System.Numerics; +using DemaConsulting.CanvasNet.Canvas; + +// cspell:ignore precomputation +namespace DemaConsulting.CanvasNet.Drawing; + +/// +/// Evaluates a 's repeating tile bitmap at individual device-space +/// points, mirroring 's own "per-fill precomputation, per-row +/// evaluation" shape. +/// +/// +/// +/// Per-fill precomputation. Exactly like , the only +/// quantity that is invariant across an entire fill operation - the inverse of +/// - is computed exactly once, via , +/// and reused for every row via , instead of being recomputed per +/// pixel. +/// +/// +/// Degenerate Transform policy - deliberate divergence from . +/// When is not invertible (or its inverse has a non-finite +/// component), every pixel in the affected fill is left fully transparent (alpha zero, +/// default()) - this is NOT the same policy +/// uses for its own "Degenerate Transform" case (which flat-fills with the last stop's +/// color - see 's remarks). has no +/// stop/last-color concept at all, so there is no equivalent fallback color to flat-fill +/// with. Instead, this mirrors 's own separate +/// "no valid root" radial-gradient case, which also leaves affected pixels fully transparent/ +/// unpainted. This is an intentional, documented design decision - not an oversight - so a +/// future reader is not confused by the difference from 's own +/// degenerate-transform behavior. +/// +/// +/// Sampling. A device-space point is mapped back into pattern space via the +/// precomputed inverse transform, then its X/Y coordinates are each reduced modulo +/// / using floor-mod (never the CLR +/// % operator, which is negative for a negative dividend and would wrap a negative +/// coordinate - or a negative step - to the wrong side of the tile, matching +/// 's own documented Repeat floor-mod precedent) into +/// the half-open range [0, |XStep|) x [0, |YStep|). That wrapped point is then mapped +/// into the tile 's own pixel coordinate space by the ratio of the tile +/// surface's pixel dimensions to /, +/// clamped into [0, Width - 1] x [0, Height - 1] (guarding a floating-point edge case +/// landing exactly on the width/height boundary), and sampled via nearest-neighbor +/// (tile.Surface[tx, ty]) - bilinear interpolation is explicitly out of scope. +/// +/// +internal static class TilePaintEvaluator +{ + /// + /// Holds every quantity can compute once per + /// and reuse for every point evaluated against it, instead of + /// recomputing per pixel - see this class's "Per-fill precomputation" remarks. + /// + internal readonly record struct TilePaintPlan + { + /// The tile paint this plan was built for. + public required TilePaint Tile { get; init; } + + /// + /// Whether is invertible with a finite inverse; + /// when , every point in the fill is left fully transparent + /// (the "Degenerate Transform" case) and is not + /// meaningful. + /// + public required bool HasInverseTransform { get; init; } + + /// The inverse of , when . + public Matrix3x2 InverseTransform { get; init; } + } + + /// + /// Computes every per- (never per-point) quantity once, for reuse + /// across an entire fill operation (every row of a tile + /// fill) via . + /// + /// The tile paint to build a plan for. Must not be . + /// Thrown when is . + internal static TilePaintPlan CreatePlan(TilePaint tile) + { + ArgumentNullException.ThrowIfNull(tile); + + var hasInverse = Matrix3x2.Invert(tile.Transform, out var inverse) && IsFinite(inverse); + + return new TilePaintPlan + { + Tile = tile, + HasInverseTransform = hasInverse, + InverseTransform = inverse, + }; + } + + /// + /// Evaluates 's tile paint once per pixel center of a horizontal + /// run of pixels starting at column on row + /// , writing each pixel's color into . + /// + /// + /// The tile paint's precomputed per-fill-invariant plan, built once per fill operation via + /// and reused across every row. + /// + /// The zero-based row, in the same coordinate space 's tile's maps into. + /// The zero-based column at which the run starts. + /// The number of pixels to evaluate. + /// + /// The destination span to receive each pixel's color, one entry per pixel. Must have a + /// length of at least . + /// + internal static void EvaluateRow(in TilePaintPlan plan, int y, int x, int count, Span destination) + { + if (!plan.HasInverseTransform) + { + // Degenerate Transform case applies to the whole fill, not just this row - resolve + // it once for the entire run instead of per pixel. See this class's remarks for why + // this leaves pixels transparent rather than flat-filling, unlike Gradient. + for (var i = 0; i < count; i++) + { + destination[i] = default; + } + + return; + } + + var tile = plan.Tile; + var surface = tile.Surface; + var xStep = Math.Abs((double)tile.XStep); + var yStep = Math.Abs((double)tile.YStep); + var scaleX = surface.Width / xStep; + var scaleY = surface.Height / yStep; + + for (var i = 0; i < count; i++) + { + var devicePoint = new Vector2(x + i + 0.5f, y + 0.5f); + var patternPoint = Vector2.Transform(devicePoint, plan.InverseTransform); + + var wrappedX = FloorMod(patternPoint.X, xStep); + var wrappedY = FloorMod(patternPoint.Y, yStep); + + var tx = Math.Clamp((int)(wrappedX * scaleX), 0, surface.Width - 1); + var ty = Math.Clamp((int)(wrappedY * scaleY), 0, surface.Height - 1); + + destination[i] = surface[tx, ty]; + } + } + + /// + /// Reduces modulo using floor-mod + /// (always returning a non-negative result in [0, modulus)), rather than the CLR + /// % operator - matching 's own documented + /// Repeat floor-mod precedent. + /// + /// The value to reduce. May be negative. + /// The modulus; must already be positive (the absolute step value). + private static double FloorMod(double value, double modulus) => + value - (Math.Floor(value / modulus) * modulus); + + /// + /// Determines whether every component of is a finite value. + /// + private static bool IsFinite(Matrix3x2 matrix) => + float.IsFinite(matrix.M11) && float.IsFinite(matrix.M12) && + float.IsFinite(matrix.M21) && float.IsFinite(matrix.M22) && + float.IsFinite(matrix.M31) && float.IsFinite(matrix.M32); +} diff --git a/src/DemaConsulting.CanvasNet/Fonts/BundledFonts/LiberationMono-Bold.ttf b/src/DemaConsulting.CanvasNet/Fonts/BundledFonts/LiberationMono-Bold.ttf new file mode 100644 index 00000000..2e46737a Binary files /dev/null and b/src/DemaConsulting.CanvasNet/Fonts/BundledFonts/LiberationMono-Bold.ttf differ diff --git a/src/DemaConsulting.CanvasNet/Fonts/BundledFonts/LiberationMono-BoldItalic.ttf b/src/DemaConsulting.CanvasNet/Fonts/BundledFonts/LiberationMono-BoldItalic.ttf new file mode 100644 index 00000000..d1f46d7c Binary files /dev/null and b/src/DemaConsulting.CanvasNet/Fonts/BundledFonts/LiberationMono-BoldItalic.ttf differ diff --git a/src/DemaConsulting.CanvasNet/Fonts/BundledFonts/LiberationMono-Italic.ttf b/src/DemaConsulting.CanvasNet/Fonts/BundledFonts/LiberationMono-Italic.ttf new file mode 100644 index 00000000..954c3943 Binary files /dev/null and b/src/DemaConsulting.CanvasNet/Fonts/BundledFonts/LiberationMono-Italic.ttf differ diff --git a/src/DemaConsulting.CanvasNet/Fonts/BundledFonts/LiberationMono-Regular.ttf b/src/DemaConsulting.CanvasNet/Fonts/BundledFonts/LiberationMono-Regular.ttf new file mode 100644 index 00000000..e774859c Binary files /dev/null and b/src/DemaConsulting.CanvasNet/Fonts/BundledFonts/LiberationMono-Regular.ttf differ diff --git a/src/DemaConsulting.CanvasNet/Fonts/BundledFonts/LiberationSans-Bold.ttf b/src/DemaConsulting.CanvasNet/Fonts/BundledFonts/LiberationSans-Bold.ttf new file mode 100644 index 00000000..dc5d57f1 Binary files /dev/null and b/src/DemaConsulting.CanvasNet/Fonts/BundledFonts/LiberationSans-Bold.ttf differ diff --git a/src/DemaConsulting.CanvasNet/Fonts/BundledFonts/LiberationSans-BoldItalic.ttf b/src/DemaConsulting.CanvasNet/Fonts/BundledFonts/LiberationSans-BoldItalic.ttf new file mode 100644 index 00000000..158488a1 Binary files /dev/null and b/src/DemaConsulting.CanvasNet/Fonts/BundledFonts/LiberationSans-BoldItalic.ttf differ diff --git a/src/DemaConsulting.CanvasNet/Fonts/BundledFonts/LiberationSans-Italic.ttf b/src/DemaConsulting.CanvasNet/Fonts/BundledFonts/LiberationSans-Italic.ttf new file mode 100644 index 00000000..25970d9d Binary files /dev/null and b/src/DemaConsulting.CanvasNet/Fonts/BundledFonts/LiberationSans-Italic.ttf differ diff --git a/src/DemaConsulting.CanvasNet/Fonts/BundledFonts/LiberationSans-Regular.ttf b/src/DemaConsulting.CanvasNet/Fonts/BundledFonts/LiberationSans-Regular.ttf new file mode 100644 index 00000000..e6339859 Binary files /dev/null and b/src/DemaConsulting.CanvasNet/Fonts/BundledFonts/LiberationSans-Regular.ttf differ diff --git a/src/DemaConsulting.CanvasNet/Fonts/BundledFonts/LiberationSerif-Bold.ttf b/src/DemaConsulting.CanvasNet/Fonts/BundledFonts/LiberationSerif-Bold.ttf new file mode 100644 index 00000000..3c7c55b5 Binary files /dev/null and b/src/DemaConsulting.CanvasNet/Fonts/BundledFonts/LiberationSerif-Bold.ttf differ diff --git a/src/DemaConsulting.CanvasNet/Fonts/BundledFonts/LiberationSerif-BoldItalic.ttf b/src/DemaConsulting.CanvasNet/Fonts/BundledFonts/LiberationSerif-BoldItalic.ttf new file mode 100644 index 00000000..6b35d9f7 Binary files /dev/null and b/src/DemaConsulting.CanvasNet/Fonts/BundledFonts/LiberationSerif-BoldItalic.ttf differ diff --git a/src/DemaConsulting.CanvasNet/Fonts/BundledFonts/LiberationSerif-Italic.ttf b/src/DemaConsulting.CanvasNet/Fonts/BundledFonts/LiberationSerif-Italic.ttf new file mode 100644 index 00000000..54d51648 Binary files /dev/null and b/src/DemaConsulting.CanvasNet/Fonts/BundledFonts/LiberationSerif-Italic.ttf differ diff --git a/src/DemaConsulting.CanvasNet/Fonts/BundledFonts/LiberationSerif-Regular.ttf b/src/DemaConsulting.CanvasNet/Fonts/BundledFonts/LiberationSerif-Regular.ttf new file mode 100644 index 00000000..5e5550c0 Binary files /dev/null and b/src/DemaConsulting.CanvasNet/Fonts/BundledFonts/LiberationSerif-Regular.ttf differ diff --git a/src/DemaConsulting.CanvasNet/Fonts/BundledFonts/NotoFonts-OFL.txt b/src/DemaConsulting.CanvasNet/Fonts/BundledFonts/NotoFonts-OFL.txt new file mode 100644 index 00000000..9d46ce2b --- /dev/null +++ b/src/DemaConsulting.CanvasNet/Fonts/BundledFonts/NotoFonts-OFL.txt @@ -0,0 +1,95 @@ +Copyright 2022 The Noto Project Authors (https://github.com/notofonts/latin-greek-cyrillic) +Copyright 2022 The Noto Project Authors (https://github.com/notofonts/symbols) +Copyright 2022 The Noto Project Authors (https://github.com/notofonts/math) + +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://scripts.sil.org/OFL + + +----------------------------------------------------------- +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/src/DemaConsulting.CanvasNet/Fonts/BundledFonts/NotoSans-Regular.ttf b/src/DemaConsulting.CanvasNet/Fonts/BundledFonts/NotoSans-Regular.ttf new file mode 100644 index 00000000..a522291a Binary files /dev/null and b/src/DemaConsulting.CanvasNet/Fonts/BundledFonts/NotoSans-Regular.ttf differ diff --git a/src/DemaConsulting.CanvasNet/Fonts/BundledFonts/NotoSansMath-Regular.ttf b/src/DemaConsulting.CanvasNet/Fonts/BundledFonts/NotoSansMath-Regular.ttf new file mode 100644 index 00000000..7e505c9b Binary files /dev/null and b/src/DemaConsulting.CanvasNet/Fonts/BundledFonts/NotoSansMath-Regular.ttf differ diff --git a/src/DemaConsulting.CanvasNet/Fonts/BundledFonts/NotoSansSymbols2-Regular.ttf b/src/DemaConsulting.CanvasNet/Fonts/BundledFonts/NotoSansSymbols2-Regular.ttf new file mode 100644 index 00000000..caf89dd0 Binary files /dev/null and b/src/DemaConsulting.CanvasNet/Fonts/BundledFonts/NotoSansSymbols2-Regular.ttf differ diff --git a/src/DemaConsulting.CanvasNet/Fonts/BundledFonts/OFL.txt b/src/DemaConsulting.CanvasNet/Fonts/BundledFonts/OFL.txt new file mode 100644 index 00000000..aba73e8a --- /dev/null +++ b/src/DemaConsulting.CanvasNet/Fonts/BundledFonts/OFL.txt @@ -0,0 +1,102 @@ +Digitized data copyright (c) 2010 Google Corporation + with Reserved Font Arimo, Tinos and Cousine. +Copyright (c) 2012 Red Hat, Inc. + with Reserved Font Name Liberation. + +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: +http://scripts.sil.org/OFL + +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/src/DemaConsulting.CanvasNet/Fonts/BundledFonts/README.md b/src/DemaConsulting.CanvasNet/Fonts/BundledFonts/README.md new file mode 100644 index 00000000..85db0108 --- /dev/null +++ b/src/DemaConsulting.CanvasNet/Fonts/BundledFonts/README.md @@ -0,0 +1,103 @@ +# Bundled Fallback Fonts + + + + +The 12 `.ttf` files in this folder are the real, unmodified, production "Liberation Sans", +"Liberation Serif", and "Liberation Mono" TrueType font families (four styles each - Regular, +Bold, Italic, BoldItalic), sourced from the official +[liberation-fonts](https://github.com/liberationfonts/liberation-fonts) upstream project's +`2.1.5` release asset `liberation-fonts-ttf-2.1.5.tar.gz` +(). No file's bytes were +altered in any way after download - each is the exact binary shipped by that release. + +All 12 files are licensed under the [SIL Open Font License, Version 1.1](https://openfontlicense.org/) + +- see `OFL.txt` (the license's full text, as required for redistribution) alongside them in this +folder, copied verbatim from the same release archive's own `LICENSE` file. This same `OFL.txt` +text is also packed into the NuGet package's `licenses\LiberationFonts-OFL.txt` (see the +`DemaConsulting.CanvasNet.csproj` `Pack="true"` entry) so that anyone inspecting the `.nupkg` +directly, independent of the embedded-resource copy used at runtime, can find the license text +that governs these bundled fonts. + +Unlike `test/DemaConsulting.CanvasNet.Tests/FontFixtures/` (which bundles font files purely as +*test* fixtures, never shipped to a consumer), these 12 files are the first **runtime** (non-test) +binary assets ever bundled by this repository: they are embedded as `` items in +`DemaConsulting.CanvasNet.csproj` and loaded at runtime via +`Assembly.GetManifestResourceStream` by `Fonts.SystemFontCatalog.LoadBundledFallback` - the +deterministic, always-available last-resort font used when a consumer (for example +`DemaConsulting.CanvasNet.Pdf`'s `PdfDocument`) needs to render text with a non-embedded, +non-Standard-14, or otherwise-unmatched font and no equivalent font is installed on the host +operating system. + +The SIL OFL permits bundling and redistributing these fonts as part of this software; it may not +be sold on its own, and this repository does not do so. No modification is made to the fonts +themselves, so the "a modified version may not use the original font's Reserved Font Name" +restriction does not apply either. + +`BundledLiberationFontsTests.cs` (in `test/DemaConsulting.CanvasNet.Tests/Fonts/`) dogfoods every +one of these 12 files through `Fonts.TrueTypeFont.Load`, verifying each is a genuine, parseable +TrueType font exposing the expected family name and style flags - this is both a one-time sanity +check performed before committing these files and a permanent, repeatable CI gate against any +future accidental corruption or replacement of these files. + +## Noto Substitute Fonts (Symbol/ZapfDingbats Fallback) + + + +The 3 additional `.ttf` files in this folder - +`NotoSans-Regular.ttf`, `NotoSansMath-Regular.ttf`, and `NotoSansSymbols2-Regular.ttf` - are used +by `DemaConsulting.CanvasNet.Pdf`'s `PdfDocument` as the bundled substitute font(s) for a +`/BaseFont /Symbol` or `/BaseFont /ZapfDingbats` simple font with no embedded font program (see +`PdfDocument.FontFallback.cs`'s `ResolveSymbolicNotoFallback`) - unlike every other non-embedded +font, `Symbol`/`ZapfDingbats` are never matched against an unrelated system or Liberation font, +since a symbol/dingbat glyph set has no meaningful generic-family equivalent; instead, a real +font whose glyph repertoire actually covers (most of) the Symbol/ZapfDingbats Adobe/ISO 32000-1 +Appendix D character sets is bundled and substituted directly. + +Each file is sourced from the official [Noto Fonts](https://notofonts.github.io/) project, from +the following upstream repositories (all at the `2022` release generation referenced by the +`NotoFonts-OFL.txt` copyright header below): + +- `NotoSans-Regular.ttf` - from + [notofonts/latin-greek-cyrillic](https://github.com/notofonts/latin-greek-cyrillic)'s variable + font `NotoSans[wdth,wght].ttf`, instanced to a single static Regular (weight 400, width 100%) + instance using [fonttools](https://github.com/fonttools/fonttools)' `varLib.instancer`: + + ```sh + python -m fontTools.varLib.instancer "NotoSans[wdth,wght].ttf" wght=400 wdth=100 -o NotoSans-Regular.ttf --update-name-table + ``` + + Used as the Symbol substitute's primary font: its Greek-letter and general-symbol glyphs (for + example `alpha`/`Alpha`, punctuation-like symbol glyphs) cover the bulk of the Symbol encoding. +- `NotoSansMath-Regular.ttf` - from + [notofonts/math](https://github.com/notofonts/math)'s released static `Regular` instance, + unmodified. Used as the Symbol substitute's second-priority font, covering Symbol's + mathematical-operator glyphs (for example summation, integral, and set-theory symbols) that + `NotoSans-Regular.ttf` itself does not carry. +- `NotoSansSymbols2-Regular.ttf` - from + [notofonts/symbols](https://github.com/notofonts/symbols)'s released static `Regular` instance + (the "Symbols 2" family, covering a broader symbol repertoire than the base "Symbols" family), + unmodified. Used as the Symbol substitute's third-priority (Private-Use-Area-adjacent/rare + symbol) font, and as the sole substitute font for ZapfDingbats (covering its dingbat glyph + repertoire, for example `a1` through the arrow/star/circled-digit dingbats). + +All 3 files are licensed under the same [SIL Open Font License, Version 1.1](https://openfontlicense.org/) +as the 12 Liberation files above - see `NotoFonts-OFL.txt` alongside them in this folder (its +copyright header separately attributes all three source sub-projects). This same text is also +packed into the NuGet package's `licenses\NotoFonts-OFL.txt` (see the +`DemaConsulting.CanvasNet.csproj` `Pack="true"` entry), exactly mirroring the Liberation fonts' +own `licenses\LiberationFonts-OFL.txt` entry. + +`PdfDocument`'s glyph-lookup priority order for a `Symbol` `/BaseFont` tries +`NotoSans-Regular.ttf` first, then `NotoSansMath-Regular.ttf`, then +`NotoSansSymbols2-Regular.ttf` (the first font whose glyph cmap covers a given codepoint wins); +`ZapfDingbats` uses only `NotoSansSymbols2-Regular.ttf`. This is a documented, accepted fidelity +limitation, not full glyph coverage: of Symbol's 163 distinct mapped codepoints, 161 are covered +by this 3-font union (only U+2329/U+232A are not); of ZapfDingbats' 202 distinct mapped +codepoints, 158 are covered (the circled-digit Dingbats U+2460-U+2469/U+2776-U+2793 and +U+271D/U+271E/U+271F/U+2721 are not). + +`BundledNotoFontsTests.cs` (in `test/DemaConsulting.CanvasNet.Tests/Fonts/`) dogfoods all 3 of +these files through `Fonts.TrueTypeFont.Load`, exactly like `BundledLiberationFontsTests.cs` does +for the 12 Liberation files above. diff --git a/src/DemaConsulting.CanvasNet/Fonts/CffCharstringInterpreter.cs b/src/DemaConsulting.CanvasNet/Fonts/CffCharstringInterpreter.cs new file mode 100644 index 00000000..87040e99 --- /dev/null +++ b/src/DemaConsulting.CanvasNet/Fonts/CffCharstringInterpreter.cs @@ -0,0 +1,1032 @@ +// 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 CFF charstring charstrings subr subrs +// cspell:ignore hintmask cntrmask rmoveto hmoveto vmoveto rlineto hlineto vlineto +// cspell:ignore rrcurveto hhcurveto vvcurveto hvcurveto vhcurveto callsubr callgsubr +// cspell:ignore endchar seac hstem vstem hstemhm vstemhm rcurveline rlinecurve bchar achar adx ady +// cspell:ignore hflex flex1 hflex1 +using System.Numerics; +using DemaConsulting.CanvasNet.Geometry; +using Path = DemaConsulting.CanvasNet.Geometry.Path; + +namespace DemaConsulting.CanvasNet.Fonts; + +/// +/// Executes a single glyph's Type 2 charstring bytecode (as defined by the CFF/Type 2 +/// Charstring Format specification) into geometry, alongside the glyph's +/// resolved advance width. +/// +/// +/// +/// Supports exactly this operator set: stem hints (hstem/vstem/hstemhm/ +/// vstemhm), hint masks (hintmask/cntrmask), moves (rmoveto/ +/// hmoveto/vmoveto), lines (rlineto/hlineto/vlineto), +/// curves (rrcurveto/hhcurveto/vvcurveto/hvcurveto/vhcurveto), +/// combined curve-and-line sequences (rcurveline/rlinecurve), the flex shortcuts +/// (hflex/flex/hflex1/flex1, escape operators 12 34/12 +/// 35/12 36/12 37), the deprecated no-op dotsection (escape operator +/// 12 0), subroutine calls (callsubr/callgsubr/return), and +/// endchar - including its deprecated 4-operand seac-style accent composition +/// form (adx ady bchar achar endchar), which this class composes when a +/// resolveStandardEncodedGlyph callback is supplied to +/// (see 's remarks). Every other two-byte escape operator - arithmetic/ +/// logical and other Type 1 heritage operators - is unsupported and rejected with +/// rather than silently ignored or mis-decoded. +/// +/// +/// Stem hints only affect hint execution (out of scope for this library, which never rasterizes +/// via hinting) - their operands are consumed purely to keep the operand stack correctly +/// positioned and to compute the correct hintmask/cntrmask byte count from the +/// running total of declared stem hints (including any implicit trailing vstem group +/// immediately preceding a mask operator). +/// +/// +/// Per the Type 2 Charstring Format specification's own width convention, a glyph's charstring +/// may carry one extra leading operand - on whichever stack-clearing operator executes first +/// (a stem-hint operator, a move operator, or endchar) - encoding its advance width as +/// a delta from the font's Private DICT nominalWidthX operand; if no such extra operand +/// is present, the glyph's width is instead the Private DICT's defaultWidthX operand. +/// resolves this directly, returning the glyph's final width alongside +/// its outline, so callers (see ) never need to +/// replicate this convention themselves. +/// +/// +/// callsubr/callgsubr nesting is bounded by , and the +/// total number of charstring tokens (operands and operators, across every subroutine call +/// within one invocation) is bounded by - +/// mirroring 's depth/total-count precedent for bounding malicious +/// or pathological font data deterministically rather than by a timer. +/// +/// +/// The deprecated seac-style 4-operand form of endchar (adx ady bchar achar +/// endchar) composes a "base" glyph and an "accent" glyph - both identified by Adobe +/// StandardEncoding code, resolved to a glyph name via and +/// then to a glyph index in the same font - into one outline: the accent's own +/// outline is translated by (adx, ady) () and combined with +/// the base's own untranslated outline. This class has no knowledge of any font's charset or +/// glyph-name resolution, so it cannot perform that resolution itself; instead, +/// accepts an optional resolveStandardEncodedGlyph +/// callback mapping a StandardEncoding code to that code's resolved glyph's own already- +/// decoded outline. When this callback is - including, deliberately, +/// when recursively decodes a seac component's own charstring (see +/// 's remarks) - a seac-style endchar is rejected +/// with exactly as before, which is what naturally rejects +/// a doubly-nested seac composition without needing a separate depth counter: the component +/// decode's own resolver argument is simply never supplied. +/// +/// +internal static class CffCharstringInterpreter +{ + /// + /// The maximum callsubr/callgsubr nesting depth permitted before + /// is thrown. + /// + private const int MaxCallDepth = 10; + + /// + /// The maximum total number of charstring tokens (numeric operands and operators) + /// permitted across a single call, including every subroutine + /// invoked - bounding pathological/malicious subroutine call patterns deterministically. + /// + private const int MaxOperatorSteps = 200_000; + + /// + /// The maximum operand stack depth permitted before is + /// thrown - generously above the Type 2 specification's nominal 48-operand limit to + /// tolerate any well-formed charstring using only this type's supported operator set. + /// + private const int MaxStackSize = 96; + + /// + /// Decodes a single glyph's Type 2 charstring bytecode into geometry + /// and its resolved advance width. + /// + /// + /// The CFF table's own byte array (see 's remarks) - every + /// Offset in , , and + /// is relative to this array's start. + /// + /// The target glyph's own charstring byte range. + /// Every global subroutine's byte range, in index order. + /// Every local subroutine's byte range, in index order. + /// + /// The font's Private DICT defaultWidthX operand (0 if absent) - the + /// glyph's resolved width when its charstring carries no leading width operand. + /// + /// + /// The font's Private DICT nominalWidthX operand (0 if absent) - added to + /// the glyph's charstring-encoded width delta when one is present. + /// + /// + /// When supplied, resolves an Adobe StandardEncoding code to that code's glyph's own + /// already-decoded outline - enabling this call's charstring to use the + /// deprecated 4-operand seac-style accent composition form of endchar (see + /// this class's remarks). When (the default), a seac-style + /// endchar is rejected with - this is also how + /// naturally rejects a doubly-nested seac composition, by omitting + /// this argument on a component glyph's own recursive call. + /// + /// + /// The decoded glyph outline ( for an empty charstring, or the + /// composed base+accent outline for a seac-style endchar) alongside the glyph's + /// resolved advance width - see this class's remarks for the exact width resolution + /// convention (a seac-style glyph's width still follows that same convention, never the + /// base/accent components' own widths). + /// + /// + /// Thrown when the charstring bytecode is malformed or truncated, uses an operand count an + /// operator does not accept, uses an unsupported operator (including any two-byte escape + /// operator), uses the deprecated 4-operand seac-style form of endchar with + /// no supplied, references an out-of-range + /// subroutine index, or exceeds the call-depth or operator-step bound. + /// + public static (Path Outline, double Width) Decode( + byte[] data, + (int Offset, int Length) charstring, + IReadOnlyList<(int Offset, int Length)> globalSubrs, + IReadOnlyList<(int Offset, int Length)> localSubrs, + double defaultWidthX = 0, + double nominalWidthX = 0, + Func? resolveStandardEncodedGlyph = null) + { + var state = new InterpreterState(data, globalSubrs, localSubrs, resolveStandardEncodedGlyph); + state.Run(charstring, 0); + state.CloseIfOpen(); + var width = state.WidthDelta.HasValue ? nominalWidthX + state.WidthDelta.Value : defaultWidthX; + return (state.SeacOutline ?? state.Builder.Build(), width); + } + + /// + /// Holds the mutable interpreter state (operand stack, current point, stem-hint count, + /// width-parsed flag, and resource-bound counters) shared across every nested + /// call for a single invocation. + /// + private sealed class InterpreterState + { + private readonly byte[] _data; + private readonly IReadOnlyList<(int Offset, int Length)> _globalSubrs; + private readonly IReadOnlyList<(int Offset, int Length)> _localSubrs; + private readonly Func? _resolveStandardEncodedGlyph; + private readonly int _globalBias; + private readonly int _localBias; + private readonly List _stack = []; + private int _stems; + private bool _widthParsed; + private double? _widthDelta; + private bool _hasOpenPath; + private bool _done; + private double _x; + private double _y; + private int _steps; + + public PathBuilder Builder { get; } = new(); + + /// + /// The composed base+accent outline produced by a seac-style endchar (see + /// ), or if this charstring + /// used the normal endchar form - in which case instead + /// builds the outline from as usual. + /// + public Path? SeacOutline { get; private set; } + + /// + /// The charstring's leading width operand's raw value, if one was present (see + /// // + /// ), or if the charstring had none + /// - in which case resolves the glyph's width to + /// defaultWidthX rather than nominalWidthX + WidthDelta. + /// + public double? WidthDelta => _widthDelta; + + public InterpreterState( + byte[] data, + IReadOnlyList<(int Offset, int Length)> globalSubrs, + IReadOnlyList<(int Offset, int Length)> localSubrs, + Func? resolveStandardEncodedGlyph) + { + _data = data; + _globalSubrs = globalSubrs; + _localSubrs = localSubrs; + _resolveStandardEncodedGlyph = resolveStandardEncodedGlyph; + _globalBias = Bias(globalSubrs.Count); + _localBias = Bias(localSubrs.Count); + } + + /// + /// Computes the subroutine index bias applied by callsubr/callgsubr, + /// per the Type 2 specification: 107 for fewer than 1240 subroutines, + /// 1131 for fewer than 33900, otherwise 32768. + /// + private static int Bias(int count) + { + if (count < 1240) + { + return 107; + } + + return count < 33900 ? 1131 : 32768; + } + + /// + /// Executes one charstring's (or subroutine's) bytecode range, dispatching every + /// numeric operand and operator until return, endchar, or the end of the + /// byte range is reached. + /// + public void Run((int Offset, int Length) range, int depth) + { + if (_done) + { + return; + } + + if (depth > MaxCallDepth) + { + throw new InvalidDataException("CFF charstring subroutine call nesting exceeds the supported depth."); + } + + var pos = range.Offset; + var end = range.Offset + range.Length; + while (pos < end && !_done) + { + Step(); + var b0 = _data[pos]; + if (b0 >= 32 || b0 == 28) + { + pos = ReadNumber(pos); + continue; + } + + if (b0 == 12) + { + if (pos + 1 >= end) + { + throw new InvalidDataException("CFF charstring escape operator is truncated."); + } + + var b1 = _data[pos + 1]; + pos += 2; + switch (b1) + { + case 0: // dotsection (deprecated no-op) + _stack.Clear(); + break; + + case 34: HandleHFlex(); break; + case 35: HandleFlex(); break; + case 36: HandleHFlex1(); break; + case 37: HandleFlex1(); break; + + default: + throw new InvalidDataException($"Unsupported CFF charstring escape operator (12 {b1})."); + } + + continue; + } + + pos++; + switch (b0) + { + case 1: + case 3: + case 18: + case 23: // hstem, vstem, hstemhm, vstemhm + HandleStems(); + break; + + case 19: + case 20: // hintmask, cntrmask + HandleStems(); + var maskBytes = (_stems + 7) / 8; + if (pos + maskBytes > end) + { + throw new InvalidDataException("CFF charstring hint mask is truncated."); + } + + pos += maskBytes; + break; + + case 21: HandleRMoveTo(); break; + case 22: HandleAxisMoveTo(isX: true); break; // hmoveto + case 4: HandleAxisMoveTo(isX: false); break; // vmoveto + case 5: HandleRLineTo(); break; + case 6: HandleAltLineTo(startHorizontal: true); break; // hlineto + case 7: HandleAltLineTo(startHorizontal: false); break; // vlineto + case 8: HandleRRCurveTo(); break; + case 24: HandleRCurveLine(); break; + case 25: HandleRLineCurve(); break; + case 26: HandleVVCurveTo(); break; + case 27: HandleHHCurveTo(); break; + case 31: HandleAltCurveTo(startHorizontal: true); break; // hvcurveto + case 30: HandleAltCurveTo(startHorizontal: false); break; // vhcurveto + case 10: CallSubr(local: true, depth); break; // callsubr + case 29: CallSubr(local: false, depth); break; // callgsubr + case 11: return; // return + case 14: // endchar + HandleEndChar(); + _done = true; + break; + + default: + throw new InvalidDataException($"Unsupported CFF charstring operator ({b0})."); + } + } + } + + /// + /// Closes the currently open subpath (if any) - called once after the outermost + /// call returns, so a charstring that never reaches endchar + /// (malformed, or simply truncated at the byte range's end) still yields a + /// well-formed closed path for whatever contour data it did produce. + /// + public void CloseIfOpen() + { + if (_hasOpenPath) + { + Builder.Close(); + _hasOpenPath = false; + } + } + + /// + /// Charges one unit against the total operator-step budget, throwing once + /// is exceeded. + /// + private void Step() + { + _steps++; + if (_steps > MaxOperatorSteps) + { + throw new InvalidDataException("CFF charstring execution exceeds the supported operator budget."); + } + } + + /// + /// Reads one Type 2 numeric operand token starting at and + /// pushes it onto the operand stack. + /// + private int ReadNumber(int pos) + { + var b0 = _data[pos]; + double value; + int consumed; + if (b0 == 28) + { + if (pos + 3 > _data.Length) + { + throw new InvalidDataException("CFF charstring numeric operand is truncated."); + } + + value = SfntContainer.ReadInt16(_data, pos + 1); + consumed = 3; + } + else if (b0 == 255) + { + if (pos + 5 > _data.Length) + { + throw new InvalidDataException("CFF charstring numeric operand is truncated."); + } + + value = SfntContainer.ReadInt32(_data, pos + 1) / 65536.0; + consumed = 5; + } + else if (b0 is >= 32 and <= 246) + { + value = b0 - 139; + consumed = 1; + } + else if (b0 is >= 247 and <= 250) + { + if (pos + 2 > _data.Length) + { + throw new InvalidDataException("CFF charstring numeric operand is truncated."); + } + + value = ((b0 - 247) * 256) + _data[pos + 1] + 108; + consumed = 2; + } + else if (b0 is >= 251 and <= 254) + { + if (pos + 2 > _data.Length) + { + throw new InvalidDataException("CFF charstring numeric operand is truncated."); + } + + value = (-(b0 - 251) * 256) - _data[pos + 1] - 108; + consumed = 2; + } + else + { + throw new InvalidDataException($"Unexpected CFF charstring byte (0x{b0:X2}) where a numeric operand or operator was expected."); + } + + if (_stack.Count >= MaxStackSize) + { + throw new InvalidDataException("CFF charstring operand stack exceeds the supported size."); + } + + _stack.Add(value); + return pos + consumed; + } + + /// + /// Consumes a leading width operand, if present, the first time any stack-clearing + /// operator executes - the Type 2 format allows a glyph's advance width to be encoded + /// as one extra leading operand on the very first such operator only. + /// + private void ConsumeOptionalWidth(int expectedCount) + { + if (_widthParsed) + { + return; + } + + if (_stack.Count == expectedCount + 1) + { + _widthDelta = _stack[0]; + _stack.RemoveAt(0); + } + + _widthParsed = true; + } + + /// + /// Handles hstem/vstem/hstemhm/vstemhm: consumes the + /// operand stack in stem-hint pairs (an odd leftover operand on the first + /// stack-clearing operator is the optional width), accumulating the running stem + /// count used by hintmask/cntrmask. + /// + private void HandleStems() + { + var count = _stack.Count; + if (!_widthParsed) + { + if (count % 2 != 0) + { + _widthDelta = _stack[0]; + _stack.RemoveAt(0); + count--; + } + + _widthParsed = true; + } + else if (count % 2 != 0) + { + throw new InvalidDataException("CFF charstring stem-hint operator has an odd operand count."); + } + + _stems += count / 2; + _stack.Clear(); + } + + private void HandleRMoveTo() + { + ConsumeOptionalWidth(2); + if (_stack.Count != 2) + { + throw new InvalidDataException("CFF charstring 'rmoveto' requires exactly two operands."); + } + + CloseIfOpen(); + _x += _stack[0]; + _y += _stack[1]; + Builder.MoveTo(CurrentPoint()); + _hasOpenPath = true; + _stack.Clear(); + } + + private void HandleAxisMoveTo(bool isX) + { + ConsumeOptionalWidth(1); + if (_stack.Count != 1) + { + throw new InvalidDataException("CFF charstring 'hmoveto'/'vmoveto' requires exactly one operand."); + } + + CloseIfOpen(); + if (isX) + { + _x += _stack[0]; + } + else + { + _y += _stack[0]; + } + + Builder.MoveTo(CurrentPoint()); + _hasOpenPath = true; + _stack.Clear(); + } + + private void HandleRLineTo() + { + if (_stack.Count == 0 || _stack.Count % 2 != 0) + { + throw new InvalidDataException("CFF charstring 'rlineto' requires a positive, even operand count."); + } + + for (var i = 0; i < _stack.Count; i += 2) + { + _x += _stack[i]; + _y += _stack[i + 1]; + Builder.LineTo(CurrentPoint()); + } + + _stack.Clear(); + } + + private void HandleAltLineTo(bool startHorizontal) + { + var horizontal = startHorizontal; + foreach (var value in _stack) + { + if (horizontal) + { + _x += value; + } + else + { + _y += value; + } + + Builder.LineTo(CurrentPoint()); + horizontal = !horizontal; + } + + _stack.Clear(); + } + + private void HandleRRCurveTo() + { + if (_stack.Count == 0 || _stack.Count % 6 != 0) + { + throw new InvalidDataException("CFF charstring 'rrcurveto' requires a positive multiple-of-six operand count."); + } + + for (var i = 0; i < _stack.Count; i += 6) + { + var c1X = _x + _stack[i]; + var c1Y = _y + _stack[i + 1]; + var c2X = c1X + _stack[i + 2]; + var c2Y = c1Y + _stack[i + 3]; + _x = c2X + _stack[i + 4]; + _y = c2Y + _stack[i + 5]; + Builder.CubicBezierTo(Pt(c1X, c1Y), Pt(c2X, c2Y), CurrentPoint()); + } + + _stack.Clear(); + } + + /// + /// Handles rcurveline: one or more rrcurveto-style curves followed by + /// exactly one trailing relative line. + /// + private void HandleRCurveLine() + { + if (_stack.Count < 8 || (_stack.Count - 2) % 6 != 0) + { + throw new InvalidDataException("CFF charstring 'rcurveline' requires a positive multiple-of-six operand count plus two trailing line operands."); + } + + var curveCount = _stack.Count - 2; + var index = 0; + while (index < curveCount) + { + var c1X = _x + _stack[index]; + var c1Y = _y + _stack[index + 1]; + var c2X = c1X + _stack[index + 2]; + var c2Y = c1Y + _stack[index + 3]; + _x = c2X + _stack[index + 4]; + _y = c2Y + _stack[index + 5]; + Builder.CubicBezierTo(Pt(c1X, c1Y), Pt(c2X, c2Y), CurrentPoint()); + index += 6; + } + + _x += _stack[index]; + _y += _stack[index + 1]; + Builder.LineTo(CurrentPoint()); + + _stack.Clear(); + } + + /// + /// Handles rlinecurve: one or more relative lines followed by exactly one + /// trailing rrcurveto-style curve. + /// + private void HandleRLineCurve() + { + if (_stack.Count < 8 || (_stack.Count - 6) % 2 != 0) + { + throw new InvalidDataException("CFF charstring 'rlinecurve' requires a positive even operand count plus six trailing curve operands."); + } + + var lineCount = _stack.Count - 6; + var index = 0; + while (index < lineCount) + { + _x += _stack[index]; + _y += _stack[index + 1]; + Builder.LineTo(CurrentPoint()); + index += 2; + } + + var c1X = _x + _stack[index]; + var c1Y = _y + _stack[index + 1]; + var c2X = c1X + _stack[index + 2]; + var c2Y = c1Y + _stack[index + 3]; + _x = c2X + _stack[index + 4]; + _y = c2Y + _stack[index + 5]; + Builder.CubicBezierTo(Pt(c1X, c1Y), Pt(c2X, c2Y), CurrentPoint()); + + _stack.Clear(); + } + + /// + /// Handles hflex: a flex shortcut whose two curves both start and end with a + /// horizontal-only first/last delta, and whose second curve's control-point vertical + /// offset mirrors the first curve's to net the overall vertical displacement to zero. + /// + private void HandleHFlex() + { + if (_stack.Count != 7) + { + throw new InvalidDataException("CFF charstring 'hflex' requires exactly seven operands."); + } + + var dx1 = _stack[0]; + var dx2 = _stack[1]; + var dy2 = _stack[2]; + var dx3 = _stack[3]; + var dx4 = _stack[4]; + var dx5 = _stack[5]; + var dx6 = _stack[6]; + + var c1X = _x + dx1; + var c1Y = _y; + var c2X = c1X + dx2; + var c2Y = c1Y + dy2; + var end1X = c2X + dx3; + var end1Y = c2Y; + Builder.CubicBezierTo(Pt(c1X, c1Y), Pt(c2X, c2Y), Pt(end1X, end1Y)); + + var c3X = end1X + dx4; + var c3Y = end1Y; + var c4X = c3X + dx5; + var c4Y = c3Y - dy2; + _x = c4X + dx6; + _y = c4Y; + Builder.CubicBezierTo(Pt(c3X, c3Y), Pt(c4X, c4Y), CurrentPoint()); + + _stack.Clear(); + } + + /// + /// Handles flex: two full rrcurveto-style curves followed by a trailing + /// flex-height operand that selects between a flex hint and a straight line in + /// hinted rendering (out of scope for this library, so it is simply consumed). + /// + private void HandleFlex() + { + if (_stack.Count != 13) + { + throw new InvalidDataException("CFF charstring 'flex' requires exactly thirteen operands."); + } + + var c1X = _x + _stack[0]; + var c1Y = _y + _stack[1]; + var c2X = c1X + _stack[2]; + var c2Y = c1Y + _stack[3]; + var end1X = c2X + _stack[4]; + var end1Y = c2Y + _stack[5]; + Builder.CubicBezierTo(Pt(c1X, c1Y), Pt(c2X, c2Y), Pt(end1X, end1Y)); + + var c3X = end1X + _stack[6]; + var c3Y = end1Y + _stack[7]; + var c4X = c3X + _stack[8]; + var c4Y = c3Y + _stack[9]; + _x = c4X + _stack[10]; + _y = c4Y + _stack[11]; + Builder.CubicBezierTo(Pt(c3X, c3Y), Pt(c4X, c4Y), CurrentPoint()); + + _stack.Clear(); + } + + /// + /// Handles hflex1: a flex shortcut whose final curve's end point is forced + /// back onto the subpath's vertical position at the start of the operator, regardless + /// of the accumulated vertical deltas. + /// + private void HandleHFlex1() + { + if (_stack.Count != 9) + { + throw new InvalidDataException("CFF charstring 'hflex1' requires exactly nine operands."); + } + + var originalStartY = _y; + + var c1X = _x + _stack[0]; + var c1Y = _y + _stack[1]; + var c2X = c1X + _stack[2]; + var c2Y = c1Y + _stack[3]; + var end1X = c2X + _stack[4]; + var end1Y = c2Y; + Builder.CubicBezierTo(Pt(c1X, c1Y), Pt(c2X, c2Y), Pt(end1X, end1Y)); + + var c3X = end1X + _stack[5]; + var c3Y = end1Y; + var c4X = c3X + _stack[6]; + var c4Y = c3Y + _stack[7]; + _x = c4X + _stack[8]; + _y = originalStartY; + Builder.CubicBezierTo(Pt(c3X, c3Y), Pt(c4X, c4Y), CurrentPoint()); + + _stack.Clear(); + } + + /// + /// Handles flex1: a flex shortcut whose final curve's end point is forced back + /// onto the subpath's starting position along whichever axis (horizontal or vertical) + /// accumulated the smaller total displacement across the operator's first ten + /// operands, with the eleventh operand supplying the larger axis's final delta. + /// + private void HandleFlex1() + { + if (_stack.Count != 11) + { + throw new InvalidDataException("CFF charstring 'flex1' requires exactly eleven operands."); + } + + var originalStartX = _x; + var originalStartY = _y; + + var c1X = _x + _stack[0]; + var c1Y = _y + _stack[1]; + var c2X = c1X + _stack[2]; + var c2Y = c1Y + _stack[3]; + var end1X = c2X + _stack[4]; + var end1Y = c2Y + _stack[5]; + Builder.CubicBezierTo(Pt(c1X, c1Y), Pt(c2X, c2Y), Pt(end1X, end1Y)); + + var c3X = end1X + _stack[6]; + var c3Y = end1Y + _stack[7]; + var c4X = c3X + _stack[8]; + var c4Y = c3Y + _stack[9]; + + var dx = _stack[0] + _stack[2] + _stack[4] + _stack[6] + _stack[8]; + var dy = _stack[1] + _stack[3] + _stack[5] + _stack[7] + _stack[9]; + if (Math.Abs(dx) > Math.Abs(dy)) + { + _x = c4X + _stack[10]; + _y = originalStartY; + } + else + { + _x = originalStartX; + _y = c4Y + _stack[10]; + } + + Builder.CubicBezierTo(Pt(c3X, c3Y), Pt(c4X, c4Y), CurrentPoint()); + + _stack.Clear(); + } + + private void HandleVVCurveTo() + { + var index = 0; + double dx1 = 0; + var remaining = _stack.Count; + if (remaining % 4 == 1) + { + dx1 = _stack[0]; + index = 1; + remaining--; + } + + if (remaining == 0 || remaining % 4 != 0) + { + throw new InvalidDataException("CFF charstring 'vvcurveto' has an unexpected operand count."); + } + + var first = true; + while (index < _stack.Count) + { + var c1X = _x + (first ? dx1 : 0); + var c1Y = _y + _stack[index]; + var c2X = c1X + _stack[index + 1]; + var c2Y = c1Y + _stack[index + 2]; + _x = c2X; + _y = c2Y + _stack[index + 3]; + Builder.CubicBezierTo(Pt(c1X, c1Y), Pt(c2X, c2Y), CurrentPoint()); + index += 4; + first = false; + } + + _stack.Clear(); + } + + private void HandleHHCurveTo() + { + var index = 0; + double dy1 = 0; + var remaining = _stack.Count; + if (remaining % 4 == 1) + { + dy1 = _stack[0]; + index = 1; + remaining--; + } + + if (remaining == 0 || remaining % 4 != 0) + { + throw new InvalidDataException("CFF charstring 'hhcurveto' has an unexpected operand count."); + } + + var first = true; + while (index < _stack.Count) + { + var c1X = _x + _stack[index]; + var c1Y = _y + (first ? dy1 : 0); + var c2X = c1X + _stack[index + 1]; + var c2Y = c1Y + _stack[index + 2]; + _x = c2X + _stack[index + 3]; + _y = c2Y; + Builder.CubicBezierTo(Pt(c1X, c1Y), Pt(c2X, c2Y), CurrentPoint()); + index += 4; + first = false; + } + + _stack.Clear(); + } + + /// + /// Handles hvcurveto/vhcurveto: a sequence of curves whose starting + /// tangent direction alternates (horizontal, vertical, horizontal, ...) each curve, + /// with the very last curve in the sequence optionally carrying one extra operand for + /// its otherwise-implicit final-axis offset. + /// + private void HandleAltCurveTo(bool startHorizontal) + { + var count = _stack.Count; + if (count < 4 || (count % 4 != 0 && count % 4 != 1)) + { + throw new InvalidDataException("CFF charstring 'hvcurveto'/'vhcurveto' has an unexpected operand count."); + } + + var horizontal = startHorizontal; + var index = 0; + while (index + 4 <= count) + { + var isLast = index + 4 == count - 1; + double c1X, c1Y, c2X, c2Y; + if (horizontal) + { + c1X = _x + _stack[index]; + c1Y = _y; + c2X = c1X + _stack[index + 1]; + c2Y = c1Y + _stack[index + 2]; + _y = c2Y + _stack[index + 3]; + _x = isLast ? c2X + _stack[index + 4] : c2X; + } + else + { + c1X = _x; + c1Y = _y + _stack[index]; + c2X = c1X + _stack[index + 1]; + c2Y = c1Y + _stack[index + 2]; + _x = c2X + _stack[index + 3]; + _y = isLast ? c2Y + _stack[index + 4] : c2Y; + } + + Builder.CubicBezierTo(Pt(c1X, c1Y), Pt(c2X, c2Y), CurrentPoint()); + horizontal = !horizontal; + index += 4; + } + + _stack.Clear(); + } + + private void CallSubr(bool local, int depth) + { + if (_stack.Count == 0) + { + throw new InvalidDataException("CFF charstring 'callsubr'/'callgsubr' is missing its subroutine index operand."); + } + + var index = (int)_stack[^1]; + _stack.RemoveAt(_stack.Count - 1); + + var subrs = local ? _localSubrs : _globalSubrs; + var bias = local ? _localBias : _globalBias; + var selected = index + bias; + if (selected < 0 || selected >= subrs.Count) + { + throw new InvalidDataException("CFF charstring subroutine index is out of range."); + } + + Run(subrs[selected], depth + 1); + } + + /// + /// Handles endchar: an optional leading width, followed by either zero + /// operands (the normal case, closing and finishing the outline built so far) or + /// exactly four operands (the deprecated seac-style accent composition form - + /// see ). + /// + private void HandleEndChar() + { + var count = _stack.Count; + if (!_widthParsed) + { + if (count == 1 || count == 5) + { + _widthDelta = _stack[0]; + _stack.RemoveAt(0); + count--; + } + + _widthParsed = true; + } + + if (count == 4) + { + HandleSeacEndChar(); + return; + } + + if (count != 0) + { + throw new InvalidDataException("CFF charstring 'endchar' has an unexpected operand count."); + } + + CloseIfOpen(); + _stack.Clear(); + } + + /// + /// Handles the deprecated 4-operand seac-style form of endchar (adx + /// ady bchar achar endchar): composes a "base" glyph (bchar) and an + /// "accent" glyph (achar) - both identified by Adobe StandardEncoding code via + /// - into , with + /// the accent translated by (adx, ady) and combined with the base's own + /// untranslated outline. + /// + /// + /// Thrown when is - + /// covering both "no resolver supplied at all" (the normal top-level rejection) and, + /// critically, "this charstring is itself a seac component being decoded without a + /// resolver" ( deliberately omits one for that recursive call), + /// which is what naturally rejects a doubly-nested seac composition. + /// + private void HandleSeacEndChar() + { + if (_resolveStandardEncodedGlyph is null) + { + throw new InvalidDataException( + "CFF charstring 'endchar' with the deprecated 4-operand seac-style accent composition form requires a StandardEncoding glyph resolver, which is not available here (either no resolver was supplied, or this charstring is itself a seac component - nested seac composition is not supported)."); + } + + var adx = _stack[0]; + var ady = _stack[1]; + var bchar = (int)_stack[2]; + var achar = (int)_stack[3]; + _stack.Clear(); + + var baseOutline = _resolveStandardEncodedGlyph(bchar); + var accentOutline = _resolveStandardEncodedGlyph(achar); + var translatedAccent = accentOutline.Transform(Matrix3x2.CreateTranslation((float)adx, (float)ady)); + + SeacOutline = CombineOutlines(baseOutline, translatedAccent); + CloseIfOpen(); + } + + /// + /// Combines two already-decoded outlines into one by concatenating + /// their lists - the seac-style composite's final outline + /// is simply the base glyph's subpaths followed by the (already translated) accent + /// glyph's subpaths, with no further transform or fill-rule adjustment needed, the + /// same subpath-list-concatenation principle applies when + /// combining a composite glyf glyph's components. + /// + private static Path CombineOutlines(Path first, Path second) + { + if (first.Subpaths.Count == 0) + { + return second; + } + + if (second.Subpaths.Count == 0) + { + return first; + } + + var combined = new List(first.Subpaths.Count + second.Subpaths.Count); + combined.AddRange(first.Subpaths); + combined.AddRange(second.Subpaths); + return new Path(combined); + } + + private Vector2 CurrentPoint() => Pt(_x, _y); + + private static Vector2 Pt(double x, double y) => new((float)x, (float)y); + } +} diff --git a/src/DemaConsulting.CanvasNet/Fonts/CffStandardEncoding.cs b/src/DemaConsulting.CanvasNet/Fonts/CffStandardEncoding.cs new file mode 100644 index 00000000..a84d1d8c --- /dev/null +++ b/src/DemaConsulting.CanvasNet/Fonts/CffStandardEncoding.cs @@ -0,0 +1,96 @@ +// cspell:ignore seac bchar achar adx ady Agrave quoteleft quoteright exclamdown +// cspell:ignore endash questiondown caron emdash ordfeminine Lslash Oslash +// cspell:ignore ordmasculine dotlessi lslash oslash germandbls + +namespace DemaConsulting.CanvasNet.Fonts; + +/// +/// The Adobe StandardEncoding table (CFF/Type 2 Charstring Format specification Appendix C; +/// equivalently, the Type 1 Font Format specification's own "StandardEncoding" appendix), +/// mapping each 8-bit character code 0-255 to its conventional PostScript glyph +/// name, for use by 's seac-style endchar accent composition +/// support (see 's remarks). +/// +/// +/// +/// This table is distinct from, and must never be confused with or merged into, +/// DemaConsulting.CanvasNet.Pdf's own PdfDocument.Fonts.cs +/// StandardEncodingTable: that table lives in a different assembly, maps each code to +/// a Unicode codepoint (for building a PDF-level code-to-glyph-index path through a +/// caller-supplied codepointToGlyphName map), and serves an entirely different +/// purpose. This table instead maps each code directly to a PostScript glyph name, for +/// resolving a seac-style endchar's bchar/achar operands against +/// this same CFF font's own charset via - +/// no Unicode codepoint is ever involved in that resolution. +/// +/// +/// Built from a compact code -> SID table (a code of 0 meaning "undefined" - +/// StandardEncoding never assigns any character code to SID 0, .notdef, so this +/// is an unambiguous sentinel) indexing directly, +/// mirroring that array's own documented Adobe Technical Note #5176 Appendix A source and +/// avoiding retyping roughly 150 glyph-name string literals by hand. +/// +/// +internal static class CffStandardEncoding +{ + /// + /// Every defined character code's Standard String ID (SID), indexing directly into + /// (for example index 65 is 34, + /// [34] = "A"); 0 marks a code + /// StandardEncoding leaves undefined (control codes, and other gaps in the 161-255 range). + /// + private static readonly int[] CodeToSid = + [ + // 0x00-0x0F + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + // 0x10-0x1F + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + // 0x20-0x2F (space .. slash) + 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, + // 0x30-0x3F (zero .. question) + 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, + // 0x40-0x4F (at .. O) + 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, + // 0x50-0x5F (P .. underscore) + 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, + // 0x60-0x6F (quoteleft .. o) + 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, + // 0x70-0x7F (p .. asciitilde, 0x7F undefined) + 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95, 0, + // 0x80-0x8F + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + // 0x90-0x9F + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + // 0xA0-0xAF (0xA0 undefined, exclamdown .. fl) + 0, 96, 97, 98, 99, 100, 101, 102, 103, 104, 105, 106, 107, 108, 109, 110, + // 0xB0-0xBF (endash .. questiondown, two gaps) + 0, 111, 112, 113, 114, 0, 115, 116, 117, 118, 119, 120, 121, 122, 0, 123, + // 0xC0-0xCF (0xC0 undefined, grave .. caron, two gaps) + 0, 124, 125, 126, 127, 128, 129, 130, 131, 0, 132, 133, 0, 134, 135, 136, + // 0xD0-0xDF (emdash, rest undefined) + 137, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + // 0xE0-0xEF (AE, ordfeminine, Lslash, Oslash, OE, ordmasculine, rest undefined) + 0, 138, 0, 139, 0, 0, 0, 0, 140, 141, 142, 143, 0, 0, 0, 0, + // 0xF0-0xFF (ae, dotlessi, lslash, oslash, oe, germandbls, rest undefined) + 0, 144, 0, 0, 0, 145, 0, 0, 146, 147, 148, 149, 0, 0, 0, 0, + ]; + + /// + /// The 256-entry code-to-glyph-name table, built by resolving each + /// entry against ( for an + /// undefined code). + /// + internal static readonly string?[] CodeToGlyphName = BuildCodeToGlyphName(); + + private static string?[] BuildCodeToGlyphName() + { + var names = new string?[CodeToSid.Length]; + for (var code = 0; code < CodeToSid.Length; code++) + { + var sid = CodeToSid[code]; + names[code] = sid == 0 ? null : CffTable.StandardStrings[sid]; + } + + return names; + } +} diff --git a/src/DemaConsulting.CanvasNet/Fonts/CffTable.cs b/src/DemaConsulting.CanvasNet/Fonts/CffTable.cs new file mode 100644 index 00000000..86598374 --- /dev/null +++ b/src/DemaConsulting.CanvasNet/Fonts/CffTable.cs @@ -0,0 +1,877 @@ +// 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 CFF charstring charstrings subr subrs +// cspell:ignore hintmask cntrmask rmoveto hmoveto vmoveto rlineto hlineto vlineto +// cspell:ignore rrcurveto hhcurveto vvcurveto hvcurveto vhcurveto callsubr callgsubr +// cspell:ignore endchar seac hstem vstem hstemhm vstemhm nominalWidthX defaultWidthX +// cspell:ignore ISOAdobe charsets psnames pstables bchar achar +// cspell:ignore quoteright exclamdown quoteleft quotedblleft guillemotleft guilsinglleft guilsinglright +// cspell:ignore endash daggerdbl periodcentered quotesinglbase quotedblbase quotedblright +// cspell:ignore guillemotright perthousand questiondown dotaccent hungarumlaut ogonek caron +// cspell:ignore emdash ordfeminine Lslash Oslash ordmasculine dotlessi lslash oslash +// cspell:ignore germandbls onesuperior logicalnot onehalf plusminus onequarter brokenbar +// cspell:ignore threequarters twosuperior threesuperior Aacute Acircumflex Adieresis Agrave +// cspell:ignore Aring Atilde Ccedilla Eacute Ecircumflex Edieresis Egrave Iacute Icircumflex +// cspell:ignore Idieresis Igrave Ntilde Oacute Ocircumflex Odieresis Ograve Otilde Scaron +// cspell:ignore Uacute Ucircumflex Udieresis Ugrave Yacute Ydieresis Zcaron aacute acircumflex +// cspell:ignore adieresis agrave aring atilde ccedilla eacute ecircumflex edieresis egrave +// cspell:ignore iacute icircumflex idieresis igrave ntilde oacute ocircumflex odieresis +// cspell:ignore ograve otilde scaron uacute ucircumflex udieresis ugrave yacute ydieresis +// cspell:ignore zcaron exclamsmall Hungarumlautsmall dollaroldstyle dollarsuperior +// cspell:ignore ampersandsmall Acutesmall parenleftsuperior parenrightsuperior twodotenleader +// cspell:ignore onedotenleader zerooldstyle oneoldstyle twooldstyle threeoldstyle fouroldstyle +// cspell:ignore fiveoldstyle sixoldstyle sevenoldstyle eightoldstyle nineoldstyle +// cspell:ignore commasuperior threequartersemdash periodsuperior questionsmall asuperior +// cspell:ignore bsuperior centsuperior dsuperior esuperior isuperior lsuperior msuperior +// cspell:ignore nsuperior osuperior rsuperior ssuperior tsuperior parenleftinferior +// cspell:ignore parenrightinferior Circumflexsmall hyphensuperior Gravesmall Asmall Bsmall +// cspell:ignore Csmall Dsmall Esmall Fsmall Gsmall Hsmall Ismall Jsmall Ksmall Lsmall Msmall +// cspell:ignore Nsmall Osmall Psmall Qsmall Rsmall Ssmall Tsmall Usmall Vsmall Wsmall Xsmall +// cspell:ignore Ysmall Zsmall colonmonetary onefitted Tildesmall exclamdownsmall centoldstyle +// cspell:ignore Lslashsmall Scaronsmall Zcaronsmall Dieresissmall Brevesmall Caronsmall +// cspell:ignore Dotaccentsmall Macronsmall figuredash hypheninferior Ogoneksmall Ringsmall +// cspell:ignore Cedillasmall questiondownsmall oneeighth threeeighths fiveeighths seveneighths +// cspell:ignore onethird twothirds zerosuperior foursuperior fivesuperior sixsuperior +// cspell:ignore sevensuperior eightsuperior ninesuperior zeroinferior oneinferior twoinferior +// cspell:ignore threeinferior fourinferior fiveinferior sixinferior seveninferior +// cspell:ignore eightinferior nineinferior centinferior dollarinferior periodinferior +// cspell:ignore commainferior Agravesmall Aacutesmall Acircumflexsmall Atildesmall +// cspell:ignore Adieresissmall Aringsmall Ccedillasmall Egravesmall Eacutesmall +// cspell:ignore Ecircumflexsmall Edieresissmall Igravesmall Iacutesmall Icircumflexsmall +// cspell:ignore Idieresissmall Ethsmall Ntildesmall Ogravesmall Oacutesmall Ocircumflexsmall +// cspell:ignore Otildesmall Odieresissmall Oslashsmall Ugravesmall Uacutesmall +// cspell:ignore Ucircumflexsmall Udieresissmall Yacutesmall Thornsmall Ydieresissmall +using System.Text; +using Path = DemaConsulting.CanvasNet.Geometry.Path; + +namespace DemaConsulting.CanvasNet.Fonts; + +/// +/// Parses a font's CFF (Compact Font Format) table - header, Name/Top DICT/String/ +/// Global Subr INDEXes, Private DICT, Local Subr INDEX, and CharStrings INDEX - and decodes +/// individual glyph outlines from Type 2 charstring bytecode into geometry +/// via . +/// +/// +/// +/// Every structural offset inside a CFF table is relative to the start of the CFF table's own +/// byte range, never the overall font file - this type therefore slices the CFF table's bytes +/// into their own private array at construction time, so every offset it subsequently reads +/// or hands to is relative to that array's start +/// (position 0), matching the format's own convention exactly. +/// +/// +/// Only non-CID-keyed CFF fonts are supported: a Top DICT declaring the ROS operator +/// (CID-keyed font identification) is rejected with rather +/// than attempting unsupported FDArray/FDSelect parsing - mirroring +/// 's existing "reject rather than silently mis-parse" posture for +/// unsupported maxp versions. A Top DICT is also required to resolve to exactly one +/// entry (the non-CID-keyed case always has exactly one). +/// +/// +/// Like , no individual glyph's charstring is decoded until +/// is called for that specific index - one corrupt glyph's +/// charstring bytecode does not prevent using every other, otherwise well-formed, glyph in +/// the font. is equally lazy (it decodes the same charstring a +/// second time, discarding the outline), deliberately trading a little redundant work for +/// keeping this "nothing decoded until asked for" posture uniform across both members. +/// +/// +/// The Top DICT's charset operator (operator 15) - which maps each glyph index +/// to a String ID (SID) identifying its PostScript glyph name - is resolved by +/// for exactly three cases: the operator is absent, or its +/// value is the predefined ID 0 (both mean the ISOAdobe/Standard charset: glyph +/// i's SID is simply i, for i in 1..GlyphCount-1); or its value is +/// greater than 2, a byte offset (relative to this table's own start) to a custom +/// charset table in format 0 (a flat array of 2-byte SIDs), format 1 (ranges of +/// (first SID: 2 bytes, nLeft: 1 byte)), or format 2 (the same as format +/// 1, but nLeft is 2 bytes). The predefined Expert (1) and ExpertSubset +/// (2) charsets are a deliberate, documented scope boundary: a glyph index is still +/// decodable via , but can never +/// resolve a glyph name against either of them, returning rather than +/// throwing (the same "absent optional data => benign negative result" contract +/// documents for its own optional tables). A custom +/// charset table in any other format byte is rejected with . +/// Every SID a resolved charset produces is itself resolved to a glyph name using either the +/// fixed Standard Strings table (SID 0-390, see ) +/// or the font's own String INDEX (SID 391 and above, indexed as SID - 391). +/// +/// +/// The Private DICT's defaultWidthX (operator 20) and nominalWidthX +/// (operator 21) operands - both 0 when no Private DICT is present, or when +/// present but the operator itself is absent, per the CFF specification's own documented +/// defaults - combine with each glyph's own charstring-encoded width delta (see +/// ) to resolve . +/// +/// +/// A glyph whose charstring uses the deprecated 4-operand seac-style form of +/// endchar is supported: both and +/// pass into +/// as its resolver callback, so +/// can compose the base/accent outline itself. +/// maps an Adobe StandardEncoding code to a glyph name via +/// , resolves that name to a glyph index in this same +/// font via , and decodes that glyph's own charstring +/// without supplying a resolver of its own - so a seac component glyph that is +/// itself (illegally) seac-style fails with rather than +/// recursing further; no separate depth counter is needed for this, since the omission is +/// structural. An undefined StandardEncoding code, or one whose glyph name is not present in +/// this font's own charset, is likewise rejected with +/// rather than silently dropping the accent or producing a partial outline. +/// +/// +internal sealed class CffTable : IGlyphOutlineSource +{ + private readonly byte[] _cffData; + private readonly (int Offset, int Length)[] _charStrings; + private readonly (int Offset, int Length)[] _globalSubrs; + private readonly (int Offset, int Length)[] _localSubrs; + private readonly double _defaultWidthX; + private readonly double _nominalWidthX; + private readonly IReadOnlyDictionary _nameToGlyphIndex; + + /// + /// The ROS Top DICT operator (escape operator 12 30), identifying a + /// CID-keyed CFF font - explicitly rejected (see this class's remarks). + /// + private const int RosOperator = 1230; + + /// + /// The CharStrings Top DICT operator, giving the offset (relative to the start of + /// the CFF table) of the CharStrings INDEX. + /// + private const int CharStringsOperator = 17; + + /// + /// The Private Top DICT operator, giving the Private DICT's (size, offset) + /// pair (offset relative to the start of the CFF table). + /// + private const int PrivateOperator = 18; + + /// + /// The Subrs Private DICT operator, giving the Local Subr INDEX offset relative to + /// the start of the Private DICT itself (not the CFF table). + /// + private const int SubrsOperator = 19; + + /// + /// The defaultWidthX Private DICT operator - see this class's remarks. + /// + private const int DefaultWidthXOperator = 20; + + /// + /// The nominalWidthX Private DICT operator - see this class's remarks. + /// + private const int NominalWidthXOperator = 21; + + /// + /// The charset Top DICT operator, giving either a predefined charset ID + /// (0/1/2) or a byte offset (relative to the start of the CFF table) + /// to a custom charset table - see this class's remarks. + /// + private const int CharsetOperator = 15; + + /// + /// The predefined ISOAdobe/Standard charset ID: glyph i's SID is i, for + /// i in 1..GlyphCount-1 - see this class's remarks. + /// + private const int IsoAdobeCharsetId = 0; + + /// + /// The predefined Expert charset ID - a deliberate, documented scope boundary (see this + /// class's remarks); glyphs using it are decodable by index but never resolvable by name. + /// + private const int ExpertCharsetId = 1; + + /// + /// The predefined ExpertSubset charset ID - see 's remarks. + /// + private const int ExpertSubsetCharsetId = 2; + + /// + /// The number of standard (predefined, universally-known) CFF/Type 2 glyph name strings - + /// SIDs 0-390. A SID of .Length or greater + /// instead indexes the font's own String INDEX, at SID - StandardStrings.Length. + /// + /// + /// Transcribed from Adobe Technical Note #5176 (The Compact Font Format Specification) + /// Appendix A, "Standard Strings" - cross-checked against FreeType's independently + /// published ft_standard_glyph_names/ft_sid_names tables + /// (src/psnames/pstables.h), itself a direct encoding of the same specification + /// appendix - used here only as a cross-check source, not copied code (these are the + /// specification's own fixed, universally-known string values, not copyrightable + /// expression). + /// + internal static readonly string[] StandardStrings = + [ + ".notdef", "space", "exclam", "quotedbl", "numbersign", "dollar", "percent", "ampersand", "quoteright", + "parenleft", "parenright", "asterisk", "plus", "comma", "hyphen", "period", "slash", "zero", "one", "two", + "three", "four", "five", "six", "seven", "eight", "nine", "colon", "semicolon", "less", "equal", "greater", + "question", "at", "A", "B", "C", "D", "E", "F", "G", "H", "I", "J", "K", "L", "M", "N", "O", "P", "Q", "R", + "S", "T", "U", "V", "W", "X", "Y", "Z", "bracketleft", "backslash", "bracketright", "asciicircum", + "underscore", "quoteleft", "a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l", "m", "n", "o", "p", + "q", "r", "s", "t", "u", "v", "w", "x", "y", "z", "braceleft", "bar", "braceright", "asciitilde", + "exclamdown", "cent", "sterling", "fraction", "yen", "florin", "section", "currency", "quotesingle", + "quotedblleft", "guillemotleft", "guilsinglleft", "guilsinglright", "fi", "fl", "endash", "dagger", + "daggerdbl", "periodcentered", "paragraph", "bullet", "quotesinglbase", "quotedblbase", "quotedblright", + "guillemotright", "ellipsis", "perthousand", "questiondown", "grave", "acute", "circumflex", "tilde", + "macron", "breve", "dotaccent", "dieresis", "ring", "cedilla", "hungarumlaut", "ogonek", "caron", "emdash", + "AE", "ordfeminine", "Lslash", "Oslash", "OE", "ordmasculine", "ae", "dotlessi", "lslash", "oslash", "oe", + "germandbls", "onesuperior", "logicalnot", "mu", "trademark", "Eth", "onehalf", "plusminus", "Thorn", + "onequarter", "divide", "brokenbar", "degree", "thorn", "threequarters", "twosuperior", "registered", + "minus", "eth", "multiply", "threesuperior", "copyright", "Aacute", "Acircumflex", "Adieresis", "Agrave", + "Aring", "Atilde", "Ccedilla", "Eacute", "Ecircumflex", "Edieresis", "Egrave", "Iacute", "Icircumflex", + "Idieresis", "Igrave", "Ntilde", "Oacute", "Ocircumflex", "Odieresis", "Ograve", "Otilde", "Scaron", + "Uacute", "Ucircumflex", "Udieresis", "Ugrave", "Yacute", "Ydieresis", "Zcaron", "aacute", "acircumflex", + "adieresis", "agrave", "aring", "atilde", "ccedilla", "eacute", "ecircumflex", "edieresis", "egrave", + "iacute", "icircumflex", "idieresis", "igrave", "ntilde", "oacute", "ocircumflex", "odieresis", "ograve", + "otilde", "scaron", "uacute", "ucircumflex", "udieresis", "ugrave", "yacute", "ydieresis", "zcaron", + "exclamsmall", "Hungarumlautsmall", "dollaroldstyle", "dollarsuperior", "ampersandsmall", "Acutesmall", + "parenleftsuperior", "parenrightsuperior", "twodotenleader", "onedotenleader", "zerooldstyle", + "oneoldstyle", "twooldstyle", "threeoldstyle", "fouroldstyle", "fiveoldstyle", "sixoldstyle", + "sevenoldstyle", "eightoldstyle", "nineoldstyle", "commasuperior", "threequartersemdash", + "periodsuperior", "questionsmall", "asuperior", "bsuperior", "centsuperior", "dsuperior", "esuperior", + "isuperior", "lsuperior", "msuperior", "nsuperior", "osuperior", "rsuperior", "ssuperior", "tsuperior", + "ff", "ffi", "ffl", "parenleftinferior", "parenrightinferior", "Circumflexsmall", "hyphensuperior", + "Gravesmall", "Asmall", "Bsmall", "Csmall", "Dsmall", "Esmall", "Fsmall", "Gsmall", "Hsmall", "Ismall", + "Jsmall", "Ksmall", "Lsmall", "Msmall", "Nsmall", "Osmall", "Psmall", "Qsmall", "Rsmall", "Ssmall", + "Tsmall", "Usmall", "Vsmall", "Wsmall", "Xsmall", "Ysmall", "Zsmall", "colonmonetary", "onefitted", + "rupiah", "Tildesmall", "exclamdownsmall", "centoldstyle", "Lslashsmall", "Scaronsmall", "Zcaronsmall", + "Dieresissmall", "Brevesmall", "Caronsmall", "Dotaccentsmall", "Macronsmall", "figuredash", + "hypheninferior", "Ogoneksmall", "Ringsmall", "Cedillasmall", "questiondownsmall", "oneeighth", + "threeeighths", "fiveeighths", "seveneighths", "onethird", "twothirds", "zerosuperior", "foursuperior", + "fivesuperior", "sixsuperior", "sevensuperior", "eightsuperior", "ninesuperior", "zeroinferior", + "oneinferior", "twoinferior", "threeinferior", "fourinferior", "fiveinferior", "sixinferior", + "seveninferior", "eightinferior", "nineinferior", "centinferior", "dollarinferior", "periodinferior", + "commainferior", "Agravesmall", "Aacutesmall", "Acircumflexsmall", "Atildesmall", "Adieresissmall", + "Aringsmall", "AEsmall", "Ccedillasmall", "Egravesmall", "Eacutesmall", "Ecircumflexsmall", + "Edieresissmall", "Igravesmall", "Iacutesmall", "Icircumflexsmall", "Idieresissmall", "Ethsmall", + "Ntildesmall", "Ogravesmall", "Oacutesmall", "Ocircumflexsmall", "Otildesmall", "Odieresissmall", + "OEsmall", "Oslashsmall", "Ugravesmall", "Uacutesmall", "Ucircumflexsmall", "Udieresissmall", + "Yacutesmall", "Thornsmall", "Ydieresissmall", "001.000", "001.001", "001.002", "001.003", "Black", + "Bold", "Book", "Light", "Medium", "Regular", "Roman", "Semibold", + ]; + + /// + /// The total number of glyphs described by the CharStrings INDEX. + /// + public int GlyphCount => _charStrings.Length; + + private CffTable( + byte[] cffData, + (int Offset, int Length)[] charStrings, + (int Offset, int Length)[] globalSubrs, + (int Offset, int Length)[] localSubrs, + double defaultWidthX, + double nominalWidthX, + IReadOnlyDictionary nameToGlyphIndex) + { + _cffData = cffData; + _charStrings = charStrings; + _globalSubrs = globalSubrs; + _localSubrs = localSubrs; + _defaultWidthX = defaultWidthX; + _nominalWidthX = nominalWidthX; + _nameToGlyphIndex = nameToGlyphIndex; + } + + /// + /// Reports whether begins with a structurally plausible bare CFF + /// header - exactly the same bounds checks itself + /// applies to its own leading header bytes (major version 1, and a declared + /// headerSize not exceeding the data's own length), but without parsing any + /// further INDEX/DICT structure. A lightweight, non-throwing shape sniff distinguishing a + /// bare (standalone) CFF program from an SFNT-wrapped one (see + /// ), used by Pdf.PdfDocument's own + /// /FontFile3 shape-sniffing dispatch. + /// + /// + /// The candidate font program bytes (the complete bare-CFF stream, not an SFNT table + /// directory entry's own sliced-out range). + /// + /// + /// if is at least 4 bytes long, its first + /// byte (the CFF header's major version) is 1, and its third byte (the header's + /// own declared headerSize) does not exceed 's length; + /// otherwise, . Deliberately shallow - a value recognized here can + /// still fail 's own, stricter validation (for + /// example a malformed INDEX/DICT, or a CID-keyed Top DICT). + /// + public static bool LooksLikeCffHeader(byte[] data) => + data.Length >= 4 && data[0] == 1 && data[2] <= data.Length; + + /// + /// Parses a font's CFF table. + /// + /// The complete font file contents. + /// The offset of the CFF table within . + /// The length of the CFF table. + /// A new ready to decode glyph outlines. + /// + /// Thrown when the CFF header, an INDEX, or a DICT is malformed or truncated, when the + /// header's major version is not 1, when the Top DICT INDEX does not resolve to + /// exactly one entry, when the Top DICT declares a CID-keyed (ROS) font, when the + /// required CharStrings operator is missing, or when the CharStrings INDEX + /// contains zero glyphs. + /// + public static CffTable Parse(byte[] data, int tableOffset, int tableLength) + { + if (tableLength < 4) + { + throw new InvalidDataException("The 'CFF ' table is too short to contain a CFF header."); + } + + var cffData = new byte[tableLength]; + Array.Copy(data, tableOffset, cffData, 0, tableLength); + + var majorVersion = cffData[0]; + if (majorVersion != 1) + { + throw new InvalidDataException($"Unsupported CFF header major version ({majorVersion})."); + } + + var headerSize = cffData[2]; + if (headerSize > cffData.Length) + { + throw new InvalidDataException("The CFF header size exceeds the table bounds."); + } + + var pos = (int)headerSize; + ParseIndex(cffData, ref pos); // Name INDEX - parsed only to skip past it; not exposed + var topDictIndex = ParseIndex(cffData, ref pos); + if (topDictIndex.Length != 1) + { + throw new InvalidDataException("CFF fonts with other than exactly one Top DICT are not supported."); + } + + var stringIndex = ParseIndex(cffData, ref pos); + var globalSubrs = ParseIndex(cffData, ref pos); + + var topDict = ParseDict(cffData, topDictIndex[0].Offset, topDictIndex[0].Length); + if (topDict.ContainsKey(RosOperator)) + { + throw new InvalidDataException("CID-keyed CFF fonts are not supported."); + } + + if (!topDict.TryGetValue(CharStringsOperator, out var charStringsOperands) || charStringsOperands.Count == 0) + { + throw new InvalidDataException("The CFF Top DICT is missing the required 'CharStrings' operator."); + } + + var localSubrs = Array.Empty<(int Offset, int Length)>(); + var defaultWidthX = 0.0; + var nominalWidthX = 0.0; + if (topDict.TryGetValue(PrivateOperator, out var privateOperands)) + { + if (privateOperands.Count != 2) + { + throw new InvalidDataException("The CFF Top DICT's 'Private' operator must have exactly two operands."); + } + + var privateSize = (int)privateOperands[0]; + var privateOffset = (int)privateOperands[1]; + if (privateSize < 0 || privateOffset < 0 || checked((long)privateOffset + privateSize) > cffData.Length) + { + throw new InvalidDataException("The CFF Private DICT offset/size exceeds the table bounds."); + } + + var privateDict = ParseDict(cffData, privateOffset, privateSize); + if (privateDict.TryGetValue(SubrsOperator, out var subrsOperands) && subrsOperands.Count > 0) + { + var localSubrPos = privateOffset + (int)subrsOperands[^1]; + localSubrs = ParseIndex(cffData, ref localSubrPos); + } + + if (privateDict.TryGetValue(DefaultWidthXOperator, out var dwxOperands) && dwxOperands.Count > 0) + { + defaultWidthX = dwxOperands[^1]; + } + + if (privateDict.TryGetValue(NominalWidthXOperator, out var nwxOperands) && nwxOperands.Count > 0) + { + nominalWidthX = nwxOperands[^1]; + } + } + + var charStringsPos = (int)charStringsOperands[^1]; + var charStrings = ParseIndex(cffData, ref charStringsPos); + if (charStrings.Length == 0) + { + throw new InvalidDataException("The CFF 'CharStrings' INDEX contains no glyphs."); + } + + var charsetSids = ParseCharset(topDict, cffData, charStrings.Length); + var nameToGlyphIndex = BuildNameToGlyphIndex(charsetSids, stringIndex, cffData); + + return new CffTable(cffData, charStrings, globalSubrs, localSubrs, defaultWidthX, nominalWidthX, nameToGlyphIndex); + } + + /// + /// Resolves the Top DICT's charset operator (operator 15) to a per-glyph + /// array of String IDs (SIDs) - see this class's remarks for the complete set of + /// supported predefined IDs and custom table formats. + /// + /// The parsed Top DICT. + /// The complete CFF table bytes. + /// The number of glyphs declared by the CharStrings INDEX. + /// + /// An array of length , where element 0 is always + /// 0 (the .notdef SID) and each other element is either the glyph's + /// resolved SID, or -1 if the glyph's name cannot be resolved (always and only for + /// the predefined Expert/ExpertSubset charsets - see this class's remarks). + /// + /// + /// Thrown when a custom charset table's byte offset is out of bounds, or its format byte + /// is not 0, 1, or 2. + /// + private static int[] ParseCharset(Dictionary> topDict, byte[] cffData, int glyphCount) + { + var sids = new int[glyphCount]; + + var charsetId = IsoAdobeCharsetId; + if (topDict.TryGetValue(CharsetOperator, out var charsetOperands) && charsetOperands.Count > 0) + { + charsetId = (int)charsetOperands[^1]; + } + + switch (charsetId) + { + case IsoAdobeCharsetId: + for (var i = 1; i < glyphCount; i++) + { + sids[i] = i; + } + + return sids; + + case ExpertCharsetId: + case ExpertSubsetCharsetId: + // Deliberate, documented scope boundary - see this class's remarks. + for (var i = 1; i < glyphCount; i++) + { + sids[i] = -1; + } + + return sids; + } + + var pos = charsetId; + EnsureAvailable(cffData, pos, 1); + var format = cffData[pos]; + pos++; + + var glyphIndex = 1; + switch (format) + { + case 0: + while (glyphIndex < glyphCount) + { + EnsureAvailable(cffData, pos, 2); + sids[glyphIndex] = SfntContainer.ReadUInt16(cffData, pos); + pos += 2; + glyphIndex++; + } + + break; + + case 1: + while (glyphIndex < glyphCount) + { + EnsureAvailable(cffData, pos, 3); + var first = SfntContainer.ReadUInt16(cffData, pos); + var nLeft = cffData[pos + 2]; + pos += 3; + for (var i = 0; i <= nLeft && glyphIndex < glyphCount; i++) + { + sids[glyphIndex] = first + i; + glyphIndex++; + } + } + + break; + + case 2: + while (glyphIndex < glyphCount) + { + EnsureAvailable(cffData, pos, 4); + var first = SfntContainer.ReadUInt16(cffData, pos); + var nLeft = SfntContainer.ReadUInt16(cffData, pos + 2); + pos += 4; + for (var i = 0; i <= nLeft && glyphIndex < glyphCount; i++) + { + sids[glyphIndex] = first + i; + glyphIndex++; + } + } + + break; + + default: + throw new InvalidDataException($"Unsupported CFF charset table format ({format})."); + } + + return sids; + } + + /// + /// Builds a map from PostScript glyph name to glyph index, by resolving each glyph's SID + /// (from ) to a name using either the fixed + /// table (SID 0-390) or + /// (SID 391 and above). A SID of -1 (see + /// ) is skipped - that glyph simply cannot be resolved by name. + /// + private static Dictionary BuildNameToGlyphIndex( + int[] charsetSids, + (int Offset, int Length)[] stringIndex, + byte[] cffData) + { + var nameToGlyphIndex = new Dictionary(); + for (var glyphIndex = 0; glyphIndex < charsetSids.Length; glyphIndex++) + { + var sid = charsetSids[glyphIndex]; + if (sid < 0) + { + continue; + } + + string name; + if (sid < StandardStrings.Length) + { + name = StandardStrings[sid]; + } + else + { + var customIndex = sid - StandardStrings.Length; + if (customIndex >= stringIndex.Length) + { + continue; + } + + var entry = stringIndex[customIndex]; + name = Encoding.Latin1.GetString(cffData, entry.Offset, entry.Length); + } + + nameToGlyphIndex.TryAdd(name, glyphIndex); + } + + return nameToGlyphIndex; + } + + /// + /// Attempts to resolve a PostScript glyph name to its glyph index, using the Top DICT's + /// charset operator - see this class's remarks for the complete set of supported + /// predefined IDs and custom table formats. + /// + /// The PostScript glyph name to resolve (for example A). + /// The resolved glyph index, or 0 if not found. + /// + /// if was resolved; otherwise + /// (including, always, for any glyph whose charset is the + /// predefined Expert or ExpertSubset charset - see this class's remarks). + /// + internal bool TryGetGlyphIndex(string glyphName, out int glyphIndex) => + _nameToGlyphIndex.TryGetValue(glyphName, out glyphIndex); + + /// + /// Resolves a glyph's advance width, decoding its Type 2 charstring (see + /// ) a second time - deliberately, to keep this + /// table's "nothing decoded until asked for" laziness uniform with + /// - and combining its charstring-encoded width delta (if + /// any) with the Private DICT's defaultWidthX/nominalWidthX operands. + /// + /// The glyph index to resolve. + /// The glyph's advance width, in raw font design units. + /// + /// Thrown when is outside [0, GlyphCount). + /// + /// + /// Thrown when the glyph's charstring bytecode is malformed, truncated, uses an + /// unsupported operator, or exceeds an internal recursion/step bound; or, for a seac-style + /// glyph, when a StandardEncoding code is undefined, its glyph name is absent from this + /// font's own charset, or the resolved component glyph is itself (illegally) seac-style. + /// See and for + /// the complete set of rejection conditions. + /// + internal double GetAdvanceWidth(int glyphIndex) + { + if (glyphIndex < 0 || glyphIndex >= GlyphCount) + { + throw new ArgumentOutOfRangeException(nameof(glyphIndex), glyphIndex, "Glyph index is out of range."); + } + + return CffCharstringInterpreter.Decode( + _cffData, _charStrings[glyphIndex], _globalSubrs, _localSubrs, _defaultWidthX, _nominalWidthX, + ResolveSeacComponent).Width; + } + + /// + /// Decodes a single glyph's outline from Type 2 charstring bytecode. + /// + /// 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), or the composed base+accent outline + /// for a seac-style glyph (see this class's remarks). + /// + /// + /// Thrown when is outside [0, GlyphCount). + /// + /// + /// Thrown when the glyph's charstring bytecode is malformed, truncated, uses an + /// unsupported operator, or exceeds an internal recursion/step bound; or, for a seac-style + /// glyph, when a StandardEncoding code is undefined, its glyph name is absent from this + /// font's own charset, or the resolved component glyph is itself (illegally) seac-style. + /// See and for + /// the complete set of rejection conditions. + /// + public Path GetGlyphOutline(int glyphIndex) + { + if (glyphIndex < 0 || glyphIndex >= GlyphCount) + { + throw new ArgumentOutOfRangeException(nameof(glyphIndex), glyphIndex, "Glyph index is out of range."); + } + + return CffCharstringInterpreter.Decode( + _cffData, _charStrings[glyphIndex], _globalSubrs, _localSubrs, _defaultWidthX, _nominalWidthX, + ResolveSeacComponent).Outline; + } + + /// + /// Resolves a seac-style endchar's bchar/achar operand (an Adobe + /// StandardEncoding code) to that code's glyph's own already-decoded outline, for use as + /// 's resolveStandardEncodedGlyph + /// callback from both and . + /// + /// The Adobe StandardEncoding code (0-255). + /// The resolved component glyph's own decoded outline. + /// + /// Thrown when is outside 0-255 or + /// has no defined glyph name, when that glyph name is + /// not present in this font's own charset ( returns + /// ), or when the resolved component glyph's own charstring is + /// malformed, unsupported, or is itself (illegally) a seac-style endchar - this + /// method deliberately omits a resolver on its own recursive + /// call, so a doubly-nested seac composition + /// fails here rather than recursing further. + /// + private Path ResolveSeacComponent(int standardEncodingCode) + { + if (standardEncodingCode < 0 || standardEncodingCode >= CffStandardEncoding.CodeToGlyphName.Length) + { + throw new InvalidDataException( + $"CFF seac-style 'endchar' references an out-of-range StandardEncoding code ({standardEncodingCode})."); + } + + var glyphName = CffStandardEncoding.CodeToGlyphName[standardEncodingCode]; + if (glyphName is null) + { + throw new InvalidDataException( + $"CFF seac-style 'endchar' references a StandardEncoding code ({standardEncodingCode}) with no defined glyph name."); + } + + if (!TryGetGlyphIndex(glyphName, out var glyphIndex)) + { + throw new InvalidDataException( + $"CFF seac-style 'endchar' references glyph '{glyphName}' (StandardEncoding code {standardEncodingCode}), which is not present in this font's charset."); + } + + return CffCharstringInterpreter.Decode( + _cffData, _charStrings[glyphIndex], _globalSubrs, _localSubrs, _defaultWidthX, _nominalWidthX).Outline; + } + + /// + /// Parses a CFF INDEX structure starting at , returning every + /// entry's absolute (Offset, Length) within and advancing + /// to just past the INDEX. + /// + /// + /// Thrown when the INDEX's count/offset-size header, offset array, or object data region + /// is truncated, declares an unsupported offset size, or contains non-monotonically + /// non-decreasing offsets. + /// + private static (int Offset, int Length)[] ParseIndex(byte[] data, ref int pos) + { + EnsureAvailable(data, pos, 2); + var count = SfntContainer.ReadUInt16(data, pos); + pos += 2; + if (count == 0) + { + return []; + } + + EnsureAvailable(data, pos, 1); + var offSize = data[pos]; + pos += 1; + if (offSize is < 1 or > 4) + { + throw new InvalidDataException($"CFF INDEX declares an unsupported offset size ({offSize})."); + } + + var offsetsCount = count + 1; + EnsureAvailable(data, pos, offsetsCount * offSize); + var offsets = new int[offsetsCount]; + for (var i = 0; i < offsetsCount; i++) + { + long value = 0; + for (var b = 0; b < offSize; b++) + { + value = (value << 8) | data[pos + i * offSize + b]; + } + + offsets[i] = (int)value; + } + + pos += offsetsCount * offSize; + var dataStart = pos; + + var result = new (int Offset, int Length)[count]; + for (var i = 0; i < count; i++) + { + var start = offsets[i] - 1; + var end = offsets[i + 1] - 1; + if (start < 0 || end < start) + { + throw new InvalidDataException("CFF INDEX offsets are not monotonically non-decreasing."); + } + + var absoluteStart = dataStart + start; + var length = end - start; + EnsureAvailable(data, absoluteStart, length); + result[i] = (absoluteStart, length); + } + + pos = dataStart + (offsets[^1] - 1); + return result; + } + + /// + /// Parses a CFF DICT (Top DICT or Private DICT) into a map from operator code to its + /// operand list. Two-byte escape operators (12 XX) are keyed as 1200 + XX. + /// Real-number operands are consumed (so parsing stays correctly positioned) but decoded + /// as 0 rather than interpreted. Every operator this type reads whose value could + /// be meaningfully non-zero (CharStrings, Private, Subrs, + /// ROS, charset) is always encoded as an integer in practice, never a real + /// number, per the CFF specification. defaultWidthX/nominalWidthX are the + /// one documented exception: the specification permits (but no known font producer uses) + /// a real-number encoding for either, which this method would silently decode as 0 + /// - a deliberate, narrow scope boundary rather than a silent correctness risk in + /// practice. + /// + /// + /// Thrown when an operand or escape operator is truncated, or a reserved/invalid DICT byte + /// is encountered. + /// + private static Dictionary> ParseDict(byte[] data, int offset, int length) + { + var dict = new Dictionary>(); + var operands = new List(); + var end = offset + length; + if (offset < 0 || length < 0 || end > data.Length) + { + throw new InvalidDataException("CFF DICT data exceeds the table bounds."); + } + + var pos = offset; + while (pos < end) + { + var b0 = data[pos]; + if (b0 <= 21) + { + int op = b0; + pos++; + if (b0 == 12) + { + if (pos >= end) + { + throw new InvalidDataException("CFF DICT escape operator is truncated."); + } + + op = 1200 + data[pos]; + pos++; + } + + dict[op] = [.. operands]; + operands.Clear(); + } + else if (b0 == 28) + { + if (pos + 3 > end) + { + throw new InvalidDataException("CFF DICT operand is truncated."); + } + + operands.Add(SfntContainer.ReadInt16(data, pos + 1)); + pos += 3; + } + else if (b0 == 29) + { + if (pos + 5 > end) + { + throw new InvalidDataException("CFF DICT operand is truncated."); + } + + operands.Add(SfntContainer.ReadInt32(data, pos + 1)); + pos += 5; + } + else if (b0 == 30) + { + pos++; + var terminated = false; + while (pos < end && !terminated) + { + var nibbles = data[pos]; + pos++; + if ((nibbles >> 4) == 0xF || (nibbles & 0xF) == 0xF) + { + terminated = true; + } + } + + operands.Add(0); // Real-number value is never interpreted; see this method's summary + } + else if (b0 is >= 32 and <= 246) + { + operands.Add(b0 - 139); + pos++; + } + else if (b0 is >= 247 and <= 250) + { + if (pos + 2 > end) + { + throw new InvalidDataException("CFF DICT operand is truncated."); + } + + operands.Add(((b0 - 247) * 256) + data[pos + 1] + 108); + pos += 2; + } + else if (b0 is >= 251 and <= 254) + { + if (pos + 2 > end) + { + throw new InvalidDataException("CFF DICT operand is truncated."); + } + + operands.Add((-(b0 - 251) * 256) - data[pos + 1] - 108); + pos += 2; + } + else + { + throw new InvalidDataException($"CFF DICT contains a reserved/invalid byte (0x{b0:X2})."); + } + } + + return dict; + } + + /// + /// Validates that bytes starting at lie + /// within 's bounds, throwing otherwise. + /// + private static void EnsureAvailable(byte[] data, int pos, int length) + { + if (pos < 0 || length < 0 || checked((long)pos + length) > data.Length) + { + throw new InvalidDataException("CFF data is truncated."); + } + } +} diff --git a/src/DemaConsulting.CanvasNet/Fonts/CmapTable.cs b/src/DemaConsulting.CanvasNet/Fonts/CmapTable.cs index ff274c68..7feebdff 100644 --- a/src/DemaConsulting.CanvasNet/Fonts/CmapTable.cs +++ b/src/DemaConsulting.CanvasNet/Fonts/CmapTable.cs @@ -54,6 +54,27 @@ private CmapTable(Func? lookup) _lookup = lookup; } + /// + /// Builds a directly from an in-memory codepoint-to-glyph-index + /// map, bypassing 's binary cmap table format entirely. + /// + /// + /// Used for font formats with no cmap table of their own - a classic PostScript + /// Type 1 font program instead associates glyphs with names, and the caller (or, for a + /// standalone .pfb/.pfa load, ) supplies + /// a codepoint-to-name encoding that gets resolved to glyph indices ahead of time. + /// + /// The codepoint-to-glyph-index map to wrap. + /// + /// A whose returns the mapped glyph + /// index for a known codepoint, or 0 (.notdef) for any other codepoint. + /// + internal static CmapTable FromMap(IReadOnlyDictionary codepointToGlyphIndex) + { + ArgumentNullException.ThrowIfNull(codepointToGlyphIndex); + return new CmapTable(codepoint => codepointToGlyphIndex.GetValueOrDefault(codepoint, 0)); + } + /// /// Parses a cmap table and selects the highest-priority supported subtable. /// diff --git a/src/DemaConsulting.CanvasNet/Fonts/GlyfLocaReader.cs b/src/DemaConsulting.CanvasNet/Fonts/GlyfLocaReader.cs index 51cc8386..a28a7a4e 100644 --- a/src/DemaConsulting.CanvasNet/Fonts/GlyfLocaReader.cs +++ b/src/DemaConsulting.CanvasNet/Fonts/GlyfLocaReader.cs @@ -43,7 +43,7 @@ namespace DemaConsulting.CanvasNet.Fonts; /// is effective against that amplification even though the component-count cap is not. /// /// -internal sealed class GlyfLocaReader +internal sealed class GlyfLocaReader : IGlyphOutlineSource { /// /// The maximum composite glyph nesting depth permitted before diff --git a/src/DemaConsulting.CanvasNet/Fonts/HmtxHheaReader.cs b/src/DemaConsulting.CanvasNet/Fonts/HmtxHheaReader.cs index dac350a2..b0834395 100644 --- a/src/DemaConsulting.CanvasNet/Fonts/HmtxHheaReader.cs +++ b/src/DemaConsulting.CanvasNet/Fonts/HmtxHheaReader.cs @@ -9,10 +9,13 @@ namespace DemaConsulting.CanvasNet.Fonts; /// /// /// hmtx's array holds numOfLongHorMetrics (from hhea) -/// (advanceWidth, leftSideBearing) pairs, followed by +/// (advanceWidth, leftSideBearing) pairs, optionally 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. +/// entry, per the OpenType hmtx "monospaced tail" convention. This reader never reads +/// any byte of that trailing left-side-bearing-only tail - it is spec-optional reuse data, +/// not consumed data - so its complete omission (a shape produced by some real-world font +/// subsetting tools) is tolerated rather than rejected. /// internal sealed class HmtxHheaReader { @@ -57,13 +60,19 @@ private HmtxHheaReader(int ascender, int descender, int lineGap, int[] advanceWi /// 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 length of the hmtx table. Only the leading numOfLongHorMetrics * 4 + /// bytes (the explicit (advanceWidth, leftSideBearing) pairs) are required to be + /// present and are ever read; any trailing left-side-bearing-only tail is optional and, + /// if present, is not inspected. + /// /// 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. + /// range (0, numGlyphs], or when hmtx is too short to contain the + /// numOfLongHorMetrics explicit (advanceWidth, leftSideBearing) pairs + /// actually read (the optional trailing left-side-bearing-only tail is never required). /// public static HmtxHheaReader Parse(byte[] data, int hheaOffset, int hheaLength, int hmtxOffset, int hmtxLength, int numGlyphs) { @@ -83,8 +92,7 @@ public static HmtxHheaReader Parse(byte[] data, int hheaOffset, int hheaLength, $"'hhea.numOfLongHorMetrics' ({numOfLongHorMetrics}) must be in the range (0, {numGlyphs}]."); } - var tailCount = numGlyphs - numOfLongHorMetrics; - var requiredBytes = checked((long)numOfLongHorMetrics * 4 + (long)tailCount * 2); + var requiredBytes = checked((long)numOfLongHorMetrics * 4); if (requiredBytes > hmtxLength) { throw new InvalidDataException("The 'hmtx' table is too short for its declared metric counts."); @@ -99,6 +107,27 @@ public static HmtxHheaReader Parse(byte[] data, int hheaOffset, int hheaLength, return new HmtxHheaReader(ascender, descender, lineGap, advanceWidths); } + /// + /// Builds an directly from an already-resolved per-glyph + /// advance-width array, bypassing 's binary hhea/hmtx + /// table format entirely. + /// + /// + /// Used for font formats with no hhea/hmtx tables of their own - a classic + /// PostScript Type 1 font program instead declares each glyph's own advance width via its + /// charstring's hsbw/sbw operator (see ). + /// + /// The typographic ascender. + /// The typographic descender. + /// The typographic line gap. + /// Every glyph's advance width, indexed by glyph index. + /// A new exposing the supplied metrics. + internal static HmtxHheaReader FromAdvanceWidths(int ascender, int descender, int lineGap, int[] advanceWidths) + { + ArgumentNullException.ThrowIfNull(advanceWidths); + return new HmtxHheaReader(ascender, descender, lineGap, advanceWidths); + } + /// /// Looks up a glyph's advance width. /// diff --git a/src/DemaConsulting.CanvasNet/Fonts/IGlyphOutlineSource.cs b/src/DemaConsulting.CanvasNet/Fonts/IGlyphOutlineSource.cs new file mode 100644 index 00000000..0bc69f36 --- /dev/null +++ b/src/DemaConsulting.CanvasNet/Fonts/IGlyphOutlineSource.cs @@ -0,0 +1,37 @@ +// 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 Path = DemaConsulting.CanvasNet.Geometry.Path; + +namespace DemaConsulting.CanvasNet.Fonts; + +/// +/// Common contract implemented by every glyph outline decoding backend +/// ( for TrueType glyf outlines, for +/// CFF outlines), letting dispatch +/// without a type-check against which outline flavor the loaded font actually uses. +/// +internal interface IGlyphOutlineSource +{ + /// + /// The total number of glyphs this outline source can decode. + /// + int GlyphCount { get; } + + /// + /// 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. + /// + /// + /// Thrown when is outside [0, GlyphCount). + /// + /// + /// Thrown when the glyph's outline data is malformed, truncated, or exceeds an internal + /// resource bound. + /// + Path GetGlyphOutline(int glyphIndex); +} diff --git a/src/DemaConsulting.CanvasNet/Fonts/NameTable.cs b/src/DemaConsulting.CanvasNet/Fonts/NameTable.cs new file mode 100644 index 00000000..0d89bf11 --- /dev/null +++ b/src/DemaConsulting.CanvasNet/Fonts/NameTable.cs @@ -0,0 +1,311 @@ +// 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 langTagRecords nameID +using System.Text; + +namespace DemaConsulting.CanvasNet.Fonts; + +/// +/// Parses a font's name table and resolves the family, subfamily, full, and PostScript +/// name strings, preferring the typographic name identifiers (16/17) over the +/// standard ones (1/2) and preferring a Windows/Unicode BMP platform record over +/// a Macintosh platform record when both are present for the same name identifier. +/// +/// +/// +/// Only two platform/encoding combinations are decoded: Windows (platformID 3) with +/// encoding 1 (Unicode BMP) or 10 (Unicode full repertoire), both decoded as +/// big-endian UTF-16, and Macintosh (platformID 1) with encoding 0 (Roman), +/// decoded one byte per character via a fixed Mac OS Roman lookup table for bytes +/// >= 0x80. Any other platform/encoding combination is ignored - not decoded, not +/// preferred, not used as a fallback. +/// +/// +/// name is an optional table: this class never throws. A table too short to contain +/// its 6-byte header, or whose declared record count does not fit the table's bounds, is +/// treated as . Otherwise, each of the table's individual 12-byte name +/// records is validated independently - a record whose string bytes would fall outside the +/// table's bounds, or whose platform/encoding pair is not one of the two decoded +/// combinations, is silently skipped without discarding any other, well-formed record. +/// +/// +internal sealed class NameTable +{ + /// + /// The Windows platform ID (platformID == 3). + /// + private const int PlatformWindows = 3; + + /// + /// The Macintosh platform ID (platformID == 1). + /// + private const int PlatformMacintosh = 1; + + /// + /// The Macintosh Roman encoding ID (encodingID == 0), used with + /// . + /// + private const int EncodingMacRoman = 0; + + /// + /// The Windows Unicode BMP encoding ID (encodingID == 1), used with + /// . + /// + private const int EncodingWindowsUnicodeBmp = 1; + + /// + /// The Windows Unicode full-repertoire encoding ID (encodingID == 10), used with + /// . + /// + private const int EncodingWindowsUnicodeFull = 10; + + /// + /// The Windows US-English language ID (0x0409), preferred among multiple Windows + /// platform records for the same name identifier. + /// + private const int LanguageWindowsUsEnglish = 0x0409; + + /// + /// The Macintosh English language ID (0), preferred among multiple Macintosh + /// platform records for the same name identifier. + /// + private const int LanguageMacintoshEnglish = 0; + + /// + /// The typographic family name identifier (nameID 16). + /// + private const int NameIdTypographicFamily = 16; + + /// + /// The typographic subfamily name identifier (nameID 17). + /// + private const int NameIdTypographicSubfamily = 17; + + /// + /// The standard family name identifier (nameID 1). + /// + private const int NameIdFamily = 1; + + /// + /// The standard subfamily name identifier (nameID 2). + /// + private const int NameIdSubfamily = 2; + + /// + /// The full font name identifier (nameID 4). + /// + private const int NameIdFull = 4; + + /// + /// The PostScript name identifier (nameID 6). + /// + private const int NameIdPostScript = 6; + + /// + /// Maps Mac OS Roman bytes 0x80-0xFF (index byte - 0x80) to their + /// Unicode codepoint, per the Unicode Consortium's published MACROMAN.TXT/Apple + /// ROMAN.TXT mapping. Bytes below 0x80 never consult this table: Mac Roman + /// is ASCII-identical in that range. + /// + private static readonly char[] MacRomanHighBytes = + [ + '\u00C4', '\u00C5', '\u00C7', '\u00C9', '\u00D1', '\u00D6', '\u00DC', '\u00E1', // 0x80-0x87 + '\u00E0', '\u00E2', '\u00E4', '\u00E3', '\u00E5', '\u00E7', '\u00E9', '\u00E8', // 0x88-0x8F + '\u00EA', '\u00EB', '\u00ED', '\u00EC', '\u00EE', '\u00EF', '\u00F1', '\u00F3', // 0x90-0x97 + '\u00F2', '\u00F4', '\u00F6', '\u00F5', '\u00FA', '\u00F9', '\u00FB', '\u00FC', // 0x98-0x9F + '\u2020', '\u00B0', '\u00A2', '\u00A3', '\u00A7', '\u2022', '\u00B6', '\u00DF', // 0xA0-0xA7 + '\u00AE', '\u00A9', '\u2122', '\u00B4', '\u00A8', '\u2260', '\u00C6', '\u00D8', // 0xA8-0xAF + '\u221E', '\u00B1', '\u2264', '\u2265', '\u00A5', '\u00B5', '\u2202', '\u2211', // 0xB0-0xB7 + '\u220F', '\u03C0', '\u222B', '\u00AA', '\u00BA', '\u03A9', '\u00E6', '\u00F8', // 0xB8-0xBF + '\u00BF', '\u00A1', '\u00AC', '\u221A', '\u0192', '\u2248', '\u2206', '\u00AB', // 0xC0-0xC7 + '\u00BB', '\u2026', '\u00A0', '\u00C0', '\u00C3', '\u00D5', '\u0152', '\u0153', // 0xC8-0xCF + '\u2013', '\u2014', '\u201C', '\u201D', '\u2018', '\u2019', '\u00F7', '\u25CA', // 0xD0-0xD7 + '\u00FF', '\u0178', '\u2044', '\u20AC', '\u2039', '\u203A', '\uFB01', '\uFB02', // 0xD8-0xDF + '\u2021', '\u00B7', '\u201A', '\u201E', '\u2030', '\u00C2', '\u00CA', '\u00C1', // 0xE0-0xE7 + '\u00CB', '\u00C8', '\u00CD', '\u00CE', '\u00CF', '\u00CC', '\u00D3', '\u00D4', // 0xE8-0xEF + '\uF8FF', '\u00D2', '\u00DA', '\u00DB', '\u00D9', '\u0131', '\u02C6', '\u02DC', // 0xF0-0xF7 + '\u00AF', '\u02D8', '\u02D9', '\u02DA', '\u00B8', '\u02DD', '\u02DB', '\u02C7', // 0xF8-0xFF + ]; + + /// + /// A with every field : the font has no + /// usable name table. + /// + public static readonly NameTable Empty = new(null, null, null, null); + + /// + /// The typographic family name (nameID 16) if present; otherwise the standard + /// family name (nameID 1); otherwise . + /// + public string? FamilyName { get; } + + /// + /// The typographic subfamily name (nameID 17) if present; otherwise the standard + /// subfamily name (nameID 2); otherwise . + /// + public string? SubfamilyName { get; } + + /// + /// The full font name (nameID 4), or . + /// + public string? FullName { get; } + + /// + /// The PostScript name (nameID 6), or . + /// + public string? PostScriptName { get; } + + private NameTable(string? familyName, string? subfamilyName, string? fullName, string? postScriptName) + { + FamilyName = familyName; + SubfamilyName = subfamilyName; + FullName = fullName; + PostScriptName = postScriptName; + } + + /// + /// Parses a name table and resolves the family/subfamily/full/PostScript name + /// strings. + /// + /// The complete font file contents. + /// The offset of the name table within . + /// The length of the name table. + /// + /// A with every resolvable field populated, or + /// if the table's own 6-byte header is too short, or its declared record count does not + /// fit the table's bounds. + /// + public static NameTable Parse(byte[] data, int tableOffset, int tableLength) + { + if (tableLength < 6) + { + return Empty; + } + + var count = SfntContainer.ReadUInt16(data, tableOffset + 2); + var stringOffsetField = SfntContainer.ReadUInt16(data, tableOffset + 4); + + const int recordSize = 12; + const int headerSize = 6; + var tableEnd = (long)tableOffset + tableLength; + var recordsEnd = (long)tableOffset + headerSize + (long)count * recordSize; + if (recordsEnd > tableEnd) + { + return Empty; + } + + var stringAreaStart = tableOffset + stringOffsetField; + + // Every candidate record found for each name identifier, keyed by nameID, in table order. + var windowsCandidates = new Dictionary>(); + var macintoshCandidates = new Dictionary>(); + + for (var i = 0; i < count; i++) + { + var recordOffset = tableOffset + headerSize + i * recordSize; + var platformId = SfntContainer.ReadUInt16(data, recordOffset); + var encodingId = SfntContainer.ReadUInt16(data, recordOffset + 2); + var languageId = SfntContainer.ReadUInt16(data, recordOffset + 4); + var nameId = SfntContainer.ReadUInt16(data, recordOffset + 6); + var length = SfntContainer.ReadUInt16(data, recordOffset + 8); + var offset = SfntContainer.ReadUInt16(data, recordOffset + 10); + + if (nameId is not (NameIdFamily or NameIdSubfamily or NameIdFull or NameIdPostScript + or NameIdTypographicFamily or NameIdTypographicSubfamily)) + { + // Not one of the six name identifiers this class resolves; skip decoding it. + continue; + } + + var stringStart = (long)stringAreaStart + offset; + var stringEnd = stringStart + length; + if (stringStart < tableOffset || stringEnd > tableEnd) + { + // This one record's string bytes fall outside the table's bounds; skip only this + // record, leaving every other well-formed record resolvable. + continue; + } + + string? value = (platformId, encodingId) switch + { + (PlatformWindows, EncodingWindowsUnicodeBmp) or (PlatformWindows, EncodingWindowsUnicodeFull) => + DecodeWindowsUnicode(data, (int)stringStart, length), + (PlatformMacintosh, EncodingMacRoman) => DecodeMacRoman(data, (int)stringStart, length), + _ => null, + }; + + if (value is null) + { + continue; + } + + var candidates = platformId == PlatformWindows ? windowsCandidates : macintoshCandidates; + if (!candidates.TryGetValue(nameId, out var list)) + { + list = []; + candidates[nameId] = list; + } + + list.Add((languageId, value)); + } + + string? Resolve(int nameId) => + ResolvePreferred(windowsCandidates, nameId, LanguageWindowsUsEnglish) ?? + ResolvePreferred(macintoshCandidates, nameId, LanguageMacintoshEnglish); + + var familyName = Resolve(NameIdTypographicFamily) ?? Resolve(NameIdFamily); + var subfamilyName = Resolve(NameIdTypographicSubfamily) ?? Resolve(NameIdSubfamily); + var fullName = Resolve(NameIdFull); + var postScriptName = Resolve(NameIdPostScript); + + return new NameTable(familyName, subfamilyName, fullName, postScriptName); + } + + /// + /// Selects the preferred candidate string for a name identifier from one platform's + /// candidate list: the entry matching if present, + /// otherwise the first candidate encountered in table order. + /// + private static string? ResolvePreferred( + Dictionary> candidates, + int nameId, + int preferredLanguageId) + { + if (!candidates.TryGetValue(nameId, out var list) || list.Count == 0) + { + return null; + } + + foreach (var candidate in list) + { + if (candidate.LanguageId == preferredLanguageId) + { + return candidate.Value; + } + } + + return list[0].Value; + } + + /// + /// Decodes a Windows-platform name record's bytes as big-endian UTF-16. + /// + private static string DecodeWindowsUnicode(byte[] data, int offset, int length) => + Encoding.BigEndianUnicode.GetString(data, offset, length); + + /// + /// Decodes a Macintosh-platform (Roman) name record's bytes, one byte per character: bytes + /// below 0x80 map directly to the same ASCII codepoint, and bytes at or above + /// 0x80 map through . + /// + private static string DecodeMacRoman(byte[] data, int offset, int length) => + string.Create(length, (data, offset), static (span, state) => + { + for (var i = 0; i < span.Length; i++) + { + var b = state.data[state.offset + i]; + span[i] = b < 0x80 ? (char)b : MacRomanHighBytes[b - 0x80]; + } + }); +} diff --git a/src/DemaConsulting.CanvasNet/Fonts/NamespaceDoc.cs b/src/DemaConsulting.CanvasNet/Fonts/NamespaceDoc.cs index 52a816a5..58573e05 100644 --- a/src/DemaConsulting.CanvasNet/Fonts/NamespaceDoc.cs +++ b/src/DemaConsulting.CanvasNet/Fonts/NamespaceDoc.cs @@ -1,20 +1,28 @@ // cspell:ignore SFNT Sfnt sfnt glyf Glyf cmap Cmap loca Loca hmtx Hmtx hhea Hhea // cspell:ignore maxp Maxp notdef codepoint codepoints subtable subtables subsetted -// cspell:ignore subsetting PPEM OTTO +// cspell:ignore subsetting PPEM OTTO ttcf 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 +/// The namespace provides SFNT font parsing +/// through : container/table-directory parsing, cmap +/// character-to-glyph mapping, glyph outline extraction into +/// from either TrueType +/// glyf/loca outlines (including composite glyphs) or CFF/OpenType (CFF , +/// OTTO-tagged) Type 2 charstring outlines, hmtx/hhea horizontal metrics, +/// and basic (kern format 0) pairwise kerning. TrueType Collection (ttcf) +/// containers holding one or more faces are also supported, with explicit face selection and +/// face-count querying, including the flex shortcuts (hflex/flex/ +/// hflex1/flex1) and the deprecated 4-operand seac-style accent +/// composition form of endchar. CID-keyed CFF fonts (a Top DICT declaring the +/// ROS operator), a doubly-nested seac-style composition, and any Type 2 +/// charstring operator outside the supported set 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 index cf796b91..4947de5b 100644 --- a/src/DemaConsulting.CanvasNet/Fonts/SfntContainer.cs +++ b/src/DemaConsulting.CanvasNet/Fonts/SfntContainer.cs @@ -1,19 +1,27 @@ // cspell:ignore SFNT Sfnt sfnt glyf Glyf cmap Cmap loca Loca hmtx Hmtx hhea Hhea // cspell:ignore maxp Maxp notdef codepoint codepoints subtable subtables subsetted -// cspell:ignore subsetting PPEM OTTO +// cspell:ignore subsetting PPEM OTTO ttcf 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. +/// OpenType (CFF-based) font files, parses the leading ttcf TrueType Collection header +/// when present, 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. +/// sfntVersion must be 0x00010000, the classic Mac 'true' tag, or the +/// CFF/OpenType tag 'OTTO'; any other value is rejected with +/// . An 'OTTO'-tagged font whose table directory has +/// no CFF table is itself structurally inconsistent (it claims CFF outlines but +/// supplies none) and is also rejected here, at the container level, rather than left for +/// to discover later - this is a fact available directly from the +/// table directory this type already parses, not a decision that depends on any +/// -specific semantics. Whether a 'true'/0x00010000-tagged +/// font actually supplies its own required TrueType tables (glyf/loca) is left to +/// , since that decision does depend on which outline flavor the font +/// declares. /// /// /// Every table directory entry's offset/length pair is validated against the @@ -41,11 +49,17 @@ internal sealed class SfntContainer private const uint MacTrueVersion = 0x74727565; /// - /// The CFF/OpenType sfntVersion tag ('OTTO'), explicitly rejected since CFF - /// outlines are out of scope for this namespace. + /// The CFF/OpenType sfntVersion tag ('OTTO'). Accepted only when the table + /// directory also declares a CFF table (see this class's remarks). /// private const uint OttoVersion = 0x4F54544F; + /// + /// The TrueType Collection ttcTag value ('ttcf'), identifying a multi-face font + /// collection container rather than a bare SFNT offset table. + /// + private const uint TtcTag = 0x74746366; + /// /// The size, in bytes, of the 12-byte SFNT offset table. /// @@ -56,6 +70,12 @@ internal sealed class SfntContainer /// private const int TableDirectoryEntrySize = 16; + /// + /// The size, in bytes, of the ttcf header prefix (ttcTag, majorVersion, + /// minorVersion, numFonts) preceding the per-face offset table. + /// + private const int TtcHeaderPrefixSize = 12; + /// /// Every table's raw byte range, keyed by its 4-character tag. /// @@ -70,37 +90,54 @@ private SfntContainer(Dictionary tables) } /// - /// Parses the SFNT offset table and table directory from . + /// Parses the SFNT offset table and table directory starting at the beginning of + /// (offset table start 0). /// /// 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 + /// See . + /// + public static SfntContainer Parse(byte[] data) => Parse(data, 0); + + /// + /// Parses a single face's SFNT offset table and table directory starting at + /// within . + /// + /// + /// The complete, in-memory font file contents - for a ttcf TrueType Collection, + /// this is the whole container (every face's table directory entry offset/length is + /// already absolute from the start of this array, per the ttcf format, regardless + /// of which face's offset table selects). + /// + /// + /// The byte offset within where this face's 12-byte SFNT offset + /// table begins - 0 for an ordinary (non-collection) SFNT file, or one of the + /// face offsets returned by for a ttcf container. + /// + /// A new describing every table's byte range. + /// + /// Thrown when is too short to contain a 12-byte offset table (at + /// ) or its declared table directory, when + /// sfntVersion is 'OTTO' without a CFF table present, or any other + /// value not recognized as TrueType or CFF/OpenType, or when any table directory entry's /// offset/length falls outside the bounds of . /// - public static SfntContainer Parse(byte[] data) + public static SfntContainer Parse(byte[] data, int offsetTableStart) { - if (data.Length < OffsetTableSize) + if (offsetTableStart < 0 || checked((long)offsetTableStart + OffsetTableSize) > data.Length) { 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) + var version = ReadUInt32(data, offsetTableStart); + if (version != TrueTypeVersion && version != MacTrueVersion && version != OttoVersion) { throw new InvalidDataException($"Unrecognized SFNT version 0x{version:X8}."); } - var numTables = ReadUInt16(data, 4); - var directoryEnd = checked((long)OffsetTableSize + (long)numTables * TableDirectoryEntrySize); + var numTables = ReadUInt16(data, offsetTableStart + 4); + var directoryEnd = checked((long)offsetTableStart + OffsetTableSize + (long)numTables * TableDirectoryEntrySize); if (directoryEnd > data.Length) { throw new InvalidDataException("Font data is too short to contain its declared table directory."); @@ -109,7 +146,7 @@ public static SfntContainer Parse(byte[] data) var tables = new Dictionary(numTables, StringComparer.Ordinal); for (var i = 0; i < numTables; i++) { - var entryOffset = OffsetTableSize + i * TableDirectoryEntrySize; + var entryOffset = offsetTableStart + OffsetTableSize + i * TableDirectoryEntrySize; var tag = ReadTag(data, entryOffset); var tableOffset = ReadUInt32(data, entryOffset + 8); var tableLength = ReadUInt32(data, entryOffset + 12); @@ -124,9 +161,104 @@ public static SfntContainer Parse(byte[] data) tables[tag] = ((int)tableOffset, (int)tableLength); } + if (version == OttoVersion && !tables.ContainsKey("CFF ")) + { + throw new InvalidDataException( + "Font uses CFF/OpenType ('OTTO') outlines but has no 'CFF ' table."); + } + return new SfntContainer(tables); } + /// + /// Reports whether 's leading 4 bytes match one of the recognized + /// SFNT sfntVersion/ttcTag values (, + /// , , or ) - a + /// lightweight, non-throwing shape sniff distinguishing an SFNT-wrapped font program from + /// a bare (standalone) CFF program (see ). Used + /// by Pdf.PdfDocument's own /FontFile3 shape-sniffing dispatch, since the + /// PDF specification permits (and real-world producers sometimes emit) a mismatch + /// between a /FontFile3 stream's declared /Subtype name and its actual byte + /// container shape. + /// + /// The candidate font program bytes. + /// + /// if is at least 4 bytes long and its + /// leading 4 bytes match a recognized SFNT version or collection tag; otherwise, + /// . Deliberately shallow - this method does not otherwise + /// validate the table directory or any table's own structure, unlike + /// : a value recognized here can still fail that + /// method's own, stricter validation. + /// + public static bool LooksLikeSfnt(byte[] data) => + data.Length >= 4 && + ReadUInt32(data, 0) is TrueTypeVersion or MacTrueVersion or OttoVersion or TtcTag; + + /// + /// Attempts to parse a leading ttcf TrueType Collection header from + /// , returning every face's SFNT offset table start position. + /// + /// The complete, in-memory font file contents. + /// + /// Every face's SFNT offset table start position within , in + /// declaration order, if this is a ttcf container; otherwise an empty list. + /// + /// + /// if begins with the ttcf tag; + /// otherwise, (an ordinary, non-collection SFNT file, which this + /// method does not otherwise validate). + /// + /// + /// Thrown when begins with the ttcf tag but is too short to + /// contain the fixed 12-byte header prefix or its declared face offset table, or declares + /// zero faces. + /// + /// + /// Deliberately lightweight: only the fixed header prefix and the face offset table are + /// read here - no face's own SFNT offset table or table directory is parsed. Callers parse + /// an individual face on demand via . + /// + public static bool TryReadTtcHeader(byte[] data, out IReadOnlyList faceOffsets) + { + faceOffsets = []; + if (data.Length < 4 || ReadUInt32(data, 0) != TtcTag) + { + return false; + } + + if (data.Length < TtcHeaderPrefixSize) + { + throw new InvalidDataException("Font data is too short to contain a 'ttcf' header."); + } + + var numFonts = ReadUInt32(data, 8); + if (numFonts == 0) + { + throw new InvalidDataException("A 'ttcf' container must declare at least one font."); + } + + var directoryEnd = checked((long)TtcHeaderPrefixSize + (long)numFonts * 4); + if (directoryEnd > data.Length) + { + throw new InvalidDataException("Font data is too short to contain its declared 'ttcf' face offset table."); + } + + var offsets = new int[numFonts]; + for (var i = 0; i < numFonts; i++) + { + var offset = ReadUInt32(data, TtcHeaderPrefixSize + i * 4); + if (offset > data.Length) + { + throw new InvalidDataException("A 'ttcf' face offset exceeds the font data bounds."); + } + + offsets[i] = (int)offset; + } + + faceOffsets = offsets; + return true; + } + /// /// Attempts to look up a table's byte range by tag. /// diff --git a/src/DemaConsulting.CanvasNet/Fonts/StyleTable.cs b/src/DemaConsulting.CanvasNet/Fonts/StyleTable.cs new file mode 100644 index 00000000..88aa5545 --- /dev/null +++ b/src/DemaConsulting.CanvasNet/Fonts/StyleTable.cs @@ -0,0 +1,174 @@ +// 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 macStyle fsSelection usWeightClass isFixedPitch italicAngle +namespace DemaConsulting.CanvasNet.Fonts; + +/// +/// Derives a font's bold/italic/fixed-pitch style metadata from the optional OS/2 and +/// post tables and the required head.macStyle field. +/// +/// +/// +/// Every source table/field is consulted independently and OR'd together - the first +/// applicable signal to fire for a given boolean wins, and every source that can contribute +/// to that boolean is always consulted, not merely as a fallback for another source's +/// absence: head.macStyle always contributes alongside OS/2/post when +/// they are present, rather than being consulted only when they are absent (see this class's +/// unit design documentation for the rationale). +/// +/// +/// OS/2 and post are both optional, and each field read from them is +/// individually gated by its own minimum table-length check - a table present but too short +/// for one particular field simply skips that field's contribution, rather than discarding +/// the whole table or throwing. This class never throws. +/// +/// +internal sealed class StyleTable +{ + /// + /// The OS/2 fsSelection BOLD bit (bit 5, 0x20). + /// + private const int Os2FsSelectionBold = 0x20; + + /// + /// The OS/2 fsSelection ITALIC bit (bit 0, 0x1). + /// + private const int Os2FsSelectionItalic = 0x1; + + /// + /// The minimum usWeightClass value (per the OpenType OS/2 specification's + /// weight class scale) treated as a bold-weight design. + /// + private const int BoldWeightClassThreshold = 600; + + /// + /// The byte offset, within OS/2, of the 16-bit usWeightClass field. + /// + private const int Os2UsWeightClassOffset = 4; + + /// + /// The minimum OS/2 table length required to read usWeightClass. + /// + private const int Os2UsWeightClassMinLength = 6; + + /// + /// The byte offset, within OS/2, of the 16-bit fsSelection field. + /// + private const int Os2FsSelectionOffset = 62; + + /// + /// The minimum OS/2 table length required to read fsSelection. + /// + private const int Os2FsSelectionMinLength = 64; + + /// + /// The byte offset, within post, of the 32-bit italicAngle field. + /// + private const int PostItalicAngleOffset = 4; + + /// + /// The minimum post table length required to read italicAngle. + /// + private const int PostItalicAngleMinLength = 8; + + /// + /// The byte offset, within post, of the 32-bit isFixedPitch field. Per the + /// OpenType post table specification, the fixed 32-byte header is + /// version (4 bytes) + italicAngle (4 bytes) + underlinePosition + /// (2 bytes) + underlineThickness (2 bytes) + isFixedPitch (4 bytes) + four + /// more memory-usage fields. + /// + private const int PostIsFixedPitchOffset = 12; + + /// + /// The minimum post table length required to read isFixedPitch. + /// + private const int PostIsFixedPitchMinLength = 16; + + /// + /// The head.macStyle Bold bit (bit 0, 0x1). + /// + private const int MacStyleBold = 0x1; + + /// + /// The head.macStyle Italic bit (bit 1, 0x2). + /// + private const int MacStyleItalic = 0x2; + + /// + /// Whether this font is a bold-weight design. + /// + public bool IsBold { get; } + + /// + /// Whether this font is an italic/oblique design. + /// + public bool IsItalic { get; } + + /// + /// Whether this font is a fixed-pitch (monospaced) design. + /// + public bool IsFixedPitch { get; } + + private StyleTable(bool isBold, bool isItalic, bool isFixedPitch) + { + IsBold = isBold; + IsItalic = isItalic; + IsFixedPitch = isFixedPitch; + } + + /// + /// Derives bold/italic/fixed-pitch style metadata from the given tables. + /// + /// The complete font file contents. + /// The already-decoded head.macStyle field. + /// + /// The OS/2 table's offset and length within , or + /// if the font has no OS/2 table. + /// + /// + /// The post table's offset and length within , or + /// if the font has no post table. + /// + /// The derived . Never throws. + public static StyleTable Parse(byte[] data, int macStyle, (int Offset, int Length)? os2, (int Offset, int Length)? post) + { + var isBold = (macStyle & MacStyleBold) != 0; + var isItalic = (macStyle & MacStyleItalic) != 0; + var isFixedPitch = false; + + if (os2 is { } os2Range) + { + if (os2Range.Length >= Os2FsSelectionMinLength) + { + var fsSelection = SfntContainer.ReadUInt16(data, os2Range.Offset + Os2FsSelectionOffset); + isBold |= (fsSelection & Os2FsSelectionBold) != 0; + isItalic |= (fsSelection & Os2FsSelectionItalic) != 0; + } + + if (os2Range.Length >= Os2UsWeightClassMinLength) + { + var usWeightClass = SfntContainer.ReadUInt16(data, os2Range.Offset + Os2UsWeightClassOffset); + isBold |= usWeightClass >= BoldWeightClassThreshold; + } + } + + if (post is { } postRange) + { + if (postRange.Length >= PostItalicAngleMinLength) + { + var italicAngle = SfntContainer.ReadInt32(data, postRange.Offset + PostItalicAngleOffset); + isItalic |= italicAngle != 0; + } + + if (postRange.Length >= PostIsFixedPitchMinLength) + { + var isFixedPitchField = SfntContainer.ReadInt32(data, postRange.Offset + PostIsFixedPitchOffset); + isFixedPitch = isFixedPitchField != 0; + } + } + + return new StyleTable(isBold, isItalic, isFixedPitch); + } +} diff --git a/src/DemaConsulting.CanvasNet/Fonts/SystemFontCatalog.cs b/src/DemaConsulting.CanvasNet/Fonts/SystemFontCatalog.cs new file mode 100644 index 00000000..4d4243d9 --- /dev/null +++ b/src/DemaConsulting.CanvasNet/Fonts/SystemFontCatalog.cs @@ -0,0 +1,554 @@ +// cspell:ignore Dejavu Nimbus Consolas ttcf rescanned Segoe LOCALAPPDATA Noto Zapf +using System.Runtime.InteropServices; + +namespace DemaConsulting.CanvasNet.Fonts; + +/// +/// A single OS-discovered font file's identity, name, and style metadata, as recorded by +/// . +/// +/// +/// The font's -resolved family name, or (when that is +/// ) the font file's own name without its extension - every entry in +/// therefore always has a non-null family name, even +/// for a font with no usable name table. +/// +/// +/// The font's -resolved subfamily name, or +/// when absent. +/// +/// The font's value. +/// The font's value. +/// The font's value. +/// The absolute path of the font file on disk. +/// +/// The zero-based face index this entry was loaded from - 0 for an ordinary +/// (non-collection) font file, or the matched face within a .ttc collection. +/// +public readonly record struct SystemFontInfo( + string FamilyName, + string SubfamilyName, + bool Bold, + bool Italic, + bool FixedPitch, + string FilePath, + int FaceIndex); + +/// +/// Provides directory-scan-only discovery of fonts installed on the host operating system +/// (), best-effort name/style matching against that catalog +/// (), and a bundled, always-available Liberation Sans/Serif/Mono +/// last-resort fallback font (). +/// +/// +/// +/// SystemFontCatalog is a format-agnostic building block: it knows nothing about +/// any particular document format's font-naming conventions (for example a PDF subset +/// tag or a Standard-14 name) - a caller (today, DemaConsulting.CanvasNet.Pdf's +/// PdfDocument) is responsible for cleaning up a format-specific font name into a +/// plain family-name hint before calling . +/// +/// +/// is built exactly once per process, the first time it is accessed, +/// and is never rescanned afterward - installing or removing a font on the host machine +/// while the process is running is not detected. Both 's scan itself +/// and are deliberately tolerant of failure: a missing +/// or inaccessible scan directory, an unparseable candidate font file, or a single +/// corrupt face within an otherwise well-formed .ttc collection is silently +/// skipped rather than aborting the whole catalog build. +/// +/// +public static class SystemFontCatalog +{ + /// + /// The well-known family names searched, in order, when no exact family match is found + /// and a "generic sans-serif" font is requested ('s tier 2) - + /// the first of these actually present in wins. + /// + private static readonly string[] GenericSansFamilies = + [ + "Arial", "Helvetica", "Liberation Sans", "DejaVu Sans", "Nimbus Sans", "Segoe UI", + ]; + + /// + /// The well-known family names searched, in order, when no exact family match is found + /// and a "generic serif" font is requested ('s tier 2) - the + /// first of these actually present in wins. + /// + private static readonly string[] GenericSerifFamilies = + [ + "Times New Roman", "Times", "Liberation Serif", "DejaVu Serif", "Nimbus Roman", + ]; + + /// + /// The well-known family names searched, in order, when no exact family match is found + /// and a "generic monospace" font is requested ('s tier 2) - + /// the first of these actually present in wins. + /// + private static readonly string[] GenericMonospaceFamilies = + [ + "Courier New", "Courier", "Liberation Mono", "DejaVu Sans Mono", "Consolas", + ]; + + /// + /// Backs - built once per process, on first access, and never + /// rescanned afterward. + /// + private static readonly Lazy> LazyFonts = + new(BuildCatalog, LazyThreadSafetyMode.ExecutionAndPublication); + + /// + /// Caches every loaded so far by + /// or, cross-assembly, by DemaConsulting.CanvasNet.Pdf's own Noto symbolic-font + /// fallback (see 's remarks), keyed by the embedded + /// resource's bundled file name (for example "LiberationSans-Bold.ttf") - at most + /// 15 entries are ever created (12 Liberation Sans/Serif/Mono style variants plus 3 Noto + /// substitute fonts), each created at most once, for the lifetime of the process. + /// + private static readonly Dictionary BundledFontCache = new(StringComparer.Ordinal); + + /// Guards against concurrent population. + private static readonly object BundledFontLock = new(); + + /// + /// Gets every font (or, for a .ttc collection, every face) discovered by scanning + /// this operating system's well-known font directories, built exactly once per process on + /// first access. + /// + /// + /// Never throws: a missing or inaccessible scan directory, or a candidate file that fails + /// to parse as a valid font, is silently skipped rather than aborting the scan. An empty + /// result (for example on a minimal container image with no fonts installed at all) is a + /// valid, expected outcome, not an error. + /// + public static IReadOnlyList Fonts => LazyFonts.Value; + + /// + /// Finds the closest-matching font in for a requested family-name + /// hint and style, following a two-tier strategy: an exact (case-insensitive) family-name + /// match first, then - only when no exact family match exists - a search of a small, + /// hand-maintained list of well-known generic-family names (sans/serif/monospace) for the + /// first one actually present on this system. + /// + /// + /// A plain font family name to match, already cleaned of any format-specific decoration + /// (for example a PDF subset tag or a ,Bold/-Italic-style suffix) by the + /// caller - SystemFontCatalog itself has no knowledge of any particular document + /// format's naming conventions. + /// + /// Whether a bold style is requested. + /// Whether an italic style is requested. + /// + /// Whether a serif design is requested - consulted only for tier 2's generic-bucket + /// search, and mutually exclusive with in this model + /// ( takes priority when both are ). + /// + /// + /// Whether a fixed-pitch (monospace) design is requested - consulted only for tier 2's + /// generic-bucket search. + /// + /// + /// The best-matching , or when + /// is empty, or genuinely has no exact family match and no + /// generic-bucket match either. This method never consults + /// - composing "system match, else bundled fallback" is + /// the caller's responsibility. + /// + public static SystemFontInfo? FindBestMatch( + string familyNameHint, + bool bold, + bool italic, + bool serif, + bool fixedPitch) => + FindBestMatchCore(Fonts, familyNameHint, bold, italic, serif, fixedPitch); + + /// + /// Loads (and process-lifetime-caches) the closest-matching bundled Liberation + /// Sans/Serif/Mono style for the requested axes, from this assembly's embedded resources + /// - never consults or the host operating system's installed fonts at + /// all. + /// + /// Whether the serif family (Liberation Serif) is requested. + /// + /// Whether the fixed-pitch family (Liberation Mono) is requested - takes priority over + /// when both are , mirroring + /// 's own tier 2 priority. + /// + /// Whether the bold style is requested. + /// Whether the italic style is requested. + /// The loaded (or cached) bundled . + /// + /// Thrown only when the expected embedded resource is unexpectedly absent - a defensive, + /// effectively-unreachable packaging-integrity guard under normal operation, since all 12 + /// Liberation Sans/Serif/Mono style-variant bundled files this method can request are + /// guaranteed present at build time. + /// + public static TrueTypeFont LoadBundledFallback(bool serif, bool fixedPitch, bool bold, bool italic) + { + string family; + if (fixedPitch) + { + family = "LiberationMono"; + } + else + { + family = serif ? "LiberationSerif" : "LiberationSans"; + } + var style = (bold, italic) switch + { + (true, true) => "BoldItalic", + (true, false) => "Bold", + (false, true) => "Italic", + (false, false) => "Regular", + }; + + return LoadBundledFallbackCore($"{family}-{style}.ttf"); + } + + /// + /// The scoring algorithm, exposed over an injectable + /// candidate list so its tier-1/tier-2/no-match correctness can be tested without + /// depending on what happens to be installed on the machine running the tests. + /// + /// The font catalog to search (in production, ). + /// See . + /// See . + /// See . + /// See . + /// See . + /// See . + internal static SystemFontInfo? FindBestMatchCore( + IReadOnlyList candidates, + string familyNameHint, + bool bold, + bool italic, + bool serif, + bool fixedPitch) + { + var exactMatch = BestStyleMatch( + candidates, + f => string.Equals(f.FamilyName, familyNameHint, StringComparison.OrdinalIgnoreCase), + bold, + italic); + if (exactMatch is not null) + { + return exactMatch; + } + + string[] genericFamilies; + if (fixedPitch) + { + genericFamilies = GenericMonospaceFamilies; + } + else + { + genericFamilies = serif ? GenericSerifFamilies : GenericSansFamilies; + } + + foreach (var genericFamily in genericFamilies) + { + var bucketMatch = BestStyleMatch( + candidates, + f => string.Equals(f.FamilyName, genericFamily, StringComparison.OrdinalIgnoreCase), + bold, + italic); + if (bucketMatch is not null) + { + return bucketMatch; + } + } + + return null; + } + + /// + /// Resolves (and process-lifetime-caches) a bundled embedded-resource font by its bundled + /// file name, exposed separately from so a test can + /// exercise the missing-resource defensive guard with a deliberately-wrong file name + /// without needing to delete a real embedded resource. This method is also called + /// directly, cross-assembly (via InternalsVisibleTo), by + /// DemaConsulting.CanvasNet.Pdf's own PdfDocument.FontFallback.cs to load + /// the bundled Noto substitute font(s) for a Symbol/ZapfDingbats + /// /BaseFont with no embedded font program - this is deliberate reuse of this + /// method's embedded-resource-loading and caching logic, not duplication of it. + /// + /// + /// The bundled file's name (for example "LiberationSans-Bold.ttf"), used both as + /// the cache key and (combined with this assembly's embedded-resource namespace prefix) + /// as the resource's logical name. + /// + /// The loaded (or cached) . + /// + /// Thrown when no embedded resource with the resulting logical name exists in this + /// assembly. + /// + internal static TrueTypeFont LoadBundledFallbackCore(string bundledFileName) + { + lock (BundledFontLock) + { + if (BundledFontCache.TryGetValue(bundledFileName, out var cached)) + { + return cached; + } + + var logicalName = $"DemaConsulting.CanvasNet.Fonts.BundledFonts.{bundledFileName}"; + using var stream = typeof(SystemFontCatalog).Assembly.GetManifestResourceStream(logicalName) + ?? throw new InvalidOperationException( + $"Embedded bundled font resource '{logicalName}' is missing; this indicates " + + "a packaging defect in the DemaConsulting.CanvasNet assembly."); + + var font = TrueTypeFont.Load(stream); + BundledFontCache[bundledFileName] = font; + return font; + } + } + + /// + /// Finds the best style match (minimizing mismatched bold/italic flag count, first + /// encountered wins any tie) among every candidate satisfying . + /// + private static SystemFontInfo? BestStyleMatch( + IReadOnlyList candidates, + Func familyFilter, + bool bold, + bool italic) + { + SystemFontInfo? best = null; + var bestScore = int.MaxValue; + foreach (var candidate in candidates) + { + if (!familyFilter(candidate)) + { + continue; + } + + var score = (candidate.Bold != bold ? 1 : 0) + (candidate.Italic != italic ? 1 : 0); + if (score < bestScore) + { + best = candidate; + bestScore = score; + } + } + + return best; + } + + /// + /// Builds 's backing catalog: scans every well-known font directory for + /// this operating system, loading every well-formed face of every candidate + /// .ttf/.ttc/.otf file found. + /// + private static IReadOnlyList BuildCatalog() => BuildCatalogFromRoots(GetScanRoots()); + + /// + /// Builds a catalog by scanning an explicit, caller-supplied set of directory roots, + /// exposed separately from so this scan/tolerance logic (a + /// missing root, an inaccessible subdirectory, or an unparseable candidate file are all + /// silently skipped) can be tested against controlled temporary directories, without + /// depending on what happens to be installed on the machine running the tests. + /// + /// The directory roots to scan. + /// Every well-formed font face discovered. + internal static IReadOnlyList BuildCatalogFromRoots(IEnumerable roots) + { + var result = new List(); + foreach (var root in roots) + { + foreach (var path in EnumerateFontFiles(root)) + { + AddFacesForFile(path, result); + } + } + + return result; + } + + /// + /// Loads every face of a single candidate font file, appending a + /// for each well-formed face found, and silently skipping the whole file (or, for a + /// .ttc, just the one corrupt face) when it fails to parse. + /// + private static void AddFacesForFile(string path, List result) + { + int faceCount; + try + { + faceCount = TrueTypeFont.GetFaceCount(path); + } + catch (Exception ex) when (ex is InvalidDataException or IOException or UnauthorizedAccessException) + { + return; + } + + for (var faceIndex = 0; faceIndex < faceCount; faceIndex++) + { + TrueTypeFont font; + try + { + font = TrueTypeFont.Load(path, faceIndex); + } + catch (Exception ex) when (ex is InvalidDataException or IOException or UnauthorizedAccessException) + { + continue; + } + + var nameInfo = font.GetNameInfo(); + var familyName = nameInfo.FamilyName ?? Path.GetFileNameWithoutExtension(path); + var subfamilyName = nameInfo.SubfamilyName ?? string.Empty; + result.Add(new SystemFontInfo( + familyName, + subfamilyName, + font.IsBold, + font.IsItalic, + font.IsFixedPitch, + Path.GetFullPath(path), + faceIndex)); + } + } + + /// + /// Reports this process's well-known font-directory scan roots for the current operating + /// system, per . + /// + private static IEnumerable GetScanRoots() + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + yield return Environment.GetFolderPath(Environment.SpecialFolder.Fonts); + + var localAppData = Environment.GetEnvironmentVariable("LOCALAPPDATA"); + if (!string.IsNullOrEmpty(localAppData)) + { + yield return Path.Combine(localAppData, "Microsoft", "Windows", "Fonts"); + } + + yield break; + } + + if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + yield return "/System/Library/Fonts"; + yield return "/Library/Fonts"; + + var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + if (!string.IsNullOrEmpty(home)) + { + yield return Path.Combine(home, "Library", "Fonts"); + } + + yield break; + } + + // Linux (and any other non-Windows/non-macOS platform): the FreeDesktop-conventional + // system, local, and per-user font directories. + yield return "/usr/share/fonts"; + yield return "/usr/local/share/fonts"; + + var homeDirectory = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + if (!string.IsNullOrEmpty(homeDirectory)) + { + yield return Path.Combine(homeDirectory, ".local", "share", "fonts"); + yield return Path.Combine(homeDirectory, ".fonts"); + } + } + + /// + /// Recursively enumerates every .ttf/.ttc/.otf file (case-insensitive + /// extension match) under , tolerating a missing root and any + /// inaccessible subdirectory encountered along the way (both are silently skipped rather + /// than aborting the whole scan). Symlinked or junction subdirectories (reparse points) are + /// skipped entirely, and every other subdirectory's canonical path is tracked so a cycle + /// (for example, a symlink pointing back at an ancestor) cannot cause an infinite scan. + /// + private static IEnumerable EnumerateFontFiles(string root) + { + var results = new List(); + if (string.IsNullOrEmpty(root) || !Directory.Exists(root)) + { + return results; + } + + // Tracks every directory's canonicalized path already queued or visited, so a symlink (or + // junction/reparse point) that cycles back to an ancestor - or to another already-visited + // branch - cannot make this scan revisit the same real directory indefinitely. + var visited = new HashSet(StringComparer.OrdinalIgnoreCase) + { + NormalizeDirectoryPath(root), + }; + + var pending = new Stack(); + pending.Push(root); + while (pending.Count > 0) + { + var directory = pending.Pop(); + + string[] files; + try + { + files = Directory.GetFiles(directory); + } + catch (Exception ex) when (ex is UnauthorizedAccessException or IOException) + { + continue; + } + + foreach (var file in files) + { + var extension = Path.GetExtension(file); + if (string.Equals(extension, ".ttf", StringComparison.OrdinalIgnoreCase) || + string.Equals(extension, ".ttc", StringComparison.OrdinalIgnoreCase) || + string.Equals(extension, ".otf", StringComparison.OrdinalIgnoreCase)) + { + results.Add(file); + } + } + + string[] subdirectories; + try + { + subdirectories = Directory.GetDirectories(directory); + } + catch (Exception ex) when (ex is UnauthorizedAccessException or IOException) + { + continue; + } + + foreach (var subdirectory in subdirectories) + { + try + { + // A symlinked or junction subdirectory can point anywhere - including back + // at an ancestor of this scan - so it is skipped entirely rather than + // followed, matching common font-discovery tooling's own treatment of + // reparse points. + if (new DirectoryInfo(subdirectory).Attributes.HasFlag(FileAttributes.ReparsePoint)) + { + continue; + } + } + catch (Exception ex) when (ex is UnauthorizedAccessException or IOException) + { + continue; + } + + if (!visited.Add(NormalizeDirectoryPath(subdirectory))) + { + continue; + } + + pending.Push(subdirectory); + } + } + + return results; + } + + /// + /// Canonicalizes a directory path (resolving ./.. segments and relative + /// roots via , then trimming any trailing directory + /// separator) so two different textual spellings of the same real directory compare equal + /// in 's visited-directory set. + /// + private static string NormalizeDirectoryPath(string path) => + Path.GetFullPath(path).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); +} diff --git a/src/DemaConsulting.CanvasNet/Fonts/TrueTypeFont.cs b/src/DemaConsulting.CanvasNet/Fonts/TrueTypeFont.cs index ff134c48..47cd5803 100644 --- a/src/DemaConsulting.CanvasNet/Fonts/TrueTypeFont.cs +++ b/src/DemaConsulting.CanvasNet/Fonts/TrueTypeFont.cs @@ -1,35 +1,95 @@ // cspell:ignore SFNT Sfnt sfnt glyf Glyf cmap Cmap loca Loca hmtx Hmtx hhea Hhea // cspell:ignore maxp Maxp notdef codepoint codepoints subtable subtables subsetted -// cspell:ignore subsetting PPEM OTTO +// cspell:ignore subsetting PPEM OTTO ttcf +// cspell:ignore macStyle fsSelection usWeightClass isFixedPitch italicAngle nameID using DemaConsulting.CanvasNet.Geometry; using Path = DemaConsulting.CanvasNet.Geometry.Path; namespace DemaConsulting.CanvasNet.Fonts; +/// +/// A font's resolved name-table strings: the typographic (or, if absent, standard) +/// family and subfamily names, the full name, and the PostScript name. +/// +/// +/// Every field is when the underlying font has no name table, +/// or no record for that specific name identifier, in any of the platform/encoding +/// combinations this type resolves (see ) - this never +/// throws, mirroring 's "absent optional table => benign +/// default" contract rather than a required-table throwing contract. +/// +/// +/// The typographic family name (nameID 16) if present; otherwise the standard family +/// name (nameID 1); otherwise . +/// +/// +/// The typographic subfamily name (nameID 17) if present; otherwise the standard +/// subfamily name (nameID 2); otherwise . +/// +/// The full font name (nameID 4), or . +/// +/// The PostScript name (nameID 6), or . +/// +public readonly record struct FontNameInfo( + string? FamilyName, + string? SubfamilyName, + string? FullName, + string? PostScriptName); + /// /// 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. +/// and CFF/OpenType (CFF -based) SFNT font files, including TrueType Collection +/// (ttcf) containers: metrics, character-to-glyph mapping, glyph outline extraction, +/// advance widths, basic pairwise kerning, and name-table/style metadata. /// /// /// -/// 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 / -/// . +/// TrueTypeFont is one of two public units in the namespace +/// (alongside , which locates installed system font files +/// rather than parsing font data); it fronts several internal, independently-parsed helpers +/// (, +/// , , , +/// , , ) +/// exactly as internally parses chunks as one unit, and as +/// fronts / +/// . and +/// both implement the internal +/// abstraction, letting dispatch to whichever outline flavor +/// the loaded font actually uses without a type-check. +/// +/// +/// head, hhea, and hmtx are always required. Which further tables are +/// required depends on the font's outline flavor: a TrueType-outline font (no CFF +/// table) requires maxp version 1.0 (0x00010000), plus loca and +/// glyf; a CFF-outline font (a CFF table is present) requires maxp +/// version 0.5 (0x00005000) instead, and does not require loca/glyf. +/// A missing or structurally malformed required table 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. /// /// -/// 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. +/// A CFF-outline font whose Top DICT declares a CID-keyed font (the ROS operator) is +/// rejected with rather than attempting unsupported +/// FDArray/FDSelect parsing; see . A glyph whose Type 2 +/// charstring uses the deprecated 4-operand seac-style accent composition form of +/// endchar, or any operator outside 's supported +/// set (including the two-byte escape operators, such as flex), is likewise rejected with +/// from rather than silently +/// mis-decoded. +/// +/// +/// A leading ttcf tag identifies a TrueType Collection container of one or more faces +/// rather than a single bare SFNT font. / +/// transparently select face 0 when given a ttcf container - this is a purely +/// additive relaxation of previously-thrown behavior (a ttcf container previously threw +/// as an "unrecognized SFNT version"). / +/// select an explicit face by index, and +/// / report how many faces +/// a file holds (1 for an ordinary, non-collection SFNT file) so a caller can enumerate +/// faces before choosing one to load. /// /// /// SFNT table checksums (including head.checkSumAdjustment) are not enforced as a @@ -39,6 +99,17 @@ namespace DemaConsulting.CanvasNet.Fonts; /// malformed input instead. /// /// +/// resolves the font's name-table family/subfamily/full/ +/// PostScript name strings, and // +/// derive bold/italic/fixed-pitch style metadata from the optional +/// OS/2 and post tables and the required head.macStyle field. Both are +/// resolved per-face for a ttcf container, exactly as every other table this class +/// reads. name and OS/2/post are all optional; their absence, or the +/// absence of any specific record/field within them, never throws - see +/// / for the exact resolution and fallback +/// rules. +/// +/// /// 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. @@ -48,9 +119,10 @@ namespace DemaConsulting.CanvasNet.Fonts; public sealed class TrueTypeFont { private readonly HmtxHheaReader _metrics; - private readonly GlyfLocaReader _glyphs; + private readonly IGlyphOutlineSource _glyphs; private readonly CmapTable _cmap; private readonly KernTable _kern; + private readonly NameTable _name; /// /// The number of font design units per em, from head.unitsPerEm. @@ -77,22 +149,51 @@ public sealed class TrueTypeFont /// public int GlyphCount { get; } - private TrueTypeFont(int unitsPerEm, int glyphCount, HmtxHheaReader metrics, GlyfLocaReader glyphs, CmapTable cmap, KernTable kern) + /// + /// Whether this font is a bold-weight design, derived from the OS/2 table's + /// fsSelection BOLD bit or usWeightClass >= 600 when OS/2 is + /// present, and always OR'd with head.macStyle bit 0. + /// + public bool IsBold { get; } + + /// + /// Whether this font is an italic/oblique design, derived from the OS/2 table's + /// fsSelection ITALIC bit or a nonzero post.italicAngle when either table is + /// present, and always OR'd with head.macStyle bit 1. + /// + public bool IsItalic { get; } + + /// + /// Whether this font is a fixed-pitch (monospaced) design, from the post table's + /// isFixedPitch field. when the post table is + /// absent or too short to contain that field. + /// + public bool IsFixedPitch { get; } + + private TrueTypeFont( + int unitsPerEm, int glyphCount, HmtxHheaReader metrics, IGlyphOutlineSource glyphs, CmapTable cmap, KernTable kern, + NameTable name, bool isBold, bool isItalic, bool isFixedPitch) { UnitsPerEm = unitsPerEm; GlyphCount = glyphCount; Ascender = metrics.Ascender; Descender = metrics.Descender; LineGap = metrics.LineGap; + IsBold = isBold; + IsItalic = isItalic; + IsFixedPitch = isFixedPitch; _metrics = metrics; _glyphs = glyphs; _cmap = cmap; _kern = kern; + _name = name; } /// /// Loads a from an open, readable stream containing a TrueType - /// (glyf-based) SFNT font file. + /// or CFF/OpenType SFNT font file, or a TrueType Collection (ttcf) container (in + /// which case face 0 is loaded - see to select a + /// different face). /// /// /// The stream to read the font from. Reading begins at the stream's current position and @@ -101,12 +202,13 @@ private TrueTypeFont(int unitsPerEm, int glyphCount, HmtxHheaReader metrics, Gly /// 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. + /// Thrown when does not contain a valid, supported font: the + /// SFNT version is not recognized, an OTTO-tagged font has no CFF table, a + /// required table for the font's outline flavor is missing or structurally malformed + /// (see this class's remarks), a table directory entry's offset/length is out of bounds + /// or would overflow, the stream is truncated, or (for a CFF-outline font) the CFF data or + /// an individual glyph's Type 2 charstring is malformed, uses an unsupported operator, or + /// declares a CID-keyed font. /// /// /// @@ -120,13 +222,12 @@ public static TrueTypeFont Load(Stream stream) { ArgumentNullException.ThrowIfNull(stream); - using var memory = new MemoryStream(); - stream.CopyTo(memory); - return LoadFromBytes(memory.ToArray()); + return LoadFromBytesWithFaceIndex(ReadAllBytes(stream), 0); } /// - /// Loads a from a TrueType font file at the specified path. + /// Loads a from a font file at the specified path, exactly as + /// . /// /// The path of the font file to load. Must not be null or empty. /// A new ready to be queried. @@ -151,6 +252,202 @@ public static TrueTypeFont Load(string path) return Load(stream); } + /// + /// Loads a specific face of a from an open, readable stream, + /// supporting explicit face selection within a TrueType Collection (ttcf) + /// container. + /// + /// + /// The stream to read the font from. Reading begins at the stream's current position and + /// consumes the remainder of the stream. + /// + /// + /// The zero-based face index to load. For a ttcf container, must be within + /// [0, GetFaceCount(stream)). For an ordinary (non-collection) SFNT file, only + /// 0 is valid, and behaves identically to - a single-face + /// file is treated as a one-face collection for this overload's purposes. + /// + /// A new ready to be queried. + /// Thrown when is null. + /// + /// Thrown when is negative, or at or beyond the number of + /// faces the font file declares. + /// + /// Thrown for the same conditions as . + public static TrueTypeFont Load(Stream stream, int faceIndex) + { + ArgumentNullException.ThrowIfNull(stream); + + return LoadFromBytesWithFaceIndex(ReadAllBytes(stream), faceIndex); + } + + /// + /// Loads a specific face of a font file at the specified path, exactly as + /// . + /// + /// The path of the font file to load. Must not be null or empty. + /// See . + /// A new ready to be queried. + /// Thrown when is null. + /// Thrown when is an empty string. + /// Thrown for the same condition as . + /// Thrown for the same conditions as . + public static TrueTypeFont Load(string path, int faceIndex) + { + 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, faceIndex); + } + + /// + /// Loads a from the raw segments of a classic PostScript Type 1 + /// font program (as opposed to an SFNT-wrapped or standalone .pfb/.pfa file - + /// see , which auto-detects the latter two). + /// + /// + /// This overload is intended for a caller that already has direct access to a Type 1 + /// font's embedded byte segments - typically from a PDF Type1 font's own + /// FontFile stream, whose /Length1 and /Length2 values delimit the + /// cleartext and eexec-encrypted regions of an otherwise-unframed font program - and + /// that also already knows the font's own text encoding (a Type 1 font associates glyphs + /// with names, not codepoints, and has no cmap-equivalent table of its own). + /// + /// + /// The stream to read the Type 1 font program from. Reading begins at the stream's current + /// position and consumes the remainder of the stream. + /// + /// + /// The number of cleartext bytes at the start of the font program (never parsed - only + /// counted). + /// + /// + /// The number of eexec-encrypted bytes immediately following the cleartext region. + /// + /// + /// The font's own codepoint-to-glyph-name encoding, used to build this font's synthesized + /// cmap-equivalent lookup. + /// + /// A new ready to be queried. + /// + /// Thrown when or is null. + /// + /// + /// Thrown for the same conditions as and + /// (see their own documentation) - most notably + /// when / are negative or exceed the + /// stream's length, when the decrypted private dictionary is missing the required + /// /CharStrings dictionary, or when a glyph's charstring bytecode is malformed or + /// uses an unsupported operator (including seac). + /// + public static TrueTypeFont LoadType1(Stream stream, int length1, int length2, IReadOnlyDictionary codepointToGlyphName) + { + ArgumentNullException.ThrowIfNull(stream); + ArgumentNullException.ThrowIfNull(codepointToGlyphName); + + var data = ReadAllBytes(stream); + var table = Type1Table.Parse(data, length1, length2); + return BuildFromType1Table(table, codepointToGlyphName); + } + + /// + /// Loads a from a bare (standalone, non-SFNT-wrapped) CFF + /// "Type1C" font program - as opposed to the same CFF table embedded inside an + /// OTTO-tagged SFNT container (see , which parses that + /// case via 's own CFF branch). + /// + /// + /// A bare Type1C font program is exactly a CFF table's own bytes with no + /// surrounding SFNT table directory - typically a PDF Type1 font's own + /// FontFile3 stream (/Subtype /Type1C) - so this overload parses + /// 's entire contents directly via , + /// exactly as parses a bare Type 1 font program via + /// . Like , the caller already knows + /// the font's own text encoding (a Type1C font associates glyphs with names via its + /// charset, not codepoints, and has no cmap-equivalent table of its own); a + /// CFF table's design space is always 1000 units per em (the format has no + /// unitsPerEm-equivalent operator). + /// + /// + /// The stream to read the bare CFF font program from. Reading begins at the stream's + /// current position and consumes the remainder of the stream. + /// + /// + /// The font's own codepoint-to-glyph-name encoding, used to build this font's synthesized + /// cmap-equivalent lookup. + /// + /// A new ready to be queried. + /// + /// Thrown when or is null. + /// + /// + /// Thrown for the same conditions as (see its own + /// documentation) - most notably when the CFF header, an INDEX, or a DICT is malformed or + /// truncated, when the Top DICT declares a CID-keyed (ROS) font, or when a glyph's + /// Type 2 charstring bytecode is malformed or uses an unsupported operator. + /// + public static TrueTypeFont LoadType1C(Stream stream, IReadOnlyDictionary codepointToGlyphName) + { + ArgumentNullException.ThrowIfNull(stream); + ArgumentNullException.ThrowIfNull(codepointToGlyphName); + + var data = ReadAllBytes(stream); + var table = CffTable.Parse(data, 0, data.Length); + return BuildFromCffTable(table, codepointToGlyphName); + } + + /// + /// Reports how many faces a font file holds, without parsing any face's own table + /// directory. + /// + /// + /// The stream to inspect. Reading begins at the stream's current position and consumes + /// the remainder of the stream. + /// + /// + /// The number of faces declared by a ttcf TrueType Collection header, or 1 + /// for an ordinary (non-collection) SFNT file - callers can therefore iterate + /// 0..GetFaceCount(file) uniformly, without a separate is-this-a-collection branch. + /// + /// Thrown when is null. + /// + /// Thrown when begins with the ttcf tag but is too short + /// to contain its fixed header prefix or its declared face offset table, or declares zero + /// faces. + /// + public static int GetFaceCount(Stream stream) + { + ArgumentNullException.ThrowIfNull(stream); + + var data = ReadAllBytes(stream); + return SfntContainer.TryReadTtcHeader(data, out var faceOffsets) ? faceOffsets.Count : 1; + } + + /// + /// Reports how many faces a font file at the specified path holds, exactly as + /// . + /// + /// The path of the font file to inspect. Must not be null or empty. + /// See . + /// Thrown when is null. + /// Thrown when is an empty string. + /// Thrown for the same condition as . + public static int GetFaceCount(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 GetFaceCount(stream); + } + /// /// Looks up the glyph index mapped to a Unicode codepoint via the font's cmap table. /// @@ -236,6 +533,19 @@ public int GetKerning(int leftGlyphIndex, int rightGlyphIndex) return _kern.GetKerning(leftGlyphIndex, rightGlyphIndex); } + /// + /// Resolves this font's family/subfamily/full/PostScript name strings from its name + /// table, preferring the typographic (16/17) family/subfamily names over the standard + /// (1/2) ones when both are present, and preferring a Windows/Unicode-BMP platform record + /// over a Macintosh platform record when both are present for the same name identifier. + /// + /// + /// A with every resolvable field populated, and any field the + /// font's name table (or the font as a whole) does not provide left + /// . Never throws. + /// + public FontNameInfo GetNameInfo() => new(_name.FamilyName, _name.SubfamilyName, _name.FullName, _name.PostScriptName); + /// /// Validates a glyph index argument shared by and /// . @@ -249,19 +559,180 @@ private void ValidateGlyphIndex(int glyphIndex) } /// - /// Parses the SFNT container, validates and parses every required table, and parses the - /// optional cmap/kern tables tolerantly. + /// Reads the entirety of an open, readable stream into an in-memory byte array, from the + /// stream's current position through its end. + /// + private static byte[] ReadAllBytes(Stream stream) + { + using var memory = new MemoryStream(); + stream.CopyTo(memory); + return memory.ToArray(); + } + + /// + /// Resolves against the font file's declared face count + /// (transparently treating an ordinary, non-collection SFNT file as a one-face + /// collection), then parses that single face via . + /// + private static TrueTypeFont LoadFromBytesWithFaceIndex(byte[] data, int faceIndex) + { + if (faceIndex < 0) + { + throw new ArgumentOutOfRangeException(nameof(faceIndex), faceIndex, "Face index must not be negative."); + } + + var offsetTableStart = 0; + if (SfntContainer.TryReadTtcHeader(data, out var faceOffsets)) + { + if (faceIndex >= faceOffsets.Count) + { + throw new ArgumentOutOfRangeException( + nameof(faceIndex), faceIndex, $"Face index must be less than the container's face count ({faceOffsets.Count})."); + } + + offsetTableStart = faceOffsets[faceIndex]; + } + else if (faceIndex != 0) + { + throw new ArgumentOutOfRangeException( + nameof(faceIndex), faceIndex, "Face index must be 0 for a font file that is not a 'ttcf' collection."); + } + else if (TryLoadStandaloneType1(data, out var type1Font)) + { + return type1Font; + } + + return LoadFromBytes(data, offsetTableStart); + } + + /// + /// Auto-detects a standalone .pfb or .pfa-framed classic PostScript Type 1 + /// font program (see /) and, if + /// recognized, parses it using as its default + /// codepoint-to-glyph-name encoding (a standalone file, unlike a PDF FontFile + /// stream loaded via , has no caller-supplied encoding available). + /// + private static bool TryLoadStandaloneType1(byte[] data, out TrueTypeFont font) + { + byte[] fontFileBytes; + int length1; + int length2; + + if (Type1PfbReader.TrySniff(data)) + { + (fontFileBytes, length1, length2) = Type1PfbReader.Read(data); + } + else if (Type1PfaReader.TrySniff(data)) + { + (fontFileBytes, length1, length2) = Type1PfaReader.Read(data); + } + else + { + font = null!; + return false; + } + + var table = Type1Table.Parse(fontFileBytes, length1, length2); + font = BuildFromType1Table(table, Type1StandardGlyphNames.CodepointToGlyphName); + return true; + } + + /// + /// Builds a directly from a parsed and + /// a codepoint-to-glyph-name encoding - the common construction path shared by + /// and . + /// + /// + /// A Type 1 font's design space is conventionally 1000 units per em (unlike TrueType's + /// conventional 1000/2048/etc. - but always caller/table-declared; Type 1 fonts never + /// declare unitsPerEm at all, so this is a fixed assumption, not a parsed value). + /// Bold/italic/fixed-pitch flags and kerning/naming data have no Type 1 equivalent + /// consulted here (a documented Non-Goal for this phase), so they are fixed to + /// //. + /// + private static TrueTypeFont BuildFromType1Table(Type1Table table, IReadOnlyDictionary codepointToGlyphName) + { + const int type1UnitsPerEm = 1000; + + var codepointToGlyphIndex = new Dictionary(); + foreach (var (codepoint, glyphName) in codepointToGlyphName) + { + if (table.TryGetGlyphIndex(glyphName, out var glyphIndex)) + { + codepointToGlyphIndex[codepoint] = glyphIndex; + } + } + + var advanceWidths = new int[table.GlyphCount]; + for (var i = 0; i < table.GlyphCount; i++) + { + advanceWidths[i] = table.GetAdvanceWidth(i); + } + + var metrics = HmtxHheaReader.FromAdvanceWidths(0, 0, 0, advanceWidths); + var cmap = CmapTable.FromMap(codepointToGlyphIndex); + + return new TrueTypeFont( + type1UnitsPerEm, table.GlyphCount, metrics, table, cmap, KernTable.Empty, NameTable.Empty, + isBold: false, isItalic: false, isFixedPitch: false); + } + + /// + /// Builds a from an already-parsed bare CFF (Type1C) table, + /// for . /// - private static TrueTypeFont LoadFromBytes(byte[] data) + /// + /// Deliberately a separate, non-shared method from + /// rather than a forced common helper: already + /// returns while returns + /// (per-glyph widths resolved from a defaultWidthX/ + /// nominalWidthX/charstring-delta combination that is never guaranteed integral), + /// so this method's own rounding step has no equivalent in + /// - sharing a single helper would otherwise force an awkward, unused rounding parameter + /// onto the Type 1 path. Everything else (the per-codepoint glyph-name lookup loop, the + /// 1000-units-per-em assumption, and the bold/italic/fixed-pitch/kerning/naming Non-Goal) + /// mirrors exactly. + /// + private static TrueTypeFont BuildFromCffTable(CffTable table, IReadOnlyDictionary codepointToGlyphName) { - var container = SfntContainer.Parse(data); + const int cffUnitsPerEm = 1000; + + var codepointToGlyphIndex = new Dictionary(); + foreach (var (codepoint, glyphName) in codepointToGlyphName) + { + if (table.TryGetGlyphIndex(glyphName, out var glyphIndex)) + { + codepointToGlyphIndex[codepoint] = glyphIndex; + } + } + + var advanceWidths = new int[table.GlyphCount]; + for (var i = 0; i < table.GlyphCount; i++) + { + advanceWidths[i] = (int)Math.Round(table.GetAdvanceWidth(i)); + } + + var metrics = HmtxHheaReader.FromAdvanceWidths(0, 0, 0, advanceWidths); + var cmap = CmapTable.FromMap(codepointToGlyphIndex); + + return new TrueTypeFont( + cffUnitsPerEm, table.GlyphCount, metrics, table, cmap, KernTable.Empty, NameTable.Empty, + isBold: false, isItalic: false, isFixedPitch: false); + } + + /// + /// Parses one face's SFNT container, validates and parses every required table (branching + /// on the font's outline flavor - see this class's remarks), and parses the optional + /// cmap/kern tables tolerantly. + /// + private static TrueTypeFont LoadFromBytes(byte[] data, int offsetTableStart) + { + var container = SfntContainer.Parse(data, offsetTableStart); 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) @@ -275,35 +746,62 @@ private static TrueTypeFont LoadFromBytes(byte[] data) 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})."); - } + var isCff = container.TryGetTable("CFF ", out var cff); - const uint maxpVersion10 = 0x00010000; if (maxp.Length < 6) { throw new InvalidDataException("The 'maxp' table is too short."); } var maxpVersion = SfntContainer.ReadUInt32(data, maxp.Offset); - if (maxpVersion != maxpVersion10) - { - throw new InvalidDataException( - "The 'maxp' table version is not 1.0 (0x00010000); CFF-oriented maxp (version 0.5) fonts are not supported."); - } + var numGlyphs = SfntContainer.ReadUInt16(data, maxp.Offset + 4); - const int maxpVersion10Size = 32; - if (maxp.Length < maxpVersion10Size) + IGlyphOutlineSource glyphs; + if (isCff) { - throw new InvalidDataException("The 'maxp' table (version 1.0) is too short; expected 32 bytes."); + const uint maxpVersion05 = 0x00005000; + if (maxpVersion != maxpVersion05) + { + throw new InvalidDataException( + "The 'maxp' table version is not 0.5 (0x00005000), as required for a CFF-outline font."); + } + + var cffTable = CffTable.Parse(data, cff.Offset, cff.Length); + if (cffTable.GlyphCount != numGlyphs) + { + throw new InvalidDataException( + $"'CFF ' CharStrings count ({cffTable.GlyphCount}) does not match 'maxp.numGlyphs' ({numGlyphs})."); + } + + glyphs = cffTable; } + else + { + const uint maxpVersion10 = 0x00010000; + if (maxpVersion != maxpVersion10) + { + throw new InvalidDataException( + "The 'maxp' table version is not 1.0 (0x00010000); CFF-oriented maxp (version 0.5) fonts require a 'CFF ' table."); + } - var numGlyphs = SfntContainer.ReadUInt16(data, maxp.Offset + 4); + const int maxpVersion10Size = 32; + if (maxp.Length < maxpVersion10Size) + { + throw new InvalidDataException("The 'maxp' table (version 1.0) is too short; expected 32 bytes."); + } + + var indexToLocFormat = SfntContainer.ReadInt16(data, head.Offset + 50); + if (indexToLocFormat != 0 && indexToLocFormat != 1) + { + throw new InvalidDataException($"'head.indexToLocFormat' has an unsupported value ({indexToLocFormat})."); + } + + var loca = container.RequireTable("loca"); + var glyf = container.RequireTable("glyf"); + glyphs = GlyfLocaReader.Parse(data, loca.Offset, loca.Length, glyf.Offset, glyf.Length, numGlyphs, indexToLocFormat == 1); + } 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) @@ -313,6 +811,18 @@ private static TrueTypeFont LoadFromBytes(byte[] data) ? KernTable.Parse(data, kernRange.Offset, kernRange.Length) : KernTable.Empty; - return new TrueTypeFont(unitsPerEm, numGlyphs, metrics, glyphs, cmap, kern); + var macStyle = SfntContainer.ReadUInt16(data, head.Offset + 44); + + var name = container.TryGetTable("name", out var nameRange) + ? NameTable.Parse(data, nameRange.Offset, nameRange.Length) + : NameTable.Empty; + + var os2 = container.TryGetTable("OS/2", out var os2Range) ? os2Range : ((int, int)?)null; + var post = container.TryGetTable("post", out var postRange) ? postRange : ((int, int)?)null; + var style = StyleTable.Parse(data, macStyle, os2, post); + + return new TrueTypeFont( + unitsPerEm, numGlyphs, metrics, glyphs, cmap, kern, + name, style.IsBold, style.IsItalic, style.IsFixedPitch); } } diff --git a/src/DemaConsulting.CanvasNet/Fonts/Type1CharstringDecryption.cs b/src/DemaConsulting.CanvasNet/Fonts/Type1CharstringDecryption.cs new file mode 100644 index 00000000..d9a404c7 --- /dev/null +++ b/src/DemaConsulting.CanvasNet/Fonts/Type1CharstringDecryption.cs @@ -0,0 +1,101 @@ +// cspell:ignore eexec lenIV charstring charstrings +namespace DemaConsulting.CanvasNet.Fonts; + +/// +/// Implements the classic PostScript Type 1 font "eexec" cipher: a simple additive stream +/// cipher (a linear congruential generator keyed by the cipher byte itself) used to encrypt +/// both a font program's private dictionary (the eexec-wrapped segment as a whole) and, +/// independently, each individual charstring/subroutine entry within that segment. +/// +/// +/// +/// The two uses share the exact same algorithm and differ only in their starting key +/// ( vs. ) and how many leading decrypted bytes +/// are discarded: the eexec-wrapped segment always discards a fixed 4 leading bytes; +/// each individual charstring/subroutine discards lenIV leading bytes (conventionally +/// 4, but overridable per font via a /lenIV declaration - see +/// ). The discarded bytes are random padding the original encoder used +/// to prime the cipher state before the real content begins; they carry no semantic meaning. +/// +/// +/// This is a pure, allocation-bounded operation: the amount of work performed by +/// is always exactly proportional to the requested byte range, with +/// no unbounded loop or recursion, so it cannot itself be a resource-exhaustion vector +/// independent of however large a slice of the font file the caller supplies. +/// +/// +internal static class Type1CharstringDecryption +{ + /// + /// The starting cipher key used to decrypt a font program's eexec-wrapped private + /// dictionary segment as a whole. + /// + public const ushort EexecR0 = 55665; + + /// + /// The starting cipher key used to decrypt each individual charstring or subroutine entry + /// within an already-eexec-decrypted private dictionary segment. + /// + public const ushort CharstringR0 = 4330; + + /// + /// The cipher's first multiplier constant, fixed by the Type 1 Font Format specification. + /// + private const ushort C1 = 52845; + + /// + /// The cipher's additive constant, fixed by the Type 1 Font Format specification. + /// + private const ushort C2 = 22719; + + /// + /// Decrypts bytes of Type 1 "eexec"-ciphered data starting at + /// within , then discards the first + /// decrypted bytes (random cipher-priming padding with no + /// semantic meaning). + /// + /// The byte array containing the ciphered data. + /// The offset of the ciphered data within . + /// The number of ciphered bytes to decrypt. + /// + /// The starting cipher key: for an eexec-wrapped segment as a whole, + /// or for an individual charstring/subroutine entry. + /// + /// + /// The number of leading decrypted bytes to discard (4 for an eexec-wrapped + /// segment; the font's declared lenIV, conventionally 4, for an individual + /// charstring/subroutine entry). + /// + /// + /// The decrypted bytes, with the first bytes already + /// removed - a new array of length length - discardCount. + /// + /// + /// Thrown when / fall outside + /// 's bounds, or when is negative or + /// greater than . + /// + public static byte[] Decrypt(byte[] data, int offset, int length, ushort r, int discardCount) + { + if (offset < 0 || length < 0 || checked((long)offset + length) > data.Length) + { + throw new InvalidDataException("Type 1 encrypted data is truncated."); + } + + if (discardCount < 0 || discardCount > length) + { + throw new InvalidDataException("Type 1 encrypted data is shorter than its declared discard count."); + } + + var plain = new byte[length]; + var key = r; + for (var i = 0; i < length; i++) + { + var cipher = data[offset + i]; + plain[i] = (byte)(cipher ^ (key >> 8)); + key = (ushort)(((cipher + key) * C1) + C2); + } + + return discardCount == 0 ? plain : plain[discardCount..]; + } +} diff --git a/src/DemaConsulting.CanvasNet/Fonts/Type1CharstringInterpreter.cs b/src/DemaConsulting.CanvasNet/Fonts/Type1CharstringInterpreter.cs new file mode 100644 index 00000000..5abc77f9 --- /dev/null +++ b/src/DemaConsulting.CanvasNet/Fonts/Type1CharstringInterpreter.cs @@ -0,0 +1,695 @@ +// cspell:ignore charstring charstrings subr subrs hsbw rmoveto hmoveto vmoveto rlineto hlineto +// cspell:ignore vlineto rrcurveto vhcurveto hvcurveto callsubr callothersubr dotsection vstem +// cspell:ignore hstem endchar seac setcurrentpoint othersubr othersubrs +using System.Globalization; +using System.Numerics; +using DemaConsulting.CanvasNet.Geometry; +using Path = DemaConsulting.CanvasNet.Geometry.Path; + +namespace DemaConsulting.CanvasNet.Fonts; + +/// +/// Executes a single glyph's classic PostScript Type 1 charstring bytecode (as defined by the +/// Adobe Type 1 Font Format specification) into geometry. +/// +/// +/// +/// Supports the complete Type 1 operator set: stem hints (hstem/vstem/ +/// hstem3/vstem3) and dotsection (consumed and discarded - this library +/// never rasterizes via hinting); the side-bearing/width operators hsbw/sbw; +/// moves (rmoveto/hmoveto/vmoveto); lines (rlineto/hlineto/ +/// vlineto); curves (rrcurveto/hvcurveto/vhcurveto); +/// closepath; subroutine calls (callsubr/return - unlike Type 2, Type 1 +/// subroutine indices carry no bias); div; the "OtherSubrs" mechanism +/// (callothersubr/pop/setcurrentpoint), including real flex geometry +/// (OtherSubrs 0/1/2) and hint replacement (OtherSubrs 3, a transparent pass-through); and +/// endchar. seac (accented composite glyph construction) is explicitly rejected +/// with - a documented Non-Goal. +/// +/// +/// Unlike (which always discards its optional leading +/// width operand, since CFF/OpenType fonts supply advance width independently via hmtx), +/// returns the glyph's width: a Type 1 font has no hmtx +/// equivalent, so its own hsbw/sbw operator is the only source of that value. +/// +/// +/// Type 1's "flex" mechanism replaces what would otherwise be two rrcurveto path +/// segments with a sequence of seven rmoveto calls bracketed by OtherSubrs 1 ("begin +/// flex") and OtherSubrs 0 ("end flex") calls (with an OtherSubrs 2 "point mark" call after +/// each rmoveto). While flexing, every rmoveto/hmoveto/vmoveto +/// delta is buffered as an absolute point rather than emitted as a real path move. On "end +/// flex", the first buffered point (a reference point used only by a real PostScript +/// interpreter to decide whether to actually flex, never part of the final outline) is +/// discarded, and the remaining six buffered points become the two control points and end +/// point of each of two rrcurveto-equivalent cubic Bezier segments. +/// +/// +/// callsubr nesting is bounded by , and the total number of +/// charstring tokens executed (across every subroutine call within one +/// invocation) is bounded by - mirroring +/// 's deterministic resource-bounding posture. +/// +/// +internal static class Type1CharstringInterpreter +{ + /// + /// The maximum callsubr nesting depth permitted before + /// is thrown. + /// + private const int MaxCallDepth = 10; + + /// + /// The maximum total number of charstring tokens (numeric operands and operators) + /// permitted across a single call, including every subroutine + /// invoked - bounding pathological/malicious subroutine call patterns deterministically. + /// + private const int MaxOperatorSteps = 200_000; + + /// + /// The maximum operand stack depth permitted before is + /// thrown. + /// + private const int MaxStackSize = 96; + + /// + /// The maximum depth permitted for the small, separate "PostScript interpreter" operand + /// stack used by the callothersubr/pop mechanism before + /// is thrown. + /// + private const int MaxPsStackSize = 32; + + /// + /// Decodes a single glyph's Type 1 charstring bytecode into geometry and + /// its declared advance width. + /// + /// + /// The font's own private-dictionary byte array (see 's remarks) - + /// every Offset in and is + /// relative to this array's start. + /// + /// The target glyph's own charstring byte range. + /// Every subroutine's byte range, indexed by subroutine number. + /// + /// The decoded glyph outline (or for an empty charstring) and the + /// width declared by the charstring's hsbw/sbw operator (0 if neither + /// was ever executed). + /// + /// + /// Thrown when the charstring bytecode is malformed or truncated, uses an operand count an + /// operator does not accept, uses an unsupported operator or escape operator (including + /// seac), uses an unsupported/malformed callothersubr sequence, references an + /// out-of-range subroutine index, or exceeds the call-depth, operator-step, or stack-size + /// bound. + /// + public static (Path Outline, double Width) Decode( + byte[] data, + (int Offset, int Length) charstring, + IReadOnlyList<(int Offset, int Length)> subrs) + { + var state = new InterpreterState(data, subrs); + state.Run(charstring, 0); + state.CloseIfOpen(); + return (state.Builder.Build(), state.Width); + } + + /// + /// Reads one Type 1 numeric operand token starting at and pushes it + /// onto , returning the position immediately after the token. + /// + /// + /// Exposed (rather than private to ) so + /// can reuse this exact number encoding for its own cheap hsbw/sbw-only + /// advance-width peek, without duplicating a second, potentially-divergent implementation. + /// Type 1's numeric encoding differs from Type 2's in one respect: the 5-byte 255 + /// form introduces a plain 32-bit signed integer, not Type 2's 16.16 fixed-point value. + /// + /// + /// Thrown when the numeric token is truncated, or when already + /// holds entries. + /// + internal static int ReadNumber(byte[] data, int pos, int end, List stack) + { + var b0 = data[pos]; + double value; + int consumed; + if (b0 is >= 32 and <= 246) + { + value = b0 - 139; + consumed = 1; + } + else if (b0 is >= 247 and <= 250) + { + if (pos + 2 > end) + { + throw new InvalidDataException("Type 1 charstring numeric operand is truncated."); + } + + value = ((b0 - 247) * 256) + data[pos + 1] + 108; + consumed = 2; + } + else if (b0 is >= 251 and <= 254) + { + if (pos + 2 > end) + { + throw new InvalidDataException("Type 1 charstring numeric operand is truncated."); + } + + value = (-(b0 - 251) * 256) - data[pos + 1] - 108; + consumed = 2; + } + else if (b0 == 255) + { + if (pos + 5 > end) + { + throw new InvalidDataException("Type 1 charstring numeric operand is truncated."); + } + + value = SfntContainer.ReadInt32(data, pos + 1); + consumed = 5; + } + else + { + throw new InvalidDataException( + string.Create( + CultureInfo.InvariantCulture, + $"Unexpected Type 1 charstring byte (0x{b0:X2}) where a numeric operand or operator was expected.")); + } + + if (stack.Count >= MaxStackSize) + { + throw new InvalidDataException("Type 1 charstring operand stack exceeds the supported size."); + } + + stack.Add(value); + return pos + consumed; + } + + /// + /// Holds the mutable interpreter state (operand stack, "PostScript interpreter" stack, + /// current point, width, flex-buffering state, and resource-bound counters) shared across + /// every nested call for a single invocation. + /// + private sealed class InterpreterState + { + private readonly byte[] _data; + private readonly IReadOnlyList<(int Offset, int Length)> _subrs; + private readonly List _stack = []; + private readonly List _psStack = []; + private readonly List<(double X, double Y)> _flexPoints = []; + private bool _hasOpenPath; + private bool _done; + private bool _flexing; + private double _x; + private double _y; + private int _steps; + + public PathBuilder Builder { get; } = new(); + + public double Width { get; private set; } + + public InterpreterState(byte[] data, IReadOnlyList<(int Offset, int Length)> subrs) + { + _data = data; + _subrs = subrs; + } + + /// + /// Executes one charstring's (or subroutine's) bytecode range, dispatching every + /// numeric operand and operator until return, endchar, or the end of the + /// byte range is reached. + /// + public void Run((int Offset, int Length) range, int depth) + { + if (_done) + { + return; + } + + if (depth > MaxCallDepth) + { + throw new InvalidDataException("Type 1 charstring subroutine call nesting exceeds the supported depth."); + } + + var pos = range.Offset; + var end = range.Offset + range.Length; + while (pos < end && !_done) + { + Step(); + var b0 = _data[pos]; + if (b0 >= 32) + { + pos = ReadNumber(_data, pos, end, _stack); + continue; + } + + if (b0 == 12) + { + if (pos + 1 >= end) + { + throw new InvalidDataException("Type 1 charstring escape operator is truncated."); + } + + var escOp = _data[pos + 1]; + pos += 2; + switch (escOp) + { + case 0: // dotsection + case 1: // vstem3 + case 2: // hstem3 + _stack.Clear(); + break; + + case 6: // seac + throw new InvalidDataException( + "Type 1 charstring 'seac' (accented composite glyph construction) is not supported."); + + case 7: HandleSbw(); break; + case 12: HandleDiv(); break; + case 16: HandleCallOtherSubr(); break; + case 17: HandlePop(); break; + case 33: HandleSetCurrentPoint(); break; + + default: + throw new InvalidDataException( + string.Create(CultureInfo.InvariantCulture, $"Unsupported Type 1 charstring escape operator (12 {escOp}).")); + } + + continue; + } + + pos++; + switch (b0) + { + case 1: // hstem + case 3: // vstem + _stack.Clear(); + break; + + case 4: HandleAxisMoveTo(isX: false); break; // vmoveto + case 5: HandleRLineTo(); break; + case 6: HandleAxisLineTo(isX: true); break; // hlineto + case 7: HandleAxisLineTo(isX: false); break; // vlineto + case 8: HandleRRCurveTo(); break; + case 9: HandleClosePath(); break; + case 10: CallSubr(depth); break; + case 11: return; // return + case 13: HandleHsbw(); break; + case 14: HandleEndChar(); break; + case 21: HandleRMoveTo(); break; + case 22: HandleAxisMoveTo(isX: true); break; // hmoveto + case 30: HandleVhCurveTo(); break; + case 31: HandleHvCurveTo(); break; + + default: + throw new InvalidDataException( + string.Create(CultureInfo.InvariantCulture, $"Unsupported Type 1 charstring operator ({b0}).")); + } + } + } + + /// + /// Closes the currently open subpath (if any) - called once after the outermost + /// call returns, so a charstring that never reaches endchar + /// still yields a well-formed closed path for whatever contour data it did produce. + /// + public void CloseIfOpen() + { + if (_hasOpenPath) + { + Builder.Close(); + _hasOpenPath = false; + } + } + + private void Step() + { + _steps++; + if (_steps > MaxOperatorSteps) + { + throw new InvalidDataException("Type 1 charstring execution exceeds the supported operator budget."); + } + } + + private void HandleHsbw() + { + if (_stack.Count != 2) + { + throw new InvalidDataException("Type 1 charstring 'hsbw' requires exactly two operands."); + } + + _x = _stack[0]; + _y = 0; + Width = _stack[1]; + _stack.Clear(); + } + + private void HandleSbw() + { + if (_stack.Count != 4) + { + throw new InvalidDataException("Type 1 charstring 'sbw' requires exactly four operands."); + } + + _x = _stack[0]; + _y = _stack[1]; + Width = _stack[2]; + _stack.Clear(); + } + + private void HandleRMoveTo() + { + if (_stack.Count != 2) + { + throw new InvalidDataException("Type 1 charstring 'rmoveto' requires exactly two operands."); + } + + ApplyMove(_stack[0], _stack[1]); + _stack.Clear(); + } + + private void HandleAxisMoveTo(bool isX) + { + if (_stack.Count != 1) + { + throw new InvalidDataException("Type 1 charstring 'hmoveto'/'vmoveto' requires exactly one operand."); + } + + ApplyMove(isX ? _stack[0] : 0, isX ? 0 : _stack[0]); + _stack.Clear(); + } + + /// + /// Applies a move delta: while flexing (see this class's remarks), buffers the + /// resulting absolute point instead of emitting a real path move; otherwise closes any + /// currently open subpath and starts a new one. + /// + private void ApplyMove(double dx, double dy) + { + _x += dx; + _y += dy; + + if (_flexing) + { + if (_flexPoints.Count >= 7) + { + throw new InvalidDataException("Type 1 charstring flex sequence buffered more than seven points."); + } + + _flexPoints.Add((_x, _y)); + return; + } + + CloseIfOpen(); + Builder.MoveTo(CurrentPoint()); + _hasOpenPath = true; + } + + private void HandleRLineTo() + { + if (_stack.Count != 2) + { + throw new InvalidDataException("Type 1 charstring 'rlineto' requires exactly two operands."); + } + + _x += _stack[0]; + _y += _stack[1]; + Builder.LineTo(CurrentPoint()); + _stack.Clear(); + } + + private void HandleAxisLineTo(bool isX) + { + if (_stack.Count != 1) + { + throw new InvalidDataException("Type 1 charstring 'hlineto'/'vlineto' requires exactly one operand."); + } + + if (isX) + { + _x += _stack[0]; + } + else + { + _y += _stack[0]; + } + + Builder.LineTo(CurrentPoint()); + _stack.Clear(); + } + + private void HandleRRCurveTo() + { + if (_stack.Count != 6) + { + throw new InvalidDataException("Type 1 charstring 'rrcurveto' requires exactly six operands."); + } + + var c1X = _x + _stack[0]; + var c1Y = _y + _stack[1]; + var c2X = c1X + _stack[2]; + var c2Y = c1Y + _stack[3]; + _x = c2X + _stack[4]; + _y = c2Y + _stack[5]; + Builder.CubicBezierTo(Pt(c1X, c1Y), Pt(c2X, c2Y), CurrentPoint()); + _stack.Clear(); + } + + private void HandleVhCurveTo() + { + if (_stack.Count != 4) + { + throw new InvalidDataException("Type 1 charstring 'vhcurveto' requires exactly four operands."); + } + + var c1X = _x; + var c1Y = _y + _stack[0]; + var c2X = c1X + _stack[1]; + var c2Y = c1Y + _stack[2]; + _x = c2X + _stack[3]; + _y = c2Y; + Builder.CubicBezierTo(Pt(c1X, c1Y), Pt(c2X, c2Y), CurrentPoint()); + _stack.Clear(); + } + + private void HandleHvCurveTo() + { + if (_stack.Count != 4) + { + throw new InvalidDataException("Type 1 charstring 'hvcurveto' requires exactly four operands."); + } + + var c1X = _x + _stack[0]; + var c1Y = _y; + var c2X = c1X + _stack[1]; + var c2Y = c1Y + _stack[2]; + _x = c2X; + _y = c2Y + _stack[3]; + Builder.CubicBezierTo(Pt(c1X, c1Y), Pt(c2X, c2Y), CurrentPoint()); + _stack.Clear(); + } + + private void HandleClosePath() + { + if (_hasOpenPath) + { + Builder.Close(); + _hasOpenPath = false; + } + + _stack.Clear(); + } + + private void CallSubr(int depth) + { + if (_stack.Count == 0) + { + throw new InvalidDataException("Type 1 charstring 'callsubr' is missing its subroutine index operand."); + } + + var index = (int)_stack[^1]; + _stack.RemoveAt(_stack.Count - 1); + + if (index < 0 || index >= _subrs.Count) + { + throw new InvalidDataException("Type 1 charstring subroutine index is out of range."); + } + + Run(_subrs[index], depth + 1); + } + + /// + /// Handles the binary div operator: consumes exactly the top two operands + /// (regardless of how many other operands already sit beneath them on the stack - a + /// common technique for constructing one operand of a later, still-pending operator + /// via division) and pushes their quotient. + /// + private void HandleDiv() + { + if (_stack.Count < 2) + { + throw new InvalidDataException("Type 1 charstring 'div' requires at least two operands."); + } + + var divisor = _stack[^1]; + var dividend = _stack[^2]; + _stack.RemoveAt(_stack.Count - 1); + _stack.RemoveAt(_stack.Count - 1); + _stack.Add(dividend / divisor); + } + + /// + /// Handles callothersubr: pops the OtherSubrs number and argument count, then + /// that many arguments, and dispatches to the three recognized OtherSubrs (flex + /// begin/point-mark/end, and hint replacement) - see this class's remarks for the flex + /// geometry conversion. Any other OtherSubrs number is rejected (fail closed). + /// + private void HandleCallOtherSubr() + { + if (_stack.Count < 2) + { + throw new InvalidDataException( + "Type 1 charstring 'callothersubr' requires at least an argument count and an OtherSubrs number."); + } + + var otherSubrNumber = (int)_stack[^1]; + var argCount = (int)_stack[^2]; + _stack.RemoveAt(_stack.Count - 1); + _stack.RemoveAt(_stack.Count - 1); + + if (argCount < 0 || argCount > _stack.Count) + { + throw new InvalidDataException("Type 1 charstring 'callothersubr' declares an invalid argument count."); + } + + var args = _stack.GetRange(_stack.Count - argCount, argCount); + _stack.RemoveRange(_stack.Count - argCount, argCount); + + switch (otherSubrNumber) + { + case 1: // begin flex + _flexing = true; + _flexPoints.Clear(); + break; + + case 2: // flex point mark - no-op; the preceding rmoveto already buffered the point + break; + + case 0: // end flex + if (!_flexing) + { + throw new InvalidDataException("Type 1 charstring flex 'end' OtherSubrs called without a matching 'begin'."); + } + + if (args.Count != 3) + { + throw new InvalidDataException("Type 1 charstring flex 'end' OtherSubrs requires exactly three arguments."); + } + + if (_flexPoints.Count != 7) + { + throw new InvalidDataException("Type 1 charstring flex sequence did not buffer exactly seven points."); + } + + EmitFlex(); + _flexing = false; + + // The real PostScript flex procedure leaves (x, y) on the PostScript + // interpreter stack for the charstring's following 'pop pop setcurrentpoint' + // to retrieve, in that order - push y first (bottom) then x (top), so the + // first 'pop' yields x and the second yields y. + PushPs(args[2]); + PushPs(args[1]); + break; + + case 3: // hint replacement - transparent pass-through + if (args.Count != 1) + { + throw new InvalidDataException("Type 1 charstring hint-replacement OtherSubrs requires exactly one argument."); + } + + PushPs(args[0]); + break; + + default: + throw new InvalidDataException( + string.Create(CultureInfo.InvariantCulture, $"Unsupported Type 1 charstring 'callothersubr' index ({otherSubrNumber}).")); + } + } + + /// + /// Converts the seven buffered flex reference points into two cubic Bezier segments: + /// the first buffered point is a reference point discarded per the Type 1 flex + /// mechanism (see this class's remarks), and the remaining six become, in order, the + /// first curve's two control points and join point, then the second curve's two + /// control points and end point. + /// + private void EmitFlex() + { + if (!_hasOpenPath) + { + throw new InvalidDataException("Type 1 charstring flex sequence occurred before the first moveto."); + } + + var c1A = _flexPoints[1]; + var c1B = _flexPoints[2]; + var joint = _flexPoints[3]; + var c2A = _flexPoints[4]; + var c2B = _flexPoints[5]; + var flexEnd = _flexPoints[6]; + + Builder.CubicBezierTo(Pt(c1A.X, c1A.Y), Pt(c1B.X, c1B.Y), Pt(joint.X, joint.Y)); + Builder.CubicBezierTo(Pt(c2A.X, c2A.Y), Pt(c2B.X, c2B.Y), Pt(flexEnd.X, flexEnd.Y)); + } + + private void PushPs(double value) + { + if (_psStack.Count >= MaxPsStackSize) + { + throw new InvalidDataException("Type 1 charstring PostScript-interpreter stack exceeds the supported size."); + } + + _psStack.Add(value); + } + + private void HandlePop() + { + if (_psStack.Count == 0) + { + throw new InvalidDataException("Type 1 charstring 'pop' with an empty PostScript-interpreter stack."); + } + + var value = _psStack[^1]; + _psStack.RemoveAt(_psStack.Count - 1); + + if (_stack.Count >= MaxStackSize) + { + throw new InvalidDataException("Type 1 charstring operand stack exceeds the supported size."); + } + + _stack.Add(value); + } + + private void HandleSetCurrentPoint() + { + if (_stack.Count != 2) + { + throw new InvalidDataException("Type 1 charstring 'setcurrentpoint' requires exactly two operands."); + } + + _x = _stack[0]; + _y = _stack[1]; + _stack.Clear(); + } + + private void HandleEndChar() + { + CloseIfOpen(); + _stack.Clear(); + _done = true; + } + + private Vector2 CurrentPoint() => Pt(_x, _y); + + private static Vector2 Pt(double x, double y) => new((float)x, (float)y); + } +} diff --git a/src/DemaConsulting.CanvasNet/Fonts/Type1PfaReader.cs b/src/DemaConsulting.CanvasNet/Fonts/Type1PfaReader.cs new file mode 100644 index 00000000..5bc7410f --- /dev/null +++ b/src/DemaConsulting.CanvasNet/Fonts/Type1PfaReader.cs @@ -0,0 +1,146 @@ +// cspell:ignore eexec sniff cleartomark +using System.Text; + +namespace DemaConsulting.CanvasNet.Fonts; + +/// +/// Reads the "PFA" plain-ASCII framing of a Type 1 font program - the same font program as +/// a .pfb file, but with its encrypted region conventionally represented as +/// hexadecimal digit text rather than raw binary bytes - and reassembles the underlying +/// cleartext and eexec-encrypted byte regions that +/// expects. +/// +/// +/// A PFA file's cleartext region precedes the literal ASCII keyword eexec; its exact +/// content is irrelevant here ( never inspects cleartext bytes, +/// only counts them), so this reader reuses the original file bytes from the start of the +/// file through the end of the eexec keyword verbatim as the cleartext region. The +/// encrypted region follows as a run of hexadecimal digit characters (conventionally wrapped +/// at a fixed column width, so embedded whitespace/newlines are tolerated and skipped) that +/// decodes to the same bytes a .pfb file's binary segment would contain, including its +/// own conventional trailing zero-padding, which is trimmed after decoding (not before, since +/// '0' is itself a valid hex digit and can't be distinguished from padding except by +/// position). +/// +internal static class Type1PfaReader +{ + private const string EexecKeyword = "eexec"; + + /// + /// Determines whether looks like a PFA file, without fully + /// parsing it. + /// + /// The candidate font file bytes. + /// if starts with %!. + public static bool TrySniff(byte[] data) => data.Length >= 2 && data[0] == '%' && data[1] == '!'; + + /// + /// Reassembles a PFA file's cleartext and hex-encoded encrypted regions into the single + /// contiguous byte layout - length1 cleartext bytes immediately followed by + /// length2 encrypted bytes - that expects. + /// + /// The complete PFA file bytes. + /// + /// The reassembled font file bytes, along with the cleartext and encrypted region + /// lengths. + /// + /// + /// Thrown when the file does not contain the literal eexec keyword, the following + /// hexadecimal run has an odd number of digits, a non-hexadecimal/non-whitespace byte is + /// encountered before any hex digit has been read, or zero bytes remain after decoding and + /// trimming trailing zero padding. + /// + public static (byte[] FontFileBytes, int Length1, int Length2) Read(byte[] data) + { + ArgumentNullException.ThrowIfNull(data); + + var text = Encoding.Latin1.GetString(data); + var keywordIndex = text.IndexOf(EexecKeyword, StringComparison.Ordinal); + if (keywordIndex < 0) + { + throw new InvalidDataException("Type 1 PFA file is missing the required 'eexec' keyword."); + } + + var length1 = keywordIndex + EexecKeyword.Length; + + var encrypted = new List(); + var haveHighNibble = false; + var highNibble = 0; + var sawAnyHexDigit = false; + + for (var pos = length1; pos < data.Length; pos++) + { + var b = data[pos]; + var digit = HexDigitValue(b); + if (digit >= 0) + { + sawAnyHexDigit = true; + if (haveHighNibble) + { + encrypted.Add((byte)((highNibble << 4) | digit)); + haveHighNibble = false; + } + else + { + highNibble = digit; + haveHighNibble = true; + } + + continue; + } + + if (IsWhitespace(b)) + { + continue; + } + + if (!sawAnyHexDigit) + { + throw new InvalidDataException( + "Type 1 PFA file's encrypted region contains a non-hexadecimal byte before any hex digit was read."); + } + + // A non-hex, non-whitespace byte after at least one hex digit marks the end of the + // hexadecimal run (for example the conventional trailing '0' padding is itself valid + // hex, but PostScript operators like 'cleartomark' are not). + break; + } + + if (haveHighNibble) + { + throw new InvalidDataException("Type 1 PFA file's encrypted region has an odd number of hexadecimal digits."); + } + + // Trim the conventional trailing zero-padding (512 zero bytes is the usual convention, + // but only the fact that it's trailing zeros matters here). + var end = encrypted.Count; + while (end > 0 && encrypted[end - 1] == 0) + { + end--; + } + + if (end == 0) + { + throw new InvalidDataException("Type 1 PFA file's encrypted region decodes to zero bytes."); + } + + var fontFileBytes = new byte[length1 + end]; + Array.Copy(data, 0, fontFileBytes, 0, length1); + for (var i = 0; i < end; i++) + { + fontFileBytes[length1 + i] = encrypted[i]; + } + + return (fontFileBytes, length1, end); + } + + private static int HexDigitValue(byte b) => b switch + { + >= (byte)'0' and <= (byte)'9' => b - '0', + >= (byte)'a' and <= (byte)'f' => b - 'a' + 10, + >= (byte)'A' and <= (byte)'F' => b - 'A' + 10, + _ => -1, + }; + + private static bool IsWhitespace(byte b) => b is (byte)' ' or (byte)'\t' or (byte)'\r' or (byte)'\n' or (byte)'\f' or 0; +} diff --git a/src/DemaConsulting.CanvasNet/Fonts/Type1PfbReader.cs b/src/DemaConsulting.CanvasNet/Fonts/Type1PfbReader.cs new file mode 100644 index 00000000..2d6e4c39 --- /dev/null +++ b/src/DemaConsulting.CanvasNet/Fonts/Type1PfbReader.cs @@ -0,0 +1,137 @@ +// cspell:ignore eexec sniff +namespace DemaConsulting.CanvasNet.Fonts; + +/// +/// Reads the classic MS-DOS/Windows "PFB" binary framing of a Type 1 font program - a +/// sequence of 0x80-prefixed segment headers, each declaring a segment type and a +/// little-endian 32-bit payload length - and reassembles the underlying cleartext and +/// eexec-encrypted byte regions that expects. +/// +/// +/// Each segment header is exactly six bytes: 0x80, a one-byte segment type +/// (0x01 = ASCII/cleartext, 0x02 = binary/encrypted, 0x03 = end of +/// file - no payload follows type 0x03), then a 4-byte little-endian payload length. +/// Real-world PFB files conventionally contain exactly three segments (cleartext, encrypted, +/// trailer cleartext) but this reader does not assume that: it loops generically over +/// however many segments are present, concatenating every 0x01 segment seen before the +/// first 0x02 segment into the cleartext region, every 0x02 segment into the +/// encrypted region (also generically, in case more than one is present), and discarding any +/// 0x01 segment seen after the first 0x02 segment (the conventional trailing +/// "512 zeros + cleartomark" footer, irrelevant to glyph decoding). +/// +internal static class Type1PfbReader +{ + private const byte SegmentMarker = 0x80; + private const byte TypeAscii = 0x01; + private const byte TypeBinary = 0x02; + private const byte TypeEndOfFile = 0x03; + private const int HeaderSize = 6; + + /// + /// Determines whether begins with a PFB segment header, without + /// fully parsing the file. + /// + /// The candidate font file bytes. + /// if looks like a PFB file. + public static bool TrySniff(byte[] data) => + data.Length >= 2 && data[0] == SegmentMarker && data[1] is TypeAscii or TypeBinary; + + /// + /// Reassembles a PFB file's cleartext and encrypted regions into the single contiguous + /// byte layout - length1 cleartext bytes immediately followed by length2 + /// encrypted bytes - that expects. + /// + /// The complete PFB file bytes. + /// + /// The reassembled font file bytes, along with the cleartext and encrypted region + /// lengths. + /// + /// + /// Thrown when a segment header or its declared payload is truncated, an unrecognized + /// segment type byte is encountered, or the file contains no 0x02 (binary/encrypted) + /// segment. + /// + public static (byte[] FontFileBytes, int Length1, int Length2) Read(byte[] data) + { + ArgumentNullException.ThrowIfNull(data); + + var cleartext = new List(); + var encrypted = new List(); + var sawBinarySegment = false; + + var pos = 0; + while (true) + { + if (pos >= data.Length) + { + throw new InvalidDataException("Type 1 PFB file is missing its terminating end-of-file segment."); + } + + if (data[pos] != SegmentMarker) + { + throw new InvalidDataException("Type 1 PFB file segment header does not start with the expected 0x80 marker byte."); + } + + if (pos + 1 >= data.Length) + { + throw new InvalidDataException("Type 1 PFB file segment header is truncated."); + } + + var segmentType = data[pos + 1]; + if (segmentType == TypeEndOfFile) + { + break; + } + + if (segmentType != TypeAscii && segmentType != TypeBinary) + { + throw new InvalidDataException( + $"Type 1 PFB file contains an unrecognized segment type byte (0x{segmentType:X2})."); + } + + if (pos + HeaderSize > data.Length) + { + throw new InvalidDataException("Type 1 PFB file segment header is truncated."); + } + + var length = ReadLengthLittleEndian(data, pos + 2); + var payloadStart = pos + HeaderSize; + if (length < 0 || checked((long)payloadStart + length) > data.Length) + { + throw new InvalidDataException("Type 1 PFB file segment payload is truncated."); + } + + if (segmentType == TypeAscii) + { + if (!sawBinarySegment) + { + cleartext.AddRange(new ArraySegment(data, payloadStart, length)); + } + + // An ASCII segment seen after the first binary segment is the conventional + // trailer (padding zeros + 'cleartomark') and is intentionally discarded. + } + else + { + sawBinarySegment = true; + encrypted.AddRange(new ArraySegment(data, payloadStart, length)); + } + + pos = payloadStart + length; + } + + if (!sawBinarySegment) + { + throw new InvalidDataException("Type 1 PFB file contains no binary (encrypted) segment."); + } + + var fontFileBytes = new byte[cleartext.Count + encrypted.Count]; + cleartext.CopyTo(fontFileBytes, 0); + encrypted.CopyTo(fontFileBytes, cleartext.Count); + + return (fontFileBytes, cleartext.Count, encrypted.Count); + } + + private static int ReadLengthLittleEndian(byte[] data, int offset) => + data[offset] | (data[offset + 1] << 8) | (data[offset + 2] << 16) | (data[offset + 3] << 24); +} diff --git a/src/DemaConsulting.CanvasNet/Fonts/Type1StandardGlyphNames.cs b/src/DemaConsulting.CanvasNet/Fonts/Type1StandardGlyphNames.cs new file mode 100644 index 00000000..4513d403 --- /dev/null +++ b/src/DemaConsulting.CanvasNet/Fonts/Type1StandardGlyphNames.cs @@ -0,0 +1,140 @@ +namespace DemaConsulting.CanvasNet.Fonts; + +/// +/// A small, curated table mapping Unicode codepoints to their conventional Adobe Glyph List +/// (AGL)-style Type 1 glyph names, covering common Latin/ASCII characters - used as the +/// default encoding for a standalone .pfb/.pfa font program auto-detected by +/// , which (unlike ) +/// has no caller-supplied codepoint-to-glyph-name mapping available. +/// +/// +/// This table intentionally duplicates (by content, not by shared code) the small subset of +/// the Adobe Glyph List that a PDF-aware caller's own, more complete glyph-name table would +/// also cover - the core DemaConsulting.CanvasNet assembly cannot depend on the +/// downstream DemaConsulting.CanvasNet.Pdf assembly (the dependency direction is the +/// other way around), so a small, self-contained, core-owned table is required for this +/// assembly's own standalone-loading feature to work without that dependency. Only glyphs +/// needed to resolve common Latin/ASCII text are covered; a font whose glyph names fall +/// outside this vocabulary simply has no cmap entry synthesized for the corresponding +/// codepoint (see ), matching this library's existing +/// "gracefully degrade" posture for unmapped codepoints elsewhere. +/// +internal static class Type1StandardGlyphNames +{ + /// + /// The reverse mapping (Unicode codepoint to glyph name) built from this class's own + /// curated glyph-name-to-codepoint vocabulary. When more than one glyph name maps to the + /// same codepoint, the first one encountered wins. + /// + internal static readonly IReadOnlyDictionary CodepointToGlyphName = BuildCodepointToGlyphName(); + + private static IReadOnlyDictionary BuildCodepointToGlyphName() + { + var glyphNameToCodepoint = new (string Name, int Codepoint)[] + { + ("space", ' '), + ("exclam", '!'), + ("quotedbl", '"'), + ("numbersign", '#'), + ("dollar", '$'), + ("percent", '%'), + ("ampersand", '&'), + ("quotesingle", '\''), + ("parenleft", '('), + ("parenright", ')'), + ("asterisk", '*'), + ("plus", '+'), + ("comma", ','), + ("hyphen", '-'), + ("period", '.'), + ("slash", '/'), + ("zero", '0'), + ("one", '1'), + ("two", '2'), + ("three", '3'), + ("four", '4'), + ("five", '5'), + ("six", '6'), + ("seven", '7'), + ("eight", '8'), + ("nine", '9'), + ("colon", ':'), + ("semicolon", ';'), + ("less", '<'), + ("equal", '='), + ("greater", '>'), + ("question", '?'), + ("at", '@'), + ("A", 'A'), + ("B", 'B'), + ("C", 'C'), + ("D", 'D'), + ("E", 'E'), + ("F", 'F'), + ("G", 'G'), + ("H", 'H'), + ("I", 'I'), + ("J", 'J'), + ("K", 'K'), + ("L", 'L'), + ("M", 'M'), + ("N", 'N'), + ("O", 'O'), + ("P", 'P'), + ("Q", 'Q'), + ("R", 'R'), + ("S", 'S'), + ("T", 'T'), + ("U", 'U'), + ("V", 'V'), + ("W", 'W'), + ("X", 'X'), + ("Y", 'Y'), + ("Z", 'Z'), + ("bracketleft", '['), + ("backslash", '\\'), + ("bracketright", ']'), + ("asciicircum", '^'), + ("underscore", '_'), + ("grave", '`'), + ("a", 'a'), + ("b", 'b'), + ("c", 'c'), + ("d", 'd'), + ("e", 'e'), + ("f", 'f'), + ("g", 'g'), + ("h", 'h'), + ("i", 'i'), + ("j", 'j'), + ("k", 'k'), + ("l", 'l'), + ("m", 'm'), + ("n", 'n'), + ("o", 'o'), + ("p", 'p'), + ("q", 'q'), + ("r", 'r'), + ("s", 's'), + ("t", 't'), + ("u", 'u'), + ("v", 'v'), + ("w", 'w'), + ("x", 'x'), + ("y", 'y'), + ("z", 'z'), + ("braceleft", '{'), + ("bar", '|'), + ("braceright", '}'), + ("asciitilde", '~'), + }; + + var result = new Dictionary(); + foreach (var (name, codepoint) in glyphNameToCodepoint) + { + result.TryAdd(codepoint, name); // first-wins on a (not expected) collision + } + + return result; + } +} diff --git a/src/DemaConsulting.CanvasNet/Fonts/Type1Table.cs b/src/DemaConsulting.CanvasNet/Fonts/Type1Table.cs new file mode 100644 index 00000000..0d6fe0c5 --- /dev/null +++ b/src/DemaConsulting.CanvasNet/Fonts/Type1Table.cs @@ -0,0 +1,558 @@ +// cspell:ignore eexec lenIV charstring charstrings subr subrs notdef hsbw sbw +// cspell:ignore noaccess definefont currentfile closefile +using System.Globalization; +using System.Text; +using Path = DemaConsulting.CanvasNet.Geometry.Path; + +namespace DemaConsulting.CanvasNet.Fonts; + +/// +/// Parses a classic PostScript Type 1 font program - a cleartext segment (skipped entirely, +/// never inspected) followed by an eexec-encrypted private-dictionary segment holding +/// /Subrs and /CharStrings - and decodes individual glyph outlines from Type 1 +/// charstring bytecode into geometry via +/// . +/// +/// +/// +/// skips the cleartext segment's length1 bytes entirely - their +/// content (the font's /FontMatrix, its own built-in /Encoding, etc.) is never +/// parsed, a deliberate scope decision documented on - +/// then eexec-decrypts the following length2 bytes +/// (, discarding the fixed leading 4 bytes) into +/// the private dictionary's plaintext. This exact "skip length1, decrypt length2" +/// byte-layout contract is also what lets / +/// feed a standalone .pfb/.pfa font program to this same method unchanged. +/// +/// +/// Within that plaintext, /Subrs and /CharStrings entries are located by an ad +/// hoc, procedure-name-agnostic binary-blob scanner: entries have the structural shape +/// <integer> <word-token> <exactly-that-many-raw-bytes> (preceded by +/// dup <index> for /Subrs, or /<name> for /CharStrings). +/// The word token between the length and the raw bytes is the font's own aliased "read binary +/// data" procedure name (conventionally RD/-| or similar) - its literal spelling +/// varies per font and is never inspected, only skipped, since the entry's boundaries are +/// already fully determined by the declared length. /Subrs and /CharStrings +/// themselves are not aliased - they are the private dictionary's own fixed key names - so +/// locating those two keywords directly is reliable regardless of which font produced the +/// file. The /CharStrings scan (see ) is bounded by that +/// dictionary's own matching closing end keyword - tracked via begin/end +/// nesting depth - rather than by the end of the decrypted plaintext, so trailing Type 1 +/// font-closing PostScript boilerplate some real-world producers emit immediately afterward +/// (for example end end readonly put noaccess put dup /FontName get exch definefont pop +/// mark currentfile closefile) is never misinterpreted as further glyph entries. +/// +/// +/// Each entry's raw bytes are then individually decrypted +/// (, discarding the font's declared +/// /lenIV leading bytes - default 4 if no /lenIV declaration is present) +/// into a private byte array, copied once at parse time (the same "copy once, offset/length +/// tuples into that array" convention uses for its own CFF table +/// bytes). .notdef, if present, is forced to glyph index 0; every other glyph +/// name gets the next sequential glyph index in /CharStrings dict-encounter order. +/// +/// +/// Like , no individual glyph's charstring is decoded until +/// is called for that specific index. Each glyph's advance width +/// is instead resolved eagerly at parse time via a cheap hsbw/sbw-only peek +/// (that operator is spec-guaranteed to be the very first in every glyph's charstring, with +/// only literal numeric operands), since a Type 1 font has no hmtx-equivalent table to +/// supply advance widths independently. +/// +/// +internal sealed class Type1Table : IGlyphOutlineSource +{ + /// + /// The maximum /Subrs index this parser will accept. A legitimate Type 1 font's + /// subroutine index is small (classic Type 1 fonts rarely declare more than a few hundred + /// subroutines); this cap matches the 16-bit subroutine-index space used by the CFF/Type 2 + /// charstring format and rejects a crafted font declaring a sparse, enormous index before + /// sizes the dense array from it (which would + /// otherwise risk an out-of-memory allocation driven entirely by untrusted font data). + /// + private const int MaxSubrIndex = 65535; + + private readonly byte[] _data; + private readonly (int Offset, int Length)[] _charStrings; + private readonly (int Offset, int Length)[] _subrs; + private readonly int[] _advanceWidths; + private readonly IReadOnlyDictionary _nameToGlyphIndex; + + /// + /// The total number of glyphs described by the /CharStrings dictionary. + /// + public int GlyphCount => _charStrings.Length; + + private Type1Table( + byte[] data, + (int Offset, int Length)[] charStrings, + (int Offset, int Length)[] subrs, + int[] advanceWidths, + IReadOnlyDictionary nameToGlyphIndex) + { + _data = data; + _charStrings = charStrings; + _subrs = subrs; + _advanceWidths = advanceWidths; + _nameToGlyphIndex = nameToGlyphIndex; + } + + /// + /// Parses a Type 1 font program from its cleartext/encrypted segment byte counts. + /// + /// + /// The complete font program bytes: cleartext bytes (skipped, + /// never parsed) immediately followed by eexec-encrypted + /// bytes. + /// + /// The cleartext segment's length, in bytes. + /// The encrypted segment's length, in bytes. + /// A new ready to decode glyph outlines. + /// + /// Thrown when / are negative or + /// exceed 's bounds, when the decrypted private dictionary + /// is missing the required /CharStrings keyword, when a /Subrs/ + /// /CharStrings entry or the optional /lenIV declaration is malformed or + /// truncated, or when the /CharStrings dictionary contains no glyphs. + /// + public static Type1Table Parse(byte[] fontFileBytes, int length1, int length2) + { + ArgumentNullException.ThrowIfNull(fontFileBytes); + if (length1 < 0 || length2 < 0) + { + throw new InvalidDataException("Type 1 'Length1'/'Length2' must not be negative."); + } + + if (checked((long)length1 + length2) > fontFileBytes.Length) + { + throw new InvalidDataException("Type 1 font program is shorter than its declared 'Length1' + 'Length2'."); + } + + var plaintext = Type1CharstringDecryption.Decrypt(fontFileBytes, length1, length2, Type1CharstringDecryption.EexecR0, discardCount: 4); + var text = Encoding.Latin1.GetString(plaintext); + + var lenIv = ParseLenIv(text); + + const string charStringsKeyword = "/CharStrings"; + var charStringsKeywordStart = text.IndexOf(charStringsKeyword, StringComparison.Ordinal); + if (charStringsKeywordStart < 0) + { + throw new InvalidDataException("Type 1 font program is missing the required '/CharStrings' dictionary."); + } + + const string subrsKeyword = "/Subrs"; + var subrsKeywordStart = text.IndexOf(subrsKeyword, StringComparison.Ordinal); + + var rawSubrs = new Dictionary(); + if (subrsKeywordStart >= 0 && subrsKeywordStart < charStringsKeywordStart) + { + ScanSubrs(text, subrsKeywordStart + subrsKeyword.Length, charStringsKeywordStart, rawSubrs); + } + + var rawCharStrings = new List<(string Name, int Offset, int Length)>(); + ScanCharStrings(text, charStringsKeywordStart + charStringsKeyword.Length, text.Length, rawCharStrings); + if (rawCharStrings.Count == 0) + { + throw new InvalidDataException("Type 1 font program's '/CharStrings' dictionary contains no glyphs."); + } + + // Force '.notdef' (if present) to glyph index 0, preserving encounter order otherwise. + var notdefEntryIndex = rawCharStrings.FindIndex(e => e.Name == ".notdef"); + if (notdefEntryIndex > 0) + { + var notdefEntry = rawCharStrings[notdefEntryIndex]; + rawCharStrings.RemoveAt(notdefEntryIndex); + rawCharStrings.Insert(0, notdefEntry); + } + + var combined = new List(); + + var maxSubrIndex = rawSubrs.Count == 0 ? -1 : rawSubrs.Keys.Max(); + if (maxSubrIndex > MaxSubrIndex) + { + throw new InvalidDataException( + $"Type 1 font program's '/Subrs' index {maxSubrIndex} exceeds the maximum supported index of {MaxSubrIndex}."); + } + + var subrs = new (int Offset, int Length)[maxSubrIndex + 1]; + foreach (var (index, entry) in rawSubrs) + { + var decrypted = Type1CharstringDecryption.Decrypt(plaintext, entry.Offset, entry.Length, Type1CharstringDecryption.CharstringR0, lenIv); + subrs[index] = (combined.Count, decrypted.Length); + combined.AddRange(decrypted); + } + + var charStrings = new (int Offset, int Length)[rawCharStrings.Count]; + var nameToGlyphIndex = new Dictionary(); + for (var i = 0; i < rawCharStrings.Count; i++) + { + var entry = rawCharStrings[i]; + var decrypted = Type1CharstringDecryption.Decrypt(plaintext, entry.Offset, entry.Length, Type1CharstringDecryption.CharstringR0, lenIv); + charStrings[i] = (combined.Count, decrypted.Length); + combined.AddRange(decrypted); + nameToGlyphIndex.TryAdd(entry.Name, i); // first-wins on a duplicate glyph name + } + + var data = combined.ToArray(); + var advanceWidths = new int[charStrings.Length]; + for (var i = 0; i < charStrings.Length; i++) + { + advanceWidths[i] = PeekAdvanceWidth(data, charStrings[i]); + } + + return new Type1Table(data, charStrings, subrs, advanceWidths, nameToGlyphIndex); + } + + /// + /// Decodes a single glyph's outline from Type 1 charstring bytecode. + /// + /// 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 charstring bytecode is malformed, truncated, uses an + /// unsupported operator (including seac), or exceeds an internal recursion/step + /// bound. See for the complete set of rejection + /// conditions. + /// + public Path GetGlyphOutline(int glyphIndex) + { + ValidateGlyphIndex(glyphIndex); + var (outline, _) = Type1CharstringInterpreter.Decode(_data, _charStrings[glyphIndex], _subrs); + return outline; + } + + /// + /// Looks up a glyph's advance width, resolved eagerly at parse time from that glyph's own + /// hsbw/sbw operator. + /// + /// The glyph index to look up. + /// The glyph's advance width, in font design units. + /// + /// Thrown when is outside [0, GlyphCount). + /// + public int GetAdvanceWidth(int glyphIndex) + { + ValidateGlyphIndex(glyphIndex); + return _advanceWidths[glyphIndex]; + } + + /// + /// Looks up a glyph's index by its /CharStrings glyph name. + /// + /// The glyph name to look up. + /// The resolved glyph index, if found. + /// if is a known glyph name. + public bool TryGetGlyphIndex(string glyphName, out int glyphIndex) => _nameToGlyphIndex.TryGetValue(glyphName, out glyphIndex); + + private void ValidateGlyphIndex(int glyphIndex) + { + if (glyphIndex < 0 || glyphIndex >= GlyphCount) + { + throw new ArgumentOutOfRangeException(nameof(glyphIndex), glyphIndex, "Glyph index is out of range."); + } + } + + /// + /// Parses an optional /lenIV <n> def declaration from the decrypted private + /// dictionary plaintext, defaulting to 4 (the Type 1 Font Format's documented + /// default) when no such declaration is present. + /// + private static int ParseLenIv(string text) + { + const string keyword = "/lenIV"; + var idx = text.IndexOf(keyword, StringComparison.Ordinal); + if (idx < 0) + { + return 4; + } + + var pos = idx + keyword.Length; + if (!NextToken(text, ref pos, text.Length, out var tokenStart, out var tokenEnd)) + { + throw new InvalidDataException("Type 1 font program's '/lenIV' declaration is truncated."); + } + + if (!int.TryParse(text.AsSpan(tokenStart, tokenEnd - tokenStart), NumberStyles.Integer, CultureInfo.InvariantCulture, out var value) + || value < 0) + { + throw new InvalidDataException("Type 1 font program's '/lenIV' declaration is malformed."); + } + + return value; + } + + /// + /// Scans the /Subrs region (from just after the /Subrs keyword through the + /// start of the /CharStrings keyword) for dup <index> <length> + /// <token> <raw bytes> entries, agnostic to the literal spelling of + /// <token> (the font's own aliased "read binary data" procedure name). + /// + private static void ScanSubrs(string text, int start, int end, Dictionary result) + { + var pos = start; + while (pos < end) + { + if (!NextToken(text, ref pos, end, out var tokenStart, out var tokenEnd)) + { + break; + } + + if (tokenEnd - tokenStart != 3 || text[tokenStart] != 'd' || text[tokenStart + 1] != 'u' || text[tokenStart + 2] != 'p') + { + continue; // not the 'dup' keyword - skip unrelated tokens between entries + } + + if (!NextToken(text, ref pos, end, out var idxStart, out var idxEnd)) + { + throw new InvalidDataException("Type 1 font program's '/Subrs' entry is truncated."); + } + + if (!int.TryParse(text.AsSpan(idxStart, idxEnd - idxStart), NumberStyles.Integer, CultureInfo.InvariantCulture, out var index) + || index < 0) + { + throw new InvalidDataException("Type 1 font program's '/Subrs' entry has a malformed index."); + } + + var length = ReadLengthToken(text, ref pos, end, "'/Subrs'"); + SkipProcNameToken(text, ref pos, end, "'/Subrs'"); + + if (pos >= end || !IsWhitespace(text[pos])) + { + throw new InvalidDataException("Type 1 font program's '/Subrs' entry is missing its single separator byte before the raw data."); + } + + pos++; + + if (checked((long)pos + length) > end) + { + throw new InvalidDataException("Type 1 font program's '/Subrs' raw charstring data is truncated."); + } + + result[index] = (pos, length); + pos += length; + } + } + + /// + /// Scans the /CharStrings region (from just after the /CharStrings keyword + /// through that dictionary's own matching closing end keyword - tracked via + /// begin/end nesting depth, starting at the begin that opens the + /// /CharStrings n dict dup begin construct) for /<name> <length> + /// <token> <raw bytes> entries, agnostic to the literal spelling of + /// <token>. Bounding the scan at the dictionary's own closing end + /// (rather than scanning unconditionally to ) means trailing Type 1 + /// font-closing PostScript boilerplate that some real-world producers emit immediately + /// after the /CharStrings dictionary closes (for example end end readonly put + /// noaccess put dup /FontName get exch definefont pop mark currentfile closefile) is + /// never misinterpreted as further glyph entries. + /// + private static void ScanCharStrings(string text, int start, int end, List<(string Name, int Offset, int Length)> result) + { + var pos = start; + var depth = 0; + while (pos < end) + { + if (!NextToken(text, ref pos, end, out var tokenStart, out var tokenEnd)) + { + break; + } + + if (text[tokenStart] != '/') + { + var tokenLength = tokenEnd - tokenStart; + if (tokenLength == 5 && text.AsSpan(tokenStart, 5).SequenceEqual("begin")) + { + depth++; + } + else if (tokenLength == 3 && text[tokenStart] == 'e' && text[tokenStart + 1] == 'n' && text[tokenStart + 2] == 'd') + { + if (depth == 0) + { + break; // an 'end' with no matching 'begin' seen yet - stop defensively + } + + depth--; + if (depth == 0) + { + break; // the 'end' matching this dictionary's own opening 'begin' + } + } + + continue; // not a glyph-name token - skip unrelated tokens between entries + } + + var name = text[(tokenStart + 1)..tokenEnd]; + if (name.Length == 0) + { + throw new InvalidDataException("Type 1 font program's '/CharStrings' dictionary contains an empty glyph name."); + } + + var length = ReadLengthToken(text, ref pos, end, "'/CharStrings'"); + SkipProcNameToken(text, ref pos, end, "'/CharStrings'"); + + if (pos >= end || !IsWhitespace(text[pos])) + { + throw new InvalidDataException( + "Type 1 font program's '/CharStrings' entry is missing its single separator byte before the raw data."); + } + + pos++; + + if (checked((long)pos + length) > end) + { + throw new InvalidDataException("Type 1 font program's '/CharStrings' raw charstring data is truncated."); + } + + result.Add((name, pos, length)); + pos += length; + } + } + + private static int ReadLengthToken(string text, ref int pos, int end, string entryKind) + { + if (!NextToken(text, ref pos, end, out var lenStart, out var lenEnd)) + { + throw new InvalidDataException( + string.Create(CultureInfo.InvariantCulture, $"Type 1 font program's {entryKind} entry is truncated.")); + } + + if (!int.TryParse(text.AsSpan(lenStart, lenEnd - lenStart), NumberStyles.Integer, CultureInfo.InvariantCulture, out var length) + || length < 0) + { + throw new InvalidDataException( + string.Create(CultureInfo.InvariantCulture, $"Type 1 font program's {entryKind} entry has a malformed length.")); + } + + return length; + } + + private static void SkipProcNameToken(string text, ref int pos, int end, string entryKind) + { + if (!NextToken(text, ref pos, end, out _, out _)) + { + throw new InvalidDataException( + string.Create(CultureInfo.InvariantCulture, $"Type 1 font program's {entryKind} entry is truncated.")); + } + } + + /// + /// Skips leading Type 1/PostScript whitespace, then returns the next contiguous run of + /// non-whitespace characters as a token. + /// + private static bool NextToken(string text, ref int pos, int end, out int tokenStart, out int tokenEnd) + { + while (pos < end && IsWhitespace(text[pos])) + { + pos++; + } + + if (pos >= end) + { + tokenStart = pos; + tokenEnd = pos; + return false; + } + + tokenStart = pos; + while (pos < end && !IsWhitespace(text[pos])) + { + pos++; + } + + tokenEnd = pos; + return true; + } + + /// + /// Classifies a byte (reinterpreted as a Latin-1 character) as PostScript whitespace: + /// space, tab, carriage return, line feed, form feed, or NUL. + /// + private static bool IsWhitespace(char c) => c is ' ' or '\t' or '\r' or '\n' or '\f' or '\0'; + + /// + /// Peeks a single glyph's charstring for only its leading hsbw/sbw operator + /// to resolve its advance width, without running the full charstring interpreter. + /// + /// + /// Tolerates a leading div (escape 12 12) operator interleaved with the number + /// pushes that precede hsbw/sbw - real-world Type 1 fonts commonly encode a + /// fractional side-bearing or width (which plain Type 1 integers cannot represent) as + /// <numerator> <denominator> div immediately before hsbw/sbw + /// (for example 0 20225 61 div hsbw to express a width of 331.56...). No other + /// operator (subroutine calls, flex, etc.) is tolerated here - this remains a narrow, + /// stack-only peek, not a general-purpose interpreter; 's full + /// run is the authoritative source for anything + /// beyond this. + /// + private static int PeekAdvanceWidth(byte[] data, (int Offset, int Length) charstring) + { + var pos = charstring.Offset; + var end = charstring.Offset + charstring.Length; + var stack = new List(); + while (pos < end) + { + var b0 = data[pos]; + if (b0 >= 32) + { + pos = Type1CharstringInterpreter.ReadNumber(data, pos, end, stack); + continue; + } + + pos++; + if (b0 == 13) // hsbw + { + if (stack.Count != 2) + { + throw new InvalidDataException("Type 1 charstring 'hsbw' requires exactly two operands."); + } + + return (int)Math.Round(stack[1], MidpointRounding.AwayFromZero); + } + + if (b0 == 12) + { + if (pos >= end) + { + throw new InvalidDataException("Type 1 charstring escape operator is truncated."); + } + + var escOp = data[pos]; + pos++; + if (escOp == 7) // sbw + { + if (stack.Count != 4) + { + throw new InvalidDataException("Type 1 charstring 'sbw' requires exactly four operands."); + } + + return (int)Math.Round(stack[2], MidpointRounding.AwayFromZero); + } + + if (escOp == 12) // div - see this method's remarks + { + if (stack.Count < 2) + { + throw new InvalidDataException("Type 1 charstring 'div' requires at least two operands."); + } + + var divisor = stack[^1]; + var dividend = stack[^2]; + stack.RemoveAt(stack.Count - 1); + stack.RemoveAt(stack.Count - 1); + stack.Add(dividend / divisor); + continue; + } + } + + throw new InvalidDataException("Type 1 charstring does not begin with 'hsbw'/'sbw'."); + } + + throw new InvalidDataException("Type 1 charstring is truncated before 'hsbw'/'sbw'."); + } +} diff --git a/test/DemaConsulting.CanvasNet.Pdf.Tests/DemaConsulting.CanvasNet.Pdf.Tests.csproj b/test/DemaConsulting.CanvasNet.Pdf.Tests/DemaConsulting.CanvasNet.Pdf.Tests.csproj new file mode 100644 index 00000000..c454c651 --- /dev/null +++ b/test/DemaConsulting.CanvasNet.Pdf.Tests/DemaConsulting.CanvasNet.Pdf.Tests.csproj @@ -0,0 +1,95 @@ + + + + + net8.0;net9.0;net10.0 + latest + enable + enable + + Exe + false + true + true + + + true + true + true + latest + + + + + + + + + + + + all + compile; runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + + + + PreserveNewest + + + + + + + PreserveNewest + + + + diff --git a/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfDocumentEncryptionTests.cs b/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfDocumentEncryptionTests.cs new file mode 100644 index 00000000..d56ca040 --- /dev/null +++ b/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfDocumentEncryptionTests.cs @@ -0,0 +1,1242 @@ +// cspell:ignore AESV StdCF sAlT ObjStm +using System.Reflection; +using System.Security.Cryptography; +using System.Text; +using DemaConsulting.CanvasNet.Codecs; + +namespace DemaConsulting.CanvasNet.Pdf.Tests; + +/// +/// Tests for the PDF "Standard" security handler (/Filter /Standard) with an empty +/// user password: RC4 (40/128-bit), AES-128 (/V 4//CFM /AESV2), and AES-256 +/// using the simpler R5 key derivation (/V 5//R 5//CFM /AESV3), plus the +/// scope boundaries this phase deliberately still rejects (/R 6 and a genuinely +/// required non-empty password). +/// +/// +/// Every positive test below builds its own minimal encrypted PDF entirely in-memory, using +/// test-only helper methods that independently re-derive the ISO 32000-1 Algorithm 1/2/3/4/5 +/// and ISO 32000-2 Algorithm 2.A steps directly from the specification text (not copy-pasted +/// from PdfDocument.Encryption.cs's production implementation), so that a bug shared +/// between the production decrypt path and a copy-pasted test-side encrypt path could not +/// silently mask itself. RC4 is symmetric, so the same helper both +/// "encrypts" (here) and decrypts (in production); every other primitive (MD5/SHA-256/AES) is +/// the same BCL primitive either side would have to use regardless. +/// +public class PdfDocumentEncryptionTests +{ + /// The opaque black color the re f operator sequence paints with. + private static readonly Canvas.Rgba32 Black = new(0, 0, 0, 255); + + /// + /// The standard 32-byte password padding string (ISO 32000-1 7.6.3.3), used verbatim as + /// the padded empty password throughout every helper below. + /// + private static readonly byte[] PasswordPadding = + [ + 0x28, 0xBF, 0x4E, 0x5E, 0x4E, 0x75, 0x8A, 0x41, 0x64, 0x00, 0x4E, 0x56, 0xFF, 0xFA, 0x01, 0x08, + 0x2E, 0x2E, 0x00, 0xB6, 0xD0, 0x68, 0x3E, 0x80, 0x2F, 0x0C, 0xA9, 0xFE, 0x64, 0x53, 0x69, 0x7A, + ]; + + #region Test-only mirrored cryptographic helpers + + /// A from-scratch, hand-rolled RC4 stream cipher (classic KSA/PRGA) - symmetric, so this single method both "encrypts" test fixtures and would decrypt them. + private static byte[] Rc4(byte[] key, byte[] data) + { + var state = new byte[256]; + for (var i = 0; i < 256; i++) + { + state[i] = (byte)i; + } + + var j = 0; + for (var i = 0; i < 256; i++) + { + j = (j + state[i] + key[i % key.Length]) & 0xFF; + (state[i], state[j]) = (state[j], state[i]); + } + + var output = new byte[data.Length]; + var x = 0; + j = 0; + for (var n = 0; n < data.Length; n++) + { + x = (x + 1) & 0xFF; + j = (j + state[x]) & 0xFF; + (state[x], state[j]) = (state[j], state[x]); + var keystreamByte = state[(state[x] + state[j]) & 0xFF]; + output[n] = (byte)(data[n] ^ keystreamByte); + } + + return output; + } + + /// + /// Computes the Encrypt dictionary's /O entry per ISO 32000-1 Algorithm 3's encrypt + /// direction, given explicit padded owner and user passwords (both + /// for the shared empty-password case most existing tests + /// still exercise). + /// + private static byte[] ComputeOwnerEntryAlgorithm3(int keyLengthBytes, int revision, byte[] paddedOwnerPasswordBytes, byte[] paddedUserPasswordBytes) + { + var digest = MD5.HashData(paddedOwnerPasswordBytes); + if (revision >= 3) + { + for (var i = 0; i < 50; i++) + { + digest = MD5.HashData(digest.AsSpan(0, keyLengthBytes).ToArray()); + } + } + + var ownerKey = digest.AsSpan(0, keyLengthBytes).ToArray(); + var result = Rc4(ownerKey, paddedUserPasswordBytes); + + if (revision >= 3) + { + for (var round = 1; round <= 19; round++) + { + var roundKey = new byte[ownerKey.Length]; + for (var i = 0; i < ownerKey.Length; i++) + { + roundKey[i] = (byte)(ownerKey[i] ^ round); + } + + result = Rc4(roundKey, result); + } + } + + return result; + } + + /// Computes the file encryption key per ISO 32000-1 Algorithm 2, given an already-padded 32-byte password. + private static byte[] ComputeFileKeyAlgorithm2(byte[] paddedPasswordBytes, byte[] oBytes, int permissions, byte[] idBytes, int keyLengthBytes, int revision) + { + using var input = new MemoryStream(); + input.Write(paddedPasswordBytes); + input.Write(oBytes); + input.Write([(byte)permissions, (byte)(permissions >> 8), (byte)(permissions >> 16), (byte)(permissions >> 24)]); + input.Write(idBytes); + + var digest = MD5.HashData(input.ToArray()); + if (revision >= 3) + { + for (var i = 0; i < 50; i++) + { + digest = MD5.HashData(digest.AsSpan(0, keyLengthBytes).ToArray()); + } + } + + return digest.AsSpan(0, keyLengthBytes).ToArray(); + } + + /// Encodes (Latin-1) and pads/truncates a real password to exactly 32 bytes, independently re-derived from (not copy-pasted from) production's own PadPasswordBytes/EncodeR2R4PasswordBytes helpers. + private static byte[] EncodeAndPadPassword(string password) + { + var encoded = Encoding.Latin1.GetBytes(password); + var padded = new byte[32]; + var copyLength = Math.Min(encoded.Length, 32); + encoded.AsSpan(0, copyLength).CopyTo(padded); + if (copyLength < 32) + { + PasswordPadding.AsSpan(0, 32 - copyLength).CopyTo(padded.AsSpan(copyLength)); + } + + return padded; + } + + + + /// + /// Computes the Encrypt dictionary's /U entry per ISO 32000-1 Algorithm 4 + /// (revision 2) or Algorithm 5 (revision 3/4), for an empty user password. For revision + /// 3/4, the trailing 16 of 32 bytes are filled with arbitrary non-zero padding (mirroring + /// a real-world producer convention) specifically to prove that production authentication + /// only ever compares the first 16 bytes. + /// + private static byte[] ComputeUserEntryAlgorithm45(byte[] fileKey, byte[] idBytes, int revision) + { + if (revision == 2) + { + return Rc4(fileKey, PasswordPadding); + } + + using var hashInput = new MemoryStream(); + hashInput.Write(PasswordPadding); + hashInput.Write(idBytes); + var result = MD5.HashData(hashInput.ToArray()); + result = Rc4(fileKey, result); + for (var round = 1; round <= 19; round++) + { + var roundKey = new byte[fileKey.Length]; + for (var i = 0; i < fileKey.Length; i++) + { + roundKey[i] = (byte)(fileKey[i] ^ round); + } + + result = Rc4(roundKey, result); + } + + var padded = new byte[32]; + result.CopyTo(padded, 0); + for (var i = 16; i < 32; i++) + { + padded[i] = (byte)(0xAA + i); + } + + return padded; + } + + /// Computes a per-object encryption key per ISO 32000-1 Algorithm 1 (RC4/AESV2 only). + private static byte[] ComputeObjectKeyAlgorithm1(byte[] fileKey, int objectNumber, int generation, bool isAes) + { + using var input = new MemoryStream(); + input.Write(fileKey); + input.Write([(byte)objectNumber, (byte)(objectNumber >> 8), (byte)(objectNumber >> 16)]); + input.Write([(byte)generation, (byte)(generation >> 8)]); + if (isAes) + { + input.Write("sAlT"u8.ToArray()); + } + + var digest = MD5.HashData(input.ToArray()); + var keyLength = Math.Min(fileKey.Length + 5, 16); + return digest.AsSpan(0, keyLength).ToArray(); + } + + /// Encrypts as {iv}{AES-CBC/PKCS7 ciphertext}, the wire format every AESV2/AESV3 string/stream uses. + private static byte[] AesCbcEncryptIvPrefixed(byte[] key, byte[] iv, byte[] data) + { + using var aes = Aes.Create(); + aes.Key = key; + aes.IV = iv; + aes.Mode = CipherMode.CBC; + aes.Padding = PaddingMode.PKCS7; + using var encryptor = aes.CreateEncryptor(); + var cipher = encryptor.TransformFinalBlock(data, 0, data.Length); + return [.. iv, .. cipher]; + } + + /// Encrypts (an exact multiple of 16 bytes) with a zero IV and no padding - the wire format ISO 32000-2 Algorithm 2.A's /UE uses. + private static byte[] AesCbcEncryptNoIv(byte[] key, byte[] data) + { + using var aes = Aes.Create(); + aes.Key = key; + aes.IV = new byte[16]; + aes.Mode = CipherMode.CBC; + aes.Padding = PaddingMode.None; + using var encryptor = aes.CreateEncryptor(); + return encryptor.TransformFinalBlock(data, 0, data.Length); + } + + /// + /// Builds an R5 /O//OE owner-password pair per the owner-password variant of + /// ISO 32000-2 Algorithm 2.A: hashes/encrypts over ownerPassword + salt + fullU + /// (the full 48-byte /U value, not a sub-slice), independently re-derived from (not + /// copy-pasted from) production's own TryComputeFileKeyAlgorithm2AOwnerPassword. + /// + private static (byte[] OBytes, byte[] OeBytes) BuildOwnerEntryAndOeAlgorithm2AOwnerPassword(string ownerPassword, byte[] fileKey, byte[] fullUBytes) + { + var ownerPasswordBytes = Encoding.UTF8.GetBytes(ownerPassword); + var validationSalt = (byte[])[.. Enumerable.Range(0, 8).Select(i => (byte)(0x80 + i))]; + var keySalt = (byte[])[.. Enumerable.Range(0, 8).Select(i => (byte)(0x90 + i))]; + + var hash = SHA256.HashData([.. ownerPasswordBytes, .. validationSalt, .. fullUBytes]); + var oBytes = (byte[])[.. hash, .. validationSalt, .. keySalt]; + + var intermediateKey = SHA256.HashData([.. ownerPasswordBytes, .. keySalt, .. fullUBytes]); + var oeBytes = AesCbcEncryptNoIv(intermediateKey, fileKey); + + return (oBytes, oeBytes); + } + + #endregion + + #region PDF construction helpers + + private static string ToHex(byte[] bytes) => Convert.ToHexString(bytes); + + /// Builds a stream object's body (dictionary header + raw bytes + endstream), matching this project's hand-authored fixture style. + private static byte[] BuildStreamBody(byte[] data) + { + var header = Encoding.ASCII.GetBytes($"<< /Length {data.Length} >>\nstream\n"); + var footer = "\nendstream"u8.ToArray(); + var body = new byte[header.Length + data.Length + footer.Length]; + header.CopyTo(body, 0); + data.CopyTo(body, header.Length); + footer.CopyTo(body, header.Length + data.Length); + return body; + } + + /// + /// Builds an in-memory, single-page (/MediaBox [0 0 100 100]), classic-xref, + /// encrypted PDF: object 1-3 are the Catalog/Pages/Page, object 4 is the (possibly + /// encrypted) /Contents stream holding verbatim, + /// object 5 is the Encrypt dictionary (), and the + /// trailer declares /Encrypt 5 0 R plus a two-element /ID array (both + /// elements set to , matching the common real-world convention + /// of identical creation/update IDs for a freshly-written file). + /// defaults to a plain Catalog with no strings of its own, but a caller may supply a body + /// carrying an encrypted direct string (object 1's own per-object key) to prove the + /// Catalog's own strings - not just a referenced stream's - are correctly decrypted. + /// + private static byte[] BuildEncryptedPdf(string encryptDictBody, byte[] idBytes, byte[] contentBytes, string? catalogBody = null) + { + var bodies = new List + { + Encoding.ASCII.GetBytes(catalogBody ?? "<< /Type /Catalog /Pages 2 0 R >>"), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 100 100] >>"u8.ToArray(), + "<< /Type /Page /Parent 2 0 R /Contents 4 0 R >>"u8.ToArray(), + BuildStreamBody(contentBytes), + Encoding.ASCII.GetBytes(encryptDictBody), + }; + + var buffer = new List(); + buffer.AddRange("%PDF-1.7\n"u8.ToArray()); + var offsets = new List(); + for (var i = 0; i < bodies.Count; i++) + { + offsets.Add(buffer.Count); + buffer.AddRange(Encoding.ASCII.GetBytes($"{i + 1} 0 obj\n")); + buffer.AddRange(bodies[i]); + buffer.AddRange("\nendobj\n"u8.ToArray()); + } + + var xrefOffset = buffer.Count; + buffer.AddRange(Encoding.ASCII.GetBytes($"xref\n0 {bodies.Count + 1}\n")); + buffer.AddRange("0000000000 65535 f \n"u8.ToArray()); + foreach (var offset in offsets) + { + buffer.AddRange(Encoding.ASCII.GetBytes($"{offset:D10} 00000 n \n")); + } + + var idHex = ToHex(idBytes); + buffer.AddRange(Encoding.ASCII.GetBytes( + $"trailer\n<< /Size {bodies.Count + 1} /Root 1 0 R /Encrypt 5 0 R /ID [<{idHex}> <{idHex}>] >>\nstartxref\n{xrefOffset}\n%%EOF\n")); + return [.. buffer]; + } + + /// + /// Compresses into a standards-conformant zlib stream (header + + /// deflate + Adler-32), as FlateDecode expects. Duplicated (rather than shared) + /// from PdfDocumentTests.cs's own identical helper, consistent with this file's own + /// "independently re-derived, not copy-pasted" philosophy for its test-only helpers. + /// + private static byte[] ZlibCompress(byte[] data) + { + using var output = new MemoryStream(); + using (var zlib = new System.IO.Compression.ZLibStream(output, System.IO.Compression.CompressionLevel.Optimal, leaveOpen: true)) + { + zlib.Write(data, 0, data.Length); + } + + return output.ToArray(); + } + + /// + /// Builds an in-memory, single-page, /Type /XRef cross-reference-stream-based, + /// encrypted PDF whose single compressed object (object 6, a plain dictionary) lives + /// inside an encrypted /Type /ObjStm container (object 7): objects 1-3 are the + /// Catalog/Pages/Page (plain, unencrypted bodies), object 4 is the RC4-encrypted + /// /Contents stream holding verbatim, + /// object 5 is the Encrypt dictionary (), object 7 is the + /// /Type /ObjStm container whose own raw (zlib-compressed) bytes are + /// (RC4-encrypted as a whole, exactly like + /// any other stream's raw bytes - never re-encrypted per contained object, proving the + /// single-pass decryption guarantee), and object 8 is the cross-reference stream itself + /// (a self-referential type-1 entry, left deliberately unencrypted per ISO 32000-1 7.6.1 - + /// a cross-reference stream is always parsed before _encryptionKey exists, so the + /// production code never attempts to decrypt it). A classic xref/trailer + /// table cannot express object 6's type-2 (compressed) cross-reference entry, hence the + /// cross-reference-stream format here, mirroring PdfDocumentTests.cs's own + /// xref-stream-single-page.pdf/object-stream.pdf fixture pair's own + /// /W [1 4 1] field-width convention. + /// + private static byte[] BuildEncryptedPdfWithObjectStream( + string encryptDictBody, + byte[] idBytes, + byte[] encryptedContentBytes, + byte[] encryptedObjStmBytes) + { + var bodies = new List + { + "<< /Type /Catalog /Pages 2 0 R >>"u8.ToArray(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 100 100] >>"u8.ToArray(), + "<< /Type /Page /Parent 2 0 R /Contents 4 0 R >>"u8.ToArray(), + BuildStreamBody(encryptedContentBytes), + Encoding.ASCII.GetBytes(encryptDictBody), + }; + + var buffer = new List(); + buffer.AddRange("%PDF-1.7\n"u8.ToArray()); + var offsets = new List(); + for (var i = 0; i < bodies.Count; i++) + { + offsets.Add(buffer.Count); + buffer.AddRange(Encoding.ASCII.GetBytes($"{i + 1} 0 obj\n")); + buffer.AddRange(bodies[i]); + buffer.AddRange("\nendobj\n"u8.ToArray()); + } + + // Object 7: the /Type /ObjStm container. /First 4 matches the fixed "6 0\n" 4-byte + // header this helper's callers always use ahead of the single contained object's body. + // Its /Length is the encrypted (ciphertext) byte count - identical to the plaintext + // zlib-compressed byte count, since RC4 never changes a stream's length. + var objStmOffset = buffer.Count; + buffer.AddRange("7 0 obj\n"u8.ToArray()); + buffer.AddRange(Encoding.ASCII.GetBytes( + $"<< /Type /ObjStm /N 1 /First 4 /Filter /FlateDecode /Length {encryptedObjStmBytes.Length} >>\nstream\n")); + buffer.AddRange(encryptedObjStmBytes); + buffer.AddRange("\nendstream\nendobj\n"u8.ToArray()); + + // Object 8: the cross-reference stream itself - self-referential (its own type-1 entry + // below points back at xrefStreamOffset) and deliberately left unencrypted. + var xrefStreamOffset = buffer.Count; + var entries = new (int Type, int Field2, int Field3)[] + { + (0, 0, 0), // object 0: free + (1, offsets[0], 0), // object 1: Catalog + (1, offsets[1], 0), // object 2: Pages + (1, offsets[2], 0), // object 3: Page + (1, offsets[3], 0), // object 4: Contents stream + (1, offsets[4], 0), // object 5: Encrypt dictionary + (2, 7, 0), // object 6: compressed inside object 7, index 0 + (1, objStmOffset, 0), // object 7: ObjStm container + (1, xrefStreamOffset, 0), // object 8: the xref stream itself + }; + + var rawEntries = new byte[entries.Length * 6]; + for (var i = 0; i < entries.Length; i++) + { + var (type, field2, field3) = entries[i]; + var position = i * 6; + rawEntries[position] = (byte)type; + rawEntries[position + 1] = (byte)(field2 >> 24); + rawEntries[position + 2] = (byte)(field2 >> 16); + rawEntries[position + 3] = (byte)(field2 >> 8); + rawEntries[position + 4] = (byte)field2; + rawEntries[position + 5] = (byte)field3; + } + + var compressedXref = ZlibCompress(rawEntries); + var idHex = ToHex(idBytes); + buffer.AddRange("8 0 obj\n"u8.ToArray()); + buffer.AddRange(Encoding.ASCII.GetBytes( + $"<< /Type /XRef /Size {entries.Length} /W [1 4 1] /Root 1 0 R /Encrypt 5 0 R /ID [<{idHex}> <{idHex}>] " + + $"/Filter /FlateDecode /Length {compressedXref.Length} >>\nstream\n")); + buffer.AddRange(compressedXref); + buffer.AddRange("\nendstream\nendobj\n"u8.ToArray()); + + buffer.AddRange(Encoding.ASCII.GetBytes($"startxref\n{xrefStreamOffset}\n%%EOF\n")); + return [.. buffer]; + } + + /// + /// Builds an in-memory, single-page, /Type /XRef cross-reference-stream-based, + /// encrypted PDF whose /Type /Catalog object itself (object 1) is compressed inside + /// an encrypted /Type /ObjStm container (object 6): objects 2-4 are the + /// Pages/Page/Contents (the latter RC4-encrypted, holding + /// verbatim), object 5 is the Encrypt dictionary (), + /// object 6 is the /Type /ObjStm container whose own raw (zlib-compressed) bytes are + /// (RC4-encrypted as a whole), and object 7 is the + /// cross-reference stream itself (a self-referential type-1 entry, left deliberately + /// unencrypted). Resolving /Root therefore requires the file decryption key to + /// already be established - the regression scenario for the fix that initializes + /// encryption before validating the catalog root. + /// + private static byte[] BuildEncryptedPdfWithCompressedCatalog( + string encryptDictBody, + byte[] idBytes, + byte[] encryptedContentBytes, + byte[] encryptedObjStmBytes) + { + var bodies = new List + { + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 100 100] >>"u8.ToArray(), + "<< /Type /Page /Parent 2 0 R /Contents 4 0 R >>"u8.ToArray(), + BuildStreamBody(encryptedContentBytes), + Encoding.ASCII.GetBytes(encryptDictBody), + }; + + var buffer = new List(); + buffer.AddRange("%PDF-1.7\n"u8.ToArray()); + var offsets = new List(); + for (var i = 0; i < bodies.Count; i++) + { + // Bodies start at object 2 (object 1, the Catalog, is compressed - see below). + offsets.Add(buffer.Count); + buffer.AddRange(Encoding.ASCII.GetBytes($"{i + 2} 0 obj\n")); + buffer.AddRange(bodies[i]); + buffer.AddRange("\nendobj\n"u8.ToArray()); + } + + // Object 6: the /Type /ObjStm container holding the compressed Catalog (object 1). + // /First 4 matches the fixed "1 0\n" 4-byte header this helper's callers always use + // ahead of the single contained object's body. + var objStmOffset = buffer.Count; + buffer.AddRange("6 0 obj\n"u8.ToArray()); + buffer.AddRange(Encoding.ASCII.GetBytes( + $"<< /Type /ObjStm /N 1 /First 4 /Filter /FlateDecode /Length {encryptedObjStmBytes.Length} >>\nstream\n")); + buffer.AddRange(encryptedObjStmBytes); + buffer.AddRange("\nendstream\nendobj\n"u8.ToArray()); + + // Object 7: the cross-reference stream itself - self-referential (its own type-1 entry + // below points back at xrefStreamOffset) and deliberately left unencrypted. + var xrefStreamOffset = buffer.Count; + var entries = new (int Type, int Field2, int Field3)[] + { + (0, 0, 0), // object 0: free + (2, 6, 0), // object 1: Catalog, compressed inside object 6, index 0 + (1, offsets[0], 0), // object 2: Pages + (1, offsets[1], 0), // object 3: Page + (1, offsets[2], 0), // object 4: Contents stream + (1, offsets[3], 0), // object 5: Encrypt dictionary + (1, objStmOffset, 0), // object 6: ObjStm container + (1, xrefStreamOffset, 0), // object 7: the xref stream itself + }; + + var rawEntries = new byte[entries.Length * 6]; + for (var i = 0; i < entries.Length; i++) + { + var (type, field2, field3) = entries[i]; + var position = i * 6; + rawEntries[position] = (byte)type; + rawEntries[position + 1] = (byte)(field2 >> 24); + rawEntries[position + 2] = (byte)(field2 >> 16); + rawEntries[position + 3] = (byte)(field2 >> 8); + rawEntries[position + 4] = (byte)field2; + rawEntries[position + 5] = (byte)field3; + } + + var compressedXref = ZlibCompress(rawEntries); + var idHex = ToHex(idBytes); + buffer.AddRange("7 0 obj\n"u8.ToArray()); + buffer.AddRange(Encoding.ASCII.GetBytes( + $"<< /Type /XRef /Size {entries.Length} /W [1 4 1] /Root 1 0 R /Encrypt 5 0 R /ID [<{idHex}> <{idHex}>] " + + $"/Filter /FlateDecode /Length {compressedXref.Length} >>\nstream\n")); + buffer.AddRange(compressedXref); + buffer.AddRange("\nendstream\nendobj\n"u8.ToArray()); + + buffer.AddRange(Encoding.ASCII.GetBytes($"startxref\n{xrefStreamOffset}\n%%EOF\n")); + return [.. buffer]; + } + + /// The fixed, arbitrary 16-byte /ID every test below uses, since a real-world ID's exact value carries no meaning beyond its role as Algorithm 2/4/5 input. + private static byte[] TestIdBytes { get; } = [.. Enumerable.Range(0, 16).Select(i => (byte)(0x10 + i))]; + + /// The content-stream text every positive test below encrypts: a filled 40x40 rectangle at user (10,10)-(50,50), matching PdfSystemIntegrationTests's own pixel-assertion convention. + private const string PlaintextContent = "10 10 40 40 re f"; + + #endregion + + /// Proves an RC4 40-bit (/V 1//R 2) encrypted document with an empty user password opens and renders correctly. + [Fact] + public void PdfDocument_Open_EncryptedRc4_40Bit_DecryptsAndRenders() + { + const int keyLengthBytes = 5; + const int revision = 2; + const int permissions = -3904; + + var oBytes = ComputeOwnerEntryAlgorithm3(keyLengthBytes, revision, PasswordPadding, PasswordPadding); + var fileKey = ComputeFileKeyAlgorithm2(PasswordPadding, oBytes, permissions, TestIdBytes, keyLengthBytes, revision); + var uBytes = ComputeUserEntryAlgorithm45(fileKey, TestIdBytes, revision); + + var objectKey = ComputeObjectKeyAlgorithm1(fileKey, 4, 0, isAes: false); + var encryptedContent = Rc4(objectKey, Encoding.ASCII.GetBytes(PlaintextContent)); + + var encryptDictBody = $"<< /Filter /Standard /V 1 /R {revision} /O <{ToHex(oBytes)}> /U <{ToHex(uBytes)}> /P {permissions} >>"; + var pdfBytes = BuildEncryptedPdf(encryptDictBody, TestIdBytes, encryptedContent); + + using var document = PdfDocument.Open(new MemoryStream(pdfBytes)); + using var surface = document.Render(0, 100, 100); + + Assert.Equal(Black, surface[30, 70]); + Assert.Equal(default, surface[5, 5]); + } + + /// + /// Proves an RC4 128-bit (/V 2//R 3) encrypted document with an empty user + /// password opens and renders correctly, including when /U's trailing 16 padding + /// bytes are non-zero (proving only the first 16 bytes are ever compared). + /// + [Fact] + public void PdfDocument_Open_EncryptedRc4_128Bit_DecryptsAndRenders() + { + const int keyLengthBytes = 16; + const int revision = 3; + const int permissions = -3904; + + var oBytes = ComputeOwnerEntryAlgorithm3(keyLengthBytes, revision, PasswordPadding, PasswordPadding); + var fileKey = ComputeFileKeyAlgorithm2(PasswordPadding, oBytes, permissions, TestIdBytes, keyLengthBytes, revision); + var uBytes = ComputeUserEntryAlgorithm45(fileKey, TestIdBytes, revision); + + var objectKey = ComputeObjectKeyAlgorithm1(fileKey, 4, 0, isAes: false); + var encryptedContent = Rc4(objectKey, Encoding.ASCII.GetBytes(PlaintextContent)); + + var encryptDictBody = + $"<< /Filter /Standard /V 2 /R {revision} /Length 128 /O <{ToHex(oBytes)}> /U <{ToHex(uBytes)}> /P {permissions} >>"; + var pdfBytes = BuildEncryptedPdf(encryptDictBody, TestIdBytes, encryptedContent); + + using var document = PdfDocument.Open(new MemoryStream(pdfBytes)); + using var surface = document.Render(0, 100, 100); + + Assert.Equal(Black, surface[30, 70]); + Assert.Equal(default, surface[5, 5]); + } + + /// Proves an AES-128 (/V 4//R 4//CFM /AESV2) encrypted document with an empty user password opens and renders correctly. + [Fact] + public void PdfDocument_Open_EncryptedAesV2_128Bit_DecryptsAndRenders() + { + const int keyLengthBytes = 16; + const int revision = 4; + const int permissions = -3904; + + var oBytes = ComputeOwnerEntryAlgorithm3(keyLengthBytes, revision, PasswordPadding, PasswordPadding); + var fileKey = ComputeFileKeyAlgorithm2(PasswordPadding, oBytes, permissions, TestIdBytes, keyLengthBytes, revision); + var uBytes = ComputeUserEntryAlgorithm45(fileKey, TestIdBytes, revision); + + var objectKey = ComputeObjectKeyAlgorithm1(fileKey, 4, 0, isAes: true); + var contentIv = (byte[])[.. Enumerable.Range(0, 16).Select(i => (byte)(0x30 + i))]; + var encryptedContent = AesCbcEncryptIvPrefixed(objectKey, contentIv, Encoding.ASCII.GetBytes(PlaintextContent)); + + var encryptDictBody = + $"<< /Filter /Standard /V 4 /R {revision} /Length 128 /O <{ToHex(oBytes)}> /U <{ToHex(uBytes)}> /P {permissions} " + + "/CF << /StdCF << /CFM /AESV2 /Length 16 >> >> /StmF /StdCF /StrF /StdCF >>"; + var pdfBytes = BuildEncryptedPdf(encryptDictBody, TestIdBytes, encryptedContent); + + using var document = PdfDocument.Open(new MemoryStream(pdfBytes)); + using var surface = document.Render(0, 100, 100); + + Assert.Equal(Black, surface[30, 70]); + Assert.Equal(default, surface[5, 5]); + } + + /// Proves an AES-256 R5 (/V 5//R 5//CFM /AESV3) encrypted document with an empty user password opens and renders correctly. + [Fact] + public void PdfDocument_Open_EncryptedAesV3_R5_DecryptsAndRenders() + { + const int permissions = -3904; + + var fileKey = (byte[])[.. Enumerable.Range(0, 32).Select(i => (byte)(0x40 + i))]; + var validationSalt = (byte[])[.. Enumerable.Range(0, 8).Select(i => (byte)(0x50 + i))]; + var keySalt = (byte[])[.. Enumerable.Range(0, 8).Select(i => (byte)(0x60 + i))]; + + var hash = SHA256.HashData(validationSalt); + var uBytes = (byte[])[.. hash, .. validationSalt, .. keySalt]; + var intermediateKey = SHA256.HashData(keySalt); + var ueBytes = AesCbcEncryptNoIv(intermediateKey, fileKey); + var oBytes = new byte[32]; + var oeBytes = new byte[32]; + + var contentIv = (byte[])[.. Enumerable.Range(0, 16).Select(i => (byte)(0x70 + i))]; + var encryptedContent = AesCbcEncryptIvPrefixed(fileKey, contentIv, Encoding.ASCII.GetBytes(PlaintextContent)); + + var encryptDictBody = + $"<< /Filter /Standard /V 5 /R 5 /Length 256 /O <{ToHex(oBytes)}> /U <{ToHex(uBytes)}> " + + $"/OE <{ToHex(oeBytes)}> /UE <{ToHex(ueBytes)}> /P {permissions} " + + "/CF << /StdCF << /CFM /AESV3 /Length 32 >> >> /StmF /StdCF /StrF /StdCF >>"; + var pdfBytes = BuildEncryptedPdf(encryptDictBody, TestIdBytes, encryptedContent); + + using var document = PdfDocument.Open(new MemoryStream(pdfBytes)); + using var surface = document.Render(0, 100, 100); + + Assert.Equal(Black, surface[30, 70]); + Assert.Equal(default, surface[5, 5]); + } + + /// Proves an RC4 128-bit (/V 2//R 3) encrypted document with a correct, non-empty, real user password opens and renders correctly when that password is supplied to . + [Fact] + public void PdfDocument_Open_EncryptedRc4_CorrectUserPassword_DecryptsAndRenders() + { + const int keyLengthBytes = 16; + const int revision = 3; + const int permissions = -3904; + const string userPassword = "test"; + + var paddedUserPassword = EncodeAndPadPassword(userPassword); + var oBytes = ComputeOwnerEntryAlgorithm3(keyLengthBytes, revision, PasswordPadding, paddedUserPassword); + var fileKey = ComputeFileKeyAlgorithm2(paddedUserPassword, oBytes, permissions, TestIdBytes, keyLengthBytes, revision); + var uBytes = ComputeUserEntryAlgorithm45(fileKey, TestIdBytes, revision); + + var objectKey = ComputeObjectKeyAlgorithm1(fileKey, 4, 0, isAes: false); + var encryptedContent = Rc4(objectKey, Encoding.ASCII.GetBytes(PlaintextContent)); + + var encryptDictBody = + $"<< /Filter /Standard /V 2 /R {revision} /Length 128 /O <{ToHex(oBytes)}> /U <{ToHex(uBytes)}> /P {permissions} >>"; + var pdfBytes = BuildEncryptedPdf(encryptDictBody, TestIdBytes, encryptedContent); + + using var document = PdfDocument.Open(new MemoryStream(pdfBytes), userPassword); + using var surface = document.Render(0, 100, 100); + + Assert.Equal(Black, surface[30, 70]); + Assert.Equal(default, surface[5, 5]); + } + + /// Proves an AES-128 (/V 4//R 4//CFM /AESV2) encrypted document with a correct, non-empty, real user password opens and renders correctly when that password is supplied to . + [Fact] + public void PdfDocument_Open_EncryptedAesV2_CorrectUserPassword_DecryptsAndRenders() + { + const int keyLengthBytes = 16; + const int revision = 4; + const int permissions = -3904; + const string userPassword = "test"; + + var paddedUserPassword = EncodeAndPadPassword(userPassword); + var oBytes = ComputeOwnerEntryAlgorithm3(keyLengthBytes, revision, PasswordPadding, paddedUserPassword); + var fileKey = ComputeFileKeyAlgorithm2(paddedUserPassword, oBytes, permissions, TestIdBytes, keyLengthBytes, revision); + var uBytes = ComputeUserEntryAlgorithm45(fileKey, TestIdBytes, revision); + + var objectKey = ComputeObjectKeyAlgorithm1(fileKey, 4, 0, isAes: true); + var contentIv = (byte[])[.. Enumerable.Range(0, 16).Select(i => (byte)(0x31 + i))]; + var encryptedContent = AesCbcEncryptIvPrefixed(objectKey, contentIv, Encoding.ASCII.GetBytes(PlaintextContent)); + + var encryptDictBody = + $"<< /Filter /Standard /V 4 /R {revision} /Length 128 /O <{ToHex(oBytes)}> /U <{ToHex(uBytes)}> /P {permissions} " + + "/CF << /StdCF << /CFM /AESV2 /Length 16 >> >> /StmF /StdCF /StrF /StdCF >>"; + var pdfBytes = BuildEncryptedPdf(encryptDictBody, TestIdBytes, encryptedContent); + + using var document = PdfDocument.Open(new MemoryStream(pdfBytes), userPassword); + using var surface = document.Render(0, 100, 100); + + Assert.Equal(Black, surface[30, 70]); + Assert.Equal(default, surface[5, 5]); + } + + /// Proves an AES-256 R5 (/V 5//R 5//CFM /AESV3) encrypted document with a correct, non-empty, real user password opens and renders correctly when that password is supplied to . + [Fact] + public void PdfDocument_Open_EncryptedAesV3_CorrectUserPassword_DecryptsAndRenders() + { + const int permissions = -3904; + const string userPassword = "test"; + var userPasswordBytes = Encoding.UTF8.GetBytes(userPassword); + + var fileKey = (byte[])[.. Enumerable.Range(0, 32).Select(i => (byte)(0x41 + i))]; + var validationSalt = (byte[])[.. Enumerable.Range(0, 8).Select(i => (byte)(0x51 + i))]; + var keySalt = (byte[])[.. Enumerable.Range(0, 8).Select(i => (byte)(0x61 + i))]; + + var hash = SHA256.HashData([.. userPasswordBytes, .. validationSalt]); + var uBytes = (byte[])[.. hash, .. validationSalt, .. keySalt]; + var intermediateKey = SHA256.HashData([.. userPasswordBytes, .. keySalt]); + var ueBytes = AesCbcEncryptNoIv(intermediateKey, fileKey); + var oBytes = new byte[32]; + var oeBytes = new byte[32]; + + var contentIv = (byte[])[.. Enumerable.Range(0, 16).Select(i => (byte)(0x71 + i))]; + var encryptedContent = AesCbcEncryptIvPrefixed(fileKey, contentIv, Encoding.ASCII.GetBytes(PlaintextContent)); + + var encryptDictBody = + $"<< /Filter /Standard /V 5 /R 5 /Length 256 /O <{ToHex(oBytes)}> /U <{ToHex(uBytes)}> " + + $"/OE <{ToHex(oeBytes)}> /UE <{ToHex(ueBytes)}> /P {permissions} " + + "/CF << /StdCF << /CFM /AESV3 /Length 32 >> >> /StmF /StdCF /StrF /StdCF >>"; + var pdfBytes = BuildEncryptedPdf(encryptDictBody, TestIdBytes, encryptedContent); + + using var document = PdfDocument.Open(new MemoryStream(pdfBytes), userPassword); + using var surface = document.Render(0, 100, 100); + + Assert.Equal(Black, surface[30, 70]); + Assert.Equal(default, surface[5, 5]); + } + + /// + /// Proves an RC4 128-bit (/V 2//R 3) encrypted document - created with a + /// real, distinct owner password and a different, real user password - opens and renders + /// correctly when the owner password is supplied to , + /// via the ISO 32000-1 Algorithm 3 owner-password-recovery path (the supplied password + /// fails to authenticate as the user password first, exactly as required). + /// + [Fact] + public void PdfDocument_Open_EncryptedRc4_CorrectOwnerPassword_DecryptsAndRenders() + { + const int keyLengthBytes = 16; + const int revision = 3; + const int permissions = -3904; + const string ownerPassword = "owner-secret"; + const string userPassword = "user-secret"; + + var paddedOwnerPassword = EncodeAndPadPassword(ownerPassword); + var paddedUserPassword = EncodeAndPadPassword(userPassword); + var oBytes = ComputeOwnerEntryAlgorithm3(keyLengthBytes, revision, paddedOwnerPassword, paddedUserPassword); + var fileKey = ComputeFileKeyAlgorithm2(paddedUserPassword, oBytes, permissions, TestIdBytes, keyLengthBytes, revision); + var uBytes = ComputeUserEntryAlgorithm45(fileKey, TestIdBytes, revision); + + var objectKey = ComputeObjectKeyAlgorithm1(fileKey, 4, 0, isAes: false); + var encryptedContent = Rc4(objectKey, Encoding.ASCII.GetBytes(PlaintextContent)); + + var encryptDictBody = + $"<< /Filter /Standard /V 2 /R {revision} /Length 128 /O <{ToHex(oBytes)}> /U <{ToHex(uBytes)}> /P {permissions} >>"; + var pdfBytes = BuildEncryptedPdf(encryptDictBody, TestIdBytes, encryptedContent); + + using var document = PdfDocument.Open(new MemoryStream(pdfBytes), ownerPassword); + using var surface = document.Render(0, 100, 100); + + Assert.Equal(Black, surface[30, 70]); + Assert.Equal(default, surface[5, 5]); + } + + /// + /// Proves an AES-256 R5 (/V 5//R 5//CFM /AESV3) encrypted document - + /// created with a real, distinct owner password and an empty (unrelated) user password - + /// opens and renders correctly when the owner password is supplied to + /// , via + /// TryComputeFileKeyAlgorithm2AOwnerPassword (the supplied password fails to + /// authenticate as the user password first, exactly as required). + /// + [Fact] + public void PdfDocument_Open_EncryptedAesV3_CorrectOwnerPassword_DecryptsAndRenders() + { + const int permissions = -3904; + const string ownerPassword = "owner-test"; + + var fileKey = (byte[])[.. Enumerable.Range(0, 32).Select(i => (byte)(0x42 + i))]; + var userValidationSalt = (byte[])[.. Enumerable.Range(0, 8).Select(i => (byte)(0x52 + i))]; + var userKeySalt = (byte[])[.. Enumerable.Range(0, 8).Select(i => (byte)(0x62 + i))]; + + // /U/UE are built from an unrelated, empty user password, so the supplied owner password + // necessarily fails the user-password attempt first. + var userHash = SHA256.HashData(userValidationSalt); + var uBytes = (byte[])[.. userHash, .. userValidationSalt, .. userKeySalt]; + var userIntermediateKey = SHA256.HashData(userKeySalt); + var ueBytes = AesCbcEncryptNoIv(userIntermediateKey, fileKey); + + var (oBytes, oeBytes) = BuildOwnerEntryAndOeAlgorithm2AOwnerPassword(ownerPassword, fileKey, uBytes); + + var contentIv = (byte[])[.. Enumerable.Range(0, 16).Select(i => (byte)(0x72 + i))]; + var encryptedContent = AesCbcEncryptIvPrefixed(fileKey, contentIv, Encoding.ASCII.GetBytes(PlaintextContent)); + + var encryptDictBody = + $"<< /Filter /Standard /V 5 /R 5 /Length 256 /O <{ToHex(oBytes)}> /U <{ToHex(uBytes)}> " + + $"/OE <{ToHex(oeBytes)}> /UE <{ToHex(ueBytes)}> /P {permissions} " + + "/CF << /StdCF << /CFM /AESV3 /Length 32 >> >> /StmF /StdCF /StrF /StdCF >>"; + var pdfBytes = BuildEncryptedPdf(encryptDictBody, TestIdBytes, encryptedContent); + + using var document = PdfDocument.Open(new MemoryStream(pdfBytes), ownerPassword); + using var surface = document.Render(0, 100, 100); + + Assert.Equal(Black, surface[30, 70]); + Assert.Equal(default, surface[5, 5]); + } + + /// + /// Proves that a well-formed, real-password-protected document throws + /// with the distinguishable + /// pdf-encrypted-incorrect-password feature token when the supplied password is + /// wrong for both the user and the owner role - distinct from the null-password + /// pdf-encrypted-password-required token. + /// + [Fact] + public void PdfDocument_Open_Encrypted_IncorrectPassword_ThrowsUnsupportedImageFeatureException() + { + const int keyLengthBytes = 16; + const int revision = 3; + const int permissions = -3904; + + var paddedOwnerPassword = EncodeAndPadPassword("owner-secret"); + var paddedUserPassword = EncodeAndPadPassword("user-secret"); + var oBytes = ComputeOwnerEntryAlgorithm3(keyLengthBytes, revision, paddedOwnerPassword, paddedUserPassword); + var fileKey = ComputeFileKeyAlgorithm2(paddedUserPassword, oBytes, permissions, TestIdBytes, keyLengthBytes, revision); + var uBytes = ComputeUserEntryAlgorithm45(fileKey, TestIdBytes, revision); + + var encryptDictBody = + $"<< /Filter /Standard /V 2 /R {revision} /Length 128 /O <{ToHex(oBytes)}> /U <{ToHex(uBytes)}> /P {permissions} >>"; + var pdfBytes = BuildEncryptedPdf(encryptDictBody, TestIdBytes, Encoding.ASCII.GetBytes(PlaintextContent)); + + var exception = Assert.Throws( + () => PdfDocument.Open(new MemoryStream(pdfBytes), "wrong-password")); + Assert.Equal("pdf-encrypted-incorrect-password", exception.Feature); + } + + /// + /// Proves that supplying a password containing a character outside ASCII (0-127) for an + /// /R 2-4 document throws + /// with the distinguishable pdf-encrypted-password-non-ascii feature token, before + /// any RC4/MD5 authentication work is attempted. + /// + [Fact] + public void PdfDocument_Open_Encrypted_NonAsciiPassword_ThrowsUnsupportedImageFeatureException() + { + const int keyLengthBytes = 5; + const int revision = 2; + const int permissions = -3904; + + var oBytes = ComputeOwnerEntryAlgorithm3(keyLengthBytes, revision, PasswordPadding, PasswordPadding); + var fileKey = ComputeFileKeyAlgorithm2(PasswordPadding, oBytes, permissions, TestIdBytes, keyLengthBytes, revision); + var uBytes = ComputeUserEntryAlgorithm45(fileKey, TestIdBytes, revision); + + var encryptDictBody = $"<< /Filter /Standard /V 1 /R {revision} /O <{ToHex(oBytes)}> /U <{ToHex(uBytes)}> /P {permissions} >>"; + var pdfBytes = BuildEncryptedPdf(encryptDictBody, TestIdBytes, Encoding.ASCII.GetBytes(PlaintextContent)); + + var exception = Assert.Throws( + () => PdfDocument.Open(new MemoryStream(pdfBytes), "caf\u00e9")); + Assert.Equal("pdf-encrypted-password-non-ascii", exception.Feature); + } + + /// Proves /R 6 (AES-256 "hardened hash" key derivation) throws with its own distinguishable feature token, instead of being silently mishandled as a regular R5 document. + [Fact] + public void PdfDocument_Open_EncryptedAesV3_R6_ThrowsUnsupportedImageFeatureException() + { + var zero32 = new byte[32]; + var zero48 = new byte[48]; + var encryptDictBody = + $"<< /Filter /Standard /V 5 /R 6 /O <{ToHex(zero32)}> /U <{ToHex(zero48)}> " + + $"/OE <{ToHex(zero32)}> /UE <{ToHex(zero32)}> /P -1 >>"; + var pdfBytes = BuildEncryptedPdf(encryptDictBody, TestIdBytes, Encoding.ASCII.GetBytes(PlaintextContent)); + + var exception = Assert.Throws(() => PdfDocument.Open(new MemoryStream(pdfBytes))); + Assert.Equal("pdf-encrypted-r6-hardened-hash", exception.Feature); + } + + /// Proves that an Encrypt dictionary whose /Length entry resolves to a key length outside the ISO 32000-1 ยง7.6.2 valid range of 40-128 bits (5-16 bytes) throws before any key derivation is attempted, instead of deriving a nonsensical-length key or indexing out of range later. + [Fact] + public void PdfDocument_Open_Encrypted_LengthOutOfRange_ThrowsInvalidDataException() + { + var zero32 = new byte[32]; + var encryptDictBody = $"<< /Filter /Standard /V 2 /R 3 /Length 0 /O <{ToHex(zero32)}> /U <{ToHex(zero32)}> /P -3904 >>"; + var pdfBytes = BuildEncryptedPdf(encryptDictBody, TestIdBytes, Encoding.ASCII.GetBytes(PlaintextContent)); + + Assert.Throws(() => PdfDocument.Open(new MemoryStream(pdfBytes))); + } + + /// Proves that a document whose /U does not authenticate against the empty password throws (a real, non-empty password is required), instead of silently proceeding with the wrong key. + [Fact] + public void PdfDocument_Open_EncryptedRc4_WrongUserPasswordHash_ThrowsUnsupportedImageFeatureException() + { + const int keyLengthBytes = 5; + const int revision = 2; + const int permissions = -3904; + + // A well-formed /O (so the file key derives without error) paired with a deliberately + // wrong /U (all zero bytes, which cannot be the real RC4(fileKey, padding) result) - + // simulating a document that genuinely requires a non-empty password to open. + var oBytes = ComputeOwnerEntryAlgorithm3(keyLengthBytes, revision, PasswordPadding, PasswordPadding); + var wrongUBytes = new byte[32]; + + var encryptDictBody = + $"<< /Filter /Standard /V 1 /R {revision} /O <{ToHex(oBytes)}> /U <{ToHex(wrongUBytes)}> /P {permissions} >>"; + var pdfBytes = BuildEncryptedPdf(encryptDictBody, TestIdBytes, Encoding.ASCII.GetBytes(PlaintextContent)); + + var exception = Assert.Throws(() => PdfDocument.Open(new MemoryStream(pdfBytes))); + Assert.Equal("pdf-encrypted-password-required", exception.Feature); + } + + /// Proves that an AES-256 R5 document whose empty-password validation hash does not match /U throws with the same password-required feature token as the RC4/AESV2 path. + [Fact] + public void PdfDocument_Open_EncryptedAesV3_WrongValidationHash_ThrowsUnsupportedImageFeatureException() + { + const int permissions = -3904; + var wrongUBytes = new byte[48]; + var oeBytes = new byte[32]; + var ueBytes = new byte[32]; + + var encryptDictBody = + $"<< /Filter /Standard /V 5 /R 5 /Length 256 /O <{ToHex(new byte[32])}> /U <{ToHex(wrongUBytes)}> " + + $"/OE <{ToHex(oeBytes)}> /UE <{ToHex(ueBytes)}> /P {permissions} " + + "/CF << /StdCF << /CFM /AESV3 /Length 32 >> >> /StmF /StdCF /StrF /StdCF >>"; + var pdfBytes = BuildEncryptedPdf(encryptDictBody, TestIdBytes, Encoding.ASCII.GetBytes(PlaintextContent)); + + var exception = Assert.Throws(() => PdfDocument.Open(new MemoryStream(pdfBytes))); + Assert.Equal("pdf-encrypted-password-required", exception.Feature); + } + + /// + /// Regression test proving the /Encrypt dictionary's own /O//U + /// strings are never mistakenly decrypted a second time. If InitializeEncryption's + /// cache-before-key-exists ordering were ever broken, re-resolving the /Encrypt + /// dictionary after _encryptionKey had been set would run its /O/ + /// /U bytes through a spurious extra RC4 pass, so this document's own + /// Algorithm 2/4/5 computation would authenticate against corrupted bytes and + /// would be thrown instead of the document + /// opening and rendering correctly - exactly as every other positive test above also + /// implicitly proves, but reconstructed standalone here as a dedicated anchor for this + /// specific guarantee. + /// + [Fact] + public void PdfDocument_Open_Encrypted_EncryptDictionaryStringsAreNeverDecrypted() + { + const int keyLengthBytes = 5; + const int revision = 2; + const int permissions = -44; + var idBytes = (byte[])[.. Enumerable.Range(0, 16).Select(i => (byte)(0xC0 + i))]; + + var oBytes = ComputeOwnerEntryAlgorithm3(keyLengthBytes, revision, PasswordPadding, PasswordPadding); + var fileKey = ComputeFileKeyAlgorithm2(PasswordPadding, oBytes, permissions, idBytes, keyLengthBytes, revision); + var uBytes = ComputeUserEntryAlgorithm45(fileKey, idBytes, revision); + + var objectKey = ComputeObjectKeyAlgorithm1(fileKey, 4, 0, isAes: false); + var encryptedContent = Rc4(objectKey, Encoding.ASCII.GetBytes(PlaintextContent)); + + var encryptDictBody = $"<< /Filter /Standard /V 1 /R {revision} /O <{ToHex(oBytes)}> /U <{ToHex(uBytes)}> /P {permissions} >>"; + var pdfBytes = BuildEncryptedPdf(encryptDictBody, idBytes, encryptedContent); + + using var document = PdfDocument.Open(new MemoryStream(pdfBytes)); + + Assert.Equal(1, document.PageCount); + using var surface = document.Render(0, 100, 100); + Assert.Equal(Black, surface[30, 70]); + Assert.Equal(default, surface[5, 5]); + } + + /// + /// Regression test for the object-caching ordering defect fixed alongside this test: + /// every object resolved and cached before _encryptionKey is established - most + /// notably the trailer's own /Root Catalog, resolved by the constructor's + /// IsValidCatalogRoot check strictly before InitializeEncryption ever runs - + /// must still have its own direct strings correctly decrypted once the key exists, not + /// silently left as ciphertext forever. Embeds an RC4-encrypted /Lang (en-US) + /// literal string directly in the Catalog's own body (object 1), encrypted with object 1's + /// own per-object key exactly like every other string in the document, then - after + /// confirming the whole document still opens and renders correctly - uses reflection to + /// re-fetch the (now post-fix, re-resolved) cached Catalog object and asserts its + /// /Lang bytes are the plaintext "en-US", not the original ciphertext. + /// + [Fact] + public void PdfDocument_Open_Encrypted_CatalogOwnStringIsDecrypted() + { + const int keyLengthBytes = 5; + const int revision = 2; + const int permissions = -3904; + + var oBytes = ComputeOwnerEntryAlgorithm3(keyLengthBytes, revision, PasswordPadding, PasswordPadding); + var fileKey = ComputeFileKeyAlgorithm2(PasswordPadding, oBytes, permissions, TestIdBytes, keyLengthBytes, revision); + var uBytes = ComputeUserEntryAlgorithm45(fileKey, TestIdBytes, revision); + + var contentObjectKey = ComputeObjectKeyAlgorithm1(fileKey, 4, 0, isAes: false); + var encryptedContent = Rc4(contentObjectKey, Encoding.ASCII.GetBytes(PlaintextContent)); + + // Object 1 is the Catalog: RC4-encrypt "en-US" with its own per-object key (object + // number 1, generation 0) exactly like every other string in the document. + var catalogObjectKey = ComputeObjectKeyAlgorithm1(fileKey, 1, 0, isAes: false); + var encryptedLang = Rc4(catalogObjectKey, Encoding.ASCII.GetBytes("en-US")); + var catalogBody = $"<< /Type /Catalog /Pages 2 0 R /Lang <{ToHex(encryptedLang)}> >>"; + + var encryptDictBody = $"<< /Filter /Standard /V 1 /R {revision} /O <{ToHex(oBytes)}> /U <{ToHex(uBytes)}> /P {permissions} >>"; + var pdfBytes = BuildEncryptedPdf(encryptDictBody, TestIdBytes, encryptedContent, catalogBody); + + using var document = PdfDocument.Open(new MemoryStream(pdfBytes)); + + // (a) the whole document still opens and renders correctly. + Assert.Equal(1, document.PageCount); + using var surface = document.Render(0, 100, 100); + Assert.Equal(Black, surface[30, 70]); + Assert.Equal(default, surface[5, 5]); + + // (b) the Catalog's own /Lang string is correctly decrypted, proving the object cache + // entry cached pre-key by IsValidCatalogRoot was invalidated and the Catalog re-resolved + // (and, this time, decrypted) after InitializeEncryption established _encryptionKey. + var getObject = typeof(PdfDocument).GetMethod("GetObject", BindingFlags.NonPublic | BindingFlags.Instance)!; + var catalog = (PdfDocument.PdfObject)getObject.Invoke(document, [1])!; + Assert.Equal(Encoding.ASCII.GetBytes("en-US"), catalog.Get("Lang")!.Bytes); + } + + /// + /// Proves an object-stream-compressed (/Type /ObjStm) object inside an encrypted + /// document is decrypted/decompressed correctly, and exactly once (never double-decrypted): + /// GetObject dispatches each indirect object to exactly one of two mutually + /// exclusive paths - ParseIndirectObjectAt (which calls + /// DecryptStringsInPlace) for a direct cross-reference entry, or + /// LoadCompressedObject (which never calls DecryptStringsInPlace) for a + /// compressed entry. A compressed object's strings are only ever decrypted once, as part + /// of its containing /Type /ObjStm stream's own raw bytes being decrypted by + /// GetStreamRawBytes before decompression - LoadCompressedObject then parses + /// the already-plaintext decompressed bytes directly, with no second decrypt call site to + /// even reach. Constructs a synthetic /Type /XRef cross-reference stream whose + /// compressed object 6 is << /Greeting (Hello, Encrypted World!) >>, + /// held inside object 7's RC4-encrypted (as a whole) /Type /ObjStm container, and + /// asserts both that the document still renders correctly (object 4's own encrypted + /// /Contents stream decrypts correctly) and that the compressed object's own + /// /Greeting string decompresses/decrypts to the correct plaintext. + /// + [Fact] + public void PdfDocument_Open_Encrypted_ObjStmContainedObjects_AreNeverDoubleDecrypted_DocumentedByDesign() + { + const int keyLengthBytes = 5; + const int revision = 2; + const int permissions = -3904; + var idBytes = (byte[])[.. Enumerable.Range(0, 16).Select(i => (byte)(0x80 + i))]; + + var oBytes = ComputeOwnerEntryAlgorithm3(keyLengthBytes, revision, PasswordPadding, PasswordPadding); + var fileKey = ComputeFileKeyAlgorithm2(PasswordPadding, oBytes, permissions, idBytes, keyLengthBytes, revision); + var uBytes = ComputeUserEntryAlgorithm45(fileKey, idBytes, revision); + + var contentObjectKey = ComputeObjectKeyAlgorithm1(fileKey, 4, 0, isAes: false); + var encryptedContent = Rc4(contentObjectKey, Encoding.ASCII.GetBytes(PlaintextContent)); + + // Object 6 (compressed inside object 7's ObjStm container at index 0): a plain + // dictionary with one string entry. The object stream header is "6 0\n" (object number 6 + // at relative offset 0), so /First is 4 (the header's own byte length). + const string greeting = "Hello, Encrypted World!"; + var objStmPlaintext = Encoding.ASCII.GetBytes($"6 0\n<< /Greeting ({greeting}) >>"); + var compressedObjStm = ZlibCompress(objStmPlaintext); + + // Object 7's own raw (compressed) bytes are RC4-encrypted as a whole with object 7's own + // per-object key - never re-encrypted per contained object - proving the single-pass + // decryption guarantee this test exists to verify. + var objStmObjectKey = ComputeObjectKeyAlgorithm1(fileKey, 7, 0, isAes: false); + var encryptedObjStm = Rc4(objStmObjectKey, compressedObjStm); + + var encryptDictBody = $"<< /Filter /Standard /V 1 /R {revision} /O <{ToHex(oBytes)}> /U <{ToHex(uBytes)}> /P {permissions} >>"; + var pdfBytes = BuildEncryptedPdfWithObjectStream(encryptDictBody, idBytes, encryptedContent, encryptedObjStm); + + using var document = PdfDocument.Open(new MemoryStream(pdfBytes)); + + // Object 4's own encrypted /Contents stream decrypted correctly. + Assert.Equal(1, document.PageCount); + using var surface = document.Render(0, 100, 100); + Assert.Equal(Black, surface[30, 70]); + Assert.Equal(default, surface[5, 5]); + + // Object 6's own /Greeting string, reached only via LoadCompressedObject's decompression + // of object 7's already-decrypted container bytes, is correct - proving exactly one + // decrypt pass (the container stream's own), not a second, erroneous per-object pass. + var getObject = typeof(PdfDocument).GetMethod("GetObject", BindingFlags.NonPublic | BindingFlags.Instance)!; + var compressedObject = (PdfDocument.PdfObject)getObject.Invoke(document, [6])!; + Assert.Equal(Encoding.ASCII.GetBytes(greeting), compressedObject.Get("Greeting")!.Bytes); + } + + /// + /// Proves that an encrypted document whose /Type /Catalog object itself is + /// compressed inside an encrypted /Type /ObjStm container still opens and renders + /// correctly, instead of incorrectly falling back to a linear scan. Resolving /Root + /// (to validate the catalog) requires decompressing the container object, which in turn + /// requires the container's own raw bytes to already be correctly decrypted - so this only + /// succeeds when the file decryption key is established before the catalog root is + /// validated, not after. + /// + [Fact] + public void PdfDocument_Open_Encrypted_CompressedCatalog_OpensWithoutLinearScanFallback() + { + const int keyLengthBytes = 5; + const int revision = 2; + const int permissions = -3904; + var idBytes = (byte[])[.. Enumerable.Range(0, 16).Select(i => (byte)(0xA0 + i))]; + + var oBytes = ComputeOwnerEntryAlgorithm3(keyLengthBytes, revision, PasswordPadding, PasswordPadding); + var fileKey = ComputeFileKeyAlgorithm2(PasswordPadding, oBytes, permissions, idBytes, keyLengthBytes, revision); + var uBytes = ComputeUserEntryAlgorithm45(fileKey, idBytes, revision); + + var contentObjectKey = ComputeObjectKeyAlgorithm1(fileKey, 4, 0, isAes: false); + var encryptedContent = Rc4(contentObjectKey, Encoding.ASCII.GetBytes(PlaintextContent)); + + // Object 1 (the Catalog, compressed inside object 6's ObjStm container at index 0). The + // object stream header is "1 0\n" (object number 1 at relative offset 0), so /First is 4. + var objStmPlaintext = Encoding.ASCII.GetBytes("1 0\n<< /Type /Catalog /Pages 2 0 R >>"); + var compressedObjStm = ZlibCompress(objStmPlaintext); + + // Object 6's own raw (compressed) bytes are RC4-encrypted as a whole with object 6's own + // per-object key - the catalog is only reachable once this container decrypts correctly. + var objStmObjectKey = ComputeObjectKeyAlgorithm1(fileKey, 6, 0, isAes: false); + var encryptedObjStm = Rc4(objStmObjectKey, compressedObjStm); + + var encryptDictBody = $"<< /Filter /Standard /V 1 /R {revision} /O <{ToHex(oBytes)}> /U <{ToHex(uBytes)}> /P {permissions} >>"; + var pdfBytes = BuildEncryptedPdfWithCompressedCatalog(encryptDictBody, idBytes, encryptedContent, encryptedObjStm); + + using var document = PdfDocument.Open(new MemoryStream(pdfBytes)); + + // The document opened via normal cross-reference parsing - not the linear-scan fallback, + // which would have scanned for "N G obj" markers and never found object 1 (it has no such + // marker; it only exists compressed inside object 6) - and renders correctly. + Assert.Equal(1, document.PageCount); + using var surface = document.Render(0, 100, 100); + Assert.Equal(Black, surface[30, 70]); + Assert.Equal(default, surface[5, 5]); + } + + /// + /// Builds an in-memory, single-page, classic-xref, encrypted PDF whose classic + /// cross-reference table deliberately records the wrong offset for object 1 (the + /// Catalog) - pointing instead at object 2's (the Pages dictionary's) own real location, + /// so normal cross-reference-based resolution of /Root parses successfully but + /// yields a /Type /Pages object rather than a /Type /Catalog one, failing + /// catalog-root validation and forcing the linear-scan fallback. The buffer also carries + /// a second, decoy trailer dictionary near the end - with a valid /Root but + /// no /Encrypt entry at all - that is never linked via startxref//Prev + /// and so is invisible to normal parsing, but which ScanForTrailerDictionary (used + /// only by the fallback) finds by tokenizing the whole buffer for every trailer + /// keyword occurrence and keeping the last one seen. The fallback therefore recovers a + /// trailer with no /Encrypt entry even though the document is genuinely encrypted - + /// exactly the scenario that would leave a stale _encryptionKey from the abandoned + /// normal-path attempt in place without this fix. Object 4's /Contents stream is + /// stored as plain, unencrypted bytes ( verbatim), proving + /// the fallback path treats it as genuinely unencrypted rather than incorrectly RC4 + /// "decrypting" it with the stale key. + /// + private static byte[] BuildEncryptedPdfWithBrokenCatalogOffsetAndNoEncryptDecoyTrailer(string encryptDictBody, byte[] idBytes) + { + var bodies = new List + { + "<< /Type /Catalog /Pages 2 0 R >>"u8.ToArray(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 100 100] >>"u8.ToArray(), + "<< /Type /Page /Parent 2 0 R /Contents 4 0 R >>"u8.ToArray(), + BuildStreamBody(Encoding.ASCII.GetBytes(PlaintextContent)), + Encoding.ASCII.GetBytes(encryptDictBody), + }; + + var buffer = new List(); + buffer.AddRange("%PDF-1.7\n"u8.ToArray()); + var offsets = new List(); + for (var i = 0; i < bodies.Count; i++) + { + offsets.Add(buffer.Count); + buffer.AddRange(Encoding.ASCII.GetBytes($"{i + 1} 0 obj\n")); + buffer.AddRange(bodies[i]); + buffer.AddRange("\nendobj\n"u8.ToArray()); + } + + var xrefOffset = buffer.Count; + buffer.AddRange(Encoding.ASCII.GetBytes($"xref\n0 {bodies.Count + 1}\n")); + buffer.AddRange("0000000000 65535 f \n"u8.ToArray()); + + // Object 1's recorded offset is deliberately wrong - object 2's real location - so the + // normal cross-reference path resolves /Root to a non-Catalog dictionary instead of + // throwing outright, failing IsValidCatalogRoot cleanly. + buffer.AddRange(Encoding.ASCII.GetBytes($"{offsets[1]:D10} 00000 n \n")); + for (var i = 1; i < offsets.Count; i++) + { + buffer.AddRange(Encoding.ASCII.GetBytes($"{offsets[i]:D10} 00000 n \n")); + } + + var idHex = ToHex(idBytes); + buffer.AddRange(Encoding.ASCII.GetBytes( + $"trailer\n<< /Size {bodies.Count + 1} /Root 1 0 R /Encrypt 5 0 R /ID [<{idHex}> <{idHex}>] >>\nstartxref\n{xrefOffset}\n%%EOF\n")); + + // The decoy trailer: valid /Root, no /Encrypt, unreachable via startxref/Prev - only the + // linear-scan fallback's raw "trailer" keyword scan ever sees this one. + buffer.AddRange(Encoding.ASCII.GetBytes($"trailer\n<< /Size {bodies.Count + 1} /Root 1 0 R >>\n")); + + return [.. buffer]; + } + + /// + /// Proves that when normal cross-reference parsing establishes a genuine encryption key + /// but catalog-root validation then fails, triggering the linear-scan fallback, and the + /// fallback's own recovered trailer has no /Encrypt entry, the stale encryption key + /// from the abandoned normal-path attempt is discarded rather than reused - so a plain, + /// unencrypted /Contents stream in the fallback-resolved document renders correctly + /// instead of being incorrectly RC4 "decrypted" with a key that does not actually apply. + /// + [Fact] + public void PdfDocument_Open_FallbackTrailerWithoutEncrypt_DiscardsStaleEncryptionKey() + { + const int keyLengthBytes = 5; + const int revision = 2; + const int permissions = -3904; + var idBytes = (byte[])[.. Enumerable.Range(0, 16).Select(i => (byte)(0xC0 + i))]; + + var oBytes = ComputeOwnerEntryAlgorithm3(keyLengthBytes, revision, PasswordPadding, PasswordPadding); + var fileKey = ComputeFileKeyAlgorithm2(PasswordPadding, oBytes, permissions, idBytes, keyLengthBytes, revision); + var uBytes = ComputeUserEntryAlgorithm45(fileKey, idBytes, revision); + var encryptDictBody = $"<< /Filter /Standard /V 1 /R {revision} /O <{ToHex(oBytes)}> /U <{ToHex(uBytes)}> /P {permissions} >>"; + + var pdfBytes = BuildEncryptedPdfWithBrokenCatalogOffsetAndNoEncryptDecoyTrailer(encryptDictBody, idBytes); + + // Act + using var document = PdfDocument.Open(new MemoryStream(pdfBytes)); + + // Assert: the plain, unencrypted "10 10 40 40 re f" content stream rendered correctly - + // without the fix, the stale RC4 key would have garbled these already-plaintext bytes + // before content-stream parsing ever saw them. + Assert.Equal(1, document.PageCount); + using var surface = document.Render(0, 100, 100); + Assert.Equal(Black, surface[30, 70]); + Assert.Equal(default, surface[5, 5]); + } +} diff --git a/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfDocumentSymbolicEncodingTests.cs b/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfDocumentSymbolicEncodingTests.cs new file mode 100644 index 00000000..44235b1a --- /dev/null +++ b/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfDocumentSymbolicEncodingTests.cs @@ -0,0 +1,67 @@ +// cspell:ignore Zapf ZapfDingbats registerserif copyrightserif trademarkserif radicalex Noto +using System.Reflection; + +namespace DemaConsulting.CanvasNet.Pdf.Tests; + +/// +/// Spot-checks representative, known-correct entries of the two new PDF 32000-1 Appendix D +/// "Symbol Set and ZapfDingbats Encoding" code-to-Unicode-codepoint tables +/// (SymbolEncodingTable/ZapfDingbatsEncodingTable in +/// PdfDocument.Fonts.cs), rather than re-asserting the entire 256-entry table +/// verbatim (which would be a tautological "test" proving nothing beyond a successful +/// copy-paste). Accessed via reflection since both tables are private static fields - +/// there is no existing precedent in this test suite for exposing such data publicly purely +/// for testing, and the plan deliberately favors reflection over widening the fields' +/// accessibility. +/// +public class PdfDocumentSymbolicEncodingTests +{ + private static int[] GetEncodingTable(string fieldName) + { + var field = typeof(PdfDocument).GetField(fieldName, BindingFlags.NonPublic | BindingFlags.Static) + ?? throw new MissingFieldException(nameof(PdfDocument), fieldName); + return (int[])field.GetValue(null)!; + } + + /// + /// Proves SymbolEncodingTable correctly maps a representative sample of Symbol + /// font codes to their known-correct Unicode codepoints, per PDF 32000-1 Appendix D: + /// alpha (lowercase Greek alpha, code 0x61) to U+03B1, Alpha + /// (uppercase Greek Alpha, code 0x41) to U+0391, the Private-Use-Area-fallback + /// glyph name registerserif (code 0xD2) to the plain/generic U+00AE + /// ("registered sign") rather than a Private-Use-Area codepoint, and the deliberately + /// unmapped extensible-delimiter-piece glyph name radicalex (code 0x60) to + /// 0 (no defined glyph). + /// + [Fact] + public void SymbolEncodingTable_RepresentativeSample_MapsToKnownCorrectCodepoints() + { + var table = GetEncodingTable("SymbolEncodingTable"); + + Assert.Equal(256, table.Length); + Assert.Equal(0x03B1, table[0x61]); // alpha + Assert.Equal(0x0391, table[0x41]); // Alpha + Assert.Equal(0x00AE, table[0xD2]); // registerserif -> generic U+00AE, not a PUA codepoint + Assert.Equal(0x0000, table[0x60]); // radicalex -> deliberately unmapped + } + + /// + /// Proves ZapfDingbatsEncodingTable correctly maps a representative sample of + /// ZapfDingbats font codes to their known-correct Unicode codepoints, per PDF 32000-1 + /// Appendix D: space (code 0x20) to U+0020, a1 (the first dingbat + /// glyph, code 0x21) to U+2701, and the documented, accepted-fidelity-limitation + /// uncovered case a120 (circled digit one, code 0xAC) to U+2460 - the table + /// itself still defines this mapping; only the bundled Noto substitute font does not + /// cover this particular codepoint. + /// + [Fact] + public void ZapfDingbatsEncodingTable_RepresentativeSample_MapsToKnownCorrectCodepoints() + { + var table = GetEncodingTable("ZapfDingbatsEncodingTable"); + + Assert.Equal(256, table.Length); + Assert.Equal(0x0020, table[0x20]); // space + Assert.Equal(0x2701, table[0x21]); // a1 + Assert.Equal(0x2460, table[0xAC]); // a120 (circled digit one) - defined, but not substitute-font-covered + } +} diff --git a/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfDocumentTests.cs b/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfDocumentTests.cs new file mode 100644 index 00000000..8632898c --- /dev/null +++ b/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfDocumentTests.cs @@ -0,0 +1,8382 @@ +using DemaConsulting.CanvasNet.Codecs; +using DemaConsulting.CanvasNet.Fonts; +using DemaConsulting.CanvasNet.Tests.TestSupport; + +// cspell:ignore agrave Xyzzy Zapf Nonsymbolic cids +// cspell:ignore beginbfchar endbfchar beginbfrange endbfrange begincodespacerange +// cspell:ignore endcodespacerange findresource defineresource currentdict begincmap endcmap +// cspell:ignore bfchar bfrange nendbfchar nendbfrange tounicode usecmap cidrange cidchar codepoints +// cspell:ignore OTTO rmoveto rlineto endchar notdef charstring charstrings cidfonttype +// cspell:ignore functiontype multiinput fitz uncatchable +// cspell:ignore Noto +// cspell:ignore hsbw closepath fontfile lenIV quoteright Quoteright hival + +namespace DemaConsulting.CanvasNet.Pdf.Tests; + +/// +/// Unit-level tests for the parsing internals (tokenizer, object +/// model, cross-reference resolution, page-tree traversal) and public API surface. +/// Complements , which proves the same behaviors +/// end-to-end through the public API only. +/// +public class PdfDocumentTests +{ + /// The directory containing the hand-authored PDF fixtures (see PdfFixtures\README.md). + private static string FixturesPath => Path.Join(AppContext.BaseDirectory, "PdfFixtures"); + + private static string Fixture(string name) => Path.Join(FixturesPath, name); + + /// The opaque black color every Phase 2 fill/stroke operator paints with. + private static readonly Canvas.Rgba32 Black = new(0, 0, 0, 255); + + /// + /// Builds an in-memory, single-page, classic-xref PDF (matching + /// PdfFixtures\README.md's hand-authored template) whose one page declares the + /// given /MediaBox, optional /Rotate, and a single /Contents stream + /// holding verbatim - used by tests that only need arbitrary + /// content-stream text against a fixed page size, without a dedicated fixture file on disk. + /// + private static byte[] BuildSinglePagePdf(double mediaBoxWidth, double mediaBoxHeight, string content, int rotate = 0) + { + var contentBytes = System.Text.Encoding.ASCII.GetBytes(content); + var rotateEntry = rotate == 0 ? string.Empty : $" /Rotate {rotate}"; + var streamHeader = System.Text.Encoding.ASCII.GetBytes($"<< /Length {contentBytes.Length} >>\nstream\n"); + var streamFooter = "\nendstream"u8.ToArray(); + var streamBody = new byte[streamHeader.Length + contentBytes.Length + streamFooter.Length]; + streamHeader.CopyTo(streamBody, 0); + contentBytes.CopyTo(streamBody, streamHeader.Length); + streamFooter.CopyTo(streamBody, streamHeader.Length + contentBytes.Length); + + var bodies = new List + { + "<< /Type /Catalog /Pages 2 0 R >>"u8.ToArray(), + System.Text.Encoding.ASCII.GetBytes( + $"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 {mediaBoxWidth} {mediaBoxHeight}] >>"), + System.Text.Encoding.ASCII.GetBytes($"<< /Type /Page /Parent 2 0 R{rotateEntry} /Contents 4 0 R >>"), + streamBody, + }; + + var buffer = new List(); + buffer.AddRange("%PDF-1.7\n"u8.ToArray()); + var offsets = new List(); + for (var i = 0; i < bodies.Count; i++) + { + offsets.Add(buffer.Count); + buffer.AddRange(System.Text.Encoding.ASCII.GetBytes($"{i + 1} 0 obj\n")); + buffer.AddRange(bodies[i]); + buffer.AddRange("\nendobj\n"u8.ToArray()); + } + + var xrefOffset = buffer.Count; + buffer.AddRange(System.Text.Encoding.ASCII.GetBytes($"xref\n0 {bodies.Count + 1}\n")); + buffer.AddRange("0000000000 65535 f \n"u8.ToArray()); + foreach (var offset in offsets) + { + buffer.AddRange(System.Text.Encoding.ASCII.GetBytes($"{offset:D10} 00000 n \n")); + } + + buffer.AddRange(System.Text.Encoding.ASCII.GetBytes( + $"trailer\n<< /Size {bodies.Count + 1} /Root 1 0 R >>\nstartxref\n{xrefOffset}\n%%EOF\n")); + return [.. buffer]; + } + + /// + /// Renders against a single-page PDF built by + /// , returning the resulting . + /// + private static Canvas.Surface RenderContent( + string content, + int width = 100, + int height = 100, + double mediaBoxWidth = 100, + double mediaBoxHeight = 100, + int rotate = 0) + { + var bytes = BuildSinglePagePdf(mediaBoxWidth, mediaBoxHeight, content, rotate); + using var document = PdfDocument.Open(new MemoryStream(bytes)); + return document.Render(0, width, height); + } + + /// + /// Builds an in-memory, single-page, classic-xref PDF exactly like + /// , but with the page declaring a + /// /Resources << ... >> dictionary (, + /// inserted verbatim between the outer << >>) and 0 or more extra + /// indirect objects (, numbered 5 0 obj, + /// 6 0 obj, ... in order) that may reference by + /// number - used by every color-space-resource and image-XObject test that needs a + /// /Resources dictionary (plain color-operator tests needing no /Resources + /// keep using / unchanged). + /// + private static byte[] BuildSinglePagePdfWithResources( + double mediaBoxWidth, + double mediaBoxHeight, + string content, + string resourcesBody, + IReadOnlyList extraObjectBodies, + int rotate = 0) + { + var contentBytes = System.Text.Encoding.ASCII.GetBytes(content); + var rotateEntry = rotate == 0 ? string.Empty : $" /Rotate {rotate}"; + var streamHeader = System.Text.Encoding.ASCII.GetBytes($"<< /Length {contentBytes.Length} >>\nstream\n"); + var streamFooter = "\nendstream"u8.ToArray(); + var streamBody = new byte[streamHeader.Length + contentBytes.Length + streamFooter.Length]; + streamHeader.CopyTo(streamBody, 0); + contentBytes.CopyTo(streamBody, streamHeader.Length); + streamFooter.CopyTo(streamBody, streamHeader.Length + contentBytes.Length); + + var bodies = new List + { + "<< /Type /Catalog /Pages 2 0 R >>"u8.ToArray(), + System.Text.Encoding.ASCII.GetBytes( + $"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 {mediaBoxWidth} {mediaBoxHeight}] >>"), + System.Text.Encoding.ASCII.GetBytes( + $"<< /Type /Page /Parent 2 0 R{rotateEntry} /Contents 4 0 R /Resources << {resourcesBody} >> >>"), + streamBody, + }; + bodies.AddRange(extraObjectBodies); + + var buffer = new List(); + buffer.AddRange("%PDF-1.7\n"u8.ToArray()); + var offsets = new List(); + for (var i = 0; i < bodies.Count; i++) + { + offsets.Add(buffer.Count); + buffer.AddRange(System.Text.Encoding.ASCII.GetBytes($"{i + 1} 0 obj\n")); + buffer.AddRange(bodies[i]); + buffer.AddRange("\nendobj\n"u8.ToArray()); + } + + var xrefOffset = buffer.Count; + buffer.AddRange(System.Text.Encoding.ASCII.GetBytes($"xref\n0 {bodies.Count + 1}\n")); + buffer.AddRange("0000000000 65535 f \n"u8.ToArray()); + foreach (var offset in offsets) + { + buffer.AddRange(System.Text.Encoding.ASCII.GetBytes($"{offset:D10} 00000 n \n")); + } + + buffer.AddRange(System.Text.Encoding.ASCII.GetBytes( + $"trailer\n<< /Size {bodies.Count + 1} /Root 1 0 R >>\nstartxref\n{xrefOffset}\n%%EOF\n")); + return [.. buffer]; + } + + /// + /// Builds an indirect-object body (dictionary header + raw stream bytes) for use as one of + /// 's extraObjectBodies - the + /// text is inserted verbatim between the dictionary's + /// outer << >> alongside an automatically computed /Length. + /// + private static byte[] BuildStreamObjectBody(string dictionaryEntries, byte[] streamData) + { + var header = System.Text.Encoding.ASCII.GetBytes($"<< {dictionaryEntries} /Length {streamData.Length} >>\nstream\n"); + var footer = "\nendstream"u8.ToArray(); + var body = new byte[header.Length + streamData.Length + footer.Length]; + header.CopyTo(body, 0); + streamData.CopyTo(body, header.Length); + footer.CopyTo(body, header.Length + streamData.Length); + return body; + } + + /// Compresses into a standards-conformant zlib stream (header + deflate + Adler-32), as FlateDecode expects. + private static byte[] ZlibCompress(byte[] data) + { + using var output = new MemoryStream(); + using (var zlib = new System.IO.Compression.ZLibStream(output, System.IO.Compression.CompressionLevel.Optimal, leaveOpen: true)) + { + zlib.Write(data, 0, data.Length); + } + + return output.ToArray(); + } + + /// Renders page 0 of an in-memory PDF built by . + private static Canvas.Surface RenderPdfBytes(byte[] bytes, int width = 100, int height = 100) + { + using var document = PdfDocument.Open(new MemoryStream(bytes)); + return document.Render(0, width, height); + } + + /// + /// Renders a x1 DeviceGray image XObject (declaring + /// , e.g. "/Filter /LZWDecode") placed to exactly + /// fill a x1 page/device surface - giving an exact 1:1 + /// nearest-neighbor correspondence between decoded-byte index and device pixel column, so + /// every filter unit test below can directly assert surface[i, 0] against the + /// exact byte it expects GetStreamDecodedBytes to have produced at index + /// i, exactly like the existing predictor tests already do for 2x2 images. + /// + private static Canvas.Surface RenderGrayscaleImage(int width, byte[] streamData, string filterAndParams) + { + var imageStream = BuildStreamObjectBody( + $"/Type /XObject /Subtype /Image /Width {width} /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8 {filterAndParams}", + streamData); + + var bytes = BuildSinglePagePdfWithResources( + width, + 1, + $"{width} 0 0 1 0 0 cm /Im0 Do", + "/XObject << /Im0 5 0 R >>", + [imageStream]); + + return RenderPdfBytes(bytes, width, 1); + } + + /// + /// Builds the deterministic, synthetic (not spec-provided) 320-byte data vector the + /// EarlyChange growth-timing tests use - long enough for the dictionary to cross + /// dynamic code 511 (the point where code width must grow from 9 to 10 bits), so the two + /// /EarlyChange timings actually diverge (independently confirmed via a Python + /// prototype during this phase's planning: shorter vectors never reach the boundary and + /// cannot distinguish the two timings at all). + /// + private static byte[] BuildLzwGrowthTestData() + { + var data = new byte[320]; + for (var i = 0; i < data.Length; i++) + { + data[i] = (byte)(((i * 37) + 11) % 199); + } + + return data; + } + + /// + /// A minimal, test-only classic-LZW encoder that produces exactly the byte sequence the + /// production PDF-variant LZWDecode decoder expects - + /// used only to synthesize test vectors, never shipped in src/. Mirrors the + /// encoder side of the same classic-LZW algorithm described in + /// PdfDocument.Filters.Lzw.cs's own remarks: the encoder grows its code width one + /// table entry earlier than the decoder's own growth check, because the encoder omits a + /// dictionary addition only for its final flushed code, while the decoder omits one only + /// for its very first code after a Clear. + /// + private static byte[] EncodeLzwForTest(byte[] data, bool earlyChange) + { + var earlyChangeAmount = earlyChange ? 1 : 0; + var bits = new List(); + + void Emit(int code, int width) + { + for (var i = width - 1; i >= 0; i--) + { + bits.Add((code >> i) & 1); + } + } + + var table = new Dictionary<(int Prefix, byte Suffix), int>(); + var codeWidth = 9; + var nextCode = 258; + var extCode = (1 << codeWidth) + 1 - earlyChangeAmount; + + Emit(256, codeWidth); // Clear + + if (data.Length == 0) + { + Emit(257, codeWidth); // EOD + } + else + { + var currentCode = (int)data[0]; + for (var i = 1; i < data.Length; i++) + { + var next = data[i]; + if (table.TryGetValue((currentCode, next), out var extended)) + { + currentCode = extended; + continue; + } + + Emit(currentCode, codeWidth); + if (nextCode < 4096) + { + table[(currentCode, next)] = nextCode; + nextCode++; + if (nextCode >= extCode && codeWidth < 12) + { + codeWidth++; + extCode = (1 << codeWidth) + 1 - earlyChangeAmount; + } + } + + currentCode = next; + } + + Emit(currentCode, codeWidth); + Emit(257, codeWidth); // EOD + } + + while (bits.Count % 8 != 0) + { + bits.Add(0); + } + + var output = new byte[bits.Count / 8]; + for (var i = 0; i < output.Length; i++) + { + var b = 0; + for (var j = 0; j < 8; j++) + { + b = (b << 1) | bits[(i * 8) + j]; + } + + output[i] = (byte)b; + } + + return output; + } + + /// + /// Renders a x DeviceGray + /// CCITTFaxDecode image XObject placed to exactly fill a same-size device surface - + /// giving an exact 1:1 nearest-neighbor correspondence between decoded pixel + /// (x, y) and device pixel (x, y), mirroring 's + /// own precedent for the other filter unit tests. is + /// inserted verbatim into /DecodeParms (after the mandatory /K -1 /Columns + /// /Rows entries) so callers can add /BlackIs1//EncodedByteAlign/ + /// /EndOfLine or override /K for the rejection-path tests. + /// + private static Canvas.Surface RenderCcittFaxImage( + int columns, + int rows, + byte[] encodedData, + string extraDecodeParms = "") + { + var imageStream = BuildStreamObjectBody( + $"/Type /XObject /Subtype /Image /Width {columns} /Height {rows} /ColorSpace /DeviceGray " + + $"/BitsPerComponent 8 /Filter /CCITTFaxDecode " + + $"/DecodeParms << /K -1 /Columns {columns} /Rows {rows}{extraDecodeParms} >>", + encodedData); + + var bytes = BuildSinglePagePdfWithResources( + columns, + rows, + $"{columns} 0 0 {rows} 0 0 cm /Im0 Do", + "/XObject << /Im0 5 0 R >>", + [imageStream]); + + return RenderPdfBytes(bytes, columns, rows); + } + + /// + /// A minimal, test-only ITU-T T.6 Group 4 (MMR) two-dimensional encoder, parameterized + /// over an arbitrary pixels[x, y] grid ( = black) - used only + /// to synthesize trustworthy encoded test vectors, never shipped in src/. Mirrors + /// 's precedent: an independent, from-scratch + /// implementation of the encode side of the same algorithm the production decoder + /// implements, written separately (and, per the mode-code/run-length tables below, + /// transcribed separately) so that a bug in one is not mechanically guaranteed to be + /// masked by a matching bug in the other. + /// + /// + /// + /// Deliberately supports only run lengths < 64 (a single terminating code, + /// never a makeup code) - every test pattern in this file uses small images for which + /// this is never a true limitation, and it keeps this test-only encoder's own + /// run-length table small enough to transcribe (and visually cross-check) by hand. + /// Throws if a test accidentally supplies a + /// pattern requiring a run of 64 or more pixels. + /// + /// + /// Round-tripping a pattern through this encoder and then the production + /// DecodeCcittFax decoder alone is not sufficient to prove the + /// decoder correct, since a bug here could happen to exactly cancel a bug there - see + /// 's + /// own remarks for the independent, hand-verified (bit-by-bit, outside of any C# code) + /// cross-check this file relies on instead. + /// + /// + private static byte[] EncodeCcittGroup4ForTest(bool[,] pixels) + { + var columns = pixels.GetLength(0); + var rows = pixels.GetLength(1); + var bits = new List(); + + void EmitBits(int length, int code) + { + for (var i = length - 1; i >= 0; i--) + { + bits.Add((code >> i) & 1); + } + } + + var modeCodes = new Dictionary + { + ["Pass"] = (4, 0b0001), + ["Horizontal"] = (3, 0b001), + ["V0"] = (1, 0b1), + ["VR1"] = (3, 0b011), + ["VR2"] = (6, 0b000011), + ["VR3"] = (7, 0b0000011), + ["VL1"] = (3, 0b010), + ["VL2"] = (6, 0b000010), + ["VL3"] = (7, 0b0000010), + }; + + // ITU-T T.4 Table 3 White / Table 2 Black terminating codes (run lengths 0-63 only, see + // this method's own remarks), transcribed independently of + // PdfDocument.CcittFax.cs's own WhiteCodeEntries/BlackCodeEntries (same ultimate ITU-T + // source, but retyped separately here rather than referencing those internals, so a + // transcription slip in one is not silently hidden by reusing the other). + var whiteTerm = new Dictionary + { + [0] = (8, 0x35), + [1] = (6, 0x7), + [2] = (4, 0x7), + [3] = (4, 0x8), + [4] = (4, 0xB), + [5] = (4, 0xC), + [6] = (4, 0xE), + [7] = (4, 0xF), + [8] = (5, 0x13), + [9] = (5, 0x14), + [10] = (5, 0x7), + [11] = (5, 0x8), + [12] = (6, 0x8), + [13] = (6, 0x3), + [14] = (6, 0x34), + [15] = (6, 0x35), + [16] = (6, 0x2A), + [17] = (6, 0x2B), + [18] = (7, 0x27), + [19] = (7, 0xC), + [20] = (7, 0x8), + [21] = (7, 0x17), + [22] = (7, 0x3), + [23] = (7, 0x4), + [24] = (7, 0x28), + [25] = (7, 0x2B), + [26] = (7, 0x13), + [27] = (7, 0x24), + [28] = (7, 0x18), + [29] = (8, 0x2), + [30] = (8, 0x3), + [31] = (8, 0x1A), + [32] = (8, 0x1B), + [33] = (8, 0x12), + [34] = (8, 0x13), + [35] = (8, 0x14), + [36] = (8, 0x15), + [37] = (8, 0x16), + [38] = (8, 0x17), + [39] = (8, 0x28), + [40] = (8, 0x29), + [41] = (8, 0x2A), + [42] = (8, 0x2B), + [43] = (8, 0x2C), + [44] = (8, 0x2D), + [45] = (8, 0x4), + [46] = (8, 0x5), + [47] = (8, 0xA), + [48] = (8, 0xB), + [49] = (8, 0x52), + [50] = (8, 0x53), + [51] = (8, 0x54), + [52] = (8, 0x55), + [53] = (8, 0x24), + [54] = (8, 0x25), + [55] = (8, 0x58), + [56] = (8, 0x59), + [57] = (8, 0x5A), + [58] = (8, 0x5B), + [59] = (8, 0x4A), + [60] = (8, 0x4B), + [61] = (8, 0x32), + [62] = (8, 0x33), + [63] = (8, 0x34), + }; + var blackTerm = new Dictionary + { + [0] = (10, 0x37), + [1] = (3, 0x2), + [2] = (2, 0x3), + [3] = (2, 0x2), + [4] = (3, 0x3), + [5] = (4, 0x3), + [6] = (4, 0x2), + [7] = (5, 0x3), + [8] = (6, 0x5), + [9] = (6, 0x4), + [10] = (7, 0x4), + [11] = (7, 0x5), + [12] = (7, 0x7), + [13] = (8, 0x4), + [14] = (8, 0x7), + [15] = (9, 0x18), + [16] = (10, 0x17), + [17] = (10, 0x18), + [18] = (10, 0x8), + [19] = (11, 0x67), + [20] = (11, 0x68), + [21] = (11, 0x6C), + [22] = (11, 0x37), + [23] = (11, 0x28), + [24] = (11, 0x17), + [25] = (11, 0x18), + [26] = (12, 0xCA), + [27] = (12, 0xCB), + [28] = (12, 0xCC), + [29] = (12, 0xCD), + [30] = (12, 0x68), + [31] = (12, 0x69), + [32] = (12, 0x6A), + [33] = (12, 0x6B), + [34] = (12, 0xD2), + [35] = (12, 0xD3), + [36] = (12, 0xD4), + [37] = (12, 0xD5), + [38] = (12, 0xD6), + [39] = (12, 0xD7), + [40] = (12, 0x6C), + [41] = (12, 0x6D), + [42] = (12, 0xDA), + [43] = (12, 0xDB), + [44] = (12, 0x54), + [45] = (12, 0x55), + [46] = (12, 0x56), + [47] = (12, 0x57), + [48] = (12, 0x64), + [49] = (12, 0x65), + [50] = (12, 0x52), + [51] = (12, 0x53), + [52] = (12, 0x24), + [53] = (12, 0x37), + [54] = (12, 0x38), + [55] = (12, 0x27), + [56] = (12, 0x28), + [57] = (12, 0x58), + [58] = (12, 0x59), + [59] = (12, 0x2B), + [60] = (12, 0x2C), + [61] = (12, 0x5A), + [62] = (12, 0x66), + [63] = (12, 0x67), + }; + + void EmitRun(bool isBlack, int run) + { + if (run >= 64) + { + throw new InvalidOperationException( + $"Test-only encoder limitation: run length {run} requires a makeup code, which this encoder does not support."); + } + + var (length, code) = (isBlack ? blackTerm : whiteTerm)[run]; + EmitBits(length, code); + } + + List RowChanges(int y) + { + var changes = new List(); + var color = false; + for (var x = 0; x < columns; x++) + { + if (pixels[x, y] != color) + { + changes.Add(x); + color = pixels[x, y]; + } + } + + return changes; + } + + (int B1, int B2) FindB1B2(IReadOnlyList reference, int a0, bool currentIsBlack) + { + var idx = 0; + while (idx < reference.Count && reference[idx] <= a0) + { + idx++; + } + + var colorAtIdxIsBlack = idx % 2 == 0; + if (colorAtIdxIsBlack == currentIsBlack) + { + idx++; + } + + var b1 = idx < reference.Count ? reference[idx] : columns; + var b2 = idx + 1 < reference.Count ? reference[idx + 1] : columns; + return (b1, b2); + } + + int NextChangeAfter(List changes, int position) + { + foreach (var c in changes) + { + if (c > position) + { + return c; + } + } + + return columns; + } + + var referenceChanges = new List(); + for (var y = 0; y < rows; y++) + { + var changes = RowChanges(y); + var a0 = -1; + var color = false; + while (a0 < columns) + { + var (b1, b2) = FindB1B2(referenceChanges, a0, color); + var a1 = NextChangeAfter(changes, a0); + + if (a1 > b2) + { + EmitBits(modeCodes["Pass"].Length, modeCodes["Pass"].Code); + a0 = b2; + } + else + { + var delta = a1 - b1; + if (delta is >= -3 and <= 3) + { + var name = delta switch + { + 0 => "V0", + 1 => "VR1", + 2 => "VR2", + 3 => "VR3", + -1 => "VL1", + -2 => "VL2", + _ => "VL3", + }; + EmitBits(modeCodes[name].Length, modeCodes[name].Code); + a0 = a1; + color = !color; + } + else + { + var a2 = NextChangeAfter(changes, a1); + var start = a0 < 0 ? 0 : a0; + EmitBits(modeCodes["Horizontal"].Length, modeCodes["Horizontal"].Code); + EmitRun(color, a1 - start); + EmitRun(!color, a2 - a1); + a0 = a2; + } + } + } + + referenceChanges = changes; + } + + while (bits.Count % 8 != 0) + { + bits.Add(0); + } + + var output = new byte[bits.Count / 8]; + for (var i = 0; i < output.Length; i++) + { + var b = 0; + for (var j = 0; j < 8; j++) + { + b = (b << 1) | bits[(i * 8) + j]; + } + + output[i] = (byte)b; + } + + return output; + } + + /// + /// Builds a minimal, well-formed synthetic embedded TrueType font (see + /// ): a 1000-unit em square, glyph 0 the + /// (empty-outline) .notdef, and every glyph from index 1 onward a filled square + /// outline spanning font-design-space (100, 100)-(500, 500) with a fixed + /// 600-unit advance width - used by every Phase 4 unit test that needs a real, loadable + /// TrueTypeFont without depending on a real-world font file (see + /// for the one required real-embedded-font, + /// real-glyph-shape pixel-level test, using the shared Open Sans fixture instead). + /// + /// The cmap table's codepoint-to-glyph-index mappings. + /// The total number of glyphs (including glyph 0). + private static byte[] BuildEmbeddedFontBytes( + IReadOnlyList<(int Codepoint, int GlyphId)> cmapMappings, + int glyphCount = 3) + { + var square = SyntheticFontBuilder.SimpleGlyph( + [ + [(100, 100, true), (500, 100, true), (500, 500, true), (100, 500, true)], + ]); + + var glyphLengths = new List { 0 }; + var glyf = new List(); + var advanceWidths = new List { 0 }; + for (var glyphIndex = 1; glyphIndex < glyphCount; glyphIndex++) + { + glyphLengths.Add(square.Length); + glyf.AddRange(square); + advanceWidths.Add(600); + } + + var cmap = SyntheticFontBuilder.CmapFormat4(3, 1, cmapMappings); + + return new SyntheticFontBuilder() + .AddTable("head", SyntheticFontBuilder.Head(1000, 0)) + .AddTable("maxp", SyntheticFontBuilder.Maxp(glyphCount)) + .AddTable("hhea", SyntheticFontBuilder.Hhea(800, -200, 0, glyphCount)) + .AddTable("hmtx", SyntheticFontBuilder.Hmtx(advanceWidths)) + .AddTable("loca", SyntheticFontBuilder.Loca(glyphLengths, longFormat: false)) + .AddTable("glyf", [.. glyf]) + .AddTable("cmap", cmap) + .Build(); + } + + /// + /// Builds a single glyph's Type 2 charstring bytecode: a filled square outline spanning + /// font-design-space (100, 100)-(500, 500) (matching + /// 's own TrueType-outline square glyph, so both + /// descendant-font flavors paint identical ink for a given glyph index), via + /// rmoveto to (100, 100) then a single rlineto with three relative + /// deltas - the Type 2 charstring language auto-closes the final segment back to the + /// starting point at endchar, exactly like + /// Fonts.TrueTypeFontTests.BuildWellFormedCffFont's own square glyph. + /// + private static byte[] BuildSquareCffCharstring() + { + var cs = new List(); + SyntheticFontBuilder.WriteCharstringNumber(cs, 100); + SyntheticFontBuilder.WriteCharstringNumber(cs, 100); + SyntheticFontBuilder.WriteCharstringOperator(cs, 21); // rmoveto + SyntheticFontBuilder.WriteCharstringNumber(cs, 400); + SyntheticFontBuilder.WriteCharstringNumber(cs, 0); + SyntheticFontBuilder.WriteCharstringNumber(cs, 0); + SyntheticFontBuilder.WriteCharstringNumber(cs, 400); + SyntheticFontBuilder.WriteCharstringNumber(cs, -400); + SyntheticFontBuilder.WriteCharstringNumber(cs, 0); + SyntheticFontBuilder.WriteCharstringOperator(cs, 5); // rlineto + SyntheticFontBuilder.WriteCharstringOperator(cs, 14); // endchar + return [.. cs]; + } + + /// + /// Builds a minimal, well-formed synthetic embedded OTTO/CFF-flavored SFNT font (see + /// ): a 1000-unit em square, glyph 0 the + /// (empty-outline) .notdef, and every glyph from index 1 onward + /// 's filled square outline with a fixed 600-unit + /// advance width - the CIDFontType0//FontFile3 counterpart of + /// , built via the exact + /// WithSfntVersion(0x4F54544F) ('OTTO') + maxp version 0x00005000 + + /// CFF -table pattern already proven in + /// Fonts.TrueTypeFontTests.BuildWellFormedCffFont. No cmap table is included + /// (composite fonts never consult cmap - a CID is used directly as a glyph index), + /// mirroring how Fonts.TrueTypeFont.Load tolerates a missing cmap entirely. + /// + /// The total number of glyphs (including glyph 0). + /// + /// When , the embedded CFF program's Top DICT declares the + /// ROS operator (CID-keyed font identification), which Fonts.CffTable.Parse + /// rejects - used to prove that rejection propagates uncaught through + /// LoadCidFontType0Font. + /// + private static byte[] BuildEmbeddedCffFontBytes(int glyphCount = 2, bool includeRos = false) + { + var notdefCharstring = new List(); + SyntheticFontBuilder.WriteCharstringOperator(notdefCharstring, 14); // endchar + var charStrings = new List { notdefCharstring.ToArray() }; // glyph 0: empty .notdef + var advanceWidths = new List { 0 }; + for (var glyphIndex = 1; glyphIndex < glyphCount; glyphIndex++) + { + charStrings.Add(BuildSquareCffCharstring()); + advanceWidths.Add(600); + } + + var cff = SyntheticFontBuilder.Cff(charStrings, includeRos: includeRos); + + return new SyntheticFontBuilder() + .WithSfntVersion(0x4F54544F) // 'OTTO' + .AddTable("head", SyntheticFontBuilder.Head(1000, 0)) + .AddTable("maxp", SyntheticFontBuilder.Maxp(glyphCount, version: 0x00005000)) + .AddTable("hhea", SyntheticFontBuilder.Hhea(800, -200, 0, glyphCount)) + .AddTable("hmtx", SyntheticFontBuilder.Hmtx(advanceWidths)) + .AddTable("CFF ", cff) + .Build(); + } + + /// + /// Builds a /Resources/Font dictionary (as a - + /// compatible resourcesBody/extraObjectBodies pair) declaring a single simple + /// TrueType font resource named /F1, with the given embedded /FontFile2 bytes + /// and font-dictionary/font-descriptor entries appended verbatim. + /// + /// + /// Numbered so is always object 7 (referenced as + /// 7 0 R by the descriptor, object 6, in turn referenced as 6 0 R by + /// the font dictionary, object 5, in turn referenced as 5 0 R by the + /// returned /Font resources entry) - matching every other Phase 3 image-XObject + /// test's own "extra objects start at 5" convention. + /// + private static (string ResourcesBody, List ExtraObjects) BuildSimpleTrueTypeFontResources( + byte[] fontFileBytes, + string fontDictExtra = "/FirstChar 65 /LastChar 66 /Widths [600 600]", + string descriptorExtra = "", + string fontResourceName = "F1") + { + var fontFileObj = BuildStreamObjectBody(string.Empty, fontFileBytes); + var descriptorObj = System.Text.Encoding.ASCII.GetBytes( + $"<< /Type /FontDescriptor /FontName /Test {descriptorExtra} /FontFile2 7 0 R >>"); + var fontDictObj = System.Text.Encoding.ASCII.GetBytes( + $"<< /Type /Font /Subtype /TrueType /BaseFont /Test {fontDictExtra} /FontDescriptor 6 0 R >>"); + + return ($"/Font << /{fontResourceName} 5 0 R >>", [fontDictObj, descriptorObj, fontFileObj]); + } + + /// + /// Builds a classic PostScript Type 1 glyph charstring: hsbw (left side bearing 0, + /// advance ) then an empty-outline endchar - used for the + /// .notdef/space glyphs of 's + /// synthetic font, mirroring Type1TableTests.SpaceCharstring's own shape. + /// + private static byte[] BuildEmptyType1Charstring(int width = 300) + { + var buf = new List(); + SyntheticFontBuilder.WriteType1CharstringNumber(buf, 0); + SyntheticFontBuilder.WriteType1CharstringNumber(buf, width); + SyntheticFontBuilder.WriteType1CharstringOperator(buf, 13); // hsbw + SyntheticFontBuilder.WriteType1CharstringOperator(buf, 14); // endchar + return [.. buf]; + } + + /// + /// Builds a classic PostScript Type 1 "A" glyph charstring: hsbw then a + /// filled square outline spanning font-design-space (100, 100)-(500, 500) of + /// a 1000-unit em (matching 's own TrueType-outline + /// square glyph and 's own CFF-outline square glyph, + /// so every embedded-font flavor paints identical ink for its own square glyph), via + /// rmoveto to (100, 100) then three relative rlineto segments and a + /// closepath. + /// + private static byte[] BuildSquareType1Charstring(int width = 600) + { + var buf = new List(); + SyntheticFontBuilder.WriteType1CharstringNumber(buf, 0); + SyntheticFontBuilder.WriteType1CharstringNumber(buf, width); + SyntheticFontBuilder.WriteType1CharstringOperator(buf, 13); // hsbw + SyntheticFontBuilder.WriteType1CharstringNumber(buf, 100); + SyntheticFontBuilder.WriteType1CharstringNumber(buf, 100); + SyntheticFontBuilder.WriteType1CharstringOperator(buf, 21); // rmoveto + SyntheticFontBuilder.WriteType1CharstringNumber(buf, 400); + SyntheticFontBuilder.WriteType1CharstringNumber(buf, 0); + SyntheticFontBuilder.WriteType1CharstringOperator(buf, 5); // rlineto + SyntheticFontBuilder.WriteType1CharstringNumber(buf, 0); + SyntheticFontBuilder.WriteType1CharstringNumber(buf, 400); + SyntheticFontBuilder.WriteType1CharstringOperator(buf, 5); // rlineto + SyntheticFontBuilder.WriteType1CharstringNumber(buf, -400); + SyntheticFontBuilder.WriteType1CharstringNumber(buf, 0); + SyntheticFontBuilder.WriteType1CharstringOperator(buf, 5); // rlineto + SyntheticFontBuilder.WriteType1CharstringOperator(buf, 9); // closepath + SyntheticFontBuilder.WriteType1CharstringOperator(buf, 14); // endchar + return [.. buf]; + } + + /// + /// Builds a /Resources/Font dictionary (as a - + /// compatible resourcesBody/extraObjectBodies pair) declaring a single simple + /// /Subtype /Type1 font resource named /F1, backed by a synthetic, entirely + /// hand-authored, SyntheticFontBuilder.Type1-built classic PostScript Type 1 font + /// program embedded via /FontDescriptor/FontFile - the Phase B counterpart of + /// . The synthetic font declares three + /// glyphs (.notdef, space, and A - glyph A being + /// 's filled square), matching + /// PdfDocument.Fonts.cs's own StandardGlyphNames Adobe glyph-name + /// vocabulary so codepoint 65 ('A') resolves to the square glyph via that file's own + /// CodepointToStandardGlyphName reverse map. + /// + /// + /// Numbered identically to (font-file + /// object 7, descriptor object 6, font dictionary object 5) - the + /// /FontDescriptor/FontFile stream's own /Length1//Length2 entries + /// (not the descriptor's) are always emitted, matching real PDF producers and + /// PdfDocument.Fonts.Type1.cs's own LoadType1Font documented requirement. + /// + private static (string ResourcesBody, List ExtraObjects) BuildEmbeddedType1FontResources( + string fontDictExtra = "/FirstChar 65 /LastChar 65 /Widths [600]", + string descriptorExtra = "", + string fontResourceName = "F1", + bool omitLength2 = false, + string baseFontName = "Test") + { + var (fontFileBytes, length1, length2) = SyntheticFontBuilder.Type1( + [ + (".notdef", BuildEmptyType1Charstring()), + ("space", BuildEmptyType1Charstring()), + ("A", BuildSquareType1Charstring()), + ]); + + var lengthEntries = omitLength2 ? $"/Length1 {length1}" : $"/Length1 {length1} /Length2 {length2}"; + var fontFileObj = BuildStreamObjectBody(lengthEntries, fontFileBytes); + var descriptorObj = System.Text.Encoding.ASCII.GetBytes( + $"<< /Type /FontDescriptor /FontName /{baseFontName} {descriptorExtra} /FontFile 7 0 R >>"); + var fontDictObj = System.Text.Encoding.ASCII.GetBytes( + $"<< /Type /Font /Subtype /Type1 /BaseFont /{baseFontName} {fontDictExtra} /FontDescriptor 6 0 R >>"); + + return ($"/Font << /{fontResourceName} 5 0 R >>", [fontDictObj, descriptorObj, fontFileObj]); + } + + /// + /// Builds a /Resources/Font dictionary (as a - + /// compatible resourcesBody/extraObjectBodies pair) declaring a single simple + /// /Subtype /Type1 font resource named /F1, backed by a synthetic, bare + /// Type1C/CFF (no SFNT/OpenType wrapper) font program embedded via + /// /FontDescriptor/FontFile3 (the /FontFile3 stream's own /Subtype set + /// to ) - the simple-font counterpart of + /// 's composite-font bare-CFF pattern. The + /// synthetic CFF program declares two glyphs (.notdef and, via a custom charset + /// format 0 table, glyph 1 resolved to Standard String SID 34 i.e. A - + /// 's filled square), matching + /// PdfDocument.Fonts.cs's own CodepointToStandardGlyphName reverse map so + /// codepoint 65 ('A') resolves to the square glyph exactly like + /// 's classic /FontFile counterpart. + /// + /// + /// The declared /FontFile3 stream /Subtype name (empty to omit the key + /// entirely). As of the shape-sniffing dispatch in PdfDocument.Fonts.Type1.cs's + /// LoadType1CFont, this value is never consulted for dispatch - only + /// 's own byte container shape is - so a caller can + /// deliberately declare a mismatched or missing name here to prove that leniency. + /// + /// Extra entries appended verbatim to the font dictionary. + /// Extra entries appended verbatim to the /FontDescriptor. + /// The font resource's key within /Resources/Font. + /// + /// The /FontFile3 stream's raw bytes, overriding the default synthetic bare + /// Type1C/CFF program described above - used by a caller proving the "genuinely + /// unrecognized bytes" rejection path with bytes that are neither a valid SFNT container + /// nor a valid bare CFF header. When (the default), the usual + /// synthetic bare CFF program is built and used instead. + /// + /// + /// Numbered identically to / + /// (font-file object 7, descriptor + /// object 6, font dictionary object 5). + /// + private static (string ResourcesBody, List ExtraObjects) BuildEmbeddedType1CFontResources( + string fontFileSubtype = "/Type1C", + string fontDictExtra = "/FirstChar 65 /LastChar 65 /Widths [600]", + string descriptorExtra = "", + string fontResourceName = "F1", + byte[]? fontFileBytes = null) + { + byte[] cff; + if (fontFileBytes is not null) + { + cff = fontFileBytes; + } + else + { + var notdefCharstring = new List(); + SyntheticFontBuilder.WriteCharstringOperator(notdefCharstring, 14); // endchar + + byte[] charsetTable = [0, 0, 34]; // format 0: glyph 1 -> SID 34 (A) + cff = SyntheticFontBuilder.Cff( + [[.. notdefCharstring], BuildSquareCffCharstring()], + charsetTable: charsetTable); + } + + var fontFileDictEntries = string.IsNullOrEmpty(fontFileSubtype) ? string.Empty : $"/Subtype {fontFileSubtype}"; + var fontFileObj = BuildStreamObjectBody(fontFileDictEntries, cff); + var descriptorObj = System.Text.Encoding.ASCII.GetBytes( + $"<< /Type /FontDescriptor /FontName /Test {descriptorExtra} /FontFile3 7 0 R >>"); + var fontDictObj = System.Text.Encoding.ASCII.GetBytes( + $"<< /Type /Font /Subtype /Type1 /BaseFont /Test {fontDictExtra} /FontDescriptor 6 0 R >>"); + + return ($"/Font << /{fontResourceName} 5 0 R >>", [fontDictObj, descriptorObj, fontFileObj]); + } + + /// + /// Builds a /Resources/Font dictionary (as a - + /// compatible resourcesBody/extraObjectBodies pair) declaring a single + /// synthetic /Subtype /Type3 font resource named /F1, with its + /// /CharProcs glyph-procedure content streams supplied verbatim in + /// (each one becomes its own extra object, referenced from + /// an inline /CharProcs dictionary by glyph name), an inline /Encoding + /// /Differences array built from (omitted + /// entirely when empty, so callers can exercise the "no /Encoding at all" case), + /// and // + /// appended verbatim. + /// + /// + /// Numbered so the Type3 font dictionary is object 5 (referenced as 5 0 R by + /// the returned /Font resources entry) and each glyph procedure content stream in + /// is object 6, 7, ... in order - matching + /// every other font-resource helper's own "extra objects start at 5" convention. + /// + private static (string ResourcesBody, List ExtraObjects) BuildType3FontResources( + IReadOnlyList<(string GlyphName, byte[] ContentBytes)> glyphProcs, + string differencesBody, + string fontMatrix = "[0.001 0 0 0.001 0 0]", + string fontDictExtra = "/FirstChar 65 /LastChar 65 /Widths [750]", + string? type3ResourcesBody = null, + string fontResourceName = "F1") + { + var extraObjects = new List(); + var charProcEntries = new List(); + var objNum = 6; + foreach (var (glyphName, contentBytes) in glyphProcs) + { + extraObjects.Add(BuildStreamObjectBody(string.Empty, contentBytes)); + charProcEntries.Add($"/{glyphName} {objNum} 0 R"); + objNum++; + } + + var encodingClause = string.IsNullOrEmpty(differencesBody) + ? string.Empty + : $" /Encoding << /Differences [{differencesBody}] >>"; + var resourcesClause = type3ResourcesBody is null ? string.Empty : $" /Resources << {type3ResourcesBody} >>"; + + var fontDictObj = System.Text.Encoding.ASCII.GetBytes( + $"<< /Type /Font /Subtype /Type3 /FontMatrix {fontMatrix} " + + $"/CharProcs << {string.Join(' ', charProcEntries)} >>{encodingClause}{resourcesClause} {fontDictExtra} >>"); + + extraObjects.Insert(0, fontDictObj); + + return ($"/Font << /{fontResourceName} 5 0 R >>", extraObjects); + } + + /// + /// Builds a /Resources/Font dictionary (as a - + /// compatible resourcesBody/extraObjectBodies pair) declaring a single + /// Type0/CIDFontType2 composite font resource named /F1, with the given embedded + /// /FontFile2 bytes and /Encoding, descendant /Subtype, + /// /CIDToGIDMap stream, and other descendant-dictionary entries appended verbatim. + /// + /// + /// Numbered so the Type0 font dictionary is object 5 (referenced as 5 0 R by + /// the returned /Font resources entry), the CIDFontType2 descendant dictionary is + /// object 6 (referenced as 6 0 R by the font dictionary's + /// /DescendantFonts), the /FontDescriptor is object 7, the + /// /FontFile2 stream is object 8, and (only when + /// is supplied) the /CIDToGIDMap stream is + /// object 9 - matching every other Phase 3+ image-XObject/font test's own + /// "extra objects start at 5" convention. + /// + private static (string ResourcesBody, List ExtraObjects) BuildCompositeFontResources( + byte[] fontFileBytes, + string encoding = "/Identity-H", + string descendantSubtype = "/CIDFontType2", + string cidFontExtra = "", + byte[]? cidToGidMapStreamBytes = null, + string fontResourceName = "F1") + { + var cidToGidEntry = cidToGidMapStreamBytes is not null ? " /CIDToGIDMap 9 0 R" : string.Empty; + + var fontDictObj = System.Text.Encoding.ASCII.GetBytes( + $"<< /Type /Font /Subtype /Type0 /BaseFont /Test /Encoding {encoding} /DescendantFonts [6 0 R] >>"); + var descendantObj = System.Text.Encoding.ASCII.GetBytes( + $"<< /Type /Font /Subtype {descendantSubtype} /BaseFont /Test " + + "/CIDSystemInfo << /Registry (Adobe) /Ordering (Identity) /Supplement 0 >> " + + $"/FontDescriptor 7 0 R{cidToGidEntry} {cidFontExtra} >>"); + var descriptorObj = "<< /Type /FontDescriptor /FontFile2 8 0 R >>"u8.ToArray(); + var fontFileObj = BuildStreamObjectBody(string.Empty, fontFileBytes); + + var extraObjects = new List { fontDictObj, descendantObj, descriptorObj, fontFileObj }; + if (cidToGidMapStreamBytes is not null) + { + extraObjects.Add(BuildStreamObjectBody(string.Empty, cidToGidMapStreamBytes)); + } + + return ($"/Font << /{fontResourceName} 5 0 R >>", extraObjects); + } + + /// + /// Builds a /Resources/Font dictionary (as a - + /// compatible resourcesBody/extraObjectBodies pair) declaring a single + /// Type0/CIDFontType0 composite font resource named /F1, with the given embedded + /// /FontFile3 bytes (the /FontFile3 stream's own /Subtype set to + /// ) and descendant-dictionary entries appended + /// verbatim - the CIDFontType0 counterpart of + /// . + /// + /// + /// Numbered so the Type0 font dictionary is object 5 (referenced as 5 0 R by + /// the returned /Font resources entry), the CIDFontType0 descendant dictionary is + /// object 6 (referenced as 6 0 R by the font dictionary's + /// /DescendantFonts), the /FontDescriptor is object 7, and the + /// /FontFile3 stream is object 8 - matching + /// 's own "extra objects start at 5" convention. + /// + private static (string ResourcesBody, List ExtraObjects) BuildCidFontType0FontResources( + byte[] fontFileBytes, + string fontFileSubtype = "/OpenType", + string cidFontExtra = "", + string fontResourceName = "F1") + { + var fontDictObj = System.Text.Encoding.ASCII.GetBytes( + "<< /Type /Font /Subtype /Type0 /BaseFont /Test /Encoding /Identity-H /DescendantFonts [6 0 R] >>"); + var descendantObj = System.Text.Encoding.ASCII.GetBytes( + "<< /Type /Font /Subtype /CIDFontType0 /BaseFont /Test " + + "/CIDSystemInfo << /Registry (Adobe) /Ordering (Identity) /Supplement 0 >> " + + $"/FontDescriptor 7 0 R {cidFontExtra} >>"); + var descriptorObj = "<< /Type /FontDescriptor /FontFile3 8 0 R >>"u8.ToArray(); + var fontFileDictEntries = string.IsNullOrEmpty(fontFileSubtype) ? string.Empty : $"/Subtype {fontFileSubtype}"; + var fontFileObj = BuildStreamObjectBody(fontFileDictEntries, fontFileBytes); + + return ($"/Font << /{fontResourceName} 5 0 R >>", [fontDictObj, descendantObj, descriptorObj, fontFileObj]); + } + + /// Encodes each CID in as a 2-byte big-endian code, returning the resulting hex-string content-stream operand text (including the enclosing angle brackets). + private static string BuildIdentityHHexString(params int[] cids) => + "<" + string.Concat(cids.Select(cid => $"{cid:X4}")) + ">"; + + /// + /// Wraps (the beginbfchar/beginbfrange/ + /// begincodespacerange/other bare-operator content under test) in the standard + /// Adobe CMap/PostScript resource-management boilerplate every real-world + /// /ToUnicode stream requires (PDF 32000-1:2008 §9.10.3's own worked example), + /// so ResolveToUnicodeMap tests exercise that the boilerplate is tolerated, not + /// merely a bare bfchar/bfrange block. + /// + private static byte[] BuildToUnicodeCMapStreamBytes(string cmapBody) => + System.Text.Encoding.ASCII.GetBytes( + "/CIDInit /ProcSet findresource begin\n" + + "12 dict begin\n" + + "begincmap\n" + + "/CIDSystemInfo << /Registry (Adobe) /Ordering (UCS) /Supplement 0 >> def\n" + + "/CMapName /Adobe-Identity-UCS def\n" + + "/CMapType 2 def\n" + + "1 begincodespacerange\n" + + "<0000> \n" + + "endcodespacerange\n" + + $"{cmapBody}\n" + + "endcmap\n" + + "CMapName currentdict /CMap defineresource pop\n" + + "end\n" + + "end"); + + #region Tokenizer + + /// Proves that the tokenizer parses integer, negative, leading-dot, and zero number forms. + [Fact] + public void PdfDocument_Tokenizer_Numbers_ParsesIntegerAndRealForms() + { + // Arrange + var tokenizer = new PdfDocument.PdfTokenizer("12 -3.5 +.5 0"u8.ToArray()); + + // Act + var first = tokenizer.NextToken(); + var second = tokenizer.NextToken(); + var third = tokenizer.NextToken(); + var fourth = tokenizer.NextToken(); + + // Assert + Assert.Equal(PdfDocument.PdfTokenKind.Number, first.Kind); + Assert.Equal(12, first.Number); + Assert.Equal(-3.5, second.Number); + Assert.Equal(0.5, third.Number); + Assert.Equal(0, fourth.Number); + } + + /// Proves that literal strings decode nested parentheses and backslash/octal escapes. + [Fact] + public void PdfDocument_Tokenizer_LiteralString_HandlesNestedParensAndEscapes() + { + // Arrange + var tokenizer = new PdfDocument.PdfTokenizer("(He said (\\\"hi\\\") \\n \\061)"u8.ToArray()); + + // Act + var token = tokenizer.NextToken(); + + // Assert + Assert.Equal(PdfDocument.PdfTokenKind.LiteralString, token.Kind); + var text = System.Text.Encoding.Latin1.GetString(token.Bytes!); + Assert.Equal("He said (\"hi\") \n 1", text); + } + + /// + /// Proves that a literal string missing its closing ) before EOF (the parenthesis- + /// nesting depth counter is still nonzero when the tokenizer runs out of input) + /// throws instead of silently returning a token built + /// from the truncated scan. + /// + [Fact] + public void PdfDocument_Tokenizer_LiteralString_UnterminatedAtEof_ThrowsInvalidDataException() + { + // Arrange: an opening '(' with no matching closing ')' before the input ends. + var tokenizer = new PdfDocument.PdfTokenizer("(unterminated"u8.ToArray()); + + // Act & Assert + Assert.Throws(() => tokenizer.NextToken()); + } + + /// Proves that a hex string with a full, even digit count decodes to the exact byte sequence. + [Fact] + public void PdfDocument_Tokenizer_HexString_DecodesFullBytes() + { + // Arrange + var tokenizer = new PdfDocument.PdfTokenizer("<48656C6C6F>"u8.ToArray()); + + // Act + var token = tokenizer.NextToken(); + + // Assert + Assert.Equal("Hello", System.Text.Encoding.ASCII.GetString(token.Bytes!)); + } + + /// Proves that an odd hex-digit count is padded with a trailing zero nibble per spec. + [Fact] + public void PdfDocument_Tokenizer_HexString_PadsOddDigitCountWithTrailingZero() + { + // Arrange: an odd number of hex digits is padded with a trailing '0' per the PDF + // specification, so "<4>" decodes as if it were "<40>" (0x40). + var tokenizer = new PdfDocument.PdfTokenizer("<4>"u8.ToArray()); + + // Act + var token = tokenizer.NextToken(); + + // Assert + Assert.Equal([0x40], token.Bytes); + } + + /// Proves that #xx hash-escapes within a name token are decoded to the literal character. + [Fact] + public void PdfDocument_Tokenizer_Name_DecodesHashEscapes() + { + // Arrange + var tokenizer = new PdfDocument.PdfTokenizer("/A#20Name"u8.ToArray()); + + // Act + var token = tokenizer.NextToken(); + + // Assert + Assert.Equal(PdfDocument.PdfTokenKind.Name, token.Kind); + Assert.Equal("A Name", token.Text); + } + + /// Proves that array ([ ]) and dictionary (<< >>) delimiters are recognized as distinct token kinds. + [Fact] + public void PdfDocument_Tokenizer_Delimiters_RecognizesArrayAndDictionaryBrackets() + { + // Arrange + var tokenizer = new PdfDocument.PdfTokenizer("[ ] << >>"u8.ToArray()); + + // Act & Assert + Assert.Equal(PdfDocument.PdfTokenKind.ArrayStart, tokenizer.NextToken().Kind); + Assert.Equal(PdfDocument.PdfTokenKind.ArrayEnd, tokenizer.NextToken().Kind); + Assert.Equal(PdfDocument.PdfTokenKind.DictStart, tokenizer.NextToken().Kind); + Assert.Equal(PdfDocument.PdfTokenKind.DictEnd, tokenizer.NextToken().Kind); + } + + /// Proves that a %-comment is skipped entirely and does not interrupt subsequent tokenization. + [Fact] + public void PdfDocument_Tokenizer_Comments_AreSkipped() + { + // Arrange + var tokenizer = new PdfDocument.PdfTokenizer("% a comment\n42"u8.ToArray()); + + // Act + var token = tokenizer.NextToken(); + + // Assert + Assert.Equal(PdfDocument.PdfTokenKind.Number, token.Kind); + Assert.Equal(42, token.Number); + } + + /// Proves that each reserved PDF keyword is recognized verbatim as a Keyword token. + /// The keyword text under test. + [Theory] + [InlineData("true")] + [InlineData("false")] + [InlineData("null")] + [InlineData("obj")] + [InlineData("endobj")] + [InlineData("stream")] + [InlineData("endstream")] + [InlineData("xref")] + [InlineData("trailer")] + [InlineData("startxref")] + [InlineData("R")] + public void PdfDocument_Tokenizer_Keywords_AreRecognizedVerbatim(string keyword) + { + // Arrange + var tokenizer = new PdfDocument.PdfTokenizer(System.Text.Encoding.ASCII.GetBytes(keyword)); + + // Act + var token = tokenizer.NextToken(); + + // Assert + Assert.Equal(PdfDocument.PdfTokenKind.Keyword, token.Kind); + Assert.Equal(keyword, token.Text); + } + + /// Proves that tokenizing whitespace-only (or empty) input yields an EndOfFile token. + [Fact] + public void PdfDocument_Tokenizer_EndOfInput_ReturnsEndOfFileToken() + { + // Arrange + var tokenizer = new PdfDocument.PdfTokenizer(" "u8.ToArray()); + + // Act + var token = tokenizer.NextToken(); + + // Assert + Assert.Equal(PdfDocument.PdfTokenKind.EndOfFile, token.Kind); + } + + #endregion + + #region Object model + + /// Proves that a dictionary parses nested name/number/dictionary entries by key. + [Fact] + public void PdfDocument_ObjectModel_Dictionary_ParsesNestedEntries() + { + // Arrange + var tokenizer = new PdfDocument.PdfTokenizer("<< /Type /Catalog /Count 3 /Nested << /A 1 >> >>"u8.ToArray()); + + // Act + var value = PdfDocument.ParseValue(tokenizer); + + // Assert + Assert.Equal(PdfDocument.PdfKind.Dictionary, value.Kind); + Assert.Equal(PdfDocument.PdfKind.Name, value.Get("Type")!.Kind); + Assert.Equal("Catalog", value.Get("Type")!.Text); + Assert.Equal(3, value.Get("Count")!.Number); + Assert.Equal(1, value.Get("Nested")!.Get("A")!.Number); + } + + /// Proves that an array parses mixed element kinds (number, name, string, boolean, null, nested array) in order. + [Fact] + public void PdfDocument_ObjectModel_Array_ParsesMixedElementTypes() + { + // Arrange + var tokenizer = new PdfDocument.PdfTokenizer("[1 2.5 /Name (str) true null [9]]"u8.ToArray()); + + // Act + var value = PdfDocument.ParseValue(tokenizer); + + // Assert + Assert.Equal(PdfDocument.PdfKind.Array, value.Kind); + Assert.Equal(7, value.Items.Count); + Assert.Equal(PdfDocument.PdfKind.Number, value.Items[0].Kind); + Assert.Equal(PdfDocument.PdfKind.Name, value.Items[2].Kind); + Assert.Equal(PdfDocument.PdfKind.Boolean, value.Items[4].Kind); + Assert.Equal(PdfDocument.PdfKind.Array, value.Items[6].Kind); + } + + /// Proves that an N G R sequence parses as an indirect reference, capturing both the object and generation numbers. + [Fact] + public void PdfDocument_ObjectModel_IndirectReference_ParsesObjectAndGenerationNumbers() + { + // Arrange + var tokenizer = new PdfDocument.PdfTokenizer("12 0 R"u8.ToArray()); + + // Act + var value = PdfDocument.ParseValue(tokenizer); + + // Assert + Assert.Equal(PdfDocument.PdfKind.Reference, value.Kind); + Assert.Equal(12, value.RefNumber); + Assert.Equal(0, value.RefGeneration); + } + + /// Proves that a bare number followed by N obj (an object definition header) is parsed as a plain number, not misread as a reference. + [Fact] + public void PdfDocument_ObjectModel_BareNumber_IsNotMisreadAsReference() + { + // Arrange + var tokenizer = new PdfDocument.PdfTokenizer("12 0 obj"u8.ToArray()); + + // Act + var value = PdfDocument.ParseValue(tokenizer); + + // Assert + Assert.Equal(PdfDocument.PdfKind.Number, value.Kind); + Assert.Equal(12, value.Number); + } + + /// + /// Proves that an array nested within itself well beyond the MaxObjectNestingDepth + /// guard throws instead of exhausting the native call + /// stack with an uncatchable (a deeply/adversarially + /// nested array or dictionary is otherwise unbounded recursive-descent input). + /// + [Fact] + public void PdfDocument_ObjectModel_Array_NestingDepthExceeded_ThrowsInvalidDataException() + { + // Arrange: 200 levels of nested arrays, far beyond any realistic legitimate document + // structure and well beyond the guard's threshold. + var source = string.Concat(Enumerable.Repeat("[", 200)) + "1" + string.Concat(Enumerable.Repeat("]", 200)); + var tokenizer = new PdfDocument.PdfTokenizer(System.Text.Encoding.ASCII.GetBytes(source)); + + // Act & Assert + Assert.Throws(() => PdfDocument.ParseValue(tokenizer)); + } + + /// + /// Proves that a dictionary nested within itself well beyond the + /// MaxObjectNestingDepth guard throws , mirroring + /// + /// for the dictionary recursion path. + /// + [Fact] + public void PdfDocument_ObjectModel_Dictionary_NestingDepthExceeded_ThrowsInvalidDataException() + { + // Arrange: 200 levels of nested dictionaries. + var source = string.Concat(Enumerable.Repeat("<< /A ", 200)) + "1" + string.Concat(Enumerable.Repeat(" >>", 200)); + var tokenizer = new PdfDocument.PdfTokenizer(System.Text.Encoding.ASCII.GetBytes(source)); + + // Act & Assert + Assert.Throws(() => PdfDocument.ParseValue(tokenizer)); + } + + #endregion + + #region Cross-reference forms + + /// + /// Builds an in-memory, single-page, /Type /XRef cross-reference-stream-only PDF + /// (no classic table) whose stream dictionary declares the given raw /W array + /// literal verbatim - used to craft a malformed/adversarial /W entry (for example a + /// negative field width) that the classic-xref-table builders above cannot express. + /// Deliberately hides the /Type /Catalog object inside a compressed + /// /Type /ObjStm container (object 6, compressed inside object 1) rather than + /// leaving it as a plain top-level object: 's own + /// BuildLinearScanFallback recovery path (triggered whenever normal + /// cross-reference parsing throws ) only finds plain + /// N G obj headers by a raw byte scan, so with the catalog plain and + /// top-level, recovery would silently rebuild a working document regardless of whether + /// the crafted /W entry is rejected - masking the very defect this helper exists to + /// exercise. Hiding the catalog inside an object stream (which recovery's linear scan + /// cannot see into) ensures that when the crafted /W entry is rejected and + /// cross-reference parsing falls back to recovery, recovery also fails to locate a + /// catalog, so the caller still observes a thrown, typed exception - proving the crafted + /// input is converted into a controlled failure, never a raw CLR crash, at every layer. + /// + private static byte[] BuildXrefStreamPdfWithWidths(string wLiteral) + { + var catalogBytes = "<< /Type /Catalog /Pages 2 0 R >>"u8.ToArray(); + var objStmHeader = "6 0\n"u8.ToArray(); + var objStmContent = new List(); + objStmContent.AddRange(objStmHeader); + objStmContent.AddRange(catalogBytes); + var compressedObjStm = ZlibCompress([.. objStmContent]); + + var bodies = new List + { + BuildStreamObjectBody($"/Type /ObjStm /N 1 /First {objStmHeader.Length} /Filter /FlateDecode", compressedObjStm), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 100 100] >>"u8.ToArray(), + "<< /Type /Page /Parent 2 0 R /Contents 4 0 R >>"u8.ToArray(), + BuildStreamObjectBody(string.Empty, []), + }; + + var buffer = new List(); + buffer.AddRange("%PDF-1.7\n"u8.ToArray()); + var offsets = new List(); + for (var i = 0; i < bodies.Count; i++) + { + offsets.Add(buffer.Count); + buffer.AddRange(System.Text.Encoding.ASCII.GetBytes($"{i + 1} 0 obj\n")); + buffer.AddRange(bodies[i]); + buffer.AddRange("\nendobj\n"u8.ToArray()); + } + + // Object 5: the self-referential, /Filter-less cross-reference stream, declaring the + // caller's crafted /W literal. Its raw entry bytes describe: object 0 (free), objects + // 1-5 (plain, direct), and object 6 (compressed, inside object 1 at index 0) - 7 entries + // of 10 zero-filled bytes each, far more than any legal (<=8-byte-wide field) entry could + // ever need, so a truncation check never short-circuits before reaching the actual + // malformed-width read this helper means to exercise. + var entryBytes = new byte[70]; + var xrefStreamOffset = buffer.Count; + buffer.AddRange("5 0 obj\n"u8.ToArray()); + buffer.AddRange(System.Text.Encoding.ASCII.GetBytes( + $"<< /Type /XRef /Size 7 /W {wLiteral} /Root 6 0 R /Length {entryBytes.Length} >>\nstream\n")); + buffer.AddRange(entryBytes); + buffer.AddRange("\nendstream\nendobj\n"u8.ToArray()); + + buffer.AddRange(System.Text.Encoding.ASCII.GetBytes($"startxref\n{xrefStreamOffset}\n%%EOF\n")); + return [.. buffer]; + } + + /// Proves that a classic xref/trailer document resolves the catalog and its single page. + [Fact] + public void PdfDocument_Open_ClassicXref_ResolvesRootAndPage() + { + // Arrange & Act + using var document = PdfDocument.Open(Fixture("classic-xref-single-page.pdf")); + + // Assert + Assert.Equal(1, document.PageCount); + var info = document.GetPageInfo(0); + Assert.Equal(200, info.Width); + Assert.Equal(300, info.Height); + Assert.Equal(0, info.Rotation); + } + + /// Proves that a /Type /XRef cross-reference stream (no classic table) resolves the catalog and its single page. + [Fact] + public void PdfDocument_Open_XrefStream_ResolvesRootAndPage() + { + // Arrange & Act + using var document = PdfDocument.Open(Fixture("xref-stream-single-page.pdf")); + + // Assert + Assert.Equal(1, document.PageCount); + var info = document.GetPageInfo(0); + Assert.Equal(200, info.Width); + Assert.Equal(300, info.Height); + } + + /// Proves that a cross-reference stream's /W entry with a negative field width (which would otherwise drive a negative byte offset into ) throws instead. + [Fact] + public void PdfDocument_Open_XrefStream_NegativeWidth_ThrowsInvalidDataException() + { + // Arrange + var pdfBytes = BuildXrefStreamPdfWithWidths("[-1 1 1]"); + + // Act & Assert + Assert.Throws(() => PdfDocument.Open(new MemoryStream(pdfBytes))); + } + + /// Proves that a cross-reference stream's /W entry whose three widths are all zero (which would otherwise produce a zero-length per-entry stride and never advance the decode position, allowing a crafted large /Size or /Index to spin indefinitely) throws instead of looping. + [Fact] + public void PdfDocument_Open_XrefStream_AllZeroWidths_ThrowsInvalidDataException() + { + // Arrange + var pdfBytes = BuildXrefStreamPdfWithWidths("[0 0 0]"); + + // Act & Assert + Assert.Throws(() => PdfDocument.Open(new MemoryStream(pdfBytes))); + } + + /// + /// Rewrites the trailing startxref\n<offset>\n%%EOF marker of an otherwise + /// well-formed PDF (built via , with its catalog + /// hidden inside an /Type /ObjStm object stream so BuildLinearScanFallback's + /// raw-byte-header scan cannot recover it either) with an attacker-controlled literal + /// offset, to exercise the bounds validation applied to a startxref value before it + /// is ever used to position a . + /// + private static byte[] CorruptStartXrefOffset(string offsetLiteral) + { + var pdfBytes = BuildXrefStreamPdfWithWidths("[1 2 1]"); + var text = System.Text.Encoding.ASCII.GetString(pdfBytes); + var markerIndex = text.LastIndexOf("startxref\n", StringComparison.Ordinal); + var valueStart = markerIndex + "startxref\n".Length; + var valueEnd = text.IndexOf('\n', valueStart); + var corrupted = text[..valueStart] + offsetLiteral + text[valueEnd..]; + return System.Text.Encoding.ASCII.GetBytes(corrupted); + } + + /// Proves that a negative startxref offset (which would otherwise index the tokenizer's buffer negatively, leaking an ) throws instead. + [Fact] + public void PdfDocument_Open_NegativeStartXrefOffset_ThrowsInvalidDataException() + { + // Arrange + var pdfBytes = CorruptStartXrefOffset("-1"); + + // Act & Assert + Assert.Throws(() => PdfDocument.Open(new MemoryStream(pdfBytes))); + } + + /// Proves that a startxref offset far beyond the end of the buffer throws instead of being used as-is. + [Fact] + public void PdfDocument_Open_OutOfRangeStartXrefOffset_ThrowsInvalidDataException() + { + // Arrange + var pdfBytes = CorruptStartXrefOffset("999999999"); + + // Act & Assert + Assert.Throws(() => PdfDocument.Open(new MemoryStream(pdfBytes))); + } + + /// + /// Builds an in-memory, /Type /XRef cross-reference-stream-only PDF (no classic + /// table, no explicit trailer keyword) whose object 1 - the trailer's intended + /// /Root catalog - is marked, via its own raw cross-reference entry, as a + /// compressed object (type 2) living inside object stream "1": itself. No object 1 bytes + /// ever actually appear in the file (there is nothing to decompress into) - the crafted + /// entry alone is enough to make resolving object 1 recurse back into resolving object 1 + /// as its own container, exercising the compressed-object-resolution cycle guard rather + /// than overflowing the stack. Mirrors 's own + /// filter-less raw-entry-bytes technique. + /// + private static byte[] BuildCompressedObjectSelfCyclePdf() + { + var buffer = new List(); + buffer.AddRange("%PDF-1.7\n"u8.ToArray()); + + // Object 2: the self-referential, /Filter-less cross-reference stream, declaring 3 raw + // 6-byte entries (/W [1 4 1]): object 0 (free), object 1 (compressed, claiming to live + // inside object stream "1" - itself - at index 0), and object 2 (this very xref stream, + // direct, at its own real offset). + var xrefStreamOffset = buffer.Count; + + static byte[] Entry(byte type, int field2, byte field3) => + [ + type, + (byte)(field2 >> 24), (byte)(field2 >> 16), (byte)(field2 >> 8), (byte)field2, + field3, + ]; + + var entryBytes = new List(); + entryBytes.AddRange(Entry(0, 0, 0)); + entryBytes.AddRange(Entry(2, 1, 0)); + entryBytes.AddRange(Entry(1, xrefStreamOffset, 0)); + + buffer.AddRange("2 0 obj\n"u8.ToArray()); + buffer.AddRange(System.Text.Encoding.ASCII.GetBytes( + $"<< /Type /XRef /Size 3 /W [1 4 1] /Root 1 0 R /Length {entryBytes.Count} >>\nstream\n")); + buffer.AddRange(entryBytes); + buffer.AddRange("\nendstream\nendobj\n"u8.ToArray()); + + buffer.AddRange(System.Text.Encoding.ASCII.GetBytes($"startxref\n{xrefStreamOffset}\n%%EOF\n")); + return [.. buffer]; + } + + /// + /// Proves that a crafted cross-reference table marking an object stream's own container + /// object as itself compressed (a reference cycle entirely through the + /// compressed-object-resolution path) throws instead + /// of recursing indefinitely into an uncatchable . + /// + [Fact] + public void PdfDocument_Open_CompressedObjectStreamSelfCycle_ThrowsInvalidDataException() + { + // Arrange + var pdfBytes = BuildCompressedObjectSelfCyclePdf(); + + // Act & Assert + Assert.Throws(() => PdfDocument.Open(new MemoryStream(pdfBytes))); + } + + /// + /// Builds an in-memory, /Type /XRef cross-reference-stream-only PDF whose object 1 + /// - an object-stream (/Type /ObjStm) container - declares its own /Length + /// as an indirect reference to object 2, a compressed object that lives inside object 1 + /// itself. Decoding object 1's own stream bytes (to in turn decompress and resolve object + /// 2) therefore requires first resolving object 1's /Length, which requires + /// resolving object 2, which requires decoding object 1's stream bytes - a resolution + /// cycle reachable entirely through direct-then-compressed + /// dependency (not a container marking itself + /// compressed, as in ), exercising the + /// general-purpose GetObject resolution-stack guard rather than overflowing the + /// stack. + /// + private static byte[] BuildContainerLengthReferencesOwnCompressedObjectCyclePdf() + { + // The object-stream header declares one entry: object 2 at relative offset 0. + var objStmHeader = "2 0\n"u8.ToArray(); + var objStmContent = new List(); + objStmContent.AddRange(objStmHeader); + objStmContent.AddRange("<< /Type /Catalog >>"u8.ToArray()); + var compressedObjStm = ZlibCompress([.. objStmContent]); + + var buffer = new List(); + buffer.AddRange("%PDF-1.7\n"u8.ToArray()); + + var object1Offset = buffer.Count; + buffer.AddRange("1 0 obj\n"u8.ToArray()); + buffer.AddRange(System.Text.Encoding.ASCII.GetBytes( + $"<< /Type /ObjStm /N 1 /First {objStmHeader.Length} /Filter /FlateDecode /Length 2 0 R >>\nstream\n")); + buffer.AddRange(compressedObjStm); + buffer.AddRange("\nendstream\nendobj\n"u8.ToArray()); + + // Object 3: the self-referential, /Filter-less cross-reference stream, declaring 4 raw + // 6-byte entries (/W [1 4 1]): object 0 (free), object 1 (direct, the ObjStm container), + // object 2 (compressed, inside object stream "1" at index 0 - the crafted /Length + // target), and object 3 (this very xref stream, direct, at its own real offset). + var xrefStreamOffset = buffer.Count; + + static byte[] Entry(byte type, int field2, byte field3) => + [ + type, + (byte)(field2 >> 24), (byte)(field2 >> 16), (byte)(field2 >> 8), (byte)field2, + field3, + ]; + + var entryBytes = new List(); + entryBytes.AddRange(Entry(0, 0, 0)); + entryBytes.AddRange(Entry(1, object1Offset, 0)); + entryBytes.AddRange(Entry(2, 1, 0)); + entryBytes.AddRange(Entry(1, xrefStreamOffset, 0)); + + buffer.AddRange("3 0 obj\n"u8.ToArray()); + buffer.AddRange(System.Text.Encoding.ASCII.GetBytes( + $"<< /Type /XRef /Size 4 /W [1 4 1] /Root 2 0 R /Length {entryBytes.Count} >>\nstream\n")); + buffer.AddRange(entryBytes); + buffer.AddRange("\nendstream\nendobj\n"u8.ToArray()); + + buffer.AddRange(System.Text.Encoding.ASCII.GetBytes($"startxref\n{xrefStreamOffset}\n%%EOF\n")); + return [.. buffer]; + } + + /// + /// Proves that a crafted object-stream container whose own /Length indirectly + /// references a compressed object living inside itself (a resolution cycle reachable via + /// the general-purpose, not compressed-object-specific, GetObject guard) throws + /// instead of recursing indefinitely into an + /// uncatchable . + /// + [Fact] + public void PdfDocument_Open_ContainerLengthReferencesOwnCompressedObjectCycle_ThrowsInvalidDataException() + { + // Arrange + var pdfBytes = BuildContainerLengthReferencesOwnCompressedObjectCyclePdf(); + + // Act & Assert + Assert.Throws(() => PdfDocument.Open(new MemoryStream(pdfBytes))); + } + + /// + /// Builds an in-memory, /Type /XRef cross-reference-stream-only PDF whose object 1 + /// - an object-stream (/Type /ObjStm) container - declares a wildly inflated + /// /N (one million) while its actual decoded content holds only a single, tiny + /// header entry. Without an upfront bound check relating /N to the decoded byte + /// length, the object-stream header-reading loop would keep calling NextToken for + /// up to /N iterations even after the header's real content is exhausted, burning + /// CPU time proportional to the attacker's chosen /N rather than the stream's + /// actual size. + /// + private static byte[] BuildObjectStreamDeclaredCountExceedsDecodedLengthPdf() + { + var objStmHeader = "2 0\n"u8.ToArray(); + var objStmContent = new List(); + objStmContent.AddRange(objStmHeader); + objStmContent.AddRange("<< /Type /Catalog >>"u8.ToArray()); + var compressedObjStm = ZlibCompress([.. objStmContent]); + + var buffer = new List(); + buffer.AddRange("%PDF-1.7\n"u8.ToArray()); + + var object1Offset = buffer.Count; + buffer.AddRange("1 0 obj\n"u8.ToArray()); + buffer.AddRange(System.Text.Encoding.ASCII.GetBytes( + $"<< /Type /ObjStm /N 1000000 /First {objStmHeader.Length} /Filter /FlateDecode /Length {compressedObjStm.Length} >>\nstream\n")); + buffer.AddRange(compressedObjStm); + buffer.AddRange("\nendstream\nendobj\n"u8.ToArray()); + + var xrefStreamOffset = buffer.Count; + + static byte[] Entry(byte type, int field2, byte field3) => + [ + type, + (byte)(field2 >> 24), (byte)(field2 >> 16), (byte)(field2 >> 8), (byte)field2, + field3, + ]; + + var entryBytes = new List(); + entryBytes.AddRange(Entry(0, 0, 0)); + entryBytes.AddRange(Entry(1, object1Offset, 0)); + entryBytes.AddRange(Entry(2, 1, 0)); + entryBytes.AddRange(Entry(1, xrefStreamOffset, 0)); + + buffer.AddRange("3 0 obj\n"u8.ToArray()); + buffer.AddRange(System.Text.Encoding.ASCII.GetBytes( + $"<< /Type /XRef /Size 4 /W [1 4 1] /Root 2 0 R /Length {entryBytes.Count} >>\nstream\n")); + buffer.AddRange(entryBytes); + buffer.AddRange("\nendstream\nendobj\n"u8.ToArray()); + + buffer.AddRange(System.Text.Encoding.ASCII.GetBytes($"startxref\n{xrefStreamOffset}\n%%EOF\n")); + return [.. buffer]; + } + + /// Proves that an object stream declaring an /N far larger than its decoded byte length is rejected up front instead of spinning through a huge declared entry count. + [Fact] + public void PdfDocument_Open_ObjectStream_DeclaredCountExceedsDecodedLength_ThrowsInvalidDataException() + { + // Arrange + var pdfBytes = BuildObjectStreamDeclaredCountExceedsDecodedLengthPdf(); + + // Act & Assert + Assert.Throws(() => PdfDocument.Open(new MemoryStream(pdfBytes))); + } + + /// + /// Builds an in-memory, classic xref-table PDF whose single subsection header + /// declares a wildly inflated entry count (one million) immediately followed by only a + /// literal trailer keyword and a tiny trailer dictionary - no real entry bytes at + /// all. Without an upfront bound check relating the declared count to the bytes actually + /// remaining in the buffer, the classic cross-reference parser's entry-reading loop would + /// keep calling NextToken for up to the declared count of iterations, burning CPU + /// time proportional to the attacker's chosen count rather than the file's real content. + /// + private static byte[] BuildClassicXrefSubsectionCountExceedsBufferLengthPdf() + { + var buffer = new List(); + buffer.AddRange("%PDF-1.7\n"u8.ToArray()); + + var xrefOffset = buffer.Count; + + // A declared subsection count of one million, with no real entry bytes following it at + // all - the "trailer" keyword appears immediately, far short of satisfying even one real + // "N G n\n" entry triplet, let alone a million of them. + buffer.AddRange("xref\n0 1000000\n"u8.ToArray()); + buffer.AddRange(System.Text.Encoding.ASCII.GetBytes( + $"trailer\n<< /Size 1 /Root 1 0 R >>\nstartxref\n{xrefOffset}\n%%EOF\n")); + + return [.. buffer]; + } + + /// Proves that a classic cross-reference subsection header declaring an entry count far larger than the bytes remaining in the buffer is rejected up front instead of spinning through a huge declared count. + [Fact] + public void PdfDocument_Open_ClassicXref_SubsectionCountExceedsBufferLength_ThrowsInvalidDataException() + { + // Arrange + var pdfBytes = BuildClassicXrefSubsectionCountExceedsBufferLengthPdf(); + + // Act & Assert + Assert.Throws(() => PdfDocument.Open(new MemoryStream(pdfBytes))); + } + + /// Proves that a page object compressed inside a /Type /ObjStm object stream is decompressed and resolved correctly. + [Fact] + public void PdfDocument_Open_ObjectStream_DecompressesAndResolvesCompressedPage() + { + // Arrange & Act + using var document = PdfDocument.Open(Fixture("object-stream.pdf")); + + // Assert + Assert.Equal(1, document.PageCount); + var info = document.GetPageInfo(0); + Assert.Equal(220, info.Width); + Assert.Equal(320, info.Height); + } + + /// Proves that a hybrid classic trailer with an /XRefStm link resolves an entry that only the supplementary xref stream describes. + [Fact] + public void PdfDocument_Open_HybridXref_ResolvesCompressedEntryViaXRefStm() + { + // Arrange & Act + using var document = PdfDocument.Open(Fixture("hybrid-xref.pdf")); + + // Assert + Assert.Equal(1, document.PageCount); + var info = document.GetPageInfo(0); + Assert.Equal(250, info.Width); + Assert.Equal(350, info.Height); + } + + /// Proves that a document with no startxref/xref/trailer at all still resolves via the linear-scan fallback. + [Fact] + public void PdfDocument_Open_MalformedStartxref_FallsBackToLinearScan() + { + // Arrange & Act + using var document = PdfDocument.Open(Fixture("malformed-startxref.pdf")); + + // Assert + Assert.Equal(1, document.PageCount); + var info = document.GetPageInfo(0); + Assert.Equal(180, info.Width); + Assert.Equal(260, info.Height); + } + + /// + /// Proves that a stream object declaring a crafted, near- + /// /Length - large enough that adding it to the stream's (small) start offset + /// overflows 32-bit arithmetic and wraps to a negative value - + /// still fails closed with the documented ("Stream + /// data range is out of bounds.") rather than an undocumented + /// escaping from the later AsSpan call. + /// Builds the stream object by hand (rather than via , + /// which always computes a correct, consistent /Length) specifically so the + /// declared /Length can diverge from the actual byte count present. + /// + [Fact] + public void PdfDocument_GetStreamRawBytes_LengthOverflowsIntArithmetic_ThrowsInvalidDataException() + { + // Arrange: a /Length just below int.MaxValue - the stream's actual start offset (a few + // hundred bytes into a small file) pushes `StreamDataStart + length` past int.MaxValue, + // which wraps to a negative number under 32-bit int arithmetic and would incorrectly + // satisfy an `int`-typed "> _buffer.Length" bounds check. + const int declaredLength = int.MaxValue - 100; + var streamData = "AB"u8.ToArray(); + var header = System.Text.Encoding.ASCII.GetBytes( + $"<< /Type /XObject /Subtype /Form /BBox [0 0 100 100] /Length {declaredLength} >>\nstream\n"); + var footer = "\nendstream"u8.ToArray(); + var formStream = new byte[header.Length + streamData.Length + footer.Length]; + header.CopyTo(formStream, 0); + streamData.CopyTo(formStream, header.Length); + footer.CopyTo(formStream, header.Length + streamData.Length); + + var bytes = BuildSinglePagePdfWithResources( + 100, + 100, + "/Fm0 Do", + "/XObject << /Fm0 5 0 R >>", + [formStream]); + + // Act & Assert + var exception = Assert.Throws(() => RenderPdfBytes(bytes)); + Assert.Contains("out of bounds", exception.Message, StringComparison.Ordinal); + } + + #endregion + + #region Page tree + + /// Proves that all pages across a multi-level page tree are reported, in document order. + [Fact] + public void PdfDocument_PageTree_Traversal_ReportsAllPagesInDocumentOrder() + { + // Arrange & Act + using var document = PdfDocument.Open(Fixture("multi-page-mixed-mediabox-rotate.pdf")); + + // Assert + Assert.Equal(3, document.PageCount); + } + + /// Proves that a page with its own explicit /MediaBox and no /Rotate reports that MediaBox unchanged. + [Fact] + public void PdfDocument_PageTree_Inheritance_ExplicitMediaBoxAndNoRotationIsUnchanged() + { + // Arrange & Act + using var document = PdfDocument.Open(Fixture("multi-page-mixed-mediabox-rotate.pdf")); + var info = document.GetPageInfo(0); + + // Assert + Assert.Equal(200, info.Width); + Assert.Equal(300, info.Height); + Assert.Equal(0, info.Rotation); + } + + /// Proves that a /Rotate 90 page swaps its displayed width and height relative to its raw /MediaBox. + [Fact] + public void PdfDocument_PageTree_Inheritance_Rotation90SwapsDisplayedWidthAndHeight() + { + // Arrange & Act + using var document = PdfDocument.Open(Fixture("multi-page-mixed-mediabox-rotate.pdf")); + var info = document.GetPageInfo(1); + + // Assert: raw /MediaBox is [0 0 400 100]; /Rotate 90 swaps it for display + Assert.Equal(100, info.Width); + Assert.Equal(400, info.Height); + Assert.Equal(90, info.Rotation); + } + + /// Proves that a page omitting its own /MediaBox inherits its ancestor /Pages node's MediaBox. + [Fact] + public void PdfDocument_PageTree_Inheritance_MediaBoxInheritedFromPagesNodeWhenPageOmitsIt() + { + // Arrange & Act + using var document = PdfDocument.Open(Fixture("multi-page-mixed-mediabox-rotate.pdf")); + var info = document.GetPageInfo(2); + + // Assert: the third page declares no MediaBox of its own, so it inherits its ancestor + // Pages node's box (width 150, height 250); Rotate 180 does not swap width/height. + Assert.Equal(150, info.Width); + Assert.Equal(250, info.Height); + Assert.Equal(180, info.Rotation); + } + + /// Proves that a /Kids reference forming a cycle back to an ancestor throws instead of looping forever. + [Fact] + public void PdfDocument_PageTree_CycleRejection_ThrowsInvalidDataException() + { + // Arrange, Act & Assert + Assert.Throws(() => PdfDocument.Open(Fixture("cyclic-page-tree.pdf"))); + } + + /// + /// Builds an in-memory PDF whose page tree is a chain of nested, + /// distinct (never-repeated) /Type /Pages objects, each with a single /Kids + /// entry pointing to the next - deliberately never revisiting an object number, so the + /// existing cycle-detecting visited set cannot catch it, isolating the dedicated + /// nesting-depth guard under test. Modeled on 's own + /// object/xref/trailer-writing tail. + /// + private static byte[] BuildDeepPageTreePdf(int depth) + { + var bodies = new List + { + "<< /Type /Catalog /Pages 2 0 R >>"u8.ToArray(), + }; + + // Objects 2..(1 + depth): a chain of distinct /Pages nodes, each pointing only forward. + for (var i = 0; i < depth; i++) + { + var nextObjectNumber = 2 + i + 1; + bodies.Add(System.Text.Encoding.ASCII.GetBytes( + $"<< /Type /Pages /Kids [{nextObjectNumber} 0 R] /Count 1 /MediaBox [0 0 100 100] >>")); + } + + // Final leaf page, parented to the last /Pages node. + var leafObjectNumber = 2 + depth; + var contentsObjectNumber = leafObjectNumber + 1; + bodies.Add(System.Text.Encoding.ASCII.GetBytes( + $"<< /Type /Page /Parent {leafObjectNumber - 1} 0 R /Contents {contentsObjectNumber} 0 R >>")); + bodies.Add(BuildStreamObjectBody(string.Empty, [])); + + var buffer = new List(); + buffer.AddRange("%PDF-1.7\n"u8.ToArray()); + var offsets = new List(); + for (var i = 0; i < bodies.Count; i++) + { + offsets.Add(buffer.Count); + buffer.AddRange(System.Text.Encoding.ASCII.GetBytes($"{i + 1} 0 obj\n")); + buffer.AddRange(bodies[i]); + buffer.AddRange("\nendobj\n"u8.ToArray()); + } + + var xrefOffset = buffer.Count; + buffer.AddRange(System.Text.Encoding.ASCII.GetBytes($"xref\n0 {bodies.Count + 1}\n")); + buffer.AddRange("0000000000 65535 f \n"u8.ToArray()); + foreach (var offset in offsets) + { + buffer.AddRange(System.Text.Encoding.ASCII.GetBytes($"{offset:D10} 00000 n \n")); + } + + buffer.AddRange(System.Text.Encoding.ASCII.GetBytes( + $"trailer\n<< /Size {bodies.Count + 1} /Root 1 0 R >>\nstartxref\n{xrefOffset}\n%%EOF\n")); + return [.. buffer]; + } + + /// Proves that a page tree nested deeper than the maximum supported depth (via distinct, never-repeated /Pages objects the cycle check cannot catch) throws instead of exhausting the call stack. + [Fact] + public void PdfDocument_PageTree_ExcessiveNestingDepth_ThrowsInvalidDataException() + { + // Arrange + var pdfBytes = BuildDeepPageTreePdf(40); + + // Act & Assert + Assert.Throws(() => PdfDocument.Open(new MemoryStream(pdfBytes))); + } + + #endregion + + #region Encryption detection + + /// Proves that a non-/Standard security handler (for example /Adobe.PubSec) throws without decoding any content. + [Fact] + public void PdfDocument_Open_EncryptedTrailer_ThrowsUnsupportedImageFeatureException() + { + // Arrange, Act & Assert + var exception = Assert.Throws( + () => PdfDocument.Open(Fixture("encrypted-trailer.pdf"))); + Assert.Equal("pdf-encrypted-filter-Adobe.PubSec", exception.Feature); + } + + #endregion + + #region Open validation + + /// Proves that rejects a null stream. + [Fact] + public void PdfDocument_Open_NullStream_ThrowsArgumentNullException() + { + // Arrange, Act & Assert + Assert.Throws(() => PdfDocument.Open((Stream)null!)); + } + + /// Proves that rejects a null path. + [Fact] + public void PdfDocument_Open_NullPath_ThrowsArgumentNullException() + { + // Arrange, Act & Assert + Assert.Throws(() => PdfDocument.Open((string)null!)); + } + + /// Proves that rejects an empty or whitespace-only path. + /// The invalid path under test. + [Theory] + [InlineData("")] + [InlineData(" ")] + public void PdfDocument_Open_EmptyOrWhitespacePath_ThrowsArgumentException(string path) + { + // Arrange, Act & Assert + Assert.Throws(() => PdfDocument.Open(path)); + } + + #endregion + + #region GetPageInfo validation + + /// Proves that rejects a negative or too-large page index. + /// The out-of-range page index under test. + [Theory] + [InlineData(-1)] + [InlineData(1)] + public void PdfDocument_GetPageInfo_OutOfRangeIndex_ThrowsArgumentOutOfRangeException(int pageIndex) + { + // Arrange + using var document = PdfDocument.Open(Fixture("classic-xref-single-page.pdf")); + + // Act & Assert + Assert.Throws(() => document.GetPageInfo(pageIndex)); + } + + #endregion + + #region Render + + /// Proves that returns a fully transparent surface of the caller-requested size (Phase 1 renders no content). + [Fact] + public void PdfDocument_Render_ValidPageIndex_ReturnsCorrectlySizedBlankSurface() + { + // Arrange + using var document = PdfDocument.Open(Fixture("classic-xref-single-page.pdf")); + + // Act + using var surface = document.Render(0, 64, 48); + + // Assert + Assert.Equal(64, surface.Width); + Assert.Equal(48, surface.Height); + for (var y = 0; y < surface.Height; y++) + { + for (var x = 0; x < surface.Width; x++) + { + Assert.Equal(default, surface[x, y]); + } + } + } + + /// Proves that rejects an out-of-range page index the same way does. + [Fact] + public void PdfDocument_Render_OutOfRangePageIndex_ThrowsArgumentOutOfRangeException() + { + // Arrange + using var document = PdfDocument.Open(Fixture("classic-xref-single-page.pdf")); + + // Act & Assert + Assert.Throws(() => document.Render(5, 10, 10)); + } + + /// Proves that lets 's own constructor validate width/height rather than duplicating that check. + [Fact] + public void PdfDocument_Render_InvalidWidth_PropagatesSurfaceArgumentOutOfRangeException() + { + // Arrange + using var document = PdfDocument.Open(Fixture("classic-xref-single-page.pdf")); + + // Act & Assert + Assert.Throws(() => document.Render(0, 0, 10)); + } + + /// Proves that scales the page's own point-space size by DPI/72, preserving aspect ratio. + [Theory] + [InlineData(72f, 200, 300)] + [InlineData(36f, 100, 150)] + [InlineData(144f, 400, 600)] + public void PdfDocument_RenderWithDpi_ScalesPageSizeByDpiOver72(float dpi, int expectedWidth, int expectedHeight) + { + // Arrange + using var document = PdfDocument.Open(Fixture("classic-xref-single-page.pdf")); + + // Act + using var surface = document.Render(0, dpi); + + // Assert + Assert.Equal(expectedWidth, surface.Width); + Assert.Equal(expectedHeight, surface.Height); + } + + /// Proves that rejects a non-positive or non-finite DPI. + /// The invalid DPI value under test. + [Theory] + [InlineData(0f)] + [InlineData(-72f)] + [InlineData(float.NaN)] + [InlineData(float.PositiveInfinity)] + public void PdfDocument_RenderWithDpi_InvalidDpi_ThrowsArgumentOutOfRangeException(float dpi) + { + // Arrange + using var document = PdfDocument.Open(Fixture("classic-xref-single-page.pdf")); + + // Act & Assert + Assert.Throws(() => document.Render(0, dpi)); + } + + /// Proves that rejects an out-of-range page index the same way does. + [Fact] + public void PdfDocument_RenderWithDpi_OutOfRangePageIndex_ThrowsArgumentOutOfRangeException() + { + // Arrange + using var document = PdfDocument.Open(Fixture("classic-xref-single-page.pdf")); + + // Act & Assert + Assert.Throws(() => document.Render(5, 72f)); + } + + /// Proves that throws once the document is disposed. + [Fact] + public void PdfDocument_RenderWithDpi_AfterDispose_ThrowsObjectDisposedException() + { + // Arrange + var document = PdfDocument.Open(Fixture("classic-xref-single-page.pdf")); + document.Dispose(); + + // Act & Assert + Assert.Throws(() => document.Render(0, 72f)); + } + + #endregion + + #region Dispose + + /// Proves that calling a second time is a no-op rather than throwing. + [Fact] + public void PdfDocument_Dispose_CalledTwice_DoesNotThrow() + { + // Arrange + var document = PdfDocument.Open(Fixture("classic-xref-single-page.pdf")); + + // Act + var exception = Record.Exception(() => + { + document.Dispose(); + document.Dispose(); + }); + + // Assert + Assert.Null(exception); + } + + /// Proves that throws once the document is disposed. + [Fact] + public void PdfDocument_PageCount_AfterDispose_ThrowsObjectDisposedException() + { + // Arrange + var document = PdfDocument.Open(Fixture("classic-xref-single-page.pdf")); + document.Dispose(); + + // Act & Assert + Assert.Throws(() => document.PageCount); + } + + /// Proves that throws once the document is disposed. + [Fact] + public void PdfDocument_GetPageInfo_AfterDispose_ThrowsObjectDisposedException() + { + // Arrange + var document = PdfDocument.Open(Fixture("classic-xref-single-page.pdf")); + document.Dispose(); + + // Act & Assert + Assert.Throws(() => document.GetPageInfo(0)); + } + + /// Proves that throws once the document is disposed. + [Fact] + public void PdfDocument_Render_AfterDispose_ThrowsObjectDisposedException() + { + // Arrange + var document = PdfDocument.Open(Fixture("classic-xref-single-page.pdf")); + document.Dispose(); + + // Act & Assert + Assert.Throws(() => document.Render(0, 10, 10)); + } + + #endregion + + #region ContentStream + + /// Proves that an unknown/unimplemented operator is silently skipped and does not stop subsequent operators from executing. + [Fact] + public void PdfDocument_ContentStream_UnknownOperator_IsSkippedWithoutThrowing() + { + // Arrange + const string content = "/GS1 gs 2 w 10 10 m 10 90 l S"; + + // Act + using var surface = RenderContent(content); + + // Assert: the unrecognized 'gs' (ExtGState) operator was skipped, and the stroke after it still painted. + Assert.Equal(Black, surface[10, 50]); + } + + /// Proves that a /Contents array of streams is concatenated with a space separator, rather than merging adjacent tokens. + [Fact] + public void PdfDocument_ContentStream_ContentsArray_ConcatenatesStreamsWithSpaceSeparator() + { + // Arrange + using var document = PdfDocument.Open(Fixture("contents-array-two-streams.pdf")); + + // Act: stream 4 holds "10 10 40" and stream 5 holds "40 re f"; correct concatenation + // with a space separator reconstructs "10 10 40 40 re f" (a filled 40x40 square). + using var surface = document.Render(0, 100, 100); + + // Assert + Assert.Equal(Black, surface[30, 70]); + Assert.Equal(default, surface[90, 90]); + } + + /// + /// Proves that a marked-content BDC operator's inline properties dictionary + /// operand (e.g. /P << /MCID 0 >> BDC, as commonly emitted by tagged-PDF + /// producers such as Word/LibreOffice/browser print-to-PDF) is parsed as a dictionary + /// operand rather than throwing - regression test for a real-world content stream that + /// previously failed with "Unexpected token 'DictStart'". + /// + [Fact] + public void PdfDocument_ContentStream_BdcWithInlinePropertiesDictionary_IsParsedWithoutThrowing() + { + // Arrange + const string content = "/P << /MCID 0 >> BDC 20 20 30 30 re f EMC"; + + // Act + using var surface = RenderContent(content); + + // Assert: the fill after the BDC operator still painted, proving the inline dictionary + // operand was consumed correctly rather than corrupting subsequent operator parsing. + Assert.Equal(Black, surface[30, 65]); + } + + /// Proves that a page with no /Contents key at all renders as a fully blank surface, without throwing. + [Fact] + public void PdfDocument_ContentStream_NoContents_RendersBlankSurface() + { + // Arrange + using var document = PdfDocument.Open(Fixture("no-contents-page.pdf")); + + // Act + using var surface = document.Render(0, 20, 20); + + // Assert + for (var y = 0; y < surface.Height; y++) + { + for (var x = 0; x < surface.Width; x++) + { + Assert.Equal(default, surface[x, y]); + } + } + } + + #endregion + + #region GraphicsState + + /// Proves that q/cm/Q restores the prior transform after the matching pop. + [Fact] + public void PdfDocument_GraphicsState_QPushCmThenQPop_RestoresPriorTransform() + { + // Arrange: inside q/Q, 'cm' doubles the scale, so the first diagonal segment is drawn + // through a different device location than the second, identical, segment issued after Q. + const string content = "2 w q 2 0 0 2 0 0 cm 10 10 m 20 20 l S Q 10 10 m 20 20 l S"; + + // Act + using var surface = RenderContent(content); + + // Assert: the second segment (after Q) is drawn at the unscaled location - proving the + // CTM was restored, not left doubled. + Assert.NotEqual(default, surface[15, 85]); + } + + /// Proves that nested q/cm/q/cm/.../Q/Q composes both matrices, in order, onto the same drawing operation. + [Fact] + public void PdfDocument_GraphicsState_NestedQQ_ComposesTransformsInOrder() + { + // Arrange: an outer cm scales X only (x2), an inner cm scales Y only (x2); only their + // composition scales both axes, placing the drawn segment at (30,70)-ish. A bug applying + // only one of the two cm's would place it at (30,85) (X-only) or (15,70) (Y-only) instead. + const string content = "2 w q 2 0 0 1 0 0 cm q 1 0 0 2 0 0 cm 10 10 m 20 20 l S Q Q"; + + // Act + using var surface = RenderContent(content); + + // Assert + Assert.NotEqual(default, surface[30, 70]); + Assert.Equal(default, surface[30, 85]); + Assert.Equal(default, surface[15, 70]); + } + + /// Proves that an unbalanced Q with no matching prior q is tolerated as a no-op, rather than throwing. + [Fact] + public void PdfDocument_GraphicsState_UnbalancedQWithNoMatchingPush_DoesNotThrow() + { + // Arrange + const string content = "Q 2 w 10 10 m 20 20 l S"; + + // Act + var exception = Record.Exception(() => RenderContent(content).Dispose()); + + // Assert + Assert.Null(exception); + } + + /// Proves that a malformed cm operand count throws . + [Fact] + public void PdfDocument_GraphicsState_MalformedCmOperandCount_ThrowsInvalidDataException() + { + // Arrange + const string content = "1 2 3 cm"; + + // Act & Assert + Assert.Throws(() => RenderContent(content)); + } + + /// + /// Proves that a non-finite cm operand throws + /// rather than propagating an infinity into the current transformation matrix. A PDF + /// content stream has no literal syntax for NaN/infinity, but an all-digit literal + /// with enough digits to overflow (per IEEE 754, rounds to positive + /// infinity rather than failing to parse) reaches the same non-finite value through + /// entirely well-formed PDF numeric-token syntax. + /// + [Fact] + public void PdfDocument_GraphicsState_CmNonFiniteOperand_ThrowsInvalidDataException() + { + // Arrange: a 400-digit literal overflows double to +Infinity. + var hugeDigits = new string('9', 400); + var content = $"1 0 0 1 {hugeDigits} 0 cm"; + + // Act & Assert + Assert.Throws(() => RenderContent(content)); + } + + /// + /// Proves that q operators nested beyond the graphics-state stack's maximum + /// supported depth throw rather than letting an + /// unbounded run of unmatched q operators grow the stack without limit. + /// + [Fact] + public void PdfDocument_GraphicsState_ExcessiveQNestingDepth_ThrowsInvalidDataException() + { + // Arrange: one more 'q' than the documented 256-entry cap allows. + var content = string.Concat(Enumerable.Repeat("q ", 257)); + + // Act & Assert + var exception = Assert.Throws(() => RenderContent(content)); + Assert.Contains("exceeds the maximum supported depth", exception.Message, StringComparison.Ordinal); + } + + #endregion + + #region PathOps + + /// Proves that m/l/c/v/y/re each build the geometry the PDF specification documents for them. + [Fact] + public void PdfDocument_PathOps_MoveLineRectCurve_BuildExpectedGeometry() + { + // m/l/h: an explicit square built from move/line/closepath, filled. + using (var surface = RenderContent("10 10 m 90 10 l 90 90 l 10 90 l h f")) + { + Assert.Equal(Black, surface[50, 50]); + } + + // re: a rectangle built via the single-operator shorthand, filled. + using (var surface = RenderContent("20 20 30 30 re f")) + { + Assert.Equal(Black, surface[30, 65]); + Assert.Equal(default, surface[5, 5]); + } + + // c: a full cubic Bezier curve with two explicit control points, stroked. + using (var surface = RenderContent("3 w 10 50 m 10 10 90 10 90 50 c S")) + { + Assert.Equal(Black, surface[50, 80]); + } + + // v: the first control point defaults to the current point. + using (var surface = RenderContent("3 w 10 50 m 90 10 90 50 v S")) + { + Assert.Equal(Black, surface[50, 65]); + } + + // y: the second control point defaults to the curve's own endpoint. + using (var surface = RenderContent("3 w 10 50 m 30 10 90 50 y S")) + { + Assert.Equal(Black, surface[58, 65]); + } + } + + /// Proves that a malformed operand count for a path-construction operator throws . + [Theory] + [InlineData("m")] + [InlineData("1 m")] + [InlineData("1 2 3 m")] + [InlineData("10 10 m 1 l")] + [InlineData("10 10 m 1 2 3 4 5 c")] + [InlineData("10 10 m 1 2 3 v")] + [InlineData("10 10 m 1 2 3 y")] + [InlineData("1 2 3 re")] + public void PdfDocument_PathOps_MalformedOperandCount_ThrowsInvalidDataException(string content) + { + // Act & Assert + Assert.Throws(() => RenderContent(content)); + } + + /// + /// Proves that a non-finite path-construction operand throws + /// rather than propagating an infinity into the path + /// geometry. See 's + /// own remarks for why an all-digit overflowing literal is used instead of a literal + /// NaN/infinity token (which PDF content-stream syntax has no way to express). + /// + [Fact] + public void PdfDocument_PathOps_NonFiniteOperand_ThrowsInvalidDataException() + { + // Arrange: a 400-digit literal overflows double to +Infinity. + var hugeDigits = new string('9', 400); + var content = $"{hugeDigits} 10 m"; + + // Act & Assert + Assert.Throws(() => RenderContent(content)); + } + + /// Proves that issuing a draw operator before any m/re throws . + [Fact] + public void PdfDocument_PathOps_DrawBeforeMoveTo_ThrowsInvalidDataException() + { + // Arrange + const string content = "10 10 l"; + + // Act & Assert + Assert.Throws(() => RenderContent(content)); + } + + /// + /// Proves that a redundant h issued right after re (which already + /// self-closes its rectangle subpath) is a no-op per PDF 32000-1:2008 §8.5.2.1, + /// rather than throwing - a common real-world idiom + /// (for example emitted by MuPDF/fitz). + /// + [Fact] + public void PdfDocument_PathOps_CloseAfterRectangle_IsNoOp() + { + // Arrange + const string content = "10 10 40 40 re h f"; + + // Act + using var surface = RenderContent(content); + + // Assert + Assert.Equal(Black, surface[30, 70]); + Assert.Equal(default, surface[5, 5]); + } + + /// Proves that a bare h issued with no path constructed at all is a no-op, not an error. + [Fact] + public void PdfDocument_PathOps_CloseWithNoSubpath_IsNoOp() + { + // Arrange + const string content = "h"; + + // Act + using var surface = RenderContent(content); + + // Assert + Assert.Equal(default, surface[5, 5]); + } + + /// Proves that f fills using the nonzero winding rule. + [Fact] + public void PdfDocument_PathOps_FillNonZero_PaintsExpectedPixels() + { + // Arrange + const string content = "10 10 40 40 re f"; + + // Act + using var surface = RenderContent(content); + + // Assert + Assert.Equal(Black, surface[30, 70]); + Assert.Equal(default, surface[5, 5]); + } + + /// Proves that f* fills using the even-odd rule, leaving a nested same-winding rectangle unfilled as a "hole". + [Fact] + public void PdfDocument_PathOps_FillEvenOdd_PaintsExpectedPixels() + { + // Arrange + const string content = "10 10 80 80 re 30 30 40 40 re f*"; + + // Act + using var surface = RenderContent(content); + + // Assert: the outer ring is filled, but the doubly-covered inner region is not (even-odd hole). + Assert.Equal(Black, surface[15, 50]); + Assert.Equal(default, surface[50, 50]); + } + + /// Proves that S strokes the current path without filling it. + [Fact] + public void PdfDocument_PathOps_Stroke_PaintsExpectedPixels() + { + // Arrange + const string content = "2 w 60 10 m 60 90 l S"; + + // Act + using var surface = RenderContent(content); + + // Assert + Assert.Equal(Black, surface[60, 50]); + Assert.Equal(default, surface[70, 50]); + } + + /// Proves that b closes the current (still-open) subpath before both filling and stroking it. + [Fact] + public void PdfDocument_PathOps_CloseAndFillAndStroke_PaintsExpectedPixels() + { + // Arrange: an open triangle (no 'h'); 'b' must close it before painting. + const string closed = "2 w 20 20 m 80 20 l 50 70 l b"; + const string open = "2 w 20 20 m 80 20 l 50 70 l S"; + + // Act + using var closedSurface = RenderContent(closed); + using var openSurface = RenderContent(open); + + // Assert: the interior is filled, and the implicit closing edge is stroked - proven by + // comparison against a plain 'S' (no closing), which leaves that same pixel unpainted. + Assert.Equal(Black, closedSurface[50, 63]); + Assert.Equal(Black, closedSurface[35, 55]); + Assert.Equal(default, openSurface[35, 55]); + } + + /// Proves that n discards the current path without painting anything. + [Fact] + public void PdfDocument_PathOps_NoOp_DiscardsPathWithoutPainting() + { + // Arrange + const string content = "10 10 40 40 re n"; + + // Act + using var surface = RenderContent(content); + + // Assert + for (var y = 0; y < surface.Height; y++) + { + for (var x = 0; x < surface.Width; x++) + { + Assert.Equal(default, surface[x, y]); + } + } + } + + /// Proves that a path-painting operator clears the current path, but leaves the surrounding graphics state (CTM/line width) untouched for the next path. + [Fact] + public void PdfDocument_PathOps_PaintOperator_ClearsPathButPreservesGraphicsState() + { + // Arrange: the first rectangle is filled, then a second, unrelated rectangle is filled + // under the same (still-scaled) graphics state - if the first path were not cleared, the + // second 're' would incorrectly append to (rather than replace) it. + const string content = "2 0 0 2 0 0 cm 5 5 10 10 re f 20 20 10 10 re f"; + + // Act + using var surface = RenderContent(content); + + // Assert: second rectangle (user 20..30,20..30, scaled x2 = device 40..60, then flipped + // y => device y 40..60) is painted, and the first rectangle's area does not bleed into it. + Assert.Equal(Black, surface[50, 50]); + Assert.Equal(default, surface[15, 15]); + } + + #endregion + + #region Color + + /// Proves that g sets the fill color to the expected gray RGBA value. + [Fact] + public void PdfDocument_Color_SetGrayFill_SetsExpectedRgbaColor() + { + // Arrange: 0.5 gray rounds (round-half-to-even) to byte 128. + const string content = "0.5 g 10 10 80 80 re f"; + + // Act + using var surface = RenderContent(content); + + // Assert + Assert.Equal(new Canvas.Rgba32(128, 128, 128, 255), surface[50, 50]); + } + + /// Proves that G sets the stroke color to the expected gray RGBA value. + [Fact] + public void PdfDocument_Color_SetGrayStroke_SetsExpectedRgbaColor() + { + // Arrange + const string content = "0.5 G 4 w 10 50 m 90 50 l S"; + + // Act + using var surface = RenderContent(content); + + // Assert + Assert.Equal(new Canvas.Rgba32(128, 128, 128, 255), surface[50, 50]); + } + + /// Proves that rg sets the fill color to the expected RGB value. + [Fact] + public void PdfDocument_Color_SetRgbFill_SetsExpectedRgbaColor() + { + // Arrange + const string content = "1 0 0 rg 10 10 80 80 re f"; + + // Act + using var surface = RenderContent(content); + + // Assert + Assert.Equal(new Canvas.Rgba32(255, 0, 0, 255), surface[50, 50]); + } + + /// Proves that RG sets the stroke color to the expected RGB value. + [Fact] + public void PdfDocument_Color_SetRgbStroke_SetsExpectedRgbaColor() + { + // Arrange + const string content = "0 1 0 RG 4 w 10 50 m 90 50 l S"; + + // Act + using var surface = RenderContent(content); + + // Assert + Assert.Equal(new Canvas.Rgba32(0, 255, 0, 255), surface[50, 50]); + } + + /// Proves that k converts CMYK to the expected RGB fill color. + [Fact] + public void PdfDocument_Color_SetCmykFill_ConvertsToExpectedRgbaColor() + { + // Arrange: C=0 M=1 Y=1 K=0 -> R=255*(1-0)*(1-0)=255, G=255*(1-1)*(1-0)=0, B=255*(1-1)*(1-0)=0 (red). + const string content = "0 1 1 0 k 10 10 80 80 re f"; + + // Act + using var surface = RenderContent(content); + + // Assert + Assert.Equal(new Canvas.Rgba32(255, 0, 0, 255), surface[50, 50]); + } + + /// Proves that K converts CMYK to the expected RGB stroke color. + [Fact] + public void PdfDocument_Color_SetCmykStroke_ConvertsToExpectedRgbaColor() + { + // Arrange: C=1 M=0 Y=0 K=0 -> R=0, G=255, B=255 (cyan). + const string content = "1 0 0 0 K 4 w 10 50 m 90 50 l S"; + + // Act + using var surface = RenderContent(content); + + // Assert + Assert.Equal(new Canvas.Rgba32(0, 255, 255, 255), surface[50, 50]); + } + + /// Proves that color-component values outside [0, 1] are clamped, not rejected. + [Theory] + [InlineData("-1 2 -0.5 rg 10 10 80 80 re f", 0, 255, 0)] + [InlineData("2 -1 -1 rg 10 10 80 80 re f", 255, 0, 0)] + public void PdfDocument_Color_ComponentValuesOutsideZeroToOne_AreClamped(string content, byte r, byte g, byte b) + { + // Act + using var surface = RenderContent(content); + + // Assert + Assert.Equal(new Canvas.Rgba32(r, g, b, 255), surface[50, 50]); + } + + /// Proves that a malformed operand count for every device color operator throws . + [Theory] + [InlineData("0.5 0.5 g")] + [InlineData("0.5 0.5 G")] + [InlineData("1 0 rg")] + [InlineData("1 0 RG")] + [InlineData("1 0 0 k")] + [InlineData("1 0 0 K")] + [InlineData("/DeviceRGB /DeviceGray cs")] + [InlineData("/DeviceRGB /DeviceGray CS")] + [InlineData("1 0 sc")] + [InlineData("1 0 SC")] + [InlineData("1 0 scn")] + [InlineData("1 0 SCN")] + public void PdfDocument_Color_MalformedOperandCount_ThrowsInvalidDataException(string content) + { + Assert.Throws(() => RenderContent(content)); + } + + /// Proves that cs accepts the three device color-space names and resets the fill color to black. + [Theory] + [InlineData("DeviceGray")] + [InlineData("DeviceRGB")] + [InlineData("DeviceCMYK")] + public void PdfDocument_Color_SetColorSpaceFill_DeviceNames_ResetsColorToBlack(string colorSpaceName) + { + // Arrange: paint red first, then switch color space (resetting to black), then fill. + var content = $"1 0 0 rg 10 10 40 80 re f /{colorSpaceName} cs 60 10 20 80 re f"; + + // Act + using var surface = RenderContent(content); + + // Assert + Assert.Equal(new Canvas.Rgba32(255, 0, 0, 255), surface[20, 50]); + Assert.Equal(Black, surface[70, 50]); + } + + /// Proves that CS accepts the three device color-space names and resets the stroke color to black. + [Theory] + [InlineData("DeviceGray")] + [InlineData("DeviceRGB")] + [InlineData("DeviceCMYK")] + public void PdfDocument_Color_SetColorSpaceStroke_DeviceNames_ResetsColorToBlack(string colorSpaceName) + { + // Arrange: stroke red first, then switch stroke color space (resetting to black), then stroke again. + var content = $"1 0 0 RG 4 w 10 30 m 90 30 l S /{colorSpaceName} CS 10 70 m 90 70 l S"; + + // Act + using var surface = RenderContent(content); + + // Assert: PDF y-up user space flips to device y-down, so user y=30 -> device y=70, and + // user y=70 -> device y=30. + Assert.Equal(new Canvas.Rgba32(255, 0, 0, 255), surface[50, 70]); + Assert.Equal(Black, surface[50, 30]); + } + + /// Proves that sc paints using the current fill color space's component count. + [Fact] + public void PdfDocument_Color_SetColorFillUsingCurrentColorSpace_Sc_PaintsExpectedColor() + { + // Arrange: switch to DeviceRGB (resets to black), then 'sc' with 3 components. + const string content = "/DeviceRGB cs 0 0 1 sc 10 10 80 80 re f"; + + // Act + using var surface = RenderContent(content); + + // Assert + Assert.Equal(new Canvas.Rgba32(0, 0, 255, 255), surface[50, 50]); + } + + /// Proves that SC paints using the current stroke color space's component count. + [Fact] + public void PdfDocument_Color_SetColorStrokeUsingCurrentColorSpace_SC_PaintsExpectedColor() + { + // Arrange + const string content = "/DeviceRGB CS 0 0 1 SC 4 w 10 50 m 90 50 l S"; + + // Act + using var surface = RenderContent(content); + + // Assert + Assert.Equal(new Canvas.Rgba32(0, 0, 255, 255), surface[50, 50]); + } + + /// + /// Proves that /Pattern cs alone (no base color space) resolves without throwing - + /// a smoke test against the new cs /Pattern handling. + /// + [Fact] + public void PdfDocument_Color_PatternColorSpace_Cs_ResolvesToPatternFamily() + { + // Arrange + const string content = "/Pattern cs"; + + // Act & Assert: no exception. + using var surface = RenderContent(content); + Assert.NotNull(surface); + } + + /// + /// Proves that /CS0 cs naming a [/Pattern /DeviceRGB] array form declared in + /// /Resources/ColorSpace (an array form declaring an underlying tint base) parses, + /// and that a declared shading pattern selected afterward via scn (with the 3 + /// leading numeric operands this implementation's ResolvePatternOperand requires + /// whenever the color space declares an underlying base - regardless of whether the + /// resolved pattern turns out to be a shading or tiling pattern) paints its own + /// tint-independent shading color. (An inline array is not itself a legal cs + /// operand - PDF content streams only allow a Name operand there, resolved against + /// /Resources/ColorSpace.) + /// + [Fact] + public void PdfDocument_Color_PatternColorSpace_ArrayWithBase_ResolvesPatternBase() + { + // Arrange: an uncolored (/PaintType 2) tiling pattern selected against a /Pattern color + // space with an underlying /DeviceRGB base - the base is only actually consulted for an + // uncolored tiling pattern (see ResolvePatternOperand's own remarks: a shading pattern or + // a colored tiling pattern takes 0 leading operands regardless of the declared base). + var cellContent = "0 0 0 rg 0 0 10 10 re f"; + var patternStream = BuildStreamObjectBody( + "/PatternType 1 /PaintType 2 /TilingType 1 /BBox [0 0 10 10] /XStep 10 /YStep 10", + System.Text.Encoding.ASCII.GetBytes(cellContent)); + var bytes = BuildSinglePagePdfWithResources( + 100, + 100, + "/CS0 cs 0 1 0 /P1 scn 10 10 80 80 re f", + "/ColorSpace << /CS0 [/Pattern /DeviceRGB] >> /Pattern << /P1 5 0 R >>", + [patternStream]); + + // Act + using var surface = RenderPdfBytes(bytes); + + // Assert: the supplied (0, 1, 0) tint (green) is resolved via the declared /DeviceRGB + // base and applied to the uncolored tile's painted pixels. + Assert.Equal(new Canvas.Rgba32(0, 255, 0, 255), surface[50, 50]); + } + + /// + /// Proves that /CS0 cs naming a [/Pattern /DeviceRGB /DeviceGray] array + /// (3 array elements) declared in /Resources/ColorSpace throws + /// with feature pdf-colorspace-Pattern. + /// + [Fact] + public void PdfDocument_Color_PatternColorSpace_TooManyArrayElements_ThrowsUnsupportedImageFeatureException() + { + // Arrange + var bytes = BuildSinglePagePdfWithResources( + 100, + 100, + "/CS0 cs", + "/ColorSpace << /CS0 [/Pattern /DeviceRGB /DeviceGray] >>", + []); + + // Act & Assert + var exception = Assert.Throws(() => RenderPdfBytes(bytes)); + Assert.Equal("pdf-colorspace-Pattern", exception.Feature); + } + + /// Proves that /P1 scn (colored pattern operand, name alone, no base space) resolves a declared pattern successfully. + [Fact] + public void PdfDocument_Color_Scn_ColoredPatternOperand_NameAlone_ResolvesPattern() + { + // Arrange: a flat shading pattern selected with no leading numeric operands (/Pattern cs + // declares no underlying base, so 0 numeric operands are expected before the name). + // Object numbering: 5 = patternDict, 6 = shadingDict, 7 = functionStream. + var patternDict = "<< /PatternType 2 /Shading 6 0 R >>"u8.ToArray(); + var shadingDict = "<< /ShadingType 2 /ColorSpace /DeviceRGB /Coords [0 0 100 0] /Function 7 0 R >>"u8.ToArray(); + var functionStream = BuildStreamObjectBody( + "/FunctionType 2 /Domain [0 1] /C0 [1 0 0] /C1 [1 0 0] /N 1", []); + var bytes = BuildSinglePagePdfWithResources( + 100, + 100, + "/Pattern cs /P1 scn 10 10 80 80 re f", + "/Pattern << /P1 5 0 R >>", + [patternDict, shadingDict, functionStream]); + + // Act + using var surface = RenderPdfBytes(bytes); + + // Assert + Assert.Equal(new Canvas.Rgba32(255, 0, 0, 255), surface[50, 50]); + } + + /// + /// Proves that scn selecting a colored (/PaintType 1) tiling + /// pattern with name alone (no leading tint operands) succeeds even when the active + /// color space was declared with an underlying /DeviceRGB base - a common + /// real-world PDF producer pattern (declaring [/Pattern baseSpace] regardless of + /// the referenced pattern's own /PaintType) that every mainstream viewer + /// tolerates, since a colored pattern never actually needs a tint - regression test for + /// a corpus-observed failure. + /// + [Fact] + public void PdfDocument_Color_Scn_ColoredPatternOperand_DeclaredBaseIgnored_ResolvesPattern() + { + // Arrange: /CS0 declares [/Pattern /DeviceRGB], but /P1 is a colored (/PaintType 1) + // tiling pattern - 'scn' supplies only the pattern name, no leading tint components. + var cellContent = "1 0 0 rg 0 0 10 10 re f"; + var patternStream = BuildStreamObjectBody( + "/PatternType 1 /PaintType 1 /TilingType 1 /BBox [0 0 10 10] /XStep 10 /YStep 10", + System.Text.Encoding.ASCII.GetBytes(cellContent)); + var bytes = BuildSinglePagePdfWithResources( + 100, + 100, + "/CS0 cs /P1 scn 10 10 80 80 re f", + "/ColorSpace << /CS0 [/Pattern /DeviceRGB] >> /Pattern << /P1 5 0 R >>", + [patternStream]); + + // Act + using var surface = RenderPdfBytes(bytes); + + // Assert: the colored tile's own declared red paints through, unaffected by the + // declared-but-unused /DeviceRGB base. + Assert.Equal(new Canvas.Rgba32(255, 0, 0, 255), surface[50, 50]); + } + + /// Proves that an uncolored-pattern scn operand with the wrong leading numeric-operand count throws . + [Fact] + public void PdfDocument_Color_Scn_UncoloredPatternOperand_WrongComponentCount_ThrowsInvalidDataException() + { + // Arrange: base space /DeviceRGB requires 3 numeric operands before the pattern name, + // but only 2 are supplied. The base is declared via a named /CS0 color-space resource + // (an inline array is not itself a legal 'cs' operand). + var bytes = BuildSinglePagePdfWithResources( + 100, + 100, + "/CS0 cs 0.5 0.5 /P1 scn", + "/ColorSpace << /CS0 [/Pattern /DeviceRGB] >> /Pattern << /P1 5 0 R >>", + [BuildStreamObjectBody("/PatternType 1 /PaintType 2 /BBox [0 0 10 10] /XStep 10 /YStep 10", [])]); + + // Act & Assert + Assert.Throws(() => RenderPdfBytes(bytes)); + } + + /// Proves that a scn operand against a /Pattern color space with no trailing Name operand throws . + [Fact] + public void PdfDocument_Color_Scn_PatternOperand_MissingTrailingName_ThrowsInvalidDataException() + { + // Arrange + const string content = "/Pattern cs 1 scn"; + + // Act & Assert + Assert.Throws(() => RenderContent(content)); + } + + /// Proves that selecting an undeclared pattern name via scn throws with feature pdf-pattern-not-declared. + [Fact] + public void PdfDocument_Color_Scn_PatternOperand_UndeclaredPatternName_ThrowsUnsupportedImageFeatureException() + { + // Arrange: /Pattern resource dictionary exists but does not declare /P0. + var bytes = BuildSinglePagePdfWithResources( + 100, + 100, + "/Pattern cs /P0 scn", + "/Pattern << >>", + []); + + // Act & Assert + var exception = Assert.Throws(() => RenderPdfBytes(bytes)); + Assert.Equal("pdf-pattern-not-declared", exception.Feature); + } + + /// Proves that an unsupported /PatternType (regression guard: only 1/2 are supported) throws with feature pdf-pattern-type-{n}. + [Fact] + public void PdfDocument_Color_Scn_PatternOperand_UnsupportedPatternType_ThrowsUnsupportedImageFeatureException() + { + // Arrange + var bytes = BuildSinglePagePdfWithResources( + 100, + 100, + "/Pattern cs /P1 scn", + "/Pattern << /P1 5 0 R >>", + ["<< /PatternType 3 >>"u8.ToArray()]); + + // Act & Assert + var exception = Assert.Throws(() => RenderPdfBytes(bytes)); + Assert.Equal("pdf-pattern-type-3", exception.Feature); + } + + /// Proves that an unsupported named color space (resolved via /Resources/ColorSpace) throws when selected with cs. + [Theory] + [InlineData("[/Separation /Spot /DeviceGray 4 0 R]")] + [InlineData("[/DeviceN [/Spot] /DeviceGray 4 0 R]")] + [InlineData("[/ICCBased 4 0 R]")] + [InlineData("[/CalGray << >>]")] + [InlineData("[/Lab << >>]")] + public void PdfDocument_Color_UnsupportedNamedColorSpace_ThrowsUnsupportedImageFeatureException(string colorSpaceArray) + { + // Arrange: an unused placeholder function stream, referenced only when the color space + // array under test declares /Separation or /DeviceN (both name a tint-transform function + // by indirect reference); the other cases in this theory never dereference object 5. + var functionStream = BuildStreamObjectBody("/FunctionType 4", "{ }"u8.ToArray()); + var bytes = BuildSinglePagePdfWithResources( + 100, + 100, + "/CS0 cs", + $"/ColorSpace << /CS0 {colorSpaceArray} >>", + [functionStream]); + + // Act & Assert + Assert.Throws(() => RenderPdfBytes(bytes)); + } + + /// + /// Proves that a color-space name that resolves back to itself (directly, or via a cycle + /// of several names) throws instead of recursing until + /// the process' call stack is exhausted (a regression test for a real-world malformed PDF + /// that crashed the whole process with an unrecoverable ). + /// + [Theory] + [InlineData("/ColorSpace << /CS0 /CS0 >>")] + [InlineData("/ColorSpace << /CS0 /CS1 /CS1 /CS0 >>")] + public void PdfDocument_Color_SelfReferentialNamedColorSpace_ThrowsInvalidDataException(string colorSpaceDictionary) + { + // Arrange + var bytes = BuildSinglePagePdfWithResources( + 100, + 100, + "/CS0 cs", + colorSpaceDictionary, + []); + + // Act & Assert + Assert.Throws(() => RenderPdfBytes(bytes)); + } + + /// Proves that an /ICCBased color space with /N 3 (and no /Alternate) resolves/paints identically to DeviceRGB. + [Fact] + public void PdfDocument_Color_IccBasedN3_ResolvesAsDeviceRgb() + { + // Arrange: object 5 is an ICC profile stream declaring only /N 3 (no /Alternate). + var iccStream = BuildStreamObjectBody("/N 3", [0x00]); + var bytes = BuildSinglePagePdfWithResources( + 100, + 100, + "/CS0 cs 0 0 1 scn 10 10 80 80 re f", + "/ColorSpace << /CS0 [/ICCBased 5 0 R] >>", + [iccStream]); + + // Act + using var surface = RenderPdfBytes(bytes); + + // Assert: 3 operands were accepted (/N 3 implies DeviceRGB's component count) and the + // fill painted blue, exactly like DeviceRGB would. + Assert.Equal(new Canvas.Rgba32(0, 0, 255, 255), surface[50, 50]); + } + + /// + /// Proves that a [/CalRGB dict] color space resolves/paints identically to + /// DeviceRGB, ignoring its own /WhitePoint//Gamma//Matrix + /// entries entirely - a corpus-observed gap (PDFTest's sample01-sample08.pdf + /// and pdf-sample.pdf all declare page content color spaces this way). + /// + [Fact] + public void PdfDocument_Color_CalRgbColorSpace_ResolvesToDeviceRgb() + { + // Arrange: a /CalRGB color space declaring a non-trivial /WhitePoint/Gamma/Matrix, which + // this renderer deliberately ignores (see ResolveColorSpaceValue's own remarks). + var bytes = BuildSinglePagePdfWithResources( + 100, + 100, + "/CS0 cs 0 0 1 scn 10 10 80 80 re f", + "/ColorSpace << /CS0 [/CalRGB << /WhitePoint [0.9505 1.0 1.089] " + + "/Gamma [2.2 2.2 2.2] /Matrix [0.41 0.21 0.019 0.36 0.72 0.12 0.18 0.07 0.95] >>] >>", + []); + + // Act + using var surface = RenderPdfBytes(bytes); + + // Assert: 3 operands were accepted (CalRGB's component count matches DeviceRGB's) and + // the fill painted blue, exactly like DeviceRGB would. + Assert.Equal(new Canvas.Rgba32(0, 0, 255, 255), surface[50, 50]); + } + + /// Proves that an /ICCBased color space prefers a present, supported /Alternate over its own /N. + [Fact] + public void PdfDocument_Color_IccBasedWithAlternate_PrefersAlternateOverN() + { + // Arrange: object 5 declares /N 1 (DeviceGray, 1 component) but /Alternate /DeviceRGB (3 + // components). Supplying 3 operands to 'scn' only succeeds if /Alternate won: had /N been + // used instead, 3 operands would mismatch the expected 1 and throw InvalidDataException. + var iccStream = BuildStreamObjectBody("/N 1 /Alternate /DeviceRGB", [0x00]); + var bytes = BuildSinglePagePdfWithResources( + 100, + 100, + "/CS0 cs 1 0 0 scn 10 10 80 80 re f", + "/ColorSpace << /CS0 [/ICCBased 5 0 R] >>", + [iccStream]); + + // Act + using var surface = RenderPdfBytes(bytes); + + // Assert + Assert.Equal(new Canvas.Rgba32(255, 0, 0, 255), surface[50, 50]); + } + + /// Proves that an /ICCBased color space with an unsupported /N and no usable /Alternate throws . + [Fact] + public void PdfDocument_Color_IccBasedUnsupportedN_ThrowsUnsupportedImageFeatureException() + { + // Arrange: object 5 declares /N 2, which maps to no device color space, and no /Alternate. + var iccStream = BuildStreamObjectBody("/N 2", [0x00]); + var bytes = BuildSinglePagePdfWithResources( + 100, + 100, + "/CS0 cs", + "/ColorSpace << /CS0 [/ICCBased 5 0 R] >>", + [iccStream]); + + // Act & Assert + Assert.Throws(() => RenderPdfBytes(bytes)); + } + + /// Proves that an /Indexed color space selected via scn paints the expected palette entry. + [Fact] + public void PdfDocument_Color_IndexedColorSpace_Scn_PaintsExpectedPaletteColor() + { + // Arrange: a 2-entry (Hival = 1) DeviceRGB palette: index 0 = red, index 1 = green. + var bytes = BuildSinglePagePdfWithResources( + 100, + 100, + "/CS0 cs 1 scn 10 10 80 80 re f", + "/ColorSpace << /CS0 [/Indexed /DeviceRGB 1 ] >>", + []); + + // Act + using var surface = RenderPdfBytes(bytes); + + // Assert: index 1 selects the green palette entry. + Assert.Equal(new Canvas.Rgba32(0, 255, 0, 255), surface[50, 50]); + } + + /// Proves that an /Indexed color space clamps an out-of-range index to the highest valid palette entry instead of throwing. + [Fact] + public void PdfDocument_Color_IndexedColorSpace_OutOfRangeIndex_ClampsToHighestPaletteEntry() + { + // Arrange: same 2-entry (Hival = 1) palette as above, but 'scn' selects index 5. + var bytes = BuildSinglePagePdfWithResources( + 100, + 100, + "/CS0 cs 5 scn 10 10 80 80 re f", + "/ColorSpace << /CS0 [/Indexed /DeviceRGB 1 ] >>", + []); + + // Act + using var surface = RenderPdfBytes(bytes); + + // Assert: index 5 clamps to Hival (1), the highest valid (green) palette entry. + Assert.Equal(new Canvas.Rgba32(0, 255, 0, 255), surface[50, 50]); + } + + /// Proves that an /Indexed color space whose base color space is itself unsupported throws . + [Fact] + public void PdfDocument_Color_IndexedColorSpace_UnsupportedBase_ThrowsUnsupportedImageFeatureException() + { + // Arrange: the base color space (/Separation) is unsupported, and is rejected before the + // /Hival/lookup table are ever consulted. + var bytes = BuildSinglePagePdfWithResources( + 100, + 100, + "/CS0 cs", + "/ColorSpace << /CS0 [/Indexed [/Separation /Spot /DeviceGray 4 0 R] 1 <00>] >>", + []); + + // Act & Assert + Assert.Throws(() => RenderPdfBytes(bytes)); + } + + /// Proves that an /Indexed color space's /Hival exceeding the spec's 8-bit index maximum (255) throws rather than silently corrupting the palette-length validation via int overflow. + [Fact] + public void PdfDocument_Color_IndexedColorSpace_HivalExceeds255_ThrowsInvalidDataException() + { + // Arrange: /Hival 999999999 is far outside the 8-bit index range (0-255) /Hival must stay + // within per the PDF specification. + var bytes = BuildSinglePagePdfWithResources( + 100, + 100, + "/CS0 cs", + "/ColorSpace << /CS0 [/Indexed /DeviceRGB 999999999 <00>] >>", + []); + + // Act & Assert + Assert.Throws(() => RenderPdfBytes(bytes)); + } + + #endregion + + #region Filters + + /// Proves that a FlateDecode+PNG-predictor image XObject decodes the expected raw pixels. + [Fact] + public void PdfDocument_Images_FlateDecodePngPredictor_DecodesExpectedPixels() + { + // Arrange: a 2x2 DeviceRGB image, PNG predictor 15 (adaptive). Row 0 uses filter type 0 + // (None): raw pixels (255,0,0) (0,255,0). Row 1 uses filter type 2 (Up): stored as the + // difference from row 0, encoding raw pixels (0,0,255) (255,255,0). + byte[] row0 = [0, 255, 0, 0, 0, 255, 0]; + byte[] row1raw = [0, 0, 255, 255, 255, 0]; + var row1Filtered = new byte[row1raw.Length]; + for (var i = 0; i < row1raw.Length; i++) + { + row1Filtered[i] = (byte)(row1raw[i] - row0[i + 1]); + } + + var rawRows = new List(); + rawRows.AddRange(row0); + rawRows.Add(2); // filter type: Up + rawRows.AddRange(row1Filtered); + var compressed = ZlibCompress([.. rawRows]); + + var imageStream = BuildStreamObjectBody( + "/Type /XObject /Subtype /Image /Width 2 /Height 2 /ColorSpace /DeviceRGB /BitsPerComponent 8 " + + "/Filter /FlateDecode /DecodeParms << /Predictor 15 /Colors 3 /BitsPerComponent 8 /Columns 2 >>", + compressed); + + var bytes = BuildSinglePagePdfWithResources( + 100, + 100, + "100 0 0 100 0 0 cm /Im0 Do", + "/XObject << /Im0 5 0 R >>", + [imageStream]); + + // Act + using var surface = RenderPdfBytes(bytes); + + // Assert: row 0 (top of unit square) -> device top half; row 1 -> device bottom half. + Assert.Equal(new Canvas.Rgba32(255, 0, 0, 255), surface[25, 25]); + Assert.Equal(new Canvas.Rgba32(0, 255, 0, 255), surface[75, 25]); + Assert.Equal(new Canvas.Rgba32(0, 0, 255, 255), surface[25, 75]); + Assert.Equal(new Canvas.Rgba32(255, 255, 0, 255), surface[75, 75]); + } + + /// + /// Proves that a FlateDecode+PNG fixed-predictor (/Predictor 12, "Up") + /// image XObject decodes correctly: unlike the adaptive /Predictor 15 form, a + /// fixed predictor has no per-row filter-type tag byte at all - every row is exactly + /// /Columns * /Colors * /BitsPerComponent / 8 payload bytes, with the single + /// declared filter type (here, "Up") applied uniformly to every row. + /// + [Fact] + public void PdfDocument_Images_FlateDecodePngFixedPredictor_DecodesExpectedPixels() + { + // Arrange: a 2x2 DeviceGray image. Row 0 raw: (10, 200); "Up" filtered against an + // implicit all-zero previous row leaves it unchanged. Row 1 raw: (50, 90); "Up" filtered + // against row 0 stores the difference. No per-row tag byte precedes either row. + byte[] row0Raw = [10, 200]; + byte[] row1Raw = [50, 90]; + byte[] row0Filtered = [.. row0Raw]; + var row1Filtered = new byte[] { (byte)(row1Raw[0] - row0Raw[0]), (byte)(row1Raw[1] - row0Raw[1]) }; + var rawRows = new List(); + rawRows.AddRange(row0Filtered); + rawRows.AddRange(row1Filtered); + var compressed = ZlibCompress([.. rawRows]); + + var imageStream = BuildStreamObjectBody( + "/Type /XObject /Subtype /Image /Width 2 /Height 2 /ColorSpace /DeviceGray /BitsPerComponent 8 " + + "/Filter /FlateDecode /DecodeParms << /Predictor 12 /Colors 1 /BitsPerComponent 8 /Columns 2 >>", + compressed); + + var bytes = BuildSinglePagePdfWithResources( + 100, + 100, + "100 0 0 100 0 0 cm /Im0 Do", + "/XObject << /Im0 5 0 R >>", + [imageStream]); + + // Act + using var surface = RenderPdfBytes(bytes); + + // Assert: row 0 (top of unit square) -> device top half; row 1 -> device bottom half. + Assert.Equal(new Canvas.Rgba32(10, 10, 10, 255), surface[25, 25]); + Assert.Equal(new Canvas.Rgba32(200, 200, 200, 255), surface[75, 25]); + Assert.Equal(new Canvas.Rgba32(50, 50, 50, 255), surface[25, 75]); + Assert.Equal(new Canvas.Rgba32(90, 90, 90, 255), surface[75, 75]); + } + + /// + /// Proves that a FlateDecode+PNG fixed-predictor (/Predictor 11, "Sub") + /// stream whose decoded length is not an exact multiple of the fixed row stride (no tag + /// byte, so the stride is exactly rowBytes) throws + /// rather than silently truncating the incomplete trailing row via integer-division. + /// + [Fact] + public void PdfDocument_Images_FlateDecodePngFixedPredictor_LengthNotMultipleOfRowStride_ThrowsInvalidDataException() + { + // Arrange: rowBytes is 2 (1 colors * 8 bits * 2 columns / 8), but only 3 bytes of decoded + // payload are supplied for 2 declared rows (needs 4) - not a multiple of the stride. + var compressed = ZlibCompress([10, 20, 30]); + + var imageStream = BuildStreamObjectBody( + "/Type /XObject /Subtype /Image /Width 2 /Height 2 /ColorSpace /DeviceGray /BitsPerComponent 8 " + + "/Filter /FlateDecode /DecodeParms << /Predictor 11 /Colors 1 /BitsPerComponent 8 /Columns 2 >>", + compressed); + + var bytes = BuildSinglePagePdfWithResources( + 100, + 100, + "100 0 0 100 0 0 cm /Im0 Do", + "/XObject << /Im0 5 0 R >>", + [imageStream]); + + // Act & Assert + Assert.Throws(() => RenderPdfBytes(bytes)); + } + + /// Proves that a FlateDecode+TIFF-predictor image XObject decodes the expected raw pixels. + [Fact] + public void PdfDocument_Images_FlateDecodeTiffPredictor_DecodesExpectedPixels() + { + // Arrange: a 2x2 DeviceGray image, TIFF predictor 2. Raw pixels per row: (10, 200). + // TIFF-encoded: byte 0 verbatim, byte 1 = raw[1] - raw[0]. + byte[] rawRow = [10, 200]; + var encodedRow = new byte[] { rawRow[0], (byte)(rawRow[1] - rawRow[0]) }; + var rawBytes = new List(); + rawBytes.AddRange(encodedRow); + rawBytes.AddRange(encodedRow); + var compressed = ZlibCompress([.. rawBytes]); + + var imageStream = BuildStreamObjectBody( + "/Type /XObject /Subtype /Image /Width 2 /Height 2 /ColorSpace /DeviceGray /BitsPerComponent 8 " + + "/Filter /FlateDecode /DecodeParms << /Predictor 2 /Colors 1 /BitsPerComponent 8 /Columns 2 >>", + compressed); + + var bytes = BuildSinglePagePdfWithResources( + 100, + 100, + "100 0 0 100 0 0 cm /Im0 Do", + "/XObject << /Im0 5 0 R >>", + [imageStream]); + + // Act + using var surface = RenderPdfBytes(bytes); + + // Assert: both source pixels in a row decode to (10) then (200); both rows identical. + Assert.Equal(new Canvas.Rgba32(10, 10, 10, 255), surface[25, 25]); + Assert.Equal(new Canvas.Rgba32(200, 200, 200, 255), surface[75, 25]); + Assert.Equal(new Canvas.Rgba32(10, 10, 10, 255), surface[25, 75]); + Assert.Equal(new Canvas.Rgba32(200, 200, 200, 255), surface[75, 75]); + } + + /// + /// Proves that a /DecodeParms /Colors 0 predictor declaration throws + /// instead of being silently accepted (defense-in-depth: + /// 's own TIFF/PNG predictor row/stride arithmetic already guards + /// its internal divisors via Math.Max(1, ...)/ternary zero-checks, so this input + /// does not currently reproduce a raw CLR crash - this test instead proves the new + /// upfront validation rejects a spec-illegal value outright). + /// + [Fact] + public void PdfDocument_Images_FlateDecodeTiffPredictor_ZeroColors_ThrowsInvalidDataException() + { + // Arrange: /Colors 0 is not a legal PDF /DecodeParms value (must be a positive integer). + var imageStream = BuildStreamObjectBody( + "/Type /XObject /Subtype /Image /Width 2 /Height 2 /ColorSpace /DeviceGray /BitsPerComponent 8 " + + "/Filter /FlateDecode /DecodeParms << /Predictor 2 /Colors 0 /BitsPerComponent 8 /Columns 2 >>", + ZlibCompress([10, 190, 10, 190])); + + var bytes = BuildSinglePagePdfWithResources( + 100, + 100, + "100 0 0 100 0 0 cm /Im0 Do", + "/XObject << /Im0 5 0 R >>", + [imageStream]); + + // Act & Assert + Assert.Throws(() => RenderPdfBytes(bytes)); + } + + /// + /// Proves that a /DecodeParms /Columns 0 predictor declaration throws + /// (the same "must be a positive integer" guard + /// already applied to /Colors, exercised here for /Columns's own + /// independent check). + /// + [Fact] + public void PdfDocument_Images_FlateDecodeTiffPredictor_ZeroColumns_ThrowsInvalidDataException() + { + // Arrange: /Columns 0 is not a legal PDF /DecodeParms value (must be a positive integer). + var imageStream = BuildStreamObjectBody( + "/Type /XObject /Subtype /Image /Width 2 /Height 2 /ColorSpace /DeviceGray /BitsPerComponent 8 " + + "/Filter /FlateDecode /DecodeParms << /Predictor 2 /Colors 1 /BitsPerComponent 8 /Columns 0 >>", + ZlibCompress([10, 190, 10, 190])); + + var bytes = BuildSinglePagePdfWithResources( + 100, + 100, + "100 0 0 100 0 0 cm /Im0 Do", + "/XObject << /Im0 5 0 R >>", + [imageStream]); + + // Act & Assert + Assert.Throws(() => RenderPdfBytes(bytes)); + } + + /// + /// Proves that a /DecodeParms /Columns declaration so large that + /// /Colors * /BitsPerComponent * /Columns would overflow plain int32 + /// arithmetic (wrapping the computed row stride to a small or negative value and + /// defeating the positivity checks already applied to /Colors//Columns + /// themselves) throws instead of corrupting the + /// downstream TIFF/PNG predictor row/stride allocation. + /// + [Fact] + public void PdfDocument_Images_FlateDecodeTiffPredictor_ColumnsOverflowsRowStride_ThrowsInvalidDataException() + { + // Arrange: /Colors 1, /BitsPerComponent 16, /Columns int.MaxValue - the row-stride product + // (1 * 16 * 2147483647 + 7) / 8 overflows int32 but is still rejected safely via the + // `long`-arithmetic bounds-check added for this case. + var imageStream = BuildStreamObjectBody( + "/Type /XObject /Subtype /Image /Width 2 /Height 2 /ColorSpace /DeviceGray /BitsPerComponent 8 " + + "/Filter /FlateDecode /DecodeParms << /Predictor 2 /Colors 1 /BitsPerComponent 16 /Columns 2147483647 >>", + ZlibCompress([10, 190, 10, 190])); + + var bytes = BuildSinglePagePdfWithResources( + 100, + 100, + "100 0 0 100 0 0 cm /Im0 Do", + "/XObject << /Im0 5 0 R >>", + [imageStream]); + + // Act & Assert + var exception = Assert.Throws(() => RenderPdfBytes(bytes)); + Assert.Contains("exceeds the maximum supported size", exception.Message, StringComparison.Ordinal); + } + + /// Proves that an image XObject declaring an unsupported filter throws . + [Fact] + public void PdfDocument_Images_UnsupportedFilter_ThrowsUnsupportedImageFeatureException() + { + // Arrange: /JPXDecode remains genuinely unsupported (unlike /LZWDecode and + // /CCITTFaxDecode, both of which this library implements - see + // PdfFixtures/README.md/design docs). + var imageStream = BuildStreamObjectBody( + "/Type /XObject /Subtype /Image /Width 2 /Height 2 /ColorSpace /DeviceGray /BitsPerComponent 8 /Filter /JPXDecode", + [1, 2, 3, 4]); + + var bytes = BuildSinglePagePdfWithResources( + 100, + 100, + "100 0 0 100 0 0 cm /Im0 Do", + "/XObject << /Im0 5 0 R >>", + [imageStream]); + + // Act & Assert + Assert.Throws(() => RenderPdfBytes(bytes)); + } + + /// Proves that an image XObject declaring an LZWDecode+PNG-predictor filter decodes the expected raw pixels. + [Fact] + public void PdfDocument_Images_LzwDecodePngPredictor_DecodesExpectedPixels() + { + // Arrange: same 2x2 DeviceRGB/PNG-predictor-15 raw layout as + // PdfDocument_Images_FlateDecodePngPredictor_DecodesExpectedPixels, but compressed with + // LZWDecode instead of FlateDecode - proves predictor reversal applies after LZWDecode too. + byte[] row0 = [0, 255, 0, 0, 0, 255, 0]; + byte[] row1raw = [0, 0, 255, 255, 255, 0]; + var row1Filtered = new byte[row1raw.Length]; + for (var i = 0; i < row1raw.Length; i++) + { + row1Filtered[i] = (byte)(row1raw[i] - row0[i + 1]); + } + + var rawRows = new List(); + rawRows.AddRange(row0); + rawRows.Add(2); // filter type: Up + rawRows.AddRange(row1Filtered); + var compressed = EncodeLzwForTest([.. rawRows], earlyChange: true); + + var imageStream = BuildStreamObjectBody( + "/Type /XObject /Subtype /Image /Width 2 /Height 2 /ColorSpace /DeviceRGB /BitsPerComponent 8 " + + "/Filter /LZWDecode /DecodeParms << /Predictor 15 /Colors 3 /BitsPerComponent 8 /Columns 2 >>", + compressed); + + var bytes = BuildSinglePagePdfWithResources( + 100, + 100, + "100 0 0 100 0 0 cm /Im0 Do", + "/XObject << /Im0 5 0 R >>", + [imageStream]); + + // Act + using var surface = RenderPdfBytes(bytes); + + // Assert: row 0 (top of unit square) -> device top half; row 1 -> device bottom half. + Assert.Equal(new Canvas.Rgba32(255, 0, 0, 255), surface[25, 25]); + Assert.Equal(new Canvas.Rgba32(0, 255, 0, 255), surface[75, 25]); + Assert.Equal(new Canvas.Rgba32(0, 0, 255, 255), surface[25, 75]); + Assert.Equal(new Canvas.Rgba32(255, 255, 0, 255), surface[75, 75]); + } + + /// Proves that a plain (unpredicted) LZWDecode image XObject decodes and places the expected pixels via Do. + [Fact] + public void PdfDocument_Images_DoOperator_DeviceGrayLzwDecode_PlacesExpectedPixels() + { + // Arrange: a 2x1 DeviceGray image, raw samples (10, 200), no predictor. + byte[] rawSamples = [10, 200]; + var compressed = EncodeLzwForTest(rawSamples, earlyChange: true); + + var imageStream = BuildStreamObjectBody( + "/Type /XObject /Subtype /Image /Width 2 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8 /Filter /LZWDecode", + compressed); + + var bytes = BuildSinglePagePdfWithResources( + 100, + 100, + "100 0 0 100 0 0 cm /Im0 Do", + "/XObject << /Im0 5 0 R >>", + [imageStream]); + + // Act + using var surface = RenderPdfBytes(bytes); + + // Assert + Assert.Equal(new Canvas.Rgba32(10, 10, 10, 255), surface[25, 50]); + Assert.Equal(new Canvas.Rgba32(200, 200, 200, 255), surface[75, 50]); + } + + /// + /// Proves that the mandatory ISO 32000-1/2 section 7.4.4.2 Table 7/EXAMPLE 2 + /// worked LZWDecode vector (input 45 45 45 45 45 65 45 45 45 66, i.e. ASCII + /// "-----A---B", packed per EXAMPLE 2 as 80 0B 60 50 22 0C 0C 85 01) + /// decodes to the exact expected bytes. + /// + [Fact] + public void PdfDocument_Filters_LzwDecode_SpecExampleTable7_DecodesExpectedBytes() + { + // Arrange: the spec's own EXAMPLE 2 byte sequence, as a 10x1 DeviceGray image so each + // decoded byte becomes one directly inspectable pixel. + byte[] specBytes = [0x80, 0x0B, 0x60, 0x50, 0x22, 0x0C, 0x0C, 0x85, 0x01]; + + // Act + using var surface = RenderGrayscaleImage(10, specBytes, "/Filter /LZWDecode"); + + // Assert: "-----A---B" == 2D 2D 2D 2D 2D 41 2D 2D 2D 42 + byte[] expected = [0x2D, 0x2D, 0x2D, 0x2D, 0x2D, 0x41, 0x2D, 0x2D, 0x2D, 0x42]; + for (var i = 0; i < expected.Length; i++) + { + Assert.Equal(new Canvas.Rgba32(expected[i], expected[i], expected[i], 255), surface[i, 0]); + } + } + + /// + /// Proves that the default (/EarlyChange absent, meaning 1) code-width + /// growth timing decodes a synthetic vector deliberately long enough to cross the 9-to-10 + /// bit boundary, and that decoding the very same bytes with /EarlyChange 0 instead + /// (the wrong timing for how they were encoded) is rejected - proving the two timings are + /// genuinely different, not merely two names for the same behavior. + /// + [Fact] + public void PdfDocument_Filters_LzwDecode_EarlyChangeDefault_GrowsCodeWidthOneCodeEarly() + { + // Arrange: a synthetic (not spec-provided), deterministically generated 320-byte vector + // long enough for the dictionary to cross code 511 (the 9-to-10-bit growth boundary). + var data = BuildLzwGrowthTestData(); + var encoded = EncodeLzwForTest(data, earlyChange: true); + + // Act + using var correctSurface = RenderGrayscaleImage(data.Length, encoded, "/Filter /LZWDecode"); + + // Assert: decoded with the matching (default) EarlyChange timing, every byte round-trips. + for (var i = 0; i < data.Length; i++) + { + Assert.Equal(new Canvas.Rgba32(data[i], data[i], data[i], 255), correctSurface[i, 0]); + } + + // Assert: decoding the same bytes with the opposite (late) timing desyncs and fails closed. + Assert.Throws( + () => RenderGrayscaleImage(data.Length, encoded, "/Filter /LZWDecode /DecodeParms << /EarlyChange 0 >>")); + } + + /// + /// Proves that /EarlyChange 0 code-width growth timing decodes a synthetic vector + /// encoded with that same (later) timing, and that decoding the very same bytes with the + /// default (early) timing instead is rejected. + /// + [Fact] + public void PdfDocument_Filters_LzwDecode_EarlyChangeZero_GrowsCodeWidthOneCodeLater() + { + // Arrange: the same synthetic vector, this time encoded with EarlyChange 0 timing. + var data = BuildLzwGrowthTestData(); + var encoded = EncodeLzwForTest(data, earlyChange: false); + + // Act + using var correctSurface = RenderGrayscaleImage( + data.Length, encoded, "/Filter /LZWDecode /DecodeParms << /EarlyChange 0 >>"); + + // Assert: decoded with the matching (EarlyChange 0) timing, every byte round-trips. + for (var i = 0; i < data.Length; i++) + { + Assert.Equal(new Canvas.Rgba32(data[i], data[i], data[i], 255), correctSurface[i, 0]); + } + + // Assert: decoding the same bytes with the default (early) timing instead desyncs. + Assert.Throws(() => RenderGrayscaleImage(data.Length, encoded, "/Filter /LZWDecode")); + } + + /// + /// Proves that a Clear-table code occurring mid-stream actually reinitializes the + /// dictionary: after CLEAR, 'A', 'A', 'A' (which would - if the table were + /// not reset - leave dynamic code 260 meaning "AA"), a second + /// CLEAR followed by 'B', 260 must reject code 260 as not-yet-present + /// (since a properly reset table has not yet reassigned it) rather than silently + /// resolving it against the stale pre-reset entry. + /// + [Fact] + public void PdfDocument_Filters_LzwDecode_ClearCodeMidStream_ReinitializesTable() + { + // Arrange: 9-bit codes [256 (Clear), 65, 65, 65, 256 (Clear), 66, 260], hand-packed MSB-first. + byte[] data = [0x80, 0x10, 0x48, 0x24, 0x18, 0x01, 0x0A, 0x08]; + + // Act & Assert + Assert.Throws(() => RenderGrayscaleImage(1, data, "/Filter /LZWDecode")); + } + + /// Proves that a bit stream truncated before an EOD code is reached throws . + [Fact] + public void PdfDocument_Filters_LzwDecode_MissingEodCode_ThrowsInvalidDataException() + { + // Arrange: 9-bit codes [256 (Clear), 65, 66] with no EOD code, hand-packed MSB-first. + byte[] data = [0x80, 0x10, 0x48, 0x40]; + + // Act & Assert + Assert.Throws(() => RenderGrayscaleImage(1, data, "/Filter /LZWDecode")); + } + + /// Proves that an out-of-range code (not yet present in the table, and not the very next code) throws . + [Fact] + public void PdfDocument_Filters_LzwDecode_InvalidCode_ThrowsInvalidDataException() + { + // Arrange: 9-bit codes [256 (Clear), 300] - 300 is not a valid code before any table + // entry exists (only 0-257 are), hand-packed MSB-first. + byte[] data = [0x80, 0x4B, 0x00]; + + // Act & Assert + Assert.Throws(() => RenderGrayscaleImage(1, data, "/Filter /LZWDecode")); + } + + /// Proves that an LZWDecode stream whose decoded output would exceed the decoder's output-size cap (a decompression-bomb-style crafted stream: a tiny, highly repetitive, well-compressed input expanding into tens of megabytes) throws instead of exhausting memory. + [Fact] + public void PdfDocument_Filters_LzwDecode_OutputExceedsMaxSize_ThrowsInvalidDataException() + { + // Arrange: a single repeated byte value compresses to a tiny LZW stream (classic-LZW's + // well-known exponential-chain-growth behavior for a run of one repeated symbol), but + // decodes back to just over the decoder's 64 MiB output cap - used via a Form XObject's + // content stream (rather than an image XObject) so the image /Width x /Height dimension + // cap added by this same hardening pass cannot short-circuit this test before ever + // reaching the LZW decoder itself. + var rawData = new byte[(64 * 1024 * 1024) + (1024 * 1024)]; + Array.Fill(rawData, (byte)'A'); + var encoded = EncodeLzwForTest(rawData, earlyChange: true); + + var formStream = BuildStreamObjectBody("/Type /XObject /Subtype /Form /BBox [0 0 100 100] /Filter /LZWDecode", encoded); + + var bytes = BuildSinglePagePdfWithResources( + 100, + 100, + "/Fm0 Do", + "/XObject << /Fm0 5 0 R >>", + [formStream]); + + // Act & Assert + Assert.Throws(() => RenderPdfBytes(bytes)); + } + + /// + /// Proves that a FlateDecode stream whose decoded output would exceed the + /// decoder's output-size cap (a decompression-bomb-style crafted zlib stream: a tiny, + /// highly repetitive, well-compressed input expanding into tens of megabytes) throws + /// instead of exhausting memory. + /// + [Fact] + public void PdfDocument_Filters_FlateDecode_OutputExceedsMaxSize_ThrowsInvalidDataException() + { + // Arrange: a single repeated byte value compresses to a tiny zlib stream, but decodes + // back to just over the decoder's 64 MiB output cap - used via a Form XObject's content + // stream (rather than an image XObject) so the image /Width x /Height dimension cap + // cannot short-circuit this test before ever reaching the FlateDecode decoder itself. + var rawData = new byte[(64 * 1024 * 1024) + (1024 * 1024)]; + Array.Fill(rawData, (byte)'A'); + var encoded = ZlibCompress(rawData); + + var formStream = BuildStreamObjectBody("/Type /XObject /Subtype /Form /BBox [0 0 100 100] /Filter /FlateDecode", encoded); + + var bytes = BuildSinglePagePdfWithResources( + 100, + 100, + "/Fm0 Do", + "/XObject << /Fm0 5 0 R >>", + [formStream]); + + // Act & Assert + Assert.Throws(() => RenderPdfBytes(bytes)); + } + + /// Proves that an all-zero-group ASCII85Decode z shorthand decodes to four zero bytes. + [Fact] + public void PdfDocument_Filters_Ascii85Decode_ZeroGroup_DecodesToFourZeroBytes() + { + // Arrange: "z~>" - the z shorthand for 00 00 00 00, then EOD. + var data = "z~>"u8.ToArray(); + + // Act + using var surface = RenderGrayscaleImage(4, data, "/Filter /ASCII85Decode"); + + // Assert + for (var i = 0; i < 4; i++) + { + Assert.Equal(new Canvas.Rgba32(0, 0, 0, 255), surface[i, 0]); + } + } + + /// + /// Proves that a full 5-character ASCII85 group decodes to its expected 4 bytes - + /// self-derived (per the spec's own section 7.4.3 formula) vector: bytes + /// 00 01 02 03 encode to !!*-'. + /// + [Fact] + public void PdfDocument_Filters_Ascii85Decode_FullGroup_DecodesExpectedBytes() + { + // Arrange + var data = "!!*-'~>"u8.ToArray(); + + // Act + using var surface = RenderGrayscaleImage(4, data, "/Filter /ASCII85Decode"); + + // Assert + byte[] expected = [0x00, 0x01, 0x02, 0x03]; + for (var i = 0; i < expected.Length; i++) + { + Assert.Equal(new Canvas.Rgba32(expected[i], expected[i], expected[i], 255), surface[i, 0]); + } + } + + /// + /// Proves the final-partial-group padding rule: self-derived vectors 4D 61 (2 + /// bytes) encodes to 9jn (n+1 = 3 characters), and 4D 61 6E (3 bytes) + /// encodes to 9jqo (n+1 = 4 characters). + /// + [Fact] + public void PdfDocument_Filters_Ascii85Decode_PartialFinalGroup_AppliesPaddingRule() + { + // Arrange & Act + using var twoByteSurface = RenderGrayscaleImage(2, "9jn~>"u8.ToArray(), "/Filter /ASCII85Decode"); + using var threeByteSurface = RenderGrayscaleImage(3, "9jqo~>"u8.ToArray(), "/Filter /ASCII85Decode"); + + // Assert + Assert.Equal(new Canvas.Rgba32(0x4D, 0x4D, 0x4D, 255), twoByteSurface[0, 0]); + Assert.Equal(new Canvas.Rgba32(0x61, 0x61, 0x61, 255), twoByteSurface[1, 0]); + Assert.Equal(new Canvas.Rgba32(0x4D, 0x4D, 0x4D, 255), threeByteSurface[0, 0]); + Assert.Equal(new Canvas.Rgba32(0x61, 0x61, 0x61, 255), threeByteSurface[1, 0]); + Assert.Equal(new Canvas.Rgba32(0x6E, 0x6E, 0x6E, 255), threeByteSurface[2, 0]); + } + + /// Proves that PDF white-space characters interspersed within an ASCII85Decode stream are ignored. + [Fact] + public void PdfDocument_Filters_Ascii85Decode_WhitespaceIgnored_DecodesExpectedBytes() + { + // Arrange: the same "!!*-'" vector, with whitespace inserted between every character. + var data = "! ! \t* \n - \r ' \f ~ >"u8.ToArray(); + + // Act + using var surface = RenderGrayscaleImage(4, data, "/Filter /ASCII85Decode"); + + // Assert + byte[] expected = [0x00, 0x01, 0x02, 0x03]; + for (var i = 0; i < expected.Length; i++) + { + Assert.Equal(new Canvas.Rgba32(expected[i], expected[i], expected[i], 255), surface[i, 0]); + } + } + + /// Proves that a z character occurring in the middle of a group throws . + [Fact] + public void PdfDocument_Filters_Ascii85Decode_ZInMiddleOfGroup_ThrowsInvalidDataException() + { + // Arrange: 'z' after one already-accumulated group character is invalid. + var data = "!z~>"u8.ToArray(); + + // Act & Assert + Assert.Throws(() => RenderGrayscaleImage(1, data, "/Filter /ASCII85Decode")); + } + + /// Proves that a 5-character group whose base-85 value exceeds 2^32 - 1 throws . + [Fact] + public void PdfDocument_Filters_Ascii85Decode_ValueExceedsRange_ThrowsInvalidDataException() + { + // Arrange: five 'u' (84) digits: 84*85^4 + 84*85^3 + 84*85^2 + 84*85 + 84 = 85^5 - 1, + // which exceeds uint.MaxValue (2^32 - 1 = 4294967295 < 85^5 - 1 = 4437053124). + var data = "uuuuu~>"u8.ToArray(); + + // Act & Assert + Assert.Throws(() => RenderGrayscaleImage(1, data, "/Filter /ASCII85Decode")); + } + + /// Proves that a missing ~> EOD marker throws . + [Fact] + public void PdfDocument_Filters_Ascii85Decode_MissingEodMarker_ThrowsInvalidDataException() + { + // Arrange: a well-formed group, but no ~> terminator. + var data = "!!*-'"u8.ToArray(); + + // Act & Assert + Assert.Throws(() => RenderGrayscaleImage(4, data, "/Filter /ASCII85Decode")); + } + + /// + /// Proves that an ASCII85Decode stream whose decoded output would exceed the + /// decoder's output-size cap (a decompression-bomb-style crafted stream: the z + /// shorthand's 4x amplification - 1 input byte expands to 4 output bytes - applied many + /// times) throws instead of exhausting memory. Used + /// via a Form XObject's content stream (rather than an image XObject) so the image + /// /Width x /Height dimension cap cannot short-circuit this test before ever reaching the + /// ASCII85Decode decoder itself. Mirrors + /// . + /// + [Fact] + public void PdfDocument_Filters_Ascii85Decode_OutputExceedsMaxSize_ThrowsInvalidDataException() + { + // Arrange: enough 'z' characters (each expanding to 4 output bytes) to push the total + // decoded output just over the decoder's 64 MiB output cap, followed by the ~> EOD marker. + const int maxOutputBytes = 64 * 1024 * 1024; + var zCount = (maxOutputBytes / 4) + (1024 * 1024); + var data = new byte[zCount + 2]; + Array.Fill(data, (byte)'z', 0, zCount); + data[zCount] = (byte)'~'; + data[zCount + 1] = (byte)'>'; + + var formStream = BuildStreamObjectBody("/Type /XObject /Subtype /Form /BBox [0 0 100 100] /Filter /ASCII85Decode", data); + + var bytes = BuildSinglePagePdfWithResources( + 100, + 100, + "/Fm0 Do", + "/XObject << /Fm0 5 0 R >>", + [formStream]); + + // Act & Assert + Assert.Throws(() => RenderPdfBytes(bytes)); + } + + /// Proves that hex-digit pairs decode to the expected bytes. + [Fact] + public void PdfDocument_Filters_AsciiHexDecode_HexDigitPairs_DecodesExpectedBytes() + { + // Arrange + var data = "0A1B2C>"u8.ToArray(); + + // Act + using var surface = RenderGrayscaleImage(3, data, "/Filter /ASCIIHexDecode"); + + // Assert + Assert.Equal(new Canvas.Rgba32(0x0A, 0x0A, 0x0A, 255), surface[0, 0]); + Assert.Equal(new Canvas.Rgba32(0x1B, 0x1B, 0x1B, 255), surface[1, 0]); + Assert.Equal(new Canvas.Rgba32(0x2C, 0x2C, 0x2C, 255), surface[2, 0]); + } + + /// Proves that an odd trailing hex digit is implicitly padded with a zero low nibble. + [Fact] + public void PdfDocument_Filters_AsciiHexDecode_OddTrailingDigit_PadsWithZeroNibble() + { + // Arrange: a single trailing "A" digit, implicitly followed by a zero nibble -> 0xA0. + var data = "0A1BA>"u8.ToArray(); + + // Act + using var surface = RenderGrayscaleImage(3, data, "/Filter /ASCIIHexDecode"); + + // Assert + Assert.Equal(new Canvas.Rgba32(0x0A, 0x0A, 0x0A, 255), surface[0, 0]); + Assert.Equal(new Canvas.Rgba32(0x1B, 0x1B, 0x1B, 255), surface[1, 0]); + Assert.Equal(new Canvas.Rgba32(0xA0, 0xA0, 0xA0, 255), surface[2, 0]); + } + + /// Proves that PDF white-space characters interspersed within an ASCIIHexDecode stream are ignored. + [Fact] + public void PdfDocument_Filters_AsciiHexDecode_WhitespaceIgnored_DecodesExpectedBytes() + { + // Arrange + var data = "0A \t1B\n2C\r\f>"u8.ToArray(); + + // Act + using var surface = RenderGrayscaleImage(3, data, "/Filter /ASCIIHexDecode"); + + // Assert + Assert.Equal(new Canvas.Rgba32(0x0A, 0x0A, 0x0A, 255), surface[0, 0]); + Assert.Equal(new Canvas.Rgba32(0x1B, 0x1B, 0x1B, 255), surface[1, 0]); + Assert.Equal(new Canvas.Rgba32(0x2C, 0x2C, 0x2C, 255), surface[2, 0]); + } + + /// Proves that a non-hexadecimal, non-whitespace character throws . + [Fact] + public void PdfDocument_Filters_AsciiHexDecode_InvalidCharacter_ThrowsInvalidDataException() + { + // Arrange: 'G' is not a valid hex digit. + var data = "0AG1>"u8.ToArray(); + + // Act & Assert + Assert.Throws(() => RenderGrayscaleImage(2, data, "/Filter /ASCIIHexDecode")); + } + + /// Proves that a missing > EOD marker throws . + [Fact] + public void PdfDocument_Filters_AsciiHexDecode_MissingEodMarker_ThrowsInvalidDataException() + { + // Arrange: well-formed hex digits, but no > terminator. + var data = "0A1B2C"u8.ToArray(); + + // Act & Assert + Assert.Throws(() => RenderGrayscaleImage(3, data, "/Filter /ASCIIHexDecode")); + } + + /// Proves that a length byte 0-127 (a literal run) copies the following length + 1 bytes verbatim. + [Fact] + public void PdfDocument_Filters_RunLengthDecode_LiteralRun_CopiesBytesVerbatim() + { + // Arrange: length byte 2 -> copy the next 3 literal bytes, then EOD (128). + byte[] data = [2, 0x10, 0x20, 0x30, 128]; + + // Act + using var surface = RenderGrayscaleImage(3, data, "/Filter /RunLengthDecode"); + + // Assert + Assert.Equal(new Canvas.Rgba32(0x10, 0x10, 0x10, 255), surface[0, 0]); + Assert.Equal(new Canvas.Rgba32(0x20, 0x20, 0x20, 255), surface[1, 0]); + Assert.Equal(new Canvas.Rgba32(0x30, 0x30, 0x30, 255), surface[2, 0]); + } + + /// Proves that a length byte 129-255 (a repeat run) repeats the following single byte 257 - length times. + [Fact] + public void PdfDocument_Filters_RunLengthDecode_RepeatRun_RepeatsSingleByte() + { + // Arrange: length byte 254 -> repeat the next byte 257 - 254 = 3 times, then EOD (128). + byte[] data = [254, 0x55, 128]; + + // Act + using var surface = RenderGrayscaleImage(3, data, "/Filter /RunLengthDecode"); + + // Assert + for (var i = 0; i < 3; i++) + { + Assert.Equal(new Canvas.Rgba32(0x55, 0x55, 0x55, 255), surface[i, 0]); + } + } + + /// Proves that the EOD length byte (128) stops decoding, ignoring any trailing bytes after it. + [Fact] + public void PdfDocument_Filters_RunLengthDecode_EodMarker_StopsDecoding() + { + // Arrange: a literal run, then EOD, then trailing bytes that must be ignored. + byte[] data = [1, 0x10, 0x20, 128, 0xFF, 0xFF]; + + // Act + using var surface = RenderGrayscaleImage(2, data, "/Filter /RunLengthDecode"); + + // Assert + Assert.Equal(new Canvas.Rgba32(0x10, 0x10, 0x10, 255), surface[0, 0]); + Assert.Equal(new Canvas.Rgba32(0x20, 0x20, 0x20, 255), surface[1, 0]); + } + + /// Proves that a literal run requiring more bytes than remain (truncated, no EOD) throws . + [Fact] + public void PdfDocument_Filters_RunLengthDecode_TruncatedRun_ThrowsInvalidDataException() + { + // Arrange: length byte 5 declares a 6-byte literal run, but only 2 bytes follow. + byte[] data = [5, 0x10, 0x20]; + + // Act & Assert + Assert.Throws(() => RenderGrayscaleImage(1, data, "/Filter /RunLengthDecode")); + } + + /// + /// Proves that a RunLengthDecode stream whose decoded output would exceed the + /// decoder's output-size cap (a decompression-bomb-style crafted stream: a small run of + /// repeat-run pairs, each a 128x amplification, expanding into tens of megabytes) throws + /// instead of exhausting memory. Used via a Form + /// XObject's content stream (rather than an image XObject) so the image /Width x /Height + /// dimension cap cannot short-circuit this test before ever reaching the RunLengthDecode + /// decoder itself. + /// + [Fact] + public void PdfDocument_Filters_RunLengthDecode_OutputExceedsMaxSize_ThrowsInvalidDataException() + { + // Arrange: enough [254, 0x41] repeat-run pairs (each expanding to 3 bytes) to push the + // total decoded output just over the decoder's 64 MiB output cap, followed by EOD (128). + const int maxOutputBytes = 64 * 1024 * 1024; + const int bytesPerRepeat = 3; + var pairCount = (maxOutputBytes / bytesPerRepeat) + 1024; + var data = new byte[(pairCount * 2) + 1]; + for (var i = 0; i < pairCount; i++) + { + data[i * 2] = 254; + data[(i * 2) + 1] = 0x41; + } + + data[^1] = 128; + + var formStream = BuildStreamObjectBody("/Type /XObject /Subtype /Form /BBox [0 0 100 100] /Filter /RunLengthDecode", data); + + var bytes = BuildSinglePagePdfWithResources( + 100, + 100, + "/Fm0 Do", + "/XObject << /Fm0 5 0 R >>", + [formStream]); + + // Act & Assert + Assert.Throws(() => RenderPdfBytes(bytes)); + } + + /// + /// Proves that RunLengthDecode's output-size cap is enforced by a preflight check + /// against the REMAINING budget before a run is appended - not a check only after the run + /// has already been added to the output - by crafting an input that fills the cap exactly + /// with 128-byte literal runs and then adds one more single-byte literal run: the single + /// byte that would push the total 1 byte past the cap is rejected immediately, so the + /// output is never allowed to grow past the cap even transiently. + /// + [Fact] + public void PdfDocument_Filters_RunLengthDecode_RunExceedsRemainingBudget_ThrowsBeforeExceedingCap() + { + // Arrange: fill the cap exactly with maximal (128-byte) literal runs (length byte 127), + // then append one more single-byte literal run (length byte 0) that would overshoot. + const int maxOutputBytes = 64 * 1024 * 1024; + const int runSize = 128; + var runCount = maxOutputBytes / runSize; + var data = new byte[(runCount * (runSize + 1)) + 2 + 1]; + var offset = 0; + for (var i = 0; i < runCount; i++) + { + data[offset++] = 127; + for (var k = 0; k < runSize; k++) + { + data[offset++] = 0x41; + } + } + + data[offset++] = 0; // literal run length byte -> 1 literal byte follows + data[offset++] = 0x42; + data[offset] = 128; // EOD, unreachable: the preceding run must already have thrown + + var formStream = BuildStreamObjectBody("/Type /XObject /Subtype /Form /BBox [0 0 100 100] /Filter /RunLengthDecode", data); + + var bytes = BuildSinglePagePdfWithResources( + 100, + 100, + "/Fm0 Do", + "/XObject << /Fm0 5 0 R >>", + [formStream]); + + // Act & Assert + var exception = Assert.Throws(() => RenderPdfBytes(bytes)); + Assert.Contains("exceeds the maximum supported size", exception.Message, StringComparison.Ordinal); + } + + /// + /// Proves PdfDocument.CcittFax.cs's White/Black Modified Huffman run-length code + /// tables are internally self-consistent: writing any single table entry's own exact bits + /// through and reading it back via + /// recovers the exact same run length - + /// independently of whether any single rendered test image happens to exercise that + /// particular code (most test images in this file only ever exercise a handful of small + /// run lengths). Exercises the internal (not private) visibility + /// deliberately given to these members for this exact purpose. + /// + [Fact] + public void PdfDocument_Filters_CcittFaxRunLengthTables_RoundTripEveryCode() + { + foreach (var table in new[] { PdfDocument.WhiteCodeTable, PdfDocument.BlackCodeTable }) + { + foreach (var ((length, code), run) in table) + { + // Arrange: pack just this one code's bits (MSB-first), left-justified in enough + // bytes to hold it, zero-padded - exactly what CcittBitReader expects. + var byteCount = (length + 7) / 8; + var bytes = new byte[byteCount]; + for (var i = 0; i < length; i++) + { + var bit = (code >> (length - 1 - i)) & 1; + if (bit == 1) + { + bytes[i / 8] |= (byte)(0x80 >> (i % 8)); + } + } + + var reader = new PdfDocument.CcittBitReader(bytes); + + // Act + var decodedRun = PdfDocument.ReadVariableLengthCode(reader, table, length); + + // Assert + Assert.Equal(run, decodedRun); + } + } + } + + /// + /// Proves PdfDocument.CcittFax.cs's ModeCodeTable (all nine ITU-T T.6 + /// two-dimensional mode codes: Pass, Horizontal, V0, VR1-3, VL1-3) is internally + /// self-consistent: writing any single table entry's own exact bits through + /// and reading it back via + /// recovers the exact same mode - independently of + /// whether any single rendered test image happens to exercise that particular mode code + /// (most rendered test images in this file only ever exercise Horizontal/V0/VL1/VR2). + /// Mirrors 's + /// own precedent; exercises the internal (not private) visibility + /// deliberately given to ModeCodeTable/CcittMode/ReadMode for this + /// exact purpose. + /// + [Fact] + public void PdfDocument_Filters_CcittFaxModeCodeTable_RoundTripEveryCode() + { + foreach (var ((length, code), mode) in PdfDocument.ModeCodeTable) + { + // Arrange: pack just this one code's bits (MSB-first), left-justified in enough + // bytes to hold it, zero-padded - exactly what CcittBitReader expects. + var byteCount = (length + 7) / 8; + var bytes = new byte[byteCount]; + for (var i = 0; i < length; i++) + { + var bit = (code >> (length - 1 - i)) & 1; + if (bit == 1) + { + bytes[i / 8] |= (byte)(0x80 >> (i % 8)); + } + } + + var reader = new PdfDocument.CcittBitReader(bytes); + + // Act + var decodedMode = PdfDocument.ReadMode(reader); + + // Assert + Assert.Equal(mode, decodedMode); + } + } + + #endregion + + #region Images + + /// Proves that Do decodes and places a raw 8-bit DeviceGray FlateDecode image XObject at the expected device pixels. + [Fact] + public void PdfDocument_Images_DoOperator_DeviceGrayFlateDecode_PlacesExpectedPixels() + { + // Arrange: a 2x2 DeviceGray image: row0 = (0, 255), row1 = (64, 192). + byte[] raw = [0, 255, 64, 192]; + var compressed = ZlibCompress(raw); + + var imageStream = BuildStreamObjectBody( + "/Type /XObject /Subtype /Image /Width 2 /Height 2 /ColorSpace /DeviceGray /BitsPerComponent 8 /Filter /FlateDecode", + compressed); + + var bytes = BuildSinglePagePdfWithResources( + 100, + 100, + "100 0 0 100 0 0 cm /Im0 Do", + "/XObject << /Im0 5 0 R >>", + [imageStream]); + + // Act + using var surface = RenderPdfBytes(bytes); + + // Assert + Assert.Equal(new Canvas.Rgba32(0, 0, 0, 255), surface[25, 25]); + Assert.Equal(new Canvas.Rgba32(255, 255, 255, 255), surface[75, 25]); + Assert.Equal(new Canvas.Rgba32(64, 64, 64, 255), surface[25, 75]); + Assert.Equal(new Canvas.Rgba32(192, 192, 192, 255), surface[75, 75]); + } + + /// + /// Proves that a FlateDecode stream whose trailing Adler-32 checksum does not match + /// the compressed data still decodes successfully - matching real-world reader tolerance + /// for the wrong/placeholder checksums some PDF producers emit, found via a batch-rendering + /// survey against a public real-world PDF test corpus. + /// + [Fact] + public void PdfDocument_Images_DeviceGrayFlateDecode_CorruptAdlerChecksum_StillDecodes() + { + // Arrange: a 2x2 DeviceGray image, with the zlib stream's last byte (part of the trailing + // Adler-32 checksum) deliberately flipped - the DEFLATE payload itself is untouched. + byte[] raw = [0, 255, 64, 192]; + var compressed = ZlibCompress(raw); + compressed[^1] ^= 0xFF; + + var imageStream = BuildStreamObjectBody( + "/Type /XObject /Subtype /Image /Width 2 /Height 2 /ColorSpace /DeviceGray /BitsPerComponent 8 /Filter /FlateDecode", + compressed); + + var bytes = BuildSinglePagePdfWithResources( + 100, + 100, + "100 0 0 100 0 0 cm /Im0 Do", + "/XObject << /Im0 5 0 R >>", + [imageStream]); + + // Act + using var surface = RenderPdfBytes(bytes); + + // Assert: decoded exactly as if the checksum had been correct. + Assert.Equal(new Canvas.Rgba32(0, 0, 0, 255), surface[25, 25]); + Assert.Equal(new Canvas.Rgba32(255, 255, 255, 255), surface[75, 25]); + Assert.Equal(new Canvas.Rgba32(64, 64, 64, 255), surface[25, 75]); + Assert.Equal(new Canvas.Rgba32(192, 192, 192, 255), surface[75, 75]); + } + + /// Proves that Do decodes and places a raw 8-bit DeviceRGB FlateDecode image XObject at the expected device pixels. + [Fact] + public void PdfDocument_Images_DoOperator_DeviceRgbFlateDecode_PlacesExpectedPixels() + { + // Arrange: a 2x2 DeviceRGB image: row0 = red, green; row1 = blue, white. + byte[] raw = [255, 0, 0, 0, 255, 0, 0, 0, 255, 255, 255, 255]; + var compressed = ZlibCompress(raw); + + var imageStream = BuildStreamObjectBody( + "/Type /XObject /Subtype /Image /Width 2 /Height 2 /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /FlateDecode", + compressed); + + var bytes = BuildSinglePagePdfWithResources( + 100, + 100, + "100 0 0 100 0 0 cm /Im0 Do", + "/XObject << /Im0 5 0 R >>", + [imageStream]); + + // Act + using var surface = RenderPdfBytes(bytes); + + // Assert + Assert.Equal(new Canvas.Rgba32(255, 0, 0, 255), surface[25, 25]); + Assert.Equal(new Canvas.Rgba32(0, 255, 0, 255), surface[75, 25]); + Assert.Equal(new Canvas.Rgba32(0, 0, 255, 255), surface[25, 75]); + Assert.Equal(new Canvas.Rgba32(255, 255, 255, 255), surface[75, 75]); + } + + /// Proves that Do decodes and places a raw 8-bit DeviceCMYK FlateDecode image XObject at the expected device pixels. + [Fact] + public void PdfDocument_Images_DoOperator_DeviceCmykFlateDecode_PlacesExpectedPixels() + { + // Arrange: a single-pixel 1x1 DeviceCMYK image: C=0 M=1 Y=1 K=0 -> red. + byte[] raw = [0, 255, 255, 0]; + var compressed = ZlibCompress(raw); + + var imageStream = BuildStreamObjectBody( + "/Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceCMYK /BitsPerComponent 8 /Filter /FlateDecode", + compressed); + + var bytes = BuildSinglePagePdfWithResources( + 100, + 100, + "100 0 0 100 0 0 cm /Im0 Do", + "/XObject << /Im0 5 0 R >>", + [imageStream]); + + // Act + using var surface = RenderPdfBytes(bytes); + + // Assert + Assert.Equal(new Canvas.Rgba32(255, 0, 0, 255), surface[50, 50]); + } + + /// Proves that Do decodes and places an /Indexed image XObject's palette colors at the expected device pixels. + [Fact] + public void PdfDocument_Images_DoOperator_IndexedColorSpace_PlacesExpectedPaletteColors() + { + // Arrange: a 2x2 image over a 3-entry (Hival = 2) DeviceRGB palette (red/green/blue). + // Raw index samples: row0 = (0, 1), row1 = (2, 2). + byte[] raw = [0, 1, 2, 2]; + var compressed = ZlibCompress(raw); + + var imageStream = BuildStreamObjectBody( + "/Type /XObject /Subtype /Image /Width 2 /Height 2 " + + "/ColorSpace [/Indexed /DeviceRGB 2 ] /BitsPerComponent 8 /Filter /FlateDecode", + compressed); + + var bytes = BuildSinglePagePdfWithResources( + 100, + 100, + "100 0 0 100 0 0 cm /Im0 Do", + "/XObject << /Im0 5 0 R >>", + [imageStream]); + + // Act + using var surface = RenderPdfBytes(bytes); + + // Assert + Assert.Equal(new Canvas.Rgba32(255, 0, 0, 255), surface[25, 25]); + Assert.Equal(new Canvas.Rgba32(0, 255, 0, 255), surface[75, 25]); + Assert.Equal(new Canvas.Rgba32(0, 0, 255, 255), surface[25, 75]); + Assert.Equal(new Canvas.Rgba32(0, 0, 255, 255), surface[75, 75]); + } + + /// Proves that Do decodes a bare DCTDecode (JPEG) image XObject via and places the expected (solid-color, lossless-for-flat-blocks) pixels. + [Fact] + public void PdfDocument_Images_DoOperator_DctDecodeJpeg_PlacesExpectedPixels() + { + // Arrange: an 8x8 (one MCU block), solid blue surface - a flat color block's DCT has only + // a DC coefficient, so a high-quality encode/decode round-trip reproduces it exactly (or + // within a tiny rounding tolerance). + var solid = new Canvas.Surface(8, 8); + var blue = new Canvas.Rgba32(0, 0, 255, 255); + for (var y = 0; y < 8; y++) + { + for (var x = 0; x < 8; x++) + { + solid[x, y] = blue; + } + } + + using var jpegStream = new MemoryStream(); + JpegCodec.Save(solid, jpegStream, quality: 100); + var jpegBytes = jpegStream.ToArray(); + + var imageStream = BuildStreamObjectBody( + "/Type /XObject /Subtype /Image /Width 8 /Height 8 /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /DCTDecode", + jpegBytes); + + var bytes = BuildSinglePagePdfWithResources( + 100, + 100, + "100 0 0 100 0 0 cm /Im0 Do", + "/XObject << /Im0 5 0 R >>", + [imageStream]); + + // Act + using var surface = RenderPdfBytes(bytes); + + // Assert: within a small per-channel tolerance of solid blue. + var pixel = surface[50, 50]; + Assert.True(Math.Abs(pixel.R - blue.R) <= 8, $"R={pixel.R}"); + Assert.True(Math.Abs(pixel.G - blue.G) <= 8, $"G={pixel.G}"); + Assert.True(Math.Abs(pixel.B - blue.B) <= 8, $"B={pixel.B}"); + Assert.Equal(255, pixel.A); + } + + /// + /// Proves that Do decodes a Group 4 (T.6 MMR) CCITTFaxDecode image XObject + /// and places the expected black/white pixels at specific (x, y) coordinates. + /// + /// + /// + /// Uses the 8x4 pattern below, encoded via the test-only + /// . Per that method's own remarks, a + /// round-trip through the test encoder and the production decoder alone cannot rule + /// out a matching pair of bugs, so this test additionally hand-verifies, bit by bit, + /// the exact encoded byte sequence this pattern must produce - worked out independently + /// of both the production C# decoder and the test-only C# encoder (originally derived + /// and cross-checked via a from-scratch Python prototype, then re-derived by hand + /// below) - and asserts the encoder actually produces those exact bytes before even + /// reaching the decoder, so a mismatch here would fail loudly as a wrong-test-vector + /// bug rather than silently validating nothing. + /// + /// + /// Pattern (W=white, B=black), 8 columns x 4 rows: + /// + /// row 0: W W W W B B B B + /// row 1: W W W W B B B B (identical to row 0 -> both changing elements at V0) + /// row 2: W W W B B B B B (black run starts 1 pixel earlier -> VL1) + /// row 3: W W W W W B B B (black run starts 2 pixels later -> VR2) + /// + /// + /// + /// Hand-derived bit trace (reference line for row 0 is the imaginary all-white line, + /// so b1/b2 both default to columns = 8): + /// + /// + /// Row 0 (changing element at 4): a0=-1, white. + /// b1=8, b2=8 (empty reference). a1=4 (the only change). Since + /// a1(4) <= b2(8), not Pass. delta = a1 - b1 = 4 - 8 = -4, outside + /// [-3, 3], so Horizontal: run1 (white) = 4 - 0 = 4 -> code + /// 1011 (length 4); run2 (black) = 8 - 4 = 4 -> code 011 + /// (length 3). Bits: Horizontal 001 + white-4 1011 + black-4 011 + /// = 0011011011 (10 bits). a0 becomes 8, row done. + /// + /// + /// Row 1 (reference = row 0's changes [4, 8]): first element: + /// a0=-1, white, b1=4, b2=8; a1=4; delta=4-4=0 -> V0 + /// (bit 1), a0=4, color -> black. Second element: b1=8, b2=8 + /// (no further reference change past a0=4 except the sentinel at 8); + /// a1=8 (no further row-1 change, i.e. black runs to the end); + /// delta=8-8=0 -> V0 (bit 1), a0=8, row done. Bits: 11 + /// (2 bits). + /// + /// + /// Running total so far: 0011011011 + 11 = 001101101111 (12 + /// bits); the first byte (00110110) = 0x36. + /// + /// + /// Row 2 (reference = row 1's changes [4, 8], changing + /// element at 3): a0=-1, white, b1=4, b2=8; a1=3; + /// delta=3-4=-1 -> VL1 (code 010, length 3), a0=3, color -> + /// black. Second element: reference index advances past b1=4<=a0(3)? No + /// (4 > 3), so b1 search continues from the same reference index but the + /// color now matches (black at index 0 already consumed as b1 above) - working + /// through 's own FindB1B2 logic by hand gives + /// b1=8, b2=8; a1=8 (no further row-2 change); + /// delta=8-8=0 -> V0 (bit 1), a0=8, row done. Bits: + /// 010 + 1 = 0101 (4 bits). + /// + /// + /// Running total: 12 + 4 = 16 bits; bits 8-15 (11110101) = 0xF5 (the + /// second byte). + /// + /// + /// Row 3 (reference = row 2's changes [3, 8], changing + /// element at 5): a0=-1, white, b1=3, b2=8; a1=5; + /// delta=5-3=2 -> VR2 (code 000011, length 6), a0=5, color + /// -> black. Second element: b1=8, b2=8; a1=8; delta=0 -> + /// V0 (bit 1), a0=8, row done. Bits: 000011 + 1 = + /// 0000111 (7 bits). + /// + /// + /// Running total: 16 + 7 = 23 bits, zero-padded to 24: the final 8 bits are + /// 00001110 = 0x0E. Final expected encoded bytes: + /// 0x36 0xF5 0x0E - exactly matching both this hand trace and the + /// independent Python prototype's output (confidence: high - every single bit of + /// all 23 was traced by hand above, not merely asserted). + /// + /// + [Fact] + public void PdfDocument_Images_DoOperator_CcittFaxGroup4_PlacesExpectedPixels() + { + // Arrange + var pixels = new bool[8, 4]; + for (var y = 0; y < 4; y++) + { + for (var x = 0; x < 8; x++) + { + pixels[x, y] = y switch + { + 0 or 1 => x >= 4, + 2 => x >= 3, + _ => x >= 5, + }; + } + } + + var encoded = EncodeCcittGroup4ForTest(pixels); + + // Assert the hand-derived test vector itself, before even reaching the decoder. + Assert.Equal([0x36, 0xF5, 0x0E], encoded); + + // Act + using var surface = RenderCcittFaxImage(8, 4, encoded); + + // Assert: white -> (255,255,255,255); black -> (0,0,0,255) under the default /BlackIs1. + var white = new Canvas.Rgba32(255, 255, 255, 255); + var black = new Canvas.Rgba32(0, 0, 0, 255); + Assert.Equal(white, surface[0, 0]); + Assert.Equal(black, surface[4, 0]); + Assert.Equal(white, surface[0, 1]); + Assert.Equal(black, surface[4, 1]); + Assert.Equal(white, surface[2, 2]); + Assert.Equal(black, surface[3, 2]); + Assert.Equal(white, surface[4, 3]); + Assert.Equal(black, surface[5, 3]); + } + + /// Proves that the default (absent) /BlackIs1 maps a physically black pixel to packed bit 0 (and a decoded gray value of 0). + [Fact] + public void PdfDocument_Images_DoOperator_CcittFaxBlackIs1Default_MapsZeroBitToBlack() + { + // Arrange: same 8x4 pattern/encoding as the hand-verified test above. + var pixels = new bool[8, 4]; + for (var y = 0; y < 4; y++) + { + for (var x = 0; x < 8; x++) + { + pixels[x, y] = x >= 4; + } + } + + var encoded = EncodeCcittGroup4ForTest(pixels); + + // Act + using var surface = RenderCcittFaxImage(8, 4, encoded); + + // Assert: default /BlackIs1 (absent -> false) -> black pixel -> gray 0. + Assert.Equal(new Canvas.Rgba32(255, 255, 255, 255), surface[0, 0]); + Assert.Equal(new Canvas.Rgba32(0, 0, 0, 255), surface[4, 0]); + } + + /// Proves that /BlackIs1 true inverts the polarity of the identical encoded bit stream relative to the default. + [Fact] + public void PdfDocument_Images_DoOperator_CcittFaxBlackIs1True_InvertsPolarity() + { + // Arrange: the exact same encoded bytes as the Default test above. + var pixels = new bool[8, 4]; + for (var y = 0; y < 4; y++) + { + for (var x = 0; x < 8; x++) + { + pixels[x, y] = x >= 4; + } + } + + var encoded = EncodeCcittGroup4ForTest(pixels); + + // Act + using var surface = RenderCcittFaxImage(8, 4, encoded, " /BlackIs1 true"); + + // Assert: polarity inverted relative to the default-/BlackIs1 test - the physically black + // run (x >= 4) now decodes to gray 255 (white), and the physically white run to gray 0. + Assert.Equal(new Canvas.Rgba32(0, 0, 0, 255), surface[0, 0]); + Assert.Equal(new Canvas.Rgba32(255, 255, 255, 255), surface[4, 0]); + } + + /// Proves that non-byte-aligned /Columns values (not a multiple of 8) still decode every row's correct pixel colors, with no padding-bit leakage. + [Theory] + [InlineData(10)] + [InlineData(20)] + public void PdfDocument_Images_DoOperator_CcittFaxNonByteAlignedColumns_DecodesExpectedRowPadding(int columns) + { + // Arrange: 3 rows, each with a single black run covering the row's final 3 columns (and + // shifted by 1 column between rows, to force real 2D vertical-mode coding rather than + // every row trivially matching its reference) - deliberately placed across the + // not-a-multiple-of-8 /Columns boundary so a padding-bit bug would corrupt either this + // run or the next row's leading pixels. + var pixels = new bool[columns, 3]; + for (var y = 0; y < 3; y++) + { + var blackStart = columns - 3 - y; + for (var x = 0; x < columns; x++) + { + pixels[x, y] = x >= blackStart; + } + } + + var encoded = EncodeCcittGroup4ForTest(pixels); + + // Act + using var surface = RenderCcittFaxImage(columns, 3, encoded); + + // Assert: every column of every row matches the source pattern exactly. + var white = new Canvas.Rgba32(255, 255, 255, 255); + var black = new Canvas.Rgba32(0, 0, 0, 255); + for (var y = 0; y < 3; y++) + { + var blackStart = columns - 3 - y; + for (var x = 0; x < columns; x++) + { + Assert.Equal(x >= blackStart ? black : white, surface[x, y]); + } + } + } + + /// + /// Proves that Do decodes a Group 4 (T.6 MMR) CCITTFaxDecode image XObject + /// whose second row decodes via a Pass mode element, and places the expected + /// black/white pixels at specific (x, y) coordinates. + /// + /// + /// + /// None of this file's other CCITTFaxDecode tests happen to force a Pass mode + /// element (ITU-T T.6's "the current run extends past the reference line's b2 + /// changing element without itself changing color" case), so this test uses a + /// dedicated hand-derived literal byte array (not + /// - that test-only encoder's own output for this exact pattern was independently + /// cross-checked against the hand trace below and happens to match, but the literal + /// bytes are asserted directly so this test's evidence does not depend on the test + /// encoder's own correctness). + /// + /// + /// Pattern (W=white, B=black), 8 columns x 2 rows: + /// + /// row 0: W W B B W W W W (changing elements at x=2, x=4) + /// row 1: W W W W W W W W (no changing element - all white) + /// + /// + /// + /// Row 0 (reference line empty, i.e. b1=b2=8): first element: + /// a0=-1, white; a1=2; delta = a1 - b1 = 2 - 8 = -6, outside + /// [-3, 3] -> Horizontal: run1 (white) = 2 -> code 0111 + /// (length 4); run2 (black) = 4 - 2 = 2 -> code 11 (length 2). Bits: + /// Horizontal 001 + white-2 0111 + black-2 11 = 0010111 11 + /// (9 bits), a0 becomes 4. Second element: b1=b2=8 (reference exhausted); + /// a1=8 (sentinel); delta=0 -> V0 (bit 1), a0=8, row + /// done. Row 0 bits: 0010111111 (10 bits). + /// + /// + /// Row 1 (reference [2, 4]): first element: a0=-1, + /// white; FindB1B2([2,4], a0=-1, isBlack=false) walks idx from 0 (no + /// reference element <= -1); the color at idx=0 is black (even index + /// = black, per FindB1B2's own color-parity convention), which already differs + /// from the current white, so idx is not incremented: + /// b1 = referenceChanges[0] = 2, b2 = referenceChanges[1] = 4. Row 1 has + /// no changing element at all, so its next change (conceptually at columns, + /// i.e. 8) is strictly greater than b2 = 4 - exactly ITU-T T.6's Pass condition + /// - so Pass is coded (0001, 4 bits), advancing a0 to + /// b2 = 4 without emitting any changing element. Second element: b1=b2=8 + /// (no further reference elements remain past index 2); a1=8; delta=0 + /// -> V0 (bit 1), a0=8, row done. Row 1 bits: 00011 (5 bits). + /// + /// + /// Row 0 (10 bits) + row 1 (5 bits) = 15 bits, zero-padded to 16: 0010111111000110 + /// -> bytes 0x2F, 0xC6. This was independently re-verified by stepping a + /// from-scratch Python re-implementation of 's own + /// ReadMode/FindB1B2/DecodeCcittRow logic through these exact + /// bytes, which printed each decoded mode in turn and confirmed the second row's + /// very first coding element is read as Pass before producing the expected + /// [2, 4] / all-white rows. + /// + /// + [Fact] + public void PdfDocument_Images_DoOperator_CcittFaxPassModeElement_PlacesExpectedPixels() + { + // Arrange + var pixels = new bool[8, 2]; + for (var x = 0; x < 8; x++) + { + pixels[x, 0] = x is 2 or 3; + pixels[x, 1] = false; + } + + var encoded = EncodeCcittGroup4ForTest(pixels); + + // Assert the hand-derived test vector itself, before even reaching the decoder. + Assert.Equal([0x2F, 0xC6], encoded); + + // Act + using var surface = RenderCcittFaxImage(8, 2, encoded); + + // Assert: row 0 is W W B B W W W W; row 1 (decoded via a Pass mode element) is all white. + var white = new Canvas.Rgba32(255, 255, 255, 255); + var black = new Canvas.Rgba32(0, 0, 0, 255); + Assert.Equal(white, surface[0, 0]); + Assert.Equal(white, surface[1, 0]); + Assert.Equal(black, surface[2, 0]); + Assert.Equal(black, surface[3, 0]); + Assert.Equal(white, surface[4, 0]); + Assert.Equal(white, surface[7, 0]); + Assert.Equal(white, surface[0, 1]); + Assert.Equal(white, surface[3, 1]); + Assert.Equal(white, surface[7, 1]); + } + + /// + /// Proves that CCITTFaxDecode with /EncodedByteAlign true actually invokes + /// AlignToByte before each row (not merely happens to work by coincidence): the + /// second row's leading padding bits after row 0 are deliberately non-zero garbage, which + /// would corrupt row 1's decode if AlignToByte were not called (or were a no-op + /// bug) - the test still recovers the exact same expected pixels as + /// , + /// proving the garbage bits were correctly skipped rather than misinterpreted as row 1 + /// data. + /// + /// + /// + /// Uses the same 8x2 pattern as the Pass-mode test above, but with /Rows + /// byte-aligned independently rather than packed back-to-back: row 0's 10 bits + /// (0010111111) occupy byte 0 (00101111 = 0x2F) plus 2 bits into + /// byte 1. Byte 1's remaining 6 bits are deliberately set to non-zero garbage + /// (111111, not the zero padding a decoder might coincidentally tolerate) - + /// byte 1 = 11111111 = 0xFF. Row 1's own 5 bits (00011) plus 3 + /// zero pad bits then form byte 2: 00011000 = 0x18. Final bytes: + /// 0x2F, 0xFF, 0x18. This literal byte array (not produced by + /// , since that test-only helper has no + /// per-row byte-alignment/padding concept) was independently re-verified by stepping + /// the same from-scratch Python re-implementation referenced above through these + /// exact bytes with byte-alignment applied before each row, which confirmed byte 1 is + /// skipped in its entirety (regardless of its garbage content) and row 1 still decodes + /// to the expected all-white, Pass-mode-driven result. + /// + /// + [Fact] + public void PdfDocument_Images_DoOperator_CcittFaxEncodedByteAlignTrue_SkipsRowPaddingBits() + { + // Arrange: hand-derived bytes - see remarks above for the full bit-level derivation. + byte[] encoded = [0x2F, 0xFF, 0x18]; + + // Act + using var surface = RenderCcittFaxImage(8, 2, encoded, " /EncodedByteAlign true"); + + // Assert: identical expected pixels to the Pass-mode test above. + var white = new Canvas.Rgba32(255, 255, 255, 255); + var black = new Canvas.Rgba32(0, 0, 0, 255); + Assert.Equal(white, surface[0, 0]); + Assert.Equal(white, surface[1, 0]); + Assert.Equal(black, surface[2, 0]); + Assert.Equal(black, surface[3, 0]); + Assert.Equal(white, surface[4, 0]); + Assert.Equal(white, surface[7, 0]); + Assert.Equal(white, surface[0, 1]); + Assert.Equal(white, surface[3, 1]); + Assert.Equal(white, surface[7, 1]); + } + + /// Proves that CCITTFaxDecode with a non-negative /K (Group 3) throws with a clear message. + [Fact] + public void PdfDocument_Images_CcittFaxGroup3K_ThrowsUnsupportedImageFeatureException() + { + // Arrange + var imageStream = BuildStreamObjectBody( + "/Type /XObject /Subtype /Image /Width 8 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8 " + + "/Filter /CCITTFaxDecode /DecodeParms << /K 0 /Columns 8 /Rows 1 >>", + [0x00]); + + var bytes = BuildSinglePagePdfWithResources( + 100, + 100, + "100 0 0 100 0 0 cm /Im0 Do", + "/XObject << /Im0 5 0 R >>", + [imageStream]); + + // Act & Assert + var exception = Assert.Throws(() => RenderPdfBytes(bytes)); + Assert.Contains("Group 3", exception.Message, StringComparison.Ordinal); + } + + /// + /// Proves that CCITTFaxDecode with an oversized /Columns//Rows is + /// rejected with before DecodeCcittFax ever + /// allocates its internal output buffer sized by those attacker-controlled values - the + /// bounds-check must run before the decode call, not merely before the later + /// new Surface(...) call. + /// + [Fact] + public void PdfDocument_Images_CcittFaxOversizedDimensions_ThrowsInvalidDataExceptionBeforeDecoding() + { + // Arrange: /Columns and /Rows both declare a value far beyond Surface.MaxDimension; the + // stream bytes themselves can be tiny/garbage since the decoder must never be reached. + var imageStream = BuildStreamObjectBody( + "/Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8 " + + "/Filter /CCITTFaxDecode /DecodeParms << /K -1 /Columns 1000000 /Rows 1000000 >>", + [0x00]); + + var bytes = BuildSinglePagePdfWithResources( + 100, + 100, + "100 0 0 100 0 0 cm /Im0 Do", + "/XObject << /Im0 5 0 R >>", + [imageStream]); + + // Act & Assert + var exception = Assert.Throws(() => RenderPdfBytes(bytes)); + Assert.Contains("exceed the maximum supported size", exception.Message, StringComparison.Ordinal); + } + + /// + /// Proves that CCITTFaxDecode with /Columns 0 is rejected with + /// before DecodeCcittFax ever allocates its + /// internal output buffer (sized rowBytes * rows) using that invalid dimension. + /// + [Fact] + public void PdfDocument_Images_CcittFaxZeroColumns_ThrowsInvalidDataException() + { + // Arrange: /Columns 0 is neither oversized nor negative, but still an invalid dimension + // that must be rejected before DecodeCcittFax's buffer allocation and row-decoding loop. + var imageStream = BuildStreamObjectBody( + "/Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8 " + + "/Filter /CCITTFaxDecode /DecodeParms << /K -1 /Columns 0 /Rows 1 >>", + [0x00]); + + var bytes = BuildSinglePagePdfWithResources( + 100, + 100, + "100 0 0 100 0 0 cm /Im0 Do", + "/XObject << /Im0 5 0 R >>", + [imageStream]); + + // Act & Assert + Assert.Throws(() => RenderPdfBytes(bytes)); + } + + /// + /// Proves that CCITTFaxDecode with a negative /Columns is rejected with + /// before DecodeCcittFax ever allocates its + /// internal output buffer using that invalid dimension. (A negative /Rows cannot + /// reach this check as a negative value: unlike /Columns, an absent-or-non-positive + /// /Rows falls back to the already-validated-positive /Height before this + /// check runs, so only /Columns can carry a crafted negative value this far.) + /// + [Fact] + public void PdfDocument_Images_CcittFaxNegativeColumns_ThrowsInvalidDataException() + { + // Arrange: a negative /Columns value reaches this check unchanged via GetIntEntry. + var imageStream = BuildStreamObjectBody( + "/Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8 " + + "/Filter /CCITTFaxDecode /DecodeParms << /K -1 /Columns -1 /Rows 1 >>", + [0x00]); + + var bytes = BuildSinglePagePdfWithResources( + 100, + 100, + "100 0 0 100 0 0 cm /Im0 Do", + "/XObject << /Im0 5 0 R >>", + [imageStream]); + + // Act & Assert + Assert.Throws(() => RenderPdfBytes(bytes)); + } + + /// Proves that CCITTFaxDecode combined with another filter throws . + [Fact] + public void PdfDocument_Images_CcittFaxCombinedWithAnotherFilter_ThrowsInvalidDataException() + { + // Arrange + var imageStream = BuildStreamObjectBody( + "/Type /XObject /Subtype /Image /Width 8 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8 " + + "/Filter [/FlateDecode /CCITTFaxDecode] /DecodeParms [null << /K -1 /Columns 8 /Rows 1 >>]", + ZlibCompress([0x00])); + + var bytes = BuildSinglePagePdfWithResources( + 100, + 100, + "100 0 0 100 0 0 cm /Im0 Do", + "/XObject << /Im0 5 0 R >>", + [imageStream]); + + // Act & Assert + Assert.Throws(() => RenderPdfBytes(bytes)); + } + + /// Proves that CCITTFaxDecode with /EndOfLine true throws . + [Fact] + public void PdfDocument_Images_CcittFaxEndOfLineTrue_ThrowsUnsupportedImageFeatureException() + { + // Arrange + var pixels = new bool[8, 1]; + for (var x = 0; x < 8; x++) + { + pixels[x, 0] = x >= 4; + } + + var encoded = EncodeCcittGroup4ForTest(pixels); + + // Act & Assert + var exception = Assert.Throws( + () => RenderCcittFaxImage(8, 1, encoded, " /EndOfLine true")); + Assert.Contains("EndOfLine", exception.Message, StringComparison.Ordinal); + } + + /// Proves that an unsupported /BitsPerComponent throws . + [Fact] + public void PdfDocument_Images_UnsupportedBitsPerComponent_ThrowsUnsupportedImageFeatureException() + { + // Arrange + var imageStream = BuildStreamObjectBody( + "/Type /XObject /Subtype /Image /Width 2 /Height 2 /ColorSpace /DeviceGray /BitsPerComponent 1 /Filter /FlateDecode", + ZlibCompress([0x00])); + + var bytes = BuildSinglePagePdfWithResources( + 100, + 100, + "100 0 0 100 0 0 cm /Im0 Do", + "/XObject << /Im0 5 0 R >>", + [imageStream]); + + // Act & Assert + Assert.Throws(() => RenderPdfBytes(bytes)); + } + + /// Proves that an image XObject with an unsupported /ColorSpace throws . + [Fact] + public void PdfDocument_Images_UnsupportedColorSpace_ThrowsUnsupportedImageFeatureException() + { + // Arrange: /Lab remains unsupported (unlike /CalRGB - see + // PdfDocument_Color_CalRgbColorSpace_ResolvesToDeviceRgb). + var imageStream = BuildStreamObjectBody( + "/Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace [/Lab << >>] /BitsPerComponent 8 /Filter /FlateDecode", + ZlibCompress([0x00])); + + var bytes = BuildSinglePagePdfWithResources( + 100, + 100, + "100 0 0 100 0 0 cm /Im0 Do", + "/XObject << /Im0 5 0 R >>", + [imageStream]); + + // Act & Assert + Assert.Throws(() => RenderPdfBytes(bytes)); + } + + /// Proves that Do with a name not declared in /Resources/XObject throws . + [Fact] + public void PdfDocument_Images_DoOperator_UndefinedXObjectName_ThrowsInvalidDataException() + { + // Arrange + var bytes = BuildSinglePagePdfWithResources( + 100, + 100, + "/NotDeclared Do", + "/XObject << >>", + []); + + // Act & Assert + Assert.Throws(() => RenderPdfBytes(bytes)); + } + + /// Proves that Do on a /Subtype /Form XObject executes its nested content stream, painting at the expected device pixel and leaving pixels outside the painted rectangle untouched. + [Fact] + public void PdfDocument_Images_DoOperator_FormXObject_PaintsNestedContentStream() + { + // Arrange: a Form whose own content stream fills a centered rectangle. + var formStream = BuildStreamObjectBody( + "/Type /XObject /Subtype /Form /BBox [0 0 100 100]", + "10 10 80 80 re f"u8.ToArray()); + + var bytes = BuildSinglePagePdfWithResources( + 100, + 100, + "/Fm0 Do", + "/XObject << /Fm0 5 0 R >>", + [formStream]); + + // Act + using var surface = RenderPdfBytes(bytes); + + // Assert: the rectangle's device footprint is painted; outside it is left blank. + Assert.Equal(Black, surface[50, 50]); + Assert.Equal(default, surface[5, 5]); + } + + /// Proves that a Form XObject's own /Matrix is concatenated into the CTM using the same left-multiply convention as cm, before the CTM in effect when Do was invoked. + [Fact] + public void PdfDocument_Images_DoOperator_FormXObjectWithMatrix_AppliesMatrixToNestedContent() + { + // Arrange: the Form's own /Matrix translates its content by (40, 40) user-space units. + var formStream = BuildStreamObjectBody( + "/Type /XObject /Subtype /Form /BBox [0 0 100 100] /Matrix [1 0 0 1 40 40]", + "0 0 10 10 re f"u8.ToArray()); + + var bytes = BuildSinglePagePdfWithResources( + 100, + 100, + "/Fm0 Do", + "/XObject << /Fm0 5 0 R >>", + [formStream]); + + // Act + using var surface = RenderPdfBytes(bytes); + + // Assert: the translated rectangle (device x:40-50, y:50-60) is painted; the + // un-translated rectangle's device position (x:0-10, y:90-100) is left blank, proving + // the /Matrix was actually applied (not silently ignored). + Assert.Equal(Black, surface[45, 55]); + Assert.Equal(default, surface[5, 95]); + } + + /// Proves that a Form XObject with no /Resources entry of its own falls back to (and successfully resolves against) the invoking content stream's /Resources. + [Fact] + public void PdfDocument_Images_DoOperator_FormXObjectWithoutOwnResources_FallsBackToInvokingResources() + { + // Arrange: the Form declares no /Resources of its own, so its "/CS1 cs" must resolve + // against the invoking page's /Resources/ColorSpace. + var formStream = BuildStreamObjectBody( + "/Type /XObject /Subtype /Form /BBox [0 0 100 100]", + "/CS1 cs 1 0 0 sc 10 10 80 80 re f"u8.ToArray()); + + var bytes = BuildSinglePagePdfWithResources( + 100, + 100, + "/Fm0 Do", + "/ColorSpace << /CS1 /DeviceRGB >> /XObject << /Fm0 5 0 R >>", + [formStream]); + + // Act + using var surface = RenderPdfBytes(bytes); + + // Assert: /CS1 resolved to DeviceRGB (3 operands accepted by "sc"), painting red. + Assert.Equal(new Canvas.Rgba32(255, 0, 0, 255), surface[50, 50]); + } + + /// Proves that a Form XObject's own /Resources takes precedence over the invoking content stream's /Resources when both declare the same color-space name. + [Fact] + public void PdfDocument_Images_DoOperator_FormXObjectWithOwnResources_TakesPrecedenceOverInvokingResources() + { + // Arrange: the page declares /CS1 as DeviceGray (1 component); the Form declares its own + // /CS1 as DeviceRGB (3 components). "/CS1 cs 1 0 0 sc" supplies 3 numeric operands - + // this only succeeds (rather than throwing for a wrong operand count) if the Form's own + // /Resources definition is consulted, not the page's. + var formStream = BuildStreamObjectBody( + "/Type /XObject /Subtype /Form /BBox [0 0 100 100] /Resources << /ColorSpace << /CS1 /DeviceRGB >> >>", + "/CS1 cs 1 0 0 sc 10 10 80 80 re f"u8.ToArray()); + + var bytes = BuildSinglePagePdfWithResources( + 100, + 100, + "/Fm0 Do", + "/ColorSpace << /CS1 /DeviceGray >> /XObject << /Fm0 5 0 R >>", + [formStream]); + + // Act + using var surface = RenderPdfBytes(bytes); + + // Assert + Assert.Equal(new Canvas.Rgba32(255, 0, 0, 255), surface[50, 50]); + } + + /// Proves that a two-level-deep nested Form XObject paints at the correctly compounded transform, and that the invoking content stream's own painting after Do returns is unaffected by the nested Forms' CTM mutations. + [Fact] + public void PdfDocument_Images_DoOperator_NestedFormXObjects_RestoresGraphicsStateAfterReturn() + { + // Arrange: the page invokes an outer Form (/Fm0), which applies "2 0 0 2 0 0 cm" and + // invokes an inner Form (/Fm1), which paints a rectangle in its own content stream; the + // page then paints its own rectangle after /Fm0's Do returns. + var outerFormStream = BuildStreamObjectBody( + "/Type /XObject /Subtype /Form /BBox [0 0 100 100]", + "2 0 0 2 0 0 cm /Fm1 Do"u8.ToArray()); + var innerFormStream = BuildStreamObjectBody( + "/Type /XObject /Subtype /Form /BBox [0 0 100 100]", + "5 5 10 10 re f"u8.ToArray()); + + var bytes = BuildSinglePagePdfWithResources( + 100, + 100, + "/Fm0 Do 40 10 5 5 re f", + "/XObject << /Fm0 5 0 R /Fm1 6 0 R >>", + [outerFormStream, innerFormStream]); + + // Act + using var surface = RenderPdfBytes(bytes); + + // Assert: the inner Form's rectangle, scaled 2x by the outer Form's "cm", lands at + // device (10,70)-(30,90) - its center is painted. + Assert.Equal(Black, surface[20, 80]); + + // Assert: the page's own rectangle (painted after /Fm0's Do returns) lands at its own + // normal, unscaled device position (40,85)-(45,90) - proving the outer Form's "cm" did + // not leak back out into the invoking stream's graphics state. + Assert.Equal(Black, surface[42, 87]); + + // Assert: the device position the page's rectangle would occupy if the 2x scale had + // leaked ((80,70)-(90,80)) is left blank. + Assert.Equal(default, surface[85, 75]); + } + + /// Proves that a Form XObject nesting beyond the maximum supported depth throws rather than hanging or crashing the process. + [Fact] + public void PdfDocument_Images_DoOperator_FormXObjectExceedsMaxRecursionDepth_ThrowsInvalidDataException() + { + // Arrange: a Form XObject that invokes itself by name, falling back to the invoking + // page's /Resources/XObject (which declares it). + var selfReferencingFormStream = BuildStreamObjectBody( + "/Type /XObject /Subtype /Form /BBox [0 0 100 100]", + "/FmSelf Do"u8.ToArray()); + + var bytes = BuildSinglePagePdfWithResources( + 100, + 100, + "/FmSelf Do", + "/XObject << /FmSelf 5 0 R >>", + [selfReferencingFormStream]); + + // Act & Assert + Assert.Throws(() => RenderPdfBytes(bytes)); + } + + /// Proves that an image XObject declaring /Width//Height beyond throws instead of allocating an unbounded decode buffer. + [Fact] + public void PdfDocument_Images_DoOperator_WidthExceedsMaxDimension_ThrowsInvalidDataException() + { + // Arrange: /Width is declared far beyond Surface.MaxDimension; the stream itself can be + // empty/tiny, since the dimension guard must fire before any pixel data is read. + var imageStream = BuildStreamObjectBody( + "/Type /XObject /Subtype /Image /Width 100000000 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8", + []); + + var bytes = BuildSinglePagePdfWithResources( + 100, + 100, + "/Im0 Do", + "/XObject << /Im0 5 0 R >>", + [imageStream]); + + // Act & Assert + Assert.Throws(() => RenderPdfBytes(bytes)); + } + + /// Proves that an image XObject whose decoded sample data is shorter than its declared /Width//Height//ColorSpace require (a truncated stream) throws instead of a raw . + [Fact] + public void PdfDocument_Images_DoOperator_TruncatedSampleData_ThrowsInvalidDataException() + { + // Arrange: a 2x2 DeviceRGB image (needs 12 raw bytes) whose uncompressed stream supplies + // only a single byte. + var imageStream = BuildStreamObjectBody( + "/Type /XObject /Subtype /Image /Width 2 /Height 2 /ColorSpace /DeviceRGB /BitsPerComponent 8", + [0]); + + var bytes = BuildSinglePagePdfWithResources( + 100, + 100, + "100 0 0 100 0 0 cm /Im0 Do", + "/XObject << /Im0 5 0 R >>", + [imageStream]); + + // Act & Assert + Assert.Throws(() => RenderPdfBytes(bytes)); + } + + /// Proves that Do with a malformed (non-1, non-name) operand count/type throws . + [Theory] + [InlineData("Do")] + [InlineData("/Im0 /Im1 Do")] + [InlineData("1 Do")] + public void PdfDocument_Images_DoOperator_MalformedOperandCount_ThrowsInvalidDataException(string content) + { + Assert.Throws(() => RenderContent(content)); + } + + + #endregion + + #region Fonts + + /// + /// Proves that a non-TrueType/non-Type1/non-Type0/non-Type3 + /// simple font /Subtype throws + /// rather than being silently substituted (/Type1 itself is no longer universally + /// rejected - see the new PdfDocument_Fonts_Type1_* tests below; /Type3 is + /// likewise no longer rejected - see the new PdfDocument_Fonts_Type3_* tests + /// below). + /// + [Theory] + [InlineData("MMType1")] + public void PdfDocument_Fonts_UnsupportedSubtype_ThrowsUnsupportedImageFeatureException(string subtype) + { + // Arrange + var fontFileObj = BuildStreamObjectBody(string.Empty, BuildEmbeddedFontBytes([(65, 1)])); + var descriptorObj = "<< /Type /FontDescriptor /FontFile2 7 0 R >>"u8.ToArray(); + var fontDictObj = System.Text.Encoding.ASCII.GetBytes( + $"<< /Type /Font /Subtype /{subtype} /BaseFont /Test /FontDescriptor 6 0 R >>"); + + var bytes = BuildSinglePagePdfWithResources( + 100, 100, "BT /F1 20 Tf (A) Tj ET", "/Font << /F1 5 0 R >>", + [fontDictObj, descriptorObj, fontFileObj]); + + // Act & Assert + Assert.Throws(() => RenderPdfBytes(bytes)); + } + + /// + /// Proves that a Standard-14 simple /Subtype /TrueType font (/BaseFont /Helvetica) + /// with no embedded /FontFile2 resolves via + /// (a matching system font, or the bundled Liberation Sans fallback) rather than throwing, + /// and paints real visible glyph ink. + /// + [Fact] + public void PdfDocument_BuildResolvedFont_Standard14NoEmbeddedFont_ResolvesViaFallback() + { + // Arrange: /Helvetica, no /FontFile2 - Standard-14, sans-serif, non-bold, non-italic + var descriptorObj = "<< /Type /FontDescriptor >>"u8.ToArray(); + var fontDictObj = + "<< /Type /Font /Subtype /TrueType /BaseFont /Helvetica /FirstChar 65 /LastChar 65 /Widths [600] /FontDescriptor 6 0 R >>"u8.ToArray(); + + var bytes = BuildSinglePagePdfWithResources( + 100, 100, "BT /F1 40 Tf 10 30 Td (A) Tj ET", "/Font << /F1 5 0 R >>", + [fontDictObj, descriptorObj]); + + // Act + using var surface = RenderPdfBytes(bytes); + + // Assert: no exception, and some glyph ink was actually painted somewhere on the canvas + var paintedAnyPixel = false; + for (var y = 0; y < surface.Height && !paintedAnyPixel; y++) + { + for (var x = 0; x < surface.Width; x++) + { + if (surface[x, y].A > 0) + { + paintedAnyPixel = true; + break; + } + } + } + + Assert.True(paintedAnyPixel, "Expected the fallback-resolved font to paint at least one visible pixel."); + } + + /// + /// Proves that a non-Standard-14 simple /Subtype /TrueType font with no embedded + /// /FontFile2, no /FontDescriptor flags, and a /BaseFont family name + /// unlikely to be installed on any host resolves via the bundled Liberation Sans fallback + /// () rather than throwing. + /// + [Fact] + public void PdfDocument_BuildResolvedFont_NonStandard14FlagsOnlyUnmatchedFamily_ResolvesViaBundledFallback() + { + // Arrange: an unmatched family name, no descriptor flags at all + var descriptorObj = "<< /Type /FontDescriptor >>"u8.ToArray(); + var fontDictObj = + "<< /Type /Font /Subtype /TrueType /BaseFont /TotallyUnlikelyFontFamilyXyzzy /FirstChar 65 /LastChar 65 /Widths [600] /FontDescriptor 6 0 R >>"u8.ToArray(); + + var bytes = BuildSinglePagePdfWithResources( + 100, 100, "BT /F1 40 Tf 10 30 Td (A) Tj ET", "/Font << /F1 5 0 R >>", + [fontDictObj, descriptorObj]); + + // Act & Assert: renders without throwing, using the bundled Liberation Sans fallback + using var surface = RenderPdfBytes(bytes); + Assert.NotNull(surface); + } + + /// + /// Proves that an embedded /FontFile2 always takes priority over fallback + /// substitution: even though the /BaseFont name is entirely unmatched, the embedded + /// synthetic font's own known glyph shape is what gets rendered. + /// + [Fact] + public void PdfDocument_BuildResolvedFont_EmbeddedFontFileTakesPriorityOverFallback() + { + // Arrange: an embedded font (mapping codepoint 65 to glyph 1's known square) + var fontBytes = BuildEmbeddedFontBytes([(65, 1)]); + var (resourcesBody, extraObjects) = BuildSimpleTrueTypeFontResources(fontBytes); + + var bytes = BuildSinglePagePdfWithResources( + 100, 100, "BT /F1 20 Tf 5 50 Td (A) Tj ET", resourcesBody, extraObjects); + + // Act + using var surface = RenderPdfBytes(bytes); + + // Assert: the embedded synthetic font's own known square glyph shape was painted (not a + // fallback font's differently-shaped glyph) - matching every other embedded-font test's + // own expected-pixel convention (text x [7, 15), text y [52, 60) -> device y [40, 48)). + Assert.NotEqual(default, surface[10, 44]); + } + + /// + /// Proves that a /BaseFont /Symbol font with no embedded /FontFile2 no + /// longer fails closed: it resolves via the bundled Noto substitute font union (see + /// PdfDocument.FontFallback.cs's ResolveSymbolicNotoFallback) and renders + /// without throwing. + /// + [Fact] + public void PdfDocument_BuildResolvedFont_SymbolFont_ResolvesViaNotoSubstituteWithoutThrowing() + { + // Arrange + var descriptorObj = "<< /Type /FontDescriptor >>"u8.ToArray(); + var fontDictObj = "<< /Type /Font /Subtype /TrueType /BaseFont /Symbol /FontDescriptor 6 0 R >>"u8.ToArray(); + + var bytes = BuildSinglePagePdfWithResources( + 100, 100, "BT /F1 20 Tf (A) Tj ET", "/Font << /F1 5 0 R >>", + [fontDictObj, descriptorObj]); + + // Act & Assert: renders without throwing, using the bundled Noto substitute font union + using var surface = RenderPdfBytes(bytes); + Assert.NotNull(surface); + } + + /// + /// Proves that a /BaseFont /ZapfDingbats font with no embedded /FontFile2 + /// no longer fails closed: it resolves via the bundled + /// NotoSansSymbols2-Regular.ttf substitute font and renders without throwing. + /// + [Fact] + public void PdfDocument_BuildResolvedFont_ZapfDingbatsFont_ResolvesViaNotoSubstituteWithoutThrowing() + { + // Arrange + var descriptorObj = "<< /Type /FontDescriptor >>"u8.ToArray(); + var fontDictObj = "<< /Type /Font /Subtype /TrueType /BaseFont /ZapfDingbats /FontDescriptor 6 0 R >>"u8.ToArray(); + + var bytes = BuildSinglePagePdfWithResources( + 100, 100, "BT /F1 20 Tf (A) Tj ET", "/Font << /F1 5 0 R >>", + [fontDictObj, descriptorObj]); + + // Act & Assert: renders without throwing, using the bundled Noto substitute font + using var surface = RenderPdfBytes(bytes); + Assert.NotNull(surface); + } + + /// + /// Proves that a non-Standard-14 font whose /FontDescriptor/Flags declares the + /// Symbolic bit (bit 3, value 4) without also declaring Nonsymbolic (bit 6, + /// value 32) fails closed exactly like Symbol/ZapfDingbats, even though its + /// /BaseFont name is not one of those two reserved names. + /// + [Fact] + public void PdfDocument_BuildResolvedFont_SymbolicFlagWithoutEmbeddedFont_ThrowsSymbolicNotEmbeddedException() + { + // Arrange: Flags = 4 (Symbolic bit only, no Nonsymbolic bit) + var descriptorObj = "<< /Type /FontDescriptor /Flags 4 >>"u8.ToArray(); + var fontDictObj = "<< /Type /Font /Subtype /TrueType /BaseFont /SomeCustomSymbolFont /FontDescriptor 6 0 R >>"u8.ToArray(); + + var bytes = BuildSinglePagePdfWithResources( + 100, 100, "BT /F1 20 Tf (A) Tj ET", "/Font << /F1 5 0 R >>", + [fontDictObj, descriptorObj]); + + // Act & Assert + var ex = Assert.Throws(() => RenderPdfBytes(bytes)); + Assert.Equal("pdf-font-symbolic-not-embedded", ex.Feature); + } + + /// Proves that an unrecognized /Encoding base-encoding name throws . + [Fact] + public void PdfDocument_Fonts_UnrecognizedEncoding_ThrowsUnsupportedImageFeatureException() + { + // Arrange + var fontBytes = BuildEmbeddedFontBytes([(65, 1)]); + var (resourcesBody, extraObjects) = BuildSimpleTrueTypeFontResources( + fontBytes, fontDictExtra: "/FirstChar 65 /LastChar 66 /Widths [600 600] /Encoding /PDFDocEncoding"); + + var bytes = BuildSinglePagePdfWithResources(100, 100, "BT /F1 20 Tf (A) Tj ET", resourcesBody, extraObjects); + + // Act & Assert + Assert.Throws(() => RenderPdfBytes(bytes)); + } + + /// Proves that a Tf operator naming a font absent from /Resources/Font throws . + [Fact] + public void PdfDocument_Fonts_UndefinedFontName_ThrowsInvalidDataException() + { + // Arrange + var bytes = BuildSinglePagePdfWithResources(100, 100, "BT /NotDeclared 20 Tf (A) Tj ET", "/Font << >>", []); + + // Act & Assert + Assert.Throws(() => RenderPdfBytes(bytes)); + } + + /// Proves that a font with no /Encoding entry defaults to /WinAnsiEncoding: code 0xE0 maps to Unicode codepoint 224. + [Fact] + public void PdfDocument_Fonts_DefaultEncoding_IsWinAnsiEncoding() + { + // Arrange: the font's only mapped codepoint (224) is WinAnsiEncoding's mapping for code + // 0xE0 ('\340' octal) - MacRomanEncoding maps that same code to codepoint 8225 instead. + var fontBytes = BuildEmbeddedFontBytes([(224, 1)]); + var (resourcesBody, extraObjects) = BuildSimpleTrueTypeFontResources( + fontBytes, fontDictExtra: "/FirstChar 0 /LastChar 255 /Widths [" + string.Join(' ', Enumerable.Repeat(600, 256)) + "]"); + + var bytes = BuildSinglePagePdfWithResources( + 100, 100, "BT /F1 20 Tf 5 50 Td (\\340) Tj ET", resourcesBody, extraObjects); + + // Act + using var surface = RenderPdfBytes(bytes); + + // Assert: glyph 1's square is painted at the position code 0xE0 resolves to under WinAnsiEncoding. + Assert.NotEqual(default, surface[11, 44]); + } + + /// Proves that an explicit /Encoding /MacRomanEncoding resolves code 0xE0 to a different codepoint than /WinAnsiEncoding does. + [Fact] + public void PdfDocument_Fonts_MacRomanEncoding_DiffersFromWinAnsiEncoding() + { + // Arrange: same font as the WinAnsiEncoding test (only codepoint 224 mapped), but this + // font dictionary declares /MacRomanEncoding, under which code 0xE0 maps to codepoint + // 8225 (not 224) - so the code resolves to the unmapped .notdef glyph and paints nothing. + var fontBytes = BuildEmbeddedFontBytes([(224, 1)]); + var (resourcesBody, extraObjects) = BuildSimpleTrueTypeFontResources( + fontBytes, + fontDictExtra: "/FirstChar 0 /LastChar 255 /Widths [" + string.Join(' ', Enumerable.Repeat(600, 256)) + "] /Encoding /MacRomanEncoding"); + + var bytes = BuildSinglePagePdfWithResources( + 100, 100, "BT /F1 20 Tf 5 50 Td (\\340) Tj ET", resourcesBody, extraObjects); + + // Act + using var surface = RenderPdfBytes(bytes); + + // Assert: no glyph outline is painted anywhere in the expected region. + Assert.Equal(default, surface[11, 44]); + } + + /// Proves that an /Encoding/Differences override changes a code's mapped codepoint away from its base encoding's own mapping. + [Fact] + public void PdfDocument_Fonts_Differences_OverridesBaseEncodingCode() + { + // Arrange: the font's only mapped codepoint is 224 ('agrave'). Code 65 ('A') would + // ordinarily resolve (via the default WinAnsiEncoding) to codepoint 65, which the font + // has no glyph for - but /Differences remaps code 65 to the "agrave" glyph name (U+00E0). + var fontBytes = BuildEmbeddedFontBytes([(224, 1)]); + var (resourcesBody, extraObjects) = BuildSimpleTrueTypeFontResources( + fontBytes, + fontDictExtra: "/FirstChar 65 /LastChar 66 /Widths [600 600] " + + "/Encoding << /Differences [65 /agrave] >>"); + + var bytes = BuildSinglePagePdfWithResources(100, 100, "BT /F1 20 Tf 5 50 Td (A) Tj ET", resourcesBody, extraObjects); + + // Act + using var surface = RenderPdfBytes(bytes); + + // Assert + Assert.NotEqual(default, surface[11, 44]); + } + + /// Proves that without the /Differences override, the same code paints nothing (the font has no glyph for its base-encoding codepoint). + [Fact] + public void PdfDocument_Fonts_Differences_Absent_LeavesBaseEncodingCodeUnmapped() + { + // Arrange + var fontBytes = BuildEmbeddedFontBytes([(224, 1)]); + var (resourcesBody, extraObjects) = BuildSimpleTrueTypeFontResources( + fontBytes, fontDictExtra: "/FirstChar 65 /LastChar 66 /Widths [600 600]"); + + var bytes = BuildSinglePagePdfWithResources(100, 100, "BT /F1 20 Tf 5 50 Td (A) Tj ET", resourcesBody, extraObjects); + + // Act + using var surface = RenderPdfBytes(bytes); + + // Assert + Assert.Equal(default, surface[11, 44]); + } + + /// + /// Proves that a /Differences array referencing an unrecognized glyph name is + /// tolerated rather than rejected: the affected code is simply left at whatever its base + /// encoding already assigned it (here, WinAnsiEncoding's own default mapping of code 65 to + /// U+0041), so the glyph still paints via the embedded font's own cmap + /// mapping for that unchanged codepoint. + /// + [Fact] + public void PdfDocument_Fonts_Differences_UnrecognizedGlyphName_FallsBackToBaseEncoding() + { + // Arrange + var fontBytes = BuildEmbeddedFontBytes([(65, 1)]); + var (resourcesBody, extraObjects) = BuildSimpleTrueTypeFontResources( + fontBytes, + fontDictExtra: "/FirstChar 65 /LastChar 66 /Widths [600 600] " + + "/Encoding << /Differences [65 /thisGlyphNameDoesNotExist] >>"); + + var bytes = BuildSinglePagePdfWithResources(100, 100, "BT /F1 20 Tf 5 50 Td (A) Tj ET", resourcesBody, extraObjects); + + // Act + using var surface = RenderPdfBytes(bytes); + + // Assert: no exception, and the glyph painted (base encoding's codepoint 65 mapping was + // preserved rather than cleared by the unrecognized override). + Assert.NotEqual(default, surface[11, 44]); + } + + /// + /// Proves that /Differences recognizes the ff/ffi/ffl Latin + /// ligature glyph names (found via a real-world pdfLaTeX Computer Modern font from + /// py-pdf/sample-files, which declares them in its own /Differences array) - + /// previously only fi/fl were present in StandardGlyphNames. + /// + [Fact] + public void PdfDocument_Fonts_Differences_FfFfiFflLigatureNames_DoNotThrow() + { + // Arrange + var fontBytes = BuildEmbeddedFontBytes([(65, 1)]); + var (resourcesBody, extraObjects) = BuildSimpleTrueTypeFontResources( + fontBytes, + fontDictExtra: "/FirstChar 65 /LastChar 68 /Widths [600 600 600 600] " + + "/Encoding << /Differences [65 /ff /ffi /ffl] >>"); + + var bytes = BuildSinglePagePdfWithResources(100, 100, "BT /F1 20 Tf (A) Tj ET", resourcesBody, extraObjects); + + // Act & Assert: no exception + using var surface = RenderPdfBytes(bytes); + Assert.NotNull(surface); + } + + /// + /// Proves that /Differences recognizes the nacute (Polish/Czech "ล„") glyph + /// name (found via the same real-world pdfLaTeX Computer Modern font from + /// py-pdf/sample-files) - a Latin Extended-A accented letter previously absent from + /// StandardGlyphNames. + /// + [Fact] + public void PdfDocument_Fonts_Differences_NacuteName_DoesNotThrow() + { + // Arrange + var fontBytes = BuildEmbeddedFontBytes([(65, 1)]); + var (resourcesBody, extraObjects) = BuildSimpleTrueTypeFontResources( + fontBytes, + fontDictExtra: "/FirstChar 65 /LastChar 65 /Widths [600] " + + "/Encoding << /Differences [65 /nacute] >>"); + + var bytes = BuildSinglePagePdfWithResources(100, 100, "BT /F1 20 Tf (A) Tj ET", resourcesBody, extraObjects); + + // Act & Assert: no exception + using var surface = RenderPdfBytes(bytes); + Assert.NotNull(surface); + } + + /// Proves that a /Differences array starting with a glyph name (before any starting code number) throws . + [Fact] + public void PdfDocument_Fonts_Differences_NameBeforeStartingCode_ThrowsInvalidDataException() + { + // Arrange + var fontBytes = BuildEmbeddedFontBytes([(65, 1)]); + var (resourcesBody, extraObjects) = BuildSimpleTrueTypeFontResources( + fontBytes, + fontDictExtra: "/FirstChar 65 /LastChar 66 /Widths [600 600] " + + "/Encoding << /Differences [/A 65 /B] >>"); + + var bytes = BuildSinglePagePdfWithResources(100, 100, "BT /F1 20 Tf (A) Tj ET", resourcesBody, extraObjects); + + // Act & Assert + Assert.Throws(() => RenderPdfBytes(bytes)); + } + + /// Proves that an explicit /Widths entry determines a glyph's advance width. + [Fact] + public void PdfDocument_Fonts_Widths_ExplicitEntry_DeterminesAdvance() + { + // Arrange: code 65's declared width is 1000 (a full em) -> advance = 1.0 * 20 = 20. + var fontBytes = BuildEmbeddedFontBytes([(65, 1), (66, 2)]); + var (resourcesBody, extraObjects) = BuildSimpleTrueTypeFontResources( + fontBytes, fontDictExtra: "/FirstChar 65 /LastChar 66 /Widths [1000 600]"); + + var bytes = BuildSinglePagePdfWithResources(100, 100, "BT /F1 20 Tf 5 5 Td (AB) Tj ET", resourcesBody, extraObjects); + + // Act + using var surface = RenderPdfBytes(bytes); + + // Assert: 'A' paints at text x [7, 15); 'B' starts at Tm.x = 5 + 20 = 25, painting at + // text x [27, 35) - clearly past the "no explicit width" 12-unit-advance gap boundary. + Assert.NotEqual(default, surface[11, 89]); + Assert.Equal(default, surface[16, 89]); + Assert.NotEqual(default, surface[31, 89]); + } + + /// Proves that /FontDescriptor/MissingWidth determines a code's advance width when it has no explicit /Widths entry. + [Fact] + public void PdfDocument_Fonts_Widths_MissingWidthFallback_DeterminesAdvance() + { + // Arrange: code 65 is outside [FirstChar, LastChar] (so has no explicit width), and + // /MissingWidth is 500 -> advance = 0.5 * 20 = 10. + var fontBytes = BuildEmbeddedFontBytes([(65, 1), (66, 2)]); + var (resourcesBody, extraObjects) = BuildSimpleTrueTypeFontResources( + fontBytes, fontDictExtra: "/FirstChar 66 /LastChar 66 /Widths [600]", descriptorExtra: "/MissingWidth 500"); + + var bytes = BuildSinglePagePdfWithResources(100, 100, "BT /F1 20 Tf 5 5 Td (AB) Tj ET", resourcesBody, extraObjects); + + // Act + using var surface = RenderPdfBytes(bytes); + + // Assert: 'B' starts at Tm.x = 5 + 10 = 15, painting at text x [17, 25). + Assert.NotEqual(default, surface[11, 89]); + Assert.Equal(default, surface[16, 89]); + Assert.NotEqual(default, surface[21, 89]); + } + + /// Proves that a code with no explicit /Widths entry and no /MissingWidth falls back to the embedded font's own advance width. + [Fact] + public void PdfDocument_Fonts_Widths_FontOwnAdvanceFallback_DeterminesAdvance() + { + // Arrange: code 65 is outside [FirstChar, LastChar], and /MissingWidth is not declared - + // falls back to the embedded font's own 600-unit hmtx advance -> advance = 0.6 * 20 = 12. + var fontBytes = BuildEmbeddedFontBytes([(65, 1), (66, 2)]); + var (resourcesBody, extraObjects) = BuildSimpleTrueTypeFontResources( + fontBytes, fontDictExtra: "/FirstChar 66 /LastChar 66 /Widths [600]"); + + var bytes = BuildSinglePagePdfWithResources(100, 100, "BT /F1 20 Tf 5 5 Td (AB) Tj ET", resourcesBody, extraObjects); + + // Act + using var surface = RenderPdfBytes(bytes); + + // Assert: 'B' starts at Tm.x = 5 + 12 = 17, painting at text x [19, 27). + Assert.NotEqual(default, surface[11, 89]); + Assert.Equal(default, surface[16, 89]); + Assert.NotEqual(default, surface[23, 89]); + } + + /// Proves that a /Type0//Identity-H/CIDFontType2 composite font resolves its embedded /FontFile2 and paints real glyph ink rather than throwing. + [Fact] + public void PdfDocument_Fonts_Type0_IdentityHCidFontType2_ResolvesEmbeddedFont() + { + // Arrange: no /CIDToGIDMap declared -> identity (CID 1 -> GID 1, a painted square glyph). + var fontBytes = BuildEmbeddedFontBytes([], glyphCount: 3); + var (resourcesBody, extraObjects) = BuildCompositeFontResources(fontBytes); + + var bytes = BuildSinglePagePdfWithResources( + 100, 100, $"BT /F1 20 Tf 5 5 Td {BuildIdentityHHexString(1)} Tj ET", resourcesBody, extraObjects); + + // Act + using var surface = RenderPdfBytes(bytes); + + // Assert: some glyph ink was actually painted somewhere on the canvas. + var paintedAnyPixel = false; + for (var y = 0; y < surface.Height && !paintedAnyPixel; y++) + { + for (var x = 0; x < surface.Width; x++) + { + if (surface[x, y].A > 0) + { + paintedAnyPixel = true; + break; + } + } + } + + Assert.True(paintedAnyPixel); + } + + /// Proves that an explicit /CIDToGIDMap /Identity name behaves identically to an absent /CIDToGIDMap entry (CID used directly as GID). + [Fact] + public void PdfDocument_Fonts_Type0_CidToGidMapIdentity_UsesCidAsGid() + { + // Arrange: CID 1 -> GID 1 (a painted square glyph) via the explicit /Identity name. + var fontBytes = BuildEmbeddedFontBytes([], glyphCount: 3); + var (resourcesBody, extraObjects) = BuildCompositeFontResources( + fontBytes, cidFontExtra: "/CIDToGIDMap /Identity"); + + var bytes = BuildSinglePagePdfWithResources( + 100, 100, $"BT /F1 20 Tf 5 5 Td {BuildIdentityHHexString(1)} Tj ET", resourcesBody, extraObjects); + + // Act + using var surface = RenderPdfBytes(bytes); + + // Assert: text x [7, 15) holds the painted square glyph (design x [100, 500) of 1000, + // scaled by fontSize 20, offset by originX 5). + Assert.NotEqual(default, surface[11, 89]); + } + + /// Proves that a /CIDToGIDMap stream remaps a CID to a different glyph index rather than treating the CID as the glyph index directly. + [Fact] + public void PdfDocument_Fonts_Type0_CidToGidMapStream_RemapsCidToGid() + { + // Arrange: the stream remaps CID 1 -> GID 0 (.notdef, an empty glyph) - had the map been + // ignored (or treated as identity), CID 1 would instead resolve to GID 1 (a painted + // square), so no painted pixels proves the stream's remapping was actually applied. + var fontBytes = BuildEmbeddedFontBytes([], glyphCount: 3); + var cidToGidMapBytes = new byte[] { 0x00, 0x00, 0x00, 0x00 }; + var (resourcesBody, extraObjects) = BuildCompositeFontResources( + fontBytes, cidToGidMapStreamBytes: cidToGidMapBytes); + + var bytes = BuildSinglePagePdfWithResources( + 100, 100, $"BT /F1 20 Tf 5 5 Td {BuildIdentityHHexString(1)} Tj ET", resourcesBody, extraObjects); + + // Act + using var surface = RenderPdfBytes(bytes); + + // Assert: no glyph ink painted anywhere on the canvas. + for (var y = 0; y < surface.Height; y++) + { + for (var x = 0; x < surface.Width; x++) + { + Assert.Equal(0, surface[x, y].A); + } + } + } + + /// Proves that a CID beyond the end of a /CIDToGIDMap stream's table maps to .notdef (GID 0) rather than throwing. + [Fact] + public void PdfDocument_Fonts_Type0_CidToGidMapStream_OutOfRangeCid_MapsToNotdef() + { + // Arrange: the map's table has only 1 entry (index 0), so CID 5 is out of range. + var fontBytes = BuildEmbeddedFontBytes([], glyphCount: 3); + var cidToGidMapBytes = new byte[] { 0x00, 0x63 }; + var (resourcesBody, extraObjects) = BuildCompositeFontResources( + fontBytes, cidToGidMapStreamBytes: cidToGidMapBytes); + + var bytes = BuildSinglePagePdfWithResources( + 100, 100, $"BT /F1 20 Tf 5 5 Td {BuildIdentityHHexString(5)} Tj ET", resourcesBody, extraObjects); + + // Act + using var surface = RenderPdfBytes(bytes); + + // Assert: no exception, and no glyph ink painted anywhere on the canvas. + for (var y = 0; y < surface.Height; y++) + { + for (var x = 0; x < surface.Width; x++) + { + Assert.Equal(0, surface[x, y].A); + } + } + } + + /// Proves that a /Type0 font's /Encoding value other than /Identity-H - including /Identity-V and a predefined CJK encoding name - throws . + [Theory] + [InlineData("/Identity-V")] + [InlineData("/UniGB-UCS2-H")] + [InlineData("/SomeOtherEncoding")] + public void PdfDocument_Fonts_Type0_NonIdentityHEncoding_ThrowsUnsupportedImageFeatureException(string encoding) + { + // Arrange + var fontBytes = BuildEmbeddedFontBytes([], glyphCount: 3); + var (resourcesBody, extraObjects) = BuildCompositeFontResources(fontBytes, encoding: encoding); + + var bytes = BuildSinglePagePdfWithResources( + 100, 100, $"BT /F1 20 Tf {BuildIdentityHHexString(1)} Tj ET", resourcesBody, extraObjects); + + // Act & Assert + Assert.Throws(() => RenderPdfBytes(bytes)); + } + + /// Proves that a CIDFontType0 descendant font whose /FontDescriptor has no embedded /FontFile3 (only /FontFile2) throws . + [Fact] + public void PdfDocument_Fonts_Type0_CidFontType0_NoFontFile3_ThrowsInvalidDataException() + { + // Arrange: a CIDFontType0 descendant whose /FontDescriptor declares only /FontFile2 (no + // /FontFile3) - LoadCidFontType0Font requires /FontFile3, so this fails closed rather than + // falling back to /FontFile2. + var fontBytes = BuildEmbeddedFontBytes([], glyphCount: 3); + var (resourcesBody, extraObjects) = BuildCompositeFontResources(fontBytes, descendantSubtype: "/CIDFontType0"); + + var bytes = BuildSinglePagePdfWithResources( + 100, 100, $"BT /F1 20 Tf {BuildIdentityHHexString(1)} Tj ET", resourcesBody, extraObjects); + + // Act & Assert + Assert.Throws(() => RenderPdfBytes(bytes)); + } + + /// Proves that a /Type0//Identity-H/CIDFontType0 composite font resolves its embedded, /OpenType-wrapped, non-CID-keyed /FontFile3 CFF program and paints real glyph ink - identity CID-to-glyph-index (no /CIDToGIDMap consulted) - rather than throwing. + [Fact] + public void PdfDocument_Fonts_Type0_CidFontType0_OpenTypeCff_ResolvesEmbeddedFont() + { + // Arrange: CID 1 -> GID 1 (identity, a painted square glyph) via a synthetic OTTO/CFF + // /FontFile3. + var fontBytes = BuildEmbeddedCffFontBytes(glyphCount: 3); + var (resourcesBody, extraObjects) = BuildCidFontType0FontResources(fontBytes); + + var bytes = BuildSinglePagePdfWithResources( + 100, 100, $"BT /F1 20 Tf 5 5 Td {BuildIdentityHHexString(1)} Tj ET", resourcesBody, extraObjects); + + // Act + using var surface = RenderPdfBytes(bytes); + + // Assert: text x [7, 15) holds the painted square glyph (design x [100, 500) of 1000, + // scaled by fontSize 20, offset by originX 5) - matching the CIDFontType2 equivalent + // (PdfDocument_Fonts_Type0_CidToGidMapIdentity_UsesCidAsGid) exactly, since both flavors + // paint the same square glyph shape. + Assert.NotEqual(default, surface[11, 89]); + } + + /// Proves that a non-standard /CIDToGIDMap entry on a CIDFontType0 descendant is ignored (identity CID-to-glyph-index is always used), rather than remapping the CID away from its painted glyph. + [Fact] + public void PdfDocument_Fonts_Type0_CidFontType0_NonStandardCidToGidMap_IsIgnored() + { + // Arrange: a /CIDToGIDMap /Identity entry (not a valid key for CIDFontType0 per the PDF + // specification, but sometimes seen in real-world producers) is declared on the + // descendant - had it been consulted (rather than ignored) as a stream remap or any other + // shape, this would behave differently; here it is simply ignored, so CID 1 still resolves + // to GID 1 via identity. + var fontBytes = BuildEmbeddedCffFontBytes(glyphCount: 3); + var (resourcesBody, extraObjects) = BuildCidFontType0FontResources( + fontBytes, cidFontExtra: "/CIDToGIDMap /Identity"); + + var bytes = BuildSinglePagePdfWithResources( + 100, 100, $"BT /F1 20 Tf 5 5 Td {BuildIdentityHHexString(1)} Tj ET", resourcesBody, extraObjects); + + // Act + using var surface = RenderPdfBytes(bytes); + + // Assert: text x [7, 15) holds the painted square glyph. + Assert.NotEqual(default, surface[11, 89]); + } + + /// Proves that a CIDFontType0 descendant's /DW//W width resolution behaves identically to the CIDFontType2 path (fully generic, no subtype-specific logic). + [Fact] + public void PdfDocument_Fonts_Type0_CidFontType0_Widths_WArrayIndividualForm_DeterminesAdvance() + { + // Arrange: CID 1's declared width is 500 (individual form) -> advance = 0.5 * 20 = 10. + // CID 2 has no matching entry, so falls back to the default DW value of 1000. + var fontBytes = BuildEmbeddedCffFontBytes(glyphCount: 3); + var (resourcesBody, extraObjects) = BuildCidFontType0FontResources(fontBytes, cidFontExtra: "/W [1 [500]]"); + + var bytes = BuildSinglePagePdfWithResources( + 100, 100, $"BT /F1 20 Tf 5 5 Td {BuildIdentityHHexString(1, 2)} Tj ET", resourcesBody, extraObjects); + + // Act + using var surface = RenderPdfBytes(bytes); + + // Assert: CID 2 starts at Tm.x = 5 + 10 = 15, painting at text x [17, 25). + Assert.NotEqual(default, surface[11, 89]); + Assert.Equal(default, surface[16, 89]); + Assert.NotEqual(default, surface[21, 89]); + } + + /// + /// Proves that a CIDFontType0 descendant's /FontFile3 stream whose declared + /// /Subtype does not match its actual byte container shape - an SFNT-wrapped + /// ('OTTO') CFF program declared as the bare /Type1C or /CIDFontType0C + /// names - still resolves and paints real glyph ink, per PDF 32000-1's leniency around + /// the declared /Subtype name versus the stream's actual byte container shape: as + /// of the shape-sniffing dispatch in LoadCidFontType0Font + /// (PdfDocument.Fonts.Type0.cs), the stream's own decoded bytes - not its declared + /// /Subtype - determine which loader runs. + /// + [Theory] + [InlineData("/Type1C")] + [InlineData("/CIDFontType0C")] + public void PdfDocument_Fonts_Type0_CidFontType0_MismatchedSubtype_SfntBytes_ResolvesEmbeddedFont(string fontFileSubtype) + { + // Arrange: the bytes are a well-formed SFNT-wrapped ('OTTO') CFF program (the same shape + // PdfDocument_Fonts_Type0_CidFontType0_OpenTypeCff_ResolvesEmbeddedFont uses), but the + // stream's own declared /Subtype deliberately mismatches that shape. + var fontBytes = BuildEmbeddedCffFontBytes(glyphCount: 3); + var (resourcesBody, extraObjects) = BuildCidFontType0FontResources(fontBytes, fontFileSubtype: fontFileSubtype); + + var bytes = BuildSinglePagePdfWithResources( + 100, 100, $"BT /F1 20 Tf 5 5 Td {BuildIdentityHHexString(1)} Tj ET", resourcesBody, extraObjects); + + // Act + using var surface = RenderPdfBytes(bytes); + + // Assert: text x [7, 15) holds the painted square glyph. + Assert.NotEqual(default, surface[11, 89]); + } + + /// + /// Proves that a CIDFontType0 descendant's /FontFile3 stream with no + /// /Subtype key at all still resolves and paints real glyph ink when its bytes are + /// a well-formed SFNT-wrapped CFF program - the shape-sniffing dispatch in + /// LoadCidFontType0Font never consults the declared /Subtype at all, so a + /// missing key is no different from any other (mismatched or absent) declared name. + /// + [Fact] + public void PdfDocument_Fonts_Type0_CidFontType0_MissingFontFile3Subtype_ResolvesEmbeddedFont() + { + // Arrange + var fontBytes = BuildEmbeddedCffFontBytes(glyphCount: 3); + var (resourcesBody, extraObjects) = BuildCidFontType0FontResources(fontBytes, fontFileSubtype: ""); + + var bytes = BuildSinglePagePdfWithResources( + 100, 100, $"BT /F1 20 Tf 5 5 Td {BuildIdentityHHexString(1)} Tj ET", resourcesBody, extraObjects); + + // Act + using var surface = RenderPdfBytes(bytes); + + // Assert: text x [7, 15) holds the painted square glyph. + Assert.NotEqual(default, surface[11, 89]); + } + + /// + /// Proves that a CIDFontType0 descendant's /FontFile3 stream whose decoded + /// bytes match neither a recognized SFNT container nor a structurally plausible bare CFF + /// header still throws - the + /// shape-sniffing dispatch in LoadCidFontType0Font is lenient about the declared + /// /Subtype name, never about the bytes themselves. + /// + [Fact] + public void PdfDocument_Fonts_Type0_CidFontType0_UnrecognizedFontFile3Bytes_ThrowsUnsupportedImageFeatureException() + { + // Arrange: a short, synthetic, non-corpus byte array that is neither a recognized SFNT + // version/collection tag nor a plausible bare-CFF header (major version byte != 1). + byte[] garbageBytes = "NOTAFONT"u8.ToArray(); + var (resourcesBody, extraObjects) = BuildCidFontType0FontResources(garbageBytes, fontFileSubtype: "/OpenType"); + + var bytes = BuildSinglePagePdfWithResources( + 100, 100, $"BT /F1 20 Tf {BuildIdentityHHexString(1)} Tj ET", resourcesBody, extraObjects); + + // Act & Assert + Assert.Throws(() => RenderPdfBytes(bytes)); + } + + /// Proves that a CIDFontType0 descendant's /OpenType-wrapped /FontFile3 whose embedded CFF program's Top DICT declares ROS (CID-keyed CFF) throws - Fonts.CffTable.Parse's existing CID-keyed rejection surfaces uncaught through this new path, with no new translation code. + [Fact] + public void PdfDocument_Fonts_Type0_CidFontType0_CidKeyedCff_ThrowsInvalidDataException() + { + // Arrange + var fontBytes = BuildEmbeddedCffFontBytes(glyphCount: 3, includeRos: true); + var (resourcesBody, extraObjects) = BuildCidFontType0FontResources(fontBytes); + + var bytes = BuildSinglePagePdfWithResources( + 100, 100, $"BT /F1 20 Tf {BuildIdentityHHexString(1)} Tj ET", resourcesBody, extraObjects); + + // Act & Assert + Assert.Throws(() => RenderPdfBytes(bytes)); + } + + /// Proves that a /Type0 font dictionary with no /DescendantFonts entry throws . + [Fact] + public void PdfDocument_Fonts_Type0_MissingDescendantFonts_ThrowsInvalidDataException() + { + // Arrange + var fontDictObj = "<< /Type /Font /Subtype /Type0 /BaseFont /Test /Encoding /Identity-H >>"u8.ToArray(); + + var bytes = BuildSinglePagePdfWithResources( + 100, 100, $"BT /F1 20 Tf {BuildIdentityHHexString(1)} Tj ET", "/Font << /F1 5 0 R >>", [fontDictObj]); + + // Act & Assert + Assert.Throws(() => RenderPdfBytes(bytes)); + } + + /// Proves that a /DescendantFonts array with other than exactly one element throws . + [Fact] + public void PdfDocument_Fonts_Type0_DescendantFontsNotSingleElement_ThrowsInvalidDataException() + { + // Arrange + var fontDictObj = + "<< /Type /Font /Subtype /Type0 /BaseFont /Test /Encoding /Identity-H /DescendantFonts [] >>"u8.ToArray(); + + var bytes = BuildSinglePagePdfWithResources( + 100, 100, $"BT /F1 20 Tf {BuildIdentityHHexString(1)} Tj ET", "/Font << /F1 5 0 R >>", [fontDictObj]); + + // Act & Assert + Assert.Throws(() => RenderPdfBytes(bytes)); + } + + /// Proves that a descendant font whose /FontDescriptor has no embedded /FontFile2 throws (composite fonts have no system-font fallback path). + [Fact] + public void PdfDocument_Fonts_Type0_NoEmbeddedFontFile_ThrowsInvalidDataException() + { + // Arrange + var descriptorObj = "<< /Type /FontDescriptor >>"u8.ToArray(); + var descendantObj = + "<< /Type /Font /Subtype /CIDFontType2 /BaseFont /Test /FontDescriptor 7 0 R >>"u8.ToArray(); + var fontDictObj = + "<< /Type /Font /Subtype /Type0 /BaseFont /Test /Encoding /Identity-H /DescendantFonts [6 0 R] >>"u8.ToArray(); + + var bytes = BuildSinglePagePdfWithResources( + 100, 100, $"BT /F1 20 Tf {BuildIdentityHHexString(1)} Tj ET", "/Font << /F1 5 0 R >>", + [fontDictObj, descendantObj, descriptorObj]); + + // Act & Assert + Assert.Throws(() => RenderPdfBytes(bytes)); + } + + /// Proves that an absent /DW defaults to 1000 (a full em) and determines every CID's advance width when no /W entry applies. + [Fact] + public void PdfDocument_Fonts_Type0_Widths_DwDefault1000_DeterminesAdvance() + { + // Arrange: no /W declared, /DW absent -> defaults to 1000 -> advance = 1.0 * 20 = 20 for + // both CIDs. + var fontBytes = BuildEmbeddedFontBytes([], glyphCount: 3); + var (resourcesBody, extraObjects) = BuildCompositeFontResources(fontBytes); + + var bytes = BuildSinglePagePdfWithResources( + 100, 100, $"BT /F1 20 Tf 5 5 Td {BuildIdentityHHexString(1, 2)} Tj ET", resourcesBody, extraObjects); + + // Act + using var surface = RenderPdfBytes(bytes); + + // Assert: CID 1's square paints at text x [7, 15); CID 2 starts at Tm.x = 5 + 20 = 25, + // painting at text x [27, 35) - clearly past the gap after CID 1's square. + Assert.NotEqual(default, surface[11, 89]); + Assert.Equal(default, surface[16, 89]); + Assert.NotEqual(default, surface[31, 89]); + } + + /// Proves that the individual-width /W sub-form (c [w1 w2 ...]) determines each listed CID's advance width. + [Fact] + public void PdfDocument_Fonts_Type0_Widths_WArrayIndividualForm_DeterminesAdvance() + { + // Arrange: CID 1's declared width is 500 (individual form) -> advance = 0.5 * 20 = 10. + // CID 2 has no matching entry, so falls back to the default DW value of 1000. + var fontBytes = BuildEmbeddedFontBytes([], glyphCount: 3); + var (resourcesBody, extraObjects) = BuildCompositeFontResources(fontBytes, cidFontExtra: "/W [1 [500]]"); + + var bytes = BuildSinglePagePdfWithResources( + 100, 100, $"BT /F1 20 Tf 5 5 Td {BuildIdentityHHexString(1, 2)} Tj ET", resourcesBody, extraObjects); + + // Act + using var surface = RenderPdfBytes(bytes); + + // Assert: CID 2 starts at Tm.x = 5 + 10 = 15, painting at text x [17, 25). + Assert.NotEqual(default, surface[11, 89]); + Assert.Equal(default, surface[16, 89]); + Assert.NotEqual(default, surface[21, 89]); + } + + /// Proves that the range-width /W sub-form (cFirst cLast w) determines every CID in the range's advance width. + [Fact] + public void PdfDocument_Fonts_Type0_Widths_WArrayRangeForm_DeterminesAdvance() + { + // Arrange: both CID 1 and CID 2's declared width is 500 (range form) -> advance = 0.5 * + // 20 = 10 each. + var fontBytes = BuildEmbeddedFontBytes([], glyphCount: 3); + var (resourcesBody, extraObjects) = BuildCompositeFontResources(fontBytes, cidFontExtra: "/W [1 2 500]"); + + var bytes = BuildSinglePagePdfWithResources( + 100, 100, $"BT /F1 20 Tf 5 5 Td {BuildIdentityHHexString(1, 2)} Tj ET", resourcesBody, extraObjects); + + // Act + using var surface = RenderPdfBytes(bytes); + + // Assert: CID 2 starts at Tm.x = 5 + 10 = 15, painting at text x [17, 25). + Assert.NotEqual(default, surface[11, 89]); + Assert.Equal(default, surface[16, 89]); + Assert.NotEqual(default, surface[21, 89]); + } + + /// Proves that a /W array entry whose second element is neither an array (individual form) nor a number (range form) throws . + [Fact] + public void PdfDocument_Fonts_Type0_Widths_MalformedWArray_ThrowsInvalidDataException() + { + // Arrange + var fontBytes = BuildEmbeddedFontBytes([], glyphCount: 3); + var (resourcesBody, extraObjects) = BuildCompositeFontResources(fontBytes, cidFontExtra: "/W [1 (bad)]"); + + var bytes = BuildSinglePagePdfWithResources( + 100, 100, $"BT /F1 20 Tf {BuildIdentityHHexString(1)} Tj ET", resourcesBody, extraObjects); + + // Act & Assert + Assert.Throws(() => RenderPdfBytes(bytes)); + } + + /// Proves that a /W array's cFirst cLast w range-form entry spanning an absurd number of CIDs (for example [0 999999999 500]) throws instead of expanding the range and exhausting memory/time. + [Fact] + public void PdfDocument_Fonts_Type0_Widths_WArrayRangeFormExcessiveSpan_ThrowsInvalidDataException() + { + // Arrange: a crafted range spanning essentially the entire CID space. + var fontBytes = BuildEmbeddedFontBytes([], glyphCount: 3); + var (resourcesBody, extraObjects) = BuildCompositeFontResources(fontBytes, cidFontExtra: "/W [0 999999999 500]"); + + var bytes = BuildSinglePagePdfWithResources( + 100, 100, $"BT /F1 20 Tf {BuildIdentityHHexString(1)} Tj ET", resourcesBody, extraObjects); + + // Act & Assert + Assert.Throws(() => RenderPdfBytes(bytes)); + } + + /// + /// Proves that a /W array's cFirst cLast w range-form entry whose + /// cLast is exactly (a small, in-bounds span) still + /// terminates, rather than hanging forever: an -typed loop counter would + /// overflow to on the final iteration and never satisfy its own + /// exit condition. + /// + [Fact] + public async Task PdfDocument_Fonts_Type0_Widths_WArrayRangeFormCLastAtInt32MaxValue_Terminates() + { + // Arrange: a small, in-bounds span [int.MaxValue - 2, int.MaxValue]. + var fontBytes = BuildEmbeddedFontBytes([], glyphCount: 3); + var (resourcesBody, extraObjects) = BuildCompositeFontResources( + fontBytes, cidFontExtra: "/W [2147483645 2147483647 777]"); + + var bytes = BuildSinglePagePdfWithResources( + 100, 100, $"BT /F1 20 Tf {BuildIdentityHHexString(1)} Tj ET", resourcesBody, extraObjects); + + // Act: run on a background thread with a generous bounded timeout - if the loop-termination + // fix regresses, this fails fast with a TimeoutException instead of hanging the test run + // forever. + using var surface = await Task.Run(() => RenderPdfBytes(bytes)) + .WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); + + // Assert + Assert.NotNull(surface); + } + + /// Proves that ResolveToUnicodeMap returns when the font dictionary has no /ToUnicode entry at all. + [Fact] + public void PdfDocument_Fonts_ToUnicode_Absent_ResolvesNull() + { + // Arrange + var bytes = BuildSinglePagePdf(100, 100, "BT ET"); + using var document = PdfDocument.Open(new MemoryStream(bytes)); + var fontDict = PdfDocument.PdfObject.FromDictionary(new Dictionary()); + + // Act + var result = document.ResolveToUnicodeMap(fontDict); + + // Assert + Assert.Null(result); + } + + /// Proves that a beginbfchar/endbfchar entry maps its single source code to its destination's codepoint. + [Fact] + public void PdfDocument_Fonts_ToUnicode_BfChar_MapsSingleCode() + { + // Arrange + var cmapBytes = BuildToUnicodeCMapStreamBytes("1 beginbfchar\n<0041> <0048>\nendbfchar"); + var streamObj = BuildStreamObjectBody(string.Empty, cmapBytes); + var bytes = BuildSinglePagePdfWithResources(100, 100, "BT ET", string.Empty, [streamObj]); + using var document = PdfDocument.Open(new MemoryStream(bytes)); + var fontDict = PdfDocument.PdfObject.FromDictionary(new Dictionary + { + ["ToUnicode"] = PdfDocument.PdfObject.FromReference(5, 0), + }); + + // Act + var result = document.ResolveToUnicodeMap(fontDict); + + // Assert + Assert.NotNull(result); + Assert.Equal(0x0048, result[0x0041]); + } + + /// Proves that a beginbfrange/endbfrange entry with a single hex-string destination maps consecutive source codes to consecutive incrementing codepoints. + [Fact] + public void PdfDocument_Fonts_ToUnicode_BfRangeHexForm_MapsConsecutiveCodes() + { + // Arrange + var cmapBytes = BuildToUnicodeCMapStreamBytes("1 beginbfrange\n<0001> <0003> <0048>\nendbfrange"); + var streamObj = BuildStreamObjectBody(string.Empty, cmapBytes); + var bytes = BuildSinglePagePdfWithResources(100, 100, "BT ET", string.Empty, [streamObj]); + using var document = PdfDocument.Open(new MemoryStream(bytes)); + var fontDict = PdfDocument.PdfObject.FromDictionary(new Dictionary + { + ["ToUnicode"] = PdfDocument.PdfObject.FromReference(5, 0), + }); + + // Act + var result = document.ResolveToUnicodeMap(fontDict); + + // Assert + Assert.NotNull(result); + Assert.Equal(0x0048, result[1]); + Assert.Equal(0x0049, result[2]); + Assert.Equal(0x004A, result[3]); + } + + /// + /// Proves that a beginbfrange/endbfrange entry whose single-hex-string-destination + /// [srcLo, srcHi] span exceeds the decoder's maximum supported span throws + /// instead of allocating an enormous map for a tiny + /// crafted CMap stream (a decompression-bomb-style attack). + /// + [Fact] + public void PdfDocument_Fonts_ToUnicode_BfRangeHexForm_SpanExceedsMaxSize_ThrowsInvalidDataException() + { + // Arrange: srcLo=0x000000, srcHi=0x010000 - a span of 65537 codes, one more than the + // decoder's 65536-code cap (expressible only via 3-byte source codes, since 2-byte codes + // top out at 0xFFFF, a span of only 65536). + var cmapBytes = BuildToUnicodeCMapStreamBytes("1 beginbfrange\n<000000> <010000> <0048>\nendbfrange"); + var streamObj = BuildStreamObjectBody(string.Empty, cmapBytes); + var bytes = BuildSinglePagePdfWithResources(100, 100, "BT ET", string.Empty, [streamObj]); + using var document = PdfDocument.Open(new MemoryStream(bytes)); + var fontDict = PdfDocument.PdfObject.FromDictionary(new Dictionary + { + ["ToUnicode"] = PdfDocument.PdfObject.FromReference(5, 0), + }); + + // Act & Assert + Assert.Throws(() => document.ResolveToUnicodeMap(fontDict)); + } + + /// + /// Proves that a beginbfrange/endbfrange entry whose [srcLo, srcHi] + /// span would overflow ordinary 32-bit arithmetic (a 4-byte srcLo with its top bit + /// set decodes to a negative , so srcHi - srcLo + 1 computed purely + /// in wraps around instead of producing the span's true, enormous size) + /// still throws rather than silently passing the span + /// check and expanding an effectively unbounded map. + /// + [Fact] + public void PdfDocument_Fonts_ToUnicode_BfRangeHexForm_SpanArithmeticWouldOverflowInt32_ThrowsInvalidDataException() + { + // Arrange: srcLo=0x80000000 decodes to int.MinValue; srcHi=0x00000001. The true span + // (2147483650) vastly exceeds the decoder's cap, but `srcHi - srcLo + 1` computed in + // `int` would wrap around to a small/negative value and incorrectly pass a naive check. + var cmapBytes = BuildToUnicodeCMapStreamBytes("1 beginbfrange\n<80000000> <00000001> <0048>\nendbfrange"); + var streamObj = BuildStreamObjectBody(string.Empty, cmapBytes); + var bytes = BuildSinglePagePdfWithResources(100, 100, "BT ET", string.Empty, [streamObj]); + using var document = PdfDocument.Open(new MemoryStream(bytes)); + var fontDict = PdfDocument.PdfObject.FromDictionary(new Dictionary + { + ["ToUnicode"] = PdfDocument.PdfObject.FromReference(5, 0), + }); + + // Act & Assert + Assert.Throws(() => document.ResolveToUnicodeMap(fontDict)); + } + + /// + /// Proves that a beginbfrange/endbfrange entry whose srcLo and + /// srcHi are both int.MaxValue (a single-code, in-bounds span) still + /// terminates and maps that one code, rather than hanging forever: an -typed + /// loop counter would overflow to after the single iteration and + /// never satisfy its own exit condition. + /// + [Fact] + public async Task PdfDocument_Fonts_ToUnicode_BfRangeHexForm_SrcAtInt32MaxValue_TerminatesAndMapsSingleCode() + { + // Arrange + var cmapBytes = BuildToUnicodeCMapStreamBytes("1 beginbfrange\n<7FFFFFFF> <7FFFFFFF> <0048>\nendbfrange"); + var streamObj = BuildStreamObjectBody(string.Empty, cmapBytes); + var bytes = BuildSinglePagePdfWithResources(100, 100, "BT ET", string.Empty, [streamObj]); + using var document = PdfDocument.Open(new MemoryStream(bytes)); + var fontDict = PdfDocument.PdfObject.FromDictionary(new Dictionary + { + ["ToUnicode"] = PdfDocument.PdfObject.FromReference(5, 0), + }); + + // Act: run on a background thread with a generous bounded timeout - if the loop-termination + // fix regresses, this fails fast with a TimeoutException instead of hanging the test run + // forever. + var result = await Task.Run(() => document.ResolveToUnicodeMap(fontDict)) + .WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); + + // Assert + Assert.NotNull(result); + Assert.Equal(0x0048, result[int.MaxValue]); + } + + /// Proves that a beginbfrange/endbfrange entry with an array-of-hex-strings destination maps each code in the range to its own corresponding array element. + [Fact] + public void PdfDocument_Fonts_ToUnicode_BfRangeArrayForm_MapsEachCodeIndividually() + { + // Arrange + var cmapBytes = BuildToUnicodeCMapStreamBytes("1 beginbfrange\n<0001> <0002> [<0048> <004F>]\nendbfrange"); + var streamObj = BuildStreamObjectBody(string.Empty, cmapBytes); + var bytes = BuildSinglePagePdfWithResources(100, 100, "BT ET", string.Empty, [streamObj]); + using var document = PdfDocument.Open(new MemoryStream(bytes)); + var fontDict = PdfDocument.PdfObject.FromDictionary(new Dictionary + { + ["ToUnicode"] = PdfDocument.PdfObject.FromReference(5, 0), + }); + + // Act + var result = document.ResolveToUnicodeMap(fontDict); + + // Assert + Assert.NotNull(result); + Assert.Equal(0x0048, result[1]); + Assert.Equal(0x004F, result[2]); + } + + /// Proves that a beginbfchar/endbfchar entry with an empty hex-string destination (<>, seen from real-world producers such as WeasyPrint to mean "no single-character Unicode equivalent") is skipped rather than treated as malformed. + [Fact] + public void PdfDocument_Fonts_ToUnicode_BfChar_EmptyDestination_SkipsMapping() + { + // Arrange + var cmapBytes = BuildToUnicodeCMapStreamBytes("2 beginbfchar\n<0003> <>\n<0041> <0048>\nendbfchar"); + var streamObj = BuildStreamObjectBody(string.Empty, cmapBytes); + var bytes = BuildSinglePagePdfWithResources(100, 100, "BT ET", string.Empty, [streamObj]); + using var document = PdfDocument.Open(new MemoryStream(bytes)); + var fontDict = PdfDocument.PdfObject.FromDictionary(new Dictionary + { + ["ToUnicode"] = PdfDocument.PdfObject.FromReference(5, 0), + }); + + // Act + var result = document.ResolveToUnicodeMap(fontDict); + + // Assert + Assert.NotNull(result); + Assert.False(result.ContainsKey(0x0003)); + Assert.Equal(0x0048, result[0x0041]); + } + + /// Proves that a beginbfrange/endbfrange entry with a single empty hex-string destination skips mapping the whole range rather than treated as malformed. + [Fact] + public void PdfDocument_Fonts_ToUnicode_BfRangeHexForm_EmptyDestination_SkipsWholeRange() + { + // Arrange + var cmapBytes = BuildToUnicodeCMapStreamBytes("1 beginbfrange\n<0001> <0003> <>\nendbfrange"); + var streamObj = BuildStreamObjectBody(string.Empty, cmapBytes); + var bytes = BuildSinglePagePdfWithResources(100, 100, "BT ET", string.Empty, [streamObj]); + using var document = PdfDocument.Open(new MemoryStream(bytes)); + var fontDict = PdfDocument.PdfObject.FromDictionary(new Dictionary + { + ["ToUnicode"] = PdfDocument.PdfObject.FromReference(5, 0), + }); + + // Act + var result = document.ResolveToUnicodeMap(fontDict); + + // Assert + Assert.NotNull(result); + Assert.Empty(result); + } + + /// Proves that a bfrange destination array's empty hex-string element skips mapping only that one code, leaving its siblings mapped. + [Fact] + public void PdfDocument_Fonts_ToUnicode_BfRangeArrayForm_EmptyDestinationElement_SkipsThatCodeOnly() + { + // Arrange + var cmapBytes = BuildToUnicodeCMapStreamBytes("1 beginbfrange\n<0001> <0002> [<> <004F>]\nendbfrange"); + var streamObj = BuildStreamObjectBody(string.Empty, cmapBytes); + var bytes = BuildSinglePagePdfWithResources(100, 100, "BT ET", string.Empty, [streamObj]); + using var document = PdfDocument.Open(new MemoryStream(bytes)); + var fontDict = PdfDocument.PdfObject.FromDictionary(new Dictionary + { + ["ToUnicode"] = PdfDocument.PdfObject.FromReference(5, 0), + }); + + // Act + var result = document.ResolveToUnicodeMap(fontDict); + + // Assert + Assert.NotNull(result); + Assert.False(result.ContainsKey(0x0001)); + Assert.Equal(0x004F, result[0x0002]); + } + + /// Proves that a non-empty destination string with an odd byte count (not a whole number of UTF-16BE code units) still throws rather than being silently treated as "no mapping". + [Fact] + public void PdfDocument_Fonts_ToUnicode_BfChar_OddLengthDestination_ThrowsInvalidDataException() + { + // Arrange + var cmapBytes = BuildToUnicodeCMapStreamBytes("1 beginbfchar\n<0041> <00>\nendbfchar"); + var streamObj = BuildStreamObjectBody(string.Empty, cmapBytes); + var bytes = BuildSinglePagePdfWithResources(100, 100, "BT ET", string.Empty, [streamObj]); + using var document = PdfDocument.Open(new MemoryStream(bytes)); + var fontDict = PdfDocument.PdfObject.FromDictionary(new Dictionary + { + ["ToUnicode"] = PdfDocument.PdfObject.FromReference(5, 0), + }); + + // Act & Assert + Assert.Throws(() => document.ResolveToUnicodeMap(fontDict)); + } + + /// Proves that a bfrange destination array containing a nested array (the out-of-scope CIDSystemInfo-style "array of arrays" destination sub-form) throws , unlike the in-scope array-of-hex-strings destination form. + [Fact] + public void PdfDocument_Fonts_ToUnicode_UnsupportedArrayDestinationBfRange_ThrowsUnsupportedImageFeatureException() + { + // Arrange + var cmapBytes = BuildToUnicodeCMapStreamBytes("1 beginbfrange\n<0001> <0001> [[<0048>]]\nendbfrange"); + var streamObj = BuildStreamObjectBody(string.Empty, cmapBytes); + var bytes = BuildSinglePagePdfWithResources(100, 100, "BT ET", string.Empty, [streamObj]); + using var document = PdfDocument.Open(new MemoryStream(bytes)); + var fontDict = PdfDocument.PdfObject.FromDictionary(new Dictionary + { + ["ToUnicode"] = PdfDocument.PdfObject.FromReference(5, 0), + }); + + // Act & Assert + var exception = Assert.Throws(() => document.ResolveToUnicodeMap(fontDict)); + Assert.Equal("pdf-font-tounicode-bfrange-array-destination", exception.Feature); + } + + /// Proves that each explicitly out-of-scope, data-bearing CMap operator (usecmap/cidrange/cidchar) fails closed with rather than being silently ignored like the CMap's own PostScript wrapper keywords. + [Theory] + [InlineData("usecmap")] + [InlineData("cidrange")] + [InlineData("cidchar")] + public void PdfDocument_Fonts_ToUnicode_UnsupportedOperator_ThrowsUnsupportedImageFeatureException(string operatorName) + { + // Arrange + var cmapBytes = BuildToUnicodeCMapStreamBytes(operatorName); + var streamObj = BuildStreamObjectBody(string.Empty, cmapBytes); + var bytes = BuildSinglePagePdfWithResources(100, 100, "BT ET", string.Empty, [streamObj]); + using var document = PdfDocument.Open(new MemoryStream(bytes)); + var fontDict = PdfDocument.PdfObject.FromDictionary(new Dictionary + { + ["ToUnicode"] = PdfDocument.PdfObject.FromReference(5, 0), + }); + + // Act & Assert + var exception = Assert.Throws(() => document.ResolveToUnicodeMap(fontDict)); + Assert.Equal($"pdf-font-tounicode-{operatorName}", exception.Feature); + } + + /// + /// Proves that /Subtype /Type1 dispatches to simple-font resolution + /// (BuildResolvedSimpleFont) and, when the descriptor embeds a classic PostScript + /// /FontDescriptor/FontFile Type 1 font program, resolves and paints that program's + /// own glyph ink - proving the Phase B dispatch widening end to end. + /// + [Fact] + public void PdfDocument_Fonts_Type1_DispatchesToSimpleFontResolution_PaintsGlyphInk() + { + // Arrange + var (resourcesBody, extraObjects) = BuildEmbeddedType1FontResources(); + + var bytes = BuildSinglePagePdfWithResources( + 100, 100, "BT /F1 20 Tf 5 5 Td (A) Tj ET", resourcesBody, extraObjects); + + // Act + using var surface = RenderPdfBytes(bytes); + + // Assert: text x [7, 15) holds the painted square glyph (design x [100, 500) of 1000, + // scaled by fontSize 20, offset by originX 5, flipped for originY 5 on a 100-tall + // MediaBox) - matching every other embedded-font flavor's own pixel-position convention. + Assert.NotEqual(default, surface[11, 89]); + } + + /// + /// Proves that an embedded /FontDescriptor/FontFile classic Type 1 program always + /// takes priority over fallback substitution: even though /BaseFont names an + /// entirely unmatched family, the embedded synthetic font's own known square glyph shape + /// is what gets rendered. + /// + [Fact] + public void PdfDocument_Fonts_Type1_EmbeddedFontFileTakesPriorityOverFallback() + { + // Arrange: /BaseFont names a family unlikely to be installed/matched on any host. + var (resourcesBody, extraObjects) = BuildEmbeddedType1FontResources(baseFontName: "TotallyUnlikelyFontFamilyXyzzy"); + + var bytes = BuildSinglePagePdfWithResources( + 100, 100, "BT /F1 20 Tf 5 5 Td (A) Tj ET", resourcesBody, extraObjects); + + // Act + using var surface = RenderPdfBytes(bytes); + + // Assert: the embedded synthetic font's own known square glyph shape was painted (not a + // differently-shaped fallback glyph). + Assert.NotEqual(default, surface[11, 89]); + } + + /// + /// Proves that a /Subtype /Type1 font with no embedded /FontFile (and no + /// /FontFile3) resolves via ResolveFallbackFont (a Standard-14/system/ + /// bundled-Liberation match) rather than throwing, and paints real visible glyph ink - + /// mirroring PdfDocument_BuildResolvedFont_Standard14NoEmbeddedFont_ResolvesViaFallback's + /// own TrueType-subtype test, now proven reachable for /Type1 too. + /// + [Fact] + public void PdfDocument_Fonts_Type1_NoFontFile_ResolvesViaFallback() + { + // Arrange: /Helvetica, no /FontFile - Standard-14, sans-serif, non-bold, non-italic + var descriptorObj = "<< /Type /FontDescriptor >>"u8.ToArray(); + var fontDictObj = + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /FirstChar 65 /LastChar 65 /Widths [600] /FontDescriptor 6 0 R >>"u8.ToArray(); + + var bytes = BuildSinglePagePdfWithResources( + 100, 100, "BT /F1 40 Tf 10 30 Td (A) Tj ET", "/Font << /F1 5 0 R >>", + [fontDictObj, descriptorObj]); + + // Act + using var surface = RenderPdfBytes(bytes); + + // Assert: no exception, and some glyph ink was actually painted somewhere on the canvas + var paintedAnyPixel = false; + for (var y = 0; y < surface.Height && !paintedAnyPixel; y++) + { + for (var x = 0; x < surface.Width; x++) + { + if (surface[x, y].A > 0) + { + paintedAnyPixel = true; + break; + } + } + } + + Assert.True(paintedAnyPixel, "Expected the fallback-resolved /Type1 font to paint at least one visible pixel."); + } + + /// + /// Proves that a simple font dictionary with no /FontDescriptor entry at all - as + /// PDF 32000-1 ยง9.6.2.2 explicitly permits for the standard 14 fonts, and as several + /// real-world producers (for example ReportLab) emit - resolves via + /// ResolveFallbackFont rather than throwing . + /// + [Fact] + public void PdfDocument_Fonts_SimpleFont_NoFontDescriptorAtAll_ResolvesViaFallback() + { + // Arrange: no /FontDescriptor key at all on the font dictionary. + var fontDictObj = "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>"u8.ToArray(); + + var bytes = BuildSinglePagePdfWithResources( + 100, 100, "BT /F1 40 Tf 10 30 Td (A) Tj ET", "/Font << /F1 5 0 R >>", + [fontDictObj]); + + // Act + using var surface = RenderPdfBytes(bytes); + + // Assert: no exception, and some glyph ink was actually painted somewhere on the canvas + var paintedAnyPixel = false; + for (var y = 0; y < surface.Height && !paintedAnyPixel; y++) + { + for (var x = 0; x < surface.Width; x++) + { + if (surface[x, y].A > 0) + { + paintedAnyPixel = true; + break; + } + } + } + + Assert.True(paintedAnyPixel, "Expected the fallback-resolved font (no /FontDescriptor at all) to paint at least one visible pixel."); + } + + /// + /// Proves that a /Subtype /Type1 descriptor declaring only /FontFile3 + /// (neither /FontFile nor /FontFile2), whose /FontFile3 stream is a + /// bare Type1C/CFF program (/Subtype /Type1C, no SFNT/OpenType wrapper), resolves + /// and paints that program's own glyph ink - the Type1C counterpart of + /// . + /// + [Fact] + public void PdfDocument_Fonts_Type1_FontFile3Type1C_ResolvesEmbeddedFont_PaintsGlyphInk() + { + // Arrange + var (resourcesBody, extraObjects) = BuildEmbeddedType1CFontResources(); + + var bytes = BuildSinglePagePdfWithResources( + 100, 100, "BT /F1 20 Tf 5 5 Td (A) Tj ET", resourcesBody, extraObjects); + + // Act + using var surface = RenderPdfBytes(bytes); + + // Assert: text x [7, 15) holds the painted square glyph - matching every other + // embedded-font flavor's own pixel-position convention. + Assert.NotEqual(default, surface[11, 89]); + } + + /// + /// Proves that a /Subtype /Type1 descriptor's /FontFile3 stream whose + /// declared /Subtype does not match its actual byte container shape - a bare, + /// standalone CFF program declared as /OpenType or /CIDFontType0C - still + /// resolves and paints real glyph ink, rather than throwing, per PDF 32000-1's leniency + /// around the declared /Subtype name versus the stream's actual byte container + /// shape: as of the shape-sniffing dispatch in LoadType1CFont + /// (PdfDocument.Fonts.Type1.cs), the stream's own decoded bytes - not its declared + /// /Subtype - determine which loader runs - the simple-font counterpart of + /// . + /// + [Theory] + [InlineData("/OpenType")] + [InlineData("/CIDFontType0C")] + public void PdfDocument_Fonts_Type1_MismatchedSubtype_BareCffBytes_ResolvesEmbeddedFont(string fontFileSubtype) + { + // Arrange: the bytes are a well-formed bare (standalone, non-SFNT-wrapped) CFF program + // (the same shape PdfDocument_Fonts_Type1_FontFile3Type1C_ResolvesEmbeddedFont_PaintsGlyphInk + // uses), but the stream's own declared /Subtype deliberately mismatches that shape. + var (resourcesBody, extraObjects) = BuildEmbeddedType1CFontResources(fontFileSubtype: fontFileSubtype); + + var bytes = BuildSinglePagePdfWithResources( + 100, 100, "BT /F1 20 Tf 5 5 Td (A) Tj ET", resourcesBody, extraObjects); + + // Act + using var surface = RenderPdfBytes(bytes); + + // Assert: text x [7, 15) holds the painted square glyph. + Assert.NotEqual(default, surface[11, 89]); + } + + /// + /// Proves that a /Subtype /Type1 descriptor's /FontFile3 stream with no + /// /Subtype key at all still resolves and paints real glyph ink when its bytes are + /// a well-formed bare CFF program - the shape-sniffing dispatch in + /// LoadType1CFont never consults the declared /Subtype at all, so a missing + /// key is no different from any other (mismatched or absent) declared name. + /// + [Fact] + public void PdfDocument_Fonts_Type1_MissingFontFile3Subtype_ResolvesEmbeddedFont() + { + // Arrange + var (resourcesBody, extraObjects) = BuildEmbeddedType1CFontResources(fontFileSubtype: ""); + + var bytes = BuildSinglePagePdfWithResources( + 100, 100, "BT /F1 20 Tf 5 5 Td (A) Tj ET", resourcesBody, extraObjects); + + // Act + using var surface = RenderPdfBytes(bytes); + + // Assert: text x [7, 15) holds the painted square glyph. + Assert.NotEqual(default, surface[11, 89]); + } + + /// + /// Proves that a /Subtype /Type1 descriptor's /FontFile3 stream whose + /// decoded bytes match neither a recognized SFNT container nor a structurally plausible + /// bare CFF header still throws - the + /// shape-sniffing dispatch in LoadType1CFont is lenient about the declared + /// /Subtype name, never about the bytes themselves - the simple-font counterpart + /// of . + /// + [Fact] + public void PdfDocument_Fonts_Type1_UnrecognizedFontFile3Bytes_ThrowsUnsupportedImageFeatureException() + { + // Arrange: a short, synthetic, non-corpus byte array that is neither a recognized SFNT + // version/collection tag nor a plausible bare-CFF header (major version byte != 1). + byte[] garbageBytes = "NOTAFONT"u8.ToArray(); + var (resourcesBody, extraObjects) = BuildEmbeddedType1CFontResources(fontFileBytes: garbageBytes); + + var bytes = BuildSinglePagePdfWithResources( + 100, 100, "BT /F1 20 Tf (A) Tj ET", resourcesBody, extraObjects); + + // Act & Assert + Assert.Throws(() => RenderPdfBytes(bytes)); + } + + /// + /// Proves that an embedded /FontFile stream missing its own required /Length1 + /// entry throws (read from the stream itself, not the + /// /FontDescriptor). + /// + [Fact] + public void PdfDocument_Fonts_Type1_FontFileMissingLength1_ThrowsInvalidDataException() + { + // Arrange: a /FontFile stream declaring only /Length2 (no /Length1 at all). + var (fontFileBytes, _, length2) = SyntheticFontBuilder.Type1( + [(".notdef", BuildEmptyType1Charstring()), ("A", BuildSquareType1Charstring())]); + var fontFileObj = BuildStreamObjectBody($"/Length2 {length2}", fontFileBytes); + var descriptorObj = "<< /Type /FontDescriptor /FontFile 7 0 R >>"u8.ToArray(); + var fontDictObj = + "<< /Type /Font /Subtype /Type1 /BaseFont /Test /FirstChar 65 /LastChar 65 /Widths [600] /FontDescriptor 6 0 R >>"u8.ToArray(); + + var bytes = BuildSinglePagePdfWithResources( + 100, 100, "BT /F1 20 Tf (A) Tj ET", "/Font << /F1 5 0 R >>", + [fontDictObj, descriptorObj, fontFileObj]); + + // Act & Assert + Assert.Throws(() => RenderPdfBytes(bytes)); + } + + /// + /// Proves that an embedded /FontFile stream missing its own required /Length2 + /// entry throws (read from the stream itself, not the + /// /FontDescriptor). + /// + [Fact] + public void PdfDocument_Fonts_Type1_FontFileMissingLength2_ThrowsInvalidDataException() + { + // Arrange: BuildEmbeddedType1FontResources(omitLength2: true) declares only /Length1. + var (resourcesBody, extraObjects) = BuildEmbeddedType1FontResources(omitLength2: true); + + var bytes = BuildSinglePagePdfWithResources( + 100, 100, "BT /F1 20 Tf (A) Tj ET", resourcesBody, extraObjects); + + // Act & Assert + Assert.Throws(() => RenderPdfBytes(bytes)); + } + + /// + /// Proves that an embedded /FontFile stream whose /Length2 entry is present + /// but not a number throws . + /// + [Fact] + public void PdfDocument_Fonts_Type1_FontFileNonNumericLength2_ThrowsInvalidDataException() + { + // Arrange + var (fontFileBytes, length1, _) = SyntheticFontBuilder.Type1( + [(".notdef", BuildEmptyType1Charstring()), ("A", BuildSquareType1Charstring())]); + var fontFileObj = BuildStreamObjectBody($"/Length1 {length1} /Length2 /NotANumber", fontFileBytes); + var descriptorObj = "<< /Type /FontDescriptor /FontFile 7 0 R >>"u8.ToArray(); + var fontDictObj = + "<< /Type /Font /Subtype /Type1 /BaseFont /Test /FirstChar 65 /LastChar 65 /Widths [600] /FontDescriptor 6 0 R >>"u8.ToArray(); + + var bytes = BuildSinglePagePdfWithResources( + 100, 100, "BT /F1 20 Tf (A) Tj ET", "/Font << /F1 5 0 R >>", + [fontDictObj, descriptorObj, fontFileObj]); + + // Act & Assert + Assert.Throws(() => RenderPdfBytes(bytes)); + } + + /// + /// Proves that an explicit /Encoding /StandardEncoding resolves code 0x27 + /// ("quoteright", Unicode U+2019) differently than /WinAnsiEncoding does + /// (which maps the same code to "quotesingle", Unicode U+0027) - the code's mapped + /// codepoint is what actually differs between the two base encodings for this fixture, not + /// merely the glyph name. + /// + [Fact] + public void PdfDocument_Fonts_StandardEncoding_DiffersFromWinAnsiEncoding() + { + // Arrange: the font's only mapped codepoint is U+2019 (quoteright), which /StandardEncoding + // maps code 0x27 to, but /WinAnsiEncoding maps code 0x27 to U+0027 (quotesingle) instead. + var fontBytes = BuildEmbeddedFontBytes([(0x2019, 1)]); + var (resourcesBody, extraObjects) = BuildSimpleTrueTypeFontResources( + fontBytes, + fontDictExtra: "/FirstChar 0 /LastChar 255 /Widths [" + string.Join(' ', Enumerable.Repeat(600, 256)) + "] /Encoding /StandardEncoding"); + + var bytes = BuildSinglePagePdfWithResources( + 100, 100, "BT /F1 20 Tf 5 50 Td (') Tj ET", resourcesBody, extraObjects); + + // Act + using var surface = RenderPdfBytes(bytes); + + // Assert: glyph 1's square is painted at the position code 0x27 resolves to under + // /StandardEncoding (U+2019), which this font has a mapped glyph for. + Assert.NotEqual(default, surface[11, 44]); + } + + /// + /// Proves that, unlike , + /// the default (no /Encoding entry) /WinAnsiEncoding base encoding does not + /// paint the same code's glyph, since it resolves code 0x27 to Unicode U+0027 + /// (quotesingle) instead of U+2019 (quoteright) - confirming the two base encodings + /// are genuinely different for this code, not merely that some encoding resolved. + /// + [Fact] + public void PdfDocument_Fonts_StandardEncoding_Absent_DefaultWinAnsiDoesNotPaintQuoteright() + { + // Arrange: same font as the /StandardEncoding test (only codepoint U+2019 mapped), but no + // /Encoding entry at all, so the default /WinAnsiEncoding applies instead. + var fontBytes = BuildEmbeddedFontBytes([(0x2019, 1)]); + var (resourcesBody, extraObjects) = BuildSimpleTrueTypeFontResources( + fontBytes, + fontDictExtra: "/FirstChar 0 /LastChar 255 /Widths [" + string.Join(' ', Enumerable.Repeat(600, 256)) + "]"); + + var bytes = BuildSinglePagePdfWithResources( + 100, 100, "BT /F1 20 Tf 5 50 Td (') Tj ET", resourcesBody, extraObjects); + + // Act + using var surface = RenderPdfBytes(bytes); + + // Assert: no glyph outline is painted anywhere in the expected region. + Assert.Equal(default, surface[11, 44]); + } + + /// + /// Proves BuildResolvedFont's Type3 dispatch: a minimal + /// /Subtype /Type3 font declaring a single glyph (code 65 ('A'), mapped via + /// /Encoding//Differences to glyph name /Square, whose + /// /CharProcs content stream paints a filled design-space rectangle) paints that + /// glyph's ink, at the device location its default /FontMatrix + /// ([0.001 0 0 0.001 0 0]) scales it to - the same location an equivalent + /// /UnitsPerEm 1000 TrueType outline glyph would paint at (see + /// PdfDocument_Fonts_Type1_* tests' own, numerically identical, pixel assertions). + /// + [Fact] + public void PdfDocument_Fonts_Type3_MinimalFont_DifferencesAndCharProcs_PaintsGlyphInk() + { + // Arrange: glyph /Square paints a filled rectangle spanning design (100,100)-(500,500). + var (resourcesBody, extraObjects) = BuildType3FontResources( + [("Square", "100 100 400 400 re f"u8.ToArray())], + "65 /Square"); + + var bytes = BuildSinglePagePdfWithResources( + 100, 100, "BT /F1 20 Tf 5 5 Td (A) Tj ET", resourcesBody, extraObjects); + + // Act + using var surface = RenderPdfBytes(bytes); + + // Assert: device x [7,15), y [85,93) - identical to the Type1/TrueType embedded-font + // tests' own pixel assertions for the same design-space rectangle and /FontMatrix- + // equivalent 0.001 scale. + Assert.NotEqual(default, surface[11, 89]); + + // Assert: well outside the glyph's painted region, nothing is painted. + Assert.Equal(default, surface[50, 50]); + } + + /// + /// Proves that a non-default, anisotropic /FontMatrix + /// ([0.002 0 0 0.0015 0 0] - a different horizontal/vertical scale, neither of which + /// is the conventional 0.001) is actually consulted to compute the glyph's painted + /// device location - not merely accepted and ignored in favor of a hardcoded + /// 0.001/1000-unit-em assumption (the highest-risk area of this feature, per the + /// implementation plan). + /// + [Fact] + public void PdfDocument_Fonts_Type3_NonDefaultFontMatrix_ScalesGlyphGeometry() + { + // Arrange: same glyph rectangle (100,100)-(500,500) as the minimal-font test, but with + // /FontMatrix [0.002 0 0.0015 0 0] instead of the conventional [0.001 0 0 0.001 0 0]. + var (resourcesBody, extraObjects) = BuildType3FontResources( + [("Square", "100 100 400 400 re f"u8.ToArray())], + "65 /Square", + fontMatrix: "[0.002 0 0 0.0015 0 0]"); + + var bytes = BuildSinglePagePdfWithResources( + 100, 100, "BT /F1 20 Tf 5 5 Td (A) Tj ET", resourcesBody, extraObjects); + + // Act + using var surface = RenderPdfBytes(bytes); + + // Assert: under this /FontMatrix, the rectangle lands at device x [9,25), y [80,92) - + // verified empirically against the implementation's own rasterization. + Assert.NotEqual(default, surface[17, 86]); + + // Assert: device (8,92) lies inside the region the rectangle would occupy under the + // conventional (but, for this font, wrong) 0.001 /FontMatrix scale (x [7,15), y [85,93)) + // but outside this font's own, correctly-scaled region (x [9,25), y [80,92)) - proving the + // glyph was positioned using this font's actual /FontMatrix, not a hardcoded assumption. + Assert.Equal(default, surface[8, 92]); + } + + /// + /// Proves that Resolve's glyph-advance width is the /Widths entry scaled + /// through /FontMatrix (Vector2.TransformNormal) - not divided by the + /// simple/composite-font convention of 1000 - by painting two glyphs in a single + /// Tj and asserting the second glyph's device position reflects the first glyph's + /// /FontMatrix-scaled (not /1000-scaled) advance. + /// + [Fact] + public void PdfDocument_Fonts_Type3_Widths_ScaledViaFontMatrix_DeterminesAdvance() + { + // Arrange: /FontMatrix [0.0025 0 0 0.001 0 0] (a deliberately non-conventional horizontal + // scale); code 65 ('A') declares /Widths entry 1000 (glyph-space units). The correct + // advance is 1000 * 0.0025 * 20pt = 50 text-space units; a /1000-divided (wrong) + // convention would instead advance only 1000/1000 * 20pt = 20 units. + var (resourcesBody, extraObjects) = BuildType3FontResources( + [("Square", "100 100 400 400 re f"u8.ToArray())], + "65 /Square 66 /Square", + fontMatrix: "[0.0025 0 0 0.001 0 0]", + fontDictExtra: "/FirstChar 65 /LastChar 66 /Widths [1000 1000]"); + + var bytes = BuildSinglePagePdfWithResources( + 100, 100, "BT /F1 20 Tf 5 5 Td (AB) Tj ET", resourcesBody, extraObjects); + + // Act + using var surface = RenderPdfBytes(bytes); + + // Assert: the first glyph paints at its own origin (device x [10,30)). + Assert.NotEqual(default, surface[20, 89]); + + // Assert: the second glyph paints at the correctly-advanced origin (tx = 5 + 50 = 55, + // device x [60,80)) - proving the /FontMatrix-scaled (not /1000-scaled) width was used. + Assert.NotEqual(default, surface[70, 89]); + + // Assert: the second glyph does NOT paint at the position a wrong, /1000-divided-width + // advance would have produced (tx = 5 + 20 = 25, device x [30,50)). + Assert.Equal(default, surface[40, 89]); + } + + /// + /// Proves that a character code with no /Encoding//Differences mapping at + /// all paints nothing (no exception), but still advances the text position by its declared + /// /Widths entry. + /// + [Fact] + public void PdfDocument_Fonts_Type3_UnmappedCode_NoDifferencesEntry_PaintsNothingButAdvances() + { + // Arrange: only code 65 ('A') is mapped via /Differences; code 66 ('B') has a declared + // /Widths entry (750) but no glyph-name mapping at all. "(BA)" shows B first (unmapped, + // paints nothing, but still advances 750 * 0.001 * 20pt = 15 units), then A (mapped, + // painted at the resulting, advanced origin). + var (resourcesBody, extraObjects) = BuildType3FontResources( + [("Square", "100 100 400 400 re f"u8.ToArray())], + "65 /Square", + fontDictExtra: "/FirstChar 65 /LastChar 66 /Widths [750 750]"); + + var bytes = BuildSinglePagePdfWithResources( + 100, 100, "BT /F1 20 Tf 5 5 Td (BA) Tj ET", resourcesBody, extraObjects); + + // Act + using var surface = RenderPdfBytes(bytes); + + // Assert: 'A' paints at the advanced origin (tx = 5 + 15 = 20, device x [22,30)) - proving + // the unmapped 'B' code's declared width was still applied. + Assert.NotEqual(default, surface[25, 89]); + + // Assert: nothing paints at the un-advanced origin (tx = 5, device x [7,15)) - proving 'B' + // itself painted nothing. + Assert.Equal(default, surface[10, 89]); + } + + /// + /// Proves that a character code mapped (via /Differences) to a glyph name absent + /// from /CharProcs paints nothing (no exception), but still advances the text + /// position by its declared /Widths entry - the companion leniency case to + /// . + /// + [Fact] + public void PdfDocument_Fonts_Type3_MappedGlyphNameAbsentFromCharProcs_PaintsNothingButAdvances() + { + // Arrange: code 66 ('B') is mapped via /Differences to glyph name /Missing, which has no + // corresponding /CharProcs entry (only /Square, for code 65 ('A'), is supplied). + var (resourcesBody, extraObjects) = BuildType3FontResources( + [("Square", "100 100 400 400 re f"u8.ToArray())], + "65 /Square 66 /Missing", + fontDictExtra: "/FirstChar 65 /LastChar 66 /Widths [750 750]"); + + var bytes = BuildSinglePagePdfWithResources( + 100, 100, "BT /F1 20 Tf 5 5 Td (BA) Tj ET", resourcesBody, extraObjects); + + // Act + using var surface = RenderPdfBytes(bytes); + + // Assert: 'A' paints at the advanced origin (tx = 5 + 15 = 20, device x [22,30)). + Assert.NotEqual(default, surface[25, 89]); + + // Assert: nothing paints at the un-advanced origin (tx = 5, device x [7,15)). + Assert.Equal(default, surface[10, 89]); + } + + /// + /// Proves that a /Subtype /Type3 font dictionary missing its required + /// /FontMatrix entry throws . + /// + [Fact] + public void PdfDocument_Fonts_Type3_MissingFontMatrix_ThrowsInvalidDataException() + { + // Arrange: a /Subtype /Type3 font dictionary with no /FontMatrix entry at all. + var glyphStreamObj = BuildStreamObjectBody(string.Empty, "100 100 400 400 re f"u8.ToArray()); + var fontDictObj = System.Text.Encoding.ASCII.GetBytes( + "<< /Type /Font /Subtype /Type3 /CharProcs << /Square 6 0 R >> " + + "/Encoding << /Differences [65 /Square] >> /FirstChar 65 /LastChar 65 /Widths [750] >>"); + + var bytes = BuildSinglePagePdfWithResources( + 100, 100, "BT /F1 20 Tf (A) Tj ET", "/Font << /F1 5 0 R >>", [fontDictObj, glyphStreamObj]); + + // Act & Assert + Assert.Throws(() => RenderPdfBytes(bytes)); + } + + /// + /// Proves that a /Subtype /Type3 font dictionary missing its required + /// /CharProcs entry throws . + /// + [Fact] + public void PdfDocument_Fonts_Type3_MissingCharProcs_ThrowsInvalidDataException() + { + // Arrange: a /Subtype /Type3 font dictionary with no /CharProcs entry at all. + var fontDictObj = System.Text.Encoding.ASCII.GetBytes( + "<< /Type /Font /Subtype /Type3 /FontMatrix [0.001 0 0 0.001 0 0] " + + "/Encoding << /Differences [65 /Square] >> /FirstChar 65 /LastChar 65 /Widths [750] >>"); + + var bytes = BuildSinglePagePdfWithResources( + 100, 100, "BT /F1 20 Tf (A) Tj ET", "/Font << /F1 5 0 R >>", [fontDictObj]); + + // Act & Assert + Assert.Throws(() => RenderPdfBytes(bytes)); + } + + /// + /// Proves that a glyph procedure's own cm/color-operator mutations are fully + /// isolated - they do not leak back into the invoking content stream's graphics state + /// after the glyph finishes painting - mirroring + /// 's + /// own Form-XObject state-isolation proof, applied to a Type3 glyph procedure's re-entrant + /// execution instead. + /// + [Fact] + public void PdfDocument_Fonts_Type3_GlyphProc_GraphicsStateIsolated_DoesNotLeakOut() + { + // Arrange: /FontMatrix [1 0 0 1 0 0] (identity) with /Tf 1 and no /Td, so the glyph's own + // content-stream coordinates map 1:1 onto the same device space a Form XObject's own + // BBox-space coordinates would (see the Form XObject test this mirrors). The glyph + // procedure applies "2 0 0 2 0 0 cm 1 0 0 rg" before painting its own rectangle; the page + // then paints its own rectangle, in its own default (black) fill color, after Tj returns. + var (resourcesBody, extraObjects) = BuildType3FontResources( + [("Square", "2 0 0 2 0 0 cm 1 0 0 rg 5 5 10 10 re f"u8.ToArray())], + "65 /Square", + fontMatrix: "[1 0 0 1 0 0]", + fontDictExtra: "/FirstChar 65 /LastChar 65 /Widths [0]"); + + var bytes = BuildSinglePagePdfWithResources( + 100, 100, "BT /F1 1 Tf (A) Tj ET 40 10 5 5 re f", resourcesBody, extraObjects); + + // Act + using var surface = RenderPdfBytes(bytes); + + // Assert: the glyph's own rectangle, scaled 2x and filled red by its own "cm"/"rg", lands + // at device (10,70)-(30,90) - its center is painted red. + Assert.Equal(new Canvas.Rgba32(255, 0, 0, 255), surface[20, 80]); + + // Assert: the page's own rectangle (painted after Tj returns) lands at its own normal, + // unscaled device position (40,85)-(45,90), in the page's own default black fill color - + // proving the glyph's "cm"/"rg" did not leak back out into the invoking stream's graphics + // state. + Assert.Equal(Black, surface[42, 87]); + + // Assert: the device position the page's rectangle would occupy if the glyph's 2x scale + // had leaked ((80,70)-(90,80)) is left blank. + Assert.Equal(default, surface[85, 75]); + } + + /// + /// Proves that a d0 operator inside a glyph procedure is parsed (operand + /// count/type validated) and then discarded - its wx operand is never fed back into + /// glyph-advance layout, which remains solely determined by the font's own declared + /// /Widths entry (scaled via /FontMatrix). + /// + [Fact] + public void PdfDocument_Fonts_Type3_D0Operator_ParsedButDoesNotAffectAdvanceWidth() + { + // Arrange: code 65's glyph procedure declares "2000 d0" (a /d0 width wildly different from + // the font's own declared /Widths entry of 750) before painting; "(AA)" shows the same + // glyph twice. The correct second-glyph advance is 750 * 0.001 * 20pt = 15 units (from + // /Widths); a bug that fed d0's wx back into layout would instead advance + // 2000 * 0.001 * 20pt = 40 units. + var (resourcesBody, extraObjects) = BuildType3FontResources( + [("Square", "2000 0 d0 100 100 400 400 re f"u8.ToArray())], + "65 /Square", + fontDictExtra: "/FirstChar 65 /LastChar 65 /Widths [750]"); + + var bytes = BuildSinglePagePdfWithResources( + 100, 100, "BT /F1 20 Tf 5 5 Td (AA) Tj ET", resourcesBody, extraObjects); + + // Act + using var surface = RenderPdfBytes(bytes); + + // Assert: the second glyph paints at the /Widths-correct origin (tx = 5 + 15 = 20, device + // x [22,30)). + Assert.NotEqual(default, surface[25, 89]); + + // Assert: the second glyph does NOT paint at the position a d0-driven (wrong) advance + // would have produced (tx = 5 + 40 = 45, device x [47,55)). + Assert.Equal(default, surface[50, 89]); + } + + /// + /// Proves that a /Subtype /Type3 font with no /Resources entry of its own + /// falls back, at glyph-paint time, to the invoking page's own /Resources - exactly + /// like a /Subtype /Form XObject with no /Resources of its own (per PDF + /// specification section 9.6.5.3) - proven by a glyph procedure that invokes a Form + /// XObject (/Fm0) declared only in the page's own /Resources, not the + /// Type3 font's. + /// + [Fact] + public void PdfDocument_Fonts_Type3_NoOwnResources_FallsBackToOuterPageResources() + { + // Arrange: the glyph procedure for code 65 ('A') does "/Fm0 Do"; /Fm0 is declared only in + // the page's own /Resources (the Type3 font declares no /Resources of its own at all). + var formStream = BuildStreamObjectBody( + "/Type /XObject /Subtype /Form /BBox [0 0 1000 1000]", "100 100 400 400 re f"u8.ToArray()); + + var (fontResourcesBody, fontExtraObjects) = BuildType3FontResources( + [("Square", "/Fm0 Do"u8.ToArray())], + "65 /Square"); + + var resourcesBody = fontResourcesBody + " /XObject << /Fm0 7 0 R >>"; + var extraObjects = new List(fontExtraObjects) { formStream }; + + var bytes = BuildSinglePagePdfWithResources( + 100, 100, "BT /F1 20 Tf 5 5 Td (A) Tj ET", resourcesBody, extraObjects); + + // Act + using var surface = RenderPdfBytes(bytes); + + // Assert: the Form XObject's rectangle paints at the same device location the minimal- + // font test's own directly-painted rectangle does (device x [7,15), y [85,93)) - proving + // /Fm0 was successfully resolved from the page's own /Resources while painting the glyph. + Assert.NotEqual(default, surface[11, 89]); + } + + /// + /// Proves that a self-referencing Type 3 glyph procedure - one that re-shows its own code + /// via Tj from inside its own content stream - fails closed with + /// once the fixed maximum Type3 glyph-procedure nesting + /// depth is exceeded, rather than hanging or overflowing the call stack. The nested graphics + /// state clone PaintType3Glyph hands to the recursive ExecuteOperators call + /// inherits the outer Tf-selected font (see that method's own remarks), so a bare + /// (A) Tj inside the glyph procedure's own content bytes legally re-invokes + /// ShowGlyph/PaintType3Glyph recursively without any nested BT/Tf + /// of its own. + /// + [Fact] + public void PdfDocument_Fonts_Type3_SelfReferencingGlyphProc_ExceedsMaxNestingDepth_ThrowsInvalidDataException() + { + // Arrange: glyph /Square (code 65) re-shows its own code via "(A) Tj" inside its own + // content stream - unbounded recursion through PaintType3Glyph until the fixed nesting + // depth trips. + var (resourcesBody, extraObjects) = BuildType3FontResources( + [("Square", "(A) Tj"u8.ToArray())], + "65 /Square"); + + var bytes = BuildSinglePagePdfWithResources( + 100, 100, "BT /F1 20 Tf (A) Tj ET", resourcesBody, extraObjects); + + // Act & Assert + Assert.Throws(() => RenderPdfBytes(bytes)); + } + + /// + /// Proves that a render mode of 3 (invisible) skips Type3 glyph-procedure execution + /// entirely - ShowGlyph's ResolvedType3Font branch only calls + /// PaintType3Glyph when _gs.RenderMode != 3 - while the shared, unconditional + /// trailing displacement/advance logic still advances the text position by each glyph's + /// declared /Widths entry. + /// + [Fact] + public void PdfDocument_Fonts_Type3_RenderMode3_SkipsGlyphProcedureButStillAdvances() + { + // Arrange: the same minimal filled-rectangle /Square glyph (code 65, /Widths [750]) as + // PdfDocument_Fonts_Type3_MinimalFont_DifferencesAndCharProcs_PaintsGlyphInk. "3 Tr (AA) + // Tj" shows two glyphs under render mode 3 (invisible) - each should advance the text + // position by its declared width (750 * 0.001 * 20pt = 15 units) without painting + // anything. "0 Tr (A) Tj" then shows a third, visible glyph at the now fully-advanced + // origin. + var (resourcesBody, extraObjects) = BuildType3FontResources( + [("Square", "100 100 400 400 re f"u8.ToArray())], + "65 /Square"); + + var bytes = BuildSinglePagePdfWithResources( + 100, 100, "BT /F1 20 Tf 5 5 Td 3 Tr (AA) Tj 0 Tr (A) Tj ET", resourcesBody, extraObjects); + + // Act + using var surface = RenderPdfBytes(bytes); + + // Assert: nothing paints at the first invisible glyph's own, would-be-painted device + // location (tx = 5, device x [7,15), y [85,93)) - proving the glyph procedure never + // executed. + Assert.Equal(default, surface[11, 89]); + + // Assert: nothing paints at the second invisible glyph's own, would-be-painted device + // location either (tx = 5 + 15 = 20, device x [22,30)). + Assert.Equal(default, surface[25, 89]); + + // Assert: the third, visible glyph paints at the fully-advanced origin (tx = 5 + 15 + 15 + // = 35, device x [37,45)) - proving both invisible glyphs' declared widths were applied + // to the text position even though neither painted. + Assert.NotEqual(default, surface[40, 89]); + } + + /// + /// Proves that Type3 glyphs behave identically under Tr modes 1 (stroke) and + /// 2 (fill+stroke) as they do under mode 0 (fill) - a Type3 glyph is an + /// arbitrary re-executed content-stream procedure, not an outline, so + /// PaintType3Glyph/ShowGlyph's ResolvedType3Font branch has no + /// fill/stroke distinction at all (only the shared != 3 invisible-mode gate applies); + /// the new mode-aware outline fill/stroke logic added to ShowGlyph's non-Type3 + /// branch is therefore never reached for a Type3 font, and the glyph procedure's own ink + /// paints exactly as it does for . + /// + [Theory] + [InlineData(1)] + [InlineData(2)] + public void PdfDocument_Fonts_Type3_RenderMode1Or2_BehavesLikeRenderMode0(int mode) + { + // Arrange: the same minimal filled-rectangle /Square glyph (code 65) as + // PdfDocument_Fonts_Type3_MinimalFont_DifferencesAndCharProcs_PaintsGlyphInk. + var (resourcesBody, extraObjects) = BuildType3FontResources( + [("Square", "100 100 400 400 re f"u8.ToArray())], + "65 /Square"); + + var bytes = BuildSinglePagePdfWithResources( + 100, 100, $"BT /F1 20 Tf {mode} Tr 5 5 Td (A) Tj ET", resourcesBody, extraObjects); + + // Act + using var surface = RenderPdfBytes(bytes); + + // Assert: device x [7,15), y [85,93) - identical to mode 0's own pixel assertions; the + // glyph procedure's ink paints regardless of the (irrelevant, for Type3) render mode. + Assert.NotEqual(default, surface[11, 89]); + + // Assert: well outside the glyph's painted region, nothing is painted. + Assert.Equal(default, surface[50, 50]); + } + + /// + /// Proves that a malformed /FontMatrix array of the wrong length (5 or 7 elements, + /// instead of exactly 6) throws - distinct from + /// 's own + /// "entirely absent" case. + /// + [Theory] + [InlineData("[0.001 0 0 0.001 0]")] + [InlineData("[0.001 0 0 0.001 0 0 0]")] + public void PdfDocument_Fonts_Type3_FontMatrixWrongArrayLength_ThrowsInvalidDataException(string fontMatrix) + { + // Arrange: a /Subtype /Type3 font dictionary whose /FontMatrix array has the wrong number + // of elements. + var glyphStreamObj = BuildStreamObjectBody(string.Empty, "100 100 400 400 re f"u8.ToArray()); + var fontDictObj = System.Text.Encoding.ASCII.GetBytes( + $"<< /Type /Font /Subtype /Type3 /FontMatrix {fontMatrix} /CharProcs << /Square 6 0 R >> " + + "/Encoding << /Differences [65 /Square] >> /FirstChar 65 /LastChar 65 /Widths [750] >>"); + + var bytes = BuildSinglePagePdfWithResources( + 100, 100, "BT /F1 20 Tf (A) Tj ET", "/Font << /F1 5 0 R >>", [fontDictObj, glyphStreamObj]); + + // Act & Assert + Assert.Throws(() => RenderPdfBytes(bytes)); + } + + /// + /// Proves that a /FontMatrix array containing a non-number entry (a name, in place + /// of a number) throws - distinct from + /// 's + /// own wrong-length case. + /// + [Fact] + public void PdfDocument_Fonts_Type3_FontMatrixNonNumberEntry_ThrowsInvalidDataException() + { + // Arrange: /FontMatrix with a name (/Foo) in place of a number for one entry. + var (resourcesBody, extraObjects) = BuildType3FontResources( + [("Square", "100 100 400 400 re f"u8.ToArray())], + "65 /Square", + fontMatrix: "[0.001 0 0 0.001 /Foo 0]"); + + var bytes = BuildSinglePagePdfWithResources( + 100, 100, "BT /F1 20 Tf (A) Tj ET", resourcesBody, extraObjects); + + // Act & Assert + Assert.Throws(() => RenderPdfBytes(bytes)); + } + + /// + /// Proves that a /CharProcs entry resolving to something other than a dictionary + /// (a number, here) throws - distinct from + /// 's own + /// "entirely absent" case. + /// + [Fact] + public void PdfDocument_Fonts_Type3_CharProcsNotADictionary_ThrowsInvalidDataException() + { + // Arrange: a /Subtype /Type3 font dictionary whose /CharProcs resolves to a number, not a + // dictionary. + var fontDictObj = System.Text.Encoding.ASCII.GetBytes( + "<< /Type /Font /Subtype /Type3 /FontMatrix [0.001 0 0 0.001 0 0] /CharProcs 42 " + + "/Encoding << /Differences [65 /Square] >> /FirstChar 65 /LastChar 65 /Widths [750] >>"); + + var bytes = BuildSinglePagePdfWithResources( + 100, 100, "BT /F1 20 Tf (A) Tj ET", "/Font << /F1 5 0 R >>", [fontDictObj]); + + // Act & Assert + Assert.Throws(() => RenderPdfBytes(bytes)); + } + + /// + /// Proves that a d1 operator (6 operands: wx wy llx lly urx ury) inside a + /// glyph procedure is parsed (operand count/type validated) and then discarded - its + /// wx operand is never fed back into glyph-advance layout, which remains solely + /// determined by the font's own declared /Widths entry - mirroring + /// 's own + /// d0 proof, applied to d1's own 6-operand path instead. + /// + [Fact] + public void PdfDocument_Fonts_Type3_D1Operator_ParsedButDoesNotAffectAdvanceWidth() + { + // Arrange: code 65's glyph procedure declares "2000 0 0 0 100 100 d1" (a /d1 wx wildly + // different from the font's own declared /Widths entry of 750) before painting; "(AA)" + // shows the same glyph twice. The correct second-glyph advance is 750 * 0.001 * 20pt = 15 + // units (from /Widths); a bug that fed d1's wx back into layout would instead advance + // 2000 * 0.001 * 20pt = 40 units. + var (resourcesBody, extraObjects) = BuildType3FontResources( + [("Square", "2000 0 0 0 100 100 d1 100 100 400 400 re f"u8.ToArray())], + "65 /Square", + fontDictExtra: "/FirstChar 65 /LastChar 65 /Widths [750]"); + + var bytes = BuildSinglePagePdfWithResources( + 100, 100, "BT /F1 20 Tf 5 5 Td (AA) Tj ET", resourcesBody, extraObjects); + + // Act + using var surface = RenderPdfBytes(bytes); + + // Assert: the second glyph paints at the /Widths-correct origin (tx = 5 + 15 = 20, device + // x [22,30)). + Assert.NotEqual(default, surface[25, 89]); + + // Assert: the second glyph does NOT paint at the position a d1-driven (wrong) advance + // would have produced (tx = 5 + 40 = 45, device x [47,55)). + Assert.Equal(default, surface[50, 89]); + } + + /// + /// Proves that a stray d0/d1 pair issued in an ordinary, non-Type3 page + /// content stream (no font ever selected at all) renders successfully with no exception + /// and no visible effect - proving ExecuteOperators' shared d0/d1 + /// dispatch (PdfDocument.ContentStream.cs) is unconditional, not gated on "currently + /// inside a Type3 glyph procedure". + /// + [Fact] + public void PdfDocument_Fonts_Type3_StrayD0D1OutsideGlyphProc_NoExceptionNoEffect() + { + // Arrange/Act: a stray d0 (2 operands) and d1 (6 operands) issued directly in an ordinary + // page content stream, followed by an ordinary filled rectangle. + using var surface = RenderContent("5 5 d0 1 1 2 2 3 3 d1 10 10 20 20 re f"); + + // Assert: no exception was thrown (RenderContent above completed), and the rectangle + // after the stray d0/d1 painted normally at its expected device location (device x + // [10,30), y [70,90)). + Assert.Equal(Black, surface[20, 80]); + } + + /// + /// Proves that a Type 3 font's own /Resources takes precedence over the invoking + /// page's own /Resources when both declare an XObject of the same name - the + /// non-null branch of PaintType3Glyph's _resources = font.Resources ?? + /// _resources fallback, distinct from + /// 's own + /// null-branch proof. + /// + [Fact] + public void PdfDocument_Fonts_Type3_OwnResourcesTakePrecedenceOverPageResources_UsesFontResources() + { + // Arrange: both the Type3 font's own /Resources and the page's own /Resources declare an + // XObject named /Fm0 - the font's own paints red, the page's own paints blue. The glyph + // procedure for code 65 ('A') does "/Fm0 Do". + var redFormStream = BuildStreamObjectBody( + "/Type /XObject /Subtype /Form /BBox [0 0 1000 1000]", "1 0 0 rg 0 0 1000 1000 re f"u8.ToArray()); + var blueFormStream = BuildStreamObjectBody( + "/Type /XObject /Subtype /Form /BBox [0 0 1000 1000]", "0 0 1 rg 0 0 1000 1000 re f"u8.ToArray()); + + var (fontResourcesBody, fontExtraObjects) = BuildType3FontResources( + [("Square", "/Fm0 Do"u8.ToArray())], + "65 /Square", + type3ResourcesBody: "/XObject << /Fm0 7 0 R >>"); + + var resourcesBody = fontResourcesBody + " /XObject << /Fm0 8 0 R >>"; + var extraObjects = new List(fontExtraObjects) { redFormStream, blueFormStream }; + + var bytes = BuildSinglePagePdfWithResources( + 100, 100, "BT /F1 20 Tf 5 5 Td (A) Tj ET", resourcesBody, extraObjects); + + // Act + using var surface = RenderPdfBytes(bytes); + + // Assert: the glyph paints red (the font's own /Fm0, not the page's blue /Fm0) at the + // expected glyph-matrix-transformed device location (device x [5,25), y [75,95)). + Assert.Equal(new Canvas.Rgba32(255, 0, 0, 255), surface[15, 85]); + } + + #endregion + + #region Text + + /// Proves that Tj paints a glyph outline through the composed text-rendering matrix, positioned per Tf/Td. + [Fact] + public void PdfDocument_Text_ShowText_PaintsGlyphAtComposedTextRenderingMatrix() + { + // Arrange + var fontBytes = BuildEmbeddedFontBytes([(65, 1), (66, 2)]); + var (resourcesBody, extraObjects) = BuildSimpleTrueTypeFontResources(fontBytes); + var bytes = BuildSinglePagePdfWithResources(100, 100, "BT /F1 20 Tf 5 50 Td (A) Tj ET", resourcesBody, extraObjects); + + // Act + using var surface = RenderPdfBytes(bytes); + + // Assert: text x [7, 15), text y [52, 60) -> device x [7, 15), device y [40, 48). + Assert.NotEqual(default, surface[10, 44]); + Assert.Equal(default, surface[50, 50]); + } + + /// Proves that a text-showing operator issued before any Tf throws . + [Theory] + [InlineData("BT (A) Tj ET")] + [InlineData("BT (A) ' ET")] + [InlineData("BT 0 0 (A) \" ET")] + [InlineData("BT [(A)] TJ ET")] + public void PdfDocument_Text_ShowText_NoFontSelected_ThrowsInvalidDataException(string content) + { + Assert.Throws(() => RenderContent(content)); + } + + /// Proves that render mode 3 (invisible) lays out but does not paint a glyph. + [Fact] + public void PdfDocument_Text_RenderMode3_Invisible_DoesNotPaintGlyph() + { + // Arrange + var fontBytes = BuildEmbeddedFontBytes([(65, 1), (66, 2)]); + var (resourcesBody, extraObjects) = BuildSimpleTrueTypeFontResources(fontBytes); + var bytes = BuildSinglePagePdfWithResources( + 100, 100, "BT /F1 20 Tf 3 Tr 5 50 Td (A) Tj ET", resourcesBody, extraObjects); + + // Act + using var surface = RenderPdfBytes(bytes); + + // Assert: nowhere in the surface has any painted pixel. + for (var y = 0; y < 100; y++) + { + for (var x = 0; x < 100; x++) + { + Assert.Equal(default, surface[x, y]); + } + } + } + + /// Proves that render mode 3 still advances the text position (glyphs are laid out, just not painted). + [Fact] + public void PdfDocument_Text_RenderMode3_Invisible_StillAdvancesTextPosition() + { + // Arrange: 'A' is invisible (mode 3); 'B' switches back to mode 0 (fill) and should + // still appear offset by 'A's own advance, proving 'A' was laid out, not skipped. + var fontBytes = BuildEmbeddedFontBytes([(65, 1), (66, 2)]); + var (resourcesBody, extraObjects) = BuildSimpleTrueTypeFontResources(fontBytes); + var bytes = BuildSinglePagePdfWithResources( + 100, 100, "BT /F1 20 Tf 5 5 Td 3 Tr (A) Tj 0 Tr (B) Tj ET", resourcesBody, extraObjects); + + // Act + using var surface = RenderPdfBytes(bytes); + + // Assert: 'A' paints nothing; 'B' starts at Tm.x = 5 + 12 = 17, painting at text x [19, 27). + Assert.Equal(default, surface[11, 89]); + Assert.NotEqual(default, surface[23, 89]); + } + + /// + /// Proves that render mode 1 (stroke) paints a stroked glyph outline without + /// filling the glyph's interior - the stroke color (red) appears along the outline's edge, + /// while the glyph's geometric center (which mode 0/2 would fill) remains + /// unpainted. + /// + [Fact] + public void PdfDocument_Text_RenderMode1_Stroke_PaintsStrokedOutlineNotSolidFill() + { + // Arrange: square glyph (device x [7,15), y [40,48) - see + // PdfDocument_Text_ShowText_PaintsGlyphAtComposedTextRenderingMatrix) stroked red with a + // thick (2 device pixel) line width, so the stroke reliably covers its edge pixels. Fill + // color (black, the default) would paint the center under mode 0/2, but must NOT appear + // here under mode 1. + var fontBytes = BuildEmbeddedFontBytes([(65, 1)]); + var (resourcesBody, extraObjects) = BuildSimpleTrueTypeFontResources(fontBytes); + var bytes = BuildSinglePagePdfWithResources( + 100, 100, "BT /F1 20 Tf 2 w 1 Tr 1 0 0 RG 5 50 Td (A) Tj ET", resourcesBody, extraObjects); + + // Act + using var surface = RenderPdfBytes(bytes); + + // Assert: the left edge of the glyph's outline (device x = 7) is stroked red. + Assert.Equal(new Canvas.Rgba32(255, 0, 0, 255), surface[7, 44]); + + // Assert: the glyph's geometric center (device x = 11, equidistant from all four edges) + // is not painted at all - only the outline was stroked, not the interior filled. + Assert.Equal(default, surface[11, 44]); + } + + /// + /// Proves that render mode 2 (fill, then stroke) paints both the glyph's solid + /// interior fill and its stroked outline, with the stroke painted on top of (and thus + /// visible over) the fill at the outline's edge - matching the path-painting operators' + /// own fill-then-stroke paint order (B/b). + /// + [Fact] + public void PdfDocument_Text_RenderMode2_FillAndStroke_PaintsBothFillAndStroke() + { + // Arrange: distinct fill (green) and stroke (red) colors, so the center (fill-only) and + // edge (stroke-over-fill) pixels are unambiguously distinguishable. + var fontBytes = BuildEmbeddedFontBytes([(65, 1)]); + var (resourcesBody, extraObjects) = BuildSimpleTrueTypeFontResources(fontBytes); + var bytes = BuildSinglePagePdfWithResources( + 100, + 100, + "BT /F1 20 Tf 2 w 2 Tr 0 1 0 rg 1 0 0 RG 5 50 Td (A) Tj ET", + resourcesBody, + extraObjects); + + // Act + using var surface = RenderPdfBytes(bytes); + + // Assert: the center is filled green (proving the fill still happened under mode 2). + Assert.Equal(new Canvas.Rgba32(0, 255, 0, 255), surface[11, 44]); + + // Assert: the left edge is stroked red, painted on top of the green fill. + Assert.Equal(new Canvas.Rgba32(255, 0, 0, 255), surface[7, 44]); + } + + /// + /// Proves that a glyph stroke (Tr mode 1) painted under a /Pattern + /// stroke color space uses the resolved tiling pattern's own tile colors - exercising + /// PaintStroke's /Pattern-aware branch (shared, via the PaintCurrentPath + /// extraction, with ordinary path stroking) for a glyph outline for the first time. + /// + [Fact] + public void PdfDocument_Text_RenderMode1_StrokePattern_PaintsPatternStroke() + { + // Arrange: a colored (/PaintType 1) checkerboard tiling pattern (left half of each 10x10 + // cell red, right half blue - see BuildCheckerboardTilingPatternStream), selected as the + // stroke color space via '/Pattern CS'/'SCN'. At 1:1 device scale, device column x = 7 + // (the glyph outline's left edge - see + // PdfDocument_Text_RenderMode1_Stroke_PaintsStrokedOutlineNotSolidFill) falls in the + // pattern cell's right (blue) half (7 mod 10 = 7, which is >= 5). + var fontBytes = BuildEmbeddedFontBytes([(65, 1)]); + var (fontResourcesBody, fontExtraObjects) = BuildSimpleTrueTypeFontResources(fontBytes); + var resourcesBody = fontResourcesBody + " /Pattern << /P1 8 0 R >>"; + var extraObjects = new List(fontExtraObjects) { BuildCheckerboardTilingPatternStream() }; + + var bytes = BuildSinglePagePdfWithResources( + 100, + 100, + "BT /F1 20 Tf 2 w 1 Tr /Pattern CS /P1 SCN 5 50 Td (A) Tj ET", + resourcesBody, + extraObjects); + + // Act + using var surface = RenderPdfBytes(bytes); + + // Assert: the stroked left edge takes the pattern's blue tile color - not a flat + // StrokeColor (which defaults to opaque black). + Assert.Equal(new Canvas.Rgba32(0, 0, 255, 255), surface[7, 44]); + } + + /// Proves that a defined but unsupported text-rendering mode throws . + [Theory] + [InlineData(4)] + [InlineData(5)] + [InlineData(6)] + [InlineData(7)] + public void PdfDocument_Text_RenderMode_UnsupportedDefinedMode_ThrowsUnsupportedImageFeatureException(int mode) + { + Assert.Throws(() => RenderContent($"BT {mode} Tr ET")); + } + + /// Proves that a text-rendering mode outside the PDF specification's defined [0, 7] range throws . + [Theory] + [InlineData(-1)] + [InlineData(8)] + public void PdfDocument_Text_RenderMode_OutOfDefinedRange_ThrowsInvalidDataException(int mode) + { + Assert.Throws(() => RenderContent($"BT {mode} Tr ET")); + } + + /// Proves that BT resets the text/line matrix to identity, but leaves the selected font (and every other text-state graphics parameter) untouched. + [Fact] + public void PdfDocument_Text_BeginText_ResetsTextMatrixButPreservesFontAndTextState() + { + // Arrange: the second BT/ET block issues no Tf of its own - it must still have a font + // selected (from the first block) to show text without throwing, and its Tm/Tlm must + // have reset to identity (not still be positioned at (5, 50) from the first block). + var fontBytes = BuildEmbeddedFontBytes([(65, 1), (66, 2)]); + var (resourcesBody, extraObjects) = BuildSimpleTrueTypeFontResources(fontBytes); + var bytes = BuildSinglePagePdfWithResources( + 100, 100, "BT /F1 20 Tf 5 50 Td (A) Tj ET BT (B) Tj ET", resourcesBody, extraObjects); + + // Act + using var surface = RenderPdfBytes(bytes); + + // Assert: the second block's glyph paints at text x [2, 10), y [2, 10) -> device y [90, 98). + Assert.NotEqual(default, surface[6, 94]); + } + + /// Proves that q/Q save/restore the font size (part of the graphics state), independently of the (never saved/restored) text/line matrix. + [Fact] + public void PdfDocument_Text_PushPopGraphicsState_RestoresFontSize() + { + // Arrange: 'q' saves FontSize=20; the nested '40 Tf' changes it to 40; 'Q' restores 20 - + // if it did not, the glyph painted afterward would be twice as large (and shifted). + var fontBytes = BuildEmbeddedFontBytes([(65, 1), (66, 2)]); + var (resourcesBody, extraObjects) = BuildSimpleTrueTypeFontResources(fontBytes); + var bytes = BuildSinglePagePdfWithResources( + 100, 100, "BT /F1 20 Tf 5 50 Td q /F1 40 Tf Q (A) Tj ET", resourcesBody, extraObjects); + + // Act + using var surface = RenderPdfBytes(bytes); + + // Assert: with FontSize correctly restored to 20, the glyph paints at text y [52, 60) -> + // device y [40, 48) - device y = 47 is inside that restored-size band, but outside the + // much taller band an un-restored FontSize = 40 would have produced (device y [30, 46)). + Assert.NotEqual(default, surface[11, 47]); + } + + /// Proves that Td offsets the line matrix (not the text matrix as last advanced by a preceding show operator), and both are made equal. + [Fact] + public void PdfDocument_Text_Td_OffsetsLineMatrixNotLastTextMatrix() + { + // Arrange: after showing 'A', the text matrix has advanced past the line matrix's own + // (5, 5) position - '10 0 Td' must offset the *line* matrix's (5, 5), landing 'B' at + // Tm.x = 15, not at (5 + 'A's own advance + 10). + var fontBytes = BuildEmbeddedFontBytes([(65, 1), (66, 2)]); + var (resourcesBody, extraObjects) = BuildSimpleTrueTypeFontResources(fontBytes); + var bytes = BuildSinglePagePdfWithResources( + 100, 100, "BT /F1 20 Tf 5 5 Td (A) Tj 10 0 Td (B) Tj ET", resourcesBody, extraObjects); + + // Act + using var surface = RenderPdfBytes(bytes); + + // Assert: 'B' paints at text x [17, 25) -> device x [17, 25); a buggy "offset from the + // last text matrix" implementation would instead land it at text x [29, 37). + Assert.NotEqual(default, surface[20, 89]); + Assert.Equal(default, surface[32, 89]); + } + + /// Proves that TD behaves like Td and additionally sets the leading to -ty, observable via a subsequent T*. + [Fact] + public void PdfDocument_Text_TD_SetsLeadingToNegativeTy() + { + // Arrange + var fontBytes = BuildEmbeddedFontBytes([(65, 1), (66, 2)]); + var (resourcesBody, extraObjects) = BuildSimpleTrueTypeFontResources(fontBytes); + var bytes = BuildSinglePagePdfWithResources( + 100, 100, "BT /F1 20 Tf 5 50 Td (A) Tj 0 -7 TD (A) Tj T* (A) Tj ET", resourcesBody, extraObjects); + + // Act + using var surface = RenderPdfBytes(bytes); + + // Assert: line y = 50, then 43 (TD), then 36 (T* using leading = 7 set by TD) -> device + // y centers approximately 44, 51, 58 respectively. + Assert.NotEqual(default, surface[11, 44]); + Assert.NotEqual(default, surface[11, 51]); + Assert.NotEqual(default, surface[11, 58]); + } + + /// Proves that Tm replaces (rather than composes with) the text/line matrix. + [Fact] + public void PdfDocument_Text_Tm_ReplacesTextAndLineMatrix() + { + // Arrange + var fontBytes = BuildEmbeddedFontBytes([(65, 1), (66, 2)]); + var (resourcesBody, extraObjects) = BuildSimpleTrueTypeFontResources(fontBytes); + var bytes = BuildSinglePagePdfWithResources( + 100, 100, "BT /F1 20 Tf 5 50 Td (A) Tj 1 0 0 1 30 30 Tm (A) Tj ET", resourcesBody, extraObjects); + + // Act + using var surface = RenderPdfBytes(bytes); + + // Assert: 'Tm' replaces (5, 50) outright with (30, 30) - text x/y [32, 40) -> device y + // [60, 68) - not composed with the prior (5, 50) (which would instead land at (35, 80)). + Assert.NotEqual(default, surface[36, 64]); + Assert.Equal(default, surface[41, 14]); + } + + /// Proves that T* moves to the next line using the current leading (set via TL). + [Fact] + public void PdfDocument_Text_TStar_UsesCurrentLeading() + { + // Arrange + var fontBytes = BuildEmbeddedFontBytes([(65, 1), (66, 2)]); + var (resourcesBody, extraObjects) = BuildSimpleTrueTypeFontResources(fontBytes); + var bytes = BuildSinglePagePdfWithResources( + 100, 100, "BT /F1 20 Tf 7 TL 5 50 Td (A) Tj T* (A) Tj ET", resourcesBody, extraObjects); + + // Act + using var surface = RenderPdfBytes(bytes); + + // Assert: line y = 50, then 43 (T* using leading = 7) -> device y centers 44 and 51. + Assert.NotEqual(default, surface[11, 44]); + Assert.NotEqual(default, surface[11, 51]); + } + + /// Proves that Tc (character spacing) is added to every glyph's advance. + [Fact] + public void PdfDocument_Text_Tc_AddsToGlyphAdvance() + { + // Arrange: 'A's own advance is 0.6 * 20 = 12; with Tc = 3, 'B' starts at Tm.x = 5 + 15 = 20. + var fontBytes = BuildEmbeddedFontBytes([(65, 1), (66, 2)]); + var (resourcesBody, extraObjects) = BuildSimpleTrueTypeFontResources(fontBytes); + var bytes = BuildSinglePagePdfWithResources( + 100, 100, "BT /F1 20 Tf 5 50 Td 3 Tc (AB) Tj ET", resourcesBody, extraObjects); + + // Act + using var surface = RenderPdfBytes(bytes); + + // Assert: 'B' paints at text x [22, 30); without Tc it would paint at [19, 27) instead. + Assert.NotEqual(default, surface[29, 44]); + Assert.Equal(default, surface[20, 44]); + } + + /// Proves that Tw (word spacing) applies only to single-byte character code 32 (space), not to any other code. + [Fact] + public void PdfDocument_Text_Tw_AppliesOnlyToCode32() + { + // Arrange: "A A" is codes 65, 32, 65. 'A's own advance is 12; the space (code 32, no + // glyph, zero declared/font advance) gets an extra 5 units from Tw. The final 'A' + // starts at Tm.x = 5 + 12 + 5 = 22, painting at text x [24, 32). + var fontBytes = BuildEmbeddedFontBytes([(65, 1), (66, 2)]); + var (resourcesBody, extraObjects) = BuildSimpleTrueTypeFontResources(fontBytes); + var bytes = BuildSinglePagePdfWithResources( + 100, 100, "BT /F1 20 Tf 5 50 Td 5 Tw (A A) Tj ET", resourcesBody, extraObjects); + + // Act + using var surface = RenderPdfBytes(bytes); + + // Assert: without Tw applied to the space, the final 'A' would instead paint at [19, 27). + Assert.NotEqual(default, surface[30, 44]); + Assert.Equal(default, surface[20, 44]); + } + + /// Proves that Tz (horizontal scaling) scales both glyph shape and advance along the x-axis only. + [Fact] + public void PdfDocument_Text_Tz_ScalesHorizontalShapeAndAdvance() + { + // Arrange: Th = 0.5 halves the glyph's horizontal extent (font-space x scale factor + // becomes 0.01 * 20 * 0.5 = 0.01, so the 100-500 unit square spans text x [1, 5) instead + // of the unscaled [2, 10)) and halves 'A's own advance (0.6 * 20 * 0.5 = 6). + var fontBytes = BuildEmbeddedFontBytes([(65, 1), (66, 2)]); + var (resourcesBody, extraObjects) = BuildSimpleTrueTypeFontResources(fontBytes); + var bytes = BuildSinglePagePdfWithResources( + 100, 100, "BT /F1 20 Tf 50 Tz 5 50 Td (AB) Tj ET", resourcesBody, extraObjects); + + // Act + using var surface = RenderPdfBytes(bytes); + + // Assert: 'A' paints narrowly at text x [6, 10); the unscaled width would instead extend + // well past text x 11, but the correctly-narrowed shape leaves the gap before 'B' empty. + // 'B' starts at Tm.x = 5 + 6 = 11, painting at text x [12, 16). + Assert.NotEqual(default, surface[8, 44]); + Assert.Equal(default, surface[11, 44]); + Assert.NotEqual(default, surface[14, 44]); + } + + /// Proves that TJ's numeric array elements pre-adjust the text position, in thousandths of text-space units. + [Fact] + public void PdfDocument_Text_TJ_AppliesPositionAdjustments() + { + // Arrange: after showing 'A' (advance 12), a "-250" adjustment adds + // -(-250 / 1000) * 20 = 5 further units before 'B' - landing 'B' at Tm.x = 5 + 12 + 5 = 22. + var fontBytes = BuildEmbeddedFontBytes([(65, 1), (66, 2)]); + var (resourcesBody, extraObjects) = BuildSimpleTrueTypeFontResources(fontBytes); + var bytes = BuildSinglePagePdfWithResources( + 100, 100, "BT /F1 20 Tf 5 50 Td [(A) -250 (B)] TJ ET", resourcesBody, extraObjects); + + // Act + using var surface = RenderPdfBytes(bytes); + + // Assert: 'B' paints at text x [24, 32); without the adjustment it would paint at [19, 27). + Assert.NotEqual(default, surface[28, 44]); + Assert.Equal(default, surface[20, 44]); + } + + /// Proves that a TJ array entry that is neither a string nor a number throws . + [Fact] + public void PdfDocument_Text_TJ_ArrayEntryNotStringOrNumber_ThrowsInvalidDataException() + { + Assert.Throws(() => RenderContent("BT [true] TJ ET")); + } + + /// Proves that the ' operator moves to the next line (using the current leading) and then shows its string operand. + [Fact] + public void PdfDocument_Text_QuoteOperator_MovesToNextLineThenShows() + { + // Arrange + var fontBytes = BuildEmbeddedFontBytes([(65, 1), (66, 2)]); + var (resourcesBody, extraObjects) = BuildSimpleTrueTypeFontResources(fontBytes); + var bytes = BuildSinglePagePdfWithResources( + 100, 100, "BT /F1 20 Tf 5 50 Td (A) Tj 7 TL (B) ' ET", resourcesBody, extraObjects); + + // Act + using var surface = RenderPdfBytes(bytes); + + // Assert: 'A' at device y 44; ' moves to the next line (leading 7, from the line + // matrix's own (5, 50), not the text matrix advanced by 'A') and shows 'B' at text x + // [7, 15) again, device y 51 - a buggy "advance from the text matrix" would instead + // shift 'B' rightward, out of this x window. + Assert.NotEqual(default, surface[11, 44]); + Assert.NotEqual(default, surface[11, 51]); + } + + /// Proves that the " operator sets word/character spacing, then behaves like '. + [Fact] + public void PdfDocument_Text_DoubleQuoteOperator_SetsSpacingThenMovesAndShows() + { + // Arrange: '4 2 (A) "' sets Tw=4/Tc=2 then shows 'A' (advance 12 + 2 = 14); the + // following 'Tj' for 'B' starts at Tm.x = 5 + 14 = 19, painting at text x [21, 29). + var fontBytes = BuildEmbeddedFontBytes([(65, 1), (66, 2)]); + var (resourcesBody, extraObjects) = BuildSimpleTrueTypeFontResources(fontBytes); + var bytes = BuildSinglePagePdfWithResources( + 100, 100, "BT /F1 20 Tf 5 50 Td 4 2 (A) \" (B) Tj ET", resourcesBody, extraObjects); + + // Act + using var surface = RenderPdfBytes(bytes); + + // Assert: without Tc applied, 'B' would instead paint at text x [19, 27) only. + Assert.NotEqual(default, surface[28, 44]); + } + + /// Proves that every new text operator's malformed operand count/type throws . + [Theory] + [InlineData("BT 1 2 ET")] + [InlineData("1 2 Tc")] + [InlineData("1 2 Tw")] + [InlineData("1 2 Tz")] + [InlineData("1 2 TL")] + [InlineData("1 2 Ts")] + [InlineData("/F1 Tf")] + [InlineData("24 /F1 Tf")] + [InlineData("Tr")] + [InlineData("1 2 Tr")] + [InlineData("1 Td")] + [InlineData("1 2 3 Td")] + [InlineData("1 TD")] + [InlineData("1 2 3 4 5 Tm")] + [InlineData("1 T*")] + [InlineData("(A) (B) Tj")] + [InlineData("1 Tj")] + [InlineData("1 '")] + [InlineData("1 2 \"")] + [InlineData("1 2 (A) 3 \"")] + [InlineData("(A) TJ")] + public void PdfDocument_Text_MalformedOperandCount_ThrowsInvalidDataException(string content) + { + Assert.Throws(() => RenderContent(content)); + } + + /// Proves that showing a hex string with an odd byte count against a /Type0 (2-byte-per-code) composite font throws . + [Fact] + public void PdfDocument_ShowText_Type0_OddByteLengthString_ThrowsInvalidDataException() + { + // Arrange: a single-byte hex string cannot be split into whole 2-byte composite codes. + var fontBytes = BuildEmbeddedFontBytes([], glyphCount: 3); + var (resourcesBody, extraObjects) = BuildCompositeFontResources(fontBytes); + + var bytes = BuildSinglePagePdfWithResources(100, 100, "BT /F1 20 Tf <01> Tj ET", resourcesBody, extraObjects); + + // Act & Assert + Assert.Throws(() => RenderPdfBytes(bytes)); + } + + /// Proves that Tj against a /Type0 composite font decodes 2 bytes per code and paints each resolved glyph at its correctly advanced position. + [Fact] + public void PdfDocument_ShowText_Type0_TwoByteCodes_ShowsEachGlyphAtCorrectPosition() + { + // Arrange: CID 1's declared width is 1000 (a full em) -> advance = 1.0 * 20 = 20; CID 2's + // declared width is 600 -> advance = 0.6 * 20 = 12 (unused here, but present to mirror the + // simple-font "ExplicitEntry" width test's exact numeric pattern). + var fontBytes = BuildEmbeddedFontBytes([], glyphCount: 3); + var (resourcesBody, extraObjects) = BuildCompositeFontResources( + fontBytes, cidFontExtra: "/W [1 [1000] 2 3 600]"); + + var bytes = BuildSinglePagePdfWithResources( + 100, 100, $"BT /F1 20 Tf 5 5 Td {BuildIdentityHHexString(1, 2)} Tj ET", resourcesBody, extraObjects); + + // Act + using var surface = RenderPdfBytes(bytes); + + // Assert: CID 1 paints at text x [7, 15); CID 2 starts at Tm.x = 5 + 20 = 25, painting at + // text x [27, 35) - clearly past the "no explicit width applied yet" gap boundary. + Assert.NotEqual(default, surface[11, 89]); + Assert.Equal(default, surface[16, 89]); + Assert.NotEqual(default, surface[31, 89]); + } + + /// + /// Proves that Tw (word spacing) is not applied to a composite + /// /Type0//Identity-H font's 2-byte code 32 - per PDF specification + /// section 9.3.3, word spacing only applies to the single-byte code 32 of a simple + /// font, never to any code decoded from a composite font (even one numerically equal to + /// 32). Regression test for a bug where the word-spacing check compared the + /// decoded code to 32 without also checking the font's code-byte-width. + /// + [Fact] + public void PdfDocument_ShowText_Type0_WordSpacingCode32_IsNotAppliedToCompositeFont() + { + // Arrange: CID 32 declares width 1000 (a full em -> 20 device units at font size 20); a + // large 1000 Tw would grossly displace the following CID 1 glyph if incorrectly applied. + var fontBytes = BuildEmbeddedFontBytes([], glyphCount: 33); + var (resourcesBody, extraObjects) = BuildCompositeFontResources( + fontBytes, cidFontExtra: "/W [32 [1000] 1 [1000]]"); + + var bytes = BuildSinglePagePdfWithResources( + 100, 100, $"BT /F1 20 Tf 1000 Tw 5 5 Td {BuildIdentityHHexString(32, 1)} Tj ET", + resourcesBody, extraObjects); + + // Act + using var surface = RenderPdfBytes(bytes); + + // Assert: CID 1 (the second glyph) begins immediately after CID 32's own 20-unit advance + // (Tm.x = 5 + 20 = 25), not displaced by any extra Tw - it paints starting at text x + // ~[27, 35), well short of where an incorrectly-applied 1000 Tw would push it off-canvas. + Assert.NotEqual(default, surface[31, 89]); + } + + #endregion + + #region Patterns + + /// Proves that an axial (/ShadingType 2) shading pattern driven by a /FunctionType 2 function fills with a visibly varying gradient (near-black at the start coordinate, near-white at the end coordinate). + [Fact] + public void PdfDocument_Patterns_ShadingPattern_AxialFunctionType2_FillsVisiblyVaryingGradient() + { + // Arrange: axis from (0,0) to (100,0), black -> white. Object numbering (via + // BuildSinglePagePdfWithResources's extraObjectBodies, starting at 5): 5 = patternDict, + // 6 = shadingDict, 7 = functionStream. + var patternDict = "<< /PatternType 2 /Shading 6 0 R >>"u8.ToArray(); + var shadingDict = "<< /ShadingType 2 /ColorSpace /DeviceRGB /Coords [0 0 100 0] /Function 7 0 R >>"u8.ToArray(); + var functionStream = BuildStreamObjectBody( + "/FunctionType 2 /Domain [0 1] /C0 [0 0 0] /C1 [1 1 1] /N 1", []); + var bytes = BuildSinglePagePdfWithResources( + 100, + 100, + "/Pattern cs /P1 scn 0 0 100 100 re f", + "/Pattern << /P1 5 0 R >>", + [patternDict, shadingDict, functionStream]); + + // Act + using var surface = RenderPdfBytes(bytes); + + // Assert: at least 2 distinct colors across the fill, plus specific endpoint colors. + var distinctColors = new HashSet(); + for (var x = 0; x < 100; x += 10) + { + distinctColors.Add(surface[x, 50]); + } + + Assert.True(distinctColors.Count >= 2); + Assert.True(surface[2, 50].R < 50); + Assert.True(surface[97, 50].R > 200); + } + + /// Proves that a radial (/ShadingType 3) shading pattern driven by a /FunctionType 2 function fills with a visibly varying gradient (center color differs from edge color). + [Fact] + public void PdfDocument_Patterns_ShadingPattern_RadialFunctionType2_FillsVisiblyVaryingGradient() + { + // Arrange: two concentric circles centered at (50,50), inner radius 0, outer radius 50, + // black -> white. + var patternDict = "<< /PatternType 2 /Shading 6 0 R >>"u8.ToArray(); + var shadingDict = "<< /ShadingType 3 /ColorSpace /DeviceRGB /Coords [50 50 0 50 50 50] /Function 7 0 R >>"u8.ToArray(); + var functionStream = BuildStreamObjectBody( + "/FunctionType 2 /Domain [0 1] /C0 [0 0 0] /C1 [1 1 1] /N 1", []); + var bytes = BuildSinglePagePdfWithResources( + 100, + 100, + "/Pattern cs /P1 scn 0 0 100 100 re f", + "/Pattern << /P1 5 0 R >>", + [patternDict, shadingDict, functionStream]); + + // Act + using var surface = RenderPdfBytes(bytes); + + // Assert: the center is darker than near the outer edge. + Assert.True(surface[50, 50].R < surface[95, 50].R); + } + + /// Proves that an unsupported /ShadingType (1 or 4) throws with feature pdf-shading-type-{n}. + [Theory] + [InlineData(1)] + [InlineData(4)] + public void PdfDocument_Patterns_ShadingPattern_UnsupportedShadingType_ThrowsUnsupportedImageFeatureException(int shadingType) + { + // Arrange: 5 = patternDict, 6 = shadingDict. + var patternDict = "<< /PatternType 2 /Shading 6 0 R >>"u8.ToArray(); + var shadingDict = System.Text.Encoding.ASCII.GetBytes($"<< /ShadingType {shadingType} >>"); + var bytes = BuildSinglePagePdfWithResources( + 100, + 100, + "/Pattern cs /P1 scn 0 0 100 100 re f", + "/Pattern << /P1 5 0 R >>", + [patternDict, shadingDict]); + + // Act & Assert + var exception = Assert.Throws(() => RenderPdfBytes(bytes)); + Assert.Equal($"pdf-shading-type-{shadingType}", exception.Feature); + } + + /// Proves that a shading pattern whose /Function is /FunctionType 4 throws with feature pdf-functiontype-4 (regression guard: still rejected even when reached through a shading pattern). + [Fact] + public void PdfDocument_Patterns_ShadingPattern_UnsupportedFunctionType4_ThrowsUnsupportedImageFeatureException() + { + // Arrange: 5 = patternDict, 6 = shadingDict, 7 = functionStream. + var patternDict = "<< /PatternType 2 /Shading 6 0 R >>"u8.ToArray(); + var shadingDict = "<< /ShadingType 2 /ColorSpace /DeviceRGB /Coords [0 0 100 0] /Function 7 0 R >>"u8.ToArray(); + var functionStream = BuildStreamObjectBody("/FunctionType 4", "{ }"u8.ToArray()); + var bytes = BuildSinglePagePdfWithResources( + 100, + 100, + "/Pattern cs /P1 scn 0 0 100 100 re f", + "/Pattern << /P1 5 0 R >>", + [patternDict, shadingDict, functionStream]); + + // Act & Assert + var exception = Assert.Throws(() => RenderPdfBytes(bytes)); + Assert.Equal("pdf-functiontype-4", exception.Feature); + } + + /// Proves that a shading pattern's /Function array-of-1-output-functions form (3 separate /FunctionType 2 functions, one per RGB component) builds a working gradient. + [Fact] + public void PdfDocument_Patterns_ShadingPattern_FunctionArrayForm_ThreeOneOutputFunctions_BuildsRgbGradient() + { + // Arrange: red ramps 0->1, green stays 0, blue stays 0 - a pure red gradient. Object + // numbering: 5 = patternDict, 6 = shadingDict, 7/8/9 = fnR/fnG/fnB. + var patternDict = "<< /PatternType 2 /Shading 6 0 R >>"u8.ToArray(); + var shadingDict = "<< /ShadingType 2 /ColorSpace /DeviceRGB /Coords [0 0 100 0] /Function [7 0 R 8 0 R 9 0 R] >>"u8.ToArray(); + var fnR = BuildStreamObjectBody("/FunctionType 2 /Domain [0 1] /C0 [0] /C1 [1] /N 1", []); + var fnG = BuildStreamObjectBody("/FunctionType 2 /Domain [0 1] /C0 [0] /C1 [0] /N 1", []); + var fnB = BuildStreamObjectBody("/FunctionType 2 /Domain [0 1] /C0 [0] /C1 [0] /N 1", []); + var bytes = BuildSinglePagePdfWithResources( + 100, + 100, + "/Pattern cs /P1 scn 0 0 100 100 re f", + "/Pattern << /P1 5 0 R >>", + [patternDict, shadingDict, fnR, fnG, fnB]); + + // Act + using var surface = RenderPdfBytes(bytes); + + // Assert + Assert.True(surface[2, 50].R < 50); + Assert.Equal(0, surface[2, 50].G); + Assert.Equal(0, surface[2, 50].B); + Assert.True(surface[97, 50].R > 200); + } + + /// Builds a simple colored (/PaintType 1) tiling pattern stream: a 10x10 cell whose left half is red and whose right half is blue. + private static byte[] BuildCheckerboardTilingPatternStream(string extraEntries = "") + { + const string cellContent = "1 0 0 rg 0 0 5 10 re f 0 0 1 rg 5 0 5 10 re f"; + return BuildStreamObjectBody( + $"/PatternType 1 /PaintType 1 /TilingType 1 /BBox [0 0 10 10] /XStep 10 /YStep 10 {extraEntries}", + System.Text.Encoding.ASCII.GetBytes(cellContent)); + } + + /// Proves that a colored (/PaintType 1) tiling pattern fill paints a repeating tile, with both tile colors appearing at multiple correctly offset sample points. + [Fact] + public void PdfDocument_Patterns_TilingPattern_ColoredPaintType1_FillsRepeatingTile() + { + // Arrange: 10x10 pattern-space cell, left half red / right half blue, tiled across a + // 100x100 fill - at 1:1 scale, device pixel columns repeat identically to pattern-space + // columns (device y is flipped, but the tile content is uniform along y). + var bytes = BuildSinglePagePdfWithResources( + 100, + 100, + "/Pattern cs /P1 scn 0 0 100 100 re f", + "/Pattern << /P1 5 0 R >>", + [BuildCheckerboardTilingPatternStream()]); + + // Act + using var surface = RenderPdfBytes(bytes); + + // Assert: both colors appear, at multiple repeated tile offsets (every 10 device pixels). + Assert.Equal(new Canvas.Rgba32(255, 0, 0, 255), surface[2, 50]); + Assert.Equal(new Canvas.Rgba32(0, 0, 255, 255), surface[7, 50]); + Assert.Equal(new Canvas.Rgba32(255, 0, 0, 255), surface[12, 50]); + Assert.Equal(new Canvas.Rgba32(0, 0, 255, 255), surface[17, 50]); + Assert.Equal(new Canvas.Rgba32(255, 0, 0, 255), surface[92, 50]); + Assert.Equal(new Canvas.Rgba32(0, 0, 255, 255), surface[97, 50]); + } + + /// Proves that an uncolored (/PaintType 2) tiling pattern applies the scn-supplied tint to painted pixels while preserving alpha, leaving unpainted (transparent) cell regions untouched. + [Fact] + public void PdfDocument_Patterns_TilingPattern_UncoloredPaintType2_AppliesSuppliedTintPreservingAlpha() + { + // Arrange: a /PaintType 2 cell that paints only a small centered 4x4 square (leaving the + // rest of the 10x10 cell transparent); 'scn' supplies a green tint via a /CS0 named + // color space resolving to [/Pattern /DeviceRGB] (an inline array is not itself a legal + // 'cs' operand - PDF content streams only allow a Name operand there, resolved against + // /Resources/ColorSpace). Object numbering: 5 = patternStream. + var cellContent = "0 0 0 rg 3 3 4 4 re f"; + var patternStream = BuildStreamObjectBody( + "/PatternType 1 /PaintType 2 /TilingType 1 /BBox [0 0 10 10] /XStep 10 /YStep 10", + System.Text.Encoding.ASCII.GetBytes(cellContent)); + var bytes = BuildSinglePagePdfWithResources( + 100, + 100, + "/CS0 cs 0 1 0 /P1 scn 0 0 10 10 re f", + "/ColorSpace << /CS0 [/Pattern /DeviceRGB] >> /Pattern << /P1 5 0 R >>", + [patternStream]); + + // Act + using var surface = RenderPdfBytes(bytes); + + // Assert: the painted 4x4 square (pattern-space x in [3,7), y in [3,7), device y flipped + // to [93,97)) takes the green tint; the untouched corner of the cell stays transparent. + Assert.Equal(new Canvas.Rgba32(0, 255, 0, 255), surface[5, 95]); + Assert.Equal(default, surface[1, 99]); + } + + /// Proves that a pathological /XStep//YStep combined with an extreme CTM scale (that would otherwise allocate a tile surface exceeding MaxTileSurfaceDimension) throws with feature pdf-pattern-tile-too-large. + [Fact] + public void PdfDocument_Patterns_TilingPattern_OversizedTileDimensions_ThrowsUnsupportedImageFeatureException() + { + // Arrange: a tiny 0.01x0.01 pattern-space cell, but the pattern's own /Matrix scales it + // up 1,000,000x - the resulting tile would need a 10,000x10,000 device-pixel surface, + // far exceeding MaxTileSurfaceDimension (2048). + var patternStream = BuildStreamObjectBody( + "/PatternType 1 /PaintType 1 /TilingType 1 /BBox [0 0 0.01 0.01] /XStep 0.01 /YStep 0.01 " + + "/Matrix [1000000 0 0 1000000 0 0]", + "1 0 0 rg 0 0 0.01 0.01 re f"u8.ToArray()); + var bytes = BuildSinglePagePdfWithResources( + 100, + 100, + "/Pattern cs /P1 scn 0 0 100 100 re f", + "/Pattern << /P1 5 0 R >>", + [patternStream]); + + // Act & Assert + var exception = Assert.Throws(() => RenderPdfBytes(bytes)); + Assert.Equal("pdf-pattern-tile-too-large", exception.Feature); + } + + /// Proves that a malformed (zero) /XStep throws rather than being treated merely as unsupported. + [Fact] + public void PdfDocument_Patterns_TilingPattern_ZeroXStep_ThrowsInvalidDataException() + { + // Arrange + var patternStream = BuildStreamObjectBody( + "/PatternType 1 /PaintType 1 /TilingType 1 /BBox [0 0 10 10] /XStep 0 /YStep 10", + "1 0 0 rg 0 0 10 10 re f"u8.ToArray()); + var bytes = BuildSinglePagePdfWithResources( + 100, + 100, + "/Pattern cs /P1 scn 0 0 100 100 re f", + "/Pattern << /P1 5 0 R >>", + [patternStream]); + + // Act & Assert + Assert.Throws(() => RenderPdfBytes(bytes)); + } + + /// Proves that a tiling pattern whose own content stream selects/paints with another tiling pattern, nested deeply enough, still triggers the existing MaxFormNestingDepth guard (shared, not a new counter) - confirming the guard fires through this new call path, without any dedicated correctness test of deep nested rendering itself (out of scope). + [Fact] + public void PdfDocument_Patterns_TilingPattern_NestingDepthExceeded_ThrowsInvalidDataException() + { + // Arrange: pattern /P1's own content stream fills using itself (/P1) again, recursing + // indefinitely until the shared Form/tiling-pattern nesting-depth guard fires. + var patternStream = BuildStreamObjectBody( + "/PatternType 1 /PaintType 1 /TilingType 1 /BBox [0 0 10 10] /XStep 10 /YStep 10 " + + "/Resources << /Pattern << /P1 5 0 R >> >>", + "/Pattern cs /P1 scn 0 0 10 10 re f"u8.ToArray()); + var bytes = BuildSinglePagePdfWithResources( + 100, + 100, + "/Pattern cs /P1 scn 0 0 100 100 re f", + "/Pattern << /P1 5 0 R >>", + [patternStream]); + + // Act & Assert + Assert.Throws(() => RenderPdfBytes(bytes)); + } + + #endregion + + #region Functions + + /// + /// Resolves a single-input /FunctionType 0 sampled function whose stream is a + /// new indirect object (number 5, matching 's + /// own extra-object numbering) referenced by 's own + /// dictionary - mirroring the ResolveToUnicodeMap tests' own "build a minimal + /// document, open it, resolve a reference into it" convention. + /// + private static PdfDocument.SampledFunction ResolveTestFunction(string dictionaryEntries, byte[] sampleBytes) + { + var functionStream = BuildStreamObjectBody(dictionaryEntries, sampleBytes); + var bytes = BuildSinglePagePdfWithResources(100, 100, "BT ET", string.Empty, [functionStream]); + using var document = PdfDocument.Open(new MemoryStream(bytes)); + return document.ResolveFunction(PdfDocument.PdfObject.FromReference(5, 0)); + } + + /// Packs (each in [0, 65535]) as 16-bit big-endian sample bytes. + private static byte[] BuildUInt16SampleBytes(params int[] values) + { + var bytes = new byte[values.Length * 2]; + for (var i = 0; i < values.Length; i++) + { + bytes[i * 2] = (byte)(values[i] >> 8); + bytes[(i * 2) + 1] = (byte)values[i]; + } + + return bytes; + } + + /// Proves that an input outside /Domain is clamped to the nearest domain boundary before being mapped to a sample index. + [Fact] + public void PdfDocument_Functions_Type0_InputOutsideDomain_ClampsToBoundary() + { + // Arrange: Domain [0, 1], 2 samples (0, 255), Range [0, 255] so Decode is an identity + // pass-through and the result directly reflects the clamped-then-selected raw sample. + var function = ResolveTestFunction( + "/FunctionType 0 /Domain [0 1] /Range [0 255] /Size [2] /BitsPerSample 8", + [0, 255]); + + // Act + var belowDomain = function.Evaluate(-5); + var aboveDomain = function.Evaluate(5); + + // Assert: -5 clamps to 0 (selects sample 0); 5 clamps to 1 (selects sample 1). + Assert.Equal(0.0, belowDomain[0]); + Assert.Equal(255.0, aboveDomain[0]); + } + + /// Proves that an absent /Encode defaults to [0, Size - 1], mapping the full /Domain onto the full sample-index range. + [Fact] + public void PdfDocument_Functions_Type0_EncodeAbsent_DefaultsToZeroToSizeMinusOne() + { + // Arrange: Domain [0, 10], 6 samples evenly spaced 0..255, no /Encode - the default + // [0, Size - 1] = [0, 5] maps input 5 (domain midpoint) to sample index 2.5. + var function = ResolveTestFunction( + "/FunctionType 0 /Domain [0 10] /Range [0 255] /Size [6] /BitsPerSample 8", + [0, 51, 102, 153, 204, 255]); + + // Act + var result = function.Evaluate(5); + + // Assert: interpolates halfway between sample 2 (102) and sample 3 (153) -> 127.5. + Assert.Equal(127.5, result[0]); + } + + /// Proves that an explicit /Encode overrides the default, mapping /Domain onto a custom sample-index sub-range. + [Fact] + public void PdfDocument_Functions_Type0_ExplicitEncode_MapsDomainToCustomSampleIndexRange() + { + // Arrange: Domain [0, 1], 10 samples, /Encode [2, 5] - without this override, the default + // encode ([0, 9]) would select sample 0 at input 0 and sample 9 at input 1, both 0; the + // explicit override instead selects samples 2 and 5, which alone carry non-zero values. + var function = ResolveTestFunction( + "/FunctionType 0 /Domain [0 1] /Range [0 255] /Size [10] /BitsPerSample 8 /Encode [2 5]", + [0, 0, 50, 0, 0, 200, 0, 0, 0, 0]); + + // Act + var atDomainMin = function.Evaluate(0); + var atDomainMax = function.Evaluate(1); + + // Assert: domain 0 -> sample index 2 (50); domain 1 -> sample index 5 (200). + Assert.Equal(50.0, atDomainMin[0]); + Assert.Equal(200.0, atDomainMax[0]); + } + + /// Proves that /Decode linearly remaps a raw sample value into its own declared range, independent of (though still clipped to) /Range. + [Fact] + public void PdfDocument_Functions_Type0_Decode_MapsRawSampleToCustomOutputRange() + { + // Arrange: Domain [0, 1], 2 samples (0, 255), /Decode [10, 20] (distinct from the wider + // /Range [0, 100], so the assertions below isolate /Decode's own mapping, not /Range's + // clipping). + var function = ResolveTestFunction( + "/FunctionType 0 /Domain [0 1] /Range [0 100] /Decode [10 20] /Size [2] /BitsPerSample 8", + [0, 255]); + + // Act + var atDomainMin = function.Evaluate(0); + var atDomainMax = function.Evaluate(1); + + // Assert: raw sample 0 decodes to 10; raw sample 255 decodes to 20. + Assert.Equal(10.0, atDomainMin[0]); + Assert.Equal(20.0, atDomainMax[0]); + } + + /// Proves that a sample-index position that falls between two samples linearly interpolates between them. + [Fact] + public void PdfDocument_Functions_Type0_NonIntegerSampleIndex_InterpolatesBetweenAdjacentSamples() + { + // Arrange: Domain [0, 1], 3 samples (0, 100, 200), Range [0, 255] (identity Decode). + // Default /Encode [0, 2] maps input 0.25 to sample index 0.5 - exactly halfway between + // sample 0 and sample 1. + var function = ResolveTestFunction( + "/FunctionType 0 /Domain [0 1] /Range [0 255] /Size [3] /BitsPerSample 8", + [0, 100, 200]); + + // Act + var result = function.Evaluate(0.25); + + // Assert + Assert.Equal(50.0, result[0]); + } + + /// Proves that 8-bit samples are read and evaluated correctly. + [Fact] + public void PdfDocument_Functions_Type0_EightBitSamples_EvaluatesCorrectly() + { + // Arrange + var function = ResolveTestFunction( + "/FunctionType 0 /Domain [0 1] /Range [0 255] /Size [2] /BitsPerSample 8", + [10, 250]); + + // Act + var atDomainMin = function.Evaluate(0); + var atDomainMax = function.Evaluate(1); + + // Assert + Assert.Equal(10.0, atDomainMin[0]); + Assert.Equal(250.0, atDomainMax[0]); + } + + /// Proves that 16-bit samples (big-endian, 2 bytes per sample) are read and evaluated correctly. + [Fact] + public void PdfDocument_Functions_Type0_SixteenBitSamples_EvaluatesCorrectly() + { + // Arrange: Range [0, 65535] matches the 16-bit maximum raw sample value exactly, so + // Decode (defaulted to Range) is an identity pass-through. + var function = ResolveTestFunction( + "/FunctionType 0 /Domain [0 1] /Range [0 65535] /Size [2] /BitsPerSample 16", + BuildUInt16SampleBytes(1000, 60000)); + + // Act + var atDomainMin = function.Evaluate(0); + var atDomainMax = function.Evaluate(1); + + // Assert + Assert.Equal(1000.0, atDomainMin[0]); + Assert.Equal(60000.0, atDomainMax[0]); + } + + /// Proves that a /FunctionType other than 0 (2 exponential, 3 stitching, 4 PostScript calculator) throws rather than being evaluated. + [Theory] + [InlineData(2)] + [InlineData(3)] + [InlineData(4)] + public void PdfDocument_Functions_Type0_UnsupportedFunctionType_ThrowsUnsupportedImageFeatureException(int functionType) + { + // Act & Assert + var exception = Assert.Throws( + () => ResolveTestFunction($"/FunctionType {functionType}", [])); + Assert.Equal($"pdf-functiontype-{functionType}", exception.Feature); + } + + /// Proves that a multi-input /FunctionType 0 function (a /Domain with more than 2 elements - the /DeviceN//Separation tint-transform shape) throws . + [Fact] + public void PdfDocument_Functions_Type0_MultiInputDomain_ThrowsUnsupportedImageFeatureException() + { + // Act & Assert: a 2-input function's /Domain has 4 elements (a [min, max] pair per input). + var exception = Assert.Throws( + () => ResolveTestFunction("/FunctionType 0 /Domain [0 1 0 1]", [])); + Assert.Equal("pdf-function-multiinput", exception.Feature); + } + + /// Proves that a sample-table whose /Size and output count would overflow int32 arithmetic in the bit-offset computation (silently wrapping to a negative offset, which previously bypassed 's own truncation guard and raised a raw ) now throws instead. + [Fact] + public void PdfDocument_Functions_Type0_SampleTableDimensionsOverflowBitOffset_ThrowsInvalidDataException() + { + // Arrange: /Size [2000000000] with 2 outputs makes (sampleGroupIndex * outputCount) + // exceed int32.MaxValue when evaluated at the sample index nearest the domain maximum - + // silently wrapping negative under unchecked int arithmetic before this phase's fix. + var function = ResolveTestFunction( + "/FunctionType 0 /Domain [0 1] /Range [0 255 0 255] /Size [2000000000] /BitsPerSample 8", + []); + + // Act & Assert + Assert.Throws(() => function.Evaluate(1)); + } + + /// + /// Proves that a /Size element too large for a to + /// represent (a run of 310 9 digits, which overflows to + /// during PDF numeric-token parsing - PDF's own + /// numeric syntax has no exponent form, so this is the only way a non-finite value can + /// reach /Size from PDF text) throws instead of + /// reaching the unchecked (int) cast that previously followed. + /// + [Fact] + public void PdfDocument_Functions_Type0_SizeElementNonFinite_ThrowsInvalidDataException() + { + // Arrange: a 310-digit literal exceeds double.MaxValue (~1.8e308) and parses to + // positive infinity. + var oversizedDigits = new string('9', 310); + + // Act & Assert + var exception = Assert.Throws( + () => ResolveTestFunction( + $"/FunctionType 0 /Domain [0 1] /Range [0 255] /Size [{oversizedDigits}] /BitsPerSample 8", + [])); + Assert.Contains("/Size", exception.Message, StringComparison.Ordinal); + } + + /// + /// Resolves a /Function entry (any of the 3 supported /FunctionTypes) whose + /// dictionary is a new indirect object (number 5, matching + /// 's own extra-object numbering) via + /// . + /// + private static PdfDocument.IPdfFunction ResolveTestFunctionGeneric(string dictionaryEntries, byte[] streamBytes) + { + var functionStream = BuildStreamObjectBody(dictionaryEntries, streamBytes); + var bytes = BuildSinglePagePdfWithResources(100, 100, "BT ET", string.Empty, [functionStream]); + using var document = PdfDocument.Open(new MemoryStream(bytes)); + return document.ResolveFunctionGeneric(PdfDocument.PdfObject.FromReference(5, 0)); + } + + /// Proves that ExponentialFunction.Evaluate computes the expected interpolated output at known input/output points, for both a linear (N=1) and a quadratic (N=2) exponent. + [Theory] + [InlineData(1, 0.5, 0.5)] + [InlineData(2, 0.5, 0.25)] + public void PdfDocument_Functions_Type2_Exponential_EvaluatesExpectedInterpolatedOutput(double n, double input, double expected) + { + // Arrange: C0=0, C1=1 -> output = input^N. + var function = ResolveTestFunctionGeneric($"/FunctionType 2 /Domain [0 1] /C0 [0] /C1 [1] /N {n}", []); + + // Act + var result = function.Evaluate(input); + + // Assert + Assert.Equal(1, function.OutputCount); + Assert.Equal(expected, result[0], 5); + } + + /// Proves that StitchingFunction.Evaluate selects the correct sub-function for inputs falling in each of 2 partitions, applying each sub-function's own /Encode remap. + [Theory] + [InlineData(0.25, 0.0)] + [InlineData(0.75, 1.0)] + public void PdfDocument_Functions_Type3_Stitching_EvaluatesExpectedSubFunctionOutput(double input, double expected) + { + // Arrange: Domain [0, 1], Bounds [0.5] splits into 2 sub-domains; sub-function 0 (flat 0) + // covers [0, 0.5), sub-function 1 (flat 1) covers [0.5, 1]; /Encode [0 1 0 1] is an + // identity remap for each sub-domain. + var fn0 = BuildStreamObjectBody("/FunctionType 2 /Domain [0 1] /C0 [0] /C1 [0] /N 1", []); + var fn1 = BuildStreamObjectBody("/FunctionType 2 /Domain [0 1] /C0 [1] /C1 [1] /N 1", []); + var stitchingStream = BuildStreamObjectBody( + "/FunctionType 3 /Domain [0 1] /Functions [6 0 R 7 0 R] /Bounds [0.5] /Encode [0 1 0 1]", []); + var bytes = BuildSinglePagePdfWithResources(100, 100, "BT ET", string.Empty, [stitchingStream, fn0, fn1]); + using var document = PdfDocument.Open(new MemoryStream(bytes)); + var function = document.ResolveFunctionGeneric(PdfDocument.PdfObject.FromReference(5, 0)); + + // Act + var result = function.Evaluate(input); + + // Assert + Assert.Equal(expected, result[0], 5); + } + + /// Proves that /FunctionType 4 (PostScript calculator) still throws with feature pdf-functiontype-4 through the generic resolver (regression guard, updated message content). + [Fact] + public void PdfDocument_Functions_Type4_PostScriptCalculator_ThrowsUnsupportedImageFeatureException() + { + // Act & Assert + var exception = Assert.Throws( + () => ResolveTestFunctionGeneric("/FunctionType 4", "{ }"u8.ToArray())); + Assert.Equal("pdf-functiontype-4", exception.Feature); + } + + /// Proves that a /FunctionType 3 stitching function whose own /Functions array references itself triggers the new MaxFunctionRecursionDepth guard with , rather than exhausting the process' call stack with an uncatchable . + [Fact] + public void PdfDocument_Functions_Type3_Stitching_NestingDepthExceeded_ThrowsInvalidDataException() + { + // Arrange: object 5's own /Functions array references itself (5 0 R), recursing + // indefinitely until the new function-resolution nesting-depth guard fires. A single + // sub-function (k = 1) requires 0 /Bounds elements and 2 /Encode elements. + var stitchingStream = BuildStreamObjectBody( + "/FunctionType 3 /Domain [0 1] /Functions [5 0 R] /Bounds [] /Encode [0 1]", []); + var bytes = BuildSinglePagePdfWithResources(100, 100, "BT ET", string.Empty, [stitchingStream]); + using var document = PdfDocument.Open(new MemoryStream(bytes)); + + // Act & Assert + Assert.Throws( + () => document.ResolveFunctionGeneric(PdfDocument.PdfObject.FromReference(5, 0))); + } + + #endregion +} diff --git a/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfFixtureTests.cs b/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfFixtureTests.cs new file mode 100644 index 00000000..4b3f041d --- /dev/null +++ b/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfFixtureTests.cs @@ -0,0 +1,265 @@ +using DemaConsulting.CanvasNet.Codecs; +using DemaConsulting.CanvasNet.Fonts; + +// cspell:ignore xobject devicergb fontfile + +namespace DemaConsulting.CanvasNet.Pdf.Tests; + +/// +/// Fixture-conformance tests that exercise against the real-file PDF +/// fixture corpus in PdfFixtures (see PdfFixtures\README.md for provenance - the +/// entire corpus is hand-authored for this repository), mirroring the pattern used by +/// SvgFixtureTests in DemaConsulting.CanvasNet.Svg.Tests. Each test opens one +/// on-disk fixture, reads its page info, renders at least one page, and asserts a concrete, +/// non-trivial rendered result - a broad "this file loads and renders as documented" check, +/// distinct from (and complementing, not duplicating) the finer-grained behavioral assertions +/// already made against several of the same files in and +/// . +/// +public class PdfFixtureTests +{ + /// The directory containing the PDF fixture corpus, copied to the test output directory + /// by this project's PdfFixtures\** content item. + private static string FixturesPath => Path.Join(AppContext.BaseDirectory, "PdfFixtures"); + + /// Resolves a fixture file within . + private static string Fixture(string name) => Path.Join(FixturesPath, name); + + /// + /// The path to the real "Open Sans" TrueType font, copied to the test output directory by + /// this project's FontFixtures\** content-link item (see + /// DemaConsulting.CanvasNet.Tests\FontFixtures\README.md for provenance/licensing). + /// + private static string FontPath => Path.Join(AppContext.BaseDirectory, "FontFixtures", "OpenSans-Regular.ttf"); + + /// + /// Proves that at least one non-transparent pixel was painted somewhere on + /// - the broad "real content was rendered, not merely a blank + /// surface" assertion this fixture-conformance tier favors over per-pixel checks (which + /// already exist, per fixture, in ). + /// + private static void AssertPaintedSomePixel(Canvas.Surface surface) + { + for (var y = 0; y < surface.Height; y++) + { + for (var x = 0; x < surface.Width; x++) + { + if (surface[x, y].A > 0) + { + return; + } + } + } + + Assert.Fail("Expected at least one non-transparent pixel to be painted."); + } + + /// + /// Proves classic-xref-single-page.pdf (a classic xref table + trailer + /// dictionary, Phase 1) opens, reports one page at its declared /MediaBox size, and + /// renders without error. + /// + [Fact] + public void PdfDocument_Load_ClassicXrefSinglePageFixture_OpensAndRenders() + { + // Arrange & Act + using var document = PdfDocument.Open(Fixture("classic-xref-single-page.pdf")); + var info = document.GetPageInfo(0); + using var surface = document.Render(0, info.Width, info.Height); + + // Assert + Assert.Equal(1, document.PageCount); + Assert.Equal(info.Width, surface.Width); + Assert.Equal(info.Height, surface.Height); + } + + /// + /// Proves multi-page-mixed-mediabox-rotate.pdf (three pages, differing/inherited + /// /MediaBox and /Rotate) reports the correct page count and renders every + /// page at its own reported (rotation-swapped, where applicable) size. + /// + [Fact] + public void PdfDocument_Load_MultiPageMixedMediaBoxRotateFixture_RendersEveryPageAtItsOwnSize() + { + // Arrange + using var document = PdfDocument.Open(Fixture("multi-page-mixed-mediabox-rotate.pdf")); + + // Act & Assert + Assert.Equal(3, document.PageCount); + for (var pageIndex = 0; pageIndex < document.PageCount; pageIndex++) + { + var info = document.GetPageInfo(pageIndex); + using var surface = document.Render(pageIndex, info.Width, info.Height); + Assert.Equal(info.Width, surface.Width); + Assert.Equal(info.Height, surface.Height); + } + } + + /// + /// Proves color-rgb-rectangle-fill.pdf (Phase 3 rg device color) renders a + /// visible, non-transparent painted region. + /// + [Fact] + public void PdfDocument_Load_ColorRgbRectangleFillFixture_PaintsVisibleContent() + { + // Arrange & Act + using var document = PdfDocument.Open(Fixture("color-rgb-rectangle-fill.pdf")); + using var surface = document.Render(0, 100, 100); + + // Assert + AssertPaintedSomePixel(surface); + } + + /// + /// Proves image-xobject-devicergb-flate.pdf (Phase 3 /Subtype /Image XObject) + /// renders a visible, non-transparent painted region. + /// + [Fact] + public void PdfDocument_Load_ImageXObjectDeviceRgbFlateFixture_PaintsVisibleContent() + { + // Arrange & Act + using var document = PdfDocument.Open(Fixture("image-xobject-devicergb-flate.pdf")); + using var surface = document.Render(0, 100, 100); + + // Assert + AssertPaintedSomePixel(surface); + } + + /// + /// Proves text-embedded-truetype-font.pdf (Phase 4 embedded /FontFile2) renders + /// visible glyph ink. + /// + [Fact] + public void PdfDocument_Load_TextEmbeddedTrueTypeFontFixture_PaintsVisibleGlyphInk() + { + // Arrange & Act + using var document = PdfDocument.Open(Fixture("text-embedded-truetype-font.pdf")); + using var surface = document.Render(0, 200, 100); + + // Assert + AssertPaintedSomePixel(surface); + } + + /// + /// Proves each of the four Phase 7 content-stream filter fixtures (LZWDecode, + /// ASCII85Decode, ASCIIHexDecode, RunLengthDecode) decodes correctly + /// and renders the same visible filled rectangle. + /// + [Theory] + [InlineData("content-stream-lzw.pdf")] + [InlineData("content-stream-ascii85.pdf")] + [InlineData("content-stream-asciihex.pdf")] + [InlineData("content-stream-runlength.pdf")] + public void PdfDocument_Load_ContentStreamFilterFixtures_PaintsVisibleContent(string fixtureName) + { + // Arrange & Act + using var document = PdfDocument.Open(Fixture(fixtureName)); + using var surface = document.Render(0, 100, 100); + + // Assert + AssertPaintedSomePixel(surface); + } + + /// + /// Proves the new combined-vector-text-image.pdf breadth fixture - a single page + /// combining a filled/stroked vector shape, a placed image XObject, and text drawn with an + /// embedded TrueType font - opens and renders visible content from all three construct + /// families in one document (mirroring how several SVG fixtures combine multiple + /// constructs per file). + /// + [Fact] + public void PdfDocument_Load_CombinedVectorTextImageFixture_RendersAllThreeConstructFamilies() + { + // Arrange: MediaBox [0 0 200 200], filled rect at (10,10)-(50,50), stroked line at x=60 + // from y=10 to y=90, a 60x60 image placed at (100,10), and "HO" text at (100,150). + using var document = PdfDocument.Open(Fixture("combined-vector-text-image.pdf")); + + // Act + using var surface = document.Render(0, 200, 200); + + // Assert: the filled vector rectangle painted opaque black. + Assert.Equal(new Canvas.Rgba32(0, 0, 0, 255), surface[30, 170]); + + // Assert: the stroked vertical line painted opaque black at its own x-position. + Assert.Equal(new Canvas.Rgba32(0, 0, 0, 255), surface[60, 130]); + + // Assert: the placed image's device-space footprint (x in [100,160], y in [130,190] once + // flipped for a 200-tall MediaBox) contains real composited color, not just background. + Assert.True(surface[130, 160].A > 0, "Expected the placed image to composite visible pixels."); + + // Assert: some glyph ink was painted from the embedded-font text draw. + AssertPaintedSomePixel(surface); + } + + /// + /// Proves the new standard14-font-fallback.pdf fixture - the first on-disk fixture + /// exercising the Phase 6 font-fallback path (/BaseFont /Helvetica, no embedded + /// /FontFile2) - renders visible substitute glyph ink. As with the equivalent + /// synthetic-PDF test in , the actual substitute + /// glyph shape varies across the CI platform matrix, so only "some ink was painted" is + /// asserted here. + /// + [Fact] + public void PdfDocument_Load_Standard14FontFallbackFixture_PaintsVisibleSubstituteGlyphInk() + { + // Arrange & Act + using var document = PdfDocument.Open(Fixture("standard14-font-fallback.pdf")); + using var surface = document.Render(0, 100, 100); + + // Assert + AssertPaintedSomePixel(surface); + } + + /// + /// Proves the new text-embedded-type1-font.pdf fixture (Phase B) - a + /// hand-authored, entirely synthetic /Subtype /Type1 font with a classic + /// PostScript /FontDescriptor/FontFile program - opens and renders visible glyph + /// ink from its embedded Type 1 font program. + /// + [Fact] + public void PdfDocument_Load_TextEmbeddedType1FontFixture_PaintsVisibleGlyphInk() + { + // Arrange & Act + using var document = PdfDocument.Open(Fixture("text-embedded-type1-font.pdf")); + using var surface = document.Render(0, 100, 100); + + // Assert + AssertPaintedSomePixel(surface); + } + + /// + /// Proves the new text-standard14-type1-no-fontfile.pdf fixture (Phase B) - a + /// /Subtype /Type1, /BaseFont /Helvetica font with no + /// /FontDescriptor/FontFile at all - resolves via the free non-embedded fallback + /// path and renders visible substitute glyph ink, exactly like + /// 's + /// own TrueType-subtype equivalent, now proven reachable for /Type1 too. + /// + [Fact] + public void PdfDocument_Load_TextStandard14Type1NoFontFileFixture_PaintsVisibleSubstituteGlyphInk() + { + // Arrange & Act + using var document = PdfDocument.Open(Fixture("text-standard14-type1-no-fontfile.pdf")); + using var surface = document.Render(0, 100, 100); + + // Assert + AssertPaintedSomePixel(surface); + } + + /// + /// Proves the shared "Open Sans" font fixture used by combined-vector-text-image.pdf + /// and text-embedded-truetype-font.pdf loads independently via the real + /// API, confirming the embedded /FontFile2 bytes those + /// fixtures carry are a genuine, well-formed copy of the shared font (not corrupted while + /// hand-authoring the fixture). + /// + [Fact] + public void PdfDocument_Load_SharedFontFixture_LoadsAsValidTrueTypeFont() + { + // Arrange & Act + var font = TrueTypeFont.Load(FontPath); + + // Assert + Assert.NotEqual(0, font.GetGlyphIndex('H')); + } +} diff --git a/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfFixtures/README.md b/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfFixtures/README.md new file mode 100644 index 00000000..9e37be97 --- /dev/null +++ b/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfFixtures/README.md @@ -0,0 +1,118 @@ +# PDF Test Fixtures + + + + + + +Every PDF file in this folder is a small, hand-authored document created specifically for this +repository to exercise `PdfDocument`'s parsing internals (tokenizer, object model, +cross-reference resolution in all three forms, the linear-scan fallback, page-tree traversal with +inheritance, and `/Encrypt` detection - Phase 1), its content-stream interpreter (path +construction/painting operators, the graphics-state stack, CTM/rotation derivation, and +`/Contents` resolution - Phase 2), its device color/image-XObject support (`rg`/`cs`/`scn` +device color operators and `Do`-placed image XObjects - Phase 3), its embedded-TrueType text +support (`Tf`/`Td`/`Tj` against a `/FontFile2`-embedded simple font - Phase 4), its Standard-14 +font-fallback substitution (Phase 6), its `LZWDecode`/`ASCII85Decode`/`ASCIIHexDecode`/ +`RunLengthDecode` content-stream filter set (Phase 7), its `/Type0`/`/Encoding /Identity-H`/ +`/CIDFontType2` composite-font text support (2-byte-code `Tj` against a `/FontFile2`-embedded +descendant font, with a non-identity `/CIDToGIDMap` - Phase 9), its `/ToUnicode` CMap +resolution (`bfchar`/`bfrange` operators - Phase 10, resolved but unconsumed at rendering time), +and its `/Type0`/`/CIDFontType0` (CFF-outline) composite-font text support (a non-CID-keyed CFF +program embedded via an `/OpenType`-wrapped `/FontFile3` - Phase 12), and its classic PostScript +Type 1 simple-font support (`/Subtype /Type1`, either an embedded `/FontDescriptor/FontFile` +program or the free non-embedded fallback path, plus the `/StandardEncoding` base encoding - +Phase B) +end to end via real files on disk. +There is no third-party source corpus behind any of them (unlike, for example, `PngSuite` in the core test +project): each was constructed byte-by-byte from scratch for CanvasNet and is licensed under the +same MIT license as the rest of this repository. + +| File | Exercises | +| ------ | ----------- | +| `classic-xref-single-page.pdf` | Classic `xref` table + `trailer` dictionary, one page | +| `xref-stream-single-page.pdf` | A `/Type /XRef` cross-reference stream, no classic table | +| `object-stream.pdf` | A `/Type /ObjStm` compressed-object stream holding the page dictionary | +| `hybrid-xref.pdf` | Classic trailer + `/XRefStm` hybrid link to a supplementary xref stream | +| `multi-page-mixed-mediabox-rotate.pdf` | Three pages, differing/inherited `/MediaBox` and `/Rotate` | +| `malformed-startxref.pdf` | No `startxref`/`xref`/`trailer` at all - exercises the linear-scan fallback | +| `encrypted-trailer.pdf` | Non-`/Standard` handler (`/Filter /Adobe.PubSec`) - rejects unsupported handlers | +| `cyclic-page-tree.pdf` | A `/Kids` entry referencing an ancestor - exercises page-tree cycle rejection | +| `path-construction-rect-and-line.pdf` | Filled rectangle (`re f`) plus a stroked line (`m`/`l`/`S`) | +| `path-construction-rotated-page.pdf` | `/Rotate 90` with an asymmetric filled rectangle - CTM/rotation-sign check | +| `contents-array-two-streams.pdf` | `/Contents` as an array - proves space-separator stream concatenation | +| `no-contents-page.pdf` | A page with no `/Contents` key - proves it still renders as a blank surface | +| `color-rgb-rectangle-fill.pdf` | `rg` device color (Phase 3) - a rectangle filled opaque red | +| `image-xobject-devicergb-flate.pdf` | `/Subtype /Image` XObject (2x2 `DeviceRGB`/`FlateDecode`) placed via `cm`/`Do` | +| `text-embedded-truetype-font.pdf` | `/Subtype /TrueType` font, embedded `/FontFile2` (Phase 4) - `Tf`/`Td`/`Tj` | +| `text-composite-truetype-identity-h.pdf` | `/Type0`/`Identity-H`/`CIDFontType2` (Phase 9) + `/ToUnicode` (Phase 10) | +| `text-composite-cff-cidfonttype0-identity-h.pdf` | `/Type0`/`Identity-H`/`CIDFontType0` non-CID-keyed OpenType CFF | +| `content-stream-lzw.pdf` | `/Contents` compressed with `LZWDecode` (Phase 7) - a filled rectangle | +| `content-stream-ascii85.pdf` | `/Contents` armored with `ASCII85Decode` (Phase 7) - the same filled rectangle | +| `content-stream-asciihex.pdf` | `/Contents` armored with `ASCIIHexDecode` (Phase 7) - the same filled rectangle | +| `content-stream-runlength.pdf` | `/Contents` compressed with `RunLengthDecode` (Phase 7) - the same filled rectangle | +| `combined-vector-text-image.pdf` | Filled rect + stroked line + image XObject + font text (Phase 8) | +| `standard14-font-fallback.pdf` | `/BaseFont /Helvetica`, no `/FontFile2` - on-disk fallback (Phase 6) fixture | +| `malformed-content-stream.pdf` | `re` operator given only 2 of its 4 required operands (malformed) | +| `text-embedded-type1-font.pdf` | `/Subtype /Type1`, embedded PostScript `/FontFile` (Phase B) - `Tf`/`Td`/`Tj` | +| `text-standard14-type1-no-fontfile.pdf` | `/Subtype /Type1`, `/BaseFont /Helvetica`, no `/FontFile*` (Phase B) | + +For this phase, a real-world third-party PDF sourcing pass was investigated (mirroring +`SvgFixtures`' Wikimedia Commons CC0 sourcing) to see whether a small, genuinely verifiable, +permissively-licensed sample corpus could be added alongside the hand-authored fixtures. No +suitable candidate was found: Wikimedia Commons no longer topically categorizes "PDF files" (that +category is deprecated/under discussion), and the "public domain sample PDF" sites that turned up +in a web search (`pdfhost.io`, `flipperfile.com`, `qwikpdf.com`) have no checkable authorship or +license text comparable to `WikimediaCommons.LICENSE`'s verifiable provenance. Rather than +fabricate a "real-world" provenance claim for an unverifiable source, the corpus remains entirely +hand-authored and honestly documented as such; a human maintainer can revisit real-world sourcing +in a future, non-blocking follow-up. + +`text-embedded-truetype-font.pdf` is the one exception to the "no third-party source corpus" +statement above: its `/FontFile2` stream is a real, unmodified, `FlateDecode`-compressed copy of +the same "Open Sans" TrueType font every other CanvasNet test project shares (see +`DemaConsulting.CanvasNet.Tests\FontFixtures\README.md` for its provenance and SIL Open Font +License 1.1 text, not repeated here) - everything else about the file (its object structure, +page/font/descriptor dictionaries, and content stream) was still hand-authored from scratch for +this repository, exactly like every other fixture in this folder. `text-composite-truetype-identity-h.pdf` +reuses the exact same real, unmodified Open Sans TrueType font bytes (independently +`FlateDecode`-recompressed for this second file, but decoding to identical font bytes) as its +`/CIDFontType2` descendant font's own `/FontFile2` - no new font asset was sourced for this +second fixture either. As of Phase 10, `text-composite-truetype-identity-h.pdf` also declares a +`/ToUnicode` CMap stream (object 10, added without disturbing any pre-existing object) mapping +CID 1 via `bfchar` and CID 2 via a single-entry `bfrange` to the Unicode codepoints `U+0048`/ +`U+004F` respectively - the same two CIDs the existing content stream's `Tj` operator shows - +wrapped in the standard Adobe CMap/PostScript resource-management boilerplate; this map is +resolved by `ResolveToUnicodeMap` but not yet consumed anywhere at rendering time. + +`text-composite-cff-cidfonttype0-identity-h.pdf` (Phase 12) exercises the `/CIDFontType0` +(CFF-outline) descendant-font path: unlike every other fixture with an embedded font, it is +**entirely hand-authored and synthetic, including its embedded font program** - built with the +same `SyntheticFontBuilder.Cff`/`WithSfntVersion(0x4F54544F)` ('OTTO') helper combination already +proven by `DemaConsulting.CanvasNet.Tests\Fonts\TrueTypeFontTests.cs`'s own OTTO/CFF coverage, not +derived from any real-world font. Its `/Type0`/`/Identity-H` font names a `/CIDFontType0` +descendant whose `/FontDescriptor/FontFile3` is a minimal, well-formed, non-CID-keyed CFF program +(no `ROS`/`FDArray`/`FDSelect`) wrapped in an `OTTO`-tagged SFNT container (`/Subtype /OpenType`), +with no `/CIDToGIDMap` declared (ignored for this subtype regardless - identity CID-to-glyph-index +is always used). Its content stream draws Identity-H code `0001` (CID 1, resolving directly to +GID 1 - a filled square spanning font-design-space `(100, 100)`-`(500, 500)` of a 1000-unit em) at +font size 20, text-space origin `(5, 5)`, on a 100x100 `/MediaBox`. + +`text-embedded-type1-font.pdf` (Phase B) exercises the `/Subtype /Type1` embedded +`/FontDescriptor/FontFile` classic PostScript Type 1 font path: like the Phase 12 CFF fixture +above, it is **entirely hand-authored and synthetic, including its embedded font program** - built +with the same `SyntheticFontBuilder.Type1` helper already proven by +`DemaConsulting.CanvasNet.Tests\Fonts\Type1TableTests.cs`'s own Type 1 coverage, not derived from +any real-world font. Its font dictionary declares no `/Encoding` (so the default +`/WinAnsiEncoding` base encoding applies), and its embedded program declares three glyphs +(`.notdef`, `space`, and `A` - glyph `A` a filled square spanning font-design-space +`(100, 100)`-`(500, 500)` of a 1000-unit em, matching the Phase 12 CFF fixture's own glyph +design/placement so both fixtures' system-integration tests share the exact same pixel-assertion +convention). Its content stream draws `(A) Tj` at font size 20, text-space origin `(5, 5)`, on a +100x100 `/MediaBox`. + +`text-standard14-type1-no-fontfile.pdf` (Phase B) mirrors `standard14-font-fallback.pdf` exactly, +except its font dictionary declares `/Subtype /Type1` instead of `/Subtype /TrueType` - proving +the free non-embedded fallback path (`ResolveFallbackFont`) is reachable for `/Type1` fonts too, +not only `/TrueType` fonts. Like `standard14-font-fallback.pdf`, it declares no +`/FontDescriptor/FontFile`/`/FontFile2`/`/FontFile3` at all. diff --git a/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfFixtures/classic-xref-single-page.pdf b/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfFixtures/classic-xref-single-page.pdf new file mode 100644 index 00000000..ec70738f Binary files /dev/null and b/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfFixtures/classic-xref-single-page.pdf differ diff --git a/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfFixtures/color-rgb-rectangle-fill.pdf b/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfFixtures/color-rgb-rectangle-fill.pdf new file mode 100644 index 00000000..bd21b9dc Binary files /dev/null and b/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfFixtures/color-rgb-rectangle-fill.pdf differ diff --git a/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfFixtures/combined-vector-text-image.pdf b/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfFixtures/combined-vector-text-image.pdf new file mode 100644 index 00000000..428562ba Binary files /dev/null and b/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfFixtures/combined-vector-text-image.pdf differ diff --git a/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfFixtures/content-stream-ascii85.pdf b/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfFixtures/content-stream-ascii85.pdf new file mode 100644 index 00000000..1ea72515 Binary files /dev/null and b/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfFixtures/content-stream-ascii85.pdf differ diff --git a/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfFixtures/content-stream-asciihex.pdf b/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfFixtures/content-stream-asciihex.pdf new file mode 100644 index 00000000..527d0d67 Binary files /dev/null and b/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfFixtures/content-stream-asciihex.pdf differ diff --git a/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfFixtures/content-stream-lzw.pdf b/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfFixtures/content-stream-lzw.pdf new file mode 100644 index 00000000..985da24c Binary files /dev/null and b/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfFixtures/content-stream-lzw.pdf differ diff --git a/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfFixtures/content-stream-runlength.pdf b/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfFixtures/content-stream-runlength.pdf new file mode 100644 index 00000000..18cab689 Binary files /dev/null and b/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfFixtures/content-stream-runlength.pdf differ diff --git a/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfFixtures/contents-array-two-streams.pdf b/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfFixtures/contents-array-two-streams.pdf new file mode 100644 index 00000000..36c3bc1e Binary files /dev/null and b/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfFixtures/contents-array-two-streams.pdf differ diff --git a/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfFixtures/cyclic-page-tree.pdf b/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfFixtures/cyclic-page-tree.pdf new file mode 100644 index 00000000..eee0f805 Binary files /dev/null and b/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfFixtures/cyclic-page-tree.pdf differ diff --git a/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfFixtures/encrypted-trailer.pdf b/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfFixtures/encrypted-trailer.pdf new file mode 100644 index 00000000..78624220 Binary files /dev/null and b/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfFixtures/encrypted-trailer.pdf differ diff --git a/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfFixtures/hybrid-xref.pdf b/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfFixtures/hybrid-xref.pdf new file mode 100644 index 00000000..1b08afdb Binary files /dev/null and b/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfFixtures/hybrid-xref.pdf differ diff --git a/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfFixtures/image-xobject-devicergb-flate.pdf b/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfFixtures/image-xobject-devicergb-flate.pdf new file mode 100644 index 00000000..49a2080d Binary files /dev/null and b/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfFixtures/image-xobject-devicergb-flate.pdf differ diff --git a/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfFixtures/malformed-content-stream.pdf b/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfFixtures/malformed-content-stream.pdf new file mode 100644 index 00000000..c4489cfb Binary files /dev/null and b/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfFixtures/malformed-content-stream.pdf differ diff --git a/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfFixtures/malformed-startxref.pdf b/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfFixtures/malformed-startxref.pdf new file mode 100644 index 00000000..fcb3b5a7 Binary files /dev/null and b/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfFixtures/malformed-startxref.pdf differ diff --git a/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfFixtures/multi-page-mixed-mediabox-rotate.pdf b/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfFixtures/multi-page-mixed-mediabox-rotate.pdf new file mode 100644 index 00000000..c0f4694a Binary files /dev/null and b/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfFixtures/multi-page-mixed-mediabox-rotate.pdf differ diff --git a/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfFixtures/no-contents-page.pdf b/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfFixtures/no-contents-page.pdf new file mode 100644 index 00000000..6bae68c6 Binary files /dev/null and b/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfFixtures/no-contents-page.pdf differ diff --git a/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfFixtures/object-stream.pdf b/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfFixtures/object-stream.pdf new file mode 100644 index 00000000..57c7c8b6 Binary files /dev/null and b/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfFixtures/object-stream.pdf differ diff --git a/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfFixtures/path-construction-rect-and-line.pdf b/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfFixtures/path-construction-rect-and-line.pdf new file mode 100644 index 00000000..e2a0aba1 Binary files /dev/null and b/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfFixtures/path-construction-rect-and-line.pdf differ diff --git a/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfFixtures/path-construction-rotated-page.pdf b/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfFixtures/path-construction-rotated-page.pdf new file mode 100644 index 00000000..d1bb1f11 Binary files /dev/null and b/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfFixtures/path-construction-rotated-page.pdf differ diff --git a/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfFixtures/standard14-font-fallback.pdf b/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfFixtures/standard14-font-fallback.pdf new file mode 100644 index 00000000..a146a3c6 Binary files /dev/null and b/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfFixtures/standard14-font-fallback.pdf differ diff --git a/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfFixtures/text-composite-cff-cidfonttype0-identity-h.pdf b/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfFixtures/text-composite-cff-cidfonttype0-identity-h.pdf new file mode 100644 index 00000000..62329d1e Binary files /dev/null and b/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfFixtures/text-composite-cff-cidfonttype0-identity-h.pdf differ diff --git a/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfFixtures/text-composite-truetype-identity-h.pdf b/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfFixtures/text-composite-truetype-identity-h.pdf new file mode 100644 index 00000000..df70a44a Binary files /dev/null and b/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfFixtures/text-composite-truetype-identity-h.pdf differ diff --git a/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfFixtures/text-embedded-truetype-font.pdf b/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfFixtures/text-embedded-truetype-font.pdf new file mode 100644 index 00000000..ff07c8cf Binary files /dev/null and b/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfFixtures/text-embedded-truetype-font.pdf differ diff --git a/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfFixtures/text-embedded-type1-font.pdf b/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfFixtures/text-embedded-type1-font.pdf new file mode 100644 index 00000000..a2154cc1 Binary files /dev/null and b/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfFixtures/text-embedded-type1-font.pdf differ diff --git a/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfFixtures/text-standard14-type1-no-fontfile.pdf b/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfFixtures/text-standard14-type1-no-fontfile.pdf new file mode 100644 index 00000000..5f7d0109 Binary files /dev/null and b/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfFixtures/text-standard14-type1-no-fontfile.pdf differ diff --git a/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfFixtures/xref-stream-single-page.pdf b/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfFixtures/xref-stream-single-page.pdf new file mode 100644 index 00000000..a8c1a259 Binary files /dev/null and b/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfFixtures/xref-stream-single-page.pdf differ diff --git a/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfSystemIntegrationTests.cs b/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfSystemIntegrationTests.cs new file mode 100644 index 00000000..5b3dc930 --- /dev/null +++ b/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfSystemIntegrationTests.cs @@ -0,0 +1,1154 @@ +// cspell:ignore xobject devicergb Zapf Nonsymbolic OTTO cidfonttype Noto +using System.Security.Cryptography; +using DemaConsulting.CanvasNet.Codecs; +using DemaConsulting.CanvasNet.Fonts; + +namespace DemaConsulting.CanvasNet.Pdf.Tests; + +/// +/// System-level integration tests for PDF page-info/blank-render support via the +/// CanvasNet.Pdf package. Each test proves one CanvasNetPdf-* top-level requirement +/// end-to-end through the public API, complementing (never +/// replacing) the unit-level PdfDocument_* coverage in +/// , which exercises each feature's finer-grained behavioral +/// variations. +/// +public class PdfSystemIntegrationTests +{ + private static string FixturesPath => Path.Join(AppContext.BaseDirectory, "PdfFixtures"); + + private static string Fixture(string name) => Path.Join(FixturesPath, name); + + /// + /// The path to the real "Open Sans" TrueType font, copied to the test output directory by + /// this project's FontFixtures\** content-link item (mirroring + /// DemaConsulting.CanvasNet.Svg.Tests's own reuse of the same shared file - see + /// DemaConsulting.CanvasNet.Tests\FontFixtures\README.md for provenance/licensing). + /// + private static string FontPath => Path.Join(AppContext.BaseDirectory, "FontFixtures", "OpenSans-Regular.ttf"); + + /// + /// Opens a multi-page fixture once, asserts , then calls + /// for two different pages and + /// for two different pages against the same + /// opened instance - demonstrating the "parse once, reused across calls" property that no + /// stateless design could express. This is also the shared platform-proof test referenced + /// by every CanvasNetPdf-Platform-* requirement. + /// + [Fact] + public void CanvasNetPdf_SystemIntegration_PdfOpen_ReturnsExpectedPageCount() + { + // Arrange + using var document = PdfDocument.Open(Fixture("multi-page-mixed-mediabox-rotate.pdf")); + + // Act + var pageCount = document.PageCount; + var firstInfo = document.GetPageInfo(0); + var secondInfo = document.GetPageInfo(2); + using var firstSurface = document.Render(0, 32, 32); + using var secondSurface = document.Render(2, 16, 16); + + // Assert + Assert.Equal(3, pageCount); + Assert.Equal(200, firstInfo.Width); + Assert.Equal(300, firstInfo.Height); + Assert.Equal(150, secondInfo.Width); + Assert.Equal(250, secondInfo.Height); + Assert.Equal(32, firstSurface.Width); + Assert.Equal(16, secondSurface.Width); + } + + /// Proves a rotated page reports swapped display dimensions and its effective rotation. + [Fact] + public void CanvasNetPdf_SystemIntegration_PdfGetPageInfo_ReportsRotatedDimensions() + { + // Arrange + using var document = PdfDocument.Open(Fixture("multi-page-mixed-mediabox-rotate.pdf")); + + // Act + var info = document.GetPageInfo(1); + + // Assert: raw /MediaBox [0 0 400 100] with /Rotate 90 swaps to a taller-than-wide display + Assert.Equal(100, info.Width); + Assert.Equal(400, info.Height); + Assert.Equal(90, info.Rotation); + } + + /// Proves returns a blank, correctly sized surface (Phase 1). + [Fact] + public void CanvasNetPdf_SystemIntegration_PdfRender_ReturnsBlankSizedSurface() + { + // Arrange + using var document = PdfDocument.Open(Fixture("classic-xref-single-page.pdf")); + + // Act + using var surface = document.Render(0, 40, 20); + + // Assert + Assert.Equal(40, surface.Width); + Assert.Equal(20, surface.Height); + Assert.Equal(default, surface[0, 0]); + Assert.Equal(default, surface[39, 19]); + } + + /// + /// Proves rasterizes real path geometry end-to-end: a + /// hand-authored fixture containing a filled rectangle and a stroked vertical line, + /// asserting specific opaque-black/transparent pixels at specific coordinates (not merely + /// "the surface is not blank"). + /// + [Fact] + public void CanvasNetPdf_SystemIntegration_PdfRender_FilledRectangleAndStrokedLine_PaintsExpectedPixels() + { + // Arrange: MediaBox [0 0 100 100], /Contents = "10 10 40 40 re f\n2 w 60 10 m 60 90 l S" + // (a filled 40x40 square at user (10,10)-(50,50), plus a 2-unit-wide stroked vertical + // line at user x=60 from y=10 to y=90). + using var document = PdfDocument.Open(Fixture("path-construction-rect-and-line.pdf")); + + // Act + using var surface = document.Render(0, 100, 100); + + // Assert: interior of the filled rectangle is opaque black; a point outside it is not. + Assert.Equal(new Canvas.Rgba32(0, 0, 0, 255), surface[30, 70]); + Assert.Equal(default, surface[5, 5]); + + // Assert: the stroked line is opaque black at its own x-position, but not a bit away. + Assert.Equal(new Canvas.Rgba32(0, 0, 0, 255), surface[60, 50]); + Assert.Equal(default, surface[70, 50]); + } + + /// + /// Proves the base CTM correctly incorporates a page's effective /Rotate: a + /// hand-authored, 90-degree-rotated fixture's asymmetric filled rectangle lands at its + /// mathematically correct device-pixel position, not at the mirrored/opposite position a + /// 270-degree-instead-of-90-degree rotation-sign bug would produce. + /// + [Fact] + public void CanvasNetPdf_SystemIntegration_PdfRender_RotatedPage_MapsGeometryToCorrectPixelPosition() + { + // Arrange: MediaBox [0 0 200 100], /Rotate 90, /Contents = "10 10 30 20 re f" (an + // asymmetric rectangle near the raw MediaBox's bottom-left corner). + using var document = PdfDocument.Open(Fixture("path-construction-rotated-page.pdf")); + + // Act: displayed size swaps to 100x200 under /Rotate 90 - render at that exact size. + using var surface = document.Render(0, 100, 200); + + // Assert: correctly rotated, the rectangle lands at device px in [10,30), py in [10,40). + Assert.Equal(new Canvas.Rgba32(0, 0, 0, 255), surface[20, 25]); + + // Assert: a 270-instead-of-90 rotation-sign bug would instead place it near (80,175) - + // proving this specific pixel is untouched rules out that regression. + Assert.Equal(default, surface[80, 175]); + } + + /// Proves a null stream is rejected before any parsing is attempted. + [Fact] + public void CanvasNetPdf_SystemIntegration_PdfValidationNull_NullStreamThrowsArgumentNullException() + { + // Arrange, Act & Assert + Assert.Throws(() => PdfDocument.Open((Stream)null!)); + } + + /// Proves an empty path is rejected before any file access is attempted. + [Fact] + public void CanvasNetPdf_SystemIntegration_PdfValidationEmptyPath_EmptyPathThrowsArgumentException() + { + // Arrange, Act & Assert + Assert.Throws(() => PdfDocument.Open(string.Empty)); + } + + /// + /// Proves a well-formed document/xref/page-tree whose /Contents stream is itself + /// lexically malformed (a re operator given only 2 of its 4 required operands) is + /// rejected with end-to-end through + /// , distinct from the structural malformations + /// (malformed-startxref.pdf/cyclic-page-tree.pdf) already covered elsewhere. + /// + [Fact] + public void CanvasNetPdf_SystemIntegration_PdfUnsupportedFormatValidation_MalformedContentStreamThrowsInvalidDataException() + { + // Arrange + using var document = PdfDocument.Open(Fixture("malformed-content-stream.pdf")); + + // Act & Assert + Assert.Throws(() => document.Render(0, 100, 100)); + } + + /// Proves an out-of-range page index is rejected by . + [Fact] + public void CanvasNetPdf_SystemIntegration_PdfGetPageInfoValidation_OutOfRangePageIndexThrowsArgumentOutOfRangeException() + { + // Arrange + using var document = PdfDocument.Open(Fixture("classic-xref-single-page.pdf")); + + // Act & Assert + Assert.Throws(() => document.GetPageInfo(1)); + } + + /// Proves every other public member throws once the document has been disposed. + [Fact] + public void CanvasNetPdf_SystemIntegration_PdfDispose_ObjectDisposedExceptionAfterDispose() + { + // Arrange + var document = PdfDocument.Open(Fixture("classic-xref-single-page.pdf")); + + // Act + document.Dispose(); + document.Dispose(); // idempotent - must not throw + + // Assert + Assert.Throws(() => document.PageCount); + Assert.Throws(() => document.GetPageInfo(0)); + Assert.Throws(() => document.Render(0, 10, 10)); + } + + /// Proves a document encrypted with a non-/Standard security handler is rejected rather than silently mis-parsed. + [Fact] + public void CanvasNetPdf_SystemIntegration_PdfEncryptDetection_EncryptedTrailerThrowsUnsupportedImageFeatureException() + { + // Arrange, Act & Assert + var exception = Assert.Throws( + () => PdfDocument.Open(Fixture("encrypted-trailer.pdf"))); + Assert.Equal("pdf-encrypted-filter-Adobe.PubSec", exception.Feature); + } + + /// + /// Proves genuinely decrypts and renders an + /// RC4 128-bit (/V 2//R 3) encrypted document end-to-end when the correct, + /// non-empty, real user password is supplied: a synthetic, in-memory encrypted PDF (no + /// binary fixture) whose /Contents stream is RC4-encrypted with a file key derived + /// from a real (not empty) user password, proving the painted rectangle's device pixel is + /// actually opaque black (i.e. the content stream was genuinely decrypted to its real + /// plaintext operators and rendered, not merely that Open fails to throw). + /// + [Fact] + public void CanvasNetPdf_SystemIntegration_PdfOpen_EncryptedRc4_CorrectUserPassword_DecryptsAndRendersExpectedPixels() + { + // Arrange: RC4 128-bit, correct real (non-empty) user password "test". + const int keyLengthBytes = 16; + const int revision = 3; + const int permissions = -3904; + const string userPassword = "test"; + const string plaintextContent = "10 10 40 40 re f"; + byte[] idBytes = [.. Enumerable.Range(0, 16).Select(i => (byte)(0x10 + i))]; + + var paddedUserPassword = EncodeAndPadRc4Password(userPassword); + var oBytes = ComputeOwnerEntryRc4Algorithm3(keyLengthBytes, revision, PasswordPadding, paddedUserPassword); + var fileKey = ComputeFileKeyRc4Algorithm2(paddedUserPassword, oBytes, permissions, idBytes, keyLengthBytes, revision); + var uBytes = ComputeUserEntryRc4Algorithm45(fileKey, idBytes); + + var objectKey = ComputeObjectKeyRc4Algorithm1(fileKey, 4, 0); + var encryptedContent = Rc4(objectKey, System.Text.Encoding.ASCII.GetBytes(plaintextContent)); + + var encryptDictBody = + $"<< /Filter /Standard /V 2 /R {revision} /Length 128 /O <{Convert.ToHexString(oBytes)}> /U <{Convert.ToHexString(uBytes)}> /P {permissions} >>"; + var pdfBytes = BuildEncryptedPdf(encryptDictBody, idBytes, encryptedContent); + + // Act + using var document = PdfDocument.Open(new MemoryStream(pdfBytes), userPassword); + using var surface = document.Render(0, 100, 100); + + // Assert: the rectangle's device footprint is painted; outside it is left blank. + Assert.Equal(new Canvas.Rgba32(0, 0, 0, 255), surface[30, 70]); + Assert.Equal(default, surface[5, 5]); + } + + /// + /// Proves paints real device color end-to-end (Phase 3): + /// a hand-authored fixture using rg to fill a rectangle red, asserting a specific + /// interior pixel is opaque red and an exterior pixel remains transparent. + /// + [Fact] + public void CanvasNetPdf_SystemIntegration_PdfRender_ColoredRectangleFill_PaintsExpectedRgbPixels() + { + // Arrange: MediaBox [0 0 100 100], /Contents = "1 0 0 rg 10 10 80 80 re f" (an + // 80x80 rectangle filled opaque red). + using var document = PdfDocument.Open(Fixture("color-rgb-rectangle-fill.pdf")); + + // Act + using var surface = document.Render(0, 100, 100); + + // Assert: interior of the filled rectangle is opaque red; a point outside it is not. + Assert.Equal(new Canvas.Rgba32(255, 0, 0, 255), surface[50, 50]); + Assert.Equal(default, surface[5, 5]); + } + + /// + /// Proves decodes and composites an image XObject + /// end-to-end (Phase 3): a hand-authored fixture placing a 2x2 DeviceRGB + /// FlateDecode image via cm/Do, asserting specific composited pixel + /// colors matching the fixture's known source pixels, and a pixel outside the placed + /// image's device-space footprint remains transparent. + /// + [Fact] + public void CanvasNetPdf_SystemIntegration_PdfRender_ImageXObjectPlacement_CompositesExpectedPixels() + { + // Arrange: MediaBox [0 0 100 100], /Contents = "60 0 0 60 10 10 cm /Im0 Do" places a 2x2 + // image (top-left red, top-right green, bottom-left blue, bottom-right yellow) into the + // device-space footprint x in [10,70], y in [30,90] (PDF user y=30..90 flips to that same + // device y range for this identity-rotation, full-page-sized MediaBox). + using var document = PdfDocument.Open(Fixture("image-xobject-devicergb-flate.pdf")); + + // Act + using var surface = document.Render(0, 100, 100); + + // Assert: the four source quadrants land in their mathematically correct device pixels. + Assert.Equal(new Canvas.Rgba32(255, 0, 0, 255), surface[25, 40]); + Assert.Equal(new Canvas.Rgba32(0, 255, 0, 255), surface[55, 40]); + Assert.Equal(new Canvas.Rgba32(0, 0, 255, 255), surface[25, 75]); + Assert.Equal(new Canvas.Rgba32(255, 255, 0, 255), surface[55, 75]); + + // Assert: a pixel outside the placed image's device-space footprint remains transparent. + Assert.Equal(default, surface[5, 5]); + } + + /// + /// Proves executes a /Subtype /Form XObject + /// placed via the Do operator end-to-end: a synthetic, in-memory single-page PDF + /// (no binary fixture) whose page content stream invokes /Fm0 Do, where /Fm0 + /// is a genuine Form XObject (/Type /XObject /Subtype /Form) with its own nested + /// content stream filling a rectangle, proving the Form's nested content is actually + /// painted at the correct device pixels (not merely that Do fails to throw). + /// + [Fact] + public void CanvasNetPdf_SystemIntegration_PdfRender_FormXObject_PaintsNestedContentStream() + { + // Arrange: the Form's own content stream fills a centered rectangle. + const string formContent = "10 10 80 80 re f"; + var formStreamBytes = System.Text.Encoding.ASCII.GetBytes(formContent); + var formStreamBody = System.Text.Encoding.ASCII.GetBytes( + $"<< /Type /XObject /Subtype /Form /BBox [0 0 100 100] /Length {formStreamBytes.Length} >>\nstream\n{formContent}\nendstream"); + + var bytes = BuildSyntheticPatternPdf( + "/Fm0 Do", + "/XObject << /Fm0 5 0 R >>", + [formStreamBody]); + + // Act + using var document = PdfDocument.Open(new MemoryStream(bytes)); + using var surface = document.Render(0, 100, 100); + + // Assert: the rectangle's device footprint is painted; outside it is left blank. + Assert.Equal(new Canvas.Rgba32(0, 0, 0, 255), surface[50, 50]); + Assert.Equal(default, surface[5, 5]); + } + + /// + /// Proves decodes an LZWDecode-compressed page + /// content stream end-to-end (Phase 7): a hand-authored fixture whose /Contents + /// stream is the PDF-variant-LZW-compressed bytes of "1 0 0 rg 10 10 80 80 re f" + /// (an 80x80 rectangle filled opaque red), proving the decoded operator text is parsed + /// and rendered exactly like an uncompressed content stream would be. + /// + [Fact] + public void CanvasNetPdf_SystemIntegration_PdfRender_LzwDecodeContentStream_PaintsExpectedPixels() + { + // Arrange + using var document = PdfDocument.Open(Fixture("content-stream-lzw.pdf")); + + // Act + using var surface = document.Render(0, 100, 100); + + // Assert: interior of the filled rectangle is opaque red; a point outside it is not. + Assert.Equal(new Canvas.Rgba32(255, 0, 0, 255), surface[50, 50]); + Assert.Equal(default, surface[5, 5]); + } + + /// + /// Proves decodes an ASCII85Decode-armored page + /// content stream end-to-end (Phase 7): a hand-authored fixture whose /Contents + /// stream is the base-85 encoding (terminated by ~>) of the same + /// "1 0 0 rg 10 10 80 80 re f" content-stream text. + /// + [Fact] + public void CanvasNetPdf_SystemIntegration_PdfRender_Ascii85DecodeContentStream_PaintsExpectedPixels() + { + // Arrange + using var document = PdfDocument.Open(Fixture("content-stream-ascii85.pdf")); + + // Act + using var surface = document.Render(0, 100, 100); + + // Assert: interior of the filled rectangle is opaque red; a point outside it is not. + Assert.Equal(new Canvas.Rgba32(255, 0, 0, 255), surface[50, 50]); + Assert.Equal(default, surface[5, 5]); + } + + /// + /// Proves decodes an ASCIIHexDecode-armored page + /// content stream end-to-end (Phase 7): a hand-authored fixture whose /Contents + /// stream is the hex-digit-pair encoding (terminated by >) of the same + /// "1 0 0 rg 10 10 80 80 re f" content-stream text. + /// + [Fact] + public void CanvasNetPdf_SystemIntegration_PdfRender_AsciiHexDecodeContentStream_PaintsExpectedPixels() + { + // Arrange + using var document = PdfDocument.Open(Fixture("content-stream-asciihex.pdf")); + + // Act + using var surface = document.Render(0, 100, 100); + + // Assert: interior of the filled rectangle is opaque red; a point outside it is not. + Assert.Equal(new Canvas.Rgba32(255, 0, 0, 255), surface[50, 50]); + Assert.Equal(default, surface[5, 5]); + } + + /// + /// Proves decodes a RunLengthDecode-compressed + /// page content stream end-to-end (Phase 7): a hand-authored fixture whose + /// /Contents stream is a single PackBits-style literal run (length byte, the + /// literal bytes, then the 128 EOD marker) wrapping the same + /// "1 0 0 rg 10 10 80 80 re f" content-stream text. + /// + [Fact] + public void CanvasNetPdf_SystemIntegration_PdfRender_RunLengthDecodeContentStream_PaintsExpectedPixels() + { + // Arrange + using var document = PdfDocument.Open(Fixture("content-stream-runlength.pdf")); + + // Act + using var surface = document.Render(0, 100, 100); + + // Assert: interior of the filled rectangle is opaque red; a point outside it is not. + Assert.Equal(new Canvas.Rgba32(255, 0, 0, 255), surface[50, 50]); + Assert.Equal(default, surface[5, 5]); + } + + /// + /// Proves resolves an embedded simple TrueType font end + /// to end (Phase 4): a hand-authored fixture with a real, embedded (via + /// /FontDescriptor/FontFile2) copy of the shared OpenSans-Regular.ttf + /// production font (see PdfFixtures\README.md for provenance and the + /// FontFixtures\README.md in DemaConsulting.CanvasNet.Tests for the font's + /// own SIL OFL 1.1 licensing - not duplicated here), drawing "HO" at font size 60 + /// with the default /WinAnsiEncoding. Rather than hardcoding font-specific magic + /// pixel numbers, this test independently re-derives the expected device-pixel positions + /// of real glyph ink from the font's own outline/metrics (the same production + /// API itself uses) via the + /// documented text-rendering-matrix formula, then asserts specific pixels: one inside + /// 'H's left stroke (opaque), one inside 'O's hollow counter (transparent), and the + /// canvas corners, well outside both glyphs (transparent background). + /// + [Fact] + public void CanvasNetPdf_SystemIntegration_PdfRender_EmbeddedTrueTypeFontText_PaintsGlyphStrokesNotCounters() + { + // Arrange: MediaBox [0 0 200 100], /Contents = "BT /F1 60 Tf 10 20 Td (HO) Tj ET" - + // renders "HO" at font size 60, text-space origin (10, 20), using the embedded font's + // own advance widths (the font dictionary declares no /Widths array). + const double fontSize = 60; + const double originX = 10; + const double originY = 20; + const double mediaBoxHeight = 100; + + using var document = PdfDocument.Open(Fixture("text-embedded-truetype-font.pdf")); + + // Act: render at the MediaBox's own pixel dimensions (a 1:1 user-space-to-device-pixel + // mapping, since width/height exactly match the MediaBox), then load the same real font + // independently to re-derive expected glyph-ink pixel positions from its own metrics. + using var surface = document.Render(0, 200, 100); + var font = TrueTypeFont.Load(FontPath); + + // Maps a font-design-space point (in the glyph currently being measured, whose text-space + // origin is (originXForGlyph, originY)) to the device pixel it lands on, replicating the + // exact Trm/base-CTM composition PdfDocument.Text.cs uses: text-space x/y scale by + // fontSize/UnitsPerEm and offset by the glyph's own text-space origin, then flip y against + // the MediaBox height (device x is otherwise unchanged for this identity-rotation page). + (int X, int Y) ToDevicePixel(double originXForGlyph, double fx, double fy) + { + var textX = fx / font.UnitsPerEm * fontSize + originXForGlyph; + var textY = fy / font.UnitsPerEm * fontSize + originY; + return ((int)Math.Floor(textX), (int)Math.Floor(mediaBoxHeight - textY)); + } + + var hGlyph = font.GetGlyphIndex('H'); + var oGlyph = font.GetGlyphIndex('O'); + Assert.NotEqual(0, hGlyph); + Assert.NotEqual(0, oGlyph); + + var hBounds = font.GetGlyphOutline(hGlyph).GetBounds(0.25f); + var oBounds = font.GetGlyphOutline(oGlyph).GetBounds(0.25f); + Assert.False(hBounds.IsEmpty); + Assert.False(oBounds.IsEmpty); + + // 'O' is placed immediately after 'H', advanced by 'H's own advance width (in text + // space) - matching ShowText's undeclared-/Widths fallback to the font's own metrics. + var originXForO = originX + (double)font.GetAdvanceWidth(hGlyph) / font.UnitsPerEm * fontSize; + + // Assert: a point 15% in from 'H's left edge, at half its glyph height, lies on 'H's + // solid left vertical stem (which spans 'H's full height) - real opaque ink. + var (strokeX, strokeY) = ToDevicePixel(originX, hBounds.X + hBounds.Width * 0.15, hBounds.Y + hBounds.Height * 0.5); + Assert.True(surface[strokeX, strokeY].A > 0, $"Expected opaque ink inside 'H's left stroke at ({strokeX},{strokeY})."); + + // Assert: the exact center of 'O's bounding box lies within its hollow counter (the + // round hole every 'O' glyph has at its geometric center) - not painted. + var (counterX, counterY) = ToDevicePixel( + originXForO, oBounds.X + oBounds.Width * 0.5, oBounds.Y + oBounds.Height * 0.5); + Assert.Equal(0, surface[counterX, counterY].A); + + // Assert: the canvas's far corners, well outside both glyphs, remain fully transparent. + Assert.Equal(0, surface[0, 0].A); + Assert.Equal(0, surface[199, 0].A); + Assert.Equal(0, surface[0, 99].A); + Assert.Equal(0, surface[199, 99].A); + } + + /// + /// Proves resolves a /Type0//Identity-H + /// CIDFontType2 composite font end to end (Phase 9): a hand-authored fixture with a + /// real, embedded (via the descendant font's /FontDescriptor/FontFile2) copy of the + /// shared OpenSans-Regular.ttf production font (see PdfFixtures\README.md for + /// provenance), a non-identity /CIDToGIDMap stream remapping CID 1 to the glyph + /// index of 'H' and CID 2 to the glyph index of 'O', and an explicit + /// /W array declaring their advance widths - drawing the 2-byte Identity-H codes + /// 0001 0002 at font size 60. Rather than hardcoding font-specific magic pixel + /// numbers, this test independently re-derives the expected device-pixel positions of real + /// glyph ink from the font's own outline (looked up directly by glyph index, bypassing + /// cmap entirely - exactly as the composite code path does), combined with the + /// fixture's own declared /W advance widths (composite fonts never fall back to the + /// font's own metrics), then asserts specific pixels: one inside 'H's left stroke (opaque), + /// one inside 'O's hollow counter (transparent), and the canvas corners, well outside both + /// glyphs (transparent background). + /// + [Fact] + public void CanvasNetPdf_SystemIntegration_RenderType0CompositeFont_PaintsExpectedGlyphInk() + { + // Arrange: MediaBox [0 0 200 100], /Contents = "BT /F1 60 Tf 10 20 Td <00010002> Tj ET" - + // renders CID 1 then CID 2 at font size 60, text-space origin (10, 20). The descendant + // font's /CIDToGIDMap stream remaps CID 1 -> GID 43 ('H') and CID 2 -> GID 50 ('O'); its + // /W array declares CID 1's width as 700 and CID 2's width as 650 (both /1000 em units). + const double fontSize = 60; + const double originX = 10; + const double originY = 20; + const double mediaBoxHeight = 100; + const int hGlyph = 43; + const int oGlyph = 50; + const double wH = 700.0 / 1000.0; + const double wO = 650.0 / 1000.0; + + using var document = PdfDocument.Open(Fixture("text-composite-truetype-identity-h.pdf")); + + // Act: render at the MediaBox's own pixel dimensions (a 1:1 user-space-to-device-pixel + // mapping), then load the same real font independently to re-derive expected glyph-ink + // pixel positions from its own outline, looked up directly by glyph index. + using var surface = document.Render(0, 200, 100); + var font = TrueTypeFont.Load(FontPath); + + // Maps a font-design-space point (in the glyph currently being measured, whose text-space + // origin is (originXForGlyph, originY)) to the device pixel it lands on, replicating the + // exact Trm/base-CTM composition PdfDocument.Text.cs uses: text-space x/y scale by + // fontSize/UnitsPerEm and offset by the glyph's own text-space origin, then flip y against + // the MediaBox height (device x is otherwise unchanged for this identity-rotation page). + (int X, int Y) ToDevicePixel(double originXForGlyph, double fx, double fy) + { + var textX = fx / font.UnitsPerEm * fontSize + originXForGlyph; + var textY = fy / font.UnitsPerEm * fontSize + originY; + return ((int)Math.Floor(textX), (int)Math.Floor(mediaBoxHeight - textY)); + } + + var hBounds = font.GetGlyphOutline(hGlyph).GetBounds(0.25f); + var oBounds = font.GetGlyphOutline(oGlyph).GetBounds(0.25f); + Assert.False(hBounds.IsEmpty); + Assert.False(oBounds.IsEmpty); + + // The second glyph ('O') is placed immediately after the first ('H'), advanced by CID 1's + // declared /W width (in text space) - never the font's own metrics, since composite fonts + // have no such fallback. + var originXForO = originX + wH * fontSize; + + // Assert: a point 15% in from 'H's left edge, at half its glyph height, lies on 'H's + // solid left vertical stem (which spans 'H's full height) - real opaque ink. + var (strokeX, strokeY) = ToDevicePixel(originX, hBounds.X + hBounds.Width * 0.15, hBounds.Y + hBounds.Height * 0.5); + Assert.True(surface[strokeX, strokeY].A > 0, $"Expected opaque ink inside 'H's left stroke at ({strokeX},{strokeY})."); + + // Assert: the exact center of 'O's bounding box lies within its hollow counter (the + // round hole every 'O' glyph has at its geometric center) - not painted. + var (counterX, counterY) = ToDevicePixel( + originXForO, oBounds.X + oBounds.Width * 0.5, oBounds.Y + oBounds.Height * 0.5); + Assert.Equal(0, surface[counterX, counterY].A); + + // Assert: a point one full CID-2 advance width (per its declared /W entry) past 'O's own + // origin lies well beyond 'O's right edge - transparent background, confirming CID 2's + // declared width (not the font's own advance metric) determines how far text position + // moves. + var (pastOX, pastOY) = ToDevicePixel(originXForO + wO * fontSize, oBounds.X + oBounds.Width * 0.5, oBounds.Y + oBounds.Height * 0.5); + Assert.Equal(0, surface[pastOX, pastOY].A); + + // Assert: the canvas's far corners, well outside both glyphs, remain fully transparent. + Assert.Equal(0, surface[0, 0].A); + Assert.Equal(0, surface[199, 0].A); + Assert.Equal(0, surface[0, 99].A); + Assert.Equal(0, surface[199, 99].A); + } + + /// + /// Proves resolves a /Type0//Identity-H + /// CIDFontType0 composite font end to end (Phase 12): a hand-authored, entirely + /// synthetic fixture (see PdfFixtures\README.md) whose descendant font's + /// /FontDescriptor/FontFile3 is a synthetic, non-CID-keyed, /OpenType- + /// wrapped CFF program (built via SyntheticFontBuilder.Cff - no third-party font + /// asset), with no /CIDToGIDMap declared (identity CID-to-glyph-index is used + /// unconditionally for this subtype). The content stream draws the 2-byte Identity-H code + /// 0001 (CID 1, resolving to GID 1 - a filled square spanning font-design-space + /// (100, 100)-(500, 500) of a 1000-unit em) at font size 20, text-space + /// origin (5, 5), on a 100x100 MediaBox - matching + /// 's own PdfDocument_Fonts_Type0_CidFontType0_* unit + /// tests' pixel-position convention exactly, since both share the same synthetic glyph + /// design. + /// + [Fact] + public void CanvasNetPdf_SystemIntegration_RenderCidFontType0CompositeFont_PaintsExpectedGlyphInk() + { + // Arrange + using var document = PdfDocument.Open(Fixture("text-composite-cff-cidfonttype0-identity-h.pdf")); + + // Act + using var surface = document.Render(0, 100, 100); + + // Assert: text x [7, 15) holds the painted square glyph (design x [100, 500) of 1000, + // scaled by fontSize 20, offset by originX 5, flipped against the MediaBox height for + // originY 5) - real glyph ink, not merely "did not throw". + Assert.NotEqual(0, surface[11, 89].A); + + // Assert: the canvas's far corners, well outside the glyph, remain fully transparent. + Assert.Equal(0, surface[0, 0].A); + Assert.Equal(0, surface[99, 0].A); + Assert.Equal(0, surface[0, 99].A); + Assert.Equal(0, surface[99, 99].A); + } + + /// + /// Proves resolves a /Subtype /Type1 simple font + /// with an embedded classic PostScript /FontDescriptor/FontFile program end to end + /// (Phase B): a hand-authored, entirely synthetic fixture (see + /// PdfFixtures\README.md) built via SyntheticFontBuilder.Type1 (no + /// third-party font asset), whose A glyph is a filled square spanning + /// font-design-space (100, 100)-(500, 500) of a 1000-unit em - deliberately + /// matching 's + /// own glyph design/placement (font size 20, text-space origin (5, 5), on a 100x100 + /// MediaBox), so both share the exact same pixel-assertion convention. + /// + [Fact] + public void CanvasNetPdf_SystemIntegration_RenderEmbeddedType1Font_PaintsExpectedGlyphInk() + { + // Arrange + using var document = PdfDocument.Open(Fixture("text-embedded-type1-font.pdf")); + + // Act + using var surface = document.Render(0, 100, 100); + + // Assert: text x [7, 15) holds the painted square glyph (design x [100, 500) of 1000, + // scaled by fontSize 20, offset by originX 5, flipped against the MediaBox height for + // originY 5) - real glyph ink, not merely "did not throw". + Assert.NotEqual(0, surface[11, 89].A); + + // Assert: the canvas's far corners, well outside the glyph, remain fully transparent. + Assert.Equal(0, surface[0, 0].A); + Assert.Equal(0, surface[99, 0].A); + Assert.Equal(0, surface[0, 99].A); + Assert.Equal(0, surface[99, 99].A); + } + + /// + /// Proves resolves a Standard-14 simple TrueType font + /// (/BaseFont /Helvetica) with no embedded /FontFile2 end to end (Phase 6): + /// a synthetic, in-memory single-page document (no new binary fixture needed) drawing a + /// single glyph. Since the actual substitute font (a matching system font, or the bundled + /// Liberation Sans fallback) genuinely varies across the Windows/Linux/macOS CI matrix, + /// this test asserts the strongest property achievable without hardcoding a + /// platform-specific glyph shape: real, visible glyph ink is painted somewhere on the + /// canvas, proving the fallback path genuinely renders rather than merely not throwing. + /// + [Fact] + public void CanvasNetPdf_SystemIntegration_RenderStandard14HelveticaWithoutEmbeddedFont_PaintsVisibleGlyphInk() + { + // Arrange: a synthetic single-page PDF with a /Helvetica font resource and no /FontFile2 + var bytes = BuildSyntheticFontFallbackPdf( + "/Type /FontDescriptor", + "/Type /Font /Subtype /TrueType /BaseFont /Helvetica /FirstChar 65 /LastChar 65 /Widths [600]", + "BT /F1 60 Tf 20 30 Td (A) Tj ET"); + + // Act + using var document = PdfDocument.Open(new MemoryStream(bytes)); + using var surface = document.Render(0, 100, 100); + + // Assert: at least one visibly-painted (non-transparent) pixel proves a real substitute + // glyph was actually rendered, not merely that no exception was thrown. + var paintedAnyPixel = false; + for (var y = 0; y < surface.Height && !paintedAnyPixel; y++) + { + for (var x = 0; x < surface.Width; x++) + { + if (surface[x, y].A > 0) + { + paintedAnyPixel = true; + break; + } + } + } + + Assert.True(paintedAnyPixel, "Expected the Standard-14 fallback-resolved font to paint visible glyph ink."); + } + + /// + /// Proves resolves a /BaseFont /Symbol font with + /// no embedded /FontFile2 and no /FontDescriptor entries at all (PDF + /// 32000-1 ยง9.6.2.2 permits an entirely absent/empty descriptor) end to end (Phase 6): + /// Symbol/ZapfDingbats no longer fail closed, instead resolving via the bundled Noto + /// substitute font union and painting real, visible glyph ink - the same + /// "actually painted ink, not merely did not throw" assertion style as the Standard-14 + /// Helvetica fallback test above. + /// + [Fact] + public void CanvasNetPdf_SystemIntegration_RenderSymbolFontWithoutEmbeddedFont_PaintsVisibleGlyphInk() + { + // Arrange: a synthetic single-page PDF with a /Symbol font resource, no /FontFile2, and + // an entirely empty /FontDescriptor + var bytes = BuildSyntheticFontFallbackPdf( + string.Empty, + "/Type /Font /Subtype /TrueType /BaseFont /Symbol /FirstChar 97 /LastChar 97 /Widths [600]", + "BT /F1 60 Tf 20 30 Td (a) Tj ET"); + + // Act + using var document = PdfDocument.Open(new MemoryStream(bytes)); + using var surface = document.Render(0, 100, 100); + + // Assert: at least one visibly-painted (non-transparent) pixel proves a real Noto + // substitute glyph was actually rendered, not merely that no exception was thrown. + var paintedAnyPixel = false; + for (var y = 0; y < surface.Height && !paintedAnyPixel; y++) + { + for (var x = 0; x < surface.Width; x++) + { + if (surface[x, y].A > 0) + { + paintedAnyPixel = true; + break; + } + } + } + + Assert.True(paintedAnyPixel, "Expected the Symbol Noto-substitute-resolved font to paint visible glyph ink."); + } + + /// + /// Proves, via the full production round-trip ('s + /// embedded-resource-loading path, loading NotoSans-Regular.ttf the same way + /// PdfDocument.FontFallback.cs's ResolveSymbolicNotoFallback does for the + /// Symbol substitute's primary font) that Symbol code 0x61 ('alpha', per + /// PDF 32000-1 Appendix D's Symbol encoding, mapped to Unicode U+03B1) resolves to a + /// nonzero, valid glyph index - proving the bundled substitute font genuinely carries a + /// real Greek alpha glyph, not merely that the pipeline declines to throw. + /// + [Fact] + public void CanvasNetPdf_SystemIntegration_NotoSansRegularBundledFont_ResolvesSymbolAlphaToNonzeroGlyphIndex() + { + // Arrange: load the exact same embedded resource the Symbol fallback path loads + using var stream = typeof(SystemFontCatalog).Assembly.GetManifestResourceStream( + "DemaConsulting.CanvasNet.Fonts.BundledFonts.NotoSans-Regular.ttf"); + Assert.NotNull(stream); + var font = TrueTypeFont.Load(stream); + + // Act: Symbol code 0x61 ('alpha') maps to Unicode U+03B1 per the Symbol encoding table + var glyphIndex = font.GetGlyphIndex(0x03B1); + + // Assert: a real, non-.notdef glyph exists for Greek alpha in this substitute font + Assert.NotEqual(0, glyphIndex); + } + + /// + /// Regression guard: proves still fails closed end to + /// end (Phase 6) for a non-Symbol/ZapfDingbats font whose /FontDescriptor/Flags + /// declares the Symbolic bit without also declaring Nonsymbolic, and has no + /// embedded /FontFile2 - proving the new Symbol/ZapfDingbats Noto-substitution + /// path introduced by this feature did not loosen the fail-closed policy for any other + /// symbolic font. + /// + [Fact] + public void CanvasNetPdf_SystemIntegration_RenderOtherSymbolicFontWithoutEmbeddedFont_ThrowsUnsupportedImageFeatureException() + { + // Arrange: a synthetic single-page PDF with a non-Symbol/ZapfDingbats symbolic font + // resource (Flags = 4, Symbolic bit only) and no /FontFile2 + var bytes = BuildSyntheticFontFallbackPdf( + "/Type /FontDescriptor /Flags 4", + "/Type /Font /Subtype /TrueType /BaseFont /SomeCustomSymbolFont", + "BT /F1 20 Tf (A) Tj ET"); + + // Act & Assert + using var document = PdfDocument.Open(new MemoryStream(bytes)); + var exception = Assert.Throws(() => document.Render(0, 100, 100)); + Assert.Equal("pdf-font-symbolic-not-embedded", exception.Feature); + } + + /// + /// Builds a minimal, synthetic, in-memory single-page PDF (100x100 /MediaBox) + /// declaring one /F1 simple TrueType font resource (object 5, referencing a + /// /FontDescriptor at object 6, deliberately without any /FontFile2 entry) + /// and the given content stream - used by the Phase 6 font-fallback end-to-end tests, so + /// no new binary PDF fixture file is needed for them. + /// + private static byte[] BuildSyntheticFontFallbackPdf(string descriptorEntries, string fontDictEntries, string content) + { + var contentBytes = System.Text.Encoding.ASCII.GetBytes(content); + var streamBody = System.Text.Encoding.ASCII.GetBytes( + $"<< /Length {contentBytes.Length} >>\nstream\n{content}\nendstream"); + + var bodies = new List + { + "<< /Type /Catalog /Pages 2 0 R >>"u8.ToArray(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 100 100] >>"u8.ToArray(), + "<< /Type /Page /Parent 2 0 R /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>"u8.ToArray(), + streamBody, + System.Text.Encoding.ASCII.GetBytes($"<< {fontDictEntries} /FontDescriptor 6 0 R >>"), + System.Text.Encoding.ASCII.GetBytes($"<< {descriptorEntries} >>"), + }; + + var buffer = new List(); + buffer.AddRange("%PDF-1.7\n"u8.ToArray()); + var offsets = new List(); + for (var i = 0; i < bodies.Count; i++) + { + offsets.Add(buffer.Count); + buffer.AddRange(System.Text.Encoding.ASCII.GetBytes($"{i + 1} 0 obj\n")); + buffer.AddRange(bodies[i]); + buffer.AddRange("\nendobj\n"u8.ToArray()); + } + + var xrefOffset = buffer.Count; + buffer.AddRange(System.Text.Encoding.ASCII.GetBytes($"xref\n0 {bodies.Count + 1}\n")); + buffer.AddRange("0000000000 65535 f \n"u8.ToArray()); + foreach (var offset in offsets) + { + buffer.AddRange(System.Text.Encoding.ASCII.GetBytes($"{offset:D10} 00000 n \n")); + } + + buffer.AddRange(System.Text.Encoding.ASCII.GetBytes( + $"trailer\n<< /Size {bodies.Count + 1} /Root 1 0 R >>\nstartxref\n{xrefOffset}\n%%EOF\n")); + return [.. buffer]; + } + + /// + /// Proves paints an axial (/ShadingType 2) shading + /// pattern fill end-to-end through the public API: a synthetic, in-memory single-page PDF + /// (no binary fixture) declaring a /Pattern-color-space fill driven by a + /// /FunctionType 2 function, proving the painted gradient visibly varies from + /// near-black at one end to near-white at the other. + /// + [Fact] + public void CanvasNetPdf_SystemIntegration_AxialShadingPatternFill_PaintsVisiblyVaryingColors() + { + // Arrange: axis from (0,0) to (100,0), black -> white, filling the whole 100x100 page. + var bytes = BuildSyntheticPatternPdf( + "/Pattern cs /P1 scn 0 0 100 100 re f", + "/Pattern << /P1 5 0 R >>", + [ + "<< /PatternType 2 /Shading 6 0 R >>"u8.ToArray(), + "<< /ShadingType 2 /ColorSpace /DeviceRGB /Coords [0 0 100 0] /Function 7 0 R >>"u8.ToArray(), + BuildPatternFunctionStreamBody("/FunctionType 2 /Domain [0 1] /C0 [0 0 0] /C1 [1 1 1] /N 1"), + ]); + + // Act + using var document = PdfDocument.Open(new MemoryStream(bytes)); + using var surface = document.Render(0, 100, 100); + + // Assert: near-black at the start coordinate, near-white at the end coordinate. + Assert.True(surface[2, 50].R < 50); + Assert.True(surface[97, 50].R > 200); + } + + /// + /// Proves paints a colored (/PaintType 1) tiling + /// pattern fill end-to-end through the public API: a synthetic, in-memory single-page PDF + /// (no binary fixture) declaring a /Pattern-color-space fill driven by a 10x10 + /// pattern cell (left half red, right half blue), proving the painted result repeats both + /// tile colors at the correctly offset device pixel columns. + /// + [Fact] + public void CanvasNetPdf_SystemIntegration_ColoredTilingPatternFill_PaintsRepeatingTileColors() + { + // Arrange: 10x10 pattern-space cell, left half red / right half blue, tiled across a + // 100x100 fill. + const string cellContent = "1 0 0 rg 0 0 5 10 re f 0 0 1 rg 5 0 5 10 re f"; + var patternStreamBytes = System.Text.Encoding.ASCII.GetBytes(cellContent); + var patternStreamBody = System.Text.Encoding.ASCII.GetBytes( + $"<< /PatternType 1 /PaintType 1 /TilingType 1 /BBox [0 0 10 10] /XStep 10 /YStep 10 /Length {patternStreamBytes.Length} >>\nstream\n{cellContent}\nendstream"); + + var bytes = BuildSyntheticPatternPdf( + "/Pattern cs /P1 scn 0 0 100 100 re f", + "/Pattern << /P1 5 0 R >>", + [patternStreamBody]); + + // Act + using var document = PdfDocument.Open(new MemoryStream(bytes)); + using var surface = document.Render(0, 100, 100); + + // Assert: both tile colors appear, at multiple repeated tile offsets. + Assert.Equal(new Canvas.Rgba32(255, 0, 0, 255), surface[2, 50]); + Assert.Equal(new Canvas.Rgba32(0, 0, 255, 255), surface[7, 50]); + Assert.Equal(new Canvas.Rgba32(255, 0, 0, 255), surface[92, 50]); + Assert.Equal(new Canvas.Rgba32(0, 0, 255, 255), surface[97, 50]); + } + + /// Builds a /FunctionType 2 stream object body (no sample data - exponential functions carry no /FunctionType 0 sample bytes) for 's own -driven extra objects. + private static byte[] BuildPatternFunctionStreamBody(string dictionaryEntries) => + System.Text.Encoding.ASCII.GetBytes($"<< {dictionaryEntries} /Length 0 >>\nstream\n\nendstream"); + + /// + /// Builds a minimal, synthetic, in-memory single-page PDF (100x100 /MediaBox) + /// declaring the given content stream and /Resources body (object 4's own page + /// dictionary references object 5, 6, 7, ... in + /// order), mirroring 's own "hand-rolled + /// classic-xref PDF, no binary fixture" shape - used by the Pattern system-integration + /// tests so no new binary PDF fixture file is needed for them. + /// + private static byte[] BuildSyntheticPatternPdf(string content, string resourcesBody, IReadOnlyList extraObjectBodies) + { + var contentBytes = System.Text.Encoding.ASCII.GetBytes(content); + var streamBody = System.Text.Encoding.ASCII.GetBytes( + $"<< /Length {contentBytes.Length} >>\nstream\n{content}\nendstream"); + + var bodies = new List + { + "<< /Type /Catalog /Pages 2 0 R >>"u8.ToArray(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 100 100] >>"u8.ToArray(), + System.Text.Encoding.ASCII.GetBytes( + $"<< /Type /Page /Parent 2 0 R /Contents 4 0 R /Resources << {resourcesBody} >> >>"), + streamBody, + }; + bodies.AddRange(extraObjectBodies); + + var buffer = new List(); + buffer.AddRange("%PDF-1.7\n"u8.ToArray()); + var offsets = new List(); + for (var i = 0; i < bodies.Count; i++) + { + offsets.Add(buffer.Count); + buffer.AddRange(System.Text.Encoding.ASCII.GetBytes($"{i + 1} 0 obj\n")); + buffer.AddRange(bodies[i]); + buffer.AddRange("\nendobj\n"u8.ToArray()); + } + + var xrefOffset = buffer.Count; + buffer.AddRange(System.Text.Encoding.ASCII.GetBytes($"xref\n0 {bodies.Count + 1}\n")); + buffer.AddRange("0000000000 65535 f \n"u8.ToArray()); + foreach (var offset in offsets) + { + buffer.AddRange(System.Text.Encoding.ASCII.GetBytes($"{offset:D10} 00000 n \n")); + } + + buffer.AddRange(System.Text.Encoding.ASCII.GetBytes( + $"trailer\n<< /Size {bodies.Count + 1} /Root 1 0 R >>\nstartxref\n{xrefOffset}\n%%EOF\n")); + return [.. buffer]; + } + + #region RC4 encryption test-only helpers + + /// + /// The standard 32-byte password padding string (ISO 32000-1 7.6.3.3). Duplicated + /// (rather than shared) from 's own identical + /// constant/helpers below, consistent with that file's own "independently re-derived, not + /// copy-pasted" philosophy for test-only cryptographic helpers - this system-level test + /// only needs the single RC4-with-a-real-password path, not that file's full RC4/AES + /// matrix, so the subset is re-derived here rather than exposed as shared production- or + /// test-internal surface. + /// + private static readonly byte[] PasswordPadding = + [ + 0x28, 0xBF, 0x4E, 0x5E, 0x4E, 0x75, 0x8A, 0x41, 0x64, 0x00, 0x4E, 0x56, 0xFF, 0xFA, 0x01, 0x08, + 0x2E, 0x2E, 0x00, 0xB6, 0xD0, 0x68, 0x3E, 0x80, 0x2F, 0x0C, 0xA9, 0xFE, 0x64, 0x53, 0x69, 0x7A, + ]; + + /// A from-scratch, hand-rolled RC4 stream cipher (classic KSA/PRGA) - symmetric, so this single method both "encrypts" the test fixture and would decrypt it. + private static byte[] Rc4(byte[] key, byte[] data) + { + var state = new byte[256]; + for (var i = 0; i < 256; i++) + { + state[i] = (byte)i; + } + + var j = 0; + for (var i = 0; i < 256; i++) + { + j = (j + state[i] + key[i % key.Length]) & 0xFF; + (state[i], state[j]) = (state[j], state[i]); + } + + var output = new byte[data.Length]; + var x = 0; + j = 0; + for (var n = 0; n < data.Length; n++) + { + x = (x + 1) & 0xFF; + j = (j + state[x]) & 0xFF; + (state[x], state[j]) = (state[j], state[x]); + var keystreamByte = state[(state[x] + state[j]) & 0xFF]; + output[n] = (byte)(data[n] ^ keystreamByte); + } + + return output; + } + + /// Encodes (Latin-1) and pads/truncates a real password to exactly 32 bytes per ISO 32000-1 7.6.3.3. + private static byte[] EncodeAndPadRc4Password(string password) + { + var encoded = System.Text.Encoding.Latin1.GetBytes(password); + var padded = new byte[32]; + var copyLength = Math.Min(encoded.Length, 32); + encoded.AsSpan(0, copyLength).CopyTo(padded); + if (copyLength < 32) + { + PasswordPadding.AsSpan(0, 32 - copyLength).CopyTo(padded.AsSpan(copyLength)); + } + + return padded; + } + + /// Computes the Encrypt dictionary's /O entry per ISO 32000-1 Algorithm 3's encrypt direction. + private static byte[] ComputeOwnerEntryRc4Algorithm3(int keyLengthBytes, int revision, byte[] paddedOwnerPasswordBytes, byte[] paddedUserPasswordBytes) + { + var digest = MD5.HashData(paddedOwnerPasswordBytes); + if (revision >= 3) + { + for (var i = 0; i < 50; i++) + { + digest = MD5.HashData(digest.AsSpan(0, keyLengthBytes).ToArray()); + } + } + + var ownerKey = digest.AsSpan(0, keyLengthBytes).ToArray(); + var result = Rc4(ownerKey, paddedUserPasswordBytes); + + if (revision >= 3) + { + for (var round = 1; round <= 19; round++) + { + var roundKey = new byte[ownerKey.Length]; + for (var i = 0; i < ownerKey.Length; i++) + { + roundKey[i] = (byte)(ownerKey[i] ^ round); + } + + result = Rc4(roundKey, result); + } + } + + return result; + } + + /// Computes the file encryption key per ISO 32000-1 Algorithm 2, given an already-padded 32-byte password. + private static byte[] ComputeFileKeyRc4Algorithm2(byte[] paddedPasswordBytes, byte[] oBytes, int permissions, byte[] idBytes, int keyLengthBytes, int revision) + { + using var input = new MemoryStream(); + input.Write(paddedPasswordBytes); + input.Write(oBytes); + input.Write([(byte)permissions, (byte)(permissions >> 8), (byte)(permissions >> 16), (byte)(permissions >> 24)]); + input.Write(idBytes); + + var digest = MD5.HashData(input.ToArray()); + if (revision >= 3) + { + for (var i = 0; i < 50; i++) + { + digest = MD5.HashData(digest.AsSpan(0, keyLengthBytes).ToArray()); + } + } + + return digest.AsSpan(0, keyLengthBytes).ToArray(); + } + + /// Computes the Encrypt dictionary's /U entry per ISO 32000-1 Algorithm 5 (revision 3/4). + private static byte[] ComputeUserEntryRc4Algorithm45(byte[] fileKey, byte[] idBytes) + { + using var hashInput = new MemoryStream(); + hashInput.Write(PasswordPadding); + hashInput.Write(idBytes); + var result = MD5.HashData(hashInput.ToArray()); + result = Rc4(fileKey, result); + for (var round = 1; round <= 19; round++) + { + var roundKey = new byte[fileKey.Length]; + for (var i = 0; i < fileKey.Length; i++) + { + roundKey[i] = (byte)(fileKey[i] ^ round); + } + + result = Rc4(roundKey, result); + } + + var padded = new byte[32]; + result.CopyTo(padded, 0); + for (var i = 16; i < 32; i++) + { + padded[i] = (byte)(0xAA + i); + } + + return padded; + } + + /// Computes a per-object encryption key per ISO 32000-1 Algorithm 1 (RC4 only). + private static byte[] ComputeObjectKeyRc4Algorithm1(byte[] fileKey, int objectNumber, int generation) + { + using var input = new MemoryStream(); + input.Write(fileKey); + input.Write([(byte)objectNumber, (byte)(objectNumber >> 8), (byte)(objectNumber >> 16)]); + input.Write([(byte)generation, (byte)(generation >> 8)]); + + var digest = MD5.HashData(input.ToArray()); + var keyLength = Math.Min(fileKey.Length + 5, 16); + return digest.AsSpan(0, keyLength).ToArray(); + } + + /// + /// Builds an in-memory, single-page (/MediaBox [0 0 100 100]), classic-xref, + /// encrypted PDF: objects 1-3 are the Catalog/Pages/Page, object 4 is the RC4-encrypted + /// /Contents stream holding verbatim, + /// object 5 is the Encrypt dictionary (), and the + /// trailer declares /Encrypt 5 0 R plus a two-element /ID array (both + /// elements set to ). + /// + private static byte[] BuildEncryptedPdf(string encryptDictBody, byte[] idBytes, byte[] encryptedContentBytes) + { + var header = System.Text.Encoding.ASCII.GetBytes($"<< /Length {encryptedContentBytes.Length} >>\nstream\n"); + var footer = "\nendstream"u8.ToArray(); + var contentStreamBody = new byte[header.Length + encryptedContentBytes.Length + footer.Length]; + header.CopyTo(contentStreamBody, 0); + encryptedContentBytes.CopyTo(contentStreamBody, header.Length); + footer.CopyTo(contentStreamBody, header.Length + encryptedContentBytes.Length); + + var bodies = new List + { + "<< /Type /Catalog /Pages 2 0 R >>"u8.ToArray(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 100 100] >>"u8.ToArray(), + "<< /Type /Page /Parent 2 0 R /Contents 4 0 R >>"u8.ToArray(), + contentStreamBody, + System.Text.Encoding.ASCII.GetBytes(encryptDictBody), + }; + + var buffer = new List(); + buffer.AddRange("%PDF-1.7\n"u8.ToArray()); + var offsets = new List(); + for (var i = 0; i < bodies.Count; i++) + { + offsets.Add(buffer.Count); + buffer.AddRange(System.Text.Encoding.ASCII.GetBytes($"{i + 1} 0 obj\n")); + buffer.AddRange(bodies[i]); + buffer.AddRange("\nendobj\n"u8.ToArray()); + } + + var xrefOffset = buffer.Count; + buffer.AddRange(System.Text.Encoding.ASCII.GetBytes($"xref\n0 {bodies.Count + 1}\n")); + buffer.AddRange("0000000000 65535 f \n"u8.ToArray()); + foreach (var offset in offsets) + { + buffer.AddRange(System.Text.Encoding.ASCII.GetBytes($"{offset:D10} 00000 n \n")); + } + + var idHex = Convert.ToHexString(idBytes); + buffer.AddRange(System.Text.Encoding.ASCII.GetBytes( + $"trailer\n<< /Size {bodies.Count + 1} /Root 1 0 R /Encrypt 5 0 R /ID [<{idHex}> <{idHex}>] >>\nstartxref\n{xrefOffset}\n%%EOF\n")); + return [.. buffer]; + } + + #endregion +} diff --git a/test/DemaConsulting.CanvasNet.Tests/CanvasNetTests.cs b/test/DemaConsulting.CanvasNet.Tests/CanvasNetTests.cs index cce477e7..5b05967e 100644 --- a/test/DemaConsulting.CanvasNet.Tests/CanvasNetTests.cs +++ b/test/DemaConsulting.CanvasNet.Tests/CanvasNetTests.cs @@ -914,4 +914,279 @@ public void CanvasNet_SystemIntegration_DrawAndMeasureTextViaCanvas_RendersAndMe Assert.True(coveredPixelCount > 0, "DrawText produced no rendered pixels"); } + + /// + /// Proves that the system can load a real CFF/OpenType (OTTO-flavored) production font + /// through , decode one of its glyphs from Type 2 + /// charstring bytecode, and render it as real visible ink through the public + /// API - confirming the system integrates CFF outline decoding + /// with vector rasterization end to end, not merely glyf-flavored TrueType outlines. + /// + [Fact] + public void CanvasNet_SystemIntegration_LoadCffOtfFontAndFillGlyphOutline_ReturnsExpectedPixels() + { + // Arrange: load the real "Source Sans 3" CFF/OpenType (.otf) fixture and resolve capital + // 'H' (a straight-line-only glyph verified to decode with this library's supported Type 2 + // charstring operator subset - see FontFixtures/README.md) to a real glyph index + var fontPath = System.IO.Path.Join(AppContext.BaseDirectory, "FontFixtures", "SourceSans3-Regular.otf"); + var font = TrueTypeFont.Load(fontPath); + var glyphIndex = font.GetGlyphIndex('H'); + Assert.NotEqual(0, glyphIndex); + + var outline = font.GetGlyphOutline(glyphIndex); + Assert.NotEmpty(outline.Subpaths); + + // Act: transform the glyph's font-design-unit outline into a small pixel-space canvas + // and fill it through the public PathFiller API + const int canvasSize = 64; + const float margin = 8f; + var rawBounds = outline.GetBounds(0.25f); + Assert.False(rawBounds.IsEmpty); + var maxExtent = Math.Max(rawBounds.Width, rawBounds.Height); + var scale = (canvasSize - 2 * margin) / maxExtent; + + Vector2 Map(Vector2 p) => new( + (p.X - rawBounds.Left) * scale + margin, + (rawBounds.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.CubicBezierTo: + builder.CubicBezierTo(Map(command.Control1), Map(command.Control2), Map(command.EndPoint)); + break; + case PathCommandType.Close: + builder.Close(); + break; + } + } + } + + var transformed = builder.Build(); + var surface = new Surface(canvasSize, canvasSize); + PathFiller.Fill(surface, transformed, new Rgba32(0, 0, 0, 255)); + + // Assert: at least one pixel was actually painted (real ink), and the surface's far + // corners remain fully transparent background + var foundInk = false; + for (var y = 0; y < canvasSize && !foundInk; y++) + { + for (var x = 0; x < canvasSize; x++) + { + if (surface[x, y].A > 0) + { + foundInk = true; + break; + } + } + } + + Assert.True(foundInk, "Expected at least one filled (non-transparent) pixel from the decoded CFF glyph outline."); + Assert.Equal(0, surface[0, 0].A); + Assert.Equal(0, surface[canvasSize - 1, canvasSize - 1].A); + } + + /// + /// Proves that the system can load a classic PostScript Type 1 font program through the + /// public API, decode one of its glyphs from Type 1 + /// charstring bytecode, and render it as real visible ink through the public + /// API - confirming the system integrates Type 1 outline decoding + /// with vector rasterization end to end, alongside the glyf and CFF outline flavors. + /// + [Fact] + public void CanvasNet_SystemIntegration_LoadType1FontAndFillGlyphOutline_ReturnsExpectedPixels() + { + // Arrange: a hand-authored synthetic Type 1 font program (a required ".notdef" glyph plus + // a single triangular glyph) built from hsbw/rmoveto/rlineto/closepath/endchar (this + // library's supported Type 1 charstring operator subset) + var notdef = new List(); + SyntheticFontBuilder.WriteType1CharstringNumber(notdef, 0); + SyntheticFontBuilder.WriteType1CharstringNumber(notdef, 600); + SyntheticFontBuilder.WriteType1CharstringOperator(notdef, 13); // hsbw + SyntheticFontBuilder.WriteType1CharstringOperator(notdef, 14); // endchar + + var triangle = new List(); + SyntheticFontBuilder.WriteType1CharstringNumber(triangle, 0); + SyntheticFontBuilder.WriteType1CharstringNumber(triangle, 700); + SyntheticFontBuilder.WriteType1CharstringOperator(triangle, 13); // hsbw + SyntheticFontBuilder.WriteType1CharstringNumber(triangle, 0); + SyntheticFontBuilder.WriteType1CharstringNumber(triangle, 0); + SyntheticFontBuilder.WriteType1CharstringOperator(triangle, 21); // rmoveto + SyntheticFontBuilder.WriteType1CharstringNumber(triangle, 600); + SyntheticFontBuilder.WriteType1CharstringNumber(triangle, 0); + SyntheticFontBuilder.WriteType1CharstringOperator(triangle, 5); // rlineto + SyntheticFontBuilder.WriteType1CharstringNumber(triangle, -300); + SyntheticFontBuilder.WriteType1CharstringNumber(triangle, 600); + SyntheticFontBuilder.WriteType1CharstringOperator(triangle, 5); // rlineto + SyntheticFontBuilder.WriteType1CharstringOperator(triangle, 9); // closepath + SyntheticFontBuilder.WriteType1CharstringOperator(triangle, 14); // endchar + + var program = SyntheticFontBuilder.Type1([(".notdef", [.. notdef]), ("T", [.. triangle])]); + using var fontStream = new MemoryStream(program.FontFileBytes); + var font = TrueTypeFont.LoadType1( + fontStream, + program.Length1, + program.Length2, + new Dictionary { ['T'] = "T" }); + + var glyphIndex = font.GetGlyphIndex('T'); + Assert.NotEqual(0, glyphIndex); + + var outline = font.GetGlyphOutline(glyphIndex); + Assert.Single(outline.Subpaths); + + // Act: transform the glyph's font-design-unit outline into a small pixel-space canvas + // and fill it through the public PathFiller API + const int canvasSize = 64; + const float margin = 8f; + var rawBounds = outline.GetBounds(0.25f); + Assert.False(rawBounds.IsEmpty); + var maxExtent = Math.Max(rawBounds.Width, rawBounds.Height); + var scale = (canvasSize - 2 * margin) / maxExtent; + + Vector2 Map(Vector2 p) => new( + (p.X - rawBounds.Left) * scale + margin, + (rawBounds.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.CubicBezierTo: + builder.CubicBezierTo(Map(command.Control1), Map(command.Control2), Map(command.EndPoint)); + break; + case PathCommandType.Close: + builder.Close(); + break; + } + } + } + + var transformed = builder.Build(); + var surface = new Surface(canvasSize, canvasSize); + PathFiller.Fill(surface, transformed, new Rgba32(0, 0, 0, 255)); + + // Assert: at least one pixel was actually painted (real ink), and the surface's far + // corners remain fully transparent background + var foundInk = false; + for (var y = 0; y < canvasSize && !foundInk; y++) + { + for (var x = 0; x < canvasSize; x++) + { + if (surface[x, y].A > 0) + { + foundInk = true; + break; + } + } + } + + Assert.True(foundInk, "Expected at least one filled (non-transparent) pixel from the decoded Type 1 glyph outline."); + Assert.Equal(0, surface[0, 0].A); + Assert.Equal(0, surface[canvasSize - 1, canvasSize - 1].A); + } + + /// + /// Proves that the system can report a TrueType Collection (ttcf) container's face + /// count and load either of its faces independently by index through the public + /// / + /// API, and that omitting the face index defaults to face zero - confirming the system's + /// multi-face collection support integrates end to end against a real ttcf + /// container. + /// + [Fact] + public void CanvasNet_SystemIntegration_LoadFaceFromTtcContainerByIndex_ReturnsExpectedFace() + { + // Arrange: the locally-assembled 2-face ttcf fixture (see FontFixtures/README.md) + var ttcPath = System.IO.Path.Join(AppContext.BaseDirectory, "FontFixtures", "OpenSans-SourceSans3.ttc"); + + // Act: report the face count, load both faces explicitly by index, and once more with + // no index at all + var faceCount = TrueTypeFont.GetFaceCount(ttcPath); + var faceZero = TrueTypeFont.Load(ttcPath, 0); + var faceOne = TrueTypeFont.Load(ttcPath, 1); + var defaultFace = TrueTypeFont.Load(ttcPath); + + // Assert: the container reports 2 faces, face 0 is "Open Sans", face 1 is "Source Sans + // 3", and loading with no explicit face index behaves exactly like requesting face 0 + Assert.Equal(2, faceCount); + Assert.Equal("Open Sans", faceZero.GetNameInfo().FamilyName); + Assert.Equal("Source Sans 3", faceOne.GetNameInfo().FamilyName); + Assert.Equal("Open Sans", defaultFace.GetNameInfo().FamilyName); + } + + /// + /// Proves that the system exposes a real, loaded production font's name-table strings and + /// derived style metadata through / + /// // + /// , confirming the system integrates + /// name/OS/2/post table parsing end to end against real font data + /// (values independently confirmed against this fixture via fonttools - see + /// 's remarks). + /// + [Fact] + public void CanvasNet_SystemIntegration_LoadFontAndQueryNameAndStyleMetadata_ReturnsExpectedValues() + { + // Arrange/Act: load the real "Open Sans" production font and resolve its name/style + // metadata through the public API + var fontPath = System.IO.Path.Join(AppContext.BaseDirectory, "FontFixtures", "OpenSans-Regular.ttf"); + var font = TrueTypeFont.Load(fontPath); + var info = font.GetNameInfo(); + + // Assert: the exact name-table values and derived style metadata for this fixture + Assert.Equal("Open Sans", info.FamilyName); + Assert.Equal("Regular", info.SubfamilyName); + Assert.Equal("Open Sans Regular", info.FullName); + Assert.Equal("OpenSans-Regular", info.PostScriptName); + Assert.False(font.IsBold); + Assert.False(font.IsItalic); + Assert.False(font.IsFixedPitch); + } + + /// + /// Proves that the system's bundled, always-available Liberation Sans last-resort + /// fallback font loads successfully and exposes a real, usable font through the public + /// API, deterministically regardless + /// of what (if anything) is actually installed on the machine running the test - + /// confirming the system-font-discovery boundary's fallback path integrates end to end + /// even in a CI environment with zero discoverable OS fonts. + /// + [Fact] + public void CanvasNet_SystemIntegration_ResolveSystemFontFallback_ReturnsBundledFallbackFont() + { + // Act: request the bundled Liberation Sans Regular fallback through the public API - + // this never consults the host operating system's installed fonts at all + var font = SystemFontCatalog.LoadBundledFallback(serif: false, fixedPitch: false, bold: false, italic: false); + var info = font.GetNameInfo(); + var glyphIndex = font.GetGlyphIndex('A'); + + // Assert: a genuine, well-formed, usable font was loaded - real glyph data, not a stub + Assert.StartsWith("Liberation Sans", info.FamilyName, StringComparison.Ordinal); + Assert.False(font.IsBold); + Assert.False(font.IsItalic); + Assert.False(font.IsFixedPitch); + Assert.NotEqual(0, glyphIndex); + Assert.True(font.GlyphCount > 0); + } } diff --git a/test/DemaConsulting.CanvasNet.Tests/Drawing/PathFillerTests.cs b/test/DemaConsulting.CanvasNet.Tests/Drawing/PathFillerTests.cs index 41422561..8d9d4d11 100644 --- a/test/DemaConsulting.CanvasNet.Tests/Drawing/PathFillerTests.cs +++ b/test/DemaConsulting.CanvasNet.Tests/Drawing/PathFillerTests.cs @@ -570,5 +570,107 @@ public void PathFiller_Fill_Gradient_OverlappingSameWoundRectangles_NonZeroVsEve Assert.NotEqual((byte)0, surface1[2, 2].A); Assert.Equal((byte)0, surface2[2, 2].A); } + + /// + /// Builds a 2x2 tile surface split into a red/blue 2-color checkerboard (red at (0,0) + /// and (1,1), blue at (1,0) and (0,1)), used by the overload + /// tests below. + /// + private static Surface CreateCheckerboardTileSurface() + { + var tileSurface = new Surface(2, 2); + tileSurface[0, 0] = new Rgba32(255, 0, 0, 255); + tileSurface[1, 0] = new Rgba32(0, 0, 255, 255); + tileSurface[0, 1] = new Rgba32(0, 0, 255, 255); + tileSurface[1, 1] = new Rgba32(255, 0, 0, 255); + return tileSurface; + } + + /// + /// Proves that the overload of Fill paints a repeating tile + /// pattern: filling a region larger than a single tile with a 2-color checkerboard tile + /// paint produces both of the tile's own colors in the filled result. + /// + [Fact] + public void PathFiller_Fill_TilePaint_CheckerboardTile_FillsWithBothTileColors() + { + using var tileSurface = CreateCheckerboardTileSurface(); + var tilePaint = new TilePaint(tileSurface, Matrix3x2.Identity, 2f, 2f); + + using var surface = new Surface(8, 8); + var path = new PathBuilder() + .MoveTo(new Vector2(0, 0)).LineTo(new Vector2(8, 0)).LineTo(new Vector2(8, 8)).LineTo(new Vector2(0, 8)) + .Close() + .Build(); + + PathFiller.Fill(surface, path, tilePaint); + + var distinctColors = new HashSet(); + for (var y = 0; y < 8; y++) + { + for (var x = 0; x < 8; x++) + { + distinctColors.Add(surface[x, y]); + } + } + + Assert.Contains(new Rgba32(255, 0, 0, 255), distinctColors); + Assert.Contains(new Rgba32(0, 0, 255, 255), distinctColors); + } + + /// + /// Proves that the overload throws ArgumentNullException when the + /// surface argument is . + /// + [Fact] + public void PathFiller_Fill_TilePaint_NullSurface_ThrowsArgumentNullException() + { + using var tileSurface = CreateCheckerboardTileSurface(); + var tilePaint = new TilePaint(tileSurface, Matrix3x2.Identity, 2f, 2f); + + Assert.Throws(() => PathFiller.Fill(null!, Path.Empty, tilePaint)); + } + + /// + /// Proves that the overload throws ArgumentNullException when the + /// path argument is . + /// + [Fact] + public void PathFiller_Fill_TilePaint_NullPath_ThrowsArgumentNullException() + { + using var tileSurface = CreateCheckerboardTileSurface(); + var tilePaint = new TilePaint(tileSurface, Matrix3x2.Identity, 2f, 2f); + using var surface = new Surface(1, 1); + + Assert.Throws(() => PathFiller.Fill(surface, null!, tilePaint)); + } + + /// + /// Proves that the overload throws ArgumentNullException when the + /// paint argument is . + /// + [Fact] + public void PathFiller_Fill_TilePaint_NullPaint_ThrowsArgumentNullException() + { + using var surface = new Surface(1, 1); + + Assert.Throws(() => PathFiller.Fill(surface, Path.Empty, (TilePaint)null!)); + } + + /// + /// Proves that the overload is a no-op on an empty path, matching + /// the solid-color and gradient overloads' own documented behavior. + /// + [Fact] + public void PathFiller_Fill_TilePaint_EmptyPath_NoOpLeavesSurfaceUnchanged() + { + using var tileSurface = CreateCheckerboardTileSurface(); + var tilePaint = new TilePaint(tileSurface, Matrix3x2.Identity, 2f, 2f); + using var surface = new Surface(2, 2); + + PathFiller.Fill(surface, Path.Empty, tilePaint); + + Assert.Equal((byte)0, surface[0, 0].A); + } } diff --git a/test/DemaConsulting.CanvasNet.Tests/Drawing/ScanlineRasterizerTests.cs b/test/DemaConsulting.CanvasNet.Tests/Drawing/ScanlineRasterizerTests.cs index 08f1d125..cf607a57 100644 --- a/test/DemaConsulting.CanvasNet.Tests/Drawing/ScanlineRasterizerTests.cs +++ b/test/DemaConsulting.CanvasNet.Tests/Drawing/ScanlineRasterizerTests.cs @@ -561,5 +561,65 @@ public void ScanlineRasterizer_Fill_Gradient_NoPolygons_NoOp() Assert.Equal(new Rgba32(1, 2, 3, 4), surface[0, 0]); } + + /// + /// Proves that the overload of Fill evaluates a genuinely + /// repeating tile (not a single constant color) - a fully covered 1x4 row filled with a + /// 2-color, 2-wide tile paint produces alternating colors across its width. + /// + [Fact] + public void ScanlineRasterizer_Fill_TilePaint_FullCoverageRow_RepeatsAcrossWidth() + { + using var tileSurface = new Surface(2, 1); + tileSurface[0, 0] = new Rgba32(255, 0, 0, 255); + tileSurface[1, 0] = new Rgba32(0, 0, 255, 255); + var tilePaint = new TilePaint(tileSurface, Matrix3x2.Identity, 2f, 1f); + + using var surface = new Surface(4, 1); + var polygon = new List + { + new(0, 0), new(4, 0), new(4, 1), new(0, 1), new(0, 0) + }; + var clipBounds = new Rect(0, 0, 4, 1); + + ScanlineRasterizer.Fill(surface, [polygon], tilePaint, FillRule.NonZero, clipBounds); + + Assert.Equal(new Rgba32(255, 0, 0, 255), surface[0, 0]); + Assert.Equal(new Rgba32(0, 0, 255, 255), surface[1, 0]); + Assert.Equal(new Rgba32(255, 0, 0, 255), surface[2, 0]); + Assert.Equal(new Rgba32(0, 0, 255, 255), surface[3, 0]); + } + + /// + /// Proves that the overload is a no-op when given no polygons, + /// matching the solid-color and gradient overloads' documented behavior. + /// + [Fact] + public void ScanlineRasterizer_Fill_TilePaint_NoPolygons_NoOp() + { + using var tileSurface = new Surface(1, 1); + tileSurface[0, 0] = new Rgba32(9, 9, 9, 9); + var tilePaint = new TilePaint(tileSurface, Matrix3x2.Identity, 1f, 1f); + + using var surface = new Surface(2, 2); + surface[0, 0] = new Rgba32(1, 2, 3, 4); + + ScanlineRasterizer.Fill(surface, [], tilePaint, FillRule.NonZero, new Rect(0, 0, 2, 2)); + + Assert.Equal(new Rgba32(1, 2, 3, 4), surface[0, 0]); + } + + /// + /// Proves that the overload throws ArgumentNullException when the + /// paint argument is . + /// + [Fact] + public void ScanlineRasterizer_Fill_TilePaint_NullPaint_ThrowsArgumentNullException() + { + using var surface = new Surface(2, 2); + + Assert.Throws( + () => ScanlineRasterizer.Fill(surface, [], (TilePaint)null!, FillRule.NonZero, new Rect(0, 0, 2, 2))); + } } diff --git a/test/DemaConsulting.CanvasNet.Tests/Drawing/TilePaintEvaluatorTests.cs b/test/DemaConsulting.CanvasNet.Tests/Drawing/TilePaintEvaluatorTests.cs new file mode 100644 index 00000000..7480424f --- /dev/null +++ b/test/DemaConsulting.CanvasNet.Tests/Drawing/TilePaintEvaluatorTests.cs @@ -0,0 +1,118 @@ +using System.Numerics; +using DemaConsulting.CanvasNet.Canvas; +using DemaConsulting.CanvasNet.Drawing; + +namespace DemaConsulting.CanvasNet.Tests.Drawing; + +/// +/// Unit tests for the internal class. +/// +public class TilePaintEvaluatorTests +{ + /// + /// Builds a 2x2 tile surface split into a red/blue 2-color checkerboard (red at (0,0) + /// and (1,1), blue at (1,0) and (0,1)). + /// + private static Surface CreateCheckerboardTileSurface() + { + var surface = new Surface(2, 2); + surface[0, 0] = new Rgba32(255, 0, 0, 255); + surface[1, 0] = new Rgba32(0, 0, 255, 255); + surface[0, 1] = new Rgba32(0, 0, 255, 255); + surface[1, 1] = new Rgba32(255, 0, 0, 255); + return surface; + } + + /// + /// Proves that CreatePlan throws ArgumentNullException when the tile is null. + /// + [Fact] + public void CreatePlan_NullTile_ThrowsArgumentNullException() + { + Assert.Throws(() => TilePaintEvaluator.CreatePlan(null!)); + } + + /// + /// Proves that a non-invertible (all-zero) Transform causes EvaluateRow to write fully + /// transparent pixels for the whole row - this is the documented, deliberate + /// "paint nothing" Degenerate Transform policy, which intentionally diverges from + /// Gradient's own "flat-fill with the last stop color" policy (see + /// TilePaintEvaluator's remarks). + /// + [Fact] + public void EvaluateRow_NonInvertibleTransform_WritesFullyTransparentRow() + { + using var tileSurface = CreateCheckerboardTileSurface(); + var tile = new TilePaint(tileSurface, default, 2f, 2f); + var plan = TilePaintEvaluator.CreatePlan(tile); + + Span destination = stackalloc Rgba32[4]; + TilePaintEvaluator.EvaluateRow(in plan, 0, 0, 4, destination); + + foreach (var pixel in destination) + { + Assert.Equal(default, pixel); + } + } + + /// + /// Proves that an in-bounds sample against an identity-transform tile produces the exact + /// expected tile pixel at each device point within the first tile repetition. + /// + [Fact] + public void EvaluateRow_IdentityTransform_SamplesExpectedTilePixels() + { + using var tileSurface = CreateCheckerboardTileSurface(); + var tile = new TilePaint(tileSurface, Matrix3x2.Identity, 2f, 2f); + var plan = TilePaintEvaluator.CreatePlan(tile); + + Span destination = stackalloc Rgba32[2]; + TilePaintEvaluator.EvaluateRow(in plan, 0, 0, 2, destination); + + Assert.Equal(new Rgba32(255, 0, 0, 255), destination[0]); + Assert.Equal(new Rgba32(0, 0, 255, 255), destination[1]); + } + + /// + /// Proves that sampling beyond one tile repetition wraps around via modulo - sampling at + /// x=2.5 (one full XStep=2 beyond x=0.5) produces the same color as sampling at x=0.5. + /// + [Fact] + public void EvaluateRow_WrapAroundBeyondOneTile_MatchesFirstTileRepetition() + { + using var tileSurface = CreateCheckerboardTileSurface(); + var tile = new TilePaint(tileSurface, Matrix3x2.Identity, 2f, 2f); + var plan = TilePaintEvaluator.CreatePlan(tile); + + Span destination = stackalloc Rgba32[4]; + TilePaintEvaluator.EvaluateRow(in plan, 0, 0, 4, destination); + + // Columns 0 and 2 both sample pattern-space x in [0,1) -> same wrapped tile pixel. + Assert.Equal(destination[0], destination[2]); + // Columns 1 and 3 both sample pattern-space x in [1,2) -> same wrapped tile pixel. + Assert.Equal(destination[1], destination[3]); + } + + /// + /// Proves that a negative XStep/YStep still samples correctly - wraparound uses the + /// absolute value of the step, not the signed value. + /// + [Fact] + public void EvaluateRow_NegativeSteps_SamplesUsingAbsoluteValueWraparound() + { + using var tileSurface = CreateCheckerboardTileSurface(); + var positiveTile = new TilePaint(tileSurface, Matrix3x2.Identity, 2f, 2f); + var negativeTile = new TilePaint(tileSurface, Matrix3x2.Identity, -2f, -2f); + + var positivePlan = TilePaintEvaluator.CreatePlan(positiveTile); + var negativePlan = TilePaintEvaluator.CreatePlan(negativeTile); + + Span positiveRow = stackalloc Rgba32[2]; + Span negativeRow = stackalloc Rgba32[2]; + TilePaintEvaluator.EvaluateRow(in positivePlan, 0, 0, 2, positiveRow); + TilePaintEvaluator.EvaluateRow(in negativePlan, 0, 0, 2, negativeRow); + + Assert.Equal(positiveRow[0], negativeRow[0]); + Assert.Equal(positiveRow[1], negativeRow[1]); + } +} diff --git a/test/DemaConsulting.CanvasNet.Tests/Drawing/TilePaintTests.cs b/test/DemaConsulting.CanvasNet.Tests/Drawing/TilePaintTests.cs new file mode 100644 index 00000000..e49b2c6d --- /dev/null +++ b/test/DemaConsulting.CanvasNet.Tests/Drawing/TilePaintTests.cs @@ -0,0 +1,138 @@ +using System.Numerics; +using DemaConsulting.CanvasNet.Canvas; +using DemaConsulting.CanvasNet.Drawing; + +namespace DemaConsulting.CanvasNet.Tests.Drawing; + +/// +/// Unit tests for . +/// +public class TilePaintTests +{ + private static Surface OneByOneSurface() => new(1, 1); + + /// + /// Proves that the constructor rejects a null surface. + /// + [Fact] + public void TilePaint_Constructor_NullSurface_ThrowsArgumentNullException() + { + Assert.Throws(() => new TilePaint(null!, Matrix3x2.Identity, 1f, 1f)); + } + + /// + /// Proves that the constructor rejects a transform with a NaN component. + /// + [Fact] + public void TilePaint_Constructor_TransformWithNaNComponent_ThrowsArgumentOutOfRangeException() + { + using var surface = OneByOneSurface(); + var transform = new Matrix3x2(float.NaN, 0, 0, 1, 0, 0); + + Assert.Throws(() => new TilePaint(surface, transform, 1f, 1f)); + } + + /// + /// Proves that the constructor rejects a transform with an infinity component. + /// + [Fact] + public void TilePaint_Constructor_TransformWithInfinityComponent_ThrowsArgumentOutOfRangeException() + { + using var surface = OneByOneSurface(); + var transform = new Matrix3x2(1, 0, 0, float.PositiveInfinity, 0, 0); + + Assert.Throws(() => new TilePaint(surface, transform, 1f, 1f)); + } + + /// + /// Proves that the constructor rejects a zero XStep. + /// + [Fact] + public void TilePaint_Constructor_ZeroXStep_ThrowsArgumentOutOfRangeException() + { + using var surface = OneByOneSurface(); + + Assert.Throws(() => new TilePaint(surface, Matrix3x2.Identity, 0f, 1f)); + } + + /// + /// Proves that the constructor rejects a zero YStep. + /// + [Fact] + public void TilePaint_Constructor_ZeroYStep_ThrowsArgumentOutOfRangeException() + { + using var surface = OneByOneSurface(); + + Assert.Throws(() => new TilePaint(surface, Matrix3x2.Identity, 1f, 0f)); + } + + /// + /// Proves that the constructor rejects a NaN or infinite XStep/YStep. + /// + [Theory] + [InlineData(float.NaN, 1f)] + [InlineData(float.PositiveInfinity, 1f)] + [InlineData(1f, float.NaN)] + [InlineData(1f, float.NegativeInfinity)] + public void TilePaint_Constructor_NonFiniteStep_ThrowsArgumentOutOfRangeException(float xStep, float yStep) + { + using var surface = OneByOneSurface(); + + Assert.Throws(() => new TilePaint(surface, Matrix3x2.Identity, xStep, yStep)); + } + + /// + /// Proves that a negative XStep/YStep is accepted, not rejected - it is a legitimate PDF + /// tiling-pattern value meaning the tile repeats in the negative axis direction. + /// + [Fact] + public void TilePaint_Constructor_NegativeSteps_Succeeds() + { + using var surface = OneByOneSurface(); + + var tile = new TilePaint(surface, Matrix3x2.Identity, -2f, -3f); + + Assert.Equal(-2f, tile.XStep); + Assert.Equal(-3f, tile.YStep); + } + + /// + /// Proves that every constructor argument round-trips through its corresponding property. + /// + [Fact] + public void TilePaint_Constructor_ValidValues_PropertiesRoundTrip() + { + using var surface = new Surface(2, 3); + var transform = Matrix3x2.CreateScale(2f); + + var tile = new TilePaint(surface, transform, 5f, 7f); + + Assert.Same(surface, tile.Surface); + Assert.Equal(transform, tile.Transform); + Assert.Equal(5f, tile.XStep); + Assert.Equal(7f, tile.YStep); + } + + /// + /// Proves that composes the supplied transform + /// after this tile paint's own existing (row-vector + /// convention: this.Transform * transform), returning a new + /// that otherwise preserves every other property (Surface/XStep/YStep) unchanged, and + /// leaves the original instance untouched. + /// + [Fact] + public void TilePaint_WithTransform_ComposesTransformPreservingOtherProperties() + { + using var surface = OneByOneSurface(); + var original = new TilePaint(surface, Matrix3x2.CreateScale(2f), 4f, 6f); + var extra = Matrix3x2.CreateTranslation(10f, 5f); + + var result = original.WithTransform(extra); + + Assert.Equal(original.Transform * extra, result.Transform); + Assert.Same(surface, result.Surface); + Assert.Equal(4f, result.XStep); + Assert.Equal(6f, result.YStep); + Assert.Equal(Matrix3x2.CreateScale(2f), original.Transform); + } +} diff --git a/test/DemaConsulting.CanvasNet.Tests/FontFixtures/OpenSans-SourceSans3.ttc b/test/DemaConsulting.CanvasNet.Tests/FontFixtures/OpenSans-SourceSans3.ttc new file mode 100644 index 00000000..c3dfe2f7 Binary files /dev/null and b/test/DemaConsulting.CanvasNet.Tests/FontFixtures/OpenSans-SourceSans3.ttc differ diff --git a/test/DemaConsulting.CanvasNet.Tests/FontFixtures/README.md b/test/DemaConsulting.CanvasNet.Tests/FontFixtures/README.md index 5a76bc1a..94c08773 100644 --- a/test/DemaConsulting.CanvasNet.Tests/FontFixtures/README.md +++ b/test/DemaConsulting.CanvasNet.Tests/FontFixtures/README.md @@ -5,9 +5,47 @@ sourced from the [Open Sans](https://www.opensans.com/) project and licensed und [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 +`SourceSans3-Regular.otf` is the real, production "Source Sans 3" CFF/OpenType font (Regular +weight), sourced from Adobe's [source-sans](https://github.com/adobe-fonts/source-sans) project +(the `OTF/SourceSans3-Regular.otf` release asset - Adobe's own repository, unlike most Google +Fonts family repositories, ships genuine `OTTO`-tagged/CFF-outline `.otf` files rather than only +`.ttf`) and licensed under the SIL Open Font License, Version 1.1 - see `SourceSans3.LICENSE` +(the license's full text) alongside it in this folder. + +`OpenSans-SourceSans3.ttc` is a 2-face TrueType Collection (`ttcf`) container, locally assembled +(not redistributed as obtained from either upstream project) by concatenating the exact bytes of +`OpenSans-Regular.ttf` (face 0) and `SourceSans3-Regular.otf` (face 1) behind a `ttcf` header, +with each face's own table directory entries rewritten so their `offset` fields are absolute from +the start of the container rather than from the start of that face's own bytes - the same +transformation a genuine multi-face `ttcf` container's table directory entries already reflect. +No table data byte was altered, added, deduplicated, or removed; every table remains wholly +contained within its own face's original byte range. This container was assembled directly from +this folder's two already-present, unmodified upstream fixtures via a short one-off script (not +itself part of this repository) purely to exercise `TrueTypeFont`'s multi-face collection support +against genuine SFNT table data end to end, since no sufficiently small, permissively-licensed +real-world `.ttc` fixture was readily available to source instead. The SIL OFL's Section 2 permits +bundling a Reserved Font Name-carrying font together with other software/fonts (including inside +a single container file) provided the font itself is not sold by itself and any modified version +does not retain the original name as its own font name; this container carries both source fonts' +unmodified bytes side by side rather than a modified derivative of either, and is not distributed +or sold on its own, so no restriction is violated. Both `OpenSans.LICENSE` and +`SourceSans3.LICENSE` therefore continue to attribute and license every byte this container +contains. + +These fixtures are used only by real-world integration tests that exercise `TrueTypeFont` against +actual production font files, 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. +redistributing a font with software; it may not be sold standalone, and a modified version may not +use the original font's name - neither restriction applies to this repository's use of these fonts +as unmodified, bundled test fixtures (and their locally-assembled `ttcf` combination, per the +paragraph above). + +`SourceSans3-Regular.otf`'s real-font integration test deliberately selects a single, +straight-line-only glyph (capital `H`) rather than every Latin letter: this choice predates this +library's CFF Type 2 charstring interpreter gaining support for the two-byte flex escape +operators and the `rcurveline`/`rlinecurve` operators (24/25) - all of which are now implemented +and covered by dedicated tests in `CffCharstringInterpreterTests.cs` (see +`CffCharstringInterpreter`'s own documentation) - and the glyph selection was simply never +revisited afterward. `H`, like several other straight-sided capitals (`I`, `L`, `T`, `F`) and the +digit `1`, remains a reliable, intentionally simple fixture glyph for this integration test +regardless. diff --git a/test/DemaConsulting.CanvasNet.Tests/FontFixtures/SourceSans3-Regular.otf b/test/DemaConsulting.CanvasNet.Tests/FontFixtures/SourceSans3-Regular.otf new file mode 100644 index 00000000..7a261d93 Binary files /dev/null and b/test/DemaConsulting.CanvasNet.Tests/FontFixtures/SourceSans3-Regular.otf differ diff --git a/test/DemaConsulting.CanvasNet.Tests/FontFixtures/SourceSans3.LICENSE b/test/DemaConsulting.CanvasNet.Tests/FontFixtures/SourceSans3.LICENSE new file mode 100644 index 00000000..22c601b8 --- /dev/null +++ b/test/DemaConsulting.CanvasNet.Tests/FontFixtures/SourceSans3.LICENSE @@ -0,0 +1,93 @@ +Copyright 2010-2024 Adobe (http://www.adobe.com/), with Reserved Font Name 'Source'. All Rights Reserved. Source is a trademark of Adobe in the United States and/or other countries. + +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: http://scripts.sil.org/OFL + + +----------------------------------------------------------- +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/Fonts/BundledLiberationFontsTests.cs b/test/DemaConsulting.CanvasNet.Tests/Fonts/BundledLiberationFontsTests.cs new file mode 100644 index 00000000..c7fc5a10 --- /dev/null +++ b/test/DemaConsulting.CanvasNet.Tests/Fonts/BundledLiberationFontsTests.cs @@ -0,0 +1,61 @@ +// cspell:ignore Liberation Dogfoods +using DemaConsulting.CanvasNet.Fonts; + +namespace DemaConsulting.CanvasNet.Tests.Fonts; + +/// +/// Dogfoods all 12 bundled Liberation Sans/Serif/Mono embedded-resource fonts (see +/// Fonts\BundledFonts\README.md for sourcing/licensing provenance) through +/// , proving every one is a genuine, well-formed, loadable TrueType +/// font exposing the expected family name and style metadata - both a one-time sanity check +/// performed before committing these files, and a permanent, repeatable CI gate against any +/// future accidental corruption or replacement of the bundled assets. +/// +public class BundledLiberationFontsTests +{ + /// The 12 Liberation Sans/Serif/Mono bundled variants and their expected metadata. + public static TheoryData LiberationVariants => new() + { + { "LiberationSans-Regular.ttf", "Liberation Sans", false, false, false }, + { "LiberationSans-Bold.ttf", "Liberation Sans", true, false, false }, + { "LiberationSans-Italic.ttf", "Liberation Sans", false, true, false }, + { "LiberationSans-BoldItalic.ttf", "Liberation Sans", true, true, false }, + { "LiberationSerif-Regular.ttf", "Liberation Serif", false, false, false }, + { "LiberationSerif-Bold.ttf", "Liberation Serif", true, false, false }, + { "LiberationSerif-Italic.ttf", "Liberation Serif", false, true, false }, + { "LiberationSerif-BoldItalic.ttf", "Liberation Serif", true, true, false }, + { "LiberationMono-Regular.ttf", "Liberation Mono", false, false, true }, + { "LiberationMono-Bold.ttf", "Liberation Mono", true, false, true }, + { "LiberationMono-Italic.ttf", "Liberation Mono", false, true, true }, + { "LiberationMono-BoldItalic.ttf", "Liberation Mono", true, true, true }, + }; + + /// + /// Proves that every one of the 12 bundled Liberation embedded-resource font files loads + /// successfully via (through + /// , the same embedded-resource + /// loading path itself uses) and + /// exposes the expected family name and bold/italic/fixed-pitch style metadata. + /// + [Theory] + [MemberData(nameof(LiberationVariants))] + public void BundledFonts_AllTwelveLiberationVariants_LoadSuccessfullyAndExposeExpectedNameAndStyle( + string bundledFileName, + string expectedFamilyPrefix, + bool expectedBold, + bool expectedItalic, + bool expectedFixedPitch) + { + // Act: load the bundled font through the same embedded-resource path production code uses + var font = SystemFontCatalog.LoadBundledFallbackCore(bundledFileName); + var info = font.GetNameInfo(); + + // Assert: genuine, well-formed font data with the expected family/style metadata + Assert.NotNull(info.FamilyName); + Assert.StartsWith(expectedFamilyPrefix, info.FamilyName, StringComparison.Ordinal); + Assert.Equal(expectedBold, font.IsBold); + Assert.Equal(expectedItalic, font.IsItalic); + Assert.Equal(expectedFixedPitch, font.IsFixedPitch); + Assert.True(font.GlyphCount > 0); + } +} diff --git a/test/DemaConsulting.CanvasNet.Tests/Fonts/BundledNotoFontsTests.cs b/test/DemaConsulting.CanvasNet.Tests/Fonts/BundledNotoFontsTests.cs new file mode 100644 index 00000000..c76ed521 --- /dev/null +++ b/test/DemaConsulting.CanvasNet.Tests/Fonts/BundledNotoFontsTests.cs @@ -0,0 +1,49 @@ +// cspell:ignore Noto Dogfoods ZapfDingbats +using DemaConsulting.CanvasNet.Fonts; + +namespace DemaConsulting.CanvasNet.Tests.Fonts; + +/// +/// Dogfoods all 3 bundled Noto embedded-resource substitute fonts (see +/// Fonts\BundledFonts\README.md's "Noto Substitute Fonts" section for +/// sourcing/licensing provenance) through , proving every one is a +/// genuine, well-formed, loadable TrueType font exposing a sane family name - both a +/// one-time sanity check performed before committing these files, and a permanent, +/// repeatable CI gate against any future accidental corruption or replacement of the bundled +/// assets. These 3 fonts are used by DemaConsulting.CanvasNet.Pdf as the +/// Symbol/ZapfDingbats font-fallback substitutes - see +/// PdfDocument.FontFallback.cs's ResolveSymbolicNotoFallback. +/// +public class BundledNotoFontsTests +{ + /// The 3 bundled Noto substitute font files and their expected family-name prefixes. + public static TheoryData NotoVariants => new() + { + { "NotoSans-Regular.ttf", "Noto Sans" }, + { "NotoSansMath-Regular.ttf", "Noto Sans Math" }, + { "NotoSansSymbols2-Regular.ttf", "Noto Sans Symbols" }, + }; + + /// + /// Proves that every one of the 3 bundled Noto embedded-resource font files loads + /// successfully via (through + /// , the same embedded-resource + /// loading path PdfDocument.FontFallback.cs's ResolveSymbolicNotoFallback + /// itself uses) and exposes a sane, expected family name. + /// + [Theory] + [MemberData(nameof(NotoVariants))] + public void BundledFonts_AllThreeNotoVariants_LoadSuccessfullyAndExposeExpectedName( + string bundledFileName, + string expectedFamilyPrefix) + { + // Act: load the bundled font through the same embedded-resource path production code uses + var font = SystemFontCatalog.LoadBundledFallbackCore(bundledFileName); + var info = font.GetNameInfo(); + + // Assert: genuine, well-formed font data with a sane, expected family name + Assert.NotNull(info.FamilyName); + Assert.StartsWith(expectedFamilyPrefix, info.FamilyName, StringComparison.Ordinal); + Assert.True(font.GlyphCount > 0); + } +} diff --git a/test/DemaConsulting.CanvasNet.Tests/Fonts/CffCharstringInterpreterTests.cs b/test/DemaConsulting.CanvasNet.Tests/Fonts/CffCharstringInterpreterTests.cs new file mode 100644 index 00000000..643422f1 --- /dev/null +++ b/test/DemaConsulting.CanvasNet.Tests/Fonts/CffCharstringInterpreterTests.cs @@ -0,0 +1,1489 @@ +// 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 CFF charstring charstrings subr subrs +// cspell:ignore hintmask cntrmask rmoveto hmoveto vmoveto rlineto hlineto vlineto +// cspell:ignore rrcurveto hhcurveto vvcurveto hvcurveto vhcurveto callsubr callgsubr +// cspell:ignore endchar seac hstem vstem hstemhm vstemhm rcurveline rlinecurve bchar achar adx ady +// cspell:ignore hflex flex1 hflex1 +using System.Numerics; +using DemaConsulting.CanvasNet.Fonts; +using DemaConsulting.CanvasNet.Geometry; +using DemaConsulting.CanvasNet.Tests.TestSupport; +using Path = DemaConsulting.CanvasNet.Geometry.Path; + +namespace DemaConsulting.CanvasNet.Tests.Fonts; + +/// +/// Unit tests for . +/// +public class CffCharstringInterpreterTests +{ + /// + /// Decodes a single charstring built from a sequence of pre-encoded byte chunks (each + /// chunk written via / + /// by the caller) with no + /// global/local subroutines. + /// + private static Path Decode(byte[] charstring, IReadOnlyList<(int Offset, int Length)>? globalSubrs = null, IReadOnlyList<(int Offset, int Length)>? localSubrs = null) => + CffCharstringInterpreter.Decode(charstring, (0, charstring.Length), globalSubrs ?? [], localSubrs ?? []).Outline; + + private static byte[] Build(Action> write) + { + var buf = new List(); + write(buf); + return [.. buf]; + } + + private static void Number(List buf, double value) => SyntheticFontBuilder.WriteCharstringNumber(buf, value); + + private static void Op(List buf, int op) => SyntheticFontBuilder.WriteCharstringOperator(buf, op); + + /// + /// Proves that CffCharstringInterpreter EmptyCharstring ProducesEmptyPath. + /// + [Fact] + public void CffCharstringInterpreter_EmptyCharstring_ProducesEmptyPath() + { + var path = Decode([]); + Assert.Empty(path.Subpaths); + } + + /// + /// Proves that CffCharstringInterpreter RMoveTo LineTo Endchar ProducesClosedContour. + /// + [Fact] + public void CffCharstringInterpreter_RMoveTo_LineTo_Endchar_ProducesClosedContour() + { + var cs = Build(buf => + { + Number(buf, 10); + Number(buf, 20); + Op(buf, 21); // rmoveto + Number(buf, 100); + Number(buf, 0); + Op(buf, 5); // rlineto + Op(buf, 14); // endchar + }); + + var path = Decode(cs); + var subpath = Assert.Single(path.Subpaths); + Assert.Equal(new Vector2(10, 20), subpath.Start); + Assert.Contains(subpath.Commands, c => c.Type == PathCommandType.LineTo); + Assert.Contains(subpath.Commands, c => c.Type == PathCommandType.Close); + } + + /// + /// Proves that CffCharstringInterpreter RMoveTo WithLeadingWidth IgnoresWidthOperand. + /// + [Fact] + public void CffCharstringInterpreter_RMoveTo_WithLeadingWidth_IgnoresWidthOperand() + { + var cs = Build(buf => + { + Number(buf, 500); // width + Number(buf, 10); + Number(buf, 20); + Op(buf, 21); // rmoveto + Op(buf, 14); // endchar + }); + + var path = Decode(cs); + var subpath = Assert.Single(path.Subpaths); + Assert.Equal(new Vector2(10, 20), subpath.Start); + } + + /// + /// Proves that CffCharstringInterpreter Decode RMoveToWithLeadingWidth + /// ReturnsNominalWidthXPlusDelta. + /// + [Fact] + public void CffCharstringInterpreter_Decode_RMoveToWithLeadingWidth_ReturnsNominalWidthXPlusDelta() + { + var cs = Build(buf => + { + Number(buf, 25); // width delta + Number(buf, 10); + Number(buf, 20); + Op(buf, 21); // rmoveto + Op(buf, 14); // endchar + }); + + var (_, width) = CffCharstringInterpreter.Decode(cs, (0, cs.Length), [], [], defaultWidthX: 500, nominalWidthX: 100); + + Assert.Equal(125, width); + } + + /// + /// Proves that CffCharstringInterpreter Decode NoLeadingWidth ReturnsDefaultWidthX. + /// + [Fact] + public void CffCharstringInterpreter_Decode_NoLeadingWidth_ReturnsDefaultWidthX() + { + var cs = Build(buf => + { + Number(buf, 10); + Number(buf, 20); + Op(buf, 21); // rmoveto + Op(buf, 14); // endchar + }); + + var (_, width) = CffCharstringInterpreter.Decode(cs, (0, cs.Length), [], [], defaultWidthX: 500, nominalWidthX: 100); + + Assert.Equal(500, width); + } + + /// + /// Proves that CffCharstringInterpreter Decode HStemWithLeadingWidth + /// ReturnsNominalWidthXPlusDelta. + /// + [Fact] + public void CffCharstringInterpreter_Decode_HStemWithLeadingWidth_ReturnsNominalWidthXPlusDelta() + { + var cs = Build(buf => + { + Number(buf, 25); // width delta (odd operand count before the stem pairs) + Number(buf, 0); + Number(buf, 10); + Op(buf, 1); // hstem + Number(buf, 10); + Number(buf, 20); + Op(buf, 21); // rmoveto + Op(buf, 14); // endchar + }); + + var (_, width) = CffCharstringInterpreter.Decode(cs, (0, cs.Length), [], [], defaultWidthX: 500, nominalWidthX: 100); + + Assert.Equal(125, width); + } + + /// + /// Proves that CffCharstringInterpreter Decode EndCharWithLeadingWidth + /// ReturnsNominalWidthXPlusDelta. + /// + [Fact] + public void CffCharstringInterpreter_Decode_EndCharWithLeadingWidth_ReturnsNominalWidthXPlusDelta() + { + var cs = Build(buf => + { + Number(buf, 25); // width delta + Op(buf, 14); // endchar + }); + + var (_, width) = CffCharstringInterpreter.Decode(cs, (0, cs.Length), [], [], defaultWidthX: 500, nominalWidthX: 100); + + Assert.Equal(125, width); + } + + /// + /// Proves that CffCharstringInterpreter Decode DefaultWidthParameters AreBothZero. + /// + [Fact] + public void CffCharstringInterpreter_Decode_DefaultWidthParameters_AreBothZero() + { + var cs = Build(buf => + { + Number(buf, 10); + Number(buf, 20); + Op(buf, 21); // rmoveto + Op(buf, 14); // endchar + }); + + var (_, width) = CffCharstringInterpreter.Decode(cs, (0, cs.Length), [], []); + + Assert.Equal(0, width); + } + + /// + /// Proves that CffCharstringInterpreter HMoveTo VMoveTo MoveAlongSingleAxis. + /// + [Fact] + public void CffCharstringInterpreter_HMoveTo_VMoveTo_MoveAlongSingleAxis() + { + var hCs = Build(buf => + { + Number(buf, 30); + Op(buf, 22); // hmoveto + Op(buf, 14); + }); + var hPath = Decode(hCs); + Assert.Equal(new Vector2(30, 0), Assert.Single(hPath.Subpaths).Start); + + var vCs = Build(buf => + { + Number(buf, 40); + Op(buf, 4); // vmoveto + Op(buf, 14); + }); + var vPath = Decode(vCs); + Assert.Equal(new Vector2(0, 40), Assert.Single(vPath.Subpaths).Start); + } + + /// + /// Proves that CffCharstringInterpreter HLineTo VLineTo AlternateAxes. + /// + [Fact] + public void CffCharstringInterpreter_HLineTo_VLineTo_AlternateAxes() + { + var cs = Build(buf => + { + Number(buf, 0); + Number(buf, 0); + Op(buf, 21); // rmoveto + Number(buf, 10); + Number(buf, 20); + Number(buf, 30); + Op(buf, 6); // hlineto: dx1 dy2 dx3 -> alternating h,v,h + Op(buf, 14); + }); + + var path = Decode(cs); + var subpath = Assert.Single(path.Subpaths); + var lineTos = subpath.Commands.Where(c => c.Type == PathCommandType.LineTo).ToArray(); + Assert.Equal(3, lineTos.Length); + Assert.Equal(new Vector2(10, 0), lineTos[0].EndPoint); + Assert.Equal(new Vector2(10, 20), lineTos[1].EndPoint); + Assert.Equal(new Vector2(40, 20), lineTos[2].EndPoint); + } + + /// + /// Proves that CffCharstringInterpreter RLineTo OddOperandCount ThrowsInvalidDataException. + /// + [Fact] + public void CffCharstringInterpreter_RLineTo_OddOperandCount_ThrowsInvalidDataException() + { + var cs = Build(buf => + { + Number(buf, 0); + Number(buf, 0); + Op(buf, 21); // rmoveto + Number(buf, 5); + Op(buf, 5); // rlineto - odd operand count + }); + + Assert.Throws(() => Decode(cs)); + } + + /// + /// Proves that CffCharstringInterpreter RRCurveTo ProducesCubicBezier. + /// + [Fact] + public void CffCharstringInterpreter_RRCurveTo_ProducesCubicBezier() + { + var cs = Build(buf => + { + Number(buf, 0); + Number(buf, 0); + Op(buf, 21); // rmoveto + Number(buf, 10); + Number(buf, 0); + Number(buf, 10); + Number(buf, 10); + Number(buf, 0); + Number(buf, 10); + Op(buf, 8); // rrcurveto + Op(buf, 14); + }); + + var path = Decode(cs); + var subpath = Assert.Single(path.Subpaths); + var curve = Assert.Single(subpath.Commands, c => c.Type == PathCommandType.CubicBezierTo); + Assert.Equal(new Vector2(20, 20), curve.EndPoint); + } + + /// + /// Proves that CffCharstringInterpreter HHCurveTo VVCurveTo ProduceCubicBeziers. + /// + [Fact] + public void CffCharstringInterpreter_HHCurveTo_VVCurveTo_ProduceCubicBeziers() + { + var hhCs = Build(buf => + { + Number(buf, 0); + Number(buf, 0); + Op(buf, 21); + Number(buf, 10); + Number(buf, 10); + Number(buf, 10); + Number(buf, 10); + Op(buf, 27); // hhcurveto + Op(buf, 14); + }); + var hhPath = Decode(hhCs); + Assert.Contains(Assert.Single(hhPath.Subpaths).Commands, c => c.Type == PathCommandType.CubicBezierTo); + + var vvCs = Build(buf => + { + Number(buf, 0); + Number(buf, 0); + Op(buf, 21); + Number(buf, 10); + Number(buf, 10); + Number(buf, 10); + Number(buf, 10); + Op(buf, 26); // vvcurveto + Op(buf, 14); + }); + var vvPath = Decode(vvCs); + Assert.Contains(Assert.Single(vvPath.Subpaths).Commands, c => c.Type == PathCommandType.CubicBezierTo); + } + + /// + /// Proves that CffCharstringInterpreter HHCurveTo LeadingOperand AppliesToFirstCurveOnly. + /// + [Fact] + public void CffCharstringInterpreter_HHCurveTo_LeadingOperand_AppliesToFirstCurveOnly() + { + var cs = Build(buf => + { + Number(buf, 0); + Number(buf, 0); + Op(buf, 21); + Number(buf, 5); // leading dy1 + Number(buf, 10); + Number(buf, 10); + Number(buf, 10); + Number(buf, 10); + Op(buf, 27); // hhcurveto: 4n+1 operands + Op(buf, 14); + }); + + var path = Decode(cs); + var curve = Assert.Single(Assert.Single(path.Subpaths).Commands, c => c.Type == PathCommandType.CubicBezierTo); + Assert.Equal(5, curve.Control1.Y); + } + + /// + /// Proves that CffCharstringInterpreter HVCurveTo VHCurveTo AlternateStartTangent. + /// + [Fact] + public void CffCharstringInterpreter_HVCurveTo_VHCurveTo_AlternateStartTangent() + { + var hvCs = Build(buf => + { + Number(buf, 0); + Number(buf, 0); + Op(buf, 21); + Number(buf, 10); + Number(buf, 10); + Number(buf, 10); + Number(buf, 10); + Op(buf, 31); // hvcurveto + Op(buf, 14); + }); + var hvPath = Decode(hvCs); + Assert.Contains(Assert.Single(hvPath.Subpaths).Commands, c => c.Type == PathCommandType.CubicBezierTo); + + var vhCs = Build(buf => + { + Number(buf, 0); + Number(buf, 0); + Op(buf, 21); + Number(buf, 10); + Number(buf, 10); + Number(buf, 10); + Number(buf, 10); + Op(buf, 30); // vhcurveto + Op(buf, 14); + }); + var vhPath = Decode(vhCs); + Assert.Contains(Assert.Single(vhPath.Subpaths).Commands, c => c.Type == PathCommandType.CubicBezierTo); + } + + /// + /// Proves that CffCharstringInterpreter HVCurveTo TrailingOperand SuppliesFinalAxisDelta. + /// + [Fact] + public void CffCharstringInterpreter_HVCurveTo_TrailingOperand_SuppliesFinalAxisDelta() + { + var cs = Build(buf => + { + Number(buf, 0); + Number(buf, 0); + Op(buf, 21); + Number(buf, 10); + Number(buf, 10); + Number(buf, 10); + Number(buf, 10); + Number(buf, 5); // trailing operand supplies final-axis delta + Op(buf, 31); // hvcurveto + Op(buf, 14); + }); + + var path = Decode(cs); + var curve = Assert.Single(Assert.Single(path.Subpaths).Commands, c => c.Type == PathCommandType.CubicBezierTo); + Assert.Equal(25, curve.EndPoint.X); + } + + /// + /// Proves that CffCharstringInterpreter HStem VStem AccumulateStemCountForHintMask. + /// + [Fact] + public void CffCharstringInterpreter_HStem_VStem_AccumulateStemCountForHintMask() + { + var cs = Build(buf => + { + Number(buf, 0); + Number(buf, 10); + Op(buf, 1); // hstem: one stem hint + Number(buf, 0); + Number(buf, 10); + Op(buf, 3); // vstem: one more stem hint (2 total -> mask is 1 byte) + Op(buf, 19); // hintmask + buf.Add(0xFF); // 1 mask byte for 2 stems + Number(buf, 0); + Number(buf, 0); + Op(buf, 21); // rmoveto + Op(buf, 14); + }); + + var path = Decode(cs); + Assert.Equal(new Vector2(0, 0), Assert.Single(path.Subpaths).Start); + } + + /// + /// Proves that CffCharstringInterpreter HintMask ImplicitVStem CountsTowardMaskBytes. + /// + [Fact] + public void CffCharstringInterpreter_HintMask_ImplicitVStem_CountsTowardMaskBytes() + { + // 9 leftover operands on the stack (before hintmask) implicitly declare a vstem group + // of ceil(9/2)... actually stem operands come in pairs; here we use 16 leftover operands + // (8 stems) requiring exactly 1 mask byte. + var cs = Build(buf => + { + for (var i = 0; i < 16; i++) + { + Number(buf, i == 0 ? 0 : 1); + } + + Op(buf, 19); // hintmask - operands still on stack become an implicit vstem (8 stems) + buf.Add(0xFF); // 1 mask byte + Number(buf, 0); + Number(buf, 0); + Op(buf, 21); + Op(buf, 14); + }); + + var path = Decode(cs); + Assert.Equal(new Vector2(0, 0), Assert.Single(path.Subpaths).Start); + } + + /// + /// Proves that CffCharstringInterpreter HintMask TruncatedMaskBytes ThrowsInvalidDataException. + /// + [Fact] + public void CffCharstringInterpreter_HintMask_TruncatedMaskBytes_ThrowsInvalidDataException() + { + var cs = Build(buf => + { + Number(buf, 0); + Number(buf, 10); + Op(buf, 1); // hstem + Op(buf, 19); // hintmask - requires 1 mask byte, but charstring ends here + }); + + Assert.Throws(() => Decode(cs)); + } + + /// + /// Proves that CffCharstringInterpreter CallSubr AppliesBias AndReturns. + /// + [Fact] + public void CffCharstringInterpreter_CallSubr_AppliesBias_AndReturns() + { + // A single local subroutine (count=1 -> bias=107): index -107 selects subr[0]. + var subr = Build(buf => + { + Number(buf, 10); + Number(buf, 20); + Op(buf, 21); // rmoveto + Op(buf, 11); // return + }); + + var localSubrs = new List<(int Offset, int Length)>(); + var combined = new List(); + localSubrs.Add((0, subr.Length)); + combined.AddRange(subr); + + var main = Build(buf => + { + Number(buf, -107); // subr index before bias + Op(buf, 10); // callsubr + Op(buf, 14); + }); + combined.AddRange(main); + + var path = CffCharstringInterpreter.Decode( + [.. combined], (subr.Length, main.Length), [], localSubrs).Outline; + + Assert.Equal(new Vector2(10, 20), Assert.Single(path.Subpaths).Start); + } + + /// + /// Proves that CffCharstringInterpreter CallGSubr AppliesGlobalBias. + /// + [Fact] + public void CffCharstringInterpreter_CallGSubr_AppliesGlobalBias() + { + var subr = Build(buf => + { + Number(buf, 5); + Number(buf, 5); + Op(buf, 21); // rmoveto + Op(buf, 11); // return + }); + + var globalSubrs = new List<(int Offset, int Length)> { (0, subr.Length) }; + var combined = new List(); + combined.AddRange(subr); + + var main = Build(buf => + { + Number(buf, -107); // subr index before bias (count=1 -> bias=107) + Op(buf, 29); // callgsubr + Op(buf, 14); + }); + combined.AddRange(main); + + var path = CffCharstringInterpreter.Decode( + [.. combined], (subr.Length, main.Length), globalSubrs, []).Outline; + + Assert.Equal(new Vector2(5, 5), Assert.Single(path.Subpaths).Start); + } + + /// + /// Proves that CffCharstringInterpreter CallSubr OutOfRangeIndex ThrowsInvalidDataException. + /// + [Fact] + public void CffCharstringInterpreter_CallSubr_OutOfRangeIndex_ThrowsInvalidDataException() + { + var cs = Build(buf => + { + Number(buf, 1000); + Op(buf, 10); // callsubr - no subroutines exist + Op(buf, 14); + }); + + Assert.Throws(() => Decode(cs)); + } + + /// + /// Proves that CffCharstringInterpreter CallSubr ExceedsMaxDepth ThrowsInvalidDataException. + /// + [Fact] + public void CffCharstringInterpreter_CallSubr_ExceedsMaxDepth_ThrowsInvalidDataException() + { + // A single subroutine that immediately calls itself (index -107 with bias 107 -> subr 0) + // recurses without ever returning, so it must be rejected once the call-depth bound is + // exceeded rather than looping/overflowing the stack forever. + var subr = Build(buf => + { + Number(buf, -107); + Op(buf, 10); // callsubr (self-recursive) + }); + + var localSubrs = new List<(int Offset, int Length)> { (0, subr.Length) }; + + var main = Build(buf => + { + Number(buf, -107); + Op(buf, 10); // callsubr + Op(buf, 14); + }); + + var combined = new List(); + combined.AddRange(subr); + combined.AddRange(main); + + Assert.Throws(() => + CffCharstringInterpreter.Decode([.. combined], (subr.Length, main.Length), [], localSubrs)); + } + + /// + /// Proves that CffCharstringInterpreter EndChar SeacStyleFourOperands NoResolverSupplied + /// ThrowsInvalidDataException. + /// + [Fact] + public void CffCharstringInterpreter_EndChar_SeacStyleFourOperands_NoResolverSupplied_ThrowsInvalidDataException() + { + var cs = Build(buf => + { + Number(buf, 0); + Number(buf, 0); + Number(buf, 1); + Number(buf, 2); + Op(buf, 14); // endchar with 4 leftover operands - deprecated seac form + }); + + Assert.Throws(() => Decode(cs)); + } + + /// + /// Proves that CffCharstringInterpreter EndChar SeacStyleFourOperands WithResolver + /// ComposesBaseAndTranslatedAccent. + /// + [Fact] + public void CffCharstringInterpreter_EndChar_SeacStyleFourOperands_WithResolver_ComposesBaseAndTranslatedAccent() + { + var basePath = new PathBuilder() + .MoveTo(new Vector2(0, 0)) + .LineTo(new Vector2(10, 0)) + .LineTo(new Vector2(10, 10)) + .Close() + .Build(); + var accentPath = new PathBuilder() + .MoveTo(new Vector2(0, 0)) + .LineTo(new Vector2(5, 0)) + .LineTo(new Vector2(5, 5)) + .Close() + .Build(); + + Path Resolver(int code) => code == 65 ? basePath : accentPath; + + var cs = Build(buf => + { + Number(buf, 20); // adx + Number(buf, 30); // ady + Number(buf, 65); // bchar + Number(buf, 194); // achar + Op(buf, 14); // endchar with 4 leftover operands - seac-style composition + }); + + var result = CffCharstringInterpreter.Decode(cs, (0, cs.Length), [], [], resolveStandardEncodedGlyph: Resolver).Outline; + + var expectedAccent = accentPath.Transform(Matrix3x2.CreateTranslation(20, 30)); + Assert.Equal(basePath.Subpaths.Count + expectedAccent.Subpaths.Count, result.Subpaths.Count); + Assert.Equal(basePath.Subpaths[0].Start, result.Subpaths[0].Start); + Assert.Equal(expectedAccent.Subpaths[0].Start, result.Subpaths[1].Start); + } + + /// + /// Proves that CffCharstringInterpreter EndChar SeacStyleFourOperands + /// ResolverOmittedOnComponentDecode ThrowsInvalidDataException. + /// + [Fact] + public void CffCharstringInterpreter_EndChar_SeacStyleFourOperands_ResolverOmittedOnComponentDecode_ThrowsInvalidDataException() + { + // The component "resolver" itself decodes a nested 4-operand-endchar charstring with no + // resolver of its own - proving the "resolver omitted on component decode" guard, rather + // than only exercising the "no resolver at all" case. + var nestedSeac = Build(buf => + { + Number(buf, 0); + Number(buf, 0); + Number(buf, 1); + Number(buf, 2); + Op(buf, 14); // endchar with 4 leftover operands - deprecated seac form + }); + + Path Resolver(int _) => Decode(nestedSeac); + + var cs = Build(buf => + { + Number(buf, 20); + Number(buf, 30); + Number(buf, 65); + Number(buf, 194); + Op(buf, 14); // endchar with 4 leftover operands - seac-style composition + }); + + Assert.Throws(() => + CffCharstringInterpreter.Decode(cs, (0, cs.Length), [], [], resolveStandardEncodedGlyph: Resolver)); + } + + /// + /// Proves that CffCharstringInterpreter EndChar UnexpectedOperandCount ThrowsInvalidDataException. + /// + [Fact] + public void CffCharstringInterpreter_EndChar_UnexpectedOperandCount_ThrowsInvalidDataException() + { + var cs = Build(buf => + { + Number(buf, 1); + Number(buf, 2); + Number(buf, 3); + Op(buf, 14); // endchar with 3 leftover operands - not width(1), not seac(4), not 0 + }); + + Assert.Throws(() => Decode(cs)); + } + + /// + /// Proves that CffCharstringInterpreter UnsupportedOperator ThrowsInvalidDataException. + /// + [Fact] + public void CffCharstringInterpreter_UnsupportedOperator_ThrowsInvalidDataException() + { + var cs = Build(buf => + { + Number(buf, 0); + Op(buf, 9); // closepath - not in the supported operator set (Type 1 heritage) + }); + + Assert.Throws(() => Decode(cs)); + } + + /// + /// Proves that CffCharstringInterpreter UnsupportedEscapeOperator ThrowsInvalidDataException. + /// + [Fact] + public void CffCharstringInterpreter_UnsupportedEscapeOperator_ThrowsInvalidDataException() + { + var cs = Build(buf => + { + Op(buf, 1203); // escape operator 12 3 - genuinely unsupported two-byte escape + }); + + Assert.Throws(() => Decode(cs)); + } + + /// + /// Proves that CffCharstringInterpreter FixedPointOperand DecodesCorrectly. + /// + [Fact] + public void CffCharstringInterpreter_FixedPointOperand_DecodesCorrectly() + { + var cs = Build(buf => + { + SyntheticFontBuilder.WriteCharstringFixed(buf, 12.5); + SyntheticFontBuilder.WriteCharstringFixed(buf, 7.25); + Op(buf, 21); // rmoveto + Op(buf, 14); + }); + + var path = Decode(cs); + Assert.Equal(new Vector2(12.5f, 7.25f), Assert.Single(path.Subpaths).Start); + } + + /// + /// Proves that CffCharstringInterpreter TruncatedCharstring ThrowsInvalidDataException. + /// + [Fact] + public void CffCharstringInterpreter_TruncatedCharstring_ThrowsInvalidDataException() + { + byte[] cs = [28, 0]; // 3-byte int16 operand (0x1C), but only 1 byte follows + Assert.Throws(() => Decode(cs)); + } + + /// + /// Proves that CffCharstringInterpreter RMoveTo WrongOperandCount ThrowsInvalidDataException. + /// + [Fact] + public void CffCharstringInterpreter_RMoveTo_WrongOperandCount_ThrowsInvalidDataException() + { + var cs = Build(buf => + { + Number(buf, 1); + Number(buf, 2); + Number(buf, 3); + Number(buf, 4); + Op(buf, 21); // rmoveto expects exactly 2 (or 3 with width) + }); + + Assert.Throws(() => Decode(cs)); + } + + /// + /// Proves that CffCharstringInterpreter RCurveLine OneCurveThenLine ProducesCurveAndLine. + /// + [Fact] + public void CffCharstringInterpreter_RCurveLine_OneCurveThenLine_ProducesCurveAndLine() + { + // Curve (10,0,10,10,0,10) from (0,0): cp1=(10,0), cp2=(20,10), end=(20,20). + // Trailing line (5,5) from (20,20): final (25,25). + var cs = Build(buf => + { + Number(buf, 0); + Number(buf, 0); + Op(buf, 21); // rmoveto + Number(buf, 10); + Number(buf, 0); + Number(buf, 10); + Number(buf, 10); + Number(buf, 0); + Number(buf, 10); + Number(buf, 5); + Number(buf, 5); + Op(buf, 24); // rcurveline + Op(buf, 14); + }); + + var path = Decode(cs); + var subpath = Assert.Single(path.Subpaths); + var curve = Assert.Single(subpath.Commands, c => c.Type == PathCommandType.CubicBezierTo); + Assert.Equal(new Vector2(20, 20), curve.EndPoint); + var line = Assert.Single(subpath.Commands, c => c.Type == PathCommandType.LineTo); + Assert.Equal(new Vector2(25, 25), line.EndPoint); + } + + /// + /// Proves that CffCharstringInterpreter RCurveLine TwoCurvesThenLine ProducesCurvesAndLine. + /// + [Fact] + public void CffCharstringInterpreter_RCurveLine_TwoCurvesThenLine_ProducesCurvesAndLine() + { + // First curve (10,0,10,10,0,10) from (0,0): end=(20,20). + // Second curve, same deltas, from (20,20): cp1=(30,20), cp2=(40,30), end=(40,40). + // Trailing line (5,5) from (40,40): final (45,45). + var cs = Build(buf => + { + Number(buf, 0); + Number(buf, 0); + Op(buf, 21); // rmoveto + Number(buf, 10); + Number(buf, 0); + Number(buf, 10); + Number(buf, 10); + Number(buf, 0); + Number(buf, 10); + Number(buf, 10); + Number(buf, 0); + Number(buf, 10); + Number(buf, 10); + Number(buf, 0); + Number(buf, 10); + Number(buf, 5); + Number(buf, 5); + Op(buf, 24); // rcurveline + Op(buf, 14); + }); + + var path = Decode(cs); + var subpath = Assert.Single(path.Subpaths); + var curves = subpath.Commands.Where(c => c.Type == PathCommandType.CubicBezierTo).ToArray(); + Assert.Equal(2, curves.Length); + Assert.Equal(new Vector2(20, 20), curves[0].EndPoint); + Assert.Equal(new Vector2(40, 40), curves[1].EndPoint); + var line = Assert.Single(subpath.Commands, c => c.Type == PathCommandType.LineTo); + Assert.Equal(new Vector2(45, 45), line.EndPoint); + } + + /// + /// Proves that CffCharstringInterpreter RCurveLine WrongOperandCount OneShort + /// ThrowsInvalidDataException. + /// + [Fact] + public void CffCharstringInterpreter_RCurveLine_WrongOperandCount_OneShort_ThrowsInvalidDataException() + { + var cs = Build(buf => + { + Number(buf, 0); + Number(buf, 0); + Op(buf, 21); // rmoveto + for (var i = 0; i < 7; i++) // 7 operands: one short of the minimum valid 8 + { + Number(buf, 1); + } + + Op(buf, 24); // rcurveline + Op(buf, 14); + }); + + Assert.Throws(() => Decode(cs)); + } + + /// + /// Proves that CffCharstringInterpreter RCurveLine WrongOperandCount OneOver + /// ThrowsInvalidDataException. + /// + [Fact] + public void CffCharstringInterpreter_RCurveLine_WrongOperandCount_OneOver_ThrowsInvalidDataException() + { + var cs = Build(buf => + { + Number(buf, 0); + Number(buf, 0); + Op(buf, 21); // rmoveto + for (var i = 0; i < 9; i++) // 9 operands: not of the form 6n+2 + { + Number(buf, 1); + } + + Op(buf, 24); // rcurveline + Op(buf, 14); + }); + + Assert.Throws(() => Decode(cs)); + } + + /// + /// Proves that CffCharstringInterpreter RLineCurve OneLineThenCurve ProducesLineAndCurve. + /// + [Fact] + public void CffCharstringInterpreter_RLineCurve_OneLineThenCurve_ProducesLineAndCurve() + { + // Line (10,0) from (0,0): (10,0). + // Curve (10,0,10,10,0,10) from (10,0): cp1=(20,0), cp2=(30,10), end=(30,20). + var cs = Build(buf => + { + Number(buf, 0); + Number(buf, 0); + Op(buf, 21); // rmoveto + Number(buf, 10); + Number(buf, 0); + Number(buf, 10); + Number(buf, 0); + Number(buf, 10); + Number(buf, 10); + Number(buf, 0); + Number(buf, 10); + Op(buf, 25); // rlinecurve + Op(buf, 14); + }); + + var path = Decode(cs); + var subpath = Assert.Single(path.Subpaths); + var line = Assert.Single(subpath.Commands, c => c.Type == PathCommandType.LineTo); + Assert.Equal(new Vector2(10, 0), line.EndPoint); + var curve = Assert.Single(subpath.Commands, c => c.Type == PathCommandType.CubicBezierTo); + Assert.Equal(new Vector2(30, 20), curve.EndPoint); + } + + /// + /// Proves that CffCharstringInterpreter RLineCurve TwoLinesThenCurve ProducesLinesAndCurve. + /// + [Fact] + public void CffCharstringInterpreter_RLineCurve_TwoLinesThenCurve_ProducesLinesAndCurve() + { + // Line (10,0) from (0,0): (10,0). Line (5,5) from (10,0): (15,5). + // Curve (10,0,10,10,0,10) from (15,5): cp1=(25,5), cp2=(35,15), end=(35,25). + var cs = Build(buf => + { + Number(buf, 0); + Number(buf, 0); + Op(buf, 21); // rmoveto + Number(buf, 10); + Number(buf, 0); + Number(buf, 5); + Number(buf, 5); + Number(buf, 10); + Number(buf, 0); + Number(buf, 10); + Number(buf, 10); + Number(buf, 0); + Number(buf, 10); + Op(buf, 25); // rlinecurve + Op(buf, 14); + }); + + var path = Decode(cs); + var subpath = Assert.Single(path.Subpaths); + var lines = subpath.Commands.Where(c => c.Type == PathCommandType.LineTo).ToArray(); + Assert.Equal(2, lines.Length); + Assert.Equal(new Vector2(10, 0), lines[0].EndPoint); + Assert.Equal(new Vector2(15, 5), lines[1].EndPoint); + var curve = Assert.Single(subpath.Commands, c => c.Type == PathCommandType.CubicBezierTo); + Assert.Equal(new Vector2(35, 25), curve.EndPoint); + } + + /// + /// Proves that CffCharstringInterpreter RLineCurve WrongOperandCount OneShort + /// ThrowsInvalidDataException. + /// + [Fact] + public void CffCharstringInterpreter_RLineCurve_WrongOperandCount_OneShort_ThrowsInvalidDataException() + { + var cs = Build(buf => + { + Number(buf, 0); + Number(buf, 0); + Op(buf, 21); // rmoveto + for (var i = 0; i < 7; i++) // 7 operands: one short of the minimum valid 8 + { + Number(buf, 1); + } + + Op(buf, 25); // rlinecurve + Op(buf, 14); + }); + + Assert.Throws(() => Decode(cs)); + } + + /// + /// Proves that CffCharstringInterpreter RLineCurve WrongOperandCount OneOver + /// ThrowsInvalidDataException. + /// + [Fact] + public void CffCharstringInterpreter_RLineCurve_WrongOperandCount_OneOver_ThrowsInvalidDataException() + { + var cs = Build(buf => + { + Number(buf, 0); + Number(buf, 0); + Op(buf, 21); // rmoveto + for (var i = 0; i < 9; i++) // 9 operands: not of the form 2n+6 + { + Number(buf, 1); + } + + Op(buf, 25); // rlinecurve + Op(buf, 14); + }); + + Assert.Throws(() => Decode(cs)); + } + + /// + /// Proves that CffCharstringInterpreter HFlex ProducesTwoCubicBeziersWithHandComputedCoordinates. + /// + [Fact] + public void CffCharstringInterpreter_HFlex_ProducesTwoCubicBeziersWithHandComputedCoordinates() + { + // hflex(dx1=10, dx2=20, dy2=5, dx3=15, dx4=25, dx5=35, dx6=45) from (0,0): + // cp1 = (0+10, 0) = (10, 0) + // cp2 = (10+20, 0+5) = (30, 5) + // end1 = (30+15, 5) = (45, 5) + // cp3 = (45+25, 5) = (70, 5) + // cp4 = (70+35, 5-5) = (105, 0) + // final = (105+45, 0) = (150, 0) + var cs = Build(buf => + { + Number(buf, 0); + Number(buf, 0); + Op(buf, 21); // rmoveto + Number(buf, 10); + Number(buf, 20); + Number(buf, 5); + Number(buf, 15); + Number(buf, 25); + Number(buf, 35); + Number(buf, 45); + Op(buf, 1234); // hflex (escape 12 34) + Op(buf, 14); + }); + + var path = Decode(cs); + var subpath = Assert.Single(path.Subpaths); + var curves = subpath.Commands.Where(c => c.Type == PathCommandType.CubicBezierTo).ToArray(); + Assert.Equal(2, curves.Length); + Assert.Equal(new Vector2(10, 0), curves[0].Control1); + Assert.Equal(new Vector2(30, 5), curves[0].Control2); + Assert.Equal(new Vector2(45, 5), curves[0].EndPoint); + Assert.Equal(new Vector2(70, 5), curves[1].Control1); + Assert.Equal(new Vector2(105, 0), curves[1].Control2); + Assert.Equal(new Vector2(150, 0), curves[1].EndPoint); + } + + /// + /// Proves that CffCharstringInterpreter HFlex WrongOperandCount OneShort + /// ThrowsInvalidDataException. + /// + [Fact] + public void CffCharstringInterpreter_HFlex_WrongOperandCount_OneShort_ThrowsInvalidDataException() + { + var cs = Build(buf => + { + Number(buf, 0); + Number(buf, 0); + Op(buf, 21); // rmoveto + for (var i = 0; i < 6; i++) // 6 operands: hflex requires exactly 7 + { + Number(buf, 1); + } + + Op(buf, 1234); // hflex + Op(buf, 14); + }); + + Assert.Throws(() => Decode(cs)); + } + + /// + /// Proves that CffCharstringInterpreter HFlex WrongOperandCount OneOver + /// ThrowsInvalidDataException. + /// + [Fact] + public void CffCharstringInterpreter_HFlex_WrongOperandCount_OneOver_ThrowsInvalidDataException() + { + var cs = Build(buf => + { + Number(buf, 0); + Number(buf, 0); + Op(buf, 21); // rmoveto + for (var i = 0; i < 8; i++) // 8 operands: hflex requires exactly 7 + { + Number(buf, 1); + } + + Op(buf, 1234); // hflex + Op(buf, 14); + }); + + Assert.Throws(() => Decode(cs)); + } + + /// + /// Proves that CffCharstringInterpreter Flex ProducesTwoCubicBeziersWithHandComputedCoordinates. + /// + [Fact] + public void CffCharstringInterpreter_Flex_ProducesTwoCubicBeziersWithHandComputedCoordinates() + { + // flex(dx1..dy6 = 1,2,3,4,5,6,7,8,9,10,11,12, fd=50) from (0,0): + // cp1 = (0+1, 0+2) = (1, 2) + // cp2 = (1+3, 2+4) = (4, 6) + // end1 = (4+5, 6+6) = (9, 12) + // cp3 = (9+7, 12+8) = (16, 20) + // cp4 = (16+9, 20+10) = (25, 30) + // final = (25+11, 30+12) = (36, 42) + // fd (50) is consumed with no geometric effect. + var cs = Build(buf => + { + Number(buf, 0); + Number(buf, 0); + Op(buf, 21); // rmoveto + Number(buf, 1); + Number(buf, 2); + Number(buf, 3); + Number(buf, 4); + Number(buf, 5); + Number(buf, 6); + Number(buf, 7); + Number(buf, 8); + Number(buf, 9); + Number(buf, 10); + Number(buf, 11); + Number(buf, 12); + Number(buf, 50); // fd + Op(buf, 1235); // flex (escape 12 35) + Op(buf, 14); + }); + + var path = Decode(cs); + var subpath = Assert.Single(path.Subpaths); + var curves = subpath.Commands.Where(c => c.Type == PathCommandType.CubicBezierTo).ToArray(); + Assert.Equal(2, curves.Length); + Assert.Equal(new Vector2(1, 2), curves[0].Control1); + Assert.Equal(new Vector2(4, 6), curves[0].Control2); + Assert.Equal(new Vector2(9, 12), curves[0].EndPoint); + Assert.Equal(new Vector2(16, 20), curves[1].Control1); + Assert.Equal(new Vector2(25, 30), curves[1].Control2); + Assert.Equal(new Vector2(36, 42), curves[1].EndPoint); + } + + /// + /// Proves that CffCharstringInterpreter Flex WrongOperandCount OneShort + /// ThrowsInvalidDataException. + /// + [Fact] + public void CffCharstringInterpreter_Flex_WrongOperandCount_OneShort_ThrowsInvalidDataException() + { + var cs = Build(buf => + { + Number(buf, 0); + Number(buf, 0); + Op(buf, 21); // rmoveto + for (var i = 0; i < 12; i++) // 12 operands: flex requires exactly 13 + { + Number(buf, 1); + } + + Op(buf, 1235); // flex + Op(buf, 14); + }); + + Assert.Throws(() => Decode(cs)); + } + + /// + /// Proves that CffCharstringInterpreter Flex WrongOperandCount OneOver + /// ThrowsInvalidDataException. + /// + [Fact] + public void CffCharstringInterpreter_Flex_WrongOperandCount_OneOver_ThrowsInvalidDataException() + { + var cs = Build(buf => + { + Number(buf, 0); + Number(buf, 0); + Op(buf, 21); // rmoveto + for (var i = 0; i < 14; i++) // 14 operands: flex requires exactly 13 + { + Number(buf, 1); + } + + Op(buf, 1235); // flex + Op(buf, 14); + }); + + Assert.Throws(() => Decode(cs)); + } + + /// + /// Proves that CffCharstringInterpreter HFlex1 ProducesTwoCubicBeziersWithHandComputedCoordinates. + /// + [Fact] + public void CffCharstringInterpreter_HFlex1_ProducesTwoCubicBeziersWithHandComputedCoordinates() + { + // hflex1(dx1=10, dy1=10, dx2=20, dy2=-20, dx3=30, dx4=40, dx5=50, dy5=5, dx6=60) + // from (100,200), originalStartY=200: + // cp1 = (100+10, 200+10) = (110, 210) + // cp2 = (110+20, 210-20) = (130, 190) + // end1 = (130+30, 190) = (160, 190) + // cp3 = (160+40, 190) = (200, 190) + // cp4 = (200+50, 190+5) = (250, 195) + // final = (250+60, originalStartY) = (310, 200) + var cs = Build(buf => + { + Number(buf, 100); + Number(buf, 200); + Op(buf, 21); // rmoveto + Number(buf, 10); + Number(buf, 10); + Number(buf, 20); + Number(buf, -20); + Number(buf, 30); + Number(buf, 40); + Number(buf, 50); + Number(buf, 5); + Number(buf, 60); + Op(buf, 1236); // hflex1 (escape 12 36) + Op(buf, 14); + }); + + var path = Decode(cs); + var subpath = Assert.Single(path.Subpaths); + var curves = subpath.Commands.Where(c => c.Type == PathCommandType.CubicBezierTo).ToArray(); + Assert.Equal(2, curves.Length); + Assert.Equal(new Vector2(110, 210), curves[0].Control1); + Assert.Equal(new Vector2(130, 190), curves[0].Control2); + Assert.Equal(new Vector2(160, 190), curves[0].EndPoint); + Assert.Equal(new Vector2(200, 190), curves[1].Control1); + Assert.Equal(new Vector2(250, 195), curves[1].Control2); + Assert.Equal(new Vector2(310, 200), curves[1].EndPoint); // forced back to originalStartY (200), not naive 195 + } + + /// + /// Proves that CffCharstringInterpreter HFlex1 WrongOperandCount OneShort + /// ThrowsInvalidDataException. + /// + [Fact] + public void CffCharstringInterpreter_HFlex1_WrongOperandCount_OneShort_ThrowsInvalidDataException() + { + var cs = Build(buf => + { + Number(buf, 0); + Number(buf, 0); + Op(buf, 21); // rmoveto + for (var i = 0; i < 8; i++) // 8 operands: hflex1 requires exactly 9 + { + Number(buf, 1); + } + + Op(buf, 1236); // hflex1 + Op(buf, 14); + }); + + Assert.Throws(() => Decode(cs)); + } + + /// + /// Proves that CffCharstringInterpreter HFlex1 WrongOperandCount OneOver + /// ThrowsInvalidDataException. + /// + [Fact] + public void CffCharstringInterpreter_HFlex1_WrongOperandCount_OneOver_ThrowsInvalidDataException() + { + var cs = Build(buf => + { + Number(buf, 0); + Number(buf, 0); + Op(buf, 21); // rmoveto + for (var i = 0; i < 10; i++) // 10 operands: hflex1 requires exactly 9 + { + Number(buf, 1); + } + + Op(buf, 1236); // hflex1 + Op(buf, 14); + }); + + Assert.Throws(() => Decode(cs)); + } + + /// + /// Proves that CffCharstringInterpreter Flex1 DxDominant ProducesHandComputedCoordinates. + /// + [Fact] + public void CffCharstringInterpreter_Flex1_DxDominant_ProducesHandComputedCoordinates() + { + // flex1(dx1=100,dy1=10, dx2=10,dy2=-5, dx3=10,dy3=5, dx4=10,dy4=-10, dx5=10,dy5=10, d6=5) + // from (0,0), originalStart=(0,0): + // cp1 = (100, 10) + // cp2 = (110, 5) + // end1 = (120, 10) + // cp3 = (130, 0) + // cp4 = (140, 10) + // dx = 100+10+10+10+10 = 140, dy = 10-5+5-10+10 = 10 + // |dx| > |dy| -> final = (140+5, originalStartY) = (145, 0) + var cs = Build(buf => + { + Number(buf, 0); + Number(buf, 0); + Op(buf, 21); // rmoveto + Number(buf, 100); + Number(buf, 10); + Number(buf, 10); + Number(buf, -5); + Number(buf, 10); + Number(buf, 5); + Number(buf, 10); + Number(buf, -10); + Number(buf, 10); + Number(buf, 10); + Number(buf, 5); + Op(buf, 1237); // flex1 (escape 12 37) + Op(buf, 14); + }); + + var path = Decode(cs); + var subpath = Assert.Single(path.Subpaths); + var curves = subpath.Commands.Where(c => c.Type == PathCommandType.CubicBezierTo).ToArray(); + Assert.Equal(2, curves.Length); + Assert.Equal(new Vector2(100, 10), curves[0].Control1); + Assert.Equal(new Vector2(110, 5), curves[0].Control2); + Assert.Equal(new Vector2(120, 10), curves[0].EndPoint); + Assert.Equal(new Vector2(130, 0), curves[1].Control1); + Assert.Equal(new Vector2(140, 10), curves[1].Control2); + Assert.Equal(new Vector2(145, 0), curves[1].EndPoint); + } + + /// + /// Proves that CffCharstringInterpreter Flex1 DyDominant ProducesHandComputedCoordinates. + /// + [Fact] + public void CffCharstringInterpreter_Flex1_DyDominant_ProducesHandComputedCoordinates() + { + // flex1(dx1=5,dy1=100, dx2=-5,dy2=10, dx3=5,dy3=10, dx4=-10,dy4=10, dx5=10,dy5=10, d6=7) + // from (0,0), originalStart=(0,0): + // cp1 = (5, 100) + // cp2 = (0, 110) + // end1 = (5, 120) + // cp3 = (-5, 130) + // cp4 = (5, 140) + // dx = 5-5+5-10+10 = 5, dy = 100+10+10+10+10 = 140 + // |dy| > |dx| -> final = (originalStartX, 140+7) = (0, 147) + var cs = Build(buf => + { + Number(buf, 0); + Number(buf, 0); + Op(buf, 21); // rmoveto + Number(buf, 5); + Number(buf, 100); + Number(buf, -5); + Number(buf, 10); + Number(buf, 5); + Number(buf, 10); + Number(buf, -10); + Number(buf, 10); + Number(buf, 10); + Number(buf, 10); + Number(buf, 7); + Op(buf, 1237); // flex1 (escape 12 37) + Op(buf, 14); + }); + + var path = Decode(cs); + var subpath = Assert.Single(path.Subpaths); + var curves = subpath.Commands.Where(c => c.Type == PathCommandType.CubicBezierTo).ToArray(); + Assert.Equal(2, curves.Length); + Assert.Equal(new Vector2(5, 100), curves[0].Control1); + Assert.Equal(new Vector2(0, 110), curves[0].Control2); + Assert.Equal(new Vector2(5, 120), curves[0].EndPoint); + Assert.Equal(new Vector2(-5, 130), curves[1].Control1); + Assert.Equal(new Vector2(5, 140), curves[1].Control2); + Assert.Equal(new Vector2(0, 147), curves[1].EndPoint); + } + + /// + /// Proves that CffCharstringInterpreter Flex1 WrongOperandCount OneShort + /// ThrowsInvalidDataException. + /// + [Fact] + public void CffCharstringInterpreter_Flex1_WrongOperandCount_OneShort_ThrowsInvalidDataException() + { + var cs = Build(buf => + { + Number(buf, 0); + Number(buf, 0); + Op(buf, 21); // rmoveto + for (var i = 0; i < 10; i++) // 10 operands: flex1 requires exactly 11 + { + Number(buf, 1); + } + + Op(buf, 1237); // flex1 + Op(buf, 14); + }); + + Assert.Throws(() => Decode(cs)); + } + + /// + /// Proves that CffCharstringInterpreter Flex1 WrongOperandCount OneOver + /// ThrowsInvalidDataException. + /// + [Fact] + public void CffCharstringInterpreter_Flex1_WrongOperandCount_OneOver_ThrowsInvalidDataException() + { + var cs = Build(buf => + { + Number(buf, 0); + Number(buf, 0); + Op(buf, 21); // rmoveto + for (var i = 0; i < 12; i++) // 12 operands: flex1 requires exactly 11 + { + Number(buf, 1); + } + + Op(buf, 1237); // flex1 + Op(buf, 14); + }); + + Assert.Throws(() => Decode(cs)); + } + + /// + /// Proves that CffCharstringInterpreter DotSection IsNoOp MatchesSameCharstringWithoutDotSection. + /// + [Fact] + public void CffCharstringInterpreter_DotSection_IsNoOp_MatchesSameCharstringWithoutDotSection() + { + var csWith = Build(buf => + { + Number(buf, 10); + Number(buf, 20); + Op(buf, 21); // rmoveto + Op(buf, 1200); // dotsection (escape 12 0) - no-op + Number(buf, 100); + Number(buf, 0); + Op(buf, 5); // rlineto + Op(buf, 14); // endchar + }); + + var csWithout = Build(buf => + { + Number(buf, 10); + Number(buf, 20); + Op(buf, 21); // rmoveto + Number(buf, 100); + Number(buf, 0); + Op(buf, 5); // rlineto + Op(buf, 14); // endchar + }); + + var pathWith = Decode(csWith); + var pathWithout = Decode(csWithout); + + Assert.Equal(pathWithout.Subpaths.Count, pathWith.Subpaths.Count); + var subpathWith = Assert.Single(pathWith.Subpaths); + var subpathWithout = Assert.Single(pathWithout.Subpaths); + Assert.Equal(subpathWithout.Start, subpathWith.Start); + Assert.Equal(subpathWithout.Commands.Count, subpathWith.Commands.Count); + for (var i = 0; i < subpathWith.Commands.Count; i++) + { + Assert.Equal(subpathWithout.Commands[i].Type, subpathWith.Commands[i].Type); + Assert.Equal(subpathWithout.Commands[i].EndPoint, subpathWith.Commands[i].EndPoint); + } + } +} diff --git a/test/DemaConsulting.CanvasNet.Tests/Fonts/CffStandardEncodingTests.cs b/test/DemaConsulting.CanvasNet.Tests/Fonts/CffStandardEncodingTests.cs new file mode 100644 index 00000000..003e6f8d --- /dev/null +++ b/test/DemaConsulting.CanvasNet.Tests/Fonts/CffStandardEncodingTests.cs @@ -0,0 +1,45 @@ +using DemaConsulting.CanvasNet.Fonts; + +namespace DemaConsulting.CanvasNet.Tests.Fonts; + +/// +/// Unit tests for . +/// +public class CffStandardEncodingTests +{ + /// + /// Proves that CffStandardEncoding CodeToGlyphName HasExpectedLength. + /// + [Fact] + public void CffStandardEncoding_CodeToGlyphName_HasExpectedLength() + { + Assert.Equal(256, CffStandardEncoding.CodeToGlyphName.Length); + } + + /// + /// Proves that CffStandardEncoding CodeToGlyphName SpotChecksKnownCodes. + /// + [Theory] + [InlineData(32, "space")] + [InlineData(65, "A")] + [InlineData(97, "a")] + [InlineData(193, "grave")] + [InlineData(194, "acute")] + public void CffStandardEncoding_CodeToGlyphName_SpotChecksKnownCodes(int code, string expectedName) + { + Assert.Equal(expectedName, CffStandardEncoding.CodeToGlyphName[code]); + } + + /// + /// Proves that CffStandardEncoding CodeToGlyphName UndefinedControlCodesAreNull. + /// + [Theory] + [InlineData(0)] + [InlineData(31)] + [InlineData(127)] + [InlineData(160)] + public void CffStandardEncoding_CodeToGlyphName_UndefinedControlCodesAreNull(int code) + { + Assert.Null(CffStandardEncoding.CodeToGlyphName[code]); + } +} diff --git a/test/DemaConsulting.CanvasNet.Tests/Fonts/CffTableTests.cs b/test/DemaConsulting.CanvasNet.Tests/Fonts/CffTableTests.cs new file mode 100644 index 00000000..285cd909 --- /dev/null +++ b/test/DemaConsulting.CanvasNet.Tests/Fonts/CffTableTests.cs @@ -0,0 +1,578 @@ +// 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 CFF charstring charstrings subr subrs +// cspell:ignore endchar hstem nosuchglyph charsets isoadobe +// cspell:ignore seac bchar achar adx ady rmoveto rlineto Agrave +using DemaConsulting.CanvasNet.Fonts; +using DemaConsulting.CanvasNet.Tests.TestSupport; + +namespace DemaConsulting.CanvasNet.Tests.Fonts; + +/// +/// Unit tests for . +/// +public class CffTableTests +{ + /// + /// Builds a single-glyph charstring: rmoveto to (0, 0) then endchar. + /// + private static byte[] SimpleCharstring() + { + var buf = new List(); + SyntheticFontBuilder.WriteCharstringNumber(buf, 0); + SyntheticFontBuilder.WriteCharstringNumber(buf, 0); + SyntheticFontBuilder.WriteCharstringOperator(buf, 21); // rmoveto + SyntheticFontBuilder.WriteCharstringOperator(buf, 14); // endchar + return [.. buf]; + } + + private static CffTable Parse(byte[] cffTable) => CffTable.Parse(cffTable, 0, cffTable.Length); + + /// + /// Proves that CffTable Parse WellFormedFont ExposesGlyphCountAndOutlines. + /// + [Fact] + public void CffTable_Parse_WellFormedFont_ExposesGlyphCountAndOutlines() + { + var cff = SyntheticFontBuilder.Cff([SimpleCharstring(), SimpleCharstring()]); + var table = Parse(cff); + + Assert.Equal(2, table.GlyphCount); + var outline = table.GetGlyphOutline(0); + Assert.Single(outline.Subpaths); + } + + /// + /// Proves that CffTable Parse CidKeyedRos ThrowsInvalidDataException. + /// + [Fact] + public void CffTable_Parse_CidKeyedRos_ThrowsInvalidDataException() + { + var cff = SyntheticFontBuilder.Cff([SimpleCharstring()], includeRos: true); + Assert.Throws(() => Parse(cff)); + } + + /// + /// Proves that CffTable Parse NoPrivateDict SucceedsWithNoLocalSubrs. + /// + [Fact] + public void CffTable_Parse_NoPrivateDict_SucceedsWithNoLocalSubrs() + { + var cff = SyntheticFontBuilder.Cff([SimpleCharstring()], includePrivate: false); + var table = Parse(cff); + + Assert.Equal(1, table.GlyphCount); + Assert.Single(table.GetGlyphOutline(0).Subpaths); + } + + /// + /// Proves that CffTable Parse WithLocalSubrs DecodesGlyphUsingCallSubr. + /// + [Fact] + public void CffTable_Parse_WithLocalSubrs_DecodesGlyphUsingCallSubr() + { + var subr = new List(); + SyntheticFontBuilder.WriteCharstringNumber(subr, 5); + SyntheticFontBuilder.WriteCharstringNumber(subr, 5); + SyntheticFontBuilder.WriteCharstringOperator(subr, 21); // rmoveto + SyntheticFontBuilder.WriteCharstringOperator(subr, 11); // return + + var main = new List(); + SyntheticFontBuilder.WriteCharstringNumber(main, -107); // subr index before bias (count=1 -> bias 107) + SyntheticFontBuilder.WriteCharstringOperator(main, 10); // callsubr + SyntheticFontBuilder.WriteCharstringOperator(main, 14); // endchar + + var cff = SyntheticFontBuilder.Cff([[.. main]], localSubrs: [[.. subr]]); + var table = Parse(cff); + + Assert.Single(table.GetGlyphOutline(0).Subpaths); + } + + /// + /// Proves that CffTable Parse WithGlobalSubrs DecodesGlyphUsingCallGSubr. + /// + [Fact] + public void CffTable_Parse_WithGlobalSubrs_DecodesGlyphUsingCallGSubr() + { + var subr = new List(); + SyntheticFontBuilder.WriteCharstringNumber(subr, 3); + SyntheticFontBuilder.WriteCharstringNumber(subr, 3); + SyntheticFontBuilder.WriteCharstringOperator(subr, 21); // rmoveto + SyntheticFontBuilder.WriteCharstringOperator(subr, 11); // return + + var main = new List(); + SyntheticFontBuilder.WriteCharstringNumber(main, -107); + SyntheticFontBuilder.WriteCharstringOperator(main, 29); // callgsubr + SyntheticFontBuilder.WriteCharstringOperator(main, 14); // endchar + + var cff = SyntheticFontBuilder.Cff([[.. main]], globalSubrs: [[.. subr]]); + var table = Parse(cff); + + Assert.Single(table.GetGlyphOutline(0).Subpaths); + } + + /// + /// Proves that CffTable GetGlyphOutline OutOfRangeIndex ThrowsArgumentOutOfRangeException. + /// + [Fact] + public void CffTable_GetGlyphOutline_OutOfRangeIndex_ThrowsArgumentOutOfRangeException() + { + var table = Parse(SyntheticFontBuilder.Cff([SimpleCharstring()])); + Assert.Throws(() => table.GetGlyphOutline(1)); + Assert.Throws(() => table.GetGlyphOutline(-1)); + } + + /// + /// Proves that CffTable GetGlyphOutline OneCorruptGlyph DoesNotBreakOtherGlyphs. + /// + [Fact] + public void CffTable_GetGlyphOutline_OneCorruptGlyph_DoesNotBreakOtherGlyphs() + { + var corrupt = new List(); + SyntheticFontBuilder.WriteCharstringOperator(corrupt, 9); // unsupported operator + + var cff = SyntheticFontBuilder.Cff([SimpleCharstring(), [.. corrupt], SimpleCharstring()]); + var table = Parse(cff); + + Assert.Single(table.GetGlyphOutline(0).Subpaths); + Assert.Throws(() => table.GetGlyphOutline(1)); + Assert.Single(table.GetGlyphOutline(2).Subpaths); + } + + /// + /// Proves that CffTable Parse MissingCharStringsOperator ThrowsInvalidDataException. + /// + [Fact] + public void CffTable_Parse_MissingCharStringsOperator_ThrowsInvalidDataException() + { + // A CFF table truncated to just the header + empty Name/Top DICT/String/Global Subr + // INDEXes has a Top DICT with no operators at all, so 'CharStrings' is missing. + byte[] header = [1, 0, 4, 4]; + var emptyIndex = SyntheticFontBuilder.WriteCffIndex([]); + var emptyTopDict = SyntheticFontBuilder.WriteCffIndex([[]]); + + var buf = new List(); + buf.AddRange(header); + buf.AddRange(emptyIndex); // Name INDEX + buf.AddRange(emptyTopDict); // Top DICT INDEX (one empty entry) + buf.AddRange(emptyIndex); // String INDEX + buf.AddRange(emptyIndex); // Global Subr INDEX + + Assert.Throws(() => Parse([.. buf])); + } + + /// + /// Proves that CffTable Parse UnsupportedMajorVersion ThrowsInvalidDataException. + /// + [Fact] + public void CffTable_Parse_UnsupportedMajorVersion_ThrowsInvalidDataException() + { + var cff = SyntheticFontBuilder.Cff([SimpleCharstring()]); + cff[0] = 2; // major version 2 is not supported + + Assert.Throws(() => Parse(cff)); + } + + /// + /// Proves that CffTable Parse TruncatedTable ThrowsInvalidDataException. + /// + [Fact] + public void CffTable_Parse_TruncatedTable_ThrowsInvalidDataException() + { + byte[] tooShort = [1, 0, 4]; + Assert.Throws(() => Parse(tooShort)); + } + + /// + /// Proves that CffTable Parse EmptyCharStringsIndex ThrowsInvalidDataException. + /// + [Fact] + public void CffTable_Parse_EmptyCharStringsIndex_ThrowsInvalidDataException() + { + var cff = SyntheticFontBuilder.Cff([]); + Assert.Throws(() => Parse(cff)); + } + + /// + /// Proves that CffTable's transcribed Standard Strings table has the exact length and + /// spot-checked entries documented by Adobe Technical Note #5176 Appendix A. + /// + [Fact] + public void CffTable_StandardStrings_HasExpectedLengthAndSpotCheckedEntries() + { + Assert.Equal(391, CffTable.StandardStrings.Length); + Assert.Equal(".notdef", CffTable.StandardStrings[0]); + Assert.Equal("space", CffTable.StandardStrings[1]); + Assert.Equal("A", CffTable.StandardStrings[34]); + Assert.Equal("Z", CffTable.StandardStrings[59]); + Assert.Equal("a", CffTable.StandardStrings[66]); + Assert.Equal("z", CffTable.StandardStrings[91]); + Assert.Equal("copyright", CffTable.StandardStrings[170]); + Assert.Equal("001.000", CffTable.StandardStrings[379]); + Assert.Equal("Semibold", CffTable.StandardStrings[390]); + } + + /// + /// Proves that CffTable Parse AbsentCharset DefaultsToIsoAdobeAndResolvesByName. + /// + [Fact] + public void CffTable_Parse_AbsentCharset_DefaultsToIsoAdobeAndResolvesByName() + { + // No 'charset' operator at all: glyph i's SID is i, so glyph 1 is SID 1 ("space") and + // glyph 2 is SID 2 ("exclam"), per the Standard Strings table. + var cff = SyntheticFontBuilder.Cff([SimpleCharstring(), SimpleCharstring(), SimpleCharstring()]); + var table = Parse(cff); + + Assert.True(table.TryGetGlyphIndex("space", out var spaceIndex)); + Assert.Equal(1, spaceIndex); + Assert.True(table.TryGetGlyphIndex("exclam", out var exclamIndex)); + Assert.Equal(2, exclamIndex); + Assert.False(table.TryGetGlyphIndex("nosuchglyph", out _)); + } + + /// + /// Proves that CffTable Parse PredefinedIsoAdobeCharsetId MatchesAbsentCharset. + /// + [Fact] + public void CffTable_Parse_PredefinedIsoAdobeCharsetId_MatchesAbsentCharset() + { + var cff = SyntheticFontBuilder.Cff([SimpleCharstring(), SimpleCharstring()], charsetId: 0); + var table = Parse(cff); + + Assert.True(table.TryGetGlyphIndex("space", out var spaceIndex)); + Assert.Equal(1, spaceIndex); + } + + /// + /// Proves that CffTable Parse PredefinedExpertOrExpertSubsetCharset + /// NeverResolvesByNameButNeverThrows - a deliberate, documented scope boundary. + /// + [Theory] + [InlineData(1)] + [InlineData(2)] + public void CffTable_Parse_PredefinedExpertOrExpertSubsetCharset_NeverResolvesByNameButNeverThrows(int charsetId) + { + var cff = SyntheticFontBuilder.Cff([SimpleCharstring(), SimpleCharstring()], charsetId: charsetId); + var table = Parse(cff); + + Assert.False(table.TryGetGlyphIndex("space", out _)); + Assert.False(table.TryGetGlyphIndex("A", out _)); + // Outline decoding is unaffected by the charset at all - only name resolution is scoped out. + Assert.Single(table.GetGlyphOutline(1).Subpaths); + } + + /// + /// Proves that CffTable Parse CustomCharsetFormat0 ResolvesEachGlyphBySid. + /// + [Fact] + public void CffTable_Parse_CustomCharsetFormat0_ResolvesEachGlyphBySid() + { + // Format 0: a flat array of 2-byte SIDs, one per non-.notdef glyph. Glyph 1 -> SID 34 + // ("A"), glyph 2 -> SID 66 ("a"). + byte[] charsetTable = [0, 0, 34, 0, 66]; + var cff = SyntheticFontBuilder.Cff( + [SimpleCharstring(), SimpleCharstring(), SimpleCharstring()], charsetTable: charsetTable); + var table = Parse(cff); + + Assert.True(table.TryGetGlyphIndex("A", out var aIndex)); + Assert.Equal(1, aIndex); + Assert.True(table.TryGetGlyphIndex("a", out var lowerAIndex)); + Assert.Equal(2, lowerAIndex); + } + + /// + /// Proves that CffTable Parse CustomCharsetFormat1 ResolvesRangesOfGlyphs. + /// + [Fact] + public void CffTable_Parse_CustomCharsetFormat1_ResolvesRangesOfGlyphs() + { + // Format 1: ranges of (first SID: 2 bytes, nLeft: 1 byte). A single range starting at SID + // 34 ("A") with nLeft=2 covers glyphs 1, 2, 3 as SIDs 34, 35, 36 ("A", "B", "C"). + byte[] charsetTable = [1, 0, 34, 2]; + var cff = SyntheticFontBuilder.Cff( + [SimpleCharstring(), SimpleCharstring(), SimpleCharstring(), SimpleCharstring()], charsetTable: charsetTable); + var table = Parse(cff); + + Assert.True(table.TryGetGlyphIndex("A", out var aIndex)); + Assert.Equal(1, aIndex); + Assert.True(table.TryGetGlyphIndex("B", out var bIndex)); + Assert.Equal(2, bIndex); + Assert.True(table.TryGetGlyphIndex("C", out var cIndex)); + Assert.Equal(3, cIndex); + } + + /// + /// Proves that CffTable Parse CustomCharsetFormat2 ResolvesRangesWithTwoByteNLeft. + /// + [Fact] + public void CffTable_Parse_CustomCharsetFormat2_ResolvesRangesWithTwoByteNLeft() + { + // Format 2: identical to format 1, but nLeft is 2 bytes - exercised with a small nLeft + // value (1) to keep the test glyph count small, proving the wider field is read correctly. + byte[] charsetTable = [2, 0, 34, 0, 1]; + var cff = SyntheticFontBuilder.Cff( + [SimpleCharstring(), SimpleCharstring(), SimpleCharstring()], charsetTable: charsetTable); + var table = Parse(cff); + + Assert.True(table.TryGetGlyphIndex("A", out var aIndex)); + Assert.Equal(1, aIndex); + Assert.True(table.TryGetGlyphIndex("B", out var bIndex)); + Assert.Equal(2, bIndex); + } + + /// + /// Proves that CffTable Parse CustomCharsetUnsupportedFormat ThrowsInvalidDataException. + /// + [Fact] + public void CffTable_Parse_CustomCharsetUnsupportedFormat_ThrowsInvalidDataException() + { + byte[] charsetTable = [3, 0, 0]; + var cff = SyntheticFontBuilder.Cff([SimpleCharstring(), SimpleCharstring()], charsetTable: charsetTable); + + Assert.Throws(() => Parse(cff)); + } + + /// + /// Proves that CffTable Parse CharsetOffsetOutOfBounds ThrowsInvalidDataException. + /// + [Fact] + public void CffTable_Parse_CharsetOffsetOutOfBounds_ThrowsInvalidDataException() + { + var cff = SyntheticFontBuilder.Cff([SimpleCharstring()], charsetId: 999_999); + Assert.Throws(() => Parse(cff)); + } + + /// + /// Proves that CffTable Parse CustomStringIndex ResolvesCustomGlyphNameBySid. + /// + [Fact] + public void CffTable_Parse_CustomStringIndex_ResolvesCustomGlyphNameBySid() + { + // SID 391 is the first entry of the font's own String INDEX (391 = StandardStrings.Length). + byte[] charsetTable = [0, 1, 135]; // glyph 1 -> SID 391 (0x0187) + var cff = SyntheticFontBuilder.Cff( + [SimpleCharstring(), SimpleCharstring()], + stringIndexEntries: [System.Text.Encoding.ASCII.GetBytes("Alpha")], + charsetTable: charsetTable); + var table = Parse(cff); + + Assert.True(table.TryGetGlyphIndex("Alpha", out var alphaIndex)); + Assert.Equal(1, alphaIndex); + } + + /// + /// Proves that CffTable GetAdvanceWidth NoWidthOperand UsesDefaultWidthX. + /// + [Fact] + public void CffTable_GetAdvanceWidth_NoWidthOperand_UsesDefaultWidthX() + { + // 'endchar' with zero operands (the width-parsed flag consumes nothing, since the operand + // count is already 0): the glyph's width is the Private DICT's defaultWidthX unchanged. + var cs = new List(); + SyntheticFontBuilder.WriteCharstringOperator(cs, 14); // endchar + + var cff = SyntheticFontBuilder.Cff([[.. cs]], defaultWidthX: 500, nominalWidthX: 100); + var table = Parse(cff); + + Assert.Equal(500, table.GetAdvanceWidth(0)); + } + + /// + /// Proves that CffTable GetAdvanceWidth WithWidthOperand UsesNominalWidthXPlusDelta. + /// + [Fact] + public void CffTable_GetAdvanceWidth_WithWidthOperand_UsesNominalWidthXPlusDelta() + { + // 'endchar' with a single leading operand: that operand is the width delta, so the + // glyph's resolved width is nominalWidthX + delta, not defaultWidthX. + var cs = new List(); + SyntheticFontBuilder.WriteCharstringNumber(cs, 30); // width delta + SyntheticFontBuilder.WriteCharstringOperator(cs, 14); // endchar + + var cff = SyntheticFontBuilder.Cff([[.. cs]], defaultWidthX: 500, nominalWidthX: 100); + var table = Parse(cff); + + Assert.Equal(130, table.GetAdvanceWidth(0)); + } + + /// + /// Proves that CffTable GetAdvanceWidth OutOfRangeIndex ThrowsArgumentOutOfRangeException. + /// + [Fact] + public void CffTable_GetAdvanceWidth_OutOfRangeIndex_ThrowsArgumentOutOfRangeException() + { + var table = Parse(SyntheticFontBuilder.Cff([SimpleCharstring()])); + Assert.Throws(() => table.GetAdvanceWidth(1)); + Assert.Throws(() => table.GetAdvanceWidth(-1)); + } + + /// + /// Builds a small outline charstring: rmoveto to (0, 0), two rlineto + /// steps of the given size, then endchar - with no leading width operand, so the + /// glyph's resolved width is always the font's defaultWidthX. + /// + private static byte[] OutlineCharstring(double dx, double dy) + { + var buf = new List(); + SyntheticFontBuilder.WriteCharstringNumber(buf, 0); + SyntheticFontBuilder.WriteCharstringNumber(buf, 0); + SyntheticFontBuilder.WriteCharstringOperator(buf, 21); // rmoveto + SyntheticFontBuilder.WriteCharstringNumber(buf, dx); + SyntheticFontBuilder.WriteCharstringNumber(buf, 0); + SyntheticFontBuilder.WriteCharstringOperator(buf, 5); // rlineto + SyntheticFontBuilder.WriteCharstringNumber(buf, 0); + SyntheticFontBuilder.WriteCharstringNumber(buf, dy); + SyntheticFontBuilder.WriteCharstringOperator(buf, 5); // rlineto + SyntheticFontBuilder.WriteCharstringOperator(buf, 14); // endchar + return [.. buf]; + } + + /// + /// Builds a seac-style 4-operand endchar charstring: width? adx ady bchar achar + /// endchar - is written as a leading width operand only when + /// supplied. + /// + private static byte[] SeacCharstring(double adx, double ady, int bchar, int achar, double? width = null) + { + var buf = new List(); + if (width.HasValue) + { + SyntheticFontBuilder.WriteCharstringNumber(buf, width.Value); + } + + SyntheticFontBuilder.WriteCharstringNumber(buf, adx); + SyntheticFontBuilder.WriteCharstringNumber(buf, ady); + SyntheticFontBuilder.WriteCharstringNumber(buf, bchar); + SyntheticFontBuilder.WriteCharstringNumber(buf, achar); + SyntheticFontBuilder.WriteCharstringOperator(buf, 14); // endchar + return [.. buf]; + } + + /// + /// Builds a 4-glyph synthetic font (.notdef, A, grave, + /// Agrave) with a format-0 charset mapping glyph indices to SIDs 34, 124, 174 - + /// "A", "grave", "Agrave" respectively - where Agrave (glyph 3) is defined as a + /// seac-style endchar composing StandardEncoding codes 65 ("A") and + /// 193 ("grave") at offset (20, 30), with its own leading width operand of + /// 700 (distinct from the font's defaultWidthX of 500, which + /// A/grave themselves resolve to, having no leading width operand of their + /// own). + /// + private static byte[] SeacFixtureCff() + { + byte[] charsetTable = [0, 0, 34, 0, 124, 0, 174]; + return SyntheticFontBuilder.Cff( + [ + SimpleCharstring(), + OutlineCharstring(10, 10), + OutlineCharstring(5, 5), + SeacCharstring(20, 30, bchar: 65, achar: 193, width: 700), + ], + charsetTable: charsetTable, + defaultWidthX: 500, + nominalWidthX: 0); + } + + /// + /// Proves that CffTable GetGlyphOutline SeacStyleEndChar ComposesBaseAndTranslatedAccent. + /// + [Fact] + public void CffTable_GetGlyphOutline_SeacStyleEndChar_ComposesBaseAndTranslatedAccent() + { + var table = Parse(SeacFixtureCff()); + + var baseOutline = table.GetGlyphOutline(1); // A + var accentOutline = table.GetGlyphOutline(2); // grave + var composite = table.GetGlyphOutline(3); // Agrave + + var expectedAccent = accentOutline.Transform(System.Numerics.Matrix3x2.CreateTranslation(20, 30)); + Assert.Equal(baseOutline.Subpaths.Count + expectedAccent.Subpaths.Count, composite.Subpaths.Count); + Assert.Equal(baseOutline.Subpaths[0].Start, composite.Subpaths[0].Start); + Assert.Equal(expectedAccent.Subpaths[0].Start, composite.Subpaths[1].Start); + } + + /// + /// Proves that CffTable GetAdvanceWidth SeacStyleEndChar UsesOwnWidthNotComponentWidths. + /// + [Fact] + public void CffTable_GetAdvanceWidth_SeacStyleEndChar_UsesOwnWidthNotComponentWidths() + { + var table = Parse(SeacFixtureCff()); + + Assert.Equal(500, table.GetAdvanceWidth(1)); // A -> defaultWidthX + Assert.Equal(500, table.GetAdvanceWidth(2)); // grave -> defaultWidthX + Assert.Equal(700, table.GetAdvanceWidth(3)); // Agrave -> its own leading width operand + } + + /// + /// Proves that CffTable GetGlyphOutline SeacStyleEndChar + /// UndefinedStandardEncodingCode ThrowsInvalidDataException. + /// + [Fact] + public void CffTable_GetGlyphOutline_SeacStyleEndChar_UndefinedStandardEncodingCode_ThrowsInvalidDataException() + { + byte[] charsetTable = [0, 0, 34, 0, 124, 0, 174]; + var cff = SyntheticFontBuilder.Cff( + [ + SimpleCharstring(), + OutlineCharstring(10, 10), + OutlineCharstring(5, 5), + SeacCharstring(20, 30, bchar: 65, achar: 127), // code 127 is undefined in StandardEncoding + ], + charsetTable: charsetTable, + defaultWidthX: 500, + nominalWidthX: 0); + var table = Parse(cff); + + Assert.Throws(() => table.GetGlyphOutline(3)); + } + + /// + /// Proves that CffTable GetGlyphOutline SeacStyleEndChar + /// GlyphNameNotInCharset ThrowsInvalidDataException. + /// + [Fact] + public void CffTable_GetGlyphOutline_SeacStyleEndChar_GlyphNameNotInCharset_ThrowsInvalidDataException() + { + byte[] charsetTable = [0, 0, 34, 0, 124, 0, 174]; + var cff = SyntheticFontBuilder.Cff( + [ + SimpleCharstring(), + OutlineCharstring(10, 10), + OutlineCharstring(5, 5), + SeacCharstring(20, 30, bchar: 66, achar: 193), // code 66 ("B") is not in this font's charset + ], + charsetTable: charsetTable, + defaultWidthX: 500, + nominalWidthX: 0); + var table = Parse(cff); + + Assert.Throws(() => table.GetGlyphOutline(3)); + } + + /// + /// Proves that CffTable GetGlyphOutline SeacStyleEndChar + /// ComponentItselfSeacStyle ThrowsInvalidDataException - a seac component's own charstring + /// being itself a (illegal, nested) seac-style endchar is rejected rather than + /// recursed into, because deliberately omits the resolver on the + /// component's own recursive decode. + /// + [Fact] + public void CffTable_GetGlyphOutline_SeacStyleEndChar_ComponentItselfSeacStyle_ThrowsInvalidDataException() + { + byte[] charsetTable = [0, 0, 34, 0, 124, 0, 174]; + var cff = SyntheticFontBuilder.Cff( + [ + SimpleCharstring(), + SeacCharstring(1, 1, bchar: 65, achar: 193), // "A" is itself (illegally) seac-style + OutlineCharstring(5, 5), + SeacCharstring(20, 30, bchar: 65, achar: 193), + ], + charsetTable: charsetTable, + defaultWidthX: 500, + nominalWidthX: 0); + var table = Parse(cff); + + Assert.Throws(() => table.GetGlyphOutline(3)); + } +} diff --git a/test/DemaConsulting.CanvasNet.Tests/Fonts/HmtxHheaReaderTests.cs b/test/DemaConsulting.CanvasNet.Tests/Fonts/HmtxHheaReaderTests.cs index 63794254..7c9fa541 100644 --- a/test/DemaConsulting.CanvasNet.Tests/Fonts/HmtxHheaReaderTests.cs +++ b/test/DemaConsulting.CanvasNet.Tests/Fonts/HmtxHheaReaderTests.cs @@ -115,4 +115,25 @@ public void HmtxHheaReader_Parse_TruncatedHmtx_ThrowsInvalidDataException() // Act/Assert: parsing the truncated hmtx table throws Assert.Throws(() => Parse(hhea, hmtx, 2)); } + + /// + /// Proves that HmtxHheaReader Parse MissingTrailingLsbTail ParsesSuccessfully. + /// + [Fact] + public void HmtxHheaReader_Parse_MissingTrailingLsbTail_ParsesSuccessfully() + { + // Arrange: build an hmtx table with only the explicit long-metrics entries - numGlyphs is + // far larger than numOfLongHorMetrics, but zero trailing left-side-bearing-only tail + // bytes are present (a shape produced by some real-world font subsetting tools, which the + // reader tolerates since it never reads that tail anyway) + var hhea = SyntheticFontBuilder.Hhea(2048, -512, 100, 2); + var hmtx = SyntheticFontBuilder.Hmtx([600, 700]); // tailLsbCount defaults to 0 + + // Act: parse the combined hhea/hmtx tables, declaring far more glyphs than hmtx covers + var reader = Parse(hhea, hmtx, 50); + + // Assert: parsing succeeds, and glyphs beyond the last explicit entry reuse it + Assert.Equal(700, reader.GetAdvanceWidth(1)); + Assert.Equal(700, reader.GetAdvanceWidth(49)); + } } diff --git a/test/DemaConsulting.CanvasNet.Tests/Fonts/NameTableTests.cs b/test/DemaConsulting.CanvasNet.Tests/Fonts/NameTableTests.cs new file mode 100644 index 00000000..540da79f --- /dev/null +++ b/test/DemaConsulting.CanvasNet.Tests/Fonts/NameTableTests.cs @@ -0,0 +1,248 @@ +// 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 NameTableTests +{ + /// + /// Proves that NameTable Parse WindowsAndMacintoshRecords PrefersWindowsRecord. + /// + [Fact] + public void NameTable_Parse_WindowsAndMacintoshRecords_PrefersWindowsRecord() + { + // Arrange: build a name table with both a Windows and a Macintosh record for nameID 1 + var table = SyntheticFontBuilder.Name( + [ + new SyntheticFontBuilder.NameRecord(3, 1, 0x0409, 1, "Windows Family"), + new SyntheticFontBuilder.NameRecord(1, 0, 0, 1, "Macintosh Family"), + ]); + + // Act: parse the name table + var name = NameTable.Parse(table, 0, table.Length); + + // Assert: the Windows/Unicode BMP record is preferred over the Macintosh record + Assert.Equal("Windows Family", name.FamilyName); + } + + /// + /// Proves that NameTable Parse OnlyMacintoshRecord ResolvesMacintoshRecord. + /// + [Fact] + public void NameTable_Parse_OnlyMacintoshRecord_ResolvesMacintoshRecord() + { + // Arrange: build a name table with only a Macintosh record for nameID 1 + var table = SyntheticFontBuilder.Name( + [ + new SyntheticFontBuilder.NameRecord(1, 0, 0, 1, "Macintosh Only Family"), + ]); + + // Act: parse the name table + var name = NameTable.Parse(table, 0, table.Length); + + // Assert: the Macintosh record is resolved as a fallback when no Windows record exists + Assert.Equal("Macintosh Only Family", name.FamilyName); + } + + /// + /// Proves that NameTable Parse TypographicNamesPresent PreferredOverStandardNames. + /// + [Fact] + public void NameTable_Parse_TypographicNamesPresent_PreferredOverStandardNames() + { + // Arrange: build a name table with both standard (1/2) and typographic (16/17) names + var table = SyntheticFontBuilder.Name( + [ + new SyntheticFontBuilder.NameRecord(3, 1, 0x0409, 1, "Standard Family"), + new SyntheticFontBuilder.NameRecord(3, 1, 0x0409, 2, "Standard Subfamily"), + new SyntheticFontBuilder.NameRecord(3, 1, 0x0409, 16, "Typographic Family"), + new SyntheticFontBuilder.NameRecord(3, 1, 0x0409, 17, "Typographic Subfamily"), + ]); + + // Act: parse the name table + var name = NameTable.Parse(table, 0, table.Length); + + // Assert: the typographic names are preferred over the standard names + Assert.Equal("Typographic Family", name.FamilyName); + Assert.Equal("Typographic Subfamily", name.SubfamilyName); + } + + /// + /// Proves that NameTable Parse TypographicNamesAbsent FallsBackToStandardNames. + /// + [Fact] + public void NameTable_Parse_TypographicNamesAbsent_FallsBackToStandardNames() + { + // Arrange: build a name table with only standard (1/2) names, no typographic (16/17) + var table = SyntheticFontBuilder.Name( + [ + new SyntheticFontBuilder.NameRecord(3, 1, 0x0409, 1, "Standard Family"), + new SyntheticFontBuilder.NameRecord(3, 1, 0x0409, 2, "Standard Subfamily"), + ]); + + // Act: parse the name table + var name = NameTable.Parse(table, 0, table.Length); + + // Assert: the standard names are resolved when no typographic names are present + Assert.Equal("Standard Family", name.FamilyName); + Assert.Equal("Standard Subfamily", name.SubfamilyName); + } + + /// + /// Proves that NameTable Parse MissingRecord ReturnsNullForThatField. + /// + [Fact] + public void NameTable_Parse_MissingRecord_ReturnsNullForThatField() + { + // Arrange: build a name table with a family name but no PostScript name (nameID 6) + var table = SyntheticFontBuilder.Name( + [ + new SyntheticFontBuilder.NameRecord(3, 1, 0x0409, 1, "Some Family"), + ]); + + // Act: parse the name table + var name = NameTable.Parse(table, 0, table.Length); + + // Assert: the present field resolves, and the missing field is null + Assert.Equal("Some Family", name.FamilyName); + Assert.Null(name.PostScriptName); + } + + /// + /// Proves that NameTable Parse TruncatedTable ReturnsEmpty. + /// + [Fact] + public void NameTable_Parse_TruncatedTable_ReturnsEmpty() + { + // Arrange: a table too short to contain the 6-byte header + byte[] data = [0, 0, 0, 1]; + + // Act: parse the truncated name table + var name = NameTable.Parse(data, 0, data.Length); + + // Assert: the table is treated as empty (no throw, every field null) + Assert.Same(NameTable.Empty, name); + } + + /// + /// Proves that NameTable Parse DeclaredCountTooLargeForBounds ReturnsEmpty. + /// + [Fact] + public void NameTable_Parse_DeclaredCountTooLargeForBounds_ReturnsEmpty() + { + // Arrange: a well-formed 6-byte header declaring far more records than the table can hold + var buf = new List(); + SyntheticFontBuilder.WriteUInt16(buf, 0); // format + SyntheticFontBuilder.WriteUInt16(buf, 1000); // count: absurdly large, does not fit + SyntheticFontBuilder.WriteUInt16(buf, 6); // stringOffset + var data = buf.ToArray(); + + // Act: parse the malformed name table + var name = NameTable.Parse(data, 0, data.Length); + + // Assert: the whole table is treated as absent (the header itself is malformed) + Assert.Same(NameTable.Empty, name); + } + + /// + /// Proves that NameTable Parse RecordStringOutOfBounds SkipsRecord. + /// + [Fact] + public void NameTable_Parse_RecordStringOutOfBounds_SkipsRecord() + { + // Arrange: hand-build a name table with one well-formed record and one record whose + // string bytes would fall outside the table's bounds + var buf = new List(); + SyntheticFontBuilder.WriteUInt16(buf, 0); // format + SyntheticFontBuilder.WriteUInt16(buf, 2); // count + SyntheticFontBuilder.WriteUInt16(buf, 6 + 2 * 12); // stringOffset + + // Record 0: well-formed, resolves "OK" + SyntheticFontBuilder.WriteUInt16(buf, 3); // platformID + SyntheticFontBuilder.WriteUInt16(buf, 1); // encodingID + SyntheticFontBuilder.WriteUInt16(buf, 0x0409); // languageID + SyntheticFontBuilder.WriteUInt16(buf, 1); // nameID + SyntheticFontBuilder.WriteUInt16(buf, 4); // length ("OK" as UTF-16BE = 4 bytes) + SyntheticFontBuilder.WriteUInt16(buf, 0); // offset + + // Record 1: malformed - offset/length reaches far beyond the table's bounds + SyntheticFontBuilder.WriteUInt16(buf, 3); // platformID + SyntheticFontBuilder.WriteUInt16(buf, 1); // encodingID + SyntheticFontBuilder.WriteUInt16(buf, 0x0409); // languageID + SyntheticFontBuilder.WriteUInt16(buf, 4); // nameID (full name) + SyntheticFontBuilder.WriteUInt16(buf, 0xFFFF); // length: absurdly large + SyntheticFontBuilder.WriteUInt16(buf, 0xFFFF); // offset: absurdly large + + buf.AddRange(System.Text.Encoding.BigEndianUnicode.GetBytes("OK")); + var data = buf.ToArray(); + + // Act: parse the table containing one well-formed and one malformed record + var name = NameTable.Parse(data, 0, data.Length); + + // Assert: the well-formed record still resolves, and the malformed record is skipped + // (not resolved, and does not cause the whole table to be discarded) + Assert.Equal("OK", name.FamilyName); + Assert.Null(name.FullName); + } + + /// + /// Proves that NameTable Parse MultipleWindowsLanguages PrefersEnUs. + /// + [Fact] + public void NameTable_Parse_MultipleWindowsLanguages_PrefersEnUs() + { + // Arrange: build a name table with two Windows records for the same nameID, in different + // languages, en-US listed second + var table = SyntheticFontBuilder.Name( + [ + new SyntheticFontBuilder.NameRecord(3, 1, 0x0407, 1, "German Family"), // de-DE + new SyntheticFontBuilder.NameRecord(3, 1, 0x0409, 1, "US English Family"), // en-US + ]); + + // Act: parse the name table + var name = NameTable.Parse(table, 0, table.Length); + + // Assert: the en-US record is preferred over the other-language record + Assert.Equal("US English Family", name.FamilyName); + } + + /// + /// Proves that NameTable Parse UnsupportedPlatformEncoding IgnoresRecord. + /// + [Fact] + public void NameTable_Parse_UnsupportedPlatformEncoding_IgnoresRecord() + { + // Arrange: build a name table with only a record under an unsupported platform/encoding + // pair (platform 0, "Unicode direct" - out of scope per this class's documented contract) + var table = SyntheticFontBuilder.Name( + [ + new SyntheticFontBuilder.NameRecord(0, 3, 0, 1, "Unicode Direct Family"), + ]); + + // Act: parse the name table + var name = NameTable.Parse(table, 0, table.Length); + + // Assert: the unsupported platform/encoding record is ignored entirely + Assert.Null(name.FamilyName); + } + + /// + /// Proves that NameTable Empty AllFieldsNull. + /// + [Fact] + public void NameTable_Empty_AllFieldsNull() + { + // Arrange/Act: use the empty name table singleton + // Assert: every field is null + Assert.Null(NameTable.Empty.FamilyName); + Assert.Null(NameTable.Empty.SubfamilyName); + Assert.Null(NameTable.Empty.FullName); + Assert.Null(NameTable.Empty.PostScriptName); + } +} diff --git a/test/DemaConsulting.CanvasNet.Tests/Fonts/SfntContainerTests.cs b/test/DemaConsulting.CanvasNet.Tests/Fonts/SfntContainerTests.cs index 6df6551b..93e08763 100644 --- a/test/DemaConsulting.CanvasNet.Tests/Fonts/SfntContainerTests.cs +++ b/test/DemaConsulting.CanvasNet.Tests/Fonts/SfntContainerTests.cs @@ -87,7 +87,8 @@ public void SfntContainer_Parse_TruncatedTableDirectory_ThrowsInvalidDataExcepti [Fact] public void SfntContainer_Parse_OttoVersion_ThrowsInvalidDataException() { - // Arrange: build a font with the unsupported 'OTTO' (CFF-flavored) sfnt version tag + // Arrange: build a font with the 'OTTO' (CFF-flavored) sfnt version tag but no 'CFF ' + // table - structurally inconsistent, and still rejected var data = new SyntheticFontBuilder() .WithSfntVersion(0x4F54544F) // 'OTTO' .AddTable("head", SyntheticFontBuilder.Head(1000, 0)) @@ -98,6 +99,27 @@ public void SfntContainer_Parse_OttoVersion_ThrowsInvalidDataException() Assert.Contains("OTTO", ex.Message, StringComparison.Ordinal); } + /// + /// Proves that SfntContainer Parse OttoVersionWithCffTable Succeeds. + /// + [Fact] + public void SfntContainer_Parse_OttoVersionWithCffTable_Succeeds() + { + // Arrange: build a font with the 'OTTO' sfnt version tag and a 'CFF ' table present + var data = new SyntheticFontBuilder() + .WithSfntVersion(0x4F54544F) // 'OTTO' + .AddTable("head", SyntheticFontBuilder.Head(1000, 0)) + .AddTable("CFF ", [1, 0, 4, 4]) + .Build(); + + // Act: parse the font data + var container = SfntContainer.Parse(data); + + // Assert: parsing succeeds and both tables are exposed + Assert.True(container.TryGetTable("head", out _)); + Assert.True(container.TryGetTable("CFF ", out _)); + } + /// /// Proves that SfntContainer Parse MacTrueVersion Succeeds. /// @@ -259,4 +281,110 @@ public void SfntContainer_ReadF2Dot14_ReturnsExpectedValue(float value) // Assert: the read value matches the original within F2Dot14 precision Assert.Equal(value, result, 3); } + + /// + /// Proves that SfntContainer TryReadTtcHeader WellFormedContainer ReturnsFaceOffsets. + /// + [Fact] + public void SfntContainer_TryReadTtcHeader_WellFormedContainer_ReturnsFaceOffsets() + { + // Arrange: build two independent single-face fonts, then wrap them in a synthetic ttcf + // container + var face0 = BuildMinimalFont(); + var face1 = BuildMinimalFont(); + var ttc = SyntheticFontBuilder.Ttc([face0, face1]); + + // Act: read the ttcf header + var isTtc = SfntContainer.TryReadTtcHeader(ttc, out var faceOffsets); + + // Assert: the container is recognized and both face offsets are reported + Assert.True(isTtc); + Assert.Equal(2, faceOffsets.Count); + Assert.True(faceOffsets[1] > faceOffsets[0]); + } + + /// + /// Proves that SfntContainer TryReadTtcHeader NonTtcFont ReturnsFalse. + /// + [Fact] + public void SfntContainer_TryReadTtcHeader_NonTtcFont_ReturnsFalse() + { + // Arrange: an ordinary, non-collection SFNT font + var data = BuildMinimalFont(); + + // Act: attempt to read a ttcf header + var isTtc = SfntContainer.TryReadTtcHeader(data, out var faceOffsets); + + // Assert: not recognized as a collection, and no face offsets are reported + Assert.False(isTtc); + Assert.Empty(faceOffsets); + } + + /// + /// Proves that SfntContainer TryReadTtcHeader EachFaceIndependentlyParsable. + /// + [Fact] + public void SfntContainer_TryReadTtcHeader_EachFaceIndependentlyParsable() + { + // Arrange: wrap two single-face fonts in a synthetic ttcf container + var face0 = BuildMinimalFont(); + var face1 = BuildMinimalFont(); + var ttc = SyntheticFontBuilder.Ttc([face0, face1]); + Assert.True(SfntContainer.TryReadTtcHeader(ttc, out var faceOffsets)); + + // Act: parse each face independently via its own offset table start + var container0 = SfntContainer.Parse(ttc, faceOffsets[0]); + var container1 = SfntContainer.Parse(ttc, faceOffsets[1]); + + // Assert: both faces' tables are independently resolvable + Assert.True(container0.TryGetTable("head", out _)); + Assert.True(container1.TryGetTable("head", out _)); + } + + /// + /// Proves that SfntContainer TryReadTtcHeader TruncatedHeader ThrowsInvalidDataException. + /// + [Fact] + public void SfntContainer_TryReadTtcHeader_TruncatedHeader_ThrowsInvalidDataException() + { + // Arrange: only the 4-byte 'ttcf' tag, missing the rest of the fixed 12-byte prefix + byte[] data = [0x74, 0x74, 0x63, 0x66]; + + // Act/Assert: reading the truncated header throws + Assert.Throws(() => SfntContainer.TryReadTtcHeader(data, out _)); + } + + /// + /// Proves that SfntContainer TryReadTtcHeader ZeroFonts ThrowsInvalidDataException. + /// + [Fact] + public void SfntContainer_TryReadTtcHeader_ZeroFonts_ThrowsInvalidDataException() + { + // Arrange: a ttcf header declaring zero faces + var buf = new List(); + SyntheticFontBuilder.WriteUInt32(buf, 0x74746366); // 'ttcf' + SyntheticFontBuilder.WriteUInt16(buf, 1); + SyntheticFontBuilder.WriteUInt16(buf, 0); + SyntheticFontBuilder.WriteUInt32(buf, 0); // numFonts = 0 + + // Act/Assert: reading the header throws + Assert.Throws(() => SfntContainer.TryReadTtcHeader([.. buf], out _)); + } + + /// + /// Proves that SfntContainer TryReadTtcHeader TruncatedFaceOffsetTable ThrowsInvalidDataException. + /// + [Fact] + public void SfntContainer_TryReadTtcHeader_TruncatedFaceOffsetTable_ThrowsInvalidDataException() + { + // Arrange: a ttcf header declaring 2 faces but supplying no face offset table at all + var buf = new List(); + SyntheticFontBuilder.WriteUInt32(buf, 0x74746366); // 'ttcf' + SyntheticFontBuilder.WriteUInt16(buf, 1); + SyntheticFontBuilder.WriteUInt16(buf, 0); + SyntheticFontBuilder.WriteUInt32(buf, 2); // numFonts = 2, but no offsets follow + + // Act/Assert: reading the header throws + Assert.Throws(() => SfntContainer.TryReadTtcHeader([.. buf], out _)); + } } diff --git a/test/DemaConsulting.CanvasNet.Tests/Fonts/StyleTableTests.cs b/test/DemaConsulting.CanvasNet.Tests/Fonts/StyleTableTests.cs new file mode 100644 index 00000000..eeccc33e --- /dev/null +++ b/test/DemaConsulting.CanvasNet.Tests/Fonts/StyleTableTests.cs @@ -0,0 +1,219 @@ +// 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 macStyle fsSelection usWeightClass isFixedPitch italicAngle +using DemaConsulting.CanvasNet.Fonts; +using DemaConsulting.CanvasNet.Tests.TestSupport; + +namespace DemaConsulting.CanvasNet.Tests.Fonts; + +/// +/// Unit tests for . +/// +public class StyleTableTests +{ + /// + /// Proves that StyleTable Parse Os2FsSelectionBoldBit IsBoldTrue. + /// + [Fact] + public void StyleTable_Parse_Os2FsSelectionBoldBit_IsBoldTrue() + { + // Arrange: build an OS/2 table with only the BOLD fsSelection bit set + var os2 = SyntheticFontBuilder.Os2(usWeightClass: 400, fsSelection: 0x20); + + // Act: parse the style table + var style = ParseOs2Only(os2); + + // Assert: IsBold is true, and italic is unaffected + Assert.True(style.IsBold); + Assert.False(style.IsItalic); + } + + /// + /// Proves that StyleTable Parse Os2WeightClassHigh IsBoldTrue. + /// + [Fact] + public void StyleTable_Parse_Os2WeightClassHigh_IsBoldTrue() + { + // Arrange: build an OS/2 table with a heavy usWeightClass (700) and no fsSelection bits + var os2 = SyntheticFontBuilder.Os2(usWeightClass: 700, fsSelection: 0); + + // Act: parse the style table + var style = ParseOs2Only(os2); + + // Assert: IsBold is true purely from the weight class threshold + Assert.True(style.IsBold); + } + + /// + /// Proves that StyleTable Parse Os2WeightClassBelowThreshold IsBoldFalse. + /// + [Fact] + public void StyleTable_Parse_Os2WeightClassBelowThreshold_IsBoldFalse() + { + // Arrange: build an OS/2 table with a regular usWeightClass (400) and no fsSelection bits + var os2 = SyntheticFontBuilder.Os2(usWeightClass: 400, fsSelection: 0); + + // Act: parse the style table + var style = ParseOs2Only(os2); + + // Assert: IsBold is false + Assert.False(style.IsBold); + } + + /// + /// Proves that StyleTable Parse Os2FsSelectionItalicBit IsItalicTrue. + /// + [Fact] + public void StyleTable_Parse_Os2FsSelectionItalicBit_IsItalicTrue() + { + // Arrange: build an OS/2 table with only the ITALIC fsSelection bit set + var os2 = SyntheticFontBuilder.Os2(usWeightClass: 400, fsSelection: 0x1); + + // Act: parse the style table + var style = ParseOs2Only(os2); + + // Assert: IsItalic is true, and bold is unaffected + Assert.True(style.IsItalic); + Assert.False(style.IsBold); + } + + /// + /// Proves that StyleTable Parse PostItalicAngleNonZero IsItalicTrue. + /// + [Fact] + public void StyleTable_Parse_PostItalicAngleNonZero_IsItalicTrue() + { + // Arrange: build a post table with a nonzero italicAngle and no OS/2 table at all + var post = SyntheticFontBuilder.Post(isFixedPitch: 0, italicAngle: -12.0); + + // Act: parse the style table (no OS/2, only post) + var style = StyleTable.Parse(post, macStyle: 0, os2: null, post: (0, post.Length)); + + // Assert: IsItalic is true purely from the nonzero italic angle + Assert.True(style.IsItalic); + } + + /// + /// Proves that StyleTable Parse Os2Absent FallsBackToMacStyleBits. + /// + [Fact] + public void StyleTable_Parse_Os2Absent_FallsBackToMacStyleBits() + { + // Arrange: no OS/2 or post table at all, only head.macStyle bits set + const int macStyle = 0x1 | 0x2; // Bold | Italic + + // Act: parse the style table + var style = StyleTable.Parse([], macStyle, os2: null, post: null); + + // Assert: both IsBold and IsItalic are true, derived purely from macStyle + Assert.True(style.IsBold); + Assert.True(style.IsItalic); + } + + /// + /// Proves that StyleTable Parse MacStyleAlwaysContributes EvenWhenOs2Present. + /// + [Fact] + public void StyleTable_Parse_MacStyleAlwaysContributes_EvenWhenOs2Present() + { + // Arrange: an OS/2 table that itself indicates neither bold nor italic, but macStyle's + // Bold bit is set + var os2 = SyntheticFontBuilder.Os2(usWeightClass: 400, fsSelection: 0); + + // Act: parse the style table with both OS/2 present and macStyle's Bold bit set + var style = StyleTable.Parse(os2, macStyle: 0x1, os2: (0, os2.Length), post: null); + + // Assert: IsBold is true - macStyle contributes even though OS/2 is present + Assert.True(style.IsBold); + } + + /// + /// Proves that StyleTable Parse PostIsFixedPitchNonZero IsFixedPitchTrue. + /// + [Fact] + public void StyleTable_Parse_PostIsFixedPitchNonZero_IsFixedPitchTrue() + { + // Arrange: build a post table with isFixedPitch set + var post = SyntheticFontBuilder.Post(isFixedPitch: 1); + + // Act: parse the style table + var style = StyleTable.Parse(post, macStyle: 0, os2: null, post: (0, post.Length)); + + // Assert: IsFixedPitch is true + Assert.True(style.IsFixedPitch); + } + + /// + /// Proves that StyleTable Parse PostAbsent IsFixedPitchFalse. + /// + [Fact] + public void StyleTable_Parse_PostAbsent_IsFixedPitchFalse() + { + // Arrange/Act: parse the style table with no post table at all + var style = StyleTable.Parse([], macStyle: 0, os2: null, post: null); + + // Assert: IsFixedPitch is false when post is entirely absent + Assert.False(style.IsFixedPitch); + } + + /// + /// Proves that StyleTable Parse Os2TooShortForFsSelection IgnoresFsSelectionFields. + /// + [Fact] + public void StyleTable_Parse_Os2TooShortForFsSelection_IgnoresFsSelectionFields() + { + // Arrange: a present OS/2 table truncated to only 6 bytes - long enough for + // usWeightClass but too short to reach fsSelection at all + var os2 = SyntheticFontBuilder.Os2(usWeightClass: 400, fsSelection: 0x21)[..6]; + + // Act: parse the style table + var style = StyleTable.Parse(os2, macStyle: 0, os2: (0, os2.Length), post: null); + + // Assert: the too-short table's fsSelection bits are never read (bold/italic both + // false), even though a full-length table with this same fsSelection value would set + // both + Assert.False(style.IsBold); + Assert.False(style.IsItalic); + } + + /// + /// Proves that StyleTable Parse Os2TooShortForWeightClass IgnoresWeightClassField. + /// + [Fact] + public void StyleTable_Parse_Os2TooShortForWeightClass_IgnoresWeightClassField() + { + // Arrange: a present OS/2 table truncated to fewer than 6 bytes - too short even for + // usWeightClass + var os2 = SyntheticFontBuilder.Os2(usWeightClass: 900, fsSelection: 0)[..4]; + + // Act: parse the style table + var style = StyleTable.Parse(os2, macStyle: 0, os2: (0, os2.Length), post: null); + + // Assert: the too-short table's usWeightClass is never read + Assert.False(style.IsBold); + } + + /// + /// Proves that StyleTable Parse PostTooShortForIsFixedPitch IsFixedPitchFalse. + /// + [Fact] + public void StyleTable_Parse_PostTooShortForIsFixedPitch_IsFixedPitchFalse() + { + // Arrange: a present post table truncated before isFixedPitch (offset 12, needs 16 bytes) + var post = SyntheticFontBuilder.Post(isFixedPitch: 1)[..10]; + + // Act: parse the style table + var style = StyleTable.Parse(post, macStyle: 0, os2: null, post: (0, post.Length)); + + // Assert: the too-short table's isFixedPitch is never read + Assert.False(style.IsFixedPitch); + } + + /// + /// Parses a style table backed only by the given OS/2 table bytes at offset 0, with no + /// macStyle bits and no post table - a convenience shared by the OS/2-focused tests above. + /// + private static StyleTable ParseOs2Only(byte[] os2) => + StyleTable.Parse(os2, macStyle: 0, os2: (0, os2.Length), post: null); +} diff --git a/test/DemaConsulting.CanvasNet.Tests/Fonts/SystemFontCatalogRealDiscoveryIntegrationTests.cs b/test/DemaConsulting.CanvasNet.Tests/Fonts/SystemFontCatalogRealDiscoveryIntegrationTests.cs new file mode 100644 index 00000000..ae88febe --- /dev/null +++ b/test/DemaConsulting.CanvasNet.Tests/Fonts/SystemFontCatalogRealDiscoveryIntegrationTests.cs @@ -0,0 +1,86 @@ +// cspell:ignore Neue Segoe Xyzzy +using DemaConsulting.CanvasNet.Fonts; + +namespace DemaConsulting.CanvasNet.Tests.Fonts; + +/// +/// Real-filesystem integration tests for : unconditional on +/// every CI runner (Windows/Linux/macOS), never skipped, but "conditionally strengthen the +/// assertion" - each test asserts a property that is always true regardless of how many (or +/// how few) fonts happen to be installed on the machine actually running the test. +/// +public class SystemFontCatalogRealDiscoveryIntegrationTests +{ + /// + /// Proves that scanning this operating system's real, well-known font directories + /// completes without throwing, and that every discovered entry's + /// genuinely exists on disk - regardless of whether the catalog turns out to be empty (for + /// example a minimal container image with no fonts installed) or populated. + /// + [Fact] + public void SystemFontCatalog_RealFilesystem_ScanKnownOsDirectories_DiscoversFontsOrIsGracefullyEmpty() + { + // Act: this is the real, unmocked SystemFontCatalog.Fonts scan + var fonts = SystemFontCatalog.Fonts; + + // Assert: the scan completed (no exception), and every reported entry satisfies the + // documented invariants (CanvasNet-Fonts-SystemFontCatalog-DirectoryScanDiscovery): the + // path genuinely exists on disk, the family name is non-empty, and the path has one of + // the three supported font-file extensions. + Assert.NotNull(fonts); + foreach (var font in fonts) + { + Assert.True(File.Exists(font.FilePath), $"Reported font path '{font.FilePath}' does not exist."); + Assert.False(string.IsNullOrEmpty(font.FamilyName), $"Reported font '{font.FilePath}' has an empty family name."); + var extension = Path.GetExtension(font.FilePath); + Assert.True( + extension.Equals(".ttf", StringComparison.OrdinalIgnoreCase) + || extension.Equals(".ttc", StringComparison.OrdinalIgnoreCase) + || extension.Equals(".otf", StringComparison.OrdinalIgnoreCase), + $"Reported font path '{font.FilePath}' does not end in .ttf, .ttc, or .otf."); + } + } + + /// + /// Proves the strongest property this method can assert without depending on what is + /// actually installed: if a documented platform-typical family (Arial on Windows, + /// Liberation Sans or DejaVu Sans on Linux, Helvetica Neue on macOS) is present in + /// , then + /// resolves to that real system font rather than requiring the bundled fallback; if none + /// of those families are installed, the weaker (always-true) property is asserted instead: + /// the OS-only catalog match for an unrelated, made-up family name is . + /// + [Fact] + public void SystemFontCatalog_RealFilesystem_KnownPlatformFontFamily_ResolvesToRealFontWhenInstalled() + { + // The exact same well-known generic-sans family list FindBestMatch itself searches (see + // SystemFontCatalog's own GenericSansFamilies), so "installed" here is always consistent + // with what a made-up-name query would actually resolve to. + string[] platformTypicalSansFamilies = + [ + "Arial", "Helvetica", "Liberation Sans", "DejaVu Sans", "Nimbus Sans", "Segoe UI", + ]; + + var installedFamily = platformTypicalSansFamilies.FirstOrDefault( + family => SystemFontCatalog.Fonts.Any( + f => string.Equals(f.FamilyName, family, StringComparison.OrdinalIgnoreCase))); + + if (installedFamily is not null) + { + // Strong assertion: a real system font resolves for its own exact family name + var match = SystemFontCatalog.FindBestMatch( + installedFamily, bold: false, italic: false, serif: false, fixedPitch: false); + + Assert.NotNull(match); + Assert.Equal(installedFamily, match.Value.FamilyName, ignoreCase: true); + } + else + { + // Weaker, always-true assertion: nothing in the OS-only catalog matches a made-up name + var match = SystemFontCatalog.FindBestMatch( + "Totally-Unlikely-Font-Name-Xyzzy", bold: false, italic: false, serif: false, fixedPitch: false); + + Assert.Null(match); + } + } +} diff --git a/test/DemaConsulting.CanvasNet.Tests/Fonts/SystemFontCatalogTests.cs b/test/DemaConsulting.CanvasNet.Tests/Fonts/SystemFontCatalogTests.cs new file mode 100644 index 00000000..24df0517 --- /dev/null +++ b/test/DemaConsulting.CanvasNet.Tests/Fonts/SystemFontCatalogTests.cs @@ -0,0 +1,321 @@ +// cspell:ignore Nimbus Dejavu Consolas canvasnet +using DemaConsulting.CanvasNet.Fonts; +using DemaConsulting.CanvasNet.Tests.TestSupport; + +namespace DemaConsulting.CanvasNet.Tests.Fonts; + +/// +/// Unit tests for . The scoring tests below (tier-1 exact +/// family match, tier-2 generic-bucket fallback, and no-match cases) exercise +/// over a fully synthetic, injected +/// candidate list, so they never depend on what happens to be installed on the machine +/// running these tests. The scan-tolerance tests exercise +/// against controlled temporary +/// directories, likewise never depending on the real host font directories. +/// +public class SystemFontCatalogTests +{ + /// + /// Proves that a subdirectory symlink (or junction) that cycles back to an ancestor + /// directory does not cause font scanning to hang, and that a sibling real font file is + /// still discovered. Skips itself (rather than failing) when the current process lacks + /// the privilege to create a directory symlink, since that privilege is environment- + /// dependent (for example, Windows requires Developer Mode or an elevated process). + /// + [Fact] + public void SystemFontCatalog_Fonts_SymlinkCycleInScanDirectory_DoesNotHangAndSiblingFontStillFound() + { + // Arrange: root/Valid.ttf plus root/loop -> root (a symlinked subdirectory cycling back + // to its own ancestor). + var root = Directory.CreateDirectory( + Path.Combine(Path.GetTempPath(), "canvasnet-test-symlink-cycle-" + Guid.NewGuid())).FullName; + + try + { + File.WriteAllBytes(Path.Combine(root, "Valid.ttf"), BuildMinimalValidFont()); + + var loopPath = Path.Combine(root, "loop"); + try + { + Directory.CreateSymbolicLink(loopPath, root); + } + catch (Exception ex) when (ex is UnauthorizedAccessException or IOException) + { + // This process/environment cannot create directory symlinks (for example, + // Windows without Developer Mode or elevation) - nothing more to verify here. + return; + } + + // Act + var result = SystemFontCatalog.BuildCatalogFromRoots([root]); + + // Assert: the scan terminated (it did not hang following the cycle forever) and the + // real font sibling to the symlink was still discovered. + var entry = Assert.Single(result); + Assert.Equal(Path.Combine(root, "Valid.ttf"), entry.FilePath); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + + /// + /// Proves that an exact (case-insensitive) family-name match is found and preferred over + /// any generic-bucket candidate, even when the case of the query and the catalog entry + /// differ. + /// + [Fact] + public void SystemFontCatalog_FindBestMatch_ExactFamilyNameCaseInsensitive_ReturnsMatch() + { + // Arrange: a synthetic catalog with one exact-family match and one unrelated entry + SystemFontInfo[] candidates = + [ + new("Some Other Family", "Regular", false, false, false, "/other.ttf", 0), + new("My Custom Font", "Bold", true, false, false, "/custom-bold.ttf", 0), + ]; + + // Act: query using a different case than the catalog entry + var match = SystemFontCatalog.FindBestMatchCore( + candidates, "my custom font", bold: true, italic: false, serif: false, fixedPitch: false); + + // Assert: the exact family match (case-insensitive) is returned + Assert.NotNull(match); + Assert.Equal("/custom-bold.ttf", match.Value.FilePath); + } + + /// + /// Proves that, among multiple style variants of the same exactly-matched family, the + /// variant minimizing mismatched bold/italic flags is preferred, even when no variant is + /// an exact style match. + /// + [Fact] + public void SystemFontCatalog_FindBestMatch_ExactFamilyStyleMismatch_PrefersClosestStyleVariant() + { + // Arrange: only Regular and Bold variants exist; BoldItalic is requested + SystemFontInfo[] candidates = + [ + new("My Custom Font", "Regular", false, false, false, "/custom-regular.ttf", 0), + new("My Custom Font", "Bold", true, false, false, "/custom-bold.ttf", 0), + ]; + + // Act: request Bold+Italic (Bold mismatches by 1 flag, Regular mismatches by 2) + var match = SystemFontCatalog.FindBestMatchCore( + candidates, "My Custom Font", bold: true, italic: true, serif: false, fixedPitch: false); + + // Assert: the closer (Bold, 1-flag-mismatch) variant wins over Regular (2-flag-mismatch) + Assert.NotNull(match); + Assert.Equal("/custom-bold.ttf", match.Value.FilePath); + } + + /// + /// Proves that, when no exact family match exists, the generic sans-serif bucket's + /// well-known family list is searched and the first one actually present is returned. + /// + [Fact] + public void SystemFontCatalog_FindBestMatch_NoExactFamily_FallsBackToGenericSansBucket() + { + // Arrange: no family matches the hint, but "Arial" (first in the generic sans list) exists + SystemFontInfo[] candidates = + [ + new("Some Unrelated Family", "Regular", false, false, false, "/unrelated.ttf", 0), + new("Arial", "Regular", false, false, false, "/arial.ttf", 0), + ]; + + // Act + var match = SystemFontCatalog.FindBestMatchCore( + candidates, "Totally Unknown Font", bold: false, italic: false, serif: false, fixedPitch: false); + + // Assert + Assert.NotNull(match); + Assert.Equal("/arial.ttf", match.Value.FilePath); + } + + /// + /// Proves that, when serif is requested and no exact family match exists, the + /// generic serif bucket's well-known family list is searched. + /// + [Fact] + public void SystemFontCatalog_FindBestMatch_NoExactFamily_FallsBackToGenericSerifBucket() + { + // Arrange + SystemFontInfo[] candidates = + [ + new("Some Unrelated Family", "Regular", false, false, false, "/unrelated.ttf", 0), + new("Times New Roman", "Regular", false, false, false, "/times.ttf", 0), + ]; + + // Act + var match = SystemFontCatalog.FindBestMatchCore( + candidates, "Totally Unknown Font", bold: false, italic: false, serif: true, fixedPitch: false); + + // Assert + Assert.NotNull(match); + Assert.Equal("/times.ttf", match.Value.FilePath); + } + + /// + /// Proves that, when fixedPitch is requested and no exact family match exists, the + /// generic monospace bucket's well-known family list is searched. + /// + [Fact] + public void SystemFontCatalog_FindBestMatch_NoExactFamily_FallsBackToGenericFixedPitchBucket() + { + // Arrange + SystemFontInfo[] candidates = + [ + new("Some Unrelated Family", "Regular", false, false, false, "/unrelated.ttf", 0), + new("Courier New", "Regular", false, false, true, "/courier.ttf", 0), + ]; + + // Act + var match = SystemFontCatalog.FindBestMatchCore( + candidates, "Totally Unknown Font", bold: false, italic: false, serif: false, fixedPitch: true); + + // Assert + Assert.NotNull(match); + Assert.Equal("/courier.ttf", match.Value.FilePath); + } + + /// Proves that an empty candidate list always returns . + [Fact] + public void SystemFontCatalog_FindBestMatch_EmptyCandidateList_ReturnsNull() + { + // Act + var match = SystemFontCatalog.FindBestMatchCore( + [], "Anything", bold: false, italic: false, serif: false, fixedPitch: false); + + // Assert + Assert.Null(match); + } + + /// + /// Proves that a non-empty candidate list with no exact family match and no generic-bucket + /// match either (none of the well-known bucket family names are present) returns + /// . + /// + [Fact] + public void SystemFontCatalog_FindBestMatch_NoFamilyAndNoBucketMatch_ReturnsNull() + { + // Arrange: an entry whose family matches neither the hint nor any well-known bucket name + SystemFontInfo[] candidates = + [ + new("Some Wholly Unrelated Family", "Regular", false, false, false, "/unrelated.ttf", 0), + ]; + + // Act + var match = SystemFontCatalog.FindBestMatchCore( + candidates, "Totally Unknown Font", bold: false, italic: false, serif: false, fixedPitch: false); + + // Assert + Assert.Null(match); + } + + /// + /// Proves that scanning a set of roots including a nonexistent directory does not throw, + /// and that a sibling, existing root's fonts are still discovered normally. + /// + [Fact] + public void SystemFontCatalog_Fonts_MissingScanDirectory_SkippedWithoutThrowing() + { + // Arrange: one root that does not exist, and one real temp directory with a valid font + var missingRoot = Path.Combine(Path.GetTempPath(), "canvasnet-test-missing-" + Guid.NewGuid()); + var validRoot = Directory.CreateDirectory( + Path.Combine(Path.GetTempPath(), "canvasnet-test-valid-" + Guid.NewGuid())).FullName; + + try + { + File.WriteAllBytes(Path.Combine(validRoot, "Valid.ttf"), BuildMinimalValidFont()); + + // Act + var result = SystemFontCatalog.BuildCatalogFromRoots([missingRoot, validRoot]); + + // Assert: no exception, and the valid root's font was still discovered + Assert.Contains(result, f => f.FilePath == Path.Combine(validRoot, "Valid.ttf")); + } + finally + { + Directory.Delete(validRoot, recursive: true); + } + } + + /// + /// Proves that an unparseable candidate file (garbage bytes with a recognized font + /// extension) is silently skipped, while a sibling valid font file in the same directory + /// is still discovered. + /// + [Fact] + public void SystemFontCatalog_Fonts_UnparseableCandidateFile_SkippedWithoutThrowing() + { + // Arrange: a directory containing one garbage ".ttf" and one genuinely valid ".ttf" + var root = Directory.CreateDirectory( + Path.Combine(Path.GetTempPath(), "canvasnet-test-unparseable-" + Guid.NewGuid())).FullName; + + try + { + File.WriteAllBytes(Path.Combine(root, "Garbage.ttf"), [0x00, 0x01, 0x02, 0x03, 0x04]); + File.WriteAllBytes(Path.Combine(root, "Valid.ttf"), BuildMinimalValidFont()); + + // Act + var result = SystemFontCatalog.BuildCatalogFromRoots([root]); + + // Assert: only the valid font was discovered; the garbage file was silently skipped + var entry = Assert.Single(result); + Assert.Equal(Path.Combine(root, "Valid.ttf"), entry.FilePath); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + + /// + /// Proves that is built exactly once per process and + /// the same cached instance is returned on every subsequent access. + /// + [Fact] + public void SystemFontCatalog_Fonts_IsLazilyBuiltOnceAndCachedForProcessLifetime() + { + // Act: access Fonts twice + var first = SystemFontCatalog.Fonts; + var second = SystemFontCatalog.Fonts; + + // Assert: the exact same cached instance is returned both times + Assert.Same(first, second); + } + + /// + /// Proves that throws + /// for a deliberately-wrong bundled file name, + /// exercising the defensive packaging-integrity guard without deleting any real embedded + /// resource. + /// + [Fact] + public void SystemFontCatalog_LoadBundledFallback_MissingEmbeddedResource_ThrowsInvalidOperationException() + { + Assert.Throws( + () => SystemFontCatalog.LoadBundledFallbackCore("NoSuchBundledFont-Regular.ttf")); + } + + /// + /// Builds a minimal, complete, well-formed synthetic font's raw bytes (mirroring + /// TrueTypeFontTests.BuildWellFormedFont), suitable for writing to a temporary file + /// and loading via -backed catalog-scanning code. + /// + private static byte[] BuildMinimalValidFont() + { + var glyph = SyntheticFontBuilder.SimpleGlyph( + [ + [(0, 0, true), (10, 0, true), (10, 10, true), (0, 10, true)] + ]); + + return 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([0, glyph.Length], longFormat: false)) + .AddTable("glyf", glyph) + .Build(); + } +} diff --git a/test/DemaConsulting.CanvasNet.Tests/Fonts/TrueTypeFontRealFontIntegrationTests.cs b/test/DemaConsulting.CanvasNet.Tests/Fonts/TrueTypeFontRealFontIntegrationTests.cs index dd50bad9..2393b8bf 100644 --- a/test/DemaConsulting.CanvasNet.Tests/Fonts/TrueTypeFontRealFontIntegrationTests.cs +++ b/test/DemaConsulting.CanvasNet.Tests/Fonts/TrueTypeFontRealFontIntegrationTests.cs @@ -18,6 +18,18 @@ namespace DemaConsulting.CanvasNet.Tests.Fonts; /// production font data, complementing the hand-rolled synthetic fixtures every other /// Fonts test builds via . /// +/// +/// The expected // +/// / values asserted +/// by the name/style metadata tests below were confirmed directly against these exact fixture +/// files using a throwaway fonttools (Python) inspection script - not guessed - which +/// read name.getName(nameID, platformID, encodingID, languageID) for IDs 1/2/4/6/16/17 +/// under both (3, 1, 0x409) and (1, 0, 0), and printed +/// OS/2.fsSelection/usWeightClass, head.macStyle, and +/// post.isFixedPitch/italicAngle. All three fixtures (and both faces of the +/// .ttc) were confirmed to have no nameID 16/17 records, and to be +/// Regular/non-bold/non-italic/non-fixed-pitch. +/// public class TrueTypeFontRealFontIntegrationTests { /// @@ -29,6 +41,19 @@ public class TrueTypeFontRealFontIntegrationTests /// private static string FontPath => System.IO.Path.Join(AppContext.BaseDirectory, "FontFixtures", "OpenSans-Regular.ttf"); + /// + /// The path to the real "Source Sans 3" CFF/OpenType (.otf) font (see + /// FontFixtures\README.md for provenance and SIL OFL 1.1 licensing). + /// + private static string OtfFontPath => System.IO.Path.Join(AppContext.BaseDirectory, "FontFixtures", "SourceSans3-Regular.otf"); + + /// + /// The path to the synthetic ttcf container locally assembled from + /// (face 0) and (face 1) - see + /// FontFixtures\README.md for how it was built and its licensing basis. + /// + private static string TtcFontPath => System.IO.Path.Join(AppContext.BaseDirectory, "FontFixtures", "OpenSans-SourceSans3.ttc"); + /// /// Proves that a real glyph loaded from a real production font, when transformed into /// pixel space and filled onto a via , @@ -43,6 +68,162 @@ public void TrueTypeFont_RealOpenSansFont_RendersGlyphOutlineAsVisibleInk() var glyphIndex = font.GetGlyphIndex('A'); Assert.NotEqual(0, glyphIndex); // must be a real mapped glyph, not .notdef + AssertGlyphRendersAsVisibleInk(font, glyphIndex); + } + + /// + /// Proves that a real glyph decoded from a real CFF/OpenType (.otf) production + /// font's Type 2 charstring bytecode, when transformed into pixel space and filled onto a + /// , produces actual visible ink - proving / + /// genuinely decode real-world CFF outline data, + /// not just synthetic fixtures. The glyph ('H') is deliberately chosen from among a set of + /// straight-line-only Latin letters verified not to require the Type 2 flex escape + /// operators, which are outside this library's supported operator set (see + /// FontFixtures\README.md for the verification detail). + /// + [Fact] + public void TrueTypeFont_RealSourceSans3OtfFont_RendersCffGlyphOutlineAsVisibleInk() + { + // Arrange: load the real CFF/OpenType font from disk and resolve capital 'H' + var font = TrueTypeFont.Load(OtfFontPath); + var glyphIndex = font.GetGlyphIndex('H'); + Assert.NotEqual(0, glyphIndex); // must be a real mapped glyph, not .notdef + + AssertGlyphRendersAsVisibleInk(font, glyphIndex); + } + + /// + /// Proves that a locally-assembled ttcf TrueType Collection fixture's second face + /// (the CFF/OpenType "Source Sans 3" font) is independently loadable and decodable via + /// , with its glyph outline rendering as real + /// visible ink exactly as it does when loaded as a standalone .otf file. + /// + [Fact] + public void TrueTypeFont_RealTtcContainer_FaceOne_RendersCffGlyphOutlineAsVisibleInk() + { + // Arrange: the ttcf container's face count and explicit face-1 selection + var faceCount = TrueTypeFont.GetFaceCount(TtcFontPath); + Assert.Equal(2, faceCount); + + var font = TrueTypeFont.Load(TtcFontPath, 1); + var glyphIndex = font.GetGlyphIndex('H'); + Assert.NotEqual(0, glyphIndex); + + AssertGlyphRendersAsVisibleInk(font, glyphIndex); + } + + /// + /// Proves that the locally-assembled ttcf container's first face (the glyf-flavored + /// "Open Sans" font) is independently loadable and decodable via + /// with an explicit face index of + /// 0, with its glyph outline rendering as real visible ink exactly as it does when + /// loaded as a standalone .ttf file - complementing + /// 's + /// coverage of the container's second (CFF-flavored) face, so both faces of the real + /// .ttc fixture are proven independently renderable through the explicit + /// face-index API, not merely the default/no-index overload. + /// + [Fact] + public void TrueTypeFont_RealTtcContainer_FaceZero_RendersGlyfGlyphOutlineAsVisibleInk() + { + // Arrange: the ttcf container's face count and explicit face-0 selection + var faceCount = TrueTypeFont.GetFaceCount(TtcFontPath); + Assert.Equal(2, faceCount); + + var font = TrueTypeFont.Load(TtcFontPath, 0); + var glyphIndex = font.GetGlyphIndex('A'); + Assert.NotEqual(0, glyphIndex); + + AssertGlyphRendersAsVisibleInk(font, glyphIndex); + } + + /// + /// Proves that the real "Open Sans" production font exposes the exact family/subfamily/ + /// full/PostScript name and bold/italic/fixed-pitch style metadata confirmed directly + /// against the fixture via fonttools (see this class's remarks): a font with no + /// nameID 16/17 records (so the 1/2 fallback path resolves), non-bold, non-italic, + /// non-fixed-pitch. + /// + [Fact] + public void TrueTypeFont_RealOpenSansFont_ExposesExpectedNameAndStyleMetadata() + { + // Act: load the real font and resolve its name/style metadata + var font = TrueTypeFont.Load(FontPath); + var info = font.GetNameInfo(); + + // Assert: the exact values confirmed via fonttools inspection of this fixture + Assert.Equal("Open Sans", info.FamilyName); + Assert.Equal("Regular", info.SubfamilyName); + Assert.Equal("Open Sans Regular", info.FullName); + Assert.Equal("OpenSans-Regular", info.PostScriptName); + Assert.False(font.IsBold); + Assert.False(font.IsItalic); + Assert.False(font.IsFixedPitch); + } + + /// + /// Proves that the real "Source Sans 3" CFF/OpenType production font exposes the exact + /// name/style metadata confirmed directly against the fixture via fonttools (see + /// this class's remarks). + /// + [Fact] + public void TrueTypeFont_RealSourceSans3OtfFont_ExposesExpectedNameAndStyleMetadata() + { + // Act: load the real CFF/OpenType font and resolve its name/style metadata + var font = TrueTypeFont.Load(OtfFontPath); + var info = font.GetNameInfo(); + + // Assert: the exact values confirmed via fonttools inspection of this fixture + Assert.Equal("Source Sans 3", info.FamilyName); + Assert.Equal("Regular", info.SubfamilyName); + Assert.Equal("Source Sans 3", info.FullName); + Assert.Equal("SourceSans3-Regular", info.PostScriptName); + Assert.False(font.IsBold); + Assert.False(font.IsItalic); + Assert.False(font.IsFixedPitch); + } + + /// + /// Proves that both faces of the locally-assembled ttcf container independently + /// expose their own real name/style metadata - face 0's "Open Sans" metadata and face 1's + /// "Source Sans 3" metadata are never cross-contaminated, confirming per-face table + /// resolution is correct for name/OS/2/post exactly as it already is + /// for every other table this unit reads. + /// + [Fact] + public void TrueTypeFont_RealTtcContainer_BothFaces_ExposeExpectedNameAndStyleMetadataIndependently() + { + // Act: load both faces and resolve each one's own name/style metadata + var faceZero = TrueTypeFont.Load(TtcFontPath, 0); + var faceZeroInfo = faceZero.GetNameInfo(); + + var faceOne = TrueTypeFont.Load(TtcFontPath, 1); + var faceOneInfo = faceOne.GetNameInfo(); + + // Assert: face 0 matches the standalone Open Sans fixture's metadata + Assert.Equal("Open Sans", faceZeroInfo.FamilyName); + Assert.Equal("OpenSans-Regular", faceZeroInfo.PostScriptName); + Assert.False(faceZero.IsBold); + Assert.False(faceZero.IsItalic); + Assert.False(faceZero.IsFixedPitch); + + // Assert: face 1 matches the standalone Source Sans 3 fixture's metadata, not face 0's + Assert.Equal("Source Sans 3", faceOneInfo.FamilyName); + Assert.Equal("SourceSans3-Regular", faceOneInfo.PostScriptName); + Assert.False(faceOne.IsBold); + Assert.False(faceOne.IsItalic); + Assert.False(faceOne.IsFixedPitch); + } + + /// + /// Extracts 's real outline from , + /// confirms its metrics are sane for a real font, transforms it from font design-unit + /// space into a small pixel-space canvas, fills it via , and + /// asserts that real visible ink was painted within the glyph's own bounding box while the + /// canvas's far corners remain fully transparent background. + /// + private static void AssertGlyphRendersAsVisibleInk(TrueTypeFont font, int glyphIndex) + { // 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); @@ -51,7 +232,7 @@ public void TrueTypeFont_RealOpenSansFont_RendersGlyphOutlineAsVisibleInk() // 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."); + Assert.True(advance > 0, "A real 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 @@ -132,14 +313,20 @@ private static GlyphPath TransformGlyphToPixelSpace(GlyphPath outline, Rect boun builder.QuadraticBezierTo(Map(command.Control1), Map(command.EndPoint)); break; + case PathCommandType.CubicBezierTo: + builder.CubicBezierTo(Map(command.Control1), Map(command.Control2), 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. + // TrueTypeFont glyph outlines only ever contain LineTo, + // QuadraticBezierTo, CubicBezierTo, and Close commands (TrueType glyf + // outlines never emit CubicBezierTo; CFF outlines never emit + // QuadraticBezierTo) - matching GlyfLocaReader's own AppendTransformed + // helper, extended for CFF's cubic segments. throw new InvalidOperationException("Unexpected path command in a glyph outline."); } } diff --git a/test/DemaConsulting.CanvasNet.Tests/Fonts/TrueTypeFontTests.cs b/test/DemaConsulting.CanvasNet.Tests/Fonts/TrueTypeFontTests.cs index 7abd3aa2..228234b1 100644 --- a/test/DemaConsulting.CanvasNet.Tests/Fonts/TrueTypeFontTests.cs +++ b/test/DemaConsulting.CanvasNet.Tests/Fonts/TrueTypeFontTests.cs @@ -2,6 +2,7 @@ // 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; @@ -455,4 +456,951 @@ public void TrueTypeFont_Load_TruncatedStream_ThrowsInvalidDataException() using var ms438 = new MemoryStream(truncated); Assert.Throws(() => TrueTypeFont.Load(ms438)); } + + /// + /// Builds a minimal, well-formed synthetic CFF/OTTO font with a single glyph, glyph 0 + /// being a 10x10 square outline via a Type 2 charstring. + /// + private static byte[] BuildWellFormedCffFont() + { + var cs = new List(); + SyntheticFontBuilder.WriteCharstringNumber(cs, 0); + SyntheticFontBuilder.WriteCharstringNumber(cs, 0); + SyntheticFontBuilder.WriteCharstringOperator(cs, 21); // rmoveto + SyntheticFontBuilder.WriteCharstringNumber(cs, 10); + SyntheticFontBuilder.WriteCharstringNumber(cs, 0); + SyntheticFontBuilder.WriteCharstringNumber(cs, 0); + SyntheticFontBuilder.WriteCharstringNumber(cs, 10); + SyntheticFontBuilder.WriteCharstringNumber(cs, -10); + SyntheticFontBuilder.WriteCharstringNumber(cs, 0); + SyntheticFontBuilder.WriteCharstringOperator(cs, 5); // rlineto + SyntheticFontBuilder.WriteCharstringOperator(cs, 14); // endchar + + var cff = SyntheticFontBuilder.Cff([[.. cs]]); + + return new SyntheticFontBuilder() + .WithSfntVersion(0x4F54544F) // 'OTTO' + .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("CFF ", cff) + .Build(); + } + + /// + /// Proves that TrueTypeFont Load OttoFontWithCffTable Succeeds. + /// + [Fact] + public void TrueTypeFont_Load_OttoFontWithCffTable_Succeeds() + { + // Arrange: build a well-formed synthetic OTTO/CFF font + var data = BuildWellFormedCffFont(); + + // Act: load the font and decode its single glyph's outline + using var ms = new MemoryStream(data); + var font = TrueTypeFont.Load(ms); + var outline = font.GetGlyphOutline(0); + + // Assert: the font loads successfully and the CFF outline is decoded + Assert.Equal(1, font.GlyphCount); + Assert.Single(outline.Subpaths); + Assert.Contains(outline.Subpaths[0].Commands, c => c.Type == PathCommandType.LineTo); + } + + /// + /// Proves that TrueTypeFont Load CffGlyphCountMismatchesMaxp ThrowsInvalidDataException. + /// + [Fact] + public void TrueTypeFont_Load_CffGlyphCountMismatchesMaxp_ThrowsInvalidDataException() + { + // Arrange: build an OTTO/CFF font whose maxp.numGlyphs (2) does not match the CFF + // CharStrings INDEX's actual glyph count (1) + var cs = new List(); + SyntheticFontBuilder.WriteCharstringOperator(cs, 14); // endchar + var cff = SyntheticFontBuilder.Cff([[.. cs]]); + + var data = new SyntheticFontBuilder() + .WithSfntVersion(0x4F54544F) + .AddTable("head", SyntheticFontBuilder.Head(1000, 0)) + .AddTable("maxp", SyntheticFontBuilder.Maxp(2, version: 0x00005000)) + .AddTable("hhea", SyntheticFontBuilder.Hhea(800, -200, 50, 2)) + .AddTable("hmtx", SyntheticFontBuilder.Hmtx([500, 500])) + .AddTable("CFF ", cff) + .Build(); + + // Act/Assert: loading the font with the mismatched glyph count throws + using var ms = new MemoryStream(data); + Assert.Throws(() => TrueTypeFont.Load(ms)); + } + + /// + /// Proves that TrueTypeFont GetFaceCount PlainSfnt ReturnsOne. + /// + [Fact] + public void TrueTypeFont_GetFaceCount_PlainSfnt_ReturnsOne() + { + // Arrange: a well-formed, single-face (non-collection) font + var data = BuildWellFormedFont(); + + // Act: query the face count + using var ms = new MemoryStream(data); + var faceCount = TrueTypeFont.GetFaceCount(ms); + + // Assert: an ordinary SFNT font always reports exactly one face + Assert.Equal(1, faceCount); + } + + /// + /// Proves that TrueTypeFont GetFaceCount TtcContainer ReturnsFaceCount. + /// + [Fact] + public void TrueTypeFont_GetFaceCount_TtcContainer_ReturnsFaceCount() + { + // Arrange: a synthetic 2-face ttcf container wrapping two independent, well-formed fonts + var ttc = SyntheticFontBuilder.Ttc([BuildWellFormedFont(), BuildWellFormedCffFont()]); + + // Act: query the face count + using var ms = new MemoryStream(ttc); + var faceCount = TrueTypeFont.GetFaceCount(ms); + + // Assert: both faces are reported + Assert.Equal(2, faceCount); + } + + /// + /// Proves that TrueTypeFont GetFaceCount Path ReadsFromFile. + /// + [Fact] + public void TrueTypeFont_GetFaceCount_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: query the face count from the file path + var faceCount = TrueTypeFont.GetFaceCount(path); + + // Assert: an ordinary SFNT font always reports exactly one face + Assert.Equal(1, faceCount); + } + finally + { + File.Delete(path); + } + } + + /// + /// Proves that TrueTypeFont Load TtcContainer DefaultsToFaceZero. + /// + [Fact] + public void TrueTypeFont_Load_TtcContainer_DefaultsToFaceZero() + { + // Arrange: a synthetic 2-face ttcf container; face 0 is the TrueType font, face 1 is CFF + var ttc = SyntheticFontBuilder.Ttc([BuildWellFormedFont(), BuildWellFormedCffFont()]); + + // Act: load without an explicit face index + using var ms = new MemoryStream(ttc); + var font = TrueTypeFont.Load(ms); + + // Assert: face 0 (the TrueType font, GlyphCount 2) was selected, not face 1 + Assert.Equal(2, font.GlyphCount); + } + + /// + /// Proves that TrueTypeFont Load TtcContainer ExplicitFaceIndex SelectsThatFace. + /// + [Fact] + public void TrueTypeFont_Load_TtcContainer_ExplicitFaceIndex_SelectsThatFace() + { + // Arrange: a synthetic 2-face ttcf container; face 0 is the TrueType font, face 1 is CFF + var ttc = SyntheticFontBuilder.Ttc([BuildWellFormedFont(), BuildWellFormedCffFont()]); + + // Act: load face 1 explicitly + using var ms = new MemoryStream(ttc); + var font = TrueTypeFont.Load(ms, 1); + + // Assert: face 1 (the CFF font, GlyphCount 1) was selected + Assert.Equal(1, font.GlyphCount); + } + + /// + /// Proves that TrueTypeFont Load TtcContainer FaceIndexOutOfRange ThrowsArgumentOutOfRangeException. + /// + [Fact] + public void TrueTypeFont_Load_TtcContainer_FaceIndexOutOfRange_ThrowsArgumentOutOfRangeException() + { + // Arrange: a synthetic 2-face ttcf container + var ttc = SyntheticFontBuilder.Ttc([BuildWellFormedFont(), BuildWellFormedCffFont()]); + + // Act/Assert: an out-of-range face index throws + using var ms = new MemoryStream(ttc); + Assert.Throws(() => TrueTypeFont.Load(ms, 2)); + } + + /// + /// Proves that TrueTypeFont Load NonTtcFont FaceIndexOne ThrowsArgumentOutOfRangeException. + /// + [Fact] + public void TrueTypeFont_Load_NonTtcFont_FaceIndexOne_ThrowsArgumentOutOfRangeException() + { + // Arrange: an ordinary, single-face (non-collection) font + var data = BuildWellFormedFont(); + + // Act/Assert: requesting face index 1 on a single-face file throws + using var ms = new MemoryStream(data); + Assert.Throws(() => TrueTypeFont.Load(ms, 1)); + } + + /// + /// Proves that TrueTypeFont Load NonTtcFont FaceIndexZero BehavesIdenticallyToLoad. + /// + [Fact] + public void TrueTypeFont_Load_NonTtcFont_FaceIndexZero_BehavesIdenticallyToLoad() + { + // Arrange: an ordinary, single-face (non-collection) font + var data = BuildWellFormedFont(); + + // Act: load with explicit face index 0 + using var ms = new MemoryStream(data); + var font = TrueTypeFont.Load(ms, 0); + + // Assert: behaves identically to the parameterless Load + Assert.Equal(1000, font.UnitsPerEm); + Assert.Equal(2, font.GlyphCount); + } + + /// + /// Proves that TrueTypeFont Load TtcContainer Path FaceIndex ReadsFromFile. + /// + [Fact] + public void TrueTypeFont_Load_TtcContainer_Path_FaceIndex_ReadsFromFile() + { + // Arrange: write a synthetic 2-face ttcf container to a temporary file + var ttc = SyntheticFontBuilder.Ttc([BuildWellFormedFont(), BuildWellFormedCffFont()]); + var path = System.IO.Path.GetTempFileName(); + try + { + File.WriteAllBytes(path, ttc); + + // Act: load face 1 explicitly from the file path + var font = TrueTypeFont.Load(path, 1); + + // Assert: face 1 (the CFF font, GlyphCount 1) was selected + Assert.Equal(1, font.GlyphCount); + } + finally + { + File.Delete(path); + } + } + + /// + /// Proves that TrueTypeFont Load NegativeFaceIndex ThrowsArgumentOutOfRangeException. + /// + [Fact] + public void TrueTypeFont_Load_NegativeFaceIndex_ThrowsArgumentOutOfRangeException() + { + // Arrange: an ordinary, well-formed font + var data = BuildWellFormedFont(); + + // Act/Assert: a negative face index throws + using var ms = new MemoryStream(data); + Assert.Throws(() => TrueTypeFont.Load(ms, -1)); + } + + /// + /// Proves that TrueTypeFont Load MalformedTtcHeader ThrowsInvalidDataException. + /// + [Fact] + public void TrueTypeFont_Load_MalformedTtcHeader_ThrowsInvalidDataException() + { + // Arrange: a 'ttcf'-tagged file too short to contain its declared face offset table + var buf = new List(); + SyntheticFontBuilder.WriteUInt32(buf, 0x74746366); // 'ttcf' + SyntheticFontBuilder.WriteUInt16(buf, 1); + SyntheticFontBuilder.WriteUInt16(buf, 0); + SyntheticFontBuilder.WriteUInt32(buf, 2); // numFonts = 2, but no offsets follow + + // Act/Assert: loading the malformed ttcf header throws + using var ms = new MemoryStream(buf.ToArray()); + Assert.Throws(() => TrueTypeFont.Load(ms)); + } + + /// + /// Builds a well-formed synthetic font (as ) that also + /// carries the given optional name/OS/2/post tables and + /// head.macStyle value, for exercising and + /// the // + /// properties end to end. + /// + private static byte[] BuildFontWithNameAndStyle( + byte[]? name = null, byte[]? os2 = null, byte[]? post = null, int macStyle = 0) + { + var glyph = SyntheticFontBuilder.SimpleGlyph( + [ + [(0, 0, true), (10, 0, true), (10, 10, true), (0, 10, true)] + ]); + + var builder = new SyntheticFontBuilder() + .AddTable("head", SyntheticFontBuilder.Head(1000, 0, macStyle)) + .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); + + if (name != null) + { + builder.AddTable("name", name); + } + + if (os2 != null) + { + builder.AddTable("OS/2", os2); + } + + if (post != null) + { + builder.AddTable("post", post); + } + + return builder.Build(); + } + + /// + /// Proves that TrueTypeFont GetNameInfo TypographicNamesPresent PrefersNameId16And17. + /// + [Fact] + public void TrueTypeFont_GetNameInfo_TypographicNamesPresent_PrefersNameId16And17() + { + // Arrange: a font whose name table has both standard (1/2) and typographic (16/17) names + var name = SyntheticFontBuilder.Name( + [ + new SyntheticFontBuilder.NameRecord(3, 1, 0x0409, 1, "Standard Family"), + new SyntheticFontBuilder.NameRecord(3, 1, 0x0409, 2, "Standard Subfamily"), + new SyntheticFontBuilder.NameRecord(3, 1, 0x0409, 16, "Typographic Family"), + new SyntheticFontBuilder.NameRecord(3, 1, 0x0409, 17, "Typographic Subfamily"), + ]); + var data = BuildFontWithNameAndStyle(name: name); + + // Act: load the font and resolve its name info + using var ms = new MemoryStream(data); + var font = TrueTypeFont.Load(ms); + var info = font.GetNameInfo(); + + // Assert: the typographic names are preferred over the standard names + Assert.Equal("Typographic Family", info.FamilyName); + Assert.Equal("Typographic Subfamily", info.SubfamilyName); + } + + /// + /// Proves that TrueTypeFont GetNameInfo TypographicNamesAbsent FallsBackToNameId1And2. + /// + [Fact] + public void TrueTypeFont_GetNameInfo_TypographicNamesAbsent_FallsBackToNameId1And2() + { + // Arrange: a font whose name table has only standard (1/2) names + var name = SyntheticFontBuilder.Name( + [ + new SyntheticFontBuilder.NameRecord(3, 1, 0x0409, 1, "Standard Family"), + new SyntheticFontBuilder.NameRecord(3, 1, 0x0409, 2, "Standard Subfamily"), + ]); + var data = BuildFontWithNameAndStyle(name: name); + + // Act: load the font and resolve its name info + using var ms = new MemoryStream(data); + var font = TrueTypeFont.Load(ms); + var info = font.GetNameInfo(); + + // Assert: the standard names are resolved + Assert.Equal("Standard Family", info.FamilyName); + Assert.Equal("Standard Subfamily", info.SubfamilyName); + } + + /// + /// Proves that TrueTypeFont GetNameInfo WindowsAndMacintoshRecordsPresent PrefersWindowsRecord. + /// + [Fact] + public void TrueTypeFont_GetNameInfo_WindowsAndMacintoshRecordsPresent_PrefersWindowsRecord() + { + // Arrange: a font whose name table has both a Windows and a Macintosh record for nameID 1 + var name = SyntheticFontBuilder.Name( + [ + new SyntheticFontBuilder.NameRecord(3, 1, 0x0409, 1, "Windows Family"), + new SyntheticFontBuilder.NameRecord(1, 0, 0, 1, "Macintosh Family"), + ]); + var data = BuildFontWithNameAndStyle(name: name); + + // Act: load the font and resolve its name info + using var ms = new MemoryStream(data); + var font = TrueTypeFont.Load(ms); + var info = font.GetNameInfo(); + + // Assert: the Windows record is preferred + Assert.Equal("Windows Family", info.FamilyName); + } + + /// + /// Proves that TrueTypeFont GetNameInfo OnlyMacintoshRecordPresent ResolvesFromMacintoshRecord. + /// + [Fact] + public void TrueTypeFont_GetNameInfo_OnlyMacintoshRecordPresent_ResolvesFromMacintoshRecord() + { + // Arrange: a font whose name table has only a Macintosh record for nameID 1 + var name = SyntheticFontBuilder.Name( + [ + new SyntheticFontBuilder.NameRecord(1, 0, 0, 1, "Macintosh Only Family"), + ]); + var data = BuildFontWithNameAndStyle(name: name); + + // Act: load the font and resolve its name info + using var ms = new MemoryStream(data); + var font = TrueTypeFont.Load(ms); + var info = font.GetNameInfo(); + + // Assert: the Macintosh record resolves as a fallback + Assert.Equal("Macintosh Only Family", info.FamilyName); + } + + /// + /// Proves that TrueTypeFont GetNameInfo PostScriptNameRecordMissing ReturnsNullPostScriptName. + /// + [Fact] + public void TrueTypeFont_GetNameInfo_PostScriptNameRecordMissing_ReturnsNullPostScriptName() + { + // Arrange: a font whose name table has a family name but no PostScript name (nameID 6) + var name = SyntheticFontBuilder.Name( + [ + new SyntheticFontBuilder.NameRecord(3, 1, 0x0409, 1, "Some Family"), + ]); + var data = BuildFontWithNameAndStyle(name: name); + + // Act: load the font and resolve its name info + using var ms = new MemoryStream(data); + var font = TrueTypeFont.Load(ms); + var info = font.GetNameInfo(); + + // Assert: the present field resolves, and the missing field is null + Assert.Equal("Some Family", info.FamilyName); + Assert.Null(info.PostScriptName); + } + + /// + /// Proves that TrueTypeFont GetNameInfo NoNameTable ReturnsAllNullFontNameInfo. + /// + [Fact] + public void TrueTypeFont_GetNameInfo_NoNameTable_ReturnsAllNullFontNameInfo() + { + // Arrange: a well-formed font with no name table at all + var data = BuildFontWithNameAndStyle(); + + // Act: load the font and resolve its name info + using var ms = new MemoryStream(data); + var font = TrueTypeFont.Load(ms); + var info = font.GetNameInfo(); + + // Assert: every field is null + Assert.Equal(default, info); + Assert.Null(info.FamilyName); + Assert.Null(info.SubfamilyName); + Assert.Null(info.FullName); + Assert.Null(info.PostScriptName); + } + + /// + /// Proves that TrueTypeFont GetNameInfo MalformedNameRecord IgnoresRecordWithoutThrowing. + /// + [Fact] + public void TrueTypeFont_GetNameInfo_MalformedNameRecord_IgnoresRecordWithoutThrowing() + { + // Arrange: hand-build a name table with one well-formed record and one record whose + // string bytes fall outside the table's bounds + var buf = new List(); + SyntheticFontBuilder.WriteUInt16(buf, 0); // format + SyntheticFontBuilder.WriteUInt16(buf, 2); // count + SyntheticFontBuilder.WriteUInt16(buf, 6 + 2 * 12); // stringOffset + + SyntheticFontBuilder.WriteUInt16(buf, 3); // platformID + SyntheticFontBuilder.WriteUInt16(buf, 1); // encodingID + SyntheticFontBuilder.WriteUInt16(buf, 0x0409); // languageID + SyntheticFontBuilder.WriteUInt16(buf, 1); // nameID + SyntheticFontBuilder.WriteUInt16(buf, 4); // length + SyntheticFontBuilder.WriteUInt16(buf, 0); // offset + + SyntheticFontBuilder.WriteUInt16(buf, 3); // platformID + SyntheticFontBuilder.WriteUInt16(buf, 1); // encodingID + SyntheticFontBuilder.WriteUInt16(buf, 0x0409); // languageID + SyntheticFontBuilder.WriteUInt16(buf, 4); // nameID (full name) + SyntheticFontBuilder.WriteUInt16(buf, 0xFFFF); // length: absurdly large + SyntheticFontBuilder.WriteUInt16(buf, 0xFFFF); // offset: absurdly large + + buf.AddRange(System.Text.Encoding.BigEndianUnicode.GetBytes("OK")); + var name = buf.ToArray(); + var data = BuildFontWithNameAndStyle(name: name); + + // Act: load the font and resolve its name info - never throws despite the malformed + // second record + using var ms = new MemoryStream(data); + var font = TrueTypeFont.Load(ms); + var info = font.GetNameInfo(); + + // Assert: the well-formed record still resolves, and the malformed record is ignored + Assert.Equal("OK", info.FamilyName); + Assert.Null(info.FullName); + } + + /// + /// Proves that TrueTypeFont IsBold Os2FsSelectionBoldBitSet ReturnsTrue. + /// + [Fact] + public void TrueTypeFont_IsBold_Os2FsSelectionBoldBitSet_ReturnsTrue() + { + // Arrange: a font whose OS/2 table sets only the BOLD fsSelection bit + var os2 = SyntheticFontBuilder.Os2(usWeightClass: 400, fsSelection: 0x20); + var data = BuildFontWithNameAndStyle(os2: os2); + + // Act: load the font + using var ms = new MemoryStream(data); + var font = TrueTypeFont.Load(ms); + + // Assert: IsBold is true, IsItalic is false + Assert.True(font.IsBold); + Assert.False(font.IsItalic); + } + + /// + /// Proves that TrueTypeFont IsItalic Os2FsSelectionItalicBitSet ReturnsTrue. + /// + [Fact] + public void TrueTypeFont_IsItalic_Os2FsSelectionItalicBitSet_ReturnsTrue() + { + // Arrange: a font whose OS/2 table sets only the ITALIC fsSelection bit + var os2 = SyntheticFontBuilder.Os2(usWeightClass: 400, fsSelection: 0x1); + var data = BuildFontWithNameAndStyle(os2: os2); + + // Act: load the font + using var ms = new MemoryStream(data); + var font = TrueTypeFont.Load(ms); + + // Assert: IsItalic is true, IsBold is false + Assert.True(font.IsItalic); + Assert.False(font.IsBold); + } + + /// + /// Proves that TrueTypeFont IsBold Os2WeightClassAtLeast600 ReturnsTrue. + /// + [Fact] + public void TrueTypeFont_IsBold_Os2WeightClassAtLeast600_ReturnsTrue() + { + // Arrange: a font whose OS/2 table declares a heavy usWeightClass with no fsSelection bits + var os2 = SyntheticFontBuilder.Os2(usWeightClass: 700, fsSelection: 0); + var data = BuildFontWithNameAndStyle(os2: os2); + + // Act: load the font + using var ms = new MemoryStream(data); + var font = TrueTypeFont.Load(ms); + + // Assert: IsBold is true purely from the weight class + Assert.True(font.IsBold); + } + + /// + /// Proves that TrueTypeFont IsItalic PostItalicAngleNonZero ReturnsTrue. + /// + [Fact] + public void TrueTypeFont_IsItalic_PostItalicAngleNonZero_ReturnsTrue() + { + // Arrange: a font with a post table declaring a nonzero italicAngle and no OS/2 table + var post = SyntheticFontBuilder.Post(isFixedPitch: 0, italicAngle: -12.0); + var data = BuildFontWithNameAndStyle(post: post); + + // Act: load the font + using var ms = new MemoryStream(data); + var font = TrueTypeFont.Load(ms); + + // Assert: IsItalic is true purely from the post table's italic angle + Assert.True(font.IsItalic); + } + + /// + /// Proves that TrueTypeFont Style Os2TableAbsent FallsBackToHeadMacStyle. + /// + [Fact] + public void TrueTypeFont_Style_Os2TableAbsent_FallsBackToHeadMacStyle() + { + // Arrange: a font with no OS/2 or post table at all, but head.macStyle's Bold and Italic + // bits both set + const int macStyle = 0x1 | 0x2; + var data = BuildFontWithNameAndStyle(macStyle: macStyle); + + // Act: load the font + using var ms = new MemoryStream(data); + var font = TrueTypeFont.Load(ms); + + // Assert: both IsBold and IsItalic are true, derived purely from head.macStyle + Assert.True(font.IsBold); + Assert.True(font.IsItalic); + } + + /// + /// Proves that TrueTypeFont IsFixedPitch PostIsFixedPitchNonZero ReturnsTrue. + /// + [Fact] + public void TrueTypeFont_IsFixedPitch_PostIsFixedPitchNonZero_ReturnsTrue() + { + // Arrange: a font whose post table declares isFixedPitch + var post = SyntheticFontBuilder.Post(isFixedPitch: 1); + var data = BuildFontWithNameAndStyle(post: post); + + // Act: load the font + using var ms = new MemoryStream(data); + var font = TrueTypeFont.Load(ms); + + // Assert: IsFixedPitch is true + Assert.True(font.IsFixedPitch); + } + + /// + /// Proves that TrueTypeFont IsFixedPitch PostTableAbsent ReturnsFalseWithoutThrowing. + /// + [Fact] + public void TrueTypeFont_IsFixedPitch_PostTableAbsent_ReturnsFalseWithoutThrowing() + { + // Arrange: a well-formed font with no post table at all + var data = BuildFontWithNameAndStyle(); + + // Act: load the font (never throws over the missing post table) + using var ms = new MemoryStream(data); + var font = TrueTypeFont.Load(ms); + + // Assert: IsFixedPitch is false + Assert.False(font.IsFixedPitch); + } + + /// + /// Builds a minimal, well-formed synthetic Type 1 font program (not SFNT-wrapped, not + /// PFB/PFA-framed) with .notdef, space, and A glyphs. + /// + private static (byte[] FontFileBytes, int Length1, int Length2) BuildSimpleType1Program() + { + var notdef = new List(); + SyntheticFontBuilder.WriteType1CharstringNumber(notdef, 0); + SyntheticFontBuilder.WriteType1CharstringNumber(notdef, 600); + SyntheticFontBuilder.WriteType1CharstringOperator(notdef, 13); // hsbw + SyntheticFontBuilder.WriteType1CharstringOperator(notdef, 14); // endchar + + var space = new List(); + SyntheticFontBuilder.WriteType1CharstringNumber(space, 0); + SyntheticFontBuilder.WriteType1CharstringNumber(space, 300); + SyntheticFontBuilder.WriteType1CharstringOperator(space, 13); // hsbw + SyntheticFontBuilder.WriteType1CharstringOperator(space, 14); // endchar + + var a = new List(); + SyntheticFontBuilder.WriteType1CharstringNumber(a, 50); + SyntheticFontBuilder.WriteType1CharstringNumber(a, 700); + SyntheticFontBuilder.WriteType1CharstringOperator(a, 13); // hsbw + SyntheticFontBuilder.WriteType1CharstringNumber(a, 0); + SyntheticFontBuilder.WriteType1CharstringNumber(a, 0); + SyntheticFontBuilder.WriteType1CharstringOperator(a, 21); // rmoveto + SyntheticFontBuilder.WriteType1CharstringNumber(a, 10); + SyntheticFontBuilder.WriteType1CharstringNumber(a, 0); + SyntheticFontBuilder.WriteType1CharstringOperator(a, 5); // rlineto + SyntheticFontBuilder.WriteType1CharstringOperator(a, 9); // closepath + SyntheticFontBuilder.WriteType1CharstringOperator(a, 14); // endchar + + return SyntheticFontBuilder.Type1([(".notdef", [.. notdef]), ("space", [.. space]), ("A", [.. a])]); + } + + private static readonly Dictionary Type1TestEncoding = new() + { + [' '] = "space", + ['A'] = "A", + }; + + /// + /// Proves that TrueTypeFont LoadType1 WellFormedProgram ExposesGlyphsAndMetrics. + /// + [Fact] + public void TrueTypeFont_LoadType1_WellFormedProgram_ExposesGlyphsAndMetrics() + { + var program = BuildSimpleType1Program(); + + using var ms = new MemoryStream(program.FontFileBytes); + var font = TrueTypeFont.LoadType1(ms, program.Length1, program.Length2, Type1TestEncoding); + + Assert.Equal(3, font.GlyphCount); + + var glyphIndexA = font.GetGlyphIndex('A'); + Assert.NotEqual(0, glyphIndexA); + Assert.Equal(700, font.GetAdvanceWidth(glyphIndexA)); + + var outline = font.GetGlyphOutline(glyphIndexA); + Assert.Single(outline.Subpaths); + + var glyphIndexSpace = font.GetGlyphIndex(' '); + Assert.Equal(300, font.GetAdvanceWidth(glyphIndexSpace)); + Assert.Empty(font.GetGlyphOutline(glyphIndexSpace).Subpaths); + + // An unmapped codepoint resolves to .notdef (glyph 0), matching the SFNT cmap convention. + Assert.Equal(0, font.GetGlyphIndex('Z')); + } + + /// + /// Proves that TrueTypeFont LoadType1 NegativeLength1 ThrowsInvalidDataException. + /// + [Fact] + public void TrueTypeFont_LoadType1_NegativeLength1_ThrowsInvalidDataException() + { + var program = BuildSimpleType1Program(); + using var ms = new MemoryStream(program.FontFileBytes); + + Assert.Throws(() => TrueTypeFont.LoadType1(ms, -1, program.Length2, Type1TestEncoding)); + } + + /// + /// Proves that TrueTypeFont LoadType1 Length2ExceedsStreamBounds ThrowsInvalidDataException. + /// + [Fact] + public void TrueTypeFont_LoadType1_Length2ExceedsStreamBounds_ThrowsInvalidDataException() + { + var program = BuildSimpleType1Program(); + using var ms = new MemoryStream(program.FontFileBytes); + + Assert.Throws(() => TrueTypeFont.LoadType1(ms, program.Length1, program.Length2 + 10_000, Type1TestEncoding)); + } + + /// + /// Proves that TrueTypeFont LoadType1 NullStream ThrowsArgumentNullException. + /// + [Fact] + public void TrueTypeFont_LoadType1_NullStream_ThrowsArgumentNullException() => + Assert.Throws(() => TrueTypeFont.LoadType1(null!, 0, 0, Type1TestEncoding)); + + /// + /// Proves that TrueTypeFont LoadType1 NullEncoding ThrowsArgumentNullException. + /// + [Fact] + public void TrueTypeFont_LoadType1_NullEncoding_ThrowsArgumentNullException() + { + var program = BuildSimpleType1Program(); + using var ms = new MemoryStream(program.FontFileBytes); + + Assert.Throws(() => TrueTypeFont.LoadType1(ms, program.Length1, program.Length2, null!)); + } + + /// + /// Proves that TrueTypeFont Load StandalonePfbFile AutoDetectsAndRoundTrips. + /// + [Fact] + public void TrueTypeFont_Load_StandalonePfbFile_AutoDetectsAndRoundTrips() + { + var program = BuildSimpleType1Program(); + var pfb = SyntheticFontBuilder.Type1Pfb(program.FontFileBytes, program.Length1, program.Length2); + + using var ms = new MemoryStream(pfb); + var font = TrueTypeFont.Load(ms); + + Assert.Equal(3, font.GlyphCount); + var glyphIndex = font.GetGlyphIndex('A'); + Assert.NotEqual(0, glyphIndex); + Assert.Single(font.GetGlyphOutline(glyphIndex).Subpaths); + } + + /// + /// Proves that TrueTypeFont Load StandalonePfaFile AutoDetectsAndRoundTrips. + /// + [Fact] + public void TrueTypeFont_Load_StandalonePfaFile_AutoDetectsAndRoundTrips() + { + var program = BuildSimpleType1Program(); + var pfa = SyntheticFontBuilder.Type1Pfa(program.FontFileBytes, program.Length1, program.Length2); + + using var ms = new MemoryStream(pfa); + var font = TrueTypeFont.Load(ms); + + Assert.Equal(3, font.GlyphCount); + var glyphIndex = font.GetGlyphIndex('A'); + Assert.NotEqual(0, glyphIndex); + Assert.Single(font.GetGlyphOutline(glyphIndex).Subpaths); + } + + /// + /// Proves that TrueTypeFont Load StandalonePfbFile Path ReadsFromFile. + /// + [Fact] + public void TrueTypeFont_Load_StandalonePfbFile_Path_ReadsFromFile() + { + var program = BuildSimpleType1Program(); + var pfb = SyntheticFontBuilder.Type1Pfb(program.FontFileBytes, program.Length1, program.Length2); + + var path = System.IO.Path.GetTempFileName(); + try + { + File.WriteAllBytes(path, pfb); + var font = TrueTypeFont.Load(path); + + Assert.Equal(3, font.GlyphCount); + } + finally + { + File.Delete(path); + } + } + + /// + /// Proves that TrueTypeFont GetFaceCount StandaloneType1File ReturnsOne. + /// + [Fact] + public void TrueTypeFont_GetFaceCount_StandaloneType1File_ReturnsOne() + { + var program = BuildSimpleType1Program(); + var pfb = SyntheticFontBuilder.Type1Pfb(program.FontFileBytes, program.Length1, program.Length2); + + using var ms = new MemoryStream(pfb); + Assert.Equal(1, TrueTypeFont.GetFaceCount(ms)); + } + + /// + /// Proves that TrueTypeFont Load StandaloneType1File FaceIndexOne ThrowsArgumentOutOfRangeException. + /// + [Fact] + public void TrueTypeFont_Load_StandaloneType1File_FaceIndexOne_ThrowsArgumentOutOfRangeException() + { + var program = BuildSimpleType1Program(); + var pfb = SyntheticFontBuilder.Type1Pfb(program.FontFileBytes, program.Length1, program.Length2); + + using var ms = new MemoryStream(pfb); + Assert.Throws(() => TrueTypeFont.Load(ms, 1)); + } + + /// + /// Proves that TrueTypeFont Load NonType1NonSfntGarbage ThrowsInvalidDataException. + /// + [Fact] + public void TrueTypeFont_Load_NonType1NonSfntGarbage_ThrowsInvalidDataException() + { + byte[] garbage = [1, 2, 3, 4, 5, 6, 7, 8]; + using var ms = new MemoryStream(garbage); + + Assert.Throws(() => TrueTypeFont.Load(ms)); + } + + /// + /// Builds a minimal, well-formed bare CFF (Type1C) table with three glyphs + /// (.notdef, space, A) resolved via a custom charset format 0 table + /// mapping glyph 1 to SID 1 (space) and glyph 2 to SID 34 (A), and widths + /// resolved via the Private DICT's defaultWidthX/nominalWidthX operators. + /// + private static byte[] BuildType1CProgram() + { + var notdef = new List(); + SyntheticFontBuilder.WriteCharstringOperator(notdef, 14); // endchar + + var space = new List(); + SyntheticFontBuilder.WriteCharstringOperator(space, 14); // endchar (width = defaultWidthX) + + var a = new List(); + SyntheticFontBuilder.WriteCharstringNumber(a, 600); // width delta: nominalWidthX(100) + 600 = 700 + SyntheticFontBuilder.WriteCharstringNumber(a, 0); + SyntheticFontBuilder.WriteCharstringNumber(a, 0); + SyntheticFontBuilder.WriteCharstringOperator(a, 21); // rmoveto + SyntheticFontBuilder.WriteCharstringNumber(a, 10); + SyntheticFontBuilder.WriteCharstringNumber(a, 0); + SyntheticFontBuilder.WriteCharstringOperator(a, 5); // rlineto + SyntheticFontBuilder.WriteCharstringOperator(a, 14); // endchar + + byte[] charsetTable = [0, 0, 1, 0, 34]; // format 0: glyph 1 -> SID 1 (space), glyph 2 -> SID 34 (A) + + return SyntheticFontBuilder.Cff( + [[.. notdef], [.. space], [.. a]], + charsetTable: charsetTable, + defaultWidthX: 300, + nominalWidthX: 100); + } + + private static readonly Dictionary Type1CTestEncoding = new() + { + [' '] = "space", + ['A'] = "A", + }; + + /// + /// Proves that TrueTypeFont LoadType1C WellFormedCff ExposesGlyphsAndMetrics. + /// + [Fact] + public void TrueTypeFont_LoadType1C_WellFormedCff_ExposesGlyphsAndMetrics() + { + var data = BuildType1CProgram(); + + using var ms = new MemoryStream(data); + var font = TrueTypeFont.LoadType1C(ms, Type1CTestEncoding); + + Assert.Equal(3, font.GlyphCount); + + var glyphIndexA = font.GetGlyphIndex('A'); + Assert.NotEqual(0, glyphIndexA); + Assert.Equal(700, font.GetAdvanceWidth(glyphIndexA)); + + var outline = font.GetGlyphOutline(glyphIndexA); + Assert.Single(outline.Subpaths); + Assert.Contains(outline.Subpaths[0].Commands, c => c.Type == PathCommandType.LineTo); + + var glyphIndexSpace = font.GetGlyphIndex(' '); + Assert.NotEqual(0, glyphIndexSpace); + Assert.Equal(300, font.GetAdvanceWidth(glyphIndexSpace)); + Assert.Empty(font.GetGlyphOutline(glyphIndexSpace).Subpaths); + + // An unmapped codepoint resolves to .notdef (glyph 0), matching LoadType1's convention. + Assert.Equal(0, font.GetGlyphIndex('Z')); + } + + /// + /// Proves that TrueTypeFont LoadType1C NullStream ThrowsArgumentNullException. + /// + [Fact] + public void TrueTypeFont_LoadType1C_NullStream_ThrowsArgumentNullException() => + Assert.Throws(() => TrueTypeFont.LoadType1C(null!, Type1CTestEncoding)); + + /// + /// Proves that TrueTypeFont LoadType1C NullEncoding ThrowsArgumentNullException. + /// + [Fact] + public void TrueTypeFont_LoadType1C_NullEncoding_ThrowsArgumentNullException() + { + var data = BuildType1CProgram(); + using var ms = new MemoryStream(data); + + Assert.Throws(() => TrueTypeFont.LoadType1C(ms, null!)); + } + + /// + /// Proves that TrueTypeFont LoadType1C MalformedCff ThrowsInvalidDataException. + /// + [Fact] + public void TrueTypeFont_LoadType1C_MalformedCff_ThrowsInvalidDataException() + { + byte[] garbage = [1, 2, 3, 4, 5, 6, 7, 8]; + using var ms = new MemoryStream(garbage); + + Assert.Throws(() => TrueTypeFont.LoadType1C(ms, Type1CTestEncoding)); + } + + /// + /// Proves that TrueTypeFont LoadType1C CidKeyedCff ThrowsInvalidDataException. + /// + [Fact] + public void TrueTypeFont_LoadType1C_CidKeyedCff_ThrowsInvalidDataException() + { + var cs = new List(); + SyntheticFontBuilder.WriteCharstringOperator(cs, 14); // endchar + var data = SyntheticFontBuilder.Cff([[.. cs]], includeRos: true); + + using var ms = new MemoryStream(data); + Assert.Throws(() => TrueTypeFont.LoadType1C(ms, Type1CTestEncoding)); + } } diff --git a/test/DemaConsulting.CanvasNet.Tests/Fonts/Type1CharstringInterpreterTests.cs b/test/DemaConsulting.CanvasNet.Tests/Fonts/Type1CharstringInterpreterTests.cs new file mode 100644 index 00000000..4a41e1ff --- /dev/null +++ b/test/DemaConsulting.CanvasNet.Tests/Fonts/Type1CharstringInterpreterTests.cs @@ -0,0 +1,645 @@ +// cspell:ignore charstring charstrings subr subrs hsbw rmoveto hmoveto vmoveto rlineto hlineto +// cspell:ignore vlineto rrcurveto vhcurveto hvcurveto callsubr callothersubr dotsection vstem +// cspell:ignore hstem endchar seac setcurrentpoint othersubr sbw hstem3 vstem3 +using System.Numerics; +using DemaConsulting.CanvasNet.Fonts; +using DemaConsulting.CanvasNet.Geometry; +using DemaConsulting.CanvasNet.Tests.TestSupport; +using Path = DemaConsulting.CanvasNet.Geometry.Path; + +namespace DemaConsulting.CanvasNet.Tests.Fonts; + +/// +/// Unit tests for . +/// +public class Type1CharstringInterpreterTests +{ + private static (Path Outline, double Width) Decode(byte[] charstring, IReadOnlyList<(int Offset, int Length)>? subrs = null) => + Type1CharstringInterpreter.Decode(charstring, (0, charstring.Length), subrs ?? []); + + private static byte[] Build(Action> write) + { + var buf = new List(); + write(buf); + return [.. buf]; + } + + private static void Number(List buf, int value) => SyntheticFontBuilder.WriteType1CharstringNumber(buf, value); + + private static void Op(List buf, int op) => SyntheticFontBuilder.WriteType1CharstringOperator(buf, op); + + /// + /// Proves that Type1CharstringInterpreter EmptyCharstring ProducesEmptyPathAndZeroWidth. + /// + [Fact] + public void Type1CharstringInterpreter_EmptyCharstring_ProducesEmptyPathAndZeroWidth() + { + var (outline, width) = Decode([]); + Assert.Empty(outline.Subpaths); + Assert.Equal(0, width); + } + + /// + /// Proves that Type1CharstringInterpreter Hsbw CapturesWidthAndSideBearing. + /// + [Fact] + public void Type1CharstringInterpreter_Hsbw_CapturesWidthAndSideBearing() + { + var cs = Build(buf => + { + Number(buf, 50); + Number(buf, 600); + Op(buf, 13); // hsbw + Number(buf, 0); + Number(buf, 0); + Op(buf, 21); // rmoveto - relative to (sbx, 0) + Op(buf, 14); // endchar + }); + + var (outline, width) = Decode(cs); + Assert.Equal(600, width); + Assert.Equal(new Vector2(50, 0), Assert.Single(outline.Subpaths).Start); + } + + /// + /// Proves that Type1CharstringInterpreter Sbw CapturesWidthAndBothSideBearings. + /// + [Fact] + public void Type1CharstringInterpreter_Sbw_CapturesWidthAndBothSideBearings() + { + var cs = Build(buf => + { + Number(buf, 30); + Number(buf, 40); + Number(buf, 700); + Number(buf, 0); + Op(buf, 1207); // escape 12 7 = sbw + Number(buf, 0); + Number(buf, 0); + Op(buf, 21); // rmoveto + Op(buf, 14); + }); + + var (outline, width) = Decode(cs); + Assert.Equal(700, width); + Assert.Equal(new Vector2(30, 40), Assert.Single(outline.Subpaths).Start); + } + + /// + /// Proves that Type1CharstringInterpreter RLineTo HLineTo VLineTo ProduceLines. + /// + [Fact] + public void Type1CharstringInterpreter_RLineTo_HLineTo_VLineTo_ProduceLines() + { + var cs = Build(buf => + { + Number(buf, 0); + Number(buf, 0); + Op(buf, 21); // rmoveto + Number(buf, 10); + Number(buf, 20); + Op(buf, 5); // rlineto + Number(buf, 5); + Op(buf, 6); // hlineto + Number(buf, 7); + Op(buf, 7); // vlineto + Op(buf, 14); + }); + + var (outline, _) = Decode(cs); + var lineTos = Assert.Single(outline.Subpaths).Commands.Where(c => c.Type == PathCommandType.LineTo).ToArray(); + Assert.Equal(3, lineTos.Length); + Assert.Equal(new Vector2(10, 20), lineTos[0].EndPoint); + Assert.Equal(new Vector2(15, 20), lineTos[1].EndPoint); + Assert.Equal(new Vector2(15, 27), lineTos[2].EndPoint); + } + + /// + /// Proves that Type1CharstringInterpreter RRCurveTo ProducesCubicBezier. + /// + [Fact] + public void Type1CharstringInterpreter_RRCurveTo_ProducesCubicBezier() + { + var cs = Build(buf => + { + Number(buf, 0); + Number(buf, 0); + Op(buf, 21); // rmoveto + Number(buf, 10); + Number(buf, 0); + Number(buf, 10); + Number(buf, 10); + Number(buf, 0); + Number(buf, 10); + Op(buf, 8); // rrcurveto + Op(buf, 14); + }); + + var (outline, _) = Decode(cs); + var curve = Assert.Single(Assert.Single(outline.Subpaths).Commands, c => c.Type == PathCommandType.CubicBezierTo); + Assert.Equal(new Vector2(20, 20), curve.EndPoint); + } + + /// + /// Proves that Type1CharstringInterpreter VhCurveTo HvCurveTo AlternateStartTangent. + /// + [Fact] + public void Type1CharstringInterpreter_VhCurveTo_HvCurveTo_AlternateStartTangent() + { + var vhCs = Build(buf => + { + Number(buf, 0); + Number(buf, 0); + Op(buf, 21); + Number(buf, 10); + Number(buf, 10); + Number(buf, 10); + Number(buf, 10); + Op(buf, 30); // vhcurveto + Op(buf, 14); + }); + var (vhOutline, _) = Decode(vhCs); + Assert.Contains(Assert.Single(vhOutline.Subpaths).Commands, c => c.Type == PathCommandType.CubicBezierTo); + + var hvCs = Build(buf => + { + Number(buf, 0); + Number(buf, 0); + Op(buf, 21); + Number(buf, 10); + Number(buf, 10); + Number(buf, 10); + Number(buf, 10); + Op(buf, 31); // hvcurveto + Op(buf, 14); + }); + var (hvOutline, _) = Decode(hvCs); + Assert.Contains(Assert.Single(hvOutline.Subpaths).Commands, c => c.Type == PathCommandType.CubicBezierTo); + } + + /// + /// Proves that Type1CharstringInterpreter ClosePath ClosesSubpath. + /// + [Fact] + public void Type1CharstringInterpreter_ClosePath_ClosesSubpath() + { + var cs = Build(buf => + { + Number(buf, 0); + Number(buf, 0); + Op(buf, 21); // rmoveto + Number(buf, 10); + Number(buf, 10); + Op(buf, 5); // rlineto + Op(buf, 9); // closepath + Op(buf, 14); + }); + + var (outline, _) = Decode(cs); + Assert.True(Assert.Single(outline.Subpaths).IsClosed); + } + + /// + /// Proves that Type1CharstringInterpreter HStem VStem Hstem3 Vstem3 Dotsection ConsumedWithoutError. + /// + [Fact] + public void Type1CharstringInterpreter_HStem_VStem_Hstem3_Vstem3_Dotsection_ConsumedWithoutError() + { + var cs = Build(buf => + { + Number(buf, 0); + Number(buf, 10); + Op(buf, 1); // hstem + Number(buf, 0); + Number(buf, 10); + Op(buf, 3); // vstem + Op(buf, 1200); // dotsection + Number(buf, 0); + Number(buf, 10); + Number(buf, 20); + Number(buf, 30); + Number(buf, 40); + Number(buf, 50); + Op(buf, 1202); // hstem3 + Number(buf, 0); + Number(buf, 10); + Number(buf, 20); + Number(buf, 30); + Number(buf, 40); + Number(buf, 50); + Op(buf, 1201); // vstem3 + Number(buf, 0); + Number(buf, 0); + Op(buf, 21); // rmoveto + Op(buf, 14); + }); + + var (outline, _) = Decode(cs); + Assert.Single(outline.Subpaths); + } + + /// + /// Proves that Type1CharstringInterpreter CallSubr Return NoBias DirectIndex. + /// + [Fact] + public void Type1CharstringInterpreter_CallSubr_Return_NoBias_DirectIndex() + { + var subr = Build(buf => + { + Number(buf, 10); + Number(buf, 20); + Op(buf, 21); // rmoveto + Op(buf, 11); // return + }); + + var main = Build(buf => + { + Number(buf, 0); // subr index 0 - no bias in Type 1 + Op(buf, 10); // callsubr + Op(buf, 14); + }); + + var combined = new List(); + combined.AddRange(subr); + combined.AddRange(main); + + var (outline, _) = Type1CharstringInterpreter.Decode( + [.. combined], (subr.Length, main.Length), [(0, subr.Length)]); + + Assert.Equal(new Vector2(10, 20), Assert.Single(outline.Subpaths).Start); + } + + /// + /// Proves that Type1CharstringInterpreter CallSubr OutOfRangeIndex ThrowsInvalidDataException. + /// + [Fact] + public void Type1CharstringInterpreter_CallSubr_OutOfRangeIndex_ThrowsInvalidDataException() + { + var cs = Build(buf => + { + Number(buf, 5); + Op(buf, 10); // callsubr - no subroutines exist + Op(buf, 14); + }); + + Assert.Throws(() => Decode(cs)); + } + + /// + /// Proves that Type1CharstringInterpreter CallSubr ExceedsMaxDepth ThrowsInvalidDataException. + /// + [Fact] + public void Type1CharstringInterpreter_CallSubr_ExceedsMaxDepth_ThrowsInvalidDataException() + { + var subr = Build(buf => + { + Number(buf, 0); + Op(buf, 10); // callsubr (self-recursive) + }); + + var main = Build(buf => + { + Number(buf, 0); + Op(buf, 10); // callsubr + Op(buf, 14); + }); + + var combined = new List(); + combined.AddRange(subr); + combined.AddRange(main); + + Assert.Throws(() => + Type1CharstringInterpreter.Decode([.. combined], (subr.Length, main.Length), [(0, subr.Length)])); + } + + /// + /// Proves that Type1CharstringInterpreter Div ResultFeedsSubsequentOperator. + /// + [Fact] + public void Type1CharstringInterpreter_Div_ResultFeedsSubsequentOperator() + { + var cs = Build(buf => + { + Number(buf, 0); + Number(buf, 0); + Op(buf, 21); // rmoveto + Number(buf, 20); + Number(buf, 4); + Op(buf, 1212); // div -> 5 + Number(buf, 7); + Op(buf, 5); // rlineto: (5, 7) + Op(buf, 14); + }); + + var (outline, _) = Decode(cs); + var lineTo = Assert.Single(Assert.Single(outline.Subpaths).Commands, c => c.Type == PathCommandType.LineTo); + Assert.Equal(new Vector2(5, 7), lineTo.EndPoint); + } + + /// + /// Proves that Type1CharstringInterpreter Flex EndToEnd ProducesTwoRealCubicBeziers. + /// + /// + /// Exercises the complete Type 1 flex idiom via callsubr-wrapped OtherSubrs calls + /// (subr 1 = begin flex, subr 2 = flex point mark, subr 0 = end flex), buffering seven + /// rmoveto deltas and asserting the exact resulting curve geometry - not merely + /// that decoding succeeds without an exception. + /// + [Fact] + public void Type1CharstringInterpreter_Flex_EndToEnd_ProducesTwoRealCubicBeziers() + { + // Subr 0: end flex - "3 0 callothersubr pop pop setcurrentpoint return". + var subr0 = Build(buf => + { + Number(buf, 3); + Number(buf, 0); + Op(buf, 1216); // callothersubr + Op(buf, 1217); // pop + Op(buf, 1217); // pop + Op(buf, 1233); // setcurrentpoint + Op(buf, 11); // return + }); + + // Subr 1: begin flex - "0 1 callothersubr return". + var subr1 = Build(buf => + { + Number(buf, 0); + Number(buf, 1); + Op(buf, 1216); // callothersubr + Op(buf, 11); // return + }); + + // Subr 2: flex point mark - "0 2 callothersubr return". + var subr2 = Build(buf => + { + Number(buf, 0); + Number(buf, 2); + Op(buf, 1216); // callothersubr + Op(buf, 11); // return + }); + + var main = Build(buf => + { + Number(buf, 50); + Number(buf, 600); + Op(buf, 13); // hsbw - current point becomes (50, 0) + + Number(buf, 50); + Number(buf, 100); + Op(buf, 21); // rmoveto -> (100, 100) - starts the open subpath + + Number(buf, 1); + Op(buf, 10); // callsubr 1 - begin flex + + Number(buf, 0); + Number(buf, 0); + Op(buf, 21); // rmoveto -> reference point (100, 100), buffered and later discarded + Number(buf, 2); + Op(buf, 10); // callsubr 2 + + Number(buf, 10); + Number(buf, 20); + Op(buf, 21); // rmoveto -> (110, 120) - curve 1 control point 1 + Number(buf, 2); + Op(buf, 10); + + Number(buf, 10); + Number(buf, 20); + Op(buf, 21); // rmoveto -> (120, 140) - curve 1 control point 2 + Number(buf, 2); + Op(buf, 10); + + Number(buf, 10); + Number(buf, 20); + Op(buf, 21); // rmoveto -> (130, 160) - curve 1 end point / curve 2 start reference + Number(buf, 2); + Op(buf, 10); + + Number(buf, 10); + Number(buf, 20); + Op(buf, 21); // rmoveto -> (140, 180) - curve 2 control point 1 + Number(buf, 2); + Op(buf, 10); + + Number(buf, 10); + Number(buf, 20); + Op(buf, 21); // rmoveto -> (150, 200) - curve 2 control point 2 + Number(buf, 2); + Op(buf, 10); + + Number(buf, 10); + Number(buf, 20); + Op(buf, 21); // rmoveto -> (160, 220) - curve 2 end point + Number(buf, 2); + Op(buf, 10); + + Number(buf, 50); // flexHeight (unused by geometry, only by a real PS flex decision) + Number(buf, 160); // finalX - must match the 7th buffered point + Number(buf, 220); // finalY - must match the 7th buffered point + Number(buf, 0); + Op(buf, 10); // callsubr 0 - end flex + + Number(buf, 20); + Number(buf, -20); + Op(buf, 5); // rlineto -> (180, 200) + Op(buf, 9); // closepath + Op(buf, 14); // endchar + }); + + var combined = new List(); + var subr0Range = (0, subr0.Length); + combined.AddRange(subr0); + var subr1Range = (combined.Count, subr1.Length); + combined.AddRange(subr1); + var subr2Range = (combined.Count, subr2.Length); + combined.AddRange(subr2); + var mainRange = (combined.Count, main.Length); + combined.AddRange(main); + + var subrs = new List<(int Offset, int Length)> { subr0Range, subr1Range, subr2Range }; + + var (outline, width) = Type1CharstringInterpreter.Decode([.. combined], mainRange, subrs); + + Assert.Equal(600, width); + var subpath = Assert.Single(outline.Subpaths); + Assert.Equal(new Vector2(100, 100), subpath.Start); + + var curves = subpath.Commands.Where(c => c.Type == PathCommandType.CubicBezierTo).ToArray(); + Assert.Equal(2, curves.Length); + + // Curve 1: control points (110,120)/(120,140), end (130,160). + Assert.Equal(new Vector2(110, 120), curves[0].Control1); + Assert.Equal(new Vector2(120, 140), curves[0].Control2); + Assert.Equal(new Vector2(130, 160), curves[0].EndPoint); + + // Curve 2: control points (140,180)/(150,200), end (160,220). + Assert.Equal(new Vector2(140, 180), curves[1].Control1); + Assert.Equal(new Vector2(150, 200), curves[1].Control2); + Assert.Equal(new Vector2(160, 220), curves[1].EndPoint); + + // The trailing rlineto/closepath after 'setcurrentpoint' resets (x, y) to (160, 220) + // prove setcurrentpoint's (x, y) stack order was honored correctly. + var lineTo = Assert.Single(subpath.Commands, c => c.Type == PathCommandType.LineTo); + Assert.Equal(new Vector2(180, 200), lineTo.EndPoint); + } + + /// + /// Proves that Type1CharstringInterpreter HintReplacement OtherSubr3 IsTransparentPassThrough. + /// + [Fact] + public void Type1CharstringInterpreter_HintReplacement_OtherSubr3_IsTransparentPassThrough() + { + // Subr 5: the "replacement hint" subroutine - just more (discarded) stem hints. + var subr5 = Build(buf => + { + Number(buf, 0); + Number(buf, 10); + Op(buf, 1); // hstem + Op(buf, 11); // return + }); + + var main = Build(buf => + { + Number(buf, 5); // the subr number to hint-replace with + Number(buf, 1); + Number(buf, 3); + Op(buf, 1216); // callothersubr: 1 arg, othersubr 3 + Op(buf, 1217); // pop - retrieves the subr number (5) back onto the operand stack + Op(buf, 10); // callsubr - invokes subr 5 (pure pass-through) + Number(buf, 0); + Number(buf, 0); + Op(buf, 21); // rmoveto + Op(buf, 14); + }); + + var combined = new List(); + var paddingSubrCount = 5; + for (var i = 0; i < paddingSubrCount; i++) + { + combined.Add(11); // 'return' - unused filler subroutines just to occupy indices 0..4 + } + + var subr5Range = (combined.Count, subr5.Length); + combined.AddRange(subr5); + var mainRange = (combined.Count, main.Length); + combined.AddRange(main); + + var subrs = new List<(int Offset, int Length)>(); + for (var i = 0; i < paddingSubrCount; i++) + { + subrs.Add((i, 1)); + } + + subrs.Add(subr5Range); + + var (outline, _) = Type1CharstringInterpreter.Decode([.. combined], mainRange, subrs); + Assert.Single(outline.Subpaths); + } + + /// + /// Proves that Type1CharstringInterpreter Seac ThrowsInvalidDataException. + /// + [Fact] + public void Type1CharstringInterpreter_Seac_ThrowsInvalidDataException() + { + var cs = Build(buf => + { + Number(buf, 0); + Number(buf, 0); + Number(buf, 0); + Number(buf, 65); + Number(buf, 66); + Op(buf, 1206); // escape 12 6 = seac + }); + + Assert.Throws(() => Decode(cs)); + } + + /// + /// Proves that Type1CharstringInterpreter UnsupportedOperator ThrowsInvalidDataException. + /// + [Fact] + public void Type1CharstringInterpreter_UnsupportedOperator_ThrowsInvalidDataException() + { + var cs = Build(buf => + { + Number(buf, 0); + Op(buf, 16); // unassigned Type 1 single-byte operator code + }); + + Assert.Throws(() => Decode(cs)); + } + + /// + /// Proves that Type1CharstringInterpreter UnsupportedEscapeOperator ThrowsInvalidDataException. + /// + [Fact] + public void Type1CharstringInterpreter_UnsupportedEscapeOperator_ThrowsInvalidDataException() + { + var cs = Build(buf => Op(buf, 1299)); // escape 12 99 - not a recognized Type 1 escape operator + + Assert.Throws(() => Decode(cs)); + } + + /// + /// Proves that Type1CharstringInterpreter CallOtherSubr UnsupportedIndex ThrowsInvalidDataException. + /// + [Fact] + public void Type1CharstringInterpreter_CallOtherSubr_UnsupportedIndex_ThrowsInvalidDataException() + { + var cs = Build(buf => + { + Number(buf, 0); + Number(buf, 99); // an unsupported OtherSubrs index + Op(buf, 1216); // callothersubr + }); + + Assert.Throws(() => Decode(cs)); + } + + /// + /// Proves that Type1CharstringInterpreter RLineTo WrongOperandCount ThrowsInvalidDataException. + /// + [Fact] + public void Type1CharstringInterpreter_RLineTo_WrongOperandCount_ThrowsInvalidDataException() + { + var cs = Build(buf => + { + Number(buf, 0); + Number(buf, 0); + Op(buf, 21); // rmoveto + Number(buf, 5); + Op(buf, 5); // rlineto - requires exactly 2 operands + }); + + Assert.Throws(() => Decode(cs)); + } + + /// + /// Proves that Type1CharstringInterpreter FivByteInteger DecodesAsPlainInt32. + /// + [Fact] + public void Type1CharstringInterpreter_FiveByteInteger_DecodesAsPlainInt32() + { + var cs = Build(buf => + { + Number(buf, 5000); // forces the 5-byte '255' plain-int32 form + Number(buf, -5000); + Op(buf, 21); // rmoveto + Op(buf, 14); + }); + + var (outline, _) = Decode(cs); + Assert.Equal(new Vector2(5000, -5000), Assert.Single(outline.Subpaths).Start); + } + + /// + /// Proves that Type1CharstringInterpreter TruncatedCharstring ThrowsInvalidDataException. + /// + [Fact] + public void Type1CharstringInterpreter_TruncatedCharstring_ThrowsInvalidDataException() + { + byte[] cs = [247]; // 2-byte numeric form, but only 1 byte present + Assert.Throws(() => Decode(cs)); + } +} diff --git a/test/DemaConsulting.CanvasNet.Tests/Fonts/Type1PfaReaderTests.cs b/test/DemaConsulting.CanvasNet.Tests/Fonts/Type1PfaReaderTests.cs new file mode 100644 index 00000000..c9510ea7 --- /dev/null +++ b/test/DemaConsulting.CanvasNet.Tests/Fonts/Type1PfaReaderTests.cs @@ -0,0 +1,147 @@ +// cspell:ignore eexec sniff +using DemaConsulting.CanvasNet.Fonts; +using DemaConsulting.CanvasNet.Tests.TestSupport; + +namespace DemaConsulting.CanvasNet.Tests.Fonts; + +/// +/// Unit tests for . +/// +public class Type1PfaReaderTests +{ + private static (byte[] FontFileBytes, int Length1, int Length2) BuildSimpleFontFileBytes() + { + var charstring = new List(); + SyntheticFontBuilder.WriteType1CharstringNumber(charstring, 0); + SyntheticFontBuilder.WriteType1CharstringNumber(charstring, 600); + SyntheticFontBuilder.WriteType1CharstringOperator(charstring, 13); // hsbw + SyntheticFontBuilder.WriteType1CharstringOperator(charstring, 14); // endchar + + return SyntheticFontBuilder.Type1([(".notdef", [.. charstring])]); + } + + /// + /// Proves that Type1PfaReader TrySniff RecognizesPercentBangPrefix. + /// + [Fact] + public void Type1PfaReader_TrySniff_RecognizesPercentBangPrefix() + { + Assert.True(Type1PfaReader.TrySniff("%!PS-AdobeFont-1.0"u8.ToArray())); + } + + /// + /// Proves that Type1PfaReader TrySniff NonPfaData ReturnsFalse. + /// + [Fact] + public void Type1PfaReader_TrySniff_NonPfaData_ReturnsFalse() + { + Assert.False(Type1PfaReader.TrySniff([0x80, 0x01, 0, 0, 0, 0])); + Assert.False(Type1PfaReader.TrySniff([])); + Assert.False(Type1PfaReader.TrySniff([(byte)'%'])); + } + + /// + /// Proves that Type1PfaReader Read WellFormedFile ReassemblesLength1AndLength2. + /// + [Fact] + public void Type1PfaReader_Read_WellFormedFile_ReassemblesLength1AndLength2() + { + var program = BuildSimpleFontFileBytes(); + var pfa = SyntheticFontBuilder.Type1Pfa(program.FontFileBytes, program.Length1, program.Length2); + + var (fontFileBytes, length1, length2) = Type1PfaReader.Read(pfa); + + Assert.Equal(program.Length2, length2); + Assert.Equal( + program.FontFileBytes.AsSpan(program.Length1, program.Length2).ToArray(), + fontFileBytes.AsSpan(length1, length2).ToArray()); + } + + /// + /// Proves that Type1PfaReader Read MultiLineWhitespaceWrappedHex ToleratesEmbeddedWhitespace. + /// + [Fact] + public void Type1PfaReader_Read_MultiLineWhitespaceWrappedHex_ToleratesEmbeddedWhitespace() + { + var program = BuildSimpleFontFileBytes(); + var pfa = SyntheticFontBuilder.Type1Pfa(program.FontFileBytes, program.Length1, program.Length2, hexLineWidth: 4); + + var (fontFileBytes, length1, length2) = Type1PfaReader.Read(pfa); + + Assert.Equal(program.Length2, length2); + Assert.Equal( + program.FontFileBytes.AsSpan(program.Length1, program.Length2).ToArray(), + fontFileBytes.AsSpan(length1, length2).ToArray()); + } + + /// + /// Proves that Type1PfaReader Read TrailingZeroPadding IsTrimmed. + /// + [Fact] + public void Type1PfaReader_Read_TrailingZeroPadding_IsTrimmed() + { + var program = BuildSimpleFontFileBytes(); + var pfa = SyntheticFontBuilder.Type1Pfa(program.FontFileBytes, program.Length1, program.Length2); + + var withPadding = new List(pfa); + withPadding.RemoveRange(withPadding.Count - 1, 1); // drop the trailing newline added by the helper + withPadding.AddRange("00000000000000000000000000000000\n"u8.ToArray()); // 17 zero bytes of padding + + var (_, _, length2) = Type1PfaReader.Read([.. withPadding]); + + Assert.Equal(program.Length2, length2); + } + + /// + /// Proves that Type1PfaReader Read MissingEexecKeyword ThrowsInvalidDataException. + /// + [Fact] + public void Type1PfaReader_Read_MissingEexecKeyword_ThrowsInvalidDataException() + { + var data = "%!PS-AdobeFont-1.0: NoEexecHere\n"u8.ToArray(); + Assert.Throws(() => Type1PfaReader.Read(data)); + } + + /// + /// Proves that Type1PfaReader Read OddHexDigitCount ThrowsInvalidDataException. + /// + [Fact] + public void Type1PfaReader_Read_OddHexDigitCount_ThrowsInvalidDataException() + { + var data = "%!PS-AdobeFont-1.0\neexec\nABC\n"u8.ToArray(); // 3 hex digits - odd count + Assert.Throws(() => Type1PfaReader.Read(data)); + } + + /// + /// Proves that Type1PfaReader Read NonHexByteBeforeAnyHexDigit ThrowsInvalidDataException. + /// + [Fact] + public void Type1PfaReader_Read_NonHexByteBeforeAnyHexDigit_ThrowsInvalidDataException() + { + var data = "%!PS-AdobeFont-1.0\neexec\nZZ\n"u8.ToArray(); // 'Z' is not a hex digit or whitespace + Assert.Throws(() => Type1PfaReader.Read(data)); + } + + /// + /// Proves that Type1PfaReader Read ZeroBytesAfterTrim ThrowsInvalidDataException. + /// + [Fact] + public void Type1PfaReader_Read_ZeroBytesAfterTrim_ThrowsInvalidDataException() + { + var data = "%!PS-AdobeFont-1.0\neexec\n0000\n"u8.ToArray(); // decodes to all-zero bytes, trimmed to none + Assert.Throws(() => Type1PfaReader.Read(data)); + } + + /// + /// Proves that Type1PfaReader Read LowercaseAndUppercaseHexDigits AreBothAccepted. + /// + [Fact] + public void Type1PfaReader_Read_LowercaseAndUppercaseHexDigits_AreBothAccepted() + { + var data = "%!PS-AdobeFont-1.0\neexec\naAbBcC\n"u8.ToArray(); + var (fontFileBytes, length1, length2) = Type1PfaReader.Read(data); + + Assert.Equal(3, length2); + Assert.Equal(new byte[] { 0xAA, 0xBB, 0xCC }, fontFileBytes.AsSpan(length1, length2).ToArray()); + } +} diff --git a/test/DemaConsulting.CanvasNet.Tests/Fonts/Type1PfbReaderTests.cs b/test/DemaConsulting.CanvasNet.Tests/Fonts/Type1PfbReaderTests.cs new file mode 100644 index 00000000..7f3fddb9 --- /dev/null +++ b/test/DemaConsulting.CanvasNet.Tests/Fonts/Type1PfbReaderTests.cs @@ -0,0 +1,204 @@ +// cspell:ignore eexec sniff +using DemaConsulting.CanvasNet.Fonts; +using DemaConsulting.CanvasNet.Tests.TestSupport; + +namespace DemaConsulting.CanvasNet.Tests.Fonts; + +/// +/// Unit tests for . +/// +public class Type1PfbReaderTests +{ + private static (byte[] FontFileBytes, int Length1, int Length2) BuildSimpleFontFileBytes() + { + var charstring = new List(); + SyntheticFontBuilder.WriteType1CharstringNumber(charstring, 0); + SyntheticFontBuilder.WriteType1CharstringNumber(charstring, 600); + SyntheticFontBuilder.WriteType1CharstringOperator(charstring, 13); // hsbw + SyntheticFontBuilder.WriteType1CharstringOperator(charstring, 14); // endchar + + return SyntheticFontBuilder.Type1([(".notdef", [.. charstring])]); + } + + /// + /// Proves that Type1PfbReader TrySniff RecognizesPfbHeader. + /// + [Fact] + public void Type1PfbReader_TrySniff_RecognizesPfbHeader() + { + var program = BuildSimpleFontFileBytes(); + var pfb = SyntheticFontBuilder.Type1Pfb(program.FontFileBytes, program.Length1, program.Length2); + + Assert.True(Type1PfbReader.TrySniff(pfb)); + } + + /// + /// Proves that Type1PfbReader TrySniff NonPfbData ReturnsFalse. + /// + [Fact] + public void Type1PfbReader_TrySniff_NonPfbData_ReturnsFalse() + { + Assert.False(Type1PfbReader.TrySniff("%!PS-AdobeFont"u8.ToArray())); + Assert.False(Type1PfbReader.TrySniff([])); + Assert.False(Type1PfbReader.TrySniff([0x80])); + } + + /// + /// Proves that Type1PfbReader Read TwoSegmentFile ReassemblesLength1AndLength2. + /// + [Fact] + public void Type1PfbReader_Read_TwoSegmentFile_ReassemblesLength1AndLength2() + { + var program = BuildSimpleFontFileBytes(); + var pfb = SyntheticFontBuilder.Type1Pfb(program.FontFileBytes, program.Length1, program.Length2); + + var (fontFileBytes, length1, length2) = Type1PfbReader.Read(pfb); + + Assert.Equal(program.Length1, length1); + Assert.Equal(program.Length2, length2); + Assert.Equal(program.FontFileBytes, fontFileBytes); + } + + /// + /// Proves that Type1PfbReader Read MultiSegmentFile ConcatenatesLikeTypedSegments. + /// + [Fact] + public void Type1PfbReader_Read_MultiSegmentFile_ConcatenatesLikeTypedSegments() + { + var program = BuildSimpleFontFileBytes(); + + // Split the cleartext region into two separate 0x01 segments, and the encrypted region + // into two separate 0x02 segments - proving the reader's segment loop is generic, not + // hard-coded to exactly one segment of each type. + var clearSplit = program.Length1 / 2; + var encSplit = program.Length2 / 2; + + var buf = new List(); + AppendSegment(buf, 0x01, program.FontFileBytes, 0, clearSplit); + AppendSegment(buf, 0x01, program.FontFileBytes, clearSplit, program.Length1 - clearSplit); + AppendSegment(buf, 0x02, program.FontFileBytes, program.Length1, encSplit); + AppendSegment(buf, 0x02, program.FontFileBytes, program.Length1 + encSplit, program.Length2 - encSplit); + buf.Add(0x80); + buf.Add(0x03); + + var (fontFileBytes, length1, length2) = Type1PfbReader.Read([.. buf]); + + Assert.Equal(program.Length1, length1); + Assert.Equal(program.Length2, length2); + Assert.Equal(program.FontFileBytes, fontFileBytes); + } + + /// + /// Proves that Type1PfbReader Read TrailingAsciiSegment DiscardsItAsTrailer. + /// + [Fact] + public void Type1PfbReader_Read_TrailingAsciiSegment_DiscardsItAsTrailer() + { + var program = BuildSimpleFontFileBytes(); + + var buf = new List(); + AppendSegment(buf, 0x01, program.FontFileBytes, 0, program.Length1); + AppendSegment(buf, 0x02, program.FontFileBytes, program.Length1, program.Length2); + AppendSegment(buf, 0x01, "0000\ncleartomark\n"u8.ToArray(), 0, 17); // conventional trailer + buf.Add(0x80); + buf.Add(0x03); + + var (fontFileBytes, length1, length2) = Type1PfbReader.Read([.. buf]); + + Assert.Equal(program.Length1, length1); + Assert.Equal(program.Length2, length2); + Assert.Equal(program.FontFileBytes, fontFileBytes); + } + + /// + /// Proves that Type1PfbReader Read NoBinarySegment ThrowsInvalidDataException. + /// + [Fact] + public void Type1PfbReader_Read_NoBinarySegment_ThrowsInvalidDataException() + { + var buf = new List(); + AppendSegment(buf, 0x01, "hello"u8.ToArray(), 0, 5); + buf.Add(0x80); + buf.Add(0x03); + + Assert.Throws(() => Type1PfbReader.Read([.. buf])); + } + + /// + /// Proves that Type1PfbReader Read MissingEndOfFileMarker ThrowsInvalidDataException. + /// + [Fact] + public void Type1PfbReader_Read_MissingEndOfFileMarker_ThrowsInvalidDataException() + { + var buf = new List(); + AppendSegment(buf, 0x02, "hello"u8.ToArray(), 0, 5); + + Assert.Throws(() => Type1PfbReader.Read([.. buf])); + } + + /// + /// Proves that Type1PfbReader Read UnrecognizedSegmentType ThrowsInvalidDataException. + /// + [Fact] + public void Type1PfbReader_Read_UnrecognizedSegmentType_ThrowsInvalidDataException() + { + var buf = new List(); + buf.Add(0x80); + buf.Add(0x05); // unrecognized segment type + buf.Add(0); + buf.Add(0); + buf.Add(0); + buf.Add(0); + + Assert.Throws(() => Type1PfbReader.Read([.. buf])); + } + + /// + /// Proves that Type1PfbReader Read TruncatedHeader ThrowsInvalidDataException. + /// + [Fact] + public void Type1PfbReader_Read_TruncatedHeader_ThrowsInvalidDataException() + { + byte[] data = [0x80, 0x02, 0, 0]; // segment header cut short (needs 6 bytes) + Assert.Throws(() => Type1PfbReader.Read(data)); + } + + /// + /// Proves that Type1PfbReader Read TruncatedPayload ThrowsInvalidDataException. + /// + [Fact] + public void Type1PfbReader_Read_TruncatedPayload_ThrowsInvalidDataException() + { + var buf = new List(); + buf.Add(0x80); + buf.Add(0x02); + buf.Add(100); // declares 100 bytes of payload + buf.Add(0); + buf.Add(0); + buf.Add(0); + buf.AddRange("short"u8.ToArray()); // but only 5 bytes actually follow + + Assert.Throws(() => Type1PfbReader.Read([.. buf])); + } + + /// + /// Proves that Type1PfbReader Read MissingSegmentMarkerByte ThrowsInvalidDataException. + /// + [Fact] + public void Type1PfbReader_Read_MissingSegmentMarkerByte_ThrowsInvalidDataException() + { + byte[] data = [0x00, 0x02, 0, 0, 0, 0]; + Assert.Throws(() => Type1PfbReader.Read(data)); + } + + private static void AppendSegment(List buf, byte segmentType, byte[] data, int offset, int length) + { + buf.Add(0x80); + buf.Add(segmentType); + buf.Add((byte)(length & 0xFF)); + buf.Add((byte)((length >> 8) & 0xFF)); + buf.Add((byte)((length >> 16) & 0xFF)); + buf.Add((byte)((length >> 24) & 0xFF)); + buf.AddRange(new ArraySegment(data, offset, length)); + } +} diff --git a/test/DemaConsulting.CanvasNet.Tests/Fonts/Type1TableTests.cs b/test/DemaConsulting.CanvasNet.Tests/Fonts/Type1TableTests.cs new file mode 100644 index 00000000..3b41d810 --- /dev/null +++ b/test/DemaConsulting.CanvasNet.Tests/Fonts/Type1TableTests.cs @@ -0,0 +1,365 @@ +// cspell:ignore charstring charstrings subr subrs notdef hsbw rlineto closepath endchar +// cspell:ignore lenIV rmoveto noaccess definefont currentfile closefile misparse nnoaccess +using DemaConsulting.CanvasNet.Fonts; +using DemaConsulting.CanvasNet.Tests.TestSupport; + +namespace DemaConsulting.CanvasNet.Tests.Fonts; + +/// +/// Unit tests for . +/// +public class Type1TableTests +{ + /// + /// Builds a simple charstring: hsbw then rmoveto to (10, 10), a line + /// to (20, 10), closepath, then endchar. + /// + private static byte[] SimpleCharstring(int sbx = 0, int width = 600) + { + var buf = new List(); + SyntheticFontBuilder.WriteType1CharstringNumber(buf, sbx); + SyntheticFontBuilder.WriteType1CharstringNumber(buf, width); + SyntheticFontBuilder.WriteType1CharstringOperator(buf, 13); // hsbw + SyntheticFontBuilder.WriteType1CharstringNumber(buf, 10); + SyntheticFontBuilder.WriteType1CharstringNumber(buf, 10); + SyntheticFontBuilder.WriteType1CharstringOperator(buf, 21); // rmoveto + SyntheticFontBuilder.WriteType1CharstringNumber(buf, 10); + SyntheticFontBuilder.WriteType1CharstringNumber(buf, 0); + SyntheticFontBuilder.WriteType1CharstringOperator(buf, 5); // rlineto + SyntheticFontBuilder.WriteType1CharstringOperator(buf, 9); // closepath + SyntheticFontBuilder.WriteType1CharstringOperator(buf, 14); // endchar + return [.. buf]; + } + + /// + /// Builds an empty-outline charstring (a space glyph): just hsbw then + /// endchar, with no path operators. + /// + private static byte[] SpaceCharstring(int width = 300) + { + var buf = new List(); + SyntheticFontBuilder.WriteType1CharstringNumber(buf, 0); + SyntheticFontBuilder.WriteType1CharstringNumber(buf, width); + SyntheticFontBuilder.WriteType1CharstringOperator(buf, 13); // hsbw + SyntheticFontBuilder.WriteType1CharstringOperator(buf, 14); // endchar + return [.. buf]; + } + + private static Type1Table Parse((byte[] FontFileBytes, int Length1, int Length2) program) => + Type1Table.Parse(program.FontFileBytes, program.Length1, program.Length2); + + /// + /// Proves that Type1Table Parse WellFormedFont ExposesGlyphCountAndOutlines. + /// + [Fact] + public void Type1Table_Parse_WellFormedFont_ExposesGlyphCountAndOutlines() + { + var program = SyntheticFontBuilder.Type1( + [(".notdef", SpaceCharstring()), ("space", SpaceCharstring()), ("A", SimpleCharstring())]); + var table = Parse(program); + + Assert.Equal(3, table.GlyphCount); + Assert.Empty(table.GetGlyphOutline(1).Subpaths); // space - no contour + Assert.Single(table.GetGlyphOutline(2).Subpaths); // A - one contour + } + + /// + /// Proves that Type1Table Parse NotdefNotFirst ReindexesToGlyphZero. + /// + [Fact] + public void Type1Table_Parse_NotdefNotFirst_ReindexesToGlyphZero() + { + var program = SyntheticFontBuilder.Type1( + [("space", SpaceCharstring()), ("A", SimpleCharstring()), (".notdef", SpaceCharstring(width: 999))]); + var table = Parse(program); + + Assert.True(table.TryGetGlyphIndex(".notdef", out var notdefIndex)); + Assert.Equal(0, notdefIndex); + Assert.Equal(999, table.GetAdvanceWidth(0)); + + Assert.True(table.TryGetGlyphIndex("space", out var spaceIndex)); + Assert.Equal(1, spaceIndex); + + Assert.True(table.TryGetGlyphIndex("A", out var aIndex)); + Assert.Equal(2, aIndex); + } + + /// + /// Proves that Type1Table GetAdvanceWidth ReturnsHsbwWidth. + /// + [Fact] + public void Type1Table_GetAdvanceWidth_ReturnsHsbwWidth() + { + var program = SyntheticFontBuilder.Type1([(".notdef", SimpleCharstring(width: 742))]); + var table = Parse(program); + + Assert.Equal(742, table.GetAdvanceWidth(0)); + } + + /// + /// Proves that Type1Table TryGetGlyphIndex UnknownName ReturnsFalse. + /// + [Fact] + public void Type1Table_TryGetGlyphIndex_UnknownName_ReturnsFalse() + { + var program = SyntheticFontBuilder.Type1([(".notdef", SimpleCharstring())]); + var table = Parse(program); + + Assert.False(table.TryGetGlyphIndex("nonexistent", out _)); + } + + /// + /// Proves that Type1Table GetGlyphOutline OutOfRangeIndex ThrowsArgumentOutOfRangeException. + /// + [Fact] + public void Type1Table_GetGlyphOutline_OutOfRangeIndex_ThrowsArgumentOutOfRangeException() + { + var table = Parse(SyntheticFontBuilder.Type1([(".notdef", SimpleCharstring())])); + Assert.Throws(() => table.GetGlyphOutline(1)); + Assert.Throws(() => table.GetGlyphOutline(-1)); + } + + /// + /// Proves that Type1Table Parse WithLocalSubrs DecodesGlyphUsingCallSubr. + /// + [Fact] + public void Type1Table_Parse_WithLocalSubrs_DecodesGlyphUsingCallSubr() + { + var subr = new List(); + SyntheticFontBuilder.WriteType1CharstringNumber(subr, 5); + SyntheticFontBuilder.WriteType1CharstringNumber(subr, 5); + SyntheticFontBuilder.WriteType1CharstringOperator(subr, 5); // rlineto + SyntheticFontBuilder.WriteType1CharstringOperator(subr, 11); // return + + var main = new List(); + SyntheticFontBuilder.WriteType1CharstringNumber(main, 0); + SyntheticFontBuilder.WriteType1CharstringNumber(main, 600); + SyntheticFontBuilder.WriteType1CharstringOperator(main, 13); // hsbw + SyntheticFontBuilder.WriteType1CharstringNumber(main, 10); + SyntheticFontBuilder.WriteType1CharstringNumber(main, 10); + SyntheticFontBuilder.WriteType1CharstringOperator(main, 21); // rmoveto + SyntheticFontBuilder.WriteType1CharstringNumber(main, 0); // subr index - no bias in Type 1 + SyntheticFontBuilder.WriteType1CharstringOperator(main, 10); // callsubr + SyntheticFontBuilder.WriteType1CharstringOperator(main, 9); // closepath + SyntheticFontBuilder.WriteType1CharstringOperator(main, 14); // endchar + + var program = SyntheticFontBuilder.Type1([(".notdef", [.. main])], subrs: [[.. subr]]); + var table = Parse(program); + + Assert.Single(table.GetGlyphOutline(0).Subpaths); + } + + /// + /// Proves that Type1Table Parse RdNdNpTokens ScannerIsProcedureNameAgnostic. + /// + [Theory] + [InlineData("RD", "ND", "NP")] + [InlineData("-|", "|-", "|-")] + [InlineData("Foo123", "Bar456", "Baz789")] + public void Type1Table_Parse_VariousProcNameTokens_ScannerIsProcedureNameAgnostic(string readToken, string defToken, string subrDefToken) + { + var subr = new List(); + SyntheticFontBuilder.WriteType1CharstringNumber(subr, 5); + SyntheticFontBuilder.WriteType1CharstringNumber(subr, 5); + SyntheticFontBuilder.WriteType1CharstringOperator(subr, 5); // rlineto + SyntheticFontBuilder.WriteType1CharstringOperator(subr, 11); // return + + var main = new List(); + SyntheticFontBuilder.WriteType1CharstringNumber(main, 0); + SyntheticFontBuilder.WriteType1CharstringNumber(main, 600); + SyntheticFontBuilder.WriteType1CharstringOperator(main, 13); // hsbw + SyntheticFontBuilder.WriteType1CharstringNumber(main, 10); + SyntheticFontBuilder.WriteType1CharstringNumber(main, 10); + SyntheticFontBuilder.WriteType1CharstringOperator(main, 21); // rmoveto + SyntheticFontBuilder.WriteType1CharstringNumber(main, 0); + SyntheticFontBuilder.WriteType1CharstringOperator(main, 10); // callsubr + SyntheticFontBuilder.WriteType1CharstringOperator(main, 14); // endchar + + // Subrs use 'defToken' for its own dedicated (font-specific) define-procedure token + // (conventionally 'NP'/'|-'), which may differ from CharStrings' own 'ND'/'|-' token - + // both are passed through the same 'defToken' parameter here since Type1's own builder + // applies one token uniformly, but the scanner never inspects either spelling regardless. + _ = subrDefToken; + var program = SyntheticFontBuilder.Type1([(".notdef", [.. main])], subrs: [[.. subr]], readToken: readToken, defToken: defToken); + var table = Parse(program); + + Assert.Single(table.GetGlyphOutline(0).Subpaths); + } + + /// + /// Proves that Type1Table Parse CustomLenIv DecodesCorrectly. + /// + [Theory] + [InlineData(0)] + [InlineData(1)] + [InlineData(4)] + [InlineData(8)] + public void Type1Table_Parse_CustomLenIv_DecodesCorrectly(int lenIv) + { + var program = SyntheticFontBuilder.Type1([(".notdef", SimpleCharstring())], lenIv: lenIv); + var table = Parse(program); + + Assert.Single(table.GetGlyphOutline(0).Subpaths); + } + + /// + /// Proves that Type1Table GetAdvanceWidth FractionalWidthViaDiv ReturnsRoundedWidth. + /// + /// + /// Real-world Type 1 fonts (this exact idiom was found in a pdfLaTeX Computer Modern + /// font) commonly encode a fractional side-bearing/width - which plain Type 1 integers + /// cannot represent - as <numerator> <denominator> div immediately + /// before hsbw (for example 0 20225 61 div hsbw for a width of + /// 331.56..., here rounded to 332). 's advance-width peek must + /// tolerate this, not just a literal <sbx> <width> hsbw. + /// + [Fact] + public void Type1Table_GetAdvanceWidth_FractionalWidthViaDiv_ReturnsRoundedWidth() + { + var buf = new List(); + SyntheticFontBuilder.WriteType1CharstringNumber(buf, 0); + SyntheticFontBuilder.WriteType1CharstringNumber(buf, 20225); + SyntheticFontBuilder.WriteType1CharstringNumber(buf, 61); + SyntheticFontBuilder.WriteType1CharstringOperator(buf, 1212); // escape 12 12 = div + SyntheticFontBuilder.WriteType1CharstringOperator(buf, 13); // hsbw + SyntheticFontBuilder.WriteType1CharstringOperator(buf, 14); // endchar + + var program = SyntheticFontBuilder.Type1([(".notdef", [.. buf])]); + var table = Parse(program); + + Assert.Equal(332, table.GetAdvanceWidth(0)); + } + + /// + /// Proves that Type1Table Parse SeacOperator ThrowsInvalidDataException. + /// + [Fact] + public void Type1Table_Parse_SeacOperator_ThrowsInvalidDataException() + { + // 'seac' is rejected while Type1Table.Parse itself peeks the glyph's advance width (the + // peek requires the charstring to begin with 'hsbw'/'sbw', which a 'seac'-based + // accented-composite glyph never does), so the rejection surfaces at Parse time here + // rather than at GetGlyphOutline time - Type1CharstringInterpreterTests covers the + // interpreter's own direct 'seac' rejection for a well-formed (hsbw-prefixed) charstring. + var buf = new List(); + SyntheticFontBuilder.WriteType1CharstringNumber(buf, 0); + SyntheticFontBuilder.WriteType1CharstringNumber(buf, 0); + SyntheticFontBuilder.WriteType1CharstringNumber(buf, 0); + SyntheticFontBuilder.WriteType1CharstringNumber(buf, 65); + SyntheticFontBuilder.WriteType1CharstringNumber(buf, 66); + SyntheticFontBuilder.WriteType1CharstringOperator(buf, 1206); // escape 12 6 = seac + + var program = SyntheticFontBuilder.Type1([(".notdef", [.. buf])]); + + Assert.Throws(() => Parse(program)); + } + + /// + /// Proves that Type1Table Parse MissingCharStrings ThrowsInvalidDataException. + /// + [Fact] + public void Type1Table_Parse_MissingCharStrings_ThrowsInvalidDataException() + { + var fontFileBytes = System.Text.Encoding.ASCII.GetBytes("%!PS-AdobeFont\ncurrentfile eexec\n"); + Assert.Throws(() => Type1Table.Parse(fontFileBytes, fontFileBytes.Length, 0)); + } + + /// + /// Proves that Type1Table Parse NegativeLength ThrowsInvalidDataException. + /// + [Fact] + public void Type1Table_Parse_NegativeLength_ThrowsInvalidDataException() + { + var program = SyntheticFontBuilder.Type1([(".notdef", SimpleCharstring())]); + Assert.Throws(() => Type1Table.Parse(program.FontFileBytes, -1, program.Length2)); + Assert.Throws(() => Type1Table.Parse(program.FontFileBytes, program.Length1, -1)); + } + + /// + /// Proves that Type1Table Parse LengthsExceedFileBounds ThrowsInvalidDataException. + /// + [Fact] + public void Type1Table_Parse_LengthsExceedFileBounds_ThrowsInvalidDataException() + { + var program = SyntheticFontBuilder.Type1([(".notdef", SimpleCharstring())]); + Assert.Throws(() => Type1Table.Parse(program.FontFileBytes, program.Length1, program.Length2 + 1000)); + } + + /// + /// Proves that Type1Table Parse EmptyCharStrings ThrowsInvalidDataException. + /// + [Fact] + public void Type1Table_Parse_EmptyCharStrings_ThrowsInvalidDataException() + { + var program = SyntheticFontBuilder.Type1([]); + Assert.Throws(() => Parse(program)); + } + + /// + /// Proves that Type1Table Parse CharStringsLengthNearIntMaxValue ThrowsInvalidDataException. + /// + /// + /// A crafted font can declare a '/CharStrings' entry length near int.MaxValue; 'pos' (the + /// plaintext scan cursor) plus that length overflows ordinary 32-bit arithmetic. The scanner + /// must widen to long before adding (see HmtxHheaReader's and this file's own Length1+Length2 + /// check for the established convention) so this is reported as the documented + /// InvalidDataException rather than an unhandled OverflowException. + /// + [Fact] + public void Type1Table_Parse_CharStringsLengthNearIntMaxValue_ThrowsInvalidDataException() + { + var program = SyntheticFontBuilder.Type1( + [(".notdef", SimpleCharstring())], + charStringLengthOverrides: new Dictionary { [".notdef"] = int.MaxValue - 16 }); + + Assert.Throws(() => Parse(program)); + } + + /// + /// Proves that Type1Table Parse SubrIndexExceedsMaximum ThrowsInvalidDataException. + /// + /// + /// A crafted font's '/Subrs' dictionary can declare a single entry with a huge, sparse + /// dup <index> (for example near ); naively sizing a + /// dense array from that index as its length would risk an out-of-memory allocation driven + /// entirely by untrusted font data. The parser must reject an index beyond its supported + /// bound instead. + /// + [Fact] + public void Type1Table_Parse_SubrIndexExceedsMaximum_ThrowsInvalidDataException() + { + var program = SyntheticFontBuilder.Type1( + [(".notdef", SimpleCharstring())], + subrs: [SimpleCharstring()], + subrIndices: [1_000_000_000]); + + Assert.Throws(() => Parse(program)); + } + + /// + /// Proves that Type1Table Parse TrailingBoilerplateAfterCharStringsEnd DoesNotMisparse. + /// + /// + /// Real-world Type 1 fonts (this exact idiom was found in a pdfLaTeX Computer Modern + /// font) commonly follow the /CharStrings dictionary's own closing end with + /// standard font-closing PostScript boilerplate in the very same decrypted plaintext + /// (end readonly put noaccess put dup /FontName get exch definefont pop mark + /// currentfile closefile). An unbounded scanner mistakes the boilerplate's + /// /FontName token for a further glyph-name entry, then fails trying to parse its + /// next token (get) as an integer length. 's scanner must + /// stop at the dictionary's own matching closing end instead. + /// + [Fact] + public void Type1Table_Parse_TrailingBoilerplateAfterCharStringsEnd_DoesNotMisparse() + { + var program = SyntheticFontBuilder.Type1( + [(".notdef", SpaceCharstring()), ("space", SpaceCharstring()), ("A", SimpleCharstring())], + trailer: "readonly put\nnoaccess put\ndup /FontName get exch definefont pop\nmark currentfile closefile\n"); + + var table = Parse(program); + + Assert.Equal(3, table.GlyphCount); + Assert.Empty(table.GetGlyphOutline(1).Subpaths); // space - no contour + Assert.Single(table.GetGlyphOutline(2).Subpaths); // A - one contour + } +} diff --git a/test/DemaConsulting.CanvasNet.Tests/TestSupport/SyntheticFontBuilder.cs b/test/DemaConsulting.CanvasNet.Tests/TestSupport/SyntheticFontBuilder.cs index 8b4a1ac6..b7ca3826 100644 --- a/test/DemaConsulting.CanvasNet.Tests/TestSupport/SyntheticFontBuilder.cs +++ b/test/DemaConsulting.CanvasNet.Tests/TestSupport/SyntheticFontBuilder.cs @@ -1,6 +1,9 @@ // cspell:ignore SFNT Sfnt sfnt glyf Glyf cmap Cmap loca Loca hmtx Hmtx hhea Hhea // cspell:ignore maxp Maxp notdef codepoint codepoints subtable subtables subsetted -// cspell:ignore subsetting PPEM OTTO +// cspell:ignore subsetting PPEM OTTO ttcf +// cspell:ignore noaccess definefont currentfile closefile nnoaccess +using DemaConsulting.CanvasNet.Fonts; + namespace DemaConsulting.CanvasNet.Tests.TestSupport; /// @@ -132,7 +135,7 @@ private static void WriteUInt32At(List buf, int index, uint value) /// /// Builds a 54-byte head table. /// - public static byte[] Head(int unitsPerEm, int indexToLocFormat) + public static byte[] Head(int unitsPerEm, int indexToLocFormat, int macStyle = 0) { var buf = new List(); WriteUInt32(buf, 0x00010000); // version @@ -150,7 +153,7 @@ public static byte[] Head(int unitsPerEm, int indexToLocFormat) WriteInt16(buf, 0); // yMin WriteInt16(buf, 0); // xMax WriteInt16(buf, 0); // yMax - WriteUInt16(buf, 0); // macStyle + WriteUInt16(buf, macStyle); WriteUInt16(buf, 0); // lowestRecPPEM WriteInt16(buf, 0); // fontDirectionHint WriteInt16(buf, indexToLocFormat); @@ -553,6 +556,385 @@ private static byte[] WrapSingleSubtableCmap(int platformId, int encodingId, byt return [.. buf]; } + /// + /// Builds a minimal, well-formed raw CFF table byte array (Header, Name INDEX, Top DICT + /// INDEX, String INDEX, Global Subr INDEX, an optional Private DICT + Local Subr INDEX, + /// the CharStrings INDEX, and an optional custom charset table) directly from + /// already-encoded Type 2 charstring bytecode for each glyph - used to exercise + /// and without any + /// third-party font fixture. + /// + /// Every glyph's raw Type 2 charstring bytecode, glyph 0 first. + /// Every global subroutine's raw bytecode, index order. + /// Every local subroutine's raw bytecode, index order. + /// + /// When , the Top DICT declares the ROS operator (CID-keyed + /// font identification) so 's CID-rejection path can be exercised; + /// is then not required to be well-formed, since the CID + /// check happens first. + /// + /// + /// When , the Top DICT omits the Private operator entirely + /// (a legitimate CFF font may have no Private DICT at all), so no local subroutines are + /// reachable regardless of , and + /// / are not written. + /// + /// + /// The font's own custom String INDEX entries (SID 391 and above, in index order) - + /// empty (the default) when the test has no custom glyph names to resolve. + /// + /// + /// When set, the Top DICT's charset operator is written with this predefined + /// charset ID (0/1/2). Mutually exclusive with + /// ; when both are (the default), + /// the charset operator is omitted entirely (the ISOAdobe/Standard default). + /// + /// + /// When set, a custom charset table's already-encoded raw bytes (format byte plus data), + /// appended after every other section; the Top DICT's charset operator is written + /// with this table's own computed byte offset. Mutually exclusive with + /// . + /// + /// + /// When set (and is ), the Private + /// DICT's defaultWidthX operator (20) is written with this value. + /// + /// + /// When set (and is ), the Private + /// DICT's nominalWidthX operator (21) is written with this value. + /// + public static byte[] Cff( + IReadOnlyList charStrings, + IReadOnlyList? globalSubrs = null, + IReadOnlyList? localSubrs = null, + bool includeRos = false, + bool includePrivate = true, + IReadOnlyList? stringIndexEntries = null, + int? charsetId = null, + byte[]? charsetTable = null, + int? defaultWidthX = null, + int? nominalWidthX = null) + { + globalSubrs ??= []; + localSubrs ??= []; + stringIndexEntries ??= []; + + byte[] header = [1, 0, 4, 4]; // major, minor, hdrSize, offSize (offSize is advisory only) + var nameIndex = WriteCffIndex([System.Text.Encoding.ASCII.GetBytes("Synthetic")]); + var stringIndex = WriteCffIndex(stringIndexEntries); + var globalSubrIndex = WriteCffIndex(globalSubrs); + var charStringsIndex = WriteCffIndex(charStrings); + + // The Private DICT's own content is built up operator-by-operator (every operand always + // using the fixed 5-byte integer encoding, so each operator contributes a fixed 6 bytes), + // so the Local Subr INDEX's offset relative to the Private DICT's own start - the 'Subrs' + // operand - is always exactly "bytes already written", computed as each operator is added + // rather than hardcoded, now that defaultWidthX/nominalWidthX may also be present. + var privateDict = new List(); + if (includePrivate && defaultWidthX.HasValue) + { + WriteDictInt(privateDict, defaultWidthX.Value); + WriteDictOperator(privateDict, 20); // defaultWidthX + } + + if (includePrivate && nominalWidthX.HasValue) + { + WriteDictInt(privateDict, nominalWidthX.Value); + WriteDictOperator(privateDict, 21); // nominalWidthX + } + + if (includePrivate && localSubrs.Count > 0) + { + WriteDictInt(privateDict, privateDict.Count + 6); + WriteDictOperator(privateDict, 19); // Subrs + } + + var privateDictBytes = privateDict.ToArray(); + var localSubrIndex = WriteCffIndex(localSubrs); + + // Two-pass layout: the Top DICT's own byte length is fixed by which operators it + // contains (every operand uses the fixed 5-byte integer encoding), independent of the + // operand *values* - so a placeholder Top DICT (all offsets 0, but with a placeholder + // 'charset' operand present whenever the real one will be) has the exact same length as + // the final one, letting every other section's position be computed from it before the + // Top DICT's real offset values are known. + var hasCharsetOperator = charsetId.HasValue || charsetTable is not null; + var topDictPlaceholder = BuildTopDict(0, 0, 0, hasCharsetOperator ? 0 : null, includePrivate, includeRos); + var topDictIndexPlaceholder = WriteCffIndex([topDictPlaceholder]); + + var prefixLength = header.Length + nameIndex.Length + topDictIndexPlaceholder.Length + stringIndex.Length + globalSubrIndex.Length; + var charStringsOffset = prefixLength; + var privateOffset = charStringsOffset + charStringsIndex.Length; + var privateSize = privateDictBytes.Length; + var charsetTableOffset = privateOffset + privateSize + localSubrIndex.Length; + + var charsetOperand = charsetTable is not null ? charsetTableOffset : charsetId; + var topDict = BuildTopDict(charStringsOffset, privateOffset, privateSize, charsetOperand, includePrivate, includeRos); + var topDictIndex = WriteCffIndex([topDict]); + + var buf = new List(); + buf.AddRange(header); + buf.AddRange(nameIndex); + buf.AddRange(topDictIndex); + buf.AddRange(stringIndex); + buf.AddRange(globalSubrIndex); + buf.AddRange(charStringsIndex); + buf.AddRange(privateDictBytes); + buf.AddRange(localSubrIndex); + if (charsetTable is not null) + { + buf.AddRange(charsetTable); + } + + return [.. buf]; + } + + /// + /// Builds a Top DICT's raw bytes, including the CharStrings operator, an optional + /// Private operator, an optional charset operator, and (for CID-rejection + /// tests) an optional ROS operator - every operand encoded with the fixed 5-byte + /// integer form, so this method's return length depends only on + /// /'s null-ness/ + /// , never on the operand values themselves. + /// + private static byte[] BuildTopDict( + int charStringsOffset, int privateOffset, int privateSize, int? charsetOperand, bool includePrivate, bool includeRos) + { + var buf = new List(); + if (includeRos) + { + WriteDictInt(buf, 0); + WriteDictInt(buf, 0); + WriteDictInt(buf, 0); + WriteDictOperator(buf, 1230); // ROS + } + + if (charsetOperand.HasValue) + { + WriteDictInt(buf, charsetOperand.Value); + WriteDictOperator(buf, 15); // charset + } + + WriteDictInt(buf, charStringsOffset); + WriteDictOperator(buf, 17); // CharStrings + + if (includePrivate) + { + WriteDictInt(buf, privateSize); + WriteDictInt(buf, privateOffset); + WriteDictOperator(buf, 18); // Private + } + + return [.. buf]; + } + + /// + /// Appends a CFF DICT integer operand using the fixed 5-byte form (29 followed by a + /// big-endian ) - always exactly 5 bytes, regardless of magnitude, + /// which is what lets 's length stay independent of its operand + /// values (see 's remarks on its two-pass layout). + /// + private static void WriteDictInt(List buf, int value) + { + buf.Add(29); + WriteUInt32(buf, unchecked((uint)value)); + } + + /// + /// Appends a CFF DICT operator: a single byte for an operator code below 1200, or + /// the two-byte escape form (12, code - 1200) otherwise. + /// + private static void WriteDictOperator(List buf, int op) + { + if (op >= 1200) + { + buf.Add(12); + buf.Add((byte)(op - 1200)); + } + else + { + buf.Add((byte)op); + } + } + + /// + /// Appends a Type 2 charstring numeric operand using the smallest applicable encoding: the + /// compact single/two-byte forms for -1131..1131, the 3-byte 28 form for the + /// rest of the 16-bit signed range, or the 5-byte 255 16.16 fixed-point form for + /// anything larger, or for a non-integral value. + /// + public static void WriteCharstringNumber(List buf, double value) + { + var isInteger = Math.Abs(value - Math.Round(value)) < double.Epsilon; + if (isInteger && value is >= -32768 and <= 32767) + { + var intValue = (int)value; + switch (intValue) + { + case >= -107 and <= 107: + buf.Add((byte)(intValue + 139)); + return; + case >= 108 and <= 1131: + buf.Add((byte)(((intValue - 108) / 256) + 247)); + buf.Add((byte)((intValue - 108) % 256)); + return; + case >= -1131 and <= -108: + var positive = -intValue - 108; + buf.Add((byte)((positive / 256) + 251)); + buf.Add((byte)(positive % 256)); + return; + default: + buf.Add(28); + WriteInt16(buf, intValue); + return; + } + } + + WriteCharstringFixed(buf, value); + } + + /// + /// Appends a Type 2 charstring numeric operand using the 5-byte 255 16.16 + /// fixed-point form, regardless of whether could fit a more + /// compact integer encoding - used to deliberately exercise the fixed-point decode path. + /// + public static void WriteCharstringFixed(List buf, double value) + { + buf.Add(255); + WriteUInt32(buf, unchecked((uint)(int)Math.Round(value * 65536.0))); + } + + /// + /// Appends a Type 2 charstring operator: a single byte for an operator code below + /// 1200 (the two-byte escape range base used by this test support's own encoding, + /// matching 's convention), or the two-byte escape form + /// (12, code - 1200) otherwise. + /// + public static void WriteCharstringOperator(List buf, int op) + { + if (op >= 1200) + { + buf.Add(12); + buf.Add((byte)(op - 1200)); + } + else + { + buf.Add((byte)op); + } + } + + /// + /// Builds a raw CFF INDEX structure's bytes (count, offSize, the 1-based + /// offset array, and the concatenated entry bytes) from a list of already-encoded entries. + /// Writes only the 2-byte count field (no offSize/offset array/data) when + /// is empty, per the CFF INDEX format. + /// + public static byte[] WriteCffIndex(IReadOnlyList entries) + { + var buf = new List(); + WriteUInt16(buf, entries.Count); + if (entries.Count == 0) + { + return [.. buf]; + } + + var totalDataLength = entries.Sum(e => e.Length); + var maxOffset = totalDataLength + 1; + var offSize = maxOffset switch + { + <= 0xFF => 1, + <= 0xFFFF => 2, + <= 0xFFFFFF => 3, + _ => 4, + }; + + buf.Add((byte)offSize); + + var offset = 1; + WriteOffset(buf, offset, offSize); + foreach (var entry in entries) + { + offset += entry.Length; + WriteOffset(buf, offset, offSize); + } + + foreach (var entry in entries) + { + buf.AddRange(entry); + } + + return [.. buf]; + } + + /// + /// Appends a CFF INDEX offset-array entry as a big-endian, unsigned integer occupying + /// exactly bytes. + /// + private static void WriteOffset(List buf, int value, int offSize) + { + for (var i = offSize - 1; i >= 0; i--) + { + buf.Add((byte)((value >> (8 * i)) & 0xFF)); + } + } + + /// + /// Assembles two or more independently-built, standalone SFNT font byte arrays (each + /// already internally self-consistent, with table directory offsets relative to its own + /// start) into a single ttcf TrueType Collection container, rewriting each face's + /// table directory entries' offsets to be absolute from the start of the container - + /// exactly the transformation a real multi-face ttcf container's table directory + /// entries already reflect (see 's remarks). + /// + /// Every face's complete, standalone SFNT byte array, in face order. + /// A single well-formed ttcf container holding every given face. + public static byte[] Ttc(IReadOnlyList faces) + { + var numFonts = faces.Count; + var headerSize = 12 + 4 * numFonts; + var faceStarts = new int[numFonts]; + var cursor = headerSize; + for (var i = 0; i < numFonts; i++) + { + faceStarts[i] = cursor; + cursor += faces[i].Length; + } + + var buf = new List(); + WriteUInt32(buf, 0x74746366); // ttcTag ('ttcf') + WriteUInt16(buf, 1); // majorVersion + WriteUInt16(buf, 0); // minorVersion + WriteUInt32(buf, (uint)numFonts); + foreach (var faceStart in faceStarts) + { + WriteUInt32(buf, (uint)faceStart); + } + + for (var i = 0; i < numFonts; i++) + { + var faceBytes = (byte[])faces[i].Clone(); + var numTables = (faceBytes[4] << 8) | faceBytes[5]; + for (var t = 0; t < numTables; t++) + { + var entryOffset = 12 + t * 16 + 8; + var original = + ((uint)faceBytes[entryOffset] << 24) | + ((uint)faceBytes[entryOffset + 1] << 16) | + ((uint)faceBytes[entryOffset + 2] << 8) | + faceBytes[entryOffset + 3]; + var rewritten = original + (uint)faceStarts[i]; + faceBytes[entryOffset] = (byte)((rewritten >> 24) & 0xFF); + faceBytes[entryOffset + 1] = (byte)((rewritten >> 16) & 0xFF); + faceBytes[entryOffset + 2] = (byte)((rewritten >> 8) & 0xFF); + faceBytes[entryOffset + 3] = (byte)(rewritten & 0xFF); + } + + buf.AddRange(faceBytes); + } + + return [.. buf]; + } + /// /// Builds a kern table with a single format 0, horizontal-only subtable containing /// the given sorted (left, right, value) pairs. @@ -584,4 +966,405 @@ public static byte[] KernFormat0(IReadOnlyList<(int Left, int Right, int Value)> buf.AddRange(subtable); return [.. buf]; } + + /// + /// Describes one name table record to be built by . + /// + public readonly record struct NameRecord(int PlatformId, int EncodingId, int LanguageId, int NameId, string Value); + + /// + /// Builds a format-0 name table from an ordered list of records, encoding each + /// record's string as big-endian UTF-16 for a Windows platform record + /// ( 3) or as ASCII for a Macintosh platform + /// record ( 1) - sufficient for every test in + /// this suite, since no synthetic fixture needs a genuinely non-ASCII Mac Roman string. + /// + public static byte[] Name(IReadOnlyList records) + { + var stringData = new List(); + var entries = new List<(int Offset, int Length)>(); + foreach (var record in records) + { + var encoded = record.PlatformId == 1 + ? System.Text.Encoding.ASCII.GetBytes(record.Value) + : System.Text.Encoding.BigEndianUnicode.GetBytes(record.Value); + entries.Add((stringData.Count, encoded.Length)); + stringData.AddRange(encoded); + } + + var buf = new List(); + WriteUInt16(buf, 0); // format + WriteUInt16(buf, records.Count); + WriteUInt16(buf, 6 + records.Count * 12); // stringOffset + + for (var i = 0; i < records.Count; i++) + { + var record = records[i]; + var (offset, length) = entries[i]; + WriteUInt16(buf, record.PlatformId); + WriteUInt16(buf, record.EncodingId); + WriteUInt16(buf, record.LanguageId); + WriteUInt16(buf, record.NameId); + WriteUInt16(buf, length); + WriteUInt16(buf, offset); + } + + buf.AddRange(stringData); + return [.. buf]; + } + + /// + /// Builds a full 64-byte version-0 OS/2 table (long enough to include + /// fsSelection), with every field beyond usWeightClass/fsSelection - + /// the only two fields reads - filled with a plausible + /// placeholder value. + /// + public static byte[] Os2(int usWeightClass, int fsSelection) + { + var buf = new List(); + WriteUInt16(buf, 0); // version + WriteInt16(buf, 0); // xAvgCharWidth + WriteUInt16(buf, usWeightClass); + WriteUInt16(buf, 5); // usWidthClass + WriteUInt16(buf, 0); // fsType + WriteInt16(buf, 0); // ySubscriptXSize + WriteInt16(buf, 0); // ySubscriptYSize + WriteInt16(buf, 0); // ySubscriptXOffset + WriteInt16(buf, 0); // ySubscriptYOffset + WriteInt16(buf, 0); // ySuperscriptXSize + WriteInt16(buf, 0); // ySuperscriptYSize + WriteInt16(buf, 0); // ySuperscriptXOffset + WriteInt16(buf, 0); // ySuperscriptYOffset + WriteInt16(buf, 0); // yStrikeoutSize + WriteInt16(buf, 0); // yStrikeoutPosition + WriteInt16(buf, 0); // sFamilyClass + for (var i = 0; i < 10; i++) + { + buf.Add(0); // panose + } + + WriteUInt32(buf, 0); // ulUnicodeRange1 + WriteUInt32(buf, 0); // ulUnicodeRange2 + WriteUInt32(buf, 0); // ulUnicodeRange3 + WriteUInt32(buf, 0); // ulUnicodeRange4 + buf.AddRange("SYNT"u8.ToArray()); // achVendID + WriteUInt16(buf, fsSelection); + return [.. buf]; + } + + /// + /// Builds a full 32-byte version-3.0 post table (long enough to include + /// isFixedPitch), with every field beyond italicAngle/isFixedPitch - + /// the only two fields reads - filled with a plausible + /// placeholder value. + /// + public static byte[] Post(int isFixedPitch, double italicAngle = 0) + { + var buf = new List(); + WriteUInt32(buf, 0x00030000); // version 3.0: no glyph name arrays follow + WriteUInt32(buf, unchecked((uint)(int)Math.Round(italicAngle * 65536.0))); // italicAngle (Fixed) + WriteInt16(buf, 0); // underlinePosition + WriteInt16(buf, 0); // underlineThickness + WriteUInt32(buf, unchecked((uint)isFixedPitch)); + WriteUInt32(buf, 0); // minMemType42 + WriteUInt32(buf, 0); // maxMemType42 + WriteUInt32(buf, 0); // minMemType1 + WriteUInt32(buf, 0); // maxMemType1 + return [.. buf]; + } + + /// + /// Appends a Type 1 charstring numeric operand using the smallest applicable encoding. + /// + /// + /// Unlike (Type 2's encoding), Type 1 has no 3-byte + /// 28 form - anything outside the compact single/two-byte ranges falls straight + /// through to the 5-byte 255 form, which for Type 1 is a plain 32-bit signed + /// integer (not Type 2's 16.16 fixed-point value). + /// + public static void WriteType1CharstringNumber(List buf, int value) + { + switch (value) + { + case >= -107 and <= 107: + buf.Add((byte)(value + 139)); + return; + case >= 108 and <= 1131: + buf.Add((byte)(((value - 108) / 256) + 247)); + buf.Add((byte)((value - 108) % 256)); + return; + case >= -1131 and <= -108: + var positive = -value - 108; + buf.Add((byte)((positive / 256) + 251)); + buf.Add((byte)(positive % 256)); + return; + default: + buf.Add(255); + WriteUInt32(buf, unchecked((uint)value)); + return; + } + } + + /// + /// Appends a Type 1 charstring operator: a single byte for an operator code below + /// 1200, or the two-byte escape form (12, code - 1200) otherwise - + /// matching 's convention for the escape range base. + /// + public static void WriteType1CharstringOperator(List buf, int op) + { + if (op >= 1200) + { + buf.Add(12); + buf.Add((byte)(op - 1200)); + } + else + { + buf.Add((byte)op); + } + } + + /// + /// Builds a complete, hand-authored classic PostScript Type 1 font program from already + /// hand-encoded glyph charstrings and (optional) subroutines, returning the byte layout + /// expects directly: Length1 cleartext bytes + /// immediately followed by Length2 eexec-encrypted bytes. + /// + /// + /// Every glyph's name and already-encoded (plaintext, not yet charstring-encrypted) + /// charstring bytes, in /CharStrings dict-encounter order. + /// + /// + /// Every subroutine's already-encoded (plaintext) charstring bytes, indexed by + /// subroutine number ( or empty for no /Subrs dictionary). + /// + /// + /// The /lenIV value to declare and to use for individual charstring/subroutine + /// encryption lead-in padding. Only an explicit /lenIV NN def declaration is + /// emitted when this differs from the Type 1 Font Format's documented default of 4. + /// + /// + /// The (arbitrary, font-specific) "read binary data" procedure name token to emit between + /// each entry's declared length and its raw bytes - varying this across test fixtures + /// proves 's scanner never depends on its literal spelling. + /// + /// + /// The (arbitrary, font-specific) "define" procedure name token to emit immediately after + /// each entry's raw bytes. + /// + /// + /// Optional raw ASCII text to append immediately after the private dictionary's closing + /// end\nend\n boilerplate, before eexec-encryption - defaults to empty, so + /// every existing call site is unaffected. Lets a test fixture reproduce the trailing + /// font-closing PostScript boilerplate (for example readonly put\nnoaccess put\ndup + /// /FontName get exch definefont pop\nmark currentfile closefile\n) that some + /// real-world Type 1 producers emit after the /CharStrings dictionary closes. + /// + /// + /// Optional per-glyph overrides for the declared /CharStrings entry length token, + /// keyed by glyph name - lets a test fixture declare a length that diverges from the + /// entry's real encrypted byte count (for example near ), to + /// simulate a crafted malicious font, while the actual appended encrypted bytes remain + /// unaffected. Defaults to null, so every existing call site is unaffected. + /// + /// + /// Optional per-entry dup <index> override, positionally paired with + /// - lets a test fixture declare a crafted, sparse subroutine + /// index (for example near ) instead of the default sequential + /// 0, 1, 2, ... numbering, to simulate a malicious font. Defaults to + /// , so every existing call site is unaffected. + /// + /// The assembled font program bytes, and its cleartext/encrypted region lengths. + public static (byte[] FontFileBytes, int Length1, int Length2) Type1( + IReadOnlyList<(string Name, byte[] Charstring)> charStrings, + IReadOnlyList? subrs = null, + int lenIv = 4, + string readToken = "RD", + string defToken = "ND", + string trailer = "", + IReadOnlyDictionary? charStringLengthOverrides = null, + IReadOnlyList? subrIndices = null) + { + subrs ??= []; + + var cleartext = System.Text.Encoding.ASCII.GetBytes( + "%!PS-AdobeFont-1.0: Synthetic\n/FontName /Synthetic def\ncurrentfile eexec\n"); + + var plaintext = new List(); + void AppendAscii(string s) => plaintext.AddRange(System.Text.Encoding.ASCII.GetBytes(s)); + + AppendAscii("dup /Private 15 dict dup begin\n"); + if (lenIv != 4) + { + AppendAscii($"/lenIV {lenIv} def\n"); + } + + if (subrs.Count > 0) + { + AppendAscii($"/Subrs {subrs.Count} array\n"); + for (var i = 0; i < subrs.Count; i++) + { + var index = subrIndices != null && i < subrIndices.Count ? subrIndices[i] : i; + var encrypted = EncryptType1Entry(subrs[i], lenIv); + AppendAscii($"dup {index} {encrypted.Length} {readToken} "); + plaintext.AddRange(encrypted); + AppendAscii($" {defToken}\n"); + } + } + + AppendAscii($"/CharStrings {charStrings.Count} dict dup begin\n"); + foreach (var (name, charstring) in charStrings) + { + var encrypted = EncryptType1Entry(charstring, lenIv); + var declaredLength = charStringLengthOverrides != null + && charStringLengthOverrides.TryGetValue(name, out var overrideLength) + ? overrideLength + : encrypted.Length; + AppendAscii($"/{name} {declaredLength} {readToken} "); + plaintext.AddRange(encrypted); + AppendAscii($" {defToken}\n"); + } + + AppendAscii("end\nend\n"); + if (trailer.Length > 0) + { + AppendAscii(trailer); + } + + // The eexec cipher's own leading discard count is fixed at 4 bytes, regardless of the + // font's declared '/lenIV' (which only governs individual charstring/subroutine entries). + var withLeadIn = new byte[4 + plaintext.Count]; + withLeadIn[0] = 0x01; + withLeadIn[1] = 0x02; + withLeadIn[2] = 0x03; + withLeadIn[3] = 0x04; + plaintext.CopyTo(withLeadIn, 4); + + var encryptedPrivateDict = EncryptType1(withLeadIn, EexecR0); + + var fontFileBytes = new byte[cleartext.Length + encryptedPrivateDict.Length]; + cleartext.CopyTo(fontFileBytes, 0); + encryptedPrivateDict.CopyTo(fontFileBytes, cleartext.Length); + + return (fontFileBytes, cleartext.Length, encryptedPrivateDict.Length); + } + + /// + /// Encrypts a single charstring/subroutine entry's plaintext bytes, prefixed with + /// arbitrary lead-in bytes, using the classic PostScript Type 1 + /// charstring cipher key - the inverse of what the production Type1Table.Parse + /// parser performs when reading the entry back. This helper avoids referencing the + /// internal Type1CharstringDecryption/Type1Table types directly (their own + /// assembly only grants InternalsVisibleTo to the core test project, not to the + /// other test projects that link this shared file), so the cipher key constant is + /// duplicated locally as . + /// + private static byte[] EncryptType1Entry(byte[] plainCharstring, int lenIv) + { + var withLeadIn = new byte[lenIv + plainCharstring.Length]; + for (var i = 0; i < lenIv; i++) + { + withLeadIn[i] = (byte)(0x55 + i); // arbitrary, non-zero lead-in padding + } + + plainCharstring.CopyTo(withLeadIn, lenIv); + return EncryptType1(withLeadIn, CharstringR0); + } + + /// + /// The classic PostScript Type 1 "eexec" cipher's initial key, duplicated locally from the + /// published Type 1 Font Format specification (see remarks on + /// for why this is not simply referenced from the internal production type). + /// + private const ushort EexecR0 = 55665; + + /// + /// The classic PostScript Type 1 individual charstring/subroutine cipher's initial key, + /// duplicated locally from the published Type 1 Font Format specification (see remarks on + /// for why this is not simply referenced from the internal + /// production type). + /// + private const ushort CharstringR0 = 4330; + + /// + /// Encrypts using the classic PostScript Type 1 "eexec" additive + /// stream cipher - the forward direction of the same published cipher that production code + /// only ever decrypts. + /// + /// + /// This is deliberately not implemented by reusing the production decryption routine with + /// its arguments swapped: the cipher's key-update step must always feed back the + /// ciphertext byte, not the plaintext byte, so encryption and decryption apply + /// the same recurrence in opposite output roles rather than being literally the same + /// function call with its input and output swapped. + /// + private static byte[] EncryptType1(byte[] plain, ushort r) + { + const ushort c1 = 52845; + const ushort c2 = 22719; + + var cipher = new byte[plain.Length]; + var key = r; + for (var i = 0; i < plain.Length; i++) + { + var c = (byte)(plain[i] ^ (key >> 8)); + cipher[i] = c; + key = (ushort)(((c + key) * c1) + c2); + } + + return cipher; + } + + /// + /// Re-serializes a Type 1 font program (already in 's + /// Length1/Length2 byte layout - see ) into the classic + /// "PFB" binary segment framing: a cleartext segment (0x80 0x01), a binary segment + /// (0x80 0x02), and a terminating end-of-file marker (0x80 0x03). + /// + public static byte[] Type1Pfb(byte[] fontFileBytes, int length1, int length2) + { + var buf = new List(); + AppendPfbSegment(buf, 0x01, fontFileBytes, 0, length1); + AppendPfbSegment(buf, 0x02, fontFileBytes, length1, length2); + buf.Add(0x80); + buf.Add(0x03); + return [.. buf]; + } + + private static void AppendPfbSegment(List buf, byte segmentType, byte[] data, int offset, int length) + { + buf.Add(0x80); + buf.Add(segmentType); + buf.Add((byte)(length & 0xFF)); + buf.Add((byte)((length >> 8) & 0xFF)); + buf.Add((byte)((length >> 16) & 0xFF)); + buf.Add((byte)((length >> 24) & 0xFF)); + buf.AddRange(new ArraySegment(data, offset, length)); + } + + /// + /// Re-serializes a Type 1 font program (already in 's + /// Length1/Length2 byte layout - see ) into the plain-ASCII + /// "PFA" framing: the cleartext bytes verbatim (already containing the literal + /// eexec keyword - see ), followed by the encrypted region + /// hex-encoded, wrapped at characters per line to also + /// exercise 's embedded-whitespace tolerance. + /// + public static byte[] Type1Pfa(byte[] fontFileBytes, int length1, int length2, int hexLineWidth = 64) + { + var sb = new System.Text.StringBuilder(); + sb.Append(System.Text.Encoding.Latin1.GetString(fontFileBytes, 0, length1)); + + for (var i = 0; i < length2; i++) + { + if (i > 0 && i % hexLineWidth == 0) + { + sb.Append('\n'); + } + + sb.Append(fontFileBytes[length1 + i].ToString("X2", System.Globalization.CultureInfo.InvariantCulture)); + } + + sb.Append('\n'); + return System.Text.Encoding.Latin1.GetBytes(sb.ToString()); + } }