From 4b27dc5d42fb6ca2a83d0f79024af9eb433d08fe Mon Sep 17 00:00:00 2001 From: Yihui Xie Date: Wed, 26 Aug 2026 12:06:32 -0400 Subject: [PATCH 1/4] feat: add chunk option fig.note for figure notes (#2022) Add a new chunk option `fig.note` to place a note (e.g., a source or explanatory note) below a figure, separate from its caption. - LaTeX/PDF: emit `\figurenote{...}` inside the figure environment after `\caption`. A default `\providecommand{\figurenote}` (footnotesize italic) is shipped so it works out of the box on both the Rnw and Rmd->pandoc paths; users can override it in the preamble, e.g. with `\newcommand{\figurenote}[1]{\floatfoot{#1}}` (floatrow package). - HTML: place the note in `

` inside the figure `

`; a note with no caption still gets a figure div (no empty caption paragraph). A default style is added to inst/misc/vignette.css. - Typst: emit an emphasized small block after `#figure`. `fig.note` is added to `eval.after` (so inline R is evaluated after the chunk), to `.recyle.opts` (multi-plot recycling), and to `need_special_plot_hook()` so Rmd->LaTeX routes through hook_plot_tex(). Co-Authored-By: Claude Opus 4.8 --- NEWS.md | 2 ++ R/defaults.R | 5 +++-- R/hooks-latex.R | 9 ++++++++- R/hooks-md.R | 15 +++++++++------ R/hooks-typst.R | 8 +++++++- R/plot.R | 4 ++-- inst/misc/vignette.css | 4 ++++ tests/testit/test-hooks-latex.R | 19 +++++++++++++++++++ tests/testit/test-hooks-md.R | 14 ++++++++++++++ 9 files changed, 68 insertions(+), 12 deletions(-) diff --git a/NEWS.md b/NEWS.md index ff4bf06312..86dd3a541c 100644 --- a/NEWS.md +++ b/NEWS.md @@ -4,6 +4,8 @@ - `hook_plot_tex()` now respects the chunk option `animation.hook` (or the package option `animation.fun`) when it is set to a function of your own, and calls it to generate the LaTeX code for a chunk with `fig.show = 'animate'` instead of the default `\animategraphics{}`. This makes the LaTeX plot hook extensible in the same way as `hook_plot_html()`, which has always supported these options, and allows for LaTeX packages other than **animate** to be used for animations, e.g. **xmpmulti** for beamer overlays. The built-in hooks such as `hook_ffmpeg_html()` generate HTML and continue to be ignored for LaTeX output, so this does not change the output of existing documents (thanks, @jolars #2452). +- Added a new chunk option `fig.note` to add a note (e.g., a source or explanatory note) below a figure, separate from its caption (thanks, @turbanisch, #2022). It works for LaTeX/PDF, HTML, and Typst output. For LaTeX, the note is emitted via a `\figurenote{}` command inside the figure environment; a default definition is provided (footnotesize italic) that you may override in the preamble, e.g., `\newcommand{\figurenote}[1]{\floatfoot{#1}}` (with the **floatrow** package). For HTML, the note is placed in `

` inside the figure `

`, which you can style with CSS. + - Added support for a new input format `.Rtyp` for the [Typst](https://typst.app) typesetting system (thanks, @blset #2401, @aksigkvgithub #2283). You can use code chunks and inline R expressions in `.Rtyp` files, knit them via `knitr::knit()` to `.typ` output, or compile to PDF directly via `knit2pdf('input.Rtyp')`. See https://github.com/yihui/knitr-examples/blob/master/128-minimal.Rtyp for a minimal example. - Added a `knitr::rtyp` vignette engine to build `.Rtyp` vignettes to PDF via Typst, so packages no longer need to register their own engine for this purpose (thanks, @ggrothendieck, #2447). diff --git a/R/defaults.R b/R/defaults.R index 5db8d399a5..b4b15a2973 100644 --- a/R/defaults.R +++ b/R/defaults.R @@ -93,7 +93,8 @@ opts_chunk = new_defaults(list( fig.keep = 'high', fig.show = 'asis', fig.align = 'default', fig.path = 'figure/', dev = NULL, dev.args = NULL, dpi = 72, fig.ext = NULL, fig.width = 7, fig.height = 7, - fig.env = 'figure', fig.cap = NULL, fig.scap = NULL, fig.lp = 'fig:', fig.subcap = NULL, + fig.env = 'figure', fig.cap = NULL, fig.scap = NULL, fig.note = NULL, + fig.lp = 'fig:', fig.subcap = NULL, fig.pos = '', out.width = NULL, out.height = NULL, out.extra = NULL, fig.retina = 1, external = TRUE, sanitize = FALSE, interval = 1, aniopts = 'controls,loop', @@ -198,7 +199,7 @@ set_alias = function(...) { #' } #' @include hooks-html.R opts_knit = new_defaults(list( - progress = TRUE, verbose = FALSE, eval.after = c('fig.cap', 'fig.scap', 'fig.alt'), + progress = TRUE, verbose = FALSE, eval.after = c('fig.cap', 'fig.scap', 'fig.alt', 'fig.note'), base.dir = NULL, base.url = NULL, root.dir = NULL, child.path = '', upload.fun = identity, global.device = FALSE, global.par = FALSE, concordance = FALSE, documentation = 1L, self.contained = TRUE, diff --git a/R/hooks-latex.R b/R/hooks-latex.R index f1b9fd3640..a99153c5b4 100644 --- a/R/hooks-latex.R +++ b/R/hooks-latex.R @@ -157,7 +157,14 @@ hook_plot_tex = function(x, options) { '\\caption%s{%s}%s\n', escape_percent(scap), escape_percent(cap), create_label(lab, if (mcap) c('-', fig.cur), latex = TRUE) ) - fig2 = sprintf('%s\\end{%s}\n', cap, options$fig.env) + note = options$fig.note + note = if (is.null(note) || is.na(note) || note == '') '' else sprintf( + # define a default \figurenote command that users may override in the + # preamble (\providecommand won't clobber an existing definition) + '\\providecommand{\\figurenote}[1]{\\vspace{2pt}\\par\\raggedright\\footnotesize\\emph{#1}}\\figurenote{%s}\n', + escape_percent(note) + ) + fig2 = sprintf('%s%s\\end{%s}\n', cap, note, options$fig.env) } } else if (pandoc_to(c('latex', 'beamer'))) { # use alignment environments for R Markdown latex output (\centering won't work) diff --git a/R/hooks-md.R b/R/hooks-md.R index 5a7b843540..6272306f96 100644 --- a/R/hooks-md.R +++ b/R/hooks-md.R @@ -34,7 +34,7 @@ need_special_plot_hook = function(options) { opts = opts_chunk$get(default = TRUE) for (i in c( 'out.width', 'out.height', 'out.extra', 'fig.align', 'fig.subcap', - 'fig.env', 'fig.scap', 'fig.alt' + 'fig.env', 'fig.scap', 'fig.alt', 'fig.note' )) if (!identical(options[[i]], opts[[i]])) return(TRUE) FALSE } @@ -61,13 +61,15 @@ hook_plot_md_base = function(x, options) { # self-contained mode? sc = any(c('--embed-resources', '--self-contained') %in% opts_knit$get('rmarkdown.pandoc.args')) lnk = options$fig.link - pandoc_html = cap != '' && is_html_output() + note = options$fig.note + has_note = !is.null(note) && !is.na(note) && note != '' + pandoc_html = (cap != '' || has_note) && is_html_output() in_bookdown = isTRUE(opts_knit$get('bookdown.internal.label')) plot1 = ai || options$fig.cur <= 1L plot2 = ai || options$fig.cur == options$fig.num to = pandoc_to(); from = pandoc_from() if (is.null(w) && is.null(h) && is.null(s) && is.null(options$fig.alt) && - a == 'default' && !(pandoc_html && in_bookdown) && !is_svg) { + a == 'default' && !(pandoc_html && in_bookdown) && !is_svg && !has_note) { # append to ![]() to prevent the figure environment in these cases nocap = cap == '' && !is.null(to) && !grepl('^markdown', to) && (options$fig.num == 1 || ai) && !grepl('-implicit_figures', from) @@ -88,13 +90,14 @@ hook_plot_md_base = function(x, options) { # use HTML syntax if (pandoc_html && !isTRUE(grepl('-implicit_figures', from))) { d1 = if (plot1) sprintf('
\n', css_text_align(a)) - d2 = sprintf('

%s

', cap) + d2 = if (cap != '') sprintf('

%s

', cap) + d3 = if (has_note) sprintf('

%s

', note) img = img_code() # whether to place figure caption at the top or bottom of a figure if (isTRUE(options$fig.topcaption)) { - paste0(d1, if (ai || options$fig.cur <= 1) d2, img, if (plot2) '
') + paste0(d1, if (ai || options$fig.cur <= 1) d2, img, if (plot2) paste0(d3, '
')) } else { - paste0(d1, img, if (plot2) paste0('\n', d2, '\n
')) + paste0(d1, img, if (plot2) paste0('\n', d2, d3, '\n')) } } else { img_code(sprintf('style="%s"', css_align(a))) diff --git a/R/hooks-typst.R b/R/hooks-typst.R index 3c3ddcf8a9..a2e6fdf666 100644 --- a/R/hooks-typst.R +++ b/R/hooks-typst.R @@ -18,8 +18,14 @@ hook_plot_typst = function(x, options) { img_path = paste0(opts_knit$get('base.url'), .upload.url(x)) img_args = paste(c(sprintf('"%s"', img_path), args), collapse = ', ') + note = options$fig.note + note = if (is.null(note) || is.na(note) || note == '') '' else + sprintf('\n#block(inset: (top: 4pt))[#text(size: 0.85em)[#emph[%s]]]\n', note) + if (nzchar(cap)) { - sprintf('\n#figure(\n image(%s),\n caption: [%s],\n)\n', img_args, cap) + sprintf('\n#figure(\n image(%s),\n caption: [%s],\n)%s\n', img_args, cap, note) + } else if (nzchar(note)) { + sprintf('\n#image(%s)%s\n', img_args, note) } else { sprintf('\n#image(%s)\n', img_args) } diff --git a/R/plot.R b/R/plot.R index a5ef688fd0..ac9a858ff1 100644 --- a/R/plot.R +++ b/R/plot.R @@ -284,8 +284,8 @@ is_low_change.default = function(p1, p2) { # recycle some plot options such as fig.cap, out.width/height, etc when there # are multiple plots per chunk -.recyle.opts = c('fig.cap', 'fig.scap', 'fig.alt', 'fig.env', 'fig.pos', 'fig.subcap', - 'out.width', 'out.height', 'out.extra', 'fig.link') +.recyle.opts = c('fig.cap', 'fig.scap', 'fig.alt', 'fig.note', 'fig.env', 'fig.pos', + 'fig.subcap', 'out.width', 'out.height', 'out.extra', 'fig.link') # when passing options to plot hooks, reduce the recycled options to scalars reduce_plot_opts = function(options) { diff --git a/inst/misc/vignette.css b/inst/misc/vignette.css index 7d2eb68e1a..8592cf9422 100644 --- a/inst/misc/vignette.css +++ b/inst/misc/vignette.css @@ -142,6 +142,10 @@ table > caption span, div.figure p.caption span { font-style: normal; font-weight: bold; } +div.figure p.figure-note { + font-size: 90%; + font-style: italic; +} img:not([class]) { background-color: #FFFFFF; diff --git a/tests/testit/test-hooks-latex.R b/tests/testit/test-hooks-latex.R index 73cf531d0b..a44fa4a22c 100644 --- a/tests/testit/test-hooks-latex.R +++ b/tests/testit/test-hooks-latex.R @@ -63,3 +63,22 @@ assert("a user-provided animation hook generates the LaTeX code for animations", 'foo-3.pdf', opts(fig.cur = 3, fig.num = 3) ), fixed = TRUE)) }) + +assert("fig.note produces \\figurenote{} inside the figure environment", { + res = hook_plot_tex('foo.pdf', opts_chunk$merge(list( + label = 'l', fig.cap = 'Cap', fig.note = 'A note.', fig.show = 'asis' + ))) + # \figurenote{} appears after \caption and before \end{figure} + (grepl('\\figurenote{A note.}', res, fixed = TRUE)) + (grepl('\\providecommand{\\figurenote}', res, fixed = TRUE)) + # \figurenote{} comes after \caption and before \end{figure} + (regexpr('\\caption', res, fixed = TRUE) < + regexpr('\\figurenote{A note.}', res, fixed = TRUE)) + (regexpr('\\figurenote{A note.}', res, fixed = TRUE) < + regexpr('\\end{figure}', res, fixed = TRUE)) + + # an empty/NA note adds nothing + (!grepl('figurenote', hook_plot_tex('foo.pdf', opts_chunk$merge(list( + label = 'l', fig.cap = 'Cap', fig.note = NA, fig.show = 'asis' + ))), fixed = TRUE)) +}) diff --git a/tests/testit/test-hooks-md.R b/tests/testit/test-hooks-md.R index 7ff7e1058e..0128d199bb 100644 --- a/tests/testit/test-hooks-md.R +++ b/tests/testit/test-hooks-md.R @@ -106,6 +106,20 @@ assert('empty alt text is preserved and NA alt is discarded', { (hook_plot_md(x, opts_chunk$merge(list(fig.alt = NA, out.width = '100'))) %==% '') }) +assert("fig.note is placed in a figure-note paragraph for HTML output", { + old = opts_knit$get('rmarkdown.pandoc.to') + opts_knit$set(rmarkdown.pandoc.to = 'html') + # note together with a caption + (hook_plot_md(x, opt(cap = cap, fig.note = 'A note.')) %==% + paste0('
\nfoo\n', + '

foo

A note.

\n
')) + # note without a caption (no empty caption paragraph) + (hook_plot_md(x, opt(fig.note = 'A note.')) %==% + paste0('
\n\n', + '

A note.

\n
')) + opts_knit$set('rmarkdown.pandoc.to' = old) +}) + assert("fig.alt does not break office document", { old = opts_knit$get('rmarkdown.pandoc.to') opts_knit$set(rmarkdown.pandoc.to = "docx") From f11167f952bf170bd2dcc422da224b0941537886 Mon Sep 17 00:00:00 2001 From: Yihui Xie Date: Wed, 26 Aug 2026 14:41:57 -0400 Subject: [PATCH 2/4] Emit the \figurenote definition only once per document Previously every figure with fig.note carried the full \providecommand{\figurenote}{...} definition, which was verbose. Since LaTeX is processed sequentially, define it once (on the first figure note in a document) via a per-document flag; later notes only call \figurenote{}. \providecommand remains a no-op when the user has defined \figurenote in the preamble, so the default and customization both work. Co-Authored-By: Claude Opus 4.8 --- R/hooks-latex.R | 19 +++++++++++++------ R/output.R | 3 +++ tests/testit/test-hooks-latex.R | 22 ++++++++++++++++------ 3 files changed, 32 insertions(+), 12 deletions(-) diff --git a/R/hooks-latex.R b/R/hooks-latex.R index a99153c5b4..48e73fb8e7 100644 --- a/R/hooks-latex.R +++ b/R/hooks-latex.R @@ -158,12 +158,8 @@ hook_plot_tex = function(x, options) { create_label(lab, if (mcap) c('-', fig.cur), latex = TRUE) ) note = options$fig.note - note = if (is.null(note) || is.na(note) || note == '') '' else sprintf( - # define a default \figurenote command that users may override in the - # preamble (\providecommand won't clobber an existing definition) - '\\providecommand{\\figurenote}[1]{\\vspace{2pt}\\par\\raggedright\\footnotesize\\emph{#1}}\\figurenote{%s}\n', - escape_percent(note) - ) + note = if (is.null(note) || is.na(note) || note == '') '' else + sprintf('%s\\figurenote{%s}\n', define_figurenote(), escape_percent(note)) fig2 = sprintf('%s%s\\end{%s}\n', cap, note, options$fig.env) } } else if (pandoc_to(c('latex', 'beamer'))) { @@ -223,6 +219,17 @@ animation_hook_tex = function(options) { fun } +# provide a default \figurenote command (for the chunk option fig.note) the +# first time it is needed in a document; \providecommand is a no-op if the user +# has defined \figurenote in the preamble (e.g. via \newcommand), so this both +# works out of the box and stays customizable; emitting it only once avoids +# repeating the long definition before every figure note +define_figurenote = function() { + if (isTRUE(.knitEnv$fig.note.defined)) return('') + .knitEnv$fig.note.defined = TRUE + '\\providecommand{\\figurenote}[1]{\\vspace{2pt}\\par\\raggedright\\footnotesize\\emph{#1}}\n' +} + # % -> \%, but do not touch \% escape_percent = function(x) gsub('(? Date: Wed, 26 Aug 2026 14:56:57 -0400 Subject: [PATCH 3/4] Address review comments - keep fig.note near fig.subcap in .recyle.opts to minimize the diff - use .knitEnv directly in tests (internal objects are visible to testit) Co-Authored-By: Claude Opus 4.8 --- R/plot.R | 4 ++-- tests/testit/test-hooks-latex.R | 3 +-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/R/plot.R b/R/plot.R index ac9a858ff1..93b4d05f4a 100644 --- a/R/plot.R +++ b/R/plot.R @@ -284,8 +284,8 @@ is_low_change.default = function(p1, p2) { # recycle some plot options such as fig.cap, out.width/height, etc when there # are multiple plots per chunk -.recyle.opts = c('fig.cap', 'fig.scap', 'fig.alt', 'fig.note', 'fig.env', 'fig.pos', - 'fig.subcap', 'out.width', 'out.height', 'out.extra', 'fig.link') +.recyle.opts = c('fig.cap', 'fig.scap', 'fig.alt', 'fig.env', 'fig.pos', 'fig.subcap', + 'fig.note', 'out.width', 'out.height', 'out.extra', 'fig.link') # when passing options to plot hooks, reduce the recycled options to scalars reduce_plot_opts = function(options) { diff --git a/tests/testit/test-hooks-latex.R b/tests/testit/test-hooks-latex.R index d6768d42cb..cec0dc72af 100644 --- a/tests/testit/test-hooks-latex.R +++ b/tests/testit/test-hooks-latex.R @@ -70,8 +70,7 @@ assert("fig.note produces \\figurenote{} inside the figure environment", { )) # pretend we are at the start of a fresh document - knitr_env = getFromNamespace('.knitEnv', 'knitr') - knitr_env$fig.note.defined = FALSE + .knitEnv$fig.note.defined = FALSE res = hook_plot_tex('foo.pdf', note_opts('A note.')) # \figurenote{} appears after \caption and before \end{figure} From df81a40354a4ec65b6b6896ff7e764e62d625c7f Mon Sep 17 00:00:00 2001 From: Yihui Xie Date: Wed, 26 Aug 2026 15:09:42 -0400 Subject: [PATCH 4/4] reorder news [ci skip] --- DESCRIPTION | 2 +- NEWS.md | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/DESCRIPTION b/DESCRIPTION index 27128609eb..979e7d8325 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -1,7 +1,7 @@ Package: knitr Type: Package Title: A General-Purpose Package for Dynamic Report Generation in R -Version: 1.51.14 +Version: 1.51.15 Authors@R: c( person("Yihui", "Xie", role = c("aut", "cre"), email = "xie@yihui.name", comment = c(ORCID = "0000-0003-0645-5666", URL = "https://yihui.org")), person("Abhraneel", "Sarma", role = "ctb"), diff --git a/NEWS.md b/NEWS.md index 6df71b7571..350714e117 100644 --- a/NEWS.md +++ b/NEWS.md @@ -2,14 +2,14 @@ ## NEW FEATURES -- `hook_plot_tex()` now respects the chunk option `animation.hook` (or the package option `animation.fun`) when it is set to a function of your own, and calls it to generate the LaTeX code for a chunk with `fig.show = 'animate'` instead of the default `\animategraphics{}`. This makes the LaTeX plot hook extensible in the same way as `hook_plot_html()`, which has always supported these options, and allows for LaTeX packages other than **animate** to be used for animations, e.g. **xmpmulti** for beamer overlays. The built-in hooks such as `hook_ffmpeg_html()` generate HTML and continue to be ignored for LaTeX output, so this does not change the output of existing documents (thanks, @jolars #2452). - - Added a new chunk option `fig.note` to add a note (e.g., a source or explanatory note) below a figure, separate from its caption (thanks, @turbanisch, #2022). It works for LaTeX/PDF, HTML, and Typst output. For LaTeX, the note is emitted via a `\figurenote{}` command inside the figure environment; a default definition is provided (footnotesize italic) that you may override in the preamble, e.g., `\newcommand{\figurenote}[1]{\floatfoot{#1}}` (with the **floatrow** package). For HTML, the note is placed in `

` inside the figure `

`, which you can style with CSS. - Added support for a new input format `.Rtyp` for the [Typst](https://typst.app) typesetting system (thanks, @blset #2401, @aksigkvgithub #2283). You can use code chunks and inline R expressions in `.Rtyp` files, knit them via `knitr::knit()` to `.typ` output, or compile to PDF directly via `knit2pdf('input.Rtyp')`. See https://github.com/yihui/knitr-examples/blob/master/128-minimal.Rtyp for a minimal example. - Added a `knitr::rtyp` vignette engine to build `.Rtyp` vignettes to PDF via Typst, so packages no longer need to register their own engine for this purpose (thanks, @ggrothendieck, #2447). +- `hook_plot_tex()` now respects the chunk option `animation.hook` (or the package option `animation.fun`) when it is set to a function of your own, and calls it to generate the LaTeX code for a chunk with `fig.show = 'animate'` instead of the default `\animategraphics{}`. This makes the LaTeX plot hook extensible in the same way as `hook_plot_html()`, which has always supported these options, and allows for LaTeX packages other than **animate** to be used for animations, e.g. **xmpmulti** for beamer overlays. The built-in hooks such as `hook_ffmpeg_html()` generate HTML and continue to be ignored for LaTeX output, so this does not change the output of existing documents (thanks, @jolars #2452). + - Added support for `ragg::agg_webp()` as a graphics device for WebP output (thanks, @heavywatal, #2434). To use this device, set `dev = 'agg_webp'` in the chunk options. This device is available in **ragg** >= 1.5.0. - The chunk option `dev.args` can now customize the `dev = 'gridSVG'` device. Arguments are passed to both `grDevices::svg()` (e.g., `pointsize`) and `gridSVG::grid.export()` (e.g., `strict`), with each function receiving only the arguments it recognizes (thanks, @deepayan, #2450, #2451).