| 1 | use canvas::{ |
| 2 | document::TextDocument, |
| 3 | layout::TextEngine, |
| 4 | outline::{Arrange, OutlineLayout}, |
| 5 | }; |
| 6 | use onestore::page::{Outline, Page, PageObject}; |
| 7 | use onestore::{RevisionIndex, Store, document::Document}; |
| 8 | use serde_json::json; |
| 9 | use std::{env, fs, io, sync::Arc, time::Instant}; |
| 10 | |
| 11 | fn main() -> Result<(), Box<dyn std::error::Error>> { |
| 12 | let mut args = env::args().skip(1); |
| 13 | let path = args |
| 14 | .next() |
| 15 | .ok_or("Expected a section snapshot and exact page title")?; |
| 16 | let title = args.next().ok_or("Expected an exact page title")?; |
| 17 | let mut substitutes = Vec::new(); |
| 18 | while let Some(option) = args.next() { |
| 19 | if option != "--substitute-font" { |
| 20 | return Err(format!("Unknown option: {option}").into()); |
| 21 | } |
| 22 | substitutes.push( |
| 23 | args.next() |
| 24 | .ok_or("Provide a font file after --substitute-font.")?, |
| 25 | ); |
| 26 | } |
| 27 | let start = Instant::now(); |
| 28 | let bytes = fs::read(path)?; |
| 29 | let store = Store::parse(&bytes)?; |
| 30 | let index = RevisionIndex::parse(&store)?; |
| 31 | let document = Document::parse(&index)?; |
| 32 | let page = Page::from_document(&document, &title)?; |
| 33 | drop(document); |
| 34 | drop(index); |
| 35 | drop(store); |
| 36 | drop(bytes); |
| 37 | let import_ms = start.elapsed().as_secs_f64() * 1000.0; |
| 38 | let start = Instant::now(); |
| 39 | let mut engine = TextEngine::default(); |
| 40 | for path in substitutes { |
| 41 | engine.register_substitute(parley::fontique::Blob::new(Arc::new(fs::read(path)?)))?; |
| 42 | } |
| 43 | let mut objects = Vec::new(); |
| 44 | let mut title_areas = Vec::new(); |
| 45 | for object in &page.objects { |
| 46 | match object { |
| 47 | PageObject::Outline(outline) => objects.push(outline_json( |
| 48 | outline, |
| 49 | &outline.layout(&mut engine, &page.definitions)?, |
| 50 | None, |
| 51 | )?), |
| 52 | PageObject::Title(title) => { |
| 53 | title_areas.push( |
| 54 | json!({"id": title.id, "layout": title.layout, "date_outline": title.date, |
| 55 | "outlines": title.outlines.iter().map(|o| o.id).collect::<Vec<_>>()}), |
| 56 | ); |
| 57 | for (outline, (origin, layout)) in title |
| 58 | .outlines |
| 59 | .iter() |
| 60 | .zip(title.layout(&mut engine, &page.definitions)?) |
| 61 | { |
| 62 | objects.push(outline_json(outline, &layout, Some(origin))?); |
| 63 | } |
| 64 | } |
| 65 | PageObject::Image(image) => objects.push(json!({"kind": "image", "id": image.id, |
| 66 | "layout": image.layout, "bytes": image.bytes.as_ref().map(|b| b.len()), |
| 67 | "alt": image.alt, "background": image.background})), |
| 68 | PageObject::Attachment(file) => objects.push(json!({"kind": "file", "id": file.id, |
| 69 | "layout": file.layout, "filename": file.filename, |
| 70 | "bytes": file.bytes.as_ref().map(|b| b.len())})), |
| 71 | PageObject::Ink(ink) => objects.push(json!({"kind": "ink", "id": ink.id, |
| 72 | "bounds": ink.bounds(), "strokes": ink.strokes.len(), "groups": ink.groups.len()})), |
| 73 | PageObject::Unsupported(object) => objects.push(json!({"kind": "unsupported", |
| 74 | "id": object.id, "layout": object.layout, "jcid": object.jcid})), |
| 75 | } |
| 76 | } |
| 77 | serde_json::to_writer_pretty( |
| 78 | io::stdout().lock(), |
| 79 | &json!({ |
| 80 | "title": page.title, "created_filetime": page.created, "title_areas": title_areas, "margin_origin": page.margin_origin, "objects": objects, |
| 81 | "definitions": page.definitions.iter().map(|(id, definition)| (id.to_string(), json!({ |
| 82 | "kind": definition.kind, "format": definition.format, |
| 83 | }))).collect::<serde_json::Map<_, _>>(), "import_ms": import_ms, |
| 84 | "layout_ms": start.elapsed().as_secs_f64() * 1000.0, |
| 85 | "measurement": "Outline layout applies indentation, paragraph spacing, collapsed descendants and bullet height; titles use retained parent geometry and stacked child outlines; tags and numbered markers are separate", |
| 86 | }), |
| 87 | )?; |
| 88 | Ok(()) |
| 89 | } |
| 90 | |
| 91 | fn outline_json( |
| 92 | outline: &Outline, |
| 93 | laid_out: &OutlineLayout, |
| 94 | title_origin: Option<[f32; 2]>, |
| 95 | ) -> Result<serde_json::Value, Box<dyn std::error::Error>> { |
| 96 | let mut paragraphs = Vec::new(); |
| 97 | let document = TextDocument::from_nodes(outline.paragraphs.clone())?; |
| 98 | for paragraph in document.text_nodes() { |
| 99 | let placed = laid_out.paragraphs.iter().find(|p| p.id == paragraph.id); |
| 100 | let source = &paragraph |
| 101 | .text() |
| 102 | .ok_or("Unsupported paragraph content")? |
| 103 | .text; |
| 104 | let projection; |
| 105 | let visible = if let Some(placed) = placed { |
| 106 | placed.projection.text() |
| 107 | } else { |
| 108 | projection = source.project()?; |
| 109 | projection.text() |
| 110 | }; |
| 111 | let layout = placed.map(|p| &p.text); |
| 112 | let lines: Vec<_> = layout |
| 113 | .as_ref() |
| 114 | .into_iter() |
| 115 | .flat_map(|layout| layout.lines()) |
| 116 | .map(|(line, bounds)| { |
| 117 | let range = bounds.source.clone(); |
| 118 | json!({ |
| 119 | "start_utf16": visible.utf16_offset(range.start).unwrap(), |
| 120 | "end_utf16": visible.utf16_offset(range.end).unwrap(), |
| 121 | "text": &visible.text()[range], |
| 122 | "advance": line.metrics().advance, |
| 123 | "baseline": bounds.baseline, |
| 124 | "height": bounds.height, |
| 125 | }) |
| 126 | }) |
| 127 | .collect(); |
| 128 | paragraphs.push(json!({ |
| 129 | "id": paragraph.id, "parent": paragraph.parent, "level": paragraph.level, |
| 130 | "date_fields": paragraph.text().into_iter().filter_map(|text| text.date_field).map(|field| format!("{field:?}")).collect::<Vec<_>>(), |
| 131 | "format": paragraph.format, "collapsed": paragraph.collapsed, |
| 132 | "lists": paragraph.lists, |
| 133 | "tags": paragraph.tags.iter().chain(paragraph.text().into_iter().flat_map(|t| &t.tags)).collect::<Vec<_>>(), |
| 134 | "source_utf16": source.text().encode_utf16().count(), |
| 135 | "visible_utf16": visible.text().encode_utf16().count(), |
| 136 | "visible_text": visible.text(), "spans": visible.spans().iter().map(|s| json!({"end_utf8": s.end, "format": s.format})).collect::<Vec<_>>(), |
| 137 | "height": layout.as_ref().map(|l| l.height()), "lines": lines, |
| 138 | "origin": placed.map(|p| p.origin), |
| 139 | "tag_icons": placed.map(|p| p.tags.iter().map(|tag| json!({ |
| 140 | "icon": format!("{:?}", tag.icon), "label": tag.label, "disabled": tag.disabled, |
| 141 | "origin": [tag.origin[0], tag.origin[1] + p.origin[1]], |
| 142 | })).collect::<Vec<_>>()), |
| 143 | "markers": placed.map(|p| p.markers.iter().map(|(layout, origin)| json!({ |
| 144 | "origin": [origin[0], origin[1] + p.origin[1]], "height": layout.height(), |
| 145 | "baseline": layout.lines().next().unwrap().1.baseline, |
| 146 | "advance": layout.lines().next().unwrap().0.metrics().advance, |
| 147 | })).collect::<Vec<_>>()), |
| 148 | })); |
| 149 | } |
| 150 | Ok( |
| 151 | json!({"kind": "outline", "id": outline.id, "layout": outline.layout, |
| 152 | "indents": outline.indents, "is_title": title_origin.is_some(), "is_title_text": outline.title, "minimum_width": outline.min_width, "title_origin": title_origin, "paragraphs": paragraphs, |
| 153 | "size": laid_out.size, |
| 154 | "tables": laid_out.tables.iter().map(|table| json!({ |
| 155 | "id": table.id, "borders": table.borders, |
| 156 | "cells": table.cells.iter().map(|cell| json!({"id": cell.id, "rect": cell.rect})).collect::<Vec<_>>() |
| 157 | })).collect::<Vec<_>>(), |
| 158 | "unsupported": outline.unsupported.iter().map(|u| u.jcid).collect::<Vec<_>>() }), |
| 159 | ) |
| 160 | } |