take a look at 1eb960ba4ad8f155d70037395ca50b28b3993cf82d03a3d787daced092ddbd54.pages, blank lines don't seem to be blanking?
ls /Users/phebert/pnk/fixtures/crawl*/ 2>/dev/null | head -3; find /Users/phebert/pnk/fixtures -name "1eb960ba*" 2>/dev/null
/Users/phebert/pnk/fixtures/crawl_old/: 0189c9dc3da0c1477042f5e4e7594116b4baa7621f7ed747808f9833a168e14a.unknown 01e96ac0cee203c94ea3ce64f0b6f4bdd9f734d97f696c1618a2928a041a4809.unknown
find /Users/phebert/pnk/fixtures/ ~/Development/pnk-fixtures -maxdepth 3 -name "1eb960ba*" 2>/dev/null
/Users/phebert/Development/pnk-fixtures/crawl/1eb960ba4ad8f155d70037395ca50b28b3993cf82d03a3d787daced092ddbd54.pages
cd /Users/phebert/pnk && ./target/release/pnk2json --pretty ~/Development/pnk-fixtures/crawl/1eb960ba4ad8f155d70037395ca50b28b3993cf82d03a3d787daced092ddbd54.pages > /private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/1eb960ba.json 2>/private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/1eb960ba.err; echo exit=$?; wc -c /private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/1eb960ba.json; head -c 300 /private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/1eb960ba.err
exit=0 15783 /private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/1eb960ba.json
cd /private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad && python3 -c "
import json
d = json.load(open('1eb960ba.json'))
doc = d.get('document', d)
print('keys:', list(doc.keys())[:20])
body = doc.get('body') or {}
paras = body.get('paragraphs') or doc.get('paragraphs')
print('flavor:', doc.get('flavor'))
"keys: ['kind', 'flavor', 'meta', 'warnings', 'fonts', 'media', 'styles', 'pageSize', 'pageMargins', 'orientation', 'pageScale', 'body', 'floating', 'pageTemplates', 'sections'] flavor: word-processing Shell cwd was reset to /Users/phebert/pnk
python3 -c "
import json
d = json.load(open('/private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/1eb960ba.json'))
doc = d.get('document', d)
body = doc['body']
print(type(body), list(body.keys()) if isinstance(body, dict) else len(body))
paras = body['paragraphs'] if isinstance(body, dict) else body
for i, p in enumerate(paras[:40]):
if isinstance(p, str):
print(i, 'STR', repr(p[:60]))
else:
items = p.get('items')
print(i, 'style=', p.get('style'), 'items=', repr(items)[:100])
"<class 'dict'> ['paragraphs']
0 style= None items= [{'text': 'ATM-DOMINA', 'cStyle': 0}]
1 style= None items= []
2 style= None items= []
3 style= None items= [{'text': 'Verbale riunione a Roma del 7 ottobre 2019', 'cStyle': 1}]
4 style= None items= []
5 style= None items= []
6 style= None items= []
7 style= None items= [{'text': 'Presenti: Gaetano Esposito – Presidente Atm', 'cStyle': 2}]
8 style= None items= [{'text': ' Massimo Giuliano – Vice Presidente Atm e Legale Atm', 'cStyle': 2}]
9 style= None items= [{'text': ' Loredana Rasile – Consigliere Atm', 'cStyle': 2}]
10 style= None items= [{'text': ' Pier Luigi Rossi – Consigliere Atm', 'cStyle': 2}]
11 style= None items= [{'text': ' Paolo Tanturri – per Palumbalza ed. altre comproprietà ', 'cStyle': 2}]
12 style= None items= [{'text': ' Giovanni Motto – per Corvara', 'cStyle': 2}]
13 style= None items= [{'text': ' Gian Franco Pasquetti – per Corvara e Peyo', 'cStyle': 2}]
14 style= None items= [{'text': ' Gabriella Rossi-Neri – per Cortina', 'cStyle': 2}]
15 style= None items= [{'text': ' Maria Montopoli – per Tremiti e altre comproprietà', 'cStyle': 2}]
16 style= None items= []
17 style= None items= []
18 style= None items= [{'text': 'La riunione inizia alle ore 11,10 ed è ripresa in streaming.', 'cStyle': 2}]
19 style= None items= [{'text': ' ', 'cStyle': 2}]
20 style= None items= [{'text': 'Il Presidente illustra i motivi della riunione, dopo aver comunicato assieme all’avv. Giu
21 style= None items= [{'text': 'In estrema sintesi, Atm ha promosso un ricorso per la ri-determinazione delle TAA che in
22 style= None items= [{'text': "Lo studio legale Giuliano si occuperà , in collaborazione con i rappresentanti, di tutte
23 style= None items= [{'text': 'Infatti, per ottenere le necessarie/auspicate sinergie fra le “comproprietà” – convinti c
24 style= None items= [{'text': 'L’obiettivo è: sinergia fra le comproprietà e le azioni fatte e/o da fare per tutelare i
25 style= None items= [{'text': 'Se una comproprietà ottiene un risultato positivo, tutte le altre potranno usufruirne, se
26 style= None items= [{'text': 'In attesa che sia completata la rosa dei Rappresentanti, da sottoporre poi per la ratific
27 style= None items= [{'text': ' Vengono al momento individuati i sigg.:', 'cStyle': 2}]
28 style= None items= []
29 style= None items= [{'text': 'per ALASKA/CORTINA Pier Luigi ROSSI', 'cStyle': 2}]
30 style= None items= [{'text': 'per CA ZUSTO Maria MONTOPOLI', 'cStyle': 2}]
31 style= None items= [{'text': 'per COURMAYER Rossana INGALA', 'cStyle': 2}]
32 style= None items= [{'text': 'per CORVARA Giovanni MOTTO', 'cStyle': 2}]
33 style= None items= [{'text': 'per GIUDECCA Paolo LAZZARI ', 'cStyle': 2}]
34 style= None items= [{'text': 'per PORTOFINO da definire', 'cStyle': 2}]
35 style= None items= [{'text': 'per PEYO da definire', 'cStyle': 2}]
36 style= None items= [{'text': 'per PALUMBANZA Paolo TANTURRI', 'cStyle': 2}]
37 style= None items= [{'text': 'per POSITANO vedi nota', 'cStyle': 2}]
38 style= None items= [{'text': 'per TREMITI vedi nota ', 'cSty
39 style= None items= []The converter emits the blank paragraphs fine (items: []). So it's a viewer rendering issue — an empty paragraph is probably collapsing to zero height. Let me check the text renderer.
grep -n "items" /Users/phebert/pnk/viewer/src/text.ts | head -30; ls /Users/phebert/pnk/viewer/src/
189: // items where we drew lone ▶ glyphs); inline objects/fields still count
191: const hasContent = p.items.some((it) =>
253: const runs = p.items.filter(
303: let items = p.items;
306: const first = items[0];
324: items = [
326: ...items.slice(1),
330: for (const item of items) {
ctx.ts
drawables.ts
errors.ts
hydrate.ts
keynote.ts
main.ts
numbers.ts
pages.ts
tables.ts
text.ts
warnings.ts
wasm{
"file_path": "/Users/phebert/pnk/viewer/src/text.ts",
"offset": 175,
"limit": 130
}175 * One paragraph, styled from the hydrated pools. Headings by outlineLevel;
176 * list membership renders a marker (• / 1. …) with restart-aware numbering
177 * tracked in the shared ListNumberingState.
178 */
179 export function renderParagraph(
180 p: Paragraph,
181 doc: HydratedDoc,
182 ctx: ViewerCtx,
183 listState: ListNumberingState = newListNumberingState(),
184 ): HTMLElement {
185 const style = paraStyleOf(doc, p.pStyle);
186 const list = style?.list;
187 // Apple draws no marker on an EMPTY list paragraph (blank bullet lines
188 // exist only while editing — 1249b390's preview shows clean gaps between
189 // items where we drew lone ▶ glyphs); inline objects/fields still count
190 // as content.
191 const hasContent = p.items.some((it) =>
192 typeof it === "string" ? it.length > 0 : "type" in it ? true : (it as TextRun).text.length > 0,
193 );
194 const hasMarker = !!list && hasContent && list.markerKind !== "none" &&
195 (list.markerKind === "string" ? !!list.markerText : list.markerKind === "number");
196
197 const level = style?.outlineLevel ?? 0;
198 const el = level >= 1 && level <= 5
199 ? document.createElement(`h${level}`)
200 : document.createElement("p");
201
202 if (!hasMarker) {
203 listState.lastKey = null;
204 if (style) applyParaStyle(el, style);
205 } else {
206 // numbering: the stored restart flag (surfaced as list.start on the
207 // paragraph's pooled style) resets the counter; otherwise numbering
208 // CONTINUES the counter for this key — even across intervening
209 // paragraphs or nested levels, which is Pages' own "continue from
210 // previous" semantics (G5: "Four (Numbered, continued)" resumes 4 after
211 // a nested run; "Restart One" carries start=1).
212 const key = `${list!.level}:${list!.markerKind}:${list!.markerKind === "number" ? list!.numberKind : list!.markerText}`;
213 const n = list!.start !== undefined
214 ? list!.start
215 : (listState.counters.get(key) ?? 0) + 1;
216 listState.counters.set(key, n);
217 listState.lastKey = key;
218 const markerText = list!.markerKind === "number"
219 ? numberMarker(n, list!.numberKind, list!.numberSurround)
220 : (list!.markerText ?? "•");
221
222 // marker hangs in a flex row; paragraph margins live on the wrapper
223 const wrap = document.createElement("div");
224 wrap.className = "list-item";
225 if (style) {
226 applyParaStyle(wrap, style);
227 el.style.marginTop = "0";
228 el.style.marginBottom = "0";
229 el.style.marginLeft = "0";
230 }
231 // Nesting: the marker indent (absolute, per level) shifts the whole
232 // row when the paragraph style itself has no left indent — G5's nested
233 // bullets step 9/18pt per level, numbered 18/36/54pt. A NEGATIVE indent
234 // (PowerPoint-import decks store -27 at level 0: marker hangs left of
235 // the text origin) must not become a negative margin — that shifted the
236 // whole row out of the box, clipping the first glyph of every line and
237 // hiding the marker entirely (1249b390 'FileMaker Clipboard' deck).
238 if (!style?.leftIndentPt && list!.markerIndentPt && list!.markerIndentPt > 0) {
239 wrap.style.marginLeft = `${list!.markerIndentPt}px`;
240 }
241 const marker = document.createElement("span");
242 marker.className = "list-marker";
243 marker.textContent = markerText;
244 marker.style.minWidth = "18px";
245 // The marker inherits the first run's look (size + color): an unstyled
246 // span rendered 15px near-black bullets INVISIBLE on dark decks (RIPE
247 // slides 2/5: 28pt white body, default marker). Apple actually colors
248 // markers from the list style's own font_color/scale — not yet in the
249 // model (proposal sent) — so the run style is the faithful fallback.
250 // Style source: the first run with visible text (writers prepend empty
251 // runs whose styles carry no size — RIPE), preferring one that resolves
252 // to an explicit font size.
253 const runs = p.items.filter(
254 (it): it is TextRun => typeof it !== "string" && !("type" in it) && it.text.length > 0,
255 );
256 const runCs =
257 runs.map((r) => charStyleOf(doc, r.cStyle)).find((cs) => cs?.fontSizePt) ??
258 (runs.length ? charStyleOf(doc, runs[0].cStyle) : undefined);
259 if (runCs) applyCharStyle(marker, runCs);
260 marker.style.paddingRight = "0.3em"; // marker-to-text gap, scales with size
261 // The list style's OWN marker look wins over run inheritance when stored
262 // (ListFormat markerColor/markerFontName/markerScale — RIPE orange dots).
263 // markerScale multiplies the RUN size (LabelGeometry scale_with_text), so
264 // resolve to px against it — an em here would key off the wrapper's
265 // default 15px, not the paragraph's size.
266 if (list!.markerColor) marker.style.color = list!.markerColor;
267 if (list!.markerFontName) marker.style.fontFamily = `"${list!.markerFontName}", sans-serif`;
268 // Symbol-font markers (Wingdings/Webdings/Symbol) address glyphs through
269 // the U+F0xx private-use range; machines without the font draw tofu.
270 // Substitute the Unicode equivalent and let any real font draw it.
271 if (/^[-]$/.test(markerText)) {
272 const uni = PUA_MARKERS[`${(list!.markerFontName ?? "").replace(/\s+/g, "").toLowerCase()}:${markerText.charCodeAt(0) & 0xff}`];
273 marker.textContent = uni ?? "•";
274 marker.style.fontFamily = "";
275 }
276 if (list!.markerScale) {
277 const basePt = runCs?.fontSizePt;
278 marker.style.fontSize = basePt
279 ? `${basePt * list!.markerScale}px`
280 : `${list!.markerScale}em`;
281 }
282 if (list!.markerBaselineOffsetPt) marker.style.verticalAlign = `${list!.markerBaselineOffsetPt}px`;
283 wrap.appendChild(marker);
284 wrap.appendChild(el);
285 renderParagraphContent(el, p, doc, ctx, style?.dropCap);
286 return wrap;
287 }
288
289 renderParagraphContent(el, p, doc, ctx, style?.dropCap);
290 return el;
291 }
292
293 /** Items of a paragraph into the given element. A dropCap (ParaStyle) carves
294 * the leading characters off the first text run into a floated cap glyph
295 * sized to span `lines` body lines (G5 page 5's big "T"). */
296 function renderParagraphContent(
297 el: HTMLElement,
298 p: Paragraph,
299 doc: HydratedDoc,
300 ctx: ViewerCtx,
301 dropCap?: import("../../model/src/shared").ParaStyle["dropCap"],
302 ): void {
303 let items = p.items;
304 if (dropCap) {{
"file_path": "/Users/phebert/pnk/viewer/src/text.ts",
"offset": 304,
"limit": 80
}304 if (dropCap) {
305 const k = dropCap.characters ?? 1;
306 const first = items[0];
307 const text = typeof first === "string" ? first : !("type" in (first ?? {})) ? (first as TextRun).text : undefined;
308 if (text && text.length >= k) {
309 const capText = [...text].slice(0, k).join("");
310 const rest = [...text].slice(k).join("");
311 const cap = document.createElement("span");
312 cap.className = "drop-cap";
313 if (typeof first !== "string") applyCharStyle(cap, charStyleOf(doc, (first as TextRun).cStyle));
314 applyCharStyle(cap, dropCap.charStyle);
315 const lines = dropCap.lines ?? 3;
316 const scale = dropCap.characterScale ?? 1;
317 cap.style.fontSize = `${(lines * 1.2 * scale).toFixed(2)}em`;
318 cap.style.lineHeight = "0.85";
319 cap.style.cssFloat = "left";
320 cap.style.paddingRight = `${dropCap.paddingPt ?? 4}px`;
321 if (dropCap.outdentPt) cap.style.marginLeft = `${-dropCap.outdentPt}px`;
322 cap.textContent = capText;
323 el.appendChild(cap);
324 items = [
325 typeof first === "string" ? rest : { ...(first as TextRun), text: rest },
326 ...items.slice(1),
327 ];
328 }
329 }
330 for (const item of items) {
331 // bare string = plain unstyled run; object = styled/typed run
332 if (typeof item === "string") {
333 appendRunText(el, item, undefined);
334 } else if ("type" in item && item.type === "field") {
335 const span = document.createElement("span");
336 span.className = "field";
337 span.dataset.fieldKind = item.field.kind;
338 span.textContent = item.value ?? fieldPlaceholderText(item);
339 applyCharStyle(span, charStyleOf(doc, item.cStyle));
340 el.appendChild(span);
341 } else if ("type" in item && item.type === "inline-object") {
342 // U+FFFC inline attachment: images flow WITH the sentence (Apple
343 // renders them mid-text, baseline-ish); block drawables (tables)
344 // keep the flow renderer
345 const d = item.drawable;
346 if (d.type === "image") {
347 el.appendChild(inlineImageEl(d, ctx));
348 } else {
349 el.appendChild(renderFlowDrawable(d, doc, ctx));
350 }
351 } else {
352 const run = item as TextRun;
353 const span = document.createElement(run.hyperlink ? "a" : "span");
354 applyCharStyle(span, charStyleOf(doc, run.cStyle));
355 if (run.hyperlink) {
356 (span as HTMLAnchorElement).href = run.hyperlink;
357 (span as HTMLAnchorElement).target = "_blank";
358 (span as HTMLAnchorElement).rel = "noopener";
359 }
360 appendRunText(span, run.text, span);
361 el.appendChild(span);
362 }
363 }
364 }
365
366 /** An inline-attachment image: flows with the sentence, baseline-aligned. */
367 function inlineImageEl(d: ImageDrawable, ctx: ViewerCtx): HTMLElement {
368 let url = ctx.url(d.image.dataId);
369 const size = d.common?.size;
370 // Vector art (PDF/AI/EPS) cannot be an <img> src: fall back to a raster
371 // thumbnail twin when one ships, else a small neutral tile — a broken-image
372 // icon per attachment turned the kcsrk equation-dense deck into noise.
373 const isVec = (n?: string) => /\.(pdf|ai|eps)$/i.test(n ?? "");
374 if (isVec(d.image.preferredFileName ?? d.image.fileName)) {
375 const thumbUrl = d.thumbnail ? ctx.url(d.thumbnail.dataId) : undefined;
376 if (thumbUrl && !isVec(d.thumbnail?.fileName ?? d.thumbnail?.preferredFileName)) {
377 url = thumbUrl;
378 } else {
379 const tile = document.createElement("span");
380 tile.className = "inline-vector-tile";
381 if (size) {
382 tile.style.width = `${size.width}px`;
383 tile.style.height = `${size.height}px`;Confirmed the issue: blank paragraphs become items: [], causing renderParagraphContent to output an empty <p> that collapses to zero height. Now checking if there's existing empty-paragraph handling in CSS or the appendRunText path.
cd /Users/phebert/pnk/viewer && grep -n "nbsp\|00a0\|createElement(\"br\")\|min-height" src/text.ts src/pages.ts styles.css | head -20; grep -n "appendRunText" src/text.ts | head
src/text.ts:414: if (i > 0) parent.appendChild(document.createElement("br"));
styles.css:363: background: #d4d4d8; border-radius: 2px; min-width: 8px; min-height: 8px;
333: appendRunText(el, item, undefined);
360: appendRunText(span, run.text, span);
411:function appendRunText(parent: HTMLElement, text: string, styleHost: HTMLElement | undefined): void {{
"file_path": "/Users/phebert/pnk/viewer/src/text.ts",
"offset": 400,
"limit": 35
}400 }
401 img.style.verticalAlign = "text-bottom";
402 img.className = "inline-image";
403 return img;
404 }
405
406 /**
407 * Run text into the given parent: soft line breaks (U+2028 paragraph
408 * separator / U+2029 line separator) are visible breaks in iWork but not in
409 * HTML text, so they split into nodes joined by <br>.
410 */
411 function appendRunText(parent: HTMLElement, text: string, styleHost: HTMLElement | undefined): void {
412 const parts = text.split(/[\u2028\u2029]/);
413 parts.forEach((part, i) => {
414 if (i > 0) parent.appendChild(document.createElement("br"));
415 if (part) parent.appendChild(document.createTextNode(part));
416 });
417 void styleHost;
418 }
419
420 /** A whole text block (body, notes, cell rich text…). */
421 export function renderStyledText(t: StyledText | undefined, doc: HydratedDoc, ctx: ViewerCtx): HTMLElement {
422 const div = document.createElement("div");
423 div.className = "styled-text";
424 const listState = newListNumberingState();
425 if (t) for (const p of t.paragraphs) div.appendChild(renderParagraph(p, doc, ctx, listState));
426 return div;
427 }
428 Found it: empty paragraphs render as <p></p>, which collapses to zero height without a <br> to give the line box its strut. I'll check applyParaStyle for font sizing behavior, then apply the fix.
grep -n "function applyParaStyle" -A 30 /Users/phebert/pnk/viewer/src/text.ts | head -40
60:export function applyParaStyle(el: HTMLElement, ps: ParaStyle): void {
61- const s = el.style;
62- const align = ps.horizontalAlignment;
63- if (align === "center" || align === "right" || align === "justify") s.textAlign = align;
64- // TSWP indents: first_line_indent is ABSOLUTE from the margin while
65- // left_indent applies to continuation lines (G5 fixture: styles storing
66- // left=36/first=0 render flush first lines in Apple's export; a hanging
67- // style stores left=72/first=36). CSS text-indent is RELATIVE to
68- // margin-left, so emit first - left; absent first means 0 (flush).
69- const leftIndent = ps.leftIndentPt ?? 0;
70- const firstIndent = ps.firstLineIndentPt ?? 0;
71- if (leftIndent) s.marginLeft = `${leftIndent}px`;
72- if (ps.rightIndentPt) s.marginRight = `${ps.rightIndentPt}px`;
73- if (firstIndent - leftIndent) s.textIndent = `${firstIndent - leftIndent}px`;
74- if (ps.spaceBeforePt) s.marginTop = `${ps.spaceBeforePt}px`;
75- if (ps.spaceAfterPt) s.marginBottom = `${ps.spaceAfterPt}px`;
76- if (ps.lineSpacingMultiple) s.lineHeight = String(ps.lineSpacingMultiple);
77- else if (ps.lineSpacingExactPt) s.lineHeight = `${ps.lineSpacingExactPt}px`;
78- if (ps.backgroundColor) s.backgroundColor = ps.backgroundColor;
79- if (ps.border) {
80- const b = ps.border;
81- s.border = `${b.widthPt}px ${b.dash?.length ? "dashed" : "solid"} ${b.color}`;
82- }
83- if (ps.writingDirection === "right-to-left") s.direction = "rtl";
84- // tabs render via white-space: pre-wrap (set in CSS for print areas);
85- // tab-size approximates the default tab stop interval. Positioned
86- // center/right/decimal stops are not modeled in CSS — heuristic only.
87- if (ps.defaultTabStopPt) (s as CSSStyleDeclaration & { tabSize: string }).tabSize = `${ps.defaultTabStopPt}px`;
88-}
89-
90-function fieldPlaceholderText(item: Extract<ParagraphItem, { type: "field" }>): string { appendRunText(span, run.text, span);
el.appendChild(span);
}
}
} appendRunText(span, run.text, span);
el.appendChild(span);
}
}
// A blank paragraph (items: [] or only empty runs) is a blank LINE in
// iWork, but an empty <p> collapses to zero height and the surrounding
// text fuses together (1eb960ba: the gaps around the title and between
// the attendee block and body vanished). A <br> gives the line box its
// strut at the paragraph's inherited size, and keeps it measurable for
// WP pagination.
if (el.textContent === "" && !el.querySelector("br, img")) {
el.appendChild(document.createElement("br"));
}
}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 -3
⚡ Done in 3ms ==> static shell -> viewer/dist/ viewer built: viewer/dist/ (serve: cd viewer && npm run serve)
uv run --with pillow --with pyobjc-framework-Quartz --with pymupdf python3 scripts/visual_diff.py --app pages --fixture ~/Development/pnk-fixtures/crawl/1eb960ba4ad8f155d70037395ca50b28b3993cf82d03a3d787daced092ddbd54.pages --out /private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/vd-1eb960ba --base-url http://127.0.0.1:8123 2>&1 | tail -8
[visual_diff] closed Pages copy '1eb960ba4ad8f155d70037395ca50b28b3993cf82d03a3d787daced092ddbd54-visualdiff-copy.pages' without saving [visual_diff] rasterized 2 Apple pages at 150dpi [visual_diff] apple pages: 2 [visual_diff] reusing viewer server already at http://127.0.0.1:8123 [visual_diff] wrote 2 per-page composites [visual_diff] wrote 0 region crops [visual_diff] artifacts: /private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/vd-1eb960ba [visual_diff] summary: /private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/vd-1eb960ba/summary.md
The 1eb960ba composites are ready — and I'll check G2 for the opposite problem (extra blanks) right after. Let me eyeball both pages first.
{
"file_path": "/private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/vd-1eb960ba/composite-p1.png"
}ls /private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/vd-1eb960ba/
ls /private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/vd-1eb960ba/composites/
{
"file_path": "/private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/vd-1eb960ba/composites/composite-page-1.png"
}[Image: original 2490x1754, displayed at 2000x1409. Multiply coordinates by 1.25 to map to original image.]
The 1eb960ba fix resolves the blank line placement issue correctly. Now I'll run the harness on G2 to investigate the reported extra blank lines issue.
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-g2 --base-url http://127.0.0.1:8123 2>&1 | tail -4
[visual_diff] wrote 2 per-page composites [visual_diff] wrote 0 region crops [visual_diff] artifacts: /private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/vd-g2 [visual_diff] summary: /private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/vd-g2/summary.md
{
"file_path": "/private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/vd-g2/composites/composite-page-1.png"
}[Image: original 2562x1650, displayed at 2000x1288. Multiply coordinates by 1.28 to map to original image.]
I've confirmed the G2 gap issue appears between those specific text sections, where Apple renders tight consecutive lines. Now I'll check G2's JSON to see what content those paragraphs actually hold.
./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):
t = dr.get('type')
if t in ('shape','textbox') and dr.get('text'):
paras = dr['text'].get('paragraphs', [])
texts = []
for p in paras:
if isinstance(p, str): texts.append(('STR', repr(p[:40])))
else: texts.append(('P', p.get('pStyle'), [(it if isinstance(it,str) else it.get('text','<obj>'))[:40] if not isinstance(it,dict) or 'text' in it else '<obj>' for it in p.get('items',[])]))
print(' '*depth, t, texts)
for c in dr.get('children', []): walk(c, depth+1)
for page in doc.get('floating', []) or []:
for dr in page.get('drawables', []):
walk(dr)
" 2>&1 | head -30 textbox [('P', None, ['Shapes can hold text too.']), ('P', None, ['Paragraphs ', 'a', 'n', 'd', ' spans'])]
shape [('P', 0, [])]
shape [('P', 0, [])]
shape [('P', 0, [])]
shape [('P', 0, ['Text'])]
shape [('P', 0, [])]
shape [('P', 0, [])]
shape [('P', 0, [])]
shape [('P', 0, [])]
shape [('P', 0, [])]
textbox [('P', None, ['Source (CC0):']), ('P', None, ['https://commons.wikimedia.org/wiki/File:'])]
shape [('P', 0, [])]
shape [('P', None, [])]
textbox [('P', 1, ['Page 2'])]
textbox [('P', 2, ['Once upon a time, in a GitHub repo far a'])]The gap isn't from a blank paragraph but from default browser <p> margins since no pStyle is applied. I'll check the CSS reset to confirm.
grep -n "^p\b\|styled-text\| p {\| p,\|margin" /Users/phebert/pnk/viewer/styles.css | grep -i "p\b\|para" | head -20; grep -n "styled-text" /Users/phebert/pnk/viewer/styles.css47:#app { max-width: 1100px; margin: 0 auto; padding: 16px; }
59:#drop-card h1 { margin: 0; font-size: 44px; letter-spacing: 2px; color: var(--accent); }
60:#drop-card .tagline { margin: 12px 0 16px; }
62:.fmt-chips { display: flex; justify-content: center; gap: 10px; margin: 0 0 24px; }
199:.canvas-caption { font-size: 12px; color: var(--muted); margin-top: 4px; }
231:.slide-list-item .label { display: block; margin-top: 4px; color: var(--muted); }
235: padding: 10px 16px; margin-top: 14px; font-size: 14px;
241:.sheet-tabs { display: flex; gap: 6px; flex-wrap: wrap; margin: 14px 0; }
269:.pages-flow p { margin: 0.4em 0; }
271: border-top: 1px solid var(--line); margin-top: 24px; padding-top: 10px;
276:.pages-page + .pages-page { margin-top: 22px; }
280:.list-item > p, .list-item > h1, .list-item > h2, .list-item > h3,
287: paragraph margins from collapsing out, so offscreen measurement and the
288: final page agree. Tight defaults: Apple has no implicit paragraph margins;
291:.pages-print p { margin: 0; }
294:.pages-wp-page { margin: 18px auto; max-width: 720px; }
304:.pages-print p, .pages-print h1, .pages-print h2, .pages-print h3,
321:.pages-hf .styled-text p { margin: 0; }
344:.pages-footnote-area .footnote { display: flex; gap: 4px; margin: 2px 0; }
345:.pages-footnote-area .styled-text p { margin: 2px 0; }
321:.pages-hf .styled-text p { margin: 0; }
345:.pages-footnote-area .styled-text p { margin: 2px 0; }I've confirmed the print margin fix covers the WP text flow, but text inside drawable shapes/textboxes still has no margin reset, letting default 1em margins leak in. Now I'll locate the drawable text container class to fix that.
grep -n "renderStyledText\|shape-text\|className" /Users/phebert/pnk/viewer/src/drawables.ts | head -20; grep -n "shape-text" /Users/phebert/pnk/viewer/styles.css
23:import { renderStyledText } from "./text";
26:function el(tag: string, className?: string): HTMLElement {
28: if (className) e.className = className;
508: inner.appendChild(renderStyledText(d.text as never, doc, ctx));
751: div.className = "canvas-drawable unknown-drawable";
895: wrap.appendChild(renderStyledText(d.text, doc, ctx));
900: wrap.appendChild(renderStyledText(d.text, doc, ctx));sed -n 495,520p /Users/phebert/pnk/viewer/src/drawables.ts; sed -n 885,905p /Users/phebert/pnk/viewer/src/drawables.ts; grep -n "\.styled-text" /Users/phebert/pnk/viewer/styles.css
}
/** Text content of a textbox/shape, filling the positioned container. */
function textLayer(d: Drawable & { text?: unknown; common?: DrawableCommon }, doc: HydratedDoc, ctx: ViewerCtx): HTMLElement | null {
if (!("text" in d) || !d.text || (d.text as { paragraphs?: unknown[] }).paragraphs === undefined) return null;
const layer = el("div", "drawable-text");
layer.style.alignItems = verticalAlignStyle(d as { verticalAlignment?: string });
// NOTE on units: canvas geometry renders 1 document-point = 1px, and
// text.ts emits sizes in CSS `px` for the same reason (commit c94861a) —
// together they keep 1pt of text = 1px of canvas with no extra scaling.
// (An earlier 0.75 layer transform compensated the old CSS-`pt` emission;
// both fixes active would double-shrink — keep exactly one.)
const inner = el("div", "drawable-text-inner");
inner.appendChild(renderStyledText(d.text as never, doc, ctx));
layer.appendChild(inner);
return layer;
}
// ---------------------------------------------------------------------------
// Text fit (model textFit: "grow" | "shrink"; absent = fixed box, clipped)
// ---------------------------------------------------------------------------
/**
* "grow": a plain Keynote/Pages text box auto-sizes its height to its
* content; the stored height is Apple's layout under Apple's font metrics,
* so browsers (taller line boxes, fallback fonts) treat it as a MINIMUM and
div.appendChild(chartSummary(d));
}
}
return div;
}
/** One drawable in a flow context (inline attachment / floating text stream). */
export function renderFlowDrawable(d: Drawable, doc: HydratedDoc, ctx: ViewerCtx): HTMLElement {
if (d.type === "textbox") {
const wrap = el("div", "flow-textbox");
wrap.appendChild(renderStyledText(d.text, doc, ctx));
return wrap;
}
if (d.type === "shape" && d.text) {
const wrap = el("div", "flow-textbox");
wrap.appendChild(renderStyledText(d.text, doc, ctx));
return wrap;
}
if (d.type === "image") {
const wrap = el("div", "flow-image");
const url = ctx.url(d.image.dataId);
321:.pages-hf .styled-text p { margin: 0; }
345:.pages-footnote-area .styled-text p { margin: 2px 0; }grep -n "drawable-text" /Users/phebert/pnk/viewer/styles.css
201:.drawable-text { overflow: hidden; display: flex; }
202:.drawable-text-inner { width: 100%; }
300:.canvas-drawable > .drawable-text { position: absolute; inset: 0; }
368:.drawable-text p, .drawable-text h1, .drawable-text h2, .drawable-text h3,
369:.drawable-text h4, .drawable-text h5, .drawable-text h6,
370:.drawable-text .list-item { white-space: pre-wrap; tab-size: 36px; }Found the issue: the rule at styles.css:368 handles white-space for drawable text but doesn't reset the browser's default 1em paragraph margins, causing the extra blank line. I'll apply the same margin reset used for the WP print area (line 291) to the drawable text as well.
360 instead of a broken-image icon; sized by the drawable, tiny by nature. */
361 .inline-vector-tile {
362 display: inline-block; vertical-align: text-bottom;
363 background: #d4d4d8; border-radius: 2px; min-width: 8px; min-height: 8px;
364 }
365 /* Canvas drawable text needs the same TAB treatment as the print area:
366 Labothek heading bands store "\t Arbeitsmaterialien" and Apple jumps the
367 default 36pt tab stop past the deco square — collapsed tabs overlap it. */
368 .drawable-text p, .drawable-text h1, .drawable-text h2, .drawable-text h3,
369 .drawable-text h4, .drawable-text h5, .drawable-text h6,
370 .drawable-text .list-item { white-space: pre-wrap; tab-size: 36px; }
371 .drawable-text .list-item { white-space: pre-wrap; tab-size: 36px; }.drawable-text .list-item { white-space: pre-wrap; tab-size: 36px; }
/* Apple has no implicit paragraph margins in shape/textbox text either —
the UA's 1em <p> margin read as a phantom blank line between consecutive
paragraphs (G2's caption box). Spacing comes only from spaceBefore/After
on the paragraph style. */
.drawable-text p, .drawable-text h1, .drawable-text h2, .drawable-text h3,
.drawable-text h4, .drawable-text h5, .drawable-text h6 { margin: 0; }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 && 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-g2b --base-url http://127.0.0.1:8123 2>&1 | tail -2
viewer built: viewer/dist/ (serve: cd viewer && npm run serve) [visual_diff] artifacts: /private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/vd-g2b [visual_diff] summary: /private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/vd-g2b/summary.md
{
"file_path": "/private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/vd-g2b/composites/composite-page-1.png"
}