Skip to content

SVG: even-odd fill, inherited fill and stroke, nested transforms, gradients on every shape, faster paths and circles - #46

Closed
serpentblade wants to merge 11 commits into
dskripchenko:mainfrom
One-Learning-Community:feat/even-odd-fill
Closed

serpentblade wants to merge 11 commits into
dskripchenko:mainfrom
One-Learning-Community:feat/even-odd-fill

Conversation

@serpentblade

Copy link
Copy Markdown
Contributor

Why

I hit these drawing real-world SVGs through SvgRenderer, most visibly QR codes from BaconQrCode's SVG backend. With these changes, Bacon's styled codes (dot modules, round eyes, a diagonal gradient) render as they do in a browser and scan with the macOS QR detector. Each fix is its own commit, so you can review them one at a time or drop any you'd rather not take.

1. Even-odd fill (feat(pdf), fix(svg))

fill-rule="evenodd" was ignored, and emitPath() had no way to ask for the even-odd rule, so every fill used the nonzero winding rule.

That matters whenever a hole is drawn the same way round as the outline around it. Under nonzero, a point inside both outlines has a winding number of 2, so it's filled; only an inner outline drawn the opposite way cuts a hole. Many tools don't do that and rely on fill-rule="evenodd" instead, so today these all print solid:

  • a donut drawn as two circles in one path,
  • a single-path letter O, or any glyph outline with a counter,
  • nested squares (a frame), and
  • a five-point star drawn as one self-crossing <polygon>, whose centre stays filled.

emitPath() now takes 'fill-evenodd' (f*) and 'fillstroke-evenodd' (B*), documented on Page::emitPath(); an unknown mode still falls back to f. SvgRenderer reads fill-rule from the attribute, inline style, a <style> rule or an ancestor group. Rects, circles and ellipses are single simple outlines, where the two rules agree. clip-rule isn't touched because there is no clipping yet for it to apply to.

2. Gradients inside groups (fix(svg))

walkElement() reset the gradient table to its default empty argument on every <g>, so a fill="url(#…)" rect inside any group printed black. The table is now set once per document.

3. Fill and stroke inherited from groups (fix(svg))

Shapes read fill, stroke, stroke-width, fill-opacity and stroke-opacity from their own attributes only, so <g fill="#f00"> was ignored and its shapes printed black. These now resolve the way the text properties already do: inline style, the attribute, then the nearest ancestor. opacity stays per element, as SVG defines it. A later commit (perf(svg)) finds a shape's ancestors once instead of once per property, so inheritance costs about 1 µs a shape: 30,000 rects take 0.114s, against 0.086s before inheritance.

4. Nested transforms (fix(svg))

Under a transformed group, walkElementXY() never read a child's own transform. <g transform="scale(2)"><g transform="translate(2,2)">, as Bacon writes it, lost its translate, and so did a shape's own transform inside a transformed group. Each child's transform is now composed with its group's, and stroke widths follow the combined scale.

5. Gradient fills on every shape (feat(svg))

Only a plain <rect> could take a gradient; paths, polygons, circles, ellipses and transformed rects printed black. They're now filled through a new Page::fillPathWithPattern() (and ContentStream::fillPathWithPattern()), with a pattern whose matrix maps gradient space onto the page: the shape's user space including group transforms, its bounding box for objectBoundingBox units, then gradientTransform. gradientUnits="userSpaceOnUse" is honoured. The existing plain-rect gradient code and its output are unchanged; only the stop handling moved into a shared shadingFunction().

6. Streaming path data (perf(svg))

We hit this drawing complex data visualisations: dense line charts and maps whose paths carry hundreds of thousands of points. parsePathD() split the whole d attribute with one preg_match_all (a PHP array per command), built a second array per command, and only then wrote the path. A 5 MB path with a million points peaked at about 511 MB.

Path commands are now read in place with strcspn() and yielded one at a time, and emitPath() / fillPathWithPattern() accept any iterable, writing a generator as they read it. Widening array to iterable doesn't break existing callers. The same path now peaks at about 11 MB, mostly the content stream itself, and takes about the same time. Gradient fills, which read the path twice, still build the list.

7. Circles and ellipses as Bézier curves (perf(svg))

Each circle and ellipse was a 36-sided polygon, filled and then stroked as two separate paths. Large ones showed visible facets, and scatter plots paid for 36 points a dot, about 640 bytes of content each. They're now four cubic Béziers with the usual control distance of 4/3 (√2 − 1), which stays within 0.03% of the true outline. Fill and stroke go out as one path through emitPath(), and gradients and fill-rule work as on any path.

A 25,000-dot scatter plot now takes 1.2s instead of 2.5s and peaks at 19 MB instead of 46 MB. Its PDF is 1.8 MB instead of 5.2 MB. A zero or negative radius now draws nothing, as SVG defines.

8. A cap on <use> copies (fix(svg))

<use> elements can refer to groups that contain <use> elements, and the copies multiply. References resolve in one pass, so a cycle can't loop, but a group with n <use> references to itself still draws 2^n copies, since each one copies the copies made before it. Either way, a small file could expand into millions of shapes and take minutes to draw. resolveUseReferences() now counts each copy before making it, and a <use> that would take a document past SvgRenderer::MAX_USE_COPIES (50,000 elements) is left out. Smaller ones after it are still drawn.

Tests

  • tests/Pdf/ContentStreamTest.php: the new emitPath() modes, the unchanged ones with the fallback, and fillPathWithPattern().
  • tests/Svg/SvgFillRuleTest.php: fill-rule from the attribute, style, a style sheet and a group, overrides, strokes, and polygons plain and transformed.
  • tests/Svg/SvgGroupsTest.php: gradients in groups, inherited fill, stroke, stroke width and fill opacity, and nested transforms checked against exact page coordinates.
  • tests/Svg/SvgGradientShapesTest.php: exact /Coords and /Matrix for bounding-box and user-space gradients, gradientTransform, group transforms, radial on a circle, polygon, ellipse, transformed rect, a gradient fill with a stroke, and the black fallback for an unknown id.
  • tests/Svg/SvgCirclesTest.php: exact curve coordinates for circles and ellipses, fill and stroke as one path, zero radii, transforms, and a size budget for a 1,000-dot scatter plot.
  • tests/Svg/SvgLargePathTest.php: a 200,000-segment path drawn within a 16 MB budget, generators accepted by emitPath(), and gradient fills still seeing the whole path.
  • tests/Svg/SvgDefsUseTest.php: the copy cap for nested groups and for a group that uses itself, and a small <use> drawn after a refused one.

I also rasterised a donut, a letter O, a star, nested squares and a group-level fill-rule with pdftoppm: on main every centre is filled, and with this branch every centre is empty.

The full suite passes (2650 tests), and PHPStan reports no errors. The CHANGELOG has an [Unreleased] entry for each change, and USAGE has a short paragraph in each language (en, de, ru, zh).

serpentblade and others added 11 commits October 20, 2026 13:08
emitPath() knew 'fill', 'stroke' and 'fillstroke' only, so a caller had no
way to ask for the even-odd rule. It takes 'fill-evenodd' (f*) and
'fillstroke-evenodd' (B*) now. An unknown mode still emits f.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
SvgRenderer never read fill-rule, so every fill used the nonzero rule. A
hole drawn the same way round as its outline — a ring made of two circles in
one path, a single-path letter O, nested squares — filled solid, and a
five-point star polygon with fill-rule="evenodd" kept its centre filled.

fill-rule is resolved like the other inherited properties: the element, its
inline style, a style-sheet rule, then the nearest group. Even-odd paths
fill with f* (B* with a stroke); even-odd polygons go through emitPath with
'fill-evenodd'. Nonzero output is unchanged byte for byte.

Rect, circle and ellipse are single simple outlines, where the two rules
agree. clip-rule has nothing to apply to yet: the renderer has no clipPath.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
walkElement() reset the gradient table to its default (empty) argument on
every <g>, so fill="url(#…)" on a rect inside any group printed black. The
table is now set once per document and kept for the whole walk.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every shape read fill, stroke, stroke-width, fill-opacity and
stroke-opacity from its own attributes only. A group's fill="#f00" was
ignored and its shapes printed black; a group's stroke was dropped.
These properties now resolve like the text properties already do: the
element's inline style, its attribute, then the nearest ancestor.
opacity stays per element, as SVG defines it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Under a transformed group, children were drawn with the group's mapping
only; walkElementXY() never read their own transform attribute. A
<g transform="scale(2)"><g transform="translate(2,2)">, as BaconQrCode
and other generators write it, lost its translate, and a shape's own
transform inside a transformed group was dropped. Each child's transform
is now composed with its group's, and stroke widths follow the combined
scale.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
fill="url(#…)" worked on a plain <rect> only; paths, polygons, circles,
ellipses and transformed rects printed black. They are now filled with
a shading pattern through Page::fillPathWithPattern(). The pattern's
matrix maps gradient space onto the page: the shape's user space (with
its group transforms), its bounding box for objectBoundingBox units, then
gradientTransform. gradientUnits="userSpaceOnUse" is honoured, so
BaconQrCode's gradient QR codes come out as they do in a browser, and
they scan. Even-odd paths fill with f*.

The plain-rect gradient code and its output are unchanged; the stop
handling moves to a shared shadingFunction().

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
resolveUseReferences() copied every reference in full, and a copy can
hold <use> elements that were already expanded, so nested references
multiply. A small SVG could expand into millions of shapes and keep a
renderer busy for minutes. Copies are now counted before they are made,
and a <use> that would take a document past
SvgRenderer::MAX_USE_COPIES (50,000 elements) is left out; smaller ones
after it are still drawn.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
<use> references resolve in one pass, so a cycle cannot loop, but each
resolved <use> copies its group along with the copies made so far: n
references from a group to itself draw 2^n copies. The cap already
bounds that; this test keeps it bounded.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
We hit this drawing complex data visualisations: dense line charts and
maps whose paths carry hundreds of thousands of points. parsePathD()
split the whole d attribute with one preg_match_all (an array per
command), built a second array per command, and only then wrote the
path, so a 5 MB path with a million points peaked at about 511 MB.

Path commands are now read in place with strcspn() and yielded one at a
time, and emitPath() / fillPathWithPattern() take any iterable, writing a
generator as it is read. The same path peaks at about 11 MB, mostly the
content stream itself; time is about the same. A gradient fill, which
reads the path twice, still builds the list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Each circle and ellipse was a 36-sided polygon, filled and then stroked
as two paths. Large ones showed their facets, and scatter plots paid for
36 points a dot: about 640 bytes of content each. They are now four
cubic Béziers (control distance 4/3 (sqrt 2 - 1), within 0.03% of the
true outline), filled and stroked as one path through emitPath(), with
gradients and fill-rule as for any path.

A 25,000-dot scatter plot takes 1.2 s instead of 2.5 s, peaks at 19 MB
instead of 46 MB, and its PDF is 1.8 MB instead of 5.2 MB. A zero or
negative radius now draws nothing, as SVG defines.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Inherited fill and stroke made every shape look up about six properties,
and each lookup ran its own ancestor-or-self XPath query: 30,000 rects
went from 0.086 s to 0.157 s. The ancestor chain is now kept for the
element being drawn (keyed by the object every lookup receives) and
dropped when the render ends; the same rects take 0.114 s.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
dskripchenko added a commit that referenced this pull request Oct 27, 2026
…ients

SVG: even-odd fills, gradients and inheritance in groups (from #46) with follow-up fixes
@dskripchenko

Copy link
Copy Markdown
Owner

Thank you, @serpentblade — real-world SVGs, styled QR codes and dense charts, were exactly where the renderer fell short. Every one of these fixes came with a test and a clear explanation, and the <use> cap and the streaming path parser close real robustness gaps.

All eleven of your commits are merged unchanged, with your authorship, through #49. Edits by maintainers weren't enabled here, so we carried them over to a branch and added six follow-ups.

What we changed, and why:

  • The <use> cap without the quadratic cost. Every <use> still counted its target's subtree, even when the copy was then refused. 20,000 <use> of a 60,000-element group took about 3 s, and a few-megabyte file still took minutes. Refused ids are now remembered and skipped at once, and the loop stops when the cap is reached.
  • A plain <rect> reads its gradient through gradientFor(), so fill="url('#g')" with quotes or spaces works on it as on every other shape.
  • Tight bounds for gradient-filled curves. objectBoundingBox gradients were sized by the control points, so they stretched wider than in a browser. The bounds now come from where each cubic turns.
  • Opacity on transformed shapes, paths and lines. These printed opaque before, on main too; an opaque shape still writes no transparency.
  • Small ones: no unused pattern for an empty path; a note that generators passed to emitPath() are consumed once; and the CHANGELOG.

Two things are left for later: reusing one shading for many shapes with the same gradient, and keeping an SVG's transparency out of PDF/A-1 documents, which predates both PRs.

A small request for next time: please open pull requests from a fork under your personal account with "Allow edits by maintainers" ticked, so we can push fixes straight into your branch.

Thanks again!

@serpentblade

Copy link
Copy Markdown
Contributor Author

Apologies for being locked out from editing the branches. I'll get that fixed before any future PRs.

Really appreciate you merging these. This project and your docx one are fantastic.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants