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 008063632d..350714e117 100644
--- a/NEWS.md
+++ b/NEWS.md
@@ -2,12 +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 `
`, 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).
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..48e73fb8e7 100644
--- a/R/hooks-latex.R
+++ b/R/hooks-latex.R
@@ -157,7 +157,10 @@ 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('%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'))) {
# use alignment environments for R Markdown latex output (\centering won't work)
@@ -216,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('(? 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('
')
+ 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/output.R b/R/output.R
index 9cc5793c44..63a2e7d5a0 100644
--- a/R/output.R
+++ b/R/output.R
@@ -194,6 +194,9 @@ knit = function(
# we need some special treatment for chunks in Quarto document
.knitEnv$is_quarto = !is.null(opts_knit$get('quarto.version')) || ext == 'qmd'
+ # reset the flag for defining \figurenote (fig.note) once per document
+ if (!child_mode()) .knitEnv$fig.note.defined = FALSE
+
text = if (is.null(text)) xfun::read_utf8(input) else split_lines(text)
if (!length(text)) {
if (is.character(output)) file.create(output)
diff --git a/R/plot.R b/R/plot.R
index a5ef688fd0..93b4d05f4a 100644
--- a/R/plot.R
+++ b/R/plot.R
@@ -285,7 +285,7 @@ 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')
+ '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/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..cec0dc72af 100644
--- a/tests/testit/test-hooks-latex.R
+++ b/tests/testit/test-hooks-latex.R
@@ -63,3 +63,31 @@ 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", {
+ note_opts = function(note) opts_chunk$merge(list(
+ label = 'l', fig.cap = 'Cap', fig.note = note, fig.show = 'asis'
+ ))
+
+ # pretend we are at the start of a fresh document
+ .knitEnv$fig.note.defined = FALSE
+
+ res = hook_plot_tex('foo.pdf', note_opts('A note.'))
+ # \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))
+
+ # the definition is emitted only once per document: a second figure note
+ # calls \figurenote{} but does not repeat \providecommand
+ res2 = hook_plot_tex('foo.pdf', note_opts('Another note.'))
+ (grepl('\\figurenote{Another note.}', res2, fixed = TRUE))
+ (!grepl('\\providecommand', res2, fixed = TRUE))
+
+ # an empty/NA note adds nothing
+ (!grepl('figurenote', hook_plot_tex('foo.pdf', note_opts(NA)), fixed = TRUE))
+})
diff --git a/tests/testit/test-hooks-md.R b/tests/testit/test-hooks-md.R
index 9f3cf52291..734b522e69 100644
--- a/tests/testit/test-hooks-md.R
+++ b/tests/testit/test-hooks-md.R
@@ -117,6 +117,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'))) %==% '