amethyst-cli 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.
amethyst/render/pdf.py ADDED
@@ -0,0 +1,219 @@
1
+ """HTML to PDF, through WeasyPrint.
2
+
3
+ Two things here are less obvious than the conversion itself.
4
+
5
+ WeasyPrint is imported inside the render call, not at module scope. It loads
6
+ Pango through cffi, and when that fails it fails with an ``OSError`` naming a
7
+ dylib — which is true, and useless. Importing late means the failure lands
8
+ where there is enough context to turn it into a sentence the user can act on;
9
+ it keeps ``amethyst --help`` working on a machine with no Pango at all; and it
10
+ is the one moment at which the search path can still be repaired.
11
+
12
+ WeasyPrint also reports what it could not do — an image it failed to load, a
13
+ declaration it does not support — through the logging module. With no handler
14
+ attached those records go to stderr raw, ignoring --quiet and looking like a
15
+ crash. They are forwarded through the caller's warning channel instead.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import io
21
+ import logging
22
+ import os
23
+ import sys
24
+ from collections.abc import Iterator
25
+ from contextlib import contextmanager, redirect_stdout
26
+ from pathlib import Path
27
+ from typing import Any
28
+ from urllib.parse import urlsplit
29
+
30
+ from amethyst.document import Document
31
+ from amethyst.errors import MissingDependencyError
32
+ from amethyst.parse.assets import REMOTE_SCHEMES
33
+ from amethyst.render.base import RenderOptions, RenderResult, Warn
34
+ from amethyst.render.html import render_html
35
+
36
+ #: The logger WeasyPrint reports through, and everything below it.
37
+ WEASYPRINT_LOGGER = "weasyprint"
38
+
39
+ #: What a document is allowed to reference: files beside it, and data URIs it
40
+ #: carries itself. Both are already in hand when the conversion starts.
41
+ LOCAL_PROTOCOLS = ("file", "data")
42
+
43
+ #: Where Homebrew puts its dylibs, Apple Silicon first. Checked on disk to tell
44
+ #: "Pango is not installed" apart from "Pango is installed but unreachable",
45
+ #: which need opposite advice and are indistinguishable from the traceback.
46
+ HOMEBREW_LIB_DIRS = (Path("/opt/homebrew/lib"), Path("/usr/local/lib"))
47
+
48
+ #: The library whose absence WeasyPrint reports first. It is a GLib library
49
+ #: rather than Pango itself, which is what makes the raw error so misleading.
50
+ PANGO_MARKER = "libgobject-2.0.dylib"
51
+
52
+ #: The macOS dynamic loader's list of last-resort directories.
53
+ DYLD_FALLBACK_PATH = "DYLD_FALLBACK_LIBRARY_PATH"
54
+
55
+ WEASYPRINT_INSTALL_DOCS = (
56
+ "https://doc.courtbouillon.org/weasyprint/stable/first_steps.html"
57
+ )
58
+
59
+
60
+ def render_pdf(document: Document, options: RenderOptions) -> RenderResult:
61
+ """Convert a document to the bytes of a PDF."""
62
+ weasyprint = import_weasyprint()
63
+ html = render_html(document, options)
64
+
65
+ with forwarding_warnings(options.warn):
66
+ rendered = weasyprint.HTML(
67
+ string=html,
68
+ # Relative references in the markup resolve against the source
69
+ # file's directory, and so must the absolute paths asset
70
+ # resolution rewrote image sources to — which only works if the
71
+ # base is a file: URL rather than a bare path.
72
+ base_url=document.base_dir.resolve().as_uri() + "/",
73
+ url_fetcher=local_only_fetcher(weasyprint),
74
+ ).render()
75
+ data: bytes = rendered.write_pdf()
76
+
77
+ return RenderResult(data=data, pages=len(rendered.pages))
78
+
79
+
80
+ def local_only_fetcher(weasyprint: Any) -> Any:
81
+ """A fetcher that reads local files and never reaches the network.
82
+
83
+ Remote images *are* downloaded — by :mod:`amethyst.remote`, as a step of
84
+ the conversion, before any of this runs — and by the time a document
85
+ reaches WeasyPrint every image it can have is a file on disk. So a URL
86
+ arriving here is one that step could not get, or one it was told not to
87
+ fetch with ``--no-remote``, and rendering is the wrong moment to try
88
+ again: there is no cache, no timeout and no size limit down here, and a
89
+ conversion that quietly waits on the network is slow when it works and
90
+ mystifying when it does not. The refusal carries its own wording because
91
+ WeasyPrint's — "URI uses disallowed protocol" — reads like a security
92
+ policy rather than an image that is simply absent. Everything else
93
+ unexpected is refused by protocol.
94
+
95
+ Subclassed inside the function because the base class arrives with the
96
+ lazily imported module, and there is nothing to inherit from until then.
97
+ """
98
+
99
+ class LocalOnlyFetcher(weasyprint.URLFetcher): # type: ignore[misc, name-defined]
100
+ def fetch(self, url: str, headers: Any = None) -> Any:
101
+ if urlsplit(url).scheme in REMOTE_SCHEMES:
102
+ raise ValueError("this image was not downloaded")
103
+ return super().fetch(url, headers)
104
+
105
+ return LocalOnlyFetcher(allowed_protocols=LOCAL_PROTOCOLS)
106
+
107
+
108
+ @contextmanager
109
+ def forwarding_warnings(warn: Warn) -> Iterator[None]:
110
+ """Send WeasyPrint's log through ``warn`` for the duration of a render.
111
+
112
+ Adding a handler is also what stops the logging module falling back to its
113
+ last-resort handler, which writes to stderr no matter what the CLI wants.
114
+ """
115
+ logger = logging.getLogger(WEASYPRINT_LOGGER)
116
+ handler = _ForwardingHandler(warn)
117
+ logger.addHandler(handler)
118
+ try:
119
+ yield
120
+ finally:
121
+ logger.removeHandler(handler)
122
+
123
+
124
+ class _ForwardingHandler(logging.Handler):
125
+ """A log handler that hands each record's message to a callback."""
126
+
127
+ def __init__(self, warn: Warn) -> None:
128
+ super().__init__(level=logging.WARNING)
129
+ self._warn = warn
130
+
131
+ def emit(self, record: logging.LogRecord) -> None:
132
+ self._warn(record.getMessage())
133
+
134
+
135
+ def import_weasyprint() -> Any:
136
+ """Import WeasyPrint, or explain in one line why it will not load."""
137
+ _add_homebrew_libraries_to_search_path()
138
+ try:
139
+ # WeasyPrint prints its own installation advice when the dynamic
140
+ # libraries will not open, and prints it to stdout, where a document
141
+ # may be on its way out. It is caught and dropped: the error raised
142
+ # below says the same thing about the machine it is actually running
143
+ # on, which is the more useful half.
144
+ with redirect_stdout(io.StringIO()):
145
+ import weasyprint
146
+ except Exception as exc: # noqa: BLE001 - the cffi layer raises OSError
147
+ raise _dependency_error(exc) from exc
148
+ return weasyprint
149
+
150
+
151
+ def _add_homebrew_libraries_to_search_path() -> None:
152
+ """Point the dynamic loader at Homebrew's libraries, on macOS.
153
+
154
+ Homebrew installs Pango somewhere an interpreter Homebrew did not install
155
+ does not look, so importing WeasyPrint fails naming a dylib even though
156
+ ``brew install pango`` succeeded and the file is right there. The usual
157
+ cure is to export the fallback path in the shell before running the
158
+ command, and for the installed console script that cannot work: the script
159
+ is a ``/bin/sh`` wrapper, and macOS strips every ``DYLD_*`` variable from
160
+ the environment when it executes a system-protected binary like ``sh``.
161
+
162
+ Setting the variable here does work, late as it looks. The loader reads it
163
+ when a library is first opened rather than when the process starts, so a
164
+ change made in-process moments before the import still counts.
165
+ """
166
+ if sys.platform != "darwin":
167
+ return
168
+ directory = _installed_pango_dir()
169
+ if directory is None:
170
+ return
171
+ current = os.environ.get(DYLD_FALLBACK_PATH, "")
172
+ entries = current.split(os.pathsep) if current else []
173
+ if str(directory) not in entries:
174
+ os.environ[DYLD_FALLBACK_PATH] = os.pathsep.join([*entries, str(directory)])
175
+
176
+
177
+ def _dependency_error(exc: BaseException) -> MissingDependencyError:
178
+ """Turn an import failure into the advice that actually fixes it.
179
+
180
+ The distinction worth making is whether Pango is on the machine at all.
181
+ If it is not, there is one command to run. If it is — and the loader has
182
+ already been pointed at it — then something stranger is wrong, and telling
183
+ someone to install a library they already installed sends them in circles.
184
+ """
185
+ if isinstance(exc, ModuleNotFoundError) and exc.name == "weasyprint":
186
+ return MissingDependencyError(
187
+ "WeasyPrint is not installed, so PDF output is unavailable.",
188
+ hint="Reinstall Amethyst, or convert to DOCX with -f docx.",
189
+ )
190
+
191
+ if sys.platform != "darwin":
192
+ return MissingDependencyError(
193
+ "PDF output needs Pango, which could not be loaded.",
194
+ hint=f"Install your system's Pango packages: {WEASYPRINT_INSTALL_DOCS}",
195
+ )
196
+
197
+ installed_at = _installed_pango_dir()
198
+ if installed_at is None:
199
+ return MissingDependencyError(
200
+ "PDF output needs Pango, which is not installed.",
201
+ hint="Install it with `brew install pango`.",
202
+ )
203
+ return MissingDependencyError(
204
+ f"Pango is installed in {installed_at}, but WeasyPrint could not load "
205
+ "it from there.",
206
+ hint=(
207
+ "Most often the libraries and this Python are built for different "
208
+ "architectures — check that `brew config` and this Python agree "
209
+ "on arm64 against x86_64."
210
+ ),
211
+ )
212
+
213
+
214
+ def _installed_pango_dir() -> Path | None:
215
+ """The Homebrew lib directory holding Pango's GLib dependency, if any."""
216
+ for directory in HOMEBREW_LIB_DIRS:
217
+ if (directory / PANGO_MARKER).exists():
218
+ return directory
219
+ return None