Skip to main content

fleuron/
wire.rs

1//! The wire: a display structure as bytes, with a version in front of it.
2//!
3//! JSON is what the content tree serializes to, because a person
4//! reads it. The display structure is machine output: every glyph of
5//! every page, produced once per keystroke and decoded on someone's
6//! main thread. So it crosses as [postcard], which packs varints,
7//! sends no field names, and needs no tree of maps built before the
8//! first page can be read.
9//!
10//! The encoding is positional, which is the price of that: a host's
11//! decoder reads fields in declaration order and has no way to notice
12//! that the order changed. So a version leads the bytes, [`VERSION`]
13//! moves whenever what crosses changes shape, and a host that reads
14//! a number it does not know refuses at the first byte instead of
15//! painting nonsense.
16//!
17//! A reply need not carry the whole book: [`encode_range`] sends a
18//! slice of the pages and says where it falls, so a host looking at
19//! one page pays for one page. Layout still runs over the whole book
20//! either way; what a range saves is serializing and decoding the
21//! pages nobody asked for. `fonts`, `assets` and `warnings` are never
22//! sliced, since none of them is per page.
23//!
24//! An EPUB crosses the same wall behind the same version, with the
25//! warnings writing it raised: see [`encode_epub`]. Its files can
26//! cross one by one instead: see [`encode_epub_files`].
27//!
28//! [postcard]: https://postcard.jamesmunns.com/
29
30use crate::content::NodeId;
31use crate::fonts::FontRefEntry;
32use crate::images::Asset;
33use crate::pages::Page;
34use crate::{LayoutOutput, Warning};
35
36/// What the encoding is. A host checks this before reading anything
37/// else, and a mismatch is a refusal rather than a best effort.
38pub const VERSION: u16 = 19;
39
40/// Why a buffer could not be read as a display structure.
41#[derive(Debug, thiserror::Error)]
42pub enum WireError {
43    /// The bytes were written by a build that disagrees about the
44    /// shape of the display structure.
45    #[error("wire version {found}, expected {VERSION}")]
46    Version {
47        /// The version the buffer leads with.
48        found: u16,
49    },
50    /// The bytes are not a display structure at all, or are truncated.
51    #[error("the wire could not be read: {0}")]
52    Malformed(#[from] postcard::Error),
53}
54
55/// What one wire reply carried, and where it falls in the book.
56///
57/// Distinct from [`LayoutOutput`], whose `pages` is always the whole
58/// run's: a reply's `pages` is a slice, `first` is where that slice
59/// begins (counting from 0), and `book_pages` is how many pages the
60/// book has, so a reply carrying page 12 alone still answers "page 12
61/// of 337".
62#[derive(Debug, PartialEq, serde::Serialize, serde::Deserialize)]
63pub struct Reply {
64    /// The pages this reply carries, in reading order.
65    pub pages: Vec<Page>,
66    /// The index of `pages[0]` in the book. Zero for a whole-book reply.
67    pub first: usize,
68    /// How many pages the book has, which is more than `pages.len()`
69    /// for anything short of a whole-book reply.
70    pub book_pages: usize,
71    /// The whole run's font table, unsliced.
72    pub fonts: Vec<FontRefEntry>,
73    /// The whole run's asset table, unsliced.
74    pub assets: Vec<Asset>,
75    /// The whole run's warnings, unsliced.
76    pub warnings: Vec<Warning>,
77}
78
79/// Encodes the whole display structure, version first.
80pub fn encode(output: &LayoutOutput) -> Result<Vec<u8>, WireError> {
81    encode_range(output, 0, output.pages.len())
82}
83
84/// Encodes `count` pages starting at `first`, version first. A range
85/// past the end of the book is clamped rather than refused: an edit
86/// that shortens the book while a page past its new end is still
87/// being asked for gets back whatever is left, not a panic.
88///
89/// `first` and the book's page count travel as `u32`, the same as
90/// every other count on the wire; a book past four billion pages is
91/// not one this format is sized for.
92pub fn encode_range(
93    output: &LayoutOutput,
94    first: usize,
95    count: usize,
96) -> Result<Vec<u8>, WireError> {
97    let book_pages = output.pages.len();
98    let first = first.min(book_pages);
99    let end = first.saturating_add(count).min(book_pages);
100    let slice = &output.pages[first..end];
101    Ok(postcard::to_stdvec(&(
102        VERSION,
103        first as u32,
104        book_pages as u32,
105        &output.fonts,
106        &output.assets,
107        &output.warnings,
108        slice,
109    ))?)
110}
111
112/// Reads a reply back, refusing a version this build does not write.
113pub fn decode(bytes: &[u8]) -> Result<Reply, WireError> {
114    let (found, rest) = postcard::take_from_bytes::<u16>(bytes)?;
115    if found != VERSION {
116        return Err(WireError::Version { found });
117    }
118    let (first, book_pages, fonts, assets, warnings, pages): (
119        u32,
120        u32,
121        Vec<FontRefEntry>,
122        Vec<Asset>,
123        Vec<Warning>,
124        Vec<Page>,
125    ) = postcard::from_bytes(rest)?;
126    Ok(Reply {
127        pages,
128        first: first as usize,
129        book_pages: book_pages as usize,
130        fonts,
131        assets,
132        warnings,
133    })
134}
135
136/// An EPUB as one wire reply, and what writing it warned about.
137///
138/// A display structure carries its warnings, and an EPUB is a file
139/// with no room for them, so the two cross together.
140#[derive(Debug, PartialEq, serde::Serialize, serde::Deserialize)]
141pub struct Epub {
142    /// What writing it had to complain about, in the shape a display
143    /// structure's warnings take.
144    pub warnings: Vec<Warning>,
145    /// The whole file.
146    pub bytes: Vec<u8>,
147}
148
149/// Encodes an EPUB and its warnings, version first.
150pub fn encode_epub(bytes: &[u8], warnings: &[Warning]) -> Result<Vec<u8>, WireError> {
151    Ok(postcard::to_stdvec(&(VERSION, warnings, bytes))?)
152}
153
154/// Reads an EPUB reply back, refusing a version this build does not
155/// write.
156pub fn decode_epub(bytes: &[u8]) -> Result<Epub, WireError> {
157    let (found, rest) = postcard::take_from_bytes::<u16>(bytes)?;
158    if found != VERSION {
159        return Err(WireError::Version { found });
160    }
161    Ok(postcard::from_bytes(rest)?)
162}
163
164/// An EPUB as its files, not zipped, and what writing it warned
165/// about.
166#[derive(Debug, PartialEq, serde::Serialize, serde::Deserialize)]
167pub struct EpubFiles {
168    /// The same warnings [`Epub::warnings`] holds.
169    pub warnings: Vec<Warning>,
170    /// The documents in reading order.
171    pub spine: Vec<EpubSpineEntry>,
172    /// Every file the zip holds, in the order it holds them.
173    pub files: Vec<EpubFile>,
174}
175
176/// One document of an EPUB's spine.
177#[derive(Debug, PartialEq, serde::Serialize, serde::Deserialize)]
178pub struct EpubSpineEntry {
179    /// Where the document is in the container.
180    pub path: String,
181    /// The node of the section the document holds. `None` for the
182    /// one empty document of a book with no sections.
183    pub section: Option<NodeId>,
184}
185
186/// One file of an EPUB.
187#[derive(Debug, PartialEq, serde::Serialize, serde::Deserialize)]
188pub struct EpubFile {
189    /// Where the file is in the container.
190    pub path: String,
191    /// Its media type.
192    pub media_type: String,
193    /// Its bytes, not compressed.
194    pub bytes: Vec<u8>,
195}
196
197/// Encodes an EPUB's files, version first.
198pub fn encode_epub_files(files: &EpubFiles) -> Result<Vec<u8>, WireError> {
199    Ok(postcard::to_stdvec(&(VERSION, files))?)
200}
201
202/// Reads an EPUB's files back, refusing a version this build does not
203/// write.
204pub fn decode_epub_files(bytes: &[u8]) -> Result<EpubFiles, WireError> {
205    let (found, rest) = postcard::take_from_bytes::<u16>(bytes)?;
206    if found != VERSION {
207        return Err(WireError::Version { found });
208    }
209    Ok(postcard::from_bytes(rest)?)
210}
211
212/// The version a buffer leads with, without reading the rest of it.
213pub fn version(bytes: &[u8]) -> Result<u16, WireError> {
214    Ok(postcard::take_from_bytes::<u16>(bytes)?.0)
215}
216
217#[cfg(test)]
218mod tests {
219    use super::*;
220    use crate::Warning;
221    use crate::content::{NodeId, PseudoElement, SourceRange};
222    use crate::fonts::{AxisSetting, FaceAttributes, FeatureSetting, Features, FontRefEntry};
223    use crate::images::{Asset, Intrinsic};
224    use crate::pages::{Corners, DrawItem, Glyph, Link, LinkTo, Page, PageBox, Radius, Side};
225    use crate::style::{Color, Edges};
226
227    fn output() -> LayoutOutput {
228        LayoutOutput {
229            pages: vec![Page {
230                number: 1,
231                side: Side::Recto,
232                width: 396.0,
233                height: 612.0,
234                sections: vec![NodeId::UNASSIGNED],
235                items: vec![
236                    DrawItem::Text {
237                        x: 72.0,
238                        y: 96.5,
239                        font_id: 0,
240                        size: 11.0,
241                        width: 18.5,
242                        text: "FI ❦".into(),
243                        source: "fi ❦".into(),
244                        source_map: vec![0, 1, 2, 3, 4, 5, 6],
245                        origin: Some(SourceRange {
246                            node: NodeId::UNASSIGNED,
247                            range: 3..9,
248                        }),
249                        pseudo_element: Some(NodeId::new(7).pseudo(PseudoElement::FirstLine)),
250                        features: Features::new(true, vec![FeatureSetting::new(*b"onum", 1)]),
251                        color: Color::rgb(180, 30, 30),
252                        glyphs: vec![Glyph {
253                            id: 42,
254                            x: 72.0,
255                            range: 0..2,
256                        }],
257                        layer: 10,
258                    },
259                    DrawItem::Rect {
260                        x: 0.0,
261                        y: 0.0,
262                        w: 396.0,
263                        h: 0.5,
264                        color: Color::BLACK,
265                        layer: DrawItem::PAGE_BACKGROUND,
266                    },
267                    DrawItem::Image {
268                        x: 1.0,
269                        y: 2.0,
270                        w: 3.0,
271                        h: 4.0,
272                        asset: 7,
273                        alpha: 128,
274                        layer: -1,
275                    },
276                    DrawItem::Rounded {
277                        x: 5.0,
278                        y: 6.0,
279                        w: 70.0,
280                        h: 20.0,
281                        radii: Corners {
282                            top_left: Radius { x: 3.0, y: 3.0 },
283                            ..Corners::SQUARE
284                        },
285                        ring: Edges {
286                            left: 2.0,
287                            ..Edges::all(0.0)
288                        },
289                        color: Color::rgba(51, 102, 153, 128),
290                        layer: 0,
291                    },
292                ],
293                links: vec![
294                    Link {
295                        areas: vec![
296                            PageBox {
297                                page: 0,
298                                x: 72.0,
299                                y: 88.0,
300                                width: 40.0,
301                                height: 12.0,
302                            },
303                            PageBox {
304                                page: 0,
305                                x: 72.0,
306                                y: 102.0,
307                                width: 20.0,
308                                height: 12.0,
309                            },
310                        ],
311                        to: LinkTo::Place {
312                            node: NodeId::new(12),
313                            place: PageBox {
314                                page: 4,
315                                x: 72.0,
316                                y: 72.0,
317                                width: 252.0,
318                                height: 30.0,
319                            },
320                        },
321                    },
322                    Link {
323                        areas: vec![],
324                        to: LinkTo::Uri("https://example.com".into()),
325                    },
326                ],
327            }],
328            fonts: vec![FontRefEntry {
329                family: "eb garamond".into(),
330                name: "EB Garamond Regular".into(),
331                style: "Regular".into(),
332                attributes: FaceAttributes::REGULAR,
333                variations: vec![AxisSetting {
334                    tag: *b"wght",
335                    value: 400.0,
336                }],
337            }],
338            assets: vec![Asset {
339                url: "plate.jpg".into(),
340                intrinsic: Intrinsic {
341                    width: 480,
342                    height: 320,
343                    dpi_x: 300.0,
344                    dpi_y: 300.0,
345                },
346            }],
347            warnings: vec![Warning {
348                message: "a table became prose".into(),
349                origin: Some("ch01.md:12:1".into()),
350            }],
351            navigation: Default::default(),
352        }
353    }
354
355    /// A reply as a `LayoutOutput`, for re-encoding it and comparing
356    /// the bytes: sound whenever the reply is a whole book, which is
357    /// all the round-trip tests below ask of it.
358    fn as_output(reply: Reply) -> LayoutOutput {
359        LayoutOutput {
360            pages: reply.pages,
361            fonts: reply.fonts,
362            assets: reply.assets,
363            warnings: reply.warnings,
364            navigation: Default::default(),
365        }
366    }
367
368    /// What went out comes back, and going out again writes the same
369    /// bytes.
370    #[test]
371    fn the_wire_round_trips() {
372        let bytes = encode(&output()).unwrap();
373        let read = decode(&bytes).unwrap();
374        assert_eq!(read.pages, output().pages);
375        assert_eq!(read.first, 0, "a whole-book reply starts at page 0");
376        assert_eq!(
377            read.book_pages,
378            output().pages.len(),
379            "a whole-book reply's total is its own page count"
380        );
381        assert_eq!(read.fonts, output().fonts);
382        assert_eq!(read.assets, output().assets);
383        assert_eq!(read.warnings, output().warnings);
384        assert_eq!(encode(&as_output(read)).unwrap(), bytes);
385    }
386
387    /// The version leads the bytes, so a host reads it before it
388    /// commits to anything.
389    #[test]
390    fn the_version_leads_the_bytes() {
391        let bytes = encode(&output()).unwrap();
392        assert_eq!(version(&bytes).unwrap(), VERSION);
393        assert_eq!(bytes[0], VERSION as u8);
394    }
395
396    /// A version this build does not write is refused rather than
397    /// read as best it can be.
398    #[test]
399    fn an_unknown_version_is_refused() {
400        let mut bytes = encode(&output()).unwrap();
401        bytes[0] = VERSION as u8 + 1;
402        assert!(matches!(decode(&bytes), Err(WireError::Version { .. })));
403    }
404
405    /// A ranged reply carries only its own slice, but the tables and
406    /// the book's total page count are the whole run's.
407    #[test]
408    fn a_range_carries_only_its_own_slice_with_the_full_tables() {
409        let mut book = output();
410        book.pages.push(Page {
411            number: 2,
412            side: Side::Verso,
413            width: 396.0,
414            height: 612.0,
415            sections: vec![],
416            items: vec![],
417            links: vec![],
418        });
419        let bytes = encode_range(&book, 1, 1).unwrap();
420        let read = decode(&bytes).unwrap();
421        assert_eq!(read.pages, book.pages[1..2]);
422        assert_eq!(read.first, 1);
423        assert_eq!(read.book_pages, 2);
424        assert_eq!(read.fonts, book.fonts, "the font table is not sliced");
425        assert_eq!(read.assets, book.assets, "the asset table is not sliced");
426        assert_eq!(read.warnings, book.warnings, "the warnings are not sliced");
427    }
428
429    /// A range past the end of the book is clamped to what is left
430    /// rather than refused or panicking: a page asked for from a book
431    /// an edit just shortened still gets an answer.
432    #[test]
433    fn a_range_past_the_end_clamps_rather_than_panics() {
434        let book = output();
435        let bytes = encode_range(&book, 5, 3).unwrap();
436        let read = decode(&bytes).unwrap();
437        assert!(read.pages.is_empty());
438        assert_eq!(read.first, 1, "first clamps to the book's own length");
439        assert_eq!(read.book_pages, 1);
440    }
441
442    /// An EPUB's files cross with their paths, media types and
443    /// spine, and a version this build does not write is refused.
444    #[test]
445    fn an_epub_s_files_round_trip() {
446        let files = EpubFiles {
447            warnings: output().warnings,
448            spine: vec![
449                EpubSpineEntry {
450                    path: "EPUB/section-001.xhtml".into(),
451                    section: Some(NodeId::new(7)),
452                },
453                EpubSpineEntry {
454                    path: "EPUB/section-002.xhtml".into(),
455                    section: None,
456                },
457            ],
458            files: vec![
459                EpubFile {
460                    path: "mimetype".into(),
461                    media_type: "text/plain".into(),
462                    bytes: b"application/epub+zip".to_vec(),
463                },
464                EpubFile {
465                    path: "EPUB/section-001.xhtml".into(),
466                    media_type: "application/xhtml+xml".into(),
467                    bytes: b"<html/>".to_vec(),
468                },
469            ],
470        };
471        let bytes = encode_epub_files(&files).unwrap();
472        assert_eq!(version(&bytes).unwrap(), VERSION);
473        assert_eq!(decode_epub_files(&bytes).unwrap(), files);
474
475        let mut stale = bytes;
476        stale[0] = VERSION as u8 + 1;
477        assert!(matches!(
478            decode_epub_files(&stale),
479            Err(WireError::Version { .. })
480        ));
481    }
482
483    /// An EPUB crosses whole, its warnings beside it, and a version
484    /// this build does not write is refused.
485    #[test]
486    fn an_epub_round_trips_with_its_warnings() {
487        let file = b"PK\x03\x04mimetypeapplication/epub+zip".to_vec();
488        let warnings = output().warnings;
489        let bytes = encode_epub(&file, &warnings).unwrap();
490        assert_eq!(version(&bytes).unwrap(), VERSION);
491        assert_eq!(
492            decode_epub(&bytes).unwrap(),
493            Epub {
494                warnings,
495                bytes: file
496            }
497        );
498
499        let mut stale = bytes;
500        stale[0] = VERSION as u8 + 1;
501        assert!(matches!(
502            decode_epub(&stale),
503            Err(WireError::Version { .. })
504        ));
505    }
506}