blob: 560716d48e6b16551ade6605e03763cd799a94ca [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
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.
144apply_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.
180inject_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
198timing_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}