#!/usr/bin/env python3
"""PageHawk compare/diff — a fast, PRIVATE, Draftable-parity redline between two documents.

On-device only (no network). Two engines, one front door (build_compare):

VISUAL (default when geometry is available): both documents' REAL pages are rendered
(poppler pdftoppm) and every change is marked in place on those pages — per-word
geometry from `pdftotext -bbox-layout` (same library, same PDF-point coordinate space
as the rendered images), a token-stream diff, overlay rectangles as page-relative
percentages, lockstep scrolling aligned on matched equal-blocks, a change minimap and
a filterable change list. The viewer is pure DOM (images + positioned divs) — no
pdf.js, so it behaves identically in WKWebView and any browser. Word/Office docs are
converted to PDF first (LibreOffice, like Draftable); scans go through the on-device
OCR cache so geometry and pixels come from the same text-layered file.

TEXT (fallback): the classic reflowed-text redline (pdftotext / textutil extraction,
paragraph alignment, word-level ins/del) when page geometry isn't available — plain
text files, no converter, extraction failure. Both engines share the Inline redline,
the Departures summary, and the native tracked-changes DOCX export.

Used by triage_app.py --mode compare (two paths).
"""
import os, re, sys, html, difflib, subprocess, json, time


def _ocr_pdf(path, timeout=240):
    """OCR a scanned/no-text-layer PDF on-device (ocrmypdf + tesseract), cached by
    path+mtime so it is a one-time cost. Returns '' if OCR is unavailable/fails."""
    import tempfile, hashlib
    try:
        key = hashlib.sha1((os.path.abspath(path) + str(int(os.path.getmtime(path)))).encode()).hexdigest()[:16]
        cdir = os.path.expanduser("~/Triage Review and Compare/.ocrcache"); os.makedirs(cdir, exist_ok=True)
        cf = os.path.join(cdir, key + ".txt")
        if os.path.exists(cf):
            return open(cf, encoding="utf-8", errors="ignore").read()
        tmp = tempfile.mktemp(suffix=".pdf")
        r = subprocess.run(["ocrmypdf", "--force-ocr", "--quiet", path, tmp], capture_output=True, timeout=timeout)
        if r.returncode == 0 and os.path.exists(tmp):
            t = subprocess.run(["pdftotext", "-q", "-nopgbrk", tmp, "-"], capture_output=True, timeout=60).stdout.decode("utf-8", "ignore")
            open(cf, "w", encoding="utf-8").write(t)
            try: os.remove(tmp)
            except OSError: pass
            return t
    except Exception:
        pass
    return ""


def extract(path):
    ext = path.lower().rsplit(".", 1)[-1] if "." in path else ""
    try:
        if ext == "pdf":
            r = subprocess.run(["pdftotext", "-q", "-nopgbrk", path, "-"], capture_output=True, timeout=120)
            t = r.stdout.decode("utf-8", "ignore")
            if len(t.strip()) < 120:        # scanned / no text layer -> OCR on-device
                t = _ocr_pdf(path) or t
            return t
        if ext in ("doc", "docx", "rtf", "odt", "html", "htm", "wpd", "pages", "webarchive"):
            r = subprocess.run(["textutil", "-convert", "txt", "-stdout", path], capture_output=True, timeout=120)
            return r.stdout.decode("utf-8", "ignore")
        if ext in ("txt", "md", "markdown", "csv", "tsv", "log", "json", "xml", "py", "js", "swift"):
            return open(path, encoding="utf-8", errors="ignore").read()
    except Exception:
        return ""
    try:
        r = subprocess.run(["textutil", "-convert", "txt", "-stdout", path], capture_output=True, timeout=120)
        return r.stdout.decode("utf-8", "ignore")
    except Exception:
        return ""


def esc(s):
    return html.escape(s)


def split_paras(text):
    text = re.sub(r"[ \t]+", " ", text)
    paras = [re.sub(r"\s*\n\s*", " ", p).strip() for p in re.split(r"\n\s*\n", text)]
    paras = [p for p in paras if p]
    if len(paras) < 3:                       # no paragraph structure -> fall back to lines
        paras = [l.strip() for l in text.split("\n") if l.strip()]
    return paras or [""]


def word_redline(a, b):
    """Inline word-level redline of two paragraphs -> (left_html, right_html, ins, del)."""
    aw, bw = a.split(" "), b.split(" ")
    sm = difflib.SequenceMatcher(None, aw, bw, autojunk=False)
    L, R, ni, nd = [], [], 0, 0
    for tag, i1, i2, j1, j2 in sm.get_opcodes():
        if tag == "equal":
            seg = esc(" ".join(aw[i1:i2])); L.append(seg + " "); R.append(seg + " ")
        elif tag == "delete":
            L.append('<span class="d">' + esc(" ".join(aw[i1:i2])) + "</span> "); nd += i2 - i1
        elif tag == "insert":
            R.append('<span class="i">' + esc(" ".join(bw[j1:j2])) + "</span> "); ni += j2 - j1
        elif tag == "replace":
            L.append('<span class="d">' + esc(" ".join(aw[i1:i2])) + "</span> ")
            R.append('<span class="i">' + esc(" ".join(bw[j1:j2])) + "</span> ")
            nd += i2 - i1; ni += j2 - j1
    return "".join(L), "".join(R), ni, nd


def _sim(a, b):
    """Fast word-level similarity in [0,1] (Jaccard over lowercased tokens)."""
    if a == b:
        return 1.0
    sa, sb = set(a.lower().split()), set(b.lower().split())
    if not sa or not sb:
        return 0.0
    inter = len(sa & sb)
    return inter / float(len(sa) + len(sb) - inter)


def align_block(la, lb, thresh=0.35, max_cells=4000):
    """Align two lists of paragraphs (a difflib 'replace' block) by SIMILARITY, not by
    position. This is the Draftable-class trick: a single inserted/removed paragraph no
    longer misaligns every paragraph after it into garbage redlines. Returns an ordered
    list of ops: ('chg', a, b) | ('del', a, None) | ('ins', None, b), monotonic in both."""
    n, m = len(la), len(lb)
    if n == 0:
        return [("ins", None, x) for x in lb]
    if m == 0:
        return [("del", x, None) for x in la]
    if n * m > max_cells:                       # huge block -> cheap positional fallback
        p = min(n, m)
        ops = [("chg", la[k], lb[k]) for k in range(p)]
        ops += [("del", la[k], None) for k in range(p, n)]
        ops += [("ins", None, lb[k]) for k in range(p, m)]
        return ops
    cands = []
    for i in range(n):
        for j in range(m):
            s = _sim(la[i], lb[j])
            if s >= thresh:
                cands.append((s, i, j))
    cands.sort(reverse=True)
    used_i, used_j, matched = set(), set(), []
    for s, i, j in cands:                        # greedy best-first, kept monotonic (no crossings)
        if i in used_i or j in used_j:
            continue
        if all((i - mi) * (j - mj) > 0 for mi, mj in matched):
            used_i.add(i); used_j.add(j); matched.append((i, j))
    matched.sort()
    ops, pi, pj = [], 0, 0
    for (i, j) in matched:                        # emit gaps before each anchor, then the pair
        ops += [("del", la[k], None) for k in range(pi, i)]
        ops += [("ins", None, lb[k]) for k in range(pj, j)]
        ops.append(("chg", la[i], lb[j])); pi, pj = i + 1, j + 1
    ops += [("del", la[k], None) for k in range(pi, n)]
    ops += [("ins", None, lb[k]) for k in range(pj, m)]
    return ops


def _xml(s):
    return s.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace('"', "&quot;")


def word_runs(a, b):
    """Word-level diff of two paragraphs as ordered (kind, text) runs for a Word document."""
    aw, bw = a.split(" "), b.split(" ")
    sm = difflib.SequenceMatcher(None, aw, bw, autojunk=False)
    runs = []
    for tag, i1, i2, j1, j2 in sm.get_opcodes():
        if tag == "equal":
            runs.append(("eq", " ".join(aw[i1:i2])))
        elif tag == "delete":
            runs.append(("del", " ".join(aw[i1:i2])))
        elif tag == "insert":
            runs.append(("ins", " ".join(bw[j1:j2])))
        elif tag == "replace":
            runs.append(("del", " ".join(aw[i1:i2])))
            runs.append(("ins", " ".join(bw[j1:j2])))
    return runs


def build_tracked_docx(doc_paras, a_name, b_name, out_path):
    """Write a real Word .docx with native TRACKED CHANGES (w:ins / w:del) — the format
    legal/diligence teams actually work in — entirely on-device (stdlib zipfile, no Word)."""
    import zipfile
    date = time.strftime("%Y-%m-%dT%H:%M:%SZ")
    cnt = [0]
    def rid():
        cnt[0] += 1; return cnt[0]
    def r_norm(t):
        return '<w:r><w:t xml:space="preserve">%s</w:t></w:r>' % _xml(t) if t else ""
    def r_ins(t):
        if not t.strip():
            return r_norm(t)
        return ('<w:ins w:id="%d" w:author="PageHawk" w:date="%s"><w:r><w:t xml:space="preserve">%s</w:t></w:r></w:ins>'
                % (rid(), date, _xml(t)))
    def r_del(t):
        if not t.strip():
            return ""
        return ('<w:del w:id="%d" w:author="PageHawk" w:date="%s"><w:r><w:delText xml:space="preserve">%s</w:delText></w:r></w:del>'
                % (rid(), date, _xml(t)))
    body = ['<w:p><w:pPr><w:rPr><w:b/><w:sz w:val="30"/></w:rPr></w:pPr><w:r><w:rPr><w:b/><w:sz w:val="30"/></w:rPr>'
            '<w:t xml:space="preserve">Redline: %s &#8594; %s</w:t></w:r></w:p>' % (_xml(a_name), _xml(b_name)),
            '<w:p><w:r><w:rPr><w:i/><w:color w:val="888888"/></w:rPr>'
            '<w:t xml:space="preserve">Generated on your Mac by PageHawk. Tracked changes shown below; nothing was uploaded.</w:t></w:r></w:p>']
    for runs in doc_paras:
        parts = []
        for kind, text in runs:
            seg = text + " "
            parts.append(r_norm(seg) if kind == "eq" else r_ins(seg) if kind == "ins" else r_del(seg))
        body.append("<w:p>" + "".join(parts) + "</w:p>")
    document = ('<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
                '<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">'
                '<w:body>' + "".join(body) + '<w:sectPr/></w:body></w:document>')
    ctypes = ('<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
              '<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">'
              '<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>'
              '<Default Extension="xml" ContentType="application/xml"/>'
              '<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>'
              '</Types>')
    rels = ('<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
            '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
            '<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/>'
            '</Relationships>')
    with zipfile.ZipFile(out_path, "w", zipfile.ZIP_DEFLATED) as z:
        z.writestr("[Content_Types].xml", ctypes)
        z.writestr("_rels/.rels", rels)
        z.writestr("word/document.xml", document)
    return out_path


def _safe_name(n):
    return re.sub(r"[^A-Za-z0-9._-]+", "_", os.path.splitext(n)[0])[:50] or "doc"


def _text_payload(a, b):
    """The reflowed-TEXT diff payload (side-by-side rows, unified inline redline, change
    list, tracked-changes DOCX). Powers the classic view AND the Inline/Summary/Export
    surfaces of the visual compare."""
    PA, PB = split_paras(extract(a)), split_paras(extract(b))
    sm = difflib.SequenceMatcher(None, PA, PB, autojunk=False)
    rows, uni, doc_paras = [], [], []
    ins = dels = changes = 0
    for tag, i1, i2, j1, j2 in sm.get_opcodes():
        if tag == "equal":
            for k in range(i1, i2):
                p = esc(PA[k]); rows.append(("same", p, p)); uni.append("<p>" + p + "</p>"); doc_paras.append([("eq", PA[k])])
        elif tag == "delete":
            for k in range(i1, i2):
                d = '<span class="d">' + esc(PA[k]) + "</span>"; rows.append(("del", d, "")); uni.append("<p>" + d + "</p>"); doc_paras.append([("del", PA[k])]); dels += 1; changes += 1
        elif tag == "insert":
            for k in range(j1, j2):
                ic = '<span class="i">' + esc(PB[k]) + "</span>"; rows.append(("ins", "", ic)); uni.append("<p>" + ic + "</p>"); doc_paras.append([("ins", PB[k])]); ins += 1; changes += 1
        elif tag == "replace":
            for kind, pa, pb in align_block(PA[i1:i2], PB[j1:j2]):
                if kind == "chg":
                    l, r, ni, nd = word_redline(pa, pb)
                    if ni == 0 and nd == 0:           # aligned pair turned out identical -> not a change
                        p = esc(pa); rows.append(("same", p, p)); uni.append("<p>" + p + "</p>"); doc_paras.append([("eq", pa)])
                    else:
                        rows.append(("chg", l, r)); uni.append("<p>" + l + " " + r + "</p>"); doc_paras.append(word_runs(pa, pb)); ins += ni; dels += nd; changes += 1
                elif kind == "del":
                    d = '<span class="d">' + esc(pa) + "</span>"; rows.append(("del", d, "")); uni.append("<p>" + d + "</p>"); doc_paras.append([("del", pa)]); dels += 1; changes += 1
                else:
                    ic = '<span class="i">' + esc(pb) + "</span>"; rows.append(("ins", "", ic)); uni.append("<p>" + ic + "</p>"); doc_paras.append([("ins", pb)]); ins += 1; changes += 1
    # tag each changed row with a stable change index + type + short preview (sidebar / minimap)
    _LABEL = {"del": "Deletion", "ins": "Insertion", "chg": "Revision"}
    rows_html, chlist, ci = [], [], 0
    for t, l, r in rows:
        if t == "same":
            rows_html.append(f'<div class="row same"><div class="cell l">{l or ""}</div><div class="cell r">{r or ""}</div></div>')
            continue
        ci += 1
        prev = re.sub("<[^>]+>", "", (r or l or "")).strip()
        prev = (prev[:90] + "…") if len(prev) > 90 else prev
        rows_html.append(
            f'<div class="row {t}" id="c{ci}" data-chg="1" data-ct="{t}" data-ci="{ci}">'
            f'<div class="cell l">{l or ""}</div><div class="cell r">{r or ""}</div></div>')
        chlist.append({"i": ci, "t": t, "label": _LABEL[t], "prev": prev})
    rows_html = "".join(rows_html)
    pct = round(100 * changes / max(1, len(rows)), 1)
    ndel = sum(1 for c in chlist if c["t"] == "del")
    nins = sum(1 for c in chlist if c["t"] == "ins")
    nchg = sum(1 for c in chlist if c["t"] == "chg")
    an, bn = os.path.basename(a), os.path.basename(b)
    # also write an editable Word tracked-changes redline next to the reviews (the legal/diligence currency)
    docx_path = ""
    try:
        ddir = os.path.expanduser("~/Triage Review and Compare/PageHawk Compares")
        os.makedirs(ddir, exist_ok=True)
        docx_path = os.path.join(ddir, "%s_vs_%s_redline.docx" % (_safe_name(an), _safe_name(bn)))
        build_tracked_docx(doc_paras, an, bn, docx_path)
    except Exception:
        docx_path = ""
    return {"a": an, "b": bn, "rows": rows_html,
            "uni": "".join(uni), "ins": ins, "dels": dels, "changes": changes, "pct": pct,
            "chlist": chlist, "ndel": ndel, "nins": nins, "nchg": nchg, "docx": docx_path,
            "blocks": len(rows), "built": time.strftime("%Y-%m-%d %H:%M")}


def build_text_compare(paths, out_path, task=None):
    """Classic reflowed-text compare — the fallback when page geometry isn't available."""
    data = _text_payload(paths[0], paths[1])
    with open(out_path, "w", encoding="utf-8") as f:
        f.write(TEMPLATE.replace("/*DATA*/", json.dumps(data)))
    print(f"PageHawk compare (text): {data['a']} ↔ {data['b']} — {data['changes']} changed blocks "
          f"({data['ins']} word-ins / {data['dels']} word-del)")
    return {"out": out_path, "kb": os.path.getsize(out_path) / 1024.0, "note": f"{data['changes']} changes"}


# ═══════════════ Visual (Draftable-parity) compare ═══════════════
# Real rendered pages + changes marked in place. All geometry and pixels come from
# poppler (pdftotext -bbox-layout / pdftoppm) so word rects and page images share one
# PDF-point coordinate space; overlays are emitted as page-relative PERCENTAGES so the
# viewer needs no pixel math at any zoom. On-device only.

_CACHE_ROOT = os.path.expanduser("~/Triage Review and Compare")
_MAX_VIS_PAGES = 400          # beyond this, fall back to the text redline


def _bin(name):
    """Resolve a tool that may not be on a GUI app's minimal PATH."""
    import shutil
    p = shutil.which(name)
    if p:
        return p
    for d in ("/opt/homebrew/bin", "/usr/local/bin"):
        c = os.path.join(d, name)
        if os.path.exists(c):
            return c
    return name


def _key(path, extra=""):
    import hashlib
    return hashlib.sha1((os.path.abspath(path) + "|" + str(int(os.path.getmtime(path))) + "|" + extra)
                        .encode()).hexdigest()[:16]


def _fu(p):
    import urllib.parse
    return "file://" + urllib.parse.quote(os.path.abspath(p))


def _pdf_for_visual(path):
    """A PDF rendering of `path` for the visual compare: the file itself if PDF, else a
    cached LibreOffice conversion (what Draftable does too). None -> no geometry (text mode)."""
    ext = path.lower().rsplit(".", 1)[-1] if "." in path else ""
    if ext == "pdf":
        return path
    if ext not in ("doc", "docx", "rtf", "odt", "pages", "ppt", "pptx", "key", "html", "htm"):
        return None
    import shutil
    so = next((c for c in (shutil.which("soffice"), "/Applications/LibreOffice.app/Contents/MacOS/soffice")
               if c and os.path.exists(c)), None)
    if not so:
        return None
    pdir = os.path.join(_CACHE_ROOT, ".cmppdf", _key(path))
    out = os.path.join(pdir, "doc.pdf")
    if os.path.exists(out):
        return out
    os.makedirs(pdir, exist_ok=True)
    try:
        subprocess.run([so, "--headless", "--convert-to", "pdf", "--outdir", pdir, path],
                       stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=240)
    except Exception:
        return None
    import glob
    cands = glob.glob(pdir + "/*.pdf")
    if not cands:
        return None
    if cands[0] != out:
        try:
            os.replace(cands[0], out)
        except OSError:
            out = cands[0]
    return out


def _ocr_pdf_file(path, timeout=300):
    """OCR a scanned PDF into a TEXT-LAYERED PDF (cached by path+mtime). The visual compare
    then takes BOTH geometry and page pixels from the OCR'd file, so overlays stay aligned."""
    try:
        cdir = os.path.join(_CACHE_ROOT, ".ocrcache")
        os.makedirs(cdir, exist_ok=True)
        cf = os.path.join(cdir, _key(path) + ".pdf")
        if os.path.exists(cf):
            return cf
        r = subprocess.run([_bin("ocrmypdf"), "--force-ocr", "--quiet", path, cf],
                           capture_output=True, timeout=timeout)
        if r.returncode == 0 and os.path.exists(cf):
            return cf
    except Exception:
        pass
    return None


def extract_tokens(pdf, max_pages=_MAX_VIS_PAGES):
    """Per-word positioned tokens via poppler `pdftotext -bbox-layout` (PDF points,
    top-left origin). Returns {"pages": [{"w","h"}], "tokens": [{t,p,x,y,w,h,ln}]} or None."""
    try:
        r = subprocess.run([_bin("pdftotext"), "-bbox-layout", pdf, "-"], capture_output=True, timeout=180)
        raw = r.stdout.decode("utf-8", "ignore")
    except Exception:
        return None
    if "<page" not in raw:
        return None
    raw = re.sub(r"[\x00-\x08\x0b\x0c\x0e-\x1f]", "", raw)      # strip XML-invalid control chars
    import xml.etree.ElementTree as ET
    try:
        root = ET.fromstring(raw)
    except Exception:
        return None
    NS = "{http://www.w3.org/1999/xhtml}"
    pages, tokens, ln = [], [], 0
    for pg in root.iter(NS + "page"):
        pi = len(pages)
        if pi >= max_pages:
            return None                                          # too big for visual -> text fallback
        pages.append({"w": float(pg.get("width")), "h": float(pg.get("height"))})
        for line in pg.iter(NS + "line"):
            ln += 1
            for w in line.iter(NS + "word"):
                t = (w.text or "").strip()
                if not t:
                    continue
                x0, y0 = float(w.get("xMin")), float(w.get("yMin"))
                x1, y1 = float(w.get("xMax")), float(w.get("yMax"))
                tokens.append({"t": t, "p": pi, "x": x0, "y": y0, "w": x1 - x0, "h": y1 - y0, "ln": ln})
    if not pages or not tokens:
        return None
    return {"pages": pages, "tokens": tokens}


def _render_cmp_pages(pdf, max_pages=_MAX_VIS_PAGES, dpi=144):
    """Render every page to PNG (cached by content key). 144dpi = 2x crisp at fit-width."""
    import glob
    pdir = os.path.join(_CACHE_ROOT, ".cmppages", _key(pdf, "r%d" % dpi))
    if not (os.path.isdir(pdir) and glob.glob(pdir + "/p-*.png")):
        os.makedirs(pdir, exist_ok=True)
        try:
            subprocess.run([_bin("pdftoppm"), "-png", "-r", str(dpi), "-l", str(max_pages),
                            pdf, os.path.join(pdir, "p")],
                           stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=600)
        except Exception:
            return []
    return sorted(glob.glob(pdir + "/p-*.png"))[:max_pages]


def _rects_pct(toks, pages):
    """Group a change's tokens by (page, line) -> one padded rect per line run, expressed
    as page-relative percentages (the viewer positions overlays with zero pixel math)."""
    groups = {}
    for tk in toks:
        groups.setdefault((tk["p"], tk["ln"]), []).append(tk)
    out = []
    for (pi, _), g in sorted(groups.items()):
        W, H = pages[pi]["w"], pages[pi]["h"]
        x0 = min(t["x"] for t in g) - 1.0
        y0 = min(t["y"] for t in g) - 1.2
        x1 = max(t["x"] + t["w"] for t in g) + 1.0
        y1 = max(t["y"] + t["h"] for t in g) + 1.2
        out.append({"p": pi,
                    "l": round(max(0.0, x0 / W * 100), 3), "t": round(max(0.0, y0 / H * 100), 3),
                    "w": round(min(100.0, (x1 - x0) / W * 100), 3),
                    "h": round(min(100.0, (y1 - y0) / H * 100), 3)})
    return out


def _prev_txt(toks, n=64):
    s = " ".join(t["t"] for t in toks)
    return (s[:n] + "…") if len(s) > n else s


def _diff_tokens(da, db):
    """Diff two positioned token streams. Returns (changes, anchors, word_ins, word_del).
    changes: [{i, t: chg|ins|del, pg, prev, a: [rects], b: [rects]}] — a-side rects mark
    deletions on the original, b-side rects mark insertions on the modified (Draftable's
    model). anchors: [[pgA, fracA, pgB, fracB]] sampled from matched equal runs, the spine
    for lockstep scrolling."""
    A, B = da["tokens"], db["tokens"]
    sm = difflib.SequenceMatcher(None, [t["t"] for t in A], [t["t"] for t in B], autojunk=False)
    segs = [(tag, A[i1:i2], B[j1:j2]) for tag, i1, i2, j1, j2 in sm.get_opcodes()]
    # Draftable-style grouping: changes separated by <=2 equal "glue" tokens merge into
    # one revision (so "1st day of January" vs "15th day of March" is ONE change, not two).
    merged, k = [], 0
    while k < len(segs):
        tag, at, bt = segs[k]
        if tag == "equal":
            merged.append(segs[k]); k += 1
            continue
        cat, cbt = list(at), list(bt)
        k += 1
        while (k + 1 < len(segs) and segs[k][0] == "equal" and len(segs[k][1]) <= 2
               and segs[k + 1][0] != "equal"):
            cat += segs[k][1]; cbt += segs[k][2]
            cat += segs[k + 1][1]; cbt += segs[k + 1][2]
            k += 2
        merged.append(("change", cat, cbt))
    changes, anchors, nins, ndel, ci = [], [], 0, 0, 0
    for tag, at, bt in merged:
        if tag == "equal":
            if len(at) >= 6:                       # sample sync anchors along solid equal runs
                for s in range(0, len(at), 40):
                    wa, wb = at[s], bt[s]
                    anchors.append([wa["p"], round((wa["y"] + wa["h"] / 2) / da["pages"][wa["p"]]["h"], 4),
                                    wb["p"], round((wb["y"] + wb["h"] / 2) / db["pages"][wb["p"]]["h"], 4)])
            continue
        ci += 1
        kind = "chg" if (at and bt) else ("del" if at else "ins")
        nins += len(bt); ndel += len(at)
        if kind == "chg":
            prev = _prev_txt(at, 46) + " → " + _prev_txt(bt, 46)
        else:
            prev = _prev_txt(at if kind == "del" else bt)
        first = (bt or at)[0]
        changes.append({"i": ci, "t": kind, "pg": first["p"] + 1, "prev": prev,
                        "a": _rects_pct(at, da["pages"]), "b": _rects_pct(bt, db["pages"])})
    return changes, anchors, nins, ndel


def build_visual_compare(paths, out_path, task=None):
    """Draftable-parity visual redline. Returns the result dict, or None when geometry
    isn't available (caller falls back to the text redline)."""
    a, b = paths[0], paths[1]
    pa, pb = _pdf_for_visual(a), _pdf_for_visual(b)
    if not (pa and pb):
        return None
    ta, tb = extract_tokens(pa), extract_tokens(pb)
    # Scanned/no-text-layer PDFs: OCR on-device, then take geometry AND pixels from the OCR'd file.
    if (not ta or len(ta["tokens"]) < 15) and pa.lower().endswith(".pdf"):
        p2 = _ocr_pdf_file(pa)
        if p2:
            pa, ta = p2, extract_tokens(p2)
    if (not tb or len(tb["tokens"]) < 15) and pb.lower().endswith(".pdf"):
        p2 = _ocr_pdf_file(pb)
        if p2:
            pb, tb = p2, extract_tokens(p2)
    if not ta or not tb or len(ta["tokens"]) < 3 or len(tb["tokens"]) < 3:
        return None
    imgs_a, imgs_b = _render_cmp_pages(pa), _render_cmp_pages(pb)
    if not imgs_a or not imgs_b:
        return None
    changes, anchors, nins, ndel = _diff_tokens(ta, tb)
    text = _text_payload(a, b)                     # Inline redline + Summary + tracked DOCX
    npages_a = min(len(imgs_a), len(ta["pages"]))
    npages_b = min(len(imgs_b), len(tb["pages"]))
    # Full paths ride along beside the basenames: the pane headers are right-clickable
    # (Reveal in Finder / Open / Copy path) and the Swap button re-runs the compare the
    # other way round, and every one of those needs the real file, not its display name.
    data = {"a": os.path.basename(a), "b": os.path.basename(b),
            "apath": os.path.abspath(a), "bpath": os.path.abspath(b),
            "pa": [{"u": _fu(imgs_a[i]), "w": ta["pages"][i]["w"], "h": ta["pages"][i]["h"]} for i in range(npages_a)],
            "pb": [{"u": _fu(imgs_b[i]), "w": tb["pages"][i]["w"], "h": tb["pages"][i]["h"]} for i in range(npages_b)],
            "ch": changes, "an": anchors,
            "ins": nins, "dels": ndel, "changes": len(changes),
            "nchg": sum(1 for c in changes if c["t"] == "chg"),
            "nins": sum(1 for c in changes if c["t"] == "ins"),
            "ndel": sum(1 for c in changes if c["t"] == "del"),
            "uni": text["uni"], "docx": text["docx"],
            "built": time.strftime("%Y-%m-%d %H:%M")}
    with open(out_path, "w", encoding="utf-8") as f:
        f.write(TEMPLATE_VISUAL.replace("/*DATA*/", json.dumps(data)))
    print(f"PageHawk compare (visual): {data['a']} ↔ {data['b']} — {len(changes)} changes "
          f"(+{nins} / −{ndel} words) on {npages_a}/{npages_b} rendered pages")
    return {"out": out_path, "kb": os.path.getsize(out_path) / 1024.0, "note": f"{len(changes)} changes"}


def build_compare(paths, out_path, task=None):
    """Two-document compare, the front door. Prefers the VISUAL Draftable-parity redline
    (changes marked on the real rendered pages); falls back to the classic reflowed-text
    redline when page geometry isn't available. On-device only — nothing is uploaded."""
    try:
        res = build_visual_compare(paths, out_path, task)
        if res:
            return res
    except Exception as e:
        print("visual compare unavailable (%s: %s); using text redline" % (e.__class__.__name__, e))
    return build_text_compare(paths, out_path, task)


TEMPLATE = r"""<!doctype html><html lang="en" data-theme="light"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>PageHawk Compare</title>
<script>document.documentElement.dataset.theme=new URLSearchParams(location.search).get('theme')||'light';</script>
<style>
/* LIGHT is the default (Jason 2026-07-04, app-wide policy); the Swift shell pushes the user's
 * actual appearance (dataset.theme) on load, and ?theme=dark works standalone. The old
 * hardcoded dark palette lives on under html[data-theme="dark"]. */
:root{--ink:#FBFAF7;--ink2:#F4F2EB;--s2:#F0EDE4;--s3:#E9E5DA;--line:#E3DFD3;--gold:#C8A872;--goldlt:#8A6516;--goldtext:#7E5E10;
 --text:#15171A;--muted:#6A6A6B;--dim:#9A958C;--del:#B3402F;--ins:#2F7A4E;--mov:#3E5C78;
 --delbg:rgba(179,64,47,.09);--insbg:rgba(47,122,78,.10);
 --on-gold:#1a1a1a;--tbar:linear-gradient(#FFFFFF,#F6F3EC);--line-soft:rgba(160,155,140,.35);
 --rowdim:rgba(0,0,0,.014);--vpfill:rgba(20,18,12,.05);--vpline:rgba(20,18,12,.14);
 --serif:'Cambria','Big Caslon','Hoefler Text',Georgia,serif;--sans:-apple-system,'Inter',system-ui,sans-serif}
html[data-theme="dark"]{--ink:#131519;--ink2:#181B1F;--s2:#22262B;--s3:#2B2F35;--line:#2A2D31;--goldlt:#E2C998;--goldtext:#E2C998;
 --text:#F6F1E7;--muted:#9A917E;--dim:#6E6757;--del:#E0796B;--ins:#6CC08A;--mov:#7FA8D9;
 --delbg:rgba(224,121,107,.14);--insbg:rgba(108,192,138,.14);
 --on-gold:#131519;--tbar:linear-gradient(#1F2328,#181B1F);--line-soft:rgba(42,45,49,.55);
 --rowdim:rgba(255,255,255,.012);--vpfill:rgba(246,241,231,.07);--vpline:rgba(246,241,231,.16)}
*{margin:0;padding:0;box-sizing:border-box}
body{background:var(--ink);color:var(--text);font-family:var(--sans);height:100vh;overflow:hidden;font-size:14px;-webkit-font-smoothing:antialiased}
.app{display:flex;flex-direction:column;height:100vh}
.titlebar{height:38px;display:flex;align-items:center;justify-content:center;border-bottom:.5px solid var(--line);background:var(--tbar);font-size:12.5px;color:var(--muted);letter-spacing:.2px}
.titlebar .wm{font-family:var(--serif);color:var(--text);font-size:14px;margin-right:7px}
.toolbar{display:flex;align-items:center;gap:13px;padding:9px 16px;border-bottom:.5px solid var(--line);background:var(--ink2)}
.names{display:flex;align-items:center;gap:8px;font-size:13px;font-weight:500;max-width:30vw}
.names span#na,.names span#nb{white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:12vw}
.names .v{font-size:9.5px;color:var(--ink);background:var(--muted);border-radius:4px;padding:1px 6px;font-weight:700}
.names .v.new{background:var(--gold)}.names .arrow{color:var(--dim)}
.stat{font-size:12px;color:var(--muted);white-space:nowrap}.stat b.i{color:var(--ins)}.stat b.d{color:var(--del)}
.chips{display:flex;gap:4px}
.chip{font:inherit;font-size:11.5px;color:var(--muted);background:var(--s2);border:.5px solid var(--line);border-radius:20px;padding:4px 11px;cursor:pointer;display:flex;align-items:center;gap:5px;white-space:nowrap}
.chip:hover{color:var(--text)}.chip.on{color:var(--text);border-color:var(--gold)}
.chip .dot{width:7px;height:7px;border-radius:50%}.chip .dot.r{background:var(--gold)}.chip .dot.i{background:var(--ins)}.chip .dot.d{background:var(--del)}
.nav{display:flex;align-items:center;gap:2px;background:var(--s2);border:.5px solid var(--line);border-radius:8px;padding:2px}
.nav button{font:inherit;font-size:12px;color:var(--muted);background:none;border:none;border-radius:6px;padding:5px 9px;cursor:pointer}
.nav button:hover{color:var(--text)}.nav .pos{font-size:11px;color:var(--dim);padding:0 6px;font-variant-numeric:tabular-nums;min-width:42px;text-align:center}
.seg{display:flex;background:var(--s2);border:.5px solid var(--line);border-radius:8px;padding:3px;gap:2px}
.seg button{font:inherit;font-size:12px;color:var(--muted);padding:5px 11px;border:none;border-radius:6px;background:none;cursor:pointer}
.seg button.on{background:var(--gold);color:var(--on-gold);font-weight:600}
.tb-sp{flex:1}
.btn{font:inherit;font-size:12px;border-radius:8px;padding:7px 13px;border:.5px solid var(--line);background:var(--s2);color:var(--text);cursor:pointer}
.btn:hover{background:var(--s3)}.btn.gold{background:var(--gold);color:var(--on-gold);font-weight:600;border-color:var(--gold)}.btn.gold:hover{background:var(--goldlt)}
.body{flex:1;display:flex;min-height:0}
/* change-list sidebar */
.side{width:264px;flex:none;border-right:.5px solid var(--line);background:var(--ink2);display:flex;flex-direction:column;transition:margin-left .18s ease}
.side.collapsed{margin-left:-264px}
.side .sh{padding:11px 14px 9px;font-family:var(--serif);font-size:12.5px;color:var(--dim);border-bottom:.5px solid var(--line);display:flex;justify-content:space-between;align-items:baseline}
.side .sh b{color:var(--text);font-family:var(--sans);font-weight:600}
.clist{overflow:auto;flex:1;padding:6px}
.ci{display:flex;gap:9px;padding:8px 9px;border-radius:8px;cursor:pointer;align-items:flex-start;border:.5px solid transparent}
.ci:hover{background:var(--s2)}.ci.active{background:var(--s2);border-color:var(--gold)}
.ci .b{font-size:9px;font-weight:700;border-radius:4px;padding:2px 5px;margin-top:1px;flex:none;letter-spacing:.3px}
.ci .b.del{background:var(--delbg);color:var(--del)}.ci .b.ins{background:var(--insbg);color:var(--ins)}.ci .b.chg{background:rgba(200,168,114,.16);color:var(--goldtext)}
.ci .pv{font-size:12px;line-height:1.42;color:var(--muted)}.ci.active .pv,.ci:hover .pv{color:var(--text)}
.ci .n{font-size:10px;color:var(--dim);margin-top:3px}
/* viewport (scroll grid + minimap) */
.viewport{flex:1;position:relative;display:flex;min-width:0}
.gridwrap{flex:1;display:flex;flex-direction:column;min-width:0}
.headrow{display:grid;grid-template-columns:1fr 1fr;border-bottom:.5px solid var(--line);background:var(--ink2);flex:none}
.headrow div{padding:9px 26px;font-family:var(--serif);font-size:12.5px;color:var(--dim);border-right:.5px solid var(--line);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.headrow div:last-child{border-right:none}
.scroll{flex:1;overflow:auto;position:relative}
.diffgrid{min-height:100%}
.row{display:grid;grid-template-columns:1fr 1fr;border-bottom:.5px solid var(--line-soft);scroll-margin-top:60px}
.row.del,.row.ins,.row.chg{border-left:3px solid var(--gold)}
.row.del .l,.row.chg .l{background:var(--delbg)}.row.ins .r,.row.chg .r{background:var(--insbg)}
.row.del .r,.row.ins .l{background:var(--rowdim)}
.cell{padding:11px 26px;line-height:1.62;border-right:.5px solid var(--line);overflow-wrap:anywhere}
.cell.r{border-right:none}.cell:empty::after{content:"";display:block}
.d{color:var(--del);text-decoration:line-through;text-decoration-color:var(--del)}
.i{color:var(--ins);text-decoration:underline;text-decoration-color:var(--ins)}
/* type filters hide rows */
.grid.f-chg .row.del,.grid.f-chg .row.ins,.grid.f-ins .row.del,.grid.f-ins .row.chg,.grid.f-del .row.ins,.grid.f-del .row.chg{display:none}
.grid.fonly .row.same{display:none}
/* minimap */
.minimap{width:14px;flex:none;background:var(--ink2);border-left:.5px solid var(--line);position:relative;cursor:pointer}
.minimap .tick{position:absolute;left:3px;right:3px;height:3px;border-radius:2px;opacity:.85}
.minimap .tick.del{background:var(--del)}.minimap .tick.ins{background:var(--ins)}.minimap .tick.chg{background:var(--gold)}
.minimap .vp{position:absolute;left:0;right:0;background:var(--vpfill);border:.5px solid var(--vpline);border-radius:2px;pointer-events:none}
.unified{display:none;overflow:auto;flex:1;padding:24px 40px;line-height:1.8}
.unified .inner{max-width:920px;margin:0 auto}.unified p{margin-bottom:11px}
/* summary / departures */
.summary{display:none;overflow:auto;flex:1;padding:30px 40px}
.summary .inner{max-width:880px;margin:0 auto}
.summary h2{font-family:var(--serif);font-size:21px;font-weight:400;margin-bottom:4px}
.summary .sub{color:var(--muted);font-size:12.5px;margin-bottom:22px}
.cards{display:flex;gap:12px;margin-bottom:26px}
.card{flex:1;background:var(--ink2);border:.5px solid var(--line);border-radius:12px;padding:16px 18px}
.card .n{font-size:27px;font-family:var(--serif);font-weight:400}.card .lab{font-size:11.5px;color:var(--muted);margin-top:2px}
.card.r .n{color:var(--goldtext)}.card.i .n{color:var(--ins)}.card.d .n{color:var(--del)}
table.dep{width:100%;border-collapse:collapse;font-size:12.5px}
table.dep th{text-align:left;font-family:var(--serif);font-weight:400;color:var(--dim);padding:8px 10px;border-bottom:.5px solid var(--line)}
table.dep td{padding:9px 10px;border-bottom:.5px solid var(--line-soft);vertical-align:top;line-height:1.5}
table.dep td.num{color:var(--dim);font-variant-numeric:tabular-nums;width:34px}
table.dep .b{font-size:9px;font-weight:700;border-radius:4px;padding:2px 6px}
table.dep .b.del{background:var(--delbg);color:var(--del)}table.dep .b.ins{background:var(--insbg);color:var(--ins)}table.dep .b.chg{background:rgba(200,168,114,.16);color:var(--goldtext)}
.flash{animation:fl 1.1s ease}@keyframes fl{0%,100%{background:transparent}28%{background:rgba(200,168,114,.20)}}
.statusbar{height:32px;display:flex;align-items:center;gap:8px;padding:0 16px;border-top:.5px solid var(--line);background:var(--ink2);font-size:11.5px;color:var(--dim)}
.statusbar .lock{color:var(--gold)}.statusbar .sp{flex:1}.statusbar .tog{color:var(--muted);cursor:pointer}.statusbar .tog:hover{color:var(--text)}
.pfoot{display:none}
@media print{@page{margin:13mm}
 :root{--ink:#fff;--ink2:#fff;--s2:#fff;--s3:#fff;--line:#bbb;--text:#111;--muted:#444;--dim:#666;--delbg:#fde8e4;--insbg:#e6f5ec;--del:#b3402f;--ins:#2f7a4e}
 body{height:auto;overflow:visible}.toolbar,.statusbar,.titlebar,.side,.minimap{display:none!important}
 .scroll{overflow:visible}.row{break-inside:avoid}.headrow{position:static}.card{break-inside:avoid}table.dep td,table.dep th{break-inside:avoid}
 .pfoot{display:block!important;position:fixed;bottom:5mm;left:0;right:0;text-align:center;font-size:8.5px;color:#9a917e;letter-spacing:.3px}}
</style></head><body>
<div class="app">
  <div class="titlebar"><span class="wm">PageHawk</span> · Compare</div>
  <div class="toolbar">
    <div class="names"><span id="na"></span><span class="v">A</span><span class="arrow">→</span><span id="nb"></span><span class="v new">B</span></div>
    <div class="stat" id="stat"></div>
    <div class="tb-sp"></div>
    <div class="chips">
      <button class="chip on" data-f="all" id="fall">All</button>
      <button class="chip" data-f="chg"><span class="dot r"></span>Revisions</button>
      <button class="chip" data-f="ins"><span class="dot i"></span>Insertions</button>
      <button class="chip" data-f="del"><span class="dot d"></span>Deletions</button>
    </div>
    <div class="nav"><button id="prev" title="Previous change (↑ / p)">‹</button><span class="pos" id="pos"></span><button id="next" title="Next change (↓ / n)">›</button></div>
    <div class="seg"><button class="on" data-v="side" id="vside">Side by side</button><button data-v="inline" id="vinline">Inline</button><button data-v="summary" id="vsummary">Summary</button></div>
    <button class="btn" id="docx" title="Open an editable Word file with native tracked changes">Export Word</button>
    <button class="btn" id="pdf">Export PDF</button>
    <button class="btn gold" id="share" title="Share via AirDrop, Mail, Messages — the file is on your Mac, nothing is uploaded">Share</button>
  </div>
  <div class="body">
    <div class="side" id="side"><div class="sh"><span>Changes</span><b id="cct"></b></div><div class="clist" id="clist"></div></div>
    <div class="viewport">
      <div class="gridwrap">
        <div class="headrow" id="headrow"><div id="ha"></div><div id="hb"></div></div>
        <div class="scroll" id="scroll"><div class="diffgrid grid" id="grid"></div></div>
      </div>
      <div class="minimap" id="minimap"><div class="vp" id="vp"></div></div>
    </div>
    <div class="unified" id="uni"><div class="inner" id="uniInner"></div></div>
    <div class="summary" id="summary"><div class="inner" id="sumInner"></div></div>
  </div>
  <div class="statusbar"><span class="lock">&#128274;</span> Compared on your Mac. Nothing uploaded.<span class="sp"></span><span class="tog" id="togside">Hide list</span><span id="meta"></span></div>
  <div class="pfoot">Compared with PageHawk &middot; on your Mac &middot; pagehawk.co</div>
</div>
<script>
const D=/*DATA*/;const $=id=>document.getElementById(id);
$('na').textContent=D.a;$('nb').textContent=D.b;$('ha').textContent=D.a;$('hb').textContent=D.b;
$('grid').innerHTML=D.rows;$('uniInner').innerHTML=D.uni;$('meta').textContent=D.built;
$('stat').innerHTML=D.changes+' changes &nbsp; <b class="i">+'+D.ins+'</b> / <b class="d">−'+D.dels+'</b> words';
$('cct').textContent=D.changes;
// --- change-list sidebar ---
const LAB={del:'DEL',ins:'INS',chg:'REV'};
$('clist').innerHTML=D.chlist.map(c=>'<div class="ci" data-go="'+c.i+'"><span class="b '+c.t+'">'+LAB[c.t]+'</span><div><div class="pv">'+(c.prev||'(empty)')+'</div><div class="n">Change '+c.i+'</div></div></div>').join('');
const cis=[].slice.call(document.querySelectorAll('.ci'));
cis.forEach(el=>el.onclick=()=>{const i=+el.getAttribute('data-go');const k=chg.findIndex(r=>+r.getAttribute('data-ci')===i);if(k>=0){ci=k;focusCur();}});
// --- navigation over VISIBLE changes ---
let chg=[].slice.call(document.querySelectorAll('.row[data-chg]'));let ci=-1;
function visible(){return chg.filter(el=>el.offsetParent!==null);}
function pos(){const v=visible();let n=0;if(ci>=0){const cur=chg[ci];n=v.indexOf(cur)+1;}$('pos').textContent=v.length?(n+' / '+v.length):'0';}
function focusCur(){const el=chg[ci];if(!el)return;el.scrollIntoView({behavior:'smooth',block:'center'});el.classList.remove('flash');void el.offsetWidth;el.classList.add('flash');
  const id=el.getAttribute('data-ci');cis.forEach(c=>c.classList.toggle('active',c.getAttribute('data-go')===id));
  const ac=document.querySelector('.ci.active');if(ac)ac.scrollIntoView({block:'nearest'});pos();}
function go(d){const v=visible();if(!v.length)return;let cur=chg[ci];let idx=v.indexOf(cur);idx=(idx+d+v.length)%v.length;ci=chg.indexOf(v[idx]);focusCur();}
$('next').onclick=()=>go(1);$('prev').onclick=()=>go(-1);
document.addEventListener('keydown',e=>{if(e.target.tagName==='INPUT')return;
  if(e.key==='ArrowDown'||e.key==='n'){go(1);e.preventDefault();}if(e.key==='ArrowUp'||e.key==='p'){go(-1);e.preventDefault();}});
// --- type filters ---
let filt='all';
function applyFilter(f){filt=f;const g=$('grid');g.className='diffgrid grid'+(f==='all'?'':' f-'+f);
  document.querySelectorAll('.chip').forEach(c=>c.classList.toggle('on',c.getAttribute('data-f')===f));
  cis.forEach(c=>{const t=D.chlist.find(x=>x.i===+c.getAttribute('data-go')).t;c.style.display=(f==='all'||f===t)?'':'none';});
  buildMinimap();pos();}
document.querySelectorAll('.chip').forEach(c=>c.onclick=()=>applyFilter(c.getAttribute('data-f')));
// --- minimap ---
const scroll=$('scroll'),grid=$('grid'),mm=$('minimap'),vp=$('vp');
function buildMinimap(){[].slice.call(mm.querySelectorAll('.tick')).forEach(t=>t.remove());
  const H=scroll.scrollHeight||1;const gtop=grid.getBoundingClientRect().top;
  visible().forEach(el=>{const top=el.getBoundingClientRect().top-gtop+scroll.scrollTop;
    const t=document.createElement('div');t.className='tick '+el.getAttribute('data-ct');
    t.style.top=(top/H*100)+'%';t.title='Change '+el.getAttribute('data-ci');
    t.onclick=()=>{ci=chg.indexOf(el);focusCur();};mm.appendChild(t);});updateVp();}
function updateVp(){const H=scroll.scrollHeight||1;vp.style.top=(scroll.scrollTop/H*100)+'%';vp.style.height=(scroll.clientHeight/H*100)+'%';}
scroll.addEventListener('scroll',updateVp);
mm.addEventListener('click',e=>{if(e.target!==mm)return;const r=mm.getBoundingClientRect();const frac=(e.clientY-r.top)/r.height;scroll.scrollTop=frac*scroll.scrollHeight-scroll.clientHeight/2;});
// --- views ---
function view(v){const isSide=v==='side',isInline=v==='inline',isSum=v==='summary';
  $('side').style.display=isSum?'none':'flex';
  document.querySelector('.viewport').style.display=isSide?'flex':'none';
  $('uni').style.display=isInline?'block':'none';$('summary').style.display=isSum?'block':'none';
  ['side','inline','summary'].forEach(k=>$('v'+k).classList.toggle('on',k===v));
  if(isSide)setTimeout(buildMinimap,30);}
['side','inline','summary'].forEach(k=>$('v'+k).onclick=()=>view(k));
// --- departures summary ---
$('sumInner').innerHTML='<h2>Departures summary</h2><div class="sub">'+D.a+' &nbsp;→&nbsp; '+D.b+' &nbsp;·&nbsp; built '+D.built+' on your Mac</div>'+
  '<div class="cards"><div class="card r"><div class="n">'+D.nchg+'</div><div class="lab">Revisions</div></div>'+
  '<div class="card i"><div class="n">'+D.nins+'</div><div class="lab">Insertions</div></div>'+
  '<div class="card d"><div class="n">'+D.ndel+'</div><div class="lab">Deletions</div></div>'+
  '<div class="card"><div class="n"><span class="i" style="text-decoration:none">+'+D.ins+'</span> / <span class="d" style="text-decoration:none">−'+D.dels+'</span></div><div class="lab">Words in / out</div></div></div>'+
  '<table class="dep"><thead><tr><th>#</th><th>Type</th><th>Change</th></tr></thead><tbody>'+
  D.chlist.map(c=>'<tr><td class="num">'+c.i+'</td><td><span class="b '+c.t+'">'+c.label+'</span></td><td>'+(c.prev||'(empty)')+'</td></tr>').join('')+'</tbody></table>';
// --- sidebar toggle + export ---
$('togside').onclick=()=>{const s=$('side');s.classList.toggle('collapsed');$('togside').textContent=s.classList.contains('collapsed')?'Show list':'Hide list';setTimeout(buildMinimap,200);};
$('pdf').onclick=()=>window.print();
// Export Word = open the on-device tracked-changes .docx PageHawk already wrote (legal/diligence currency)
if(D.docx){$('docx').onclick=()=>{location.href='hvniz://opendefault?path='+encodeURIComponent(D.docx);};$('share').onclick=()=>{location.href='hvniz://share?path='+encodeURIComponent(D.docx);};}else{$('docx').style.display='none';$('share').style.display='none';}
window.addEventListener('resize',()=>{if($('summary').style.display!=='block')buildMinimap();});
view('side');
</script></body></html>"""


TEMPLATE_VISUAL = r"""<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>PageHawk Compare</title>
<style>
:root{--ink:#F7F5EF;--ink2:#FBFAF6;--s2:#F0EDE3;--s3:#E9E5D8;--line:#E2DCCF;--gold:#C8A872;--goldlt:#B8912F;--goldtext:#7E5E10;
 --text:#1B1A17;--muted:#6B6455;--dim:#8A8271;--del:#B3402F;--ins:#2F7A4E;--mov:#2F5FA5;
 --delbg:rgba(179,64,47,.12);--insbg:rgba(47,122,78,.12);
 --serif:'Cambria','Big Caslon','Hoefler Text',Georgia,serif;--sans:-apple-system,'Inter',system-ui,sans-serif}
*{margin:0;padding:0;box-sizing:border-box}
body{background:var(--ink);color:var(--text);font-family:var(--sans);height:100vh;overflow:hidden;font-size:14px;-webkit-font-smoothing:antialiased}
.app{display:flex;flex-direction:column;height:100vh}
/* The product lockup, not a caption (Jason, 2026-08-04: "bolder and more branded, like a
   product"). It was 12.5px muted grey and read as a window title; a compare IS the product here,
   so the wordmark carries real weight and "Compare" becomes a gold chip rather than a dot and a
   word. */
.titlebar{height:46px;display:flex;align-items:center;justify-content:center;gap:9px;border-bottom:.5px solid var(--line);background:linear-gradient(#FDFCF8,#FBFAF6);flex:none}
.titlebar .wm{font-family:var(--serif);color:var(--text);font-size:19px;letter-spacing:.1px;line-height:1}
.titlebar .mode{font-size:11px;font-weight:700;letter-spacing:.7px;text-transform:uppercase;
  color:var(--on-gold,#2A2416);background:var(--gold);border-radius:5px;padding:3px 8px;line-height:1}
.toolbar{display:flex;align-items:center;gap:11px;padding:9px 16px;border-bottom:.5px solid var(--line);background:var(--ink2);flex:none;flex-wrap:nowrap;overflow:hidden}
/* The ribbon's A -> B chip pair lived here until 2026-08-05. It was a real improvement over the
   two inert truncated spans it replaced, and it still showed every filename TWICE: 190px of
   ellipsis in the ribbon, and the full name centred over its own document three pixels below.
   Jason chose option B (twice): the header takes the letter and the menu, the chips go, and the
   toolbar gets ~455px back. The .phead rules further down are where those styles went. */
/* Swap earned a label: it is the most useful control on this side and it was a 12px glyph
   (Jason: "a little bit bigger and more clear ... include the word swap"). Matched to the
   right-hand button styling so the two sides of the ribbon read as one family. */
/* Swap ON the divider. It was a 12px glyph at the right edge of the A->B group; it is now a
   pill centred on the gutter between the two documents, at their vertical midpoint — literally
   between left and right, which is the whole point of the control. .panes is position:relative
   so this anchors to the pane pair rather than the window, and it inherits the side-by-side
   view's visibility for free. */
.panes{position:relative}
.panes .swp{position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);z-index:30;
  display:flex;flex-direction:column;align-items:center;gap:1px;font:inherit;font-size:10px;
  font-weight:600;letter-spacing:.3px;background:var(--ink2);border:.5px solid var(--line);
  color:var(--text);border-radius:999px;padding:9px 8px;cursor:pointer;white-space:nowrap;
  box-shadow:0 3px 14px rgba(39,42,45,.18);transition:border-color .12s,background .12s}
.panes .swp:hover{border-color:var(--gold);background:#FFFDF7}
.panes .swp .gx{font-size:16px;line-height:1}
.stat{font-size:12px;color:var(--muted);white-space:nowrap}.stat b.i{color:var(--ins)}.stat b.d{color:var(--del)}
.chips{display:flex;gap:4px}
.chip{font:inherit;font-size:11.5px;color:var(--muted);background:var(--s2);border:.5px solid var(--line);border-radius:20px;padding:4px 11px;cursor:pointer;display:flex;align-items:center;gap:5px;white-space:nowrap}
.chip:hover{color:var(--text)}.chip.on{color:var(--text);border-color:var(--gold)}
.chip .dot{width:7px;height:7px;border-radius:50%}.chip .dot.r{background:var(--gold)}.chip .dot.i{background:var(--ins)}.chip .dot.d{background:var(--del)}
.nav,.zoom{display:flex;align-items:center;gap:2px;background:var(--s2);border:.5px solid var(--line);border-radius:8px;padding:2px}
.nav button,.zoom button{font:inherit;font-size:12px;color:var(--muted);background:none;border:none;border-radius:6px;padding:5px 9px;cursor:pointer}
.nav button:hover,.zoom button:hover{color:var(--text)}
.nav .pos{font-size:11px;color:var(--dim);padding:0 6px;font-variant-numeric:tabular-nums;min-width:42px;text-align:center}
.zoom .zl{font-size:10.5px;color:var(--dim);min-width:38px;text-align:center;font-variant-numeric:tabular-nums}
/* View switch. Two things were wrong and they compounded: "Side by side" was three words in a
   control with no `nowrap`, so under a crowded toolbar it broke onto two lines and made the whole
   ribbon taller and ragged (Jason, 2026-08-04: "kind of ugly ... should be in one line or use an
   icon"); and the toolbar was crowded in the first place because Export Word and Export PDF each
   took a full button. Both are fixed here: one word per view with a glyph that carries the meaning,
   nowrap so it can never break again whatever the window width, and the two exports collapse into
   one Export menu (which is also what the web compare surface already does). */
.seg{display:flex;background:var(--s2);border:.5px solid var(--line);border-radius:8px;padding:3px;gap:2px;flex:none}
.seg button{font:inherit;font-size:12px;color:var(--muted);padding:5px 10px;border:none;border-radius:6px;background:none;cursor:pointer;
  white-space:nowrap;display:flex;align-items:center;gap:5px;line-height:1}
.seg button .g{font-size:11px;opacity:.85;letter-spacing:-1px}
.seg button.on{background:var(--gold);color:#1B1A17;font-weight:600}
.seg button.on .g{opacity:1}
.tb-sp{flex:1}
.btn{font:inherit;font-size:12px;border-radius:8px;padding:7px 13px;border:.5px solid var(--line);background:var(--s2);color:var(--text);cursor:pointer;white-space:nowrap;display:flex;align-items:center;gap:6px}
.btn:hover{background:var(--s3)}.btn.gold{background:var(--gold);color:#1B1A17;font-weight:600;border-color:var(--gold)}.btn.gold:hover{background:var(--goldlt)}
.btn.on{border-color:var(--gold);color:var(--text)}
.btn .cbadge{font-size:10px;font-weight:700;color:var(--goldtext);background:rgba(200,168,114,.16);border-radius:20px;padding:1px 7px;min-width:16px;text-align:center}
.sep{width:1px;align-self:stretch;margin:5px 3px;background:var(--line)}
.body{flex:1;display:flex;min-height:0;position:relative}
/* change list: a slide-over overlay so it never steals the documents' width */
.side{position:absolute;top:0;left:0;bottom:0;width:290px;z-index:7;flex:none;border-right:.5px solid var(--line);background:var(--ink2);display:flex;flex-direction:column;transform:translateX(-101%);transition:transform .2s ease;box-shadow:3px 0 20px rgba(39,42,45,.12)}
.side.open{transform:translateX(0)}
.side .sh{padding:11px 14px 9px;font-family:var(--serif);font-size:12.5px;color:var(--dim);border-bottom:.5px solid var(--line);display:flex;gap:8px;align-items:baseline}
.side .sh b{color:var(--text);font-family:var(--sans);font-weight:600}.side .sh .sp{flex:1}
.side .sx{border:none;background:none;color:var(--dim);font-size:16px;cursor:pointer;line-height:1;padding:0 2px}.side .sx:hover{color:var(--text)}
.sfilt{display:flex;gap:4px;padding:8px 10px;border-bottom:.5px solid var(--line);flex-wrap:wrap}
.clist{overflow:auto;flex:1;padding:6px}
.ci{display:flex;gap:9px;padding:8px 9px;border-radius:8px;cursor:pointer;align-items:flex-start;border:.5px solid transparent}
.ci:hover{background:var(--s2)}.ci.active{background:var(--s2);border-color:var(--gold)}
.ci .b{font-size:9px;font-weight:700;border-radius:4px;padding:2px 5px;margin-top:1px;flex:none;letter-spacing:.3px}
.ci .b.del{background:var(--delbg);color:var(--del)}.ci .b.ins{background:var(--insbg);color:var(--ins)}.ci .b.chg{background:rgba(200,168,114,.16);color:var(--goldtext)}
.ci .pv{font-size:12px;line-height:1.42;color:var(--muted);overflow-wrap:anywhere}.ci.active .pv,.ci:hover .pv{color:var(--text)}
.ci .n{font-size:10px;color:var(--dim);margin-top:3px}
/* visual two-pane viewport */
.viewport{flex:1;position:relative;display:flex;min-width:0}
.panes{flex:1;display:flex;min-width:0}
.pane{flex:1;display:flex;flex-direction:column;min-width:0;border-right:.5px solid var(--line)}
.pane:last-child{border-right:none}
/* Centered over its own pane (Jason, 2026-08-04). Left-aligned, the two names sat at the far
   outside edges of a wide window with the documents centered between them, so neither label
   read as belonging to the page under it. Centered, each one sits directly over its document. */
/* The header IS the chip now (Jason 2026-08-05, option B, his second time choosing it). Every
   filename used to be on screen twice: truncated to 190px in a ribbon chip, and in full centred
   over its own document three pixels below. On his Grachev pair the chip read "HVN Convertible
   Note …", so the copy that could not be read was the one carrying the letter and the menu. Now
   the letter and the menu live on the header, which was already showing the full name, and the
   ribbon gets ~455px back. Swap does not move: it stays on the divider (variant C). */
.phead{padding:8px 18px;font-family:var(--serif);font-size:12.5px;color:var(--dim);border-bottom:.5px solid var(--line);background:var(--ink2);white-space:nowrap;overflow:hidden;flex:none;
  display:flex;align-items:center;justify-content:center;gap:7px;cursor:pointer;user-select:none}
.phead .v{font-family:var(--sans);font-size:9.5px;color:#FFFDF7;background:var(--muted);border-radius:4px;padding:1px 5px;font-weight:700;flex:none}
.phead.b .v{background:var(--gold);color:#3A2E14}
.phead .nm{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0}
/* The header is a handle for a real file, so say so on hover rather than leaving the right-click
   menu undiscoverable. Subtle: this is a label first and a control second. */
.phead:hover{color:var(--text);background:linear-gradient(var(--ink2),var(--s2))}
.hctx{position:fixed;z-index:400;display:none;min-width:220px;background:var(--ink2);border:.5px solid var(--line);
  border-radius:10px;padding:5px;box-shadow:0 10px 30px rgba(39,42,45,.18)}
.hctx.show{display:block}
.hctx button{display:block;width:100%;text-align:left;background:transparent;border:none;border-radius:7px;
  padding:7px 10px;cursor:pointer;color:var(--text);font:inherit;font-size:12.5px;white-space:nowrap}
.hctx button:hover{background:rgba(200,168,114,.12)}
.hsep{height:.5px;background:var(--line);margin:4px 8px}
.expmenu{position:absolute;z-index:400;display:none;top:100px;right:16px;min-width:290px;max-width:340px;background:var(--ink2);
  border:.5px solid var(--line);border-radius:10px;padding:6px;box-shadow:0 10px 30px rgba(39,42,45,.18)}
.expmenu.show{display:block}
.expmenu button{display:block;width:100%;text-align:left;background:transparent;border:none;border-radius:8px;
  padding:8px 10px;cursor:pointer;color:var(--text);font:inherit}
.expmenu button:hover{background:rgba(200,168,114,.10)}
.expmenu b{display:block;font-size:12.5px}
/* --dim is the right token for a hairline, a tick or a disabled glyph and the WRONG one for a
   line of prose: measured against the light ink it is 2.85:1, and 3.26:1 against the dark, so
   these 11px descriptions failed AA in BOTH themes. --muted is 5.18:1 light and 5.85:1 dark and
   is the token already used for every other caption in this window. Found 2026-08-06 while
   rendering these rows for a ballot: the specimen had to show the real palette, and the real
   palette did not pass. (contrast-legibility-guardrail.md: the floor is not a taste call.) */
.expmenu span{display:block;font-size:11px;color:var(--muted);margin-top:1px}
/* An absolutely-positioned box sizes to MAX-CONTENT, so the one-line descriptions below stretched
   this sheet to 678px before it was pinned. Fixed width, and the descriptions wrap inside it. */
.sharesheet{width:352px;min-width:0;max-width:352px}
.sharesheet .ssh{display:block;width:100%;text-align:left;background:transparent;border:none;border-radius:8px;padding:9px 10px 8px;cursor:pointer;font:inherit;color:var(--text)}
.sharesheet .ssh:hover{background:rgba(200,168,114,.12)}
.sharesheet .ssh b{font-family:var(--serif);font-size:15px;color:var(--text)}
.sharesheet .ssh span{display:block;font-size:11px;color:var(--muted);margin-top:2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.sharesheet .sshfoot{padding:7px 10px 4px;font-size:10.5px;color:var(--muted);border-top:.5px solid var(--line);margin-top:4px}
.pscroll{flex:1;overflow:auto;padding:18px 0}
.pstack{position:relative;width:calc(88% * var(--z,1));margin:0 auto}
.page{position:relative;background:#fff;box-shadow:0 1px 3px rgba(39,42,45,.10),0 4px 14px rgba(39,42,45,.10),0 0 0 .5px var(--line);margin:0 auto 16px;border-radius:2px}
.page img{width:100%;display:block;-webkit-user-drag:none;user-select:none}
.page .pno{position:absolute;right:7px;bottom:5px;font-size:9px;color:#999;background:rgba(255,255,255,.72);border-radius:3px;padding:1px 5px;pointer-events:none}
.ov{position:absolute;border-radius:2px;cursor:pointer;mix-blend-mode:multiply}
.ov.del{background:rgba(179,64,47,.26);box-shadow:inset 0 0 0 1px rgba(179,64,47,.5)}
.ov.ins{background:rgba(47,122,78,.24);box-shadow:inset 0 0 0 1px rgba(47,122,78,.48)}
.ov.cur{mix-blend-mode:normal;background:rgba(200,168,114,.30);box-shadow:inset 0 0 0 1.5px var(--gold),0 0 0 2px rgba(200,168,114,.35);z-index:2}
.ov.pulse{animation:ovp 1s ease}
@keyframes ovp{0%{filter:brightness(1.55)}100%{filter:none}}
body.f-chg .ov[data-ct=ins],body.f-chg .ov[data-ct=del],body.f-ins .ov[data-ct=chg],body.f-ins .ov[data-ct=del],body.f-del .ov[data-ct=chg],body.f-del .ov[data-ct=ins]{display:none}
/* center change-rail: the color map that lives BETWEEN the two documents */
.cmp-rail{width:24px;flex:none;background:var(--ink2);border-left:.5px solid var(--line);border-right:.5px solid var(--line);position:relative;cursor:pointer}
.cmp-rail .tick{position:absolute;left:5px;right:5px;height:4px;border-radius:2px;opacity:.92;cursor:pointer;transition:transform .1s ease}
.cmp-rail .tick:hover{transform:scaleX(1.5)}
.cmp-rail .tick.del{background:var(--del)}.cmp-rail .tick.ins{background:var(--ins)}.cmp-rail .tick.chg{background:var(--gold)}
.cmp-rail .tick.cur{left:2px;right:2px;height:7px;box-shadow:0 0 0 1.5px var(--gold),0 0 6px rgba(200,168,114,.6);opacity:1;z-index:2}
.cmp-rail .rvp{position:absolute;left:0;right:0;background:rgba(27,26,23,.055);border-top:.5px solid rgba(27,26,23,.16);border-bottom:.5px solid rgba(27,26,23,.16);pointer-events:none}
/* redline view: ONE tracked-changes document on the real rendered pages (revised doc,
   insertions underlined in place, deletions marked where text was removed) */
.unified{display:none;overflow:auto;flex:1;padding:20px 0 40px;background:#EFEADD}
.rlstack{position:relative;width:calc(min(720px,92%) * var(--z,1));margin:0 auto}
.rlstack .page{position:relative;background:#fff;box-shadow:0 1px 3px rgba(39,42,45,.10),0 6px 20px rgba(39,42,45,.10),0 0 0 .5px var(--line);margin:0 auto 18px;border-radius:2px;overflow:hidden}
.rlstack .ov.ins{position:absolute;background:rgba(47,122,78,.14);border-bottom:2px solid var(--ins);border-radius:1px;mix-blend-mode:normal;cursor:pointer;z-index:1}
.rlstack .ov.ins.cur{background:rgba(200,168,114,.28);border-bottom-color:var(--gold);box-shadow:inset 0 0 0 1.5px var(--gold)}
.rlstack .delmark{position:absolute;left:0;width:3px;min-height:11px;background:var(--del);cursor:help;z-index:4;border-radius:0 2px 2px 0}
.rlstack .delmark::before{content:"";position:absolute;left:0;top:0;bottom:0;width:3px;background:var(--del)}
.rlstack .delmark.cur{width:5px;box-shadow:0 0 0 1.5px var(--gold)}
.rlstack .ov.pulse,.rlstack .delmark.pulse{animation:ovp 1s ease}
.rl-empty{text-align:center;color:var(--muted);font-size:12.5px;padding:10px 0 2px}
.summary{display:none;overflow:auto;flex:1;padding:30px 40px}
.summary .inner{max-width:880px;margin:0 auto}
.summary h2{font-family:var(--serif);font-size:21px;font-weight:400;margin-bottom:4px}
.summary .sub{color:var(--muted);font-size:12.5px;margin-bottom:22px}
.cards{display:flex;gap:12px;margin-bottom:26px}
.card{flex:1;background:var(--ink2);border:.5px solid var(--line);border-radius:12px;padding:16px 18px}
.card .n{font-size:27px;font-family:var(--serif);font-weight:400}.card .lab{font-size:11.5px;color:var(--muted);margin-top:2px}
.card.r .n{color:var(--goldtext)}.card.i .n{color:var(--ins)}.card.d .n{color:var(--del)}
.card .n .i{color:var(--ins)}.card .n .d{color:var(--del)}
table.dep{width:100%;border-collapse:collapse;font-size:12.5px}
table.dep th{text-align:left;font-family:var(--serif);font-weight:400;color:var(--dim);padding:8px 10px;border-bottom:.5px solid var(--line)}
table.dep td{padding:9px 10px;border-bottom:.5px solid rgba(90,84,70,.18);vertical-align:top;line-height:1.5}
table.dep td.num{color:var(--dim);font-variant-numeric:tabular-nums;width:34px}
table.dep td.pgc{color:var(--dim);font-variant-numeric:tabular-nums;width:52px;white-space:nowrap}
table.dep .b{font-size:9px;font-weight:700;border-radius:4px;padding:2px 6px}
table.dep .b.del{background:var(--delbg);color:var(--del)}table.dep .b.ins{background:var(--insbg);color:var(--ins)}table.dep .b.chg{background:rgba(200,168,114,.16);color:var(--goldtext)}
.statusbar{height:32px;display:flex;align-items:center;gap:8px;padding:0 16px;border-top:.5px solid var(--line);background:var(--ink2);font-size:11.5px;color:var(--dim);flex:none}
.statusbar .lock{color:var(--gold)}.statusbar .sp{flex:1}.statusbar .tog{color:var(--muted);cursor:pointer}.statusbar .tog:hover{color:var(--text)}
.pfoot{display:none}
@media print{@page{margin:13mm}
 :root{--ink:#fff;--ink2:#fff;--s2:#fff;--s3:#fff;--line:#bbb;--text:#111;--muted:#444;--dim:#666;--delbg:#fde8e4;--insbg:#e6f5ec;--del:#b3402f;--ins:#2f7a4e}
 body{height:auto;overflow:visible}.toolbar,.statusbar,.titlebar,.side,.cmp-rail,.viewport{display:none!important}
 .unified{display:block!important;overflow:visible;padding:0}
 .card{break-inside:avoid}table.dep td,table.dep th{break-inside:avoid}
 .pfoot{display:block!important;position:fixed;bottom:5mm;left:0;right:0;text-align:center;font-size:8.5px;color:#9a917e;letter-spacing:.3px}}
</style></head><body>
<div class="app">
  <div class="titlebar"><span class="wm">PageHawk</span><span class="mode">Compare</span></div>
  <div class="toolbar">
    <div class="stat" id="stat"></div>
    <div class="tb-sp"></div>
    <div class="seg"><button class="on" data-v="side" id="vside" title="Side by side — both documents, aligned"><span class="g">&#9707;</span>Side</button><button data-v="inline" id="vinline" title="Redline — one merged document with the changes marked"><span class="g">&#9776;</span>Redline</button><button data-v="summary" id="vsummary" title="Summary — every change in a table"><span class="g">&#9636;</span>Summary</button></div>
    <div class="nav"><button id="prev" title="Previous change (↑ / p)">‹</button><span class="pos" id="pos"></span><button id="next" title="Next change (↓ / n)">›</button></div>
    <div class="zoom"><button id="zout" title="Zoom out">−</button><span class="zl" id="zl">100%</span><button id="zin" title="Zoom in">+</button></div>
    <div class="sep"></div>
    <button class="btn" id="changes" title="Show the list of changes">&#9776; Changes <span class="cbadge" id="cbadge"></span></button>
    <button class="btn" id="exp" title="Export this comparison">Export &#9662;</button>
    <button class="btn gold" id="share" title="Share via AirDrop, Mail, Messages — the file is on your Mac, nothing is uploaded">Share</button>
  </div>
  <!-- Export menu: Word and PDF were two toolbar buttons, which is most of why the ribbon was
       crowded enough to wrap the view switch. One button, one menu, same two destinations. -->
  <div class="expmenu" id="expmenu">
    <button data-x="docx"><b>Word, with tracked changes</b><span>Editable, native track-changes — the legal working file</span></button>
    <button data-x="pdf"><b>PDF</b><span>Print-ready, exactly what is on screen</span></button>
  </div>
  <!-- OUR share surface, not the OS's. Jason, 2026-08-04: "whenever we hit the Share button, we
       are 100% in control of the share interface." Share used to jump straight to the macOS share
       sheet, which knows nothing about a comparison and offers a .docx with no context. This is
       PageHawk's own sheet; the system sheet is now ONE labelled choice inside it, for the case
       where AirDrop or Messages really is what you want. -->
  <div class="expmenu sharesheet" id="sharesheet">
    <!-- The title is a BUTTON, not a caption. Jason, 2026-08-04: "the first thing I see is 'Share
         this comparison', but I can't click it. How is that even not an option?" He is right: it
         is the largest, first thing in the panel and it read as the primary action while being
         inert. It now IS the primary action — one click hands the whole comparison to the system
         share sheet as the tracked-changes Word file, the single most complete artifact we have.
         The itemised choices below remain for when a specific format is wanted. -->
    <button class="ssh" data-s="primary"><b>Share this comparison</b><span id="sshsub"></span></button>
    <button data-s="copysum"><b>Copy the summary</b><span>Both file names, the change counts and the words in and out</span></button>
    <button data-s="pdf"><b>Send a redline PDF</b><span>One merged document anyone can open, nothing to install</span></button>
    <button data-s="docx"><b>Send the tracked-changes Word file</b><span>The working file for counsel to edit</span></button>
    <div class="hsep"></div>
    <button data-s="system"><b>Use the macOS share sheet…</b><span>AirDrop, Messages, Mail — sends the Word file as-is</span></button>
    <div class="sshfoot">Everything here is on your Mac. Nothing is uploaded.</div>
  </div>
  <!-- Right-click a pane header: the file under it is a real file, so offer the things a real
       file affords. Built here rather than borrowing the reader's menu because the actions are
       per-SIDE (this header's document), not per-window. -->
  <div class="hctx" id="hctx">
    <button data-h="reveal">Reveal in Finder</button>
    <button data-h="open">Open in the default app</button>
    <div class="hsep"></div>
    <button data-h="copypath">Copy path</button>
    <button data-h="copyname">Copy file name</button>
    <div class="hsep"></div>
    <button data-h="swap">Swap sides</button>
  </div>
  <div class="body">
    <div class="side" id="side">
      <div class="sh"><span>Changes</span><b id="cct"></b><span class="sp"></span><button class="sx" id="sideclose" title="Close">&times;</button></div>
      <div class="sfilt">
        <button class="chip on" data-f="all">All</button>
        <button class="chip" data-f="chg"><span class="dot r"></span>Rev</button>
        <button class="chip" data-f="ins"><span class="dot i"></span>Ins</button>
        <button class="chip" data-f="del"><span class="dot d"></span>Del</button>
      </div>
      <div class="clist" id="clist"></div>
    </div>
    <div class="viewport" id="viewport">
      <div class="panes">
        <!-- Swap lives ON the divider between the two documents, not in the toolbar. Jason,
             2026-08-04: "put the swap button ... dead center. That is where you can swap left
             and right. That is very logical." The gutter IS the axis between left and right,
             so the control sits on it. It is a child of .panes, which only renders in the
             side-by-side view, so it disappears by construction in Redline and Summary rather
             than needing a rule to hide it. -->
        <button class="swp" id="swap" title="Swap sides &#8212; compare B against A instead"><span class="gx">&#8646;</span>Swap</button>
        <div class="pane"><div class="phead" id="ha" title="Document A &#8212; click for Reveal in Finder, Open, Copy path"><span class="v">A</span><span class="nm" id="na"></span></div><div class="pscroll" id="sa"><div class="pstack" id="stA"></div></div></div>
        <div class="cmp-rail" id="minimap"><div class="rvp" id="vp"></div></div>
        <div class="pane"><div class="phead b" id="hb" title="Document B &#8212; click for Reveal in Finder, Open, Copy path"><span class="v">B</span><span class="nm" id="nb"></span></div><div class="pscroll" id="sb"><div class="pstack" id="stB"></div></div></div>
      </div>
    </div>
    <div class="unified" id="uni"><div class="rlstack" id="rlstack"></div></div>
    <div class="summary" id="summary"><div class="inner" id="sumInner"></div></div>
  </div>
  <div class="statusbar"><span class="lock">&#128274;</span> Compared on your Mac. Nothing uploaded.<span class="sp"></span><span id="meta"></span></div>
  <div class="pfoot">Compared with PageHawk &middot; on your Mac &middot; pagehawk.co</div>
</div>
<script>
const D=/*DATA*/;const $=id=>document.getElementById(id);
const esc=s=>String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
$('na').textContent=D.a;$('nb').textContent=D.b;   // #na/#nb ARE the .nm spans inside the pane headers now (option B)
$('meta').textContent=D.built;$('cct').textContent=D.changes;$('cbadge').textContent=D.changes;
$('stat').innerHTML=(D.changes?D.changes+' changes':'No text changes found')+' &nbsp; <b class="i">+'+D.ins+'</b> / <b class="d">−'+D.dels+'</b> words';
const sa=$('sa'),sb=$('sb'),stA=$('stA'),stB=$('stB');
// ── build the rendered-page stacks (aspect-ratio pins layout before images load) ──
function buildPane(list,mount){list.forEach((pg,i)=>{const d=document.createElement('div');d.className='page';
  d.style.aspectRatio=pg.w+' / '+pg.h;d.dataset.pg=i;
  const im=document.createElement('img');im.loading='lazy';im.src=pg.u;im.alt='page '+(i+1);d.appendChild(im);
  const no=document.createElement('div');no.className='pno';no.textContent=i+1;d.appendChild(no);
  mount.appendChild(d);});}
buildPane(D.pa,stA);buildPane(D.pb,stB);
// ── change overlays, positioned as page-relative percentages (zero pixel math) ──
function addOvs(c,side,stack){(c[side]||[]).forEach(r=>{const pe=stack.children[r.p];if(!pe)return;
  const o=document.createElement('div');o.className='ov '+(side==='a'?'del':'ins');
  o.dataset.ci=c.i;o.dataset.ct=c.t;o.title='Change '+c.i+(c.t==='chg'?' (revision)':c.t==='ins'?' (insertion)':' (deletion)');
  o.style.left=r.l+'%';o.style.top=r.t+'%';o.style.width=r.w+'%';o.style.height=r.h+'%';
  o.onclick=()=>{const v=vis();const k=v.findIndex(x=>x.i===c.i);if(k>=0){ci=k;focusCur();}};
  pe.appendChild(o);});}
D.ch.forEach(c=>{addOvs(c,'a',stA);addOvs(c,'b',stB);});
// ── position helpers (rect-based: exact regardless of padding/offset parents) ──
function apx(side,pg,frac){const sc=side==='a'?sa:sb,st=side==='a'?stA:stB;
  const pe=st.children[Math.min(pg,st.children.length-1)];if(!pe)return 0;
  return pe.getBoundingClientRect().top-sc.getBoundingClientRect().top+sc.scrollTop+frac*pe.offsetHeight;}
function rpx(side,r){const sc=side==='a'?sa:sb,st=side==='a'?stA:stB;const pe=st.children[r.p];if(!pe)return 0;
  return pe.getBoundingClientRect().top-sc.getBoundingClientRect().top+sc.scrollTop+(r.t/100)*pe.offsetHeight;}
// ── lockstep scroll: piecewise-linear map over equal-block anchors ──
let AX=[],BX=[];
function recalc(){AX=[];BX=[];let la=-1e9,lb=-1e9;
  (D.an||[]).forEach(a=>{const xa=apx('a',a[0],a[1]),xb=apx('b',a[2],a[3]);
    if(xa>la+2&&xb>lb+2){AX.push(xa);BX.push(xb);la=xa;lb=xb;}});}
function mapPos(pos,X,Y){if(!X.length)return pos;
  if(pos<=X[0])return Y[0]-(X[0]-pos);
  if(pos>=X[X.length-1])return Y[Y.length-1]+(pos-X[X.length-1]);
  let lo=0,hi=X.length-1;while(hi-lo>1){const m=(lo+hi)>>1;if(X[m]<=pos)lo=m;else hi=m;}
  const t=(pos-X[lo])/Math.max(1,X[hi]-X[lo]);return Y[lo]+t*(Y[hi]-Y[lo]);}
let lock=null;
function syncFrom(src){if(lock&&lock!==src)return;lock=src;
  const dst=src===sa?sb:sa,X=src===sa?AX:BX,Y=src===sa?BX:AX;
  dst.scrollTop=mapPos(src.scrollTop+src.clientHeight/2,X,Y)-dst.clientHeight/2;
  requestAnimationFrame(()=>requestAnimationFrame(()=>{if(lock===src)lock=null;}));updateVp();}
sa.addEventListener('scroll',()=>syncFrom(sa));sb.addEventListener('scroll',()=>syncFrom(sb));
// ── change list + navigation over VISIBLE changes ──
const LAB={del:'DEL',ins:'INS',chg:'REV'};let filt='all',ci=-1;
function vis(){return D.ch.filter(c=>filt==='all'||filt===c.t);}
$('clist').innerHTML=D.ch.map(c=>'<div class="ci" data-go="'+c.i+'"><span class="b '+c.t+'">'+LAB[c.t]+'</span><div><div class="pv">'+esc(c.prev||'(empty)')+'</div><div class="n">Change '+c.i+' · p.'+c.pg+'</div></div></div>').join('');
const cis=[].slice.call(document.querySelectorAll('.ci'));
cis.forEach(el=>el.onclick=()=>{const i=+el.getAttribute('data-go');const k=vis().findIndex(c=>c.i===i);if(k>=0){ci=k;focusCur();}});
function pos(){const v=vis();$('pos').textContent=v.length?((ci>=0?ci+1:0)+' / '+v.length):'0';}
function focusCur(){const c=vis()[ci];if(!c)return;
  if(curView==='inline'){                 // redline view: scroll + pulse on the single doc
    rlFocus(c);
  } else {                                // side-by-side: scroll both panes in lockstep
    const side=(c.b&&c.b.length)?'b':'a';const r=(side==='b'?c.b:c.a)[0];
    const scr=side==='b'?sb:sa,oth=side==='b'?sa:sb;
    const px=rpx(side,r),X=side==='b'?BX:AX,Y=side==='b'?AX:BX;
    lock='nav';
    scr.scrollTo({top:Math.max(0,px-scr.clientHeight*0.35),behavior:'smooth'});
    oth.scrollTo({top:Math.max(0,mapPos(px,X,Y)-oth.clientHeight*0.35),behavior:'smooth'});
    setTimeout(()=>{if(lock==='nav')lock=null;updateVp();},650);
    document.querySelectorAll('.viewport .ov.cur').forEach(e=>e.classList.remove('cur','pulse'));
    document.querySelectorAll('.viewport .ov[data-ci="'+c.i+'"]').forEach(e=>{e.classList.add('cur');void e.offsetWidth;e.classList.add('pulse');});
  }
  markRailCur();
  cis.forEach(el=>el.classList.toggle('active',+el.getAttribute('data-go')===c.i));
  const ac=document.querySelector('.ci.active');if(ac)ac.scrollIntoView({block:'nearest'});pos();}
function go(d){const v=vis();if(!v.length)return;ci=((ci<0?(d>0?-1:0):ci)+d+v.length)%v.length;focusCur();}
$('next').onclick=()=>go(1);$('prev').onclick=()=>go(-1);
document.addEventListener('keydown',e=>{if(e.target.tagName==='INPUT')return;
  if(e.key==='ArrowDown'||e.key==='n'){go(1);e.preventDefault();}
  if(e.key==='ArrowUp'||e.key==='p'){go(-1);e.preventDefault();}});
// ── type filters (overlays hide via CSS on body.f-*, list + minimap rebuilt) ──
function applyFilter(f){filt=f;ci=-1;
  document.body.className=(f==='all'?'':'f-'+f);
  document.querySelectorAll('.chip').forEach(c=>c.classList.toggle('on',c.getAttribute('data-f')===f));
  cis.forEach(el=>{const c=D.ch.find(x=>x.i===+el.getAttribute('data-go'));el.style.display=(f==='all'||f===c.t)?'':'none';});
  buildRail();pos();}
document.querySelectorAll('.chip').forEach(c=>c.onclick=()=>applyFilter(c.getAttribute('data-f')));
// ── center change-rail (the color map BETWEEN the two documents) ──
// ins/revision ticks sit at the revised-doc y; pure deletions map across via the anchor spine.
const rail=$('minimap'),vp=$('vp');
function railY(c){return (c.b&&c.b.length)?rpx('b',c.b[0]):mapPos(rpx('a',c.a[0]),AX,BX);}
function buildRail(){[].slice.call(rail.querySelectorAll('.tick')).forEach(t=>t.remove());
  const H=sb.scrollHeight||1;
  vis().forEach(c=>{const t=document.createElement('div');t.className='tick '+c.t;t.dataset.ci=c.i;
    t.style.top=(railY(c)/H*100)+'%';t.title='Change '+c.i+' · p.'+c.pg;
    t.onclick=()=>{const k=vis().findIndex(x=>x.i===c.i);if(k>=0){ci=k;focusCur();}};
    rail.appendChild(t);});
  markRailCur();updateVp();}
function markRailCur(){const c=vis()[ci];
  rail.querySelectorAll('.tick').forEach(t=>t.classList.toggle('cur',!!c&&+t.dataset.ci===c.i));}
function updateVp(){const H=sb.scrollHeight||1;vp.style.top=(sb.scrollTop/H*100)+'%';vp.style.height=(sb.clientHeight/H*100)+'%';}
rail.addEventListener('click',e=>{if(e.target!==rail)return;const r=rail.getBoundingClientRect();
  sb.scrollTop=(e.clientY-r.top)/r.height*sb.scrollHeight-sb.clientHeight/2;});
// ── on-page REDLINE view: the revised document's real pages, insertions underlined in
// place, deletions marked where text was removed (mapped across via the anchor spine) ──
let rlBuilt=false;
function nearestB(a0){                       // revised-doc anchor just after a deletion
  const ck=a0.p+a0.t/100;let best=null;
  (D.an||[]).forEach(an=>{const k=an[0]+an[1];
    if(k>=ck-0.03&&(best===null||k<best.k))best={k:k,p:an[2],t:an[3]*100};});
  if(best)return{p:best.p,t:best.t};
  const last=(D.an&&D.an.length)?D.an[D.an.length-1]:null;
  return last?{p:last[2],t:last[3]*100}:{p:Math.min(a0.p,D.pb.length-1),t:a0.t};}
function bLoc(c){return (c.b&&c.b.length)?{p:c.b[0].p,t:c.b[0].t}:nearestB(c.a[0]);}
function buildRedline(){
  if(rlBuilt)return;rlBuilt=true;
  const stack=$('rlstack');
  buildPane(D.pb,stack);
  D.ch.forEach(c=>{
    (c.b||[]).forEach(r=>{                     // insertions: green underline, exact
      const pe=stack.children[r.p];if(!pe)return;
      const o=document.createElement('div');o.className='ov ins';o.dataset.ci=c.i;o.dataset.ct=c.t;
      o.style.left=r.l+'%';o.style.top=r.t+'%';o.style.width=r.w+'%';o.style.height=r.h+'%';
      o.title='Change '+c.i+(c.t==='chg'?' (revision)':' (insertion)');
      o.onclick=()=>{const k=vis().findIndex(x=>x.i===c.i);if(k>=0){ci=k;focusCur();}};
      pe.appendChild(o);});
    if(c.t!=='ins'){                            // deletions: red margin marker where removed
      const loc=bLoc(c),pe=stack.children[loc.p];if(!pe)return;
      const m=document.createElement('div');m.className='delmark';m.dataset.ci=c.i;m.style.top=loc.t+'%';
      m.style.height=((c.a&&c.a[0]?c.a[0].h:1.4))+'%';
      m.title='Deleted: '+(c.prev||'');
      m.onclick=()=>{const k=vis().findIndex(x=>x.i===c.i);if(k>=0){ci=k;focusCur();}};
      pe.appendChild(m);}});}
function rlFocus(c){
  const stack=$('rlstack'),sc=$('uni'),loc=bLoc(c),pe=stack.children[loc.p];if(!pe)return;
  const y=pe.offsetTop+(loc.t/100)*pe.offsetHeight;
  sc.scrollTo({top:Math.max(0,y-sc.clientHeight*0.35),behavior:'smooth'});
  stack.querySelectorAll('.cur').forEach(e=>e.classList.remove('cur','pulse'));
  stack.querySelectorAll('[data-ci="'+c.i+'"]').forEach(e=>{e.classList.add('cur');void e.offsetWidth;e.classList.add('pulse');});}
// ── zoom (both panes together; anchors recomputed since layout changes) ──
const ZS=[0.6,0.75,0.9,1,1.2,1.45,1.75];let zi=3;
function setZoom(k){zi=Math.max(0,Math.min(ZS.length-1,k));
  document.documentElement.style.setProperty('--z',ZS[zi]);
  $('zl').textContent=Math.round(ZS[zi]*100)+'%';
  recalc();buildRail();}
$('zin').onclick=()=>setZoom(zi+1);$('zout').onclick=()=>setZoom(zi-1);
// ── views (Side by side / Redline / Summary) ──
let curView='side';
function view(v){curView=v;const isSide=v==='side',isRl=v==='inline',isSum=v==='summary';
  $('viewport').style.display=isSide?'flex':'none';
  $('uni').style.display=isRl?'block':'none';$('summary').style.display=isSum?'block':'none';
  ['side','inline','summary'].forEach(k=>$('v'+k).classList.toggle('on',k===v));
  if(isSide)setTimeout(()=>{recalc();buildRail();},30);
  if(isRl)buildRedline();}
['side','inline','summary'].forEach(k=>$('v'+k).onclick=()=>view(k));
// ── departures summary ──
$('sumInner').innerHTML='<h2>Departures summary</h2><div class="sub">'+esc(D.a)+' &nbsp;→&nbsp; '+esc(D.b)+' &nbsp;·&nbsp; built '+D.built+' on your Mac</div>'+
  '<div class="cards"><div class="card r"><div class="n">'+D.nchg+'</div><div class="lab">Revisions</div></div>'+
  '<div class="card i"><div class="n">'+D.nins+'</div><div class="lab">Insertions</div></div>'+
  '<div class="card d"><div class="n">'+D.ndel+'</div><div class="lab">Deletions</div></div>'+
  '<div class="card"><div class="n"><span class="i">+'+D.ins+'</span> / <span class="d">−'+D.dels+'</span></div><div class="lab">Words in / out</div></div></div>'+
  '<table class="dep"><thead><tr><th>#</th><th>Type</th><th>Page</th><th>Change</th></tr></thead><tbody>'+
  D.ch.map(c=>'<tr><td class="num">'+c.i+'</td><td><span class="b '+c.t+'">'+({del:'Deletion',ins:'Insertion',chg:'Revision'})[c.t]+'</span></td><td class="pgc">p.'+c.pg+'</td><td>'+esc(c.prev||'(empty)')+'</td></tr>').join('')+'</tbody></table>';
// ── changes-list overlay (default CLOSED so the documents get full width) ──
function toggleSide(open){const s=$('side');
  const willOpen=(open===undefined)?!s.classList.contains('open'):open;
  s.classList.toggle('open',willOpen);
  $('changes').classList.toggle('on',willOpen);}
$('changes').onclick=()=>toggleSide();
$('sideclose').onclick=()=>toggleSide(false);
// ── Export: one button, one menu (was two toolbar buttons, which is most of why the ribbon
//    was crowded enough to wrap the view switch onto two lines).
function exportPdf(){if(curView==='side'){view('inline');setTimeout(()=>{window.print();view('side');},80);}else window.print();}
const expm=$('expmenu');
$('exp').onclick=e=>{e.stopPropagation();const was=expm.classList.contains('show');closeMenus();
  if(!was)expm.classList.add('show');};
expm.onclick=e=>{const b=e.target.closest('button');if(!b)return;expm.classList.remove('show');
  if(b.dataset.x==='pdf')exportPdf();
  else if(b.dataset.x==='docx'&&D.docx)location.href='hvniz://opendefault?path='+encodeURIComponent(D.docx);};
// ── Share: OUR sheet first, the OS sheet only if that is what you actually pick ──────────────
const ssheet=$('sharesheet');
$('sshsub').textContent=D.a+'  →  '+D.b+'  ·  '+D.changes+' changes';
function closeMenus(){expm.classList.remove('show');ssheet.classList.remove('show');hideHctx();}
$('share').onclick=e=>{e.stopPropagation();const was=ssheet.classList.contains('show');closeMenus();
  if(!was)ssheet.classList.add('show');};
ssheet.onclick=e=>{const b=e.target.closest('button');if(!b)return;ssheet.classList.remove('show');
  const s=b.dataset.s;
  if(s==='primary'){ if(D.docx)location.href='hvniz://share?path='+encodeURIComponent(D.docx); else exportPdf(); }
  else if(s==='pdf')exportPdf();
  else if(s==='docx'&&D.docx)location.href='hvniz://opendefault?path='+encodeURIComponent(D.docx);
  else if(s==='system'&&D.docx)location.href='hvniz://share?path='+encodeURIComponent(D.docx);
  else if(s==='copysum')copyText(D.a+'  →  '+D.b+'\n'+D.changes+' changes · +'+D.ins+' / -'+D.dels+' words'
    +'\n'+D.nchg+' revisions, '+D.nins+' insertions, '+D.ndel+' deletions'
    +'\nCompared on '+D.built+' with PageHawk. Nothing uploaded.');};
if(!D.docx){['docx','system'].forEach(k=>{const n=ssheet.querySelector('[data-s="'+k+'"]');if(n)n.style.display='none';});
  expm.querySelector('[data-x="docx"]').style.display='none';}

// ── The pane header IS the handle for its file: left-click or right-click opens the same menu.
//    It already showed the full name, so putting the letter and the menu here removed the
//    truncated ribbon copy entirely rather than duplicating it (option B, Jason 2026-08-05).
[['ha','a'],['hb','b']].forEach(([id,side])=>{const c=$(id);if(!c)return;
  c.onclick=e=>{e.stopPropagation();const r=c.getBoundingClientRect();closeMenus();showHctx(r.left+r.width/2-110,r.bottom+6,side);};
  c.addEventListener('contextmenu',e=>{e.preventDefault();e.stopPropagation();
    closeMenus();showHctx(e.clientX,e.clientY,side);});});

// ── Swap sides: re-runs the compare the other way round. A and B are an arbitrary order until
//    you have seen the diff, and by then it is often the wrong one. Native re-runs it because
//    swapping is not a repaint: every insertion becomes a deletion and the anchors move.
if(D.apath&&D.bpath){
  $('swap').onclick=()=>{location.href='hvniz://comparego?a='+encodeURIComponent(D.bpath)+'&b='+encodeURIComponent(D.apath);};
}else{$('swap').style.display='none';}

// ── Right-click a pane header. The header names a real file on this Mac, so it affords what a
//    real file affords. Left header = A, right header = B; the menu remembers which was hit.
const hctx=$('hctx');let hctxSide='a';
function hideHctx(){hctx.classList.remove('show');}
function showHctx(x,y,side){hctxSide=side;expm.classList.remove('show');hctx.classList.add('show');
  // Clamp inside the window so a header near the right edge does not open a menu off-screen.
  const w=hctx.offsetWidth,h=hctx.offsetHeight;
  hctx.style.left=Math.min(x,innerWidth-w-8)+'px';hctx.style.top=Math.min(y,innerHeight-h-8)+'px';}
function hctxPath(){return hctxSide==='a'?D.apath:D.bpath;}
function hctxName(){return hctxSide==='a'?D.a:D.b;}
function copyText(t){
  // A right-click menu item IS a user gesture, so the async clipboard is allowed; the textarea
  // fallback covers the WKWebView configurations where it is not.
  try{if(navigator.clipboard&&navigator.clipboard.writeText){navigator.clipboard.writeText(t);return;}}catch(e){}
  try{const ta=document.createElement('textarea');ta.value=t;ta.style.position='fixed';ta.style.opacity='0';
    document.body.appendChild(ta);ta.select();document.execCommand('copy');document.body.removeChild(ta);}catch(e){}}
[['ha','a'],['hb','b']].forEach(([id,side])=>{const n=$(id);if(!n)return;
  n.addEventListener('contextmenu',e=>{e.preventDefault();e.stopPropagation();showHctx(e.clientX,e.clientY,side);});
  n.title='Right-click for Reveal in Finder, Open, Copy path';});
hctx.onclick=e=>{const b=e.target.closest('button');if(!b)return;hideHctx();
  const p=hctxPath();const act=b.dataset.h;
  if(act==='reveal'&&p)location.href='hvniz://reveal?path='+encodeURIComponent(p);
  else if(act==='open'&&p)location.href='hvniz://opendefault?path='+encodeURIComponent(p);
  else if(act==='copypath'&&p)copyText(p);
  else if(act==='copyname')copyText(hctxName());
  else if(act==='swap')$('swap').click();};
// `closest` only exists on Elements, and a mousedown can be retargeted at the document itself
// (or at a text node in some engines), where this would THROW and leave both menus stuck open.
// Resolve to the nearest Element first, then ask.
function inside(e,sel){const t=e.target;const el=(t&&t.nodeType===1)?t:(t&&t.parentElement);return !!(el&&el.closest&&el.closest(sel));}
document.addEventListener('mousedown',e=>{
  if(!inside(e,'#hctx')&&!inside(e,'#chipa')&&!inside(e,'#chipb'))hideHctx();
  if(!inside(e,'#expmenu')&&!inside(e,'#exp'))expm.classList.remove('show');
  if(!inside(e,'#sharesheet')&&!inside(e,'#share'))ssheet.classList.remove('show');});
document.addEventListener('keydown',e=>{if(e.key==='Escape')closeMenus();});
let rsz;window.addEventListener('resize',()=>{clearTimeout(rsz);rsz=setTimeout(()=>{recalc();if(curView==='side')buildRail();},120);});
// ── init ──
setZoom(3);recalc();buildRail();pos();
// test handle: harness assertions drive the same internals users do
window.__cmp={D:D,vis:vis,apx:apx,rpx:rpx,mapPos:mapPos,ax:()=>AX,bx:()=>BX,go:go,view:view,setZoom:setZoom,applyFilter:applyFilter,focus:i=>{ci=i;focusCur();}};
window.__ready=true;
</script></body></html>"""


# ─────────────────────────────────────────────────────────────────────────────
# DRAFT TIMELINE (v0.56): N-drafts-of-one-document, reorder = re-diff.
# The native drafttimeline.html asks the host for adjacent-pair redlines along a
# sequence the user drags; the host drives THIS verb per pair (caching per
# directional pair, so a reorder only recomputes new adjacencies). We reuse the
# SAME alignment + word_redline the two-doc compare uses, so the timeline redline
# is identical to what "Compare documents" would show for that pair. Inline only:
# no side-by-side, no per-pair DOCX (a reorder must be instant), all on-device.
# ─────────────────────────────────────────────────────────────────────────────

def _pair_inline_from_paras(PA, PB):
    """Inline word-level redline HTML + counts for two paragraph lists.
    Mirrors the 'uni' branch of _text_payload but skips the side-by-side rows and
    the tracked-changes DOCX (kept fast for interactive reorders)."""
    sm = difflib.SequenceMatcher(None, PA, PB, autojunk=False)
    uni, ins, dels, changes = [], 0, 0, 0
    for tag, i1, i2, j1, j2 in sm.get_opcodes():
        if tag == "equal":
            for k in range(i1, i2):
                uni.append("<p>" + esc(PA[k]) + "</p>")
        elif tag == "delete":
            for k in range(i1, i2):
                uni.append('<p><span class="d">' + esc(PA[k]) + "</span></p>"); dels += 1; changes += 1
        elif tag == "insert":
            for k in range(j1, j2):
                uni.append('<p><span class="i">' + esc(PB[k]) + "</span></p>"); ins += 1; changes += 1
        elif tag == "replace":
            for kind, pa, pb in align_block(PA[i1:i2], PB[j1:j2]):
                if kind == "chg":
                    l, r, ni, nd = word_redline(pa, pb)
                    if ni == 0 and nd == 0:
                        uni.append("<p>" + esc(pa) + "</p>")
                    else:
                        uni.append("<p>" + l + " " + r + "</p>"); ins += ni; dels += nd; changes += 1
                elif kind == "del":
                    uni.append('<p><span class="d">' + esc(pa) + "</span></p>"); dels += 1; changes += 1
                else:
                    uni.append('<p><span class="i">' + esc(pb) + "</span></p>"); ins += 1; changes += 1
    return {"uni": "".join(uni), "ins": ins, "dels": dels, "changes": changes}


def _doc_date(path):
    """A best-effort authored date for auto-sequencing: dcterms:modified inside a
    .docx/.odt when present, otherwise the file's modification time. YYYY-MM-DD."""
    lp = path.lower()
    try:
        if lp.endswith((".docx", ".odt")):
            import zipfile
            with zipfile.ZipFile(path) as z:
                names = z.namelist()
                core = "docProps/core.xml" if lp.endswith(".docx") else "meta.xml"
                if core in names:
                    x = z.read(core).decode("utf-8", "ignore")
                    m = (re.search(r"<dcterms:modified[^>]*>([^<]+)</dcterms:modified>", x)
                         or re.search(r"<dcterms:created[^>]*>([^<]+)</dcterms:created>", x)
                         or re.search(r"<dc:date[^>]*>([^<]+)</dc:date>", x)
                         or re.search(r"<meta:creation-date[^>]*>([^<]+)</meta:creation-date>", x))
                    if m:
                        return m.group(1).strip()[:10]
    except Exception:
        pass
    try:
        return time.strftime("%Y-%m-%d", time.localtime(os.path.getmtime(path)))
    except Exception:
        return ""


def multi_diff(spec):
    """spec = {paths:[...], pairs:[[i,j],...]?, meta:bool}. Returns
    {diffs:{"i>j":{uni,ins,dels,changes}}, drafts:[{idx,id,name,date,text}]? }.
    Extraction is cached per path index so N-1 pairs never re-extract a file."""
    paths = spec.get("paths") or []
    pairs = spec.get("pairs")
    if pairs is None:
        pairs = [[k, k + 1] for k in range(len(paths) - 1)]
    want_meta = bool(spec.get("meta"))
    cache = {}

    def paras(k):
        if k not in cache:
            cache[k] = split_paras(extract(paths[k]))
        return cache[k]

    diffs = {}
    for pr in pairs:
        a, b = int(pr[0]), int(pr[1])
        if a < 0 or b < 0 or a >= len(paths) or b >= len(paths):
            continue
        diffs["%d>%d" % (a, b)] = _pair_inline_from_paras(paras(a), paras(b))
    out = {"diffs": diffs}
    if want_meta:
        drafts = []
        for k, p in enumerate(paths):
            drafts.append({"idx": k, "id": "d%d" % k, "name": os.path.basename(p),
                           "date": _doc_date(p), "text": "\n\n".join(paras(k))})
        out["drafts"] = drafts
    return out


if __name__ == "__main__":
    # Host-driven verb only (the module is otherwise imported by triage_app.py).
    #   python3 pagehawk_diff.py --multi-diff <in.json> <out.json>
    if "--multi-diff" in sys.argv:
        _i = sys.argv.index("--multi-diff")
        _in, _out = sys.argv[_i + 1], sys.argv[_i + 2]
        _spec = json.load(open(_in, encoding="utf-8"))
        _res = multi_diff(_spec)
        with open(_out, "w", encoding="utf-8") as _f:
            json.dump(_res, _f)
        print("multi-diff: %d drafts, %d pairs" % (len(_spec.get("paths") or []), len(_res["diffs"])))
        sys.exit(0)
    sys.stderr.write("pagehawk_diff.py: no runnable verb (use --multi-diff IN OUT)\n")
    sys.exit(2)
