//! albedo-render: a highlighted range of a source file as PNG pages. //! //! albedo-render FILE --start N --end N --out DIR [--columns 79] //! [--tab-width 4] [--language NAME] [--max-rows 80] [--max-images 4] //! [--no-line-numbers] //! //! Writes DIR/view-1.png onward and prints one line per fact, for the caller //! to parse from text output: //! //! language rust (or: language plain [reason]) //! image DIR/view-1.png FIRST LAST WIDTHxHEIGHT //! remaining FIRST LAST (lines left for another call, if any) //! //! A line split across pages appears in both. Errors go to stderr, exit 1. //! //! albedo-render --snapcompact[-dir] PATH --out DIR --advance N --pitch N //! --width N: dense pixel-font frames, one `image PATH WxH` stdout line //! per frame. Unknown glyphs render as '?'. //! //! albedo-render --fit EDGE IMAGE --out DIR: IMAGE (PNG, JPEG, or WebP) //! scaled so neither edge passes EDGE, written to DIR/fit.png (or //! fit.jpg for a JPEG), with one `image PATH WxH` stdout line. mod fit; mod grid; mod highlight; mod layout; mod raster; use highlight::{Colors, Language}; use std::path::PathBuf; use std::process::ExitCode; const MAX_FILE_BYTES: u64 = 8 * 1024 * 1024; struct Request { file: PathBuf, start: usize, end: usize, out: PathBuf, columns: usize, tab_width: usize, language: Option, max_rows: usize, max_images: usize, line_numbers: bool, snapcompact: bool, snapcompact_dir: bool, advance: usize, pitch: usize, frame_width: usize, fit: Option, } fn main() -> ExitCode { let parsed = parse(std::env::args().skip(1)); match parsed.and_then(|request| { if let Some(edge) = request.fit { run_fit(&request, edge) } else if request.snapcompact && request.snapcompact_dir { run_snapcompact_dir(&request) } else if request.snapcompact { run_snapcompact(&request) } else { run(&request) } }) { Ok(report) => { print!("{report}"); ExitCode::SUCCESS } Err(message) => { eprintln!("albedo-render: {message}"); ExitCode::FAILURE } } } fn parse(mut args: impl Iterator) -> Result { let mut file = None; let mut request = Request { file: PathBuf::new(), start: 0, end: 0, out: PathBuf::new(), columns: 79, tab_width: 4, language: None, max_rows: 80, max_images: 4, line_numbers: true, snapcompact: false, snapcompact_dir: false, advance: 11, pitch: 16, frame_width: 1568, fit: None, }; let mut out = None; while let Some(arg) = args.next() { let mut value = || args.next().ok_or(format!("{arg} needs a value")); let mut number = |low: usize, high: usize| -> Result { let text = value()?; match text.parse::() { Ok(n) if (low..=high).contains(&n) => Ok(n), _ => Err(format!( "{arg} must be an integer from {low} to {high}, not {text:?}" )), } }; match arg.as_str() { "--start" => request.start = number(1, usize::MAX)?, "--end" => request.end = number(1, usize::MAX)?, "--columns" => request.columns = number(20, 400)?, "--tab-width" => request.tab_width = number(1, 16)?, "--max-rows" => request.max_rows = number(1, 1000)?, "--max-images" => request.max_images = number(1, 64)?, "--language" => request.language = Some(value()?), "--no-line-numbers" => request.line_numbers = false, "--snapcompact" => request.snapcompact = true, "--snapcompact-dir" => { request.snapcompact = true; request.snapcompact_dir = true; } "--advance" => request.advance = number(6, 24)?, "--pitch" => request.pitch = number(10, 64)?, "--width" => request.frame_width = number(256, 4096)?, "--fit" => request.fit = Some(number(16, 16384)? as u32), "--out" => out = Some(PathBuf::from(value()?)), flag if flag.starts_with("--") => return Err(format!("unknown option {flag}")), _ if file.is_none() => file = Some(PathBuf::from(arg)), _ => return Err(format!("unexpected argument {arg:?}")), } } request.file = file.ok_or("a file to render is required")?; request.out = out.ok_or("--out DIR is required")?; if request.fit.is_some() || request.snapcompact { if !request.snapcompact_dir && !request.file.is_file() { return Err(format!("{}: not a file", request.file.display())); } return Ok(request); } if request.start == 0 || request.end == 0 { return Err("--start and --end are required".into()); } if request.end < request.start { return Err(format!( "--end {} is before --start {}", request.end, request.start )); } Ok(request) } /// One subprocess for all frames: the bundled BDF is parsed once and each /// NN.txt in the input dir renders, in name order, to snap-N.png. fn run_snapcompact_dir(request: &Request) -> Result { if !request.file.is_dir() { return Err(format!("{}: not a directory", request.file.display())); } let mut names: Vec = std::fs::read_dir(&request.file) .map_err(|e| format!("{}: {e}", request.file.display()))? .filter_map(|entry| entry.ok().map(|e| e.path())) .filter(|path| path.extension().is_some_and(|e| e == "txt")) .collect(); names.sort(); if names.is_empty() { return Err(format!("{}: no .txt chunks", request.file.display())); } let mut report = String::new(); for (index, path) in names.iter().enumerate() { let text = std::fs::read_to_string(path).map_err(|e| format!("{}: {e}", path.display()))?; let frame = grid::render(&text, request.advance, request.pitch, request.frame_width)?; let out = request.out.join(format!("snap-{}.png", index + 1)); std::fs::write(&out, &frame.png).map_err(|e| format!("{}: {e}", out.display()))?; report.push_str(&format!( "image {} {}x{}\n", out.display(), frame.width, frame.height )); } Ok(report) } fn run_fit(request: &Request, edge: u32) -> Result { let bytes = std::fs::read(&request.file).map_err(|e| format!("{}: {e}", request.file.display()))?; let fitted = fit::fit(&bytes, edge)?; let path = request.out.join(format!("fit.{}", fitted.extension)); std::fs::write(&path, &fitted.bytes).map_err(|e| format!("{}: {e}", path.display()))?; Ok(format!( "image {} {}x{}\n", path.display(), fitted.width, fitted.height )) } fn run_snapcompact(request: &Request) -> Result { let text = std::fs::read_to_string(&request.file) .map_err(|e| format!("{}: {e}", request.file.display()))?; let frame = grid::render(&text, request.advance, request.pitch, request.frame_width)?; let path = request.out.join("snap-1.png"); std::fs::write(&path, &frame.png).map_err(|e| format!("{}: {e}", path.display()))?; Ok(format!( "image {} {}x{}\n", path.display(), frame.width, frame.height )) } fn run(request: &Request) -> Result { let shown = request.file.display(); let size = std::fs::metadata(&request.file) .map_err(|e| format!("{shown}: {e}"))? .len(); if size > MAX_FILE_BYTES { return Err(format!( "{shown} is {size} bytes; at most {MAX_FILE_BYTES} can be rendered" )); } let bytes = std::fs::read(&request.file).map_err(|e| format!("{shown}: {e}"))?; if bytes[..bytes.len().min(8192)].contains(&0) { return Err(format!("{shown} looks binary (it contains NUL bytes)")); } let source = String::from_utf8_lossy(&bytes); let lines = layout::lines(&source); if request.start > lines.len() { return Err(format!( "{shown} has {} lines; --start {} is past the end", lines.len(), request.start )); } let end = request.end.min(lines.len()); let language = request .language .clone() .or_else(|| arborium::detect_language(&request.file.to_string_lossy()).map(String::from)); let colors = Colors::new( &source, language.as_deref(), &arborium_theme::theme::builtin::github_light(), ); let selected = &lines[request.start - 1..end]; let rows = layout::rows( selected, request.start, &colors, request.columns, request.tab_width, ); let pages = layout::pages(&rows, request.max_rows); let mut report = match &colors.language { Language::Highlighted(name) => format!("language {name}\n"), Language::Unsupported(name) => format!("language plain no grammar for {name}\n"), Language::Failed(message) => format!("language plain {message}\n"), Language::Unknown => "language plain unknown file type\n".to_string(), }; let mut painter = raster::Painter::new(); let gutter = match request.line_numbers { true => end.to_string().len(), false => 1, }; for (index, range) in pages.iter().take(request.max_images).enumerate() { let page = &rows[range.clone()]; let path = request.out.join(format!("view-{}.png", index + 1)); let png = painter.png(&raster::Page { rows: page, gutter, numbers: request.line_numbers, columns: request.columns, foreground: colors.foreground, background: colors.background, }); std::fs::write(&path, &png).map_err(|e| format!("{}: {e}", path.display()))?; let (width, height) = dimensions(&png); let first = page.first().map_or(request.start, |row| row.line); let last = page.last().map_or(end, |row| row.line); report += &format!("image {} {first} {last} {width}x{height}\n", path.display()); } if let Some(rest) = pages.get(request.max_images) { report += &format!("remaining {} {end}\n", rows[rest.start].line); } Ok(report) } /// Width and height from the IHDR chunk the encoder just wrote. fn dimensions(png: &[u8]) -> (u32, u32) { let field = |at: usize| u32::from_be_bytes(png[at..at + 4].try_into().unwrap()); (field(16), field(20)) }