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 +74 -0
- mpl_typst/as_default.py +7 -0
- mpl_typst/backend.py +420 -0
- mpl_typst/prologue.typ +53 -0
- mpl_typst/typst.py +182 -0
- mpl_typst/typst_test.py +18 -0
- mpl_typst-0.1.0.dist-info/LICENSE +21 -0
- mpl_typst-0.1.0.dist-info/METADATA +104 -0
- mpl_typst-0.1.0.dist-info/RECORD +12 -0
- mpl_typst-0.1.0.dist-info/WHEEL +5 -0
- mpl_typst-0.1.0.dist-info/top_level.txt +1 -0
- mpl_typst-0.1.0.dist-info/zip-safe +1 -0
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)
|
mpl_typst/as_default.py
ADDED
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('}')
|
mpl_typst/typst_test.py
ADDED
|
@@ -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 — a LaTeX markup itself — 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 @@
|
|
|
1
|
+
mpl_typst
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|