Skip to content

SVG: inherit the stops and attributes of the gradient an href references - #2169

Merged
andersonhc merged 8 commits into
py-pdf:masterfrom
RavSinghChandan:svg-gradient-href
Nov 13, 2026
Merged

andersonhc merged 8 commits into
py-pdf:masterfrom
RavSinghChandan:svg-gradient-href

Conversation

@RavSinghChandan

Copy link
Copy Markdown

Inkscape writes every gradient as two elements: one with the stops, and one with the coordinates that references it with xlink:href:

<linearGradient id="linearGradient1">
  <stop offset="0" stop-color="red"/>
  <stop offset="1" stop-color="blue"/>
</linearGradient>
<linearGradient xlink:href="#linearGradient1" id="linearGradient2"
                x1="0" y1="0" x2="200" y2="0" gradientUnits="userSpaceOnUse"/>

fpdf2 ignores the reference, so it logs "Linear gradient '#linearGradient2' has no valid stops, skipping" and the shapes filled with it are not painted. matplotlib's toolbar icon mpl-data/images/hand.svg is such a file: its 5 linear and 3 radial gradients are skipped and the cross in it comes out hollow instead of filled in teal.

As the specification says for href on gradients, a gradient now takes the attributes it does not set (x1... r, fx, fr, gradientUnits, spreadMethod, gradientTransform) and, if it has no stops, the stops from the gradient it references, following a chain of references, also to a gradient defined later, and stopping at a cycle.

Added test_gradient_inherits_stops_with_href (Inkscape's form) and test_gradient_inherits_attributes_with_href (a chain of radial gradients, the last one defined after the first); both fail on master. test_gradient_href_cycle_without_stops_is_skipped checks that a cycle ends. The SVG tests, black, pylint and mypy pass

@andersonhc andersonhc left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I noticed two things worth addressing before merging:

  • Long reference chains get expensive because each gradient rebuilds the chain using list searches. A roughly 82 KB SVG with 2,000 linked gradients took 42 seconds locally, compared with 0.038 seconds on the base. Could we cache the resolved templates and use a set for cycle detection?
  • _gradient_stops() falls back to the template when local stops exist but none parse successfully. Could we check whether local elements exist before deciding to inherit? That would prevent invalid local colors from unexpectedly picking up the template’s colors.

The chain of a gradient was rebuilt for every gradient, with list
searches, so 2,000 linked gradients took seconds; each gradient is now
resolved once and cached, with a set for cycles. A gradient with
<stop> elements no longer takes the stops of its template when none of
its own can be read.
@RavSinghChandan

Copy link
Copy Markdown
Author

Thanks! Each gradient's href chain is now resolved once and cached (_gradient_inheritance()), with a set for cycles and a loop instead of recursion. Your case of 2,000 linked gradients takes 0.08 s here; test_gradient_long_href_chain covers it. A gradient with its own <stop> elements now always uses them, even when none can be read (test_gradient_with_invalid_stops_does_not_inherit_stops). I also merged master.

@andersonhc

Copy link
Copy Markdown
Collaborator

Thanks! Each gradient's href chain is now resolved once and cached (_gradient_inheritance()), with a set for cycles and a loop instead of recursion. Your case of 2,000 linked gradients takes 0.08 s here; test_gradient_long_href_chain covers it. A gradient with its own <stop> elements now always uses them, even when none can be read (test_gradient_with_invalid_stops_does_not_inherit_stops). I also merged master.

The cycle-cache case still reproduces on the latest head: with a -> b -> a, where only a has stops, defining a first causes b to be skipped. Reversing the definitions makes both gradients resolve.

Could we avoid caching that incomplete result and add a regression test covering both definition orders?

With a -> b -> a where only a has stops, resolving a first cached b
before a's stops were known, so b was skipped; defined the other way
round both resolved. The members of a cycle now each follow the cycle
from themselves: going round it backwards twice gives each one the
nearest attributes and stops after it, still in linear time.
@RavSinghChandan

Copy link
Copy Markdown
Author

Thanks, you're right: resolving a first cached b before a's stops were known. The members of a cycle now each follow the cycle from themselves (going round it backwards twice, still linear: a 2,000-gradient cycle takes 0.08 s here), so a -> b -> a resolves both gradients in either order. test_gradient_href_cycle_does_not_depend_on_order covers both orders; the a-first one failed before.

@andersonhc
andersonhc merged commit cbc2061 into py-pdf:master Nov 13, 2026
37 of 42 checks passed
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