G2 has something odd where the green shadow appears to apply to the black diamond instead of the green pentagon?
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('vd-g2c/composites/composite-page-1.png')
# apple: pentagon ~ (890-1180, 360-680); ours right half starts ~1281
im.crop((860, 350, 1200, 700)).resize((680,700)).save('g2-pent-apple.png')
im.crop((1870, 400, 2080, 700)).resize((630,900)).save('g2-pent-ours.png')
"{
"file_path": "/private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/g2-pent-apple.png"
}{
"file_path": "/private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/g2-pent-ours.png"
}The render is showing the pentagon's green reflection under the diamond instead, while the pentagon lost its shadow and reflection—I'll check the JSON to see which drawable actually holds those effects.
./target/release/pnk2json --pretty fixtures/golden/G2-golden-pages-layout.pages 2>/dev/null | python3 -c "
import json, sys
d = json.load(sys.stdin)
doc = d.get('document', d)
def walk(dr, depth=0):
c = dr.get('common', {})
fill = dr.get('fill') or (dr.get('style') or {}).get('fill')
print(' '*depth, dr.get('type'),
'pos=', c.get('position'), 'size=', c.get('size'),
'fill=', json.dumps(fill)[:60] if fill else None,
'shadow=', 'shadow' in c or 'shadow' in dr,
'reflection=', 'reflection' in c or 'reflection' in dr)
for ch in dr.get('children', []): walk(ch, depth+2)
for page in doc.get('floating', []) or []:
for dr in page.get('drawables', []): walk(dr)
" textbox pos= {'x': 75.97708129882812, 'y': 62.400001525878906} size= {'width': 250.0, 'height': 128.0} fill= None shadow= False reflection= False
shape pos= {'x': 306.0, 'y': 62.400001525878906} size= {'width': 100.0, 'height': 100.0} fill= None shadow= False reflection= False
shape pos= {'x': 431.16876220703125, 'y': 62.400001525878906} size= {'width': 100.0, 'height': 100.0} fill= None shadow= False reflection= False
shape pos= {'x': 306.0, 'y': 176.9499969482422} size= {'width': 100.0, 'height': 100.0} fill= None shadow= False reflection= False
shape pos= {'x': 426.16876220703125, 'y': 176.9499969482422} size= {'width': 110.0, 'height': 110.0} fill= None shadow= True reflection= True
shape pos= {'x': 306.0, 'y': 296.0} size= {'width': 100.0, 'height': 100.0} fill= None shadow= True reflection= False
shape pos= {'x': 428.86065673828125, 'y': 296.0} size= {'width': 100.0, 'height': 100.0} fill= None shadow= False reflection= False
shape pos= {'x': 296.0, 'y': 438.5062561035156} size= {'width': 120.0, 'height': 60.0} fill= None shadow= False reflection= False
shape pos= {'x': 442.9926452636719, 'y': 441.3953552246094} size= {'width': 120.35222625732422, 'height': 57.11088943481445} fill= None shadow= False reflection= False
shape pos= {'x': 443.09375, 'y': 543.1658935546875} size= {'width': 114.37886810302734, 'height': 57.22382354736328} fill= None shadow= False reflection= False
textbox pos= {'x': 63.0, 'y': 368.0} size= {'width': 207.7375030517578, 'height': 31.0} fill= None shadow= False reflection= False
image pos= {'x': 63.0, 'y': 246.0} size= {'width': 94.43827056884766, 'height': 113.32592010498047} fill= None shadow= False reflection= False
group pos= {'x': 245.95416259765625, 'y': 524.7249755859375} size= {'width': 160.04583740234375, 'height': 100.0} fill= None shadow= False reflection= False
shape pos= {'x': 60.04583740234375, 'y': 0.0} size= {'width': 100.0, 'height': 100.0} fill= None shadow= False reflection= False
shape pos= {'x': 0.0, 'y': 50.0} size= {'width': 57.045833587646484, 'height': 0.0} fill= None shadow= False reflection= False
textbox pos= {'x': 181.0, 'y': 148.1999969482422} size= {'width': 250.0, 'height': 128.0} fill= None shadow= False reflection= False
textbox pos= {'x': 87.80000305175781, 'y': 285.3999938964844} size= {'width': 250.0, 'height': 128.0} fill= None shadow= False reflection= FalseThe converter is correct—the pentagon has both shadow and reflection while the diamond has neither, so the bug is in how the viewer positions the reflection (rendering it ~100px too low). I'll check the reflection rendering code next.
grep -n "reflection" /Users/phebert/pnk/viewer/src/drawables.ts | head
711: if (c.reflection) {
712: // Chromium/WebKit only; other engines just skip the reflection.
714: // the contact line: Apple's reflection is strongest there (~opacity)
720: `below 0px linear-gradient(rgba(0,0,0,${c.reflection.opacity}), transparent 55%)`,{
"file_path": "/Users/phebert/pnk/viewer/src/drawables.ts",
"offset": 660,
"limit": 70
}660 dot.setAttribute("fill", "#fff");
661 dot.setAttribute("stroke", color);
662 dot.setAttribute("stroke-width", "2");
663 svg.appendChild(dot);
664 }
665 });
666 return svg;
667 }
668 const barW = (groupW * 0.7) / chart.series.length;
669 chart.series.forEach((s, si) => {
670 s.values.forEach((v, vi) => {
671 if (typeof v !== "number") return;
672 const bar = document.createElementNS(NS, "rect");
673 const bh = (v / max) * (h - 8);
674 bar.setAttribute("x", String(vi * groupW + groupW * 0.15 + si * barW));
675 bar.setAttribute("y", String(h - bh));
676 bar.setAttribute("width", String(barW));
677 bar.setAttribute("height", String(bh));
678 bar.setAttribute("fill", colors[si % colors.length]);
679 svg.appendChild(bar);
680 });
681 });
682 return svg;
683 }
684
685 // ---------------------------------------------------------------------------
686 // Public entry points
687 // ---------------------------------------------------------------------------
688
689 /** Position + rotate + opacity from DrawableCommon, 1pt = 1px. */
690 export function applyCommonGeometry(div: HTMLElement, c: DrawableCommon): void {
691 const s = div.style;
692 if (c.position) {
693 s.left = `${c.position.x}px`;
694 s.top = `${c.position.y}px`;
695 }
696 if (c.size) {
697 s.width = `${c.size.width}px`;
698 s.height = `${c.size.height}px`;
699 }
700 if (c.angleDeg) s.transform = `rotate(${-c.angleDeg}deg)`;
701 if (c.opacity !== undefined) s.opacity = String(c.opacity);
702 if (c.shadow && c.shadow.kind === "drop") {
703 // Angle convention fixture-verified on G2's pentagon (angle 45, offset 5
704 // renders down-right in Apple's raster): dx = cos, dy = sin, CSS y-down.
705 const a = (c.shadow.angleDeg * Math.PI) / 180;
706 const dx = Math.cos(a) * c.shadow.offsetPt;
707 const dy = Math.sin(a) * c.shadow.offsetPt;
708 const [r, g, b] = hexRgb(c.shadow.color);
709 s.filter = `drop-shadow(${dx.toFixed(1)}px ${dy.toFixed(1)}px ${c.shadow.radiusPt}px rgba(${r},${g},${b},${c.shadow.opacity}))`;
710 }
711 if (c.reflection) {
712 // Chromium/WebKit only; other engines just skip the reflection.
713 // The mask paints over the MIRRORED copy top-down, so its top edge is
714 // the contact line: Apple's reflection is strongest there (~opacity)
715 // and dies out ~55% down (3c844ac1 logos vs Keynote's export) — the
716 // previous transparent-to-opaque ramp faded the wrong way, drawing a
717 // readable mirrored logo at full strength far from the object.
718 s.setProperty(
719 "-webkit-box-reflect",
720 `below 0px linear-gradient(rgba(0,0,0,${c.reflection.opacity}), transparent 55%)`,
721 );
722 }
723 }
724
725 function hexRgb(hex: string): [number, number, number] {
726 const v = parseInt(hex.replace("#", "").slice(0, 6), 16);
727 return [(v >> 16) & 255, (v >> 8) & 255, v & 255];
728 }
729 The JSON logic is fine, but the reflection gradient mask is running backward and the pentagon's drop shadow is missing—both introduced in applyCommonGeometry (drawables.ts:702-722). I'll write a minimal test to verify Chromium's actual box-reflect mask direction rather than guessing.
<!doctype html>
<meta charset="utf-8">
<style>
body { background: #fff; margin: 20px; display: flex; gap: 60px; }
.box { width: 100px; height: 100px; }
.grad { background: linear-gradient(#0a0, #0f0); }
/* A: current viewer ramp: opaque at gradient-top -> transparent 55% */
.a { -webkit-box-reflect: below 0px linear-gradient(rgba(0,0,0,.5), transparent 55%); }
/* B: inverse ramp: transparent 45% -> opaque at gradient-bottom */
.b { -webkit-box-reflect: below 0px linear-gradient(transparent 45%, rgba(0,0,0,.5)); }
/* C: A plus a drop-shadow filter, to see if filter+reflect coexist */
.c { -webkit-box-reflect: below 0px linear-gradient(rgba(0,0,0,.5), transparent 55%);
filter: drop-shadow(4px 4px 3px rgba(0,0,0,.6)); }
</style>
<div class="box grad a"></div>
<div class="box grad b"></div>
<div class="box grad c"></div>
File created successfully at: /private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/reflect-probe.html (file state is current in your context — no need to Read it back)
cd /Users/phebert/pnk/viewer && node -e "
const { chromium } = require('playwright');
(async () => {
const b = await chromium.launch();
const p = await b.newPage({ viewport: { width: 560, height: 280 } });
await p.goto('file:///private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/reflect-probe.html');
await p.screenshot({ path: '/private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/reflect-probe.png' });
await b.close();
})();
"{
"file_path": "/private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/reflect-probe.png"
}The probe confirms Chromium flips the mask with the reflection, so the current gradient direction causes the G2 gap bug, while reversing it (transparent → opaque) matches Apple's look. Before making that change, I'll check what the specialist actually modified and why, since their comment claims the opposite direction broke a logos fixture.
cd /Users/phebert/pnk && git log -L 700,725:viewer/src/drawables.ts --oneline 2>/dev/null | head -60
43879e8 drawables: reflection fades FROM the contact edge, not toward it
diff --git a/viewer/src/drawables.ts b/viewer/src/drawables.ts
--- a/viewer/src/drawables.ts
+++ b/viewer/src/drawables.ts
@@ -700,21 +700,26 @@
if (c.angleDeg) s.transform = `rotate(${-c.angleDeg}deg)`;
if (c.opacity !== undefined) s.opacity = String(c.opacity);
if (c.shadow && c.shadow.kind === "drop") {
// Angle convention fixture-verified on G2's pentagon (angle 45, offset 5
// renders down-right in Apple's raster): dx = cos, dy = sin, CSS y-down.
const a = (c.shadow.angleDeg * Math.PI) / 180;
const dx = Math.cos(a) * c.shadow.offsetPt;
const dy = Math.sin(a) * c.shadow.offsetPt;
const [r, g, b] = hexRgb(c.shadow.color);
s.filter = `drop-shadow(${dx.toFixed(1)}px ${dy.toFixed(1)}px ${c.shadow.radiusPt}px rgba(${r},${g},${b},${c.shadow.opacity}))`;
}
if (c.reflection) {
// Chromium/WebKit only; other engines just skip the reflection.
+ // The mask paints over the MIRRORED copy top-down, so its top edge is
+ // the contact line: Apple's reflection is strongest there (~opacity)
+ // and dies out ~55% down (3c844ac1 logos vs Keynote's export) — the
+ // previous transparent-to-opaque ramp faded the wrong way, drawing a
+ // readable mirrored logo at full strength far from the object.
s.setProperty(
"-webkit-box-reflect",
- `below 0px linear-gradient(transparent 30%, rgba(0,0,0,${c.reflection.opacity}))`,
+ `below 0px linear-gradient(rgba(0,0,0,${c.reflection.opacity}), transparent 55%)`,
);
}
}
function hexRgb(hex: string): [number, number, number] {
5fe7d1d viewer: regular-polygon + double-arrow presets, drop/contact shadows, reflections
diff --git a/viewer/src/drawables.ts b/viewer/src/drawables.ts
--- a/viewer/src/drawables.ts
+++ b/viewer/src/drawables.ts
@@ -367,2 +387,21 @@
if (c.angleDeg) s.transform = `rotate(${-c.angleDeg}deg)`;
if (c.opacity !== undefined) s.opacity = String(c.opacity);
+ if (c.shadow && c.shadow.kind === "drop") {
+ // Angle convention fixture-verified on G2's pentagon (angle 45, offset 5
+ // renders down-right in Apple's raster): dx = cos, dy = sin, CSS y-down.
+ const a = (c.shadow.angleDeg * Math.PI) / 180;
+ const dx = Math.cos(a) * c.shadow.offsetPt;
+ const dy = Math.sin(a) * c.shadow.offsetPt;
+ const [r, g, b] = hexRgb(c.shadow.color);
+ s.filter = `drop-shadow(${dx.toFixed(1)}px ${dy.toFixed(1)}px ${c.shadow.radiusPt}px rgba(${r},${g},${b},${c.shadow.opacity}))`;
+ }
+ if (c.reflection) {
+ // Chromium/WebKit only; other engines just skip the reflection.
+ s.setProperty(
+ "-webkit-box-reflect",
+ `below 0px linear-gradient(transparent 30%, rgba(0,0,0,${c.reflection.opacity}))`,
+ );
+ }
+}
+
+function hexRgb(hex: string): [number, number, number] {Confirmed the original transparent-to-opaque ramp direction was correct, and the specialist's inversion stemmed from misreading flip semantics; I'll keep their measured geometry (strongest at contact, fading by ~55%) but apply the right orientation. Next I'll check the pentagon's shadow values since they seemed missing in our render.
./target/release/pnk2json --pretty fixtures/golden/G2-golden-pages-layout.pages 2>/dev/null | python3 -c "
import json, sys
d = json.load(sys.stdin)
doc = d.get('document', d)
def walk(dr):
c = dr.get('common', {})
if c.get('shadow') or c.get('reflection'):
print(dr.get('type'), c.get('position'), 'shadow=', c.get('shadow'), 'refl=', c.get('reflection'))
for ch in dr.get('children', []): walk(ch)
for page in doc.get('floating', []) or []:
for dr in page.get('drawables', []): walk(dr)
"shape {'x': 426.16876220703125, 'y': 176.9499969482422} shadow= {'color': '#000000', 'angleDeg': 45.0, 'offsetPt': 5.0, 'radiusPt': 4.0, 'opacity': 0.5, 'kind': 'drop'} refl= {'opacity': 0.5}
shape {'x': 306.0, 'y': 296.0} shadow= {'color': '#000000', 'angleDeg': 0.0, 'offsetPt': 9.0, 'radiusPt': 30.0, 'opacity': 0.75, 'kind': 'contact', 'contact': {'height': 0.25}} refl= NoneThe pentagon's shadow settings are fine — the real issue is the inverted mask. I'll flip it back using the measured falloff stops (strong at contact, fading out by ~55%).
if (c.reflection) {
// Chromium/WebKit only; other engines just skip the reflection.
// The mask paints over the MIRRORED copy top-down, so its top edge is
// the contact line: Apple's reflection is strongest there (~opacity)
// and dies out ~55% down (3c844ac1 logos vs Keynote's export) — the
// previous transparent-to-opaque ramp faded the wrong way, drawing a
// readable mirrored logo at full strength far from the object.
s.setProperty(
"-webkit-box-reflect",
`below 0px linear-gradient(rgba(0,0,0,${c.reflection.opacity}), transparent 55%)`,
);
} if (c.reflection) {
// Chromium/WebKit only; other engines just skip the reflection.
// Mask orientation, probe-verified on Chromium 151 (scratchpad
// reflect-probe.html): the gradient is flipped WITH the mirrored copy,
// so gradient-BOTTOM lands on the contact line. Apple's reflection is
// strongest at contact (~opacity) and dead ~55% out (measured on
// 3c844ac1 logos vs Keynote's export), so: transparent through the far
// 45%, ramping to opaque at the bottom. The previous opaque-to-
// transparent ramp painted the ghost far from the shape and nothing at
// contact (G2's pentagon reflection appeared under the diamond).
s.setProperty(
"-webkit-box-reflect",
`below 0px linear-gradient(transparent 45%, rgba(0,0,0,${c.reflection.opacity}))`,
);
}The file /Users/phebert/pnk/viewer/src/drawables.ts 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 && uv run --with pillow --with pyobjc-framework-Quartz --with pymupdf python3 scripts/visual_diff.py --app pages --fixture fixtures/golden/G2-golden-pages-layout.pages --out /private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/vd-g2d --base-url http://127.0.0.1:8123 2>&1 | tail -1; find ~/Development/pnk-fixtures -name "3c844ac1*" | head -1
viewer built: viewer/dist/ (serve: cd viewer && npm run serve) [visual_diff] summary: /private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/vd-g2d/summary.md /Users/phebert/Development/pnk-fixtures/crawl/3c844ac16fa2f0abceaf0d5de44904411bcfdf70b339d7322c7d264f406ee593.key
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('vd-g2d/composites/composite-page-1.png')
im.crop((860, 350, 1200, 700)).resize((680,700)).save('g2-pent-apple2.png')
im.crop((1870, 400, 2080, 700)).resize((630,900)).save('g2-pent-ours3.png')
"{
"file_path": "/private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/g2-pent-ours3.png"
}Reflection now hugs the contact line and fades out, with the diamond correctly painting over it. Comparing directly with Apple's crop:
{
"file_path": "/private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/g2-pent-apple2.png"
}The reflection now matches Apple's style closely enough. Now I'll run the regression check on the logos deck that the specialist's inverted fix was based on.
uv run --with pillow --with pyobjc-framework-Quartz --with pymupdf python3 scripts/visual_diff.py --app keynote --fixture /Users/phebert/Development/pnk-fixtures/crawl/3c844ac16fa2f0abceaf0d5de44904411bcfdf70b339d7322c7d264f406ee593.key --out /private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/vd-logos --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-logos [visual_diff] summary: /private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/vd-logos/summary.md
./target/release/pnk2json --pretty /Users/phebert/Development/pnk-fixtures/crawl/3c844ac16fa2f0abceaf0d5de44904411bcfdf70b339d7322c7d264f406ee593.key 2>/dev/null | python3 -c "
import json, sys
d = json.load(sys.stdin)
doc = d.get('document', d)
for i, sl in enumerate(doc.get('slides', []), 1):
n = 0
def walk(dr):
global n
if (dr.get('common') or {}).get('reflection'): n += 1
for ch in dr.get('children', []): walk(ch)
for dr in sl.get('drawables', []) + sl.get('masterDrawables', []): walk(dr)
if n: print('slide', i, 'reflections:', n)
" | head{
"file_path": "/private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/vd-logos/composites/composite-page-1.png"
}The logos deck confirms both logos now correctly mirror at the contact line, matching Apple's export — the flip direction is fixed. I'll gate and ship this now.
cd /Users/phebert/pnk/viewer && npm test 2>&1 | tail -2 && git add viewer/src/drawables.ts && git commit -m "drawables: reflection mask direction re-flipped — gradient-bottom is the contact line G2's pentagon reflection was painting its ghost ~a shape-height away (peeking out from under the black diamond, reading as a green shadow on the wrong drawable) with NOTHING at the contact line — exactly inverse of Apple. Root cause: -webkit-box-reflect flips the mask gradient WITH the mirrored copy, so gradient-TOP lands on the FAR edge; the previous commit (43879e8) assumed top = contact and inverted a correct ramp. Settled empirically this time: scratchpad/reflect-probe.html renders both ramp directions plus a filter+reflect combo on Chromium 151 — transparent- to-opaque puts the strength at contact; drop-shadow coexists fine. Kept 43879e8's measured Apple geometry (strongest ~opacity at contact, dead ~55% out), now correctly oriented: transparent through the far 45%, ramping to opaque at the bottom. Verified by eye vs Apple PDF on BOTH fixtures: G2 pentagon (reflection at contact, diamond over it, drop shadow intact) and the 3c844ac1 logos deck 43879e8 cited (IIT/Tarides logos mirror at contact, fade down). 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
Exit code 128 6 passed (1.4s) warning: could not open directory 'viewer/viewer/src/': No such file or directory fatal: pathspec 'viewer/src/drawables.ts' did not match any files
git add viewer/src/drawables.ts && git commit -m "drawables: reflection mask direction re-flipped — gradient-bottom is the contact line G2's pentagon reflection was painting its ghost ~a shape-height away (peeking out from under the black diamond, reading as a green shadow on the wrong drawable) with NOTHING at the contact line — exactly inverse of Apple. Root cause: -webkit-box-reflect flips the mask gradient WITH the mirrored copy, so gradient-TOP lands on the FAR edge; the previous commit (43879e8) assumed top = contact and inverted a correct ramp. Settled empirically this time: scratchpad/reflect-probe.html renders both ramp directions plus a filter+reflect combo on Chromium 151 — transparent- to-opaque puts the strength at contact; drop-shadow coexists fine. Kept 43879e8's measured Apple geometry (strongest ~opacity at contact, dead ~55% out), now correctly oriented: transparent through the far 45%, ramping to opaque at the bottom. Verified by eye vs Apple PDF on BOTH fixtures: G2 pentagon (reflection at contact, diamond over it, drop shadow intact) and the 3c844ac1 logos deck 43879e8 cited (IIT/Tarides logos mirror at contact, fade down). 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
1 file changed, 9 insertions(+), 6 deletions(-) 5daf1d3..01021ad main -> main
Fixed and pushed as 01021ad. Good catch — and your instinct about what was happening was exactly right, just with the causality inverted: the green wasn't the diamond's shadow, it was the pentagon's reflection being painted a full shape-height too low, so only its far end peeked out from under the diamond while the contact line got nothing.
The story: the converter was blameless (pentagon correctly owns both the shadow and reflection in the JSON). The viewer bug was in the -webkit-box-reflect mask — Chromium flips the gradient mask together with the mirrored copy, so the gradient's top edge lands on the far edge of the reflection, not the contact line. The Drawables specialist assumed the opposite yesterday (commit 43879e8) and inverted what had been a correct ramp. Rather than guess again, I wrote a three-box probe page and screenshotted it in Chromium 151 to pin down the semantics, then restored the transparent→opaque direction while keeping the specialist's measured Apple geometry (full strength at contact, dead ~55% out).
Verified by eye on both sides of the dispute: G2's pentagon now shows the green wash right at its base with the diamond painting over it (drop shadow intact), and the 3c844ac1 logos deck — the very fixture the specialist cited — now matches Apple's export too, with IIT/Tarides mirroring at contact and fading down. Gate 6/6. Hard-refresh :8123 to see it.
I want to work on some subtle issues with: 0d5851c0105958b602c58646b98ffaee1b453def8478f9b872aca37319d2ed5f.key
- all keynote thumbnails on the left seem to be a little truncated (their right sides and bottoms cut off?)
- on the title slide 1, text alignment of How hot is your network? is way off
- slide 2: arrow bullets indentation are missing
- slide 9: the two text areas are equally centered in Keynote but not in ours
- slide 10: presenter notes have excessive leading
- slide 19: aside from the existing known indentation/bullet issues, this is a good demonstration of some subtle leading/layout differences we have (our line spacing seems a little loose)
- slide 29: centered quote is wrong width/position
Also, I would prefer the keynote viewer UI to show all of the slides in one continuous view (like a Pages file) so a presentation is easier to scroll through. figure out what to do with the presentation notes to make them space-efficient please when they are present and out of the way when not.
Solid list — several of these look like shared root causes (the notes leading smells like the same UA-margin leak I fixed for shapes, and the loose line spacing is probably line-height: normal vs Apple's tighter default). I'll run ground truth on the deck first, then work through the fidelity items, then rebuild the Keynote UI as a continuous scroll.
ls -la ~/Development/pnk-fixtures/crawl/0d5851c0105958b602c58646b98ffaee1b453def8478f9b872aca37319d2ed5f.key && uv run --with pillow --with pyobjc-framework-Quartz --with pymupdf python3 scripts/visual_diff.py --app keynote --fixture ~/Development/pnk-fixtures/crawl/0d5851c0105958b602c58646b98ffaee1b453def8478f9b872aca37319d2ed5f.key --out /private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/vd-0d58 --base-url http://127.0.0.1:8123 2>&1 | tail -3
-rw-rw-r--@ 1 phebert staff 3603213 Aug 28 18:09 /Users/phebert/Development/pnk-fixtures/crawl/0d5851c0105958b602c58646b98ffaee1b453def8478f9b872aca37319d2ed5f.key [visual_diff] wrote 0 region crops [visual_diff] artifacts: /private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/vd-0d58 [visual_diff] summary: /private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/vd-0d58/summary.md
{
"file_path": "/private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/vd-0d58/composites/composite-page-1.png"
}{
"file_path": "/private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/vd-0d58/composites/composite-page-2.png"
}{
"file_path": "/private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/vd-0d58/composites/composite-page-9.png"
}{
"file_path": "/private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/vd-0d58/composites/composite-page-10.png"
}{
"file_path": "/private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/vd-0d58/composites/composite-page-19.png"
}{
"file_path": "/private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/vd-0d58/composites/composite-page-29.png"
}Slide 29 confirmed too — the quote box renders ~90px wide and wraps 4 lines. Let me pull the JSON facts for slides 1, 2, 9, 29 and the notes:
./target/release/pnk2json --pretty ~/Development/pnk-fixtures/crawl/0d5851c0105958b602c58646b98ffaee1b453def8478f9b872aca37319d2ed5f.key > /private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/0d58.json 2>&1; python3 -c "
import json
d = json.load(open('/private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/0d58.json'))
doc = d.get('document', d)
print('slideSize:', doc.get('slideSize') or doc.get('size'))
sl = doc['slides'][0]
def dump(dr, depth=0):
c = dr.get('common', {})
txt = ''
if dr.get('text'):
for p in dr['text'].get('paragraphs', [])[:2]:
if isinstance(p, dict):
txt += '|'.join((it if isinstance(it,str) else it.get('text',''))[:30] for it in p.get('items',[]) if isinstance(it,(str,dict)) and (isinstance(it,str) or 'text' in it))[:45]
print(' '*depth, dr.get('type'), c.get('position'), c.get('size'), 'fit=', dr.get('textFit'), repr(txt[:45]))
for ch in dr.get('children', []): dump(ch, depth+2)
print('--- slide 1 drawables:')
for dr in sl.get('drawables', []): dump(dr)
print('--- slide 1 masterDrawables:')
for dr in sl.get('masterDrawables', []): dump(dr)
"slideSize: {'width': 1024.0, 'height': 768.0}
--- slide 1 drawables:
image {'x': 0.0, 'y': 53.29889678955078} {'width': 1024.0, 'height': 471.40216064453125} fit= None ''
image {'x': 0.0, 'y': 692.1768188476562} {'width': 1024.0, 'height': 75.64632415771484} fit= None ''
image {'x': -1.1203429698944092, 'y': -8.733123779296875} {'width': 1026.24072265625, 'height': 861.4662475585938} fit= None ''
image {'x': 181.5, 'y': 619.0} {'width': 643.0, 'height': 36.0} fit= None ''
shape {'x': 512.0, 'y': 638.0} {'width': 500.7637939453125, 'height': 36.0} fit= None 'How hot is your network?'
textbox {'x': 511.5, 'y': 728.5} {'width': 0.0, 'height': 0.0} fit= None ''
--- slide 1 masterDrawables:Slide 1's title uses center-anchored geometry (x=512, y=638) matching the blue band, consistent with this being an early Keynote format 1.5.0 deck. I'll now check slides 9 and 29 to see if the same pattern applies.
python3 -c "
import json
d = json.load(open('/private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/0d58.json'))
doc = d.get('document', d)
for idx in (8, 28):
sl = doc['slides'][idx]
print(f'--- slide {idx+1}:')
def dump(dr, depth=0):
c = dr.get('common', {})
txt = ''
if dr.get('text'):
for p in dr['text'].get('paragraphs', [])[:1]:
if isinstance(p, dict):
txt += '|'.join((it if isinstance(it,str) else it.get('text',''))[:35] for it in p.get('items',[]) if isinstance(it,(str,dict)) and (isinstance(it,str) or 'text' in it))[:40]
print(' '*depth, dr.get('type'), c.get('position'), c.get('size'), 'fit=', dr.get('textFit'), repr(txt))
for ch in dr.get('children', []): dump(ch, depth+2)
for dr in sl.get('drawables', []): dump(dr)
"--- slide 9:
textbox {'x': 100.0, 'y': 129.0} {'width': 824.0, 'height': 260.0} fit= shrink '8,906,901'
textbox {'x': 100.0, 'y': 396.0} {'width': 824.0, 'height': 89.0} fit= shrink 'Unique IP addresses listed by XBL\u2028o'
textbox {'x': 511.5, 'y': 728.5} {'width': 0.0, 'height': 0.0} fit= None ''
--- slide 29:
textbox {'x': 60.0, 'y': 158.0} {'width': 873.9999389648438, 'height': 75.64632415771484} fit= None 'ISPs'
textbox {'x': 59.99997329711914, 'y': 246.9429473876953} {'width': 874.0, 'height': 427.0} fit= None 'But no single benefit has the same '
shape {'x': 271.0579833984375, 'y': 397.3497009277344} {'width': 0.0, 'height': 0.0} fit= None ''
textbox {'x': 511.5, 'y': 728.5} {'width': 0.0, 'height': 0.0} fit= None ''Found the cause: slide 29's quote shape lacks Apple's natural width so it's wrapping too narrow, and slide 9's boxes are symmetric but likely misaligned/shrunk vertically. Next, I'll look into slide 2's missing arrow bullets.
python3 -c "
import json
d = json.load(open('/private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/0d58.json'))
doc = d.get('document', d)
sl = doc['slides'][1]
for dr in sl.get('drawables', []):
if dr.get('text'):
for p in dr['text'].get('paragraphs', []):
if isinstance(p, dict):
print('pStyle=', p.get('pStyle'), [(it if isinstance(it,str) else it.get('text',''))[:40] for it in p.get('items',[]) if isinstance(it,(str,dict))])
print('--- para styles with list:')
for i, ps in enumerate(doc.get('styles',{}).get('para',[])):
if 'list' in ps or 'outlineLevel' in ps: print(i, json.dumps(ps)[:220])
" | head -30pStyle= 5 ['Introduction']
pStyle= 6 ['“If you can’t measure it, you can’t mana']
pStyle= 6 ['The importance of monitoring how others ']
pStyle= 6 ['And comparing yourself to your peers']
pStyle= 22 ['Every network could do better!']
pStyle= 0 ['']
--- para styles with list:
1 {"horizontalAlignment": "center", "defaultTabStopPt": 46.0, "list": {"level": 1, "markerKind": "none", "markerIndentPt": 18.0}, "keepLinesTogether": true}
2 {"horizontalAlignment": "center", "defaultTabStopPt": 46.0, "list": {"level": 2, "markerKind": "none", "markerIndentPt": 36.0}, "keepLinesTogether": true}
3 {"horizontalAlignment": "center", "defaultTabStopPt": 46.0, "list": {"level": 3, "markerKind": "none", "markerIndentPt": 54.0}, "keepLinesTogether": true}
4 {"horizontalAlignment": "center", "defaultTabStopPt": 46.0, "list": {"level": 4, "markerKind": "none", "markerIndentPt": 72.0}, "keepLinesTogether": true}
6 {"horizontalAlignment": "left", "spaceBeforePt": 18.0, "lineSpacingExactPt": 40.0, "defaultTabStopPt": 46.0, "list": {"level": 0, "markerKind": "image", "markerImage": {"dataId": "5501", "fileName": "rightArrow-5501.png"
7 {"horizontalAlignment": "left", "lineSpacingExactPt": 40.0, "defaultTabStopPt": 46.0, "list": {"level": 1, "markerKind": "image", "markerImage": {"dataId": "5501", "fileName": "rightArrow-5501.png", "preferredFileName":
8 {"horizontalAlignment": "left", "lineSpacingExactPt": 40.0, "defaultTabStopPt": 46.0, "list": {"level": 2, "markerKind": "image", "markerImage": {"dataId": "5501", "fileName": "rightArrow-5501.png", "preferredFileName":
9 {"horizontalAlignment": "left", "lineSpacingExactPt": 40.0, "defaultTabStopPt": 46.0, "list": {"level": 3, "markerKind": "image", "markerImage": {"dataId": "5501", "fileName": "rightArrow-5501.png", "preferredFileName":
10 {"horizontalAlignment": "left", "lineSpacingExactPt": 40.0, "defaultTabStopPt": 46.0, "list": {"level": 4, "markerKind": "image", "markerImage": {"dataId": "5501", "fileName": "rightArrow-5501.png", "preferredFileName":
11 {"horizontalAlignment": "left", "spaceBeforePt": 32.0, "defaultTabStopPt": 46.0, "list": {"level": 0, "markerKind": "string", "markerText": "\u2022", "markerScale": 0.75}, "keepLinesTogether": true}
12 {"horizontalAlignment": "left", "spaceBeforePt": 32.0, "defaultTabStopPt": 46.0, "list": {"level": 1, "markerKind": "string", "markerText": "\u2022", "markerIndentPt": 27.0, "markerScale": 0.75}, "keepLinesTogether": tru
13 {"horizontalAlignment": "left", "spaceBeforePt": 32.0, "defaultTabStopPt": 46.0, "list": {"level": 2, "markerKind": "string", "markerText": "\u2022", "markerIndentPt": 54.0, "markerScale": 0.75}, "keepLinesTogether": tru
14 {"horizontalAlignment": "left", "spaceBeforePt": 32.0, "defaultTabStopPt": 46.0, "list": {"level": 3, "markerKind": "string", "markerText": "\u2022", "markerIndentPt": 81.0, "markerScale": 0.75}, "keepLinesTogether": tru
15 {"horizontalAlignment": "left", "spaceBeforePt": 32.0, "defaultTabStopPt": 46.0, "list": {"level": 4, "markerKind": "string", "markerText": "\u2022", "markerIndentPt": 108.0, "markerScale": 0.75}, "keepLinesTogether": tr
16 {"horizontalAlignment": "left", "lineSpacingExactPt": 46.0, "defaultTabStopPt": 46.0, "list": {"level": 0, "markerKind": "image", "markerImage": {"dataId": "2089", "fileName": "rightArrow-2089.png", "preferredFileName":
17 {"horizontalAlignment": "left", "lineSpacingExactPt": 46.0, "defaultTabStopPt": 46.0, "list": {"level": 1, "markerKind": "image", "markerImage": {"dataId": "2089", "fileName": "rightArrow-2089.png", "preferredFileName":
18 {"horizontalAlignment": "left", "lineSpacingExactPt": 46.0, "defaultTabStopPt": 46.0, "list": {"level": 2, "markerKind": "image", "markerImage": {"dataId": "2089", "fileName": "rightArrow-2089.png", "preferredFileName":
19 {"horizontalAlignment": "left", "lineSpacingExactPt": 46.0, "defaultTabStopPt": 46.0, "list": {"level": 3, "markerKind": "image", "markerImage": {"dataId": "2089", "fileName": "rightArrow-2089.png", "preferredFileName":
20 {"horizontalAlignment": "left", "lineSpacingExactPt": 46.0, "defaultTabStopPt": 46.0, "list": {"level": 4, "markerKind": "image", "markerImage": {"dataId": "2089", "fileName": "rightArrow-2089.png", "preferredFileName":
22 {"horizontalAlignment": "left", "spaceBeforePt": 18.0, "lineSpacingExactPt": 40.0, "defaultTabStopPt": 46.0, "list": {"level": 1, "markerKind": "image", "markerImage": {"dataId": "5501", "fileName": "rightArrow-5501.png"
25 {"horizontalAlignment": "left", "spaceBeforePt": 18.0, "lineSpacingExactPt": 40.0, "defaultTabStopPt": 46.0, "list": {"level": 0, "markerKind": "image", "markerImage": {"dataId": "5501", "fileName": "rightArrow-5501.png"Slide 2/19's arrows use an image-based marker, which the viewer skips since it only renders string/number markers, explaining the missing glyph and indent. Moving on to check slide 9/29's details.
python3 -c "
import json
d = json.load(open('/private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/0d58.json'))
doc = d.get('document', d)
import re
# slide 9 full textboxes
for idx in (8, 28):
print(f'=== slide {idx+1}')
for dr in doc['slides'][idx].get('drawables', []):
print(json.dumps({k:v for k,v in dr.items() if k not in ('text','common')}, default=str)[:200])
print(' common keys:', {k: v for k,v in (dr.get('common') or {}).items() if k not in ('position','size')})
if dr.get('text'):
for p in dr['text'].get('paragraphs', []):
if isinstance(p, dict):
print(' p pStyle=', p.get('pStyle'), [(it if isinstance(it,str) else (it.get('text') or it.get('type','?')))[:38] for it in p.get('items',[])])
"=== slide 9
{"type": "textbox", "verticalAlignment": "bottom", "textFit": "shrink"}
common keys: {'angleDeg': 0.0, 'locked': False, 'textWrap': {'kind': 'right', 'marginPt': 12.0}, 'style': {}, 'opacity': 1.0, 'placeholder': {'role': 'title'}}
p pStyle= 0 ['8,906,901']
{"type": "textbox", "verticalAlignment": "top", "textFit": "shrink"}
common keys: {'angleDeg': 0.0, 'locked': False, 'textWrap': {'kind': 'right', 'marginPt': 12.0}, 'style': {}, 'opacity': 1.0, 'placeholder': {'role': 'body'}}
p pStyle= 0 ['Unique IP addresses listed by XBL\u2028on 1']
p pStyle= 0 []
{"type": "textbox", "verticalAlignment": "top"}
common keys: {'angleDeg': 0.0, 'locked': False, 'textWrap': {'kind': 'right', 'marginPt': 12.0}, 'style': {}, 'opacity': 1.0, 'placeholder': {'role': 'slide-number'}}
p pStyle= 0 ['field']
=== slide 29
{"type": "textbox", "verticalAlignment": "middle"}
common keys: {'angleDeg': 0.0, 'locked': False, 'textWrap': {'kind': 'right', 'marginPt': 12.0}, 'style': {}, 'opacity': 1.0, 'placeholder': {'role': 'title'}}
p pStyle= 5 ['ISPs']
{"type": "textbox", "verticalAlignment": "top"}
common keys: {'angleDeg': 0.0, 'locked': False, 'textWrap': {'kind': 'right', 'marginPt': 12.0}, 'style': {}, 'opacity': 1.0, 'placeholder': {'role': 'body'}}
p pStyle= 6 ['But no single benefit has the same pun']
{"type": "shape", "geometry": {"naturalSize": {"width": 0.0, "height": 0.0}, "path": {"elements": [{"type": "move", "points": [0.0, 0.0]}, {"type": "line", "points": [100.0, 0.0]}, {"type": "line", "p
common keys: {'angleDeg': 0.0, 'locked': False, 'textWrap': {'kind': 'right', 'marginPt': 12.0}, 'style': {}, 'opacity': 1.0}
p pStyle= 23 []
p pStyle= 23 ['“Adds 3.4% to GDP”']
p pStyle= 23 []
{"type": "textbox", "verticalAlignment": "top"}
common keys: {'angleDeg': 0.0, 'locked': False, 'textWrap': {'kind': 'right', 'marginPt': 12.0}, 'style': {}, 'opacity': 1.0, 'placeholder': {'role': 'slide-number'}}
p pStyle= 0 ['field']Found the causes: slide 9's number box uses bottom alignment with shrink-to-fit that our pass likely ignores, and slide 29's quote is a 0×0 shape positioned via paragraph alignment. Now checking the viewer's shrink/grow logic, 0×0 shape handling, and keynote.ts for thumbnails/notes/layout.
python3 -c "
import json
d = json.load(open('/private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/0d58.json'))
doc = d.get('document', d)
print('pStyle 23:', json.dumps(doc['styles']['para'][23]))
print('pStyle 0:', json.dumps(doc['styles']['para'][0]))
print('pStyle 5:', json.dumps(doc['styles']['para'][5]))
print('pStyle 6 full:', json.dumps(doc['styles']['para'][6]))
sl = doc['slides'][28]
q = sl['drawables'][2]
for p in q['text']['paragraphs']:
for it in p.get('items', []):
if isinstance(it, dict) and 'text' in it: print('quote run cStyle:', it.get('cStyle'), json.dumps(doc['styles']['char'][it['cStyle']]) if it.get('cStyle') is not None else None)
"; grep -n "shrink\|grow" /Users/phebert/pnk/viewer/src/drawables.ts | head -20pStyle 23: {"horizontalAlignment": "left", "spaceBeforePt": 18.0, "lineSpacingExactPt": 40.0, "defaultTabStopPt": 46.0, "keepLinesTogether": true}
pStyle 0: {"horizontalAlignment": "center", "defaultTabStopPt": 46.0, "keepLinesTogether": true}
pStyle 5: {"horizontalAlignment": "left", "defaultTabStopPt": 46.0, "keepLinesTogether": true}
pStyle 6 full: {"horizontalAlignment": "left", "spaceBeforePt": 18.0, "lineSpacingExactPt": 40.0, "defaultTabStopPt": 46.0, "list": {"level": 0, "markerKind": "image", "markerImage": {"dataId": "5501", "fileName": "rightArrow-5501.png", "preferredFileName": "rightArrow.png"}, "markerFontName": "STIXGeneral-Regular", "markerScale": 0.3499999940395355}, "keepLinesTogether": true}
quote run cStyle: 34 {"fontName": "AppleSDGothicNeo-Regular", "fontSizePt": 51.0, "fontColor": "#002452", "outline": {"widthPt": 0.0}}
127: // scalar = pointiness (0..1): inner radius shrinks as it grows
506: // both fixes active would double-shrink — keep exactly one.)
514:// Text fit (model textFit: "grow" | "shrink"; absent = fixed box, clipped)
518: * "grow": a plain Keynote/Pages text box auto-sizes its height to its
521: * let the box grow downward instead of clipping the last line.
522: * "shrink": tag the box for the post-attach measurement pass (applyTextFit).
527: fit: "grow" | "shrink" | undefined,
530: if (fit === "grow") {
542: } else if (fit === "shrink") {
543: div.dataset.textFit = "shrink";
549: // shrink bounded (>=0.6) to absorb that drift — never past it, so truly
556: * Post-attach pass for boxes tagged "shrink" (Keynote's "shrink text on
582: // "shrink" = Keynote's shrink-on-overflow (scale as far as needed);
583: // "tolerance" = fixed box, bounded shrink for font-metric drift only.
584: const minScale = box.dataset.textFit === "shrink" ? 0.35 : 0.6;
835: // Shapes keep their geometry: a shape never grows for its text, so
836: // "grow" degrades to the fixed-box tolerance mode.
837: applyTextFitMode(div, layer, d.textFit === "grow" ? undefined : d.textFit, d.verticalAlignment);{
"file_path": "/Users/phebert/pnk/viewer/src/drawables.ts",
"offset": 514,
"limit": 115
}514 // Text fit (model textFit: "grow" | "shrink"; absent = fixed box, clipped)
515 // ---------------------------------------------------------------------------
516
517 /**
518 * "grow": a plain Keynote/Pages text box auto-sizes its height to its
519 * content; the stored height is Apple's layout under Apple's font metrics,
520 * so browsers (taller line boxes, fallback fonts) treat it as a MINIMUM and
521 * let the box grow downward instead of clipping the last line.
522 * "shrink": tag the box for the post-attach measurement pass (applyTextFit).
523 */
524 function applyTextFitMode(
525 div: HTMLElement,
526 layer: HTMLElement,
527 fit: "grow" | "shrink" | undefined,
528 verticalAlignment?: string,
529 ): void {
530 if (fit === "grow") {
531 // keep the stored height as a minimum so vertical alignment still works
532 // when the content is shorter than the box
533 const storedH = div.style.height;
534 div.style.height = "auto";
535 if (storedH && storedH !== "auto") div.style.minHeight = storedH;
536 div.style.display = "flex";
537 div.style.flexDirection = "column";
538 div.style.justifyContent = verticalAlignStyle({ verticalAlignment });
539 layer.style.position = "relative";
540 layer.style.overflow = "visible";
541 layer.style.height = "auto";
542 } else if (fit === "shrink") {
543 div.dataset.textFit = "shrink";
544 } else {
545 // Fixed box (no flag). Apple laid the stored frame out with ITS font
546 // metrics; browser fallback faces + line boxes run taller, so text that
547 // fits exactly in Keynote clips mid-line here (0f9df553 byline: 132px of
548 // content in a 102pt box). Tolerance mode: the measurement pass may
549 // shrink bounded (>=0.6) to absorb that drift — never past it, so truly
550 // authored overflow still clips like Apple's fixed frames do.
551 div.dataset.textFit = "tolerance";
552 }
553 }
554
555 /**
556 * Post-attach pass for boxes tagged "shrink" (Keynote's "shrink text on
557 * overflow"): when the laid-out text is taller than its box, scale it down.
558 * `scale(s)` with an inverse width (100/s %) reproduces a font-size
559 * reduction — same wrap width in text space — and the transform origin
560 * follows the box's vertical alignment. MUST run with `root` attached and
561 * displayed (measurement). Idempotent: safe to re-run.
562 */
563 export function applyTextFit(root: HTMLElement): void {
564 for (const box of root.querySelectorAll<HTMLElement>("[data-text-fit]")) {
565 const layer = box.querySelector<HTMLElement>(":scope > .drawable-text");
566 const inner = layer?.querySelector<HTMLElement>(":scope > .drawable-text-inner");
567 if (!layer || !inner) continue;
568 if (box.dataset.textFit === "edge-clamp") {
569 // Zero-size auto box: scale down (bounded) when the laid-out label
570 // spills past the canvas' right edge — Apple's metrics fit it inside.
571 const canvas = box.closest<HTMLElement>(".canvas-inner");
572 if (!canvas) continue;
573 inner.style.transform = "";
574 const spill = box.offsetLeft + box.offsetWidth - canvas.clientWidth;
575 if (spill > 1 && box.offsetWidth > 0) {
576 const s = Math.max((box.offsetWidth - spill) / box.offsetWidth, 0.7);
577 inner.style.transform = `scale(${s.toFixed(4)})`;
578 inner.style.transformOrigin = "left top";
579 }
580 continue;
581 }
582 // "shrink" = Keynote's shrink-on-overflow (scale as far as needed);
583 // "tolerance" = fixed box, bounded shrink for font-metric drift only.
584 const minScale = box.dataset.textFit === "shrink" ? 0.35 : 0.6;
585 inner.style.transform = "";
586 inner.style.width = "";
587 const boxH = layer.clientHeight;
588 if (boxH <= 0) continue;
589 const align = layer.style.alignItems;
590 const origin = align === "flex-end" ? "left bottom" : align === "center" ? "left center" : "left top";
591 let s = 1;
592 // Rewrapping at the compensated width changes the height, so iterate;
593 // s only ever decreases, which converges without oscillation.
594 for (let i = 0; i < 3; i++) {
595 const contentH = inner.offsetHeight;
596 if (contentH * s <= boxH + 0.5) break;
597 s = Math.max(Math.min(s, boxH / contentH), minScale);
598 inner.style.width = `${(100 / s).toFixed(3)}%`;
599 inner.style.transform = `scale(${s.toFixed(4)})`;
600 inner.style.transformOrigin = origin;
601 }
602 }
603 }
604
605 // ---------------------------------------------------------------------------
606 // Chart (minimal: inline numeric series -> SVG bars, else a summary card)
607 // ---------------------------------------------------------------------------
608
609 function chartSummary(d: Extract<Drawable, { type: "chart" }>): HTMLElement {
610 const card = el("div", "unknown-drawable");
611 const c = d.chart;
612 const seriesDesc = c.series.map((s) => s.name ?? "series").join(", ");
613 card.textContent = `${c.type}${c.threeD ? " (3D)" : ""} chart — ${c.series.length} series, ${c.categories.length} categories${seriesDesc ? `: ${seriesDesc}` : ""}`;
614 return card;
615 }
616
617 function chartSvg(chart: ChartModel, w: number, h: number): SVGSVGElement | null {
618 const numeric = chart.series.every((s) => s.values.every((v) => v === null || typeof v === "number"));
619 if (!numeric || chart.series.length === 0 || chart.categories.length === 0) return null;
620 const NS = "http://www.w3.org/2000/svg";
621 const svg = document.createElementNS(NS, "svg") as SVGSVGElement;
622 svg.setAttribute("viewBox", `0 0 ${w} ${h}`);
623 const max = Math.max(...chart.series.flatMap((s) => s.values.map((v) => (typeof v === "number" ? v : 0))), 1e-9);
624 const colors = chart.seriesColors ?? ["#4a90d9", "#e0762e", "#7bb662", "#b0578d", "#5b6abf"];
625 const groupW = w / chart.categories.length;
626 const lineKinds = ["line", "area", "stacked-area", "scatter"];
627 if (lineKinds.includes(chart.type)) {
628 // Line family: one polyline per series through the category centers,I'll check verticalAlignStyle and use Playwright to inspect live DOM measurements, since the shrink pass relies on layer.style.alignItems and bottom-align isn't landing as expected.
grep -n "function verticalAlignStyle" -A 8 /Users/phebert/pnk/viewer/src/drawables.ts; grep -n "setInputFiles\|dispatchEvent\|DataTransfer\|drop" /private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/vd-0d58/render.js | head
493:function verticalAlignStyle(d: { verticalAlignment?: string }): string {
494- return d.verticalAlignment === "middle" ? "center" : d.verticalAlignment === "bottom" ? "flex-end" : "flex-start";
495-}
496-
497-/** Text content of a textbox/shape, filling the positioned container. */
498-function textLayer(d: Drawable & { text?: unknown; common?: DrawableCommon }, doc: HydratedDoc, ctx: ViewerCtx): HTMLElement | null {
499- if (!("text" in d) || !d.text || (d.text as { paragraphs?: unknown[] }).paragraphs === undefined) return null;
500- const layer = el("div", "drawable-text");
501- layer.style.alignItems = verticalAlignStyle(d as { verticalAlignment?: string });
13: await page.setInputFiles("#file-input", fixture);import { chromium } from "playwright";
const fixture = "/Users/phebert/Development/pnk-fixtures/crawl/0d5851c0105958b602c58646b98ffaee1b453def8478f9b872aca37319d2ed5f.key";
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", fixture);
await page.waitForSelector(".canvas-inner", { timeout: 30000 });
await page.waitForTimeout(1500);
const info = await page.evaluate((n) => {
// find slide canvas N (however the viewer structures slides)
const canvases = [...document.querySelectorAll(".canvas-inner")];
const out = { canvasCount: canvases.length };
const canvas = canvases[n - 1];
if (!canvas) return out;
const cr = canvas.getBoundingClientRect();
out.canvas = { w: cr.width, h: cr.height };
out.drawables = [...canvas.querySelectorAll(":scope > .canvas-drawable")].map((d) => {
const r = d.getBoundingClientRect();
const layer = d.querySelector(":scope > .drawable-text");
const inner = layer?.querySelector(".drawable-text-inner");
const ir = inner?.getBoundingClientRect();
return {
cls: d.className,
box: { x: +(r.x - cr.x).toFixed(1), y: +(r.y - cr.y).toFixed(1), w: +r.width.toFixed(1), h: +r.height.toFixed(1) },
text: (d.textContent || "").trim().slice(0, 30),
alignItems: layer?.style.alignItems,
layerDisplay: layer ? getComputedStyle(layer).display : undefined,
innerBox: ir ? { y: +(ir.y - cr.y).toFixed(1), h: +ir.height.toFixed(1) } : undefined,
transform: inner?.style.transform,
dataFit: d.dataset.textFit,
};
});
return out;
}, 9);
console.log(JSON.stringify(info, null, 1));
await b.close();
File created successfully at: /private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/probe-0d58.mjs (file state is current in your context — no need to Read it back)
cd /Users/phebert/pnk/viewer && node /private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/probe-0d58.mjs
Exit code 1
node:internal/modules/package_json_reader:314
throw new ERR_MODULE_NOT_FOUND(packageName, fileURLToPath(base), null);
^
Error [ERR_MODULE_NOT_FOUND]: Cannot find package 'playwright' imported from /private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/probe-0d58.mjs
at Object.getPackageJSONURL (node:internal/modules/package_json_reader:314:9)
at packageResolve (node:internal/modules/esm/resolve:768:81)
at moduleResolve (node:internal/modules/esm/resolve:855:18)
at defaultResolve (node:internal/modules/esm/resolve:985:11)
at #cachedDefaultResolve (node:internal/modules/esm/loader:747:20)
at ModuleLoader.resolve (node:internal/modules/esm/loader:724:38)
at ModuleLoader.getModuleJobForImport (node:internal/modules/esm/loader:320:38)
at ModuleJob._link (node:internal/modules/esm/module_job:182:49) {
code: 'ERR_MODULE_NOT_FOUND'
}
Node.js v22.23.2cp /private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/probe-0d58.mjs /Users/phebert/pnk/viewer/probe-tmp.mjs && cd /Users/phebert/pnk/viewer && node probe-tmp.mjs
{
"canvasCount": 33,
"canvas": {
"w": 168,
"h": 126
},
"drawables": [
{
"cls": "canvas-drawable",
"box": {
"x": 0,
"y": 0.2,
"w": 168,
"h": 23.5
},
"text": ""
},
{
"cls": "canvas-drawable",
"box": {
"x": 0,
"y": 113.6,
"w": 168,
"h": 12.4
},
"text": ""
},
{
"cls": "canvas-drawable",
"box": {
"x": 16.4,
"y": 21.2,
"w": 135.2,
"h": 42.7
},
"text": "8,906,901",
"alignItems": "flex-end",
"layerDisplay": "flex",
"innerBox": {
"y": 47.3,
"h": 16.6
},
"transform": "",
"dataFit": "shrink"
},
{
"cls": "canvas-drawable",
"box": {
"x": 16.4,
"y": 65,
"w": 135.2,
"h": 14.6
},
"text": "Unique IP addresses listed by ",
"alignItems": "flex-start",
"layerDisplay": "flex",
"innerBox": {
"y": 65,
"h": 14.6
},
"transform": "scale(0.9082)",
"dataFit": "shrink"
}
]
}I found that probe hit the slide 9 thumbnail instead of the main canvas, and confirmed there are 33 mini canvases (relevant to the truncation issue too). Now I'll navigate to slide 9 in the main view and measure it at full size.
grep -n "slide-list-item\|thumb\|click\|current" /private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/vd-0d58/render.js | head; grep -n "slide-list-item\|slide-stage\|main.*canvas\|renderSlide" /Users/phebert/pnk/viewer/src/keynote.ts | head -20
18: // Deck mode (.key): click through thumbnails and screenshot the STAGE only,
21: const items = page.locator(".slide-list-item");
27: await items.nth(i).click();
299: stage.className = "slide-stage";
349: stageSlot.className = "slide-stage-slot";
366: item.className = "slide-list-item";cat > probe-tmp.mjs <<'EOF'
import { chromium } from "playwright";
const fixture = "/Users/phebert/Development/pnk-fixtures/crawl/0d5851c0105958b602c58646b98ffaee1b453def8478f9b872aca37319d2ed5f.key";
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", fixture);
await page.waitForSelector(".slide-list-item", { timeout: 30000 });
await page.locator(".slide-list-item").nth(8).click();
await page.waitForTimeout(800);
const info = await page.evaluate(() => {
const stage = document.querySelector(".slide-stage");
const canvas = stage.querySelector(".canvas-inner");
const cr = canvas.getBoundingClientRect();
const scale = cr.width / 1024;
return {
canvas: { w: cr.width, h: cr.height, scale },
drawables: [...canvas.querySelectorAll(":scope > .canvas-drawable")].map((d) => {
const r = d.getBoundingClientRect();
const layer = d.querySelector(":scope > .drawable-text");
const inner = layer?.querySelector(".drawable-text-inner");
const ir = inner?.getBoundingClientRect();
return {
text: (d.textContent || "").trim().slice(0, 25),
docBox: { x: +((r.x - cr.x) / scale).toFixed(0), y: +((r.y - cr.y) / scale).toFixed(0), w: +(r.width / scale).toFixed(0), h: +(r.height / scale).toFixed(0) },
innerDoc: ir ? { x: +((ir.x - cr.x) / scale).toFixed(0), y: +((ir.y - cr.y) / scale).toFixed(0), w: +(ir.width / scale).toFixed(0), h: +(ir.height / scale).toFixed(0) } : null,
alignItems: layer?.style.alignItems, transform: inner?.style.transform, width: inner?.style.width,
};
}),
};
});
console.log(JSON.stringify(info, null, 1));
await b.close();
EOF
node probe-tmp.mjs{
"canvas": {
"w": 860,
"h": 645,
"scale": 0.83984375
},
"drawables": [
{
"text": "",
"docBox": {
"x": 0,
"y": 1,
"w": 1024,
"h": 143
},
"innerDoc": null
},
{
"text": "",
"docBox": {
"x": 0,
"y": 692,
"w": 1024,
"h": 76
},
"innerDoc": null
},
{
"text": "8,906,901",
"docBox": {
"x": 100,
"y": 129,
"w": 824,
"h": 260
},
"innerDoc": {
"x": 100,
"y": 288,
"w": 824,
"h": 101
},
"alignItems": "flex-end",
"transform": "",
"width": ""
},
{
"text": "Unique IP addresses liste",
"docBox": {
"x": 100,
"y": 396,
"w": 824,
"h": 89
},
"innerDoc": {
"x": 100,
"y": 396,
"w": 733,
"h": 89
},
"alignItems": "flex-start",
"transform": "scale(0.89)",
"width": "112.36%"
}
]
}Found the slide 9 bug: the shrink pass's width compensation gets overridden because the flex container's default flex-shrink squashes it back, causing left-drift on centered text — a one-line fix. Now I'll dig into slide 1's raw archive to investigate its center-anchored geometry from the older 1.5.0 format.
cargo run --release -p iwadump -- ~/Development/pnk-fixtures/crawl/0d5851c0105958b602c58646b98ffaee1b453def8478f9b872aca37319d2ed5f.key 2>/dev/null | grep -i "how hot" | head -5
cargo run --release -p iwadump -- --help 2>/dev/null | head -20; ls crates/iwadump/src/
Exit code 1
Dump the structure of an iWork '13+ document (.pages/.numbers/.key)
Usage: iwadump [OPTIONS] <FILE>
Arguments:
<FILE> Document: flat .pages/.numbers/.key file or package directory
Options:
--list List container members only (no IWA decode)
--archive <ARCHIVE> Decode a single IWA stream (member name, or a unique suffix like `Document.iwa`)
--message <MESSAGE> Dump one payload as hex + best-effort field walk, by archive local id
--json Machine-readable JSON output
--limit <LIMIT> Limit messages listed per stream (default: all)
--legacy-ok List legacy (pre-'13) files as raw zip members instead of rejecting
-h, --help Print help
-V, --version Print version
ls: crates/iwadump/src/: No such file or directorycargo run --release -p iwadump -- ~/Development/pnk-fixtures/crawl/0d5851c0105958b602c58646b98ffaee1b453def8478f9b872aca37319d2ed5f.key 2>/dev/null | head -40
/Users/phebert/Development/pnk-fixtures/crawl/0d5851c0105958b602c58646b98ffaee1b453def8478f9b872aca37319d2ed5f.key — flat zip, 130 members, 52 iwa streams, app: keynote
Index/Document.iwa — 1 blocks, 7658 B compressed → 11980 B decoded, 73 archives
id=1 KN.DocumentArchive len=40 ok
id=2405 TSA.FunctionBrowserStateArchive len=17 ok
id=2406 KN.ShowArchive len=66 ok
id=2409 KN.ThemeArchive len=2417 ok
id=2962 KN.SlideNodeArchive len=89 ok
id=2970 KN.ClassicThemeRecordArchive len=103 ok
id=3193 KN.ClassicStylesheetRecordArchive len=708 ok
id=52343 TSCH.ChartStylePreset len=78 ok
id=2969 KN.SlideNodeArchive len=89 ok
id=2968 KN.SlideNodeArchive len=89 ok
id=2467 TST.TableStylePresetArchive len=7 ok
id=2728 TST.TableStyleNetworkArchive len=57 ok
id=2960 KN.SlideNodeArchive len=89 ok
id=2475 TSCH.ChartStylePreset len=158 ok
id=2957 TSCH.ChartStylePreset len=78 ok
id=2959 KN.SlideNodeArchive len=89 ok
id=2468 TST.TableStylePresetArchive len=7 ok
id=2544 TST.TableStyleNetworkArchive len=57 ok
id=2472 TST.TableStylePresetArchive len=7 ok
id=2731 TST.TableStyleNetworkArchive len=57 ok
id=2470 TST.TableStylePresetArchive len=7 ok
id=2729 TST.TableStyleNetworkArchive len=57 ok
id=2958 KN.SlideNodeArchive len=89 ok
id=2477 TSCH.ChartStylePreset len=153 ok
id=2473 TSCH.ChartStylePreset len=158 ok
id=2963 KN.SlideNodeArchive len=89 ok
id=2476 TSCH.ChartStylePreset len=153 ok
id=2964 KN.SlideNodeArchive len=89 ok
id=2961 KN.SlideNodeArchive len=89 ok
id=2471 TST.TableStylePresetArchive len=7 ok
id=2573 TST.TableStyleNetworkArchive len=57 ok
id=2965 KN.SlideNodeArchive len=89 ok
id=2967 KN.SlideNodeArchive len=88 ok
id=2478 TSCH.ChartStylePreset len=143 ok
id=2956 TST.TableStylePresetArchive len=7 ok
id=3345 TST.TableStyleNetworkArchive len=57 ok
id=2474 TSCH.ChartStylePreset len=153 ok
id=2469 TST.TableStylePresetArchive len=7 okcargo run --release -p iwadump -- --list ~/Development/pnk-fixtures/crawl/0d5851c0105958b602c58646b98ffaee1b453def8478f9b872aca37319d2ed5f.key 2>/dev/null | grep -i "slide\|\.iwa" | head -20
7658 Index/Document.iwa
1372 Index/MasterSlide-6.iwa
1365 Index/MasterSlide-5.iwa
1351 Index/MasterSlide-7.iwa
983 Index/Slide29.iwa
862 Index/Slide5.iwa
884 Index/Slide30.iwa
908 Index/Slide11.iwa
1357 Index/MasterSlide.iwa
824 Index/Slide21.iwa
1058 Index/Slide10.iwa
1329 Index/Slide6.iwa
962 Index/Slide2.iwa
1261 Index/Slide25.iwa
859 Index/Slide12.iwa
827 Index/Slide7/Slide2.iwa
915 Index/Slide19.iwa
1366 Index/MasterSlide-4.iwa
1353 Index/MasterSlide-2.iwa
1207 Index/Slide14.iwafor m in Slide.iwa Slide1.iwa Slide-1.iwa; do echo "== $m"; cargo run --release -p iwadump -- --archive "$m" ~/Development/pnk-fixtures/crawl/0d5851c0105958b602c58646b98ffaee1b453def8478f9b872aca37319d2ed5f.key 2>/dev/null | head -20; done
== Slide.iwa
/Users/phebert/Development/pnk-fixtures/crawl/0d5851c0105958b602c58646b98ffaee1b453def8478f9b872aca37319d2ed5f.key — flat zip, 130 members, 1 iwa streams, app: keynote
Index/MasterSlide.iwa — 1 blocks, 1357 B compressed → 2709 B decoded, 13 archives
id=2881 KN.SlideArchive len=221 ok
id=3434 KN.PlaceholderArchive len=188 ok
id=3638 TSWP.StorageArchive len=60 ok
id=3433 KN.PlaceholderArchive len=188 ok
id=3641 TSWP.StorageArchive len=62 ok
id=3431 KN.PlaceholderArchive len=188 ok
id=3639 TSWP.StorageArchive len=73 ok
id=3432 KN.PlaceholderArchive len=188 ok
id=3637 TSWP.StorageArchive len=188 ok
id=2882 TSD.ImageArchive len=115 ok
id=3640 TSD.MaskArchive len=170 ok
id=3435 TSD.GuideStorageArchive len=0 ok
id=3436 KN.ClassicStylesheetRecordArchive len=743 ok
== Slide1.iwa
/Users/phebert/Development/pnk-fixtures/crawl/0d5851c0105958b602c58646b98ffaee1b453def8478f9b872aca37319d2ed5f.key — flat zip, 130 members, 2 iwa streams, app: keynote
Index/Slide7/Slide1.iwa — 1 blocks, 1031 B compressed → 1535 B decoded, 11 archives
id=2924 KN.SlideArchive len=111 ok
id=3821 KN.NoteArchive len=5 ok
id=3962 TSWP.StorageArchive len=62 ok
id=3817 KN.PlaceholderArchive len=188 ok
id=3961 TSWP.StorageArchive len=75 ok
id=3818 KN.PlaceholderArchive len=188 ok
id=3960 TSWP.StorageArchive len=386 ok
id=3819 KN.PlaceholderArchive len=188 ok
id=3959 TSWP.StorageArchive len=76 ok
id=4021 TSWP.NumberAttachmentArchive len=8 ok
id=3820 TSD.GuideStorageArchive len=0 ok
Index/Slide1.iwa — 1 blocks, 1280 B compressed → 2300 B decoded, 17 archives
id=2892 KN.SlideArchive len=148 ok
id=3369 KN.PlaceholderArchive len=188 ok
id=3546 TSWP.StorageArchive len=76 ok
id=3722 TSWP.NumberAttachmentArchive len=8 ok
id=43271 TSD.ImageArchive len=108 ok
id=3366 TSWP.ShapeInfoArchive len=183 ok
== Slide-1.iwa
/Users/phebert/Development/pnk-fixtures/crawl/0d5851c0105958b602c58646b98ffaee1b453def8478f9b872aca37319d2ed5f.key — flat zip, 130 members, 1 iwa streams, app: keynote
Index/MasterSlide-1.iwa — 1 blocks, 1526 B compressed → 3128 B decoded, 15 archives
id=2883 KN.SlideArchive len=211 ok
id=3487 TSD.ImageArchive len=110 ok
id=27664 TSWP.ShapeInfoArchive len=184 ok
id=27665 TSWP.StorageArchive len=236 ok
id=3491 KN.PlaceholderArchive len=188 ok
id=3678 TSWP.StorageArchive len=62 ok
id=3490 KN.PlaceholderArchive len=188 ok
id=3680 TSWP.StorageArchive len=188 ok
id=3494 KN.ClassicStylesheetRecordArchive len=743 ok
id=3489 KN.PlaceholderArchive len=188 ok
id=3679 TSWP.StorageArchive len=72 ok
id=3488 TSD.ImageArchive len=110 ok
id=3492 KN.PlaceholderArchive len=188 ok
id=3677 TSWP.StorageArchive len=60 ok
id=3493 TSD.GuideStorageArchive len=0 okcargo run --release -p iwadump -- --archive Index/Slide1.iwa --message 3366 ~/Development/pnk-fixtures/crawl/0d5851c0105958b602c58646b98ffaee1b453def8478f9b872aca37319d2ed5f.key 2>/dev/null | head -60
stream: Index/Slide1.iwa
local id: 3366
type: 2011 (TSWP.ShapeInfoArchive)
payload length: 183 bytes
status: decoded (TSWP.ShapeInfoArchive)
hex:
00000000 0a af 01 0a 3e 0a 1f 0a 0a 0d 00 00 00 44 15 00 |....>........D..|
00000010 80 1f 44 12 0a 0d c4 61 fa 43 15 00 00 10 42 18 |..D....a.C....B.|
00000020 00 25 00 00 00 00 12 03 08 cc 16 1a 12 08 04 10 |.%..............|
00000030 02 18 01 25 00 00 40 41 2d 00 00 00 3f 30 00 28 |...%..@A-...?0.(|
00000040 00 38 00 12 03 08 fa 12 1a 68 08 00 10 00 2a 62 |.8.......h....*b|
00000050 12 0a 0d c5 61 fa 43 15 00 00 10 42 1a 54 0a 0e |....a.C....B.T..|
00000060 08 01 12 0a 0d 00 00 00 00 15 00 00 00 00 0a 0e |................|
00000070 08 02 12 0a 0d c5 61 fa 43 15 00 00 00 00 0a 0e |......a.C.......|
00000080 08 02 12 0a 0d c5 61 fa 43 15 00 00 10 42 0a 0e |......a.C....B..|
00000090 08 02 12 0a 0d 00 00 00 00 15 00 00 10 42 0a 02 |.............B..|
000000a0 08 05 0a 0e 08 01 12 0a 0d 00 00 00 00 15 00 00 |................|
000000b0 00 00 12 03 08 d7 1b |.......|
best-effort field walk:
1: len (175 B) → nested message:
1: len (62 B) → nested message:
1: len (31 B) → nested message:
1: len (10 B) → nested message:
1: fixed32 = 0x44000000
2: fixed32 = 0x441f8000
2: len (10 B) → nested message:
1: fixed32 = 0x43fa61c4
2: fixed32 = 0x42100000
3: varint = 0
4: fixed32 = 0x00000000
2: len (3 B) → nested message:
1: varint = 2892
3: len (18 B) → nested message:
1: varint = 4
2: varint = 2
3: varint = 1
4: fixed32 = 0x41400000
5: fixed32 = 0x3f000000
6: varint = 0
5: varint = 0
7: varint = 0
2: len (3 B) → nested message:
1: varint = 2426
3: len (104 B) → nested message:
1: varint = 0
2: varint = 0
5: len (98 B) → nested message:
2: len (10 B) → nested message:
1: fixed32 = 0x43fa61c5
2: fixed32 = 0x42100000
3: len (84 B) → nested message:
1: len (14 B) → nested message:
1: varint = 1
2: len (10 B) → nested message:
1: fixed32 = 0x00000000
2: fixed32 = 0x00000000
1: len (14 B) → nested message:
1: varint = 2I noticed the title shape's text appears centered on its position point rather than filling its stored bounding box, suggesting anchored-text semantics. I'll check the format docs on TSD.GeometryArchive flags and shape text storage before drawing conclusions.
grep -rn "flags" /Users/phebert/pnk/docs/format/*.md | grep -i "geometry\|anchor\|natural" | head; grep -rln "ShapeInfo" /Users/phebert/pnk/docs/format/ | head -3
/Users/phebert/pnk/docs/format/text.md /Users/phebert/pnk/docs/format/pages.md /Users/phebert/pnk/docs/format/drawables.md
grep -n -i "GeometryArchive" -A 12 /Users/phebert/pnk/docs/format/drawables.md | head -40; grep -n -i "anchor\|natural.size" /Users/phebert/pnk/docs/format/text.md | head
16:- `geometry = 1` — inline `TSD.GeometryArchive`.
17-- `parent = 2` — `TSP.Reference` to the containing drawable (a group, or the
18- canvas owner's container).
19-- `exterior_text_wrap = 3` — `TSD.ExteriorTextWrapArchive` (lines 466-473:
20- `type`/`direction`/`fit_type`/`margin`/`alpha_threshold`/`is_html_wrap`) for
21- wrap-around-object text flow. [proto]
22-- `hyperlink_url = 4`, `locked = 5`, `comment = 6` (`TSP.Reference`),
23- `aspect_ratio_locked = 7`, `accessibility_description = 8`,
24- `pencil_annotations = 9` (repeated `TSP.Reference`), `title = 10`,
25- `caption = 11`, `title_hidden = 12`, `caption_hidden = 13`. [proto]
26-
27:Geometry lives in `TSD.GeometryArchive` (lines 21-26): `position = 1`
28-(`TSP.Point { x = 1, y = 2 }`, floats — `TSPMessages.proto:45-48`),
29-`size = 2` (`TSP.Size { width = 1, height = 2 }` — `TSPMessages.proto:61-64`),
30-`flags = 3` (uint32 bitfield; semantics not documented in any local proto
31-[inferred: flags exist but no local source defines the bits]),
32-`angle = 4` (float, **degrees** [inferred→fixture-verified 2026-08-29: the
33-24_Briefing.key master's tick rules store `angle = 90.0` and Keynote's own PDF
34-export renders them vertical — 90 radians would display as ≡116.6°]). [proto]
35-
36-Every concrete drawable embeds the base as `super` (required, field 1) — the
37-Apple-protobuf subclassing idiom used throughout iWork (same pattern as
38-`TST.TableInfoArchive` → `TSD.DrawableArchive` in [tables.md](tables.md)):
39-
112:- Inline/anchored objects occupy a position in the text via `table_attachment`
120: v_offset_type = 4, v_offset = 5 }` — anchored drawables with offset semanticsgrep -rn "shrink\|autosize\|auto_size\|maximum_size\|natural" /Users/phebert/pnk/crates/pnk2json/src/*.rs | head -15
/Users/phebert/pnk/crates/pnk2json/src/drawables.rs:345: // Text-fit semantics: "shrink text on overflow" (resolved flag) scales
/Users/phebert/pnk/crates/pnk2json/src/drawables.rs:351: let text_fit = if frame.shrink_to_fit == Some(true) {
/Users/phebert/pnk/crates/pnk2json/src/drawables.rs:446: /// .shrink_to_fit (field 1) [proto: TSWPArchives.proto:502]; the older
/Users/phebert/pnk/crates/pnk2json/src/drawables.rs:447: /// TSWP.ColumnStyleArchive keeps it in column_properties.shrink_to_fit
/Users/phebert/pnk/crates/pnk2json/src/drawables.rs:450: shrink_to_fit: Option<bool>,
/Users/phebert/pnk/crates/pnk2json/src/drawables.rs:455:/// shrink_to_fit (field 1) read off the text shape's TSWP.ShapeStyleArchive
/Users/phebert/pnk/crates/pnk2json/src/drawables.rs:462: let mut props = TextFrameProps { vertical_alignment: None, shrink_to_fit: None };
/Users/phebert/pnk/crates/pnk2json/src/drawables.rs:486: if props.shrink_to_fit.is_none() {
/Users/phebert/pnk/crates/pnk2json/src/drawables.rs:488: props.shrink_to_fit = Some(b);
/Users/phebert/pnk/crates/pnk2json/src/drawables.rs:491: if props.vertical_alignment.is_some() && props.shrink_to_fit.is_some() {
/Users/phebert/pnk/crates/pnk2json/src/drawables.rs:519: natural_size: None,
/Users/phebert/pnk/crates/pnk2json/src/drawables.rs:574: let natural_size = m.size(9).map(|(w, h)| Size { width: w, height: h });
/Users/phebert/pnk/crates/pnk2json/src/drawables.rs:587: natural_size: None,
/Users/phebert/pnk/crates/pnk2json/src/drawables.rs:601: Drawable::Image { common, image, original, thumbnail, svg, natural_size, mask, adjustments }
/Users/phebert/pnk/crates/pnk2json/src/drawables.rs:951: natural_size: None,{
"file_path": "/Users/phebert/pnk/crates/pnk2json/src/drawables.rs",
"offset": 340,
"limit": 40
}340 promote_para_font(ctx, sid, t);
341 }
342 }
343 let is_text_box = info.boolean(6).unwrap_or(false);
344 let frame = shape_text_frame_props(ctx, &shape);
345 // Text-fit semantics: "shrink text on overflow" (resolved flag) scales
346 // text down to the stored box; a plain text box (is_text_box, not a
347 // placeholder) auto-grows its height as content wraps — Keynote stores
348 // the height laid out with Apple's font metrics, so renderers with
349 // different metrics must treat it as a minimum, not a clip [inferred:
350 // Keynote app behavior; placeholders keep layout-fixed frames].
351 let text_fit = if frame.shrink_to_fit == Some(true) {
352 Some(TextFit::Shrink)
353 } else if is_text_box && placeholder_role.is_none() {
354 Some(TextFit::Grow)
355 } else {
356 None
357 };
358
359 let mut drawable = if is_text_box || type_id == 7 || type_id == 12 {
360 // Textbox (or placeholder, which renders like a textbox).
361 let mut common = common_from_shape(ctx, &shape);
362 if let Some(role) = placeholder_role {
363 common.placeholder = Some(PlaceholderInfo { role, inherited: None });
364 }
365 Drawable::Textbox {
366 common,
367 text: text.unwrap_or_default(),
368 vertical_alignment: frame.vertical_alignment,
369 text_insets: None,
370 text_fit,
371 }
372 } else {
373 let mut d = shape_drawable(ctx, &shape, text, frame.vertical_alignment);
374 if text_fit.is_some() {
375 if let Drawable::Shape { text_fit: tf, .. } = &mut d {
376 *tf = text_fit;
377 }
378 }
379 if let Some(role) = placeholder_role {The shape lacks is_text_box (a true Shape with text). Let me probe our slide-1 DOM to see exactly how we lay out that title text before deciding the rule:
sed -i '' 's/nth(8)/nth(0)/' probe-tmp.mjs && node probe-tmp.mjs 2>&1 | python3 -c "
import json,sys
d = json.load(sys.stdin)
for dr in d['drawables']:
print(dr['text'][:25].ljust(26), dr['docBox'], dr.get('innerDoc'), dr.get('alignItems'), dr.get('transform'))
"; python3 -c "
import json
d = json.load(open('/private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/0d58.json'))
doc = d.get('document', d)
sl = doc['slides'][0]
sh = sl['drawables'][4]
print('shape keys:', [k for k in sh.keys()])
print('verticalAlignment:', sh.get('verticalAlignment'))
for p in sh['text']['paragraphs']:
print('pStyle', p.get('pStyle'), '->', json.dumps(doc['styles']['para'][p['pStyle']]) if p.get('pStyle') is not None else None)
for it in p.get('items', []):
if isinstance(it, dict) and 'cStyle' in it: print(' cStyle', it['cStyle'], json.dumps(doc['styles']['char'][it['cStyle']]))
" {'x': 0, 'y': 53, 'w': 1024, 'h': 471} None None None
{'x': 0, 'y': 692, 'w': 1024, 'h': 76} None None None
{'x': -1, 'y': -9, 'w': 1026, 'h': 861} None None None
{'x': 182, 'y': 619, 'w': 643, 'h': 36} None None None
How hot is your network? {'x': 512, 'y': 638, 'w': 501, 'h': 36} {'x': 512, 'y': 639, 'w': 501, 'h': 35} center
shape keys: ['type', 'common', 'geometry', 'text', 'verticalAlignment']
verticalAlignment: middle
pStyle 0 -> {"horizontalAlignment": "center", "defaultTabStopPt": 46.0, "keepLinesTogether": true}
cStyle 19 {"fontName": "AppleSDGothicNeo-Heavy", "fontSizePt": 28.0, "bold": true, "fontColor": "#ffffff", "outline": {"widthPt": 0.0, "color": "#ffffff"}}I've confirmed that in this classic deck, ShapeInfo position represents the shape's center (matching slide 29's quote anchor too). Now I'll check if modern shapes use different geometry flags by dumping G2's pentagon data.
cargo run --release -p iwadump -- --list fixtures/golden/G2-golden-pages-layout.pages 2>/dev/null | grep -i iwa | head;
cargo run --release -p iwadump -- --list fixtures/golden/G2-golden-pages-layout.pages 2>&1 | head -15
Finished `release` profile [optimized] target(s) in 0.02s
Running `/Users/phebert/pnk/target/release/iwadump --list fixtures/golden/G2-golden-pages-layout.pages`
iwadump: cannot read fixtures/golden/G2-golden-pages-layout.pages: No such file or directory (os error 2) [container]cd /Users/phebert/pnk && ./target/release/iwadump --list fixtures/golden/G2-golden-pages-layout.pages | grep "\.iwa" | head -8
11605 Index/Document.iwa
725 Index/ViewState.iwa
546 Index/CalculationEngine-1732636.iwa
23 Index/AnnotationAuthorStorage-1732635.iwa
46313 Index/DocumentStylesheet.iwa
230 Index/DocumentMetadata.iwa
19389 Index/Metadata.iwa./target/release/iwadump --archive Index/Document.iwa fixtures/golden/G2-golden-pages-layout.pages | grep -n "ShapeInfo" | head -4
60: id=1732749 TSWP.ShapeInfoArchive len=209 ok 64: id=1734362 TSWP.ShapeInfoArchive len=131 ok 68: id=1734939 TSWP.ShapeInfoArchive len=324 ok 72: id=1735565 TSWP.ShapeInfoArchive len=193 ok
./target/release/iwadump --archive Index/Document.iwa --message 1734939 fixtures/golden/G2-golden-pages-layout.pages | sed -n '/field walk/,/^$/p' | head -25
best-effort field walk:
1: len (307 B) → nested message:
1: len (73 B) → nested message:
1: len (31 B) → nested message:
1: len (10 B) → nested message:
1: fixed32 = 0x43d7959a
2: fixed32 = 0x4279999a
2: len (10 B) → nested message:
1: fixed32 = 0x42c80000
2: fixed32 = 0x42c80000
3: varint = 3
4: fixed32 = 0x00000000
3: len (18 B) → nested message:
1: varint = 5
2: varint = 2
3: varint = 1
4: fixed32 = 0x41400000
5: fixed32 = 0x3f000000
6: varint = 0
5: varint = 0
7: varint = 0
10: len (4 B) → nested message:
1: varint = 1734937
11: len (4 B) → nested message:
1: varint = 1734942I've found a promising discriminator: G2's modern pentagon has flags = 3 while the mis-anchored classic title has flags = 0. I'll now test this against the correctly-positioned band image on slide 1 and the slide-29 quote to validate it.
./target/release/iwadump --archive Index/Slide1.iwa --message 43271 ~/Development/pnk-fixtures/crawl/0d5851c0105958b602c58646b98ffaee1b453def8478f9b872aca37319d2ed5f.key | sed -n '/field walk/,$p' | head -14; echo ====; ./target/release/iwadump --archive Index/Slide29.iwa ~/Development/pnk-fixtures/crawl/0d5851c0105958b602c58646b98ffaee1b453def8478f9b872aca37319d2ed5f.key | head -14
Exit code 1
best-effort field walk:
1: len (62 B) → nested message:
1: len (31 B) → nested message:
1: len (10 B) → nested message:
1: fixed32 = 0x43358000
2: fixed32 = 0x441ac000
2: len (10 B) → nested message:
1: fixed32 = 0x4420c000
2: fixed32 = 0x42100000
3: varint = 3
4: fixed32 = 0x00000000
2: len (3 B) → nested message:
1: varint = 2892
3: len (18 B) → nested message:
(eval):1: === not foundThe classic deck's correctly-placed band image has flags=3, the broken title has flags=0 — the discriminator holds so far. Checking slide 29's quote shape (expect 0):
./target/release/iwadump --archive Index/Slide29.iwa ~/Development/pnk-fixtures/crawl/0d5851c0105958b602c58646b98ffaee1b453def8478f9b872aca37319d2ed5f.key | grep ShapeInfo
./target/release/iwadump --archive Index/Slide29.iwa ~/Development/pnk-fixtures/crawl/0d5851c0105958b602c58646b98ffaee1b453def8478f9b872aca37319d2ed5f.key | head -20
/Users/phebert/Development/pnk-fixtures/crawl/0d5851c0105958b602c58646b98ffaee1b453def8478f9b872aca37319d2ed5f.key — flat zip, 130 members, 1 iwa streams, app: keynote
Index/Slide29.iwa — 1 blocks, 983 B compressed → 1507 B decoded, 11 archives
id=46476 KN.SlideArchive len=120 ok
id=46484 TSD.GuideStorageArchive len=0 ok
id=46481 KN.PlaceholderArchive len=190 ok
id=46487 TSWP.StorageArchive len=84 ok
id=46482 KN.PlaceholderArchive len=190 ok
id=46488 TSWP.StorageArchive len=317 ok
id=46483 KN.PlaceholderArchive len=190 ok
id=46489 TSWP.StorageArchive len=77 ok
id=46491 TSWP.NumberAttachmentArchive len=8 ok
id=46485 KN.NoteArchive len=6 ok
id=46490 TSWP.StorageArchive len=62 okfor f in $(./target/release/iwadump --list ~/Development/pnk-fixtures/crawl/0d5851c0105958b602c58646b98ffaee1b453def8478f9b872aca37319d2ed5f.key | grep -o "Index/Slide[^ ]*\.iwa"); do out=$(./target/release/iwadump --archive "$f" ~/Development/pnk-fixtures/crawl/0d5851c0105958b602c58646b98ffaee1b453def8478f9b872aca37319d2ed5f.key 2>/dev/null | grep "TSWP.ShapeInfoArchive") [ -n "$out" ] && echo "$f: $out" done
Exit code 1 Index/Slide10.iwa: id=44725 TSWP.ShapeInfoArchive len=186 ok Index/Slide14.iwa: id=45474 TSWP.ShapeInfoArchive len=186 ok Index/Slide15.iwa: id=45581 TSWP.ShapeInfoArchive len=186 ok Index/Slide27.iwa: id=51720 TSWP.ShapeInfoArchive len=185 ok Index/Slide28.iwa: id=52255 TSWP.ShapeInfoArchive len=185 ok Index/Slide1.iwa: id=3366 TSWP.ShapeInfoArchive len=183 ok
for pair in "Index/Slide27.iwa 51720" "Index/Slide28.iwa 52255" "Index/Slide10.iwa 44725"; do set -- $pair echo "== $1 $2" ./target/release/iwadump --archive "$1" --message "$2" ~/Development/pnk-fixtures/crawl/0d5851c0105958b602c58646b98ffaee1b453def8478f9b872aca37319d2ed5f.key | sed -n '/field walk/,$p' | sed -n '3,12p' done
== Index/Slide27.iwa 51720 error: invalid value '' for '--message <MESSAGE>': cannot parse integer from empty string For more information, try '--help'. == Index/Slide28.iwa 52255 error: invalid value '' for '--message <MESSAGE>': cannot parse integer from empty string For more information, try '--help'. == Index/Slide10.iwa 44725 error: invalid value '' for '--message <MESSAGE>': cannot parse integer from empty string For more information, try '--help'.
K=~/Development/pnk-fixtures/crawl/0d5851c0105958b602c58646b98ffaee1b453def8478f9b872aca37319d2ed5f.key ./target/release/iwadump --archive Index/Slide27.iwa --message 51720 $K | sed -n '/field walk/,$p' | sed -n '2,12p' echo "-- 28" ./target/release/iwadump --archive Index/Slide28.iwa --message 52255 $K | sed -n '/field walk/,$p' | sed -n '2,12p' echo "-- 10" ./target/release/iwadump --archive Index/Slide10.iwa --message 44725 $K | sed -n '/field walk/,$p' | sed -n '2,12p'
1: len (176 B) → nested message:
1: len (63 B) → nested message:
1: len (31 B) → nested message:
1: len (10 B) → nested message:
1: fixed32 = 0x4387876c
2: fixed32 = 0x43c6acc3
2: len (10 B) → nested message:
1: fixed32 = 0x00000000
2: fixed32 = 0x00000000
3: varint = 0
4: fixed32 = 0x00000000
-- 28
1: len (176 B) → nested message:
1: len (63 B) → nested message:
1: len (31 B) → nested message:
1: len (10 B) → nested message:
1: fixed32 = 0x4387876c
2: fixed32 = 0x43c6acc3
2: len (10 B) → nested message:
1: fixed32 = 0x00000000
2: fixed32 = 0x00000000
3: varint = 0
4: fixed32 = 0x00000000
-- 10
1: len (177 B) → nested message:
1: len (63 B) → nested message:
1: len (31 B) → nested message:
1: len (10 B) → nested message:
1: fixed32 = 0x41900000
2: fixed32 = 0x430c0000
2: len (10 B) → nested message:
1: fixed32 = 0x446c131c
2: fixed32 = 0x440b9ed5
3: varint = 3
4: fixed32 = 0x00000000I've found a consistent pattern: flags=3 marks top-left positioning, while flags=0 either indicates a text anchor point or a shape's center depending on size. Now I'll verify this against shapes from a modern Keynote deck before updating the converter.
K2=/Users/phebert/Development/pnk-fixtures/crawl/0f9df553f4a7afb8147288b9ffb4352624edea6448e946b859516768ed99d573.key for f in $(./target/release/iwadump --list $K2 | grep -o "Index/[^ ]*\.iwa" | head 2>/dev/null); do :; done ./target/release/iwadump $K2 2>/dev/null | grep -m3 "ShapeInfo"
id=4125 TSWP.ShapeInfoArchive len=105 ok
id=4141 TSWP.ShapeInfoArchive len=105 ok
id=4121 TSWP.ShapeInfoArchive len=105 okK2=/Users/phebert/Development/pnk-fixtures/crawl/0f9df553f4a7afb8147288b9ffb4352624edea6448e946b859516768ed99d573.key
./target/release/iwadump $K2 2>/dev/null | grep -B2 "id=4125" | head -4
for m in $(./target/release/iwadump --list $K2 | grep -o "Index/[^ ]*\.iwa"); do
ids=$(./target/release/iwadump --archive "$m" $K2 2>/dev/null | grep "ShapeInfo" | grep -o "id=[0-9]*" | head -2)
for id in $ids; do
fl=$(./target/release/iwadump --archive "$m" --message "${id#id=}" $K2 2>/dev/null | sed -n '/field walk/,$p' | grep -m1 "3: varint")
echo "$m ${id}: geo-flags $fl"
done
done 2>/dev/null | head -12 id=3110 TSD.GroupArchive len=74 ok
id=3106 TSD.GroupArchive len=279 ok
id=4125 TSWP.ShapeInfoArchive len=105 ok
Index/Slide.iwa id=4125
id=4141: geo-flags
Index/Slide-15.iwa id=3978
id=3977: geo-flags
Index/Slide-120.iwa id=4299
id=4310: geo-flags
Index/Slide-138.iwa id=4591
id=4590: geo-flagsK2=/Users/phebert/Development/pnk-fixtures/crawl/0f9df553f4a7afb8147288b9ffb4352624edea6448e946b859516768ed99d573.key
for m in Index/Slide.iwa Index/Slide-15.iwa Index/Slide-120.iwa; do
for id in $(./target/release/iwadump --archive "$m" $K2 2>/dev/null | grep "ShapeInfo" | sed 's/.*id=\([0-9]*\).*/\1/' | head -2); do
fl=$(./target/release/iwadump --archive "$m" --message "$id" $K2 2>/dev/null | awk '/field walk/,0' | awk '/3: varint/{print $3; exit}')
echo "$m id=$id flags=$fl"
done
doneIndex/Slide.iwa id=4125 flags== Index/Slide.iwa id=4141 flags== Index/Slide-15.iwa id=3978 flags== Index/Slide-15.iwa id=3977 flags== Index/Slide-120.iwa id=4299 flags== Index/Slide-120.iwa id=4310 flags==
K2=/Users/phebert/Development/pnk-fixtures/crawl/0f9df553f4a7afb8147288b9ffb4352624edea6448e946b859516768ed99d573.key ./target/release/iwadump --archive Index/Slide.iwa --message 4125 $K2 | sed -n '/field walk/,$p' | head -14
best-effort field walk:
1: len (98 B) → nested message:
1: len (62 B) → nested message:
1: len (31 B) → nested message:
1: len (10 B) → nested message:
1: fixed32 = 0x4106b600
2: fixed32 = 0x41663a40
2: len (10 B) → nested message:
1: fixed32 = 0x401112bc
2: fixed32 = 0x4116a3fe
3: varint = 7
4: fixed32 = 0x43340000
2: len (3 B) → nested message:
1: varint = 3106Modern shapes always show flags=3 or 7, so this repositioning rule only applies to classic imports. I'll implement the fix in the converter—re-anchoring position from center to top-left when a text shape has flags=0 and nonzero size—starting by locating the geometry parsing code.
grep -n "fn.*geometry\|angle\|flags" crates/pnk2json/src/tsd.rs crates/pnk2json/src/drawables.rs | head -20
crates/pnk2json/src/drawables.rs:123: // Geometry angle is DEGREES, not radians — fixture-verified:
crates/pnk2json/src/drawables.rs:127: c.angle_deg = Some(deg as f64);
crates/pnk2json/src/drawables.rs:181: // Per-property "seen at a nearer chain level" flags:
crates/pnk2json/src/drawables.rs:676:fn anchor_geometry(ctx: &Ctx, aid: u64) -> Option<(Point, Size)> {
crates/pnk2json/src/drawables.rs:757: common.angle_deg = None;
crates/pnk2json/src/tsd.rs:174:pub fn shape_geometry(pathsource: &Msg) -> ShapeGeometry {
crates/pnk2json/src/tsd.rs:312: let mut gradient = Gradient { kind, stops, angle_deg: None, start_point: None, end_point: None };
crates/pnk2json/src/tsd.rs:314: // TSD.AngleGradientArchive.gradientangle is RADIANS (fixture-verified:
crates/pnk2json/src/tsd.rs:316: // top-to-bottom backdrop) — unlike TSD.Geometry.angle, which is
crates/pnk2json/src/tsd.rs:318: gradient.angle_deg = a.f32v(2).map(|v| (v as f64).to_degrees());
crates/pnk2json/src/tsd.rs:385: angle_deg: m.f32v(2).unwrap_or(315.0) as f64,{
"file_path": "/Users/phebert/pnk/crates/pnk2json/src/drawables.rs",
"offset": 95,
"limit": 75
}95 ),
96 format!("0x{:x}", rec.type_id),
97 );
98 Drawable::Unknown {
99 common: drawable_common(ctx, &msg).ok(),
100 type_id: format!("0x{:x}", rec.type_id),
101 type_name,
102 reason: "recognized object type has no drawable model".into(),
103 }
104 }
105 }
106 }
107
108 /// Extract `DrawableCommon` from a `TSD.DrawableArchive` payload. `Err(())`
109 /// when the payload carries no geometry at all.
110 fn drawable_common(ctx: &mut Ctx, m: &Msg) -> Result<DrawableCommon, ()> {
111 let mut c = DrawableCommon::default();
112 let mut any = false;
113 if let Some(g) = m.msg(1) {
114 if let Some((x, y)) = g.point(1) {
115 c.position = Some(Point { x, y });
116 any = true;
117 }
118 if let Some((w, h)) = g.size(2) {
119 c.size = Some(Size { width: w, height: h });
120 any = true;
121 }
122 if let Some(deg) = g.f32v(4) {
123 // Geometry angle is DEGREES, not radians — fixture-verified:
124 // 24_Briefing master ticks store 90.0 and Apple's own PDF export
125 // renders them vertical (a radians read would give 5156.6°≡117°,
126 // the diagonal we used to draw). docs/format/drawables.md updated.
127 c.angle_deg = Some(deg as f64);
128 any = true;
129 }
130 }
131 if let Some(w) = m.msg(3) {
132 let kind = match w.varint(1).unwrap_or(0) {
133 0 => TextWrapKind::None,
134 1 => TextWrapKind::Around,
135 2 => TextWrapKind::AboveBelow,
136 3 => TextWrapKind::Left,
137 4 => TextWrapKind::Right,
138 5 => TextWrapKind::Largest,
139 _ => TextWrapKind::Around,
140 };
141 c.text_wrap = Some(TextWrap { kind, margin_pt: w.f32v(4).map(|v| v as f64) });
142 any = true;
143 }
144 if let Some(h) = m.string(4) {
145 if !h.is_empty() {
146 c.hyperlink = Some(h);
147 }
148 any = any || m.has(4);
149 }
150 if let Some(l) = m.boolean(5) {
151 c.locked = Some(l);
152 any = true;
153 }
154 if let Some(a) = m.string(8) {
155 if !a.is_empty() {
156 c.accessibility_description = Some(a);
157 }
158 any = any || m.has(8);
159 }
160 if any {
161 Ok(c)
162 } else {
163 Err(())
164 }
165 }
166
167 /// Style from TSD.ShapeStyleArchive (shape_properties = 11) or
168 /// TSD.MediaStyleArchive (media_properties = 11), resolved through the
169 /// TSS.StyleArchive `parent = 3` chain (docs/format/styles.md): a shape{
"file_path": "/Users/phebert/pnk/crates/pnk2json/src/drawables.rs",
"offset": 290,
"limit": 55
}290 type_id: u32,
291 type_name: Option<String>,
292 ) -> Drawable {
293 // Placeholder types: unwrap `{ super, kind }` → the ShapeInfoArchive.
294 let super_info =
295 if type_id == 7 || type_id == 12 { m.msg(1) } else { None };
296 let info = super_info.as_ref().unwrap_or(m);
297 let kind = if type_id == 7 || type_id == 12 { m.varint(2) } else { None };
298 let Some(shape) = info.msg(1) else {
299 return Drawable::Unknown {
300 common: None,
301 type_id: format!("0x{type_id:x}"),
302 type_name,
303 reason: "ShapeInfoArchive without a ShapeArchive super".into(),
304 };
305 };
306 let placeholder_role = match type_id {
307 // KN.PlaceholderArchive.Kind (KNArchives.proto:203-209)
308 7 | 12 if type_name.as_deref().map(|n| n.starts_with("KN.")).unwrap_or(false) => {
309 Some(
310 match kind.unwrap_or(0) {
311 1 => "slide-number",
312 2 => "title",
313 3 => "body",
314 4 => "object",
315 _ => "placeholder",
316 }
317 .to_string(),
318 )
319 }
320 7 | 12 => Some("placeholder".to_string()), // TP.PlaceholderArchive
321 _ => None,
322 };
323
324 // Text: owned_storage (4) wins, else text_flow (3) → FlowInfo.text_storage (1),
325 // else deprecated_storage (2) — older docs (pre-flow) reference the
326 // StorageArchive there directly [proto: TSWPArchives.proto ShapeInfoArchive
327 // field 2, deprecated=true; fixture 5008407355… stores its template text so].
328 let storage_id = info
329 .reference(4)
330 .or_else(|| info.msg(3).and_then(|f| f.reference(1)))
331 .or_else(|| info.reference(2));
332 let mut text = storage_id.and_then(|sid| crate::text::extract(ctx, sid)).map(|e| e.text);
333 // Keynote placeholders and title/body shapes keep their look on the
334 // referenced paragraph style (their storage char-style tables hold null
335 // overrides), so runs without their own character style inherit the
336 // paragraph style's font name/size/color. Keynote-scoped: Pages storages
337 // carry real character styles and are golden-pinned.
338 if ctx.app_kind == crate::model::AppKind::Keynote {
339 if let (Some(sid), Some(t)) = (storage_id, text.as_mut()) {
340 promote_para_font(ctx, sid, t);
341 }
342 }
343 let is_text_box = info.boolean(6).unwrap_or(false);
344 let frame = shape_text_frame_props(ctx, &shape);{
"file_path": "/Users/phebert/pnk/crates/pnk2json/src/drawables.rs",
"offset": 358,
"limit": 45
}358
359 let mut drawable = if is_text_box || type_id == 7 || type_id == 12 {
360 // Textbox (or placeholder, which renders like a textbox).
361 let mut common = common_from_shape(ctx, &shape);
362 if let Some(role) = placeholder_role {
363 common.placeholder = Some(PlaceholderInfo { role, inherited: None });
364 }
365 Drawable::Textbox {
366 common,
367 text: text.unwrap_or_default(),
368 vertical_alignment: frame.vertical_alignment,
369 text_insets: None,
370 text_fit,
371 }
372 } else {
373 let mut d = shape_drawable(ctx, &shape, text, frame.vertical_alignment);
374 if text_fit.is_some() {
375 if let Drawable::Shape { text_fit: tf, .. } = &mut d {
376 *tf = text_fit;
377 }
378 }
379 if let Some(role) = placeholder_role {
380 if let Drawable::Shape { common, .. } = &mut d {
381 common.placeholder = Some(PlaceholderInfo { role, inherited: None });
382 }
383 }
384 d
385 };
386
387 // Distinguish a bare empty textbox: if the shape has no pathsource and no
388 // style at all it is still a textbox; nothing more to do here.
389 let _ = id;
390 drawable
391 }
392
393 fn common_from_shape(ctx: &mut Ctx, shape: &Msg) -> DrawableCommon {
394 // shape: super = 1 (TSD.DrawableArchive), style = 2, pathsource = 3
395 let mut common = shape
396 .msg(1)
397 .and_then(|d| drawable_common(ctx, &d).ok())
398 .unwrap_or_default();
399 let (style, extras) = drawable_style(ctx, shape.reference(2), false);
400 common.style = style;
401 merge_extras(&mut common, extras);
402 common // Distinguish a bare empty textbox: if the shape has no pathsource and no
// style at all it is still a textbox; nothing more to do here.
let _ = id;
drawable
} // Classic-import anchored geometry: Keynote-'09-converted decks (format
// 1.5) store some text shapes' geometry with flags == 0 and position =
// the shape's CENTER, not its top-left. 0d5851c0 slide 1: the title
// stores (512, 638) — the slide's horizontal center — and Apple lays the
// 500×36 rect out at 262..762 with its centered text on x=512; modern
// archives (G2, 0f9df553) always write flags 3 (7 when rotated).
// Re-anchor to top-left here so the model's geometry contract holds and
// the viewer never learns about the flag. A 0×0 anchored label is
// unaffected (shift of half-zero), and rotation is left alone — no
// rotated flags==0 sample exists to verify against. [inferred: flag-bit
// semantics are undocumented; behavior verified against Apple's own
// render of 0d5851c0 slides 1/27/28]
if shape.msg(1).and_then(|d| d.msg(1)).and_then(|g| g.varint(3)) == Some(0) {
if let Drawable::Shape { common, .. } | Drawable::Textbox { common, .. } = &mut drawable {
if common.angle_deg.unwrap_or(0.0) == 0.0 {
if let (Some(p), Some(s)) = (common.position.as_mut(), common.size.as_ref()) {
p.x -= s.width / 2.0;
p.y -= s.height / 2.0;
}
}
}
}
// Distinguish a bare empty textbox: if the shape has no pathsource and no
// style at all it is still a textbox; nothing more to do here.
let _ = id;
drawable
}The file /Users/phebert/pnk/crates/pnk2json/src/drawables.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 | tail -3 && ./target/release/pnk2json --pretty ~/Development/pnk-fixtures/crawl/0d5851c0105958b602c58646b98ffaee1b453def8478f9b872aca37319d2ed5f.key 2>/dev/null | python3 -c "
import json, sys
d = json.load(sys.stdin)
doc = d.get('document', d)
sh = doc['slides'][0]['drawables'][4]
print('title now at:', sh['common']['position'], sh['common']['size'])
"warning: `pnk2json` (lib) generated 12 warnings (run `cargo fix --lib -p pnk2json` to apply 7 suggestions)
Finished `release` profile [optimized] target(s) in 4.67s
title now at: {'x': 261.61810302734375, 'y': 620.0} {'width': 500.7637939453125, 'height': 36.0}Title now lands at 262..762 × 620..656 — exactly Apple's placement. Now the viewer fixes. First the flex-shrink bug in the shrink pass, then the 0×0 natural-width issue:
grep -n "edge-clamp\|0×0\|zero\|offsetWidth === 0\|width === 0" /Users/phebert/pnk/viewer/src/drawables.ts | head
59: // Apple emits placeholder dash arrays of all zeros for solid strokes; SVG
60: // would render those as invisible zero-length dashes.
568: if (box.dataset.textFit === "edge-clamp") {
780: // Zero-size textboxes (Keynote emits some badge labels at 0×0) carry
782: if (!c.size || (c.size.width === 0 && c.size.height === 0)) {
809: div.dataset.textFit = "edge-clamp";
874: // connection lines have a 0-width/0-height frame, and a zero (or 1e-7){
"file_path": "/Users/phebert/pnk/viewer/src/drawables.ts",
"offset": 770,
"limit": 75
}770 // background if only the text layer paints (monster deck: lavender
771 // "Transverse density" tags, dark-red banner boxes rendered as ghost
772 // text on nothing).
773 const bg = fillToCss(c.style?.fill);
774 if (bg) div.style.background = bg;
775 if (c.style?.stroke && c.style.stroke.widthPt > 0) {
776 div.style.border = `${c.style.stroke.widthPt}px solid ${c.style.stroke.color}`;
777 }
778 const layer = textLayer(d, doc, ctx);
779 if (layer) {
780 // Zero-size textboxes (Keynote emits some badge labels at 0×0) carry
781 // their text unclipped: let the content size the box instead.
782 if (!c.size || (c.size.width === 0 && c.size.height === 0)) {
783 div.style.width = "auto";
784 div.style.height = "auto";
785 div.style.overflow = "visible";
786 layer.style.overflow = "visible";
787 layer.style.position = "relative";
788 layer.style.whiteSpace = "nowrap";
789 layer.style.width = "max-content"; // percentage of an auto box is meaningless
790 layer.style.height = "auto";
791 // A 0-size box is a point ANCHOR: text laid out in it overflows
792 // equally per its alignment, so centered paragraphs center ON the
793 // stored position and right-aligned ones end there; same vertically
794 // via the box's own vertical alignment. Monster deck fixture: the
795 // centered "DGLAP, ERBL" tag stores (888, 477) and Apple draws its
796 // box spanning 767..1016 x 456..507 — dead-center on the point;
797 // the left-aligned "Transverse" label anchors top-left as before.
798 const firstPara = d.text.paragraphs?.[0];
799 const hAlign = paraStyleOf(doc, firstPara?.pStyle)?.horizontalAlignment;
800 const tx = hAlign === "center" ? "-50%" : hAlign === "right" ? "-100%" : "0%";
801 const ty = d.verticalAlignment === "middle" ? "-50%" : d.verticalAlignment === "bottom" ? "-100%" : "0%";
802 if (tx !== "0%" || ty !== "0%") {
803 div.style.transform = `${div.style.transform ?? ""} translate(${tx}, ${ty})`.trim();
804 } else {
805 // Auto-sized boxes lay out at Apple's metrics; browser faces run
806 // wider, so a label Apple fits to the slide edge can spill past it
807 // ("James 3:13-18" bottom-right badges). The measurement pass
808 // clamps (offset-based, so only valid untransformed).
809 div.dataset.textFit = "edge-clamp";
810 }
811 } else {
812 applyTextFitMode(div, layer, d.textFit, d.verticalAlignment);
813 }
814 div.appendChild(layer);
815 } else div.textContent = "";
816 } else if (d.type === "shape") {
817 // A 0-height shape whose PATH carries a real natural height is a full
818 // box stored degenerate (proteger-les-donnees red banner: size 471x0,
819 // path 471x32) — adopt the path height so its white caption gets the
820 // band as its layout/fit box instead of spilling invisibly below it.
821 const naturalH = d.geometry.naturalSize?.height ?? 0;
822 const effH = h === 0 && d.geometry.path && naturalH > 1 ? naturalH : h;
823 if (effH !== h) div.style.height = `${effH}px`;
824 const svg = shapeSvg(d.geometry, w, effH, c.style);
825 div.appendChild(svg);
826 const layer = textLayer({ ...d, text: d.text, verticalAlignment: d.verticalAlignment, common: c }, doc, ctx);
827 if (layer) {
828 if (effH === 0) {
829 // 0-height shape carrying text (RIPE ea785d2e subtitle): the box is
830 // an anchor, not a clip — let the text flow down from it.
831 layer.style.bottom = "auto";
832 layer.style.height = "auto";
833 layer.style.overflow = "visible";
834 } else {
835 // Shapes keep their geometry: a shape never grows for its text, so
836 // "grow" degrades to the fixed-box tolerance mode.
837 applyTextFitMode(div, layer, d.textFit === "grow" ? undefined : d.textFit, d.verticalAlignment);
838 }
839 div.appendChild(layer);
840 }
841 } else if (d.type === "image") {
842 const img = imageEl(d.image.dataId, d.image.preferredFileName ?? d.image.fileName, ctx, d.image.preferredFileName, d.thumbnail);
843 const m = d.mask?.common;
844 if (m?.position && m.size && m.size.width > 0 && m.size.height > 0) {Three viewer fixes now. First check the marker fields in the model and the exact indent values:
grep -n "markerImage\|markerIndent\|markerKind" /Users/phebert/pnk/model/src/shared.ts | head; python3 -c "
import json
d = json.load(open('/private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/0d58.json'))
doc = d.get('document', d)
for i in (6, 22):
ps = doc['styles']['para'][i]
print(i, json.dumps(ps['list']))
"6 {"level": 0, "markerKind": "image", "markerImage": {"dataId": "5501", "fileName": "rightArrow-5501.png", "preferredFileName": "rightArrow.png"}, "markerFontName": "STIXGeneral-Regular", "markerScale": 0.3499999940395355}
22 {"level": 1, "markerKind": "image", "markerImage": {"dataId": "5501", "fileName": "rightArrow-5501.png", "preferredFileName": "rightArrow.png"}, "markerIndentPt": 48.0, "markerFontName": "STIXGeneral-Regular", "markerScale": 0.3499999940395355}grep -n "markerImage\|markerKind" /Users/phebert/pnk/model/src/shared.ts
grep -rn "markerKind\|ListFormat" /Users/phebert/pnk/model/src/*.ts | head; grep -rn "marker_image\|markerImage" /Users/phebert/pnk/crates/pnk2json/src/model.rs | head -3
/Users/phebert/pnk/model/src/primitives.ts:362: list?: ListFormat;
/Users/phebert/pnk/model/src/primitives.ts:415:export interface ListFormat {
/Users/phebert/pnk/model/src/primitives.ts:419: markerKind: "none" | "string" | "number" | "image";
/Users/phebert/pnk/model/src/primitives.ts:420: /** Literal marker text when markerKind = "string" (e.g. "•", "→"). */
/Users/phebert/pnk/model/src/primitives.ts:423: * Numbering scheme when markerKind = "number"
/Users/phebert/pnk/model/src/primitives.ts:429: /** Marker image when markerKind = "image". */
/Users/phebert/pnk/model/src/primitives.ts:432: * Number surround when markerKind = "number": "1." (period, the default —
/Users/phebert/pnk/crates/pnk2json/src/model.rs:433: pub marker_image: Option<MediaRef>,Model's ready (markerImage exists both sides) — the viewer just never renders it. Making all three fixes:
const hasMarker = !!list && hasContent && list.markerKind !== "none" &&
(list.markerKind === "string" ? !!list.markerText : list.markerKind === "number"); const hasMarker = !!list && hasContent && list.markerKind !== "none" &&
(list.markerKind === "string"
? !!list.markerText
: list.markerKind === "image"
? !!list.markerImage
: list.markerKind === "number");The file /Users/phebert/pnk/viewer/src/text.ts has been updated successfully. (file state is current in your context — no need to Read it back)
{
"file_path": "/Users/phebert/pnk/viewer/src/text.ts",
"offset": 220,
"limit": 18
}220 listState.lastKey = null;
221 if (style) applyParaStyle(el, style);
222 } else {
223 // numbering: the stored restart flag (surfaced as list.start on the
224 // paragraph's pooled style) resets the counter; otherwise numbering
225 // CONTINUES the counter for this key — even across intervening
226 // paragraphs or nested levels, which is Pages' own "continue from
227 // previous" semantics (G5: "Four (Numbered, continued)" resumes 4 after
228 // a nested run; "Restart One" carries start=1).
229 const key = `${list!.level}:${list!.markerKind}:${list!.markerKind === "number" ? list!.numberKind : list!.markerText}`;
230 const n = list!.start !== undefined
231 ? list!.start
232 : (listState.counters.get(key) ?? 0) + 1;
233 listState.counters.set(key, n);
234 listState.lastKey = key;
235 const markerText = list!.markerKind === "number"
236 ? numberMarker(n, list!.numberKind, list!.numberSurround)
237 : (list!.markerText ?? "•");{
"file_path": "/Users/phebert/pnk/viewer/src/text.ts",
"offset": 255,
"limit": 50
}255 if (!style?.leftIndentPt && list!.markerIndentPt && list!.markerIndentPt > 0) {
256 wrap.style.marginLeft = `${list!.markerIndentPt}px`;
257 }
258 const marker = document.createElement("span");
259 marker.className = "list-marker";
260 marker.textContent = markerText;
261 marker.style.minWidth = "18px";
262 // The marker inherits the first run's look (size + color): an unstyled
263 // span rendered 15px near-black bullets INVISIBLE on dark decks (RIPE
264 // slides 2/5: 28pt white body, default marker). Apple actually colors
265 // markers from the list style's own font_color/scale — not yet in the
266 // model (proposal sent) — so the run style is the faithful fallback.
267 // Style source: the first run with visible text (writers prepend empty
268 // runs whose styles carry no size — RIPE), preferring one that resolves
269 // to an explicit font size.
270 const runs = p.items.filter(
271 (it): it is TextRun => typeof it !== "string" && !("type" in it) && it.text.length > 0,
272 );
273 const runCs =
274 runs.map((r) => charStyleOf(doc, r.cStyle)).find((cs) => cs?.fontSizePt) ??
275 (runs.length ? charStyleOf(doc, runs[0].cStyle) : undefined);
276 if (runCs) applyCharStyle(marker, runCs);
277 marker.style.paddingRight = "0.3em"; // marker-to-text gap, scales with size
278 // The list style's OWN marker look wins over run inheritance when stored
279 // (ListFormat markerColor/markerFontName/markerScale — RIPE orange dots).
280 // markerScale multiplies the RUN size (LabelGeometry scale_with_text), so
281 // resolve to px against it — an em here would key off the wrapper's
282 // default 15px, not the paragraph's size.
283 if (list!.markerColor) marker.style.color = list!.markerColor;
284 if (list!.markerFontName) marker.style.fontFamily = `"${list!.markerFontName}", sans-serif`;
285 // Symbol-font markers (Wingdings/Webdings/Symbol) address glyphs through
286 // the U+F0xx private-use range; machines without the font draw tofu.
287 // Substitute the Unicode equivalent and let any real font draw it.
288 if (/^[-]$/.test(markerText)) {
289 const uni = PUA_MARKERS[`${(list!.markerFontName ?? "").replace(/\s+/g, "").toLowerCase()}:${markerText.charCodeAt(0) & 0xff}`];
290 marker.textContent = uni ?? "•";
291 marker.style.fontFamily = "";
292 }
293 if (list!.markerScale) {
294 const basePt = runCs?.fontSizePt;
295 marker.style.fontSize = basePt
296 ? `${basePt * list!.markerScale}px`
297 : `${list!.markerScale}em`;
298 }
299 if (list!.markerBaselineOffsetPt) marker.style.verticalAlign = `${list!.markerBaselineOffsetPt}px`;
300 wrap.appendChild(marker);
301 wrap.appendChild(el);
302 renderParagraphContent(el, p, doc, ctx, style?.dropCap);
303 return wrap;
304 } const marker = document.createElement("span");
marker.className = "list-marker";
marker.textContent = markerText;
marker.style.minWidth = "18px"; const marker = document.createElement("span");
marker.className = "list-marker";
if (list!.markerKind !== "image") marker.textContent = markerText;
marker.style.minWidth = "18px";The file /Users/phebert/pnk/viewer/src/text.ts has been updated successfully. (file state is current in your context — no need to Read it back)
if (list!.markerScale) {
const basePt = runCs?.fontSizePt;
marker.style.fontSize = basePt
? `${basePt * list!.markerScale}px`
: `${list!.markerScale}em`;
}
if (list!.markerBaselineOffsetPt) marker.style.verticalAlign = `${list!.markerBaselineOffsetPt}px`; if (list!.markerScale && list!.markerKind !== "image") {
const basePt = runCs?.fontSizePt;
marker.style.fontSize = basePt
? `${basePt * list!.markerScale}px`
: `${list!.markerScale}em`;
}
if (list!.markerBaselineOffsetPt) marker.style.verticalAlign = `${list!.markerBaselineOffsetPt}px`;
// Image marker (0d5851c0 rightArrow bullets): the PNG scales with the
// text like a glyph would — markerScale × the run's size (same
// scale_with_text rule as string markers), hung on the baseline.
if (list!.markerKind === "image" && list!.markerImage) {
const img = document.createElement("img");
img.src = ctx.url(list!.markerImage.dataId);
const basePt = runCs?.fontSizePt;
const hPx = (list!.markerScale ?? 0.5) * (basePt ?? 15);
img.style.height = `${hPx.toFixed(1)}px`;
img.style.width = "auto";
img.style.verticalAlign = "baseline";
marker.appendChild(img);
}The file /Users/phebert/pnk/viewer/src/text.ts has been updated successfully. (file state is current in your context — no need to Read it back)
const minScale = box.dataset.textFit === "shrink" ? 0.35 : 0.6;
inner.style.transform = "";
inner.style.width = ""; const minScale = box.dataset.textFit === "shrink" ? 0.35 : 0.6;
inner.style.transform = "";
inner.style.width = "";
// The inner is a flex ITEM (.drawable-text is the flex container for
// vertical alignment); its default flex-shrink:1 silently squashed the
// compensated >100% width back to the container's, so text wrapped at
// the ORIGINAL width and the scale left a right-side gap — centered
// lines drifted left by (1−s)/2·width (0d5851c0 slide 9 subtitle,
// ~45pt off-center at s=0.89).
inner.style.flex = "0 0 auto";The file /Users/phebert/pnk/viewer/src/drawables.ts has been updated successfully. (file state is current in your context — no need to Read it back)
// Zero-size textboxes (Keynote emits some badge labels at 0×0) carry
// their text unclipped: let the content size the box instead.
if (!c.size || (c.size.width === 0 && c.size.height === 0)) {
div.style.width = "auto";
div.style.height = "auto";
div.style.overflow = "visible";
layer.style.overflow = "visible";
layer.style.position = "relative";
layer.style.whiteSpace = "nowrap";
layer.style.width = "max-content"; // percentage of an auto box is meaningless
layer.style.height = "auto";
// A 0-size box is a point ANCHOR: text laid out in it overflows
// equally per its alignment, so centered paragraphs center ON the
// stored position and right-aligned ones end there; same vertically
// via the box's own vertical alignment. Monster deck fixture: the
// centered "DGLAP, ERBL" tag stores (888, 477) and Apple draws its
// box spanning 767..1016 x 456..507 — dead-center on the point;
// the left-aligned "Transverse" label anchors top-left as before.
const firstPara = d.text.paragraphs?.[0];
const hAlign = paraStyleOf(doc, firstPara?.pStyle)?.horizontalAlignment;
const tx = hAlign === "center" ? "-50%" : hAlign === "right" ? "-100%" : "0%";
const ty = d.verticalAlignment === "middle" ? "-50%" : d.verticalAlignment === "bottom" ? "-100%" : "0%";
if (tx !== "0%" || ty !== "0%") {
div.style.transform = `${div.style.transform ?? ""} translate(${tx}, ${ty})`.trim();
} else {
// Auto-sized boxes lay out at Apple's metrics; browser faces run
// wider, so a label Apple fits to the slide edge can spill past it
// ("James 3:13-18" bottom-right badges). The measurement pass
// clamps (offset-based, so only valid untransformed).
div.dataset.textFit = "edge-clamp";
}
} else { // Zero-size textboxes (Keynote emits some badge labels at 0×0) carry
// their text unclipped: let the content size the box instead.
if (!c.size || (c.size.width === 0 && c.size.height === 0)) {
anchorZeroSizeText(div, layer, d.text, d.verticalAlignment, doc);
} else {The file /Users/phebert/pnk/viewer/src/drawables.ts has been updated successfully. (file state is current in your context — no need to Read it back)
const layer = textLayer({ ...d, text: d.text, verticalAlignment: d.verticalAlignment, common: c }, doc, ctx);
if (layer) {
if (effH === 0) {
// 0-height shape carrying text (RIPE ea785d2e subtitle): the box is
// an anchor, not a clip — let the text flow down from it.
layer.style.bottom = "auto";
layer.style.height = "auto";
layer.style.overflow = "visible";
} else { const layer = textLayer({ ...d, text: d.text, verticalAlignment: d.verticalAlignment, common: c }, doc, ctx);
if (layer) {
if (w === 0 && effH === 0) {
// 0×0 shape carrying text: a point anchor exactly like the 0×0
// textbox labels (0d5851c0 slide 29's 51pt quote — Apple lays it
// out natural-width from the anchor; our 0-width box wrapped it
// into a 4-line sliver).
anchorZeroSizeText(div, layer, d.text, d.verticalAlignment, doc);
} else if (effH === 0) {
// 0-height shape carrying text (RIPE ea785d2e subtitle): the box is
// an anchor, not a clip — let the text flow down from it.
layer.style.bottom = "auto";
layer.style.height = "auto";
layer.style.overflow = "visible";
} else {The file /Users/phebert/pnk/viewer/src/drawables.ts has been updated successfully. (file state is current in your context — no need to Read it back)
/** Position + rotate + opacity from DrawableCommon, 1pt = 1px. */
export function applyCommonGeometry(div: HTMLElement, c: DrawableCommon): void {/**
* A 0-size box is a point ANCHOR: text laid out in it overflows per its
* alignment, so centered paragraphs center ON the stored position and
* right-aligned ones end there; same vertically via the box's own vertical
* alignment. Monster deck fixture: the centered "DGLAP, ERBL" tag stores
* (888, 477) and Apple draws its box spanning 767..1016 x 456..507 —
* dead-center on the point; the left-aligned "Transverse" label anchors
* top-left as before. Applies to 0×0 textboxes and 0×0 shapes alike.
*/
function anchorZeroSizeText(
div: HTMLElement,
layer: HTMLElement,
text: { paragraphs?: Paragraph[] },
verticalAlignment: string | undefined,
doc: HydratedDoc,
): void {
div.style.width = "auto";
div.style.height = "auto";
div.style.overflow = "visible";
layer.style.overflow = "visible";
layer.style.position = "relative";
layer.style.whiteSpace = "nowrap";
layer.style.width = "max-content"; // percentage of an auto box is meaningless
layer.style.height = "auto";
const firstPara = text.paragraphs?.find((p) => typeof p !== "string" && p.items.length > 0) ?? text.paragraphs?.[0];
const hAlign = paraStyleOf(doc, typeof firstPara === "string" ? undefined : firstPara?.pStyle)?.horizontalAlignment;
const tx = hAlign === "center" ? "-50%" : hAlign === "right" ? "-100%" : "0%";
const ty = verticalAlignment === "middle" ? "-50%" : verticalAlignment === "bottom" ? "-100%" : "0%";
if (tx !== "0%" || ty !== "0%") {
div.style.transform = `${div.style.transform ?? ""} translate(${tx}, ${ty})`.trim();
} else {
// Auto-sized boxes lay out at Apple's metrics; browser faces run
// wider, so a label Apple fits to the slide edge can spill past it
// ("James 3:13-18" bottom-right badges). The measurement pass
// clamps (offset-based, so only valid untransformed).
div.dataset.textFit = "edge-clamp";
}
}
/** Position + rotate + opacity from DrawableCommon, 1pt = 1px. */
export function applyCommonGeometry(div: HTMLElement, c: DrawableCommon): void {The file /Users/phebert/pnk/viewer/src/drawables.ts has been updated successfully. (file state is current in your context — no need to Read it back)
cd /Users/phebert/pnk/viewer && npx tsc --noEmit 2>&1 | head; bash scripts/build_viewer.sh 2>&1 | tail -1
src/drawables.ts(708,24): error TS2304: Cannot find name 'Paragraph'.
src/drawables.ts(852,40): error TS2345: Argument of type 'StyledText | undefined' is not assignable to parameter of type '{ paragraphs?: Paragraph[] | undefined; }'.
Type 'undefined' is not assignable to type '{ paragraphs?: Paragraph[] | undefined; }'.
src/text.ts(305,7): error TS2322: Type 'string | undefined' is not assignable to type 'string'.
Type 'undefined' is not assignable to type 'string'.
bash: scripts/build_viewer.sh: No such file or directorygrep -n "^import\|from \"../../model" /Users/phebert/pnk/viewer/src/drawables.ts | head -8; sed -n '300,310p' /Users/phebert/pnk/viewer/src/text.ts; sed -n '848,856p' /Users/phebert/pnk/viewer/src/drawables.ts
11:import type {
20:} from "../../model/src/shared";
21:import type { ViewerCtx } from "./ctx";
22:import { renderTable } from "./tables";
23:import { renderStyledText } from "./text";
24:import { paraStyleOf, type HydratedDoc } from "./hydrate";
// Image marker (0d5851c0 rightArrow bullets): the PNG scales with the
// text like a glyph would — markerScale × the run's size (same
// scale_with_text rule as string markers), hung on the baseline.
if (list!.markerKind === "image" && list!.markerImage) {
const img = document.createElement("img");
img.src = ctx.url(list!.markerImage.dataId);
const basePt = runCs?.fontSizePt;
const hPx = (list!.markerScale ?? 0.5) * (basePt ?? 15);
img.style.height = `${hPx.toFixed(1)}px`;
img.style.width = "auto";
img.style.verticalAlign = "baseline";
// 0×0 shape carrying text: a point anchor exactly like the 0×0
// textbox labels (0d5851c0 slide 29's 51pt quote — Apple lays it
// out natural-width from the anchor; our 0-width box wrapped it
// into a 4-line sliver).
anchorZeroSizeText(div, layer, d.text, d.verticalAlignment, doc);
} else if (effH === 0) {
// 0-height shape carrying text (RIPE ea785d2e subtitle): the box is
// an anchor, not a clip — let the text flow down from it.
layer.style.bottom = "auto";sed -n '11,20p' /Users/phebert/pnk/viewer/src/drawables.ts; grep -n "url(" /Users/phebert/pnk/viewer/src/ctx.ts | head -3; grep -n "url" /Users/phebert/pnk/viewer/src/ctx.ts | head -6import type {
ChartModel,
CurveElement,
Drawable,
DrawableCommon,
Fill,
LineEnd,
ShapeGeometry,
Stroke,
} from "../../model/src/shared";
32: /** Register bytes for a DataInfo id; later `url()` calls hand back a blob URL. */
43: url(dataId: string): string | undefined {
30: private urls = new Map<string, string>();
32: /** Register bytes for a DataInfo id; later `url()` calls hand back a blob URL. */
40: this.urls.set(dataId, URL.createObjectURL(blob));
43: url(dataId: string): string | undefined {
44: return this.urls.get(dataId);
49: for (const u of this.urls.values()) URL.revokeObjectURL(u);ShapeGeometry, Stroke, } from "../../model/src/shared";
ShapeGeometry, Stroke, StyledText, } from "../../model/src/shared";
The file /Users/phebert/pnk/viewer/src/drawables.ts has been updated successfully. (file state is current in your context — no need to Read it back)
function anchorZeroSizeText(
div: HTMLElement,
layer: HTMLElement,
text: { paragraphs?: Paragraph[] },
verticalAlignment: string | undefined,
doc: HydratedDoc,
): void {function anchorZeroSizeText(
div: HTMLElement,
layer: HTMLElement,
text: StyledText | undefined,
verticalAlignment: string | undefined,
doc: HydratedDoc,
): void {The file /Users/phebert/pnk/viewer/src/drawables.ts has been updated successfully. (file state is current in your context — no need to Read it back)
const firstPara = text.paragraphs?.find((p) => typeof p !== "string" && p.items.length > 0) ?? text.paragraphs?.[0]; const hAlign = paraStyleOf(doc, typeof firstPara === "string" ? undefined : firstPara?.pStyle)?.horizontalAlignment;
const paras = text?.paragraphs; const firstPara = paras?.find((p) => typeof p !== "string" && p.items.length > 0) ?? paras?.[0]; const hAlign = paraStyleOf(doc, typeof firstPara === "string" ? undefined : firstPara?.pStyle)?.horizontalAlignment;
The file /Users/phebert/pnk/viewer/src/drawables.ts has been updated successfully. (file state is current in your context — no need to Read it back)
if (list!.markerKind === "image" && list!.markerImage) {
const img = document.createElement("img");
img.src = ctx.url(list!.markerImage.dataId);
const basePt = runCs?.fontSizePt;
const hPx = (list!.markerScale ?? 0.5) * (basePt ?? 15);
img.style.height = `${hPx.toFixed(1)}px`;
img.style.width = "auto";
img.style.verticalAlign = "baseline";
marker.appendChild(img);
} if (list!.markerKind === "image" && list!.markerImage) {
const url = ctx.url(list!.markerImage.dataId);
if (url) {
const img = document.createElement("img");
img.src = url;
const basePt = runCs?.fontSizePt;
const hPx = (list!.markerScale ?? 0.5) * (basePt ?? 15);
img.style.height = `${hPx.toFixed(1)}px`;
img.style.width = "auto";
img.style.verticalAlign = "baseline";
marker.appendChild(img);
} else marker.textContent = "•"; // media bytes missing: glyph fallback
}The file /Users/phebert/pnk/viewer/src/text.ts has been updated successfully. (file state is current in your context — no need to Read it back)
cd /Users/phebert/pnk && bash scripts/build_viewer.sh 2>&1 | tail -1 && cd viewer && npx tsc --noEmit && echo TSC-OK
cd /Users/phebert/pnk && uv run --with pillow --with pyobjc-framework-Quartz --with pymupdf python3 scripts/visual_diff.py --app keynote --fixture ~/Development/pnk-fixtures/crawl/0d5851c0105958b602c58646b98ffaee1b453def8478f9b872aca37319d2ed5f.key --out /private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/vd-0d58b --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-0d58b/summary.md
{
"file_path": "/private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/vd-0d58b/composites/composite-page-1.png"
}{
"file_path": "/private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/vd-0d58b/composites/composite-page-2.png"
}{
"file_path": "/private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/vd-0d58b/composites/composite-page-9.png"
}{
"file_path": "/private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/vd-0d58b/composites/composite-page-29.png"
}{
"file_path": "/private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/vd-0d58b/composites/composite-page-19.png"
}Slide 19's arrows are in; the mono block runs ~6% loose. Let me check what spacing those paragraphs actually specify:
python3 -c "
import json
d = json.load(open('/private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/0d58.json'))
doc = d.get('document', d)
sl = doc['slides'][18]
seen = set()
for dr in sl.get('drawables', []):
if dr.get('text'):
for p in dr['text'].get('paragraphs', []):
if isinstance(p, dict) and p.get('pStyle') not in seen:
seen.add(p.get('pStyle'))
txt = [(it if isinstance(it,str) else it.get('text',''))[:25] for it in p.get('items',[]) if isinstance(it,(str,dict))][:1]
ps = doc['styles']['para'][p['pStyle']] if p.get('pStyle') is not None else {}
cs = None
for it in p.get('items', []):
if isinstance(it, dict) and it.get('cStyle') is not None:
cs = doc['styles']['char'][it['cStyle']]; break
print(p.get('pStyle'), txt, '| spacing:', {k:v for k,v in ps.items() if 'pacing' in k or 'pace' in k}, '| font:', (cs or {}).get('fontName'), (cs or {}).get('fontSizePt'))
"5 ['Spamhaus XBL'] | spacing: {} | font: AppleSDGothicNeo-Bold 52.0
6 ['Detailed exploit intellig'] | spacing: {'spaceBeforePt': 18.0, 'lineSpacingExactPt': 40.0} | font: AppleSDGothicNeo-Regular 32.0
23 ['188.51.94.89, '] | spacing: {'spaceBeforePt': 18.0, 'lineSpacingExactPt': 40.0} | font: CourierNewPSMT 28.0
24 ['AS25019, '] | spacing: {'lineSpacingExactPt': 40.0} | font: CourierNewPSMT 28.0
0 [''] | spacing: {} | font: None NoneSpecs say exactly 40px pitch — let me measure the live DOM to see what we actually produce:
cd /Users/phebert/pnk/viewer && cat > probe-tmp.mjs <<'EOF'
import { chromium } from "playwright";
const fixture = "/Users/phebert/Development/pnk-fixtures/crawl/0d5851c0105958b602c58646b98ffaee1b453def8478f9b872aca37319d2ed5f.key";
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", fixture);
await page.waitForSelector(".slide-list-item", { timeout: 30000 });
await page.locator(".slide-list-item").nth(18).click();
await page.waitForTimeout(600);
const info = await page.evaluate(() => {
const stage = document.querySelector(".slide-stage");
const canvas = stage.querySelector(".canvas-inner");
const cr = canvas.getBoundingClientRect();
const scale = cr.width / 1024;
const ps = [...canvas.querySelectorAll(".drawable-text p, .drawable-text .list-item")];
return ps.map((p) => {
const r = p.getBoundingClientRect();
const cs = getComputedStyle(p.tagName === "P" ? p : p.querySelector("p") ?? p);
return {
t: (p.textContent || "").trim().slice(0, 22),
yDoc: +((r.y - cr.y) / scale).toFixed(1),
hDoc: +(r.height / scale).toFixed(1),
lh: cs.lineHeight, fs: cs.fontSize, mt: cs.marginTop,
};
});
});
console.log(JSON.stringify(info, null, 0).replace(/\},/g, "},\n"));
await b.close();
EOF
node probe-tmp.mjs[{"t":"Spamhaus XBL","yDoc":162.8,"hDoc":66,"lh":"normal","fs":"52px","mt":"0px"},
{"t":"Detailed exploit intel","yDoc":264,"hDoc":40.7,"lh":"40px","fs":"32px","mt":"0px"},
{"t":"Detailed exploit intel","yDoc":264,"hDoc":40.7,"lh":"40px","fs":"32px","mt":"0px"},
{"t":"188.51.94.89, IP DETEC","yDoc":321.7,"hDoc":41.7,"lh":"40px","fs":"28px","mt":"18px"},
{"t":"AS25019, ASN","yDoc":363.4,"hDoc":41.7,"lh":"40px","fs":"28px","mt":"0px"},
{"t":"188.51.64.0/19, IP RAN","yDoc":405.1,"hDoc":41.7,"lh":"40px","fs":"28px","mt":"0px"},
{"t":"SA, COUNTRY","yDoc":446.7,"hDoc":41.7,"lh":"40px","fs":"28px","mt":"0px"},
{"t":"saudi.net.sa, ISP/NETW","yDoc":488.4,"hDoc":41.7,"lh":"40px","fs":"28px","mt":"0px"},
{"t":", CONTACT","yDoc":530.1,"hDoc":41.7,"lh":"40px","fs":"28px","mt":"0px"},
{"t":"1426757771, TIMESTAMP ","yDoc":571.7,"hDoc":41.7,"lh":"40px","fs":"28px","mt":"0px"},
{"t":"BOT type 9000 s_zeus 8","yDoc":613.4,"hDoc":40.7,"lh":"40px","fs":"22px","mt":"0px"},
{"t":"BOT NAME ","yDoc":654.1,"hDoc":38.8,"lh":"40px","fs":"20px","mt":"0px"}]Found the issue: baseline-shifted red label spans are inflating row height from 40px to 41.7px. I'll fix this by setting line-height: 0 on those shifted spans so they stop pushing the line box.
if (cs.baseline === "superscript") s.verticalAlign = "super";
else if (cs.baseline === "subscript") s.verticalAlign = "sub";
if (cs.baselineShiftPt) s.verticalAlign = `${cs.baselineShiftPt}px`; if (cs.baseline === "superscript") s.verticalAlign = "super";
else if (cs.baseline === "subscript") s.verticalAlign = "sub";
if (cs.baselineShiftPt) s.verticalAlign = `${cs.baselineShiftPt}px`;
// A shifted run must not grow the LINE box (Apple's baseline shifts never
// change line spacing; 0d5851c0 slide 19's raised red labels stretched
// 40px-exact rows to 41.7px — visibly loose over 8 rows). line-height: 0
// removes the shifted inline box from line-height calculation.
if (cs.baseline === "superscript" || cs.baseline === "subscript" || cs.baselineShiftPt) s.lineHeight = "0";The file /Users/phebert/pnk/viewer/src/text.ts has been updated successfully. (file state is current in your context — no need to Read it back)
1 // Keynote (.key): slide list + per-slide canvas + presenter notes.
2
3 import type { Fill } from "../../model/src/shared";
4 import type { Drawable, DrawableCommon, KeynoteDocument, Slide } from "../../model/src/keynote";
5 import type { ViewerCtx } from "./ctx";
6 import { applyTextFit, fillToCss, renderCanvasDrawable } from "./drawables";
7 import { renderStyledText } from "./text";
8 import type { HydratedDoc } from "./hydrate";
9
10 const THUMB_WIDTH = 168;
11
12 function buildCanvas(
13 slide: Slide,
14 doc: KeynoteDocument,
15 hdoc: HydratedDoc,
16 ctx: ViewerCtx,
17 widthPx: number,
18 slideNumber: number,
19 ): HTMLElement {
20 const { width, height } = doc.slideSize;
21 const scale = widthPx / width;
22
23 const frame = document.createElement("div");
24 frame.className = "canvas-frame";
25 frame.style.aspectRatio = `${width} / ${height}`;
26
27 const inner = document.createElement("div");
28 inner.className = "canvas-inner";
29 inner.style.width = `${width}px`;
30 inner.style.height = `${height}px`;
31 inner.style.transform = `scale(${scale})`;
32
33 // Resolved-inheritance contract (docs/model-review.md §3b): the converter
34 // emits Slide.background already master-resolved and masterDrawables as the
35 // filtered underlay — paint background, underlay, drawables, verbatim.
36 applyStageBackground(inner, slide.background ?? null, ctx);
37 for (const d of slide.masterDrawables ?? []) inner.appendChild(renderCanvasDrawable(d, hdoc, ctx));
38 for (const d of slide.drawables) {
39 // The slide-number placeholder paints only when the slide shows numbers
40 // (Apple hides it otherwise); its page-number field bakes to the real
41 // index so it reads "2", not a field label.
42 if (roleOf(d) === "slide-number") {
43 if (!slide.slideNumberVisible) continue;
44 inner.appendChild(renderCanvasDrawable(bakePageNumber(d, slideNumber), hdoc, ctx));
45 continue;
46 }
47 // Empty placeholders are editor chrome: Keynote's own export paints
48 // nothing for them (their theme para styles can carry stray borders).
49 if (roleOf(d) && !hasVisibleText(d)) continue;
50 inner.appendChild(renderCanvasDrawable(d, hdoc, ctx));
51 }
52
53 frame.appendChild(inner);
54 return frame;
55 }
56
57 // -- slide background -------------------------------------------------------
58
59 /** Image extensions <img> cannot rasterize (Apple renders them natively). */
60 const VECTOR_FILL = /\.(pdf|ai|eps)$/i;
61
62 /**
63 * Best-effort CSS gradient from PDF-based vector art (.ai/.pdf background
64 * fills). Modern .ai files are PDF-compatible, and their shading DICTS are
65 * plain text even when content streams are Flate-compressed: an axial
66 * gradient carries `/ShadingType 2` with `/C0 [...]` / `/C1 [...]` function
67 * endpoints (PDF 32000-1 §8.7.4.5.3). The axis direction lives in the
68 * compressed stream's transform, so we assume top-to-bottom — the common
69 * orientation for slide backdrops [inferred]. Returns null when no shading
70 * is found (fully-compressed or raster-only art).
71 */
72 async function vectorArtGradientCss(url: string): Promise<string | null> {
73 try {
74 const buf = await (await fetch(url)).arrayBuffer();
75 const text = new TextDecoder("latin1").decode(buf);
76 if (!/\/ShadingType\s*2/.test(text)) return null;
77 const comp = (name: string): number[] | null => {
78 const m = text.match(new RegExp(`\\/${name}\\s*\\[([^\\]]*)\\]`));
79 if (!m) return null;
80 const nums = m[1].trim().split(/\s+/).map(Number).filter((n) => !Number.isNaN(n));
81 return nums.length ? nums : null;
82 };
83 const rgb = (c: number[]): string | null => {
84 const to255 = (v: number) => Math.round(Math.min(1, Math.max(0, v)) * 255);
85 if (c.length === 3) return `rgb(${to255(c[0])},${to255(c[1])},${to255(c[2])})`;
86 if (c.length === 1) return `rgb(${to255(c[0])},${to255(c[0])},${to255(c[0])})`;
87 if (c.length === 4) {
88 // DeviceCMYK -> naive RGB
89 const [cy, mg, ye, k] = c;
90 return `rgb(${to255((1 - cy) * (1 - k))},${to255((1 - mg) * (1 - k))},${to255((1 - ye) * (1 - k))})`;
91 }
92 return null;
93 };
94 const c0 = comp("C0"), c1 = comp("C1");
95 if (!c0 || !c1) return null;
96 const a = rgb(c0), b = rgb(c1);
97 return a && b ? `linear-gradient(180deg, ${a}, ${b})` : null;
98 } catch {
99 return null;
100 }
101 }
102
103 /**
104 * Second-chance backdrop sniff for vector art with no axial shading: inflate
105 * the PDF's FlateDecode content streams (DecompressionStream "deflate" — PDF
106 * streams are zlib-wrapped) and take the FIRST 3-component fill operator
107 * (`r g b rg|sc|scn`) in a stream that draws paths — slide backdrops open
108 * with a full-page fill (RIPE ea785d2e: `0.224 0.227 0.239 scn` charcoal
109 * before the polygon texture) [inferred: single-page .ai backdrop layout].
110 */
111 async function vectorArtFirstFillCss(url: string): Promise<string | null> {
112 try {
113 const bytes = new Uint8Array(await (await fetch(url)).arrayBuffer());
114 const text = new TextDecoder("latin1").decode(bytes); // 1:1 byte mapping
115 // Match `stream` but not `endstream` (Illustrator uses \r EOLs).
116 const streamRe = /(^|[^d])stream\r?\n?/g;
117 let mm: RegExpExecArray | null;
118 let tries = 0;
119 while ((mm = streamRe.exec(text)) && tries < 40) {
120 const start = mm.index + mm[0].length;
121 const end = text.indexOf("endstream", start);
122 if (end < 0) break;
123 tries++;
124 let content: string | null = null;
125 // zlib CMF: compression method nibble 8 (0x78 AND Illustrator's 0x48).
126 if ((bytes[start] & 0x0f) === 8) {
127 const chunks: number[] = [];
128 try {
129 const ds = new DecompressionStream("deflate");
130 const reader = new Blob([bytes.slice(start, end)]).stream().pipeThrough(ds).getReader();
131 // Tolerant read: PDF streams may carry trailing EOL junk that makes
132 // DecompressionStream throw at the very end — keep what inflated.
133 for (;;) {
134 const r = await reader.read();
135 if (r.done) break;
136 for (const b of r.value) chunks.push(b);
137 }
138 } catch { /* partial output retained */ }
139 if (chunks.length) content = new TextDecoder("latin1").decode(new Uint8Array(chunks));
140 }
141 if (content === null) content = text.slice(start, end); // plain-text form streams
142 if (!/\d\s+[ml]\b/.test(content)) continue; // no path ops: not page content
143 const m = content.match(/([\d.]+)\s+([\d.]+)\s+([\d.]+)\s+(?:rg|scn|sc)\b/);
144 if (!m) continue;
145 const to255 = (v: string) => Math.round(Math.min(1, Math.max(0, Number(v))) * 255);
146 return `rgb(${to255(m[1])},${to255(m[2])},${to255(m[3])})`;
147 }
148 return null;
149 } catch {
150 return null;
151 }
152 }
153
154 /** A stage-renderable image fill, or null (missing bytes / vector art). */
155 function renderableImageFill(
156 f: Fill | null,
157 ctx: ViewerCtx,
158 ): { url: string; objectFit: string } | null {
159 if (!f || f.type !== "image") return null;
160 const name = f.image.preferredFileName ?? f.image.fileName ?? "";
161 const url = ctx.url(f.image.dataId);
162 if (!url || VECTOR_FILL.test(name)) return null;
163 const objectFit =
164 f.technique === "scale-to-fill" ? "cover"
165 : f.technique === "scale-to-fit" ? "contain"
166 : "fill";
167 return { url, objectFit };
168 }
169
170 /**
171 * Paint the stage background (the fill arrives master-resolved from the
172 * converter, model-review §3b). A CSS solid/gradient fill paints directly;
173 * a raster image fill paints via <img>; an unrenderable image fill (vector
174 * art / theme assets whose bytes .key files do not ship) degrades to a
175 * name-keyed approximation, upgraded async by PDF shading-dict sniffing.
176 */
177 function applyStageBackground(
178 inner: HTMLElement,
179 slideFill: Fill | null,
180 ctx: ViewerCtx,
181 ): void {
182 const img = renderableImageFill(slideFill, ctx);
183 if (img) {
184 const imgEl = document.createElement("img");
185 imgEl.src = img.url;
186 imgEl.alt = "slide background";
187 imgEl.style.position = "absolute";
188 imgEl.style.inset = "0";
189 imgEl.style.width = "100%";
190 imgEl.style.height = "100%";
191 imgEl.style.objectFit = img.objectFit;
192 inner.appendChild(imgEl);
193 return;
194 }
195 const css = slideFill && slideFill.type !== "image" ? fillToCss(slideFill) : undefined;
196 if (css) {
197 inner.style.background = css;
198 return;
199 }
200 if (slideFill?.type === "image") {
201 // Built-in theme art is not shipped in .key files (DataInfo names
202 // survive, bytes don't); approximate the known Keynote backdrop assets
203 // by name, else leave white.
204 const name = slideFill.image.preferredFileName ?? slideFill.image.fileName ?? "";
205 const approx = THEME_BACKDROP_APPROX[name.toLowerCase()];
206 if (approx) inner.style.background = approx;
207 // Vector art (.ai/.pdf) backgrounds whose bytes DID ship: sniff an axial
208 // shading gradient out of the PDF dictionaries and paint it (async —
209 // upgrades the backdrop as soon as the bytes parse).
210 const url = ctx.url(slideFill.image.dataId);
211 if (url && VECTOR_FILL.test(name)) {
212 void vectorArtGradientCss(url).then(async (grad) => {
213 const css = grad ?? (await vectorArtFirstFillCss(url));
214 if (css) inner.style.background = css;
215 });
216 }
217 }
218 }
219
220 /**
221 * Approximations for Apple-shipped theme backdrop assets whose bytes .key
222 * files omit (Keynote renders them from its app-bundled theme store).
223 */
224 const THEME_BACKDROP_APPROX: Record<string, string> = {
225 // Keynote default template spotlight backdrop (a9d4f68c "Home" deck).
226 "spotlight_10x7.jpeg":
227 "radial-gradient(ellipse 95% 80% at 50% 32%, #8f8f8f 0%, #6d6d6d 38%, #4b4b4b 68%, #3a3a3a 100%)",
228 // News/photo-deck cream paper texture (5089b9c7 deck).
229 "cotton_paper_hd.jpeg": "radial-gradient(ellipse 120% 100% at 50% 40%, #f7f3e8 0%, #efe9d8 60%, #e7dfc9 100%)",
230 // Keynote '09 "Showroom" theme backdrop: light-gray studio-sweep gradient
231 // (0f9df553 / b52c89c1 decks reference the jpeg, bytes not shipped).
232 "showroom2_1024x768.jpeg":
233 "linear-gradient(180deg, #e9eaec 0%, #dcdee1 45%, #c9cbd0 75%, #babcc2 100%)",
234 "showroom_1024x768.jpeg":
235 "linear-gradient(180deg, #e9eaec 0%, #dcdee1 45%, #c9cbd0 75%, #babcc2 100%)",
236 "showroom_1024x768-1.jpeg":
237 "linear-gradient(180deg, #e9eaec 0%, #dcdee1 45%, #c9cbd0 75%, #babcc2 100%)",
238 };
239
240 /** Placeholder role of a drawable, when it has one. Master-underlay
241 * filtering itself lives in the converter now (model-review §3b). */
242 function roleOf(d: Drawable): string | null {
243 const c = "common" in d && d.common ? (d.common as DrawableCommon) : null;
244 return c?.placeholder?.role ?? null;
245 }
246
247 /** Replace page-number fields with the slide's real number, inheriting the
248 * char style of a neighboring styled run (Keynote pairs the field with an
249 * empty styled run carrying its look). */
250 function bakePageNumber(d: Drawable, n: number): Drawable {
251 if (!("text" in d) || !d.text) return d;
252 let changed = false;
253 const paragraphs = d.text.paragraphs.map((p) => {
254 const cStyle = p.items.reduce<number | undefined>(
255 (acc, it) =>
256 acc ?? (typeof it === "object" && it && "cStyle" in it ? (it as { cStyle?: number }).cStyle : undefined),
257 undefined,
258 );
259 const items = p.items.map((it) => {
260 const isPageField =
261 typeof it === "object" && it !== null && "type" in it
262 && (it as { type?: string }).type === "field"
263 && (it as { field?: { kind?: string } }).field?.kind === "page-number";
264 if (!isPageField) return it;
265 changed = true;
266 return cStyle === undefined ? { text: String(n) } : { text: String(n), cStyle };
267 });
268 return { ...p, items };
269 });
270 return changed ? ({ ...d, text: { ...d.text, paragraphs } } as Drawable) : d;
271 }
272
273 /** True when the drawable carries at least one non-whitespace text run. */
274 function hasVisibleText(d: Drawable): boolean {
275 if (!("text" in d) || !d.text) return false;
276 for (const p of d.text.paragraphs) {
277 for (const item of p.items) {
278 if (typeof item === "string") {
279 if (item.trim()) return true;
280 } else if ("text" in item && typeof item.text === "string" && item.text.trim()) {
281 return true;
282 } else if ("type" in item && item.type === "field") {
283 return true; // fields (page number etc.) render content
284 }
285 }
286 }
287 return false;
288 }
289
290 function renderStage(
291 doc: KeynoteDocument,
292 hdoc: HydratedDoc,
293 ctx: ViewerCtx,
294 slide: Slide,
295 index: number,
296 widthPx: number,
297 ): HTMLElement {
298 const stage = document.createElement("div");
299 stage.className = "slide-stage";
300
301 const frame = buildCanvas(slide, doc, hdoc, ctx, widthPx, index + 1);
302 frame.dataset.slideIndex = String(index);
303 stage.appendChild(frame);
304
305 const caption = document.createElement("div");
306 caption.className = "slide-caption muted";
307 const bits = [`Slide ${index + 1}${slide.name ? ` — ${slide.name}` : ""}`];
308 if (slide.masterName) bits.push(`master: ${slide.masterName}`);
309 if (slide.transition?.effect) bits.push(`transition: ${slide.transition.effect}`);
310 if (slide.skipped) bits.push("skipped");
311 caption.textContent = bits.join(" · ");
312 stage.appendChild(caption);
313
314 const notes = document.createElement("div");
315 notes.className = "notes-panel";
316 notes.dataset.hasNotes = slide.notes ? "true" : "false";
317 const h = document.createElement("h3");
318 h.textContent = "Presenter notes";
319 notes.appendChild(h);
320 notes.appendChild(slide.notes
321 ? renderStyledText(slide.notes, hdoc, ctx)
322 : Object.assign(document.createElement("p"), { textContent: "No notes on this slide.", className: "muted" }));
323 stage.appendChild(notes);
324
325 // Corner badge only when no slide-number placeholder painted the number
326 // on the canvas itself.
327 if (slide.slideNumberVisible && !slide.drawables.some((d) => roleOf(d) === "slide-number")) {
328 const num = document.createElement("div");
329 num.className = "slide-number";
330 num.textContent = String(index + 1);
331 stage.appendChild(num);
332 }
333 return stage;
334 }
335
336 export function renderKeynote(
337 doc: KeynoteDocument,
338 hdoc: HydratedDoc,
339 ctx: ViewerCtx,
340 mount: HTMLElement,
341 ): void {
342 const view = document.createElement("div");
343 view.id = "keynote-view";
344
345 const list = document.createElement("div");
346 list.className = "slide-list";
347
348 const stageSlot = document.createElement("div");
349 stageSlot.className = "slide-stage-slot";
350
351 let active = doc.slides.findIndex((s) => !s.skipped);
352 if (active < 0) active = 0;
353
354 const activate = (index: number) => {
355 stageSlot.replaceChildren();
356 stageSlot.appendChild(renderStage(doc, hdoc, ctx, doc.slides[index], index, stageSlot.clientWidth || 800));
357 // Shrink-to-fit measurement pass needs the stage attached and laid out.
358 applyTextFit(stageSlot);
359 for (const item of list.children) {
360 item.classList.toggle("active", (item as HTMLElement).dataset.slideIndex === String(index));
361 }
362 };
363
364 doc.slides.forEach((slide, i) => {
365 const item = document.createElement("div");
366 item.className = "slide-list-item";
367 item.dataset.slideIndex = String(i);
368 item.appendChild(buildCanvas(slide, doc, hdoc, ctx, THUMB_WIDTH, i + 1));
369 const label = document.createElement("span");
370 label.className = "label";
371 label.textContent = `${i + 1}${slide.name ? ` · ${slide.name}` : ""}${slide.skipped ? " (skipped)" : ""}`;
372 item.appendChild(label);
373 item.addEventListener("click", () => activate(i));
374 list.appendChild(item);
375 });
376
377 view.appendChild(list);
378 view.appendChild(stageSlot);
379 mount.appendChild(view);
380 activate(active);
381 // Thumbnails were built detached; measure their shrink boxes now that the
382 // whole view is attached.
383 applyTextFit(list);
384 }I'll restructure the UI from one-stage-at-a-time to a lazily-rendered continuous scroll using IntersectionObserver (to keep the 645-slide deck performant), add a compact notes strip when present, keep the thumbnail rail for navigation, and fix its clipping issue. Starting by checking the thumbnail CSS to locate the truncation.
grep -n "canvas-frame\|canvas-inner\|slide-list\|slide-stage\|notes-panel\|keynote-view\|slide-caption\|slide-number" /Users/phebert/pnk/viewer/styles.css
15: (.canvas-frame/.canvas-inner/.sheet-* keep their explicit whites). */
29: body .sheet-area, body .pages-flow, body .notes-panel, body .error-card { color: #1d1d1f; }
189:.canvas-frame { /* scaled wrapper: aspect kept via padding trick below */
196:.canvas-inner { position: absolute; top: 0; left: 0; transform-origin: top left; background: #fff; }
217:#keynote-view { display: flex; gap: 18px; align-items: flex-start; }
218:.slide-stage-slot { flex: 1; min-width: 0; }
219:.slide-list {
224:.slide-list-item {
228:.slide-list-item:hover { background: var(--surface); }
229:.slide-list-item.active { border-color: var(--accent); }
230:.slide-list-item .canvas-frame { position: relative; }
231:.slide-list-item .label { display: block; margin-top: 4px; color: var(--muted); }
232:.slide-stage { flex: 1; min-width: 0; }
233:.notes-panel {
237:.notes-panel h3 { margin: 0 0 6px; font-size: 12px; text-transform: uppercase; color: var(--muted); letter-spacing: 1px; }185
186 /* ---- canvases (Keynote slides / Pages layout pages) ---------------------- */
187
188 .canvas-outer { margin: 18px 0; }
189 .canvas-frame { /* scaled wrapper: aspect kept via padding trick below */
190 position: relative;
191 overflow: hidden;
192 background: #fff;
193 border: 1px solid var(--line);
194 box-shadow: 0 1px 4px rgba(0,0,0,.08);
195 }
196 .canvas-inner { position: absolute; top: 0; left: 0; transform-origin: top left; background: #fff; }
197 .canvas-drawable { position: absolute; }
198 .canvas-drawable > * { width: 100%; height: 100%; }
199 .canvas-caption { font-size: 12px; color: var(--muted); margin-top: 4px; }
200
201 .drawable-text { overflow: hidden; display: flex; }
202 .drawable-text-inner { width: 100%; }
203
204 .media-missing {
205 display: flex; align-items: center; justify-content: center;
206 border: 1px dashed var(--line); color: var(--muted); font-size: 11px;
207 text-align: center; padding: 4px; background: var(--surface);
208 }
209 .unknown-drawable {
210 display: flex; align-items: center; justify-content: center;
211 border: 1px dashed #e0b0ae; color: #9c5350; font-size: 11px;
212 text-align: center; padding: 4px; background: #fdf7f6;
213 }
214
215 /* ---- Keynote -------------------------------------------------------------- */
216
217 #keynote-view { display: flex; gap: 18px; align-items: flex-start; }
218 .slide-stage-slot { flex: 1; min-width: 0; }
219 .slide-list {
220 flex: 0 0 190px; max-height: 75vh; overflow-y: auto;
221 background: var(--card); border: 1px solid var(--line); border-radius: 10px;
222 padding: 6px;
223 }
224 .slide-list-item {
225 border-radius: 8px; padding: 8px; cursor: pointer; border: 2px solid transparent;
226 font-size: 13px;
227 }
228 .slide-list-item:hover { background: var(--surface); }
229 .slide-list-item.active { border-color: var(--accent); }
230 .slide-list-item .canvas-frame { position: relative; }
231 .slide-list-item .label { display: block; margin-top: 4px; color: var(--muted); }
232 .slide-stage { flex: 1; min-width: 0; }
233 .notes-panel {
234 background: #fffbe8; border: 1px solid #efe3b0; border-radius: 10px;
235 padding: 10px 16px; margin-top: 14px; font-size: 14px;
236 }
237 .notes-panel h3 { margin: 0 0 6px; font-size: 12px; text-transform: uppercase; color: var(--muted); letter-spacing: 1px; }
238
239 /* ---- Numbers --------------------------------------------------------------- */
240
241 .sheet-tabs { display: flex; gap: 6px; flex-wrap: wrap; margin: 14px 0; }
242 .sheet-tab {
243 border-radius: 8px 8px 0 0; border-bottom: 3px solid transparent; padding: 6px 16px;
244 }Found the truncation cause: .canvas-frame's 1px border with box-sizing: border-box shrinks the content box, causing the full-size inner canvas to get clipped by overflow: hidden. I'll now check what the gate tests expect before restructuring the fix.
grep -n "slide\|notes\|keynote" /Users/phebert/pnk/viewer/tests/gate.spec.ts | head; grep -n "slide-stage\|slide-list\|canvas" /Users/phebert/pnk/scripts/visual_diff.py | head -20
20: keynote: "85c3a6f17ca8e64ae24fb95c64af6c47e87f27237bfc19386bd09088da998007.key",
70:test("keynote fixture renders slides with positioned content + notes", async ({ page }) => {
73: await page.setInputFiles("#file-input", path.join(CRAWL, FIXTURES.keynote));
74: await expect(page.locator("#app-badge")).toContainText(/keynote/i);
75: const items = page.locator(".slide-list-item");
77: await expect(page.locator(".notes-panel")).toBeVisible();
78: // slide switching works; walk the deck until a slide carries a raster
85: const imgs = page.locator(".slide-stage .canvas-drawable img");
96: await shot(page, "keynote.png");
230: const items = page.locator(".slide-list-item");
237: const stage = page.locator(".slide-stage").first();
371: canvas = Image.new("RGB", (ap.width * 2 + 12, ap.height), "#d0d0d8")
372: canvas.paste(Image.open(apple_pages[i]).convert("RGB"), (0, 0))
373: canvas.paste(ours_slice, (ap.width + 12, 0))
375: canvas.save(out)
388: canvas_h = 480
392: a_img = a_img.resize((int(a_img.width * canvas_h / a_img.height), canvas_h), Image.LANCZOS)
395: o_img = o_img.resize((int(o_img.width * canvas_h / o_img.height), canvas_h), Image.LANCZOS)
398: canvas = Image.new("RGB", (a_img.width + o_img.width + 12, canvas_h + 22), "#d0d0d8")
399: canvas.paste(a_img, (0, 22))
400: canvas.paste(o_img, (a_img.width + 12, 22))
402: canvas.save(out)
442: canvas = Image.new("RGB", (apple_img.width + ours_img.width + gutter, h + 22), "#404048")
443: d = ImageDraw.Draw(canvas)
446: canvas.paste(apple_img.convert("RGB"), (0, 22))
447: canvas.paste(ours_img.convert("RGB"), (apple_img.width + gutter, 22))
449: canvas.save(out)sed -n 70,100p /Users/phebert/pnk/viewer/tests/gate.spec.ts; sed -n 225,250p /Users/phebert/pnk/scripts/visual_diff.py
test("keynote fixture renders slides with positioned content + notes", async ({ page }) => {
await page.goto("/");
trackRequests(page);
await page.setInputFiles("#file-input", path.join(CRAWL, FIXTURES.keynote));
await expect(page.locator("#app-badge")).toContainText(/keynote/i);
const items = page.locator(".slide-list-item");
await expect(items.count()).resolves.toBeGreaterThanOrEqual(2);
await expect(page.locator(".notes-panel")).toBeVisible();
// slide switching works; walk the deck until a slide carries a raster
// image that decoded from local blob bytes (some master art is vector
// PDF — those render blank in <img> and are skipped)
let sawImage = false;
const total = Math.min(await items.count(), 12);
for (let i = 0; i < total && !sawImage; i++) {
await items.nth(i).click();
const imgs = page.locator(".slide-stage .canvas-drawable img");
for (let k = 0; k < (await imgs.count()); k++) {
const w = await imgs.nth(k).evaluate((node) => (node as HTMLImageElement).naturalWidth);
if (w > 0) {
await expect(imgs.nth(k)).toBeVisible();
sawImage = true;
break;
}
}
}
expect(sawImage).toBe(true);
await shot(page, "keynote.png");
assertNoRuntimeNetwork(page);
});
test("numbers fixture renders sheet tables with real cell values", async ({ page }) => {
await page.waitForTimeout(1500);
// Deck mode (.key): click through thumbnails and screenshot the STAGE only,
// one PNG per slide, so composites align 1:1 with Apple's per-page rasters.
let slideCount = 0;
const items = page.locator(".slide-list-item");
if ((await items.count()) > 0) {
slideCount = await items.count();
const shotDir = shotPath.replace(/\/[^/]+$/, "/");
fs.mkdirSync(shotDir, { recursive: true });
for (let i = 0; i < slideCount; i++) {
await items.nth(i).click();
const stage = page.locator(".slide-stage").first();
await stage.waitFor({ state: "visible", timeout: 10000 });
await page.waitForTimeout(250);
await stage.screenshot({ path: `${shotDir}slide-${i + 1}.png` });
}
}
await page.screenshot({ path: shotPath, fullPage: true });
const bboxes = await page.evaluate(() => {
const grab = (el) => {
const r = el.getBoundingClientRect();
return { x: r.x, y: r.y + window.scrollY, w: r.width, h: r.height };
}; const { width, height } = doc.slideSize;
const scale = widthPx / width; const { width, height } = doc.slideSize;
// The frame's 1px border eats into its border-box: scale to the CONTENT
// box or the right/bottom edges of every canvas get clipped by
// overflow:hidden (~12 doc-px on a thumbnail — visibly truncated).
const scale = (widthPx - 2) / width;The file /Users/phebert/pnk/viewer/src/keynote.ts has been updated successfully. (file state is current in your context — no need to Read it back)
function renderStage(
doc: KeynoteDocument,
hdoc: HydratedDoc,
ctx: ViewerCtx,
slide: Slide,
index: number,
widthPx: number,
): HTMLElement {
const stage = document.createElement("div");
stage.className = "slide-stage";
const frame = buildCanvas(slide, doc, hdoc, ctx, widthPx, index + 1);
frame.dataset.slideIndex = String(index);
stage.appendChild(frame);
const caption = document.createElement("div");
caption.className = "slide-caption muted";
const bits = [`Slide ${index + 1}${slide.name ? ` — ${slide.name}` : ""}`];
if (slide.masterName) bits.push(`master: ${slide.masterName}`);
if (slide.transition?.effect) bits.push(`transition: ${slide.transition.effect}`);
if (slide.skipped) bits.push("skipped");
caption.textContent = bits.join(" · ");
stage.appendChild(caption);
const notes = document.createElement("div");
notes.className = "notes-panel";
notes.dataset.hasNotes = slide.notes ? "true" : "false";
const h = document.createElement("h3");
h.textContent = "Presenter notes";
notes.appendChild(h);
notes.appendChild(slide.notes
? renderStyledText(slide.notes, hdoc, ctx)
: Object.assign(document.createElement("p"), { textContent: "No notes on this slide.", className: "muted" }));
stage.appendChild(notes);
// Corner badge only when no slide-number placeholder painted the number
// on the canvas itself.
if (slide.slideNumberVisible && !slide.drawables.some((d) => roleOf(d) === "slide-number")) {
const num = document.createElement("div");
num.className = "slide-number";
num.textContent = String(index + 1);
stage.appendChild(num);
}
return stage;
}
export function renderKeynote(
doc: KeynoteDocument,
hdoc: HydratedDoc,
ctx: ViewerCtx,
mount: HTMLElement,
): void {
const view = document.createElement("div");
view.id = "keynote-view";
const list = document.createElement("div");
list.className = "slide-list";
const stageSlot = document.createElement("div");
stageSlot.className = "slide-stage-slot";
let active = doc.slides.findIndex((s) => !s.skipped);
if (active < 0) active = 0;
const activate = (index: number) => {
stageSlot.replaceChildren();
stageSlot.appendChild(renderStage(doc, hdoc, ctx, doc.slides[index], index, stageSlot.clientWidth || 800));
// Shrink-to-fit measurement pass needs the stage attached and laid out.
applyTextFit(stageSlot);
for (const item of list.children) {
item.classList.toggle("active", (item as HTMLElement).dataset.slideIndex === String(index));
}
};
doc.slides.forEach((slide, i) => {
const item = document.createElement("div");
item.className = "slide-list-item";
item.dataset.slideIndex = String(i);
item.appendChild(buildCanvas(slide, doc, hdoc, ctx, THUMB_WIDTH, i + 1));
const label = document.createElement("span");
label.className = "label";
label.textContent = `${i + 1}${slide.name ? ` · ${slide.name}` : ""}${slide.skipped ? " (skipped)" : ""}`;
item.appendChild(label);
item.addEventListener("click", () => activate(i));
list.appendChild(item);
});
view.appendChild(list);
view.appendChild(stageSlot);
mount.appendChild(view);
activate(active);
// Thumbnails were built detached; measure their shrink boxes now that the
// whole view is attached.
applyTextFit(list);
}/** Fill one slide's stage holder: canvas, caption, notes strip, badge. */
function fillStage(
stage: HTMLElement,
doc: KeynoteDocument,
hdoc: HydratedDoc,
ctx: ViewerCtx,
slide: Slide,
index: number,
widthPx: number,
): void {
stage.replaceChildren();
const frame = buildCanvas(slide, doc, hdoc, ctx, widthPx, index + 1);
stage.appendChild(frame);
const caption = document.createElement("div");
caption.className = "slide-caption muted";
const bits = [`Slide ${index + 1}${slide.name ? ` — ${slide.name}` : ""}`];
if (slide.masterName) bits.push(`master: ${slide.masterName}`);
if (slide.transition?.effect) bits.push(`transition: ${slide.transition.effect}`);
if (slide.skipped) bits.push("skipped");
caption.textContent = bits.join(" · ");
stage.appendChild(caption);
// Notes: a compact strip only when the slide HAS notes — nothing to scroll
// past otherwise. Typography is normalized to the strip (notes storages
// carry 18pt+ editor styles that read as bloat here); bold/color survive.
if (slide.notes) {
const notes = document.createElement("div");
notes.className = "notes-panel";
const h = document.createElement("h3");
h.textContent = "Notes";
notes.appendChild(h);
notes.appendChild(renderStyledText(slide.notes, hdoc, ctx));
stage.appendChild(notes);
}
// Corner badge only when no slide-number placeholder painted the number
// on the canvas itself.
if (slide.slideNumberVisible && !slide.drawables.some((d) => roleOf(d) === "slide-number")) {
const num = document.createElement("div");
num.className = "slide-number";
num.textContent = String(index + 1);
frame.appendChild(num);
}
}
export function renderKeynote(
doc: KeynoteDocument,
hdoc: HydratedDoc,
ctx: ViewerCtx,
mount: HTMLElement,
): void {
const view = document.createElement("div");
view.id = "keynote-view";
const list = document.createElement("div");
list.className = "slide-list";
// One continuous scroll of every slide (like the Pages flow). Stages start
// as aspect-ratio placeholders and render lazily as they approach the
// viewport — the 645-slide monster deck must not pay for 645 full
// canvases up front.
const scroll = document.createElement("div");
scroll.className = "slides-scroll";
const stages: HTMLElement[] = [];
const built = new Set<number>();
const buildStage = (i: number): void => {
if (built.has(i)) return;
built.add(i);
const stage = stages[i];
fillStage(stage, doc, hdoc, ctx, doc.slides[i], i, stage.clientWidth || 800);
// Shrink-to-fit measurement pass needs the stage attached and laid out.
applyTextFit(stage);
};
doc.slides.forEach((slide, i) => {
const stage = document.createElement("div");
stage.className = "slide-stage";
stage.dataset.slideIndex = String(i);
const ph = document.createElement("div");
ph.className = "canvas-frame";
ph.style.aspectRatio = `${doc.slideSize.width} / ${doc.slideSize.height}`;
stage.appendChild(ph);
scroll.appendChild(stage);
stages.push(stage);
});
const io = new IntersectionObserver(
(entries) => {
for (const e of entries) {
if (!e.isIntersecting) continue;
const i = Number((e.target as HTMLElement).dataset.slideIndex);
io.unobserve(e.target);
buildStage(i);
}
},
{ rootMargin: "1500px 0px" },
);
stages.forEach((s) => io.observe(s));
const setActive = (index: number): void => {
for (const item of list.children) {
item.classList.toggle("active", (item as HTMLElement).dataset.slideIndex === String(index));
}
};
doc.slides.forEach((slide, i) => {
const item = document.createElement("div");
item.className = "slide-list-item";
item.dataset.slideIndex = String(i);
item.appendChild(buildCanvas(slide, doc, hdoc, ctx, THUMB_WIDTH, i + 1));
const label = document.createElement("span");
label.className = "label";
label.textContent = `${i + 1}${slide.name ? ` · ${slide.name}` : ""}${slide.skipped ? " (skipped)" : ""}`;
item.appendChild(label);
item.addEventListener("click", () => {
buildStage(i);
stages[i].scrollIntoView({ behavior: "smooth", block: "start" });
setActive(i);
});
list.appendChild(item);
});
view.appendChild(list);
view.appendChild(scroll);
mount.appendChild(view);
setActive(doc.slides.findIndex((s) => !s.skipped) < 0 ? 0 : doc.slides.findIndex((s) => !s.skipped));
// Thumbnails were built detached; measure their shrink boxes now that the
// whole view is attached.
applyTextFit(list);
}The file /Users/phebert/pnk/viewer/src/keynote.ts has been updated successfully. (file state is current in your context — no need to Read it back)
#keynote-view { display: flex; gap: 18px; align-items: flex-start; }
.slide-stage-slot { flex: 1; min-width: 0; }
.slide-list {
flex: 0 0 190px; max-height: 75vh; overflow-y: auto;
background: var(--card); border: 1px solid var(--line); border-radius: 10px;
padding: 6px;
}
.slide-list-item {
border-radius: 8px; padding: 8px; cursor: pointer; border: 2px solid transparent;
font-size: 13px;
}
.slide-list-item:hover { background: var(--surface); }
.slide-list-item.active { border-color: var(--accent); }
.slide-list-item .canvas-frame { position: relative; }
.slide-list-item .label { display: block; margin-top: 4px; color: var(--muted); }
.slide-stage { flex: 1; min-width: 0; }
.notes-panel {
background: #fffbe8; border: 1px solid #efe3b0; border-radius: 10px;
padding: 10px 16px; margin-top: 14px; font-size: 14px;
}
.notes-panel h3 { margin: 0 0 6px; font-size: 12px; text-transform: uppercase; color: var(--muted); letter-spacing: 1px; }#keynote-view { display: flex; gap: 18px; align-items: flex-start; }
.slide-list {
flex: 0 0 190px; overflow-y: auto;
position: sticky; top: 58px; max-height: calc(100vh - 74px);
background: var(--card); border: 1px solid var(--line); border-radius: 10px;
padding: 6px;
}
.slide-list-item {
border-radius: 8px; padding: 8px; cursor: pointer; border: 2px solid transparent;
font-size: 13px;
}
.slide-list-item:hover { background: var(--surface); }
.slide-list-item.active { border-color: var(--accent); }
.slide-list-item .canvas-frame { position: relative; }
.slide-list-item .label { display: block; margin-top: 4px; color: var(--muted); }
/* Continuous deck flow: every slide stacked, Pages-style. */
.slides-scroll { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 8px; }
.slide-stage { min-width: 0; margin-bottom: 14px; }
.slide-stage .canvas-frame { border-radius: 3px; }
/* Compact presenter-notes strip: rendered only when a slide HAS notes.
Notes storages carry the editor's 18pt+ styles — normalize the strip's
typography (size/leading) while bold/italic/color survive. */
.notes-panel {
background: #fffbe8; border: 1px solid #efe3b0; border-radius: 8px;
padding: 6px 12px 8px; margin-top: 8px; font-size: 13px; line-height: 1.45;
max-height: 150px; overflow-y: auto; color: #1d1d1f;
}
.notes-panel h3 {
margin: 0 0 4px; font-size: 10px; text-transform: uppercase;
color: #8a7b3a; letter-spacing: 1px;
}
.notes-panel .styled-text p { margin: 2px 0; font-size: inherit !important; line-height: inherit !important; }
.notes-panel .styled-text span { font-size: inherit !important; }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)
.notes-panel .styled-text p { margin: 2px 0; font-size: inherit !important; line-height: inherit !important; }
.notes-panel .styled-text span { font-size: inherit !important; }.notes-panel .styled-text p { margin: 2px 0; font-size: inherit !important; line-height: inherit !important; }
.notes-panel .styled-text span { font-size: inherit !important; }
.slide-number {
position: absolute; right: 6px; bottom: 4px; font-size: 11px;
color: var(--muted); background: rgba(255,255,255,.7);
padding: 0 5px; border-radius: 4px; pointer-events: none;
}The file /Users/phebert/pnk/viewer/styles.css has been updated successfully. (file state is current in your context — no need to Read it back)
Now update the harness's deck branch (screenshot per-slide canvas frames in the continuous view) and the gate test:
{
"file_path": "/Users/phebert/pnk/scripts/visual_diff.py",
"offset": 215,
"limit": 40
}215 const [fixture, shotPath, bboxPath, baseURL] = process.argv.slice(2);
216 const browser = await chromium.launch();
217 const page = await browser.newPage({
218 viewport: { width: 1280, height: 900 },
219 deviceScaleFactor: 2,
220 });
221 await page.goto(baseURL + "/");
222 await page.setInputFiles("#file-input", fixture);
223 await page.waitForSelector("#pages-view, #numbers-view, #keynote-view", { timeout: 30000 });
224 // tables/images settle after first paint
225 await page.waitForTimeout(1500);
226
227 // Deck mode (.key): click through thumbnails and screenshot the STAGE only,
228 // one PNG per slide, so composites align 1:1 with Apple's per-page rasters.
229 let slideCount = 0;
230 const items = page.locator(".slide-list-item");
231 if ((await items.count()) > 0) {
232 slideCount = await items.count();
233 const shotDir = shotPath.replace(/\/[^/]+$/, "/");
234 fs.mkdirSync(shotDir, { recursive: true });
235 for (let i = 0; i < slideCount; i++) {
236 await items.nth(i).click();
237 const stage = page.locator(".slide-stage").first();
238 await stage.waitFor({ state: "visible", timeout: 10000 });
239 await page.waitForTimeout(250);
240 await stage.screenshot({ path: `${shotDir}slide-${i + 1}.png` });
241 }
242 }
243
244 await page.screenshot({ path: shotPath, fullPage: true });
245
246 const bboxes = await page.evaluate(() => {
247 const grab = (el) => {
248 const r = el.getBoundingClientRect();
249 return { x: r.x, y: r.y + window.scrollY, w: r.width, h: r.height };
250 };
251 const out = { tables: [], imageParagraph: null, view: null, innerWidth: window.innerWidth };
252 const view = document.querySelector("#pages-view, #numbers-view, #keynote-view");
253 if (view) out.view = grab(view);
254 document.querySelectorAll("table.sheet-table").forEach((t) => { // Deck mode (.key): click through thumbnails and screenshot the STAGE only,
// one PNG per slide, so composites align 1:1 with Apple's per-page rasters.
let slideCount = 0;
const items = page.locator(".slide-list-item");
if ((await items.count()) > 0) {
slideCount = await items.count();
const shotDir = shotPath.replace(/\/[^/]+$/, "/");
fs.mkdirSync(shotDir, { recursive: true });
for (let i = 0; i < slideCount; i++) {
await items.nth(i).click();
const stage = page.locator(".slide-stage").first();
await stage.waitFor({ state: "visible", timeout: 10000 });
await page.waitForTimeout(250);
await stage.screenshot({ path: `${shotDir}slide-${i + 1}.png` });
}
} // Deck mode (.key): the viewer shows all slides in one continuous scroll,
// lazily rendered. Click each thumbnail (forces render + scrolls there) and
// screenshot that slide's CANVAS FRAME only, one PNG per slide, so
// composites align 1:1 with Apple's per-page rasters (no caption/notes).
let slideCount = 0;
const items = page.locator(".slide-list-item");
if ((await items.count()) > 0) {
slideCount = await items.count();
const shotDir = shotPath.replace(/\/[^/]+$/, "/");
fs.mkdirSync(shotDir, { recursive: true });
for (let i = 0; i < slideCount; i++) {
await items.nth(i).click();
const frame = page.locator(`.slide-stage[data-slide-index="${i}"] .canvas-frame`).first();
await frame.waitFor({ state: "visible", timeout: 10000 });
await page.waitForTimeout(250);
await frame.screenshot({ path: `${shotDir}slide-${i + 1}.png` });
}
}The file /Users/phebert/pnk/scripts/visual_diff.py has been updated successfully. (file state is current in your context — no need to Read it back)
./target/release/pnk2json --pretty ~/Development/pnk-fixtures/crawl/85c3a6f17ca8e64ae24fb95c64af6c47e87f27237bfc19386bd09088da998007.key 2>/dev/null | python3 -c "
import json, sys
d = json.load(sys.stdin)
doc = d.get('document', d)
for i, sl in enumerate(doc['slides'][:8], 1):
n = sl.get('notes')
print(i, 'notes' if n else '-', (json.dumps(n)[:60] if n else ''))
"Exit code 1
Traceback (most recent call last):
File "<string>", line 3, in <module>
d = json.load(sys.stdin)
File "/opt/homebrew/Cellar/[email protected]/3.14.7/Frameworks/Python.framework/Versions/3.14/lib/python3.14/json/__init__.py", line 298, in load
return loads(fp.read(),
cls=cls, object_hook=object_hook,
parse_float=parse_float, parse_int=parse_int,
parse_constant=parse_constant, object_pairs_hook=object_pairs_hook, **kw)
File "/opt/homebrew/Cellar/[email protected]/3.14.7/Frameworks/Python.framework/Versions/3.14/lib/python3.14/json/__init__.py", line 352, in loads
return _default_decoder.decode(s)
~~~~~~~~~~~~~~~~~~~~~~~^^^
File "/opt/homebrew/Cellar/[email protected]/3.14.7/Frameworks/Python.framework/Versions/3.14/lib/python3.14/json/decoder.py", line 345, in decode
obj, end = self.raw_decode(s, idx=_w(s, 0).end())
~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/[email protected]/3.14.7/Frameworks/Python.framework/Versions/3.14/lib/python3.14/json/decoder.py", line 363, in raw_decode
raise JSONDecodeError("Expecting value", s, err.value) from None
json.decoder.JSONDecodeError: Expecting value: line 1 column 1 (char 0)ls ~/Development/pnk-fixtures/crawl/85c3a6f1* 2>/dev/null; grep -n "CRAWL" /Users/phebert/pnk/viewer/tests/gate.spec.ts | head -3
/Users/phebert/Development/pnk-fixtures/crawl/85c3a6f17ca8e64ae24fb95c64af6c47e87f27237bfc19386bd09088da998007.key
15:const CRAWL = path.join(HERE, "../../fixtures/crawl");
73: await page.setInputFiles("#file-input", path.join(CRAWL, FIXTURES.keynote));
103: await page.setInputFiles("#file-input", path.join(CRAWL, FIXTURES.numbers));./target/release/pnk2json ~/Development/pnk-fixtures/crawl/85c3a6f17ca8e64ae24fb95c64af6c47e87f27237bfc19386bd09088da998007.key > /private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/85c3.json 2>/private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/85c3.err; echo $?; head -c 200 /private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/85c3.err; python3 -c "
import json
d = json.load(open('/private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/85c3.json'))
doc = d.get('document', d)
print([('N' if sl.get('notes') else '-') for sl in doc['slides']])
"Exit code 1
127
(eval):1: no such file or directory: ./target/release/pnk2json
Traceback (most recent call last):
File "<string>", line 3, in <module>
d = json.load(open('/private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/85c3.json'))
File "/opt/homebrew/Cellar/[email protected]/3.14.7/Frameworks/Python.framework/Versions/3.14/lib/python3.14/json/__init__.py", line 298, in load
return loads(fp.read(),
cls=cls, object_hook=object_hook,
parse_float=parse_float, parse_int=parse_int,
parse_constant=parse_constant, object_pairs_hook=object_pairs_hook, **kw)
File "/opt/homebrew/Cellar/[email protected]/3.14.7/Frameworks/Python.framework/Versions/3.14/lib/python3.14/json/__init__.py", line 352, in loads
return _default_decoder.decode(s)
~~~~~~~~~~~~~~~~~~~~~~~^^^
File "/opt/homebrew/Cellar/[email protected]/3.14.7/Frameworks/Python.framework/Versions/3.14/lib/python3.14/json/decoder.py", line 345, in decode
obj, end = self.raw_decode(s, idx=_w(s, 0).end())
~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/[email protected]/3.14.7/Frameworks/Python.framework/Versions/3.14/lib/python3.14/json/decoder.py", line 363, in raw_decode
raise JSONDecodeError("Expecting value", s, err.value) from None
json.decoder.JSONDecodeError: Expecting value: line 1 column 1 (char 0)cd /Users/phebert/pnk && ./target/release/pnk2json ~/Development/pnk-fixtures/crawl/85c3a6f17ca8e64ae24fb95c64af6c47e87f27237bfc19386bd09088da998007.key 2>/dev/null | python3 -c "
import json, sys
d = json.load(sys.stdin)
doc = d.get('document', d)
print(''.join('N' if sl.get('notes') else '-' for sl in doc['slides']))
"bash scripts/build_viewer.sh 2>&1 | tail -1 && cd viewer && npm test 2>&1 | tail -8
viewer built: viewer/dist/ (serve: cd viewer && npm run serve)
80 | // PDF — those render blank in <img> and are skipped)
at /Users/phebert/pnk/viewer/tests/gate.spec.ts:77:46
Error Context: test-results/gate-keynote-fixture-rende-f559c-th-positioned-content-notes/error-context.md
1 failed
tests/gate.spec.ts:70:1 › keynote fixture renders slides with positioned content + notes ───────
5 passed (1.7s)sed -n 74,80p /Users/phebert/pnk/viewer/tests/gate.spec.ts
await expect(page.locator("#app-badge")).toContainText(/keynote/i);
const items = page.locator(".slide-list-item");
await expect(items.count()).resolves.toBeGreaterThanOrEqual(2);
await expect(page.locator(".notes-panel")).toBeVisible();
// slide switching works; walk the deck until a slide carries a raster
// image that decoded from local blob bytes (some master art is vector
// PDF — those render blank in <img> and are skipped) await expect(page.locator(".notes-panel")).toBeVisible(); // Continuous-scroll view: one .notes-panel per slide that HAS notes.
await expect(page.locator(".notes-panel").first()).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)
✓ 6 tests/gate.spec.ts:145:1 › encrypted fixture gets the password-protected explanation (87ms) 6 passed (1.6s)
cd /Users/phebert/pnk && uv run --with pillow --with pyobjc-framework-Quartz --with pymupdf python3 scripts/visual_diff.py --app keynote --fixture ~/Development/pnk-fixtures/crawl/0d5851c0105958b602c58646b98ffaee1b453def8478f9b872aca37319d2ed5f.key --out /private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/vd-0d58c --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-0d58c [visual_diff] summary: /private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/vd-0d58c/summary.md
{
"file_path": "/private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/vd-0d58c/composites/composite-page-19.png"
}{
"file_path": "/private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/vd-0d58c/composites/composite-page-10.png"
}Composites now align canvas-to-canvas and slide 19's rhythm matches. Let me eyeball the new continuous UI + notes strip + thumbnails directly:
cd /Users/phebert/pnk/viewer && cat > probe-tmp.mjs <<'EOF'
import { chromium } from "playwright";
const fixture = "/Users/phebert/Development/pnk-fixtures/crawl/0d5851c0105958b602c58646b98ffaee1b453def8478f9b872aca37319d2ed5f.key";
const b = await chromium.launch();
const page = await b.newPage({ viewport: { width: 1280, height: 900 } });
await page.goto("http://127.0.0.1:8123/");
await page.setInputFiles("#file-input", fixture);
await page.waitForSelector(".slide-list-item", { timeout: 30000 });
await page.waitForTimeout(1200);
await page.screenshot({ path: "/private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/ui-top.png" });
await page.locator(".slide-list-item").nth(9).click();
await page.waitForTimeout(1200);
await page.screenshot({ path: "/private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/ui-slide10.png" });
await b.close();
EOF
node probe-tmp.mjs{
"file_path": "/private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/ui-top.png"
}{
"file_path": "/private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/ui-slide10.png"
}Everything's working except slide 9, which shows an empty "NOTES" strip since its notes object contains only blank paragraphs. I'll fix this by gating the strip's visibility on whether there's actual visible text.
// Notes: a compact strip only when the slide HAS notes — nothing to scroll
// past otherwise. Typography is normalized to the strip (notes storages
// carry 18pt+ editor styles that read as bloat here); bold/color survive.
if (slide.notes) { // Notes: a compact strip only when the slide HAS visible notes — nothing
// to scroll past otherwise (a notes storage of empty paragraphs counts as
// none). Typography is normalized to the strip (notes storages carry
// 18pt+ editor styles that read as bloat here); bold/color survive.
const hasNotes = slide.notes?.paragraphs.some((p) =>
p.items.some((it) => (typeof it === "string" ? it.trim() : "type" in it ? true : it.text.trim())),
);
if (slide.notes && hasNotes) {The file /Users/phebert/pnk/viewer/src/keynote.ts has been updated successfully. (file state is current in your context — no need to Read it back)
Now the full gauntlet — the converter changed, so conformance + cargo golden tests + viewer gate:
cd /Users/phebert/pnk && bash scripts/build_viewer.sh 2>&1 | tail -1 && cd viewer && npx tsc --noEmit && echo TSC-OK && npm test 2>&1 | tail -2
viewer built: viewer/dist/ (serve: cd viewer && npm run serve)
TSC-OK
tests/gate.spec.ts:70:1 › keynote fixture renders slides with positioned content + notes ───────
5 passed (6.7s)npx playwright test -g "keynote" 2>&1 | tail -25
Error: expect(locator).toBeVisible() failed
Locator: locator('.notes-panel').first()
Expected: visible
Timeout: 5000ms
Error: element(s) not found
Call log:
- Expect "toBeVisible" with timeout 5000ms
- waiting for locator('.notes-panel').first()
76 | await expect(items.count()).resolves.toBeGreaterThanOrEqual(2);
77 | // Continuous-scroll view: one .notes-panel per slide that HAS notes.
> 78 | await expect(page.locator(".notes-panel").first()).toBeVisible();
| ^
79 | // slide switching works; walk the deck until a slide carries a raster
80 | // image that decoded from local blob bytes (some master art is vector
81 | // PDF — those render blank in <img> and are skipped)
at /Users/phebert/pnk/viewer/tests/gate.spec.ts:78:54
Error Context: test-results/gate-keynote-fixture-rende-f559c-th-positioned-content-notes/error-context.md
1 failed
tests/gate.spec.ts:70:1 › keynote fixture renders slides with positioned content + notes ───────cd /Users/phebert/pnk && ./target/release/pnk2json ~/Development/pnk-fixtures/crawl/85c3a6f17ca8e64ae24fb95c64af6c47e87f27237bfc19386bd09088da998007.key 2>/dev/null | python3 -c "
import json, sys
d = json.load(sys.stdin)
doc = d.get('document', d)
for i, sl in enumerate(doc['slides'], 1):
n = sl.get('notes') or {}
txt = ''.join((it if isinstance(it,str) else it.get('text','')) for p in n.get('paragraphs',[]) if isinstance(p,dict) for it in p.get('items',[]) if isinstance(it,(str,dict)))
print(i, repr(txt[:50]))
"The gate deck's notes are all empty — the old UI showed 11 empty yellow panels (exactly the noise being removed). Updating the test to assert the new spec:
// Continuous-scroll view: one .notes-panel per slide that HAS notes.
await expect(page.locator(".notes-panel").first()).toBeVisible(); // Continuous-scroll view: a .notes-panel renders only for slides with
// VISIBLE notes. This deck's notes storages are all empty paragraphs, so
// no panel may appear (the old UI showed 11 empty yellow strips).
await expect(page.locator(".notes-panel")).toHaveCount(0);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)
cd /Users/phebert/pnk/viewer && npm test 2>&1 | tail -2 && cargo test -p pnk2json --release 2>&1 | tail -5
6 passed (1.6s) running 0 tests test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
cd /Users/phebert/pnk && cargo test -p pnk2json --release 2>&1 | grep -E "test result|running|FAILED|passed" | head
running 3 tests test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s running 0 tests test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s running 2 tests test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s running 9 tests test result: ok. 9 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.05s running 0 tests test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
CC-MAIN-2026-34-cdx-00174-3 keynote 0.367s in= 701234477 out= 636459 CC-MAIN-2026-34-cdx-00238-5 numbers 0.344s in= 14679959 out= 18162943 CC-MAIN-2026-34-cdx-00008-1 keynote 0.304s in= 521170455 out= 828736 CC-MAIN-2026-34-cdx-00203-5 keynote 0.287s in= 601335887 out= 369871 NO DEFECTS.
All green — conformance NO DEFECTS, goldens byte-stable, gate 6/6. Committing in three educational steps:
rm -f viewer/probe-tmp.mjs && git add crates/pnk2json/src/drawables.rs && git commit -m "pnk2json: classic Keynote text shapes are CENTER-anchored — geometry flags=0 Keynote-'09-converted decks (format 1.5, KN.Classic* records) store some TSWP.ShapeInfoArchive geometry with flags == 0 and position = the shape's CENTER: 0d5851c0's title stores (512, 638) — the slide's exact horizontal center — and Apple lays the 500×36 rect out at 262..762 with its centered text on x=512; our top-left read pushed the text half a box right, off the band and clipped at the slide edge. Modern archives always write flags 3 (|4 when rotated): verified across G2 and 0f9df553 shapes, plus this deck's own correctly-placed flags=3 images. Re-anchor position to top-left at conversion (Shape/Textbox from ShapeInfo, unrotated only — no rotated flags=0 sample exists to verify), so the model's geometry contract holds and the viewer never learns about the flag. 0×0 anchored labels are unaffected (half-zero shift). flags bit semantics remain undocumented in any local proto [inferred, behavior verified against Apple's render of 0d5851c0 slides 1/27/28]. Goldens byte-stable (modern docs all flags=3); conformance NO DEFECTS. Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_018a2hmoDhN4kBq1BwfbFdAU" && git push origin main 2>&1 | tail -1
1 file changed, 23 insertions(+) 01021ad..676533d main -> main
git add viewer/src/text.ts viewer/src/drawables.ts && git commit -m "viewer: four text-fidelity fixes from the 0d5851c0 deck - Shrink pass width compensation actually applies now: the inner is a flex ITEM and its default flex-shrink:1 silently squashed the >100% compensated width back to the container's — text wrapped at the original width, then scaled, leaving a right gap; centered lines drifted left by (1-s)/2·width (slide 9 subtitle ~45pt off-center). flex: 0 0 auto during measurement. - Image list markers render (markerKind 'image' was skipped entirely — slide 2/19's rightArrow PNG bullets showed neither glyph nor indent). The PNG scales like a glyph: markerScale × run size, on the baseline, with a • fallback when the media bytes didn't ship. - 0×0 SHAPES join 0×0 textboxes in point-anchor layout (natural width, anchored per paragraph alignment): slide 29's 51pt quote is a 0×0 shape Apple lays out on one line; our 0-width box wrapped it into a four-line sliver. Shared helper anchorZeroSizeText. - Baseline-shifted runs no longer grow the line box (line-height: 0 on shifted spans): slide 19's raised red labels stretched 40px-exact rows to 41.7px — visibly loose leading across 8 rows. Apple's baseline shifts never change line spacing. All verified by eye against Apple's PDF via visual_diff. 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, 84 insertions(+), 32 deletions(-) 676533d..f8bbc68 main -> main
git add viewer/src/keynote.ts viewer/styles.css viewer/tests/gate.spec.ts scripts/visual_diff.py && git commit -m "viewer: Keynote decks scroll as one continuous view; notes become a compact strip The stage no longer swaps one slide at a time: every slide stacks in a single scrolling column (like the Pages flow), rendered lazily via IntersectionObserver (aspect-ratio placeholders hold scroll height; the 645-slide monster deck must not pay for 645 canvases up front). The thumbnail rail stays as navigation — sticky, click scrolls to the slide. Presenter notes: a compact strip under each slide ONLY when the slide has visible notes (a storage of empty paragraphs counts as none — the gate deck previously showed 11 empty yellow panels). Strip typography is normalized (13px/1.45, capped at 150px with scroll) since notes storages carry 18pt+ editor styles; bold/italic/color survive. Fixes the excessive notes leading (UA <p> margins) as a side effect. Thumbnail truncation fixed: .canvas-frame's 1px border eats into its border-box, so scaling the inner canvas to the full given width clipped ~12 doc-px off every thumbnail's right/bottom edge under overflow:hidden. Scale to the content box (width−2) instead — applies to full-size stages too (their 2px loss was invisible but real). visual_diff deck mode now screenshots each slide's .canvas-frame (not the whole stage), so composites align canvas-to-canvas with Apple's rasters without caption/notes skewing the height match. 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, 109 insertions(+), 42 deletions(-) f8bbc68..bc5004d main -> main
cd /Users/phebert/pnk/viewer && cat > /private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/probe2.mjs <<'EOF'
import { chromium } from "playwright";
const b = await chromium.launch();
const page = await b.newPage({ viewport: { width: 1280, height: 900 } });
await page.goto("http://127.0.0.1:8123/");
await page.setInputFiles("#file-input", "/Users/phebert/Development/pnk-fixtures/crawl/0d5851c0105958b602c58646b98ffaee1b453def8478f9b872aca37319d2ed5f.key");
await page.waitForSelector(".slide-list-item", { timeout: 30000 });
await page.locator(".slide-list-item").nth(9).click();
await page.waitForTimeout(1500);
await page.screenshot({ path: "/private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/ui-final.png" });
await b.close();
EOF
node /private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/probe2.mjs 2>/dev/null || (cp /private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/probe2.mjs probe2-tmp.mjs && node probe2-tmp.mjs && rm probe2-tmp.mjs){
"file_path": "/private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/ui-final.png"
}All seven fidelity items plus the UI rework are done, verified against Apple's PDF, and pushed as three commits (676533d, f8bbc68, bc5004d). The interesting finds:
Slide 1 title (and 27/28's quotes) — a format archaeology dig. This is a Keynote-'09-converted deck (format 1.5), and its text shapes store geometry with flags=0 where position means the shape's center — the title stores x=512, the slide's exact midpoint. Modern archives always write flags=3 (verified across G2 and other decks, plus this deck's own correctly-placed images). The converter now re-anchors these to top-left, so the viewer never learns the flag exists. The flag bits are documented in no local proto, so the commit carries the full evidence chain.
Slide 9 — a real bug hiding under "not centered." The shrink-to-fit pass compensates its scale with a >100% width, but the inner div is a flex item and default flex-shrink:1 silently squashed it back — text wrapped at the wrong width, so centered lines drifted left ~45pt. One flex: 0 0 auto fixes every shrink box, not just this slide.
Slide 2/19 arrows: the bullets are markerKind: "image" (a rightArrow PNG in the media store) — the viewer only knew string/number markers. They now render scaled like glyphs, with their indents.
Slide 19's loose leading: the raised red labels (baseline-shifted runs) were growing each 40px-exact line box to 41.7px. Shifted spans now get line-height: 0, Apple's own rule (baseline shifts never affect leading).
Slide 29's quote: a 0×0 shape — it now gets the same point-anchor natural-width layout the 0×0 textbox labels already had, so it's one 51pt line instead of a four-line sliver.
Thumbnails: the frame's 1px border eats into the border-box, so every canvas was scaled 2px too wide and clipped right/bottom by overflow:hidden — ~12 doc-px at thumbnail scale.
UI: slides now stack in one continuous scroll like Pages, lazily rendered (aspect-ratio placeholders + IntersectionObserver, so the 645-slide monster deck stays fast). The thumbnail rail is sticky and clicking scrolls to the slide. Notes are a compact 13px strip under a slide only when it has visible notes — empty note storages (the gate deck had 11 of them) render nothing. That also killed the slide-10 leading problem. The visual_diff harness now screenshots per-slide canvas frames, so composites align canvas-to-canvas.
Gates: conformance NO DEFECTS, goldens byte-stable, cargo tests green, viewer 6/6.
please massage the viewer UI to have a top nav-bar, be more compact, remove the fonts list, make warnings much more subtle, call it pnk.vu, design a subtle dev-forward/minimalist branding
1 <!doctype html> 2 <html lang="en"> 3 <head> 4 <meta charset="utf-8"> 5 <meta name="viewport" content="width=device-width, initial-scale=1"> 6 <title>pnk viewer — iWork, locally</title> 7 <link rel="stylesheet" href="styles.css"> 8 </head> 9 <body> 10 <div id="app"> 11 <header id="app-header" class="hidden"> 12 <span id="doc-filename"></span> 13 <span id="app-badge" class="badge"></span> 14 <span id="doc-meta" class="muted"></span> 15 <span class="spacer"></span> 16 <button id="json-btn" type="button" title="Download the converted JSON model">JSON</button> 17 <button id="reset-btn" type="button" title="Open another file">Open another…</button> 18 </header> 19 20 <div id="panel-fonts" class="panel hidden"> 21 <details> 22 <summary>Fonts <span class="pill" id="fonts-count">0</span></summary> 23 <div id="fonts-list" class="chips"></div> 24 </details> 25 </div> 26 27 <div id="panel-warnings" class="panel hidden"> 28 <details> 29 <summary>Warnings <span class="pill" id="warnings-count">0</span></summary> 30 <div id="warnings-list"></div> 31 </details> 32 </div> 33 34 <div id="drop-zone"> 35 <div id="drop-card"> 36 <h1>pnk</h1> 37 <p class="tagline">Open a Pages, Numbers or Keynote file — <strong>entirely in this browser</strong>.<br> 38 No accounts, no upload, no backend: the file is parsed locally and never leaves your machine.</p> 39 <div class="fmt-chips"> 40 <span class="fmt-chip fmt-pages">📄 Pages</span> 41 <span class="fmt-chip fmt-numbers">📊 Numbers</span> 42 <span class="fmt-chip fmt-keynote">🎞️ Keynote</span> 43 </div> 44 <div id="drop-target">Drop a <code>.pages</code>, <code>.numbers</code> or <code>.key</code> file here<br> 45 <span class="muted">or</span><br> 46 <button id="pick-btn" type="button">Choose a file…</button> 47 </div> 48 <p class="muted" id="drop-hint">Encrypted (password-protected) and legacy pre-iWork '13 files are politely refused — nothing about them leaves the browser either.</p> 49 </div> 50 </div> 51 52 <main id="view" class="hidden"></main> 53 </div> 54 55 <div id="drag-overlay" class="hidden" aria-hidden="true"> 56 <div class="drag-overlay-card">Drop to open<br> 57 <span class="drag-overlay-sub"><code>.pages</code> · <code>.numbers</code> · <code>.key</code> — parsed locally, never uploaded</span> 58 </div> 59 </div> 60 <div id="parse-status" class="hidden" role="status"></div> 61 62 <input type="file" id="file-input" class="hidden" 63 accept=".pages,.numbers,.key,.pages.zip,.numbers.zip,.key.zip,application/vnd.apple.pages,application/vnd.apple.numbers,application/vnd.apple.keynote"> 64 65 <script type="module" src="main.js"></script> 66 </body> 67 </html>
grep -n "panel-fonts\|fonts-list\|fonts-count\|panel-warnings\|app-header\|doc-filename\|doc-meta\|app-badge\|parse-status\|sticky" /Users/phebert/pnk/viewer/src/main.ts | head -25; wc -l /Users/phebert/pnk/viewer/src/main.ts /Users/phebert/pnk/viewer/styles.css
24: $("app-header").classList.add("hidden");
25: $("panel-fonts").classList.add("hidden");
26: $("panel-warnings").classList.add("hidden");
33: $("doc-filename").textContent = filename;
34: $("app-badge").textContent = doc.meta.application ?? doc.meta.app;
41: $("doc-meta").textContent = meta.join(" · ");
43: $("app-header").classList.remove("hidden");
79: $("app-header").classList.remove("hidden");
80: $("doc-filename").textContent = filename;
81: $("app-badge").textContent = "rejected";
82: $("doc-meta").textContent = "";
92: const status = $("parse-status");
177 /Users/phebert/pnk/viewer/src/main.ts
393 /Users/phebert/pnk/viewer/styles.css
570 total1 // pnk viewer entry point: init the wasm converter, wire the drop zone /
2 // file picker, and dispatch to the per-app renderers. No network calls after
3 // the static assets load — the file is parsed in-process and never uploaded.
4
5 import init, { convert, media_bytes } from "./wasm/pnk2json_wasm.js";
6 import { ViewerCtx } from "./ctx";
7 import { hydrate } from "./hydrate";
8 import { mapError, renderErrorCard } from "./errors";
9 import { renderKeynote } from "./keynote";
10 import { renderNumbers } from "./numbers";
11 import { setTableLocale } from "./tables";
12 import { renderPages } from "./pages";
13 import { renderFonts, renderWarnings } from "./warnings";
14 import type { PnkDocument } from "../../model/src/shared";
15
16 let ctx: ViewerCtx | null = null;
17 let lastJson: { text: string; filename: string } | null = null;
18
19 const $ = (id: string) => document.getElementById(id)!;
20
21 function showLanding(): void {
22 ctx?.dispose();
23 ctx = null;
24 $("app-header").classList.add("hidden");
25 $("panel-fonts").classList.add("hidden");
26 $("panel-warnings").classList.add("hidden");
27 $("view").classList.add("hidden");
28 $("view").replaceChildren();
29 $("drop-zone").classList.remove("hidden");
30 }
31
32 function renderHeader(doc: PnkDocument, filename: string): void {
33 $("doc-filename").textContent = filename;
34 $("app-badge").textContent = doc.meta.application ?? doc.meta.app;
35 const meta: string[] = [];
36 if (doc.meta.fileFormatVersion) meta.push(`format ${doc.meta.fileFormatVersion}`);
37 if (doc.meta.createdAt) meta.push(`created ${doc.meta.createdAt.slice(0, 10)}`);
38 if (doc.meta.modifiedAt) meta.push(`modified ${doc.meta.modifiedAt.slice(0, 10)}`);
39 if (doc.meta.locale) meta.push(doc.meta.locale);
40 if (doc.meta.documentId) meta.push(`id ${doc.meta.documentId.slice(0, 8)}`);
41 $("doc-meta").textContent = meta.join(" · ");
42 $("json-btn").classList.remove("hidden");
43 $("app-header").classList.remove("hidden");
44 }
45
46 function renderDocument(doc: PnkDocument, filename: string): void {
47 ctx?.dispose();
48 const mediaCtx = new ViewerCtx();
49 ctx = mediaCtx;
50
51 // media bytes: per-dataId raw fetch from the wasm side (no base64 in the
52 // envelope); missing bytes render as a labeled placeholder instead
53 if (typeof media_bytes === "function") {
54 for (const asset of doc.media) {
55 const bytes = media_bytes(asset.dataId);
56 if (bytes) mediaCtx.addMedia(asset.dataId, bytes, asset.fileName ?? asset.preferredFileName);
57 }
58 }
59
60 renderHeader(doc, filename);
61 setTableLocale(doc.meta.locale);
62 renderFonts(doc.fonts);
63 renderWarnings(doc.warnings);
64
65 const view = $("view");
66 view.replaceChildren();
67 view.classList.remove("hidden");
68 $("drop-zone").classList.add("hidden");
69
70 if (doc.kind === "keynote") renderKeynote(doc, hydrate(doc), mediaCtx, view);
71 else if (doc.kind === "numbers") renderNumbers(doc, hydrate(doc), mediaCtx, view);
72 else renderPages(doc, hydrate(doc), mediaCtx, view);
73 }
74
75 function showError(err: unknown, filename: string): void {
76 lastJson = null;
77 $("json-btn").classList.add("hidden");
78 $("drop-zone").classList.add("hidden");
79 $("app-header").classList.remove("hidden");
80 $("doc-filename").textContent = filename;
81 $("app-badge").textContent = "rejected";
82 $("doc-meta").textContent = "";
83 const view = $("view");
84 view.replaceChildren();
85 view.classList.remove("hidden");
86 renderErrorCard(mapError(err, filename), view);
87 }
88
89 async function handleFile(file: File): Promise<void> {
90 // The current document (or landing card) stays on screen while we parse;
91 // the swap happens only once the new document is ready (or errors out).
92 const status = $("parse-status");
93 status.textContent = `Parsing ${file.name}…`;
94 status.classList.remove("hidden");
95 try {
96 const bytes = new Uint8Array(await file.arrayBuffer());
97 const json = convert(bytes);
98 const doc = JSON.parse(json) as PnkDocument;
99 lastJson = { text: json, filename: file.name.replace(/\.[^.]+$/, "") + ".json" };
100 renderDocument(doc, file.name);
101 } catch (err) {
102 showError(err, file.name);
103 } finally {
104 status.classList.add("hidden");
105 }
106 }
107
108 // Whole-window drag & drop: any file drag anywhere over the app raises a
109 // full-viewport overlay; dropping loads the file, whatever view is showing.
110 function wireDragAndDrop(): void {
111 const overlay = $("drag-overlay");
112 const target = $("drop-target");
113 let depth = 0; // dragenter/leave fire per descendant element — count them
114
115 const isFileDrag = (e: DragEvent) =>
116 Array.from(e.dataTransfer?.types ?? []).includes("Files");
117 const hideOverlay = () => {
118 depth = 0;
119 overlay.classList.add("hidden");
120 target.classList.remove("dragover");
121 };
122
123 window.addEventListener("dragenter", (e) => {
124 if (!isFileDrag(e)) return;
125 e.preventDefault();
126 depth++;
127 overlay.classList.remove("hidden");
128 target.classList.add("dragover");
129 });
130 window.addEventListener("dragover", (e) => {
131 if (isFileDrag(e)) e.preventDefault();
132 });
133 window.addEventListener("dragleave", (e) => {
134 if (!isFileDrag(e)) return;
135 if (--depth <= 0) hideOverlay();
136 });
137 window.addEventListener("drop", (e) => {
138 e.preventDefault();
139 hideOverlay();
140 const file = e.dataTransfer?.files?.[0];
141 if (file) handleFile(file);
142 });
143 }
144
145 function wireEvents(): void {
146 const input = $("file-input") as HTMLInputElement;
147
148 $("pick-btn").addEventListener("click", () => input.click());
149 $("reset-btn").addEventListener("click", () => {
150 input.value = "";
151 showLanding();
152 });
153 // Download the converted JSON model (blob URL — still no network, no upload)
154 $("json-btn").addEventListener("click", () => {
155 if (!lastJson) return;
156 const url = URL.createObjectURL(new Blob([lastJson.text], { type: "application/json" }));
157 const a = document.createElement("a");
158 a.href = url;
159 a.download = lastJson.filename;
160 a.click();
161 URL.revokeObjectURL(url);
162 });
163 input.addEventListener("change", () => {
164 if (input.files?.[0]) handleFile(input.files[0]);
165 });
166 wireDragAndDrop();
167 }
168
169 async function boot(): Promise<void> {
170 await init("wasm/pnk2json_wasm_bg.wasm");
171 wireEvents();
172 $("drop-hint").textContent =
173 "Encrypted (password-protected) and legacy pre-iWork '13 files are politely refused — nothing about them leaves the browser either.";
174 }
175
176 boot().catch((err) => {
177 $("drop-hint").textContent = `Failed to load the local parser: ${err}`;
178 });40 body {
41 margin: 0;
42 font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
43 color: var(--ink);
44 background: var(--surface);
45 }
46
47 #app { max-width: 1100px; margin: 0 auto; padding: 16px; }
48
49 /* ---- landing / drop zone ------------------------------------------------ */
50
51 #drop-zone { padding: 8vh 0; }
52 #drop-card {
53 background: var(--card);
54 border: 1px solid var(--line);
55 border-radius: 16px;
56 padding: 40px 48px;
57 text-align: center;
58 }
59 #drop-card h1 { margin: 0; font-size: 44px; letter-spacing: 2px; color: var(--accent); }
60 #drop-card .tagline { margin: 12px 0 16px; }
61
62 .fmt-chips { display: flex; justify-content: center; gap: 10px; margin: 0 0 24px; }
63 .fmt-chip {
64 border: 1px solid var(--line); border-radius: 999px;
65 padding: 4px 14px; font-size: 13px; font-weight: 600; background: var(--surface);
66 }
67 .fmt-pages { color: #b3730d; }
68 .fmt-numbers { color: #1e7e34; }
69 .fmt-keynote { color: #1a6ec0; }
70
71 #drop-target {
72 border: 2px dashed var(--line);
73 border-radius: 12px;
74 padding: 36px 20px;
75 transition: border-color .15s, background .15s;
76 }
77 #drop-target.dragover { border-color: var(--accent); background: #fdf1f0; }
78 #drop-target code { background: var(--surface); padding: 1px 5px; border-radius: 4px; }
79
80 button {
81 font: inherit;
82 padding: 8px 18px;
83 border-radius: 8px;
84 border: 1px solid var(--line);
85 background: var(--card);
86 cursor: pointer;
87 }
88 button:hover { border-color: var(--muted); }
89
90 /* ---- whole-window drag overlay + parse toast ----------------------------- */
91
92 #drag-overlay {
93 position: fixed; inset: 0; z-index: 1000;
94 display: flex; align-items: center; justify-content: center;
95 background: rgba(245, 245, 247, .82);
96 backdrop-filter: blur(3px);
97 pointer-events: none; /* drops land on window, not this layer */
98 }
99 .drag-overlay-card {
100 border: 3px dashed var(--accent);
101 border-radius: 20px;
102 padding: 48px 72px;
103 font-size: 28px; font-weight: 700; color: var(--accent);
104 text-align: center; background: var(--card);
105 box-shadow: 0 8px 40px rgba(0,0,0,.12);
106 }
107 .drag-overlay-sub { font-size: 14px; font-weight: 400; color: var(--muted); }
108 .drag-overlay-sub code { background: var(--surface); padding: 1px 5px; border-radius: 4px; }
109
110 #parse-status {
111 position: fixed; left: 50%; bottom: 28px; transform: translateX(-50%);
112 z-index: 1001;
113 background: var(--ink); color: #fff;
114 border-radius: 999px; padding: 8px 20px; font-size: 13px;
115 box-shadow: 0 4px 16px rgba(0,0,0,.25);
116 }
117
118 /* ---- header / envelope -------------------------------------------------- */
119
120 #app-header {
121 display: flex;
122 align-items: center;
123 gap: 12px;
124 padding: 10px 4px;
125 flex-wrap: wrap;
126 position: sticky; top: 0; z-index: 20;
127 background: color-mix(in srgb, var(--surface) 88%, transparent);
128 backdrop-filter: blur(6px);
129 }
130 #doc-filename { font-weight: 600; word-break: break-all; }
131 .badge {
132 background: var(--accent);
133 color: #fff;
134 border-radius: 999px;
135 padding: 2px 12px;
136 font-size: 12px;
137 font-weight: 700;
138 text-transform: uppercase;
139 letter-spacing: 1px;
140 }
141 .pill {
142 display: inline-block;
143 min-width: 20px;
144 text-align: center;
145 background: var(--surface);
146 border-radius: 999px;
147 padding: 0 7px;
148 font-size: 12px;
149 }
150
151 .panel {
152 background: var(--card);
153 border: 1px solid var(--line);
154 border-radius: 10px;
155 padding: 6px 14px;
156 margin-bottom: 10px;
157 font-size: 13px;
158 }
159 .panel summary { cursor: pointer; user-select: none; }
160 .chips { display: flex; flex-wrap: wrap; gap: 6px; padding: 8px 0; }
161 .chips .chip {
162 background: var(--surface);
163 border: 1px solid var(--line);
164 border-radius: 999px;
165 padding: 2px 10px;
166 font-size: 12px;
167 }
168 #warnings-list { padding: 6px 0; }
169 .warning-row { display: flex; gap: 10px; padding: 3px 0; border-top: 1px solid var(--surface); }
170 .warning-row code { color: var(--muted); flex: 0 0 160px; }
171 .warning-row .path { color: var(--muted); font-style: italic; }
172
173 /* ---- error card ---------------------------------------------------------- */
174
175 .error-card {
176 background: #fff6f5;
177 border: 1px solid #f2c4c0;
178 border-left: 4px solid var(--accent);
179 border-radius: 10px;
180 padding: 18px 22px;
181 margin: 24px 0;
182 }
183 .error-card h2 { margin: 0 0 8px; color: var(--accent); font-size: 18px; }
184 .error-card .error-detail { margin: 8px 0 0; font-size: 13px; color: var(--muted); word-break: break-word; }
185
186 /* ---- canvases (Keynote slides / Pages layout pages) ---------------------- */
187
188 .canvas-outer { margin: 18px 0; }
189 .canvas-frame { /* scaled wrapper: aspect kept via padding trick below */grep -n "fonts\|warnings\|app-badge\|doc-filename\|drop-hint\|panel" /Users/phebert/pnk/viewer/tests/gate.spec.ts; cat /Users/phebert/pnk/viewer/src/warnings.ts
62: await expect(page.locator("#drop-hint")).toContainText("nothing about them leaves the browser");
74: await expect(page.locator("#app-badge")).toContainText(/keynote/i);
77: // Continuous-scroll view: a .notes-panel renders only for slides with
79: // no panel may appear (the old UI showed 11 empty yellow strips).
80: await expect(page.locator(".notes-panel")).toHaveCount(0);
// Envelope UI: fonts (font-ready indicator) and warnings (collapsible).
// Warnings never block rendering — unknown-object-type and friends are shown
// as context alongside the rendered document.
import type { Warning } from "../../model/src/shared";
const CODE_LABELS: Record<string, string> = {
"unknown-object-type": "unknown object type",
"undecodable-object": "undecodable object",
"unresolved-reference": "unresolved reference",
"unsupported-feature": "unsupported feature",
"media-missing": "media missing",
"color-degraded": "color degraded",
"legacy-variant": "legacy variant",
"table-degraded": "degraded table",
"formula-unparsed": "formula not parsed",
};
export function renderFonts(fonts: string[]): void {
const panel = document.getElementById("panel-fonts");
if (!panel) return;
panel.classList.remove("hidden");
document.getElementById("fonts-count")!.textContent = String(fonts.length);
const list = document.getElementById("fonts-list")!;
list.replaceChildren();
for (const f of fonts) {
const chip = document.createElement("span");
chip.className = "chip";
chip.textContent = f;
list.appendChild(chip);
}
if (fonts.length === 0) list.textContent = "none";
}
export function renderWarnings(warnings: Warning[]): void {
const panel = document.getElementById("panel-warnings");
if (!panel) return;
panel.classList.remove("hidden");
const total = warnings.reduce((n, w) => n + (w.count ?? 1), 0);
document.getElementById("warnings-count")!.textContent = String(total);
const list = document.getElementById("warnings-list")!;
list.replaceChildren();
// summary: count per code, so a pile of unknown-type-ids stays one line
const byCode = new Map<string, number>();
for (const w of warnings) byCode.set(w.code, (byCode.get(w.code) ?? 0) + (w.count ?? 1));
const summary = document.createElement("div");
summary.className = "chips";
for (const [code, count] of byCode) {
const chip = document.createElement("span");
chip.className = "chip";
chip.dataset.warningCode = code;
chip.textContent = `${CODE_LABELS[code] ?? code} × ${count}`;
summary.appendChild(chip);
}
if (warnings.length > 0) list.appendChild(summary);
for (const w of warnings) {
const row = document.createElement("div");
row.className = "warning-row";
const code = document.createElement("code");
code.textContent = CODE_LABELS[w.code] ?? w.code;
row.appendChild(code);
const msg = document.createElement("span");
msg.textContent = w.count && w.count > 1 ? `${w.message} (×${w.count})` : w.message;
row.appendChild(msg);
const pathText = w.path ?? (w.paths ? w.paths.join(", ") + (w.count && w.count > w.paths.length ? ", …" : "") : undefined);
if (pathText) {
const path = document.createElement("span");
path.className = "path";
path.textContent = pathText;
row.appendChild(path);
}
list.appendChild(row);
}
if (warnings.length === 0) list.textContent = "No warnings — clean decode.";
}<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>pnk.vu — iWork, locally</title>
<link rel="stylesheet" href="styles.css">
</head>
<body>
<nav id="topnav">
<a id="brand" href="#" title="pnk.vu — back to start">pnk<span class="brand-tld">.vu</span></a>
<span id="doc-filename" class="hidden"></span>
<span id="app-badge" class="badge hidden"></span>
<span id="doc-meta" class="muted hidden"></span>
<span class="spacer"></span>
<details id="warnings-dd" class="hidden">
<summary title="Decode warnings"><span class="warn-mark">▲</span> <span id="warnings-count">0</span></summary>
<div id="warnings-list"></div>
</details>
<button id="json-btn" type="button" class="hidden" title="Download the converted JSON model">json</button>
<button id="reset-btn" type="button" title="Open a file">open…</button>
</nav>
<div id="app">
<div id="drop-zone">
<div id="drop-card">
<h1>pnk<span class="brand-tld">.vu</span></h1>
<p class="tagline">a client-side viewer for Apple iWork files.<br>
<strong>everything runs in this browser</strong> — no account, no upload, no backend.</p>
<div class="fmt-chips">
<span class="fmt-chip fmt-pages"><i></i>.pages</span>
<span class="fmt-chip fmt-numbers"><i></i>.numbers</span>
<span class="fmt-chip fmt-keynote"><i></i>.key</span>
</div>
<div id="drop-target">drop a file anywhere<br>
<span class="muted">or</span><br>
<button id="pick-btn" type="button">choose a file…</button>
</div>
<p class="muted" id="drop-hint">Encrypted (password-protected) and legacy pre-iWork '13 files are politely refused — nothing about them leaves the browser either.</p>
</div>
</div>
<main id="view" class="hidden"></main>
</div>
<div id="drag-overlay" class="hidden" aria-hidden="true">
<div class="drag-overlay-card">drop to open<br>
<span class="drag-overlay-sub"><code>.pages</code> · <code>.numbers</code> · <code>.key</code> — parsed locally, never uploaded</span>
</div>
</div>
<div id="parse-status" class="hidden" role="status"></div>
<input type="file" id="file-input" class="hidden"
accept=".pages,.numbers,.key,.pages.zip,.numbers.zip,.key.zip,application/vnd.apple.pages,application/vnd.apple.numbers,application/vnd.apple.keynote">
<script type="module" src="main.js"></script>
</body>
</html>
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)
import { renderFonts, renderWarnings } from "./warnings";
import type { PnkDocument } from "../../model/src/shared";
let ctx: ViewerCtx | null = null;
let lastJson: { text: string; filename: string } | null = null;
const $ = (id: string) => document.getElementById(id)!;
function showLanding(): void {
ctx?.dispose();
ctx = null;
$("app-header").classList.add("hidden");
$("panel-fonts").classList.add("hidden");
$("panel-warnings").classList.add("hidden");
$("view").classList.add("hidden");
$("view").replaceChildren();
$("drop-zone").classList.remove("hidden");
}
function renderHeader(doc: PnkDocument, filename: string): void {
$("doc-filename").textContent = filename;
$("app-badge").textContent = doc.meta.application ?? doc.meta.app;
const meta: string[] = [];
if (doc.meta.fileFormatVersion) meta.push(`format ${doc.meta.fileFormatVersion}`);
if (doc.meta.createdAt) meta.push(`created ${doc.meta.createdAt.slice(0, 10)}`);
if (doc.meta.modifiedAt) meta.push(`modified ${doc.meta.modifiedAt.slice(0, 10)}`);
if (doc.meta.locale) meta.push(doc.meta.locale);
if (doc.meta.documentId) meta.push(`id ${doc.meta.documentId.slice(0, 8)}`);
$("doc-meta").textContent = meta.join(" · ");
$("json-btn").classList.remove("hidden");
$("app-header").classList.remove("hidden");
}import { renderWarnings } from "./warnings";
import type { PnkDocument } from "../../model/src/shared";
let ctx: ViewerCtx | null = null;
let lastJson: { text: string; filename: string } | null = null;
const $ = (id: string) => document.getElementById(id)!;
function showLanding(): void {
ctx?.dispose();
ctx = null;
for (const id of ["doc-filename", "app-badge", "doc-meta", "warnings-dd", "json-btn"]) {
$(id).classList.add("hidden");
}
$("view").classList.add("hidden");
$("view").replaceChildren();
$("drop-zone").classList.remove("hidden");
}
function renderHeader(doc: PnkDocument, filename: string): void {
$("doc-filename").textContent = filename;
const badge = $("app-badge");
badge.textContent = doc.meta.application ?? doc.meta.app;
badge.dataset.app = doc.kind;
const meta: string[] = [];
if (doc.meta.fileFormatVersion) meta.push(`v${doc.meta.fileFormatVersion}`);
if (doc.meta.locale) meta.push(doc.meta.locale);
$("doc-meta").textContent = meta.join(" · ");
for (const id of ["doc-filename", "app-badge", "doc-meta", "json-btn"]) {
$(id).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)
renderHeader(doc, filename); setTableLocale(doc.meta.locale); renderFonts(doc.fonts); renderWarnings(doc.warnings);
renderHeader(doc, filename); setTableLocale(doc.meta.locale); renderWarnings(doc.warnings);
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)
function showError(err: unknown, filename: string): void {
lastJson = null;
$("json-btn").classList.add("hidden");
$("drop-zone").classList.add("hidden");
$("app-header").classList.remove("hidden");
$("doc-filename").textContent = filename;
$("app-badge").textContent = "rejected";
$("doc-meta").textContent = "";function showError(err: unknown, filename: string): void {
lastJson = null;
$("json-btn").classList.add("hidden");
$("warnings-dd").classList.add("hidden");
$("drop-zone").classList.add("hidden");
$("doc-filename").textContent = filename;
$("doc-filename").classList.remove("hidden");
const badge = $("app-badge");
badge.textContent = "rejected";
delete badge.dataset.app;
badge.classList.remove("hidden");
$("doc-meta").textContent = "";
$("doc-meta").classList.add("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)
$("pick-btn").addEventListener("click", () => input.click());
$("reset-btn").addEventListener("click", () => {
input.value = "";
showLanding();
}); $("pick-btn").addEventListener("click", () => input.click());
// nav "open…" goes straight to the picker; the brand is the way home
$("reset-btn").addEventListener("click", () => {
input.value = "";
input.click();
});
$("brand").addEventListener("click", (e) => {
e.preventDefault();
showLanding();
});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)
// Envelope UI: fonts (font-ready indicator) and warnings (collapsible). // Warnings never block rendering — unknown-object-type and friends are shown // as context alongside the rendered document.
// Envelope UI: the nav warnings indicator + dropdown. Warnings never block // rendering — unknown-object-type and friends are context alongside the // rendered document, and a clean decode shows no indicator at all.
The file /Users/phebert/pnk/viewer/src/warnings.ts has been updated successfully. (file state is current in your context — no need to Read it back)
export function renderFonts(fonts: string[]): void {
const panel = document.getElementById("panel-fonts");
if (!panel) return;
panel.classList.remove("hidden");
document.getElementById("fonts-count")!.textContent = String(fonts.length);
const list = document.getElementById("fonts-list")!;
list.replaceChildren();
for (const f of fonts) {
const chip = document.createElement("span");
chip.className = "chip";
chip.textContent = f;
list.appendChild(chip);
}
if (fonts.length === 0) list.textContent = "none";
}
export function renderWarnings(warnings: Warning[]): void {
const panel = document.getElementById("panel-warnings");
if (!panel) return;
panel.classList.remove("hidden");
const total = warnings.reduce((n, w) => n + (w.count ?? 1), 0);
document.getElementById("warnings-count")!.textContent = String(total);export function renderWarnings(warnings: Warning[]): void {
const panel = document.getElementById("warnings-dd") as HTMLDetailsElement | null;
if (!panel) return;
const total = warnings.reduce((n, w) => n + (w.count ?? 1), 0);
panel.classList.toggle("hidden", total === 0);
panel.open = false;
document.getElementById("warnings-count")!.textContent = String(total);The file /Users/phebert/pnk/viewer/src/warnings.ts has been updated successfully. (file state is current in your context — no need to Read it back)
list.appendChild(row); } if (warnings.length === 0) list.textContent = "No warnings — clean decode."; }
list.appendChild(row); } }
The file /Users/phebert/pnk/viewer/src/warnings.ts has been updated successfully. (file state is current in your context — no need to Read it back)
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
color: var(--ink);
background: var(--surface);
}
#app { max-width: 1100px; margin: 0 auto; padding: 16px; }
/* ---- landing / drop zone ------------------------------------------------ */
#drop-zone { padding: 8vh 0; }
#drop-card {
background: var(--card);
border: 1px solid var(--line);
border-radius: 16px;
padding: 40px 48px;
text-align: center;
}
#drop-card h1 { margin: 0; font-size: 44px; letter-spacing: 2px; color: var(--accent); }
#drop-card .tagline { margin: 12px 0 16px; }
.fmt-chips { display: flex; justify-content: center; gap: 10px; margin: 0 0 24px; }
.fmt-chip {
border: 1px solid var(--line); border-radius: 999px;
padding: 4px 14px; font-size: 13px; font-weight: 600; background: var(--surface);
}
.fmt-pages { color: #b3730d; }
.fmt-numbers { color: #1e7e34; }
.fmt-keynote { color: #1a6ec0; }body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
color: var(--ink);
background: var(--surface);
}
.mono, #brand, .fmt-chip, #doc-filename, #doc-meta, .badge {
font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
}
#app { max-width: 1160px; margin: 0 auto; padding: 10px 16px 16px; }
/* ---- top nav ------------------------------------------------------------- */
#topnav {
position: sticky; top: 0; z-index: 40;
display: flex; align-items: center; gap: 10px;
height: 42px; padding: 0 14px;
background: color-mix(in srgb, var(--surface) 86%, transparent);
backdrop-filter: blur(8px);
border-bottom: 1px solid var(--line);
font-size: 13px;
}
#brand {
font-size: 15px; font-weight: 700; letter-spacing: .5px;
color: var(--ink); text-decoration: none;
}
.brand-tld { color: var(--accent); font-weight: 700; }
#doc-filename {
font-size: 12px; color: var(--ink);
max-width: 34vw; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
#doc-meta { font-size: 11.5px; }
@media (max-width: 860px) { #doc-meta { display: none; } }
#topnav button {
padding: 3px 12px; font-size: 12px; border-radius: 6px;
font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
}
/* subtle warnings indicator + dropdown */
#warnings-dd { position: relative; }
#warnings-dd summary {
list-style: none; cursor: pointer; user-select: none;
color: #9a7b18; font-size: 12px; padding: 2px 8px; border-radius: 6px;
}
#warnings-dd summary::-webkit-details-marker { display: none; }
#warnings-dd summary:hover, #warnings-dd[open] summary { background: rgba(200, 160, 30, .12); }
#warnings-dd .warn-mark { font-size: 9px; vertical-align: 1px; }
#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;
}
/* ---- landing / drop zone ------------------------------------------------ */
#drop-zone { padding: 9vh 0; }
#drop-card {
background: var(--card);
border: 1px solid var(--line);
border-radius: 14px;
padding: 36px 44px;
max-width: 560px; margin: 0 auto;
text-align: center;
}
#drop-card h1 {
margin: 0; font-size: 34px; letter-spacing: .5px; color: var(--ink);
font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
}
#drop-card .tagline { margin: 10px 0 18px; font-size: 14px; color: var(--muted); }
#drop-card .tagline strong { color: var(--ink); }
.fmt-chips { display: flex; justify-content: center; gap: 8px; margin: 0 0 20px; }
.fmt-chip {
border: 1px solid var(--line); border-radius: 6px;
padding: 3px 10px; font-size: 12px; background: var(--surface);
display: inline-flex; align-items: center; gap: 6px;
}
.fmt-chip i { width: 7px; height: 7px; border-radius: 50%; display: inline-block; }
.fmt-pages i { background: #e8883a; }
.fmt-numbers i { background: #35a651; }
.fmt-keynote i { background: #2f8bde; }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)
/* ---- header / envelope -------------------------------------------------- */
#app-header {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 4px;
flex-wrap: wrap;
position: sticky; top: 0; z-index: 20;
background: color-mix(in srgb, var(--surface) 88%, transparent);
backdrop-filter: blur(6px);
}
#doc-filename { font-weight: 600; word-break: break-all; }
.badge {
background: var(--accent);
color: #fff;
border-radius: 999px;
padding: 2px 12px;
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 1px;
}
.pill {
display: inline-block;
min-width: 20px;
text-align: center;
background: var(--surface);
border-radius: 999px;
padding: 0 7px;
font-size: 12px;
}
.panel {
background: var(--card);
border: 1px solid var(--line);
border-radius: 10px;
padding: 6px 14px;
margin-bottom: 10px;
font-size: 13px;
}
.panel summary { cursor: pointer; user-select: none; }
.chips { display: flex; flex-wrap: wrap; gap: 6px; padding: 8px 0; }/* ---- envelope bits ------------------------------------------------------- */
/* App badge: quiet colored text chip, not a shouty pill. */
.badge {
border: 1px solid var(--line);
border-radius: 5px;
padding: 1px 8px;
font-size: 11px;
font-weight: 600;
text-transform: lowercase;
letter-spacing: .5px;
color: var(--muted);
}
.badge[data-app="pages"] { color: #b06a20; border-color: #e0b98c; }
.badge[data-app="numbers"] { color: #2c7d43; border-color: #a3d4b1; }
.badge[data-app="keynote"] { color: #2569ab; border-color: #a7cced; }
.chips { display: flex; flex-wrap: wrap; gap: 6px; padding: 8px 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)
.slide-list {
flex: 0 0 190px; overflow-y: auto;
position: sticky; top: 58px; max-height: calc(100vh - 74px);.slide-list {
flex: 0 0 190px; overflow-y: auto;
position: sticky; top: 52px; max-height: calc(100vh - 66px);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 "panel-fonts\|panel-warnings\|app-header\|fonts-list\|fonts-count\|\.pill" /Users/phebert/pnk/viewer/src/*.ts /Users/phebert/pnk/viewer/styles.css /Users/phebert/pnk/viewer/tests/gate.spec.ts /Users/phebert/pnk/scripts/visual_diff.py
cd /Users/phebert/pnk && bash scripts/build_viewer.sh 2>&1 | tail -1 && cd viewer && npx tsc --noEmit && echo TSC-OK && npm test 2>&1 | tail -3
viewer built: viewer/dist/ (serve: cd viewer && npm run serve) TSC-OK ✓ 6 tests/gate.spec.ts:147:1 › encrypted fixture gets the password-protected explanation (94ms) 6 passed (1.6s)
cat > probe2-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: 860 } });
await page.goto("http://127.0.0.1:8123/");
await page.waitForSelector("#drop-card");
await page.screenshot({ path: `${SCRATCH}/nav-landing.png` });
// a doc that has warnings: G2 has 1
await page.setInputFiles("#file-input", "/Users/phebert/pnk/fixtures/golden/G2-golden-pages-layout.pages");
await page.waitForSelector("#view:not(.hidden)");
await page.waitForTimeout(800);
await page.screenshot({ path: `${SCRATCH}/nav-doc.png` });
const dd = page.locator("#warnings-dd summary");
if (await dd.isVisible()) { await dd.click(); await page.waitForTimeout(200); }
await page.screenshot({ path: `${SCRATCH}/nav-warn.png` });
// dark mode landing
const p2 = await b.newPage({ viewport: { width: 1280, height: 860 }, colorScheme: "dark" });
await p2.goto("http://127.0.0.1:8123/");
await p2.waitForSelector("#drop-card");
await p2.screenshot({ path: `${SCRATCH}/nav-dark.png` });
await b.close();
EOF
node probe2-tmp.mjs && rm probe2-tmp.mjs{
"file_path": "/private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/nav-landing.png"
}{
"file_path": "/private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/nav-warn.png"
}{
"file_path": "/private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/nav-dark.png"
}cd /Users/phebert/pnk && git add viewer/index.html viewer/src/main.ts viewer/src/warnings.ts viewer/styles.css && git commit -m "viewer: pnk.vu — top nav bar, minimalist dev-forward chrome The shell becomes a product: a slim sticky nav (42px) carries the monospace wordmark pnk.vu (accent .vu — the brand doubles as the way home), the open filename, a quiet per-app badge (colored text chip: pages orange / numbers green / keynote blue — no more shouty red pill), compact meta, and right-aligned json/open actions. - Fonts list: gone (it was inspector chrome, not viewer UI). - Warnings: demoted from a full-width panel to a small amber ▲ n indicator that only exists when the decode produced warnings; the detail rows live in a right-anchored dropdown. Clean decodes show nothing at all. - Landing card restyled to match: mono wordmark, lowercase tagline, dot-labeled .pages/.numbers/.key chips, tighter paddings. - nav 'open…' goes straight to the file picker; clicking the brand returns to the landing card. Verified light + dark, doc + landing + warnings-dropdown states via Playwright screenshots; gate 6/6 (tsc + Playwright). 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, 136 insertions(+), 123 deletions(-) bc5004d..95c7ada main -> main
Done and pushed as 95c7ada. The viewer is now pnk.vu:
pnk.vu wordmark with the accent-red .vu, then the filename, a quiet app badge — colored text chips (pages orange / numbers green / keynote blue) replacing the old red pill — compact format/locale meta, and right-aligned json / open… actions. The brand is the way home; open… goes straight to the picker.▲ n in the nav that exists only when the decode actually produced warnings; clicking it drops down the detail rows. Clean decodes show nothing..pages / .numbers / .key chips, tighter spacing.Verified by screenshot in light and dark mode across landing, document, and warnings-dropdown states; tsc clean and Playwright gate 6/6.
One taste note you might want to weigh in on: the wordmark renders as pnk.vu in SF Mono with the dot getting a full monospace cell — I kept it because the airiness reads intentionally terminal-ish, but it's a one-line change if you'd rather tighten the dot.
grep -n "drop-hint\|politely" /Users/phebert/pnk/viewer/src/main.ts /Users/phebert/pnk/viewer/tests/gate.spec.ts
/Users/phebert/pnk/viewer/tests/gate.spec.ts:62: await expect(page.locator("#drop-hint")).toContainText("nothing about them leaves the browser");
/Users/phebert/pnk/viewer/src/main.ts:181: $("drop-hint").textContent =
/Users/phebert/pnk/viewer/src/main.ts:182: "Encrypted (password-protected) and legacy pre-iWork '13 files are politely refused — nothing about them leaves the browser either.";
/Users/phebert/pnk/viewer/src/main.ts:186: $("drop-hint").textContent = `Failed to load the local parser: ${err}`; <p class="tagline">a client-side viewer for Apple iWork files.<br>
<strong>everything runs in this browser</strong> — no account, no upload, no backend.</p>
<div class="fmt-chips">
<span class="fmt-chip fmt-pages"><i></i>.pages</span>
<span class="fmt-chip fmt-numbers"><i></i>.numbers</span>
<span class="fmt-chip fmt-keynote"><i></i>.key</span>
</div>
<div id="drop-target">drop a file anywhere<br>
<span class="muted">or</span><br>
<button id="pick-btn" type="button">choose a file…</button>
</div>
<p class="muted" id="drop-hint">Encrypted (password-protected) and legacy pre-iWork '13 files are politely refused — nothing about them leaves the browser either.</p> <p class="tagline">view iWork files in your browser. nothing is uploaded.</p>
<div class="fmt-chips">
<span class="fmt-chip fmt-pages"><i></i>.pages</span>
<span class="fmt-chip fmt-numbers"><i></i>.numbers</span>
<span class="fmt-chip fmt-keynote"><i></i>.key</span>
</div>
<div id="drop-target">drop a file anywhere<br>
<button id="pick-btn" type="button">choose a file…</button>
</div>
<p class="muted" id="drop-hint">encrypted and pre-2013 files are refused.</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)
<div class="drag-overlay-card">drop to open<br>
<span class="drag-overlay-sub"><code>.pages</code> · <code>.numbers</code> · <code>.key</code> — parsed locally, never uploaded</span>
</div> <div class="drag-overlay-card">drop to open<br>
<span class="drag-overlay-sub"><code>.pages</code> · <code>.numbers</code> · <code>.key</code></span>
</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)
<button id="json-btn" type="button" class="hidden" title="Download the converted JSON model">json</button>
<button id="reset-btn" type="button" title="Open a file">open…</button> <button id="json-btn" type="button" class="hidden" title="Download the converted JSON model">json</button>
<button id="reset-btn" type="button" class="hidden" title="Open a file">open…</button>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)
for (const id of ["doc-filename", "app-badge", "doc-meta", "warnings-dd", "json-btn"]) {
$(id).classList.add("hidden");
} // the landing card's own CTA is the only "open" on the landing screen
for (const id of ["doc-filename", "app-badge", "doc-meta", "warnings-dd", "json-btn", "reset-btn"]) {
$(id).classList.add("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)
for (const id of ["doc-filename", "app-badge", "doc-meta", "json-btn"]) {
$(id).classList.remove("hidden");
} for (const id of ["doc-filename", "app-badge", "doc-meta", "json-btn", "reset-btn"]) {
$(id).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)
badge.classList.remove("hidden");
$("doc-meta").textContent = "";
$("doc-meta").classList.add("hidden"); badge.classList.remove("hidden");
$("reset-btn").classList.remove("hidden");
$("doc-meta").textContent = "";
$("doc-meta").classList.add("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)
$("drop-hint").textContent =
"Encrypted (password-protected) and legacy pre-iWork '13 files are politely refused — nothing about them leaves the browser either."; $("drop-hint").textContent = "encrypted and pre-2013 files are refused.";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("nothing about them leaves the browser"); await expect(page.locator("#drop-hint")).toContainText("encrypted and pre-2013 files are refused");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)
#drop-target {
border: 2px dashed var(--line);
border-radius: 12px;
padding: 36px 20px;
transition: border-color .15s, background .15s;
}#drop-target {
border: 2px dashed var(--line);
border-radius: 12px;
padding: 28px 20px;
transition: border-color .15s, background .15s;
color: var(--muted);
}
#pick-btn {
margin-top: 12px;
background: var(--accent); color: #fff; border-color: var(--accent);
font-weight: 600;
}
#pick-btn:hover { background: #b73632; border-color: #b73632; }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 && npm test 2>&1 | tail -2 && cat > probe3-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: 760 } });
await page.goto("http://127.0.0.1:8123/");
await page.waitForSelector("#drop-card");
await page.screenshot({ path: `${SCRATCH}/landing2.png` });
await b.close();
EOF
node probe3-tmp.mjs && rm probe3-tmp.mjscat > probe3-tmp.mjs <<'EOF'
import { chromium } from "playwright";
const b = await chromium.launch();
const page = await b.newPage({ viewport: { width: 1280, height: 760 } });
await page.goto("http://127.0.0.1:8123/");
await page.waitForSelector("#drop-card");
await page.screenshot({ path: "/private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/landing2.png" });
await b.close();
EOF
node probe3-tmp.mjs && rm probe3-tmp.mjs{
"file_path": "/private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/landing2.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: landing trimmed to a sentence; one open CTA, colored Copy cut to the bone: tagline is 'view iWork files in your browser. nothing is uploaded.', hint is 'encrypted and pre-2013 files are refused.', drag overlay loses its reassurance subtitle. The nav 'open…' button now appears only while a document (or error) is showing — on the landing screen the card's own 'choose a file…' is the single open affordance, and it's painted as the primary CTA (accent). Gate 6/6 (hint assertion updated to the new copy). 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, 18 insertions(+), 12 deletions(-) 95c7ada..a85b362 main -> main
Pushed as a85b362. The landing is now one sentence — "view iWork files in your browser. nothing is uploaded." — with the chips, the drop zone, and a six-word hint. The nav open… button only exists while a document or error is showing, so the accent-red "choose a file…" is the lone CTA dead-center on the landing. Gate 6/6 with the hint assertion updated.
Nice feature. Plan: json in the nav becomes a toggle to a pretty-printed, syntax-colored JSON panel (document DOM stays alive underneath), with a download button in the panel — coloring capped for multi-MB envelopes so the monster docs don't explode the DOM.
<main id="view" class="hidden"></main> </div>
<main id="view" class="hidden"></main>
<div id="json-view" class="hidden">
<div id="json-toolbar">
<span id="json-size" class="muted"></span>
<span class="spacer"></span>
<button id="json-dl-btn" type="button" title="Save the JSON model to a file">download</button>
</div>
<pre id="json-pre"></pre>
</div>
</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)
let ctx: ViewerCtx | null = null;
let lastJson: { text: string; filename: string } | null = null;
const $ = (id: string) => document.getElementById(id)!;
function showLanding(): void {
ctx?.dispose();
ctx = null;
// the landing card's own CTA is the only "open" on the landing screen
for (const id of ["doc-filename", "app-badge", "doc-meta", "warnings-dd", "json-btn", "reset-btn"]) {
$(id).classList.add("hidden");
}
$("view").classList.add("hidden");
$("view").replaceChildren();
$("drop-zone").classList.remove("hidden");
}let ctx: ViewerCtx | null = null;
let lastJson: { text: string; filename: string } | null = null;
let jsonRendered = false;
const $ = (id: string) => document.getElementById(id)!;
function closeJsonView(): void {
jsonRendered = false;
$("json-view").classList.add("hidden");
$("json-pre").replaceChildren();
$("json-btn").classList.remove("active");
}
function showLanding(): void {
ctx?.dispose();
ctx = null;
closeJsonView();
// the landing card's own CTA is the only "open" on the landing screen
for (const id of ["doc-filename", "app-badge", "doc-meta", "warnings-dd", "json-btn", "reset-btn"]) {
$(id).classList.add("hidden");
}
$("view").classList.add("hidden");
$("view").replaceChildren();
$("drop-zone").classList.remove("hidden");
}
// 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;
function highlightJson(pretty: string): string {
const esc = pretty.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
return esc.replace(
/("(?:[^"\\]|\\.)*")(\s*:)?|\b(true|false|null)\b|-?\b\d+(?:\.\d+)?(?:[eE][+-]?\d+)?\b/g,
(m, str: string | undefined, colon: string | undefined, kw: string | undefined) => {
if (str !== undefined) {
return colon !== undefined
? `<span class="j-key">${str}</span>${colon}`
: `<span class="j-str">${str}</span>`;
}
if (kw !== undefined) return `<span class="j-kw">${kw}</span>`;
return `<span class="j-num">${m}</span>`;
},
);
}
function toggleJsonView(): void {
if (!lastJson) return;
const panel = $("json-view");
const showing = !panel.classList.contains("hidden");
if (showing) {
panel.classList.add("hidden");
$("view").classList.remove("hidden");
$("json-btn").classList.remove("active");
return;
}
if (!jsonRendered) {
jsonRendered = true;
const pretty = JSON.stringify(JSON.parse(lastJson.text), null, 2);
const kb = pretty.length / 1024;
$("json-size").textContent = `${lastJson.filename} · ${kb >= 1024 ? (kb / 1024).toFixed(1) + " MB" : Math.ceil(kb) + " KB"} pretty-printed`;
const pre = $("json-pre");
if (pretty.length <= HIGHLIGHT_LIMIT) pre.innerHTML = highlightJson(pretty);
else pre.textContent = pretty;
}
$("view").classList.add("hidden");
panel.classList.remove("hidden");
$("json-btn").classList.add("active");
}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)
renderHeader(doc, filename);
setTableLocale(doc.meta.locale);
renderWarnings(doc.warnings);
const view = $("view"); closeJsonView();
renderHeader(doc, filename);
setTableLocale(doc.meta.locale);
renderWarnings(doc.warnings);
const view = $("view");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)
function showError(err: unknown, filename: string): void {
lastJson = null;
$("json-btn").classList.add("hidden");
$("warnings-dd").classList.add("hidden");function showError(err: unknown, filename: string): void {
lastJson = null;
closeJsonView();
$("json-btn").classList.add("hidden");
$("warnings-dd").classList.add("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)
// Download the converted JSON model (blob URL — still no network, no upload)
$("json-btn").addEventListener("click", () => {
if (!lastJson) return;
const url = URL.createObjectURL(new Blob([lastJson.text], { type: "application/json" }));
const a = document.createElement("a");
a.href = url;
a.download = lastJson.filename;
a.click();
URL.revokeObjectURL(url);
}); // json = toggle the pretty-printed model view; download lives in its toolbar
$("json-btn").addEventListener("click", toggleJsonView);
// Download the converted JSON model (blob URL — still no network, no upload)
$("json-dl-btn").addEventListener("click", () => {
if (!lastJson) return;
const url = URL.createObjectURL(new Blob([lastJson.text], { type: "application/json" }));
const a = document.createElement("a");
a.href = url;
a.download = lastJson.filename;
a.click();
URL.revokeObjectURL(url);
});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)
/* ---- error card ---------------------------------------------------------- */
/* ---- JSON model view ------------------------------------------------------ */
#topnav button.active { background: var(--ink); color: var(--card); border-color: var(--ink); }
#json-toolbar {
display: flex; align-items: center; gap: 10px;
padding: 8px 2px; font-size: 12px;
font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
}
#json-toolbar button {
padding: 3px 12px; font-size: 12px; border-radius: 6px;
font-family: inherit;
}
#json-pre {
margin: 0; padding: 14px 16px;
background: var(--card); border: 1px solid var(--line); border-radius: 10px;
font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
font-size: 12px; line-height: 1.5;
overflow-x: auto; white-space: pre;
}
.j-key { color: #8250df; }
.j-str { color: #0a7d4d; }
.j-num { color: #1a6ec0; }
.j-kw { color: var(--accent); }
@media (prefers-color-scheme: dark) {
.j-key { color: #c197ff; }
.j-str { color: #5fd39a; }
.j-num { color: #6cb4f5; }
}
/* ---- 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 > probe4-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.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);
await page.screenshot({ path: `${SCRATCH}/json-view.png` });
await page.click("#json-btn");
await page.waitForTimeout(200);
const backToDoc = await page.evaluate(() => !document.getElementById("view").classList.contains("hidden"));
console.log("toggled back to doc:", backToDoc);
await b.close();
EOF
node probe4-tmp.mjs && rm probe4-tmp.mjsviewer built: viewer/dist/ (serve: cd viewer && npm run serve) 6 passed (1.5s) toggled back to doc: true
{
"file_path": "/private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/json-view.png"
}cd /Users/phebert/pnk && git add viewer/index.html viewer/src/main.ts viewer/styles.css && git commit -m "viewer: json is now a toggled, colored model view with download inside The nav 'json' button no longer fires a download — it toggles a pretty-printed, syntax-colored view of the converted model (keys purple, strings green, numbers blue, keywords accent; dark variants included). The rendered document DOM stays alive underneath, so toggling back is instant. The toolbar shows filename + pretty-printed size and carries the download button (same blob URL, still no network). Highlighting is capped at 3 MB of pretty text: a dense Numbers envelope runs to tens of MB and a span-per-token DOM would hang the tab — big models fall back to plain <pre>, fast at any size. Render is lazy (first toggle) and cached per document. Gate 6/6; toggle round-trip verified via Playwright. Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_018a2hmoDhN4kBq1BwfbFdAU" && git push origin main 2>&1 | tail -1
3 files changed, 97 insertions(+), 1 deletion(-) a85b362..f72882d main -> main
Done, pushed as f72882d. The nav json button now toggles a pretty-printed, syntax-colored view of the model — purple keys, green strings, blue numbers, with dark-mode variants — and the button shows an active (inverted) state while it's open. The toolbar above the code shows the filename and pretty-printed size, and holds the download button (same local blob URL, no network).
Two engineering details worth knowing: the rendered document DOM stays alive underneath, so toggling back is instant even on a big deck; and syntax coloring caps at 3 MB of pretty text — a dense Numbers envelope can hit tens of MB, where a span-per-token DOM would hang the tab, so those fall back to plain (still pretty-printed) text. Render is lazy on first toggle and cached per document.