diff --git a/.cspell.yaml b/.cspell.yaml index 2dbb3000..4274ee66 100644 --- a/.cspell.yaml +++ b/.cspell.yaml @@ -15,9 +15,13 @@ language: en # Project-specific technical terms and tool names words: - Aabb + - functiontype + - Poppler - AABB - Adler - AESV + - pypdf + - udata - AGG - apimark - ApiMark diff --git a/.reviewmark.yaml b/.reviewmark.yaml index 27465cb1..644c749f 100644 --- a/.reviewmark.yaml +++ b/.reviewmark.yaml @@ -761,6 +761,7 @@ reviews: - "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.Shading.Mesh.cs" # mesh shadings (types 4-7) - "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) @@ -768,6 +769,7 @@ reviews: - "src/DemaConsulting.CanvasNet.Pdf/PdfRenderOptions.cs" # supporting options type (background color) - "src/DemaConsulting.CanvasNet.Pdf/NamespaceDoc.cs" # Pdf namespace documentation - "test/DemaConsulting.CanvasNet.Pdf.Tests/PdfDocumentTests.cs" # unit tests + - "test/DemaConsulting.CanvasNet.Pdf.Tests/PdfDocumentMeshShadingTests.cs" # mesh shading unit tests - "test/DemaConsulting.CanvasNet.Pdf.Tests/PdfSystemIntegrationTests.cs" # integration tests - "test/DemaConsulting.CanvasNet.Pdf.Tests/PdfFixtureTests.cs" # fixture conformance tests diff --git a/docs/design/canvas-net-pdf.md b/docs/design/canvas-net-pdf.md index 8939e598..546bee06 100644 --- a/docs/design/canvas-net-pdf.md +++ b/docs/design/canvas-net-pdf.md @@ -86,25 +86,26 @@ content stream as a nested, implicitly `q`/`Q`-bracketed execution of the same i 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 +`/ShadingType 2`-`7`) 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 +(`/V 1`/`/V 2`), AES-128 (`/V 4`/`/CFM /AESV2`), or AES-256 using the R5 or R6 (hardened hash) key derivation +(`/V 5`/`/R 5` or `/R 6`/`/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 +and invisible (`Tr 3`) text-rendering modes and the clip modes (`Tr 4`-`7`) are supported (a Type 3 +glyph under a clip mode fails 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 +entry is never consulted, though the Form itself renders; `/ShadingType` values outside `2`-`7` +and `/FunctionType 4` (PostScript calculator) functions fail closed; the `sh` operator and general +path clipping (`W`/`W*`) are supported; 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 @@ -153,7 +154,8 @@ The system exposes the following public API to external consumers, all on the se 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` + Type 3 glyph under a clip text-rendering mode (`pdf-text-render-mode-type3-clip`)/ + 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, @@ -261,7 +263,7 @@ each with its own consistently-applied behavior: 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`/ +AES-128 (`/V 4`/`/CFM /AESV2`), or AES-256 using the R5 or R6 (hardened hash) key derivation (`/V 5`/`/R 5` or `/R 6`/ `/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 @@ -273,8 +275,8 @@ to every well-formed but out-of-scope construct it could then encounter: an unsu 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 +`/MMType1` subtype, an `/Encoding` naming an unrecognized base encoding, and (as of the text-clip +change) a Type 3 glyph shown under 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 @@ -328,7 +330,7 @@ descriptive `Feature` string so a caller (or this repository's own tests) can di 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}`, +crypt filter), `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), @@ -343,7 +345,7 @@ font program), `pdf-font-encoding-{name}`, `pdf-font-type0-encoding-{name}` (a ` `/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), +unsupported `/ToUnicode` CMap construct), `pdf-text-render-mode-type3-clip` (a Type 3 glyph under clip mode), `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 @@ -398,7 +400,8 @@ guards. 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 + font subtype/encoding/symbolic-font-without-embedded-data/Type 3 glyph under a clip + text-rendering mode (`pdf-text-render-mode-type3-clip`) 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 diff --git a/docs/design/canvas-net-pdf/pdf-document.md b/docs/design/canvas-net-pdf/pdf-document.md index 8c48080f..e842901c 100644 --- a/docs/design/canvas-net-pdf/pdf-document.md +++ b/docs/design/canvas-net-pdf/pdf-document.md @@ -63,11 +63,11 @@ procedures rather than an outline/CFF program) is also resolved and rendered, vi 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 +the R5 or R6 (hardened hash) key derivation (`/CFM /AESV3`/`/R 5` or `/R 6`) 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 +an `/R` other than 5/6, 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 @@ -88,8 +88,8 @@ entirely unsupported and fail closed with `Codecs.UnsupportedImageFeatureExcepti 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. +(stroke), `2` (fill, then stroke), `3` (invisible), and the clip modes `4`-`7` are supported (a Type 3 +glyph under a clip mode fails 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 @@ -346,7 +346,7 @@ non-`null` password is supplied, it is first tried as the **user password** (the 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 +owner-password variant of ISO 32000-2 Algorithm 2.A (R5/R6: 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 @@ -365,10 +365,10 @@ truncation rule. `/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` + RC4/AESV2 initialization path; `5` → accepts `/R 5` and `/R 6` (hardened hash) and rejects any + other `/R` (feature `pdf-encrypted-r-{revision}`), validates `/CF/StdCF/CFM` is + `/AESV3`, computes `passwordBytes` (empty, or `EncodeR5PasswordBytes(password)`), passes `revision` to + `TryComputeFileKeyAlgorithm2A`, tried 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 @@ -429,24 +429,32 @@ truncation rule. 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 +- **`ComputeHashAlgorithm2B(byte[] password, ReadOnlySpan salt, ReadOnlySpan udata, int revision)`** + — the password hash used for both the validation hash and the intermediate key. R5: a single + `SHA-256(password ‖ salt ‖ udata)`. R6 (ISO 32000-2 Algorithm 2.B): `K = SHA-256(input)`, then + rounds of `K1 = (password ‖ K ‖ udata) × 64`, `E = AES-128-CBC(key = K[0..16], iv = K[16..32], K1)` + with no padding, `K = SHA-256/384/512(E)` selected by the sum of `E`'s first 16 bytes mod 3; at + least 64 rounds, then stop once the last byte of `E` is `<= round - 32`; the result is the + first 32 bytes of `K`. The loop is capped (64 + 256 rounds) and throws `InvalidDataException` + beyond that, failing closed. +- **`TryComputeFileKeyAlgorithm2A(byte[] passwordBytes, …, int revision)`** (ISO 32000-2 Algorithm 2.A, R5/R6 AESV3 + only, user-password path) — authenticates by comparing `ComputeHashAlgorithm2B(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 + `ComputeHashAlgorithm2B(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 +- **`TryComputeFileKeyAlgorithm2AOwnerPassword(byte[] passwordBytes, byte[] oBytes, byte[] oeBytes, byte[] uBytes, int revision)`** + (owner-password variant of ISO 32000-2 Algorithm 2.A, R5/R6 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 + compares `ComputeHashAlgorithm2B(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, + zero IV, no padding) using `ComputeHashAlgorithm2B(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, + (R5/R6) 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` @@ -459,11 +467,12 @@ truncation rule. 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 +(`/V 1`/`/V 2`), AES-128 (`/V 4`/`/CFM /AESV2`), and AES-256 using the R5 or R6 +(hardened hash) key derivation (`/V 5`/`/R 5` or `/R 6`/`/CFM /AESV3`) are supported. A caller-supplied password +(R2-R4: Latin-1/ASCII only; +R5/R6: 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, an `/R` other +than 5/6, 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. @@ -1168,8 +1177,18 @@ its own distinguishable `Feature` token. 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`/ + entirely), and `4`-`7` (the clip modes: fill/stroke/fill+stroke/invisible respectively, each + additionally accumulating the glyph's device-space outline into `_textClipBuilder` and setting + `_textClipPending`, even for outline-less glyphs such as spaces). `OpEndText` (`ET`) then, if a + clip is pending, builds the accumulated path, converts it via `ClipMask.FromPath` (non-zero + winding) and intersects it into `_gs.Clip`; the clip is therefore not applied until `ET`, and is + restored by `Q`. Space-only clip text yields an empty region that clips everything (spec- + conformant). `OpBeginText`, `ExecuteContentStream`, and the Form XObject/Type 3/tiling re-entrant + executors reset or save/restore the accumulator so nested content cannot clobber it. A Type 3 + glyph shown under a clip mode throws `Codecs.UnsupportedImageFeatureException` (feature + `pdf-text-render-mode-type3-clip`) from `ShowGlyph` since it has no outline; any other numeric + value throws `InvalidDataException`. Modes `4`/`6` use the same flat fill color as modes `0`/`2` + (the pre-existing no-Pattern-fill simplification). `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 @@ -1286,7 +1305,8 @@ its own distinguishable `Feature` token. 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 + resolves `/ShadingType 2` (axial), `3` (radial) or `4`-`7` (mesh, see _Mesh shadings_ below); + 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 @@ -1367,9 +1387,31 @@ its own distinguishable `Feature` token. `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) and `/FunctionType 4` (PostScript calculator functions) - remain unsupported/unchanged, now reachable through two paths — `scn`/`SCN` + `/Pattern` and +- **Mesh shadings (`PdfDocument.Patterns.Shading.Mesh.cs`, `/ShadingType` 4-7)** — `ResolveMeshShading` + requires the shading to be a stream and validates `/BitsPerCoordinate` (1/2/4/8/12/16/24/32), + `/BitsPerComponent` (1/2/4/8/12/16), `/BitsPerFlag` (2/4/8; not used by type 5), `/Decode` + (4 + 2n finite entries) and `/VerticesPerRow` (type 5, at least 2), plus an optional `/Function` + (one parametric component, sampled into a 256-entry lookup table across the `/Decode` t range; + `/FunctionType 4` stays unsupported) and `/Background`. Type 4 reads free-form triangles + (flag 0 starts a triangle, 1 reuses the previous edge vb-vc, 2 reuses va-vc); type 5 reads a lattice + of `/VerticesPerRow` columns; types 6/7 read Coons/tensor patches whose 12/16 control points + are mapped into a 4x4 net, with flags 1-3 inheriting the previous patch's edge and two corner + colors (types 6 derives the four interior points from the Coons formula). **Every type 4/5 vertex + and every patch starts on a byte boundary** (the alignment the Poppler and PDFium renderers use). + Patches are + evaluated as bicubic Bezier surfaces tessellated at a fixed 16x16 subdivision (a deliberate + choice balancing cost and smoothness), triangles are rasterized with barycentric interpolation, + no anti-aliasing and pixel-centre sampling, into an offscreen bitmap painted through the + pattern's fill/stroke path or the `sh` clip/`/BBox` region. Colors are interpolated in RGB + even for `DeviceCMYK` (a documented approximation). `/Background` paints beneath the mesh for + pattern use only (not for `sh`). Malformed data (missing/illegal entries, truncated records, + bad flags, bad lattice) throws `InvalidDataException`; unsupported color spaces/functions and DoS + limits (1,048,576 vertices, 65,536 patches, 2^28 raster-work units) throw + `Codecs.UnsupportedImageFeatureException` (`pdf-shading-mesh-too-many-vertices`/ + `-too-many-patches`/`-too-complex`). +- **Scope boundaries (deliberately not implemented this phase)** — `ShadingType` `1` + (function-based shadings) and `/FunctionType 4` (PostScript calculator functions) + remain unsupported/unchanged, reachable through two paths — `scn`/`SCN` + `/Pattern` and `sh` — both throwing the identical `Codecs.UnsupportedImageFeatureException` (shared code, not a reimplementation); 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 @@ -1451,8 +1493,7 @@ change behavior for any document within normal real-world limits. 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-cfm-{name}`), **`/V 5` with an unsupported `/R` (not 5 or 6)** (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 @@ -1485,7 +1526,7 @@ change behavior for any document within normal real-world limits. 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 +- **An unsupported shading pattern** — a `/ShadingType` outside `2`–`7` (feature `pdf-shading-type-{n}`), or a `/ColorSpace` other than `DeviceGray`/`DeviceRGB`/`DeviceCMYK` (feature `pdf-shading-colorspace-{family}`) — `Codecs.UnsupportedImageFeatureException`. The `sh` operator reaching an undeclared shading name throws the same shape, feature @@ -1605,9 +1646,10 @@ change behavior for any document within normal real-world limits. - **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 Type 3 glyph shown under a clipping `Tr` mode (`4`-`7`)** — + `Codecs.UnsupportedImageFeatureException` (feature `pdf-text-render-mode-type3-clip`), raised at + show time; `Tr` itself accepts `0`-`7`, and any other numeric value is + `InvalidDataException`. - **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. diff --git a/docs/design/introduction.md b/docs/design/introduction.md index 452a7ee5..b5e86dd3 100644 --- a/docs/design/introduction.md +++ b/docs/design/introduction.md @@ -454,6 +454,7 @@ src/DemaConsulting.CanvasNet.Pdf/ ├── 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.Shading.Mesh.cs — Mesh (`/ShadingType` `4`-`7`) decoding, tessellation and rasterization ├── 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 diff --git a/docs/reqstream/canvas-net-pdf.yaml b/docs/reqstream/canvas-net-pdf.yaml index 6b8f516c..d2ddf19a 100644 --- a/docs/reqstream/canvas-net-pdf.yaml +++ b/docs/reqstream/canvas-net-pdf.yaml @@ -127,13 +127,13 @@ sections: 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 + AES-128 /V 4/CFM AESV2, or AES-256 /V 5/R 5 or R 6/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 + /Standard security handler, an AES-256 /R other than 5 or 6, 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. @@ -151,6 +151,7 @@ sections: - CanvasNetPdf-PdfDocument-EncryptionRc4 - CanvasNetPdf-PdfDocument-EncryptionAesV2 - CanvasNetPdf-PdfDocument-EncryptionAesV3R5 + - CanvasNetPdf-PdfDocument-EncryptionAesV3R6 - CanvasNetPdf-PdfDocument-UserPasswordAuthentication - CanvasNetPdf-PdfDocument-OwnerPasswordAuthentication - CanvasNetPdf-PdfDocument-IncorrectPasswordRejection @@ -340,7 +341,8 @@ sections: 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 + axial (/ShadingType 2), radial (/ShadingType 3) or mesh (/ShadingType 4-7) 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] @@ -354,6 +356,9 @@ sections: - CanvasNetPdf-PdfDocument-ShadingPatternFill - CanvasNetPdf-PdfDocument-TilingPatternFill - CanvasNetPdf-PdfDocument-ShadingOperator + - CanvasNetPdf-PdfDocument-MeshShadingFill + - CanvasNetPdf-PdfDocument-MeshShadingOperator + - CanvasNetPdf-PdfDocument-MeshShadingFailClosed 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 index 16fbac4b..cd4bec60 100644 --- a/docs/reqstream/canvas-net-pdf/pdf-document.yaml +++ b/docs/reqstream/canvas-net-pdf/pdf-document.yaml @@ -192,12 +192,12 @@ sections: 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", + example "pdf-encrypted-filter-{name}", "pdf-encrypted-r-{revision}", "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 + does not support: a non-/Standard security handler, an AES-256 /R other than 5 or 6, + 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. @@ -209,7 +209,7 @@ sections: tests: - PdfDocument_Open_EncryptedTrailer_ThrowsUnsupportedImageFeatureException - CanvasNetPdf_SystemIntegration_PdfEncryptDetection_EncryptedTrailerThrowsUnsupportedImageFeatureException - - PdfDocument_Open_EncryptedAesV3_R6_ThrowsUnsupportedImageFeatureException + - PdfDocument_Open_EncryptedAesV3_UnknownRevision_ThrowsUnsupportedImageFeatureException - PdfDocument_Open_EncryptedRc4_WrongUserPasswordHash_ThrowsUnsupportedImageFeatureException - PdfDocument_Open_EncryptedAesV3_WrongValidationHash_ThrowsUnsupportedImageFeatureException @@ -260,21 +260,36 @@ sections: 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. + found in the wild; supporting it extends empty-user-password coverage to a widely-deployed + encryption variant (the stronger /R 6 variant is covered by its own requirement). tests: - PdfDocument_Open_EncryptedAesV3_R5_DecryptsAndRenders + - id: CanvasNetPdf-PdfDocument-EncryptionAesV3R6 + title: >- + The PdfDocument class shall open a /Filter /Standard, /V 5, /R 6, + /CF/StdCF/CFM /AESV3 (AES-256, hardened hash) encrypted document, computing the + password validation hash and intermediate key with ISO 32000-2 Algorithm 2.B (an + iterated SHA-256/384/512 and AES-128-CBC construction, with a bounded round count) + for the empty user password, a caller-supplied user password, or a caller-supplied + owner password, then unwrapping /UE or /OE and decrypting every object as for R5. + justification: | + Documents written by current PDF producers use /R 6 (ISO 32000-2) rather than the + deprecated /R 5; supporting it lets CanvasNetPdf open and render them. Fixtures + generated independently with pypdf guard against a self-consistent implementation + bug. + tests: + - PdfDocument_Open_EncryptedAesV3_R6_EmptyUserPassword_DecryptsAndRenders + - PdfDocument_Open_EncryptedAesV3_R6_InMemoryEmptyUserPassword_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 + 4/5 for R2-R4, or Algorithm 2.A for R5/R6) 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. + supported cipher (RC4, AESV2, or AESV3/R5/R6) 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 @@ -283,13 +298,16 @@ sections: - PdfDocument_Open_EncryptedRc4_CorrectUserPassword_DecryptsAndRenders - PdfDocument_Open_EncryptedAesV2_CorrectUserPassword_DecryptsAndRenders - PdfDocument_Open_EncryptedAesV3_CorrectUserPassword_DecryptsAndRenders + - PdfDocument_Open_EncryptedAesV3_R6_CorrectUserPassword_DecryptsAndRenders + - PdfDocument_Open_EncryptedAesV3_R6_Utf8UserPassword_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 + of ISO 32000-2 Algorithm 2.A for R5 and R6, using the simple SHA-256 hash for R5 and + the Algorithm 2.B hardened hash for R6, 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: | @@ -299,6 +317,7 @@ sections: tests: - PdfDocument_Open_EncryptedRc4_CorrectOwnerPassword_DecryptsAndRenders - PdfDocument_Open_EncryptedAesV3_CorrectOwnerPassword_DecryptsAndRenders + - PdfDocument_Open_EncryptedAesV3_R6_CorrectOwnerPassword_DecryptsAndRenders - id: CanvasNetPdf-PdfDocument-IncorrectPasswordRejection title: >- @@ -313,6 +332,8 @@ sections: password was supplied at all. tests: - PdfDocument_Open_Encrypted_IncorrectPassword_ThrowsUnsupportedImageFeatureException + - PdfDocument_Open_EncryptedAesV3_R6_WrongPassword_ThrowsIncorrectPassword + - PdfDocument_Open_EncryptedAesV3_R6_NoPassword_ThrowsPasswordRequired - id: CanvasNetPdf-PdfDocument-PasswordNonAsciiRejection title: >- @@ -1480,12 +1501,16 @@ sections: 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 + and the clip modes 4-7 (fill/stroke/fill+stroke/invisible, each also adding the glyph + outlines to a clipping path intersected into the clip at ET), failing closed with + UnsupportedImageFeatureException for a Type 3 glyph under a clip mode, 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 + Clip text-rendering modes are used to fill text with images or gradients (for + example, shaded headings); ignoring them would paint the shading over the whole + page. Type 3 glyphs have no outline to clip with, so they must fail closed rather + than being silently mis-rendered, 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 @@ -1497,7 +1522,21 @@ sections: - PdfDocument_Text_RenderMode2_FillAndStroke_PaintsBothFillAndStroke - PdfDocument_Text_RenderMode1_StrokePattern_PaintsPatternStroke - PdfDocument_Fonts_Type3_RenderMode1Or2_BehavesLikeRenderMode0 - - PdfDocument_Text_RenderMode_UnsupportedDefinedMode_ThrowsUnsupportedImageFeatureException + - PdfDocument_Text_RenderMode_ClipModes_AcceptedByTr + - PdfDocument_Text_RenderMode7_ClipOnly_ClipsSubsequentFillToGlyph + - PdfDocument_Text_RenderMode7_ClipOnly_PaintsNoGlyphInk + - PdfDocument_Text_RenderMode4_FillAndClip_FillsThenClips + - PdfDocument_Text_RenderMode4_FillAndClip_PaintsGlyphFill + - PdfDocument_Text_RenderMode5_StrokeAndClip_StrokesThenClips + - PdfDocument_Text_RenderMode6_FillStrokeClip_PaintsBothThenClips + - PdfDocument_Text_RenderMode7_ClipAccumulatesAcrossMultipleGlyphs + - PdfDocument_Text_RenderMode7_ClipIntersectsWithExistingClip + - PdfDocument_Text_RenderMode7_ClipNotAppliedUntilEndText + - PdfDocument_Text_RenderMode7_ClipRestoredByRestoreState + - PdfDocument_Text_RenderMode7_ClipWithCompositeFont + - PdfDocument_Text_RenderMode_ClipModes_Type3Font_ThrowsUnsupportedImageFeatureException + - PdfDocument_Text_RenderMode7_NoGlyphsShown_DoesNotChangeClip + - PdfDocument_Text_RenderMode7_NestedFormXObjectText_DoesNotClobberOuterClip - PdfDocument_Text_RenderMode_OutOfDefinedRange_ThrowsInvalidDataException - id: CanvasNetPdf-PdfDocument-TextPositioning @@ -1648,13 +1687,14 @@ sections: - 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 + pattern whose /ShadingType is 2 (axial) or 3 (radial) (mesh types 4-7 are covered by + CanvasNetPdf-PdfDocument-MeshShadingFill), 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 + initial (pre-cm) coordinate transform. An unsupported /ShadingType (outside 2-7) 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 @@ -1701,6 +1741,78 @@ sections: - PdfDocument_ShadingOperator_UndeclaredShadingName_ThrowsUnsupportedImageFeatureException - CanvasNetPdf_SystemIntegration_ShadingOperatorOverBlackFallback_PaintsGradientNotBlack + - id: CanvasNetPdf-PdfDocument-MeshShadingFill + title: >- + The PdfDocument class shall fill or stroke a path using a /PatternType 2 pattern + whose shading is a mesh (/ShadingType 4 free-form triangles, 5 lattice triangles, + 6 Coons patches, or 7 tensor-product patches), decoding the shading stream per its + /BitsPerCoordinate, /BitsPerComponent, /BitsPerFlag (types 4, 6, 7), /VerticesPerRow + (type 5) and /Decode, honoring edge flags 0-3 (including patch continuation and the + Coons-derived interior points of type 6), mapping a single parametric color through + the optional /Function, converting colors through DeviceGray/DeviceRGB/DeviceCMYK + (interpolated in RGB), and painting the interpolated mesh (patches tessellated at a + fixed 16x16 subdivision, no anti-aliasing) positioned by the pattern /Matrix; the + pattern's /Background shall be painted beneath the mesh for pattern use only. + justification: | + Mesh shadings are produced by vector-art tools for smooth multi-color gradients; + previously every such fill failed closed and the document could not be rendered. + tests: + - PdfDocument_MeshShading_Type4_Pattern_PaintsOnlyInsideFillAndMesh + - PdfDocument_MeshShading_Type4_Flag1_ContinuesStrip + - PdfDocument_MeshShading_Type4_Flag2_ContinuesFan + - PdfDocument_MeshShading_Type4_Function_MapsParametricValue + - PdfDocument_MeshShading_Type4_BitSizeMatrix_RendersSame + - PdfDocument_MeshShading_GrayAndCmykColorSpaces_Render + - PdfDocument_MeshShading_Type5_Lattice_RendersAllCells + - PdfDocument_MeshShading_Type5_NonByteAlignedVertices_AreByteAligned + - PdfDocument_MeshShading_Type6_FlatPatch_BilinearCornerColors + - PdfDocument_MeshShading_Type6_CurvedEdge_CoversAreaBeyondChord + - PdfDocument_MeshShading_Type7_CoonsInteriorPoints_EqualsType6 + - PdfDocument_MeshShading_PatchContinuation_InheritsEdgeAndColors + - PdfDocument_MeshShading_Background_AppliesToPatternsOnly + - PdfDocument_MeshShading_PatternMatrix_ShiftsMesh + - PdfDocument_MeshShading_PatternStroke_PaintsThroughMesh + + - id: CanvasNetPdf-PdfDocument-MeshShadingOperator + title: >- + The PdfDocument class shall paint a mesh shading (/ShadingType 4-7) named by the sh + operator directly onto the destination surface, restricted to the current clipping + path or the shading's /BBox, with the same decoding as the pattern path but without + painting /Background. + justification: | + Producers also paint gradient meshes via sh rather than via a pattern fill. + tests: + - PdfDocument_MeshShading_Type4_ShOperator_InterpolatesVertexColors + - PdfDocument_MeshShading_ShOperator_HonorsBBoxAndClip + - CanvasNetPdf_SystemIntegration_ShOperatorMeshShadingTypes4To7_PaintsExpectedCornerColors + + - id: CanvasNetPdf-PdfDocument-MeshShadingFailClosed + title: >- + The PdfDocument class shall fail closed on mesh shadings: missing or illegal + /BitsPerCoordinate, /BitsPerComponent, /BitsPerFlag, /Decode or /VerticesPerRow, + a non-stream shading, truncated vertex or patch data, and invalid edge flags shall + throw InvalidDataException; an unsupported color space or function shall throw + UnsupportedImageFeatureException (pdf-shading-colorspace-{family} or a function + feature); and a mesh exceeding the vertex, patch or rasterization-work limits shall + throw UnsupportedImageFeatureException with feature + pdf-shading-mesh-too-many-vertices, pdf-shading-mesh-too-many-patches or + pdf-shading-mesh-too-complex. + justification: | + Mesh data is untrusted input that can be malformed or sized to exhaust memory/CPU. + tests: + - PdfDocument_MeshShading_MissingEntry_ThrowsInvalidData + - PdfDocument_MeshShading_IllegalBitSizeOrDecode_ThrowsInvalidData + - PdfDocument_MeshShading_TruncatedData_ThrowsInvalidData + - PdfDocument_MeshShading_PatchCutShort_ThrowsInvalidData + - PdfDocument_MeshShading_BadFlags_ThrowInvalidData + - PdfDocument_MeshShading_Type4_ReservedFlagInInitialTriangleVertices_ThrowsInvalidData + - PdfDocument_MeshShading_Type5_BadLattice_ThrowsInvalidData + - PdfDocument_MeshShading_NotAStream_ThrowsInvalidData + - PdfDocument_MeshShading_IndexedColorSpace_ThrowsUnsupported + - PdfDocument_MeshShading_FunctionProblems_FailClosed + - PdfDocument_MeshShading_TooManyVertices_ThrowsUnsupported + - PdfDocument_MeshShading_TooManyPatches_ThrowsUnsupported + - PdfDocument_MeshShading_TooMuchRasterWork_ThrowsUnsupported - id: CanvasNetPdf-PdfDocument-TilingPatternFill title: >- The PdfDocument class shall fill or stroke a path using a /PatternType 1 (tiling) diff --git a/docs/sysml2/model/canvas-net-pdf.sysml b/docs/sysml2/model/canvas-net-pdf.sysml index 403157fc..0af5a02e 100644 --- a/docs/sysml2/model/canvas-net-pdf.sysml +++ b/docs/sysml2/model/canvas-net-pdf.sysml @@ -6,7 +6,7 @@ package CanvasNet { 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. */ + 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 R5 or R6 hardened-hash key derivation (/V 5/R 5 or R 6/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, an /R other than 5 or 6, or any other unsupported /V/R combination) or to an incorrect or missing required password. */ part pdfDocument : PdfDocument; diff --git a/docs/sysml2/model/canvas-net-pdf/pdf-document.sysml b/docs/sysml2/model/canvas-net-pdf/pdf-document.sysml index 51c85f2c..96ca4b35 100644 --- a/docs/sysml2/model/canvas-net-pdf/pdf-document.sysml +++ b/docs/sysml2/model/canvas-net-pdf/pdf-document.sysml @@ -2,8 +2,8 @@ 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/clipping/shading, 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/PdfRenderOptions.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 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.Shading.Mesh.cs, src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Patterns.Tiling.cs, src/DemaConsulting.CanvasNet.Pdf/PdfPageInfo.cs, src/DemaConsulting.CanvasNet.Pdf/PdfRenderOptions.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/PdfDocumentMeshShadingTests.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/user_guide/introduction.md b/docs/user_guide/introduction.md index 65a46199..b5f92a43 100644 --- a/docs/user_guide/introduction.md +++ b/docs/user_guide/introduction.md @@ -1102,7 +1102,7 @@ The `PdfDocument` sealed class opens a PDF document, parses its cross-reference 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 +pattern fills/strokes - axial/radial/mesh (`/ShadingType 2`-`7`) 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 @@ -1131,10 +1131,11 @@ equivalent - it still fails closed with `UnsupportedImageFeatureException`, exac 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` +clipping (`W`/`W*`) are supported. **Documented scope +boundaries**: `/MMType1` fonts, function-based shadings (`/ShadingType 1`), `/FunctionType 4` +(PostScript calculator) functions, transparency groups, +and Type 3 glyphs shown under a clip text-rendering mode (`Tr 4`-`7`; clip modes with outline-based +fonts are supported) all fail closed with `UnsupportedImageFeatureException` rather than being silently skipped or mis-rendered. ```csharp @@ -1158,8 +1159,8 @@ The optional `password` parameter defaults to `null`, which preserves the librar 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 +ISO 32000-2 Algorithm 2.A for AES-256/R5/R6 documents). R2-R4 (RC4/AES-128) passwords are encoded as +Latin-1 (the ASCII range of PDFDocEncoding); R5/R6 (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. @@ -1171,13 +1172,13 @@ truncate the password's encoded bytes to a maximum of 127 bytes before any hashi 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 + an AES-256 document using an `/R` other than 5 or 6, a crypt filter other than + the standard `/StdCF` filter (RC4, AES-128/`AESV2`, or AES-256-R5/R6/`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 - + security handler using RC4 (40-128 bit), AES-128, or AES-256 (R5/R6) 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). @@ -1255,7 +1256,7 @@ reproduce the pre-`PdfRenderOptions` fully transparent background, pass 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. + Type 3 glyph under a clip text-rendering mode (`pdf-text-render-mode-type3-clip`). - `ObjectDisposedException`: Thrown when called after `Dispose()` has been called. ##### PdfDocument.Render(int pageIndex, float dpi, PdfRenderOptions? options = null) diff --git a/docs/verification/canvas-net-pdf/pdf-document.md b/docs/verification/canvas-net-pdf/pdf-document.md index fb6fade1..e4500a49 100644 --- a/docs/verification/canvas-net-pdf/pdf-document.md +++ b/docs/verification/canvas-net-pdf/pdf-document.md @@ -390,7 +390,7 @@ traversal rather than looping forever. **Tests**: `PdfDocument_Open_EncryptedTrailer_ThrowsUnsupportedImageFeatureException`, `CanvasNetPdf_SystemIntegration_PdfEncryptDetection_EncryptedTrailerThrowsUnsupportedImageFeatureException`, -`PdfDocument_Open_EncryptedAesV3_R6_ThrowsUnsupportedImageFeatureException`, +`PdfDocument_Open_EncryptedAesV3_UnknownRevision_ThrowsUnsupportedImageFeatureException`, `PdfDocument_Open_EncryptedRc4_WrongUserPasswordHash_ThrowsUnsupportedImageFeatureException`, `PdfDocument_Open_EncryptedAesV3_WrongValidationHash_ThrowsUnsupportedImageFeatureException` @@ -398,9 +398,9 @@ Opens `PdfFixtures/encrypted-trailer.pdf` (a trailer containing an `/Encrypt` ke 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 +end-to-end copy of the same assertion). Separately builds an in-memory `/V 5`/`/R 7` document and +asserts `Feature == "pdf-encrypted-r-7"` (only AES-256 `/R 5` and `/R 6` are supported). +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; @@ -474,13 +474,26 @@ specifies). Opens it through the public API with no password supplied and assert 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-EncryptionAesV3R6: AES-256 R6 (Hardened Hash) Documents + +**Tests**: `PdfDocument_Open_EncryptedAesV3_R6_EmptyUserPassword_DecryptsAndRenders`, +`PdfDocument_Open_EncryptedAesV3_R6_InMemoryEmptyUserPassword_DecryptsAndRenders` + +Opens `PdfFixtures/encrypted-aes256-r6-empty-user-password.pdf`, generated independently with +pypdf (confirmed `/V 5`/`/R 6`; see the fixtures README) with an empty user password, and asserts +`Render` paints the expected blue rectangle - an independent implementation proves Algorithm 2.B +is not merely self-consistent. The in-memory test builds `/U`/`/UE` with a separately written +test-side Algorithm 2.B. Reverting the `/R 6` support makes every R6 test fail. + #### 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` +`PdfDocument_Open_EncryptedAesV3_CorrectUserPassword_DecryptsAndRenders`, +`PdfDocument_Open_EncryptedAesV3_R6_CorrectUserPassword_DecryptsAndRenders`, +`PdfDocument_Open_EncryptedAesV3_R6_Utf8UserPassword_DecryptsAndRenders` -The three tests build RC4 (`/V 2`/`/R 3`), AES-128 (`/V 4`/`/R 4`/`/CFM /AESV2`), and AES-256 R5 +The three non-fixture 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 - @@ -491,7 +504,11 @@ 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_EncryptedAesV3_CorrectOwnerPassword_DecryptsAndRenders`, +`PdfDocument_Open_EncryptedAesV3_R6_CorrectOwnerPassword_DecryptsAndRenders` + +The R6 owner test opens the pypdf `encrypted-aes256-r6-user-password.pdf` fixture with its owner +password (`owner-secret`). `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 @@ -510,7 +527,12 @@ document then renders correctly. #### CanvasNetPdf-PdfDocument-IncorrectPasswordRejection: Wrong Password Rejected With Distinguishable Feature -**Test**: `PdfDocument_Open_Encrypted_IncorrectPassword_ThrowsUnsupportedImageFeatureException` +**Tests**: `PdfDocument_Open_Encrypted_IncorrectPassword_ThrowsUnsupportedImageFeatureException`, +`PdfDocument_Open_EncryptedAesV3_R6_WrongPassword_ThrowsIncorrectPassword`, +`PdfDocument_Open_EncryptedAesV3_R6_NoPassword_ThrowsPasswordRequired` + +(The two R6 tests use the pypdf user-password fixture: a wrong password throws +`pdf-encrypted-incorrect-password`; no password throws `pdf-encrypted-password-required`.) 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 @@ -1721,7 +1743,7 @@ position/size matches the composed text-rendering matrix formula independently r 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 +#### CanvasNetPdf-PdfDocument-Tr: Tr Supports Fill/Stroke/Fill+Stroke/Invisible and Clip Modes **Tests**: `PdfDocument_Text_RenderMode3_Invisible_DoesNotPaintGlyph`, `PdfDocument_Text_RenderMode3_Invisible_StillAdvancesTextPosition`, @@ -1729,7 +1751,21 @@ against the real rasterizer before being fixed into every position-dependent tes `PdfDocument_Text_RenderMode2_FillAndStroke_PaintsBothFillAndStroke`, `PdfDocument_Text_RenderMode1_StrokePattern_PaintsPatternStroke`, `PdfDocument_Fonts_Type3_RenderMode1Or2_BehavesLikeRenderMode0`, -`PdfDocument_Text_RenderMode_UnsupportedDefinedMode_ThrowsUnsupportedImageFeatureException`, +`PdfDocument_Text_RenderMode_ClipModes_AcceptedByTr`, +`PdfDocument_Text_RenderMode7_ClipOnly_ClipsSubsequentFillToGlyph`, +`PdfDocument_Text_RenderMode7_ClipOnly_PaintsNoGlyphInk`, +`PdfDocument_Text_RenderMode4_FillAndClip_FillsThenClips`, +`PdfDocument_Text_RenderMode4_FillAndClip_PaintsGlyphFill`, +`PdfDocument_Text_RenderMode5_StrokeAndClip_StrokesThenClips`, +`PdfDocument_Text_RenderMode6_FillStrokeClip_PaintsBothThenClips`, +`PdfDocument_Text_RenderMode7_ClipAccumulatesAcrossMultipleGlyphs`, +`PdfDocument_Text_RenderMode7_ClipIntersectsWithExistingClip`, +`PdfDocument_Text_RenderMode7_ClipNotAppliedUntilEndText`, +`PdfDocument_Text_RenderMode7_ClipRestoredByRestoreState`, +`PdfDocument_Text_RenderMode7_ClipWithCompositeFont`, +`PdfDocument_Text_RenderMode_ClipModes_Type3Font_ThrowsUnsupportedImageFeatureException`, +`PdfDocument_Text_RenderMode7_NoGlyphsShown_DoesNotChangeClip`, +`PdfDocument_Text_RenderMode7_NestedFormXObjectText_DoesNotClobberOuterClip`, `PdfDocument_Text_RenderMode_OutOfDefinedRange_ThrowsInvalidDataException` Sets `Tr 3` (invisible) and shows a glyph, asserting no ink is painted at its expected position. @@ -1747,8 +1783,15 @@ glyph stroke step shares the path-painting operators' own `/Pattern`-aware strok `[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 +`[Theory]` sets `Tr` to each clip mode (`4`/`5`/`6`/`7`) alone, asserting it is accepted. Mode `7` +tests show a glyph then fill the page, asserting only the glyph area receives the later fill and +that mode `7` itself paints nothing; modes `4`/`5`/`6` assert their fill/stroke ink plus the clip. +Further tests assert the clip accumulates the union of several glyphs, intersects an existing +`W n` clip, is not applied until `ET`, is undone by `Q`, works with a composite font, and is +unchanged when no glyph is shown. A test invokes a Form XObject mid-`BT` of an outer mode `7` +block, the Form running its own mode `7` `BT`...`ET`, asserting the outer accumulation is not +clobbered (only the outer glyph survives as the clip). A `[Theory]` shows a Type 3 glyph under each clip mode, +asserting `Codecs.UnsupportedImageFeatureException`; 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 @@ -1893,7 +1936,7 @@ Every unit test builds a minimal single-page PDF with a declared `/Pattern` reso 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 +asserts `/ShadingType 0`/`1`/`8` all 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 @@ -1941,6 +1984,70 @@ intervening `scn`/Pattern selection at all) - asserting the resulting pixels are and instead show the expected light-blue/white gradient progression, proving the gradient now actually overlays the fallback rather than remaining invisibly skipped underneath it. +#### CanvasNetPdf-PdfDocument-MeshShadingFill: Mesh Shadings (Types 4-7) Paint Interpolated Colors + +**Tests**: `PdfDocument_MeshShading_Type4_Pattern_PaintsOnlyInsideFillAndMesh`, +`PdfDocument_MeshShading_Type4_Flag1_ContinuesStrip`, `PdfDocument_MeshShading_Type4_Flag2_ContinuesFan`, +`PdfDocument_MeshShading_Type4_Function_MapsParametricValue`, +`PdfDocument_MeshShading_Type4_BitSizeMatrix_RendersSame`, +`PdfDocument_MeshShading_GrayAndCmykColorSpaces_Render`, +`PdfDocument_MeshShading_Type5_Lattice_RendersAllCells`, +`PdfDocument_MeshShading_Type5_NonByteAlignedVertices_AreByteAligned`, +`PdfDocument_MeshShading_Type6_FlatPatch_BilinearCornerColors`, +`PdfDocument_MeshShading_Type6_CurvedEdge_CoversAreaBeyondChord`, +`PdfDocument_MeshShading_Type7_CoonsInteriorPoints_EqualsType6`, +`PdfDocument_MeshShading_PatchContinuation_InheritsEdgeAndColors`, +`PdfDocument_MeshShading_Background_AppliesToPatternsOnly`, +`PdfDocument_MeshShading_PatternMatrix_ShiftsMesh`, +`PdfDocument_MeshShading_PatternStroke_PaintsThroughMesh` + +Every test builds a synthetic single-page PDF whose mesh stream is bit-packed in the test. Asserts +type 4 triangles interpolate vertex colors only inside the filled area, flags 1 and 2 reuse the +correct previous edge, `/Function` maps the parametric value through `/Decode`, and a 45-case +bit-size matrix (coordinate/component/flag sizes) renders identically. Asserts type 5 lattices +render every cell and that vertices are padded to a byte boundary. Asserts type 6 patches +interpolate bilinear corner colors and bulge along curved edges, that type 7 with Coons-derived +interior points matches type 6 (and differs when an interior point moves), and that patch +continuation flags 1-3 inherit the previous edge and colors. Asserts `/Background` paints beneath +a pattern fill, and that `/Matrix` and stroking apply to the mesh. + +#### CanvasNetPdf-PdfDocument-MeshShadingOperator: sh Paints Mesh Shadings + +**Tests**: `PdfDocument_MeshShading_Type4_ShOperator_InterpolatesVertexColors`, +`PdfDocument_MeshShading_ShOperator_HonorsBBoxAndClip`, +`CanvasNetPdf_SystemIntegration_ShOperatorMeshShadingTypes4To7_PaintsExpectedCornerColors` + +Asserts `sh` paints a type 4 mesh directly with interpolated vertex colors and honors both the +shading `/BBox` and a preceding clip path. The system-integration test opens +`PdfFixtures/shading-mesh-types-4-5-6-7.pdf` (one quadrant per shading type 4, 5, 6 and 7) and +asserts the expected corner/vertex colors in every quadrant, unpainted pixels outside the type 4 +triangle, and that the type 7 interior control points change the interior color relative to the +type 6 patch with the same boundary. + +#### CanvasNetPdf-PdfDocument-MeshShadingFailClosed: Malformed or Oversized Mesh Shadings Fail Closed + +**Tests**: `PdfDocument_MeshShading_MissingEntry_ThrowsInvalidData`, +`PdfDocument_MeshShading_IllegalBitSizeOrDecode_ThrowsInvalidData`, +`PdfDocument_MeshShading_TruncatedData_ThrowsInvalidData`, +`PdfDocument_MeshShading_PatchCutShort_ThrowsInvalidData`, +`PdfDocument_MeshShading_BadFlags_ThrowInvalidData`, +`PdfDocument_MeshShading_Type4_ReservedFlagInInitialTriangleVertices_ThrowsInvalidData`, +`PdfDocument_MeshShading_Type5_BadLattice_ThrowsInvalidData`, +`PdfDocument_MeshShading_NotAStream_ThrowsInvalidData`, +`PdfDocument_MeshShading_IndexedColorSpace_ThrowsUnsupported`, +`PdfDocument_MeshShading_FunctionProblems_FailClosed`, +`PdfDocument_MeshShading_TooManyVertices_ThrowsUnsupported`, +`PdfDocument_MeshShading_TooManyPatches_ThrowsUnsupported`, +`PdfDocument_MeshShading_TooMuchRasterWork_ThrowsUnsupported` + +Asserts missing or illegal bit sizes, `/Decode`, `/VerticesPerRow`, a non-stream shading, +truncated records and bad edge flags (including a reserved flag on the 2nd or 3rd vertex of a +flag-0 type 4 record) throw `InvalidDataException`; an Indexed color space and +invalid/FunctionType 4 functions throw `Codecs.UnsupportedImageFeatureException`; and the vertex, +patch and rasterization-work limits throw `Codecs.UnsupportedImageFeatureException` with features +`pdf-shading-mesh-too-many-vertices`, `pdf-shading-mesh-too-many-patches` and +`pdf-shading-mesh-too-complex`. + #### CanvasNetPdf-PdfDocument-TilingPatternFill: Colored/Uncolored Tiling Patterns Paint a Tile, Fail Closed Otherwise **Tests**: `PdfDocument_Patterns_TilingPattern_ColoredPaintType1_FillsRepeatingTile`, diff --git a/src/DemaConsulting.CanvasNet.Pdf/NamespaceDoc.cs b/src/DemaConsulting.CanvasNet.Pdf/NamespaceDoc.cs index 2da912af..69cf1e62 100644 --- a/src/DemaConsulting.CanvasNet.Pdf/NamespaceDoc.cs +++ b/src/DemaConsulting.CanvasNet.Pdf/NamespaceDoc.cs @@ -30,12 +30,13 @@ namespace DemaConsulting.CanvasNet.Pdf; /// 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. +/// /Matrix//Resources), and /Pattern-color-space shading (axial, radial, and +/// mesh types 4-7) and tiling pattern fills/strokes. Opening a document encrypted with the PDF +/// /Filter /Standard security handler (RC4, AES-128, or AES-256/R5/R6) 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, transparency groups, and clip text-rendering modes). +/// documented scope boundaries (for example function-based shadings, /FunctionType 4 +/// PostScript-calculator functions, transparency groups, and Type 3 glyphs shown in the +/// clipping text-rendering modes). /// /// /// Rendering every page of a PDF document to a 300 DPI PNG file, using diff --git a/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.ContentStream.cs b/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.ContentStream.cs index c06950e9..e0c867ff 100644 --- a/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.ContentStream.cs +++ b/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.ContentStream.cs @@ -82,6 +82,8 @@ private void ExecuteContentStream(byte[] contentBytes, Surface surface, Matrix3x _fontCache = new Dictionary(); _textMatrix = Matrix3x2.Identity; _lineMatrix = Matrix3x2.Identity; + _textClipBuilder = null; + _textClipPending = false; _formNestingDepth = 0; _type3NestingDepth = 0; _colorSpaceRecursionDepth = 0; @@ -172,8 +174,8 @@ private void ExecuteOperators(byte[] contentBytes) /// requirement. /// /// - /// Propagated from (an unsupported font), - /// (a defined but unsupported text-rendering mode), or + /// Propagated from (an unsupported font), ShowGlyph (a Type 3 + /// glyph shown under a clipping text-rendering mode 4-7), or /// (an undeclared shading name, or a defined but unsupported /// /ShadingType//ColorSpace - the same exception the scn/SCN /// Pattern-color-space path already throws for the identical underlying condition). diff --git a/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Encryption.cs b/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Encryption.cs index e5407d54..62fed4b5 100644 --- a/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Encryption.cs +++ b/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Encryption.cs @@ -135,7 +135,7 @@ private static byte[] EncodeR5PasswordBytes(string password) } /// - /// The resolved file (or, for AES-256/R5, directly-usable) encryption key, set once by + /// The resolved file (or, for AES-256/R5/R6, directly-usable) encryption key, set once by /// when the trailer declares a supported /// /Encrypt shape, or left for an unencrypted document. /// @@ -199,9 +199,9 @@ private static byte[] EncodeR5PasswordBytes(string 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 + /// /CFM /AESV2), and AES-256 (/V 5//R 5 or /R 6 "hardened hash" key + /// derivation, ISO 32000-2 Algorithm 2.B//CFM /AESV3) are supported. Every other shape (a + /// non-/Standard filter, an unknown /R, a crypt /// filter other than the standard /StdCF) fails closed with /// and its own distinguishable /// token. @@ -307,14 +307,7 @@ private void InitializeEncryption(PdfObject trailer, string? password = null) 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) + if (revision is not (5 or 6)) { throw new UnsupportedImageFeatureException( $"pdf-encrypted-r-{revision}", @@ -325,11 +318,11 @@ private void InitializeEncryption(PdfObject trailer, string? password = null) var ueBytes = GetRequiredBytesEntry(encryptDict, "UE"); byte[] r5PasswordBytes = password is null ? [] : EncodeR5PasswordBytes(password); - var r5FileKey = TryComputeFileKeyAlgorithm2A(r5PasswordBytes, uBytes, ueBytes); + var r5FileKey = TryComputeFileKeyAlgorithm2A(r5PasswordBytes, uBytes, ueBytes, revision); if (r5FileKey is null && password is not null) { var oeBytes = GetRequiredBytesEntry(encryptDict, "OE"); - r5FileKey = TryComputeFileKeyAlgorithm2AOwnerPassword(r5PasswordBytes, oBytes, oeBytes, uBytes); + r5FileKey = TryComputeFileKeyAlgorithm2AOwnerPassword(r5PasswordBytes, oBytes, oeBytes, uBytes, revision); } if (r5FileKey is null) @@ -710,7 +703,7 @@ private static byte[] RecoverPaddedUserPasswordAlgorithm3(byte[] paddedOwnerPass } /// - /// Computes the file encryption key for AES-256/R5 by trying + /// Computes the file encryption key for AES-256 (R5/R6) 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. @@ -725,6 +718,7 @@ private static byte[] RecoverPaddedUserPasswordAlgorithm3(byte[] paddedOwnerPass /// validation salt, 8 bytes of key salt. /// /// The Encrypt dictionary's 32-byte /UE entry. + /// The security handler revision (5 or 6), selecting the hash function. /// /// The 32-byte file encryption key (used directly, with no further per-object derivation) /// when authenticates against /U's validation @@ -734,7 +728,7 @@ private static byte[] RecoverPaddedUserPasswordAlgorithm3(byte[] paddedOwnerPass /// Thrown when / are not the lengths /// the specification requires. /// - private static byte[]? TryComputeFileKeyAlgorithm2A(byte[] passwordBytes, byte[] uBytes, byte[] ueBytes) + private static byte[]? TryComputeFileKeyAlgorithm2A(byte[] passwordBytes, byte[] uBytes, byte[] ueBytes, int revision) { if (uBytes.Length < 48) { @@ -752,14 +746,14 @@ private static byte[] RecoverPaddedUserPasswordAlgorithm3(byte[] paddedOwnerPass // Step 1: SHA-256(password + validationSalt) - authenticate by comparing against /U's // own embedded hash. - var computedHash = SHA256.HashData([.. passwordBytes, .. validationSalt]); + var computedHash = ComputeHashAlgorithm2B(passwordBytes, validationSalt, [], revision); if (!computedHash.AsSpan().SequenceEqual(hash)) { return null; } // Step 2: the intermediate key is SHA-256(password + keySalt). - var intermediateKey = SHA256.HashData([.. passwordBytes, .. keySalt]); + var intermediateKey = ComputeHashAlgorithm2B(passwordBytes, keySalt, [], revision); // 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). @@ -767,7 +761,7 @@ private static byte[] RecoverPaddedUserPasswordAlgorithm3(byte[] paddedOwnerPass } /// - /// Computes the file encryption key for AES-256/R5 by trying + /// Computes the file encryption key for AES-256 (R5/R6) 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 @@ -785,6 +779,7 @@ private static byte[] RecoverPaddedUserPasswordAlgorithm3(byte[] paddedOwnerPass /// /// The Encrypt dictionary's 32-byte /OE entry. /// The Encrypt dictionary's full, raw 48-byte /U entry. + /// The security handler revision (5 or 6), selecting the hash function. /// /// The 32-byte file encryption key when authenticates /// against /O's validation hash; when it does not. @@ -793,7 +788,7 @@ private static byte[] RecoverPaddedUserPasswordAlgorithm3(byte[] paddedOwnerPass /// Thrown when // /// are not the lengths the specification requires. /// - private static byte[]? TryComputeFileKeyAlgorithm2AOwnerPassword(byte[] passwordBytes, byte[] oBytes, byte[] oeBytes, byte[] uBytes) + private static byte[]? TryComputeFileKeyAlgorithm2AOwnerPassword(byte[] passwordBytes, byte[] oBytes, byte[] oeBytes, byte[] uBytes, int revision) { if (oBytes.Length < 48) { @@ -817,23 +812,88 @@ private static byte[] RecoverPaddedUserPasswordAlgorithm3(byte[] paddedOwnerPass // 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]); + var computedHash = ComputeHashAlgorithm2B(passwordBytes, validationSalt, fullU, revision); 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]); + var intermediateKey = ComputeHashAlgorithm2B(passwordBytes, keySalt, fullU, revision); // 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 the 32-byte password hash used by the AES-256 security handler (ISO 32000-2 + /// Algorithm 2.B for /R 6; a single SHA-256 over password + salt + user data for + /// /R 5). Used for the validation hash and the intermediate key, for both the user + /// and owner password variants. + /// + /// The UTF-8-encoded, 127-byte-truncated candidate password. + /// The 8-byte validation or key salt. + /// The full 48-byte /U for the owner variant; empty for the user variant. + /// The security handler revision (5 or 6). + /// The 32-byte hash. + /// Thrown when the Algorithm 2.B loop does not terminate within a defensive round cap. + private static byte[] ComputeHashAlgorithm2B(byte[] password, ReadOnlySpan salt, ReadOnlySpan userData, int revision) + { + byte[] input = [.. password, .. salt, .. userData]; + var k = SHA256.HashData(input); + if (revision == 5) + { + return k; + } + + // Defensive cap: the loop ends once the last byte of E <= round - 32, so it cannot + // legitimately exceed 64 + 255 rounds. + const int maxRounds = 64 + 256; + for (var round = 0; round < maxRounds; round++) + { + var unit = new byte[password.Length + k.Length + userData.Length]; + password.CopyTo(unit, 0); + k.CopyTo(unit, password.Length); + userData.CopyTo(unit.AsSpan(password.Length + k.Length)); + + var k1 = new byte[unit.Length * 64]; + for (var i = 0; i < 64; i++) + { + unit.CopyTo(k1, i * unit.Length); + } + + byte[] e; + using (var aes = Aes.Create()) + { + aes.Key = k.AsSpan(0, 16).ToArray(); + e = aes.EncryptCbc(k1, k.AsSpan(16, 16), PaddingMode.None); + } + + var mod = 0; + for (var i = 0; i < 16; i++) + { + mod += e[i]; + } + + k = (mod % 3) switch + { + 0 => SHA256.HashData(e), + 1 => SHA384.HashData(e), + _ => SHA512.HashData(e) + }; + + if (round >= 63 && e[^1] <= round - 31) + { + return k.AsSpan(0, 32).ToArray(); + } + } + + throw new InvalidDataException("AES-256 /R 6 password hash (Algorithm 2.B) did not terminate."); + } /// /// 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 + /// AES-128/AESV2 (revisions 2-4). AES-256 does not use this - it uses the file /// encryption key directly for every object (see 's /// own remarks). /// diff --git a/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Fonts.Type3.cs b/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Fonts.Type3.cs index c82add3e..b1797741 100644 --- a/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Fonts.Type3.cs +++ b/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Fonts.Type3.cs @@ -373,6 +373,10 @@ private void PaintType3Glyph(ResolvedType3Font font, int code) var savedGs = _gs; var savedGsStack = _gsStack; var savedPendingClipFillRule = _pendingClipFillRule; + var savedTextClipBuilder = _textClipBuilder; + var savedTextClipPending = _textClipPending; + _textClipBuilder = null; + _textClipPending = false; _type3NestingDepth++; try { @@ -391,6 +395,8 @@ private void PaintType3Glyph(ResolvedType3Font font, int code) _gs = savedGs; _gsStack = savedGsStack; _pendingClipFillRule = savedPendingClipFillRule; + _textClipBuilder = savedTextClipBuilder; + _textClipPending = savedTextClipPending; } } diff --git a/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.GraphicsState.cs b/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.GraphicsState.cs index 6cb9796e..f35835c9 100644 --- a/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.GraphicsState.cs +++ b/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.GraphicsState.cs @@ -151,7 +151,8 @@ private sealed class GraphicsState /// /// Gets or sets the text-rendering mode. The PDF specification's default is 0 /// (fill) - see for which modes this - /// phase actually supports. + /// implementation supports (all of 0-7; modes 4-7 also feed + /// the text clip applied at ET). /// internal int RenderMode { get; set; } diff --git a/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Images.cs b/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Images.cs index 68a1d71e..25da2a21 100644 --- a/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Images.cs +++ b/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Images.cs @@ -150,6 +150,10 @@ private void OpDrawFormXObject(PdfObject formStream) var savedGs = _gs; var savedGsStack = _gsStack; var savedPendingClipFillRule = _pendingClipFillRule; + var savedTextClipBuilder = _textClipBuilder; + var savedTextClipPending = _textClipPending; + _textClipBuilder = null; + _textClipPending = false; _formNestingDepth++; try { @@ -168,6 +172,8 @@ private void OpDrawFormXObject(PdfObject formStream) _gs = savedGs; _gsStack = savedGsStack; _pendingClipFillRule = savedPendingClipFillRule; + _textClipBuilder = savedTextClipBuilder; + _textClipPending = savedTextClipPending; } } diff --git a/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.PathOps.cs b/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.PathOps.cs index c4780187..4d15b6ae 100644 --- a/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.PathOps.cs +++ b/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.PathOps.cs @@ -413,6 +413,13 @@ private void PaintPatternFill(Geometry.Path path, FillRule fillRule, ResolvedPat if (pattern.Kind == ResolvedPattern.PatternKind.Shading) { + if (pattern.Mesh is { } mesh) + { + // Mesh shadings (types 4-7) honor /Background for pattern use (PDF 32000-1 §8.7.4.3). + PaintMesh(mesh, patternToDevice, path, fillRule, applyBackground: true); + return; + } + var gradient = BuildShadingGradient(pattern, patternToDevice); PathFiller.Fill(_surface, path, gradient, _gs.Clip, fillRule); return; diff --git a/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Patterns.Shading.Mesh.cs b/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Patterns.Shading.Mesh.cs new file mode 100644 index 00000000..53b2e218 --- /dev/null +++ b/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Patterns.Shading.Mesh.cs @@ -0,0 +1,856 @@ +// cspell:ignore Gouraud Coons bicubic Bezier Bernstein bbox lerp +using System.Numerics; +using DemaConsulting.CanvasNet.Canvas; +using DemaConsulting.CanvasNet.Codecs; +using DemaConsulting.CanvasNet.Drawing; +using DemaConsulting.CanvasNet.Geometry; + +namespace DemaConsulting.CanvasNet.Pdf; + +// Mesh shadings (PDF 32000-1 8.7.4.5.5-8.7.4.5.8): /ShadingType 4 (free-form Gouraud triangle +// mesh), 5 (lattice-form Gouraud triangle mesh), 6 (Coons patch mesh) and 7 (tensor-product +// patch mesh). Decoding, validation and limits live here; painting goes through an offscreen, +// surface-sized bitmap painted with the public TilePaint, so no core-library change is needed. +public sealed partial class PdfDocument +{ + /// The maximum number of vertices (type 4/5) a single mesh shading may declare. + internal const int MaxMeshVertices = 1_048_576; + + /// The maximum number of patches (type 6/7) a single mesh shading may declare. + internal const int MaxMeshPatches = 65_536; + + /// + /// The fixed number of quads per patch edge a patch is subdivided into when painted + /// (N x N quads, 2 * N * N triangles per patch); uniform and deterministic. + /// + internal const int MeshPatchSubdivision = 16; + + /// + /// The maximum total rasterization work (clamped triangle bounding-box pixels plus a + /// per-triangle constant) a single mesh paint may perform. + /// + internal const long MaxMeshRasterPixels = 1L << 28; + + /// The number of entries in the color lookup table used for meshes with a /Function. + private const int MeshFunctionLutSize = 256; + + /// + /// Maps a patch's stream point order (boundary: p00 p01 p02 p03 p13 p23 p33 p32 p31 p30 + /// p20 p10, then interior: p11 p12 p22 p21) to the row-major index + /// (i * 4 + j) in a patch's 4x4 control net. + /// + private static readonly int[] PatchStreamOrder = [0, 1, 2, 3, 7, 11, 15, 14, 13, 12, 8, 4, 5, 6, 10, 9]; + + /// + /// For patch edge flags 1, 2 and 3: the previous patch's control-net indices that supply + /// the new patch's first four stream points (ISO 32000-1 Tables 85/86). + /// + private static readonly int[][] PatchContinuationPoints = + [ + [3, 7, 11, 15], + [15, 14, 13, 12], + [12, 8, 4, 0], + ]; + + /// + /// For patch edge flags 1, 2 and 3: the previous patch's corner-color indices + /// (in stream order c00 c03 c33 c30) that supply the new patch's first two colors. + /// + private static readonly int[][] PatchContinuationColors = + [ + [1, 2], + [2, 3], + [3, 0], + ]; + + /// A mesh color: either (R, G, B) in 0-255 or, for function-based meshes, the parametric value t in . + /// First color component (red, or the parametric value t). + /// Second color component (green; unused with a function). + /// Third color component (blue; unused with a function). + internal readonly record struct MeshColor(double A, double B, double C); + + /// A mesh vertex: a position and a color. + /// The x coordinate. + /// The y coordinate. + /// The vertex color. + internal readonly record struct MeshVertex(double X, double Y, MeshColor Color); + + /// A decoded Coons/tensor patch: a 4x4 control net and 4 corner colors. + /// The 16 control point x coordinates (row-major, i * 4 + j). + /// The 16 control point y coordinates (row-major, i * 4 + j). + /// The 4 corner colors in stream order (c00 c03 c33 c30). + internal sealed record MeshPatch(double[] X, double[] Y, MeshColor[] Colors); + + /// + /// The fully decoded contents of a /ShadingType 4-7 shading, decoded once + /// at resolve time so malformed data fails identically for sh and for patterns. + /// + internal sealed class MeshShading + { + /// Gets the shading's /ShadingType (4-7). + internal required int ShadingType { get; init; } + + /// Gets the vertices (type 4/5); empty for patch types. + internal MeshVertex[] Vertices { get; init; } = []; + + /// Gets triangle vertex indices, 3 per triangle (type 4/5); empty for patch types. + internal int[] Triangles { get; init; } = []; + + /// Gets the patches (type 6/7); empty for triangle types. + internal MeshPatch[] Patches { get; init; } = []; + + /// Gets the color lookup table over .. when the shading has a /Function, else . + internal Rgba32[]? Lut { get; init; } + + /// Gets the lower bound of the parametric value t (function-based meshes). + internal double TMin { get; init; } + + /// Gets the upper bound of the parametric value t (function-based meshes). + internal double TMax { get; init; } + + /// Gets the optional /Background color (honored for pattern use only), or . + internal Rgba32? Background { get; init; } + } + + /// A big-endian, MSB-first bit reader over a byte array supporting 1-32 bit reads. + internal sealed class MeshBitReader + { + private readonly byte[] _data; + private long _bitPosition; + + /// Initializes a new instance of the class. + /// The data to read. + internal MeshBitReader(byte[] data) => _data = data; + + /// Gets the number of unread bits. + internal long BitsRemaining => ((long)_data.Length * 8) - _bitPosition; + + /// Reads bits (1-32) as an unsigned value. + /// The number of bits to read. + /// The value read. + /// Thrown when fewer than bits remain. + internal ulong Read(int bits) + { + if (bits is < 1 or > 32 || BitsRemaining < bits) + { + throw new InvalidDataException("Mesh shading data is truncated."); + } + + ulong value = 0; + for (var i = 0; i < bits; i++) + { + var byteValue = _data[(int)(_bitPosition >> 3)]; + var bit = (byteValue >> (7 - (int)(_bitPosition & 7))) & 1; + value = (value << 1) | (uint)bit; + _bitPosition++; + } + + return value; + } + + /// Skips to the next byte boundary (no-op when already aligned). + internal void AlignToByte() => _bitPosition = (_bitPosition + 7) & ~7L; + } + + /// + /// Resolves a /ShadingType 4-7 shading stream into a . + /// + /// The already-resolved shading (must be a stream). + /// The shading type (4-7). + /// The already-validated Device color space. + /// The decoded mesh. + /// Thrown for missing/illegal parameters, bad flags or truncated data. + /// + /// Thrown when a mesh limit is exceeded (features pdf-shading-mesh-too-many-vertices, + /// pdf-shading-mesh-too-many-patches). + /// + private MeshShading ResolveMeshShading(PdfObject shading, int shadingType, PdfColorSpace colorSpace) + { + if (shading.Kind != PdfKind.Stream) + { + throw new InvalidDataException($"/ShadingType {shadingType} shading must be a stream."); + } + + var bitsPerCoordinate = RequireMeshBits(shading, "BitsPerCoordinate", [1, 2, 4, 8, 12, 16, 24, 32]); + var bitsPerComponent = RequireMeshBits(shading, "BitsPerComponent", [1, 2, 4, 8, 12, 16]); + var bitsPerFlag = shadingType == 5 ? 0 : RequireMeshBits(shading, "BitsPerFlag", [2, 4, 8]); + + var functionEntry = shading.Get("Function"); + Func? evaluate = null; + if (functionEntry is not null) + { + (_, evaluate) = ResolveFunctionOrFunctionArray(functionEntry); + } + + var componentCount = ComponentCount(colorSpace); + var colorValueCount = evaluate is null ? componentCount : 1; + var decode = RequireNumberArray(shading, "Decode"); + if (decode.Length != 4 + (2 * colorValueCount) || decode.Any(d => !double.IsFinite(d))) + { + throw new InvalidDataException( + $"/Decode must have exactly {4 + (2 * colorValueCount)} finite elements for this mesh shading."); + } + + Rgba32[]? lut = null; + var tMin = 0.0; + var tMax = 0.0; + if (evaluate is not null) + { + tMin = Math.Min(decode[4], decode[5]); + tMax = Math.Max(decode[4], decode[5]); + lut = new Rgba32[MeshFunctionLutSize]; + for (var i = 0; i < lut.Length; i++) + { + var t = tMin + ((tMax - tMin) * i / (lut.Length - 1)); + var output = evaluate(t); + if (output.Length != componentCount) + { + throw new InvalidDataException( + $"/Function must produce {componentCount} output component(s) for the shading /ColorSpace."); + } + + lut[i] = ColorFromComponents(colorSpace, output); + } + } + + Rgba32? background = null; + var backgroundValues = ResolveOptionalNumberArray(shading, "Background"); + if (backgroundValues is not null) + { + if (backgroundValues.Length != componentCount || backgroundValues.Any(d => !double.IsFinite(d))) + { + throw new InvalidDataException( + $"/Background must have exactly {componentCount} finite elements for the shading /ColorSpace."); + } + + background = ColorFromComponents(colorSpace, backgroundValues); + } + + var parameters = new MeshParameters( + colorSpace, bitsPerCoordinate, bitsPerComponent, bitsPerFlag, decode, evaluate is not null, colorValueCount); + + int verticesPerRow = 0; + if (shadingType == 5) + { + verticesPerRow = RequireIntEntry(shading, "VerticesPerRow"); + if (verticesPerRow < 2) + { + throw new InvalidDataException("/VerticesPerRow must be at least 2."); + } + } + + var reader = new MeshBitReader(GetStreamDecodedBytes(shading)); + if (reader.BitsRemaining == 0) + { + throw new InvalidDataException("Mesh shading stream contains no data."); + } + + return shadingType switch + { + 4 => BuildTriangleMesh(ReadFreeFormTriangles(reader, parameters), shadingType, lut, tMin, tMax, background), + 5 => BuildTriangleMesh(ReadLatticeTriangles(reader, parameters, verticesPerRow), shadingType, lut, tMin, tMax, background), + _ => new MeshShading + { + ShadingType = shadingType, + Patches = ReadPatches(reader, parameters, shadingType), + Lut = lut, + TMin = tMin, + TMax = tMax, + Background = background, + }, + }; + } + + /// The validated, shared decode parameters of a mesh stream. + private readonly record struct MeshParameters( + PdfColorSpace ColorSpace, + int BitsPerCoordinate, + int BitsPerComponent, + int BitsPerFlag, + double[] Decode, + bool UsesFunction, + int ColorValueCount); + + /// Assembles a triangle-type . + private static MeshShading BuildTriangleMesh( + (MeshVertex[] Vertices, int[] Triangles) data, + int shadingType, + Rgba32[]? lut, + double tMin, + double tMax, + Rgba32? background) => + new() + { + ShadingType = shadingType, + Vertices = data.Vertices, + Triangles = data.Triangles, + Lut = lut, + TMin = tMin, + TMax = tMax, + Background = background, + }; + + /// Reads a required bit-size entry and validates it against the spec-legal set. + /// Thrown when missing or not one of . + private int RequireMeshBits(PdfObject shading, string key, int[] legal) + { + var value = RequireIntEntry(shading, key); + if (Array.IndexOf(legal, value) < 0) + { + throw new InvalidDataException($"/{key} {value} is not a legal value for a mesh shading."); + } + + return value; + } + + /// Maps a raw -bit sample into [dMin, dMax]. + private static double DecodeSample(ulong raw, int bits, double dMin, double dMax) => + dMin + (raw * (dMax - dMin) / (Math.Pow(2, bits) - 1)); + + /// Reads one coordinate pair. + private static (double X, double Y) ReadMeshPoint(MeshBitReader reader, MeshParameters p) + { + var x = DecodeSample(reader.Read(p.BitsPerCoordinate), p.BitsPerCoordinate, p.Decode[0], p.Decode[1]); + var y = DecodeSample(reader.Read(p.BitsPerCoordinate), p.BitsPerCoordinate, p.Decode[2], p.Decode[3]); + return (x, y); + } + + /// Reads one color (RGB bytes, or the parametric value t when a function is present). + private static MeshColor ReadMeshColor(MeshBitReader reader, MeshParameters p) + { + var values = new double[p.ColorValueCount]; + for (var i = 0; i < values.Length; i++) + { + values[i] = DecodeSample( + reader.Read(p.BitsPerComponent), p.BitsPerComponent, p.Decode[4 + (2 * i)], p.Decode[5 + (2 * i)]); + } + + if (p.UsesFunction) + { + return new MeshColor(values[0], 0, 0); + } + + var color = ColorFromComponents(p.ColorSpace, values); + return new MeshColor(color.R, color.G, color.B); + } + + /// Reads one vertex (position and color) and aligns to the next byte boundary. + private static MeshVertex ReadMeshVertex(MeshBitReader reader, MeshParameters p) + { + var (x, y) = ReadMeshPoint(reader, p); + var color = ReadMeshColor(reader, p); + reader.AlignToByte(); + return new MeshVertex(x, y, color); + } + + /// Reads a type 4 (free-form) mesh into a vertex list and triangle index list. + private static (MeshVertex[] Vertices, int[] Triangles) ReadFreeFormTriangles(MeshBitReader reader, MeshParameters p) + { + var vertices = new List(); + var triangles = new List(); + int a = -1; + int b = -1; + int c = -1; + while (reader.BitsRemaining > 0) + { + var flag = reader.Read(p.BitsPerFlag); + if (flag > 2 || (flag != 0 && a < 0)) + { + throw new InvalidDataException($"Invalid edge flag {flag} in type 4 mesh shading."); + } + + var vertex = ReadMeshVertex(reader, p); + if (flag == 0) + { + AddMeshVertex(vertices, vertex); + for (var extra = 0; extra < 2; extra++) + { + var extraFlag = reader.Read(p.BitsPerFlag); + if (extraFlag > 2) + { + throw new InvalidDataException($"Invalid edge flag {extraFlag} in type 4 mesh shading."); + } + AddMeshVertex(vertices, ReadMeshVertex(reader, p)); + } + + a = vertices.Count - 3; + b = vertices.Count - 2; + c = vertices.Count - 1; + } + else + { + AddMeshVertex(vertices, vertex); + var added = vertices.Count - 1; + var oldB = b; + var oldC = c; + if (flag == 1) + { + a = oldB; + } + + b = oldC; + c = added; + } + + triangles.Add(a); + triangles.Add(b); + triangles.Add(c); + } + + return (vertices.ToArray(), triangles.ToArray()); + } + + /// Reads a type 5 (lattice) mesh into a vertex list and triangle index list. + private static (MeshVertex[] Vertices, int[] Triangles) ReadLatticeTriangles( + MeshBitReader reader, MeshParameters p, int verticesPerRow) + { + var vertices = new List(); + while (reader.BitsRemaining > 0) + { + AddMeshVertex(vertices, ReadMeshVertex(reader, p)); + } + + if (vertices.Count % verticesPerRow != 0 || vertices.Count / verticesPerRow < 2) + { + throw new InvalidDataException( + "Type 5 mesh shading vertex count must be a multiple of /VerticesPerRow and span at least 2 rows."); + } + + var rows = vertices.Count / verticesPerRow; + var triangles = new List(2 * 3 * (rows - 1) * (verticesPerRow - 1)); + for (var r = 0; r < rows - 1; r++) + { + for (var col = 0; col < verticesPerRow - 1; col++) + { + var i00 = (r * verticesPerRow) + col; + var i01 = i00 + 1; + var i10 = i00 + verticesPerRow; + var i11 = i10 + 1; + triangles.AddRange([i00, i01, i10, i01, i11, i10]); + } + } + + return (vertices.ToArray(), triangles.ToArray()); + } + + /// Adds a vertex, enforcing . + /// Thrown when the limit would be exceeded. + private static void AddMeshVertex(List vertices, MeshVertex vertex) + { + if (vertices.Count >= MaxMeshVertices) + { + throw new UnsupportedImageFeatureException( + "pdf-shading-mesh-too-many-vertices", + $"Mesh shading declares more than {MaxMeshVertices} vertices."); + } + + vertices.Add(vertex); + } + + /// Reads a type 6 (Coons) or type 7 (tensor) patch mesh. + private static MeshPatch[] ReadPatches(MeshBitReader reader, MeshParameters p, int shadingType) + { + var patches = new List(); + MeshPatch? previous = null; + var streamPoints = shadingType == 6 ? 12 : 16; + while (reader.BitsRemaining > 0) + { + var flag = (int)reader.Read(p.BitsPerFlag); + if (flag > 3 || (flag > 0 && previous is null)) + { + throw new InvalidDataException($"Invalid edge flag {flag} in type {shadingType} mesh shading."); + } + + if (patches.Count >= MaxMeshPatches) + { + throw new UnsupportedImageFeatureException( + "pdf-shading-mesh-too-many-patches", + $"Mesh shading declares more than {MaxMeshPatches} patches."); + } + + var x = new double[16]; + var y = new double[16]; + var colors = new MeshColor[4]; + var firstPoint = 0; + var firstColor = 0; + if (flag > 0) + { + for (var k = 0; k < 4; k++) + { + var source = PatchContinuationPoints[flag - 1][k]; + x[PatchStreamOrder[k]] = previous!.X[source]; + y[PatchStreamOrder[k]] = previous.Y[source]; + } + + for (var k = 0; k < 2; k++) + { + colors[k] = previous!.Colors[PatchContinuationColors[flag - 1][k]]; + } + + firstPoint = 4; + firstColor = 2; + } + + for (var k = firstPoint; k < streamPoints; k++) + { + (x[PatchStreamOrder[k]], y[PatchStreamOrder[k]]) = ReadMeshPoint(reader, p); + } + + for (var k = firstColor; k < 4; k++) + { + colors[k] = ReadMeshColor(reader, p); + } + + reader.AlignToByte(); + if (shadingType == 6) + { + ApplyCoonsInteriorPoints(x); + ApplyCoonsInteriorPoints(y); + } + + previous = new MeshPatch(x, y, colors); + patches.Add(previous); + } + + return patches.ToArray(); + } + + /// + /// Fills the 4 interior control points of a Coons patch's 4x4 net (ISO 32000-1 + /// §8.7.4.5.7) from its 12 boundary points, one coordinate axis at a time. + /// + /// The 16 row-major (i * 4 + j) values of one coordinate; interior entries are overwritten. + private static void ApplyCoonsInteriorPoints(double[] p) + { + double P(int i, int j) => p[(i * 4) + j]; + + var p11 = ((-4 * P(0, 0)) + (6 * (P(0, 1) + P(1, 0))) - (2 * (P(0, 3) + P(3, 0))) + (3 * (P(3, 1) + P(1, 3))) - P(3, 3)) / 9; + var p12 = ((-4 * P(0, 3)) + (6 * (P(0, 2) + P(1, 3))) - (2 * (P(0, 0) + P(3, 3))) + (3 * (P(3, 2) + P(1, 0))) - P(3, 0)) / 9; + var p21 = ((-4 * P(3, 0)) + (6 * (P(3, 1) + P(2, 0))) - (2 * (P(3, 3) + P(0, 0))) + (3 * (P(0, 1) + P(2, 3))) - P(0, 3)) / 9; + var p22 = ((-4 * P(3, 3)) + (6 * (P(3, 2) + P(2, 3))) - (2 * (P(3, 0) + P(0, 3))) + (3 * (P(0, 2) + P(2, 0))) - P(0, 0)) / 9; + p[5] = p11; + p[6] = p12; + p[9] = p21; + p[10] = p22; + } + + /// + /// Paints through (honoring the active clip): + /// the mesh is rasterized into an offscreen bitmap bounded to the path's device-space extent + /// (transparent outside the mesh) which is then painted over with a + /// . The bitmap is bounded to the path, the active clip and (when no + /// /Background is painted) the mesh's own transformed geometry. + /// + /// The decoded mesh. + /// The transform from the mesh's coordinate space to device space. + /// The device-space region/path to paint through. + /// The fill rule for . + /// Whether to pre-fill the mesh's /Background (pattern use only). + /// Thrown (feature pdf-shading-mesh-too-complex) when the raster work budget is exceeded. + private void PaintMesh(MeshShading mesh, Matrix3x2 toDevice, Geometry.Path path, FillRule fillRule, bool applyBackground) + { + // Rasterize only the part of the destination the path can touch, so a small painted + // region never allocates a page-sized temporary bitmap. + var bounds = path.GetBounds(); + if (bounds.IsEmpty) + { + return; + } + + var left = (int)Math.Max(0, Math.Floor(Math.Min(bounds.Left, _surface.Width)) - 1); + var top = (int)Math.Max(0, Math.Floor(Math.Min(bounds.Top, _surface.Height)) - 1); + var right = (int)Math.Min(_surface.Width, Math.Ceiling(Math.Max(bounds.Right, 0)) + 1); + var bottom = (int)Math.Min(_surface.Height, Math.Ceiling(Math.Max(bounds.Bottom, 0)) + 1); + if (_gs.Clip is { } clip) + { + var clipBounds = clip.Bounds; + left = Math.Max(left, (int)clipBounds.Left - 1); + top = Math.Max(top, (int)clipBounds.Top - 1); + right = Math.Min(right, (int)Math.Ceiling(clipBounds.Right) + 1); + bottom = Math.Min(bottom, (int)Math.Ceiling(clipBounds.Bottom) + 1); + } + + if (!(applyBackground && mesh.Background is not null)) + { + // Without a /Background the mesh paints only its own geometry, so the temporary + // raster need not extend beyond it (patch control points bound the Bezier surface). + if (!TryGetMeshDeviceBounds(mesh, toDevice, out var meshBounds)) + { + return; + } + + left = Math.Max(left, (int)Math.Floor(Math.Clamp(meshBounds.MinX, 0, _surface.Width)) - 1); + top = Math.Max(top, (int)Math.Floor(Math.Clamp(meshBounds.MinY, 0, _surface.Height)) - 1); + right = Math.Min(right, (int)Math.Ceiling(Math.Clamp(meshBounds.MaxX, 0, _surface.Width)) + 1); + bottom = Math.Min(bottom, (int)Math.Ceiling(Math.Clamp(meshBounds.MaxY, 0, _surface.Height)) + 1); + } + + left = Math.Max(left, 0); + top = Math.Max(top, 0); + if (right <= left || bottom <= top) + { + return; + } + + toDevice *= Matrix3x2.CreateTranslation(-left, -top); + using var meshSurface = new Surface(right - left, bottom - top); + if (applyBackground && mesh.Background is { } background) + { + for (var y = 0; y < meshSurface.Height; y++) + { + meshSurface.GetRowSpan(y).Fill(background); + } + } + + var rasterizer = new MeshRasterizer(meshSurface, mesh); + if (mesh.ShadingType is 4 or 5) + { + for (var i = 0; i + 2 < mesh.Triangles.Length; i += 3) + { + rasterizer.Triangle( + ToDevice(mesh.Vertices[mesh.Triangles[i]], toDevice), + ToDevice(mesh.Vertices[mesh.Triangles[i + 1]], toDevice), + ToDevice(mesh.Vertices[mesh.Triangles[i + 2]], toDevice)); + } + } + else + { + var grid = new MeshVertex[(MeshPatchSubdivision + 1) * (MeshPatchSubdivision + 1)]; + foreach (var patch in mesh.Patches) + { + EvaluatePatchGrid(patch, toDevice, grid); + for (var i = 0; i < MeshPatchSubdivision; i++) + { + for (var j = 0; j < MeshPatchSubdivision; j++) + { + var g00 = grid[(i * (MeshPatchSubdivision + 1)) + j]; + var g01 = grid[(i * (MeshPatchSubdivision + 1)) + j + 1]; + var g10 = grid[((i + 1) * (MeshPatchSubdivision + 1)) + j]; + var g11 = grid[((i + 1) * (MeshPatchSubdivision + 1)) + j + 1]; + rasterizer.Triangle(g00, g10, g01); + rasterizer.Triangle(g10, g11, g01); + } + } + } + } + + // The tile repeats outside the temporary bitmap, so confine the fill to its rectangle. + var bitmapClip = ClipMask.FromPath( + new PathBuilder() + .MoveTo(new Vector2(left, top)) + .LineTo(new Vector2(right, top)) + .LineTo(new Vector2(right, bottom)) + .LineTo(new Vector2(left, bottom)) + .Close() + .Build(), + FillRule.NonZero, + _surface.Width, + _surface.Height); + var tilePaint = new TilePaint(meshSurface, Matrix3x2.CreateTranslation(left, top), meshSurface.Width, meshSurface.Height); + PathFiller.Fill(_surface, path, tilePaint, _gs.Clip?.Intersect(bitmapClip) ?? bitmapClip, fillRule); + } + + /// + /// Computes the device-space bounding box of a mesh's geometry (vertices, or patch control + /// points, which enclose each Bezier surface). + /// + /// when the mesh has no geometry or its bounds are not finite. + private static bool TryGetMeshDeviceBounds( + MeshShading mesh, Matrix3x2 toDevice, out (double MinX, double MinY, double MaxX, double MaxY) bounds) + { + var minX = double.PositiveInfinity; + var minY = double.PositiveInfinity; + var maxX = double.NegativeInfinity; + var maxY = double.NegativeInfinity; + + void Include(double x, double y) + { + var dx = (x * toDevice.M11) + (y * toDevice.M21) + toDevice.M31; + var dy = (x * toDevice.M12) + (y * toDevice.M22) + toDevice.M32; + minX = Math.Min(minX, dx); + minY = Math.Min(minY, dy); + maxX = Math.Max(maxX, dx); + maxY = Math.Max(maxY, dy); + } + + if (mesh.ShadingType is 4 or 5) + { + foreach (var vertex in mesh.Vertices) + { + Include(vertex.X, vertex.Y); + } + } + else + { + foreach (var patch in mesh.Patches) + { + for (var i = 0; i < patch.X.Length; i++) + { + Include(patch.X[i], patch.Y[i]); + } + } + } + + bounds = (minX, minY, maxX, maxY); + return double.IsFinite(minX) && double.IsFinite(minY) && double.IsFinite(maxX) && double.IsFinite(maxY); + } + + /// Transforms a mesh vertex's position by (in double precision). + private static MeshVertex ToDevice(MeshVertex v, Matrix3x2 m) => + new( + (v.X * m.M11) + (v.Y * m.M21) + m.M31, + (v.X * m.M12) + (v.Y * m.M22) + m.M32, + v.Color); + + /// + /// Evaluates a patch's bicubic Bezier surface and bilinear corner-color blend on an + /// (N+1) x (N+1) grid, in device space. + /// + private static void EvaluatePatchGrid(MeshPatch patch, Matrix3x2 toDevice, MeshVertex[] grid) + { + var n = MeshPatchSubdivision; + Span bu = stackalloc double[4]; + Span bv = stackalloc double[4]; + for (var i = 0; i <= n; i++) + { + var u = (double)i / n; + Bernstein(u, bu); + for (var j = 0; j <= n; j++) + { + var v = (double)j / n; + Bernstein(v, bv); + double x = 0; + double y = 0; + for (var a = 0; a < 4; a++) + { + for (var b = 0; b < 4; b++) + { + var w = bu[a] * bv[b]; + x += patch.X[(a * 4) + b] * w; + y += patch.Y[(a * 4) + b] * w; + } + } + + var c00 = patch.Colors[0]; + var c03 = patch.Colors[1]; + var c33 = patch.Colors[2]; + var c30 = patch.Colors[3]; + var w00 = (1 - u) * (1 - v); + var w03 = (1 - u) * v; + var w33 = u * v; + var w30 = u * (1 - v); + var color = new MeshColor( + (c00.A * w00) + (c03.A * w03) + (c33.A * w33) + (c30.A * w30), + (c00.B * w00) + (c03.B * w03) + (c33.B * w33) + (c30.B * w30), + (c00.C * w00) + (c03.C * w03) + (c33.C * w33) + (c30.C * w30)); + grid[(i * (n + 1)) + j] = ToDevice(new MeshVertex(x, y, color), toDevice); + } + } + } + + /// Computes the 4 cubic Bernstein basis values at . + private static void Bernstein(double t, Span result) + { + var s = 1 - t; + result[0] = s * s * s; + result[1] = 3 * s * s * t; + result[2] = 3 * s * t * t; + result[3] = t * t * t; + } + + /// + /// A from-scratch Gouraud triangle rasterizer writing opaque pixels (no anti-aliasing, + /// pixel-center sampling, edge-inclusive so shared edges leave no cracks) into a + /// with a total work budget. + /// + private sealed class MeshRasterizer + { + private const double EdgeEpsilon = 1e-9; + private const long PerTriangleCost = 16; + + private readonly Surface _surface; + private readonly Rgba32[]? _lut; + private readonly double _tMin; + private readonly double _tRange; + private long _remainingBudget = MaxMeshRasterPixels; + + /// Initializes a new instance of the class. + /// The destination surface. + /// The mesh being painted (supplies the color lookup table). + internal MeshRasterizer(Surface surface, MeshShading mesh) + { + _surface = surface; + _lut = mesh.Lut; + _tMin = mesh.TMin; + _tRange = mesh.TMax - mesh.TMin; + } + + /// Rasterizes one device-space Gouraud triangle. + /// Thrown when the work budget is exhausted. + internal void Triangle(MeshVertex a, MeshVertex b, MeshVertex c) + { + if (!double.IsFinite(a.X + a.Y + b.X + b.Y + c.X + c.Y)) + { + return; + } + + var area = Edge(a, b, c.X, c.Y); + if (!double.IsFinite(area) || Math.Abs(area) < 1e-12) + { + return; + } + + var x0 = (int)Math.Clamp(Math.Ceiling(Math.Min(a.X, Math.Min(b.X, c.X)) - 0.5 - 1e-6), 0, _surface.Width); + var x1 = (int)Math.Clamp(Math.Floor(Math.Max(a.X, Math.Max(b.X, c.X)) - 0.5 + 1e-6), -1, _surface.Width - 1); + var y0 = (int)Math.Clamp(Math.Ceiling(Math.Min(a.Y, Math.Min(b.Y, c.Y)) - 0.5 - 1e-6), 0, _surface.Height); + var y1 = (int)Math.Clamp(Math.Floor(Math.Max(a.Y, Math.Max(b.Y, c.Y)) - 0.5 + 1e-6), -1, _surface.Height - 1); + + _remainingBudget -= PerTriangleCost + (x1 >= x0 && y1 >= y0 ? (long)(x1 - x0 + 1) * (y1 - y0 + 1) : 0); + if (_remainingBudget < 0) + { + throw new UnsupportedImageFeatureException( + "pdf-shading-mesh-too-complex", + "Mesh shading requires more rasterization work than the supported limit."); + } + + for (var y = y0; y <= y1; y++) + { + var row = _surface.GetRowSpan(y); + var py = y + 0.5; + for (var x = x0; x <= x1; x++) + { + var px = x + 0.5; + var l0 = Edge(b, c, px, py) / area; + var l1 = Edge(c, a, px, py) / area; + var l2 = Edge(a, b, px, py) / area; + if (l0 < -EdgeEpsilon || l1 < -EdgeEpsilon || l2 < -EdgeEpsilon) + { + continue; + } + + row[x] = ColorAt( + (l0 * a.Color.A) + (l1 * b.Color.A) + (l2 * c.Color.A), + (l0 * a.Color.B) + (l1 * b.Color.B) + (l2 * c.Color.B), + (l0 * a.Color.C) + (l1 * b.Color.C) + (l2 * c.Color.C)); + } + } + } + + private static double Edge(MeshVertex p, MeshVertex q, double rx, double ry) => + ((q.X - p.X) * (ry - p.Y)) - ((q.Y - p.Y) * (rx - p.X)); + + private Rgba32 ColorAt(double a, double b, double c) + { + if (_lut is not null) + { + var fraction = _tRange == 0 ? 0 : (a - _tMin) / _tRange; + var index = (int)Math.Round(Math.Clamp(fraction, 0, 1) * (_lut.Length - 1)); + return _lut[index]; + } + + return new Rgba32(ClampByte(a), ClampByte(b), ClampByte(c), 255); + } + + private static byte ClampByte(double value) => (byte)Math.Clamp(Math.Round(value), 0, 255); + } +} diff --git a/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Patterns.Shading.cs b/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Patterns.Shading.cs index 492b5638..e4dff036 100644 --- a/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Patterns.Shading.cs +++ b/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Patterns.Shading.cs @@ -23,8 +23,8 @@ public sealed partial class PdfDocument private const int ShadingGradientSampleCount = 32; /// - /// The fully resolved, immutable contents of a /ShadingType 2/3 shading - /// dictionary, shared by both consumers of a /Shading dictionary: the + /// The fully resolved, immutable contents of a /ShadingType 2-7 shading + /// dictionary (or mesh stream), shared by both consumers of a /Shading dictionary: the /// scn/SCN + /Pattern + /PatternType 2 path /// (, which wraps a shading dictionary in a pattern's own /// /Matrix) and the sh operator (, which paints @@ -42,13 +42,19 @@ public sealed partial class PdfDocument /// a shading *pattern*'s own fill region is instead whatever path it is painted onto, per /// . /// + /// + /// The decoded mesh for /ShadingType 4-7 (see ), or + /// for axial/radial shadings (for mesh shadings + /// // are unused). + /// private readonly record struct ShadingDescriptor( int ShadingType, PdfColorSpace ColorSpace, double[] Coords, double[] Domain, Func Evaluate, - double[]? BBox); + double[]? BBox, + MeshShading? Mesh = null); /// /// Resolves an already-resolved /Shading dictionary (or stream - some @@ -64,7 +70,7 @@ private readonly record struct ShadingDescriptor( /// cases. /// /// - /// Thrown when /ShadingType is not 2 or 3 (feature + /// Thrown when /ShadingType is not 2-7 (feature /// pdf-shading-type-{n}), when the resolved /ColorSpace's family is not /// DeviceGray/DeviceRGB/DeviceCMYK (feature /// pdf-shading-colorspace-{family}), or propagated from @@ -74,11 +80,11 @@ private readonly record struct ShadingDescriptor( private ShadingDescriptor ResolveShadingDescriptor(PdfObject shading) { var shadingType = RequireIntEntry(shading, "ShadingType"); - if (shadingType is not (2 or 3)) + if (shadingType is < 2 or > 7) { throw new UnsupportedImageFeatureException( $"pdf-shading-type-{shadingType}", - $"/ShadingType {shadingType} is not supported; only 2 (axial) and 3 (radial) are supported."); + $"/ShadingType {shadingType} is not supported; only 2 (axial), 3 (radial), 4-7 (mesh) are supported."); } var colorSpaceEntry = shading.Get("ColorSpace") @@ -93,6 +99,21 @@ private ShadingDescriptor ResolveShadingDescriptor(PdfObject shading) "DeviceRGB, and DeviceCMYK are supported."); } + // /BBox (PDF 32000-1 §8.7.4.3) is only meaningful to sh (see ShadingDescriptor's own + // remarks); it is still validated here, unconditionally, so a malformed /BBox is rejected + // identically regardless of which of the two consumers resolved this shading dictionary. + var bbox = ResolveOptionalNumberArray(shading, "BBox"); + if (bbox is not null && bbox.Length != 4) + { + throw new InvalidDataException("/Shading /BBox must have exactly 4 elements."); + } + + if (shadingType >= 4) + { + var mesh = ResolveMeshShading(shading, shadingType, colorSpace); + return new ShadingDescriptor(shadingType, colorSpace, [], [0.0, 1.0], static _ => [], bbox, mesh); + } + var functionEntry = shading.Get("Function") ?? throw new InvalidDataException("/Shading is missing required /Function."); var (_, evaluate) = ResolveFunctionOrFunctionArray(functionEntry); @@ -114,15 +135,6 @@ private ShadingDescriptor ResolveShadingDescriptor(PdfObject shading) $"/Coords must have exactly {expectedCoordCount} elements for /ShadingType {shadingType}."); } - // /BBox (PDF 32000-1 §8.7.4.3) is only meaningful to sh (see ShadingDescriptor's own - // remarks); it is still validated here, unconditionally, so a malformed /BBox is rejected - // identically regardless of which of the two consumers resolved this shading dictionary. - var bbox = ResolveOptionalNumberArray(shading, "BBox"); - if (bbox is not null && bbox.Length != 4) - { - throw new InvalidDataException("/Shading /BBox must have exactly 4 elements."); - } - return new ShadingDescriptor(shadingType, colorSpace, coords, domain, evaluate, bbox); } @@ -156,6 +168,7 @@ private ResolvedPattern BuildShadingPattern(PdfObject patternDict) Coords = descriptor.Coords, Domain = descriptor.Domain, Evaluate = descriptor.Evaluate, + Mesh = descriptor.Mesh, }; } @@ -222,8 +235,15 @@ private void OpPaintShading(IReadOnlyList operands) Domain = descriptor.Domain, Evaluate = descriptor.Evaluate, }; - var gradient = BuildShadingGradient(pattern, _gs.CurrentTransform); var region = BuildShadingPaintRegion(descriptor.BBox); + if (descriptor.Mesh is { } mesh) + { + // /Background applies to shading patterns only, never to sh (PDF 32000-1 §8.7.4.3). + PaintMesh(mesh, _gs.CurrentTransform, region, FillRule.NonZero, applyBackground: false); + return; + } + + var gradient = BuildShadingGradient(pattern, _gs.CurrentTransform); PathFiller.Fill(_surface, region, gradient, _gs.Clip, FillRule.NonZero); } diff --git a/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Patterns.Tiling.cs b/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Patterns.Tiling.cs index 83a0f566..648bef10 100644 --- a/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Patterns.Tiling.cs +++ b/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Patterns.Tiling.cs @@ -167,6 +167,10 @@ private TilePaint RenderTilingPatternCell(ResolvedPattern pattern, Matrix3x2 pat var savedSubpathStart = _subpathStart; var savedHasOpenSubpath = _hasOpenSubpath; var savedPendingClipFillRule = _pendingClipFillRule; + var savedTextClipBuilder = _textClipBuilder; + var savedTextClipPending = _textClipPending; + _textClipBuilder = null; + _textClipPending = false; _formNestingDepth++; try { @@ -202,6 +206,8 @@ private TilePaint RenderTilingPatternCell(ResolvedPattern pattern, Matrix3x2 pat _subpathStart = savedSubpathStart; _hasOpenSubpath = savedHasOpenSubpath; _pendingClipFillRule = savedPendingClipFillRule; + _textClipBuilder = savedTextClipBuilder; + _textClipPending = savedTextClipPending; } if (pattern.PaintType == 2 && tint is { } tintColor) diff --git a/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Patterns.cs b/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Patterns.cs index 7b8fe2db..42066667 100644 --- a/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Patterns.cs +++ b/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Patterns.cs @@ -36,7 +36,10 @@ internal enum PatternKind /// internal required Matrix3x2 Matrix { get; init; } - /// Gets the shading's /ShadingType (2 axial or 3 radial). Only meaningful when is . + /// Gets the decoded mesh for a /ShadingType 4-7 shading, or otherwise. Only meaningful when is . + internal MeshShading? Mesh { get; init; } + + /// Gets the shading's /ShadingType (2 axial, 3 radial, or 4-7 mesh). Only meaningful when is . internal int ShadingType { get; init; } /// Gets the shading's resolved /ColorSpace (restricted to DeviceGray/DeviceRGB/DeviceCMYK). Only meaningful when is . diff --git a/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Text.cs b/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Text.cs index be2ce0e0..114de449 100644 --- a/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Text.cs +++ b/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Text.cs @@ -37,9 +37,23 @@ public sealed partial class PdfDocument /// private Matrix3x2 _lineMatrix; + /// + /// Device-space accumulator of the glyph outlines shown under a clipping text-rendering + /// mode (Tr 4-7) since the current BT; until the + /// first such glyph. Applied to the clip, and cleared, by ET. + /// + private PathBuilder? _textClipBuilder; + + /// + /// Whether any glyph (including one with an empty outline, such as a space) has been shown + /// under a clipping mode since the current BT, so ET must intersect the clip. + /// + private bool _textClipPending; + /// /// Handles the BT operator: resets / - /// to the identity matrix. Every other text-state parameter (, + /// to the identity matrix and discards any pending text-clip accumulation. Every other + /// text-state parameter (, /// etc.) is part of the graphics state and is therefore left untouched - see /// 's remarks. /// @@ -47,19 +61,30 @@ private void OpBeginText() { _textMatrix = Matrix3x2.Identity; _lineMatrix = Matrix3x2.Identity; + _textClipBuilder = null; + _textClipPending = false; } /// - /// 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. + /// Handles the ET operator. If any glyph was shown under a clipping text-rendering + /// mode (Tr 4-7) since BT, the union of those glyph outlines + /// (non-zero winding) is intersected into the current clip. Text consisting only of + /// outline-less glyphs (spaces) yields an empty region and therefore clips everything, per + /// the specification. Otherwise ET has no effect on persisted state + /// (/ are left stale until the next BT). /// - private static void OpEndText() + private void OpEndText() { - // Intentionally a no-op - see remarks. + if (!_textClipPending) + { + return; + } + + var path = (_textClipBuilder ?? new PathBuilder()).Build(); + var mask = ClipMask.FromPath(path, FillRule.NonZero, _surface.Width, _surface.Height); + _gs.Clip = _gs.Clip is null ? mask : _gs.Clip.Intersect(mask); + _textClipBuilder = null; + _textClipPending = false; } /// Handles the Tc operator: sets the character spacing, in unscaled text-space units. @@ -113,43 +138,27 @@ private void OpSetFont(IReadOnlyList operands) /// /// Handles the Tr operator: sets the text-rendering mode. Modes 0 (fill, the - /// PDF specification's default), 1 (stroke), 2 (fill, then stroke), and + /// PDF specification's default), 1 (stroke), 2 (fill, then stroke), /// 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. + /// painted) and the four clipping variants 4-7 (fill, stroke, fill+stroke, + /// invisible - each additionally adding the glyph outline to a text clip applied at + /// ET, see ) are all accepted. A Type 3 glyph shown under a + /// clipping mode fails closed at show time (see ShowGlyph), not here. /// /// /// 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). + /// number is not one of the PDF specification's eight defined render modes (0-7). /// private void OpSetTextRenderMode(IReadOnlyList operands) { var mode = (int)RequireNumbers(operands, "Tr", 1)[0]; - switch (mode) + if (mode is < 0 or > 7) { - 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}."); + throw new InvalidDataException( + $"Operator 'Tr' requires a render mode in [0, 7]; got {mode}."); } + + _gs.RenderMode = 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. @@ -346,12 +355,17 @@ private void ShowText(byte[] bytes) /// 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, + /// other concrete implementation (painted via the outline-based path below, mode-aware: + /// fill for modes 0/2/4/6, /// then stroke - via the same helper shared with the - /// path-painting operators - for modes 1/2) - the shared trailing + /// path-painting operators - for modes 1/2/5/6; modes 4-7 + /// additionally accumulate the outline into the text clip applied by ET, and a Type 3 + /// glyph under those modes throws ) - the shared trailing /// displacement/advance logic runs unconditionally either way. /// + /// + /// Thrown for a Type 3 glyph when the render mode is 4-7. + /// private void ShowGlyph(IResolvedFont font, int code) { var (ttf, glyphIndex, w0) = font.Resolve(code); @@ -363,7 +377,16 @@ private void ShowGlyph(IResolvedFont font, int code) // 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. + // branch below skips GetGlyphOutline/fill entirely in that mode. A Type 3 glyph has + // no outline to add to a text clip, so the clipping modes 4-7 fail closed. + if (_gs.RenderMode >= 4) + { + throw new UnsupportedImageFeatureException( + "pdf-text-render-mode-type3-clip", + $"Text-rendering mode {_gs.RenderMode} (clipping) is not supported for Type 3 fonts; " + + "a Type 3 glyph procedure has no outline to add to the text clip."); + } + if (_gs.RenderMode != 3) { PaintType3Glyph(type3Font, code); @@ -375,6 +398,13 @@ private void ShowGlyph(IResolvedFont font, int code) // see ResolvedSimpleFont/ResolvedCompositeFont - so this null-forgiving use is safe. var resolvedTtf = ttf!; var outline = resolvedTtf.GetGlyphOutline(glyphIndex); + var clipMode = _gs.RenderMode >= 4; + if (clipMode) + { + // Even an outline-less glyph (a space) marks the text object as clipping. + _textClipPending = true; + } + if (outline.Subpaths.Count > 0) { var trm = ComputeTextRenderingMatrix(); @@ -383,7 +413,13 @@ private void ShowGlyph(IResolvedFont font, int code) AppendTransformedGlyphOutline(builder, outline, glyphMatrix); var path = builder.Build(); - if (_gs.RenderMode is 0 or 2) + if (clipMode) + { + _textClipBuilder ??= new PathBuilder(); + AppendTransformedGlyphOutline(_textClipBuilder, outline, glyphMatrix); + } + + if (_gs.RenderMode is 0 or 2 or 4 or 6) { // Known pre-existing simplification, out of scope for this change: unlike // the glyph-stroke step below (which does mirror a Pattern stroke color @@ -393,7 +429,7 @@ private void ShowGlyph(IResolvedFont font, int code) PathFiller.Fill(_surface, path, _gs.FillColor, _gs.Clip, FillRule.NonZero); } - if (_gs.RenderMode is 1 or 2) + if (_gs.RenderMode is 1 or 2 or 5 or 6) { PaintStroke(path); } diff --git a/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.cs b/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.cs index 645028b4..c8218239 100644 --- a/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.cs +++ b/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.cs @@ -80,7 +80,7 @@ namespace DemaConsulting.CanvasNet.Pdf; /// has no meaningful generic-family equivalent. /// /// -/// /Pattern-color-space shading (axial/radial, /ShadingType 2/3, +/// /Pattern-color-space shading (axial/radial/mesh, /ShadingType 2-7, /// driven by /FunctionType 0/2/3 functions) and tiling /// (/PaintType 1/2) pattern fills/strokes are fully supported, as are placed /// Form XObjects: Do on a /Subtype /Form XObject decodes its content stream @@ -105,13 +105,13 @@ namespace DemaConsulting.CanvasNet.Pdf; /// 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 +/// the R5 or R6 hardened-hash key derivation (/V 5, /R 5 or /R 6, /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. A non-/Standard security handler (for -/// example /Adobe.PubSec), AES-256 /R 6 ("hardened hash" key derivation), a +/// example /Adobe.PubSec), an AES-256 /R other than 5 or 6, 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 , @@ -119,10 +119,11 @@ namespace DemaConsulting.CanvasNet.Pdf; /// token. /// /// -/// Documented scope boundaries (not currently supported): mesh shadings -/// (/ShadingType 4-7; axial/radial types 2-3 are supported by the sh +/// Documented scope boundaries (not currently supported): function-based shadings +/// (/ShadingType 1; types 2-7 are supported by the sh /// operator and shading patterns), /FunctionType 4 PostScript-calculator functions, -/// transparency groups, and clip text-rendering modes. The sh operator and generic +/// transparency groups, and Type 3 glyphs shown in the clipping text-rendering modes +/// (Tr 4-7, which do clip outline-based fonts). The sh operator and generic /// path clipping (W/W*) are both supported. Every other keyword /// not implemented is silently skipped, not an error. A page with no /Contents at /// all still renders a cleared to @@ -211,7 +212,7 @@ public sealed partial class PdfDocument : IDisposable /// /// Thrown when the document's trailer declares an /Encrypt entry whose security /// handler, crypt filter, or revision is unsupported (a non-/Standard - /// security handler, /R 6, a non-/StdCF crypt filter, a genuinely-required + /// security handler, an AES-256 /R other than 5 or 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' encryption scope boundary. @@ -278,7 +279,7 @@ private PdfDocument(byte[] buffer, string? password) /// 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, against whichever /// security handler the document's /Encrypt entry declares (RC4, AES-128, or - /// AES-256 with R5 key derivation). Ignored entirely when the document is not encrypted. + /// AES-256 with R5/R6 key derivation). Ignored entirely when the document is not encrypted. /// /// A new instance representing the parsed document. /// Thrown when is null. @@ -288,7 +289,7 @@ private PdfDocument(byte[] buffer, string? password) /// /// Thrown when the document's trailer declares an /Encrypt entry whose security /// handler, crypt filter, or revision is unsupported (a non-/Standard security - /// handler, /R 6, a non-/StdCF crypt filter, a genuinely-required password + /// handler, an AES-256 /R other than 5 or 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). diff --git a/src/DemaConsulting.CanvasNet/Drawing/ClipMask.cs b/src/DemaConsulting.CanvasNet/Drawing/ClipMask.cs index 2af18be4..19acb855 100644 --- a/src/DemaConsulting.CanvasNet/Drawing/ClipMask.cs +++ b/src/DemaConsulting.CanvasNet/Drawing/ClipMask.cs @@ -86,6 +86,12 @@ private ClipMask(float[] coverage, int width, int height, int originX, int origi /// The device pixel extent's height, in whole pixel rows, this mask covers. public int Height { get; } + /// + /// Gets the whole-pixel rectangle outside which this mask's coverage is zero (zero-sized + /// when the mask covers nothing). + /// + public Rect Bounds => new(_originX, _originY, _boundsWidth, _boundsHeight); + /// /// Builds a new whose per-pixel coverage is 's /// own antialiased fill coverage, interpreted under , across a diff --git a/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfDocumentEncryptionTests.cs b/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfDocumentEncryptionTests.cs index 09a8abb2..e4927802 100644 --- a/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfDocumentEncryptionTests.cs +++ b/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfDocumentEncryptionTests.cs @@ -1,4 +1,4 @@ -// cspell:ignore AESV StdCF sAlT ObjStm +// cspell:ignore AESV StdCF sAlT ObjStm pypdf using System.Reflection; using System.Security.Cryptography; using System.Text; @@ -10,7 +10,7 @@ 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 +/// AES-256 /R 6 (algorithm 2.B hardened hash), plus the scope boundaries this phase deliberately still rejects (an unknown /R and a genuinely /// required non-empty password). /// /// @@ -932,21 +932,146 @@ public void PdfDocument_Open_Encrypted_NonAsciiPassword_ThrowsUnsupportedImageFe 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. + /// Proves an unsupported AES-256 revision (/V 5 with /R 7) throws with the pdf-encrypted-r-{revision} feature token. [Fact] - public void PdfDocument_Open_EncryptedAesV3_R6_ThrowsUnsupportedImageFeatureException() + public void PdfDocument_Open_EncryptedAesV3_UnknownRevision_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)}> " + + $"<< /Filter /Standard /V 5 /R 7 /O <{ToHex(zero48)}> /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); + Assert.Equal("pdf-encrypted-r-7", exception.Feature); } + /// Resolves a pypdf-generated AES-256 /R 6 fixture file. + private static string R6Fixture(string name) => Path.Join(AppContext.BaseDirectory, "PdfFixtures", name); + + /// Asserts the R6 fixtures' blue filled rectangle rendered (and so the content stream decrypted). + private static void AssertR6FixtureRendered(PdfDocument document) + { + using var surface = document.Render(0, 100, 100, Transparent); + Assert.Equal(new Canvas.Rgba32(0, 0, 255, 255), surface[50, 50]); + Assert.Equal(default, surface[5, 5]); + } + + /// Proves an independently generated (pypdf) AES-256 /R 6 document with an empty user password opens and renders. + [Fact] + public void PdfDocument_Open_EncryptedAesV3_R6_EmptyUserPassword_DecryptsAndRenders() + { + using var document = PdfDocument.Open(R6Fixture("encrypted-aes256-r6-empty-user-password.pdf")); + AssertR6FixtureRendered(document); + } + + /// Proves an independently generated AES-256 /R 6 document opens and renders with its correct user password. + [Fact] + public void PdfDocument_Open_EncryptedAesV3_R6_CorrectUserPassword_DecryptsAndRenders() + { + using var document = PdfDocument.Open(R6Fixture("encrypted-aes256-r6-user-password.pdf"), "user-secret"); + AssertR6FixtureRendered(document); + } + + /// Proves an independently generated AES-256 /R 6 document opens and renders with its correct owner password. + [Fact] + public void PdfDocument_Open_EncryptedAesV3_R6_CorrectOwnerPassword_DecryptsAndRenders() + { + using var document = PdfDocument.Open(R6Fixture("encrypted-aes256-r6-user-password.pdf"), "owner-secret"); + AssertR6FixtureRendered(document); + } + + /// Proves a non-ASCII (UTF-8 encoded) user password authenticates an AES-256 /R 6 document. + [Fact] + public void PdfDocument_Open_EncryptedAesV3_R6_Utf8UserPassword_DecryptsAndRenders() + { + using var document = PdfDocument.Open(R6Fixture("encrypted-aes256-r6-utf8-password.pdf"), "p\u00e4ssw\u00f6rd\u20ac"); + AssertR6FixtureRendered(document); + } + + /// Proves a wrong password on an AES-256 /R 6 document throws the incorrect-password feature. + [Fact] + public void PdfDocument_Open_EncryptedAesV3_R6_WrongPassword_ThrowsIncorrectPassword() + { + var exception = Assert.Throws( + () => PdfDocument.Open(R6Fixture("encrypted-aes256-r6-user-password.pdf"), "wrong")); + Assert.Equal("pdf-encrypted-incorrect-password", exception.Feature); + } + + /// Proves a missing password on an AES-256 /R 6 document requiring one throws the password-required feature. + [Fact] + public void PdfDocument_Open_EncryptedAesV3_R6_NoPassword_ThrowsPasswordRequired() + { + var exception = Assert.Throws( + () => PdfDocument.Open(R6Fixture("encrypted-aes256-r6-user-password.pdf"))); + Assert.Equal("pdf-encrypted-password-required", exception.Feature); + } + + /// + /// Test-only, independently written ISO 32000-2 Algorithm 2.B (hardened hash), used to + /// build an in-memory /R 6 document. + /// + private static byte[] TestHash2B(byte[] password, byte[] salt, byte[] userData) + { + var k = SHA256.HashData([.. password, .. salt, .. userData]); + var round = 0; + while (true) + { + var oneCopy = (byte[])[.. password, .. k, .. userData]; + var k1 = new List(); + for (var n = 0; n < 64; n++) + { + k1.AddRange(oneCopy); + } + + using var aes = Aes.Create(); + aes.Mode = CipherMode.CBC; + aes.Padding = PaddingMode.None; + var e = aes.CreateEncryptor(k[..16], k[16..32]).TransformFinalBlock([.. k1], 0, k1.Count); + + var sum = e.Take(16).Sum(b => b); + k = (sum % 3) switch + { + 0 => SHA256.HashData(e), + 1 => SHA384.HashData(e), + _ => SHA512.HashData(e), + }; + + round++; + if (round >= 64 && e[^1] <= round - 32) + { + return k[..32]; + } + } + } + + /// Proves an in-memory AES-256 /R 6 document (built with a test-side Algorithm 2.B) with an empty user password opens and renders. + [Fact] + public void PdfDocument_Open_EncryptedAesV3_R6_InMemoryEmptyUserPassword_DecryptsAndRenders() + { + 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 uBytes = (byte[])[.. TestHash2B([], validationSalt, []), .. validationSalt, .. keySalt]; + var ueBytes = AesCbcEncryptNoIv(TestHash2B([], keySalt, []), fileKey); + + 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 6 /Length 256 /O <{ToHex(new byte[48])}> /U <{ToHex(uBytes)}> " + + $"/OE <{ToHex(new byte[32])}> /UE <{ToHex(ueBytes)}> /P -3904 " + + "/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, Transparent); + + Assert.Equal(Black, surface[30, 70]); + Assert.Equal(default, surface[5, 5]); + } /// 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() diff --git a/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfDocumentMeshShadingTests.cs b/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfDocumentMeshShadingTests.cs new file mode 100644 index 00000000..05edb869 --- /dev/null +++ b/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfDocumentMeshShadingTests.cs @@ -0,0 +1,894 @@ +using DemaConsulting.CanvasNet.Codecs; + +// cspell:ignore Gouraud Coons bicubic Bezier subdivision + +namespace DemaConsulting.CanvasNet.Pdf.Tests; + +/// +/// Unit-level tests for PDF mesh shadings (/ShadingType 4 free-form Gouraud, 5 +/// lattice Gouraud, 6 Coons patch, 7 tensor patch), exercised through both the +/// sh operator and /PatternType 2 shading patterns, plus fail-closed behavior +/// and DoS limits. Mesh streams are packed bit-exactly by the test-local helpers below. +/// +public class PdfDocumentMeshShadingTests +{ + /// The default decode array: coordinates 0-100 (the page size) and colors 0-1. + private const string DefaultDecode = "[0 100 0 100 0 1 0 1 0 1]"; + + /// The (i, j) control-net position of each point in a patch's stream order. + private static readonly (int I, int J)[] PatchOrder = + [ + (0, 0), (0, 1), (0, 2), (0, 3), (1, 3), (2, 3), (3, 3), (3, 2), (3, 1), (3, 0), (2, 0), (1, 0), + (1, 1), (1, 2), (2, 2), (2, 1), + ]; + + private static readonly PdfRenderOptions Transparent = new() { BackgroundColor = new(0, 0, 0, 0) }; + + /// The bit sizes a mesh stream is packed with. + private readonly record struct Fmt(int CoordBits, int CompBits, int FlagBits); + + private static readonly Fmt Std = new(16, 8, 8); + + /// Packs values MSB-first into bytes. + private sealed class BitPacker + { + private readonly List _bytes = []; + private int _used; + + public void Write(ulong value, int bits) + { + for (var i = bits - 1; i >= 0; i--) + { + var bit = (int)((value >> i) & 1); + if (_used == 0) + { + _bytes.Add(0); + } + + _bytes[^1] |= (byte)(bit << (7 - _used)); + _used = (_used + 1) % 8; + } + } + + public void Align() => _used = 0; + + public byte[] ToArray() => [.. _bytes]; + } + + private static void WriteCoord(BitPacker p, Fmt f, double v) => + p.Write((ulong)Math.Round(v / 100.0 * (Math.Pow(2, f.CoordBits) - 1)), f.CoordBits); + + private static void WriteComp(BitPacker p, Fmt f, double v) => + p.Write((ulong)Math.Round(v * (Math.Pow(2, f.CompBits) - 1)), f.CompBits); + + private static void WriteVertex(BitPacker p, Fmt f, int? flag, double x, double y, params double[] comps) + { + if (flag is { } fl) + { + p.Write((ulong)fl, f.FlagBits); + } + + WriteCoord(p, f, x); + WriteCoord(p, f, y); + foreach (var c in comps) + { + WriteComp(p, f, c); + } + + p.Align(); + } + + private static byte[] Type4(Fmt f, params (int Flag, double X, double Y, double R, double G, double B)[] vs) + { + var p = new BitPacker(); + foreach (var v in vs) + { + WriteVertex(p, f, v.Flag, v.X, v.Y, v.R, v.G, v.B); + } + + return p.ToArray(); + } + + private static byte[] Type5(Fmt f, params (double X, double Y, double R, double G, double B)[] vs) + { + var p = new BitPacker(); + foreach (var v in vs) + { + WriteVertex(p, f, null, v.X, v.Y, v.R, v.G, v.B); + } + + return p.ToArray(); + } + + private static void WritePatch( + BitPacker p, Fmt f, int type, int flag, Func point, (double R, double G, double B)[] colors) + { + p.Write((ulong)flag, f.FlagBits); + var count = type == 6 ? 12 : 16; + for (var k = flag == 0 ? 0 : 4; k < count; k++) + { + var (x, y) = point(PatchOrder[k].I, PatchOrder[k].J); + WriteCoord(p, f, x); + WriteCoord(p, f, y); + } + + foreach (var c in colors) + { + WriteComp(p, f, c.R); + WriteComp(p, f, c.G); + WriteComp(p, f, c.B); + } + + p.Align(); + } + + private static readonly (double R, double G, double B)[] CornerColors = + [ + (1, 0, 0), (0, 1, 0), (0, 0, 1), (1, 1, 1), + ]; + + private static (double X, double Y) Square(int i, int j, double x0 = 10, double y0 = 10, double size = 80) => + (x0 + (size * i / 3.0), y0 + (size * j / 3.0)); + + private static byte[] SinglePatch(int type, Func point) + { + var p = new BitPacker(); + WritePatch(p, Std, type, 0, point, CornerColors); + return p.ToArray(); + } + + /// Builds the shading dictionary entries (a null argument omits that entry). + private static string Entries( + int type, + string? coordBits = "16", + string? compBits = "8", + string? flagBits = "8", + string? decode = DefaultDecode, + string colorSpace = "/DeviceRGB", + string extra = "") + { + var s = $"/ShadingType {type} /ColorSpace {colorSpace}"; + s += coordBits is null ? string.Empty : $" /BitsPerCoordinate {coordBits}"; + s += compBits is null ? string.Empty : $" /BitsPerComponent {compBits}"; + s += flagBits is null || type == 5 ? string.Empty : $" /BitsPerFlag {flagBits}"; + s += decode is null ? string.Empty : $" /Decode {decode}"; + return s + " " + extra; + } + + /// Builds a one-page PDF holding a mesh shading used via sh or as a shading pattern. + private static byte[] BuildPdf(string entries, byte[] data, bool pattern, string? content = null, string patternEntries = "", string? shadingBody = null) + { + var functionNumber = pattern ? "7" : "6"; + var shading = shadingBody is null + ? BuildStream(entries.Replace("{FN}", functionNumber, StringComparison.Ordinal), data) + : System.Text.Encoding.ASCII.GetBytes(shadingBody); + var function = BuildStream("/FunctionType 2 /Domain [0 1] /C0 [0 0 0] /C1 [1 1 1] /N 1", []); + string resources; + List objects; + if (pattern) + { + objects = + [ + System.Text.Encoding.ASCII.GetBytes($"<< /PatternType 2 /Shading 6 0 R {patternEntries} >>"), + shading, + function, + ]; + resources = "/Pattern << /P1 5 0 R >>"; + content ??= "/Pattern cs /P1 scn 0 0 100 100 re f"; + } + else + { + objects = [shading, function]; + resources = "/Shading << /Sh1 5 0 R >>"; + content ??= "/Sh1 sh"; + } + + return BuildSinglePage(content, resources, objects); + } + + private static byte[] BuildStream(string entries, byte[] data) + { + var header = System.Text.Encoding.ASCII.GetBytes($"<< {entries} /Length {data.Length} >>\nstream\n"); + return [.. header, .. data, .. "\nendstream"u8.ToArray()]; + } + + private static byte[] BuildSinglePage(string content, string resources, List extraObjects) + { + var contentBytes = System.Text.Encoding.ASCII.GetBytes(content); + 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 << {resources} >> >>"), + BuildStream(string.Empty, contentBytes), + }; + bodies.AddRange(extraObjects); + + using var buffer = new MemoryStream(); + void Write(byte[] b) => buffer.Write(b, 0, b.Length); + Write("%PDF-1.7\n"u8.ToArray()); + var offsets = new List(); + for (var i = 0; i < bodies.Count; i++) + { + offsets.Add(buffer.Position); + Write(System.Text.Encoding.ASCII.GetBytes($"{i + 1} 0 obj\n")); + Write(bodies[i]); + Write("\nendobj\n"u8.ToArray()); + } + + var xref = buffer.Position; + Write(System.Text.Encoding.ASCII.GetBytes($"xref\n0 {bodies.Count + 1}\n0000000000 65535 f \n")); + foreach (var o in offsets) + { + Write(System.Text.Encoding.ASCII.GetBytes($"{o:D10} 00000 n \n")); + } + + Write(System.Text.Encoding.ASCII.GetBytes( + $"trailer\n<< /Size {bodies.Count + 1} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n")); + return buffer.ToArray(); + } + + private static Canvas.Surface Render(byte[] pdf) + { + using var document = PdfDocument.Open(new MemoryStream(pdf)); + return document.Render(0, 100, 100, Transparent); + } + + private static Canvas.Surface RenderMesh( + int type, byte[] data, bool pattern = false, string? entries = null, string? content = null, string patternEntries = "") => + Render(BuildPdf(entries ?? Entries(type), data, pattern, content, patternEntries)); + + /// The pixel covering the PDF point (x, y) (PDF y points up, the surface's y points down). + private static Canvas.Rgba32 At(Canvas.Surface s, double x, double y) => s[(int)x, 99 - (int)y]; + + private static void AssertNear(Canvas.Rgba32 actual, int r, int g, int b, int tol) + { + Assert.InRange(actual.A, 255, 255); + Assert.InRange(actual.R, r - tol, r + tol); + Assert.InRange(actual.G, g - tol, g + tol); + Assert.InRange(actual.B, b - tol, b + tol); + } + + private static readonly (int, double, double, double, double, double)[] RgbTriangle = + [ + (0, 10, 10, 1, 0, 0), (0, 90, 10, 0, 1, 0), (0, 50, 90, 0, 0, 1), + ]; + + private static byte[] RgbTriangleData(Fmt f) => + Type4(f, RgbTriangle[0], RgbTriangle[1], RgbTriangle[2]); + + #region Type 4 + + /// Proves that sh paints a type 4 triangle with Gouraud-interpolated vertex colors and leaves outside pixels untouched. + [Fact] + public void PdfDocument_MeshShading_Type4_ShOperator_InterpolatesVertexColors() + { + using var surface = RenderMesh(4, RgbTriangleData(Std)); + + AssertNear(At(surface, 50, 36), 85, 85, 85, 25); + Assert.True(At(surface, 12, 11).R > 200); + Assert.True(At(surface, 88, 11).G > 200); + Assert.True(At(surface, 50, 88).B > 200); + Assert.Equal(default, At(surface, 95, 95)); + Assert.Equal(default, At(surface, 5, 5)); + } + + /// Proves that a type 4 shading pattern paints only where both the filled rectangle and the mesh cover. + [Fact] + public void PdfDocument_MeshShading_Type4_Pattern_PaintsOnlyInsideFillAndMesh() + { + using var surface = RenderMesh( + 4, RgbTriangleData(Std), pattern: true, content: "/Pattern cs /P1 scn 0 0 50 100 re f"); + + Assert.NotEqual(default, At(surface, 30, 20)); + Assert.Equal(default, At(surface, 70, 20)); + Assert.Equal(default, At(surface, 30, 95)); + } + + /// Proves that edge flag 1 continues a type 4 strip with (vb, vc, new). + [Fact] + public void PdfDocument_MeshShading_Type4_Flag1_ContinuesStrip() + { + using var surface = RenderMesh(4, Type4( + Std, + (0, 10, 10, 1, 0, 0), (0, 40, 10, 1, 0, 0), (0, 10, 40, 1, 0, 0), (1, 80, 80, 0, 0, 1))); + + Assert.NotEqual(default, At(surface, 68, 64)); + Assert.NotEqual(default, At(surface, 15, 15)); + Assert.Equal(default, At(surface, 15, 70)); + } + + /// Proves that edge flag 2 continues a type 4 fan with (va, vc, new), distinct from flag 1. + [Fact] + public void PdfDocument_MeshShading_Type4_Flag2_ContinuesFan() + { + using var surface = RenderMesh(4, Type4( + Std, + (0, 10, 10, 1, 0, 0), (0, 40, 10, 1, 0, 0), (0, 10, 40, 1, 0, 0), (2, 80, 80, 0, 0, 1))); + + Assert.NotEqual(default, At(surface, 50, 55)); + Assert.Equal(default, At(surface, 68, 64)); + Assert.Equal(default, At(surface, 15, 70)); + } + + /// Proves that a type 4 shading with a /Function maps the vertex parametric value through the function (and honors non-default coordinate/t decode ranges). + [Fact] + public void PdfDocument_MeshShading_Type4_Function_MapsParametricValue() + { + // Coordinates decode over 0-200 (so raw 0.5 -> 100 only reaches the page edge), t over 0-1. + var p = new BitPacker(); + var f = Std; + foreach (var (x, y, t) in new[] { (0.0, 0.0, 0.0), (100.0, 0.0, 1.0), (0.0, 100.0, 0.0) }) + { + p.Write(0, 8); + p.Write((ulong)Math.Round(x / 200.0 * 65535), 16); + p.Write((ulong)Math.Round(y / 200.0 * 65535), 16); + p.Write((ulong)Math.Round(t * 255), 8); + p.Align(); + } + + var entries = $"/ShadingType 4 /ColorSpace /DeviceRGB /BitsPerCoordinate {f.CoordBits} /BitsPerComponent 8 " + + "/BitsPerFlag 8 /Decode [0 200 0 200 0 1] /Function {FN} 0 R"; + using var surface = Render(BuildPdf(entries, p.ToArray(), pattern: false)); + + Assert.True(At(surface, 5, 3).R < 40); + Assert.True(At(surface, 90, 3).R > 200); + Assert.Equal(At(surface, 90, 3).R, At(surface, 90, 3).G); + } + + /// Proves that type 4 decodes identically across coordinate/component/flag bit sizes, including non-byte-aligned vertex records. + [Theory] + [MemberData(nameof(BitSizeMatrix))] + public void PdfDocument_MeshShading_Type4_BitSizeMatrix_RendersSame(int coordBits, int compBits, int flagBits) + { + var f = new Fmt(coordBits, compBits, flagBits); + var entries = Entries(4, coordBits.ToString(), compBits.ToString(), flagBits.ToString()); + using var surface = RenderMesh(4, RgbTriangleData(f), entries: entries); + + AssertNear(At(surface, 50, 36), 85, 85, 85, 30); + Assert.NotEqual(default, At(surface, 50, 50)); + Assert.Equal(default, At(surface, 95, 95)); + } + + /// The bit-size combinations exercised by the matrix test. + public static TheoryData BitSizeMatrix() + { + var data = new TheoryData(); + foreach (var c in new[] { 8, 12, 16, 24, 32 }) + { + foreach (var k in new[] { 8, 12, 16 }) + { + foreach (var fl in new[] { 2, 4, 8 }) + { + data.Add(c, k, fl); + } + } + } + + return data; + } + + /// Proves that DeviceGray and DeviceCMYK mesh color spaces render. + [Fact] + public void PdfDocument_MeshShading_GrayAndCmykColorSpaces_Render() + { + var gray = new BitPacker(); + foreach (var (x, y) in new[] { (10.0, 10.0), (90.0, 10.0), (50.0, 90.0) }) + { + WriteVertex(gray, Std, 0, x, y, 0.5); + } + + using var graySurface = RenderMesh( + 4, gray.ToArray(), entries: Entries(4, decode: "[0 100 0 100 0 1]", colorSpace: "/DeviceGray")); + AssertNear(At(graySurface, 50, 36), 128, 128, 128, 4); + + var cmyk = new BitPacker(); + foreach (var (x, y) in new[] { (10.0, 10.0), (90.0, 10.0), (50.0, 90.0) }) + { + WriteVertex(cmyk, Std, 0, x, y, 1, 0, 0, 0); + } + + using var cmykSurface = RenderMesh( + 4, cmyk.ToArray(), entries: Entries(4, decode: "[0 100 0 100 0 1 0 1 0 1 0 1]", colorSpace: "/DeviceCMYK")); + AssertNear(At(cmykSurface, 50, 36), 0, 255, 255, 4); + } + + #endregion + + #region Type 5 + + /// Proves that a type 5 lattice (3 columns x 2 rows) renders its 4 triangles with interpolated colors. + [Fact] + public void PdfDocument_MeshShading_Type5_Lattice_RendersAllCells() + { + var data = Type5( + Std, + (10, 10, 1, 0, 0), (50, 10, 0, 1, 0), (90, 10, 0, 0, 1), + (10, 90, 1, 1, 0), (50, 90, 0, 1, 1), (90, 90, 1, 0, 1)); + using var surface = RenderMesh(5, data, entries: Entries(5, extra: "/VerticesPerRow 3")); + + Assert.True(At(surface, 11, 11).R > 200); + Assert.True(At(surface, 50, 11).G > 200); + Assert.True(At(surface, 89, 11).B > 200); + AssertNear(At(surface, 30, 50), 128, 255, 0, 40); + Assert.NotEqual(default, At(surface, 70, 70)); + Assert.Equal(default, At(surface, 5, 50)); + } + + /// Proves that type 5 pads every vertex to a byte boundary (spec-literal per-vertex alignment). + [Fact] + public void PdfDocument_MeshShading_Type5_NonByteAlignedVertices_AreByteAligned() + { + var f = new Fmt(12, 12, 8); + var data = Type5( + f, + (10, 10, 1, 0, 0), (90, 10, 1, 0, 0), (10, 90, 1, 0, 0), (90, 90, 1, 0, 0)); + var entries = Entries(5, "12", "12", extra: "/VerticesPerRow 2"); + using var surface = RenderMesh(5, data, entries: entries); + + AssertNear(At(surface, 50, 50), 255, 0, 0, 3); + } + + #endregion + + #region Types 6 and 7 + + /// Proves that a flat-edged Coons patch blends its 4 corner colors with bilinear weights. + [Fact] + public void PdfDocument_MeshShading_Type6_FlatPatch_BilinearCornerColors() + { + using var surface = RenderMesh(6, SinglePatch(6, (i, j) => Square(i, j))); + + Assert.True(At(surface, 11, 11).R > 200 && At(surface, 11, 11).G < 60); + Assert.True(At(surface, 11, 88).G > 200 && At(surface, 11, 88).R < 60); + Assert.True(At(surface, 88, 88).B > 200 && At(surface, 88, 88).R < 60); + AssertNear(At(surface, 88, 11), 255, 255, 255, 40); + AssertNear(At(surface, 50, 50), 128, 128, 128, 12); + Assert.Equal(default, At(surface, 5, 50)); + } + + /// Proves that Coons patch edges are Bezier curves, not the chord between the corners. + [Fact] + public void PdfDocument_MeshShading_Type6_CurvedEdge_CoversAreaBeyondChord() + { + (double X, double Y) Bulged(int i, int j) + { + var (x, y) = Square(i, j, 30, 30, 60); + return i == 0 && (j is 1 or 2) ? (x - 20, y) : (x, y); + } + + using var surface = RenderMesh(6, SinglePatch(6, Bulged)); + + Assert.NotEqual(default, At(surface, 20, 60)); + Assert.Equal(default, At(surface, 10, 60)); + } + + /// Proves that a type 7 patch whose interior points are the Coons-derived ones renders like the equivalent type 6 patch. + [Fact] + public void PdfDocument_MeshShading_Type7_CoonsInteriorPoints_EqualsType6() + { + (double X, double Y) Bulged(int i, int j) + { + var (x, y) = Square(i, j, 30, 30, 60); + return i == 0 && (j is 1 or 2) ? (x - 20, y + CurveOffset(j)) : (x, y); + } + + double[] CoonsAxis(Func p) + { + double P(int i, int j) => p(i, j); + return + [ + (-4 * P(0, 0) + 6 * (P(0, 1) + P(1, 0)) - 2 * (P(0, 3) + P(3, 0)) + 3 * (P(3, 1) + P(1, 3)) - P(3, 3)) / 9, + (-4 * P(0, 3) + 6 * (P(0, 2) + P(1, 3)) - 2 * (P(0, 0) + P(3, 3)) + 3 * (P(3, 2) + P(1, 0)) - P(3, 0)) / 9, + (-4 * P(3, 0) + 6 * (P(3, 1) + P(2, 0)) - 2 * (P(3, 3) + P(0, 0)) + 3 * (P(0, 1) + P(2, 3)) - P(0, 3)) / 9, + (-4 * P(3, 3) + 6 * (P(3, 2) + P(2, 3)) - 2 * (P(3, 0) + P(0, 3)) + 3 * (P(0, 2) + P(2, 0)) - P(0, 0)) / 9, + ]; + } + + var xs = CoonsAxis((i, j) => Bulged(i, j).X); + var ys = CoonsAxis((i, j) => Bulged(i, j).Y); + (double X, double Y) WithCoons(int i, int j) => (i, j) switch + { + (1, 1) => (xs[0], ys[0]), + (1, 2) => (xs[1], ys[1]), + (2, 1) => (xs[2], ys[2]), + (2, 2) => (xs[3], ys[3]), + _ => Bulged(i, j), + }; + + using var six = RenderMesh(6, SinglePatch(6, Bulged)); + using var seven = RenderMesh(7, SinglePatch(7, WithCoons)); + using var moved = RenderMesh(7, SinglePatch(7, (i, j) => (i, j) == (1, 1) ? (WithCoons(1, 1).X + 40, WithCoons(1, 1).Y) : WithCoons(i, j))); + + var maxDiff = 0; + var movedDiff = 0; + for (var y = 0; y < 100; y++) + { + for (var x = 0; x < 100; x++) + { + maxDiff = Math.Max(maxDiff, Math.Abs(six[x, y].R - seven[x, y].R) + Math.Abs(six[x, y].G - seven[x, y].G) + Math.Abs(six[x, y].B - seven[x, y].B)); + movedDiff = Math.Max(movedDiff, Math.Abs(six[x, y].R - moved[x, y].R) + Math.Abs(six[x, y].G - moved[x, y].G) + Math.Abs(six[x, y].B - moved[x, y].B)); + } + } + + Assert.True(maxDiff <= 12, $"type 6 and Coons-derived type 7 differ by {maxDiff}"); + Assert.True(movedDiff > 20, "moving a tensor interior point must change the rendering"); + } + + /// Proves that patch edge flags 1-3 inherit the shared edge's points and corner colors from the previous patch (type 6 and 7). + [Theory] + [InlineData(6, 1)] + [InlineData(6, 2)] + [InlineData(6, 3)] + [InlineData(7, 1)] + [InlineData(7, 2)] + [InlineData(7, 3)] + public void PdfDocument_MeshShading_PatchContinuation_InheritsEdgeAndColors(int type, int flag) + { + var f = Std; + Func first = (i, j) => Square(i, j, 30, 30, 30); + + // Previous patch's shared edge (new p00..p03) and the outward direction of the new patch. + (double X, double Y)[] edge; + (double X, double Y) d; + (double R, double G, double B)[] inherited; + switch (flag) + { + case 1: + edge = [first(0, 3), first(1, 3), first(2, 3), first(3, 3)]; + d = (0, 30); + inherited = [CornerColors[1], CornerColors[2]]; + break; + case 2: + edge = [first(3, 3), first(3, 2), first(3, 1), first(3, 0)]; + d = (30, 0); + inherited = [CornerColors[2], CornerColors[3]]; + break; + default: + edge = [first(3, 0), first(2, 0), first(1, 0), first(0, 0)]; + d = (0, -30); + inherited = [CornerColors[3], CornerColors[0]]; + break; + } + + (double X, double Y) Next(int i, int j) => (edge[j].X + (d.X * i / 3.0), edge[j].Y + (d.Y * i / 3.0)); + + var p = new BitPacker(); + WritePatch(p, f, type, 0, first, CornerColors); + WritePatch(p, f, type, flag, Next, [(0, 0, 0), (0, 0, 0)]); + using var surface = RenderMesh(type, p.ToArray(), entries: Entries(type)); + + // The new patch is painted at its center, and its two inherited corners carry the previous + // patch's colors (sampled 2px inward from the shared-edge corners). + var center = (X: edge[0].X + (edge[3].X - edge[0].X) / 2 + d.X / 2, Y: edge[0].Y + (edge[3].Y - edge[0].Y) / 2 + d.Y / 2); + Assert.NotEqual(default, At(surface, center.X, center.Y)); + for (var k = 0; k < 2; k++) + { + var corner = edge[k * 3]; + var sx = corner.X + (center.X > corner.X ? 2 : -2); + var sy = corner.Y + (center.Y > corner.Y ? 2 : -2); + AssertNear(At(surface, sx, sy), (int)(inherited[k].R * 255), (int)(inherited[k].G * 255), (int)(inherited[k].B * 255), 60); + } + } + + #endregion + + #region sh and pattern behaviors + + /// Proves that sh honors the shading's /BBox and the active clip for meshes. + [Fact] + public void PdfDocument_MeshShading_ShOperator_HonorsBBoxAndClip() + { + var data = SinglePatch(6, (i, j) => Square(i, j)); + using var boxed = RenderMesh(6, data, entries: Entries(6, extra: "/BBox [0 0 50 100]")); + Assert.NotEqual(default, At(boxed, 30, 50)); + Assert.Equal(default, At(boxed, 70, 50)); + + using var clipped = RenderMesh(6, data, content: "0 0 100 50 re W n /Sh1 sh"); + Assert.NotEqual(default, At(clipped, 50, 30)); + Assert.Equal(default, At(clipped, 50, 70)); + } + + /// Proves that /Background fills uncovered pattern area but is ignored by sh. + [Fact] + public void PdfDocument_MeshShading_Background_AppliesToPatternsOnly() + { + var data = SinglePatch(6, (i, j) => Square(i, j, 30, 30, 40)); + var entries = Entries(6, extra: "/Background [0 0 1]"); + using var pattern = RenderMesh(6, data, pattern: true, entries: entries); + AssertNear(At(pattern, 10, 10), 0, 0, 255, 0); + Assert.NotEqual(At(pattern, 10, 10), At(pattern, 50, 50)); + + using var sh = RenderMesh(6, data, entries: entries); + Assert.Equal(default, At(sh, 10, 10)); + } + + /// Proves that a mesh shading pattern's /Matrix positions the mesh. + [Fact] + public void PdfDocument_MeshShading_PatternMatrix_ShiftsMesh() + { + var data = RgbTriangleData(Std); + using var surface = RenderMesh(4, data, pattern: true, patternEntries: "/Matrix [1 0 0 1 30 0]"); + + Assert.NotEqual(default, At(surface, 80, 20)); + Assert.Equal(default, At(surface, 15, 12)); + } + + /// Proves that stroking with a mesh shading pattern paints the stroke through the mesh. + [Fact] + public void PdfDocument_MeshShading_PatternStroke_PaintsThroughMesh() + { + using var surface = RenderMesh( + 4, + RgbTriangleData(Std), + pattern: true, + content: "/Pattern CS /P1 SCN 6 w 0 30 m 100 30 l S"); + + Assert.NotEqual(default, At(surface, 50, 30)); + Assert.Equal(default, At(surface, 5, 30)); + Assert.Equal(default, At(surface, 50, 60)); + } + + #endregion + + #region Fail-closed + + private static double CurveOffset(int j) => j == 1 ? 6 : -6; + + private static byte[] ValidData(int type) => type switch + { + 4 => RgbTriangleData(Std), + 5 => Type5(Std, (10, 10, 1, 0, 0), (90, 10, 0, 1, 0), (10, 90, 0, 0, 1), (90, 90, 1, 1, 1)), + _ => SinglePatch(type, (i, j) => Square(i, j)), + }; + + private static string ValidEntries(int type) => type == 5 ? Entries(5, extra: "/VerticesPerRow 2") : Entries(type); + + private static string MissingEntryEntries(int type, string missing) + { + if (type == 5 && missing == "VerticesPerRow") + { + return Entries(5); + } + + return Entries( + type, + coordBits: Omit(missing, "BitsPerCoordinate", "16"), + compBits: Omit(missing, "BitsPerComponent", "8"), + flagBits: Omit(missing, "BitsPerFlag", "8"), + decode: Omit(missing, "Decode", DefaultDecode), + extra: type == 5 ? "/VerticesPerRow 2" : string.Empty); + } + + private static string? Omit(string missing, string key, string value) => missing == key ? null : value; + + /// Proves that a missing required mesh entry throws . + [Theory] + [InlineData(4, "BitsPerCoordinate")] + [InlineData(4, "BitsPerComponent")] + [InlineData(4, "BitsPerFlag")] + [InlineData(4, "Decode")] + [InlineData(5, "BitsPerCoordinate")] + [InlineData(5, "BitsPerComponent")] + [InlineData(5, "Decode")] + [InlineData(5, "VerticesPerRow")] + [InlineData(6, "BitsPerFlag")] + [InlineData(6, "Decode")] + [InlineData(7, "BitsPerFlag")] + [InlineData(7, "BitsPerCoordinate")] + public void PdfDocument_MeshShading_MissingEntry_ThrowsInvalidData(int type, string missing) + { + var entries = MissingEntryEntries(type, missing); + Assert.Throws(() => RenderMesh(type, ValidData(type), entries: entries)); + Assert.Throws(() => RenderMesh(type, ValidData(type), pattern: true, entries: entries)); + } + + /// Proves that spec-illegal bit sizes and a wrong-length /Decode throw . + [Theory] + [InlineData("3", "8", "8", DefaultDecode)] + [InlineData("33", "8", "8", DefaultDecode)] + [InlineData("16", "7", "8", DefaultDecode)] + [InlineData("16", "20", "8", DefaultDecode)] + [InlineData("16", "8", "3", DefaultDecode)] + [InlineData("16", "8", "16", DefaultDecode)] + [InlineData("16", "8", "8", "[0 100 0 100 0 1 0 1]")] + [InlineData("16", "8", "8", "[0 100 0 100 0 1 0 1 0 1 0 1]")] + public void PdfDocument_MeshShading_IllegalBitSizeOrDecode_ThrowsInvalidData( + string coordBits, string compBits, string flagBits, string decode) + { + var entries = Entries(4, coordBits, compBits, flagBits, decode); + + Assert.Throws(() => RenderMesh(4, RgbTriangleData(Std), entries: entries)); + } + + /// Proves that truncated or empty mesh data throws for every type. + [Theory] + [InlineData(4, "partial")] + [InlineData(4, "empty")] + [InlineData(4, "flag0-short")] + [InlineData(5, "partial")] + [InlineData(5, "empty")] + [InlineData(6, "partial")] + [InlineData(6, "empty")] + [InlineData(7, "partial")] + [InlineData(7, "empty")] + public void PdfDocument_MeshShading_TruncatedData_ThrowsInvalidData(int type, string kind) + { + var data = ValidData(type); + data = kind switch + { + "empty" => [], + "partial" => [.. data, 0], + _ => data[..(data.Length / 3 * 2)], + }; + + Assert.Throws(() => RenderMesh(type, data, entries: ValidEntries(type))); + } + + /// Proves that patch data cut inside a patch throws . + [Theory] + [InlineData(6)] + [InlineData(7)] + public void PdfDocument_MeshShading_PatchCutShort_ThrowsInvalidData(int type) + { + var data = ValidData(type); + + Assert.Throws(() => RenderMesh(type, data[..(data.Length - 3)])); + } + + /// Proves that illegal or first-record edge flags throw . + [Theory] + [InlineData(4, 3, false)] + [InlineData(4, 1, true)] + [InlineData(6, 4, false)] + [InlineData(6, 1, true)] + [InlineData(7, 4, false)] + [InlineData(7, 2, true)] + public void PdfDocument_MeshShading_BadFlags_ThrowInvalidData(int type, int flag, bool first) + { + byte[] data; + if (type == 4) + { + data = first + ? Type4(Std, (flag, 10, 10, 1, 0, 0), (0, 90, 10, 0, 1, 0), (0, 50, 90, 0, 0, 1)) + : Type4(Std, (0, 10, 10, 1, 0, 0), (0, 90, 10, 0, 1, 0), (0, 50, 90, 0, 0, 1), (flag, 50, 50, 1, 1, 1)); + } + else + { + var p = new BitPacker(); + if (!first) + { + WritePatch(p, Std, type, 0, (i, j) => Square(i, j), CornerColors); + } + + WritePatch(p, Std, type, flag, (i, j) => Square(i, j), flag == 0 ? CornerColors : [(0, 0, 0), (0, 0, 0)]); + data = p.ToArray(); + } + + Assert.Throws(() => RenderMesh(type, data)); + } + + /// Proves that a reserved edge flag on the 2nd or 3rd vertex of a flag-0 type 4 record throws . + [Theory] + [InlineData(1)] + [InlineData(2)] + public void PdfDocument_MeshShading_Type4_ReservedFlagInInitialTriangleVertices_ThrowsInvalidData(int position) + { + var second = position == 1 ? 3 : 0; + var third = position == 2 ? 3 : 0; + var data = Type4(Std, (0, 10, 10, 1, 0, 0), (second, 90, 10, 0, 1, 0), (third, 50, 90, 0, 0, 1)); + + Assert.Throws(() => RenderMesh(4, data)); + } + + /// Proves that invalid type 5 lattice shapes throw . + [Theory] + [InlineData(1, 4)] + [InlineData(0, 4)] + [InlineData(3, 4)] + [InlineData(4, 4)] + public void PdfDocument_MeshShading_Type5_BadLattice_ThrowsInvalidData(int verticesPerRow, int vertexCount) + { + var vertices = Enumerable.Range(0, vertexCount).Select(i => (10.0 + i, 10.0, 1.0, 0.0, 0.0)).ToArray(); + var data = Type5(Std, vertices); + + Assert.Throws(() => RenderMesh( + 5, data, entries: Entries(5, extra: $"/VerticesPerRow {verticesPerRow}"))); + } + + /// Proves that a mesh shading given as a plain dictionary (no stream data) throws . + [Fact] + public void PdfDocument_MeshShading_NotAStream_ThrowsInvalidData() + { + var body = "<< /ShadingType 4 /ColorSpace /DeviceRGB /BitsPerCoordinate 16 /BitsPerComponent 8 /BitsPerFlag 8 /Decode [0 100 0 100 0 1 0 1 0 1] >>"; + + Assert.Throws(() => Render(BuildPdf(string.Empty, [], false, shadingBody: body))); + } + + /// Proves that a non-Device mesh /ColorSpace throws . + [Fact] + public void PdfDocument_MeshShading_IndexedColorSpace_ThrowsUnsupported() + { + var entries = Entries(4, colorSpace: "[/Indexed /DeviceRGB 1 <000000FFFFFF>]", decode: "[0 100 0 100 0 1]"); + + var ex = Assert.Throws(() => RenderMesh(4, RgbTriangleData(Std), entries: entries)); + Assert.Equal("pdf-shading-colorspace-Indexed", ex.Feature); + } + + /// Proves that a /Function with the wrong output arity throws and a Type 4 function stays unsupported. + [Fact] + public void PdfDocument_MeshShading_FunctionProblems_FailClosed() + { + var arity = Entries(4, decode: "[0 100 0 100 0 1]", extra: "/Function {FN} 0 R"); + var p = new BitPacker(); + WriteVertex(p, Std, 0, 10, 10, 0); + WriteVertex(p, Std, 0, 90, 10, 0.5); + WriteVertex(p, Std, 0, 50, 90, 1); + var data = p.ToArray(); + var bytes = BuildSinglePage( + "/Sh1 sh", + "/Shading << /Sh1 5 0 R >>", + [ + BuildStream(arity.Replace("{FN}", "6", StringComparison.Ordinal), data), + BuildStream("/FunctionType 2 /Domain [0 1] /C0 [0] /C1 [1] /N 1", []), + ]); + Assert.Throws(() => Render(bytes)); + + var type4 = BuildSinglePage( + "/Sh1 sh", + "/Shading << /Sh1 5 0 R >>", + [ + BuildStream(arity.Replace("{FN}", "6", StringComparison.Ordinal), data), + BuildStream("/FunctionType 4", "{ }"u8.ToArray()), + ]); + var ex = Assert.Throws(() => Render(type4)); + Assert.Equal("pdf-functiontype-4", ex.Feature); + } + + /// Proves that exceeding the vertex limit throws (pdf-shading-mesh-too-many-vertices). + [Fact] + public void PdfDocument_MeshShading_TooManyVertices_ThrowsUnsupported() + { + // 1-bit coordinates/components and 2-bit flags: 7 bits per vertex, padded to 1 byte. + var data = new byte[PdfDocument.MaxMeshVertices + 1]; + var entries = Entries(4, "1", "1", "2"); + + var ex = Assert.Throws(() => RenderMesh(4, data, entries: entries)); + Assert.Equal("pdf-shading-mesh-too-many-vertices", ex.Feature); + } + + /// Proves that exceeding the patch limit throws (pdf-shading-mesh-too-many-patches). + [Fact] + public void PdfDocument_MeshShading_TooManyPatches_ThrowsUnsupported() + { + // 2-bit flag + 12 points x 2 bits + 4 colors x 3 bits = 38 bits = 5 bytes per patch. + var data = new byte[5 * (PdfDocument.MaxMeshPatches + 1)]; + var entries = Entries(6, "1", "1", "2"); + + var ex = Assert.Throws(() => RenderMesh(6, data, entries: entries)); + Assert.Equal("pdf-shading-mesh-too-many-patches", ex.Feature); + } + + /// Proves that a mesh exceeding the rasterization work budget throws (pdf-shading-mesh-too-complex). + [Fact] + public void PdfDocument_MeshShading_TooMuchRasterWork_ThrowsUnsupported() + { + // Each page-covering triangle costs ~10,000 pixels of work on the 100x100 surface. + var triangles = (int)(PdfDocument.MaxMeshRasterPixels / 10_000) + 2_000; + var p = new BitPacker(); + var f = new Fmt(8, 1, 2); + for (var t = 0; t < triangles; t++) + { + WriteVertex(p, f, 0, 0, 0, 1, 1, 1); + WriteVertex(p, f, 0, 100, 0, 1, 1, 1); + WriteVertex(p, f, 0, 0, 100, 1, 1, 1); + } + + var entries = Entries(4, "8", "1", "2"); + + var ex = Assert.Throws(() => RenderMesh(4, p.ToArray(), entries: entries)); + Assert.Equal("pdf-shading-mesh-too-complex", ex.Feature); + } + + #endregion +} diff --git a/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfDocumentTests.cs b/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfDocumentTests.cs index c72e08a2..1a3b10c7 100644 --- a/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfDocumentTests.cs +++ b/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfDocumentTests.cs @@ -10397,15 +10397,221 @@ public void PdfDocument_Text_RenderMode1_StrokePattern_PaintsPatternStroke() Assert.Equal(new Canvas.Rgba32(0, 0, 255, 255), surface[7, 44]); } - /// Proves that a defined but unsupported text-rendering mode throws . + /// Proves that the clipping text-rendering modes 4-7 are accepted by Tr on their own. [Theory] [InlineData(4)] [InlineData(5)] [InlineData(6)] [InlineData(7)] - public void PdfDocument_Text_RenderMode_UnsupportedDefinedMode_ThrowsUnsupportedImageFeatureException(int mode) + public void PdfDocument_Text_RenderMode_ClipModes_AcceptedByTr(int mode) { - Assert.Throws(() => RenderContent($"BT {mode} Tr ET")); + var exception = Record.Exception(() => RenderContent($"BT {mode} Tr ET")); + Assert.Null(exception); + } + + /// Renders one 'A' (and optionally 'B') glyph page with the given content and returns the surface. + private static Canvas.Surface RenderTextClipContent(string content) + { + var fontBytes = BuildEmbeddedFontBytes([(65, 1), (66, 2)]); + var (resourcesBody, extraObjects) = BuildSimpleTrueTypeFontResources(fontBytes); + return RenderPdfBytes(BuildSinglePagePdfWithResources(100, 100, content, resourcesBody, extraObjects)); + } + + /// Proves that mode 7 paints no glyph ink but clips a later fill to the glyph outline. + [Fact] + public void PdfDocument_Text_RenderMode7_ClipOnly_ClipsSubsequentFillToGlyph() + { + using var surface = RenderTextClipContent( + "BT /F1 20 Tf 7 Tr 5 50 Td (A) Tj ET 1 0 0 rg 0 0 100 100 re f"); + + Assert.Equal(new Canvas.Rgba32(255, 0, 0, 255), surface[11, 44]); + Assert.Equal(default, surface[50, 50]); + Assert.Equal(default, surface[11, 60]); + } + + /// Proves that mode 7 itself paints nothing when no later painting occurs. + [Fact] + public void PdfDocument_Text_RenderMode7_ClipOnly_PaintsNoGlyphInk() + { + using var surface = RenderTextClipContent("BT /F1 20 Tf 7 Tr 5 50 Td (A) Tj ET"); + + Assert.Equal(default, surface[11, 44]); + } + + /// Proves that mode 4 fills the glyph and then clips a later fill to it. + [Fact] + public void PdfDocument_Text_RenderMode4_FillAndClip_FillsThenClips() + { + using var surface = RenderTextClipContent( + "0 1 0 rg BT /F1 20 Tf 4 Tr 5 50 Td (A) Tj ET 1 0 0 rg 0 0 100 100 re f"); + + // The later red fill covers the glyph; outside the glyph nothing is painted. + Assert.Equal(new Canvas.Rgba32(255, 0, 0, 255), surface[11, 44]); + Assert.Equal(default, surface[50, 50]); + } + + /// Proves that mode 4 paints the glyph fill itself. + [Fact] + public void PdfDocument_Text_RenderMode4_FillAndClip_PaintsGlyphFill() + { + using var surface = RenderTextClipContent("0 1 0 rg BT /F1 20 Tf 4 Tr 5 50 Td (A) Tj ET"); + + Assert.Equal(new Canvas.Rgba32(0, 255, 0, 255), surface[11, 44]); + } + + /// Proves that mode 5 strokes the outline without filling, then clips. + [Fact] + public void PdfDocument_Text_RenderMode5_StrokeAndClip_StrokesThenClips() + { + using var surface = RenderTextClipContent( + "BT /F1 20 Tf 2 w 5 Tr 1 0 0 RG 5 50 Td (A) Tj ET"); + + Assert.Equal(new Canvas.Rgba32(255, 0, 0, 255), surface[7, 44]); + Assert.Equal(default, surface[11, 44]); + + using var clipped = RenderTextClipContent( + "BT /F1 20 Tf 2 w 5 Tr 1 0 0 RG 5 50 Td (A) Tj ET 0 0 1 rg 0 0 100 100 re f"); + Assert.Equal(new Canvas.Rgba32(0, 0, 255, 255), clipped[11, 44]); + Assert.Equal(default, clipped[50, 50]); + } + + /// Proves that mode 6 fills and strokes the glyph, then clips. + [Fact] + public void PdfDocument_Text_RenderMode6_FillStrokeClip_PaintsBothThenClips() + { + using var surface = RenderTextClipContent( + "BT /F1 20 Tf 2 w 6 Tr 0 1 0 rg 1 0 0 RG 5 50 Td (A) Tj ET 0 0 1 rg 30 0 70 100 re f"); + + Assert.Equal(new Canvas.Rgba32(0, 255, 0, 255), surface[11, 44]); + Assert.Equal(new Canvas.Rgba32(255, 0, 0, 255), surface[7, 44]); + + // The later blue fill lies outside the glyph, so the clip removes it entirely. + Assert.Equal(default, surface[50, 50]); + } + + /// Proves that the clip is the union of every glyph shown in one BT/ET. + [Fact] + public void PdfDocument_Text_RenderMode7_ClipAccumulatesAcrossMultipleGlyphs() + { + using var surface = RenderTextClipContent( + "BT /F1 20 Tf 7 Tr 5 50 Td (AB) Tj ET 1 0 0 rg 0 0 100 100 re f"); + + // 'A' occupies x [7,15), 'B' x [19,27); the gap at x = 17 stays unpainted. + Assert.Equal(new Canvas.Rgba32(255, 0, 0, 255), surface[11, 44]); + Assert.Equal(new Canvas.Rgba32(255, 0, 0, 255), surface[23, 44]); + Assert.Equal(default, surface[17, 44]); + } + + /// Proves that the text clip intersects with an existing clip. + [Fact] + public void PdfDocument_Text_RenderMode7_ClipIntersectsWithExistingClip() + { + // Existing clip covers x [0,12) only, so only the left part of 'A' (x [7,15)) survives. + using var surface = RenderTextClipContent( + "0 0 12 100 re W n BT /F1 20 Tf 7 Tr 5 50 Td (A) Tj ET 1 0 0 rg 0 0 100 100 re f"); + + Assert.Equal(new Canvas.Rgba32(255, 0, 0, 255), surface[9, 44]); + Assert.Equal(default, surface[13, 44]); + } + + /// Proves that the clip is not applied until ET. + [Fact] + public void PdfDocument_Text_RenderMode7_ClipNotAppliedUntilEndText() + { + using var surface = RenderTextClipContent( + "BT /F1 20 Tf 7 Tr 5 50 Td (A) Tj 1 0 0 rg 0 0 100 100 re f ET"); + + Assert.Equal(new Canvas.Rgba32(255, 0, 0, 255), surface[50, 50]); + } + + /// Proves that Q restores the pre-text clip. + [Fact] + public void PdfDocument_Text_RenderMode7_ClipRestoredByRestoreState() + { + using var surface = RenderTextClipContent( + "q BT /F1 20 Tf 7 Tr 5 50 Td (A) Tj ET Q 1 0 0 rg 0 0 100 100 re f"); + + Assert.Equal(new Canvas.Rgba32(255, 0, 0, 255), surface[50, 50]); + Assert.Equal(new Canvas.Rgba32(255, 0, 0, 255), surface[11, 44]); + } + + /// Proves that clip modes work with a composite (Type 0, Identity-H) font. + [Fact] + public void PdfDocument_Text_RenderMode7_ClipWithCompositeFont() + { + var fontBytes = BuildEmbeddedFontBytes([(65, 1)]); + var (resourcesBody, extraObjects) = BuildCompositeFontResources(fontBytes); + var bytes = BuildSinglePagePdfWithResources( + 100, + 100, + "BT /F1 20 Tf 7 Tr 5 50 Td <0001> Tj ET 1 0 0 rg 0 0 100 100 re f", + resourcesBody, + extraObjects); + + using var surface = RenderPdfBytes(bytes); + + Assert.Equal(new Canvas.Rgba32(255, 0, 0, 255), surface[11, 44]); + Assert.Equal(default, surface[50, 50]); + } + + /// Proves that a Type 3 glyph shown under a clipping mode fails closed. + [Theory] + [InlineData(4)] + [InlineData(5)] + [InlineData(6)] + [InlineData(7)] + public void PdfDocument_Text_RenderMode_ClipModes_Type3Font_ThrowsUnsupportedImageFeatureException(int mode) + { + 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); + + Assert.Throws(() => RenderPdfBytes(bytes)); + } + + /// Proves that a BT...ET inside a Form XObject invoked mid-BT of an outer clip-mode text block does not clobber the outer text-clip accumulation. + [Fact] + public void PdfDocument_Text_RenderMode7_NestedFormXObjectText_DoesNotClobberOuterClip() + { + // Arrange: the outer block accumulates 'A' under mode 7, then invokes a Form (mid-BT) + // whose own BT...ET clips to 'B' further right; the outer ET must still apply 'A' only. + var fontBytes = BuildEmbeddedFontBytes([(65, 1), (66, 2)]); + var (fontResourcesBody, fontExtraObjects) = BuildSimpleTrueTypeFontResources(fontBytes); + var formObjectNumber = 5 + fontExtraObjects.Count; + var resourcesBody = fontResourcesBody + $" /XObject << /Fm0 {formObjectNumber} 0 R >>"; + var extraObjects = new List(fontExtraObjects) + { + BuildStreamObjectBody( + "/Type /XObject /Subtype /Form /BBox [0 0 100 100] /Resources << " + + fontResourcesBody + " >>", + "BT /F1 20 Tf 7 Tr 60 50 Td (B) Tj ET"u8.ToArray()), + }; + + var bytes = BuildSinglePagePdfWithResources( + 100, + 100, + "BT /F1 20 Tf 7 Tr 5 50 Td (A) Tj /Fm0 Do ET 1 0 0 rg 0 0 100 100 re f", + resourcesBody, + extraObjects); + + // Act + using var surface = RenderPdfBytes(bytes); + + // Assert: only the outer 'A' glyph survives as the clip. + Assert.Equal(new Canvas.Rgba32(255, 0, 0, 255), surface[11, 44]); + Assert.Equal(default, surface[50, 50]); + } + + /// Proves that a clip mode with no glyphs shown leaves the clip unchanged. + + [Fact] + public void PdfDocument_Text_RenderMode7_NoGlyphsShown_DoesNotChangeClip() + { + using var surface = RenderTextClipContent("BT 7 Tr ET 1 0 0 rg 0 0 100 100 re f"); + + Assert.Equal(new Canvas.Rgba32(255, 0, 0, 255), surface[50, 50]); } /// Proves that a text-rendering mode outside the PDF specification's defined [0, 7] range throws . @@ -10855,10 +11061,11 @@ public void PdfDocument_Patterns_ShadingPattern_NonDefaultDomain_RespectsDomainB Assert.InRange(surface[97, 50].R, 185, 215); } - /// Proves that an unsupported /ShadingType (1 or 4) throws with feature pdf-shading-type-{n}. + /// Proves that an unsupported /ShadingType (0, 1 or 8) throws with feature pdf-shading-type-{n}. [Theory] + [InlineData(0)] [InlineData(1)] - [InlineData(4)] + [InlineData(8)] public void PdfDocument_Patterns_ShadingPattern_UnsupportedShadingType_ThrowsUnsupportedImageFeatureException(int shadingType) { // Arrange: 5 = patternDict, 6 = shadingDict. @@ -11222,8 +11429,9 @@ public void PdfDocument_ShadingOperator_BBoxNarrowerThanPage_RestrictsGradientTo /// category. /// [Theory] + [InlineData(0)] [InlineData(1)] - [InlineData(4)] + [InlineData(8)] public void PdfDocument_ShadingOperator_UnsupportedShadingType_ThrowsUnsupportedImageFeatureException(int shadingType) { // Arrange: 5 = shadingDict. diff --git a/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfFixtures/README.md b/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfFixtures/README.md index 75c2eadc..14d24024 100644 --- a/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfFixtures/README.md +++ b/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfFixtures/README.md @@ -50,6 +50,9 @@ own entry below for details). | `linear-scan-stream-noise.pdf` | Stray ` tests, which pixel-assert the specific glyph position against a synthetic embedded font built > for exactly that purpose; this fixture merely shows the real-world name occurs and the document > still opens and renders without error. + +## AES-256 `/R 6` encrypted fixtures + +The three `encrypted-aes256-r6-*.pdf` files were generated independently of CanvasNet's own +implementation with Python `pypdf` 6.10.2 (`PdfWriter.encrypt(user, owner, algorithm="AES-256")`, +which emits `/V 5`/`/R 6`; confirmed by inspecting each file's `/Encrypt` dictionary). Each is a +single 100x100 page whose content stream is `0 0 1 rg 20 20 60 60 re f` (a blue filled square +at (20,20)-(80,80)); tests expect the pixel at (50, 50) to be opaque blue and (5, 5) to be +unpainted. Passwords: user `""` / owner `owner-secret`; user `user-secret` / owner `owner-secret`; +a non-ASCII user password (contains U+00E4, U+00F6 and U+20AC) / owner `owner-secret`. They are small, +created for this repository, and +licensed under the same MIT license as the rest of it. diff --git a/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfFixtures/encrypted-aes256-r6-empty-user-password.pdf b/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfFixtures/encrypted-aes256-r6-empty-user-password.pdf new file mode 100644 index 00000000..e04f2c14 Binary files /dev/null and b/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfFixtures/encrypted-aes256-r6-empty-user-password.pdf differ diff --git a/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfFixtures/encrypted-aes256-r6-user-password.pdf b/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfFixtures/encrypted-aes256-r6-user-password.pdf new file mode 100644 index 00000000..86137eaa Binary files /dev/null and b/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfFixtures/encrypted-aes256-r6-user-password.pdf differ diff --git a/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfFixtures/encrypted-aes256-r6-utf8-password.pdf b/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfFixtures/encrypted-aes256-r6-utf8-password.pdf new file mode 100644 index 00000000..36308fd9 Binary files /dev/null and b/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfFixtures/encrypted-aes256-r6-utf8-password.pdf differ diff --git a/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfFixtures/shading-mesh-types-4-5-6-7.pdf b/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfFixtures/shading-mesh-types-4-5-6-7.pdf new file mode 100644 index 00000000..68a22b1d Binary files /dev/null and b/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfFixtures/shading-mesh-types-4-5-6-7.pdf differ diff --git a/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfSystemIntegrationTests.cs b/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfSystemIntegrationTests.cs index 389efd84..0ed9271a 100644 --- a/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfSystemIntegrationTests.cs +++ b/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfSystemIntegrationTests.cs @@ -974,6 +974,68 @@ public void CanvasNetPdf_SystemIntegration_PdfRender_EvenOddFill_MultipleBoundar Assert.True(alpha is > 0 and < 255); } + /// + /// Proves sh paints all four mesh shading types (4 free-form triangles, 5 lattice, + /// 6 Coons patch, 7 tensor-product patch) end-to-end from a real fixture, one 200x200 + /// quadrant each on a 400x400 page rendered 1:1, and that type 7's interior control points + /// change the interior color relative to the type 6 patch with the same boundary. + /// + [Fact] + public void CanvasNetPdf_SystemIntegration_ShOperatorMeshShadingTypes4To7_PaintsExpectedCornerColors() + { + // Arrange: see PdfFixtures/README.md. Device y = 400 - user y; quadrant origins are + // top-left (0,200) type 4, top-right (200,200) type 5, bottom-left (0,0) type 6, + // bottom-right (200,0) type 7. + using var document = PdfDocument.Open(Fixture("shading-mesh-types-4-5-6-7.pdf")); + + // Act + using var surface = document.Render(0, 400, 400, Transparent); + + // Assert: type 4 - red/green near the lower corners, yellow near the apex, and the + // upper-left half of the quadrant (outside both triangles) stays unpainted. + AssertRed(surface[12, 188]); + AssertGreen(surface[185, 188]); + AssertYellow(surface[188, 12]); + Assert.Equal(0, surface[20, 20].A); + + // Assert: type 5 - lattice vertex colors and the white center vertex. + AssertRed(surface[212, 188]); + AssertBlue(surface[388, 188]); + AssertGreen(surface[212, 12]); + AssertYellow(surface[388, 12]); + var center = surface[300, 90]; + Assert.True(center.R > 240 && center.G > 240 && center.B > 240); + + // Assert: type 6 and 7 share corner colors (red, green, blue, yellow). + foreach (var offsetX in new[] { 0, 200 }) + { + AssertRed(surface[offsetX + 12, 388]); + AssertGreen(surface[offsetX + 12, 215]); + AssertBlue(surface[offsetX + 188, 215]); + AssertYellow(surface[offsetX + 188, 385]); + } + + // Assert: type 7's interior control points change the interior color, and the gap + // between quadrants is unpainted. + var coons = surface[131, 261]; + var tensor = surface[331, 261]; + Assert.True( + Math.Abs(coons.R - tensor.R) + Math.Abs(coons.G - tensor.G) + Math.Abs(coons.B - tensor.B) > 50); + Assert.Equal(0, surface[200, 200].A); + } + + private static void AssertRed(Canvas.Rgba32 pixel) => + Assert.True(pixel.A == 255 && pixel.R > 200 && pixel.G < 60 && pixel.B < 60, $"Expected red but was {pixel}"); + + private static void AssertGreen(Canvas.Rgba32 pixel) => + Assert.True(pixel.A == 255 && pixel.G > 200 && pixel.R < 60 && pixel.B < 60, $"Expected green but was {pixel}"); + + private static void AssertBlue(Canvas.Rgba32 pixel) => + Assert.True(pixel.A == 255 && pixel.B > 200 && pixel.R < 60 && pixel.G < 60, $"Expected blue but was {pixel}"); + + private static void AssertYellow(Canvas.Rgba32 pixel) => + Assert.True(pixel.A == 255 && pixel.R > 200 && pixel.G > 200 && pixel.B < 60, $"Expected yellow but was {pixel}"); + /// 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"); diff --git a/test/DemaConsulting.CanvasNet.Tests/Drawing/ClipMaskTests.cs b/test/DemaConsulting.CanvasNet.Tests/Drawing/ClipMaskTests.cs index b4c0d59a..6d17bb84 100644 --- a/test/DemaConsulting.CanvasNet.Tests/Drawing/ClipMaskTests.cs +++ b/test/DemaConsulting.CanvasNet.Tests/Drawing/ClipMaskTests.cs @@ -10,6 +10,32 @@ namespace DemaConsulting.CanvasNet.Tests.Drawing; /// public class ClipMaskTests { + /// + /// Proves reports the clip path's own whole-pixel bounding + /// box, not the full device extent. + /// + [Fact] + public void ClipMask_Bounds_SmallRectangle_ReportsPathBoundingBox() + { + // Arrange: a rectangle from (2.5, 3) to (6, 7) on a 100x100 extent + var path = new PathBuilder() + .MoveTo(new Vector2(2.5f, 3)) + .LineTo(new Vector2(6, 3)) + .LineTo(new Vector2(6, 7)) + .LineTo(new Vector2(2.5f, 7)) + .Close() + .Build(); + + // Act + var bounds = ClipMask.FromPath(path, FillRule.NonZero, 100, 100).Bounds; + + // Assert: outward-rounded to whole pixels + Assert.Equal(2f, bounds.Left); + Assert.Equal(3f, bounds.Top); + Assert.Equal(6f, bounds.Right); + Assert.Equal(7f, bounds.Bottom); + } + /// /// Proves that rasterizes an axis-aligned rectangle's /// coverage identically to the way itself would fill it: fully