From df43a4a267921346f14b601a047c95e592ead450 Mon Sep 17 00:00:00 2001 From: sehkone Date: Sat, 8 Aug 2026 13:42:03 +0900 Subject: [PATCH] Give a PDF table a grid and a place to wrap Material draws its table grid with a custom property that only a browser's colour scheme defines. The PDF render never sets one, so the declaration was invalid at computed-value time and the border fell away entirely: a table arrived as unruled columns of text, with the setting name in one column vertically centred and its description top-aligned several lines above, and nothing to say which cell belonged to which row. The grid, the header shading, and the cell padding are drawn here because nothing else will draw them. A dotted setting name has no break opportunity of its own. Left unbreakable it becomes its column's minimum width, and the auto layout pays for it out of the other columns: a three-column table squeezed its description into a ribbon four words wide. `overflow-wrap: anywhere` on the inline code lets the name wrap and gives the column back. That property stays off the cell. On a cell it also collapses the cell's own minimum width, so the column shrinks to one character and the renderer chops whatever is in it: a `Default` heading came back as `Defau` / `lt`, `604800` as `60480` / `0`, and a `Key` column as seven stacked letters. It is also pathologically slow -- a cell holding a page of prose took 9 minutes to lay out where the same document takes 5 seconds, because every line break is re-tried at every character. Cells get `overflow-wrap: break-word`, which breaks a word only once it actually overflows and leaves the minimum width alone. A row that splits leaves its cells misaligned either side of the break, so a row moves whole. The table around it still splits one row at a time, and the two-row guards already on `tbody > tr` still hold. A row too tall for a page of its own splits anyway rather than being dropped. `styles/pdf.css` carries the same fragmentation and wrapping rules, so browser print and PDF export still break a line and a page at the same places. `pdf-test.sh` reads the table fixture back with `pdftotext` twice over. Its line breaks are kept in one reading, because the whitespace-stripped reading every other assertion uses glues a chopped word back together and hides exactly this class of bug; the other reading is `-raw`, which is the only order that reunites a word split inside a cell -- the default order walks the whole row between the two halves. The chopped heading, the unwrapped setting name, a row split across a page, and the content after a row taller than a page are each asserted. The checks fail against the stylesheet this replaces. Closes #13 Co-authored-by: jinjoolee07 --- CHANGELOG.md | 33 +++++++ templates/api-reference/pdf/styles.scss | 54 +++++++++++ templates/api-reference/styles/pdf.css | 25 ++++++ templates/manual/pdf/styles.scss | 54 +++++++++++ templates/manual/styles/pdf.css | 25 ++++++ tests/pdf-test.sh | 113 ++++++++++++++++++++++++ 6 files changed, 304 insertions(+) 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."