[Image: original 13506x2137, displayed at 2000x316. Multiply coordinates by 6.75 to map to original image.]
Our side is blank below the tabs — possible JS error with the new output. Checking the console:
cd /Users/phebert/pnk/viewer && cat > probe7-tmp.mjs <<'EOF'
import { chromium } from "playwright";
const b = await chromium.launch();
const page = await b.newPage({ viewport: { width: 1400, height: 900 } });
page.on("console", (m) => { if (m.type() === "error") console.log("CONSOLE:", m.text().slice(0, 300)); });
page.on("pageerror", (e) => console.log("PAGEERROR:", String(e).slice(0, 500)));
await page.goto("http://127.0.0.1:8123/");
await page.setInputFiles("#file-input", "/Users/phebert/Development/pnk-fixtures/crawl/17891b89da2f381e1449442f3c118363fa621404a5602235cca476400bb88e63.numbers");
await page.waitForTimeout(5000);
const n = await page.locator("table.sheet-table").count();
console.log("tables rendered:", n);
await b.close();
EOF
node probe7-tmp.mjs && rm probe7-tmp.mjscd /Users/phebert/pnk && ./target/release/pnk2json ~/Development/pnk-fixtures/crawl/17891b89da2f381e1449442f3c118363fa621404a5602235cca476400bb88e63.numbers 2>/dev/null | python3 -c "
import json,sys
d=json.load(sys.stdin); doc=d.get('document',d)
sh = doc['sheets'][0]
def count(dr, depth=0):
n = 1 if dr.get('type')=='table' else 0
for ch in dr.get('children',[]): n += count(ch, depth+1)
return n
print('sheet1 name:', sh['name'], 'drawables:', len(sh['drawables']), 'tables:', sum(count(dr) for dr in sh['drawables']))
print('types:', [dr.get('type') for dr in sh['drawables']][:12])
"One wide table print-paginated by Apple into columns — the composite is just misleading for print layouts. Let me look at our actual table's borders directly:
cd /Users/phebert/pnk/viewer && cat > probe8-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: 900 } });
await page.goto("http://127.0.0.1:8123/");
await page.setInputFiles("#file-input", "/Users/phebert/Development/pnk-fixtures/crawl/17891b89da2f381e1449442f3c118363fa621404a5602235cca476400bb88e63.numbers");
await page.waitForSelector("table.sheet-table", { timeout: 30000 });
await page.waitForTimeout(500);
const t = page.locator("table.sheet-table").first();
await t.screenshot({ path: `${SCRATCH}/num4-table.png`, clip: undefined }).catch(async () => {});
await b.close();
EOF
node probe8-tmp.mjs && rm probe8-tmp.mjs && cd /private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad && uv run --with pillow python3 -c "
from PIL import Image
im = Image.open('num4-table.png'); print(im.size)
im.crop((0,0,min(700,im.width),min(300,im.height))).save('num4-crop.png')
"{
"file_path": "/private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/num4-crop.png"
}Borders look right — full header outlines and the outer left edge, matching Apple. Full gates and ship:
bash scripts/build_viewer.sh 2>&1 | tail -1 && cd viewer && npx tsc --noEmit && npm test 2>&1 | tail -2 && cd .. && python3 scripts/conformance.py 2>&1 | tail -2
viewer built: viewer/dist/ (serve: cd viewer && npm run serve) 6 passed (1.6s) NO DEFECTS.
cat > probe9-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.screenshot({ path: `${SCRATCH}/final-landing.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("#warnings-dd summary");
await page.waitForTimeout(200);
await page.screenshot({ path: `${SCRATCH}/final-warn.png`, clip: { x: 640, y: 0, width: 640, height: 260 } });
await b.close();
EOF
cd viewer && cp ../probe9-tmp.mjs . 2>/dev/null; mv probe9-tmp.mjs viewer/ && cd viewer && node probe9-tmp.mjs && rm probe9-tmp.mjscd /Users/phebert/pnk/viewer && cat > probe9-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.screenshot({ path: `${SCRATCH}/final-landing.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("#warnings-dd summary");
await page.waitForTimeout(200);
await page.screenshot({ path: `${SCRATCH}/final-warn.png`, clip: { x: 620, y: 0, width: 660, height: 280 } });
await b.close();
EOF
node probe9-tmp.mjs && rm probe9-tmp.mjs{
"file_path": "/private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/final-landing.png"
}{
"file_path": "/private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/final-warn.png"
}cd /Users/phebert/pnk && git add viewer/index.html viewer/src/main.ts viewer/styles.css viewer/tests/gate.spec.ts && git commit -m "viewer: drop the refusal hint, octocat nav icon, roomier warnings dropdown The landing loses its last footnote (the refusal copy lives in the error cards where it matters; #drop-hint stays as the parser-load failure slot). The nav github link becomes the octocat mark. The warnings dropdown gets real padding and a rule between the per-code summary chips and the detail rows. Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_018a2hmoDhN4kBq1BwfbFdAU" && git add crates/pnk2json/src/tables.rs && git commit -m "pnk2json: tables draw their outer frame — top/left edges were unowned The default gridline strokes become section-style borders in half-open form (each cell owns right+bottom) so sidecar erases stay one-owner-per- edge — but that leaves boundary 0, the table's TOP and LEFT outer edge, with no owner: corpus scan found dozens of docs whose row-0 cells carry zero top borders while every interior boundary is stroked (the missing top/left borders Peter spotted). Apple draws the frame with dedicated strokes: table_body_horizontal/ vertical_border_stroke (f58/f59, gated 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]. Bake them onto boundary cells as the weakest per-cell overlay — the stroke sidecar's explicit edges and erases still win — including bottom/right, which previously borrowed the interior gridline stroke. Corpus rescan: zero tables left with unowned outer edges. Verified by eye vs Apple (05377030 template, 17891b89 budget). Goldens byte-stable, conformance NO DEFECTS, gate 6/6. Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_018a2hmoDhN4kBq1BwfbFdAU" && git push origin main 2>&1 | tail -1
4 files changed, 12 insertions(+), 10 deletions(-)
1 file changed, 62 insertions(+) 38c264c..09cb423 main -> main
All four done, pushed as a95c79d + 09cb423:
#drop-hint survives invisibly as the parser-load-failure slot.)table_body_*_border_stroke (f58/f59, gated by table_border_visible) plus header-region border strokes (f47/f50). Those now bake onto boundary cells as the weakest overlay, so explicit sidecar strokes and erases still win. Bottom/right outer edges also now use the true frame stroke instead of borrowing the interior gridline.Verified: corpus rescan shows zero tables left with unowned outer edges, spot-checked by eye against Apple's PDF on two flagged docs, goldens byte-stable, conformance NO DEFECTS, gate 6/6.
One thing I noticed en route, for the backlog: Apple's PDF export print-paginates wide Numbers sheets into page columns, so visual_diff composites for such docs pair one long viewer screenshot against many narrow pages — misleading but harmless. A future harness tweak could tile our screenshot to Apple's print geometry.
on mobile maybe remove the keynote left-side slide selector and make sure the numbers tabs render OK, pages docs default to full-width?
Let me check how Pages sizes its pages and what the Numbers tabs do, then write the mobile block:
grep -n "pages-wp-page\|pageSize\|width" /Users/phebert/pnk/viewer/src/pages.ts | head -20; grep -n "pages-flow\|pages-wp-page\|pages-print\|sheet-tabs\|sheet-area" /Users/phebert/pnk/viewer/styles.css
87: if (doc.pageSize) {
88: frame.style.aspectRatio = `${doc.pageSize.width} / ${doc.pageSize.height}`;
91: // scale: pages render at a fixed 720px logical width, scaled responsively
92: const scale = 720 / doc.pageSize.width;
93: inner.style.width = `${doc.pageSize.width}px`;
94: inner.style.height = `${doc.pageSize.height}px`;
99: inner.style.marginLeft = `${-doc.pageSize.width * scale / 2}px`;
104: frame.style.height = `${doc.pageSize.height * scale}px`;
110: inner.style.width = "720px";
146: * (TP.DocumentArchive page_width/height 30/31, margins 32-35 [proto]). */
157: const ps = doc.pageSize;
158: if (!ps || !(ps.width > 0) || !(ps.height > 0)) return null;
164: const contentW = ps.width - left - right;
167: return { w: ps.width, h: ps.height, left, top, contentW, contentH };
186: * width, then greedily packed into page frames of the printable height.
192: * column-by-column (column-fill: auto), measured at the column width and
210: const pageSizedDrawables = (p: (typeof body.paragraphs)[number]): Drawable[] | null => {
211: let pageSized = false;
219: if (sz.width >= g.w * 0.85 && sz.height >= g.h * 0.85) pageSized = true;
221: return pageSized ? positioned : null;
29: body .sheet-area, body .pages-flow, body .notes-panel, body .error-card { color: #1d1d1f; }
30: body .sheet-area, body .pages-flow { background: #ffffff; border-color: #d2d2d7; }
348:.sheet-tabs { display: flex; gap: 6px; flex-wrap: wrap; margin: 14px 0; }
354:.sheet-area { background: var(--card); border: 1px solid var(--line); border-radius: 10px; padding: 18px; overflow: auto; }
370:.pages-flow {
374:.pages-flow h1, .pages-flow h2, .pages-flow h3,
375:.pages-flow h4, .pages-flow h5, .pages-flow h6 { margin: 0.6em 0 0.3em; }
376:.pages-flow p { margin: 0.4em 0; }
392:/* Word-processing pagination: .pages-print is the printable area inside a
397:.pages-print { display: flow-root; color: #1d1d1f; line-height: 1.2; font-size: 11px; }
398:.pages-print p { margin: 0; }
399:.pages-print h1, .pages-print h2, .pages-print h3,
400:.pages-print h4, .pages-print h5, .pages-print h6 { margin: 0; }
401:.pages-wp-page { margin: 18px auto; max-width: 720px; }
411:.pages-print p, .pages-print h1, .pages-print h2, .pages-print h3,
412:.pages-print h4, .pages-print h5, .pages-print h6,
413:.pages-print .list-item { white-space: pre-wrap; tab-size: 36px; }
430:.pages-print .field[data-field-kind="footnote-mark"] { vertical-align: super; font-size: 0.7em; }{
"file_path": "/Users/phebert/pnk/viewer/src/pages.ts",
"offset": 80,
"limit": 40
}80 ctx: ViewerCtx,
81 drawables: Drawable[],
82 pageIndex: number | undefined,
83 templateDrawables?: Drawable[],
84 ): HTMLElement {
85 const frame = document.createElement("div");
86 frame.className = "canvas-frame pages-page";
87 if (doc.pageSize) {
88 frame.style.aspectRatio = `${doc.pageSize.width} / ${doc.pageSize.height}`;
89 const inner = document.createElement("div");
90 inner.className = "canvas-inner";
91 // scale: pages render at a fixed 720px logical width, scaled responsively
92 const scale = 720 / doc.pageSize.width;
93 inner.style.width = `${doc.pageSize.width}px`;
94 inner.style.height = `${doc.pageSize.height}px`;
95 inner.style.transform = `scale(${scale})`;
96 inner.style.position = "absolute";
97 inner.style.top = "0";
98 inner.style.left = "50%";
99 inner.style.marginLeft = `${-doc.pageSize.width * scale / 2}px`;
100 // template underlay paints first, beneath the page's own drawables
101 for (const d of templateDrawables ?? []) inner.appendChild(renderCanvasDrawable(d, hdoc, ctx));
102 for (const d of drawables) inner.appendChild(renderCanvasDrawable(d, hdoc, ctx));
103 frame.appendChild(inner);
104 frame.style.height = `${doc.pageSize.height * scale}px`;
105 frame.dataset.pageIndex = pageIndex === undefined ? "" : String(pageIndex);
106 } else {
107 const inner = document.createElement("div");
108 inner.className = "canvas-inner";
109 inner.style.position = "relative";
110 inner.style.width = "720px";
111 inner.style.minHeight = "400px";
112 for (const d of templateDrawables ?? []) inner.appendChild(renderCanvasDrawable(d, hdoc, ctx));
113 for (const d of drawables) inner.appendChild(renderCanvasDrawable(d, hdoc, ctx));
114 frame.appendChild(inner);
115 }
116 return frame;
117 }
118
119 function floatingSection(grep -n "pages-wp-page\|pages-print\|style.width\|transform" /Users/phebert/pnk/viewer/src/pages.ts | head -20
93: inner.style.width = `${doc.pageSize.width}px`;
95: inner.style.transform = `scale(${scale})`;
110: inner.style.width = "720px";
263: // transform scaling does not affect layout metrics, so these heights match
282: wrap.className = "pages-print";
392: frame.className = "canvas-frame pages-page pages-wp-page";
401: inner.style.width = `${g.w}px`;
403: inner.style.transform = `scale(${scale})`;
419: content.className = "pages-print";
423: content.style.width = `${g.contentW}px`;
444: area.className = "pages-footnote-area pages-print";
446: area.style.width = `${g.contentW}px`;
480: h.style.width = `${g.contentW}px`;
487: f.style.width = `${g.contentW}px`;{
"file_path": "/Users/phebert/pnk/viewer/src/pages.ts",
"offset": 385,
"limit": 30
}385 footnotesByPage.set(pg, list);
386 });
387 }
388
389 const scale = 720 / g.w;
390 for (let i = 0; i < pageCount; i++) {
391 const frame = document.createElement("div");
392 frame.className = "canvas-frame pages-page pages-wp-page";
393 frame.style.aspectRatio = `${g.w} / ${g.h}`;
394 frame.style.height = `${g.h * scale}px`;
395 // clip at the paper edge like Pages (full-bleed covers store images
396 // taller than the page: 00C's is 1155pt on a 1024pt page)
397 frame.style.overflow = "hidden";
398 frame.dataset.pageIndex = String(i);
399 const inner = document.createElement("div");
400 inner.className = "canvas-inner";
401 inner.style.width = `${g.w}px`;
402 inner.style.height = `${g.h}px`;
403 inner.style.transform = `scale(${scale})`;
404 inner.style.position = "absolute";
405 inner.style.top = "0";
406 inner.style.left = "50%";
407 inner.style.marginLeft = `${-g.w * scale / 2}px`;
408
409 // floating drawables first: behind the body text, like Apple's default
410 for (const d of floatingByPage.get(i) ?? []) {
411 inner.appendChild(renderCanvasDrawable(d, hdoc, ctx));
412 }
413 // full-bleed cover drawables paint at page coordinates (not inset)
414 for (const d of fullBleedByPage.get(i) ?? []) {I found both flavors hardcode a 720px display width, so I'll make it responsive using min(720, container) since pagination is measured in page points independent of display scale. Checking the call structure now.
grep -n "export function renderPages\|function floatingSection\|function wordProcessing\|floatingSection(\|720" /Users/phebert/pnk/viewer/src/pages.ts | head
91: // scale: pages render at a fixed 720px logical width, scaled responsively
92: const scale = 720 / doc.pageSize.width;
110: inner.style.width = "720px";
119:function floatingSection(
389: const scale = 720 / g.w;
508:export function renderPages(doc: PagesDocument, hdoc: HydratedDoc, ctx: ViewerCtx, mount: HTMLElement): void {
531: floatingSection(doc, hdoc, ctx, view, leading, wordProcessing ? "Cover page" : "Pages");
544: floatingSection(doc, hdoc, ctx, view, trailing, "Floating objects");{
"file_path": "/Users/phebert/pnk/viewer/src/pages.ts",
"offset": 508,
"limit": 45
}508 export function renderPages(doc: PagesDocument, hdoc: HydratedDoc, ctx: ViewerCtx, mount: HTMLElement): void {
509 const view = document.createElement("div");
510 view.id = "pages-view";
511
512 const wordProcessing = doc.flavor === "word-processing";
513 const geom = pageGeom(doc);
514
515 if (wordProcessing && doc.body && geom) {
516 // paginated word-processing render: page frames + margins. Footnotes
517 // render at their anchor pages' bottoms unless the document asks for
518 // endnotes (footnotePlacement: section/document endnotes).
519 paginatedBody(doc, hdoc, ctx, view);
520 if (doc.footnotePlacement) appendFootnotes(doc, hdoc, ctx, view);
521 mount.appendChild(view);
522 applyTextFit(view);
523 return;
524 }
525
526 // document order: floating groups anchored to page 1 (a cover) belong
527 // above the flowing body; later pages trail it
528 const leading = doc.floating.filter((g) => (g.pageIndex ?? 0) === 0);
529 const trailing = doc.floating.filter((g) => (g.pageIndex ?? 0) !== 0);
530 if (!wordProcessing || doc.body) {
531 floatingSection(doc, hdoc, ctx, view, leading, wordProcessing ? "Cover page" : "Pages");
532 }
533
534 if (wordProcessing && doc.body) {
535 const flow = document.createElement("article");
536 flow.className = "pages-flow";
537 const listState = newListNumberingState();
538 for (const p of doc.body.paragraphs) flow.appendChild(renderParagraph(p, hdoc, ctx, listState));
539 view.appendChild(flow);
540 appendFootnotes(doc, hdoc, ctx, view);
541 }
542
543 if (trailing.length > 0) {
544 floatingSection(doc, hdoc, ctx, view, trailing, "Floating objects");
545 }
546 mount.appendChild(view);
547 // measurement pass (attached): bounded shrink absorbs font-metric drift
548 applyTextFit(view);
549 }
550
551 function appendFootnotes(doc: PagesDocument, hdoc: HydratedDoc, ctx: ViewerCtx, view: HTMLElement): void {
552 if (!doc.footnotes?.length) return; if (doc.pageSize) {
frame.style.aspectRatio = `${doc.pageSize.width} / ${doc.pageSize.height}`;
const inner = document.createElement("div");
inner.className = "canvas-inner";
// scale: pages render at a fixed 720px logical width, scaled responsively
const scale = 720 / doc.pageSize.width; if (doc.pageSize) {
frame.style.aspectRatio = `${doc.pageSize.width} / ${doc.pageSize.height}`;
const inner = document.createElement("div");
inner.className = "canvas-inner";
// scale: pages render at a 720px logical width, or the container's on
// narrow (mobile) screens — display-only, layout metrics are in pt
const scale = pageDisplayWidth / doc.pageSize.width;The file /Users/phebert/pnk/viewer/src/pages.ts has been updated successfully. (file state is current in your context — no need to Read it back)
inner.style.position = "relative";
inner.style.width = "720px"; inner.style.position = "relative";
inner.style.width = `${pageDisplayWidth}px`;The file /Users/phebert/pnk/viewer/src/pages.ts has been updated successfully. (file state is current in your context — no need to Read it back)
const scale = 720 / g.w;
for (let i = 0; i < pageCount; i++) { const scale = pageDisplayWidth / g.w;
for (let i = 0; i < pageCount; i++) {The file /Users/phebert/pnk/viewer/src/pages.ts has been updated successfully. (file state is current in your context — no need to Read it back)
export function renderPages(doc: PagesDocument, hdoc: HydratedDoc, ctx: ViewerCtx, mount: HTMLElement): void {
const view = document.createElement("div");
view.id = "pages-view";/** Display width for a page frame: 720px logical, or the container width on
* narrow screens so pages default to full width on mobile. Set per render
* from the mount; pagination itself measures in page POINTS regardless. */
let pageDisplayWidth = 720;
export function renderPages(doc: PagesDocument, hdoc: HydratedDoc, ctx: ViewerCtx, mount: HTMLElement): void {
pageDisplayWidth = Math.max(280, Math.min(720, mount.clientWidth || 720));
const view = document.createElement("div");
view.id = "pages-view";The file /Users/phebert/pnk/viewer/src/pages.ts has been updated successfully. (file state is current in your context — no need to Read it back)
tail -8 /Users/phebert/pnk/viewer/styles.css; grep -n "\.sheet-tab {" -A 3 /Users/phebert/pnk/viewer/styles.css.drawable-text h4, .drawable-text h5, .drawable-text h6,
.drawable-text .list-item { white-space: pre-wrap; tab-size: 36px; }
/* Apple has no implicit paragraph margins in shape/textbox text either —
the UA's 1em <p> margin read as a phantom blank line between consecutive
paragraphs (G2's caption box). Spacing comes only from spaceBefore/After
on the paragraph style. */
.drawable-text p, .drawable-text h1, .drawable-text h2, .drawable-text h3,
.drawable-text h4, .drawable-text h5, .drawable-text h6 { margin: 0; }
349:.sheet-tab {
350- border-radius: 8px 8px 0 0; border-bottom: 3px solid transparent; padding: 6px 16px;
351-}
352-.sheet-tab.active { border-bottom-color: var(--accent); font-weight: 600; background: var(--card); }cat >> /Users/phebert/pnk/viewer/styles.css <<'EOF'
/* ---- mobile --------------------------------------------------------------- */
@media (max-width: 700px) {
#app { padding: 8px; }
/* Keynote: the thumbnail rail eats half the screen — the continuous
scroll IS the navigation on a phone. */
#keynote-view .slide-list { display: none; }
/* Numbers: sheet tabs stay one row, scrolled horizontally. */
.sheet-tabs { flex-wrap: nowrap; overflow-x: auto; }
.sheet-tab { flex: 0 0 auto; white-space: nowrap; padding: 6px 12px; }
.sheet-area { padding: 10px; }
/* Pages: page frames already size to the container (pages.ts
pageDisplayWidth); trim the chrome around them. */
.pages-flow { padding: 20px 16px; }
.canvas-outer { margin: 10px 0; }
#drop-card { padding: 24px 18px; }
}
EOF
bash scripts/build_viewer.sh 2>&1 | tail -1 && cd viewer && npx tsc --noEmit && npm test 2>&1 | tail -2The old #warnings-list { padding: 6px 0 } rule at line 212 (from the panel era) overrides my new dropdown padding since it comes later — removing the stale rule:
#warnings-list { padding: 6px 0; }
.warning-row { display: flex; gap: 10px; padding: 3px 0; border-top: 1px solid var(--surface); }.warning-row { display: flex; gap: 10px; padding: 3px 0; border-top: 1px solid var(--surface); }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 && cat > probem-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: 390, height: 844 }, isMobile: true, hasTouch: true, deviceScaleFactor: 2 });
await page.goto("http://127.0.0.1:8123/");
await page.waitForSelector("#drop-card");
// keynote
await page.setInputFiles("#file-input", "/Users/phebert/Development/pnk-fixtures/crawl/0d5851c0105958b602c58646b98ffaee1b453def8478f9b872aca37319d2ed5f.key");
await page.waitForSelector("#keynote-view", { timeout: 30000 });
await page.waitForTimeout(1500);
await page.screenshot({ path: `${SCRATCH}/mob-key.png` });
// numbers (many sheets)
await page.setInputFiles("#file-input", "/Users/phebert/Development/pnk-fixtures/crawl/17891b89da2f381e1449442f3c118363fa621404a5602235cca476400bb88e63.numbers");
await page.waitForSelector("#numbers-view", { timeout: 30000 });
await page.waitForTimeout(1500);
await page.screenshot({ path: `${SCRATCH}/mob-num.png` });
// pages WP
await page.setInputFiles("#file-input", "/Users/phebert/Development/pnk-fixtures/crawl/1eb960ba4ad8f155d70037395ca50b28b3993cf82d03a3d787daced092ddbd54.pages");
await page.waitForSelector("#pages-view", { timeout: 30000 });
await page.waitForTimeout(1200);
await page.screenshot({ path: `${SCRATCH}/mob-pages.png` });
await b.close();
EOF
node probem-tmp.mjs && rm probem-tmp.mjsExit code 1
bash: scripts/build_viewer.sh: No such file or directory
(eval):cd:1: no such file or directory: viewer
node:internal/modules/cjs/loader:1433
throw err;
^
Error: Cannot find module '/Users/phebert/pnk/viewer/probem-tmp.mjs'
at Function._resolveFilename (node:internal/modules/cjs/loader:1430:15)
at defaultResolveImpl (node:internal/modules/cjs/loader:1040:19)
at resolveForCJSWithHooks (node:internal/modules/cjs/loader:1045:22)
at Function._load (node:internal/modules/cjs/loader:1216:25)
at wrapModuleLoad (node:internal/modules/cjs/loader:254:19)
at Function.executeUserEntryPoint [as runMain] (node:internal/modules/run_main:171:5)
at node:internal/main/run_main_module:36:49 {
code: 'MODULE_NOT_FOUND',
requireStack: []
}
Node.js v22.23.2cd /Users/phebert/pnk && bash scripts/build_viewer.sh 2>&1 | tail -1 && cd viewer && cat > probem-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: 390, height: 844 }, isMobile: true, hasTouch: true, deviceScaleFactor: 2 });
await page.goto("http://127.0.0.1:8123/");
await page.waitForSelector("#drop-card");
await page.setInputFiles("#file-input", "/Users/phebert/Development/pnk-fixtures/crawl/0d5851c0105958b602c58646b98ffaee1b453def8478f9b872aca37319d2ed5f.key");
await page.waitForSelector("#keynote-view", { timeout: 30000 });
await page.waitForTimeout(1500);
await page.screenshot({ path: `${SCRATCH}/mob-key.png` });
await page.setInputFiles("#file-input", "/Users/phebert/Development/pnk-fixtures/crawl/17891b89da2f381e1449442f3c118363fa621404a5602235cca476400bb88e63.numbers");
await page.waitForSelector("#numbers-view", { timeout: 30000 });
await page.waitForTimeout(1500);
await page.screenshot({ path: `${SCRATCH}/mob-num.png` });
await page.setInputFiles("#file-input", "/Users/phebert/Development/pnk-fixtures/crawl/1eb960ba4ad8f155d70037395ca50b28b3993cf82d03a3d787daced092ddbd54.pages");
await page.waitForSelector("#pages-view", { timeout: 30000 });
await page.waitForTimeout(1200);
await page.screenshot({ path: `${SCRATCH}/mob-pages.png` });
await b.close();
EOF
node probem-tmp.mjs && rm probem-tmp.mjsls -la /private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/mob-*.png 2>/dev/null; ls probem-tmp.mjs 2>/dev/null && node probem-tmp.mjs && rm probem-tmp.mjs
Exit code 1 -rw-r--r--@ 1 phebert wheel 381383 Aug 30 12:58 /private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/mob-key.png -rw-r--r--@ 1 phebert wheel 71085 Aug 30 12:58 /private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/mob-num.png -rw-r--r--@ 1 phebert wheel 252980 Aug 30 12:58 /private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/mob-pages.png
{
"file_path": "/private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/mob-key.png"
}{
"file_path": "/private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/mob-num.png"
}{
"file_path": "/private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/mob-pages.png"
}cd /Users/phebert/pnk && git add viewer/src/pages.ts viewer/styles.css && git commit -m "viewer: mobile pass — no keynote rail, scrollable sheet tabs, full-width pages Under 700px: the Keynote thumbnail rail disappears (the continuous scroll IS the navigation on a phone), Numbers sheet tabs keep one row and scroll horizontally, and chrome padding tightens. Pages frames stop hardcoding a 720px display width: pageDisplayWidth = min(720, container) measured per render, so documents fill a phone screen edge-to-edge. Display-only — pagination still measures in page points, so page breaks are identical at every size. Also removed a stale warnings-panel padding rule that was overriding the new dropdown's. Verified at 390×844 (iPhone-ish) on the spamhaus deck, the budget spreadsheet, and the ATM-DOMINA word-processing doc; gate 6/6. Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_018a2hmoDhN4kBq1BwfbFdAU" && git push origin main 2>&1 | tail -1
2 files changed, 29 insertions(+), 5 deletions(-) 09cb423..6985d83 main -> main
Pushed as 6985d83. On screens under 700px:
min(720, container), so documents fill a phone edge-to-edge. This is display-only scaling: pagination still measures in page points, so page breaks are identical at every screen size. Desktop is unchanged (container > 720).I verified all three by screenshot at 390×844 — the Spamhaus deck scrolls cleanly slide-by-slide, the budget spreadsheet shows its tab strip and bordered table properly, and the ATM word-processing doc reads full-width. Also caught and removed a stale CSS rule from the old warnings-panel era that was silently overriding the new dropdown's padding.
One caveat worth knowing: the mobile width is measured at render time, so rotating a phone (or resizing a window across the breakpoint) after a document is open keeps the old page scale until the file is reopened. Fine for a hackathon demo; a resize listener is a small follow-up if you want it.
we're going to have to freeze fixes for submission, can you button up README and other documentation? Be concise, avoid narrative, do NOT delve into overwrought LLM load-bearing Claudlish please.
ls *.md docs/*.md 2>/dev/null && wc -l README.md 2>/dev/null; cat README.md 2>/dev/null | head -60
AGENTS.md
CLAUDE.md
docs/CONFORMANCE.md
docs/model-design.md
docs/model-review.md
README.md
83 README.md
# pnk
**Client-side Apple iWork document viewer** — open `.pages`, `.numbers`, and
`.key` files entirely in your browser. Drop a file, it's parsed in-page from the
raw file bytes by Rust compiled to WebAssembly, rendered as JSON, and displayed.
No account, no login, no email, no upload — nothing ever leaves your machine.
Built for [Hackyard Yard #1](https://hackyard.tech/yards/yard-1) ("no accounts",
48-hour solo build).
## What it does
- **Drag & drop** a Keynote / Numbers / Pages document (iWork '13 and newer,
flat file or package directory) onto the page.
- It is parsed **in your browser** by a Rust pipeline compiled to WebAssembly:
ZIP container → Snappy-compressed IWA streams → protobuf (TSP) object
database → a typed JSON document model.
- The viewer renders that JSON: Keynote slides with positioned shapes, images
and presenter notes; Numbers sheets with styled tables; Pages word-processing
flow with headings, paragraphs, and floating covers.
- Friendly error cards for the things it deliberately refuses: password-protected
files (`.iwph` / `.iwpv2` — we never ask for a password) and legacy pre-2013
iWork formats.
- **Zero network calls after load** — asserted in the Playwright gate. No
accounts, no analytics, no server.
## Quick start
```bash
# viewer (TS + esbuild, no framework)
cd viewer && npm install && npm run build && npm run serve
# CLI converters
cargo build --release
target/release/pnk2json document.pages > out.json # compact JSON
target/release/pnk2json document.numbers --markdown # readable fallback
```
Playwright gate: `cd viewer && npm install && npm run build && npm test`.
## Repo layout
| path | what |
| --- | --- |
| `crates/iwadump` | Rust CLI structure inspector for iWork '13+ documents |
| `crates/pnk2json` | converter lib + wasm binding + text/markdown dumpers |
| `viewer/` | vanilla-TS web app consuming `pnk2json.wasm` |
| `model/src/` | the TypeScript contract the JSON output obeys (strict tsc) |
| `docs/format/` | provenance-tagged iWork format reference — start at `INDEX.md` |
| `docs/CONFORMANCE.md` | how correctness is proven; corpus + cross-validation results |
| `scripts/` | research tooling, conformance + cross-validation harnesses |
| `fixtures/` | local corpus (gitignored; `provenance.json` committed) |
## How the format works (short version)
An iWork '13+ file is a ZIP whose `Index.zip` holds `.iwa` members: sequences
of Snappy-compressed blocks wrapping protobuf messages — a small object database
(TSWP text, TST tables, TSD drawables, TSCH charts, TSCE formulas, KN Keynote,
TSP shared storage). We decode it from scratch in Rust; every format claim in
`docs/format/` is provenance-tagged (`proto` / `parser` / `fixture-verified` /sed -n 60,83p README.md; head -30 docs/CONFORMANCE.md; head -25 docs/model-design.md; ls docs/format/ | head
`docs/format/` is provenance-tagged (`proto` / `parser` / `fixture-verified` / `inferred`). Start at [`docs/format/INDEX.md`](docs/format/INDEX.md). ## Verification - `scripts/conformance.py` — the whole corpus × JSON + markdown: 2,488 ok, 8 controlled encrypted rejects, 0 defects; timing stays linear. - `scripts/crossval.py` — every file's embedded QuickLook preview (rendered by Apple's own importer) is compared against our output: table censuses, text tokens, empty-grid detection. 960/968 clean; every flag investigated. - Viewer gate: one real fixture per app, encrypted + legacy error cards, and an assertion that **zero non-blob network requests** occur after load — the no-upload theme, enforced by test. ## Status Built for Hackyard Yard #1 (2026-08-28/30). See `docs/CONFORMANCE.md` for the reliability work and `docs/model-design.md` for the JSON model rationale. ## License Dual-licensed under [MIT](LICENSE-MIT) or [Apache-2.0](LICENSE-APACHE), at your option. Format research is documented with per-claim provenance and source commit hashes in `docs/format/`. # Conformance & Reliability — pnk2json How we prove the converter is correct and fast, and the specs for hand-built golden fixtures (built by a human in the real apps, asserted in tests). ## 1. Corpus harness ```bash cargo build --release python3 scripts/conformance.py --mode both # json + markdown over all of success.tsv ``` Verdicts per (fixture, mode): `ok`, `ok:encrypted-reject` (exit 1 with password/encryption wording), or defects (`DEFECT:*`, `TIMEOUT`, `PANIC`, `MISSING_FILE`). JSON report lands in `fixtures/conformance-report.json` (regenerable, not committed). ### Corpus health (2026-08-28, CC-MAIN-2026-34, 1,248 files × 2 modes) | metric | value | | --- | --- | | verdicts | 2,488 ok + 8 ok:encrypted-reject (4 files × 2 modes) | | defects | **0** (no panics, no timeouts, no unexpected rejects) | | wall (json mode) | 24.3 s total; median 8.3 ms; p95 39.5 ms; max 2.59 s | | throughput | median 241 MB/s of input | | time↔size Pearson r | 0.30 — **no super-linear timing** | ### Findings 1. **JSON amplification on table-heavy Numbers files** — the one reliability # pnk JSON Document Model — Design Notes The JSON the pnk pipeline emits (Rust `pnk2json` → TS viewer). The model is for a **reader/viewer, not an editor**: everything is resolved, flattened, and self-contained. Source of truth for the format is `docs/format/` (start at `INDEX.md`); this doc says how that format maps onto the model in `model/src/*.ts`. Files: | file | contents | |---|---| | `model/src/primitives.ts` | colors, geometry, fills/shadows/strokes, curve primitives, resolved text styles, units + conventions | | `model/src/shared.ts` | root envelope, TSWP text model, TSD drawable union, TST table model, TSCH chart model, TSCE placeholder | | `model/src/pages.ts` | `PagesDocument` — both flavors (word-processing / page-layout) | | `model/src/numbers.ts` | `NumbersDocument` — sheets as canvases | | `model/src/keynote.ts` | `KeynoteDocument` — show → slides, masters resolved | --- ## 1. Conventions ### 1.1 Units (everywhere, no exceptions) | quantity | unit | JSON shape | source | ATTRIBUTION.md calcengine.md charts.md container.md drawables.md gotchas.md incremental.md INDEX.md iwa.md keynote.md
cat viewer/package.json | head -20; head -20 scripts/build_viewer.sh; ls LICENSE* 2>/dev/null; git log --oneline | wc -l
{
"name": "pnk-viewer",
"version": "0.1.0",
"private": true,
"description": "Zero-backend in-browser viewer for iWork documents (.pages/.numbers/.key) via pnk2json.wasm",
"type": "module",
"scripts": {
"build": "bash ../scripts/build_viewer.sh",
"serve": "node_modules/.bin/esbuild --servedir=dist --serve=127.0.0.1:8123 --log-level=warning",
"typecheck": "tsc --noEmit -p tsconfig.json",
"test": "npm run typecheck && playwright test"
},
"devDependencies": {
"@playwright/test": "1.62.1",
"esbuild": "^0.25.0"
}
}#!/usr/bin/env bash
# Build the pnk viewer (viewer/ -> viewer/dist/), fully static:
# 1. cargo builds pnk2json-wasm for wasm32-unknown-unknown
# 2. wasm-bindgen --target web emits JS glue + .wasm into viewer/dist/wasm
# 3. the generated glue is vendored into viewer/src/wasm/ (committed; the
# tiny JS file is deterministic for a given wasm-bindgen version) so
# esbuild can bundle `import init from "./wasm/pnk2json_wasm.js"`
# 4. esbuild bundles viewer/src/main.ts -> viewer/dist/main.js
# 5. the static shell (index.html, styles.css) is copied to viewer/dist/
#
# Prerequisites: cargo, wasm-bindgen 0.2.127 on PATH; `npm install` run once
# inside viewer/ (esbuild + playwright devDependencies).
#
# Output layout (viewer/dist/): index.html styles.css main.js wasm/*.wasm
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT"
ESBUILD="$ROOT/viewer/node_modules/.bin/esbuild"
LICENSE-APACHE
LICENSE-MIT
204npm run build already wraps the full wasm build, so the quick start stands. Rewriting the README tight and current:
1 # pnk
2
3 **Client-side Apple iWork document viewer** — open `.pages`, `.numbers`, and
4 `.key` files entirely in your browser. Drop a file, it's parsed in-page from the
5 raw file bytes by Rust compiled to WebAssembly, rendered as JSON, and displayed.
6 No account, no login, no email, no upload — nothing ever leaves your machine.
7
8 Built for [Hackyard Yard #1](https://hackyard.tech/yards/yard-1) ("no accounts",
9 48-hour solo build).
10
11 ## What it does
12 # pnk
**Client-side Apple iWork viewer** — open `.pages`, `.numbers`, and `.key`
files entirely in your browser. The file is parsed from raw bytes by Rust
compiled to WebAssembly, emitted as a typed JSON model, and rendered by a
vanilla-TS viewer. No account, no upload, no backend.
Live at **[pnk.live](https://pnk.live)**. Built solo for
[Hackyard Yard #1](https://hackyard.tech/yards/yard-1) ("no accounts",
48 hours, 2026-08-28 → 30).
## Features
- Drag & drop anywhere; iWork '13+ flat files and package directories.
- **Keynote**: continuous slide scroll, master/theme underlays, shapes with
arrowheads and reflections, image bullets, shrink-to-fit text, presenter
notes.
- **Numbers**: per-sheet tabs, styled tables — merges, exact column widths,
cell borders and outer frames, number/currency/fraction/base-n/duration
formats.
- **Pages**: paginated word-processing with margins, headers/footers,
footnotes, drop caps, lists; page-layout canvases with template underlays.
- Syntax-colored JSON model view (`json` in the nav) with download.
- Encrypted and pre-2013 legacy files are refused with a clear error card;
no password prompt, nothing inspected server-side.
- Zero network requests after load — asserted in the test gate.
- Dark mode, mobile layout.
## Quick start
```bash
# viewer: wasm + bundle + static shell -> viewer/dist/
cd viewer && npm install && npm run build && npm run serve
# gate: strict tsc + Playwright (includes a zero-network assertion)
npm test
# CLI
cargo build --release
target/release/pnk2json document.pages > out.json # compact JSON
target/release/pnk2json document.numbers --markdown # readable fallback
target/release/iwadump document.key # raw IWA inspector
```
Prerequisites: rust + `wasm32-unknown-unknown` target, `wasm-bindgen` 0.2.127,
node 22+.
## Repo layout
| path | what |
| --- | --- |
| `crates/iwadump` | Rust CLI structure inspector for iWork '13+ documents |
| `crates/pnk2json` | converter lib + wasm binding + text/markdown dumpers |
| `viewer/` | vanilla-TS web app consuming `pnk2json.wasm` |
| `model/src/` | the TypeScript contract the JSON output obeys (strict tsc) |
| `docs/format/` | provenance-tagged iWork format reference — start at `INDEX.md` |
| `docs/model-design.md` | JSON model rationale and conventions |
| `docs/CONFORMANCE.md` | corpus + cross-validation results |
| `scripts/` | conformance / visual-diff / research harnesses |
| `fixtures/` | corpus (gitignored; `provenance.json` and golden checklists committed) |
## Format, short version
An iWork '13+ file is a ZIP whose `Index/` members are `.iwa` streams:
Snappy-compressed blocks wrapping a protobuf object database (TSWP text, TST
tables, TSD drawables, TSCH charts, TSCE formulas, KN Keynote, TSP storage).
Decoded from scratch in Rust. Every format claim in `docs/format/` carries a
provenance tag (`proto` / `parser` / `fixture-verified` / `inferred`); start
at [`docs/format/INDEX.md`](docs/format/INDEX.md).
## Verification
- `scripts/conformance.py` — 1,248-file Common Crawl corpus × JSON + markdown:
2,488 ok, 8 controlled encrypted rejects, 0 defects, linear timing.
- `scripts/visual_diff.py` — side-by-side composites of our render vs PDF
exported from the real apps (driven by AppleScript), judged per page.
- Golden fixtures (`fixtures/golden/`) hand-built in the real apps from
one-feature-per-item checklists; converter output is byte-pinned in
`cargo test`.
- Viewer gate: one real fixture per app, error cards, and zero non-blob
network requests after load.
## License
MIT ([LICENSE-MIT](LICENSE-MIT)) or Apache-2.0
([LICENSE-APACHE](LICENSE-APACHE)), at your option.
Pages, Numbers, Keynote, and iWork are trademarks of Apple Inc. This project
is not affiliated with or endorsed by Apple.
The file /Users/phebert/pnk/README.md has been updated successfully. (file state is current in your context — no need to Read it back)
ls scripts/ | head -20; grep -c "crossval" docs/CONFORMANCE.md 2>/dev/null; head -5 docs/format/INDEX.md
__pycache__ app_export_pdf.sh build_viewer.sh conformance.py crossval.py docs_fetch_sources.py fixtures_downloadall.py fixtures_ec2_runbook.md fixtures_queryindex.py visual_diff.py 1 # iWork File Format Reference — INDEX Reference documentation for the Apple iWork '13+ file format (`.pages`, `.numbers`, `.key`), as used by the **pnk** viewer. Built during phase 1 from primary sources: protobuf definitions extracted from the locally installed
Everything referenced exists. One small close-out stamp on the steward record, then commit:
- §2 hygiene nits (Sheet.style typing, legendFrame→Rect, adjustments index signature): still open, owners fix opportunistically with a steward ping.
- §2 hygiene nits (Sheet.style typing, legendFrame→Rect, adjustments index signature): still open, owners fix opportunistically with a steward ping. - 2026-08-30: submission freeze. All rulings above are landed; §2 nits carry over as post-hackathon work.
The file /Users/phebert/pnk/docs/model-review.md has been updated successfully. (file state is current in your context — no need to Read it back)
git add README.md docs/model-review.md && git commit -m "docs: README buttoned up for submission Feature list matches the shipped viewer (continuous keynote scroll, JSON view, borders/formats, mobile), quick start verified against the actual npm scripts, verification section lists the four proof mechanisms (conformance corpus, visual_diff vs real apps, golden fixtures, viewer gate), live link to pnk.live, Apple trademark note. model-review.md gets a freeze stamp. Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_018a2hmoDhN4kBq1BwfbFdAU" && git push origin main 2>&1 | tail -1
2 files changed, 59 insertions(+), 52 deletions(-) 6985d83..bf44ba7 main -> main
Done, pushed as bf44ba7. The README is now a tight one-screen read: what it is, feature list matching what actually shipped, verified quick-start commands (checked against the real npm scripts and build script), repo layout table, a four-line format primer, the four verification mechanisms, and license + Apple trademark note. It links to pnk.live as the live site. docs/model-review.md got a one-line freeze stamp marking the §2 hygiene nits as post-hackathon carry-over; everything else in docs/ was already factual and dated, so I left it alone.
Two things to know for the deploy from the other machine:
viewer/dist/ — regenerate it there with cd viewer && npm install && npm run build (needs rust + wasm32-unknown-unknown + wasm-bindgen 0.2.127), or just rsync the dist/ from this machine since it's fully static.pnk.vu (wordmark, tab title) while the site will live at pnk.live. If that's intentional, ignore this; if you'd rather the wordmark match the domain, it's a two-file find/replace — say the word before you build for deploy.sorry I misspoke it is going to pnk.vu! $50 cheaper lol. making the video now need to close this session, thanks again you were amaze amaze amaze!
Live at **[pnk.live](https://pnk.live)**.
Live at **[pnk.vu](https://pnk.vu)**.
The file /Users/phebert/pnk/README.md has been updated successfully. (file state is current in your context — no need to Read it back)
git add README.md && git commit -m "docs: the live site is pnk.vu after all Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_018a2hmoDhN4kBq1BwfbFdAU" && git push origin main 2>&1 | tail -1 && git status --short | head -3
1 file changed, 1 insertion(+), 1 deletion(-) bf44ba7..dd5d81b main -> main ?? crates/pnk2json/examples/ ?? fixtures/templates/keynote/ ?? fixtures/templates/numbers/
Fixed and pushed as dd5d81b — README now points to pnk.vu, which also means the in-app branding matches the domain perfectly. Nothing to change before you build for deploy.
The tree is fully synced: every commit is on origin/main, all gates green as of the freeze. The only untracked leftovers are the Apple template binaries (deliberately untracked) and that stray debug probe in crates/pnk2json/examples/ — neither ships.
It was a genuinely fun build — from Snappy blocks to arrowhead geometry to a product with a wordmark, in 48 hours, with a public paper trail of 200+ commits to show for it. Good luck with the video and the voting — go get 'em! 🎉