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.
- svg_ultralight/__init__.py +8 -6
- svg_ultralight/bounding_boxes/bound_helpers.py +37 -50
- svg_ultralight/bounding_boxes/padded_text_initializers.py +148 -182
- svg_ultralight/bounding_boxes/type_bounding_box.py +31 -14
- svg_ultralight/bounding_boxes/type_padded_list.py +2 -7
- svg_ultralight/bounding_boxes/type_padded_text.py +240 -53
- svg_ultralight/constructors/new_element.py +37 -3
- svg_ultralight/font_tools/comp_results.py +18 -18
- svg_ultralight/font_tools/font_info.py +117 -36
- svg_ultralight/layout.py +37 -18
- svg_ultralight/main.py +26 -16
- svg_ultralight/root_elements.py +6 -4
- svg_ultralight/string_conversion.py +40 -8
- svg_ultralight/unit_conversion.py +104 -27
- {svg_ultralight-0.64.0.dist-info → svg_ultralight-0.73.1.dist-info}/METADATA +1 -1
- {svg_ultralight-0.64.0.dist-info → svg_ultralight-0.73.1.dist-info}/RECORD +17 -18
- svg_ultralight/read_svg.py +0 -58
- {svg_ultralight-0.64.0.dist-info → svg_ultralight-0.73.1.dist-info}/WHEEL +0 -0
|
@@ -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.
|
|
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 `
|
|
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
|
-
|
|
125
|
+
DATA_TEXT_ESCAPE_CHARS = {
|
|
131
126
|
"&": "&",
|
|
132
127
|
"<": "<",
|
|
133
128
|
">": ">",
|
|
@@ -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
|
|
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
|
|
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
|
|
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
|
-
|
|
548
|
-
|
|
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 `
|
|
721
|
-
inspect font files and relies on Inkscape to
|
|
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
|
|
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
|
|
12
|
+
from svg_ultralight.unit_conversion import (
|
|
13
|
+
Measurement,
|
|
14
|
+
MeasurementArg,
|
|
15
|
+
is_measurement_arg,
|
|
16
|
+
)
|
|
14
17
|
|
|
15
|
-
|
|
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
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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.,
|
|
226
|
-
height) will be interpreted as that unit. This is important for InDesign,
|
|
227
|
-
|
|
228
|
-
|
|
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_:
|
|
48
|
-
y_:
|
|
49
|
-
width_:
|
|
50
|
-
height_:
|
|
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_:
|
|
54
|
-
print_height_:
|
|
55
|
-
dpu_: float =
|
|
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
|
-
|
|
97
|
-
|
|
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_,
|
|
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)
|
svg_ultralight/root_elements.py
CHANGED
|
@@ -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_:
|
|
45
|
-
print_height_:
|
|
46
|
-
dpu_: float =
|
|
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 =
|
|
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
|
|
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 `
|
|
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
|
-
|
|
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
|
-
|
|
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:
|