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            let text_x = match self.options.text_align {
332                TextAlign::Left => column_x,
333                TextAlign::Center => {
334                    let line_width = self.estimate_text_width(line);
335                    let column_width = self.column_widths[0]; // Simplified for now
336                    column_x + (column_width - line_width) / 2.0
337                }
338                TextAlign::Right => {
339                    let line_width = self.estimate_text_width(line);
340                    let column_width = self.column_widths[0]; // Simplified for now
341                    column_x + column_width - line_width
342                }
343                TextAlign::Justified => column_x, // TODO: Implement justification
344            };
345
346            graphics.set_text_position(text_x, current_y);
347            graphics.show_text(line)?;
348            graphics.end_text();
349
350            current_y -= line_height;
351        }
352
353        graphics.restore_state();
354        Ok(())
355    }
356
357    /// Draw column separators
358    fn draw_separators(
359        &self,
360        graphics: &mut GraphicsContext,
361        start_x: f64,
362        start_y: f64,
363        column_height: f64,
364    ) -> Result<(), PdfError> {
365        if self.column_count <= 1 {
366            return Ok(());
367        }
368
369        graphics.save_state();
370        graphics.set_stroke_color(self.options.separator_color);
371        graphics.set_line_width(self.options.separator_width);
372
373        for i in 0..self.column_count - 1 {
374            let separator_x = start_x
375                + self.column_x_position(i)
376                + self.column_widths[i]
377                + (self.column_gap / 2.0);
378
379            graphics.move_to(separator_x, start_y);
380            graphics.line_to(separator_x, start_y - column_height);
381            graphics.stroke();
382        }
383
384        graphics.restore_state();
385        Ok(())
386    }
387}
388
389impl ColumnContent {
390    /// Create new column content
391    pub fn new(text: impl Into<String>) -> Self {
392        Self {
393            text: text.into(),
394            formatting: Vec::new(),
395        }
396    }
397
398    /// Add text formatting
399    pub fn add_format(&mut self, format: TextFormat) -> &mut Self {
400        self.formatting.push(format);
401        self
402    }
403
404    /// Get the text content
405    pub fn text(&self) -> &str {
406        &self.text
407    }
408}
409
410impl TextFormat {
411    /// Create a new text format
412    pub fn new(start: usize, end: usize) -> Self {
413        Self {
414            start,
415            end,
416            font: None,
417            font_size: None,
418            color: None,
419            bold: false,
420            italic: false,
421        }
422    }
423
424    /// Set font override
425    pub fn with_font(mut self, font: Font) -> Self {
426        self.font = Some(font);
427        self
428    }
429
430    /// Set font size override
431    pub fn with_font_size(mut self, size: f64) -> Self {
432        self.font_size = Some(size);
433        self
434    }
435
436    /// Set color override
437    pub fn with_color(mut self, color: Color) -> Self {
438        self.color = Some(color);
439        self
440    }
441
442    /// Set bold
443    pub fn bold(mut self) -> Self {
444        self.bold = true;
445        self
446    }
447
448    /// Set italic
449    pub fn italic(mut self) -> Self {
450        self.italic = true;
451        self
452    }
453}
454
455#[cfg(test)]
456mod tests {
457    use super::*;
458
459    #[test]
460    fn test_column_layout_creation() {
461        let layout = ColumnLayout::new(3, 600.0, 20.0);
462        assert_eq!(layout.column_count(), 3);
463        assert_eq!(layout.total_width(), 600.0);
464
465        // Each column should be (600 - 2*20) / 3 = 186.67 points wide
466        assert!((layout.column_width(0).unwrap() - 186.67).abs() < 0.01);
467    }
468
469    #[test]
470    fn test_custom_column_widths() {
471        let layout = ColumnLayout::with_custom_widths(vec![200.0, 150.0, 250.0], 15.0);
472        assert_eq!(layout.column_count(), 3);
473        assert_eq!(layout.total_width(), 630.0); // 200 + 150 + 250 + 2*15
474        assert_eq!(layout.column_width(0), Some(200.0));
475        assert_eq!(layout.column_width(1), Some(150.0));
476        assert_eq!(layout.column_width(2), Some(250.0));
477    }
478
479    #[test]
480    fn test_column_x_positions() {
481        let layout = ColumnLayout::with_custom_widths(vec![100.0, 200.0, 150.0], 20.0);
482        assert_eq!(layout.column_x_position(0), 0.0);
483        assert_eq!(layout.column_x_position(1), 120.0); // 100 + 20
484        assert_eq!(layout.column_x_position(2), 340.0); // 100 + 20 + 200 + 20
485    }
486
487    #[test]
488    fn test_column_options_default() {
489        let options = ColumnOptions::default();
490        assert_eq!(options.font, Font::Helvetica);
491        assert_eq!(options.font_size, 10.0);
492        assert_eq!(options.line_height, 1.2);
493        assert!(options.balance_columns);
494        assert!(!options.show_separators);
495    }
496
497    #[test]
498    fn test_column_content() {
499        let mut content = ColumnContent::new("Hello world");
500        assert_eq!(content.text(), "Hello world");
501
502        content.add_format(TextFormat::new(0, 5).bold());
503        assert_eq!(content.formatting.len(), 1);
504        assert!(content.formatting[0].bold);
505    }
506
507    #[test]
508    fn test_text_format() {
509        let format = TextFormat::new(0, 10)
510            .with_font(Font::HelveticaBold)
511            .with_font_size(14.0)
512            .with_color(Color::red())
513            .bold()
514            .italic();
515
516        assert_eq!(format.start, 0);
517        assert_eq!(format.end, 10);
518        assert_eq!(format.font, Some(Font::HelveticaBold));
519        assert_eq!(format.font_size, Some(14.0));
520        assert_eq!(format.color, Some(Color::red()));
521        assert!(format.bold);
522        assert!(format.italic);
523    }
524
525    #[test]
526    fn test_flow_context_creation() {
527        let layout = ColumnLayout::new(2, 400.0, 20.0);
528        let context = layout.create_flow_context(100.0, 500.0);
529
530        assert_eq!(context.current_column, 0);
531        assert_eq!(context.column_positions.len(), 2);
532        assert_eq!(context.column_heights.len(), 2);
533        assert_eq!(context.column_contents.len(), 2);
534        assert_eq!(context.column_positions[0], 100.0);
535        assert_eq!(context.column_heights[0], 500.0);
536    }
537
538    #[test]
539    fn test_text_width_estimation() {
540        let layout = ColumnLayout::new(1, 100.0, 0.0);
541        let width = layout.estimate_text_width("Hello");
542        assert_eq!(width, 5.0 * 10.0 * 0.6); // 5 chars * 10pt font * 0.6 factor
543    }
544
545    #[test]
546    fn test_split_text_into_words() {
547        let layout = ColumnLayout::new(1, 100.0, 0.0);
548        let words = layout.split_text_into_words("Hello world, this is a test");
549        assert_eq!(words, vec!["Hello", "world,", "this", "is", "a", "test"]);
550    }
551
552    #[test]
553    fn test_column_layout_with_options() {
554        let mut layout = ColumnLayout::new(2, 400.0, 20.0);
555        let options = ColumnOptions {
556            font: Font::TimesBold,
557            font_size: 12.0,
558            show_separators: true,
559            ..Default::default()
560        };
561
562        layout.set_options(options);
563        assert_eq!(layout.options.font, Font::TimesBold);
564        assert_eq!(layout.options.font_size, 12.0);
565        assert!(layout.options.show_separators);
566    }
567
568    #[test]
569    #[should_panic(expected = "Column count must be greater than 0")]
570    fn test_zero_columns_panic() {
571        ColumnLayout::new(0, 100.0, 10.0);
572    }
573
574    #[test]
575    #[should_panic(expected = "Must have at least one column")]
576    fn test_empty_custom_widths_panic() {
577        ColumnLayout::with_custom_widths(vec![], 10.0);
578    }
579
580    #[test]
581    fn test_column_width_out_of_bounds() {
582        let layout = ColumnLayout::new(2, 400.0, 20.0);
583        assert_eq!(layout.column_width(5), None);
584    }
585}