// slides.mbt - Generate slide images using Marp CLI and pdftoppm
///|
/// Generate slide images from markdown file
/// Step 1: Use Marp CLI to generate PDF
/// Step 2: Use pdftoppm to convert PDF to PNG images
pub async fn generate_slides(
input_file : String,
output_dir : String,
options : Options,
) -> Bool {
println("Generating slides...")
let slides_dir = slides_dir(output_dir)
create_dir_all(slides_dir)
// Get PDF filename (same as input but .pdf extension)
let pdf_file = get_pdf_filename(input_file)
// Step 1: Generate PDF using Marp CLI
println(" Generating PDF...")
if !generate_pdf(input_file, pdf_file, options) {
return false
}
// Step 2: Convert PDF to PNG images using pdftoppm
println(" Converting PDF to PNG images...")
if !convert_pdf_to_png(pdf_file, slides_dir) {
return false
}
// Count generated slides
let slide_files = list_slide_files(slides_dir)
println(" [+] Generated \{slide_files.length()} slide images")
// Show first few filenames
let show_count = if slide_files.length() < 3 {
slide_files.length()
} else {
3
}
for i = 0; i < show_count; i = i + 1 {
println(" - \{slide_files[i]}")
}
if slide_files.length() > 3 {
println(" ... and \{slide_files.length() - 3} more")
}
true
}
///|
/// Generate PDF from markdown using Marp CLI
async fn generate_pdf(
input_file : String,
pdf_file : String,
options : Options,
) -> Bool {
// Get absolute paths for input and output
let abs_input = get_absolute_path(input_file)
let abs_pdf = get_absolute_path(pdf_file)
// Use local marp-cli if available (from ~/.mdcourse/node_modules)
// Otherwise fall back to global npx
let resource_dir = get_resource_dir()
let use_local_marp = resource_dir != "." &&
resource_dir.contains("/.mdcourse")
let args : Array[String] = if use_local_marp {
// Use local installation with absolute paths to engine and theme
[
resource_dir + "/node_modules/.bin/marp",
"--theme",
options.theme, // Already absolute from types.mbt
"--engine",
options.engine, // Already absolute from types.mbt
abs_input,
"--pdf",
"--allow-local-files",
"--html",
"-o",
abs_pdf,
]
} else {
// Use global npx (development mode)
[
"@marp-team/marp-cli",
"--theme",
options.theme,
"--engine",
options.engine,
abs_input,
"--pdf",
"--allow-local-files",
"--html",
"-o",
abs_pdf,
]
}
let cmd = if use_local_marp { args[0] } else { "npx" }
let cmd_args : Array[String] = if use_local_marp {
// Skip the first element (the command itself) and use the rest as args
let result : Array[String] = []
for i = 1; i < args.length(); i = i + 1 {
result.push(args[i])
}
result
} else {
args
}
let (exit_code, _stdout, stderr) = @process.collect_output(cmd, cmd_args)
if exit_code != 0 {
println(" [ERROR] Marp CLI failed with exit code \{exit_code}")
let error_text = stderr.text()
if !error_text.is_empty() {
println(" stderr: \{error_text}")
}
return false
}
println(" [+] PDF saved: \{pdf_file}")
true
}
///|
/// Get absolute path from relative or absolute path
async fn get_absolute_path(path : String) -> String {
if path.has_prefix("/") {
// Already absolute
path
} else {
// Get current working directory and join with path
let cwd = @process.collect_output("pwd", [])
match cwd {
(0, stdout, _) => {
let dir = stdout.text().trim(char_set=" \t\n").to_owned()
dir + "/" + path
}
_ => path // Fallback to relative
}
}
}
///|
/// Convert PDF to PNG images using pdftoppm
async fn convert_pdf_to_png(pdf_file : String, slides_dir : String) -> Bool {
// Remove all existing PNG files first to avoid conflicts
let entries = @fs.readdir(slides_dir) catch { _ => [] }
for entry in entries {
if entry.has_suffix(".png") {
let file_path = path_join([slides_dir, entry])
@fs.remove(file_path)
}
}
let args : Array[String] = [
"-png",
"-r",
"300", // 300 DPI resolution
pdf_file,
slides_dir + "/slide",
]
let (exit_code, _stdout, stderr) = @process.collect_output("pdftoppm", args)
if exit_code != 0 {
println(" [ERROR] pdftoppm failed with exit code \{exit_code}")
let error_text = stderr.text()
if !error_text.is_empty() {
println(" stderr: \{error_text}")
}
return false
}
// Rename slides from pdftoppm format (slide-1.png, slide-2.png) to standard format (slide-001.png, slide-002.png)
rename_slides(slides_dir)
true
}
///|
/// Get PDF filename from input markdown file
/// Example: "../course7/lec7-1.mbt.md" -> "../course7/lec7-1.pdf"
fn get_pdf_filename(input_file : String) -> String {
// Remove .mbt.md or .md extension
let mut result = input_file[:]
if result.has_suffix(".mbt.md") {
let len = result.length()
result = result[0:len - 7]
} else if result.has_suffix(".md") {
let len = result.length()
result = result[0:len - 3]
}
result.to_owned() + ".pdf"
}
///|
/// List all PNG files in slides directory
async fn list_slide_files(slides_dir : String) -> Array[String] {
let files : Array[String] = []
// Try to read directory, return empty array if it doesn't exist
let dir_exists = @fs.exists(slides_dir) catch { _ => false }
if !dir_exists {
return files
}
let entries = @fs.readdir(slides_dir) catch { _ => [] }
for entry in entries {
if entry.has_suffix(".png") {
files.push(entry)
}
}
// Sort files naturally
files.sort()
files
}
///|
/// Rename slides from pdftoppm format to standard format
/// pdftoppm generates: slide-1.png, slide-2.png, ..., slide-10.png (no leading zeros)
/// We want: slide-001.png, slide-002.png, ..., slide-010.png (3-digit padding)
async fn rename_slides(slides_dir : String) -> Unit {
let new_entries : Array[SlideFile] = []
// Read directory and find pdftoppm-generated files
let entries = @fs.readdir(slides_dir) catch { _ => [] }
for entry in entries {
if !entry.has_suffix(".png") {
continue
}
// Match pdftoppm format: slide-1.png, slide-2.png (1-2 digits, no leading zeros)
// Exclude already-renamed files: slide-001.png (3 digits)
// Extract the number from the filename
let parts : Array[String] = []
for view in entry.split("-") {
parts.push(view.to_owned())
}
if parts.length() == 2 && parts[0] == "slide" {
let num_part = parts[1]
if num_part.has_suffix(".png") {
let num_str = num_part[0:num_part.length() - 4].to_owned()
// Only accept 1-2 digit numbers (pdftoppm format)
// Skip 3-digit numbers (already renamed files)
if num_str.length() > 2 {
continue
}
let num = @string.parse_int(num_str) catch { _ => continue }
new_entries.push({ name: entry, num, })
}
}
}
if new_entries.length() == 0 {
println(" [WARN] No pdftoppm-generated slide images found")
return
}
// Sort by number
new_entries.sort_by(fn(a, b) { a.num - b.num })
// Rename each file to standard format
for i, slide_file in new_entries {
let old_path = path_join([slides_dir, slide_file.name])
let slide_id = pad_slide_id(i + 1)
let new_name = "slide-\{slide_id}.png"
let new_path = path_join([slides_dir, new_name])
// Rename the file using mv command
let (exit_code, _, _) = @process.collect_output("mv", [old_path, new_path])
if exit_code != 0 {
println(" [WARN] Failed to rename \{slide_file.name} to \{new_name}")
}
}
println(
" [+] Renamed \{new_entries.length()} slide images to standard format",
)
}
///|
priv struct SlideFile {
name : String
num : Int
}