diff --git a/pyproject.toml b/pyproject.toml index af3ca7c3..285c1ab4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,6 +24,9 @@ dependencies = [ "polars>=0.20.0", "pyarrow==15.0.0", "requests", + "typst>=0.11.0", + "beautifulsoup4>=4.10.0", + "lxml>=5.0.0", ] [project.urls] diff --git a/src/pyobsplot/obsplot.py b/src/pyobsplot/obsplot.py index 077b461d..64615140 100644 --- a/src/pyobsplot/obsplot.py +++ b/src/pyobsplot/obsplot.py @@ -7,11 +7,13 @@ import shutil import signal import warnings +import tempfile +import typst from pathlib import Path from subprocess import PIPE, Popen, SubprocessError from typing import Any, Optional, Union -from IPython.display import HTML, SVG, display +from IPython.display import HTML, SVG, Image, display from ipywidgets.embed import embed_minimal_html from pyobsplot.jsdom import ObsplotJsdom @@ -20,6 +22,7 @@ AVAILABLE_THEMES, DEFAULT_THEME, MIN_NPM_VERSION, + bundler_output_dir ) from pyobsplot.widget import ObsplotWidget @@ -38,6 +41,7 @@ def __new__( theme: str = DEFAULT_THEME, default: Optional[dict] = None, debug: bool = False, # noqa: FBT001, FBT002 + **kwargs, ) -> Any: """ Main Obsplot class constructor. Returns a Creator instance depending on the @@ -70,6 +74,8 @@ def __new__( return ObsplotWidgetCreator(theme=theme, default=default, debug=debug) elif renderer == "jsdom": return ObsplotJsdomCreator(theme=theme, default=default, debug=debug) + elif renderer == "typst": + return ObsplotTypstCreator(theme=theme, default=default, debug=debug, **kwargs) else: msg = f""" Incorrect renderer '{renderer}'. @@ -319,3 +325,99 @@ def save_to_file(path: str, res: Union[SVG, HTML]) -> None: ) with open(path, "w", encoding="utf-8") as f: f.write(str(res.data)) + + +class ObsplotTypstCreator(ObsplotJsdomCreator): + """ + Typst renderer Creator class. + """ + + def __init__( + self, + theme: str = DEFAULT_THEME, + default: Optional[dict] = None, + debug: bool = False, + font_size: int = 12, + font: str = "sans-serif", + dpi: int = 300, + margin: int = 4 + ) -> None: + super().__init__(theme, default, debug) + self._proc = None + self.font_size = font_size + self.font = font + self.dpi = dpi + self.margin = margin + self.start_server() + + def __call__(self, *args, **kwargs) -> None: + """ + Method called when an instance is called. + """ + if self._proc is not None and self._proc.poll() is not None: + msg = "Server has ended, please recreate your plot generator object." + raise RuntimeError(msg) + path = None + if "path" in kwargs: + path = kwargs["path"] + del kwargs["path"] + spec = self.get_spec(*args, **kwargs) + if "figure" not in spec: + spec["figure"] = True + res = ObsplotJsdom( + spec, + port=self._port, + theme=self._theme, + default=self._default, + debug=self._debug, + ).plot() + if path is None: + with tempfile.NamedTemporaryFile(suffix=".png") as f: + self.render_typst(str(res.data), f.name) + width = spec["width"] if "width" in spec else 640 + if "margin" in spec: + width += 2*spec["margin"] + else: + if "marginLeft" in spec: + width += spec["marginLeft"] + if "marginRight" in spec: + width += spec["marginRight"] + display( + Image(data=f.read(), width=width, height=spec["height"] if "height" in spec else None) + ) + else: + self.save_to_file(path, res) + + def save_to_file(self, path: str, res: HTML) -> None: + if isinstance(path, io.StringIO): + path.write(str(res.data)) + return + extension = Path(path).suffix.lower() + if extension not in [".png", ".svg", ".pdf"]: + raise ValueError( + "Output file extension should be one of 'png', 'svg' or 'pdf'" + ) + with open(path, "w", encoding="utf-8") as f: + self.render_typst(str(res.data), f.name) + + def render_typst(self, html: str, path: str) -> None: + path_obj = Path(path) + ext = "".join(path_obj.suffixes) + + with tempfile.TemporaryDirectory() as tmpdirname: + with open(f"{tmpdirname}/jsdom.html", "w") as f: + f.write(html) + shutil.copy(bundler_output_dir / "template.typ", f"{tmpdirname}/template.typ") + with open(f"{tmpdirname}/input.typ", "w") as f: + f.write(f""" +#import "template.typ": obsplot + +#show: obsplot( + "jsdom.html", + margin: {self.margin}4pt, + font: "{self.font}", + font-size: {self.font_size}pt, +) + """) + + typst.compile(f"{tmpdirname}/input.typ", output=path, ppi=self.dpi, format=ext[1:]) diff --git a/src/pyobsplot/static/template.typ b/src/pyobsplot/static/template.typ new file mode 100644 index 00000000..9872adee --- /dev/null +++ b/src/pyobsplot/static/template.typ @@ -0,0 +1,78 @@ +#let find-child(elem, tag) = { + elem.children + .find(e => "tag" in e and e.tag == tag) +} + +#let encode-xml(elem) = { + if (type(elem) == "string") { + elem + } else if (type(elem) == "dictionary") { + "<" + elem.tag + elem.attrs.pairs().map( + v => " " + v.at(0) + "=\"" + v.at(1) + "\"" + ).join("") + if (elem.tag == "svg") {" xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\""} + ">" + elem.children.map(encode-xml).join("") + "" + } +} + +#let obsplot( + file, + margin: 4pt, + font: "SF Pro Display", + font-size: 10pt, +) = { + let swatch-item(elem) = { + set align(horizon) + stack( + dir: ltr, + spacing: .5em, + image.decode( + encode-xml(elem.children.first()), + width: 1pt * int(elem.children.first().attrs.width), + height: 1pt * int(elem.children.first().attrs.width) + ), + text(elem.children.last()) + ) + } + + let swatch(elem) = { + stack( + dir: ltr, + spacing: 1em, + ..elem.children.filter(e => e.tag == "span").map(swatch-item) + ) + } + + let html = xml(file) + let figure = html.first() + let title = find-child(figure, "h2") + let subtitle = find-child(figure, "h3") + let caption = find-child(figure, "figcaption") + let figuresvg = find-child(figure, "svg") + let figurewidth = int(figuresvg.attrs.width) + + set text( + font: "SF Pro Display", + size: font-size, + fallback: false + ) + + set page( + width: 1pt*figurewidth + 2*margin, + height: auto, + margin: (x: margin, y: margin) + ) + + stack( + dir: ttb, + spacing: 1em, + heading(title.children.first(), level: 1), + if (subtitle != none) { + heading(subtitle.children.first(), level: 2) + }, + v(2em), + ..figure.children.filter(e => e.tag == "div").map(swatch), + image.decode(encode-xml(figuresvg)), + if (caption != none) { + text(caption.children.first()) + } + ) +} \ No newline at end of file