Skip to content
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ image operations using `Span<T>`, and supports independent-copy cropping for loa
- 🎨 **PNG Codec** - Load non-interlaced PNGs; save 8-bit RGB/RGBA
- 🖨️ **TIFF Codec** - Load/save 8-bit RGB/RGBA/Grayscale TIFF files
- 🗜️ **JPEG Codec** - Load baseline/progressive; save baseline JPEG
- 🎞️ **GIF Codec** - Decode-only load of first GIF frame
- 🎞️ **GIF Codec** - Decode-only load of first GIF frame; `GetInfo` reports the true frame count
- 📐 **SVG Codec** - Rasterize a common SVG subset to a surface
- 🔍 **Header-Only Probing** - `GetInfo` reads headers without decoding pixels
- 🖌️ **Path Filling** - Antialiased nonzero/even-odd fill of vector paths
Expand Down Expand Up @@ -85,6 +85,10 @@ using var reloadedJpeg = JpegCodec.Load("surface.jpg");
// Decode-only: load the first frame of a GIF
using var reloadedGif = GifCodec.Load("surface.gif");

// GetInfo also reports a GIF's true total frame count, without decoding any frame's pixels
Comment thread
Copilot marked this conversation as resolved.
Outdated
var gifInfo = GifCodec.GetInfo("surface.gif");
Console.WriteLine($"Frames: {gifInfo.FrameCount}");

// Decode/rasterize an SVG into a 256x256 surface
using var rasterized = SvgCodec.Load("icon.svg", 256, 256);

Expand Down
43 changes: 27 additions & 16 deletions docs/design/canvas-net/codecs.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,8 @@ in-memory pixel representation; `SvgCodec` additionally depends on the `Geometry
- **JpegCodec** — hand-rolled loader/saver for a common real-world subset of JPEG files; see
_JpegCodec Unit Design_ (`codecs/jpeg-codec.md`)
- **GifCodec** — hand-rolled, decode-only loader for a common real-world subset of GIF files
(first frame only); see _GifCodec Unit Design_ (`codecs/gif-codec.md`)
(first frame only); `GetInfo` additionally reports the file's true total frame count; see
_GifCodec Unit Design_ (`codecs/gif-codec.md`)
- **SvgCodec** — decode/rasterize-only loader for a common real-world subset of SVG documents; see
_SvgCodec Unit Design_ (`codecs/svg-codec.md`)

Expand All @@ -48,21 +49,27 @@ in-memory pixel representation; `SvgCodec` additionally depends on the `Geometry
public readonly record struct ImageInfo(int Width, int Height, int Channels, bool HasAlpha)
{
public bool CanDecode { get; init; } = true;
public int FrameCount { get; init; } = 1;
}
```

It reports a candidate image's declared width, height, channel count, and alpha presence without
requiring the caller to decode (or even fully read) the file. It is the return type of every
`{Codec}.GetInfo(Stream)` / `{Codec}.GetInfo(string)` method across `BmpCodec`, `PngCodec`,
`TiffCodec`, `JpegCodec`, `GifCodec`, and `SvgCodec`. `CanDecode` is declared as an `init`-only property
outside the primary constructor (rather than a fifth positional parameter) specifically to avoid
changing the compiler-emitted constructor/`Deconstruct` signature — a binary-compatibility
concern, since a fifth positional parameter would break any pre-compiled caller's IL even though
source would still compile unchanged. It defaults to `true` and is set to `false` only by
`PngCodec.GetInfo` when the probed file is well-formed per the PNG specification but declares
Adam7 interlacing, the one case (see below) where a codec's `Load` refuses a file `GetInfo`
otherwise accepts. Because `ImageInfo` is a record struct, `CanDecode` participates in its
generated value equality like every other member.
`TiffCodec`, `JpegCodec`, `GifCodec`, and `SvgCodec`. `CanDecode` and `FrameCount` are both
declared as `init`-only properties outside the primary constructor (rather than positional
parameters) specifically to avoid changing the compiler-emitted constructor/`Deconstruct`
signature — a binary-compatibility concern, since an additional positional parameter would break
any pre-compiled caller's IL even though source would still compile unchanged. `CanDecode`
defaults to `true` and is set to `false` only by `PngCodec.GetInfo` when the probed file is
well-formed per the PNG specification but declares Adam7 interlacing, the one case (see below)
where a codec's `Load` refuses a file `GetInfo` otherwise accepts. `FrameCount` defaults to `1`
and is overridden only by `GifCodec.GetInfo`, the only codec whose file format can legitimately
declare more than one frame (an animation); it reports the file's true total Image Descriptor
count by walking the file's block structure, never decoding any frame's LZW-compressed pixel
data — see _GifCodec Unit Design_ (`codecs/gif-codec.md`) for the exact walk. Because `ImageInfo`
is a record struct, `CanDecode` and `FrameCount` both participate in its generated value equality
like every other member.

### Header-Only Probing (`GetInfo`)

Expand Down Expand Up @@ -94,12 +101,16 @@ Adam7-interlacing rejection (every other header-validity check is unconditional,
decodable color-type/bit-depth space now spans the PNG specification's entire legal space) — see
_PngCodec Unit Design_ (`codecs/png-codec.md`) for the exact rationale; the other three raster
codecs still use only the single `enforceMaxDimension` flag, since none of them has a
feature-based `Load` refusal that is independent of header well-formedness. `GifCodec` shares a
similarly-purposed internal header-parsing helper (`ReadLogicalScreenDescriptor`), but with no
boolean flag at all: unlike the other four raster codecs, `GifCodec.GetInfo` never enforces
`Surface.MaxDimension` under any circumstance, since it performs no arithmetic or allocation based
on the reported width/height beyond constructing the returned `ImageInfo` itself — see _GifCodec
Unit Design_ (`codecs/gif-codec.md`) for the exact rationale. `SvgCodec` does not
feature-based `Load` refusal that is independent of header well-formedness. `GifCodec` shares its
per-frame structural-validation helpers (region bounds, minimum code size range, color table
resolution) between `Load` and `GetInfo`'s frame-counting walk, but with no boolean flag at all:
unlike the other four raster codecs, `GifCodec.GetInfo` never enforces `Surface.MaxDimension`
under any circumstance and performs no arithmetic or allocation proportional to the reported
width/height itself (only bounded, per-frame bounds-check arithmetic and a budget-capped sub-block
buffer) — see _GifCodec Unit Design_ (`codecs/gif-codec.md`) for the exact rationale, including
the deliberate asymmetry that `GetInfo` never invokes the LZW decoder for any frame (not even the
first, which `Load` does decode), so a first-frame compressed-data corruption that makes `Load`
throw does not make `GetInfo` throw. `SvgCodec` does not
use this pattern, because
its `Load` overloads take the requested output raster's width/height as ordinary caller-supplied
parameters (not values decoded from the file) and delegate them directly to `Surface`'s own
Expand Down
87 changes: 74 additions & 13 deletions docs/design/canvas-net/codecs/gif-codec.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,12 @@ well-formed) and is then discarded. This is a deliberate, documented scope limit
malformed-input condition: a multi-frame GIF never causes `Load` to throw, and
`ImageInfo.CanDecode` is always `true` for a well-formed GIF file, unlike PNG's Adam7-interlacing
case (see *Codecs Subsystem Design*, `../codecs.md`, for the cross-codec `CanDecode` rationale).
`GifCodec.GetInfo` additionally reports the file's true total frame count via
`ImageInfo.FrameCount`, by walking every block in the file and structurally validating every
frame exactly as `Load` does. Unlike `Load` — which decodes the first frame's LZW data to
produce pixels — `GetInfo` never decodes any frame's LZW-compressed pixel data; it only counts
frames and validates structure (see *Codecs Subsystem Design*, `../codecs.md`, for the
cross-codec `FrameCount` rationale).

`GifCodec` is a `static` class: GIF decoding has no instance state to carry, so a static utility
shape was chosen over an object with nothing to construct or configure.
Expand Down Expand Up @@ -118,14 +124,17 @@ a time:
transparency flag/transparent-color-index are recorded for the *next* Image Descriptor only.
Every other extension label's data is read and discarded via the shared `ReadSubBlocks` helper
(no special casing needed, since `ReadSubBlocks` already fully consumes it).
- **Image Descriptor (`0x2C`)** — reads the 9-byte descriptor, any Local Color Table, and the
LZW-minimum-code-size byte, validating it is in the range 2-8 for *every* frame (not merely the
first, whose pixel data `DecodeGifLzw` separately range-checks as part of decoding); then reads
the compressed image sub-block chain. Validates the frame's width/height are positive and its
region lies fully within the logical screen. On the *first*
- **Image Descriptor (`0x2C`)** — calls the shared `ReadImageDescriptorHeader` helper (see
below), which reads the 9-byte descriptor, any Local Color Table, and the LZW-minimum-code-size
byte, validating it is in the range 2-8 for *every* frame (not merely the first, whose pixel
data `DecodeGifLzw` separately range-checks as part of decoding); then reads the compressed
image sub-block chain. Calls the shared `ValidateFrameRegionAndResolveColorTable` helper (see
below) to validate the frame's width/height are positive, its region lies fully within the
logical screen, and resolve its active color table (Local, else Global, else
`InvalidDataException`). On the *first*
Image Descriptor only: decodes the compressed data via the GIF-native LZW decoder
(`DecodeGifLzw`), de-interlaces it first if the interlace flag is set, resolves each palette
index through the active color table (Local, else Global, else `InvalidDataException`) to an
index through the active color table to an
`Rgba32` (alpha 0 if the index matches a pending Graphic Control Extension's transparent index
and its transparency flag is set, else alpha 255), allocates a canvas-sized `Surface`
(defaulting to fully transparent), and blits the frame into it at its declared offset. Every
Expand Down Expand Up @@ -168,21 +177,48 @@ Opens `path` as a read-only `FileStream` and delegates to `Load(Stream)`.

Calls the shared `ReadLogicalScreenDescriptor` helper, validates the declared width/height are
positive (the identical non-positive check `Load` performs, but without `Load`'s additional
`Surface.MaxDimension` upper bound), and returns
`new ImageInfo(width, height, 1, false)`. No color table, extension, or image block is ever read;
`Surface.MaxDimension` upper bound), reads the Global Color Table if present (identically to
`Load`), then calls the shared `CountFrames` helper (see below) to walk every remaining block
through and including the Trailer, and returns
`new ImageInfo(width, height, 1, false) { FrameCount = frameCount }`.
`Surface.MaxDimension` is never enforced — the raw header-declared width/height are always
returned, even when they exceed it, matching `ImageInfo`'s documented "bomb triage" contract (see
*Codecs Subsystem Design*, `../codecs.md`). `Channels` is always 1 (the raw file's single
palette-index-per-pixel encoding); `HasAlpha` is always `false` (see `ImageInfo`'s remarks for the
full rationale). `CanDecode` defaults to `true` and is never overridden — see this unit's
*Purpose* section above for why a multi-frame GIF is not a `CanDecode == false` case.
`FrameCount` reports the file's true total Image Descriptor count.

Counting a GIF's frames correctly requires walking every block in the file — not merely reading
the Logical Screen Descriptor, as an earlier version of this method did — because an Image
Descriptor's position in the file is not otherwise predictable (it is interleaved with an
arbitrary number of extension blocks). `GetInfo` shares its per-frame structural validation with
`Load` via the same private helpers (`ReadImageDescriptorHeader`,
`ValidateFrameRegionAndResolveColorTable`), so any input `GetInfo` rejects for a structural reason
is also an input `Load` would reject, upholding `ImageInfo`'s "never throws for input `Load` would
accept" invariant. Crucially, `GetInfo` never invokes the LZW decoder (`DecodeGifLzw`) for *any*
frame — not even the first, which `Load` does decode — so a first-frame compressed-data
corruption that makes `Load` throw does not make `GetInfo` throw. This is a deliberate, bounded
asymmetry, consistent with `GetInfo`'s existing `Surface.MaxDimension` leniency: `GetInfo` is
only ever more lenient than `Load`, never less. The frame-counting walk reuses the exact same
`MaxTotalSubBlockBytes` cumulative budget `Load` enforces (see the *Data Model* and
*Error Handling* sections above), so it introduces no new unbounded-loop or resource-exhaustion
risk: the walk performs no allocation proportional to the frame count beyond a single `int`
counter, every per-frame allocation it does perform is itself capped by this same 64 MiB
cumulative budget, and the walk is strictly bounded by the number of bytes actually present in
the input stream.

**Throws:**

- `ArgumentNullException` — `stream` is null
- `InvalidDataException` — missing `"GIF87a"`/`"GIF89a"` signature; non-positive declared width or
height; the stream ends before the 13-byte signature and Logical Screen Descriptor has been
fully read
height; no color table (Global or Local) available for some Image Descriptor; a Graphic Control
Extension whose data is not exactly 4 bytes; an Image Descriptor's region lying outside the
logical screen; any Image Descriptor's LZW minimum code size byte outside the 2-8 range; an
unexpected block introducer byte; no Image Descriptor ever seen before the Trailer; the
cumulative sub-block data read across every extension and image-data chain in the whole file
exceeding `MaxTotalSubBlockBytes`; the stream ending before all header, color-table, or block
data has been read

#### GetInfo(string path)

Expand All @@ -201,9 +237,34 @@ Reads and validates the 6-byte signature and 7-byte Logical Screen Descriptor, r
decoded width, height, and packed byte. Called identically, as the literal first statement, by
both `GetInfo(Stream)` and `Load(Stream)` — the same parity guarantee documented for the other
four raster codecs' shared header helpers (see *Codecs Subsystem Design*, `../codecs.md`'s
Header-Only Probing section), but simpler here: GIF has no analogous "skip past optional extra
header bytes" step at this stage, since the color table and all subsequent block parsing happens
only in `Load`, never in `GetInfo`.
Header-Only Probing section).

#### ReadImageDescriptorHeader(Stream stream) — shared, private

Reads a single Image Descriptor's 9-byte fixed header, any Local Color Table, and the LZW minimum
code size byte (with its 2-8 range check), without reading the descriptor's compressed image data
sub-block chain. Called identically by `Load(Stream)`'s `ImageSeparator` case and `CountFrames`,
so both walk an Image Descriptor's fixed-size fields in exactly the same order and with exactly
the same validation.

#### ValidateFrameRegionAndResolveColorTable(...) — shared, private

Validates that an Image Descriptor's declared size is positive and its placement lies entirely
within the logical screen, then resolves its active color table (its own Local Color Table if
present, otherwise the file's Global Color Table, otherwise `InvalidDataException`). Called
identically by `Load(Stream)`'s `ImageSeparator` case and `CountFrames`, so both apply exactly the
same per-frame structural validation — not merely to the first frame, whose pixel data is the
only one either method ever actually decodes or counts pixels for.

#### CountFrames(Stream stream, int canvasWidth, int canvasHeight, Rgba32[]? globalColorTable) — shared, private

Walks a GIF file's blocks, from the current stream position (immediately after any Global Color
Table) through and including the Trailer, counting every Image Descriptor encountered. For each
block type it mirrors `Load`'s own block loop exactly, except that it never invokes the LZW
decoder: an Image Descriptor's compressed image data is read only far enough to be skipped (via
`ReadSubBlocks`, discarding the result) for *every* frame, not merely frames after the first.
Comment thread
Copilot marked this conversation as resolved.
Outdated
Throws `InvalidDataException` if the Trailer is reached having counted zero Image Descriptors,
matching `Load`'s identical "GIF stream contains no Image Descriptor" rejection.

### Error Handling

Expand Down
32 changes: 28 additions & 4 deletions docs/reqstream/canvas-net.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -879,22 +879,46 @@ sections:
- id: CanvasNet-Lib-GifGetInfo
title: >-
The library shall report a GIF's width, height, channel count, and CanDecode via
GetInfo, given a stream or a file path, without decoding pixel data and without
enforcing the maximum permitted dimension.
GetInfo, given a stream or a file path, validating the file's block structure (Logical
Screen Descriptor, color tables, extensions, and every Image Descriptor) without ever
decoding any frame's pixel data, and without enforcing the maximum permitted dimension.
tags: [system]
justification: |
Callers need to inspect a GIF's declared dimensions - for example to reject unreasonably
large images before allocating a Surface - without paying the cost of decoding the full
pixel buffer, and without GetInfo itself preemptively rejecting dimensions that only
Load's allocation path needs to guard against.
Load's allocation path needs to guard against. GetInfo walks the same block structure
Load validates so it can still reject a genuinely malformed GIF; this same per-frame walk
is what lets GetInfo also report ImageInfo.FrameCount (see CanvasNet-Lib-GifFrameCount).
children:
- CanvasNet-Codecs-GifGetInfo
tests:
- GifCodec_GetInfo_ReturnsExpectedDimensionsChannelsAndCanDecode
- GifCodec_GetInfo_NeverReadsPixelData
- GifCodec_GetInfo_NeverDecodesLzwPixelData_AcceptsCorruptFirstFrameCompressedData
- GifCodec_GetInfo_OversizedDimensions_ReturnsRawValue_ButLoadThrows
- GifCodec_GetInfo_Fixture_MatchesLoadDimensionsAndReportsDecodable

- id: CanvasNet-Lib-GifFrameCount
title: >-
The library shall report a well-formed GIF's total Image-Descriptor (frame) count via
ImageInfo.FrameCount from GetInfo, without decoding any frame's LZW-compressed pixel
data, and shall not weaken its rejection of a structurally malformed frame merely
because that frame's pixel data is never decoded.
tags: [system]
justification: |
Callers triaging an untrusted or unknown-source GIF (for example, to warn a user before
rendering an animation, or to cap the number of frames a downstream process will attempt
to handle) need to know a file's true frame count without paying the cost of decoding
any frame's pixel data, and need that count to be trustworthy - a frame count is
worthless if it can be produced for a file GetInfo should have rejected as malformed.
children:
- CanvasNet-Codecs-GifFrameCount
tests:
- GifCodec_GetInfo_SingleFrame_ReportsFrameCountOne
- GifCodec_GetInfo_MultiFrame_ReportsCorrectFrameCount
- GifCodec_GetInfo_Fixture_MatchesLoadDimensionsAndReportsDecodable
- GifCodec_GetInfo_SecondFrameMissingColorTable_ThrowsInvalidDataException

- id: CanvasNet-Lib-GifGetInfoValidation
title: >-
The library shall reject invalid arguments or malformed GIF data (including a
Expand Down
Loading
Loading