Skip to content

Commit

Permalink
Merge pull request #4 from joneuhauser/fix-path-computation
Browse files Browse the repository at this point in the history
Correct path conversion (cubic and quadratic beziers, subpaths)
  • Loading branch information
daskol authored Sep 1, 2024
2 parents 9a3187c + af3ddc3 commit f18c1ac
Show file tree
Hide file tree
Showing 6 changed files with 140 additions and 22 deletions.
1 change: 1 addition & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
*.png binary
1 change: 1 addition & 0 deletions .mailmap
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<jonathan.hofinger@gmx.de> <jonathan.neuhauser@kit.edu>
73 changes: 52 additions & 21 deletions mpl_typst/backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -174,35 +174,66 @@ def normalize(coords) -> tuple[float, ...]:
else:
stroke.kwargs.update({'dash': bounds})

# Construct a `path` routine invokation.
line = Call('path', fill=fill, stroke=stroke)
# The path in node form, that is [[[[in_handle, node, out_handle] ...],
# True], [[subpath2, closed]]].
superpath = [[[], False]]
current_subpath = superpath[-1][0]
for points, code in path.iter_segments(transform):

points = normalize(points)

match code:
case Path.STOP:
pass
case Path.MOVETO | Path.LINETO:
x, y = points
line.args.append(Array([Scalar(x, 'in'), Scalar(y, 'in')]))
case Path.CURVE3:
cx, cy, px, py = points
p = Array([Scalar(px, 'in'), Scalar(py, 'in')])
c = Array([Scalar(cx - px, 'in'), Scalar(cy - py, 'in')])
line.args.append(Array([p, c]))
case Path.CURVE4:
inx, iny, outx, outy, px, py = points
p = Array([Scalar(px, 'in'), Scalar(py, 'in')])
inp = Array([Scalar(inx - px, 'in'),
Scalar(iny - py, 'in')])
out = Array([Scalar(outx - px, 'in'),
Scalar(outy - py, 'in')])
line.args.append(Array([p, inp, out]))
if code == Path.MOVETO:
superpath.append([[], False])
current_subpath = superpath[-1][0]
p = complex(*points)
current_subpath.append([p, p, p])
case Path.CURVE3 | Path.CURVE4:
if code == Path.CURVE3:
qp1, qp2 = complex(*points[:2]), complex(*points[2:])
# Convert quadratic to cubic bezier
qp0 = current_subpath[-1][1]
cp1 = qp0 + 2 / 3 * (qp1 - qp0)
cp2 = qp2 + 2 / 3 * (qp1 - qp2)
cp3 = qp2
else:
cp1 = complex(*points[:2])
cp2 = complex(*points[2:4])
cp3 = complex(*points[4:6])
current_subpath[-1][-1] = cp1
current_subpath.append([cp2, cp3, cp3])
case Path.CLOSEPOLY:
line.kwargs.update({'closed': True})
end = current_subpath[0][1]
superpath[-1][1] = True
superpath.append([[], False])
current_subpath = superpath[-1][0]
current_subpath.append([end, end, end])

def node2array(node: complex) -> Array:
return Array([Scalar(node.real, 'in'), Scalar(node.imag, 'in')])

for subpath, closed in superpath:
if len(subpath) < 2:
continue # Empty subpath.
# Construct a `path` routine invokation.
line = Call('path', fill=fill, stroke=stroke)
if closed:
line.kwargs.update({'closed': True})

for node in subpath:
if node[0] == node[1] == node[2]:
line.args.append(node2array(node[0]))
else:
args = node[1], node[0] - node[1], node[2] - node[1]
line.args.append(Array([node2array(arg) for arg in args]))

# Place a line path relative to parent block element without layouting.
place = Call('place', line, dx=Scalar(0, 'in'), dy=Scalar(0, 'in'))
self.main.append(place)
# Place a line path relative to parent block element without
# layouting.
place = Call('place', line, dx=Scalar(0, 'in'), dy=Scalar(0, 'in'))
self.main.append(place)

def draw_quad_mesh(self, gc, master_transform, meshWidth, meshHeight,
coordinates, offsets, offsetTrans, facecolors,
Expand Down
84 changes: 84 additions & 0 deletions mpl_typst/backend_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import pathlib
from io import BytesIO

import matplotlib.pyplot as plt
import numpy as np
from matplotlib.patches import PathPatch
from matplotlib.path import Path
from numpy.testing import assert_array_equal
from PIL import Image

from mpl_typst import rc_context

data_dir = pathlib.Path(__file__).parent / 'testdata'


class TestTypstRenderer:

def test_draw_path(self):
verts = [
(Path.MOVETO, (0.0, 0.0)),
(Path.LINETO, (0.0, 1.0)),
(Path.LINETO, (1.0, 1.0)),
(Path.LINETO, (1.0, 0.0)),
(Path.CLOSEPOLY, (0.0, 0.0)),
(Path.MOVETO, (-1, 2)),
(Path.LINETO, (0, 2)),
(Path.CURVE3, (1, 1)),
(Path.CURVE3, (2, 2)),
(Path.CURVE3, (3, 3)),
(Path.CURVE3, (2, 4)),
(Path.CURVE4, (-1, 4)),
(Path.CURVE4, (1, 6)),
(Path.CURVE4, (1, 3)),
(Path.LINETO, (1, 2)),
(Path.MOVETO, (2, -1)),
(Path.LINETO, (2.0, 0.0)),
(Path.CURVE4, (2.2, 1.0)),
(Path.CURVE4, (3.0, 0.8)),
(Path.CURVE4, (2.8, 0.0)),
(Path.CLOSEPOLY, (0, 0)),
(Path.MOVETO, (4, 1)),
(Path.CURVE4, (4.2, 1.0)),
(Path.CURVE4, (5.0, 0.8)),
(Path.CURVE4, (4.8, 0.0)),
(Path.CURVE4, (4, -1)),
(Path.CURVE4, (5.0, -1)),
(Path.CURVE4, (5, 0)),
(Path.CURVE3, (6, 2)),
(Path.CURVE3, (5, 2)),
(Path.LINETO, (3, 3)),
(Path.MOVETO, (4, 3)),
(Path.CURVE3, (5, 3)),
(Path.CURVE3, (5, 4)),
(Path.CLOSEPOLY, (0, 0)),
(Path.LINETO, (5, 5)),
(Path.CLOSEPOLY, (0, 0)),
]

codes, coords = zip(*verts)
path = Path(coords, codes)
patch = PathPatch(path, facecolor='orange', lw=2)

def render_figure() -> BytesIO:
fig, ax = plt.subplots()
ax.axis('off')
ax.add_patch(patch)
ax.set_xlim(-2, 6)
ax.set_ylim(-2, 6)

buf = BytesIO()
fig.savefig(buf, format='png', bbox_inches='tight', pad_inches=0)
buf.seek(0)

img = Image.open(buf)
return np.asarray(img)

# Use `mpl_typst` renderer.
with rc_context():
actual = render_figure()

# Load ground-truth image.
img = Image.open(data_dir / 'draw_path.png')
expected = np.asarray(img)
assert_array_equal(actual, expected)
Binary file added mpl_typst/testdata/draw_path.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 2 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,12 @@ classifiers = [
"Topic :: Utilities",
"Typing :: Typed",
]
dependencies = ["matplotlib"]
dependencies = ["matplotlib", "numpy"]
requires-python = ">=3.11,<4"

[project.optional-dependencies]
dev = ["isort", "pytest>=8", "ruff"]
test = ["pillow", "numpy"]

[project.urls]
Homepage = "https://github.com/daskol/typst-mpl-backend"
Expand Down

0 comments on commit f18c1ac

Please sign in to comment.