Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 21 additions & 4 deletions docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down Expand Up @@ -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).
Expand Down Expand Up @@ -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,
)
```

3 changes: 2 additions & 1 deletion src/mono_pixel/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -59,6 +59,7 @@
"render_text",
# Exporter
"strict_binarization",
"export_to_svg",
"save_image",
# High-level API
"generate_pixel_text",
Expand Down
161 changes: 158 additions & 3 deletions src/mono_pixel/exporter.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Image export and binarization utilities."""

import xml.etree.ElementTree as ET
from pathlib import Path

from PIL import Image
Expand Down Expand Up @@ -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.

Expand All @@ -155,17 +157,170 @@ 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
)
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
Comment on lines +236 to +238
Copy link

Copilot AI Mar 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pixel_size is used as a multiplier/divisor for the SVG dimensions and rect sizes, but there’s no validation that it’s a positive integer. Passing 0 or a negative value will produce an invalid SVG (zero/negative width/height) or silently generate degenerate rects. Add input validation (e.g., pixel_size >= 1) and raise a clear ValueError/ExportError if invalid.

Copilot uses AI. Check for mistakes.

# 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),
},
)
Comment on lines +269 to +299
Copy link

Copilot AI Mar 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The per-pixel getpixel calls inside nested Python loops will be very slow for larger images and will generate extremely large SVGs (one <rect> per foreground pixel). Consider using image.load()/bulk pixel access and emitting fewer elements (e.g., run-length encode rows into wider rects, or generate a <path>). This will significantly improve export speed and output size for typical large canvases.

Suggested change
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),
},
)
pixels = image.load()
for y in range(height):
run_start = None
for x in range(width):
pixel = pixels[x, y]
# For 1-bit images, 0 is foreground (black)
is_fg = pixel == 0
if is_fg:
if run_start is None:
run_start = x
else:
if run_start is not None:
run_width = x - run_start
ET.SubElement(
fg_group,
"rect",
{
"x": str(run_start * pixel_size),
"y": str(y * pixel_size),
"width": str(run_width * pixel_size),
"height": str(pixel_size),
},
)
run_start = None
# Flush any run that reaches the end of the row
if run_start is not None:
run_width = width - run_start
ET.SubElement(
fg_group,
"rect",
{
"x": str(run_start * pixel_size),
"y": str(y * pixel_size),
"width": str(run_width * pixel_size),
"height": str(pixel_size),
},
)
elif image.mode == "L":
pixels = image.load()
for y in range(height):
run_start = None
for x in range(width):
pixel = pixels[x, y]
# For grayscale, treat dark pixels as foreground
is_fg = isinstance(pixel, int) and pixel < 128
if is_fg:
if run_start is None:
run_start = x
else:
if run_start is not None:
run_width = x - run_start
ET.SubElement(
fg_group,
"rect",
{
"x": str(run_start * pixel_size),
"y": str(y * pixel_size),
"width": str(run_width * pixel_size),
"height": str(pixel_size),
},
)
run_start = None
# Flush any run that reaches the end of the row
if run_start is not None:
run_width = width - run_start
ET.SubElement(
fg_group,
"rect",
{
"x": str(run_start * pixel_size),
"y": str(y * pixel_size),
"width": str(run_width * pixel_size),
"height": str(pixel_size),
},
)

Copilot uses AI. Check for mistakes.
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):
Comment on lines +301 to +307
Copy link

Copilot AI Mar 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

export_to_svg treats any non-white RGB pixel as foreground (pixel[:3] != (255, 255, 255)), which breaks SVG output whenever the background color is not white (e.g., bg_color="black" or when save_image(..., strict_binarize=True, bg_color!=white) produces a two-color RGB image). This can result in every background pixel being emitted as a foreground rect and the SVG rendering incorrectly. Compare pixels against the computed background color (derived from bg_color / the processed image) rather than hard-coding white, and consider handling RGBA alpha consistently.

Suggested change
# 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):
# RGB, RGBA, or other multi-channel modes
# Derive a background color from the image (top-left pixel) to avoid
# assuming a white background. This handles cases where the background
# is not white (e.g., custom bg_color in preprocessing).
bg_pixel = image.getpixel((0, 0))
if isinstance(bg_pixel, tuple) and len(bg_pixel) >= 3:
bg_rgb = bg_pixel[:3]
else:
# Fallback to white if we cannot infer a background color
bg_rgb = (255, 255, 255)
for y in range(height):
for x in range(width):
pixel = image.getpixel((x, y))
if isinstance(pixel, tuple) and len(pixel) >= 3:
# Skip fully transparent pixels (treat as background)
if len(pixel) >= 4 and pixel[3] == 0:
continue
# Treat pixels that differ from the inferred background color as foreground
if pixel[:3] != bg_rgb:

Copilot uses AI. Check for mistakes.
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
Loading
Loading