civilytics_logo(): drops patchwork panels, clips captions, and silently rescales fonts #20

Open
opened 2026-08-10 13:02:42 -04:00 by jared · 0 comments
Owner

Summary

civilytics_logo() / add_logo() silently corrupts figures in three distinct ways. All
three are invisible at authoring time — the function returns a grob and never warns — and
all three surfaced together in crdc-arrests while preparing 18 figures for
civilytics.com. Every affected chunk there now routes around the function rather than
using it, which is why this is filed upstream.

The root cause of two of the three is that add_logo() hand-tunes layout with negative
point constants and heights that sum to more than 1
, calibrated against text that was
being drawn at roughly one-third size because .onLoad enables showtext.

Environment

  • civilytics as installed 2026-08-10 (renv library, crdc-arrests)
  • R 4.6.1, ragg device, dpi = 300
  • Reproduced with and without showtext

Defect 1 — patchwork compositions lose every panel but the last

add_logo() ends with:

composed <- arrangeGrob(plot, logo, heights = c(0.93, 0.1), padding = unit(0.1, "line"))

gridExtra::arrangeGrob() coerces a ggplot via ggplotGrob() → ggplot_build().
A patchwork object inherits c("patchwork", "gg", "ggplot"), so it satisfies the
dispatch, but patchwork composes at print()/plot() time and has no ggplot_build
method. ggplot_build() therefore builds only the last plot added to the composition.

No error, no warning — you get a valid PNG containing one panel of a multi-panel figure.

Repro:

library(civilytics); library(ggplot2); library(patchwork); library(ragg)
p1 <- ggplot(mtcars, aes(mpg, wt)) + geom_point() + labs(title = "Panel A")
p2 <- ggplot(mtcars, aes(hp, wt))  + geom_point() + labs(title = "Panel B")
pw <- p1 + p2 + plot_annotation(title = "Both panels")

agg_png("patchwork_bug.png", width = 9, height = 5, units = "in", res = 300)
grid::grid.draw(civilytics_logo(pw))
dev.off()
# -> contains only "Panel B". Panel A and the plot_annotation title are gone.

Observed in production: three figures in crdc-arrests shipped with 1 of 2, 1 of 4,
and 1 of 2 panels respectively. One of them (zero_arrest_counts_draws) had prose
walking the reader through "the top left" and "the top right panel" of a figure that had
been reduced to a single cell.

Suggested fix: branch on the class before composing.

plot_grob <- if (inherits(plot, "patchwork")) patchwork::patchworkGrob(plot) else plot
composed  <- arrangeGrob(plot_grob, logo, heights = ..., padding = ...)

Defect 2 — captions are pushed off the bottom of the canvas

Two things combine here.

(a) The composed heights sum to 1.03:

composed <- arrangeGrob(plot, logo, heights = c(0.93, 0.1), ...)   # 0.93 + 0.1 = 1.03

The composition is 3% taller than the device, so the bottom 3% — where the caption
sits — falls outside the canvas.

(b) A negative bottom margin proportional to caption line count:

if (has_caption(plot)) {
  cap_lines <- measure_caption(plot)
  plot <- plot + theme(plot.margin = unit(c(7, 7, cap_lines * -52, 7), "pt"))
} else {
  plot <- plot + theme(plot.margin = unit(c(7, 7, -14, 7), "pt"))
}

-52 pt per caption line is a magic constant that only holds for one font at one size on
one device. It appears to have been tuned while showtext was active — and showtext is
enabled by this package's own .onLoad via civilytics_load_fonts() → showtext_auto().
showtext draws glyphs at its fixed 96 dpi while the device lays out at res = 300, so at
300 dpi text renders at roughly 32% of its requested size. A -52 pt pull is survivable
against third-size text and destructive against correctly-sized text.

Repro (A/B on identical plots, only the logo call differs):

library(civilytics); library(ggplot2); library(ragg)
showtext::showtext_auto(FALSE)          # required for correct sizing with ragg
p <- ggplot(mtcars, aes(mpg, wt)) + geom_point() +
  labs(title = "Title", caption = "Source: a reasonably long attribution line, 2021-22") +
  theme_civilytics()

agg_png("no_logo.png",   width = 11, height = 7, units = "in", res = 300); print(p); dev.off()
agg_png("with_logo.png", width = 11, height = 7, units = "in", res = 300)
grid::grid.draw(civilytics_logo(p)); dev.off()

Measuring ink in the bottom two pixel rows of each file:

file ink in bottom 2 rows
no_logo.png 0 (clean)
with_logo.png 1527 (caption clipped mid-glyph)

Observed in production: 8 of 18 figures shipped with the source attribution cut
roughly in half vertically.

Suggested fix: make the logo band additive rather than subtractive — reserve its
height in the layout so collision is impossible by construction, rather than pulling the
plot down with a negative margin and hoping. Heights should also sum to 1. Sizing the
logo in inches rather than as a fraction would additionally keep its apparent size
stable across 8–24 in canvases.


Defect 3 — the base font is silently rescaled by 10%

add_logo <- function(plot, logo, margin_param = NULL, font_scale = 1.1, ...) {
  if (!is.null(font_scale) && font_scale != 1) {
    base_size <- plot$theme$text$size %||% 14
    plot <- plot + ggplot2::theme(text = ggplot2::element_text(size = base_size * font_scale))
  }

Adding a logo changes the typography of the plot. A caller who set
theme_civilytics(font_size = 12) gets 13.2, and any constant tuned against 12 — wrap
widths, annotation offsets, manual nudge_* values — is now off. This is a surprising
side effect for a function whose documented job is to stamp a logo, and it is not
mentioned at the call site.

Suggested fix: default font_scale = 1, and if the 1.1 behaviour is wanted somewhere,
make it opt-in at the call site.


Why this went unnoticed for so long

The three defects mask each other and mask their own cause:

  1. .onLoad turns showtext on, so all text renders at ~1/3 size.
  2. Layout constants in add_logo() were tuned in that regime and look fine there.
  3. Figures are typically reviewed at full size, where 1/3-size text is small but legible
    and a 3% overflow clips only a caption most reviewers don't read.
  4. The damage becomes obvious only when the figure is scaled down — e.g. into a 1100px
    web content column, where 10pt table text landed at ~5 CSS pixels against 18px body
    text.

There is a documented note in crdc-arrests from an earlier encounter with (2), where
civilytics_logo() was found to shrink a plot ~7% and collapse a facet. That was worked
around locally at the time rather than reported — hence this issue now covering all of it.

Suggested acceptance criteria

  • A patchwork passed to civilytics_logo() renders all its panels and its plot_annotation()
  • A plot with a 1–3 line caption renders with zero ink in the bottom 2 pixel rows at
    res = 300, at canvas sizes from 8 to 24 inches wide
  • font_scale defaults to 1
  • Layout is expressed in reserved space rather than negative margins, so it is
    independent of whether showtext is active
  • Consider whether .onLoad should call showtext_auto() at all — it makes correct
    ragg/systemfonts rendering opt-out rather than opt-in, and the two disagree about dpi

Workaround currently in use

crdc-arrests stamps the logo onto the saved raster instead of composing it into the
grob — ragg::agg_png() → print(p) → dev.off() → composite the logo with magick.
That never reflows the plot, so it is immune to all three defects. Helper is
cv_stamp_logo_png() in that repo's R/branding.R if it is useful as a starting point.

## Summary `civilytics_logo()` / `add_logo()` silently corrupts figures in three distinct ways. All three are invisible at authoring time — the function returns a grob and never warns — and all three surfaced together in `crdc-arrests` while preparing 18 figures for civilytics.com. Every affected chunk there now routes around the function rather than using it, which is why this is filed upstream. The root cause of two of the three is that `add_logo()` hand-tunes layout with **negative point constants and heights that sum to more than 1**, calibrated against text that was being drawn at roughly one-third size because `.onLoad` enables showtext. ## Environment - `civilytics` as installed 2026-08-10 (renv library, `crdc-arrests`) - R 4.6.1, `ragg` device, `dpi = 300` - Reproduced with and without showtext --- ## Defect 1 — patchwork compositions lose every panel but the last `add_logo()` ends with: ```r composed <- arrangeGrob(plot, logo, heights = c(0.93, 0.1), padding = unit(0.1, "line")) ``` `gridExtra::arrangeGrob()` coerces a ggplot via `ggplotGrob()` → `ggplot_build()`. A `patchwork` object inherits `c("patchwork", "gg", "ggplot")`, so it satisfies the dispatch, but patchwork composes at `print()`/`plot()` time and has no `ggplot_build` method. `ggplot_build()` therefore builds **only the last plot added to the composition**. No error, no warning — you get a valid PNG containing one panel of a multi-panel figure. **Repro:** ```r library(civilytics); library(ggplot2); library(patchwork); library(ragg) p1 <- ggplot(mtcars, aes(mpg, wt)) + geom_point() + labs(title = "Panel A") p2 <- ggplot(mtcars, aes(hp, wt)) + geom_point() + labs(title = "Panel B") pw <- p1 + p2 + plot_annotation(title = "Both panels") agg_png("patchwork_bug.png", width = 9, height = 5, units = "in", res = 300) grid::grid.draw(civilytics_logo(pw)) dev.off() # -> contains only "Panel B". Panel A and the plot_annotation title are gone. ``` **Observed in production:** three figures in `crdc-arrests` shipped with 1 of 2, 1 of 4, and 1 of 2 panels respectively. One of them (`zero_arrest_counts_draws`) had prose walking the reader through "the top left" and "the top right panel" of a figure that had been reduced to a single cell. **Suggested fix:** branch on the class before composing. ```r plot_grob <- if (inherits(plot, "patchwork")) patchwork::patchworkGrob(plot) else plot composed <- arrangeGrob(plot_grob, logo, heights = ..., padding = ...) ``` --- ## Defect 2 — captions are pushed off the bottom of the canvas Two things combine here. **(a) The composed heights sum to 1.03:** ```r composed <- arrangeGrob(plot, logo, heights = c(0.93, 0.1), ...) # 0.93 + 0.1 = 1.03 ``` The composition is 3% taller than the device, so the bottom 3% — where the caption sits — falls outside the canvas. **(b) A negative bottom margin proportional to caption line count:** ```r if (has_caption(plot)) { cap_lines <- measure_caption(plot) plot <- plot + theme(plot.margin = unit(c(7, 7, cap_lines * -52, 7), "pt")) } else { plot <- plot + theme(plot.margin = unit(c(7, 7, -14, 7), "pt")) } ``` `-52` pt per caption line is a magic constant that only holds for one font at one size on one device. It appears to have been tuned while showtext was active — and showtext is enabled by this package's own `.onLoad` via `civilytics_load_fonts()` → `showtext_auto()`. showtext draws glyphs at its fixed 96 dpi while the device lays out at `res = 300`, so at 300 dpi text renders at roughly 32% of its requested size. A `-52` pt pull is survivable against third-size text and destructive against correctly-sized text. **Repro** (A/B on identical plots, only the logo call differs): ```r library(civilytics); library(ggplot2); library(ragg) showtext::showtext_auto(FALSE) # required for correct sizing with ragg p <- ggplot(mtcars, aes(mpg, wt)) + geom_point() + labs(title = "Title", caption = "Source: a reasonably long attribution line, 2021-22") + theme_civilytics() agg_png("no_logo.png", width = 11, height = 7, units = "in", res = 300); print(p); dev.off() agg_png("with_logo.png", width = 11, height = 7, units = "in", res = 300) grid::grid.draw(civilytics_logo(p)); dev.off() ``` Measuring ink in the bottom two pixel rows of each file: | file | ink in bottom 2 rows | |---|---| | `no_logo.png` | **0** (clean) | | `with_logo.png` | **1527** (caption clipped mid-glyph) | **Observed in production:** 8 of 18 figures shipped with the source attribution cut roughly in half vertically. **Suggested fix:** make the logo band additive rather than subtractive — reserve its height in the layout so collision is impossible by construction, rather than pulling the plot down with a negative margin and hoping. Heights should also sum to 1. Sizing the logo in **inches** rather than as a fraction would additionally keep its apparent size stable across 8–24 in canvases. --- ## Defect 3 — the base font is silently rescaled by 10% ```r add_logo <- function(plot, logo, margin_param = NULL, font_scale = 1.1, ...) { if (!is.null(font_scale) && font_scale != 1) { base_size <- plot$theme$text$size %||% 14 plot <- plot + ggplot2::theme(text = ggplot2::element_text(size = base_size * font_scale)) } ``` Adding a logo changes the typography of the plot. A caller who set `theme_civilytics(font_size = 12)` gets 13.2, and any constant tuned against 12 — wrap widths, annotation offsets, manual `nudge_*` values — is now off. This is a surprising side effect for a function whose documented job is to stamp a logo, and it is not mentioned at the call site. **Suggested fix:** default `font_scale = 1`, and if the 1.1 behaviour is wanted somewhere, make it opt-in at the call site. --- ## Why this went unnoticed for so long The three defects mask each other and mask their own cause: 1. `.onLoad` turns showtext on, so all text renders at ~1/3 size. 2. Layout constants in `add_logo()` were tuned in that regime and look fine there. 3. Figures are typically reviewed at full size, where 1/3-size text is small but legible and a 3% overflow clips only a caption most reviewers don't read. 4. The damage becomes obvious only when the figure is scaled down — e.g. into a 1100px web content column, where 10pt table text landed at ~5 CSS pixels against 18px body text. There is a documented note in `crdc-arrests` from an earlier encounter with (2), where `civilytics_logo()` was found to shrink a plot ~7% and collapse a facet. That was worked around locally at the time rather than reported — hence this issue now covering all of it. ## Suggested acceptance criteria - [ ] A patchwork passed to `civilytics_logo()` renders all its panels and its `plot_annotation()` - [ ] A plot with a 1–3 line caption renders with zero ink in the bottom 2 pixel rows at `res = 300`, at canvas sizes from 8 to 24 inches wide - [ ] `font_scale` defaults to 1 - [ ] Layout is expressed in reserved space rather than negative margins, so it is independent of whether showtext is active - [ ] Consider whether `.onLoad` should call `showtext_auto()` at all — it makes correct ragg/systemfonts rendering opt-out rather than opt-in, and the two disagree about dpi ## Workaround currently in use `crdc-arrests` stamps the logo onto the saved raster instead of composing it into the grob — `ragg::agg_png()` → `print(p)` → `dev.off()` → composite the logo with `magick`. That never reflows the plot, so it is immune to all three defects. Helper is `cv_stamp_logo_png()` in that repo's `R/branding.R` if it is useful as a starting point.
jared added the bug label 2026-08-10 13:02:42 -04:00
jared added the
type
defect
ws
logo
labels 2026-08-23 23:33:24 -04:00
Sign in to join this conversation.
No labels bug
type
defect
ws
logo
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: Civilytics/civilyticsR#20