diff --git a/docs/usage.md b/docs/usage.md index fa21c61..fb7a796 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -44,7 +44,7 @@ uv run mono-pixel --help | `--image-size` | `-s` | Output image size, e.g. `1024x1024` (also accepts `X`, `*`, space as separator) | | `--font-size` | `-z` | Manual font size in **pixels** (mutually exclusive with `--auto-fit`) | | `--auto-fit` | `-a` | Auto-fit font size to fill the canvas (mutually exclusive with `--font-size`) | -| `--output` | `-o` | Output PNG path (default: `output.png`) | +| `--output` | `-o` | Output file path (`.png` for raster, `.svg` for vector; default: `output.png`) | > **Note:** > - You must supply exactly one of `--font-size` or `--auto-fit`. @@ -86,10 +86,12 @@ The default preview (when neither flag is set) shows a **position-aware ASCII pr | Option | Default | Description | |--------|---------|-------------| -| `--dpi` | `72` | Output image DPI | -| `--overwrite` | | Overwrite the output file without prompting | +| `--dpi` | `72` | Output image DPI (for PNG) | +| `--overwrite` | | Overwrite the existing output file without prompting | | `--quiet` / `-q` | | Quiet mode — suppress all progress and status messages | +> **Note:** Output format is determined by file extension. Use `.png` for raster images (default) or `.svg` for vector graphics. + ### Binarization Mono-pixel renders strictly monochrome images by default (every pixel becomes either pure black or pure white). @@ -223,7 +225,22 @@ image = render_text( ) # Export -from mono_pixel.exporter import save_image +from mono_pixel.exporter import save_image, export_to_svg + +# Export to PNG (default) save_image(image, "out.png", strict_binarize=True, dpi=(300, 300)) + +# Export to SVG (vector graphics) +# Automatically detects format from file extension +save_image(image, "out.svg", svg_pixel_size=2) + +# Or use the dedicated SVG export function directly +export_to_svg( + image, + "out.svg", + bg_color="white", + fg_color="black", + pixel_size=1, +) ``` diff --git a/src/mono_pixel/__init__.py b/src/mono_pixel/__init__.py index 8982e84..d6d4a9d 100644 --- a/src/mono_pixel/__init__.py +++ b/src/mono_pixel/__init__.py @@ -2,7 +2,7 @@ from PIL import Image -from .exporter import save_image, strict_binarization +from .exporter import export_to_svg, save_image, strict_binarization from .font_loader import ( calculate_text_size, get_builtin_fonts, @@ -59,6 +59,7 @@ "render_text", # Exporter "strict_binarization", + "export_to_svg", "save_image", # High-level API "generate_pixel_text", diff --git a/src/mono_pixel/exporter.py b/src/mono_pixel/exporter.py index c569bc1..ccc3f4c 100644 --- a/src/mono_pixel/exporter.py +++ b/src/mono_pixel/exporter.py @@ -1,5 +1,6 @@ """Image export and binarization utilities.""" +import xml.etree.ElementTree as ET from pathlib import Path from PIL import Image @@ -145,6 +146,7 @@ def save_image( binarization_threshold: int = 127, dpi: tuple[int, int] = (72, 72), optimize: bool = True, + svg_pixel_size: int = 1, ) -> Path: """Run full image save pipeline. @@ -155,12 +157,15 @@ def save_image( bg_color: Background color. fg_color: Foreground color. binarization_threshold: Binarization threshold. - dpi: Output DPI. - optimize: Whether to optimize output size. + dpi: Output DPI (for PNG). + optimize: Whether to optimize output size (for PNG). + svg_pixel_size: Size of each pixel in SVG units (for SVG). Returns: Path to output file. """ + output_path = Path(output_path) + if strict_binarize: processed_image = convert_to_monochrome( image, bg_color, fg_color, binarization_threshold @@ -168,4 +173,154 @@ def save_image( else: processed_image = image - return export_to_png(processed_image, output_path, dpi, optimize) + # Determine output format based on file extension + suffix = output_path.suffix.lower() + + if suffix == ".svg": + return export_to_svg( + processed_image, + output_path, + bg_color=bg_color, + fg_color=fg_color, + pixel_size=svg_pixel_size, + ) + else: + # Default to PNG + return export_to_png(processed_image, output_path, dpi, optimize) + + +def export_to_svg( + image: Image.Image, + output_path: str | Path, + bg_color: str | tuple[int, int, int] = "white", + fg_color: str | tuple[int, int, int] = "black", + pixel_size: int = 1, +) -> Path: + """Export image to SVG file. + + Args: + image: Image to export (should be 1-bit or RGB). + output_path: Output file path. + bg_color: Background color. + fg_color: Foreground color. + pixel_size: Size of each pixel in SVG units. + + Returns: + Path to output file. + + Raises: + ExportError: Export failed. + """ + output_path = Path(output_path) + + try: + output_path.parent.mkdir(parents=True, exist_ok=True) + except Exception as e: + raise ExportError( + f"Failed to create output directory: {output_path.parent}. Error: {e}" + ) from e + + def parse_color(color): + if isinstance(color, str): + from PIL import ImageColor + + rgb = ImageColor.getrgb(color) + return f"#{rgb[0]:02x}{rgb[1]:02x}{rgb[2]:02x}" + elif isinstance(color, tuple) and len(color) >= 3: + return f"#{color[0]:02x}{color[1]:02x}{color[2]:02x}" + return str(color) + + bg_svg = parse_color(bg_color) + fg_svg = parse_color(fg_color) + + width, height = image.size + svg_width = width * pixel_size + svg_height = height * pixel_size + + # Create SVG root element + svg = ET.Element( + "svg", + { + "xmlns": "http://www.w3.org/2000/svg", + "width": str(svg_width), + "height": str(svg_height), + "viewBox": f"0 0 {svg_width} {svg_height}", + }, + ) + + # Add background rectangle + ET.SubElement( + svg, + "rect", + { + "x": "0", + "y": "0", + "width": str(svg_width), + "height": str(svg_height), + "fill": bg_svg, + }, + ) + + # Create a group for foreground pixels + fg_group = ET.SubElement(svg, "g", {"fill": fg_svg}) + + # Get pixel data based on image mode + if image.mode == "1": + for y in range(height): + for x in range(width): + pixel = image.getpixel((x, y)) + # For 1-bit images, 0 is foreground (black) + if pixel == 0: + ET.SubElement( + fg_group, + "rect", + { + "x": str(x * pixel_size), + "y": str(y * pixel_size), + "width": str(pixel_size), + "height": str(pixel_size), + }, + ) + elif image.mode == "L": + for y in range(height): + for x in range(width): + pixel = image.getpixel((x, y)) + # For grayscale, treat dark pixels as foreground + if isinstance(pixel, int) and pixel < 128: + ET.SubElement( + fg_group, + "rect", + { + "x": str(x * pixel_size), + "y": str(y * pixel_size), + "width": str(pixel_size), + "height": str(pixel_size), + }, + ) + else: + # RGB or RGBA + for y in range(height): + for x in range(width): + pixel = image.getpixel((x, y)) + if isinstance(pixel, tuple) and len(pixel) >= 3: + # Treat non-white pixels as foreground + if pixel[:3] != (255, 255, 255): + ET.SubElement( + fg_group, + "rect", + { + "x": str(x * pixel_size), + "y": str(y * pixel_size), + "width": str(pixel_size), + "height": str(pixel_size), + }, + ) + + # Write SVG file + tree = ET.ElementTree(svg) + try: + tree.write(str(output_path), encoding="utf-8", xml_declaration=True) + except Exception as e: + raise ExportError(f"Failed to save SVG file: {output_path}. Error: {e}") from e + + return output_path diff --git a/tests/test_exporter.py b/tests/test_exporter.py index 0333e8f..74e8be6 100644 --- a/tests/test_exporter.py +++ b/tests/test_exporter.py @@ -1,5 +1,6 @@ """Exporter module unit tests.""" +import xml.etree.ElementTree as ET from pathlib import Path import pytest @@ -8,6 +9,7 @@ from mono_pixel.exporter import ( convert_to_monochrome, export_to_png, + export_to_svg, save_image, strict_binarization, ) @@ -198,6 +200,139 @@ def test_large_image(self, temp_output_path: Path): result = save_image(canvas, temp_output_path, strict_binarize=True) assert result.exists() + +class TestExportToSvg: + """Tests for SVG export.""" + + def test_export_basic(self, tmp_path: Path): + """Basic SVG export writes valid file.""" + canvas = create_canvas(100, 50, "white") + output_path = tmp_path / "output.svg" + result = export_to_svg(canvas, output_path) + + assert result == output_path + assert result.exists() + + # Verify it's valid XML + tree = ET.parse(str(result)) + root = tree.getroot() + assert root.tag == "{http://www.w3.org/2000/svg}svg" + assert root.attrib["width"] == "100" + assert root.attrib["height"] == "50" + + def test_export_with_pixel_size(self, tmp_path: Path): + """Custom pixel size works.""" + canvas = create_canvas(10, 10, "white") + output_path = tmp_path / "output.svg" + result = export_to_svg(canvas, output_path, pixel_size=2) + + assert result.exists() + + tree = ET.parse(str(result)) + root = tree.getroot() + assert root.attrib["width"] == "20" + assert root.attrib["height"] == "20" + + def test_export_custom_colors(self, tmp_path: Path): + """Custom colors work.""" + canvas = create_canvas(50, 50, "white") + output_path = tmp_path / "output.svg" + result = export_to_svg(canvas, output_path, bg_color="red", fg_color="blue") + + assert result.exists() + + tree = ET.parse(str(result)) + root = tree.getroot() + # Check background rect has red color + bg_rect = root.find(".//{http://www.w3.org/2000/svg}rect") + assert bg_rect is not None + assert bg_rect.attrib["fill"] == "#ff0000" + + def test_export_with_binarized_image(self, tmp_path: Path): + """Export binarized image works.""" + canvas = create_canvas(50, 50, "white") + binarized = strict_binarization(canvas) + output_path = tmp_path / "output.svg" + result = export_to_svg( + binarized, output_path, bg_color="white", fg_color="black" + ) + + assert result.exists() + + tree = ET.parse(str(result)) + root = tree.getroot() + # Should have background and foreground group + fg_group = root.find(".//{http://www.w3.org/2000/svg}g") + assert fg_group is not None + + def test_export_creates_parent_dir(self, tmp_path: Path): + """Creates non-existent parent directory.""" + canvas = create_canvas(50, 50, "white") + output_path = tmp_path / "nonexistent" / "output.svg" + result = export_to_svg(canvas, output_path) + + assert result.exists() + + +class TestSaveImageSvg: + """Tests for save_image with SVG format.""" + + def test_save_as_svg(self, tmp_path: Path): + """save_image auto-detects SVG format.""" + canvas = create_canvas(100, 50, "white") + output_path = tmp_path / "output.svg" + result = save_image(canvas, output_path) + + assert result.exists() + assert result.suffix == ".svg" + + # Verify it's valid SVG + tree = ET.parse(str(result)) + root = tree.getroot() + assert root.tag == "{http://www.w3.org/2000/svg}svg" + + def test_save_as_svg_with_pixel_size(self, tmp_path: Path): + """SVG pixel size option works.""" + canvas = create_canvas(10, 10, "white") + output_path = tmp_path / "output.svg" + result = save_image(canvas, output_path, svg_pixel_size=3) + + assert result.exists() + + tree = ET.parse(str(result)) + root = tree.getroot() + assert root.attrib["width"] == "30" + assert root.attrib["height"] == "30" + + def test_save_png_still_works(self, temp_output_path: Path): + """PNG export still works after SVG changes.""" + canvas = create_canvas(100, 50, "white") + result = save_image(canvas, temp_output_path) + + assert result.exists() + assert result.suffix == ".png" + + with Image.open(result) as img: + assert img.size == (100, 50) + + def test_save_svg_overrides_format_for_svg_ext(self, tmp_path: Path): + """Even with PNG options, .svg extension produces SVG.""" + canvas = create_canvas(100, 50, "white") + output_path = tmp_path / "output.svg" + result = save_image( + canvas, + output_path, + dpi=(300, 300), # PNG-only option, ignored for SVG + svg_pixel_size=2, + ) + + assert result.exists() + assert result.suffix == ".svg" + + tree = ET.parse(str(result)) + root = tree.getroot() + assert root.attrib["width"] == "200" + def test_black_background(self, temp_output_path: Path): """测试黑色背景""" canvas = create_canvas(200, 100, "black")