From 0e9f5ddf34d19899d554583a0f66a61f47770c18 Mon Sep 17 00:00:00 2001 From: Ian Robinson Date: Fri, 10 Jul 2026 22:30:27 -0400 Subject: [PATCH 1/2] fix(text): crisp density text - adaptive supersampling, BOX resample, S-curve MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The block/small/shadow styles rendered text at a FIXED 24px font and resized that to the target grid with default bicubic: wide targets upscaled a small blurry render, bicubic rang at edges, and anti-aliased strokes mapped through the ramp as a halo of light characters — 'I see you' at width 60 was unreadable scatter. Now: one measured re-render sizes the font so each output cell averages ~8x8 source pixels; BOX resampling gives true per-cell coverage; and a contrast S-curve (0.18..0.82 stretched to 0..1) collapses edge halos while keeping genuine midtones. Captions inherit all of it via caption_lines. Co-Authored-By: Claude Fable 5 --- src/asciimagic/text_to_ascii.py | 23 ++++++++++++++++++----- tests/test_text_to_ascii.py | 24 ++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 5 deletions(-) diff --git a/src/asciimagic/text_to_ascii.py b/src/asciimagic/text_to_ascii.py index 17a9b6c..ae287a5 100755 --- a/src/asciimagic/text_to_ascii.py +++ b/src/asciimagic/text_to_ascii.py @@ -163,12 +163,23 @@ def text_to_ascii_art( # Target character width (number of characters per line) target_w = max(1, int(width)) + # Supersample: re-render so each output cell averages ~8x8 source pixels. + # A fixed-size render UPSCALED to a wide grid is pure blur; one measured + # re-render at the right size keeps strokes crisp at every width. + desired_px = target_w * 8 + if img.width and abs(img.width - desired_px) > desired_px * 0.2: + adjusted = max(12, min(400, round(font_size * desired_px / img.width))) + if adjusted != font_size: + img = render_text_to_image(text, font_size=adjusted, font_path=font_path).convert("L") + logger.debug("Re-rendered at font_size=%d for %dpx target", adjusted, desired_px) + # Map image pixels -> characters. Characters are taller than wide, # so reduce the height when computing rows to keep aspect ratio. scale = target_w / max(1, img.width) target_h = max(1, int(img.height * scale * 0.5)) - img_small = img.resize((target_w, target_h)) + # BOX = true area-average coverage per cell (default bicubic rings at edges) + img_small = img.resize((target_w, target_h), Image.Resampling.BOX) logger.debug("Resized image to %dx%d for ascii mapping", target_w, target_h) # Choose character ramps per style @@ -186,13 +197,15 @@ def text_to_ascii_art( else: pixels = list(img_small.getdata()) lines = [] + n = len(ramp) - 1 for row in range(target_h): line_chars = [] for col in range(target_w): - val = pixels[row * target_w + col] - idx = int((val / 255) * (len(ramp) - 1)) - ch = ramp[idx] - line_chars.append(ch) + val = pixels[row * target_w + col] / 255 + # Contrast S-curve: anti-aliased letter edges otherwise become a + # halo of light ramp characters around every stroke. + val = min(1.0, max(0.0, (val - 0.18) / 0.64)) + line_chars.append(ramp[round(val * n)]) lines.append("".join(line_chars)) return "\n".join(lines) diff --git a/tests/test_text_to_ascii.py b/tests/test_text_to_ascii.py index 081a650..51d2e95 100644 --- a/tests/test_text_to_ascii.py +++ b/tests/test_text_to_ascii.py @@ -100,3 +100,27 @@ def test_all_output_modes(self, style): res = text_to_ascii_art("Test", style=style) assert len(res) > 0 + + +def test_block_render_fills_wide_targets(): + """Regression: a fixed 24px render upscaled to wide grids was pure blur; + adaptive supersampling re-renders at the size the grid needs.""" + from asciimagic.text_to_ascii import text_to_ascii_art + + art = text_to_ascii_art("See", style="block", width=90) + lines = [ln for ln in art.splitlines() if ln.strip()] + ink = max(len(ln.rstrip()) - (len(ln) - len(ln.lstrip())) for ln in lines) + assert ink >= 65 # fills most of the requested width + assert len(lines) >= 10 # tall enough to have real letterforms + + +def test_block_render_is_crisp_not_haloed(): + """Regression: anti-aliased edges mapped straight through the ramp gave + every stroke a halo of light characters; the S-curve collapses them.""" + from asciimagic.text_to_ascii import text_to_ascii_art + + art = text_to_ascii_art("H", style="block", width=40) + inked = [c for c in art if c not in " \n"] + halo = [c for c in inked if c in "+=-:."] + assert inked + assert len(halo) / len(inked) < 0.35 From 90192a9ce288a1b284cb4596abe783411ffbdf00 Mon Sep 17 00:00:00 2001 From: Ian Robinson Date: Thu, 16 Jul 2026 16:37:37 -0400 Subject: [PATCH 2/2] feat(caption): side/corner/wrap positions with rotation; center-out drag - Caption positions grow beyond top/bottom: left/right run the text rotated 90 degrees along the side (book-spine style, sized against the art height); the four corners are sugar for top/bottom with forced alignment; 'wrap' flows the text clockwise as a 1-char marquee frame around the whole piece. Works in colorized ANSI, HTML, and the plain path; image-sampled caption colors use the adjacent side strip. Animated/video sinks stack caption rows, so side/wrap fall back to bottom there with a warning. - Centered captions expand out of their middle while dragging - each pixel adds width both directions so the text stays visible under the cursor - instead of only growing rightward. - Resize rings step aside for side/wrap layouts (the measured block changes in both axes); the number knobs still apply. Co-Authored-By: Claude Fable 5 --- src/asciimagic/animate.py | 8 ++ src/asciimagic/colorize_ascii.py | 166 +++++++++++++++++++++++++++---- src/asciimagic/image_to_ascii.py | 2 +- src/asciimagic/static/app.js | 15 ++- src/asciimagic/static/index.html | 7 ++ src/asciimagic/text_to_ascii.py | 91 ++++++++++++++++- src/asciimagic/webapp.py | 45 +++++---- tests/test_caption.py | 68 +++++++++++++ tests/test_webapp.py | 25 +++++ 9 files changed, 383 insertions(+), 44 deletions(-) diff --git a/src/asciimagic/animate.py b/src/asciimagic/animate.py index aec1bda..3eff303 100644 --- a/src/asciimagic/animate.py +++ b/src/asciimagic/animate.py @@ -21,6 +21,7 @@ import random import sys import time +import dataclasses from dataclasses import dataclass from typing import List, Optional, Tuple @@ -484,6 +485,13 @@ def _resolve_caption( ) -> Optional[CaptionRender]: if not caption or not caption.text: return None + from .colorize_ascii import normalize_caption + + caption = normalize_caption(caption) + if caption.position not in ("top", "bottom"): + # Animated/video sinks stack caption ROWS; side/wrap layouts are a + # static-render feature. Fall back to bottom rather than mangling. + caption = dataclasses.replace(caption, position="bottom") from .text_to_ascii import caption_lines lines = caption_lines( diff --git a/src/asciimagic/colorize_ascii.py b/src/asciimagic/colorize_ascii.py index e63490e..e3bf933 100755 --- a/src/asciimagic/colorize_ascii.py +++ b/src/asciimagic/colorize_ascii.py @@ -5,6 +5,7 @@ import html import logging import time +import dataclasses from dataclasses import dataclass, field from typing import List, Optional, Sequence, Tuple from PIL import Image, ImageFilter @@ -101,7 +102,7 @@ class CaptionOptions: by the image (optionally tinted a uniform color).""" text: Optional[str] = None - position: str = "bottom" # "top" | "bottom" + position: str = "bottom" # top | bottom | left | right | wrap | the four corners style: str = "block" # block | small | shadow | box | banner | figlet scale: float = 0.6 # AUTO sizing: fraction of art width for rendered styles cols: Optional[int] = None # EXACT width in chars (overrides scale; free transform) @@ -241,7 +242,7 @@ def build_arg_parser() -> argparse.ArgumentParser: g = ap.add_argument_group("caption") g.add_argument("--caption", default=None, metavar="TEXT", help="Render TEXT as ASCII and stitch it onto the art") - g.add_argument("--caption-pos", choices=["top", "bottom"], default="bottom") + g.add_argument("--caption-pos", choices=["top", "bottom", "left", "right", "wrap", "top-left", "top-right", "bottom-left", "bottom-right"], default="bottom") g.add_argument("--caption-style", choices=["block", "small", "shadow", "box", "banner", "figlet"], default="block") g.add_argument("--caption-scale", type=float, default=0.6, metavar="F", @@ -794,6 +795,89 @@ def _caption_lines_html(lines: Sequence[str], cap: CaptionOptions) -> List[str]: ] +def _pad_plain(row: str, width: int) -> str: + """Pad rows that carry no ANSI/HTML markup (headers, spacers) to the art + width; colored rows are already full-width.""" + if len(row) < width and "\x1b" not in row and "<" not in row: + return row.ljust(width) + return row + + +def _side_caption_block(cap: CaptionOptions, art_h: int) -> List[str]: + """The caption rendered against the art HEIGHT budget, rotated 90° to run + along the side (CW for right — book-spine style — CCW for left).""" + from . import text_to_ascii as text_mod + + lines = text_mod.caption_lines( + cap.text, max(1, art_h), style=cap.style, scale=cap.scale, + align="center", cols=cap.cols, rows=cap.rows, + ) + block = ( + text_mod.rotate_grid_cw(lines) if cap.position == "right" + else text_mod.rotate_grid_ccw(lines) + ) + if not block: + return [] + bw = max(len(ln) for ln in block) + return [ln.ljust(bw) for ln in block] + + +def _attach_side(body: List[str], width: int, cap: CaptionOptions, img, + html_mode: bool) -> List[str]: + block = _side_caption_block(cap, len(body)) + if not block: + return body + bw = len(block[0]) + if cap.color in _CAPTION_IMAGE_COLORS: + strip = caption_ref_image(img, cap) + colored = ( + colorize_lines_html(block, strip, color_spaces=False, fill_spaces=False) + if html_mode else colorize_lines_ansi(block, strip, color_spaces=False) + ) + else: + colored = _caption_lines_html(block, cap) if html_mode else _caption_lines_ansi(block, cap) + blank = " " * bw + total = max(len(body), len(block)) + b_off = (total - len(body)) // 2 + c_off = (total - len(block)) // 2 + spacer = " " * max(0, int(cap.gap)) + out = [] + for i in range(total): + row = body[i - b_off] if 0 <= i - b_off < len(body) else " " * width + row = _pad_plain(row, width) + c = colored[i - c_off] if 0 <= i - c_off < len(block) else blank + out.append(c + spacer + row if cap.position == "left" else row + spacer + c) + return out + + +def _attach_wrap(body: List[str], width: int, cap: CaptionOptions, html_mode: bool) -> List[str]: + from . import text_to_ascii as text_mod + + gap = max(0, int(cap.gap)) + frame = text_mod.marquee_frame(cap.text, width, len(body), gap=gap) + + if cap.color and cap.color not in _CAPTION_IMAGE_COLORS: + r, g, b = parse_matrix_color(cap.color) + if html_mode: + paint = lambda s: f'{html.escape(s)}' + else: + paint = lambda s: f"{ESC}[38;2;{r};{g};{b}m{s}{ESC}[0m" + else: + paint = html.escape if html_mode else (lambda s: s) + + pad = " " * gap + out = [paint(frame[0])] + for i in range(1, len(frame) - 1): + ai = i - 1 - gap + if 0 <= ai < len(body): + mid = pad + _pad_plain(body[ai], width) + pad + else: + mid = " " * (gap + width + gap) + out.append(paint(frame[i][0]) + mid + paint(frame[i][-1])) + out.append(paint(frame[-1])) + return out + + def _with_caption(body: List[str], cap_lines: List[str], cap: CaptionOptions) -> List[str]: if not cap_lines: return body @@ -803,10 +887,34 @@ def _with_caption(body: List[str], cap_lines: List[str], cap: CaptionOptions) -> return body + spacer + cap_lines +_CORNER_POSITIONS = { + "top-left": ("top", "left"), + "top-right": ("top", "right"), + "bottom-left": ("bottom", "left"), + "bottom-right": ("bottom", "right"), +} + +CAPTION_POSITIONS = ("top", "bottom", "left", "right", "wrap") + tuple(_CORNER_POSITIONS) + + +def normalize_caption(cap: CaptionOptions) -> CaptionOptions: + """Corner positions are sugar for a top/bottom caption with forced + alignment; everything downstream sees only the five base positions.""" + if cap.position in _CORNER_POSITIONS: + pos, align = _CORNER_POSITIONS[cap.position] + return dataclasses.replace(cap, position=pos, align=align) + return cap + + def caption_image_strip(img: Image.Image, position: str) -> Image.Image: """The quarter of the picture adjacent to the caption, so image-colored captions flow from the nearest art rows instead of the whole image.""" w, h = img.size + if position in ("left", "right"): + strip_w = max(1, w // 4) + if position == "left": + return img.crop((0, 0, strip_w, h)) + return img.crop((w - strip_w, 0, w, h)) strip_h = max(1, h // 4) if position == "top": return img.crop((0, 0, w, strip_h)) @@ -847,14 +955,21 @@ def render_ansi( else: out_lines.extend(colorize_lines_ansi(art, img, color_spaces=False)) - if cap and cap_lines: - if cap.color in _CAPTION_IMAGE_COLORS: - rendered = colorize_lines_ansi( - cap_lines, caption_ref_image(img, cap), color_spaces=False - ) - else: - rendered = _caption_lines_ansi(cap_lines, cap) - out_lines = _with_caption(out_lines, rendered, cap) + if cap and cap.text: + base = normalize_caption(cap) + width = max([len(ln) for ln in art] + [len(ln) for ln in header] + [1]) + if base.position in ("left", "right"): + out_lines = _attach_side(out_lines, width, base, img, html_mode=False) + elif base.position == "wrap": + out_lines = _attach_wrap(out_lines, width, base, html_mode=False) + elif cap_lines: + if base.color in _CAPTION_IMAGE_COLORS: + rendered = colorize_lines_ansi( + cap_lines, caption_ref_image(img, base), color_spaces=False + ) + else: + rendered = _caption_lines_ansi(cap_lines, base) + out_lines = _with_caption(out_lines, rendered, base) return out_lines def render_html( @@ -883,15 +998,22 @@ def render_html( else: pre_lines.extend(colorize_lines_html(art, img, color_spaces=False, fill_spaces=html_opt.fill_spaces)) - if cap and cap_lines: - if cap.color in _CAPTION_IMAGE_COLORS: - rendered = colorize_lines_html( - cap_lines, caption_ref_image(img, cap), - color_spaces=False, fill_spaces=False, - ) - else: - rendered = _caption_lines_html(cap_lines, cap) - pre_lines = _with_caption(pre_lines, rendered, cap) + if cap and cap.text: + base = normalize_caption(cap) + width = max([len(ln) for ln in art] + [len(ln) for ln in header] + [1]) + if base.position in ("left", "right"): + pre_lines = _attach_side(pre_lines, width, base, img, html_mode=True) + elif base.position == "wrap": + pre_lines = _attach_wrap(pre_lines, width, base, html_mode=True) + elif cap_lines: + if base.color in _CAPTION_IMAGE_COLORS: + rendered = colorize_lines_html( + cap_lines, caption_ref_image(img, base), + color_spaces=False, fill_spaces=False, + ) + else: + rendered = _caption_lines_html(cap_lines, base) + pre_lines = _with_caption(pre_lines, rendered, base) return wrap_html( pre_lines, @@ -926,7 +1048,7 @@ def colorize_ascii_text( cap_lines: List[str] = [] if opt.caption.text: width = max([len(ln) for ln in scaled_art] + [len(ln) for ln in header] + [1]) - cap_lines = _build_caption_lines(opt.caption, width) + cap_lines = _build_caption_lines(normalize_caption(opt.caption), width) if opt.out_format == "html": return render_html( @@ -1023,7 +1145,7 @@ def main(): cap_lines = [] if opt.caption.text: width = max([len(ln) for ln in scaled_art] + [len(ln) for ln in header] + [1]) - cap_lines = _build_caption_lines(opt.caption, width) + cap_lines = _build_caption_lines(normalize_caption(opt.caption), width) out_lines = render_ansi( header, scaled_art, base_img, opt.color_top, opt.matrix, cap=opt.caption, cap_lines=cap_lines, @@ -1038,7 +1160,7 @@ def main(): cap_lines = [] if opt.caption.text: width = max([len(ln) for ln in scaled_art] + [len(ln) for ln in header] + [1]) - cap_lines = _build_caption_lines(opt.caption, width) + cap_lines = _build_caption_lines(normalize_caption(opt.caption), width) doc = render_html( header, scaled_art, base_img, opt.color_top, opt.html, opt.matrix, cap=opt.caption, cap_lines=cap_lines, diff --git a/src/asciimagic/image_to_ascii.py b/src/asciimagic/image_to_ascii.py index 16632c4..392cf2a 100755 --- a/src/asciimagic/image_to_ascii.py +++ b/src/asciimagic/image_to_ascii.py @@ -604,7 +604,7 @@ def build_arg_parser() -> argparse.ArgumentParser: ap.add_argument("--caption", default=None, metavar="TEXT", help="Render TEXT as ASCII and stitch it onto the art") - ap.add_argument("--caption-pos", choices=["top", "bottom"], default="bottom") + ap.add_argument("--caption-pos", choices=["top", "bottom", "left", "right", "wrap", "top-left", "top-right", "bottom-left", "bottom-right"], default="bottom") ap.add_argument("--caption-style", choices=["block", "small", "shadow", "box", "banner", "figlet"], default="block") ap.add_argument("--caption-cols", type=int, default=None, metavar="N", diff --git a/src/asciimagic/static/app.js b/src/asciimagic/static/app.js index 787038e..7b94ebf 100644 --- a/src/asciimagic/static/app.js +++ b/src/asciimagic/static/app.js @@ -454,7 +454,9 @@ function captionBox(d) { } function showRing(d) { - if (!state.result || !state.art || !state.art.cols || d.w < 4) { + if (!state.result || !state.art || !state.art.cols || d.w < 4 || state.art.ring === false) { + // ring === false: side/wrap captions reshape the block in both axes, + // so pixel->cell math steps aside (the number knobs still work). ring.hidden = true; capRing.hidden = true; return; @@ -567,7 +569,11 @@ function capStartDrag(axis) { document.getElementById("preview-wrap").classList.add("dragging"); capRing.classList.add("dragging"); const start = { x: e.clientX, y: e.clientY, w: capBox.w, h: capBox.h, + cx: capBox.x + capBox.w / 2, aspect: capBox.w / Math.max(1, capBox.h) }; + // A centered caption grows out of its middle, so the drag should too — + // expanding both directions keeps the text visible under the cursor. + const centered = $("caption_align").value === "center"; const capDims = () => { const c = cellSize(); @@ -578,7 +584,12 @@ function capStartDrag(axis) { }; const move = (ev) => { - if (axis !== "s") capBox.w = Math.max(12, start.w + (ev.clientX - start.x)); + if (axis !== "s") { + // centered captions grow symmetrically: each pixel of drag adds two + const dx = ev.clientX - start.x; + capBox.w = Math.max(12, start.w + (centered ? dx * 2 : dx)); + if (centered) capBox.x = start.cx - capBox.w / 2; + } if (axis !== "e") capBox.h = Math.max(6, start.h + (ev.clientY - start.y)); if (axis === "se" && ev.shiftKey) capBox.h = capBox.w / start.aspect; applyCapRing(); diff --git a/src/asciimagic/static/index.html b/src/asciimagic/static/index.html index df4aa38..9c551fb 100644 --- a/src/asciimagic/static/index.html +++ b/src/asciimagic/static/index.html @@ -179,6 +179,13 @@

ASCIIMagic

diff --git a/src/asciimagic/text_to_ascii.py b/src/asciimagic/text_to_ascii.py index ae287a5..3f4bf25 100755 --- a/src/asciimagic/text_to_ascii.py +++ b/src/asciimagic/text_to_ascii.py @@ -374,6 +374,52 @@ def caption_lines( return out +def rotate_grid_cw(lines: list[str]) -> list[str]: + """Rotate a character grid 90° clockwise (text reads top-to-bottom with + the head tilted right — book-spine style for right-side captions).""" + if not lines: + return [] + w = max(len(ln) for ln in lines) + g = [ln.ljust(w) for ln in lines] + h = len(g) + return ["".join(g[h - 1 - r][c] for r in range(h)) for c in range(w)] + + +def rotate_grid_ccw(lines: list[str]) -> list[str]: + """Rotate a character grid 90° counter-clockwise (left-side captions).""" + if not lines: + return [] + w = max(len(ln) for ln in lines) + g = [ln.ljust(w) for ln in lines] + h = len(g) + return ["".join(g[r][w - 1 - c] for r in range(h)) for c in range(w)] + + +def marquee_frame(text: str, inner_w: int, inner_h: int, gap: int = 1, sep: str = " · ") -> list[str]: + """A 1-char border of the text flowing clockwise around a block of + inner_w x inner_h, with `gap` columns/rows of breathing room inside. + Returns the framed grid rows with the interior left blank (spaces).""" + pad = max(0, int(gap)) + total_w = inner_w + 2 * pad + 2 + total_h = inner_h + 2 * pad + 2 + perim = 2 * total_w + 2 * (total_h - 2) + src = (text.strip() or "·") + sep + flow = (src * (perim // len(src) + 1))[:perim] + + # Walk the border clockwise from the top-left corner. + grid = [[" "] * total_w for _ in range(total_h)] + i = 0 + for x in range(total_w): + grid[0][x] = flow[i]; i += 1 + for y in range(1, total_h - 1): + grid[y][total_w - 1] = flow[i]; i += 1 + for x in range(total_w - 1, -1, -1): + grid[total_h - 1][x] = flow[i]; i += 1 + for y in range(total_h - 2, 0, -1): + grid[y][0] = flow[i]; i += 1 + return ["".join(row) for row in grid] + + def compose_caption( art: str, text: str, @@ -383,11 +429,52 @@ def compose_caption( gap: int = 1, align: str = "center", font_path: str | None = None, + cols: int | None = None, + rows: int | None = None, ) -> str: - """Stitch a rendered text caption above or below a block of ASCII art.""" + """Stitch a rendered text caption onto a block of ASCII art. + + Positions: top, bottom, left, right (text rotated 90° to run along the + side), the four corners (top-left ... bottom-right), and "wrap" (the + text flows clockwise as a 1-char marquee frame around the art).""" art_lines = art.splitlines() width = max((len(ln) for ln in art_lines), default=1) - cap = caption_lines(text, width, style=style, scale=scale, align=align, font_path=font_path) + height = len(art_lines) + + if position in ("top-left", "top-right", "bottom-left", "bottom-right"): + base, align = position.split("-") + position = base + + if position == "wrap": + frame = marquee_frame(text, width, height, gap=gap) + pad = max(0, int(gap)) + 1 + out = list(frame) + for i, ln in enumerate(art_lines): + row = out[pad + i] + out[pad + i] = row[:pad] + ln.ljust(width) + row[pad + width:] + return "\n".join(out) + + if position in ("left", "right"): + # Size against the art height (that budget becomes the vertical + # extent once rotated), then run the block down the side. + block = caption_lines(text, height, style=style, scale=scale, + align="center", font_path=font_path, cols=cols, rows=rows) + block = rotate_grid_cw(block) if position == "right" else rotate_grid_ccw(block) + bw = max((len(ln) for ln in block), default=0) + block = [ln.ljust(bw) for ln in block] + total_h = max(height, len(block)) + art_off = (total_h - height) // 2 + cap_off = (total_h - len(block)) // 2 + spacer = " " * max(0, int(gap)) + out = [] + for i in range(total_h): + a = art_lines[i - art_off].ljust(width) if 0 <= i - art_off < height else " " * width + c = block[i - cap_off] if 0 <= i - cap_off < len(block) else " " * bw + out.append(c + spacer + a if position == "left" else a + spacer + c) + return "\n".join(out) + + cap = caption_lines(text, width, style=style, scale=scale, align=align, + font_path=font_path, cols=cols, rows=rows) spacer = [""] * max(0, int(gap)) if position == "top": combined = cap + spacer + art_lines diff --git a/src/asciimagic/webapp.py b/src/asciimagic/webapp.py index d1f8cfb..20174ed 100644 --- a/src/asciimagic/webapp.py +++ b/src/asciimagic/webapp.py @@ -241,7 +241,9 @@ def _render_video(upload: Optional[UploadFile], o: dict[str, Any], t0: float) -> "frames_text": frames_text, "video": {"frames": len(v.frames), "fps": round(v.fps, 2)}, "seed": None, - "warning": None, + "warning": ("Side/wrap captions apply to static renders; the video uses bottom." + if o.get("caption_text") and o.get("caption_pos") in ("left", "right", "wrap") + else None), "elapsed_ms": round((time.perf_counter() - t0) * 1000), } @@ -310,6 +312,9 @@ def render( t0 = time.perf_counter() ctx = AsciiPipelineContext() warning: Optional[str] = None + if o.get("caption_text") and bool(o.get("animate")) and \ + o.get("caption_pos") in ("left", "right", "wrap"): + warning = "Side/wrap captions apply to static renders; the animation uses bottom." if o.get("source") == "video": return _render_video(image, o, t0) @@ -467,28 +472,34 @@ def render( "cap_gap": 0, "cap_pos": "bottom", "cap_style": None, + "ring": True, } # Caption rows share the art's rendered block; report how many so the # GUI's resize ring can exclude them. The animation player renders its # caption in a separate element, so nothing to exclude there. - cap = _build_options(o, "ansi").caption + cap = colorize_mod.normalize_caption(_build_options(o, "ansi").caption) if cap.text and not do_animate and art_dims["cols"]: - try: - from .text_to_ascii import caption_lines as _caption_lines + if cap.position in ("left", "right", "wrap"): + # Side/wrap layouts change the measured block in both axes; the + # pixel->cell math can't split them out, so the rings step aside. + art_dims["ring"] = False + else: + try: + from .text_to_ascii import caption_lines as _caption_lines - cl = _caption_lines( - cap.text, art_dims["cols"], style=cap.style, scale=cap.scale, align=cap.align, - cols=cap.cols, rows=cap.rows, - ) - art_dims["cap_lines"] = len(cl) - art_dims["cap_gap"] = max(0, int(cap.gap)) - art_dims["cap_pos"] = cap.position - art_dims["cap_style"] = cap.style - inked = [ln for ln in cl if ln.strip()] - art_dims["cap_cols"] = max((len(ln.strip()) for ln in inked), default=0) - art_dims["cap_x"] = min((len(ln) - len(ln.lstrip()) for ln in inked), default=0) - except Exception: - pass # caption metrics are best-effort; the ring just wraps everything + cl = _caption_lines( + cap.text, art_dims["cols"], style=cap.style, scale=cap.scale, align=cap.align, + cols=cap.cols, rows=cap.rows, + ) + art_dims["cap_lines"] = len(cl) + art_dims["cap_gap"] = max(0, int(cap.gap)) + art_dims["cap_pos"] = cap.position + art_dims["cap_style"] = cap.style + inked = [ln for ln in cl if ln.strip()] + art_dims["cap_cols"] = max((len(ln.strip()) for ln in inked), default=0) + art_dims["cap_x"] = min((len(ln) - len(ln.lstrip()) for ln in inked), default=0) + except Exception: + pass # caption metrics are best-effort; the ring just wraps everything return { "ascii": ascii_display, diff --git a/tests/test_caption.py b/tests/test_caption.py index 6283852..55cfd95 100644 --- a/tests/test_caption.py +++ b/tests/test_caption.py @@ -234,3 +234,71 @@ def test_caption_exact_dims_work_for_box_style(): lines = caption_lines("Hi", width=60, style="box", cols=30, rows=5) assert len(lines) == 5 assert _ink_width(lines) in (29, 30, 31) + + +def test_rotate_grid_helpers(): + from asciimagic.text_to_ascii import rotate_grid_ccw, rotate_grid_cw + + g = ["ab", "cd", "ef"] # 3 rows x 2 cols + cw = rotate_grid_cw(g) + assert cw == ["eca", "fdb"] # 2 rows x 3 cols, reads down on the right + ccw = rotate_grid_ccw(g) + assert ccw == ["bdf", "ace"] + # rotating one way then the other restores the grid + assert rotate_grid_ccw(cw) == g + + +def test_marquee_frame_geometry(): + from asciimagic.text_to_ascii import marquee_frame + + f = marquee_frame("HI", inner_w=6, inner_h=3, gap=1) + assert len(f) == 3 + 2 + 2 # inner + gap rows + border rows + assert all(len(ln) == 6 + 2 + 2 for ln in f) + assert "H" in f[0] and "I" in f[0] # text flows through the border + # interior is blank for the art to sit in + assert f[2][2:8].strip() == "" + + +def test_compose_caption_sides_and_wrap(): + right = compose_caption(ART, "Hi", position="right", style="box", gap=1) + rl = right.splitlines() + assert all(len(ln) == len(rl[0]) for ln in rl) + assert len(rl[0]) > 40 # art width + gap + rotated caption columns + assert rl[0][:1] != "│" # caption is on the right, not the left + + left = compose_caption(ART, "Hi", position="left", style="box", gap=1) + assert left.splitlines()[0] != rl[0] + + wrap = compose_caption(ART, "Hi", position="wrap", gap=1) + wl = wrap.splitlines() + assert len(wl) == 10 + 4 + assert all(len(ln) == 40 + 4 for ln in wl) + assert "#" * 40 in wl[2] # art intact inside the frame + + +def test_compose_caption_corner_positions(): + tl = compose_caption(ART, "Hi", position="top-left", style="box") + tr = compose_caption(ART, "Hi", position="top-right", style="box") + assert tl.splitlines()[0].startswith("┌") + assert tr.splitlines()[0].endswith("┐") + + +def test_colorized_side_caption_ansi(): + opt = Options(out_format="ansi") + opt.caption = CaptionOptions(text="Hi", style="box", position="right", color="amber") + out = colorize_ascii_text(_img(), ART, opt=opt) + plain = STRIP(out) + lines = plain.splitlines() + assert len(lines) >= 10 + # art rows still colorized, caption columns appended + assert "\x1b[38;2;255;176;0m" in out # amber caption cells + assert max(len(ln) for ln in lines) > 40 # wider than the art alone + + +def test_colorized_wrap_caption_ansi(): + opt = Options(out_format="ansi") + opt.caption = CaptionOptions(text="Whiskers", position="wrap", gap=1) + out = colorize_ascii_text(_img(), ART, opt=opt) + lines = STRIP(out).splitlines() + assert len(lines) == 10 + 4 + assert "W" in lines[0] # marquee text in the border diff --git a/tests/test_webapp.py b/tests/test_webapp.py index 8644990..112a5a1 100644 --- a/tests/test_webapp.py +++ b/tests/test_webapp.py @@ -462,3 +462,28 @@ def test_render_bad_options_400(): def test_render_invalid_mode_400(): r = _render({"source": "image", "mode": "nope"}) assert r.status_code == 400 + + +def test_side_and_wrap_captions_disable_ring(): + for pos in ("left", "right", "wrap"): + r = _render( + {"source": "image", "mode": "braille", "cols": 20, + "caption_text": "Hi", "caption_pos": pos} + ) + assert r.status_code == 200 + assert r.json()["art"]["ring"] is False + r2 = _render({"source": "image", "mode": "braille", "cols": 20, + "caption_text": "Hi", "caption_pos": "bottom-right"}) + assert r2.status_code == 200 + art = r2.json()["art"] + assert art["ring"] is True + assert art["cap_pos"] == "bottom" # corner normalized + + +def test_animate_side_caption_warns_and_coerces(): + r = _render( + {"source": "image", "cols": 12, "matrix": True, "animate": True, + "anim_frames": 2, "caption_text": "Hi", "caption_pos": "left"} + ) + assert r.status_code == 200 + assert "static renders" in (r.json()["warning"] or "")