Skip to contents

ksTFL logo

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:

  1. VISIBLE Columns (isVisible != FALSE)
    • Displayed in the report output
    • Participate in width calculations
    • This is the default state for all columns
  2. 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
  3. UNLOCKED Columns (no colWidth set)
    • Automatically recalculated to fill available space
    • Normalized proportionally based on initial auto-detected weights
    • Only visible unlocked columns participate in recalculation
  4. 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:

  1. Analyzes each column’s data type and content
  2. Estimates visual width from the widest rendered cell (numeric values measured after the auto-guessed %d / %.df format), 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.
  3. Distributes widths proportionally so they sum to 100%
  4. 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 columns

The 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% proportionally

Flag 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 proportionally

Important: 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:

  1. Partitions columns into LOCKED and UNLOCKED
  2. Calculates available space: 100% minus sum of locked percentage widths
  3. Normalizes unlocked widths to fill available space proportionally
  4. 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 correction

Note 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%.

Drift Correction

To ensure widths sum exactly to 100%, ksTFL applies drift correction:

  • Rounds all widths to 1 decimal place
  • Calculates total rounding error (drift)
  • Adds/subtracts drift from the largest unlocked column

The sum is exact; the largest unlocked column absorbs the correction.

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)

Why Use Manual Mode?

  • Precision: When you need exact widths without rounding
  • Complex layouts: Multi-level headers with specific alignments
  • Pre-calculated: When you’ve determined optimal widths externally

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 widths

Rule 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 default

Common 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-distribute

Pattern 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 lock

Troubleshooting

Issue: “Insufficient space for remaining columns”

Cause: A locked width leaves less than minColWidth % for each column that stays unlocked.

Solutions:

  1. Reduce the locked width you’re trying to set
  2. Lower minColWidth via tfl_set_options(minColWidth = 0.3)
  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$.metadata is an internal field. Its structure may change between package versions. Use print(spec) and define_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

  1. Start with defaults: Let ksTFL auto-detect widths initially
  2. Lock strategically: Fix only the columns that need exact widths
  3. Use print(): Inspect the spec after each define_cols() call
  4. Test rendering: Verify widths in actual output documents
  5. Document intent: Add comments explaining width choices
  6. 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: