okami.mom/albedo
git clone https://git.nkp.pet/okami.mom/albedo
b2e29fd
main
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 { 37file : PathBuf , 38start : usize , 39end : usize , 40out : PathBuf , 41columns : usize , 42tab_width : usize , 43language : Option < String >, 44max_rows : usize , 45max_images : usize , 46line_numbers : bool , 47snapcompact : bool , 48snapcompact_dir : bool , 49advance : usize , 50pitch : usize , 51frame_width : usize , 52fit : Option < u32 >, 53} 54 55fn main () ->ExitCode { 56let parsed =parse ( std:: env:: args (). skip ( 1 )); 57match parsed. and_then ( |request|{ 58if let Some ( edge) = request. fit { 59run_fit ( & request, edge) 60} else if request. snapcompact && request. snapcompact_dir { 61run_snapcompact_dir ( & request) 62} else if request. snapcompact { 63run_snapcompact ( & request) 64} else { 65run ( & request) 66} 67}) { 68Ok ( report) =>{ 69print! ( "{report}" ); 70ExitCode :: SUCCESS 71} 72Err ( message) =>{ 73eprintln! ( "albedo-render: {message}" ); 74ExitCode :: FAILURE 75} 76} 77} 78 79fn parse ( mut args : impl Iterator < Item =String >) ->Result < Request , String > { 80let mut file =None ; 81let mut request =Request { 82file : PathBuf :: new (), 83start : 0 , 84end : 0 , 85out : PathBuf :: new (), 86columns : 79 , 87tab_width : 4 , 88language : None , 89max_rows : 80 , 90max_images : 4 , 91line_numbers : true , 92snapcompact : false , 93snapcompact_dir : false , 94advance : 11 , 95pitch : 16 , 96frame_width : 1568 , 97fit : None , 98}; 99let mut out =None ; 100while let Some ( arg) = args. next () { 101let mut value = || args. next (). ok_or ( format! ( "{arg} needs a value" )); 102let mut number = |low : usize , high : usize | ->Result < usize , String > { 103let text =value () ?; 104match text. parse ::< usize >() { 105Ok ( 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}; 111match 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 flagif 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" ) ?; 137if request. fit . is_some () || request. snapcompact { 138if !request. snapcompact_dir && !request. file . is_file () { 139return Err ( format! ( "{}: not a file" , request. file . display ())); 140} 141return Ok ( request); 142} 143if request. start ==0 || request. end ==0 { 144return Err ( "--start and --end are required" . into ()); 145} 146if request. end < request. start { 147return Err ( format! ( 148"--end {} is before --start {}" , 149 request. end , request. start 150)); 151} 152Ok ( 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 > { 158if !request. file . is_dir () { 159return Err ( format! ( "{}: not a directory" , request. file . display ())); 160} 161let 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 (); 167if names. is_empty () { 168return Err ( format! ( "{}: no .txt chunks" , request. file . display ())); 169} 170let mut report =String :: new (); 171for ( index, path) in names. iter (). enumerate () { 172let text = std:: fs:: read_to_string ( path). map_err ( |e|format! ( "{}: {e}" , path. display ())) ?; 173let frame = grid:: render ( & text, request. advance , request. pitch , request. frame_width ) ?; 174let 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} 183Ok ( report) 184} 185 186fn run_fit ( request : & Request , edge : u32 ) ->Result < String , String > { 187let bytes = 188 std:: fs:: read ( & request. file ). map_err ( |e|format! ( "{}: {e}" , request. file . display ())) ?; 189let fitted = fit:: fit ( & bytes, edge) ?; 190let path = request. out . join ( format! ( "fit.{}" , fitted. extension )); 191 std:: fs:: write ( & path, & fitted. bytes ). map_err ( |e|format! ( "{}: {e}" , path. display ())) ?; 192Ok ( 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 > { 201let text = std:: fs:: read_to_string ( & request. file ) 202. map_err ( |e|format! ( "{}: {e}" , request. file . display ())) ?; 203let frame = grid:: render ( & text, request. advance , request. pitch , request. frame_width ) ?; 204let path = request. out . join ( "snap-1.png" ); 205 std:: fs:: write ( & path, & frame. png ). map_err ( |e|format! ( "{}: {e}" , path. display ())) ?; 206Ok ( format! ( 207"image {} {}x{}\n" , 208 path. display (), 209 frame. width , 210 frame. height 211)) 212} 213 214fn run ( request : & Request ) ->Result < String , String > { 215let shown = request. file . display (); 216let size = std:: fs:: metadata ( & request. file ) 217. map_err ( |e|format! ( "{shown}: {e}" )) ? 218. len (); 219if size >MAX_FILE_BYTES { 220return Err ( format! ( 221"{shown} is {size} bytes; at most {MAX_FILE_BYTES} can be rendered" 222)); 223} 224let bytes = std:: fs:: read ( & request. file ). map_err ( |e|format! ( "{shown}: {e}" )) ?; 225if bytes[ ..bytes. len (). min ( 8192 )]. contains ( & 0 ) { 226return Err ( format! ( "{shown} looks binary (it contains NUL bytes)" )); 227} 228let source =String :: from_utf8_lossy ( & bytes); 229let lines = layout:: lines ( & source); 230if request. start > lines. len () { 231return Err ( format! ( 232"{shown} has {} lines; --start {} is past the end" , 233 lines. len (), 234 request. start 235)); 236} 237let end = request. end . min ( lines. len ()); 238 239let language = request 240. language 241. clone () 242. or_else ( || arborium:: detect_language ( & request. file . to_string_lossy ()). map ( String :: from)); 243let colors =Colors :: new ( 244& source, 245 language. as_deref (), 246& arborium_theme:: theme:: builtin:: github_light (), 247); 248let selected =& lines[ request. start -1 ..end]; 249let rows = layout:: rows ( 250 selected, 251 request. start , 252& colors, 253 request. columns , 254 request. tab_width , 255); 256let pages = layout:: pages ( & rows, request. max_rows ); 257 258let mut report =match & colors. language { 259Language :: Highlighted ( name) =>format! ( "language {name}\n" ), 260Language :: Unsupported ( name) =>format! ( "language plain no grammar for {name}\n" ), 261Language :: Failed ( message) =>format! ( "language plain {message}\n" ), 262Language :: Unknown =>"language plain unknown file type\n" . to_string (), 263}; 264let mut painter = raster:: Painter :: new (); 265let gutter =match request. line_numbers { 266true => end. to_string (). len (), 267false =>1 , 268}; 269for ( index, range) in pages. iter (). take ( request. max_images ). enumerate () { 270let page =& rows[ range. clone ()]; 271let path = request. out . join ( format! ( "view-{}.png" , index +1 )); 272let png = painter. png ( & raster:: Page { 273rows : page, 274 gutter, 275numbers : request. line_numbers , 276columns : request. columns , 277foreground : colors. foreground , 278background : colors. background , 279}); 280 std:: fs:: write ( & path, & png). map_err ( |e|format! ( "{}: {e}" , path. display ())) ?; 281let ( width, height) =dimensions ( & png); 282let first = page. first (). map_or ( request. start , |row| row. line ); 283let last = page. last (). map_or ( end, |row| row. line ); 284 report +=& format! ( "image {} {first} {last} {width}x{height}\n" , path. display ()); 285} 286if let Some ( rest) = pages. get ( request. max_images ) { 287 report +=& format! ( "remaining {} {end}\n" , rows[ rest. start ]. line ); 288} 289Ok ( report) 290} 291 292/// Width and height from the IHDR chunk the encoder just wrote. 293fn dimensions ( png : & [ u8 ]) ->( u32 , u32 ) { 294let field = |at : usize | u32:: from_be_bytes ( png[ at..at +4 ]. try_into (). unwrap ()); 295( field ( 16 ), field ( 20 )) 296}