1use 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 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 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 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 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 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 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 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 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 #[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 fn under_h2(css: &str) -> Session<'static> {
460 under_h2_opening(css, "")
461 }
462
463 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 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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 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 #[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 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}