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?
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):
// 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)
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)
<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)
<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 & 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)
/* 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)
/* ---- 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)
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{
"file_path": "/private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/footer.png"
}{
"file_path": "/private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/json-compact.png"
}Both land nicely — "position": { "x": …, "y": … } inlines, and whole paragraph items collapse to a line when they fit. Committing:
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 -13 files changed, 65 insertions(+), 1 deletion(-) f72882d..38c264c main -> main
All three shipped as 38c264c:
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.github link to the repo, muted until hover."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?
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:
<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)
<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)
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)
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)
#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)
#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)
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");/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
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./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']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 -2500a641002b038f06 '?' {'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}Pools carry all four sides — need to see the actual rendering. Ground truth on the first 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
ls /private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/vd-num1/composites/
{
"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.]
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
{
"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.]
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:
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": ""
}
]
}{
"file_path": "/private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/num-table-dom.png"
}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:
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);
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 -2500a641002b038f06 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
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.
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({
"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 {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.
{
"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);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
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
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;grep -n "TableStylePropertiesArchive" -A 75 /Users/phebert/pnk/.scratch/otorp/Numbers/TSTArchives.proto | grep -n "stroke\|= 3[3-7]\|border" | head -20
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"
awk '/message TableStylePropertiesArchive/,/^}/' /Users/phebert/pnk/.scratch/otorp/Numbers/TSTArchives.proto | grep -in "stroke\|vis\|border\|outline"
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.protogrep -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;
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;
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.
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;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).
{
"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() { // 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)
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)
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,
// 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)
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
Boundary coverage is now filled corpus-wide. Golden guard: this could alter G5's acid table, so visual verification first, then gates:
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
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
{
"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.]
Goldens byte-stable, model coverage fixed. Now checking for overcorrection on a known-good doc (frames Apple doesn't draw) plus conformance and gate:
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
{
"file_path": "/private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/vd-num4/composites/composite-page-1.png"
}