oxedyne/fe2o3/fe2o3_austenite/src/emit/pearl.rs
60.3 KiB, 215 runs
created by r1870400018:38445, which is this file's identity for as long as the history lasts, whatever it is later renamed to
download · who wrote it · its history
| 1 | //! The Pearl page writer: a content-addressed, self-describing document file (`.prl`). |
| 2 | //! |
| 3 | //! Where the SVG arm renders a frame straight to markup, Pearl serialises the same placed frame to a |
| 4 | //! neutral, queryable file: the glyph outlines stored once and referenced, each page a view over a |
| 5 | //! content-addressed block, and the resolved ledger shipping inside. The point is that the file, not |
| 6 | //! the engine, is enough to render or query the document -- `pearl_render` reads a `.prl` back to the |
| 7 | //! very SVG the SVG arm would have written. |
| 8 | //! |
| 9 | //! A leaf is the placed geometry itself: a text run is its box plus, per glyph, the key of a stored |
| 10 | //! outline and that glyph's offset within the run; a figure op is its placement plus a path and its |
| 11 | //! paint. This is the "outlines, not programs" shape -- the outline is stored in the glyph store in |
| 12 | //! the font frame, once, and a placement carries only where it went, not a baked copy. |
| 13 | //! |
| 14 | //! v1 scope: one document round-tripping to the SVG arm's own output, selectable text layer included. |
| 15 | //! Every leaf kind the SVG arm draws is carried -- text, rule, reservation, and a figure's fills, strokes |
| 16 | //! and rasters. A glyph is keyed by the content of its outline rather than by `face:id:size`, which is |
| 17 | //! font-source-safe: two runs drawn from different font chains cannot collide on a shared `(face, id)`. |
| 18 | //! |
| 19 | //! A `text` leaf's fields past its rigid geometry and outline glyphs (size, selectable spans, the |
| 20 | //! optional colour) ride in one keyed, self-describing map rather than further positional list |
| 21 | //! elements -- v0 (still readable only by refusal; see [`PEARL_VERSION`]) appended `colour` positionally |
| 22 | //! only when set, and v1's own first draft inserted `size` and `spans` ahead of it, silently shifting |
| 23 | //! its index. A keyed map has no index to shift, so a future field joins it without another version |
| 24 | //! bump. |
| 25 | |
| 26 | use crate::doc::Heading; |
| 27 | use crate::ir::{ |
| 28 | DrawOp, |
| 29 | LinkTarget, |
| 30 | Sp, |
| 31 | }; |
| 32 | use crate::ledger::{ |
| 33 | AnchorId, |
| 34 | Ledger, |
| 35 | }; |
| 36 | use crate::page::{ |
| 37 | Page, |
| 38 | PageGeometry, |
| 39 | PlacedKind, |
| 40 | }; |
| 41 | use crate::vfs; |
| 42 | |
| 43 | use std::collections::BTreeMap; |
| 44 | |
| 45 | use oxedyne_fe2o3_core::prelude::*; |
| 46 | use oxedyne_fe2o3_jdat::prelude::*; |
| 47 | use oxedyne_fe2o3_graphics::{ |
| 48 | colour::Rgba, |
| 49 | path::{ |
| 50 | Bounds, |
| 51 | Path, |
| 52 | }, |
| 53 | pixmap::Pixmap, |
| 54 | stroke::Stroke, |
| 55 | svg::{ |
| 56 | path_data, |
| 57 | presentation, |
| 58 | write_path_data, |
| 59 | }, |
| 60 | transform::Transform, |
| 61 | }; |
| 62 | use oxedyne_fe2o3_text::base64; |
| 63 | use oxedyne_fe2o3_text::xml::write::escape as xml_escape; |
| 64 | |
| 65 | /// The Pearl format version this writer emits and the reader accepts. `from_string` refuses any other |
| 66 | /// version outright (see its own comment) rather than guessing at an old or newer shape, so a version |
| 67 | /// bump is the whole fix whenever a leaf's fixed fields change -- as v0 -> v1 (the selectable text |
| 68 | /// layer's `size`/`spans`/`colour` tail) needed and did not originally get, which is what let a stale |
| 69 | /// reader misparse a new file instead of refusing it. |
| 70 | pub const PEARL_VERSION: &str = "1"; |
| 71 | |
| 72 | /// A 64-bit FNV-1a over bytes, as sixteen lower-case hex digits: the content address of a block, a |
| 73 | /// glyph outline or a raster. A short hex key is enough here -- a collision costs a wrong lookup, not |
| 74 | /// a silent corruption, and the space is far too large to meet one here. |
| 75 | fn address(bytes: &[u8]) -> String { |
| 76 | let mut h: u64 = 0xcbf2_9ce4_8422_2325; |
| 77 | for b in bytes { |
| 78 | h ^= *b as u64; |
| 79 | h = h.wrapping_mul(0x0000_0100_0000_01b3); |
| 80 | } |
| 81 | fmt!("{:016x}", h) |
| 82 | } |
| 83 | |
| 84 | /// A colour as a four-element `[r, g, b, a]` list, the form a leaf stores its paint in. |
| 85 | fn rgba_to_dat(c: Rgba) -> Dat { |
| 86 | listdat![c.r, c.g, c.b, c.a] |
| 87 | } |
| 88 | |
| 89 | /// Reads a colour back from a `[r, g, b, a]` list. |
| 90 | pub fn rgba_from_dat(dat: &Dat) -> Outcome<Rgba> { |
| 91 | let v = try_extract_dat!(dat.clone(), List); |
| 92 | if v.len() != 4 { |
| 93 | return Err(err!( |
| 94 | "A Pearl colour is a list of four bytes, found {}.", v.len(); Input, Invalid, Mismatch)); |
| 95 | } |
| 96 | Ok(Rgba::new( |
| 97 | try_extract_dat!(v[0].clone(), U8), |
| 98 | try_extract_dat!(v[1].clone(), U8), |
| 99 | try_extract_dat!(v[2].clone(), U8), |
| 100 | try_extract_dat!(v[3].clone(), U8), |
| 101 | )) |
| 102 | } |
| 103 | |
| 104 | /// A link target as it is stored in a `link` leaf: `["uri", <string>]` for an external address, or |
| 105 | /// `["anchor", <AnchorId map>]` for an internal cross-reference. The anchor rides its own [`ToDat`] form |
| 106 | /// so the reader rebuilds it with [`AnchorId::from_dat`], no private tag table exposed. |
| 107 | fn link_target_to_dat(target: &LinkTarget) -> Outcome<Dat> { |
| 108 | Ok(match target { |
| 109 | LinkTarget::Uri(uri) => listdat![dat!("uri"), dat!(uri.clone())], |
| 110 | LinkTarget::Anchor(id) => listdat![dat!("anchor"), res!(id.to_dat())], |
| 111 | }) |
| 112 | } |
| 113 | |
| 114 | /// Reads a link target back from its stored `["uri", ...]` or `["anchor", ...]` form. |
| 115 | fn link_target_from_dat(dat: &Dat) -> Outcome<LinkTarget> { |
| 116 | let items = try_extract_dat!(dat.clone(), List); |
| 117 | let tag = try_extract_dat!(res!(items.first().ok_or_else(|| err!( |
| 118 | "A link target carries no kind tag."; Input, Invalid))).clone(), Str); |
| 119 | match tag.as_str() { |
| 120 | "uri" => { |
| 121 | let uri = try_extract_dat!(res!(items.get(1).ok_or_else(|| err!( |
| 122 | "A uri link target is missing its address."; Input, Invalid))).clone(), Str); |
| 123 | Ok(LinkTarget::Uri(uri)) |
| 124 | }, |
| 125 | "anchor" => { |
| 126 | let id = res!(AnchorId::from_dat(res!(items.get(1).ok_or_else(|| err!( |
| 127 | "An anchor link target is missing its anchor identity."; Input, Invalid))).clone())); |
| 128 | Ok(LinkTarget::Anchor(id)) |
| 129 | }, |
| 130 | other => Err(err!( |
| 131 | "'{}' is not a Pearl v1 link-target kind.", other; Input, Invalid)), |
| 132 | } |
| 133 | } |
| 134 | |
| 135 | /// A link read back from a `.prl`: the rectangle it covers on its page, and where it points. Where a |
| 136 | /// [`Resolved`](LinkResolution) internal target is wanted, [`PearlDoc::resolve_link`] turns the anchor |
| 137 | /// into a block address through the shipped ledger and index. |
| 138 | #[derive(Clone, Debug, PartialEq, Eq)] |
| 139 | pub struct PearlLink { |
| 140 | pub x: Sp, |
| 141 | pub y: Sp, |
| 142 | pub w: Sp, |
| 143 | pub h: Sp, |
| 144 | pub target: LinkTarget, |
| 145 | } |
| 146 | |
| 147 | /// A link's destination once resolved: an external address stands as-is; an internal anchor becomes the |
| 148 | /// content-addressed block it landed in, on the page the ledger fixed it to. |
| 149 | #[derive(Clone, Debug, PartialEq, Eq)] |
| 150 | pub enum LinkResolution { |
| 151 | Uri(String), |
| 152 | Block { block: String, page: u32 }, |
| 153 | } |
| 154 | |
| 155 | /// One resolved heading of the document outline: its level (1 for a chapter or part, 2 for a section, |
| 156 | /// and so on), the dotted number a numbered heading shows (empty otherwise), the display title, and |
| 157 | /// where it landed -- the 1-based page and the y within it -- resolved through the shipped ledger. An |
| 158 | /// entry whose anchor the ledger never fixed carries `None` for its location, the dangling case a reader |
| 159 | /// greys rather than jumps to, mirroring [`PearlDoc::resolve_link`]'s `None`. |
| 160 | #[derive(Clone, Debug, PartialEq)] |
| 161 | pub struct OutlineEntry { |
| 162 | pub level: u8, |
| 163 | pub number: String, |
| 164 | pub title: String, |
| 165 | pub page: Option<u32>, // the page the heading landed on, or None when the ledger never fixed it |
| 166 | pub y: Option<Sp>, // the y within that page, paired with page |
| 167 | } |
| 168 | |
| 169 | /// What an annotation is. A kind the reader does not know is a limit it declares, not a gap it hides, so |
| 170 | /// decoding an unknown kind is an error rather than a silent default -- the same stance the ledger takes |
| 171 | /// on an anchor kind. |
| 172 | #[derive(Clone, Copy, Debug, PartialEq, Eq)] |
| 173 | pub enum AnnotationKind { |
| 174 | Highlight, // a marked span, no text of its own beyond the region |
| 175 | Note, // a comment attached to the anchor, its words in the payload |
| 176 | } |
| 177 | |
| 178 | impl AnnotationKind { |
| 179 | pub fn as_str(&self) -> &'static str { |
| 180 | match self { |
| 181 | AnnotationKind::Highlight => "highlight", |
| 182 | AnnotationKind::Note => "note", |
| 183 | } |
| 184 | } |
| 185 | |
| 186 | pub fn from_str(s: &str) -> Outcome<Self> { |
| 187 | match s { |
| 188 | "highlight" => Ok(AnnotationKind::Highlight), |
| 189 | "note" => Ok(AnnotationKind::Note), |
| 190 | other => Err(err!( |
| 191 | "'{}' is not a Pearl v1 annotation kind.", other; Input, Invalid)), |
| 192 | } |
| 193 | } |
| 194 | } |
| 195 | |
| 196 | /// A saved annotation. The anchor is the content address of the block it attaches to -- not a page or an |
| 197 | /// offset -- so it survives repagination: the block keeps its identity when it moves to another page, and |
| 198 | /// the annotation follows it. `rect` is a region within that block, in the block's own coordinates, or the |
| 199 | /// whole block when absent. `created` is an author-supplied timestamp, carried verbatim. |
| 200 | #[derive(Clone, Debug, PartialEq, Eq)] |
| 201 | pub struct Annotation { |
| 202 | pub anchor: String, // content address of the anchored block |
| 203 | pub rect: Option<(Sp, Sp, Sp, Sp)>, // x, y, w, h within the block, or the whole block when None |
| 204 | pub kind: AnnotationKind, |
| 205 | pub payload: String, // the note's text, or a highlight's optional label |
| 206 | pub author: String, |
| 207 | pub created: String, // author-supplied timestamp, carried as written |
| 208 | pub doc_id: Option<String>, // the document the annotation belongs to, when carried |
| 209 | } |
| 210 | |
| 211 | impl Annotation { |
| 212 | pub fn new<S: Into<String>>( |
| 213 | anchor: S, |
| 214 | kind: AnnotationKind, |
| 215 | payload: S, |
| 216 | author: S, |
| 217 | created: S, |
| 218 | ) |
| 219 | -> Self |
| 220 | { |
| 221 | Self { |
| 222 | anchor: anchor.into(), |
| 223 | rect: None, |
| 224 | kind, |
| 225 | payload: payload.into(), |
| 226 | author: author.into(), |
| 227 | created: created.into(), |
| 228 | doc_id: None, |
| 229 | } |
| 230 | } |
| 231 | |
| 232 | /// Confines the annotation to a rectangle within its anchored block, rather than the whole block. |
| 233 | pub fn with_rect(mut self, x: Sp, y: Sp, w: Sp, h: Sp) -> Self { |
| 234 | self.rect = Some((x, y, w, h)); |
| 235 | self |
| 236 | } |
| 237 | |
| 238 | /// Stamps the annotation with the document it belongs to, so a fold can refuse |
| 239 | /// one replayed onto a different document that happens to share a block address. |
| 240 | pub fn with_doc_id<S: Into<String>>(mut self, doc_id: S) -> Self { |
| 241 | self.doc_id = Some(doc_id.into()); |
| 242 | self |
| 243 | } |
| 244 | } |
| 245 | |
| 246 | impl ToDat for Annotation { |
| 247 | fn to_dat(&self) -> Outcome<Dat> { |
| 248 | let mut d = omapdat!{ |
| 249 | "anchor" => dat!(self.anchor.clone()), |
| 250 | "kind" => dat!(self.kind.as_str()), |
| 251 | "payload" => dat!(self.payload.clone()), |
| 252 | "author" => dat!(self.author.clone()), |
| 253 | "created" => dat!(self.created.clone()), |
| 254 | }; |
| 255 | if let Some((x, y, w, h)) = self.rect { |
| 256 | res!(d.map_put(dat!("rect"), listdat![ |
| 257 | res!(x.to_dat()), |
| 258 | res!(y.to_dat()), |
| 259 | res!(w.to_dat()), |
| 260 | res!(h.to_dat()), |
| 261 | ])); |
| 262 | } |
| 263 | // Written only when set, so a document without one encodes exactly as before |
| 264 | // and every .prl written before the field existed reads unchanged. |
| 265 | if let Some(doc_id) = &self.doc_id { |
| 266 | res!(d.map_put(dat!("doc_id"), dat!(doc_id.clone()))); |
| 267 | } |
| 268 | Ok(d) |
| 269 | } |
| 270 | } |
| 271 | |
| 272 | impl FromDat for Annotation { |
| 273 | fn from_dat(mut dat: Dat) -> Outcome<Self> { |
| 274 | let anchor = try_extract_dat!(res!(dat.map_remove_must(&dat!("anchor"))), Str); |
| 275 | let kind = res!(AnnotationKind::from_str( |
| 276 | &try_extract_dat!(res!(dat.map_remove_must(&dat!("kind"))), Str))); |
| 277 | let payload = try_extract_dat!(res!(dat.map_remove_must(&dat!("payload"))), Str); |
| 278 | let author = try_extract_dat!(res!(dat.map_remove_must(&dat!("author"))), Str); |
| 279 | let created = try_extract_dat!(res!(dat.map_remove_must(&dat!("created"))), Str); |
| 280 | let rect = match res!(dat.map_remove(&dat!("rect"))) { |
| 281 | Some(d) => { |
| 282 | let v = try_extract_dat!(d, List); |
| 283 | if v.len() != 4 { |
| 284 | return Err(err!( |
| 285 | "An annotation rect is four scaled lengths, found {}.", v.len(); |
| 286 | Input, Invalid, Mismatch)); |
| 287 | } |
| 288 | Some(( |
| 289 | res!(Sp::from_dat(v[0].clone())), |
| 290 | res!(Sp::from_dat(v[1].clone())), |
| 291 | res!(Sp::from_dat(v[2].clone())), |
| 292 | res!(Sp::from_dat(v[3].clone())), |
| 293 | )) |
| 294 | }, |
| 295 | None => None, |
| 296 | }; |
| 297 | let doc_id = match res!(dat.map_remove(&dat!("doc_id"))) { |
| 298 | Some(d) => Some(try_extract_dat!(d, Str)), |
| 299 | None => None, |
| 300 | }; |
| 301 | Ok(Self { anchor, rect, kind, payload, author, created, doc_id }) |
| 302 | } |
| 303 | } |
| 304 | |
| 305 | /// Accumulates pages into one Pearl document: the glyph, image and block stores deduplicated by content |
| 306 | /// address, the page index in order, and the ledger shipped inside. Fed one page at a time from the |
| 307 | /// emit loop, before each page's frame is dropped, so it streams exactly as the SVG and PDF arms do. |
| 308 | pub struct PearlBuilder { |
| 309 | glyphs: BTreeMap<String, Dat>, // outline address -> { "d", "adv" } |
| 310 | images: BTreeMap<String, Dat>, // raster address -> { "w", "h", "png" (base64) } |
| 311 | blocks: BTreeMap<String, Dat>, // block address -> page block |
| 312 | index: Vec<Dat>, // [{ "page", "block" }, ...], in page order |
| 313 | geom: Dat, // the document geometry, [w, h, inside, outside, top, bottom] |
| 314 | ledger: Ledger, // shipped inside, and used to resolve internal link targets |
| 315 | doc_id: Option<String>, // author-minted stable identity, written only when set (see with_doc_id) |
| 316 | outline: Vec<Dat>, // the heading outline, one entry per fixed heading, written only when non-empty (see with_outline) |
| 317 | } |
| 318 | |
| 319 | impl PearlBuilder { |
| 320 | pub fn new(ledger: &Ledger, geom: PageGeometry) -> Outcome<Self> { |
| 321 | Ok(Self { |
| 322 | glyphs: BTreeMap::new(), |
| 323 | images: BTreeMap::new(), |
| 324 | blocks: BTreeMap::new(), |
| 325 | index: Vec::new(), |
| 326 | geom: geometry_to_dat(&geom), |
| 327 | ledger: ledger.clone(), |
| 328 | doc_id: None, |
| 329 | outline: Vec::new(), |
| 330 | }) |
| 331 | } |
| 332 | |
| 333 | /// Stamps the document with a stable, author-minted identity, carried in the `.prl` header as an |
| 334 | /// optional `doc_id` field. This is the key a collaboration layer folds a document's edit stream |
| 335 | /// against, and it is chosen to survive re-pagination -- unlike a page number or a block address, |
| 336 | /// which move when the document is recomposed. A builder that is never given one writes no `doc_id` |
| 337 | /// key at all, so a document without collaboration is byte-identical to what this writer emitted |
| 338 | /// before the field existed; see [`PearlBuilder::into_dat`]. |
| 339 | pub fn with_doc_id<S: Into<String>>(mut self, id: S) -> Self { |
| 340 | self.doc_id = Some(id.into()); |
| 341 | self |
| 342 | } |
| 343 | |
| 344 | /// Records the document's heading outline, carried in the `.prl` header as an `outline` list -- one |
| 345 | /// entry per heading in document order, each `{ level, number, title, anchor }`. The anchor is the |
| 346 | /// heading's own identity, the same `{ kind, key }` shape a link target rides, so a reader resolves |
| 347 | /// an entry to its page and y through the shipped ledger exactly as it follows a link (see |
| 348 | /// [`PearlDoc::outline`]). A heading the ledger never fixed still lists here -- its anchor simply |
| 349 | /// resolves to nothing, the dangling case the reader reports rather than jumps to -- so the outline |
| 350 | /// mirrors the document's structure whatever the pagination did. A builder given no headings, or an |
| 351 | /// empty set, writes no `outline` key at all, so a document without one is byte-identical to what this |
| 352 | /// writer emitted before the field existed; see [`PearlBuilder::into_dat`]. |
| 353 | pub fn with_outline(mut self, heads: &[Heading]) -> Self { |
| 354 | let mut out = Vec::with_capacity(heads.len()); |
| 355 | for h in heads { |
| 356 | // The anchor's own `ToDat` form, the same `{ kind, key }` a link target rides. It cannot fail |
| 357 | // for a heading identity, so a stray error drops just that entry rather than the whole outline. |
| 358 | let anchor = match h.id.to_dat() { |
| 359 | Ok(a) => a, |
| 360 | Err(_) => continue, |
| 361 | }; |
| 362 | out.push(omapdat!{ |
| 363 | "level" => dat!(h.level), |
| 364 | "number" => dat!(h.number.clone()), |
| 365 | "title" => dat!(h.title.clone()), |
| 366 | "anchor" => anchor, |
| 367 | }); |
| 368 | } |
| 369 | self.outline = out; |
| 370 | self |
| 371 | } |
| 372 | |
| 373 | /// Serialises one page into a content block, folding its glyphs and rasters into the shared stores |
| 374 | /// and appending its index entry. The leaf order and per-glyph order match the SVG arm's walk of |
| 375 | /// `frame.placed`, which is what lets the reader reproduce that arm's bytes. |
| 376 | pub fn add_page(&mut self, page: &Page) -> Outcome<()> { |
| 377 | let mut leaves: Vec<Dat> = Vec::new(); |
| 378 | |
| 379 | for placed in &page.frame.placed { |
| 380 | match &placed.kind { |
| 381 | PlacedKind::Text(shaped) => { |
| 382 | let mut glyphs: Vec<Dat> = Vec::new(); |
| 383 | for glyph in &shaped.run().glyphs { |
| 384 | let path = res!(shaped.outline(glyph)); |
| 385 | // A glyph with no ink -- a space -- carries an advance but nothing to draw, and the |
| 386 | // SVG arm skips it; skip it here too so the two runs place the same marks. |
| 387 | if path.is_empty() { |
| 388 | continue; |
| 389 | } |
| 390 | let d = write_path_data(&path); |
| 391 | let key = address(d.as_bytes()); |
| 392 | self.glyphs.entry(key.clone()).or_insert_with(|| omapdat!{ |
| 393 | "d" => dat!(d), |
| 394 | "adv" => dat!(glyph.adv), |
| 395 | }); |
| 396 | glyphs.push(listdat![dat!(key), dat!(glyph.x), dat!(glyph.y)]); |
| 397 | } |
| 398 | // The selectable text layer's spans: one `(gx, gy, text)` per glyph `glyph_text` gives a |
| 399 | // non-empty mapping to -- spaces included, unlike `glyphs` above, since a space still |
| 400 | // carries a text node the SVG arm's `.tsel` layer needs even though it draws no outline. |
| 401 | // Reusing `glyph_text` rather than re-deriving keeps Pearl, the PDF `/ToUnicode` CMap and |
| 402 | // the SVG arm agreeing on what each glyph says. |
| 403 | let spans: Vec<Dat> = shaped.run().glyphs.iter().zip(shaped.glyph_text().into_iter()) |
| 404 | .filter(|(_, text)| !text.is_empty()) |
| 405 | .map(|(g, text)| listdat![dat!(g.x), dat!(g.y), dat!(text)]) |
| 406 | .collect(); |
| 407 | // Everything past the rigid geometry (position, box, outline glyphs) rides in ONE |
| 408 | // self-describing, keyed tail rather than further positional elements: a v0 leaf grew a |
| 409 | // positional "colour" appended only when set, and a later positional insert for "size" |
| 410 | // and "spans" silently shifted it -- a stale reader or an old file then misreads a field |
| 411 | // by index instead of failing cleanly. A keyed map has no index to shift, so a leaf kind |
| 412 | // can grow a field forever without another version bump. |
| 413 | let mut meta = omapdat!{ |
| 414 | "size" => dat!(shaped.size()), // the run's point size, for the tsel layer's font-size |
| 415 | "spans" => Dat::List(spans), |
| 416 | }; |
| 417 | // The fill rides as an optional key, present only when the run is not black. A black run |
| 418 | // adds nothing, so an all-black document's bytes are exactly what they were before text |
| 419 | // carried a colour; the reader defaults a missing key to black. |
| 420 | if shaped.colour() != Rgba::BLACK { |
| 421 | res!(meta.map_put(dat!("colour"), rgba_to_dat(shaped.colour()))); |
| 422 | } |
| 423 | let leaf = listdat![ |
| 424 | dat!("text"), |
| 425 | res!(placed.x.to_dat()), |
| 426 | res!(placed.y.to_dat()), |
| 427 | res!(placed.dims.width.to_dat()), |
| 428 | res!(placed.dims.height.to_dat()), |
| 429 | res!(placed.dims.depth.to_dat()), |
| 430 | Dat::List(glyphs), |
| 431 | meta, |
| 432 | ]; |
| 433 | leaves.push(leaf); |
| 434 | }, |
| 435 | PlacedKind::Rule => { |
| 436 | leaves.push(box_leaf("rule", placed.x, placed.y, placed.dims)?); |
| 437 | }, |
| 438 | PlacedKind::Reserved => { |
| 439 | leaves.push(box_leaf("reserved", placed.x, placed.y, placed.dims)?); |
| 440 | }, |
| 441 | PlacedKind::Graphic(g) => { |
| 442 | let bx = placed.x; |
| 443 | let by = placed.y; |
| 444 | // A linked graphic carries a `link` leaf over its placement box, additive beside its ink so a |
| 445 | // reader that ignores the tag still draws the figure. The box is the graphic's, matching the |
| 446 | // PDF arm's annotation rectangle: width, and height plus depth. |
| 447 | if let Some(target) = &g.link { |
| 448 | leaves.push(listdat![ |
| 449 | dat!("link"), |
| 450 | res!(bx.to_dat()), |
| 451 | res!(by.to_dat()), |
| 452 | res!(g.dims.width.to_dat()), |
| 453 | res!((g.dims.height + g.dims.depth).to_dat()), |
| 454 | res!(link_target_to_dat(target)), |
| 455 | ]); |
| 456 | } |
| 457 | for op in &g.ops { |
| 458 | match op { |
| 459 | DrawOp::Fill { path, colour } => { |
| 460 | leaves.push(listdat![ |
| 461 | dat!("fill"), |
| 462 | res!(bx.to_dat()), |
| 463 | res!(by.to_dat()), |
| 464 | dat!(write_path_data(path)), |
| 465 | rgba_to_dat(*colour), |
| 466 | ]); |
| 467 | }, |
| 468 | DrawOp::Stroke { path, colour, width } => { |
| 469 | leaves.push(listdat![ |
| 470 | dat!("stroke"), |
| 471 | res!(bx.to_dat()), |
| 472 | res!(by.to_dat()), |
| 473 | dat!(write_path_data(path)), |
| 474 | rgba_to_dat(*colour), |
| 475 | dat!(*width), |
| 476 | ]); |
| 477 | }, |
| 478 | DrawOp::Image { image, x, y, w, h } => { |
| 479 | // Re-encode the raster exactly as the SVG arm does, and store that PNG's base64 |
| 480 | // so the reader emits a byte-identical data URI. |
| 481 | let pm = res!(Pixmap::from_data(image.width, image.height, image.rgba.clone())); |
| 482 | let png = res!(pm.to_png()); |
| 483 | let b64 = base64::encode(&png); |
| 484 | let key = address(png.as_slice()); |
| 485 | self.images.entry(key.clone()).or_insert_with(|| omapdat!{ |
| 486 | "w" => dat!(image.width as u32), |
| 487 | "h" => dat!(image.height as u32), |
| 488 | "png" => dat!(b64.clone()), |
| 489 | }); |
| 490 | leaves.push(listdat![ |
| 491 | dat!("image"), |
| 492 | res!(bx.to_dat()), |
| 493 | res!(by.to_dat()), |
| 494 | dat!(*x), |
| 495 | dat!(*y), |
| 496 | dat!(*w), |
| 497 | dat!(*h), |
| 498 | dat!(key), |
| 499 | ]); |
| 500 | }, |
| 501 | } |
| 502 | } |
| 503 | }, |
| 504 | } |
| 505 | } |
| 506 | |
| 507 | let block = omapdat!{ |
| 508 | "kind" => dat!("page"), |
| 509 | "number" => dat!(page.number), |
| 510 | "geom" => geometry_to_dat(&page.geom), |
| 511 | "leaves" => Dat::List(leaves), |
| 512 | }; |
| 513 | let enc = res!(encode(&block)); |
| 514 | let key = address(enc.as_bytes()); |
| 515 | self.index.push(omapdat!{ |
| 516 | "page" => dat!(page.number), |
| 517 | "block" => dat!(key.clone()), |
| 518 | }); |
| 519 | self.blocks.entry(key).or_insert(block); |
| 520 | Ok(()) |
| 521 | } |
| 522 | |
| 523 | /// The whole document as one jdat map, ready to encode. The `annotations` section opens empty: the |
| 524 | /// engine authors none, and a reader adds them through [`PearlDoc::add_annotation`]. |
| 525 | /// |
| 526 | /// A `doc_id` key is added only when one was set through [`PearlBuilder::with_doc_id`]. A document |
| 527 | /// without a doc_id therefore emits exactly the keys, in exactly the order, this writer emitted |
| 528 | /// before the field existed, so its bytes are unchanged -- the property the `.prl`/`.tsel` oracle |
| 529 | /// relies on. |
| 530 | pub fn into_dat(self) -> Outcome<Dat> { |
| 531 | let glyphs = create_dat_ordmap(self.glyphs.into_iter().map(|(k, v)| (dat!(k), v)).collect()); |
| 532 | let images = create_dat_ordmap(self.images.into_iter().map(|(k, v)| (dat!(k), v)).collect()); |
| 533 | let blocks = create_dat_ordmap(self.blocks.into_iter().map(|(k, v)| (dat!(k), v)).collect()); |
| 534 | let mut top = omapdat!{ |
| 535 | "pearl" => dat!(PEARL_VERSION), |
| 536 | "index" => Dat::List(self.index), |
| 537 | "glyphs" => glyphs, |
| 538 | "images" => images, |
| 539 | "blocks" => blocks, |
| 540 | "ledger" => res!(self.ledger.to_dat()), |
| 541 | "geom" => self.geom, |
| 542 | "annotations" => Dat::List(Vec::new()), |
| 543 | }; |
| 544 | if let Some(id) = self.doc_id { |
| 545 | res!(top.map_put(dat!("doc_id"), dat!(id))); |
| 546 | } |
| 547 | // The heading outline, only when the document has headings, so a headless manuscript stays |
| 548 | // byte-identical to what this writer emitted before the field existed. |
| 549 | if !self.outline.is_empty() { |
| 550 | res!(top.map_put(dat!("outline"), Dat::List(self.outline))); |
| 551 | } |
| 552 | Ok(top) |
| 553 | } |
| 554 | |
| 555 | /// The whole document encoded as text jdat, the same encoding the ledger uses. |
| 556 | pub fn to_string(self) -> Outcome<String> { |
| 557 | encode(&res!(self.into_dat())) |
| 558 | } |
| 559 | |
| 560 | /// Writes the document to `path` as text jdat. |
| 561 | pub fn to_file<P: AsRef<std::path::Path>>(self, path: P) -> Outcome<()> { |
| 562 | res!(vfs::write(path.as_ref(), res!(self.to_string()).as_bytes())); |
| 563 | Ok(()) |
| 564 | } |
| 565 | } |
| 566 | |
| 567 | /// A rule or reservation leaf: the box's position and its three dimensions, from which the reader |
| 568 | /// rebuilds the same rectangle the SVG arm draws. |
| 569 | fn box_leaf(tag: &str, x: Sp, y: Sp, dims: crate::ir::Dims) -> Outcome<Dat> { |
| 570 | Ok(listdat![ |
| 571 | dat!(tag), |
| 572 | res!(x.to_dat()), |
| 573 | res!(y.to_dat()), |
| 574 | res!(dims.width.to_dat()), |
| 575 | res!(dims.height.to_dat()), |
| 576 | res!(dims.depth.to_dat()), |
| 577 | ]) |
| 578 | } |
| 579 | |
| 580 | /// A page geometry as `[width, height, inside, outside, top, bottom]` scaled points. |
| 581 | fn geometry_to_dat(g: &PageGeometry) -> Dat { |
| 582 | listdat![ |
| 583 | g.width.raw(), |
| 584 | g.height.raw(), |
| 585 | g.inside.raw(), |
| 586 | g.outside.raw(), |
| 587 | g.top.raw(), |
| 588 | g.bottom.raw(), |
| 589 | ] |
| 590 | } |
| 591 | |
| 592 | /// Encodes a `Dat` to text jdat with the default config, as [`Ledger::to_file`] does. |
| 593 | fn encode(dat: &Dat) -> Outcome<String> { |
| 594 | let cfg = oxedyne_fe2o3_jdat::string::enc::EncoderConfig::<(), ()>::default(); |
| 595 | Ok(res!(dat.encode_string_with_config(&cfg))) |
| 596 | } |
| 597 | |
| 598 | // --------------------------------------------------------------------------------------------------- |
| 599 | // Reading a `.prl` back, and rendering it to the SVG the SVG arm would have written. |
| 600 | // --------------------------------------------------------------------------------------------------- |
| 601 | |
| 602 | /// One placed span of the invisible, selectable text layer: an offset within its run and the word it |
| 603 | /// carries. See [`TselRun`]. |
| 604 | pub struct TselSpan { |
| 605 | pub gx: f32, |
| 606 | pub gy: f32, |
| 607 | pub text: String, |
| 608 | } |
| 609 | |
| 610 | /// A text run's contribution to the selectable-text layer: the run's baseline origin and size, and the |
| 611 | /// spans placed against it. A visual [`PageSink`] ignores these; the SVG sink turns them into the page's |
| 612 | /// one `.tsel` `<text>` element. Kept as structured data rather than pre-built markup so no sink but the |
| 613 | /// SVG one ever handles a tspan. |
| 614 | pub struct TselRun { |
| 615 | pub base_x: f32, |
| 616 | pub base_y: f32, |
| 617 | pub size: f32, |
| 618 | pub spans: Vec<TselSpan>, |
| 619 | } |
| 620 | |
| 621 | /// A destination for a page's placed ink. The one leaf walk in [`PearlDoc::render_page_to`] drives a |
| 622 | /// sink rather than building SVG inline, so the SVG writer and a direct rasteriser (`fe2o3_pearlite`'s |
| 623 | /// pixmap sink) share that single walk instead of the rasteriser re-parsing the SVG. Every path arrives |
| 624 | /// already placed in the page's point frame -- origin top-left, y down, one unit one point -- so a sink |
| 625 | /// applies only its own device transform (a DPI scale, say) on top. |
| 626 | pub trait PageSink { |
| 627 | /// Opens a page `w` by `h` points; a sink sizes its canvas and lays the white ground here. |
| 628 | fn begin(&mut self, w: usize, h: usize) -> Outcome<()>; |
| 629 | /// Fills the placed `path` with `colour`. |
| 630 | fn fill(&mut self, path: &Path, colour: Rgba) -> Outcome<()>; |
| 631 | /// Strokes the placed `path` with `colour` and pen `pen`. |
| 632 | fn stroke(&mut self, path: &Path, colour: Rgba, pen: &Stroke) -> Outcome<()>; |
| 633 | /// Places a base64 PNG at (`x`, `y`), `w` by `h` points. |
| 634 | fn image(&mut self, png_base64: &str, x: f32, y: f32, w: f32, h: f32) -> Outcome<()>; |
| 635 | /// The page's selectable-text runs, in placement order. A visual sink ignores them. |
| 636 | fn text_layer(&mut self, runs: &[TselRun]) -> Outcome<()>; |
| 637 | /// Closes the page. |
| 638 | fn end(&mut self) -> Outcome<()>; |
| 639 | } |
| 640 | |
| 641 | /// The [`PageSink`] that reconstructs the SVG arm's own page markup, byte for byte. Every method writes |
| 642 | /// through the very `write_path_data`, `presentation` and `.tsel` shapes the SVG arm uses, so a rendered |
| 643 | /// page is that arm's output exactly. |
| 644 | pub struct SvgSink { |
| 645 | out: String, |
| 646 | } |
| 647 | |
| 648 | impl SvgSink { |
| 649 | pub fn new() -> Self { |
| 650 | Self { out: String::new() } |
| 651 | } |
| 652 | |
| 653 | pub fn into_string(self) -> String { |
| 654 | self.out |
| 655 | } |
| 656 | } |
| 657 | |
| 658 | impl Default for SvgSink { |
| 659 | fn default() -> Self { |
| 660 | Self::new() |
| 661 | } |
| 662 | } |
| 663 | |
| 664 | impl PageSink for SvgSink { |
| 665 | fn begin(&mut self, w: usize, h: usize) -> Outcome<()> { |
| 666 | self.out.push_str(&fmt!( |
| 667 | "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"{}\" height=\"{}\" viewBox=\"0 0 {} {}\">\n", |
| 668 | w, h, w, h)); |
| 669 | self.out.push_str(&fmt!( |
| 670 | "<rect x=\"0\" y=\"0\" width=\"{}\" height=\"{}\" fill=\"#ffffff\"/>\n", w, h)); |
| 671 | // Matches the SVG arm's own `.tsel` style declaration, emitted at the very same point. |
| 672 | self.out.push_str("<style>.tsel { fill: transparent; }</style>\n"); |
| 673 | Ok(()) |
| 674 | } |
| 675 | |
| 676 | fn fill(&mut self, path: &Path, colour: Rgba) -> Outcome<()> { |
| 677 | self.out.push_str(&fmt!( |
| 678 | " <path d=\"{}\" {}/>\n", write_path_data(path), presentation(Some(colour), None))); |
| 679 | Ok(()) |
| 680 | } |
| 681 | |
| 682 | fn stroke(&mut self, path: &Path, colour: Rgba, pen: &Stroke) -> Outcome<()> { |
| 683 | self.out.push_str(&fmt!( |
| 684 | " <path d=\"{}\" {}/>\n", write_path_data(path), presentation(None, Some((colour, pen))))); |
| 685 | Ok(()) |
| 686 | } |
| 687 | |
| 688 | fn image(&mut self, png_base64: &str, x: f32, y: f32, w: f32, h: f32) -> Outcome<()> { |
| 689 | self.out.push_str(&fmt!( |
| 690 | " <image x=\"{}\" y=\"{}\" width=\"{}\" height=\"{}\" preserveAspectRatio=\"none\" \ |
| 691 | href=\"data:image/png;base64,{}\"/>\n", |
| 692 | x, y, w, h, png_base64)); |
| 693 | Ok(()) |
| 694 | } |
| 695 | |
| 696 | fn text_layer(&mut self, runs: &[TselRun]) -> Outcome<()> { |
| 697 | // One page-wide `.tsel` buffer: every run's tspans joining a single `<text>`, in placement order, |
| 698 | // with a leading space ahead of every run but the first to stand in for the interword gap Pearl's |
| 699 | // per-word leaves carry as pure position. One element rather than one per run sidesteps a |
| 700 | // `window.find` gap Chromium was found to have across sibling `<text>` elements once tspans carry |
| 701 | // per-glyph `x`/`y`. |
| 702 | let mut buf = String::new(); |
| 703 | let mut seen_text = false; |
| 704 | for run in runs { |
| 705 | let mut tspans = String::new(); |
| 706 | for s in &run.spans { |
| 707 | tspans.push_str(&fmt!( |
| 708 | "<tspan x=\"{}\" y=\"{}\" font-size=\"{}\">{}</tspan>", |
| 709 | run.base_x + s.gx, run.base_y - s.gy, run.size, xml_escape(&s.text))); |
| 710 | } |
| 711 | if tspans.is_empty() { |
| 712 | continue; |
| 713 | } |
| 714 | if seen_text { |
| 715 | buf.push_str(&fmt!( |
| 716 | "<tspan x=\"{}\" y=\"{}\" font-size=\"{}\"> </tspan>", |
| 717 | run.base_x, run.base_y, run.size)); |
| 718 | } |
| 719 | buf.push_str(&tspans); |
| 720 | seen_text = true; |
| 721 | } |
| 722 | if !buf.is_empty() { |
| 723 | self.out.push_str(&fmt!(" <text class=\"tsel\">{}</text>\n", buf)); |
| 724 | } |
| 725 | Ok(()) |
| 726 | } |
| 727 | |
| 728 | fn end(&mut self) -> Outcome<()> { |
| 729 | self.out.push_str("</svg>\n"); |
| 730 | Ok(()) |
| 731 | } |
| 732 | } |
| 733 | |
| 734 | /// A decoded Pearl document, enough to render or query without the engine. The rendering below walks |
| 735 | /// each page's block and its stored outlines through the very `write_path_data` and `presentation` the |
| 736 | /// SVG arm uses, so a rendered page is that arm's output byte for byte. |
| 737 | pub struct PearlDoc { |
| 738 | top: Dat, |
| 739 | } |
| 740 | |
| 741 | impl PearlDoc { |
| 742 | /// Reads a `.prl` file, decoding its text jdat. |
| 743 | pub fn read_file<P: AsRef<std::path::Path>>(path: P) -> Outcome<Self> { |
| 744 | Self::from_string(res!(vfs::read_to_string(path.as_ref()))) |
| 745 | } |
| 746 | |
| 747 | /// Decodes a Pearl document from its text-jdat form, checking the version. |
| 748 | pub fn from_string(s: String) -> Outcome<Self> { |
| 749 | let top = res!(Dat::decode_string(s)); |
| 750 | let ver = res!(top.map_get_string(&dat!("pearl"))); |
| 751 | if ver != PEARL_VERSION { |
| 752 | return Err(err!( |
| 753 | "This reader speaks Pearl v{}, but the file is v{}.", PEARL_VERSION, ver; |
| 754 | Input, Invalid, Mismatch)); |
| 755 | } |
| 756 | Ok(Self { top }) |
| 757 | } |
| 758 | |
| 759 | /// The number of pages in the document's index. |
| 760 | pub fn page_count(&self) -> Outcome<usize> { |
| 761 | Ok(res!(self.top.map_get_list(&dat!("index"))).len()) |
| 762 | } |
| 763 | |
| 764 | /// The media-box size of the page at `idx`, in whole points -- the same viewport |
| 765 | /// [`render_page`](Self::render_page) draws into. A reader lays pages out from these before rendering |
| 766 | /// any, so it need not raster a page merely to learn its size. |
| 767 | pub fn page_size(&self, idx: usize) -> Outcome<(usize, usize)> { |
| 768 | let index = res!(self.top.map_get_list(&dat!("index"))); |
| 769 | let entry = res!(index.get(idx).ok_or_else(|| err!( |
| 770 | "Page index {} is past the {} pages the document holds.", idx, index.len(); Input, Range))); |
| 771 | let block_key = res!(entry.map_get_string(&dat!("block"))); |
| 772 | let blocks = res!(self.top.map_get_must(&dat!("blocks"))); |
| 773 | let block = res!(blocks.map_get_must(&dat!(block_key))); |
| 774 | let geom = res!(block.map_get_list(&dat!("geom"))); |
| 775 | let w = res!(sp_at(geom, 0)).to_pt().round() as usize; |
| 776 | let h = res!(sp_at(geom, 1)).to_pt().round() as usize; |
| 777 | Ok((w, h)) |
| 778 | } |
| 779 | |
| 780 | /// The document's stable identity, or `None` for a `.prl` written without one -- every file that |
| 781 | /// predates the field, and any document a collaboration layer has not stamped. This is the key an |
| 782 | /// edit stream is folded against, and it survives re-pagination where a page number or block address |
| 783 | /// would not; see [`PearlBuilder::with_doc_id`]. |
| 784 | pub fn doc_id(&self) -> Outcome<Option<String>> { |
| 785 | match res!(self.top.map_get(&dat!("doc_id"))) { |
| 786 | Some(d) => Ok(Some(try_extract_dat!(d.clone(), Str))), |
| 787 | None => Ok(None), |
| 788 | } |
| 789 | } |
| 790 | |
| 791 | /// The document's heading outline, each entry resolved through the shipped ledger to the page and y it |
| 792 | /// landed on -- the same ledger lookup [`resolve_link`](Self::resolve_link) follows for a cross-reference, |
| 793 | /// so a table of contents and a link agree about where a heading is. A `.prl` written without an |
| 794 | /// outline (a headless manuscript, or a file that predates the field) returns an empty vector. An |
| 795 | /// entry the ledger never fixed keeps its level, number and title but resolves its location to `None`. |
| 796 | pub fn outline(&self) -> Outcome<Vec<OutlineEntry>> { |
| 797 | let list = match res!(self.top.map_get(&dat!("outline"))) { |
| 798 | Some(d) => try_extract_dat!(d.clone(), List), |
| 799 | None => return Ok(Vec::new()), |
| 800 | }; |
| 801 | let ledger = res!(Ledger::from_dat(res!(self.top.map_get_must(&dat!("ledger"))).clone())); |
| 802 | let mut out = Vec::with_capacity(list.len()); |
| 803 | for entry in &list { |
| 804 | let level = try_extract_dat!(res!(entry.map_get_must(&dat!("level"))).clone(), U8); |
| 805 | let number = res!(entry.map_get_string(&dat!("number"))); |
| 806 | let title = res!(entry.map_get_string(&dat!("title"))); |
| 807 | let id = res!(AnchorId::from_dat(res!(entry.map_get_must(&dat!("anchor"))).clone())); |
| 808 | let (page, y) = match ledger.get(&id) { |
| 809 | Some(a) => (Some(a.pos.page), Some(a.pos.y)), |
| 810 | None => (None, None), |
| 811 | }; |
| 812 | out.push(OutlineEntry { level, number, title, page, y }); |
| 813 | } |
| 814 | Ok(out) |
| 815 | } |
| 816 | |
| 817 | /// Stamps a decoded document with a stable identity, so a reader that opened a `.prl` written |
| 818 | /// without one can mint an identity and write it back through [`to_string`](Self::to_string) or |
| 819 | /// [`write_file`](Self::write_file). An identity already present is overwritten, which is how a |
| 820 | /// document minted twice is reconciled onto a single agreed key. |
| 821 | pub fn set_doc_id<S: Into<String>>(&mut self, id: S) -> Outcome<()> { |
| 822 | res!(self.top.map_put(dat!("doc_id"), dat!(id.into()))); |
| 823 | Ok(()) |
| 824 | } |
| 825 | |
| 826 | /// Renders the page at `idx` (zero-based) to a self-contained SVG document, reconstructing the SVG |
| 827 | /// arm's output from the stored geometry, glyph outlines and paint. A thin wrapper over the shared |
| 828 | /// leaf walk [`render_page_to`](Self::render_page_to), driving an [`SvgSink`]. |
| 829 | pub fn render_page(&self, idx: usize) -> Outcome<String> { |
| 830 | let mut sink = SvgSink::new(); |
| 831 | res!(self.render_page_to(idx, &mut sink)); |
| 832 | Ok(sink.into_string()) |
| 833 | } |
| 834 | |
| 835 | /// The one leaf walk for a page: loads the page's block and stores, then drives every placed leaf |
| 836 | /// through `sink`. The SVG writer and a direct rasteriser share this walk, so a page never needs |
| 837 | /// re-parsing from SVG to reach pixels. See [`PageSink`]. |
| 838 | pub fn render_page_to<S: PageSink>(&self, idx: usize, sink: &mut S) -> Outcome<()> { |
| 839 | let index = res!(self.top.map_get_list(&dat!("index"))); |
| 840 | let entry = res!(index.get(idx).ok_or_else(|| err!( |
| 841 | "Page index {} is past the {} pages the document holds.", idx, index.len(); Input, Range))); |
| 842 | let block_key = res!(entry.map_get_string(&dat!("block"))); |
| 843 | let blocks = res!(self.top.map_get_must(&dat!("blocks"))); |
| 844 | let block = res!(blocks.map_get_must(&dat!(block_key))); |
| 845 | let glyphs = res!(self.top.map_get_must(&dat!("glyphs"))); |
| 846 | let images = res!(self.top.map_get_must(&dat!("images"))); |
| 847 | |
| 848 | // The viewport is the media box: the geometry's width and height rounded to whole points, exactly |
| 849 | // as `PageGeometry::media_box` does. |
| 850 | let geom = res!(block.map_get_list(&dat!("geom"))); |
| 851 | let w = res!(sp_at(geom, 0)).to_pt().round() as usize; |
| 852 | let h = res!(sp_at(geom, 1)).to_pt().round() as usize; |
| 853 | |
| 854 | res!(sink.begin(w, h)); |
| 855 | |
| 856 | // A half-point grey pen for a reservation, matching the SVG arm's `pen`/`grey`. |
| 857 | let pen = res!(Stroke::new(0.5)); |
| 858 | let grey = Rgba::new(176, 176, 176, 255); |
| 859 | |
| 860 | let leaves = res!(block.map_get_list(&dat!("leaves"))); |
| 861 | for leaf in leaves { |
| 862 | let items = try_extract_dat!(leaf.clone(), List); |
| 863 | let tag = try_extract_dat!(res!(items.first().ok_or_else(|| err!( |
| 864 | "An empty leaf carries no kind tag."; Input, Invalid))).clone(), Str); |
| 865 | match tag.as_str() { |
| 866 | "text" => { |
| 867 | let x = res!(sp_at(&items, 1)); |
| 868 | let y = res!(sp_at(&items, 2)); |
| 869 | let height = res!(sp_at(&items, 4)); |
| 870 | let base_x = x.to_pt() as f32; |
| 871 | let base_y = (y + height).to_pt() as f32; |
| 872 | // The fill is an optional key in the leaf's metadata tail (see `text_leaf_meta`); a leaf |
| 873 | // without one is black, the form every pre-colour text leaf took, so an all-black document |
| 874 | // reads back byte-identical. |
| 875 | let meta = res!(text_leaf_meta(&items)); |
| 876 | let colour = match res!(meta.map_get(&dat!("colour"))) { |
| 877 | Some(d) => res!(rgba_from_dat(d)), |
| 878 | None => Rgba::BLACK, |
| 879 | }; |
| 880 | let run = try_extract_dat!(res!(items.get(6).ok_or_else(|| err!( |
| 881 | "A text leaf is missing its glyph list."; Input, Invalid))).clone(), List); |
| 882 | for g in &run { |
| 883 | let gl = try_extract_dat!(g.clone(), List); |
| 884 | let key = try_extract_dat!(res!(gl.first().ok_or_else(|| err!( |
| 885 | "A glyph placement carries no outline key."; Input, Invalid))).clone(), Str); |
| 886 | let gx = res!(f32_at(&gl, 1)); |
| 887 | let gy = res!(f32_at(&gl, 2)); |
| 888 | let entry = res!(glyphs.map_get_must(&dat!(key))); |
| 889 | let d = res!(entry.map_get_string(&dat!("d"))); |
| 890 | let path = res!(path_data(&d)); |
| 891 | if path.is_empty() { |
| 892 | continue; |
| 893 | } |
| 894 | // The stored outline is font-frame, y up; flip in y and move onto the baseline at the |
| 895 | // glyph's offset, exactly as `draw_text` does. |
| 896 | let t = Transform::scale(1.0, -1.0) |
| 897 | .then(&Transform::translate(base_x + gx, base_y - gy)); |
| 898 | let placed = res!(path.transform(&t)); |
| 899 | res!(sink.fill(&placed, colour)); |
| 900 | } |
| 901 | }, |
| 902 | "rule" | "reserved" => { |
| 903 | let x = res!(sp_at(&items, 1)); |
| 904 | let y = res!(sp_at(&items, 2)); |
| 905 | let width = res!(sp_at(&items, 3)); |
| 906 | let height = res!(sp_at(&items, 4)); |
| 907 | let depth = res!(sp_at(&items, 5)); |
| 908 | let x0 = x.to_pt() as f32; |
| 909 | let y0 = y.to_pt() as f32; |
| 910 | let x1 = (x + width).to_pt() as f32; |
| 911 | let y1 = (y + height + depth).to_pt() as f32; |
| 912 | // A zero-area box has nothing to draw, and `Path::rect` would reject it -- the SVG arm |
| 913 | // skips it too, so skipping here keeps the two outputs identical. |
| 914 | if x1 <= x0 || y1 <= y0 { |
| 915 | continue; |
| 916 | } |
| 917 | let path = res!(Path::rect(Bounds::new(x0, y0, x1, y1))); |
| 918 | if tag == "rule" { |
| 919 | res!(sink.fill(&path, Rgba::BLACK)); |
| 920 | } else { |
| 921 | res!(sink.stroke(&path, grey, &pen)); |
| 922 | } |
| 923 | }, |
| 924 | "fill" => { |
| 925 | let bx = res!(sp_at(&items, 1)); |
| 926 | let by = res!(sp_at(&items, 2)); |
| 927 | let d = try_extract_dat!(res!(items.get(3).ok_or_else(|| err!( |
| 928 | "A fill leaf is missing its path."; Input, Invalid))).clone(), Str); |
| 929 | let colour = res!(rgba_from_dat(res!(items.get(4).ok_or_else(|| err!( |
| 930 | "A fill leaf is missing its colour."; Input, Invalid))))); |
| 931 | let t = Transform::translate(bx.to_pt() as f32, by.to_pt() as f32); |
| 932 | let p = res!(res!(path_data(&d)).transform(&t)); |
| 933 | res!(sink.fill(&p, colour)); |
| 934 | }, |
| 935 | "stroke" => { |
| 936 | let bx = res!(sp_at(&items, 1)); |
| 937 | let by = res!(sp_at(&items, 2)); |
| 938 | let d = try_extract_dat!(res!(items.get(3).ok_or_else(|| err!( |
| 939 | "A stroke leaf is missing its path."; Input, Invalid))).clone(), Str); |
| 940 | let colour = res!(rgba_from_dat(res!(items.get(4).ok_or_else(|| err!( |
| 941 | "A stroke leaf is missing its colour."; Input, Invalid))))); |
| 942 | let width = res!(f32_at(&items, 5)); |
| 943 | let stroke = res!(Stroke::new(width)); |
| 944 | let t = Transform::translate(bx.to_pt() as f32, by.to_pt() as f32); |
| 945 | let p = res!(res!(path_data(&d)).transform(&t)); |
| 946 | res!(sink.stroke(&p, colour, &stroke)); |
| 947 | }, |
| 948 | "image" => { |
| 949 | let bx = res!(sp_at(&items, 1)); |
| 950 | let by = res!(sp_at(&items, 2)); |
| 951 | let x = res!(f32_at(&items, 3)); |
| 952 | let y = res!(f32_at(&items, 4)); |
| 953 | let iw = res!(f32_at(&items, 5)); |
| 954 | let ih = res!(f32_at(&items, 6)); |
| 955 | let key = try_extract_dat!(res!(items.get(7).ok_or_else(|| err!( |
| 956 | "An image leaf is missing its raster key."; Input, Invalid))).clone(), Str); |
| 957 | let entry = res!(images.map_get_must(&dat!(key))); |
| 958 | let b64 = res!(entry.map_get_string(&dat!("png"))); |
| 959 | let ox = bx.to_pt() as f32; |
| 960 | let oy = by.to_pt() as f32; |
| 961 | res!(sink.image(&b64, ox + x, oy + y, iw, ih)); |
| 962 | }, |
| 963 | // A link leaf places no ink: the SVG arm draws no clickable annotation, so rendering skips it and |
| 964 | // the page stays byte-identical to that arm's output. A reader that wants the links reads them |
| 965 | // with `PearlDoc::links_on_page`. |
| 966 | "link" => {}, |
| 967 | other => return Err(err!( |
| 968 | "'{}' is not a Pearl v1 leaf kind.", other; Input, Invalid)), |
| 969 | } |
| 970 | } |
| 971 | |
| 972 | // A second pass collects every text leaf's selectable spans, in placement order, and hands them to |
| 973 | // the sink as the page's selectable-text layer. The SVG sink turns them into one page-wide `.tsel` |
| 974 | // `<text>`; a visual sink ignores them (the ink is already placed above). The runs carry structured |
| 975 | // span data, not markup, so no sink but the SVG one ever handles a tspan. |
| 976 | let mut runs: Vec<TselRun> = Vec::new(); |
| 977 | for leaf in leaves { |
| 978 | let items = try_extract_dat!(leaf.clone(), List); |
| 979 | let tag = try_extract_dat!(res!(items.first().ok_or_else(|| err!( |
| 980 | "An empty leaf carries no kind tag."; Input, Invalid))).clone(), Str); |
| 981 | if tag != "text" { |
| 982 | continue; |
| 983 | } |
| 984 | let x = res!(sp_at(&items, 1)); |
| 985 | let y = res!(sp_at(&items, 2)); |
| 986 | let height = res!(sp_at(&items, 4)); |
| 987 | let base_x = x.to_pt() as f32; |
| 988 | let base_y = (y + height).to_pt() as f32; |
| 989 | let meta = res!(text_leaf_meta(&items)); |
| 990 | let size_dat = res!(meta.map_get_must(&dat!("size"))); |
| 991 | let size = res!(f32_from(size_dat)); |
| 992 | let spans = try_extract_dat!(res!(meta.map_get_must(&dat!("spans"))).clone(), List); |
| 993 | |
| 994 | let mut out_spans: Vec<TselSpan> = Vec::new(); |
| 995 | for s in &spans { |
| 996 | let sl = try_extract_dat!(s.clone(), List); |
| 997 | let gx = res!(f32_at(&sl, 0)); |
| 998 | let gy = res!(f32_at(&sl, 1)); |
| 999 | let text = try_extract_dat!(res!(sl.get(2).ok_or_else(|| err!( |
| 1000 | "A selectable span is missing its text."; Input, Invalid))).clone(), Str); |
| 1001 | out_spans.push(TselSpan { gx, gy, text }); |
| 1002 | } |
| 1003 | runs.push(TselRun { base_x, base_y, size, spans: out_spans }); |
| 1004 | } |
| 1005 | res!(sink.text_layer(&runs)); |
| 1006 | |
| 1007 | res!(sink.end()); |
| 1008 | Ok(()) |
| 1009 | } |
| 1010 | |
| 1011 | /// The links on the page at `idx` (zero-based): each `link` leaf's rectangle and target, in the order |
| 1012 | /// they were emitted. A page with no links returns an empty vector. |
| 1013 | pub fn links_on_page(&self, idx: usize) -> Outcome<Vec<PearlLink>> { |
| 1014 | let index = res!(self.top.map_get_list(&dat!("index"))); |
| 1015 | let entry = res!(index.get(idx).ok_or_else(|| err!( |
| 1016 | "Page index {} is past the {} pages the document holds.", idx, index.len(); Input, Range))); |
| 1017 | let block_key = res!(entry.map_get_string(&dat!("block"))); |
| 1018 | let blocks = res!(self.top.map_get_must(&dat!("blocks"))); |
| 1019 | let block = res!(blocks.map_get_must(&dat!(block_key))); |
| 1020 | let leaves = res!(block.map_get_list(&dat!("leaves"))); |
| 1021 | let mut out = Vec::new(); |
| 1022 | for leaf in leaves { |
| 1023 | let items = try_extract_dat!(leaf.clone(), List); |
| 1024 | let tag = try_extract_dat!(res!(items.first().ok_or_else(|| err!( |
| 1025 | "An empty leaf carries no kind tag."; Input, Invalid))).clone(), Str); |
| 1026 | if tag != "link" { |
| 1027 | continue; |
| 1028 | } |
| 1029 | let target = res!(link_target_from_dat(res!(items.get(5).ok_or_else(|| err!( |
| 1030 | "A link leaf is missing its target."; Input, Invalid))))); |
| 1031 | out.push(PearlLink { |
| 1032 | x: sp_at(&items, 1)?, |
| 1033 | y: sp_at(&items, 2)?, |
| 1034 | w: sp_at(&items, 3)?, |
| 1035 | h: sp_at(&items, 4)?, |
| 1036 | target, |
| 1037 | }); |
| 1038 | } |
| 1039 | Ok(out) |
| 1040 | } |
| 1041 | |
| 1042 | /// Resolves a link's destination: an external target stands as its address; an internal anchor is |
| 1043 | /// resolved through the shipped ledger to the page it landed on, then through the index to that page's |
| 1044 | /// content-addressed block. `None` when the ledger has not fixed the anchor, or no page in the index |
| 1045 | /// carries it -- a dangling cross-reference the caller reports rather than follows. |
| 1046 | pub fn resolve_link(&self, target: &LinkTarget) -> Outcome<Option<LinkResolution>> { |
| 1047 | match target { |
| 1048 | LinkTarget::Uri(uri) => Ok(Some(LinkResolution::Uri(uri.clone()))), |
| 1049 | LinkTarget::Anchor(id) => { |
| 1050 | let ledger = res!(Ledger::from_dat(res!(self.top.map_get_must(&dat!("ledger"))).clone())); |
| 1051 | let page = match ledger.page_of(id) { |
| 1052 | Some(p) => p, |
| 1053 | None => return Ok(None), // the ledger has not fixed this anchor |
| 1054 | }; |
| 1055 | let index = res!(self.top.map_get_list(&dat!("index"))); |
| 1056 | for entry in index { |
| 1057 | if try_extract_dat!(res!(entry.map_get_must(&dat!("page"))).clone(), U32) == page { |
| 1058 | let block = res!(entry.map_get_string(&dat!("block"))); |
| 1059 | return Ok(Some(LinkResolution::Block { block, page })); |
| 1060 | } |
| 1061 | } |
| 1062 | Ok(None) // the anchor's page is not one the index holds |
| 1063 | }, |
| 1064 | } |
| 1065 | } |
| 1066 | |
| 1067 | /// The content addresses of the document's page blocks, in page order, read from the index. These are |
| 1068 | /// the stable identities an annotation anchors to. |
| 1069 | pub fn block_hashes(&self) -> Outcome<Vec<String>> { |
| 1070 | let index = res!(self.top.map_get_list(&dat!("index"))); |
| 1071 | let mut out = Vec::with_capacity(index.len()); |
| 1072 | for entry in index { |
| 1073 | out.push(res!(entry.map_get_string(&dat!("block")))); |
| 1074 | } |
| 1075 | Ok(out) |
| 1076 | } |
| 1077 | |
| 1078 | /// Is `hash` the address of a block the document holds? An annotation whose anchor answers false is |
| 1079 | /// orphaned -- its content is gone from the file. |
| 1080 | pub fn has_block(&self, hash: &str) -> Outcome<bool> { |
| 1081 | let blocks = res!(self.top.map_get_must(&dat!("blocks"))); |
| 1082 | Ok(res!(blocks.map_get(&dat!(hash))).is_some()) |
| 1083 | } |
| 1084 | |
| 1085 | /// The annotations carried in the document, in the order they were added. A file written before the |
| 1086 | /// annotations section existed, or one with an empty section, returns an empty vector. |
| 1087 | pub fn annotations(&self) -> Outcome<Vec<Annotation>> { |
| 1088 | let list = match self.top.map_get(&dat!("annotations")) { |
| 1089 | Ok(Some(d)) => try_extract_dat!(d.clone(), List), |
| 1090 | _ => return Ok(Vec::new()), |
| 1091 | }; |
| 1092 | let mut out = Vec::with_capacity(list.len()); |
| 1093 | for d in list { |
| 1094 | out.push(res!(Annotation::from_dat(d))); |
| 1095 | } |
| 1096 | Ok(out) |
| 1097 | } |
| 1098 | |
| 1099 | /// Attaches an annotation, appending it to the `annotations` section. The anchor must address a block |
| 1100 | /// the document holds, so an annotation cannot be attached to content that is not here; the rectangle, |
| 1101 | /// if any, is a region within that block. The change lives in memory until [`to_string`](Self::to_string) |
| 1102 | /// or [`write_file`](Self::write_file) writes the document back. |
| 1103 | pub fn add_annotation(&mut self, ann: Annotation) -> Outcome<()> { |
| 1104 | if !res!(self.has_block(&ann.anchor)) { |
| 1105 | return Err(err!( |
| 1106 | "Annotation anchor block {} is not in the document, so nothing to attach it to.", |
| 1107 | ann.anchor; Input, Invalid, Missing)); |
| 1108 | } |
| 1109 | let mut list = match res!(self.top.map_get(&dat!("annotations"))) { |
| 1110 | Some(d) => try_extract_dat!(d.clone(), List), |
| 1111 | None => Vec::new(), |
| 1112 | }; |
| 1113 | list.push(res!(ann.to_dat())); |
| 1114 | res!(self.top.map_put(dat!("annotations"), Dat::List(list))); |
| 1115 | Ok(()) |
| 1116 | } |
| 1117 | |
| 1118 | /// The document re-encoded as text jdat, carrying every later change -- added annotations included. |
| 1119 | pub fn to_string(&self) -> Outcome<String> { |
| 1120 | encode(&self.top) |
| 1121 | } |
| 1122 | |
| 1123 | /// Writes the document back to `path` as text jdat, carrying every later change. |
| 1124 | pub fn write_file<P: AsRef<std::path::Path>>(&self, path: P) -> Outcome<()> { |
| 1125 | res!(vfs::write(path.as_ref(), res!(self.to_string()).as_bytes())); |
| 1126 | Ok(()) |
| 1127 | } |
| 1128 | } |
| 1129 | |
| 1130 | /// The scaled length at `i` in a list, decoded through the same [`Sp::from_dat`] the emit used. |
| 1131 | fn sp_at(list: &[Dat], i: usize) -> Outcome<Sp> { |
| 1132 | let d = res!(list.get(i).ok_or_else(|| err!( |
| 1133 | "A leaf is missing its scaled length at position {}.", i; Input, Invalid))); |
| 1134 | Sp::from_dat(d.clone()) |
| 1135 | } |
| 1136 | |
| 1137 | /// The `f32` at `i` in a list. |
| 1138 | fn f32_at(list: &[Dat], i: usize) -> Outcome<f32> { |
| 1139 | let d = res!(list.get(i).ok_or_else(|| err!( |
| 1140 | "A leaf is missing its float at position {}.", i; Input, Invalid))); |
| 1141 | Ok(try_extract_dat!(d.clone(), F32).0) |
| 1142 | } |
| 1143 | |
| 1144 | /// A `Dat::F32` unwrapped to its `f32`, for a value already fetched (typically out of a keyed map, |
| 1145 | /// where [`f32_at`]'s list-and-index reading does not apply). |
| 1146 | fn f32_from(d: &Dat) -> Outcome<f32> { |
| 1147 | Ok(try_extract_dat!(d.clone(), F32).0) |
| 1148 | } |
| 1149 | |
| 1150 | /// A `text` leaf's self-describing tail (position 7, past the tag, geometry and outline glyphs): a |
| 1151 | /// keyed map carrying `size`, `spans` and the optional `colour`. Introduced in v1 so a future field |
| 1152 | /// joins this map instead of another positional append -- the fault v0 had, where inserting `size` and |
| 1153 | /// `spans` ahead of the already-optional `colour` silently shifted its index and broke every reader that |
| 1154 | /// still expected the old position. |
| 1155 | fn text_leaf_meta(items: &[Dat]) -> Outcome<&Dat> { |
| 1156 | items.get(7).ok_or_else(|| err!( |
| 1157 | "A text leaf is missing its metadata map."; Input, Invalid)) |
| 1158 | } |
| 1159 | |
| 1160 | #[cfg(test)] |
| 1161 | mod tests { |
| 1162 | use super::*; |
| 1163 | |
| 1164 | use crate::emit::svg; |
| 1165 | use crate::font::ShapedText; |
| 1166 | use crate::ir::{ |
| 1167 | Dims, |
| 1168 | DrawOp, |
| 1169 | Graphic, |
| 1170 | LinkTarget, |
| 1171 | }; |
| 1172 | use crate::ledger::{ |
| 1173 | Anchor, |
| 1174 | AnchorId, |
| 1175 | AnchorKind, |
| 1176 | Ledger, |
| 1177 | Position, |
| 1178 | }; |
| 1179 | use crate::page::{ |
| 1180 | Frame, |
| 1181 | Page, |
| 1182 | PageGeometry, |
| 1183 | Placed, |
| 1184 | PlacedKind, |
| 1185 | }; |
| 1186 | |
| 1187 | use std::sync::Arc; |
| 1188 | |
| 1189 | use oxedyne_fe2o3_font::{ |
| 1190 | face::Role, |
| 1191 | shape::Dir, |
| 1192 | }; |
| 1193 | use oxedyne_fe2o3_graphics::{ |
| 1194 | colour::Rgba, |
| 1195 | path::{ |
| 1196 | Bounds, |
| 1197 | Path, |
| 1198 | }, |
| 1199 | }; |
| 1200 | |
| 1201 | // One page carrying every leaf kind the SVG arm draws bar a raster -- a shaped text run, a rule, and a |
| 1202 | // figure of one fill and one stroke -- rendered both by the SVG arm and by a Pearl round trip, must |
| 1203 | // come out byte for byte the same. This is the keystone claim in miniature, without the CLI or files. |
| 1204 | #[test] |
| 1205 | fn test_pearl_round_trips_to_the_svg_arm_00() -> Outcome<()> { |
| 1206 | let fonts = Arc::new(res!(crate::fonts::libertinus())); |
| 1207 | let geom = PageGeometry::a4(); |
| 1208 | |
| 1209 | let mut frame = Frame::new(); |
| 1210 | |
| 1211 | // A shaped run of real text, placed like a line of body copy. |
| 1212 | let shaped = res!(ShapedText::new(fonts, Role::Body, Dir::Ltr, Sp::from_pt(11.0), "Pearl round trip.")); |
| 1213 | let tdims = shaped.dims(); |
| 1214 | frame.push(Placed::new(Sp::from_pt(60.0), Sp::from_pt(80.0), tdims, PlacedKind::Text(shaped))); |
| 1215 | |
| 1216 | // A rule: a thin filled rectangle. |
| 1217 | let rule_dims = Dims::new(Sp::from_pt(120.0), Sp::from_pt(0.6), Sp::ZERO); |
| 1218 | frame.push(Placed::new(Sp::from_pt(60.0), Sp::from_pt(100.0), rule_dims, PlacedKind::Rule)); |
| 1219 | |
| 1220 | // A figure of one filled and one stroked path, in the graphic's own frame. |
| 1221 | let fill = res!(Path::rect(Bounds::new(0.0, 0.0, 40.0, 30.0))); |
| 1222 | let stroke = res!(Path::rect(Bounds::new(5.0, 5.0, 35.0, 25.0))); |
| 1223 | let ops = vec![ |
| 1224 | DrawOp::Fill { path: fill, colour: Rgba::new(233, 236, 239, 255) }, |
| 1225 | DrawOp::Stroke { path: stroke, colour: Rgba::BLACK, width: 1.0 }, |
| 1226 | ]; |
| 1227 | let graphic = Graphic::new(ops, Dims::new(Sp::from_pt(40.0), Sp::from_pt(30.0), Sp::ZERO)); |
| 1228 | frame.push(Placed::new( |
| 1229 | Sp::from_pt(200.0), Sp::from_pt(120.0), graphic.dims, PlacedKind::Graphic(Arc::new(graphic)))); |
| 1230 | |
| 1231 | let page = Page::new(1, geom, frame); |
| 1232 | |
| 1233 | // The reference: what the SVG arm writes for this page. |
| 1234 | let want = res!(svg::render_page(&page)); |
| 1235 | |
| 1236 | // The round trip: emit to Pearl, encode, decode, render back. |
| 1237 | let mut builder = res!(PearlBuilder::new(&Ledger::new(), geom)); |
| 1238 | res!(builder.add_page(&page)); |
| 1239 | let encoded = res!(builder.to_string()); |
| 1240 | let doc = res!(PearlDoc::from_string(encoded)); |
| 1241 | assert_eq!(res!(doc.page_count()), 1); |
| 1242 | let got = res!(doc.render_page(0)); |
| 1243 | |
| 1244 | assert_eq!(want, got, "the Pearl round trip did not reproduce the SVG arm's page byte for byte"); |
| 1245 | Ok(()) |
| 1246 | } |
| 1247 | |
| 1248 | // A colour survives the list encoding it is stored in. |
| 1249 | #[test] |
| 1250 | fn test_a_colour_round_trips_through_its_leaf_form_01() -> Outcome<()> { |
| 1251 | let c = Rgba::new(128, 0, 200, 64); |
| 1252 | assert_eq!(c, res!(rgba_from_dat(&rgba_to_dat(c)))); |
| 1253 | Ok(()) |
| 1254 | } |
| 1255 | |
| 1256 | /// A one-op figure, enough to place as a linked graphic without a font. |
| 1257 | fn dot_graphic(link: Option<LinkTarget>) -> Outcome<Graphic> { |
| 1258 | let fill = res!(Path::rect(Bounds::new(0.0, 0.0, 20.0, 20.0))); |
| 1259 | let mut g = Graphic::new( |
| 1260 | vec![DrawOp::Fill { path: fill, colour: Rgba::BLACK }], |
| 1261 | Dims::new(Sp::from_pt(20.0), Sp::from_pt(20.0), Sp::ZERO)); |
| 1262 | g.link = link; |
| 1263 | Ok(g) |
| 1264 | } |
| 1265 | |
| 1266 | // A document with an external `#link` and an internal `@ref` carries both into the `.prl` as `link` |
| 1267 | // leaves, and the reader reads them back: the external one stands as its URI, and the internal one |
| 1268 | // resolves -- through the shipped ledger and index -- to the content-addressed block of the page its |
| 1269 | // anchor landed on, not to a raw page number. |
| 1270 | #[test] |
| 1271 | fn test_links_round_trip_internal_and_external_02() -> Outcome<()> { |
| 1272 | let geom = PageGeometry::a4(); |
| 1273 | let anchor = AnchorId::new(AnchorKind::Label, "sec:intro"); |
| 1274 | |
| 1275 | // Page 1 carries the two links; page 2 is where the internal anchor resolves. |
| 1276 | let mut frame1 = Frame::new(); |
| 1277 | let ext = res!(dot_graphic(Some(LinkTarget::Uri("https://oxedyne.com".to_string())))); |
| 1278 | frame1.push(Placed::new( |
| 1279 | Sp::from_pt(50.0), Sp::from_pt(50.0), ext.dims, PlacedKind::Graphic(Arc::new(ext)))); |
| 1280 | let int = res!(dot_graphic(Some(LinkTarget::Anchor(anchor.clone())))); |
| 1281 | frame1.push(Placed::new( |
| 1282 | Sp::from_pt(50.0), Sp::from_pt(120.0), int.dims, PlacedKind::Graphic(Arc::new(int)))); |
| 1283 | let page1 = Page::new(1, geom, frame1); |
| 1284 | |
| 1285 | let mut frame2 = Frame::new(); |
| 1286 | let target = res!(dot_graphic(None)); |
| 1287 | frame2.push(Placed::new( |
| 1288 | Sp::from_pt(60.0), Sp::from_pt(60.0), target.dims, PlacedKind::Graphic(Arc::new(target)))); |
| 1289 | let page2 = Page::new(2, geom, frame2); |
| 1290 | |
| 1291 | // The ledger fixes the anchor on page 2, as a composition pass would. |
| 1292 | let mut ledger = Ledger::new(); |
| 1293 | ledger.record(Anchor::new(anchor.clone(), Position::new(2, Sp::ZERO, Sp::ZERO))); |
| 1294 | |
| 1295 | let mut builder = res!(PearlBuilder::new(&ledger, geom)); |
| 1296 | res!(builder.add_page(&page1)); |
| 1297 | res!(builder.add_page(&page2)); |
| 1298 | let doc = res!(PearlDoc::from_string(res!(builder.to_string()))); |
| 1299 | |
| 1300 | let links = res!(doc.links_on_page(0)); |
| 1301 | assert_eq!(links.len(), 2, "both links are carried onto page 1"); |
| 1302 | assert!(res!(doc.links_on_page(1)).is_empty(), "page 2 carries no links"); |
| 1303 | |
| 1304 | // The external link stands as its address. |
| 1305 | let ext_link = res!(links.iter().find(|l| matches!(l.target, LinkTarget::Uri(_))) |
| 1306 | .ok_or_else(|| err!("the external link is missing"; Test))); |
| 1307 | assert_eq!( |
| 1308 | res!(doc.resolve_link(&ext_link.target)), |
| 1309 | Some(LinkResolution::Uri("https://oxedyne.com".to_string()))); |
| 1310 | |
| 1311 | // The internal link resolves to page 2's block, not to the number 2. |
| 1312 | let int_link = res!(links.iter().find(|l| matches!(l.target, LinkTarget::Anchor(_))) |
| 1313 | .ok_or_else(|| err!("the internal link is missing"; Test))); |
| 1314 | let want_block = res!(doc.block_hashes())[1].clone(); |
| 1315 | assert_eq!( |
| 1316 | res!(doc.resolve_link(&int_link.target)), |
| 1317 | Some(LinkResolution::Block { block: want_block, page: 2 })); |
| 1318 | Ok(()) |
| 1319 | } |
| 1320 | |
| 1321 | // Annotations anchor to content-addressed block hashes, read back with their fields intact, and survive |
| 1322 | // a re-emit of the document -- the point being that the anchor is the block's identity, so an annotation |
| 1323 | // stays attached across a rewrite the way it would across a repagination. |
| 1324 | #[test] |
| 1325 | fn test_annotations_anchor_to_blocks_and_survive_re_emit_03() -> Outcome<()> { |
| 1326 | let geom = PageGeometry::a4(); |
| 1327 | |
| 1328 | // A two-page document, so there are two real block hashes to anchor to. |
| 1329 | let mut b = res!(PearlBuilder::new(&Ledger::new(), geom)); |
| 1330 | for n in 1..=2u32 { |
| 1331 | let mut frame = Frame::new(); |
| 1332 | let g = res!(dot_graphic(None)); |
| 1333 | frame.push(Placed::new( |
| 1334 | Sp::from_pt(40.0), Sp::from_pt(40.0), g.dims, PlacedKind::Graphic(Arc::new(g)))); |
| 1335 | res!(b.add_page(&Page::new(n, geom, frame))); |
| 1336 | } |
| 1337 | let mut doc = res!(PearlDoc::from_string(res!(b.to_string()))); |
| 1338 | |
| 1339 | // Emit is empty by default. |
| 1340 | assert!(res!(doc.annotations()).is_empty(), "the engine authors no annotations"); |
| 1341 | |
| 1342 | let hashes = res!(doc.block_hashes()); |
| 1343 | assert_eq!(hashes.len(), 2); |
| 1344 | res!(doc.add_annotation(Annotation::new( |
| 1345 | hashes[0].as_str(), AnnotationKind::Highlight, "the opening claim", "jason", |
| 1346 | "2026-09-17T10:00:00Z").with_rect( |
| 1347 | Sp::from_pt(40.0), Sp::from_pt(40.0), Sp::from_pt(120.0), Sp::from_pt(12.0)))); |
| 1348 | res!(doc.add_annotation(Annotation::new( |
| 1349 | hashes[1].as_str(), AnnotationKind::Note, "check this figure", "jason", |
| 1350 | "2026-09-17T11:00:00Z"))); |
| 1351 | |
| 1352 | // An annotation cannot attach to a block the document does not hold. |
| 1353 | assert!(doc.add_annotation(Annotation::new( |
| 1354 | "0000000000000000", AnnotationKind::Note, "orphan", "jason", "2026-09-17T12:00:00Z")).is_err(), |
| 1355 | "attaching to an absent block is refused"); |
| 1356 | |
| 1357 | // Re-emit and re-read: the annotations survive, resolve to the right blocks, and keep their fields. |
| 1358 | let doc2 = res!(PearlDoc::from_string(res!(doc.to_string()))); |
| 1359 | let anns = res!(doc2.annotations()); |
| 1360 | assert_eq!(anns.len(), 2, "both annotations survive the re-emit"); |
| 1361 | assert_eq!(anns[0].anchor, hashes[0], "the first annotation still names page 1's block"); |
| 1362 | assert!(res!(doc2.has_block(&anns[0].anchor)), "and that block is still in the document"); |
| 1363 | assert_eq!(anns[0].kind, AnnotationKind::Highlight); |
| 1364 | assert_eq!(anns[0].rect, Some(( |
| 1365 | Sp::from_pt(40.0), Sp::from_pt(40.0), Sp::from_pt(120.0), Sp::from_pt(12.0)))); |
| 1366 | assert_eq!(anns[1].anchor, hashes[1], "the second annotation still names page 2's block"); |
| 1367 | assert_eq!(anns[1].kind, AnnotationKind::Note); |
| 1368 | assert_eq!(anns[1].payload, "check this figure"); |
| 1369 | assert_eq!(anns[1].rect, None, "a whole-block annotation carries no rect"); |
| 1370 | |
| 1371 | // A second re-emit keeps them still, so the section is stable under repeated rewrites. |
| 1372 | let doc3 = res!(PearlDoc::from_string(res!(doc2.to_string()))); |
| 1373 | assert_eq!(res!(doc3.annotations()).len(), 2, "annotations persist across a second re-emit"); |
| 1374 | Ok(()) |
| 1375 | } |
| 1376 | |
| 1377 | // A document given a doc_id carries it through the encode/decode round trip, and a document given |
| 1378 | // none reports none and writes no `doc_id` key at all -- so the bytes of a doc without one are |
| 1379 | // exactly what they were before the field existed, which is what the `.prl`/`.tsel` oracle depends |
| 1380 | // on. Two builders over the same page, one stamped and one not, must agree byte for byte once the |
| 1381 | // stamped one's single added key is discounted; the plain one must be unchanged. |
| 1382 | #[test] |
| 1383 | fn test_doc_id_round_trips_and_is_absent_by_default_05() -> Outcome<()> { |
| 1384 | let geom = PageGeometry::a4(); |
| 1385 | |
| 1386 | // A one-page document, built once with a doc_id and once without. |
| 1387 | let build = |id: Option<&str>| -> Outcome<String> { |
| 1388 | let mut frame = Frame::new(); |
| 1389 | let g = res!(dot_graphic(None)); |
| 1390 | frame.push(Placed::new( |
| 1391 | Sp::from_pt(40.0), Sp::from_pt(40.0), g.dims, PlacedKind::Graphic(Arc::new(g)))); |
| 1392 | let mut b = res!(PearlBuilder::new(&Ledger::new(), geom)); |
| 1393 | if let Some(id) = id { |
| 1394 | b = b.with_doc_id(id); |
| 1395 | } |
| 1396 | res!(b.add_page(&Page::new(1, geom, frame))); |
| 1397 | b.to_string() |
| 1398 | }; |
| 1399 | |
| 1400 | // A doc without a doc_id writes no such key, and reads back as None. |
| 1401 | let plain = res!(build(None)); |
| 1402 | assert!(!plain.contains("doc_id"), "a document with no doc_id must emit no doc_id key"); |
| 1403 | let plain_doc = res!(PearlDoc::from_string(plain.clone())); |
| 1404 | assert_eq!(res!(plain_doc.doc_id()), None, "a document with no doc_id reads back None"); |
| 1405 | |
| 1406 | // A doc with a doc_id carries it verbatim through the round trip. |
| 1407 | let stamped = res!(build(Some("prl-abc123"))); |
| 1408 | let stamped_doc = res!(PearlDoc::from_string(stamped)); |
| 1409 | assert_eq!(res!(stamped_doc.doc_id()), Some("prl-abc123".to_string()), |
| 1410 | "a document with a doc_id round-trips it"); |
| 1411 | // And that stamped document still reads as a document: the added key disturbs nothing else. |
| 1412 | assert_eq!(res!(stamped_doc.page_count()), 1); |
| 1413 | |
| 1414 | // A reader can stamp a doc that arrived without one, and it then round-trips. |
| 1415 | let mut minted = res!(PearlDoc::from_string(plain)); |
| 1416 | assert_eq!(res!(minted.doc_id()), None); |
| 1417 | res!(minted.set_doc_id("prl-minted")); |
| 1418 | let reread = res!(PearlDoc::from_string(res!(minted.to_string()))); |
| 1419 | assert_eq!(res!(reread.doc_id()), Some("prl-minted".to_string()), |
| 1420 | "a minted doc_id survives a write-back and re-read"); |
| 1421 | Ok(()) |
| 1422 | } |
| 1423 | |
| 1424 | /// Reads a real, checked-in `.prl` -- one of the five `web/pearl-reader/samples/*.prl` the web reader |
| 1425 | /// serves -- through the actual file-reading path (`PearlDoc::read_file`, the same call |
| 1426 | /// `pearl_render` makes), not just the in-memory encode/decode `test_pearl_round_trips_...` above |
| 1427 | /// exercises. That in-memory test alone is exactly what missed the v0 -> v1 split-brain: a positional |
| 1428 | /// leaf-shape change can leave two in-process round trips agreeing with each other while a real file |
| 1429 | /// written by an older or newer build is unreadable or misparsed. Reading one of the samples this |
| 1430 | /// crate ships closes that gap; regenerate them (see the reader's README) whenever the leaf shape |
| 1431 | /// changes, and this test fails loudly if a regeneration is forgotten. |
| 1432 | /// |
| 1433 | /// `demo.prl` alone is not enough: it is `pearl_demo_gen`'s hand-built frame, not real driver output, |
| 1434 | /// so a genuine emit regression (the cap-height/baseline block-edge model landing after these samples |
| 1435 | /// were first checked in is exactly the shape of one -- see `linebreak.rs`) could move every engine |
| 1436 | /// sample's glyph placement while this test kept passing against the one sample no engine ever wrote. |
| 1437 | /// `keystone.prl` -- `samples/keystone.typ` compiled with `--pearl`, one real prose-and-diagram page |
| 1438 | /// -- closes that gap. |
| 1439 | #[test] |
| 1440 | fn test_a_checked_in_sample_reads_and_renders_04() -> Outcome<()> { |
| 1441 | let path = concat!(env!("CARGO_MANIFEST_DIR"), "/web/pearl-reader/samples/demo.prl"); |
| 1442 | let doc = res!(PearlDoc::read_file(path)); |
| 1443 | assert_eq!(res!(doc.page_count()), 2, "demo.prl is a two-page fixture"); |
| 1444 | for idx in 0..2 { |
| 1445 | let svg = res!(doc.render_page(idx)); |
| 1446 | assert!(svg.starts_with("<svg "), "page {} did not render as an SVG document", idx); |
| 1447 | assert!(svg.contains("class=\"tsel\""), "page {} carries no selectable text layer", idx); |
| 1448 | assert!(svg.ends_with("</svg>\n"), "page {} is not a well-formed, closed SVG document", idx); |
| 1449 | } |
| 1450 | |
| 1451 | let engine_path = concat!(env!("CARGO_MANIFEST_DIR"), "/web/pearl-reader/samples/keystone.prl"); |
| 1452 | let engine_doc = res!(PearlDoc::read_file(engine_path)); |
| 1453 | assert_eq!(res!(engine_doc.page_count()), 1, "keystone.prl is a one-page fixture"); |
| 1454 | let svg = res!(engine_doc.render_page(0)); |
| 1455 | assert!(svg.starts_with("<svg "), "keystone page 0 did not render as an SVG document"); |
| 1456 | assert!(svg.contains("class=\"tsel\""), "keystone page 0 carries no selectable text layer"); |
| 1457 | assert!(svg.ends_with("</svg>\n"), "keystone page 0 is not a well-formed, closed SVG document"); |
| 1458 | Ok(()) |
| 1459 | } |
| 1460 | } |