Skip to content

Add PDF document rendering support (DemaConsulting.CanvasNet.Pdf) - #31

Merged
Malcolmnixon merged 70 commits into
mainfrom
feature/pdf-document-support
Oct 2, 2026
Merged

Malcolmnixon merged 70 commits into
mainfrom
feature/pdf-document-support

Conversation

@Malcolmnixon

Copy link
Copy Markdown
Member

Pull Request

Description

Adds PDF document rendering support to CanvasNet via a new DemaConsulting.CanvasNet.Pdf
assembly (PdfDocument). This covers PDF parsing (xref tables and cross-reference streams,
page tree, content streams), rendering of vector paths, text (TrueType, Type 1, Type 1C, Type 3,
Type 0/CID-keyed fonts, with OS/bundled-font fallback including Symbol/ZapfDingbats), raster
images (JPEG/DCTDecode, CCITT G4 fax, PNG-predictor-filtered raw samples), device/CalRGB/ICC/
Indexed/Pattern color spaces, axial/radial shading and tiling pattern fills, Form XObjects, and
optional /Standard security-handler decryption (RC4, AES-128, AES-256). It also adds a
TilePaint paint type and a PdfDocument.Render(int, float dpi) convenience overload to the core
DemaConsulting.CanvasNet library's Drawing subsystem.

This branch also incorporates a full pre-merge quality pass: 16 formal reviews were run against
requirements/design/verification documentation and the CanvasNetPdf-PdfDocument implementation;
all findings from that pass have been remediated, including:

  • 2 Critical + 1 High security hardening fixes (decompression-bomb caps, allocate-before-bounds-check
    ordering, an object-stream reference-cycle guard) plus 4 Medium-severity robustness fixes, each with
    a regression test
  • Resynced docs/design/introduction.md (folder-layout trees, prose, Xref/FontFallback claims) and
    several unit-level design docs that had self-contradictory "sole software unit" claims
    after document splits
  • Closed verification/traceability gaps: a genuine TrueType Collection (TTC) face-0 test, a
    SystemFontCatalog stale bundled-font count, a new requirement + verification section for the
    Render(int, float dpi) overload, and two new genuine system/integration tests (a real Form
    XObject render and a real RC4-encrypted-PDF decrypt+render) replacing two previously mis-cited
    test links

Type of Change

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to not work as expected)
  • Documentation update
  • Code quality improvement

Related Issues

Closes #

Pre-Submission Checklist

Build and Test

  • Code builds successfully and all tests pass: pwsh ./build.ps1 — Build succeeded, 0 Warning(s), 0 Error(s); 7218/7218 tests passed
  • Code produces zero warnings

Code Quality

  • New code has appropriate XML documentation comments
  • Static analyzer warnings have been addressed

Quality Checks

  • All linters pass: pwsh ./lint.ps1 — passed (YAML, cspell, markdownlint, ReqStream, VersionMark, ReviewMark, SysML2 Tools, dotnet format --verify-no-changes)

Testing

  • Added unit tests for new functionality
  • Updated existing tests if behavior changed
  • All tests follow the AAA (Arrange, Act, Assert) pattern
  • Test coverage is maintained or improved

Documentation

  • Updated README.md (if applicable)
  • Updated docs/ documentation (if applicable)
  • Added code examples for new features (if applicable)
  • Updated requirements.yaml (if applicable)

Additional Notes

dotnet reqstream --requirements requirements.yaml --tests "artifacts/**/*.trx" --report ... --matrix ...
runs clean with no unsatisfied/orphaned-requirement errors against this branch's HEAD.

16 formal reviews were re-run against this branch after the doc-sync/hardening pass described above;
a further round of re-review is planned immediately after this PR is opened to confirm all review-sets
now PASS before requesting merge.

Malcolm Nixon and others added 30 commits September 29, 2026 00:55
Introduces the DemaConsulting.CanvasNet.Pdf package with a stateful,
IDisposable PdfDocument API (Open/PageCount/GetPageInfo/Render/Dispose)
distinct from the existing stateless codec pattern, per the document
vs. image architecture decision.

Phase 1 scope:
- Tokenizer and internal PDF object model
- Classic, cross-reference stream, and object-stream xref parsing
  with hybrid /XRefStm merging and /Prev chain support
- Linear-scan xref recovery fallback for damaged files
- /Encrypt detection (fail-closed via UnsupportedImageFeatureException)
- Page-tree traversal with /MediaBox and /Rotate inheritance
- PdfPageInfo reporting rotation-adjusted dimensions
- Render currently returns a blank Surface (rendering lands in later phases)

Includes full companion artifacts: design, reqstream, verification, and
sysml2 model/views documents, plus README/user-guide updates.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Render(pageIndex, width, height) now tokenizes and executes the page's
/Contents content stream instead of returning a blank Surface.

- PdfDocument.ContentStream.cs: content-stream tokenizer/dispatch loop,
  /Contents single-stream and array-of-streams resolution
- PdfDocument.GraphicsState.cs: q/Q graphics-state stack, cm matrix
  composition, w/J/j/M/d line-style state
- PdfDocument.PathOps.cs: path-construction operators (m/l/c/v/y/h/re)
  baking the CTM into emitted points, and path-painting operators
  (f/F/f*/S/s/B/B*/b/b*/n) invoking Geometry.PathBuilder and
  Drawing.PathFiller/PathStroker
- Base CTM derivation incorporates MediaBox origin, effective /Rotate,
  and requested render-size scaling (PdfDocument.PageTree.cs)

Phase 2 limitation (documented): all fill/stroke color is fixed to
opaque black; no color space, text, image, or clipping operators are
implemented yet (silently skipped, not an error).

Extends companion artifacts (design/reqstream/verification/sysml2) and
adds unit + system-integration tests with concrete pixel assertions,
including a rotated-page geometry-placement regression test.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Render now paints real device color (not just fixed black) and
composites decoded image XObjects.

- PdfDocument.Color.cs: g/G, rg/RG, k/K, cs/CS, sc/SC/scn/SCN device
  color operators (DeviceGray/DeviceRGB/DeviceCMYK only); operand
  values are clamped to [0,1] rather than rejected; any other color
  space (Indexed/Separation/DeviceN/ICCBased/Cal*/Lab) or a /Pattern
  name fails closed with UnsupportedImageFeatureException
- PdfDocument.Filters.cs: generalizes stream decoding into an ordered
  /Filter + /DecodeParms pipeline; adds PNG (predictors 10-15) and
  TIFF (predictor 2) predictor reversal reusing PngCodec's approach;
  any filter other than FlateDecode/DCTDecode fails closed
- PdfDocument.Images.cs: /Subtype /Image XObject decoding (DCTDecode
  via Codecs.JpegCodec.Load; DeviceGray/RGB/CMYK 8-bit raw samples
  otherwise) and the Do operator, compositing the image's unit square
  through the current CTM onto the destination surface (nearest-
  neighbor sampling, treated fully opaque - no SMask/alpha support
  yet). Form XObjects fail closed rather than being silently skipped.

Extends companion artifacts and adds unit + system-integration tests
with concrete pixel-color assertions for colored fills and composited
image placement.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Render now resolves simple embedded TrueType fonts and paints text via
the standard PDF text-object/positioning/showing operator set.

- PdfDocument.Fonts.cs: /Resources/Font dictionary resolution for
  /Subtype /TrueType simple fonts requiring an embedded
  /FontDescriptor/FontFile2 (loaded via Fonts.TrueTypeFont.Load); no
  font substitution is performed - Type0/Type1/MMType1/Type3 and
  non-embedded fonts fail closed with UnsupportedImageFeatureException.
  /Encoding supports /WinAnsiEncoding (default) and /MacRomanEncoding
  plus /Differences glyph-name overrides; /Widths/FirstChar/LastChar/
  MissingWidth resolved with a documented fallback priority. Fonts are
  cached per resolved font-dictionary identity, scoped to a single
  Render call.
- PdfDocument.Text.cs: BT/ET (resets only the text/line matrices - text
  state itself persists in the graphics state, including across q/Q),
  text-state operators (Tc/Tw/Tz/TL/Tf/Tr/Ts - render modes 1/2/4-7
  fail closed), positioning operators (Td/TD/Tm/T*), and showing
  operators (Tj/'/"/TJ). Glyph layout composes the text-rendering
  matrix (Trm) per PDF spec 9.4.4 and transforms each glyph outline
  before filling with the current fill color, following the same
  outline-transform-fill pattern used for SVG text.

Extends companion artifacts and adds unit + system-integration tests,
including a pixel-level test that independently re-derives expected
glyph-ink positions from the shared OpenSans-Regular.ttf font's own
metrics rather than hardcoding magic pixel values.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Extends Fonts.TrueTypeFont in place (no rename) to support CFF/OpenType
(OTTO) outlines via a new Type 2 charstring interpreter (CffTable/
CffCharstringInterpreter), and TrueType Collection (ttcf) containers via
new Load(stream/path, faceIndex) and GetFaceCount overloads.

CID-keyed CFF and seac-style accent composition are explicitly out of
scope and fail closed with InvalidDataException.

Adds real .otf/.ttc fixtures (Source Sans 3, locally-assembled ttcf
container) with license/provenance documentation, unit tests for the
CFF interpreter and TTC parsing, and updates companion artifacts
(design, reqstream, verification, sysml2, reviewmark).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Adds TrueTypeFont.GetNameInfo() (family/subfamily/full/PostScript name
strings from the sfnt 'name' table, preferring typographic nameID 16/17
over 1/2, Windows-Unicode platform records over Macintosh-Roman) and
IsBold/IsItalic/IsFixedPitch properties (derived from OS/2 fsSelection/
usWeightClass, head.macStyle, and post.isFixedPitch/italicAngle).

New internal NameTable/StyleTable parsers follow the existing per-table
helper convention; both tables are optional per spec and degrade
gracefully (never throw) when absent or too short for a given field.

Purely additive - no existing TrueTypeFont member changed. Adds unit
tests plus real-fixture integration tests confirming actual Open Sans/
Source Sans 3/TTC metadata values, and updates companion artifacts.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Adds Fonts.SystemFontCatalog (core package): lazy, process-lifetime
cached, directory-scan-only discovery of installed fonts across
Windows/macOS/Linux well-known font directories, tolerant of missing
directories and unparseable files; FindBestMatch(familyNameHint, bold,
italic, serif, fixedPitch) two-tier (exact family, then generic
sans/serif/monospace) scoring; and LoadBundledFallback, backed by 12
bundled Liberation Sans/Serif/Mono TrueType fonts (SIL OFL 1.1, ~4.16 MB,
shipped as embedded resources in the core NuGet package with the OFL
license text also packed at licenses/LiberationFonts-OFL.txt).

Rewrites PdfDocument.BuildResolvedFont's no-/FontFile2 branch
(PdfDocument.FontFallback.cs): Standard-14 name -> fixed style mapping,
/FontDescriptor flag/weight/angle-based flavor classification for
non-Standard-14 names, system-catalog match first, then bundled
Liberation fallback. Symbol/ZapfDingbats and other symbolic fonts remain
fail-closed (no substitution) since they have no generic-family
equivalent. Fallback is fully automatic/silent - no diagnostics API, no
new Render parameter.

Adds unit tests for SystemFontCatalog scoring/matching and bundled-font
dogfooding, PDF integration tests for Standard-14 fallback rendering and
Symbol/ZapfDingbats fail-closed behavior, and updates companion
artifacts (design, reqstream, verification, sysml2, reviewmark).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Implements the final four stream filters: LZWDecode (PDF variant per
ISO 32000-1/2 section 7.4.4 - MSB-first 9-12 bit variable-width codes,
early-change growth timing per /DecodeParms /EarlyChange, independent
from-scratch implementation distinct from GifCodec's LZW), ASCII85Decode
(base-85 groups, z shorthand, final-group padding), ASCIIHexDecode
(hex-pair decoding with whitespace tolerance and odd-digit padding), and
RunLengthDecode (PackBits-style literal/repeat runs).

All four are wired into GetStreamDecodedBytes's filter dispatch;
LZWDecode reuses the existing predictor-reversal path shared with
FlateDecode.

Adds ~24 unit tests (including the ISO 32000-1 section 7.4.4 LZWDecode
worked example) plus 4 end-to-end integration tests with fixture PDFs,
and updates companion artifacts (design, reqstream, verification,
sysml2, reviewmark).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Adds a small hand-authored PDF fixture corpus (honestly labeled as
synthetic - no suitable, responsibly-licensed real-world corpus could
be sourced): combined-vector-text-image.pdf (breadth: vector shape +
image XObject + embedded-font text), standard14-font-fallback.pdf (the
first fixture exercising the Phase 6 non-embedded-font fallback path),
and malformed-content-stream.pdf (a dedicated malformed-data scenario).

Adds PdfFixtureTests.cs (fixture-conformance tests mirroring
SvgFixtureTests.cs's rigor) and extends PdfSystemIntegrationTests.cs
with the previously-missing empty-path, malformed-data, and
out-of-range-page-index validation-contract scenarios.

Brings the PDF system design/verification docs and README/introduction
docs current through Phases 5-7 (previously still described Phase 4),
and finalizes .reviewmark.yaml coverage for the PDF package's complete
file set.

This is the final planned phase of the feature/pdf-document-support
roadmap - no production rendering-logic changes.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Phases 5/5b/6 of this branch added correct unit-level requirements for
the Fonts subsystem's CFF/OTF, TTC, name/style metadata, and OS font
discovery/fallback capabilities, but no system-level requirement
decomposed down to them, leaving them unreachable from any
system-tagged root requirement.

Adds 4 system-level requirements to canvas-net.yaml's Fonts API section
(CanvasNet-Lib-FontsCffOutlineDecoding/TtcSupport/NameStyleMetadata/
SystemFontDiscovery), each linking to a new system-integration test in
CanvasNetTests.cs exercising the corresponding capability through the
public API end-to-end, plus matching verification test-scenario
entries.

dotnet reqstream --enforce now reports 0 orphaned requirements (was 43).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Real-world PDF testing surfaced a genuine parser bug: content streams
containing an inline dictionary operand (e.g. the BDC/DP marked-content
operators' properties dictionary, as commonly emitted by tagged-PDF
producers like Word/LibreOffice/browser print-to-PDF) threw
InvalidDataException("Unexpected token 'DictStart'") instead of parsing
the dictionary and continuing, since ExecuteContentStream's operand loop
only special-cased ArrayStart, not DictStart.

Reuse the existing generic ParseValue/ParseDictionaryOrStream parser
(already used for regular indirect objects) for DictStart tokens, exactly
as already done for ArrayStart. Added a regression test and a cspell
entry for the newly-referenced MCID term.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
… encoding

Phase 9 of the Type0/CID font roadmap. Adds IResolvedFont abstraction over
simple (TrueType) and composite (Type0) font resolution, CIDFontType2
CIDToGIDMap handling (Identity or embedded stream), /DW+/W width resolution,
and 2-byte Identity-H code decoding in ShowText/ShowGlyph.

Also fixes a spec-correctness bug found during review: word spacing (Tw)
must apply only to a simple font's single-byte code 32, never to a
composite font's 2-byte code even when it numerically equals 32 (PDF spec
section 9.3.3). Gated the ShowGlyph word-spacing check on CodeByteWidth == 1.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Adds ResolveToUnicodeMap, a narrow bfchar/bfrange-only CMap parser reusing
the existing content-stream tokenizer/parser primitives. Resolves a
Type0 font's optional /ToUnicode entry into a code-to-Unicode-codepoint
map stored on ResolvedCompositeFont.ToUnicode - groundwork only, not yet
consumed at rendering time.

Fails closed on usecmap/cidrange/cidchar operators and the CIDSystemInfo-
style array-of-arrays bfrange destination sub-form; silently ignores the
mandatory Adobe CMap PostScript wrapper boilerplate and
begincodespacerange/endcodespacerange, which carry no mapping-relevant
data.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…ype CFF)

Phase 12 of the composite-font roadmap. Adds a /CIDFontType0 descendant-
font dispatch branch alongside the existing /CIDFontType2 path: loads a
non-CID-keyed CFF program embedded via an /OpenType-wrapped /FontFile3,
using identity CID-to-glyph-index per PDF 32000-1 section 9.7.4.2 (ignoring
any non-standard /CIDToGIDMap entry on the descendant, which is not a valid
key for this subtype).

No Fonts-subsystem changes needed - Fonts.TrueTypeFont.Load already parses
these OpenType/CFF containers unmodified; only unsupported/CID-keyed CFF
(ROS/FDArray/FDSelect) still fails closed via the existing CffTable.Parse
rejection, surfacing as InvalidDataException.

A bare/naked /CIDFontType0C CFF stream (no SFNT wrapper) and general
CID-keyed CFF support (a separately-scoped future phase) remain explicit
non-goals.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
… Phase 1)

Adds SampledFunction/ResolveFunction: restricted to /FunctionType 0
(sampled), 1 input, 8/16-bit samples, with clamped-/Domain, /Encode
sample-index mapping, linear interpolation between adjacent samples, and
/Decode-mapped, /Range-clipped output. Groundwork only - not yet wired
into any rendering path (no /Pattern color-space plumbing yet).

Rejects /FunctionType 2/3/4 and multi-input Type 0 functions (the
/DeviceN//Separation tint-transform shape, already out of scope) with
UnsupportedImageFeatureException.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…ping

Real-world producers (e.g. WeasyPrint) emit an empty hex-string
destination (<>) to mean 'this code has no single-character Unicode
equivalent'. The Phase 10 parser treated this as malformed and threw
InvalidDataException, which propagated out of BuildResolvedCompositeFont
and aborted the whole font resolution - blocking rendering entirely for
an otherwise-renderable page, even though /ToUnicode is diagnostic-only
groundwork not yet consumed by rendering.

Skip mapping the affected code(s) instead of failing closed; a non-empty
destination with an odd byte count still throws, since that remains
genuine corruption.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Per PDF 32000-1:2008 section 8.5.2.1, 'If the current subpath is
already closed, h shall do nothing.' The interpreter previously threw
InvalidDataException whenever 'h' was issued without an open subpath,
including the extremely common 're h' idiom (a redundant explicit
close right after 're', which already self-closes its rectangle) -
real-world producers such as MuPDF/fitz emit this routinely. l/c/v/y
still correctly require an open subpath, since drawing without a
preceding move is genuinely invalid.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Adds general-purpose Type 1 font loading to the core DemaConsulting.CanvasNet
Fonts library, available to every consumer (Pdf, Svg, Pptx, Vsdx, or any
future caller of TrueTypeFont), not just PDF rendering:

- Type1CharstringDecryption: the eexec/charstring LCG decryption cipher.
- Type1Table: parses the eexec-decrypted private dictionary's /Subrs and
  /CharStrings (name-keyed, token-agnostic RD/ND/NP scanner), assigns glyph
  indices (.notdef -> GID 0), exposes outlines/advance widths/name lookup.
- Type1CharstringInterpreter: the full Type 1 charstring opcode set,
  including real flex geometry (OtherSubrs 0/1/2 - converts the seven
  buffered rmoveto points into two genuine cubic Bezier segments, not a
  hinting no-op) and hint-replacement pass-through (OtherSubrs 3). seac
  (accented composites) is a documented Non-Goal, rejected explicitly.
- TrueTypeFont.LoadType1(stream, length1, length2, codepointToGlyphName):
  new public entry point for embedding formats (like PDF's /FontFile) that
  supply segment lengths and an authoritative encoding out-of-band.
- TrueTypeFont.Load(stream)/Load(string) now also auto-detect standalone
  .pfb (binary segment-framed) and .pfa (hex-encoded) Type 1 font files
  with zero extra parameters, exactly like existing TrueType/OpenType/CFF
  support - via new Type1PfbReader/Type1PfaReader plus a core-owned
  Type1StandardGlyphNames default encoding table.

PDF-project integration (dispatching /Subtype /Type1 font dictionaries to
this new API, plus /StandardEncoding support) is a separate, follow-up
phase - this commit only touches the core Fonts library and its own tests/
requirements/design/verification docs.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Integrates the core Type1 font support (previous commit) into PDF font
resolution:

- /Subtype /Type1 now dispatches through BuildResolvedSimpleFont alongside
  /TrueType, with three cases per font descriptor: /FontFile2 (unchanged
  embedded TrueType path), /FontFile (new - embedded classic PostScript
  Type 1 program, via new PdfDocument.Fonts.Type1.cs delegating to
  Fonts.TrueTypeFont.LoadType1), neither (existing non-embedded Standard-14
  fallback path, now reachable for Type1 too). /FontFile3 on a /Type1
  descriptor (embedded Type1C/CFF as a simple font) remains explicitly
  rejected via UnsupportedImageFeatureException, distinct from the
  already-supported CIDFontType0 composite CFF path.
- Adds /StandardEncoding as a third recognized named base encoding
  (alongside /WinAnsiEncoding/MacRomanEncoding), backed by the Adobe
  Standard Encoding table, needed by many real-world Type 1 fonts/PDF
  producers.
- Two new hand-authored fixtures: an embedded Type 1 font (/FontFile) and
  a non-embedded Standard-14 Type1 fallback.

/MMType1 and /Type3 subtypes remain rejected (out of scope). seac
(accented composites) and /FontFile3-on-/Type1 remain documented
Non-Goals, consistent with the core library's own scope.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Type1Table's lightweight advance-width peek (used eagerly at Parse time,
without running the full charstring interpreter) required a glyph's
charstring to literally begin with 'hsbw'/'sbw'. Real-world Type 1 fonts
(found via a pdfLaTeX Computer Modern font from py-pdf/sample-files)
commonly encode a fractional side-bearing/width - which plain Type 1
integers cannot represent - as '<numerator> <denominator> div' immediately
before hsbw/sbw (for example '0 20225 61 div hsbw' for a width of
331.56...), which the peek rejected outright.

The peek now also recognizes and evaluates a leading div (escape 12 12)
operator, matching the full Type1CharstringInterpreter's own div handling,
while remaining deliberately narrow (no subroutine calls or other
operators) so that a font containing other, unrelated unsupported glyphs
(for example seac-based accented composites) can still load successfully
as long as those glyphs are never actually rendered.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…lback

Per PDF 32000-1 section 9.6.2.2, /FontDescriptor is optional for the
standard 14 fonts (and several real-world producers, e.g. ReportLab, omit
it entirely). BuildResolvedSimpleFont previously required /FontDescriptor
unconditionally, throwing InvalidDataException for these otherwise
perfectly valid font dictionaries instead of resolving them via the
existing ResolveFallbackFont substitution path, consistent with this
method's already-lenient 'substitute automatically' policy for any other
non-embedded font.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…hNames

The Adobe-glyph-name table used to resolve /Encoding/Differences glyph
names only recognized 'fi' and 'fl'; the 'ff', 'ffi', and 'ffl' Latin
ligatures were missing, causing a real-world pdfLaTeX Computer Modern
font (which declares these in its own /Differences array) to throw
InvalidDataException.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The 'nacute' (Polish/Czech 'n-acute') Adobe glyph name was missing from
StandardGlyphNames, causing a real-world pdfLaTeX Computer Modern font
(which declares it in its own /Differences array) to throw
InvalidDataException.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Implements /Subtype /Form XObject execution: decodes the Form's content
stream, concatenates its optional /Matrix into the current CTM, resolves
its own /Resources (falling back to the invoking stream's resources when
absent), and recursively executes the nested content implicitly bracketed
like q/Q around the invoking stream's graphics state. Guards against
pathological/self-referencing nesting with a depth limit of 12.

/BBox clipping and /Group transparency groups remain out of scope for this
phase, matching the existing lack of clipping-path support elsewhere in the
interpreter.

Verified against the py-pdf/sample-files corpus and a real-world 26-page
document previously blocked solely by this gap (now renders all pages
without error).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Replaces the enum-only PdfColorSpaceKind representation with a
PdfColorSpace class carrying per-instance data, enabling:
- /ICCBased: resolved via /Alternate when supported, else via /N
  (1/3/4 -> DeviceGray/DeviceRGB/DeviceCMYK).
- /Indexed: a 1-component palette lookup over any supported base
  color space (device spaces or /ICCBased), with out-of-range indices
  clamped rather than rejected. Usable both for image XObjects and
  for cs/sc/scn painting operators.

Full ICC profile color management and non-device-mappable bases
(e.g. /Separation under /Indexed) remain out of scope.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Implements a from-scratch ITU-T T.6 Group 4 (Modified Modified READ)
two-dimensional decoder for CCITTFaxDecode image XObjects: full
White/Black Modified Huffman run-length code tables, the nine
two-dimensional mode codes (Pass/Horizontal/V0/VR1-3/VL1-3), /K,
/Columns, /Rows, /BlackIs1, and /EncodedByteAlign support. Decoded
samples route through the existing /ColorSpace pipeline.

Group 3 (/K >= 0) and explicit /EndOfLine markers are out of scope
for this phase and fail closed with UnsupportedImageFeatureException.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Adds CFF Charset (Top DICT operator 15) parsing to CffTable - the
full 391-entry Standard Strings table plus custom charset formats
0/1/2 and the predefined ISOAdobe default - enabling glyph-name-to-
glyph-index resolution (CffTable.TryGetGlyphIndex), previously only
possible for classic Type 1 (/FontFile) fonts via Type1Table. Also
surfaces proper CFF advance widths via Private DICT
defaultWidthX/nominalWidthX.

Wires this into PdfDocument.Fonts.cs: a /Subtype /Type1 font whose
/FontDescriptor declares only /FontFile3 (/Subtype /Type1C, a bare
CFF stream with no SFNT wrapper) now loads via the new
TrueTypeFont.LoadType1C, instead of unconditionally throwing
UnsupportedImageFeatureException.

Predefined Expert/ExpertSubset charsets remain a documented scope
boundary (glyphs silently unmapped, not thrown, matching Type1Table's
existing behavior for unmapped glyph names).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Adds a ResolvedType3Font (IResolvedFont) resolving /FontMatrix,
/Encoding (via /Differences), /CharProcs, /Resources, and /Widths
from a /Subtype /Type3 font dictionary. Each glyph is painted by
re-entering ExecuteOperators against its own /CharProcs content
stream, mirroring OpDrawFormXObject's save/restore/recurse pattern,
with the glyph-space-to-device matrix computed as
FontMatrix * ComputeTextRenderingMatrix(). Advance widths are
transformed through /FontMatrix as a vector (Vector2.TransformNormal)
rather than the fixed /1000 convention other font kinds use.

IResolvedFont.Font is now nullable to accommodate Type3 fonts, which
have no outline-glyph font program; ShowGlyph branches on the
concrete font type before ever touching it. The d0/d1 glyph-
description operators are recognized and their operands parsed and
discarded (both are no-ops outside a Type3 glyph proc); /Widths
remains the authoritative width source for text layout. A dedicated
nesting-depth guard bounds recursive Type3-glyph-proc invocation.

True d1 in-glyph color-operator suppression is a documented scope
boundary: a glyph proc's own color-setting operators are not
forcibly overridden.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
A malformed/adversarial PDF with a /Resources/ColorSpace entry that
resolves back to itself (directly, or via a cycle of several names)
caused ResolveColorSpaceByName/ResolveColorSpaceValue to recurse
until the process' call stack was exhausted, raising an unrecoverable
StackOverflowException that crashed the whole process instead of
failing just that document's open/render call.

Found via a batch-rendering survey against the py-pdf/sample-files,
pdf-association/pdf20examples, sambitdash/PDFTest and
luigigubello/PayloadsAllThePDFs corpora (GeoTopo-komprimiert.pdf).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Several real-world PDF producers emit a wrong/placeholder Adler-32
trailer over an otherwise well-formed DEFLATE payload; mainstream PDF
readers tolerate this. ZlibDecompress no longer validates the trailer,
matching that ecosystem tolerance instead of rejecting an otherwise
perfectly decodable stream.

Found via a batch-rendering survey against public real-world PDF test
corpora (141 occurrences in one corpus alone).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
A /W [0 0 0] entry-width table produces a zero per-entry stride, so the
decode loop in ParseXrefStreamSection never advances position. With a
crafted large /Size or /Index range this can spin for an unbounded
number of iterations without making progress or hitting the truncation
check (which also degenerates to position + 0). Reject a zero total
entry width before iterating so malformed cross-reference streams fail
closed instead of hanging.

Adds a regression test exercising /W [0 0 0] via the existing crafted
xref-stream-with-custom-widths test helper.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot AI lite review requested due to automatic review settings October 2, 2026 18:25

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

One or more issues must be addressed before approval.

Review effort: Lite
Findings: 1 High severity

Open (1)
Resolved since last review (1)

Comment thread src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Xref.cs Outdated
GetObject only guarded against resolution cycles reached via the
compressed-object-stream path (LoadCompressedObject). A cycle reachable
through a mix of direct and compressed resolution - for example an
object-stream container whose own /Length is an indirect reference to
a compressed object living inside that same container - was not
guarded, recursing indefinitely.

Renamed the per-document resolution-tracking set from
_compressedObjectResolutionStack to _objectResolutionStack and moved
the cycle guard in GetObject to cover both the direct and compressed
branches, so any repeat entry into GetObject for an object number still
being resolved now fails closed with InvalidDataException.

Adds a regression test building a self-referential xref-stream-only PDF
whose container's /Length indirectly references a compressed object
living inside itself, exercising this cross-branch cycle.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot AI lite review requested due to automatic review settings October 2, 2026 18:51

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

Unresolved critical and moderate findings remain in PDF parsing, pattern handling, and font processing.

Review effort: Lite
Findings: 1 High severity

Open (1)
Resolved since last review (1)

Comment thread src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Xref.cs
LoadCompressedObject's header-reading loop trusted a crafted /N even
after the decoded object-stream header had no real content left,
calling NextToken() up to /N times regardless of the stream's actual
size. A tiny decoded stream paired with a huge /N made this spin
through a correspondingly huge number of EndOfFile tokens before
failing, a CPU-time denial-of-service proportional to the attacker's
chosen /N rather than the stream's real size.

Added an upfront check rejecting /N whenever it exceeds the decoded
byte length (each header entry needs at least one byte), plus an
explicit in-loop check that stops and throws as soon as the header
tokenizer has no bytes left, rather than relying on the eventual
malformed-token check to notice.

Adds a regression test building an object stream that declares /N as
one million while its decoded content holds a single real entry.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot AI lite review requested due to automatic review settings October 2, 2026 19:12

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

Unresolved critical and moderate findings affect malformed-input handling, encrypted documents, font discovery, and pattern rendering.

Review effort: Lite
Findings: 3 High severity

Open (3)
Resolved since last review (1)

Comment thread src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Xref.cs
Comment thread src/DemaConsulting.CanvasNet.Pdf/PdfDocument.cs
Comment thread src/DemaConsulting.CanvasNet/Fonts/SystemFontCatalog.cs
Malcolm Nixon and others added 3 commits October 2, 2026 15:42
Added an upfront check rejecting a declared classic cross-reference
subsection entry count that exceeds the real bytes remaining in the
buffer, plus an in-loop EOF guard before each entry triplet is read.
Exposed PdfTokenizer.BufferLength to support the bound check.

Added a regression test building a classic xref table whose subsection
header declares one million entries with no real entry bytes following
it, expecting InvalidDataException instead of a CPU-time denial of
service.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Reordered the constructor so InitializeEncryption runs immediately
after cross-reference parsing succeeds, before IsValidCatalogRoot
resolves /Root. Previously, when the catalog (or an ancestor page-tree
object) lived inside a compressed, encrypted /Type /ObjStm object
stream, resolving it before the file key existed decompressed
ciphertext as if it were plaintext, spuriously failed, and incorrectly
triggered the linear-scan fallback even though the document was
otherwise well-formed.

The fallback path (xref parsing failure, malformed /Encrypt dictionary,
or a genuinely invalid catalog) is unaffected: InitializeEncryption is
still called again once a fallback trailer is recovered, matching the
prior behavior and exception-propagation semantics exactly.

Updated the stale cache-invalidation comments that assumed
IsValidCatalogRoot's own /Root lookup as a pre-key caching source in
the normal path.

Added a regression test building an encrypted document whose /Type
/Catalog object is itself compressed inside an encrypted /Type /ObjStm
container, proving it now opens and renders correctly instead of
falling back to a linear scan.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
EnumerateFontFiles now skips any subdirectory marked as a reparse point
(a symlink or junction) entirely, and tracks the canonicalized path of
every other subdirectory queued or visited in a HashSet, so a cycle -
for example a symlink pointing back at an ancestor directory - cannot
make the scan loop indefinitely.

Added a regression test that creates a directory symlink cycling back
to its own ancestor alongside a real font file, proving the scan
terminates and still discovers the sibling font. The test skips itself
when the current process lacks the privilege to create a directory
symlink (environment-dependent, for example Windows without Developer
Mode or elevation).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot AI lite review requested due to automatic review settings October 2, 2026 19:42

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment thread src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Filters.cs
Comment thread src/DemaConsulting.CanvasNet.Pdf/PdfDocument.ObjectModel.cs Outdated
Comment thread src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Xref.cs
Comment thread src/DemaConsulting.CanvasNet.Pdf/PdfDocument.cs
Malcolm Nixon and others added 4 commits October 2, 2026 16:16
Fixed PNG predictors (10-14) apply a single filter uniformly to every
row with no per-row filter-tag byte, unlike adaptive predictor 15
which has a tag byte per row. ApplyPngPredictor previously assumed
the adaptive layout unconditionally, corrupting fixed-predictor image
data. Also validate that data length is an exact multiple of the row
stride, throwing InvalidDataException instead of silently truncating.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
ParseValue/ParseArray/ParseDictionaryEntries/ParseDictionaryOrStream
recursed without a depth limit, allowing a maliciously crafted PDF
with deeply nested arrays/dictionaries to trigger an uncatchable
StackOverflowException. Thread a depth counter through these methods
and throw InvalidDataException once MaxObjectNestingDepth (64) is
exceeded.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
PdfTokenizer only guards against Position >= Length; a negative
Position falls through to direct buffer indexing and throws an
uncatchable IndexOutOfRangeException. A malformed startxref value,
/Prev or /XRefStm link, classic-xref entry offset, or compressed
object's computed First+relative offset could all reach this path
unchecked. Add ValidateBufferOffset and apply it at every offset
derived from untrusted PDF input before any Position assignment, and
compute compressed-object offsets using long arithmetic to avoid
Int32 overflow/wraparound.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
When primary xref/trailer parsing fails and PdfDocument falls back to
a linear scan, InitializeEncryption is re-run against whatever
trailer the fallback recovers. If that trailer has no /Encrypt entry,
the previously computed encryption key and cipher were left set from
the first (failed) attempt, so plaintext content streams recovered by
the fallback would be incorrectly decrypted. Reset
_encryptionKey/_encryptionCipher before re-initializing encryption in
the fallback path.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot AI lite review requested due to automatic review settings October 2, 2026 20:16

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🔵 Needs a closer look

One or more issues must be addressed before approval.

Review effort: Lite
Findings: None

Resolved since last review (4)

@Malcolmnixon
Malcolmnixon merged commit c9baf30 into main Oct 2, 2026
7 checks passed
@Malcolmnixon
Malcolmnixon deleted the feature/pdf-document-support branch October 2, 2026 20:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants