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:
UnknownEasy
Documentation:
N/A
Documentation in Release Notes:
N/A
Assignee:
Marius Dumitru Florea
Tests:
Integration
2 comments
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).
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}
This message was sent by Atlassian Jira (v9.3.0#930000-sha1:287aeb6)
If image attachments aren't displayed, see this article.