Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
14 changes: 7 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,9 +72,9 @@ 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
Expand Down Expand Up @@ -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
31 changes: 31 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,29 @@ 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, matching the existing
`RowChannelBuffers`/`CompositeWorkBuffers` internal helper types elsewhere in this same file,
which follow the same "flag check, set, return" idempotent-disposal shape (though those types
also have rented `ArrayPool<T>` arrays to actually return, whereas `Surface` currently does not).

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 change. 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.

### Error Handling

All validation is performed at the start of the constructor, indexer, `GetRowSpanBytes`, `Crop`,
Expand All @@ -338,6 +363,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
15 changes: 15 additions & 0 deletions docs/reqstream/canvas-net.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,21 @@ 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:
- Surface_Dispose_CalledOnce_DoesNotThrow
- Surface_IndexerGet_AfterDispose_ThrowsObjectDisposedException
Comment thread
Copilot marked this conversation as resolved.
Resolved

- id: CanvasNet-Lib-CanvasMaxDimensionPublic
title: >-
The library shall expose the maximum permitted pixel-buffer dimension as a public
Expand Down
28 changes: 28 additions & 0 deletions docs/reqstream/canvas-net/canvas.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -395,3 +395,31 @@ 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_Clear_AfterDispose_ThrowsObjectDisposedException
30 changes: 30 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,33 @@ 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_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
50 changes: 36 additions & 14 deletions docs/user_guide/introduction.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ dotnet add package DemaConsulting.CanvasNet
using DemaConsulting.CanvasNet.Canvas;
using DemaConsulting.CanvasNet.Codecs;

var surface = new Surface(4, 4);
using var surface = new Surface(4, 4);
surface[0, 0] = new Rgba32(255, 0, 0, 255); // opaque red pixel
Console.WriteLine(surface[0, 0].R); // Output: 255
```
Expand Down Expand Up @@ -83,6 +83,28 @@ is fully transparent (all channels zero) until pixels are explicitly set.
- `ArgumentOutOfRangeException`: Thrown when `width` or `height` is less than or equal to zero, or
greater than 8192.

#### Disposal

`Surface` implements `IDisposable`. In this release its pixel buffer is a plain managed array, so
`Dispose()` has nothing to actually release yet — it exists to establish the disposal contract
ahead of a future release that may back the buffer with a pooled array, without another breaking
API change. Call `Dispose()` (or wrap construction in a `using`/`using var` statement) once a
`Surface` is no longer needed; calling `Dispose()` more than once is safe and has no additional
effect.

```csharp
public void Dispose()
```

After `Dispose()` has been called, every other public member that touches the pixel buffer (the
indexer, `GetRowSpanBytes`, `GetRowSpan`, `Crop`, `PremultiplyAlpha`, `UnpremultiplyAlpha`,
`Clear`, `CompositeOver`, and `CompositeOverSpan`) throws `ObjectDisposedException`.

**Exceptions:**

- `ObjectDisposedException`: Thrown by any other public buffer-touching member once this
`Surface` has been disposed.

#### Surface Properties

##### Width
Expand Down Expand Up @@ -1170,7 +1192,7 @@ explicitly-supplied value - including the all-zero matrix - is preserved exactly
using DemaConsulting.CanvasNet.Canvas;
using DemaConsulting.CanvasNet.Codecs;

var surface = new Surface(4, 4);
using var surface = new Surface(4, 4);
surface[1, 1] = new Rgba32(255, 0, 0, 255); // opaque red pixel
var pixel = surface[1, 1];
Console.WriteLine(pixel.R); // Output: 255
Expand All @@ -1182,10 +1204,10 @@ Console.WriteLine(pixel.R); // Output: 255
using DemaConsulting.CanvasNet.Canvas;
using DemaConsulting.CanvasNet.Codecs;

var surface = new Surface(4, 4);
using var surface = new Surface(4, 4);
surface[1, 1] = new Rgba32(0, 255, 0, 255); // opaque green pixel

var cropped = surface.Crop(1, 1, 2, 2);
using var cropped = surface.Crop(1, 1, 2, 2);
Console.WriteLine(cropped.Width); // Output: 2
Console.WriteLine(cropped.Height); // Output: 2
Console.WriteLine(cropped[0, 0].G); // Output: 255
Expand All @@ -1197,7 +1219,7 @@ Console.WriteLine(cropped[0, 0].G); // Output: 255
using DemaConsulting.CanvasNet.Canvas;
using DemaConsulting.CanvasNet.Codecs;

var surface = new Surface(2, 2);
using var surface = new Surface(2, 2);
surface[0, 0] = new Rgba32(255, 0, 0, 128); // semi-transparent red pixel

// Save with alpha preserved (Bit32, the default)
Expand All @@ -1217,7 +1239,7 @@ Console.WriteLine(loaded24[0, 0].A); // Output: 255
using DemaConsulting.CanvasNet.Canvas;
using DemaConsulting.CanvasNet.Codecs;

var surface = new Surface(2, 2);
using var surface = new Surface(2, 2);
surface[0, 0] = new Rgba32(0, 255, 0, 128); // semi-transparent green pixel

// Save with alpha preserved (Rgba, the default)
Expand All @@ -1237,7 +1259,7 @@ Console.WriteLine(loadedRgb[0, 0].A); // Output: 255
using DemaConsulting.CanvasNet.Canvas;
using DemaConsulting.CanvasNet.Codecs;

var surface = new Surface(2, 2);
using var surface = new Surface(2, 2);
surface[0, 0] = new Rgba32(0, 0, 255, 128); // semi-transparent blue pixel

// Save uncompressed (the default)
Expand All @@ -1257,7 +1279,7 @@ Console.WriteLine(loadedLzw[0, 0].B); // Output: 255
using DemaConsulting.CanvasNet.Canvas;
using DemaConsulting.CanvasNet.Codecs;

var surface = new Surface(16, 16);
using var surface = new Surface(16, 16);
surface[0, 0] = new Rgba32(255, 128, 0, 255); // opaque orange pixel

// Save at quality 90 (the default)
Expand All @@ -1274,7 +1296,7 @@ Console.WriteLine(Math.Abs(loaded[0, 0].R - surface[0, 0].R) <= 15); // Output:
```csharp
using DemaConsulting.CanvasNet.Canvas;

var background = new Surface(1, 1);
using var background = new Surface(1, 1);
background[0, 0] = new Rgba32(0, 255, 0, 255); // opaque green background

// Composite a semi-transparent red overlay over the background, in place
Expand Down Expand Up @@ -1319,7 +1341,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 @@ -1340,7 +1362,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 @@ -1366,7 +1388,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 square = new PathBuilder()
.MoveTo(new Vector2(4, 4))
.LineTo(new Vector2(60, 4))
Expand Down Expand Up @@ -1437,7 +1459,7 @@ var glyphOutline = font.GetGlyphOutline(glyphIndex);
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));
Console.WriteLine(font.GetAdvanceWidth(glyphIndex));
```
Expand Down Expand Up @@ -1479,7 +1501,7 @@ using DemaConsulting.CanvasNet.Canvas;
using DemaConsulting.CanvasNet.Rendering;
using System.Numerics;

var surface = new Surface(200, 200);
using var surface = new Surface(200, 200);
var canvas = new Canvas(surface);

canvas.Save();
Expand Down
Loading
Loading