diff --git a/CHANGELOG.md b/CHANGELOG.md index dc975de..920d183 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,22 @@ sections that affect its template. Version numbers follow the this path against a theme installed from the local checkout; its `docs/theme/` tree is generated, not committed. - `extra_css` now points at `theme/`. +- `pdf/styles.scss` no longer forces a page break before every subsection + and no longer pads one out to a full page. A nav section whose children + are separate pages becomes a single article of stacked `section` + elements, and the forced break dropped everything after each one's first + paragraph from the PDF, so a chapter's tables, lists, and admonitions + went missing without a warning. Top-level chapters still start on a new + page. +- A long table, list, code block, or block quote now splits across pages + instead of moving whole and leaving the page it came from half empty. + Admonitions and content tabs still move whole, because splitting a + bordered box leaves it open at the page edge. A split never strands a + lone list item or table row on either side of the break, and a heading + or a bold-only label stays with the block it introduces. +- `styles/pdf.css` now agrees with `pdf/styles.scss` on how a block + fragments. The two disagreed, so browser print and PDF export broke a + page at different points. ### api-reference @@ -44,6 +60,22 @@ sections that affect its template. Version numbers follow the this path against a theme installed from the local checkout; its `docs/theme/` tree is generated, not committed. - `extra_css` now points at `theme/` and includes `api.css`. +- `pdf/styles.scss` no longer forces a page break before every subsection + and no longer pads one out to a full page. A nav section whose children + are separate pages becomes a single article of stacked `section` + elements, and the forced break dropped everything after each one's first + paragraph from the PDF, so a chapter's tables, lists, and admonitions + went missing without a warning. Top-level chapters still start on a new + page. +- A long table, list, code block, or block quote now splits across pages + instead of moving whole and leaving the page it came from half empty. + Admonitions and content tabs still move whole, because splitting a + bordered box leaves it open at the page edge. A split never strands a + lone list item or table row on either side of the break, and a heading + or a bold-only label stays with the block it introduces. +- `styles/pdf.css` now agrees with `pdf/styles.scss` on how a block + fragments. The two disagreed, so browser print and PDF export broke a + page at different points. ### shared @@ -86,6 +118,11 @@ sections that affect its template. Version numbers follow the `extra.cover_tagline` no longer renders when `extra.pdf.cover_tagline` is unset for the locale being built. - Removed the root `VERSION` file. The git tag is the version. +- `pdf-test.sh` builds a nav-grouped fixture and reads the rendered pages + back with `pdftotext`, so the chapter content, the chapter page break, + the label kept with its list, and the two-row minimum on a split table + are each asserted. A PDF build that exits 0 was not evidence the chapter + was in the PDF, which is how the dropped content went unnoticed. ## [0.1.0] - 2026-03-30 diff --git a/templates/api-reference/pdf/styles.scss b/templates/api-reference/pdf/styles.scss index ec78d1c..ba63794 100644 --- a/templates/api-reference/pdf/styles.scss +++ b/templates/api-reference/pdf/styles.scss @@ -285,3 +285,56 @@ article#doc-toc li>a { display: inline-block; line-height: 0.5; } + +// Chapters (articles) still start on a new page; subsections flow. +article:not(#doc-cover):not(#doc-toc) section + section { + break-before: auto; + page-break-before: auto; + min-height: auto; +} + +article:not(#doc-cover):not(#doc-toc) :is(h1, h2, h3, h4, h5, h6) { + break-after: avoid; + page-break-after: avoid; +} + +// Only break-inside is relaxed. The inherited break-before: avoid keeps a +// block attached to the sentence or label that introduces it. +article:not(#doc-cover):not(#doc-toc) :is(table, pre, blockquote, ul, ol, div.highlight) { + break-inside: auto; + page-break-inside: auto; +} + +// A block may split, but never so that one item is stranded on either side. +// Scoped to top-level lists: chaining the guard through nested lists +// over-constrains the break and pushes whole blocks to the next page. +article:not(#doc-cover):not(#doc-toc) :not(li) > :is(ul, ol) > li:nth-child(-n+2) { + break-after: avoid; + page-break-after: avoid; +} + +article:not(#doc-cover):not(#doc-toc) :not(li) > :is(ul, ol) > li:nth-last-child(-n+2) { + break-before: avoid; + page-break-before: avoid; +} + +article:not(#doc-cover):not(#doc-toc) tbody > tr:nth-child(-n+2) { + break-after: avoid; + page-break-after: avoid; +} + +article:not(#doc-cover):not(#doc-toc) tbody > tr:nth-last-child(-n+2) { + break-before: avoid; + page-break-before: avoid; +} + +// Bordered containers stay whole; splitting them leaves an open box. +article:not(#doc-cover):not(#doc-toc) :is(div.admonition, div.tabbed-content--wrap) { + break-inside: avoid; + page-break-inside: avoid; +} + +article:not(#doc-cover):not(#doc-toc) :is(p, li, pre) { + orphans: 2; + widows: 2; +} diff --git a/templates/api-reference/styles/pdf.css b/templates/api-reference/styles/pdf.css index 66d4236..ac9b07a 100644 --- a/templates/api-reference/styles/pdf.css +++ b/templates/api-reference/styles/pdf.css @@ -1,18 +1,56 @@ +/* Print guardrails for the browser. The PDF export layers + * pdf/styles.scss on top of these rules, so the two must agree on how a + * block fragments; a block forbidden to split here and allowed to split + * there produces a different page break in each output. + */ + @media print { h1, h2, - h3 { + h3, + h4, + h5, + h6 { break-after: avoid; page-break-after: avoid; } + /* Splitting a long block beats pushing it whole to the next page and + * leaving the current one half empty. + */ table, pre, blockquote, ul, ol { - break-inside: avoid; - page-break-inside: avoid; + break-inside: auto; + page-break-inside: auto; + } + + /* ...but never so that a single item is stranded on either side of the + * break. Scoped to top-level lists: chaining the guard through nested + * lists over-constrains the break and pushes whole blocks to the next + * page. + */ + :not(li) > ul > li:nth-child(-n+2), + :not(li) > ol > li:nth-child(-n+2), + tbody > tr:nth-child(-n+2) { + break-after: avoid; + page-break-after: avoid; + } + + :not(li) > ul > li:nth-last-child(-n+2), + :not(li) > ol > li:nth-last-child(-n+2), + tbody > tr:nth-last-child(-n+2) { + break-before: avoid; + page-break-before: avoid; + } + + p, + li, + pre { + orphans: 2; + widows: 2; } img { diff --git a/templates/manual/pdf/styles.scss b/templates/manual/pdf/styles.scss index ec78d1c..ba63794 100644 --- a/templates/manual/pdf/styles.scss +++ b/templates/manual/pdf/styles.scss @@ -285,3 +285,56 @@ article#doc-toc li>a { display: inline-block; line-height: 0.5; } + +// Chapters (articles) still start on a new page; subsections flow. +article:not(#doc-cover):not(#doc-toc) section + section { + break-before: auto; + page-break-before: auto; + min-height: auto; +} + +article:not(#doc-cover):not(#doc-toc) :is(h1, h2, h3, h4, h5, h6) { + break-after: avoid; + page-break-after: avoid; +} + +// Only break-inside is relaxed. The inherited break-before: avoid keeps a +// block attached to the sentence or label that introduces it. +article:not(#doc-cover):not(#doc-toc) :is(table, pre, blockquote, ul, ol, div.highlight) { + break-inside: auto; + page-break-inside: auto; +} + +// A block may split, but never so that one item is stranded on either side. +// Scoped to top-level lists: chaining the guard through nested lists +// over-constrains the break and pushes whole blocks to the next page. +article:not(#doc-cover):not(#doc-toc) :not(li) > :is(ul, ol) > li:nth-child(-n+2) { + break-after: avoid; + page-break-after: avoid; +} + +article:not(#doc-cover):not(#doc-toc) :not(li) > :is(ul, ol) > li:nth-last-child(-n+2) { + break-before: avoid; + page-break-before: avoid; +} + +article:not(#doc-cover):not(#doc-toc) tbody > tr:nth-child(-n+2) { + break-after: avoid; + page-break-after: avoid; +} + +article:not(#doc-cover):not(#doc-toc) tbody > tr:nth-last-child(-n+2) { + break-before: avoid; + page-break-before: avoid; +} + +// Bordered containers stay whole; splitting them leaves an open box. +article:not(#doc-cover):not(#doc-toc) :is(div.admonition, div.tabbed-content--wrap) { + break-inside: avoid; + page-break-inside: avoid; +} + +article:not(#doc-cover):not(#doc-toc) :is(p, li, pre) { + orphans: 2; + widows: 2; +} diff --git a/templates/manual/styles/pdf.css b/templates/manual/styles/pdf.css index 66d4236..ac9b07a 100644 --- a/templates/manual/styles/pdf.css +++ b/templates/manual/styles/pdf.css @@ -1,18 +1,56 @@ +/* Print guardrails for the browser. The PDF export layers + * pdf/styles.scss on top of these rules, so the two must agree on how a + * block fragments; a block forbidden to split here and allowed to split + * there produces a different page break in each output. + */ + @media print { h1, h2, - h3 { + h3, + h4, + h5, + h6 { break-after: avoid; page-break-after: avoid; } + /* Splitting a long block beats pushing it whole to the next page and + * leaving the current one half empty. + */ table, pre, blockquote, ul, ol { - break-inside: avoid; - page-break-inside: avoid; + break-inside: auto; + page-break-inside: auto; + } + + /* ...but never so that a single item is stranded on either side of the + * break. Scoped to top-level lists: chaining the guard through nested + * lists over-constrains the break and pushes whole blocks to the next + * page. + */ + :not(li) > ul > li:nth-child(-n+2), + :not(li) > ol > li:nth-child(-n+2), + tbody > tr:nth-child(-n+2) { + break-after: avoid; + page-break-after: avoid; + } + + :not(li) > ul > li:nth-last-child(-n+2), + :not(li) > ol > li:nth-last-child(-n+2), + tbody > tr:nth-last-child(-n+2) { + break-before: avoid; + page-break-before: avoid; + } + + p, + li, + pre { + orphans: 2; + widows: 2; } img { diff --git a/tests/pdf-test.sh b/tests/pdf-test.sh index fda6013..ae7b6c5 100755 --- a/tests/pdf-test.sh +++ b/tests/pdf-test.sh @@ -5,7 +5,7 @@ # ./tests/pdf-test.sh # # Requirements: python3, mkdocs (with mkdocs-material and mkdocs-with-pdf), -# and pdftotext (poppler-utils) to read the rendered covers back. +# and pdftotext/pdfinfo (poppler-utils) to read the rendered pages back. set -euo pipefail REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" @@ -24,6 +24,8 @@ die() { command -v mkdocs >/dev/null || die "mkdocs is required to run this test" command -v pdftotext >/dev/null \ || die "pdftotext (poppler-utils) is required to run this test" +command -v pdfinfo >/dev/null \ + || die "pdfinfo (poppler-utils) is required to run this test" new_project() { # new_project @@ -71,6 +73,31 @@ pdf_text() { pdftotext "$1" - | tr -d '[:space:]' } +# Read one page of a PDF back the same way, and find the page a marker +# landed on. An assertion about what shares a page with what cannot be +# made against the whole document at once. +page_text() { + # page_text + pdftotext -f "$2" -l "$2" "$1" - | tr -d '[:space:]' +} + +page_count() { + pdfinfo "$1" | sed -n 's/^Pages:[[:space:]]*//p' +} + +page_of() { + # page_of -- first page whose text contains needle. + local pdf="$1" needle page total + needle="$(printf '%s' "$2" | tr -d '[:space:]')" + total="$(page_count "$pdf")" + for ((page = 1; page <= total; page++)); do + case "$(page_text "$pdf" "$page")" in + *"$needle"*) printf '%s\n' "$page"; return 0 ;; + esac + done + return 1 +} + contains() { # contains local needle @@ -460,4 +487,115 @@ grep -q "docs/theme/pdf/" "$WORK/nopdf.log" \ || die "the message does not name docs/theme/pdf/" ok "an incomplete install exits with an actionable message" +# --- within-chapter pagination ---------------------------------------- +# A nav section whose children are separate pages is rendered as one +# article of stacked `section` elements. Forcing a page break before +# each of those sections dropped everything after the section's first +# paragraph out of the render, so `mkdocs build` exiting 0 is not +# evidence the chapter is in the PDF -- the markers have to be read back. +# The blocks below are each taller than the space left on the page they +# start on, which is the case that used to lose them. + +paging="$WORK/paging" +new_project "$paging" +cat > "$paging/mkdocs.yml" <<'EOF' +site_name: Fixture Paging +nav: + - Home: index.md + - Guide: + - Overview: guide/overview.md + - Reference: guide/reference.md +extra: + pdf: + output_basename: fixture-paging +markdown_extensions: + - admonition + - tables +EOF + +mkdir -p "$paging/docs/guide" +cat > "$paging/docs/guide/overview.md" <<'EOF' +# Overview + +A subsection short enough to leave most of its page empty when it is +padded out to a full one. +EOF + +{ + echo '# Reference' + echo + echo 'The first paragraph of the chapter. Everything below it is what a' + echo 'forced break used to drop.' + echo + echo '## Endpoints' + echo + echo '| Name | Description |' + echo '| --- | --- |' + for i in $(seq 1 21); do + echo "| field-$i | Description of field number $i. |" + done + echo '| field-omega | The last row of the table. |' + echo + echo '## Parameters' + echo + echo '**Key inputs**' + echo + echo '- first input, which must not be split off from its label.' + echo '- second input, which must not be split off either.' + for i in $(seq 3 13); do + echo "- input number $i, padding the list past the end of the page." + done + echo '- option-omega, the last item of the list.' + echo + echo '!!! note' + echo ' admonition-omega. A note tall enough to be worth keeping whole:' + echo ' line two, line three, line four, and line five of the body.' +} > "$paging/docs/guide/reference.md" + +build_pdf "$paging" en +paging_pdf="$paging/site/pdf/fixture-paging.en.pdf" +[ -f "$paging_pdf" ] || die "the nav-grouped fixture produced no PDF" + +pdf_text "$paging_pdf" > "$WORK/paging.txt" +contains "$WORK/paging.txt" "field-omega" "a subsection's table" +contains "$WORK/paging.txt" "option-omega" "a subsection's list" +contains "$WORK/paging.txt" "admonition-omega" "a subsection's admonition" +ok "a chapter's tables, lists, and admonitions survive into the PDF" + +# A top-level chapter still starts on a page of its own. +home_page="$(page_of "$paging_pdf" "Fixture body text")" \ + || die "the first chapter is not in the PDF" +guide_page="$(page_of "$paging_pdf" "A subsection short enough")" \ + || die "the Guide chapter is not in the PDF" +[ "$guide_page" -gt "$home_page" ] \ + || die "the Guide chapter did not start after the first chapter" +ok "a top-level chapter still starts on a new page" + +# A subsection flows on instead of being padded out to a full page: the +# two children of Guide have to share a page somewhere, or the chapter +# has been padded again. +reference_page="$(page_of "$paging_pdf" "The first paragraph of the chapter")" \ + || die "the Reference subsection is not in the PDF" +[ "$reference_page" -eq "$guide_page" ] \ + || die "a subsection was padded onto a page of its own" +ok "a subsection flows on rather than opening a padded page" + +# A bold-only label is a heading for the block under it, so the break +# must not land between them. This is the "short label almost alone on +# a page" case: the label keeps at least the first two items with it. +label_page="$(page_of "$paging_pdf" "Key inputs")" \ + || die "the bold label is not in the PDF" +page_text "$paging_pdf" "$label_page" > "$WORK/paging-label.txt" +contains "$WORK/paging-label.txt" "first input" "the label's page" +contains "$WORK/paging-label.txt" "second input" "the label's page" +ok "a bold-only label keeps the first items of its list with it" + +# A split table leaves no lone row behind either. +first_row_page="$(page_of "$paging_pdf" "Description of field number 1.")" \ + || die "the table's first row is not in the PDF" +page_text "$paging_pdf" "$first_row_page" > "$WORK/paging-table.txt" +contains "$WORK/paging-table.txt" "Description of field number 2." \ + "the page the table starts on" +ok "a split table keeps at least two rows on the page it starts on" + echo "All PDF script checks passed."