1use 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 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 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
99fn 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}