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 self.begin_debug_block(entry);
63 let text = marker_text(entry);
64 let subruns = shape_with_fallback(regular, self.text.family.fallbacks, size, &text);
65 let width_pt: f32 = subruns.iter().map(|s| s.advance_pt).sum();
66 let marker_word = Word {
67 text,
68 actual_text: None,
69 space_before_pt: 0.0,
70 font: regular,
71 size_pt: size,
72 width_pt,
73 subruns,
74 shy_break_offsets: Vec::new(),
75 };
76 let marker_x = entry_left - marker_gap_pt - marker_word.width_pt;
77
78 self.current_left_pt = entry_left;
79 self.pending_marker = Some(PendingMarker {
80 x_pt: marker_x,
81 word: marker_word,
82 });
83
84 let words = self.collect_words(document, entry, regular, size);
85 if words.is_empty() {
86 self.flush_line(&[], leading);
87 } else {
88 self.flow_words(&words, leading);
89 if self.pending_marker.is_some() {
90 self.flush_line(&[], leading);
91 }
92 }
93 self.end_debug_block();
94 }
95
96 self.current_left_pt = saved_left;
97 self.cursor_y += PARA_SPACE_AFTER_PT;
98 }
99}
100
101fn marker_text(entry: &Node) -> String {
106 match entry.attributes.get("entry_number") {
107 Some(AttrValue::Int(number)) => format!("[{number}]"),
108 _ => "[?]".to_owned(),
109 }
110}
111
112#[cfg(test)]
113mod tests {
114 #![allow(
115 clippy::unwrap_used,
116 clippy::expect_used,
117 reason = "tests panic loudly on setup failure; matches crate-wide test-module convention"
118 )]
119
120 use std::path::PathBuf;
121
122 use mos_core::{AttrMap, NodeId, NodeSpec, SourceSpan};
123
124 use crate::{LayoutEngine, MARGIN_PT, TextRun};
125
126 use super::*;
127
128 fn node(kind: NodeKind, attributes: AttrMap) -> NodeSpec {
129 NodeSpec::new(kind, SourceSpan::placeholder(PathBuf::from("test.mos")))
130 .with_attributes(attributes)
131 }
132
133 fn pin_helvetica(doc: &mut Document) {
134 let mut attrs = AttrMap::new();
135 attrs.insert("set".to_owned(), AttrValue::Str("text".to_owned()));
136 attrs.insert(
137 "set.arg.font".to_owned(),
138 AttrValue::Str("Helvetica".to_owned()),
139 );
140 doc.alloc_child(doc.root, node(NodeKind::Raw, attrs));
141 }
142
143 fn alloc_bibliography(doc: &mut Document) -> NodeId {
144 doc.alloc_child(doc.root, node(NodeKind::Bibliography, AttrMap::new()))
145 }
146
147 fn alloc_entry(doc: &mut Document, bib: NodeId, number: i64, text: &str) {
148 let mut attrs = AttrMap::new();
149 attrs.insert("entry_number".to_owned(), AttrValue::Int(number));
150 attrs.insert("entry_key".to_owned(), AttrValue::Str(format!("k{number}")));
151 let paragraph = doc.alloc_child(bib, node(NodeKind::Paragraph, attrs));
152 let mut text_attrs = AttrMap::new();
153 text_attrs.insert("text".to_owned(), AttrValue::Str(text.to_owned()));
154 doc.alloc_child(paragraph, node(NodeKind::Text, text_attrs));
155 }
156
157 #[test]
158 fn entries_emit_numbered_markers_with_hanging_indent() {
159 let mut doc = Document::new(PathBuf::from("test.mos"));
160 pin_helvetica(&mut doc);
161 let bib = alloc_bibliography(&mut doc);
162 alloc_entry(&mut doc, bib, 1, "First Entry. 1990.");
163 alloc_entry(&mut doc, bib, 2, "Second Entry. 1991.");
164
165 let result = LayoutEngine::new().layout(&doc);
166
167 assert!(result.diagnostics.is_empty(), "{:?}", result.diagnostics);
168 let runs = &result.graph.pages[0].runs;
169 let markers: Vec<&TextRun> = runs
170 .iter()
171 .filter(|r| r.text == "[1]" || r.text == "[2]")
172 .collect();
173 assert_eq!(markers.len(), 2, "expected [1] and [2], got {runs:?}");
174 assert!(markers[0].baseline_from_top_pt < markers[1].baseline_from_top_pt);
175 for marker in &markers {
176 let width = text_width(marker.font, marker.size_pt, &marker.text);
177 assert!(marker.x_pt >= MARGIN_PT - 0.5, "{runs:?}");
178 assert!(marker.x_pt + width <= MARGIN_PT + LIST_MARKER_GUTTER_PT + 0.5);
179 }
180 let first = runs.iter().find(|r| r.text == "First").expect("entry text");
181 let expected_left = MARGIN_PT + LIST_MARKER_GUTTER_PT;
182 assert!((first.x_pt - expected_left).abs() < 0.5, "{runs:?}");
183 assert!(
184 (first.baseline_from_top_pt - markers[0].baseline_from_top_pt).abs() < 1e-3,
185 "marker sits on the entry's first baseline"
186 );
187 }
188
189 #[test]
190 fn declaration_only_bibliography_emits_nothing() {
191 let mut doc = Document::new(PathBuf::from("test.mos"));
192 pin_helvetica(&mut doc);
193 alloc_bibliography(&mut doc);
194
195 let result = LayoutEngine::new().layout(&doc);
196
197 assert!(result.diagnostics.is_empty(), "{:?}", result.diagnostics);
198 assert!(
199 result.graph.pages[0].runs.is_empty(),
200 "a childless #bibliography declaration stays invisible: {:?}",
201 result.graph.pages[0].runs
202 );
203 }
204
205 #[test]
206 fn long_entry_text_wraps_within_the_indented_column() {
207 let mut doc = Document::new(PathBuf::from("test.mos"));
208 pin_helvetica(&mut doc);
209 let bib = alloc_bibliography(&mut doc);
210 let long = "word ".repeat(60);
211 alloc_entry(&mut doc, bib, 1, long.trim());
212
213 let result = LayoutEngine::new().layout(&doc);
214
215 let text_left = MARGIN_PT + LIST_MARKER_GUTTER_PT;
216 let wrapped: Vec<&TextRun> = result.graph.pages[0]
217 .runs
218 .iter()
219 .filter(|r| r.text != "[1]")
220 .collect();
221 assert!(wrapped.len() > 1, "long entry should wrap: {wrapped:?}");
222 for run in wrapped {
223 assert!(run.x_pt >= text_left - 0.5, "hanging indent holds: {run:?}");
224 }
225 }
226}