blob: 560716d48e6b16551ade6605e03763cd799a94ca [file] [log] [blame]
# Pattern matching timing comments, e.g. <!-- MK 00:30 --> or just <!-- 00:30 -->
timing_comment_pattern <- paste0(
"<!--[[:space:]]*(?:([[:alnum:]._-]+)[[:space:]]+)?",
"([0-9]+(?::[0-9]{2}){0,2})[[:space:]]*-->"
)
parse_duration_seconds <- function(x) {
vapply(x, function(t) {
parts <- strsplit(t, ":", fixed = TRUE)[[1]]
parts <- suppressWarnings(as.numeric(parts))
if (length(parts) == 0 || length(parts) > 3 || anyNA(parts)) {
return(NA_real_)
}
multipliers <- c(3600, 60, 1)[(4 - length(parts)):3]
sum(parts * multipliers)
}, numeric(1), USE.NAMES = FALSE)
}
format_seconds <- function(x) {
x <- round(x)
h <- floor(x / 3600)
m <- floor((x %% 3600) / 60)
s <- x %% 60
ifelse(h > 0, sprintf("%d:%02d:%02d", h, m, s), sprintf("%d:%02d", m, s))
}
# Extract all timing comments from a character vector (one line per element).
# Returns a data frame with columns line (1-based index), speaker (NA for
# comments without a speaker code), duration, and seconds.
extract_timing_comments <- function(lines) {
matches <- regmatches(lines, regexec(timing_comment_pattern, lines))
hits <- which(lengths(matches) > 0)
if (length(hits) == 0) {
return(data.frame(
line = integer(), speaker = character(), duration = character(),
seconds = numeric()
))
}
speaker <- trimws(vapply(matches[hits], `[[`, character(1), 2))
speaker[speaker == ""] <- NA_character_
data.frame(
line = hits,
speaker = speaker,
duration = vapply(matches[hits], `[[`, character(1), 3),
seconds = parse_duration_seconds(vapply(matches[hits], `[[`, character(1), 3)),
stringsAsFactors = FALSE
)
}
# Assign each timing comment to the slide it belongs to. A comment belongs to
# the slide whose heading most recently appeared before it; content before the
# first heading belongs to the title slide.
assign_slides <- function(lines, comments, slide_level) {
headings <- grepl(paste0("^#{1,", slide_level, "}[[:space:]]"), lines)
slide_of_line <- cumsum(headings)
comments$slide <- slide_of_line[comments$line]
titles <- trimws(sub("^#+[[:space:]]+", "", lines[headings]))
c("(title)", titles)[comments$slide + 1]
}
#' Compute per-speaker slide times from timing comments
#'
#' Parses an R Markdown presentation for timing comments of the form
#' `<!-- MK 00:30 -->` (speaker code followed by a duration in `MM:SS`,
#' `HH:MM:SS`, or plain seconds format) or, for single-speaker talks, simply
#' `<!-- 00:30 -->`, and computes the time allocated to each slide, each
#' speaker, and the whole presentation.
#'
#' The same convention can be used directly in
#' [revealjs_presentation()]: any such comment in the source document is
#' automatically converted into a `data-timing` attribute on the enclosing
#' slide's `<section>` element, which activates the pacing timer in the
#' reveal.js speaker view.
#'
#' @param input Path to an R Markdown file.
#' @param slide_level Level of heading that denotes individual slides (should
#' match the `slide_level` used when rendering).
#'
#' @return A list with three elements: `slides` (one row per slide and
#' speaker), `speakers` (total time per speaker), and `total` (grand total
#' in seconds). `slide_timing()` is called for its side effect of printing a
#' summary.
#'
#' @examples
#' \dontrun{
#' slide_timing("talk.Rmd")
#' }
#'
#' @importFrom stats aggregate
#' @export
slide_timing <- function(input, slide_level = 2) {
lines <- readLines(input, warn = FALSE)
comments <- extract_timing_comments(lines)
if (nrow(comments) == 0) {
message("No timing comments found in ", input)
return(invisible(NULL))
}
comments$slide_title <- assign_slides(lines, comments, slide_level)
comments$speaker <- timing_speaker_label(comments$speaker)
by_speaker <- aggregate(seconds ~ speaker, comments, FUN = sum)
total <- sum(comments$seconds)
show_speakers <- any(comments$speaker != "(unnamed)")
cat("Slide timing for", basename(input), "\n\n")
if (show_speakers) {
per_slide <- aggregate(seconds ~ slide_title + speaker, comments, FUN = sum)
} else {
per_slide <- aggregate(seconds ~ slide_title, comments, FUN = sum)
}
print(data.frame(
slide = per_slide$slide_title,
speaker = if (show_speakers) per_slide$speaker else NULL,
time = format_seconds(per_slide$seconds),
stringsAsFactors = FALSE
))
cat("\n")
named_speakers <- by_speaker[by_speaker$speaker != "(unnamed)", , drop = FALSE]
if (nrow(named_speakers) > 0) {
print(
data.frame(
speaker = c(named_speakers$speaker, "total"),
time = format_seconds(c(named_speakers$seconds, total)),
stringsAsFactors = FALSE
),
row.names = FALSE
)
} else {
cat("total time:", format_seconds(total), "\n")
}
invisible(list(slides = comments[, c("slide_title", "speaker", "duration", "seconds")],
speakers = by_speaker, total = total))
}
# Display label for unnamed speakers
timing_speaker_label <- function(speaker) {
ifelse(is.na(speaker), "(unnamed)", speaker)
}
# Convert timing comments into data-timing attributes on the enclosing
# <section> elements of a rendered reveal.js HTML document. Returns the
# modified lines invisibly along with the extracted comments as attributes.
apply_timing_attributes <- function(lines) {
comments <- extract_timing_comments(lines)
if (nrow(comments) == 0) {
return(invisible(structure(lines, comments = comments)))
}
section_lines <- grep("^<section[^>]*>", lines)
section_of_comment <- findInterval(comments$line, section_lines)
ok <- section_of_comment > 0
if (!all(ok)) {
warning(
"Ignoring ", sum(!ok), " timing comment(s) outside any <section>",
call. = FALSE
)
comments <- comments[ok, , drop = FALSE]
section_of_comment <- section_of_comment[ok]
}
per_section <- tapply(comments$seconds, section_of_comment, sum)
for (idx in names(per_section)) {
target <- section_lines[as.integer(idx)]
lines[target] <- sub(
"^(<section[^>]*?)(/?>)$",
sprintf('\\1 data-timing="%d"\\2', round(per_section[[idx]])),
lines[target]
)
}
invisible(structure(lines, comments = comments))
}
# Activate the pacing timer of the notes plugin by injecting the total time
# planned with the timing comments into the reveal.js configuration. Without
# a totalTime or defaultTiming config value, the speaker view does not show
# any pacing information at all.
inject_total_time <- function(lines, seconds) {
# only respect explicitly configured pacing values, not e.g. the
# unconditional "defaultTiming: null" emitted by the pandoc template
pacing <- grep("\\b(totalTime|defaultTiming)\\s*:", lines, value = TRUE)
if (any(!grepl(":\\s*(null|0|false|\"\"|'')\\s*,?\\s*$", trimws(pacing)))) {
return(lines)
}
init <- grep("Reveal\\.initialize\\(\\s*\\{", lines)
if (length(init) == 0) {
return(lines)
}
append(
lines,
sprintf("\t\t\ttotalTime: %d,", round(seconds)),
after = init[[1]]
)
}
timing_report_message <- function(comments) {
comments$speaker <- timing_speaker_label(comments$speaker)
by_speaker <- aggregate(seconds ~ speaker, comments, FUN = sum)
total <- sum(comments$seconds)
named <- by_speaker[by_speaker$speaker != "(unnamed)", , drop = FALSE]
unnamed <- by_speaker[by_speaker$speaker == "(unnamed)", , drop = FALSE]
parts <- c(
# note: paste0() would treat a zero-length data frame column as "",
# so only build these strings when there is something to report
if (nrow(named) > 0) paste0(named$speaker, ": ", format_seconds(named$seconds)),
if (nrow(unnamed) > 1 || (nrow(unnamed) == 1 && nrow(named) > 0)) {
paste0("(unnamed): ", format_seconds(unnamed$seconds))
}
)
if (length(parts) > 0) {
sprintf(
"Slide timing -- %s -- total %s",
paste(parts, collapse = ", "),
format_seconds(total)
)
} else {
sprintf("Slide timing -- total %s", format_seconds(total))
}
}