diff --git a/src/mono_pixel/cli.py b/src/mono_pixel/cli.py index 5996d57..13793e9 100644 --- a/src/mono_pixel/cli.py +++ b/src/mono_pixel/cli.py @@ -110,6 +110,19 @@ def parse_padding(padding_str: str) -> int | tuple[int, int, int, int]: ) +def normalize_cli_text(text: str) -> str: + """Normalize direct CLI text input. + + Convert escaped newline sequences ("\\n", "\\r\\n") to actual line breaks. + Preserve escaped literal "\\n" written as "\\\\n". + """ + literal_newline_token = "\x00MONO_PIXEL_LITERAL_BACKSLASH_N\x00" + normalized = text.replace("\\\\n", literal_newline_token) + normalized = normalized.replace("\\r\\n", "\n") + normalized = normalized.replace("\\n", "\n") + return normalized.replace(literal_newline_token, "\\n") + + # preview helpers have been moved to `mono_pixel.utils.preview` @@ -498,6 +511,10 @@ def run_command( console.print(f"[red]Failed to read text file: {e}[/red]") raise typer.Exit(1) from e + # For direct CLI input, users commonly pass escaped newlines as "\n". + if text is not None and text_file is None: + text = normalize_cli_text(text) + if not text: console.print("[red]Error: text cannot be empty[/red]") raise typer.Exit(1) diff --git a/src/mono_pixel/font_loader.py b/src/mono_pixel/font_loader.py index 18c65cf..763df6b 100644 --- a/src/mono_pixel/font_loader.py +++ b/src/mono_pixel/font_loader.py @@ -3,7 +3,7 @@ from importlib import resources from pathlib import Path -from PIL import ImageFont +from PIL import Image, ImageDraw, ImageFont from .utils.exceptions import ( FontError, @@ -22,12 +22,26 @@ "get_font_metrics", "calculate_text_bbox", "calculate_text_size", + "get_multiline_spacing", "get_builtin_fonts", "get_bundled_font_path", "load_builtin_font", ] +def get_multiline_spacing(font: ImageFont.FreeTypeFont) -> int: + """Return an adaptive line spacing for multiline text rendering. + + The value is derived from font size/metrics to keep visual breathing room + between lines without introducing an overly large gap. + """ + ascent, descent = font.getmetrics() + nominal_size = getattr(font, "size", ascent + descent) + + # Keep at least 1px and scale with font size for consistent aesthetics. + return max(1, int(nominal_size * 0.18)) + + def validate_font_file(font_path: str | Path) -> Path: """Validate whether a font file is usable. @@ -130,7 +144,19 @@ def calculate_text_bbox( Returns: Bounding box as (left, top, right, bottom). """ - bbox = font.getbbox(text) + if not text: + return (0, 0, 0, 0) + + # Use Pillow multiline metrics when text contains explicit line breaks. + if "\n" in text: + spacing = get_multiline_spacing(font) + draw = ImageDraw.Draw(Image.new("L", (1, 1), 0)) + bbox = draw.multiline_textbbox( + (0, 0), text, font=font, spacing=spacing, align="left" + ) + else: + bbox = font.getbbox(text) + if bbox is None: return (0, 0, 0, 0) diff --git a/src/mono_pixel/renderer.py b/src/mono_pixel/renderer.py index 963ebb7..62dc2e0 100644 --- a/src/mono_pixel/renderer.py +++ b/src/mono_pixel/renderer.py @@ -4,7 +4,12 @@ from PIL import Image, ImageDraw, ImageFont -from .font_loader import calculate_text_bbox, calculate_text_size, load_font +from .font_loader import ( + calculate_text_bbox, + calculate_text_size, + get_multiline_spacing, + load_font, +) class HorizontalAlign(StrEnum): @@ -174,12 +179,23 @@ def render_pixel_text( """ draw = ImageDraw.Draw(canvas) - draw.text( - position, - text, - font=font, - fill=fg_color, - ) + if "\n" in text: + spacing = get_multiline_spacing(font) + draw.multiline_text( + position, + text, + font=font, + fill=fg_color, + spacing=spacing, + align="left", + ) + else: + draw.text( + position, + text, + font=font, + fill=fg_color, + ) return canvas diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..81bd857 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,21 @@ +"""CLI text normalization tests.""" + +from mono_pixel.cli import normalize_cli_text + + +def test_normalize_cli_text_converts_escaped_newline() -> None: + """Literal "\\n" in CLI input should become a real line break.""" + text = normalize_cli_text("Hello\\nWorld") + assert text == "Hello\nWorld" + + +def test_normalize_cli_text_converts_escaped_crlf() -> None: + """Literal "\\r\\n" should normalize to "\n".""" + text = normalize_cli_text("A\\r\\nB") + assert text == "A\nB" + + +def test_normalize_cli_text_preserves_double_escaped_newline() -> None: + """Double-escaped newline should remain a visible "\\n" literal.""" + text = normalize_cli_text("Show \\\\n literally") + assert text == "Show \\n literally" diff --git a/tests/test_renderer.py b/tests/test_renderer.py index 2c0b469..fb7445f 100644 --- a/tests/test_renderer.py +++ b/tests/test_renderer.py @@ -5,6 +5,7 @@ import pytest from PIL import Image +from mono_pixel.font_loader import calculate_text_size, get_multiline_spacing, load_font from mono_pixel.renderer import ( HorizontalAlign, VerticalAlign, @@ -150,14 +151,20 @@ def test_render_basic(self, test_font_path: Path, sample_text: str): def test_different_colors(self, test_font_path: Path): """Rendering supports different foreground/background colors.""" - from mono_pixel.font_loader import load_font - canvas = create_canvas(200, 100, "black") font = load_font(test_font_path, 32) result = render_pixel_text(canvas, "Test", font, (10, 10), "white") assert isinstance(result, Image.Image) + def test_render_multiline_text(self, test_font_path: Path): + """Rendering supports explicit newline characters.""" + canvas = create_canvas(300, 200, "white") + font = load_font(test_font_path, 40) + + result = render_pixel_text(canvas, "Hello\nPixel", font, (20, 20), "black") + assert isinstance(result, Image.Image) + class TestRenderText: """Full render_text workflow tests.""" @@ -207,6 +214,36 @@ def test_render_with_custom_options(self, test_font_path: Path, sample_text: str assert isinstance(result, Image.Image) assert result.size == (400, 200) + def test_render_with_newlines(self, test_font_path: Path): + """render_text supports explicit newlines in text.""" + result = render_text( + text="Line 1\nLine 2", + font_path=str(test_font_path), + image_size=(300, 200), + auto_fit=True, + ) + assert isinstance(result, Image.Image) + assert result.size == (300, 200) + + +class TestTextSize: + """Text size helpers should support multiline text.""" + + def test_multiline_height_exceeds_single_line(self, test_font_path: Path): + """Multiline text should have larger height than single-line text.""" + font = load_font(test_font_path, 32) + _, single_h = calculate_text_size("Hello Pixel", font) + _, multi_h = calculate_text_size("Hello\nPixel", font) + + assert multi_h > single_h + + def test_multiline_spacing_is_positive(self, test_font_path: Path): + """Adaptive multiline spacing should always be positive.""" + font = load_font(test_font_path, 32) + spacing = get_multiline_spacing(font) + + assert spacing > 0 + def test_invalid_parameters( self, test_font_path: Path, sample_text: str, image_size: tuple[int, int] ):