mpl-typst 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.
mpl_typst/__init__.py ADDED
@@ -0,0 +1,74 @@
1
+ """Package `mpl_typst` provides a :py:`matplotlib` backend for rendering
2
+ directly to Typst as well as rendering to PDF, PNG, SVG indirectly through
3
+ Typst renderer.
4
+
5
+ One can import `mpl_typst.as_default` module in order to use `mpl_typst`
6
+ backend by default.
7
+
8
+ .. code:: python
9
+
10
+ import mpl_typst.as_default
11
+
12
+ Or one can configure it manually.
13
+
14
+ .. code:: python
15
+
16
+ import matplotlib
17
+ import mpl_typst
18
+ mpl.use('module://mpl_typst')
19
+
20
+ Also, it is possible to use rendering context as usual to override backend.
21
+
22
+ .. code:: python
23
+
24
+ import matplotlib as mpl
25
+ import mpl_typst
26
+ with mpl.rc_context({'backend': 'module://mpl_typst'}):
27
+ ...
28
+ """
29
+
30
+ from contextlib import contextmanager
31
+ from os import PathLike
32
+ from pathlib import Path
33
+ from typing import Any, Generator
34
+
35
+ import matplotlib as mpl
36
+ from mpl_typst.backend import (FigureCanvas, FigureManager, TypstFigureCanvas,
37
+ TypstFigureManager, TypstRenderer,
38
+ TypstRenderingError)
39
+
40
+ __all__ = ('BACKEND', 'FigureCanvas', 'FigureManager', 'TypstFigureCanvas',
41
+ 'TypstFigureManager', 'TypstGraphicsContext', 'TypstRenderer',
42
+ 'TypstRenderingError', 'rc_context', 'use')
43
+
44
+ # Backend specification for use with `matplotlib.use`
45
+ BACKEND = 'module://mpl_typst'
46
+
47
+
48
+ @contextmanager
49
+ def rc_context(
50
+ rc: dict[str, Any] | None = None,
51
+ fname: PathLike | Path | str | None = None,
52
+ ) -> Generator[None, None, None]:
53
+ """A shortcut for using Typst as default backend in a context.
54
+
55
+ It forward all arguments to original :py:`matplotlib.rc_context` context
56
+ manager but it enforces use of `mpl_typst` backend in the beginning of
57
+ newly created context.
58
+
59
+ .. code:: python
60
+
61
+ import mpl_typst
62
+ with mpl_typst.rc_context():
63
+ ...
64
+ """
65
+ original_backend = mpl.rcParams['backend']
66
+ with mpl.rc_context(rc, fname):
67
+ mpl.use(BACKEND)
68
+ yield
69
+ mpl.use(original_backend)
70
+
71
+
72
+ def use(*, force: bool = True):
73
+ """Set Typst rendering backend as default on (see :py:`matplotlib.use`)."""
74
+ mpl.use(BACKEND)
@@ -0,0 +1,7 @@
1
+ """This module is aimed to use by end-users in order to configure `mpl_typst`
2
+ as default backend.
3
+ """
4
+
5
+ from mpl_typst import use
6
+
7
+ use() # Use this MPL backend as default.
mpl_typst/backend.py ADDED
@@ -0,0 +1,420 @@
1
+ import pathlib
2
+ import re
3
+ import subprocess
4
+ from datetime import date, datetime
5
+ from io import BytesIO, StringIO
6
+ from shutil import copyfileobj
7
+ from tempfile import TemporaryDirectory
8
+ from typing import Any, Literal, Optional, Self, TextIO, Type
9
+
10
+ import numpy as np
11
+ from matplotlib import get_cachedir
12
+ from matplotlib.backend_bases import (FigureCanvasBase, FigureManagerBase,
13
+ GraphicsContextBase, RendererBase,
14
+ register_backend)
15
+ from matplotlib.figure import Figure
16
+ from matplotlib.font_manager import FontProperties
17
+ from matplotlib.path import Path
18
+ from matplotlib.text import Text
19
+ from matplotlib.transforms import Affine2DBase, Transform
20
+ from matplotlib.typing import ColorType
21
+ from mpl_typst.typst import Array, Block, Call, Content, Dictionary, Scalar
22
+ from mpl_typst.typst import Writer as TypstWriter
23
+ from numpy.typing import ArrayLike
24
+
25
+ __all__ = ('FigureCanvas', 'FigureManager', 'TypstFigureCanvas',
26
+ 'TypstFigureManager', 'TypstGraphicsContext', 'TypstRenderer',
27
+ 'TypstRenderingError')
28
+
29
+ PROLOGUE = pathlib.Path(__file__).parent / 'prologue.typ'
30
+
31
+ RE_ERROR = re.compile(
32
+ r'^(?P<filename>.*):(?P<line>\d+):(?P<column>\d+): error: (?P<reason>.*)$')
33
+
34
+ TPL_ERROR = ' {filename}:{line}:{column}: {reason}'
35
+
36
+
37
+ class TypstRenderingError(RuntimeError):
38
+ """Represent an error occured in rendering target with Typst binary."""
39
+
40
+ def __init__(self, stdout: str, stderr: str, errors: list[dict[str, Any]]):
41
+ header = (f'Typst renderer failed with {len(errors)} errors. '
42
+ 'They are shown below')
43
+ lines = [header]
44
+ for error in errors:
45
+ lines.append(TPL_ERROR.format(**error))
46
+ message = '\n'.join(lines)
47
+
48
+ super().__init__(message)
49
+ self.stdout = stdout
50
+ self.stderr = stderr
51
+ self.errors = errors
52
+
53
+ def to_dict(self):
54
+ return {'stdout': self.stdout, 'stderr': self.stderr,
55
+ 'errors': self.errors}
56
+
57
+
58
+ class TypstRenderer(RendererBase):
59
+ """Typst renderer handles drawing/rendering operations."""
60
+
61
+ def __init__(self, figure: Figure, fout: TextIO,
62
+ metadata: dict[str, str] = {}):
63
+ super().__init__()
64
+ self.figure = figure
65
+ self.fout = fout
66
+ self.metadata = metadata
67
+
68
+ self.width = self.figure.get_figwidth()
69
+ self.height = self.figure.get_figheight()
70
+ self.dpi = self.figure.dpi
71
+ self.timestamp = datetime.now().replace(microsecond=0)
72
+
73
+ self.writer: Optional[TypstWriter] = None
74
+ self.main: Optional[Block] = None
75
+
76
+ def __enter__(self) -> Self:
77
+ # First of all, add some helpers for rendering at the beginning.
78
+ with open(PROLOGUE) as fin:
79
+ template = fin.read()
80
+ text = template.replace('{{ date }}', self.timestamp.isoformat())
81
+ self.fout.write(text)
82
+ self.fout.write('\n')
83
+
84
+ # Now configure document geometry and set metadata.
85
+ if self.metadata:
86
+ title = 'none'
87
+ if value := self.metadata.get('title'):
88
+ escaped = value.replace('"', '\"')
89
+ title = f'"{escaped}"'
90
+
91
+ author = '()'
92
+ if value := self.metadata.get('author'):
93
+ escaped = value.replace('"', '\"')
94
+ author = f'"{value}"'
95
+
96
+ date_ = 'auto'
97
+ if ts := self.metadata.get('date', self.timestamp.date()):
98
+ if isinstance(ts, datetime):
99
+ ts: date = ts.date()
100
+ elif isinstance(ts, date):
101
+ ts: date = ts
102
+ elif isinstance(ts, str):
103
+ ts: date = datetime.fromisoformat(ts).date()
104
+ else:
105
+ raise ValueError(f'Wrong date format in metadata: {ts}.')
106
+ date_ = (f'datetime(year: {ts.year}, month: {ts.month}, '
107
+ f'day: {ts.day})')
108
+
109
+ self.fout.write(f'#set document(title: {title}, author: {author}, '
110
+ f'date: {date_})\n')
111
+ self.fout.write(f'#set page(width: {self.width}in, '
112
+ f'height: {self.height}in, margin: 0pt)\n')
113
+ self.fout.write('\n')
114
+
115
+ # Create a main block for drawing.
116
+ self.main = Block()
117
+ return self
118
+
119
+ def __exit__(self, exc_type, exc_value, traceback):
120
+ self.fout.write('#') # Escape command.
121
+ self.writer = TypstWriter(self.fout)
122
+ expr = Call('block', self.main, spacing=Scalar(0, 'pt'),
123
+ above=Scalar(0, 'pt'), below=Scalar(0, 'pt'),
124
+ width=Scalar(self.width, 'in'),
125
+ height=Scalar(self.height, 'in'))
126
+ expr.to_string(self.writer)
127
+
128
+ def draw_image(self, gc: GraphicsContextBase, x: float, y: float,
129
+ im: ArrayLike, transform: Affine2DBase | None = None):
130
+ raise NotImplementedError
131
+
132
+ def draw_path(self, gc: GraphicsContextBase, path: Path,
133
+ transform: Transform, rgbFace: ColorType | None = None):
134
+ # Transform y-coordinates since Oy axis is flipped.
135
+ def normalize(coords) -> tuple[float, ...]:
136
+ result = []
137
+ for ix, coord in enumerate(coords):
138
+ if (ix % 2) == 1:
139
+ coord = self.height - coord / self.dpi
140
+ else:
141
+ coord = coord / self.dpi
142
+ result.append(coord)
143
+ return tuple(result)
144
+
145
+ # Configure how to fill the path.
146
+ fill = None
147
+ if rgbFace is not None:
148
+ fill = Call('rgb', *[Scalar(c * 100, '%') for c in rgbFace])
149
+
150
+ # Configure basic appearance of a line.
151
+ if (capstyle := gc.get_capstyle()) == 'projecting':
152
+ capstyle = 'square'
153
+ colour = Call('rgb', *[Scalar(c * 100, '%') for c in gc.get_rgb()])
154
+ stroke = Call(
155
+ 'stroke',
156
+ paint=colour,
157
+ thickness=Scalar(gc.get_linewidth(), 'pt'),
158
+ cap=Scalar(capstyle),
159
+ join=Scalar(gc.get_joinstyle()),
160
+ )
161
+
162
+ # Configure appearance of dashed line.
163
+ (offset, bounds) = gc.get_dashes()
164
+ if bounds:
165
+ bounds = Array([Scalar(bound, 'pt') for bound in bounds])
166
+ if offset:
167
+ stroke.kwargs.update({
168
+ 'dash': Dictionary({
169
+ 'array': bounds,
170
+ 'phase': Scalar(offset, 'pt'),
171
+ })
172
+ })
173
+ else:
174
+ stroke.kwargs.update({'dash': bounds})
175
+
176
+ # Construct a `path` routine invokation.
177
+ line = Call('path', fill=fill, stroke=stroke)
178
+ for points, code in path.iter_segments(transform):
179
+ points = normalize(points)
180
+ match code:
181
+ case Path.STOP:
182
+ pass
183
+ case Path.MOVETO | Path.LINETO:
184
+ x, y = points
185
+ line.args.append(Array([Scalar(x, 'in'), Scalar(y, 'in')]))
186
+ case Path.CURVE3:
187
+ cx, cy, px, py = points
188
+ p = Array([Scalar(px, 'in'), Scalar(py, 'in')])
189
+ c = Array([Scalar(cx - px, 'in'), Scalar(cy - py, 'in')])
190
+ line.args.append(Array([p, c]))
191
+ case Path.CURVE4:
192
+ inx, iny, outx, outy, px, py = points
193
+ p = Array([Scalar(px, 'in'), Scalar(py, 'in')])
194
+ inp = Array([Scalar(inx - px, 'in'),
195
+ Scalar(iny - py, 'in')])
196
+ out = Array([Scalar(outx - px, 'in'),
197
+ Scalar(outy - py, 'in')])
198
+ line.args.append(Array([p, inp, out]))
199
+ case Path.CLOSEPOLY:
200
+ line.kwargs.update({'closed': True})
201
+
202
+ # Place a line path relative to parent block element without layouting.
203
+ place = Call('place', line, dx=Scalar(0, 'in'), dy=Scalar(0, 'in'))
204
+ self.main.append(place)
205
+
206
+ def draw_quad_mesh(self, gc, master_transform, meshWidth, meshHeight,
207
+ coordinates, offsets, offsetTrans, facecolors,
208
+ antialiased, edgecolors):
209
+ # TODO(@daskol): Apply offset transformation.
210
+ vertices = np.array([master_transform.transform(point)
211
+ for point in coordinates])
212
+ vertices /= self.dpi # Points to inches.
213
+ vertices[..., 1] = self.height - vertices[..., 1] # Inverted Oy axis.
214
+
215
+ # Cast coordinates to term in internal representation.
216
+ shape = vertices.shape
217
+ vertices = np.array([Scalar(el, 'in') for el in vertices.flatten()])
218
+ vertices = vertices.reshape(shape)
219
+
220
+ for i in range(vertices.shape[0] - 1):
221
+ # TODO(@daskol): What about shapes coordinates, facecolors, and
222
+ # edgecolors?
223
+ facecolor = [Scalar(c * 100, '%') for c in facecolors[i]]
224
+ for j in range(vertices.shape[1] - 1):
225
+ # Create filling color.
226
+ fill = Call('rgb', *facecolor)
227
+
228
+ # Create stroke if line width is given.
229
+ if edgecolors:
230
+ edgecolor = [Scalar(c * 100, '%') for c in edgecolors[i]]
231
+ else:
232
+ edgecolor = facecolor
233
+ stroke = None
234
+ if (lw := gc.get_linewidth()) > 0:
235
+ paint = Call('rgb', *edgecolor)
236
+ stroke = Call('stroke', paint=paint,
237
+ thickness=Scalar(lw, 'pt'))
238
+
239
+ # TODO(@daskol): Take into account joints, dashes, and hatches.
240
+
241
+ # Select quad and walk over it anti-clockwise.
242
+ quad = vertices[i:i + 2, j:j + 2]
243
+ quad = quad.reshape(4, 2)
244
+ quad = quad[[2, 3, 1, 0]]
245
+ line = Call('path', fill=fill, stroke=stroke, closed=True)
246
+ for coords in quad:
247
+ point = Array(coords)
248
+ line.args.append(point)
249
+
250
+ # Put on canvas with respect of the origin.
251
+ place = Call('place', line,
252
+ dx=Scalar(0, 'in'), dy=Scalar(0, 'in'))
253
+ self.main.append(place)
254
+
255
+ def draw_text(self, gc: GraphicsContextBase, x: float, y: float, s: str,
256
+ prop: FontProperties, angle: float,
257
+ ismath: bool | Literal['TeX'] = False, *,
258
+ mtext: Text | None = None):
259
+ alignment = 'center + horizon'
260
+ baseline = False
261
+ fontsize = prop.get_size_in_points()
262
+ if mtext:
263
+ pos = mtext.get_unitless_position()
264
+ x, y = mtext.get_transform().transform(pos)
265
+ x = x / self.figure.dpi
266
+ y = self.height - y / self.figure.dpi
267
+ halign = mtext.get_horizontalalignment()
268
+ valign = mtext.get_verticalalignment()
269
+ match valign:
270
+ case 'center':
271
+ valign = 'horizon'
272
+ case 'center_baseline':
273
+ valign = 'horizon'
274
+ baseline = True
275
+ case 'baseline':
276
+ valign = 'bottom'
277
+ baseline = True
278
+ alignment = f'{halign} + {valign}'
279
+ fontsize = mtext.get_fontsize()
280
+ angle = mtext.get_rotation()
281
+ else:
282
+ x = x / self.figure.dpi
283
+ y = self.height + y / self.figure.dpi
284
+
285
+ elem = Call('draw-text',
286
+ Content(s),
287
+ dx=Scalar(x, 'in'),
288
+ dy=Scalar(y, 'in'),
289
+ size=Scalar(fontsize, 'pt'),
290
+ alignment=alignment,
291
+ baseline=baseline,
292
+ angle=Scalar(360 - angle, 'deg'))
293
+ self.main.append(elem)
294
+
295
+ def flipy(self):
296
+ """Axis Oy points to bottom."""
297
+ return True
298
+
299
+ def get_canvas_width_height(self) -> tuple[float, float]:
300
+ return self.width, self.height
301
+
302
+ def get_text_width_height_descent(
303
+ self, s: str, prop: FontProperties,
304
+ ismath: bool | Literal['TeX'] = False,
305
+ ) -> tuple[float, float, float]:
306
+ return super().get_text_width_height_descent(s, prop, ismath)
307
+
308
+ def new_gc(self) -> Type[GraphicsContextBase]:
309
+ return TypstGraphicsContext()
310
+
311
+ def points_to_pixels(self, points):
312
+ return points # if backend doesn't have dpi, e.g., postscript or svg
313
+
314
+
315
+ class TypstGraphicsContext(GraphicsContextBase):
316
+ """In Typst, all the work is done by the renderer, mapping line styles to
317
+ Typst calls.
318
+ """
319
+
320
+
321
+ class TypstFigureManager(FigureManagerBase):
322
+ """Non-interactive backend requires nothing."""
323
+
324
+
325
+ class TypstFigureCanvas(FigureCanvasBase):
326
+ """The canvas the figure renders into. Calls the draw and print fig
327
+ methods, creates the renderers, etc.
328
+
329
+ Attributes
330
+ ----------
331
+ figure : `~matplotlib.figure.Figure`
332
+ A high-level Figure instance
333
+ """
334
+
335
+ filetypes = {**FigureCanvasBase.filetypes, 'typ': 'Typst Markup'}
336
+
337
+ fixed_dpi: Optional[float] = None
338
+
339
+ manager_class = TypstFigureManager
340
+
341
+ def draw(self):
342
+ """Draw the figure using the renderer."""
343
+ self.figure.draw_without_rendering()
344
+ super().draw()
345
+
346
+ def get_default_filetype(self):
347
+ return 'typ'
348
+
349
+ def print_pdf(self, filename, **kwargs):
350
+ return self._print_as('pdf', filename, **kwargs)
351
+
352
+ def print_png(self, filename, **kwargs):
353
+ return self._print_as('png', filename, **kwargs)
354
+
355
+ def print_svg(self, filename, **kwargs):
356
+ return self._print_as('svg', filename, **kwargs)
357
+
358
+ def print_typ(self, filename, *, metadata=None, **kwargs):
359
+ # TODO(@daskol): Matplotlib shows quite unexpectedbehaviour. It renders
360
+ # the same figure multiple types with randering to temporary buffer
361
+ # (BytesIO) rather than directly to file. So, it would be great to
362
+ # rewrite this function and neighnoring one to make it file-agnostic.
363
+ if isinstance(filename, BytesIO):
364
+ buf = StringIO()
365
+ with TypstRenderer(self.figure, buf, metadata or {}) as renderer:
366
+ self.figure.draw(renderer)
367
+ content = buf.getvalue().encode('utf-8')
368
+ buf.write(content)
369
+ else:
370
+ with open(filename, 'w') as fout:
371
+ metadata = metadata or {}
372
+ with TypstRenderer(self.figure, fout, metadata) as renderer:
373
+ self.figure.draw(renderer)
374
+
375
+ def _print_as(self, fmt, filename, *, metadata=None, **kwargs):
376
+ # Set up default metadata. We use metadata as a condition for setting
377
+ # canvas geometry in rendering.
378
+ metadata = metadata or {}
379
+ if 'author' not in metadata:
380
+ metadata['author'] = 'mpl_typst (Typst Matplotlib backend)'
381
+
382
+ with TemporaryDirectory(prefix='typst-', dir=get_cachedir()) as tmpdir:
383
+ # Render figure in pure textual typst markup.
384
+ inp_path = pathlib.Path(tmpdir) / 'main.typ'
385
+ self.print_typ(inp_path, metadata=metadata, **kwargs)
386
+
387
+ # Render typst markup running typst binary.
388
+ out_path = inp_path.with_suffix(f'.{fmt}')
389
+ dpi = kwargs.get('dpi', self.figure.dpi)
390
+ cmd = ['typst', 'compile', f'--root={tmpdir}', f'--format={fmt}',
391
+ '--diagnostic-format=short', f'--ppi={dpi}',
392
+ str(inp_path), str(out_path)]
393
+ proc = subprocess.run(cmd, capture_output=True, cwd=tmpdir)
394
+ if proc.returncode:
395
+ kwargs = {'stdout': proc.stdout.decode('utf-8'),
396
+ 'stderr': proc.stderr.decode('utf-8'),
397
+ 'errors': []}
398
+ for m in RE_ERROR.finditer(kwargs['stderr']):
399
+ error = m.groupdict()
400
+ error['line'] = int(error['line'])
401
+ error['column'] = int(error['column'])
402
+ kwargs['errors'].append(error)
403
+ raise TypstRenderingError(**kwargs)
404
+
405
+ # Move rendered figure from temporary directory to target location.
406
+ if isinstance(filename, BytesIO):
407
+ with open(out_path, 'rb') as fin:
408
+ copyfileobj(fin, filename)
409
+ else:
410
+ dst_path = pathlib.Path(filename)
411
+ dst_path.parent.mkdir(exist_ok=True, parents=True)
412
+ out_path.rename(dst_path)
413
+
414
+
415
+ # Now, just provide the standard names that `backend.__init__` is expecting.
416
+ FigureCanvas = TypstFigureCanvas
417
+ FigureManager = TypstFigureManager
418
+
419
+ # Register file format for Typst markup.
420
+ register_backend('typ', 'mpl_typst', 'Typst markup')
mpl_typst/prologue.typ ADDED
@@ -0,0 +1,53 @@
1
+ /**
2
+ * This is autogenerated Typst markup from a scene description created by
3
+ * matplotlib in Python.
4
+ *
5
+ * Timestamp: {{ date }}.
6
+ */
7
+
8
+ #let draw-text(dx: 0pt, dy: 0pt, size: 10pt, alignment: center + horizon, baseline: false, angle: 0deg, body) = style(styles => {
9
+ // In order to align a text properly, we need to configure bounding box of a
10
+ // text.
11
+ let top-edge = "cap-height"
12
+ let bot-edge = "bounds"
13
+ let valign = alignment.y;
14
+ if baseline and valign == bottom {
15
+ bot-edge = "baseline"
16
+ }
17
+ if baseline and valign == horizon {
18
+ bot-edge = "baseline"
19
+ }
20
+
21
+ // Measure shape of text block.
22
+ let content = text(size: size, top-edge: top-edge, bottom-edge: bot-edge, body)
23
+ let shape = measure(content, styles)
24
+
25
+ // Adjust horizontal position.
26
+ let px = dx
27
+ if alignment.x == left {
28
+ // Do nothing.
29
+ } else if alignment.x == center {
30
+ px -= shape.width / 2
31
+ } else if alignment.x == right {
32
+ px -= shape.width
33
+ }
34
+
35
+ // Adjust vertical position.
36
+ let py = dy
37
+ if valign == top {
38
+ // Do nothing.
39
+ } else if valign == horizon {
40
+ py -= shape.height / 2
41
+ } else if valign == bottom {
42
+ py -= shape.height
43
+ }
44
+
45
+ // Rotate text block if required. Note, that matplotlib supports two modes
46
+ // for rotation while Typst allows to perform only the second one.
47
+ if angle != 0deg {
48
+ content = rotate(angle, origin: alignment, content)
49
+ }
50
+
51
+ // Finaly, place a content block in calculated displacement.
52
+ place(dx: px, dy: py, content)
53
+ })
mpl_typst/typst.py ADDED
@@ -0,0 +1,182 @@
1
+ from contextlib import contextmanager
2
+ from dataclasses import dataclass, field
3
+ from io import StringIO
4
+ from typing import Any, Literal, Self
5
+
6
+ __all__ = ('Array', 'Block', 'Content', 'Dictionary', 'Node', 'Scalar',
7
+ 'Writer')
8
+
9
+
10
+ class Writer:
11
+
12
+ def __init__(self, buf: StringIO | None = None, indent: int = 0):
13
+ self.buf = buf or StringIO()
14
+ self.indent_ = indent
15
+ self.indented = False
16
+
17
+ @contextmanager
18
+ def indent(self):
19
+ self.indent_ += 1
20
+ yield
21
+ self.indent_ -= 1
22
+
23
+ def to_string(self, value: Any) -> Self:
24
+ match value:
25
+ case Node():
26
+ value.to_string(self)
27
+ case bool():
28
+ self.write(str(value).lower())
29
+ case None:
30
+ self.write('none')
31
+ case _:
32
+ self.write(str(value))
33
+ return self
34
+
35
+ def write(self, content: str):
36
+ if not self.indented:
37
+ self.indented = True
38
+ self.buf.write(' ' * (2 * self.indent_))
39
+ self.buf.write(content)
40
+
41
+ def writeln(self, content: str | None = None):
42
+ if content:
43
+ self.write(content)
44
+ self.buf.write('\n')
45
+ self.indented = False
46
+
47
+
48
+ class Node:
49
+
50
+ def __str__(self) -> str:
51
+ buf = StringIO()
52
+ self.to_string(buf)
53
+ return buf.getvalue()
54
+
55
+ def to_string(self, writer: Writer):
56
+ raise NotImplementedError
57
+
58
+
59
+ Unit = Literal['pt', 'cm', 'mm', 'in', 'em', 'px', 'deg', 'rad', '%']
60
+
61
+
62
+ @dataclass
63
+ class Scalar(Node):
64
+
65
+ value: float | int | str
66
+
67
+ unit: Unit | None = None
68
+
69
+ def __post_init__(self):
70
+ if self.unit and isinstance(self.value, str):
71
+ raise ValueError(
72
+ f'String literals cannot have a unit: {self.unit}.')
73
+
74
+ def to_string(self, writer: Writer):
75
+ if self.unit:
76
+ writer.write(f'{self.value}{self.unit}')
77
+ elif isinstance(self.value, str):
78
+ writer.write(f'"{self.value}"')
79
+ else:
80
+ writer.write(f'{self.value}')
81
+
82
+
83
+ @dataclass
84
+ class Content(Node):
85
+
86
+ content: str
87
+
88
+ def to_string(self, writer: Writer):
89
+ writer.write('[')
90
+ writer.write(self.content)
91
+ writer.write(']')
92
+
93
+
94
+ @dataclass
95
+ class Array(Node):
96
+
97
+ array: list[Any] = field(default_factory=list)
98
+
99
+ def append(self, value):
100
+ self.array.append(value)
101
+
102
+ def to_string(self, writer: Writer, *, interior_only=False):
103
+ if not interior_only:
104
+ writer.write('(')
105
+ for ix, item in enumerate(self.array):
106
+ if ix > 0:
107
+ if interior_only:
108
+ writer.writeln(',') # Indentation in Call expression.
109
+ else:
110
+ writer.write(', ')
111
+ writer.to_string(item)
112
+ if not interior_only:
113
+ writer.write(')')
114
+
115
+
116
+ @dataclass
117
+ class Dictionary(Node):
118
+
119
+ dictionary: dict[str, Any] = field(default_factory=dict)
120
+
121
+ def to_string(self, writer: Writer, *, interior_only=False):
122
+ if not interior_only:
123
+ writer.write('(')
124
+ for ix, (key, val) in enumerate(self.dictionary.items()):
125
+ if ix > 0:
126
+ if interior_only:
127
+ writer.writeln(',') # Indentation in Call expression.
128
+ else:
129
+ writer.write(', ')
130
+ writer.write(f'{key}: ')
131
+ writer.to_string(val)
132
+ if not interior_only:
133
+ writer.write(')')
134
+
135
+ def update(self, items):
136
+ self.dictionary.update(items)
137
+
138
+
139
+ @dataclass(init=False)
140
+ class Call(Node):
141
+
142
+ name: str
143
+
144
+ args: Array = field(default_factory=Array)
145
+
146
+ kwargs: Array = field(default_factory=Dictionary)
147
+
148
+ def __init__(self, name: str, *args, **kwargs):
149
+ self.name = name
150
+ self.args = Array([*args])
151
+ self.kwargs = Dictionary(kwargs)
152
+
153
+ def to_string(self, writer: Writer):
154
+ writer.write(f'{self.name}(')
155
+ with writer.indent():
156
+ writer.writeln()
157
+ self.args.to_string(writer, interior_only=True)
158
+ if self.args.array and self.kwargs.dictionary:
159
+ writer.writeln(',')
160
+ self.kwargs.to_string(writer, interior_only=True)
161
+ writer.write(')')
162
+
163
+
164
+ @dataclass
165
+ class Block(Node):
166
+
167
+ exprs: list[Node] = field(default_factory=list)
168
+
169
+ def append(self, expr: Node):
170
+ self.exprs.append(expr)
171
+
172
+ def to_string(self, writer: Writer):
173
+ if not self.exprs:
174
+ writer.write('{}')
175
+ return
176
+
177
+ writer.writeln('{')
178
+ with writer.indent():
179
+ for expr in self.exprs:
180
+ expr.to_string(writer)
181
+ writer.writeln()
182
+ writer.write('}')
@@ -0,0 +1,18 @@
1
+ from mpl_typst.typst import Block, Call, Scalar, Writer
2
+
3
+
4
+ def test_sanity():
5
+ block = Block()
6
+
7
+ arr = [0.1, Scalar(1, 'pt'), '2em']
8
+ dic = {'phase': 0, 'array': arr, 'baseline': False}
9
+ path = Call('path', *arr, **dic)
10
+ block.append(path)
11
+
12
+ components = (0, 0, 0, 1)
13
+ rgb = Call('rgb', *components)
14
+ block.append(rgb)
15
+
16
+ writer = Writer()
17
+ block.to_string(writer)
18
+ assert writer.buf.getvalue() != ''
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 Daniel Bershatsky
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,104 @@
1
+ Metadata-Version: 2.1
2
+ Name: mpl-typst
3
+ Version: 0.1.0
4
+ Summary: Typst backend for matplotlib (Python visualization library).
5
+ Author-email: Daniel Bershatsky <daniel.bershatsky@gmail.com>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/daskol/typst-mpl-backend
8
+ Project-URL: Repository, https://github.com/daskol/typst-mpl-backend.git
9
+ Classifier: Development Status :: 5 - Production/Stable
10
+ Classifier: Environment :: Other Environment
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Intended Audience :: Education
13
+ Classifier: Intended Audience :: End Users/Desktop
14
+ Classifier: Intended Audience :: Information Technology
15
+ Classifier: Intended Audience :: Science/Research
16
+ Classifier: License :: OSI Approved :: MIT License
17
+ Classifier: Natural Language :: English
18
+ Classifier: Operating System :: OS Independent
19
+ Classifier: Programming Language :: Python
20
+ Classifier: Programming Language :: Python :: 3
21
+ Classifier: Programming Language :: Python :: 3.11
22
+ Classifier: Programming Language :: Python :: 3.12
23
+ Classifier: Topic :: Scientific/Engineering
24
+ Classifier: Topic :: Scientific/Engineering :: Visualization
25
+ Classifier: Topic :: Text Processing
26
+ Classifier: Topic :: Text Processing :: Markup
27
+ Classifier: Topic :: Utilities
28
+ Classifier: Typing :: Typed
29
+ Requires-Python: <4,>=3.11
30
+ Description-Content-Type: text/markdown
31
+ License-File: LICENSE
32
+ Requires-Dist: matplotlib
33
+ Provides-Extra: dev
34
+ Requires-Dist: flake8; extra == "dev"
35
+ Requires-Dist: isort; extra == "dev"
36
+ Requires-Dist: pytest>=7; extra == "dev"
37
+
38
+ # Typst Matplotlib Backend
39
+
40
+ *Typst backend for matplotlib (Python visualization library).*
41
+
42
+ ## Overview
43
+
44
+ At the moment, Typst supports main vector and raster image formats. Namely,
45
+ images in PNG, JPEG, GIF, or SVG format can be easily emplaced in a document
46
+ with Typst. However, it is **not possible** to keep metadata and annotations.
47
+ These are mandatory in order to allow a reader to select and interact with
48
+ vector content (e.g. text) on images. Although SVG can contain text metadata in
49
+ principle, Typst does not support this feature at the moment but still it is
50
+ able to render SVG as a vector content.
51
+
52
+ This package solves this problem for `matplotlib` users. Basically, this
53
+ project implements a custom render (or backend) for `matplotlib` which
54
+ generates `typ`-file containing Typst markup. Generated markup file can be
55
+ later included in the original markup so that the resulting PDF will have
56
+ interactable content. Matplotlib exploits exactly the same strategy in order to
57
+ generate PGF-files &mdash; a LaTeX markup itself &mdash; which can be included
58
+ into LaTeX markup directly.
59
+
60
+ ## Usage
61
+
62
+ In order to render image with `mpl_typst` one can import `mpl_typst.as_default`
63
+ module in order to use `mpl_typst` backend by default.
64
+
65
+ ```python
66
+ import mpl_typst.as_default
67
+ ```
68
+
69
+ Or one can configure it manually.
70
+
71
+ ```python
72
+ import matplotlib
73
+ import mpl_typst
74
+ mpl.use('module://mpl_typst')
75
+ ```
76
+
77
+ Also, it is possible to use rendering context as usual to override backend.
78
+
79
+ ```python
80
+ import matplotlib as mpl
81
+ import mpl_typst
82
+ with mpl.rc_context({'backend': 'module://mpl_typst'}): # or mpl_typst.BACKEND
83
+ ...
84
+ ```
85
+
86
+ Next, you can save your figure to `typ` as usual.
87
+
88
+ ```python
89
+ fig, ax = plt.subplots()
90
+ ...
91
+ fig.savefig('line-plot-simple.typ')
92
+ ```
93
+
94
+ As soon as you get a `typ`-file you can included it directly to `figure`
95
+ function and adjust figure time.
96
+
97
+ ```typst
98
+ #figure(
99
+ include "line-plot-simple.typ",
100
+ kind: image,
101
+ caption: [Simple line plot],
102
+ placement: top,
103
+ ) <line-plot-simple>
104
+ ```
@@ -0,0 +1,12 @@
1
+ mpl_typst/__init__.py,sha256=1NH9wxnWW5gWADkQ961EX3iDDy3iThtTXyhZ0InKuZg,2089
2
+ mpl_typst/as_default.py,sha256=TjbPHcx_4xrWa8tVQ1rsgWRfNd7qi2_6O4tG60ChLr0,172
3
+ mpl_typst/backend.py,sha256=2j0UD1cFGUhQJNEGK9M2EAR84tsMABEAkOqquPBMhws,16735
4
+ mpl_typst/prologue.typ,sha256=nCffqDEvkSb5ZiOptZnjABqphISi3KzWk5Sfi2CZ0do,1480
5
+ mpl_typst/typst.py,sha256=PRF792Db1kBFEgDPQgCk5gh3euEkgxjqyMMh8pNxyZA,4658
6
+ mpl_typst/typst_test.py,sha256=hLX8V1g6M7j3lafgQDLBA2z7cChRoyhW08AGCqXk6Wc,432
7
+ mpl_typst-0.1.0.dist-info/LICENSE,sha256=2G5-6WwJXB6j5mWLRTa-xU3G-PK4LuqJW-Mt7CBmp8E,1074
8
+ mpl_typst-0.1.0.dist-info/METADATA,sha256=f1IpWyg94amffMQ6udVZAYjrFNfPbB7eZrHlj8DPgrg,3509
9
+ mpl_typst-0.1.0.dist-info/WHEEL,sha256=eOLhNAGa2EW3wWl_TU484h7q1UNgy0JXjjoqKoxAAQc,92
10
+ mpl_typst-0.1.0.dist-info/top_level.txt,sha256=Bc6UA_Jam1EyTtRfp3uxF07DEqDClZPSQrY6LVSJGdA,10
11
+ mpl_typst-0.1.0.dist-info/zip-safe,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
12
+ mpl_typst-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: bdist_wheel (0.44.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ mpl_typst
@@ -0,0 +1 @@
1
+