svgconverter 1.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 Chien-Hsun, Chang
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,131 @@
1
+ Metadata-Version: 2.4
2
+ Name: svgconverter
3
+ Version: 1.2.0
4
+ Summary: Embed PNG and JPEG raster images in SVG containers.
5
+ Author: Chien-Hsun Chang
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/KageRyo/SVGConverter
8
+ Project-URL: Documentation, https://github.com/KageRyo/SVGConverter#readme
9
+ Project-URL: Issues, https://github.com/KageRyo/SVGConverter/issues
10
+ Project-URL: Changelog, https://github.com/KageRyo/SVGConverter/blob/main/CHANGELOG.md
11
+ Keywords: image,svg,converter,raster,embed
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Environment :: Console
14
+ Classifier: Environment :: Win32 (MS Windows)
15
+ Classifier: Intended Audience :: End Users/Desktop
16
+ Classifier: Operating System :: OS Independent
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3 :: Only
19
+ Classifier: Programming Language :: Python :: 3.10
20
+ Classifier: Programming Language :: Python :: 3.11
21
+ Classifier: Programming Language :: Python :: 3.12
22
+ Classifier: Programming Language :: Python :: 3.13
23
+ Classifier: Programming Language :: Python :: 3.14
24
+ Classifier: Topic :: Multimedia :: Graphics :: Graphics Conversion
25
+ Requires-Python: >=3.10
26
+ Description-Content-Type: text/markdown
27
+ License-File: LICENSE
28
+ Requires-Dist: Pillow>=10.0
29
+ Provides-Extra: dev
30
+ Requires-Dist: build>=1.2; extra == "dev"
31
+ Requires-Dist: pytest>=8.0; extra == "dev"
32
+ Requires-Dist: ruff>=0.8; extra == "dev"
33
+ Requires-Dist: twine>=6.0; extra == "dev"
34
+ Dynamic: license-file
35
+
36
+ # SVGConverter
37
+
38
+ [正體中文](README_TW.md)
39
+
40
+ SVGConverter wraps a PNG or JPEG image in an SVG container. It provides a
41
+ small Python API, command-line interface, and desktop GUI.
42
+
43
+ > **Important:** current `embed` mode Base64-encodes the original raster image
44
+ > into an SVG `<image>` element. It does **not** trace pixels into vector paths,
45
+ > and it does not inherently reduce file size. A future `vectorize` mode is
46
+ > tracked separately.
47
+
48
+ ## Installation
49
+
50
+ SVGConverter requires Python 3.10 or newer. After v1.2.0 has been published to
51
+ PyPI:
52
+
53
+ ```bash
54
+ python -m pip install --upgrade svgconverter
55
+ ```
56
+
57
+ To run the current development version instead:
58
+
59
+ ```bash
60
+ git clone https://github.com/KageRyo/SVGConverter.git
61
+ cd SVGConverter
62
+ python -m pip install .
63
+ ```
64
+
65
+ ## Command line
66
+
67
+ Convert one image, retaining its dimensions in the generated SVG:
68
+
69
+ ```bash
70
+ svgconverter image.png
71
+ svgconverter photo.jpg --output output.svg
72
+ ```
73
+
74
+ Convert all supported images immediately inside a directory:
75
+
76
+ ```bash
77
+ svgconverter ./images --output-dir ./svg-output
78
+ ```
79
+
80
+ Outputs are never overwritten unless `--overwrite` is supplied. Run
81
+ `svgconverter --help` for all options. Supported inputs are PNG, JPG, and JPEG
82
+ (including upper-case extensions); recursive conversion and image optimization
83
+ are not part of the current release.
84
+
85
+ ## Python API
86
+
87
+ ```python
88
+ from svgconverter import SVGConverter, convert_file
89
+
90
+ convert_file("image.png", "image.svg")
91
+
92
+ converter = SVGConverter(overwrite=True)
93
+ result = converter.convert_directory("./images", "./svg-output")
94
+ print(result.success_count, result.failure_count)
95
+ ```
96
+
97
+ `convert_file()` returns the output `pathlib.Path`. Directory conversion returns
98
+ a `BatchResult` containing successful output paths and per-file failures, so a
99
+ bad image does not abort the entire batch.
100
+
101
+ ## GUI
102
+
103
+ Install the package and run:
104
+
105
+ ```bash
106
+ svgconverter-gui
107
+ ```
108
+
109
+ The legacy development command `python main.py` starts the same GUI. The GUI
110
+ currently selects a directory and offers Traditional Chinese, English, and
111
+ Japanese. It uses the same public conversion API as the CLI.
112
+
113
+ ## Release process
114
+
115
+ The tag workflow uses [PyPI Trusted Publishing](https://docs.pypi.org/trusted-publishers/)
116
+ instead of a long-lived API token. Before the first release, configure a
117
+ pending or normal PyPI Trusted Publisher for:
118
+
119
+ - owner: `KageRyo`
120
+ - repository: `SVGConverter`
121
+ - workflow: `release.yml`
122
+ - environment: `pypi`
123
+
124
+ After the pull request is merged, create an annotated `v1.2.0` tag whose
125
+ version matches `pyproject.toml`. The workflow builds, checks, publishes to
126
+ PyPI, and then creates a GitHub Release with the distributions attached.
127
+
128
+ ## Contributing and license
129
+
130
+ See [CONTRIBUTING.md](CONTRIBUTING.md) for local checks and commit conventions.
131
+ This project is licensed under the [MIT License](LICENSE).
@@ -0,0 +1,96 @@
1
+ # SVGConverter
2
+
3
+ [正體中文](README_TW.md)
4
+
5
+ SVGConverter wraps a PNG or JPEG image in an SVG container. It provides a
6
+ small Python API, command-line interface, and desktop GUI.
7
+
8
+ > **Important:** current `embed` mode Base64-encodes the original raster image
9
+ > into an SVG `<image>` element. It does **not** trace pixels into vector paths,
10
+ > and it does not inherently reduce file size. A future `vectorize` mode is
11
+ > tracked separately.
12
+
13
+ ## Installation
14
+
15
+ SVGConverter requires Python 3.10 or newer. After v1.2.0 has been published to
16
+ PyPI:
17
+
18
+ ```bash
19
+ python -m pip install --upgrade svgconverter
20
+ ```
21
+
22
+ To run the current development version instead:
23
+
24
+ ```bash
25
+ git clone https://github.com/KageRyo/SVGConverter.git
26
+ cd SVGConverter
27
+ python -m pip install .
28
+ ```
29
+
30
+ ## Command line
31
+
32
+ Convert one image, retaining its dimensions in the generated SVG:
33
+
34
+ ```bash
35
+ svgconverter image.png
36
+ svgconverter photo.jpg --output output.svg
37
+ ```
38
+
39
+ Convert all supported images immediately inside a directory:
40
+
41
+ ```bash
42
+ svgconverter ./images --output-dir ./svg-output
43
+ ```
44
+
45
+ Outputs are never overwritten unless `--overwrite` is supplied. Run
46
+ `svgconverter --help` for all options. Supported inputs are PNG, JPG, and JPEG
47
+ (including upper-case extensions); recursive conversion and image optimization
48
+ are not part of the current release.
49
+
50
+ ## Python API
51
+
52
+ ```python
53
+ from svgconverter import SVGConverter, convert_file
54
+
55
+ convert_file("image.png", "image.svg")
56
+
57
+ converter = SVGConverter(overwrite=True)
58
+ result = converter.convert_directory("./images", "./svg-output")
59
+ print(result.success_count, result.failure_count)
60
+ ```
61
+
62
+ `convert_file()` returns the output `pathlib.Path`. Directory conversion returns
63
+ a `BatchResult` containing successful output paths and per-file failures, so a
64
+ bad image does not abort the entire batch.
65
+
66
+ ## GUI
67
+
68
+ Install the package and run:
69
+
70
+ ```bash
71
+ svgconverter-gui
72
+ ```
73
+
74
+ The legacy development command `python main.py` starts the same GUI. The GUI
75
+ currently selects a directory and offers Traditional Chinese, English, and
76
+ Japanese. It uses the same public conversion API as the CLI.
77
+
78
+ ## Release process
79
+
80
+ The tag workflow uses [PyPI Trusted Publishing](https://docs.pypi.org/trusted-publishers/)
81
+ instead of a long-lived API token. Before the first release, configure a
82
+ pending or normal PyPI Trusted Publisher for:
83
+
84
+ - owner: `KageRyo`
85
+ - repository: `SVGConverter`
86
+ - workflow: `release.yml`
87
+ - environment: `pypi`
88
+
89
+ After the pull request is merged, create an annotated `v1.2.0` tag whose
90
+ version matches `pyproject.toml`. The workflow builds, checks, publishes to
91
+ PyPI, and then creates a GitHub Release with the distributions attached.
92
+
93
+ ## Contributing and license
94
+
95
+ See [CONTRIBUTING.md](CONTRIBUTING.md) for local checks and commit conventions.
96
+ This project is licensed under the [MIT License](LICENSE).
@@ -0,0 +1,64 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "svgconverter"
7
+ version = "1.2.0"
8
+ description = "Embed PNG and JPEG raster images in SVG containers."
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
13
+ authors = [{ name = "Chien-Hsun Chang" }]
14
+ keywords = ["image", "svg", "converter", "raster", "embed"]
15
+ classifiers = [
16
+ "Development Status :: 4 - Beta",
17
+ "Environment :: Console",
18
+ "Environment :: Win32 (MS Windows)",
19
+ "Intended Audience :: End Users/Desktop",
20
+ "Operating System :: OS Independent",
21
+ "Programming Language :: Python :: 3",
22
+ "Programming Language :: Python :: 3 :: Only",
23
+ "Programming Language :: Python :: 3.10",
24
+ "Programming Language :: Python :: 3.11",
25
+ "Programming Language :: Python :: 3.12",
26
+ "Programming Language :: Python :: 3.13",
27
+ "Programming Language :: Python :: 3.14",
28
+ "Topic :: Multimedia :: Graphics :: Graphics Conversion",
29
+ ]
30
+ dependencies = ["Pillow>=10.0"]
31
+
32
+ [project.optional-dependencies]
33
+ dev = ["build>=1.2", "pytest>=8.0", "ruff>=0.8", "twine>=6.0"]
34
+
35
+ [project.urls]
36
+ Homepage = "https://github.com/KageRyo/SVGConverter"
37
+ Documentation = "https://github.com/KageRyo/SVGConverter#readme"
38
+ Issues = "https://github.com/KageRyo/SVGConverter/issues"
39
+ Changelog = "https://github.com/KageRyo/SVGConverter/blob/main/CHANGELOG.md"
40
+
41
+ [project.scripts]
42
+ svgconverter = "svgconverter.cli:main"
43
+ svgconverter-gui = "svgconverter.gui:run_gui"
44
+
45
+ [tool.setuptools]
46
+ package-dir = { "" = "src" }
47
+ include-package-data = true
48
+
49
+ [tool.setuptools.packages.find]
50
+ where = ["src"]
51
+
52
+ [tool.setuptools.package-data]
53
+ "svgconverter.resources" = ["*.json"]
54
+
55
+ [tool.pytest.ini_options]
56
+ testpaths = ["tests"]
57
+ addopts = "-ra"
58
+
59
+ [tool.ruff]
60
+ target-version = "py310"
61
+ line-length = 88
62
+
63
+ [tool.ruff.lint]
64
+ select = ["E", "F", "I", "UP", "B"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,33 @@
1
+ """Public library interface for SVGConverter.
2
+
3
+ SVGConverter's current ``embed`` mode wraps raster image bytes in an SVG
4
+ ``<image>`` element. It does not trace images into vector paths.
5
+ """
6
+
7
+ from .converter import (
8
+ BatchResult,
9
+ ConversionError,
10
+ ConversionFailure,
11
+ InputPathError,
12
+ OutputExistsError,
13
+ SVGConverter,
14
+ SVGConverterError,
15
+ UnsupportedImageError,
16
+ convert_directory,
17
+ convert_file,
18
+ )
19
+
20
+ __all__ = [
21
+ "BatchResult",
22
+ "ConversionError",
23
+ "ConversionFailure",
24
+ "InputPathError",
25
+ "OutputExistsError",
26
+ "SVGConverter",
27
+ "SVGConverterError",
28
+ "UnsupportedImageError",
29
+ "convert_directory",
30
+ "convert_file",
31
+ ]
32
+
33
+ __version__ = "1.2.0"
@@ -0,0 +1,94 @@
1
+ """Command-line interface for SVGConverter."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ from collections.abc import Sequence
7
+ from pathlib import Path
8
+
9
+ from . import __version__
10
+ from .converter import BatchResult, SVGConverterError, convert_directory, convert_file
11
+
12
+
13
+ def build_parser() -> argparse.ArgumentParser:
14
+ """Build the ``svgconverter`` command parser."""
15
+
16
+ parser = argparse.ArgumentParser(
17
+ prog="svgconverter",
18
+ description=(
19
+ "Embed a PNG or JPEG raster image in an SVG container. "
20
+ "This does not vectorize the image."
21
+ ),
22
+ )
23
+ parser.add_argument(
24
+ "input", type=Path, help="PNG/JPG/JPEG file or a directory of images"
25
+ )
26
+ output_group = parser.add_mutually_exclusive_group()
27
+ output_group.add_argument(
28
+ "-o",
29
+ "--output",
30
+ type=Path,
31
+ help="Output SVG path for a single input file",
32
+ )
33
+ output_group.add_argument(
34
+ "--output-dir",
35
+ type=Path,
36
+ help="Output directory for a directory input",
37
+ )
38
+ parser.add_argument(
39
+ "--overwrite", action="store_true", help="Replace existing output SVG files"
40
+ )
41
+ parser.add_argument(
42
+ "--mode",
43
+ choices=("embed",),
44
+ default="embed",
45
+ help="Conversion mode (only embed is currently available)",
46
+ )
47
+ parser.add_argument(
48
+ "--version", action="version", version=f"%(prog)s {__version__}"
49
+ )
50
+ return parser
51
+
52
+
53
+ def _print_batch_result(result: BatchResult) -> None:
54
+ for output_path in result.converted:
55
+ print(f"Converted: {output_path}")
56
+ for failure in result.failed:
57
+ print(f"Failed: {failure.input_path}: {failure.error}")
58
+ print(f"Summary: {result.success_count} converted, {result.failure_count} failed")
59
+
60
+
61
+ def main(argv: Sequence[str] | None = None) -> int:
62
+ """Run the CLI and return a process exit code."""
63
+
64
+ parser = build_parser()
65
+ arguments = parser.parse_args(argv)
66
+ try:
67
+ if arguments.input.is_file():
68
+ if arguments.output_dir is not None:
69
+ parser.error("--output-dir can only be used with a directory input")
70
+ output_path = convert_file(
71
+ arguments.input,
72
+ arguments.output,
73
+ overwrite=arguments.overwrite,
74
+ mode=arguments.mode,
75
+ )
76
+ print(f"Converted: {output_path}")
77
+ return 0
78
+
79
+ if arguments.output is not None:
80
+ parser.error("--output can only be used with a single input file")
81
+ result = convert_directory(
82
+ arguments.input,
83
+ arguments.output_dir,
84
+ overwrite=arguments.overwrite,
85
+ mode=arguments.mode,
86
+ )
87
+ _print_batch_result(result)
88
+ return 1 if result.failed else 0
89
+ except SVGConverterError as error:
90
+ parser.exit(1, f"error: {error}\n")
91
+
92
+
93
+ if __name__ == "__main__":
94
+ raise SystemExit(main())
@@ -0,0 +1,236 @@
1
+ """Core raster-image embedding functionality.
2
+
3
+ The conversion mode implemented here is deliberately named ``embed``: the
4
+ source raster bytes are Base64 encoded into an SVG ``<image>`` element. No
5
+ attempt is made to convert pixels into vector paths.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import base64
11
+ from dataclasses import dataclass
12
+ from pathlib import Path
13
+ from typing import Literal
14
+
15
+ from PIL import Image, UnidentifiedImageError
16
+
17
+ ConversionMode = Literal["embed"]
18
+
19
+ _SUPPORTED_EXTENSIONS = frozenset({".png", ".jpg", ".jpeg"})
20
+ _MIME_TYPES = {"PNG": "image/png", "JPEG": "image/jpeg"}
21
+
22
+
23
+ class SVGConverterError(Exception):
24
+ """Base exception for expected SVGConverter failures."""
25
+
26
+
27
+ class InputPathError(SVGConverterError):
28
+ """Raised when an input path is missing or is the wrong kind of path."""
29
+
30
+
31
+ class UnsupportedImageError(SVGConverterError):
32
+ """Raised when an input is not a supported PNG or JPEG image."""
33
+
34
+
35
+ class OutputExistsError(SVGConverterError):
36
+ """Raised when conversion would overwrite an existing SVG."""
37
+
38
+
39
+ class ConversionError(SVGConverterError):
40
+ """Raised when an image cannot be read or an SVG cannot be written."""
41
+
42
+
43
+ @dataclass(frozen=True)
44
+ class ConversionFailure:
45
+ """One failed item from a batch conversion."""
46
+
47
+ input_path: Path
48
+ error: SVGConverterError
49
+
50
+
51
+ @dataclass(frozen=True)
52
+ class BatchResult:
53
+ """Successful and failed items from a directory conversion."""
54
+
55
+ converted: tuple[Path, ...]
56
+ failed: tuple[ConversionFailure, ...]
57
+
58
+ @property
59
+ def success_count(self) -> int:
60
+ """Return the number of converted images."""
61
+
62
+ return len(self.converted)
63
+
64
+ @property
65
+ def failure_count(self) -> int:
66
+ """Return the number of files that could not be converted."""
67
+
68
+ return len(self.failed)
69
+
70
+
71
+ def _validate_mode(mode: ConversionMode) -> None:
72
+ if mode != "embed":
73
+ raise ValueError(
74
+ "Only mode='embed' is available; vectorization is not implemented."
75
+ )
76
+
77
+
78
+ def _validate_input(input_path: Path) -> None:
79
+ if not input_path.exists():
80
+ raise InputPathError(f"Input file does not exist: {input_path}")
81
+ if not input_path.is_file():
82
+ raise InputPathError(f"Input path is not a file: {input_path}")
83
+ if input_path.suffix.lower() not in _SUPPORTED_EXTENSIONS:
84
+ supported = ", ".join(sorted(_SUPPORTED_EXTENSIONS))
85
+ raise UnsupportedImageError(
86
+ f"Unsupported image extension {input_path.suffix!r}; "
87
+ f"supported extensions: {supported}"
88
+ )
89
+
90
+
91
+ def _read_image_metadata(input_path: Path) -> tuple[int, int, str]:
92
+ try:
93
+ with Image.open(input_path) as image:
94
+ image.verify()
95
+ with Image.open(input_path) as image:
96
+ mime_type = _MIME_TYPES.get(image.format or "")
97
+ if mime_type is None:
98
+ raise UnsupportedImageError(
99
+ f"Unsupported image format in {input_path}: "
100
+ f"{image.format or 'unknown'}"
101
+ )
102
+ return image.width, image.height, mime_type
103
+ except UnsupportedImageError:
104
+ raise
105
+ except (OSError, UnidentifiedImageError) as error:
106
+ raise ConversionError(f"Cannot read image {input_path}: {error}") from error
107
+
108
+
109
+ def _svg_document(*, data_uri: str, width: int, height: int) -> str:
110
+ return (
111
+ '<?xml version="1.0" encoding="UTF-8"?>\n'
112
+ '<svg xmlns="http://www.w3.org/2000/svg" '
113
+ f'width="{width}" height="{height}" viewBox="0 0 {width} {height}">\n'
114
+ f' <image href="{data_uri}" width="{width}" height="{height}"/>\n'
115
+ "</svg>\n"
116
+ )
117
+
118
+
119
+ def convert_file(
120
+ input_path: str | Path,
121
+ output_path: str | Path | None = None,
122
+ *,
123
+ overwrite: bool = False,
124
+ mode: ConversionMode = "embed",
125
+ ) -> Path:
126
+ """Embed one PNG or JPEG image in an SVG file and return its output path.
127
+
128
+ ``output_path`` defaults to the input filename with an ``.svg`` suffix.
129
+ Parent directories for an explicit output path are created automatically.
130
+ Existing outputs are preserved unless ``overwrite=True`` is provided.
131
+ """
132
+
133
+ _validate_mode(mode)
134
+ source = Path(input_path)
135
+ _validate_input(source)
136
+ destination = (
137
+ Path(output_path) if output_path is not None else source.with_suffix(".svg")
138
+ )
139
+
140
+ if destination.exists() and not overwrite:
141
+ raise OutputExistsError(
142
+ f"Output already exists: {destination}. Pass overwrite=True to replace it."
143
+ )
144
+ if destination.exists() and destination.is_dir():
145
+ raise InputPathError(f"Output path is a directory: {destination}")
146
+
147
+ width, height, mime_type = _read_image_metadata(source)
148
+ try:
149
+ image_data = source.read_bytes()
150
+ data_uri = (
151
+ f"data:{mime_type};base64,{base64.b64encode(image_data).decode('ascii')}"
152
+ )
153
+ destination.parent.mkdir(parents=True, exist_ok=True)
154
+ destination.write_text(
155
+ _svg_document(data_uri=data_uri, width=width, height=height),
156
+ encoding="utf-8",
157
+ )
158
+ except OSError as error:
159
+ raise ConversionError(f"Cannot write SVG {destination}: {error}") from error
160
+
161
+ return destination
162
+
163
+
164
+ def convert_directory(
165
+ directory: str | Path,
166
+ output_dir: str | Path | None = None,
167
+ *,
168
+ overwrite: bool = False,
169
+ mode: ConversionMode = "embed",
170
+ ) -> BatchResult:
171
+ """Convert supported images directly in a directory without recursing.
172
+
173
+ Files with non-image extensions are ignored. Each supported image is
174
+ attempted independently, so a corrupt image does not stop the batch.
175
+ """
176
+
177
+ _validate_mode(mode)
178
+ source_directory = Path(directory)
179
+ if not source_directory.exists():
180
+ raise InputPathError(f"Input directory does not exist: {source_directory}")
181
+ if not source_directory.is_dir():
182
+ raise InputPathError(f"Input path is not a directory: {source_directory}")
183
+
184
+ destination_directory = (
185
+ Path(output_dir) if output_dir is not None else source_directory
186
+ )
187
+ converted: list[Path] = []
188
+ failed: list[ConversionFailure] = []
189
+ candidates = sorted(
190
+ (
191
+ path
192
+ for path in source_directory.iterdir()
193
+ if path.is_file() and path.suffix.lower() in _SUPPORTED_EXTENSIONS
194
+ ),
195
+ key=lambda path: path.name.casefold(),
196
+ )
197
+
198
+ for source in candidates:
199
+ destination = destination_directory / f"{source.stem}.svg"
200
+ try:
201
+ converted.append(
202
+ convert_file(source, destination, overwrite=overwrite, mode=mode)
203
+ )
204
+ except SVGConverterError as error:
205
+ failed.append(ConversionFailure(input_path=source, error=error))
206
+
207
+ return BatchResult(converted=tuple(converted), failed=tuple(failed))
208
+
209
+
210
+ class SVGConverter:
211
+ """Configurable facade for repeated SVG embedding operations."""
212
+
213
+ def __init__(
214
+ self, *, overwrite: bool = False, mode: ConversionMode = "embed"
215
+ ) -> None:
216
+ _validate_mode(mode)
217
+ self.overwrite = overwrite
218
+ self.mode = mode
219
+
220
+ def convert_file(
221
+ self, input_path: str | Path, output_path: str | Path | None = None
222
+ ) -> Path:
223
+ """Convert one image using this instance's configuration."""
224
+
225
+ return convert_file(
226
+ input_path, output_path, overwrite=self.overwrite, mode=self.mode
227
+ )
228
+
229
+ def convert_directory(
230
+ self, directory: str | Path, output_dir: str | Path | None = None
231
+ ) -> BatchResult:
232
+ """Convert one directory using this instance's configuration."""
233
+
234
+ return convert_directory(
235
+ directory, output_dir, overwrite=self.overwrite, mode=self.mode
236
+ )
@@ -0,0 +1,82 @@
1
+ """Tkinter desktop interface built on the public conversion API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import tkinter as tk
6
+ from tkinter import filedialog, messagebox
7
+
8
+ from .converter import SVGConverter, SVGConverterError
9
+ from .i18n import (
10
+ DEFAULT_LOCALE,
11
+ load_translations,
12
+ locale_for_display_name,
13
+ translation_for,
14
+ )
15
+
16
+
17
+ class SVGConverterApp:
18
+ """The standalone GUI application."""
19
+
20
+ def __init__(self) -> None:
21
+ self.root = tk.Tk()
22
+ self.root.title("SVGConverter")
23
+ self.converter = SVGConverter()
24
+ self.translations = load_translations()
25
+ self.locale = DEFAULT_LOCALE
26
+ self.button = tk.Button(self.root, command=self.select_folder)
27
+ self.button.pack(pady=20)
28
+ initial_name = translation_for(self.locale, self.translations)["name"]
29
+ self.language_var = tk.StringVar(value=initial_name)
30
+ self.language_menu = tk.OptionMenu(
31
+ self.root,
32
+ self.language_var,
33
+ *(text["name"] for text in self.translations.values()),
34
+ command=self.change_language,
35
+ )
36
+ self.language_menu.pack()
37
+ self._refresh_text()
38
+
39
+ def _refresh_text(self) -> None:
40
+ self.button.config(
41
+ text=translation_for(self.locale, self.translations)["select"]
42
+ )
43
+
44
+ def select_folder(self) -> None:
45
+ folder = filedialog.askdirectory(parent=self.root)
46
+ if not folder:
47
+ return
48
+ text = translation_for(self.locale, self.translations)
49
+ try:
50
+ result = self.converter.convert_directory(folder)
51
+ except SVGConverterError as error:
52
+ messagebox.showerror("SVGConverter", str(error), parent=self.root)
53
+ return
54
+
55
+ message = text["done"].format(
56
+ converted=result.success_count, failed=result.failure_count
57
+ )
58
+ if result.failed:
59
+ details = "\n".join(
60
+ f"{failure.input_path.name}: {failure.error}"
61
+ for failure in result.failed
62
+ )
63
+ messagebox.showwarning(
64
+ "SVGConverter", f"{message}\n\n{details}", parent=self.root
65
+ )
66
+ else:
67
+ messagebox.showinfo("SVGConverter", message, parent=self.root)
68
+
69
+ def change_language(self, display_name: str) -> None:
70
+ self.locale = locale_for_display_name(display_name, self.translations)
71
+ self._refresh_text()
72
+
73
+ def run(self) -> None:
74
+ """Start the Tkinter event loop."""
75
+
76
+ self.root.mainloop()
77
+
78
+
79
+ def run_gui() -> None:
80
+ """Launch the SVGConverter desktop interface."""
81
+
82
+ SVGConverterApp().run()
@@ -0,0 +1,43 @@
1
+ """Package-safe localization helpers for the Tkinter interface."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from importlib import resources
7
+
8
+ DEFAULT_LOCALE = "zh_TW"
9
+
10
+
11
+ def load_translations() -> dict[str, dict[str, str]]:
12
+ """Load bundled translations without relying on the current directory."""
13
+
14
+ resource = resources.files("svgconverter.resources").joinpath("languages.json")
15
+ with resource.open("r", encoding="utf-8") as translation_file:
16
+ translations: dict[str, dict[str, str]] = json.load(translation_file)
17
+ return translations
18
+
19
+
20
+ def locale_for_display_name(
21
+ display_name: str, translations: dict[str, dict[str, str]]
22
+ ) -> str:
23
+ """Return the locale key represented by a language menu label.
24
+
25
+ Unknown labels use Traditional Chinese, the package's default locale.
26
+ """
27
+
28
+ for locale, text in translations.items():
29
+ if text.get("name") == display_name:
30
+ return locale
31
+ return (
32
+ DEFAULT_LOCALE if DEFAULT_LOCALE in translations else next(iter(translations))
33
+ )
34
+
35
+
36
+ def translation_for(
37
+ locale: str, translations: dict[str, dict[str, str]]
38
+ ) -> dict[str, str]:
39
+ """Return translations for ``locale`` with a safe fallback."""
40
+
41
+ return translations.get(
42
+ locale, translations.get(DEFAULT_LOCALE, next(iter(translations.values())))
43
+ )
@@ -0,0 +1 @@
1
+ """Bundled localization resources for SVGConverter."""
@@ -0,0 +1,17 @@
1
+ {
2
+ "zh_TW": {
3
+ "name": "正體中文",
4
+ "select": "選擇資料夾",
5
+ "done": "轉換完成:已轉換 {converted} 個檔案;失敗 {failed} 個。"
6
+ },
7
+ "en_US": {
8
+ "name": "English",
9
+ "select": "Select Folder",
10
+ "done": "Conversion complete: {converted} converted; {failed} failed."
11
+ },
12
+ "ja_JP": {
13
+ "name": "日本語",
14
+ "select": "フォルダを選択",
15
+ "done": "変換完了:{converted} 件変換、{failed} 件失敗。"
16
+ }
17
+ }
@@ -0,0 +1,131 @@
1
+ Metadata-Version: 2.4
2
+ Name: svgconverter
3
+ Version: 1.2.0
4
+ Summary: Embed PNG and JPEG raster images in SVG containers.
5
+ Author: Chien-Hsun Chang
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/KageRyo/SVGConverter
8
+ Project-URL: Documentation, https://github.com/KageRyo/SVGConverter#readme
9
+ Project-URL: Issues, https://github.com/KageRyo/SVGConverter/issues
10
+ Project-URL: Changelog, https://github.com/KageRyo/SVGConverter/blob/main/CHANGELOG.md
11
+ Keywords: image,svg,converter,raster,embed
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Environment :: Console
14
+ Classifier: Environment :: Win32 (MS Windows)
15
+ Classifier: Intended Audience :: End Users/Desktop
16
+ Classifier: Operating System :: OS Independent
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3 :: Only
19
+ Classifier: Programming Language :: Python :: 3.10
20
+ Classifier: Programming Language :: Python :: 3.11
21
+ Classifier: Programming Language :: Python :: 3.12
22
+ Classifier: Programming Language :: Python :: 3.13
23
+ Classifier: Programming Language :: Python :: 3.14
24
+ Classifier: Topic :: Multimedia :: Graphics :: Graphics Conversion
25
+ Requires-Python: >=3.10
26
+ Description-Content-Type: text/markdown
27
+ License-File: LICENSE
28
+ Requires-Dist: Pillow>=10.0
29
+ Provides-Extra: dev
30
+ Requires-Dist: build>=1.2; extra == "dev"
31
+ Requires-Dist: pytest>=8.0; extra == "dev"
32
+ Requires-Dist: ruff>=0.8; extra == "dev"
33
+ Requires-Dist: twine>=6.0; extra == "dev"
34
+ Dynamic: license-file
35
+
36
+ # SVGConverter
37
+
38
+ [正體中文](README_TW.md)
39
+
40
+ SVGConverter wraps a PNG or JPEG image in an SVG container. It provides a
41
+ small Python API, command-line interface, and desktop GUI.
42
+
43
+ > **Important:** current `embed` mode Base64-encodes the original raster image
44
+ > into an SVG `<image>` element. It does **not** trace pixels into vector paths,
45
+ > and it does not inherently reduce file size. A future `vectorize` mode is
46
+ > tracked separately.
47
+
48
+ ## Installation
49
+
50
+ SVGConverter requires Python 3.10 or newer. After v1.2.0 has been published to
51
+ PyPI:
52
+
53
+ ```bash
54
+ python -m pip install --upgrade svgconverter
55
+ ```
56
+
57
+ To run the current development version instead:
58
+
59
+ ```bash
60
+ git clone https://github.com/KageRyo/SVGConverter.git
61
+ cd SVGConverter
62
+ python -m pip install .
63
+ ```
64
+
65
+ ## Command line
66
+
67
+ Convert one image, retaining its dimensions in the generated SVG:
68
+
69
+ ```bash
70
+ svgconverter image.png
71
+ svgconverter photo.jpg --output output.svg
72
+ ```
73
+
74
+ Convert all supported images immediately inside a directory:
75
+
76
+ ```bash
77
+ svgconverter ./images --output-dir ./svg-output
78
+ ```
79
+
80
+ Outputs are never overwritten unless `--overwrite` is supplied. Run
81
+ `svgconverter --help` for all options. Supported inputs are PNG, JPG, and JPEG
82
+ (including upper-case extensions); recursive conversion and image optimization
83
+ are not part of the current release.
84
+
85
+ ## Python API
86
+
87
+ ```python
88
+ from svgconverter import SVGConverter, convert_file
89
+
90
+ convert_file("image.png", "image.svg")
91
+
92
+ converter = SVGConverter(overwrite=True)
93
+ result = converter.convert_directory("./images", "./svg-output")
94
+ print(result.success_count, result.failure_count)
95
+ ```
96
+
97
+ `convert_file()` returns the output `pathlib.Path`. Directory conversion returns
98
+ a `BatchResult` containing successful output paths and per-file failures, so a
99
+ bad image does not abort the entire batch.
100
+
101
+ ## GUI
102
+
103
+ Install the package and run:
104
+
105
+ ```bash
106
+ svgconverter-gui
107
+ ```
108
+
109
+ The legacy development command `python main.py` starts the same GUI. The GUI
110
+ currently selects a directory and offers Traditional Chinese, English, and
111
+ Japanese. It uses the same public conversion API as the CLI.
112
+
113
+ ## Release process
114
+
115
+ The tag workflow uses [PyPI Trusted Publishing](https://docs.pypi.org/trusted-publishers/)
116
+ instead of a long-lived API token. Before the first release, configure a
117
+ pending or normal PyPI Trusted Publisher for:
118
+
119
+ - owner: `KageRyo`
120
+ - repository: `SVGConverter`
121
+ - workflow: `release.yml`
122
+ - environment: `pypi`
123
+
124
+ After the pull request is merged, create an annotated `v1.2.0` tag whose
125
+ version matches `pyproject.toml`. The workflow builds, checks, publishes to
126
+ PyPI, and then creates a GitHub Release with the distributions attached.
127
+
128
+ ## Contributing and license
129
+
130
+ See [CONTRIBUTING.md](CONTRIBUTING.md) for local checks and commit conventions.
131
+ This project is licensed under the [MIT License](LICENSE).
@@ -0,0 +1,19 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ src/svgconverter/__init__.py
5
+ src/svgconverter/cli.py
6
+ src/svgconverter/converter.py
7
+ src/svgconverter/gui.py
8
+ src/svgconverter/i18n.py
9
+ src/svgconverter.egg-info/PKG-INFO
10
+ src/svgconverter.egg-info/SOURCES.txt
11
+ src/svgconverter.egg-info/dependency_links.txt
12
+ src/svgconverter.egg-info/entry_points.txt
13
+ src/svgconverter.egg-info/requires.txt
14
+ src/svgconverter.egg-info/top_level.txt
15
+ src/svgconverter/resources/__init__.py
16
+ src/svgconverter/resources/languages.json
17
+ tests/test_cli.py
18
+ tests/test_converter.py
19
+ tests/test_i18n.py
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ svgconverter = svgconverter.cli:main
3
+ svgconverter-gui = svgconverter.gui:run_gui
@@ -0,0 +1,7 @@
1
+ Pillow>=10.0
2
+
3
+ [dev]
4
+ build>=1.2
5
+ pytest>=8.0
6
+ ruff>=0.8
7
+ twine>=6.0
@@ -0,0 +1 @@
1
+ svgconverter
@@ -0,0 +1,54 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ import pytest
6
+ from PIL import Image
7
+
8
+ from svgconverter.cli import main
9
+
10
+
11
+ def create_image(path: Path, image_format: str) -> Path:
12
+ image = Image.new("RGB", (2, 2), color=(25, 50, 75))
13
+ image.save(path, format=image_format)
14
+ return path
15
+
16
+
17
+ def test_cli_converts_single_file_to_explicit_output(
18
+ tmp_path: Path, capsys: pytest.CaptureFixture[str]
19
+ ) -> None:
20
+ source = create_image(tmp_path / "sample.png", "PNG")
21
+ destination = tmp_path / "output.svg"
22
+
23
+ exit_code = main([str(source), "--output", str(destination)])
24
+
25
+ assert exit_code == 0
26
+ assert destination.is_file()
27
+ assert "Converted:" in capsys.readouterr().out
28
+
29
+
30
+ def test_cli_converts_directory_and_reports_a_failure(
31
+ tmp_path: Path, capsys: pytest.CaptureFixture[str]
32
+ ) -> None:
33
+ source_directory = tmp_path / "images"
34
+ source_directory.mkdir()
35
+ create_image(source_directory / "good.png", "PNG")
36
+ (source_directory / "bad.jpg").write_bytes(b"not an image")
37
+ output_directory = tmp_path / "out"
38
+
39
+ exit_code = main([str(source_directory), "--output-dir", str(output_directory)])
40
+
41
+ captured = capsys.readouterr()
42
+ assert exit_code == 1
43
+ assert (output_directory / "good.svg").is_file()
44
+ assert "Summary: 1 converted, 1 failed" in captured.out
45
+
46
+
47
+ def test_cli_uses_nonzero_exit_code_for_invalid_input(
48
+ tmp_path: Path, capsys: pytest.CaptureFixture[str]
49
+ ) -> None:
50
+ with pytest.raises(SystemExit) as exit_status:
51
+ main([str(tmp_path / "missing.png")])
52
+
53
+ assert exit_status.value.code == 1
54
+ assert "does not exist" in capsys.readouterr().err
@@ -0,0 +1,123 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ import pytest
6
+ from PIL import Image
7
+
8
+ from svgconverter import (
9
+ ConversionError,
10
+ InputPathError,
11
+ OutputExistsError,
12
+ SVGConverter,
13
+ UnsupportedImageError,
14
+ convert_directory,
15
+ convert_file,
16
+ )
17
+
18
+
19
+ def create_image(path: Path, image_format: str, size: tuple[int, int] = (3, 2)) -> Path:
20
+ image = Image.new("RGB", size, color=(25, 50, 75))
21
+ image.save(path, format=image_format)
22
+ return path
23
+
24
+
25
+ def test_convert_png_embeds_correct_mime_and_dimensions(tmp_path: Path) -> None:
26
+ source = create_image(tmp_path / "sample.png", "PNG", (3, 2))
27
+
28
+ output = convert_file(source)
29
+
30
+ document = output.read_text(encoding="utf-8")
31
+ assert output == tmp_path / "sample.svg"
32
+ assert 'width="3" height="2"' in document
33
+ assert "data:image/png;base64," in document
34
+
35
+
36
+ @pytest.mark.parametrize("suffix", [".jpg", ".jpeg", ".JPG"])
37
+ def test_convert_jpeg_uses_jpeg_mime_type(tmp_path: Path, suffix: str) -> None:
38
+ source = create_image(tmp_path / f"photo{suffix}", "JPEG")
39
+
40
+ output = convert_file(source)
41
+
42
+ assert "data:image/jpeg;base64," in output.read_text(encoding="utf-8")
43
+
44
+
45
+ def test_mime_type_is_detected_from_image_content(tmp_path: Path) -> None:
46
+ source = create_image(tmp_path / "misnamed.jpg", "PNG")
47
+
48
+ output = convert_file(source)
49
+
50
+ assert "data:image/png;base64," in output.read_text(encoding="utf-8")
51
+
52
+
53
+ def test_explicit_output_creates_parent_directories(tmp_path: Path) -> None:
54
+ source = create_image(tmp_path / "sample.png", "PNG")
55
+ destination = tmp_path / "nested" / "result.svg"
56
+
57
+ output = convert_file(source, destination)
58
+
59
+ assert output == destination
60
+ assert output.is_file()
61
+
62
+
63
+ def test_existing_output_requires_overwrite(tmp_path: Path) -> None:
64
+ source = create_image(tmp_path / "sample.png", "PNG")
65
+ destination = tmp_path / "result.svg"
66
+ destination.write_text("original", encoding="utf-8")
67
+
68
+ with pytest.raises(OutputExistsError):
69
+ convert_file(source, destination)
70
+
71
+ convert_file(source, destination, overwrite=True)
72
+ assert "data:image/png;base64," in destination.read_text(encoding="utf-8")
73
+
74
+
75
+ def test_invalid_inputs_raise_useful_errors(tmp_path: Path) -> None:
76
+ with pytest.raises(InputPathError):
77
+ convert_file(tmp_path / "missing.png")
78
+
79
+ text_file = tmp_path / "not-an-image.gif"
80
+ text_file.write_text("not an image", encoding="utf-8")
81
+ with pytest.raises(UnsupportedImageError):
82
+ convert_file(text_file)
83
+
84
+ corrupt_image = tmp_path / "corrupt.png"
85
+ corrupt_image.write_bytes(b"not a PNG")
86
+ with pytest.raises(ConversionError, match="Cannot read image"):
87
+ convert_file(corrupt_image)
88
+
89
+
90
+ def test_directory_conversion_reports_successes_and_failures(tmp_path: Path) -> None:
91
+ source_directory = tmp_path / "images"
92
+ source_directory.mkdir()
93
+ create_image(source_directory / "one.png", "PNG")
94
+ create_image(source_directory / "two.JPEG", "JPEG")
95
+ (source_directory / "bad.jpg").write_bytes(b"not an image")
96
+ (source_directory / "ignored.txt").write_text("ignored", encoding="utf-8")
97
+ output_directory = tmp_path / "converted"
98
+
99
+ result = convert_directory(source_directory, output_directory)
100
+
101
+ assert result.success_count == 2
102
+ assert result.failure_count == 1
103
+ assert {path.name for path in result.converted} == {"one.svg", "two.svg"}
104
+ assert result.failed[0].input_path.name == "bad.jpg"
105
+ assert (output_directory / "one.svg").is_file()
106
+
107
+
108
+ def test_converter_class_uses_its_configuration(tmp_path: Path) -> None:
109
+ source = create_image(tmp_path / "sample.png", "PNG")
110
+ destination = tmp_path / "result.svg"
111
+ destination.write_text("old", encoding="utf-8")
112
+
113
+ output = SVGConverter(overwrite=True).convert_file(source, destination)
114
+
115
+ assert output == destination
116
+ assert "data:image/png;base64," in destination.read_text(encoding="utf-8")
117
+
118
+
119
+ def test_only_embed_mode_is_available(tmp_path: Path) -> None:
120
+ source = create_image(tmp_path / "sample.png", "PNG")
121
+
122
+ with pytest.raises(ValueError, match="Only mode='embed'"):
123
+ convert_file(source, mode="vectorize") # type: ignore[arg-type]
@@ -0,0 +1,23 @@
1
+ from __future__ import annotations
2
+
3
+ from svgconverter.i18n import (
4
+ DEFAULT_LOCALE,
5
+ load_translations,
6
+ locale_for_display_name,
7
+ translation_for,
8
+ )
9
+
10
+
11
+ def test_bundled_translations_have_all_supported_locales() -> None:
12
+ translations = load_translations()
13
+
14
+ assert set(translations) == {"zh_TW", "en_US", "ja_JP"}
15
+ assert locale_for_display_name("English", translations) == "en_US"
16
+ assert locale_for_display_name("日本語", translations) == "ja_JP"
17
+
18
+
19
+ def test_unknown_locale_and_display_name_fall_back_to_default() -> None:
20
+ translations = load_translations()
21
+
22
+ assert locale_for_display_name("unknown", translations) == DEFAULT_LOCALE
23
+ assert translation_for("unknown", translations) == translations[DEFAULT_LOCALE]