Skip to main content

fop_layout/layout/
list.rs

1//! List layout algorithm
2//!
3//! Implements layout for lists with labels and bodies positioned side-by-side.
4
5use crate::area::{Area, AreaContent, AreaId, AreaTree, AreaType};
6use fop_types::{Length, Point, Rect, Result, Size};
7
8/// List layout engine
9pub struct ListLayout {
10    /// Available width for the list
11    available_width: Length,
12
13    /// End position of label area: start-indent + provisional-distance-between-starts - provisional-label-separation
14    /// This corresponds to `label-end()` in XSL-FO
15    label_width: Length,
16
17    /// Gap between label end and body start (provisional-label-separation)
18    label_separation: Length,
19
20    /// Start position of body: start-indent + provisional-distance-between-starts
21    /// This corresponds to `body-start()` in XSL-FO
22    body_start_offset: Length,
23}
24
25/// Layout information for a list item
26#[derive(Debug, Clone)]
27pub struct ListItemLayout {
28    /// Y position of the item
29    pub y_position: Length,
30
31    /// Height of the item
32    pub height: Length,
33
34    /// Label area ID
35    pub label_id: Option<AreaId>,
36
37    /// Body area ID
38    pub body_id: Option<AreaId>,
39}
40
41impl ListLayout {
42    /// Create a new list layout engine
43    pub fn new(available_width: Length) -> Self {
44        Self {
45            available_width,
46            label_width: Length::from_pt(18.0), // 24pt - 6pt = label_end by default
47            label_separation: Length::from_pt(6.0), // Default provisional-label-separation
48            body_start_offset: Length::from_pt(24.0), // Default provisional-distance-between-starts
49        }
50    }
51
52    /// Set label end position (provisional-distance-between-starts - provisional-label-separation)
53    /// This corresponds to the `label-end()` function value in XSL-FO.
54    pub fn with_label_width(mut self, width: Length) -> Self {
55        self.label_width = width;
56        self
57    }
58
59    /// Set label separation (provisional-label-separation)
60    pub fn with_label_separation(mut self, separation: Length) -> Self {
61        self.label_separation = separation;
62        self
63    }
64
65    /// Set body start position (provisional-distance-between-starts)
66    /// This corresponds to the `body-start()` function value in XSL-FO.
67    pub fn with_body_start(mut self, body_start: Length) -> Self {
68        self.body_start_offset = body_start;
69        self
70    }
71
72    /// Calculate body width (available_width - body_start)
73    pub fn body_width(&self) -> Length {
74        (self.available_width - self.body_start_offset).max(Length::from_pt(10.0))
75    }
76
77    /// Calculate body start x position — corresponds to `body-start()` in XSL-FO
78    pub fn body_start(&self) -> Length {
79        self.body_start_offset
80    }
81
82    /// Calculate label end position — corresponds to `label-end()` in XSL-FO
83    pub fn label_end(&self) -> Length {
84        self.label_width
85    }
86
87    /// Deprecated: use body_start() instead
88    pub fn body_start_x(&self) -> Length {
89        self.body_start_offset
90    }
91
92    /// Layout a single list item
93    pub fn layout_item(
94        &self,
95        area_tree: &mut AreaTree,
96        y_position: Length,
97        label_content: Option<&str>,
98        body_height: Length,
99    ) -> Result<ListItemLayout> {
100        let item_height = body_height.max(Length::from_pt(12.0)); // Minimum height
101
102        // Create label area
103        let label_id = if let Some(label_text) = label_content {
104            let label_rect = Rect::from_point_size(
105                Point::new(Length::ZERO, y_position),
106                Size::new(self.label_width, item_height),
107            );
108            let mut label_area = Area::new(AreaType::Block, label_rect);
109            label_area.content = Some(AreaContent::Text(label_text.to_string()));
110
111            Some(area_tree.add_area(label_area))
112        } else {
113            None
114        };
115
116        // Create body area
117        let body_rect = Rect::from_point_size(
118            Point::new(self.body_start_x(), y_position),
119            Size::new(self.body_width(), item_height),
120        );
121        let body_area = Area::new(AreaType::Block, body_rect);
122        let body_id = Some(area_tree.add_area(body_area));
123
124        Ok(ListItemLayout {
125            y_position,
126            height: item_height,
127            label_id,
128            body_id,
129        })
130    }
131
132    /// Layout a complete list
133    pub fn layout_list(
134        &self,
135        area_tree: &mut AreaTree,
136        items: &[(Option<String>, Length)], // (label, body_height)
137        start_y: Length,
138    ) -> Result<Vec<ListItemLayout>> {
139        let mut layouts = Vec::new();
140        let mut current_y = start_y;
141
142        for (label, body_height) in items {
143            let layout = self.layout_item(area_tree, current_y, label.as_deref(), *body_height)?;
144
145            current_y += layout.height;
146            layouts.push(layout);
147        }
148
149        Ok(layouts)
150    }
151
152    /// Generate marker text based on list style
153    pub fn generate_marker(&self, index: usize, style: ListMarkerStyle) -> String {
154        match style {
155            ListMarkerStyle::Disc => "•".to_string(),
156            ListMarkerStyle::Circle => "○".to_string(),
157            ListMarkerStyle::Square => "■".to_string(),
158            ListMarkerStyle::Decimal => index.to_string(),
159            ListMarkerStyle::LowerAlpha => Self::to_alpha(index, false),
160            ListMarkerStyle::UpperAlpha => Self::to_alpha(index, true),
161            ListMarkerStyle::LowerRoman => Self::to_roman(index, false),
162            ListMarkerStyle::UpperRoman => Self::to_roman(index, true),
163            ListMarkerStyle::None => String::new(),
164        }
165    }
166
167    /// Convert number to alphabetic
168    fn to_alpha(mut n: usize, uppercase: bool) -> String {
169        if n == 0 {
170            return String::new();
171        }
172
173        let mut result = String::new();
174        while n > 0 {
175            n -= 1;
176            let ch = if uppercase {
177                (b'A' + (n % 26) as u8) as char
178            } else {
179                (b'a' + (n % 26) as u8) as char
180            };
181            result.insert(0, ch);
182            n /= 26;
183        }
184        result
185    }
186
187    /// Convert number to Roman numerals
188    fn to_roman(n: usize, uppercase: bool) -> String {
189        if n == 0 || n > 3999 {
190            return n.to_string();
191        }
192
193        let values = [1000, 900, 500, 400, 100, 90, 50, 40, 10, 9, 5, 4, 1];
194        let symbols_lower = [
195            "m", "cm", "d", "cd", "c", "xc", "l", "xl", "x", "ix", "v", "iv", "i",
196        ];
197        let symbols_upper = [
198            "M", "CM", "D", "CD", "C", "XC", "L", "XL", "X", "IX", "V", "IV", "I",
199        ];
200
201        let symbols = if uppercase {
202            symbols_upper
203        } else {
204            symbols_lower
205        };
206
207        let mut result = String::new();
208        let mut num = n;
209
210        for (i, &value) in values.iter().enumerate() {
211            while num >= value {
212                result.push_str(symbols[i]);
213                num -= value;
214            }
215        }
216
217        result
218    }
219}
220
221/// List marker styles
222#[derive(Debug, Clone, Copy, PartialEq, Eq)]
223pub enum ListMarkerStyle {
224    /// Bullet (•)
225    Disc,
226
227    /// Circle (○)
228    Circle,
229
230    /// Square (■)
231    Square,
232
233    /// Decimal numbers
234    Decimal,
235
236    /// Lowercase letters
237    LowerAlpha,
238
239    /// Uppercase letters
240    UpperAlpha,
241
242    /// Lowercase Roman numerals
243    LowerRoman,
244
245    /// Uppercase Roman numerals
246    UpperRoman,
247
248    /// No marker
249    None,
250}
251
252#[cfg(test)]
253mod tests {
254    use super::*;
255
256    #[test]
257    fn test_list_layout_creation() {
258        let layout = ListLayout::new(Length::from_pt(400.0));
259        assert_eq!(layout.available_width, Length::from_pt(400.0));
260        // Default: provisional-distance=24pt, separation=6pt => label_end=18pt
261        assert_eq!(layout.label_end(), Length::from_pt(18.0));
262        // Default body_start = provisional-distance = 24pt
263        assert_eq!(layout.body_start(), Length::from_pt(24.0));
264    }
265
266    #[test]
267    fn test_body_width_calculation() {
268        // provisional-distance=60pt, separation=10pt
269        // label_end = 60-10 = 50pt, body_start = 60pt
270        // body_width = 400 - 60 = 340pt
271        let layout = ListLayout::new(Length::from_pt(400.0))
272            .with_label_width(Length::from_pt(50.0))
273            .with_label_separation(Length::from_pt(10.0))
274            .with_body_start(Length::from_pt(60.0));
275
276        assert_eq!(layout.body_width(), Length::from_pt(340.0));
277    }
278
279    #[test]
280    fn test_body_start_position() {
281        let layout = ListLayout::new(Length::from_pt(400.0))
282            .with_label_width(Length::from_pt(50.0))
283            .with_label_separation(Length::from_pt(10.0))
284            .with_body_start(Length::from_pt(60.0));
285
286        // body-start() = provisional-distance-between-starts = 60pt
287        assert_eq!(layout.body_start(), Length::from_pt(60.0));
288        assert_eq!(layout.body_start_x(), Length::from_pt(60.0));
289    }
290
291    #[test]
292    fn test_layout_single_item() {
293        let layout = ListLayout::new(Length::from_pt(400.0));
294        let mut area_tree = AreaTree::new();
295
296        let item_layout = layout
297            .layout_item(
298                &mut area_tree,
299                Length::ZERO,
300                Some("1."),
301                Length::from_pt(20.0),
302            )
303            .expect("test: should succeed");
304
305        assert_eq!(item_layout.y_position, Length::ZERO);
306        assert_eq!(item_layout.height, Length::from_pt(20.0));
307        assert!(item_layout.label_id.is_some());
308        assert!(item_layout.body_id.is_some());
309    }
310
311    #[test]
312    fn test_layout_item_without_label() {
313        let layout = ListLayout::new(Length::from_pt(400.0));
314        let mut area_tree = AreaTree::new();
315
316        let item_layout = layout
317            .layout_item(&mut area_tree, Length::ZERO, None, Length::from_pt(20.0))
318            .expect("test: should succeed");
319
320        assert!(item_layout.label_id.is_none());
321        assert!(item_layout.body_id.is_some());
322    }
323
324    #[test]
325    fn test_layout_complete_list() {
326        let layout = ListLayout::new(Length::from_pt(400.0));
327        let mut area_tree = AreaTree::new();
328
329        let items = vec![
330            (Some("1.".to_string()), Length::from_pt(20.0)),
331            (Some("2.".to_string()), Length::from_pt(30.0)),
332            (Some("3.".to_string()), Length::from_pt(20.0)),
333        ];
334
335        let layouts = layout
336            .layout_list(&mut area_tree, &items, Length::ZERO)
337            .expect("test: should succeed");
338
339        assert_eq!(layouts.len(), 3);
340        assert_eq!(layouts[0].y_position, Length::ZERO);
341        assert_eq!(layouts[1].y_position, Length::from_pt(20.0));
342        assert_eq!(layouts[2].y_position, Length::from_pt(50.0));
343    }
344
345    #[test]
346    fn test_marker_disc() {
347        let layout = ListLayout::new(Length::from_pt(400.0));
348        assert_eq!(layout.generate_marker(1, ListMarkerStyle::Disc), "•");
349    }
350
351    #[test]
352    fn test_marker_decimal() {
353        let layout = ListLayout::new(Length::from_pt(400.0));
354        assert_eq!(layout.generate_marker(5, ListMarkerStyle::Decimal), "5");
355    }
356
357    #[test]
358    fn test_marker_lower_alpha() {
359        let layout = ListLayout::new(Length::from_pt(400.0));
360        assert_eq!(layout.generate_marker(1, ListMarkerStyle::LowerAlpha), "a");
361        assert_eq!(layout.generate_marker(26, ListMarkerStyle::LowerAlpha), "z");
362        assert_eq!(
363            layout.generate_marker(27, ListMarkerStyle::LowerAlpha),
364            "aa"
365        );
366    }
367
368    #[test]
369    fn test_marker_lower_roman() {
370        let layout = ListLayout::new(Length::from_pt(400.0));
371        assert_eq!(layout.generate_marker(1, ListMarkerStyle::LowerRoman), "i");
372        assert_eq!(layout.generate_marker(4, ListMarkerStyle::LowerRoman), "iv");
373        assert_eq!(
374            layout.generate_marker(1994, ListMarkerStyle::LowerRoman),
375            "mcmxciv"
376        );
377    }
378
379    #[test]
380    fn test_marker_upper_roman() {
381        let layout = ListLayout::new(Length::from_pt(400.0));
382        assert_eq!(layout.generate_marker(1, ListMarkerStyle::UpperRoman), "I");
383        assert_eq!(
384            layout.generate_marker(2023, ListMarkerStyle::UpperRoman),
385            "MMXXIII"
386        );
387    }
388
389    #[test]
390    fn test_marker_none() {
391        let layout = ListLayout::new(Length::from_pt(400.0));
392        assert_eq!(layout.generate_marker(1, ListMarkerStyle::None), "");
393    }
394    #[test]
395    fn test_roman_zero_returns_zero_string() {
396        // to_roman(0) returns "0" (can't represent zero in Roman numerals)
397        let layout = ListLayout::new(Length::from_pt(400.0));
398        let result = layout.generate_marker(0, ListMarkerStyle::LowerRoman);
399        assert_eq!(
400            result, "0",
401            "Zero should return '0' since it has no Roman form"
402        );
403    }
404
405    #[test]
406    fn test_roman_large_over_3999_returns_decimal() {
407        // to_roman(4000) returns "4000" (out of range for Roman numerals)
408        let layout = ListLayout::new(Length::from_pt(400.0));
409        let result = layout.generate_marker(4000, ListMarkerStyle::LowerRoman);
410        assert_eq!(result, "4000", "Numbers > 3999 should fall back to decimal");
411    }
412
413    #[test]
414    fn test_roman_3999_max_valid() {
415        let layout = ListLayout::new(Length::from_pt(400.0));
416        let result = layout.generate_marker(3999, ListMarkerStyle::UpperRoman);
417        assert_eq!(result, "MMMCMXCIX");
418    }
419
420    #[test]
421    fn test_roman_subtractive_notation() {
422        let layout = ListLayout::new(Length::from_pt(400.0));
423        assert_eq!(layout.generate_marker(4, ListMarkerStyle::LowerRoman), "iv");
424        assert_eq!(layout.generate_marker(9, ListMarkerStyle::LowerRoman), "ix");
425        assert_eq!(
426            layout.generate_marker(40, ListMarkerStyle::LowerRoman),
427            "xl"
428        );
429        assert_eq!(
430            layout.generate_marker(90, ListMarkerStyle::LowerRoman),
431            "xc"
432        );
433        assert_eq!(
434            layout.generate_marker(400, ListMarkerStyle::LowerRoman),
435            "cd"
436        );
437        assert_eq!(
438            layout.generate_marker(900, ListMarkerStyle::LowerRoman),
439            "cm"
440        );
441    }
442
443    #[test]
444    fn test_alpha_zero_returns_empty() {
445        // to_alpha(0) returns "" (undefined)
446        let layout = ListLayout::new(Length::from_pt(400.0));
447        let result = layout.generate_marker(0, ListMarkerStyle::LowerAlpha);
448        assert_eq!(result, "");
449    }
450
451    #[test]
452    fn test_alpha_overflow_27_is_aa() {
453        let layout = ListLayout::new(Length::from_pt(400.0));
454        assert_eq!(
455            layout.generate_marker(27, ListMarkerStyle::LowerAlpha),
456            "aa"
457        );
458    }
459
460    #[test]
461    fn test_alpha_overflow_52_is_az() {
462        // 52 = 26 + 26, so it's "az"
463        let layout = ListLayout::new(Length::from_pt(400.0));
464        assert_eq!(
465            layout.generate_marker(52, ListMarkerStyle::LowerAlpha),
466            "az"
467        );
468    }
469
470    #[test]
471    fn test_alpha_overflow_53_is_ba() {
472        // 53 = 2*26 + 1 -> "ba"
473        let layout = ListLayout::new(Length::from_pt(400.0));
474        assert_eq!(
475            layout.generate_marker(53, ListMarkerStyle::LowerAlpha),
476            "ba"
477        );
478    }
479
480    #[test]
481    fn test_alpha_upper_case() {
482        let layout = ListLayout::new(Length::from_pt(400.0));
483        assert_eq!(layout.generate_marker(1, ListMarkerStyle::UpperAlpha), "A");
484        assert_eq!(layout.generate_marker(26, ListMarkerStyle::UpperAlpha), "Z");
485        assert_eq!(
486            layout.generate_marker(27, ListMarkerStyle::UpperAlpha),
487            "AA"
488        );
489    }
490
491    #[test]
492    fn test_marker_circle_and_square() {
493        let layout = ListLayout::new(Length::from_pt(400.0));
494        assert_eq!(layout.generate_marker(1, ListMarkerStyle::Circle), "○");
495        assert_eq!(layout.generate_marker(1, ListMarkerStyle::Square), "■");
496    }
497}