Claude Code transcript - page 10/11

add footer with trademark attribution to Apple for p/n/k and iWork, basic combined privacy and terms that says we don't collect any personal data aside from server-side logs and anonymous analytics by Cloudflare. also add a link to the github repo in the header! also is it possible to make pretty-printing a little smarter so small objects like "position": { "x": 511.5, "y": 728.5 }, can be on one line?

Thinking

Four items. First the pretty-printer — a bottom-up formatter that inlines any subtree fitting one line (built once per node, no O(n²) restringify):

✏️ Edit main.ts
/Users/phebert/pnk/viewer/src/main.ts
// Above this many pretty-printed bytes, skip syntax coloring: a dense
// Numbers envelope runs to tens of MB and a span-per-token DOM would hang
// the tab. Plain <pre> text stays fast at any size.
const HIGHLIGHT_LIMIT = 3 * 1024 * 1024;
+
// Above this many pretty-printed bytes, skip syntax coloring: a dense
// Numbers envelope runs to tens of MB and a span-per-token DOM would hang
// the tab. Plain <pre> text stays fast at any size.
const HIGHLIGHT_LIMIT = 3 * 1024 * 1024;

// Pretty-print with compaction: any object/array whose one-line form fits
// the line budget stays on one line ({"x": 511.5, "y": 728.5} instead of
// four lines), which is how a person would write it. Built bottom-up so
// every node is stringified exactly once — no quadratic restringify on
// multi-MB envelopes.
const LINE_BUDGET = 76;

function prettyJson(v: unknown, indent = ""): { s: string; flat: boolean } {
  if (v === null || typeof v !== "object") {
    const s = JSON.stringify(v) ?? "null";
    return { s, flat: true };
  }
  const isArr = Array.isArray(v);
  const kids: { head: string; r: { s: string; flat: boolean } }[] = isArr
    ? (v as unknown[]).map((x) => ({ head: "", r: prettyJson(x, indent + "  ") }))
    : Object.entries(v as Record<string, unknown>).map(([k, x]) => ({
        head: `${JSON.stringify(k)}: `,
        r: prettyJson(x, indent + "  "),
      }));
  if (kids.length === 0) return { s: isArr ? "[]" : "{}", flat: true };
  if (kids.every(({ r }) => r.flat)) {
    const one = (isArr ? "[" : "{ ") + kids.map(({ head, r }) => head + r.s).join(", ") + (isArr ? "]" : " }");
    if (indent.length + one.length <= LINE_BUDGET) return { s: one, flat: true };
  }
  const inner = kids.map(({ head, r }) => indent + "  " + head + r.s).join(",\n");
  return { s: (isArr ? "[" : "{") + "\n" + inner + "\n" + indent + (isArr ? "]" : "}"), flat: false };
}
The file /Users/phebert/pnk/viewer/src/main.ts has been updated successfully. (file state is current in your context — no need to Read it back)
✏️ Edit main.ts
/Users/phebert/pnk/viewer/src/main.ts
    const pretty = JSON.stringify(JSON.parse(lastJson.text), null, 2);
+
    const pretty = prettyJson(JSON.parse(lastJson.text)).s;
The file /Users/phebert/pnk/viewer/src/main.ts has been updated successfully. (file state is current in your context — no need to Read it back)

Now the footer + GitHub nav link in index.html:

✏️ Edit index.html
/Users/phebert/pnk/viewer/index.html
    <details id="warnings-dd" class="hidden">
+
    <a id="gh-link" href="https://github.com/peterheb/pnk" target="_blank" rel="noopener" title="Source on GitHub">github</a>
    <details id="warnings-dd" class="hidden">
The file /Users/phebert/pnk/viewer/index.html has been updated successfully. (file state is current in your context — no need to Read it back)
✏️ Edit index.html
/Users/phebert/pnk/viewer/index.html
      <pre id="json-pre"></pre>
    </div>
  </div>
+
      <pre id="json-pre"></pre>
    </div>

    <footer id="app-footer">
      <p>Pages, Numbers, Keynote and iWork are trademarks of Apple Inc. pnk.vu is not affiliated with or endorsed by Apple.</p>
      <details id="legal">
        <summary>privacy &amp; terms</summary>
        <p>pnk.vu runs entirely in your browser: documents you open are parsed locally and never uploaded.
        We collect no personal data. Our host, Cloudflare, keeps standard server-side logs and anonymous
        analytics. Provided as-is, without warranty of any kind.
        Source is MIT / Apache-2.0 licensed on <a href="https://github.com/peterheb/pnk" target="_blank" rel="noopener">GitHub</a>.</p>
      </details>
    </footer>
  </div>
The file /Users/phebert/pnk/viewer/index.html has been updated successfully. (file state is current in your context — no need to Read it back)
✏️ Edit styles.css
/Users/phebert/pnk/viewer/styles.css
/* subtle warnings indicator + dropdown */
+
#gh-link {
  color: var(--muted); text-decoration: none; font-size: 12px;
  font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
  padding: 2px 8px; border-radius: 6px;
}
#gh-link:hover { color: var(--ink); background: var(--card); }

/* subtle warnings indicator + dropdown */
The file /Users/phebert/pnk/viewer/styles.css has been updated successfully. (file state is current in your context — no need to Read it back)
✏️ Edit styles.css
/Users/phebert/pnk/viewer/styles.css
/* ---- error card ---------------------------------------------------------- */
+
/* ---- footer --------------------------------------------------------------- */

#app-footer {
  margin: 40px 0 8px; padding-top: 14px; border-top: 1px solid var(--line);
  font-size: 11.5px; color: var(--muted); text-align: center;
}
#app-footer p { margin: 4px 0; }
#app-footer a { color: var(--muted); }
#legal { margin-top: 2px; }
#legal summary {
  cursor: pointer; user-select: none; display: inline-block; list-style: none;
  font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
}
#legal summary::-webkit-details-marker { display: none; }
#legal summary:hover { color: var(--ink); }
#legal p { max-width: 620px; margin: 6px auto; }

/* ---- error card ---------------------------------------------------------- */
The file /Users/phebert/pnk/viewer/styles.css has been updated successfully. (file state is current in your context — no need to Read it back)
Thinking
$ Bash
Build, gate, screenshot footer + compact JSON
bash scripts/build_viewer.sh 2>&1 | tail -1 && cd viewer && npx tsc --noEmit && npm test 2>&1 | tail -2 && cat > probe5-tmp.mjs <<'EOF'
import { chromium } from "playwright";
const SCRATCH = "/private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad";
const b = await chromium.launch();
const page = await b.newPage({ viewport: { width: 1280, height: 800 } });
await page.goto("http://127.0.0.1:8123/");
await page.waitForSelector("#drop-card");
await page.click("#legal summary");
await page.screenshot({ path: `${SCRATCH}/footer.png` });
await page.setInputFiles("#file-input", "/Users/phebert/pnk/fixtures/golden/G2-golden-pages-layout.pages");
await page.waitForSelector("#view:not(.hidden)");
await page.click("#json-btn");
await page.waitForTimeout(400);
// scroll json to a drawable with position
await page.evaluate(() => {
  const pre = document.getElementById("json-pre");
  const idx = pre.textContent.indexOf('"position"');
  // rough: scroll proportionally
  pre.scrollIntoView();
  window.scrollBy(0, (idx / pre.textContent.length) * pre.scrollHeight);
});
await page.screenshot({ path: `${SCRATCH}/json-compact.png` });
await b.close();
EOF
node probe5-tmp.mjs && rm probe5-tmp.mjs
viewer built: viewer/dist/  (serve: cd viewer && npm run serve)

  6 passed (1.5s)
Read
{
  "file_path": "/private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/footer.png"
}
Read
{
  "file_path": "/private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/json-compact.png"
}
Thinking

Both land nicely — "position": { "x": …, "y": … } inlines, and whole paragraph items collapse to a line when they fit. Committing:

$ Bash
Commit footer, gh link, compact pretty-print
cd /Users/phebert/pnk && git add viewer/index.html viewer/src/main.ts viewer/styles.css && git commit -m "viewer: footer (Apple trademarks + privacy/terms), github nav link, smarter pretty-print

- Footer: Apple trademark attribution for Pages/Numbers/Keynote/iWork
  with a non-affiliation line, plus a collapsed 'privacy & terms' blurb —
  everything runs in-browser, no personal data collected, host
  (Cloudflare) keeps standard server logs + anonymous analytics, as-is,
  MIT/Apache-2.0.
- Nav grows a quiet monospace 'github' link to the repo.
- JSON view pretty-printer now compacts: any object/array whose one-line
  form fits a 76-char budget stays on one line ('position': { 'x': 511.5,
  'y': 728.5 }), the way a person would write it. Built bottom-up so each
  node stringifies exactly once — no quadratic cost on multi-MB
  envelopes; dense grid rows collapse too, shrinking the pretty output.

Gate 6/6 (the github link is an anchor — the zero-network assertion
still holds).

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018a2hmoDhN4kBq1BwfbFdAU" && git push origin main 2>&1 | tail -1
3 files changed, 65 insertions(+), 1 deletion(-)
   f72882d..38c264c  main -> main

All three shipped as 38c264c:

  • Footer: the Apple trademark line ("Pages, Numbers, Keynote and iWork are trademarks of Apple Inc. pnk.vu is not affiliated with or endorsed by Apple.") plus a collapsed privacy & terms toggle with the combined blurb — runs entirely in-browser, no personal data, Cloudflare keeps standard server-side logs and anonymous analytics, provided as-is, MIT/Apache-2.0 with a GitHub link.
  • Nav: a quiet monospace github link to the repo, muted until hover.
  • Smarter pretty-printing: the JSON view now keeps any object or array on one line when its inline form fits a 76-character budget — so "position": { "x": 511.5, "y": 728.5 } and "pageSize": { "width": 612, "height": 792 } read like a person wrote them. It's built bottom-up (each node stringified exactly once), so multi-MB envelopes pay no quadratic cost, and dense grid rows collapsing to single lines actually shrinks the pretty output substantially.

Gate 6/6 — the github anchor doesn't trip the zero-network assertion since it only fires on click.

One thing I noticed while verifying the JSON view on G2: the envelope has a hiddenBody carrying the text "[where is this mystery text]" — that's text you typed somewhere in the fixture that isn't rendered on any page (Pages word-processing bodies exist even in page-layout docs). Might be worth a look at whether the viewer should surface hidden bodies somehow, or whether it's fine as a model-only artifact.

remove encrypted and pre-2013 files are refused.. attribution/.privacy is great. add octocat icon in header. add better margins around warning dialog. numbers files seem to have top and left borders on tables missing sometimes?

Thinking

Quick UI items first, then I'll dig into the Numbers border bug. Removing the hint (keeping the element as the parser-failure slot), octocat SVG, warnings dialog margins:

✏️ Edit index.html
/Users/phebert/pnk/viewer/index.html
        <p class="muted" id="drop-hint">encrypted and pre-2013 files are refused.</p>
+
        <p class="muted hidden" id="drop-hint"></p>
The file /Users/phebert/pnk/viewer/index.html has been updated successfully. (file state is current in your context — no need to Read it back)
✏️ Edit index.html
/Users/phebert/pnk/viewer/index.html
    <a id="gh-link" href="https://github.com/peterheb/pnk" target="_blank" rel="noopener" title="Source on GitHub">github</a>
+
    <a id="gh-link" href="https://github.com/peterheb/pnk" target="_blank" rel="noopener" title="Source on GitHub" aria-label="Source on GitHub"><svg viewBox="0 0 16 16" width="18" height="18" aria-hidden="true" fill="currentColor"><path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27s1.36.09 2 .27c1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0 0 16 8c0-4.42-3.58-8-8-8Z"/></svg></a>
The file /Users/phebert/pnk/viewer/index.html has been updated successfully. (file state is current in your context — no need to Read it back)
✏️ Edit main.ts
/Users/phebert/pnk/viewer/src/main.ts
async function boot(): Promise<void> {
  await init("wasm/pnk2json_wasm_bg.wasm");
  wireEvents();
  $("drop-hint").textContent = "encrypted and pre-2013 files are refused.";
}

boot().catch((err) => {
  $("drop-hint").textContent = `Failed to load the local parser: ${err}`;
});
+
async function boot(): Promise<void> {
  await init("wasm/pnk2json_wasm_bg.wasm");
  wireEvents();
}

boot().catch((err) => {
  const hint = $("drop-hint");
  hint.textContent = `Failed to load the local parser: ${err}`;
  hint.classList.remove("hidden");
});
The file /Users/phebert/pnk/viewer/src/main.ts has been updated successfully. (file state is current in your context — no need to Read it back)
✏️ Edit gate.spec.ts
/Users/phebert/pnk/viewer/tests/gate.spec.ts
  await expect(page.locator("#drop-hint")).toContainText("encrypted and pre-2013 files are refused");
+
  await expect(page.locator("#pick-btn")).toBeVisible();
The file /Users/phebert/pnk/viewer/tests/gate.spec.ts has been updated successfully. (file state is current in your context — no need to Read it back)
✏️ Edit styles.css
/Users/phebert/pnk/viewer/styles.css
#gh-link {
  color: var(--muted); text-decoration: none; font-size: 12px;
  font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
  padding: 2px 8px; border-radius: 6px;
}
#gh-link:hover { color: var(--ink); background: var(--card); }
+
#gh-link {
  color: var(--muted); display: inline-flex; align-items: center;
  padding: 3px 6px; border-radius: 6px;
}
#gh-link:hover { color: var(--ink); background: var(--card); }
The file /Users/phebert/pnk/viewer/styles.css has been updated successfully. (file state is current in your context — no need to Read it back)
✏️ Edit styles.css
/Users/phebert/pnk/viewer/styles.css
#warnings-list {
  position: absolute; right: 0; top: calc(100% + 8px);
  width: min(560px, 86vw); max-height: 55vh; overflow-y: auto;
  background: var(--card); border: 1px solid var(--line); border-radius: 10px;
  box-shadow: 0 8px 30px rgba(0,0,0,.14);
  padding: 10px 14px; font-size: 12.5px;
}
+
#warnings-list {
  position: absolute; right: 0; top: calc(100% + 10px);
  width: min(560px, 86vw); max-height: 55vh; overflow-y: auto;
  background: var(--card); border: 1px solid var(--line); border-radius: 10px;
  box-shadow: 0 8px 30px rgba(0,0,0,.14);
  padding: 14px 18px 16px; font-size: 12.5px;
}
#warnings-list .chips { padding: 0 0 10px; margin-bottom: 8px; border-bottom: 1px solid var(--line); }
#warnings-list .warning-row { padding: 5px 0; }
The file /Users/phebert/pnk/viewer/styles.css has been updated successfully. (file state is current in your context — no need to Read it back)

Now the Numbers border bug. Checking how table borders render:

$ Bash
Table border CSS + rendering logic
grep -n "border" /Users/phebert/pnk/viewer/styles.css | grep -i "sheet\|table" ; grep -n "border\|collapse" /Users/phebert/pnk/viewer/src/tables.ts | head -30
30:  body .sheet-area, body .pages-flow { background: #ffffff; border-color: #d2d2d7; }
352:.sheet-tab.active { border-bottom-color: var(--accent); font-weight: 600; background: var(--card); }
354:.sheet-area { background: var(--card); border: 1px solid var(--line); border-radius: 10px; padding: 18px; overflow: auto; }
356:table.sheet-table { border-collapse: collapse; font-size: 13px; margin: 0 0 18px; }
431:/* A table with decoded stroke styles draws only its own borders; the base
433:table.sheet-table.own-strokes td, table.sheet-table.own-strokes th { border: none; }
385:  if (style?.borders) {
386:    const b = style.borders;
392:    if (b.top) s.borderTop = css(b.top);
393:    if (b.right) s.borderRight = css(b.right);
394:    if (b.bottom) s.borderBottom = css(b.bottom);
395:    if (b.left) s.borderLeft = css(b.left);
448:  // column collapses toward min-content (lafs_playlist wrapped 3-6 lines
462:  // borders — the base gray gridlines would add lines Apple doesn't draw
464:  if (model.style?.bodyCellStyle?.borders) table.classList.add("own-strokes");
Thinking
$ Bash
List numbers fixtures
ls ~/Development/pnk-fixtures/crawl/*.numbers | head -20
/Users/phebert/Development/pnk-fixtures/crawl/00a641002b038f06a6b3c52f5d3d02f4d1af4e66d06807b70493a5feff04674f.numbers
/Users/phebert/Development/pnk-fixtures/crawl/021084ac7183e93cd1b0401db42569bc421de27501aa2ef75e530bcd745cec42.numbers
/Users/phebert/Development/pnk-fixtures/crawl/04a201224e162fa5d70b029f4499df9b109387fb86055ce1c8b3bf0a32788f31.numbers
/Users/phebert/Development/pnk-fixtures/crawl/05377030c6172f60c8965bd65f04a2450f811fe8cbc177aa50ad7d8b4235f1cf.numbers
/Users/phebert/Development/pnk-fixtures/crawl/086384d4a484942cd2eb2c90c51060ee3190ed744695619f008b27b74f5307c6.numbers
/Users/phebert/Development/pnk-fixtures/crawl/08a4af8d00619475a9780029e23f34ddab90482839abf239074372e435861a51.numbers
/Users/phebert/Development/pnk-fixtures/crawl/0ab5dd52841ea629531fd2c2723a51d4fd1768205e7f73ddcaff956811eede6b.numbers
/Users/phebert/Development/pnk-fixtures/crawl/0c27b930c58a4cf9c399ea8f99d019d7dec021864a246d9c256863d0b69402af.numbers
/Users/phebert/Development/pnk-fixtures/crawl/0c44858f7ce5569f88b169bde58f36fe530b79a7c5d93d256cdf7c7f02cad7b7.numbers
/Users/phebert/Development/pnk-fixtures/crawl/1066e15850317ea1cc367edf050b1e9a8f0e1e1165a510ef1ce7eaa59f7dbef6.numbers
/Users/phebert/Development/pnk-fixtures/crawl/1132f0aa39be85a2ec07ea7fc102c7ea11a7db1e0678086764b0cb3e08b1c9e8.numbers
/Users/phebert/Development/pnk-fixtures/crawl/12098b6d2e46f9faf44b4d9d769e9c07f7fff3051cbcc6d58705e25e25c2cd10.numbers
/Users/phebert/Development/pnk-fixtures/crawl/136e893e290df242f87cf67b0196e22d5495f87c8cd1ecc9bc7e1bdef009b25e.numbers
/Users/phebert/Development/pnk-fixtures/crawl/16c9478d6d219afc93d947ea437de83e2f72a3e24d186c74cc1a7a7488f906e6.numbers
/Users/phebert/Development/pnk-fixtures/crawl/17891b89da2f381e1449442f3c118363fa621404a5602235cca476400bb88e63.numbers
/Users/phebert/Development/pnk-fixtures/crawl/181f2b199bd387ca44a57afc91b58266c2f473189812f95659474dc865458e68.numbers
/Users/phebert/Development/pnk-fixtures/crawl/1ad9b484c0c72226bd6d9fe71e15b442c2465449c710e1d134dc5e7e317b4e14.numbers
/Users/phebert/Development/pnk-fixtures/crawl/1b995efc0318c884abdc2c7b4766c4c271c8adfe631a3fd69164661b2c801f5c.numbers
/Users/phebert/Development/pnk-fixtures/crawl/1ee286477c077518da9f316dad66b303a4542cf238d89cacdd7768bd11343c52.numbers
/Users/phebert/Development/pnk-fixtures/crawl/232540a417f607feb2c344e79455e7e924b12eda7eb70d0b7b574e489c40c5af.numbers
$ Bash
Scan cellStyles border sides across corpus
cat > /private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/borderscan.py <<'EOF'
import json, subprocess, glob, sys

files = sorted(glob.glob("/Users/phebert/Development/pnk-fixtures/crawl/*.numbers"))[:40]
for f in files:
    try:
        out = subprocess.run(["/Users/phebert/pnk/target/release/pnk2json", f], capture_output=True, timeout=60)
        if out.returncode != 0: continue
        d = json.loads(out.stdout)
        doc = d.get("document", d)
        for sh in doc.get("sheets", []):
            for t in sh.get("tables", []):
                tm = t.get("table") or t  # drawable wrapper?
                styles = (tm.get("cellStyles") or [])
                sides = {"top":0,"left":0,"bottom":0,"right":0}
                for cs in styles:
                    b = cs.get("borders") or {}
                    for k in sides:
                        if b.get(k): sides[k]+=1
                if any(sides.values()):
                    print(f.split("/")[-1][:16], tm.get("name","?")[:20], sides)
    except Exception as e:
        pass
EOF
python3 /private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/borderscan.py 2>/dev/null | head -30
(Bash completed with no output)
Thinking
$ Bash
Inspect numbers model shape
./target/release/pnk2json ~/Development/pnk-fixtures/crawl/00a641002b038f06a6b3c52f5d3d02f4d1af4e66d06807b70493a5feff04674f.numbers 2>/dev/null | python3 -c "
import json,sys
d=json.load(sys.stdin); doc=d.get('document',d)
print(list(doc.keys()))
sh=doc['sheets'][0]; print('sheet keys:', list(sh.keys()))
t=sh['tables'][0]; print('table keys:', list(t.keys()))
print('style keys:', list((t.get('style') or {}).keys()))
"
Exit code 1
Traceback (most recent call last):
  File "<string>", line 6, in <module>
    t=sh['tables'][0]; print('table keys:', list(t.keys()))
      ~~^^^^^^^^^^
KeyError: 'tables'
['kind', 'meta', 'warnings', 'fonts', 'media', 'styles', 'sheets', 'pageSize']
sheet keys: ['name', 'drawables', 'headers', 'footers', 'usesSingleHeaderFooter', 'style', 'print', 'layoutDirectionRtl']
$ Bash
Scan borders with correct model shape
cat > /private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/borderscan.py <<'EOF'
import json, subprocess, glob

files = sorted(glob.glob("/Users/phebert/Development/pnk-fixtures/crawl/*.numbers"))[:50]
for f in files:
    try:
        out = subprocess.run(["/Users/phebert/pnk/target/release/pnk2json", f], capture_output=True, timeout=60)
        if out.returncode != 0: continue
        d = json.loads(out.stdout)
        doc = d.get("document", d)
        def walk(dr, sheet):
            if dr.get("type") == "table" and dr.get("table"):
                tm = dr["table"]
                sides = {"top":0,"left":0,"bottom":0,"right":0}
                for cs in tm.get("cellStyles") or []:
                    b = cs.get("borders") or {}
                    for k in sides:
                        if b.get(k): sides[k]+=1
                if any(sides.values()):
                    print(f.split("/")[-1][:16], repr(tm.get("name","?"))[:22], sides)
            for ch in dr.get("children", []): walk(ch, sheet)
        for sh in doc.get("sheets", []):
            for dr in sh.get("drawables", []): walk(dr, sh)
    except Exception:
        pass
EOF
python3 /private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/borderscan.py 2>/dev/null | head -25
00a641002b038f06 '?' {'top': 33, 'left': 24, 'bottom': 35, 'right': 34}
00a641002b038f06 '?' {'top': 48, 'left': 33, 'bottom': 50, 'right': 49}
00a641002b038f06 '?' {'top': 2, 'left': 2, 'bottom': 2, 'right': 2}
021084ac7183e93c '?' {'top': 31, 'left': 31, 'bottom': 31, 'right': 31}
05377030c6172f60 '?' {'top': 31, 'left': 23, 'bottom': 33, 'right': 32}
05377030c6172f60 '?' {'top': 39, 'left': 28, 'bottom': 41, 'right': 38}
086384d4a484942c '?' {'top': 39, 'left': 28, 'bottom': 40, 'right': 32}
086384d4a484942c '?' {'top': 55, 'left': 38, 'bottom': 56, 'right': 48}
0ab5dd52841ea629 '?' {'top': 16, 'left': 15, 'bottom': 17, 'right': 15}
0ab5dd52841ea629 '?' {'top': 16, 'left': 15, 'bottom': 17, 'right': 15}
0ab5dd52841ea629 '?' {'top': 16, 'left': 15, 'bottom': 17, 'right': 15}
0ab5dd52841ea629 '?' {'top': 13, 'left': 17, 'bottom': 15, 'right': 13}
0ab5dd52841ea629 '?' {'top': 14, 'left': 17, 'bottom': 16, 'right': 14}
0ab5dd52841ea629 '?' {'top': 17, 'left': 20, 'bottom': 16, 'right': 14}
0ab5dd52841ea629 '?' {'top': 13, 'left': 17, 'bottom': 15, 'right': 13}
0ab5dd52841ea629 '?' {'top': 17, 'left': 20, 'bottom': 16, 'right': 14}
0ab5dd52841ea629 '?' {'top': 13, 'left': 17, 'bottom': 15, 'right': 13}
0ab5dd52841ea629 '?' {'top': 17, 'left': 20, 'bottom': 16, 'right': 14}
0ab5dd52841ea629 '?' {'top': 13, 'left': 17, 'bottom': 15, 'right': 13}
0ab5dd52841ea629 '?' {'top': 13, 'left': 17, 'bottom': 15, 'right': 13}
0ab5dd52841ea629 '?' {'top': 13, 'left': 17, 'bottom': 15, 'right': 13}
0ab5dd52841ea629 '?' {'top': 13, 'left': 17, 'bottom': 15, 'right': 13}
0ab5dd52841ea629 '?' {'top': 13, 'left': 17, 'bottom': 15, 'right': 13}
0ab5dd52841ea629 '?' {'top': 13, 'left': 17, 'bottom': 15, 'right': 13}
0ab5dd52841ea629 '?' {'top': 17, 'left': 20, 'bottom': 16, 'right': 14}
Thinking

Pools carry all four sides — need to see the actual rendering. Ground truth on the first doc:

$ Bash
Visual diff a bordered numbers doc
uv run --with pillow --with pyobjc-framework-Quartz --with pymupdf python3 scripts/visual_diff.py --app numbers --fixture ~/Development/pnk-fixtures/crawl/00a641002b038f06a6b3c52f5d3d02f4d1af4e66d06807b70493a5feff04674f.numbers --out /private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/vd-num1 --base-url http://127.0.0.1:8123 2>&1 | tail -2
[visual_diff] artifacts: /private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/vd-num1
[visual_diff] summary: /private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/vd-num1/summary.md
$ Bash
List numbers composites
ls /private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/vd-num1/composites/
composite-page-1.png
composite-page-2.png
composite-page-3.png
Read
{
  "file_path": "/private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/vd-num1/composites/composite-page-1.png"
}
[Image: original 3998x1650, displayed at 2000x825. Multiply coordinates by 2.00 to map to original image.]
Thinking

This doc's visible part matches. Trying the uniformly-bordered one:

$ Bash
Visual diff second numbers doc
uv run --with pillow --with pyobjc-framework-Quartz --with pymupdf python3 scripts/visual_diff.py --app numbers --fixture ~/Development/pnk-fixtures/crawl/021084ac7183e93cd1b0401db42569bc421de27501aa2ef75e530bcd745cec42.numbers --out /private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/vd-num2 --base-url http://127.0.0.1:8123 2>&1 | tail -1
[visual_diff] summary: /private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/vd-num2/summary.md
Read
{
  "file_path": "/private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/vd-num2/composites/composite-page-1.png"
}
[Image: original 3032x1650, displayed at 2000x1088. Multiply coordinates by 1.52 to map to original image.]
Thinking

Not a table-heavy repro. Let me probe the DOM directly on the 18-table doc — compare the model's borders for the top-left cells against what actually paints:

$ Bash
Probe first table's computed borders
cd /Users/phebert/pnk/viewer && cat > probe6-tmp.mjs <<'EOF'
import { chromium } from "playwright";
const SCRATCH = "/private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad";
const b = await chromium.launch();
const page = await b.newPage({ viewport: { width: 1400, height: 1000 } });
await page.goto("http://127.0.0.1:8123/");
await page.setInputFiles("#file-input", "/Users/phebert/Development/pnk-fixtures/crawl/0ab5dd52841ea629531fd2c2723a51d4fd1768205e7f73ddcaff956811eede6b.numbers");
await page.waitForSelector("table.sheet-table", { timeout: 30000 });
await page.waitForTimeout(800);
const info = await page.evaluate(() => {
  const t = document.querySelector("table.sheet-table");
  const rows = [...t.querySelectorAll("tr")].slice(0, 3);
  const grab = (td) => {
    const cs = getComputedStyle(td);
    return { txt: (td.textContent||"").slice(0,10), t: cs.borderTopWidth+" "+cs.borderTopStyle, l: cs.borderLeftWidth+" "+cs.borderLeftStyle, b: cs.borderBottomWidth, r: cs.borderRightWidth, inlineT: td.style.borderTop, inlineL: td.style.borderLeft };
  };
  return {
    cls: t.className,
    r0: [...rows[0].children].slice(0,3).map(grab),
    r1: rows[1] ? [...rows[1].children].slice(0,3).map(grab) : [],
  };
});
console.log(JSON.stringify(info, null, 1));
const table = page.locator("table.sheet-table").first();
await table.screenshot({ path: `${SCRATCH}/num-table-dom.png` });
await b.close();
EOF
node probe6-tmp.mjs && rm probe6-tmp.mjs
{
 "cls": "sheet-table exact-cols own-strokes",
 "r0": [
  {
   "txt": "Percentile",
   "t": "2px solid",
   "l": "2px solid",
   "b": "1px",
   "r": "1px",
   "inlineT": "2px solid rgb(0, 0, 0)",
   "inlineL": "2px solid rgb(0, 0, 0)"
  },
  {
   "txt": "0-10%",
   "t": "2px solid",
   "l": "0px none",
   "b": "1px",
   "r": "1px",
   "inlineT": "2px solid rgb(0, 0, 0)",
   "inlineL": ""
  },
  {
   "txt": "",
   "t": "2px solid",
   "l": "0px none",
   "b": "1px",
   "r": "1px",
   "inlineT": "2px solid rgb(0, 0, 0)",
   "inlineL": ""
  }
 ],
 "r1": [
  {
   "txt": "Av frequen",
   "t": "0px none",
   "l": "2px solid",
   "b": "1px",
   "r": "1px",
   "inlineT": "",
   "inlineL": "2px solid rgb(0, 0, 0)"
  },
  {
   "txt": "#",
   "t": "0px none",
   "l": "0px none",
   "b": "1px",
   "r": "1px",
   "inlineT": "",
   "inlineL": ""
  },
  {
   "txt": "C",
   "t": "0px none",
   "l": "0px none",
   "b": "1px",
   "r": "1px",
   "inlineT": "",
   "inlineL": ""
  }
 ]
}
Read
{
  "file_path": "/private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/num-table-dom.png"
}
Thinking

This doc paints its borders fine — I need a sharper signal. Let me check how per-cell styles map and scan for tables whose row-0/col-0 specifically lack top/left in the resolved model:

$ Bash
GridCell style reference encoding
grep -n "cellStyle\|interface GridCell\|cs\b" /Users/phebert/pnk/model/src/shared.ts | sed -n 1,15p; grep -n "cellStyleIndex\|\.cs\b\|cellStyles\[" /Users/phebert/pnk/viewer/src/tables.ts | head
7: * Conventions (see primitives.ts header + docs/model-design.md):
107: * `TSP.PackageMetadata` (object id 2). See docs/format/container.md.
154:   * pooling them is not worth the churn — docs/model-design.md §2).
177: * (docs/model-design.md §Flattening).
179: *  splitting verified in docs/format/text.md]
213: * the source text [proto + parser: docs/format/text.md §Attachments]. The
227: * [proto: docs/format/text.md §Fields].
287: * decode — never silently dropped (see docs/model-design.md §Dropped).
340:   * (source `ScalarPathSourceArchive.scalar`) [inferred: semantic per docs/format/drawables.md].
411:   * a group can be moved as one — docs/model-design.md §Flattening).
465: * (docs/format/tables.md). Dimensions and header counts are explicit; the
469: * re-evaluates formulas (docs/format/calcengine.md).
494:   * docs/model-design.md §Reading the envelope. Values are the LAST
496:   * (docs/format/calcengine.md).
507:   * `TableCell.cellStyleIndex`, absent = table default style.
507:      const style = cellStyleOf(model, norm?.cellStyleIndex);
$ Bash
Scan for tables missing row0-top/col0-left borders
cat > /private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/borderscan2.py <<'EOF'
import json, subprocess, glob

files = sorted(glob.glob("/Users/phebert/Development/pnk-fixtures/crawl/*.numbers"))[:60]
def cell_style_idx(cell):
    if isinstance(cell, dict): return cell.get("cellStyleIndex")
    return None
for f in files:
    try:
        out = subprocess.run(["/Users/phebert/pnk/target/release/pnk2json", f], capture_output=True, timeout=60)
        if out.returncode != 0: continue
        d = json.loads(out.stdout); doc = d.get("document", d)
        def walk(dr):
            if dr.get("type") == "table" and dr.get("table"):
                tm = dr["table"]; grid = tm.get("grid") or []; pool = tm.get("cellStyles") or []
                if not grid or not pool: return
                def side(cell, s):
                    i = cell_style_idx(cell)
                    if i is None or i >= len(pool): return False
                    return bool((pool[i].get("borders") or {}).get(s))
                r0top = sum(1 for c in grid[0] if side(c, "top"))
                r1top = sum(1 for c in grid[1] if side(c, "top")) if len(grid) > 1 else 0
                r1bot = sum(1 for c in grid[1] if side(c, "bottom")) if len(grid) > 1 else 0
                c0left = sum(1 for row in grid if row and side(row[0], "left"))
                c1right = sum(1 for row in grid if len(row) > 1 and side(row[1], "right"))
                anyb = any(side(c, s) for row in grid for c in row if c for s in ("top","left","bottom","right"))
                if anyb and (r0top == 0 or c0left == 0):
                    print(f.split("/")[-1][:16], f"rows={len(grid)} r0top={r0top} r1top={r1top} r1bot={r1bot} c0left={c0left} c1right={c1right}")
            for ch in dr.get("children", []): walk(ch)
        for sh in doc.get("sheets", []):
            for dr in sh.get("drawables", []): walk(dr)
    except Exception:
        pass
EOF
python3 /private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/borderscan2.py 2>/dev/null | head -25
00a641002b038f06 rows=19 r0top=0 r1top=7 r1bot=7 c0left=0 c1right=18
00a641002b038f06 rows=52 r0top=0 r1top=7 r1bot=7 c0left=25 c1right=51
05377030c6172f60 rows=16 r0top=0 r1top=10 r1bot=10 c0left=0 c1right=15
05377030c6172f60 rows=28 r0top=0 r1top=10 r1bot=10 c0left=0 c1right=27
086384d4a484942c rows=18 r0top=0 r1top=6 r1bot=6 c0left=0 c1right=17
086384d4a484942c rows=30 r0top=0 r1top=6 r1bot=6 c0left=0 c1right=29
0c27b930c58a4cf9 rows=27 r0top=0 r1top=7 r1bot=7 c0left=0 c1right=26
0c27b930c58a4cf9 rows=35 r0top=0 r1top=7 r1bot=7 c0left=0 c1right=34
0c44858f7ce5569f rows=16 r0top=0 r1top=7 r1bot=7 c0left=0 c1right=15
0c44858f7ce5569f rows=26 r0top=0 r1top=7 r1bot=7 c0left=0 c1right=25
12098b6d2e46f9fa rows=22 r0top=0 r1top=0 r1bot=0 c0left=0 c1right=18
136e893e290df242 rows=22 r0top=0 r1top=0 r1bot=0 c0left=0 c1right=18
16c9478d6d219afc rows=69 r0top=0 r1top=13 r1bot=13 c0left=43 c1right=23
16c9478d6d219afc rows=28 r0top=0 r1top=0 r1bot=0 c0left=5 c1right=7
16c9478d6d219afc rows=274 r0top=0 r1top=13 r1bot=13 c0left=22 c1right=22
16c9478d6d219afc rows=18 r0top=0 r1top=13 r1bot=13 c0left=4 c1right=4
16c9478d6d219afc rows=54 r0top=0 r1top=1 r1bot=0 c0left=0 c1right=51
17891b89da2f381e rows=55 r0top=0 r1top=33 r1bot=33 c0left=1 c1right=1
17891b89da2f381e rows=54 r0top=0 r1top=33 r1bot=33 c0left=1 c1right=1
17891b89da2f381e rows=54 r0top=0 r1top=33 r1bot=33 c0left=1 c1right=1
17891b89da2f381e rows=54 r0top=0 r1top=33 r1bot=33 c0left=1 c1right=1
17891b89da2f381e rows=54 r0top=0 r1top=33 r1bot=33 c0left=1 c1right=1
17891b89da2f381e rows=54 r0top=0 r1top=33 r1bot=33 c0left=1 c1right=1
17891b89da2f381e rows=54 r0top=0 r1top=33 r1bot=33 c0left=1 c1right=1
17891b89da2f381e rows=54 r0top=0 r1top=33 r1bot=33 c0left=1 c1right=1
Thinking
Thinking

Found the pattern: the converter is dropping boundary 0 (outer edges), causing row-0 to miss top borders and column-0 to miss left borders. Now I'll look at the stroke decoding logic to confirm.

$ Bash
Locate stroke-layer decode in tables.rs
grep -n "stroke\|border" /Users/phebert/pnk/crates/pnk2json/src/tables.rs | head -30
335:/// Overlay explicit edge strokes onto a cell style's borders.
336:fn merge_borders(s: &mut TableCellStyle, b: CellBorders) {
337:    let dst = s.borders.get_or_insert(CellBorders {
357:/// A table stroke, or None when it draws nothing: StrokePatternArchive
360:fn table_stroke(ctx: &mut Ctx, m: &Msg) -> Option<Stroke> {
364:    let s = crate::tsd::stroke_of(ctx, m)?;
371:/// Merge default grid strokes into a section style's borders (only where
373:fn attach_borders(
384:    let b = s.borders.get_or_insert(CellBorders {
404:/// Per-cell edge stroke overrides from the StrokeSidecarArchive (model
407:/// over columns). Each run { origin = 1, length = 2, stroke = 3 } paints
411:fn sidecar_borders(ctx: &mut Ctx, m: &Msg) -> HashMap<(u32, u32), CellBorders> {
424:                // stroke on that edge — encoded as a 0-width stroke (the
427:                let stroke = table_stroke(ctx, &sm).unwrap_or(Stroke {
454:                    *slot = Some(stroke.clone());
705:        // (02_Invoice's dashed body horizontal stroke lives only on the
718:        // Default gridline strokes (TableStylePropertiesArchive modern
719:        // stroke slots f46-61 + visibility flags f33/f34/f35/f36/f37)
720:        // become section-style borders: a HORIZONTAL stroke is every
730:        let mut stroke_at = |ctx: &mut Ctx, f: u32| {
731:            first(f).and_then(|p| p.msg(f)).and_then(|sm| table_stroke(ctx, &sm))
733:        let body_h = if h_vis { stroke_at(ctx, 60) } else { None };
734:        let body_v = if v_vis { stroke_at(ctx, 61) } else { None };
735:        attach_borders(&mut body, None, body_v.clone(), body_h.clone(), None);
736:        let hr_sep = if flag(35, true) { stroke_at(ctx, 46) } else { None };
737:        attach_borders(
740:            stroke_at(ctx, 49),
741:            hr_sep.or_else(|| stroke_at(ctx, 48)),
744:        let hc_sep = if flag(36, true) { stroke_at(ctx, 51) } else { None };
745:        attach_borders(
Read
{
  "file_path": "/Users/phebert/pnk/crates/pnk2json/src/tables.rs",
  "offset": 404,
  "limit": 80
}
404	/// Per-cell edge stroke overrides from the StrokeSidecarArchive (model
405	/// f49): left/right COLUMN layers (f4/f5, row_column_index = column, runs
406	/// range over rows) and top/bottom ROW layers (f6/f7, index = row, runs
407	/// over columns). Each run { origin = 1, length = 2, stroke = 3 } paints
408	/// one edge of a contiguous cell range. [proto TSTArchives.proto
409	/// StrokeSidecarArchive/StrokeLayerArchive; fixture-verified: 02_Invoice
410	/// black rules above Tax/Total]
411	fn sidecar_borders(ctx: &mut Ctx, m: &Msg) -> HashMap<(u32, u32), CellBorders> {
412	    let mut out: HashMap<(u32, u32), CellBorders> = HashMap::new();
413	    let Some(sc) = m.reference(49).and_then(|id| ctx.loaded.msg(id).cloned()) else {
414	        return out;
415	    };
416	    for (field, edge) in [(4u32, 0u8), (5, 1), (6, 2), (7, 3)] {
417	        let layer_ids: Vec<u64> = sc.references(field);
418	        for lid in layer_ids {
419	            let Some(layer) = ctx.loaded.msg(lid).cloned() else { continue };
420	            let idx = layer.varint(1).unwrap_or(0) as u32;
421	            for run in layer.msgs(2) {
422	                let Some(sm) = run.msg(3) else { continue };
423	                // An EmptyPattern / zero-width run ERASES the default grid
424	                // stroke on that edge — encoded as a 0-width stroke (the
425	                // viewer renders 0px = no line, overriding the section
426	                // default).
427	                let stroke = table_stroke(ctx, &sm).unwrap_or(Stroke {
428	                    color: "#00000000".to_string(),
429	                    width_pt: 0.0,
430	                    cap: StrokeCap::Butt,
431	                    join: StrokeJoin::Miter,
432	                    miter_limit: None,
433	                    dash: None,
434	                    dash_phase: None,
435	                });
436	                let origin = run.varint(1).unwrap_or(0) as u32;
437	                let length = run.varint(2).unwrap_or(1).max(1) as u32;
438	                for i in origin..origin + length {
439	                    // column layers: idx = column, i = row; row layers:
440	                    // idx = row, i = column
441	                    let key = if field <= 5 { (i, idx) } else { (idx, i) };
442	                    let b = out.entry(key).or_insert(CellBorders {
443	                        top: None,
444	                        right: None,
445	                        bottom: None,
446	                        left: None,
447	                    });
448	                    let slot = match edge {
449	                        0 => &mut b.left,
450	                        1 => &mut b.right,
451	                        2 => &mut b.top,
452	                        _ => &mut b.bottom,
453	                    };
454	                    *slot = Some(stroke.clone());
455	                }
456	            }
457	        }
458	    }
459	    out
460	}
461	
462	/// Clamp a decoded merge range to the table bounds and push it; degenerate
463	/// 1x1 "merges" are no-ops and dropped.
464	fn push_merge(
465	    merges: &mut Vec<TableMerge>,
466	    r0: u64,
467	    c0: u64,
468	    r1: u64,
469	    c1: u64,
470	    rows: u32,
471	    cols: u32,
472	) {
473	    if rows == 0 || cols == 0 || r0 >= rows as u64 || c0 >= cols as u64 {
474	        return;
475	    }
476	    let r1 = r1.min(rows as u64 - 1);
477	    let c1 = c1.min(cols as u64 - 1);
478	    let row_span = (r1 - r0 + 1) as u32;
479	    let column_span = (c1 - c0 + 1) as u32;
480	    if row_span == 1 && column_span == 1 {
481	        return;
482	    }
483	    merges.push(TableMerge {
Thinking
Thinking

I've traced the issue: section styles only own the right+bottom edges (for erase-run logic), leaving the table's outer top/left boundary unrepresented. Now I'll check how section defaults and sidecar overlays merge per cell.

Read
{
  "file_path": "/Users/phebert/pnk/crates/pnk2json/src/tables.rs",
  "offset": 690,
  "limit": 110
}
690	    // to the table's base owner uid; MERGE_OWNER (kind 5) dependency
691	    // archives then list merge rects as internal range references whose
692	    // owner ids resolve via the calc engine's owner-id map.
693	    if merges.is_empty() {
694	        if let Some(haunted) = m.msg(84).and_then(|h| h.msg(1)).as_ref().and_then(uuid_u128) {
695	            merges = dependency_merges(ctx, haunted, row_count, column_count);
696	        }
697	    }
698	    merges.sort_by_key(|m| (m.anchor_row, m.anchor_column));
699	
700	    // Table-level style: TST.TableStyleArchive { super = 1,
701	    // table_properties = 11 } plus the style-network role slots on the model
702	    // (fields 18-21 cell styles, 24-27 text styles; docs/format/tables.md).
703	    let style = m.reference(3).and_then(|sid| {
704	        // TableStyleArchive properties INHERIT along the TSS parent chain
705	        // (02_Invoice's dashed body horizontal stroke lives only on the
706	        // preset parent); first-wins per field like every other style walk.
707	        let props = styles::chain(ctx, sid, 11);
708	        if props.is_empty() {
709	            return None;
710	        }
711	        let first = |f: u32| props.iter().find(|p| p.has(f)).cloned();
712	        let flag = |f: u32, default: bool| {
713	            props
714	                .iter()
715	                .find_map(|p| p.boolean(f))
716	                .unwrap_or(default)
717	        };
718	        // Default gridline strokes (TableStylePropertiesArchive modern
719	        // stroke slots f46-61 + visibility flags f33/f34/f35/f36/f37)
720	        // become section-style borders: a HORIZONTAL stroke is every
721	        // cell's bottom edge, a VERTICAL one its right edge, and the
722	        // separators paint the header/footer boundary. Strokes with an
723	        // empty pattern (TSDEmptyPattern) or zero width draw nothing.
724	        let h_vis = flag(34, true);
725	        let v_vis = flag(33, true);
726	        let mut body = section_style(ctx, &m, 18, 24);
727	        let mut hrow = section_style(ctx, &m, 19, 25);
728	        let mut hcol = section_style(ctx, &m, 20, 26);
729	        let mut foot = section_style(ctx, &m, 21, 27);
730	        let mut stroke_at = |ctx: &mut Ctx, f: u32| {
731	            first(f).and_then(|p| p.msg(f)).and_then(|sm| table_stroke(ctx, &sm))
732	        };
733	        let body_h = if h_vis { stroke_at(ctx, 60) } else { None };
734	        let body_v = if v_vis { stroke_at(ctx, 61) } else { None };
735	        attach_borders(&mut body, None, body_v.clone(), body_h.clone(), None);
736	        let hr_sep = if flag(35, true) { stroke_at(ctx, 46) } else { None };
737	        attach_borders(
738	            &mut hrow,
739	            None,
740	            stroke_at(ctx, 49),
741	            hr_sep.or_else(|| stroke_at(ctx, 48)),
742	            None,
743	        );
744	        let hc_sep = if flag(36, true) { stroke_at(ctx, 51) } else { None };
745	        attach_borders(
746	            &mut hcol,
747	            None,
748	            hc_sep.or_else(|| stroke_at(ctx, 53)),
749	            stroke_at(ctx, 52),
750	            None,
751	        );
752	        let foot_sep = if flag(37, true) { stroke_at(ctx, 54) } else { None };
753	        attach_borders(&mut foot, foot_sep, stroke_at(ctx, 57), stroke_at(ctx, 56), None);
754	        Some(TableStyle {
755	            banded_rows: props.iter().find_map(|p| p.boolean(1)),
756	            banded_fill: first(2)
757	                .and_then(|p| p.msg(2))
758	                .and_then(|f| crate::tsd::fill_of(ctx, &f)),
759	            body_cell_style: body,
760	            header_row_cell_style: hrow,
761	            header_column_cell_style: hcol,
762	            footer_row_cell_style: foot,
763	        })
764	    });
765	
766	    // Dense row-major grid with None holes, plus a deduped formats pool;
767	    // cells reference formats by index. Malformed formats (negative /
768	    // absurd decimals) were already warned about and dropped inside
769	    // decode_cell — the cell is emitted with `formatIndex` absent.
770	    let mut grid: Vec<Vec<Option<GridCell>>> =
771	        vec![vec![None; column_count as usize]; row_count as usize];
772	    // Row/column DEFAULT styles (header buckets) fill in for cells with no
773	    // per-cell style; row beats column.
774	    let row_styles = header_styles(ctx, store.as_ref(), 1, 0);
775	    let col_styles = header_styles(ctx, store.as_ref(), 0, 2);
776	    // Per-cell edge stroke overrides (stroke sidecar) merge into the
777	    // pooled cell styles; leftovers on empty positions synthesize a
778	    // value-less styled cell after the loop.
779	    let mut edge_overrides = sidecar_borders(ctx, &m);
780	    let mut formats: Vec<CellFormat> = Vec::new();
781	    for (row, col, mut cell, format) in cells {
782	        if cell.cell_style_index.is_none() {
783	            if let Some(s) = row_styles.get(&row).or_else(|| col_styles.get(&col)) {
784	                cell.cell_style_index = cell_pool.intern(s.clone());
785	            }
786	        }
787	        if let Some(b) = edge_overrides.remove(&(row, col)) {
788	            let mut s = cell
789	                .cell_style_index
790	                .and_then(|i| cell_pool.items.get(i as usize).cloned())
791	                .unwrap_or_default();
792	            merge_borders(&mut s, b);
793	            cell.cell_style_index = cell_pool.intern(s);
794	        }
795	        if let Some(f) = format {
796	            let idx = match formats.iter().position(|e| *e == f) {
797	                Some(i) => i,
798	                None => {
799	                    formats.push(f);
Thinking
$ Bash
Look for table outline property in docs/protos
grep -n -i "outline\|outer" /Users/phebert/pnk/docs/format/tables.md | head; grep -rn -i "outline" /Users/phebert/pnk/.scratch/*/proto/TSTArchives.proto 2>/dev/null | head -8; ls /Users/phebert/pnk/.scratch 2>/dev/null | head
crossval-campaign
g5-full.txt
g5-visual
iwork
keynote-parser
litchi
numbers-parser
otorp
otorp-tool
$ Bash
Find TST proto and outline fields
find /Users/phebert/pnk/.scratch -name "TSTArchives.proto" 2>/dev/null | head -3; grep -rn -i "outline" $(find /Users/phebert/pnk/.scratch -name "TSTArchives.proto" | head -1) | head
/Users/phebert/pnk/.scratch/otorp/Numbers/TSTArchives.proto
/Users/phebert/pnk/.scratch/otorp/Keynote/TSTArchives.proto
/Users/phebert/pnk/.scratch/otorp/Pages/TSTArchives.proto
$ Bash
TableStyleProperties stroke field names
grep -n -i "outline\|border" /Users/phebert/pnk/.scratch/otorp/Numbers/TSTArchives.proto | head -12; grep -n "vertical_stroke\|horizontal_stroke\|= 4[5-9]\|= 5[0-9]\|= 6[0-1]" /Users/phebert/pnk/.scratch/otorp/Numbers/TSTArchives.proto | sed -n 1,25p
492:  optional bool table_name_border_enabled = 37;
646:  optional .TST.CellBorderArchive cell_border = 27;
686:  optional bool affects_cell_borders = 6 [default = true];
1334:message CellBorderArchive {
1456:  optional .TST.CellBorderArchive cell_border = 503;
471:  optional bool was_cut = 50;
483:  optional .TSP.Reference table_style_preset = 48;
514:  optional .TST.SortRuleReferenceTrackerArchive sort_rule_reference_tracker = 45;
515:  optional .TSP.Reference base_column_row_uids = 46;
516:  optional .TST.MergeOwnerArchive merge_owner = 47;
517:  optional .TSP.Reference stroke_sidecar = 49;
518:  optional bool preset_needs_strong_ownership = 51;
519:  optional .TST.StructuredTextImportRecord text_import_record = 52;
520:  optional .TSP.Reference category_level_1_style = 60;
521:  optional .TSP.Reference category_level_2_style = 61;
1454:  optional .TST.ImportWarningSetArchive import_warning_set = 500;
1455:  optional .TST.CellFormatAndValueArchive format_and_value = 501;
1456:  optional .TST.CellBorderArchive cell_border = 503;
1457:  optional .TST.CommentStorageWrapperArchive comment_storage = 504;
1458:  optional .TST.CellSpecArchive cell_spec = 505;
$ Bash
TableStyleProperties stroke slots
grep -n "TableStylePropertiesArchive" -A 75 /Users/phebert/pnk/.scratch/otorp/Numbers/TSTArchives.proto | grep -n "stroke\|= 3[3-7]\|border" | head -20
(Bash completed with no output)
$ Bash
Dump stroke fields of TableStyleProperties
grep -n "message TableStyleProperties" /Users/phebert/pnk/.scratch/otorp/Numbers/TSTArchives.proto; sed -n "$(grep -n 'message TableStyleProperties' /Users/phebert/pnk/.scratch/otorp/Numbers/TSTArchives.proto | cut -d: -f1),+80p" /Users/phebert/pnk/.scratch/otorp/Numbers/TSTArchives.proto | grep -i "stroke\|vis\|border"
sed: 1: ",+80p
": invalid command code ,
$ Bash
Stroke/visibility fields in TableStylePropertiesArchive
awk '/message TableStylePropertiesArchive/,/^}/' /Users/phebert/pnk/.scratch/otorp/Numbers/TSTArchives.proto | grep -in "stroke\|vis\|border\|outline"
(Bash completed with no output)
$ Bash
Find where stroke slots 60/61 are defined
grep -n "message TableStyle" /Users/phebert/pnk/.scratch/otorp/Numbers/TSTArchives.proto; grep -rn "= 60;\|= 61;" /Users/phebert/pnk/.scratch/otorp/Numbers/TSTStylePropertyArchiving.proto 2>/dev/null | head; ls /Users/phebert/pnk/.scratch/otorp/Numbers/ | grep -i tst
347:message TableStyleNetworkArchive {
852:message TableStyleArchive {
/Users/phebert/pnk/.scratch/otorp/Numbers/TSTStylePropertyArchiving.proto:101:  optional .TSD.StrokeArchive table_body_horizontal_stroke = 60;
/Users/phebert/pnk/.scratch/otorp/Numbers/TSTStylePropertyArchiving.proto:102:  optional .TSD.StrokeArchive table_body_vertical_stroke = 61;
TSTArchives.proto
TSTArchives.sos.proto
TSTCommandArchives.proto
TSTStylePropertyArchiving.proto
$ Bash
All stroke/visibility slots in the style property archive
grep -n "stroke\|vis" /Users/phebert/pnk/.scratch/otorp/Numbers/TSTStylePropertyArchiving.proto | sed -n 1,30p
11:  optional .TSD.StrokeArchive stroke = 1;
20:  optional .TST.Deprecated_TableStrokeArchive deprecated_top_stroke = 4;
21:  optional .TST.Deprecated_TableStrokeArchive deprecated_right_stroke = 5;
22:  optional .TST.Deprecated_TableStrokeArchive deprecated_bottom_stroke = 6;
23:  optional .TST.Deprecated_TableStrokeArchive deprecated_left_stroke = 7;
26:  optional .TSD.StrokeArchive top_stroke = 10;
27:  optional .TSD.StrokeArchive right_stroke = 11;
28:  optional .TSD.StrokeArchive bottom_stroke = 12;
29:  optional .TSD.StrokeArchive left_stroke = 13;
33:  required .TST.Deprecated_TableStrokeArchive deprecated_horizontal_stroke = 2;
34:  required .TST.Deprecated_TableStrokeArchive deprecated_vertical_stroke = 1;
35:  required .TST.Deprecated_TableStrokeArchive deprecated_exterior_stroke = 3;
36:  required int32 deprecated_visible_mask = 5;
40:  optional .TSD.StrokeArchive horizontal_stroke = 1;
41:  optional .TSD.StrokeArchive vertical_stroke = 2;
42:  optional .TSD.StrokeArchive exterior_stroke = 3;
43:  optional int32 visible_mask = 4;
57:  optional .TST.Deprecated_TableStrokeArchive deprecated_header_row_separator_stroke = 4;
58:  optional .TST.Deprecated_TableStrokeArchive deprecated_header_row_border_stroke = 5;
59:  optional .TST.Deprecated_TableStrokeArchive deprecated_header_row_horizontal_stroke = 23;
60:  optional .TST.Deprecated_TableStrokeArchive deprecated_header_row_vertical_stroke = 24;
61:  optional .TST.Deprecated_TableStrokeArchive deprecated_header_column_border_stroke = 7;
62:  optional .TST.Deprecated_TableStrokeArchive deprecated_header_column_separator_stroke = 8;
63:  optional .TST.Deprecated_TableStrokeArchive deprecated_header_column_horizontal_stroke = 25;
64:  optional .TST.Deprecated_TableStrokeArchive deprecated_header_column_vertical_stroke = 26;
65:  optional .TST.Deprecated_TableStrokeArchive deprecated_footer_row_separator_stroke = 10;
66:  optional .TST.Deprecated_TableStrokeArchive deprecated_footer_row_border_stroke = 11;
67:  optional .TST.Deprecated_TableStrokeArchive deprecated_footer_row_horizontal_stroke = 27;
68:  optional .TST.Deprecated_TableStrokeArchive deprecated_footer_row_vertical_stroke = 28;
69:  optional .TST.Deprecated_TableStrokeArchive deprecated_table_body_horizontal_border_stroke = 12;
$ Bash
Remaining stroke slots incl. outline
sed -n 70,110p /Users/phebert/pnk/.scratch/otorp/Numbers/TSTStylePropertyArchiving.proto
  optional .TST.Deprecated_TableStrokeArchive deprecated_table_body_vertical_border_stroke = 29;
  optional .TST.Deprecated_TableStrokeArchive deprecated_table_body_horizontal_stroke = 30;
  optional .TST.Deprecated_TableStrokeArchive deprecated_table_body_vertical_stroke = 31;
  optional .TST.StrokePresetListArchive stroke_preset_list = 32;
  optional bool v_strokes_visible = 33;
  optional bool h_strokes_visible = 34;
  optional bool hr_separator_visible = 35;
  optional bool hc_separator_visible = 36;
  optional bool footer_separator_visible = 37;
  optional bool table_border_visible = 38;
  optional bool table_header_border_visible = 39;
  optional bool table_hc_divider_visible = 42;
  optional bool table_hr_divider_visible = 43;
  optional bool table_footer_divider_visible = 44;
  optional int32 OBSOLETE_master_font_size = 40;
  optional string master_font_family = 41;
  optional .TSWP.WritingDirectionType writing_direction = 45;
  optional .TSD.StrokeArchive header_row_separator_stroke = 46;
  optional .TSD.StrokeArchive header_row_border_stroke = 47;
  optional .TSD.StrokeArchive header_row_horizontal_stroke = 48;
  optional .TSD.StrokeArchive header_row_vertical_stroke = 49;
  optional .TSD.StrokeArchive header_column_border_stroke = 50;
  optional .TSD.StrokeArchive header_column_separator_stroke = 51;
  optional .TSD.StrokeArchive header_column_horizontal_stroke = 52;
  optional .TSD.StrokeArchive header_column_vertical_stroke = 53;
  optional .TSD.StrokeArchive footer_row_separator_stroke = 54;
  optional .TSD.StrokeArchive footer_row_border_stroke = 55;
  optional .TSD.StrokeArchive footer_row_horizontal_stroke = 56;
  optional .TSD.StrokeArchive footer_row_vertical_stroke = 57;
  optional .TSD.StrokeArchive table_body_horizontal_border_stroke = 58;
  optional .TSD.StrokeArchive table_body_vertical_border_stroke = 59;
  optional .TSD.StrokeArchive table_body_horizontal_stroke = 60;
  optional .TSD.StrokeArchive table_body_vertical_stroke = 61;
  optional .TSD.StrokeArchive category_level1_top_stroke = 62;
  optional .TSD.StrokeArchive category_level2_top_stroke = 63;
  optional .TSD.StrokeArchive category_level3_top_stroke = 64;
  optional .TSD.StrokeArchive category_level4_top_stroke = 65;
  optional .TSD.StrokeArchive category_level5_top_stroke = 66;
  optional .TSD.StrokeArchive category_level1_bottom_stroke = 67;
  optional .TSD.StrokeArchive category_level2_bottom_stroke = 68;
  optional .TSD.StrokeArchive category_level3_bottom_stroke = 69;
Thinking
Thinking

Confirmed the proto has separate outer-frame border fields (f58/f59, gated by f38) distinct from the interior gridlines (f60/f61). Next I'll check how the viewer merges role defaults with per-cell styles to pinpoint where the fix should go.

$ Bash
Viewer cell style + role default merge
sed -n 440,530p /Users/phebert/pnk/viewer/src/tables.ts
      col.style.width = `${w}px`;
      totalW += w;
    } else allWidthsKnown = false;
    cg.appendChild(col);
  }
  table.appendChild(cg);
  // Stored column widths are exact: fixed layout + explicit table width,
  // otherwise the auto algorithm shrink-to-fits the container and every
  // column collapses toward min-content (lafs_playlist wrapped 3-6 lines
  // per cell inside its drawable box).
  if (allWidthsKnown && totalW > 0) {
    table.style.tableLayout = "fixed";
    table.style.width = `${totalW}px`;
    table.classList.add("exact-cols"); // lifts the base min-width guard
  }

  let sectionEl: HTMLTableSectionElement | null = null;
  let sectionKind: string | null = null;
  let bodyOrdinal = -1;
  const banded = model.style?.bandedRows && model.style.bandedFill?.type === "solid"
    ? model.style.bandedFill.color : undefined;
  // A table whose style carries real stroke info paints ONLY its own
  // borders — the base gray gridlines would add lines Apple doesn't draw
  // (02_Invoice has horizontal rules only).
  if (model.style?.bodyCellStyle?.borders) table.classList.add("own-strokes");
  for (const r of visRows) {
    const kind = r < headEnd ? "thead" : r >= footStart ? "tfoot" : "tbody";
    if (kind !== sectionKind) {
      sectionKind = kind;
      sectionEl = document.createElement(kind) as HTMLTableSectionElement;
      table.appendChild(sectionEl);
    }
    const tr = document.createElement("tr");
    const info = model.rows?.[r];
    // stored height wins (CSS height on a <tr> is a minimum — content can
    // still grow it); 0/absent rows auto-fit their content like Apple.
    // Do NOT fall back to defaultRowHeightPt: Apple auto-fits unsized rows
    // (mini-calendar rows render ~13px under a 25.9pt stored default).
    if (info?.sizePt) tr.style.height = `${info.sizePt}px`;
    if (kind === "tbody") bodyOrdinal++;
    for (const c of visCols) {
      if (covered.has(cellKey(r, c))) continue;
      const cell: GridCell | null = grid[r]?.[c] ?? null;
      const header = r < headEnd || c < model.headerColumnCount;
      const footer = r >= footStart && !header;
      const td = document.createElement(header ? "th" : "td") as HTMLTableCellElement;
      const merge = anchor.get(cellKey(r, c));
      if (merge) {
        if (merge.rowSpan > 1) td.rowSpan = merge.rowSpan;
        if (merge.columnSpan > 1) td.colSpan = merge.columnSpan;
      }
      const norm = cell !== null ? asCell(cell) : null;
      // Section default first (header-row/header-column/footer/body look
      // from the table style — Apple's templates keep the whole header
      // look there and no per-cell styles), then the per-cell style
      // overrides on top.
      const section = r < headEnd ? model.style?.headerRowCellStyle
        : c < model.headerColumnCount ? model.style?.headerColumnCellStyle
        : r >= footStart ? model.style?.footerRowCellStyle
        : model.style?.bodyCellStyle;
      // banded rows: every second BODY row takes the banded fill; section
      // and per-cell fills paint over it (G5 acid table, Apple pattern:
      // 2nd/4th/... body rows banded)
      if (banded !== undefined && !header && !footer && bodyOrdinal % 2 === 1) {
        td.style.backgroundColor = banded;
      }
      if (section) applyCellStyle(td, section, header, footer, ctx);
      const style = cellStyleOf(model, norm?.cellStyleIndex);
      applyCellStyle(td, style, header, footer, ctx);
      if (norm) {
        const format = norm.fmt !== undefined ? formats[norm.fmt] : undefined;
        // Apple convention: numeric-formatted values right-align; an
        // explicit paragraph alignment (already applied) wins over the auto
        const formatAligns = format !== undefined && ["number", "currency", "percent", "date", "duration"].includes(format.kind);
        const typedAligns = norm.type === "date" || norm.type === "duration" || norm.type === "currency";
        const numeric = typeof norm.v === "number" || typedAligns || (norm.type === undefined && formatAligns && typeof norm.v !== "string" && typeof norm.v !== "boolean");
        if (!td.style.textAlign && numeric && norm.type !== "error") td.style.textAlign = "right";
        if (norm.type === "error") td.classList.add("cell-error");
        const text = valueToText(norm, format);
        td.textContent = text;
        // multi-paragraph cell text (rich-text cells join with \n) keeps
        // its line structure like Apple
        if (text.includes("\n")) td.style.whiteSpace = "pre-line";
      }
      td.dataset.row = String(r);
      td.dataset.col = String(c);
      tr.appendChild(td);
    }
    sectionEl!.appendChild(tr);
  }
  return table;
Thinking
Thinking

I've decided the fix is a converter-side per-cell overlay that pre-populates edge overrides with frame strokes on boundary cells (weaker than sidecar entries), avoiding any schema changes. Now implementing: outer frame from f58/f59 (flag 38) and header outer edges preferring f47/f50 (flag 39).

Read
{
  "file_path": "/Users/phebert/pnk/crates/pnk2json/src/tables.rs",
  "offset": 753,
  "limit": 30
}
753	        attach_borders(&mut foot, foot_sep, stroke_at(ctx, 57), stroke_at(ctx, 56), None);
754	        Some(TableStyle {
755	            banded_rows: props.iter().find_map(|p| p.boolean(1)),
756	            banded_fill: first(2)
757	                .and_then(|p| p.msg(2))
758	                .and_then(|f| crate::tsd::fill_of(ctx, &f)),
759	            body_cell_style: body,
760	            header_row_cell_style: hrow,
761	            header_column_cell_style: hcol,
762	            footer_row_cell_style: foot,
763	        })
764	    });
765	
766	    // Dense row-major grid with None holes, plus a deduped formats pool;
767	    // cells reference formats by index. Malformed formats (negative /
768	    // absurd decimals) were already warned about and dropped inside
769	    // decode_cell — the cell is emitted with `formatIndex` absent.
770	    let mut grid: Vec<Vec<Option<GridCell>>> =
771	        vec![vec![None; column_count as usize]; row_count as usize];
772	    // Row/column DEFAULT styles (header buckets) fill in for cells with no
773	    // per-cell style; row beats column.
774	    let row_styles = header_styles(ctx, store.as_ref(), 1, 0);
775	    let col_styles = header_styles(ctx, store.as_ref(), 0, 2);
776	    // Per-cell edge stroke overrides (stroke sidecar) merge into the
777	    // pooled cell styles; leftovers on empty positions synthesize a
778	    // value-less styled cell after the loop.
779	    let mut edge_overrides = sidecar_borders(ctx, &m);
780	    let mut formats: Vec<CellFormat> = Vec::new();
781	    for (row, col, mut cell, format) in cells {
782	        if cell.cell_style_index.is_none() {
✏️ Edit tables.rs
/Users/phebert/pnk/crates/pnk2json/src/tables.rs
    // Table-level style: TST.TableStyleArchive { super = 1,
    // table_properties = 11 } plus the style-network role slots on the model
    // (fields 18-21 cell styles, 24-27 text styles; docs/format/tables.md).
    let style = m.reference(3).and_then(|sid| {
+
    // Table-level style: TST.TableStyleArchive { super = 1,
    // table_properties = 11 } plus the style-network role slots on the model
    // (fields 18-21 cell styles, 24-27 text styles; docs/format/tables.md).
    // Outer-frame strokes are captured out of the closure for per-cell
    // baking below.
    let mut frame_h: Option<Stroke> = None;
    let mut frame_v: Option<Stroke> = None;
    let mut frame_hdr_h: Option<Stroke> = None;
    let mut frame_hdr_v: Option<Stroke> = None;
    let style = m.reference(3).and_then(|sid| {
The file /Users/phebert/pnk/crates/pnk2json/src/tables.rs has been updated successfully. (file state is current in your context — no need to Read it back)
✏️ Edit tables.rs
/Users/phebert/pnk/crates/pnk2json/src/tables.rs
        let foot_sep = if flag(37, true) { stroke_at(ctx, 54) } else { None };
        attach_borders(&mut foot, foot_sep, stroke_at(ctx, 57), stroke_at(ctx, 56), None);
        Some(TableStyle {
+
        let foot_sep = if flag(37, true) { stroke_at(ctx, 54) } else { None };
        attach_borders(&mut foot, foot_sep, stroke_at(ctx, 57), stroke_at(ctx, 56), None);
        // Outer frame: the section gridline defaults above are half-open
        // (each cell owns right+bottom) so boundary 0 — the table's top and
        // left OUTER edge — has no owner and never painted. Apple draws the
        // frame with its own strokes: table_body_*_border_stroke (f58/f59,
        // gated by table_border_visible f38), and the header region's outer
        // edge with header_row/column_border_stroke (f47/f50, gated f39)
        // [proto TSTStylePropertyArchiving.proto:70-101]. Captured here,
        // baked onto boundary cells below.
        if flag(38, true) {
            frame_h = stroke_at(ctx, 58);
            frame_v = stroke_at(ctx, 59);
        }
        if flag(39, true) {
            frame_hdr_h = stroke_at(ctx, 47);
            frame_hdr_v = stroke_at(ctx, 50);
        }
        Some(TableStyle {
The file /Users/phebert/pnk/crates/pnk2json/src/tables.rs has been updated successfully. (file state is current in your context — no need to Read it back)
Thinking
$ Bash
Header count variable names
grep -n "header_row_count\|header_column_count\|footer_row_count" /Users/phebert/pnk/crates/pnk2json/src/tables.rs | head -6
899:        header_row_count: m.varint(9).unwrap_or(0) as u32,
900:        header_column_count: m.varint(10).unwrap_or(0) as u32,
901:        footer_row_count: m.varint(11).unwrap_or(0) as u32,
929:        header_row_count: 0,
930:        header_column_count: 0,
931:        footer_row_count: 0,
✏️ Edit tables.rs
/Users/phebert/pnk/crates/pnk2json/src/tables.rs
    // Per-cell edge stroke overrides (stroke sidecar) merge into the
    // pooled cell styles; leftovers on empty positions synthesize a
    // value-less styled cell after the loop.
    let mut edge_overrides = sidecar_borders(ctx, &m);
+
    // Per-cell edge stroke overrides (stroke sidecar) merge into the
    // pooled cell styles; leftovers on empty positions synthesize a
    // value-less styled cell after the loop.
    let mut edge_overrides = sidecar_borders(ctx, &m);
    // Bake the outer frame onto boundary cells (weakest layer: only sides
    // the sidecar left unset — its explicit strokes AND erases win). The
    // header region's outer edge prefers the header border strokes; the
    // bottom/right outer edge also gets the frame so it draws Apple's
    // border stroke rather than the interior gridline.
    if frame_h.is_some() || frame_v.is_some() || frame_hdr_h.is_some() || frame_hdr_v.is_some() {
        let hdr_rows = m.varint(9).unwrap_or(0) as u32;
        let hdr_cols = m.varint(10).unwrap_or(0) as u32;
        let empty = || CellBorders { top: None, right: None, bottom: None, left: None };
        for c in 0..column_count {
            let top = if hdr_rows > 0 { frame_hdr_h.as_ref().or(frame_h.as_ref()) } else { frame_h.as_ref() };
            if let Some(s) = top {
                let e = edge_overrides.entry((0, c)).or_insert_with(empty);
                if e.top.is_none() {
                    e.top = Some(s.clone());
                }
            }
            if let (Some(s), true) = (frame_h.as_ref(), row_count > 0) {
                let e = edge_overrides.entry((row_count - 1, c)).or_insert_with(empty);
                if e.bottom.is_none() {
                    e.bottom = Some(s.clone());
                }
            }
        }
        for r in 0..row_count {
            let left = if hdr_cols > 0 { frame_hdr_v.as_ref().or(frame_v.as_ref()) } else { frame_v.as_ref() };
            if let Some(s) = left {
                let e = edge_overrides.entry((r, 0)).or_insert_with(empty);
                if e.left.is_none() {
                    e.left = Some(s.clone());
                }
            }
            if let (Some(s), true) = (frame_v.as_ref(), column_count > 0) {
                let e = edge_overrides.entry((r, column_count - 1)).or_insert_with(empty);
                if e.right.is_none() {
                    e.right = Some(s.clone());
                }
            }
        }
    }
The file /Users/phebert/pnk/crates/pnk2json/src/tables.rs has been updated successfully. (file state is current in your context — no need to Read it back)
$ Bash
Rebuild converter, re-scan boundary coverage
cargo build --release -p pnk2json 2>&1 | grep -E "^error|warning: unused" | head; echo BUILD-DONE; python3 /private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/borderscan2.py 2>/dev/null | head -8
warning: unused variable: `pixel`
warning: unused variable: `ctx`
warning: unused variable: `registry`
warning: unused variable: `formula_table`
BUILD-DONE
Thinking

Boundary coverage is now filled corpus-wide. Golden guard: this could alter G5's acid table, so visual verification first, then gates:

$ Bash
Visual diff flagged doc + golden tests
uv run --with pillow --with pyobjc-framework-Quartz --with pymupdf python3 scripts/visual_diff.py --app numbers --fixture ~/Development/pnk-fixtures/crawl/05377030c6172f60c8965bd65f04a2450f811fe8cbc177aa50ad7d8b4235f1cf.numbers --out /private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/vd-num3 --base-url http://127.0.0.1:8123 2>&1 | tail -1 && cargo test -p pnk2json --release 2>&1 | grep -E "test result|FAILED" | head -5
python3: can't open file '/Users/phebert/pnk/viewer/scripts/visual_diff.py': [Errno 2] No such file or directory
test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.03s
test result: ok. 9 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.08s
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
$ Bash
Visual diff from repo root
cd /Users/phebert/pnk && uv run --with pillow --with pyobjc-framework-Quartz --with pymupdf python3 scripts/visual_diff.py --app numbers --fixture ~/Development/pnk-fixtures/crawl/05377030c6172f60c8965bd65f04a2450f811fe8cbc177aa50ad7d8b4235f1cf.numbers --out /private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/vd-num3 --base-url http://127.0.0.1:8123 2>&1 | tail -1
[visual_diff] summary: /private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/vd-num3/summary.md
Read
{
  "file_path": "/private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/vd-num3/composites/composite-page-1.png"
}
[Image: original 5336x1650, displayed at 2000x618. Multiply coordinates by 2.67 to map to original image.]
Thinking

Goldens byte-stable, model coverage fixed. Now checking for overcorrection on a known-good doc (frames Apple doesn't draw) plus conformance and gate:

$ Bash
Visual diff a multi-table bordered doc
uv run --with pillow --with pyobjc-framework-Quartz --with pymupdf python3 scripts/visual_diff.py --app numbers --fixture ~/Development/pnk-fixtures/crawl/17891b89da2f381e1449442f3c118363fa621404a5602235cca476400bb88e63.numbers --out /private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/vd-num4 --base-url http://127.0.0.1:8123 2>&1 | tail -1
[visual_diff] summary: /private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/vd-num4/summary.md
Read
{
  "file_path": "/private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/vd-num4/composites/composite-page-1.png"
}