From f8408282bccbd77e94e326b8634ed268109ced48 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krzysztof=20Wi=C5=9Bniewski?= Date: Sat, 23 Nov 2024 01:19:29 +0100 Subject: [PATCH] Add SVG export --- .vscode/settings.json | 1 + src/pygerber/console/gerber.py | 53 +++++++++++++++---- src/pygerber/gerber/api/_gerber_file.py | 19 +++++++ test/assets/assetlib.py | 51 ++++++++++++++++++ .../reference/pygerber/console/gerber.py | 6 ++- test/e2e/test_gerber/test_shapely.py | 5 +- .../test_gerber/test_console/test_gerber.py | 43 ++++++++++++++- 7 files changed, 163 insertions(+), 15 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index 7b6ff22f..808b27ff 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -74,6 +74,7 @@ "SOSTMT", "strtree", "superfences", + "svglib", "tasklist", "tobytes", "ucamco", diff --git a/src/pygerber/console/gerber.py b/src/pygerber/console/gerber.py index 34b8fcfa..a91ba5e7 100644 --- a/src/pygerber/console/gerber.py +++ b/src/pygerber/console/gerber.py @@ -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 @@ -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.", ) @@ -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, @@ -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", @@ -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", @@ -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, @@ -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", @@ -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( diff --git a/src/pygerber/gerber/api/_gerber_file.py b/src/pygerber/gerber/api/_gerber_file.py index b143f23e..6168a931 100644 --- a/src/pygerber/gerber/api/_gerber_file.py +++ b/src/pygerber/gerber/api/_gerber_file.py @@ -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. diff --git a/test/assets/assetlib.py b/test/assets/assetlib.py index a0f1b504..58a23872 100644 --- a/test/assets/assetlib.py +++ b/test/assets/assetlib.py @@ -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 @@ -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 diff --git a/test/assets/reference/pygerber/console/gerber.py b/test/assets/reference/pygerber/console/gerber.py index ec7fa439..731c265a 100644 --- a/test/assets/reference/pygerber/console/gerber.py +++ b/test/assets/reference/pygerber/console/gerber.py @@ -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( @@ -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"), +) diff --git a/test/e2e/test_gerber/test_shapely.py b/test/e2e/test_gerber/test_shapely.py index f1fdb008..b1a4ac00 100644 --- a/test/e2e/test_gerber/test_shapely.py +++ b/test/e2e/test_gerber/test_shapely.py @@ -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 @@ -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") diff --git a/test/unit/test_gerber/test_console/test_gerber.py b/test/unit/test_gerber/test_console/test_gerber.py index 817466ba..8f824952 100644 --- a/test/unit/test_gerber/test_console/test_gerber.py +++ b/test/unit/test_gerber/test_console/test_gerber.py @@ -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, @@ -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) + )