diff --git a/CHANGELOG.md b/CHANGELOG.md index 9fef4bf..cce391a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ All notable changes to `github-showcase-skill` are recorded here. Versions follo - `content icon normalize` auto mode keys any uniform ground to transparent unless it is a dark night tile (luminance < 0.2 and not within dE 12 of the card colour), so a flat pale ground on a dark card no longer needs `--ground transparent`; `--ground keep|transparent` are unchanged. New `--ground-tolerance DE` (default 3.0) widens the corner match for slightly vignetted grounds (e.g. `--ground-tolerance 5`); above it `icon-ground` names the measured corner dE and the flag. A raised tolerance widens the keying threshold only above 3.0 and flattens a kept night tile so QA accepts it. Auto falls back to keying when a kept night tile would exceed the 300 KB budget; non-finite or negative `--ground-tolerance` is a usage error, and `--ground-tolerance 0` accepts identical corners as one ground (a positive tolerance keeps the strict boundary). The option A icon prompt (bundled and estate-format templates) states the exact flat `card_fill` ground with no vignette, texture, gradient, shadow or glow; the estate BACKGROUND heading now says one flat ground (not the avatar night tile), and the exact ground clause is now appended whenever a BACKGROUND replacement removed it (checked after all replacements). The soft 3 dE key alpha ramp applies only with a raised `--ground-tolerance`; the default transparent key is unchanged (original 0/255 step). - `content icon normalize` flattens a vignetted kept night tile with a feathered ramp (6 dE) instead of a hard threshold, and the transparent key uses a soft alpha ramp, so a glow around a solid glyph keeps its continuity with no hard outer ring; the glyph edge stays hard. `--ground-tolerance` is covered through the CLI (a raised value is applied; `nan` and negative values exit 2), and the estate-template numbered BACKGROUND paragraph is replaced by the flat-ground wording, including explicit colour or radial-gradient text. +- `content icon check` and `content icon normalize` take `--ocr` (opt-in, off by default) to read the icon with `tesseract` on `PATH` for text: words of 2 or more alphanumerics warn (fail with `--strict`); without the tool the check is skipped with `needs-input:ocr-unavailable` and `no-text` stays a manual review, so the result and exit code do not change. An unreadable or undecodable PNG (bad structure, or valid structure with corrupt compressed data) fails the icon as a structured `png` check, so `content icon check` then prints a JSON result with a `logs` list and exits 1 (bad arguments still exit 2); `logs` names the unreadable image as `unreadable-image:`, with and without `--ocr`; the command never ends in a traceback. `content icon normalize` handles an unreadable or undecodable `--in` candidate the same way (exit 1, no file written, with and without `--ocr`; `logs` names the `--in` source as `unreadable-image:` plus `art-fail:icon-qa`); a missing `--in` file stays a usage error (exit 2). +- `content icon check` and `content icon normalize` now report a PNG with a trailing empty `iCCP` chunk, which made Pillow raise `IndexError`, as the structured PNG failure (exit 1, `unreadable-image` log) instead of a traceback. +- `content icon check` and `content icon normalize` take `--min-contrast` (and the family key `icon_min_contrast`, range 1 to 21) to set the contrast floor of the icon ink on the card. Precedence is the flag, then the family key, then the default 3.0, so existing results do not change. 4.5:1 is recommended for the card plate but is not the default. +- `content icon check` and `content icon normalize` take `--strict` (and the family key `icon_strict_family_ink: true`, off by default) to fail instead of warn when the dominant icon ink is not a family ink. Without a family palette the family-ink check is still not emitted. - `org labels ORG --repos-from-gh` plans labels across accessible repositories without the private estate registry, with paginated read-only GitHub capture, optional verified local checkouts, unchanged reference guards, and a documented org-default-forms-first workflow. Registry-backed plans still require the org registry row, and checkout repository names match GitHub case-insensitively, including resolved pending renames. Registry-free runs also isolate lazy brand lookups from estate decision files, and held reference rewrites retain their file/line guidance with an incomplete result. Workflow/labeler/release/Renovate references remain final skips rather than unresolved input requests. Missing Windows device-named checkouts fall back to captured metadata instead of aborting the org plan. Pending transfers check the repository's current owner's default issue forms and hold renames when that evidence is unavailable. Sparse or partially missing checkouts retain reference holds based on captured paths and the Git index, while unrelated omitted files remain allowed. ### Fixed diff --git a/SKILL.md b/SKILL.md index 5e76d16..dd6a777 100644 --- a/SKILL.md +++ b/SKILL.md @@ -223,6 +223,7 @@ project estate-plan [--copied FILE] [--user-id ID] [--source OWNER/N] [--jobs FI content social [--out PATH] [--title TEXT] [--icon PATH] [--job] [--allow-unmeasured] [--allow-hold] content icon prompt|normalize|check [--option A|B] [--glyph FILE] [--art-direction FILE] [--in PNG] [--ground auto|transparent|keep] [--ground-tolerance DE] [--out PATH] [--preview PATH] + [--strict] [--min-contrast RATIO] [--ocr] content links [FILE...] [--readme-path PATH] [--base-ref REF] [--online] [--cache FILE] [--strict] [--diff] content linguist [--min-share 0.02] [--language NAME] content actions [--runs FILE] [--workflows FILE] [--pages FILE] [--changed PATH...] [--approval WORKFLOW=ID] diff --git a/references/brand.schema.json b/references/brand.schema.json index 8d08de3..6b8721a 100644 --- a/references/brand.schema.json +++ b/references/brand.schema.json @@ -223,6 +223,14 @@ }, "icon_pixel_art": { "type": "boolean" + }, + "icon_strict_family_ink": { + "type": "boolean" + }, + "icon_min_contrast": { + "type": "number", + "minimum": 1, + "maximum": 21 } } }, diff --git a/references/content.md b/references/content.md index cf4a35f..95ab88b 100644 --- a/references/content.md +++ b/references/content.md @@ -27,8 +27,8 @@ Every action also takes `--report FILE` (the JSON result), `--jobs FILE` (append |---|---|---|---| | `social` | R10 | The 1280×640 repository social preview (the contract name; the top-level `social-preview` is a hidden, deprecated alias with the same options and file), plus with `--job` the `browser` upload job (Settings → Social preview → Edit → Upload, then a read-only `usesCustomOpenGraphImage` check). Public T1 and T2 repositories only, the personal-profile capstone and backups included; forks, archived, `.github` and Pages repositories get no job. The image's footer reads `github.com//`, the slug the job uploads to (after an approved org rename, never the former login); when the rendered footer and the job's slug differ, the job is withheld (`needs-input:social-login`, exit 1). | `--out` | | `icon prompt` | RT-04 | Fills the repository-icon prompt from the art direction (option A: the Neon Night glyph; option B: the isometric illustration) and the repository's facts. The glyph itself is the writer's: `needs-writer` (exit 1) until `--glyph` supplies `glyph_name`, `glyph_description`, `spark_placement` and `size_class`; nothing is written before then. Option B appends the family addition (the trademark negatives in `references/art-direction.md`, such as the TCG one) found under any login of the org (CQ-03) or its display name, so a re-keyed `brand.yaml` row keeps it; when the org's addition is not in the art direction, the prompt is refused (`needs-input:family-additions`) rather than written without it. | `--out` | -| `icon normalize` | RT-04 | Keys a returned candidate to transparency (or keeps an opaque night tile full-bleed), trims and fits it to the 688 px live area of a 1024 px RGBA PNG, then runs the QA. A failing QA writes nothing (`art-fail:icon-qa`, exit 1). | `--out` (default `assets/icon.png` in the checkout), `--preview` | -| `icon check` | RT-04 | Automated icon QA: size, byte budget, alpha, live area, coverage, contrast on the card, family inks, not the org glyph, one file. OCR and the 48 px set sheet stay manual. | nothing | +| `icon normalize` | RT-04 | Keys a returned candidate to transparency (or keeps an opaque night tile full-bleed), trims and fits it to the 688 px live area of a 1024 px RGBA PNG, then runs the QA. A failing QA writes nothing (`art-fail:icon-qa`, exit 1). | `--out` (default `assets/icon.png` in the checkout), `--preview`, `--strict`, `--min-contrast`, `--ocr` | +| `icon check` | RT-04 | Automated icon QA: size, byte budget, alpha, live area, coverage, contrast on the card (floor set by `--min-contrast`, default 3:1), family inks, not the org glyph, one file. OCR is opt-in with `--ocr`; the 48 px set sheet stays manual. | `--strict`, `--min-contrast`, `--ocr` | | `links` | CQ-04, RP-09 | Every relative link, anchor and image target in the displayed README (GitHub's `.github/`, root, `docs/` precedence, or `--readme-path`), `docs/**`, `.github/*.md` and the org profile. With `--base-ref`, only breakage the change introduces fails; `--strict` fails any. `--online` adds cached, polite remote checks (HEAD first, one request per host per `--delay` seconds, at most `--max-remote`) and `gh` `full_name` checks for GitHub links, so a renamed org or repository is proposed under its current name. Offline, a link to this repository under its former slug (a transfer left its badges, release and clone URLs on the old org) is `moved` with a rewrite proposal only when an approved repository `rename` or `transfer` action in `decisions/estate-actions.json` records that exact move (the resolver's `repo_before`, CQ-03); a repository of the same name under another org is a different repository and is never rewritten (every org has its own `.github`). Every URL `references/brand.yaml` holds (the decision 18 footer's href and logo URLs, the brand and hub sites) is fixed content: it is still checked, but no org rename, transfer or online `full_name` answer proposes rewriting it. Rewrites match whole targets, so `…/tests.yml` never half-rewrites `…/tests.yml/badge.svg`. Broken links carry proposals; `--diff` prints them as a unified diff that is never applied. | `--cache` | | `linguist` | RP-07, CQ-08 | Paths that distort the language bar (vendored, generated, docs and example trees), the proposed append-only `# ct-std linguist` block for `.gitattributes`, the bar before and after, and the primary language a chip, card dot or badge may show (manifest runtime, then a `language:` override, then the corrected top language). | nothing | | `actions` | RP-06, RP-01, CQ-13 | Red runs and README status badges for failing workflows, upstream-only automation in forks (CLA, stale, labeler), and what a README merge would start (deploys first). Workflow on/off changes are proposals that need a `workflow-toggle` approval (`--approval WORKFLOW=ID`, keyed by the workflow file name); the job's argv names the workflow file (`gh workflow disable stale.yml -R OWNER/REPO`), never its numeric id, because the guard matches the approval's `detail` against it. | nothing | @@ -50,6 +50,12 @@ Each action prints one JSON object (`command: "content"`, `action`, `owner`, `re None by default. `links --online` and `images --online` make cached, rate-limited HEAD/GET requests to the hosts the files link to and read-only `gh api` GETs; `--cache` is the only state they keep. Local, private-network and `example.*` hosts are allowlisted and never fetched. +`--strict` (on `icon check` and `icon normalize`, also set by the family key `icon_strict_family_ink: true` in `references/brand.yaml`, which is off by default) makes the family-ink check fail instead of warn when the icon's dominant ink is not a family ink. Without the flag it stays a warning and the exit code is unchanged. With no family palette (`icon_ink` and `icon_spark` absent) the family-ink check is not emitted, so `--strict` has no effect there and `needs-input:icon-palette` is still logged. + +`--min-contrast` sets the contrast floor of the icon's dominant ink on the card plate (on `icon normalize` and `icon check`). The precedence is the CLI flag, then the family key `icon_min_contrast` in `references/brand.yaml`, then the default 3.0. The default stays 3:1 because a higher floor would change results that already pass. The recommended floor for the card plate is 4.5:1 (WCAG AA body text); set it with `--min-contrast 4.5` or `icon_min_contrast: 4.5` for a family. The valid range is 1 to 21; any other value, or a non-number, is a usage error (exit 2). A failing contrast check fails the icon (exit 1), as the other failing icon checks do. + +`--ocr` (on `icon check` and `icon normalize`, off by default) adds an optional text check: when `tesseract` is on `PATH`, the icon is composited on the configured family card colour (card_fill) and read with it, and words of 2 or more alphanumeric characters count as text. Found text warns (`no-text`, detail `OCR found text: ...`) and fails the icon with `--strict`; no text passes. Single characters and punctuation are ignored. Without `--ocr`, or without `tesseract`, nothing changes: `no-text` stays a manual review, and an absent tool logs `needs-input:ocr-unavailable` without changing the result or the exit code (a tool that fails to run logs `needs-input:ocr-failed`). The tool is never installed by this package. An unreadable or undecodable PNG (bad structure, or valid structure with corrupt compressed data) fails the `png` check and returns a `logs` list naming the image (`unreadable-image:`), with and without `--ocr`. On `icon normalize`, an unreadable or undecodable `--in` candidate also fails the `png` check (exit 1, `out` null, nothing written; `logs` names the `--in` source as `unreadable-image:` plus `art-fail:icon-qa`), while a missing `--in` file stays a usage error (exit 2). The 48 px set sheet stays manual. + ## Without the estate bundle For an older custom `--config` that omits `icon_ink` or `icon_spark`, normalization diff --git a/scripts/ctshowcase/content.py b/scripts/ctshowcase/content.py index 0b7ff13..4766487 100644 --- a/scripts/ctshowcase/content.py +++ b/scripts/ctshowcase/content.py @@ -22,7 +22,9 @@ import json import math import re +import shutil import subprocess +import tempfile import time import unicodedata import urllib.error @@ -2217,14 +2219,73 @@ def _dhash(img, size: int = 16) -> int: return bits +ICON_MIN_CONTRAST = 3.0 # default contrast floor of the icon ink on the card (E3.2.2) + + +def icon_contrast_floor(cli_value, family_value) -> float: + """The contrast floor of the icon ink on the card: `--min-contrast`, else the family key `icon_min_contrast`, else + 3.0. Only 1 to 21 is valid (WCAG contrast range); a bool or non-number is refused.""" + value = cli_value if cli_value is not None else family_value if family_value is not None else ICON_MIN_CONTRAST + if isinstance(value, bool) or not isinstance(value, (int, float)) or not 1 <= value <= 21: + raise ShowcaseError(f"--min-contrast / icon_min_contrast must be between 1 and 21, got {value}", log="usage") + return float(value) + + +def _tesseract() -> str | None: + """The `tesseract` executable on PATH, or None. OCR is optional: this tool never installs it.""" + return shutil.which("tesseract") + + +def _flatten_on_card(png: bytes, card: str) -> bytes: + """The icon composited on the real card colour, as it is seen on the card. Flattening on white would erase white + lettering on a dark card before OCR; an unreadable PNG is returned unchanged.""" + from PIL import Image + try: + im = Image.open(io.BytesIO(png)).convert("RGBA") + flat = Image.alpha_composite(Image.new("RGBA", im.size, _hex_rgba(card)), im).convert("RGB") + buf = io.BytesIO() + flat.save(buf, "PNG") + return buf.getvalue() + except (OSError, ValueError): + return png + + +def _ocr_words(png: bytes, exe: str) -> list[str] | None: + """The words of 2+ letters or digits (Unicode-aware) tesseract reads from the icon (opaque callers pass it already flattened), or None when the tool cannot run + (OSError, timeout or a non-zero exit). Single characters and punctuation are noise and are ignored.""" + from PIL import Image + try: + im = Image.open(io.BytesIO(png)).convert("RGBA") + flat = Image.new("RGBA", im.size, (255, 255, 255, 255)) + flat = Image.alpha_composite(flat, im).convert("RGB") + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "icon.png" + flat.save(path, "PNG") + done = subprocess.run([exe, str(path), "stdout", "--psm", "11"], capture_output=True, text=True, + encoding="utf-8", errors="replace", timeout=60) + except (OSError, subprocess.SubprocessError): + return None + if done.returncode != 0: + return None + return re.findall(r"[^\W_]{2,}", done.stdout) + + def icon_qa(data: bytes, *, card: str, ink: list[str] | None = None, org_mark: Path | None = None, - siblings: list[str] | None = None) -> dict: + siblings: list[str] | None = None, strict: bool = False, min_contrast: float = 3.0, + ocr: bool = False, source: str | None = None) -> dict: """Automated icon checks (f.3, art QA): size, budget, alpha, live area, coverage, contrast on the card, family - inks, not the org glyph, one file. OCR and the 48 px set sheet stay manual.""" + inks, not the org glyph, one file. The 48 px set sheet stays manual. `strict` makes a failing family-ink + check fail the icon (default: warn); with no `ink` the family-ink check is not emitted at all. `min_contrast` is + the contrast floor of the dominant ink on the card (default 3.0; see icon_contrast_floor). `ocr` (opt-in, off by + default) reads the icon with tesseract on PATH: words of 2+ alphanumerics are text, warn (fail with `strict`); + without tesseract, or when it fails, no-text stays manual and `logs` names needs-input:ocr-unavailable or + needs-input:ocr-failed. A `logs` key is returned with `ocr`, and always on a png failure, where it is + ["unreadable-image:"] naming the unreadable image (`source` is the file path when known, else ).""" from PIL import Image from .color import contrast, hexstr, rgb as hexrgb from .pnglint import validate_png checks: list[dict] = [] + unreadable = {"logs": [f"unreadable-image:{source or ''}"]} def add(name, ok, detail="", level="fail"): checks.append({"check": name, "result": "pass" if ok else level, "detail": detail}) @@ -2232,13 +2293,18 @@ def add(name, ok, detail="", level="fail"): w, h, colour = validate_png(data) except ValueError as exc: add("png", False, str(exc)) - return {"result": "fail", "checks": checks} + return {"result": "fail", "checks": checks, **unreadable} add("png", True) add("size", (w, h) == (ICON_PX, ICON_PX), f"{w}x{h}") add("bytes", len(data) <= ICON_BUDGET, f"{len(data)} <= {ICON_BUDGET}") - im = Image.open(io.BytesIO(data)) - im.load() - rgba = im.convert("RGBA") + try: + im = Image.open(io.BytesIO(data)) + im.load() + rgba = im.convert("RGBA") + except (OSError, ValueError, SyntaxError, IndexError, Image.DecompressionBombError) as exc: + # structurally valid PNG whose compressed IDAT data cannot be decoded: a structured failure, never a traceback + add("png", False, f"undecodable image data: {str(exc)[:80]}") + return {"result": "fail", "checks": checks, **unreadable} alpha = rgba.getchannel("A") transparent = colour in (4, 6) or (colour == 3 and "transparency" in im.info) lab = _lab_cache() @@ -2277,10 +2343,12 @@ def add(name, ok, detail="", level="fail"): counts[key] = counts.get(key, 0) + 1 dom = hexstr(max(counts, key=counts.get)) cr = contrast(dom, card) - add("contrast-on-card", cr >= 3.0, f"dominant ink {dom} on {card}: {cr:.2f}:1 (>= 3:1)") + floor = float(min_contrast) + add("contrast-on-card", cr >= floor, f"dominant ink {dom} on {card}: {cr:.2f}:1 (>= {floor:g}:1)") if ink: near = min(_de(lab(hexrgb(dom)), lab(hexrgb(x))) for x in ink) - add("family-ink", near <= 12, f"dominant ink {dom}, nearest family ink ΔE {near:.1f}", level="warn") + add("family-ink", near <= 12, f"dominant ink {dom}, nearest family ink ΔE {near:.1f}", + level="fail" if strict else "warn") else: add("contrast-on-card", False, "no opaque ink") if org_mark and Path(org_mark).is_file() and Path(org_mark).stat().st_size > 0: @@ -2293,10 +2361,31 @@ def add(name, ok, detail="", level="fail"): add("not-org-glyph", True, f"org mark unreadable: {str(exc)[:60]}", level="warn") bad = [s for s in siblings or [] if re.search(r"icon[-_.](dark|light)\.", s, re.I)] add("one-file", not bad, ", ".join(bad) or "no -dark or -light sibling (decision 21)") - checks.append({"check": "no-text", "result": "manual", "detail": "OCR and the 48 px org sheet are reviewed (f.3, g)"}) + logs: list[str] = [] + if not ocr: + checks.append({"check": "no-text", "result": "manual", "detail": "OCR and the 48 px org sheet are reviewed (f.3, g)"}) + else: + exe = _tesseract() + if exe is None: + checks.append({"check": "no-text", "result": "manual", "detail": "tesseract not installed: OCR skipped, " + "no-text stays a manual review (needs-input:ocr-unavailable)"}) + logs.append("needs-input:ocr-unavailable") + else: + words = _ocr_words(_flatten_on_card(data, card), exe) + if words is None: + checks.append({"check": "no-text", "result": "manual", + "detail": "tesseract failed: no-text stays a manual review"}) + logs.append("needs-input:ocr-failed") + elif words: + add("no-text", False, "OCR found text: " + ", ".join(words[:5]), level="fail" if strict else "warn") + else: + add("no-text", True, "OCR found no text") res = "fail" if any(c["result"] == "fail" for c in checks) else ( "warn" if any(c["result"] == "warn" for c in checks) else "pass") - return {"result": res, "checks": checks, "dominant_ink": dom} + out = {"result": res, "checks": checks, "dominant_ink": dom} + if ocr: + out["logs"] = logs + return out # ===================================================================================================== @@ -2348,6 +2437,13 @@ def add_parser(sub, cli=None): "(default assets/icon.png in the checkout); check: the icon to check") a.add_argument("--preview", help="normalize: also write a 48 px preview on the card colour") a.add_argument("--allow-hold", action="store_true", help="work on a family that is on hold (as banner and card)") + a.add_argument("--strict", action="store_true", help="fail (not warn) when the dominant ink is not a family ink; " + "also set by the family key icon_strict_family_ink") + a.add_argument("--min-contrast", type=float, default=None, + help="contrast floor of the icon ink on the card plate (default: family key icon_min_contrast, " + "else 3.0; 4.5 is recommended)") + a.add_argument("--ocr", action="store_true", help="also OCR the icon for text with tesseract when installed " + "(skipped with needs-input:ocr-unavailable otherwise)") a = cs.add_parser("links", parents=[common], help="CQ-04: relative links, anchors and remote targets") a.add_argument("files", nargs="*", help="Markdown files (default: displayed README, docs/**, .github/*.md, profile)") @@ -2631,6 +2727,8 @@ def _cmd_icon(args, cli, resolver, head): card = ctx.style["tokens"]["card_fill"] org = org_row(resolver, ctx.brand, ctx.owner) mark = ctx.brand.asset(org.get("mark")) + strict = bool(getattr(args, "strict", False) or ctx.style.get("icon_strict_family_ink")) + ocr = bool(getattr(args, "ocr", False)) if args.icon_action == "prompt": art = load_art_direction(args.art_direction) glyph = None @@ -2661,6 +2759,7 @@ def _cmd_icon(args, cli, resolver, head): note="The glyph is the writer's: fill glyph_name, glyph_description, spark_placement and size_class " "(--glyph FILE) and re-run until the result is ok; nothing is written before then.") return obj, _code(result) + floor = icon_contrast_floor(getattr(args, "min_contrast", None), ctx.style.get("icon_min_contrast")) ink = [] try: art = load_art_direction(args.art_direction) @@ -2672,15 +2771,36 @@ def _cmd_icon(args, cli, resolver, head): if args.icon_action == "normalize": if not args.src: raise ShowcaseError("content icon normalize needs --in CANDIDATE.png", log="usage") - src = Image.open(args.src) - src.load() + try: + raw = Path(args.src).read_bytes() + except OSError as exc: + raise ShowcaseError(f"cannot read --in {args.src}: {exc}", log="usage") + try: + src = Image.open(io.BytesIO(raw)) + src.load() + except (OSError, ValueError, SyntaxError, IndexError, Image.DecompressionBombError): + # readable but undecodable source: the structured png failure from icon_qa, nothing written + qa = icon_qa(raw, card=card, ink=ink, org_mark=mark, siblings=siblings, strict=strict, + min_contrast=floor, ocr=ocr, source=str(args.src)) + if qa["result"] != "fail": + qa["result"] = "fail" + qa["checks"].append({"check": "png", "result": "fail", "detail": "undecodable image data"}) + qa_logs = qa.get("logs", []) + unreadable_log = f"unreadable-image:{args.src}" + if unreadable_log not in qa_logs: + qa_logs = qa_logs + [unreadable_log] + return dict(head, result="fail", out=None, info=None, qa=qa, + logs=ctx.logs + qa_logs + ["art-fail:icon-qa"], + note="nothing written"), CHECK_FAILED img, info = normalize_icon(src, args.ground, card, pixel_art=bool(ctx.style.get("icon_pixel_art")), tolerance=args.ground_tolerance) data, qlogs = icon_bytes(img) - qa = icon_qa(data, card=card, ink=ink, org_mark=mark, siblings=siblings) + qa = icon_qa(data, card=card, ink=ink, org_mark=mark, siblings=siblings, strict=strict, min_contrast=floor, + ocr=ocr, source=str(args.src)) out = Path(args.out) if args.out else cli.default_out(args, "assets/icon.png") if qa["result"] == "fail": - return dict(head, result="fail", out=None, info=info, qa=qa, logs=ctx.logs + qlogs + ["art-fail:icon-qa"], + return dict(head, result="fail", out=None, info=info, qa=qa, + logs=ctx.logs + qlogs + qa.get("logs", []) + ["art-fail:icon-qa"], note="nothing written"), CHECK_FAILED cli.write_bytes(out, data) if args.preview: @@ -2688,13 +2808,15 @@ def _cmd_icon(args, cli, resolver, head): prev.alpha_composite(img.convert("RGBA").resize((48, 48), Image.LANCZOS)) cli.write_bytes(Path(args.preview), _png(prev.convert("RGB"))) return dict(head, result=qa["result"], out=str(out), bytes=len(data), - sha256=hashlib.sha256(data).hexdigest(), info=info, qa=qa, logs=ctx.logs + qlogs), OK + sha256=hashlib.sha256(data).hexdigest(), info=info, qa=qa, + logs=ctx.logs + qlogs + qa.get("logs", [])), OK path = Path(args.out) if args.out else (rd / "assets" / "icon.png" if rd else None) if path is None or not path.is_file(): return dict(head, result="fail", file=str(path) if path else None, logs=ctx.logs + ["needs-input:repo-icon"]), \ CHECK_FAILED - qa = icon_qa(path.read_bytes(), card=card, ink=ink, org_mark=mark, siblings=siblings) - return dict(head, result=qa["result"], file=str(path), qa=qa, logs=ctx.logs), _code(qa["result"]) + qa = icon_qa(path.read_bytes(), card=card, ink=ink, org_mark=mark, siblings=siblings, strict=strict, + min_contrast=floor, ocr=ocr, source=str(path)) + return dict(head, result=qa["result"], file=str(path), qa=qa, logs=ctx.logs + qa.get("logs", [])), _code(qa["result"]) def _hex_rgba(value: str) -> tuple[int, int, int, int]: diff --git a/tests/test_content.py b/tests/test_content.py index 231d6d5..57b70ba 100644 --- a/tests/test_content.py +++ b/tests/test_content.py @@ -1261,3 +1261,525 @@ def test_add_parser_registers_every_action(): content = sub.choices["content"] acts = next(a for a in content._actions if isinstance(a, argparse._SubParsersAction)) assert set(acts.choices) == set(C.ACTIONS) + + +# --------------------------------------------------------------------------------------------------- +# icon family ink: warn by default, fail with --strict or the family key icon_strict_family_ink (E3.2.1) +# --------------------------------------------------------------------------------------------------- +def _check_result(qa, name): + return next((c["result"] for c in qa["checks"] if c["check"] == name), None) + + +def test_icon_family_ink_warns_by_default_and_fails_when_strict(brand): + card = brand.style(NW)["tokens"]["card_fill"] + img = C.normalize_icon(_candidate(card, ink=(0, 90, 221)), "transparent", card)[0] + data = C.icon_bytes(img)[0] + qa = C.icon_qa(data, card=card, ink=["#8B5CF6", "#22D3EE"]) + assert _check_result(qa, "family-ink") == "warn" and qa["result"] == "warn", qa + qa = C.icon_qa(data, card=card, ink=["#8B5CF6", "#22D3EE"], strict=True) + assert _check_result(qa, "family-ink") == "fail" and qa["result"] == "fail", qa + assert "dominant ink" in next(c for c in qa["checks"] if c["check"] == "family-ink")["detail"] + qa = C.icon_qa(data, card=card, ink=["#0858D8"], strict=True) + assert _check_result(qa, "family-ink") == "pass", qa + # without a family palette the check is not emitted at all, so strict changes nothing + qa = C.icon_qa(data, card=card, strict=True) + assert _check_result(qa, "family-ink") is None + + +def test_icon_strict_flag_and_family_key(capsys, tmp_path, brand): + import copy + import yaml + card = brand.style(NW)["tokens"]["card_fill"] + meta = tmp_path / "meta.json" + meta.write_text(json.dumps(meta_dict()), encoding="utf-8") + old = tmp_path / "old-blue" / "assets" / "icon.png" + old.parent.mkdir(parents=True) + old.write_bytes(C.icon_bytes(C.normalize_icon(_candidate(card, ink=(0, 90, 221)), "transparent", card)[0])[0]) + other = tmp_path / "other" / "assets" / "icon.png" + other.parent.mkdir(parents=True) + other.write_bytes(C.icon_bytes(C.normalize_icon(_candidate(card, ink=(139, 92, 246)), "transparent", card)[0])[0]) + check = ("--meta", meta, "--no-repo-dir", "content", "icon", "check") + art = ("--art-direction", FIX / "ART_DIRECTION.min.md") + + # the old Northwind blue is the family ink: pass with or without --strict + code, res = run_content(capsys, *check, "--out", old, *art, *NO_ESTATE) + assert code == 0 and res["result"] == "pass", res + code, res = run_content(capsys, *check, "--out", old, "--strict", *art, *NO_ESTATE) + assert code == 0 and res["result"] == "pass", res + + # a non-family ink: warn by default (exit 0), fail with --strict (exit 1) + code, res = run_content(capsys, *check, "--out", other, *art, *NO_ESTATE) + assert code == 0 and res["result"] == "warn" and _check_result(res["qa"], "family-ink") == "warn", res + code, res = run_content(capsys, *check, "--out", other, "--strict", *art, *NO_ESTATE) + assert code == 1 and res["result"] == "fail" and _check_result(res["qa"], "family-ink") == "fail", res + + # the family key turns the strict behaviour on for the Northwind family, without the flag + raw = copy.deepcopy(brand.raw) + raw["families"]["completetech"]["icon_strict_family_ink"] = True + cfg = tmp_path / "brand-strict.yaml" + cfg.write_text(yaml.safe_dump(raw, sort_keys=False, allow_unicode=True), encoding="utf-8") + code, res = run_content(capsys, "--config", cfg, *check, "--out", other, *art, *NO_ESTATE) + assert code == 1 and res["result"] == "fail" and _check_result(res["qa"], "family-ink") == "fail", res + + # normalize: --strict (or the family key) fails with exit 1 and writes nothing; without either it writes the file + cand = tmp_path / "cand.png" + _candidate(card, ink=(139, 92, 246)).save(cand) + norm = ("--meta", meta, "--no-repo-dir", "content", "icon", "normalize", "--in", cand) + out_n = tmp_path / "norm" / "icon.png" + code, res = run_content(capsys, *norm, "--out", out_n, "--strict", *art, *NO_ESTATE) + assert code == 1 and res["out"] is None and "art-fail:icon-qa" in res["logs"] and not out_n.exists(), res + code, res = run_content(capsys, "--config", cfg, *norm, "--out", out_n, *art, *NO_ESTATE) + assert code == 1 and res["out"] is None and not out_n.exists(), res + code, res = run_content(capsys, *norm, "--out", out_n, *art, *NO_ESTATE) + assert code == 0 and res["result"] == "warn" and out_n.is_file(), res + + +def test_schema_accepts_icon_strict_family_ink(brand): + import copy + from ctshowcase.config import schema_errors + raw = copy.deepcopy(brand.raw) + raw["families"]["completetech"]["icon_strict_family_ink"] = True + assert schema_errors(raw) == [] + raw["families"]["completetech"]["icon_strict_family_ink"] = "yes" + assert schema_errors(raw) != [] + + +# --------------------------------------------------------------------------------------------------- +# configurable contrast floor (E3.2.2): default 3.0, `--min-contrast` > family key `icon_min_contrast` > 3.0 +# --------------------------------------------------------------------------------------------------- +CONTRAST_INK = (122, 141, 176) # ~3.1:1 on the card: between the 3.0 default floor and the 4.5 recommendation + + +def test_icon_min_contrast_default_3_and_configurable(brand): + from ctshowcase.color import contrast + card = brand.style(NW)["tokens"]["card_fill"] + img = C.normalize_icon(_candidate(card, ink=CONTRAST_INK), "transparent", card)[0] + data = C.icon_bytes(img)[0] + qa = C.icon_qa(data, card=card) + assert _check_result(qa, "contrast-on-card") == "pass", qa + detail = next(c["detail"] for c in qa["checks"] if c["check"] == "contrast-on-card") + assert detail.endswith("(>= 3:1)"), detail + # self-check: the measured ink lies between the default floor and the recommended one + ratio = contrast(qa["dominant_ink"], card) + assert 3.0 < ratio < 4.5, ratio + qa = C.icon_qa(data, card=card, min_contrast=4.5) + assert _check_result(qa, "contrast-on-card") == "fail" and qa["result"] == "fail", qa + detail = next(c["detail"] for c in qa["checks"] if c["check"] == "contrast-on-card") + assert "(>= 4.5:1)" in detail, detail + # an explicit 3.0 is the default + assert C.icon_qa(data, card=card, min_contrast=3.0)["checks"] == C.icon_qa(data, card=card)["checks"] + + +def test_icon_contrast_floor_helper(): + from ctshowcase.errors import ShowcaseError + assert C.icon_contrast_floor(None, None) == 3.0 + assert C.icon_contrast_floor(4.5, None) == 4.5 + assert C.icon_contrast_floor(None, 4.5) == 4.5 + assert C.icon_contrast_floor(3.5, 4.5) == 3.5 # the CLI value wins over the family key + for bad in (0.5, 22, True, "x"): + with pytest.raises(ShowcaseError) as exc: + C.icon_contrast_floor(bad, None) + assert exc.value.log == "usage" and exc.value.code == 2, bad + with pytest.raises(ShowcaseError): + C.icon_contrast_floor(None, 22) + + +def test_icon_min_contrast_cli(capsys, tmp_path, brand): + card = brand.style(NW)["tokens"]["card_fill"] + img = C.normalize_icon(_candidate(card, ink=CONTRAST_INK), "transparent", card)[0] + out = write(tmp_path, {"repo/assets/icon.png": C.icon_bytes(img)[0]}) / "repo" / "assets" / "icon.png" + meta = tmp_path / "meta.json" + meta.write_text(json.dumps(meta_dict()), encoding="utf-8") + check = ("--meta", meta, "--no-repo-dir", "content", "icon", "check", "--out", out, + "--art-direction", FIX / "ART_DIRECTION.min.md", *NO_ESTATE) + code, res = run_content(capsys, *check, "--min-contrast", "4.5") + assert code == 1 and res["result"] == "fail", res + code, res = run_content(capsys, *check) + assert code == 0 and res["result"] in ("pass", "warn"), res + code, res = run_content(capsys, *check, "--min-contrast", "30") + assert code == 2, res + + +def test_schema_icon_min_contrast(brand): + import copy + from ctshowcase.config import schema_errors + raw = copy.deepcopy(brand.raw) + raw["families"]["completetech"]["icon_min_contrast"] = 4.5 + assert schema_errors(raw) == [] + raw["families"]["completetech"]["icon_min_contrast"] = 0.5 + assert schema_errors(raw) != [] + raw["families"]["completetech"]["icon_min_contrast"] = 22 + assert schema_errors(raw) != [] + + +# --------------------------------------------------------------------------------------------------- +# optional OCR no-text check (E3.2.3): off by default, `--ocr` uses tesseract on PATH when installed +# --------------------------------------------------------------------------------------------------- +MANUAL_NO_TEXT = "OCR and the 48 px org sheet are reviewed (f.3, g)" + + +def _no_text(qa): + return next(c for c in qa["checks"] if c["check"] == "no-text") + + +def test_icon_ocr_off_by_default_keeps_manual(brand): + # Regression guard: the no-flag output must equal today's manual entry. The + # kwdefault assertion pins that --ocr exists and defaults OFF (it fails on base). + assert C.icon_qa.__kwdefaults__["ocr"] is False + card = brand.style(NW)["tokens"]["card_fill"] + data = C.icon_bytes(C.normalize_icon(_candidate(card), "transparent", card)[0])[0] + qa = C.icon_qa(data, card=card) + assert "logs" not in qa + assert _no_text(qa) == {"check": "no-text", "result": "manual", "detail": MANUAL_NO_TEXT} + + +def test_icon_ocr_unavailable_logs_and_skips(brand, monkeypatch): + card = brand.style(NW)["tokens"]["card_fill"] + data = C.icon_bytes(C.normalize_icon(_candidate(card), "transparent", card)[0])[0] + monkeypatch.setattr(C, "_tesseract", lambda: None) + off = C.icon_qa(data, card=card) + qa = C.icon_qa(data, card=card, ocr=True) + assert _no_text(qa)["result"] == "manual", qa + assert "tesseract not installed" in _no_text(qa)["detail"] + assert qa["logs"] == ["needs-input:ocr-unavailable"], qa + assert qa["result"] == off["result"] + + +def test_icon_ocr_found_text_warns_or_fails_strict(brand, monkeypatch): + card = brand.style(NW)["tokens"]["card_fill"] + data = C.icon_bytes(C.normalize_icon(_candidate(card), "transparent", card)[0])[0] + monkeypatch.setattr(C, "_tesseract", lambda: "tesseract") + monkeypatch.setattr(C, "_ocr_words", lambda png, exe: ["HELLO", "A1"]) + qa = C.icon_qa(data, card=card, ocr=True) + assert _no_text(qa)["result"] == "warn" and _no_text(qa)["detail"].startswith("OCR found text: HELLO"), qa + assert qa["logs"] == [] and qa["result"] == "warn" + qa = C.icon_qa(data, card=card, ocr=True, strict=True) + assert _no_text(qa)["result"] == "fail" and qa["result"] == "fail", qa + + +def test_icon_ocr_clean_passes(brand, monkeypatch): + card = brand.style(NW)["tokens"]["card_fill"] + data = C.icon_bytes(C.normalize_icon(_candidate(card), "transparent", card)[0])[0] + monkeypatch.setattr(C, "_tesseract", lambda: "tesseract") + monkeypatch.setattr(C, "_ocr_words", lambda png, exe: []) + qa = C.icon_qa(data, card=card, ocr=True) + assert _no_text(qa) == {"check": "no-text", "result": "pass", "detail": "OCR found no text"}, qa + assert qa["logs"] == [] + + +def test_icon_ocr_tool_failure(brand, monkeypatch): + card = brand.style(NW)["tokens"]["card_fill"] + data = C.icon_bytes(C.normalize_icon(_candidate(card), "transparent", card)[0])[0] + monkeypatch.setattr(C, "_tesseract", lambda: "tesseract") + monkeypatch.setattr(C, "_ocr_words", lambda png, exe: None) + qa = C.icon_qa(data, card=card, ocr=True) + assert _no_text(qa)["result"] == "manual" and "stays a manual review" in _no_text(qa)["detail"], qa + assert "needs-input:ocr-failed" in qa["logs"] + + +def test_ocr_words_filters_noise(monkeypatch): + import subprocess + from types import SimpleNamespace + from PIL import Image + png = _png(Image.new("RGBA", (64, 64), (0, 0, 0, 0))) + monkeypatch.setattr(C.subprocess, "run", + lambda *a, **k: SimpleNamespace(returncode=0, stdout="\n x . HI 4 ab\n")) + assert C._ocr_words(png, "tesseract") == ["HI", "ab"] + monkeypatch.setattr(C.subprocess, "run", lambda *a, **k: SimpleNamespace(returncode=1, stdout="HI")) + assert C._ocr_words(png, "tesseract") is None + + def timeout(*a, **k): + raise subprocess.TimeoutExpired(cmd="tesseract", timeout=60) + monkeypatch.setattr(C.subprocess, "run", timeout) + assert C._ocr_words(png, "tesseract") is None + + +def test_ocr_words_keeps_unicode_text(monkeypatch): + # review B: the ASCII-only filter dropped recognised text such as "ete" with accents + from types import SimpleNamespace + from PIL import Image + png = _png(Image.new("RGBA", (64, 64), (0, 0, 0, 0))) + word = "été" + monkeypatch.setattr(C.subprocess, "run", lambda *a, **k: SimpleNamespace(returncode=0, stdout=word + "\n x\n")) + assert C._ocr_words(png, "tesseract") == [word] + + +def test_icon_ocr_sees_white_lettering_on_dark_card(monkeypatch): + # review B: flattening on white erased transparent white text before OCR; it must be flattened on the card colour + from PIL import Image + card = "#0B1020" + img = Image.new("RGBA", (64, 64), (0, 0, 0, 0)) + img.paste((255, 255, 255, 255), (20, 20, 44, 44)) + seen = {} + + def fake(png, exe): + im = Image.open(io.BytesIO(png)).convert("RGB") + seen["colours"] = {c for _, c in im.getcolors()} + return [] + monkeypatch.setattr(C, "_tesseract", lambda: "tesseract") + monkeypatch.setattr(C, "_ocr_words", fake) + C.icon_qa(_png(img), card=card, ocr=True) + assert (255, 255, 255) in seen["colours"] and (11, 16, 32) in seen["colours"], seen + + +def test_icon_ocr_cli_logs(capsys, tmp_path, brand, monkeypatch): + card = brand.style(NW)["tokens"]["card_fill"] + img = C.normalize_icon(_candidate(card), "transparent", card)[0] + out = write(tmp_path, {"repo/assets/icon.png": C.icon_bytes(img)[0]}) / "repo" / "assets" / "icon.png" + meta = tmp_path / "meta.json" + meta.write_text(json.dumps(meta_dict()), encoding="utf-8") + check = ("--meta", meta, "--no-repo-dir", "content", "icon", "check", "--out", out, + "--art-direction", FIX / "ART_DIRECTION.min.md", *NO_ESTATE) + monkeypatch.setattr(C, "_tesseract", lambda: None) + code, plain = run_content(capsys, *check) + code_ocr, res = run_content(capsys, *check, "--ocr") + assert code_ocr == code and res["result"] == plain["result"], (plain, res) + assert "needs-input:ocr-unavailable" in res["logs"], res + + +# real-engine controls: these call the installed tesseract binary (no monkeypatching of _tesseract, _ocr_words +# or subprocess.run). They are skipped with an explicit reason when tesseract is not on PATH. +def _real_tesseract(): + return C._tesseract() + + +needs_tesseract = pytest.mark.skipif( + C._tesseract() is None, + reason="tesseract not installed: real-OCR controls skipped (they run where it is installed, e.g. the VM)") + + +def _font(size): + from PIL import ImageFont + for name in ("DejaVuSans-Bold.ttf", "arialbd.ttf", "Arial Bold.ttf"): + try: + return ImageFont.truetype(name, size) + except OSError: + continue + return ImageFont.load_default(size=size) + + +def _text_icon(text, fg, bg=None): + from PIL import Image + from PIL import ImageDraw + img = Image.new("RGBA", (1024, 1024), bg if bg is not None else (0, 0, 0, 0)) + draw = ImageDraw.Draw(img) + font = _font(220) + left, top, right, bottom = draw.textbbox((0, 0), text, font=font) + draw.text(((1024 - (right - left)) / 2 - left, (1024 - (bottom - top)) / 2 - top), text, fill=fg, font=font) + return img + + +def _ellipse_icon(): + from PIL import Image + from PIL import ImageDraw + img = Image.new("RGBA", (1024, 1024), (0, 0, 0, 0)) + ImageDraw.Draw(img).ellipse((180, 180, 844, 844), fill=(0, 90, 221, 255)) + return img + + +@needs_tesseract +def test_real_tesseract_reads_dark_text_on_light(): + img = _text_icon("HELLO", (0, 0, 0, 255), (255, 255, 255, 255)) + exe = _real_tesseract() + words = C._ocr_words(_png(img), exe) + assert words is not None and any("HELLO" in w.upper() for w in words), words + + +@needs_tesseract +def test_real_tesseract_text_free_control_finds_nothing(): + exe = _real_tesseract() + words = C._ocr_words(_png(_ellipse_icon()), exe) + assert words == [], words + + +@needs_tesseract +def test_real_tesseract_icon_qa_warns_on_white_lettering_on_dark_card(): + # white text on a transparent canvas is only seen if the icon is flattened on the card colour, not on white + card = "#0B1020" + img = _text_icon("HELLO", (255, 255, 255, 255)) + qa = C.icon_qa(_png(img), card=card, ocr=True) + no_text = _no_text(qa) + assert no_text["result"] == "warn" and no_text["detail"].startswith("OCR found text:") \ + and "HELLO" in no_text["detail"].upper(), qa + assert "logs" not in qa or ("needs-input:ocr-unavailable" not in qa["logs"] + and "needs-input:ocr-failed" not in qa["logs"]), qa + strict = C.icon_qa(_png(img), card=card, ocr=True, strict=True) + assert _no_text(strict)["result"] == "fail", strict + + +@needs_tesseract +def test_real_tesseract_icon_qa_text_free_icon_passes(): + card = "#0B1020" + qa = C.icon_qa(_png(_ellipse_icon()), card=card, ocr=True) + assert _no_text(qa) == {"check": "no-text", "result": "pass", "detail": "OCR found no text"}, qa + + +@needs_tesseract +def test_real_tesseract_cli_icon_check_finds_text(capsys, tmp_path): + # the CLI takes the card colour from the meta family, so the text icon is flattened on the real card fill + out = write(tmp_path, {"repo/assets/icon.png": _png(_text_icon("HELLO", (255, 255, 255, 255)))}) \ + / "repo" / "assets" / "icon.png" + meta = tmp_path / "meta.json" + meta.write_text(json.dumps(meta_dict()), encoding="utf-8") + check = ("--meta", meta, "--no-repo-dir", "content", "icon", "check", "--out", out, + "--art-direction", FIX / "ART_DIRECTION.min.md", *NO_ESTATE) + code, res = run_content(capsys, *check, "--ocr") + no_text = next(c for c in res["qa"]["checks"] if c["check"] == "no-text") + assert no_text["result"] == "warn", res + assert "needs-input:ocr-unavailable" not in res.get("logs", []), res + + +def test_icon_ocr_malformed_png_keeps_logs_key(monkeypatch): + # an unreadable PNG returns before the OCR step; `logs` names the image with and without ocr + monkeypatch.setattr(C, "_tesseract", lambda: None) + qa = C.icon_qa(b"bad", card="#0B1020", ocr=True, source="x/icon.png") + assert qa["result"] == "fail" and qa["logs"] == ["unreadable-image:x/icon.png"], qa + assert C.icon_qa(b"bad", card="#0B1020", source="x/icon.png")["logs"] == ["unreadable-image:x/icon.png"] + assert C.icon_qa(b"bad", card="#0B1020")["logs"] == ["unreadable-image:"] + + +def test_icon_ocr_undecodable_idat_is_structured_failure(monkeypatch): + # valid signature, IHDR, chunk CRCs and IEND, but IDAT is not a zlib stream: validate_png accepts it and Pillow's + # load() made the icon_qa API raise OSError('broken data stream'); the CLI already caught that globally (JSON + # result=usage, logs=['usage'], exit 2), so the fix is the structured QA failure from the API + import struct + import zlib + from ctshowcase.pnglint import validate_png + + def chunk(kind, body): + return struct.pack(">I", len(body)) + kind + body + struct.pack(">I", zlib.crc32(kind + body) & 0xFFFFFFFF) + data = (b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", struct.pack(">IIBBBBB", 1024, 1024, 8, 6, 0, 0, 0)) + + chunk(b"IDAT", b"this is not a zlib stream") + chunk(b"IEND", b"")) + validate_png(data) # structurally accepted, so the failure comes from decoding + monkeypatch.setattr(C, "_tesseract", lambda: None) + qa = C.icon_qa(data, card="#0B1020", ocr=True, source="s.png") + assert qa["result"] == "fail" and qa["logs"] == ["unreadable-image:s.png"], qa + assert any(c["check"] == "png" and c["result"] == "fail" and "undecodable" in c["detail"] for c in qa["checks"]), qa + plain = C.icon_qa(data, card="#0B1020", source="s.png") + assert plain["result"] == "fail" and plain["logs"] == ["unreadable-image:s.png"], plain + + +def test_icon_check_cli_undecodable_idat_exits_1_with_logs_and_bad_args_exit_2(tmp_path, capsys, monkeypatch): + # valid arguments plus a structurally valid PNG with corrupt IDAT: a QA failure (exit 1, JSON with a `logs` list), + # distinct from bad arguments, which stay a usage error (exit 2) + import struct + import zlib + + def chunk(kind, body): + return struct.pack(">I", len(body)) + kind + body + struct.pack(">I", zlib.crc32(kind + body) & 0xFFFFFFFF) + data = (bytes.fromhex("89504e470d0a1a0a") + chunk(b"IHDR", struct.pack(">IIBBBBB", 1024, 1024, 8, 6, 0, 0, 0)) + + chunk(b"IDAT", b"this is not a zlib stream") + chunk(b"IEND", b"")) + monkeypatch.setattr(C, "_tesseract", lambda: None) + meta = tmp_path / "meta.json" + meta.write_text(json.dumps(meta_dict()), encoding="utf-8") + bad = tmp_path / "repo" / "assets" / "icon.png" + bad.parent.mkdir(parents=True) + bad.write_bytes(data) + check = ("--meta", meta, "--no-repo-dir", "content", "icon", "check") + art = ("--art-direction", FIX / "ART_DIRECTION.min.md") + for extra in ((), ("--ocr",)): + code, res = run_content(capsys, *check, "--out", bad, *extra, *art, *NO_ESTATE) + assert code == 1 and res["result"] == "fail" and f"unreadable-image:{bad}" in res["logs"], (extra, res) + assert any(c["check"] == "png" and c["result"] == "fail" and "undecodable" in c["detail"] + for c in res["qa"]["checks"]), res + code, res = run_content(capsys, *check, "--out", bad, "--min-contrast", "0.5", *art, *NO_ESTATE) + assert code == 2, res + + +def test_icon_normalize_cli_undecodable_idat_exits_1_with_logs_and_missing_input_exit_2(tmp_path, capsys, monkeypatch): + # normalize with a structurally valid PNG whose IDAT cannot decode: a structured QA failure (exit 1, JSON with a + # `logs` list, no output file written; a pre-existing destination is left byte-identical), while a nonexistent or + # omitted --in stays a usage error (exit 2) + import struct + import zlib + + def chunk(kind, body): + return struct.pack(">I", len(body)) + kind + body + struct.pack(">I", zlib.crc32(kind + body) & 0xFFFFFFFF) + data = (bytes.fromhex("89504e470d0a1a0a") + chunk(b"IHDR", struct.pack(">IIBBBBB", 1024, 1024, 8, 6, 0, 0, 0)) + + chunk(b"IDAT", b"this is not a zlib stream") + chunk(b"IEND", b"")) + monkeypatch.setattr(C, "_tesseract", lambda: None) + meta = tmp_path / "meta.json" + meta.write_text(json.dumps(meta_dict()), encoding="utf-8") + bad = tmp_path / "bad.png" + bad.write_bytes(data) + out = tmp_path / "out" / "icon.png" + out.parent.mkdir(parents=True, exist_ok=True) + sentinel = b"PRE-EXISTING-DEST-BYTES\x00\x01\xff" + out.write_bytes(sentinel) + norm = ("--meta", meta, "--no-repo-dir", "content", "icon", "normalize") + art = ("--art-direction", FIX / "ART_DIRECTION.min.md") + for extra in ((), ("--ocr",)): + code, res = run_content(capsys, *norm, "--in", bad, "--out", out, *extra, *art, *NO_ESTATE) + assert code == 1 and res["result"] == "fail" and res["out"] is None, (extra, res) + assert res["logs"].count(f"unreadable-image:{bad}") == 1 and "art-fail:icon-qa" in res["logs"], (extra, res) + assert any(c["check"] == "png" and c["result"] == "fail" for c in res["qa"]["checks"]), res + assert out.read_bytes() == sentinel, extra + code, res = run_content(capsys, *norm, "--in", tmp_path / "missing.png", "--out", out, *art, *NO_ESTATE) + assert code == 2, res + code, res = run_content(capsys, *norm, "--out", out, *art, *NO_ESTATE) + assert code == 2, res + assert out.read_bytes() == sentinel + + +def test_icon_trailing_empty_iccp_is_structured_failure_in_api_check_and_normalize(tmp_path, capsys, monkeypatch, brand): + # validate_png accepts the sequence, but Pillow raises IndexError while loading the trailing empty iCCP chunk: + # icon_qa, `icon check` and `icon normalize` (with and without --ocr) must report the structured PNG failure + from ctshowcase.pnglint import validate_png + import binascii + import struct + card = brand.style(NW)["tokens"]["card_fill"] + good_data = C.icon_bytes(C.normalize_icon(_candidate(card, ink=(0, 90, 221)), "transparent", card)[0])[0] + + def chunk(kind, body): + return struct.pack(">I", len(body)) + kind + body + struct.pack(">I", binascii.crc32(kind + body) & 0xFFFFFFFF) + # the last 12 bytes of a PNG are the IEND chunk: insert a zero-length, CRC-valid iCCP just before it, so the + # pixel payload is byte-identical to the valid control + assert good_data[-12:] == chunk(b"IEND", b"") + data = good_data[:-12] + chunk(b"iCCP", b"") + good_data[-12:] + assert data != good_data and len(data) == len(good_data) + 12 + validate_png(good_data) + validate_png(data) + monkeypatch.setattr(C, "_tesseract", lambda: None) + for ocr in (False, True): + qa = C.icon_qa(data, card=card, ocr=ocr, source="s.png") + assert qa["result"] == "fail" and qa["logs"] == ["unreadable-image:s.png"], qa + assert any(c["check"] == "png" and c["result"] == "fail" for c in qa["checks"]), qa + meta = tmp_path / "meta.json" + meta.write_text(json.dumps(meta_dict()), encoding="utf-8") + bad = tmp_path / "bad.png" + bad.write_bytes(data) + art = ("--art-direction", FIX / "ART_DIRECTION.min.md") + base = ("--meta", meta, "--no-repo-dir", "content", "icon") + for extra in ((), ("--ocr",)): + code, res = run_content(capsys, *base, "check", "--out", bad, *extra, *art, *NO_ESTATE) + assert code == 1 and res["result"] == "fail" and f"unreadable-image:{bad}" in res["logs"], (extra, res) + assert any(c["check"] == "png" and c["result"] == "fail" for c in res["qa"]["checks"]), res + out = tmp_path / "out" / "icon.png" + out.parent.mkdir() + sentinel = b"PRE-EXISTING-DEST-BYTES\x00\x01\xff" + out.write_bytes(sentinel) + for extra in ((), ("--ocr",)): + code, res = run_content(capsys, *base, "normalize", "--in", bad, "--out", out, *extra, *art, *NO_ESTATE) + assert code == 1 and res["result"] == "fail" and res["out"] is None, (extra, res) + assert isinstance(res["logs"], list) and res["logs"].count(f"unreadable-image:{bad}") == 1, (extra, res) + assert out.read_bytes() == sentinel, extra + assert [p.name for p in out.parent.iterdir()] == ["icon.png"], extra + code, res = run_content(capsys, *base, "normalize", "--in", tmp_path / "missing.png", "--out", out, *art, *NO_ESTATE) + assert code == 2, res + assert out.read_bytes() == sentinel + # valid control: the SAME bytes without the inserted iCCP chunk pass the API and every CLI path with exit 0 + for ocr in (False, True): + qa = C.icon_qa(good_data, card=card, ocr=ocr, source="g.png") + assert qa["result"] in ("pass", "warn") and not any(log.startswith("unreadable-image") for log in qa.get("logs", [])), qa + assert not any(c["result"] == "fail" for c in qa["checks"]), qa + good = tmp_path / "good.png" + good.write_bytes(good_data) + for extra in ((), ("--ocr",)): + code, res = run_content(capsys, *base, "check", "--out", good, *extra, *art, *NO_ESTATE) + assert code == 0 and res["result"] in ("pass", "warn"), (extra, res) + assert not any(c["result"] == "fail" for c in res["qa"]["checks"]), res + good_out = out.parent / "good-norm.png" + for extra in ((), ("--ocr",)): + good_out.unlink(missing_ok=True) + code, res = run_content(capsys, *base, "normalize", "--in", good, "--out", good_out, *extra, *art, *NO_ESTATE) + assert code == 0 and res["result"] in ("pass", "warn") and good_out.is_file(), (extra, res)