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 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..aac396a 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,77 @@ 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, 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() + 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 ---------- def _su(vals, scale): @@ -197,9 +269,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), warnings) + stroke = lk.stroke base_pad = 0.7 * u def pad_for(m, r): @@ -216,26 +288,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 +317,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 +328,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 +336,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 +359,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 +379,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 +389,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 +404,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 +421,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 +440,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..282da94 --- /dev/null +++ b/tests/test_looks.py @@ -0,0 +1,138 @@ +import hashlib +import importlib.metadata +import json +import sys + +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. +# 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]) +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") \ + 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): + """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) + + +@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)