Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .cspell.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ words:
- FCHECK
- FileAssert
- fileassert
- finalizer
- blittable
- colorspace
- formedness
Expand Down
26 changes: 13 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,23 +72,23 @@ using DemaConsulting.CanvasNet.Canvas;
using DemaConsulting.CanvasNet.Codecs;
using System.IO;

var surface = new Surface(4, 4);
surface[1, 1] = new Rgba32(255, 0, 0, 255); // set a red, opaque pixel
var cropped = surface.Crop(0, 0, 2, 2); // independent 2x2 copy
using var surface = new Surface(4, 4);
surface[1, 1] = new Rgba32(255, 0, 0, 255); // set a red, opaque pixel
using var cropped = surface.Crop(0, 0, 2, 2); // independent 2x2 copy

BmpCodec.Save(surface, "surface.bmp"); // save as a 32-bit BMP file
var reloaded = BmpCodec.Load("surface.bmp"); // load it back
using var reloaded = BmpCodec.Load("surface.bmp"); // load it back

PngCodec.Save(surface, "surface.png"); // save as an RGBA PNG file
var reloadedPng = PngCodec.Load("surface.png"); // load it back
using var reloadedPng = PngCodec.Load("surface.png"); // load it back

TiffCodec.Save(surface, "surface.tiff"); // save as an RGBA TIFF file
var reloadedTiff = TiffCodec.Load("surface.tiff"); // load it back
using var reloadedTiff = TiffCodec.Load("surface.tiff"); // load it back

JpegCodec.Save(surface, "surface.jpg", 90); // save as a baseline JPEG file
var reloadedJpeg = JpegCodec.Load("surface.jpg"); // load it back
using var reloadedJpeg = JpegCodec.Load("surface.jpg"); // load it back

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

// Triage an untrusted file's header before decoding pixel data:
var info = PngCodec.GetInfo("untrusted.png"); // reads only the header, never decodes IDAT
Expand All @@ -99,7 +99,7 @@ if (info.Width > Surface.MaxDimension
throw new InvalidDataException("Image dimensions exceed the supported maximum.");
}

var safeSurface = PngCodec.Load("untrusted.png"); // safe to decode fully
using var safeSurface = PngCodec.Load("untrusted.png"); // safe to decode fully
```

Filling a vector path onto a surface:
Expand All @@ -110,7 +110,7 @@ using DemaConsulting.CanvasNet.Drawing;
using DemaConsulting.CanvasNet.Geometry;
using System.Numerics;

var canvas = new Surface(64, 64);
using var canvas = new Surface(64, 64);
var triangle = new PathBuilder()
.MoveTo(new Vector2(8, 56))
.LineTo(new Vector2(56, 56))
Expand All @@ -129,7 +129,7 @@ using DemaConsulting.CanvasNet.Drawing;
using DemaConsulting.CanvasNet.Geometry;
using System.Numerics;

var canvas = new Surface(64, 64);
using var canvas = new Surface(64, 64);
var polyline = new PathBuilder()
.MoveTo(new Vector2(8, 48))
.LineTo(new Vector2(32, 16))
Expand All @@ -154,7 +154,7 @@ using DemaConsulting.CanvasNet.Drawing;
using DemaConsulting.CanvasNet.Geometry;
using System.Numerics;

var canvas = new Surface(64, 64);
using var canvas = new Surface(64, 64);
var rectangle = new PathBuilder()
.MoveTo(new Vector2(4, 4))
.LineTo(new Vector2(60, 4))
Expand Down Expand Up @@ -221,7 +221,7 @@ var glyphOutline = font.GetGlyphOutline(glyphIndex); // raw font-design-unit coo
var scale = 48f / font.UnitsPerEm;
var canvasOutline = TransformGlyph(glyphOutline, scale, baselineY: 56f);

var surface = new Surface(64, 64);
using var surface = new Surface(64, 64);
PathFiller.Fill(surface, canvasOutline, new Rgba32(20, 120, 255, 255));
```

Expand Down
50 changes: 50 additions & 0 deletions docs/design/canvas-net/canvas/surface.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ section below).
| `_buffer` | `byte[]` | Contiguous, row-major pixel storage, sized `Height * _strideBytes`. |
| `_strideBytes` | `int` | The physical byte size of one row, including trailing padding. |
| `BytesPerPixel` | `const int` | The number of bytes per pixel (always 4: R, G, B, A). |
| `_disposed` | `bool` | Set once by `Dispose()`; guarded members then throw. |

`MaxDimension` is `public` (not merely internal) so that callers can compare a probed image's
declared dimensions — for example, a codec's `GetInfo(Stream)`/`GetInfo(string)` result — against
Expand Down Expand Up @@ -208,6 +209,7 @@ Results are converted back to bytes with round-half-away-from-zero, clamped to `

- `ArgumentNullException` — when `foreground` is `null`
- `ArgumentException` — when `foreground.Width != Width` or `foreground.Height != Height`
- `ObjectDisposedException` — when this surface, or `foreground`, has been disposed

#### CompositeOver(Rgba32 color)

Expand Down Expand Up @@ -328,6 +330,48 @@ no conditional-select primitive. Since `netstandard2.0` support has been dropped
single, unconditional code path shared by `net8.0`, `net9.0`, and `net10.0` — no `#if`
target-framework gating is required.

#### Dispose()

Releases the resources held by this `Surface` and implements `IDisposable`. This method is
idempotent: a second (or subsequent) call has no additional effect, guarded by the `_disposed`
flag. This differs from the existing `RowChannelBuffers`/`CompositeWorkBuffers` internal helper
types elsewhere in this same file, which rent-and-return `ArrayPool<T>` arrays but do **not**
guard `Dispose()` with a flag - callers must not call their `Dispose()` more than once, since a
second call would return the same rented arrays a second time. `Surface.Dispose()` intentionally
does not follow that shape, since `IDisposable.Dispose()` is conventionally expected to tolerate
repeated calls.

In this release, `_buffer` is a plain managed `byte[]`, not rented from an `ArrayPool<T>`, so
there is nothing for `Dispose()` to actually release yet — it exists purely to establish the
disposal contract _before_ the pixel-storage strategy changes, so that a future release can back
`_buffer` with a pooled array (returning it to the pool inside `Dispose()`) without another
breaking _API surface_ change. That claim is limited to the API surface: a future pooled
implementation must still honor the span-lifetime rule below as a design constraint, since it
cannot retroactively invalidate a span a caller obtained before `Dispose()` was called — pooling
does not get to relax that rule, only to make violating it (a stale span read/write after the
array is re-rented to another `Surface`) more expensive than it is today. Because the only
backing storage today is managed memory the garbage collector already reclaims safely on its
own, `Surface` deliberately declares no finalizer: forgetting to call `Dispose()` only forgoes a
(currently nonexistent) prompt release — it can never leak an unmanaged or pooled resource.

After `Dispose()` has been called, every other public member that touches the pixel buffer
(the indexer, `GetRowSpanBytes`, `GetRowSpan`, `Crop`, `PremultiplyAlpha`, `UnpremultiplyAlpha`,
`Clear`, both `CompositeOver` overloads, and both public `CompositeOverSpan` overloads) throws
`ObjectDisposedException` via an `ObjectDisposedException.ThrowIf(_disposed, this)` guard as the
first statement in the member. The two `internal` workspace-reusing `CompositeOverSpan` overloads
used by `ScanlineRasterizer` carry the same guard, so a disposed surface cannot be mutated through
either the public or the internal fill path.

**Span lifetime**: `GetRowSpanBytes`/`GetRowSpan` return a span that aliases `_buffer` directly,
and the disposal guard only runs when those methods are _called_ - it cannot revoke a span a
caller already obtained and is still holding. A caller that retains such a span across a call to
`Dispose()` and continues reading or writing through it gets undefined behavior: harmless today
(the backing array is merely unreachable through `Surface` itself), but under a future pooled-array
implementation the same array could already be rented out to, and actively used by, a different
`Surface`, so the stale-span access would corrupt unrelated pixel data. Callers must treat a span
returned by either method as invalid once `Dispose()` has been called on the same surface, and must
not retain one across a `Dispose()` call.

### Error Handling

All validation is performed at the start of the constructor, indexer, `GetRowSpanBytes`, `Crop`,
Expand All @@ -338,6 +382,12 @@ entry and the resulting exception propagates directly to the caller uncaught. Th
internal state to roll back because invalid arguments are rejected before any field is read or
written, and before the destination surface is mutated in `Crop`.

Additionally, once `Dispose()` has been called, every public member that touches the pixel
buffer — the indexer (get and set), `GetRowSpanBytes`, `GetRowSpan`, `Crop`, `PremultiplyAlpha`,
`UnpremultiplyAlpha`, `Clear`, `CompositeOver(Surface)`, `CompositeOver(Rgba32)`, and both public
`CompositeOverSpan` overloads — throws `ObjectDisposedException` via a guard at entry, before any
of the member's own argument validation runs.

### Dependencies

`Surface` has one NuGet package dependency, `System.Numerics.Tensors` (pinned to a version
Expand Down
9 changes: 9 additions & 0 deletions docs/design/canvas-net/rendering/canvas.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,15 @@ gradient-paint `FillPath` overload likewise skips `Gradient.WithTransform` and p
`Gradient` reference straight through. This preserves the exact bit pattern of pre-existing
renders when callers adopt the wrapper.

#### Surface Ownership

`Canvas` holds a non-owning reference to the `Canvas.Surface` passed to its constructor: it never
calls `Surface.Dispose()` and does not itself implement `IDisposable`. The caller that
constructed the `Surface` remains solely responsible for disposing it, once both the `Canvas` and
the `Surface` are no longer needed. This is a deliberate design choice — `Canvas` is a thin,
transform-aware wrapper around a `Surface` a caller already owns, not an owner of that surface's
lifetime.

#### Validation

- The constructor throws `ArgumentNullException` on a null `Surface`.
Expand Down
14 changes: 14 additions & 0 deletions docs/reqstream/canvas-net.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,20 @@ sections:
- CanvasNet_SystemIntegration_ClearSurfaceThenReadPixel_ReturnsExpectedColor
- CanvasNet_SystemIntegration_CanvasClearThenReadWrappedSurfacePixel_ReturnsExpectedColor

- id: CanvasNet-Lib-CanvasDisposal
title: >-
The library shall provide explicit resource-lifetime management for a pixel buffer,
logically releasing it on demand and rejecting further use of a released buffer.
tags: [system]
justification: |
Reserving the option to back the pixel buffer with a pooled (ArrayPool-rented) array in
a future release - without another breaking API change - requires an explicit disposal
contract to exist on the buffer type from the start.
children:
- CanvasNet-Canvas-Disposal
tests:
- CanvasNet_SystemIntegration_DisposeSurfaceThenUseIt_ThrowsObjectDisposedException

- id: CanvasNet-Lib-CanvasMaxDimensionPublic
title: >-
The library shall expose the maximum permitted pixel-buffer dimension as a public
Expand Down
30 changes: 30 additions & 0 deletions docs/reqstream/canvas-net/canvas.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -395,3 +395,33 @@ sections:
tests:
- Rgba32_Parse_6HexUppercase_ReturnsExpectedRgbaWithAlpha255
- Rgba32_TryParse_ValidInput_ReturnsTrueAndSetsResult

- id: CanvasNet-Canvas-Disposal
title: >-
The Canvas subsystem shall provide explicit resource-lifetime management for its
pixel buffer, logically releasing it on demand and rejecting further use once
released.
justification: |
Reserving the option to back the pixel buffer with a pooled (ArrayPool-rented)
array in a future release - without another breaking API change - requires an
explicit disposal contract to exist on the buffer type from the start.
children:
- CanvasNet-Canvas-Surface-Disposal
tests:
- Surface_Dispose_CalledOnce_DoesNotThrow
- Surface_Dispose_CalledTwice_DoesNotThrow
- Surface_IndexerGet_AfterDispose_ThrowsObjectDisposedException
- Surface_IndexerSet_AfterDispose_ThrowsObjectDisposedException
- Surface_GetRowSpanBytes_AfterDispose_ThrowsObjectDisposedException
- Surface_GetRowSpan_AfterDispose_ThrowsObjectDisposedException
- Surface_Crop_AfterDispose_ThrowsObjectDisposedException
- Surface_PremultiplyAlpha_AfterDispose_ThrowsObjectDisposedException
- Surface_UnpremultiplyAlpha_AfterDispose_ThrowsObjectDisposedException
- Surface_CompositeOverSurface_AfterDispose_ThrowsObjectDisposedException
- Surface_CompositeOverSurface_ForegroundDisposed_ThrowsObjectDisposedException
- Surface_CompositeOverColor_AfterDispose_ThrowsObjectDisposedException
- Surface_CompositeOverSpan_AfterDispose_ThrowsObjectDisposedException
- Surface_CompositeOverSpanPerPixelColors_AfterDispose_ThrowsObjectDisposedException
- Surface_CompositeOverSpanWithWorkspace_AfterDispose_ThrowsObjectDisposedException
- Surface_CompositeOverSpanWithWorkspacePerPixelColors_AfterDispose_ThrowsObjectDisposedException
- Surface_Clear_AfterDispose_ThrowsObjectDisposedException
32 changes: 32 additions & 0 deletions docs/reqstream/canvas-net/canvas/surface.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -351,3 +351,35 @@ sections:
- Surface_CompositeOverSpan_PerPixelColors_ZeroCoverage_LeavesBackgroundUnchanged
- Surface_CompositeOverSpan_PerPixelColors_LengthMismatch_ThrowsArgumentException
- Surface_CompositeOverSpanWithWorkspace_PerPixelColors_MatchesPublicOverload

- id: CanvasNet-Canvas-Surface-Disposal
title: >-
The Surface class shall implement IDisposable, logically releasing its
pixel-buffer on Dispose so that it can no longer be used, tolerating a second
call to Dispose without throwing, and rejecting any further use of a disposed
instance's buffer-touching members with ObjectDisposedException.
justification: |
Surface currently owns a plain managed byte array, but a future non-breaking
change may back it with an ArrayPool-rented array to reduce allocation pressure
in hot rendering/codec paths. Adding the IDisposable contract now - even before
any pooling is implemented - lets that optimization land later without another
breaking API change, and gives callers a single, consistent lifetime-management
pattern regardless of the internal storage strategy in effect at any given time.
tests:
- Surface_Dispose_CalledOnce_DoesNotThrow
- Surface_Dispose_CalledTwice_DoesNotThrow
- Surface_IndexerGet_AfterDispose_ThrowsObjectDisposedException
- Surface_IndexerSet_AfterDispose_ThrowsObjectDisposedException
- Surface_GetRowSpanBytes_AfterDispose_ThrowsObjectDisposedException
- Surface_GetRowSpan_AfterDispose_ThrowsObjectDisposedException
- Surface_Crop_AfterDispose_ThrowsObjectDisposedException
- Surface_PremultiplyAlpha_AfterDispose_ThrowsObjectDisposedException
- Surface_UnpremultiplyAlpha_AfterDispose_ThrowsObjectDisposedException
- Surface_CompositeOverSurface_AfterDispose_ThrowsObjectDisposedException
- Surface_CompositeOverSurface_ForegroundDisposed_ThrowsObjectDisposedException
- Surface_CompositeOverColor_AfterDispose_ThrowsObjectDisposedException
- Surface_CompositeOverSpan_AfterDispose_ThrowsObjectDisposedException
- Surface_CompositeOverSpanPerPixelColors_AfterDispose_ThrowsObjectDisposedException
- Surface_CompositeOverSpanWithWorkspace_AfterDispose_ThrowsObjectDisposedException
- Surface_CompositeOverSpanWithWorkspacePerPixelColors_AfterDispose_ThrowsObjectDisposedException
- Surface_Clear_AfterDispose_ThrowsObjectDisposedException
2 changes: 1 addition & 1 deletion docs/sysml2/model/canvas-net/canvas/surface.sysml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
package CanvasNet {
part def Surface {
doc /* Mutable, in-memory 32-bit RGBA pixel buffer providing span-based row access, independent-copy cropping, and vectorized bulk pixel operations (alpha premultiplication and Porter-Duff "over" compositing). */
doc /* Mutable, in-memory 32-bit RGBA pixel buffer providing span-based row access, independent-copy cropping, and vectorized bulk pixel operations (alpha premultiplication and Porter-Duff "over" compositing). Implements IDisposable to establish the disposal contract for its pixel buffer ahead of a future ArrayPool-backed storage change. */

comment sourceRef /* Source: src/DemaConsulting.CanvasNet/Canvas/Surface.cs, src/DemaConsulting.CanvasNet/Canvas/Rgba32.cs */
comment testRef /* Test: test/DemaConsulting.CanvasNet.Tests/Canvas/SurfaceTests.cs */
Expand Down
Loading
Loading