svg-ultralight 0.64.0__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.
Files changed (35) hide show
  1. svg_ultralight/__init__.py +112 -0
  2. svg_ultralight/animate.py +40 -0
  3. svg_ultralight/attrib_hints.py +14 -0
  4. svg_ultralight/bounding_boxes/__init__.py +5 -0
  5. svg_ultralight/bounding_boxes/bound_helpers.py +200 -0
  6. svg_ultralight/bounding_boxes/padded_text_initializers.py +442 -0
  7. svg_ultralight/bounding_boxes/supports_bounds.py +167 -0
  8. svg_ultralight/bounding_boxes/type_bound_collection.py +74 -0
  9. svg_ultralight/bounding_boxes/type_bound_element.py +68 -0
  10. svg_ultralight/bounding_boxes/type_bounding_box.py +432 -0
  11. svg_ultralight/bounding_boxes/type_padded_list.py +208 -0
  12. svg_ultralight/bounding_boxes/type_padded_text.py +502 -0
  13. svg_ultralight/constructors/__init__.py +14 -0
  14. svg_ultralight/constructors/new_element.py +117 -0
  15. svg_ultralight/font_tools/__init__.py +5 -0
  16. svg_ultralight/font_tools/comp_results.py +291 -0
  17. svg_ultralight/font_tools/font_info.py +849 -0
  18. svg_ultralight/image_ops.py +156 -0
  19. svg_ultralight/inkscape.py +261 -0
  20. svg_ultralight/layout.py +291 -0
  21. svg_ultralight/main.py +183 -0
  22. svg_ultralight/metadata.py +122 -0
  23. svg_ultralight/nsmap.py +36 -0
  24. svg_ultralight/py.typed +5 -0
  25. svg_ultralight/query.py +254 -0
  26. svg_ultralight/read_svg.py +58 -0
  27. svg_ultralight/root_elements.py +96 -0
  28. svg_ultralight/string_conversion.py +244 -0
  29. svg_ultralight/strings/__init__.py +21 -0
  30. svg_ultralight/strings/svg_strings.py +106 -0
  31. svg_ultralight/transformations.py +152 -0
  32. svg_ultralight/unit_conversion.py +247 -0
  33. svg_ultralight-0.64.0.dist-info/METADATA +208 -0
  34. svg_ultralight-0.64.0.dist-info/RECORD +35 -0
  35. svg_ultralight-0.64.0.dist-info/WHEEL +4 -0
@@ -0,0 +1,156 @@
1
+ """Crop an image before converting to binary and including in the svg file.
2
+
3
+ This optional module requires the Pillow library. Create an svg image element with a
4
+ rasterized image positioned inside a bounding box.
5
+
6
+ :author: Shay Hill
7
+ :created: 2024-11-20
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from typing import TYPE_CHECKING
13
+
14
+ from paragraphs import par
15
+
16
+ try:
17
+ from PIL import Image
18
+
19
+ if TYPE_CHECKING:
20
+ from PIL.Image import Image as ImageType
21
+ except ImportError as err:
22
+ msg = par(
23
+ """PIL is not installed. Install it using 'pip install Pillow' to use
24
+ svg_ultralight.image_ops module."""
25
+ )
26
+ raise ImportError(msg) from err
27
+
28
+ import base64
29
+ import io
30
+
31
+ from lxml import etree
32
+
33
+ from svg_ultralight import NSMAP
34
+ from svg_ultralight.bounding_boxes.bound_helpers import bbox_dict
35
+ from svg_ultralight.bounding_boxes.type_bound_element import BoundElement
36
+ from svg_ultralight.bounding_boxes.type_bounding_box import BoundingBox
37
+ from svg_ultralight.constructors import new_element
38
+
39
+ if TYPE_CHECKING:
40
+ import os
41
+
42
+ from lxml.etree import (
43
+ _Element as EtreeElement, # pyright: ignore [reportPrivateUsage]
44
+ )
45
+
46
+
47
+ def _symmetric_crop(
48
+ image: ImageType, center: tuple[float, float] | None = None
49
+ ) -> ImageType:
50
+ """Crop an image symmetrically around a center point.
51
+
52
+ :param image: PIL.Image instance
53
+ :param center: optional center point for cropping. Proportions of image with and
54
+ image height, so the default value, (0.5, 0.5), is the true center of the
55
+ image. (0.4, 0.5) would crop 20% off the right side of the image.
56
+ :return: PIL.Image instance
57
+ """
58
+ if center is None:
59
+ return image
60
+
61
+ if not all(0 < x < 1 for x in center):
62
+ msg = "Center must be between (0, 0) and (1, 1)"
63
+ raise ValueError(msg)
64
+
65
+ xd, yd = (min(x, 1 - x) for x in center)
66
+ left, right = sorted(x * image.width for x in (center[0] - xd, center[0] + xd))
67
+ top, bottom = sorted(x * image.height for x in (center[1] - yd, center[1] + yd))
68
+
69
+ return image.crop((left, top, right, bottom))
70
+
71
+
72
+ def _crop_image_to_bbox_ratio(
73
+ image: ImageType, bbox: BoundingBox, center: tuple[float, float] | None
74
+ ) -> ImageType:
75
+ """Crop an image to the ratio of a bounding box.
76
+
77
+ :param image: PIL.Image instance
78
+ :param bbox: BoundingBox instance
79
+ :param center: optional center point for cropping. Proportions of image with and
80
+ image height, so the default value, (0.5, 0.5), is the true center of the
81
+ image. (0.4, 0.5) would crop 20% off the right side of the image.
82
+ :return: PIL.Image instance
83
+
84
+ This crops the image to the specified ratio. It's not a resize, so it will cut
85
+ off the top and bottom or the sides of the image to fit the ratio.
86
+ """
87
+ image = _symmetric_crop(image, center)
88
+ width, height = image.size
89
+
90
+ ratio = bbox.width / bbox.height
91
+ if width / height > ratio:
92
+ new_width = height * ratio
93
+ left = (width - new_width) / 2
94
+ right = width - left
95
+ return image.crop((left, 0, right, height))
96
+ new_height = width / ratio
97
+ top = (height - new_height) / 2
98
+ bottom = height - top
99
+ return image.crop((0, top, width, bottom))
100
+
101
+
102
+ def _get_svg_embedded_image_str(image: ImageType) -> str:
103
+ """Return the string you'll need to embed an image in an svg.
104
+
105
+ :param image: PIL.Image instance
106
+ :return: argument for xlink:href
107
+ """
108
+ in_mem_file = io.BytesIO()
109
+ image.save(in_mem_file, format="PNG")
110
+ _ = in_mem_file.seek(0)
111
+ img_bytes = in_mem_file.read()
112
+ base64_encoded_result_bytes = base64.b64encode(img_bytes)
113
+ base64_encoded_result_str = base64_encoded_result_bytes.decode("ascii")
114
+ return "data:image/png;base64," + base64_encoded_result_str
115
+
116
+
117
+ def new_image_blem(
118
+ filename: str | os.PathLike[str],
119
+ bbox: BoundingBox | None = None,
120
+ center: tuple[float, float] | None = None,
121
+ ) -> BoundElement:
122
+ """Create a new svg image element inside a bounding box.
123
+
124
+ :param filename: filename of source image
125
+ :param bbox: bounding box for the image
126
+ :param center: center point for cropping. Proportions of image width and image
127
+ height, so the default value, (0.5, 0.5), is the true center of the image.
128
+ (0.4, 0.5) would crop 20% off the right side of the image.
129
+ :return: a BoundElement element with the cropped image embedded
130
+ """
131
+ image = Image.open(filename)
132
+ if bbox is None:
133
+ bbox = BoundingBox(0, 0, image.width, image.height)
134
+ image = _crop_image_to_bbox_ratio(Image.open(filename), bbox, center)
135
+ svg_image = new_element("image", **bbox_dict(bbox))
136
+ svg_image.set(
137
+ etree.QName(NSMAP["xlink"], "href"), _get_svg_embedded_image_str(image)
138
+ )
139
+ return BoundElement(svg_image, bbox)
140
+
141
+
142
+ def new_image_elem_in_bbox(
143
+ filename: str | os.PathLike[str],
144
+ bbox: BoundingBox | None = None,
145
+ center: tuple[float, float] | None = None,
146
+ ) -> EtreeElement:
147
+ """Create a new svg image element inside a bounding box.
148
+
149
+ :param filename: filename of source image
150
+ :param bbox: bounding box for the image
151
+ :param center: center point for cropping. Proportions of image width and image
152
+ height, so the default value, (0.5, 0.5), is the true center of the image.
153
+ (0.4, 0.5) would crop 20% off the right side of the image.
154
+ :return: an etree image element with the cropped image embedded
155
+ """
156
+ return new_image_blem(filename, bbox, center).elem
@@ -0,0 +1,261 @@
1
+ """Operations on existing svg files.
2
+
3
+ This module contains Inkscape calls for manipulating svg files after they have been
4
+ created, but not necessarily written to disk. These are just wrappers around
5
+ selections from the Inkscape command line interface.
6
+
7
+ Inkscape cli calls generally take the form
8
+
9
+ inkscape --some-option input_filename.svg --export-filename=export_filename.svg
10
+
11
+ This module allows you to optionally make these calls with svg root elements instead
12
+ of filenames. In such cases, the root elements will be written to a temporary file,
13
+ the Inkscape CLI called, and something returned.
14
+
15
+ :author: Shay Hill
16
+ :created: 2023-02-14
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import os
22
+ import subprocess
23
+ import tempfile
24
+ from pathlib import Path
25
+ from tempfile import NamedTemporaryFile
26
+ from typing import TYPE_CHECKING
27
+
28
+ from lxml import etree
29
+
30
+ from svg_ultralight import main
31
+
32
+ if TYPE_CHECKING:
33
+ from lxml.etree import (
34
+ _Element as EtreeElement, # pyright: ignore[reportPrivateUsage]
35
+ )
36
+
37
+
38
+ def write_png_from_svg(
39
+ inkscape: str | os.PathLike[str],
40
+ svg: str | os.PathLike[str],
41
+ png: str | os.PathLike[str] | None = None,
42
+ ) -> str:
43
+ """Convert an svg file to a png.
44
+
45
+ :param inkscape: path to inkscape executable
46
+ :param svg: path to svg file
47
+ :param png: optional path to png output file
48
+ :return: png filename
49
+ :effects: creates a new png from svg filename
50
+ :raises ValueError: if unable to write png. This could result from an error with
51
+ Inkscape.
52
+
53
+ If no output png path is given, the output path will be inferred from the ``svg``
54
+ filename.
55
+ """
56
+ inkscape = Path(inkscape).with_suffix("") # remove .exe if present
57
+ png = str(Path(svg).with_suffix(".png")) if png is None else str(png)
58
+
59
+ # inkscape versions >= 1.0
60
+ options = [f'"{svg}"', "--export-type=png", f'--export-filename="{png}"']
61
+ return_code = subprocess.call(f'"{inkscape}" ' + " ".join(options))
62
+ if return_code == 0:
63
+ return png
64
+
65
+ # inkscape versions < 1.0
66
+ return_code = subprocess.call(f'"{inkscape}" -f "{svg}" -e "{png}"')
67
+ if return_code == 0:
68
+ return png
69
+
70
+ msg = f"failed to write {png} with inkscape {inkscape}"
71
+ raise ValueError(msg)
72
+
73
+
74
+ def write_png(
75
+ inkscape: str | os.PathLike[str],
76
+ png: str | os.PathLike[str],
77
+ root: EtreeElement,
78
+ stylesheet: str | os.PathLike[str] | None = None,
79
+ ) -> str:
80
+ """Create a png file without writing an intermediate svg file.
81
+
82
+ :param inkscape: path to inkscape executable
83
+ :param png: path to output png file
84
+ :param root: root node of your svg geometry
85
+ :param stylesheet: optional path to css stylesheet
86
+ :return: png filename (the same you input as ``png``)
87
+ :effects: creates a new png file
88
+
89
+ This just creates a tempfile, writes the svg to the tempfile, then calls
90
+ ``write_png_from_svg`` with the tempfile. This isn't faster (it might be slightly
91
+ slower), but it keeps the filesystem clean when you only want the png.
92
+ """
93
+ with NamedTemporaryFile(mode="wb", delete=False) as svg_file:
94
+ svg = main.write_svg(svg_file, root, stylesheet)
95
+ _ = write_png_from_svg(inkscape, svg, png)
96
+ os.unlink(svg)
97
+ return str(png)
98
+
99
+
100
+ def write_pdf_from_svg(
101
+ inkscape: str | os.PathLike[str],
102
+ svg: str | os.PathLike[str],
103
+ pdf: str | os.PathLike[str] | None = None,
104
+ ) -> str:
105
+ """Convert an svg file to a pdf.
106
+
107
+ :param inkscape: path to inkscape executable
108
+ :param svg: path to svg file
109
+ :param pdf: optional path to png output file
110
+ :return: pdf filename
111
+ :effects: creates a new pfd from svg filename
112
+ :raises ValueError: if unable to write pdf. This could result from an error with
113
+ Inkscape.
114
+
115
+ If no output png path is given, the output path will be inferred from the ``svg``
116
+ filename.
117
+ """
118
+ inkscape = Path(inkscape).with_suffix("") # remove .exe if present
119
+ pdf = str(Path(svg).with_suffix(".pdf")) if pdf is None else str(pdf)
120
+
121
+ # inkscape versions >= 1.0
122
+ options = [f'"{svg}"', "--export-type=pdf", f'--export-filename="{pdf}"']
123
+ return_code = subprocess.call(f'"{inkscape}" ' + " ".join(options))
124
+ if return_code == 0:
125
+ return pdf
126
+
127
+ # inkscape versions < 1.0
128
+ return_code = subprocess.call(f'"{inkscape}" -f "{svg}" -e "{pdf}"')
129
+ if return_code == 0:
130
+ return pdf
131
+
132
+ msg = f"failed to write {pdf} from {svg}"
133
+ raise ValueError(msg)
134
+
135
+
136
+ def write_pdf(
137
+ inkscape: str | os.PathLike[str],
138
+ pdf: str | os.PathLike[str],
139
+ root: EtreeElement,
140
+ stylesheet: str | os.PathLike[str] | None = None,
141
+ ) -> str:
142
+ """Create a pdf file without writing an intermediate svg file.
143
+
144
+ :param inkscape: path to inkscape executable
145
+ :param pdf: path to output pdf file
146
+ :param root: root node of your svg geometry
147
+ :param stylesheet: optional path to css stylesheet
148
+ :return: pdf filename (the same you input as ``pdf``)
149
+ :effects: creates a new pdf file
150
+
151
+ This just creates a tempfile, writes the svg to the tempfile, then calls
152
+ ``write_pdf_from_svg`` with the tempfile. This isn't faster (it might be slightly
153
+ slower), but it keeps the filesystem clean when you only want the pdf.
154
+ """
155
+ with NamedTemporaryFile(mode="wb", delete=False) as svg_file:
156
+ svg = main.write_svg(svg_file, root, stylesheet)
157
+ _ = write_pdf_from_svg(inkscape, svg, pdf)
158
+ os.unlink(svg)
159
+ return str(pdf)
160
+
161
+
162
+ def export_text_to_path(
163
+ inkscape: str | os.PathLike[str],
164
+ input_file: str | os.PathLike[str],
165
+ export_file: str | os.PathLike[str],
166
+ ) -> str:
167
+ """Export copy of svg file with text converted to paths.
168
+
169
+ :param inkscape: Path to inkscape executable.
170
+ :param input_file: Path to svg file.
171
+ :param export_file: Path to result.
172
+ :return: Path to result.
173
+ :effect: Writes to export_file.
174
+
175
+ Find any text objects in an svg file and convert them to paths.
176
+ """
177
+ inkscape = Path(inkscape).with_suffix("") # remove .exe if present
178
+ command = [
179
+ str(inkscape),
180
+ "--export-text-to-path",
181
+ "--export-plain-svg",
182
+ str(input_file),
183
+ f"--export-filename={export_file}",
184
+ ]
185
+ _ = subprocess.call(command)
186
+ return str(export_file)
187
+
188
+
189
+ def convert_text_to_path(
190
+ inkscape: str | os.PathLike[str], root: EtreeElement
191
+ ) -> EtreeElement:
192
+ """Convert text to path in a root svg element.
193
+
194
+ :param inkscape: Path to inkscape executable.
195
+ :param root: SVG root element.
196
+ :return: SVG root element with text converted to path.
197
+ """
198
+ with tempfile.NamedTemporaryFile(suffix=".svg", delete=False) as f:
199
+ _ = main.write_svg(f, root)
200
+ temp_input = f.name
201
+ with tempfile.TemporaryDirectory() as temp_dir:
202
+ temp_export = Path(temp_dir) / "export.svg"
203
+ _ = export_text_to_path(inkscape, temp_input, temp_export)
204
+ root = etree.parse(temp_export).getroot()
205
+ os.unlink(temp_input)
206
+ return root
207
+
208
+
209
+ def write_root(
210
+ inkscape: str | os.PathLike[str],
211
+ filename: str | os.PathLike[str],
212
+ root: EtreeElement,
213
+ *,
214
+ do_text_to_path: bool = True,
215
+ do_svg: bool = True,
216
+ do_png: bool | str | os.PathLike[str] = False,
217
+ do_pdf: bool | str | os.PathLike[str] = False,
218
+ ) -> EtreeElement:
219
+ """Save xml in multiple file formats, optionally updating text to paths.
220
+
221
+ :param inkscape: Path to the Inkscape executable or command.
222
+ :param filename: Path to the output svg file.
223
+ :param root: The XML element to be saved.
224
+ :param do_text_to_path: Whether to convert text to paths.
225
+ :param do_svg: Whether to save the file in SVG format.
226
+ :param do_png: Whether to save the file in PNG format. If True, the output
227
+ filename will be generated from the filename argument. Optionally
228
+ explicity specify an output path.
229
+ :param do_pdf: Whether to save the file in PDF format. If True, the output
230
+ filename will be generated from the filename argument. Optionally
231
+ explicity specify an output path.
232
+ :return: The XML element that was saved.
233
+
234
+ This is an umbrella function over the other functions in this module. If you
235
+ have Inkscape installed, this function will likeley be a better choice than
236
+ `write_svg`, even if it will not accept IO objects as filename arguments.
237
+
238
+ The largest errors between Inkscape and browsers have to do with text. If the
239
+ browser doesn't know about your font (even if it *should*), your svg will
240
+ not look the same in your browser (or book) as it does in Inkscape. It's
241
+ good practice to save all svg images with text using this function.
242
+ """
243
+ output_svg = Path(filename).with_suffix(".svg")
244
+ output_png = output_svg.with_suffix(".png") if isinstance(do_png, bool) else do_png
245
+ output_pdf = output_svg.with_suffix(".pdf") if isinstance(do_pdf, bool) else do_pdf
246
+
247
+ if do_text_to_path and next(root.itertext(), None) is not None:
248
+ root = convert_text_to_path(inkscape, root)
249
+ if do_svg:
250
+ _ = main.write_svg(output_svg, root)
251
+ if do_png:
252
+ if do_svg:
253
+ _ = write_png_from_svg(inkscape, output_svg, output_png)
254
+ else:
255
+ _ = write_png(inkscape, output_png, root)
256
+ if do_pdf:
257
+ if do_svg:
258
+ _ = write_pdf_from_svg(inkscape, output_svg, output_pdf)
259
+ else:
260
+ _ = write_pdf(inkscape, output_pdf, root)
261
+ return root