Column Width Management in ksTFL
ksTFL Development Team
2026-10-03
Source:vignettes/Column_Width_Management.Rmd
Column_Width_Management.Rmd
Overview
This guide covers column width management in ksTFL: the LOCKED/UNLOCKED/VISIBLE model, automatic recalculation, invisible-column rules, and the tradeoffs involved in making tables fit cleanly on the page.
Core Concepts
Column States
ksTFL partitions table columns into four categories that determine how widths are calculated:
-
VISIBLE Columns (
isVisible != FALSE)- Displayed in the report output
- Participate in width calculations
- This is the default state for all columns
-
LOCKED Columns (width explicitly set via
colWidth)- Maintain their exact specified width (unit: %, cm, or in)
- Fixed during automatic recalculation
- Can use relative (%) or absolute (cm, in) units
-
UNLOCKED Columns (no
colWidthset)- Automatically recalculated to fill available space
- Normalized proportionally based on initial auto-detected weights
- Only visible unlocked columns participate in recalculation
-
INVISIBLE Columns (
isVisible = FALSE)- Hidden from output completely
- Automatically assigned width “0.0cm”
- Excluded from all width calculations
- Data still accessible for conditional logic (e.g.,
compute_cols())
Initial Width Distribution
When you create a table, ksTFL automatically:
- Analyzes each column’s data type and content
- Estimates visual width from the widest rendered cell (numeric values
measured after the auto-guessed
%d/%.dfformat), the longest line of the column name, and of the label — whichever is longest. Frames over 10,000 rows are deterministically sampled for the measurement. - Distributes widths proportionally so they sum to 100%
- Clamps each column to the 5%–60% band, renormalizes to 100%, and rounds to one decimal with drift correction (the largest column absorbs the rounding remainder)
library(ksTFL)
# Create sample data
data <- data.frame(
id = 1:100,
patient_id = sprintf("PAT-%04d", 1:100),
age = round(rnorm(100, 45, 10)),
weight_kg = round(rnorm(100, 70, 15), 1),
treatment_group = sample(c("Placebo", "Treatment A", "Treatment B"), 100, replace = TRUE)
)
# Initial spec with auto-detected widths
spec <- create_table(data)
print(spec) # Shows auto-calculated widths for all columnsThe autoColWidth Option
The autoColWidth option (default: TRUE)
controls whether widths are automatically recalculated when you lock a
column or change a column’s visibility:
# Check current setting
print(tfl_get_option("autoColWidth")) # TRUE by default
# Disable for manual width management
tfl_set_options(autoColWidth = FALSE)
# Re-enable (restore default behavior)
tfl_set_options(autoColWidth = TRUE)Width Locking Workflow
Basic Locking
When you set colWidth for a column, it becomes
LOCKED:
# Lock the 'id' column at 15%
spec <- create_table(data) |>
define_cols(id, colWidth = "15%")
# Result:
# - id: 15% (LOCKED)
# - Other visible columns: auto-recalculated to fill remaining 85%Multiple Locked Columns
You can lock multiple columns; unlocked columns fill the remaining space:
spec <- create_table(data) |>
define_cols(id, colWidth = "10%") |> # Lock at 10%
define_cols(patient_id, colWidth = "20%") |> # Lock at 20%
define_cols(treatment_group, colWidth = "25%") # Lock at 25%
# Result:
# - id: 10% (LOCKED)
# - patient_id: 20% (LOCKED)
# - treatment_group: 25% (LOCKED)
# - age, weight_kg: share remaining 45% proportionallyFlag columns (isPaging, isGrouping,
isColBreak) recalculate like any other unlocked column —
lock their widths if size matters. In column-break segments, ID columns
keep their width while the remaining segment columns are rescaled to
fill the segment.
Mixing Relative and Absolute Units
You can mix percentage widths with absolute units:
spec <- create_table(data) |>
define_cols(id, colWidth = "2.5cm") |> # Fixed width, kept exactly
define_cols(patient_id, colWidth = "20%") # 20% of the space left over
# Result:
# - id: 2.5cm (LOCKED, absolute)
# - patient_id: 20% (LOCKED, relative)
# - Other columns: share the remaining percentage pool proportionallyImportant: Fixed-unit columns (cm, in) keep their exact width at render. They do not shrink the percentage pool that the R-layer recalculation works with, but the renderer resolves every percentage column against the space left after the fixed columns are placed — a percentage width is a share of the remaining area, not of the full page.
Width Recalculation Algorithm
When autoColWidth = TRUE and you lock a column,
ksTFL:
- Partitions columns into LOCKED and UNLOCKED
- Calculates available space: 100% minus sum of locked percentage widths
- Normalizes unlocked widths to fill available space proportionally
- Rounds to 1 decimal place with drift correction
Example Walkthrough
With the sample data above (100 rows), create_table()
measures the content and produces this distribution:
# Initial auto-distribution (measured for this dataset):
# id: 7.5%, patient_id: 25.0%, age: 7.5%, weight_kg: 22.5%, treatment_group: 37.5%
spec <- create_table(data) |>
define_cols(id, colWidth = "10%")
# After locking id at 10%:
# - Available space: 100% - 10% = 90%
# - Unlocked weights: patient_id=25.0, age=7.5, weight_kg=22.5, treatment_group=37.5 (sum=92.5)
# - Normalized into 90%: patient_id=24.3%, age=7.3%, weight_kg=21.9%, treatment_group=36.5%
# - Total is exactly 100.0% after drift correctionNote how content-aware the initial split is: the narrow numeric
id and age columns keep 7.5%, while the long
text column treatment_group takes 37.5%.
Invisible Columns
Making Columns Invisible
Use isVisible = FALSE to hide columns from output:
spec <- create_table(data) |>
define_cols(id, isVisible = FALSE)
# Result:
# - id: hidden, width = "0.0cm" (automatic)
# - Other columns: recalculated to fill 100%Important Constraints
You cannot set colWidth for invisible
columns:
# This will error:
spec <- create_table(data) |>
define_cols(id, isVisible = FALSE, colWidth = "15%")
# Error message (verbatim):
# Cannot set `colWidth` for invisible column "id"
# x Column "id" has `isVisible = FALSE`
# i Invisible columns automatically have `colWidth = "0.0cm"`Why? Invisible columns are always “0.0cm”—setting a width would be contradictory.
Using Invisible Columns for Logic
Invisible columns exist mainly for this: driving conditional formatting.
spec <- create_table(data) |>
add_style(id = "highlight_yellow",
s_font(bold = TRUE),
s_table_style(background_color = "#FFFF99")) |>
# Define/lock widths BEFORE hiding (invisible columns reject colWidth)
define_cols(patient_id, isVisible = FALSE) |>
# Use the hidden column in compute_cols() for conditional styling
compute_cols(
startsWith(patient_id, "PAT-001"),
c_style(age, styleRef = "highlight_yellow")
)Manual Width Management
Disabling Auto-Recalculation
For complete manual control:
# Disable auto-recalculation
tfl_set_options(autoColWidth = FALSE)
# Set exact widths - no automatic adjustment
spec <- create_table(data) |>
define_cols(
c(id, patient_id, age, weight_kg, treatment_group),
colWidth = c("10%", "25%", "20%", "20%", "25%")
)
# Widths stay exactly as specified (sum = 100%)
# Re-enable for other tables
tfl_set_options(autoColWidth = TRUE)Validation and Constraints
Width Floors and the Unlocked Reserve
Two different constants apply to widths:
- Relative widths (%) you set yourself: minimum 0.5%
- Absolute widths (cm, in) you set yourself: minimum 0.2cm (~0.08in)
-
minColWidth(default 0.5%): the space the validator reserves for each column that stays UNLOCKED when you lock another one
# This will error:
spec <- create_table(data) |>
define_cols(id, colWidth = "0.1%") # Below the fixed 0.5% floor
# Error message (verbatim):
# Column width "0.1%" is below minimum allowed
# x Relative widths must be at least 0.5%
# i Proposed: 0.1%The floor for a width you type is fixed at 0.5% —
minColWidth does not lower it. What
minColWidth controls is the space check below.
Space Constraint Validation
ksTFL prevents you from locking widths that leave insufficient space for the columns that stay unlocked:
# 5 columns, minColWidth = 0.5% (default):
# the 4 unlocked columns need 4 x 0.5% = 2% of the space.
# This will error:
spec <- create_table(data) |>
define_cols(id, colWidth = "99%") # Leaves only 1% for 4 columns
# Error message (verbatim):
# Cannot set column `id` to "99%"
# x This would leave insufficient space for the remaining 4 unlocked
# columns to meet the minimum width of 0.5%.
# i After this change, 4 columns need 2% total. Remaining space available: 1%.
# i Maximum allowed relative width for `id`: 98.0%
# i Reduce the proposed width or adjust other locked widthsRule of thumb: while any column stays unlocked, locked relative
widths must sum to at most 99.5% — the validator keeps the
minColWidth reserve for the last unlocked column, and the
error names your exact allowed maximum.
Using minColWidth
Raise or lower the per-unlocked-column reserve to trade strictness against layout freedom:
# A 12-column frame: locking one column at 95% would leave 5% for 11
# unlocked columns, each needing 0.5% (5.5% total) — rejected.
wide_data <- as.data.frame(replicate(12, sample(100:999, 10)))
names(wide_data) <- paste0("COL", 1:12)
tfl_set_options(minColWidth = 0.5)
spec <- create_table(wide_data) |>
define_cols(COL1, colWidth = "95%") # errors: max allowed is 94.5%
# Relax the reserve and the same lock goes through:
tfl_set_options(minColWidth = 0.3)
spec <- create_table(wide_data) |>
define_cols(COL1, colWidth = "95%") # OK
tfl_set_options(minColWidth = 0.5) # restore defaultCommon Patterns
Pattern 1: ID Column + Auto Widths
spec <- create_table(data) |>
define_cols(id, colWidth = "8%", isID = TRUE) |>
define_cols(patient_id, colWidth = "15%")
# Result: ID columns fixed, others auto-distributePattern 2: Fixed-Width Text + Flex Numeric
spec <- create_table(data) |>
define_cols(
c(id, patient_id, treatment_group),
colWidth = c("8%", "20%", "22%")
)
# age and weight_kg auto-fill remaining 50%Pattern 3: All Manual Widths
tfl_set_options(autoColWidth = FALSE)
spec <- create_table(data) |>
define_cols(
c(id, patient_id, age, weight_kg, treatment_group),
colWidth = c("10%", "25%", "20%", "20%", "25%")
) # Sum = 100% exactly
tfl_set_options(autoColWidth = TRUE)Pattern 4: Progressive Locking
# Lock columns one at a time, observing effects
spec <- create_table(data)
print(spec) # See initial distribution
spec <- spec |>
define_cols(id, colWidth = "10%")
print(spec) # See after first lock
spec <- spec |>
define_cols(patient_id, colWidth = "20%")
print(spec) # See after second lockTroubleshooting
Issue: “Insufficient space for remaining columns”
Cause: A locked width leaves less than
minColWidth % for each column that stays unlocked.
Solutions:
- Reduce the locked width you’re trying to set
- Lower
minColWidthviatfl_set_options(minColWidth = 0.3) - Lock more columns explicitly to reduce the unlocked count
Invisibility does not help here — the reserve counts every column in the spec, visible or not.
Issue: A width shifts by more than a tenth of a percent after recalculation
Cause: Drift correction. The largest unlocked column absorbs the whole rounding correction so the total stays exactly 100%; with many unlocked columns that correction can exceed 0.1% on one column.
Solution: This is expected and handled automatically. The rendered output is correct.
Issue: Can’t set width for invisible column
Cause: Trying to use colWidth with
isVisible = FALSE
Solution: Remove the colWidth argument
— an invisible column is rendered at “0.0cm” and takes no space, so a
width on it would be meaningless.
Issue: Widths change unexpectedly after define_cols()
Cause: Auto-recalculation triggered by locking a width or toggling a column’s visibility.
Solution:
- This is expected behavior when
autoColWidth = TRUE - Disable with
tfl_set_options(autoColWidth = FALSE)for manual control - Or lock all columns explicitly
Advanced: Width Metadata
Note:
spec$.metadatais an internal field. Its structure may change between package versions. Useprint(spec)anddefine_cols()for all user-facing width inspection and control.
ksTFL stores width metadata internally in
spec$.metadata$colWidths. This is used by the package
itself to:
- Keep the numeric unit, value, lock flag, and initial weight per column so renormalization runs from metadata rather than display strings
- Track which columns are locked vs. unlocked
- Preserve initial proportions for normalization
You do not need to read or write this field directly. Use
print(spec) to inspect current widths and
define_cols(spec, col, colWidth = ...) to modify them.
Best Practices
- Start with defaults: Let ksTFL auto-detect widths initially
- Lock strategically: Fix only the columns that need exact widths
-
Use print(): Inspect the spec after each
define_cols()call - Test rendering: Verify widths in actual output documents
- Document intent: Add comments explaining width choices
- Use invisibility: Hide helper columns instead of tiny widths
Summary
- Four states: VISIBLE/LOCKED/UNLOCKED/INVISIBLE determine width behavior
- Auto-recalculation: Triggered when you lock widths (if enabled)
- Validation: Minimum thresholds and space constraints prevent errors
- Flexibility: Mix relative (%) and absolute (cm, in) units
- Control: Disable auto-recalculation for manual width management
For more examples, see:
- Reporting Examples — complete end-to-end workflows including column width patterns
- Getting Started — overview of the full pipeline
-
Advanced StyleRows — using
invisible columns with
compute_cols()for conditional logic