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 +88 -0
- weasyvsdx/convert.py +223 -0
- weasyvsdx/errors.py +5 -0
- weasyvsdx/fetch.py +99 -0
- weasyvsdx/geometry.py +630 -0
- weasyvsdx/gradients.py +252 -0
- weasyvsdx/hatch.py +101 -0
- weasyvsdx/hidden.py +48 -0
- weasyvsdx/hidden_text.py +82 -0
- weasyvsdx/images.py +204 -0
- weasyvsdx/markers.py +193 -0
- weasyvsdx/package.py +207 -0
- weasyvsdx/pathdata.py +77 -0
- weasyvsdx/paths.py +105 -0
- weasyvsdx/py.typed +0 -0
- weasyvsdx/raw.py +101 -0
- weasyvsdx/text.py +352 -0
- weasyvsdx-0.1.0.dist-info/METADATA +275 -0
- weasyvsdx-0.1.0.dist-info/RECORD +21 -0
- weasyvsdx-0.1.0.dist-info/WHEEL +4 -0
- weasyvsdx-0.1.0.dist-info/licenses/LICENSE +21 -0
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
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
|