import abc
import os
from pathlib import Path
from subprocess import check_call, CalledProcessError
from tempfile import NamedTemporaryFile
from typing import Union, Any, List, Type, Optional
from shapeflow.core import RootException, get_logger
from shapeflow.util import suppress_stdout
log = get_logger(__name__)
Reason = Optional[Union[Exception, str]]
[docs]class RendererError(RootException):
pass
[docs]class Renderer(metaclass=abc.ABCMeta):
"""Renders SVG (as an XML string) to a PNG image.
Multiple implementations are provided as a fallback for Windows systems
where cairo may be unavailable.
Transparency is handled after importing PNG ~ OpenCV; SVGs are rendered
with a white background.
The background color of the original SVG file is ignored. Moreover, it's no
longer required to include a "hardcoded" background layer.
Layers labelled with `_*` are ignored for backwards compatibility with
legacy design files.
"""
_works: bool = False
"""Renderer implementations should import their dependencies on __init__.
If the import is successful, they should set ``_works = True`` to mark that
this renderer can function properly.
"""
_reason: Reason
"""The reason why this renderer doesn't work
"""
[docs] def save(self, svg: bytes, dpi: int, to: Path) -> None:
"""Save an SVG string to a PNG image.
Checks whether this renderer works on the current system before trying.
Parameters
----------
svg: str
An SVG image as an XML string
dpi: int
The DPI (details per inch) to render the SVG at
to: Path
The file to save to
"""
self._ensure()
log.debug(f"{self.__class__.__name__}: rendering to {to}")
self._save(svg, dpi, to)
def _ensure(self) -> None:
"""Checks whether this renderer can be used.
If not, an exception will be raised.
Raises
------
RendererError
if this renderer can't be used on the current system
"""
if not self._works:
raise RendererError(
f"can't use {self.__class__.__name__} - {self._reason}"
)
def _confirm(self, works: bool, reason: Reason = None) -> None:
self._works = works
self._reason = reason
@abc.abstractmethod
def _save(self, svg: bytes, dpi: int, to: Path) -> None:
"""Save a PNG render
"""
@property
def works(self) -> bool:
return self._works
[docs]class CairoRenderer(Renderer):
"""First-choice renderer: fastest, cleanest interface.
Requires the Python package ``cairosvg`` to be installed,
which in turn depends on ``cairo``.
Installing ``cairo``
Installing ``cairosvg``
"""
cairosvg: Any
DEFAULT_DPI: int = 96
WHITE: str = "#ffffff"
def __init__(self):
try:
import cairosvg
self.cairosvg = cairosvg
self._confirm(True)
except Exception as e:
self._confirm(False, e)
def _save(self, svg: bytes, dpi: int, to: Path) -> None:
self.cairosvg.svg2png(
svg,
scale = dpi / self.DEFAULT_DPI,
write_to = str(to),
background_color = self.WHITE
)
[docs]class WandRenderer(Renderer):
"""Second-choice renderer: slower, but still a clean interface.
Requires the Python package ``Wand`` to be installed, which in turn depends
on ImageMagick.
Installing ``ImageMagick``
Installing ``Wand``
"""
Image: Any
background: Any
def __init__(self):
try:
from wand.color import Color
from wand.image import Image
self.Image = Image
self.background = Color('white')
self._confirm(True)
except Exception as e:
self._confirm(False, e)
def _save(self, svg: bytes, dpi: int, to: Path) -> None:
with open(to, "wb") as f:
with self.Image() as image:
image.read(
blob=svg,
background=self.background,
resolution=dpi,
)
f.write(image.make_blob("png32"))
[docs]class InkscapeRenderer(Renderer):
"""Fallback renderer: it works, but is hacky and bad.
Requires Inkscape to be installed.
"""
shell: bool = False
def __init__(self):
self._check()
def _check(self) -> None:
try:
with suppress_stdout():
check_call([
*self._prefix, "inkscape", "--version"
], shell=self.shell)
self._confirm(True)
except CalledProcessError as e:
self._confirm(False, e)
def _save(self, svg: bytes, dpi: int, to: Path) -> None:
with NamedTemporaryFile(suffix=".svg") as temp:
temp.write(svg)
with suppress_stdout():
check_call([
*self._prefix, "inkscape",
"--export-type=png",
f"--export-filename={to}",
f"--export-dpi={dpi}",
temp.name,
], shell=self.shell)
@property
def _prefix(self) -> List[str]:
return []
[docs]class WindowsInkscapeRenderer(InkscapeRenderer):
"""Fallback renderer for Windows: it works, but is hacky and bad.
Requires Inkscape to be installed.
"""
INKSCAPE_DIR_CANDIDATES: List[Path] = [
Path("C:\\Program Files (x86)\\Inkscape\\bin"),
Path("C:\\Program Files\\Inkscape\\bin"),
]
inkscape_dir: Path
shell = False
def _check(self):
if os.name != 'nt':
self._works = False
return
for candidate in self.INKSCAPE_DIR_CANDIDATES:
if candidate.is_dir():
self.inkscape_dir = candidate
super()._check()
if self.works:
break
@property
def _prefix(self) -> List[str]:
return ["cd", str(self.inkscape_dir), "&&"]
_renderer: Renderer
__choices__: List[Type[Renderer]] = [
CairoRenderer,
WandRenderer,
InkscapeRenderer,
WindowsInkscapeRenderer,
]
for _renderer_type in __choices__:
_candidate = _renderer_type()
if _candidate.works:
_renderer = _candidate
log.debug(f"using {_renderer.__class__.__name__}")
break
else:
log.debug(f"{_candidate.__class__.__name__} won't work")
if '_renderer' not in locals():
raise RendererError(f"None of the renderers seem to work")
[docs]def save_svg(svg: bytes, dpi: int, to: Path) -> None:
"""Save SVG to a PNG image using the renderer selected for this system.
Parameters
----------
svg: bytes
An SVG image as XML bytes
dpi: int
The DPI (dots per inch) to render the SVG at
to: Path
Where to save the PNG image
Raises
------
RendererError
if none of the available renderers work on this system
"""
_renderer.save(svg, dpi, to)