svg-ultralight 0.64.0__py3-none-any.whl → 0.73.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -36,10 +36,7 @@ installed on my system.
36
36
  - 7 of 389 have y-bounds differences from Pango, but the line_gap values may still be
37
37
  useful.
38
38
 
39
- - 4 of 389 have x-bounds differences from Pango. A hybrid function `pad_text_mix`
40
- uses the x-bounds from Inkscape/Pango and the y-bounds from this module. The 11
41
- total mismatched font bounds appear to all be from fonts with liguatures, which I
42
- have not implemented.
39
+ - 4 of 389 have x-bounds differences from Pango.
43
40
 
44
41
  I have provided the `check_font_tools_alignment` function to check an existing font
45
42
  for compatilibilty with Inkscape's text layout. If that returns (NO_ERROR, None),
@@ -76,7 +73,7 @@ Disadvantages:
76
73
  test to see how well your bounding boxes fit if you're using an unfamiliar font.
77
74
 
78
75
  - does not support `font-variant`, `font-kerning`, `text-anchor`, and other
79
- attributes that `pad_text` would through Inkscape.
76
+ attributes that `pad_text_inkscape` would through Inkscape.
80
77
 
81
78
  See the padded_text_initializers module for how to create a PaddedText instance using
82
79
  fontTools and this module.
@@ -98,6 +95,7 @@ from __future__ import annotations
98
95
  import functools as ft
99
96
  import itertools as it
100
97
  import logging
98
+ import weakref
101
99
  from contextlib import suppress
102
100
  from pathlib import Path
103
101
  from typing import TYPE_CHECKING, Any, cast
@@ -107,13 +105,10 @@ from fontTools.pens.boundsPen import BoundsPen
107
105
  from fontTools.ttLib import TTFont
108
106
  from paragraphs import par
109
107
  from svg_path_data import format_svgd_shortest, get_cpts_from_svgd, get_svgd_from_cpts
108
+ from typing_extensions import Self
110
109
 
111
110
  from svg_ultralight.bounding_boxes.type_bounding_box import BoundingBox
112
- from svg_ultralight.constructors.new_element import (
113
- new_element,
114
- new_sub_element,
115
- update_element,
116
- )
111
+ from svg_ultralight.constructors.new_element import new_element, new_sub_element
117
112
  from svg_ultralight.strings import svg_matrix
118
113
 
119
114
  if TYPE_CHECKING:
@@ -127,7 +122,7 @@ if TYPE_CHECKING:
127
122
  logging.getLogger("fontTools").setLevel(logging.ERROR)
128
123
 
129
124
 
130
- _ESCAPE_CHARS = {
125
+ DATA_TEXT_ESCAPE_CHARS = {
131
126
  "&": "&",
132
127
  "<": "&lt;",
133
128
  ">": "&gt;",
@@ -144,7 +139,7 @@ def _sanitize_svg_data_text(text: str) -> str:
144
139
  :param text: The input string to sanitize.
145
140
  :return: The sanitized string with XML characters escaped.
146
141
  """
147
- for char, escape_seq in _ESCAPE_CHARS.items():
142
+ for char, escape_seq in DATA_TEXT_ESCAPE_CHARS.items():
148
143
  text = text.replace(char, escape_seq)
149
144
  return text
150
145
 
@@ -289,14 +284,46 @@ class PathPen(BasePen):
289
284
  class FTFontInfo:
290
285
  """Hide all the type kludging necessary to use fontTools."""
291
286
 
292
- def __init__(self, font_path: str | os.PathLike[str]) -> None:
287
+ def __new__(cls, font: str | os.PathLike[str] | Self) -> Self:
288
+ """Create a new FTFontInfo instance as a context manager."""
289
+ if isinstance(font, FTFontInfo):
290
+ return font
291
+ instance = super().__new__(cls)
292
+ _ = weakref.finalize(instance, instance.__close__)
293
+ return instance
294
+
295
+ def __init__(self, font: str | os.PathLike[str] | Self) -> None:
293
296
  """Initialize the SUFont with a path to a TTF font file."""
294
- self._path = Path(font_path)
297
+ if self is font:
298
+ return
299
+ if isinstance(font, FTFontInfo):
300
+ msg = "Unexpected Error, should have been caught in __new__."
301
+ raise TypeError(msg)
302
+ self._ttfont_local_to_instance = True
303
+ self._path = Path(font)
295
304
  if not self.path.exists():
296
305
  msg = f"Font file '{self.path}' does not exist."
297
306
  raise FileNotFoundError(msg)
298
307
  self._font = TTFont(self.path)
299
308
 
309
+ def __close__(self) -> None:
310
+ """Close the font file."""
311
+ self._font.close()
312
+
313
+ def maybe_close(self) -> None:
314
+ """Close the TTFont instance if it is was opened by this instance."""
315
+ if self._ttfont_local_to_instance:
316
+ self.__close__()
317
+
318
+ def __enter__(self) -> Self:
319
+ """Enter the context manager."""
320
+ return self
321
+
322
+ def __exit__(self, exc_type: object, exc_value: object, traceback: object) -> None:
323
+ """Exit the context manager."""
324
+ del exc_type, exc_value, traceback
325
+ self.maybe_close()
326
+
300
327
  @property
301
328
  def path(self) -> Path:
302
329
  """Return the path to the font file."""
@@ -367,6 +394,64 @@ class FTFontInfo:
367
394
  msg = f"Font '{self.path}' does not have a 'hhea' table: {e}"
368
395
  raise ValueError(msg) from e
369
396
 
397
+ @property
398
+ def ascent(self) -> int:
399
+ """Get the ascent for the font.
400
+
401
+ :return: The ascent for the font.
402
+ """
403
+ with suppress(KeyError, AttributeError):
404
+ return self.font["os2"].sTypoAscender
405
+ with suppress(KeyError, AttributeError):
406
+ return self.font["hhea"].ascent
407
+ msg = f"Failed to find ascent for font '{self.path}'."
408
+ raise AttributeError(msg)
409
+
410
+ @property
411
+ def descent(self) -> int:
412
+ """Get the descent for the font.
413
+
414
+ :return: The descent for the font.
415
+ """
416
+ with suppress(KeyError, AttributeError):
417
+ return self.font["os2"].sTypoDescender
418
+ with suppress(KeyError, AttributeError):
419
+ return self.font["hhea"].descent
420
+ msg = f"Failed to find descent for font '{self.path}'."
421
+ raise AttributeError(msg)
422
+
423
+ @property
424
+ def line_gap(self) -> int:
425
+ """Get the cap height for the font.
426
+
427
+ :return: The (often 0) line gap for the font.
428
+ """
429
+ with suppress(KeyError, AttributeError):
430
+ return self.font["os2"].sTypoLineGap
431
+ with suppress(KeyError, AttributeError):
432
+ return self.font["hhea"].lineGap
433
+ return 0
434
+
435
+ @property
436
+ def cap_height(self) -> int:
437
+ """Get the cap height for the font.
438
+
439
+ :return: The cap height for the font.
440
+ """
441
+ with suppress(KeyError, AttributeError):
442
+ return self.font["os2"].sCapHeight
443
+ return self.get_char_bounds("H")[3]
444
+
445
+ @property
446
+ def x_height(self) -> int:
447
+ """Get the x-height for the font.
448
+
449
+ :return: The x-height for the font.
450
+ """
451
+ with suppress(KeyError, AttributeError):
452
+ return self.font["os2"].sxHeight # rare
453
+ return self.get_char_bounds("x")[3]
454
+
370
455
  def get_glyph_name(self, char: str) -> str:
371
456
  """Get the glyph name for a character in the font.
372
457
 
@@ -544,15 +629,25 @@ class FTTextInfo:
544
629
  descent: float | None = None,
545
630
  ) -> None:
546
631
  """Initialize the SUText with text, a SUFont instance, and font size."""
547
- if isinstance(font, FTFontInfo):
548
- self._font = font
549
- else:
550
- self._font = FTFontInfo(font)
551
- self._text = text.rstrip(" ")
632
+ self._font = FTFontInfo(font)
633
+ self._text = text
552
634
  self._font_size = font_size or self._font.units_per_em
553
635
  self._ascent = ascent
554
636
  self._descent = descent
555
637
 
638
+ def __close__(self) -> None:
639
+ """Close the font file."""
640
+ self._font.maybe_close()
641
+
642
+ def __enter__(self) -> Self:
643
+ """Enter the context manager."""
644
+ return self
645
+
646
+ def __exit__(self, exc_type: object, exc_value: object, traceback: object) -> None:
647
+ """Exit the context manager."""
648
+ del exc_type, exc_value, traceback
649
+ self.__close__()
650
+
556
651
  @property
557
652
  def font(self) -> FTFontInfo:
558
653
  """Return the font information."""
@@ -597,21 +692,6 @@ class FTTextInfo:
597
692
  )
598
693
  return group
599
694
 
600
- def new_chars_group_element(self, **attributes: ElemAttrib) -> EtreeElement:
601
- """Return an svg group element with a path for each character in the text."""
602
- names = map(self.font.get_glyph_name, self.text)
603
- svgds = self.font.get_text_svgd_by_char(self.text)
604
- group = new_element("g", **attributes)
605
- for name, svgd in zip(names, svgds, strict=True):
606
- data_text = _sanitize_svg_data_text(name)
607
- _ = new_sub_element(group, "path", data_text=data_text, d=svgd)
608
- matrix_vals = (self.scale, 0, 0, -self.scale, 0, 0)
609
- group.attrib["transform"] = svg_matrix(matrix_vals)
610
- stroke_width = group.attrib.get("stroke-width")
611
- if stroke_width:
612
- _ = update_element(group, stroke_width=float(stroke_width) / self.scale)
613
- return group
614
-
615
695
  @property
616
696
  def bbox(self) -> BoundingBox:
617
697
  """Return the bounding box of the text.
@@ -717,8 +797,9 @@ def get_padded_text_info(
717
797
  :param y_bounds_reference: optional character or string to use as a reference
718
798
  for the ascent and descent. If provided, the ascent and descent will be the y
719
799
  extents of the capline reference. This argument is provided to mimic the
720
- behavior of the query module's `pad_text` function. `pad_text` does no
721
- inspect font files and relies on Inkscape to measure reference characters.
800
+ behavior of the query module's `pad_text_inkscape` function.
801
+ `pad_text_inkscape` does not inspect font files and relies on Inkscape to
802
+ measure reference characters.
722
803
  :return: A FTTextInfo object with the information necessary to create a
723
804
  PaddedText instance: bbox, tpad, rpad, bpad, lpad.
724
805
  """
svg_ultralight/layout.py CHANGED
@@ -6,13 +6,36 @@
6
6
 
7
7
  from __future__ import annotations
8
8
 
9
- from collections.abc import Sequence
10
- from typing import TypeAlias
9
+ from typing import TypeAlias, cast
11
10
 
12
11
  from svg_ultralight.string_conversion import format_number
13
- from svg_ultralight.unit_conversion import Measurement, MeasurementArg
12
+ from svg_ultralight.unit_conversion import (
13
+ Measurement,
14
+ MeasurementArg,
15
+ is_measurement_arg,
16
+ )
14
17
 
15
- PadArg: TypeAlias = float | str | Measurement | Sequence[float | str | Measurement]
18
+ _MeasurementArgs: TypeAlias = (
19
+ tuple[MeasurementArg]
20
+ | tuple[MeasurementArg, MeasurementArg]
21
+ | tuple[MeasurementArg, MeasurementArg, MeasurementArg]
22
+ | tuple[MeasurementArg, MeasurementArg, MeasurementArg, MeasurementArg]
23
+ )
24
+
25
+ PadArg: TypeAlias = MeasurementArg | _MeasurementArgs
26
+
27
+
28
+ def _expand_pad_args(pad: _MeasurementArgs) -> tuple[float, float, float, float]:
29
+ """Transform a tuple of MeasurementArgs to a 4-tuple of user units."""
30
+ as_ms = (m if isinstance(m, Measurement) else Measurement(m) for m in pad)
31
+ as_units = [m.value for m in as_ms]
32
+ if len(as_units) == 1:
33
+ as_units = as_units * 4
34
+ elif len(as_units) == 2:
35
+ as_units = as_units * 2
36
+ elif len(as_units) == 3:
37
+ as_units = [*as_units, as_units[1]]
38
+ return as_units[0], as_units[1], as_units[2], as_units[3]
16
39
 
17
40
 
18
41
  def expand_pad_arg(pad: PadArg) -> tuple[float, float, float, float]:
@@ -39,15 +62,11 @@ def expand_pad_arg(pad: PadArg) -> tuple[float, float, float, float]:
39
62
  >>> expand_pad_arg((Measurement("1in"), Measurement("2in")))
40
63
  (96.0, 192.0, 96.0, 192.0)
41
64
  """
42
- if isinstance(pad, str) or not isinstance(pad, Sequence):
43
- return expand_pad_arg([pad])
44
- as_ms = [m if isinstance(m, Measurement) else Measurement(m) for m in pad]
45
- as_units = [m.value for m in as_ms]
46
- if len(as_units) == 3:
47
- as_units = [*as_units, as_units[1]]
48
- else:
49
- as_units = [as_units[i % len(as_units)] for i in range(4)]
50
- return as_units[0], as_units[1], as_units[2], as_units[3]
65
+ if is_measurement_arg(pad):
66
+ pads = cast("_MeasurementArgs", (pad,))
67
+ return _expand_pad_args(pads)
68
+ pads = cast("_MeasurementArgs", pad)
69
+ return _expand_pad_args(pads)
51
70
 
52
71
 
53
72
  def pad_viewbox(
@@ -136,7 +155,7 @@ def _infer_scale(
136
155
 
137
156
  def pad_and_scale(
138
157
  viewbox: tuple[float, float, float, float],
139
- pad: PadArg,
158
+ pad: PadArg = 0,
140
159
  print_width: MeasurementArg | None = None,
141
160
  print_height: MeasurementArg | None = None,
142
161
  dpu: float = 1,
@@ -222,10 +241,10 @@ def pad_and_scale(
222
241
  so there is no additional information supplied by giving both, but I've
223
242
  had unexpected behavior from pandoc when one was missing.
224
243
 
225
- * If only a unit is given, (e.g., "pt"), the user units (viewbox width and
226
- height) will be interpreted as that unit. This is important for InDesign,
227
- which may not display in image at all if the width and height are not
228
- explicitly "pt".
244
+ * If only a unit is given, (e.g., Unit.PT), the user units (viewbox width and
245
+ height) will be interpreted as that unit. This is important for InDesign, which
246
+ may not display an image at all if the width and height are not explicitly
247
+ "pt".
229
248
 
230
249
  * Print ratios are discarded. The viwebox ratio is preserved. For instance,
231
250
  if the viewbox is (0, 0, 16, 9), giving a 16:9 aspect ratio and the
svg_ultralight/main.py CHANGED
@@ -19,9 +19,10 @@ from typing import IO, TYPE_CHECKING, TypeGuard
19
19
  from lxml import etree
20
20
 
21
21
  from svg_ultralight.constructors import update_element
22
- from svg_ultralight.layout import pad_and_scale
22
+ from svg_ultralight.layout import PadArg, pad_and_scale
23
23
  from svg_ultralight.nsmap import NSMAP
24
24
  from svg_ultralight.string_conversion import get_view_box_str, svg_tostring
25
+ from svg_ultralight.unit_conversion import MeasurementArg, to_svg_str, to_user_units
25
26
 
26
27
  if TYPE_CHECKING:
27
28
  import os
@@ -31,7 +32,6 @@ if TYPE_CHECKING:
31
32
  )
32
33
 
33
34
  from svg_ultralight.attrib_hints import ElemAttrib, OptionalElemAttribMapping
34
- from svg_ultralight.layout import PadArg
35
35
 
36
36
 
37
37
  def _is_io_bytes(obj: object) -> TypeGuard[IO[bytes]]:
@@ -44,15 +44,15 @@ def _is_io_bytes(obj: object) -> TypeGuard[IO[bytes]]:
44
44
 
45
45
 
46
46
  def new_svg_root(
47
- x_: float | None = None,
48
- y_: float | None = None,
49
- width_: float | None = None,
50
- height_: float | None = None,
47
+ x_: MeasurementArg | None = None,
48
+ y_: MeasurementArg | None = None,
49
+ width_: MeasurementArg | None = None,
50
+ height_: MeasurementArg | None = None,
51
51
  *,
52
52
  pad_: PadArg = 0,
53
- print_width_: float | str | None = None,
54
- print_height_: float | str | None = None,
55
- dpu_: float = 1,
53
+ print_width_: MeasurementArg | None = None,
54
+ print_height_: MeasurementArg | None = None,
55
+ dpu_: float | None = None,
56
56
  nsmap: dict[str | None, str] | None = None,
57
57
  attrib: OptionalElemAttribMapping | None = None,
58
58
  **attributes: ElemAttrib,
@@ -92,17 +92,27 @@ def new_svg_root(
92
92
  nsmap = NSMAP
93
93
 
94
94
  inferred_attribs: dict[str, ElemAttrib] = {}
95
- if (
96
- isinstance(x_, (float, int))
97
- and isinstance(y_, (float, int))
98
- and isinstance(width_, (float, int))
99
- and isinstance(height_, (float, int))
100
- ):
95
+ if x_ is not None and y_ is not None and width_ is not None and height_ is not None:
96
+ dpu = dpu_ or 1
97
+ x_, y_, width_, height_ = map(to_user_units, (x_, y_, width_, height_))
101
98
  padded_viewbox, scale_attribs = pad_and_scale(
102
- (x_, y_, width_, height_), pad_, print_width_, print_height_, dpu_
99
+ (x_, y_, width_, height_), pad_, print_width_, print_height_, dpu
103
100
  )
104
101
  inferred_attribs["viewBox"] = get_view_box_str(*padded_viewbox)
105
102
  inferred_attribs.update(scale_attribs)
103
+ elif (
104
+ x_ is None
105
+ and y_ is None
106
+ and (width_ or print_width_) is not None
107
+ and (height_ or print_height_) is not None
108
+ and pad_ == 0
109
+ and dpu_ is None
110
+ ):
111
+ width = print_width_ if width_ is None else width_
112
+ height = print_height_ if height_ is None else height_
113
+ inferred_attribs["width"] = to_svg_str(width or 0)
114
+ inferred_attribs["height"] = to_svg_str(height or 0)
115
+
106
116
  inferred_attribs.update(attributes)
107
117
  # can only pass nsmap on instance creation
108
118
  svg_root = etree.Element(f"{{{nsmap[None]}}}svg", nsmap=nsmap)
@@ -11,6 +11,7 @@ from typing import TYPE_CHECKING
11
11
 
12
12
  from svg_ultralight.bounding_boxes import bound_helpers as bound
13
13
  from svg_ultralight.bounding_boxes.type_bounding_box import BoundingBox
14
+ from svg_ultralight.constructors.new_element import new_element_union
14
15
  from svg_ultralight.main import new_svg_root
15
16
 
16
17
  if TYPE_CHECKING:
@@ -21,6 +22,7 @@ if TYPE_CHECKING:
21
22
  from svg_ultralight.attrib_hints import ElemAttrib, OptionalElemAttribMapping
22
23
  from svg_ultralight.bounding_boxes.supports_bounds import SupportsBounds
23
24
  from svg_ultralight.layout import PadArg
25
+ from svg_ultralight.unit_conversion import MeasurementArg
24
26
 
25
27
 
26
28
  def _viewbox_args_from_bboxes(*bboxes: BoundingBox) -> dict[str, float]:
@@ -41,9 +43,9 @@ def _viewbox_args_from_bboxes(*bboxes: BoundingBox) -> dict[str, float]:
41
43
  def new_svg_root_around_bounds(
42
44
  *bounded: SupportsBounds | EtreeElement,
43
45
  pad_: PadArg = 0,
44
- print_width_: float | str | None = None,
45
- print_height_: float | str | None = None,
46
- dpu_: float = 1,
46
+ print_width_: MeasurementArg | None = None,
47
+ print_height_: MeasurementArg | None = None,
48
+ dpu_: float | None = None,
47
49
  nsmap: dict[str | None, str] | None = None,
48
50
  attrib: OptionalElemAttribMapping = None,
49
51
  **attributes: ElemAttrib,
@@ -75,7 +77,7 @@ def new_svg_root_around_bounds(
75
77
  bbox = bound.new_bbox_union(*bounded)
76
78
  elem: EtreeElement | None = None
77
79
  with suppress(ValueError):
78
- elem = bound.new_element_union(*bounded)
80
+ elem = new_element_union(*bounded)
79
81
  viewbox = _viewbox_args_from_bboxes(bbox)
80
82
  root = new_svg_root(
81
83
  x_=viewbox["x_"],
@@ -10,9 +10,10 @@ Rounding some numbers to ensure quality svg rendering:
10
10
  from __future__ import annotations
11
11
 
12
12
  import binascii
13
+ import itertools as it
13
14
  import re
14
15
  from enum import Enum
15
- from typing import TYPE_CHECKING, cast
16
+ from typing import TYPE_CHECKING, Literal, cast
16
17
 
17
18
  import svg_path_data
18
19
  from lxml import etree
@@ -20,7 +21,7 @@ from lxml import etree
20
21
  from svg_ultralight.nsmap import NSMAP
21
22
 
22
23
  if TYPE_CHECKING:
23
- from collections.abc import Iterable
24
+ from collections.abc import Iterable, Iterator
24
25
 
25
26
  from lxml.etree import (
26
27
  _Element as EtreeElement, # pyright: ignore[reportPrivateUsage]
@@ -29,6 +30,10 @@ if TYPE_CHECKING:
29
30
  from svg_ultralight.attrib_hints import ElemAttrib
30
31
 
31
32
 
33
+ # match a hex color string with 8 digits: #RRGGBBAA
34
+ _HEX_COLOR_8DIGIT = re.compile(r"^#([0-9a-fA-F]{8})$")
35
+
36
+
32
37
  def format_number(num: float | str, resolution: int | None = 6) -> str:
33
38
  """Format a number into an svg-readable float string with resolution = 6.
34
39
 
@@ -52,7 +57,24 @@ def format_numbers(
52
57
  return [format_number(num) for num in nums]
53
58
 
54
59
 
55
- def _fix_key_and_format_val(key: str, val: ElemAttrib) -> tuple[str, str]:
60
+ def _split_opacity(
61
+ prefix: Literal["fill", "stroke"], hex_color: str
62
+ ) -> Iterator[tuple[str, str]]:
63
+ """Get a fill and fill-opacity or stroke and stroke-opacity for an svg element.
64
+
65
+ :param prefix: either "fill" or "stroke"
66
+ :param color: an 8-digit hex color with leading # ("#RRGGBBAA")
67
+ :yield: tuples of (attribute name, attribute value)
68
+ """
69
+ rgb, opacity = hex_color[:7], hex_color[7:]
70
+ if opacity == "00":
71
+ yield (prefix, "none")
72
+ else:
73
+ yield (prefix, rgb)
74
+ yield f"{prefix}-opacity", format_number(int(opacity, 16) / 255)
75
+
76
+
77
+ def _fix_key_and_format_val(key: str, val: ElemAttrib) -> Iterator[tuple[str, str]]:
56
78
  """Format one key, value pair for an svg element.
57
79
 
58
80
  :param key: element attribute name
@@ -64,17 +86,18 @@ def _fix_key_and_format_val(key: str, val: ElemAttrib) -> tuple[str, str]:
64
86
  values. This saves having to convert input to strings.
65
87
 
66
88
  * convert float values to formatted strings
67
- * format datastring values when keyword is 'd'
68
89
  * replace '_' with '-' in keywords
69
90
  * remove trailing '_' from keywords
70
91
  * will convert `namespace:tag` to a qualified name
92
+ * will convert 8-digit hex colors to color + opacity. SVG supports 8-digit hex
93
+ colors, but Inkscape (and likely other Linux-based SVG rasterizers) do not.
71
94
 
72
95
  SVG attribute names like `font-size` and `stroke-width` are not valid python
73
- keywords, but can be passed as `font-size` and `stroke-width`.
96
+ keywords, but can be passed as `font_size` and `stroke_width`.
74
97
 
75
98
  Reserved Python keywords that are also valid and useful SVG attribute names (a
76
99
  popular one will be 'class') can be passed with a trailing underscore (e.g.,
77
- class_='body_text').
100
+ class_='body_text') to keep your code highlighter from getting confused.
78
101
  """
79
102
  if "http:" in key or "https:" in key:
80
103
  key_ = key
@@ -88,10 +111,18 @@ def _fix_key_and_format_val(key: str, val: ElemAttrib) -> tuple[str, str]:
88
111
  val_ = "none"
89
112
  elif isinstance(val, (int, float)):
90
113
  val_ = format_number(val)
114
+ elif _HEX_COLOR_8DIGIT.match(val):
115
+ if key_ == "fill":
116
+ yield from _split_opacity("fill", val)
117
+ return
118
+ if key_ == "stroke":
119
+ yield from _split_opacity("stroke", val)
120
+ return
121
+ val_ = val
91
122
  else:
92
123
  val_ = val
93
124
 
94
- return key_, val_
125
+ yield key_, val_
95
126
 
96
127
 
97
128
  def format_attr_dict(**attributes: ElemAttrib) -> dict[str, str]:
@@ -100,7 +131,8 @@ def format_attr_dict(**attributes: ElemAttrib) -> dict[str, str]:
100
131
  :param attributes: element attribute names and values.
101
132
  :return: dict of attributes, each key a valid svg attribute name, each value a str
102
133
  """
103
- return dict(_fix_key_and_format_val(key, val) for key, val in attributes.items())
134
+ items = attributes.items()
135
+ return dict(it.chain(*(_fix_key_and_format_val(k, v) for k, v in items)))
104
136
 
105
137
 
106
138
  def set_attributes(elem: EtreeElement, **attributes: ElemAttrib) -> None: