From 53134cee3c106b0b84856143eea9b6187fd16b68 Mon Sep 17 00:00:00 2001 From: testycool Date: Sat, 10 Oct 2026 02:40:32 +0300 Subject: [PATCH 1/3] Let other packages supply a mark look without changing AInotate People want marks that look different from AInotate's bold default, for example a calm hand-drawn style. Building each style into AInotate would add fonts and drawing code to every install. A plug-in point keeps AInotate small: a look lives in its own package and is used only where installed. render() now draws every mark through a Look object. The built-in look, the only one shipped, is the class `ainotate.render.Look`, which holds exactly the drawing calls render() made before (colors, label font and size, labels and text notes, outlines, step badges, arrows, highlights, the spotlight dim and corner, the redaction fill, the keycap font, the magnifier line width and the drop shadow). A package registers a subclass under the entry point group "ainotate.looks" and overrides what it changes. A look can change a redaction's color but never its area. The look is chosen by spec "look", then the env AINOTATE_LOOK, then `look` in config.toml (the same order as the output folder), then "default". `annotate` and `shoot` take --look, and the MCP annotate, preview and shoot tools take a `look` argument. An unknown name is a spec error (exit 2) that says where it came from and lists the installed looks. The default look is pixel-identical: a test pins the sha256 of a render of every mark type, light and dark, from before this change (Pillow 12.3.0 / FreeType 2.14.3; other versions skip the pin). Other tests register a fake look, check that it draws the marks and is picked by the flag, the env and the config file, and check the unknown-look error. Co-Authored-By: Claude Opus 5.5 --- skills/ainotate/references/spec.md | 7 +- src/ainotate/cli.py | 3 + src/ainotate/marks.py | 10 ++- src/ainotate/mcp_server.py | 18 +++-- src/ainotate/output.py | 15 ++++ src/ainotate/render.py | 120 +++++++++++++++++++++-------- src/ainotate/spec.py | 18 +++++ tests/conftest.py | 3 +- tests/test_looks.py | 109 ++++++++++++++++++++++++++ 9 files changed, 257 insertions(+), 46 deletions(-) create mode 100644 tests/test_looks.py diff --git a/skills/ainotate/references/spec.md b/skills/ainotate/references/spec.md index 4aaf10c..4347585 100644 --- a/skills/ainotate/references/spec.md +++ b/skills/ainotate/references/spec.md @@ -16,6 +16,7 @@ Everything the renderer accepts. `SKILL.md` has the workflow; this file is for l | `crop_pad` | margin around the marks for `"auto"` and `"tight"` (default 260 image px at 2000px width) | | `dim` | spotlight darkness 0-1 (default 0.55) | | `arrow_style` | `"skitch"` (default, tapered), `"straight"`, `"curved"`, or `"line"` | +| `look` | `"default"`, or a look from an installed package (entry point group `ainotate.looks`, a subclass of `ainotate.render.Look`); also `--look` and the MCP `look` argument | | `frame` | backdrop and window chrome, see below | | `privacy` | `"off"` (default for images), `"auto"` (OCR the image, redact findings) or an object | @@ -149,9 +150,9 @@ explains it below, and can use `**bold**`, `` `code` `` and lines starting with Final images: `/ .png`, plus an identical copy in `backup_dir` if set. Never overwrites (adds " (2)"). Configuration, highest first: -env `AINOTATE_OUTPUT_DIR`, `AINOTATE_BACKUP_DIR`, `AINOTATE_PREFIX`; then -`~/.config/ainotate/config.toml` keys `output_dir`, `backup_dir`, `prefix`; then defaults -`~/Pictures/AInotate`, no backup, prefix `AInotate`. +env `AINOTATE_OUTPUT_DIR`, `AINOTATE_BACKUP_DIR`, `AINOTATE_PREFIX`, `AINOTATE_LOOK`; then +`~/.config/ainotate/config.toml` keys `output_dir`, `backup_dir`, `prefix`, `look`; then defaults +`~/Pictures/AInotate`, no backup, prefix `AInotate`, look `default`. A spec's `look` beats both. ## More patterns diff --git a/src/ainotate/cli.py b/src/ainotate/cli.py index 11779bc..2229104 100644 --- a/src/ainotate/cli.py +++ b/src/ainotate/cli.py @@ -138,6 +138,7 @@ def _result_args(privacy_default): return [A("spec", help="spec.json, inline JSON, or - for stdin"), A("--draft", "--no-save", dest="draft", action="store_true", help="write to a temp dir"), A("--privacy", choices=("auto", "off"), help=f"solid redaction (default: {privacy_default})"), + A("--look", help="mark look: default or an installed one (default: AINOTATE_LOOK, config.toml)"), A("--copy", action="store_true", help="also put the image on the clipboard"), A("--json", action="store_true", help="print JSON status on stdout"), A("--debug", action="store_true", help="draw target/label boxes; implies --draft")] @@ -147,6 +148,8 @@ def _spec_with_flags(a): spec = _load_spec(a.spec) if a.privacy and isinstance(spec, dict): spec["privacy"] = a.privacy + if a.look and isinstance(spec, dict): + spec["look"] = a.look return spec diff --git a/src/ainotate/marks.py b/src/ainotate/marks.py index eb1098a..5f2c257 100644 --- a/src/ainotate/marks.py +++ b/src/ainotate/marks.py @@ -92,12 +92,14 @@ def _shape(d, box, shape, radius, **kw): d.rounded_rectangle(box, radius=radius, **kw) -def draw_loupe(layer, page, sample, box, shape, color, u, path=None): +def draw_loupe(layer, page, sample, box, shape, color, u, path=None, line=None): """Source outline, connector and the loupe itself (zoomed page pixels, white rim, color ring). `page` is the image before any mark is drawn (redactions and blur already applied). `path`: a - curved connector from the loupe to the sample (routed around page content), else straight.""" + curved connector from the loupe to the sample (routed around page content), else straight. + `line`: width of the connector, source outline and color ring (default from u).""" lw, lh = int(box[2] - box[0]), int(box[3] - box[1]) - line = max(2, round(0.13 * u)) + ring = line + line = line or max(2, round(0.13 * u)) rad_src = min(0.6 * u, (sample[3] - sample[1]) / 2) rad_lp = min(1.3 * u, min(lw, lh) / 4) # connector between the facing edges (drawn first: the loupe and outline cover its ends) @@ -136,7 +138,7 @@ def draw_loupe(layer, page, sample, box, shape, color, u, path=None): mask = _aa_mask((lw, lh), lambda d, S: _shape(d, [0, 0, lw * S - 1, lh * S - 1], shape, rad_lp * S, fill=255)) content.putalpha(mask) layer.alpha_composite(content, dest=(int(box[0]), int(box[1]))) - rim, ring = max(3, round(0.3 * u)), max(2, round(0.1 * u)) + rim, ring = max(3, round(0.3 * u)), ring or max(2, round(0.1 * u)) inner = grow(box, -rim / 2) aa_draw(layer, box, "white", lambda d, T, S: _shape(d, T(inner), shape, (rad_lp - rim / 2) * S, outline="white", width=rim * S)) diff --git a/src/ainotate/mcp_server.py b/src/ainotate/mcp_server.py index f221885..a52c3d6 100644 --- a/src/ainotate/mcp_server.py +++ b/src/ainotate/mcp_server.py @@ -94,6 +94,11 @@ def _spec(spec) -> dict: return spec +def _with_look(spec, look): + spec = _spec(spec) + return dict(spec, look=look) if look and isinstance(spec, dict) else spec + + # ---------------------------------------------------------------- annotate def _annotate(spec, draft: bool, debug: bool, tool: str) -> types.CallToolResult: @@ -120,22 +125,23 @@ def _annotate(spec, draft: bool, debug: bool, tool: str) -> types.CallToolResult @mcp.tool() -async def annotate(spec: Union[dict, str], draft: bool = True) -> types.CallToolResult: +async def annotate(spec: Union[dict, str], draft: bool = True, look: Optional[str] = None) -> types.CallToolResult: """Render an annotation spec onto its `input` image. Draft by default (temp dir): check the returned preview, then call again with draft=false to save to the output folder. spec: {"input": path, "scale": 1|2.., "crop": "auto"|[x1,y1,x2,y2], "name": "...", "marks": [{"type": "step"|"box"|"arrow"|"highlight"|"spotlight"|"redact"|"text"|..., "rect": {x,y,w,h} or [x1,y1,x2,y2], "label": "...", "n": 1, "color": "look|bad|good|info"}]}. Every coordinate in one unit; scale converts it to image px. Full reference: ainotate://spec. + look: sets the spec's "look" (default, or a look from an installed package). Returns paths, size, warnings, legend (step numbers + labels), redactions and a preview.""" - return await _run("annotate", lambda: _annotate(spec, draft, False, "annotate")) + return await _run("annotate", lambda: _annotate(_with_look(spec, look), draft, False, "annotate")) @mcp.tool() -async def preview(spec: Union[dict, str]) -> types.CallToolResult: +async def preview(spec: Union[dict, str], look: Optional[str] = None) -> types.CallToolResult: """Draft render with the debug overlay (cyan target rects, magenta label boxes); saves nothing permanent. Use it to check placement before annotate.""" - return await _run("preview", lambda: _annotate(spec, True, True, "preview")) + return await _run("preview", lambda: _annotate(_with_look(spec, look), True, True, "preview")) # ---------------------------------------------------------------- web @@ -180,7 +186,7 @@ def work(): @mcp.tool() -async def shoot(spec: Union[dict, str], draft: bool = True) -> types.CallToolResult: +async def shoot(spec: Union[dict, str], draft: bool = True, look: Optional[str] = None) -> types.CallToolResult: """One call from URL to annotated image: spec = render spec without input/scale, plus url (or cdp_url + page_url_contains), preset, actions, and marks with "target": {...} (same forms as capture_web) instead of "rect". Draft by default: check the preview, then @@ -196,7 +202,7 @@ def work(): "fix": "Use capture_web(targets=...) and then annotate(spec) with the returned " "rects and scale."}}, error=True) raise - res = _shoot(_spec(spec), draft=draft) + res = _shoot(_with_look(spec, look), draft=draft) paths = [str(p) for p in res.paths] jpeg, (w, h) = preview_jpeg(paths[0]) cap = getattr(res, "capture", None) diff --git a/src/ainotate/output.py b/src/ainotate/output.py index 5270d20..c0421f8 100644 --- a/src/ainotate/output.py +++ b/src/ainotate/output.py @@ -60,6 +60,21 @@ def pick(env, key, default): return Config(Path(out).expanduser(), Path(bak).expanduser() if bak else None, prefix) +def default_look() -> str: + """The look for a spec without "look": env AINOTATE_LOOK > config.toml `look` > "default".""" + from .spec import SpecError, check_look + look = os.environ.get("AINOTATE_LOOK") or None + where = "AINOTATE_LOOK" + if look is None: + try: + look = _read_config_file(CONFIG_PATH).get("look") or "default" + except OutputError as e: + raise SpecError(str(e)) + where = f"`look` in {CONFIG_PATH}" + check_look(look, where) + return look + + def safe_component(value: str, what: str = "name") -> str: """`value` if it is one plain file-name component: no path separators, no '..', no control characters, not '.'; else OutputError (a bad prefix must not write outside the folder).""" diff --git a/src/ainotate/render.py b/src/ainotate/render.py index 00fda57..ce657cf 100644 --- a/src/ainotate/render.py +++ b/src/ainotate/render.py @@ -36,6 +36,7 @@ import math from dataclasses import dataclass, field +from types import SimpleNamespace from PIL import Image, ImageDraw, ImageFilter, ImageStat @@ -44,7 +45,7 @@ overlap_warnings, route_arrow) from .label_plan import Job, plan_labels from .placement import RenderError, ContentMap, auto_crop, place_badge, place_label, place_loupe # noqa: F401 -from .spec import DECORATIVE, LABELED, open_image, spec_warnings, validate +from .spec import DECORATIVE, LABELED, installed_looks, open_image, spec_warnings, validate from .style import LABEL_FILL, SHADOW, color_of, font_for, load_font, mark_unit, pill_size, rgba, text_color_on __all__ = ["RenderError", "RenderResult", "render"] @@ -110,6 +111,66 @@ def draw_arrow_skitch(layer, gray, p1, p2, color, u, path=None): layer.alpha_composite(hi.resize((x1 - x0, y1 - y0), Image.LANCZOS), dest=(x0, y0)) +# ---------- looks ---------- + +class Look: + """The built-in look, and the base class for looks from other packages: entry point group + "ainotate.looks", name -> a Look subclass, picked by spec "look" (then AINOTATE_LOOK, then + `look` in config.toml). render() draws every mark through these methods. `ctx` holds the + unmarked `page`, `marks`, `rects` and `points` (image px, after the crop), the crop's `offset` + (image px), `scale` and `u`.""" + shadow = SHADOW # drop shadow under all marks: opacity, y-offset, blur (x u) + dim = 0.55 # spotlight darkness when the spec sets no "dim" + loupe_line = None # magnifier line width (None: the built-in width) + + def __init__(self, ctx): + self.ctx, u = ctx, ctx.u + self.stroke = max(3, round(u / 4)) + self.spot_radius = int(u) + self.font = load_font(int(1.35 * u)) + self.badge_font = load_font(int(1.4 * u)) + + def color(self, i): + return color_of(self.ctx.marks[i].get("color")) + + def label_size(self, text): + return pill_size(self.font, text, self.ctx.u) + + def label(self, layer, img, i, box, text): # labels and text notes; img: the page with tints + draw_pill(layer, box, text, self.font, self.color(i), self.ctx.u) + + def outline(self, layer, i, g): + draw_outline(layer, g, self.color(i), self.stroke, int(0.6 * self.ctx.u)) + + def badge(self, layer, img, i, b, n): + draw_badge(layer, b, self.color(i), n, self.badge_font, max(2, round(0.12 * self.ctx.u))) + + def arrow(self, layer, gray, i, path, style, badge): # badge: the mark's own step badge or None + if style == "line": + draw_arrow(ImageDraw.Draw(layer), path[0], path[-1], self.color(i), self.stroke, self.ctx.u) + else: + draw_arrow_skitch(layer, gray, path[0], path[-1], self.color(i), self.ctx.u, path) + + def highlight(self, over, img, i, r): # lighter tint on dark regions (70 reads as a solid block there) + dark = ImageStat.Stat(img.convert("L").crop([int(v) for v in r])).mean[0] < 90 + ImageDraw.Draw(over).rectangle(r, fill=rgba(self.color(i), 45 if dark else 70)) + + def redact_fill(self, i): # a look may change the color of a redaction, never its area + return "#1F1F1F" + + def keys_font(self, i, keys): + return font_for(keys, int(1.15 * self.ctx.u)) + + +def _look(spec): + if "look" in spec: + name = spec["look"] + else: + from .output import default_look + name = default_look() + return Look if name == "default" else installed_looks()[name].load() + + # ---------- render ---------- def _su(vals, scale): @@ -197,9 +258,9 @@ def render(spec: dict, debug: bool = False) -> RenderResult: W, H = img.size u = mark_unit(W, H, scale) style = spec.get("arrow_style", "skitch") - stroke = max(3, round(u / 4)) - font = load_font(int(1.35 * u)) - badge_font = load_font(int(1.4 * u)) + lk = _look(spec)(SimpleNamespace(page=page, marks=marks, rects=rects, points=points, + offset=(ox, oy), scale=scale, u=u)) + stroke = lk.stroke base_pad = 0.7 * u def pad_for(m, r): @@ -216,26 +277,24 @@ def pad_for(m, r): key, default = ("radius", 0.5 * u) if m["type"] == "blur" else ("block", 0.9 * u) img = deemphasize(img, r, m["type"], m[key] * scale if key in m else default, u) over = Image.new("RGBA", img.size, (0, 0, 0, 0)) - od = ImageDraw.Draw(over) spots = [g for m, g in zip(marks, outlines) if m["type"] == "spotlight"] if spots: - dim = Image.new("RGBA", img.size, (0, 0, 0, int(255 * spec.get("dim", 0.55)))) + dim = Image.new("RGBA", img.size, (0, 0, 0, int(255 * spec.get("dim", lk.dim)))) mask = Image.new("L", img.size, 255) md = ImageDraw.Draw(mask) for g in spots: - md.rounded_rectangle(g, radius=int(u), fill=0) + md.rounded_rectangle(g, radius=lk.spot_radius, fill=0) over.paste(dim, (0, 0), mask) - for m, r in zip(marks, rects): - if m["type"] == "highlight": # lighter tint on dark regions (70 reads as a solid block there) - dark = ImageStat.Stat(img.convert("L").crop([int(v) for v in r])).mean[0] < 90 - od.rectangle(r, fill=rgba(color_of(m.get("color")), 45 if dark else 70)) + for i, (m, r) in enumerate(zip(marks, rects)): + if m["type"] == "highlight": + lk.highlight(over, img, i, r) img = Image.alpha_composite(img, over) d = ImageDraw.Draw(img) - for m, r in zip(marks, rects): + for i, (m, r) in enumerate(zip(marks, rects)): if m["type"] == "redact": # solid, never blur: blur can be reversed out = m["pad"] * scale if "pad" in m else max(2, 2 * scale) # default outset: safer - d.rectangle(grow(r, out), fill=rgba(color_of(m.get("fill", "#1F1F1F")), 255)) + d.rectangle(grow(r, out), fill=rgba(color_of(m.get("fill", lk.redact_fill(i))), 255)) # 2) shapes; collect obstacles so labels avoid them # placement reads page content (edges = text, lines, icons) before any mark covers it @@ -247,10 +306,10 @@ def pad_for(m, r): d = ImageDraw.Draw(layer) obstacles, placed, dbg, badges = [], [], [], [] targets = [r for r, a in zip(rects, auto) if r and not a] # highlight/spotlight/redact/arrow too - for m, r, g in zip(marks, rects, outlines): + for i, (m, r, g) in enumerate(zip(marks, rects, outlines)): if m["type"] in ("box", "step"): if m.get("box", True): - draw_outline(layer, g, color_of(m.get("color")), stroke, int(0.6 * u)) + lk.outline(layer, i, g) obstacles.append(g) # forced label positions are known up front: reserve them so no badge or auto label lands there @@ -258,7 +317,7 @@ def pad_for(m, r): for i, m in enumerate(marks): if m["type"] in LABELED and m.get("label_at"): lx, ly = [v * scale for v in m["label_at"]] - pw, ph = pill_size(font, m["label"], u) + pw, ph = lk.label_size(m["label"]) forced[i] = clamp_box([lx - ox, ly - oy, lx - ox + pw, ly - oy + ph], W, H) if forced[i]: placed.append(forced[i]) @@ -266,11 +325,11 @@ def pad_for(m, r): # click / keys-at / magnify have fixed or early positions: lay them out before labels and badges own, hard, labels, arrows = {}, [], [], [] # own: box a mark's label and overlap checks refer to leaders = [] # loupe connectors (labels avoid them like arrows) - key_fonts = {i: font_for("".join(m["keys"]), int(1.15 * u)) for i, m in enumerate(marks) if m["type"] == "keys"} + key_fonts = {i: lk.keys_font(i, "".join(m["keys"])) for i, m in enumerate(marks) if m["type"] == "keys"} for i, (m, r) in enumerate(zip(marks, rects)): if m["type"] == "click": x, y = points.get(i) or ((r[0] + r[2]) / 2, (r[1] + r[3]) / 2) - draw_click(layer, x, y, color_of(m.get("color")), u) + draw_click(layer, x, y, lk.color(i), u) own[i] = click_geometry(x, y, u)[2] obstacles.append(own[i]) hard.append(own[i]) @@ -289,7 +348,7 @@ def pad_for(m, r): avoid = [o for o in obstacles + targets if o != r] box, sample, path = place_loupe(i, m, r, points.get(i), W, H, u, cmap, avoid, [o for o in targets + hard if o != r], placed, warnings, leaders) - draw_loupe(layer, img, sample, box, m.get("shape", "circle"), color_of(m.get("color")), u, path) + draw_loupe(layer, img, sample, box, m.get("shape", "circle"), lk.color(i), u, path, lk.loupe_line) if path: leaders.append(path) own[i] = box @@ -309,7 +368,7 @@ def pad_for(m, r): if not clean: warnings.append(f"mark #{i}: badge overlaps the target (target touches the frame edge); " "widen the crop") - badges.append((b, color_of(m.get("color")), str(m["n"]))) # drawn last, above arrows and labels + badges.append((b, i, str(m["n"]))) # drawn last, above arrows and labels badge_of[i] = b placed.append(b) @@ -319,7 +378,7 @@ def pad_for(m, r): if m["type"] in LABELED and m.get("label") and not m.get("label_at"): target = own.get(i) or (g if m["type"] != "arrow" else r) near = 5 if m["type"] == "arrow" else (3.5 if m.get("arrow", True) else 2) - jobs.append(Job(i, target, pill_size(font, m["label"], u), + jobs.append(Job(i, target, lk.label_size(m["label"]), [o for o in obstacles + targets if o != target and o != r], [o for o in targets + hard if o != target and o != r], near, dict(arrow=m.get("arrow", True), curve=style in ("skitch", "curved"), style=style, @@ -334,7 +393,7 @@ def pad_for(m, r): "shorten the label or set label_at") for i, (m, r, g) in enumerate(zip(marks, rects, outlines)): - t, c = m["type"], color_of(m.get("color")) + t = m["type"] if t in LABELED and m.get("label"): target = own.get(i) or (g if t != "arrow" else r) lb = forced.get(i) @@ -351,11 +410,8 @@ def pad_for(m, r): path = route_arrow(edges, lb, target, u, avoid, style, stroke, [a[3] for a in arrows] + leaders) if path_length(path) > 2.8 * u: # room for a body behind the head arrows.append((i, path[0], path[-1], path)) - if style == "line": - draw_arrow(d, path[0], path[-1], c, stroke, u) - else: - draw_arrow_skitch(layer, gray, path[0], path[-1], c, u, path) - draw_pill(layer, lb, m["label"], font, c, u) + lk.arrow(layer, gray, i, path, style, badge_of.get(i)) + lk.label(layer, img, i, lb, m["label"]) dbg.append(lb) labels.append((i, lb)) if t == "keys" and i not in points: # beside its rect, like a label without an arrow @@ -373,21 +429,21 @@ def pad_for(m, r): labels.append((i, kb)) if t == "text": x, y = [v * scale for v in m["at"]] - pw, ph = pill_size(font, m["text"], u) + pw, ph = lk.label_size(m["text"]) tb = clamp_box([x - ox, y - oy, x - ox + pw, y - oy + ph], W, H) if tb is None: raise RenderError(f"mark #{i} (text): note {m['text']!r} is larger than the frame; widen the " "crop or shorten it") - draw_pill(layer, tb, m["text"], font, c, u) + lk.label(layer, img, i, tb, m["text"]) placed.append(tb) dbg.append(tb) labels.append((i, tb)) - for b, c, n in badges: - draw_badge(layer, b, c, n, badge_font, max(2, round(0.12 * u))) + for b, i, n in badges: + lk.badge(layer, img, i, b, n) # Skitch-style soft drop shadow under all marks, then the marks themselves - opacity, dy, blur = SHADOW + opacity, dy, blur = lk.shadow alpha = layer.getchannel("A").filter(ImageFilter.GaussianBlur(blur * u)) shadow = Image.new("RGBA", img.size, (0, 0, 0, 0)) shadow.paste(Image.new("RGBA", img.size, (0, 0, 0, 255)), (0, round(dy * u)), diff --git a/src/ainotate/spec.py b/src/ainotate/spec.py index 83eae4e..3108eb8 100644 --- a/src/ainotate/spec.py +++ b/src/ainotate/spec.py @@ -80,6 +80,19 @@ def check_point(p, where, errs): errs.append(f"{where}: must be [x, y]") +def installed_looks() -> dict: + """Looks from other packages: entry point group "ainotate.looks", name -> entry point.""" + from importlib.metadata import entry_points + return {e.name: e for e in entry_points(group="ainotate.looks")} + + +def check_look(look, where="spec: 'look'"): + """SpecError naming the installed looks unless `look` is "default" or one of them.""" + if look != "default" and (not isinstance(look, str) or look not in installed_looks()): + raise SpecError(f"{where} is {look!r}, not an installed look; installed looks: " + + ", ".join(["default", *sorted(installed_looks())])) + + def validate(spec): """Return the marks of a valid spec; raise SpecError with every problem otherwise.""" errs = [] @@ -100,6 +113,11 @@ def validate(spec): errs.append("spec: 'crop_pad' must be a number >= 0") if spec.get("arrow_style", "skitch") not in ("skitch", "straight", "curved", "line"): errs.append("spec: 'arrow_style' must be skitch, straight, curved or line") + if "look" in spec: + try: + check_look(spec["look"]) + except SpecError as e: + errs.append(str(e)) if "name" in spec and not isinstance(spec["name"], str): errs.append("spec: 'name' must be a string") if "marks" not in spec: diff --git a/tests/conftest.py b/tests/conftest.py index 3315cc6..6802f69 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -43,7 +43,8 @@ def isolated_env(tmp_path, monkeypatch): home.mkdir() monkeypatch.setenv("HOME", str(home)) monkeypatch.setenv("USERPROFILE", str(home)) - for v in ("AINOTATE_OUTPUT_DIR", "AINOTATE_BACKUP_DIR", "AINOTATE_PREFIX", "AINOTATE_FONT", "AINOTATE_NEO_DIR"): + for v in ("AINOTATE_OUTPUT_DIR", "AINOTATE_BACKUP_DIR", "AINOTATE_PREFIX", "AINOTATE_FONT", "AINOTATE_NEO_DIR", + "AINOTATE_LOOK"): monkeypatch.delenv(v, raising=False) return home diff --git a/tests/test_looks.py b/tests/test_looks.py new file mode 100644 index 0000000..4d698c2 --- /dev/null +++ b/tests/test_looks.py @@ -0,0 +1,109 @@ +import hashlib +import importlib.metadata +import json + +import PIL +import pytest +from PIL import Image, features + +from ainotate import cli +from ainotate.render import Look, render +from ainotate.spec import SpecError + +EVERY = [ + {"type": "step", "n": 1, "rect": [1300, 100, 1520, 150], "label": "Checkout"}, + {"type": "box", "rect": [1300, 170, 1520, 220], "label": "Coupon", "color": "good"}, + {"type": "arrow", "rect": [280, 450, 900, 480], "label": "Row", "color": "bad"}, + {"type": "highlight", "rect": [280, 340, 900, 370]}, + {"type": "spotlight", "rect": [280, 560, 900, 600]}, + {"type": "redact", "rect": {"x": 420, "y": 230, "w": 170, "h": 14}}, + {"type": "text", "text": "A note", "at": [700, 900], "color": "info"}, + {"type": "magnify", "rect": [1300, 100, 1400, 150], "at": [1100, 800]}, + {"type": "click", "at": [1410, 125], "label": "Click", "color": "info"}, + {"type": "keys", "keys": ["Ctrl", "S"], "at": [300, 940]}, + {"type": "blur", "rect": [290, 680, 700, 700]}, + {"type": "pixelate", "rect": [290, 735, 700, 755]}, +] +# sha256 of the default render of every mark type, made with the code before looks were pluggable +DEFAULT_SHA = {False: "2459af01e003be489fe9675a07cea01f3109a6ed153d6b02d767dd0d11456417", + True: "fc93f1192da4bbcc2fb0ad05d98fa91b006776c58c0968f158ffa25769628368"} + + +@pytest.mark.parametrize("dark", [False, True]) +def test_default_look_is_unchanged(ui, ui_dark, dark): + s = {"input": ui_dark if dark else ui, "marks": EVERY} + img = render(s).image + assert render(dict(s, look="default")).image.tobytes() == img.tobytes() + if (PIL.__version__, features.version("freetype2")) != ("12.3.0", "2.14.3"): + pytest.skip("pinned pixels were made with Pillow 12.3.0 and FreeType 2.14.3") + assert hashlib.sha256(img.tobytes()).hexdigest() == DEFAULT_SHA[dark] + + +class Gray(Look): + """A test look: gray outlines and redactions, no shadow; records what it drew.""" + shadow = (0, 0, 0) + drawn = [] + + def outline(self, layer, i, g): + Gray.drawn.append(("outline", i)) + super().outline(layer, i, g) + + def color(self, i): + return "#777777" + + def redact_fill(self, i): + return "#555555" + + +class EP: + name, group = "gray", "ainotate.looks" + + def load(self): + return Gray + + +@pytest.fixture +def gray_installed(monkeypatch): + real = importlib.metadata.entry_points + monkeypatch.setattr(importlib.metadata, "entry_points", + lambda **kw: [EP()] if kw.get("group") == "ainotate.looks" else real(**kw)) + Gray.drawn = [] + + +def test_an_installed_look_draws_the_marks(ui, gray_installed): + marks = [EVERY[1], EVERY[5]] + plain = render({"input": ui, "marks": marks}).image + img = render({"input": ui, "look": "gray", "marks": marks}).image + assert img.tobytes() != plain.tobytes() and Gray.drawn == [("outline", 0)] + assert img.getpixel((500, 237)) == (0x55, 0x55, 0x55) # the redaction, in the look's color + + +def test_look_from_flag_env_and_config(ui, gray_installed, isolated_env, monkeypatch, tmp_path, capsys): + spec = tmp_path / "s.json" + spec.write_text(json.dumps({"input": ui, "marks": [EVERY[1]]})) + assert cli.main(["annotate", str(spec), "--draft", "--look", "gray"]) == 0 and Gray.drawn + cfg = isolated_env / ".config" / "ainotate" / "config.toml" + cfg.parent.mkdir(parents=True) + cfg.write_text('look = "gray"\n') + for env, used in ((None, True), ("default", False), ("gray", True)): + Gray.drawn = [] + if env: + monkeypatch.setenv("AINOTATE_LOOK", env) + render({"input": ui, "marks": [EVERY[1]]}) + assert bool(Gray.drawn) == used, env + Gray.drawn = [] + render({"input": ui, "look": "default", "marks": [EVERY[1]]}) # the spec beats env and config + assert not Gray.drawn + + +@pytest.mark.parametrize("where", ["spec", "env"]) +def test_unknown_look_lists_the_installed_looks(ui, gray_installed, monkeypatch, where): + s = {"input": ui, "marks": [EVERY[1]]} + if where == "spec": + s["look"] = "neat" + else: + monkeypatch.setenv("AINOTATE_LOOK", "neat") + with pytest.raises(SpecError) as e: + render(s) + assert "'neat', not an installed look; installed looks: default, gray" in str(e.value) + assert ("AINOTATE_LOOK" if where == "env" else "'look'") in str(e.value) From 7687af3c0d782b6b9de0fa2fa4e10b41d7fd4af4 Mon Sep 17 00:00:00 2001 From: visul Date: Sat, 10 Oct 2026 03:24:05 +0300 Subject: [PATCH 2/3] fix(looks): a broken look falls back to the default; pin default pixels per platform A look that is installed but fails to import, is not a Look subclass, or fails to start now renders with the built-in look and adds a warning instead of failing the render. The pixel pin of the default look depends on system fonts, so it is kept per platform (the macOS pin matches main before the hook: the default render is unchanged). Co-Authored-By: Claude Opus 5.5 (1M context) --- src/ainotate/render.py | 19 +++++++++++++++---- tests/test_looks.py | 41 +++++++++++++++++++++++++++++++++++------ 2 files changed, 50 insertions(+), 10 deletions(-) diff --git a/src/ainotate/render.py b/src/ainotate/render.py index ce657cf..aac396a 100644 --- a/src/ainotate/render.py +++ b/src/ainotate/render.py @@ -162,13 +162,24 @@ def keys_font(self, i, keys): return font_for(keys, int(1.15 * self.ctx.u)) -def _look(spec): +def _look(spec, ctx, warnings): + """The look instance for this render. A look that is installed but fails to load or start + falls back to the built-in one with a warning: a broken add-on never costs the image.""" if "look" in spec: name = spec["look"] else: from .output import default_look name = default_look() - return Look if name == "default" else installed_looks()[name].load() + if name != "default": + try: + cls = installed_looks()[name].load() + if not (isinstance(cls, type) and issubclass(cls, Look)): + raise TypeError("not a subclass of ainotate.render.Look") + return cls(ctx) + except Exception as e: # noqa: BLE001 (third-party code: any failure means "use the default") + warnings.append(f"look {name!r} could not be loaded ({type(e).__name__}: {e}); " + "used the default look") + return Look(ctx) # ---------- render ---------- @@ -258,8 +269,8 @@ def render(spec: dict, debug: bool = False) -> RenderResult: W, H = img.size u = mark_unit(W, H, scale) style = spec.get("arrow_style", "skitch") - lk = _look(spec)(SimpleNamespace(page=page, marks=marks, rects=rects, points=points, - offset=(ox, oy), scale=scale, u=u)) + lk = _look(spec, SimpleNamespace(page=page, marks=marks, rects=rects, points=points, + offset=(ox, oy), scale=scale, u=u), warnings) stroke = lk.stroke base_pad = 0.7 * u diff --git a/tests/test_looks.py b/tests/test_looks.py index 4d698c2..282da94 100644 --- a/tests/test_looks.py +++ b/tests/test_looks.py @@ -1,6 +1,7 @@ import hashlib import importlib.metadata import json +import sys import PIL import pytest @@ -24,9 +25,14 @@ {"type": "blur", "rect": [290, 680, 700, 700]}, {"type": "pixelate", "rect": [290, 735, 700, 755]}, ] -# sha256 of the default render of every mark type, made with the code before looks were pluggable -DEFAULT_SHA = {False: "2459af01e003be489fe9675a07cea01f3109a6ed153d6b02d767dd0d11456417", - True: "fc93f1192da4bbcc2fb0ad05d98fa91b006776c58c0968f158ffa25769628368"} +# sha256 of the default render of every mark type, made with the code before looks were pluggable. +# Fonts come from the system, so the pixels are pinned per platform (Linux: the contributor's run). +DEFAULT_SHA = { + ("linux", False): "2459af01e003be489fe9675a07cea01f3109a6ed153d6b02d767dd0d11456417", + ("linux", True): "fc93f1192da4bbcc2fb0ad05d98fa91b006776c58c0968f158ffa25769628368", + ("darwin", False): "5a795eaadddc77d6fde353ef40765eb6dac235f7963d7d56396cccdbb181956f", + ("darwin", True): "d0402a305915cd4f43326b0f8bee1d166a8cd16e95fe80ab736abd71523f92c6", +} @pytest.mark.parametrize("dark", [False, True]) @@ -34,9 +40,10 @@ def test_default_look_is_unchanged(ui, ui_dark, dark): s = {"input": ui_dark if dark else ui, "marks": EVERY} img = render(s).image assert render(dict(s, look="default")).image.tobytes() == img.tobytes() - if (PIL.__version__, features.version("freetype2")) != ("12.3.0", "2.14.3"): - pytest.skip("pinned pixels were made with Pillow 12.3.0 and FreeType 2.14.3") - assert hashlib.sha256(img.tobytes()).hexdigest() == DEFAULT_SHA[dark] + if (PIL.__version__, features.version("freetype2")) != ("12.3.0", "2.14.3") \ + or (sys.platform, dark) not in DEFAULT_SHA: + pytest.skip("pinned pixels were made with Pillow 12.3.0 and FreeType 2.14.3 on Linux and macOS") + assert hashlib.sha256(img.tobytes()).hexdigest() == DEFAULT_SHA[(sys.platform, dark)] class Gray(Look): @@ -107,3 +114,25 @@ def test_unknown_look_lists_the_installed_looks(ui, gray_installed, monkeypatch, render(s) assert "'neat', not an installed look; installed looks: default, gray" in str(e.value) assert ("AINOTATE_LOOK" if where == "env" else "'look'") in str(e.value) + + +@pytest.mark.parametrize("broken", ["import", "not-a-look", "init"]) +def test_a_broken_look_falls_back_to_the_default_with_a_warning(ui, monkeypatch, broken): + class Boom(Look): + def __init__(self, ctx): + raise RuntimeError("bad font file") + + class Bad: + name, group = "neat", "ainotate.looks" + + def load(self): + if broken == "import": + raise ImportError("No module named 'neat_look'") + return object if broken == "not-a-look" else Boom + real = importlib.metadata.entry_points + monkeypatch.setattr(importlib.metadata, "entry_points", + lambda **kw: [Bad()] if kw.get("group") == "ainotate.looks" else real(**kw)) + s = {"input": ui, "marks": [EVERY[1]]} + res = render(dict(s, look="neat")) + assert res.image.tobytes() == render(s).image.tobytes() + assert any("look 'neat' could not be loaded" in w and "used the default look" in w for w in res.warnings) From 313698d779c55412d343002b4b7e731f4dbe1d91 Mon Sep 17 00:00:00 2001 From: visul Date: Sat, 10 Oct 2026 03:24:24 +0300 Subject: [PATCH 3/3] docs(changelog): pluggable looks (#3) Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b2feee..ad84829 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,14 @@ All notable changes to AInotate. - `--privacy auto` without an OCR engine says how to fix it: install one, or use `--privacy off` (with rect coordinates when marks use text targets) (#2). -Thanks to @testy-cool for #1 and #2. +### Added +- Looks: other packages can supply a look for the marks (entry point group `ainotate.looks`, + a subclass of `ainotate.render.Look`), picked with `"look"` in the spec, `--look`, the MCP + `look` argument, `AINOTATE_LOOK` or `look` in config.toml. The built-in look stays the default + and renders pixel-identical. A look that fails to load falls back to the default with a + warning (#3). + +Thanks to @testy-cool for #1, #2 and #3. ## 0.1.7 - 2026-10-10