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 @@
ASCII✦Magic
diff --git a/src/asciimagic/text_to_ascii.py b/src/asciimagic/text_to_ascii.py
index 17a9b6c..3f4bf25 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)
@@ -361,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,
@@ -370,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_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
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 "")