Skip to main content

mos_layout/
bibliography.rs

1//! Bibliography entry-list layout.
2//!
3//! Renders the entry children that `mos-eval` attaches to a
4//! [`NodeKind::Bibliography`] node (paragraphs carrying an `entry_number`
5//! attribute) as a numbered hanging-indent list: a right-aligned `[N]`
6//! marker in a shared gutter, entry text flowing at the indented column.
7//! Mirrors `layout_list`'s ordered-marker geometry so bibliographies and
8//! numbered lists read consistently.
9
10use mos_core::{AttrValue, Document, Node, NodeKind};
11use mos_fonts::{shape_with_fallback, text_width};
12
13use crate::word::Word;
14use crate::{LIST_MARKER_GUTTER_PT, LayoutState, PARA_SPACE_AFTER_PT, PendingMarker};
15
16impl LayoutState {
17    /// Lay out a [`NodeKind::Bibliography`] node's rendered entry children.
18    ///
19    /// A declaration-only node (nothing cited, or a second `#bibliography`
20    /// declaration) has no entry children and emits nothing, preserving the
21    /// pre-rendering behavior of the directive being invisible in the PDF.
22    pub(super) fn layout_bibliography(&mut self, document: &Document, bib_node: &Node) {
23        let entries: Vec<&Node> = bib_node
24            .children
25            .iter()
26            .filter_map(|id| document.get(*id))
27            .filter(|node| {
28                node.kind == NodeKind::Paragraph && node.attributes.contains_key("entry_number")
29            })
30            .collect();
31        if entries.is_empty() {
32            return;
33        }
34
35        let regular = self.text.family.regular;
36        let size = self.text.size_pt;
37        let leading = self.text.leading;
38        let saved_left = self.current_left_pt;
39
40        // Size the gutter to the widest `[N]` marker so multi-digit entry
41        // numbers never overlap the entry text (the `layout_list` ordered
42        // gutter rule).
43        let widest_marker_pt = entries
44            .iter()
45            .map(|entry| {
46                shape_with_fallback(
47                    regular,
48                    self.text.family.fallbacks,
49                    size,
50                    &marker_text(entry),
51                )
52                .iter()
53                .map(|s| s.advance_pt)
54                .sum::<f32>()
55            })
56            .fold(0.0_f32, f32::max);
57        let marker_gap_pt = text_width(regular, size, " ");
58        let gutter = (widest_marker_pt + marker_gap_pt).max(LIST_MARKER_GUTTER_PT);
59        let entry_left = saved_left + gutter;
60
61        for entry in entries {
62            let text = marker_text(entry);
63            let subruns = shape_with_fallback(regular, self.text.family.fallbacks, size, &text);
64            let width_pt: f32 = subruns.iter().map(|s| s.advance_pt).sum();
65            let marker_word = Word {
66                text,
67                actual_text: None,
68                space_before_pt: 0.0,
69                font: regular,
70                size_pt: size,
71                width_pt,
72                subruns,
73                shy_break_offsets: Vec::new(),
74            };
75            let marker_x = entry_left - marker_gap_pt - marker_word.width_pt;
76
77            self.current_left_pt = entry_left;
78            self.pending_marker = Some(PendingMarker {
79                x_pt: marker_x,
80                word: marker_word,
81            });
82
83            let words = self.collect_words(document, entry, regular, size);
84            if words.is_empty() {
85                self.flush_line(&[], leading);
86            } else {
87                self.flow_words(&words, leading);
88                if self.pending_marker.is_some() {
89                    self.flush_line(&[], leading);
90                }
91            }
92        }
93
94        self.current_left_pt = saved_left;
95        self.cursor_y += PARA_SPACE_AFTER_PT;
96    }
97}
98
99/// The `[N]` marker for one entry paragraph. `entry_number` is stamped by
100/// `mos-eval` for every entry it renders, so the fallback is unreachable on
101/// compiler-produced documents; it keeps hand-built documents visible
102/// instead of panicking.
103fn marker_text(entry: &Node) -> String {
104    match entry.attributes.get("entry_number") {
105        Some(AttrValue::Int(number)) => format!("[{number}]"),
106        _ => "[?]".to_owned(),
107    }
108}
109
110#[cfg(test)]
111mod tests {
112    #![allow(
113        clippy::unwrap_used,
114        clippy::expect_used,
115        reason = "tests panic loudly on setup failure; matches crate-wide test-module convention"
116    )]
117
118    use std::path::PathBuf;
119
120    use mos_core::{AttrMap, NodeId, NodeSpec, SourceSpan};
121
122    use crate::{LayoutEngine, MARGIN_PT, TextRun};
123
124    use super::*;
125
126    fn node(kind: NodeKind, attributes: AttrMap) -> NodeSpec {
127        NodeSpec::new(kind, SourceSpan::placeholder(PathBuf::from("test.mos")))
128            .with_attributes(attributes)
129    }
130
131    fn pin_helvetica(doc: &mut Document) {
132        let mut attrs = AttrMap::new();
133        attrs.insert("set".to_owned(), AttrValue::Str("text".to_owned()));
134        attrs.insert(
135            "set.arg.font".to_owned(),
136            AttrValue::Str("Helvetica".to_owned()),
137        );
138        doc.alloc_child(doc.root, node(NodeKind::Raw, attrs));
139    }
140
141    fn alloc_bibliography(doc: &mut Document) -> NodeId {
142        doc.alloc_child(doc.root, node(NodeKind::Bibliography, AttrMap::new()))
143    }
144
145    fn alloc_entry(doc: &mut Document, bib: NodeId, number: i64, text: &str) {
146        let mut attrs = AttrMap::new();
147        attrs.insert("entry_number".to_owned(), AttrValue::Int(number));
148        attrs.insert("entry_key".to_owned(), AttrValue::Str(format!("k{number}")));
149        let paragraph = doc.alloc_child(bib, node(NodeKind::Paragraph, attrs));
150        let mut text_attrs = AttrMap::new();
151        text_attrs.insert("text".to_owned(), AttrValue::Str(text.to_owned()));
152        doc.alloc_child(paragraph, node(NodeKind::Text, text_attrs));
153    }
154
155    #[test]
156    fn entries_emit_numbered_markers_with_hanging_indent() {
157        let mut doc = Document::new(PathBuf::from("test.mos"));
158        pin_helvetica(&mut doc);
159        let bib = alloc_bibliography(&mut doc);
160        alloc_entry(&mut doc, bib, 1, "First Entry. 1990.");
161        alloc_entry(&mut doc, bib, 2, "Second Entry. 1991.");
162
163        let result = LayoutEngine::new().layout(&doc);
164
165        assert!(result.diagnostics.is_empty(), "{:?}", result.diagnostics);
166        let runs = &result.graph.pages[0].runs;
167        let markers: Vec<&TextRun> = runs
168            .iter()
169            .filter(|r| r.text == "[1]" || r.text == "[2]")
170            .collect();
171        assert_eq!(markers.len(), 2, "expected [1] and [2], got {runs:?}");
172        assert!(markers[0].baseline_from_top_pt < markers[1].baseline_from_top_pt);
173        for marker in &markers {
174            let width = text_width(marker.font, marker.size_pt, &marker.text);
175            assert!(marker.x_pt >= MARGIN_PT - 0.5, "{runs:?}");
176            assert!(marker.x_pt + width <= MARGIN_PT + LIST_MARKER_GUTTER_PT + 0.5);
177        }
178        let first = runs.iter().find(|r| r.text == "First").expect("entry text");
179        let expected_left = MARGIN_PT + LIST_MARKER_GUTTER_PT;
180        assert!((first.x_pt - expected_left).abs() < 0.5, "{runs:?}");
181        assert!(
182            (first.baseline_from_top_pt - markers[0].baseline_from_top_pt).abs() < 1e-3,
183            "marker sits on the entry's first baseline"
184        );
185    }
186
187    #[test]
188    fn declaration_only_bibliography_emits_nothing() {
189        let mut doc = Document::new(PathBuf::from("test.mos"));
190        pin_helvetica(&mut doc);
191        alloc_bibliography(&mut doc);
192
193        let result = LayoutEngine::new().layout(&doc);
194
195        assert!(result.diagnostics.is_empty(), "{:?}", result.diagnostics);
196        assert!(
197            result.graph.pages[0].runs.is_empty(),
198            "a childless #bibliography declaration stays invisible: {:?}",
199            result.graph.pages[0].runs
200        );
201    }
202
203    #[test]
204    fn long_entry_text_wraps_within_the_indented_column() {
205        let mut doc = Document::new(PathBuf::from("test.mos"));
206        pin_helvetica(&mut doc);
207        let bib = alloc_bibliography(&mut doc);
208        let long = "word ".repeat(60);
209        alloc_entry(&mut doc, bib, 1, long.trim());
210
211        let result = LayoutEngine::new().layout(&doc);
212
213        let text_left = MARGIN_PT + LIST_MARKER_GUTTER_PT;
214        let wrapped: Vec<&TextRun> = result.graph.pages[0]
215            .runs
216            .iter()
217            .filter(|r| r.text != "[1]")
218            .collect();
219        assert!(wrapped.len() > 1, "long entry should wrap: {wrapped:?}");
220        for run in wrapped {
221            assert!(run.x_pt >= text_left - 0.5, "hanging indent holds: {run:?}");
222        }
223    }
224}