oxidize_pdf/text/
layout.rs

1//! Multi-column layout support for PDF documents
2//!
3//! This module provides basic column support for newsletter-style documents
4//! with automatic text flow between columns.
5
6use crate::error::PdfError;
7use crate::graphics::{Color, GraphicsContext};
8use crate::text::{Font, TextAlign};
9
10/// Column layout configuration
11#[derive(Debug, Clone)]
12pub struct ColumnLayout {
13    /// Number of columns
14    column_count: usize,
15    /// Width of each column (in points)
16    column_widths: Vec<f64>,
17    /// Gap between columns (in points)
18    column_gap: f64,
19    /// Total layout width (in points)
20    total_width: f64,
21    /// Layout options
22    options: ColumnOptions,
23}
24
25/// Options for column layout
26#[derive(Debug, Clone)]
27pub struct ColumnOptions {
28    /// Font for column text
29    pub font: Font,
30    /// Font size in points
31    pub font_size: f64,
32    /// Line height multiplier
33    pub line_height: f64,
34    /// Text color
35    pub text_color: Color,
36    /// Text alignment within columns
37    pub text_align: TextAlign,
38    /// Whether to balance columns (distribute content evenly)
39    pub balance_columns: bool,
40    /// Whether to draw column separators
41    pub show_separators: bool,
42    /// Separator color
43    pub separator_color: Color,
44    /// Separator width
45    pub separator_width: f64,
46}
47
48impl Default for ColumnOptions {
49    fn default() -> Self {
50        Self {
51            font: Font::Helvetica,
52            font_size: 10.0,
53            line_height: 1.2,
54            text_color: Color::black(),
55            text_align: TextAlign::Left,
56            balance_columns: true,
57            show_separators: false,
58            separator_color: Color::gray(0.7),
59            separator_width: 0.5,
60        }
61    }
62}
63
64/// Text content for column layout
65#[derive(Debug, Clone)]
66pub struct ColumnContent {
67    /// Text content to flow across columns
68    text: String,
69    /// Formatting options
70    formatting: Vec<TextFormat>,
71}
72
73/// Text formatting information
74#[derive(Debug, Clone)]
75pub struct TextFormat {
76    /// Start position in text
77    #[allow(dead_code)]
78    start: usize,
79    /// End position in text
80    #[allow(dead_code)]
81    end: usize,
82    /// Font override
83    font: Option<Font>,
84    /// Font size override
85    font_size: Option<f64>,
86    /// Color override
87    color: Option<Color>,
88    /// Bold flag
89    bold: bool,
90    /// Italic flag
91    italic: bool,
92}
93
94/// Column flow context for managing text across columns
95#[derive(Debug)]
96pub struct ColumnFlowContext {
97    /// Current column being filled
98    current_column: usize,
99    /// Current Y position in each column
100    column_positions: Vec<f64>,
101    /// Height of each column
102    column_heights: Vec<f64>,
103    /// Content for each column
104    column_contents: Vec<Vec<String>>,
105}
106
107impl ColumnLayout {
108    /// Create a new column layout with equal column widths
109    pub fn new(column_count: usize, total_width: f64, column_gap: f64) -> Self {
110        if column_count == 0 {
111            panic!("Column count must be greater than 0");
112        }
113
114        let available_width = total_width - (column_gap * (column_count - 1) as f64);
115        let column_width = available_width / column_count as f64;
116        let column_widths = vec![column_width; column_count];
117
118        Self {
119            column_count,
120            column_widths,
121            column_gap,
122            total_width,
123            options: ColumnOptions::default(),
124        }
125    }
126
127    /// Create a new column layout with custom column widths
128    pub fn with_custom_widths(column_widths: Vec<f64>, column_gap: f64) -> Self {
129        let column_count = column_widths.len();
130        if column_count == 0 {
131            panic!("Must have at least one column");
132        }
133
134        let content_width: f64 = column_widths.iter().sum();
135        let total_width = content_width + (column_gap * (column_count - 1) as f64);
136
137        Self {
138            column_count,
139            column_widths,
140            column_gap,
141            total_width,
142            options: ColumnOptions::default(),
143        }
144    }
145
146    /// Set column options
147    pub fn set_options(&mut self, options: ColumnOptions) -> &mut Self {
148        self.options = options;
149        self
150    }
151
152    /// Get the number of columns
153    pub fn column_count(&self) -> usize {
154        self.column_count
155    }
156
157    /// Get the total width
158    pub fn total_width(&self) -> f64 {
159        self.total_width
160    }
161
162    /// Get column width by index
163    pub fn column_width(&self, index: usize) -> Option<f64> {
164        self.column_widths.get(index).copied()
165    }
166
167    /// Get the X position of a column
168    pub fn column_x_position(&self, index: usize) -> f64 {
169        let mut x = 0.0;
170        for i in 0..index.min(self.column_count) {
171            x += self.column_widths[i] + self.column_gap;
172        }
173        x
174    }
175
176    /// Create a flow context for managing text across columns
177    pub fn create_flow_context(&self, start_y: f64, column_height: f64) -> ColumnFlowContext {
178        ColumnFlowContext {
179            current_column: 0,
180            column_positions: vec![start_y; self.column_count],
181            column_heights: vec![column_height; self.column_count],
182            column_contents: vec![Vec::new(); self.column_count],
183        }
184    }
185
186    /// Render column layout with content
187    pub fn render(
188        &self,
189        graphics: &mut GraphicsContext,
190        content: &ColumnContent,
191        start_x: f64,
192        start_y: f64,
193        column_height: f64,
194    ) -> Result<(), PdfError> {
195        // Create flow context
196        let mut flow_context = self.create_flow_context(start_y, column_height);
197
198        // Split text into words for flowing
199        let words = self.split_text_into_words(&content.text);
200
201        // Flow text across columns
202        self.flow_text_across_columns(&words, &mut flow_context)?;
203
204        // Render each column
205        for (col_index, column_content) in flow_context.column_contents.iter().enumerate() {
206            let column_x = start_x + self.column_x_position(col_index);
207            self.render_column(graphics, column_content, column_x, start_y)?;
208        }
209
210        // Draw column separators if enabled
211        if self.options.show_separators {
212            self.draw_separators(graphics, start_x, start_y, column_height)?;
213        }
214
215        Ok(())
216    }
217
218    /// Split text into words for flowing
219    fn split_text_into_words(&self, text: &str) -> Vec<String> {
220        text.split_whitespace()
221            .map(|word| word.to_string())
222            .collect()
223    }
224
225    /// Flow text across columns
226    fn flow_text_across_columns(
227        &self,
228        words: &[String],
229        flow_context: &mut ColumnFlowContext,
230    ) -> Result<(), PdfError> {
231        let mut current_line = String::new();
232        let line_height = self.options.font_size * self.options.line_height;
233
234        for word in words {
235            // Check if adding this word would exceed column width
236            let test_line = if current_line.is_empty() {
237                word.clone()
238            } else {
239                format!("{current_line} {word}")
240            };
241
242            let line_width = self.estimate_text_width(&test_line);
243            let column_width = self.column_widths[flow_context.current_column];
244
245            if line_width <= column_width || current_line.is_empty() {
246                // Word fits in current line
247                current_line = test_line;
248            } else {
249                // Word doesn't fit, start new line
250                if !current_line.is_empty() {
251                    // Add current line to column
252                    flow_context.column_contents[flow_context.current_column]
253                        .push(current_line.clone());
254                    flow_context.column_positions[flow_context.current_column] -= line_height;
255
256                    // Check if we need to move to next column
257                    if flow_context.column_positions[flow_context.current_column]
258                        < flow_context.column_heights[flow_context.current_column] - line_height
259                    {
260                        // Move to next column if available
261                        if flow_context.current_column + 1 < self.column_count {
262                            flow_context.current_column += 1;
263                        }
264                    }
265                }
266                current_line = word.clone();
267            }
268        }
269
270        // Add final line if not empty
271        if !current_line.is_empty() {
272            flow_context.column_contents[flow_context.current_column].push(current_line);
273        }
274
275        // Balance columns if enabled
276        if self.options.balance_columns {
277            self.balance_column_content(flow_context)?;
278        }
279
280        Ok(())
281    }
282
283    /// Estimate text width (simple approximation)
284    fn estimate_text_width(&self, text: &str) -> f64 {
285        // Simple approximation: character count * font size * 0.6
286        text.len() as f64 * self.options.font_size * 0.6
287    }
288
289    /// Balance content across columns
290    fn balance_column_content(&self, flow_context: &mut ColumnFlowContext) -> Result<(), PdfError> {
291        // Collect all lines from all columns
292        let mut all_lines = Vec::new();
293        for column in &flow_context.column_contents {
294            all_lines.extend(column.iter().cloned());
295        }
296
297        // Clear existing column contents
298        for column in &mut flow_context.column_contents {
299            column.clear();
300        }
301
302        // Redistribute lines evenly across columns
303        let lines_per_column = all_lines.len().div_ceil(self.column_count);
304
305        for (line_index, line) in all_lines.into_iter().enumerate() {
306            let column_index = (line_index / lines_per_column).min(self.column_count - 1);
307            flow_context.column_contents[column_index].push(line);
308        }
309
310        Ok(())
311    }
312
313    /// Render a single column
314    fn render_column(
315        &self,
316        graphics: &mut GraphicsContext,
317        lines: &[String],
318        column_x: f64,
319        start_y: f64,
320    ) -> Result<(), PdfError> {
321        let line_height = self.options.font_size * self.options.line_height;
322        let mut current_y = start_y;
323
324        graphics.save_state();
325        graphics.set_font(self.options.font.clone(), self.options.font_size);
326        graphics.set_fill_color(self.options.text_color);
327
328        for line in lines {
329            graphics.begin_text();
330
331            match self.options.text_align {
332                TextAlign::Left => {
333                    graphics.set_text_position(column_x, current_y);
334                    graphics.show_text(line)?;
335                }
336                TextAlign::Center => {
337                    let line_width = self.estimate_text_width(line);
338                    let column_width = self.column_widths[0]; // Simplified for now
339                    let text_x = column_x + (column_width - line_width) / 2.0;
340                    graphics.set_text_position(text_x, current_y);
341                    graphics.show_text(line)?;
342                }
343                TextAlign::Right => {
344                    let line_width = self.estimate_text_width(line);
345                    let column_width = self.column_widths[0]; // Simplified for now
346                    let text_x = column_x + column_width - line_width;
347                    graphics.set_text_position(text_x, current_y);
348                    graphics.show_text(line)?;
349                }
350                TextAlign::Justified => {
351                    let column_width = self.column_widths[0]; // Simplified for now
352                    graphics.set_text_position(column_x, current_y);
353                    graphics.show_justified_text(line, column_width)?;
354                }
355            };
356            graphics.end_text();
357
358            current_y -= line_height;
359        }
360
361        graphics.restore_state();
362        Ok(())
363    }
364
365    /// Draw column separators
366    fn draw_separators(
367        &self,
368        graphics: &mut GraphicsContext,
369        start_x: f64,
370        start_y: f64,
371        column_height: f64,
372    ) -> Result<(), PdfError> {
373        if self.column_count <= 1 {
374            return Ok(());
375        }
376
377        graphics.save_state();
378        graphics.set_stroke_color(self.options.separator_color);
379        graphics.set_line_width(self.options.separator_width);
380
381        for i in 0..self.column_count - 1 {
382            let separator_x = start_x
383                + self.column_x_position(i)
384                + self.column_widths[i]
385                + (self.column_gap / 2.0);
386
387            graphics.move_to(separator_x, start_y);
388            graphics.line_to(separator_x, start_y - column_height);
389            graphics.stroke();
390        }
391
392        graphics.restore_state();
393        Ok(())
394    }
395}
396
397impl ColumnContent {
398    /// Create new column content
399    pub fn new(text: impl Into<String>) -> Self {
400        Self {
401            text: text.into(),
402            formatting: Vec::new(),
403        }
404    }
405
406    /// Add text formatting
407    pub fn add_format(&mut self, format: TextFormat) -> &mut Self {
408        self.formatting.push(format);
409        self
410    }
411
412    /// Get the text content
413    pub fn text(&self) -> &str {
414        &self.text
415    }
416}
417
418impl TextFormat {
419    /// Create a new text format
420    pub fn new(start: usize, end: usize) -> Self {
421        Self {
422            start,
423            end,
424            font: None,
425            font_size: None,
426            color: None,
427            bold: false,
428            italic: false,
429        }
430    }
431
432    /// Set font override
433    pub fn with_font(mut self, font: Font) -> Self {
434        self.font = Some(font);
435        self
436    }
437
438    /// Set font size override
439    pub fn with_font_size(mut self, size: f64) -> Self {
440        self.font_size = Some(size);
441        self
442    }
443
444    /// Set color override
445    pub fn with_color(mut self, color: Color) -> Self {
446        self.color = Some(color);
447        self
448    }
449
450    /// Set bold
451    pub fn bold(mut self) -> Self {
452        self.bold = true;
453        self
454    }
455
456    /// Set italic
457    pub fn italic(mut self) -> Self {
458        self.italic = true;
459        self
460    }
461}
462
463#[cfg(test)]
464mod tests {
465    use super::*;
466
467    #[test]
468    fn test_column_layout_creation() {
469        let layout = ColumnLayout::new(3, 600.0, 20.0);
470        assert_eq!(layout.column_count(), 3);
471        assert_eq!(layout.total_width(), 600.0);
472
473        // Each column should be (600 - 2*20) / 3 = 186.67 points wide
474        assert!((layout.column_width(0).unwrap() - 186.67).abs() < 0.01);
475    }
476
477    #[test]
478    fn test_custom_column_widths() {
479        let layout = ColumnLayout::with_custom_widths(vec![200.0, 150.0, 250.0], 15.0);
480        assert_eq!(layout.column_count(), 3);
481        assert_eq!(layout.total_width(), 630.0); // 200 + 150 + 250 + 2*15
482        assert_eq!(layout.column_width(0), Some(200.0));
483        assert_eq!(layout.column_width(1), Some(150.0));
484        assert_eq!(layout.column_width(2), Some(250.0));
485    }
486
487    #[test]
488    fn test_column_x_positions() {
489        let layout = ColumnLayout::with_custom_widths(vec![100.0, 200.0, 150.0], 20.0);
490        assert_eq!(layout.column_x_position(0), 0.0);
491        assert_eq!(layout.column_x_position(1), 120.0); // 100 + 20
492        assert_eq!(layout.column_x_position(2), 340.0); // 100 + 20 + 200 + 20
493    }
494
495    #[test]
496    fn test_column_options_default() {
497        let options = ColumnOptions::default();
498        assert_eq!(options.font, Font::Helvetica);
499        assert_eq!(options.font_size, 10.0);
500        assert_eq!(options.line_height, 1.2);
501        assert!(options.balance_columns);
502        assert!(!options.show_separators);
503    }
504
505    #[test]
506    fn test_column_content() {
507        let mut content = ColumnContent::new("Hello world");
508        assert_eq!(content.text(), "Hello world");
509
510        content.add_format(TextFormat::new(0, 5).bold());
511        assert_eq!(content.formatting.len(), 1);
512        assert!(content.formatting[0].bold);
513    }
514
515    #[test]
516    fn test_text_format() {
517        let format = TextFormat::new(0, 10)
518            .with_font(Font::HelveticaBold)
519            .with_font_size(14.0)
520            .with_color(Color::red())
521            .bold()
522            .italic();
523
524        assert_eq!(format.start, 0);
525        assert_eq!(format.end, 10);
526        assert_eq!(format.font, Some(Font::HelveticaBold));
527        assert_eq!(format.font_size, Some(14.0));
528        assert_eq!(format.color, Some(Color::red()));
529        assert!(format.bold);
530        assert!(format.italic);
531    }
532
533    #[test]
534    fn test_flow_context_creation() {
535        let layout = ColumnLayout::new(2, 400.0, 20.0);
536        let context = layout.create_flow_context(100.0, 500.0);
537
538        assert_eq!(context.current_column, 0);
539        assert_eq!(context.column_positions.len(), 2);
540        assert_eq!(context.column_heights.len(), 2);
541        assert_eq!(context.column_contents.len(), 2);
542        assert_eq!(context.column_positions[0], 100.0);
543        assert_eq!(context.column_heights[0], 500.0);
544    }
545
546    #[test]
547    fn test_text_width_estimation() {
548        let layout = ColumnLayout::new(1, 100.0, 0.0);
549        let width = layout.estimate_text_width("Hello");
550        assert_eq!(width, 5.0 * 10.0 * 0.6); // 5 chars * 10pt font * 0.6 factor
551    }
552
553    #[test]
554    fn test_split_text_into_words() {
555        let layout = ColumnLayout::new(1, 100.0, 0.0);
556        let words = layout.split_text_into_words("Hello world, this is a test");
557        assert_eq!(words, vec!["Hello", "world,", "this", "is", "a", "test"]);
558    }
559
560    #[test]
561    fn test_column_layout_with_options() {
562        let mut layout = ColumnLayout::new(2, 400.0, 20.0);
563        let options = ColumnOptions {
564            font: Font::TimesBold,
565            font_size: 12.0,
566            show_separators: true,
567            ..Default::default()
568        };
569
570        layout.set_options(options);
571        assert_eq!(layout.options.font, Font::TimesBold);
572        assert_eq!(layout.options.font_size, 12.0);
573        assert!(layout.options.show_separators);
574    }
575
576    #[test]
577    #[should_panic(expected = "Column count must be greater than 0")]
578    fn test_zero_columns_panic() {
579        ColumnLayout::new(0, 100.0, 10.0);
580    }
581
582    #[test]
583    #[should_panic(expected = "Must have at least one column")]
584    fn test_empty_custom_widths_panic() {
585        ColumnLayout::with_custom_widths(vec![], 10.0);
586    }
587
588    #[test]
589    fn test_column_width_out_of_bounds() {
590        let layout = ColumnLayout::new(2, 400.0, 20.0);
591        assert_eq!(layout.column_width(5), None);
592    }
593}