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 `

` 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). 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('
\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/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'))) %==% '') }) +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")