Repository navigation
SVG: even-odd fill, inherited fill and stroke, nested transforms, gradients on every shape, faster paths and circles - #46
Conversation
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>
…ients SVG: even-odd fills, gradients and inheritance in groups (from #46) with follow-up fixes
|
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 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:
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! |
|
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. |
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, andemitPath()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:<polygon>, whose centre stays filled.emitPath()now takes'fill-evenodd'(f*) and'fillstroke-evenodd'(B*), documented onPage::emitPath(); an unknown mode still falls back tof.SvgRendererreadsfill-rulefrom the attribute, inlinestyle, a<style>rule or an ancestor group. Rects, circles and ellipses are single simple outlines, where the two rules agree.clip-ruleisn'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 afill="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-opacityandstroke-opacityfrom 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: inlinestyle, the attribute, then the nearest ancestor.opacitystays 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 owntransform.<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 newPage::fillPathWithPattern()(andContentStream::fillPathWithPattern()), with a pattern whose matrix maps gradient space onto the page: the shape's user space including group transforms, its bounding box forobjectBoundingBoxunits, thengradientTransform.gradientUnits="userSpaceOnUse"is honoured. The existing plain-rect gradient code and its output are unchanged; only the stop handling moved into a sharedshadingFunction().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 wholedattribute with onepreg_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, andemitPath()/fillPathWithPattern()accept any iterable, writing a generator as they read it. Wideningarraytoiterabledoesn'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 andfill-rulework 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 pastSvgRenderer::MAX_USE_COPIES(50,000 elements) is left out. Smaller ones after it are still drawn.Tests
tests/Pdf/ContentStreamTest.php: the newemitPath()modes, the unchanged ones with the fallback, andfillPathWithPattern().tests/Svg/SvgFillRuleTest.php:fill-rulefrom 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/Coordsand/Matrixfor 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 byemitPath(), 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-rulewithpdftoppm: onmainevery 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).