Skip to contents

rtfreporter (development version)

  • rtfreporter_ai_manual(shell = "shell.txt"): a sheet copied from Excel (#692, 0.8.2.9082). A shell’s sheet copied from Excel, pasted into Notepad and saved comes in as it is – its tabs kept, the empty cells’ trailing tabs and a UTF-8 BOM dropped, CRLF or LF. A file saved as Shift-JIS (ANSI on a Japanese Windows) or UTF-16 (“Unicode”) stops with how to save it as UTF-8, rather than going to the chat garbled.

  • plan_empty(): a table of no events says so (#691). When the table has no row but its overall row, or its overall row (normalize_ard(overall = )) is 0 in every column, one row says so ("No subjects met the criteria." by default) – after the rows (the overall row’s 0 stays), before them, or instead of them – and footnote = adds a line to the table’s footnotes, on an empty table only. A table with events is as it was, so the study’s defaults can hold it for every table.

  • table_plan() without cols: one column (#689). A one-arm table (a single cohort) written with no column key – as people and AI chats write it – stopped with “cols is required”. Now, when the ARD has no group to spread, the table is one column, All, and a header’s {n} is the ARD’s total (cards::ard_total_n()). An ARD that has a group still needs cols, and the message names the group.

  • rtfreporter_ai_manual(): one file to paste, the shell in it, a short size (#687). The kit now comes with PASTE.md, its files as one text for a chat that takes no attachment (the article’s joining code goes). shell = (the shell’s text, or a .txt / .md file) puts the table’s shell at its end and in 07_shell.md; size = "short" keeps it to what a table’s program needs – 00_START_HERE.md, the manual’s sections 0, 17, 17a and 18, the one example closest to the ARD (adverse events for a hierarchical ARD, the ORR for a confidence interval, else demographics) and the ARD’s shape: about 40,000 characters, against 80,000 for every file. The ARD’s numbers (05_ard.csv) go into it only with values = TRUE. Arguments added only: a call written before writes the files it wrote, and PASTE.md.

  • plan_sort() by a plan_compare() statistic (#684): the rows by the arms’ difference. A key that names a statistic of the comparison records ("-estimate", made at the ARD step with cards::diff_ard_hierarchical()) reads its value on the row plan_compare() places it on; "-estimate[<comparison>]" picks one of plan_compare(by = ), and "-estimate@SOC" carries a SOC’s own difference to its PTs. The order of the two calls does not matter, and a statistic the body has is still the body’s.

  • plan_sort() by a row’s largest or smallest value across the columns (#683): "-max(n)" puts first the rows most frequent in any arm, "min(n)" the smallest; without the minus, ascending. They go with the other forms ("-max(n)@SOC", "-max(n)[Placebo]"); "sum(n)" is the plain "n". An addition: every sort written before orders as it did.

  • A cheat sheet of row orders (#682, docs). plan_sort() writes most clinical row orders – one arm’s count ("-n[Drug X]"), the most frequent in any arm ("-max(n)"), a rate ("-p[Total]"), SOCs by count with their PTs by name – and a code list’s order or visit order needs nothing here (a factor made with the ARD). The article “The plan verbs” and the AI user manual list them by table, with the arms’ difference (#684).

  • plan_levels() says a value the ARD does not have (#680). The levels order what the ARD has and make no row or column; a value they name that is not there (by its value or as plan_labels() prints it) was ignored without a word. widen_ard() now says which, in a message that notes = FALSE does not silence: a level shown with no record has to be in the ARD, a factor’s level (the study’s code list applied when the ARD is made), which cards counts as 0.

  • plan_levels() takes a value’s text with it (#678), as base R’s factor(levels, labels): plan_levels(SEX = c(F = "Female", M = "Male")) is the order F, M and their text, an element without a name the value as it is (so an order without names is what it always was), and plan_levels(variable = c(AGE = "Age (years)", "SEX")) the variables’ order and a heading. plan_labels() names without ordering (a study’s dictionary of headings, one variable’s heading changed), and its text wins value by value over a name an order gives, whichever is called first; it names a value by itself or by the text the order gives it (plan_labels(SEX = c(Female = "Women"))), the value first when a text is both (said). (Instead of a new plan_factor(), the owner’s choice.)

  • The rounding’s case is not significant (#676): "SAS" is "sas" and "R" is "r", for options(rtfreporter.rounding = ) and every function’s rounding (round_num(), the formatters, plan_digits(), widen_ard()). Lower case gives what it always did.

  • The AI kit and its article checked against main (#673). rtfreporter_ai_manual(dir, ard = ) writes its files as UTF-8 bytes in any locale: in a non-UTF-8 one a Japanese label in 05_ard.csv / 06_ard_shape.md, and the request’s own dashes, used to be dropped. 00_START_HERE.md now says what to paste (the kit, 06_ard_shape.md – the ARD’s summary –, 05_ard.csv when allowed, the shell as text), what never (subject-level data), the answer’s form (one fenced R block, nothing around it) and how to run it. The manual’s section 17 names every argument of table_plan() (show_keys), normalize_ard() (keys, drop_contexts, drop_key_variables, stack_sep), plan_compare() (position), plan_filter() (by), plan_paginate_group() / plan_paginate_rows() (heading, heading_at) and plan_template() (pipe), and a test keeps it so; the kit’s ORR example says heading_at = "section". The article ai-plan-code (en/ja) runs end to end on cards::ADSL; a test runs its blocks, and the kit’s three examples.

  • plan_paginate_group(heading_at = ): where the page name prints, said in the plan (#672). "section" (the section header), "title" (the last line of the titles) or "none"; plan_paginate_rows() takes it too, for its page_by pages. Each page carries it, as it carries its titles, and it is the default of rtf_tables(auto_section = , auto_title = ), which are now NULL by default: what the pages say, else FALSE as before. An argument given to rtf_tables() wins. So a report’s page settings need not say auto_section; the plan that names the pages says where the names go. Nothing changes for a plan that does not say it.

  • Docs: asking an AI chat for a table’s plan code (#670), a short article in English and Japanese (a toggle between them): make the kit with rtfreporter_ai_manual(dir, ard = ), what to give the AI (the kit and this table’s shell; never the ADaM; only 06_ard_shape.md when the ARD may not be sent; one text to paste when the chat takes no attachment), how to ask, and how to try the code it returns.

  • Docs: the navbar’s AI manual links open (#668). The “AI user manual” / “AI developer manual” links were relative .md paths, which pkgdown rewrites to .html – not published, so 404. They are absolute URLs to the published .md now, as the README’s are, and a test keeps them so.

  • Docs: the examples’ running header starts c(l = "Ichirio Pharma", r = "DRAFT") – the study is the second line’s Protocol: {STUDY} – in the README, Get started and the figures, gallery and tables-from-ARD articles; their pictures made again (data-raw/gen_gallery.R, gen_article_shots.R, whose list of pictures now reads .shot(), so a .keep() call over several lines is no longer missed).

  • A label sets only the text, never the order (#666). plan_labels() / widen_ard(labels = ) used to put the labelled variables first, in the order the labels were written, so labelling some of a table’s variables reordered its blocks with no message. The order of values, of variables and of columns is now always plan_levels()’s (plan_levels(variable = c("AGE", "SEX")) for the variables) or the ARD’s.

  • Docs: the plan verbs say what a new user looks for (#664). table_plan(): what the names in rows are and what "variable" and .label hold, where the order of the columns’ values comes from, where subgroups are made. plan_verbs: plan_levels(variable = ) orders the analysis variables, which verbs read the ARD’s columns and which the finished table’s, the names after ... are reserved, rounding is a table’s exception to the study’s rule, when plan_total() is needed, column_widths_twips wins over widths, hiding a variable with plan_filter(); value is corrected: it matters only with stats = "rows". widen_ard(), normalize_ard(), a custom split example in as_rtftables(), the layer of round_num() / fmt_round() / fmt_signif() / fmt_numeric(), and a “Looking inside a plan” heading in the reference.

  • The plan verbs take the plan as .plan (#662). A name such as p in a verb’s ... – plan_digits(p = 1) for plan_compare()’s column, plan_cells(p = ) – was taken for the first argument plan (an argument before ... matches by its first letters) and stopped with “Expected a table_plan”. Every plan_*() function’s first argument is now .plan, as plan_compare()’s already was: the pipe passes it by position, so piped code does not change; a call that named it plan = writes .plan =.

  • Docs: the plan verbs in one recommended order (#660). Plan verbs has a section, The order to write them in: first the mapping (normalize_ard(), table_plan(), plan_cells(), plan_stat_groups(), plan_total(), plan_compare(), plan_filter()), then the finishing (plan_labels(), plan_levels(), plan_nest(), plan_digits(), plan_sort(), plan_col_header(), plan_stub(), plan_blanks(), the pages, plan_columns(), plan_style(), plan_cell_style()). The examples of Get started and Tables from an ARD are written in that order; their plans give the same tables as before.

  • CRAN format (#658). The rtables and tern links of the From rtables vignette are their CRAN pages (their pkgdown sites answer 404 now); the WORDLIST has the nine words the spelling check found in the help and the shipped vignettes; ?ard-tables gives cards’ rename of ard_continuous() its version, 0.7.0.

  • Docs: the examples write rtfreporter’s own calls (#656). The README, the Gallery, Figures and Tables from an ARD no longer use a helper (study_doc()) for the study’s running header and footer: each example writes rtf_document() |> rtf_section(header = rtf_header(), footer = rtf_footer()) |> rtf_tables() itself, as the flow figure shows. The pages look the same (each document’s RTF is byte for byte what it was).

  • A document can say its file: rtf_document(file = ) (#654). The RTF file’s path (one string) is kept in the document, where the program is said, and generate_rtfreport(doc) writes there; a file_path given to generate_rtfreport() wins, and with neither it stops and says so. file_path now defaults to NULL; a call that gives the file, by position or by name, and the superseded report = work as before.

  • A gt table’s row groups are heading rows again (#650). A table built with gt(groupname_col = ) came through as_rtftables() with its group as a column, ::group_id::, repeated on every row (since 0.0.46, when the body became gt::extract_body()’s). Each group is now a heading row above its rows, which are indented under it (two non-breaking spaces, as gtsummary and tfrmt tables arrive), so group_by = "auto" finds the groups for the page breaks and blank rows; a body cell’s tab_style() moves with its row. gt is needed at 0.11.1 or later (was 0.9.0): before it, extract_body() is missing or names its columns differently.

  • The guide pages show their tables (#651). About fifty pictures, each under the example it shows – the feature tour, pagination, importing tables, page setup, borders, headers and footers, adding content, figures, listings, sections, the plan verbs, From gt and gtsummary, From rtables and tern – made from the examples’ own RTF by data-raw/gen_article_shots.R. The two Same report, every framework articles show each framework’s table as written, at the size of the other pictures, instead of “Waiting for snapshot” (data-raw/showcase_dm.R / showcase_ae.R make the pictures; the placeholder script is gone). Assembling reports into one document is an article of its own (cover, table of contents, page numbers, a folder, PDF bookmarks, Word and LibreOffice); Rendering and post-processing and the Pharmaverse catalog link to it. Figures writes its pages with the study’s running header and footer. The Articles menu names the AI manuals without a version; the manuals list the released versions. The External API specification is gone: its table of the public classes is in Architecture & internals, where the old address leads.

  • The plan verbs, by how often a table uses them (#648). The plan verbs’ At a glance is three groups: the verbs of every table, to learn first (table_plan(), plan_cells(), plan_digits(), plan_labels() / plan_levels(), plan_col_header(), and the study’s house style written once), what some tables add (plan_total(), plan_compare(), plan_sort(), plan_filter() …), and what is there when a table needs it (plan_hide(), plan_cell_style(), plan_paginate_cols() …). Code of three lines or fewer is no longer folded under Show code on any page. gt, gtsummary & rtables is two articles, From gt and gtsummary and From rtables and tern; the old address leads to the first.

  • Get started and Tables from an ARD, two roles (#646). Get started is the shortest way through the three steps (the code of each, one finished table, then the other tables as pictures linking to their sections); Tables from an ARD takes each step in detail. Get started leaves the Articles menu (the top bar links it). The flow from an ARD to an RTF file is a figure – four boxes, side by side on a wide page and one under another on a narrow one – the same in the README and both articles (a test keeps the three copies equal).

  • Articles: the finished tables. Eleven guide articles (headers and footers, pagination, borders, figures, listings, importing tables, page setup, the two on sections, adding content, the plan and as_rtftables()) show a picture of the table after their key examples, made by data-raw/gen_article_shots.R (#644). Adding tables and figures: its styling section now says what works – a title row may be styled, a footnote is styled as a block (rtf_footnotes(border = , align = )), and a footnote row may be c(l = , r = ); the per-row footnote list it showed was removed long ago and failed when written.

  • The AI kit says what to give the AI. 00_START_HERE.md ends with a section for the person attaching the folder: required (the ARD through rtfreporter_ai_manual(dir, ard = ), the table’s shell), nice to have (the exact titles and footnotes, layout conventions, one house-style program), one table a chat, never patient-level data (and an ARD is still the study’s results: the company’s rules apply), and what the assistant does when something is missing. The user manual’s §17a says the same to the assistant.

  • Home page: the three examples (from an ARD, from tern, from gtsummary) make the same table: age as Mean (SD), Median and Min, Max, sex as n (%), the same digits, labels and footnotes, with N in the column header.

  • Home page: “From an ARD to an RTF table in 30 seconds”, the page it writes first and its code under it; the word plan is kept for the code and the verbs (Get started’s steps are the table, the report, the RTF file).

  • Docs for the new plan verbs. Get started shows three more tables from the same steps (a Total and a p-value, AEs in 5% or more with the SOCs by frequency, ORR by subgroup with a section per arm); Tables from an ARD has them in full, with plan_total(), plan_compare(), plan_filter(), -n@SOC, normalize_ard(stack = ), plan_stat_groups(), heading = and rtfreporter_ai_manual(); The plan verbs lists them, and the reference index groups the verbs by job. The AI kit’s ORR example drops a plan_sort(FALSE) that was the default. The home page, Get started and Tables from an ARD present the same three steps after the ARD (made with cards / cardx): the table plan, the report, the RTF file. The example outputs carry a study’s running header and footer (the study and the page, the output’s number, title and population; its footnotes, the program’s path and the run). The Gallery shows each picture first, its code folded under it; the navbar is Get started, Reference, Articles, Gallery, Changelog; Rendering, post-processing and assembly says how the bookmarks reach a PDF.

  • A plan’s column positions count the PRINTED columns, all of them (#635). Unnamed plan_columns(widths = ), the header’s col_cell()s and plan_paginate_cols() already counted the columns a page prints; plan_columns(row_title = ) and plan_row_group(collapse = ) counted the table’s own, before a hidden key (plan_hide(), page_by) was dropped, so with arm hidden row_title = c(1, 2) landed on the hidden arm and the first printed column, and collapse = c(1, 2) never reached the second. They now count as widths does. The basis changes (no alias, before CRAN): a plan that counted the hidden column in these two moves its positions down by one. Both also take names, which a reordered or hidden column cannot shift: a row key’s name as table_plan(rows = ) gives it and .values – plan_columns(widths = c(grp1 = 42, grp2 = 28, .values = 15), row_title = c("grp1", "grp2")), plan_row_group(collapse = c("grp1", "grp2")). A hidden or unknown name, or a position past the printed columns, is an error that lists them.

  • A page key is never a row group (#635). The key the pages are cut on – plan_paginate_rows(page_by = ), plan_paginate_group(col = ) – is the same on every row of a page, yet the rows were grouped on it when it was the outermost row key: plan_blanks(where = "between_groups") then put no blank between the subgroups of an arm’s section. Row grouping now starts at the first printed row key (grp1), for the blank rows (with first / last on every page), collapse, row_title, the stub and the column positions; plan_paginate_group() is handed to as_rtftables() as page_by (still a page per value, named after it), and keeps split = "by_value" only with a plan_stub() or a second page key.

  • An AI kit: rtfreporter_ai_manual(dir, ard = ) writes a folder to attach to a chat, for an assistant to write one table’s program from a shell and an ARD: 00_START_HERE.md (the request: the three-step layout – the plan over the ARD, the document, the file – and the rules), the user manual (its new §17a: from a shell and an ARD), three example programs in that layout (an ORR by subgroup with a section per arm; AE by SOC and PT; demographics with a Total and a p-value column), and with ard the ARD as CSV and its shape (its keys, which come together, each variable’s statistics, a starting table_plan()). Nothing is sent anywhere. dir replaces file (no alias, before CRAN): rtfreporter_ai_manual() still returns the manual’s path, and the developer manual is which = "dev".

  • With a plan_compare() column, a column header written with {col} / {n} (or a short row repeated over the columns) sat one cell to the right, and the comparison column got no cell (#631). The cells now sit over their columns, and a short row gives the comparison column its name on the first row.

  • plan_filter(): rows dropped by a condition over the record (#620). plan_filter(p >= 0.05) keeps the PTs with 5% or more in any arm (any = FALSE: in every arm), in plan_cells()’ guard language over the record’s statistics and keys. A SOC stays exactly when one of its PTs does (parents = "keep"; "drop" judges it on its own); the overall row always stays; a row no statistic answers stays, noted. It runs before the cells and the sort, and the RTF is byte-identical to the same plan on an ARD filtered by hand. widen_ard(filter = ) is the same without a plan.

  • table_plan(show_keys = ) and plan_stat_groups(.variables, .others): a subgroup’s own n (%) beside its ORR (#621). Once SEX is a key anywhere in the ARD (a subgroup layer), its own tabulation is a key’s count and was left out of the body; show_keys = "SEX" keeps it, as a block of its own with rows = c(block = "variable", ...). .variables = "RESP", .others = "n" splits only the ORR into its groups and puts the other variables’ records whole in n, so SEX’s n and p stay together. Defaults change nothing.

  • The header’s {n} for stacked keys on the columns (#622). With cols = c("subgroup", "subgroup_level") (normalize_ard(stack = )), each column’s population is its layer’s own tabulation: the SEX____F column’s is SEX’s count at F. A crossed layer’s is not read (NA, with the usual warning).

  • normalize_ard(stack_nest = TRUE): a crossed layer’s outer level as a heading row (#623). The outer levels go to a key of their own, <name>_outer, the innermost to <name>_level, so with plan_stub() the rows read Age Group by Sex, 65-80, F; a single layer keeps its two levels with no empty heading. FALSE (default) is the joined form.

  • plan_compare(): a test’s p-value, a difference and its CI as columns on the summary’s row (#625). cardx’s tests write the column key with no value, so table_plan(cols = ) dropped them. plan_compare(p = c(p.value < 0.001 ~ "<0.001", "{p.value:.3f}")) and plan_compare(diff = "{estimate:.2f} ({conf.low:.2f}, {conf.high:.2f})", p = "{p.value:.3f}") place those records – an analysis the body does not have – on their variable’s block: its level’s row, else the block’s first row. by = gives one column a comparison; position = puts them first or last. Nothing is computed: the numbers are the ARD’s. A column keyed by context (p = c(stats_t_test = "{p.value:.3f}")) picks one test of several and leaves the others, noted; two tests one column reads are an error, never one picked. A column’s lines are cell_rows(), each beside the body’s line of that label (an ORR difference on line 1, its CI on line 2). The names are the columns’ own: t = "{statistic:.2f}", df = "{parameter:.0f}", an odds ratio. widen_ard(compare = ) is the same without a plan.

  • plan_total() reads only the body’s analyses: with a test in the ARD it took the test’s record for an overall row and left an empty row.

  • plan_stat_groups(): one record’s statistics in two columns, without a mutate() before the plan (#619). An ORR table with N in one column and the rate and its CI in the next had to relabel variable by stat_name first. plan_stat_groups(n = "N", orr_ci = c("n", "estimate", "conf.low", "conf.high")) puts each statistic in a named group, the key .stat_group, which table_plan(cols = c("ARM", ".stat_group")) names like any other; plan_cells() keys a template by the group’s name. A statistic in no group is not read. widen_ard(stat_groups = ) is the same for a call without a plan. The RTF is byte-identical to the mutate() version (a test). Overwriting variable was a workaround: keep the ARD as it is and choose the layout in the plan. With cell_rows() inside each group, one analysis’s statistics lay out as n rows x m columns; the groups go across (cols =) or down (rows =, in the order they are said), within one record (the same analysis and keys).

  • plan_paginate_group(heading = ): each page’s name from a template (#619). heading = "Treatment: {value} (N={n})" names each page — the heading rtf_tables(auto_section = TRUE) puts in the section header — with the page’s value and its population as the ARD states it (the page variable’s own tabulation, cards::ard_tabulate(adsl, variables = TRT01A)). A section per arm says the arm and its N with neither written by hand; what the ARD does not state is NA, with a warning. plan_paginate_rows(page_by = , heading = ) does the same for BY pages with a row budget inside (a page per value refuses one).

  • Sort keys <stat>@<key> and <stat>[<col>] (#619, the tflplanner builder’s request). -n@soc orders the SOCs by their own count and keeps each SOC’s PTs with it; -n[Total] / -n[Placebo] read one spread column; -n@soc[Total] both. A plain -n sums every spread column, as before; ties go to the keys that follow.

  • normalize_ard(stack = ): parallel subgroups as one pair of keys, whatever group slot each came in (#619). stack = list(subgroup = c("ALL", "SEX", "AGEGR1")) adds subgroup (the variable) and subgroup_level (its level), so rows = c(grp1 = "subgroup", grp2 = "subgroup_level") lays the subgroups one under another in the order written. Reading group2 / group2_level directly worked only while every subgroup came in that slot. A layer may be crossed: list("ALL", "SEX", "AGEGR1", c("AGEGR1", "SEX")) gives the row AGEGR1 / SEX | 65-80 / F (stack_sep =), three-way the same; a row goes to the layer whose variables are exactly the ones it has, and a combination no layer names is an error that says which to add. Group slots stand for what the ARD has there: c("group2", "group3") finds the layers, single and crossed, in ARD order (refused where the slots also hold the arm). The variables’ own columns stay.

Deprecated

Removed in the next minor release, the first CRAN one. The pre-CRAN API review, iteration 0 (#594; decided in the team’s review): each warns once a session and still works; 0.9.0, the first CRAN release, removes it. No old name is kept as an alias.

  • The 26 table-format arguments of rtf_tables(), and read_gt (#511 B-1). A table’s appearance is written in one place: rtftable(), as_rtftables() or plan_style(). rtf_tables(doc, df, border = "tfl", col_rel_width = c(2, 1)) is rtf_tables(doc, rtftable(df, border = "tfl", col_rel_width = c(2, 1))); a gt or gtsummary table goes through as_rtftables() instead of read_gt. Until 0.9.0 they behave as they did, including overriding a pre-built rtftable() page when passed.
  • fmt_count_paren_bare(x) – it is fmt_count_paren(x, bare = TRUE) now, the same output byte for byte (fmt_count_paren() gains bare =; fmt_value_paren() pads differently, so it is not the replacement); realign_count_pct() – as_rtftables(align_count_pct = TRUE), the built-in cell format "count_pct"; and format_count_pct(), renamed fmt_count_pct() (the same arguments), named like the other cell formatters. The cell-format name "realign_count_pct" says so too ("count_pct" is the same); "count_paren_bare" stays.

The rest of the review, iteration 0 (#594; nothing breaks)

The names below that are superseded still work and do not warn (documented on their help pages); nothing is removed.

  • rtf_section(header = , footer = ) (#594 1.5): the section is given as two arguments, rtf_section(doc, header = hdr, footer = ftr); secinfo = list(header = , footer = ) is superseded (it is still how several sections are given at once). A secinfo with another name (list(hdr = )) is an error – it was ignored, and the band it meant was left out without a word (E3) – and so is a header that is not a header, its rows or its text (list(header = 1)).
  • paginate_cols() takes plan_paginate_cols()’s names (#594 1.4): cut_by (a list of blocks or a separator), every (a block every so many columns, which paginate_cols() did not have), keep, fit (TRUE/FALSE) and order; cols / by, carry, width and page_order are superseded.
  • A character vector of titles or footnotes is one block for every page (#594 §3): rtf_titles(doc, c("Table 14.1.1", "Demographics")), and titles = / footnotes = of rtf_tables() / rtf_figures(), as list(c(...)) was. It used to be an error.
  • One name for a thing (#594 1.4): text_width_in(), auto_col_widths() and fit_listing_widths() take font_size_half_points (was size_half_points) and default font = NULL, the package’s font (getOption("rtfreporter.font"), as every other font =; it was always Courier New – the same widths under the default option); auto_col_widths(data = ) (was df); generate_rtfreport(doc = ) (was report). A call by position is unchanged.

Changed

  • generate_rtfreport(overwrite = TRUE) is the default. A report program is run again and again, and every re-run stopped with “already exists” unless the call said overwrite = TRUE – as write.csv() and ggsave() do, the file is written over now. overwrite = FALSE keeps the old guard.
  • fmt_count_pct() with pct_unit = "fraction" (the default) warns once a session when a pct is above 1: a percentage written as a fraction prints 100 times too large (5 as 500).

New

  • plan_n_candidates(): the populations a header’s {n} can say (tflplanner #288). Before {n} is chosen, a GUI can show each choice with its values: one population (scope = "all"), or on pages split by a group value two – each page’s own ("page", the subjects with that test) and the table’s ("table", the analysis set) – per column and over all the columns, as plan_col_header(values = list(n = )) prints them; attribute differ when they disagree. Nothing else changes: the page split’s column is now found by one helper that the header’s pages use too.

Continuous integration

  • R-CMD-check now also runs on R 4.2, the oldest R tested (Depends: R (>= 4.1) is kept; cardx, used in tests, needs R >= 4.2). That job leaves tern out (every tern now installable needs R >= 4.4) and lets a suggested package be missing; the tests skip what needs it.

Documentation

  • The home page leads with the two ways in (#613): a table from a cards ARD with a plan first, then a gtsummary table brought in with as_rtftables(), each with a picture of the RTF it writes; the typed-in data frame follows, without one. Get started adds an adverse-events table, and the reference index puts Bring your own table right after Tables from an ARD. The pictures are rendered RTF -> PDF -> PNG by data-raw/gen_gallery.R, from the code shown. The articles use the current names: rtf_section(header = , footer = ), paginate_cols(cut_by = , keep = , fit = , order = ) and fit_listing_widths(font_size_half_points = ).

  • rtf_figures(width_twips = NULL) is the image’s native size (#617), as ?rtfplot says: a file at its embedded DPI, a plot object at its render_width, with no fit to the page. Its help page said “the full writable width”; the figure was never stretched to it.

  • README first screen and a Gallery article. The README opens with one sentence on what the package does and a 30-second example that needs only rtfreporter, with a picture of the RTF it writes. The new Gallery article (in the navbar) shows a demographics table, an adverse-events table, a vital-signs table, a listing and two figures made from the CDISC pilot data, each with the code that wrote it. data-raw/gen_gallery.R renders the article and the README example and turns their RTF into the pictures.

  • Examples on the ARD / plan path, and Get started without cards (#594 D1, D2). ?plan_verbs now runs every plan_* verb – on a cards ARD when cards is installed, and the display verbs and plan_listing() on a plain data frame either way; normalize_ard(), widen_ard(), list_ard_keys() and plan_layers() have examples of their own. The Get started vignette loads rtfreporter in a chunk that always runs, so it builds (its last example included) on a machine without cards.

  • The docs no longer contradict each other (#594 D3-D6). ?rtf_section says what the renderer does and the headers article already said: header = NULL inherits the previous section’s header (only the first section’s NULL means none; rtf_header("") is a blank one). ?rtf_tables describes tables by its usual forms – a table_plan, the pages of as_rtftables(), a bare data.frame – and sends a gt table through as_rtftables() rather than read_gt. ?rtftable’s border =, the public-API article and ?set_blank_rows no longer present rtf_table_border(), the old border constructors or paginate() as current. Every deprecation note now says the same thing: removed in 0.9.0, the first CRAN release.

  • No code copied from a discussion, and no links to discussions that are moving (#596). The test that checks build_listing() against a listing laid out by hand now lays it out with its own code, on made-up adverse-event data, and checks the same things; the comments, docs and NEWS describe their sources without the numbers.

  • GROUPN, not BIGN (#592). The subjects per group – what clinical reporting calls big N – are a GROUPN analysis in the comments and tests, as in tflspec and tflplanner. tfrmt’s own names (prep_big_n(), big_n_structure(), bigN) are tfrmt’s and stay.

Bug fixes

  • A gtsummary table’s footnote is printed once (#613), not once per statistic column: gtsummary attaches it to every arm, and gt prints each text once.

  • add_header_row() keeps the column names (#615) of a table that has no column header yet: the new row goes above (or below) them, as documented, instead of taking their place. Tables of several data frames keep each one’s names.

  • Mistakes that were accepted now stop with a message that says what to write (#594 E1-E11). None of these changes the output of code that worked.

  • {n:sum} leaves the Total column out (#604). With plan_total(), a header’s {n:sum} (and plan_header_tokens()) added the Total column to the arms – 612 for 306 subjects. The Total column is the sum of the others, not a part of it.

New features

  • plan_total(): a Total column from the ARD’s overall rows (#600, tflspec #212). cards states the overall itself – the same analysis without its by, bound under it, or ard_stack(.overall = TRUE) – as rows with no value of the column key. plan_total(label = "Total", position = "last") makes them one more column, and its header {n} is the study total the ARD states (the column key tabulated on its own, or ..ard_total_n..), which is still never put in every column. The ARD keeps no ARM = "Total" the data does not have, so an ARS made from the same analyses stays true. A Total already in the data, or a table of several column keys, stops with why. Without plan_total(), nothing changes.

  • plan_nest(): a variable’s rows under one level of another (#598). plan_nest(RACESUB = c(RACE = "Asian")) moves RACESUB’s rows right after RACE’s “Asian” row, one stub indent step deeper, in RACE’s group (its own heading dropped): the sub-categories of a race under it, from two analyses on the same data – a hierarchical tabulation would drop every level that has no sub-level. The level is matched as the table shows it; a variable or level not in the table stops with what there is. Before, only plan_after() could do it.

  • rtf_figures(doc, plot): one figure without list() (#589). An rtfplot, one file path, a function that draws or a plot object (a ggplot, a patchwork, a grob, a recorded plot) is taken as it is; a list is still several figures. A ggplot was a list of its own parts before, so rtf_figures(doc, plot) failed on its first part.

  • plan_col_header(lines = ): the column header a row at a time (#588). A list, one element a header row, each a named character vector – a cell’s name the columns it sits on (a name, .values, 3:5, KEY = value), its value the text: plan_col_header(lines = list(c(row_label = "", .values = "{col}"), c(row_label = "Characteristic", .values = "(N={n})"))). span says how a cell over several columns is made ("each", "one" or a key’s name). It is the data frame of cells written another way, so it does all that does; header (an rtf_col_header() or the data frame) stays.

Fixes

  • plan_labels(): the variables in the order labelled (#585). A variable whose own label is in its dictionary (SEX = c(SEX = "Sex", F = "Female"), #514) moved to the end of the variable order, after the ones labelled plainly; it now keeps its place.

  • normalize_ard(): a text statistic no longer turns stat into text (#583). An ARD with a test’s method / alternative (cardx) or a flag (paired) beside the numbers made the whole stat column character, so a cells guard compared strings (N < 9 was TRUE for 86) and a rework written for numbers failed. stat stays numeric, each number read as it is (no digits lost); a text value is NA there and the row’s stat_fmt is the text, so {method} still prints it.

New

  • Added a root CITATION.cff so GitHub’s “Cite this repository” button works (#573).

  • rtf_header() / rtf_footer(): drop_empty_rows (#571). With drop_empty_rows = TRUE, a band row whose tokens of one’s own (rtf_document(tokens = ), options(rtfreporter.tokens = )) are all empty when the file is written, and which says nothing else but blanks and brackets ("<{POPULATION}>"), is left out: one header can serve every report of a study, a report with no value for a line going without it. A row with no token, rtfreporter’s own ({PAGE}) or an unknown one is always written. The default, FALSE, writes every row as before.

  • program_fallback: the program when none is said or found (#566). rtf_document(program_fallback = ) (and generate_rtfreport(program_fallback = ) for one call) names the program a {PROGRAM...} token uses when no program is said (program, the option) and none is found – last, after the script Rscript runs, so a program’s own file name always wins. A message says when it is used; the file name is completed as any program’s (.R added to a name with no extension).

  • Tokens of one’s own (#564). rtf_document(tokens = list(STUDY = "ABC-123")) – or options(rtfreporter.tokens = ) for a session, the document’s value winning – and a header, footer, title or footnote says {STUDY}, filled when the file is written (RTF-escaped). A name is upper case, a letter then letters, digits or _, and not one of rtfreporter’s own ({PAGE}, {PROGRAM}, {DATETIME} …: an error); a value is one string or number. rtf_text_tokens(doc) lists a document’s (and the session’s) with kind = "own". A column header still takes its values from set_col_header(values = ).

  • The program is found when it is not said (#562). With no rtf_document(program = ), generate_rtfreport(program = ) or options(rtfreporter.program), a {PROGRAM...} token takes the file source() is running (the innermost), the script Rscript runs, the document knitr is knitting, or the file open in RStudio’s editor (interactive, with rstudioapi; not an Untitled one) – and a message says which. A program said is used quietly, and a file with no {PROGRAM...} token searches for nothing. The file name is completed to the one on disk: its real case (t_dm.R that is T_DM.r on Windows); with no extension, the program of that name (.R, .r, .Rmd, .qmd), else .R. With no program at all, the error says rtf_document(program = ) first, or to run the program with source() or Rscript.

  • {PROGRAM_FULL}, the program’s absolute path (#560). A run token beside {PROGRAM}: the same path made absolute (normalizePath(), from the working folder when the file is written), with the system’s separator – \ on Windows, / elsewhere. {PROGRAM} is still the path as given, so existing files are unchanged. rtf_text_tokens() lists it and set_col_header() leaves it for the renderer; no program known is the same error as for {PROGRAM}. The documentation now names the program where a program says it once, rtf_document(program = ); generate_rtfreport(program = ) overrides it for one call (the order the program is looked up in is unchanged).

Documentation

  • The site leads with an ARD and a plan (#581). A new Get started (vignette("rtfreporter")) builds a table from a cards ARD with table_plan() and the plan_*() verbs, then points to the second path, bringing a table from gt / gtsummary / rtables / tfrmt / flextable / huxtable / a data frame through as_rtftables(). The articles menu and index, the reference index and the README follow that order (ARD + plan, bring your own table, listings, figures, assembling a deliverable, AI assistant); the headers article covers the run tokens, tokens of one’s own, program_fallback and drop_empty_rows. The pipe-API vignette and the two Quarto examples are gone (redirected), their ground covered by Get started, the document articles and the Demographics showcase.

  • AI developer manual: the repository rules (#574). A new §14 summarises, with a pointer to each source file, the Code of Conduct, the issue -> PR workflow and the PR / issue templates, the four pre-push checks (R CMD check --as-cran, the full suite with NOT_CRAN=true, pkgdown::check_pkgdown(), lintr::lint_package()), versioning + NEWS.md, and cran-comments.md. .github/copilot-instructions.md now points at rtfreporter_ai_manual("dev") and no longer contradicts it on Imports: and optional-package guards.

  • NEWS.md: no R comment reads as a version (#558). Two code blocks (the R6 -> S3 migration of 0.8.0 and the gt read_gt example) began their lines with R comments at column 0, which a markdown reader that does not see the block open takes as level-1 headings – versions – so R CMD check noted “Cannot extract version info”. The comments are in the text around the code now, and a test keeps every level-1 line a version.

  • README: no release/CRAN roadmap (#579). “Status & roadmap” is “Status” now, and the planned-milestones table (first CRAN submission, then v1.0.0) is gone, along with the other forward-looking CRAN mention – packages do not announce release plans. The section keeps the factual status: pre-1.0, lifecycle: experimental, the latest release and how to install it, the development-version scheme, and a pointer to NEWS.md.

Before CRAN

  • cards’ current verb names (#556). The examples, the quickstart vignette, the articles, the AI manual and the tests build their ARDs with cards::ard_summary() / cards::ard_tabulate() (cards 0.7.0 renamed ard_continuous() / ard_categorical()), so they keep running when cards drops the old names; Suggests: cards (>= 0.7.0). An ARD made with the old verbs (its context is "continuous" / "categorical") still reads the same – a test stamps the old context by hand. The RTF is unchanged.
  • plan_header_tokens()’s example runs only when cards is installed.
  • The Description says what the ARD layer (the plan) does.

Bug fixes

  • A group count stated twice is one count (#554). A column header’s {n} read nothing ((N=NA)) when the ARD counted the groups twice – a BIGN row next to an ard_stack(.by = ) that counts them too (.by_stats = TRUE, cards’ default) – though both said the same: a column’s count was dropped as soon as it appeared twice. The same count twice is now one count; two different counts for one column are still not a column’s number, and plan_header_tokens() says why (“the ARD states two different counts for this column”).

Found while raising the test coverage before CRAN (#552).

  • print(rtf_watermark()) shows the watermark’s summary: the method was not registered in NAMESPACE, so the raw list was printed (#546).
  • print(rtf_document()) names a preset page by its size and orientation (letter (landscape)) instead of x inches (#547).
  • rtf_tables(auto_section = TRUE) with an explicit rtf_section(page = n) on the page an auto section starts: the explicit section wins on that page. On page 1 this stopped with an internal error; on a later page the pages were rendered twice, once in each section (#548).
  • cell_rows(): a row written as one bare guard ("1" = n > 0 ~ "has", not inside c()) is a chain of one guard, as inside c(); it printed ~ in every cell and broke print() (#549).
  • The console preview of a table draws dash and dot rules with their own glyphs; it looked for dashed / dotted, names no border has (#550).
  • NEWS.md parses again for news() and R CMD check (a heading with a version number in it made the parser take every ### heading for a version).

Deprecated

  • The first argument of set_blank_rows() (was df) and add_cont_label() (was chunk) is data, as in the other functions that take a table. A call that gives it by position is unchanged; the old name still works and warns once a session, and is removed in 0.9.0.

Finding what the next minor release removes

0.9.0 removes everything deprecated in 0.8.x. To find it in your code (R scripts, R Markdown / Quarto), search for this regular expression – each hit warns today and stops working in 0.9.0:

\b(rtf_border_(none|top|bottom|box|with|tfl|side)|rtf_table_border|add_col_header_row|col_header_from_names|set_header_cell|update_(header|footer)_row|paginate|assemble_(files|spec|toc|from_spec)|toc_(heading|entry)|fmt_count_paren_bare|realign_count_pct|format_count_pct)\s*\(|\b(spanning_header|gt_obj|stub_vars|stub_label|stub_indent|stub_group_summary|read_gt)\s*=

For example grep -rnE '<the expression>' R/ analysis/, or RStudio’s Find in Files with Regular expression ticked. (paginate_cols() is not deprecated: the expression stops at paginate(. The two first-argument names above are not in it – they only matter when written as df = / chunk =. Nor are the 26 table-format arguments of rtf_tables() deprecated in 0.8.2.9039: rtftable() keeps the same names, so look at each rtf_tables( call for border =, col_rel_width =, font = …)

New

  • plan_levels(.drop_empty = ): the levels of these variables that no record has – n 0 (or missing) in every column – are left out of the table. An ARD made after a code list’s full set of levels was applied (tflspec) counts a value no subject has as 0, which is what a table shows by default; this is the switch to leave such rows out, one variable at a time. A level some column counts stays. The default is unchanged.

Deprecated (the pre-CRAN API review, iteration 1)

Each still works and warns once a session, naming what to write instead; all are removed in 0.9.0. ?rtf_border lists the older border ones.

The articles and the AI manual use the new spellings. Exports: 125, of which 20 deprecated – 105 to learn. The SAMPLE-01 RTFs are byte-identical.

Documentation

  • citation("rtfreporter") and credits (#528). A CITATION file, and README sections that say how to cite rtfreporter (and cards / cardx, for the statistics of a table made from their ARD) and acknowledge the packages it builds on: cards / cardx, the table builders as_rtftables() reads, pharmaverseadam and the pharmaverse examples. The cards links now point to its home at pharmaverse.github.io/cards.

Behaviour changes

  • A declared level order now orders a table with no row keys (#534). A table of one analysis variable without rows – table_plan(d, cols = "ARM") |> plan_levels(AGEGR1 = c("<65", "65-80", ">80")), or widen_ard(levels = ) – kept the ARD’s order: the sort that applies the label column’s order ran only when there were row keys. Now it runs there too. What changes: tables of that shape with levels (or factor data), whose rows now follow the declared order.

  • {DATE} is not a token (#532). It was on the list of tokens set_col_header() leaves for the renderer, and in its documentation, but nothing ever replaced it: it printed as {DATE}. It is gone from both; a date is {DATETIME:%Y-%m-%d}. A header that still writes {DATE} is now an unfilled token, which set_col_header() reports instead of printing it.

  • rtf_table_style() no longer left-aligns every column unless asked (#522). Its align defaulted to "left", so any style – even one that only draws rules (border_*, rtf_table_style_tfl(), plan_style(border_header = )) – moved the value columns to the left and lost the per-column default (row-title columns left, the others centred). align now defaults to NULL: unset, each column keeps its default; set, it applies to every column as before. What changes: a table given a style that does not say align, whose non-row-title columns now centre. Write rtf_table_style(align = "left") (or plan_style(align = "left")) for the old look.

New features

  • plan_header_tokens() (#536): the tokens print(plan) lists under “header tokens” – {col}, {col1} …, {n}, {n:sum}, {n:<column>} – as a data frame, one row a token with its values (a list column), the text print shows, and whether it resolved (and why not). For a program that offers the tokens with their values. Added only.

  • rtf_text_tokens() (#529): the {TOKEN}s a header, footer, title or footnote may carry, as a data frame – the token, whether it is a page or a run token, when it is filled (written into the file, computed by the word processor, or left for assemble_rtf()), what it becomes, and an example value for a preview. A program offering them (an “insert” menu) reads this list instead of keeping a copy. Added only.

  • Every as_rtftables() setting now has a plan verb (#518). A plan could not say cell_format, column_widths_twips, header_sep, col_header_align, the table width, the look fields of rtf_table_style(), col_spec’s underline and indent, na on a finished table, or a grouping column on a finished table without a page per value. Now: plan_columns(cell_format =, column_widths_twips =), plan_col_header(header_sep =, col_header_align =), plan_style(table_width_twips =, table_width_pct =, table_width_pct_of_writable =, header_align =, header_bold =, header_italic =, align =, bold =, italic =, underline =) (folded into one rtf_table_style() with the border_* zones), plan_cell_style(underline =, indent_twips =), plan_cells(na =) on a finished table, and plan_row_group(group_col =) on a finished table (an ARD plan refuses it: its grouping column is the outermost row key). Each gives the RTF of the direct call byte for byte. Not carried over, by design: read_meta / read_attributes, the superseded stub_* arguments (plan_stub()), and spanning_header (plan_col_header()). Added only: a plan without them gives what it gave.

  • plan_paginate_rows(page_by = ) (#516): the BY pages of as_rtftables(page_by = ) in a plan – the column(s) whose value partitions the body first, each value a page named after it, the row budget (max_rows, split, …) then applying within one partition. Before, a plan could page by a value only with plan_paginate_group() (one page per value, no row budget), and a report needing both had to be two plans with their pages interleaved by hand. Added only: a plan without page_by gives what it gave.

  • An analysis variable’s levels can be relabelled (#514): plan_labels(SEX = c(F = "Female", M = "Male")) – and widen_ard(labels = list(SEX = c(F = "Female"))) – now recode the levels of the analysis variable SEX in the label column, the way plan_levels(SEX = c("M", "F")) already ordered them (the order stays written in the values). A scope named an output column only before, so no table that printed before prints differently. The dictionary’s entry under the variable’s own name labels the variable itself: SEX = c(SEX = "Sex", F = "Female", M = "Male").

CRAN preparation

  • Every example runs (#512). The 23 \dontrun{} examples are runnable now: they write to tempdir(), make the PNG they place, run a plot preview only in an interactive session and gt / gtsummary only when installed. The help pages are ASCII but for what LaTeX sets (the PDF manual could not have been made from a Japanese example and an approximately-equal sign).
  • The quickstart vignette uses the base pipe and runs its ggplot2 chunks only when ggplot2 is installed; tests that call dplyr skip without it.
  • The example RTFs’ PNG screenshots are left out of the package build (5.3 MB installed to about 3.5 MB); they stay in the repository and on the site.
  • Language: en-GB (the text is British) and inst/WORDLIST; cran-comments.md; lintr and covr are no longer in Suggests (the CI workflows install them); four URLs that answered 404 fixed.
  • A first Linux R CMD check --as-cran with the PDF manual (#524). paginate()’s deprecation warning named the internal helper ('.warn_paginate_deprecated' is deprecated) and, fired from the example, was the one WARNING of that check: it names paginate() now and the example does not trigger it. Five wide tables in the help pages (plan_verbs, table_plan, paginate_cols) ran off the page of the PDF manual – one of them by 15 cm – because an Rd \tabular cell cannot wrap; they are bullet lists now. One test-ard-tables.R test needed broom (through cardx), which the package does not declare: it now skips without it, as R CMD check --as-cran (suggests-only library) requires.
  • Follow-up check on R 4.6.1 with every Suggests, tern included (#526). The check is clean but for the CRAN-incoming NOTE. Two test-num-format.R tests set options(scipen = -100), which R 4.6 clamps with a warning; they use -9. The helper that makes a PNG for test-print-methods.R failed with figure margins too large on its 120 x 80 px device, so its test was skipped everywhere; it passes now.
  • Test coverage 90.0 % -> 95.3 % (#551). One new file, test-coverage-edges.R, pins argument checks and their wording, print methods, the console preview, hand-read image headers, header cell sheets and plan_layers(), deprecated arguments warning once, and empty / one-row / one-column data. Tests only; the five defects it met are #546 - #550.

Bug fixes

  • An ARD plan’s non-default plan_columns(sep = ) built no spanning column header (#520). The plan widens the ARD into column names with its own separator (“Placebo–F” for sep = "--"), but as_rtftables()’s header_sep – what actually splits those names back into header rows – stayed at the default "____" unless plan_col_header(header_sep = ) was also set, contradicting plan_columns(sep = )’s own documentation that the header is split on it. The plan’s separator is now passed through as header_sep whenever the user has not set one explicitly. Table-input (data.frame) plans, and ARD plans that keep the default separator, give the same RTF as before.

  • A row split by set_decimal_split() lost the table’s font size and the cell fill (#509). The split data rows were written without the table’s own font switch (rtftable(font_size_half_points = , font = )), so their numbers fell back to the document’s size and font, and without the background of their column or of cell_styles, so a shaded column had unshaded rows wherever a number was split. Both now reach every cell of a split row – a pair merged back into one cell takes its original column’s fill. Tables without set_decimal_split() are byte-identical.

  • plan_apply() blamed a missing cols on unrelated verbs (#507). A plan with no cols role stopped with “cols is required” followed by “declared by:” and the widen-stage verbs, such as plan_cells(notes = FALSE), none of which had anything to do with it. A missing declaration has no layer to blame, so the error now says where to declare it (table_plan(cols = )) and lists no verbs.

  • A listing key column printed on every line of a record when a key column to its left wrapped (#505). With listing_col("TRT", width = 12, collapse_repeats = TRUE) before listing_col("USUBJID", collapse_repeats = TRUE), a treatment that wrapped (“Xanomeline” / “High Dose”) made the subject print on both lines. The repeats of a key are blanked hierarchically, and the wrapped cell changed its text from line to line. The lines of one record now count as one value, so a key prints on its record’s first line (and again at the top of a continuation page) whatever the other keys’ wrapping. Listings with no wrapping key are byte-identical.

Documentation

  • Development reopens at 0.8.2.9000 (#500), after the 0.8.2 release.

  • The plan and ARD route on the site (#503). Four new articles: Tables from an ARD, The plan verbs, Listings with a plan and From as_rtftables() to a plan, each run on pharmaverseadam with the RTF shown. Get started ends with a short section on tables from an ARD, and the README and the site menus point to the articles.

rtfreporter 0.8.2

Tables from a cards / cardx analysis results dataset (ARD): the plan engine and the ARD helpers, adopted in the pre-CRAN API review. Plus two fixes found by the Python port’s byte-for-byte cross-check and by the plan work. Nothing that rendered before renders differently: the six sample reports are byte-identical.

New features

  • Tables from a cards / cardx ARD (#491, #498; plan E of the maintainer’s design discussion). The engine that turns an analysis results dataset into an rtftable moves here from tflspec, so that a plan and the rendering it declares live in one package and a generated table program needs only rtfreporter (and cards).

    These are rtfreporter’s functions, not cards’; cards stays a suggestion, and the Excel definition of a table stays in tflspec (tfl_table_plan()). Four users’ sample reports written as plans render byte-identical RTF to their hand-written programs.

Bug fixes

  • A title or footnote block given font_size_half_points but no font registered a font named after the size (#496). rtf_footnotes(list(...), font_size_half_points = 16) wrote {\f1\fnil\fcharset0 16;} into the font table and set the block in \f1 – a family called “16”, which Word substitutes. The style list had no font entry, and $font partial-matched font_size_half_points. The renderer, the footnote band and the font collector now read the entry by exact name. Found by the Python port’s byte-for-byte cross-check against v0.8.1.

  • as_rtftables(cell_styles = ) follows its rows across pages (#498). A cell_styles of your own, one element per body row, was handed whole to every page, so a table that split onto a second page failed with “cell_styles length (9) must equal the number of data rows (6)”. It now goes where the rows go – through the stub, the sort and the page split – as an adapter’s cell styles always have. A single page is unchanged.

Documentation

  • The AI manuals cover the plan (rtfreporter_ai_manual(), #463). The user manual has a chapter on tables from an ARD (the flow, every verb and its arguments, which verb says what, the as_rtftables() arguments each one takes over, and the names that do not exist); the developer manual one on the plan engine (layers, last wins, resolution, the testing contract). A new test checks every argument named in the user manual’s code against the function’s formals(), and runs the plan examples.

rtfreporter 0.8.1

A patch release of the fixes and small additions made since 0.8.0. One behaviour change: a page now gathers every row with its page_by / split = "by_value" value instead of cutting on runs (#485); on an already-grouped body – the usual one – the output is unchanged, and all six sample reports are byte-identical.

Behaviour changes

  • A page gathers its rows; it does not require the body sorted first. page_by and split = "by_value" partitioned the body on RUNS of the key, so a value that came back later started another page. On an interleaved body that is not a different page order – it is half a page: a Solicited AE report built from an unsorted table came out as twelve 7-row pages instead of six 14-row ones, with nothing said about it.

    Needing the rows pre-sorted is SAS’s contract, not R’s. R subsets by value and leaves the order alone, so a page now gathers every row that has its value, scattered or not, and the body’s own order stands – inside a page and between pages. On an already-grouped body, the usual one, this is exactly what the runs were, and all six sample reports are byte-identical.

    The two splits had also disagreed with each other: by_value combined with stub_vars has always gathered (it partitions the raw body by the value before building the stub), while the plain by_value path cut the same table into one page per block. They now read the key the same way.

    An indent or filled group is untouched: there the group is a position in the body rather than a key, so there is nothing to gather by. sort_by is for choosing an order, not for making the pages whole. (#485)

New features

  • as_rtftables() is an S3 generic (#487). A package that depends on rtfreporter can make its own class a table source by registering as_rtftables.<class>(), and rtf_tables(doc, x) then accepts that object directly, converting it up front the way it converts a gt_tbl. rtfreporter names no such class: gt, rtables and the others keep their built-in adapters, because they do not know rtfreporter, while a package that does know it supplies the method itself. The function you call is unchanged; its body is now as_rtftables.default(). See “Supplying a method from your own package” in the adapter article.

  • Run tokens: {PROGRAM}, {PROGRAM_NAME}, {PROGRAM_DIR}, {DATETIME} (#478). Any header, footer, title or footnote cell can say which program wrote the file and when, filled as the file is written, like {PAGE}. The program is generate_rtfreport(program = ), else options(rtfreporter.program = ), else the script Rscript runs; {DATETIME} is %d%b%Y %H:%M in the C locale (options(rtfreporter.datetime_format = )), and {DATETIME:<format>} takes any other. The time is taken once per file, and options(rtfreporter.render_time = ) fixes it for reproducible output. The run-information footer every study writes a helper for – switch the locale, format the time, paste the path, append the row – is one row: c(l = "{PROGRAM} Generated on: {DATETIME}"), and it now stamps the time the file was generated rather than the time its footer was built. A document can carry its own program, rtf_document(program = ), which generate_rtfreport() reads before the option.

  • One rounding rule for the whole package: round_num() and options(rtfreporter.rounding = ) (#476).

    "r" (the default) rounds an exact half to the even digit, as base::round() does; "sas" rounds it away from zero, as SAS ROUND() does, and absorbs a binary representation error the way SAS’s fuzz does (2.675 -> 2.68). A study that has to match a SAS-produced table now says so once, and fmt_signif(), fmt_round(), fmt_numeric() and format_count_pct() all follow it; an explicit rounding = still wins. round_num() is the same rule on a numeric vector, and the option is reported by rtfreporter_options().

Bug fixes

  • print() of an rtftable: n = Inf shows every row, and a spanning header label with a line break stacks its lines (#489). n = Inf failed (as.integer(Inf) is NA); a spanner such as col_cell(2:3, "Placebo\n(N=86)") was drawn as one line, breaking the preview. The RTF was never affected.

  • A header row built with c() now says so. An rtf_col_cell is a list underneath, so c(list(col_cell(1, "")), col_cell(c(2, 4), "S")) splices the second cell into its own fields: the row becomes list(cell, 2:4, "S") – three elements, not two. The first $pos then failed with R’s $ operator is invalid for atomic vectors, which names neither the row nor the c().

    It now names both, and the fix: list(col_cell(1, ""), col_cell(c(2, 4), "S")). A list without a pos is a different mistake and keeps its own missing \pos`message.c(list(…), lapply(…))is still fine, becauselapply()` returns a list. (#485)

  • format_count_pct() rounds the percent with the package rule (#476). It used sprintf()’s own rounding, so 6.25% printed 6.2 even for a study that rounds like SAS; it now takes rounding = like every other formatter. Rounding first also fixes the width: 9.96 used to take the “< 10” branch and print a misaligned 10.0, and 99.96 now prints (100).

Documentation

  • The help for as_rtftables(split = ) described "group_force" wrongly, which is how a report ends up with a group cut in half when the page looked as though it had room. It said "group_force" was "group_safe" with an escape hatch for a group too big to fit. It is not: it cuts every max_rows rows wherever that falls, which is what the implementation has always done and what makes the pages come out the same height. A group straddling the cut is split and the next page opens with a continuation label.

    "group_safe" is the one that keeps a group together – a group that does not fit in what is left starts the next page – and it still splits a group that on its own exceeds max_rows, because that one has to be split.

    Measured, and now in the help and a test: ten groups of four rows with max_rows = 30 and the blanks counted give 26 and 26 rows under "group_safe", and 30 and 24 under "group_force" with the sixth group cut in half. Only the help changed; no report’s pagination moves. (#485)

  • The pre-1.0 exception and the hotfix procedure are written down (#471).

    MAJOR 0 means the package is not formally released, so within 0.y.z a MINOR may remove or change public API – semver’s own reading of a zero major. That is what makes the plan legal: v0.9.0 removes the seven deprecated border exports and is the first CRAN submission, while v1.0.0 is cut after CRAN registration, once downloads and feedback have settled the API. Registering is what produces the users whose feedback tells you whether the API is worth freezing; declaring 1.0.0 first would promise stability before anyone had tried it.

    CONTRIBUTING.md, _pkgdown.yml, README.md and R/rtf_border.R previously disagreed about which of 0.9.0 / 1.0.0 was the CRAN debut; they now say the same thing, and the backward-compatibility contract carries the 0.y.z carve-out instead of forbidding what 0.9.0 will do.

    The new urgent fix while main carries a feature procedure answers the case the release steps did not: there is no patch that contains a feature, so the question is whether the feature’s API is ready to freeze, not how urgent the fix is. If it is, release main as a MINOR; if not, cut a hotfix from the release tag. It also records the step that is easy to miss – after tagging 0.8.1, main still reads 0.8.0.9000, which is lower than the version just released, so update.packages() would replace a development install with the release build and silently drop the unreleased feature. main must move to 0.8.1.9000.

  • The bare manual link tracks the release, and the folder keeps only what is useful (#468). ai/rtfreporter-ai-user-manual.md was written from the current build, so on main it served the development version – and a bookmark of the unversioned name quietly started describing API that is in no release, which is the failure #463 existed to stop.

    It now resolves to the newest release, which is what install_github() gives whoever follows it; the development copy is published beside it under its own stamped version for anyone who wants main. The folder holds every release that shipped the manuals plus the current development build, and nothing else: a superseded development copy has no use, and a stale one found by search is a trap.

  • LEARNING.md and the architecture article describe the code that exists (#456). Both had drifted far enough to mislead: LEARNING.md presented rtf_theme as a live R6 class with a worked example, pointed at a R/rtf_theme.R that is not there, claimed R6 was in Suggests: (it is in no field of DESCRIPTION), recommended two deprecated border helpers and linked a vignette that does not exist; architecture.Rmd and its -ja twin still said Imports: was methods only.

    LEARNING.md is rewritten rather than deleted. Its post-mortem – why the R6 scaffolds, the chained border builders and the shared mutable theme each failed to pay – is recorded nowhere else, and without it the S3-only invariant is a rule with no reason attached, which is the kind a later contributor overturns. The document now ends where the code did: the one class that did look justified was deleted too, because the argument for it was sound in the abstract and worth nothing in a workflow where a report script runs once, top to bottom.

    The architecture articles now state the real Imports: set (grDevices, grid, methods, utils – all shipped with R, so the no-third-party- dependency invariant is intact) and name .need_pkg() as the guard. The AI developer manual’s warnings about both documents are removed, being no longer true.

  • Development reopens at 0.8.0.9000 (#465) – the first cycle under the standard R scheme, where the three-component part names the last release.

    The AI manuals are stamped with the development version they describe, while the home-page and navbar links name the newest release – the copy that matches what install_github() gives a reader. So the links change once per release rather than every time the development counter moves.

    The pkgdown workflow now regenerates every released manual from its own tag on each build (git show <tag>:inst/ai/...), so the version-stamped copies on the site are reproducible rather than dependent on gh-pages branch state – which is rewritten on every deploy (force_orphan).

rtfreporter 0.8.0

Documentation

  • The AI manuals ship with the package, and say which version they document (#463). They were published from main only, at a fixed URL, with no version on them – and development: mode: unreleased means the site root is overwritten on every push. So the manual described main while the reader was on whichever release they had installed:

    v0.4.0  -- the release the README points at : 59 exports
    main    -- what the published manual covers : 90 exports

    col_key(), stub_spec(), set_col_header() and rtf_columns() were among the 31 functions the published manual described that a v0.4.0 user did not have, and an assistant cannot tell: it quotes a function that will not resolve exactly as confidently as one that will.

    The manuals now live in inst/ai/, so they install with the package and are the same artefact as the code they document. New rtfreporter_ai_manual() hands them back:

    rtfreporter_ai_manual()                     # path to the user manual
    rtfreporter_ai_manual("dev")                # the developer manual
    rtfreporter_ai_manual(file = "manual.md")   # copy it out, ready to attach

    Each manual states the version it was built from in its first lines, and a test asserts that against packageVersion(), so a stale copy announces itself instead of being followed. The site still publishes them – now under ai/, both version-stamped (ai/rtfreporter-ai-user-manual-0.7.56.md) and as a stable alias – and the home-page and navbar links name the version, which a test keeps in step with DESCRIPTION.

    The home-page section also moves below the 30-second example: Why rtfreporter?, Installation and a worked example should come first.

  • The AI user manual gains a program-structure section (#461). The manual said which function to call but nothing about how to arrange a report program, so an assistant produced one long script that computed, formatted and wrote the file in a single pass – fine for one table, unmaintainable for a fifty-output deliverable.

    Section 3 now states the six stages (setup / data / body / present / pages / render) and the four rules that make them generalize: the body stage returns a plain data.frame and knows no RTF; the pages stage returns pages and writes nothing, so generate_rtfreport() is the only side effect and it comes last; a per-page denominator goes through set_col_header(values = ) rather than into a sprintf(); and widths follow the layout (rep(2, length(days) * length(arms))) instead of a hard-coded count.

    It also splits column headers into two tiers – a named row inline in set_col_header() for the simple case, a separately built rtf_col_header() with col_cell() / col_key() when there are spanning cells or per-page values – and composes the running header from a function, so the study block is stated once across the deliverable rather than in every program.

    Generalized from a real production template. The skeleton is executed by test-ai-user-manual.R like every other example in the manual. Sections 3..17 shifted to 4..18.

  • AI assistant manuals, downloadable from the docs site (#454). The package is new enough that a general-purpose chat assistant has never seen it: asked for rtfreporter code it reaches for r2rtf verbs or invents arguments, and the reader cannot tell which parts are real. Two self-contained Markdown briefings now ship as static site assets, each sized to sit in one chat session:

    • rtfreporter-ai-user-manual.md – the workflow, every as_rtftables() argument, the four clinical table shapes, headers and page tokens, listings, figures, borders, the complete export list, and the mistakes assistants actually make.
    • rtfreporter-ai-dev-manual.md – the invariants, the S3 object model and rendering pipeline, the as_rtftables() kwargs contract, how to add a table-object adapter, the test / lint / docs conventions, and the issue -> PR -> release workflow.

    They are deliberately two files, attached one at a time: the user manual forbids touching internals and the developer manual requires it, so handing an assistant both blunts each. Each opens with a scope declaration.

    A stale manual is worse than none, because an assistant cannot tell one from the other, so both are tested: the user manual’s export list must match NAMESPACE exactly, no deprecated export may appear in a code block, every idiom it teaches is executed (including the ones it documents as errors), and the developer manual’s file map, Imports: list and S3-only claim are checked against the tree. A manual that drifts from the package fails CI.

    Linked from the home page (Coding with an AI assistant) and the Articles menu.

New features

  • col_cell(pos = ) accepts a selector function, and col_key() builds one (#451). Data columns of a wide table are usually named by rule, as <group>____<sub-group>; writing a spanning header cell as c(2, 9) is exactly where a label gets attached to the wrong group, silently. A cell may now say what it covers instead:

    rtf_col_header(list(
      col_cell(1L,                 ""),
      col_cell(col_key("Placebo"), "Placebo\n(N=60)"),
      col_cell(col_key("Total"),   "Total\n(N=180)")
    ))

    col_key(key, sep = NULL, part = 1L) keeps the columns whose part-th separator-delimited segment is one of key. sep = NULL auto-detects the separator already understood elsewhere in the package (as_rtftables(header_sep = ), paginate_cols(by = )); part may be negative to count from the right.

    Because col_key() simply returns a function of the column names, glob and regular-expression selection need no further vocabulary:

    col_cell(function(nm) grepl(glob2rx("Placebo____*"), nm), "Placebo")

    A selector is resolved when the header is attached to a table, and it is an error for one to match no column or to match non-adjacent columns (a header cell can only span a contiguous range) – so a misspelt key or an interleaved column layout stops the run instead of mislabelling it.

Breaking changes

  • Page names are unique in the list and headings where they are printed (#440). Since a page name became a heading (#433, #437), the pages of one group carry the same one — and a repeated list name is a bad key, because R returns the first match for it:

    l <- list(a = "PAGE-1", a = "PAGE-2");  l[["a"]]   #> "PAGE-1"

    RStudio’s View() and object inspector address elements by name, so a paginated list looked as though every page held page 1’s columns and page 1’s stub — the pages were right all along.

    A heading used by several pages now carries a "...n" tail:

    as_rtftables(lab, page_by = "period", split = "group_safe", max_rows = 8)
    #> "Period 1...1"  "Period 1...2"  "Period 2...1"  "Period 2...2"

    and everything that uses the name as a heading strips it first — auto_section (both the comparison and the printed label) and auto_title — so those four pages are still two sections, headed "Period 1" and "Period 2". A heading used once carries no tail.

  • A page split on two axes is named by the outer one alone (#437). With group_col and page_by the name joined the two — "Period 1.1" for the group "Period 1" and the BY value "1", which reads exactly like the page numbering #433 had just removed. More than cosmetic: a name is the section heading and the line auto_section breaks on, so joining them cut one group into a section per BY value.

    The name is now the outermost value: the group when a value-based split made one, the page_by value otherwise. Both are readable per page in attr(page$data, "rtf_paginate_meta") (page_group, page_by), which paginate_cols() also nests by — and that meta now survives the column split, where subsetting the body used to drop it.

  • A page name is a heading, not an identifier (#433). Two changes that go together.

    No ".1" / ".2" suffix. When one logical table became several pages, the splitters numbered the pages apart — "Period 1.1", "Period 1.2" — and that suffix went straight into the deliverable, because the name is what rtf_tables(auto_section = TRUE) prints as the section heading. Every page of one partition, group or list element now carries the same name:

    as_rtftables(lab, page_by = "period", split = "group_safe", max_rows = 8)
    #> "Period 1"  "Period 1"  "Period 2"  "Period 2"

    It applies wherever pages were numbered apart: a "by_value" group that outgrew max_rows, a page_by cell, and a named list element that split (as_rtftables() and the deprecated paginate()). A page split on two axes (group_col and page_by) is named by the outer one alone — the two values are never joined, and both stay readable in the page’s rtf_paginate_meta.

    Equal consecutive names are one section. auto_section opened a section at every named element, so repeated names gave a section per page. A section now opens where the name changes: a run of pages sharing a heading is one section of several pages. An empty name still falls through into the open section — so combine_sections() and the blank-the-name recipe are unchanged, and A B A still opens three sections.

    Nothing renders differently (each page already carried the right heading text), but the RTF structure now matches what the names say, and the strip-the-suffix / blank-the-name bookkeeping that callers had to do to get one section per group is no longer needed.

  • {SECTION_PAGES} is removed (#410). The token wrote an RTF SECTIONPAGES field — the page count of one rtf_section. It was either redundant or wrong, with nothing in between:

    • Standalone, one file is one section is one document, so SECTIONPAGES equals NUMPAGES and {AUTO_TOTAL_PAGES} already gave the same number.
    • After assemble_rtf(), the section survives as one deliverable, so it keeps counting that table (3, 2) while the only numerator available — {AUTO_PAGE} — counts the whole document (1..5). \pgnrestart is not emitted on assembly, so the one expression a user could write, Page {AUTO_PAGE} of {SECTION_PAGES}, read “Page 4 of 2” once the deliverable was bound.

    Making it coherent would need a “page within this section” numerator, which requires \pgnrestart per section — and that destroys the document-wide numbering assemble_rtf() exists to produce.

    Using the token now errors, naming the replacement, rather than falling through: an unrecognised token is passed along as literal text, and {SECTION_PAGES} printed into a rendered deliverable is worse than a stop.

    want use
    per-table total, surviving assembly {TOTAL_PAGES} (static)
    document total {AUTO_TOTAL_PAGES}
  • as_rtftables() now speaks the same table-width vocabulary as rtftable() (#382). Two settings did not mean there what they mean everywhere else.

    • table_width_twips was ignored. It is a formal of as_rtftables() and was read only inside the auto_width branch, so it never reached rtftable() – and, being a formal, it could not pass through ... either. as_rtftables(df, table_width_twips = 6000) asked for a 6000-twip table and rendered one 13680 wide. There was no spelling that worked.
    • auto_width = TRUE ignored table_width_pct. The branch took its budget from table_width_twips alone and fell back to the default page, and the per-column widths it then set short-circuited the renderer’s total-width logic, so the percentage was silently dropped. “Size the columns within half the page” could not be expressed.

    The total width is now resolved once – absolute, else the percentage, else the default – and used both as the auto-sizing budget and as the table’s width. The vocabulary, unchanged and now uniform wherever it is written:

    column_widths_twips per column, absolute; wins outright
    col_rel_width per column, relative; apportions the total
    table_width_twips the total, absolute
    table_width_pct the total, as a share of the writable width
    nothing the writable width, split equally

    table_width_twips and table_width_pct are two spellings of one thing, so the absolute wins; neither has anything to do with auto_width, which decides the columns rather than the total. rtftable() and rtf_tables() were already right and are unchanged – measured on every row of that table.

    Existing code that passed table_width_twips to as_rtftables() was getting nothing; it now gets the width it asked for.

  • A listing cell now wraps by display width, and every line it produces fits its column (#364). Two defects in build_listing()’s wrapping, both of which made the line count disagree with what Word renders – and the line count is what pages the listing.

    • Display width. Widths were counted with nchar(), so a full-width (CJK) glyph counted as one. A Japanese listing wrapped to width = 20 asked for twenty columns and occupied up to forty; Word then re-wrapped it, adding rows this package had not counted. Widths are now display widths, a full-width glyph counting as two.
    • A token wider than the column is hard-split. It used to keep its own line, deliberately – cutting a subject id in half is ugly. That was wrong: rel_width follows width, so such a token is wider than the rendered column too, Word wraps it, and the record is a row taller than build_listing() counted. Every line now fits the column it was measured against.

    Both were found by reading the listing prototype in ydisctools, which had already solved them. Where the rule is not wrong it is left alone: the greedy word fill still measures the running line untrimmed, as the hand-written pipeline did, so existing line counts do not move for that reason.

    Line counts change for cells containing full-width glyphs or a token wider than its column; a listing of plain ASCII whose values break normally is unaffected.

  • count_blank_rows = TRUE now means what it says: max_rows is the number of rows the page prints (#362). The blank_row_first / blank_row_end page edges were printed but never counted, so as_rtftables(max_rows = 40, blank_row_first = TRUE, blank_row_end = TRUE) could put 42 rows on a sheet sized for 40. count_blank_rows = TRUE did not help: only the positions from blank_rows and the rtf_blank_rows attribute were materialised, and the materialiser keeps positions 1..nrow, which structurally excludes the two edges.

    The two settings now divide cleanly:

    • TRUE – max_rows counts every row the page prints: body rows, blank rows already in the data, between-group blanks, and the page edges. A page never overflows the budget.
    • FALSE (default, unchanged) – max_rows counts only the rows that were in the input; nothing inserted counts.

    The edges are counted as they render, not reserved as a flat two rows. blank_row_normalize merges a page-edge blank into a blank row it sits against, so an edge costs a row only where there is not one already – which matters for a listing, whose every record block ends in a blank row and would otherwise lose a row at the foot of every page. A column that drop_cols hides cannot make a row count as non-blank, since it is not on the page.

    This changes pagination for existing code that passes count_blank_rows = TRUE together with blank_row_first / blank_row_end: those pages now hold fewer rows, which is the point. Nothing else moves.

    It is also what makes a listing paginate like the hand-written pipeline it replaces. That code carried the page’s leading blank row inside its 40, so k records fitted while 1 + k x block <= 40; split = "group_safe" had one row more of budget and took one record more per page – enough to push the page past what Word could fit:

    record block hand-written before #362 with count_blank_rows = TRUE
    4 rows 9 10 9
    5 rows 7 8 7
    8 rows 4 5 4
    10 rows 3 4 3

Bug fixes

  • The gt adapter keeps the source column names verbatim (#458). A gt table whose columns are not syntactic R names came out renamed, because the extracted body was passed through make.names():

    src <- data.frame(`Drug A (N=60)` = "54.2 (11.3)", `2024 total` = "112",
                      check.names = FALSE)
    names(as_rtftables(gt::gt(src))[[1]]$data)
    #> before: "Drug.A..N.60."  "X2024.total"
    #> now:    "Drug A (N=60)"  "2024 total"

    The rendering was never wrong – display labels come from col_header, so the header still read Drug A (N=60). What broke was addressing a column by name, which is the style the package recommends because a name survives a reordering where a position does not: set_col_header(name = ), col_key() and drop_cols = all rejected the name the caller had written, and the caller had to discover and use Drug.A..N.60. instead. That also contradicted the Importing tables article, which has always promised that gt-based sources keep their id names.

    A data.frame does not need syntactic names – the frame is built with check.names = FALSE two lines earlier for exactly that reason – and nothing downstream needs them either, since columns are resolved by name string or by position. make.unique() stays, for a collision that has nothing to do with syntax: the stub is renamed to "rowname", so a source column literally called rowname still becomes rowname / rowname.1.

    make.names() is a no-op for ordinary identifiers, so gtsummary (label, stat_1, …) and most gt tables are unaffected. Code written against a mangled name will need the real one. tfrmt reaches the same code path, so column levels containing a space or a parenthesis are fixed too.

  • A named column-header label row may be shorter than the table (#453). A label row that names its entries says which column each label belongs to, so it does not need one entry per column – the columns it does not mention keep their own name. That patch form worked for a single name and for a full-length row, but a row of any length in between was refused:

    tbl <- rtftable(df, col_header = names(df))   # row_label | g1 | g2 | Total
    
    set_col_header(tbl, c(g1 = "Low"))                    # worked
    set_col_header(tbl, c(g1 = "Low", g2 = "High"))       # Error
    set_col_header(tbl, c(row_label = "Category", g1 = "Low",
                          g2 = "High", Total = "Total"))   # worked

    The width check that refuses a header written for the whole table and applied after paginate_cols() decided on length alone and never looked at names(); length 1 rode through the escape hatch kept for the "a | b | c" pipe-string form. It now steps aside for a fully named row, which cannot misalign because every label is resolved against a printed column name – and an unknown name or a partly named row is still an error, from the resolver, naming the offending column. The guard on unnamed rows is unchanged. Applies to set_col_header(), rtftable(col_header = ) and as_rtftables(col_header = ) alike.

  • paginate_cols(col_header = ) resolves col_cell() rows before slicing them (#442). A header written with spanning cells was stored as given and only re-indexed, so the renderer met a col_cell()’s pos where it expects a resolved {from, to}, and generate_rtfreport() failed with 'length = 2' in coercion to 'logical(1)'. It is now resolved once against the whole table — the coordinates the caller wrote it in — and each page takes its slice, which is what every other entry point (rtftable(), set_col_header()) has always done. A plain label row, the case the tests covered, needed no resolution and worked.

  • paginate_cols(col_header = ) resolves col_cell() rows before slicing them (#442). A header written with spanning cells was stored as given and only re-indexed, so the renderer met a col_cell()’s pos where it expects a resolved {from, to} and generate_rtfreport() failed with 'length = 2' in coercion to 'logical(1)'. It is now resolved once against the whole table – the coordinates the caller wrote it in – and each page takes its slice, which is what every other entry point (rtftable(), set_col_header()) has always done. A plain label row, the case the tests covered, needed no resolution and worked.

  • split = "by_value" + a stub accepts the pre-stub group_col its own docs promise (#429).

    as_rtftables(ae, split = "by_value", group_col = "soc",
                 stub_vars = c("soc", "pt"))
    #> Error: `group_col` 'soc' not found in the table.

    That combination takes a dedicated branch — the body is split by group_col first and the stub is built per page — and ?as_rtftables says so: “group_col here refers to the pre-stub columns”. The split itself did exactly that, but group_col was then also forwarded into the per-group build, where the stub had just consumed the column it names; the eager resolution inside set_blank_rows() raised on an argument nothing was going to use.

    group_col is spent by the time that branch builds a page — the body was split by it and each sub-body is one group — so it is no longer forwarded, the same treatment page_by gets there (#427). One consequence worth knowing: inside such a page, a blank_rows = "between_groups" spec now detects groups on the stub column rather than on the (constant) split column, so it separates the stub’s own groups — which is what a stub page wants and what it previously failed to do.

  • page_by no longer splits one row per page when a stub is built (#427). stub_cols() inserts a label row per hierarchy level — the stub column carries the label text and every other column is NA, the page_by column among them. Keyed literally, each of those rows read as a new BY value, became a page of its own (holding just the label) and split the run it was introducing in two. A missing BY value is now a continuation: it takes the value of the row it introduces, and at the foot of the body of the row it follows.

    In the same interaction, split = "by_value" + a stub takes a dedicated branch — it splits by group_col first and builds the stub per page — which did not know about page_by, so with both set the nesting inverted (the group outside, the BY value inside) and the per-group naming overwrote the page names, losing the BY value. That branch now partitions by page_by first, so it stays the outer level and the names compose as "<BY>.<group>".

    Both are regressions in page_by itself, which is new and unreleased (#423), so nothing that ever worked changes.

New features

  • set_col_header(values = ) fills {tokens} per page (#449). A header is usually one shape with a few values that change page to page — (N=120) for one period, (N=118) for the next — and building it per page meant rebuilding the whole header in a loop and hoping the labels lined up with the columns.

    hdr <- rtf_col_header(
      list(col_cell(1L, ""),
           col_cell(c(2L, 9L), "Placebo
    (N={n_pbo})
    n(%)"),
           col_cell(c(10L, 17L), "TAK-003
    (N={n_trt})
    n(%)")),
      c("Parameter", rep(VISITS, 2)))
    
    vals <- data.frame(group = periods, n_pbo = c(120, 118, 238),
                       n_trt = c(115, 112, 227))
    
    pages |> set_col_header(hdr, values = vals) |> paginate_cols(by = "____")

    The key column is named after the axis it matches — group (default), rows, name, or a combination — the same vocabulary paginate_cols(page_order = ) uses, and the keys live in the page’s metadata so they survive drop_cols and the column split. Nothing is matched by position.

    A token is name; {{ is a literal brace; the render-time tokens ({PAGE}, {TOTAL_PAGES}, {DATE}, {BOOK_PAGE}, {AUTO_*}) are left for the renderer. Every other mistake stops the call rather than printing a wrong header: a page with no row, a row no page used, a token with no value.

  • header_map() shows what each page’s header became — one row per header cell, with the page’s keys, the columns the cell covers and its final text. For eyeballing a values mapping, and for asserting it in a test.

  • fmt_value_paren() prints 100% without decimals (#447). The formatter pads a parenthetical but never rewrote it, so a percentage of 100 kept the decimals it was given — while format_count_pct(), what align_count_pct = TRUE applies, has always formatted that branch as an integer:

    fmt_value_paren(c("12 (100.0)", "6 (50.0)", "1 (8.3)"))
    #> "12  (100)"  " 6 (50.0)"  " 1  (8.3)"

    The % is kept ("(100.0%)" → "(100%)") and a value above 100 rounds the same way. Only a plain number is rewritten: "<100.0", "BLQ" and any other notation stay exactly as written — which is what the formatter is for.

  • page_order names the three split axes, outermost first (#444). A paginated table can be split three ways — the pages a value-based split makes (group_col), the row pages (page_by and every other row split), and the column blocks (paginate_cols()) — and the group was always outermost, with page_order choosing only between the other two.

    paginate_cols(pages, by = "____", page_order = c("group", "cols", "rows"))
    paginate_cols(pages, at = 6,      page_order = c("cols", "group", "rows"))
    paginate_cols(pages, at = 6,      page_order = "cols")   # cols first, rest as usual

    All six orders are now expressible. An axis left out is appended in the default order ("group", "rows", "cols"), and an axis the table does not have never varies, so one call works whether or not group_col / page_by were used. "across" and "down" stay as shorthands for c("group", "cols", "rows") and c("group", "rows", "cols"); an unknown or repeated axis is an error.

    The pages are the same whatever the order — it is a sort, on each page’s own rtf_paginate_meta$page_group plus its position and its column block, never on a page name. Worth knowing: rtf_tables(auto_section = TRUE) sections on the page name, and a page is named by its group, so a group is one section only while "group" comes first.

  • Column headers across a column split: paginate_cols(by = , col_header = ) and a width guard (#435). A header written for the whole table and applied after paginate_cols() landed on every page unchanged, so each one printed the first page’s labels over its own columns — silently.

    A plain label row is one label per printed column, so a mismatch is now an error, from set_col_header() and from the rtf_tables(col_header = ) override, naming both widths and the fix.

    The fix being: write the header once, at the split.

    paginate_cols(pages, at = c(11, 19), carry = 1, col_header = HDR)

    col_header takes a header in the full table’s coordinates and slices it per page, spanning cells clipped — the same treatment a header already on the table gets.

    by = cuts where the column names say, instead of at positions: give it the separator of a <group>____<visit> layout and each run of one group becomes a block (or give one key per column). With col_header = "names" the two-level header those names already carry is built per page — the group spanning its columns, the visit below:

    as_rtftables(df, split = "by_value", group_col = "period",
                 stub_vars = c("row_grp1", "label")) |>
      paginate_cols(by = "____", carry = 1, col_header = "names")
    #>  |            |   Placebo    |     |            |   HOGE-001   |
    #>  | Group      | D1 | D2 | D8 |     | Group      | D1 | D2 | D8 |
  • as_rtftables(page_by = ): one page per BY value, with the inner grouping still protected (#423). group_col says where a group boundary is; page_by says where a page boundary is, and what the page is called.

    as_rtftables(lab,
                 page_by   = "period",      # new page when it changes, named by it
                 split     = "group_safe",  # from here on: inside one period only
                 group_by  = "indent",
                 max_rows  = 20,
                 drop_cols = "period")
    #>  "Period 1"  "Period 1"  "Period 2"  "Period 2"  "Period 3"

    The body is partitioned on runs of the page_by value(s) first, and every other pagination setting — split, max_rows, group_col, group_by, min_group_rows, cont_label, blank_rows, collapse_repeats — then applies within one partition. split = "by_value" on its own could not express this: the group it protects is the BY column itself, so a parameter block inside a period was cut wherever max_rows happened to fall. The by-hand recipe (split() the data, drop the BY column, pass a named list) is now one argument — and its trap is gone too, because group_col left NULL defaults to the first column not named in page_by, never the BY column whose value is constant within a page.

    Every page a partition makes carries the same name — the page_by value — which feeds rtf_tables(auto_section = TRUE) / auto_title = TRUE directly, one partition to one section. When the split itself names pages ("by_value"), the group is the outer axis and page_by the inner one: a group’s pages stay together, the BY values running in order inside it, and every one of them carries the group’s name — the section heading is the group, and the BY value is in the page’s rtf_paginate_meta$page_by.

    page_by cuts pages, split then cuts rows inside each one, and paginate_cols() then cuts columns on the result: three independent axes in one pipeline. na and cell_format still run body-wide, before the partition, so one column width is shared by every page. With page_by = NULL nothing changes anywhere — "by_value" still takes its page names from group_col.

  • paginate_cols(page_order = ) chooses which axis the page number advances along (#417). When a table is split both ways — rows by as_rtftables(), columns by paginate_cols() — the page order was fixed: the row page was always outside, so a row band swept every column block before the next band started.

    as_rtftables(df, split = "group_safe", max_rows = 20) |>
      paginate_cols(at = c(4, 6), page_order = "down")

    "across" (the default) advances across the columns — the column block first, the row pages running inside it; "down" advances down the rows — the row page first, its column blocks following it (the old behaviour):

    "across" "down"
    page 1 cols 1 / row 1 row 1 / cols 1
    page 2 cols 1 / row 2 row 1 / cols 2
    page 3 cols 2 / row 1 row 2 / cols 1
    page 4 cols 2 / row 2 row 2 / cols 2

    A group sits above both: when the row pagination made a page per group value (split = "by_value", with page_by inside it), the column split happens within one group, so a group’s pages stay together — giving group / column block / page_by under "across" and group / page_by / column block under "down". Each page records its own group in rtf_paginate_meta$page_group, so this never depends on reading a page name.

    The pages themselves are identical; only their order differs. A page’s name still follows the row page it was cut from, so under "across" pages sharing a name are no longer adjacent, which reaches the sections: rtf_tables(auto_section = TRUE) opens a section where the name changes, so "down" keeps a table’s column pages in one section while "across" gives a section per page. An unnamed page list — what as_rtftables() returns for a single table — is unaffected by either order.

  • A built-in cell format can be NAMED, and fmt_value_paren() aligns text values (#418). Two related gaps in the cell formatters.

    align_count_pct was a logical switch onto exactly one built-in, so reaching any other one meant passing a function object through cell_format. Now a built-in can be named wherever a formatter is accepted — the fmt_ prefix is optional, and an unknown name lists the built-ins:

    as_rtftables(x, cell_format = "value_paren")
    as_rtftables(x, cell_format = list(NULL, "right_align", my_fmt))
    as_rtftables(x, align_count_pct = "count_paren")   # the switch takes a name

    align_count_pct = TRUE still means realign_count_pct() and FALSE is still off; given a name or a function it behaves exactly like cell_format, which continues to win when both are supplied.

    fmt_value_paren() is the general form of fmt_count_paren_bare(). The count aligners require a bare integer before the parenthesis, so a column mixing a plain N with a text value stays ragged. The new one right-justifies any value — so its rightmost character lines up — while the parenthesis itself follows format_count_pct()’s long-standing rule (#421): the whole (...) block is right-justified to a common right edge, so the padding falls before the ( and never inside the parentheses, a zero count prints without its parentheses, and a decimal-less (100) sits flush against its ) rather than being dragged into line with the decimal cells.

    12  (100)          <- no decimals: flush against the ")"
     6 (50.0)
     1  (8.3)          <- the "8" under the "0" of 50.0
     0                 <- a zero count, on its own

    Cells with no parenthesis are padded into the same field; empty cells, cells whose parenthesis is not the last thing in the cell ("Mean (SD) by visit") and cells with nothing before the parenthesis are returned unchanged. Only an all-zero parenthetical is dropped, so "0 (BLQ)" keeps what it says.

  • {BOOK_PAGE}: a slot the deliverable reserves and assemble_rtf() fills (#413). A table that will be bound into a compiled document needs two page numbers — its own (Page 1 of 3) and the book’s (Page 2 of 6). Both were already expressible, but writing both from the start prints the same number twice on a standalone deliverable, where the two are equal.

    The deliverable now reserves the position instead:

    footer = rtf_footer(rows = list(
      c(c = "Company Confidential"),
      c(l = "Statistical Programming", r = "Draft"),
      c(c = "{BOOK_PAGE}")))
    
    assemble_rtf(files, "book.rtf",
                 book_page = "Page {AUTO_PAGE} of {AUTO_TOTAL_PAGES}")

    On its own the slot renders as an empty ignorable destination, so nothing prints and the row keeps its full height — filling it later cannot shift the layout. The other footer rows are untouched and each deliverable keeps its own footer, which replacing the whole band could not have managed.

    The slot works in any band — header, footer, title or footnote — because it is only a token in cell text, and there is no addressing to learn: wherever the token is written is where the number goes. The total resolves to the book’s page count, front matter included. The static {PAGE} / {TOTAL_PAGES} are rejected in book_page: the slot sits in one band shared by every page of a section, so one substitution cannot give each page its own number.

  • Cells can carry a background colour (#400). style_cols(background =) fills a column’s body cells, header_background = fills its header cells, and style_body(background =) fills selected cells, overriding the column’s own fill – the same precedence text colour already follows.

    The fill is emitted as \clcbpat in the cell definition, next to the borders and \cellx, because a fill is a property of the cell and not of its text. Colours join the document colour table through the existing machinery, so there is no second mechanism to keep in step.

    Everything defaults to NULL: a table that does not ask for a fill renders byte-identically to before. This also closes the hole as_rtftables() documents about gt / gtsummary themes, whose cell fills had nowhere to go.

  • rtf_watermark(): a diagonal word behind the page body (#399). rtf_document(watermark = "DRAFT") takes the defaults; rtf_watermark() sets the text, size, colour, rotation, font and box. rtf_config(watermark =) puts one on an already-composed document, or clears it with NA.

    The shape is written into the section header – what Word’s own Insert > Watermark does – so it repeats on every page and stays scoped to its section. That scoping is the point for assemble_rtf(): one deliverable’s watermark cannot leak into the next. A section overrides the document setting by passing watermark in its rtf_section(secinfo = ), including watermark = NA to switch it off for that section alone.

    The shape says “behind the text” twice, as the destination flag (\shpfblwtxt1) and as the fBehindDocument property, because readers honour one or the other. Verified by rendering through LibreOffice: the table text stays fully legible over it.

  • Page-number tokens now work in titles and footnotes, not only in the header and footer bands (#398). {AUTO_PAGE}, {AUTO_TOTAL_PAGES}, {SECTION_PAGES}, {PAGE} and {TOTAL_PAGES} mean the same thing in every band. Before this they passed through the title band as literal text, and the footnote band resolved them only under footnote_format = "table" – switching to "text" dropped them silently.

    This is where sponsors actually put a per-table page number when it is not in the page margin: alongside the table, so it travels with it. Novartis writes it into the title line itself (Table 6-3.7 (Page 123 of 294)); Amgen puts it in the footnote block, right-aligned above the Program: line.

    A static {PAGE} costs nothing in these bands. The title and footnote are re-emitted for every page, so each page bakes its own number – unlike a header, where the same token forces one RTF section per page.

    Header and footer output is unchanged.

  • A figure can be a plot object, not only a saved file (#394). rtfplot() and rtf_figures() now take a ggplot2 plot, a lattice trellis object, a patchwork, a grid grob or gtable, a base plot recorded with recordPlot(), or a function of no arguments that draws – as well as the PNG/JPEG path they always took. Writing the temporary file was the caller’s job for no reason: every figure in a report is a plot object a moment before it is a file.

    rtf_document() |>
      rtf_figures(list(ggplot(dat, aes(week, mean)) + geom_line()),
                  titles = list(c("Figure 14.2.1", "Mean over time")))

    The object is drawn through grDevices::png(res = ), which records the resolution in the PNG itself, so the figure lands at exactly render_width x render_height inches (default 6.5 x 4.5) and render_dpi (default 300) decides only how sharp it is. Those three are refused for a file, which has a size and a resolution already. (The object carries the resolution it was drawn at rather than reading it back out of the PNG: macOS’s quartz device records none, and a figure that fell back to 96 dpi would be half again too big there and right everywhere else.)

    rtfplot()’s first argument is renamed path -> x, since it is no longer only a path.

  • The measurements a wrapping rule is built on are exported (#392): listing_disp_width(), listing_take() and listing_split_after(). listing_wrap_code() used to write these out with the rest, so every forked rule carried its own copy of code no one edits – and the width estimator already used them. Now it emits the rule’s three POLICY functions, which call the exported measurements: the template drops from 124 lines to 99, and a fix to how a display width is counted reaches every fork instead of none. listing_disp_width() is worth having on its own – it is what makes a listing’s arithmetic CJK-aware, where a full-width glyph counts as two.

  • listing_wrap_code(): the default wrapping rule as source to edit (#390). A listing_spec(wrap = ) that only adjusts the shipped rule can delegate to listing_wrap(), but one that has to break text differently has to change the algorithm – and the algorithm was six unexported functions, so copying it meant :::. listing_wrap_code() writes them out as one self-contained, base-R-only file, comments intact, renamed after the name you give it and ready to paste and tune – the same idea as listing_code(), which does this for a spec.

    The copy lives in inst/templates/listing_wrap.R and is regenerated from the rule itself by data-raw/gen_listing_wrap_template.R, so it is never hand-written. It cannot drift unnoticed: the suite parses it, compares each function’s deparse with the live one, and checks that what listing_wrap_code() emits reproduces listing_wrap() over a corpus of widths, separators, layouts, CJK text and embedded newlines.

  • A derived header now breaks where the data breaks (#384). A column joining SEX and AGE should read

    Sex/     Female/          Sex/Age   Female/18
    Age      18

    – two lines where the cell needs two, one line where the cell fits on one. The rule was already shared, but the step before it was not: a cell is joined into one string and wrapped once, while a derived header wrapped each label separately, so two labels could never share a line and layout = "flow" flowed the cells and not the header. The header is now joined and wrapped the same way, so the priority is the one that was always intended: break at the separator – where the data breaks – and only then because the width ran out.

    Under "stack" nothing changes, since stack breaks at every separator anyway. The two cannot always agree – a header is a different length from its cells, so it can fit where they do not – but the order of preference now matches.

    fit_listing_widths() consequently freezes the label as the joined words with no breaks at all, so editing a width in the pasted template re-flows the whole header rather than only within the pieces it was frozen into.

  • listing_spec(wrap = ) takes your own splitter (#384). The rule was already a function on the spec, carried there from the type’s template; there was simply no way to set it.

    listing_spec(cols, sep = "|",
                 wrap = function(text, width, sep, layout) { ... })

    A house rule that breaks on a different character, a byte budget for a SAS transport, a script-specific rule: all expressible without a fork. The same function lays out the cells and the headers, which is what (1) makes possible. listing_code() cannot write a function out and says so in the code it emits, rather than silently losing it.

  • listing_col(label = ) takes a vector as the header’s lines, and lays a single string out at the width (#380). The two acts an author performs are now distinguished:

    listing_col("USUBJID", width = 15, label = c("Unique", "Subject ID"))
    #> the lines, exactly as given -- not re-wrapped, even if one is too wide
    
    listing_col("AEDECOD", width = 16, label = "Preferred term of the event")
    #> the words; laid out at the width -> "Preferred term" / "of the event"

    A vector, or a string carrying its own line breaks, says “I laid this out” and is left alone. A single string with no breaks says “these are the words” and is wrapped by the same rule the cells use – separator first, then word boundaries, then a hard split – or left as one line when the column has no width. Previously every explicit label was left alone, which made a one-line label the author’s problem to break.

    fit_listing_widths() now freezes the label unwrapped, keeping only the structural breaks between source variables, so editing a width in the pasted template re-flows that header instead of leaving it broken at the old width.

  • listing_wrap() exports the wrapping rule (#380), so it can be used on its own – to preview where a column will break, or to lay a header out by hand. It composes with the above, since what it returns is a vector of lines:

    listing_col("AEDECOD", width = 16,
                label = listing_wrap("Preferred term of the event", 16))

    width itself stays, and is not replaceable by this function: its main job is not the header but the cell data, and how many physical rows a record occupies is what max_rows and split = "group_safe" page on. Measured on the same data, a column wrapped at 16 paginates 10,8,10,8 where the same column with no width gives 10,10,4 – the body says one row, Word prints two. A column whose cells always fit its rendered width needs no width, which is why a hand-written listing leaves it out for such columns.

  • The width fit now accounts for how tall a header will be (fit_listing_widths(header_lines = ), #378). The demand was data-driven, with the header contributing only the widest token it cannot break – on the reasoning that a header wraps. It does, but wrapping a 73-character label into nine characters makes a ten-line block, printed at the top of every page, and a column whose label is long and whose data is short was answered with almost nothing.

    A column is now also asked to be wide enough for its header to fit in header_lines lines (default 4), so a long label buys width in proportion to how tall it would otherwise make the header. Measured against the hand-tuned widths of a real 19-column listing, the mean error per column falls from 5.7 characters to 3.6 and the header block from ten lines to eight; header_lines = 3 takes it to 2.8 and five lines. Inf restores the previous, purely data-driven fit.

    Two things the same report surfaced, now documented on the same page: size_half_points is half-points (8pt is 16L, and the 9pt default silently costs 18 characters of budget on landscape A4), and spacer_rel_width is in the same character unit – its default of 1 gives nine gutters nearly 6% of that page, where a fractional 0.25 gives the hairline divider a hand-tuned listing uses.

  • fit_listing_widths() now writes the estimate down in full, so listing_code() emits a template you can edit (#375). The code it produced was terse by design – only what differs from the defaults – which is the wrong shape for something whose whole purpose is to be corrected by hand:

    # before
    listing_col(c("DCDECOD", "AELOC", "AEDECOD"), width = 44)
    
    # now
    listing_col(c("DCDECOD", "AELOC", "AEDECOD"), width = 44, rel_width = 44,
      label = "Disposition/
    Location of the Event/
    Preferred Term")

    Alongside the fitted width, each column that does not already set them gets rel_width (the fitted width) and label (resolved from fit_listing_widths(labels = ) or the data’s attributes, wrapped to the width just chosen). The two widths are genuinely different decisions – width is where the text wraps, rel_width is how much of the sheet the column takes – and a header you can see is a header you can fix. A value the author set is still never touched, and freezing the label changes nothing about the rendering: build_listing() would have wrapped it to the same width.

    listing_code() drops the labels lookup once every column carries its own label, because a per-column label wins and editing the table would appear to do nothing.

  • fit_listing_widths(labels = ) supplies the words for the source variables (#373, #376). Deriving a header from the data’s label attributes (#366) covers ADaM read through haven and nothing else: a CSV, a frame built in the program or a subset() that dropped them arrives with the labels known to the programmer but not to the data.

    fitted <- fit_listing_widths(
      adsl, draft,
      page   = rtf_page(paper_size = "A4", orientation = "landscape"),
      labels = c(USUBJID = "Unique Subject ID",
                 DCDECOD = "Disposition",
                 AELOC   = "Location of the Event",
                 AEDECOD = "Preferred Term")
    )

    One table, written once – the shape a define/spec extract already has (setNames(spec$label, spec$variable)). The header is still derived: the labels of a column’s source variables are joined with its separator and wrapped to the width just fitted, so where the lines break stays automatic. Only the words come from here. Precedence: listing_col(label = ), then labels, then the variable’s label attribute, then its name.

    It belongs to the estimation, not to listing_spec() (#376). A labels argument on the spec reads as “the labels of these columns” and would be expected to hold one entry per printed column, while what it carries is keyed by source variable and is consumed only while a header is being derived – and once the fit runs, that header is written onto each column and the table has no further job. (listing_spec(labels = ) existed for a few hours in 0.7.13 and is gone; per-column labels have their home in listing_col(label = ).)

  • A header is no longer cut mid-word by the width fit (#373). Scaling the demands to the page could take a column below the widest token its header cannot break – "Grade at Initial Diagnosis" in a nine-character column printed as Diagnosi / s. A column that falls under that floor is now raised back to it and the characters come from the columns with room to spare; only if the headers alone cannot fit the page does the guarantee give way.

  • fit_listing_widths() proposes every column’s width from the page and the data, and listing_code() prints the spec as source to paste (#369). Choosing widths by hand is the tedious part of writing a listing, and the answer is not a matter of taste: it follows from the paper, the margins, the font and the values.

    spec <- listing_spec(list(
      listing_col("USUBJID", collapse_repeats = TRUE),
      listing_col(c("DCDECOD", "AELOC", "AEDECOD")),
      listing_col("AETOXGR")
    ))
    
    fitted <- fit_listing_widths(
      adsl, spec,
      page = rtf_page(paper_size = "A4", orientation = "landscape",
                      margin_left_in = 0.5, margin_right_in = 0.5),
      size_half_points = 16L)
    
    listing_code(fitted, name = "listing")
    #> listing <- listing_spec(list(
    #>   listing_col("USUBJID", width = 24, collapse_repeats = TRUE),
    #>   listing_col(c("DCDECOD", "AELOC", "AEDECOD"), width = 82),
    #>   listing_col("AETOXGR", width = 15)
    #> ))

    The page sets the budget: the sheet’s writable width divided by the width of one character in the listing’s font, less the gutters – they print too – and less every width you set yourself, because those are decisions, not proposals. Each remaining column’s demand is the probs quantile (default 0.9) of the display widths of its cells, so one unusually long value wraps instead of pushing every other column narrow, floored by the widest token its header cannot break. That floor matters: a header wraps, so a label like "Grade at Initial Diagnosis" asks for nine characters ("Diagnosis"), not twenty-six.

    listing_code() writes out only what differs from the listing’s defaults, so the result reads like something a person wrote. The round trip is the point: measure, print, paste into the program, tune by eye – the measurement is a starting point, and the code is where the decisions get made and reviewed.

  • listing_spec(layout = ) is documented (#366 follow-up). The argument shipped undocumented, which R CMD check --as-cran reports as a WARNING; no behaviour changes.

  • A listing column can take its header from the data, flow its parts, and mark itself as a key (#366). Three jobs the listing prototype in ydisctools had already worked out, brought over as listing_col() arguments:

    • label = NULL now derives the header from each source column’s label attribute (falling back to its name), joined with the column’s separator and a line break – stub_cols(label = NULL)’s rule, so a listing header and a merged stub label are derived the same way. A derived header is wrapped to width and so can never be wider than the column it sits over; a label you write is used exactly as written, because you laid the lines out. label = "" asks for an empty header. Since the labels live on the data, the resolution happens in build_listing(), and the resolved spec travels back on the body.

    • layout = "flow" makes the separator a break opportunity: fill each line as far as width allows instead of breaking after every separator. Per column, because a listing wants both – a long diagnosis stacked, an age and a sex flowed. "stack" remains the default.

    • collapse_repeats = TRUE marks a key column: its value is carried down every physical row of the record, and as_rtftables() blanks the repeats. Carrying and delegating rather than blanking during composition is what makes the value reappear at the top of the next page – the suppression happens per page, after the split. A collapse_repeats you pass yourself still wins.

    One thing was deliberately not taken: ydisctools breaks at every separator even with no width. That would silently make every existing width-less multi-variable column taller, and width is what says how a column is laid out – so with no width, both layouts return the text as it stands, as before.

  • catx() joins values with a separator, skipping the missing ones (#360) – the SAS CATX rule, and the join listing_col(vars = ) already made internally:

    catx("/", "COMPLETED", NA, "HYPERGLYCAEMIA")
    #> "COMPLETED/HYPERGLYCAEMIA"      # never "COMPLETED//HYPERGLYCAEMIA"

    Exporting it is what lets a listing be prepared with rtfreporter alone: auditing a hand-written listing pipeline, this join was its only call to another package. build_listing() now goes through the same function, so a listing column and a column you join by hand cannot drift apart.

    Arguments are vectorised, a length-1 argument is recycled, a factor joins by its labels, and blanks around each value are stripped – as SAS CATX does, and as the listing columns already did. (A non-trimming implementation such as ydisctools::catx() keeps the padding; that is the one difference, and loading both packages masks one with the other.)

  • Listing preparation: listing_col(), listing_spec(), build_listing() and as_rtftables(listing = ) (#241). A clinical listing is a data.frame problem before it is an RTF problem, and this is the missing step: source variables joined into one printed column, a long cell wrapped over as many physical rows as it needs, narrow gutter columns between the printed ones, and a blank row after each record.

    spec <- listing_spec(list(
      listing_col("USUBJID", width = 15, label = "Unique
    Subject ID"),
      listing_col(c("DCDECOD", "AELOC", "AEDECOD"), width = 22,
                  label = "Disposition/
    Location of the Event/
    Preferred Term"),
      listing_col("AETOXGR", label = "Stage at
    Initial
    Diagnosis")
    ))
    
    as_rtftables(adsl, listing = spec, max_rows = 40)

    The division of labour follows the stub: build_listing() is the data.frame verb, listing_spec() bundles its settings, and as_rtftables(listing = ) runs the same work as a hook – and both read the same spec, so neither carries a copy of the other’s arguments. build_listing(data, spec) |> as_rtftables() and as_rtftables(data, listing = spec) produce the same tables; reach for the first to look at (or patch) the reshaped data before it is rendered.

    Because the hook runs inside as_rtftables(), the spec also supplies the col_header, the relative widths and the left alignment, so the three hand-maintained vectors a listing used to need – one header entry per column, col_rel_width = c(80, 1, 130, 1, ...), a col_spec repeating align = "left" – are gone, along with the job of keeping them in step with the column list. Anything you pass yourself still wins.

    A record is never split across a page. build_listing() appends a hidden record column and the hook points group_col at it, splits with "group_safe" and adds it to drop_cols; drop columns are hidden after pagination, so it decides the page breaks and is never printed. No new pagination code was needed for it.

    listing_spec(type = ) names a template – one ships, "multiline" – which supplies the separator, the gutters, the per-record blank row, the wrapping rule and the default alignment, exactly as border = "tfl" resolves a preset. Adding a type later changes no signature.

    Under "multiline" a cell breaks after the separator first, so each source variable starts its own line, and only a piece that is still too long breaks again at a word boundary. A token longer than the width keeps its own line rather than being cut mid-word.

    The reshaping is checked against the hand-written pipeline it replaces, not just against itself: a listing laid out by hand runs beside build_listing() on the same data and the two are compared cell for cell. Three differences are deliberate and pinned by tests: a token longer than the width keeps its own line instead of being preceded by an empty one; a newline already in the data is honoured; and an empty cell still occupies its row, so a record whose wrapped columns are all empty keeps its blank row (and therefore its record boundary) instead of running into the next record.

    An rlistings listing_df is rejected by both entry points, with a pointer: rlistings has already applied disp_cols, baked key-column suppression into the strings and carries its own titles, so it goes straight to as_rtftables() (#322). Without the guard it would fall silently into the plain-data.frame path.

  • as_rtftables(na = ) says what a missing value should print as (#350). Previously an NA cell rendered as an empty cell and there was no way to ask for anything else:

    as_rtftables(df, align_count_pct = TRUE, na = "-")

    The substitution is applied to every column, the row-label column included, and independently of align_count_pct / cell_format – those decide whether the token is also aligned, not whether it appears. With one of them set, the token is right-justified in the count field, so its right edge lands on the ones digit and it stays in line with the counts:

      1 ( 1.2%)
    108 (35.3%)
      -            <- na = "-"
     NE            <- na = "NE"

    The default na = "" is the previous behaviour exactly. Text already in the data that is not missing ("NE", "n/a", a literal "-") still passes through unchanged and unpadded, and the NAs collapse_repeats writes to blank out repeated values still render blank.

    NaN counts as missing (R’s own is.na() says so, and in a TFL a NaN is a 0/0 percentage). Inf / -Inf do not: they print as "Inf" / "-Inf", because an infinity means a division by zero upstream and rendering it as "-" would hide the bug. This is the policy fmt_round() / fmt_signif() already followed.

  • The shipped cell-format functions take na too (#350), so they work standalone and under cell_format: fmt_right_align(), fmt_count_paren(), fmt_count_paren_bare(), realign_count_pct() and format_count_pct(). The cell-format contract is now function(x, nbsp, na); na is passed only to functions that declare it, so one written to the older two-argument contract is called exactly as before.

Documentation

  • What auto_section actually groups (#425; the rule itself then changed in #433). ?paginate_cols and the pagination article said rtf_tables(auto_section = TRUE) opens a section per run of equal names, so a table’s column pages “stay in one section” — a claim carried since paginate_cols() landed (#275) and repeated by the page_order docs (#417). It is wrong: rtf_tables() wraps every named element and a section opens at each one, so equal consecutive names give a section per page; only a page named "" joins the section before it (which ?rtf_tables has always stated correctly). The old claim held only for an unnamed page list — what as_rtftables() returns for a single table — where there are no names for auto_section to act on.

    Nothing rendered wrongly (each page carries its own correct label, and page numbering is continuous), but the docs pointed at the wrong lever. They now state the rule and show the rename that groups a run:

    lbl <- sub("[.][0-9]+$", "", names(pages))
    names(pages) <- ifelse(c(FALSE, lbl[-1] == lbl[-length(lbl)]), "", lbl)

    Tests pin the behaviour so the documentation cannot drift from it again.

  • An article for figures (#396). Figures had one section of “Adding content” and nothing of their own, and that section still told the reader to save a PNG by hand. The new article answers what a figure actually raises: a plot object straight onto a page; what decides its size (inches, not pixels – with a worked table showing render_dpi changing the file and not the page); how to span the writable width of your own page; where a file’s native size comes from and what happens when it records none; and why PNG and JPEG are the only formats embedded. Every chunk is evaluated.

  • The listings and figures articles are on the site’s navigation and in the README (#396). The listings article (#358) had been reachable only by URL since it was written.

  • The roadmap says where the package actually is (#396). It still named v0.4.0 as the current work and planned milestones at v0.5.0/v0.6.0, numbers the development version had long passed. The milestones move to v0.8.0 (CRAN-submission preparation), v0.9.0 (CRAN registration) and v1.0.0 (stable API), and the status now lists what has landed since v0.4.0.

  • listing_spec(wrap = ) states its contract, so writing one needs no look at the source (#386). ?listing_spec now says what a wrap function is called with – positionally, with text (always length 1), width (which may be NULL), sep and an already-resolved layout – and what it must return: a non-empty character vector, one element per line. listing_wrap() is named as the reference implementation: it is exactly the "multiline" type’s own rule, so wrap = listing_wrap changes nothing and a custom rule can delegate to it and adjust around it. The cell path now validates the return value through the same check as the header path, so a wrap that returns the wrong thing fails with that message wherever it is first called.

  • The listings article covers the whole of the feature again (#371). It was written against the API as it first shipped, and gained a section where the page chooses the widths (fit_listing_widths()) and hands them back as source to paste and tune (listing_code()), plus the three listing_col() settings that landed since: headers derived from the data’s own labels, layout = "flow", and collapse_repeats = TRUE. Every chunk is evaluated, so the widths on the page are the ones the code really produces.

  • A listings article: source data to the written RTF (#358). Listings end to end runs the whole pipeline in one place – describing the columns, what build_listing() reshapes and why, where the header / widths / alignment come from, how a subject’s block survives a page break, and the finished landscape report – then says what the hand-written pipeline it replaces did differently, and when to skip it entirely (an rlistings listing_df is already laid out). Every chunk is evaluated, like the pagination article, so the output on the page is what the code actually produces and CI re-checks it on every build.

  • The Reference index stops presenting the deprecated border constructors as current API (#354). Borders listed all nine at the same level, so nothing on the page said that seven of them are on their way out. It now holds only the two that are current – rtf_border_side() and rtf_border() – and the seven it replaced sit in a Deprecated – scheduled for removal section at the bottom, which says plainly that they will be removed in one batch before the CRAN submission. paginate(), which is superseded but is not going away, moved to its own Superseded section so neither statement has to cover both cases. Documentation only: every deprecated function still works and still has its page.

Documentation

  • split = "rows" is cut positions, not a page size (#402). split_rows was documented as “Rows per page” and "rows" as “fixed chunk size”, but .split_by_rows() has always taken split_rows as the row positions to cut before: on 200 rows, split_rows = 25 gives two pages of 24 and 176, not eight of 25. Fixed-size pages are what max_rows does, with split = "group_safe" / "group_force". Both behaviours were always there; only the wording was wrong, and each entry now points at the other. The roxygen example hid it by using a 4-row frame and split_rows = 3, where cut-position and chunk readings both give two pages; it now uses a case where they differ.

Bug fixes

  • Assembling left every NUMPAGES cache claiming its own old total (#415). {AUTO_TOTAL_PAGES} bakes the count of the document being written, so a 3-page and a 2-page deliverable bound into a 5-page book each went on insisting on 3 and 2. assemble_rtf() now points every cache at the book’s page count, front matter included.

    Word recalculates header and footer fields during layout, so a Word reader already saw the right number — confirmed on the desktop version. What this fixes is the file as written: readers that display the cached result, anything that parses the RTF rather than rendering it, and a body-placed total, which Word does not recompute until the fields are refreshed.

    Only the cached result is rewritten; the field instruction is untouched. A cache is a stale answer, not a value.

  • LibreOffice dropped every page break between tables (#408). The break was emitted as two separately grouped paragraphs around the \page ({\pard\fs2\par}\page{\pard\fs2\par}, the form r2rtf and reporter use). Word honours that; LibreOffice’s RTF importer does not, and ran the next table onto the same page – so any pipeline batch-converting RTF to PDF through LibreOffice silently lost its pagination. The whole break is now one group ({\pard\fs2\par\page\pard\fs2\par}), which keeps both properties the old form was chosen for: the \page is still flanked by terminated paragraphs (what #138 found Word needs – the bug there was a bare \pard with no \par, not the grouping), and \fs2 is still scoped so the 1pt size cannot leak. Verified in LibreOffice, and checked in Word: the two forms render identically there.

  • assemble_rtf()’s cached table-of-contents page numbers were wrong for any multi-page deliverable (#401). The internal page count matched \sbkpage only. That control word starts a section, and rtfreporter emits one section per rtf_section – not one per rendered page – so a six-page table inside one section counted as a single page, and a table of contents over N inputs numbered them 1, 2, 3, … whatever their real length. The pages inside a section are separated by a plain \page, which is now counted as well. (Word refreshes the numbers from the PAGEREF fields, so what this fixes is the cached value a reader sees before refreshing, and any consumer that reads the file as written.)

  • An invalid table_width_pct no longer warns before it errors (#388). as.numeric() was called on the raw value, so R’s “NAs introduced by coercion” reached the user ahead of the message saying what the setting takes – and under options(warn = 2) replaced it entirely. Both spellings of the percentage now go through one validating helper, which also gives as_rtftables() the range check its own copy of these lines had skipped. A numeric string ("50") is still accepted, as before.

  • A factor group_col no longer breaks the " (Cont.)" continuation row (#352). The group-aware splits write the continuation label back into the group column, and a factor cannot take a value outside its levels: a data.frame stored NA instead (with an invalid factor level warning), so the continuation page opened with a stray empty row, and a tibble failed outright with Can't convert from <character> to <factor<...>>. The group column is now coerced to character just before the split. This also covers split = "group_safe" / "by_value", which reach the same code whenever a single group exceeds max_rows – which is why a table could work under "group_safe" and break the moment it was switched to "group_force". The coercion runs after sort_by, so a factor is still ordered by its levels, not alphabetically. add_cont_label() accepts a factor column for the same reason.

  • format_count_pct() no longer prints a literal "NA" for a missing count (#350). It was a side effect of sprintf("%3d", NA_integer_) rather than a decision, and it disagreed with realign_count_pct() / fmt_count_paren(), which rendered the same cell blank. All of them now follow na, which defaults to an empty cell. A missing percent next to a real count is not missing data, and still prints the count on its own.

Breaking changes

  • The line gets its own type back (#348, partly reversing #346). rtf_border_side() is no longer deprecated: style, width and color describe a line, not a border, and folding them into rtf_border() set off a chain of complications – one call could describe only one line, so sides could not differ; hence a scoping rule; hence from; hence a build-versus-layer distinction; hence a gap where layering could not return a side to unset. All five dissolve now that a side holds a value again.

    rtf_border() therefore drops style, width, color and from, leaving the seven arguments that say where:

    rtf_border(all, top, bottom, left, right, inside_h, inside_v)

    Every side keeps its shorthands, since a line’s type is what gets named most often: TRUE (a default rule), FALSE or "none" (an explicit no-line), a style name such as "double" (that style at the default weight and colour), or an rtf_border_side() for a weight or colour of its own. Sides that differ now fit in one call:

    rtf_border(top    = rtf_border_side(color = "#C9372C"),
               bottom = rtf_border_side("double", 30L))

    Layering is the job of the place a border is attached: style_zone(), style_header() and style_body() all merge side by side, so a second call adds to the first. rtf_border_with() stays deprecated and now points there.

    Borders: 2 effective exports, doing different jobs – which edges, and what the line is. Package exports 75, of which 7 deprecated, so 68 effective.

Breaking changes

  • One border constructor. rtf_border() absorbs the rest of the family (#346), finishing the API review’s decision that #342 only half delivered. A side is now written as a value rather than built:

    deprecated write instead
    rtf_border_side() TRUE
    rtf_border_side("none") "none" (or FALSE)
    rtf_border_side("double", 20L) side "double" plus width = 20L
    rtf_border_none() rtf_border()
    rtf_border_top() rtf_border(top = TRUE)
    rtf_border_bottom() rtf_border(bottom = TRUE)
    rtf_border_box() rtf_border(all = TRUE)
    rtf_border_with(b, bottom = x) rtf_border(from = b, bottom = x)
    rtf_border_tfl() border = "tfl", or rtf_table_style_tfl()

    style, width and color are arguments of rtf_border() itself and describe the line the sides named in that call draw. from decides what a call does: without it the result is exactly what that call says, with it only the named sides change and the rest of from survives, each keeping its own weight and colour. That is how a border gets a different colour on each side, and it is why rtf_border(all = FALSE) (“no rule anywhere”) is not the same as rtf_border() (“nothing said, so inherit”).

    All eight (with rtf_table_border() from 0.5.0) still work and warn once per session; they are scheduled for bulk removal before the CRAN submission. Until then the API measure is exports excluding them: borders 9 -> 1, package 75 -> 67, asserted in test-api-surface.R.

  • rtf_border() gained all as its first argument, so positional calls shift. There were none in the package; named calls are unaffected.

Bug fixes

  • rtf_border_with() forwarded inside_h/inside_v even when the caller had not named them, which made the resulting border look like it had declared its interior rules and silenced the 0.5.0 edge-reading warning. It now forwards only what was supplied.

  • rtf_header_source() emits the surviving spelling, so generated code no longer calls deprecated constructors.

Documentation

  • Help pages now render their markup instead of printing it (#344). Every roxygen block was already written in markdown, but DESCRIPTION never turned markdown on, so [rtf_border()], backticked code and **bold** reached the rendered help verbatim. Cross-references were the real loss: all 65 [fn()] references are now followable links. No exported behaviour changes.

    One hand-written abular{} in ?paginate_cols had to become a markdown table: roxygen escaped the backslash of its \%, so the % started an Rd comment and swallowed the rest of each row.

Breaking changes

  • One border type. rtf_border() now describes a selection, and where it applies is decided by where you attach it – the model Word uses, where one dialog acts on whatever is selected (#342).

    attach it to the selection is
    rtftable(border = ) the whole table
    style_zone(header = , body = , ...) that kind of row
    col_cell(border = ), cell_styles one cell
    rtf_header(border = ) / rtf_footer(border = ) that block

    One rule, no special cases: top/bottom/left/right are the selection’s outer edges, and inside_h/inside_v are the rules inside it, absent meaning no rule. This replaces the mode added in 0.4.84, where naming an inside_* switched that axis between two readings.

    Two consequences for existing code:

    • A zone edge used to be uniform: left/right reached every cell of a row and a body zone’s top/bottom every one of its rows. Both now mean the outer edge only. Name the interior rule to keep the old look – rtf_border(left = s, right = s, inside_v = s), rtf_border(bottom = s, inside_h = s). rtfreporter warns once per session when it meets a border that would have rendered differently.
    • rtftable(border = <rtf_border>) was an error (#326); it now means the whole table. Nothing that worked before changes meaning.
  • rtf_table_border() is deprecated (#342). It still works and still returns the same value, but warns once per session. The five-zone map lives on as an internal type; address a row kind with style_zone() or rtf_table_style(border_* = ). outer = becomes the four edges of an rtf_border(); inside_h = / inside_v = keep their names.

    Borders go from 9 exported entry points to 2 (rtf_border, rtf_border_side) – and from two concepts to one. The #326 defect shape, passing one border type where the other was expected and silently losing every rule, cannot occur with a single type.

New features

  • Borders can be specified the way Word specifies them: an outer frame, the rules between rows, and the rules between columns (#340). Until now a zone-level rtf_border(left =, right =) was emitted on every cell of the row, so it always drew a rule at every column boundary; an ordinary listing look – frame plus horizontal rules, no vertical rules – needed four style_header() / style_body() calls on the first and last columns.

    rtf_table_border() gains outer, inside_h and inside_v, which address the table as a whole:

    rtf_border() gains inside_h and inside_v for the same distinction one level down. Naming either one switches that axis from uniform to outer + inside: with inside_v set, left/right become the row’s outer edges and the boundaries between its cells use inside_v; with inside_h set, top/bottom become the zone’s outer edges and the boundaries between its rows use inside_h. Since rtf_border_side("none") is an explicit no line, inside_v = rtf_border_side("none") reads as “outer edges only”.

    All five arguments default to NULL and leave existing output untouched. On a row with a spanning cell, inside_v lands on cell boundaries rather than column boundaries, so no rule is drawn inside a merged cell.

Breaking changes

  • The five page_split_*() factories are retired (#334). A strategy is named and its settings are ordinary arguments alongside it:

    was write instead
    page_split_none() split = "none"
    page_split_rows(split_rows) split = "rows", split_rows =
    page_split_group_safe(...) split = "group_safe", max_rows = , group_col =
    page_split_group_force(...) split = "group_force", ...
    page_split_by_value(...) split = "by_value", group_col =

    They were the only reason the same setting could be written in two places: a factory carried its own group_col, so it could disagree with the top-level one. #328 made the two agree; this removes the shape that let them disagree, and the machinery that reconciled them goes with it. Every setting now has exactly one declaration site.

    The custom-function escape hatch is unchanged – split may still be a function called as split(df, max_rows =, group_col =, group_by =, cont_label =, min_group_rows =). The factories were only pre-built instances of that same contract with configuration baked in.

    Exports: 80 -> 75.

Bug fixes

  • A separator blank row could land on a page’s first or last row (#332), where only blank_row_first / blank_row_end should decide. A group separator belongs between groups; when a group boundary coincides with a page boundary it separates nothing on that page.

    blank_rows = "between_groups" already behaved this way – a change can never be at row 1, so its positions were never 0 or nrow – but the other spellings did not. blank_rows_by_change() put a blank before the first row and after the last row of every page with both page-level flags off, and count_blank_rows = TRUE left a marker stranded at the foot of a page.

    Separator specs – "between_groups", blank_rows_by_change(), blank_rows_by_rule() – are now trimmed to interior positions on every path. Explicit integer positions are unchanged: blank_rows = 3 is a direct request for “after row 3 of each page” and is honoured even when the page has exactly three rows. A mixed list() spec trims only its separator half.

  • count_blank_rows = TRUE let split = "group_safe" cut a group across a page boundary (#330). Counting blanks materialises them as empty marker rows before the split, and those empty cells changed what group_by = "auto" detected:

    .detect_group_mode(c("A","A","A","B","B","B"))       #> "value"
    .detect_group_mode(c("A","A","A","","B","B","B"))    #> "filled"

    Under "filled" every non-empty cell opens a group, so every row became its own group and group_safe was free to cut anywhere – silently, and violating min_group_rows at the same time. Of 20 shape / max_rows combinations, 14 split at least one group; none do now. group_by = "auto" is resolved before the markers go in, and a marker can no longer open a group in "indent" / "filled" mode either.

    blank_row_first / blank_row_end were never involved and are unchanged.

  • group_col given only inside a page_split_*() spec did not reach blank_rows / collapse_repeats (#328). The group column is used by two things – pagination, and the group-dependent row settings (blank_rows = "between_groups", collapse_repeats, blank_rows_by_change()) – but the factories captured it in the returned closure’s environment, where only pagination could see it. Writing split = page_split_group_safe(group_col = "SOC") therefore produced different output from split = "group_safe", group_col = "SOC", with no error and no warning:

    group_col at top level        blank_rows 4, 8           correct
    group_col inside the split    blank_rows 1,2,...,10      wrong
    group_col nowhere             blank_rows 1,2,...,10

    The specs now record their group_col / group_by as attributes and as_rtftables() adopts them, so both spellings mean the same thing. An explicit top-level argument still wins.

  • A cell-level rtf_border() passed to rtftable(border = ) silently rendered no borders at all (#326). border there takes a table-level border (5 zones); an rtf_border() is a classed named list, so it reached the plain-list fallback in .normalize_table_border(), which looks for zone names, found only top/bottom/left/right, and returned a border with every zone NULL. The output was byte-identical to border = NULL – the caller asked for a rule and every rule disappeared, with no error and no warning. It is now an error naming the correct spelling. The opposite mix-up was already guarded in col_cell(), so the pair is now symmetric.

  • An rlistings listing is no longer silently mis-read as a plain data.frame (#322). rlistings::as_listing() returns a listing_df, declared as setOldClass(c("listing_df", "tbl_df", "tbl", "data.frame")) – an S3 tibble, not an S4 VTableTree. .is_rtables_tbl() therefore rejected it, and because it genuinely is a data.frame it fell through to the plain-data.frame branch and rendered with no error at all, discarding the listing’s own decisions:

    listing_df is now detected before the data.frame branch and read through formatters::matrix_form() – the same canonical structure the rtables adapter already consumes, and which honours listing_dispcols() by construction. as_rtftable() accepts one too. Add rlistings to Suggests:; it stays out of Imports:.

    Note: the suppression arrives already baked into the cell strings, so unlike as_rtftables(collapse_repeats = ) a suppressed key does not reprint at the top of a new page.

  • A title or footnote block could come back as a list instead of a character vector. The pieces were c()-ed together, and the same accessor returns different types across sources – formatters::mf_rfnotes() is character(0) for an rtables table but an empty list() for an rlistings listing – so one list among the arguments turned the whole result into a list. The pieces are now unlisted and coerced before use.

Internal

  • The MatrixPrintForm reader behind the rtables adapter is factored out as .mpf_to_rtftable_kwargs() and shared with the new rlistings adapter. It was already typed on the MatrixPrintForm rather than on VTableTree – it touches no rtables-specific slot – so no logic changed; .rtables_to_rtftable_kwargs() remains as a typed wrapper and its call sites and tests are untouched.

Documentation

  • cards + gtsummary must be updated together (#319). The hierarchical-AE chunks in showcase-ae failed with The following columns are not present: ... – not a bug in the code but a version mismatch: gtsummary’s hierarchical verbs hand their ARD to cards, which validates it in check_ard_structure(), and gtsummary 2.5.0 declared only cards (>= 0.7.1) so R installs it happily beside cards 0.9.0. gtsummary 2.6.0 declares cards (>= 0.9.0) and every chunk runs again. The article’s cards + gtsummary section now says so.

  • Every article now says where to go next (#317). Measured first: eight of the twenty-five articles were orphans – no other article linked to them – and one of those was rtfreporter-pipes, which the index itself tells readers to open after the Quickstart. Thirteen articles carried no onward link at all. Fifteen articles gain a Where next block naming two or three specific follow-ons and why, so the set now reads as a path rather than a pile. Orphans: 8 -> 0.

  • The articles point at the new recipes page (#317). Nothing referenced ?rtfreporter-recipes when it landed; all fifteen do now.

  • The Quickstart’s code actually runs. It set eval = FALSE for the whole document, so its sixteen chunks were printed but never executed and could rot unnoticed. Checked by extracting and running them: 16 of 16 pass, and every render already went to tempfile(), so the vignette now evaluates. A broken example fails the build instead of reaching a reader.

  • Build artefacts no longer land in the repository root. Building the articles left twelve .rtf files there, because several showcase chunks render to a fixed filename rather than tempfile(). Those, and docs/, are now ignored. The underlying fixed filenames are tracked in #319.

Documentation

  • Four complete recipes (#317). A new ?rtfreporter-recipes page carries runnable end-to-end examples for the four table shapes a clinical report is mostly made of – demographics, adverse events, PK concentrations and a laboratory shift – each ending in a rendered RTF. They are shown together because their arguments barely overlap: a table of which setting each shape actually uses makes the differences visible at a glance, and the page names the commonest mistake (reaching for stub_vars on a table with no hierarchy, or omitting it on one that has).

  • Every reference section now says what it acts on (#317). The index listed 18 groups without stating which object a function takes, so a reader landing on rtf_border() could not tell the table side from the document side. Each description now gives the group’s purpose, the object it acts on and – for anything usable in more than one place – where it may be passed and which wins. Measured while writing them: of the value constructors only rtf_page() and rtf_default_format() are document-only, while borders and styles are accepted at four levels.

  • Examples now render (#317). An audit found 39 of 77 help topics carrying examples of ten lines or fewer and only 10 ending in a rendered RTF – most pages showed a call but never a result. The \dontrun{} around renders that needed nothing but a filename is replaced by a real tempfile() render, so those examples run and R CMD check exercises them. page_split’s example referenced an undefined object and could not be copied at all; it now builds its data, and shows the group_col-inside-a-factory pitfall. The three topics with no examples – format.rtftable(), summary.rtftable(), print.rtfreport() – have one.

    All 78 topics with examples now run clean.

New features

  • stub_spec() folds every stub setting into one argument (#314). as_rtftables() now takes a single stub =:

    as_rtftables(x, stub = stub_spec(c("SOC", "PT"), layout = "columns"))
    as_rtftables(x, stub = c("SOC", "PT"))          # shorthand for the vars

    stub_spec() takes stub_cols()’s own arguments, so the two cannot drift apart, and as_rtftables() never has to grow another argument when stub_cols() learns something new. This is the idiom the package already uses for blank_rows = blank_rows_by_change(...), page = rtf_page(...) and default_format = rtf_default_format(...).

    stub_vars / stub_label / stub_indent / stub_group_summary are superseded: they still work and are not deprecated, but they cannot reach layout or label_span, and new settings are added to stub_spec() only. Passing both forms is an error rather than a silent precedence rule.

  • layout and label_span now work from as_rtftables() (#314, closing the gap left by #312). layout = "columns" keeps every column where it is, so none of the column-indexed metadata moves and the source column widths survive – unlike the merged layout, which cannot carry them through. label_span is converted to a per-row cell style as the stub is built, so it rides the machinery that already slices styles per page and needs no re-indexing through the split.

New features

  • stub_cols() gains layout and label_span (#312), two row-heading layouts added to the existing verb rather than to a new one.

    layout = "columns" keeps the hierarchy columns, in their original positions and with their own headers. A group value moves onto its own row and is blank on that group’s member rows, whose leaf is indented in the leaf column:

    group  label     HOGE
    grp1
             label1  1 (1.11)
             label2  2 (2.22)
    grp2
             label3  3 (3.33)

    Nothing is merged – the group value is simply displayed in its own column.

    label_span = TRUE renders each group’s label row of the merged layout as one cell spanning the table, instead of a stub cell followed by empty ones. FALSE by default, so the historical look is unchanged. A group-summary row folded onto a label row is never spanned: it carries statistics in the other columns.

    The two options are contradictory in combination and error rather than silently ignoring each other, as does label (which names a merged column) with layout = "columns".

    Both layouts come out of the row walk stub_cols() already ran, so group_summary, group_by = "indent" detection, blank_rows = "between_groups" and the group-aware page splits keep working unchanged. The span is carried as a per-row cell style, which means it rides the machinery that already slices styles per page and re-indexes them through paginate_cols() – a column page merges exactly its own columns, with no bookkeeping of its own.

    Not yet wired into as_rtftables(stub_vars = ), which still builds the merged layout; use stub_cols() ahead of it for now.

New features

  • rtf_tables(auto_title = TRUE) prints each page’s name above its table (#310). The counterpart to auto_section, which puts the same name in the section’s running header: auto_title appends it as the last row of that table’s title block, left-aligned, so it sits immediately above the table.

    pages <- as_rtftables(df, split = "by_value", group_col = "COHORT")
    doc   <- rtf_document() |> rtf_tables(pages, auto_title = TRUE)

    Titles already present are kept and the name goes after them – whether they came from titles = or were carried on the object as the rtf_titles attribute by as_rtftables(). A table with no titles gets the name as its whole block. title_label_align moves it ("left" by default, mirroring section_label_align).

    Both title formats behave identically, and neither renderer needed changing: title_format = "text" and "table" already resolve their rows through the same normaliser, which honours a per-row alignment.

    Gated exactly as auto_section is – a named list, and only the elements with a non-empty name. The two compose, putting the name in both places.

New features

  • An optional package below its declared minimum now warns (#308). DESCRIPTION has always constrained gt (>= 0.9.0), gtsummary (>= 1.7.0), formatters (>= 0.5.0), rtables (>= 0.6.0), flextable (>= 0.9.0) and huxtable (>= 5.0.0), but the runtime guards asked requireNamespace() only. An installation that satisfied “is it there?” but not the version sailed past and failed later inside the conversion, where the message said nothing about the version.

    Every adapter guard now goes through one helper. Missing is an error, as before and with the same wording; too old is a warning naming the installed version, the required one and the upgrade command, after which the call proceeds – an older release often still handles the simple cases, and a hard failure over a Suggests constraint would be the worse trade. The warning fires once per package per session, so a guard inside a loop does not flood the console.

    The minimum is read from rtfreporter’s own DESCRIPTION at run time, so the numbers live in exactly one place: adding a constraint there is the whole change, and no version literal is repeated in R code.

Bug fixes

  • A command template with an unsupplied placeholder is now an error (#306). .cmd_fmt() substituted only the keys it was handed and left everything else verbatim, so a template that gained a slot the renderer did not fill produced RTF carrying the literal name. Every slot a template declares must now be supplied. Detection is narrow enough not to fire on RTF’s own groups, which always open with a control word ({\header, {\fonttbl) while escaped braces in text are \{.

  • The pharmaverse catalog examples are regenerated (#306). All six shipped with {orientation_cmd}, {header_dist_twips} and {footer_dist_twips} in their preamble – groups of plain text sitting in the document body ahead of \sectd, which a reader renders as visible text on the first page. \headery and \footery were left without their numeric argument and the document-level \landscape was missing. Their margins move 864 -> 1080 twips, the generator setting none and the page defaults being 0.75in; page counts are unchanged.

  • The data-raw/ generators render through the source tree (#306). Each now source("data-raw/_load.R"), which attaches the package with pkgload::load_all() instead of library(rtfreporter). A committed example is a derived file of the code beside it, not of whatever build happens to be installed – that mismatch is what put the placeholders in the examples in the first place.

Bug fixes

  • set_decimal_split() pads, floors and caps each half’s width (#304). The ratio came from the measured glyph counts alone, so a column of 0.0000 values measured 1 against 5 and gave the integer half one sixth of the column – at 1200 twips, 200 twips for a single digit, hard against the cell edge. Each half now gets a padding allowance and a floor, in character-width units:

    w_eff = max(measured + pad_chars, min_chars)

    The defaults pad_chars = c(1, 1) and min_chars = c(4, 6) give each half one character of breathing room over a three-digit integer part and a five-character decimal part, so anything at or below that size holds a 4 : 6 baseline and anything larger scales from its own measurement. A 0.0000 column goes from r = 0.167 to 0.400 (480 twips instead of 200, at a 1200-twip column).

    Past max_chars – the measured halves together, default 10 – the allowance is dropped and the raw measured proportions are used however lopsided. A column that long needs every twip it has, and reserving room it does not use would squeeze the digits it does: 123.4567 data measures 3 against 8 and keeps its own 3/11 = 0.273 rather than being widened to 4/13.

    The floor applies per half, so a column with fewer than five decimal characters reserves the baseline six on the right and its integer half narrows relative to before: 1439.5 / 88.012 style data moves from 4/7 = 0.571 to 5/11 = 0.455. Pass pad_chars = c(0, 0), min_chars = c(0, 0) for the previous raw ratio, max_chars = Inf to keep the allowance at any width, or an explicit ratio to fix the split point outright.

    inst/rtf-examples/pk-concentration.rtf is regenerated accordingly. That file was also stale against the footnote rework (#297) and now renders the footnote band as an independent borderless table, as current code emits.

New features

  • rtf_tables() accepts font_size_half_points and font (#299), so a table’s typography can be set from the document side rather than only on the table. font and font_size_half_points had landed on rtftable() (#292, #293) but not here, which left the table as the only page element whose typography lived on the content object: the header / footer band carries its own via rtf_header() / rtf_footer(), and the title / footnote blocks via rtf_titles() / rtf_footnotes() on the document. Both levels now work, as row_height_twips already did — set it on rtftable() when building the table, override it from rtf_tables() when placing it in a document, where the explicitly-passed value wins. Keeping them in one place also keeps the #292 coupling readable: a size given at either level still recomputes the row height unless a height is given too.

  • Per-element font family, on a variable-length font table (#293, closing the ground of #44). The document carried exactly one font: the resource template was fixed at a single {\fonttbl{\f0 ...}} entry and the renderer only ever read font_table[[1]]$name, so there was no \f1 to select. The table is now built from every family the document mentions, and rtftable(), rtf_header() / rtf_footer() and rtf_titles() / rtf_footnotes() each take font. A family not already declared is added automatically — the same resolve-and-declare the colour table already does:

    rtf_document() |>
      rtf_tables(rtftable(df, font = "Arial")) |>
      rtf_titles(list("Table 14.1.1"), font = "Calibri")
    #> {\fonttbl{\f0\fnil\fcharset0 Courier;}{\f1\fnil\fcharset0 Calibri;}
    #>           {\f2\fnil\fcharset0 Arial;}}

    Index 0 stays the document default, so an element that asks for nothing — or asks for the default by name — emits no \f at all and a single-font document renders exactly as before. font pairs with font_size_half_points from #292, emitting \f1\fs24 together.

    This completes the three-part per-element style work: widths (#291), size / height / markup / alignment (#292), and family here.

Breaking changes

  • The footnote block now uses the page footer’s mechanism, as a table of its own (#296). Two visible changes:

    • No separator rule by default. The footnote used to draw a top rule on its first row, and because rtf_border_tfl() sets only the header’s borders, that rule was the only line under the table. It is gone; ask for it with rtf_footnotes(border = rtf_border_top()).
    • Per-row styling is removed. The list(text = , bold = , italic = , underline = , color = , border = ) row form no longer works. Style is set for the block instead, via rtf_footnotes(font_size_half_points = , row_height_twips = , markup = , align = , border = ).

    In exchange a footnote row may now be c(l = , c = , r = ) — up to three cells positioned left / centre / right, exactly as rtf_footer() takes them — so “Source: ADSL” on the left and a run date on the right finally fit on one line. A bare string lands in whichever slot align selects, so plain text stays left-aligned. Page-number tokens ({PAGE}, {AUTO_PAGE}, …) are expanded now, as they are in the footer.

    The block is also emitted as an independent table: RTF merges consecutive \trowd runs that no paragraph separates, so the footnote was previously the body table wearing different \cellx values. A separating paragraph makes the footnote_width of #291 genuinely its own.

    footnote_format = "text" still renders plain paragraphs; a c(l = , c = , r = ) row cannot be expressed that way and raises a clear error pointing at the table form.

Bug fixes

  • rtf_header(markup = ) / rtf_footer(markup = ) had no effect — the argument was added in #292 but never reached the renderer, so ^{a} in a header stayed literal. The band honours it now, inheriting the document setting when unset.

New features

  • Per-element font size, row height, markup and block alignment (#292). rtf_document(default_format = ) stays the baseline; the table body (rtftable(font_size_half_points = )), the header / footer band (rtf_header() / rtf_footer()) and the title / footnote blocks (rtf_titles() / rtf_footnotes()) may each override it, and anything left NULL is inherited. Font size and row height resolve together: an element that sets a size without a height has the height recomputed from that size, rather than inheriting one chosen for a different size, while an explicit height always wins — so font_size_half_points = 24 alone yields \fs24 with the matching \trrh290, and a document row_height_twips no longer leaks across a size change. row_height_twips = 0 keeps meaning “automatic”. align on a title or footnote sets that block’s default row alignment; a per-row align still beats it, and block placement continues to follow the content. markup can now be switched on for one block alone. Nothing changes when nothing is set — asserted byte-identical.

  • Per-element widths for the header / footer, table and footnote (#291), in one vocabulary: "content" (follow the table body), "page" (the writable width, margins excluded), a fraction in (0, 1] of it, or twips. rtf_default_format() gains title_width / footnote_width (with rtfreporter.title_width / rtfreporter.footnote_width behind them), and rtf_header() / rtf_footer() gain width — the older width_twips stays as the absolute form and wins when both are given. The table body already had its own arguments. Every default is unchanged, and output is byte-identical when nothing is set. Notably footnote_width = "page" is the way to reach the full page width and keep the separator rule: footnote_format = "text" widens the block but renders plain paragraphs, which cannot draw a rule (a row-level border is silently ignored there). It also matters with paginate_cols(), where a narrow column block would otherwise drag the footnote in with it.

Breaking changes

  • paginate_cols() now recomputes relative widths from a page-1 ratio unit (#289). Splitting a col_rel_width table used to scale each page down to the kept columns’ share of the whole, so a block of four visits out of eight filled only 64% of the sheet — the ratios were preserved but the table was simply small. A ratio states a proportion within the page, so the split now fixes the twips-per-ratio unit on page 1 and reuses it on every page: page 1, and any block with the same ratio total, fills the sheet, while a ratio-1 column stays the same width throughout and a shorter final block yields a shorter page. The new width argument selects this — "fill" is the default, "keep" restores the previous behaviour. Absolute column_widths_twips are unaffected (there is nothing to rescale), so the shipped PK example is unchanged. A block totalling more ratio than block 1 would run off the sheet under "fill"; paginate_cols() warns and names it — put the widest block first, or use "keep".

New features

  • fmt_signif() / fmt_round() / fmt_numeric() format numeric columns for display (#287). A column still numeric when it reaches the renderer was converted with as.character() — 15 digits, scientific notation outside 1e-4 .. 1e5, and, worst for a deliverable, dependent on the session’s options(scipen) (as.character(123456) is "123456" under scipen = 100 but "1.23456e+05" under scipen = -100). These functions turn numbers into the report’s text beforehand, deterministically. fmt_signif() counts total printed digits including the integer part — the convention a SAP means by “4 significant digits”, not signif(): 0 → "0.000", 10.2 → "10.20", 103.4 → "103.4", 23.4463 → "23.45". Values below 1 keep true significant figures by default (0.0004567 stays 0.0004567, which matters near a limit of quantitation); small = "fixed" applies the integer-counting rule throughout. Decimals are recomputed when rounding grows the integer part (99.995 → "100.0"), and an integer part longer than the request simply prints whole (12345.6 → "12346"). fmt_round() fixes the decimal places instead. Both offer rounding = "sas" (half away from zero, with an ulp correction so 2.675 cannot fall the wrong way) beside the default "r" (base::round(), banker’s). fmt_numeric() applies either across a data frame, with by naming a carrier column — which may be the row-heading column itself, matched after trimming — so one column can carry a different format per statistic. NA and NaN both render as the na string (default ""); character columns are taken as already formatted and are never touched.

Documentation

  • The PK concentration example gains a running header with page numbers (#281), in the same shape as the pharmaverse examples: two administrative rows (CONFIDENTIAL, Protocol: RTF-101 / Page {PAGE} of {TOTAL_PAGES}) followed by the table’s title block. The titles move out of rtf_titles() and into the header so they repeat on all six pages, which is what a table split across two axes needs — and the reader finally gets a page number. Footnotes stay below the table; the example deliberately has no page footer.

  • The PK concentration example now actually needs its column split (#279). Sized with col_rel_width, the table was exactly as wide as the sheet – relative widths are normalised across the page by .compute_cellx(), so a table sized that way always fits and paginate_cols() had nothing to do; the two pages came out 73% and 47% full. It is now sized with absolute widths from auto_col_widths() (stub 2021 twips, each visit 1804) and the study extended to twelve visits, which makes the full table 23669 twips – 1.73x too wide for the 13680 writable twips – and each of the two visit blocks 12845, or 94% of the page. inst/rtf-examples/README.md gains an entry for the file and names the two screenshots the example needs (pk-concentration.png for page 1, pk-concentration-cols2.png for page 2), since a single page cannot show a horizontal split. The article explains why auto_width = TRUE is not used here: it clamps the total to the writable width when the natural size exceeds it, squeezing the columns back onto one page.

New features

  • Worked example: a PK concentration summary with both pagination axes (#277) – data-raw/gen_pk_conc.R builds a pharmacokinetic concentration table with the visits across the columns, so it is too wide and too tall for one page, and renders it into inst/rtf-examples/pk-concentration.rtf. It combines set_decimal_split() (one column, two cells) with row pagination and paginate_cols(): three time bands by three visit blocks, each page carrying the nominal-time stub at identical column widths. A new integration test covers the interaction the two features could not test separately – paginate_cols() re-indexing decimal_split$cols onto each page’s columns – plus the BLQ, <0.500, integer-n and Min, Max cell kinds. Documents that the split decision is made per page, so an all-BLQ visit column is left unsplit on that page while the others split it.

  • paginate_cols() paginates a table horizontally, by column (#275) – the counterpart to the row pagination as_rtftables() performs. A table too wide for the page is cut into column blocks (at = names the columns to cut before, or cols = states the blocks), with the row-heading column(s) repeated on every page. Row splitting happens first and the column split is applied to its result, and the row page is the outer level: a row band sweeps every column block before the next band starts, so the reader goes across the table first and then down it (two row pages by three column blocks give row1/col1, row1/col2, row1/col3, row2/col1, …). Positions refer to the final printed columns, matching set_col_header(). The carry set defaults to the table’s row_title; carry = overrides it. A column is the same width on every page it appears on in all three width modes – each page’s table width is scaled to the kept columns’ share so relative widths do not stretch back out to the margins, which means a narrow block gives a narrow page. Spanning header cells are clipped per page; a cut inside a spanning group is allowed by default (the group label repeats) and allow_span_break = FALSE rejects it. Page names are carried through unchanged, so column pages stay in one auto_section section. Works on an rtftable or an as_rtftables() page list.

  • set_decimal_split() lines a column up on its decimal point (#273) – a post-hoc verb that renders the selected body column(s) as two adjacent RTF cells: the part before the first decimal separator (right-aligned) and the separator plus everything after it (left-aligned). The points then line up exactly whatever the font, instead of merely right-aligning the last character. It is an output option, not a data transformation: the data frame is never rewritten, the table’s total width is unchanged (only the column’s own width is divided, sized automatically from the widest left and right part, or by an explicit ratio), and the column-header block still renders over the original single column. A relational or sign prefix (<0.001, >=0.5, -0.7) travels with the left half and a suffix (45.6%, 12.3^{a}) with the right, so both hang outside the aligned point. Free text ("n (%)") and – unless include_compound = TRUE – compound values ("12.3 (4.56)") are rendered as one cell across the pair, exactly as today. Works on an rtftable or an as_rtftables() page list. The console preview does not show the split.

  • set_header_cell() sets/merges individual column-header cells (#271) – a post-hoc verb that places one or more [col_cell()]s (by column name or position, spanning via c(a, b), with borders / alignment / decorations) into a chosen header row of a built table, keeping the other cells intact. Merging currently-separate cells into one spanning cell is the core use. Works on an rtftable or an as_rtftables() page list. A target span must align to existing cell boundaries (it will not split an existing span).

Breaking changes

  • collapse_repeats() is now a post-hoc verb on a built table (#265) – its first argument is an [rtftable()] or a list of pages (an as_rtftables() result), not a bare data.frame. On a page list it collapses each page independently (runs reset at page breaks), so collapse_repeats(as_rtftables(x, ...), cols) now matches as_rtftables(x, collapse_repeats = cols, ...) exactly. cols resolve against the table’s final columns. The data.frame signature was removed (a .default method errors with guidance to build the table first).

New features

  • Unified blank-row group recognition (#263):
    • blank_rows_by_change() gains a group_by argument ("value" default, plus "indent" / "filled" / "auto") – the same group detection the pagination splits and blank_rows = "between_groups" use. The standalone spec is now a full-power equivalent that carries its own group_by.
    • blank_rows_by_change() and blank_rows_by_rule() now work inside as_rtftables() / paginate() (they previously errored with “Unrecognised blank_rows entry”); they are resolved per page.
  • collapse_repeats() post-hoc verb (#261, reworked in #265) – blanks consecutive repeated values in a built [rtftable()] or a list of pages; hierarchical suppression; suppressed cells become NA; no rows removed. See Breaking changes for the signature.
  • col_header_from_names() builds a spanning header from delimited column names (#259) – the reconstruction as_rtftables() applies automatically to a plain data.frame (e.g. "Drug A____N" / "Drug A____Mean" -> a Drug A span over N / Mean) is now a public helper. Pass a character vector (or a data.frame) and an optional sep; get back an rtf_col_header ready for set_col_header() or rtftable(col_header = ). (Internally, the .split_names_to_col_header() logic moved from as_rtftables.R to col_header.R; no behaviour change.)

Documentation

Internal

  • as_rtftables.R split into focused files (#255) – the self-contained internal helpers moved out of the ~1337-line orchestrator into R/col_resolve.R (.resolve_col_indices()), R/sort_body.R (sort helpers), R/col_drop.R (drop_cols hiding + metadata reindexing), and R/stub_vars.R (stub_vars stub build + reindex). No behaviour change.

New features

  • rtf_header_source() deparses a table’s column header back to editable rtf_col_header() source (#253) – render the current header as name-based rtf_col_header(...) text, tweak the labels/spans, and re-apply with set_col_header(). Three verbosity levels ("explicit" / "default" / "all"); a snippet = TRUE mode wraps it in a pipeable set_col_header(...) |> style_zone(...) that also reproduces header text alignment and header-zone borders; and add_span_level = TRUE previews adding a hierarchy level (the stub column stays, the rest are bundled under one spanning cell to label).
  • set_col_header() configures a column header against the final printed table (#251) – a new post-hoc verb (sibling of style_header() / add_header_row()) that sets the whole column header of a finished rtftable, or every page of an as_rtftables() list, resolving each cell by column name or visible position (1 = first printed column). Because it runs on the finished table there is no intermediate layout to reason about – no hidden drop_cols, no stub_vars position shifting. Pair it with the new rtf_columns() accessor (the final body column names) to verify names and order before writing a header. add_header_row() now also resolves cells by name.
  • col_header label rows can be placed by column name (#248) – a named character vector such as c(row_label = " Category", g1 = "G1", g2 = "G2", g3 = "G3", t = "Total") is now resolved against the data columns by name instead of by position, so a header no longer lands on the wrong columns when the data column order differs from what the author assumed. A named element targets that column (unknown or duplicate name is an error); an unnamed element is matched as a column name if one exists, otherwise placed positionally; two elements resolving to the same column is an error. A fully-unnamed vector keeps the exact legacy positional behaviour.
  • col_cell() spanning cells can address columns by name (#248) – pos now also accepts column-name string(s), e.g. col_cell(c("g1", "g3"), "Drug") or col_cell("total", "Total"), resolved against the data columns (unknown name is an error). Numeric pos is unchanged.

Breaking changes

  • A user-supplied col_header in as_rtftables() is now resolved against the final printed columns (#251), not the pre-drop_cols / post-stub_vars intermediate body. Headers written in the old coordinates must be renumbered to the visible columns (e.g. with a hidden first column, a span over the six data columns changes from col_cell(c(3, 8), ...) to col_cell(c(2, 7), ...), or better, address columns by name). col_spec, col_rel_width, row_title and cell_styles are unchanged (still reindexed from the input coordinates); adapter-derived (automatic) headers are also unchanged.

Documentation

  • New article: “A tfrmt table with several stub columns: one for sections, the rest as a stub” (#246) – a runnable recipe for a tfrmt location = "column" table whose three stub columns are split one way for auto_section sections (lab test code) and merged the other way into a two-level indented stub (a fixed heading + Toxicity Grade).

Bug fixes

  • blank_rows = "between_groups" now honours group_by (#263) – it always used "auto" detection regardless of the group_by argument; set_blank_rows() gains a matching group_by argument. (group_by defaults preserve prior output.)
  • as_rtftables(split = "by_value", stub_vars = ) now splits by group_col before building the stub (#244) – for a by_value split each group is an independent section, so the indented stub is now built per page after the split. Previously the stub was built once on the whole body, which (a) fragmented the split to one page per row and produced nameless group_0 pages when the outer group_col was blanked on the inserted label rows, and (b) collapsed a constant intermediate hierarchy level (e.g. LBCAT / group1 / label with a fixed group1) into a single stub label row that spanned every group. Put the inner hierarchy in stub_vars and the outer level in group_col. Every other split keeps the previous stub-then-paginate order.

New features

  • as_rtftables() gains stub_vars (plus stub_label / stub_indent / stub_group_summary) (#242) – folds [stub_cols()] into the extraction pipeline so a clinical indented stub can be built after a table is pulled out of its source, on any input that carries the hierarchy as separate columns (a plain data.frame, a gt(df), a multi-group-column table, or a tfrmt row_grp_plan(location = "column") table) – no manual extract → stub_cols() → re-feed round-trip. The named columns are merged into one stub column at position 1; drop_cols / group_col / sort_by and the group_by = "indent" group-aware splits then operate on the reshaped, post-stub columns. Position-indexed metadata (col_header including spanning headers, col_spec, col_header_align, per-cell cell_styles) is reindexed automatically. stub_vars = NULL (default) is a no-op, so existing calls and direct stub_cols() usage are unaffected.

  • print() on an rtftable now renders a visual preview of the table (#229) – the laid-out cells with column (and spanning) headers, per-column alignment, and horizontal rules drawn where the table’s border zones are actually set, so the console shows what the RTF will look like (much like printing a gt_tbl, rtables VTableTree, or gtsummary object) instead of only metadata. Long bodies truncate with a “… N more rows” note; an internal multi-table rtftable renders its first table. The rendered lines are available on their own via the new format() method, and the previous compact metadata block via the new summary() method (it is still appended below the table by print()). Box-drawing rules degrade to ASCII in a non-UTF-8 locale or under options(rtfreporter.print_ascii = TRUE).

  • stub_cols() now folds AE-style group-summary rows onto the group label row (#231). When a hierarchy row’s leaf is NA / "", or repeats its deepest non-empty parent value, that row supplies the group’s own summary statistic (e.g. a SOC-level adverse-event count); its cells are placed on the group’s label row instead of producing a valueless label row plus a phantom indented leaf. Controlled by the new group_summary argument (default c("empty", "parent"); set "none" for the previous behaviour). Demographic tables – where the leaf is a statistic name such as "n" / "Mean (SD)", never NA and never equal to the parent – are unchanged, and the folding nests at intermediate levels for vars deeper than two.

Behavior changes

  • as_rtftables() / as_rtftable() now carry a gt table’s explicit tab_style() styling (#223) – the new gt "styles" read_meta token, enabled by the default read_meta = TRUE. Carried, because the rtftable class can hold them: cell_borders() and the cell_text() bold / italic / underline / align properties on column spanners, column labels and body/stub cells, plus body text colour (so e.g. gtsummary::bold_labels() now arrives bold). A border/underline on a column-label cell promotes the label row to single-column cells – the same mechanism style_header() uses. The style_*() verbs still override whatever was read (adapters populate first; verbs win per side / per field). Not carried: cell_fill(), fonts/sizes, header text colour, and gt’s theme borders (tab_options()) – rtfreporter’s border presets keep governing the frame. Pass read_meta = FALSE or a token list without "styles" for the previous behaviour. Transparent gt borders are likewise skipped (#226): tfrmt overlays transparent borders everywhere to hide gt’s theme lines, and an invisible border carries no author intent – a plain tfrmt::print_to_gt() table converts exactly as before.

  • as_rtftables() / as_rtftable() now read a plain data.frame’s column label attributes (the haven / labelled / xportr convention) as header labels (#210) – the "labels" read_meta token, enabled by the default read_meta = TRUE. A labelled data.frame that previously showed its raw column names now shows the labels; pass read_meta = FALSE (or drop the attributes) for the old headers. Columns without a label keep their name, the display names feed the header_sep spanning reconstruction, and an explicit col_header still always wins. Token resolution is lenient for data.frames (unknown tokens are ignored) so a list() mixing table objects and data.frames can share one read_meta.

  • rtfplot() / rtf_figures() now embed a figure at its native size (100%) by default (#208), instead of scaling it to min(writable width, 9in). The display size is the image’s pixel dimensions divided by its embedded DPI (read from the PNG pHYs chunk / JPEG JFIF density) – e.g. a 2500 x 1438 px image at 300 DPI embeds at 2500/300 x 1438/300 in. Files with no resolution metadata assume the new rtfreporter.figure.default_dpi option (factory 96). An explicit width_twips / height_twips still wins, and the native size is not auto-capped to the page – pass width_twips to shrink an over-wide figure.

New features

  • Post-hoc styling verbs (#218): style_header(), style_cols(), style_body(), style_zone() and add_header_row() restyle an already-built rtftable – or every page of an as_rtftables() list at once – addressing header rows, body rows and columns by position, so a table read with read_meta = TRUE keeps everything the adapter extracted and only the requested detail changes:

    pages <- as_rtftables(gt_obj, read_meta = TRUE) |>
      style_header(row = 2, cols = 2:4,
                   border = rtf_border(top    = rtf_border_side("single"),
                                       bottom = rtf_border_side("none"))) |>
      style_body(rows = ~ label == "Mean", bold = TRUE)

    Merge semantics everywhere are the border rule generalized: last writer wins, per side / per field. style_body(rows = ) takes positions, a logical vector, a predicate function or a one-sided formula (evaluated in the body data); on a page list only the predicate forms are allowed, since page-local row numbers are ambiguous. A border/underline request on a character label row transparently promotes it to an equivalent cell row (render-equivalence is pinned by a test). See the new Level 4 section of the Borders article.

  • Per-cell body alignment and borders (#221): cell_styles gains align (NA = inherit the column alignment) and border (a list of rtf_border() per column, merged side-by-side on top of the row’s resolved zone border – so rtf_border_side("none") erases a zone rule), and style_body() gains the matching align = / border = arguments. This completes the invariant that every visual attribute the data-row renderer takes from col_spec or the border zones can be overridden per cell through the one cell_styles channel, with the same last-writer-wins-per-side rule. The classic uses are a rule under a summary/total row and one row’s alignment flipped:

    tbl |> style_body(rows = ~ Item == "Total",
                      border = rtf_border(bottom = rtf_border_side("single")))

    Per-row row height and cell padding are deliberately not added: TFL conventions keep them uniform, and they are already settable per table and document-wide; vertical cell padding remains with the parked 4-direction \clpad* work (#125).

  • as_rtftables() now accepts a gt_group (#214) – gt’s multi-table container from gt::gt_group() / gt::gt_split(), and what tfrmt’s print_to_gt() returns when the spec has a page_plan. Each member table becomes its own page set (expanded via the exported gt::grp_pull()), so a paginated tfrmt render can be handed over directly. as_rtftable() unwraps a single-member gt_group and errors informatively for a multi-member one. (Previously a gt_group slipped into the list branch, which iterated its internal slots and failed with a misleading "got 'logical'" error.)

  • gtsummary tbl_split containers are now supported by design (#215) – tbl_split_by_rows() / tbl_split_by_columns() output is unwrapped explicitly into its member gtsummary tables, one page set per member (before, it only happened to work through the generic list branch, with no tests or documentation guarding it). as_rtftable() unwraps a single-member tbl_split and errors informatively for a multi-member one, mirroring the gt_group behaviour.

  • New stub_cols() (#210): finish a tidy “hierarchy columns + statistic columns” data.frame for clinical display by merging the hierarchy columns (parent first, leaf last) into one indented stub column – each parent value becomes a full-width label row (the other columns render blank) and the leaf rows are indented with non-breaking spaces. The output is a plain data.frame, so as_rtftables()’s indent-based group detection, the group-aware splits ((Cont.), widow/orphan control) and blank_rows = "between_groups" all work on it unchanged. An NA / empty parent cell emits no label row and no indent, keeping summary rows (e.g. “Any adverse event”) flush left. See the Importing tables article, which also maps reporter::create_table() spec features to their rtfreporter equivalents.

  • as_rtftables() / as_rtftable() gain header_sep, reconstructing a spanning (multi-row) column header from a plain data.frame’s delimited column names. The default recognizes both "____" (from ydisctools::pivot_stats_wider()) and "___tlang_delim___" (tfrmt’s column delimiter); pass your own separator(s) to override, or NULL to disable. Adjacent columns that share a label and ancestor path merge into a spanning cell; columns without a separator (id columns) keep their name on the leaf row. With "____" a doubled separator ("________") inserts a blank cell at that header level. Only applies to plain data.frames – gt / gtsummary / rtables / tern / flextable / huxtable already carry real spanning metadata. See the Importing tables article.

  • New combine_sections() assembles several converted tables – each an rtftable or an as_rtftables() page list – into one flat, named list ready for rtf_tables(..., auto_section = TRUE), so each argument renders as one clean RTF section (its name placed on the first page, the remaining pages falling through into the same section). This is the tidy way to section multi-page or row-grouped tables (gt / gtsummary / rtables / tern), where the grouping is not a body column; for column-grouped bodies, as_rtftables(x, split = "by_value") already names pages per group. See the Headers and footers article.

  • print() methods for the three central objects – rtftable, rtfplot and rtfreport – now show a concise, reporting-oriented summary instead of dumping the raw list (#196). For an rtftable: body dimensions, the column (leaf) labels, row-title column(s), border and width mode, any attached title / footnote line counts, and a short preview of the rendered body. For an rtfplot: the image’s native pixel size versus the embedded display size (in twips and inches) and alignment. For an rtfreport: page / section counts, the page geometry, and the font / colour table sizes.

Behavior changes

  • Default page margins are now based on the FDA eCTD Portable Document Format (PDF) Specifications minimums (#204, #206): a uniform 0.75" on all four sides (was 0.9" top/bottom and 0.6" left/right). The bottom leaves room for an overall page-number footer. A wide TFL that does not fit the 0.75” left/right can trade horizontal room by narrowing them to eCTD’s 0.5" (still clears the 0.375” floor) – per document via rtf_document(page = ...) / rtf_page(), or site-wide via the rtfreporter.page.margin_left_in / .margin_right_in options. See the Page setup article (“Changing the margins”).
  • The header / footer band distance (\headery / \footery) now defaults to the full top / bottom margin, not half of it (#204), so the band sits at the margin boundary. With the new margins \headery / \footery go from 648 to 1080 twips. Set header_dist_in / footer_dist_in (or the rtfreporter.page.* options) to pin either explicitly, as before.

Bug fixes

  • Border colours on table cells now actually render (#224). A coloured [rtf_border_side()] used on a zone (rtf_table_border()), a column (col_spec$border), a header cell (col_cell(border = )) or a body cell (cell_styles / style_body(border = )) was collected into the RTF colour table but the \brdrcf command was never emitted for table-cell borders (only the page header/footer band passed the colour map), so every such rule rendered black. The colour map is now threaded through all cell-definition writers, and the colour collector also picks up header-cell and per-cell border colours – so a coloured gt::tab_style() border arrives coloured end-to-end via the "styles" token.

  • as_rtftables() / as_rtftable() no longer error on a gt_tbl with more than one stub column (#193). gt::extract_body() assumes a single stub and fails with “the condition has length > 1” on such tables – which tfrmt produces via row_grp_plan(label_loc = element_row_grp_loc(location = "column")). The gt adapter now detects the multi-stub case and reads the body from the table’s own _data / _boxhead slots instead (list access only, no gt internals), so the group and label columns come through as ordinary columns.

New features

  • as_rtftables() / as_rtftable() gain sort_by / sort_desc to order the body rows before pagination (#183). Name (or index) the sort keys – in the input body’s coordinates, the same space as group_col / drop_cols – with optional per-key descending direction. Because the sort runs first, group detection, (Cont.) labels and blank_rows positions all see the sorted order; a sort key may also be listed in drop_cols to order on a column that is not printed (a hidden “sort-key” carrier column). The sort is stable and NA keys sort last. (Intended for flat / tabular bodies; on a gt / rtables body whose group + child rows are already interleaved, sort the source data before building the table instead.)

  • as_rtftables() / as_rtftable() gain a drop_cols argument to hide a column from the printed table while still using it for pagination / grouping (#184). Name (or index) the columns – in the input body’s coordinates, the same space as group_col / collapse_repeats – and they stay present through the split (so group_col, collapse_repeats, and blank_rows_by_change() can reference them), then are removed from every page before it is rendered. This makes a column usable as a hidden grouping / sort-key / carrier column without it appearing in the report. All position-indexed metadata (col_header incl. spanning headers, col_spec, column widths, col_header_align, row_title, per-cell cell_styles) is reindexed automatically to the remaining columns; drop_cols must leave at least one column to display.

Documentation

  • showcase RTF generators now derive from the articles instead of duplicating them (#179). data-raw/showcase_dm.R / showcase_ae.R are now ~20-line drivers that knitr::purl() the matching showcase-*.Rmd and run its code chunks, writing inst/rtf-examples/showcase/{dm,ae}_*.rtf. The article is the single source of truth, so the committed RTFs can no longer drift from the shown code (the previous duplication caused exactly that – e.g. a footnote fix landed in the article but not the generator). The articles’ display-only chunks (snapshot helper, library() setup, img-* chunks) are marked purl = FALSE. The old generators’ pre-#169 helpers (reorder_ae / add_any_ae_row / render_* wrappers) are retired with the duplication. All DM + AE RTFs were regenerated from the articles. Refs #146.

  • showcase-ae article + data-raw/showcase_ae.R generator + regenerated inst/rtf-examples/showcase/ae_*.rtf (#177):

    • Zero cells now align. fmt_ae previously collapsed a zero cell “0 (0.0%)” to a bare “0”, which the count/percent aligner then left ragged. It now does the reverse – expands a bare “0” to “0 (0.0%)” – and is used by every framework including tern (whose count_occurrences() emits bare zeros), so all seven tables show an aligned 0 (0.0%).
    • A SOC now splits across a page with (Cont.). AE_MAX_ROWS 32 -> 16, so the table runs to 3 pages and a System Organ Class is continued with (Cont.) at the top of the next page (verified with the real splitter).
    • Generator caught up to the article. It still passed read_meta = TRUE (so the committed RTFs carried the gtsummary {n} ({p}%) footnote that #175 removed from the article) and used the old collapse / max_rows = 32; it now matches, and the AE RTFs were regenerated.
    • AE_SORT and the PT orderings move back to the data block (revising #176), each commented with which blocks use it, so every framework block is copy-paste-runnable once the data block has run. The single-use helpers add_overall_row() / bake_indent() stay in their blocks. Refs #146.
  • showcase-ae article, three fixes from maintainer review (#175): (1) lower AE_MAX_ROWS 32 -> 30 so page 1 still fits once Word re-renders the RTF; (2) pass read_meta = FALSE in every framework block so the gtsummary hierarchical tables’ {n} ({p}%) statistic footnote (which appeared as stray lines below the table) is no longer read – each block already supplies the column header / spec / widths and the footer, so no framework metadata is needed; (3) localise variables – AE_SORT (gtsummary-family only) moves to the gtsummary block, the .pt_order / .keep_pt / .soc_pt orderings (transpose-and-set only) move to the Tplyr block, and the single-use helpers add_overall_row() / bake_indent() move into the cards + gtsummary and flextable / huxtable blocks respectively; only genuinely shared furniture stays in the setup block. Refs #146.

  • showcase article split into two: showcase-dm.Rmd (“Same report, every framework: Demographics”) and showcase-ae.Rmd (“… Adverse events”) (#173). The old single-page URL articles/showcase.html now redirects to articles/showcase-dm.html. The AE article is now self-contained (it builds adsl itself) and cross-links the DM article, and vice versa.

  • showcase AE article: restructured so rtfreporter’s own functions are used directly instead of behind a render_ae() wrapper (#173), matching the DM treatment in #171. The shared furniture (rtf_header() / rtf_footer() + the column header / spec / widths) is defined once, and each framework block ends with the full as_rtftables() -> rtf_document() |> rtf_section() |> rtf_tables() -> generate_rtfreport() pipeline written out in the open. The footer is now a single shared ae_footer (was built per framework inside the wrapper); rendered RTF is unchanged. Refs #146.

  • showcase article, DM chapter: restructured so rtfreporter’s own functions are used directly instead of behind a render_dm() wrapper (#171). The shared report furniture (rtf_header() / rtf_footer() plus the column header, spec and widths) is now defined once, and each framework block ends with the full as_rtftables() -> rtf_document() |> rtf_section() |> rtf_tables() -> generate_rtfreport() pipeline written out in the open. The overrides the old helper applied (uniform column header, 40:20:20:20 widths, alignment, group blanks) are now expressed as plain as_rtftables() arguments (col_header / col_spec / col_rel_width / blank_rows = "between_groups"), so the rendered RTF is unchanged. Refs #146.

  • showcase article, AE gtsummary / cards + gtsummary examples: the canonical SOC/PT order (SOCs alphabetical, PTs by total subject count descending, ties A -> Z) is now produced by gtsummary’s native sort_hierarchical() with a per-variable sort (list(AESOC ~ "alphanumeric", AEDECOD ~ "descending")), replacing the custom reorder_ae() $table_body surgery, which is removed (#169). The standalone tbl_hierarchical() overall-row label is set via the native label argument. Verified byte-identical row order / render to the previous helper. .pt_order is kept for the Tplyr / tfrmt transpose-and-set blocks. Refs #146.

  • showcase article, AE cards + gtsummary example: the any-AE overall row is now derived generically from the ARD instead of by re-counting the raw adae data frame (#167). A second cards::ard_stack_hierarchical(..., over_variables = TRUE) computes the distinct-subject “any event” count, and a generic add_overall_row(tbl, ard) reads it straight off that ARD (mapping by-levels to the table’s stat_* columns via its own header, formatting with the table’s glue statistic). reorder_ae() now takes pt_order explicitly, and the opaque ae_gtsummary_tbl() wrapper is gone – the tbl_hierarchical() build is shown inline – so the helpers stay generic / package-idiomatic rather than sample-specific. Rendered numbers unchanged. Refs #146.

  • showcase article “Same report, every table framework”: every code block is now copy-paste runnable, from data creation to the written .rtf (#165). The article-only helpers that the prose referred to (reorder_ae(), fmt_ae, add_any_ae_row(), bake_indent(), ae_gtsummary_tbl(), and a render_dm() / render_ae() furniture helper) are now defined in the article’s setup blocks, the abbreviated ... reshapes (Tplyr DM, cards + tfrmt DM/AE, Tplyr AE) are spelled out in full, and each framework block ends by rendering its actual .rtf – faithful to the data-raw/showcase_*.R generators that produced the screenshots. Chunks stay eval = FALSE (the screenshots are unchanged); run a section’s setup block once, then any framework block. Refs #146.

  • tlg-catalog article: the data preparation is now verbatim from the pharmaverse examples (#163). The demographic table drops screen failures (!ACTARM %in% "Screen Failure") and recodes SEX / AGEGR1, so the “All Patients” column is N=254 (not 306). The cards + tfrmt adverse-events table now uses the example’s safety / treatment-emergent filters (SAFFL == "Y", SAFFL == "Y" & TRTEMFL == "Y"). The tern adverse-events table already matched. Generator data-raw/gen_tlg_catalog_rtf.R and the article chunks regenerated.

  • New showcase article “Same report, every table framework” (#146): the same demographics (DM) report built from gtsummary, gtsummary on a cards/cardx ARD, rtables/tern, tfrmt and Tplyr, each rendered to RTF by rtfreporter – contrasting the two metadata philosophies (let rtfreporter read the framework’s headers via read_meta, vs. set them in rtfreporter on a body-only result). The tfrmt example is shown two ways – formatted by tfrmt, and formatted by rtfreporter (align_count_pct) from the same table – to contrast who owns the n (x.x%) count/percent alignment. Generator in data-raw/showcase_dm.R.

  • Showcase gained an Adverse Events (AE) section (#146): subjects with a treatment-emergent AE by System Organ Class / Preferred Term. The SOC-level count sits on the SOC row itself (an independent distinct-subject count, not the sum of its PTs), with the overall any-AE on top and PTs (in >= 3% of any group) indented under each SOC. SOCs are listed alphabetically; PTs within a SOC are ordered by subject count (all arms) then alphabetically. Zero cells render as a bare 0 at both levels, and the row-label column header is left-aligned. Built with rtables/tern (summarize_row_groups() for the SOC row) and paginated by rtfreporter with split = "group_force" so a class split across a page repeats with (Cont.). Generator in data-raw/showcase_ae.R.

  • The AE showcase now also builds the same table with gtsummary (tbl_hierarchical()), cards + gtsummary (tbl_ard_hierarchical() on a hierarchical ARD), Tplyr (transpose-and-set) and cards + tfrmt – the tfrmt version uses a group == label row to put the SOC count on the SOC row. The same gtsummary table is also converted to a flextable and a huxtable (as_flex_table() / as_hux_table()) and read back. All seven renderings produce the same numbers and SOC / PT order, paginated by rtfreporter.

New features

  • New constructors rtf_page() and rtf_default_format() turn the structured rtf_document() / rtf_config() settings into first-class S3 objects whose defaults are visible in their own signature / Usage (#152): rtf_document(page = rtf_page(paper_size = "A4", orientation = "portrait")). Site defaults still apply – an argument you do not pass falls back to the matching rtfreporter.* option (explicit argument > option > the factory default shown in the signature). A plain named list is still accepted, so existing code is unchanged.

  • The page title now renders as plain text paragraphs across the writable page width again (centred over the page), instead of the content-width single-column table introduced in v0.4.0 (#144). The legacy table form is available with rtf_document(default_format = list(title_format = "table")) (or the rtfreporter.title_format option). Footnotes are unchanged – they keep the content-width table form (so the separator rule is preserved); switch them with footnote_format = "text" if desired. The text form honours each line’s align / bold / italic / underline / color and the document-wide cell padding; a row border only applies in the table form.

  • rtftable() gains a markup argument (forwarded by as_rtftables() / rtf_tables(), with a document-wide default via rtf_document(default_format = list(markup = )) / the rtfreporter.markup option) controlling cell-text markup with two tokens (#142): "script" turns ^{...} / _{...} into super/subscript, and "relational" converts the literal ">=" / "<=" into the >= / <= symbols (U+2265 / U+2264). "all" enables both, "none" neither. The default is "script", which changes prior behaviour: >= / <= are now left literal unless you request "relational" / "all", while super/subscript (e.g. adapter footnote marks ^{N}) keep working. Applies to data cells, column / spanning headers, and title / footnote blocks.

Bug fixes

  • as_rtftables(align_count_pct = TRUE) no longer mis-aligns mixed columns. Only "integer (real)" count-percent cells are reformatted by realign_count_pct() (the real part may end in %); a bare integer (a plain N such as "86") and a continuous statistic ("75.2 (8.59)", whose “count” is not a bare integer) are now left unchanged. Previously (the #80 behaviour) a bare integer was right-padded to the column’s maximum width – which included non-count cells like "75.2 (8.59)" (12 chars) – so it gained stray leading spaces and the count digits did not line up, worst with a %. The realign_count_pct() / format_count_pct() conversion itself is unchanged (e.g. "0 (0.0%)" still becomes "0", "10 (100.0)" still "10 (100)") (#148).

Documentation

  • Reference pages are being made easier to read (#150): list-valued parameters now break their keys out with a \describe{} list (so each sub-option is headlined), and examples are fuller and story-driven. First batch: rtf_document() (the page / default_format keys + a full worked example), rtf_section() (the secinfo keys + a self-contained example), and rtf_header() / rtf_footer() (the l / c / r cell rule + page-number tokens). More functions to follow.
  • Continued the sweep (#150): the read_gt token list in rtf_tables(), the accepted row forms in rtf_col_header(), and the cover fields / toc shapes / toc_page_numbering options in assemble_rtf() now use \describe{} lists instead of plain bullet prose.

rtfreporter 0.4.0

New features

  • rtftable() gains a blank_row_normalize argument controlling two render-time blank-row clean-ups, forwarded by as_rtftables() / rtf_tables() (#136): "detect" renders a data row whose every cell is NA / "" (empty or ASCII-whitespace only) as a single full-width blank row instead of one empty cell per column; "collapse" reduces a run of two or more consecutive blank rows (separators and/or detected empty rows) to one. Default c("detect", "collapse") (both on); pass "none" / NULL to disable. Both act on the rendered table, so for a paginated table they apply per page (after the split). NBSP indentation is not whitespace-empty, so indented sub-rows are never mistaken for blanks. Note: with the default on, an existing all-empty data row now renders as one merged blank cell rather than N empty cells.

  • as_rtftables() / as_rtftable() gain a collapse_repeats argument that blanks consecutive repeated values in the named column(s) – repeat suppression for row-label / key columns (#131). Only the first value of each run is kept; the rest become NA (rendered as an empty cell, so no row is removed). Pass a character / integer vector of columns. With several columns the suppression is hierarchical: the first column collapses on its own value, each later column on its combination with all earlier listed columns (a change in any higher column restarts the lower column’s run). It runs per page, after the split, so pagination still sees the original repeated values – group boundaries and " (Cont.)" labels stay correct, and a group continued onto the next page repeats its label at the top. (In group_by terms: value-based grouping happens first, then the column is collapsed to a "filled"-style display.)

  • as_rtftables() group-aware pagination (split = "group_safe" / "group_force" / "by_value") gains a group_by argument that selects how a group boundary is detected, decoupled from group_col (which now only selects which column) (#128):

    • "indent" – a non-empty, non-whitespace-leading cell starts a group (the previous behaviour, now usable on any column, not just column 1);
    • "value" – maximal runs of the same value form a group;
    • "filled" – a non-empty cell starts a group, NA / "" cells are members (new);
    • "auto" (default) – picks one of the above from the column content, so existing tables are unaffected.

    group_col now accepts an integer index and defaults to column 1, and the grouping column need not be the first column. (For gt/gtsummary the body keeps gt’s column ids, e.g. "label"; for rtables/flextable/huxtable the columns are renamed V1, V2, … – so an integer index is the most portable.)

  • as_rtftables() / as_rtftable() can now read huxtable objects (#120), joining gt, gtsummary, rtables/tern and flextable. The displayed text is read with the huxtable’s number_format applied; header rows (header_rows()) become the column header, colspan() becomes the spanning header, per-column align() is carried, and the caption() becomes the page title. (A huxtable is a data.frame subclass, so it is detected before the plain-data.frame path.) huxtable has no footnote concept, so none is extracted; per-cell styling is not carried. New read_meta tokens for huxtable: "col_header", "alignment", "spanning", "titles".

  • Document-wide style defaults for row height and cell padding (#124). Set row_height_twips, cell_padding_left_twips and cell_padding_right_twips once for a whole report – via rtf_document(default_format = list(...)) / rtf_config(default_format = list(...)), or globally as the rtfreporter.row_height_twips / rtfreporter.cell_padding_left_twips / rtfreporter.cell_padding_right_twips options – and they apply uniformly to the content table, the page header/footer band, and the title/footnote blocks. They are defaults: a per-module value (rtftable(), rtf_header(), rtf_footer(), rtf_table_style()) always overrides them. Resolution is highest-wins: per-module > document default_format > rtfreporter.* option > font-aware / resource baseline. Default output is unchanged.

    As part of this, rtftable() / rtf_tables() cell_padding_left_twips / cell_padding_right_twips now default to NULL (“inherit”) instead of 0L, so an unset padding can pick up the document default while an explicit 0 still renders flush. (This also repairs a dormant rtf_table_style() path whose cell padding never seeded the table.)

  • as_rtftables() / as_rtftable() can now read flextable objects (#119), joining gt, gtsummary and rtables/tern. The displayed cell text is read via flextable’s exported introspection API (information_data_chunk() / information_data_paragraph()), so set_header_labels() relabelling and colformat_*() formatting are honoured. Column (leaf) labels, spanning headers, per-column alignment, the caption (title) and footer lines (footnotes) are carried; per-cell styling, explicit widths and footnote() reference marks are not. New read_meta tokens for flextable: "col_header", "alignment", "spanning", "titles", "footnotes".

  • Titles and footnotes are reworked (#117):

    • They now render as a single-column table the same width as the content (table or figure), so the blocks line up with the content above/below instead of spanning the full page width.
    • titles / footnotes accept a length-1 list as well as one-per-page: a single block is applied to every page (common title/footnote). Applies to rtf_titles(), rtf_footnotes(), rtf_tables(), and rtf_figures().
    • Per-row styling: a block element may be a styled list(text=, align=, bold=, italic=, underline=, color=, border=) (in addition to a plain character vector). Defaults are unchanged (title rows centred + bold; footnote rows left, with a top-rule separator on the first row), and the footnote separator is now overridable per row via border=.
  • Package defaults are now configurable options read through the function/page-key defaults. Every rtfreporter.* default (paper size, orientation, margins, font, font size) resolves as: explicit argument > getOption("rtfreporter.*") > factory baseline. A site can set its own defaults in Rprofile.site – e.g. options(rtfreporter.font = "Arial") – and every report inherits them, while the rtfreporter.* keys remain a single source of truth seeded at load. New helpers rtfreporter_options() (snapshot the resolved configuration, useful as an audit trail) and rtfreporter_reset_defaults() (restore the factory baseline) (#111).

  • The header/footer band distance is now an explicit page setting, header_dist_in / footer_dist_in (or the matching rtfreporter.page.* options), instead of being computed silently. When left unset it still defaults to half the top/bottom margin, preserving previous output (#111).

  • Page geometry gains a paper_size preset key: page = list(paper_size = "A4") selects a named size ("letter", "legal", "A4", "A3", "A5", case-insensitive) and orients it (paper_size = "A4" is A4 landscape in one line). Geometry resolution is also rationalized: explicit width_in / height_in now win and are used as given, with the orientation inferred from them; an orientation that contradicts the dimensions warns and the dimensions are kept (this supersedes the #106 behaviour where orientation re-oriented the given dimensions). A paper_size supplied alongside explicit dimensions is ignored with a warning. Applies to rtf_document(page=) and rtf_config(page=) (#110).

  • rtf_config() now merges page and default_format per key instead of replacing the whole block, so changing only the paper size (for example rtf_config(page = list(width_in = 11.69, height_in = 8.27))) keeps the document’s existing orientation and margins. This clarifies the split between the two functions: rtf_document() constructs a new document from defaults, while rtf_config() edits an already-composed document (content and sections preserved), changing only the keys you pass. font_table / color_table are still whole-object replacements (#108).

  • Page orientation is now authoritative: when set, the long/short page sides are assigned to match it, so rtf_document(page = list(orientation = "landscape", width_in = 8.27, height_in = 11.69)) (A4 sizes in either order) reliably produces A4 landscape. When orientation is omitted it is inferred from the dimensions (width_in >= height_in means landscape), and an invalid orientation is now rejected. Applies to rtf_document(page=) and rtf_config(page=) (#106).

Internal

  • Added multi-group / multi-page pagination tests for every table-object adapter (gt, gtsummary, rtables/tern, flextable, huxtable), exercising the shared as_rtftables() pipeline (grouping, pagination, blank rows, collapse_repeats) end to end. Test-only; no behaviour change (#134).

Bug fixes

  • Multi-page documents now insert a page break that Word actually honours. The break between pages is emitted as {\pard\fs2\par}\page{\pard\fs2\par} – the form r2rtf and reporter use – with the \page flanked by empty paragraphs; previously a bare \page after a table \row was dropped by Word, so the next page rendered flush against the previous one. Section-separated pages (\sect + \sectd\sbkpage) were unaffected. Most visible with as_rtftables(split = "group_safe") and other plain (no page-number token) multi-page splits (#130, #138).

  • The automatic spanning-header group underline is now drawn only where the column grouping actually changes below the cell (the next header row subdivides the span). Previously every multi-column spanning cell that was not on the last header row was underlined, so a span repeated unchanged across two header rows got a spurious rule between them (#102).

New features

  • New row_title argument on rtftable() (and rtf_tables() / as_rtftables()) names the row-heading columns — an integer vector of column indices or column names, defaulting to the first column (e.g. row_title = c(1, 2)) (#98).

Behaviour change

  • The per-column default data alignment now depends on row_title: row-heading columns default to "left" and every other column defaults to "center" (previously every column defaulted to "left"). Explicit col_spec alignment, an rtf_table_style, or alignment read from a gt/rtables source still override the default; column headers continue to follow their column’s data alignment (#98).

rtfreporter 0.3.4

This release rolls up all development work since v0.1.0 and is published as the current stable version. Going forward the planned milestones are v0.4.0 (CRAN-submission preparation), v0.5.0 (CRAN registration) and v1.0.0 (stable API; the lifecycle: experimental badge is removed).

Documentation

  • README “Status & roadmap” rewritten to reflect the current state: v0.3.4 is the latest release, with the development version on main advancing only the patch per PR, and the v0.4.0 / v0.5.0 / v1.0.0 milestones laid out. A version-guard CI workflow blocks an unintended MINOR/MAJOR bump (a labelled release PR is required) (#83, #92, #94).
  • as_rtftables() help is now exhaustive and per-argument. The pagination controls that were lumped into a single @param block are split into one entry each (split, max_rows, split_rows, group_col, cont_label, blank_rows, blank_row_first/blank_row_end, align_count_pct), split enumerates every strategy and what it requires, blank_rows documents all accepted value forms (integer positions incl. 0/-1, blank_rows_by_change(), blank_rows_by_rule(), and combining list()s), and runnable @examples now cover the common arguments (#85).
  • The “Importing tables” article now documents how to write a custom cell_format function: the calling contract (one column-vector argument, same-length character return, leave non-matching cells unchanged, pad with the non-breaking space), how single-function vs list dispatch picks columns, and a complete runnable example that appends a unit and right-aligns a column via a one-argument closure (#84).
  • CONTRIBUTING.md now describes a concrete backlog/progress-tracking scheme for multiple contributors: a “rtfreporter roadmap” GitHub Projects board with defined Status columns (Backlog/Ready/In progress/In review/Blocked/Done), Priority/Area/Agent custom fields, built-in automation, and the one-time maintainer setup commands – alongside how it relates to the existing exec:* dispatch labels and release milestones (#83).

Output robustness

  • Generated RTF now emits \headery / \footery (header/footer band distance from the page edge), set to half the top/bottom margin so the header and footer sit inside the margin instead of at Word’s built-in default, which could place them outside the body (#82).
  • Hardened the document preamble for portable, renderer-independent output: \ansicpg1252 (code page), \deflang1033 (default language), \uc1 (one fallback char per \uN Unicode escape, matching the \uN? form this package emits), \fcharset0 on the font, and \widowctrl (#82).

New features

  • Fine-grained column-header borders. col_cell() gains a border argument: an rtf_border() applied to that one header cell, overriding the zone border and the automatic group underline. Combined with the new rtf_border_side("none") (“explicit no line”, which overrides on merge), this lets you add or remove individual rules in a multi-row header – e.g. suppress the underline beneath a single spanning cell (#81).
  • New article “Borders and rules” documenting the three levels of border control (preset, rtf_table_border() zones, per-column col_spec and per-cell col_cell()) with runnable examples (#81).

Bug fixes

  • as_rtftables(align_count_pct = TRUE) no longer reformats integer-only columns. Previously a plain count column (cells like 3, with no n (xx.x) cells anywhere in the column) had leading spaces wrongly inserted. Bare-integer alignment is now applied only to columns that actually mix integers with count-percent cells (#80).

Internal: constructor cleanup (no API change)

Collapsed the thin rtftable() / rtfplot() wrapper layer (a vestige of the R6 era) into the public constructors: the exported rtftable() / rtfplot() are now the implementations directly (the internal .new_rtftable() / .new_rtfplot() and R/wrappers.R are gone). No change to behaviour, exported names, or signatures (#72).

Breaking changes: removed no-op deprecated functions

Pre-CRAN cleanup of long-deprecated no-ops:

  • Removed rtf_table_format(), rtf_header_format(), rtf_footer_format(), and rtf_figure_format() (they only warned and returned the document unchanged). Pass the formatting arguments to rtf_tables() / rtf_figures(), or build items with rtftable() / rtfplot().
  • Removed the deprecated top_border argument of rtf_header() / rtf_footer(). Use border = rtf_border_top() (or border = NULL). (#70)

Bug fix: assemble_rtf(toc = "auto") no longer errors

assemble_rtf(toc = "auto") errored on any input file that had a title (.extract_first_title() used a function replacement in gsub(), which base R does not support). Title extraction now un-escapes \uN? sequences via regmatches(), so auto-TOC works – including non-ASCII titles (#60).

Count blank rows toward max_rows during pagination (opt-in)

as_rtftables(count_blank_rows = TRUE) now counts blank separator rows toward max_rows, so a paginated page (data rows + blanks) does not overflow the budget. The blank positions resolved from blank_rows (and from any rtf_blank_rows attribute already on the input) are materialised before the split and re-attached per page afterwards, with a leading blank suppressed at the top of each page. blank_row_first / blank_row_end remain post-split page furniture and are not counted. Default FALSE keeps the previous behaviour (blanks added after the split, not counted) (#58).

Bug fix: border = "none" now works

rtftable(border = "none") (and the rtf_tables() border = "none" override) were documented as “no borders” but errored. "none" is now accepted as an alias for NULL (no borders) (#54).

as_rtftable() now accepts a data.frame / tibble

as_rtftable() (singular) previously rejected plain data.frames even though as_rtftables() accepted them. It now accepts a data.frame / tibble too (returning one rtftable), making the singular and plural forms consistent (#48).

Cell text colour, and color_table is now wired in

Tables can now carry text colour: set color = "#RRGGBB" on a col_spec entry for a whole column, or on a cell_styles row (a length-ncol vector, NA = no override) for individual cells. Colours are rendered as \cf and added to the document colour table automatically. The color_table argument of rtf_document() is now wired into the rendered palette (it was previously ignored); black and white remain reserved. (Cell background fill and header/footer text colour are planned follow-ups.) (#39)

Bug fix: portrait orientation is now honored

rtf_document(page = list(orientation = "portrait")) now produces a portrait document. The document preamble previously hardcoded \landscape, which forced the first section landscape in many viewers regardless of the setting; the document-level \landscape is now emitted only for landscape pages (matching the already-correct section-level behaviour) (#31).

Test suite strengthened (coverage > 90%)

Added methodology-driven tests — defensive-contract / error-path coverage, boundary-value analysis, equivalence partitioning on pure helpers, and behavioural checks of the auto_width path — covering rtftable() argument validation, multi-DF construction, the rtf_tables() override path, .flatten_col_header_labels(), the count/percent formatters, and the remaining rtf_replace_text() validation. Package coverage rises from ~89% to >90% (#25).

Built-in pagination strategies exposed as reusable factories

The built-in page-split strategies are now available as exported factory functions — page_split_none(), page_split_rows(), page_split_group_safe(), page_split_group_force(), page_split_by_value() — each returning a function usable as as_rtftables(split = ). The string forms (split = "group_safe", …) are exact aliases that delegate to these factories, so built-in and custom splitters now share one contract and can be reused or composed (#22).

as_rtftables(split = ) accepts a custom pagination function

split may now be a function for bespoke page-break rules, in addition to the built-in strategies. It is called on the (cell-formatted) body and must return a list of data.frames (one per page; named elements become page names); the shared pipeline — blank rows, metadata, per-page assembly, and header/width/style replication — is applied to its output unchanged. The new add_cont_label() helper re-creates the " (Cont.)" continuation row for custom splitters (#21).

rtfreporter 0.1.0

First public GitHub release. (CRAN submission is deferred to v0.2.0.)

rtf_replace_text() — post-processing find/replace on a rendered RTF

New helper for the “last mile” of TLG production: perform find-and-replace directly on a generated .rtf file. Supports fixed or regex targets, case-insensitive matching, vectorised target/replacement, in-place editing with an automatic .bak, or writing to a separate output_file (#9).

rtf_tables() accepts a single content item without list()

rtf_tables() now auto-wraps a single content item, so you can write rtf_tables(tbl) instead of rtf_tables(list(tbl)). A bare data.frame, rtftable(), rtfplot(), gt_tbl, or gtsummary table is accepted directly; passing a list of items continues to work unchanged (#3).

rtfreporter 0.0.64

Article: pharmaverse code folded, table print + snapshot shown

The From pharmaverse tables to RTF reports article is restructured so the pharmaverse example code no longer clutters the page: each table’s full recipe (the pharmaverse build plus the rtfreporter rendering) is now a single folded <details> block – collapsed by default, expandable, and copyable in one go. Below each fold the article shows just the printed table object and a snapshot of the rendered RTF. Snapshots live in vignettes/articles/figures/ (placeholders are shown until the PNGs are added).

rtfreporter 0.0.63

Pagination: widow control and tail packing for split groups

When a single group was larger than max_rows and had to be force-split, the continuation could be left with only one row (a (Cont.) header plus a single child) stranded on a near-empty page. Two fixes:

  • Widow control. min_group_rows (default 2) now also applies to the continuation of a force-split group: the cut is pulled back so the next page carries at least min_group_rows of the group’s rows.
  • Tail packing (split = "group_safe"). The tail of a force-split group is now kept in the page buffer, so the following whole groups pack onto it instead of each split group’s remainder getting its own sparse page.

Together these remove the near-empty continuation pages (e.g. the adverse-events tern table dropped from 12 to 11 pages with no 2-row page).

rtfreporter 0.0.62

fmt_count_paren() now aligns the percentages too

fmt_count_paren() is reworked to scan the column and right-justify both the integer count and the number inside the parentheses, so every cell ends up the same width and a column lines up on the count digit and the percentage – e.g. "10 (11.6%)" over " 4 ( 4.7%)". Because all cells share one width it aligns under centre alignment (which the adverse-events tables use), and it adapts to the column’s actual digit counts, so it also handles the 4-digit event totals that the fixed-width realign_count_pct() could not. Both adverse-events tables in the article now use it via cell_format.

rtfreporter 0.0.61

Pluggable cell formatting

as_rtftables() gains a cell_format argument: a function (applied to every data column) or a list of functions (one per column) that re-formats the body cells for monospaced alignment, applied just before pagination. A format function follows a small contract – it takes one column (a character vector) and returns a character vector of the same length, padding with the non-breaking space – documented in ?as_rtftables and the article.

Two ready-made formatters are provided alongside the existing realign_count_pct():

  • fmt_count_paren() aligns an integer count followed by any parenthetical (e.g. a column mixing "2 ( 2.8%)", "3 (<1%)", "70 (100%)" and a lone "0"), so the digits and a bare zero line up. The adverse-events tfrmt table in the article now uses it, fixing the misaligned 0 cells.
  • fmt_right_align() – the minimal “right-justify a column” formatter, used in the docs as the template for writing your own.

rtfreporter 0.0.60

TLG article: downloadable RTFs, single group gaps, wider label column

  • The rendered example RTFs are now committed under inst/rtf-examples/ and the article links to that folder. File names mark the source pharmaverse table (e.g. pharmaverse-adverse-events-tfrmt.rtf).
  • Fixed a doubled blank row between groups in the tfrmt tables: that object already bakes a group gap via element_block(post_space = " "), so the article no longer also passes blank_rows = "between_groups" (which had produced two blank rows, e.g. between “ANY EVENT” and the first SOC).
  • The adverse-events row-label column is widened to 50% so the “… (Cont.)” continuation labels fit on one line.

rtfreporter 0.0.59

Pagination: widow/orphan control for group headers

as_rtftables() (and paginate()) gain a min_group_rows argument (default 2). In the group-aware splits a page no longer ends on a group header that has fewer than min_group_rows of its child rows on that page: the whole group is moved to the next page instead. This fixes cases like a lone “MUSCULOSKELETAL AND CONNECTIVE TISSUE DISORDERS” system-organ-class header stranded at the foot of a page with none of its preferred terms. Set min_group_rows = 0 to restore the previous behaviour.

rtfreporter 0.0.58

TLG article: adverse-events alignment, three-line title, tweaks

  • Column alignment fixed. The adverse-events tables now force the row-label column left-aligned and every data column centred via col_spec, so the “All Patients” column (which had come through left-aligned from the source object) lines up with the rest.
  • The running-header title is now three centred lines – Table N, the descriptive title, and <Safety Analysis Set>.
  • max_rows for the adverse-events tables trimmed to 36 (from 38).
  • Example company name updated.

rtfreporter 0.0.57

TLG article: more rows per adverse-events page

The adverse-events tables now fit max_rows = 38 rows per printed page (up from 30), using the page space that was previously left blank. The tern table drops from 14 to 11 pages and the tfrmt table from 11 to 9, with the same wide first column, shorter row height and (for tfrmt) “(Cont.)” continuation rows.

rtfreporter 0.0.56

TLG article: title block in the running header; both pagination modes shown

  • The full title block now lives in the running header (normal weight, not bold): a single centred Table N <title> (builder) line over a centred <Safety Analysis Set> subtitle, repeated on every page. The bold content titles are replaced by a single empty content title (""), which inserts one blank line between the header and the table.
  • The adverse-events section now spells out the two pagination strategies it already used: split = "group_safe" (tern) keeps each SOC whole, while split = "group_force" (tfrmt) breaks inside a long SOC and repeats the SOC label with " (Cont.)" at the top of the next page (via cont_label).

No package code changed in this release; it documents and demonstrates the existing cont_label continuation behaviour and the empty-title blank-line convention.

rtfreporter 0.0.55

Bug fix: NBSP-indented rows no longer treated as group headers

as_rtftables() detects row groups from the indentation of the first column. It only recognised a regular space or tab as indent, so gt/tfrmt tables – which bake row-label indentation as non-breaking spaces (U+00A0) – had every indented sub-row mistaken for a new group. With blank_rows = "between_groups" that inserted a blank row after almost every line, roughly doubling the rendered rows and overflowing the printed page (e.g. the adverse-events tfrmt table rendered ~608 rows instead of ~340). A non-breaking space now counts as indentation, so blanks appear only between real groups.

TLG article: adverse-events tables now paginate cleanly

In the From pharmaverse tables to RTF reports article both adverse-events tables (tern and tfrmt) now use a wide first column (col_rel_width = c(0.40, 0.15, 0.15, 0.15, 0.15)) so the long SOC / preferred -term labels stay on one line, a shorter row_height_twips = 200 so a full 30 rows fit one printed page, and – for the tfrmt table – two/three-line column headers (Arm over (N=xx)) so the data columns can be narrow. The tables now hold their intended 14 / 11 pages instead of overflowing in Word.

rtfreporter 0.0.54

as_rtftables(auto_width = TRUE) sizes columns to their content

New auto_width argument for as_rtftables(): when TRUE, each column is sized to its widest content (column-header label or data cell) so that long row labels and column headers no longer wrap mid-word. The widths are computed once on the whole table and applied to every page, so paginated pages stay aligned.

  • The optional table_width_twips argument scales the auto-sized columns to a given total (e.g. the writable page width), so the table fills – or fits within – the page.
  • When the table is squeezed narrower than its natural width, the row-label column (column 1) is kept at its natural width and only the data columns shrink, so row labels stay readable while the data-column headers absorb the squeeze.

Supporting improvements to auto_col_widths():

  • it now measures the longest line of a multi-line cell (e.g. a wrapped column header like "Placebo\nN = 86") instead of the whole string, and
  • gains a protect_cols argument to hold chosen columns at their natural width when scaling down.

The From pharmaverse tables to RTF reports article uses auto_width = TRUE for the demographics tables so long race labels such as AMERICAN INDIAN OR ALASKA NATIVE are no longer split across lines.

rtfreporter 0.0.53

Article reframed as a continuation of the pharmaverse examples

The cookbook article (now From pharmaverse tables to RTF reports) is rewritten to make rtfreporter’s role explicit: it complements the pharmaverse, adding only the last-mile RTF rendering step. It now takes the table objects from the official pharmaverse examples – the demographic and adverse events TLGs – verbatim, and renders each to RTF, demonstrating that rtfreporter can read objects from as many pharmaverse table packages as possible:

  • Demographics, built three ways – tern + rtables, gtsummary + cards, tfrmt + cards – each rendered to RTF.
  • Adverse events, built two ways – tern + rtables and tfrmt + cards – paginated across 14 / 11 pages.
  • the gtsummary demographics and the multi-page tern AE table are assembled into one deliverable with assemble_rtf().

A separate Using rtfreporter section now collects the rtfreporter-specific options (headers/footers, pagination, count-percent alignment). forcats added to Suggests (used by the pharmaverse tfrmt demographic example).

rtfreporter 0.0.52

Adverse-events cookbook now uses the real pharmaverse example objects

The tlg-catalog adverse-events section now builds the table from the pharmaverseadam ADaM data, using the exact table objects from the pharmaverse adverse-events example – two ways:

  • tern + rtables (full SOC/PT layout with unique / non-unique patient and event counts), paginated with split = "group_safe" (each SOC kept whole);
  • cards + tfrmt (ARD-driven, frequency-sorted), paginated with split = "group_force".

With 23 system-organ classes and 242 preferred terms the table spans 14 (tern) / 11 (tfrmt) pages, exercising real multi-page pagination, repeated column/running headers, blank rows between groups, and (Cont.) markers.

cards and pharmaverseadam added to Suggests.

rtfreporter 0.0.51

TLG cookbook article reworked to follow the pharmaverse examples

The tlg-catalog article now uses the CDISC pilot ADaM data from random.cdisc.data (the data the NEST catalog uses) and mirrors the pharmaverse TLG examples:

  • the Demographic table is built three ways – tern + rtables, gtsummary, and tfrmt – and each is converted to the same clinical RTF;
  • the Adverse Events table is paginated across pages (split = "group_safe", max_rows, with blank_rows = "between_groups"), showing the running header and repeated column headers;
  • the article ends by assembling the demographics and the multi-page AE table into one deliverable with assemble_rtf().

random.cdisc.data added to Suggests.

rtfreporter 0.0.50

Fixes for gt / gtsummary reading

  • Markdown bold markers stripped. gtsummary writes **Placebo** (and similar) into by-group column headers; rtfreporter does not render Markdown, so the literal ** used to show through. Extracted labels / titles / footnotes now have **...** markers removed.

Count alignment: lone zero counts

  • align_count_pct = TRUE now also right-pads bare integer cells (e.g. a lone 0 for a zero count) to the column width, so they line up with the "n (xx.x%)" cells instead of sitting flush-left.

TLG cookbook article

  • Fixed the tfrmt example (use column = ARM directly; a redundant column variable was leaking an extra ARM column into the output), added group-separating blank rows (blank_rows = "between_groups") to the demographics and AE tables, shortened the provenance footer, and dropped the trivial subject-listing example.

rtfreporter 0.0.49

Fix: rtables / tern row-label indentation

as_rtftables() now calls formatters::matrix_form(x, indent_rownames = TRUE), so nested row labels (e.g. preferred terms under an SOC in a tern AE table) are rendered with their indentation – exactly as rtables itself prints them. Previously the indentation was lost and nested rows came out flush-left.

New: percent-sign support in count alignment

format_count_pct() gains a pct_sign argument, and realign_count_pct() (used by align_count_pct = TRUE) now recognises "n (xx.x%)" cells – e.g. from tern::count_occurrences() – and re-pads them to a uniform width while keeping the %. Cells without a % are unchanged.

rtfreporter 0.0.48

New article: TLG cookbook (pharmaverse -> RTF)

A worked, catalog-style article (vignette("tlg-catalog") on the site) showing how to take a table object built with gtsummary, tern + rtables, or tfrmt – or a plain data.frame/tibble listing – and render it to a clinical RTF page with as_rtftables() + generate_rtfreport(). Uses fully simulated, self-contained CDISC-style data (no bundled pilot data), and ends with assembling a multi-table deliverable via assemble_rtf(). tidyr added to Suggests.

rtfreporter 0.0.47

License changed to Apache 2.0

Relicensed from MIT to Apache License 2.0 to align with the pharmaverse ecosystem (admiral, rtables, tern, … are Apache 2.0) and to provide an explicit patent grant, which is preferred in regulated / corporate settings. DESCRIPTION now reads License: Apache License (>= 2); the full text is in LICENSE.md. Done while the package has a single copyright holder, so no contributor relicensing consent was required.

Project infrastructure

  • Added GitHub pull-request and issue templates (.github/).
  • CONTRIBUTING.md documents how to become a contributor, the issue → merge lifecycle, the CI workflows, and project tracking.

rtfreporter 0.0.46

Fixes & changes to the gt / gtsummary / tfrmt reading path

The gt body is now taken from gt::extract_body() (the route the deprecated paginate() used) instead of as.data.frame(). This fixes several regressions and simplifies what is read:

  • Clean body. Only the visible columns appear, so hidden / helper columns – such as tfrmt’s ..tfrmt_row_grp_lbl – are dropped (even with read_meta = FALSE). Row-group rows render as genuinely empty cells instead of leaving a stray newline (gt’s <br /> placeholder), and the indentation of nested labels is preserved.
  • Only render-relevant metadata is read. Column labels, alignment, spanning headers, widths, title/subtitle, footnotes/source notes, and in-cell footnote marks (rewritten to ^{N}). Per-cell bold/italic from gt::tab_style(), cell fills and Markdown are not read – RTF cannot reproduce them. See the new What is carried, by source table in ?as_rtftables.
  • Header alignment inheritance restored. When you override a column’s align via rtf_tables(), the column header (and any spanning header above it) now follows the new alignment again, unless header_align is set explicitly – matching the construction-time cascade.

Renamed argument

  • as_rtftables(read = ) / as_rtftable(read = ) are renamed to read_meta (“read the source table’s metadata”). read_meta = FALSE yields the rendered body only.

rtfreporter 0.0.45

New: rtables / tern input

as_rtftables() (and as_rtftable()) now accept any rtables VTableTree – the TableTree / ElementaryTable objects produced by rtables and by tern analysis functions. The table is read through its canonical formatters::matrix_form() representation, so the extracted content matches what rtables itself renders:

  • Leaf and multi-level spanning column headers (from nested split_cols_by()).
  • Per-column alignment.
  • The row-label stub with its indentation (mapped to per-cell indent_twips); rtables label / group-header rows come through as ordinary rows (already interleaved by matrix_form()).
  • Titles: main title + subtitles.
  • Footnotes: referential footnote texts plus the main and provenance footers, appended to the page footnote block.
  • In-cell footnote marks {N} (e.g. "37.7 {1}") rewritten to rtfreporter ^{N} superscript markup.

read = TRUE (default) reads all of the above; pass a character vector of tokens ("col_header", "alignment", "spanning", "titles", "footnotes", "indent", "footnote_marks") for selective control, or read = FALSE for the formatted body only.

rtables, formatters and tern are added to Suggests.

rtfreporter 0.0.44

New: as_rtftables() – one entry point for gt / gtsummary -> RTF

as_rtftables() converts a table object (gt_tbl, a gtsummary table, a data.frame/tibble, or a list of these) into a list of ready-to-render [rtftable()] objects – one per page. It both reads the source table’s metadata and paginates the body in a single call, so the long-standing gap where paginate() on a gt_tbl silently dropped all gt metadata is closed.

  • Page-level title / source-note blocks travel with each page as the rtf_titles / rtf_footnotes attributes, which rtf_tables() now reads automatically – no read_gt flag required.
  • Per-cell styles are sliced to match each page; shared header / width / spanning metadata is replicated onto every page.
  • as_rtftable() (singular) is retained as a convenience wrapper that returns a single rtftable (= as_rtftables(split = "none")[[1]]).

New: gtsummary input

Any gtsummary table (tbl_summary(), tbl_regression(), tbl_merge(), tbl_stack(), …) is accepted directly; it is converted to a gt_tbl via gtsummary::as_gt() first. Row indentation and bold group-header rows are not reproducible in RTF and are dropped; everything structural comes through.

New: per-cell styles, footnote marks, HTML cleanup (gt “Phase D”)

read = TRUE now also reads, from a gt object:

  • "styles" – tab_style(cell_text(...)) bold / italic / underline / indent, stored on the new rtftable$cell_styles field and applied per cell at render time.
  • "footnote_marks" – gt’s in-cell <sup>N</sup> footnote marks are converted to rtfreporter ^{N} superscript markup.
  • "strip_html" – stray HTML in cell values is removed (<br> becomes a line break).

rtftable() gains a cell_styles argument for setting these directly.

Deprecated

  • paginate() is deprecated in favour of as_rtftables(). It still works (and still returns per-page data.frames for backward compatibility), warning once per session.
  • rtf_tables(read_gt = ) is now a legacy path for handing a raw gt_tbl straight in; prefer converting with as_rtftables() first.

Packaging

  • Source directory renamed r/ -> R/ so R CMD check is clean (0 errors, 0 warnings).

rtfreporter 0.0.41

Breaking change: removed rtf_theme() and the R6 dependency

rtf_theme() was a small R6 class kept for the “broadcast-mutable defaults” use case – mutate theme$header_bold <- TRUE once and every referencing table picks the change up at the next render. In practice this is also straightforward to do with the immutable S3 [rtf_table_style()] (build a fresh style once, then pass it to every table you build), and the second class was carrying its own weight in maintenance, in coverage drag (R6 method bodies are hard for covr to instrument cleanly), and in a globalVariables("self") hack to silence R CMD check.

Removed:

  • rtf_theme(), rtf_theme_tfl(), the theme = argument of [rtftable()], the .refresh_theme() renderer hook, the R6 Suggests entry, the globalVariables("self") declaration.
  • vignettes/class-systems.Rmd (the S3-vs-R6 tour was the only remaining R6 user once the class itself was gone).

Migration – before (R6):

theme <- rtf_theme(header_bold = FALSE)
t1 <- rtftable(df1, theme = theme)
t2 <- rtftable(df2, theme = theme)
theme$header_bold <- TRUE      # broadcast: both tables follow

After (S3, snapshot):

style <- rtf_table_style(header_bold = TRUE)
t1 <- rtftable(df1, style = style)
t2 <- rtftable(df2, style = style)

The pipe API was always S3-only, so no pipe-style code is affected.

Test coverage lifted past 90%

The full suite gained 17 targeted “fill-in” tests in a new tests/testthat/test-coverage-fillins.R, plus the rtf_theme removal dropped a chunk of hard-to-instrument R6 code from the denominator. Net: total coverage 87.71% -> 91.88% (test count 1092 -> 1085 – net negative because removing R6 also removed 31 tests).

rtfreporter 0.0.40

gt integration – Phase C (completes the v0.1.0 roadmap)

Adds the final two read_gt = ... tokens from specs/gt-integration-spec.md. Combined with Phase A (v0.0.38) and Phase B (v0.0.39), the GT bridge now supports all nine documented tokens. read_gt = TRUE automatically expands to the full set.

  • "footnotes" – table-level only. Every tab_footnote() text (regardless of its anchor: column label, body cell, standalone, etc.) is flattened to plain text and prepended to the page footnote block. When "source_notes" is also active, footnote texts come ABOVE source notes – matching gt’s vertical layout. Cell-mark injection is deferred to a later release.

  • "stub" – two transformations:

    1. The groupname column (gt(df, groupname_col = ...), boxhead type == "row_group") is dropped from the data. Group-transition rows are then interleaved into the body wherever _stub_df$group_id changes; each transition row carries the group label in the leftmost cell.
    2. tab_stubhead(label = ...) is applied as the first column’s header label, overriding any cols_label value for the stub column.

    Combines cleanly with "hidden" (both can drop columns) and with "spanning" (stubhead label lands on the bottom-row labels under the spanner stack).

Vignette

vignettes/gt-integration.Rmd walks through the full workflow: a quick-start, a TFL-style adverse-events example end-to-end (group rows + stubhead + spanners + footnotes + source note), and the explicit-argument precedence rules.

Test additions

tests/testthat/test-gt-adapter.R gains 21 new tests for Phase C: the two new extractors individually, the row-interleaver, four .gt_to_rtftable_kwargs() integration paths (footnotes-only, footnotes+source_notes, stub+col_header, stub+spanning, stub+hidden), and one full end-to-end render with read_gt = TRUE that exercises every Phase A/B/C token at once.

Full suite: 1092 PASS / 0 FAIL.

rtfreporter 0.0.39

gt integration – Phase B

Adds the three “structural” tokens from specs/gt-integration-spec.md on top of the Phase A vocabulary:

  • "spanning" – multi-level spanner labels from gt_obj[["_spanners"]] are stacked as additional rows ABOVE the column labels in col_header. Higher spanner_level -> drawn higher in the rendered header. Spanners that cover non-contiguous visible columns are skipped (rtfreporter requires contiguity).
  • "widths" – per-column widths from gt_obj[["_boxhead"]]$column_width are translated to one of:
    • column_widths_twips (when all values are "NNpx"), using 1 px = 15 twips (the 96 dpi CSS convention).
    • col_rel_width (when all values are "NN%").
    • NULL otherwise (mixed, missing, or unparsable).
  • "hidden" – columns whose _boxhead$type == "hidden" are dropped from the extracted data.frame, the column labels, the alignment vector, the widths, and the spanner column-index mapping (so spanner rows stay aligned with the visible columns).

read_gt = TRUE now expands to all seven Phase-A + Phase-B tokens (was four). Adding spanning / widths / hidden tokens is automatic when upgrading: code that already wrote read_gt = TRUE becomes strictly more powerful with no source changes.

Two-way independence:

  • "hidden" can be used by itself – it just shrinks the data.
  • "spanning" without "col_header" falls back to using the data column names as the bottom row of the header stack.
  • "widths" without "hidden" keeps the gt width vector at its original length; the renderer fails fast if the count mismatches the data.frame.

Precedence is unchanged: explicit rtf_tables() / rtftable() arguments always beat the gt-extracted values, including the new column_widths_twips / col_rel_width slots.

Test additions: 36 new tests in tests/testthat/test-gt-adapter.R covering the three new extractors, the token resolver’s new return shape, three end-to-end render checks (including a combined spanner + hidden + widths integration), and the precedence rule for every new slot.

rtfreporter 0.0.38

gt integration – Phase A (preparing v0.1.0)

This release lays down the first slice of the gt -> rtfreporter bridge described in specs/gt-integration-spec.md. A gt_tbl can now be passed straight into rtf_tables(), and the package optionally reads four “shape-preserving” gt attributes:

  • col_header – column labels from gt_obj[["_boxhead"]]$column_label
  • alignment – per-column alignment from gt_obj[["_boxhead"]]$column_align
  • titles – title + subtitle from gt_obj[["_heading"]], mapped to the page’s titles[[i]] block
  • source_notes – source notes from gt_obj[["_source_notes"]], mapped to the page’s footnotes[[i]] block
New API

Accept a gt_tbl directly and pull every Phase-A attribute through; opt in selectively; or use the standalone wrapper (it returns a plain rtftable):

doc |> rtf_tables(list(my_gt), read_gt = TRUE)
doc |> rtf_tables(list(my_gt), read_gt = c("col_header", "titles"))
as_rtftable(my_gt, read = TRUE)

read_gt = FALSE (the new default) keeps every gt_tbl item treated as a bare data.frame via as.data.frame() – backward-compatible with v0.0.37 code that already paginated gt’s rendered table.

Precedence

Explicit rtf_tables() / rtf_titles() / rtf_footnotes() arguments always beat the gt-extracted values. Per-column col_spec entries are deep-merged: a user-supplied align = "left" for column 1 overrides gt’s column 1 alignment but leaves gt’s alignment in place for columns the user did not mention.

Forward compatibility

Tokens for Phase B ("spanning", "widths", "hidden") and Phase C ("footnotes", "stub") are recognised today but currently emit a “not yet implemented” warning and are silently dropped. Code written against the full token list will Just Work once those phases land.

gt stays in Suggests – the bridge guards with requireNamespace("gt") and raises a clear error if a gt_tbl is fed in without gt installed. The 18 new tests in tests/testthat/test-gt-adapter.R skip cleanly when gt is absent.

rtfreporter 0.0.37

Bug fix: phantom rule under header on assembled file’s first page

When assemble_rtf(toc = ...) produced an assembled deliverable, the first page of every source-file body section showed a faint horizontal rule directly under the page header.

Root cause: .insert_bookmark() placed the bookmark/outline paragraph between \sectd and the section’s page-property + header/footer declarations. RTF parsers treat a paragraph emitted in that window as part of the previous section’s flow, so any lingering paragraph state from the previous section’s last paragraph (typically a footnote with \brdrt\brdrs\brdrw15) bled onto the new section’s header band and was rendered as a thin rule.

The fix advances the insertion point past the section preamble (\sectd, \sbkpage..., {\header ...}, {\footer ...}) so the bookmark and outline paragraph land in the new section’s BODY where they belong.

Hardened invisibility: \sl1\slmult0 exact line spacing

The outline paragraph now also emits \sl1\slmult0 – exact line spacing of 1 twip (~1/1440 inch). This pins the paragraph’s rendered height to ~0 px regardless of the converter’s \fs interpretation, guaranteeing the outline label cannot push a borderline-fitting table from one page onto a second. \cf2\fs2 are retained as belt-and-braces (white text + 1-pt size).

ydisctools alignment audit – what we kept and what we adopted

After auditing Yenu’s ydisctools::assemble_rtf() for the proven-good RTF idioms, the conclusions are:

  • Adopted – white-on-white invisibility via \cf2 (v0.0.36) and exact-line-spacing belt-and-braces (v0.0.37).
  • Adopted in spirit – bookmark + outline paragraph placed in a position that does not bleed into the new section’s header band. (We place them AFTER the section preamble; ydisctools places them BEFORE \sectd. Both approaches avoid the bleed; placement after preamble keeps the anchor on the correct page for PAGEREF resolution.)
  • Kept rtfreporter – multi-level toc_heading() / toc_entry() layout, PAGEREF fields for dynamic TOC page numbers (ydisctools bakes them statically), \*\fldinst per the RTF spec, full per-page static {PAGE} token support.

rtfreporter 0.0.36

Outline label is now hidden via white-on-white (\cf2\fs2)

v0.0.35 set the PDF-outline label to \fs0 (font size 0) hoping for zero rendered height. LibreOffice interprets \fs0 as “use default” and renders the label at the body font size (~12 pt), which made the source-file title appear twice near the top of each table section.

Re-investigating Yenu’s ydisctools showed the actual invisibility mechanism there is \cf<white> (white text colour), not \fs0. rtfreporter v0.0.36 adopts the same approach:

  • .build_color_table_rtf() now always emits white at colour-table index 2 (between black at 1 and any user colours at 3+).
  • .build_color_index_map() shifts user colour indices by +2 so the hex-to-index lookup remains correct.
  • .insert_bookmark() (assemble_rtf) renders the outline label with \cf2\fs2 — white text at 1 pt — inside a balanced { ... } group. White-on-white is invisible regardless of the PDF converter’s \fs0 interpretation, and 1 pt keeps the line height to ~1 px so no vertical space leaks either.

Verified: the LibreOffice-rendered output/demo/*_assembled_*.pdf files no longer show the source-file title above each body section, and the PDF outline (bookmark panel) still lists all three sources.

rtfreporter 0.0.35

Outline label is now fully invisible (\fs0)

.insert_bookmark()’s outline-label paragraph now uses \fs0 (font size 0) instead of \fs2 (1 pt). v0.0.31’s 1-pt text was faintly visible at the top of each source-file body section; the new size-0 text occupies no rendered space at all while LibreOffice still recognises the paragraph as a heading via \outlinelevel, so the PDF outline / bookmark panel is unchanged.

This matches the trick used by Yenu’s ydisctools (which combines \fs0 with a white \cf colour for double safety).

The corresponding regression test in tests/testthat/test-assemble-rtf.R was updated to expect the \fs0-shaped balanced group.

rtfreporter 0.0.34

Bug fix: {PAGE} now increments across sub-pages of one rtf_section

In v0.0.32 {PAGE} was correctly switched from a dynamic field to a static integer, but it baked the rtf_section’s first-page number into the header text — and an rtf_section whose rtf_tables() adds multiple sub-pages emitted a single RTF \header block shared across all of them. Result: every sub-page in such a section showed “Page 1” instead of 1, 2, 3, …

generate_rtfreport() now detects when a header or footer contains {PAGE} and promotes every sub-page boundary inside that rtf_section from a \page break to a \sect break, re-emitting \sectd + page settings + {\header} + {\footer} for each sub-page with the correct baked-in number. AUTO-only headers stay on the cheap one-\header-per-rtf_section path (no extra section breaks emitted).

Three new tests in tests/testthat/test-page-tokens.R lock the behaviour: * “Page 1 of 3”, “Page 2 of 3”, “Page 3 of 3” all present in a single 3-sub-page section, * \sect breaks are emitted at every sub-page boundary, * AUTO-only headers still emit zero internal \sect breaks.

rtfreporter 0.0.33

Bug fix: character-format leak from cover / TOC / outline paragraphs

When assemble_rtf() was called with cover = list(...) or toc = ..., the front-matter paragraphs emitted bare character-format properties that bled across \par into the following body content:

  • .insert_bookmark() emitted \pard\plain\fs2\sa0\sb0\outlinelevel0 LABEL\par — the \fs2 (1-pt) outline-paragraph font carried forward into the body table, shrinking every cell’s text to invisible.
  • .build_cover_section() and .build_toc_section() emitted \pard\qc\b\fs44 TEXT\b0\fs0\par — the closing \fs0 (size 0) similarly leaked.

The visible symptom: assembled PDFs / RTFs rendered table borders correctly, but every cell appeared empty. The pre-v0.0.31 (no-cover, no-outline) path was unaffected, which is why older outputs looked fine.

Every formatted paragraph in .insert_bookmark(), .build_cover_section(), and .build_toc_section() is now wrapped in an RTF { ... } group, so its character-format state is local and cannot bleed into adjacent content. Regression tests in tests/testthat/test-assemble-rtf.R lock the fix down structurally.

Demo script — both no-cover and with-cover variants

output/gen_assemble_demo.R now produces four assembled outputs:

  • auto_assembled_default.{rtf,pdf} — AUTO sources, TOC only (no cover)
  • auto_assembled_full.{rtf,pdf} — AUTO sources, cover + TOC
  • static_assembled_default.{rtf,pdf} — STATIC sources, TOC only (no cover)
  • static_assembled_full.{rtf,pdf} — STATIC sources, cover + TOC

The “default” variants reflect the common eCTD deliverable layout where a cover page is not attached and the TOC alone serves as the front matter. No change to assemble_rtf()’s own default — cover = NULL has always been the default.

rtfreporter 0.0.32

Bug fix: {PAGE} is now a STATIC integer (was incorrectly dynamic)

Previously {PAGE} was substituted with RTF’s \chpgn dynamic field — identical to {AUTO_PAGE}. This contradicted the documented spec, which states that {PAGE} should be a static integer baked in at render time (the section’s first-page number). Effectively {PAGE} and {AUTO_PAGE} produced indistinguishable output, defeating the purpose of having two separate tokens.

generate_rtfreport() now substitutes {PAGE} with the literal first-page number of the section being rendered. Combined with the already-static {TOTAL_PAGES}, the documented Page {PAGE} of {TOTAL_PAGES} idiom now correctly emits e.g. Page 1 of 3 as literal text.

Token semantics — recap:

Token Kind Resolves to
{AUTO_PAGE} dynamic \chpgn (per-page, viewer)
{AUTO_TOTAL_PAGES} dynamic NUMPAGES field (viewer)
{SECTION_PAGES} dynamic SECTIONPAGES field (viewer)
{PAGE} static section’s first-page integer
{TOTAL_PAGES} static document total page integer

A new tests/testthat/test-page-tokens.R file locks these semantics with 7 dedicated tests. output/gen_assemble_demo.R was rewritten to produce two independent demo sets (an AUTO-only set and a STATIC-only set), each with its own TOC/cover/PDF — reflecting realistic deployment where a deliverable uses one numbering style throughout.

rtfreporter 0.0.31

assemble_rtf() — PDF outline / bookmark panel support

When toc is supplied, assemble_rtf() now also emits a tiny \outlinelevel0 paragraph next to each per-file bookmark. RTF viewers ignore it for display purposes (1-pt font, no surrounding spacing), but RTF→PDF converters such as LibreOffice translate the \outlinelevel mark into a real PDF outline entry — i.e., the bookmark panel on the left edge of an Adobe Reader / Word window. This makes the assembled deliverable navigable in PDF form, satisfying a common eCTD leaf-file expectation.

The outline label is taken from the corresponding toc_entry()’s label when present; otherwise it falls back to sub(".rtf$", "", basename(file)). toc = NULL emits no outline paragraphs (output is unchanged from v0.0.30).

Static {TOTAL_PAGES} documented + tested across assembly

Tests now lock the two distinct token semantics in assemble_rtf():

  • {AUTO_PAGE} / {AUTO_TOTAL_PAGES} / {PAGE} — all emit RTF dynamic field codes (\chpgn and NUMPAGES). They recompute correctly across the assembled document.
  • {TOTAL_PAGES} — baked into the source as a literal integer at render time. After assembly it still reflects only the source file’s own page count (the documented limitation).

rtfreporter 0.0.30

assemble_rtf() — multi-level TOC, auto-extraction, cover page, page numbering

Building on v0.0.29’s clickable TOC, this release covers the remaining requests for a full deliverable-package workflow.

toc = "auto" — auto-extract each input file’s title (the first centred-bold paragraph emitted by .render_title_text()) and use it as a TOC entry. Falls back to the file’s basename if no title is detected.

assemble_rtf(c("t14_1_1.rtf", "t14_2_1.rtf"), "out.rtf",
             toc = "auto", overwrite = TRUE)

Structured / multi-level TOC via two new helper constructors:

  • toc_heading(label, level) — non-clickable section heading rendered bold without a page number.
  • toc_entry(label, file, level) — clickable entry pointing to a per-source-file bookmark. file = can be a path that appears in input_files, an integer 1-based index, or NULL (consume the next file in declaration order).
assemble_rtf(
  c("t14_1_1.rtf", "t14_2_1.rtf", "l16_1.rtf"),
  "tfl_package.rtf",
  toc = list(
    toc_heading("EFFICACY ANALYSES"),
    toc_entry("Table 14.1.1 Demographics"),       # auto-bound to file 1
    toc_heading("SAFETY ANALYSES"),
    toc_entry("Table 14.2.1 Adverse Events"),     # auto-bound to file 2
    toc_heading("LISTINGS"),
    toc_entry("Listing 16.1 Subject Disposition") # auto-bound to file 3
  ),
  overwrite = TRUE
)

toc_page_numbering controls how the TOC pages number themselves:

  • "none" (default) — Arabic numbering flows continuously from page 1 (TOC counts as page 1, body continues from there).
  • "roman" — TOC pages use lowercase Roman numerals (i, ii, iii); body restarts at 1 with Arabic numerals.
  • "decimal" — TOC pages and body pages each start at 1.

The body-restart is achieved by injecting \pgnrestart\pgndec right after the first body section’s \sectd.

cover = list(...) — optional cover page section before the TOC. Recognised fields: title, subtitle, date, version, meta (character vector). Each is rendered as a centred line with size hierarchy (title 22pt → meta 10pt). Any field that is NULL or empty is silently skipped.

assemble_rtf(
  c("t14_1_1.rtf", "t14_2_1.rtf"),
  "tfl_package.rtf",
  cover = list(
    title    = "Study XYZ-001",
    subtitle = "Final Statistical Report",
    date     = "2026-05-29",
    version  = "v1.0",
    meta     = c("Confidential", "Prepared by ACME Pharma")
  ),
  toc                = "auto",
  toc_page_numbering = "roman",
  overwrite          = TRUE
)

Backward compatibility

toc = NULL AND cover = NULL AND toc_page_numbering = "none" still produces a byte-for-byte identical output to the v0.0.28 behaviour — locked by a regression test.

rtfreporter 0.0.29

assemble_rtf() — clickable Table of Contents + bookmarks

assemble_rtf() gains a TOC option for the common “deliverable package” workflow. Pass a character vector of labels (one per input file) to render a clickable Table of Contents on the front page of the assembled document:

assemble_rtf(
  input_files = c("t14_1_1.rtf", "t14_2_1.rtf", "l16_1.rtf"),
  output_file = "tfl_package.rtf",
  toc         = c("Table 14.1.1 Demographics",
                  "Table 14.2.1 Adverse Events",
                  "Listing 16.1 Subject Disposition"),
  overwrite   = TRUE
)
  • Each TOC entry is an RTF HYPERLINK field pointing to a per-source-file bookmark inserted right after that section’s \sectd. Clicking the entry in Word / Pages jumps to the corresponding table.
  • Each row also ends with a PAGEREF field, so the page number next to each entry is calculated by the viewer when the file opens — no hard-coded numbers, no stale TOC.
  • Bookmark names auto-derive from each input file’s basename (sanitised to ^[A-Za-z][A-Za-z0-9_]{0,31}$). Duplicate-name clashes (two files called x.rtf in different folders) are resolved automatically with _1 / _2 suffixes.
  • toc_leader = "dot" (default) draws a dotted leader between each label and its page number; "none" leaves whitespace.
  • bookmark_prefix (default "tfl_") lets you namespace bookmarks when later combining multiple assembled outputs.

The pre-existing call shape (assemble_rtf(input_files, output_file, overwrite)) is unchanged. toc = NULL (the default) produces a byte-for-byte identical output to the pre-v0.0.29 behaviour — covered by a regression test.

The originally-proposed combined-document page number in the footer feature was withdrawn: injecting it into the existing footer would shift the rest of the footer downward, and a separate footer band would clash with the source documents’ own footers. Use {AUTO_PAGE} / {AUTO_TOTAL_PAGES} in your rtf_footer() when you generate each source file instead — those are dynamic fields that already recompute across the assembled document.

rtfreporter 0.0.28

Coverage lift — Phase 1 of 3 (67% → 73%)

Backfill of the four files that were at 0% coverage:

  • R/assemble_rtf.R (0% → 95%) — five new tests cover the happy path (2-file and N-file concatenation), input-count validation, missing-file detection, overwrite guard, and rejection of non-rtfreporter RTFs (no \sectd).
  • R/rtfplot.R (0% → 56%) — six new tests cover construction from a PNG, propagation of width_twips / height_twips / align, file-not-found / unsupported-extension errors, invalid-align rejection, and end-to-end embedding into a generated RTF via rtf_figures() (checking the output contains \pict and \pngblip). A 74-byte hand-rolled 1x1 RGB PNG is constructed inside the test, so no graphics device is required.
  • R/text_width.R (0% → 93%) — eleven new tests cover text_width_in() (length parity, NA handling, linear scaling with character count, scaling with size_half_points, font variants including unknown-font fallback) and auto_col_widths() (per-column integer widths, exact scaling to table_width_twips, header-text inclusion, min_col_width_twips floor, pipe-string header).
  • R/zzz.R (0% → 100%) — four new tests cover the .onLoad hook’s namespace presence, its idempotent re-invocation, the populated .rtf_theme_class R6 generator after package load, and the no-op re-init via .init_rtf_theme_class().

Per-file post-Phase-1 coverage:

R/wrappers.R 100% R/zzz.R 100% R/assemble_rtf.R 95% R/plot.R 94% R/text_width.R 93% R/format_count_pct.R 93% R/set_blank_rows.R 92% R/paginate.R 90% R/blank_rows.R 78% R/generate_rtfreport.R 77% R/pipe-composition.R 75% R/rtf_table_style.R 75% R/rtftable.R 66% R/rtfreport.R 62% R/col_header.R 59% R/rtfplot.R 56% R/rtf_border.R 52% R/rtf_theme.R 15% TOTAL 73.04%

53 new test expectations; total 455 PASS under devtools::test().

Codecov floor locked at 70%

codecov.yml switched the project-level status from informational: true / target: auto to informational: false / target: 70%. Any PR that drops total coverage below 70% will now fail the CI check, preventing silent erosion as new features land.

Phase 2 (target 80%) and Phase 3 (target 90%) will land closer to v0.1.0 / v0.2.x respectively.

rtfreporter 0.0.27

format_count_pct() — right-align the 100% branch

The 100% branch used to render " 30 (100) " (closing paren in column 9, trailing space at column 10), so it did not line up with the other paren-bearing branches. It now renders " 30 (100)" — an extra space before ( so the closing paren sits at column 10 just like " 14 (50.0)" and " 5 (5.0)". All paren-bearing rows now share the same right edge.

rtfreporter 0.0.26

New utility functions extracted from paginate()

  • format_count_pct(count, pct, pct_unit, nbsp) — produces uniform-width "n (xx.x)" strings from numeric count and pct vectors. Port of the reference helper from Issue #2 (clinical TFL column alignment). Four width branches: count-only when count is NA or 0, n (100) at exactly 100%, one-digit pct gets an extra space (n (X.Y)), two-digit pct uses n (XX.Y). Padding spaces become NBSP (U+00A0) by default so RTF / Word does not collapse them.

  • realign_count_pct(strings, nbsp) — same width policy but starting from already-formatted strings (e.g. those gt emits in the rendered body). Cells matching "n (xx.x)" are parsed and reformatted; non-matching cells pass through unchanged.

  • set_blank_rows(df, blank_rows, blank_row_first, blank_row_end, group_col) — exposes the blank-row attribute logic that paginate() had internally. Takes ONE data.frame (already at page size), resolves the blank spec, and writes the result onto attr(df, "rtf_blank_rows") for rtftable(read_attributes = TRUE) to consume. Use this when you already do your own paging and only need the blank-row mechanism.

paginate() integration

  • paginate(..., align_count_pct = TRUE) runs realign_count_pct() on every character column other than column 1 before splitting, so every page inherits the cleaned-up cells. Default is FALSE (no automatic rewriting).
  • Internally, paginate() now delegates the per-page blank-row work to set_blank_rows(); the two APIs cannot drift apart.

rtfreporter 0.0.25

paginate() — list-name propagation, split = "by_value", clearer help

  • Named-list inputs round-trip through paginate(). When the input is a list whose elements have names, those names are now carried through to the output:

    • input → 1 page → the chunk keeps the input name
    • input → many pages → chunks named <name>.1, <name>.2, …
    • unnamed input → no name (as before)

    So paginate(list("Table 14.1.1" = tbl1, "Table 14.2.1" = tbl2)) returns a 2-element named list, and the names flow on to rtf_tables(pages, auto_section = TRUE) if you choose to use it.

  • New split = "by_value" mode. One page per detected group, never packed. Each returned page is named by the group’s label (the indented col-1 text when group_col = NULL, otherwise the value of df[[group_col]]). Combine with rtf_tables(pages, auto_section = TRUE) to render one RTF section per group value (e.g. one section per study visit). A group that exceeds max_rows is internally force-split with (Cont.) and the sub-chunks get suffixed names (<label>.1, <label>.2, …).

  • The page-name (when present) is also surfaced in attr(., "rtf_paginate_meta")$page_name for programmatic access.

  • ?paginate reference page reformatted. Each @param now stands on its own paragraph with enumeration bullets where appropriate, instead of running together as a single dense block. Two new worked examples (sections 6–7) show the "by_value" mode and the named-list round-trip.

rtfreporter 0.0.24

paginate() — preserve the input’s class chain (tibble in → tibble out)

  • paginate(<gt_tbl>) no longer down-grades the extracted body to a plain data.frame. Since gt::extract_body() returns a tibble and gt-based workflows are tibble-native, every returned page is now also a tibble (tbl_df / tbl / data.frame).
  • paginate(<tibble>) likewise returns tibbles on every page, including pages that came out of the group-force continuation path (where an internal rbind() would otherwise drop the tibble class).
  • paginate(<data.frame>) still returns plain data.frames, as before.

Implementation: the input’s class() chain is captured once and re-applied to each returned chunk after the splitter has finished.

rtfreporter 0.0.23

paginate() enhancements

  • blank_row_first = TRUE / blank_row_end = TRUE — new per-page boolean arguments that auto-insert a blank row at the top / bottom of every returned page (positions 0 and nrow(chunk) respectively). Combine with blank_rows = "between_groups" to get the clinical-TFL pattern of blank-before-each-group + blank-at-page-edges.

  • Indent-based grouping clarified. The auto-detected group semantics (default group_col = NULL) are now explicitly documented as indent-based: only rows whose first column starts with a non-space character open a new top-level group; rows with leading whitespace (any depth) stay in the current group. Multi-level layouts like

    group1
        sub-row 1
        sub-row 2
            sub-sub-row 3
    group2
        ...

    therefore split / blank correctly out of the box, using group1 and group2 as the unit.

rtfreporter 0.0.22

New features

  • paginate() — unified table-object to per-page data.frame list (#2). Single S3 entry point that converts a [gt::gt()] table, a plain data.frame, or a list of either into a list of data.frames sized for one RTF page each, ready to be passed to rtf_tables().

    Splitting strategies:

    • split = "none" (default) — pass through as a single page.
    • split = "rows" — manual force-split at the row indices in split_rows.
    • split = "group_safe" — pack whole groups onto each page; spill on overflow. A single group larger than max_rows is force-split with (Cont.) continuation rows.
    • split = "group_force" — cut every max_rows; when a cut lands inside a group, the next page begins with a <label> (Cont.) header row whose summary value cells are blanked out.

    Groups are either auto-detected from leading-whitespace indentation on the first column (default) or specified explicitly via group_col =.

    Blank-row positions can be supplied as an integer vector, as the keyword "between_groups" (auto-inserts a blank between detected groups), or as a list(...) combining both. Positions are attached as an rtf_blank_rows attribute on each returned data.frame and consumed automatically by rtftable(read_attributes = TRUE).

    Extending to a new table-object type is one S3 method:

    paginate.your_class <- function(x, ...) {
      df <- ...extract a data.frame from x...
      paginate.data.frame(df, ...)
    }

    Added to Suggests:: gt (>= 0.9.0).

rtfreporter 0.0.21

New features

  • Unified column-header API. Multi-row column headers can now be written in a single uniform form using pos = cell specs, regardless of whether the top-most row is a spanning row or a plain label row. Previously the first row had to use either col_header (no spanning) or spanning_header (separate argument); now spanning at every level (including the first) is expressed the same way.

    • col_cell(pos, label, ...) — one cell. pos = 1 is a single column; pos = c(2, 5) spans data columns 2–5. Positions always refer to the underlying data columns.
    • rtf_col_header(row1, row2, ...) — collect rows top-to-bottom.
    • add_col_header_row(hdr, row, .position = c("bottom", "top")) — incremental builder.

    Example:

    rtftable(df, col_header = rtf_col_header(
      list(col_cell(1, ""), col_cell(c(2, 5), "Treatment")),
      list(col_cell(1, ""),
           col_cell(c(2, 3), "Drug A"),
           col_cell(c(4, 5), "Drug B")),
      c("Item", "N", "Mean", "N", "Mean")
    ))

    A bare list of col_cell()s is auto-wrapped as a single header row, so col_header = list(col_cell(1, "Item"), col_cell(c(2, 3), "M (SD)")) works.

    All previously-accepted forms (c("A", "B", "C"), mixed lists with list(from, to, label, ...) specs, and the standalone spanning_header = argument) continue to work unchanged.

Default changes (potentially visible to existing reports)

  • Cell padding default lowered to 0 twips (was 72 twips ≈ 0.05 inch on both sides). Header, footer and content-table cells now sit flush against the cell border by default, matching the typical clinical TFL look. Callers who want the old behaviour can opt in: rtftable(..., cell_padding_left_twips = 72L, cell_padding_right_twips = 72L). Reflected in inst/resources/rtfreporter_defaults.R.
  • Document margins enlarged ~20% to give a little more breathing room around the writable area:
    • top / bottom: 0.75 inch → 0.9 inch
    • left / right: 0.5 inch → 0.6 inch Callers passing explicit page metrics are unaffected.

rtfreporter 0.0.20

Infrastructure

  • Added a pkgdown site published to GitHub Pages at https://ichirio.github.io/rtfreporter/.
  • Migrated the test suite from bare tests/test-*.R scripts to testthat (edition 3) under tests/testthat/.
  • Added GitHub Actions workflows for R CMD check (Ubuntu, macOS, Windows × R-release / devel / oldrel-1), pkgdown deployment, and test coverage reporting via covr + Codecov.
  • DESCRIPTION modernised: full author/maintainer information, URL, BugReports, expanded Description, Depends: R (>= 4.1), and optional Suggests: for testthat and covr.
  • LICENSE converted to CRAN’s YEAR / COPYRIGHT HOLDER template with the full MIT text moved to LICENSE.md.
  • README.md rewritten with installation instructions, a runnable 30-second example, and badges.
  • Community files added: CODE_OF_CONDUCT.md, CONTRIBUTING.md, GitHub issue and pull-request templates.

rtfreporter 0.0.19

Breaking changes

  • Removed all R6 usage. rtftable_r6, rtfplot_r6, rtfreport_r6, and the R6 versions of rtf_border and rtf_table_style are now plain S3 records. The class names lose the _r6 suffix (inherits(x, "rtftable") etc.). Imports: R6 was dropped from DESCRIPTION.
  • The R6 method chains b$set_top() / b$with_top() / b$apply_override() / b$override() / style$clone_with() / style$as_table_border() are gone. Replacements:
    • rtf_border_with(b, top = ..., bottom = ..., ...) for non-mutating border derivation.
    • rtf_table_style_with(style, ...) for non-mutating style derivation.
    • rtf_table_style(...) is now called as a function (not rtf_table_style$new(...)).
  • The “share a style instance, mutate it, see every existing table reflect the change” pattern no longer applies. Each rtftable() call snapshots the style fields it needs at construction time. Build a new style with rtf_table_style_with() and pass it to new tables.

Other

  • Public S3 print methods are now exported for rtf_border, rtf_border_side, rtf_table_border, and rtf_table_style.

rtfreporter 0.0.18

  • Data section now has no default borders. The TFL preset rtf_border_tfl() no longer paints a horizontal rule under the last data row; default borders belong to the column-header block only. Callers who want a bottom rule under the last data row can still set it explicitly: rtf_table_border(last_row = rtf_border(bottom = rtf_border_side())).

Earlier versions: see CHANGELOG.md for the detailed pre-v0.0.18 history.