okami.mom/albedo

git clone https://git.nkp.pet/okami.mom/albedo

dawnrequests: fit history images over the provider's edge by appending image fit rows to the transcriptb2e29fd

main
10.6 KiB296 linesraw
1//! albedo-render: a highlighted range of a source file as PNG pages.
2//!
3//! albedo-render FILE --start N --end N --out DIR [--columns 79]
4//!     [--tab-width 4] [--language NAME] [--max-rows 80] [--max-images 4]
5//!     [--no-line-numbers]
6//!
7//! Writes DIR/view-1.png onward and prints one line per fact, for the caller
8//! to parse from text output:
9//!
10//!   language rust            (or: language plain [reason])
11//!   image DIR/view-1.png FIRST LAST WIDTHxHEIGHT
12//!   remaining FIRST LAST     (lines left for another call, if any)
13//!
14//! A line split across pages appears in both. Errors go to stderr, exit 1.
15//!
16//! albedo-render --snapcompact[-dir] PATH --out DIR --advance N --pitch N
17//!     --width N: dense pixel-font frames, one `image PATH WxH` stdout line
18//!     per frame. Unknown glyphs render as '?'.
19//!
20//! albedo-render --fit EDGE IMAGE --out DIR: IMAGE (PNG, JPEG, or WebP)
21//!     scaled so neither edge passes EDGE, written to DIR/fit.png (or
22//!     fit.jpg for a JPEG), with one `image PATH WxH` stdout line.
23
24mod fit;
25mod grid;
26mod highlight;
27mod layout;
28mod raster;
29
30use highlight::{Colors, Language};
31use std::path::PathBuf;
32use std::process::ExitCode;
33
34const MAX_FILE_BYTES: u64 = 8 * 1024 * 1024;
35
36struct Request {
37    file: PathBuf,
38    start: usize,
39    end: usize,
40    out: PathBuf,
41    columns: usize,
42    tab_width: usize,
43    language: Option<String>,
44    max_rows: usize,
45    max_images: usize,
46    line_numbers: bool,
47    snapcompact: bool,
48    snapcompact_dir: bool,
49    advance: usize,
50    pitch: usize,
51    frame_width: usize,
52    fit: Option<u32>,
53}
54
55fn main() -> ExitCode {
56    let parsed = parse(std::env::args().skip(1));
57    match parsed.and_then(|request| {
58        if let Some(edge) = request.fit {
59            run_fit(&request, edge)
60        } else if request.snapcompact && request.snapcompact_dir {
61            run_snapcompact_dir(&request)
62        } else if request.snapcompact {
63            run_snapcompact(&request)
64        } else {
65            run(&request)
66        }
67    }) {
68        Ok(report) => {
69            print!("{report}");
70            ExitCode::SUCCESS
71        }
72        Err(message) => {
73            eprintln!("albedo-render: {message}");
74            ExitCode::FAILURE
75        }
76    }
77}
78
79fn parse(mut args: impl Iterator<Item = String>) -> Result<Request, String> {
80    let mut file = None;
81    let mut request = Request {
82        file: PathBuf::new(),
83        start: 0,
84        end: 0,
85        out: PathBuf::new(),
86        columns: 79,
87        tab_width: 4,
88        language: None,
89        max_rows: 80,
90        max_images: 4,
91        line_numbers: true,
92        snapcompact: false,
93        snapcompact_dir: false,
94        advance: 11,
95        pitch: 16,
96        frame_width: 1568,
97        fit: None,
98    };
99    let mut out = None;
100    while let Some(arg) = args.next() {
101        let mut value = || args.next().ok_or(format!("{arg} needs a value"));
102        let mut number = |low: usize, high: usize| -> Result<usize, String> {
103            let text = value()?;
104            match text.parse::<usize>() {
105                Ok(n) if (low..=high).contains(&n) => Ok(n),
106                _ => Err(format!(
107                    "{arg} must be an integer from {low} to {high}, not {text:?}"
108                )),
109            }
110        };
111        match arg.as_str() {
112            "--start" => request.start = number(1, usize::MAX)?,
113            "--end" => request.end = number(1, usize::MAX)?,
114            "--columns" => request.columns = number(20, 400)?,
115            "--tab-width" => request.tab_width = number(1, 16)?,
116            "--max-rows" => request.max_rows = number(1, 1000)?,
117            "--max-images" => request.max_images = number(1, 64)?,
118            "--language" => request.language = Some(value()?),
119            "--no-line-numbers" => request.line_numbers = false,
120            "--snapcompact" => request.snapcompact = true,
121            "--snapcompact-dir" => {
122                request.snapcompact = true;
123                request.snapcompact_dir = true;
124            }
125            "--advance" => request.advance = number(6, 24)?,
126            "--pitch" => request.pitch = number(10, 64)?,
127            "--width" => request.frame_width = number(256, 4096)?,
128            "--fit" => request.fit = Some(number(16, 16384)? as u32),
129            "--out" => out = Some(PathBuf::from(value()?)),
130            flag if flag.starts_with("--") => return Err(format!("unknown option {flag}")),
131            _ if file.is_none() => file = Some(PathBuf::from(arg)),
132            _ => return Err(format!("unexpected argument {arg:?}")),
133        }
134    }
135    request.file = file.ok_or("a file to render is required")?;
136    request.out = out.ok_or("--out DIR is required")?;
137    if request.fit.is_some() || request.snapcompact {
138        if !request.snapcompact_dir && !request.file.is_file() {
139            return Err(format!("{}: not a file", request.file.display()));
140        }
141        return Ok(request);
142    }
143    if request.start == 0 || request.end == 0 {
144        return Err("--start and --end are required".into());
145    }
146    if request.end < request.start {
147        return Err(format!(
148            "--end {} is before --start {}",
149            request.end, request.start
150        ));
151    }
152    Ok(request)
153}
154
155/// One subprocess for all frames: the bundled BDF is parsed once and each
156/// NN.txt in the input dir renders, in name order, to snap-N.png.
157fn run_snapcompact_dir(request: &Request) -> Result<String, String> {
158    if !request.file.is_dir() {
159        return Err(format!("{}: not a directory", request.file.display()));
160    }
161    let mut names: Vec<PathBuf> = std::fs::read_dir(&request.file)
162        .map_err(|e| format!("{}: {e}", request.file.display()))?
163        .filter_map(|entry| entry.ok().map(|e| e.path()))
164        .filter(|path| path.extension().is_some_and(|e| e == "txt"))
165        .collect();
166    names.sort();
167    if names.is_empty() {
168        return Err(format!("{}: no .txt chunks", request.file.display()));
169    }
170    let mut report = String::new();
171    for (index, path) in names.iter().enumerate() {
172        let text = std::fs::read_to_string(path).map_err(|e| format!("{}: {e}", path.display()))?;
173        let frame = grid::render(&text, request.advance, request.pitch, request.frame_width)?;
174        let out = request.out.join(format!("snap-{}.png", index + 1));
175        std::fs::write(&out, &frame.png).map_err(|e| format!("{}: {e}", out.display()))?;
176        report.push_str(&format!(
177            "image {} {}x{}\n",
178            out.display(),
179            frame.width,
180            frame.height
181        ));
182    }
183    Ok(report)
184}
185
186fn run_fit(request: &Request, edge: u32) -> Result<String, String> {
187    let bytes =
188        std::fs::read(&request.file).map_err(|e| format!("{}: {e}", request.file.display()))?;
189    let fitted = fit::fit(&bytes, edge)?;
190    let path = request.out.join(format!("fit.{}", fitted.extension));
191    std::fs::write(&path, &fitted.bytes).map_err(|e| format!("{}: {e}", path.display()))?;
192    Ok(format!(
193        "image {} {}x{}\n",
194        path.display(),
195        fitted.width,
196        fitted.height
197    ))
198}
199
200fn run_snapcompact(request: &Request) -> Result<String, String> {
201    let text = std::fs::read_to_string(&request.file)
202        .map_err(|e| format!("{}: {e}", request.file.display()))?;
203    let frame = grid::render(&text, request.advance, request.pitch, request.frame_width)?;
204    let path = request.out.join("snap-1.png");
205    std::fs::write(&path, &frame.png).map_err(|e| format!("{}: {e}", path.display()))?;
206    Ok(format!(
207        "image {} {}x{}\n",
208        path.display(),
209        frame.width,
210        frame.height
211    ))
212}
213
214fn run(request: &Request) -> Result<String, String> {
215    let shown = request.file.display();
216    let size = std::fs::metadata(&request.file)
217        .map_err(|e| format!("{shown}: {e}"))?
218        .len();
219    if size > MAX_FILE_BYTES {
220        return Err(format!(
221            "{shown} is {size} bytes; at most {MAX_FILE_BYTES} can be rendered"
222        ));
223    }
224    let bytes = std::fs::read(&request.file).map_err(|e| format!("{shown}: {e}"))?;
225    if bytes[..bytes.len().min(8192)].contains(&0) {
226        return Err(format!("{shown} looks binary (it contains NUL bytes)"));
227    }
228    let source = String::from_utf8_lossy(&bytes);
229    let lines = layout::lines(&source);
230    if request.start > lines.len() {
231        return Err(format!(
232            "{shown} has {} lines; --start {} is past the end",
233            lines.len(),
234            request.start
235        ));
236    }
237    let end = request.end.min(lines.len());
238
239    let language = request
240        .language
241        .clone()
242        .or_else(|| arborium::detect_language(&request.file.to_string_lossy()).map(String::from));
243    let colors = Colors::new(
244        &source,
245        language.as_deref(),
246        &arborium_theme::theme::builtin::github_light(),
247    );
248    let selected = &lines[request.start - 1..end];
249    let rows = layout::rows(
250        selected,
251        request.start,
252        &colors,
253        request.columns,
254        request.tab_width,
255    );
256    let pages = layout::pages(&rows, request.max_rows);
257
258    let mut report = match &colors.language {
259        Language::Highlighted(name) => format!("language {name}\n"),
260        Language::Unsupported(name) => format!("language plain no grammar for {name}\n"),
261        Language::Failed(message) => format!("language plain {message}\n"),
262        Language::Unknown => "language plain unknown file type\n".to_string(),
263    };
264    let mut painter = raster::Painter::new();
265    let gutter = match request.line_numbers {
266        true => end.to_string().len(),
267        false => 1,
268    };
269    for (index, range) in pages.iter().take(request.max_images).enumerate() {
270        let page = &rows[range.clone()];
271        let path = request.out.join(format!("view-{}.png", index + 1));
272        let png = painter.png(&raster::Page {
273            rows: page,
274            gutter,
275            numbers: request.line_numbers,
276            columns: request.columns,
277            foreground: colors.foreground,
278            background: colors.background,
279        });
280        std::fs::write(&path, &png).map_err(|e| format!("{}: {e}", path.display()))?;
281        let (width, height) = dimensions(&png);
282        let first = page.first().map_or(request.start, |row| row.line);
283        let last = page.last().map_or(end, |row| row.line);
284        report += &format!("image {} {first} {last} {width}x{height}\n", path.display());
285    }
286    if let Some(rest) = pages.get(request.max_images) {
287        report += &format!("remaining {} {end}\n", rows[rest.start].line);
288    }
289    Ok(report)
290}
291
292/// Width and height from the IHDR chunk the encoder just wrote.
293fn dimensions(png: &[u8]) -> (u32, u32) {
294    let field = |at: usize| u32::from_be_bytes(png[at..at + 4].try_into().unwrap());
295    (field(16), field(20))
296}