blob: 7fbc976fa98fdfc4527e7673d2612985374b6e7e [file] [log] [blame]
Marc Kupietzb066e742026-08-23 09:57:06 +03001# Pattern matching timing comments, e.g. <!-- MK 00:30 --> or just <!-- 00:30 -->
2timing_comment_pattern <- paste0(
3 "<!--[[:space:]]*(?:([[:alnum:]._-]+)[[:space:]]+)?",
4 "([0-9]+(?::[0-9]{2}){0,2})[[:space:]]*-->"
5)
6
7parse_duration_seconds <- function(x) {
8 vapply(x, function(t) {
9 parts <- strsplit(t, ":", fixed = TRUE)[[1]]
10 parts <- suppressWarnings(as.numeric(parts))
11 if (length(parts) == 0 || length(parts) > 3 || anyNA(parts)) {
12 return(NA_real_)
13 }
14 multipliers <- c(3600, 60, 1)[(4 - length(parts)):3]
15 sum(parts * multipliers)
16 }, numeric(1), USE.NAMES = FALSE)
17}
18
19format_seconds <- function(x) {
20 x <- round(x)
21 h <- floor(x / 3600)
22 m <- floor((x %% 3600) / 60)
23 s <- x %% 60
24 ifelse(h > 0, sprintf("%d:%02d:%02d", h, m, s), sprintf("%d:%02d", m, s))
25}
26
27# Extract all timing comments from a character vector (one line per element).
28# Returns a data frame with columns line (1-based index), speaker (NA for
29# comments without a speaker code), duration, and seconds.
30extract_timing_comments <- function(lines) {
31 matches <- regmatches(lines, regexec(timing_comment_pattern, lines))
32 hits <- which(lengths(matches) > 0)
33 if (length(hits) == 0) {
34 return(data.frame(
35 line = integer(), speaker = character(), duration = character(),
36 seconds = numeric()
37 ))
38 }
39 speaker <- trimws(vapply(matches[hits], `[[`, character(1), 2))
40 speaker[speaker == ""] <- NA_character_
41 data.frame(
42 line = hits,
43 speaker = speaker,
44 duration = vapply(matches[hits], `[[`, character(1), 3),
45 seconds = parse_duration_seconds(vapply(matches[hits], `[[`, character(1), 3)),
46 stringsAsFactors = FALSE
47 )
48}
49
50# Assign each timing comment to the slide it belongs to. A comment belongs to
51# the slide whose heading most recently appeared before it; content before the
52# first heading belongs to the title slide.
53assign_slides <- function(lines, comments, slide_level) {
54 headings <- grepl(paste0("^#{1,", slide_level, "}[[:space:]]"), lines)
55 slide_of_line <- cumsum(headings)
56 comments$slide <- slide_of_line[comments$line]
57 titles <- trimws(sub("^#+[[:space:]]+", "", lines[headings]))
58 c("(title)", titles)[comments$slide + 1]
59}
60
61#' Compute per-speaker slide times from timing comments
62#'
63#' Parses an R Markdown presentation for timing comments of the form
64#' `<!-- MK 00:30 -->` (speaker code followed by a duration in `MM:SS`,
65#' `HH:MM:SS`, or plain seconds format) or, for single-speaker talks, simply
66#' `<!-- 00:30 -->`, and computes the time allocated to each slide, each
67#' speaker, and the whole presentation.
68#'
69#' The same convention can be used directly in
70#' [revealjs_presentation()]: any such comment in the source document is
71#' automatically converted into a `data-timing` attribute on the enclosing
72#' slide's `<section>` element, which activates the pacing timer in the
73#' reveal.js speaker view.
74#'
75#' @param input Path to an R Markdown file.
76#' @param slide_level Level of heading that denotes individual slides (should
77#' match the `slide_level` used when rendering).
78#'
79#' @return A list with three elements: `slides` (one row per slide and
80#' speaker), `speakers` (total time per speaker), and `total` (grand total
81#' in seconds). `slide_timing()` is called for its side effect of printing a
82#' summary.
83#'
84#' @examples
85#' \dontrun{
86#' slide_timing("talk.Rmd")
87#' }
88#'
89#' @importFrom stats aggregate
90#' @export
91slide_timing <- function(input, slide_level = 2) {
92 lines <- readLines(input, warn = FALSE)
93 comments <- extract_timing_comments(lines)
94 if (nrow(comments) == 0) {
95 message("No timing comments found in ", input)
96 return(invisible(NULL))
97 }
98 comments$slide_title <- assign_slides(lines, comments, slide_level)
99 comments$speaker <- timing_speaker_label(comments$speaker)
100
101 by_speaker <- aggregate(seconds ~ speaker, comments, FUN = sum)
102 total <- sum(comments$seconds)
103 show_speakers <- any(comments$speaker != "(unnamed)")
104
105 cat("Slide timing for", basename(input), "\n\n")
106 if (show_speakers) {
107 per_slide <- aggregate(seconds ~ slide_title + speaker, comments, FUN = sum)
108 } else {
109 per_slide <- aggregate(seconds ~ slide_title, comments, FUN = sum)
110 }
111 print(data.frame(
112 slide = per_slide$slide_title,
113 speaker = if (show_speakers) per_slide$speaker else NULL,
114 time = format_seconds(per_slide$seconds),
115 stringsAsFactors = FALSE
116 ))
117 cat("\n")
118 named_speakers <- by_speaker[by_speaker$speaker != "(unnamed)", , drop = FALSE]
119 if (nrow(named_speakers) > 0) {
120 print(
121 data.frame(
122 speaker = c(named_speakers$speaker, "total"),
123 time = format_seconds(c(named_speakers$seconds, total)),
124 stringsAsFactors = FALSE
125 ),
126 row.names = FALSE
127 )
128 } else {
129 cat("total time:", format_seconds(total), "\n")
130 }
131
132 invisible(list(slides = comments[, c("slide_title", "speaker", "duration", "seconds")],
133 speakers = by_speaker, total = total))
134}
135
136# Display label for unnamed speakers
137timing_speaker_label <- function(speaker) {
138 ifelse(is.na(speaker), "(unnamed)", speaker)
139}
140
Marc Kupietz6b65c032026-08-25 09:43:28 +0300141# Timing comments and ::: notes blocks written before the first heading do
142# not land on the title slide: pandoc puts them into an otherwise empty
143# phantom slide right after it. Merge such a phantom slide -- consisting of
144# nothing but notes, timing comments, and whitespace -- into the title
145# slide, so that notes and timing can be given for the title as well.
146merge_title_slide_notes <- function(lines) {
147 title_open <- grep('<section class="title-frame"', lines)
148 if (length(title_open) == 0) {
149 return(lines)
150 }
151 title_open <- title_open[1]
152 title_close <- which(lines == "</section>")
153 title_close <- title_close[title_close > title_open][1]
154
155 phantom_open <- grep("^<section", lines)
156 phantom_open <- phantom_open[phantom_open > title_close][1]
157 if (is.na(phantom_open)) {
158 return(lines)
159 }
160 phantom_close <- which(lines == "</section>")
161 phantom_close <- phantom_close[phantom_close > phantom_open][1]
162
163 inner <- lines[(phantom_open + 1):(phantom_close - 1)]
164
165 # the phantom slide is only merged if it has no visible content beyond
166 # notes, timing comments, and whitespace
167 aside <- grep('<aside class="notes">', inner)
168 aside_end <- grep("</aside>", inner)
169 in_aside <- if (length(aside) > 0) {
170 unlist(Map(":", aside, aside_end[aside_end > aside][1]))
171 }
172 content <- seq_along(inner)
173 content <- content[!inner %in% ""]
174 content <- setdiff(content, c(grep("<!--", inner), in_aside))
175 if (length(content) > 0) {
176 return(lines)
177 }
178
179 moved <- inner[nzchar(trimws(inner))]
180 append(
181 lines[-(phantom_open:phantom_close)],
182 moved,
183 after = title_close - 1
184 )
185}
186
Marc Kupietzb066e742026-08-23 09:57:06 +0300187# Convert timing comments into data-timing attributes on the enclosing
188# <section> elements of a rendered reveal.js HTML document. Returns the
189# modified lines invisibly along with the extracted comments as attributes.
190apply_timing_attributes <- function(lines) {
191 comments <- extract_timing_comments(lines)
192 if (nrow(comments) == 0) {
193 return(invisible(structure(lines, comments = comments)))
194 }
195
196 section_lines <- grep("^<section[^>]*>", lines)
197 section_of_comment <- findInterval(comments$line, section_lines)
198 ok <- section_of_comment > 0
199 if (!all(ok)) {
200 warning(
201 "Ignoring ", sum(!ok), " timing comment(s) outside any <section>",
202 call. = FALSE
203 )
204 comments <- comments[ok, , drop = FALSE]
205 section_of_comment <- section_of_comment[ok]
206 }
207
208 per_section <- tapply(comments$seconds, section_of_comment, sum)
209
210 for (idx in names(per_section)) {
211 target <- section_lines[as.integer(idx)]
212 lines[target] <- sub(
213 "^(<section[^>]*?)(/?>)$",
214 sprintf('\\1 data-timing="%d"\\2', round(per_section[[idx]])),
215 lines[target]
216 )
217 }
218
219 invisible(structure(lines, comments = comments))
220}
221
222# Activate the pacing timer of the notes plugin by injecting the total time
223# planned with the timing comments into the reveal.js configuration. Without
224# a totalTime or defaultTiming config value, the speaker view does not show
225# any pacing information at all.
226inject_total_time <- function(lines, seconds) {
227 # only respect explicitly configured pacing values, not e.g. the
228 # unconditional "defaultTiming: null" emitted by the pandoc template
229 pacing <- grep("\\b(totalTime|defaultTiming)\\s*:", lines, value = TRUE)
230 if (any(!grepl(":\\s*(null|0|false|\"\"|'')\\s*,?\\s*$", trimws(pacing)))) {
231 return(lines)
232 }
233 init <- grep("Reveal\\.initialize\\(\\s*\\{", lines)
234 if (length(init) == 0) {
235 return(lines)
236 }
237 append(
238 lines,
239 sprintf("\t\t\ttotalTime: %d,", round(seconds)),
240 after = init[[1]]
241 )
242}
243
244timing_report_message <- function(comments) {
245 comments$speaker <- timing_speaker_label(comments$speaker)
246 by_speaker <- aggregate(seconds ~ speaker, comments, FUN = sum)
247 total <- sum(comments$seconds)
248 named <- by_speaker[by_speaker$speaker != "(unnamed)", , drop = FALSE]
249 unnamed <- by_speaker[by_speaker$speaker == "(unnamed)", , drop = FALSE]
250 parts <- c(
251 # note: paste0() would treat a zero-length data frame column as "",
252 # so only build these strings when there is something to report
253 if (nrow(named) > 0) paste0(named$speaker, ": ", format_seconds(named$seconds)),
254 if (nrow(unnamed) > 1 || (nrow(unnamed) == 1 && nrow(named) > 0)) {
255 paste0("(unnamed): ", format_seconds(unnamed$seconds))
256 }
257 )
258 if (length(parts) > 0) {
259 sprintf(
260 "Slide timing -- %s -- total %s",
261 paste(parts, collapse = ", "),
262 format_seconds(total)
263 )
264 } else {
265 sprintf("Slide timing -- total %s", format_seconds(total))
266 }
267}