The hardware and bandwidth for this mirror is donated by METANET, the Webhosting and Full Service-Cloud Provider.
If you wish to report a bug, or if you are interested in having us mirror your free-software or open-source project, please feel free to contact us at mirror[@]metanet.ch.

simPDF: Fast Multi-Page PDF Reports

Kyun-Seop Bae

Why simPDF

simPDF builds multi-page PDF reports on top of R’s built-in graphics device (pdf() / cairo_pdf()). It is deliberately small: base R only, no knitr/LaTeX/pandoc toolchain, so a report is written in a single fast pass.

Its defining idea is measured layout. Every block of content reports its real width and height (via strwidth()/strheight() on the open device); the vertical cursor advances by that measured height; and pages break automatically. This removes the text/table overlap that plagues reports built with hand-computed row/column coordinates – content never collides no matter how many parameters, rows, or random effects it has.

Interactive fillable forms (AcroForm CRFs) are out of scope and handled by the sibling package pdfCRF; simPDF is for reports and figures.

The mental model

A report is a document (sp_new()), a rectangular content frame (frame_set()), and a list of blocks run through the flow engine (flow_run()). Coordinates are PDF points (1/72 inch); doc$W/doc$H are the page width/height.

doc <- sp_new(out(), paper = "letter")
flow_run(doc, list(
  block_para("Hello, simPDF", size = 16, font = 2),   # font: 1 plain, 2 bold, 3 italic
  block_rule(),
  block_para("This paragraph is measured and wraps to the frame width automatically.
              Add as much text as you like; the engine breaks lines and pages for you.")
))
sp_close(doc)

flow_run() measures each block, draws it if it fits, otherwise starts a new page. NULL entries are dropped, so conditional blocks (if (cond) block_*()) are fine.

Text blocks

block_para() word-wraps and aligns prose. block_pre() renders a character vector one physical line each – ideal for capture.output() dumps – and auto-shrinks the font so the widest line still fits the frame width.

doc <- sp_new(out())
flow_run(doc, list(
  block_para("Left / centre / right alignment", size = 12, font = 2),
  block_para("centred", align = "center"),
  block_para("right",   align = "right"),
  block_para("A fixed-point summary printed verbatim:", size = 11, font = 2),
  block_pre(capture.output(summary(1:100)))
))
sp_close(doc)

Tables and matrices

block_table() renders a data.frame/matrix with column widths measured from the content, numeric columns right-aligned, a header that repeats on every page, and automatic row-boundary page splitting. Row names become a leading label column.

big <- data.frame(ID = 1:200, TIME = round(runif(200, 0, 24), 2),
                  DV = round(rlnorm(200), 3), WT = round(runif(200, 50, 90), 1))
doc <- sp_new(out())
frame_set(doc, top = doc$H - 45, bottom = 45, left = 45, right = doc$W - 45)
flow_run(doc, list(block_para("Input data", 13, font = 2), block_table(big, size = 9)))
cat("pages:", doc$page_no, "\n")
#> pages: 4
sp_close(doc)

block_matrix() handles a wide matrix by splitting its columns into groups that each fit the page (row labels repeated in every group). This is what fixes the classic “print a 14x14 covariance matrix” overlap.

M <- matrix(round(rnorm(144, 0, .4), 3), 12, 12,
            dimnames = list(paste0("Eta", 1:12), paste0("Eta", 1:12)))
doc <- sp_new(out()); frame_set(doc, top = doc$H-45, bottom = 45, left = 45, right = doc$W-45)
flow_run(doc, list(block_para("Omega matrix (12 x 12)", 13, font = 2),
                   block_matrix(M, size = 9)))
sp_close(doc)

Plots and full-page figures

block_plot() draws one base-R plot into a reserved band inline with the text (the expression is captured unevaluated and run at draw time), so a heading, a plot, and a table can share a page and paginate together.

set.seed(1); pred <- rlnorm(80); dv <- pred * exp(rnorm(80, 0, .3))
doc <- sp_new(out()); frame_set(doc, top = doc$H-45, bottom = 45, left = 45, right = doc$W-45)
flow_run(doc, list(
  block_keep(list(
    block_para("Goodness of fit", 12, font = 2),
    block_plot({ plot(pred, dv); abline(0, 1, lty = 3) }, height = 220)))
))
sp_close(doc)

For a whole page of panels (e.g. one diagnostic grid per subject), use sp_figure_page(), which manages the page outside the flow engine. Draw exactly prod(mfrow) panels per call.

doc <- sp_new(out())
for (s in 1:3)
  sp_figure_page(doc, { for (i in 1:6) plot(rnorm(30), rnorm(30), main = paste("Subj", s)) },
                 mfrow = c(2, 3))
cat("pages:", doc$page_no, "\n")
#> pages: 3
sp_close(doc)

Structure: keep-together, spacers, rules, headers/footers

block_keep() keeps a group on one page (a heading never lands alone at the bottom); block_spacer(pts) and block_rule() add vertical space and lines. Running headers/footers are stamped on every page.

doc <- sp_new(out()); frame_set(doc, top = doc$H-55, bottom = 45, left = 45, right = doc$W-45)
flow_run(doc,
  blocks = list(block_para("Report body", 12), block_spacer(10),
                block_para(paste(rep("More text.", 40), collapse = " "))),
  footer = block_para("CONFIDENTIAL", size = 8, align = "center"))
sp_close(doc)

Measurement and quality checks

sp_width() / sp_height() expose the exact metrics the engine uses (AFM-exact for the core fonts). The tracer records every drawn text box so you can assert that a report has zero overlaps – handy in package tests.

doc <- sp_new(out()); frame_set(doc, top = doc$H-45, bottom = 45, left = 45, right = doc$W-45)
sp_trace(doc, TRUE)
flow_run(doc, list(block_para("Measured & overlap-free", 14, font = 2),
                   block_table(head(mtcars))))
cat("Courier '12345' at 10pt =", sp_width(doc, "12345", size = 10, family = "mono"), "pt\n")
#> Courier '12345' at 10pt = 30 pt
cat("overlapping text boxes  =", nrow(sp_overlaps(doc)), "\n")
#> overlapping text boxes  = 0
sp_close(doc)

Authorship and signatures

block_authorship() adds a “Prepared by” block; block_signature() draws visible approval lines and records their positions. sp_add_sig_fields() then inserts interactive Adobe Acrobat signature fields (/Sig) over those lines via a pure base-R incremental update – the finished PDF is one-click signable in the free Acrobat Reader.

f <- out()
doc <- sp_new(f); frame_set(doc, top = doc$H-50, bottom = 60, left = 54, right = doc$W-54)
flow_run(doc, list(
  block_para("Validation Report", 16, font = 2), block_rule(),
  block_authorship("Kyun-Seop Bae", role = "Prepared by",
                   affiliation = "Asan Medical Center", date = "2026-07-11"),
  block_spacer(30), block_para("Approvals", 12, font = 2),
  block_signature(c("Performed by", "Reviewed by"))
))
sp_close(doc)
sp_add_sig_fields(f, doc)          # add clickable /Sig fields over the drawn lines

Model flow diagrams

block_flow_diagram() lays out a tree of nodes (e.g. NONMEM model runs) as boxes sized to their text, tidily arranged and scaled to fit – so more models shrink the diagram instead of cluttering it.

nodes <- data.frame(
  id = c("001","002","003","004","005"),
  parent = c("", "001", "001", "002", "002"),
  label = c("Run 001\nOFV 1000", "Run 002\nOFV 980", "Run 003\nOFV 975",
            "Run 004\nOFV 960", "Run 005\nOFV 955"),
  stringsAsFactors = FALSE)
doc <- sp_new(out()); frame_set(doc, top = doc$H-45, bottom = 45, left = 40, right = doc$W-40)
flow_run(doc, list(block_para("Model development flow", 14, font = 2),
                   block_flow_diagram(nodes, height = 400)))
sp_close(doc)

Using simPDF from your own package

The typical pattern (as used by nmw): keep your parsing/statistics, and replace the PDF-generation with a blocks list plus one flow_run(), wrapping any diagnostic plots in block_plot() / sp_figure_page():

my_report <- function(x, file = "report.pdf") {
  # ... compute tables/values from x ...
  doc <- sp_new(file, paper = "letter", family = "Courier")
  frame_set(doc, top = doc$H - 45, bottom = 45, left = 45, right = doc$W - 45)
  flow_run(doc, list(
    block_para("Summary", size = 16, font = 2), block_rule(),
    block_table(my_table),
    block_para("Diagnostics", size = 12, font = 2),
    block_plot(plot(my_x, my_y), height = 220)
  ), footer = block_para("CONFIDENTIAL", size = 8, align = "center"))
  sp_close(doc)
}

Add simPDF to your Imports. Because simPDF uses only the graphics device, your diagnostic plots keep working unchanged – they are simply placed by the flow engine instead of by hand-computed coordinates, and never overlap.

These binaries (installable software) and packages are in development.
They may not be fully stable and should be used with caution. We make no claims about them.