svg-ultralight 0.39.1__py3-none-any.whl → 0.40.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.
Potentially problematic release.
This version of svg-ultralight might be problematic. Click here for more details.
- svg_ultralight/__init__.py +9 -1
- svg_ultralight/animate.py +3 -3
- svg_ultralight/bounding_boxes/padded_text_initializers.py +184 -0
- svg_ultralight/bounding_boxes/type_bounding_box.py +12 -0
- svg_ultralight/bounding_boxes/type_padded_text.py +38 -0
- svg_ultralight/font_tools/__init__.py +5 -0
- svg_ultralight/font_tools/comp_results.py +283 -0
- svg_ultralight/font_tools/font_css.py +82 -0
- svg_ultralight/font_tools/font_info.py +567 -0
- svg_ultralight/font_tools/globs.py +7 -0
- svg_ultralight/image_ops.py +4 -2
- svg_ultralight/inkscape.py +29 -18
- svg_ultralight/main.py +5 -3
- svg_ultralight/query.py +8 -37
- svg_ultralight/string_conversion.py +69 -1
- {svg_ultralight-0.39.1.dist-info → svg_ultralight-0.40.1.dist-info}/METADATA +4 -2
- svg_ultralight-0.40.1.dist-info/RECORD +35 -0
- {svg_ultralight-0.39.1.dist-info → svg_ultralight-0.40.1.dist-info}/WHEEL +1 -1
- svg_ultralight-0.39.1.dist-info/RECORD +0 -29
- {svg_ultralight-0.39.1.dist-info → svg_ultralight-0.40.1.dist-info}/top_level.txt +0 -0
svg_ultralight/__init__.py
CHANGED
|
@@ -14,6 +14,11 @@ from svg_ultralight.bounding_boxes.bound_helpers import (
|
|
|
14
14
|
pad_bbox,
|
|
15
15
|
parse_bound_element,
|
|
16
16
|
)
|
|
17
|
+
from svg_ultralight.bounding_boxes.padded_text_initializers import (
|
|
18
|
+
pad_text,
|
|
19
|
+
pad_text_ft,
|
|
20
|
+
pad_text_mix,
|
|
21
|
+
)
|
|
17
22
|
from svg_ultralight.bounding_boxes.supports_bounds import SupportsBounds
|
|
18
23
|
from svg_ultralight.bounding_boxes.type_bound_collection import BoundCollection
|
|
19
24
|
from svg_ultralight.bounding_boxes.type_bound_element import BoundElement
|
|
@@ -25,6 +30,7 @@ from svg_ultralight.constructors.new_element import (
|
|
|
25
30
|
new_sub_element,
|
|
26
31
|
update_element,
|
|
27
32
|
)
|
|
33
|
+
from svg_ultralight.font_tools.comp_results import check_font_tools_alignment
|
|
28
34
|
from svg_ultralight.inkscape import (
|
|
29
35
|
write_pdf,
|
|
30
36
|
write_pdf_from_svg,
|
|
@@ -39,7 +45,6 @@ from svg_ultralight.query import (
|
|
|
39
45
|
clear_svg_ultralight_cache,
|
|
40
46
|
get_bounding_box,
|
|
41
47
|
get_bounding_boxes,
|
|
42
|
-
pad_text,
|
|
43
48
|
)
|
|
44
49
|
from svg_ultralight.root_elements import new_svg_root_around_bounds
|
|
45
50
|
from svg_ultralight.string_conversion import (
|
|
@@ -63,6 +68,7 @@ __all__ = [
|
|
|
63
68
|
"PaddedText",
|
|
64
69
|
"SupportsBounds",
|
|
65
70
|
"bbox_dict",
|
|
71
|
+
"check_font_tools_alignment",
|
|
66
72
|
"clear_svg_ultralight_cache",
|
|
67
73
|
"cut_bbox",
|
|
68
74
|
"deepcopy_element",
|
|
@@ -87,6 +93,8 @@ __all__ = [
|
|
|
87
93
|
"new_svg_root_around_bounds",
|
|
88
94
|
"pad_bbox",
|
|
89
95
|
"pad_text",
|
|
96
|
+
"pad_text_ft",
|
|
97
|
+
"pad_text_mix",
|
|
90
98
|
"parse_bound_element",
|
|
91
99
|
"transform_element",
|
|
92
100
|
"update_element",
|
svg_ultralight/animate.py
CHANGED
|
@@ -16,13 +16,13 @@ except ModuleNotFoundError as exc:
|
|
|
16
16
|
from typing import TYPE_CHECKING
|
|
17
17
|
|
|
18
18
|
if TYPE_CHECKING:
|
|
19
|
+
import os
|
|
19
20
|
from collections.abc import Iterable
|
|
20
|
-
from pathlib import Path
|
|
21
21
|
|
|
22
22
|
|
|
23
23
|
def write_gif(
|
|
24
|
-
gif: str |
|
|
25
|
-
pngs: Iterable[str] | Iterable[
|
|
24
|
+
gif: str | os.PathLike[str],
|
|
25
|
+
pngs: Iterable[str] | Iterable[os.PathLike[str]] | Iterable[str | os.PathLike[str]],
|
|
26
26
|
duration: float = 100,
|
|
27
27
|
loop: int = 0,
|
|
28
28
|
) -> None:
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""Functions that create PaddedText instances.
|
|
2
|
+
|
|
3
|
+
Three variants:
|
|
4
|
+
|
|
5
|
+
- `pad_text`: uses Inkscape to measure text bounds
|
|
6
|
+
|
|
7
|
+
- `pad_text_ft`: uses fontTools to measure text bounds (faster, and you get line_gap)
|
|
8
|
+
|
|
9
|
+
- `pad_text_mix`: uses Inkscape and fontTools to give true ascent, descent, and
|
|
10
|
+
line_gap while correcting some of the layout differences between fontTools and
|
|
11
|
+
Inkscape.
|
|
12
|
+
|
|
13
|
+
:author: Shay Hill
|
|
14
|
+
:created: 2025-06-09
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from copy import deepcopy
|
|
20
|
+
from typing import TYPE_CHECKING
|
|
21
|
+
|
|
22
|
+
from svg_ultralight.bounding_boxes.type_padded_text import PaddedText
|
|
23
|
+
from svg_ultralight.constructors import new_element, update_element
|
|
24
|
+
from svg_ultralight.font_tools.font_info import (
|
|
25
|
+
get_padded_text_info,
|
|
26
|
+
get_svg_font_attributes,
|
|
27
|
+
)
|
|
28
|
+
from svg_ultralight.font_tools.globs import DEFAULT_FONT_SIZE
|
|
29
|
+
from svg_ultralight.query import get_bounding_boxes
|
|
30
|
+
from svg_ultralight.string_conversion import (
|
|
31
|
+
format_attr_dict,
|
|
32
|
+
format_number,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
if TYPE_CHECKING:
|
|
36
|
+
import os
|
|
37
|
+
|
|
38
|
+
from lxml.etree import (
|
|
39
|
+
_Element as EtreeElement, # pyright: ignore[reportPrivateUsage]
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
DEFAULT_Y_BOUNDS_REFERENCE = "{[|gjpqyf"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def pad_text(
|
|
46
|
+
inkscape: str | os.PathLike[str],
|
|
47
|
+
text_elem: EtreeElement,
|
|
48
|
+
y_bounds_reference: str | None = None,
|
|
49
|
+
*,
|
|
50
|
+
font: str | os.PathLike[str] | None = None,
|
|
51
|
+
) -> PaddedText:
|
|
52
|
+
r"""Create a PaddedText instance from a text element.
|
|
53
|
+
|
|
54
|
+
:param inkscape: path to an inkscape executable on your local file system
|
|
55
|
+
IMPORTANT: path cannot end with ``.exe``.
|
|
56
|
+
Use something like ``"C:\\Program Files\\Inkscape\\inkscape"``
|
|
57
|
+
:param text_elem: an etree element with a text tag
|
|
58
|
+
:param y_bounds_reference: an optional string to use to determine the ascent and
|
|
59
|
+
capline of the font. The default is a good choice, which approaches or even
|
|
60
|
+
meets the ascent of descent of most fonts without using utf-8 characters. You
|
|
61
|
+
might want to use a letter like "M" or even "x" if you are using an all-caps
|
|
62
|
+
string and want to center between the capline and baseline or if you'd like
|
|
63
|
+
to center between the baseline and x-line.
|
|
64
|
+
:param font: optionally add a path to a font file to use for the text element.
|
|
65
|
+
This is going to conflict with any font-family, font-style, or other
|
|
66
|
+
font-related attributes *except* font-size. You likely want to use
|
|
67
|
+
`font_tools.new_padded_text` if you're going to pass a font path, but you can
|
|
68
|
+
use it here to compare results between `pad_text` and `new_padded_text`.
|
|
69
|
+
:return: a PaddedText instance
|
|
70
|
+
"""
|
|
71
|
+
if y_bounds_reference is None:
|
|
72
|
+
y_bounds_reference = DEFAULT_Y_BOUNDS_REFERENCE
|
|
73
|
+
if font is not None:
|
|
74
|
+
_ = update_element(text_elem, **get_svg_font_attributes(font))
|
|
75
|
+
if "font-size" not in text_elem.attrib:
|
|
76
|
+
text_elem.attrib["font-size"] = format_number(DEFAULT_FONT_SIZE)
|
|
77
|
+
rmargin_ref = deepcopy(text_elem)
|
|
78
|
+
capline_ref = deepcopy(text_elem)
|
|
79
|
+
_ = rmargin_ref.attrib.pop("id", None)
|
|
80
|
+
_ = capline_ref.attrib.pop("id", None)
|
|
81
|
+
rmargin_ref.attrib["text-anchor"] = "end"
|
|
82
|
+
capline_ref.text = y_bounds_reference
|
|
83
|
+
|
|
84
|
+
bboxes = get_bounding_boxes(inkscape, text_elem, rmargin_ref, capline_ref)
|
|
85
|
+
bbox, rmargin_bbox, capline_bbox = bboxes
|
|
86
|
+
|
|
87
|
+
tpad = bbox.y - capline_bbox.y
|
|
88
|
+
rpad = -rmargin_bbox.x2
|
|
89
|
+
bpad = capline_bbox.y2 - bbox.y2
|
|
90
|
+
lpad = bbox.x
|
|
91
|
+
return PaddedText(text_elem, bbox, tpad, rpad, bpad, lpad)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def pad_text_ft(
|
|
95
|
+
font: str | os.PathLike[str],
|
|
96
|
+
text: str,
|
|
97
|
+
font_size: float = DEFAULT_FONT_SIZE,
|
|
98
|
+
ascent: float | None = None,
|
|
99
|
+
descent: float | None = None,
|
|
100
|
+
*,
|
|
101
|
+
y_bounds_reference: str | None = None,
|
|
102
|
+
**attributes: str | float,
|
|
103
|
+
) -> PaddedText:
|
|
104
|
+
"""Create a new PaddedText instance using fontTools.
|
|
105
|
+
|
|
106
|
+
:param font: path to a font file.
|
|
107
|
+
:param text: the text of the text element.
|
|
108
|
+
:param font_size: the font size to use.
|
|
109
|
+
:param ascent: the ascent of the font. If not provided, it will be calculated
|
|
110
|
+
from the font file.
|
|
111
|
+
:param descent: the descent of the font. If not provided, it will be calculated
|
|
112
|
+
from the font file.
|
|
113
|
+
:param y_bounds_reference: optional character or string to use as a reference
|
|
114
|
+
for the ascent and descent. If provided, the ascent and descent will be the y
|
|
115
|
+
extents of the capline reference. This argument is provided to mimic the
|
|
116
|
+
behavior of the query module's `pad_text` function. `pad_text` does no
|
|
117
|
+
inspect font files and relies on Inkscape to measure reference characters.
|
|
118
|
+
:param attributes: additional attributes to set on the text element. There is a
|
|
119
|
+
chance these will cause the font element to exceed the BoundingBox of the
|
|
120
|
+
PaddedText instance.
|
|
121
|
+
:return: a PaddedText instance with a line_gap defined.
|
|
122
|
+
"""
|
|
123
|
+
attributes_ = format_attr_dict(**attributes)
|
|
124
|
+
attributes_.update(get_svg_font_attributes(font))
|
|
125
|
+
attributes_["font-size"] = attributes_.get("font-size", format_number(font_size))
|
|
126
|
+
|
|
127
|
+
elem = new_element("text", text=text, **attributes_)
|
|
128
|
+
info = get_padded_text_info(
|
|
129
|
+
font, text, font_size, ascent, descent, y_bounds_reference=y_bounds_reference
|
|
130
|
+
)
|
|
131
|
+
return PaddedText(elem, info.bbox, *info.padding, info.line_gap)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def pad_text_mix(
|
|
135
|
+
inkscape: str | os.PathLike[str],
|
|
136
|
+
font: str | os.PathLike[str],
|
|
137
|
+
text: str,
|
|
138
|
+
font_size: float = DEFAULT_FONT_SIZE,
|
|
139
|
+
ascent: float | None = None,
|
|
140
|
+
descent: float | None = None,
|
|
141
|
+
*,
|
|
142
|
+
y_bounds_reference: str | None = None,
|
|
143
|
+
**attributes: str | float,
|
|
144
|
+
) -> PaddedText:
|
|
145
|
+
"""Use Inkscape text bounds and fill missing with fontTools.
|
|
146
|
+
|
|
147
|
+
:param font: path to a font file.
|
|
148
|
+
:param text: the text of the text element.
|
|
149
|
+
:param font_size: the font size to use.
|
|
150
|
+
:param ascent: the ascent of the font. If not provided, it will be calculated
|
|
151
|
+
from the font file.
|
|
152
|
+
:param descent: the descent of the font. If not provided, it will be calculated
|
|
153
|
+
from the font file.
|
|
154
|
+
:param y_bounds_reference: optional character or string to use as a reference
|
|
155
|
+
for the ascent and descent. If provided, the ascent and descent will be the y
|
|
156
|
+
extents of the capline reference. This argument is provided to mimic the
|
|
157
|
+
behavior of the query module's `pad_text` function. `pad_text` does no
|
|
158
|
+
inspect font files and relies on Inkscape to measure reference characters.
|
|
159
|
+
:param attributes: additional attributes to set on the text element. There is a
|
|
160
|
+
chance these will cause the font element to exceed the BoundingBox of the
|
|
161
|
+
PaddedText instance.
|
|
162
|
+
:return: a PaddedText instance with a line_gap defined.
|
|
163
|
+
"""
|
|
164
|
+
elem = new_element("text", text=text, **attributes)
|
|
165
|
+
padded_inkscape = pad_text(inkscape, elem, y_bounds_reference, font=font)
|
|
166
|
+
padded_fonttools = pad_text_ft(
|
|
167
|
+
font,
|
|
168
|
+
text,
|
|
169
|
+
font_size,
|
|
170
|
+
ascent,
|
|
171
|
+
descent,
|
|
172
|
+
y_bounds_reference=y_bounds_reference,
|
|
173
|
+
**attributes,
|
|
174
|
+
)
|
|
175
|
+
bbox = padded_inkscape.unpadded_bbox
|
|
176
|
+
rpad = padded_inkscape.rpad
|
|
177
|
+
lpad = padded_inkscape.lpad
|
|
178
|
+
if y_bounds_reference is None:
|
|
179
|
+
tpad = padded_fonttools.tpad
|
|
180
|
+
bpad = padded_fonttools.bpad
|
|
181
|
+
else:
|
|
182
|
+
tpad = padded_inkscape.tpad
|
|
183
|
+
bpad = padded_inkscape.bpad
|
|
184
|
+
return PaddedText(elem, bbox, tpad, rpad, bpad, lpad, padded_fonttools.line_gap)
|
|
@@ -41,6 +41,18 @@ class HasBoundingBox(SupportsBounds):
|
|
|
41
41
|
y2 = y + self.bbox.base_height
|
|
42
42
|
return (x, y), (x2, y), (x2, y2), (x, y2)
|
|
43
43
|
|
|
44
|
+
def values(self) -> tuple[float, float, float, float]:
|
|
45
|
+
"""Get the values of the bounding box.
|
|
46
|
+
|
|
47
|
+
:return: x, y, width, height of the bounding box
|
|
48
|
+
"""
|
|
49
|
+
return (
|
|
50
|
+
self.bbox.x,
|
|
51
|
+
self.bbox.y,
|
|
52
|
+
self.bbox.width,
|
|
53
|
+
self.bbox.height,
|
|
54
|
+
)
|
|
55
|
+
|
|
44
56
|
def _get_transformed_corners(
|
|
45
57
|
self,
|
|
46
58
|
) -> tuple[
|
|
@@ -66,6 +66,8 @@ from __future__ import annotations
|
|
|
66
66
|
|
|
67
67
|
from typing import TYPE_CHECKING
|
|
68
68
|
|
|
69
|
+
from paragraphs import par
|
|
70
|
+
|
|
69
71
|
from svg_ultralight.bounding_boxes.type_bound_element import BoundElement
|
|
70
72
|
from svg_ultralight.bounding_boxes.type_bounding_box import BoundingBox
|
|
71
73
|
from svg_ultralight.transformations import new_transformation_matrix, transform_element
|
|
@@ -77,6 +79,14 @@ if TYPE_CHECKING:
|
|
|
77
79
|
|
|
78
80
|
_Matrix = tuple[float, float, float, float, float, float]
|
|
79
81
|
|
|
82
|
+
_no_line_gap_msg = par(
|
|
83
|
+
"""No line_gap defined. Line gap is an inherent font attribute defined within a
|
|
84
|
+
font file. If this PaddedText instance was created with `pad_text` from reference
|
|
85
|
+
elements, a line_gap was not defined. Reading line_gap from the font file
|
|
86
|
+
requires creating a PaddedText instance with `pad_text_ft` or `pad_text_mixed`.
|
|
87
|
+
You can set an arbitrary line_gap after init with `instance.line_gap = value`."""
|
|
88
|
+
)
|
|
89
|
+
|
|
80
90
|
|
|
81
91
|
class PaddedText(BoundElement):
|
|
82
92
|
"""A line of text with a bounding box and padding."""
|
|
@@ -89,6 +99,7 @@ class PaddedText(BoundElement):
|
|
|
89
99
|
rpad: float,
|
|
90
100
|
bpad: float,
|
|
91
101
|
lpad: float,
|
|
102
|
+
line_gap: float | None = None,
|
|
92
103
|
) -> None:
|
|
93
104
|
"""Initialize a PaddedText instance.
|
|
94
105
|
|
|
@@ -105,6 +116,7 @@ class PaddedText(BoundElement):
|
|
|
105
116
|
self.rpad = rpad
|
|
106
117
|
self.base_bpad = bpad
|
|
107
118
|
self.lpad = lpad
|
|
119
|
+
self._line_gap = line_gap
|
|
108
120
|
|
|
109
121
|
@property
|
|
110
122
|
def bbox(self) -> BoundingBox:
|
|
@@ -153,6 +165,32 @@ class PaddedText(BoundElement):
|
|
|
153
165
|
self.unpadded_bbox.transform(tmat)
|
|
154
166
|
_ = transform_element(self.elem, tmat)
|
|
155
167
|
|
|
168
|
+
@property
|
|
169
|
+
def line_gap(self) -> float:
|
|
170
|
+
"""The line gap between this line of text and the next.
|
|
171
|
+
|
|
172
|
+
:return: The line gap between this line of text and the next.
|
|
173
|
+
"""
|
|
174
|
+
if self._line_gap is None:
|
|
175
|
+
raise AttributeError(_no_line_gap_msg)
|
|
176
|
+
return self._line_gap
|
|
177
|
+
|
|
178
|
+
@line_gap.setter
|
|
179
|
+
def line_gap(self, value: float) -> None:
|
|
180
|
+
"""Set the line gap between this line of text and the next.
|
|
181
|
+
|
|
182
|
+
:param value: The new line gap.
|
|
183
|
+
"""
|
|
184
|
+
self._line_gap = value
|
|
185
|
+
|
|
186
|
+
@property
|
|
187
|
+
def leading(self) -> float:
|
|
188
|
+
"""The leading of this line of text.
|
|
189
|
+
|
|
190
|
+
:return: The line gap plus the height of this line of text.
|
|
191
|
+
"""
|
|
192
|
+
return self.height + self.line_gap
|
|
193
|
+
|
|
156
194
|
@property
|
|
157
195
|
def tpad(self) -> float:
|
|
158
196
|
"""The top padding of this line of text.
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
"""Compare results between Inkscape and fontTools.
|
|
2
|
+
|
|
3
|
+
Function `check_font_tools_alignment` will let you know if it's relatively safe to
|
|
4
|
+
use `pad_text_mix` or `pad_text_ft`, which improve `pad_text` by assigning `line_gap`
|
|
5
|
+
values to the resulting PaddedText instance and by aligning with the actual descent
|
|
6
|
+
and ascent of a font instead of by attempting to infer these from a referenve string.
|
|
7
|
+
|
|
8
|
+
See Enum `FontBboxError` for the possible error codes and their meanings returned by
|
|
9
|
+
`check_font`.
|
|
10
|
+
|
|
11
|
+
You can use `draw_comparison` to debug or explore differences between fontTools and
|
|
12
|
+
Inkscape.
|
|
13
|
+
|
|
14
|
+
:author: Shay Hill
|
|
15
|
+
:created: 2025-06-08
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import enum
|
|
21
|
+
import itertools as it
|
|
22
|
+
import string
|
|
23
|
+
import sys
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import TYPE_CHECKING
|
|
26
|
+
|
|
27
|
+
from svg_ultralight.bounding_boxes.bound_helpers import new_bbox_rect, pad_bbox
|
|
28
|
+
from svg_ultralight.bounding_boxes.padded_text_initializers import (
|
|
29
|
+
DEFAULT_Y_BOUNDS_REFERENCE,
|
|
30
|
+
pad_text,
|
|
31
|
+
pad_text_ft,
|
|
32
|
+
)
|
|
33
|
+
from svg_ultralight.constructors import new_element
|
|
34
|
+
from svg_ultralight.font_tools.font_info import get_svg_font_attributes
|
|
35
|
+
from svg_ultralight.main import write_svg
|
|
36
|
+
from svg_ultralight.root_elements import new_svg_root_around_bounds
|
|
37
|
+
|
|
38
|
+
if TYPE_CHECKING:
|
|
39
|
+
import os
|
|
40
|
+
from collections.abc import Iterator
|
|
41
|
+
|
|
42
|
+
from svg_ultralight.bounding_boxes.type_bounding_box import BoundingBox
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class FontBboxError(enum.Enum):
|
|
46
|
+
"""Classify the type of error between Inkscape and fontTools bounding boxes.
|
|
47
|
+
|
|
48
|
+
INIT: Use `pad_text`.
|
|
49
|
+
|
|
50
|
+
FontTools failed to run. This can happen with fonts that, inentionally or
|
|
51
|
+
not, do not have the required tables or character sets to build a bounding box
|
|
52
|
+
around the TEXT_TEXT. You can only use the `pad_text` PaddedText constructor.
|
|
53
|
+
This font may work with other or ascii-only text.
|
|
54
|
+
|
|
55
|
+
ELEM_Y: Use `pad_text` or `pad_text_mix` with cautions.
|
|
56
|
+
|
|
57
|
+
The y coordinate of the element bounding box is off by more than 1% of
|
|
58
|
+
the height. This error matters, because the y coordinates are used by
|
|
59
|
+
`pad_bbox_ft` and `pad_bbox_mix`. You can use either of these functions with a
|
|
60
|
+
y_bounds_reference element and accept some potential error in `line_gap` or
|
|
61
|
+
explicitly pass `ascent` and `descent` values to `pad_text_ft` or `pad_text_mix`.
|
|
62
|
+
|
|
63
|
+
SAFE_ELEM_X: Use `pad_text_mix`.
|
|
64
|
+
|
|
65
|
+
The y bounds are accurate, but the x coordinate of the element
|
|
66
|
+
bounding box is off by more than 1%. This is called "safe" because it is not used
|
|
67
|
+
by pad_bbox_mix, but you cannot use `pad_text_ft` without expecting BoundingBox
|
|
68
|
+
inaccuracies.
|
|
69
|
+
|
|
70
|
+
LINE_Y: Use `pad_text` or `pad_text_mix` with caution.
|
|
71
|
+
|
|
72
|
+
All of the above match, but the y coordinate of the line bounding box
|
|
73
|
+
(the padded bounding box) is off by more than 1% of the height. This error
|
|
74
|
+
matters as does ELEM_Y, but it does not exist for any font on my system. Fonts
|
|
75
|
+
without ELEM_Y errors should not have LINE_Y errors.
|
|
76
|
+
|
|
77
|
+
SAFE_LINE_X: Use `pad_text_mix`.
|
|
78
|
+
|
|
79
|
+
All of the above match, but the x coordinate of the line bounding
|
|
80
|
+
box (the padded bounding box) is off by more than 1%. This is safe or unsafe as
|
|
81
|
+
SAFE_ELEM_X, but also does not exist for any font on my system.
|
|
82
|
+
|
|
83
|
+
NO_ERROR: Use `pad_text_ft`.
|
|
84
|
+
|
|
85
|
+
No errors were found. The bounding boxes match within 1% of the height.
|
|
86
|
+
You can use `pad_text_ft` to get the same result as `pad_text` or `pad_text_mix`
|
|
87
|
+
without the delay caused by an Inkscape call.
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
INIT = enum.auto()
|
|
91
|
+
ELEM_Y = enum.auto()
|
|
92
|
+
SAFE_ELEM_X = enum.auto()
|
|
93
|
+
LINE_Y = enum.auto()
|
|
94
|
+
SAFE_LINE_X = enum.auto()
|
|
95
|
+
NO_ERROR = enum.auto()
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
# ===================================================================================
|
|
99
|
+
# Produce some commonly used Western UTF-8 characters for test text.
|
|
100
|
+
# ===================================================================================
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _get_western_utf8() -> str:
|
|
104
|
+
"""Return a string of the commonly used Western UTF-8 character set."""
|
|
105
|
+
western = " ".join(
|
|
106
|
+
[
|
|
107
|
+
string.ascii_lowercase,
|
|
108
|
+
string.ascii_uppercase,
|
|
109
|
+
string.digits,
|
|
110
|
+
string.punctuation,
|
|
111
|
+
"áÁéÉíÍóÓúÚñÑäÄëËïÏöÖüÜçÇàÀèÈìÌòÒùÙâÂêÊîÎôÔûÛãÃõÕåÅæÆøØœŒßÿŸ",
|
|
112
|
+
]
|
|
113
|
+
)
|
|
114
|
+
return western + " "
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
DEFAULT_TEST_TEXT = _get_western_utf8()
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _format_bbox_error(
|
|
121
|
+
bbox_a: BoundingBox, bbox_b: BoundingBox
|
|
122
|
+
) -> tuple[int, int, int, int]:
|
|
123
|
+
"""Return the difference between two bounding boxes as a percentage of height."""
|
|
124
|
+
width = bbox_a.width
|
|
125
|
+
height = bbox_a.height
|
|
126
|
+
diff = (
|
|
127
|
+
bbox_b.x - bbox_a.x,
|
|
128
|
+
bbox_b.y - bbox_a.y,
|
|
129
|
+
bbox_b.width - bbox_a.width,
|
|
130
|
+
bbox_b.height - bbox_a.height,
|
|
131
|
+
)
|
|
132
|
+
scaled_diff = (x / y for x, y in zip(diff, (height, height, width, height)))
|
|
133
|
+
dx, dy, dw, dh = (int(x * 100) for x in scaled_diff)
|
|
134
|
+
return dx, dy, dw, dh
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def check_font_tools_alignment(
|
|
138
|
+
inkscape: str | os.PathLike[str],
|
|
139
|
+
font: str | os.PathLike[str],
|
|
140
|
+
text: str | None = None,
|
|
141
|
+
) -> tuple[FontBboxError, tuple[int, int, int, int] | None]:
|
|
142
|
+
"""Return an error code and the difference b/t Inkscape and fontTools bboxes.
|
|
143
|
+
|
|
144
|
+
:param inkscape: path to an Inkscape executable
|
|
145
|
+
:param font_path: path to the font file
|
|
146
|
+
:return: a tuple of the error code and the percentage difference between the
|
|
147
|
+
bounding boxes as a tuple of (dx, dy, dw, dh) or (error, None) if there was
|
|
148
|
+
an error initializing fontTools.
|
|
149
|
+
"""
|
|
150
|
+
if text is None:
|
|
151
|
+
text = DEFAULT_TEST_TEXT
|
|
152
|
+
try:
|
|
153
|
+
svg_attribs = get_svg_font_attributes(font)
|
|
154
|
+
text_elem = new_element("text", **svg_attribs, text=text)
|
|
155
|
+
rslt_pt = pad_text(inkscape, text_elem)
|
|
156
|
+
rslt_ft = pad_text_ft(
|
|
157
|
+
font,
|
|
158
|
+
text,
|
|
159
|
+
y_bounds_reference=DEFAULT_Y_BOUNDS_REFERENCE,
|
|
160
|
+
)
|
|
161
|
+
except Exception:
|
|
162
|
+
return FontBboxError.INIT, None
|
|
163
|
+
|
|
164
|
+
error = _format_bbox_error(rslt_pt.unpadded_bbox, rslt_ft.unpadded_bbox)
|
|
165
|
+
if error[1] or error[3]:
|
|
166
|
+
return FontBboxError.ELEM_Y, error
|
|
167
|
+
if error[0] or error[2]:
|
|
168
|
+
return FontBboxError.SAFE_ELEM_X, error
|
|
169
|
+
|
|
170
|
+
error = _format_bbox_error(rslt_pt.bbox, rslt_ft.bbox)
|
|
171
|
+
if error[1] or error[3]:
|
|
172
|
+
return FontBboxError.LINE_Y, error
|
|
173
|
+
if error[0] or error[2]:
|
|
174
|
+
return FontBboxError.SAFE_LINE_X, error
|
|
175
|
+
|
|
176
|
+
return FontBboxError.NO_ERROR, None
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def draw_comparison(
|
|
180
|
+
inkscape: str | os.PathLike[str],
|
|
181
|
+
output: str | os.PathLike[str],
|
|
182
|
+
font: str | os.PathLike[str],
|
|
183
|
+
text: str | None = None,
|
|
184
|
+
) -> None:
|
|
185
|
+
"""Draw a font in Inkscape and fontTools.
|
|
186
|
+
|
|
187
|
+
:param inkscape: path to an Inkscape executable
|
|
188
|
+
:param output: path to the output SVG file
|
|
189
|
+
:param font: path to the font file
|
|
190
|
+
:param text: the text to render. If None, the font name will be used.
|
|
191
|
+
:effect: Writes an SVG file to the output path.
|
|
192
|
+
|
|
193
|
+
Compare the rendering and bounding boxes of a font in Inkscape and fontTools. The
|
|
194
|
+
bounding boxes drawn will always be accurate, but some fonts will not render the
|
|
195
|
+
Inkscape version in a browser. Conversely, Inskcape will not render the fontTools
|
|
196
|
+
version in Inkscape, because Inkscape does not read locally linked fonts. It
|
|
197
|
+
usually works, and it a good place to start if you'd like to compare fontTools
|
|
198
|
+
and Inkscape results.
|
|
199
|
+
"""
|
|
200
|
+
if text is None:
|
|
201
|
+
text = Path(font).stem
|
|
202
|
+
font_size = 12
|
|
203
|
+
font_attributes = get_svg_font_attributes(font)
|
|
204
|
+
text_elem = new_element("text", text=text, **font_attributes, font_size=font_size)
|
|
205
|
+
padded_pt = pad_text(inkscape, text_elem)
|
|
206
|
+
padded_ft = pad_text_ft(
|
|
207
|
+
font,
|
|
208
|
+
text,
|
|
209
|
+
font_size,
|
|
210
|
+
y_bounds_reference=DEFAULT_Y_BOUNDS_REFERENCE,
|
|
211
|
+
fill="none",
|
|
212
|
+
stroke="orange",
|
|
213
|
+
stroke_width=0.05,
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
root = new_svg_root_around_bounds(pad_bbox(padded_pt.bbox, 10))
|
|
217
|
+
root.append(
|
|
218
|
+
new_bbox_rect(
|
|
219
|
+
padded_pt.unpadded_bbox, fill="none", stroke_width=0.07, stroke="red"
|
|
220
|
+
)
|
|
221
|
+
)
|
|
222
|
+
root.append(
|
|
223
|
+
new_bbox_rect(
|
|
224
|
+
padded_ft.unpadded_bbox, fill="none", stroke_width=0.05, stroke="blue"
|
|
225
|
+
)
|
|
226
|
+
)
|
|
227
|
+
root.append(padded_pt.elem)
|
|
228
|
+
root.append(padded_ft.elem)
|
|
229
|
+
_ = sys.stdout.write(f"{Path(font).stem} comparison drawn at {output}.\n")
|
|
230
|
+
_ = write_svg(Path(output), root)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _iter_fonts(*fonts_dirs: Path) -> Iterator[Path]:
|
|
234
|
+
"""Yield a path to each ttf and otf file in the given directories.
|
|
235
|
+
|
|
236
|
+
:param fonts_dir: directory to search for ttf and otf files, multiple ok
|
|
237
|
+
:yield: paths to ttf and otf files in the given directories
|
|
238
|
+
|
|
239
|
+
A helper function for _test_every_font_on_my_system.
|
|
240
|
+
"""
|
|
241
|
+
if not fonts_dirs:
|
|
242
|
+
return
|
|
243
|
+
head, *tail = fonts_dirs
|
|
244
|
+
ttf_files = head.glob("*.[tt][tt][ff]")
|
|
245
|
+
otf_files = head.glob("*.[oO][tT][fF]")
|
|
246
|
+
yield from it.chain(ttf_files, otf_files)
|
|
247
|
+
yield from _iter_fonts(*tail)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _test_every_font_on_my_system(
|
|
251
|
+
inkscape: str | os.PathLike[str],
|
|
252
|
+
font_dirs: list[Path],
|
|
253
|
+
text: str | None = None,
|
|
254
|
+
) -> None:
|
|
255
|
+
"""Test every font on my system."""
|
|
256
|
+
if not Path(inkscape).with_suffix(".exe").exists():
|
|
257
|
+
_ = sys.stdout.write(f"Inkscape not found at {inkscape}\n")
|
|
258
|
+
return
|
|
259
|
+
font_dirs = [x for x in font_dirs if x.exists()]
|
|
260
|
+
if not font_dirs:
|
|
261
|
+
_ = sys.stdout.write("No font directories found.\n")
|
|
262
|
+
return
|
|
263
|
+
|
|
264
|
+
counts = dict.fromkeys(FontBboxError, 0)
|
|
265
|
+
for font_path in _iter_fonts(*font_dirs):
|
|
266
|
+
error, diff = check_font_tools_alignment(inkscape, font_path, text)
|
|
267
|
+
counts[error] += 1
|
|
268
|
+
if error is not FontBboxError.NO_ERROR:
|
|
269
|
+
_ = sys.stdout.write(f"Error with {font_path.name}: {error.name} {diff}\n")
|
|
270
|
+
for k, v in counts.items():
|
|
271
|
+
_ = sys.stdout.write(f"{k.name}: {v}\n")
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
if __name__ == "__main__":
|
|
275
|
+
_INKSCAPE = Path(r"C:\Program Files\Inkscape\bin\inkscape")
|
|
276
|
+
_FONT_DIRS = [
|
|
277
|
+
Path(r"C:\Windows\Fonts"),
|
|
278
|
+
Path(r"C:\Users\shaya\AppData\Local\Microsoft\Windows\Fonts"),
|
|
279
|
+
]
|
|
280
|
+
_test_every_font_on_my_system(_INKSCAPE, _FONT_DIRS)
|
|
281
|
+
|
|
282
|
+
font = Path(r"C:\Windows\Fonts\arial.ttf")
|
|
283
|
+
draw_comparison(_INKSCAPE, "temp.svg", font)
|