"""Link survival test: analyse every exported PDF.

For each pdfs/<producer>__<style>__<layout>.pdf this checks
  - real Link annotations (pypdf, cross-checked with pdf.js getAnnotations)
  - Chrome (PDFium): annotations + PDFium web-link detection on the text
  - Firefox (pdf.js): annotations + pdf.js's own autolinker (vendored, run as is)
  - Apple (PDFKit): annotations only (plain-text detection is not measured)
  - text extraction with pdfminer.six, pypdf, pdf.js, PDFium and PDFKit: is
    "linkedin.com/in/<handle>" intact, and what URL does linkify-it pull out
  - optionally OpenResume's parser (openresume_check.mjs), if it can run
and writes raw.csv, the public CSV/PNG/zip/script copy, and the article JSON.

Ground truth: a URL "opens the right profile" when its host is linkedin.com or
www.linkedin.com and its path is exactly /in/<handle> (trailing slash OK,
query string allowed).

Run from the repo root (nothing is installed into the project):
  uv run --no-project --python 3.12 \
    --with pypdf==6.21.2 --with pdfminer.six==20260107 --with pypdfium2==5.14.0 \
    --with pyobjc-framework-Quartz==12.2.2 --with pillow==13.0.0 \
    python marketing/research/link-survival-test/analyze.py
It needs `npm ci` in marketing/research/link-survival-test (pdf.js, linkify-it).
A public copy of this file is published as
static/guides/linkedin-url-on-resume/link-survival-test-analyze.py (for
reading; re-running needs the whole research folder: pdfjs_check.mjs,
openresume_check.mjs, vendor/ and `npm ci`). `--pdfs <folder>` checks another
folder of <producer>__<style>__<layout>.pdf files and writes only .work/
outputs.
"""

from __future__ import annotations

import argparse
import csv
import ctypes
import hashlib
import io
import json
import logging
import platform
import re
import shutil
import subprocess
import sys
import zipfile
from pathlib import Path
from urllib.parse import urlsplit

HERE = Path(__file__).resolve().parent
RUN_DATE = "2026-10-25"

CLEAN = "https://www.linkedin.com/in/jordanlee"
ID_URL = "https://www.linkedin.com/in/jordan-lee-4b2a19173"
SHARE_URL = (
    "https://www.linkedin.com/in/jordan-lee-4b2a19173"
    "?utm_source=share&utm_campaign=share_via&utm_content=profile&utm_medium=ios_app"
)

# Ground truth per style. `visible` is the link text on the page (None = icon).
STYLES = [
    {"id": "typed_linked", "label": "Typed URL, linked", "visible": "linkedin.com/in/jordanlee",
     "href": CLEAN, "handle": "jordanlee", "sub": "linkedin.com/in/jordanlee"},
    {"id": "typed_plain", "label": "Typed URL, plain text", "visible": "linkedin.com/in/jordanlee",
     "href": None, "handle": "jordanlee", "sub": "linkedin.com/in/jordanlee (no link)"},
    {"id": "full_plain", "label": "Full https:// URL, plain text", "visible": "https://www.linkedin.com/in/jordanlee",
     "href": None, "handle": "jordanlee", "sub": "https://www.linkedin.com/in/jordanlee (no link)"},
    {"id": "word_linked", "label": "\"LinkedIn\" as link text", "visible": "LinkedIn",
     "href": CLEAN, "handle": "jordanlee", "sub": "LinkedIn"},
    {"id": "icon_linked", "label": "Icon only, linked", "visible": None,
     "href": CLEAN, "handle": "jordanlee", "sub": "(small icon, no text)"},
    {"id": "id_linked", "label": "Default URL with ID suffix, linked", "visible": "linkedin.com/in/jordan-lee-4b2a19173",
     "href": ID_URL, "handle": "jordan-lee-4b2a19173", "sub": "linkedin.com/in/jordan-lee-4b2a19173"},
    {"id": "share_linked", "label": "App share link, linked", "visible": SHARE_URL,
     "href": SHARE_URL, "handle": "jordan-lee-4b2a19173",
     "sub": "https://www.linkedin.com/in/jordan-lee-4b2a19173?utm_source=share&..."},
]
STYLE = {s["id"]: s for s in STYLES}
LINKED_STYLES = [s["id"] for s in STYLES if s["href"]]
LAYOUTS = [
    {"id": "single", "label": "Single column, centered contact line"},
    {"id": "sidebar", "label": "Two columns, 2.2 in contact sidebar"},
]
PRODUCERS = {
    "word_pdf": {"label": "Microsoft Word", "sub": "DOCX, Save as PDF", "how": "Word opens the DOCX; AppleScript 'save as ... file format format PDF'"},
    "pages_pdf": {"label": "Pages", "sub": "Imported DOCX, Export to PDF", "how": "Pages opens (imports) the same DOCX; AppleScript 'export ... as PDF' with default options"},
    "chrome_pdf": {"label": "Chrome", "sub": "HTML, Save as PDF engine", "how": "Equivalent HTML printed by Chromium 156's print-to-PDF (Skia PDF backend) via Playwright page.pdf(); the engine behind Chrome's Save as PDF and most web resume builders"},
}
# Producer used for the parse_view component (first one that produced PDFs)
PARSE_VIEW_PREFERENCE = ["word_pdf", "pages_pdf", "chrome_pdf"]

VIEWERS = [
    {"id": "chrome", "label": "Chrome (PDFium)",
     "stands_for": "Chrome's built-in PDF viewer and other Chromium browsers that use PDFium. Not current Microsoft Edge: its built-in reader moved to Adobe's Acrobat engine (default from Edge 156)."},
    {"id": "firefox", "label": "Firefox (pdf.js)",
     "stands_for": "Firefox's built-in PDF viewer; pdf.js also powers many in-browser PDF previews"},
    {"id": "apple", "label": "Apple Preview/Mail (PDFKit)",
     "stands_for": "Preview, Safari, Mail and Quick Look on the Mac, Files and Mail on iPhone/iPad (all PDFKit). Links from annotations only; plain-text URL detection not measured"},
]
EXTRACTORS = [
    {"id": "pdfminer", "label": "pdfminer.six", "stands_for": "Python text extraction used by many resume parsers and ATS prototypes (extract_text, default layout)"},
    {"id": "pypdf", "label": "pypdf", "stands_for": "Common pure-Python PDF library (page.extract_text(), default mode)"},
    {"id": "pdfjs", "label": "pdf.js", "stands_for": "JavaScript extraction (getTextContent()) used by browser-based parsers such as OpenResume"},
    {"id": "pdfium", "label": "PDFium", "stands_for": "PDFium's text layer (FPDFText_GetText, the call Chrome's viewer uses for selected text); also used by many server-side tools via pypdfium2"},
    {"id": "pdfkit", "label": "Apple PDFKit", "stands_for": "Apple's text layer (PDFPage.string), the text macOS/iOS apps get from a PDF"},
]
EXTRACTOR_IDS = [e["id"] for e in EXTRACTORS]
TONES = ["blue", "sky", "ink", "amber"]

# pdf.js web/app_options.js v6.4.299: enableAutoLinking { value: true,
# kind: VIEWER + PREFERENCE } -> on by default in Firefox's viewer.
FIREFOX_AUTOLINK_DEFAULT = True
# Chromium 156.0.8078.4 pdf/pdfium/pdfium_page.cc: PDFiumPage::CalculateLinks()
# always calls PopulateWebLinks() (FPDFLink_LoadWebLinks) before
# PopulateAnnotationLinks(); no setting turns it off.
CHROME_AUTOLINK = True
CHROMIUM_PDFIUM_REVISION = "8ca5b735df4263f43c830479b78213854a392c22"

OK_HOSTS = {"linkedin.com", "www.linkedin.com"}

log = logging.getLogger("analyze")


# ------------------------------------------------------------------ helpers

def opens_profile(url: str | None, handle: str) -> bool:
    if not url:
        return False
    try:
        parts = urlsplit(url.strip())
    except ValueError:
        return False
    if parts.scheme.lower() not in ("http", "https"):
        return False
    if (parts.hostname or "").lower() not in OK_HOSTS:
        return False
    path = parts.path
    if path.endswith("/"):
        path = path[:-1]
    return path == f"/in/{handle}"


def is_linkedin(url: str | None) -> bool:
    if not url:
        return False
    try:
        host = (urlsplit(url.strip()).hostname or "").lower()
    except ValueError:
        return False
    return host in OK_HOSTS


def intact(text: str, handle: str) -> bool:
    """linkedin.com/in/<handle> contiguous, not glued to more handle chars."""
    return re.search(r"linkedin\.com/in/" + re.escape(handle) + r"(?![A-Za-z0-9_-])", text) is not None


def norm_nl(text: str) -> str:
    return text.replace("\r\n", "\n").replace("\r", "\n")


def excerpt(text: str, layout: str) -> str:
    """Contact area: from the email up to the next heading (or 300 chars)."""
    t = norm_nl(text)
    i = t.find("jordan.lee@example.com")
    if i < 0:
        return ""
    stop = "SUMMARY" if layout == "single" else "SKILLS"
    j = t.find(stop, i)
    if j < 0 or j - i > 400:
        j = min(len(t), i + 300)
    return t[i:j].rstrip()


def kpct(k: int, n: int) -> dict:
    return {"k": k, "n": n, "pct": round(100.0 * k / n, 1) if n else 0.0}


def yn(v) -> str:
    return "not measured" if v is None else ("yes" if v else "no")


def tf(v) -> str:
    return "" if v is None else ("true" if v else "false")


# ------------------------------------------------------------------ engines

def check_pypdf(path: Path) -> dict:
    import pypdf
    reader = pypdf.PdfReader(str(path))
    annots = []
    for pno, page in enumerate(reader.pages):
        for a in page.get("/Annots") or []:
            a = a.get_object()
            if a.get("/Subtype") != "/Link":
                continue
            uri = None
            act = a.get("/A")
            if act is not None:
                act = act.get_object()
                if act.get("/S") == "/URI":
                    u = act.get("/URI")
                    uri = str(u.get_object() if hasattr(u, "get_object") else u)
            annots.append({"page": pno + 1, "uri": uri, "rect": [round(float(v), 2) for v in a.get("/Rect", [])]})
    text = "\n".join(page.extract_text() or "" for page in reader.pages)
    meta = reader.metadata or {}
    return {"annots": annots, "text": text, "producer": str(meta.get("/Producer", "")), "creator": str(meta.get("/Creator", ""))}


def check_pdfminer(path: Path) -> str:
    from pdfminer.high_level import extract_text
    return extract_text(str(path))


def _utf16_buf(n):
    return (ctypes.c_ushort * n)()


def check_pdfium(path: Path) -> dict:
    import pypdfium2 as pdfium
    import pypdfium2.raw as c
    pdf = pdfium.PdfDocument(str(path))
    annots, weblinks, texts = [], [], []
    for pno in range(len(pdf)):
        page = pdf[pno]
        # Link annotations, the way Chrome enumerates them
        pos = ctypes.c_int(0)
        link = c.FPDF_LINK()
        while c.FPDFLink_Enumerate(page.raw, ctypes.byref(pos), ctypes.byref(link)):
            url = None
            action = c.FPDFLink_GetAction(link)
            if action and c.FPDFAction_GetType(action) == c.PDFACTION_URI:
                n = c.FPDFAction_GetURIPath(pdf.raw, action, None, 0)
                buf = ctypes.create_string_buffer(n)
                c.FPDFAction_GetURIPath(pdf.raw, action, buf, n)
                url = buf.value.decode("latin-1")
            annots.append({"page": pno + 1, "url": url})
        # Web links detected in the text (Chrome: PDFiumPage::PopulateWebLinks)
        tp = page.get_textpage()
        wl = c.FPDFLink_LoadWebLinks(tp.raw)
        for i in range(c.FPDFLink_CountWebLinks(wl)):
            n = c.FPDFLink_GetURL(wl, i, None, 0)
            url = ""
            if n > 0:
                buf = _utf16_buf(n)
                got = c.FPDFLink_GetURL(wl, i, buf, n)
                url = bytes(buf)[: max(0, got - 1) * 2].decode("utf-16-le")
            # Chrome drops URLs with control or non-ASCII characters
            chrome_ok = all(" " <= ch < "\x7f" for ch in url)
            weblinks.append({"page": pno + 1, "url": url, "chrome_accepts": chrome_ok,
                             "rects": c.FPDFLink_CountRects(wl, i)})
        c.FPDFLink_CloseWebLinks(wl)
        texts.append(tp.get_text_range())
    return {"annots": annots, "weblinks": weblinks, "text": "\n".join(texts)}


def check_pdfkit(path: Path) -> dict:
    import Quartz
    from Foundation import NSURL, NSDataDetector, NSMakeRange, NSString, NSTextCheckingTypeLink
    doc = Quartz.PDFDocument.alloc().initWithURL_(NSURL.fileURLWithPath_(str(path)))
    annots, texts = [], []
    for pno in range(doc.pageCount()):
        page = doc.pageAtIndex_(pno)
        for a in page.annotations() or []:
            url = None
            if a.URL() is not None:
                url = str(a.URL().absoluteString())
            elif a.action() is not None and hasattr(a.action(), "URL") and a.action().URL() is not None:
                url = str(a.action().URL().absoluteString())
            annots.append({"page": pno + 1, "type": str(a.type() or ""), "url": url})
        texts.append(str(page.string() or ""))
    text = "\n".join(texts)
    # Informational only: what Foundation's NSDataDetector finds in that text.
    # This is NOT a measurement of Preview/Mail behaviour.
    det, _ = NSDataDetector.dataDetectorWithTypes_error_(NSTextCheckingTypeLink, None)
    ns = NSString.stringWithString_(text)
    found = [str(m.URL().absoluteString()) for m in det.matchesInString_options_range_(ns, 0, NSMakeRange(0, ns.length()))]
    return {"annots": annots, "text": text, "datadetector": found}


# ------------------------------------------------------------------ node side

def run_node(script: str, payload: dict, work: Path, tag: str) -> dict | None:
    inp = work / f"{tag}_in.json"
    out = work / f"{tag}_out.json"
    inp.write_text(json.dumps(payload))
    out.unlink(missing_ok=True)
    r = subprocess.run(["node", str(HERE / script), str(inp), str(out)], capture_output=True, text=True, cwd=HERE)
    if r.returncode != 0 or not out.exists():
        log.warning("%s failed: %s", script, (r.stderr or r.stdout).strip()[-1500:])
        return None
    print(r.stdout.strip().splitlines()[-1] if r.stdout.strip() else f"{script} ok")
    return json.loads(out.read_text())


# ------------------------------------------------------------------ main

def parse_name(name: str):
    producer, style, layout = name.split("__")
    return producer, style, layout


def analyse(pdf_dir: Path, work: Path) -> tuple[list[dict], dict]:
    files = sorted(pdf_dir.glob("*__*__*.pdf"))
    records = []
    for f in files:
        producer, style, layout = parse_name(f.stem)
        if style not in STYLE:
            continue
        rec = {"name": f.stem, "file": f.name, "producer": producer, "style": style, "layout": layout, "path": str(f)}
        rec["pypdf"] = check_pypdf(f)
        rec["pdfium"] = check_pdfium(f)
        rec["pdfkit"] = check_pdfkit(f)
        rec["texts"] = {
            "pdfminer": check_pdfminer(f),
            "pypdf": rec["pypdf"]["text"],
            "pdfium": rec["pdfium"]["text"],
            "pdfkit": rec["pdfkit"]["text"],
        }
        records.append(rec)
    payload = {"pdfs": [{"name": r["name"], "path": r["path"], "texts": r["texts"]} for r in records]}
    js = run_node("pdfjs_check.mjs", payload, work, "pdfjs")
    if js is None:
        sys.exit("pdfjs_check.mjs failed (did you run npm ci in the research folder?)")
    by_name = {r["name"]: r for r in js["results"]}
    for r in records:
        j = by_name[r["name"]]
        r["pdfjs"] = {"annots": j["annotations"], "autolinks": j["autolinks"]}
        r["texts"]["pdfjs"] = j["pdfjs_text"]
        r["linkify"] = j["linkify"]
    meta = {k: js[k] for k in ("pdfjs_version", "pdfjs_build", "linkify_it_version", "linkify_options", "node")}
    # Optional: OpenResume
    meta["openresume"] = None
    if (HERE / "openresume_check.mjs").exists():
        orx = run_node("openresume_check.mjs", {"pdfs": [{"name": r["name"], "path": r["path"]} for r in records]}, work, "openresume")
        if orx and orx.get("results"):
            meta["openresume"] = {k: v for k, v in orx.items() if k != "results"}
            ob = {x["name"]: x for x in orx["results"]}
            for r in records:
                r["openresume"] = ob.get(r["name"])
        else:
            meta["openresume"] = {"status": "not run", "reason": (orx or {}).get("reason", "openresume_check.mjs failed or is not set up")}
    return records, meta


def verdicts(r: dict, have_openresume: bool) -> dict:
    s = STYLE[r["style"]]
    h = s["handle"]
    v = {}
    pyp = [a["uri"] for a in r["pypdf"]["annots"]]
    pjs = [a["url"] for a in r["pdfjs"]["annots"]]
    v["n_link_annots"] = len(r["pypdf"]["annots"])
    v["n_link_annots_pdfjs"] = len(r["pdfjs"]["annots"])
    v["annot_urls"] = pyp
    v["real_link"] = any(opens_profile(u, h) for u in pyp)
    v["real_link_pdfjs"] = any(opens_profile(u, h) for u in pjs)
    # Chrome
    ch_ann = [a["url"] for a in r["pdfium"]["annots"]]
    ch_web = [w["url"] for w in r["pdfium"]["weblinks"] if w["chrome_accepts"]]
    v["chrome_annot_urls"], v["chrome_web_urls"] = ch_ann, [w["url"] for w in r["pdfium"]["weblinks"]]
    v["chrome_annot"] = any(opens_profile(u, h) for u in ch_ann)
    v["chrome_web"] = CHROME_AUTOLINK and any(opens_profile(u, h) for u in ch_web)
    v["chrome"] = v["chrome_annot"] or v["chrome_web"]
    # Firefox
    ff_auto = [a["url"] for a in r["pdfjs"]["autolinks"]]
    v["firefox_annot_urls"], v["firefox_auto_urls"] = pjs, ff_auto
    v["firefox_annot"] = any(opens_profile(u, h) for u in pjs)
    v["firefox_auto"] = any(opens_profile(u, h) for u in ff_auto)
    v["firefox"] = v["firefox_annot"] or (FIREFOX_AUTOLINK_DEFAULT and v["firefox_auto"])
    # Apple: annotation, else not measured if the page shows a URL
    ap = [a["url"] for a in r["pdfkit"]["annots"] if a["url"]]
    v["apple_annot_urls"] = ap
    v["apple_annot"] = any(opens_profile(u, h) for u in ap)
    shows_url = bool(s["visible"]) and "linkedin.com" in s["visible"]
    v["apple"] = True if v["apple_annot"] else (None if shows_url else False)
    v["apple_datadetector_urls"] = r["pdfkit"]["datadetector"]
    v["apple_datadetector_right"] = any(opens_profile(u, h) for u in r["pdfkit"]["datadetector"])
    v["all_viewers"] = v["chrome"] and v["firefox"] and v["apple"] is True
    measured = [x for x in (v["chrome"], v["firefox"], v["apple"]) if x is not None]
    v["all_measured_viewers"] = all(measured)
    v["n_viewers_measured"] = len(measured)
    v["n_viewers_ok"] = sum(1 for x in measured if x)
    # Text
    v["intact"] = {e: intact(r["texts"][e], h) for e in EXTRACTOR_IDS}
    v["excerpt"] = {e: excerpt(r["texts"][e], r["layout"]) for e in EXTRACTOR_IDS}
    v["text_intact_all"] = all(v["intact"].values())
    v["text_intact_any"] = any(v["intact"].values())
    v["n_intact"] = sum(v["intact"].values())
    v["linkify_urls"] = {e: [m["url"] for m in r["linkify"][e]] for e in EXTRACTOR_IDS}
    v["parser_url_by"] = {e: next((u for u in v["linkify_urls"][e] if is_linkedin(u)), None) for e in EXTRACTOR_IDS}
    v["parser_right_by"] = {e: opens_profile(v["parser_url_by"][e], h) for e in EXTRACTOR_IDS}
    v["parser_url"] = v["parser_url_by"]["pdfminer"]
    v["parser_right"] = v["parser_right_by"]["pdfminer"]
    v["openresume_url"] = None
    v["openresume_right"] = None
    v["openresume_contains"] = None
    if have_openresume:
        o = r.get("openresume") or {}
        u = o.get("url") or ""
        v["openresume_url"] = u or None
        # strict: the field used as a link opens the right profile. The link is
        # built the way OpenResume's own ResumePDFProfile.tsx does:
        # value.startsWith("http") ? value : `https://${value}`
        v["openresume_right"] = bool(u) and opens_profile(u if u.startswith("http") else "https://" + u, h)
        # lenient: the field contains linkedin.com/in/<handle> intact
        v["openresume_contains"] = bool(u) and intact(u, h)
    # Wrong target: a viewer or the parser produced a LinkedIn URL that is not the profile
    candidates = ch_ann + ch_web + pjs + ff_auto + ap + [x for x in v["parser_url_by"].values() if x]
    if v["openresume_url"]:
        ou = v["openresume_url"]
        candidates.append(ou if ou.startswith("http") else "https://" + ou)
    v["wrong_profile_urls"] = sorted({u for u in candidates if is_linkedin(u) and not opens_profile(u, h)})
    v["any_failure"] = (
        not v["chrome"] or not v["firefox"] or v["apple"] is False
        or not v["text_intact_all"] or not v["parser_right"]
    )
    return v


def raw_rows(r: dict, v: dict, have_openresume: bool) -> list[dict]:
    base = {"pdf": r["file"], "producer": r["producer"], "style": r["style"], "layout": r["layout"]}
    J = " | ".join
    rows = [
        {"check": "real_link", "engine": "pypdf", "result": tf(v["real_link"]), "n_found": v["n_link_annots"], "urls": J(str(u) for u in v["annot_urls"]), "excerpt": ""},
        {"check": "real_link", "engine": "pdf.js getAnnotations", "result": tf(v["real_link_pdfjs"]), "n_found": v["n_link_annots_pdfjs"], "urls": J(str(u) for u in v["firefox_annot_urls"]), "excerpt": ""},
        {"check": "chrome_annotation", "engine": "PDFium FPDFLink_Enumerate", "result": tf(v["chrome_annot"]), "n_found": len(v["chrome_annot_urls"]), "urls": J(str(u) for u in v["chrome_annot_urls"]), "excerpt": ""},
        {"check": "chrome_weblink", "engine": "PDFium FPDFLink_LoadWebLinks", "result": tf(v["chrome_web"]), "n_found": len(v["chrome_web_urls"]), "urls": J(v["chrome_web_urls"]), "excerpt": ""},
        {"check": "chrome", "engine": "PDFium (annotation or web link)", "result": tf(v["chrome"]), "n_found": "", "urls": "", "excerpt": ""},
        {"check": "firefox_annotation", "engine": "pdf.js getAnnotations", "result": tf(v["firefox_annot"]), "n_found": len(v["firefox_annot_urls"]), "urls": J(str(u) for u in v["firefox_annot_urls"]), "excerpt": ""},
        {"check": "firefox_autolink", "engine": "pdf.js Autolinker.findLinks", "result": tf(v["firefox_auto"]), "n_found": len(v["firefox_auto_urls"]), "urls": J(v["firefox_auto_urls"]), "excerpt": ""},
        {"check": "firefox", "engine": "pdf.js (annotation or autolink)", "result": tf(v["firefox"]), "n_found": "", "urls": "", "excerpt": ""},
        {"check": "apple_annotation", "engine": "PDFKit PDFPage.annotations", "result": tf(v["apple_annot"]), "n_found": len(v["apple_annot_urls"]), "urls": J(v["apple_annot_urls"]), "excerpt": ""},
        {"check": "apple", "engine": "PDFKit (annotation; plain text not measured)", "result": tf(v["apple"]), "n_found": "", "urls": "", "excerpt": ""},
        {"check": "apple_datadetector_info", "engine": "NSDataDetector on PDFKit text (informational, not a viewer measurement)", "result": tf(v["apple_datadetector_right"]), "n_found": len(v["apple_datadetector_urls"]), "urls": J(v["apple_datadetector_urls"]), "excerpt": ""},
    ]
    for e in EXTRACTOR_IDS:
        rows.append({"check": "text_intact", "engine": e, "result": tf(v["intact"][e]), "n_found": "", "urls": "", "excerpt": v["excerpt"][e]})
    for e in EXTRACTOR_IDS:
        rows.append({"check": "parser_linkify", "engine": f"linkify-it on {e} text", "result": tf(v["parser_right_by"][e]), "n_found": len(v["linkify_urls"][e]), "urls": J(v["linkify_urls"][e]), "excerpt": ""})
    if have_openresume:
        rows.append({"check": "parser_openresume", "engine": "OpenResume parseResumeFromPdf profile.url (opens right profile as a link)", "result": tf(v["openresume_right"]), "n_found": "", "urls": v["openresume_url"] or "", "excerpt": ""})
        rows.append({"check": "parser_openresume_contains", "engine": "OpenResume profile.url contains linkedin.com/in/<handle> intact", "result": tf(v["openresume_contains"]), "n_found": "", "urls": v["openresume_url"] or "", "excerpt": ""})
    return [{**base, **row} for row in rows]


def agg(vs: list[dict], have_openresume: bool) -> dict:
    n = len(vs)
    out = {"n": n}
    for key in ("real_link", "chrome", "firefox", "all_viewers", "all_measured_viewers", "text_intact_all", "text_intact_any", "parser_right"):
        name = "parser_right_profile" if key == "parser_right" else key
        out[name] = kpct(sum(1 for v in vs if v[key]), n)
    ap = [v["apple"] for v in vs]
    measured = [a for a in ap if a is not None]
    d = kpct(sum(1 for a in measured if a), len(measured))
    d["n_not_measured"] = len(ap) - len(measured)
    if not measured:
        d["na"] = True
    out["apple"] = d
    out["text_intact_by_extractor"] = {e: kpct(sum(1 for v in vs if v["intact"][e]), n) for e in EXTRACTOR_IDS}
    out["parser_right_by_extractor"] = {e: kpct(sum(1 for v in vs if v["parser_right_by"][e]), n) for e in EXTRACTOR_IDS}
    out["wrong_profile_url"] = kpct(sum(1 for v in vs if v["wrong_profile_urls"]), n)
    if have_openresume:
        out["openresume_right_profile"] = kpct(sum(1 for v in vs if v["openresume_right"]), n)
        out["openresume_contains_profile"] = kpct(sum(1 for v in vs if v["openresume_contains"]), n)
    return out


def viewer_value(stat: dict) -> dict:
    v = {"pct": stat["pct"], "k": stat["k"], "n": stat["n"]}
    if stat.get("na"):
        v["na"] = True
    if stat.get("n_not_measured"):
        v["n_not_measured"] = stat["n_not_measured"]
    return v


def verdict_for(style_id: str, bs: dict, bsl: dict, by_prod_style: dict, shown_producer: str | None = None) -> tuple[str, str]:
    s = STYLE[style_id]
    C, F = bs["chrome"]["pct"], bs["firefox"]["pct"]
    A = bs["apple"]
    apple_full = A.get("na") or A["pct"] == 100.0
    click_full = C == 100.0 and F == 100.0 and apple_full
    click_zero = C == 0.0 and F == 0.0 and (A.get("na") or A["pct"] == 0.0)
    R = bs["parser_right_profile"]["pct"]
    R1, R2 = bsl["single"]["parser_right_profile"]["pct"], bsl["sidebar"]["parser_right_profile"]["pct"]
    dropped = [PRODUCERS[p]["label"] for p, st in by_prod_style.items() if s["href"] and st["real_link"]["k"] == 0]
    shows_url = bool(s["visible"]) and "linkedin.com" in s["visible"]

    apple_note = " (Apple not measured)" if A.get("na") else ""
    if click_full:
        click = "Clickable in all 3 viewers" if not A.get("na") else "Clickable in Chrome and Firefox" + apple_note
    elif click_zero:
        click = ("Not clickable in Chrome or Firefox" + apple_note) if A.get("na") else "Not clickable in any viewer"
    elif bsl["single"]["chrome"]["pct"] == 100.0 and bsl["single"]["firefox"]["pct"] == 100.0 and bsl["sidebar"]["chrome"]["pct"] < 100.0:
        click = "Clickable in Chrome and Firefox only when the URL fits on one line" + apple_note
    elif dropped:
        kept = [PRODUCERS[p]["label"] for p, st in by_prod_style.items() if st["real_link"]["k"] == st["real_link"]["n"]]
        shown = PRODUCERS[shown_producer]["label"] if shown_producer else None
        if shown and shown in dropped:
            # The link lab shows this producer's PDFs, so lead with what the reader sees there.
            kept_how = [("Chrome's Save as PDF" if k == "Chrome" else k) for k in kept]
            click = f"No link in these {shown} PDFs: {shown} dropped it ({' and '.join(kept_how)} kept it)"
        else:
            click = f"Clickable only in the {' and '.join(kept)} PDFs ({' and '.join(dropped)} dropped the link)"
    else:
        click = f"Clickable in {C:g}% of PDFs in Chrome and {F:g}% in Firefox"

    side = bsl["sidebar"]["parser_right_profile"]
    if not shows_url:
        text = "text-only software never sees the URL"
    elif R == 100.0:
        text = "parsers read the full URL"
    elif R1 == 100.0 and R2 < 100.0:
        text = f"parsers read it from single-column PDFs but got a broken URL from {side['n'] - side['k']} of {side['n']} sidebar PDFs"
    else:
        text = f"parsers got the right profile from {bs['parser_right_profile']['k']} of {bs['parser_right_profile']['n']} PDFs"

    parse_full, parse_zero = R == 100.0, R == 0.0
    if click_full and parse_full and not A.get("na"):
        tone = "go"
    elif (not click_full and parse_zero) or (click_zero and not parse_full):
        tone = "warn"
    else:
        tone = "wait"
    verdict = f"{click}; {text}."
    return verdict.replace("\u2014", ",").replace("\u2013", "-"), tone


def contact_visible(style_id: str) -> str:
    s = STYLE[style_id]
    tail = s["visible"] if s["visible"] else "[in]"
    return "jordan.lee@example.com \u00b7 (555) 010-0142 \u00b7 Austin, TX \u00b7 " + tail


# ------------------------------------------------------------------ figure

def contact_bbox(path: Path, layout: str, annots: list[dict]):
    import pypdfium2 as pdfium
    import pypdfium2.raw as c
    pdf = pdfium.PdfDocument(str(path))
    page = pdf[0]
    W, H = page.get_size()
    tp = page.get_textpage()

    def find(s):
        se = tp.search(s, match_case=True)
        hit = se.get_next()
        return hit[0] if hit else None

    if layout == "single":
        a, b = find("Jordan Lee"), find("SUMMARY")
        xmax = W
    else:
        a, b = find("CONTACT"), find("SKILLS")
        xmax = 200.0
    boxes = []
    for i in range(a, b):
        l, bt, r, t = tp.get_charbox(i)
        if r - l <= 0 or t - bt <= 0 or l > xmax:
            continue
        boxes.append((l, bt, r, t))
    for an in annots:
        if an["rect"] and an["rect"][0] < xmax:
            x0, y0, x1, y1 = an["rect"]
            boxes.append((min(x0, x1), min(y0, y1), max(x0, x1), max(y0, y1)))
    ymin_text = min(bx[1] for bx in boxes) - 30
    for obj in page.get_objects(filter=[c.FPDF_PAGEOBJ_IMAGE]):
        l, bt, r, t = obj.get_bounds()
        if l < xmax and bt > ymin_text:
            boxes.append((l, bt, r, t))
    x0 = min(bx[0] for bx in boxes) - 8
    y0 = min(bx[1] for bx in boxes) - 6
    x1 = max(bx[2] for bx in boxes) + 8
    y1 = max(bx[3] for bx in boxes) + 6
    if layout == "sidebar":  # show the whole sidebar column width
        x0, x1 = min(x0, 36.0), max(x1, 36.0 + 2.2 * 72)
    return pdf, page, (max(0, x0), max(0, y0), min(W, x1), min(H, y1)), (W, H)


def make_figure(tiles: list[dict], out: Path) -> tuple[int, int]:
    """2-column grid of real PDFium renders of the contact area, one scale for all."""
    from PIL import Image, ImageDraw, ImageFont
    font_b = ImageFont.truetype("/System/Library/Fonts/Supplemental/Arial Bold.ttf", 21)
    font_r = ImageFont.truetype("/System/Library/Fonts/Supplemental/Arial.ttf", 19)
    cols, tile_w, label_h, gap = 2, 770, 60, 24  # gap = vertical space between rows
    gap_x = (1600 - cols * tile_w) // (cols + 1)  # 20 px, so the image is 1600 px wide
    crops = []
    for t in tiles:
        pdf, page, box, size = contact_bbox(Path(t["path"]), t["layout"], t["annots"])
        crops.append((pdf, page, box, size))
    # one scale for every tile, so text sizes are comparable across tiles
    scale = min(min((tile_w - 4) / (b[2] - b[0]) for _, _, b, _ in crops), 3.0)
    renders = []
    for (pdf, page, (x0, y0, x1, y1), (W, H)) in crops:
        im = page.render(scale=scale, crop=(x0, y0, W - x1, H - y1), fill_color=(255, 255, 255, 255)).to_pil().convert("RGB")
        renders.append(im)
        pdf.close()
    nrows = (len(tiles) + cols - 1) // cols
    row_h = [label_h + max(renders[i].height for i in range(r * cols, min(len(tiles), (r + 1) * cols))) + 4 for r in range(nrows)]
    width = cols * tile_w + (cols + 1) * gap_x
    height = sum(row_h) + (nrows + 1) * gap
    canvas = Image.new("RGB", (width, height), "white")
    d = ImageDraw.Draw(canvas)
    y = gap
    for r in range(nrows):
        for c in range(cols):
            i = r * cols + c
            if i >= len(tiles):
                continue
            t, im = tiles[i], renders[i]
            cx = gap_x + c * (tile_w + gap_x)
            d.text((cx, y), t["title"], font=font_b, fill=(17, 24, 39))
            d.text((cx, y + 29), t["verdict"], font=font_r, fill=(68, 68, 68))
            by = y + label_h
            canvas.paste(im, (cx + 2, by + 2))
            d.rectangle([cx, by, cx + im.width + 3, by + im.height + 3], outline=(200, 205, 212), width=1)
        y += row_h[r] + gap
    canvas.save(out, format="PNG", optimize=True)
    return width, height


# ------------------------------------------------------------------ outputs

def write_zip(files: list[Path], out: Path) -> None:
    with zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED) as z:
        for f in sorted(files):
            zi = zipfile.ZipInfo(f"link-survival-test-pdfs/{f.name}", date_time=(2026, 10, 25, 0, 0, 0))
            zi.compress_type = zipfile.ZIP_DEFLATED
            zi.external_attr = 0o644 << 16
            z.writestr(zi, f.read_bytes())


def versions(meta: dict, export_log: dict) -> dict:
    import pdfminer
    import pypdf
    import pypdfium2.version as pv
    import objc
    sw = {k: subprocess.run(["sw_vers", f"-{k}"], capture_output=True, text=True).stdout.strip() for k in ("productVersion", "buildVersion")}
    w = export_log.get("word_pdf", {})
    p = export_log.get("pages_pdf", {})
    ch = export_log.get("chrome_pdf", {})
    return {
        "macos": f"{sw['productVersion']} ({sw['buildVersion']})",
        "word": f"{w.get('version')} ({w.get('build')})" + ("" if w.get("status") == "ok" else f", {w.get('status')}: not used"),
        "pages": f"{p.get('version')} ({p.get('build')})",
        "chromium": ch.get("version"),
        "playwright_core": ch.get("playwright_core"),
        "pdfjs": f"pdfjs-dist {meta['pdfjs_version']} (build {meta['pdfjs_build']})",
        "pdfium": f"pypdfium2 {pv.PYPDFIUM_INFO} (PDFium {pv.PDFIUM_INFO})",
        "chrome_156_pdfium_revision": CHROMIUM_PDFIUM_REVISION,
        "pdfminer": f"pdfminer.six {pdfminer.__version__}",
        "pypdf": f"pypdf {pypdf.__version__}",
        "pyobjc": f"pyobjc-framework-Quartz {objc.__version__}",
        "linkify_it": f"linkify-it {meta['linkify_it_version']} {json.dumps(meta['linkify_options'])}",
        "python": platform.python_version(),
        "node": meta["node"],
    }


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--pdfs", type=Path, default=HERE / "pdfs")
    args = ap.parse_args()
    logging.basicConfig(level=logging.WARNING, format="%(levelname)s %(message)s")
    logging.getLogger("pypdf").setLevel(logging.ERROR)
    logging.getLogger("pdfminer").setLevel(logging.ERROR)

    in_repo = (HERE / "export.py").exists() and args.pdfs.resolve() == (HERE / "pdfs").resolve()
    root = HERE.parents[2] if in_repo else None
    work = HERE / ".work"
    work.mkdir(exist_ok=True)

    records, meta = analyse(args.pdfs, work)
    have_or = bool(meta.get("openresume")) and "status" not in meta["openresume"]
    for r in records:
        r["v"] = verdicts(r, have_or)

    producers_run = [p for p in PRODUCERS if any(r["producer"] == p for r in records)]
    export_log = {}
    if (args.pdfs / "export_log.json").exists():
        export_log = json.loads((args.pdfs / "export_log.json").read_text())

    # sanity: pypdf and pdf.js must agree on real links
    disagree = [r["name"] for r in records if r["v"]["real_link"] != r["v"]["real_link_pdfjs"]]
    if disagree:
        log.warning("pypdf and pdf.js disagree on real links: %s", disagree)
    # sanity: src manifest matches ground truth
    man = HERE / "src" / "manifest.json"
    if in_repo and man.exists():
        m = json.loads(man.read_text())
        for s in m["styles"]:
            assert STYLE[s["id"]]["visible"] == s["visible"] and STYLE[s["id"]]["href"] == s["href"], s["id"]

    # -------- raw.csv
    rows = []
    for r in records:
        rows += raw_rows(r, r["v"], have_or)
    fields = ["pdf", "producer", "style", "layout", "check", "engine", "result", "n_found", "urls", "excerpt"]
    raw_path = (HERE / "raw.csv") if in_repo else (work / "raw.csv")
    with open(raw_path, "w", newline="", encoding="utf-8") as f:
        w = csv.DictWriter(f, fieldnames=fields, lineterminator="\n")
        w.writeheader()
        w.writerows(rows)

    # -------- aggregates
    def sel(**kw):
        return [r["v"] for r in records if all(r[k] == v for k, v in kw.items())]

    by_style = {s["id"]: agg(sel(style=s["id"]), have_or) for s in STYLES}
    by_style_layout = {s["id"]: {l["id"]: agg(sel(style=s["id"], layout=l["id"]), have_or) for l in LAYOUTS} for s in STYLES}
    by_prod_style = {s["id"]: {p: agg(sel(style=s["id"], producer=p), have_or) for p in producers_run} for s in STYLES}
    by_producer = {}
    for p in producers_run:
        linked = [r["v"] for r in records if r["producer"] == p and r["style"] in LINKED_STYLES]
        allv = sel(producer=p)
        by_producer[p] = {
            "n": len(allv),
            "real_links_kept": kpct(sum(1 for v in linked if v["real_link"]), len(linked)),
            "all_viewers": kpct(sum(1 for v in allv if v["all_viewers"]), len(allv)),
            "text_intact_all": kpct(sum(1 for v in allv if v["text_intact_all"]), len(allv)),
            "parser_right_profile": kpct(sum(1 for v in allv if v["parser_right"]), len(allv)),
            "links_dropped": sorted(f"{r['style']}/{r['layout']}" for r in records if r["producer"] == p and r["style"] in LINKED_STYLES and not r["v"]["real_link"]),
        }

    def score(sid):
        b = by_style[sid]
        return (b["all_viewers"]["pct"], b["text_intact_all"]["pct"], b["parser_right_profile"]["pct"], b["chrome"]["pct"] + b["firefox"]["pct"])

    best = max((s["id"] for s in STYLES), key=lambda sid: (score(sid), -[x["id"] for x in STYLES].index(sid)))
    pcts = lambda sid, k: by_style[sid][k]["pct"]
    lay = lambda sid, l, k: by_style_layout[sid][l][k]["pct"]
    headline = {
        "best_style_id": best,
        "best_style_label": STYLE[best]["label"],
        "n_producers_run": len(producers_run),
        "typed_linked_all_viewers_pct": pcts("typed_linked", "all_viewers"),
        "typed_linked_text_intact_pct": pcts("typed_linked", "text_intact_all"),
        "typed_linked_parser_right_pct": pcts("typed_linked", "parser_right_profile"),
        "word_linked_all_viewers_pct": pcts("word_linked", "all_viewers"),
        "word_linked_text_intact_pct": pcts("word_linked", "text_intact_all"),
        "word_linked_parser_right_pct": pcts("word_linked", "parser_right_profile"),
        "icon_linked_all_viewers_pct": pcts("icon_linked", "all_viewers"),
        "icon_linked_real_link_pct": pcts("icon_linked", "real_link"),
        "icon_linked_text_intact_pct": pcts("icon_linked", "text_intact_all"),
        "id_linked_sidebar_text_intact_pct": lay("id_linked", "sidebar", "text_intact_all"),
        "id_linked_sidebar_parser_right_pct": lay("id_linked", "sidebar", "parser_right_profile"),
        "share_linked_all_viewers_pct": pcts("share_linked", "all_viewers"),
        "share_linked_single_text_intact_pct": lay("share_linked", "single", "text_intact_all"),
        "share_linked_sidebar_text_intact_pct": lay("share_linked", "sidebar", "text_intact_all"),
        "share_linked_sidebar_parser_right_pct": lay("share_linked", "sidebar", "parser_right_profile"),
        "typed_plain_chrome_pct": pcts("typed_plain", "chrome"),
        "typed_plain_firefox_pct": pcts("typed_plain", "firefox"),
        "typed_plain_parser_right_pct": pcts("typed_plain", "parser_right_profile"),
        "full_plain_chrome_pct": pcts("full_plain", "chrome"),
        "full_plain_firefox_pct": pcts("full_plain", "firefox"),
        "full_plain_single_chrome_pct": lay("full_plain", "single", "chrome"),
        "full_plain_sidebar_chrome_pct": lay("full_plain", "sidebar", "chrome"),
        "full_plain_single_firefox_pct": lay("full_plain", "single", "firefox"),
        "full_plain_sidebar_firefox_pct": lay("full_plain", "sidebar", "firefox"),
        "producers_dropping_links": [PRODUCERS[p]["label"] for p in producers_run if by_producer[p]["real_links_kept"]["k"] < by_producer[p]["real_links_kept"]["n"]],
        "links_dropped": {PRODUCERS[p]["label"]: by_producer[p]["links_dropped"] for p in producers_run if by_producer[p]["links_dropped"]},
        "real_links_kept_overall_pct": kpct(sum(by_producer[p]["real_links_kept"]["k"] for p in producers_run), sum(by_producer[p]["real_links_kept"]["n"] for p in producers_run))["pct"],
        "n_pdfs_with_any_failure": sum(1 for r in records if r["v"]["any_failure"]),
        "n_pdfs_wrong_profile_url": sum(1 for r in records if r["v"]["wrong_profile_urls"]),
        # Formats that were clickable in every viewer AND kept the URL intact in every
        # extractor, in every PDF (all producers, both layouts).
        "n_styles_pass_everything": sum(
            1 for s in [st["id"] for st in STYLES]
            if by_style[s]["all_viewers"]["k"] == by_style[s]["n"] and by_style[s]["text_intact_all"]["k"] == by_style[s]["n"]
        ),
        "styles_pass_everything": [
            s for s in [st["id"] for st in STYLES]
            if by_style[s]["all_viewers"]["k"] == by_style[s]["n"] and by_style[s]["text_intact_all"]["k"] == by_style[s]["n"]
        ],
        "wrong_profile_urls_seen": sorted({u for r in records for u in r["v"]["wrong_profile_urls"]}),
        "sidebar_parser_right_pct": kpct(sum(1 for r in records if r["layout"] == "sidebar" and r["v"]["parser_right"]), sum(1 for r in records if r["layout"] == "sidebar"))["pct"],
        "single_parser_right_pct": kpct(sum(1 for r in records if r["layout"] == "single" and r["v"]["parser_right"]), sum(1 for r in records if r["layout"] == "single"))["pct"],
        "word_producer_blocked": export_log.get("word_pdf", {}).get("status") not in (None, "ok"),
    }
    if have_or:
        headline["openresume_right_pct"] = kpct(sum(1 for r in records if r["v"]["openresume_right"]), len(records))["pct"]
        headline["openresume_contains_pct"] = kpct(sum(1 for r in records if r["v"]["openresume_contains"]), len(records))["pct"]
        headline["openresume_typed_linked_right_pct"] = by_style["typed_linked"]["openresume_right_profile"]["pct"]
        headline["openresume_typed_linked_contains_pct"] = by_style["typed_linked"]["openresume_contains_profile"]["pct"]
        headline["openresume_urls_seen"] = sorted({r["v"]["openresume_url"] for r in records if r["v"]["openresume_url"]})

    # -------- charts
    style_rows = lambda vals: [{"label": s["label"], "sub": s["sub"], "values": vals(s["id"])} for s in STYLES]
    charts = {
        "viewers_by_style": {
            "series": [{"name": "Chrome (PDFium)", "tone": "blue"}, {"name": "Firefox (pdf.js)", "tone": "sky"}, {"name": "Apple Preview/Mail (PDFKit)", "tone": "ink"}],
            "rows": style_rows(lambda sid: [viewer_value(by_style[sid]["chrome"]), viewer_value(by_style[sid]["firefox"]), viewer_value(by_style[sid]["apple"])]),
        },
        "text_by_style": {
            "series": [{"name": e["label"], "tone": TONES[i % len(TONES)]} for i, e in enumerate(EXTRACTORS)],
            "rows": style_rows(lambda sid: [viewer_value(by_style[sid]["text_intact_by_extractor"][e]) for e in EXTRACTOR_IDS]),
        },
        "parser_by_style": {
            "series": [{"name": "Single column", "tone": "blue"}, {"name": "Sidebar", "tone": "amber"}],
            "rows": style_rows(lambda sid: [viewer_value(by_style_layout[sid][l]["parser_right_profile"]) for l in ("single", "sidebar")]),
        },
        "producers": {
            "series": [{"name": "Real links kept", "tone": "blue"}],
            "rows": [{"label": PRODUCERS[p]["label"], "sub": PRODUCERS[p]["sub"], "values": [viewer_value(by_producer[p]["real_links_kept"])]} for p in producers_run],
        },
    }

    # -------- parse_view
    pv_prod = next(p for p in PARSE_VIEW_PREFERENCE if p in producers_run)
    parse_view = []
    for s in STYLES:
        entry = {"id": s["id"], "label": s["label"], "visible": contact_visible(s["id"]), "href": s["href"], "icon": s["id"] == "icon_linked"}
        for l in ("single", "sidebar"):
            r = next(x for x in records if x["producer"] == pv_prod and x["style"] == s["id"] and x["layout"] == l)
            v = r["v"]
            entry[l] = {"pdf": r["file"], "extracted": v["excerpt"]["pdfminer"], "parser_url": v["parser_url"],
                        "parser_right": v["parser_right"],
                        "viewers": {"chrome": v["chrome"], "firefox": v["firefox"], "apple": v["apple"]}}
        entry["verdict"], entry["tone"] = verdict_for(s["id"], by_style[s["id"]], by_style_layout[s["id"]], by_prod_style[s["id"]], pv_prod)
        parse_view.append(entry)

    # -------- public CSV
    pub_rows = []
    for r in records:
        v = r["v"]
        row = {"producer": r["producer"], "style": r["style"], "layout": r["layout"], "pdf": r["file"],
               "real_link": yn(v["real_link"]), "chrome": yn(v["chrome"]), "firefox": yn(v["firefox"]), "apple": yn(v["apple"])}
        for e in EXTRACTOR_IDS:
            row[f"text_{e}"] = yn(v["intact"][e])
        row["parser_url"] = v["parser_url"] or ""
        row["parser_right_profile"] = yn(v["parser_right"])
        if have_or:
            row["openresume_url"] = v["openresume_url"] or ""
            row["openresume_right_profile"] = yn(v["openresume_right"])
        pub_rows.append(row)

    # -------- figure tiles (parse_view producer)
    tile_specs = [("typed_linked", "single"), ("typed_plain", "single"), ("word_linked", "single"),
                  ("icon_linked", "single"), ("share_linked", "sidebar"), ("id_linked", "sidebar")]
    tiles = []
    for sid, l in tile_specs:
        r = next(x for x in records if x["producer"] == pv_prod and x["style"] == sid and x["layout"] == l)
        v = r["v"]
        m = v["n_viewers_measured"]
        click = f"Clickable in {v['n_viewers_ok']} of {m} viewers" if m == 3 else f"Clickable in {v['n_viewers_ok']} of {m} measured viewers"
        tiles.append({"path": r["path"], "layout": l, "annots": r["pypdf"]["annots"],
                      "title": f"{STYLE[sid]['label']} ({sid}) \u00b7 {'single column' if l == 'single' else 'sidebar'} \u00b7 {PRODUCERS[pv_prod]['label']}",
                      "verdict": f"{click} \u00b7 URL kept by {v['n_intact']} of {len(EXTRACTOR_IDS)} extractors"})

    if not in_repo:
        pub = work / "public.csv"
        with open(pub, "w", newline="", encoding="utf-8") as f:
            w = csv.DictWriter(f, fieldnames=list(pub_rows[0]), lineterminator="\n")
            w.writeheader()
            w.writerows(pub_rows)
        print(f"checked {len(records)} PDFs; wrote {raw_path} and {pub}")
        return

    public = root / "static/guides/linkedin-url-on-resume"
    public.mkdir(parents=True, exist_ok=True)
    pub_csv = public / "link-survival-test-2026-10.csv"
    with open(pub_csv, "w", newline="", encoding="utf-8") as f:
        w = csv.DictWriter(f, fieldnames=list(pub_rows[0]), lineterminator="\n")
        w.writeheader()
        w.writerows(pub_rows)
    png_w, png_h = make_figure(tiles, public / "header-samples.png")
    write_zip([Path(r["path"]) for r in records], public / "link-survival-test-pdfs.zip")
    shutil.copyfile(Path(__file__), public / "link-survival-test-analyze.py")

    n_style = {s["id"]: by_style[s["id"]]["n"] for s in STYLES}
    data = {
        "_note": "Generated by marketing/research/link-survival-test/analyze.py from real app exports and real PDF engines. Automated, not simulated. Not a test of any company's applicant tracking system. Don't edit by hand.",
        "run_date": RUN_DATE,
        "automated": True,
        "simulated": False,
        "n_pdfs": len(records),
        "n_producers": len(producers_run),
        "n_styles": len(STYLES),
        "n_layouts": len(LAYOUTS),
        "n_extractors": len(EXTRACTORS),
        "n_viewers": len(VIEWERS),
        "n_checks": len(rows),
        "n_per_style": sorted(set(n_style.values()))[0] if len(set(n_style.values())) == 1 else n_style,
        "versions": versions(meta, export_log),
        "producers": [{"id": p, "label": PRODUCERS[p]["label"], "how": PRODUCERS[p]["how"],
                       "pdf_producer_metadata": next(r["pypdf"]["producer"] for r in records if r["producer"] == p)} for p in producers_run],
        "producers_not_run": [{"id": k, "label": PRODUCERS.get(k, {}).get("label", "macOS Print > Save as PDF"),
                               "status": e.get("status"), "reason": e.get("blocker") or e.get("error")}
                              for k, e in export_log.items() if e.get("status") != "ok"],
        "styles": [{"id": s["id"], "label": s["label"], "visible": s["visible"], "href": s["href"], "handle": s["handle"]} for s in STYLES],
        "layouts": LAYOUTS,
        "viewers": VIEWERS,
        "extractors": EXTRACTORS,
        "parser": {"id": "linkify_pdfminer", "label": "linkify-it on pdfminer.six text",
                   "how": "First LinkedIn-host URL that linkify-it 6.1.0 (fuzzyLink on) finds in the pdfminer.six text; right profile = host linkedin.com/www.linkedin.com and path /in/<handle>"},
        "openresume": meta.get("openresume"),
        "firefox_autolink_default": FIREFOX_AUTOLINK_DEFAULT,
        "chrome_autolink": CHROME_AUTOLINK,
        "autolink_rules": {
            "chrome": "PDFium CPDF_LinkExtract: a whitespace-delimited word must contain http:// or https:// or start with www.; a line break ends the word unless the line ends in a hyphen",
            "firefox": "pdf.js Autolinker: regex needs https?://, www. or mailto: at a word boundary; text items joined with newlines; a hyphen at a line end is joined",
            "apple": "not measured",
        },
        "by_style": by_style,
        "by_style_layout": by_style_layout,
        "by_producer": by_producer,
        "by_producer_style": by_prod_style,
        "headline": headline,
        "charts": charts,
        "parse_view_producer": pv_prod,
        "parse_view": parse_view,
        "header_samples_png": {"path": "/static/guides/linkedin-url-on-resume/header-samples.png", "width": png_w, "height": png_h, "producer": pv_prod},
        "reused": {"website_field_study": _reused_website_field(root)},
    }
    out = root / "content/guides/data/link-survival-test.json"
    out.write_text(json.dumps(data, indent=1, ensure_ascii=False) + "\n", encoding="utf-8")

    for p in (raw_path, pub_csv, out):
        print(f"{p.relative_to(root)}  sha256 {hashlib.sha256(p.read_bytes()).hexdigest()[:16]}")
    print(f"header-samples.png {png_w}x{png_h}")
    print(f"{len(records)} PDFs, {len(rows)} checks, producers: {', '.join(producers_run)}")


def _reused_website_field(root: Path) -> dict:
    """Copy the LinkedIn-field counts from our Oct 2026 website-field study, so the
    guide can cite them from its own data file (a guide reads one data file)."""
    src = root / "content/guides/data/website-field-study.json"
    study = json.loads(src.read_text(encoding="utf-8"))
    field = study["linkedin_field"]
    return {
        "source": "content/guides/data/website-field-study.json",
        "collected_at": study.get("collected_at"),
        "n_forms": study["n_rows"],
        "linkedin_field_present": field["present"],
        "linkedin_field_required": field["required"],
    }


if __name__ == "__main__":
    main()
