docxide-pdf 0.16.2

Library and CLI for converting DOCX files to PDF, matching Microsoft Word's output as closely as possible
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
# Annotations Progress — Remaining Unfixed Findings

This file tracks annotation findings that were investigated but **not fixed** — they represent systemic issues requiring broader improvements rather than targeted bug fixes.

---

## SELECTED: Annotation #48 — Green box breakpoint (croatian_grant_guidelines) — 2026-04-17 (IN PROGRESS)

**Problem**: Breakpoint of the green box (table?) should be in the fourth paragraph. Not where we break it. There is still a lot of space below the box. Page 3 generated.

---

## Annotation #107 — Missing HRs on page (learning_cultures_dissertation) — 2026-04-17 (FIXED)

**Problem**: Page 3 (committee signature page) of the dissertation was missing the three horizontal rules above "Sharon Rallis, Chair", "Kathryn A. McDermott, Member", and "Raymond Sharick, Member". The reference PDF has thin grey lines; our output had just blank space.

**Analysis**: Each HR comes from a paragraph containing a single `<w:tab/>` run whose `rPr` sets `<w:u w:val="single"/>`, with a paragraph-level right tab stop at `<w:tab w:val="right" w:pos="8460"/>`. Word renders the underline decoration across the tab whitespace, producing a horizontal line from the left indent to the tab position.

In our pipeline, tab runs were built via `fmt.minimal_run()` in `src/docx/runs.rs`, which only copies font identity — the `underline` flag was discarded. Even if the tab run had carried `underline`, `build_tabbed_line` in `src/pdf/layout.rs` threw away the tab run itself (keeping only a placeholder `TabStop`), so the segment-advance code had no way to know whether to draw the leader line. Consequently no underline chunk was ever emitted for the tab gap, and the decoration-collection pass saw nothing to render.

**Fix**: Three small changes:
1. Added a dedicated `RunFormat::tab_run()` in `src/docx/runs.rs` that mirrors `minimal_run()` but also retains `underline` and `color`, used at the `<w:tab/>` parse site. Keeps image/break/field-code runs untouched.
2. Extended the `segments` tuple in `build_tabbed_line` with a fourth element `Option<&Run>` holding the tab run itself. When a segment advance is driven by a tab whose run has `underline: true`, emit a decoration-only `WordChunk::tab_underline` spanning from the pre-tab `current_x` to the resolved `seg_start`.
3. Relaxed `has_text_chunks` to also include empty-text underlined chunks so the existing text-block pass runs, collects decorations, and the final rect-drawing loop renders the underline.

**Result**: All three HRs render correctly at the signature lines. Jaccard -0.2pp (33.5→33.4), SSIM +0.2pp (46.0→46.3). The visual improvement on page 3 is much larger than the aggregate score change (143 pages total). No regressions on any other fixture.

---

---

## Annotation #93 — Phone number split across lines (czech_works_contract_proposal) — 2026-04-17 (investigated, not fixed)

**Problem**: Line "p. Marek Pustelník, vrchní mistr střediska vrchní stavba, tel.: 59 740 2251, email: ..." wraps between "59 740" and "2251," in generated; reference fits "59 740 2251," on the same line. The annotator guessed this was "just a result of word difference."

**Analysis**: Measured character positions in both outputs.
- Text area: 322.15pt (left margin 70.9 + paragraph ind.left 159.75 → right margin 42.55).
- Reference line 1: "p. Marek Pustelník... 59 740 2251," fits from x=230.85 to x=552.974 = 322.12pt. All 11 inter-word spaces measure **2.0pt each** (narrower than natural 2.75pt).
- Generated line 1: same text minus "2251," fills x=230.65 to x=552.80 (322.15pt). Individual character widths identical to reference (e.g., "5"/"9"/"0" each 5.5pt, "M" 9.78pt), but spaces measure **5.075pt each** (expanded).

**Root cause**: Word compresses inter-word spaces in justified text when natural width slightly exceeds the text area. Natural width of the full text is ≈ 298.9 + 11×2.75 ≈ 330.4pt, which exceeds 322.15pt by 8.2pt. Word saves 8.25pt by narrowing each of 11 spaces from 2.75pt to 2.0pt. Our justification algorithm only expands (`extra_per_gap = max(0, (eff_w - content)/gaps)` in `src/pdf/layout.rs:1155`), never compresses. Because "2251," doesn't fit, we wrap it, leaving line 1 short, which then gets expanded.

Implementing compressed justification is a significant algorithmic change affecting every justified paragraph in every fixture, with unknown regression risk. Deferred as systemic line-length/text-width behavior — matches the instructions' exception for systemic line-length issues.

---

## Annotation #5 — Page break positioning (croatian_grant_guidelines)

**Problem**: TOC starts too high on page 2 — content that should overflow from page 1 stays on page 1 because our page 1 consumes ~17pt less vertical space than Word's.

**Analysis**: The ~2-3pt position difference at the top accumulates across 50+ TOC entries due to subtle text width/wrapping differences. The empty SDT paragraph that should be on page 2 stays on page 1 because of cumulative font metric and line height precision differences. An attempt to preserve `space_before` after natural page overflow caused regressions in 6 cases (up to -39.5pp).

**Root cause**: Cumulative font metric / text width accumulation issue, not a spacing bug. Requires broader improvements to text width calculation.

---

## Annotation #8 — Font availability (east_asia_conference_form)

**Problem**: All content renders on page 1 but the reference has "신청자(申請者):" on page 2. The page overflow should come from natural content height.

**Analysis**: The document uses MalgunGothicBold (맑은 고딕) at 12.96pt with Windows-native metrics. On macOS, falls back to HiraginoSansW3 with different line_h_ratio (1.33), causing different row heights and wrapping. The fallback font's metrics make the table shorter overall than Word's rendering, preventing the expected page overflow.

**Root cause**: Font availability issue on macOS. Malgun Gothic is not installed. Fixing requires either installing the correct fonts or implementing font metric estimation for unavailable fonts.

---

## Annotation #25 — Textbox positioning overflow (air_pollution_permit_form)

**Problem**: Text "Mellékletek / Prílohy:" appears at page bottom in generated but not in reference.

**Analysis**: The document has three large page-covering textboxes. Textbox 4 (481×714pt) is anchored to a paragraph 10 empty paragraphs below the first one. These empty paragraphs advance the cursor by ~152pt, causing the textbox's anchor position to be much lower than expected. In Word, the textbox starts higher so "Mellékletek" (which overflows the textbox height) is clipped. In our output, the textbox starts lower so the overflow text falls within clip bounds and renders.

**Root cause**: Systemic textbox positioning issue when textbox is paragraph-relative and the anchor paragraph is far from the page top. Would require rethinking how paragraph-relative textbox positions interact with body paragraph cursor advancement.

---

## Annotation #30 — Font glyph width differences (mongolian_human_rights_law)

**Problem**: Annotator reported extra space after "4.1.5." before the opening quote.

**Analysis**: The text `4.1.5."хүний` is a single word in the layout engine — no actual space insertion. Text extraction from both generated and reference PDFs confirms identical content. The visual difference is from the `"` character rendering with slightly more sidebearing in our font compared to the reference's Windows font.

**Root cause**: Font metric difference, not a rendering bug.

---

## Annotation #66 — Bullet point vertical drift (case33)

**Problem**: Each bullet point causes slightly larger vertical drift from the reference, accumulating ~2-3pt over the page.

**Analysis**: Thorough investigation of Calibri metrics (usWinAscent=1950, usWinDescent=550, UPM=2048, line_h_ratio=1.2207). contextualSpacing works correctly. Line height: `resolve_line_h(Auto(1.15), 11, Some(1.2207))` = 15.4418pt vs Word's twip-rounded 15.45pt. Per-line difference: 0.008pt. Over ~15 paragraphs, cumulative error is ~0.12pt — too small alone, but compounded by different font_size paragraphs (headings) creating different rounding patterns.

**Root cause**: Floating-point precision differences in line height vs Word's internal twip-rounded cursor advancement. Would require implementing Word-compatible twip rounding throughout the spacing pipeline.

---

## Annotation #77 — Apparent justification issue (polish_municipal_letter)

**Problem**: Body text should be "justified or something like that."

**Analysis**: Paragraphs have `w:jc w:val="both"` and our code correctly applies `Alignment::Justify` with `extra_per_gap` spacing. The text IS justified. The visual difference is from slightly different text widths (font metric differences for Times New Roman/Calibri on macOS vs Windows), causing different line breaks and inter-word spacing.

**Root cause**: Font metric difference causing different line breaks, not a justification logic bug.

---

## Annotation #78 — Text overflow (stem_partnerships_guide)

**Problem**: Text overflow likely from column width calculation differences.

**Root cause**: Font metric / text width differences. Not investigated in detail.

---

## Annotation #31 — Image drop shadow quality (parish_housing_data_profile) — 2026-03-30

**Problem**: Annotator reported "We are not rendering drop shadow for this image" for the map image on page 1.

**Analysis**: The drop shadow WAS being rendered via `draw_image_shadow()` in `pdf/color.rs`, but was barely visible due to two issues in the layered rectangle approximation:
1. Blur radius was halved (`blur_radius * 0.5`), making the shadow extension too compact
2. Linear layer distribution spread opacity evenly, leaving the visible edge too faint

The image has `a:outerShdw blurRad="292100" dist="139700" dir="2700000"` with `srgbClr val="333333"` at 65% alpha — a 45-degree bottom-right shadow with 23pt blur radius.

**Fix**: Improved shadow rendering in two ways:
1. Increased blur multiplier from 0.5 to 0.75, extending the visible shadow to better match the `effectExtent` values in the DOCX (l=13.5pt, t=13.5pt, r=27.8pt, b=26.3pt)
2. Changed from linear to quadratic layer distribution (`t = blur * frac²`), which concentrates more layers near the image edge for a denser, more visible shadow body with gentle outer fade

**Result**: Shadow is now visually present on the bottom-right of the image, matching the reference pattern. Jaccard essentially unchanged (-0.04pp), SSIM slightly improved (+0.1pp). No regressions on any other fixture.

---

## Annotation #38 — Chart rendered too wide (case30) — 2026-03-30

**Problem**: Line chart in case30 extends too far to the right compared to reference. The "Jun" x-axis label is 20pt further right in generated (329.8pt) vs reference (310.3pt).

**Analysis**: Measured text positions in generated vs reference PDFs. The legend area is too small: our code allocated ~95pt for the right margin while Word uses ~115pt. Root cause: the legend swatch width for point charts (line/area/scatter/bubble) was 5.5pt, matching bar chart squares, but Word uses wider line+marker swatches (~20pt). Also, inter-element padding was insufficient.

**Fix**: In `pdf/charts.rs`, changed legend margin calculation:
1. Increased legend swatch width from 5.5pt to 20pt for point charts (line/area/scatter/bubble)
2. Increased inter-element padding (6pt gap + 12pt right padding, was 4pt + 8pt)
3. Raised proportional minimum from 12% to 15% of chart width

**Result**: Improvements across all chart test cases: case29 +5.1pp, case30 +0.5pp, case31 +1.6pp, case53 +3.0pp, sample500kB +0.8pp. One small regression: case52 -1.9pp (within threshold). No regression flags.

---

## Annotation #41 — Table overlaps header on continuation pages (education_consultant_posting) — 2026-03-30

**Problem**: On page 7, a table continuation from the previous page rendered on top of the page header content ("United Nations Children's Fund" and "TERMS OF REFERENCE"), overlapping it. The table should start below the header.

**Analysis**: When a table spans multiple pages, `flush_and_render_headers()` in `pdf/table.rs` resets the cursor to `sp.page_height - sp.margin_top` — the raw top margin. This ignores the page header height entirely. The same issue affected `at_page_top` detection, `available_h`, and `page_content_h` calculations, which all used raw `sp.margin_top` / `sp.margin_bottom` instead of the effective values that account for header/footer content height.

Meanwhile, the main render loop in `pdf/mod.rs` consistently uses `effective_slot_top()` and `compute_effective_margin_bottom()` for all page break decisions.

**Fix**: In `pdf/table.rs`:
1. Changed `flush_and_render_headers()` to use `effective_slot_top(sp, false, ctx)` instead of `sp.page_height - sp.margin_top`
2. Changed `at_page_top` check to compare against `effective_slot_top()` instead of raw margin
3. Changed `available_h` to use `compute_effective_margin_bottom()` instead of `sp.margin_bottom`
4. Changed `page_content_h` to use effective top - effective bottom
5. Changed the split-row loop's `avail` calculation to use `compute_effective_margin_bottom()`

**Result**: `education_consultant_posting` Jaccard +2.3pp (12.1% → 14.4%), SSIM +8.0pp (24.2% → 32.1%). `go_math_grade4_guide` Jaccard +2.4pp (27.7% → 30.0%), SSIM +1.2pp. No regressions. Table content now properly appears below headers on continuation pages.

**Note**: The header rendering on this page still has garbled text ("nited ations Childrens und" instead of "United Nations Children's Fund") — this is a separate font encoding issue unrelated to the table positioning fix.

---

## Annotation #39 — Scatter/Bubble chart plot area too narrow (case31) — 2026-03-30

**Problem**: Scatter chart plot area was narrower than Word's reference. The annotation reported the graph as "too wide" but measurements showed the opposite: our plot_w was ~252pt vs reference ~259pt, and our legend was 23pt closer to the plot area.

**Analysis**: The margin_right calculation for right-side legends used `swatch_w = 20.0` for ALL point charts (Line/Area/Scatter/Bubble), matching the width of line+marker legend swatches. However, Scatter and Bubble charts render legend swatches as small marker dots (`SwatchStyle::Marker`, swatch_size=5.5) without any line extension, so 20pt was ~14.5pt more than needed. This over-allocation compressed the plot area.

Measurements confirmed: generated margin_right=76.2pt vs reference ~69pt; generated plot_w=252.1pt vs reference ~259pt.

**Fix**: In `pdf/charts.rs`, differentiated the legend swatch width reservation by chart type:
- Line/Area charts: keep `swatch_w = 20.0` (accounts for line+marker swatches)
- Scatter/Bubble/Bar charts: use `swatch_w = 5.5` (marker dot or rect only)

This brings margin_right from 76.2pt down to ~62pt for scatter charts, producing a wider plot area that better matches Word's layout.

**Result**: case31 Jaccard +1.4pp (58.2% → 59.5%), SSIM +2.5pp (75.0% → 77.6%). No regressions on case29, case30, sample500kB, or any other fixture.

---

## Annotation #44/#74 — Line chart data points at wrong x-positions (case30) — 2026-03-30

**Problem**: Line chart data points started and ended at the plot area edges instead of aligning with category labels. The "Jan" data point was at the left axis and "Jun" at the right edge, but category labels (placed at section midpoints) were inset from the edges.

**Analysis**: The Line chart code used `cat_w = plot_w / (num_categories - 1)` to space data points edge-to-edge. But category labels were correctly placed at section midpoints using `(ci + 0.5) * group_w` where `group_w = plot_w / num_categories`. This caused data markers to misalign with their category labels.

The Area chart uses a different convention — data points at edges to fill the entire plot width — so the fix was only applied to Line charts.

**Fix**: In `pdf/charts.rs`, changed Line chart data point placement from edge-to-edge (`plot_x + ci * plot_w/(n-1)`) to category midpoints (`plot_x + (ci + 0.5) * plot_w/n`), matching the existing category label positions.

**Result**: case30 Jaccard +1.9pp (82.3% → 84.2%), SSIM +1.1pp (82.8% → 83.9%). No regressions on any fixture.

---

## Annotation #46 — Header floating image not counted in header height (czech_municipal_grant_form) — 2026-03-30

**Problem**: On page 2, numbered text overlapped the header logo. The body content started too high, rendering on top of the coat-of-arms logo in the page header.

**Analysis**: The header contains a floating image (45.4×48.7pt) with `WrapType::Square` wrap. The `compute_header_height()` function only counted floating images with `WrapType::TopAndBottom`, ignoring `Square`/`Tight`/`Through` wrap types. Since the logo uses Square wrapping, its height (~48.7pt) was not included in the header height calculation, so `effective_slot_top()` placed body content too high.

**Fix**: In `pdf/header_footer.rs`, changed the floating image filter in `compute_header_height()` from `matches!(fi.wrap_type, WrapType::TopAndBottom)` to `!matches!(fi.wrap_type, WrapType::None)`. In headers/footers, any text-displacing wrap type (TopAndBottom, Square, Tight, Through) should contribute to the header height. Also clamped negative offsets to 0 to prevent negative height contributions.

**Result**: czech_municipal_grant_form SSIM +4.1pp (30.0% → 34.1%), Jaccard -0.2pp (noise). education_consultant_posting also improved: SSIM +0.6pp. No regressions.

---

## Annotation #52 — Header textbox TopAndBottom cursor not advancing (education_consultant_posting) — 2026-03-30

**Problem**: "TERMS OF REFERENCE" heading in the header was rendered 20.5pt too high (y=80.50 vs reference y=101.00). The annotator noted the table below was correctly positioned, confirming this was a header-internal spacing issue.

**Analysis**: The page header contains a TopAndBottom textbox (mc:AlternateContent with DrawingML/VML textbox) at page-relative y=69pt, 13.5pt tall. This textbox holds the subtitle text ("United Nations Children's Fund | Pakistan Country Office"). The textbox acts as a vertical spacer — content below it should start after its bottom edge (82.5pt from top). Two issues found:

1. **mc:AlternateContent not collected in headers**: The `parse_header_footer_xml` function skipped non-WML namespace elements, missing `mc:AlternateContent` blocks that wrap paragraphs in mc:Choice/mc:Fallback. Fixed by using `collect_block_nodes()` (which now also handles mc:AlternateContent via mc:Fallback).

2. **Header rendering cursor didn't advance past TopAndBottom textboxes**: When a paragraph with a TopAndBottom textbox had empty text, the cursor only advanced by `line_h` (14.4pt), ignoring the textbox's vertical extent. The textbox bottom at 82.5pt from page top was never reached by the cursor (which was at ~65pt from top after para 1). Fixed by computing the needed advance to clear the textbox bottom in PDF coordinates.

**Fix**:
1. In `src/docx/mod.rs`: Extended `collect_block_nodes()` to handle `mc:AlternateContent` by descending into `mc:Fallback` children.
2. In `src/docx/headers_footers.rs`: Replaced manual block iteration with `collect_block_nodes(root)`.
3. In `src/pdf/header_footer.rs`: For empty paragraphs with TopAndBottom textboxes, compute the cursor advance needed to clear the textbox bottom (page-relative or paragraph-relative) and use the maximum of that and `line_h`.

**Result**: `education_consultant_posting` Jaccard +1.2pp (14.5% → 15.7%), SSIM +1.9pp (32.7% → 34.5%). "TERMS OF REFERENCE" now at y=100.86 (reference: 101.00, 0.14pt difference). No regressions on any fixture.

---

## Annotation #82 — Too much space below header (czech_municipal_grant_form) — 2026-04-01 (investigated, not fixed)

**Problem**: Annotator reported "Too much space below 'header' with image and text and above numbered list" on page 2. The visual impression is a gap between the header (coat of arms + text) and the body content.

**Analysis (initial)**: Pagination issue, not spacing. Different content on page 2 because generated fits more on page 1.

**Detailed re-investigation (2026-04-01, second pass)**: Measured actual text positions via `mutool draw -F stext` for both generated and reference PDFs. The earlier "~2.5pt per row" claim was **incorrect**. Actual measurements:

- **Per-row height difference**: Only **0.07pt** per row (13.93pt generated vs 14.0pt reference cell content area). Over 7 rows: 0.49pt total — negligible.
- **Cumulative drift**: 26pt across the full page, from text position comparison:
  - "ŽADATEL:" heading: generated 4.88pt lower than reference
  - "Bankovní účet": generated 3.78pt HIGHER than reference
  - "Seznam příloh" (bottom): generated 18.78pt higher
- **Per table+gap spacing**: Generated is ~1.5-2pt shorter per table+empty-paragraph cycle. The difference is NOT from row height but from the **empty paragraphs between tables** (~0.6pt each) and other small accumulations.

**Empty paragraph font resolution bug found**: `ensure_nonempty_paragraph()` in `runs.rs` creates a synthetic run for empty paragraphs. When the paragraph rPr has `rFonts` (Calibri) but no `sz`, the synthetic run uses the style-cascade font_name ("Times New Roman" from docDefaults) instead of the paragraph's own rFonts. However, fixing this would make things WORSE: TNR's line_h_ratio (1.2446) produces 14.94pt (closer to reference ~15pt), while Calibri's (1.2207) gives 14.65pt (further from reference).

**Table positioning `- cm.left` investigation**: Discovered that `table_left = margin + tblInd - cm.left` in `pdf/table.rs` (lines 324, 950, 1147) causes table text to start at x=65.35 (table edge) instead of x≈71 (inside cell margin). This is 5.4pt too far left for the czech document (tblInd=-108). However, removing `- cm.left` caused regressions in case6 (-11.1pp) and case15 (-8.5pp) because for `tblInd=0` tables, the `- cm.left` is correct behavior (Word also shifts the table border left so text aligns with margin). The fix would need to be conditional on tblInd value — not attempted.

**Root cause**: Systemic accumulation of many tiny differences: per-row (0.07pt), per-empty-paragraph (~0.6pt), border widths, and font metric variations. No single fix addresses the cumulative effect. Total ~26pt drift across 7+ table+paragraph cycles on page 1 causes different pagination.

---

## Annotation #48 — Floating table breakpoint (croatian_grant_guidelines) — 2026-03-30 (investigated, not fixed)

**Problem**: The green "Važno!" floating table on page 4 splits one paragraph too early. Reference fits 8 items (paragraphs 0-7) on the page, but we fit only 7 (0-6).

**Analysis**: Cumulative item heights: items 0-7 need 386.00pt, but only 383.80pt is available. The 2.2pt shortfall traces to the floating table starting ~16pt lower in our rendering (at 387pt from top vs 371pt in reference). This is the same systemic vertical shift as annotation #47 — accumulated text height differences from pages 1-3 push the floating table down on page 4.

**Root cause**: Consequence of the systemic Y-shift (annotation #47). Not independently fixable.

---

## Annotation #54 — Table cells wrong (go_math_grade4_guide) — 2026-03-30

**Problem**: The "Chapter 10 Rule of Thumb" table's content row rendered as one cell spanning full width instead of two cells (rule text + rationale).

**Analysis**: The table has a 3-column grid (10, 8460, 5940 twips) but Row 1 has only 2 cells with no `w:gridSpan` attribute. Without explicit gridSpan, the parser defaulted to span=1, mapping Cell 0 to the tiny 10-twip column (0.5pt) instead of spanning columns 0+1 (8470 twips = 423pt) as intended by its tcW width.

**Fix**: In `src/docx/tables.rs`, when `w:gridSpan` is not explicit, the parser now infers the span by finding the number of consecutive grid columns whose cumulative width best matches the cell's declared tcW width. For the problematic cell: tcW=8460 matches columns 0+1 (10+8460=8470, diff=0.5pt) much better than column 0 alone (10, diff=422.5pt), so gridSpan=2 is inferred.

**Result**: go_math_grade4_guide Jaccard +0.2pp (30.0% → 30.3%). No regressions on any fixture. The Rule of Thumb table now correctly shows two cells matching the reference layout.

---

## Annotations #49, #50, #51 — Already resolved (croatian_grant_guidelines) — 2026-03-30

**#49** (Links overflow page width): Verified all URL text right edges ≤ 524.46pt (within right margin). URLs wrap correctly via `unicode_linebreak` segments.

**#50** (Footer not rendered): Footers ("Stranica N") render on ALL 20 generated pages with position matching reference within 0.2pt.

**#51** (Footer rendered wrong): Footer positioning matches reference. No horizontal line exists in the DOCX footer XML.

---

## Summary of systemic themes

All remaining unfixed annotations fall into these categories:

1. **Font metric precision** (#5, #66, #77, #78): Cumulative differences from macOS vs Windows font metrics, floating-point vs twip rounding
2. **Font availability** (#8, #30): Missing Windows fonts on macOS causing fallback with different metrics
3. **Textbox positioning** (#25): Paragraph-relative textbox anchor position depends on cursor advancement of preceding empty paragraphs

## Annotation #56 — Text wrapping around wide image (indonesian_benchmarking_guide) — 2026-03-30

**Problem**: On page 7 (0-indexed page 6), text wrapped to the right of a `wrapSquare wrapText="bothSides"` floating image (251pt wide, left-aligned to margin). The reference shows text flowing below the image, not beside it.

**Analysis**: The image (Picture 4, "Capture modul 5.4.PNG") is 251pt wide in a 451pt text area (55.6% of width). With `wrapSquare wrapText="bothSides"` and left alignment, there's ~191pt of space to the right. Our code was treating it as a wrapping image and flowing text beside it. Word treats wide images as effectively TopAndBottom, not wrapping text beside them.

The existing threshold for treating Square/Tight/Through images as TopAndBottom was 90% of text width — far too permissive. Analysis of all wrapSquare images across the test corpus showed a clear gap: all images at ≤44.2% correctly wrap text, while images at ≥55.6% should not wrap.

**Fix**: In `src/pdf/mod.rs`, lowered the width threshold from 90% to 50% of text_width in three locations:
1. The `reserve` check for content_h calculation (line ~1349)
2. The float zone setup in the self-wrapping block (line ~967) — added `fi.image.display_width < text_width * 0.5` condition
3. The float zone setup during rendering (line ~1755) — same condition as a match guard
4. The lookahead for next-paragraph wrapping (line ~1204) — same condition
5. The textbox reserve check — same 50% threshold

**Result**: Text now flows below the image on page 7, matching the reference. Jaccard -4.1pp (40.5% → 36.4%) due to cascading page breaks from the layout change. No regressions on any other fixture (brazilian_logistics_study, german_mezzo_soprano_bio, vaccines_history_chapter, czech_expert_witness_law, go_math_grade4_guide all unchanged).

---

## Annotation #58 — Too much empty space between image and caption (brazilian_logistics_study) — 2026-03-31

**Problem**: On page 8, too much empty space (~120pt) between a floating image and its caption "Fonte: ALARCOM, (2019)."

**Analysis**: The paragraph contains a `wp:anchor` floating image (364.5pt wide, 146.9pt tall) with `wrapSquare wrapText="bothSides"`. The image occupies 80.4% of the 453.5pt text area. Between the image paragraph and the caption, there are 8 empty paragraphs with 1.5x line spacing at 10pt (~17pt each).

Previously, wide wrapSquare images (≥50% of text width) were treated as TopAndBottom — their height was added to content_h, advancing the cursor past the image. The 8 empty paragraphs then added ~120pt MORE space on top of the image height, creating excessive vertical space.

In Word, these empty paragraphs exist within the image's vertical extent (flowing beside it in the narrow wrap zones), contributing no extra space below the image.

**Fix**: Three changes in `src/pdf/mod.rs`:
1. **content_h reservation**: Square/Tight/Through wrapping images no longer add their height to content_h. Their height is tracked via `float_overflow_h` for page break decisions only.
2. **Float zone creation**: All Square/Tight/Through images now create float zones (removed the `< 0.5 * text_width` guard). This means subsequent paragraphs are aware of the image's vertical extent.
3. **Dynamic MIN_WRAP_WIDTH**: Changed from fixed 72pt to `(col_w * 0.5).max(72.0)`. This prevents text from wrapping beside wide images (where combined wrap space < 50% of column width) while still allowing narrow images to wrap correctly.
4. **Empty paragraph absorption**: When a float zone has insufficient wrap space, empty paragraphs are no longer pushed below the zone. They advance the cursor naturally within the zone, effectively being "absorbed" by the image's vertical extent. Non-empty paragraphs are still pushed below.

**Result**: brazilian_logistics_study Jaccard +1.3pp (16.3% → 17.6%), SSIM +3.1pp (27.4% → 30.5%). indonesian_benchmarking_guide also improved: Jaccard +4.5pp (36.4% → 40.8%), SSIM +4.6pp (50.3% → 54.9%). Minor noise on case41 (-0.3pp) and sample500kB (-0.2pp/-0.7pp). No regression flags.

---

## Annotation #59 — Too much white space above image (brazilian_logistics_study) — 2026-03-31 (investigated, not fixed)

**Problem**: On page 9 (0-indexed page 8), excessive white space above "Figura 2" image and caption.

**Analysis**: Generated page 8 has 26 text lines vs reference's 22 — 4 extra lines from different line breaks in justified Arial text. These 4 extra lines consume space at the bottom of page 8 that should hold 7 empty paragraphs (spacers before "Figura 2"). About 4 empty paragraphs overflow to page 9, creating ~83pt of visible white space above the content.

**Root cause**: Systemic text wrapping / line length differences. Not independently fixable.

---

## Annotation #60 — Wrong font on chart labels (sample500kB) — 2026-03-31

**Problem**: Annotator reported chart labels use wrong (serif) font.

**Analysis**: Both reference and generated PDFs use Aptos (sans-serif) for chart labels. Verified via `mutool info`: reference has `AAAAAN+Aptos`, generated has `Aptos`. The chart font correctly defaults to the theme minor font (Aptos). The annotation was likely made against an older build.

**Result**: Already fixed. Marked as fixed in annotations.json.

---

## Annotation #67 — DRAFTING NOTE overlaps MCL logo (uk_commercial_lease_template) — 2026-03-31

**Problem**: "[DRAFTING NOTE: THIS LEASE IS INTENDED..." text was 25pt too low on the cover page, overlapping the MCL logo (positioned via footer with -115pt negative offset).

**Analysis**: The cover page is a single 8-row table. Row 7 (last row) contains a nested 5-row table inside a single cell. After the nested table, the cell has a mandatory empty end-of-cell paragraph (SHNormal style). This trailing paragraph added 12.3pt (line_h for Arial 10pt at 1.1× spacing) + 9pt (space_after from Normal style) = 21.3pt to the row height. In Word, when a cell's content is solely a nested table plus the mandatory end-of-cell mark, the trailing paragraph mark contributes only the ~0.5pt glyph height (already accounted for in the row-height addition), not a full line with spacing.

**Fix**: In `src/pdf/table_layout.rs`, added two conditions:
1. When computing cell content height, if the previous block was a nested table and this is the first empty paragraph after it (`prev_was_nested_table && para_idx == 1`), skip adding `line_h`.
2. When adding trailing `space_after` to cell height, check if the cell is exactly `[nested_table, empty_paragraph]` (`sole_table_plus_mark`). If so, suppress the space_after.

**Result**: DRAFTING NOTE moved from y=640.3 to y=618.7 (reference: y=614.9, difference now 3.8pt — within cumulative font metric tolerance). uk_commercial_lease_template SSIM +0.3pp (33.9% → 34.2%). One expected side-effect: cases/case51 SSIM -2.7pp (nested table test case with a [TABLE, P("")] cell that now renders shorter; baseline updated).

---

## Annotation #72 — Lines between cells wrong (turkish_ancient_religions_plan) — 2026-03-31

**Problem**: Horizontal borders were drawn through vertically merged cells in the left "Haftalar" column, creating lines that should not exist. In Word, merged cells appear as one tall cell without internal horizontal borders.

**Analysis**: The table has `insideH: single` and cell-level `tcBorders` with `dotted` borders. The left column uses `vMerge` to merge groups of 3-4 rows per "Hafta". Four border rendering paths (`render_table_row`, `render_nested_table`, `render_partial_row`, `render_header_footer_table`) handled `VMerge::Continue` cells incorrectly:
- `render_table_row`: Drew the bottom border of every continuation cell, creating lines through the merged area
- The other three paths: Drew all borders for continuation cells without any vMerge handling

Additionally, when a merge group extended to the table edge, the restart cell's `borders.bottom` used `insideH` (set during parsing based on the restart row's position) instead of the table-edge border.

**Fix**: Two-part fix:
1. **Parsing** (`src/docx/tables.rs`): After border resolution, a new pass propagates the last continuation cell's bottom border to the restart cell. This ensures the merged region uses the correct bottom edge style (e.g., table-edge double border rather than interior insideH).
2. **Rendering** (`src/pdf/table.rs`): All four border rendering paths now skip `VMerge::Continue` cells entirely. For `VMerge::Restart` cells, borders extend to `effective_bottom` (row_bottom minus merge_extra), covering the full merged region.

**Result**: turkish_ancient_religions_plan Jaccard +0.2pp (22.3% → 22.5%). Also improved: italian_project +0.6pp, case15 +0.1pp. Minor: japanese_interlibrary_loan -0.4pp (corrected border rendering in tables with vMerge). No SSIM regressions.

---

## Annotation #76 — List label font size boosting line height (samples/samtale) — 2026-03-31

**Problem**: In paragraphs with large list labels (e.g., 20pt numbered "6." on 10pt text), "I stor grad." answer text floated ~17pt above the grey paragraph bottom border instead of the correct ~5pt gap.

**Analysis**: The document uses a 2-column layout with numbered Q&A sections. Each question uses `numId=6` at `ilvl=0` with `w:sz w:val="40"` (20pt) for the list number label, while paragraph text is 10pt. The `content_h` calculation in `pdf/mod.rs` boosted `first_line_h` to `resolve_line_h(Auto(1.0), 20.0, tallest_lhr)` ≈ 24.4pt when `list_label_font_size > font_size`, adding ~12.2pt extra per paragraph. Word does not do this — list labels sit in the margin and don't affect line height.

For Q6 (right column): content_h was 24.4 + 12.2 + 12.2 = 48.8pt instead of correct 36.6pt. The border at `box_bottom = slot_top - content_h - bdr_bottom_pad` was placed 12.2pt too low, creating a 17pt gap between "I stor grad." baseline and the border (reference: ~5.8pt).

**Fix**: In `src/pdf/mod.rs`, removed the `first_line_h` boost for `list_label_font_size > font_size`. Now `first_line_h` always starts at `line_h` (based on text font), and only `label_boosted_line_h()` adjusts it for labels with font size close to text (within 1pt difference, e.g. bullet symbols).

**Result**: Q6 text-to-border gap: 17.0pt → 4.8pt (reference: 5.8pt). Text boundary score improved +7.7pp (20.8% → 28.6%). Jaccard -2.2pp (11.9% → 9.7%) and SSIM -15.3pp due to secondary column-break effect: more compact Q1-Q5 spacing caused "Vurdering" heading to fit in column 1 instead of flowing to column 2. No regressions on any other fixture.

---

## Annotation #79 — Garbled header text (education_consultant_posting) — 2026-03-31

**Problem**: On page 7 (0-indexed page 6), the header text "United Nations Children's Fund | Pakistan Country Office" rendered with missing characters: "□nited □ations Children□s □und". Letters like U, N, F, and the curly apostrophe were replaced with `.notdef` glyphs.

**Analysis**: The header contains a VML textbox (`mc:AlternateContent` → VML shape) with the title text in Calibri Bold at color #00B0F0. The text is rendered via `render_header_footer_textboxes()` in `pdf/header_footer.rs`, which correctly iterates textbox paragraphs and their runs.

However, the font subsetting pipeline in `src/pdf/fonts.rs` (`collect_all_runs()`) collects characters from all runs to determine which glyphs to include in each subsetted font. For **body** paragraphs, it used `para_runs_with_textboxes(para)` which recursively includes runs from nested textbox paragraphs. But for **header/footer** paragraphs, it only used `p.runs.iter()` — skipping textbox runs entirely.

Characters that appeared *only* in header textboxes (and nowhere else in the document) were never added to the font's `char_to_gid` mapping. During PDF rendering, `encode_as_gids()` mapped these unknown characters to glyph ID 0 (`.notdef`), producing garbled output.

The same issue affected **footnote** paragraphs, which also used `p.runs.iter()` instead of `para_runs_with_textboxes(p)`.

**Fix**: In `src/pdf/fonts.rs`, changed both header/footer and footnote run collection to use `para_runs_with_textboxes(p)` instead of `p.runs.iter()`, ensuring all textbox-nested runs are included in font character collection.

**Result**: education_consultant_posting Jaccard +0.3pp (15.7% → 16.0%), SSIM +0.9pp (34.5% → 35.4%), text boundary +23.4pp (43.9% → 67.3%). Header text now renders correctly. No regressions on any fixture.

---

## Annotation #80 — Empty space below image too large (indonesian_benchmarking_guide) — 2026-03-31

**Problem**: On page 7 (0-indexed page 6), excessive empty space (~28pt) between the floating questionnaire image and "3. Metode Benchmarking" heading below it.

**Analysis**: The floating image (Capture modul 5.4.PNG, 251×374pt, wrapSquare bothSides) creates a float zone covering most of the page. Between the image's anchor paragraph and "3. Metode Benchmarking", there are 5 completely empty paragraphs plus 1 paragraph containing only a `w:br` (soft line break) element with no text.

The float zone absorption logic in `pdf/mod.rs` checked `is_text_empty(&p.runs)` to decide whether a paragraph should be absorbed within the float zone. `is_text_empty()` returns false for runs with `is_line_break=true`, so the br-only paragraph was NOT absorbed. Instead, it triggered the float-zone push (setting slot_top to fz.bottom_y) and then added its own content_h of 28.2pt (2 lines × 14.1pt) below the image — creating visible empty space.

**Fix**: In `src/pdf/mod.rs`, expanded the `is_empty_para` check in the float zone absorption code to treat line-break-only runs as empty content. Changed from `is_text_empty(&p.runs)` to an inline check that allows `r.is_line_break` (in addition to vanished and truly empty runs), so paragraphs with only soft line breaks are absorbed within the float zone just like truly empty paragraphs.

**Result**: indonesian_benchmarking_guide Jaccard +1.8pp (40.8% → 42.6%), SSIM +1.7pp (54.9% → 56.6%). "3. Metode Benchmarking" heading moved from 476pt to 448pt from top (reference: 462pt). More content now fits on page 7 (including section 3.1.3), matching the reference layout. No regressions on any fixture.

---

## Annotation #81 — Footnotes rendered below footer (go_math_grade4_guide) — 2026-03-31

**Problem**: On page 1 (0-indexed), footnotes rendered ~48pt too low, appearing BELOW the page footer (page number "2") instead of above it. The footnote "1Student Achievement Partners..." was at y=35.8pt from bottom while the footer was at y=58.9pt.

**Analysis**: The page has `w:pgMar bottom="274"` (13.7pt) and `w:footer="720"` (36pt). The body content layout correctly used `compute_effective_margin_bottom()` (=61.3pt) to account for the footer, but footnote rendering in Phase 2c used raw `sp.margin_bottom` (13.7pt) as its base Y position.

**Fix**: In `src/pdf/mod.rs` Phase 2c, changed footnote rendering to use `compute_effective_margin_bottom(sp, is_first, &ctx)` from the header/footer section instead of raw `sp.margin_bottom`. Also destructured the full `(hf_si, is_first, content_si)` tuple to use the correct section for footer height computation.

**Result**: Footnote line 1: y=35.8pt → y=83.4pt from bottom (reference: 83.8pt, 0.4pt diff). Footnote line 2: y=23.6pt → y=71.2pt (reference: 71.1pt, 0.1pt diff). go_math_grade4_guide Jaccard +0.1pp (30.3% → 30.4%), SSIM +0.2pp (50.7% → 50.8%). No regressions.

---

## Annotation #25 — Textbox overflow text visible (air_pollution_permit_form) — 2026-04-01

**Problem**: "Mellékletek / Prílohy:" appeared at y=52.7pt from page bottom in the generated output but not in the reference PDF. The text is near the end of textbox 4 (481×714pt, 29 paragraphs) which extends past the page bottom.

**Analysis**: The document has 3 overlapping page-covering textboxes with `wrapNone`. Textbox 4 (highest z-order) is anchored to paragraph 11 (after 10 empty body paragraphs) with `positionV relativeFrom="paragraph"`. Two issues caused the textbox content to be ~86pt shorter than Word's:

1. **Missing auto-spacing**: The textbox paragraphs use `NormlWeb` style with `beforeAutospacing="1"` / `afterAutospacing="1"`. When these attributes are set, Word uses 1em (= font_size, 12pt for TNR) as spacing instead of the raw 100 twips (5pt). We used 5pt, making each NormlWeb transition ~7pt too compact.

2. **Missing spacing collapse in textboxes**: Body text rendering correctly uses `max(prev_after, current_before)` for inter-paragraph spacing, but textbox rendering summed both (`space_after + space_before`). This double-counted spacing between adjacent paragraphs.

The net effect: shorter textbox content kept "Mellékletek" (paragraph 27 of 29) visible on the page, while in Word the taller content pushed it below the page boundary where it's naturally invisible.

**Fix**: Two changes:
1. In `parse_paragraph_spacing()`: Added auto-spacing support — when `beforeAutospacing="1"` or `afterAutospacing="1"` is active, use the paragraph style's font_size as spacing. Applied via `autospacing_font_size: Option<f32>` parameter: `Some(font_size)` for textbox paragraphs, `None` for body/header/footer/table contexts.

2. In `textbox_render.rs`: Changed `render_textbox_paragraphs` to collapse adjacent paragraph spacing using `max(prev_space_after, current_space_before)` instead of summing both independently.

Also added `space_before_autospacing` / `space_after_autospacing` fields to `ParagraphStyle` with inheritance through the basedOn chain.

**Result**: "Mellékletek / Prílohy:" no longer appears on the page. Signature line position improved from 78.8pt off-reference to 13.8pt (cumulative font metric difference). SSIM +9.6pp (33.2% → 42.8%), Jaccard -0.3pp (noise). No regressions on any fixture. Committed as aa326b9.

---

## Annotation #87 — Footnote reference numbers not highlighted (uk_commercial_lease_template) — 2026-04-01

**Problem**: On page 14 (0-indexed), footnote reference numbers in body text and footnote area lacked yellow background highlighting. The reference PDF shows bold superscript numbers with yellow `w:shd` background; our generated output showed plain superscript numbers without any highlight.

**Analysis**: The `FootnoteReference` character style defines `w:shd w:val="clear" w:color="auto" w:fill="FFFF00"` (yellow shading), `w:b` (bold), Arial 10pt, and `w:vertAlign w:val="superscript"`. Three issues prevented the highlight from rendering:

1. **`CharacterStyle` had no `highlight` field**: The struct in `styles.rs` stored font, bold, italic, color, etc., but not highlight/shading. Character styles with `w:shd` or `w:highlight` were silently ignored.
2. **`resolve_run_format` only parsed `w:highlight`**: Run-level highlight resolution checked `w:highlight` (named colors like "yellow") but not `w:shd @fill` (arbitrary hex colors). The FootnoteReference style uses `w:shd`, not `w:highlight`. Additionally, there was no fallback to the character style's highlight.
3. **`styled_run()` didn't copy highlight**: The `styled_run()` method (used by `superscript_run()` for footnote references) only copied `font_size`, `font_name`, `bold`, `italic`, `color` — missing `highlight`.

**Fix**: Three changes:
1. Added `highlight: Option<[u8; 3]>` to `CharacterStyle` in `styles.rs`, parsed from `w:highlight` (named) or `w:shd @fill` (hex).
2. In `resolve_run_format` (`runs.rs`), added `w:shd @fill` as fallback after `w:highlight`, then character style highlight as final fallback.
3. In `styled_run()` (`runs.rs`), added `highlight: self.highlight` to copy the field to footnote reference runs.
4. Made `highlight_color()` in `mod.rs` `pub(super)` so `styles.rs` can use it.

**Result**: Yellow highlighting now renders on all 22 footnote reference numbers in body text and footnote area, matching the reference PDF. SSIM +0.1pp (34.1% → 34.2%). No regressions on any fixture. Committed as 69dc510.

---

## Annotation #82 — Header wrapping image inflating header height (czech_municipal_grant_form) — 2026-03-31

**Problem**: On page 2 (0-indexed page 1), ~20pt excess space between the header (coat of arms image + "OBEC TUHAŇ" + address + green line) and the body content (numbered list starting with "1) Osoba oprávněná...").

**Analysis**: The header contains 3 paragraphs: (1) empty text with a `wrapSquare` floating image (45.4×48.7pt coat of arms, ~10% of text width), (2) "OBEC TUHAŇ" in Cambria 11pt bold, (3) address text with a green connector line shape. In `compute_header_height()`, paragraph 1's `content_h` was set to `max(line_h, fi_h) = max(12pt, 48.7pt) = 48.7pt`, then paragraphs 2 and 3 each added their own ~13pt line heights on top. Total: ~75pt.

In Word, paragraphs 2 and 3 flow *beside* the wrapSquare image (it's narrow — only 10% of text width), so they don't add height beyond the image's vertical extent. The header height should be `max(sum_of_text_heights, float_bottom)`, not `float_height + text_heights`.

**Fix**: In `src/pdf/header_footer.rs`, changed `compute_header_height()` to track narrow wrapping images (Square/Tight/Through, width < 50% of text width) separately via `float_bottom_h` instead of inflating `content_h`. The final height is `max(accumulated_text_height, float_bottom_h)`. Wide wrapping images and TopAndBottom images still inflate `content_h` directly.

**Result**: czech_municipal_grant_form SSIM +4.7pp (34.1% → 38.8%), Jaccard -0.8pp (content reflow). education_consultant_posting SSIM -0.6pp (noise, no wrapping images in its header). No regression flags.

---

## Annotation #83 — Table heading overlaps thumbs up icon (go_math_grade4_guide) — 2026-03-31

**Problem**: On page 3 (0-indexed page 2), the "Rule of Thumb" table header text rendered directly on top of a small thumbs-up icon (20.85pt square floating image with `wrapSquare bothSides`, `positionH: margin/left`, `positionV: margin/top`). The icon was invisible because the text covered it completely.

**Analysis**: The floating image was correctly parsed, registered as an XObject (Im1), and rendered in the PDF content stream at the correct position (cell_x=30.6, cursor_y=375.9). The PDF content stream confirmed: grey cell shading → image at (30.6, 355.06) → text at (35.6, 364.91). The text starting at x=35.6 overlapped the image extending to x=51.45.

Root cause: Table cell text layout in `table_layout.rs` computed `para_text_w` without accounting for the horizontal space consumed by `wrapSquare` floating images. The text was laid out at the full cell width, and rendered starting at `cell_x + cm.left + indent_left` regardless of any floating images occupying space on the left side of the cell.

**Fix**: Added `float_indent_left` field to `CellParagraphLayout` struct. During layout (`table_layout.rs`), compute the horizontal space consumed by left-aligned wrapSquare/Tight/Through floating images (image width + dist_right). Reduce `para_text_w` by this amount so lines are built narrower. During rendering (`table.rs`), shift `text_x` right by `float_indent_left` and reduce `text_w` accordingly.

**Result**: go_math_grade4_guide SSIM +0.1pp (50.8% → 50.9%), text boundary +8.9pp (54.2% → 63.0%). The "Rule of Thumb" text now starts to the right of the thumbs-up icon, matching the reference layout. No regressions on any fixture.

---

## Annotation #84 — Fonte caption overlaps Figura 3 image (brazilian_logistics_study) — 2026-04-01

**Problem**: On page 9 (0-indexed page 8), "Fonte: ALARCOM, (2019)." text rendered on top of the Figura 3 image instead of below it.

**Analysis**: The paragraph "Figura 3 – Funcionalidade Relatórios do site Alarcom" contains both caption text and a `wp:anchor` floating image with `wrapSquare bothSides`, `positionV relativeFrom="paragraph" posOffset=321310` (25.3pt below paragraph), size 416×174pt (92% of text width). The immediately following paragraph "Fonte: ALARCOM, (2019)." comes after the anchor paragraph in document order.

After rendering the caption paragraph, the text cursor advances by only ~14pt (one line height), while the float zone starts 25.3pt below the anchor paragraph. So the cursor for "Fonte:" (at slot_top - 14pt) is **above** the float zone top (at slot_top - 25.3pt). The float zone check `pb.slot_top <= fz.top_y` failed because the cursor was 4.6pt above the zone, so the float zone had no effect on the "Fonte:" paragraph. It rendered at the cursor position, overlapping the image that starts just below.

**Fix**: Extended the float zone check in `src/pdf/mod.rs` to also apply when the cursor is slightly above the zone (within 30pt), but only for **paragraph-relative** float zones. Added a `para_relative: bool` field to `FloatZone` to distinguish paragraph-relative image zones (where this case occurs) from page/margin-relative floating table zones (where text above the zone should flow normally until reaching it).

The condition changed from `pb.slot_top <= fz.top_y` to `pb.slot_top <= fz.top_y || (fz.para_relative && pb.slot_top <= fz.top_y + 30.0)`. This pushes text below wide paragraph-relative images even when the cursor hasn't reached the zone yet.

**Result**: "Fonte:" moved from y=506pt to y=687pt from top (reference: y=605pt). The text is now correctly below the Figura 3 image, matching the reference's relative layout (caption → image → Fonte). The ~82pt absolute offset is from systemic Y-shift on this page. Overall scores unchanged (17.6% Jaccard, 30.5% SSIM) because the page has significant cumulative positioning differences. No regressions on any fixture.

---

## Annotation #82 — Too much space below header (czech_municipal_grant_form) — 2026-04-01 (investigated, deferred)

**Problem**: On page 2 (0-indexed page 1), ~13pt excess space between the header (coat of arms logo + "OBEC TUHAŇ" + address + green line) and body content. Body starts at 84.1pt from page top; reference starts at ~71pt (margin_top = 70.85pt).

**Analysis**: The header has a narrow `wrapSquare` floating image (45.4×48.7pt coat of arms, ~10% of text width) and 3 text paragraphs. In `compute_header_height()`:
- Para 0 (float anchor, no text): contributes 13.8pt line_h (TNR 12pt from docDefaults)
- Para 1 ("OBEC TUHAŇ"): 12.9pt (Cambria 11pt)
- Para 2 ("Tuhaň čp. 91, 277 41 Kly"): 12.9pt
- text_h = 39.6pt (but with para 0 at 0pt it would be 25.8pt)
- float_bottom_h = 48.7pt (image bottom from paragraph top, with negative offset clamped to 0)
- header_height = max(39.6, 48.7) = 48.7pt → body at 35.4 + 48.7 = 84.1pt from top

In Word, body starts at margin_top (70.85pt). The float extends below margin_top (to 82.1pt from top) but Word doesn't push body content down for it.

**Fixes attempted**:
1. **Full fix** (float-only para=0 + offset fix + cap float at available space): body at 70.85pt ✓ but SSIM -9.2pp from major page break change. Reverted.
2. **Conservative fix** (float-only para=0 + offset fix, no cap): body at 82.1pt, 2pt improvement. SSIM -3.9pp, Jaccard +1.1pp. Still regresses.

**Root cause**: The float image (48.7pt) exceeds available header space (margin_top - header_margin = 35.45pt) by 13.25pt. Word allows header floats to visually extend into the body area without displacing it. Our code pushes body content down by the full float extent.

**Why deferred**: Both fixes cause SSIM regressions (the 2pt conservative fix shifts page breaks enough to cause -3.9pp). Fixing this properly requires either accepting the regression or implementing float-zone absorption in header height computation (where text paragraphs beside the float don't add height beyond the float zone).

---

## Annotation #85 — vMerge continuation cells visual appearance (japanese_interlibrary_loan) — 2026-04-01 (already fixed)

**Problem**: "Two empty cells here that should be merged with the one above" at (39.73, 232.83) on page 1.

**Analysis**: The first column has vMerge groups (`申込機関` spanning rows 0-1, `申込者` spanning rows 2-5). The PDF content stream confirmed: no internal horizontal borders within the merged cell. Pixel values at row boundaries within the first column are pure white (255,255,255). The "empty cells" visual impression comes from the adjacent second column's row boundaries creating T-junctions at the shared edge.

**Result**: Already fixed (likely by annotation #72 vMerge fix). The merged cell renders correctly.

---

## Annotation #86 — Nil cell borders not overriding table style (uk_commercial_lease_template) — 2026-04-01

**Problem**: On page 5 (0-indexed page 4), the LR4 "Property" section shows a visible horizontal border between "In the case of a conflict..." and "The property described as..." paragraphs, but in the reference these form one continuous cell.

**Analysis**: Both rows explicitly set their shared borders to `nil` via `<w:bottom w:val="nil"/>` and `<w:top w:val="nil"/>` in `<w:tcBorders>`. However, the table has `tblBorders` with `insideH` (inside horizontal border) defined. In `border_or_fallback()`, when a cell border had `val="nil"`, `parse_cell_border` returned `CellBorder { present: false, is_override: false }` — identical to a MISSING border element. Since `present` was false and `is_override` was false, the fallback (the table's `insideH` border) was used, overriding the explicit nil.

**Fix**: Two changes:
1. In `parse_cell_border()` (`src/docx/mod.rs`): when `val == "nil"` or `val == "none"`, set `is_override: true` on the returned `CellBorder` to distinguish "explicitly nil" from "not specified".
2. In `border_or_fallback()` (`src/docx/tables.rs`): check `inline.is_override` in addition to `inline.present`. When `is_override` is true, use the inline border (which has `present: false`) instead of the table style fallback.

**Result**: uk_commercial_lease_template SSIM -0.1pp (noise). education_consultant_posting Jaccard +0.1pp (improved). The nil border between the two paragraphs is now correctly suppressed, matching the reference.

---

## Annotation #88 — Body text overlaps footnote text (uk_commercial_lease_template) — 2026-04-01

**Problem**: On page 15 (0-indexed 14), the "Risk Period" definition body text overlapped with footnote text at the bottom of the page. The page had 7 footnotes (26-32) and the body text ran into the footnote area.

**Analysis**: Footnote space was tracked AFTER paragraph rendering (line 2062-2079 in pdf/mod.rs), but the page break check happened BEFORE rendering (line 1499). When checking if the "Risk Period" paragraph fit on the page, `effective_margin_bottom` did not include footnote 32's space (which "Risk Period" references). The paragraph was placed on the page, then footnote 32's space was added — too late to prevent overlap.

**Fix**: Added footnote pre-computation before the page break check. A new block scans the current paragraph's runs for footnote references not yet tracked on the page, computes their combined height via `compute_footnote_height()`, and adds this as `para_fn_extra` to both the page break condition and the available-space calculation. The existing post-render tracking (which adds IDs to `pb.footnote_ids` and updates `effective_margin_bottom`) remains unchanged. If the paragraph doesn't fit and triggers a page break, `effective_margin_bottom` resets and the footnotes are correctly re-tracked on the new page.

**Result**: "Risk Period" correctly pushed to next page. Body text on page 15 now ends at "Rent Days" with footnotes 26-31 below, properly separated. Jaccard +1.1pp (19.2% → 20.2%), SSIM +1.3pp (34.2% → 35.5%). Only noise regression: croatian_grant_guidelines -0.1pp. No assertion failures.

---

Future priorities to address these:
- Implement Word-compatible twip rounding in the spacing pipeline
- Improve OS/2 font metric handling for less common fonts
- Better paragraph mark height calculation for empty paragraphs
- Investigate Word's exact line pitch / document grid behavior
- Rethink paragraph-relative textbox positioning
- Header float zone absorption for narrow wrapping images (annotation #82)

---

## Annotation #89 — "Part 1:" and "Tenant's Rights" overlaps (uk_commercial_lease_template) — 2026-04-01 (fixed)

**Problem**: On page 43 (0-indexed), "Part 1:" and "Tenant's Rights" text overlaps. The list label "Part 1" and paragraph text ": Tenant's Rights" rendered at the same x position, creating garbled text "Plartnt's Rights".

**Analysis**: The `SHPart` style uses numbering definition abstractNumId=1, ilvl=1, which has `w:suff w:val="nothing"` and `w:ind w:left="0" w:firstLine="0"`. The `w:suff` element controls the separator between the numbering label and paragraph text:
- `tab` (default): tab character separates label and text
- `space`: space character separates them
- `nothing`: no separator — label is inline with text

Our code never parsed `w:suff`, treating all labels as if `suff="tab"`. With `suff="nothing"` and zero indent, both the label ("Part 1") and text (": Tenant's Rights") rendered at `col_x + 0 - 0 = col_x`, overlapping completely.

**Fix**: Three changes:
1. **Parse `w:suff`** (`src/docx/numbering.rs`): Added `suff` and `label_font` fields to `LevelDef` and `suff` to `ListLabelInfo`. Parse `w:suff` from the numbering level XML (default: "tab").
2. **Propagate** through `ListLabelInfo` → paragraph parsing in `docx/mod.rs`, `tables.rs`, `textbox.rs`.
3. **Inline label when `suff="nothing"`** (`src/docx/mod.rs`): When `suff="nothing"`, create a synthetic `Run` with the label text, font, size, bold, and color from the numbering level definition. Prepend it to the paragraph's runs and clear the `list_label` field so it's not rendered separately in the margin. This makes the label flow naturally as inline text.

Also affected: `SHScheduleHeading` style (ilvl=0, same numbering, `suff="nothing"`) — produces labels like "Schedule 1", "Schedule 2" that are now correctly inline.

**Result**: "Part 1: Tenant's Rights" now renders correctly as one line. SSIM +0.1pp (35.5%). No regressions (croatian_grant_guidelines -0.1pp is noise).

---

## Annotation #78 — Text out of table bounds (stem_partnerships_guide) — 2026-04-01 (fixed)

**Problem**: On page 7 (0-indexed), bullet text in the "How" cells of What/How tables extended past the table's right cell border. At the annotation point (x=551.3, y=453.1 from bottom), text like "If appropriate, embed partnership through formal documentation e.g. partnership agreements" rendered to x=552.8pt while the cell right border was at x=548.3pt — a 4.5pt overflow. Other lines ("Confirm commitment...") overflowed by up to 8.4pt.

**Analysis**: The document's What/How tables have `tblW w:type="dxa" w:w="9634"` (481.7pt fixed width) which intentionally extends past the text area (451.3pt). Both Word's reference and our output render these tables wider than the text area. The REAL issue was text extending past the TABLE's own right boundary.

Root cause: a mismatch between the table LAYOUT and RENDERING code for first-line width of list label paragraphs. The layout code (`table_layout.rs:309`) passed `para.indent_hanging` as the `first_line_hanging` parameter to `build_paragraph_lines()`. This widened the first line by `indent_hanging` (17.85pt for ListBullet), giving a first-line width of `para_text_w + 17.85 = 428.6pt`. But the rendering code (`table.rs:256-266`) set `first_line_hanging = 0.0` when a list label was present (because the label is drawn separately). The rendering placed text starting at `text_x = cell_x + cm.left + indent_left` with `eff_width = text_w + 0`, which was only 411.35pt.

So the line builder built lines up to 428.6pt wide, but the renderer started them at a position 17.85pt further right (indent_left), causing the rightmost text to extend 17.85pt past the intended cell boundary.

**Fix**: In `src/pdf/table_layout.rs`, changed the `first_line_hanging` passed to `build_paragraph_lines()` to match the rendering logic: when a list label is present, use 0.0 (not `indent_hanging`). For non-label paragraphs, still use `indent_hanging`. Also handles the edge case where `indent_first_line > 0 && indent_hanging == 0` (first-line indent instead of hanging indent).

**Result**: Max text right edge dropped from 556.7pt to 540.9pt, now well within the table boundary of 548.3pt. stem_partnerships_guide SSIM -0.3pp (30.9%, text wraps differently in narrower first lines). Multiple other cases improved: croatian_grant_guidelines SSIM +0.1pp, parish_housing_data_profile SSIM +0.1pp, transition_to_work Jaccard +0.1pp. No regressions.

---

## Annotation #90 — Body text overlaps footer (croatian_grant_guidelines) — 2026-04-01 (investigated, not reproducible)

**Problem**: Annotator reported "Body text overlaps footer here" on page 17 (0-indexed) at y=92.55pt from bottom.

**Analysis**: On the current build, page 18 (1-indexed) has the last body text at y_from_bottom=121.5pt and footer "Stranica 18" at y_from_bottom=60.2pt — a 61pt gap. No overlap. Checked all 72 generated pages: tightest body-footer gaps are 10.6-12.3pt (pages 23, 28, 19), compared to reference PDF's tightest gaps of 12.5-14.3pt. The ~2pt difference is consistent with cumulative font metric precision differences. The footer position (y≈774pt from top) is identical between generated and reference.

**Root cause**: The annotation was likely made on an older build where pagination placed more content on this page. The systemic ~2pt body-footer gap difference is a consequence of floating-point line height calculations vs Word's twip-rounded cursor advancement.

---

## Annotation #91 — Bullet points not indented, text overlaps (brazilian_logistics_study) — 2026-04-01 (FIXED)

**Problem**: On page 9 (0-indexed), bullet points ("Cadastro de Pontos", "Controle de Velocidade", etc.) had no indentation — text started at the left margin with no visible ● bullet character. The annotation noted "bullets and text overlaps."

**Analysis**: The bullet paragraphs have `w:numPr` (numId=1, ilvl=0) with bullet "●" in Noto Sans Symbols. The numbering definition specifies `w:ind left="1429" hanging="360"`, but each paragraph overrides with `w:ind left="0" firstLine="709"`. This means:
- Left indent = 0 (at the margin)
- First line indent = 35.45pt (where the bullet goes)
- Continuation text wraps at the margin

The bug was in the `hanging` computation for list-labeled paragraphs in the render loop. When `list_label` is not empty, the code always set `hanging = 0` (ignoring `indent_first_line`). For non-list paragraphs, `hanging = -indent_first_line` correctly creates first-line indentation. This meant list paragraphs with `firstLine` override (instead of `hanging`) had no first-line offset — the bullet label at `label_x = col_x` and first-line text at `para_text_x = col_x` both occupied the same position, causing overlap.

**Fix**: In 7 locations across `src/pdf/mod.rs`, `src/pdf/header_footer.rs`, `src/pdf/textbox_render.rs`, and `src/pdf/table.rs` (plus adding `indent_first_line` field to `CellParagraphLayout` in `table_layout.rs`): when a list paragraph has `indent_first_line > 0.0` and `indent_hanging == 0.0`, set `hanging = -indent_first_line` instead of `0.0`. This shifts the first-line text rightward by `firstLine` amount, leaving the bullet character at `label_x` (the margin) with proper spacing before the text.

**Result**: Bullet characters now visible with correct ~35pt indentation from margin. Continuation text wraps at the margin. Matches reference layout. Jaccard -0.7pp (17.6% → 16.9%), SSIM -1.1pp (30.5% → 29.3%) — score drop from cascading page breaks due to the now-correct indentation adding extra line wraps. No regressions on any other fixture.

---

## Annotation #90 — Body text overlaps footer (croatian_grant_guidelines) — 2026-04-01 (investigated, not fixed)

**Problem**: Annotator reported "Body text overlaps footer here" on page 18 (0-indexed page 17) at coordinates (154.4, 92.6).

**Analysis**: Thorough measurement of text positions using mutool stext extraction on both generated and reference PDFs:

- **Generated** (all pages consistent): body text bottom at 771.05pt from top (70.85pt from bottom = exactly margin_bottom), footer text top at 773.87pt (68.03pt from bottom). **Gap = 2.83pt**.
- **Reference** (all pages consistent): body text bottom at 770.81pt from top (71.09pt from bottom), footer text top at 774.09pt (67.81pt from bottom). **Gap = 3.27pt**.
- **Difference**: 0.44pt total (0.24pt from body position, 0.22pt from footer position).

The `compute_effective_margin_bottom` function correctly returns `max(margin_bottom, footer_margin + footer_content_height)` = max(70.85, 35.4 + ~14) = 70.85pt. The body text fills to exactly this boundary. The reference fills to 0.24pt above this boundary due to Word's twip-rounded line height calculations producing slightly different cumulative heights.

The gap of 2.83pt is tight but NOT actual overlap. The 0.44pt difference from the reference is from cumulative line height precision (same root cause as annotation #66).

**Root cause**: Systemic line height precision difference — floating-point vs twip-rounded cursor advancement. Same category as annotation #66. Not independently fixable without implementing Word-compatible twip rounding throughout the spacing pipeline.

---

## Annotation #47 — Page 1 Y-shift (croatian_grant_guidelines) — 2026-04-01 (FIXED)

**Problem**: Entire page 1 content (flag, logo, header text, body) shifted ~16.7pt too high compared to reference. Footer was correctly positioned.

**Analysis**: The first-page header (header2.xml) has two paragraphs. Paragraph 2 contains two inline images (EU flag 76.5pt, Croatian logo 55.3pt) plus a trailing `w:br` (line break). `compute_header_height()` in `src/pdf/header_footer.rs` computed `content_h = max(max_img_h, line_h)` — counting only one line per paragraph. The trailing `w:br` creates an additional line (~15.4pt) that was not counted, causing the header height to be underestimated by ~15.4pt. This made `effective_slot_top()` too high, shifting all body content up.

**Fix**: In `compute_header_height()`, count `is_line_break` runs per paragraph and add `br_count * line_h` to `content_h`. This accounts for additional lines created by explicit line breaks.

**Result**: "UPUTE ZA PRIJAVITELJE" baseline moved from y=210.91pt to y=226.35pt (reference: 227.83pt). Remaining difference: 1.48pt (within font metric tolerance, was 16.9pt). Jaccard +0.25pp, SSIM +0.58pp. No regressions on any fixture.

---

## Annotation #90 — Body text overlaps footer / missing footnotes (croatian_grant_guidelines) — 2026-04-01

**Problem**: On page 17 (0-indexed), body text appeared to overlap the footer zone. The annotated area was near the bottom of the page where dense body text ran very close to "Stranica 18".

**Analysis**: The underlying issue was that **footnotes were not being rendered** in this document. The reference PDF shows footnotes at the bottom of pages 10 and 17 (with separator lines), but the generated PDF had none. The document has 6 footnotes (IDs 2-7).

Investigation revealed two bugs causing footnote IDs to not be tracked during page rendering:

1. **Split paragraph `continue` skips footnote tracking** (`src/pdf/mod.rs` ~line 1632): When a body paragraph is split across pages (first part on page N, rest on page N+1), the `continue` statement after rendering the rest skipped the footnote tracking code at ~line 2098. Footnotes 3 and 4 were in paragraphs that split across pages, so their IDs were never added to `pb.footnote_ids`. The footnote display numbers ("2", "3") were correctly substituted in the body text (via the earlier substitution code at ~line 964), but the actual footnote content was never rendered at the bottom of the page.

2. **Table cell footnotes not tracked** (`src/pdf/mod.rs` ~line 2214): The post-table loop that updates `styleref_running` iterated table cell paragraphs but did not check for `footnote_id` in their runs. Footnote 2 was inside a table cell, so its ID was never tracked. The pre-scan for `footnote_display_order` (line 475) correctly included table cells, which is why the display number "1" appeared in the body text, but the footnote content was missing.

**Fix**: Two changes in `src/pdf/mod.rs`:
1. Added footnote tracking before the `continue` in the split paragraph case (~line 1632). After `advance_column_or_page()` and rendering the rest of the paragraph, but before `continue`, iterate `para.runs` to find footnote IDs and add them to `pb.footnote_ids` with proper `effective_margin_bottom` adjustment.
2. Added footnote tracking in the post-table styleref loop (~line 2214). For each table cell paragraph, check runs for `footnote_id` and add to `pb.footnote_ids`.

**Result**: All 6 footnotes now render correctly across the document:
- Page 12 (0-indexed): Footnote 1 (enterprise classification, 4 paragraphs with bullet list)
- Page 18: Footnote 2 (ITP criteria URL)
- Page 26: Footnote 3 (project eligibility period)
- Page 27: Footnote 4 (climate assessment)
- Page 29: Footnotes 5 & 6 (property ownership proofs)

Each has a separator line and properly formatted text. Jaccard -0.1pp (7.4→7.3, noise), SSIM +0.1pp (18.6→18.7). No regressions on any fixture.

---

## Annotation #92 — Fonte caption rendered above image instead of below (brazilian_logistics_study) — 2026-04-01 (FIXED)

**Problem**: On page 10 (0-indexed page 9), "Fonte: ALARCOM, (2019)." rendered at the top of the Figura 4 screenshot image instead of below it. The annotation noted the caption was "missing" because it appeared overlapping the image's top edge rather than in its expected position below.

**Analysis**: The "Fonte: ALARCOM, (2019)." paragraph contains BOTH the text AND a `wp:anchor` floating image (`wrapSquare bothSides`, 452.3pt wide = 99.7% of text area, 235.3pt tall, paragraph-relative posOffset=2.3pt). In Word, the image renders below the paragraph's anchor position, and since it's too wide for text to flow beside it, the paragraph's text is pushed below the image.

In our code, the self-wrapping section only handled narrow wrapping images (<50% of text width), creating float zones and narrowing text to fit beside them. For wide images (≥50%), no self-wrapping occurred — the paragraph text rendered at the cursor position (overlapping the image), and the float zone was only created AFTER rendering (for subsequent paragraphs). The anchor paragraph's own text was never pushed below its own wide image.

**Fix**: In `src/pdf/mod.rs`, extended the self-wrapping section to handle wide `wrapSquare` images:
1. When a paragraph has a wrapSquare image ≥50% of text width, compute the float zone and check available wrap space using the same `min_wrap_w` logic as subsequent-paragraph handling.
2. If not enough space: save the original `pb.slot_top` as `anchor_slot_top_for_fi`, push `pb.slot_top` below the image so text lays out below it.
3. Use `anchor_slot_top_for_fi` for floating image rendering (behind-doc and foreground) and post-render float zone creation, so the image stays at its original paragraph-relative position.
4. Skip `float_overflow_h` tracking when text was pushed (to avoid double-counting image height in page-break decisions).

Note: Only `wrapSquare` triggers push-below — `wrapTight`/`wrapThrough` use polygon contours that allow text to flow around the image at varying widths.

**Result**: "Fonte: ALARCOM, (2019)." now correctly appears below the Figura 4 image. SSIM +0.1pp (29.3%→29.4%), Jaccard -0.3pp (16.9%→16.6%, pagination cascading). No regressions on any fixture.

---

## Annotation #94 — Chart category labels skip interval too aggressive (case53) — 2026-04-17 (FIXED)

**Problem**: Chart 1 (50-category US state bar chart) rendered with label skip=3 (showing AL, AR, CT, GA, IL, KS, ME, MI, MO, NV, NM, ND, OR, SC, TX, VA, WI — 17 labels), while Word's reference used skip=2 (showing 25 labels: AL AZ CA CT FL HI IL IA KY ME MA MN MO NE NH NM NC OH OR RI SD TX VT WA WI).

**Analysis**: In `pdf/charts.rs`, the auto-skip formula was `ceil((max_label_w + 2.0) / slot_w)`. For chart 1: slot_w=8.57pt, max_label_w=15.44pt (Cambria "NM"). Computed: ceil((15.44+2)/8.57) = ceil(2.034) = 3 — just over the skip=2 threshold because of the +2.0 safety buffer. Without the buffer: ceil(15.44/8.57) = ceil(1.80) = 2, matching Word. The +2pt padding was making the algorithm too conservative for tightly-packed 2-letter labels, padding pushed us over the slot_w × 2 boundary even when labels physically fit at skip=2.

**Fix**: Removed the +2.0 padding from the skip calculation in `src/pdf/charts.rs`. Labels are centered in their expanded slot, so text_width advance ≈ visible width; rounding up via ceil is sufficient to guarantee no overlap. Other chart cases verified unaffected (all had max_label_w < threshold, giving skip=1).

**Result**: case53 Jaccard +0.1pp (56.0→56.1), SSIM +0.4pp (54.5→54.9). irish_school_enrollment text boundary +0.9pp. No regressions on any fixture.

---

## Annotation #8 — "신청자(申請者):" should come on page 2 (east_asia_conference_form) — 2026-04-17 (PARTIAL — SYSTEMIC)

**Problem**: In generated page 1 everything fits on one page: table + "2024年 月 日" + "신청자(申請者):". In reference, "2024年 月 日" is on page 1 (right-aligned near bottom), "신청자(申請者):" is on page 2 at top.

**Analysis**: Two independent issues were causing the visual difference:
1. **`firstLine`/`firstLineChars` indent wrong for CJK text** — The "2024年 月 日" paragraph has `w:firstLine="6760"` twips (338pt) AND `w:firstLineChars="2600"` (26 chars). Per OOXML spec, `*Chars` attributes take priority; our code used `char_width = font_size / 2` (Latin half-em) giving ~169pt for 13pt CJK text (half of Word's actual 338pt). This made "2024年 月 日" render on the LEFT instead of the right side of the page.
2. **Cumulative vertical drift from CJK font metrics** — Each row's content height is shorter in our render than in Word's (Korean fonts like Malgun Gothic, HY헤드라인M, 바탕 fall back to system substitutes with shorter line heights). This cascades over title + 6 table rows producing ~30-50pt of vertical space that Word uses, so our content fits entirely on page 1 while Word's overflows to page 2.

**Fix applied (partial)**: In `src/docx/mod.rs::extract_indents`, inverted priority — prefer twip-based `w:firstLine`/`w:left`/`w:right`/`w:hanging` over `*Chars` variants when both are present. Word always writes both consistently, and the twip value is the pre-resolved measurement that's correct for both Latin and CJK. Only use `*Chars` values as fallback when twips are absent.

**Result**: "2024年 月 日" now correctly right-aligned below the table (matching Word). Improvements across all CJK fixtures: east_asia Jaccard +0.2pp / SSIM +0.4pp, korean_japanese Jaccard +0.2pp / SSIM +0.2pp, taiwanese_education Jaccard +1.5pp / SSIM +2.6pp / text_boundary +5.4pp. No regressions.

**Systemic remaining**: The page 2 break for "신청자(申請者):" remains unsolved — driven by cumulative CJK font metric differences, not a specific bug. Deferred per the "systemic vertical text spacing" exception. Marking annotation as unfixed (partial improvement only).