diff --git a/CHANGELOG.md b/CHANGELOG.md index 920d183..063ce0c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,6 +42,20 @@ sections that affect its template. Version numbers follow the - `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. +- A PDF table now carries its own grid, header shading, and cell + padding. Material draws the grid with a custom property that only a + browser's colour scheme defines, so in the PDF it resolved to nothing + and a table arrived as unruled columns of text with no way to tell which + cell belonged to which row. A single-column table keeps its content + width instead of being stretched across the page. +- A long inline-code value in a cell -- a dotted setting name with no + break opportunity of its own -- now wraps instead of setting its + column's minimum width and squeezing every other column into a ribbon. + Ordinary text in a cell still only breaks between words. +- A table row now moves whole rather than splitting with its cells + misaligned either side of the break. The table around it still splits, + one row at a time, and a row too tall for a page of its own splits + anyway rather than losing its content. ### api-reference @@ -76,6 +90,20 @@ sections that affect its template. Version numbers follow the - `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. +- A PDF table now carries its own grid, header shading, and cell + padding. Material draws the grid with a custom property that only a + browser's colour scheme defines, so in the PDF it resolved to nothing + and a table arrived as unruled columns of text with no way to tell which + cell belonged to which row. A single-column table keeps its content + width instead of being stretched across the page. +- A long inline-code value in a cell -- a dotted setting name with no + break opportunity of its own -- now wraps instead of setting its + column's minimum width and squeezing every other column into a ribbon. + Ordinary text in a cell still only breaks between words. +- A table row now moves whole rather than splitting with its cells + misaligned either side of the break. The table around it still splits, + one row at a time, and a row too tall for a page of its own splits + anyway rather than losing its content. ### shared @@ -123,6 +151,11 @@ sections that affect its template. Version numbers follow the 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. +- `pdf-test.sh` also reads a table fixture back with its line breaks + left in, so where a cell wrapped is asserted rather than assumed. Every + other reader there strips whitespace, which glues a word the renderer + split back together and hides a cell whose column collapsed to a single + character. ## [0.1.0] - 2026-03-30 diff --git a/templates/api-reference/pdf/styles.scss b/templates/api-reference/pdf/styles.scss index ba63794..5c721db 100644 --- a/templates/api-reference/pdf/styles.scss +++ b/templates/api-reference/pdf/styles.scss @@ -338,3 +338,57 @@ article:not(#doc-cover):not(#doc-toc) :is(p, li, pre) { orphans: 2; widows: 2; } + +// Material draws the table grid with a custom property that only a +// browser's colour scheme defines. The PDF render never sets one, so the +// border collapses to nothing and the table arrives as unruled columns of +// text: the grid has to be drawn here. +article:not(#doc-cover):not(#doc-toc) table { + width: 100%; + margin: 12pt 0; + border: 1px solid #D8DEE4; + border-collapse: collapse; +} + +// A one-column table stretched to the text width is one narrow column of +// text beside a page of empty cell, so it keeps its content width. +article:not(#doc-cover):not(#doc-toc) table:not(:has(tr > :nth-child(2))) { + width: auto; +} + +article:not(#doc-cover):not(#doc-toc) :is(th, td) { + border: 0; + border-right: 1px solid #D8DEE4; + border-bottom: 1px solid #D8DEE4; + padding: 6pt 7pt; + vertical-align: top; + overflow-wrap: break-word; +} + +article:not(#doc-cover):not(#doc-toc) :is(th, td):last-child { + border-right: 0; +} + +article:not(#doc-cover):not(#doc-toc) th { + background-color: #F6F8FA; +} + +// Only inline code may break mid-token. A long dotted setting name has no +// break opportunity of its own, and left unbreakable it sets the column's +// minimum width and squeezes every other column into a ribbon. `anywhere` +// stays off the cell itself: there it also collapses the cell's minimum +// width, which chops ordinary words -- a `Default` heading came back as +// `Defau` / `lt` -- and it costs minutes of layout time on a cell holding +// a page of prose. +article:not(#doc-cover):not(#doc-toc) :is(th, td) code { + white-space: normal; + overflow-wrap: anywhere; +} + +// A row that splits leaves its cells misaligned across the break, so it +// moves whole. The table around it still splits, one row at a time; a row +// too tall for a page of its own splits anyway rather than being dropped. +article:not(#doc-cover):not(#doc-toc) tr { + break-inside: avoid; + page-break-inside: avoid; +} diff --git a/templates/api-reference/styles/pdf.css b/templates/api-reference/styles/pdf.css index ac9b07a..6c9b8db 100644 --- a/templates/api-reference/styles/pdf.css +++ b/templates/api-reference/styles/pdf.css @@ -53,6 +53,31 @@ widows: 2; } + /* A table row moves whole: split, its cells land misaligned either side + * of the break. The table around it still splits, one row at a time. + */ + tr { + break-inside: avoid; + page-break-inside: avoid; + } + + /* A long word in a cell wraps instead of widening the column past the + * page. Only inline code may break mid-token -- a dotted setting name + * has no break opportunity of its own, and left unbreakable it sets the + * column's minimum width and squeezes the rest into a ribbon. `anywhere` + * stays off the cell itself, where it would also chop ordinary words. + */ + th, + td { + overflow-wrap: break-word; + } + + th code, + td code { + white-space: normal; + overflow-wrap: anywhere; + } + img { max-width: 100%; height: auto; diff --git a/templates/manual/pdf/styles.scss b/templates/manual/pdf/styles.scss index ba63794..5c721db 100644 --- a/templates/manual/pdf/styles.scss +++ b/templates/manual/pdf/styles.scss @@ -338,3 +338,57 @@ article:not(#doc-cover):not(#doc-toc) :is(p, li, pre) { orphans: 2; widows: 2; } + +// Material draws the table grid with a custom property that only a +// browser's colour scheme defines. The PDF render never sets one, so the +// border collapses to nothing and the table arrives as unruled columns of +// text: the grid has to be drawn here. +article:not(#doc-cover):not(#doc-toc) table { + width: 100%; + margin: 12pt 0; + border: 1px solid #D8DEE4; + border-collapse: collapse; +} + +// A one-column table stretched to the text width is one narrow column of +// text beside a page of empty cell, so it keeps its content width. +article:not(#doc-cover):not(#doc-toc) table:not(:has(tr > :nth-child(2))) { + width: auto; +} + +article:not(#doc-cover):not(#doc-toc) :is(th, td) { + border: 0; + border-right: 1px solid #D8DEE4; + border-bottom: 1px solid #D8DEE4; + padding: 6pt 7pt; + vertical-align: top; + overflow-wrap: break-word; +} + +article:not(#doc-cover):not(#doc-toc) :is(th, td):last-child { + border-right: 0; +} + +article:not(#doc-cover):not(#doc-toc) th { + background-color: #F6F8FA; +} + +// Only inline code may break mid-token. A long dotted setting name has no +// break opportunity of its own, and left unbreakable it sets the column's +// minimum width and squeezes every other column into a ribbon. `anywhere` +// stays off the cell itself: there it also collapses the cell's minimum +// width, which chops ordinary words -- a `Default` heading came back as +// `Defau` / `lt` -- and it costs minutes of layout time on a cell holding +// a page of prose. +article:not(#doc-cover):not(#doc-toc) :is(th, td) code { + white-space: normal; + overflow-wrap: anywhere; +} + +// A row that splits leaves its cells misaligned across the break, so it +// moves whole. The table around it still splits, one row at a time; a row +// too tall for a page of its own splits anyway rather than being dropped. +article:not(#doc-cover):not(#doc-toc) tr { + break-inside: avoid; + page-break-inside: avoid; +} diff --git a/templates/manual/styles/pdf.css b/templates/manual/styles/pdf.css index ac9b07a..6c9b8db 100644 --- a/templates/manual/styles/pdf.css +++ b/templates/manual/styles/pdf.css @@ -53,6 +53,31 @@ widows: 2; } + /* A table row moves whole: split, its cells land misaligned either side + * of the break. The table around it still splits, one row at a time. + */ + tr { + break-inside: avoid; + page-break-inside: avoid; + } + + /* A long word in a cell wraps instead of widening the column past the + * page. Only inline code may break mid-token -- a dotted setting name + * has no break opportunity of its own, and left unbreakable it sets the + * column's minimum width and squeezes the rest into a ribbon. `anywhere` + * stays off the cell itself, where it would also chop ordinary words. + */ + th, + td { + overflow-wrap: break-word; + } + + th code, + td code { + white-space: normal; + overflow-wrap: anywhere; + } + img { max-width: 100%; height: auto; diff --git a/tests/pdf-test.sh b/tests/pdf-test.sh index ae7b6c5..7db4e5a 100755 --- a/tests/pdf-test.sh +++ b/tests/pdf-test.sh @@ -81,6 +81,21 @@ page_text() { pdftotext -f "$2" -l "$2" "$1" - | tr -d '[:space:]' } +# Read a PDF back with its line breaks left in, so an assertion can be +# made about where the renderer broke a word. Every other reader here +# strips whitespace, which puts a word the renderer split back together. +pdf_lines() { + pdftotext "$1" - +} + +# Read a PDF back in content order rather than reading order. A word the +# renderer split inside a table cell is reunited by this and by nothing +# else: the default order walks the whole row between the two halves, so +# stripping whitespace leaves them columns apart. +pdf_raw_text() { + pdftotext -raw "$1" - | tr -d '[:space:]' +} + page_count() { pdfinfo "$1" | sed -n 's/^Pages:[[:space:]]*//p' } @@ -598,4 +613,102 @@ 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" +# --- table readability ------------------------------------------------- +# Material draws its table grid with a custom property that only a +# browser's colour scheme defines, so in the PDF the border resolves to +# nothing and the rules that redraw it live in `pdf/styles.scss`. Those +# same rules decide where a cell wraps, and the wrap points have to be +# read back rather than assumed: a cell told to break `anywhere` collapses +# its column to a single character, which chops a `Default` heading into +# `Defau` / `lt` -- and costs minutes of layout time on a cell holding a +# page of prose. Every other reader here strips whitespace, which hides +# exactly that by gluing a split word back together. + +tables="$WORK/tables" +new_project "$tables" +cat > "$tables/mkdocs.yml" <<'EOF' +site_name: Fixture Tables +extra: + pdf: + output_basename: fixture-tables +markdown_extensions: + - tables +EOF + +long_setting='ingest.stream.retention_policy_duration_seconds' +{ + echo '# Tables' + echo + echo '| Setting | Default | Description |' + echo '| --- | --- | --- |' + echo "| \`$long_setting\` | \`604800\` | How long a raw stream record is kept. |" + echo + # Rows tall enough that a page break has to fall between two of them. + echo '| Key | Value |' + echo '| --- | --- |' + for i in $(seq 1 12); do + printf '| rowkey-%s | ' "$i" + for s in $(seq 1 14); do + printf 'Sentence %s of row %s, padding the row past a few lines. ' "$s" "$i" + done + printf 'rowend-%s |\n' "$i" + done + echo + # A row too tall for a page of its own has to split rather than be + # dropped, and what follows it has to survive. + echo '| Key | Value |' + echo '| --- | --- |' + printf '| overlong | ' + for s in $(seq 1 320); do + printf 'Sentence %s of a row taller than one whole page. ' "$s" + done + printf 'overlong-end |\n' + echo '| after | after-overlong |' +} > "$tables/docs/index.md" + +build_pdf "$tables" en +tables_pdf="$tables/site/pdf/fixture-tables.en.pdf" +[ -f "$tables_pdf" ] || die "the table fixture produced no PDF" + +pdf_text "$tables_pdf" > "$WORK/tables.txt" +pdf_lines "$tables_pdf" > "$WORK/tables-lines.txt" +pdf_raw_text "$tables_pdf" > "$WORK/tables-raw.txt" + +# A long setting name has no break opportunity of its own. Left +# unbreakable it sets its column's minimum width and squeezes the rest of +# the row into a ribbon, so it has to wrap -- and still arrive whole. +contains "$WORK/tables-raw.txt" "$long_setting" "a long setting name" +if grep -qF "$long_setting" "$WORK/tables-lines.txt"; then + die "a long setting name did not wrap inside its cell" +fi +ok "a long inline-code value wraps inside its cell and survives whole" + +# ...but nothing else in a cell may be broken mid-word. +grep -qF "Default" "$WORK/tables-lines.txt" \ + || die "the Default heading was broken mid-word" +grep -qF "604800" "$WORK/tables-lines.txt" \ + || die "the 604800 cell value was broken mid-number" +ok "a heading and a short code value in a cell are not broken mid-word" + +# A row that splits leaves its cells misaligned either side of the break, +# so each row's first and last marker have to share a page. The rows are +# tall enough that a page break falls between two of them. +tables_pages="$(page_count "$tables_pdf")" +[ "$tables_pages" -ge 4 ] \ + || die "the table fixture is too short to break a page between rows" +for i in $(seq 1 12); do + key_page="$(page_of "$tables_pdf" "rowkey-$i")" \ + || die "row $i is not in the PDF" + end_page="$(page_of "$tables_pdf" "rowend-$i")" \ + || die "row $i's last cell is not in the PDF" + [ "$key_page" = "$end_page" ] \ + || die "row $i was split across a page boundary" +done +ok "a table row is never split across a page boundary" + +# A row taller than a page is the one case where it has to split anyway. +contains "$WORK/tables.txt" "overlong-end" "a row taller than a page" +contains "$WORK/tables.txt" "after-overlong" "the row after an overlong one" +ok "a row taller than a page splits rather than losing its content" + echo "All PDF script checks passed."