Skip to content

Word wrapping#34

Merged
billdenney merged 7 commits into
mainfrom
claude/intelligent-chatelet-df043d
May 10, 2026
Merged

Word wrapping#34
billdenney merged 7 commits into
mainfrom
claude/intelligent-chatelet-df043d

Conversation

@billdenney
Copy link
Copy Markdown
Member

No description provided.

billdenney and others added 7 commits May 10, 2026 06:49
Wraps long cell text and column-header labels so a column can be narrower,
addressing the silent off-page overflow reported in the issue.  All wrapping
logic lives in a new R/wrap.R module with one disable lever
(wrap_cols = FALSE).

Behavioral changes:
- tfl_table(wrap_cols) defaults to "auto" - a non-group column is wrap-eligible
  when its data or header contains a configured break character; numeric and
  single-token columns are skipped.  TRUE / FALSE / a character vector
  continue to work; tfl_colspec(wrap = NA) is the new "inherit" sentinel.
- New tfl_table(wrap_breaks = wrap_breaks(...)) configures break characters.
  The default drops whitespace at the break; opt in to keep_before chars
  (e.g. "-") that stay on the left of the break.
- Column headers auto-wrap on the same eligibility as cells.
- A row whose wrapped height exceeds one page now errors via the same
  overflow_action = "error" / "warn" switch added for issue 30.

Algorithm: water-from-top.  Each iteration finds the widest wrap-eligible
columns above their floor and shrinks them together until they meet the
next-widest competitor or hit a floor.  Floor is the larger of
min_col_width and the rendered width of the column's longest unbreakable
token, so the algorithm cannot promise a width the renderer can't honour.

Tests: 47 unit tests in tests/testthat/test-wrap.R plus 8 end-to-end tests
in tests/testthat/test-tfl_table.R.  Full suite passes.

Demos: examples/wrap_demos.R generates one PDF per behavior plus a README
to a persistent temp directory for hands-on review.  examples/ is added to
.Rbuildignore so it does not ship with the built package.

Documentation: new "Word wrapping" section in v03-tfl_table_styling.Rmd
spelling out the distinction between text-wrap (wrap_cols) and
page-column-split (allow_col_split).  Design rationale in design/DESIGN.md
and decision entry D-41 in design/DECISIONS.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Verified via tfl_table_to_pagelist() that 04 (wrap_cols = c("alpha")) and
05 (per-colspec wrap = FALSE on bravo) both produce single-page output
with the demo's wide_df: alpha can shrink enough to absorb the entire
page-width deficit before hitting its longest-token floor, so no
page-column-split is triggered.

The previous blurbs claimed each demo "forces a page split", which would
have appeared in the rendered PDFs as the rotated "Columns continue ..."
side annotations.  The actual demonstration is more subtle: only one
column wraps, so it wraps more aggressively than in 02 where both
string columns shared the burden.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The column-width pipeline measured every string in a column with the cell
gpar (regular weight), but headers actually render with the header_row
gpar (bold by default).  A bold header is a few percent wider than the
same string in regular weight, so a column auto-sized against the
regular-weight measurement undersized the bold header by enough to bleed
into the neighboring column at draw time - wiping out the next column's
header (visible in demo 08 where the second column header was
invisible).

Fix - three places:

1. compute_col_widths() Pass 1 (auto-sizing): split each column's strings
   into header lines (measured with header_row gpar) and data values
   (measured with cell gpar) via the new .split_col_strings() helper.
   Take the larger of the two as the natural width.

2. .compute_wrapped_widths() floor calculation: the longest-unbreakable-
   token measurement was likewise running everything through the cell
   gpar.  Now header tokens are measured with header_row gpar.  This
   prevents the wrap algorithm from promising a width the renderer
   cannot honour.

3. .draw_cell_text() clip-viewport widening: stringWidth() picks up the
   active viewport's gpar, not the explicit `gp` arg, so it was
   measuring "Concomitant" in regular weight and then widening the clip
   for the bold rendering - bleeding past the column edge.  Replaced
   with grobWidth(textGrob(text, gp = gp)) which honours the actual
   rendering gpar.  Also capped the clip widening to a 0.05 in
   font-metric tolerance: a column genuinely too narrow for its content
   now clips at the column edge instead of overlapping the neighbor.

Tests: two new regression tests in test-tfl_table.R verify that an
auto-sized column accommodates a bold header, and that the wrap floor
reflects the bold header token width.

Demo 08 now uses a 1.3-inch column (wider than longest bold token,
narrower than the full header) so the header wraps cleanly to three
lines without overlapping the next column.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When two consecutive rows both contain wrapped (multi-line) cells, the
bottom of one row's wrapped text can sit visually flush against the top
of the next row's wrapped text - making it ambiguous where one row ends
and the next begins (visible in demo 07 with the path-like column).

New tfl_table() argument `wrap_extra_padding`, default unit(0.25, "lines"),
adds that much vertical space at the bottom of any cell whose displayed
text spans more than one line.  Single-line cells are unaffected so
compact tables are not inflated.  Set to unit(0, "lines") to disable.

The trigger is "the displayed text contains a newline after wrapping",
so cells that became multi-line via the wrap algorithm AND cells with
explicit \n in their content both receive the extra.  Headers receive
the same treatment so a wrapped header is also visually separated from
the first data row.

Threaded through table_pagelist.R: measure_row_heights_tbl() and
.measure_header_row_height() both gain a wrap_extra_pad_in numeric arg.
The drawDetails fallback path in table_draw.R applies the same logic
when it builds its own per-page row-height matrix.

Tests in test-tfl_table.R: wrap_extra_padding > 0 makes a multi-line
cell taller than the same cell with the option = 0; single-line cells
have identical height regardless.  The arg is validated as a
length-1 unit object.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Default water-fill (wrap_balance = "width") balances column widths but is
content-blind: when one wrap-eligible column has dense content and another
has sparse content, the dense column wraps to many more lines than its
neighbour even though they end up at the same width.  The new opt-in
height-balance pass shifts width away from columns that have slack
(short content with room to give up width down to their floor) and into
columns whose cells are the row-height bottleneck.  It accepts a move
only when the resulting *total table height* (sum of per-row heights,
plus header) is smaller, so opting in cannot produce a worse table.

Algorithm: bounded greedy local search starting from the water-fill
widths.  Each iteration finds the row with the maximum cell height,
identifies the wrap-eligible column whose cell drives that row's max
(bottleneck) and the wrap-eligible column with the shortest cell in
that row (slack), and tries deltas of {0.5, 0.25, 0.1, 0.05} inches
from slack to bottleneck.  Capped at 20 iterations and 1 second
wall-time.  Cell heights are cached per (column, width) and per
unique cell string so re-measurements within the loop are free in
the common case.  Total widths are preserved exactly; column floors
(longest unbreakable token + padding) are honoured; columns are
never grown past their natural content width.

Any error or invariant violation falls back silently to the input
widths via tryCatch + a defensive sanity check on the result, so the
opt-in is never worse than the default.

Concrete win on the demo asym_df (24-token vs 5-token columns, 7
rows): width-balance produces a 2-page output with row height 1.09 in;
height-balance produces a 1-page output with row height 0.88 in - a
~19% reduction in total table height in ~0.3 s.

Tests: end-to-end test in test-tfl_table.R that height-balance
strictly reduces total table height vs width-balance on asymmetric
content; a no-op test confirming a single-eligible-column table has
identical widths under both modes; a validator test for invalid
wrap_balance values.

Demos: two new PDFs (14_balance_width.pdf and 14_balance_height.pdf)
side-by-side on the same input to show the difference.

Vignette: new "Optimising for height" subsection in
v03-tfl_table_styling.Rmd describing when to opt in and the safety
properties (no-worse-than-default, time-budgeted, automatic
fallback).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The 0.25 line default ended up too small to be visually perceptible against
the natural inter-line gap from `lineheight = 1.05` (the lineheight gap is
already ~0.05 lines, so 0.25 lines extra was only a ~5x increase, not
visually obvious in a typical 12 pt PDF).  Bumping to 0.5 lines makes the
inter-row gap clearly distinguishable from the inter-line gap inside a cell
without making compact tables noticeably loose.

Disable with `wrap_extra_padding = unit(0, "lines")`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Reflects what the wrap module ships with as of issue 28:

- README.md: bullet list under "Paginated data-frame tables" updated to
  document `wrap_cols = "auto"` as the default, the `wrap_breaks()`
  break-character spec with `keep_before`, the `wrap_balance = "height"`
  opt-in, the row-overflow guard via `overflow_action`, and the new
  `wrap_extra_padding` default.
- vignettes/writetfl.Rmd: matching one-paragraph mention in the data-
  frame-tables overview.
- vignettes/v02-tfl_table_intro.Rmd: rewrote the Word-wrapping section
  with a value table for `wrap_cols`, sub-sections for per-column
  override, custom break characters via `wrap_breaks()`, the
  `wrap_balance = "height"` opt-in, and the row-overflow guard.  Added
  `wrap_breaks`, `wrap_balance`, and `wrap_extra_padding` to the summary
  table at the bottom of the vignette.
- vignettes/v04-troubleshooting.Rmd: added a "Row wrapped to taller
  than one page" section explaining the row-overflow guard and how
  `overflow_action = "warn"` helps diagnose it.  Updated the
  "Content too wide" solutions list to mention auto-detect, the
  `wrap_breaks(keep_before = …)` escape hatch for unbreakable tokens,
  and `wrap_balance = "height"` for uneven content density.

No code changes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@billdenney billdenney merged commit 70305c6 into main May 10, 2026
9 checks passed
@billdenney billdenney deleted the claude/intelligent-chatelet-df043d branch May 10, 2026 13:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant