From 0f994097684bd7fe6ea7c2389aad0a0ad13316cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominik=20Schr=C3=B6der?= Date: Mon, 6 May 2024 14:44:57 +0200 Subject: [PATCH 1/5] Added typst renderer to support png/pdf/jpg/svg exports --- pyproject.toml | 3 + src/pyobsplot/obsplot.py | 147 ++++++++++++++++++++++++++++++++++++++- 2 files changed, 149 insertions(+), 1 deletion(-) 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..10126540 100644 --- a/src/pyobsplot/obsplot.py +++ b/src/pyobsplot/obsplot.py @@ -7,11 +7,14 @@ import shutil import signal import warnings +import tempfile +import typst +from bs4 import BeautifulSoup 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 @@ -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,142 @@ 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): + """ + Jsdom 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(filename=f.name, 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", ".jpg", ".jpeg", ".svg", ".pdf"]: + warnings.warn( + "Output file extension should be one of 'png', 'jpg', 'jpeg', 'svg', or 'pdf'", + RuntimeWarning, + stacklevel=1, + ) + with open(path, "w", encoding="utf-8") as f: + self.render_typst(str(res.data), f.name) + + @staticmethod + def shift_svg(svg): + soup = BeautifulSoup(str(svg), "xml") + svg = soup.svg + if "viewBox" in svg.attrs: + x, y, width, height = map(int, svg.attrs["viewBox"].split()) + if x != 0 or y != 0: + g = soup.new_tag("g", transform=f"translate({-x}, {-y})") + g.extend(svg.contents) + svg.clear() + svg.append(g) + svg.attrs["viewBox"] = f"0 0 {width} {height}" + return str(svg) + + def render_typst(self, html: str, path: str) -> None: + path_obj = Path(path) + ext = "".join(path_obj.suffixes) + stem = str(path_obj.name).removesuffix("".join(path_obj.suffixes)) + + with tempfile.TemporaryDirectory() as tmpdirname: + soup = BeautifulSoup(html, "xml") + figure = soup.find("figure", recursive=False) + swatches = [] + plots = [] + for i, swatch in enumerate(figure.find_all("div", recursive=False)): + new_swatch = [] + for j, svg in enumerate(swatch.find_all("svg", recursive=True)): + with open(f"{tmpdirname}/{stem}_{i}_{j}.svg", "w") as f: + f.write(ObsplotTypstCreator.shift_svg(str(svg))) + new_swatch.append( + {"file": f"{stem}_{i}_{j}.svg", "width": svg.attrs["width"], "height": svg.attrs["height"], "text": svg.next_sibling} + ) + swatches.append(new_swatch) + for i, svg in enumerate(figure.find_all("svg", recursive=False)): + with open(f"{tmpdirname}/{stem}_{i}.svg", "w") as f: + f.write(ObsplotTypstCreator.shift_svg(str(svg))) + plots.append({"file": f"{stem}_{i}.svg", "width": svg.attrs["width"], "height": svg.attrs["height"]}) + max_width = max(int(svg["width"]) for svg in plots) + typeset = ( + f'#set text(\nfont: "{self.font}",\nsize: {self.font_size}pt,\nfallback: false)\n' + + f"#set page(\nwidth: {max_width+2*self.margin}pt,\nheight: auto,\nmargin: (x: {self.margin}pt, y: {self.margin}pt),\n)\n" + ) + if title := figure.find("h2"): + typeset += f"= {title.text}" + if subtitle := figure.find("h3"): + typeset += f"\n{subtitle.text}" + typeset += "\n\n" + for swatch in swatches: + typeset += "#{\nset align(horizon)\nstack(\n dir: ltr,\n spacing: 10pt,\n" + for el in swatch: + typeset += f' image("{el["file"]}", width: {el["width"]}pt),\n' + typeset += f' "{el["text"]}",\n' + typeset += ")}\n\n" + typeset += "#v(-10pt)\n".join([f'#image("{plot["file"]}", width: {plot["width"]}pt)\n' for plot in plots]) + + if caption := figure.find("figcaption"): + typeset += f"\n{caption.text}" + + with open(f"{tmpdirname}/{stem}.typ", "w") as f: + f.write(typeset) + + typst.compile(f"{tmpdirname}/{stem}.typ", output=path, ppi=self.dpi, format=ext[1:]) \ No newline at end of file From deec5e9cdec249c14ff34c66db44cec0fbbd5c45 Mon Sep 17 00:00:00 2001 From: wirhabenzeit Date: Fri, 10 May 2024 16:33:14 +0200 Subject: [PATCH 2/5] Update src/pyobsplot/obsplot.py Co-authored-by: harrylojames <51630653+harrylojames@users.noreply.github.com> --- src/pyobsplot/obsplot.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pyobsplot/obsplot.py b/src/pyobsplot/obsplot.py index 10126540..2603bae1 100644 --- a/src/pyobsplot/obsplot.py +++ b/src/pyobsplot/obsplot.py @@ -436,7 +436,7 @@ def render_typst(self, html: str, path: str) -> None: ) swatches.append(new_swatch) for i, svg in enumerate(figure.find_all("svg", recursive=False)): - with open(f"{tmpdirname}/{stem}_{i}.svg", "w") as f: + with open(f"{tmpdirname}/{stem}_{i}.svg", "w", encoding = 'utf-8') as f: f.write(ObsplotTypstCreator.shift_svg(str(svg))) plots.append({"file": f"{stem}_{i}.svg", "width": svg.attrs["width"], "height": svg.attrs["height"]}) max_width = max(int(svg["width"]) for svg in plots) From 976835a01ebffb70581f9d36629156df5c423d34 Mon Sep 17 00:00:00 2001 From: wirhabenzeit Date: Fri, 10 May 2024 16:33:22 +0200 Subject: [PATCH 3/5] Update src/pyobsplot/obsplot.py Co-authored-by: harrylojames <51630653+harrylojames@users.noreply.github.com> --- src/pyobsplot/obsplot.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pyobsplot/obsplot.py b/src/pyobsplot/obsplot.py index 2603bae1..cc69c459 100644 --- a/src/pyobsplot/obsplot.py +++ b/src/pyobsplot/obsplot.py @@ -383,7 +383,7 @@ def __call__(self, *args, **kwargs) -> None: if "marginRight" in spec: width += spec["marginRight"] display( - Image(filename=f.name, width=width, height=spec["height"] if "height" in spec else None) + Image(data=f.read(), width=width, height=spec["height"] if "height" in spec else None) ) else: self.save_to_file(path, res) From ba3139d2f5a31730b945776d0f4df4811be61604 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominik=20Schr=C3=B6der?= Date: Mon, 13 May 2024 13:16:30 +0200 Subject: [PATCH 4/5] raise error if file extension is invalid --- src/pyobsplot/obsplot.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/src/pyobsplot/obsplot.py b/src/pyobsplot/obsplot.py index cc69c459..c3970e7f 100644 --- a/src/pyobsplot/obsplot.py +++ b/src/pyobsplot/obsplot.py @@ -329,7 +329,7 @@ def save_to_file(path: str, res: Union[SVG, HTML]) -> None: class ObsplotTypstCreator(ObsplotJsdomCreator): """ - Jsdom renderer Creator class. + Typst renderer Creator class. """ def __init__( @@ -393,11 +393,9 @@ def save_to_file(self, path: str, res: HTML) -> None: path.write(str(res.data)) return extension = Path(path).suffix.lower() - if extension not in [".png", ".jpg", ".jpeg", ".svg", ".pdf"]: - warnings.warn( - "Output file extension should be one of 'png', 'jpg', 'jpeg', 'svg', or 'pdf'", - RuntimeWarning, - stacklevel=1, + 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) From b040c45c09faffb930aa06dbea51fa6c62492d33 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominik=20Schr=C3=B6der?= Date: Tue, 21 May 2024 17:18:41 +0200 Subject: [PATCH 5/5] replaced beautifulsoup with typst xml parser --- src/pyobsplot/obsplot.py | 75 +++++++---------------------- src/pyobsplot/static/template.typ | 78 +++++++++++++++++++++++++++++++ 2 files changed, 95 insertions(+), 58 deletions(-) create mode 100644 src/pyobsplot/static/template.typ diff --git a/src/pyobsplot/obsplot.py b/src/pyobsplot/obsplot.py index c3970e7f..64615140 100644 --- a/src/pyobsplot/obsplot.py +++ b/src/pyobsplot/obsplot.py @@ -9,7 +9,6 @@ import warnings import tempfile import typst -from bs4 import BeautifulSoup from pathlib import Path from subprocess import PIPE, Popen, SubprocessError from typing import Any, Optional, Union @@ -23,6 +22,7 @@ AVAILABLE_THEMES, DEFAULT_THEME, MIN_NPM_VERSION, + bundler_output_dir ) from pyobsplot.widget import ObsplotWidget @@ -400,65 +400,24 @@ def save_to_file(self, path: str, res: HTML) -> None: with open(path, "w", encoding="utf-8") as f: self.render_typst(str(res.data), f.name) - @staticmethod - def shift_svg(svg): - soup = BeautifulSoup(str(svg), "xml") - svg = soup.svg - if "viewBox" in svg.attrs: - x, y, width, height = map(int, svg.attrs["viewBox"].split()) - if x != 0 or y != 0: - g = soup.new_tag("g", transform=f"translate({-x}, {-y})") - g.extend(svg.contents) - svg.clear() - svg.append(g) - svg.attrs["viewBox"] = f"0 0 {width} {height}" - return str(svg) - def render_typst(self, html: str, path: str) -> None: path_obj = Path(path) ext = "".join(path_obj.suffixes) - stem = str(path_obj.name).removesuffix("".join(path_obj.suffixes)) with tempfile.TemporaryDirectory() as tmpdirname: - soup = BeautifulSoup(html, "xml") - figure = soup.find("figure", recursive=False) - swatches = [] - plots = [] - for i, swatch in enumerate(figure.find_all("div", recursive=False)): - new_swatch = [] - for j, svg in enumerate(swatch.find_all("svg", recursive=True)): - with open(f"{tmpdirname}/{stem}_{i}_{j}.svg", "w") as f: - f.write(ObsplotTypstCreator.shift_svg(str(svg))) - new_swatch.append( - {"file": f"{stem}_{i}_{j}.svg", "width": svg.attrs["width"], "height": svg.attrs["height"], "text": svg.next_sibling} - ) - swatches.append(new_swatch) - for i, svg in enumerate(figure.find_all("svg", recursive=False)): - with open(f"{tmpdirname}/{stem}_{i}.svg", "w", encoding = 'utf-8') as f: - f.write(ObsplotTypstCreator.shift_svg(str(svg))) - plots.append({"file": f"{stem}_{i}.svg", "width": svg.attrs["width"], "height": svg.attrs["height"]}) - max_width = max(int(svg["width"]) for svg in plots) - typeset = ( - f'#set text(\nfont: "{self.font}",\nsize: {self.font_size}pt,\nfallback: false)\n' - + f"#set page(\nwidth: {max_width+2*self.margin}pt,\nheight: auto,\nmargin: (x: {self.margin}pt, y: {self.margin}pt),\n)\n" - ) - if title := figure.find("h2"): - typeset += f"= {title.text}" - if subtitle := figure.find("h3"): - typeset += f"\n{subtitle.text}" - typeset += "\n\n" - for swatch in swatches: - typeset += "#{\nset align(horizon)\nstack(\n dir: ltr,\n spacing: 10pt,\n" - for el in swatch: - typeset += f' image("{el["file"]}", width: {el["width"]}pt),\n' - typeset += f' "{el["text"]}",\n' - typeset += ")}\n\n" - typeset += "#v(-10pt)\n".join([f'#image("{plot["file"]}", width: {plot["width"]}pt)\n' for plot in plots]) - - if caption := figure.find("figcaption"): - typeset += f"\n{caption.text}" - - with open(f"{tmpdirname}/{stem}.typ", "w") as f: - f.write(typeset) - - typst.compile(f"{tmpdirname}/{stem}.typ", output=path, ppi=self.dpi, format=ext[1:]) \ No newline at end of file + 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