Skip to main content

fleuron/session/
inspect.rs

1//! What a host asks about one thing on a page: what styled it and
2//! where it landed, and what is under a point.
3
4use crate::content::{NodeId, PseudoElement};
5use crate::layout::{Paginator, run_area};
6use crate::pages::{DrawItem, PageBox};
7use crate::style::{Inspection, MarginBox, element_of};
8
9use super::Session;
10
11impl Session<'_> {
12    /// The element one node stands for, the rules that matched it and
13    /// what it computed to, and its border box on each page it
14    /// reaches. A text node answers for the element that holds it. A
15    /// pseudo-element answers for itself, with its own boxes: the box
16    /// `::before` or `::after` generates on a block, or else the area
17    /// its text covers on each page.
18    ///
19    /// Nothing for a node the book does not hold, or for the id the
20    /// engine writes its own text under. An id names a node only
21    /// until the next edit, so the answer is about the book as it
22    /// stands.
23    pub fn inspect(&mut self, node: NodeId) -> Option<Inspection> {
24        self.update();
25        let sheets = self.sheets.as_ref()?;
26        let mut inspection = sheets.inspect(&self.book, &self.styles, node)?;
27        let element = inspection.node?;
28        inspection.boxes = self.boxes_of(element);
29        Some(inspection)
30    }
31
32    /// The same for one margin box of the page at `index`, counting
33    /// from 0: the page selector it answers to, the `@page` rules that
34    /// set it, and the box its text takes.
35    ///
36    /// Nothing for a page the book does not have, for a blank page,
37    /// and for a box no rule for that page names.
38    pub fn inspect_margin_box(&mut self, index: usize, which: MarginBox) -> Option<Inspection> {
39        self.update();
40        let sheets = self.sheets.as_ref()?;
41        let paginator = Paginator::with_contours(
42            self.registry.get(),
43            &self.styles,
44            self.assets.get(),
45            &self.contours,
46        );
47        let (query, area) = paginator.margin_box(&self.infos, index, which)?;
48        let mut inspection = sheets.inspect_margin_box(&self.styles, query, which)?;
49        inspection.boxes = area.into_iter().collect();
50        Some(inspection)
51    }
52
53    /// The innermost element at a point on the page at `index`, in
54    /// points from its top-left corner: the element that holds the
55    /// text there, or else the innermost block whose border box holds
56    /// the point, padding and empty space included. Where two such
57    /// things overlap, the one painted later answers. A pseudo-element
58    /// answers with its own id: a drop cap, the line a paragraph opens
59    /// on, and the box or the text of `::before` and `::after`. An
60    /// inline element on the opening line answers for itself.
61    ///
62    /// Nothing outside every box, and nothing for a page the book does
63    /// not have.
64    pub fn hit(&mut self, index: usize, x: f32, y: f32) -> Option<NodeId> {
65        self.update();
66        let page = self.output.as_ref()?.pages.get(index)?;
67        let text = page
68            .items
69            .iter()
70            .filter_map(|item| self.run_box(index, item))
71            .rfind(|(_, _, area)| area.contains(x, y));
72        if let Some((node, pseudo, _)) = text {
73            let element = element_of(&self.book, node);
74            let inside = |pseudo: NodeId| match pseudo.pseudo_element() {
75                Some((line, PseudoElement::FirstLine)) => element == Some(line),
76                _ => true,
77            };
78            return pseudo.filter(|pseudo| inside(*pseudo)).or(element);
79        }
80        let under: Vec<NodeId> = self
81            .boxes
82            .iter()
83            .filter(|(_, area)| area.page as usize == index && area.contains(x, y))
84            .map(|(node, _)| *node)
85            .collect();
86        // A box that holds another box under the point is not the
87        // innermost one. A block against the page is recorded after the
88        // blocks it covers. A generated box holds nothing but its text.
89        under
90            .iter()
91            .rfind(|node| {
92                let held = match node.pseudo_element() {
93                    Some(_) => None,
94                    None => self.book.subtree(**node),
95                };
96                let held = held.unwrap_or_default();
97                !under
98                    .iter()
99                    .any(|other| other != *node && held.contains(&other.element().get()))
100            })
101            .copied()
102    }
103
104    /// Where one element or pseudo-element is on the pages: the border
105    /// box of a block on each page and column it reaches, or for one
106    /// with no box of its own, such as emphasis or a drop cap, the
107    /// area its text covers on each page.
108    fn boxes_of(&self, node: NodeId) -> Vec<PageBox> {
109        let blocks: Vec<PageBox> = self
110            .boxes
111            .iter()
112            .filter(|(id, _)| *id == node)
113            .map(|(_, area)| *area)
114            .collect();
115        if !blocks.is_empty() {
116            return blocks;
117        }
118        let Some(output) = &self.output else {
119            return Vec::new();
120        };
121        let held = self.book.subtree(node);
122        let covers = |origin: NodeId, pseudo: Option<NodeId>| match node.pseudo_element() {
123            Some(_) => pseudo == Some(node),
124            None => held
125                .as_ref()
126                .is_some_and(|held| held.contains(&origin.element().get())),
127        };
128        let mut boxes = Vec::new();
129        for (index, page) in output.pages.iter().enumerate() {
130            let covered = page
131                .items
132                .iter()
133                .filter_map(|item| self.run_box(index, item))
134                .filter(|(origin, pseudo, _)| covers(*origin, *pseudo))
135                .map(|(_, _, area)| area)
136                .reduce(|one, other| {
137                    let (left, top) = (one.x.min(other.x), one.y.min(other.y));
138                    let right = (one.x + one.width).max(other.x + other.width);
139                    let bottom = (one.y + one.height).max(other.y + other.height);
140                    PageBox {
141                        page: one.page,
142                        x: left,
143                        y: top,
144                        width: right - left,
145                        height: bottom - top,
146                    }
147                });
148            boxes.extend(covered);
149        }
150        boxes
151    }
152
153    /// The node a run of text was written in, the pseudo-element it
154    /// was cut from, and the area the run covers.
155    fn run_box(&self, index: usize, item: &DrawItem) -> Option<(NodeId, Option<NodeId>, PageBox)> {
156        run_area(self.registry.get(), index, item)
157    }
158}
159
160#[cfg(test)]
161mod tests {
162    use super::*;
163    use crate::content::{Attributes, Block, Inline, PseudoElement, block_id};
164    use crate::pages::Side;
165    use crate::session::testing::{book, paragraph, prose, registry, section, sheets};
166    use crate::style::Color;
167
168    fn text(value: &str) -> Inline {
169        Inline::Text {
170            id: NodeId::UNASSIGNED,
171            value: value.into(),
172            attributes: Attributes::default(),
173            position: None,
174            span: None,
175        }
176    }
177
178    /// One chapter: a paragraph with emphasis in it, and prose after.
179    fn chapter(css: &str, first: Block) -> Session<'static> {
180        let mut session = Session::new(registry());
181        session.set_content(book(vec![section(
182            "one.md",
183            [vec![first], prose("alpha", 3)].concat(),
184        )]));
185        session.set_style(sheets(css));
186        session
187    }
188
189    fn emphatic() -> Block {
190        Block::Paragraph {
191            id: NodeId::UNASSIGNED,
192            inlines: vec![
193                text("Lo, "),
194                Inline::Emphasis {
195                    id: NodeId::UNASSIGNED,
196                    children: vec![text("behold")],
197                    attributes: Attributes::default(),
198                    position: None,
199                    span: None,
200                },
201                text(" the sea."),
202            ],
203            attributes: Attributes::default(),
204            position: None,
205            span: None,
206        }
207    }
208
209    const TINT: Color = Color::rgb(0xee, 0xdd, 0xcc);
210
211    /// The rects a page paints in `TINT`, as boxes.
212    fn tinted(session: &mut Session<'_>) -> Vec<PageBox> {
213        let output = session.preview();
214        output
215            .pages
216            .iter()
217            .enumerate()
218            .flat_map(|(index, page)| {
219                page.items.iter().filter_map(move |item| match item {
220                    DrawItem::Rect {
221                        x, y, w, h, color, ..
222                    } if *color == TINT => Some(PageBox {
223                        page: index as u32,
224                        x: *x,
225                        y: *y,
226                        width: *w,
227                        height: *h,
228                    }),
229                    _ => None,
230                })
231            })
232            .collect()
233    }
234
235    fn close(one: &PageBox, other: &PageBox) -> bool {
236        one.page == other.page
237            && [
238                (one.x, other.x),
239                (one.y, other.y),
240                (one.width, other.width),
241                (one.height, other.height),
242            ]
243            .iter()
244            .all(|(a, b)| (a - b).abs() < 0.01)
245    }
246
247    const PADDED: &str = "p:first-child { background-color: #eeddcc; padding: 12pt; \
248                          border: 3pt solid black; margin-bottom: 24pt }";
249
250    #[test]
251    fn a_block_answers_with_its_border_box_in_page_points() {
252        let mut session = chapter(PADDED, emphatic());
253        let node = block_id(&session.book().sections[0].blocks[0]);
254        let painted = tinted(&mut session);
255        let inspection = session.inspect(node).expect("the paragraph");
256
257        assert_eq!(inspection.element, "p");
258        assert_eq!(painted.len(), 1, "one background");
259        assert_eq!(inspection.boxes.len(), 1);
260        assert!(
261            close(&inspection.boxes[0], &painted[0]),
262            "{:?} is not the background at {:?}",
263            inspection.boxes[0],
264            painted[0]
265        );
266
267        let emphasis = NodeId::new(node.get() + 2);
268        let inline = session.inspect(emphasis).expect("the emphasis");
269        assert_eq!(inline.element, "em");
270        assert_eq!(inline.boxes.len(), 1, "emphasis covers its own text");
271        let (outer, inner) = (&inspection.boxes[0], &inline.boxes[0]);
272        assert!(inner.x > outer.x && inner.x + inner.width < outer.x + outer.width);
273        assert!(inner.y > outer.y && inner.y + inner.height < outer.y + outer.height);
274    }
275
276    #[test]
277    fn a_block_split_across_two_pages_answers_with_two_boxes() {
278        let long = paragraph(&"alpha ".repeat(700));
279        let mut session = chapter("p:first-child { background-color: #eeddcc }", long);
280        let node = block_id(&session.book().sections[0].blocks[0]);
281        let painted = tinted(&mut session);
282        let inspection = session.inspect(node).expect("the paragraph");
283
284        assert!(painted.len() >= 2, "the paragraph runs onto a second page");
285        assert_eq!(inspection.boxes.len(), painted.len());
286        for (area, background) in inspection.boxes.iter().zip(&painted) {
287            assert!(close(area, background), "{area:?} against {background:?}");
288        }
289        let pages: Vec<u32> = inspection.boxes.iter().map(|area| area.page).collect();
290        assert!(
291            pages.windows(2).all(|pair| pair[1] == pair[0] + 1),
292            "{pages:?}"
293        );
294    }
295
296    #[test]
297    fn a_margin_box_answers_with_its_page_selector_its_rules_and_its_box() {
298        let mut session = Session::new(registry());
299        session.set_content(book(vec![section("one.md", prose("alpha", 12))]));
300        session.set_style(sheets("@page :left { @top-left { content: \"Left\" } }"));
301        let output = session.preview();
302        let (index, run) = output
303            .pages
304            .iter()
305            .enumerate()
306            .filter(|(_, page)| page.side == Side::Verso)
307            .find_map(|(index, page)| {
308                page.items.iter().find_map(|item| match item {
309                    DrawItem::Text { x, y, text, .. } if text == "Left" => Some((index, (*x, *y))),
310                    _ => None,
311                })
312            })
313            .expect("a left page shows the box");
314        let recto = output
315            .pages
316            .iter()
317            .position(|page| page.side == Side::Recto)
318            .expect("a right page");
319
320        let inspection = session
321            .inspect_margin_box(index, MarginBox::TopLeft)
322            .expect("the left page names @top-left");
323        assert_eq!(inspection.element, "@top-left");
324        assert_eq!(
325            inspection.page.as_deref(),
326            Some("@page chapter:left"),
327            "the built-in sheet sets a chapter on the page named chapter"
328        );
329        let rule = inspection
330            .rules
331            .iter()
332            .find(|rule| rule.sheet == "test.css")
333            .expect("the author's rule");
334        assert_eq!(
335            (rule.selector.as_str(), rule.line, rule.column),
336            ("@page :left", 1, 1)
337        );
338        assert_eq!(rule.declarations[0].property, "content");
339        assert!(rule.declarations[0].applied);
340        assert_eq!(inspection.boxes.len(), 1);
341        assert_eq!(inspection.boxes[0].page, index as u32);
342        assert!(inspection.boxes[0].contains(run.0 + 1.0, run.1 - 1.0));
343
344        assert_eq!(session.inspect_margin_box(recto, MarginBox::TopLeft), None);
345    }
346
347    #[test]
348    fn a_synthesized_or_unknown_node_answers_nothing() {
349        let mut session = chapter(PADDED, emphatic());
350        assert_eq!(session.inspect(NodeId::UNASSIGNED), None);
351        assert_eq!(session.inspect(NodeId::new(u32::MAX)), None);
352        assert_eq!(session.inspect_margin_box(9999, MarginBox::TopLeft), None);
353    }
354
355    #[test]
356    fn a_point_answers_with_the_innermost_element_there() {
357        let mut session = chapter(PADDED, emphatic());
358        let node = block_id(&session.book().sections[0].blocks[0]);
359        let area = session.inspect(node).expect("the paragraph").boxes[0];
360
361        assert_eq!(
362            session.hit(0, area.x + 6.0, area.y + 6.0),
363            Some(node),
364            "the padding is the paragraph's, though no text is there"
365        );
366        let emphasis = NodeId::new(node.get() + 2);
367        let word = session.inspect(emphasis).expect("the emphasis").boxes[0];
368        assert_eq!(
369            session.hit(0, word.x + word.width / 2.0, word.y + word.height / 2.0),
370            Some(emphasis)
371        );
372        let plain = session.inspect(NodeId::new(node.get() + 1)).expect("text");
373        assert_eq!(
374            plain.node,
375            Some(node),
376            "plain text answers for its paragraph"
377        );
378
379        let section = session.book().sections[0].id;
380        let under = session.inspect(section).expect("the chapter").boxes[0];
381        assert_eq!(
382            session.hit(0, under.x + 1.0, area.y + area.height + 12.0),
383            Some(section),
384            "the margin below a paragraph is the chapter's"
385        );
386    }
387
388    #[test]
389    fn a_point_outside_every_box_answers_nothing() {
390        let mut session = chapter(PADDED, emphatic());
391        assert_eq!(session.hit(0, 1.0, 1.0), None, "the corner of the page");
392        assert_eq!(session.hit(9999, 100.0, 100.0), None, "no such page");
393    }
394
395    /// A generated box records its border box under its own id. Its
396    /// padding and its text answer with that id, and its inspection
397    /// answers with that box.
398    #[test]
399    fn a_generated_box_records_its_box_and_answers_for_itself() {
400        let css = "p:first-child::after { content: \"Fin\"; padding: 6pt; \
401                   background-color: #eeddcc }";
402        let mut session = chapter(css, emphatic());
403        let node = block_id(&session.book().sections[0].blocks[0]);
404        let painted = tinted(&mut session);
405        let generated = session
406            .styles
407            .pseudo_element(node, PseudoElement::After)
408            .expect("the paragraph generates a box");
409
410        let recorded: Vec<PageBox> = session
411            .boxes
412            .iter()
413            .filter(|(id, _)| *id == generated)
414            .map(|(_, area)| *area)
415            .collect();
416        assert_eq!(painted.len(), 1, "one background");
417        assert_eq!(recorded.len(), 1, "one box under the generated id");
418        assert!(
419            close(&recorded[0], &painted[0]),
420            "{:?} is not the background at {:?}",
421            recorded[0],
422            painted[0]
423        );
424
425        let area = recorded[0];
426        assert_eq!(
427            session.hit(0, area.x + 2.0, area.y + 2.0),
428            Some(generated),
429            "the padding of the box answers for the box"
430        );
431        let (x, y, origin) = session
432            .preview()
433            .pages
434            .iter()
435            .flat_map(|page| &page.items)
436            .find_map(|item| match item {
437                DrawItem::Text {
438                    x, y, text, origin, ..
439                } if text == "Fin" => Some((*x, *y, origin.clone())),
440                _ => None,
441            })
442            .expect("the box sets its text");
443        assert_eq!(origin.map(|origin| origin.node), Some(generated));
444        assert_eq!(
445            session.hit(0, x + 1.0, y - 2.0),
446            Some(generated),
447            "the text of the box answers for the box"
448        );
449
450        let inspection = session.inspect(generated).expect("the box");
451        assert_eq!(inspection.node, Some(generated));
452        assert_eq!(inspection.element, "p");
453        assert_eq!(inspection.boxes.len(), 1);
454        assert!(close(&inspection.boxes[0], &recorded[0]));
455    }
456
457    /// A chapter under an `h2`: a paragraph long enough to sink a drop
458    /// cap into, with a link near its end.
459    fn under_h2(css: &str) -> Session<'static> {
460        under_h2_opening(css, "")
461    }
462
463    /// The same, with `opens` written before the paragraph's prose.
464    fn under_h2_opening(css: &str, opens: &str) -> Session<'static> {
465        let text = |value: &str| format!(r#"{{"type": "text", "value": "{value}"}}"#);
466        let blocks = format!(
467            r#"{{"type": "heading", "level": 2, "inlines": [{}]}},
468               {{"type": "paragraph", "inlines": [{}, {{"type": "link",
469                 "url": "https://example.com", "children": [{}]}}, {}]}}"#,
470            text("A Voyage"),
471            text(&format!(
472                "{opens}{}",
473                "my father had a small estate in nottinghamshire ".repeat(6)
474            )),
475            text("Lilliput"),
476            text(" and more."),
477        );
478        from_json(&blocks, css)
479    }
480
481    /// The paragraph under the heading, the text node it opens with,
482    /// and the link after that.
483    fn opening(session: &Session<'_>) -> (NodeId, NodeId, NodeId) {
484        let paragraph = block_id(&session.book().sections[0].blocks[1]);
485        let text = NodeId::new(paragraph.get() + 1);
486        (paragraph, text, NodeId::new(text.get() + 1))
487    }
488
489    /// The first run `pick` takes, and the area it covers.
490    fn run_where(
491        session: &mut Session<'_>,
492        pick: impl Fn(&DrawItem) -> bool,
493    ) -> (DrawItem, PageBox) {
494        let (index, item) = session
495            .preview()
496            .pages
497            .iter()
498            .enumerate()
499            .find_map(|(index, page)| {
500                let item = page.items.iter().find(|item| pick(item))?;
501                Some((index, item.clone()))
502            })
503            .expect("a run matches");
504        let (_, _, area) = session.run_box(index, &item).expect("the run names a node");
505        (item, area)
506    }
507
508    fn spelled(text: &'static str) -> impl Fn(&DrawItem) -> bool {
509        move |item| matches!(item, DrawItem::Text { text: run, .. } if run.contains(text))
510    }
511
512    fn centre(area: &PageBox) -> (f32, f32) {
513        (area.x + area.width / 2.0, area.y + area.height / 2.0)
514    }
515
516    fn author_selectors(inspection: &Inspection) -> Vec<&str> {
517        inspection
518            .rules
519            .iter()
520            .filter(|rule| rule.sheet == "test.css")
521            .map(|rule| rule.selector.as_str())
522            .collect()
523    }
524
525    const DROP_CAP: &str = "h2 + p::first-letter { initial-letter: 3 }";
526    const SMALL_CAPS: &str = "h2 + p::first-line { font-variant-caps: small-caps }";
527    const LINK: &str = "a::after { content: \" (link)\" } a { color: #333333 }";
528
529    /// Acceptance: with `h2 + p::first-letter { initial-letter: 3 }`, a
530    /// point on the drop cap hits the paragraph's `::first-letter` id.
531    /// Its inspection lists that rule and `initial-letter: 3`.
532    #[test]
533    fn a_point_on_a_drop_cap_hits_its_first_letter() {
534        let mut session = under_h2(DROP_CAP);
535        let (paragraph, ..) = opening(&session);
536        let letter = paragraph.pseudo(PseudoElement::FirstLetter);
537        let (_, area) = run_where(
538            &mut session,
539            |item| matches!(item, DrawItem::Text { text, .. } if text == "m"),
540        );
541        let (x, y) = centre(&area);
542        assert_eq!(session.hit(area.page as usize, x, y), Some(letter));
543
544        let inspection = session.inspect(letter).expect("the drop cap");
545        assert_eq!(author_selectors(&inspection), ["h2 + p::first-letter"]);
546        let declaration = inspection
547            .rules
548            .iter()
549            .flat_map(|rule| &rule.declarations)
550            .find(|declaration| declaration.property == "initial-letter")
551            .expect("the rule declares `initial-letter`");
552        assert_eq!(declaration.value, "3");
553        assert!(declaration.applied);
554        assert_eq!(inspection.computed["initial-letter"], "3");
555    }
556
557    /// Acceptance: with `h2 + p::first-line { font-variant-caps:
558    /// small-caps }`, a point on the first line after the drop cap
559    /// hits the `::first-line` id. Its inspection lists that rule.
560    #[test]
561    fn a_point_on_the_first_line_hits_its_first_line() {
562        let mut session = under_h2(&format!("{DROP_CAP} {SMALL_CAPS}"));
563        let (paragraph, text, _) = opening(&session);
564        let line = paragraph.pseudo(PseudoElement::FirstLine);
565        let (_, area) = run_where(&mut session, |item| {
566            matches!(item, DrawItem::Text { origin: Some(origin), .. }
567                if origin.node == text && origin.range.start == 1)
568        });
569        let (x, y) = centre(&area);
570        assert_eq!(session.hit(area.page as usize, x, y), Some(line));
571        let inspection = session.inspect(line).expect("the first line");
572        assert_eq!(author_selectors(&inspection), ["h2 + p::first-line"]);
573
574        let (_, later) = run_where(&mut session, |item| {
575            matches!(item, DrawItem::Text { origin: Some(origin), .. }
576                if origin.node == text && origin.range.start > 150)
577        });
578        let (x, y) = centre(&later);
579        assert_eq!(
580            session.hit(later.page as usize, x, y),
581            Some(paragraph),
582            "a later line is the paragraph's"
583        );
584    }
585
586    /// Acceptance: a point in the box of `h2::before` hits its id. Its
587    /// inspection lists the `h2::before` rule, not only the rules for
588    /// `h2`.
589    #[test]
590    fn a_point_in_the_box_of_h2_before_hits_its_id() {
591        let css = "h2 { color: #333333 } \
592                   h2::before { content: \"\"; height: 12pt; padding: 6pt; \
593                   background-color: #eeddcc }";
594        let mut session = under_h2(css);
595        let heading = block_id(&session.book().sections[0].blocks[0]);
596        let before = heading.pseudo(PseudoElement::Before);
597        let painted = tinted(&mut session);
598        assert_eq!(painted.len(), 1, "one background");
599        let area = painted[0];
600        assert_eq!(
601            session.hit(area.page as usize, area.x + 2.0, area.y + 2.0),
602            Some(before)
603        );
604
605        let inspection = session.inspect(before).expect("the box");
606        assert_eq!(author_selectors(&inspection), ["h2::before"]);
607        let heading = session.inspect(heading).expect("the heading");
608        assert_eq!(author_selectors(&heading), ["h2"]);
609    }
610
611    /// Acceptance: a point on the text of `a::after` hits its id, and
612    /// its inspection lists the `a::after` rule.
613    #[test]
614    fn a_point_on_the_text_of_a_after_hits_its_id() {
615        let mut session = under_h2(LINK);
616        let (.., link) = opening(&session);
617        let after = link.pseudo(PseudoElement::After);
618        let (_, area) = run_where(&mut session, spelled("(link)"));
619        let (x, y) = centre(&area);
620        assert_eq!(session.hit(area.page as usize, x, y), Some(after));
621
622        let inspection = session.inspect(after).expect("the generated text");
623        assert_eq!(author_selectors(&inspection), ["a::after"]);
624        assert_eq!(inspection.computed["content"], "\" (link)\"");
625        assert_eq!(inspection.boxes.len(), 1);
626        assert!(close(&inspection.boxes[0], &area));
627    }
628
629    /// Acceptance: the box an inspection returns for a drop cap is the
630    /// rectangle of the letter, not of the paragraph.
631    #[test]
632    fn the_box_of_a_drop_cap_is_the_letter() {
633        let mut session = under_h2(DROP_CAP);
634        let (paragraph, ..) = opening(&session);
635        let (_, letter) = run_where(
636            &mut session,
637            |item| matches!(item, DrawItem::Text { text, .. } if text == "m"),
638        );
639        let boxes = session
640            .inspect(paragraph.pseudo(PseudoElement::FirstLetter))
641            .expect("the drop cap")
642            .boxes;
643        assert_eq!(boxes.len(), 1);
644        assert!(
645            close(&boxes[0], &letter),
646            "{:?} against {letter:?}",
647            boxes[0]
648        );
649        let whole = session.inspect(paragraph).expect("the paragraph").boxes;
650        assert!(
651            boxes[0].width < whole[0].width / 4.0,
652            "{boxes:?} against {whole:?}"
653        );
654    }
655
656    /// Acceptance: a host maps a glyph of a drop cap to the same
657    /// manuscript byte as before.
658    #[test]
659    fn a_glyph_of_a_drop_cap_maps_to_the_same_manuscript_byte() {
660        let first_byte = |css: &str, opens: &str| {
661            let mut session = under_h2(css);
662            let (_, text, _) = opening(&session);
663            let (item, _) = run_where(&mut session, |item| {
664                matches!(item, DrawItem::Text { origin: Some(origin), text: run, .. }
665                    if origin.node == text && run.starts_with(opens))
666            });
667            let DrawItem::Text {
668                origin: Some(origin),
669                source_map,
670                glyphs,
671                ..
672            } = item
673            else {
674                unreachable!("the run names a node");
675            };
676            let at = glyphs[0].range.start;
677            let at = source_map.get(at as usize).copied().unwrap_or(at);
678            (origin.node, origin.range.start + at)
679        };
680        let plain = first_byte("", "my father");
681        assert_eq!(first_byte(DROP_CAP, "m"), plain);
682        assert_eq!(plain.1, 0, "the paragraph opens at its first byte");
683    }
684
685    /// Acceptance: a glyph of a drop cap that opens with punctuation
686    /// maps to the manuscript byte of that punctuation.
687    #[test]
688    fn a_glyph_of_a_quoted_drop_cap_maps_to_the_byte_of_its_punctuation() {
689        let mut session = under_h2_opening(DROP_CAP, "\u{201C}");
690        let (_, text, _) = opening(&session);
691        let (item, _) = run_where(
692            &mut session,
693            |item| matches!(item, DrawItem::Text { text, .. } if text == "\u{201C}m"),
694        );
695        let DrawItem::Text {
696            origin: Some(origin),
697            source_map,
698            glyphs,
699            ..
700        } = item
701        else {
702            unreachable!("the run names a node");
703        };
704        assert_eq!(origin.node, text);
705        let byte = |glyph: usize| {
706            let at = glyphs[glyph].range.start;
707            origin.range.start + source_map.get(at as usize).copied().unwrap_or(at)
708        };
709        assert_eq!(
710            byte(0),
711            0,
712            "the quotation mark is the paragraph's first byte"
713        );
714        assert_eq!(byte(1), "\u{201C}".len() as u32, "the letter follows it");
715    }
716
717    /// Part: a run cut from a pseudo-element carries its id, and its
718    /// `origin` still names the manuscript text.
719    #[test]
720    fn a_run_cut_from_a_pseudo_element_carries_its_id_beside_its_origin() {
721        let css = format!("{DROP_CAP} {SMALL_CAPS} {LINK}");
722        let mut session = under_h2(&css);
723        let (paragraph, text, link) = opening(&session);
724        let runs: Vec<(String, Option<NodeId>, Option<NodeId>)> = session
725            .preview()
726            .pages
727            .iter()
728            .flat_map(|page| &page.items)
729            .filter_map(|item| match item {
730                DrawItem::Text {
731                    text,
732                    origin,
733                    pseudo_element,
734                    ..
735                } => Some((
736                    text.clone(),
737                    origin.as_ref().map(|origin| origin.node),
738                    *pseudo_element,
739                )),
740                _ => None,
741            })
742            .collect();
743
744        let cap = runs.iter().find(|(run, ..)| run == "m").expect("the cap");
745        let letter = paragraph.pseudo(PseudoElement::FirstLetter);
746        assert_eq!((cap.1, cap.2), (Some(text), Some(letter)));
747
748        let line = paragraph.pseudo(PseudoElement::FirstLine);
749        let opening: Vec<_> = runs.iter().filter(|(.., id)| *id == Some(line)).collect();
750        assert!(!opening.is_empty(), "no run carries the `::first-line` id");
751        assert!(
752            opening.iter().all(|(_, origin, _)| *origin == Some(text)),
753            "{opening:?}"
754        );
755
756        let after = link.pseudo(PseudoElement::After);
757        let generated = runs
758            .iter()
759            .find(|(run, ..)| run.contains("(link)"))
760            .expect("the text of `a::after`");
761        assert_eq!((generated.1, generated.2), (Some(after), Some(after)));
762    }
763
764    /// Part: the inspection names both the element and the
765    /// pseudo-element.
766    #[test]
767    fn an_inspection_names_the_element_and_the_pseudo_element() {
768        let mut session = under_h2(DROP_CAP);
769        let (paragraph, ..) = opening(&session);
770        let letter = paragraph.pseudo(PseudoElement::FirstLetter);
771        let inspection = session.inspect(letter).expect("the drop cap");
772        assert_eq!(inspection.node, Some(letter));
773        assert_eq!(inspection.element, "p");
774        assert_eq!(inspection.pseudo_element.as_deref(), Some("::first-letter"));
775        let json = serde_json::to_value(&inspection).expect("serializes");
776        assert_eq!(json["pseudoElement"], "::first-letter");
777
778        let plain = session.inspect(paragraph).expect("the paragraph");
779        assert_eq!(plain.pseudo_element, None);
780        assert!(!author_selectors(&plain).contains(&"h2 + p::first-letter"));
781        let json = serde_json::to_value(&plain).expect("serializes");
782        assert!(json.get("pseudoElement").is_none());
783
784        assert_eq!(
785            session.inspect(paragraph.pseudo(PseudoElement::FirstLine)),
786            None,
787            "no rule styles `::first-line`"
788        );
789    }
790
791    /// Acceptance: with `h2 + p::first-letter { initial-letter: 3 }`,
792    /// the inspection of the paragraph's `::first-letter` names the
793    /// paragraph's id, and inspecting that id answers for the
794    /// paragraph.
795    #[test]
796    fn a_pseudo_element_names_the_element_it_belongs_to() {
797        let mut session = under_h2(DROP_CAP);
798        let (paragraph, ..) = opening(&session);
799        let letter = paragraph.pseudo(PseudoElement::FirstLetter);
800        let inspection = session.inspect(letter).expect("the drop cap");
801        assert_eq!(inspection.node, Some(letter));
802        assert_eq!(inspection.element_node, Some(paragraph));
803
804        let element = inspection.element_node.expect("the element it belongs to");
805        let inspection = session.inspect(element).expect("the paragraph");
806        assert_eq!(inspection.node, Some(paragraph));
807        assert_eq!(inspection.element, "p");
808        assert_eq!(inspection.pseudo_element, None);
809    }
810
811    /// Acceptance: the inspection of an element names the element
812    /// itself, the same id as `node`.
813    #[test]
814    fn an_element_names_itself() {
815        let mut session = under_h2(DROP_CAP);
816        let (paragraph, text, _) = opening(&session);
817        let inspection = session.inspect(paragraph).expect("the paragraph");
818        assert_eq!(inspection.element_node, inspection.node);
819        assert_eq!(inspection.element_node, Some(paragraph));
820
821        let held = session.inspect(text).expect("the text it holds");
822        assert_eq!(held.element_node, Some(paragraph));
823    }
824
825    /// Acceptance: the inspection of a margin box names no element.
826    #[test]
827    fn a_margin_box_names_no_element() {
828        let mut session = Session::new(registry());
829        session.set_content(book(vec![section("one.md", prose("alpha", 12))]));
830        session.set_style(sheets("@page :left { @top-left { content: \"Left\" } }"));
831        let index = session
832            .preview()
833            .pages
834            .iter()
835            .position(|page| page.side == Side::Verso)
836            .expect("a left page");
837        let inspection = session
838            .inspect_margin_box(index, MarginBox::TopLeft)
839            .expect("the left page names @top-left");
840        assert_eq!(inspection.element, "@top-left");
841        assert_eq!(inspection.element_node, None);
842    }
843
844    /// Acceptance: the JSON the wasm binding returns carries the
845    /// element.
846    #[test]
847    fn the_json_of_an_inspection_carries_the_element() {
848        let mut session = under_h2(DROP_CAP);
849        let (paragraph, ..) = opening(&session);
850        let letter = paragraph.pseudo(PseudoElement::FirstLetter);
851        let drop_cap = session.inspect(letter).expect("the drop cap");
852        let json = serde_json::to_value(&drop_cap).expect("serializes");
853        assert_eq!(json["elementNode"], paragraph.get());
854
855        let plain = session.inspect(paragraph).expect("the paragraph");
856        let json = serde_json::to_value(&plain).expect("serializes");
857        assert_eq!(json["elementNode"], paragraph.get());
858    }
859
860    /// Acceptance: a stylesheet edit that adds or removes a
861    /// pseudo-element leaves the id of every content node unchanged.
862    #[test]
863    fn a_sheet_that_adds_or_removes_a_pseudo_element_keeps_every_node_id() {
864        let mut session = chapter("", emphatic());
865        let ids = |session: &mut Session<'_>| -> Vec<(u32, Option<std::ops::Range<u32>>)> {
866            session.preview();
867            let book = session.book();
868            let whole = book
869                .subtree(book.sections[0].id)
870                .expect("the chapter holds its nodes");
871            (whole.start..whole.end)
872                .map(|id| (id, book.subtree(NodeId::new(id))))
873                .collect()
874        };
875        let plain = ids(&mut session);
876        session.set_style(sheets(PSEUDO_ELEMENTS));
877        assert_eq!(
878            ids(&mut session),
879            plain,
880            "adding pseudo-elements renumbered"
881        );
882        session.set_style(sheets(""));
883        assert_eq!(ids(&mut session), plain, "removing them renumbered");
884    }
885
886    /// Every pseudo-element, on blocks and on an inline element.
887    const PSEUDO_ELEMENTS: &str = "p::before { content: \"x\" } \
888        section::after { content: \"\"; height: 2pt } \
889        p::first-letter { initial-letter: 2 } \
890        p::first-line { font-variant-caps: small-caps } \
891        em::before { content: \"[\" } em::after { content: \"]\" }";
892
893    /// Acceptance: two layouts of a book with pseudo-elements are
894    /// byte-identical.
895    #[test]
896    fn two_layouts_with_pseudo_elements_are_byte_identical() {
897        let css = format!(
898            "{PSEUDO_ELEMENTS} p:first-child::before {{ content: \"\\201C\"; \
899             position: absolute; top: 0; left: 0; wrap-flow: end }} \
900             p::after {{ content: \"\"; height: 2pt; background-color: #eeddcc }}"
901        );
902        let bytes = || crate::wire::encode(chapter(&css, emphatic()).preview()).expect("encodes");
903        let first = bytes();
904        assert_eq!(first, bytes());
905        let pseudo = crate::wire::decode(&first)
906            .expect("decodes")
907            .pages
908            .iter()
909            .flat_map(|page| &page.items)
910            .filter(|item| {
911                matches!(
912                    item,
913                    DrawItem::Text {
914                        pseudo_element: Some(_),
915                        ..
916                    }
917                )
918            })
919            .count();
920        assert!(pseudo > 0, "no run carries a pseudo-element");
921    }
922
923    /// A session over a book read from JSON, under `css`.
924    fn from_json(blocks: &str, css: &str) -> Session<'static> {
925        let json = format!(r#"{{"metadata": {{}}, "sections": [{{"blocks": [{blocks}]}}]}}"#);
926        let book: crate::content::Book = serde_json::from_str(&json).expect("the book reads");
927        let mut session = Session::new(registry());
928        session.set_content(book);
929        session.set_style(sheets(css));
930        session
931    }
932
933    fn holds(outer: &PageBox, inner: &PageBox) -> bool {
934        let slack = 0.01;
935        outer.page == inner.page
936            && inner.x >= outer.x - slack
937            && inner.y >= outer.y - slack
938            && inner.x + inner.width <= outer.x + outer.width + slack
939            && inner.y + inner.height <= outer.y + outer.height + slack
940    }
941
942    #[test]
943    fn a_table_row_its_cells_and_their_blocks_answer_with_their_boxes() {
944        let cell = |text: &str| {
945            format!(
946                r#"{{"cells": [{{"blocks": [{{"type": "paragraph",
947                    "inlines": [{{"type": "text", "value": "{text}"}}]}}]}}]}}"#
948            )
949        };
950        let table = format!(
951            r#"{{"type": "table", "head": [{}], "body": [{}]}}"#,
952            cell("Name"),
953            cell("Gulliver")
954        );
955        let mut session = from_json(&table, "td p { background-color: #eeddcc; padding: 6pt }");
956        let Block::Table { body, .. } = &session.book().sections[0].blocks[0] else {
957            panic!("a table");
958        };
959        let (row, data) = (body[0].id, body[0].cells[0].id);
960        let paragraph = block_id(&body[0].cells[0].blocks[0]);
961        let painted = tinted(&mut session);
962
963        let inner = session.inspect(paragraph).expect("the paragraph").boxes;
964        assert_eq!(inner.len(), 1);
965        assert!(
966            close(&inner[0], &painted[0]),
967            "{:?} against {:?}",
968            inner[0],
969            painted[0]
970        );
971        let cell_box = session.inspect(data).expect("the cell").boxes;
972        assert_eq!(cell_box.len(), 1);
973        assert!(
974            holds(&cell_box[0], &inner[0]),
975            "the cell holds its paragraph"
976        );
977        let row_box = session.inspect(row).expect("the row").boxes;
978        assert_eq!(row_box.len(), 1);
979        assert!(holds(&row_box[0], &cell_box[0]), "the row holds its cell");
980
981        assert_eq!(
982            session.hit(0, inner[0].x + 2.0, inner[0].y + 2.0),
983            Some(paragraph),
984            "the padding of a paragraph in a cell is the paragraph's"
985        );
986    }
987
988    #[test]
989    fn a_block_against_the_page_answers_with_its_box_and_wins_a_point_it_covers() {
990        let quote = r#"{"type": "blockquote", "blocks": [{"type": "paragraph",
991            "inlines": [{"type": "text", "value": "Motto"}]}]}"#;
992        let prose: Vec<String> = (0..3)
993            .map(|_| {
994                format!(
995                    r#"{{"type": "paragraph", "inlines": [{{"type": "text", "value": "{}"}}]}}"#,
996                    "alpha ".repeat(80)
997                )
998            })
999            .collect();
1000        let blocks = format!("{quote}, {}", prose.join(", "));
1001        let css = "blockquote { position: absolute; top: 0; left: 0; right: 150pt; \
1002                   wrap-flow: both; background-color: #eeddcc; padding: 12pt }";
1003        let mut session = from_json(&blocks, css);
1004        let quote = block_id(&session.book().sections[0].blocks[0]);
1005        let painted = tinted(&mut session);
1006
1007        let area = session.inspect(quote).expect("the quote").boxes;
1008        assert_eq!(area.len(), 1);
1009        assert_eq!(painted.len(), 1);
1010        assert!(
1011            close(&area[0], &painted[0]),
1012            "{:?} against {:?}",
1013            area[0],
1014            painted[0]
1015        );
1016        let motto = NodeId::new(quote.get() + 1);
1017        let inner = session.inspect(motto).expect("the motto").boxes;
1018        assert_eq!(inner.len(), 1);
1019        assert!(holds(&area[0], &inner[0]), "the quote holds its paragraph");
1020
1021        assert_eq!(
1022            session.hit(0, area[0].x + 3.0, area[0].y + 3.0),
1023            Some(quote),
1024            "the padding of the quote is the quote's, over the chapter under it"
1025        );
1026    }
1027
1028    #[test]
1029    fn boxes_follow_a_chapter_that_an_edit_renumbered() {
1030        let mut session = Session::new(registry());
1031        session.set_content(book(vec![
1032            section("one.md", prose("alpha", 4)),
1033            section("two.md", prose("beta", 4)),
1034        ]));
1035        session.preview();
1036        session.replace_source("one.md", vec![section("one.md", prose("alpha", 9))]);
1037        let node = block_id(&session.book().sections[1].blocks[0]);
1038        let folios = session.folios(&[node])[0].expect("the chapter reaches a page");
1039        let boxes = session.inspect(node).expect("the paragraph").boxes;
1040
1041        assert!(!boxes.is_empty());
1042        assert!(
1043            boxes
1044                .iter()
1045                .all(|area| (folios.at..folios.at + folios.count).contains(&area.page)),
1046            "{boxes:?} against {folios:?}"
1047        );
1048    }
1049}