| 1 | //! How many pages a reading view reflows into a phone's column, across the active pages of |
| 2 | //! every distinct section under the given directories, as Markdown; with `--json FILE`, one |
| 3 | //! line per page with its blocks in reading order. `--render SECTION INDEX PREFIX` draws page |
| 4 | //! INDEX of SECTION whole (`PREFIX-page.png`) and reflowed (`PREFIX-column.png`). |
| 5 | //! |
| 6 | //! `cargo run --release -p canvas --features gpu --example reading_inventory -- [--column 230] corpus` |
| 7 | |
| 8 | use canvas::{ |
| 9 | gpu::{Paper, Viewport, page::PageScene}, |
| 10 | layout::TextEngine, |
| 11 | reading::{self, Kept, Kind, Refusal, Verdict}, |
| 12 | }; |
| 13 | use draw::Renderer; |
| 14 | use onestore::{Arena, Section, page::Page}; |
| 15 | use std::{ |
| 16 | collections::{BTreeMap, BTreeSet, hash_map::DefaultHasher}, |
| 17 | fmt::Write as _, |
| 18 | hash::{Hash, Hasher}, |
| 19 | io::Write as _, |
| 20 | path::{Path, PathBuf}, |
| 21 | time::Duration, |
| 22 | }; |
| 23 | |
| 24 | /// Host points per page point at the iOS app's 100%: OneNote's 96 dpi enlarged 15%. |
| 25 | const POINT: f32 = 96.0 / 72.0 * 1.15; |
| 26 | |
| 27 | fn sections(directory: &Path, out: &mut Vec<PathBuf>) { |
| 28 | let Ok(entries) = std::fs::read_dir(directory) else { |
| 29 | return; |
| 30 | }; |
| 31 | let mut entries: Vec<PathBuf> = entries.flatten().map(|entry| entry.path()).collect(); |
| 32 | entries.sort(); |
| 33 | for path in entries { |
| 34 | if path.is_dir() { |
| 35 | sections(&path, out); |
| 36 | } else if path.extension().is_some_and(|e| e == "one") { |
| 37 | out.push(path); |
| 38 | } |
| 39 | } |
| 40 | } |
| 41 | |
| 42 | fn label(verdict: &Verdict) -> String { |
| 43 | match verdict { |
| 44 | Verdict::Fits => "fits".into(), |
| 45 | Verdict::Reflows => "reflows".into(), |
| 46 | Verdict::Partial(kept) => format!( |
| 47 | "partial: {}", |
| 48 | kept.iter() |
| 49 | .map(|k| kept_name(*k)) |
| 50 | .collect::<Vec<_>>() |
| 51 | .join(", ") |
| 52 | ), |
| 53 | Verdict::Refused(refusal) => format!( |
| 54 | "refused: {}", |
| 55 | match refusal { |
| 56 | Refusal::RightToLeft => "right to left", |
| 57 | Refusal::Drawn => "mostly ink", |
| 58 | Refusal::Arranged => "mostly overlapping", |
| 59 | } |
| 60 | ), |
| 61 | } |
| 62 | } |
| 63 | |
| 64 | fn kept_name(kept: Kept) -> &'static str { |
| 65 | match kept { |
| 66 | Kept::Asides => "asides", |
| 67 | Kept::SideBySide => "side by side", |
| 68 | Kept::Group => "overlap kept whole", |
| 69 | Kept::Wide => "wider than the column", |
| 70 | } |
| 71 | } |
| 72 | |
| 73 | fn kind_name(kind: Kind) -> &'static str { |
| 74 | match kind { |
| 75 | Kind::Text => "text", |
| 76 | Kind::Picture => "picture", |
| 77 | Kind::File => "file", |
| 78 | Kind::Drawing => "drawing", |
| 79 | Kind::Group => "group", |
| 80 | } |
| 81 | } |
| 82 | |
| 83 | fn open(path: &Path) -> Option<Vec<(String, Page)>> { |
| 84 | let image = std::fs::read(path).ok()?; |
| 85 | let arena = Arena::default(); |
| 86 | let mut section = Section::open(&arena, image).ok()?; |
| 87 | let pages = section.pages().ok()?; |
| 88 | Some( |
| 89 | pages |
| 90 | .into_iter() |
| 91 | .filter_map(|(space, title, _)| section.page(space).ok().map(|page| (title, page))) |
| 92 | .collect(), |
| 93 | ) |
| 94 | } |
| 95 | |
| 96 | fn main() -> Result<(), Box<dyn std::error::Error>> { |
| 97 | let mut args: Vec<String> = std::env::args().skip(1).collect(); |
| 98 | let mut column = 230.0; |
| 99 | let mut json = None; |
| 100 | if let Some(at) = args.iter().position(|a| a == "--column") { |
| 101 | column = args[at + 1].parse()?; |
| 102 | args.drain(at..at + 2); |
| 103 | } |
| 104 | if let Some(at) = args.iter().position(|a| a == "--json") { |
| 105 | json = Some(std::fs::File::create(&args[at + 1])?); |
| 106 | args.drain(at..at + 2); |
| 107 | } |
| 108 | let mut engine = TextEngine::default(); |
| 109 | if args.first().is_some_and(|a| a == "--render") { |
| 110 | let [_, section, index, prefix] = &args[..] else { |
| 111 | return Err("--render SECTION INDEX PREFIX".into()); |
| 112 | }; |
| 113 | let pages = open(Path::new(section)).ok_or("section does not open")?; |
| 114 | let (_, page) = pages |
| 115 | .into_iter() |
| 116 | .nth(index.parse()?) |
| 117 | .ok_or("no such page")?; |
| 118 | let read = reading::read(&page, column, &mut engine)?; |
| 119 | let mut gpu = Gpu::new()?; |
| 120 | gpu.whole(&page, &mut engine, &format!("{prefix}-page.png"))?; |
| 121 | if let Some(reflowed) = &read.page { |
| 122 | gpu.column( |
| 123 | reflowed, |
| 124 | column, |
| 125 | &mut engine, |
| 126 | &format!("{prefix}-column.png"), |
| 127 | )?; |
| 128 | } |
| 129 | println!("{}", label(&read.verdict)); |
| 130 | return Ok(()); |
| 131 | } |
| 132 | let mut seen = BTreeSet::new(); |
| 133 | let mut distinct = BTreeSet::new(); |
| 134 | let mut verdicts: BTreeMap<String, Vec<String>> = BTreeMap::new(); |
| 135 | let (mut pages, mut failed) = (0, 0); |
| 136 | for root in &args { |
| 137 | let mut paths = Vec::new(); |
| 138 | sections(Path::new(root), &mut paths); |
| 139 | for path in paths { |
| 140 | let Ok(bytes) = std::fs::read(&path) else { |
| 141 | continue; |
| 142 | }; |
| 143 | let mut hasher = DefaultHasher::new(); |
| 144 | bytes.hash(&mut hasher); |
| 145 | if !seen.insert(hasher.finish()) { |
| 146 | continue; |
| 147 | } |
| 148 | let Some(section) = open(&path) else { |
| 149 | continue; |
| 150 | }; |
| 151 | for (index, (title, page)) in section.into_iter().enumerate() { |
| 152 | // Fixtures repeat pages across their runs; each page counts once. |
| 153 | let mut hasher = DefaultHasher::new(); |
| 154 | serde_json::to_string(&page)?.hash(&mut hasher); |
| 155 | if !distinct.insert(hasher.finish()) { |
| 156 | continue; |
| 157 | } |
| 158 | pages += 1; |
| 159 | let place = format!("{} #{index} — {title}", path.display()); |
| 160 | let read = match reading::read(&page, column, &mut engine) { |
| 161 | Ok(read) => read, |
| 162 | Err(_) => { |
| 163 | failed += 1; |
| 164 | continue; |
| 165 | } |
| 166 | }; |
| 167 | let verdict = label(&read.verdict); |
| 168 | if let Some(json) = &mut json { |
| 169 | let blocks: Vec<_> = read |
| 170 | .blocks |
| 171 | .iter() |
| 172 | .map(|b| serde_json::json!({"kind": kind_name(b.kind), "members": b.members.len(), "bounds": b.bounds})) |
| 173 | .collect(); |
| 174 | let line = serde_json::json!({ |
| 175 | "section": path.display().to_string(), "index": index, "title": title, |
| 176 | "verdict": verdict, "offered": read.verdict.offered(), "blocks": blocks, |
| 177 | }); |
| 178 | writeln!(json, "{line}")?; |
| 179 | } |
| 180 | verdicts.entry(verdict).or_default().push(place); |
| 181 | } |
| 182 | } |
| 183 | } |
| 184 | let mut out = String::new(); |
| 185 | writeln!(out, "# Reading view eligibility\n")?; |
| 186 | writeln!( |
| 187 | out, |
| 188 | "{} distinct sections, {pages} distinct pages ({failed} failed to lay out), column {column} pt ({:.0} pt on screen at 100%).\n", |
| 189 | seen.len(), |
| 190 | column * POINT |
| 191 | )?; |
| 192 | writeln!( |
| 193 | out, |
| 194 | "| Verdict | Pages | Share | Examples |\n| --- | ---: | ---: | --- |" |
| 195 | )?; |
| 196 | let mut rows: Vec<_> = verdicts.iter().collect(); |
| 197 | rows.sort_by_key(|row| std::cmp::Reverse(row.1.len())); |
| 198 | for (verdict, places) in rows { |
| 199 | let examples: Vec<String> = places.iter().take(3).map(|p| format!("`{p}`")).collect(); |
| 200 | writeln!( |
| 201 | out, |
| 202 | "| {verdict} | {} | {:.1}% | {} |", |
| 203 | places.len(), |
| 204 | 100.0 * places.len() as f64 / pages.max(1) as f64, |
| 205 | examples.join("<br>").replace('|', "\\|") |
| 206 | )?; |
| 207 | } |
| 208 | print!("{out}"); |
| 209 | Ok(()) |
| 210 | } |
| 211 | |
| 212 | struct Gpu { |
| 213 | renderer: Renderer, |
| 214 | } |
| 215 | |
| 216 | impl Gpu { |
| 217 | fn new() -> Result<Self, Box<dyn std::error::Error>> { |
| 218 | let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle()); |
| 219 | let adapter = pollster::block_on(instance.request_adapter(&Default::default()))?; |
| 220 | let (device, queue) = pollster::block_on(adapter.request_device(&Default::default()))?; |
| 221 | Ok(Self { |
| 222 | renderer: Renderer::new(device, queue, wgpu::TextureFormat::Rgba8UnormSrgb), |
| 223 | }) |
| 224 | } |
| 225 | |
| 226 | /// The whole page, 804 pixels across: a phone's width at 2x. |
| 227 | fn whole( |
| 228 | &mut self, |
| 229 | page: &Page, |
| 230 | engine: &mut TextEngine, |
| 231 | path: &str, |
| 232 | ) -> Result<(), Box<dyn std::error::Error>> { |
| 233 | let (scene, bounds) = scene(page, engine)?; |
| 234 | let [x0, y0, x1, y1] = bounds; |
| 235 | let scale = 780.0 / (x1 - x0); |
| 236 | let height = (((y1 - y0) * scale) as u32 + 24).min(4000); |
| 237 | self.draw( |
| 238 | scene, |
| 239 | Viewport { |
| 240 | size: [804, height], |
| 241 | scale, |
| 242 | origin: [12.0 - x0 * scale, 12.0 - y0 * scale], |
| 243 | }, |
| 244 | path, |
| 245 | ) |
| 246 | } |
| 247 | |
| 248 | /// The reflowed page as the phone shows it at 100%, 34 points of tag room on its left. |
| 249 | fn column( |
| 250 | &mut self, |
| 251 | page: &Page, |
| 252 | column: f32, |
| 253 | engine: &mut TextEngine, |
| 254 | path: &str, |
| 255 | ) -> Result<(), Box<dyn std::error::Error>> { |
| 256 | let (scene, [x0, y0, _, y1]) = scene(page, engine)?; |
| 257 | let scale = 2.0 * POINT; |
| 258 | let _ = column; |
| 259 | let height = (((y1 - y0) * scale) as u32 + 48).min(6000); |
| 260 | self.draw( |
| 261 | scene, |
| 262 | Viewport { |
| 263 | size: [804, height], |
| 264 | scale, |
| 265 | origin: [68.0 - x0 * scale, 24.0 - y0 * scale], |
| 266 | }, |
| 267 | path, |
| 268 | ) |
| 269 | } |
| 270 | |
| 271 | fn draw( |
| 272 | &mut self, |
| 273 | mut scene: PageScene, |
| 274 | viewport: Viewport, |
| 275 | path: &str, |
| 276 | ) -> Result<(), Box<dyn std::error::Error>> { |
| 277 | scene.settle(None, viewport.scale, Paper::WHITE); |
| 278 | let mut primitives = Vec::new(); |
| 279 | scene.append_primitives(&mut primitives, [0.0; 2], Paper::WHITE)?; |
| 280 | let size = viewport.size; |
| 281 | let device = self.renderer.device().clone(); |
| 282 | let target = device.create_texture(&wgpu::TextureDescriptor { |
| 283 | label: None, |
| 284 | size: wgpu::Extent3d { |
| 285 | width: size[0], |
| 286 | height: size[1], |
| 287 | depth_or_array_layers: 1, |
| 288 | }, |
| 289 | mip_level_count: 1, |
| 290 | sample_count: 1, |
| 291 | dimension: wgpu::TextureDimension::D2, |
| 292 | format: wgpu::TextureFormat::Rgba8UnormSrgb, |
| 293 | usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC, |
| 294 | view_formats: &[], |
| 295 | }); |
| 296 | let row_bytes = (size[0] * 4).next_multiple_of(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT); |
| 297 | let buffer = device.create_buffer(&wgpu::BufferDescriptor { |
| 298 | label: None, |
| 299 | size: u64::from(row_bytes) * u64::from(size[1]), |
| 300 | usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ, |
| 301 | mapped_at_creation: false, |
| 302 | }); |
| 303 | self.renderer |
| 304 | .draw( |
| 305 | &target.create_view(&Default::default()).into(), |
| 306 | size, |
| 307 | [1.0; 4], |
| 308 | &[viewport.layer(&primitives)], |
| 309 | ) |
| 310 | .map_err(|e| format!("{e:?}"))?; |
| 311 | let mut encoder = device.create_command_encoder(&Default::default()); |
| 312 | encoder.copy_texture_to_buffer( |
| 313 | target.as_image_copy(), |
| 314 | wgpu::TexelCopyBufferInfo { |
| 315 | buffer: &buffer, |
| 316 | layout: wgpu::TexelCopyBufferLayout { |
| 317 | offset: 0, |
| 318 | bytes_per_row: Some(row_bytes), |
| 319 | rows_per_image: Some(size[1]), |
| 320 | }, |
| 321 | }, |
| 322 | wgpu::Extent3d { |
| 323 | width: size[0], |
| 324 | height: size[1], |
| 325 | depth_or_array_layers: 1, |
| 326 | }, |
| 327 | ); |
| 328 | self.renderer.queue().submit([encoder.finish()]); |
| 329 | let (sender, receiver) = std::sync::mpsc::channel(); |
| 330 | buffer.map_async(wgpu::MapMode::Read, .., move |result| { |
| 331 | let _ = sender.send(result); |
| 332 | }); |
| 333 | device.poll(wgpu::PollType::Wait { |
| 334 | submission_index: None, |
| 335 | timeout: Some(Duration::from_secs(10)), |
| 336 | })?; |
| 337 | receiver.recv_timeout(Duration::from_secs(10))??; |
| 338 | let mapped = buffer.get_mapped_range(..)?; |
| 339 | let pixels: Vec<u8> = mapped |
| 340 | .chunks_exact(row_bytes as usize) |
| 341 | .flat_map(|row| row[..size[0] as usize * 4].iter().copied()) |
| 342 | .collect(); |
| 343 | let mut encoder = png::Encoder::new(std::fs::File::create(path)?, size[0], size[1]); |
| 344 | encoder.set_color(png::ColorType::Rgba); |
| 345 | encoder.set_depth(png::BitDepth::Eight); |
| 346 | encoder.write_header()?.write_image_data(&pixels)?; |
| 347 | Ok(()) |
| 348 | } |
| 349 | } |
| 350 | |
| 351 | /// The page's scene, pictures decoded, and its content's bounds with the title. |
| 352 | fn scene( |
| 353 | page: &Page, |
| 354 | engine: &mut TextEngine, |
| 355 | ) -> Result<(PageScene, [f32; 4]), Box<dyn std::error::Error>> { |
| 356 | let editor = canvas::editor::CanvasEditor::from_page(page.clone(), engine)?; |
| 357 | let mut bounds = [ |
| 358 | f32::INFINITY, |
| 359 | f32::INFINITY, |
| 360 | f32::NEG_INFINITY, |
| 361 | f32::NEG_INFINITY, |
| 362 | ]; |
| 363 | for outline in editor.outlines() { |
| 364 | let b = outline.bounds(); |
| 365 | bounds = [ |
| 366 | bounds[0].min(b.x0 as f32), |
| 367 | bounds[1].min(b.y0 as f32), |
| 368 | bounds[2].max(b.x1 as f32), |
| 369 | bounds[3].max(b.y1 as f32), |
| 370 | ]; |
| 371 | } |
| 372 | for block in reading::read(page, f32::INFINITY, engine)?.blocks { |
| 373 | let b = block.bounds; |
| 374 | bounds = [ |
| 375 | bounds[0].min(b[0]), |
| 376 | bounds[1].min(b[1]), |
| 377 | bounds[2].max(b[2]), |
| 378 | bounds[3].max(b[3]), |
| 379 | ]; |
| 380 | } |
| 381 | Ok((PageScene::new(page.clone(), engine)?, bounds)) |
| 382 | } |