Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ This can also be enabled programmatically with `warnings.simplefilter('default',
* Chinese translation of the [Tuto 7 - Creating PDF/A Documents](https://py-pdf.github.io/fpdf2/Tutorial-zh.html#-pdfa) tutorial section - _cf._ [issue #267](https://github.com/py-pdf/fpdf2/issues/267) - _cf._ [PR #2138](https://github.com/py-pdf/fpdf2/pull/2138) - thanks to @zheng-guannan577*
* `FPDF.write_html()` makes the `id` of an element, and the `name` of an `<a>`, a named destination, so that `<a href="#...">` links within the document work, like in browsers, instead of raising `FPDFException: Named destination ... was referenced but never set` - thanks to @RavSinghChandan
### Fixed
* SVG: CSS rules with a universal selector (`*`) or an element name (`rect`, `text`...) in a `<style>` are now applied, after presentation attributes and before class rules and inline styles, as in browsers; every SVG that matplotlib writes starts with `*{stroke-linejoin: round; stroke-linecap: butt}`, which was ignored - thanks to @RavSinghChandan
* the generic font families of CSS Fonts 4, `system-ui`, `ui-sans-serif`, `ui-rounded`, `ui-serif` and `ui-monospace`, are now rendered with the matching core font in SVG text and `FPDF.write_html()`, as in the `og.svg` images of react-window, instead of failing with `KeyError: No suitable font for family='system-ui'` - thanks to @RavSinghChandan
* SVG: a root `<svg>` with `width="auto"` or `height="auto"`, as Figma writes it, for instance in the country flags of the ProtonVPN apps, is now sized as if it had no `width` or `height`, from its `viewBox`, instead of failing with `ValueError: Unable to parse 'auto' as a length` - thanks to @RavSinghChandan
* SVG: `hsl()` and `hsla()` colors are now read, with commas or space-separated, with a hue that may have an angle unit (`deg`, `grad`, `rad`, `turn`) and an alpha, as Mermaid diagrams and many stylesheets write them, instead of failing with `ValueError: unsupported color specification` - thanks to @RavSinghChandan
Expand Down
64 changes: 46 additions & 18 deletions fpdf/svg.py
Original file line number Diff line number Diff line change
Expand Up @@ -1017,10 +1017,11 @@ def first_number(
return x, y, dx, dy


def _extract_css_class_styles(css_text: str) -> list[tuple[str, dict[str, Any]]]:
styles: list[tuple[str, dict[str, Any]]] = []
def _extract_css_rules(css_text: str) -> list[tuple[str, dict[str, Any]]]:
"""The (selector, declarations) of each rule of a stylesheet, one per selector."""
rules: list[tuple[str, dict[str, Any]]] = []
if not css_text:
return styles
return rules

cleaned = CSS_COMMENT_RE.sub("", css_text)
for selector_block, body in CSS_BLOCK_RE.findall(cleaned):
Expand All @@ -1034,17 +1035,27 @@ def _extract_css_class_styles(css_text: str) -> list[tuple[str, dict[str, Any]]]
normalized[key] = norm_value
if not normalized:
continue
selectors = [selector.strip() for selector in selector_block.split(",")]
for selector in selectors:
if not selector.startswith("."):
continue
class_name = selector[1:].split(":", 1)[0].strip()
if not class_name:
continue
styles.append((class_name, dict(normalized)))
for selector in selector_block.split(","):
rules.append((selector.strip(), dict(normalized)))
return rules


def _extract_css_class_styles(css_text: str) -> list[tuple[str, dict[str, Any]]]:
styles: list[tuple[str, dict[str, Any]]] = []
for selector, declarations in _extract_css_rules(css_text):
if not selector.startswith("."):
continue
class_name = selector[1:].split(":", 1)[0].strip()
if not class_name:
continue
styles.append((class_name, declarations))
return styles


# A universal selector or an element name, such as "*" or "rect"
CSS_TYPE_SELECTOR_RE = re.compile(r"\*|[A-Za-z][\w-]*")


@force_nodocument
class ShapeBuilder:
"""A namespace within which methods for converting basic shapes can be looked up."""
Expand Down Expand Up @@ -1358,6 +1369,8 @@ def __init__(
self._cross_reference_costs: dict[str, int] = {}
self.symbol_info: dict[str, SymbolInfo] = {}
self.css_class_styles: dict[str, dict[str, Any]] = {}
# rules of "*" and element names, such as matplotlib's * { stroke-linejoin: round }
self.css_type_styles: dict[str, dict[str, Any]] = {}
self.gradient_definitions: dict[str, GradientPaint] = (
{}
) # Store parsed gradients by ID
Expand Down Expand Up @@ -1574,6 +1587,12 @@ def _collect_css_styles(self, root_tag: "Element") -> None:
# a later rule does not override an !important declaration
if key not in existing or _overrides(existing[key], value):
existing[key] = value
for selector, declarations in _extract_css_rules(css_text):
if CSS_TYPE_SELECTOR_RE.fullmatch(selector):
existing = self.css_type_styles.setdefault(selector, {})
for key, value in declarations.items():
if key not in existing or _overrides(existing[key], value):
existing[key] = value

def _is_display_none(self, tag: "Element") -> bool:
display = _get_attr_or_style(tag, "display", self._style_map_for(tag))
Expand All @@ -1583,19 +1602,28 @@ def _style_map_for(self, tag: "Element") -> dict[str, Any]:
style_map: dict[str, Any] = {}
# an !important declaration of a stylesheet wins over a normal inline one
important: set[str] = set()

def apply_rules(rules: dict[str, Any]) -> None:
for key, declared in rules.items():
value, is_important = _split_important(declared)
if key in important and not is_important:
continue
style_map[key] = value
if is_important:
important.add(key)

# as in CSS, a class rule wins over an element rule, which wins over "*"
for selector in ("*", without_ns(tag.tag)):
type_styles = self.css_type_styles.get(selector)
if type_styles:
apply_rules(type_styles)
if self.css_class_styles:
class_attr = tag.attrib.get("class")
if class_attr:
for class_name in class_attr.split():
class_styles = self.css_class_styles.get(class_name)
if class_styles:
for key, declared in class_styles.items():
value, is_important = _split_important(declared)
if key in important and not is_important:
continue
style_map[key] = value
if is_important:
important.add(key)
apply_rules(class_styles)
inline = _parse_css_declarations(tag.attrib.get("style", ""))
for key, declared in inline.items():
value, is_important = _split_important(declared)
Expand Down
Binary file modified test/svg/generated_pdf/path_clippingpath.pdf
Binary file not shown.
35 changes: 35 additions & 0 deletions test/svg/test_svg.py
Original file line number Diff line number Diff line change
Expand Up @@ -639,6 +639,41 @@ def test_svg_render_content_in_a_tag(self, tmp_path):

assert_pdf_equal(pdf, GENERATED_PDF_DIR / f"{svg_file.stem}.pdf", tmp_path)

@pytest.mark.parametrize(
"css, rect_attributes, fill",
[
("rect { fill: red }", "", "#f00"),
("* { fill: red }", "", "#f00"),
("circle { fill: blue }", 'fill="red"', "#f00"),
# a stylesheet wins over a presentation attribute
("rect { fill: red }", 'fill="blue"', "#f00"),
# specificity: inline style, then class, then element, then *
("rect { fill: blue }", 'style="fill: red"', "#f00"),
(".a { fill: red } rect { fill: blue }", 'class="a"', "#f00"),
("rect { fill: red } * { fill: blue }", "", "#f00"),
# unless the less specific rule is !important
(".a { fill: blue } rect { fill: red !important }", 'class="a"', "#f00"),
],
)
def test_svg_css_type_and_universal_selectors(self, css, rect_attributes, fill):
svg = fpdf.svg.SVGObject(
f'<svg xmlns="http://www.w3.org/2000/svg"><style>{css}</style>'
f'<rect {rect_attributes} width="10" height="10"/></svg>'
)
rect = svg.base_group.path_items[0]
assert rect.style.fill_color == fpdf.drawing.color_from_hex_string(fill)

def test_svg_css_universal_selector_of_matplotlib(self):
# every SVG that matplotlib writes starts with this rule
svg = fpdf.svg.SVGObject(
'<svg xmlns="http://www.w3.org/2000/svg"><style type="text/css">'
"*{stroke-linejoin: round; stroke-linecap: butt}</style>"
'<path d="M0 0L10 10L0 20" style="fill: none; stroke: #1f77b4"/></svg>'
)
path = svg.base_group.path_items[0]
assert path.style.stroke_join_style == fpdf.enums.StrokeJoinStyle.ROUND
assert path.style.stroke_cap_style == fpdf.enums.StrokeCapStyle.BUTT

def test_svg_switch_uses_the_first_unconditional_child(self):
svg_data = """
<svg xmlns="http://www.w3.org/2000/svg">
Expand Down
Loading