Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement resvg renderer #23

Merged
merged 15 commits into from
May 29, 2024
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 0 additions & 2 deletions data/.gitignore

This file was deleted.

92 changes: 90 additions & 2 deletions poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ pillow = "^10.3.0"
plotly = "^5.19.0"
selenium = "^4.21.0"
webdriver-manager = "^4.0.1"
resvg-py = "^0.1.5"

[tool.poetry.group.dev]
optional = true
Expand Down Expand Up @@ -52,6 +53,7 @@ sphinx-jupyterbook-latex = "^1.0.0"
sphinx-togglebutton = "^0.3.2"
sphinx-toolbox = "^3.5.0"
sphinxcontrib-bibtex = "*"
types-requests = "^2.32.0.20240523"

[tool.mypy]
allow_redefinition = true
Expand Down Expand Up @@ -148,6 +150,7 @@ ignore = [
"D106", # undocumented public nested class
"D205", # blank line after summary (prevents summary-only docstrings, which makes no sense)
"PLW0603", # using global statement
"B018", # strip useless expressions in notebooks. These "useless expressions" often serve as a way to display output
]
unfixable = []
extend-fixable = [
Expand Down
14 changes: 2 additions & 12 deletions src/penai/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -121,16 +121,7 @@ def from_penpot_file_dir(cls, file_dir: PathLike) -> Self:
return cls.from_file(Path(file_dir) / "components.svg")

def get_component_list(self) -> list[PenpotComponent]:
# Yes, lxml's find/xpath is not compatible with its own datatypes.
nsmap = self.dom.getroot().nsmap

xpath_nsmap = dict(nsmap)
xpath_nsmap[""] = xpath_nsmap.pop(None)

component_symbols = self.dom.findall(
"./defs/symbol",
namespaces=xpath_nsmap,
)
component_symbols = self.dom.findall("./defs/symbol")

components = []

Expand All @@ -139,15 +130,14 @@ def get_component_list(self) -> list[PenpotComponent]:
dimensions = Dimensions.from_viewbox_string(view_box)
svg = SVG.from_root_element(
symbol,
nsmap=nsmap,
svg_attribs=dict(
viewBox=view_box,
),
)

component = PenpotComponent(
id=symbol.get("id"),
name=symbol.find("./title", namespaces=xpath_nsmap).text,
name=symbol.find("./title").text,
container=PenpotContainer(svg=svg),
dimensions=dimensions,
)
Expand Down
78 changes: 66 additions & 12 deletions src/penai/render.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,23 @@
import atexit
import io
import time
from collections.abc import Generator
from collections.abc import Callable, Generator
from contextlib import contextmanager
from functools import wraps
from pathlib import Path
from tempfile import NamedTemporaryFile
from typing import Self, TypedDict, Unpack
from typing import ParamSpec, Self, TypedDict, TypeVar, Unpack, cast

import resvg_py
from PIL import Image
from selenium import webdriver
from selenium.webdriver.chrome.options import Options
from selenium.webdriver.chrome.service import Service as ChromeService
from selenium.webdriver.common.by import By
from webdriver_manager.chrome import ChromeDriverManager

from penai import utils
from penai.svg import SVG
from penai.types import PathLike


Expand All @@ -25,6 +29,8 @@ class BaseSVGRenderer(abc.ABC):
since SVG engines could inherently work with either representation.
"""

SUPPORTS_ALPHA: bool

@abc.abstractmethod
def render_svg(
self,
Expand All @@ -44,11 +50,32 @@ def render_svg_file(
pass


Param = ParamSpec("Param")
RetType = TypeVar("RetType")


def _size_arguments_not_supported(
fn: Callable[Param, RetType],
) -> Callable[Param, RetType]:
@wraps(fn)
def wrapper(*args: Param.args, **kwargs: Param.kwargs) -> RetType:
if kwargs.get("width") or kwargs.get("height"):
raise NotImplementedError(
"Specifying width or height is currently not supported by this renderer",
)

return fn(*args, **kwargs)

return wrapper


class ChromeSVGRendererParams(TypedDict, total=False):
wait_time: float | None


class ChromeSVGRenderer(BaseSVGRenderer):
SUPPORTS_ALPHA = False

def __init__(self, wait_time: float | None = None):
chrome_options = Options()
chrome_options.add_argument("--headless")
Expand Down Expand Up @@ -120,6 +147,7 @@ def _render_svg(self, svg_path: str) -> Image.Image:

return Image.open(buffer).convert("RGB")

@_size_arguments_not_supported
def render_svg(
self,
svg_string: str,
Expand All @@ -132,15 +160,11 @@ def render_svg(
:param width: The width of the rendered image. Currently not supported.
:param height: The height of the rendered image. Currently not supported.
"""
if width or height:
raise NotImplementedError(
"Specifying width or height is currently not supported by ChromeSVGRenderer",
)

with NamedTemporaryFile(prefix="penpy_", suffix=".svg", mode="w") as file:
file.write(svg_string)
return self._render_svg(Path(file.name).as_uri())

@_size_arguments_not_supported
def render_svg_file(
self,
svg_path: PathLike,
Expand All @@ -153,15 +177,45 @@ def render_svg_file(
:param width: The width of the rendered image. Currently not supported.
:param height: The height of the rendered image. Currently not supported.
"""
if width or height:
raise NotImplementedError(
"Specifying width or height is currently not supported by ChromeSVGRenderer",
)

svg_path = Path(svg_path)
path = svg_path.absolute()

if not path.exists():
raise FileNotFoundError(f"{path} does not exist")

return self._render_svg(path.as_uri())


class ResvgRenderer(BaseSVGRenderer):
SUPPORTS_ALPHA = True

def __init__(self, inline_linked_images: bool = True):
self.inline_linked_images = inline_linked_images

@_size_arguments_not_supported
def render_svg(
self,
svg_string: str,
width: int | None = None,
height: int | None = None,
) -> Image.Image:
if self.inline_linked_images:
svg = SVG.from_string(svg_string)
svg.inline_images()
svg_string = svg.to_string()

# resvg_py.svg_to_bytes seem to be have a wrong type hint as itr
# returns a list of ints while it's annotated to return list[bytes]
return utils.image_from_bytes(
bytes(cast(list[int], resvg_py.svg_to_bytes(svg_string=svg_string))),
)

@_size_arguments_not_supported
def render_svg_file(
self,
svg_path: PathLike,
width: int | None = None,
height: int | None = None,
) -> Image.Image:
svg_string = Path(svg_path).read_text()
return self.render_svg(svg_string)
Loading
Loading