Skip to content

Commit

Permalink
Add SVG export
Browse files Browse the repository at this point in the history
  • Loading branch information
Argmaster committed Nov 23, 2024
1 parent df935dd commit f840828
Show file tree
Hide file tree
Showing 7 changed files with 163 additions and 15 deletions.
1 change: 1 addition & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@
"SOSTMT",
"strtree",
"superfences",
"svglib",
"tasklist",
"tobytes",
"ucamco",
Expand Down
53 changes: 43 additions & 10 deletions src/pygerber/console/gerber.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import click

from pygerber.gerber.api import Color, FileTypeEnum, GerberFile, Style
from pygerber.vm.shapely.vm import is_shapely_available
from pygerber.vm.types.color import InvalidHexColorLiteralError


Expand Down Expand Up @@ -77,14 +78,14 @@ def _get_background_option() -> Callable[[click.decorators.FC], click.decorators
)


def _get_raster_implementation_option() -> (
Callable[[click.decorators.FC], click.decorators.FC]
):
def _get_raster_implementation_option(
*vals: str,
) -> Callable[[click.decorators.FC], click.decorators.FC]:
return click.option(
"-i",
"--implementation",
type=click.Choice(["pillow"], case_sensitive=False),
default="pillow",
type=click.Choice(vals, case_sensitive=False),
default=vals[0],
help="Name of rendering Virtual Machine to be used.",
)

Expand Down Expand Up @@ -124,7 +125,7 @@ def _get_source_file_argument() -> Callable[[click.decorators.FC], click.decorat
@_get_style_option()
@_get_foreground_option()
@_get_background_option()
@_get_raster_implementation_option()
@_get_raster_implementation_option("pillow")
def png(
source: str,
output: str,
Expand Down Expand Up @@ -203,7 +204,7 @@ def _sanitize_style(
@_get_style_option()
@_get_foreground_option()
@_get_background_option()
@_get_raster_implementation_option()
@_get_raster_implementation_option("pillow")
@click.option(
"-q",
"--quality",
Expand Down Expand Up @@ -243,7 +244,7 @@ def jpeg( # noqa: PLR0913
@_get_style_option()
@_get_foreground_option()
@_get_background_option()
@_get_raster_implementation_option()
@_get_raster_implementation_option("pillow")
@click.option(
"-c",
"--compression",
Expand Down Expand Up @@ -316,7 +317,7 @@ def tiff( # noqa: PLR0913
@_get_style_option()
@_get_foreground_option()
@_get_background_option()
@_get_raster_implementation_option()
@_get_raster_implementation_option("pillow")
def bmp(
source: str,
output: str,
Expand Down Expand Up @@ -348,7 +349,7 @@ def bmp(
@_get_style_option()
@_get_foreground_option()
@_get_background_option()
@_get_raster_implementation_option()
@_get_raster_implementation_option("pillow")
@click.option(
"-l",
"--lossless",
Expand Down Expand Up @@ -391,6 +392,38 @@ def webp( # noqa: PLR0913
raise NotImplementedError(msg)


if is_shapely_available():

@convert.command("svg")
@_get_source_file_argument()
@_get_output_file_option()
@_get_file_type_option()
@_get_style_option()
@_get_foreground_option()
@_get_background_option()
@_get_raster_implementation_option("shapely")
def svg(
source: str,
output: str,
file_type: str,
style: Optional[str],
foreground: Optional[str],
background: Optional[str],
implementation: str,
) -> None:
"""Convert Gerber image file to SVG image."""
style_obj = _sanitize_style(style, foreground, background)
file = GerberFile.from_file(source, file_type=FileTypeEnum(file_type.upper()))

if implementation.lower() == "shapely":
result = file.render_with_shapely(style_obj)
result.save(output)

else:
msg = f"Implementation {implementation!r} is not supported."
raise NotImplementedError(msg)


@gerber.command("project")
@click.argument("files", nargs=-1)
@click.option(
Expand Down
19 changes: 19 additions & 0 deletions src/pygerber/gerber/api/_gerber_file.py
Original file line number Diff line number Diff line change
Expand Up @@ -365,6 +365,25 @@ def save(self, destination: str | Path | BinaryIO) -> None:
"""
self._result.save_svg(destination, self._style)

def save_svg(
self,
destination: str | Path | BinaryIO,
**kwargs: Any, # noqa: ARG002
) -> None:
"""Save result to a file or buffer in SVG format.
Parameters
----------
destination : str | Path | BinaryIO
`str` and `Path` objects are interpreted as file paths and opened with
truncation. `BinaryIO`-like (files, BytesIO) objects are written to
directly.
kwargs : Any
Additional keyword arguments to pass to SVG save implementation.
"""
self._result.save_svg(destination, self._style)


class GerberFile:
"""Generic representation of Gerber file.
Expand Down
51 changes: 51 additions & 0 deletions test/assets/assetlib.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from hashlib import sha1
from io import BytesIO
from pathlib import Path
from tempfile import TemporaryDirectory
from typing import TYPE_CHECKING, Generic, Optional, Sequence, TypeVar

import dulwich
Expand Down Expand Up @@ -306,6 +307,56 @@ def new(cls, src: SourceT, image_format: ImageFormat) -> ImageAsset[SourceT]:
return cls(src=src, image_format=image_format)


class SvgImageAsset(Asset[SourceT]):
"""Asset representing an SVG image file."""

def load(self) -> str:
"""Load the asset from the source."""
return self.src.load().decode("utf-8")

def load_png(self, dpi: int = 72) -> Image.Image:
"""Load the asset as a PNG image."""
return self.svg_to_png(self.load(), dpi)

@staticmethod
def svg_to_png(src: str, dpi: int) -> Image.Image:
"""Convert SVG content to a PNG image."""
from reportlab.graphics import renderPM
from svglib.svglib import svg2rlg

with TemporaryDirectory() as tempdir:
svg_path = Path(tempdir) / "tmp.svg"
png_path = Path(tempdir) / "tmp.png"

svg_path.touch()
svg_path.write_text(src, encoding="utf-8")

drawing = svg2rlg(svg_path.as_posix())
assert drawing is not None

renderPM.drawToFile(
drawing,
png_path.as_posix(),
fmt="PNG",
dpi=dpi,
)
img = Image.open(png_path.as_posix(), formats=["png"])
# Pillow images are lazy-loaded and since we are using temporary directory
# we have to load image before temp dir is deleted.
img.load()

return img

def update(self, content: str) -> None:
"""Update the asset from the source."""
self.src.update(content.encode("utf-8"))

@classmethod
def new(cls, src: SourceT) -> SvgImageAsset[SourceT]:
"""Create a new SvgImageAsset instance."""
return cls(src=src)


_LONG_ENOUGH_TO_HAVE_ONE_MORE_FRAME = 2


Expand Down
6 changes: 5 additions & 1 deletion test/assets/reference/pygerber/console/gerber.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from __future__ import annotations

from test.assets.assetlib import GitFile, ImageAsset, ImageFormat
from test.assets.assetlib import GitFile, ImageAsset, ImageFormat, SvgImageAsset
from test.assets.reference import REFERENCE_REPOSITORY

CONVERT_PNG_REFERENCE_IMAGE = ImageAsset[GitFile].new(
Expand All @@ -27,3 +27,7 @@
REFERENCE_REPOSITORY.file("reference/pygerber/console/convert_webp_lossless.webp"),
ImageFormat.WEBP,
)

CONVERT_SVG_REFERENCE_IMAGE = SvgImageAsset[GitFile].new(
REFERENCE_REPOSITORY.file("reference/pygerber/console/convert_svg.svg"),
)
5 changes: 3 additions & 2 deletions test/e2e/test_gerber/test_shapely.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,6 @@

import pytest
from PIL import Image
from reportlab.graphics import renderPM
from svglib.svglib import svg2rlg

from pygerber.gerber.ast.nodes.file import File
from pygerber.gerber.compiler import compile
Expand Down Expand Up @@ -48,6 +46,9 @@ class ShapelyRender:
dpmm: int = 20

def create_image(self, source: str) -> Image.Image:
from reportlab.graphics import renderPM
from svglib.svglib import svg2rlg

ast = parse(source, parser=self.parser)
rvmc = compile(ast)
result = render(rvmc, backend="shapely")
Expand Down
43 changes: 41 additions & 2 deletions test/unit/test_gerber/test_console/test_gerber.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,14 @@
from click.testing import CliRunner
from PIL import Image

from pygerber.console.gerber import bmp, jpeg, png, tiff, webp
from pygerber.console.gerber import bmp, jpeg, png, svg, tiff, webp
from pygerber.examples import ExamplesEnum, get_example_path
from test.assets.assetlib import ImageAnalyzer
from test.assets.assetlib import ImageAnalyzer, SvgImageAsset
from test.assets.reference.pygerber.console.gerber import (
CONVERT_BMP_REFERENCE_IMAGE,
CONVERT_JPEG_REFERENCE_IMAGE,
CONVERT_PNG_REFERENCE_IMAGE,
CONVERT_SVG_REFERENCE_IMAGE,
CONVERT_TIFF_REFERENCE_IMAGE,
CONVERT_WEBP_LOSSLESS_REFERENCE_IMAGE,
CONVERT_WEBP_LOSSY_REFERENCE_IMAGE,
Expand Down Expand Up @@ -240,3 +241,41 @@ def test_gerber_convert_webp_lossless(*, is_regeneration_enabled: bool) -> None:
.assert_channel_count(3)
.assert_greater_or_equal_values(0.9)
)


MIN_SVG_SSIM = 0.99


@tag(Tag.PILLOW, Tag.OPENCV, Tag.SKIMAGE, Tag.SVGLIB)
def test_gerber_convert_svg(*, is_regeneration_enabled: bool) -> None:
runner = CliRunner()
with cd_to_tempdir() as temp_path:
result = runner.invoke(
svg,
[
get_example_path(ExamplesEnum.UCAMCO_2_11_2).as_posix(),
"-o",
"output.svg",
"-s",
"DEBUG_1_ALPHA",
],
)
logging.debug(result.output)
assert result.exit_code == 0
assert (temp_path / "output.svg").exists()

image = (temp_path / "output.svg").read_text()
if is_regeneration_enabled:
CONVERT_SVG_REFERENCE_IMAGE.update(image) # pragma: no cover
else:
dpi = 72
image_png = SvgImageAsset.svg_to_png(image, dpi=dpi)

ia = ImageAnalyzer(CONVERT_SVG_REFERENCE_IMAGE.load_png(dpi=dpi))
assert ia.structural_similarity(image_png) > MIN_SVG_SSIM
ia.assert_same_size(image_png)
(
ia.histogram_compare_color(image_png)
.assert_channel_count(3)
.assert_greater_or_equal_values(0.9)
)

0 comments on commit f840828

Please sign in to comment.