| Marc Kupietz | b066e74 | 2026-08-23 09:57:06 +0300 | [diff] [blame] | 1 | # Pattern matching timing comments, e.g. <!-- MK 00:30 --> or just <!-- 00:30 --> |
| 2 | timing_comment_pattern <- paste0( |
| 3 | "<!--[[:space:]]*(?:([[:alnum:]._-]+)[[:space:]]+)?", |
| 4 | "([0-9]+(?::[0-9]{2}){0,2})[[:space:]]*-->" |
| 5 | ) |
| 6 | |
| 7 | parse_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 | |
| 19 | format_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. |
| 30 | extract_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. |
| 53 | assign_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 |
| 91 | slide_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 |
| 137 | timing_speaker_label <- function(speaker) { |
| 138 | ifelse(is.na(speaker), "(unnamed)", speaker) |
| 139 | } |
| 140 | |
| 141 | # Convert timing comments into data-timing attributes on the enclosing |
| 142 | # <section> elements of a rendered reveal.js HTML document. Returns the |
| 143 | # modified lines invisibly along with the extracted comments as attributes. |
| 144 | apply_timing_attributes <- function(lines) { |
| 145 | comments <- extract_timing_comments(lines) |
| 146 | if (nrow(comments) == 0) { |
| 147 | return(invisible(structure(lines, comments = comments))) |
| 148 | } |
| 149 | |
| 150 | section_lines <- grep("^<section[^>]*>", lines) |
| 151 | section_of_comment <- findInterval(comments$line, section_lines) |
| 152 | ok <- section_of_comment > 0 |
| 153 | if (!all(ok)) { |
| 154 | warning( |
| 155 | "Ignoring ", sum(!ok), " timing comment(s) outside any <section>", |
| 156 | call. = FALSE |
| 157 | ) |
| 158 | comments <- comments[ok, , drop = FALSE] |
| 159 | section_of_comment <- section_of_comment[ok] |
| 160 | } |
| 161 | |
| 162 | per_section <- tapply(comments$seconds, section_of_comment, sum) |
| 163 | |
| 164 | for (idx in names(per_section)) { |
| 165 | target <- section_lines[as.integer(idx)] |
| 166 | lines[target] <- sub( |
| 167 | "^(<section[^>]*?)(/?>)$", |
| 168 | sprintf('\\1 data-timing="%d"\\2', round(per_section[[idx]])), |
| 169 | lines[target] |
| 170 | ) |
| 171 | } |
| 172 | |
| 173 | invisible(structure(lines, comments = comments)) |
| 174 | } |
| 175 | |
| 176 | # Activate the pacing timer of the notes plugin by injecting the total time |
| 177 | # planned with the timing comments into the reveal.js configuration. Without |
| 178 | # a totalTime or defaultTiming config value, the speaker view does not show |
| 179 | # any pacing information at all. |
| 180 | inject_total_time <- function(lines, seconds) { |
| 181 | # only respect explicitly configured pacing values, not e.g. the |
| 182 | # unconditional "defaultTiming: null" emitted by the pandoc template |
| 183 | pacing <- grep("\\b(totalTime|defaultTiming)\\s*:", lines, value = TRUE) |
| 184 | if (any(!grepl(":\\s*(null|0|false|\"\"|'')\\s*,?\\s*$", trimws(pacing)))) { |
| 185 | return(lines) |
| 186 | } |
| 187 | init <- grep("Reveal\\.initialize\\(\\s*\\{", lines) |
| 188 | if (length(init) == 0) { |
| 189 | return(lines) |
| 190 | } |
| 191 | append( |
| 192 | lines, |
| 193 | sprintf("\t\t\ttotalTime: %d,", round(seconds)), |
| 194 | after = init[[1]] |
| 195 | ) |
| 196 | } |
| 197 | |
| 198 | timing_report_message <- function(comments) { |
| 199 | comments$speaker <- timing_speaker_label(comments$speaker) |
| 200 | by_speaker <- aggregate(seconds ~ speaker, comments, FUN = sum) |
| 201 | total <- sum(comments$seconds) |
| 202 | named <- by_speaker[by_speaker$speaker != "(unnamed)", , drop = FALSE] |
| 203 | unnamed <- by_speaker[by_speaker$speaker == "(unnamed)", , drop = FALSE] |
| 204 | parts <- c( |
| 205 | # note: paste0() would treat a zero-length data frame column as "", |
| 206 | # so only build these strings when there is something to report |
| 207 | if (nrow(named) > 0) paste0(named$speaker, ": ", format_seconds(named$seconds)), |
| 208 | if (nrow(unnamed) > 1 || (nrow(unnamed) == 1 && nrow(named) > 0)) { |
| 209 | paste0("(unnamed): ", format_seconds(unnamed$seconds)) |
| 210 | } |
| 211 | ) |
| 212 | if (length(parts) > 0) { |
| 213 | sprintf( |
| 214 | "Slide timing -- %s -- total %s", |
| 215 | paste(parts, collapse = ", "), |
| 216 | format_seconds(total) |
| 217 | ) |
| 218 | } else { |
| 219 | sprintf("Slide timing -- total %s", format_seconds(total)) |
| 220 | } |
| 221 | } |