Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down
54 changes: 54 additions & 0 deletions templates/api-reference/pdf/styles.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
25 changes: 25 additions & 0 deletions templates/api-reference/styles/pdf.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
54 changes: 54 additions & 0 deletions templates/manual/pdf/styles.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
25 changes: 25 additions & 0 deletions templates/manual/styles/pdf.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
113 changes: 113 additions & 0 deletions tests/pdf-test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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'
}
Expand Down Expand Up @@ -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."