Skip to main content

fleuron/session/
edit.rs

1//! What a host changes: the book, a source, the sheet, a face, an
2//! image, the metadata.
3
4use std::borrow::Cow;
5
6use crate::Warning;
7use crate::content::{Attributes, Book, Metadata, Section};
8use crate::fonts::FontSource;
9use crate::images::Added;
10use crate::style::Stylesheets;
11
12use super::invalidate::{has_images, hyphenation};
13use super::{AddFontError, AddImageError, Session, Stale};
14
15impl Session<'_> {
16    /// Sets the book, and with it every stage below box
17    /// construction.
18    ///
19    /// Node identity is the engine's: the tree is renumbered on the
20    /// way in, so a host may hand over sections it built by hand.
21    pub fn set_content(&mut self, mut book: Book) {
22        book.assign_node_ids();
23        self.book = Cow::Owned(book);
24        self.images = has_images(&self.book);
25        self.recompile();
26        self.stale = Stale::Trace;
27    }
28
29    /// Replaces every section that came from one source file.
30    ///
31    /// The source is the replaceable unit because a host names files
32    /// and one file may split into several sections. A name the book
33    /// does not already have appends instead, which is how a file it
34    /// has not seen before arrives.
35    pub fn replace_source(&mut self, name: &str, sections: Vec<Section>) {
36        let mut sections = sections;
37        let book = self.book.to_mut();
38        let mut rebuilt = Vec::with_capacity(book.sections.len() + sections.len());
39        let mut placed = false;
40        for section in std::mem::take(&mut book.sections) {
41            if section.source.as_deref() == Some(name) {
42                if !placed {
43                    rebuilt.append(&mut sections);
44                    placed = true;
45                }
46            } else {
47                rebuilt.push(section);
48            }
49        }
50        if !placed {
51            rebuilt.append(&mut sections);
52        }
53        book.sections = rebuilt;
54        book.assign_node_ids();
55        self.images = has_images(&self.book);
56        self.recompile();
57        self.stale = Stale::Trace;
58    }
59
60    /// Names every section that came from one source by these classes
61    /// and this id, and styles the book again.
62    ///
63    /// The names sit beside the source rather than in it, so every
64    /// node keeps the bytes it was read from. A name the book does
65    /// not carry changes nothing.
66    pub fn set_source_attributes(&mut self, name: &str, attributes: &Attributes) {
67        let book = self.book.to_mut();
68        for section in &mut book.sections {
69            if section.source.as_deref() == Some(name) {
70                section.attributes = attributes.clone();
71            }
72        }
73        self.recompile();
74    }
75
76    /// Adds a frontend's complaints to the run's diagnostics.
77    ///
78    /// A construct the content vocabulary cannot express is reported
79    /// where the source was read, which is upstream of every stage a
80    /// session runs. The output's warnings are the whole run's, so
81    /// they belong in the same channel rather than in a second one
82    /// the host has to remember to read. Which of them still apply
83    /// after an edit is the caller's to decide; this replaces the
84    /// lot.
85    pub fn set_source_warnings(&mut self, warnings: Vec<Warning>) {
86        self.source_warnings = warnings;
87    }
88
89    /// Sets the styling, and with it whichever stage the change
90    /// reaches, which is usually far short of everything.
91    pub fn set_style(&mut self, sheets: Stylesheets) {
92        self.sheets = Some(sheets);
93        self.load_faces();
94        self.recompile();
95    }
96
97    /// Names the book: title, author, and whatever else a frontend
98    /// read.
99    ///
100    /// The declared language is the one field a stage below reads,
101    /// and a book that changes it is hyphenated again. The pages
102    /// already laid out are otherwise the pages the export writes
103    /// under the new name.
104    pub fn set_metadata(&mut self, metadata: Metadata) {
105        if hyphenation(&metadata) != hyphenation(&self.book.metadata) {
106            self.stale = self.stale.max(Stale::Break);
107        }
108        self.book.to_mut().metadata = metadata;
109    }
110
111    /// Registers a face, and re-runs everything a face can change.
112    ///
113    /// A family the registry did not have is a family the cascade
114    /// resolved to something else, so the styling is compiled again
115    /// and the lines are broken again. The output's font table is
116    /// rebuilt with them.
117    ///
118    /// Only a session that owns its registry has one to add to; one
119    /// that borrowed it says so instead.
120    pub fn add_font(&mut self, source: FontSource) -> Result<Vec<u16>, AddFontError> {
121        let added = self.register_font(source);
122        // The table is built with the output and never patched, so
123        // the output goes rather than outlive the ids it indexes.
124        self.output = None;
125        self.stale = Stale::Break;
126        self.recompile();
127        added
128    }
129
130    /// Registers one image, and the index `DrawItem::Image.asset`
131    /// gets for it. `None` for bytes no probe recognises,
132    /// which is a diagnostic on the next display structure and no asset.
133    ///
134    /// A url registered again with the bytes it already answers for
135    /// costs nothing. Registered again with different bytes, it
136    /// replaces them in place: the box the image takes is re-broken
137    /// only if the header now reports a different size, since the
138    /// PDF writer reads the asset table fresh on every export and
139    /// needs no invalidation to see new pixels at an unchanged size.
140    /// An image whose contour was traced is the exception, because
141    /// the pixels are what the contour came from.
142    /// Only a session that owns its asset table has one to add to;
143    /// one that borrowed it says so instead.
144    pub fn add_image(&mut self, url: &str, bytes: Vec<u8>) -> Result<Option<u32>, AddImageError> {
145        let added = self
146            .assets
147            .get_mut()
148            .ok_or(AddImageError::Borrowed)?
149            .add(url, bytes);
150        Ok(match added {
151            Added::Unchanged(index) => Some(index),
152            Added::Replaced {
153                index,
154                previous,
155                current,
156            } => {
157                if previous != Some(current) {
158                    // The table is built with the output and never
159                    // patched, so the output goes rather than
160                    // outlive the indexes it names. A size that
161                    // moved is a box line-breaking reserved
162                    // differently, so the section-local cache — keyed
163                    // on the url and not on what it resolves to — is
164                    // dropped rather than trusted to notice.
165                    self.output = None;
166                    self.lines.clear();
167                    self.settled.clear();
168                    self.stale = Stale::Trace;
169                } else if self.contours.traces(index) {
170                    self.stale = self.stale.max(Stale::Trace);
171                }
172                Some(index)
173            }
174            Added::Refused => None,
175        })
176    }
177}
178
179#[cfg(test)]
180mod tests {
181    use super::*;
182    use crate::content::{Attributes, Block, Book, NodeId, Section};
183    use crate::pages::DrawItem;
184    use crate::session::Session;
185    use crate::session::testing::{
186        MAP, book, book_with_image, gif, heading, paragraph, placed, placed_image_size, prose,
187        registry, runs, section, sheets, spelled, three_chapters,
188    };
189    use crate::style::{Source, Stylesheets};
190
191    /// An image the host pushes reaches the display structure: the box is
192    /// placed, the asset table names the url, and the pages are
193    /// broken again around the room it takes.
194    #[test]
195    fn an_image_pushed_after_the_book_is_placed_and_indexed() {
196        let mut book = Book {
197            sections: vec![Section {
198                attributes: Default::default(),
199                blocks: vec![Block::Image {
200                    id: NodeId::UNASSIGNED,
201                    url: "plate.jpg".into(),
202                    alt: "a map".into(),
203                    attributes: Attributes::default(),
204                    position: None,
205                    span: None,
206                }],
207                ..Default::default()
208            }],
209            ..Default::default()
210        };
211        book.assign_node_ids();
212        let mut session =
213            Session::owning(crate::fonts::bundled_registry().expect("bundled font parses"));
214        session.set_content(book);
215        assert!(
216            session.preview().assets.is_empty(),
217            "a book whose images nobody supplied has no assets",
218        );
219
220        assert_eq!(
221            session.add_image("plate.jpg", MAP.to_vec()).unwrap(),
222            Some(0),
223        );
224        let output = session.preview();
225        assert_eq!(output.assets.len(), 1);
226        assert_eq!(output.assets[0].url, "plate.jpg");
227        let placed: Vec<&DrawItem> = output
228            .pages
229            .iter()
230            .flat_map(|page| &page.items)
231            .filter(|item| matches!(item, DrawItem::Image { .. }))
232            .collect();
233        assert_eq!(placed.len(), 1, "the map was not placed");
234        assert!(
235            output.warnings.is_empty(),
236            "a supplied image still complains: {:?}",
237            output.warnings,
238        );
239    }
240
241    /// Registering the same url with the same bytes again costs
242    /// nothing: no stage runs again, since neither the asset nor the
243    /// box it takes changed.
244    #[test]
245    fn identical_bytes_at_a_registered_url_cost_nothing() {
246        let mut session = Session::owning(crate::fonts::bundled_registry().unwrap());
247        session.set_content(book_with_image("pic.gif"));
248        session.add_image("pic.gif", gif(64, 32, 0)).unwrap();
249        session.preview();
250        let before = session.stages();
251
252        assert_eq!(
253            session.add_image("pic.gif", gif(64, 32, 0)).unwrap(),
254            Some(0)
255        );
256        session.preview();
257        assert_eq!(session.stages(), before, "identical bytes cost a stage");
258    }
259
260    /// Different bytes at a registered url that probe to the same
261    /// size replace the asset without re-breaking anything: the box
262    /// an image takes did not move.
263    #[test]
264    fn a_same_size_replacement_breaks_nothing() {
265        let mut session = Session::owning(crate::fonts::bundled_registry().unwrap());
266        session.set_content(book_with_image("pic.gif"));
267        session.add_image("pic.gif", gif(64, 32, 0)).unwrap();
268        session.preview();
269        let before = session.stages();
270
271        assert_eq!(
272            session.add_image("pic.gif", gif(64, 32, 1)).unwrap(),
273            Some(0)
274        );
275        session.preview();
276        assert_eq!(
277            session.stages().lines,
278            before.lines,
279            "a same-size replacement broke lines it did not need to"
280        );
281    }
282
283    /// A different size at a registered url re-breaks the lines
284    /// around it, and the new size reaches the page.
285    #[test]
286    fn a_resized_replacement_re_breaks_and_reaches_the_page() {
287        let mut session = Session::owning(crate::fonts::bundled_registry().unwrap());
288        session.set_content(book_with_image("pic.gif"));
289        session.add_image("pic.gif", gif(64, 32, 0)).unwrap();
290        let before_size = placed_image_size(session.preview());
291        let before_stages = session.stages();
292
293        assert_eq!(
294            session.add_image("pic.gif", gif(640, 320, 0)).unwrap(),
295            Some(0)
296        );
297        let after_size = placed_image_size(session.preview());
298        assert!(
299            session.stages().lines > before_stages.lines,
300            "a resize did not re-break the lines around it"
301        );
302        assert_ne!(
303            after_size, before_size,
304            "the new size did not reach the page"
305        );
306    }
307
308    /// A session that borrowed its asset table says so rather than
309    /// adding to somebody else's.
310    #[test]
311    fn a_borrowed_asset_table_refuses_an_image() {
312        let mut session = Session::new(registry());
313        assert!(matches!(
314            session.add_image("plate.jpg", MAP.to_vec()),
315            Err(AddImageError::Borrowed),
316        ));
317    }
318
319    /// The replaceable unit is the file: a host names one, and only
320    /// the sections that came from it are broken again.
321    #[test]
322    fn replacing_a_source_re_breaks_only_its_own_sections() {
323        let mut session = three_chapters();
324        assert_eq!(
325            session.stages().lines,
326            3,
327            "the first pass broke every section"
328        );
329        session.replace_source("two.md", vec![section("two.md", prose("delta", 9))]);
330        session.preview();
331        assert_eq!(
332            session.stages().lines,
333            4,
334            "a section other than two.md was broken again"
335        );
336    }
337
338    /// One file may split into several sections, and all of them go
339    /// when it is replaced.
340    #[test]
341    fn a_source_that_split_into_several_sections_is_replaced_whole() {
342        let mut session = Session::new(registry());
343        session.set_content(book(vec![
344            section("one.md", vec![heading("One"), paragraph("first")]),
345            section("one.md", vec![heading("Two"), paragraph("second")]),
346            section("two.md", vec![heading("Three"), paragraph("third")]),
347        ]));
348        session.preview();
349        session.replace_source("one.md", vec![section("one.md", vec![heading("Only")])]);
350        session.preview();
351        let sources: Vec<Option<&str>> = session
352            .book()
353            .sections
354            .iter()
355            .map(|section| section.source.as_deref())
356            .collect();
357        assert_eq!(sources, vec![Some("one.md"), Some("two.md")]);
358        assert_eq!(session.book().sections.len(), 2);
359    }
360
361    /// The page name the computed style of one source's section
362    /// carries.
363    fn page_of(session: &Session, source: &str) -> Option<String> {
364        let at = session
365            .book()
366            .sections
367            .iter()
368            .position(|section| section.source.as_deref() == Some(source))
369            .expect("the book carries the source");
370        let tree = session.styles();
371        let node = tree
372            .nodes()
373            .iter()
374            .filter(|node| node.element == "section")
375            .nth(at)
376            .expect("every section is styled");
377        tree.styles()[node.style as usize].page.clone()
378    }
379
380    /// Acceptance: a host sets a section's class and id without
381    /// changing the section's text, and the sheet reaches it by them.
382    #[test]
383    fn a_host_names_a_section_beside_its_source() {
384        let mut session = three_chapters();
385        session.set_style(sheets(
386            "section.front { page: front } section#closing { page: back }",
387        ));
388        session.preview();
389        let before = session.book().sections.clone();
390
391        session.set_source_attributes(
392            "one.md",
393            &Attributes {
394                id: None,
395                classes: vec!["front".into()],
396            },
397        );
398        session.set_source_attributes(
399            "three.md",
400            &Attributes {
401                id: Some("closing".into()),
402                classes: Vec::new(),
403            },
404        );
405        session.preview();
406
407        assert_eq!(page_of(&session, "one.md").as_deref(), Some("front"));
408        assert_eq!(page_of(&session, "two.md").as_deref(), Some("chapter"));
409        assert_eq!(page_of(&session, "three.md").as_deref(), Some("back"));
410        for (was, is) in before.iter().zip(&session.book().sections) {
411            assert_eq!(was.blocks, is.blocks, "naming a section changed its text");
412            assert_eq!(was.span, is.span);
413        }
414
415        session.set_source_attributes("one.md", &Attributes::default());
416        assert_eq!(page_of(&session, "one.md").as_deref(), Some("chapter"));
417    }
418
419    /// A file the book has not seen before arrives at the end.
420    #[test]
421    fn a_source_the_book_does_not_carry_is_appended() {
422        let mut session = Session::new(registry());
423        session.set_content(book(vec![section("one.md", vec![paragraph("first")])]));
424        session.replace_source("two.md", vec![section("two.md", vec![paragraph("second")])]);
425        session.preview();
426        assert_eq!(session.book().sections.len(), 2);
427    }
428
429    /// The cache stores breaks and no positions, so a section the
430    /// flow moved paints at new coordinates with the same lines.
431    #[test]
432    fn a_moved_section_keeps_its_breaks_and_takes_new_coordinates() {
433        let mut session = Session::new(registry());
434        // Chapters that open where the last one ended, so an edit
435        // above moves what follows instead of leaving it on its own
436        // opening page.
437        session.set_style(sheets("section { break-before: auto }"));
438        session.set_content(book(vec![
439            section("one.md", prose("alpha", 2)),
440            section("two.md", prose("beta", 12)),
441        ]));
442        let before = runs(session.preview(), "beta");
443        let broke = session.stages().lines;
444
445        session.replace_source("one.md", vec![section("one.md", prose("alpha", 24))]);
446        let after = runs(session.preview(), "beta");
447
448        assert_eq!(
449            session.stages().lines,
450            broke + 1,
451            "the untouched section was broken again"
452        );
453        assert!(!before.is_empty(), "the section painted nothing");
454        assert_eq!(spelled(&before), spelled(&after), "the breaks moved");
455        assert_ne!(placed(&before), placed(&after), "nothing moved");
456    }
457
458    /// Setting the same content twice re-keys every section and
459    /// breaks none of them: the key is what the section says, not
460    /// which node ids it was given this time.
461    #[test]
462    fn identical_content_set_again_breaks_nothing() {
463        let mut session = three_chapters();
464        let before = session.stages();
465        session.set_content(book(vec![
466            section("one.md", prose("alpha", 8)),
467            section("two.md", prose("beta", 8)),
468            section("three.md", prose("gamma", 8)),
469        ]));
470        session.preview();
471        assert_eq!(
472            session.stages().lines,
473            before.lines,
474            "renumbering alone cost a re-break"
475        );
476    }
477
478    /// A session that owns its registry takes a face after it was
479    /// made, and lays out against it: bytes cross once, and the
480    /// session they crossed into is the one that keeps them.
481    #[test]
482    fn an_owning_session_takes_a_face_and_uses_it() {
483        let mut session = Session::owning(crate::fonts::bundled_registry().unwrap());
484        session.set_content(book(vec![section("one.md", prose("alpha", 2))]));
485        let faces = session.preview().fonts.len();
486
487        let mut source = crate::fonts::FontSource::from_bytes(crate::fonts::BUNDLED_FONT.to_vec())
488            .expect("the bundled face parses");
489        source.family = "borrowed garamond".into();
490        source.declared = Some(crate::fonts::FaceAttributes::REGULAR);
491        let ids = session
492            .add_font(source)
493            .expect("an owning session registers");
494        assert_eq!(ids.len(), 1);
495
496        let css = "book { font-family: 'borrowed garamond' }";
497        session.set_style(Stylesheets::parse(&[Source::author("faces.css", css)]));
498        let output = session.preview();
499        assert_eq!(
500            output.fonts.len(),
501            faces + 1,
502            "the font table did not grow with the registry"
503        );
504        assert!(
505            output
506                .pages
507                .iter()
508                .flat_map(|page| &page.items)
509                .any(|item| matches!(
510                    item,
511                    DrawItem::Text { font_id, .. } if *font_id == ids[0]
512                )),
513            "the face that was registered set nothing"
514        );
515    }
516
517    /// A session laying out against someone else's registry has none
518    /// of its own to add to, and says so rather than laying out
519    /// against a face that is not there.
520    #[test]
521    fn a_borrowed_registry_refuses_a_face() {
522        let mut session = Session::new(registry());
523        let source = crate::fonts::FontSource::from_bytes(crate::fonts::BUNDLED_FONT.to_vec())
524            .expect("the bundled face parses");
525        assert!(matches!(
526            session.add_font(source),
527            Err(AddFontError::Borrowed)
528        ));
529    }
530
531    /// A section that moves in the book keeps its lines: the key
532    /// travels with the content, and nothing in it is positional.
533    #[test]
534    fn reordering_the_book_breaks_nothing() {
535        let mut session = three_chapters();
536        let before = session.stages();
537        session.set_content(book(vec![
538            section("three.md", prose("gamma", 8)),
539            section("one.md", prose("alpha", 8)),
540            section("two.md", prose("beta", 8)),
541        ]));
542        session.preview();
543        assert_eq!(
544            session.stages().lines,
545            before.lines,
546            "a section that only moved was broken again"
547        );
548    }
549}