weasyvsdx 0.1.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.
weasyvsdx/__init__.py ADDED
@@ -0,0 +1,88 @@
1
+ """Visio drawings for WeasyPrint, drawn as vector SVG through libvisio.
2
+
3
+ Call ``install()`` once, before the first rendering. It patches WeasyPrint in place, for the process: an
4
+ image with a Visio media type renders as the SVG of its first page. The package targets WeasyPrint 70.x and
5
+ warns on any other version.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import warnings
11
+
12
+ import weasyprint
13
+ import weasysvg
14
+
15
+ from . import fetch as _fetch
16
+ from .convert import CONVERTER, LIBVISIO, MIB, Limits, Tools, find_converter, find_tools, libvisio_version
17
+ from .errors import ConversionError
18
+
19
+ __all__ = ["LIBVISIO", "SUPPORTED_MAJOR", "ConversionError", "ConverterNotFoundError", "install", "installed"]
20
+
21
+ SUPPORTED_MAJOR = 70
22
+ _INSTALLED: dict[str, str] = {}
23
+
24
+
25
+ class ConverterNotFoundError(FileNotFoundError):
26
+ """vsd2xhtml is not on the PATH: install libvisio-tools."""
27
+
28
+
29
+ def _check_version() -> None:
30
+ version = getattr(weasyprint, "__version__", "")
31
+ if version.split(".", 1)[0] != str(SUPPORTED_MAJOR):
32
+ warnings.warn(
33
+ f"weasyvsdx targets WeasyPrint {SUPPORTED_MAJOR}.x, found {version or 'unknown'}: the patch may not apply",
34
+ RuntimeWarning,
35
+ stacklevel=3,
36
+ )
37
+
38
+
39
+ def _check_libvisio(converter: str) -> None:
40
+ version = libvisio_version(converter)
41
+ if version != LIBVISIO:
42
+ warnings.warn(
43
+ f"weasyvsdx is tested with libvisio {LIBVISIO}, found {version or 'unknown'}: libvisio 0.1.8 to 0.1.11 fill the shapes of a VSDX with theme colors",
44
+ RuntimeWarning,
45
+ stacklevel=3,
46
+ )
47
+
48
+
49
+ def install(
50
+ *,
51
+ converter: str | None = None,
52
+ text: bool = True,
53
+ emf: bool = True,
54
+ timeout: float = 30.0,
55
+ max_input_mb: int = 64,
56
+ max_output_mb: int = 256,
57
+ max_memory_mb: int = 2048,
58
+ ) -> str:
59
+ """Render Visio drawings in WeasyPrint, and return the path of the converter.
60
+
61
+ ``converter`` is the vsd2xhtml to run, found on the PATH by default. ``text`` lays the text out anew
62
+ from vsd2raw, next to it, as HTML in a foreignObject: it wraps and aligns as in Visio. weasysvg draws the
63
+ foreignObject, so install() installs weasysvg with its defaults unless its foreign_object is already
64
+ installed. Without vsd2raw the text stays as vsd2xhtml writes it. ``emf`` turns EMF images into SVG
65
+ through emf2svg-conv, where it is on the PATH; WeasyPrint cannot read EMF. One run of a tool may take
66
+ ``timeout`` seconds, read ``max_input_mb`` of drawing and, on Linux, write ``max_output_mb`` of SVG and
67
+ use ``max_memory_mb`` of memory. A drawing past a limit renders as a broken image. A later call
68
+ changes the converter and the limits. Raises ConverterNotFoundError when there is no converter, and
69
+ warns when it reports another libvisio than ``LIBVISIO``.
70
+ """
71
+ _check_version()
72
+ path = find_converter(converter or CONVERTER)
73
+ if path is None:
74
+ raise ConverterNotFoundError(f"{converter or CONVERTER} not found: install libvisio-tools")
75
+ _check_libvisio(path)
76
+ found = find_tools(path)
77
+ tools = Tools(path, found.raw if text else None, found.emf if emf else None)
78
+ if tools.raw is not None and "foreign_object" not in weasysvg.installed():
79
+ weasysvg.install()
80
+ limits = Limits(timeout=timeout, max_input=max_input_mb * MIB, max_output=max_output_mb * MIB, max_memory=max_memory_mb * MIB)
81
+ _fetch.install(tools, limits)
82
+ _INSTALLED["converter"] = path
83
+ return path
84
+
85
+
86
+ def installed() -> str | None:
87
+ """The path of the converter of the last install(), or None before the first."""
88
+ return _INSTALLED.get("converter")
weasyvsdx/convert.py ADDED
@@ -0,0 +1,223 @@
1
+ """A Visio drawing to the SVG of its first page, through the tools of libvisio.
2
+
3
+ libvisio is the importer of LibreOffice. Its tool vsd2xhtml writes each page of a drawing as an SVG inside
4
+ one XHTML document. The first page is the image, as LibreOffice exports it. Its text and its arrowheads are
5
+ drawn anew from the output of vsd2raw (text.py, markers.py), and its EMF images become SVG through
6
+ emf2svg-conv (images.py). A path that starts without a moveto gets one (paths.py). Both tools are optional: without them the page stays as vsd2xhtml writes it.
7
+
8
+ The tools parse untrusted input in C, and a broken file can crash them: ``recursion-cycle.vsdx`` of the
9
+ libvisio tests ends in a segmentation fault. So each runs in a process of its own, in a temporary
10
+ directory, with a time limit, and on Linux with a limit on its memory and on the size of its output.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import logging
16
+ import re
17
+ import shutil
18
+ import subprocess
19
+ import tempfile
20
+ import time
21
+ from dataclasses import dataclass
22
+ from pathlib import Path
23
+ from xml.etree import ElementTree as ET
24
+
25
+ from . import geometry, gradients, hatch, hidden, hidden_text, images, markers, paths, raw, text
26
+ from .errors import ConversionError
27
+
28
+ SVG_NS = "http://www.w3.org/2000/svg"
29
+ CONVERTER = "vsd2xhtml"
30
+ RAW_CONVERTER = "vsd2raw"
31
+ EMF_CONVERTER = "emf2svg-conv"
32
+ MIB = 1024 * 1024
33
+ # The release of libvisio weasyvsdx is tested with. 0.1.8 to 0.1.11 fill the shapes of a VSDX with the
34
+ # colors of its theme over their own.
35
+ LIBVISIO = "0.1.7"
36
+ VERSION_TIMEOUT = 10.0
37
+ VERSION = re.compile(r"\d+(?:\.\d+)+")
38
+ # The part of the error output of a tool that an error message quotes
39
+ STDERR_CHARS = 300
40
+ LOGGER = logging.getLogger("weasyvsdx")
41
+
42
+ # The limits run through the shell: subprocess has no parameter for them, and its preexec_fn is not safe
43
+ # in a process with threads, such as a service that renders in a thread pool. ulimit -v and -f fail on
44
+ # macOS, where the limits are then left out; the time limit holds everywhere.
45
+ LIMITED = 'ulimit -v "$1" 2>/dev/null; ulimit -f "$2" 2>/dev/null; shift 2; exec "$@"'
46
+
47
+
48
+ @dataclass(frozen=True)
49
+ class Limits:
50
+ """What one run of a tool may take: seconds, and bytes of input, output and memory."""
51
+
52
+ timeout: float = 30.0
53
+ max_input: int = 64 * MIB
54
+ max_output: int = 256 * MIB
55
+ max_memory: int = 2048 * MIB
56
+
57
+ def with_timeout(self, timeout: float) -> Limits:
58
+ """The same limits with another timeout."""
59
+ return Limits(timeout=timeout, max_input=self.max_input, max_output=self.max_output, max_memory=self.max_memory)
60
+
61
+
62
+ @dataclass(frozen=True)
63
+ class Tools:
64
+ """The paths of the tools: vsd2xhtml, and vsd2raw and emf2svg-conv where found."""
65
+
66
+ converter: str
67
+ raw: str | None = None
68
+ emf: str | None = None
69
+
70
+
71
+ def libvisio_version(converter: str) -> str | None:
72
+ """The release of libvisio that vsd2xhtml reports, such as 0.1.7; None where it reports none."""
73
+ try:
74
+ result = subprocess.run([converter, "--version"], stdin=subprocess.DEVNULL, capture_output=True, timeout=VERSION_TIMEOUT, check=False)
75
+ except (OSError, subprocess.TimeoutExpired):
76
+ return None
77
+ words = result.stdout.decode(errors="replace").split()
78
+ if result.returncode != 0 or len(words) != 2 or VERSION.fullmatch(words[1]) is None:
79
+ return None
80
+ return words[1]
81
+
82
+
83
+ def find_converter(name: str = CONVERTER) -> str | None:
84
+ """The path of vsd2xhtml, or None when it is not on the PATH."""
85
+ return shutil.which(name)
86
+
87
+
88
+ def find_tools(converter: str) -> Tools:
89
+ """vsd2xhtml with the vsd2raw next to it, both of libvisio-tools, and emf2svg-conv on the PATH."""
90
+ return Tools(converter, shutil.which(str(Path(converter).with_name(RAW_CONVERTER))), shutil.which(EMF_CONVERTER))
91
+
92
+
93
+ class Job:
94
+ """A tool running in a process of its own, in folder, with the limits. result() waits for its output."""
95
+
96
+ def __init__(self, command: list[str], folder: Path, limits: Limits) -> None:
97
+ self.name = Path(command[0]).name
98
+ self._limits = limits
99
+ self._started = time.monotonic()
100
+ self._output, self._errors = folder / f"{self.name}.out", folder / f"{self.name}.err"
101
+ # ulimit -v counts KiB, ulimit -f blocks of 512 bytes
102
+ limited = ["/bin/sh", "-c", LIMITED, "sh", str(limits.max_memory // 1024), str(limits.max_output // 512), *command]
103
+ # Fixed tools on a file of the temporary directory: no shell reads the drawing
104
+ with self._output.open("wb") as stdout, self._errors.open("wb") as stderr:
105
+ self._process = subprocess.Popen(limited, stdin=subprocess.DEVNULL, stdout=stdout, stderr=stderr, cwd=folder)
106
+
107
+ def result(self) -> bytes:
108
+ """What the tool wrote to its output. Raises ConversionError."""
109
+ try:
110
+ returncode = self._process.wait(timeout=max(self._limits.timeout - (time.monotonic() - self._started), 0))
111
+ except subprocess.TimeoutExpired as e:
112
+ self.cancel()
113
+ raise ConversionError(f"{self.name} took more than {self._limits.timeout} s") from e
114
+ if returncode != 0:
115
+ message = self._errors.read_text(errors="replace")[:STDERR_CHARS].strip()
116
+ raise ConversionError(f"{self.name} failed with exit code {returncode}{': ' + message if message else ''}")
117
+ if self._output.stat().st_size > self._limits.max_output:
118
+ raise ConversionError(f"{self.name} wrote more than {self._limits.max_output} bytes")
119
+ return self._output.read_bytes()
120
+
121
+ def cancel(self) -> None:
122
+ """Stop the tool, if it still runs."""
123
+ if self._process.poll() is None:
124
+ self._process.kill()
125
+ self._process.wait()
126
+
127
+
128
+ def run(command: list[str], folder: Path, limits: Limits) -> bytes:
129
+ """What the command writes to its output, run in folder. Raises ConversionError."""
130
+ return Job(command, folder, limits).result()
131
+
132
+
133
+ def convert(data: bytes, tools: Tools, limits: Limits) -> bytes:
134
+ """The SVG of the first page of the drawing, as bytes. Raises ConversionError."""
135
+ if len(data) > limits.max_input:
136
+ raise ConversionError(f"the drawing has more than {limits.max_input} bytes")
137
+ with tempfile.TemporaryDirectory(prefix="weasyvsdx-") as directory:
138
+ folder = Path(directory)
139
+ source = folder / "drawing"
140
+ source.write_bytes(data)
141
+ # vsd2raw parses the drawing as long as vsd2xhtml: both run at once, which halves the time
142
+ raw_job = Job([tools.raw, str(source)], folder, limits) if tools.raw is not None else None
143
+ try:
144
+ page = first_page(run([tools.converter, str(source)], folder, limits))
145
+ paths.start_with_moveto(page)
146
+ if raw_job is not None:
147
+ redraw(page, raw_job, data)
148
+ finally:
149
+ if raw_job is not None:
150
+ raw_job.cancel()
151
+ if tools.emf is not None:
152
+ images.replace_emf(page, emf_converter(tools.emf, folder, limits), folder)
153
+ return serialize(page)
154
+
155
+
156
+ def emf_converter(emf: str, folder: Path, limits: Limits) -> images.Converter:
157
+ """emf2svg-conv for each EMF of a page. All of them share one timeout: a page may hold thousands."""
158
+ deadline = time.monotonic() + limits.timeout
159
+
160
+ def to_svg(path: Path) -> bytes:
161
+ remaining = deadline - time.monotonic()
162
+ if remaining <= 0:
163
+ raise ConversionError(f"the EMF images took more than {limits.timeout} s")
164
+ return run([emf, "-i", str(path), "-o", "/dev/stdout"], folder, limits.with_timeout(remaining))
165
+
166
+ return to_svg
167
+
168
+
169
+ def redraw(page: ET.Element, job: Job, document: bytes) -> None:
170
+ """Draw the text, arrowheads, hatches and gradients of the page anew from what vsd2raw prints, and drop its hidden shapes."""
171
+ try:
172
+ calls = raw.first_page(job.result().decode("utf-8", errors="replace"))
173
+ except ConversionError as e:
174
+ LOGGER.warning("The text and arrowheads of a Visio drawing stay as vsd2xhtml writes them: %s", e)
175
+ return
176
+ # First: the steps after it pair the paths of the page with the drawPath calls, and pass by the paths it adds
177
+ if geometry.add_missing(page, calls, document) is None:
178
+ LOGGER.warning("Parts of shapes of master groups of a Visio drawing may be missing: vsd2raw and vsd2xhtml count other paths")
179
+ # Before hidden shapes go: both pair the paths of the page with the drawPath calls
180
+ if not hatch.fill_hatches(page, calls):
181
+ LOGGER.warning("Hatched shapes of a Visio drawing may fill black: vsd2raw and vsd2xhtml count other paths")
182
+ filled = gradients.shape_gradients(document)
183
+ if filled and not gradients.fill_gradients(page, calls, filled):
184
+ LOGGER.warning("Shapes of a Visio drawing that fill with a gradient may fill black: vsd2raw and vsd2xhtml count other paths")
185
+ if not hidden.remove_hidden(page, calls):
186
+ LOGGER.warning("Hidden shapes of a Visio drawing may show: vsd2raw and vsd2xhtml count other paths")
187
+ objects = text.text_objects(calls)
188
+ # The text that the shapes of a VSDX hide, which libvisio writes
189
+ shapes = hidden_text.shape_texts(document)
190
+ hidden_texts = hidden_text.hidden_indexes(shapes, [text.plain(item) for item in objects]) if shapes else frozenset()
191
+ if not text.replace_text(page, objects, hidden_texts):
192
+ LOGGER.warning("The text of a Visio drawing stays as vsd2xhtml writes it: vsd2raw and vsd2xhtml count other text objects")
193
+ if not markers.replace_markers(page, calls):
194
+ LOGGER.warning("Some arrowheads of a Visio drawing stay as vsd2xhtml writes them: vsd2raw and vsd2xhtml count other markers")
195
+
196
+
197
+ def first_page(document: bytes) -> ET.Element:
198
+ """The first SVG of the XHTML of vsd2xhtml."""
199
+ try:
200
+ # The output of libvisio, which escapes the text of the drawing and declares no entities
201
+ root = ET.fromstring(document)
202
+ except ET.ParseError as e:
203
+ raise ConversionError(f"{CONVERTER} wrote no well-formed XHTML: {e}") from e
204
+ page = root.find(f".//{{{SVG_NS}}}svg")
205
+ if page is None:
206
+ raise ConversionError(f"the drawing has no page that {CONVERTER} draws")
207
+ # libvisio 0.1.9 and later write a page of no size for a drawing they cannot read, where 0.1.7 failed
208
+ if raw.points(page.get("width")) <= 0 or raw.points(page.get("height")) <= 0:
209
+ raise ConversionError(f"the first page that {CONVERTER} draws has no size")
210
+ return page
211
+
212
+
213
+ def serialize(page: ET.Element) -> bytes:
214
+ """The page as an SVG document of its own."""
215
+ # The SVG namespace as the default one, without the prefix of the XHTML. The default_namespace of
216
+ # tostring() cannot do it: it refuses attributes without a namespace, which is nearly all of them.
217
+ prefix = f"{{{SVG_NS}}}"
218
+ for element in page.iter():
219
+ if isinstance(element.tag, str) and element.tag.startswith(prefix):
220
+ element.tag = element.tag[len(prefix) :]
221
+ page.set("xmlns", SVG_NS)
222
+ svg: bytes = ET.tostring(page, encoding="utf-8")
223
+ return svg
weasyvsdx/errors.py ADDED
@@ -0,0 +1,5 @@
1
+ """The error of a drawing that does not convert, shared by the modules that run the tools."""
2
+
3
+
4
+ class ConversionError(Exception):
5
+ """A drawing that the converter cannot turn into SVG."""
weasyvsdx/fetch.py ADDED
@@ -0,0 +1,99 @@
1
+ """Hands WeasyPrint each Visio drawing as the SVG of its first page.
2
+
3
+ Touches: ``document.original_get_image_from_uri``, the name under which ``Document`` binds the image loader
4
+ of each rendering: an ``<img>``, ``<object>`` or ``<embed>``, a CSS image, an ``<image>`` inside an SVG. The
5
+ wrapper puts a fetcher in front of the fetcher of the caller. A response that holds a Visio drawing comes
6
+ back as SVG; any other response passes unchanged. WeasyPrint then builds an SVGImage from it, as from any
7
+ SVG, and every URL inside the SVG still goes through the fetcher of the caller.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from posixpath import splitext
13
+ from typing import Any
14
+ from urllib.parse import urlsplit
15
+
16
+ from weasyprint import document as wp_document
17
+ from weasyprint.urls import URLFetcherResponse
18
+
19
+ from .convert import Limits, Tools, convert
20
+
21
+ # The media types of Visio drawings, templates and stencils, binary (.vsd) and Open XML (.vsdx). They match
22
+ # lowercase: the headers of a response give the type lowercase.
23
+ VISIO_TYPES = frozenset(
24
+ {
25
+ "application/vnd.visio",
26
+ "application/vnd.ms-visio.drawing",
27
+ "application/vnd.ms-visio.drawing.main+xml",
28
+ "application/vnd.ms-visio.drawing.macroenabled.12",
29
+ "application/vnd.ms-visio.drawing.macroenabled.main+xml",
30
+ "application/vnd.ms-visio.template",
31
+ "application/vnd.ms-visio.template.main+xml",
32
+ "application/vnd.ms-visio.template.macroenabled.12",
33
+ "application/vnd.ms-visio.stencil",
34
+ "application/vnd.ms-visio.stencil.macroenabled.12",
35
+ }
36
+ )
37
+ VISIO_SUFFIXES = frozenset({".vsd", ".vsdx", ".vsdm", ".vst", ".vstx", ".vstm", ".vss", ".vssx", ".vssm"})
38
+ # The types that say nothing of the content. A file URL of an unknown suffix gets text/plain from urllib.
39
+ UNTYPED = frozenset({"", "application/octet-stream", "text/plain"})
40
+ SVG_TYPE = "image/svg+xml"
41
+
42
+ _ORIGINAL_GET_IMAGE_FROM_URI = wp_document.original_get_image_from_uri
43
+
44
+
45
+ def is_visio(content_type: str, url: str) -> bool:
46
+ """A Visio drawing by its media type, or by the suffix of its path where the type says nothing."""
47
+ if content_type in VISIO_TYPES:
48
+ return True
49
+ return content_type in UNTYPED and not url.startswith("data:") and splitext(urlsplit(url).path)[1].lower() in VISIO_SUFFIXES
50
+
51
+
52
+ class VisioFetcher:
53
+ """The fetcher of the caller, with each Visio drawing turned into SVG."""
54
+
55
+ def __init__(self, inner: Any, tools: Tools, limits: Limits) -> None:
56
+ self._inner = inner
57
+ self._tools = tools
58
+ self._limits = limits
59
+
60
+ def __getattr__(self, name: str) -> Any:
61
+ return getattr(self._inner, name)
62
+
63
+ def __call__(self, url: str) -> Any:
64
+ response = self._inner(url)
65
+ # A redirect may lead to the suffix, or away from it
66
+ if not (is_visio(response.content_type, url) or is_visio(response.content_type, response.url or "")):
67
+ return response
68
+ try:
69
+ # One byte past the limit is enough to know that the drawing is too large
70
+ data = response.read(self._limits.max_input + 1)
71
+ finally:
72
+ response.close()
73
+ # A ConversionError fails the fetch: WeasyPrint logs it and leaves the image out, as any broken image
74
+ svg = convert(data, self._tools, self._limits)
75
+ return URLFetcherResponse(response.url, svg, {"Content-Type": SVG_TYPE}, response.status)
76
+
77
+
78
+ _SETTINGS: dict[str, Any] = {}
79
+
80
+
81
+ def _get_image_from_uri(
82
+ cache: Any,
83
+ url_fetcher: Any,
84
+ options: Any,
85
+ url: str,
86
+ forced_mime_type: str | None = None,
87
+ context: Any = None,
88
+ orientation: str = "from-image",
89
+ ) -> Any:
90
+ if url_fetcher is not None and not isinstance(url_fetcher, VisioFetcher):
91
+ url_fetcher = VisioFetcher(url_fetcher, _SETTINGS["tools"], _SETTINGS["limits"])
92
+ return _ORIGINAL_GET_IMAGE_FROM_URI(cache, url_fetcher, options, url, forced_mime_type=forced_mime_type, context=context, orientation=orientation)
93
+
94
+
95
+ def install(tools: Tools, limits: Limits) -> None:
96
+ """Hook the image loader of WeasyPrint. A second call changes the tools and the limits."""
97
+ _SETTINGS["tools"] = tools
98
+ _SETTINGS["limits"] = limits
99
+ wp_document.original_get_image_from_uri = _get_image_from_uri