Oregami
Repositories/oxedyne/fe2o3

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
26use crate::doc::Heading;
27use crate::ir::{
28 DrawOp,
29 LinkTarget,
30 Sp,
31};
32use crate::ledger::{
33 AnchorId,
34 Ledger,
35};
36use crate::page::{
37 Page,
38 PageGeometry,
39 PlacedKind,
40};
41use crate::vfs;
42
43use std::collections::BTreeMap;
44
45use oxedyne_fe2o3_core::prelude::*;
46use oxedyne_fe2o3_jdat::prelude::*;
47use 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};
62use oxedyne_fe2o3_text::base64;
63use 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.
70pub 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.
75fn 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.
85fn 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.
90pub 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.
107fn 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.
115fn 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)]
139pub 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)]
150pub 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)]
161pub 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)]
173pub 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
178impl 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)]
201pub 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
211impl 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
246impl 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
272impl 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.
308pub 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
319impl 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.
569fn 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.
581fn 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.
593fn 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`].
604pub 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.
614pub 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.
626pub 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.
644pub struct SvgSink {
645 out: String,
646}
647
648impl 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
658impl Default for SvgSink {
659 fn default() -> Self {
660 Self::new()
661 }
662}
663
664impl 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.
737pub struct PearlDoc {
738 top: Dat,
739}
740
741impl 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.
1131fn 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.
1138fn 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).
1146fn 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.
1155fn 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)]
1161mod 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}