diff --git a/skills/ainotate/references/spec.md b/skills/ainotate/references/spec.md index e021477..4aaf10c 100644 --- a/skills/ainotate/references/spec.md +++ b/skills/ainotate/references/spec.md @@ -142,6 +142,8 @@ the target rects and label boxes without saving). Every command has `--help` wit Steps file for `guide`: `[{"image": "1.png", "text": "Open Settings", "alt": "..."}]` or `{"title": "...", "intro": "...", "steps": [...]}`; relative image paths resolve next to it. +A step's optional `"title"` is a short bold summary next to its number; `"text"` then +explains it below, and can use `**bold**`, `` `code` `` and lines starting with `- ` as a list. ## Output diff --git a/src/ainotate/export.py b/src/ainotate/export.py index 03cdf2c..3b0c703 100644 --- a/src/ainotate/export.py +++ b/src/ainotate/export.py @@ -111,17 +111,34 @@ def _norm_steps(steps) -> list[dict]: if not img.is_file(): raise ExportError(f"step {i}: image not found: {img}", f"steps[{i - 1}].image") text = str(s.get("text", "")).strip() - alt = str(s.get("alt") or "").strip() or (text.replace("\n", " ") or f"Step {i}") - out.append({"image": img, "text": text, "alt": alt}) + title = str(s.get("title", "")).strip() + alt = str(s.get("alt") or "").strip() or (title or text.replace("\n", " ") or f"Step {i}") + out.append({"image": img, "title": title, "text": text, "alt": alt}) return out +def _md_text(raw: str) -> str: + """Blank line after a "- " list so the next sentence is not read as part of its last item.""" + out, prev = [], False + for line in raw.split("\n"): + item = line.strip().startswith(("- ", "* ")) + if prev and not item and line.strip(): + out.append("") + out.append(line) + prev = item + return "\n".join(out) + + def _md(title, intro, steps, image_names) -> str: lines = [f"# {title}", ""] if title else [] if intro: lines += [intro.strip(), ""] for i, (s, name) in enumerate(zip(steps, image_names), 1): - text = s["text"].replace("\n", "\n ") or f"Step {i}" + text = _md_text(s["text"]).replace("\n", "\n ").replace("\n \n", "\n\n") or f"Step {i}" + if s.get("title"): + lines += [f"{i}. **{s['title']}**", ""] + ([f" {text}", ""] if s["text"] else []) + lines += [f" ![{s['alt'].replace(']', ')').replace('[', '(')}]({name})", ""] + continue lines += [f"{i}. {text}", "", f" ![{s['alt'].replace(']', ')').replace('[', '(')}]({name})", ""] return "\n".join(lines).rstrip() + "\n" @@ -164,7 +181,8 @@ def _same_width(st: list[dict], width: Optional[int], tmp: Path) -> list[dict]: def guide(steps: list, title: str = "", out_dir=None, formats: Sequence[str] = ("md", "html"), intro: str = "", *, name: str = "", draft: bool = False, width: Optional[int] = None) -> GuideResult: - """Build a step-by-step guide. steps: [{"image": path, "text": "...", "alt": optional}]. + """Build a step-by-step guide. steps: [{"image": path, "text": "...", "title": optional, "alt": optional}]. + title is a short bold summary shown above the text. Writes into out_dir (default: a new dir ` ` in the AInotate output dir, or in a temp dir when `draft`) and returns {format: Path}. md: relative images copied to `images/`; html: one self-contained file; pdf: Playwright print of the HTML (A4) diff --git a/src/ainotate/export_html.py b/src/ainotate/export_html.py index 769f399..3dc5a36 100644 --- a/src/ainotate/export_html.py +++ b/src/ainotate/export_html.py @@ -4,6 +4,7 @@ import base64 import html import mimetypes +import re from pathlib import Path from PIL import Image, ImageDraw @@ -15,33 +16,109 @@ @media (prefers-color-scheme:dark){:root{--bg:#1c1c1e;--fg:#f5f5f7;--muted:#98989d;--accent:#2f8cff;--line:#3a3a3c;--shadow:0 2px 6px rgba(0,0,0,.5),0 10px 30px rgba(0,0,0,.5)}} *{box-sizing:border-box} body{margin:0;background:var(--bg);color:var(--fg);font:17px/1.55 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;-webkit-font-smoothing:antialiased} -main{max-width:860px;margin:0 auto;padding:56px 24px 80px} +main{max-width:1100px;margin:0 auto;padding:56px 24px 80px} h1{font-size:2.1rem;line-height:1.2;letter-spacing:-.02em;margin:0 0 12px} .intro{color:var(--muted);font-size:1.1rem;margin:0 0 40px} section.step{margin:0 0 48px;break-inside:avoid;page-break-inside:avoid} -h2{display:flex;gap:14px;align-items:flex-start;font-size:1.3rem;line-height:1.35;font-weight:600;margin:0 0 16px;letter-spacing:-.01em} -h2 .n{flex:none;min-width:2rem;height:2rem;border-radius:1rem;background:var(--accent);color:#fff;font-size:1rem;font-weight:700;display:inline-flex;align-items:center;justify-content:center;margin-top:.05rem} +h2{display:flex;gap:14px;align-items:flex-start;font-size:1.15rem;line-height:28px;font-weight:400;margin:0 0 16px;letter-spacing:-.01em} +h2 .n{flex:none;min-width:28px;height:28px;border-radius:14px;background:var(--accent);color:#fff;font-size:1rem;font-weight:700;display:inline-flex;align-items:center;justify-content:center} img{display:block;max-width:100%;height:auto;border-radius:12px;box-shadow:var(--shadow);border:1px solid var(--line)} +h1,.intro,h2{max-width:860px} +section.step img{margin-left:42px;max-width:calc(100% - 42px)} +h2 .t{font-weight:600} +.say{max-width:818px;margin:-8px 0 16px 42px;line-height:1.6} +.say p{margin:0 0 8px} +.say ul{margin:0 0 8px;padding-left:22px} +.say li{margin:2px 0} +code{font:.9em/1.4 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;background:var(--line);padding:1px 5px;border-radius:4px} +img.zoomable{cursor:zoom-in} +#zoom{position:fixed;inset:0;z-index:10;display:flex;overflow:hidden;padding:24px;background:rgba(0,0,0,.7);cursor:zoom-out;opacity:0;visibility:hidden;transition:opacity .2s ease,visibility 0s .2s} +#zoom.open{opacity:1;visibility:visible;transition:opacity .2s ease} +#zoom img{max-width:100%;max-height:100%;width:auto;height:auto;object-fit:contain;margin:auto;box-shadow:0 10px 40px rgba(0,0,0,.5);transform:scale(.96);transition:transform .2s ease} +#zoom.open img{transform:none} +@media (prefers-reduced-motion:reduce){#zoom,#zoom img{transition:none}} @page{size:A4;margin:16mm} -@media print{body{background:#fff;color:#000;font-size:12pt}main{max-width:none;padding:0}img{box-shadow:none;border:1px solid #ccc;border-radius:6px}section.step{margin-bottom:22pt}h2 .n{-webkit-print-color-adjust:exact;print-color-adjust:exact}} +@media print{#zoom{display:none}body{background:#fff;color:#000;font-size:12pt}main{max-width:none;padding:0}img{box-shadow:none;border:1px solid #ccc;border-radius:6px}section.step{margin-bottom:22pt}section.step img{margin-left:0;max-width:100%}h2 .n{-webkit-print-color-adjust:exact;print-color-adjust:exact}} """ +# Click a step image shown smaller than its real size to see it as large as the window allows; click or Esc closes. +_ZOOM_JS = ( + "const z=document.getElementById('zoom');" + "const mark=()=>document.querySelectorAll('section.step img').forEach(i=>" + "i.classList.toggle('zoomable',i.naturalWidth>i.clientWidth+4));" + "addEventListener('load',mark);addEventListener('resize',mark);" + "addEventListener('click',e=>{if(e.target.closest('#zoom')){z.classList.remove('open');return}" + "const i=e.target.closest('img.zoomable');if(i){z.firstChild.src=i.src;z.classList.add('open')}});" + "addEventListener('keydown',e=>{if(e.key==='Escape')z.classList.remove('open')});" +) + + +def _inline(raw: str) -> str: + """Escape, then **bold** and `code`.""" + parts = re.split(r"(`[^`\n]+`)", raw) + out = [] + for k, part in enumerate(parts): + if k % 2: + out.append(f"{html.escape(part[1:-1])}") + else: + out.append(re.sub(r"\*\*(.+?)\*\*", r"\1", html.escape(part))) + return "".join(out) + + +def _rich(raw: str) -> str: + """Step explanation: inline marks, and lines starting with "- " become a list.""" + blocks, para, items = [], [], [] + def flush(): + if para: + blocks.append(f"

{'
'.join(para)}

") + para.clear() + if items: + blocks.append(f"
    {''.join(f'
  • {i}
  • ' for i in items)}
") + items.clear() + for line in raw.split("\n"): + s = line.strip() + if s.startswith(("- ", "* ")): + if para: + flush() + items.append(_inline(s[2:])) + elif not s: + flush() + else: + if items: + flush() + para.append(_inline(s)) + flush() + return "".join(blocks) + + +def _plain(raw: str) -> str: + """Text for the Pillow PDF: drop the marks, bullets for list lines.""" + raw = re.sub(r"\*\*(.+?)\*\*", r"\1", raw).replace("`", "") + return re.sub(r"(?m)^\s*[-*] ", "• ", raw) + def _html(title, intro, steps) -> str: parts = [] for i, s in enumerate(steps, 1): mime = mimetypes.guess_type(str(s["image"]))[0] or "image/png" b64 = base64.b64encode(s["image"].read_bytes()).decode("ascii") - text = html.escape(s["text"]).replace("\n", "
") or f"Step {i}" + title = _inline(s.get("title", "")) + if title: + head_html = f'{title}' + body = f'
{_rich(s["text"])}
' if s["text"] else "" + else: + text = "
".join(_inline(ln) for ln in s["text"].split("\n")) + head_html, body = f"{text or f'Step {i}'}", "" parts.append( - f'

{i}{text}

' + f'

{i}{head_html}

{body}' f'{html.escape(s[
') head = f"

{html.escape(title)}

" if title else "" lead = f'

{html.escape(intro).replace(chr(10), "
")}

' if intro else "" return (f'' f'' f'{html.escape(title or "Guide")}' - f'
{head}{lead}{"".join(parts)}
') + f'
{head}{lead}{"".join(parts)}
' + f'
') def _pdf_playwright(html_path: Path, pdf_path: Path) -> None: @@ -131,7 +208,7 @@ def text(lines, font, step, x, fill): d.text((M + r, y0 + r), str(i), font=style.load_font(26), fill="white", anchor="mm") tf = style.load_font(36) st["y"] = y0 + 4 - text(_wrap(d, s["text"] or f"Step {i}", tf, W - 2 * M - 2 * r - 20), tf, 46, M + 2 * r + 20, + text(_wrap(d, _plain("\n".join(x for x in (s.get("title", ""), s["text"]) if x)) or f"Step {i}", tf, W - 2 * M - 2 * r - 20), tf, 46, M + 2 * r + 20, (29, 29, 31)) y = max(st["y"], y0 + 2 * r if st["d"] is d else 0) + 28 im = _rgb(_open(s["image"])) diff --git a/tests/test_export.py b/tests/test_export.py index 2c3da90..6dea5ee 100644 --- a/tests/test_export.py +++ b/tests/test_export.py @@ -287,3 +287,32 @@ def save_spy(self, fp, format=None, **kw): assert (tmp_path / "x.pdf").read_bytes()[:5] == b"%PDF-" assert len(pasted) == 2 and all(h >= export_html.MIN_IMAGE_H for _, h in pasted) assert pages[0] >= 3 # text spilled over a page, image on its own page, step 2 + + +def test_guide_step_title_shows_bold_summary_above_text(tmp_path): + from PIL import Image + from ainotate.export import guide + img = tmp_path / "a.png" + Image.new("RGB", (40, 30), "white").save(img) + res = guide([{"image": str(img), "title": "Deploy a model", "text": "Pick one and press Deploy."}], + "T", tmp_path / "out", formats=("md", "html")) + h = res["html"].read_text() + assert 'Deploy a model' in h and '

Pick one and press Deploy.

' in h + md = res["md"].read_text() + assert "1. **Deploy a model**" in md and " Pick one and press Deploy." in md + + +def test_guide_step_text_supports_bold_code_and_sublists(tmp_path): + from PIL import Image + from ainotate.export import guide + img = tmp_path / "a.png" + Image.new("RGB", (40, 30), "white").save(img) + text = "Run `ainotate doctor` first.\n- **Mac:** use brew\n- Linux: \nDone." + res = guide([{"image": str(img), "title": "Install", "text": text}], "T", tmp_path / "out", + formats=("md", "html", "pdf")) + h = res["html"].read_text() + assert "ainotate doctor" in h + assert "
  • Mac: use brew
  • Linux: <apt>
" in h + md = res["md"].read_text() + assert " - **Mac:** use brew" in md and "<apt>

Done.

" in h and "apt>\n\n Done." in md + assert res["pdf"].stat().st_size > 0