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.
- svgconverter-1.2.0/LICENSE +21 -0
- svgconverter-1.2.0/PKG-INFO +131 -0
- svgconverter-1.2.0/README.md +96 -0
- svgconverter-1.2.0/pyproject.toml +64 -0
- svgconverter-1.2.0/setup.cfg +4 -0
- svgconverter-1.2.0/src/svgconverter/__init__.py +33 -0
- svgconverter-1.2.0/src/svgconverter/cli.py +94 -0
- svgconverter-1.2.0/src/svgconverter/converter.py +236 -0
- svgconverter-1.2.0/src/svgconverter/gui.py +82 -0
- svgconverter-1.2.0/src/svgconverter/i18n.py +43 -0
- svgconverter-1.2.0/src/svgconverter/resources/__init__.py +1 -0
- svgconverter-1.2.0/src/svgconverter/resources/languages.json +17 -0
- svgconverter-1.2.0/src/svgconverter.egg-info/PKG-INFO +131 -0
- svgconverter-1.2.0/src/svgconverter.egg-info/SOURCES.txt +19 -0
- svgconverter-1.2.0/src/svgconverter.egg-info/dependency_links.txt +1 -0
- svgconverter-1.2.0/src/svgconverter.egg-info/entry_points.txt +3 -0
- svgconverter-1.2.0/src/svgconverter.egg-info/requires.txt +7 -0
- svgconverter-1.2.0/src/svgconverter.egg-info/top_level.txt +1 -0
- svgconverter-1.2.0/tests/test_cli.py +54 -0
- svgconverter-1.2.0/tests/test_converter.py +123 -0
- svgconverter-1.2.0/tests/test_i18n.py +23 -0
|
@@ -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,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 @@
|
|
|
1
|
+
|
|
@@ -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]
|