There are 9 updates, 2 comments.
 
 
XWiki Platform / cid:jira-generated-image-avatar-23b02012-11ec-47db-bddf-855f191209b8 XWIKI-24677 Open

The nodes from the PDF export Table of Contents tree that don't fit a single print page get truncated

 
View issue   ·   Add comment
 

9 updates

 
cid:jira-generated-image-avatar-fb806391-7716-4e20-aff0-4a51f821aead Changes by Marius Dumitru Florea on 12/Aug/26 10:01
 
Fix Version: 17.10.12
Fix Version: 18.7.0-rc-1
Fix Version: 18.4.4
Development Priority: High
Difficulty: Unknown Easy
Documentation: N/A
Documentation in Release Notes: N/A
Assignee: Marius Dumitru Florea
Tests: Integration
 
 

2 comments

 
cid:jira-generated-image-avatar-fb806391-7716-4e20-aff0-4a51f821aead Marius Dumitru Florea on 12/Aug/26 10:06
 

The root cause is this CSS rule applied to the list items from the Table of Contents:

/* Make sure the fake dotted leader doesn't overflow. */
overflow-x: hidden;

The explanation and recommended fix from Claude:

 XWIKI-24677 — why `overflow-x: hidden` breaks the PDF ToC, and what removing it costs

## Context

[XWIKI-24677](https://jira.xwiki.org/browse/XWIKI-24677) (dupes: XWIKI-24649, XWIKI-24654): when the PDF
export ToC tree spans more than one print page, entries are lost — Firefox truncates the last entry of the
first ToC page, Chrome drops the continuation entirely and logs `Unable to layout item: <the "Start" node>`.

You traced it to [Sheet.xml:1547-1551](xwiki-platform-core/xwiki-platform-export/xwiki-platform-export-pdf/xwiki-platform-export-pdf-ui/src/main/resources/XWiki/PDFExport/Sheet.xml#L1547-L1551):

```css
.pdf-toc li {
  margin: .5em 0;
  /* Make sure the fake dotted leader doesn't overflow. */
  overflow-x: hidden;
}
```

### Why the rule breaks the ToC

1. **`overflow-x: hidden` + `overflow-y: visible` makes the `li` a scroll container.** Per CSS Overflow 3,
   `visible` computes to `auto` when the other axis is anything but `visible`/`clip`. So every ToC `li` gets
   computed `overflow-y: auto`.
2. **Scroll containers are *monolithic*** (CSS Fragmentation §2.1) — browsers must not fragment them.
3. **paged.js fragments each print page using multicol, not print pagination.** In
   `chunker/page.js#create()` it sets, on `.pagedjs_page_content`, `column-width: <page width>` plus a
   ~1000px `column-gap` (`--pagedjs-column-gap-offset`), with `column-fill: auto` from its base CSS.
   Content that doesn't fit the page height flows into column 2, which lands ~1000px to the right;
   `Layout.findOverflow()` then detects overflow *horizontally* (`left >= bounds.right + gap`) and
   `hasOverflow()` compares `scrollWidth` against the bounds. That is how paged.js finds its break point.
4. The ToC is a **nested** list (`PDFTocMacroTest` confirms: `li > a` + a nested `ul` *inside the same `li`*),
   so the rule turns the whole `Start` subtree — all ~40 entries — into **one monolithic box the browser
   refuses to split across columns**. Both reported symptoms follow directly:
   - Chrome pushes the whole unbreakable `li` into column 2. `findOverflow` sees `left >= end` on the
     `Start` `li` itself and returns it as the break point — i.e. the node the page already started at.
     `findBreakToken` returns a token equal to `prevBreakToken`, `renderTo` bails with
     `OverflowContentError("Unable to layout item")`, and `Page.layout()` (page.js:137) discards
     `renderResult.error` and returns `undefined` — so the chunker's `while (breakToken !== undefined)` loop
     ends and **the rest of the ToC is silently dropped**.
   - Firefox instead lets the monolithic box overflow its column; it is clipped by
     `.pagedjs_sheet { overflow: hidden }`, so the last entry on the page is **visually cut**, while paged.js
     still finds a break inside and continues onto the next page.

This only bites once the tree exceeds one page, which is why it went unnoticed since the rule was introduced
in `5232ff39643` (XWIKI-19268, the original client-side PDF export).

### What the rule is actually protecting, and what plain removal costs

The leader is a `float: left; width: 0` `::after` on `.pdf-toc li > span` holding ~140 dots at
`letter-spacing: 2px`. Zero width means it takes no layout space and its dots ink-overflow to the right;
the title (`.pdf-toc ul a`) and page number (`a[href]::after`, `position: absolute; right: 0`) both paint an
opaque white background over the dot run so only the gap between them shows dots.

If the rule is simply deleted:

- **Cosmetic (the real cost):** the dot run is then clipped only by `.pagedjs_sheet { overflow: hidden }`,
  i.e. at the *paper* edge instead of the entry's right edge — so every ToC line grows a tail of dots
  running through the right page margin. Titles and page numbers stay legible (they keep their opaque
  backgrounds); the page number is unaffected either way since its containing block
  (`.pagedjs_page_content`, `position: relative`) is an ancestor of the clipping `li`, so it already escapes
  the clip today.
- **Performance:** unclipped dots inflate `.pagedjs_page_content.scrollWidth`, so `hasOverflow()` returns
  true on every ToC page and paged.js runs a full `findOverflow()` tree walk each time even when nothing
  overflows.
- **Not a correctness risk, but close to one:** overflow is detected at `bounds.right + gap` ≈ page right
  + ~1000px. The dot run is ≈140 × (glyph + 2px) ≈ 600–700px, so it stays under the threshold and won't be
  mistaken for column-2 content — with only ~300px of headroom, and that headroom shrinks with font size
  (`.pdf-toc > ul > li > span` is already at 110%).
- No knock-on effect on `AutoScaleTables`: its `_getScale()` ancestor walk only runs for `table` clones, and
  the ToC has its own `@page toc`, so no table shares a page area with the ToC.

**Conclusion:** the benefit of the rule is purely cosmetic, but deleting it is not the best trade — we can
keep the clipping *and* fix the bug.

## Recommended change

**Use `overflow-x: clip` instead of `overflow-x: hidden`.** A `clip` box is not a scroll container
("No new formatting context is created. The element box is not a scroll container." — MDN), therefore not
monolithic, therefore fragmentable across paged.js's columns — while clipping the leader exactly as today.
It is also the only combination the spec leaves alone: `clip` on one axis does *not* force the other axis to
`auto`, so `overflow-y` stays `visible`.

In [Sheet.xml:1547-1551](xwiki-platform-core/xwiki-platform-export/xwiki-platform-export-pdf/xwiki-platform-export-pdf-ui/src/main/resources/XWiki/PDFExport/Sheet.xml#L1547-L1551):

```css
  .pdf-toc li {
    margin: .5em 0;
    /* Clip the fake dotted leader at the entry's right edge. `clip` rather than `hidden` because `hidden`
      would make the list item a scroll container, and a scroll container cannot be split between print
      pages, which breaks the layout of ToC trees taller than one page. */
    overflow-x: clip;
  }
```

Browser support is fine (Chrome 90+, Firefox 81+; the server-side export drives a recent headless Chrome).
 
cid:jira-generated-image-avatar-fb806391-7716-4e20-aff0-4a51f821aead Marius Dumitru Florea on 12/Aug/26 10:06
 
The root cause is this CSS rule applied to the list items from the Table of Contents:

{noformat}
/* Make sure the fake dotted leader doesn't overflow. */
overflow-x: hidden;
{noformat}

The explanation and recommended fix from Claude:

{noformat}
XWIKI-24677 — why `overflow-x: hidden` breaks the PDF ToC, and what removing it costs

## Context

[XWIKI-24677](https://jira.xwiki.org/browse/XWIKI-24677) (dupes: XWIKI-24649, XWIKI-24654): when the PDF
export ToC tree spans more than one print page, entries are lost — Firefox truncates the last entry of the
first ToC page, Chrome drops the continuation entirely and logs `Unable to layout item: <the "Start" node>`.

You traced it to [Sheet.xml:1547-1551](xwiki-platform-core/xwiki-platform-export/xwiki-platform-export-pdf/xwiki-platform-export-pdf-ui/src/main/resources/XWiki/PDFExport/Sheet.xml#L1547-L1551):

```css
.pdf-toc li {
  margin: .5em 0;
  /* Make sure the fake dotted leader doesn't overflow. */
  overflow-x: hidden;
}
```

### Why the rule breaks the ToC

1. **`overflow-x: hidden` + `overflow-y: visible` makes the `li` a scroll container.** Per CSS Overflow 3,
   `visible` computes to `auto` when the other axis is anything but `visible`/`clip`. So every ToC `li` gets
   computed `overflow-y: auto`.
2. **Scroll containers are *monolithic*** (CSS Fragmentation §2.1) — browsers must not fragment them.
3. **paged.js fragments each print page using multicol, not print pagination.** In
   `chunker/page.js#create()` it sets, on `.pagedjs_page_content`, `column-width: <page width>` plus a
   ~1000px `column-gap` (`--pagedjs-column-gap-offset`), with `column-fill: auto` from its base CSS.
   Content that doesn't fit the page height flows into column 2, which lands ~1000px to the right;
   `Layout.findOverflow()` then detects overflow *horizontally* (`left >= bounds.right + gap`) and
   `hasOverflow()` compares `scrollWidth` against the bounds. That is how paged.js finds its break point.
4. The ToC is a **nested** list (`PDFTocMacroTest` confirms: `li > a` + a nested `ul` *inside the same `li`*),
   so the rule turns the whole `Start` subtree — all ~40 entries — into **one monolithic box the browser
   refuses to split across columns**. Both reported symptoms follow directly:
   - Chrome pushes the whole unbreakable `li` into column 2. `findOverflow` sees `left >= end` on the
     `Start` `li` itself and returns it as the break point — i.e. the node the page already started at.
     `findBreakToken` returns a token equal to `prevBreakToken`, `renderTo` bails with
     `OverflowContentError("Unable to layout item")`, and `Page.layout()` (page.js:137) discards
     `renderResult.error` and returns `undefined` — so the chunker's `while (breakToken !== undefined)` loop
     ends and **the rest of the ToC is silently dropped**.
   - Firefox instead lets the monolithic box overflow its column; it is clipped by
     `.pagedjs_sheet { overflow: hidden }`, so the last entry on the page is **visually cut**, while paged.js
     still finds a break inside and continues onto the next page.

This only bites once the tree exceeds one page, which is why it went unnoticed since the rule was introduced
in `5232ff39643` (XWIKI-19268, the original client-side PDF export).

### What the rule is actually protecting, and what plain removal costs

The leader is a `float: left; width: 0` `::after` on `.pdf-toc li > span` holding ~140 dots at
`letter-spacing: 2px`. Zero width means it takes no layout space and its dots ink-overflow to the right;
the title (`.pdf-toc ul a`) and page number (`a[href]::after`, `position: absolute; right: 0`) both paint an
opaque white background over the dot run so only the gap between them shows dots.

If the rule is simply deleted:

- **Cosmetic (the real cost):** the dot run is then clipped only by `.pagedjs_sheet { overflow: hidden }`,
  i.e. at the *paper* edge instead of the entry's right edge — so every ToC line grows a tail of dots
  running through the right page margin. Titles and page numbers stay legible (they keep their opaque
  backgrounds); the page number is unaffected either way since its containing block
  (`.pagedjs_page_content`, `position: relative`) is an ancestor of the clipping `li`, so it already escapes
  the clip today.
- **Performance:** unclipped dots inflate `.pagedjs_page_content.scrollWidth`, so `hasOverflow()` returns
  true on every ToC page and paged.js runs a full `findOverflow()` tree walk each time even when nothing
  overflows.
- **Not a correctness risk, but close to one:** overflow is detected at `bounds.right + gap` ≈ page right
  + ~1000px. The dot run is ≈140 × (glyph + 2px) ≈ 600–700px, so it stays under the threshold and won't be
  mistaken for column-2 content — with only ~300px of headroom, and that headroom shrinks with font size
  (`.pdf-toc > ul > li > span` is already at 110%).
- No knock-on effect on `AutoScaleTables`: its `_getScale()` ancestor walk only runs for `table` clones, and
  the ToC has its own `@page toc`, so no table shares a page area with the ToC.

**Conclusion:** the benefit of the rule is purely cosmetic, but deleting it is not the best trade — we can
keep the clipping *and* fix the bug.

## Recommended change

**Use `overflow-x: clip` instead of `overflow-x: hidden`.** A `clip` box is not a scroll container
("No new formatting context is created. The element box is not a scroll container." — MDN), therefore not
monolithic, therefore fragmentable across paged.js's columns — while clipping the leader exactly as today.
It is also the only combination the spec leaves alone: `clip` on one axis does *not* force the other axis to
`auto`, so `overflow-y` stays `visible`.

In [Sheet.xml:1547-1551](xwiki-platform-core/xwiki-platform-export/xwiki-platform-export-pdf/xwiki-platform-export-pdf-ui/src/main/resources/XWiki/PDFExport/Sheet.xml#L1547-L1551):

```css
  .pdf-toc li {
    margin: .5em 0;
    /* Clip the fake dotted leader at the entry's right edge. `clip` rather than `hidden` because `hidden`
      would make the list item a scroll container, and a scroll container cannot be split between print
      pages, which breaks the layout of ToC trees taller than one page. */
    overflow-x: clip;
  }
```

Browser support is fine (Chrome 90+, Firefox 81+; the server-side export drives a recent headless Chrome).
{noformat}