From a16d1856b672c27982b7303967df09868696d129 Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Sat, 26 Sep 2026 18:03:51 -0400 Subject: [PATCH 1/8] Add SVG marker rendering support to SvgCodec Adds support for rendering elements referenced via marker-start, marker-mid, and marker-end presentation attributes on line, polyline, polygon, and path elements. - Parse marker-start/marker-mid/marker-end as url(#id) references, added to RenderState/ApplyPresentationAttributes following the existing presentation-attribute cascade pattern. - Parse element attributes: markerWidth/markerHeight, refX/refY, markerUnits (strokeWidth default, userSpaceOnUse), orient (auto, auto-start-reverse, fixed angle), and optional viewBox with meet-fit scaling. - Compute ordered vertex lists (position + tangent + start/mid/end classification) from the already-built local-space Path data for line/polyline/polygon/path; rect/circle/ellipse never receive markers. - Compose the per-vertex marker transform (ref offset, content/units scale, orientation, vertex position) and render marker content via the existing recursive RenderElement with a fresh cascaded RenderState that does not inherit the referencing shape's fill or stroke. - Reuse existing elementDepth/totalElements/workBudget bounds for marker content, and add a dedicated markerDepth cycle guard mirroring 's MaxUseDepth mechanism so marker-to-marker reference cycles are bounded rather than causing unbounded recursion. Dangling marker references tolerantly render nothing. - Update SvgCodec class-level XML doc remarks to move marker from the ignored-construct list into the supported subset, and document the out-of-scope marker sub-features (markerContentUnits, content overflow/clipping). Adds 16 new tests covering basic marker-end rendering, combined marker-start/mid/end placement, orient=auto tangent correctness for horizontal/vertical/diagonal lines, auto-start-reverse, markerUnits scaling in both modes, viewBox-based content fitting, dangling reference tolerance, marker reference cycle detection, multi-subpath path start/end placement, shape exclusions, and marker content non-inheritance of the referencing shape's presentation state, plus a fixture-based arrowhead rendering test. Updates the SVG codec design and verification documentation and adds new reqstream requirements (with system/subsystem decomposition) for marker rendering, orientation/units handling, and reference-cycle safety. Updates the README SVG feature bullet and user guide. --- README.md | 2 +- docs/design/canvas-net/codecs/svg-codec.md | 54 +- docs/reqstream/canvas-net.yaml | 22 + docs/reqstream/canvas-net/codecs.yaml | 22 + .../canvas-net/codecs/svg-codec.yaml | 52 +- docs/user_guide/introduction.md | 10 +- .../canvas-net/codecs/svg-codec.md | 56 +- .../Codecs/SvgCodec.cs | 634 +++++++++++++++++- .../Codecs/SvgCodecTests.cs | 512 +++++++++++++- .../Codecs/SvgFixtureTests.cs | 21 + .../SvgFixtures/README.md | 1 + .../SvgFixtures/arrow-markers.svg | 9 + 12 files changed, 1359 insertions(+), 36 deletions(-) create mode 100644 test/DemaConsulting.CanvasNet.Tests/SvgFixtures/arrow-markers.svg diff --git a/README.md b/README.md index f7e322e8..9516c86a 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ image operations using `Span`, and supports independent-copy cropping for loa - 🖨️ **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; `GetInfo` reports the true frame count -- 📐 **SVG Codec** - Rasterize a common SVG subset to a surface +- 📐 **SVG Codec** - Rasterize a common SVG subset, including markers, to a surface - 🔍 **Header-Only Probing** - `GetInfo` reads headers without decoding pixels (GIF excepted) - 🖌️ **Path Filling** - Antialiased nonzero/even-odd fill of vector paths - 🖊️ **Stroke-to-Fill** - Convert stroked paths into fillable outlines diff --git a/docs/design/canvas-net/codecs/svg-codec.md b/docs/design/canvas-net/codecs/svg-codec.md index f7b29daa..42465425 100644 --- a/docs/design/canvas-net/codecs/svg-codec.md +++ b/docs/design/canvas-net/codecs/svg-codec.md @@ -40,19 +40,25 @@ same reason as the other codecs: rasterizing an SVG document has no instance sta (`pad`/`reflect`/`repeat`), and a bounded, cycle-checked `href`/`xlink:href` template inheritance chain for color stops - `use`, referencing any element by id via `href`/`xlink:href`, with `x`/`y` translation +- `marker`, referenced from `line`/`polyline`/`polygon`/`path` via the `marker-start`/ + `marker-mid`/`marker-end` presentation attributes (`url(#id)`), with `markerWidth`/ + `markerHeight`, `refX`/`refY`, `markerUnits` (`strokeWidth`/`userSpaceOnUse`), `orient` + (`auto`/`auto-start-reverse`/a fixed angle in degrees), and an optional `viewBox` - `text`, with `x`/`y`, `font-family`, `font-size`, `fill`, and `text-anchor` (`start`/`middle`/`end`), rendered through a caller-supplied font dictionary #### Out-of-scope subset (tolerated, silently skipped) -`style`, `filter`, `mask`, `clipPath`, `pattern`, `marker`, a nested `svg`, `animate`/other SMIL +`style`, `filter`, `mask`, `clipPath`, `pattern`, a nested `svg`, `animate`/other SMIL animation elements, `image`, `foreignObject`, and CSS class/id selectors are all well-formed SVG constructs this codec does not implement. Encountering one of these does not fail the whole document: `SvgCodec` silently skips just that element (and, for a container element, everything nested inside it) and continues walking the rest of the tree. This is a deliberate, tolerant-parsing policy distinct from the codec's malformed-input rejection policy (see _Error Handling_ below) — a document using an out-of-scope construct is not itself invalid SVG, -only partially outside this codec's supported feature set. +only partially outside this codec's supported feature set. Within the supported `marker` feature +itself, `markerContentUnits` (a rarely-used SVG 2 attribute) and clipping marker content to its +own `markerWidth`/`markerHeight` viewport (`overflow`) are both explicitly out of scope. A percentage value on a shape/text geometry attribute (`x`, `y`, `width`, `height`, `rx`, `ry`, `cx`, `cy`, `r`, `x1`/`y1`/`x2`/`y2`, `font-size`, `stroke-width`, `stroke-miterlimit`, @@ -69,10 +75,11 @@ attributes and gradient coordinates/`stop` `offset` are unaffected, since both h (see _Codecs Subsystem Design_, `../codecs.md`). Internally, it defines a private `RenderState` record capturing the cascading presentation state (fill, stroke, fill-opacity, stroke-opacity, opacity, fill-rule, stroke-width, stroke-linecap, stroke-linejoin, stroke-miterlimit, -stroke-dasharray, stroke-dashoffset, font-family, font-size, text-anchor) that is threaded down -through the element tree alongside an accumulated `System.Numerics.Matrix3x2` transform, plus a -private `RenderContext` capturing fixed per-document state (the id→`XElement` index, the caller's -font dictionary, and the resolved fit transform). +stroke-dasharray, stroke-dashoffset, font-family, font-size, text-anchor, and the three +marker-start/marker-mid/marker-end specifications) that is threaded down through the element tree +alongside an accumulated `System.Numerics.Matrix3x2` transform, plus a private `RenderContext` +capturing fixed per-document state (the id→`XElement` index, the caller's font dictionary, and +the resolved fit transform). ### XML Parsing Hardening (XXE) @@ -251,6 +258,41 @@ recursion depth, raising `InvalidDataException` if it is exceeded rather than re indefinitely - see **Element/Group Nesting and Total-Element Bounds** below for why this depth cap, on its own, does not bound every form of unbounded rendering work. +**Markers.** A `line`/`polyline`/`polygon`/`path` element's own `marker-start`/`marker-mid`/ +`marker-end` presentation attributes (each `url(#id)`, resolved through the same id index and +dangling-reference tolerance as a gradient `fill`/`stroke` reference above) each identify a +`marker` element rendered once per eligible vertex of that shape's already-built local-space +outline (never re-parsed from the shape's own raw attribute text): the first vertex uses +`marker-start`, the last uses `marker-end`, and every vertex between uses `marker-mid` - a +2-vertex shape therefore has a start and an end but no mid. For a multi-subpath `path`, this +whole-shape first/last classification is a deliberate, documented simplification: `marker-start`/ +`marker-end` apply only to the very first/last vertex of the _whole_ path, not to each subpath's +own start/end (matches at least one common browser's behavior, rather than a spec clause verified +directly). A vertex's `orient="auto"` rotation angle is the average of its incoming and outgoing +segment tangents (falling back to whichever one is present at an open subpath's own start/end), +except that `orient="auto-start-reverse"` adds a further 180 degrees at the shape's very first +vertex only. Each marker instance is scaled by `markerWidth`/`markerHeight` (further fitted by the +marker's own optional `viewBox`, using the same "meet" scale-down as the top-level document's +own viewBox-fitting policy, but without that policy's additional centering step - a deliberate +simplification since a marker's `refX`/`refY` already provide an equivalent anchor point), then +by the referencing shape's own effective stroke width when `markerUnits` is `strokeWidth` (the +default) or left at 1:1 for `userSpaceOnUse`, then rotated and translated to the vertex position, +and finally composed with the shape's own accumulated transform - so a marker is transformed +through exactly the same pipeline as the shape it decorates. A marker's content renders through a +fresh `RenderState` cascade seeded from the marker element's own presentation attributes (or the +SVG/CSS initial defaults if it sets none), never inheriting the referencing shape's own fill/ +stroke - per the SVG specification's independent marker-content model. Marker content renders +through the same `RenderElement` recursion used for every other element (including `use`), so the +existing element-tree-depth and total-rendered-element bounds described below apply to it +automatically; because a `marker` can reference another `marker` (directly, or through a chain, +via a child shape's own `marker-start`/`marker-mid`/`marker-end`), rendering additionally tracks +its own independent `marker`-reference recursion depth, mirroring `use`'s cycle guard exactly +(same fixed-depth-cap pattern, same `InvalidDataException` on exceeding it) but counted +separately, since a marker chain and a `use` chain are independent nesting concerns. `rect`/ +`circle`/`ellipse` never receive markers (these shapes have no natural vertices to orient one +along), and a `marker-start`/`marker-mid`/`marker-end` referencing a nonexistent id, or an id that +does not resolve to a `marker` element, is tolerated as a silent no-op for that one vertex. + #### Element/Group Nesting and Total-Element Bounds The `use`-cycle guard above only bounds recursion that passes back through a `use` element - it diff --git a/docs/reqstream/canvas-net.yaml b/docs/reqstream/canvas-net.yaml index 4653c318..71903b18 100644 --- a/docs/reqstream/canvas-net.yaml +++ b/docs/reqstream/canvas-net.yaml @@ -963,6 +963,28 @@ sections: tests: - CanvasNet_SystemIntegration_SvgLoad_ReturnsExpectedPixel + - id: CanvasNet-Lib-SvgMarkerRendering + title: >- + The library shall rasterize marker elements referenced from a line, polyline, polygon, + or path element's marker-start/marker-mid/marker-end presentation attributes, at the + correct vertex, orientation, and scale, and shall reject an unbounded + marker-referencing-marker reference cycle the same way an unbounded use reference cycle + is already rejected. + tags: [system] + justification: | + Connector arrowheads and other per-vertex line decorations built via SVG's marker + mechanism are common in real-world diagrams and flowcharts; callers need these to + render rather than silently vanish, with the same bounded, predictable error-handling + behavior already established for use reference cycles. + children: + - CanvasNet-Codecs-SvgMarkerRendering + tests: + - SvgCodec_Load_MarkerEndOnLine_RendersArrowheadPastLineEnd + - SvgCodec_Load_MarkerStartMidEndOnPolyline_RendersDistinctMarkersAtEachVertex + - SvgCodec_Load_ArrowMarkersFixture_RendersArrowheadPastLineEnd + - SvgCodec_Load_MarkerSelfReferenceCycle_ThrowsInvalidDataException + - SvgCodec_Load_MarkerTwoElementReferenceCycle_ThrowsInvalidDataException + - id: CanvasNet-Lib-SvgViewBoxFitting title: >- The library shall fit an SVG document's intrinsic viewBox into the requested raster size diff --git a/docs/reqstream/canvas-net/codecs.yaml b/docs/reqstream/canvas-net/codecs.yaml index 8a6b4dfa..85048742 100644 --- a/docs/reqstream/canvas-net/codecs.yaml +++ b/docs/reqstream/canvas-net/codecs.yaml @@ -925,6 +925,28 @@ sections: - CanvasNet_SystemIntegration_SvgLoad_ReturnsExpectedPixel - SvgCodec_Load_NonPositiveWidth_PropagatesSurfaceArgumentOutOfRangeException + - id: CanvasNet-Codecs-SvgMarkerRendering + title: >- + The Codecs subsystem shall rasterize marker elements referenced from a line, + polyline, polygon, or path element's marker-start/marker-mid/marker-end + presentation attributes, at the correct vertex, orientation, and scale, and shall + reject an unbounded marker-referencing-marker reference cycle the same way an + unbounded use reference cycle is already rejected. + justification: | + Connector arrowheads and other per-vertex line decorations built via SVG's marker + mechanism are common in real-world diagrams and flowcharts; callers need these to + render rather than silently vanish, with the same bounded, predictable + error-handling behavior already established for use reference cycles. + children: + - CanvasNet-Codecs-SvgCodec-MarkerRendering + - CanvasNet-Codecs-SvgCodec-MarkerReferenceCycle + tests: + - SvgCodec_Load_MarkerEndOnLine_RendersArrowheadPastLineEnd + - SvgCodec_Load_MarkerStartMidEndOnPolyline_RendersDistinctMarkersAtEachVertex + - SvgCodec_Load_ArrowMarkersFixture_RendersArrowheadPastLineEnd + - SvgCodec_Load_MarkerSelfReferenceCycle_ThrowsInvalidDataException + - SvgCodec_Load_MarkerTwoElementReferenceCycle_ThrowsInvalidDataException + - id: CanvasNet-Codecs-SvgViewBoxFitting title: >- The Codecs subsystem shall fit an SVG document's intrinsic viewBox into the diff --git a/docs/reqstream/canvas-net/codecs/svg-codec.yaml b/docs/reqstream/canvas-net/codecs/svg-codec.yaml index 031ff37f..31023ae9 100644 --- a/docs/reqstream/canvas-net/codecs/svg-codec.yaml +++ b/docs/reqstream/canvas-net/codecs/svg-codec.yaml @@ -154,6 +154,56 @@ sections: - SvgCodec_Load_UseElementMutualRecursionCycle_ThrowsInvalidDataException - SvgCodec_Load_UseReferenceFixture_RendersAtOffsetPositionOnly + - id: CanvasNet-Codecs-SvgCodec-MarkerRendering + title: >- + The SvgCodec class shall render a marker element referenced via a line, + polyline, polygon, or path element's marker-start/marker-mid/marker-end + presentation attributes at that shape's first/interior/last vertex + respectively, honoring markerWidth/markerHeight, refX/refY, markerUnits + (strokeWidth or userSpaceOnUse), orient (auto, auto-start-reverse, or a fixed + angle in degrees), and an optional viewBox, while never rendering a marker for + rect/circle/ellipse and never inheriting the referencing shape's own + fill/stroke into the marker's own content. + justification: | + Markers are the standard SVG mechanism for arrowheads and other per-vertex + decorations on connector lines and paths; callers building diagrams, + flowcharts, or annotated line art need these to render rather than silently + vanish, with correct per-vertex placement, orientation, sizing, and an + independent presentation-attribute cascade matching the SVG specification's + marker-content model. + tests: + - SvgCodec_Load_MarkerEndOnLine_RendersArrowheadPastLineEnd + - SvgCodec_Load_MarkerStartMidEndOnPolyline_RendersDistinctMarkersAtEachVertex + - SvgCodec_Load_MarkerOrientAutoOnHorizontalLine_OrientsAlongPositiveX + - SvgCodec_Load_MarkerOrientAutoOnVerticalLine_OrientsAlongPositiveY + - SvgCodec_Load_MarkerOrientAutoOnDiagonalLine_OrientsAlong45Degrees + - SvgCodec_Load_MarkerStartOrientAuto_PointsIntoLine + - SvgCodec_Load_MarkerStartOrientAutoStartReverse_PointsAwayFromLine + - SvgCodec_Load_MarkerUnitsUserSpaceOnUseVsStrokeWidthDefault_ScalesDifferently + - SvgCodec_Load_MarkerWithViewBox_FitsContentToMarkerWidthHeight + - SvgCodec_Load_MarkerOnMultiSubpathPath_AppliesStartEndOnlyAtWholePathEnds + - SvgCodec_Load_MarkerOnRectCircleEllipse_NeverRendersMarker + - SvgCodec_Load_MarkerContent_DoesNotInheritReferencingShapeFillOrStroke + - SvgCodec_Load_ArrowMarkersFixture_RendersArrowheadPastLineEnd + + - id: CanvasNet-Codecs-SvgCodec-MarkerReferenceCycle + title: >- + The SvgCodec class shall tolerate a marker-start/marker-mid/marker-end + reference to a nonexistent id as a silent no-op, and shall reject an unbounded + marker-referencing-marker reference cycle (directly, or through a chain) with + InvalidDataException, mirroring the use element's own recursion-guard + behavior via an independent recursion-depth counter. + justification: | + A marker's own content can itself contain shapes with further marker + references, so an unbounded or cyclic marker reference chain must be rejected + the same way an unbounded use reference chain already is, rather than + recursing indefinitely; a dangling reference (a common authoring mistake) must + not abort rendering of the rest of the document. + tests: + - SvgCodec_Load_MarkerDanglingReference_IsSilentNoOp + - SvgCodec_Load_MarkerSelfReferenceCycle_ThrowsInvalidDataException + - SvgCodec_Load_MarkerTwoElementReferenceCycle_ThrowsInvalidDataException + - id: CanvasNet-Codecs-SvgCodec-ElementNestingDepthLimit title: >- The SvgCodec class shall reject an SVG document whose element tree - including @@ -403,7 +453,7 @@ sections: - id: CanvasNet-Codecs-SvgCodec-UnsupportedConstructsIgnored title: >- The SvgCodec class shall silently skip well-formed but out-of-scope elements - (including style, filter, mask, clipPath, pattern, marker, a nested svg, + (including style, filter, mask, clipPath, pattern, a nested svg, animate/SMIL, image, and foreignObject), continuing to render the rest of the document. justification: | diff --git a/docs/user_guide/introduction.md b/docs/user_guide/introduction.md index c8e4d62a..d09c0586 100644 --- a/docs/user_guide/introduction.md +++ b/docs/user_guide/introduction.md @@ -889,12 +889,14 @@ The `SvgCodec` static class decodes and rasterizes a common real-world subset of into a `Surface` of caller-chosen pixel dimensions. `SvgCodec` is decode-only: there is no `Save`. It supports basic shapes (`rect`, `circle`, `ellipse`, `line`, `polyline`, `polygon`, `path`), grouping (`g`) with cascading presentation attributes, `transform` functions, linear/radial -gradients (including `xlink:href`/`href` template inheritance), `use` references, and best-effort -`text` rendering against a caller-supplied dictionary of `TrueTypeFont` instances. The root -`viewBox`/`width`/`height` are fit into the requested raster using a "meet, centered" policy +gradients (including `xlink:href`/`href` template inheritance), `use` references, `marker` +elements (referenced via `marker-start`/`marker-mid`/`marker-end`, with `markerWidth`/ +`markerHeight`, `refX`/`refY`, `markerUnits`, `orient`, and an optional `viewBox`), and +best-effort `text` rendering against a caller-supplied dictionary of `TrueTypeFont` instances. The +root `viewBox`/`width`/`height` are fit into the requested raster using a "meet, centered" policy equivalent to CSS `object-fit: contain` (`preserveAspectRatio` itself is not read). Well-formed but out-of-scope constructs (`style`, `filter`, `mask`, `clipPath`, `animate`/SMIL, `image`, -`foreignObject`, `pattern`, `marker`, nested `svg`, CSS selectors) are silently skipped so the rest +`foreignObject`, `pattern`, nested `svg`, CSS selectors) are silently skipped so the rest of the document still renders; malformed/unparseable input throws `InvalidDataException`. #### SvgCodec Methods diff --git a/docs/verification/canvas-net/codecs/svg-codec.md b/docs/verification/canvas-net/codecs/svg-codec.md index 6e310fee..8f8736ea 100644 --- a/docs/verification/canvas-net/codecs/svg-codec.md +++ b/docs/verification/canvas-net/codecs/svg-codec.md @@ -173,6 +173,60 @@ does not throw; a `use` referencing a `g` group renders every one of the group's mutually-recursive `use`/`use` reference chain that would otherwise recurse indefinitely is rejected with `InvalidDataException` once the bounded recursion guard is exceeded. +#### CanvasNet-Codecs-SvgCodec-MarkerRendering: Marker Vertex Placement, Orientation, and Units + +**Tests**: `SvgCodec_Load_MarkerEndOnLine_RendersArrowheadPastLineEnd`, +`SvgCodec_Load_MarkerStartMidEndOnPolyline_RendersDistinctMarkersAtEachVertex`, +`SvgCodec_Load_MarkerOrientAutoOnHorizontalLine_OrientsAlongPositiveX`, +`SvgCodec_Load_MarkerOrientAutoOnVerticalLine_OrientsAlongPositiveY`, +`SvgCodec_Load_MarkerOrientAutoOnDiagonalLine_OrientsAlong45Degrees`, +`SvgCodec_Load_MarkerStartOrientAuto_PointsIntoLine`, +`SvgCodec_Load_MarkerStartOrientAutoStartReverse_PointsAwayFromLine`, +`SvgCodec_Load_MarkerUnitsUserSpaceOnUseVsStrokeWidthDefault_ScalesDifferently`, +`SvgCodec_Load_MarkerWithViewBox_FitsContentToMarkerWidthHeight`, +`SvgCodec_Load_MarkerOnMultiSubpathPath_AppliesStartEndOnlyAtWholePathEnds`, +`SvgCodec_Load_MarkerOnRectCircleEllipse_NeverRendersMarker`, +`SvgCodec_Load_MarkerContent_DoesNotInheritReferencingShapeFillOrStroke`, +`SvgCodec_Load_ArrowMarkersFixture_RendersArrowheadPastLineEnd` + +Asserts a `marker-end` reference renders its `marker` element's content past a `line`'s own end +point, sized in user-space units; asserts `marker-start`/`marker-mid`/`marker-end` each +independently resolve and render their own distinct marker at the correct vertex of a +multi-vertex `polyline`, with each marker's own reference point (`refX`/`refY`) landing exactly on +its vertex regardless of rotation; asserts `orient="auto"` orients a marker along a horizontal, +vertical, and 45-degree diagonal segment's own direction of travel (the diagonal case uses an +alpha threshold rather than exact full opacity, tolerating this rasterizer's edge anti-aliasing on +a thin, diagonally rotated shape); asserts a plain `orient="auto"` `marker-start` points into the +line's own body while `orient="auto-start-reverse"` reverses it by 180 degrees to point away from +the line instead; asserts `markerUnits="userSpaceOnUse"` keeps a marker's size independent of the +referencing shape's effective stroke width while the default `markerUnits="strokeWidth"` scales it +proportionally; asserts a marker's own `viewBox` is fitted ("meet" scale-down) into +`markerWidth`/`markerHeight` rather than rendering at its raw, unfitted size; asserts a +multi-subpath `path`'s `marker-start`/`marker-end` apply only to the very first/last vertex of the +whole path, not to each subpath's own start/end, with the interior subpath-boundary vertices +correctly classified as "mid" instead; asserts `rect`/`circle`/`ellipse` never render a marker +even when a `marker-end` attribute is present, since these shapes have no natural vertices to +orient one along; and asserts a marker's own content renders with a fresh presentation-attribute +cascade from the SVG/CSS initial defaults, not inheriting the referencing shape's own +`fill`/`stroke`. A real fixture file (`SvgFixtures/arrow-markers.svg`) exercises the common +`marker-end` arrowhead scenario end-to-end. + +#### CanvasNet-Codecs-SvgCodec-MarkerReferenceCycle: Marker Reference Dangling and Cycle Rejection + +**Tests**: `SvgCodec_Load_MarkerDanglingReference_IsSilentNoOp`, +`SvgCodec_Load_MarkerSelfReferenceCycle_ThrowsInvalidDataException`, +`SvgCodec_Load_MarkerTwoElementReferenceCycle_ThrowsInvalidDataException` + +Asserts a `marker-end` reference to a nonexistent id is tolerated as a silent no-op (the +referencing shape still renders normally, and no marker content appears), matching this codec's +general dangling-reference convention; asserts a `marker` element whose own content directly +references itself (via `marker-start` on a child shape) is rejected with `InvalidDataException` +once the bounded `marker`-reference recursion depth guard is exceeded, rather than recursing +indefinitely; and asserts a two-marker reference chain (`m1` referencing `m2` referencing `m1`) is +likewise rejected, not only a direct self-reference. This mirrors +`CanvasNet-Codecs-SvgCodec-UseElement`'s identical `InvalidDataException` cycle-rejection +behavior for `use`, applied to `marker`'s own independent recursion-depth counter. + #### CanvasNet-Codecs-SvgCodec-ElementNestingDepthLimit: Element/Group Nesting Depth Limit **Tests**: `SvgCodec_Load_DeeplyNestedGroups_ThrowsInvalidDataException` @@ -461,7 +515,7 @@ opacity-family attributes and gradient coordinates remain correctly unaffected b `SvgCodec_Load_ToleratesUnsupportedConstructFixture_StillRendersRemainingContent`, `SvgCodec_Load_InkscapeFiltersFixture_ToleratesFiltersAndRendersFlowerContent` -Builds a document containing `style`, `filter`, `mask`, `clipPath`, `pattern`, `marker`, and a +Builds a document containing `style`, `filter`, `mask`, `clipPath`, `pattern`, and a nested `svg` alongside an ordinary `rect`, and asserts the ordinary `rect` still renders — proving none of the out-of-scope elements abort the whole document. A real fixture file exercises the same property end-to-end. diff --git a/src/DemaConsulting.CanvasNet/Codecs/SvgCodec.cs b/src/DemaConsulting.CanvasNet/Codecs/SvgCodec.cs index 15270cfa..25e5e349 100644 --- a/src/DemaConsulting.CanvasNet/Codecs/SvgCodec.cs +++ b/src/DemaConsulting.CanvasNet/Codecs/SvgCodec.cs @@ -58,20 +58,33 @@ namespace DemaConsulting.CanvasNet.Codecs; /// radialGradient (with stop children, gradientUnits, /// gradientTransform, spreadMethod, and a single linear href/ /// xlink:href template-inheritance chain, cycle-checked); use (with -/// x/y translation); and text (with font-family best-effort +/// x/y translation); marker (referenced from a line/ +/// polyline/polygon/path's marker-start/marker-mid/ +/// marker-end presentation attributes via url(#id); supports +/// markerWidth/markerHeight/refX/refY/markerUnits +/// (strokeWidth or userSpaceOnUse)/orient (auto, +/// auto-start-reverse, or a fixed angle in degrees) and an optional viewBox +/// fitted with the same "meet, centered" policy described below; marker content renders with +/// its own fresh presentation-attribute cascade, never the referencing shape's fill/stroke - +/// see this class's RenderMarkers/RenderOneMarker remarks for the documented +/// vertex-placement, orientation-averaging, and multi-subpath simplifications); and +/// text (with font-family best-effort /// matching against a caller-supplied font dictionary, font-size, fill, and /// text-anchor). /// /// /// Out of scope (silently ignored, per element). style blocks and CSS /// class/id selectors, filter, mask, clipPath, pattern, -/// marker, SMIL animation (animate/animateTransform/animateMotion/ +/// SMIL animation (animate/animateTransform/animateMotion/ /// animateColor/set), image, foreignObject, nested svg, and /// an inline style="..." presentation attribute are all well-formed-but-unsupported /// constructs: encountering one never aborts the document, it is simply skipped, and every /// other element continues to render normally. The preserveAspectRatio attribute is /// never read - see this class's viewBox-fitting remarks below for the one fitting policy this -/// codec always applies instead. +/// codec always applies instead. Within the supported marker feature itself, +/// markerContentUnits (a rarely-used SVG 2 attribute) is not read, and a marker's +/// overflow/clipping-to-its-own-viewport behavior is not implemented (marker content is +/// never clipped to markerWidth/markerHeight) - both explicitly out of scope. /// /// /// ViewBox fitting. @@ -155,6 +168,21 @@ public static class SvgCodec /// private const int MaxUseDepth = 32; + /// + /// The maximum number of nested marker references this codec follows before giving + /// up, guarding against a reference cycle (a marker whose own content references + /// itself, directly or indirectly, via marker-start/marker-mid/ + /// marker-end) that would otherwise recurse indefinitely. Mirrors + /// 's value and rationale: a marker cycle is structurally the + /// same kind of id-resolved recursive re-entry into as a + /// use cycle, so it is guarded the same way, with its own independent counter + /// rather than sharing or relying solely on + /// (a marker reference is not a use reference, and + /// conflating the two budgets would make an unrelated document's use nesting depth + /// affect how many marker references a separate part of the same document may chain). + /// + private const int MaxMarkerDepth = 32; + /// /// The maximum recursion depth this codec descends through /// while walking the element tree - covering plain g/symbol nesting as well @@ -876,6 +904,19 @@ private enum TextAnchor /// The font-family value, or if never set. /// The font-size value, in local user-space units. /// The text-anchor value. + /// + /// The raw marker-start paint-like specification (none or url(#id)), + /// naming the marker element rendered at a line/polyline/ + /// polygon/path's first vertex - see this class's marker-rendering remarks. + /// + /// + /// The raw marker-mid specification, in the same form as , + /// applied to every vertex strictly between the first and last. + /// + /// + /// The raw marker-end specification, in the same form as , + /// applied to the last vertex. + /// private sealed record RenderState( string Fill, string Stroke, @@ -891,7 +932,10 @@ private sealed record RenderState( float StrokeDashOffset, string? FontFamily, float FontSize, - TextAnchor TextAnchor) + TextAnchor TextAnchor, + string MarkerStart, + string MarkerMid, + string MarkerEnd) { /// /// The default render state every document starts with, matching the SVG/CSS initial @@ -912,7 +956,10 @@ private sealed record RenderState( StrokeDashOffset: 0f, FontFamily: null, FontSize: 16f, - TextAnchor: TextAnchor.Start); + TextAnchor: TextAnchor.Start, + MarkerStart: "none", + MarkerMid: "none", + MarkerEnd: "none"); } /// @@ -983,7 +1030,7 @@ private static void RenderDocument(XElement root, Matrix3x2 fitTransform, Render var workBudget = new GeometryWorkBudget(); foreach (var child in root.Elements()) { - RenderElement(child, rootState, fitTransform, context, useDepth: 0, elementDepth: 0, ref totalElements, workBudget); + RenderElement(child, rootState, fitTransform, context, useDepth: 0, elementDepth: 0, markerDepth: 0, ref totalElements, workBudget); } } @@ -1006,6 +1053,11 @@ private static void RenderDocument(XElement root, Matrix3x2 fitTransform, Render /// regardless of whether the recursion arises from plain g/symbol nesting or /// from a use reference. /// + /// + /// The current marker-reference nesting depth, propagated so + /// can enforce . Not incremented by plain g/symbol + /// nesting or by a use reference - only by rendering one marker's own content. + /// /// /// The running count of elements rendered/visited so far across the whole document walk, /// charged before this element is processed further so this method can enforce @@ -1058,6 +1110,7 @@ private static void RenderElement( RenderContext context, int useDepth, int elementDepth, + int markerDepth, ref int totalElements, GeometryWorkBudget workBudget) { @@ -1109,7 +1162,7 @@ private static void RenderElement( // renders when referenced via ) foreach (var child in element.Elements()) { - RenderElement(child, state, transform, context, useDepth, elementDepth + 1, ref totalElements, workBudget); + RenderElement(child, state, transform, context, useDepth, elementDepth + 1, markerDepth, ref totalElements, workBudget); } break; @@ -1127,23 +1180,39 @@ private static void RenderElement( break; case "line": - RenderShape(BuildLinePath(element), state, transform, context); - break; + { + var linePath = BuildLinePath(element); + RenderShape(linePath, state, transform, context); + RenderMarkers(linePath, state, transform, context, useDepth, elementDepth, markerDepth, ref totalElements, workBudget); + break; + } case "polyline": - RenderShape(BuildPolyPath(element, closed: false, workBudget), state, transform, context); - break; + { + var polylinePath = BuildPolyPath(element, closed: false, workBudget); + RenderShape(polylinePath, state, transform, context); + RenderMarkers(polylinePath, state, transform, context, useDepth, elementDepth, markerDepth, ref totalElements, workBudget); + break; + } case "polygon": - RenderShape(BuildPolyPath(element, closed: true, workBudget), state, transform, context); - break; + { + var polygonPath = BuildPolyPath(element, closed: true, workBudget); + RenderShape(polygonPath, state, transform, context); + RenderMarkers(polygonPath, state, transform, context, useDepth, elementDepth, markerDepth, ref totalElements, workBudget); + break; + } case "path": - RenderShape(BuildPathDataPath(element, workBudget), state, transform, context); - break; + { + var dataPath = BuildPathDataPath(element, workBudget); + RenderShape(dataPath, state, transform, context); + RenderMarkers(dataPath, state, transform, context, useDepth, elementDepth, markerDepth, ref totalElements, workBudget); + break; + } case "use": - RenderUse(element, state, transform, context, useDepth, elementDepth, ref totalElements, workBudget); + RenderUse(element, state, transform, context, useDepth, elementDepth, markerDepth, ref totalElements, workBudget); break; case "text": @@ -1191,7 +1260,10 @@ _ when string.Equals(dashArrayAttr.Trim(), "none", StringComparison.OrdinalIgnor StrokeDashOffset = GetOptionalFloat(element, "stroke-dashoffset") ?? parent.StrokeDashOffset, FontFamily = (string?)element.Attribute("font-family") ?? parent.FontFamily, FontSize = GetOptionalFloat(element, "font-size") ?? parent.FontSize, - TextAnchor = ParseTextAnchor((string?)element.Attribute("text-anchor")) ?? parent.TextAnchor + TextAnchor = ParseTextAnchor((string?)element.Attribute("text-anchor")) ?? parent.TextAnchor, + MarkerStart = (string?)element.Attribute("marker-start") ?? parent.MarkerStart, + MarkerMid = (string?)element.Attribute("marker-mid") ?? parent.MarkerMid, + MarkerEnd = (string?)element.Attribute("marker-end") ?? parent.MarkerEnd }; } @@ -2681,7 +2753,7 @@ private static void RenderFill(Path localPath, Path pixelPath, RenderState state private static void RenderStroke(Path localPath, Path pixelPath, RenderState state, Matrix3x2 transform, RenderContext context) { var scale = EstimateUniformScale(transform); - var strokeWidth = state.StrokeWidth * scale; + var strokeWidth = EstimateEffectiveStrokeWidth(state.StrokeWidth, scale); if (!float.IsFinite(strokeWidth) || strokeWidth <= 0f || strokeWidth > MaxCoordinateMagnitude) { return; @@ -2780,6 +2852,27 @@ private static void FillWithPaint(Surface surface, Path path, object? paint, Fil private static float EstimateUniformScale(Matrix3x2 transform) => MathF.Sqrt(MathF.Abs((transform.M11 * transform.M22) - (transform.M12 * transform.M21))); + /// + /// Scales a local-space stroke-width into its effective pixel-space width, shared by + /// (to size the actual stroke outline) and + /// (to size a markerUnits="strokeWidth" marker, the SVG + /// default) - extracted so both call sites compute this one expression identically rather + /// than duplicating it, per this codebase's no-copy-paste coding standard. + /// + /// The local-space stroke-width value. + /// + /// The isotropic scale factor estimated by for the + /// accumulated transform in effect. + /// + /// + /// The effective pixel-space stroke width. Can be non-finite, non-positive, or extremely + /// large if is itself extreme - each caller is responsible + /// for its own tolerant-skip validation of the result, matching 's + /// existing guard. + /// + private static float EstimateEffectiveStrokeWidth(float strokeWidth, float uniformScale) => + strokeWidth * uniformScale; + /// Scales every entry of a dash array by . /// The local-space dash array, or for a solid stroke. /// The local-to-pixel-space scale factor. @@ -3521,6 +3614,12 @@ private static RadialGradient BuildRadialGradient( /// propagated to the re-rendered target so it also contributes toward /// . /// + /// + /// The current marker-reference nesting depth, propagated unchanged to the + /// re-rendered target - a use reference is not itself a marker reference, but a + /// marker reference reached inside the re-rendered target must still contribute + /// toward . + /// /// /// The running total-rendered-elements count, propagated to the re-rendered target so it /// also contributes toward . @@ -3535,7 +3634,7 @@ private static RadialGradient BuildRadialGradient( /// no-op (nothing is rendered), consistent with this class's general dangling-reference /// handling elsewhere. /// - private static void RenderUse(XElement element, RenderState state, Matrix3x2 transform, RenderContext context, int useDepth, int elementDepth, ref int totalElements, GeometryWorkBudget workBudget) + private static void RenderUse(XElement element, RenderState state, Matrix3x2 transform, RenderContext context, int useDepth, int elementDepth, int markerDepth, ref int totalElements, GeometryWorkBudget workBudget) { if (useDepth >= MaxUseDepth) { @@ -3550,7 +3649,502 @@ private static void RenderUse(XElement element, RenderState state, Matrix3x2 tra var offset = new Vector2(GetFloatAttribute(element, "x"), GetFloatAttribute(element, "y")); var useTransform = Matrix3x2.CreateTranslation(offset) * transform; - RenderElement(target, state, useTransform, context, useDepth + 1, elementDepth + 1, ref totalElements, workBudget); + RenderElement(target, state, useTransform, context, useDepth + 1, elementDepth + 1, markerDepth, ref totalElements, workBudget); + } + + // ================================================================================================ + // "marker" element handling + // ================================================================================================ + + /// + /// One vertex of a line/polyline/polygon/path's local-space + /// outline eligible to receive a marker-start/marker-mid/marker-end + /// marker, together with the unit tangent direction(s) of the segment(s) meeting at it - + /// used to compute an orient="auto" marker's rotation angle (see + /// ). + /// + /// + /// Either tangent is at an open subpath's first (no incoming + /// segment) or last (no outgoing segment) vertex, or when the adjacent segment itself + /// degenerates to a zero-length direction (for example a repeated coordinate) - see + /// . + /// + private readonly struct MarkerVertex(Vector2 position, Vector2? incomingTangent, Vector2? outgoingTangent) + { + /// The vertex's position, in the shape's own local (untransformed) space. + public Vector2 Position { get; } = position; + + /// The unit direction of the segment arriving at this vertex, or if none. + public Vector2? IncomingTangent { get; } = incomingTangent; + + /// The unit direction of the segment leaving this vertex, or if none. + public Vector2? OutgoingTangent { get; } = outgoingTangent; + + /// Returns a copy of this vertex with replaced. + /// The new outgoing tangent. + public MarkerVertex WithOutgoingTangent(Vector2? outgoingTangent) => new(Position, IncomingTangent, outgoingTangent); + } + + /// + /// Classifies a marker-eligible vertex's position within its shape's whole, flattened + /// (whole-document-order, not per-subpath) vertex sequence, selecting which of + /// marker-start/marker-mid/marker-end applies to it. + /// + private enum MarkerVertexRole + { + /// The very first vertex of the whole shape - uses marker-start. + Start, + + /// Every vertex strictly between the first and last - uses marker-mid. + Mid, + + /// The very last vertex of the whole shape - uses marker-end. + End + } + + /// + /// Builds the ordered, whole-shape list of marker-eligible vertices (and their tangents) + /// from a line/polyline/polygon/path's already-built + /// local-space outline, for to place markers along. + /// + /// + /// The shape's local-space outline, exactly as returned by / + /// / - reused directly, never + /// re-parsed from the shape's own raw attribute text. + /// + /// + /// The vertices in whole-path document order: every subpath's start point, followed by + /// every one of its non-Close commands' end points, subpaths concatenated in the + /// order they appear in . A multi-subpath path's + /// marker-start/marker-end therefore apply only to the very first/last + /// vertex of the whole path, not per-subpath - a deliberate, documented simplification + /// (matches at least one common browser's behavior; the SVG specification's own wording + /// on this point is not unambiguous across implementations) rather than a stricter + /// per-subpath interpretation. A Close command contributes no new vertex (it always + /// returns to the subpath's own , already recorded) and, as a + /// further documented simplification, no orientation tangent of its own either. + /// + private static List BuildMarkerVertices(Path localPath) + { + var vertices = new List(); + foreach (var subpath in localPath.Subpaths) + { + vertices.Add(new MarkerVertex(subpath.Start, incomingTangent: null, outgoingTangent: null)); + var current = subpath.Start; + + foreach (var command in subpath.Commands) + { + if (command.Type == PathCommandType.Close) + { + // Returns to Start (already recorded above) without introducing a new vertex + // or a tangent of its own - see this method's documented simplification + current = subpath.Start; + continue; + } + + var (outgoing, incoming) = ComputeCommandTangents(command, current); + + // Fold this command's outgoing tangent into the vertex it starts from (the + // previously-added vertex, whether that was the subpath's own Start or a prior + // command's end point) + var previousIndex = vertices.Count - 1; + vertices[previousIndex] = vertices[previousIndex].WithOutgoingTangent(outgoing); + + vertices.Add(new MarkerVertex(command.EndPoint, incoming, outgoingTangent: null)); + current = command.EndPoint; + } + } + + return vertices; + } + + /// + /// Computes one path command's outgoing (leaving its start point) and incoming (arriving + /// at its end point) unit tangent directions, per this class's documented per-command-type + /// rules. + /// + /// The command to inspect - a , , or . + /// The command's start point (the previous vertex's position). + /// + /// The outgoing/incoming unit tangents, or for either when the + /// relevant control points/endpoints are coincident (a zero-length direction has no + /// meaningful tangent). + /// + /// + /// is deliberately not one of this method's cases: + /// every one of this class's own shape builders (, + /// , and 's own arc handling) + /// converts an SVG arc to cubic Bezier segments immediately, via / + /// , before ever building a - + /// confirmed directly from this codec's own source, not merely assumed - so an + /// command never actually appears in a local-space + /// this method is called against. The default case below still + /// handles it (and , though that is filtered out by + /// before reaching here) defensively, returning "no + /// tangent" rather than throwing, so a future change elsewhere in this class that ever did + /// produce one would degrade to an un-oriented marker rather than an uncaught exception. + /// + private static (Vector2? Outgoing, Vector2? Incoming) ComputeCommandTangents(PathCommand command, Vector2 start) + { + switch (command.Type) + { + case PathCommandType.LineTo: + var lineDirection = NormalizeOrNull(command.EndPoint - start); + return (lineDirection, lineDirection); + + case PathCommandType.QuadraticBezierTo: + var outgoingQuad = NormalizeOrNull(command.Control1 - start) + ?? NormalizeOrNull(command.EndPoint - start); + var incomingQuad = NormalizeOrNull(command.EndPoint - command.Control1) + ?? NormalizeOrNull(command.EndPoint - start); + return (outgoingQuad, incomingQuad); + + case PathCommandType.CubicBezierTo: + var outgoingCubic = NormalizeOrNull(command.Control1 - start) + ?? NormalizeOrNull(command.Control2 - start) + ?? NormalizeOrNull(command.EndPoint - start); + var incomingCubic = NormalizeOrNull(command.EndPoint - command.Control2) + ?? NormalizeOrNull(command.EndPoint - command.Control1) + ?? NormalizeOrNull(command.EndPoint - start); + return (outgoingCubic, incomingCubic); + + default: + return (null, null); + } + } + + /// Normalizes , tolerating a zero-length or non-finite result. + /// The vector to normalize. + /// + /// The unit-length direction, or if 's + /// length is zero, subnormal-to-zero, or non-finite (a degenerate direction has no + /// meaningful orientation to contribute). + /// + private static Vector2? NormalizeOrNull(Vector2 vector) + { + var lengthSquared = vector.LengthSquared(); + return float.IsFinite(lengthSquared) && lengthSquared > float.Epsilon + ? Vector2.Normalize(vector) + : null; + } + + /// + /// Computes an orient="auto" marker's rotation angle at , + /// per the SVG averaging rule: the average of the incoming and outgoing tangents when both + /// are present, falling back to whichever single tangent is present, or 0 degrees + /// for a fully degenerate (zero-length) vertex. + /// + /// The vertex to orient a marker at. + /// The computed angle, in degrees, suitable for via . + private static float ComputeVertexAngleDegrees(MarkerVertex vertex) + { + Vector2? direction; + if (vertex.IncomingTangent.HasValue && vertex.OutgoingTangent.HasValue) + { + // Average the two tangents; if they nearly cancel (a near-180-degree reversal), fall + // back to just the outgoing tangent (or the incoming one, if outgoing is somehow also + // absent) rather than an undefined zero-length average + var sum = vertex.IncomingTangent.Value + vertex.OutgoingTangent.Value; + direction = NormalizeOrNull(sum) ?? vertex.OutgoingTangent ?? vertex.IncomingTangent; + } + else + { + direction = vertex.OutgoingTangent ?? vertex.IncomingTangent; + } + + return direction.HasValue + ? MathF.Atan2(direction.Value.Y, direction.Value.X) * (180f / MathF.PI) + : 0f; + } + + /// + /// Renders every marker attached to a marker-eligible shape (line/polyline/ + /// polygon/path), one per vertex whose corresponding + /// marker-start/marker-mid/marker-end spec is not none and + /// resolves to an actual marker element. + /// + /// The shape's local-space outline (see ). + /// The shape's own cascaded render state, supplying the three marker specs and effective stroke width. + /// The shape's own accumulated transform - every marker instance is transformed through this same transform. + /// The fixed per-document render context. + /// The current use-reference nesting depth, propagated unchanged to each marker's content. + /// The current element-tree recursion depth, propagated to each marker's content. + /// The current marker-reference nesting depth, propagated to each marker's content. + /// The running total-rendered-elements count. + /// The shared geometry-parsing work budget. + /// + /// Never called for rect/circle/ellipse - those shapes have no + /// natural vertices to orient a marker along, per the SVG specification, and this class's + /// dispatch simply never routes them through this method. + /// Cheaply no-ops (before building any vertex list) when every one of + /// 's three marker specs is "none" - the overwhelming + /// majority of real-world shapes - so ordinary marker-free rendering pays only one string + /// comparison per marker-eligible element. + /// + private static void RenderMarkers( + Path localPath, + RenderState state, + Matrix3x2 transform, + RenderContext context, + int useDepth, + int elementDepth, + int markerDepth, + ref int totalElements, + GeometryWorkBudget workBudget) + { + if (state.MarkerStart == "none" && state.MarkerMid == "none" && state.MarkerEnd == "none") + { + return; + } + + var vertices = BuildMarkerVertices(localPath); + if (vertices.Count < 2) + { + // A single-vertex (or empty) shape has no segment to orient a marker along - no + // start, mid, or end marker applies, per the SVG specification + return; + } + + var effectiveStrokeWidth = EstimateEffectiveStrokeWidth(state.StrokeWidth, EstimateUniformScale(transform)); + + for (var i = 0; i < vertices.Count; i++) + { + MarkerVertexRole role; + if (i == 0) + { + role = MarkerVertexRole.Start; + } + else if (i == vertices.Count - 1) + { + role = MarkerVertexRole.End; + } + else + { + role = MarkerVertexRole.Mid; + } + + var spec = role switch + { + MarkerVertexRole.Start => state.MarkerStart, + MarkerVertexRole.End => state.MarkerEnd, + _ => state.MarkerMid + }; + + if (string.Equals(spec.Trim(), "none", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + var markerElement = ResolveMarkerElement(spec, context); + if (markerElement == null) + { + continue; + } + + var vertex = vertices[i]; + var angleDegrees = ComputeVertexAngleDegrees(vertex); + RenderOneMarker( + markerElement, + vertex.Position, + angleDegrees, + isStartVertex: role == MarkerVertexRole.Start, + effectiveStrokeWidth, + transform, + context, + useDepth, + elementDepth, + markerDepth, + ref totalElements, + workBudget); + } + } + + /// + /// Resolves a raw marker-start/marker-mid/marker-end specification + /// (url(#id)) to its referenced marker element, reusing the exact same + /// url(#id)-parsing and dangling-reference tolerance as . + /// + /// The raw, already-known-non-"none" marker specification. + /// The fixed per-document render context. + /// + /// The referenced marker element, or if + /// is not url(#id) syntax, the id is dangling (no matching element), or the + /// resolved element is not literally a marker (a url(#id) mistakenly + /// pointing at an unrelated element, such as a rect, is tolerated the same way). + /// + private static XElement? ResolveMarkerElement(string spec, RenderContext context) + { + var trimmed = spec.Trim(); + if (!trimmed.StartsWith("url(", StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + var id = ExtractUrlId(trimmed); + if (id == null || !context.IdIndex.TryGetValue(id, out var element)) + { + return null; + } + + return element.Name.LocalName == "marker" ? element : null; + } + + /// + /// Renders one resolved marker element's content at one shape vertex: computes the + /// marker's own refX/refY/markerWidth/markerHeight/ + /// markerUnits/orient/viewBox transform, composes it with + /// , then re-enters for each + /// of the marker's own children with a fresh cascade seeded from the marker element itself + /// (never inheriting 's own fill/stroke state). + /// + /// The resolved marker element. + /// The vertex's position, in the referencing shape's own local space. + /// + /// The vertex's own computed tangent angle (see ), + /// used when orient is auto/auto-start-reverse/absent. + /// + /// + /// Whether this vertex is the referencing shape's very first vertex - needed only to + /// resolve orient="auto-start-reverse", which reverses by 180 degrees at the start + /// vertex only. + /// + /// + /// The referencing shape's own effective (pixel-space) stroke width, used to scale this + /// marker instance when markerUnits is strokeWidth (the SVG default). + /// + /// The referencing shape's own accumulated transform. + /// The fixed per-document render context. + /// The current use-reference nesting depth, propagated unchanged to the marker's content. + /// The current element-tree recursion depth, propagated (incremented by one) to the marker's content. + /// + /// The current marker-reference nesting depth, checked against + /// before any other work, then propagated (incremented by + /// one) to the marker's own content. + /// + /// The running total-rendered-elements count. + /// The shared geometry-parsing work budget. + /// + /// Thrown when has already reached + /// , guarding against a marker-referencing-marker reference + /// cycle (directly, or via a chain) that would otherwise recurse indefinitely - mirrors + /// 's identical guard, checked before any + /// other work for the same reason. + /// + /// + /// A degenerate markerWidth/markerHeight (non-finite or non-positive), a + /// degenerate markerUnits="strokeWidth" scale (non-finite, non-positive, or beyond + /// - which also tolerantly covers a zero/negative + /// stroke-width, since stroke="none" shapes still have a defined, positive + /// stroke-width value even when nothing is actually stroked), or a non-finite + /// composed transform are all tolerant per-marker-instance skips (this one vertex renders + /// no marker), mirroring 's/'s own + /// established tolerant-skip conventions - none of them abort the whole document. + /// + private static void RenderOneMarker( + XElement markerElement, + Vector2 vertexPosition, + float vertexAngleDegrees, + bool isStartVertex, + float effectiveStrokeWidth, + Matrix3x2 shapeTransform, + RenderContext context, + int useDepth, + int elementDepth, + int markerDepth, + ref int totalElements, + GeometryWorkBudget workBudget) + { + if (markerDepth >= MaxMarkerDepth) + { + throw new InvalidDataException("Exceeded the maximum reference nesting depth."); + } + + var markerWidth = GetFloatAttribute(markerElement, "markerWidth", 3f); + var markerHeight = GetFloatAttribute(markerElement, "markerHeight", 3f); + if (!float.IsFinite(markerWidth) || !float.IsFinite(markerHeight) || markerWidth <= 0f || markerHeight <= 0f) + { + return; + } + + var refX = GetFloatAttribute(markerElement, "refX"); + var refY = GetFloatAttribute(markerElement, "refY"); + + var isUserSpaceOnUse = string.Equals( + (string?)markerElement.Attribute("markerUnits"), "userSpaceOnUse", StringComparison.OrdinalIgnoreCase); + var unitsScale = isUserSpaceOnUse ? 1f : effectiveStrokeWidth; + if (!float.IsFinite(unitsScale) || unitsScale <= 0f || unitsScale > MaxCoordinateMagnitude) + { + return; + } + + var angleDegrees = ParseMarkerOrient((string?)markerElement.Attribute("orient"), vertexAngleDegrees, isStartVertex); + + var viewBox = ParseViewBox((string?)markerElement.Attribute("viewBox")); + var contentScale = viewBox.HasValue + ? MathF.Min(markerWidth / viewBox.Value.Size.X, markerHeight / viewBox.Value.Size.Y) + : 1f; + + // Composition order (see this method's remarks): recenter the marker's own content on its + // refX/refY anchor, scale by the viewBox-fit factor (if any) and then by the + // markerUnits-derived units scale, rotate by the resolved orientation angle, translate to + // the shape-local vertex position, then finally compose with the shape's own accumulated + // transform - row-vector convention, matching every other transform composition in this + // class (Vector2.Transform(p, A * B) applies A first, then B) + var contentTransform = + Matrix3x2.CreateTranslation(-refX, -refY) + * Matrix3x2.CreateScale(contentScale) + * Matrix3x2.CreateScale(unitsScale) + * Matrix3x2.CreateRotation(DegreesToRadians(angleDegrees)) + * Matrix3x2.CreateTranslation(vertexPosition) + * shapeTransform; + + if (!IsFiniteTransform(contentTransform)) + { + return; + } + + // A marker's content cascade starts fresh from RenderState.Initial (seeded by the marker + // element's own presentation attributes, if any) - never inherited from the referencing + // shape's own state, per the SVG specification's marker-content-is-independent model and + // this class's explicit task contract + var markerState = ApplyPresentationAttributes(RenderState.Initial, markerElement); + foreach (var child in markerElement.Elements()) + { + RenderElement(child, markerState, contentTransform, context, useDepth, elementDepth + 1, markerDepth + 1, ref totalElements, workBudget); + } + } + + /// + /// Resolves a marker element's orient attribute to a concrete rotation angle. + /// + /// The raw orient attribute value, or if absent. + /// The vertex's own computed tangent angle (see ). + /// Whether this vertex is the referencing shape's very first vertex. + /// + /// when is absent or + /// "auto" (the common case); plus 180 degrees + /// when is "auto-start-reverse" and + /// is (unchanged at any other vertex); otherwise an attempted fixed + /// degrees value, tolerantly falling back to 0 if is not a + /// valid/finite number - a cosmetic-only concern parallel to this class's existing tolerant + /// handling of an invalid stroke-miterlimit"/stroke-dasharray, not a + /// document-abort concern. + /// + private static float ParseMarkerOrient(string? raw, float autoAngleDegrees, bool isStartVertex) + { + if (string.IsNullOrWhiteSpace(raw) || string.Equals(raw.Trim(), "auto", StringComparison.OrdinalIgnoreCase)) + { + return autoAngleDegrees; + } + + if (string.Equals(raw.Trim(), "auto-start-reverse", StringComparison.OrdinalIgnoreCase)) + { + return isStartVertex ? autoAngleDegrees + 180f : autoAngleDegrees; + } + + return float.TryParse(raw.Trim(), NumberStyles.Float, CultureInfo.InvariantCulture, out var fixedAngle) && float.IsFinite(fixedAngle) + ? fixedAngle + : 0f; } // ================================================================================================ diff --git a/test/DemaConsulting.CanvasNet.Tests/Codecs/SvgCodecTests.cs b/test/DemaConsulting.CanvasNet.Tests/Codecs/SvgCodecTests.cs index 776cc10a..92cc9ac6 100644 --- a/test/DemaConsulting.CanvasNet.Tests/Codecs/SvgCodecTests.cs +++ b/test/DemaConsulting.CanvasNet.Tests/Codecs/SvgCodecTests.cs @@ -1,7 +1,7 @@ // cspell:ignore Sfnt sfnt glyf cmap notdef codepoint // cspell:ignore Dasharray hhea Hhea hmtx Hmtx hrefs letterboxed Loca Maxp unstroked // cspell:ignore miterlimit -// cspell:ignore unparseable overpainted bbox moveto lineto rects +// cspell:ignore unparseable overpainted bbox moveto lineto rects unrotated using System.Reflection; using System.Text; using System.Xml.Linq; @@ -1636,6 +1636,513 @@ public void SvgCodec_Load_DeeplyNestedGroups_ThrowsInvalidDataException() Assert.Throws(() => SvgCodec.Load(stream1576, 10, 10)); } + // ================================================================================================ + // element + // ================================================================================================ + + /// + /// Proves that a marker-end reference renders its marker element's content + /// at the shape's final vertex, oriented along the segment's own direction + /// (orient="auto", the default) and sized in user-space units + /// (markerUnits="userSpaceOnUse"). + /// + [Fact] + public void SvgCodec_Load_MarkerEndOnLine_RendersArrowheadPastLineEnd() + { + // Arrange: a horizontal line from (0,5) to (8,5); its marker-end places a 4x4 + // "userSpaceOnUse" red square anchored at (2,2) (refX/refY), so at the (8,5) end vertex + // (tangent (1,0), angle 0) the square occupies (6,3)-(10,7) + const string svg = """ + + + + + + + + + """; + + // Act + using var stream = ToStream(svg); + var surface = SvgCodec.Load(stream, 10, 10); + + // Assert: the marker's red square is visible past the line's own x2=8 end point, outside + // the line's own stroke band (y in roughly [4.5,5.5]) + Assert.Equal(new Rgba32(255, 0, 0, 255), surface[7, 4]); + + // Assert: no marker content appears at the line's start vertex (no marker-start was set) + Assert.Equal(0, surface[1, 3].A); + } + + /// + /// Proves that marker-start/marker-mid/marker-end each independently + /// resolve and render their own distinct marker at the correct vertex of a + /// polyline, and that each marker's own reference point (refX/refY) + /// lands exactly on its vertex regardless of that vertex's orientation angle. + /// + [Fact] + public void SvgCodec_Load_MarkerStartMidEndOnPolyline_RendersDistinctMarkersAtEachVertex() + { + // Arrange: an L-shaped polyline with three vertices - (2.4,2.4) start, (10.4,2.4) mid, + // (10.4,10.4) end - each marker is a 2x2 square centered on its own refX/refY=1, so its + // center always lands exactly on the vertex position regardless of rotation + const string svg = """ + + + + + + + + + + + + + + + """; + + // Act + using var stream = ToStream(svg); + var surface = SvgCodec.Load(stream, 20, 20); + + // Assert: each vertex shows its own marker's own color + Assert.Equal(new Rgba32(255, 0, 0, 255), surface[2, 2]); + Assert.Equal(new Rgba32(0, 128, 0, 255), surface[10, 2]); + Assert.Equal(new Rgba32(0, 0, 255, 255), surface[10, 10]); + } + + /// + /// Proves that orient="auto" orients a marker along a purely horizontal segment's + /// own direction of travel. + /// + [Fact] + public void SvgCodec_Load_MarkerOrientAutoOnHorizontalLine_OrientsAlongPositiveX() + { + // Arrange: a marker whose content is a bar offset 2-4 units away from its refX/refY=0 + // anchor, along local +x - when unrotated (angle 0), it extends further in +x from the + // vertex it is placed at + const string svg = """ + + + + + + + + + """; + + // Act + using var stream = ToStream(svg); + var surface = SvgCodec.Load(stream, 16, 8); + + // Assert: the bar extends to the right of the (7,6.5) end vertex - a point beyond the + // line's own x2=7 extent, in the bar's expected x[9,11]/y[6,7] footprint + Assert.Equal(new Rgba32(255, 0, 0, 255), surface[9, 6]); + } + + /// + /// Proves that orient="auto" orients a marker along a purely vertical segment's own + /// direction of travel (rotated 90 degrees relative to the horizontal case). + /// + [Fact] + public void SvgCodec_Load_MarkerOrientAutoOnVerticalLine_OrientsAlongPositiveY() + { + // Arrange: the same "bar" marker as the horizontal test, but the line now travels + // straight down, so the bar should extend further downward (+y) from its end vertex + // rather than to the right + const string svg = """ + + + + + + + + + """; + + // Act + using var stream = ToStream(svg); + var surface = SvgCodec.Load(stream, 12, 12); + + // Assert: the bar extends below the (6.5,7) end vertex - a point beyond the line's own + // y2=7 extent, in the bar's expected x[6,7]/y[9,11] footprint + Assert.Equal(new Rgba32(255, 0, 0, 255), surface[6, 9]); + } + + /// + /// Proves that orient="auto" orients a marker along a diagonal segment's own + /// direction of travel (a 45-degree angle between the horizontal and vertical cases). + /// + [Fact] + public void SvgCodec_Load_MarkerOrientAutoOnDiagonalLine_OrientsAlong45Degrees() + { + // Arrange: the same "bar" marker, on a line traveling diagonally (equal x and y + // displacement) - the bar should extend further along that same 45-degree diagonal + const string svg = """ + + + + + + + + + """; + + // Act + using var stream = ToStream(svg); + var surface = SvgCodec.Load(stream, 12, 12); + + // Assert: the bar's centerline, roughly 3 units further along the 45-degree diagonal + // from the (7,7) end vertex, lands close to (9,9) - a lower alpha threshold (rather than + // full opacity) tolerates this rasterizer's edge anti-aliasing on a thin, diagonally + // rotated shape, whose straight edges rarely align exactly with pixel boundaries + var actual = surface[9, 9]; + Assert.True( + actual.R == 255 && actual.G == 0 && actual.B == 0 && actual.A > 150, + $"Expected a strongly red-tinted pixel at (9,9), got R={actual.R} G={actual.G} B={actual.B} A={actual.A}."); + } + + /// + /// Proves that a plain orient="auto" marker-start is oriented the same way + /// as the outgoing segment's own direction (pointing into the line), contrasted by + /// . + /// + [Fact] + public void SvgCodec_Load_MarkerStartOrientAuto_PointsIntoLine() + { + // Arrange: the same "bar" marker, referenced as marker-start with plain orient="auto" - + // the bar should extend toward +x, i.e. into the line's own body + const string svg = """ + + + + + + + + + """; + + // Act + using var stream = ToStream(svg); + var surface = SvgCodec.Load(stream, 10, 10); + + // Assert: the bar occupies x[7,9] (toward the line body), not x[1,3] (away from it) + Assert.Equal(new Rgba32(255, 0, 0, 255), surface[7, 5]); + Assert.Equal(0, surface[2, 5].A); + } + + /// + /// Proves that orient="auto-start-reverse" reverses a marker-start marker's + /// orientation by 180 degrees relative to plain orient="auto", placing it away from + /// the line's own body rather than into it - the conventional orientation for an arrowhead + /// at the tail of a line. + /// + [Fact] + public void SvgCodec_Load_MarkerStartOrientAutoStartReverse_PointsAwayFromLine() + { + // Arrange: the same scenario as the plain orient="auto" test, but with + // orient="auto-start-reverse" - the bar should now extend toward -x, away from the line + const string svg = """ + + + + + + + + + """; + + // Act + using var stream = ToStream(svg); + var surface = SvgCodec.Load(stream, 10, 10); + + // Assert: the bar occupies x[1,3] (away from the line body), not x[7,9] (into it) - the + // line's own black stroke also covers x[7,9] at this y, so a not-equal-to-red check (not + // an alpha-zero check) is what actually distinguishes "no marker content here" from "the + // line's own stroke happens to cover this pixel too" + Assert.Equal(new Rgba32(255, 0, 0, 255), surface[2, 5]); + Assert.NotEqual(new Rgba32(255, 0, 0, 255), surface[7, 5]); + } + + /// + /// Proves that markerUnits="userSpaceOnUse" keeps a marker's size independent of + /// the referencing shape's effective stroke width, while the default + /// markerUnits="strokeWidth" scales the marker proportionally to it. + /// + [Fact] + public void SvgCodec_Load_MarkerUnitsUserSpaceOnUseVsStrokeWidthDefault_ScalesDifferently() + { + // Arrange: two identical 2x2 marker definitions (refX/refY=1, so each marker's ref point + // is its own center), one explicitly "userSpaceOnUse" and one left at the default + // "strokeWidth" - both lines use stroke-width="4", so the default-units marker should + // scale up to 8x8 (half-width 4) while the userSpaceOnUse marker stays 2x2 (half-width 1) + const string svg = """ + + + + + + + + + + + + + """; + + // Act + using var stream = ToStream(svg); + var surface = SvgCodec.Load(stream, 30, 15); + + // Assert: the "userSpaceOnUse" marker did not grow with stroke-width - a point 3 units + // beyond its (7,5) vertex is outside its 2x2 footprint + Assert.Equal(0, surface[10, 5].A); + + // Assert: the default "strokeWidth" marker did grow - a point 3 units beyond its (25,5) + // vertex is still within its 8x8 (half-width 4) footprint + Assert.Equal(new Rgba32(0, 0, 255, 255), surface[28, 5]); + } + + /// + /// Proves that a marker element's own viewBox is fitted into + /// markerWidth/markerHeight (a uniform "meet" scale-down), rather than the + /// marker's content rendering at its raw, unfitted local-coordinate size. + /// + [Fact] + public void SvgCodec_Load_MarkerWithViewBox_FitsContentToMarkerWidthHeight() + { + // Arrange: a marker with a 10x10 viewBox but only a 2x2 markerWidth/markerHeight - its + // 10x10 content rect should be scaled down by 0.2, landing within (5,5)-(7,7) of its + // (5,5) vertex rather than the unfitted (5,5)-(15,15) + const string svg = """ + + + + + + + + + """; + + // Act + using var stream = ToStream(svg); + var surface = SvgCodec.Load(stream, 20, 20); + + // Assert: the fitted, scaled-down content is visible close to the vertex + Assert.Equal(new Rgba32(0, 128, 0, 255), surface[6, 6]); + + // Assert: a point that would only be covered by the unfitted, raw 10x10 content remains + // transparent - proving the viewBox fit actually scaled the content down + Assert.Equal(0, surface[9, 9].A); + } + + /// + /// Proves that a marker-end reference to a nonexistent id is tolerated as a silent + /// no-op, matching this codec's general dangling-reference convention, rather than + /// throwing or aborting the rest of the document. + /// + [Fact] + public void SvgCodec_Load_MarkerDanglingReference_IsSilentNoOp() + { + // Arrange + const string svg = """ + + + + """; + + // Act + using var stream = ToStream(svg); + var surface = SvgCodec.Load(stream, 10, 10); + + // Assert: the line itself still rendered + Assert.Equal(255, surface[4, 5].A); + + // Assert: no exception, and no marker content appeared past the line's own end point + Assert.Equal(0, surface[9, 5].A); + } + + /// + /// Proves that a marker element whose own content directly references itself (via + /// marker-start on a child shape) is rejected once the bounded + /// marker-reference nesting depth guard is reached, rather than recursing + /// indefinitely, mirroring 's + /// identical behavior for use cycles. + /// + [Fact] + public void SvgCodec_Load_MarkerSelfReferenceCycle_ThrowsInvalidDataException() + { + // Arrange: marker "m" contains a line whose own marker-start references "m" again + const string svg = """ + + + + + + + + + """; + + // Act & Assert + using var stream = ToStream(svg); + Assert.Throws(() => SvgCodec.Load(stream, 10, 10)); + } + + /// + /// Proves that a two-marker reference chain (m1 referencing m2 referencing + /// m1) is likewise rejected, not only a direct self-reference. + /// + [Fact] + public void SvgCodec_Load_MarkerTwoElementReferenceCycle_ThrowsInvalidDataException() + { + // Arrange + const string svg = """ + + + + + + + + + + + + """; + + // Act & Assert + using var stream = ToStream(svg); + Assert.Throws(() => SvgCodec.Load(stream, 10, 10)); + } + + /// + /// Proves that, for a multi-subpath path, marker-start/marker-end + /// apply only to the very first/last vertex of the whole path - not to each subpath's own + /// start/end - a deliberate, documented simplification (matches at least one common + /// browser's behavior). + /// + [Fact] + public void SvgCodec_Load_MarkerOnMultiSubpathPath_AppliesStartEndOnlyAtWholePathEnds() + { + // Arrange: two separate horizontal subpaths at y=2 - vertices in whole-path order are + // (0,2) start, (4,2) mid, (6,2) mid, (10,2) end. marker-mid is "none", so the two + // interior subpath boundary vertices should show nothing + const string svg = """ + + + + + + + + + + + + """; + + // Act + using var stream = ToStream(svg); + var surface = SvgCodec.Load(stream, 12, 4); + + // Assert: the whole path's very first vertex (0,2) shows the "start" marker + Assert.Equal(new Rgba32(255, 0, 0, 255), surface[0, 2]); + + // Assert: the whole path's very last vertex (10,2) shows the "end" marker + Assert.Equal(new Rgba32(0, 0, 255, 255), surface[10, 2]); + + // Assert: the two interior subpath-boundary vertices (4,2) and (6,2) show nothing, since + // they are classified as "mid" vertices (marker-mid="none") rather than per-subpath + // start/end vertices + Assert.Equal(0, surface[4, 2].A); + Assert.Equal(0, surface[6, 2].A); + } + + /// + /// Proves that rect, circle, and ellipse never receive markers, even + /// when a marker-end attribute referencing a valid marker is present - these shapes + /// have no natural vertices to orient a marker along, per the SVG specification. + /// + [Fact] + public void SvgCodec_Load_MarkerOnRectCircleEllipse_NeverRendersMarker() + { + // Arrange + const string svg = """ + + + + + + + + + + + """; + + // Act + using var stream = ToStream(svg); + var surface = SvgCodec.Load(stream, 30, 10); + + // Assert: no exception, each shape still renders its own black fill + Assert.Equal(255, surface[2, 2].A); + Assert.Equal(255, surface[15, 5].A); + Assert.Equal(255, surface[25, 5].A); + + // Assert: no red marker content appears anywhere in the canvas + for (var y = 0; y < 10; y++) + { + for (var x = 0; x < 30; x++) + { + Assert.NotEqual(new Rgba32(255, 0, 0, 255), surface[x, y]); + } + } + } + + /// + /// Proves that a marker's own content renders with a fresh presentation-attribute cascade + /// starting from the SVG/CSS initial values, rather than inheriting the referencing + /// shape's own fill/stroke - per the SVG specification's independent marker + /// content model. + /// + [Fact] + public void SvgCodec_Load_MarkerContent_DoesNotInheritReferencingShapeFillOrStroke() + { + // Arrange: the line sets a bright "red" fill/stroke, but the marker's own has no + // fill attribute of its own, so it should fall back to the initial default ("black"), + // not inherit the line's "red" + const string svg = """ + + + + + + + + + """; + + // Act + using var stream = ToStream(svg); + var surface = SvgCodec.Load(stream, 10, 10); + + // Assert: the marker's rect rendered its own initial-default "black" fill, not "red" + Assert.Equal(new Rgba32(0, 0, 0, 255), surface[7, 5]); + } + // ================================================================================================ // Total geometry-parsing work budget (path data / point lists / text characters) // ================================================================================================ @@ -3097,7 +3604,7 @@ public void SvgCodec_Load_GradientStopOffsetPercentage_RendersGradientCorrectly( /// /// Proves that well-formed-but-out-of-scope constructs (<style>, /// <filter>, <mask>, <clipPath>, - /// <pattern>, <marker>, a nested <svg>) are silently + /// <pattern>, a nested <svg>) are silently /// skipped and do not prevent the rest of the document from rendering. /// [Fact] @@ -3112,7 +3619,6 @@ public void SvgCodec_Load_UnsupportedConstructs_StillRendersRestOfDocument() - diff --git a/test/DemaConsulting.CanvasNet.Tests/Codecs/SvgFixtureTests.cs b/test/DemaConsulting.CanvasNet.Tests/Codecs/SvgFixtureTests.cs index 38c70730..4c06d566 100644 --- a/test/DemaConsulting.CanvasNet.Tests/Codecs/SvgFixtureTests.cs +++ b/test/DemaConsulting.CanvasNet.Tests/Codecs/SvgFixtureTests.cs @@ -260,4 +260,25 @@ public void SvgCodec_Load_InkscapeFiltersFixture_ToleratesFiltersAndRendersFlowe Assert.Equal(0, surface[100, 50].A); Assert.Equal(0, surface[50, 100].A); } + + /// + /// Proves that arrow-markers.svg's marker-end-referenced arrowhead + /// (orient="auto", markerUnits="userSpaceOnUse") renders its own "navy" + /// triangle content past the line's own end point, and that a point clearly outside both + /// the line and the arrowhead remains transparent. + /// + [Fact] + public void SvgCodec_Load_ArrowMarkersFixture_RendersArrowheadPastLineEnd() + { + // Arrange & Act + var surface = SvgCodec.Load(ResolveFixturePath("arrow-markers.svg"), 100, 100); + + // Assert: the arrowhead's own "navy" fill is visible past the line's own x2=80 end point + // (the triangle's tip reaches x=82 at y=50, per its refX=8/refY=5 anchor and + // markerWidth=markerHeight=10) + Assert.Equal(new Rgba32(0, 0, 128, 255), surface[78, 50]); + + // Assert: a point clearly outside both the line and the arrowhead remains transparent + Assert.Equal(0, surface[10, 90].A); + } } diff --git a/test/DemaConsulting.CanvasNet.Tests/SvgFixtures/README.md b/test/DemaConsulting.CanvasNet.Tests/SvgFixtures/README.md index e6a83efa..f7d822e9 100644 --- a/test/DemaConsulting.CanvasNet.Tests/SvgFixtures/README.md +++ b/test/DemaConsulting.CanvasNet.Tests/SvgFixtures/README.md @@ -17,6 +17,7 @@ for their provenance and licensing. | `use-reference.svg` | `defs` (non-rendering template storage) and `use` (reference + `x`/`y` offset) | | `text.svg` | `text` rendering, paired in tests with the real `FontFixtures/OpenSans-Regular.ttf` font | | `tolerant-unsupported.svg` | A well-formed but out-of-scope `filter`, alongside a `rect` that still renders | +| `arrow-markers.svg` | `marker` referenced via `marker-end`, `orient="auto"`, `markerUnits="userSpaceOnUse"` | ## Real-world third-party fixtures diff --git a/test/DemaConsulting.CanvasNet.Tests/SvgFixtures/arrow-markers.svg b/test/DemaConsulting.CanvasNet.Tests/SvgFixtures/arrow-markers.svg new file mode 100644 index 00000000..71eb7453 --- /dev/null +++ b/test/DemaConsulting.CanvasNet.Tests/SvgFixtures/arrow-markers.svg @@ -0,0 +1,9 @@ + + + + + + + + + From d09cf470377313f4bba4d3f25393c7f11c5ab4d5 Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Sat, 26 Sep 2026 19:45:19 -0400 Subject: [PATCH 2/8] Add SVG filter rendering support to SvgCodec Implement filter="url(#id)" rendering for the common solid-color halo/ background use case, replacing the prior silent skip of elements. - Parse defs via the existing BuildIdIndex mechanism, with objectBoundingBox filter regions (default -10% -10% 120% 120%); userSpaceOnUse tolerantly falls back to the same default. - Evaluate filter primitive chains in document order: feFlood, feGaussianBlur (clamped stdDeviation), feOffset, feComposite (over/in/out/atop/xor), and feMerge, against SourceGraphic rendered into an offscreen temporary Surface sized to the filter region. Unsupported primitive types are tolerant no-op passthroughs. - Composite the filtered result back onto the main surface using the existing Surface.CompositeOver/CompositeOverSpan APIs. - Dangling filter references render the element normally, matching the codec's established dangling-reference tolerance convention. - Add resource-safety bounds: temporary surface dimensions are clamped via Surface.MaxDimension, blur stdDeviation is clamped, and a new primitive-count/region-area work budget (MaxFilterPrimitivesPerFilter / MaxFilterPrimitiveWorkUnits) rejects pathological filter chains with a tolerant fallback instead of an unbounded hang. - Update SvgCodec's class-level XML doc remarks to move filter from the out-of-scope list into the supported subset. Tests: add unit and fixture tests covering flood-only halos, flood+blur+composite/merge halos, feComposite in/atop/xor operators, dangling filter references, unsupported-primitive passthrough, pathological filter-region/blur/primitive-count bounding, and default vs. explicit filter regions. Update the pre-existing InkscapeFilters fixture test now that its filter chain is genuinely evaluated. Update design doc, verification doc, reqstream requirements (system/ subsystem/unit), README, and user guide to document the new filter support, mirroring the conventions established by the marker support added in a16d185. --- README.md | 2 +- docs/design/canvas-net/codecs/svg-codec.md | 76 +- docs/reqstream/canvas-net.yaml | 27 + docs/reqstream/canvas-net/codecs.yaml | 27 + .../canvas-net/codecs/svg-codec.yaml | 66 +- docs/user_guide/introduction.md | 18 +- .../canvas-net/codecs/svg-codec.md | 91 +- .../Codecs/SvgCodec.cs | 956 +++++++++++++++++- .../Codecs/SvgCodecTests.cs | 520 +++++++++- .../Codecs/SvgFixtureTests.cs | 78 +- .../SvgFixtures/README.md | 3 +- .../SvgFixtures/label-halo.svg | 13 + 12 files changed, 1822 insertions(+), 55 deletions(-) create mode 100644 test/DemaConsulting.CanvasNet.Tests/SvgFixtures/label-halo.svg diff --git a/README.md b/README.md index 9516c86a..44cdd305 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ image operations using `Span`, and supports independent-copy cropping for loa - 🖨️ **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; `GetInfo` reports the true frame count -- 📐 **SVG Codec** - Rasterize a common SVG subset, including markers, to a surface +- 📐 **SVG Codec** - Rasterize a common SVG subset, including markers/filters, to a surface - 🔍 **Header-Only Probing** - `GetInfo` reads headers without decoding pixels (GIF excepted) - 🖌️ **Path Filling** - Antialiased nonzero/even-odd fill of vector paths - 🖊️ **Stroke-to-Fill** - Convert stroked paths into fillable outlines diff --git a/docs/design/canvas-net/codecs/svg-codec.md b/docs/design/canvas-net/codecs/svg-codec.md index 42465425..de6d777b 100644 --- a/docs/design/canvas-net/codecs/svg-codec.md +++ b/docs/design/canvas-net/codecs/svg-codec.md @@ -2,7 +2,7 @@ ![Codecs Structure](CodecsView.svg) - + The `SvgCodec` class is the fifth software unit in the `Codecs` subsystem, and the first codec unit whose dependencies extend beyond `Canvas.Surface`. It provides hand-rolled, decode/ @@ -44,12 +44,16 @@ same reason as the other codecs: rasterizing an SVG document has no instance sta `marker-mid`/`marker-end` presentation attributes (`url(#id)`), with `markerWidth`/ `markerHeight`, `refX`/`refY`, `markerUnits` (`strokeWidth`/`userSpaceOnUse`), `orient` (`auto`/`auto-start-reverse`/a fixed angle in degrees), and an optional `viewBox` +- `filter`, referenced from any renderable shape or `text` element via the `filter` + presentation attribute (`url(#id)`), with `x`/`y`/`width`/`height` filter-region attributes + (objectBoundingBox units) and `feFlood`, `feGaussianBlur`, `feOffset`, `feComposite`, and + `feMerge` primitive children - `text`, with `x`/`y`, `font-family`, `font-size`, `fill`, and `text-anchor` (`start`/`middle`/`end`), rendered through a caller-supplied font dictionary #### Out-of-scope subset (tolerated, silently skipped) -`style`, `filter`, `mask`, `clipPath`, `pattern`, a nested `svg`, `animate`/other SMIL +`style`, `mask`, `clipPath`, `pattern`, a nested `svg`, `animate`/other SMIL animation elements, `image`, `foreignObject`, and CSS class/id selectors are all well-formed SVG constructs this codec does not implement. Encountering one of these does not fail the whole document: `SvgCodec` silently skips just that element (and, for a container element, everything @@ -58,7 +62,20 @@ tolerant-parsing policy distinct from the codec's malformed-input rejection poli _Error Handling_ below) — a document using an out-of-scope construct is not itself invalid SVG, only partially outside this codec's supported feature set. Within the supported `marker` feature itself, `markerContentUnits` (a rarely-used SVG 2 attribute) and clipping marker content to its -own `markerWidth`/`markerHeight` viewport (`overflow`) are both explicitly out of scope. +own `markerWidth`/`markerHeight` viewport (`overflow`) are both explicitly out of scope. Within +the supported `filter` feature itself, `filterUnits="userSpaceOnUse"` (tolerantly falls back to +the same objectBoundingBox-relative region computation as the default, rather than being +interpreted as literal absolute user-space coordinates), group-level filtering (a `filter` on a +`g`/`symbol`, or on a shape's own `marker` content, has no effect - filtering only ever applies +per-element to a directly renderable shape/`text` element), and every filter primitive other than +the five listed above (`feColorMatrix`, `feTurbulence`, `feDisplacementMap`, `feImage`, `feTile`, +`feDropShadow`, `feConvolveMatrix`, `feDiffuseLighting`, `feSpecularLighting`, +`feComponentTransfer`, and `feMorphology` - each tolerated as a no-op passthrough of its own input +rather than rejected or skipped at the whole-filter level) are all explicitly out of scope. +`feImage` in particular is deliberately never implemented, specifically because it is the only +primitive that could reference another filtered element's own output - omitting it means +filter-chain evaluation needs no additional recursion-depth guard of its own, unlike `use`/ +`marker` references. A percentage value on a shape/text geometry attribute (`x`, `y`, `width`, `height`, `rx`, `ry`, `cx`, `cy`, `r`, `x1`/`y1`/`x2`/`y2`, `font-size`, `stroke-width`, `stroke-miterlimit`, @@ -293,6 +310,59 @@ separately, since a marker chain and a `use` chain are independent nesting conce along), and a `marker-start`/`marker-mid`/`marker-end` referencing a nonexistent id, or an id that does not resolve to a `marker` element, is tolerated as a silent no-op for that one vertex. +**Filters.** A directly renderable shape or `text` element's own `filter` presentation attribute +(`url(#id)`, resolved through the same id index and dangling-reference tolerance as a gradient +`fill`/`stroke` reference above) identifies a `filter` element whose primitive children are +evaluated against that one element's own rendered content - `filter` never cascades through +`RenderState` (matching `transform`'s own non-cascading handling) and never applies at the +group/`marker`-content level, only per-element. The filter region - the rectangular area, in the +element's own local space, that the filter's temporary offscreen buffer covers - defaults to +-10%/-10%/120%/120% (`x`/`y`/`width`/`height`, objectBoundingBox units) of the element's own +local-space bounding box, or uses the filter element's own explicit `x`/`y`/`width`/`height` +attributes when present (still always interpreted as objectBoundingBox-relative fractions, even +when `filterUnits="userSpaceOnUse"` is declared - a deliberate, tolerant simplification). This +local-space region is transformed by the element's own accumulated transform, then rounded +outward to an integer pixel bounding box; if that box is degenerate, non-finite, or exceeds +`Surface.MaxDimension` on either axis, the filter is tolerantly skipped entirely and the element +renders normally, exactly as if it had no `filter` attribute - the same bounded-resource +philosophy as `Surface`'s own dimension cap, applied here to prevent an attacker-controlled +filter region from driving an unbounded temporary allocation. The filter's own `fe*` primitive +chain is additionally bounded by a fixed primitive-count cap (`MaxFilterPrimitivesPerFilter`, +1,000) and a primitive-count-times-region-area work budget (`MaxFilterPrimitiveWorkUnits`, +5,000,000), checked once, upfront, before `SourceGraphic` is even allocated, and tolerantly +skipped the same way once either is exceeded - because, unlike the per-element costs +`MaxTotalRenderedElements` already bounds (which assumes O(1)/O(perimeter) cost per element, not +O(region-area) cost per primitive), a single filter's own primitive-chain cost is +O(primitive count × region area), a cost dimension no pre-existing guard actually covers. When the +region is accepted and the chain's work stays within budget, the +element's own content is rendered a second time - independently of its main render onto +`context.Surface` - into a fresh, region-sized temporary `Surface` (this buffer is the filter's +implicit `SourceGraphic` input; `SourceAlpha`, its alpha-only derivative, is built lazily only if +some primitive actually references it). Each primitive in document order reads a named or +default input (the previous primitive's own output, or `SourceGraphic` if it is first), and +writes a named or default output, all buffers always exactly the temporary surface's own +dimensions - this invariant is what lets `feComposite`'s `over` operator and `feMerge` both reuse +`Surface.CompositeOver(Surface)` (which requires equal-size surfaces) unchanged, with no new +per-pixel blending math. `feFlood` fills a buffer with a solid, alpha-scaled color +(`flood-color`/`flood-opacity`). `feGaussianBlur` approximates a Gaussian blur with three passes +of a sliding-window box blur (cost independent of the requested radius, unlike a naive +per-pixel-kernel blur), reading only the first (isotropic) component of `stdDeviation` and +clamping it to a fixed `MaxFilterBlurStdDeviationPixels` bound so a pathologically large +requested radius cannot translate into unbounded per-pixel work. `feOffset` shifts a buffer's +content by `dx`/`dy` (scaled by the element's own transform). `feComposite` implements Porter-Duff +`over` by delegating to `Surface.CompositeOver`, and implements `in`/`out`/`atop`/`xor` via one +small, dedicated per-pixel premultiplied-alpha helper - the only genuinely new blending math this +feature introduces. `feMerge` layers each `feMergeNode` child's own resolved input over an +initially transparent accumulator, in document order, via the same `CompositeOver`. Any other +primitive type (`feColorMatrix`, `feTurbulence`, `feDisplacementMap`, `feImage`, `feTile`, +`feDropShadow`, `feConvolveMatrix`, `feDiffuseLighting`, `feSpecularLighting`, +`feComponentTransfer`, `feMorphology`) is a tolerant no-op passthrough of its own input, still +registered under its own `result` name so later primitives can still resolve it by name. The +final primitive's own output buffer is composited onto `context.Surface` at the region's own +pixel position via `Surface.CompositeOverSpan`, clipped to the canvas's own bounds - reusing the +same offset-aware compositing primitive used elsewhere in this codec, rather than inventing new +canvas-writing logic for filters. + #### Element/Group Nesting and Total-Element Bounds The `use`-cycle guard above only bounds recursion that passes back through a `use` element - it diff --git a/docs/reqstream/canvas-net.yaml b/docs/reqstream/canvas-net.yaml index 71903b18..df105934 100644 --- a/docs/reqstream/canvas-net.yaml +++ b/docs/reqstream/canvas-net.yaml @@ -985,6 +985,33 @@ sections: - SvgCodec_Load_MarkerSelfReferenceCycle_ThrowsInvalidDataException - SvgCodec_Load_MarkerTwoElementReferenceCycle_ThrowsInvalidDataException + - id: CanvasNet-Lib-SvgFilterRendering + title: >- + The library shall rasterize filter elements referenced from any directly renderable + shape or text element's filter presentation attribute, evaluating a + feFlood/feGaussianBlur/feOffset/feComposite/feMerge primitive chain against that + element's own content within a bounded-size temporary offscreen surface, and shall + tolerate a dangling filter reference, an unsupported primitive type, and a + pathologically large filter region or blur radius without failing or hanging. + tags: [system] + justification: | + Solid-color halo/glow backgrounds behind text labels, built via SVG's filter + mechanism, are common in real-world diagrams (the reported real-world bug this feature + fixes: SvgRenderer's own midpoint line labels use exactly this pattern, and previously + lost their halo entirely, causing label text to overlap the line beneath it); callers + need this common pattern to render rather than silently vanish, with the same bounded, + predictable resource-safety behavior already established for this codec's other + reference-following and work-budget concerns. + children: + - CanvasNet-Codecs-SvgFilterRendering + tests: + - SvgCodec_Load_FeFloodFilter_RendersSolidColorBehindElement + - SvgCodec_Load_FeFloodFeGaussianBlurFeCompositeFilter_RendersBlurredHaloBehindContent + - SvgCodec_Load_LabelHaloFixture_RendersWhiteHaloBehindLineMidpointLabel + - SvgCodec_Load_FilterDanglingReference_RendersElementNormally + - SvgCodec_Load_FilterPathologicallyLargeRegion_SkipsFilterRatherThanUnboundedAllocation + - SvgCodec_Load_FilterPathologicallyLargeBlurStdDeviation_ClampsRatherThanUnboundedWork + - id: CanvasNet-Lib-SvgViewBoxFitting title: >- The library shall fit an SVG document's intrinsic viewBox into the requested raster size diff --git a/docs/reqstream/canvas-net/codecs.yaml b/docs/reqstream/canvas-net/codecs.yaml index 85048742..9b31e199 100644 --- a/docs/reqstream/canvas-net/codecs.yaml +++ b/docs/reqstream/canvas-net/codecs.yaml @@ -947,6 +947,33 @@ sections: - SvgCodec_Load_MarkerSelfReferenceCycle_ThrowsInvalidDataException - SvgCodec_Load_MarkerTwoElementReferenceCycle_ThrowsInvalidDataException + - id: CanvasNet-Codecs-SvgFilterRendering + title: >- + The Codecs subsystem shall rasterize filter elements referenced from any directly + renderable shape or text element's filter presentation attribute, evaluating a + feFlood/feGaussianBlur/feOffset/feComposite/feMerge primitive chain against that + element's own content within a bounded-size temporary offscreen surface, and shall + tolerate a dangling filter reference, an unsupported primitive type, and a + pathologically large filter region or blur radius without failing or hanging. + justification: | + Solid-color halo/glow backgrounds behind text labels, built via SVG's filter + mechanism, are common in real-world diagrams (the reported real-world bug this + feature fixes: SvgRenderer's own midpoint line labels use exactly this pattern, and + previously lost their halo entirely, causing label text to overlap the line + beneath it); callers need this common pattern to render rather than silently + vanish, with the same bounded, predictable resource-safety behavior already + established for this codec's other reference-following and work-budget concerns. + children: + - CanvasNet-Codecs-SvgCodec-FilterRendering + - CanvasNet-Codecs-SvgCodec-FilterResourceSafety + tests: + - SvgCodec_Load_FeFloodFilter_RendersSolidColorBehindElement + - SvgCodec_Load_FeFloodFeGaussianBlurFeCompositeFilter_RendersBlurredHaloBehindContent + - SvgCodec_Load_LabelHaloFixture_RendersWhiteHaloBehindLineMidpointLabel + - SvgCodec_Load_FilterDanglingReference_RendersElementNormally + - SvgCodec_Load_FilterPathologicallyLargeRegion_SkipsFilterRatherThanUnboundedAllocation + - SvgCodec_Load_FilterPathologicallyLargeBlurStdDeviation_ClampsRatherThanUnboundedWork + - id: CanvasNet-Codecs-SvgViewBoxFitting title: >- The Codecs subsystem shall fit an SVG document's intrinsic viewBox into the diff --git a/docs/reqstream/canvas-net/codecs/svg-codec.yaml b/docs/reqstream/canvas-net/codecs/svg-codec.yaml index 31023ae9..8e278e3d 100644 --- a/docs/reqstream/canvas-net/codecs/svg-codec.yaml +++ b/docs/reqstream/canvas-net/codecs/svg-codec.yaml @@ -453,7 +453,7 @@ sections: - id: CanvasNet-Codecs-SvgCodec-UnsupportedConstructsIgnored title: >- The SvgCodec class shall silently skip well-formed but out-of-scope elements - (including style, filter, mask, clipPath, pattern, a nested svg, + (including style, mask, clipPath, pattern, a nested svg, animate/SMIL, image, and foreignObject), continuing to render the rest of the document. justification: | @@ -463,7 +463,71 @@ sections: tests: - SvgCodec_Load_UnsupportedConstructs_StillRendersRestOfDocument - SvgCodec_Load_ToleratesUnsupportedConstructFixture_StillRendersRemainingContent + + - id: CanvasNet-Codecs-SvgCodec-FilterRendering + title: >- + The SvgCodec class shall render a filter element referenced via any directly + renderable shape or text element's filter presentation attribute, evaluating + its feFlood/feGaussianBlur/feOffset/feComposite/feMerge primitive children in + document order against that element's own SourceGraphic content, honoring the + filter region's default (-10%/-10%/120%/120% of the element's own bounding box) + or explicit x/y/width/height, and tolerating any other primitive type as a + no-op passthrough of its own input. + justification: | + Real-world SVG documents commonly use filter="url(#id)" for common effects + such as a solid-color halo/background behind text labels (the reported + real-world bug this feature fixes: SvgRenderer's own midpoint line labels use + exactly this pattern, and previously lost their halo entirely, causing label + text to overlap the line beneath it); callers relying on this common pattern + need it to actually render rather than silently vanish. + tests: + - SvgCodec_Load_FeFloodFilter_RendersSolidColorBehindElement + - SvgCodec_Load_FeFloodFeMergeFilter_RendersFloodBehindSourceGraphic + - SvgCodec_Load_FeFloodFeGaussianBlurFeCompositeFilter_RendersBlurredHaloBehindContent + - SvgCodec_Load_FilterUnsupportedPrimitive_PassesThroughSourceGraphicUnchanged + - SvgCodec_Load_FilterDefaultRegion_ExpandsBoundingBoxByTenAndTwentyPercent + - SvgCodec_Load_FilterExplicitRegion_UsesDeclaredXYWidthHeight + - SvgCodec_Load_FilterUserSpaceOnUse_FallsBackToObjectBoundingBoxDefault + - SvgCodec_Load_FeOffsetFilter_ShiftsSourceGraphicByDxDy + - SvgCodec_Load_LabelHaloFixture_RendersWhiteHaloBehindLineMidpointLabel - SvgCodec_Load_InkscapeFiltersFixture_ToleratesFiltersAndRendersFlowerContent + - SvgCodec_Load_FeCompositeOperatorIn_KeepsForegroundWeightedByBackgroundAlpha + - SvgCodec_Load_FeCompositeOperatorAtop_BlendsBothInputsWeightedByBothAlphas + - SvgCodec_Load_FeCompositeOperatorXor_KeepsEachInputWhereTheOtherHasNoCoverage + + - id: CanvasNet-Codecs-SvgCodec-FilterResourceSafety + title: >- + The SvgCodec class shall tolerate a filter="url(#id)" reference to a + nonexistent id as a silent no-op, and shall bound filter-related resource use by + tolerantly skipping a filter whose region would require a temporary surface + exceeding Surface.MaxDimension and by clamping feGaussianBlur's stdDeviation to + a fixed maximum, so that neither a pathological filter region nor a + pathological blur radius can cause unbounded allocation or unbounded per-pixel + work; and shall bound the total primitive-count/region-area work a single + filter's own primitive chain may perform, tolerantly skipping the filter once + exceeded. + justification: | + A dangling filter reference is a common authoring mistake that must not abort + rendering of the rest of the document, matching this codec's general + dangling-reference convention; and since filter region size and blur radius are + both untrusted, attacker-controllable document data, they must be bounded the + same way this codec already bounds other bounded/attacker-controllable + resources (element-tree depth, total rendered elements, geometry-parsing work), + rather than allowing unbounded temporary-surface allocation or unbounded blur + work. A single filter's own primitive-chain cost (primitive count times filter + region pixel area, since every primitive's output buffer is exactly the + region's own size) is a distinct cost dimension none of those pre-existing + guards actually bounds - a filter with thousands of primitive children against + even a modest region can cost orders of magnitude more than any pre-existing + guard anticipates, so a dedicated primitive-count/region-area work budget was + added, tolerantly skipping the whole filter (rendering the element normally) + once exceeded, the same tolerant-fallback policy used for an oversized region or + dangling reference. + tests: + - SvgCodec_Load_FilterDanglingReference_RendersElementNormally + - SvgCodec_Load_FilterPathologicallyLargeRegion_SkipsFilterRatherThanUnboundedAllocation + - SvgCodec_Load_FilterPathologicallyLargeBlurStdDeviation_ClampsRatherThanUnboundedWork + - SvgCodec_Load_FilterExcessivePrimitiveCount_SkipsFilterRatherThanUnboundedWork - id: CanvasNet-Codecs-SvgCodec-ValidationNull title: >- diff --git a/docs/user_guide/introduction.md b/docs/user_guide/introduction.md index d09c0586..3d03db01 100644 --- a/docs/user_guide/introduction.md +++ b/docs/user_guide/introduction.md @@ -1,7 +1,7 @@ # Introduction - + ## Purpose @@ -891,13 +891,17 @@ It supports basic shapes (`rect`, `circle`, `ellipse`, `line`, `polyline`, `poly grouping (`g`) with cascading presentation attributes, `transform` functions, linear/radial gradients (including `xlink:href`/`href` template inheritance), `use` references, `marker` elements (referenced via `marker-start`/`marker-mid`/`marker-end`, with `markerWidth`/ -`markerHeight`, `refX`/`refY`, `markerUnits`, `orient`, and an optional `viewBox`), and -best-effort `text` rendering against a caller-supplied dictionary of `TrueTypeFont` instances. The -root `viewBox`/`width`/`height` are fit into the requested raster using a "meet, centered" policy +`markerHeight`, `refX`/`refY`, `markerUnits`, `orient`, and an optional `viewBox`), `filter` +elements (referenced via the `filter` presentation attribute on any directly renderable shape or +`text` element, with `x`/`y`/`width`/`height` filter-region attributes and `feFlood`/ +`feGaussianBlur`/`feOffset`/`feComposite`/`feMerge` primitive children), and best-effort `text` +rendering against a caller-supplied dictionary of `TrueTypeFont` instances. The root +`viewBox`/`width`/`height` are fit into the requested raster using a "meet, centered" policy equivalent to CSS `object-fit: contain` (`preserveAspectRatio` itself is not read). Well-formed but -out-of-scope constructs (`style`, `filter`, `mask`, `clipPath`, `animate`/SMIL, `image`, -`foreignObject`, `pattern`, nested `svg`, CSS selectors) are silently skipped so the rest -of the document still renders; malformed/unparseable input throws `InvalidDataException`. +out-of-scope constructs (`style`, `mask`, `clipPath`, `animate`/SMIL, `image`, +`foreignObject`, `pattern`, nested `svg`, CSS selectors, and every filter primitive other than +the five listed above) are silently skipped/passed through so the rest of the document still +renders; malformed/unparseable input throws `InvalidDataException`. #### SvgCodec Methods diff --git a/docs/verification/canvas-net/codecs/svg-codec.md b/docs/verification/canvas-net/codecs/svg-codec.md index 8f8736ea..7d441a3d 100644 --- a/docs/verification/canvas-net/codecs/svg-codec.md +++ b/docs/verification/canvas-net/codecs/svg-codec.md @@ -512,19 +512,88 @@ opacity-family attributes and gradient coordinates remain correctly unaffected b #### CanvasNet-Codecs-SvgCodec-UnsupportedConstructsIgnored: Out-of-Scope Constructs Tolerated **Tests**: `SvgCodec_Load_UnsupportedConstructs_StillRendersRestOfDocument`, -`SvgCodec_Load_ToleratesUnsupportedConstructFixture_StillRendersRemainingContent`, -`SvgCodec_Load_InkscapeFiltersFixture_ToleratesFiltersAndRendersFlowerContent` +`SvgCodec_Load_ToleratesUnsupportedConstructFixture_StillRendersRemainingContent` -Builds a document containing `style`, `filter`, `mask`, `clipPath`, `pattern`, and a +Builds a document containing `style`, `mask`, `clipPath`, `pattern`, and a nested `svg` alongside an ordinary `rect`, and asserts the ordinary `rect` still renders — proving -none of the out-of-scope elements abort the whole document. A real fixture file exercises the -same property end-to-end. -`SvgCodec_Load_InkscapeFiltersFixture_ToleratesFiltersAndRendersFlowerContent` corroborates this -with a large, real, unmodified, third-party Wikimedia Commons fixture -(`SvgFixtures/InkscapeFilters.svg`) containing dozens of `filter="url(#...)"` references to -`feGaussianBlur`/`feComposite`/`feSpecularLighting`-based filter effects, proving the -tolerant-ignore policy holds at real-world scale and complexity, not only for a small synthetic -document. +none of the out-of-scope elements abort the whole document. A real fixture file +(`SvgFixtures/tolerant-unsupported.svg`, whose `filter` def is now genuinely supported but simply +never referenced by any element) exercises the same property end-to-end. See +`CanvasNet-Codecs-SvgCodec-FilterRendering`/`CanvasNet-Codecs-SvgCodec-FilterResourceSafety` below +for `filter`'s own dedicated coverage - it is no longer an out-of-scope construct. + +#### CanvasNet-Codecs-SvgCodec-FilterRendering: Filter Primitive Chain Evaluation and Region Computation + +**Tests**: `SvgCodec_Load_FeFloodFilter_RendersSolidColorBehindElement`, +`SvgCodec_Load_FeFloodFeMergeFilter_RendersFloodBehindSourceGraphic`, +`SvgCodec_Load_FeFloodFeGaussianBlurFeCompositeFilter_RendersBlurredHaloBehindContent`, +`SvgCodec_Load_FilterUnsupportedPrimitive_PassesThroughSourceGraphicUnchanged`, +`SvgCodec_Load_FilterDefaultRegion_ExpandsBoundingBoxByTenAndTwentyPercent`, +`SvgCodec_Load_FilterExplicitRegion_UsesDeclaredXYWidthHeight`, +`SvgCodec_Load_FilterUserSpaceOnUse_FallsBackToObjectBoundingBoxDefault`, +`SvgCodec_Load_FeOffsetFilter_ShiftsSourceGraphicByDxDy`, +`SvgCodec_Load_LabelHaloFixture_RendersWhiteHaloBehindLineMidpointLabel`, +`SvgCodec_Load_InkscapeFiltersFixture_ToleratesFiltersAndRendersFlowerContent`, +`SvgCodec_Load_FeCompositeOperatorIn_KeepsForegroundWeightedByBackgroundAlpha`, +`SvgCodec_Load_FeCompositeOperatorAtop_BlendsBothInputsWeightedByBothAlphas`, +`SvgCodec_Load_FeCompositeOperatorXor_KeepsEachInputWhereTheOtherHasNoCoverage` + +Asserts a bare `feFlood` primitive's flood color entirely replaces the referencing element's own +content, filling the (default, bounding-box-relative) filter region including the area behind the +element's own shape, since a lone `feFlood` never references `SourceGraphic`; asserts `feMerge` +layers a `feFlood` result behind a `SourceGraphic` layer in document order, so the flood is visible +outside the element's own bounds while the element's own fill remains visible on top at its own +location; asserts a conventional `feFlood` → `feComposite(operator="out")` → `feGaussianBlur` → +`feMerge` halo/glow recipe produces a genuinely softened (partially transparent, not hard-edged) +flood color just outside the element's own edge - proving the blur actually ran - while the +element's own fill remains fully opaque and unaffected at its center; asserts a filter primitive +type this codec does not implement (`feColorMatrix`) is treated as a no-op passthrough of its own +input rather than throwing or blanking the element's content; asserts the filter region's default +computation expands the referencing element's own bounding box by exactly -10%/-10%/120%/120%, +verified at pixels just inside and just outside each computed edge; asserts an explicit `filter` +`x`/`y`/`width`/`height` overrides the default region computation; asserts +`filterUnits="userSpaceOnUse"` tolerantly falls back to the same objectBoundingBox-relative region +computation as the default, rather than being interpreted as literal absolute user-space +coordinates; and asserts `feOffset` shifts its input by `dx`/`dy` prior to compositing back onto +the canvas. Two real fixture files corroborate this end to end: +`SvgFixtures/label-halo.svg` mirrors the reported real-world bug (a `filter="url(#label-bg)"` +white halo behind a line's own midpoint label, previously invisible while `filter` was ignored), +and the large, real, third-party `SvgFixtures/InkscapeFilters.svg` (see +`CanvasNet-Codecs-SvgCodec-UnsupportedConstructsIgnored`'s predecessor coverage of this same +fixture) proves this genuinely-evaluated filter-chain support, including its tolerant-passthrough +handling of several unsupported primitives mixed into the same chains +(`feSpecularLighting`/`feDiffuseLighting`/arithmetic-mode `feComposite`), holds at real-world scale +and complexity, not only for small synthetic documents. `feComposite`'s `in`, `atop`, and `xor` +Porter-Duff operators (`over`/`out` were already covered above) are each additionally verified +deterministically and synthetically, against two full-region, semi-transparent `feFlood` inputs +that isolate the operator's own per-pixel formula from any shape-geometry/filter-region overlap +concern: `in` keeps the "in" input's own color weighted by the "in2" input's own alpha; `atop` +blends both inputs' own colors weighted by (`in2`'s alpha, `1 - in`'s alpha); and `xor` keeps each +input only where the other has no coverage - each asserting the exact expected pixel value the +documented formula produces. + +#### CanvasNet-Codecs-SvgCodec-FilterResourceSafety: Filter Dangling Reference and Resource-Bound Tolerance + +**Tests**: `SvgCodec_Load_FilterDanglingReference_RendersElementNormally`, +`SvgCodec_Load_FilterPathologicallyLargeRegion_SkipsFilterRatherThanUnboundedAllocation`, +`SvgCodec_Load_FilterPathologicallyLargeBlurStdDeviation_ClampsRatherThanUnboundedWork`, +`SvgCodec_Load_FilterExcessivePrimitiveCount_SkipsFilterRatherThanUnboundedWork` + +Asserts a `filter="url(#id)"` reference to a nonexistent id renders the element normally, exactly +as if no `filter` attribute were present, matching this codec's general dangling-reference +convention (`ResolvePaint`/`ResolveMarkerElement`); asserts a filter region large enough to require +an unreasonably large temporary surface (`width`/`height` of `100000%`) is tolerantly skipped +entirely, rather than attempting an allocation exceeding `Surface.MaxDimension`, with the element +still rendering its own normal content; asserts an `feGaussianBlur` `stdDeviation` many orders +of magnitude larger than the fixed `MaxFilterBlurStdDeviationPixels` bound is clamped rather than +causing unbounded work - the box-blur implementation's own cost does not scale with the requested +radius, so this completes promptly and produces a heavily diluted (rather than crashing or +hanging) result; and asserts a filter chain with an excessive number of primitives (5,000 chained +`feGaussianBlur` primitives, mirroring a reported repro that took ~26 seconds prior to this bound) +is tolerantly skipped entirely rather than evaluated, completing promptly instead of performing +5,000 region-sized blur passes - bounding the primitive-count × region-area cost dimension that +`MaxTotalRenderedElements`/`GeometryWorkBudget` do not cover, since neither tracks a single +filter's own per-primitive, region-sized buffer cost. #### CanvasNet-Codecs-SvgCodec-ValidationNull: Null Stream/Path Rejected diff --git a/src/DemaConsulting.CanvasNet/Codecs/SvgCodec.cs b/src/DemaConsulting.CanvasNet/Codecs/SvgCodec.cs index 25e5e349..ab312226 100644 --- a/src/DemaConsulting.CanvasNet/Codecs/SvgCodec.cs +++ b/src/DemaConsulting.CanvasNet/Codecs/SvgCodec.cs @@ -3,6 +3,7 @@ // cspell:ignore evenodd nonzero viewbox gradientunits gradienttransform spreadmethod // cspell:ignore userspaceonuse objectboundingbox skewx skewy tspan // cspell:ignore rasterizing unparseable rrggbb sizeless bbox moveto multiplicatively pillarbox SMIL uncatchable formedness +// cspell:ignore unblurred premult // cspell:ignore aliceblue antiquewhite blanchedalmond blueviolet burlywood cadetblue cornflowerblue // cspell:ignore cornsilk darkcyan darkgoldenrod darkgray darkgreen darkgrey darkkhaki darkmagenta // cspell:ignore darkolivegreen darkorange darkorchid darkred darksalmon darkseagreen darkslateblue @@ -67,14 +68,27 @@ namespace DemaConsulting.CanvasNet.Codecs; /// fitted with the same "meet, centered" policy described below; marker content renders with /// its own fresh presentation-attribute cascade, never the referencing shape's fill/stroke - /// see this class's RenderMarkers/RenderOneMarker remarks for the documented -/// vertex-placement, orientation-averaging, and multi-subpath simplifications); and +/// vertex-placement, orientation-averaging, and multi-subpath simplifications); a +/// rect/circle/ellipse/line/polyline/polygon/ +/// path/text element's own filter="url(#id)" presentation attribute +/// (resolved via the same url(#id) dangling-reference tolerance, per element only - +/// never for a g/symbol group, and never for a shape's own marker content), +/// referencing a filter element whose fe* primitive children +/// (feFlood, feGaussianBlur, feOffset, feComposite with +/// operator over/in/out/atop/xor, and feMerge/ +/// feMergeNode) are evaluated in document order against an offscreen buffer sized to +/// the filter region, with the SourceGraphic and SourceAlpha implicit inputs +/// supported; the filter region defaults to objectBoundingBox's standard +/// -10% -10% 120% 120% (each independently overridable via x/y/ +/// width/height) - see this class's RenderFilteredShape/ +/// EvaluateFilterChain remarks for the documented simplifications; and /// text (with font-family best-effort /// matching against a caller-supplied font dictionary, font-size, fill, and /// text-anchor). /// /// /// Out of scope (silently ignored, per element). style blocks and CSS -/// class/id selectors, filter, mask, clipPath, pattern, +/// class/id selectors, mask, clipPath, pattern, /// SMIL animation (animate/animateTransform/animateMotion/ /// animateColor/set), image, foreignObject, nested svg, and /// an inline style="..." presentation attribute are all well-formed-but-unsupported @@ -85,6 +99,20 @@ namespace DemaConsulting.CanvasNet.Codecs; /// markerContentUnits (a rarely-used SVG 2 attribute) is not read, and a marker's /// overflow/clipping-to-its-own-viewport behavior is not implemented (marker content is /// never clipped to markerWidth/markerHeight) - both explicitly out of scope. +/// Within the supported filter feature itself, an explicit +/// filterUnits="userSpaceOnUse" is tolerantly ignored and always falls back to the +/// objectBoundingBox default region computation; group-level (g/symbol) +/// filtering and filtering a shape's own marker content are both not implemented (a +/// filter only ever affects the single element it is set on directly); and every +/// primitive type other than feFlood/feGaussianBlur/feOffset/ +/// feComposite/feMerge - feColorMatrix, feTurbulence, +/// feDisplacementMap, feImage, feTile, feDropShadow, +/// feConvolveMatrix, feDiffuseLighting, feSpecularLighting, +/// feComponentTransfer, and feMorphology - is a tolerant no-op passthrough of its +/// own input rather than actually implemented; feImage in particular is out of scope +/// specifically because it is the only primitive that could otherwise reference another +/// filtered element's own render output, and omitting it removes any need for an additional +/// filter-specific recursion-depth guard. /// /// /// ViewBox fitting. @@ -183,6 +211,53 @@ public static class SvgCodec /// private const int MaxMarkerDepth = 32; + /// + /// The maximum pixel-space effective stdDeviation a feGaussianBlur filter + /// primitive (see ) may use, after scaling the raw + /// local-space value by . This codec's box-blur + /// approximation (see 's remarks) uses a box radius of + /// roughly 1.88 * stdDeviation, so 250 caps a single pass's radius at + /// roughly 470 pixels - generous for any realistic halo/background blur effect, while + /// bounding the box-blur's per-row/per-column sliding-window cost (which is proportional + /// to the already-region-bounded buffer size, not the radius itself, but whose zero-padded + /// edge handling still becomes wastefully expensive for an absurdly large radius) to a + /// small, practical amount. Clamped rather than skipped/thrown, unlike an oversized filter + /// region (see ): a clamped blur still produces + /// a visually reasonable, just-less-blurred result, whereas a clamped-but-still-rendered + /// region would be silently mis-positioned. + /// + private const float MaxFilterBlurStdDeviationPixels = 250f; + + /// + /// The maximum number of fe* primitive children a single filter element's + /// chain is evaluated with, before the whole filter is tolerantly skipped (see + /// 's remarks). Every primitive's output buffer is exactly + /// the filter region's own pixel size (see 's remarks), so + /// evaluating N primitives against a region of area A costs O(N * A) - a cost dimension + /// neither (which counts each fe* child once, + /// assuming O(1)/O(perimeter) per-element cost, not O(region-area)-per-primitive cost) nor + /// (which only tracks path/points-list/text parsing work) + /// actually bounds. 1,000 is far beyond the longest real filter chain observed across this + /// repository's entire test/fixture corpus (10 primitives, in InkscapeFilters.svg's + /// filter48), while remaining small enough that even a pathologically tiny filter + /// region (where alone would not reject quickly) + /// cannot force an unbounded number of primitive evaluations. + /// + private const int MaxFilterPrimitivesPerFilter = 1_000; + + /// + /// The maximum combined "primitive count times filter-region pixel area" work a single + /// filter element's chain may be charged for, before the whole filter is tolerantly + /// skipped (see 's remarks) - the region-weighted + /// counterpart to , bounding the complementary case + /// of a chain that stays under that flat count cap but targets an unreasonably large region. + /// 5,000,000 is more than 100 times the largest single real charge (40,000: one primitive + /// against a 200x200 region) observed across this repository's entire test/fixture corpus, + /// while remaining far below the cost a pathological chain (for example 5,000 primitives + /// against a modest ~180x180 region, charging 162,000,000) would otherwise incur. + /// + private const long MaxFilterPrimitiveWorkUnits = 5_000_000L; + /// /// The maximum recursion depth this codec descends through /// while walking the element tree - covering plain g/symbol nesting as well @@ -1003,7 +1078,7 @@ private sealed record RenderContext( /// private static readonly HashSet NonRenderingElements = new(StringComparer.Ordinal) { - "defs", "clipPath", "mask", "pattern", "marker", "linearGradient", "radialGradient" + "defs", "clipPath", "mask", "pattern", "marker", "linearGradient", "radialGradient", "filter" }; /// @@ -1012,7 +1087,7 @@ private sealed record RenderContext( /// private static readonly HashSet SkippedElements = new(StringComparer.Ordinal) { - "style", "filter", "animate", "animateTransform", "animateMotion", "animateColor", "set", + "style", "animate", "animateTransform", "animateMotion", "animateColor", "set", "image", "foreignObject", "svg", "metadata", "title", "desc", "script" }; @@ -1168,21 +1243,21 @@ private static void RenderElement( break; case "rect": - RenderShape(BuildRectPath(element), state, transform, context); + RenderShapeWithFilter(element, BuildRectPath(element), state, transform, context); break; case "circle": - RenderShape(BuildEllipsePath(element, isCircle: true), state, transform, context); + RenderShapeWithFilter(element, BuildEllipsePath(element, isCircle: true), state, transform, context); break; case "ellipse": - RenderShape(BuildEllipsePath(element, isCircle: false), state, transform, context); + RenderShapeWithFilter(element, BuildEllipsePath(element, isCircle: false), state, transform, context); break; case "line": { var linePath = BuildLinePath(element); - RenderShape(linePath, state, transform, context); + RenderShapeWithFilter(element, linePath, state, transform, context); RenderMarkers(linePath, state, transform, context, useDepth, elementDepth, markerDepth, ref totalElements, workBudget); break; } @@ -1190,7 +1265,7 @@ private static void RenderElement( case "polyline": { var polylinePath = BuildPolyPath(element, closed: false, workBudget); - RenderShape(polylinePath, state, transform, context); + RenderShapeWithFilter(element, polylinePath, state, transform, context); RenderMarkers(polylinePath, state, transform, context, useDepth, elementDepth, markerDepth, ref totalElements, workBudget); break; } @@ -1198,7 +1273,7 @@ private static void RenderElement( case "polygon": { var polygonPath = BuildPolyPath(element, closed: true, workBudget); - RenderShape(polygonPath, state, transform, context); + RenderShapeWithFilter(element, polygonPath, state, transform, context); RenderMarkers(polygonPath, state, transform, context, useDepth, elementDepth, markerDepth, ref totalElements, workBudget); break; } @@ -1206,7 +1281,7 @@ private static void RenderElement( case "path": { var dataPath = BuildPathDataPath(element, workBudget); - RenderShape(dataPath, state, transform, context); + RenderShapeWithFilter(element, dataPath, state, transform, context); RenderMarkers(dataPath, state, transform, context, useDepth, elementDepth, markerDepth, ref totalElements, workBudget); break; } @@ -2880,6 +2955,863 @@ private static float EstimateEffectiveStrokeWidth(float strokeWidth, float unifo private static float[]? ScaleDashArray(IReadOnlyList? dashArray, float scale) => dashArray?.Select(value => value * scale).ToArray(); + // ================================================================================================ + // Filter rendering + // ================================================================================================ + + /// + /// Identifies the four feComposite Porter-Duff operators (other than the default + /// over, which reuses directly) that + /// require the dedicated per-pixel blend helper . + /// + private enum FeCompositeOperator + { + /// Keeps the foreground only where the background has coverage. + In, + + /// Keeps the foreground only where the background has no coverage. + Out, + + /// Keeps the foreground over the background, but only where the background has coverage. + Atop, + + /// Keeps each input only where the other does not have coverage. + Xor + } + + /// + /// Renders through 's own filter + /// presentation attribute, if any, otherwise through the ordinary unfiltered + /// pipeline. + /// + /// + /// The originating element (a shape, or a text element whose already-laid-out + /// glyph-run outline is passed as ), whose own filter + /// attribute is read directly - filter does not cascade through , + /// unlike fill/stroke/marker specifications, per CSS/SVG semantics. + /// + /// The shape's (or glyph run's) already-built local-space outline. + /// The cascaded render state. + /// The accumulated transform from local space into pixel space. + /// The fixed per-document render context. + /// + /// A filter value of none/absent/not url(#id) syntax, a dangling id, + /// or an id resolving to an element not literally named filter are all tolerated by + /// rendering normally through - the + /// same dangling-reference tolerance convention as and + /// . Filter support applies per-element only: it is never + /// invoked for a g/symbol group (group-level filtering is out of scope) and + /// never applied to a shape's own marker content (markers always render directly onto + /// 's surface, unaffected by the referencing shape's own + /// filter) - see 's remarks for the full filter + /// evaluation pipeline. + /// + private static void RenderShapeWithFilter(XElement element, Path localPath, RenderState state, Matrix3x2 transform, RenderContext context) + { + var filterElement = ResolveFilterElement(element, context); + if (filterElement == null) + { + RenderShape(localPath, state, transform, context); + return; + } + + RenderFilteredShape(filterElement, localPath, state, transform, context); + } + + /// + /// Resolves 's own filter presentation attribute + /// (url(#id)) to its referenced filter element, reusing the exact same + /// url(#id)-parsing and dangling-reference tolerance as / + /// . + /// + /// The element whose own filter attribute is read. + /// The fixed per-document render context. + /// + /// The referenced filter element, or if the attribute is + /// absent/blank, not url(#id) syntax, the id is dangling, or the resolved element is + /// not literally a filter. + /// + private static XElement? ResolveFilterElement(XElement element, RenderContext context) + { + var spec = (string?)element.Attribute("filter"); + if (string.IsNullOrWhiteSpace(spec)) + { + return null; + } + + var trimmed = spec.Trim(); + if (!trimmed.StartsWith("url(", StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + var id = ExtractUrlId(trimmed); + if (id == null || !context.IdIndex.TryGetValue(id, out var candidate)) + { + return null; + } + + return candidate.Name.LocalName == "filter" ? candidate : null; + } + + /// + /// Renders into a temporary, filter-region-sized offscreen + /// (SourceGraphic), evaluates 's + /// own primitive chain against it, then composites the resulting buffer onto + /// 's real surface at the filter region's pixel position. + /// + /// The resolved filter element. + /// The shape's (or glyph run's) already-built local-space outline. + /// The cascaded render state. + /// The accumulated transform from local space into pixel space. + /// The fixed per-document render context. + /// + /// If the filter region cannot be computed (an empty/degenerate local bounding box), or its + /// pixel-space size is non-finite, non-positive, or exceeds + /// on either axis, the whole filter effect is tolerantly skipped - + /// renders exactly as if filter were absent - rather than attempting to clamp and + /// still render at a smaller, silently mis-positioned region. See + /// for the region computation itself. + /// + /// The SourceGraphic buffer is produced by re-entering + /// against a temporary (context with { Surface = ... }, + /// correctly sharing 's own ) + /// and a transform translated so the region's own pixel origin lands at the temporary + /// surface's local (0, 0). The final filtered buffer is composited back using + /// + /// - the existing offset-aware compositing primitive - one row at a time, clipped to + /// 's own surface bounds, rather than any new per-pixel blending + /// math. + /// + /// + /// A filter chain whose own fe* primitive count or primitive-count-times-region-area + /// work exceeds / + /// (see ) is tolerantly skipped identically - + /// checked before SourceGraphic is even allocated, so no per-primitive work (buffer + /// allocation, blur passes, compositing) ever begins for a rejected chain. + /// + /// + private static void RenderFilteredShape(XElement filterElement, Path localPath, RenderState state, Matrix3x2 transform, RenderContext context) + { + var region = ComputeFilterRegionPixelBounds(filterElement, localPath, transform); + if (region == null) + { + RenderShape(localPath, state, transform, context); + return; + } + + var (pixelX, pixelY, pixelWidth, pixelHeight) = region.Value; + + var primitiveCount = filterElement.Elements().Count(); + if (!IsFilterPrimitiveWorkWithinBudget(primitiveCount, pixelWidth, pixelHeight)) + { + RenderShape(localPath, state, transform, context); + return; + } + + var sourceGraphic = new Surface(pixelWidth, pixelHeight); + var localToTemp = transform * Matrix3x2.CreateTranslation(-pixelX, -pixelY); + var tempContext = context with { Surface = sourceGraphic }; + RenderShape(localPath, state, localToTemp, tempContext); + + var finalSurface = EvaluateFilterChain(filterElement, sourceGraphic, transform); + + CompositeFilterResultOntoCanvas(finalSurface, pixelX, pixelY, context.Surface); + } + + /// + /// Computes 's filter region - always as if + /// filterUnits="objectBoundingBox", the SVG default, regardless of what an explicit + /// filterUnits="userSpaceOnUse" actually says (a documented, deliberate + /// simplification, see this class's remarks) - and converts it to an integer pixel-space + /// bounding box, rounded outward. + /// + /// The resolved filter element. + /// The referencing shape's local-space outline. + /// The accumulated transform from local space into pixel space. + /// + /// The pixel-space region as (X, Y, Width, Height), or if + /// 's local bounds are empty/degenerate, the region resolves to + /// a non-positive size, its pixel-space transform is non-finite or exceeds + /// , or its rounded pixel size exceeds + /// on either axis. + /// + private static (int X, int Y, int Width, int Height)? ComputeFilterRegionPixelBounds( + XElement filterElement, Path localPath, Matrix3x2 transform) + { + var bounds = localPath.GetBounds(); + if (bounds.IsEmpty || bounds.Width <= 0f || bounds.Height <= 0f) + { + return null; + } + + // SVG default filter region: -10% -10% 120% 120% of the referencing element's own + // objectBoundingBox, each independently overridable via x/y/width/height + var xFraction = ParseFilterRegionFraction(filterElement, "x", -0.10f); + var yFraction = ParseFilterRegionFraction(filterElement, "y", -0.10f); + var widthFraction = ParseFilterRegionFraction(filterElement, "width", 1.20f); + var heightFraction = ParseFilterRegionFraction(filterElement, "height", 1.20f); + + var localRegion = new Rect( + bounds.X + (xFraction * bounds.Width), + bounds.Y + (yFraction * bounds.Height), + widthFraction * bounds.Width, + heightFraction * bounds.Height); + + if (localRegion.Width <= 0f || localRegion.Height <= 0f) + { + return null; + } + + var pixelRegion = localRegion.Transform(transform); + if (!float.IsFinite(pixelRegion.X) || !float.IsFinite(pixelRegion.Y) || + !float.IsFinite(pixelRegion.Width) || !float.IsFinite(pixelRegion.Height) || + pixelRegion.Width <= 0f || pixelRegion.Height <= 0f || + MathF.Abs(pixelRegion.X) > MaxCoordinateMagnitude || MathF.Abs(pixelRegion.Y) > MaxCoordinateMagnitude || + pixelRegion.Width > MaxCoordinateMagnitude || pixelRegion.Height > MaxCoordinateMagnitude) + { + return null; + } + + var minX = (int)MathF.Floor(pixelRegion.X); + var minY = (int)MathF.Floor(pixelRegion.Y); + var maxX = (int)MathF.Ceiling(pixelRegion.X + pixelRegion.Width); + var maxY = (int)MathF.Ceiling(pixelRegion.Y + pixelRegion.Height); + + var width = maxX - minX; + var height = maxY - minY; + if (width <= 0 || height <= 0 || width > Surface.MaxDimension || height > Surface.MaxDimension) + { + return null; + } + + return (minX, minY, width, height); + } + + /// + /// Reads one of a filter element's x/y/width/height + /// region attributes as an objectBoundingBox fraction, tolerantly falling back to + /// when the attribute is absent or does not parse as a + /// number/percentage - reusing the existing helper + /// already used identically for gradient objectBoundingBox-relative coordinates. + /// + private static float ParseFilterRegionFraction(XElement filterElement, string attributeName, float defaultValue) + { + var raw = (string?)filterElement.Attribute(attributeName); + return raw == null ? defaultValue : ParsePercentOrNumber(raw, 1f) ?? defaultValue; + } + + /// + /// Determines whether evaluating fe* primitives + /// against a x filter region stays within + /// this class's two filter work bounds (see / + /// ) - checked once, upfront, before any primitive + /// is actually evaluated (a cheap child-element count plus one multiply/compare), so a + /// pathological chain is rejected near-instantly rather than after partially evaluating it. + /// + /// The filter element's own fe* child count. + /// The filter region's pixel width. + /// The filter region's pixel height. + /// if the chain may be evaluated; otherwise . + private static bool IsFilterPrimitiveWorkWithinBudget(int primitiveCount, int width, int height) => + primitiveCount <= MaxFilterPrimitivesPerFilter && + (long)primitiveCount * width * height <= MaxFilterPrimitiveWorkUnits; + + /// + /// Evaluates 's fe* primitive children, in document + /// order, against . + /// + /// The resolved filter element. + /// + /// The already-rendered SourceGraphic buffer, sized to the filter region. + /// + /// + /// The referencing shape's own accumulated transform, used only to estimate the pixel-space + /// scale for feGaussianBlur/feOffset via . + /// + /// + /// The last document-order primitive's own output buffer, or + /// itself if has no fe* children at all (a + /// degenerate spec edge case tolerated as "no filter"). + /// + /// + /// Every buffer produced by every primitive in this pipeline is exactly + /// 's own size - this invariant is what lets + /// feComposite/feMerge reuse + /// directly, since that method requires equal-size surfaces. in/in2 name + /// resolution follows this fixed precedence: "SourceGraphic" resolves to + /// itself; "SourceAlpha" resolves to a lazily-built, + /// alpha-only copy of it; a name matching an earlier primitive's own result + /// resolves to that primitive's output; an absent/empty name resolves to the immediately + /// preceding primitive's own output (or for the very first + /// primitive); and any other (dangling/unrecognized) name tolerantly falls back to + /// - a documented simplification. Any primitive type other + /// than feFlood/feGaussianBlur/feOffset/feComposite/feMerge + /// (for example feColorMatrix, feTurbulence, feDisplacementMap, + /// feImage, feTile, feDropShadow, feConvolveMatrix, + /// feDiffuseLighting, feSpecularLighting, feComponentTransfer, or + /// feMorphology) is a tolerant no-op passthrough of its own resolved in input, + /// registered under its own result name (if any) so later primitives in the chain + /// still resolve correctly by name - feImage in particular is entirely out of scope, + /// which also means a filter chain can never reference another filtered element's own + /// render output, so no additional recursion-depth guard is needed here. This method itself + /// needs no internal primitive-count/work-budget guard: its only caller, + /// , already guarantees both stay within + /// / + /// before this method is ever invoked (see ). + /// + private static Surface EvaluateFilterChain(XElement filterElement, Surface sourceGraphic, Matrix3x2 transform) + { + var scale = EstimateUniformScale(transform); + var results = new Dictionary(StringComparer.Ordinal); + Surface? previousResult = null; + Surface? sourceAlpha = null; + + Surface ResolveInput(string? name) + { + if (string.IsNullOrEmpty(name)) + { + return previousResult ?? sourceGraphic; + } + + if (string.Equals(name, "SourceGraphic", StringComparison.Ordinal)) + { + return sourceGraphic; + } + + if (string.Equals(name, "SourceAlpha", StringComparison.Ordinal)) + { + return sourceAlpha ??= BuildSourceAlpha(sourceGraphic); + } + + return results.TryGetValue(name, out var namedResult) ? namedResult : sourceGraphic; + } + + foreach (var primitive in filterElement.Elements()) + { + var input = ResolveInput((string?)primitive.Attribute("in")); + + Surface output; + switch (primitive.Name.LocalName) + { + case "feFlood": + output = ApplyFeFlood(primitive, sourceGraphic.Width, sourceGraphic.Height); + break; + + case "feGaussianBlur": + output = ApplyFeGaussianBlur(primitive, input, scale); + break; + + case "feOffset": + output = ApplyFeOffset(primitive, input, scale); + break; + + case "feComposite": + output = ApplyFeComposite(primitive, input, ResolveInput((string?)primitive.Attribute("in2"))); + break; + + case "feMerge": + output = ApplyFeMerge(primitive, sourceGraphic.Width, sourceGraphic.Height, ResolveInput); + break; + + default: + // Tolerant no-op passthrough for every unsupported primitive type - see this + // method's remarks for the full enumerated list + output = input; + break; + } + + var resultName = (string?)primitive.Attribute("result"); + if (!string.IsNullOrEmpty(resultName)) + { + results[resultName] = output; + } + + previousResult = output; + } + + return previousResult ?? sourceGraphic; + } + + /// Builds a same-size copy of with every pixel's color forced to black, alpha unchanged. + /// The buffer to derive the alpha-only copy from. + /// The lazily-built SourceAlpha implicit filter input. + private static Surface BuildSourceAlpha(Surface source) + { + var output = new Surface(source.Width, source.Height); + for (var y = 0; y < source.Height; y++) + { + var sourceRow = source.GetRowSpan(y); + var outputRow = output.GetRowSpan(y); + for (var x = 0; x < source.Width; x++) + { + outputRow[x] = new Rgba32(0, 0, 0, sourceRow[x].A); + } + } + + return output; + } + + /// + /// Evaluates a feFlood primitive: a new same-size buffer filled with a constant + /// flood-color/flood-opacity color, via - + /// no new blending math. + /// + /// The feFlood primitive element. + /// The filter pipeline's fixed buffer width. + /// The filter pipeline's fixed buffer height. + /// The filled buffer. + /// + /// flood-color defaults to (and tolerantly falls back to, if absent or + /// unrecognized) opaque black, matching the SVG initial value; flood-opacity + /// defaults to (and tolerantly falls back to) 1, clamped to [0, 1]. + /// + private static Surface ApplyFeFlood(XElement element, int width, int height) + { + var rawColor = (string?)element.Attribute("flood-color"); + var color = (rawColor != null ? ParseColor(rawColor.Trim()) : null) ?? new Rgba32(0, 0, 0, 255); + + var rawOpacity = (string?)element.Attribute("flood-opacity"); + var opacity = rawOpacity == null ? 1f : ParsePercentOrNumber(rawOpacity, 1f) ?? 1f; + opacity = Math.Clamp(opacity, 0f, 1f); + + var surface = new Surface(width, height); + surface.Clear(ApplyAlpha(color, opacity)); + return surface; + } + + /// + /// Evaluates a feGaussianBlur primitive using the SVG specification's own documented + /// three-pass box-blur approximation of a true Gaussian blur. + /// + /// The feGaussianBlur primitive element. + /// The already-resolved input buffer. + /// + /// The pixel-space scale factor (see ) used to convert + /// the local-space stdDeviation into an effective pixel-space value. + /// + /// + /// A new, independent, blurred buffer - or an independent unblurred copy of + /// if the effective stdDeviation clamps to zero or below + /// (an absent/zero/invalid value, or a non-finite scaled result). + /// + /// + /// Only the first whitespace/comma-separated token of stdDeviation is read (a + /// separate x/y pair, a rarely-used form, is tolerated by treating the value as isotropic - + /// a documented simplification). The effective pixel-space stdDeviation is clamped + /// to (see that constant's remarks). The + /// box radius is floor(stdDeviation * 3 * sqrt(2*pi) / 4 + 0.5), applied as three + /// successive horizontal-then-vertical box-blur passes (see / + /// ) over 's -converted + /// copy, reusing that already-tested method (and its + /// inverse) rather than re-deriving premultiplication. + /// + private static Surface ApplyFeGaussianBlur(XElement element, Surface input, float scale) + { + var rawStdDeviation = ParseFirstNumberToken((string?)element.Attribute("stdDeviation")) ?? 0f; + var stdDeviation = rawStdDeviation * scale; + if (!float.IsFinite(stdDeviation)) + { + stdDeviation = 0f; + } + + stdDeviation = Math.Clamp(stdDeviation, 0f, MaxFilterBlurStdDeviationPixels); + + var radius = stdDeviation > 0f + ? (int)MathF.Floor((stdDeviation * 3f * MathF.Sqrt(2f * MathF.PI) / 4f) + 0.5f) + : 0; + + var working = input.Crop(0, 0, input.Width, input.Height); + if (radius <= 0) + { + return working; + } + + working.PremultiplyAlpha(); + for (var pass = 0; pass < 3; pass++) + { + BoxBlurHorizontal(working, radius); + BoxBlurVertical(working, radius); + } + + working.UnpremultiplyAlpha(); + return working; + } + + /// + /// Applies one horizontal box-blur pass, in place, to every row of , + /// using a running-sum sliding window (cost proportional to width/height, independent of + /// ) with zero-padding beyond the surface's own edges. + /// + /// The already-premultiplied-alpha buffer to blur in place. + /// The box-blur radius (window size is 2 * radius + 1). + private static void BoxBlurHorizontal(Surface surface, int radius) + { + var width = surface.Width; + var windowSize = (2 * radius) + 1; + var outR = new byte[width]; + var outG = new byte[width]; + var outB = new byte[width]; + var outA = new byte[width]; + + for (var y = 0; y < surface.Height; y++) + { + var row = surface.GetRowSpan(y); + + long sumR = 0, sumG = 0, sumB = 0, sumA = 0; + for (var k = 0; k <= radius && k < width; k++) + { + var p = row[k]; + sumR += p.R; + sumG += p.G; + sumB += p.B; + sumA += p.A; + } + + for (var x = 0; x < width; x++) + { + outR[x] = (byte)(sumR / windowSize); + outG[x] = (byte)(sumG / windowSize); + outB[x] = (byte)(sumB / windowSize); + outA[x] = (byte)(sumA / windowSize); + + var addIndex = x + radius + 1; + if (addIndex < width) + { + var p = row[addIndex]; + sumR += p.R; + sumG += p.G; + sumB += p.B; + sumA += p.A; + } + + var removeIndex = x - radius; + if (removeIndex >= 0) + { + var p = row[removeIndex]; + sumR -= p.R; + sumG -= p.G; + sumB -= p.B; + sumA -= p.A; + } + } + + for (var x = 0; x < width; x++) + { + row[x] = new Rgba32(outR[x], outG[x], outB[x], outA[x]); + } + } + } + + /// + /// Applies one vertical box-blur pass, in place, to every column of - + /// the column-wise counterpart of , using the identical + /// running-sum sliding-window/zero-padding algorithm, via the surface's own pixel indexer + /// (no column-span accessor exists on ). + /// + /// The already-premultiplied-alpha buffer to blur in place. + /// The box-blur radius (window size is 2 * radius + 1). + private static void BoxBlurVertical(Surface surface, int radius) + { + var height = surface.Height; + var windowSize = (2 * radius) + 1; + var outR = new byte[height]; + var outG = new byte[height]; + var outB = new byte[height]; + var outA = new byte[height]; + + for (var x = 0; x < surface.Width; x++) + { + long sumR = 0, sumG = 0, sumB = 0, sumA = 0; + for (var k = 0; k <= radius && k < height; k++) + { + var p = surface[x, k]; + sumR += p.R; + sumG += p.G; + sumB += p.B; + sumA += p.A; + } + + for (var y = 0; y < height; y++) + { + outR[y] = (byte)(sumR / windowSize); + outG[y] = (byte)(sumG / windowSize); + outB[y] = (byte)(sumB / windowSize); + outA[y] = (byte)(sumA / windowSize); + + var addIndex = y + radius + 1; + if (addIndex < height) + { + var p = surface[x, addIndex]; + sumR += p.R; + sumG += p.G; + sumB += p.B; + sumA += p.A; + } + + var removeIndex = y - radius; + if (removeIndex >= 0) + { + var p = surface[x, removeIndex]; + sumR -= p.R; + sumG -= p.G; + sumB -= p.B; + sumA -= p.A; + } + } + + for (var y = 0; y < height; y++) + { + surface[x, y] = new Rgba32(outR[y], outG[y], outB[y], outA[y]); + } + } + } + + /// Parses the first whitespace/comma-separated numeric token of a raw attribute value. + /// The raw attribute text, or if absent. + /// The parsed value, or if absent/blank/unparseable. + private static float? ParseFirstNumberToken(string? raw) + { + if (string.IsNullOrWhiteSpace(raw)) + { + return null; + } + + var tokens = raw.Split([' ', ','], StringSplitOptions.RemoveEmptyEntries); + return tokens.Length == 0 ? null : ParsePercentOrNumber(tokens[0], 1f); + } + + /// + /// Evaluates a feOffset primitive: shifts 's pixel content + /// by dx/dy (scaled to pixel space by ) via a clipped + /// row-copy loop - a pure data copy, not blending math. + /// + /// The feOffset primitive element. + /// The already-resolved input buffer. + /// The pixel-space scale factor (see ). + /// A new, same-size buffer with 's content shifted; pixels shifted off the edge are lost, and newly exposed pixels are fully transparent. + private static Surface ApplyFeOffset(XElement element, Surface input, float scale) + { + var dx = ParsePercentOrNumber((string?)element.Attribute("dx") ?? string.Empty, 1f) ?? 0f; + var dy = ParsePercentOrNumber((string?)element.Attribute("dy") ?? string.Empty, 1f) ?? 0f; + + var pixelDx = (int)MathF.Round(dx * scale); + var pixelDy = (int)MathF.Round(dy * scale); + + var width = input.Width; + var height = input.Height; + var output = new Surface(width, height); + + for (var destY = 0; destY < height; destY++) + { + var sourceY = destY - pixelDy; + if (sourceY < 0 || sourceY >= height) + { + continue; + } + + var destStart = Math.Max(0, pixelDx); + var destEnd = Math.Min(width, width + pixelDx); + if (destEnd <= destStart) + { + continue; + } + + var sourceRow = input.GetRowSpan(sourceY); + var destRow = output.GetRowSpan(destY); + var length = destEnd - destStart; + sourceRow.Slice(destStart - pixelDx, length).CopyTo(destRow.Slice(destStart, length)); + } + + return output; + } + + /// + /// Evaluates a feComposite primitive: operator="over" (the default, if + /// absent) reuses directly (its formula + /// already is Porter-Duff "over"); in/out/atop/xor use + /// the dedicated per-pixel helper; any other/unrecognized + /// operator value (for example "arithmetic", seen in real-world documents) is a + /// tolerant no-op passthrough of , ignoring . + /// + /// The feComposite primitive element. + /// The already-resolved in input buffer. + /// The already-resolved in2 input buffer. + /// A new, independent output buffer. + private static Surface ApplyFeComposite(XElement element, Surface input, Surface input2) + { + var op = ((string?)element.Attribute("operator"))?.Trim().ToLowerInvariant(); + switch (op) + { + case null: + case "": + case "over": + var result = input2.Crop(0, 0, input2.Width, input2.Height); + result.CompositeOver(input); + return result; + + case "in": + return CompositeFeOperator(input, input2, FeCompositeOperator.In); + + case "out": + return CompositeFeOperator(input, input2, FeCompositeOperator.Out); + + case "atop": + return CompositeFeOperator(input, input2, FeCompositeOperator.Atop); + + case "xor": + return CompositeFeOperator(input, input2, FeCompositeOperator.Xor); + + default: + // An unrecognized operator value (e.g. "arithmetic") is a tolerant no-op + // passthrough of the "in" input, ignoring in2/k1..k4 + return input.Crop(0, 0, input.Width, input.Height); + } + } + + /// + /// Composites two same-size buffers using one of the four Porter-Duff operators with no + /// existing equivalent (in/out/atop/xor - + /// over instead reuses directly). This + /// is the one place in this class's filter support genuinely new per-pixel blending math is + /// unavoidable, kept small and isolated, working on straight (unassociated) alpha read via + /// . + /// + /// The in input buffer ("A" in the Porter-Duff formulas below). + /// The in2 input buffer ("B" in the Porter-Duff formulas below). + /// Which of the four operators to apply. + /// A new, independent output buffer, the same size as both inputs. + /// + /// For each pixel, using premultiplied per-channel products + /// (Ca * Aa/Cb * Ab) and per-operator weights (Fa, Fb) - + /// in: (Ab, 0); out: (1 - Ab, 0); atop: (Ab, 1 - Aa); + /// xor: (1 - Ab, 1 - Aa) - the standard Porter-Duff formulas apply: + /// outAlpha = Fa * Aa + Fb * Ab, and each output channel is + /// (Fa * Ca * Aa + Fb * Cb * Ab) / outAlpha (or 0 if outAlpha is zero). + /// + private static Surface CompositeFeOperator(Surface foreground, Surface background, FeCompositeOperator op) + { + var width = foreground.Width; + var height = foreground.Height; + var output = new Surface(width, height); + + for (var y = 0; y < height; y++) + { + var fgRow = foreground.GetRowSpan(y); + var bgRow = background.GetRowSpan(y); + var outRow = output.GetRowSpan(y); + + for (var x = 0; x < width; x++) + { + var fg = fgRow[x]; + var bg = bgRow[x]; + + var fgA = fg.A / 255f; + var bgA = bg.A / 255f; + + var (weightFg, weightBg) = op switch + { + FeCompositeOperator.In => (bgA, 0f), + FeCompositeOperator.Out => (1f - bgA, 0f), + FeCompositeOperator.Atop => (bgA, 1f - fgA), + _ => (1f - bgA, 1f - fgA) // Xor + }; + + var outA = (weightFg * fgA) + (weightBg * bgA); + byte outR, outG, outB; + if (outA <= 0f) + { + outR = outG = outB = 0; + } + else + { + var premultR = (weightFg * fg.R * fgA) + (weightBg * bg.R * bgA); + var premultG = (weightFg * fg.G * fgA) + (weightBg * bg.G * bgA); + var premultB = (weightFg * fg.B * fgA) + (weightBg * bg.B * bgA); + outR = (byte)Math.Clamp(MathF.Round(premultR / outA), 0f, 255f); + outG = (byte)Math.Clamp(MathF.Round(premultG / outA), 0f, 255f); + outB = (byte)Math.Clamp(MathF.Round(premultB / outA), 0f, 255f); + } + + outRow[x] = new Rgba32(outR, outG, outB, (byte)Math.Clamp(MathF.Round(outA * 255f), 0f, 255f)); + } + } + + return output; + } + + /// + /// Evaluates a feMerge primitive: starts from a fresh, fully transparent buffer and + /// composites each feMergeNode child's own resolved in input over it, in + /// document order, via - whose "foreground + /// over background" semantics already are feMerge's own "later nodes on top of + /// earlier ones" semantics, so no new blending math is needed. + /// + /// The feMerge primitive element. + /// The filter pipeline's fixed buffer width. + /// The filter pipeline's fixed buffer height. + /// The enclosing filter chain's own in-name resolution function. + /// The merged buffer. + /// + /// A feMergeNode's own in follows the identical resolution rule as any other + /// primitive's in - notably, an absent in resolves to the filter chain's own + /// running previousResult (not reset between merge nodes), a documented + /// simplification. Any non-feMergeNode child is tolerantly ignored. + /// + private static Surface ApplyFeMerge(XElement feMergeElement, int width, int height, Func resolveInput) + { + var accumulator = new Surface(width, height); + foreach (var node in feMergeElement.Elements()) + { + if (node.Name.LocalName != "feMergeNode") + { + continue; + } + + var nodeInput = resolveInput((string?)node.Attribute("in")); + accumulator.CompositeOver(nodeInput); + } + + return accumulator; + } + + /// + /// Composites - the filter chain's final output buffer - onto + /// at pixel position (, + /// ), one row at a time via + /// , + /// clipped to 's own bounds. + /// + /// The filter chain's final output buffer. + /// The filter region's pixel-space X origin, which can be negative or beyond 's own width. + /// The filter region's pixel-space Y origin, which can be negative or beyond 's own height. + /// The real surface every other element also renders onto. + private static void CompositeFilterResultOntoCanvas(Surface result, int pixelX, int pixelY, Surface canvas) + { + var coverage = new float[result.Width]; + Array.Fill(coverage, 1f); + + for (var row = 0; row < result.Height; row++) + { + var canvasY = pixelY + row; + if (canvasY < 0 || canvasY >= canvas.Height) + { + continue; + } + + var startCol = Math.Max(0, -pixelX); + var endCol = Math.Min(result.Width, canvas.Width - pixelX); + if (endCol <= startCol) + { + continue; + } + + var length = endCol - startCol; + var rowSpan = result.GetRowSpan(row); + canvas.CompositeOverSpan(canvasY, pixelX + startCol, coverage.AsSpan(0, length), rowSpan.Slice(startCol, length)); + } + } + // ================================================================================================ // Paint and color resolution // ================================================================================================ @@ -4197,7 +5129,7 @@ private static void RenderText(XElement element, RenderState state, Matrix3x2 tr var origin = new Vector2(GetFloatAttribute(element, "x"), GetFloatAttribute(element, "y")); var glyphRunPath = BuildGlyphRunPath(text, font, state, origin); - RenderShape(glyphRunPath, state, transform, context); + RenderShapeWithFilter(element, glyphRunPath, state, transform, context); } /// diff --git a/test/DemaConsulting.CanvasNet.Tests/Codecs/SvgCodecTests.cs b/test/DemaConsulting.CanvasNet.Tests/Codecs/SvgCodecTests.cs index 92cc9ac6..96de0bd1 100644 --- a/test/DemaConsulting.CanvasNet.Tests/Codecs/SvgCodecTests.cs +++ b/test/DemaConsulting.CanvasNet.Tests/Codecs/SvgCodecTests.cs @@ -2,6 +2,7 @@ // cspell:ignore Dasharray hhea Hhea hmtx Hmtx hrefs letterboxed Loca Maxp unstroked // cspell:ignore miterlimit // cspell:ignore unparseable overpainted bbox moveto lineto rects unrotated +using System.Diagnostics; using System.Reflection; using System.Text; using System.Xml.Linq; @@ -2143,6 +2144,518 @@ public void SvgCodec_Load_MarkerContent_DoesNotInheritReferencingShapeFillOrStro Assert.Equal(new Rgba32(0, 0, 0, 255), surface[7, 5]); } + // ================================================================================================ + // element + // ================================================================================================ + + /// + /// Proves that a filter containing only a bare feFlood primitive replaces the + /// referencing element's own content entirely: the flood color fills the whole (default, + /// bounding-box-relative) filter region, including the area behind the element's own shape, + /// since a lone feFlood never references SourceGraphic. + /// + [Fact] + public void SvgCodec_Load_FeFloodFilter_RendersSolidColorBehindElement() + { + // Arrange: a 20x20 blue rect at (40,40); the filter's default region expands the rect's + // own bounding box by -10%/120%, i.e. (38,38)-(62,62) + const string svg = """ + + + + + + + + + """; + + // Act + using var stream = ToStream(svg); + var surface = SvgCodec.Load(stream, 100, 100); + + // Assert: the flood fills the expanded region outside the rect's own bounds + Assert.Equal(new Rgba32(255, 0, 0, 255), surface[39, 50]); + + // Assert: the flood also fully replaces the rect's own blue fill at its center, since the + // filter's final output is the bare feFlood result, not a merge with SourceGraphic + Assert.Equal(new Rgba32(255, 0, 0, 255), surface[50, 50]); + } + + /// + /// Proves that feMerge layers named results in document order: a feFlood + /// result placed first, then SourceGraphic placed second, renders the flood behind + /// the element's own content (visible outside its bounds) while the element's own fill + /// remains visible on top at its own location. + /// + [Fact] + public void SvgCodec_Load_FeFloodFeMergeFilter_RendersFloodBehindSourceGraphic() + { + // Arrange + const string svg = """ + + + + + + + + + + + + + """; + + // Act + using var stream = ToStream(svg); + var surface = SvgCodec.Load(stream, 100, 100); + + // Assert: the flood is visible in the expanded region outside the rect's own bounds + Assert.Equal(new Rgba32(255, 255, 0, 255), surface[39, 50]); + + // Assert: the rect's own blue fill remains visible on top of the flood at its center + Assert.Equal(new Rgba32(0, 0, 255, 255), surface[50, 50]); + } + + /// + /// Proves that a feFlood → feComposite (operator="out", clipping the + /// flood to the area the element's own content does not cover) → feGaussianBlur + /// → feMerge chain (a conventional halo/glow recipe) actually softens the flood's + /// edge - proving the blur genuinely ran, rather than the filter being silently ignored - + /// while leaving the element's own fill fully opaque and unaffected at its center. + /// + [Fact] + public void SvgCodec_Load_FeFloodFeGaussianBlurFeCompositeFilter_RendersBlurredHaloBehindContent() + { + // Arrange + const string svg = """ + + + + + + + + + + + + + + + """; + + // Act + using var stream = ToStream(svg); + var surface = SvgCodec.Load(stream, 100, 100); + + // Assert: the rect's own fill remains fully opaque and unaffected at its center + Assert.Equal(new Rgba32(0, 128, 0, 255), surface[50, 50]); + + // Assert: just outside the rect's own edge, a softened (partially transparent, not + // hard-edged) orange halo is visible - proving the blur actually ran + Assert.Equal(new Rgba32(255, 161, 0, 19), surface[61, 50]); + + // Assert: a couple of pixels further out, past the blur's influence, nothing remains + Assert.Equal(0, surface[62, 50].A); + } + + /// + /// Proves feComposite operator="in" keeps the "in" input's own color, weighted by the + /// "in2" input's own alpha, per the documented Porter-Duff "in" formula + /// ((Fa, Fb) = (Ab, 0)) - two full-region, semi-transparent feFlood inputs + /// isolate CompositeFeOperator's own per-pixel math from any shape-geometry/filter- + /// region overlap concern. + /// + [Fact] + public void SvgCodec_Load_FeCompositeOperatorIn_KeepsForegroundWeightedByBackgroundAlpha() + { + // Arrange + const string svg = """ + + + + + + + + + + + """; + + // Act + using var stream = ToStream(svg); + var surface = SvgCodec.Load(stream, 10, 10); + + // Assert + Assert.Equal(new Rgba32(255, 0, 0, 32), surface[5, 5]); + } + + /// + /// Proves feComposite operator="atop" blends both inputs' own colors, weighted by + /// (in2's alpha, 1 - in's alpha) respectively, per the documented Porter-Duff + /// "atop" formula ((Fa, Fb) = (Ab, 1 - Aa)) - only incidentally covered previously via + /// one pixel deep inside the third-party InkscapeFilters.svg fixture; this test + /// isolates the operator's own formula deterministically. + /// + [Fact] + public void SvgCodec_Load_FeCompositeOperatorAtop_BlendsBothInputsWeightedByBothAlphas() + { + // Arrange + const string svg = """ + + + + + + + + + + + """; + + // Act + using var stream = ToStream(svg); + var surface = SvgCodec.Load(stream, 10, 10); + + // Assert + Assert.Equal(new Rgba32(128, 0, 127, 64), surface[5, 5]); + } + + /// + /// Proves feComposite operator="xor" keeps each input only where the other does not + /// have coverage, per the documented Porter-Duff "xor" formula + /// ((Fa, Fb) = (1 - Ab, 1 - Aa)) - never previously exercised anywhere in this test + /// suite. + /// + [Fact] + public void SvgCodec_Load_FeCompositeOperatorXor_KeepsEachInputWhereTheOtherHasNoCoverage() + { + // Arrange + const string svg = """ + + + + + + + + + + + """; + + // Act + using var stream = ToStream(svg); + var surface = SvgCodec.Load(stream, 10, 10); + + // Assert + Assert.Equal(new Rgba32(191, 0, 64, 128), surface[5, 5]); + } + + /// + /// Proves that a filter="url(#id)" reference which does not resolve to any element + /// (a dangling reference) renders the element normally, exactly as if no filter + /// attribute had been present at all - matching the existing dangling-reference tolerance + /// convention used elsewhere (e.g. ResolvePaint, ResolveMarkerElement). + /// + [Fact] + public void SvgCodec_Load_FilterDanglingReference_RendersElementNormally() + { + // Arrange: two identical rects, one with a dangling filter reference and one without + const string svg = """ + + + + + """; + + // Act + using var stream = ToStream(svg); + var surface = SvgCodec.Load(stream, 100, 100); + + // Assert: both rects rendered identically, unaffected by the dangling filter reference + Assert.Equal(surface[60, 20], surface[20, 20]); + Assert.Equal(new Rgba32(128, 0, 128, 255), surface[20, 20]); + } + + /// + /// Proves that a filter primitive type this codec does not implement (here + /// feColorMatrix, but the same tolerant handling applies to feTurbulence, + /// feDisplacementMap, feImage, feTile, feDropShadow, + /// feConvolveMatrix, feDiffuseLighting, feSpecularLighting, + /// feComponentTransfer, and feMorphology) is treated as a no-op passthrough + /// of its input, rather than throwing or being ignored at the filter level. + /// + [Fact] + public void SvgCodec_Load_FilterUnsupportedPrimitive_PassesThroughSourceGraphicUnchanged() + { + // Arrange + const string svg = """ + + + + + + + + + """; + + // Act + using var stream = ToStream(svg); + var surface = SvgCodec.Load(stream, 100, 100); + + // Assert: the rect's own fill passed through unchanged, proving no exception was thrown + // and the unsupported primitive did not alter (or blank out) the element's content + Assert.Equal(new Rgba32(0, 128, 128, 255), surface[50, 50]); + } + + /// + /// Proves that a filter region which would require an unreasonably large temporary + /// surface (here a width/height of 100000% of the element's own + /// bounding box) is tolerantly skipped - the element renders normally, without its + /// filter effect - rather than attempting an unbounded allocation. + /// + [Fact] + public void SvgCodec_Load_FilterPathologicallyLargeRegion_SkipsFilterRatherThanUnboundedAllocation() + { + // Arrange + const string svg = """ + + + + + + + + + """; + + // Act: must complete promptly, without throwing or attempting to allocate a surface + // exceeding Surface.MaxDimension + using var stream = ToStream(svg); + var surface = SvgCodec.Load(stream, 100, 100); + + // Assert: the rect rendered its own normal blue fill, the (skipped) filter had no effect + Assert.Equal(new Rgba32(0, 0, 255, 255), surface[50, 50]); + } + + /// + /// Proves that a filter with a pathologically large number of fe* primitive + /// children (5,000 chained feGaussianBlur primitives, mirroring a reported repro + /// that took ~26 seconds to load prior to the MaxFilterPrimitivesPerFilter/ + /// MaxFilterPrimitiveWorkUnits bounds) is tolerantly skipped entirely rather than + /// evaluated, completing quickly instead of performing 5,000 region-sized blur passes. + /// + [Fact] + public void SvgCodec_Load_FilterExcessivePrimitiveCount_SkipsFilterRatherThanUnboundedWork() + { + // Arrange: 5,000 chained feGaussianBlur primitives in a single filter - far beyond any + // realistic chain length (the longest real chain in this repository's fixtures is 10) - + // against a modest, default-expanded region + var primitives = string.Concat(Enumerable.Repeat("", 5000)); + var svg = $""" + + + + {primitives} + + + + + """; + + // Act: must complete quickly rather than performing 5,000 region-sized blur passes - the + // 5-second threshold sits far below the ~26-second pathological baseline this bound + // eliminates, while remaining comfortably above normal test-execution variance (a + // rejected chain here does no per-primitive work at all: just one cheap element count + // plus one multiply/compare, so a healthy run completes in well under a second) + var stopwatch = Stopwatch.StartNew(); + using var stream = ToStream(svg); + var surface = SvgCodec.Load(stream, 100, 100); + stopwatch.Stop(); + + // Assert: the rect rendered its own normal blue fill - the (skipped) filter had no + // effect, the same tolerant per-element fallback used for every other filter resource + // bound + Assert.Equal(new Rgba32(0, 0, 255, 255), surface[50, 50]); + + // Assert: completed promptly, proving the filter was skipped rather than evaluated + Assert.True(stopwatch.Elapsed < TimeSpan.FromSeconds(5), $"Expected the excessive-primitive-count filter to be skipped promptly, but it took {stopwatch.Elapsed}."); + } + + /// + /// Proves that a pathologically large feGaussianBlur stdDeviation (many + /// orders of magnitude larger than the fixed MaxFilterBlurStdDeviationPixels bound) + /// is clamped rather than causing unbounded work: the blur completes promptly (the box-blur + /// implementation's cost does not scale with the requested radius) and produces a heavily + /// diluted, non-opaque result rather than crashing or hanging. + /// + [Fact] + public void SvgCodec_Load_FilterPathologicallyLargeBlurStdDeviation_ClampsRatherThanUnboundedWork() + { + // Arrange + const string svg = """ + + + + + + + + + """; + + // Act: must complete promptly despite the requested blur radius vastly exceeding both the + // clamp and the (small) temporary surface it is applied to + using var stream = ToStream(svg); + var surface = SvgCodec.Load(stream, 100, 100); + + // Assert: the clamped blur diluted the rect's own opaque fill down to fully transparent, + // rather than throwing, hanging, or leaving the fill unaffected + Assert.Equal(0, surface[50, 50].A); + } + + /// + /// Proves the filter region's default computation: absent x/y/width/ + /// height attributes, the region expands the referencing element's own bounding box + /// by -10%/-10%/120%/120% (objectBoundingBox units), per the SVG specification's own + /// defaults. + /// + [Fact] + public void SvgCodec_Load_FilterDefaultRegion_ExpandsBoundingBoxByTenAndTwentyPercent() + { + // Arrange: a 20x20 rect at (40,40); the default region is therefore exactly + // (40-2, 40-2)-(40-2+24, 40-2+24) = (38,38)-(62,62) + const string svg = """ + + + + + + + + + """; + + // Act + using var stream = ToStream(svg); + var surface = SvgCodec.Load(stream, 100, 100); + + // Assert: just outside the computed default region, nothing was rendered + Assert.Equal(0, surface[37, 50].A); + + // Assert: just inside each edge of the computed default region, the flood is visible + Assert.Equal(new Rgba32(255, 255, 255, 255), surface[38, 50]); + Assert.Equal(new Rgba32(255, 255, 255, 255), surface[61, 50]); + + // Assert: just outside the opposite edge of the computed default region, nothing rendered + Assert.Equal(0, surface[63, 50].A); + } + + /// + /// Proves that explicit x/y/width/height attributes on the + /// filter element override the default region computation, using the declared + /// (objectBoundingBox-relative) values instead. + /// + [Fact] + public void SvgCodec_Load_FilterExplicitRegion_UsesDeclaredXYWidthHeight() + { + // Arrange: an explicit region expanding the rect's own bounding box by 200% on every + // side, far beyond the -10%/120% default, reaching all the way to the canvas corner + const string svg = """ + + + + + + + + + """; + + // Act + using var stream = ToStream(svg); + var surface = SvgCodec.Load(stream, 100, 100); + + // Assert: the flood reaches a point well outside the default region, proving the + // explicit region (not the default) was used + Assert.Equal(new Rgba32(255, 255, 255, 255), surface[10, 10]); + } + + /// + /// Proves that filterUnits="userSpaceOnUse" falls back tolerantly to the same + /// objectBoundingBox-relative region computation as the default, rather than being + /// interpreted as literal absolute user-space coordinates - a deliberate simplification + /// documented on 's own class-level remarks. + /// + [Fact] + public void SvgCodec_Load_FilterUserSpaceOnUse_FallsBackToObjectBoundingBoxDefault() + { + // Arrange: x/y/width/height of 0/0/10/10 would, if interpreted literally as + // "userSpaceOnUse" absolute coordinates, place the filter region at (0,0)-(10,10) - a + // tiny box near the origin, unrelated to the rect's own (40,40)-(60,60) position + const string svg = """ + + + + + + + + + """; + + // Act + using var stream = ToStream(svg); + var surface = SvgCodec.Load(stream, 100, 100); + + // Assert: the flood reaches far into the canvas, consistent only with the tolerant + // objectBoundingBox-fraction fallback (x/y/width/height=0/0/10/10 interpreted as + // fractions of the rect's own 20x20 bounding box), not with a literal (0,0)-(10,10) + // absolute user-space box + Assert.Equal(new Rgba32(0, 255, 0, 255), surface[90, 90]); + + // Assert: nothing rendered near the origin, proving the literal absolute-coordinate + // interpretation was NOT used + Assert.Equal(0, surface[5, 5].A); + } + + /// + /// Proves that feOffset shifts its input by dx/dy (in user-space + /// units, scaled by the current transform) prior to compositing back onto the canvas. + /// + [Fact] + public void SvgCodec_Load_FeOffsetFilter_ShiftsSourceGraphicByDxDy() + { + // Arrange: a generously expanded filter region (200% on every side) so the offset shape + // remains fully within the temporary surface, avoiding incidental clipping at its edges + const string svg = """ + + + + + + + + + """; + + // Act + using var stream = ToStream(svg); + var surface = SvgCodec.Load(stream, 100, 100); + + // Assert: the rect's content reappears shifted 5 pixels to the right of its own bounds + Assert.Equal(new Rgba32(0, 0, 255, 255), surface[47, 45]); + + // Assert: nothing remains at the rect's own (pre-offset) position + Assert.Equal(0, surface[42, 45].A); + } + // ================================================================================================ // Total geometry-parsing work budget (path data / point lists / text characters) // ================================================================================================ @@ -3603,9 +4116,11 @@ public void SvgCodec_Load_GradientStopOffsetPercentage_RendersGradientCorrectly( /// /// Proves that well-formed-but-out-of-scope constructs (<style>, - /// <filter>, <mask>, <clipPath>, + /// <mask>, <clipPath>, /// <pattern>, a nested <svg>) are silently - /// skipped and do not prevent the rest of the document from rendering. + /// skipped and do not prevent the rest of the document from rendering. (filter is + /// no longer out-of-scope - see the dedicated filter tests above for its own + /// dangling-reference/unsupported-primitive tolerance coverage.) /// [Fact] public void SvgCodec_Load_UnsupportedConstructs_StillRendersRestOfDocument() @@ -3615,7 +4130,6 @@ public void SvgCodec_Load_UnsupportedConstructs_StillRendersRestOfDocument() - diff --git a/test/DemaConsulting.CanvasNet.Tests/Codecs/SvgFixtureTests.cs b/test/DemaConsulting.CanvasNet.Tests/Codecs/SvgFixtureTests.cs index 4c06d566..6937a722 100644 --- a/test/DemaConsulting.CanvasNet.Tests/Codecs/SvgFixtureTests.cs +++ b/test/DemaConsulting.CanvasNet.Tests/Codecs/SvgFixtureTests.cs @@ -133,9 +133,13 @@ public void SvgCodec_Load_UseReferenceFixture_RendersAtOffsetPositionOnly() } /// - /// Proves that tolerant-unsupported.svg's well-formed but out-of-scope - /// filter element definition does not prevent the rest of the document (an - /// ordinary rect) from rendering normally. + /// Proves that tolerant-unsupported.svg's filter element definition - + /// unreferenced by any filter="url(#id)" attribute - does not prevent the rest of + /// the document (an ordinary rect) from rendering normally. Now that filter + /// defs are genuinely parsed and evaluated, this fixture instead exercises the case of a + /// filter def that is simply never referenced, rather than an unsupported + /// construct; see the dedicated dangling-reference and unsupported-primitive tests in + /// SvgCodecTests for filter-specific tolerance coverage. /// [Fact] public void SvgCodec_Load_ToleratesUnsupportedConstructFixture_StillRendersRemainingContent() @@ -143,7 +147,8 @@ public void SvgCodec_Load_ToleratesUnsupportedConstructFixture_StillRendersRemai // Arrange & Act var surface = SvgCodec.Load(ResolveFixturePath("tolerant-unsupported.svg"), 100, 100); - // Assert: the rect still renders its "lime" fill despite the sibling element + // Assert: the rect still renders its "lime" fill despite the sibling, unreferenced + // element Assert.Equal(new Rgba32(0, 255, 0, 255), surface[25, 25]); } @@ -232,26 +237,35 @@ public void SvgCodec_Load_SvgGradientFixture_RendersVaryingGradientAndPinkBackgr /// InkscapeFilters.svg (see SvgFixtures\WikimediaCommons.LICENSE for /// provenance) - a large, complex document built entirely from defs/use /// templating, composed transform functions, and dozens of filter="url(#...)" - /// references, all of which reference out-of-scope feGaussianBlur/ - /// feComposite/feSpecularLighting effects - loads without throwing despite - /// the numerous unsupported filter references (proving the tolerant-ignore policy holds - /// for a real, unmodified, complex third-party document, not only a small synthetic one), - /// that a flower shape rendered behind one such filter reference still paints its - /// genuine fill color (proving real content rendered, not just "didn't crash"), and that - /// a point in the gap between flowers remains transparent (proving the render is not a - /// degenerate whole-canvas fill that would make the previous assertion vacuous). + /// references, whose primitive chains combine supported primitives + /// (feGaussianBlur/feComposite) with several unsupported ones + /// (feSpecularLighting/feDiffuseLighting/arithmetic-mode + /// feComposite, each a tolerant no-op passthrough of its input) - loads without + /// throwing despite this genuinely deep, mixed-support filter evaluation (proving the + /// tolerant-passthrough policy for unsupported primitives holds within a real, + /// unmodified, complex third-party document, not only a small synthetic one), that a + /// flower shape rendered behind one such filter reference still paints its genuine + /// fill color (proving real content rendered, not just "didn't crash"), and that a point + /// in the gap between flowers remains transparent (proving the render is not a degenerate + /// whole-canvas fill that would make the previous assertion vacuous). At this deep-interior + /// pixel, the asserted color happens to exactly match the shape's own original fill: every + /// unsupported primitive in the chain passes its input through unchanged, and the chain's + /// final primitive is an feComposite operator="atop" against SourceGraphic, + /// which by definition adopts SourceGraphic's own alpha (fully opaque here, far + /// from any shape edge) - so this is not evidence that the filter was skipped or ignored. /// [Fact] public void SvgCodec_Load_InkscapeFiltersFixture_ToleratesFiltersAndRendersFlowerContent() { // Arrange & Act: rasterize at the fixture's native 600x1000 width/height - this must not - // throw despite every flower (other than the very first) referencing an unsupported - // element + // throw despite every flower (other than the very first) referencing a whose + // chain mixes supported and unsupported (tolerantly passed-through) primitives var surface = SvgCodec.Load(ResolveFixturePath("InkscapeFilters.svg"), 600, 1000); // Assert: a petal of the second flower - translate(150,50), filter="url(#filter48)" - - // still renders its own "#ff8010" (255,128,16) fill, proving the unsupported - // reference was tolerantly ignored rather than suppressing the shape it decorates + // still renders its own "#ff8010" (255,128,16) fill, proving the filter's evaluated + // chain (see the remarks above) reconstructs the original color at this deep-interior + // pixel rather than suppressing or corrupting the shape it decorates Assert.Equal(new Rgba32(255, 128, 16, 255), surface[135, 30]); // Assert: the gap between flowers (the grid spacing is 100 units, and each flower's @@ -281,4 +295,36 @@ public void SvgCodec_Load_ArrowMarkersFixture_RendersArrowheadPastLineEnd() // Assert: a point clearly outside both the line and the arrowhead remains transparent Assert.Equal(0, surface[10, 90].A); } + + /// + /// Proves that label-halo.svg's filter="url(#label-bg)" reference - a + /// conventional feFlood → feGaussianBlur → feComposite label-background + /// recipe, mirroring the reported real-world bug where SvgRenderer uses this exact + /// pattern for a white halo behind midpoint line labels - actually renders the white halo + /// (rather than the halo silently disappearing, as it did while filter was ignored): + /// the halo's flood extends beyond its own rect's bounds, softened by the blur, and + /// the sibling line remains fully visible outside the halo's own bounds. + /// + [Fact] + public void SvgCodec_Load_LabelHaloFixture_RendersWhiteHaloBehindLineMidpointLabel() + { + // Arrange & Act + var surface = SvgCodec.Load(ResolveFixturePath("label-halo.svg"), 100, 40); + + // Assert: the halo rect's own center renders fully opaque white + Assert.Equal(new Rgba32(255, 255, 255, 255), surface[50, 20]); + + // Assert: just outside the halo rect's own left edge, the blurred flood blends partially + // (not fully opaquely) with the line passing beneath it - proving the halo actually + // extends past the rect's own bounds with a softened, not hard, edge + Assert.Equal(new Rgba32(152, 152, 152, 255), surface[39, 20]); + + // Assert: just outside the filter's own (default, bounding-box-relative) region, the + // line renders its plain, unaffected black stroke + Assert.Equal(new Rgba32(0, 0, 0, 255), surface[37, 20]); + + // Assert: the line remains fully visible, unaffected, far from the halo on either side + Assert.Equal(new Rgba32(0, 0, 0, 255), surface[10, 20]); + Assert.Equal(new Rgba32(0, 0, 0, 255), surface[90, 20]); + } } diff --git a/test/DemaConsulting.CanvasNet.Tests/SvgFixtures/README.md b/test/DemaConsulting.CanvasNet.Tests/SvgFixtures/README.md index f7d822e9..81e1b211 100644 --- a/test/DemaConsulting.CanvasNet.Tests/SvgFixtures/README.md +++ b/test/DemaConsulting.CanvasNet.Tests/SvgFixtures/README.md @@ -16,8 +16,9 @@ for their provenance and licensing. | `gradient.svg` | `linearGradient` with `stop` children, referenced via `fill="url(#id)"` | | `use-reference.svg` | `defs` (non-rendering template storage) and `use` (reference + `x`/`y` offset) | | `text.svg` | `text` rendering, paired in tests with the real `FontFixtures/OpenSans-Regular.ttf` font | -| `tolerant-unsupported.svg` | A well-formed but out-of-scope `filter`, alongside a `rect` that still renders | +| `tolerant-unsupported.svg` | An unreferenced `filter` def (never resolved), plus a `rect` that still renders | | `arrow-markers.svg` | `marker` referenced via `marker-end`, `orient="auto"`, `markerUnits="userSpaceOnUse"` | +| `label-halo.svg` | `filter` referenced via `filter="url(#id)"`: `feFlood`/`feGaussianBlur`/`feComposite` halo | ## Real-world third-party fixtures diff --git a/test/DemaConsulting.CanvasNet.Tests/SvgFixtures/label-halo.svg b/test/DemaConsulting.CanvasNet.Tests/SvgFixtures/label-halo.svg new file mode 100644 index 00000000..84f33628 --- /dev/null +++ b/test/DemaConsulting.CanvasNet.Tests/SvgFixtures/label-halo.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + 50 + From dd923d3f6b8607343f2d8fafa531d29357cd0c6a Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Sat, 26 Sep 2026 20:32:26 -0400 Subject: [PATCH 3/8] Add weight/style-aware font matching to SvgCodec text rendering Add SvgFontFace/SvgFontStyle types and additive Load overloads accepting IReadOnlyDictionary> so callers can register multiple weight/style variants per font family. Cascade font-weight/font-style presentation attributes through RenderState the same way font-family/font-size already do, and select the closest matching face (exact style match, then closest numeric weight, then boldness-side tiebreak) in MatchFont, falling back gracefully when no close variant is registered. Existing single-font-per-family overloads remain unchanged, wrapping each font as one normal-weight/normal-style face internally. Adds unit tests proving bold/italic face selection via distinguishable synthetic glyph outlines, closest-match fallback, cascading inheritance, and legacy single-font-per-family regression behavior. Updates design, verification, reqstream (unit/subsystem/system), README, and user guide documentation to match. --- README.md | 3 +- docs/design/canvas-net/codecs/svg-codec.md | 113 +++++- docs/reqstream/canvas-net.yaml | 22 + docs/reqstream/canvas-net/codecs.yaml | 24 ++ .../canvas-net/codecs/svg-codec.yaml | 47 +++ docs/user_guide/introduction.md | 68 +++- .../canvas-net/codecs/svg-codec.md | 50 +++ .../Codecs/SvgCodec.cs | 377 ++++++++++++++++-- .../Codecs/SvgFontFace.cs | 36 ++ .../Codecs/SvgFontStyle.cs | 33 ++ .../Codecs/SvgCodecTests.cs | 364 +++++++++++++++++ 11 files changed, 1090 insertions(+), 47 deletions(-) create mode 100644 src/DemaConsulting.CanvasNet/Codecs/SvgFontFace.cs create mode 100644 src/DemaConsulting.CanvasNet/Codecs/SvgFontStyle.cs diff --git a/README.md b/README.md index 44cdd305..0ffe5403 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,8 @@ image operations using `Span`, and supports independent-copy cropping for loa - 🖨️ **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; `GetInfo` reports the true frame count -- 📐 **SVG Codec** - Rasterize a common SVG subset, including markers/filters, to a surface +- 📐 **SVG Codec** - Rasterize a common SVG subset, including markers/filters and weight/style-aware + font matching, to a surface - 🔍 **Header-Only Probing** - `GetInfo` reads headers without decoding pixels (GIF excepted) - 🖌️ **Path Filling** - Antialiased nonzero/even-odd fill of vector paths - 🖊️ **Stroke-to-Fill** - Convert stroked paths into fillable outlines diff --git a/docs/design/canvas-net/codecs/svg-codec.md b/docs/design/canvas-net/codecs/svg-codec.md index de6d777b..33942c85 100644 --- a/docs/design/canvas-net/codecs/svg-codec.md +++ b/docs/design/canvas-net/codecs/svg-codec.md @@ -48,8 +48,10 @@ same reason as the other codecs: rasterizing an SVG document has no instance sta presentation attribute (`url(#id)`), with `x`/`y`/`width`/`height` filter-region attributes (objectBoundingBox units) and `feFlood`, `feGaussianBlur`, `feOffset`, `feComposite`, and `feMerge` primitive children -- `text`, with `x`/`y`, `font-family`, `font-size`, `fill`, and `text-anchor` - (`start`/`middle`/`end`), rendered through a caller-supplied font dictionary +- `text`, with `x`/`y`, `font-family`, `font-size`, `fill`, `text-anchor` + (`start`/`middle`/`end`), and `font-weight`/`font-style`, rendered through a caller-supplied + dictionary of per-family `SvgFontFace` lists (or, via the legacy single-font-per-family + overload, a plain `TrueTypeFont` dictionary) #### Out-of-scope subset (tolerated, silently skipped) @@ -75,7 +77,12 @@ rather than rejected or skipped at the whole-filter level) are all explicitly ou `feImage` in particular is deliberately never implemented, specifically because it is the only primitive that could reference another filtered element's own output - omitting it means filter-chain evaluation needs no additional recursion-depth guard of its own, unlike `use`/ -`marker` references. +`marker` references. Within the supported `text` feature itself, the `font-weight` relative +keywords `bolder`/`lighter` (which resolve to a value relative to the inherited weight rather +than an absolute one) are not implemented - encountering either keyword tolerantly falls back to +the inherited weight - and the `font-style` keyword `oblique` is folded into the same +`SvgFontStyle.Italic` value as `italic` rather than being distinguished as a third style (see +`SvgFontStyle`'s remarks in the source for the rationale). A percentage value on a shape/text geometry attribute (`x`, `y`, `width`, `height`, `rx`, `ry`, `cx`, `cy`, `r`, `x1`/`y1`/`x2`/`y2`, `font-size`, `stroke-width`, `stroke-miterlimit`, @@ -88,14 +95,21 @@ attributes and gradient coordinates/`stop` `offset` are unaffected, since both h ### Data Model -`SvgCodec` has no public data types of its own beyond the shared `Codecs.ImageInfo` record struct -(see _Codecs Subsystem Design_, `../codecs.md`). Internally, it defines a private `RenderState` -record capturing the cascading presentation state (fill, stroke, fill-opacity, stroke-opacity, -opacity, fill-rule, stroke-width, stroke-linecap, stroke-linejoin, stroke-miterlimit, -stroke-dasharray, stroke-dashoffset, font-family, font-size, text-anchor, and the three -marker-start/marker-mid/marker-end specifications) that is threaded down through the element tree -alongside an accumulated `System.Numerics.Matrix3x2` transform, plus a private `RenderContext` -capturing fixed per-document state (the id→`XElement` index, the caller's font dictionary, and +`SvgCodec` now has two small public data types of its own, alongside the shared +`Codecs.ImageInfo` record struct (see _Codecs Subsystem Design_, `../codecs.md`): `SvgFontFace` +(a `readonly record struct` pairing a `Fonts.TrueTypeFont` with the `Weight`/`Style` face it +represents, modeled directly on `ImageInfo`'s "plain immutable data carrier" style) and +`SvgFontStyle` (a public two-value enum, `Normal`/`Italic`, deliberately narrowed from CSS's +three-way `normal`/`italic`/`oblique` keyword set — the reported use case, "bold titles and +italic keywords", never needs to distinguish a true italic face from a mechanically-slanted +`oblique` one, so `SvgCodec`'s `font-style` parser maps both keywords onto `Italic`). Internally, +it defines a private `RenderState` record capturing the cascading presentation state (fill, +stroke, fill-opacity, stroke-opacity, opacity, fill-rule, stroke-width, stroke-linecap, +stroke-linejoin, stroke-miterlimit, stroke-dasharray, stroke-dashoffset, font-family, font-size, +font-weight, font-style, text-anchor, and the three marker-start/marker-mid/marker-end +specifications) that is threaded down through the element tree alongside an accumulated +`System.Numerics.Matrix3x2` transform, plus a private `RenderContext` capturing fixed per-document +state (the id→`XElement` index, the caller's font-family-to-`SvgFontFace`-list dictionary, and the resolved fit transform). ### XML Parsing Hardening (XXE) @@ -118,7 +132,7 @@ an external or parameter entity - is rejected with `InvalidDataException` via bo ### Key Methods -#### Load(Stream stream, int width, int height, IReadOnlyDictionary\? fonts = null) +#### Load(Stream stream, int width, int height, IReadOnlyDictionary\\>? fonts) Reads an SVG document from an open stream and rasterizes it into a new `width`x`height` `Surface`. Parses the document with `XDocument.Load`, builds an id→`XElement` index over the @@ -129,8 +143,47 @@ below), then recursively walks the tree, baking every transform (the root fit tr with every nested `g`/element `transform`) directly into the `Vector2` points fed into `Geometry.PathBuilder` before calling `Drawing.PathFiller.Fill`/`Drawing.PathStroker.Stroke` — neither of which has a transform parameter; they treat `Geometry.Path` coordinates as final -pixel-space. `fonts` is optional; when supplied, `text` elements are matched against it by -family name (see _Text Rendering and Font Lookup_ below). +pixel-space. `fonts` is optional; when supplied, `text` elements are matched against it by family +name, then, when a family has more than one registered `SvgFontFace`, by closest +`font-weight`/`font-style` match (see _Text_ under _Gradient, Use, and Text Support and Limits_ +below). This is the richer of the two `fonts`-accepting overloads — the legacy single-font-per- +family overload below delegates to this one. + +**Throws:** + +- `ArgumentNullException` — `stream` is null +- `InvalidDataException` — the stream is not well-formed XML, or the document's `viewBox`, + `transform`, gradient, or path `d` data is malformed (see _Error Handling_ below) +- `ArgumentOutOfRangeException` — `width` or `height` is not positive (propagated, unwrapped, + from `Surface`'s own constructor — see _Error Handling_ below) + +`fonts` is optional; a `null` dictionary, a dictionary with no entry matching a requested +`font-family`, or a matching entry whose face list is empty, all cause the affected `text` +element(s) to be silently skipped rather than throwing — see _Gradient, Use, and Text Support and +Limits_ below. + +#### Load(string path, int width, int height, IReadOnlyDictionary\\>? fonts) + +Opens `path` as a read-only `FileStream` and delegates to +`Load(Stream, int, int, IReadOnlyDictionary>?)`. + +**Throws:** + +- `ArgumentNullException` — `path` is null +- `ArgumentException` — `path` is empty or consists only of whitespace +- `InvalidDataException` / `ArgumentOutOfRangeException` — see the stream overload above +- Underlying file-system exceptions propagate uncaught + +#### Load(Stream stream, int width, int height, IReadOnlyDictionary\? fonts = null) + +The legacy, pre-existing overload: accepts at most one `TrueTypeFont` per font-family. A thin +wrapper that delegates to the richer overload above via a private `ToFontFaces` helper, which +wraps each dictionary entry as a single normal-weight (`400`)/normal-style `SvgFontFace` — so +every `text` element resolves to that single registered font regardless of its own +`font-weight`/`font-style`, preserving this overload's behavior exactly as it was before +`SvgFontFace` existed. Reads an SVG document from an open stream and rasterizes it into a new +`width`x`height` `Surface`. `fonts` is optional; when supplied, `text` elements are matched +against it by family name (see _Text_ below). **Throws:** @@ -147,7 +200,8 @@ Support and Limits_ below. #### Load(string path, int width, int height, IReadOnlyDictionary\? fonts = null) -Opens `path` as a read-only `FileStream` and delegates to `Load(Stream, int, int, ...)`. +Opens `path` as a read-only `FileStream` and delegates to `Load(Stream, int, int, ...)`, the +legacy single-font-per-family overload directly above. **Throws:** @@ -519,12 +573,35 @@ transitively bounds `ParseStops`'s later, otherwise-unbounded `` enumerati **Text.** A `text` element's `font-family` is matched, case-insensitively, against a caller-supplied `fonts` dictionary keyed by family name, walking a comma-separated fallback list -of families exactly as CSS `font-family` does. Each mapped character's glyph outline, advance +of families exactly as CSS `font-family` does. Once a family name matches, the matching family's +registered `SvgFontFace` list is narrowed to a single face via a private `SelectClosestFace` +helper, scoring each candidate face against the element's own cascaded `font-weight`/ +`font-style` (parsed by private `ParseFontWeight`/`ParseFontStyle` helpers alongside every other +presentation attribute in `ApplyPresentationAttributes` - `font-weight` accepts the keywords +`normal`(`400`)/`bold`(`700`) or a literal integer, tolerantly falling back to the inherited value +for the unimplemented relative keywords `bolder`/`lighter` or any other unparseable value; +`font-style` accepts `normal`/`italic`/`oblique`, the latter two both resolving to +`SvgFontStyle.Italic`) in priority order: (1) an exact style match always beats a style mismatch, +regardless of weight; (2) among faces tied on style match, the smallest absolute +`font-weight` distance wins; (3) among faces tied on both, the face on the same "boldness side" +as the request (its own weight and the requested weight are both `>= 400` or both `< 400`) wins +over one on the opposite side. This is a deliberately simple approximation of the CSS Fonts +Module Level 4 font-weight fallback cascade - not a byte-for-byte clone of it - matching a +documented "do not over-engineer" design choice; the first-registered face wins any remaining +tie, since the running best is only replaced by a strictly better-scoring candidate. A caller +registering exactly one `SvgFontFace` per family (including every family registered via the +legacy single-font-per-family `Load` overload, which always wraps its entries as a single +normal-weight/normal-style face) is therefore unaffected by this algorithm: with only one +candidate, it is always selected regardless of the requested `font-weight`/`font-style`, +preserving that overload's pre-existing behavior exactly. + +Each mapped character's glyph outline, advance width, and kerning (against the previous glyph) are looked up via `Fonts.TrueTypeFont.GetGlyphIndex`/`GetGlyphOutline`/`GetAdvanceWidth`/`GetKerning`, and the resulting glyph run is offset horizontally according to `text-anchor` before each glyph's outline -is transformed into pixel space and filled. If no `fonts` dictionary is supplied at all, or none -of its entries match the requested family (including every entry in a fallback list), that `text` +is transformed into pixel space and filled. If no `fonts` dictionary is supplied at all, none of +its entries match the requested family (including every entry in a fallback list), or a matching +family's face list is empty, that `text` element is **silently skipped** — not rendered, and not reported as an error. This is a deliberate tolerant-parsing policy: font availability is entirely up to the caller, and a document referencing a family the caller did not supply is not malformed input. diff --git a/docs/reqstream/canvas-net.yaml b/docs/reqstream/canvas-net.yaml index df105934..a5348bea 100644 --- a/docs/reqstream/canvas-net.yaml +++ b/docs/reqstream/canvas-net.yaml @@ -1066,6 +1066,28 @@ sections: - SvgCodec_Load_TextFontFamilyNoMatch_SkipsSilentlyWithoutThrowing - SvgCodec_Load_TextFontFamilyCommaSeparatedList_MatchesFirstAvailableFont + - id: CanvasNet-Lib-SvgFontWeightStyleMatching + title: >- + The library shall register more than one font face (varying in font-weight/font-style) + per SVG text font-family and select the closest-matching registered face for each text + element's own cascaded font-weight/font-style, while preserving the pre-existing + single-font-per-family Load overload's behavior unchanged. + tags: [system] + justification: | + Callers commonly need bold titles and italic keywords rendered with the correct + distinct font face, rather than every element in a family always resolving to the + same single font regardless of its requested weight/style; existing callers of the + single-font-per-family overload must observe no behavior change. + children: + - CanvasNet-Codecs-SvgFontWeightStyleMatching + tests: + - SvgCodec_Load_TextFontWeightBold_SelectsBoldFaceOverNormalFace + - SvgCodec_Load_TextFontStyleItalic_SelectsItalicFaceOverNormalFace + - SvgCodec_Load_TextFontWeightAndStyleCombined_SelectsExactMatchAmongThreeFaces + - SvgCodec_Load_GroupFontWeightInheritance_AppliesToChildTextWithoutOwnFontWeight + - SvgCodec_Load_GroupFontStyleInheritance_AppliesToChildTextWithoutOwnFontStyle + - SvgCodec_Load_TextLegacySingleFontOverload_IgnoresRequestedWeightAndStyle + - id: CanvasNet-Lib-SvgValidationNull title: >- The library shall reject null stream/path arguments to SVG Load and GetInfo operations diff --git a/docs/reqstream/canvas-net/codecs.yaml b/docs/reqstream/canvas-net/codecs.yaml index 9b31e199..1cc9f627 100644 --- a/docs/reqstream/canvas-net/codecs.yaml +++ b/docs/reqstream/canvas-net/codecs.yaml @@ -1031,6 +1031,30 @@ sections: - SvgCodec_Load_TextFontFamilyNoMatch_SkipsSilentlyWithoutThrowing - SvgCodec_Load_TextFontFamilyCommaSeparatedList_MatchesFirstAvailableFont + - id: CanvasNet-Codecs-SvgFontWeightStyleMatching + title: >- + The Codecs subsystem shall register more than one font face (varying in + font-weight/font-style) per SVG text font-family and select the closest-matching + registered face for each text element's own cascaded font-weight/font-style, + while preserving the pre-existing single-font-per-family Load overload's behavior + unchanged. + justification: | + Callers commonly need bold titles and italic keywords rendered with the correct + distinct font face, rather than every element in a family always resolving to the + same single font regardless of its requested weight/style; existing callers of the + single-font-per-family overload must observe no behavior change. + children: + - CanvasNet-Codecs-SvgCodec-FontWeightStyleCascade + - CanvasNet-Codecs-SvgCodec-FontFaceMatching + - CanvasNet-Codecs-SvgCodec-LegacySingleFontCompatibility + tests: + - SvgCodec_Load_TextFontWeightBold_SelectsBoldFaceOverNormalFace + - SvgCodec_Load_TextFontStyleItalic_SelectsItalicFaceOverNormalFace + - SvgCodec_Load_TextFontWeightAndStyleCombined_SelectsExactMatchAmongThreeFaces + - SvgCodec_Load_GroupFontWeightInheritance_AppliesToChildTextWithoutOwnFontWeight + - SvgCodec_Load_GroupFontStyleInheritance_AppliesToChildTextWithoutOwnFontStyle + - SvgCodec_Load_TextLegacySingleFontOverload_IgnoresRequestedWeightAndStyle + - id: CanvasNet-Codecs-SvgValidationNull title: >- The Codecs subsystem shall reject null stream/path arguments to SVG Load and diff --git a/docs/reqstream/canvas-net/codecs/svg-codec.yaml b/docs/reqstream/canvas-net/codecs/svg-codec.yaml index 8e278e3d..baa6c3b3 100644 --- a/docs/reqstream/canvas-net/codecs/svg-codec.yaml +++ b/docs/reqstream/canvas-net/codecs/svg-codec.yaml @@ -339,6 +339,53 @@ sections: - SvgCodec_Load_TextAnchorMiddle_CentersTextHorizontally - SvgCodec_Load_TextAnchorEnd_RightAlignsText + - id: CanvasNet-Codecs-SvgCodec-FontWeightStyleCascade + title: >- + The SvgCodec class shall parse a text element's font-weight (normal/bold + keywords or a numeric value) and font-style (normal/italic/oblique) presentation + attributes and cascade each down to descendant elements exactly as font-family + and font-size already do. + justification: | + Callers need bold titles and italic keywords - a common real-world diagram + labeling pattern - to inherit through group nesting the same way every other + presentation attribute already does, rather than requiring every leaf text + element to repeat its own font-weight/font-style. + tests: + - SvgCodec_Load_GroupFontWeightInheritance_AppliesToChildTextWithoutOwnFontWeight + - SvgCodec_Load_GroupFontStyleInheritance_AppliesToChildTextWithoutOwnFontStyle + + - id: CanvasNet-Codecs-SvgCodec-FontFaceMatching + title: >- + The SvgCodec class shall, given a font-family with more than one registered + SvgFontFace, select the face whose weight/style most closely matches a text + element's cascaded font-weight/font-style (exact style match preferred, then + closest weight distance, then matching boldness side), never throwing when no + exact match is registered. + justification: | + Callers registering a bold and/or italic variant of a family need the correct + variant actually selected per element, and a family lacking an exact + weight/style match must still render its closest available face rather than + rendering nothing. + tests: + - SvgCodec_Load_TextFontWeightBold_SelectsBoldFaceOverNormalFace + - SvgCodec_Load_TextFontStyleItalic_SelectsItalicFaceOverNormalFace + - SvgCodec_Load_TextFontWeightNumeric_SelectsClosestRegisteredFaceByDistance + - SvgCodec_Load_TextFontWeightTie_PrefersMatchingBoldnessSide + - SvgCodec_Load_TextFontStyleNoItalicRegistered_FallsBackToOnlyAvailableFace + - SvgCodec_Load_TextFontWeightAndStyleCombined_SelectsExactMatchAmongThreeFaces + + - id: CanvasNet-Codecs-SvgCodec-LegacySingleFontCompatibility + title: >- + The SvgCodec class shall preserve the pre-existing single-font-per-family Load + overload's behavior unchanged: a text element always resolves to that overload's + one registered font per family, regardless of any requested + font-weight/font-style. + justification: | + Existing callers of the single-font-per-family Load overload must not observe + any behavior change from the addition of weight/style-aware face matching. + tests: + - SvgCodec_Load_TextLegacySingleFontOverload_IgnoresRequestedWeightAndStyle + - id: CanvasNet-Codecs-SvgCodec-ViewBoxFitting title: >- The SvgCodec class shall fit the document's intrinsic viewBox into the diff --git a/docs/user_guide/introduction.md b/docs/user_guide/introduction.md index 3d03db01..3d35efec 100644 --- a/docs/user_guide/introduction.md +++ b/docs/user_guide/introduction.md @@ -894,8 +894,12 @@ elements (referenced via `marker-start`/`marker-mid`/`marker-end`, with `markerW `markerHeight`, `refX`/`refY`, `markerUnits`, `orient`, and an optional `viewBox`), `filter` elements (referenced via the `filter` presentation attribute on any directly renderable shape or `text` element, with `x`/`y`/`width`/`height` filter-region attributes and `feFlood`/ -`feGaussianBlur`/`feOffset`/`feComposite`/`feMerge` primitive children), and best-effort `text` -rendering against a caller-supplied dictionary of `TrueTypeFont` instances. The root +`feGaussianBlur`/`feOffset`/`feComposite`/`feMerge` primitive children), and best-effort, +weight/style-aware `text` rendering against a caller-supplied dictionary of per-family font faces: +either a single `TrueTypeFont` per family (the legacy shape), or a list of `SvgFontFace` values - +each pairing a `TrueTypeFont` with the `font-weight`/`font-style` it represents - letting a caller +register distinct bold/italic variants of a family and have `SvgCodec` pick the closest-matching +face for each `text` element's own cascaded `font-weight`/`font-style`. The root `viewBox`/`width`/`height` are fit into the requested raster using a "meet, centered" policy equivalent to CSS `object-fit: contain` (`preserveAspectRatio` itself is not read). Well-formed but out-of-scope constructs (`style`, `mask`, `clipPath`, `animate`/SMIL, `image`, @@ -905,6 +909,54 @@ renders; malformed/unparseable input throws `InvalidDataException`. #### SvgCodec Methods +##### SvgCodec.Load(Stream stream, ..., IReadOnlyDictionary<string, IReadOnlyList<SvgFontFace>>? fonts) + +```csharp +public static Surface Load( + Stream stream, + int width, + int height, + IReadOnlyDictionary>? fonts) +``` + +Decodes and rasterizes an SVG document from an open, readable stream into a new `width` x +`height` `Surface`, matching each `text` element's cascaded `font-family`/`font-weight`/ +`font-style` against `fonts` - a dictionary mapping family names to the list of `SvgFontFace` +instances registered for that family. When a family has more than one registered face, the face +whose `Weight`/`Style` most closely matches the element's own cascaded `font-weight`/`font-style` +is selected: an exact style match always beats a style mismatch; among faces tied on style, the +smallest weight distance wins; among faces tied on both, the face on the same "boldness side" +(`>= 400` or `< 400`) as the request wins. A `null` value, a dictionary with no entry matching a +given `text` element's `font-family`, or a matching entry whose face list is empty, causes that +element to be silently skipped rather than throwing. + +**Exceptions:** + +- `ArgumentNullException`: Thrown when `stream` is null. +- `ArgumentOutOfRangeException`: Thrown when `width` or `height` is not a valid `Surface` size (not + pre-validated by `SvgCodec`; propagates from `new Surface(width, height)`). +- `InvalidDataException`: Thrown when the stream does not contain valid, supported SVG content. + +##### SvgCodec.Load(string path, ..., IReadOnlyDictionary<string, IReadOnlyList<SvgFontFace>>? fonts) + +```csharp +public static Surface Load( + string path, + int width, + int height, + IReadOnlyDictionary>? fonts) +``` + +Decodes and rasterizes an SVG file at the specified path, as +`Load(Stream, int, int, IReadOnlyDictionary>?)`. + +**Exceptions:** + +- `ArgumentNullException`: Thrown when `path` is null. +- `ArgumentException`: Thrown when `path` is an empty string. +- `ArgumentOutOfRangeException`: Thrown for the same reason as the stream overload above. +- `InvalidDataException`: Thrown for the same conditions as the stream overload above. + ##### SvgCodec.Load(Stream stream, int width, int height, IReadOnlyDictionary<string, TrueTypeFont>? fonts = null) ```csharp @@ -916,9 +968,17 @@ public static Surface Load( ``` Decodes and rasterizes an SVG document from an open, readable stream into a new `width` x -`height` `Surface`. If `fonts` is supplied, `text` elements are rendered using the matching +`height` `Surface`, using at most one `TrueTypeFont` per font-family. A thin wrapper over the +richer `SvgFontFace`-list overload above: each registered font is wrapped as a single +normal-weight (`400`)/normal-style face, so every `text` element always resolves to that single +registered font, regardless of its own `font-weight`/`font-style` - exactly as before the richer +overload existed. If `fonts` is supplied, `text` elements are rendered using the matching `TrueTypeFont` keyed by family name; unmatched or missing fonts cause that `text` element to be -silently skipped rather than throwing. +silently skipped rather than throwing. Callers registering more than one face per family (bold/ +italic variants) should call the richer overload directly instead. Passing an explicit untyped +`null` literal as the 4th positional argument to either this overload or its richer sibling is +ambiguous between the two (a compile-time error); omit the argument, or cast the `null` to the +specific dictionary type intended. **Exceptions:** diff --git a/docs/verification/canvas-net/codecs/svg-codec.md b/docs/verification/canvas-net/codecs/svg-codec.md index 7d441a3d..6cf27ae8 100644 --- a/docs/verification/canvas-net/codecs/svg-codec.md +++ b/docs/verification/canvas-net/codecs/svg-codec.md @@ -364,6 +364,56 @@ Asserts `text-anchor="middle"` centers the glyph run on the given `x` (checked a correctly-centered position and the position a start-anchored run would have used instead), and `text-anchor="end"` right-aligns the run so it ends at the given `x` (checked the same way). +#### CanvasNet-Codecs-SvgCodec-FontWeightStyleCascade: Font-Weight/Font-Style Attribute Parsing and Cascade + +**Tests**: `SvgCodec_Load_GroupFontWeightInheritance_AppliesToChildTextWithoutOwnFontWeight`, +`SvgCodec_Load_GroupFontStyleInheritance_AppliesToChildTextWithoutOwnFontStyle` + +Uses two new synthetic test fonts (`BuildBoldTestFont`, an 80-wide square glyph; and +`BuildItalicTestFont`, a 50-wide square glyph shifted right by 20 units), each distinguishable +from `BuildTestFont`'s original 50-wide square by which canvas pixels are/are not filled, so a +test can prove *which* registered face actually rendered. Asserts a `g` element's own +`font-weight="bold"` cascades to a child `text` element with no own `font-weight` (the child +selects the bold face), and likewise for a `g` element's own `font-style="italic"` cascading to a +child `text` element with no own `font-style` (the child selects the italic face) - mirroring +`SvgCodec_Load_GroupFillInheritance_AppliesToChildWithoutOwnFill`'s inheritance pattern for +`font-family`/`font-size`'s other presentation-attribute siblings. + +#### CanvasNet-Codecs-SvgCodec-FontFaceMatching: Closest-Face Selection Algorithm + +**Tests**: `SvgCodec_Load_TextFontWeightBold_SelectsBoldFaceOverNormalFace`, +`SvgCodec_Load_TextFontStyleItalic_SelectsItalicFaceOverNormalFace`, +`SvgCodec_Load_TextFontWeightNumeric_SelectsClosestRegisteredFaceByDistance`, +`SvgCodec_Load_TextFontWeightTie_PrefersMatchingBoldnessSide`, +`SvgCodec_Load_TextFontStyleNoItalicRegistered_FallsBackToOnlyAvailableFace`, +`SvgCodec_Load_TextFontWeightAndStyleCombined_SelectsExactMatchAmongThreeFaces` + +Exercises every branch of `SelectClosestFace`'s three-part priority order using the richer +`Load(..., IReadOnlyDictionary>?)` overload: a two-face family +(400/Normal narrow, 700/Normal wide) selects the bold face for `font-weight="bold"` and the +normal face for a sibling with no own `font-weight`; a two-face family (400/Normal narrow, +400/Italic shifted) selects the italic face for `font-style="italic"`; faces registered at 400 +and 900 with a requested weight of 600 select the closer 400 face (distance 200 versus 300); faces +registered at 300 and 500 with a requested weight of 400 (an equidistant tie) select the 500 face, +proving the boldness-side tie-break; a family with only a 400/Normal face still renders it when +`font-style="italic"` is requested (graceful fallback, not silent skipping); and three faces +(400/Normal, 700/Normal, 700/Italic) with a combined `font-weight="bold" font-style="italic"` +request select the 700/Italic face specifically - proven by asserting both a pixel unique to the +italic face's glyph (proving it is not the normal face) and a pixel unique to the wide/bold face's +glyph is *not* filled (proving it is not merely the weight-matched bold/normal face). + +#### CanvasNet-Codecs-SvgCodec-LegacySingleFontCompatibility: Legacy Single-Font-Per-Family Overload Regression + +**Tests**: `SvgCodec_Load_TextLegacySingleFontOverload_IgnoresRequestedWeightAndStyle` + +Proves the pre-existing `Load(..., IReadOnlyDictionary?)` overload's +behavior is unchanged by the additive `SvgFontFace`-based overload: with only one plain +`TrueTypeFont` registered for a family, a `text` element requesting both `font-weight="bold"` and +`font-style="italic"` still renders that single registered font at its known pixel position, +proving the legacy overload's internal `ToFontFaces` wrapping (one normal-weight/normal-style +face per family) makes weight/style irrelevant to face selection, exactly as before this feature +existed. + #### CanvasNet-Codecs-SvgCodec-ViewBoxFitting: ViewBox "Meet, Centered" Fitting and Letterboxing **Tests**: `SvgCodec_Load_WideViewBoxIntoSquareRaster_LetterboxesTopAndBottom`, diff --git a/src/DemaConsulting.CanvasNet/Codecs/SvgCodec.cs b/src/DemaConsulting.CanvasNet/Codecs/SvgCodec.cs index ab312226..aa182e52 100644 --- a/src/DemaConsulting.CanvasNet/Codecs/SvgCodec.cs +++ b/src/DemaConsulting.CanvasNet/Codecs/SvgCodec.cs @@ -83,8 +83,11 @@ namespace DemaConsulting.CanvasNet.Codecs; /// width/height) - see this class's RenderFilteredShape/ /// EvaluateFilterChain remarks for the documented simplifications; and /// text (with font-family best-effort -/// matching against a caller-supplied font dictionary, font-size, fill, and -/// text-anchor). +/// matching against a caller-supplied font dictionary, font-size, fill, +/// text-anchor, and, for a caller that registers more than one +/// per family via the richer +/// overload, font-weight/font-style-aware closest-face matching - see this +/// class's SelectClosestFace remarks for the matching algorithm). /// /// /// Out of scope (silently ignored, per element). style blocks and CSS @@ -115,6 +118,16 @@ namespace DemaConsulting.CanvasNet.Codecs; /// filter-specific recursion-depth guard. /// /// +/// Within the supported text feature itself, the font-weight relative +/// keywords bolder/lighter (which resolve to a value relative to the +/// inherited weight rather than an absolute one) are not implemented - encountering either +/// keyword is tolerantly treated the same as an absent/unparseable font-weight, +/// falling back to the inherited value rather than throwing or guessing a relative +/// adjustment - and the font-style keyword oblique is not distinguished from +/// italic: both map onto the same value (see +/// 's remarks for the rationale). +/// +/// /// ViewBox fitting. /// always fits the document's intrinsic user-space size into the caller-requested raster size /// using a "meet, centered" policy equivalent to CSS object-fit: contain (SVG's own @@ -386,7 +399,9 @@ public void Charge(int amount) /// /// Rasterizes an SVG document read from an open, readable stream onto a new - /// of the requested size. + /// of the requested size, matching each text element's + /// cascaded font-family/font-weight/font-style against a + /// caller-supplied dictionary of per-family lists. /// /// /// The stream to read the SVG document from. Reading begins at the stream's current @@ -395,11 +410,15 @@ public void Charge(int amount) /// The width, in pixels, of the returned surface. /// The height, in pixels, of the returned surface. /// - /// An optional dictionary mapping font-family names to loaded - /// instances, used to render text elements. A value (the - /// default) or a dictionary with no entry matching a given text element's - /// font-family causes that element to be silently skipped rather than throwing - - /// see this class's remarks. + /// An optional dictionary mapping font-family names to the list of + /// instances registered for that family, used to render text elements. A + /// value, a dictionary with no entry matching a given text + /// element's font-family, or a matching entry whose face list is empty, causes that + /// element to be silently skipped rather than throwing - see this class's remarks. When a + /// family has more than one registered face, the face whose / + /// most closely matches the element's own cascaded + /// font-weight/font-style is selected - see SelectClosestFace's remarks + /// for the matching algorithm. /// /// /// A new of the requested size containing the rasterized document, @@ -417,16 +436,20 @@ public void Charge(int amount) /// /// /// - /// const string svg = "<svg viewBox='0 0 100 100'><circle cx='50' cy='50' r='40' fill='red'/></svg>"; + /// const string svg = "<svg viewBox='0 0 100 100'><text font-family='Sans' font-weight='bold'>Hi</text></svg>"; /// using var stream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(svg)); - /// var surface = SvgCodec.Load(stream, 200, 200); + /// var faces = new Dictionary<string, IReadOnlyList<SvgFontFace>> + /// { + /// ["Sans"] = [new SvgFontFace(regularFont), new SvgFontFace(boldFont, Weight: 700)] + /// }; + /// var surface = SvgCodec.Load(stream, 200, 200, faces); /// /// public static Surface Load( Stream stream, int width, int height, - IReadOnlyDictionary? fonts = null) + IReadOnlyDictionary>? fonts) { ArgumentNullException.ThrowIfNull(stream); @@ -466,15 +489,15 @@ public static Surface Load( /// /// Rasterizes an SVG document loaded from a file path onto a new of - /// the requested size. See + /// the requested size. See /// for the full contract. /// /// The path of the SVG file to load. Must not be null, empty, or whitespace. /// The width, in pixels, of the returned surface. /// The height, in pixels, of the returned surface. /// - /// An optional dictionary mapping font-family names to loaded - /// instances. See the stream overload's remarks. + /// An optional dictionary mapping font-family names to the list of + /// instances registered for that family. See the stream overload's remarks. /// /// A new containing the rasterized document. /// Thrown when is null. @@ -492,7 +515,7 @@ public static Surface Load( string path, int width, int height, - IReadOnlyDictionary? fonts = null) + IReadOnlyDictionary>? fonts) { ArgumentNullException.ThrowIfNull(path); if (string.IsNullOrWhiteSpace(path)) @@ -504,6 +527,133 @@ public static Surface Load( return Load(stream, width, height, fonts); } + /// + /// Rasterizes an SVG document read from an open, readable stream onto a new + /// of the requested size, using at most one + /// per font-family. + /// + /// + /// The stream to read the SVG document from. Reading begins at the stream's current + /// position and consumes the remainder of the stream. + /// + /// The width, in pixels, of the returned surface. + /// The height, in pixels, of the returned surface. + /// + /// An optional dictionary mapping font-family names to loaded + /// instances, used to render text elements. A value (the + /// default) or a dictionary with no entry matching a given text element's + /// font-family causes that element to be silently skipped rather than throwing - + /// see this class's remarks. + /// + /// + /// A new of the requested size containing the rasterized document, + /// fitted per this class's viewBox-fitting policy. + /// + /// Thrown when is null. + /// + /// Thrown when or is not a valid + /// dimension - see this class's remarks on caller-supplied raster + /// dimensions. + /// + /// + /// Thrown when the stream does not contain a well-formed, supported SVG document - see + /// this class's error-handling policy remarks. + /// + /// + /// A thin wrapper delegating to + /// , + /// via ToFontFaces wrapping each entry as a single normal-weight/normal-style + /// - so every text element always resolves to that single + /// registered font, regardless of its own font-weight/font-style, exactly as + /// before this overload existed. A caller registering more than one face per family (bold/ + /// italic variants) should call the richer overload directly instead. + /// + /// Do not pass an explicit untyped literal as the 4th positional + /// argument to either this method or its richer sibling: since neither generic + /// instantiation is more specific than the + /// other, doing so is ambiguous between the two overloads (a compile-time error). Either + /// omit the argument entirely (uses this overload's default), or cast the + /// to the specific dictionary type you intend. + /// + /// + /// + /// + /// const string svg = "<svg viewBox='0 0 100 100'><circle cx='50' cy='50' r='40' fill='red'/></svg>"; + /// using var stream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(svg)); + /// var surface = SvgCodec.Load(stream, 200, 200); + /// + /// + public static Surface Load( + Stream stream, + int width, + int height, + IReadOnlyDictionary? fonts = null) + => Load(stream, width, height, ToFontFaces(fonts)); + + /// + /// Rasterizes an SVG document loaded from a file path onto a new of + /// the requested size, using at most one per font-family. See + /// + /// for the full contract. + /// + /// The path of the SVG file to load. Must not be null, empty, or whitespace. + /// The width, in pixels, of the returned surface. + /// The height, in pixels, of the returned surface. + /// + /// An optional dictionary mapping font-family names to loaded + /// instances. See the stream overload's remarks. + /// + /// A new containing the rasterized document. + /// Thrown when is null. + /// + /// Thrown when is empty or consists only of whitespace. + /// + /// + /// Thrown when or is not a valid + /// dimension. + /// + /// + /// Thrown when the file does not contain a well-formed, supported SVG document. + /// + /// + /// A thin wrapper delegating to + /// , + /// via ToFontFaces - see the overload's remarks. + /// + public static Surface Load( + string path, + int width, + int height, + IReadOnlyDictionary? fonts = null) + => Load(path, width, height, ToFontFaces(fonts)); + + /// + /// Wraps a legacy single-font-per-family dictionary as the richer per-family + /// -list shape, each entry becoming a single normal-weight + /// (400)/normal-style () face - see the legacy + /// Load overloads' remarks. + /// + /// The legacy single-font-per-family dictionary, or . + /// + /// if is ; otherwise + /// a new dictionary with the same family-name keys, each mapped to a single-element face list. + /// + private static IReadOnlyDictionary>? ToFontFaces(IReadOnlyDictionary? fonts) + { + if (fonts == null) + { + return null; + } + + var result = new Dictionary>(fonts.Count); + foreach (var (familyName, font) in fonts) + { + result[familyName] = new SvgFontFace[] { new(font) }; + } + + return result; + } + /// /// Reads only an SVG document's root svg element (viewBox/width/ /// height attributes) and reports its resolved intrinsic size, without rendering @@ -978,6 +1128,15 @@ private enum TextAnchor /// The stroke-dashoffset value, in local user-space units. /// The font-family value, or if never set. /// The font-size value, in local user-space units. + /// + /// The CSS-numeric font-weight value (see ), used to + /// pick the closest-matching among those registered for + /// - see . + /// + /// + /// The font-style value (see ), likewise used by + /// . + /// /// The text-anchor value. /// /// The raw marker-start paint-like specification (none or url(#id)), @@ -1007,6 +1166,8 @@ private sealed record RenderState( float StrokeDashOffset, string? FontFamily, float FontSize, + int FontWeight, + SvgFontStyle FontStyle, TextAnchor TextAnchor, string MarkerStart, string MarkerMid, @@ -1031,6 +1192,8 @@ private sealed record RenderState( StrokeDashOffset: 0f, FontFamily: null, FontSize: 16f, + FontWeight: 400, + FontStyle: SvgFontStyle.Normal, TextAnchor: TextAnchor.Start, MarkerStart: "none", MarkerMid: "none", @@ -1044,11 +1207,17 @@ private sealed record RenderState( /// /// The pixel target every shape is rendered onto. /// The whole-document id-to-element index built once up front. - /// The caller-supplied font dictionary, or if none was supplied. + /// + /// The caller-supplied font-family-to-face-list dictionary, or if + /// none was supplied. Populated either directly by the richer + /// + /// overload, or via when the legacy single-font-per-family + /// overload is used. + /// private sealed record RenderContext( Surface Surface, Dictionary IdIndex, - IReadOnlyDictionary? Fonts) + IReadOnlyDictionary>? Fonts) { /// /// Caches each gradient element's own resolved (pre-alpha) color stops, keyed by the @@ -1335,6 +1504,8 @@ _ when string.Equals(dashArrayAttr.Trim(), "none", StringComparison.OrdinalIgnor StrokeDashOffset = GetOptionalFloat(element, "stroke-dashoffset") ?? parent.StrokeDashOffset, FontFamily = (string?)element.Attribute("font-family") ?? parent.FontFamily, FontSize = GetOptionalFloat(element, "font-size") ?? parent.FontSize, + FontWeight = ParseFontWeight((string?)element.Attribute("font-weight")) ?? parent.FontWeight, + FontStyle = ParseFontStyle((string?)element.Attribute("font-style")) ?? parent.FontStyle, TextAnchor = ParseTextAnchor((string?)element.Attribute("text-anchor")) ?? parent.TextAnchor, MarkerStart = (string?)element.Attribute("marker-start") ?? parent.MarkerStart, MarkerMid = (string?)element.Attribute("marker-mid") ?? parent.MarkerMid, @@ -1385,6 +1556,62 @@ _ when string.Equals(dashArrayAttr.Trim(), "none", StringComparison.OrdinalIgnor _ => null }; + /// + /// Parses a font-weight value: the keywords normal (400) and + /// bold (700), or a literal integer. + /// + /// The attribute's raw value, or if absent. + /// + /// The resolved numeric weight, or if is + /// absent, blank, the relative keywords bolder/lighter (not implemented - + /// see this class's remarks), or any other unparseable value - each tolerantly falling + /// back to the inherited weight, matching this codec's established tolerant-fallback + /// policy for a malformed presentation attribute (for example stroke-miterlimit). + /// + private static int? ParseFontWeight(string? raw) + { + if (string.IsNullOrWhiteSpace(raw)) + { + return null; + } + + var trimmed = raw.Trim(); + return trimmed switch + { + "normal" => 400, + "bold" => 700, + _ => int.TryParse(trimmed, NumberStyles.Integer, CultureInfo.InvariantCulture, out var value) ? value : null + }; + } + + /// + /// Parses a font-style value: normal, italic, or oblique (the + /// latter two both resolving to - see + /// 's remarks). Only the first whitespace-delimited token is + /// considered, tolerating a full oblique <angle> value without parsing the + /// angle itself. + /// + /// The attribute's raw value, or if absent. + /// + /// The matching , or if absent, blank, or + /// unrecognized (falling back to the inherited style). + /// + private static SvgFontStyle? ParseFontStyle(string? raw) + { + if (string.IsNullOrWhiteSpace(raw)) + { + return null; + } + + var firstToken = raw.Trim().Split(' ', StringSplitOptions.RemoveEmptyEntries)[0]; + return firstToken switch + { + "normal" => SvgFontStyle.Normal, + "italic" or "oblique" => SvgFontStyle.Italic, + _ => null + }; + } + /// /// Parses an opacity-like attribute (fill-opacity/stroke-opacity/opacity/ /// stop-opacity), accepting either a bare [0, 1] number or a percentage, and @@ -5111,7 +5338,7 @@ private static void RenderText(XElement element, RenderState state, Matrix3x2 tr return; } - var font = MatchFont(state.FontFamily, context.Fonts); + var font = MatchFont(state.FontFamily, state.FontWeight, state.FontStyle, context.Fonts); if (font == null) { return; @@ -5134,12 +5361,23 @@ private static void RenderText(XElement element, RenderState state, Matrix3x2 tr /// /// Finds the first font-family name in 's comma-separated - /// list with a case-insensitive match in . + /// list with a case-insensitive match in , then selects the + /// closest-matching registered face for that family via . /// /// The raw, possibly comma-separated, possibly quoted font-family value. - /// The caller-supplied font dictionary. - /// The matching font, or if none match (or is absent/blank). - private static TrueTypeFont? MatchFont(string? fontFamily, IReadOnlyDictionary fonts) + /// The cascaded font-weight to match against. + /// The cascaded font-style to match against. + /// The caller-supplied font-family-to-face-list dictionary. + /// + /// The selected font, or if no family name matches (or + /// is absent/blank), or the matching family's face list is + /// empty. + /// + private static TrueTypeFont? MatchFont( + string? fontFamily, + int requestedWeight, + SvgFontStyle requestedStyle, + IReadOnlyDictionary> fonts) { if (string.IsNullOrWhiteSpace(fontFamily)) { @@ -5149,11 +5387,11 @@ private static void RenderText(XElement element, RenderState state, Matrix3x2 tr foreach (var candidate in fontFamily.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)) { var name = candidate.Trim('\'', '"'); - foreach (var (familyName, font) in fonts) + foreach (var (familyName, faces) in fonts) { if (string.Equals(familyName, name, StringComparison.OrdinalIgnoreCase)) { - return font; + return SelectClosestFace(faces, requestedWeight, requestedStyle)?.Font; } } } @@ -5161,6 +5399,97 @@ private static void RenderText(XElement element, RenderState state, Matrix3x2 tr return null; } + /// + /// Selects the face in that best matches + /// /. + /// + /// One font-family's registered faces (never empty when called from with a non-empty list). + /// The requested numeric font-weight. + /// The requested font-style. + /// The best-matching face, or if is empty. + /// + /// A deliberately simple approximation of the CSS Fonts Module Level 4 font-weight + /// fallback cascade - not a byte-for-byte clone of it - matching the task's explicit + /// "do not over-engineer" guidance. Each candidate face is scored, in priority order, by: + /// + /// + /// + /// Style match. A face whose exactly + /// equals always beats one that does not, + /// regardless of how close its weight is. + /// + /// + /// + /// + /// Weight distance. Among faces tied on style match, the face whose + /// has the smallest absolute difference from + /// wins. + /// + /// + /// + /// + /// Boldness-side tie-break. Among faces tied on both style match and + /// weight distance, the face on the same "boldness side" as the request (both + /// its own weight and are either >= + /// 400 or < 400) wins over one on the opposite side. + /// + /// + /// + /// The first-registered face wins any remaining tie, since the running best is only + /// replaced by a strictly better-scoring candidate. + /// + private static SvgFontFace? SelectClosestFace(IReadOnlyList faces, int requestedWeight, SvgFontStyle requestedStyle) + { + SvgFontFace? best = null; + var bestStyleMismatch = true; + var bestWeightDistance = int.MaxValue; + var bestBoldnessMismatch = true; + + foreach (var face in faces) + { + var styleMismatch = face.Style != requestedStyle; + var weightDistance = Math.Abs(face.Weight - requestedWeight); + var boldnessMismatch = (face.Weight >= 400) != (requestedWeight >= 400); + + if (best == null + || IsBetterFace(styleMismatch, weightDistance, boldnessMismatch, bestStyleMismatch, bestWeightDistance, bestBoldnessMismatch)) + { + best = face; + bestStyleMismatch = styleMismatch; + bestWeightDistance = weightDistance; + bestBoldnessMismatch = boldnessMismatch; + } + } + + return best; + } + + /// + /// Compares a candidate face's match quality against the running-best face's, per the + /// three-part priority order documented on . + /// + /// if the candidate strictly beats the running best. + private static bool IsBetterFace( + bool candidateStyleMismatch, + int candidateWeightDistance, + bool candidateBoldnessMismatch, + bool bestStyleMismatch, + int bestWeightDistance, + bool bestBoldnessMismatch) + { + if (candidateStyleMismatch != bestStyleMismatch) + { + return bestStyleMismatch && !candidateStyleMismatch; + } + + if (candidateWeightDistance != bestWeightDistance) + { + return candidateWeightDistance < bestWeightDistance; + } + + return bestBoldnessMismatch && !candidateBoldnessMismatch; + } + /// /// Lays out as one combined local-space glyph-outline path, /// starting at , applying 's diff --git a/src/DemaConsulting.CanvasNet/Codecs/SvgFontFace.cs b/src/DemaConsulting.CanvasNet/Codecs/SvgFontFace.cs new file mode 100644 index 00000000..a608936c --- /dev/null +++ b/src/DemaConsulting.CanvasNet/Codecs/SvgFontFace.cs @@ -0,0 +1,36 @@ +using DemaConsulting.CanvasNet.Fonts; + +namespace DemaConsulting.CanvasNet.Codecs; + +/// +/// Represents one registered font face - a loaded paired +/// with the font-weight/font-style it was authored to represent - within a +/// single font-family's list of faces supplied to . +/// +/// +/// A small, immutable data carrier with no behavior beyond its record-struct value equality, +/// modeled directly on 's style: it exists purely so a caller can +/// register more than one per family name - for example a +/// regular, a bold, and an italic variant of the same family - and have +/// pick the closest-matching face for each text element's own cascaded +/// font-weight/font-style, instead of every element in a family always +/// resolving to the same single font regardless of its requested weight/style. The legacy +/// +/// overload, which accepts a single per family, is +/// internally translated into exactly one per family - with the +/// default (400, "normal") and +/// () - so that overload's behavior (always selecting that +/// single registered font, regardless of a requested font-weight/font-style) +/// is unchanged. +/// +/// The loaded font this face renders glyphs from. +/// +/// The CSS-numeric font-weight this face represents, conventionally in the well-known +/// 100-900 range (400 is "normal", 700 is "bold"), though any +/// positive integer is accepted, matching CSS's own numeric-range flexibility. Defaults to +/// 400 ("normal"). +/// +/// +/// The slant style this face represents. Defaults to . +/// +public readonly record struct SvgFontFace(TrueTypeFont Font, int Weight = 400, SvgFontStyle Style = SvgFontStyle.Normal); diff --git a/src/DemaConsulting.CanvasNet/Codecs/SvgFontStyle.cs b/src/DemaConsulting.CanvasNet/Codecs/SvgFontStyle.cs new file mode 100644 index 00000000..3aa03354 --- /dev/null +++ b/src/DemaConsulting.CanvasNet/Codecs/SvgFontStyle.cs @@ -0,0 +1,33 @@ +namespace DemaConsulting.CanvasNet.Codecs; + +/// +/// Identifies the slant style of a registered , used by +/// when matching a text element's font-style +/// presentation attribute against the caller-supplied faces registered for its +/// font-family. +/// +/// +/// Deliberately narrowed from CSS/SVG's three-way font-style keyword set +/// (normal/italic/oblique) to just two values. The reported real-world +/// use case this feature exists for - "bold titles and italic keywords" in a diagram label - +/// never needs to distinguish a true italic face (a font designer's own hand-drawn slanted +/// glyphs) from an oblique one (a mechanically-slanted upright face); both are simply +/// "the slanted variant" from a caller's point of view. 's +/// font-style parser (see its remarks) therefore maps the oblique keyword onto +/// rather than rejecting it or adding a third enum member, exactly +/// mirroring how and / +/// are each declared as their own small, top-level public +/// enum alongside 's other shared value types. +/// +public enum SvgFontStyle +{ + /// The upright, non-slanted variant (the SVG/CSS font-style default). + Normal, + + /// + /// The slanted variant - matches an SVG/CSS font-style value of either + /// italic or oblique; see this enum's remarks for why the two keywords are + /// not distinguished. + /// + Italic +} diff --git a/test/DemaConsulting.CanvasNet.Tests/Codecs/SvgCodecTests.cs b/test/DemaConsulting.CanvasNet.Tests/Codecs/SvgCodecTests.cs index 96de0bd1..aa660ee9 100644 --- a/test/DemaConsulting.CanvasNet.Tests/Codecs/SvgCodecTests.cs +++ b/test/DemaConsulting.CanvasNet.Tests/Codecs/SvgCodecTests.cs @@ -55,6 +55,71 @@ private static TrueTypeFont BuildTestFont() return TrueTypeFont.Load(stream); } + /// + /// Builds a synthetic font identical in structure to except + /// the 'A' glyph is a wider 80x50-unit square (x equals 0 to 80, rather than 0 to 50), + /// giving font-weight/font-style face-selection tests a distinguishable "which face + /// actually rendered" pixel signature (a wide-glyph-only region between local x equals 50 + /// and 80) representing a registered "bold" face. + /// + private static TrueTypeFont BuildBoldTestFont() + { + var square = SyntheticFontBuilder.SimpleGlyph( + [ + [(0, 0, true), (80, 0, true), (80, 50, true), (0, 50, true)] + ]); + + var cmap = SyntheticFontBuilder.CmapFormat4(3, 1, [(65, 1), (66, 2)]); + var kern = SyntheticFontBuilder.KernFormat0([(1, 2, -10)]); + + var data = new SyntheticFontBuilder() + .AddTable("head", SyntheticFontBuilder.Head(100, 0)) + .AddTable("maxp", SyntheticFontBuilder.Maxp(3)) + .AddTable("hhea", SyntheticFontBuilder.Hhea(100, 0, 0, 3)) + .AddTable("hmtx", SyntheticFontBuilder.Hmtx([0, 100, 100])) + .AddTable("loca", SyntheticFontBuilder.Loca([0, square.Length, square.Length], longFormat: false)) + .AddTable("glyf", [.. square, .. square]) + .AddTable("cmap", cmap) + .AddTable("kern", kern) + .Build(); + + using var stream = new MemoryStream(data); + return TrueTypeFont.Load(stream); + } + + /// + /// Builds a synthetic font identical in structure to except + /// the 'A' glyph is a 50x50-unit square shifted right by 20 units (x equals 20 to 70, + /// rather than 0 to 50), giving font-weight/font-style face-selection tests a + /// distinguishable "which face actually rendered" pixel signature (filled at local x + /// equals 60 but not at local x equals 10, the opposite of 's + /// square) representing a registered "italic" face. + /// + private static TrueTypeFont BuildItalicTestFont() + { + var square = SyntheticFontBuilder.SimpleGlyph( + [ + [(20, 0, true), (70, 0, true), (70, 50, true), (20, 50, true)] + ]); + + var cmap = SyntheticFontBuilder.CmapFormat4(3, 1, [(65, 1), (66, 2)]); + var kern = SyntheticFontBuilder.KernFormat0([(1, 2, -10)]); + + var data = new SyntheticFontBuilder() + .AddTable("head", SyntheticFontBuilder.Head(100, 0)) + .AddTable("maxp", SyntheticFontBuilder.Maxp(3)) + .AddTable("hhea", SyntheticFontBuilder.Hhea(100, 0, 0, 3)) + .AddTable("hmtx", SyntheticFontBuilder.Hmtx([0, 100, 100])) + .AddTable("loca", SyntheticFontBuilder.Loca([0, square.Length, square.Length], longFormat: false)) + .AddTable("glyf", [.. square, .. square]) + .AddTable("cmap", cmap) + .AddTable("kern", kern) + .Build(); + + using var stream = new MemoryStream(data); + return TrueTypeFont.Load(stream); + } + // ================================================================================================ // Basic shapes // ================================================================================================ @@ -3064,6 +3129,305 @@ public void SvgCodec_Load_TextFontFamilyCommaSeparatedList_MatchesFirstAvailable Assert.Equal(255, surface[35, 35].A); } + // ================================================================================================ + // font-weight / font-style-aware face matching (SvgFontFace / SelectClosestFace) + // ================================================================================================ + + /// + /// Proves that a family with two registered faces (400/Normal, using + /// 's narrow 50-wide glyph, and 700/Normal, using + /// 's wide 80-wide glyph) selects the bold face for a + /// font-weight="bold" text element, while a sibling element with no own + /// font-weight still selects the normal (narrow) face. + /// + [Fact] + public void SvgCodec_Load_TextFontWeightBold_SelectsBoldFaceOverNormalFace() + { + // Arrange: font-size equals the shared 100-unit em-square, so scale is 1:1. The narrow + // face fills canvas x equals 10 to 60; the wide face fills canvas x equals 10 to 90 - x + // equals 75 is filled only by the wide (bold) face. + const string svg = """ + + A + A + + """; + var faces = new Dictionary> + { + ["TestFont"] = + [ + new SvgFontFace(BuildTestFont(), Weight: 400), + new SvgFontFace(BuildBoldTestFont(), Weight: 700) + ] + }; + + // Act + using var stream = ToStream(svg); + var surface = SvgCodec.Load(stream, 100, 100, faces); + + // Assert: the font-weight="bold" text selected the wide face + Assert.Equal(255, surface[75, 35].A); + // Assert: the sibling text with no font-weight still selected the narrow face + Assert.Equal(0, surface[75, 70].A); + } + + /// + /// Proves that a family with two registered faces (400/Normal, using + /// 's glyph at local x equals 0-50, and 400/Italic, using + /// 's glyph shifted to local x equals 20-70) selects the + /// italic face for a font-style="italic" text element. + /// + [Fact] + public void SvgCodec_Load_TextFontStyleItalic_SelectsItalicFaceOverNormalFace() + { + // Arrange: the normal face fills canvas x equals 10 to 60; the italic face fills canvas x + // equals 30 to 80 - x equals 75 is filled only by the italic face, and x equals 15 is + // filled only by the normal face. + const string svg = """ + + A + + """; + var faces = new Dictionary> + { + ["TestFont"] = + [ + new SvgFontFace(BuildTestFont()), + new SvgFontFace(BuildItalicTestFont(), Style: SvgFontStyle.Italic) + ] + }; + + // Act + using var stream = ToStream(svg); + var surface = SvgCodec.Load(stream, 100, 100, faces); + + // Assert: filled where only the italic face's glyph reaches + Assert.Equal(255, surface[75, 35].A); + // Assert: NOT filled where only the normal face's glyph would have reached + Assert.Equal(0, surface[15, 35].A); + } + + /// + /// Proves the closest-weight-distance rule: with faces registered at 400 (narrow) and 900 + /// (wide), a request of font-weight="600" selects the 400 face (distance 200) + /// rather than the 900 face (distance 300). + /// + [Fact] + public void SvgCodec_Load_TextFontWeightNumeric_SelectsClosestRegisteredFaceByDistance() + { + // Arrange + const string svg = """ + + A + + """; + var faces = new Dictionary> + { + ["TestFont"] = + [ + new SvgFontFace(BuildTestFont(), Weight: 400), + new SvgFontFace(BuildBoldTestFont(), Weight: 900) + ] + }; + + // Act + using var stream = ToStream(svg); + var surface = SvgCodec.Load(stream, 100, 100, faces); + + // Assert: the 900 (wide) face's exclusive region was NOT selected + Assert.Equal(0, surface[75, 35].A); + // Assert: sanity check the 400 (narrow) face did render + Assert.Equal(255, surface[35, 35].A); + } + + /// + /// Proves the boldness-side tie-break rule: with faces registered at 300 (narrow) and 500 + /// (wide) - both equidistant (100) from a requested font-weight="400" - the 500 + /// face wins, because it is on the same "boldness side" (weight >= 400) as the request, + /// while the 300 face is not. + /// + [Fact] + public void SvgCodec_Load_TextFontWeightTie_PrefersMatchingBoldnessSide() + { + // Arrange + const string svg = """ + + A + + """; + var faces = new Dictionary> + { + ["TestFont"] = + [ + new SvgFontFace(BuildTestFont(), Weight: 300), + new SvgFontFace(BuildBoldTestFont(), Weight: 500) + ] + }; + + // Act + using var stream = ToStream(svg); + var surface = SvgCodec.Load(stream, 100, 100, faces); + + // Assert: the 500 (wide) face was selected, per the boldness-side tie-break + Assert.Equal(255, surface[75, 35].A); + } + + /// + /// Proves that a family with only a 400/Normal face registered still renders that face + /// when font-style="italic" is requested (graceful fallback to the sole available + /// face, rather than rendering nothing). + /// + [Fact] + public void SvgCodec_Load_TextFontStyleNoItalicRegistered_FallsBackToOnlyAvailableFace() + { + // Arrange + const string svg = """ + + A + + """; + var faces = new Dictionary> + { + ["TestFont"] = [new SvgFontFace(BuildTestFont())] + }; + + // Act + using var stream = ToStream(svg); + var surface = SvgCodec.Load(stream, 100, 100, faces); + + // Assert: the sole registered (normal) face still rendered + Assert.Equal(255, surface[35, 35].A); + } + + /// + /// Proves that, among three registered faces (400/Normal narrow, 700/Normal wide, and + /// 700/Italic shifted), a request of font-weight="bold" font-style="italic" + /// selects the 700/Italic face specifically - not merely a weight- or style-only match. + /// + [Fact] + public void SvgCodec_Load_TextFontWeightAndStyleCombined_SelectsExactMatchAmongThreeFaces() + { + // Arrange: the italic face's glyph (canvas x equals 30-80) is a strict subset of the + // bold/wide face's glyph (canvas x equals 10-90) - x equals 75 alone cannot distinguish + // them, so this also asserts x equals 85 (filled only by the wide face) is NOT filled, + // proving the wide (bold/normal) face was not selected instead. + const string svg = """ + + A + + """; + var faces = new Dictionary> + { + ["TestFont"] = + [ + new SvgFontFace(BuildTestFont(), Weight: 400), + new SvgFontFace(BuildBoldTestFont(), Weight: 700), + new SvgFontFace(BuildItalicTestFont(), Weight: 700, Style: SvgFontStyle.Italic) + ] + }; + + // Act + using var stream = ToStream(svg); + var surface = SvgCodec.Load(stream, 100, 100, faces); + + // Assert: filled where only the italic face's glyph reaches + Assert.Equal(255, surface[75, 35].A); + // Assert: NOT filled where only the wide (bold/normal) face's glyph would have reached + Assert.Equal(0, surface[85, 35].A); + } + + /// + /// Proves that a g element's font-weight="bold" cascades down to a child + /// text element that does not set its own font-weight, mirroring + /// . + /// + [Fact] + public void SvgCodec_Load_GroupFontWeightInheritance_AppliesToChildTextWithoutOwnFontWeight() + { + // Arrange + const string svg = """ + + + A + + + """; + var faces = new Dictionary> + { + ["TestFont"] = + [ + new SvgFontFace(BuildTestFont(), Weight: 400), + new SvgFontFace(BuildBoldTestFont(), Weight: 700) + ] + }; + + // Act + using var stream = ToStream(svg); + var surface = SvgCodec.Load(stream, 100, 100, faces); + + // Assert: the child text inherited font-weight="bold" and selected the wide face + Assert.Equal(255, surface[75, 35].A); + } + + /// + /// Proves that a g element's font-style="italic" cascades down to a child + /// text element that does not set its own font-style. + /// + [Fact] + public void SvgCodec_Load_GroupFontStyleInheritance_AppliesToChildTextWithoutOwnFontStyle() + { + // Arrange + const string svg = """ + + + A + + + """; + var faces = new Dictionary> + { + ["TestFont"] = + [ + new SvgFontFace(BuildTestFont()), + new SvgFontFace(BuildItalicTestFont(), Style: SvgFontStyle.Italic) + ] + }; + + // Act + using var stream = ToStream(svg); + var surface = SvgCodec.Load(stream, 100, 100, faces); + + // Assert: the child text inherited font-style="italic" and selected the italic face + Assert.Equal(255, surface[75, 35].A); + Assert.Equal(0, surface[15, 35].A); + } + + /// + /// Regression test: proves the legacy single-font-per-family Load overload (taking + /// IReadOnlyDictionary<string, TrueTypeFont>) always selects its one + /// registered font, ignoring any requested font-weight/font-style entirely - + /// proving the additive -based overload never changed this + /// overload's own established behavior. + /// + [Fact] + public void SvgCodec_Load_TextLegacySingleFontOverload_IgnoresRequestedWeightAndStyle() + { + // Arrange: bold and italic are both requested, but only one plain font is registered + const string svg = """ + + A + + """; + var fonts = new Dictionary { ["TestFont"] = BuildTestFont() }; + + // Act + using var stream = ToStream(svg); + var surface = SvgCodec.Load(stream, 100, 100, fonts); + + // Assert: the sole registered font still rendered at its known position + Assert.Equal(255, surface[35, 35].A); + } + // ================================================================================================ // viewBox fitting ("meet, centered" / object-fit: contain) // ================================================================================================ From 12ebd5083c85ee8a5816c482724cecf36717c801 Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Sat, 26 Sep 2026 21:22:57 -0400 Subject: [PATCH 4/8] Fix 6 PR #23 code-review issues in SvgCodec (markers, filters, fonts) Addresses 6 issues (1 high, 5 medium) found by Copilot code review on PR #23: 1. (HIGH) Ambiguous overload: renamed the richer Load(Stream/string, int, int, IReadOnlyDictionary>?) overloads to LoadWithFontFaces, eliminating overload-resolution ambiguity with an explicit `null` literal against the legacy single-font Load overload. Updated all call sites, XML doc references, and docs. 2. (MEDIUM) Filter work-budget bypass: feMerge's upfront primitive-count charge now includes each feMergeNode child's own cost (instead of a flat 1), since ApplyFeMerge performs one full-surface composite per node. 3. (MEDIUM) Filter bounds reject stroked lines: the filter-region degeneracy guard and region computation now use stroke-expanded bounds (via a new ExpandBoundsForStroke helper) instead of bare centerline/fill bounds, so a horizontal/vertical stroked filter is no longer misdetected as degenerate. 4. (MEDIUM) Missing closing tangent: BuildMarkerVertices now folds a closed subpath's implicit closing-segment tangent into both the last vertex's outgoing tangent and the first vertex's incoming tangent, so a closed polygon/path's marker-end orientation reflects the closing edge too. 5. (MEDIUM) Double stroke-width scaling: RenderMarkers now passes the raw, unscaled local stroke-width into RenderOneMarker (instead of an already-pixel-scaled effective width), so the shapeTransform composition is the only place the root/group pixel scale is applied. 6. (MEDIUM) Integer overflow in weight distance: SelectClosestFace/ IsBetterFace now compute weight distance using long arithmetic, avoiding OverflowException/silent-wrap for extreme caller-supplied font-weight values. Added 6 new regression tests (one per issue) plus updated design/ verification/reqstream/user-guide docs for the LoadWithFontFaces rename and the new test coverage. Test count: 4260 -> 4278 (6 new tests x 3 target frameworks). build.ps1 and lint.ps1 both pass. --- docs/design/canvas-net/codecs/svg-codec.md | 14 +- .../canvas-net/codecs/svg-codec.yaml | 6 + docs/user_guide/introduction.md | 10 +- .../canvas-net/codecs/svg-codec.md | 60 ++++- .../Codecs/SvgCodec.cs | 207 +++++++++++---- .../Codecs/SvgFontFace.cs | 2 +- .../Codecs/SvgCodecTests.cs | 247 +++++++++++++++++- 7 files changed, 469 insertions(+), 77 deletions(-) diff --git a/docs/design/canvas-net/codecs/svg-codec.md b/docs/design/canvas-net/codecs/svg-codec.md index 33942c85..600c6cdf 100644 --- a/docs/design/canvas-net/codecs/svg-codec.md +++ b/docs/design/canvas-net/codecs/svg-codec.md @@ -132,9 +132,13 @@ an external or parameter entity - is rejected with `InvalidDataException` via bo ### Key Methods -#### Load(Stream stream, int width, int height, IReadOnlyDictionary\\>? fonts) +#### LoadWithFontFaces(Stream, int, int, IReadOnlyDictionary\\>?) -Reads an SVG document from an open stream and rasterizes it into a new `width`x`height` +Named distinctly from `Load` (rather than overloaded onto it) because both `fonts` dictionary +value types share the same parameter arity, so an overload sharing the `Load` name would make an +explicit untyped `null` literal passed as the 4th positional argument ambiguous between the two +overloads — a source-breaking compile error for existing callers using that pattern. Reads an +SVG document from an open stream and rasterizes it into a new `width`x`height` `Surface`. Parses the document with `XDocument.Load`, builds an id→`XElement` index over the whole tree up front (so `use`/`href`/gradient-template references resolve correctly regardless of document order), resolves the root `viewBox`/`width`/`height` into an intrinsic size, computes @@ -162,10 +166,10 @@ family overload below delegates to this one. element(s) to be silently skipped rather than throwing — see _Gradient, Use, and Text Support and Limits_ below. -#### Load(string path, int width, int height, IReadOnlyDictionary\\>? fonts) +#### LoadWithFontFaces(string, int, int, IReadOnlyDictionary\\>?) Opens `path` as a read-only `FileStream` and delegates to -`Load(Stream, int, int, IReadOnlyDictionary>?)`. +`LoadWithFontFaces(Stream, int, int, IReadOnlyDictionary>?)`. **Throws:** @@ -177,7 +181,7 @@ Opens `path` as a read-only `FileStream` and delegates to #### Load(Stream stream, int width, int height, IReadOnlyDictionary\? fonts = null) The legacy, pre-existing overload: accepts at most one `TrueTypeFont` per font-family. A thin -wrapper that delegates to the richer overload above via a private `ToFontFaces` helper, which +wrapper that delegates to `LoadWithFontFaces` above via a private `ToFontFaces` helper, which wraps each dictionary entry as a single normal-weight (`400`)/normal-style `SvgFontFace` — so every `text` element resolves to that single registered font regardless of its own `font-weight`/`font-style`, preserving this overload's behavior exactly as it was before diff --git a/docs/reqstream/canvas-net/codecs/svg-codec.yaml b/docs/reqstream/canvas-net/codecs/svg-codec.yaml index baa6c3b3..31424692 100644 --- a/docs/reqstream/canvas-net/codecs/svg-codec.yaml +++ b/docs/reqstream/canvas-net/codecs/svg-codec.yaml @@ -185,6 +185,8 @@ sections: - SvgCodec_Load_MarkerOnRectCircleEllipse_NeverRendersMarker - SvgCodec_Load_MarkerContent_DoesNotInheritReferencingShapeFillOrStroke - SvgCodec_Load_ArrowMarkersFixture_RendersArrowheadPastLineEnd + - SvgCodec_Load_MarkerEndOnClosedPolygon_OrientsUsingClosingEdgeTangent + - SvgCodec_Load_MarkerStrokeWidthUnitsOnScaledDocument_ScalesOnceNotTwice - id: CanvasNet-Codecs-SvgCodec-MarkerReferenceCycle title: >- @@ -373,6 +375,7 @@ sections: - SvgCodec_Load_TextFontWeightTie_PrefersMatchingBoldnessSide - SvgCodec_Load_TextFontStyleNoItalicRegistered_FallsBackToOnlyAvailableFace - SvgCodec_Load_TextFontWeightAndStyleCombined_SelectsExactMatchAmongThreeFaces + - SvgCodec_Load_TextFontWeightExtremeValue_DoesNotOverflowAndSelectsClosestFace - id: CanvasNet-Codecs-SvgCodec-LegacySingleFontCompatibility title: >- @@ -385,6 +388,7 @@ sections: any behavior change from the addition of weight/style-aware face matching. tests: - SvgCodec_Load_TextLegacySingleFontOverload_IgnoresRequestedWeightAndStyle + - SvgCodec_Load_ExplicitNullFontsLiteral_CompilesUnambiguouslyAndFallsBackToBuiltInFont - id: CanvasNet-Codecs-SvgCodec-ViewBoxFitting title: >- @@ -541,6 +545,7 @@ sections: - SvgCodec_Load_FeCompositeOperatorIn_KeepsForegroundWeightedByBackgroundAlpha - SvgCodec_Load_FeCompositeOperatorAtop_BlendsBothInputsWeightedByBothAlphas - SvgCodec_Load_FeCompositeOperatorXor_KeepsEachInputWhereTheOtherHasNoCoverage + - SvgCodec_Load_FilterOnStrokedHorizontalLine_UsesStrokeAwareBoundsNotDegenerate - id: CanvasNet-Codecs-SvgCodec-FilterResourceSafety title: >- @@ -575,6 +580,7 @@ sections: - SvgCodec_Load_FilterPathologicallyLargeRegion_SkipsFilterRatherThanUnboundedAllocation - SvgCodec_Load_FilterPathologicallyLargeBlurStdDeviation_ClampsRatherThanUnboundedWork - SvgCodec_Load_FilterExcessivePrimitiveCount_SkipsFilterRatherThanUnboundedWork + - SvgCodec_Load_FeMergeExcessiveNodeCount_SkipsFilterRatherThanUnboundedWork - id: CanvasNet-Codecs-SvgCodec-ValidationNull title: >- diff --git a/docs/user_guide/introduction.md b/docs/user_guide/introduction.md index 3d35efec..5193bc49 100644 --- a/docs/user_guide/introduction.md +++ b/docs/user_guide/introduction.md @@ -909,10 +909,10 @@ renders; malformed/unparseable input throws `InvalidDataException`. #### SvgCodec Methods -##### SvgCodec.Load(Stream stream, ..., IReadOnlyDictionary<string, IReadOnlyList<SvgFontFace>>? fonts) +##### SvgCodec.LoadWithFontFaces(Stream, ..., IReadOnlyDictionary<string, IReadOnlyList<SvgFontFace>>?) ```csharp -public static Surface Load( +public static Surface LoadWithFontFaces( Stream stream, int width, int height, @@ -937,10 +937,10 @@ element to be silently skipped rather than throwing. pre-validated by `SvgCodec`; propagates from `new Surface(width, height)`). - `InvalidDataException`: Thrown when the stream does not contain valid, supported SVG content. -##### SvgCodec.Load(string path, ..., IReadOnlyDictionary<string, IReadOnlyList<SvgFontFace>>? fonts) +##### SvgCodec.LoadWithFontFaces(string, ..., IReadOnlyDictionary<string, IReadOnlyList<SvgFontFace>>?) ```csharp -public static Surface Load( +public static Surface LoadWithFontFaces( string path, int width, int height, @@ -948,7 +948,7 @@ public static Surface Load( ``` Decodes and rasterizes an SVG file at the specified path, as -`Load(Stream, int, int, IReadOnlyDictionary>?)`. +`LoadWithFontFaces(Stream, int, int, IReadOnlyDictionary>?)`. **Exceptions:** diff --git a/docs/verification/canvas-net/codecs/svg-codec.md b/docs/verification/canvas-net/codecs/svg-codec.md index 6cf27ae8..4f9a1874 100644 --- a/docs/verification/canvas-net/codecs/svg-codec.md +++ b/docs/verification/canvas-net/codecs/svg-codec.md @@ -187,7 +187,9 @@ rejected with `InvalidDataException` once the bounded recursion guard is exceede `SvgCodec_Load_MarkerOnMultiSubpathPath_AppliesStartEndOnlyAtWholePathEnds`, `SvgCodec_Load_MarkerOnRectCircleEllipse_NeverRendersMarker`, `SvgCodec_Load_MarkerContent_DoesNotInheritReferencingShapeFillOrStroke`, -`SvgCodec_Load_ArrowMarkersFixture_RendersArrowheadPastLineEnd` +`SvgCodec_Load_ArrowMarkersFixture_RendersArrowheadPastLineEnd`, +`SvgCodec_Load_MarkerEndOnClosedPolygon_OrientsUsingClosingEdgeTangent`, +`SvgCodec_Load_MarkerStrokeWidthUnitsOnScaledDocument_ScalesOnceNotTwice` Asserts a `marker-end` reference renders its `marker` element's content past a `line`'s own end point, sized in user-space units; asserts `marker-start`/`marker-mid`/`marker-end` each @@ -209,7 +211,16 @@ even when a `marker-end` attribute is present, since these shapes have no natura orient one along; and asserts a marker's own content renders with a fresh presentation-attribute cascade from the SVG/CSS initial defaults, not inheriting the referencing shape's own `fill`/`stroke`. A real fixture file (`SvgFixtures/arrow-markers.svg`) exercises the common -`marker-end` arrowhead scenario end-to-end. +`marker-end` arrowhead scenario end-to-end. Asserts a closed `polygon`'s implicit closing segment +(back to its first vertex) contributes to the `orient="auto"` tangent computed for the last +vertex's `marker-end` - averaged with the incoming open-path edge's own tangent - rather than the +closing edge being silently ignored (a regression test for a defect where a closed shape's final +vertex was oriented using only its incoming edge). Asserts a default `markerUnits="strokeWidth"` +marker on a document with a non-identity root `viewBox` fit (a uniform 2x scale from local units +to pixels) scales by exactly `stroke-width * root-scale` once, not twice (a regression test for a +defect where the already-pixel-scaled effective stroke width was passed into the marker's own +`strokeWidth`-units sizing, and then the root scale was re-applied a second time via the shared +`shapeTransform`, making the marker four times too large instead of two times). #### CanvasNet-Codecs-SvgCodec-MarkerReferenceCycle: Marker Reference Dangling and Cycle Rejection @@ -386,10 +397,11 @@ child `text` element with no own `font-style` (the child selects the italic face `SvgCodec_Load_TextFontWeightNumeric_SelectsClosestRegisteredFaceByDistance`, `SvgCodec_Load_TextFontWeightTie_PrefersMatchingBoldnessSide`, `SvgCodec_Load_TextFontStyleNoItalicRegistered_FallsBackToOnlyAvailableFace`, -`SvgCodec_Load_TextFontWeightAndStyleCombined_SelectsExactMatchAmongThreeFaces` +`SvgCodec_Load_TextFontWeightAndStyleCombined_SelectsExactMatchAmongThreeFaces`, +`SvgCodec_Load_TextFontWeightExtremeValue_DoesNotOverflowAndSelectsClosestFace` -Exercises every branch of `SelectClosestFace`'s three-part priority order using the richer -`Load(..., IReadOnlyDictionary>?)` overload: a two-face family +Exercises every branch of `SelectClosestFace`'s three-part priority order using the +`LoadWithFontFaces(..., IReadOnlyDictionary>?)` overload: a two-face family (400/Normal narrow, 700/Normal wide) selects the bold face for `font-weight="bold"` and the normal face for a sibling with no own `font-weight`; a two-face family (400/Normal narrow, 400/Italic shifted) selects the italic face for `font-style="italic"`; faces registered at 400 @@ -400,11 +412,17 @@ proving the boldness-side tie-break; a family with only a 400/Normal face still (400/Normal, 700/Normal, 700/Italic) with a combined `font-weight="bold" font-style="italic"` request select the 700/Italic face specifically - proven by asserting both a pixel unique to the italic face's glyph (proving it is not the normal face) and a pixel unique to the wide/bold face's -glyph is *not* filled (proving it is not merely the weight-matched bold/normal face). +glyph is *not* filled (proving it is not merely the weight-matched bold/normal face). Asserts an +extreme, out-of-range `font-weight="-2147483648"` request (still parsed, not rejected, by +`ParseFontWeight`) does not throw an `OverflowException` from `SelectClosestFace`'s +weight-distance computation and still deterministically selects the closer-by-magnitude +registered face - a regression test for a defect where the weight-distance computation used `int` +arithmetic, which can overflow for such extreme caller-supplied weight values. #### CanvasNet-Codecs-SvgCodec-LegacySingleFontCompatibility: Legacy Single-Font-Per-Family Overload Regression -**Tests**: `SvgCodec_Load_TextLegacySingleFontOverload_IgnoresRequestedWeightAndStyle` +**Tests**: `SvgCodec_Load_TextLegacySingleFontOverload_IgnoresRequestedWeightAndStyle`, +`SvgCodec_Load_ExplicitNullFontsLiteral_CompilesUnambiguouslyAndFallsBackToBuiltInFont` Proves the pre-existing `Load(..., IReadOnlyDictionary?)` overload's behavior is unchanged by the additive `SvgFontFace`-based overload: with only one plain @@ -412,7 +430,13 @@ behavior is unchanged by the additive `SvgFontFace`-based overload: with only on `font-style="italic"` still renders that single registered font at its known pixel position, proving the legacy overload's internal `ToFontFaces` wrapping (one normal-weight/normal-style face per family) makes weight/style irrelevant to face selection, exactly as before this feature -existed. +existed. Also proves a regression for a source-breaking ambiguous-overload defect: prior to the +richer, `SvgFontFace`-based overload being renamed to `LoadWithFontFaces`, a caller passing an +explicit `null` literal (rather than omitting the argument) to `SvgCodec.Load(stream, width, +height, null)` failed to compile with "the call is ambiguous", because `null` matched both the +legacy `IReadOnlyDictionary?` overload and the richer overload equally +well; now that the richer overload has its own distinct name, this call is unambiguous and falls +back to this legacy overload's own documented "no font registered" behavior. #### CanvasNet-Codecs-SvgCodec-ViewBoxFitting: ViewBox "Meet, Centered" Fitting and Letterboxing @@ -586,7 +610,8 @@ for `filter`'s own dedicated coverage - it is no longer an out-of-scope construc `SvgCodec_Load_InkscapeFiltersFixture_ToleratesFiltersAndRendersFlowerContent`, `SvgCodec_Load_FeCompositeOperatorIn_KeepsForegroundWeightedByBackgroundAlpha`, `SvgCodec_Load_FeCompositeOperatorAtop_BlendsBothInputsWeightedByBothAlphas`, -`SvgCodec_Load_FeCompositeOperatorXor_KeepsEachInputWhereTheOtherHasNoCoverage` +`SvgCodec_Load_FeCompositeOperatorXor_KeepsEachInputWhereTheOtherHasNoCoverage`, +`SvgCodec_Load_FilterOnStrokedHorizontalLine_UsesStrokeAwareBoundsNotDegenerate` Asserts a bare `feFlood` primitive's flood color entirely replaces the referencing element's own content, filling the (default, bounding-box-relative) filter region including the area behind the @@ -620,14 +645,20 @@ that isolate the operator's own per-pixel formula from any shape-geometry/filter concern: `in` keeps the "in" input's own color weighted by the "in2" input's own alpha; `atop` blends both inputs' own colors weighted by (`in2`'s alpha, `1 - in`'s alpha); and `xor` keeps each input only where the other has no coverage - each asserting the exact expected pixel value the -documented formula produces. +documented formula produces. Asserts a horizontal, stroked `line` with a `feFlood` filter is +evaluated (not skipped as degenerate) despite its zero-height centerline/fill bounds - a +regression test for a defect where the filter-region degeneracy guard used the bare +centerline/fill bounds instead of the actually-painted (stroke-expanded) bounds, incorrectly +treating a valid stroked line's filter as degenerate and silently falling back to unfiltered +rendering. #### CanvasNet-Codecs-SvgCodec-FilterResourceSafety: Filter Dangling Reference and Resource-Bound Tolerance **Tests**: `SvgCodec_Load_FilterDanglingReference_RendersElementNormally`, `SvgCodec_Load_FilterPathologicallyLargeRegion_SkipsFilterRatherThanUnboundedAllocation`, `SvgCodec_Load_FilterPathologicallyLargeBlurStdDeviation_ClampsRatherThanUnboundedWork`, -`SvgCodec_Load_FilterExcessivePrimitiveCount_SkipsFilterRatherThanUnboundedWork` +`SvgCodec_Load_FilterExcessivePrimitiveCount_SkipsFilterRatherThanUnboundedWork`, +`SvgCodec_Load_FeMergeExcessiveNodeCount_SkipsFilterRatherThanUnboundedWork` Asserts a `filter="url(#id)"` reference to a nonexistent id renders the element normally, exactly as if no `filter` attribute were present, matching this codec's general dangling-reference @@ -643,7 +674,12 @@ hanging) result; and asserts a filter chain with an excessive number of primitiv is tolerantly skipped entirely rather than evaluated, completing promptly instead of performing 5,000 region-sized blur passes - bounding the primitive-count × region-area cost dimension that `MaxTotalRenderedElements`/`GeometryWorkBudget` do not cover, since neither tracks a single -filter's own per-primitive, region-sized buffer cost. +filter's own per-primitive, region-sized buffer cost. Also asserts a `feMerge` primitive with an +excessive number (5,000) of `feMergeNode` children is likewise tolerantly skipped, rather than +bypassing the primitive-count work budget - a regression test for a defect where the budget check +counted a `feMerge` as a single primitive regardless of its own `feMergeNode` child count, even +though `ApplyFeMerge` performs one full-surface composite per child, letting a pathological +`feMerge` node count bypass the budget while still performing unbounded work. #### CanvasNet-Codecs-SvgCodec-ValidationNull: Null Stream/Path Rejected diff --git a/src/DemaConsulting.CanvasNet/Codecs/SvgCodec.cs b/src/DemaConsulting.CanvasNet/Codecs/SvgCodec.cs index aa182e52..575a1134 100644 --- a/src/DemaConsulting.CanvasNet/Codecs/SvgCodec.cs +++ b/src/DemaConsulting.CanvasNet/Codecs/SvgCodec.cs @@ -85,7 +85,7 @@ namespace DemaConsulting.CanvasNet.Codecs; /// text (with font-family best-effort /// matching against a caller-supplied font dictionary, font-size, fill, /// text-anchor, and, for a caller that registers more than one -/// per family via the richer +/// per family via the /// overload, font-weight/font-style-aware closest-face matching - see this /// class's SelectClosestFace remarks for the matching algorithm). /// @@ -403,6 +403,14 @@ public void Charge(int amount) /// cascaded font-family/font-weight/font-style against a /// caller-supplied dictionary of per-family lists. /// + /// + /// Named distinctly from the single-font-per-family + /// overload (rather than sharing the Load name) because both dictionary value types + /// would otherwise have identical arity and neither generic instantiation is more specific + /// than the other - an explicit literal passed as the 4th positional + /// argument would be ambiguous between the two overloads, breaking existing callers who use + /// that pattern. Using a distinct name eliminates the ambiguity entirely. + /// /// /// The stream to read the SVG document from. Reading begins at the stream's current /// position and consumes the remainder of the stream. @@ -442,10 +450,10 @@ public void Charge(int amount) /// { /// ["Sans"] = [new SvgFontFace(regularFont), new SvgFontFace(boldFont, Weight: 700)] /// }; - /// var surface = SvgCodec.Load(stream, 200, 200, faces); + /// var surface = SvgCodec.LoadWithFontFaces(stream, 200, 200, faces); /// /// - public static Surface Load( + public static Surface LoadWithFontFaces( Stream stream, int width, int height, @@ -489,7 +497,7 @@ public static Surface Load( /// /// Rasterizes an SVG document loaded from a file path onto a new of - /// the requested size. See + /// the requested size. See /// for the full contract. /// /// The path of the SVG file to load. Must not be null, empty, or whitespace. @@ -511,7 +519,7 @@ public static Surface Load( /// /// Thrown when the file does not contain a well-formed, supported SVG document. /// - public static Surface Load( + public static Surface LoadWithFontFaces( string path, int width, int height, @@ -524,7 +532,7 @@ public static Surface Load( } using var stream = new FileStream(path, FileMode.Open, FileAccess.Read); - return Load(stream, width, height, fonts); + return LoadWithFontFaces(stream, width, height, fonts); } /// @@ -561,20 +569,17 @@ public static Surface Load( /// /// /// A thin wrapper delegating to - /// , + /// , /// via ToFontFaces wrapping each entry as a single normal-weight/normal-style /// - so every text element always resolves to that single /// registered font, regardless of its own font-weight/font-style, exactly as /// before this overload existed. A caller registering more than one face per family (bold/ - /// italic variants) should call the richer overload directly instead. - /// - /// Do not pass an explicit untyped literal as the 4th positional - /// argument to either this method or its richer sibling: since neither generic - /// instantiation is more specific than the - /// other, doing so is ambiguous between the two overloads (a compile-time error). Either - /// omit the argument entirely (uses this overload's default), or cast the - /// to the specific dictionary type you intend. - /// + /// italic variants) should call + /// directly instead. That richer overload is deliberately named differently (rather than + /// overloading Load itself) because both dictionary value types have identical + /// arity, so an overload sharing this name would make an explicit untyped + /// literal passed as the 4th positional argument ambiguous between + /// the two - a source-breaking change for existing callers using that pattern. /// /// /// @@ -588,7 +593,7 @@ public static Surface Load( int width, int height, IReadOnlyDictionary? fonts = null) - => Load(stream, width, height, ToFontFaces(fonts)); + => LoadWithFontFaces(stream, width, height, ToFontFaces(fonts)); /// /// Rasterizes an SVG document loaded from a file path onto a new of @@ -617,7 +622,7 @@ public static Surface Load( /// /// /// A thin wrapper delegating to - /// , + /// , /// via ToFontFaces - see the overload's remarks. /// public static Surface Load( @@ -625,7 +630,7 @@ public static Surface Load( int width, int height, IReadOnlyDictionary? fonts = null) - => Load(path, width, height, ToFontFaces(fonts)); + => LoadWithFontFaces(path, width, height, ToFontFaces(fonts)); /// /// Wraps a legacy single-font-per-family dictionary as the richer per-family @@ -1209,8 +1214,8 @@ private sealed record RenderState( /// The whole-document id-to-element index built once up front. /// /// The caller-supplied font-family-to-face-list dictionary, or if - /// none was supplied. Populated either directly by the richer - /// + /// none was supplied. Populated either directly by the + /// /// overload, or via when the legacy single-font-per-family /// overload is used. /// @@ -3315,12 +3320,17 @@ private static void RenderShapeWithFilter(XElement element, Path localPath, Rend /// work exceeds / /// (see ) is tolerantly skipped identically - /// checked before SourceGraphic is even allocated, so no per-primitive work (buffer - /// allocation, blur passes, compositing) ever begins for a rejected chain. + /// allocation, blur passes, compositing) ever begins for a rejected chain. A feMerge + /// primitive is charged as its own feMergeNode child count (at least 1) rather than a + /// flat 1, because performs one full-surface + /// per feMergeNode - a flat charge would + /// let a pathologically large feMerge bypass this budget while still doing + /// O(node-count × region-area) work. /// /// private static void RenderFilteredShape(XElement filterElement, Path localPath, RenderState state, Matrix3x2 transform, RenderContext context) { - var region = ComputeFilterRegionPixelBounds(filterElement, localPath, transform); + var region = ComputeFilterRegionPixelBounds(filterElement, localPath, state, transform); if (region == null) { RenderShape(localPath, state, transform, context); @@ -3329,7 +3339,7 @@ private static void RenderFilteredShape(XElement filterElement, Path localPath, var (pixelX, pixelY, pixelWidth, pixelHeight) = region.Value; - var primitiveCount = filterElement.Elements().Count(); + var primitiveCount = CountFilterPrimitiveWorkUnits(filterElement); if (!IsFilterPrimitiveWorkWithinBudget(primitiveCount, pixelWidth, pixelHeight)) { RenderShape(localPath, state, transform, context); @@ -3355,18 +3365,28 @@ private static void RenderFilteredShape(XElement filterElement, Path localPath, /// /// The resolved filter element. /// The referencing shape's local-space outline. + /// The cascaded render state, used to expand the degeneracy check for a stroke. /// The accumulated transform from local space into pixel space. /// /// The pixel-space region as (X, Y, Width, Height), or if - /// 's local bounds are empty/degenerate, the region resolves to - /// a non-positive size, its pixel-space transform is non-finite or exceeds - /// , or its rounded pixel size exceeds + /// 's stroke-inflated local bounds are empty/degenerate, the + /// region resolves to a non-positive size, its pixel-space transform is non-finite or + /// exceeds , or its rounded pixel size exceeds /// on either axis. /// + /// + /// The degeneracy/zero-extent check below is performed against the actually-painted bounds + /// ('s fill/centerline bounds, inflated by half the effective + /// stroke width on each side when has a stroke - see + /// ) rather than the bare centerline bounds: a + /// horizontal or vertical line has zero height or width in centerline space, so + /// using the bare bounds would incorrectly treat a valid filter on a stroked axis-aligned + /// line as degenerate and silently fall back to unfiltered rendering. + /// private static (int X, int Y, int Width, int Height)? ComputeFilterRegionPixelBounds( - XElement filterElement, Path localPath, Matrix3x2 transform) + XElement filterElement, Path localPath, RenderState state, Matrix3x2 transform) { - var bounds = localPath.GetBounds(); + var bounds = ExpandBoundsForStroke(localPath.GetBounds(), state); if (bounds.IsEmpty || bounds.Width <= 0f || bounds.Height <= 0f) { return null; @@ -3415,6 +3435,47 @@ private static (int X, int Y, int Width, int Height)? ComputeFilterRegionPixelBo return (minX, minY, width, height); } + /// + /// Inflates by half of 's effective + /// stroke-width on every side when a stroke is actually painted, so bounds-derived + /// calculations reflect the shape's actually-painted extent rather than its bare fill/ + /// centerline geometry - notably, a horizontal or vertical line has zero height or + /// width in centerline space, so a check that ignored the stroke would treat a valid + /// stroked line as degenerate. + /// + /// The local-space fill/centerline bounds to inflate. + /// The cascaded render state supplying stroke/stroke-width. + /// + /// unchanged if is empty, stroke + /// resolves to none/empty, or stroke-width is not finite/positive; otherwise + /// inflated by half the stroke width on each side. + /// + private static Rect ExpandBoundsForStroke(Rect bounds, RenderState state) + { + if (bounds.IsEmpty) + { + return bounds; + } + + var trimmedStroke = state.Stroke.Trim(); + if (trimmedStroke.Length == 0 || string.Equals(trimmedStroke, "none", StringComparison.OrdinalIgnoreCase)) + { + return bounds; + } + + if (!float.IsFinite(state.StrokeWidth) || state.StrokeWidth <= 0f) + { + return bounds; + } + + var halfStrokeWidth = state.StrokeWidth / 2f; + return new Rect( + bounds.X - halfStrokeWidth, + bounds.Y - halfStrokeWidth, + bounds.Width + (2f * halfStrokeWidth), + bounds.Height + (2f * halfStrokeWidth)); + } + /// /// Reads one of a filter element's x/y/width/height /// region attributes as an objectBoundingBox fraction, tolerantly falling back to @@ -3436,7 +3497,10 @@ private static float ParseFilterRegionFraction(XElement filterElement, string at /// is actually evaluated (a cheap child-element count plus one multiply/compare), so a /// pathological chain is rejected near-instantly rather than after partially evaluating it. /// - /// The filter element's own fe* child count. + /// + /// The filter element's own primitive-equivalent work-unit count - see + /// . + /// /// The filter region's pixel width. /// The filter region's pixel height. /// if the chain may be evaluated; otherwise . @@ -3444,6 +3508,22 @@ private static bool IsFilterPrimitiveWorkWithinBudget(int primitiveCount, int wi primitiveCount <= MaxFilterPrimitivesPerFilter && (long)primitiveCount * width * height <= MaxFilterPrimitiveWorkUnits; + /// + /// Counts 's upfront primitive-equivalent work-unit charge + /// for : each direct child counts as 1, except + /// a feMerge child, which counts as its own feMergeNode child count (at least + /// 1) instead - because performs one full-surface + /// per feMergeNode, so a flat charge of 1 + /// would let a feMerge with a huge number of merge nodes bypass the budget while still + /// doing O(node-count × region-area) work. + /// + /// The resolved filter element. + /// The total primitive-equivalent work-unit count. + private static int CountFilterPrimitiveWorkUnits(XElement filterElement) => + filterElement.Elements().Sum(primitive => primitive.Name.LocalName == "feMerge" + ? Math.Max(1, primitive.Elements().Count(node => node.Name.LocalName == "feMergeNode")) + : 1); + /// /// Evaluates 's fe* primitive children, in document /// order, against . @@ -4842,6 +4922,10 @@ private readonly struct MarkerVertex(Vector2 position, Vector2? incomingTangent, /// Returns a copy of this vertex with replaced. /// The new outgoing tangent. public MarkerVertex WithOutgoingTangent(Vector2? outgoingTangent) => new(Position, IncomingTangent, outgoingTangent); + + /// Returns a copy of this vertex with replaced. + /// The new incoming tangent. + public MarkerVertex WithIncomingTangent(Vector2? incomingTangent) => new(Position, incomingTangent, OutgoingTangent); } /// @@ -4880,23 +4964,31 @@ private enum MarkerVertexRole /// (matches at least one common browser's behavior; the SVG specification's own wording /// on this point is not unambiguous across implementations) rather than a stricter /// per-subpath interpretation. A Close command contributes no new vertex (it always - /// returns to the subpath's own , already recorded) and, as a - /// further documented simplification, no orientation tangent of its own either. + /// returns to the subpath's own , already recorded), but its + /// implicit closing segment's tangent - computed identically to an equivalent + /// back to - is still + /// folded into both the subpath's last vertex's outgoing tangent and its first vertex's + /// incoming tangent, so a closed subpath's orient="auto" orientation at either end + /// reflects the closing edge too, not only the open-path edge that happens to meet it. /// private static List BuildMarkerVertices(Path localPath) { var vertices = new List(); foreach (var subpath in localPath.Subpaths) { + var subpathStartIndex = vertices.Count; vertices.Add(new MarkerVertex(subpath.Start, incomingTangent: null, outgoingTangent: null)); var current = subpath.Start; + var closed = false; foreach (var command in subpath.Commands) { if (command.Type == PathCommandType.Close) { // Returns to Start (already recorded above) without introducing a new vertex - // or a tangent of its own - see this method's documented simplification + // of its own - the closing segment's own tangent is folded in below, once the + // subpath's last real vertex is known + closed = true; current = subpath.Start; continue; } @@ -4912,6 +5004,19 @@ private static List BuildMarkerVertices(Path localPath) vertices.Add(new MarkerVertex(command.EndPoint, incoming, outgoingTangent: null)); current = command.EndPoint; } + + // A closed subpath with at least one real segment beyond its own Start has an + // implicit closing edge back to Start - fold its tangent into the last vertex's + // outgoing tangent and the first vertex's incoming tangent, exactly as any other + // segment's tangent is folded into its two endpoints above + var lastIndex = vertices.Count - 1; + if (closed && lastIndex != subpathStartIndex) + { + var (closingOutgoing, closingIncoming) = ComputeCommandTangents( + PathCommand.LineTo(subpath.Start), vertices[lastIndex].Position); + vertices[lastIndex] = vertices[lastIndex].WithOutgoingTangent(closingOutgoing); + vertices[subpathStartIndex] = vertices[subpathStartIndex].WithIncomingTangent(closingIncoming); + } } return vertices; @@ -5023,7 +5128,7 @@ private static float ComputeVertexAngleDegrees(MarkerVertex vertex) /// resolves to an actual marker element. /// /// The shape's local-space outline (see ). - /// The shape's own cascaded render state, supplying the three marker specs and effective stroke width. + /// The shape's own cascaded render state, supplying the three marker specs and local stroke-width. /// The shape's own accumulated transform - every marker instance is transformed through this same transform. /// The fixed per-document render context. /// The current use-reference nesting depth, propagated unchanged to each marker's content. @@ -5064,8 +5169,6 @@ private static void RenderMarkers( return; } - var effectiveStrokeWidth = EstimateEffectiveStrokeWidth(state.StrokeWidth, EstimateUniformScale(transform)); - for (var i = 0; i < vertices.Count; i++) { MarkerVertexRole role; @@ -5107,7 +5210,7 @@ private static void RenderMarkers( vertex.Position, angleDegrees, isStartVertex: role == MarkerVertexRole.Start, - effectiveStrokeWidth, + state.StrokeWidth, transform, context, useDepth, @@ -5167,9 +5270,13 @@ private static void RenderMarkers( /// resolve orient="auto-start-reverse", which reverses by 180 degrees at the start /// vertex only. /// - /// - /// The referencing shape's own effective (pixel-space) stroke width, used to scale this - /// marker instance when markerUnits is strokeWidth (the SVG default). + /// + /// The referencing shape's own stroke-width, in local (pre-) + /// user-space units - not the pixel-scaled effective width - used to scale this marker + /// instance when markerUnits is strokeWidth (the SVG default). Deliberately + /// left unscaled: , composed last below, is the only place + /// the pixel scale is applied, so composing an already-pixel-scaled width here would apply + /// that scale twice. /// /// The referencing shape's own accumulated transform. /// The fixed per-document render context. @@ -5204,7 +5311,7 @@ private static void RenderOneMarker( Vector2 vertexPosition, float vertexAngleDegrees, bool isStartVertex, - float effectiveStrokeWidth, + float localStrokeWidth, Matrix3x2 shapeTransform, RenderContext context, int useDepth, @@ -5230,7 +5337,7 @@ private static void RenderOneMarker( var isUserSpaceOnUse = string.Equals( (string?)markerElement.Attribute("markerUnits"), "userSpaceOnUse", StringComparison.OrdinalIgnoreCase); - var unitsScale = isUserSpaceOnUse ? 1f : effectiveStrokeWidth; + var unitsScale = isUserSpaceOnUse ? 1f : localStrokeWidth; if (!float.IsFinite(unitsScale) || unitsScale <= 0f || unitsScale > MaxCoordinateMagnitude) { return; @@ -5442,13 +5549,21 @@ private static void RenderText(XElement element, RenderState state, Matrix3x2 tr { SvgFontFace? best = null; var bestStyleMismatch = true; - var bestWeightDistance = int.MaxValue; + var bestWeightDistance = long.MaxValue; var bestBoldnessMismatch = true; foreach (var face in faces) { var styleMismatch = face.Style != requestedStyle; - var weightDistance = Math.Abs(face.Weight - requestedWeight); + + // Widen to long before subtracting: face.Weight and requestedWeight are caller- + // supplied int values that can each independently be as extreme as int.MinValue/ + // int.MaxValue (an out-of-range font-weight="-2147483648" is still parsed, not + // rejected), and int subtraction/Math.Abs can overflow (throwing OverflowException in + // a checked context, or silently wrapping to a wrong distance in an unchecked one) for + // such extreme pairs - long arithmetic makes the difference (and its absolute value) + // always representable. + var weightDistance = Math.Abs((long)face.Weight - requestedWeight); var boldnessMismatch = (face.Weight >= 400) != (requestedWeight >= 400); if (best == null @@ -5471,10 +5586,10 @@ private static void RenderText(XElement element, RenderState state, Matrix3x2 tr /// if the candidate strictly beats the running best. private static bool IsBetterFace( bool candidateStyleMismatch, - int candidateWeightDistance, + long candidateWeightDistance, bool candidateBoldnessMismatch, bool bestStyleMismatch, - int bestWeightDistance, + long bestWeightDistance, bool bestBoldnessMismatch) { if (candidateStyleMismatch != bestStyleMismatch) diff --git a/src/DemaConsulting.CanvasNet/Codecs/SvgFontFace.cs b/src/DemaConsulting.CanvasNet/Codecs/SvgFontFace.cs index a608936c..5fce0459 100644 --- a/src/DemaConsulting.CanvasNet/Codecs/SvgFontFace.cs +++ b/src/DemaConsulting.CanvasNet/Codecs/SvgFontFace.cs @@ -5,7 +5,7 @@ namespace DemaConsulting.CanvasNet.Codecs; /// /// Represents one registered font face - a loaded paired /// with the font-weight/font-style it was authored to represent - within a -/// single font-family's list of faces supplied to . +/// single font-family's list of faces supplied to . /// /// /// A small, immutable data carrier with no behavior beyond its record-struct value equality, diff --git a/test/DemaConsulting.CanvasNet.Tests/Codecs/SvgCodecTests.cs b/test/DemaConsulting.CanvasNet.Tests/Codecs/SvgCodecTests.cs index aa660ee9..a586e4a8 100644 --- a/test/DemaConsulting.CanvasNet.Tests/Codecs/SvgCodecTests.cs +++ b/test/DemaConsulting.CanvasNet.Tests/Codecs/SvgCodecTests.cs @@ -1939,6 +1939,47 @@ public void SvgCodec_Load_MarkerStartOrientAutoStartReverse_PointsAwayFromLine() Assert.NotEqual(new Rgba32(255, 0, 0, 255), surface[7, 5]); } + /// + /// Proves that a closed <polygon>'s implicit closing segment (back to its + /// first vertex) contributes to the orient="auto" tangent computed for the last + /// vertex's marker-end, rather than only the incoming open-path edge. + /// + [Fact] + public void SvgCodec_Load_MarkerEndOnClosedPolygon_OrientsUsingClosingEdgeTangent() + { + // Arrange: a right-triangle polygon (10,10)-(90,10)-(90,90) whose final vertex (90,90) is + // reached via a straight-down incoming edge (direction (0,1)) but whose implicit closing + // edge back to (10,10) travels up-and-left (direction (-1,-1) normalized) - averaging + // both tangents orients the marker along ~157.5 degrees, landing its content near + // (87,91); ignoring the closing edge (the pre-fix behavior) would orient it purely along + // the incoming edge's 90 degrees (straight down), landing its content near (90,93) instead + const string svg = """ + + + + + + + + + """; + + // Act + using var stream = ToStream(svg); + var surface = SvgCodec.Load(stream, 100, 100); + + // Assert: the marker is oriented along the averaged incoming/closing tangent (landing + // near (87,91)), not purely along the incoming edge as if the closing edge were ignored + // (which would land it near (90,93) instead) - a lower alpha threshold (rather than full + // opacity) tolerates this rasterizer's edge anti-aliasing on a thin, diagonally rotated + // shape, per this file's other diagonal-marker tests + var actual = surface[87, 91]; + Assert.True( + actual.R == 255 && actual.G == 0 && actual.B == 0 && actual.A > 100, + $"Expected a strongly red-tinted pixel at (87,91), got R={actual.R} G={actual.G} B={actual.B} A={actual.A}."); + Assert.Equal(0, surface[90, 93].A); + } + /// /// Proves that markerUnits="userSpaceOnUse" keeps a marker's size independent of /// the referencing shape's effective stroke width, while the default @@ -1979,6 +2020,43 @@ public void SvgCodec_Load_MarkerUnitsUserSpaceOnUseVsStrokeWidthDefault_ScalesDi Assert.Equal(new Rgba32(0, 0, 255, 255), surface[28, 5]); } + /// + /// Proves that a default markerUnits="strokeWidth" marker on a document with a + /// non-identity root viewBox fit (a uniform 2x scale from local units to pixels) + /// scales by exactly stroke-width * root-scale once, not twice - the local + /// stroke-width is applied once (for the marker's own strokeWidth-units + /// sizing) and the root scale is applied once (via the shared shapeTransform + /// composed last), rather than the root scale being folded into both. + /// + [Fact] + public void SvgCodec_Load_MarkerStrokeWidthUnitsOnScaledDocument_ScalesOnceNotTwice() + { + // Arrange: a 50x25 viewBox rendered onto a 100x50 canvas is a uniform 2x root scale; the + // line's local stroke-width is 1, so the "bar" marker's content (local x in [2,4], offset + // from refX=0) should land at pixel-offset dx*strokeWidth(1)*rootScale(2) = 2*dx from the + // transformed vertex - e.g. dx=3 lands 6 pixels away (26,25), not double-scaled dx*1*2*2 = + // 4*dx = 12 pixels away (32,25), which is what the pre-fix double-scaling bug would produce + const string svg = """ + + + + + + + + + """; + + // Act + using var stream = ToStream(svg); + var surface = SvgCodec.Load(stream, 100, 50); + + // Assert: the marker landed at the correctly-single-scaled offset (26,25), not the + // double-scaled offset (32,25) that the pre-fix bug would have produced + Assert.Equal(new Rgba32(255, 0, 0, 255), surface[26, 25]); + Assert.NotEqual(new Rgba32(255, 0, 0, 255), surface[32, 25]); + } + /// /// Proves that a marker element's own viewBox is fitted into /// markerWidth/markerHeight (a uniform "meet" scale-down), rather than the @@ -2553,6 +2631,82 @@ public void SvgCodec_Load_FilterExcessivePrimitiveCount_SkipsFilterRatherThanUnb Assert.True(stopwatch.Elapsed < TimeSpan.FromSeconds(5), $"Expected the excessive-primitive-count filter to be skipped promptly, but it took {stopwatch.Elapsed}."); } + /// + /// Regression test for a code-review finding: the upfront filter work-budget check used to + /// count only direct fe* children (a single feMerge always counted as 1), + /// even though ApplyFeMerge performs one full-surface CompositeOver per + /// feMergeNode child - so a feMerge with a pathologically large number of + /// merge nodes (5,000 here, mirroring the sibling primitive-count test) used to bypass the + /// budget entirely while still doing O(node-count × region-area) work. Proves it is + /// now charged per merge-node and tolerantly skipped just as promptly as the equivalent + /// flat primitive-count case. + /// + [Fact] + public void SvgCodec_Load_FeMergeExcessiveNodeCount_SkipsFilterRatherThanUnboundedWork() + { + // Arrange: a single feMerge with 5,000 feMergeNode children - one direct filter-primitive + // child by the old (buggy) counting, but 5,000 CompositeOver-worth of actual work + var mergeNodes = string.Concat(Enumerable.Repeat("", 5000)); + var svg = $""" + + + + + {mergeNodes} + + + + + + """; + + // Act: must complete quickly rather than performing 5,000 region-sized composite passes + var stopwatch = Stopwatch.StartNew(); + using var stream = ToStream(svg); + var surface = SvgCodec.Load(stream, 100, 100); + stopwatch.Stop(); + + // Assert: the rect rendered its own normal blue fill - the (skipped) filter had no effect + Assert.Equal(new Rgba32(0, 0, 255, 255), surface[50, 50]); + + // Assert: completed promptly, proving the feMerge was skipped rather than evaluated + Assert.True(stopwatch.Elapsed < TimeSpan.FromSeconds(5), $"Expected the excessive-feMergeNode-count filter to be skipped promptly, but it took {stopwatch.Elapsed}."); + } + + /// + /// Regression test for a code-review finding: the filter-region degeneracy guard used to + /// test the bare fill/centerline bounds (localPath.GetBounds()), which is zero- + /// height for a horizontal line - incorrectly treating a valid filter on a stroked + /// horizontal line as degenerate and silently skipping it. Proves the guard now uses the + /// stroke-inflated ("actually-painted") bounds instead, so the filter is actually evaluated. + /// + [Fact] + public void SvgCodec_Load_FilterOnStrokedHorizontalLine_UsesStrokeAwareBoundsNotDegenerate() + { + // Arrange: a horizontal line's centerline bounds have zero height; only the stroke- + // inflated bounds (centerline +/- half the 10-unit stroke width) are non-degenerate + const string svg = """ + + + + + + + + + """; + + // Act + using var stream = ToStream(svg); + var surface = SvgCodec.Load(stream, 100, 100); + + // Assert: a point just above the raw stroke's own painted extent (stroke-width 10 with + // butt caps covers y in [45,55]) but within the stroke-aware expanded filter region is + // filled with the flood color - proving the filter was actually evaluated rather than + // degenerately skipped (an unfixed, skipped filter would leave this point transparent) + Assert.Equal(new Rgba32(255, 0, 0, 255), surface[50, 44]); + } + /// /// Proves that a pathologically large feGaussianBlur stdDeviation (many /// orders of magnitude larger than the fixed MaxFilterBlurStdDeviationPixels bound) @@ -3163,7 +3317,7 @@ public void SvgCodec_Load_TextFontWeightBold_SelectsBoldFaceOverNormalFace() // Act using var stream = ToStream(svg); - var surface = SvgCodec.Load(stream, 100, 100, faces); + var surface = SvgCodec.LoadWithFontFaces(stream, 100, 100, faces); // Assert: the font-weight="bold" text selected the wide face Assert.Equal(255, surface[75, 35].A); @@ -3199,7 +3353,7 @@ public void SvgCodec_Load_TextFontStyleItalic_SelectsItalicFaceOverNormalFace() // Act using var stream = ToStream(svg); - var surface = SvgCodec.Load(stream, 100, 100, faces); + var surface = SvgCodec.LoadWithFontFaces(stream, 100, 100, faces); // Assert: filled where only the italic face's glyph reaches Assert.Equal(255, surface[75, 35].A); @@ -3232,7 +3386,7 @@ public void SvgCodec_Load_TextFontWeightNumeric_SelectsClosestRegisteredFaceByDi // Act using var stream = ToStream(svg); - var surface = SvgCodec.Load(stream, 100, 100, faces); + var surface = SvgCodec.LoadWithFontFaces(stream, 100, 100, faces); // Assert: the 900 (wide) face's exclusive region was NOT selected Assert.Equal(0, surface[75, 35].A); @@ -3240,6 +3394,49 @@ public void SvgCodec_Load_TextFontWeightNumeric_SelectsClosestRegisteredFaceByDi Assert.Equal(255, surface[35, 35].A); } + /// + /// Proves that an extreme, out-of-range font-weight value (still parsed, not + /// rejected, by ParseFontWeight) does not throw an + /// from SelectClosestFace's weight-distance computation, and still deterministically + /// selects the closer-by-magnitude registered face rather than silently wrapping to a + /// wrong distance. + /// + [Fact] + public void SvgCodec_Load_TextFontWeightExtremeValue_DoesNotOverflowAndSelectsClosestFace() + { + // Arrange: requesting int.MinValue puts both registered faces' weight distances far + // outside int range (int.MinValue - 0 and int.MinValue - 2000000000 both overflow a + // checked/unchecked int subtraction), but the 0-weight (narrow) face is unambiguously + // closer in magnitude than the 2000000000-weight (wide) face + const string svg = """ + + A + + """; + var faces = new Dictionary> + { + ["TestFont"] = + [ + new SvgFontFace(BuildTestFont(), Weight: 0), + new SvgFontFace(BuildBoldTestFont(), Weight: 2_000_000_000) + ] + }; + + // Act + using var stream = ToStream(svg); + var exception = Record.Exception(() => SvgCodec.LoadWithFontFaces(stream, 100, 100, faces)); + + // Assert: no OverflowException (or any other exception) was thrown + Assert.Null(exception); + + // Assert: the far-closer 0-weight (narrow) face was selected, not the 2000000000-weight + // (wide) face + using var stream2 = ToStream(svg); + var surface = SvgCodec.LoadWithFontFaces(stream2, 100, 100, faces); + Assert.Equal(0, surface[75, 35].A); + Assert.Equal(255, surface[35, 35].A); + } + /// /// Proves the boldness-side tie-break rule: with faces registered at 300 (narrow) and 500 /// (wide) - both equidistant (100) from a requested font-weight="400" - the 500 @@ -3266,7 +3463,7 @@ public void SvgCodec_Load_TextFontWeightTie_PrefersMatchingBoldnessSide() // Act using var stream = ToStream(svg); - var surface = SvgCodec.Load(stream, 100, 100, faces); + var surface = SvgCodec.LoadWithFontFaces(stream, 100, 100, faces); // Assert: the 500 (wide) face was selected, per the boldness-side tie-break Assert.Equal(255, surface[75, 35].A); @@ -3293,7 +3490,7 @@ public void SvgCodec_Load_TextFontStyleNoItalicRegistered_FallsBackToOnlyAvailab // Act using var stream = ToStream(svg); - var surface = SvgCodec.Load(stream, 100, 100, faces); + var surface = SvgCodec.LoadWithFontFaces(stream, 100, 100, faces); // Assert: the sole registered (normal) face still rendered Assert.Equal(255, surface[35, 35].A); @@ -3328,7 +3525,7 @@ public void SvgCodec_Load_TextFontWeightAndStyleCombined_SelectsExactMatchAmongT // Act using var stream = ToStream(svg); - var surface = SvgCodec.Load(stream, 100, 100, faces); + var surface = SvgCodec.LoadWithFontFaces(stream, 100, 100, faces); // Assert: filled where only the italic face's glyph reaches Assert.Equal(255, surface[75, 35].A); @@ -3363,7 +3560,7 @@ public void SvgCodec_Load_GroupFontWeightInheritance_AppliesToChildTextWithoutOw // Act using var stream = ToStream(svg); - var surface = SvgCodec.Load(stream, 100, 100, faces); + var surface = SvgCodec.LoadWithFontFaces(stream, 100, 100, faces); // Assert: the child text inherited font-weight="bold" and selected the wide face Assert.Equal(255, surface[75, 35].A); @@ -3395,7 +3592,7 @@ public void SvgCodec_Load_GroupFontStyleInheritance_AppliesToChildTextWithoutOwn // Act using var stream = ToStream(svg); - var surface = SvgCodec.Load(stream, 100, 100, faces); + var surface = SvgCodec.LoadWithFontFaces(stream, 100, 100, faces); // Assert: the child text inherited font-style="italic" and selected the italic face Assert.Equal(255, surface[75, 35].A); @@ -3428,6 +3625,40 @@ public void SvgCodec_Load_TextLegacySingleFontOverload_IgnoresRequestedWeightAnd Assert.Equal(255, surface[35, 35].A); } + /// + /// Regression test for a source-breaking ambiguous-overload defect: prior to the + /// + /// rename, a caller passing an explicit literal (rather than + /// omitting the argument) to SvgCodec.Load(stream, width, height, null) failed to + /// compile with "the call is ambiguous", because null matched both the legacy + /// IReadOnlyDictionary<string, TrueTypeFont>? overload and the newer, + /// since-renamed IReadOnlyDictionary<string, IReadOnlyList<SvgFontFace>>? + /// overload equally well. Now that the richer overload has its own distinct + /// + /// name, only the single-font Load overload remains a candidate, so this call is + /// unambiguous - the mere fact that this test file compiles (and this call resolves to the + /// legacy overload's own documented "no font registered" behavior) is itself the + /// regression check. + /// + [Fact] + public void SvgCodec_Load_ExplicitNullFontsLiteral_CompilesUnambiguouslyAndFallsBackToBuiltInFont() + { + // Arrange + const string svg = """ + + A + + """; + + // Act: an explicit `null` literal - this is the exact call shape that used to be rejected + // by the compiler as ambiguous before the richer overload was renamed + using var stream = ToStream(svg); + var surface = SvgCodec.Load(stream, 100, 100, null); + + // Assert: no font was registered for "TestFont", so nothing was rasterized for the glyph + Assert.Equal(0, surface[35, 35].A); + } + // ================================================================================================ // viewBox fitting ("meet, centered" / object-fit: contain) // ================================================================================================ From ad6287e85ea6b07bc8c56b3815ece827c6df5dda Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Sat, 26 Sep 2026 21:56:15 -0400 Subject: [PATCH 5/8] Fix 4 code-review findings: empty filter allocation, rounding convention, marker orient default, stale doc - HIGH: RenderFilteredShape now skips filtered rendering (falls back to unfiltered) for a with zero primitive children, before any temporary SourceGraphic surface is allocated - previously a zero work-unit charge trivially passed the primitive-work budget check even for a huge filter region, allocating a large unused surface. - MEDIUM: feOffset's dx/dy-to-pixel conversion and feComposite's per-channel output rounding now use MathF.Round(value, MidpointRounding.AwayFromZero), matching Surface.cs's established compositing convention, instead of MathF.Round's default banker's (round-to-even) rounding. - MEDIUM: ParseMarkerOrient now treats an absent/blank orient attribute as the SVG spec's fixed 0-degree default, distinct from an explicit orient="auto"; only the explicit auto/auto-start-reverse keywords opt into tangent-following behavior. - LOW: Updated the stale "ambiguous null" doc text in docs/user_guide/introduction.md now that the richer overload was renamed to LoadWithFontFaces - Load(..., null) and LoadWithFontFaces(..., null) are both unambiguous. Added/updated regression tests for all four fixes and updated the design/verification/reqstream companion docs accordingly. --- docs/design/canvas-net/codecs/svg-codec.md | 12 +- .../canvas-net/codecs/svg-codec.yaml | 20 +- docs/user_guide/introduction.md | 10 +- .../canvas-net/codecs/svg-codec.md | 24 ++- .../Codecs/SvgCodec.cs | 58 ++++-- .../Codecs/SvgCodecTests.cs | 174 +++++++++++++++++- 6 files changed, 259 insertions(+), 39 deletions(-) diff --git a/docs/design/canvas-net/codecs/svg-codec.md b/docs/design/canvas-net/codecs/svg-codec.md index 600c6cdf..d8aede9d 100644 --- a/docs/design/canvas-net/codecs/svg-codec.md +++ b/docs/design/canvas-net/codecs/svg-codec.md @@ -346,7 +346,11 @@ own start/end (matches at least one common browser's behavior, rather than a spe directly). A vertex's `orient="auto"` rotation angle is the average of its incoming and outgoing segment tangents (falling back to whichever one is present at an open subpath's own start/end), except that `orient="auto-start-reverse"` adds a further 180 degrees at the shape's very first -vertex only. Each marker instance is scaled by `markerWidth`/`markerHeight` (further fitted by the +vertex only. Per the SVG specification, an absent/blank `orient` attribute uses the fixed +0-degree default (no rotation) rather than following the vertex tangent - only the explicit +`auto`/`auto-start-reverse` keywords opt into tangent-following behavior; an unparseable explicit +value also tolerantly falls back to this same 0-degree default. Each marker instance is scaled by +`markerWidth`/`markerHeight` (further fitted by the marker's own optional `viewBox`, using the same "meet" scale-down as the top-level document's own viewBox-fitting policy, but without that policy's additional centering step - a deliberate simplification since a marker's `refX`/`refY` already provide an equivalent anchor point), then @@ -391,7 +395,11 @@ chain is additionally bounded by a fixed primitive-count cap (`MaxFilterPrimitiv skipped the same way once either is exceeded - because, unlike the per-element costs `MaxTotalRenderedElements` already bounds (which assumes O(1)/O(perimeter) cost per element, not O(region-area) cost per primitive), a single filter's own primitive-chain cost is -O(primitive count × region area), a cost dimension no pre-existing guard actually covers. When the +O(primitive count × region area), a cost dimension no pre-existing guard actually covers. A +`filter` element with zero primitive children is likewise tolerantly skipped, identically to an +out-of-budget chain and checked in the same guard, before `SourceGraphic` is allocated - because +a filter with no primitives to evaluate can never change the rendered output, regardless of how +large its filter region is. When the region is accepted and the chain's work stays within budget, the element's own content is rendered a second time - independently of its main render onto `context.Surface` - into a fresh, region-sized temporary `Surface` (this buffer is the filter's diff --git a/docs/reqstream/canvas-net/codecs/svg-codec.yaml b/docs/reqstream/canvas-net/codecs/svg-codec.yaml index 31424692..bc0076f7 100644 --- a/docs/reqstream/canvas-net/codecs/svg-codec.yaml +++ b/docs/reqstream/canvas-net/codecs/svg-codec.yaml @@ -160,8 +160,9 @@ sections: polyline, polygon, or path element's marker-start/marker-mid/marker-end presentation attributes at that shape's first/interior/last vertex respectively, honoring markerWidth/markerHeight, refX/refY, markerUnits - (strokeWidth or userSpaceOnUse), orient (auto, auto-start-reverse, or a fixed - angle in degrees), and an optional viewBox, while never rendering a marker for + (strokeWidth or userSpaceOnUse), orient (auto, auto-start-reverse, a fixed + angle in degrees, or the fixed 0-degree default when omitted/blank), and an + optional viewBox, while never rendering a marker for rect/circle/ellipse and never inheriting the referencing shape's own fill/stroke into the marker's own content. justification: | @@ -177,6 +178,7 @@ sections: - SvgCodec_Load_MarkerOrientAutoOnHorizontalLine_OrientsAlongPositiveX - SvgCodec_Load_MarkerOrientAutoOnVerticalLine_OrientsAlongPositiveY - SvgCodec_Load_MarkerOrientAutoOnDiagonalLine_OrientsAlong45Degrees + - SvgCodec_Load_MarkerOrientOmittedOnDiagonalLine_UsesFixedZeroDegreeDefaultNotTangent - SvgCodec_Load_MarkerStartOrientAuto_PointsIntoLine - SvgCodec_Load_MarkerStartOrientAutoStartReverse_PointsAwayFromLine - SvgCodec_Load_MarkerUnitsUserSpaceOnUseVsStrokeWidthDefault_ScalesDifferently @@ -540,11 +542,13 @@ sections: - SvgCodec_Load_FilterExplicitRegion_UsesDeclaredXYWidthHeight - SvgCodec_Load_FilterUserSpaceOnUse_FallsBackToObjectBoundingBoxDefault - SvgCodec_Load_FeOffsetFilter_ShiftsSourceGraphicByDxDy + - SvgCodec_Load_FeOffsetFilterWithHalfPixelDx_RoundsAwayFromZeroNotToEven - SvgCodec_Load_LabelHaloFixture_RendersWhiteHaloBehindLineMidpointLabel - SvgCodec_Load_InkscapeFiltersFixture_ToleratesFiltersAndRendersFlowerContent - SvgCodec_Load_FeCompositeOperatorIn_KeepsForegroundWeightedByBackgroundAlpha - SvgCodec_Load_FeCompositeOperatorAtop_BlendsBothInputsWeightedByBothAlphas - SvgCodec_Load_FeCompositeOperatorXor_KeepsEachInputWhereTheOtherHasNoCoverage + - SvgCodec_Load_FeCompositeOperatorXorWithHalfChannelValue_RoundsAwayFromZeroNotToEven - SvgCodec_Load_FilterOnStrokedHorizontalLine_UsesStrokeAwareBoundsNotDegenerate - id: CanvasNet-Codecs-SvgCodec-FilterResourceSafety @@ -552,10 +556,13 @@ sections: The SvgCodec class shall tolerate a filter="url(#id)" reference to a nonexistent id as a silent no-op, and shall bound filter-related resource use by tolerantly skipping a filter whose region would require a temporary surface - exceeding Surface.MaxDimension and by clamping feGaussianBlur's stdDeviation to - a fixed maximum, so that neither a pathological filter region nor a - pathological blur radius can cause unbounded allocation or unbounded per-pixel - work; and shall bound the total primitive-count/region-area work a single + exceeding Surface.MaxDimension (including a filter with zero primitive children, + which can never change the rendered output and is skipped before any temporary + surface is allocated regardless of region size) and by clamping + feGaussianBlur's stdDeviation to a fixed maximum, so that neither a pathological + filter region nor a pathological blur radius can cause unbounded allocation or + unbounded per-pixel work; and shall bound the total primitive-count/region-area + work a single filter's own primitive chain may perform, tolerantly skipping the filter once exceeded. justification: | @@ -578,6 +585,7 @@ sections: tests: - SvgCodec_Load_FilterDanglingReference_RendersElementNormally - SvgCodec_Load_FilterPathologicallyLargeRegion_SkipsFilterRatherThanUnboundedAllocation + - SvgCodec_Load_FilterWithZeroPrimitivesAndHugeRegion_SkipsFilterRatherThanAllocatingSurface - SvgCodec_Load_FilterPathologicallyLargeBlurStdDeviation_ClampsRatherThanUnboundedWork - SvgCodec_Load_FilterExcessivePrimitiveCount_SkipsFilterRatherThanUnboundedWork - SvgCodec_Load_FeMergeExcessiveNodeCount_SkipsFilterRatherThanUnboundedWork diff --git a/docs/user_guide/introduction.md b/docs/user_guide/introduction.md index 5193bc49..3152e7a3 100644 --- a/docs/user_guide/introduction.md +++ b/docs/user_guide/introduction.md @@ -975,10 +975,12 @@ registered font, regardless of its own `font-weight`/`font-style` - exactly as b overload existed. If `fonts` is supplied, `text` elements are rendered using the matching `TrueTypeFont` keyed by family name; unmatched or missing fonts cause that `text` element to be silently skipped rather than throwing. Callers registering more than one face per family (bold/ -italic variants) should call the richer overload directly instead. Passing an explicit untyped -`null` literal as the 4th positional argument to either this overload or its richer sibling is -ambiguous between the two (a compile-time error); omit the argument, or cast the `null` to the -specific dictionary type intended. +italic variants) should call the richer overload directly instead. Because the richer overload is +named `LoadWithFontFaces` rather than sharing the `Load` name, there is no ambiguity between the +two: `Load(stream, width, height, null)` always resolves to this legacy single-font-per-family +overload with no fonts registered, and `LoadWithFontFaces(stream, width, height, null)` always +resolves to the richer per-face overload with no fonts registered - each call is unambiguous +regardless of which overload the caller intends. **Exceptions:** diff --git a/docs/verification/canvas-net/codecs/svg-codec.md b/docs/verification/canvas-net/codecs/svg-codec.md index 4f9a1874..ebbe4b40 100644 --- a/docs/verification/canvas-net/codecs/svg-codec.md +++ b/docs/verification/canvas-net/codecs/svg-codec.md @@ -180,6 +180,7 @@ rejected with `InvalidDataException` once the bounded recursion guard is exceede `SvgCodec_Load_MarkerOrientAutoOnHorizontalLine_OrientsAlongPositiveX`, `SvgCodec_Load_MarkerOrientAutoOnVerticalLine_OrientsAlongPositiveY`, `SvgCodec_Load_MarkerOrientAutoOnDiagonalLine_OrientsAlong45Degrees`, +`SvgCodec_Load_MarkerOrientOmittedOnDiagonalLine_UsesFixedZeroDegreeDefaultNotTangent`, `SvgCodec_Load_MarkerStartOrientAuto_PointsIntoLine`, `SvgCodec_Load_MarkerStartOrientAutoStartReverse_PointsAwayFromLine`, `SvgCodec_Load_MarkerUnitsUserSpaceOnUseVsStrokeWidthDefault_ScalesDifferently`, @@ -198,7 +199,10 @@ multi-vertex `polyline`, with each marker's own reference point (`refX`/`refY`) its vertex regardless of rotation; asserts `orient="auto"` orients a marker along a horizontal, vertical, and 45-degree diagonal segment's own direction of travel (the diagonal case uses an alpha threshold rather than exact full opacity, tolerating this rasterizer's edge anti-aliasing on -a thin, diagonally rotated shape); asserts a plain `orient="auto"` `marker-start` points into the +a thin, diagonally rotated shape); asserts an omitted `orient` attribute uses the fixed 0-degree +default rather than following the vertex tangent like an explicit `orient="auto"` (a regression +test for a defect where an absent/blank `orient` was incorrectly routed into the same tangent- +following branch as an explicit `auto`); asserts a plain `orient="auto"` `marker-start` points into the line's own body while `orient="auto-start-reverse"` reverses it by 180 degrees to point away from the line instead; asserts `markerUnits="userSpaceOnUse"` keeps a marker's size independent of the referencing shape's effective stroke width while the default `markerUnits="strokeWidth"` scales it @@ -606,11 +610,13 @@ for `filter`'s own dedicated coverage - it is no longer an out-of-scope construc `SvgCodec_Load_FilterExplicitRegion_UsesDeclaredXYWidthHeight`, `SvgCodec_Load_FilterUserSpaceOnUse_FallsBackToObjectBoundingBoxDefault`, `SvgCodec_Load_FeOffsetFilter_ShiftsSourceGraphicByDxDy`, +`SvgCodec_Load_FeOffsetFilterWithHalfPixelDx_RoundsAwayFromZeroNotToEven`, `SvgCodec_Load_LabelHaloFixture_RendersWhiteHaloBehindLineMidpointLabel`, `SvgCodec_Load_InkscapeFiltersFixture_ToleratesFiltersAndRendersFlowerContent`, `SvgCodec_Load_FeCompositeOperatorIn_KeepsForegroundWeightedByBackgroundAlpha`, `SvgCodec_Load_FeCompositeOperatorAtop_BlendsBothInputsWeightedByBothAlphas`, `SvgCodec_Load_FeCompositeOperatorXor_KeepsEachInputWhereTheOtherHasNoCoverage`, +`SvgCodec_Load_FeCompositeOperatorXorWithHalfChannelValue_RoundsAwayFromZeroNotToEven`, `SvgCodec_Load_FilterOnStrokedHorizontalLine_UsesStrokeAwareBoundsNotDegenerate` Asserts a bare `feFlood` primitive's flood color entirely replaces the referencing element's own @@ -645,7 +651,13 @@ that isolate the operator's own per-pixel formula from any shape-geometry/filter concern: `in` keeps the "in" input's own color weighted by the "in2" input's own alpha; `atop` blends both inputs' own colors weighted by (`in2`'s alpha, `1 - in`'s alpha); and `xor` keeps each input only where the other has no coverage - each asserting the exact expected pixel value the -documented formula produces. Asserts a horizontal, stroked `line` with a `feFlood` filter is +documented formula produces. `feOffset`'s own `dx`/`dy`-to-pixel conversion and +`feComposite`'s own per-channel output are each additionally verified to round +away-from-zero (matching `Surface.CompositeOverSpanCore`'s established convention), not the +`MathF.Round` default of banker's/round-to-even rounding: a `dx` that scales to exactly 0.5 +pixels rounds up to a full 1-pixel shift, and an `xor` composite chosen to produce a computed +channel value of exactly 126.5 rounds up to 127, not down to 126. Asserts a horizontal, stroked +`line` with a `feFlood` filter is evaluated (not skipped as degenerate) despite its zero-height centerline/fill bounds - a regression test for a defect where the filter-region degeneracy guard used the bare centerline/fill bounds instead of the actually-painted (stroke-expanded) bounds, incorrectly @@ -656,6 +668,7 @@ rendering. **Tests**: `SvgCodec_Load_FilterDanglingReference_RendersElementNormally`, `SvgCodec_Load_FilterPathologicallyLargeRegion_SkipsFilterRatherThanUnboundedAllocation`, +`SvgCodec_Load_FilterWithZeroPrimitivesAndHugeRegion_SkipsFilterRatherThanAllocatingSurface`, `SvgCodec_Load_FilterPathologicallyLargeBlurStdDeviation_ClampsRatherThanUnboundedWork`, `SvgCodec_Load_FilterExcessivePrimitiveCount_SkipsFilterRatherThanUnboundedWork`, `SvgCodec_Load_FeMergeExcessiveNodeCount_SkipsFilterRatherThanUnboundedWork` @@ -665,7 +678,12 @@ as if no `filter` attribute were present, matching this codec's general dangling convention (`ResolvePaint`/`ResolveMarkerElement`); asserts a filter region large enough to require an unreasonably large temporary surface (`width`/`height` of `100000%`) is tolerantly skipped entirely, rather than attempting an allocation exceeding `Surface.MaxDimension`, with the element -still rendering its own normal content; asserts an `feGaussianBlur` `stdDeviation` many orders +still rendering its own normal content; asserts a `filter` element with zero primitive children, +paired with that same pathologically large region, is likewise tolerantly skipped before any +temporary surface is allocated - a regression test for a defect where a zero primitive/work-unit +count trivially passed the upfront work-budget check, letting the code allocate a potentially +enormous `SourceGraphic` surface for a filter that, having no primitives, could never change the +rendered output; asserts an `feGaussianBlur` `stdDeviation` many orders of magnitude larger than the fixed `MaxFilterBlurStdDeviationPixels` bound is clamped rather than causing unbounded work - the box-blur implementation's own cost does not scale with the requested radius, so this completes promptly and produces a heavily diluted (rather than crashing or diff --git a/src/DemaConsulting.CanvasNet/Codecs/SvgCodec.cs b/src/DemaConsulting.CanvasNet/Codecs/SvgCodec.cs index 575a1134..ab2c2df7 100644 --- a/src/DemaConsulting.CanvasNet/Codecs/SvgCodec.cs +++ b/src/DemaConsulting.CanvasNet/Codecs/SvgCodec.cs @@ -3327,6 +3327,14 @@ private static void RenderShapeWithFilter(XElement element, Path localPath, Rend /// let a pathologically large feMerge bypass this budget while still doing /// O(node-count × region-area) work. /// + /// + /// A filter element with zero primitive children (a zero work-unit count from + /// ) is skipped identically to an out-of-budget + /// chain - checked in the same guard, before SourceGraphic is allocated - because a + /// filter with no primitives to evaluate can never change the rendered output, regardless of + /// how large its filter region is; treating it as "budget OK" would still allocate a + /// potentially enormous temporary surface just to hand it back unchanged. + /// /// private static void RenderFilteredShape(XElement filterElement, Path localPath, RenderState state, Matrix3x2 transform, RenderContext context) { @@ -3339,8 +3347,12 @@ private static void RenderFilteredShape(XElement filterElement, Path localPath, var (pixelX, pixelY, pixelWidth, pixelHeight) = region.Value; + // A filter with zero primitive children can never change the rendered output - it has + // nothing to evaluate - so it is tolerantly treated exactly like an already-degenerate/ + // out-of-budget filter (unfiltered fallback) rather than falling through to allocate a + // potentially enormous SourceGraphic surface just to hand it back unchanged var primitiveCount = CountFilterPrimitiveWorkUnits(filterElement); - if (!IsFilterPrimitiveWorkWithinBudget(primitiveCount, pixelWidth, pixelHeight)) + if (primitiveCount == 0 || !IsFilterPrimitiveWorkWithinBudget(primitiveCount, pixelWidth, pixelHeight)) { RenderShape(localPath, state, transform, context); return; @@ -3902,8 +3914,8 @@ private static Surface ApplyFeOffset(XElement element, Surface input, float scal var dx = ParsePercentOrNumber((string?)element.Attribute("dx") ?? string.Empty, 1f) ?? 0f; var dy = ParsePercentOrNumber((string?)element.Attribute("dy") ?? string.Empty, 1f) ?? 0f; - var pixelDx = (int)MathF.Round(dx * scale); - var pixelDy = (int)MathF.Round(dy * scale); + var pixelDx = (int)MathF.Round(dx * scale, MidpointRounding.AwayFromZero); + var pixelDy = (int)MathF.Round(dy * scale, MidpointRounding.AwayFromZero); var width = input.Width; var height = input.Height; @@ -4035,12 +4047,12 @@ private static Surface CompositeFeOperator(Surface foreground, Surface backgroun var premultR = (weightFg * fg.R * fgA) + (weightBg * bg.R * bgA); var premultG = (weightFg * fg.G * fgA) + (weightBg * bg.G * bgA); var premultB = (weightFg * fg.B * fgA) + (weightBg * bg.B * bgA); - outR = (byte)Math.Clamp(MathF.Round(premultR / outA), 0f, 255f); - outG = (byte)Math.Clamp(MathF.Round(premultG / outA), 0f, 255f); - outB = (byte)Math.Clamp(MathF.Round(premultB / outA), 0f, 255f); + outR = (byte)Math.Clamp(MathF.Round(premultR / outA, MidpointRounding.AwayFromZero), 0f, 255f); + outG = (byte)Math.Clamp(MathF.Round(premultG / outA, MidpointRounding.AwayFromZero), 0f, 255f); + outB = (byte)Math.Clamp(MathF.Round(premultB / outA, MidpointRounding.AwayFromZero), 0f, 255f); } - outRow[x] = new Rgba32(outR, outG, outB, (byte)Math.Clamp(MathF.Round(outA * 255f), 0f, 255f)); + outRow[x] = new Rgba32(outR, outG, outB, (byte)Math.Clamp(MathF.Round(outA * 255f, MidpointRounding.AwayFromZero), 0f, 255f)); } } @@ -5387,28 +5399,40 @@ private static void RenderOneMarker( /// The vertex's own computed tangent angle (see ). /// Whether this vertex is the referencing shape's very first vertex. /// - /// when is absent or - /// "auto" (the common case); plus 180 degrees - /// when is "auto-start-reverse" and + /// when is exactly the keyword + /// "auto"; plus 180 degrees when + /// is "auto-start-reverse" and /// is (unchanged at any other vertex); otherwise an attempted fixed - /// degrees value, tolerantly falling back to 0 if is not a - /// valid/finite number - a cosmetic-only concern parallel to this class's existing tolerant - /// handling of an invalid stroke-miterlimit"/stroke-dasharray, not a - /// document-abort concern. + /// degrees value, tolerantly falling back to 0 if is absent, + /// blank, or not a valid/finite number. Per the SVG specification, an absent/blank + /// orient uses this fixed 0-degree default rather than following the vertex tangent - + /// only the explicit auto/auto-start-reverse keywords opt into tangent-following + /// behavior. The fallback-to-0 parse failure path is a cosmetic-only concern parallel + /// to this class's existing tolerant handling of an invalid + /// stroke-miterlimit/stroke-dasharray, not a document-abort concern. /// private static float ParseMarkerOrient(string? raw, float autoAngleDegrees, bool isStartVertex) { - if (string.IsNullOrWhiteSpace(raw) || string.Equals(raw.Trim(), "auto", StringComparison.OrdinalIgnoreCase)) + // An absent/blank orient is NOT the same as an explicit "auto": the SVG spec's fixed + // 0-degree default only falls through to the parse-failure branch below, which returns 0 - + // it must never be routed into the tangent-following "auto" branch + if (string.IsNullOrWhiteSpace(raw)) + { + return 0f; + } + + var trimmed = raw.Trim(); + if (string.Equals(trimmed, "auto", StringComparison.OrdinalIgnoreCase)) { return autoAngleDegrees; } - if (string.Equals(raw.Trim(), "auto-start-reverse", StringComparison.OrdinalIgnoreCase)) + if (string.Equals(trimmed, "auto-start-reverse", StringComparison.OrdinalIgnoreCase)) { return isStartVertex ? autoAngleDegrees + 180f : autoAngleDegrees; } - return float.TryParse(raw.Trim(), NumberStyles.Float, CultureInfo.InvariantCulture, out var fixedAngle) && float.IsFinite(fixedAngle) + return float.TryParse(trimmed, NumberStyles.Float, CultureInfo.InvariantCulture, out var fixedAngle) && float.IsFinite(fixedAngle) ? fixedAngle : 0f; } diff --git a/test/DemaConsulting.CanvasNet.Tests/Codecs/SvgCodecTests.cs b/test/DemaConsulting.CanvasNet.Tests/Codecs/SvgCodecTests.cs index a586e4a8..2681084e 100644 --- a/test/DemaConsulting.CanvasNet.Tests/Codecs/SvgCodecTests.cs +++ b/test/DemaConsulting.CanvasNet.Tests/Codecs/SvgCodecTests.cs @@ -1708,9 +1708,11 @@ public void SvgCodec_Load_DeeplyNestedGroups_ThrowsInvalidDataException() /// /// Proves that a marker-end reference renders its marker element's content - /// at the shape's final vertex, oriented along the segment's own direction - /// (orient="auto", the default) and sized in user-space units - /// (markerUnits="userSpaceOnUse"). + /// at the shape's final vertex, sized in user-space units (markerUnits="userSpaceOnUse"). + /// No orient attribute is set, so per the fixed 0-degree default (see + /// ) + /// the marker is unrotated - which happens to coincide with the horizontal segment's own + /// 0-degree tangent, so this test alone cannot distinguish the two behaviors. /// [Fact] public void SvgCodec_Load_MarkerEndOnLine_RendersArrowheadPastLineEnd() @@ -1794,7 +1796,7 @@ public void SvgCodec_Load_MarkerOrientAutoOnHorizontalLine_OrientsAlongPositiveX const string svg = """ - + @@ -1824,7 +1826,7 @@ public void SvgCodec_Load_MarkerOrientAutoOnVerticalLine_OrientsAlongPositiveY() const string svg = """ - + @@ -1853,7 +1855,7 @@ public void SvgCodec_Load_MarkerOrientAutoOnDiagonalLine_OrientsAlong45Degrees() const string svg = """ - + @@ -1875,6 +1877,43 @@ public void SvgCodec_Load_MarkerOrientAutoOnDiagonalLine_OrientsAlong45Degrees() $"Expected a strongly red-tinted pixel at (9,9), got R={actual.R} G={actual.G} B={actual.B} A={actual.A}."); } + /// + /// Regression test: proves that an omitted orient attribute uses the SVG + /// specification's fixed 0-degree default rather than following the vertex tangent like an + /// explicit orient="auto" - a defect where an absent/blank orient was + /// incorrectly routed into the same tangent-following branch as an explicit auto. + /// + [Fact] + public void SvgCodec_Load_MarkerOrientOmittedOnDiagonalLine_UsesFixedZeroDegreeDefaultNotTangent() + { + // Arrange: a 45-degree diagonal line ending at (7,6.5) with no orient attribute set on its + // marker - if the (pre-fix, buggy) tangent-following "auto" behavior applied, the bar would + // rotate 45 degrees and land far from the vertex (roughly (9.1,8.6)); the correct fixed + // 0-degree default instead leaves the bar unrotated, landing at exactly the same world + // position (9,6) as the purely-horizontal-line case (see + // SvgCodec_Load_MarkerOrientAutoOnHorizontalLine_OrientsAlongPositiveX), since a fixed + // 0-degree rotation is independent of the segment's own direction of travel + const string svg = """ + + + + + + + + + """; + + // Act + using var stream = ToStream(svg); + var surface = SvgCodec.Load(stream, 12, 12); + + // Assert: the bar is unrotated, landing at (9,6) exactly as the horizontal-line case does + // - not rotated 45 degrees along the diagonal tangent, which would leave this pixel + // transparent instead + Assert.Equal(new Rgba32(255, 0, 0, 255), surface[9, 6]); + } + /// /// Proves that a plain orient="auto" marker-start is oriented the same way /// as the outgoing segment's own direction (pointing into the line), contrasted by @@ -2499,6 +2538,47 @@ public void SvgCodec_Load_FeCompositeOperatorXor_KeepsEachInputWhereTheOtherHasN Assert.Equal(new Rgba32(191, 0, 64, 128), surface[5, 5]); } + /// + /// Regression test for a code-review finding: CompositeFeOperator's own per-channel + /// rounding used 's default banker's (round-to-even) + /// rounding, which differs from the rest of this codebase's compositing pipeline (see + /// Surface.CompositeOverSpanCore) - it consistently uses + /// . This xor combination of a near- + /// transparent black flood (flood-opacity exactly 2/255) over a 40%-opaque + /// gray flood produces a computed channel value of exactly 126.5 - away-from-zero + /// rounds this up to 127, while round-to-even rounds it down to 126 (the + /// nearest even integer). Proves the away-from-zero convention is now used. + /// + [Fact] + public void SvgCodec_Load_FeCompositeOperatorXorWithHalfChannelValue_RoundsAwayFromZeroNotToEven() + { + // Arrange: a black flood at alpha exactly 2/255 composited "xor" over a 40%-opaque gray + // flood - chosen (see this test's own remarks) so every output channel computes to + // exactly 126.5, isolating the rounding-mode difference deterministically + const string svg = """ + + + + + + + + + + + """; + + // Act + using var stream = ToStream(svg); + var surface = SvgCodec.Load(stream, 10, 10); + + // Assert: every channel rounded up to 127 (away-from-zero), not down to 126 (round-to-even) + var actual = surface[5, 5]; + Assert.Equal(127, actual.R); + Assert.Equal(127, actual.G); + Assert.Equal(127, actual.B); + } + /// /// Proves that a filter="url(#id)" reference which does not resolve to any element /// (a dangling reference) renders the element normally, exactly as if no filter @@ -2588,7 +2668,48 @@ public void SvgCodec_Load_FilterPathologicallyLargeRegion_SkipsFilterRatherThanU } /// - /// Proves that a filter with a pathologically large number of fe* primitive + /// Regression test for a code-review finding: a filter element with zero primitive + /// children has a zero primitive-count/work-unit charge, so it used to trivially pass the + /// upfront work-budget check even when paired with a pathologically large filter region + /// (here 100000% of the element's own bounding box, matching + /// 's + /// region) - the budget-OK verdict then let the code allocate a huge temporary + /// SourceGraphic surface for a filter that, having no primitives, could not possibly + /// change the rendered output. Proves the empty filter is now tolerantly skipped before any + /// surface is allocated, completing promptly and leaving the element rendered normally. + /// + [Fact] + public void SvgCodec_Load_FilterWithZeroPrimitivesAndHugeRegion_SkipsFilterRatherThanAllocatingSurface() + { + // Arrange: a filter element with no fe* primitive children at all, paired with the same + // pathologically large region used by the sibling huge-region test + const string svg = """ + + + + + + + + """; + + // Act: must complete promptly, without attempting to allocate a huge SourceGraphic + // surface for a filter that has no primitives to evaluate + var stopwatch = Stopwatch.StartNew(); + using var stream = ToStream(svg); + var surface = SvgCodec.Load(stream, 100, 100); + stopwatch.Stop(); + + // Assert: the rect rendered its own normal blue fill - the (skipped) empty filter had no + // effect, the same tolerant per-element fallback used for every other filter resource bound + Assert.Equal(new Rgba32(0, 0, 255, 255), surface[50, 50]); + + // Assert: completed promptly, proving no huge temporary surface was ever allocated + Assert.True(stopwatch.Elapsed < TimeSpan.FromSeconds(5), $"Expected the zero-primitive huge-region filter to be skipped promptly, but it took {stopwatch.Elapsed}."); + } + + /// + /// Proves that a filter with a pathologically large number of fe* primitive /// children (5,000 chained feGaussianBlur primitives, mirroring a reported repro /// that took ~26 seconds to load prior to the MaxFilterPrimitivesPerFilter/ /// MaxFilterPrimitiveWorkUnits bounds) is tolerantly skipped entirely rather than @@ -2875,6 +2996,45 @@ public void SvgCodec_Load_FeOffsetFilter_ShiftsSourceGraphicByDxDy() Assert.Equal(0, surface[42, 45].A); } + /// + /// Regression test for a code-review finding: feOffset's own dx/dy- + /// to-pixel conversion used 's default banker's + /// (round-to-even) rounding, which differs from the rest of this codebase's compositing + /// pipeline (see Surface.CompositeOverSpanCore) - it consistently uses + /// . A dx that scales to exactly + /// 0.5 pixels is the smallest case that distinguishes the two: away-from-zero rounds + /// it up to a 1-pixel shift, while round-to-even rounds it down to 0 (since 0 is the + /// nearest even integer). Proves the away-from-zero convention is now used. + /// + [Fact] + public void SvgCodec_Load_FeOffsetFilterWithHalfPixelDx_RoundsAwayFromZeroNotToEven() + { + // Arrange: a 1:1 user-space-to-pixel scale (100x100 viewBox onto a 100x100 canvas), so + // dx='0.5' scales to exactly 0.5 pixels - round-to-even would round this down to 0 (no + // shift), while away-from-zero rounds it up to a full 1-pixel shift + const string svg = """ + + + + + + + + + """; + + // Act + using var stream = ToStream(svg); + var surface = SvgCodec.Load(stream, 100, 100); + + // Assert: the rect's own original left column (x=40) is no longer covered - it shifted + // right by 1 whole pixel rather than staying at 0 shift + Assert.Equal(0, surface[40, 45].A); + + // Assert: the shifted content now starts at x=41, one pixel to the right + Assert.Equal(new Rgba32(0, 0, 255, 255), surface[41, 45]); + } + // ================================================================================================ // Total geometry-parsing work budget (path data / point lists / text characters) // ================================================================================================ From 4047385b1664939498c0b4a1c2a5dc4072cc9399 Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Sat, 26 Sep 2026 22:17:32 -0400 Subject: [PATCH 6/8] fix(svg): apply opacity after filter compositing; suppress filters in marker content - RenderFilteredShape now renders SourceGraphic with Opacity forced to 1.0, then applies the element's own cascaded opacity exactly once as a coverage multiplier when compositing the filtered result onto the canvas, instead of baking opacity into the pre-filter source paint (per SVG opacity-applies-to-filtered-result semantics). - RenderElement now threads a suppressFilter flag (markerDepth > 0) down to RenderShapeWithFilter/RenderText so a filter attribute on marker content is never resolved/evaluated, matching this codec's documented 'filters on marker content have no effect' scope decision. - Added regression tests SvgCodec_Load_OpacityWithFeFloodFilter_AppliesOpacityToFilteredResultNotSource and SvgCodec_Load_MarkerContentWithFilterAttribute_FilterHasNoEffect. - Updated design/verification/reqstream docs for svg-codec accordingly. --- docs/design/canvas-net/codecs/svg-codec.md | 12 ++- .../canvas-net/codecs/svg-codec.yaml | 2 + .../canvas-net/codecs/svg-codec.md | 22 +++++- .../Codecs/SvgCodec.cs | 77 +++++++++++++++---- .../Codecs/SvgCodecTests.cs | 73 ++++++++++++++++++ 5 files changed, 164 insertions(+), 22 deletions(-) diff --git a/docs/design/canvas-net/codecs/svg-codec.md b/docs/design/canvas-net/codecs/svg-codec.md index d8aede9d..2ea85402 100644 --- a/docs/design/canvas-net/codecs/svg-codec.md +++ b/docs/design/canvas-net/codecs/svg-codec.md @@ -302,7 +302,11 @@ gradient stop) it resolves to at the leaf, rather than modeling isolated group c documented simplification: it produces visually identical results for the common case of non-overlapping shapes within a semi-transparent group, but does not reproduce the subtly different result SVG's isolated-group compositing model would produce for overlapping shapes -within the same semi-transparent group. +within the same semi-transparent group. A filtered element (see **Filters** below) is the one +exception to this per-pixel fold: because `opacity` applies to the filtered result as a whole, +not to the pre-filter source paint, its `SourceGraphic` is instead rendered fully opaque, and the +element's own `opacity` is applied exactly once, afterward, as a uniform coverage multiplier when +the filtered result is composited onto the canvas. ### Gradient, Use, and Text Support and Limits @@ -427,7 +431,11 @@ registered under its own `result` name so later primitives can still resolve it final primitive's own output buffer is composited onto `context.Surface` at the region's own pixel position via `Surface.CompositeOverSpan`, clipped to the canvas's own bounds - reusing the same offset-aware compositing primitive used elsewhere in this codec, rather than inventing new -canvas-writing logic for filters. +canvas-writing logic for filters. The element's own cascaded `opacity` (forced to `1.0` while +`SourceGraphic` itself is rendered, so the filter chain always evaluates against a fully-opaque +source) is applied at this same final compositing step, as a uniform per-pixel coverage +multiplier passed to `CompositeOverSpan` - per SVG semantics, `opacity` applies to the filtered +result as a whole, exactly once, not to the pre-filter source paint. #### Element/Group Nesting and Total-Element Bounds diff --git a/docs/reqstream/canvas-net/codecs/svg-codec.yaml b/docs/reqstream/canvas-net/codecs/svg-codec.yaml index bc0076f7..c70aae60 100644 --- a/docs/reqstream/canvas-net/codecs/svg-codec.yaml +++ b/docs/reqstream/canvas-net/codecs/svg-codec.yaml @@ -189,6 +189,7 @@ sections: - SvgCodec_Load_ArrowMarkersFixture_RendersArrowheadPastLineEnd - SvgCodec_Load_MarkerEndOnClosedPolygon_OrientsUsingClosingEdgeTangent - SvgCodec_Load_MarkerStrokeWidthUnitsOnScaledDocument_ScalesOnceNotTwice + - SvgCodec_Load_MarkerContentWithFilterAttribute_FilterHasNoEffect - id: CanvasNet-Codecs-SvgCodec-MarkerReferenceCycle title: >- @@ -550,6 +551,7 @@ sections: - SvgCodec_Load_FeCompositeOperatorXor_KeepsEachInputWhereTheOtherHasNoCoverage - SvgCodec_Load_FeCompositeOperatorXorWithHalfChannelValue_RoundsAwayFromZeroNotToEven - SvgCodec_Load_FilterOnStrokedHorizontalLine_UsesStrokeAwareBoundsNotDegenerate + - SvgCodec_Load_OpacityWithFeFloodFilter_AppliesOpacityToFilteredResultNotSource - id: CanvasNet-Codecs-SvgCodec-FilterResourceSafety title: >- diff --git a/docs/verification/canvas-net/codecs/svg-codec.md b/docs/verification/canvas-net/codecs/svg-codec.md index ebbe4b40..8b199820 100644 --- a/docs/verification/canvas-net/codecs/svg-codec.md +++ b/docs/verification/canvas-net/codecs/svg-codec.md @@ -190,7 +190,8 @@ rejected with `InvalidDataException` once the bounded recursion guard is exceede `SvgCodec_Load_MarkerContent_DoesNotInheritReferencingShapeFillOrStroke`, `SvgCodec_Load_ArrowMarkersFixture_RendersArrowheadPastLineEnd`, `SvgCodec_Load_MarkerEndOnClosedPolygon_OrientsUsingClosingEdgeTangent`, -`SvgCodec_Load_MarkerStrokeWidthUnitsOnScaledDocument_ScalesOnceNotTwice` +`SvgCodec_Load_MarkerStrokeWidthUnitsOnScaledDocument_ScalesOnceNotTwice`, +`SvgCodec_Load_MarkerContentWithFilterAttribute_FilterHasNoEffect` Asserts a `marker-end` reference renders its `marker` element's content past a `line`'s own end point, sized in user-space units; asserts `marker-start`/`marker-mid`/`marker-end` each @@ -224,7 +225,13 @@ marker on a document with a non-identity root `viewBox` fit (a uniform 2x scale to pixels) scales by exactly `stroke-width * root-scale` once, not twice (a regression test for a defect where the already-pixel-scaled effective stroke width was passed into the marker's own `strokeWidth`-units sizing, and then the root scale was re-applied a second time via the shared -`shapeTransform`, making the marker four times too large instead of two times). +`shapeTransform`, making the marker four times too large instead of two times). Asserts a shape +inside a `marker` element's own content with a `filter="url(#id)"` attribute referencing a real +(`feFlood`) filter renders exactly as if that `filter` attribute were absent - the marker's own +fill color, not the flood's - a regression test for a defect where marker-content rendering +re-entered the same shape/text dispatch used everywhere else, which unconditionally resolved and +evaluated a shape's own `filter` attribute, contradicting this codec's documented "filters on +marker content have no effect" scope decision. #### CanvasNet-Codecs-SvgCodec-MarkerReferenceCycle: Marker Reference Dangling and Cycle Rejection @@ -617,7 +624,8 @@ for `filter`'s own dedicated coverage - it is no longer an out-of-scope construc `SvgCodec_Load_FeCompositeOperatorAtop_BlendsBothInputsWeightedByBothAlphas`, `SvgCodec_Load_FeCompositeOperatorXor_KeepsEachInputWhereTheOtherHasNoCoverage`, `SvgCodec_Load_FeCompositeOperatorXorWithHalfChannelValue_RoundsAwayFromZeroNotToEven`, -`SvgCodec_Load_FilterOnStrokedHorizontalLine_UsesStrokeAwareBoundsNotDegenerate` +`SvgCodec_Load_FilterOnStrokedHorizontalLine_UsesStrokeAwareBoundsNotDegenerate`, +`SvgCodec_Load_OpacityWithFeFloodFilter_AppliesOpacityToFilteredResultNotSource` Asserts a bare `feFlood` primitive's flood color entirely replaces the referencing element's own content, filling the (default, bounding-box-relative) filter region including the area behind the @@ -662,7 +670,13 @@ evaluated (not skipped as degenerate) despite its zero-height centerline/fill bo regression test for a defect where the filter-region degeneracy guard used the bare centerline/fill bounds instead of the actually-painted (stroke-expanded) bounds, incorrectly treating a valid stroked line's filter as degenerate and silently falling back to unfiltered -rendering. +rendering. Asserts a `rect` with `opacity="0.5"` and a filter whose entire chain is a single, +fully-opaque `feFlood` renders the flood at approximately 50% alpha, not fully opaque - a +regression test for a defect where `opacity` was applied while painting the pre-filter +`SourceGraphic` (so the filter chain, and the final compositing step, both then operated on +already-attenuated content with no further opacity ever applied) instead of being applied exactly +once, afterward, to the filter's own final output, per SVG's "opacity applies to the filtered +result as a whole" semantics. #### CanvasNet-Codecs-SvgCodec-FilterResourceSafety: Filter Dangling Reference and Resource-Bound Tolerance diff --git a/src/DemaConsulting.CanvasNet/Codecs/SvgCodec.cs b/src/DemaConsulting.CanvasNet/Codecs/SvgCodec.cs index ab2c2df7..74b4a21d 100644 --- a/src/DemaConsulting.CanvasNet/Codecs/SvgCodec.cs +++ b/src/DemaConsulting.CanvasNet/Codecs/SvgCodec.cs @@ -1391,6 +1391,13 @@ private static void RenderElement( var state = ApplyPresentationAttributes(parentState, element); var transform = ParseTransformAttribute(element) * parentTransform; + // A marker's own content never applies its own descendants' "filter" attribute - per the + // documented "filters on marker content have no effect" scope decision (see this class's + // remarks) - so every shape/text dispatch below is told to suppress filter evaluation + // whenever this call is itself part of a marker's content subtree (markerDepth > 0, + // incremented only by RenderOneMarker, never by plain "g"/"symbol" nesting or "use") + var suppressFilter = markerDepth > 0; + // A non-finite composed transform (see this method's remarks) cannot meaningfully // position this element or any descendant - skip the whole subtree as defense-in-depth, // even though every currently-known throwing downstream path is already independently @@ -1417,21 +1424,21 @@ private static void RenderElement( break; case "rect": - RenderShapeWithFilter(element, BuildRectPath(element), state, transform, context); + RenderShapeWithFilter(element, BuildRectPath(element), state, transform, context, suppressFilter); break; case "circle": - RenderShapeWithFilter(element, BuildEllipsePath(element, isCircle: true), state, transform, context); + RenderShapeWithFilter(element, BuildEllipsePath(element, isCircle: true), state, transform, context, suppressFilter); break; case "ellipse": - RenderShapeWithFilter(element, BuildEllipsePath(element, isCircle: false), state, transform, context); + RenderShapeWithFilter(element, BuildEllipsePath(element, isCircle: false), state, transform, context, suppressFilter); break; case "line": { var linePath = BuildLinePath(element); - RenderShapeWithFilter(element, linePath, state, transform, context); + RenderShapeWithFilter(element, linePath, state, transform, context, suppressFilter); RenderMarkers(linePath, state, transform, context, useDepth, elementDepth, markerDepth, ref totalElements, workBudget); break; } @@ -1439,7 +1446,7 @@ private static void RenderElement( case "polyline": { var polylinePath = BuildPolyPath(element, closed: false, workBudget); - RenderShapeWithFilter(element, polylinePath, state, transform, context); + RenderShapeWithFilter(element, polylinePath, state, transform, context, suppressFilter); RenderMarkers(polylinePath, state, transform, context, useDepth, elementDepth, markerDepth, ref totalElements, workBudget); break; } @@ -1447,7 +1454,7 @@ private static void RenderElement( case "polygon": { var polygonPath = BuildPolyPath(element, closed: true, workBudget); - RenderShapeWithFilter(element, polygonPath, state, transform, context); + RenderShapeWithFilter(element, polygonPath, state, transform, context, suppressFilter); RenderMarkers(polygonPath, state, transform, context, useDepth, elementDepth, markerDepth, ref totalElements, workBudget); break; } @@ -1455,7 +1462,7 @@ private static void RenderElement( case "path": { var dataPath = BuildPathDataPath(element, workBudget); - RenderShapeWithFilter(element, dataPath, state, transform, context); + RenderShapeWithFilter(element, dataPath, state, transform, context, suppressFilter); RenderMarkers(dataPath, state, transform, context, useDepth, elementDepth, markerDepth, ref totalElements, workBudget); break; } @@ -1465,7 +1472,7 @@ private static void RenderElement( break; case "text": - RenderText(element, state, transform, context, workBudget); + RenderText(element, state, transform, context, workBudget, suppressFilter); break; default: @@ -3226,6 +3233,14 @@ private enum FeCompositeOperator /// The cascaded render state. /// The accumulated transform from local space into pixel space. /// The fixed per-document render context. + /// + /// when is being rendered as part of a + /// marker element's own content (propagated from RenderElement's + /// markerDepth > 0) - 's own filter attribute is + /// then never resolved/evaluated at all, regardless of what it references, per the + /// documented "filters on marker content have no effect" scope decision (see this method's + /// remarks). + /// /// /// A filter value of none/absent/not url(#id) syntax, a dangling id, /// or an id resolving to an element not literally named filter are all tolerated by @@ -3238,9 +3253,9 @@ private enum FeCompositeOperator /// filter) - see 's remarks for the full filter /// evaluation pipeline. /// - private static void RenderShapeWithFilter(XElement element, Path localPath, RenderState state, Matrix3x2 transform, RenderContext context) + private static void RenderShapeWithFilter(XElement element, Path localPath, RenderState state, Matrix3x2 transform, RenderContext context, bool suppressFilter = false) { - var filterElement = ResolveFilterElement(element, context); + var filterElement = suppressFilter ? null : ResolveFilterElement(element, context); if (filterElement == null) { RenderShape(localPath, state, transform, context); @@ -3335,6 +3350,17 @@ private static void RenderShapeWithFilter(XElement element, Path localPath, Rend /// how large its filter region is; treating it as "budget OK" would still allocate a /// potentially enormous temporary surface just to hand it back unchanged. /// + /// + /// Per SVG semantics, an element's own opacity applies to the filtered result as a + /// whole, not to the pre-filter source paint: SourceGraphic is rendered with a copy + /// of whose is forced to + /// 1.0, so the filter chain (for example a feFlood) always evaluates against + /// a fully-opaque source, and 's own (possibly cascaded) opacity is + /// applied exactly once, afterward, via 's + /// coverage argument - the same per-pixel coverage-multiplier mechanism + /// + /// already uses everywhere else in this codec. + /// /// private static void RenderFilteredShape(XElement filterElement, Path localPath, RenderState state, Matrix3x2 transform, RenderContext context) { @@ -3361,11 +3387,17 @@ private static void RenderFilteredShape(XElement filterElement, Path localPath, var sourceGraphic = new Surface(pixelWidth, pixelHeight); var localToTemp = transform * Matrix3x2.CreateTranslation(-pixelX, -pixelY); var tempContext = context with { Surface = sourceGraphic }; - RenderShape(localPath, state, localToTemp, tempContext); + + // Render SourceGraphic fully opaque (Opacity forced to 1.0) rather than with the + // element's own cascaded opacity - the filter chain must evaluate against an unmodified + // source, and the element's opacity is instead applied exactly once, afterward, when the + // filtered result is composited onto the real canvas below (see this method's remarks) + var opaqueState = state with { Opacity = 1f }; + RenderShape(localPath, opaqueState, localToTemp, tempContext); var finalSurface = EvaluateFilterChain(filterElement, sourceGraphic, transform); - CompositeFilterResultOntoCanvas(finalSurface, pixelX, pixelY, context.Surface); + CompositeFilterResultOntoCanvas(finalSurface, pixelX, pixelY, context.Surface, state.Opacity); } /// @@ -4105,10 +4137,17 @@ private static Surface ApplyFeMerge(XElement feMergeElement, int width, int heig /// The filter region's pixel-space X origin, which can be negative or beyond 's own width. /// The filter region's pixel-space Y origin, which can be negative or beyond 's own height. /// The real surface every other element also renders onto. - private static void CompositeFilterResultOntoCanvas(Surface result, int pixelX, int pixelY, Surface canvas) + /// + /// The referencing element's own cascaded , applied here as + /// a uniform per-pixel coverage multiplier - the same mechanism + /// already relies on for ordinary fill/stroke opacity - so it affects the filtered result as + /// a whole exactly once, rather than the pre-filter SourceGraphic (see + /// 's remarks). + /// + private static void CompositeFilterResultOntoCanvas(Surface result, int pixelX, int pixelY, Surface canvas, float opacity) { var coverage = new float[result.Width]; - Array.Fill(coverage, 1f); + Array.Fill(coverage, opacity); for (var row = 0; row < result.Height; row++) { @@ -5454,6 +5493,12 @@ private static float ParseMarkerOrient(string? raw, float autoAngleDegrees, bool /// with the text's character count before glyph layout begins so a pathologically long /// run's per-rune outline/kerning work never starts once the budget is exceeded. /// + /// + /// Forwarded to - when + /// is part of a marker element's own content, so its own + /// filter attribute (if any) is never resolved/evaluated, per the documented + /// "filters on marker content have no effect" scope decision. + /// /// /// Silently renders nothing - never throws - when is /// , no entry matches 's font-family, @@ -5462,7 +5507,7 @@ private static float ParseMarkerOrient(string? raw, float autoAngleDegrees, bool /// descendant text node's content is concatenated and laid out as one flat run, a /// documented simplification. /// - private static void RenderText(XElement element, RenderState state, Matrix3x2 transform, RenderContext context, GeometryWorkBudget workBudget) + private static void RenderText(XElement element, RenderState state, Matrix3x2 transform, RenderContext context, GeometryWorkBudget workBudget, bool suppressFilter = false) { if (context.Fonts == null) { @@ -5487,7 +5532,7 @@ private static void RenderText(XElement element, RenderState state, Matrix3x2 tr var origin = new Vector2(GetFloatAttribute(element, "x"), GetFloatAttribute(element, "y")); var glyphRunPath = BuildGlyphRunPath(text, font, state, origin); - RenderShapeWithFilter(element, glyphRunPath, state, transform, context); + RenderShapeWithFilter(element, glyphRunPath, state, transform, context, suppressFilter); } /// diff --git a/test/DemaConsulting.CanvasNet.Tests/Codecs/SvgCodecTests.cs b/test/DemaConsulting.CanvasNet.Tests/Codecs/SvgCodecTests.cs index 2681084e..79161492 100644 --- a/test/DemaConsulting.CanvasNet.Tests/Codecs/SvgCodecTests.cs +++ b/test/DemaConsulting.CanvasNet.Tests/Codecs/SvgCodecTests.cs @@ -2326,6 +2326,41 @@ public void SvgCodec_Load_MarkerContent_DoesNotInheritReferencingShapeFillOrStro Assert.Equal(new Rgba32(0, 0, 0, 255), surface[7, 5]); } + /// + /// Regression test for a defect where a filter attribute on a marker's own content + /// was still resolved and evaluated, even though this codec's documented scope states that + /// filters on marker content have no effect. Asserts a rect inside a marker + /// with a filter="url(#f)" referencing a real feFlood filter renders exactly + /// as if it had no filter attribute at all - the marker's own fill, not the flood + /// color, since the filter must never be evaluated for marker content. + /// + [Fact] + public void SvgCodec_Load_MarkerContentWithFilterAttribute_FilterHasNoEffect() + { + // Arrange: the marker's own rect has a filter referencing a feFlood that would fill its + // region with red if evaluated; the marker's rect itself is "lime" + const string svg = """ + + + + + + + + + + + + """; + + // Act + using var stream = ToStream(svg); + var surface = SvgCodec.Load(stream, 10, 10); + + // Assert: the marker's own "lime" fill rendered - the feFlood filter was never applied + Assert.Equal(new Rgba32(0, 255, 0, 255), surface[7, 5]); + } + // ================================================================================================ // element // ================================================================================================ @@ -2364,6 +2399,44 @@ public void SvgCodec_Load_FeFloodFilter_RendersSolidColorBehindElement() Assert.Equal(new Rgba32(255, 0, 0, 255), surface[50, 50]); } + /// + /// Regression test for a defect where an element's own opacity was applied while + /// painting the pre-filter SourceGraphic instead of the final filtered result: per + /// SVG semantics, opacity applies to the filter's whole output, exactly once, so a + /// rect with opacity="0.5" and a filter containing only a fully-opaque + /// feFlood must render the flood at ~50% alpha, not fully opaque. + /// + [Fact] + public void SvgCodec_Load_OpacityWithFeFloodFilter_AppliesOpacityToFilteredResultNotSource() + { + // Arrange: a fully-opaque red feFlood is the filter's entire output; the referencing + // rect's own opacity of 0.5 must still visibly attenuate that flood's alpha + const string svg = """ + + + + + + + + + """; + + // Act + using var stream = ToStream(svg); + var surface = SvgCodec.Load(stream, 100, 100); + + // Assert: the flood's own color is still fully red, but its alpha reflects the rect's own + // 50% opacity (0.5 * 255 = 127.5), not the fully-opaque 255 a pre-filter opacity fold + // would incorrectly produce - mirrors SvgCodec_Load_Opacity_MultipliesIntoFillAlpha's own + // InRange tolerance for floating-point alpha compositing + var pixel = surface[50, 50]; + Assert.Equal(255, pixel.R); + Assert.Equal(0, pixel.G); + Assert.Equal(0, pixel.B); + Assert.InRange((int)pixel.A, 110, 145); + } + /// /// Proves that feMerge layers named results in document order: a feFlood /// result placed first, then SourceGraphic placed second, renders the flood behind From d7c889496ce90faf7a6169baa22d04e88f4218bb Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Sat, 26 Sep 2026 22:50:10 -0400 Subject: [PATCH 7/8] Enforce cumulative per-document filter-work budget in SvgCodec Fixes a high-severity code-review finding: the existing per-filter work-budget check (IsFilterPrimitiveWorkWithinBudget / MaxFilterPrimitiveWorkUnits = 5,000,000) was enforced independently for each individual filtered element. A document defining one filter and referencing it from many shapes could therefore be charged up to 5,000,000 work units once per reference with no bound on the total number of references, letting total filter-evaluation CPU/memory grow arbitrarily with shape count. Adds a new FilterWorkBudget class, mirroring GeometryWorkBudget's existing 'mutable object threaded through the render walk' pattern, constructed once per Load call and threaded through RenderElement/RenderUse/RenderMarkers/RenderOneMarker/RenderText/ RenderShapeWithFilter down to RenderFilteredShape. Every filter application that gets past the existing per-filter checks now charges its region-weighted work unit into this cumulative running total; once a new MaxCumulativeFilterWorkUnits ceiling (50,000,000 - ten times the per-filter ceiling) would be exceeded, the filter application tolerantly falls back to unfiltered rendering for the remainder of that Load call, matching every other filter resource-bound's existing tolerant-fallback convention rather than throwing. Adds a regression test (13 shapes referencing one filter, each individually charging exactly the per-filter ceiling) proving the first 10 references are genuinely filtered while the 11th-13th fall back to unfiltered rendering once the cumulative ceiling is exceeded, without throwing and while completing in well under a second. Updates the design doc, verification doc, and reqstream requirement for CanvasNet-Codecs-SvgCodec-FilterResourceSafety to document the new cumulative budget, its chosen ceiling, and rationale. --- docs/design/canvas-net/codecs/svg-codec.md | 27 ++- .../canvas-net/codecs/svg-codec.yaml | 24 ++- .../canvas-net/codecs/svg-codec.md | 18 +- .../Codecs/SvgCodec.cs | 193 +++++++++++++++--- .../Codecs/SvgCodecTests.cs | 82 ++++++++ 5 files changed, 308 insertions(+), 36 deletions(-) diff --git a/docs/design/canvas-net/codecs/svg-codec.md b/docs/design/canvas-net/codecs/svg-codec.md index 2ea85402..40a493f6 100644 --- a/docs/design/canvas-net/codecs/svg-codec.md +++ b/docs/design/canvas-net/codecs/svg-codec.md @@ -399,12 +399,26 @@ chain is additionally bounded by a fixed primitive-count cap (`MaxFilterPrimitiv skipped the same way once either is exceeded - because, unlike the per-element costs `MaxTotalRenderedElements` already bounds (which assumes O(1)/O(perimeter) cost per element, not O(region-area) cost per primitive), a single filter's own primitive-chain cost is -O(primitive count × region area), a cost dimension no pre-existing guard actually covers. A +O(primitive count × region area), a cost dimension no pre-existing guard actually covers. That +per-filter ceiling alone is enforced independently for every shape that references a filter, so a +single `filter` definition referenced by many shapes could otherwise be charged the same ceiling +once per reference with no bound on the total number of references - a document with enough +shapes could drive total filter-evaluation work arbitrarily high even though every individual +reference stayed within budget. A second, cumulative, per-`Load`-call `FilterWorkBudget` closes +that gap: it charges each filter application's own region-weighted work unit into one running +total across the whole document, and once that running total would exceed a fixed +`MaxCumulativeFilterWorkUnits` ceiling (50,000,000 - ten times `MaxFilterPrimitiveWorkUnits`, +generous enough that a real document reusing one filter across a modest number of shapes is never +rejected, while still keeping worst-case total filter-evaluation work bounded to a small, fixed +multiple of a single filter's own bound), every further filter application for the remainder of +that `Load` call tolerantly falls back to unfiltered rendering instead - the same fallback +behavior as every other filter resource bound, never a thrown exception. A `filter` element with zero primitive children is likewise tolerantly skipped, identically to an out-of-budget chain and checked in the same guard, before `SourceGraphic` is allocated - because a filter with no primitives to evaluate can never change the rendered output, regardless of how large its filter region is. When the -region is accepted and the chain's work stays within budget, the +region is accepted and the chain's work stays within both the per-filter and cumulative budgets, +the element's own content is rendered a second time - independently of its main render onto `context.Surface` - into a fresh, region-sized temporary `Surface` (this buffer is the filter's implicit `SourceGraphic` input; `SourceAlpha`, its alpha-only derivative, is built lazily only if @@ -463,7 +477,7 @@ total number of elements rendered/visited across the whole document walk - charg element is processed further, and raises `InvalidDataException` once it exceeds a fixed budget far beyond any real-world document's element count but small enough to keep worst-case rendering CPU/memory bounded to a small, practical amount. Every fixed counter/budget in this class -(`GeometryWorkBudget.Charge`, the total-rendered-element counter above, and `BuildIdIndex`'s own +(`GeometryWorkBudget.Charge`, the total-rendered-element counter above, `BuildIdIndex`'s own whole-document-walk counter described below) checks the new amount against the remaining budget _before_ adding it to the running total, rather than adding first and checking afterward - a single call charging an amount large enough to make the addition itself overflow `int` cannot @@ -471,7 +485,12 @@ therefore bypass the budget by wrapping past a small, still-under-budget-looking defense-in-depth: given today's fixed constants, no call site can charge an amount anywhere close to large enough to threaten an `int` overflow before the very next charge past the real budget already throws, but the check-before-add ordering remains correct regardless of whether these -constants are ever raised in the future. +constants are ever raised in the future. The cumulative filter-work budget described in +**Filters** above (`FilterWorkBudget.TryCharge`) follows the identical check-before-add pattern, +but - because a filter's own resource cost is always tolerated with a fallback rather than treated +as a hard document-rejection condition, per the existing convention for every other filter +resource bound - reports failure by returning `false` (letting the caller fall back to unfiltered +rendering) rather than throwing. Even the total-rendered-element budget above does not bound the size of a single element's own content: it counts how many elements are visited, so one `path`/`polyline`/`polygon`/`text` diff --git a/docs/reqstream/canvas-net/codecs/svg-codec.yaml b/docs/reqstream/canvas-net/codecs/svg-codec.yaml index c70aae60..4a1a5b0e 100644 --- a/docs/reqstream/canvas-net/codecs/svg-codec.yaml +++ b/docs/reqstream/canvas-net/codecs/svg-codec.yaml @@ -563,10 +563,15 @@ sections: surface is allocated regardless of region size) and by clamping feGaussianBlur's stdDeviation to a fixed maximum, so that neither a pathological filter region nor a pathological blur radius can cause unbounded allocation or - unbounded per-pixel work; and shall bound the total primitive-count/region-area + unbounded per-pixel work; shall bound the total primitive-count/region-area work a single filter's own primitive chain may perform, tolerantly skipping the filter once - exceeded. + exceeded; and shall additionally bound the cumulative primitive-count/region-area + work charged across every filter application for a single Load call, tolerantly + falling back to unfiltered rendering for any further filter application once that + cumulative total is exceeded, so a single filter definition referenced by many + shapes cannot bypass the per-application bound by being evaluated an unbounded + number of times. justification: | A dangling filter reference is a common authoring mistake that must not abort rendering of the rest of the document, matching this codec's general @@ -583,7 +588,19 @@ sections: guard anticipates, so a dedicated primitive-count/region-area work budget was added, tolerantly skipping the whole filter (rendering the element normally) once exceeded, the same tolerant-fallback policy used for an oversized region or - dangling reference. + dangling reference. That per-filter budget alone is enforced independently for + every shape that references a filter, so a single filter definition referenced by + many shapes could be charged the same per-filter ceiling once per reference with + no bound on the total number of references, letting total filter-evaluation work + grow arbitrarily with shape count even though every individual reference stayed + within budget - a gap neither this per-filter budget nor the pre-existing + total-rendered-element bound (which counts a filtered shape identically to an + unfiltered one) closes. A second, cumulative, per-Load-call work budget was + therefore added, charging every filter application's own region-weighted work + unit into one running total and tolerantly falling back to unfiltered rendering + for any application that would push that total over a fixed ceiling, bounding + worst-case total filter-evaluation CPU/memory for a single document regardless of + how many shapes reference the same (or different) filters. tests: - SvgCodec_Load_FilterDanglingReference_RendersElementNormally - SvgCodec_Load_FilterPathologicallyLargeRegion_SkipsFilterRatherThanUnboundedAllocation @@ -591,6 +608,7 @@ sections: - SvgCodec_Load_FilterPathologicallyLargeBlurStdDeviation_ClampsRatherThanUnboundedWork - SvgCodec_Load_FilterExcessivePrimitiveCount_SkipsFilterRatherThanUnboundedWork - SvgCodec_Load_FeMergeExcessiveNodeCount_SkipsFilterRatherThanUnboundedWork + - SvgCodec_Load_FilterReferencedByManyShapesExceedingCumulativeBudget_FallsBackToUnfilteredForExcessShapes - id: CanvasNet-Codecs-SvgCodec-ValidationNull title: >- diff --git a/docs/verification/canvas-net/codecs/svg-codec.md b/docs/verification/canvas-net/codecs/svg-codec.md index 8b199820..81544086 100644 --- a/docs/verification/canvas-net/codecs/svg-codec.md +++ b/docs/verification/canvas-net/codecs/svg-codec.md @@ -685,7 +685,8 @@ result as a whole" semantics. `SvgCodec_Load_FilterWithZeroPrimitivesAndHugeRegion_SkipsFilterRatherThanAllocatingSurface`, `SvgCodec_Load_FilterPathologicallyLargeBlurStdDeviation_ClampsRatherThanUnboundedWork`, `SvgCodec_Load_FilterExcessivePrimitiveCount_SkipsFilterRatherThanUnboundedWork`, -`SvgCodec_Load_FeMergeExcessiveNodeCount_SkipsFilterRatherThanUnboundedWork` +`SvgCodec_Load_FeMergeExcessiveNodeCount_SkipsFilterRatherThanUnboundedWork`, +`SvgCodec_Load_FilterReferencedByManyShapesExceedingCumulativeBudget_FallsBackToUnfilteredForExcessShapes` Asserts a `filter="url(#id)"` reference to a nonexistent id renders the element normally, exactly as if no `filter` attribute were present, matching this codec's general dangling-reference @@ -711,7 +712,20 @@ excessive number (5,000) of `feMergeNode` children is likewise tolerantly skippe bypassing the primitive-count work budget - a regression test for a defect where the budget check counted a `feMerge` as a single primitive regardless of its own `feMergeNode` child count, even though `ApplyFeMerge` performs one full-surface composite per child, letting a pathological -`feMerge` node count bypass the budget while still performing unbounded work. +`feMerge` node count bypass the budget while still performing unbounded work. Finally, asserts a +regression test for a code-review finding that the per-filter `MaxFilterPrimitiveWorkUnits` +ceiling is enforced independently for every individual filtered element, so a single `filter` +definition referenced by many shapes could be charged that same ceiling once per reference with no +bound on the total number of references: 13 shapes reference one filter chain that individually +charges exactly the per-filter ceiling (500 primitives against a 100×100 region) every time - the +first 10 references' cumulative total stays within the new `FilterWorkBudget`'s +`MaxCumulativeFilterWorkUnits` ceiling (50,000,000) and are genuinely filtered (rendering the +chain's trailing `feFlood` solid color), while the 11th through 13th references would push the +cumulative total over that ceiling and tolerantly fall back to unfiltered rendering (their own +plain fill color) instead of throwing - proving the new cumulative, per-`Load`-call budget bounds +total filter-evaluation work across the whole document, a dimension neither +`MaxFilterPrimitiveWorkUnits` (per-filter-application only) nor `MaxTotalRenderedElements` +(counts a filtered shape identically to an unfiltered one) previously covered. #### CanvasNet-Codecs-SvgCodec-ValidationNull: Null Stream/Path Rejected diff --git a/src/DemaConsulting.CanvasNet/Codecs/SvgCodec.cs b/src/DemaConsulting.CanvasNet/Codecs/SvgCodec.cs index 74b4a21d..e4465a9a 100644 --- a/src/DemaConsulting.CanvasNet/Codecs/SvgCodec.cs +++ b/src/DemaConsulting.CanvasNet/Codecs/SvgCodec.cs @@ -393,6 +393,94 @@ public void Charge(int amount) } } + /// + /// Tracks the cumulative "primitive count times filter-region pixel area" work charged + /// across every filter actually evaluated (i.e. every + /// call that passes its own per-filter ceiling + /// and is about to allocate a SourceGraphic buffer) within a single Load + /// call, so a single filter definition referenced by many shapes cannot bypass the + /// resource-safety bound that alone provides. + /// + /// + /// bounds only a single filter evaluation's own + /// cost - it is checked independently for every shape that references a filter, so + /// a document defining one filter once and referencing it (via filter="url(#f)") + /// from many shapes charges that same per-filter ceiling once per reference, with no bound + /// on the total number of references. bounds the + /// total number of rendered shapes, but not their filter work at all: it counts a filtered + /// shape identically to an unfiltered one, even though a filtered shape's own rendering + /// cost (allocating and evaluating a fresh SourceGraphic/filter chain) can be + /// orders of magnitude larger. This budget closes that gap by charging the same + /// region-weighted work unit already computed for the per-filter check into one running, + /// per-Load-call total, mirroring 's identical + /// "mutable reference type shared across the whole render walk" pattern - a plain + /// ref long parameter is not usable here because it must be threaded through the + /// same deeply recursive // + /// // call + /// chain already uses, and a separate object (rather than + /// folding this counter into itself) keeps each budget's + /// single responsibility - geometry-parsing work versus filter-evaluation work - distinct + /// and independently documented/testable. + /// + private sealed class FilterWorkBudget + { + /// + /// The maximum combined "primitive count times filter-region pixel area" work this + /// codec will evaluate, across every filter application, for a single Load + /// call. 50,000,000 is exactly 10 times (the + /// ceiling for a single filter application) - generous enough that a real-world + /// document legitimately reusing one filter across a modest number of shapes (for + /// example 10 shapes, each individually well within the per-filter ceiling) is never + /// rejected, while still keeping the worst-case total filter-evaluation CPU/memory for + /// a single document bounded to a small, fixed multiple of a single filter's own + /// bound, regardless of how many shapes a pathological document references the same + /// (or different) filters from. + /// + private const long MaxCumulativeFilterWorkUnits = 50_000_000L; + + /// The running total of filter-evaluation work charged so far. + private long _total; + + /// + /// Attempts to charge units of filter-evaluation work + /// against the running total, reporting whether the cumulative budget still has room + /// - called immediately before allocates its + /// SourceGraphic buffer, so a filter application that would push the cumulative + /// total over budget is rejected before any of its own work (buffer allocation, blur + /// passes, compositing) begins. + /// + /// + /// The region-weighted work unit for this one filter application - identical to the + /// value already computed for 's own + /// per-filter check. + /// + /// + /// if was charged because the + /// cumulative total remains within ; + /// (charging nothing) if it would exceed the budget, so the + /// caller can tolerantly fall back to unfiltered rendering rather than throwing - + /// a filter budget, unlike 's parsing-work budget, is + /// never treated as a hard document-rejection condition, consistent with every other + /// per-filter tolerant-fallback case in . + /// + public bool TryCharge(long amount) + { + // Check before adding (rather than adding then checking) so that a single amount + // large enough to make the addition itself overflow cannot bypass the budget - mirrors + // GeometryWorkBudget.Charge's identical check-before-add reasoning. Both operands here + // are already bounded well below long.MaxValue by MaxFilterPrimitiveWorkUnits/ + // MaxCumulativeFilterWorkUnits themselves, but the ordering remains strictly more + // correct regardless. + if (amount > MaxCumulativeFilterWorkUnits - _total) + { + return false; + } + + _total += amount; + return true; + } + } + // ================================================================================================ // Public API // ================================================================================================ @@ -1277,9 +1365,10 @@ private static void RenderDocument(XElement root, Matrix3x2 fitTransform, Render var rootState = ApplyPresentationAttributes(RenderState.Initial, root); var totalElements = 0; var workBudget = new GeometryWorkBudget(); + var filterWorkBudget = new FilterWorkBudget(); foreach (var child in root.Elements()) { - RenderElement(child, rootState, fitTransform, context, useDepth: 0, elementDepth: 0, markerDepth: 0, ref totalElements, workBudget); + RenderElement(child, rootState, fitTransform, context, useDepth: 0, elementDepth: 0, markerDepth: 0, ref totalElements, workBudget, filterWorkBudget); } } @@ -1321,6 +1410,12 @@ private static void RenderDocument(XElement root, Matrix3x2 fitTransform, Render /// pathologically large element's own content is also bounded, independent of /// . /// + /// + /// The shared cumulative filter-evaluation work budget (see ), + /// threaded down to every shape/text dispatch below so a filter reused across many shapes + /// is bounded in aggregate, independent of each individual filter's own + /// ceiling. + /// /// /// Thrown when or a descendant contains malformed presentation /// data, a use reference cycle/excessive nesting is detected, the element tree @@ -1361,7 +1456,8 @@ private static void RenderElement( int elementDepth, int markerDepth, ref int totalElements, - GeometryWorkBudget workBudget) + GeometryWorkBudget workBudget, + FilterWorkBudget filterWorkBudget) { // Fail fast before recursing any further - an unbounded element tree walk would otherwise // eventually drive the call stack into an uncatchable StackOverflowException @@ -1418,61 +1514,61 @@ private static void RenderElement( // renders when referenced via ) foreach (var child in element.Elements()) { - RenderElement(child, state, transform, context, useDepth, elementDepth + 1, markerDepth, ref totalElements, workBudget); + RenderElement(child, state, transform, context, useDepth, elementDepth + 1, markerDepth, ref totalElements, workBudget, filterWorkBudget); } break; case "rect": - RenderShapeWithFilter(element, BuildRectPath(element), state, transform, context, suppressFilter); + RenderShapeWithFilter(element, BuildRectPath(element), state, transform, context, filterWorkBudget, suppressFilter); break; case "circle": - RenderShapeWithFilter(element, BuildEllipsePath(element, isCircle: true), state, transform, context, suppressFilter); + RenderShapeWithFilter(element, BuildEllipsePath(element, isCircle: true), state, transform, context, filterWorkBudget, suppressFilter); break; case "ellipse": - RenderShapeWithFilter(element, BuildEllipsePath(element, isCircle: false), state, transform, context, suppressFilter); + RenderShapeWithFilter(element, BuildEllipsePath(element, isCircle: false), state, transform, context, filterWorkBudget, suppressFilter); break; case "line": { var linePath = BuildLinePath(element); - RenderShapeWithFilter(element, linePath, state, transform, context, suppressFilter); - RenderMarkers(linePath, state, transform, context, useDepth, elementDepth, markerDepth, ref totalElements, workBudget); + RenderShapeWithFilter(element, linePath, state, transform, context, filterWorkBudget, suppressFilter); + RenderMarkers(linePath, state, transform, context, useDepth, elementDepth, markerDepth, ref totalElements, workBudget, filterWorkBudget); break; } case "polyline": { var polylinePath = BuildPolyPath(element, closed: false, workBudget); - RenderShapeWithFilter(element, polylinePath, state, transform, context, suppressFilter); - RenderMarkers(polylinePath, state, transform, context, useDepth, elementDepth, markerDepth, ref totalElements, workBudget); + RenderShapeWithFilter(element, polylinePath, state, transform, context, filterWorkBudget, suppressFilter); + RenderMarkers(polylinePath, state, transform, context, useDepth, elementDepth, markerDepth, ref totalElements, workBudget, filterWorkBudget); break; } case "polygon": { var polygonPath = BuildPolyPath(element, closed: true, workBudget); - RenderShapeWithFilter(element, polygonPath, state, transform, context, suppressFilter); - RenderMarkers(polygonPath, state, transform, context, useDepth, elementDepth, markerDepth, ref totalElements, workBudget); + RenderShapeWithFilter(element, polygonPath, state, transform, context, filterWorkBudget, suppressFilter); + RenderMarkers(polygonPath, state, transform, context, useDepth, elementDepth, markerDepth, ref totalElements, workBudget, filterWorkBudget); break; } case "path": { var dataPath = BuildPathDataPath(element, workBudget); - RenderShapeWithFilter(element, dataPath, state, transform, context, suppressFilter); - RenderMarkers(dataPath, state, transform, context, useDepth, elementDepth, markerDepth, ref totalElements, workBudget); + RenderShapeWithFilter(element, dataPath, state, transform, context, filterWorkBudget, suppressFilter); + RenderMarkers(dataPath, state, transform, context, useDepth, elementDepth, markerDepth, ref totalElements, workBudget, filterWorkBudget); break; } case "use": - RenderUse(element, state, transform, context, useDepth, elementDepth, markerDepth, ref totalElements, workBudget); + RenderUse(element, state, transform, context, useDepth, elementDepth, markerDepth, ref totalElements, workBudget, filterWorkBudget); break; case "text": - RenderText(element, state, transform, context, workBudget, suppressFilter); + RenderText(element, state, transform, context, workBudget, filterWorkBudget, suppressFilter); break; default: @@ -3233,6 +3329,10 @@ private enum FeCompositeOperator /// The cascaded render state. /// The accumulated transform from local space into pixel space. /// The fixed per-document render context. + /// + /// The shared cumulative filter-evaluation work budget, forwarded to + /// . + /// /// /// when is being rendered as part of a /// marker element's own content (propagated from RenderElement's @@ -3253,7 +3353,7 @@ private enum FeCompositeOperator /// filter) - see 's remarks for the full filter /// evaluation pipeline. /// - private static void RenderShapeWithFilter(XElement element, Path localPath, RenderState state, Matrix3x2 transform, RenderContext context, bool suppressFilter = false) + private static void RenderShapeWithFilter(XElement element, Path localPath, RenderState state, Matrix3x2 transform, RenderContext context, FilterWorkBudget filterWorkBudget, bool suppressFilter = false) { var filterElement = suppressFilter ? null : ResolveFilterElement(element, context); if (filterElement == null) @@ -3262,7 +3362,7 @@ private static void RenderShapeWithFilter(XElement element, Path localPath, Rend return; } - RenderFilteredShape(filterElement, localPath, state, transform, context); + RenderFilteredShape(filterElement, localPath, state, transform, context, filterWorkBudget); } /// @@ -3312,6 +3412,13 @@ private static void RenderShapeWithFilter(XElement element, Path localPath, Rend /// The cascaded render state. /// The accumulated transform from local space into pixel space. /// The fixed per-document render context. + /// + /// The shared cumulative filter-evaluation work budget (see ), + /// charged with this filter application's own region-weighted work unit immediately before + /// SourceGraphic is allocated, so a filter reused across many shapes cannot bypass + /// the resource-safety bound alone provides for a + /// single filter application. + /// /// /// If the filter region cannot be computed (an empty/degenerate local bounding box), or its /// pixel-space size is non-finite, non-positive, or exceeds @@ -3351,6 +3458,17 @@ private static void RenderShapeWithFilter(XElement element, Path localPath, Rend /// potentially enormous temporary surface just to hand it back unchanged. /// /// + /// Once this filter application's own region-weighted work unit (identical to the value + /// checked against above) would push + /// 's running cumulative total past + /// 's own fixed ceiling, this filter application is + /// tolerantly skipped identically to every other case above - the same unfiltered-fallback + /// pattern, never a thrown exception, so a document that legitimately reuses one filter + /// across more shapes than the cumulative budget allows still finishes rendering, simply + /// with the excess shapes rendered unfiltered rather than the whole Load call + /// aborting. + /// + /// /// Per SVG semantics, an element's own opacity applies to the filtered result as a /// whole, not to the pre-filter source paint: SourceGraphic is rendered with a copy /// of whose is forced to @@ -3362,7 +3480,7 @@ private static void RenderShapeWithFilter(XElement element, Path localPath, Rend /// already uses everywhere else in this codec. /// /// - private static void RenderFilteredShape(XElement filterElement, Path localPath, RenderState state, Matrix3x2 transform, RenderContext context) + private static void RenderFilteredShape(XElement filterElement, Path localPath, RenderState state, Matrix3x2 transform, RenderContext context, FilterWorkBudget filterWorkBudget) { var region = ComputeFilterRegionPixelBounds(filterElement, localPath, state, transform); if (region == null) @@ -3384,6 +3502,17 @@ private static void RenderFilteredShape(XElement filterElement, Path localPath, return; } + // Charge this filter application's own region-weighted work unit against the cumulative, + // per-Load-call budget (see FilterWorkBudget's remarks) before allocating SourceGraphic - + // this is the guard that bounds a single filter definition referenced by many shapes, + // complementing IsFilterPrimitiveWorkWithinBudget's per-application-only ceiling above + var filterWorkUnits = (long)primitiveCount * pixelWidth * pixelHeight; + if (!filterWorkBudget.TryCharge(filterWorkUnits)) + { + RenderShape(localPath, state, transform, context); + return; + } + var sourceGraphic = new Surface(pixelWidth, pixelHeight); var localToTemp = transform * Matrix3x2.CreateTranslation(-pixelX, -pixelY); var tempContext = context with { Surface = sourceGraphic }; @@ -4915,6 +5044,7 @@ private static RadialGradient BuildRadialGradient( /// also contributes toward . /// /// The shared geometry-parsing work budget, propagated to the re-rendered target. + /// The shared cumulative filter-evaluation work budget, propagated to the re-rendered target. /// /// Thrown when has already reached , /// guarding against a reference cycle that would otherwise recurse indefinitely. @@ -4924,7 +5054,7 @@ private static RadialGradient BuildRadialGradient( /// no-op (nothing is rendered), consistent with this class's general dangling-reference /// handling elsewhere. /// - private static void RenderUse(XElement element, RenderState state, Matrix3x2 transform, RenderContext context, int useDepth, int elementDepth, int markerDepth, ref int totalElements, GeometryWorkBudget workBudget) + private static void RenderUse(XElement element, RenderState state, Matrix3x2 transform, RenderContext context, int useDepth, int elementDepth, int markerDepth, ref int totalElements, GeometryWorkBudget workBudget, FilterWorkBudget filterWorkBudget) { if (useDepth >= MaxUseDepth) { @@ -4939,7 +5069,7 @@ private static void RenderUse(XElement element, RenderState state, Matrix3x2 tra var offset = new Vector2(GetFloatAttribute(element, "x"), GetFloatAttribute(element, "y")); var useTransform = Matrix3x2.CreateTranslation(offset) * transform; - RenderElement(target, state, useTransform, context, useDepth + 1, elementDepth + 1, markerDepth, ref totalElements, workBudget); + RenderElement(target, state, useTransform, context, useDepth + 1, elementDepth + 1, markerDepth, ref totalElements, workBudget, filterWorkBudget); } // ================================================================================================ @@ -5187,6 +5317,7 @@ private static float ComputeVertexAngleDegrees(MarkerVertex vertex) /// The current marker-reference nesting depth, propagated to each marker's content. /// The running total-rendered-elements count. /// The shared geometry-parsing work budget. + /// The shared cumulative filter-evaluation work budget. /// /// Never called for rect/circle/ellipse - those shapes have no /// natural vertices to orient a marker along, per the SVG specification, and this class's @@ -5205,7 +5336,8 @@ private static void RenderMarkers( int elementDepth, int markerDepth, ref int totalElements, - GeometryWorkBudget workBudget) + GeometryWorkBudget workBudget, + FilterWorkBudget filterWorkBudget) { if (state.MarkerStart == "none" && state.MarkerMid == "none" && state.MarkerEnd == "none") { @@ -5268,7 +5400,8 @@ private static void RenderMarkers( elementDepth, markerDepth, ref totalElements, - workBudget); + workBudget, + filterWorkBudget); } } @@ -5340,6 +5473,7 @@ private static void RenderMarkers( /// /// The running total-rendered-elements count. /// The shared geometry-parsing work budget. + /// The shared cumulative filter-evaluation work budget. /// /// Thrown when has already reached /// , guarding against a marker-referencing-marker reference @@ -5369,7 +5503,8 @@ private static void RenderOneMarker( int elementDepth, int markerDepth, ref int totalElements, - GeometryWorkBudget workBudget) + GeometryWorkBudget workBudget, + FilterWorkBudget filterWorkBudget) { if (markerDepth >= MaxMarkerDepth) { @@ -5427,7 +5562,7 @@ private static void RenderOneMarker( var markerState = ApplyPresentationAttributes(RenderState.Initial, markerElement); foreach (var child in markerElement.Elements()) { - RenderElement(child, markerState, contentTransform, context, useDepth, elementDepth + 1, markerDepth + 1, ref totalElements, workBudget); + RenderElement(child, markerState, contentTransform, context, useDepth, elementDepth + 1, markerDepth + 1, ref totalElements, workBudget, filterWorkBudget); } } @@ -5493,6 +5628,10 @@ private static float ParseMarkerOrient(string? raw, float autoAngleDegrees, bool /// with the text's character count before glyph layout begins so a pathologically long /// run's per-rune outline/kerning work never starts once the budget is exceeded. /// + /// + /// The shared cumulative filter-evaluation work budget, forwarded to + /// . + /// /// /// Forwarded to - when /// is part of a marker element's own content, so its own @@ -5507,7 +5646,7 @@ private static float ParseMarkerOrient(string? raw, float autoAngleDegrees, bool /// descendant text node's content is concatenated and laid out as one flat run, a /// documented simplification. /// - private static void RenderText(XElement element, RenderState state, Matrix3x2 transform, RenderContext context, GeometryWorkBudget workBudget, bool suppressFilter = false) + private static void RenderText(XElement element, RenderState state, Matrix3x2 transform, RenderContext context, GeometryWorkBudget workBudget, FilterWorkBudget filterWorkBudget, bool suppressFilter = false) { if (context.Fonts == null) { @@ -5532,7 +5671,7 @@ private static void RenderText(XElement element, RenderState state, Matrix3x2 tr var origin = new Vector2(GetFloatAttribute(element, "x"), GetFloatAttribute(element, "y")); var glyphRunPath = BuildGlyphRunPath(text, font, state, origin); - RenderShapeWithFilter(element, glyphRunPath, state, transform, context, suppressFilter); + RenderShapeWithFilter(element, glyphRunPath, state, transform, context, filterWorkBudget, suppressFilter); } /// diff --git a/test/DemaConsulting.CanvasNet.Tests/Codecs/SvgCodecTests.cs b/test/DemaConsulting.CanvasNet.Tests/Codecs/SvgCodecTests.cs index 79161492..99cf250e 100644 --- a/test/DemaConsulting.CanvasNet.Tests/Codecs/SvgCodecTests.cs +++ b/test/DemaConsulting.CanvasNet.Tests/Codecs/SvgCodecTests.cs @@ -2825,6 +2825,88 @@ public void SvgCodec_Load_FilterExcessivePrimitiveCount_SkipsFilterRatherThanUnb Assert.True(stopwatch.Elapsed < TimeSpan.FromSeconds(5), $"Expected the excessive-primitive-count filter to be skipped promptly, but it took {stopwatch.Elapsed}."); } + /// + /// Regression test for a code-review finding: IsFilterPrimitiveWorkWithinBudget's + /// MaxFilterPrimitiveWorkUnits ceiling is enforced independently for each + /// individual filtered element, so a single filter definition referenced by many + /// shapes could previously be charged the same per-filter ceiling once per reference, with + /// no bound on the total number of references - a document with enough shapes could drive + /// total filter-evaluation work arbitrarily high even though every single reference stayed + /// within budget. Proves a new cumulative, per-Load-call + /// FilterWorkBudget/MaxCumulativeFilterWorkUnits bound now caps the total: 13 + /// shapes reference the same filter, each individually charging exactly + /// MaxFilterPrimitiveWorkUnits (5,000,000) work units (500 primitives against a + /// 100x100 region) - within the per-filter ceiling every time - but the 11th and later + /// references exceed the new 50,000,000 cumulative ceiling and tolerantly fall back to + /// unfiltered rendering instead. + /// + [Fact] + public void SvgCodec_Load_FilterReferencedByManyShapesExceedingCumulativeBudget_FallsBackToUnfilteredForExcessShapes() + { + // Arrange: a filter chain of 499 no-op (unrecognized primitive name) children plus one + // trailing feFlood - 500 primitive-equivalent work units total - evaluated against a + // filter region sized (via explicit 250% width/height overrides on a 40x40 rect) to + // exactly 100x100 pixels, so each individual application charges exactly + // 500 * 100 * 100 = 5,000,000 work units, precisely at (never over) the per-filter + // MaxFilterPrimitiveWorkUnits ceiling. The unknown primitives are cheap no-op passthrough steps + // (no per-pixel work at all - see EvaluateFilterChain's remarks), and feFlood's own cost + // is a single cheap fill over the small 100x100 region, so evaluating this filter even + // many times remains fast; only the cumulative work-unit total, not actual per-primitive + // cost, is what this test exercises. 13 identical, non-overlapping rects (spaced 150 units + // apart so neighboring filter regions never overlap) reference the same filter: the first + // 10 charge a running cumulative total of exactly 50,000,000 (still within the new + // ceiling), while the 11th through 13th would push the cumulative total over budget and + // must tolerantly fall back to unfiltered rendering instead of throwing. + const int shapeCount = 13; + const int filteredShapeCount = 10; + var noOpPrimitives = string.Concat(Enumerable.Repeat("", 499)); + var rects = string.Concat(Enumerable.Range(0, shapeCount).Select(i => + $"")); + var svg = $""" + + + + {noOpPrimitives} + + + + {rects} + + """; + + // Act: render the whole document - must complete promptly, and must not throw despite the + // cumulative filter work total across all 13 shapes (65,000,000) far exceeding the new + // cumulative ceiling (50,000,000) + var stopwatch = Stopwatch.StartNew(); + using var stream = ToStream(svg); + var surface = SvgCodec.Load(stream, 2000, 120); + stopwatch.Stop(); + + // Assert: the first 10 shapes (cumulative total staying within the new 50,000,000 + // ceiling) were actually filtered - each rendered as the feFlood's solid red, not its own + // blue fill + for (var i = 0; i < filteredShapeCount; i++) + { + var sampleX = 30 + (i * 150); + Assert.Equal(new Rgba32(255, 0, 0, 255), surface[sampleX, 30]); + } + + // Assert: the remaining shapes (11th through 13th), which would have pushed the + // cumulative total over the new ceiling, tolerantly fell back to unfiltered rendering - + // each still shows its own normal blue fill, exactly as the pre-existing per-filter + // tolerant-fallback cases already behave + for (var i = filteredShapeCount; i < shapeCount; i++) + { + var sampleX = 30 + (i * 150); + Assert.Equal(new Rgba32(0, 0, 255, 255), surface[sampleX, 30]); + } + + // Assert: completed promptly - the cumulative budget rejects excess filter applications + // before any of their own SourceGraphic/filter-chain work begins, so this document's total + // work stays proportional to only the 10 shapes actually filtered + Assert.True(stopwatch.Elapsed < TimeSpan.FromSeconds(1), $"Expected the cumulative-filter-work-budget document to render promptly, but it took {stopwatch.Elapsed}."); + } + /// /// Regression test for a code-review finding: the upfront filter work-budget check used to /// count only direct fe* children (a single feMerge always counted as 1), From 5cef86bc7b559680e8610822bf9d82b6d15f696e Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Sat, 26 Sep 2026 23:06:29 -0400 Subject: [PATCH 8/8] Fix whitespace tokenizing in ParseFontStyle and ParseFirstNumberToken - ParseFontStyle now splits font-style values on all XML/CSS whitespace (space, tab, newline, CR) instead of only literal space, so values like 'oblique10deg' correctly resolve to Italic instead of silently falling back to the inherited style. - ParseFirstNumberToken now splits filter-primitive numeric attributes (e.g. stdDeviation) on all XML/CSS whitespace plus comma, instead of only literal space plus comma, so values like '23' correctly parse the first token as 2 instead of silently falling back to 0 (disabling blur). - Added regression tests covering tab-separated font-style and stdDeviation values for both fixes. --- .../Codecs/SvgCodec.cs | 4 +- .../Codecs/SvgCodecTests.cs | 76 +++++++++++++++++++ 2 files changed, 78 insertions(+), 2 deletions(-) diff --git a/src/DemaConsulting.CanvasNet/Codecs/SvgCodec.cs b/src/DemaConsulting.CanvasNet/Codecs/SvgCodec.cs index e4465a9a..dbc41880 100644 --- a/src/DemaConsulting.CanvasNet/Codecs/SvgCodec.cs +++ b/src/DemaConsulting.CanvasNet/Codecs/SvgCodec.cs @@ -1711,7 +1711,7 @@ _ when string.Equals(dashArrayAttr.Trim(), "none", StringComparison.OrdinalIgnor return null; } - var firstToken = raw.Trim().Split(' ', StringSplitOptions.RemoveEmptyEntries)[0]; + var firstToken = raw.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries)[0]; return firstToken switch { "normal" => SvgFontStyle.Normal, @@ -4057,7 +4057,7 @@ private static void BoxBlurVertical(Surface surface, int radius) return null; } - var tokens = raw.Split([' ', ','], StringSplitOptions.RemoveEmptyEntries); + var tokens = raw.Split([' ', '\t', '\n', '\r', ','], StringSplitOptions.RemoveEmptyEntries); return tokens.Length == 0 ? null : ParsePercentOrNumber(tokens[0], 1f); } diff --git a/test/DemaConsulting.CanvasNet.Tests/Codecs/SvgCodecTests.cs b/test/DemaConsulting.CanvasNet.Tests/Codecs/SvgCodecTests.cs index 99cf250e..7b749e4b 100644 --- a/test/DemaConsulting.CanvasNet.Tests/Codecs/SvgCodecTests.cs +++ b/test/DemaConsulting.CanvasNet.Tests/Codecs/SvgCodecTests.cs @@ -2516,6 +2516,49 @@ public void SvgCodec_Load_FeFloodFeGaussianBlurFeCompositeFilter_RendersBlurredH Assert.Equal(0, surface[62, 50].A); } + /// + /// Regression test: stdDeviation="2 3" (tab-separated x/y radii) must still + /// tokenize on the tab and parse the first radius as 2, producing the same softened + /// halo as the equivalent space-separated stdDeviation="2" case above - rather than + /// treating the whole value as one unparseable token (which would silently fall back to a + /// zero radius, disabling the blur). + /// + [Fact] + public void SvgCodec_Load_FeGaussianBlurStdDeviationTabSeparated_RendersBlurredHaloBehindContent() + { + // Arrange: identical to the space-separated halo recipe above, except stdDeviation uses a + // tab between the x and y radii instead of a space. + const string svg = "\n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + ""; + + // Act + using var stream = ToStream(svg); + var surface = SvgCodec.Load(stream, 100, 100); + + // Assert: the rect's own fill remains fully opaque and unaffected at its center + Assert.Equal(new Rgba32(0, 128, 0, 255), surface[50, 50]); + + // Assert: just outside the rect's own edge, a softened (partially transparent, not + // hard-edged) orange halo is visible - proving the first token ("2") was actually parsed + // and used as the blur radius, rather than falling back to zero + Assert.Equal(new Rgba32(255, 161, 0, 19), surface[61, 50]); + + // Assert: a couple of pixels further out, past the blur's influence, nothing remains + Assert.Equal(0, surface[62, 50].A); + } + /// /// Proves feComposite operator="in" keeps the "in" input's own color, weighted by the /// "in2" input's own alpha, per the documented Porter-Duff "in" formula @@ -3676,6 +3719,39 @@ public void SvgCodec_Load_TextFontStyleItalic_SelectsItalicFaceOverNormalFace() Assert.Equal(0, surface[15, 35].A); } + /// + /// Regression test: font-style="oblique 10deg" (tab-separated, per the CSS + /// font-style: oblique <angle> grammar) must still tokenize on the tab and + /// resolve to , rather than treating the whole value as one + /// unparseable token (which would silently fall back to the inherited/normal style). + /// + [Fact] + public void SvgCodec_Load_TextFontStyleObliqueWithTabSeparatedAngle_SelectsItalicFaceOverNormalFace() + { + // Arrange: same glyph layout as the plain "italic" case above - x equals 75 is filled only + // by the italic face, and x equals 15 is filled only by the normal face. + const string svg = "\n" + + " A\n" + + ""; + var faces = new Dictionary> + { + ["TestFont"] = + [ + new SvgFontFace(BuildTestFont()), + new SvgFontFace(BuildItalicTestFont(), Style: SvgFontStyle.Italic) + ] + }; + + // Act + using var stream = ToStream(svg); + var surface = SvgCodec.LoadWithFontFaces(stream, 100, 100, faces); + + // Assert: filled where only the italic face's glyph reaches + Assert.Equal(255, surface[75, 35].A); + // Assert: NOT filled where only the normal face's glyph would have reached + Assert.Equal(0, surface[15, 35].A); + } + /// /// Proves the closest-weight-distance rule: with faces registered at 400 (narrow) and 900 /// (wide), a request of font-weight="600" selects the 400 face (distance 200)