Skip to main content

forme/style/
mod.rs

1//! # Style System
2//!
3//! A CSS-like style model for document nodes. This is intentionally a subset
4//! of CSS that covers the properties needed for document layout: flexbox,
5//! box model, typography, color, borders.
6//!
7//! We don't try to implement all of CSS. We implement the parts that matter
8//! for PDF documents, and we implement them correctly.
9
10use crate::model::{Edges, MarginEdges, Position};
11use serde::{Deserialize, Serialize};
12
13/// The complete set of style properties for a node.
14#[derive(Debug, Clone, Default, Serialize, Deserialize)]
15#[serde(rename_all = "camelCase")]
16pub struct Style {
17    // ── Box Model ──────────────────────────────────────────────
18    /// Explicit width in points.
19    pub width: Option<Dimension>,
20    /// Explicit height in points.
21    pub height: Option<Dimension>,
22    /// Minimum width.
23    pub min_width: Option<Dimension>,
24    /// Minimum height.
25    pub min_height: Option<Dimension>,
26    /// Maximum width.
27    pub max_width: Option<Dimension>,
28    /// Maximum height.
29    pub max_height: Option<Dimension>,
30
31    /// Padding inside the border.
32    #[serde(default)]
33    pub padding: Option<Edges>,
34    /// Margin outside the border. Supports auto values for centering.
35    #[serde(default)]
36    pub margin: Option<MarginEdges>,
37
38    // ── Display & Layout Mode ──────────────────────────────────
39    /// Display mode: flex (default) or grid.
40    pub display: Option<Display>,
41
42    // ── Flexbox Layout ─────────────────────────────────────────
43    /// Direction of the main axis.
44    #[serde(default)]
45    pub flex_direction: Option<FlexDirection>,
46    /// How to distribute space along the main axis.
47    #[serde(default)]
48    pub justify_content: Option<JustifyContent>,
49    /// How to align items along the cross axis.
50    #[serde(default)]
51    pub align_items: Option<AlignItems>,
52    /// Override align-items for this specific child.
53    #[serde(default)]
54    pub align_self: Option<AlignItems>,
55    /// Whether flex items wrap to new lines.
56    #[serde(default)]
57    pub flex_wrap: Option<FlexWrap>,
58    /// How to distribute space between flex lines on the cross axis.
59    pub align_content: Option<AlignContent>,
60    /// Flex grow factor.
61    pub flex_grow: Option<f64>,
62    /// Flex shrink factor.
63    pub flex_shrink: Option<f64>,
64    /// Flex basis (initial main size).
65    pub flex_basis: Option<Dimension>,
66    /// Gap between flex items.
67    pub gap: Option<f64>,
68    /// Row gap (overrides gap for rows).
69    pub row_gap: Option<f64>,
70    /// Column gap (overrides gap for columns).
71    pub column_gap: Option<f64>,
72
73    // ── CSS Grid Layout ──────────────────────────────────────────
74    /// Column track definitions (e.g., `[Pt(100), Fr(1), Fr(2)]`).
75    pub grid_template_columns: Option<Vec<GridTrackSize>>,
76    /// Row track definitions.
77    pub grid_template_rows: Option<Vec<GridTrackSize>>,
78    /// Auto-generated row size.
79    pub grid_auto_rows: Option<GridTrackSize>,
80    /// Auto-generated column size.
81    pub grid_auto_columns: Option<GridTrackSize>,
82    /// Grid placement for this child item.
83    pub grid_placement: Option<GridPlacement>,
84
85    // ── Typography ─────────────────────────────────────────────
86    /// Font family name.
87    pub font_family: Option<String>,
88    /// Font size in points.
89    pub font_size: Option<f64>,
90    /// Font weight (100-900).
91    pub font_weight: Option<u32>,
92    /// Font style.
93    pub font_style: Option<FontStyle>,
94    /// Line height as a multiplier of font size.
95    pub line_height: Option<f64>,
96    /// Text alignment within the text block.
97    pub text_align: Option<TextAlign>,
98    /// Letter spacing in points.
99    pub letter_spacing: Option<f64>,
100    /// Text decoration.
101    pub text_decoration: Option<TextDecoration>,
102    /// Text transform.
103    pub text_transform: Option<TextTransform>,
104    /// Hyphenation mode (CSS `hyphens` property).
105    pub hyphens: Option<Hyphens>,
106    /// BCP 47 language tag for hyphenation and line breaking.
107    pub lang: Option<String>,
108    /// Text direction (ltr, rtl, or auto).
109    pub direction: Option<Direction>,
110    /// Text overflow behavior (wrap, ellipsis, clip).
111    pub text_overflow: Option<TextOverflow>,
112    /// Line breaking algorithm: optimal (Knuth-Plass, default) or greedy.
113    pub line_breaking: Option<LineBreaking>,
114
115    /// Overflow behavior for container elements.
116    pub overflow: Option<Overflow>,
117
118    // ── Color & Background ─────────────────────────────────────
119    /// Text color.
120    pub color: Option<Color>,
121    /// Background color.
122    pub background_color: Option<Color>,
123    /// Opacity (0.0 - 1.0).
124    pub opacity: Option<f64>,
125
126    // ── Border ─────────────────────────────────────────────────
127    /// Border width for all sides.
128    pub border_width: Option<EdgeValues<f64>>,
129    /// Border color for all sides.
130    pub border_color: Option<EdgeValues<Color>>,
131    /// Border radius (uniform or per-corner).
132    pub border_radius: Option<CornerValues>,
133
134    // ── Positioning ─────────────────────────────────────────────
135    /// Positioning mode (relative or absolute).
136    pub position: Option<Position>,
137    /// Top offset (for absolute positioning).
138    pub top: Option<f64>,
139    /// Right offset (for absolute positioning).
140    pub right: Option<f64>,
141    /// Bottom offset (for absolute positioning).
142    pub bottom: Option<f64>,
143    /// Left offset (for absolute positioning).
144    pub left: Option<f64>,
145
146    // ── Page Behavior ──────────────────────────────────────────
147    /// Whether this node can be broken across pages.
148    /// `true` = breakable (default for View, Text, Table).
149    /// `false` = keep on one page; if it doesn't fit, move to next page.
150    pub wrap: Option<bool>,
151
152    /// Force a page break before this node.
153    pub break_before: Option<bool>,
154
155    /// Minimum number of lines to keep at the bottom of a page before
156    /// breaking (widow control). Default: 2.
157    pub min_widow_lines: Option<u32>,
158
159    /// Minimum number of lines to keep at the top of a new page after
160    /// breaking (orphan control). Default: 2.
161    pub min_orphan_lines: Option<u32>,
162}
163
164/// A dimension that can be points, percentage, or auto.
165#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
166pub enum Dimension {
167    /// Fixed size in points (1/72 inch).
168    Pt(f64),
169    /// Percentage of parent's corresponding dimension.
170    Percent(f64),
171    /// Size determined by content.
172    Auto,
173}
174
175impl Dimension {
176    /// Resolve this dimension given a parent size.
177    /// Returns None for Auto.
178    pub fn resolve(&self, parent_size: f64) -> Option<f64> {
179        match self {
180            Dimension::Pt(v) => Some(*v),
181            Dimension::Percent(p) => Some(parent_size * p / 100.0),
182            Dimension::Auto => None,
183        }
184    }
185}
186
187/// Layout display mode.
188#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
189pub enum Display {
190    /// Flexbox layout (default).
191    #[default]
192    Flex,
193    /// CSS Grid layout.
194    Grid,
195}
196
197/// A single grid track size definition.
198#[derive(Debug, Clone, Serialize, Deserialize)]
199pub enum GridTrackSize {
200    /// Fixed size in points.
201    Pt(f64),
202    /// Fractional unit (distributes remaining space proportionally).
203    Fr(f64),
204    /// Size determined by content.
205    Auto,
206    /// Clamped between min and max.
207    MinMax(Box<GridTrackSize>, Box<GridTrackSize>),
208}
209
210/// Grid item placement.
211#[derive(Debug, Clone, Default, Serialize, Deserialize)]
212#[serde(rename_all = "camelCase")]
213pub struct GridPlacement {
214    /// Column start line (1-based).
215    pub column_start: Option<i32>,
216    /// Column end line (1-based).
217    pub column_end: Option<i32>,
218    /// Row start line (1-based).
219    pub row_start: Option<i32>,
220    /// Row end line (1-based).
221    pub row_end: Option<i32>,
222    /// Number of columns to span.
223    pub column_span: Option<u32>,
224    /// Number of rows to span.
225    pub row_span: Option<u32>,
226}
227
228#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
229pub enum FlexDirection {
230    #[default]
231    Column,
232    Row,
233    ColumnReverse,
234    RowReverse,
235}
236
237#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
238pub enum JustifyContent {
239    #[default]
240    FlexStart,
241    FlexEnd,
242    Center,
243    SpaceBetween,
244    SpaceAround,
245    SpaceEvenly,
246}
247
248#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
249pub enum AlignItems {
250    FlexStart,
251    FlexEnd,
252    Center,
253    #[default]
254    Stretch,
255    Baseline,
256}
257
258#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
259pub enum FlexWrap {
260    #[default]
261    NoWrap,
262    Wrap,
263    WrapReverse,
264}
265
266#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
267pub enum AlignContent {
268    #[default]
269    FlexStart,
270    FlexEnd,
271    Center,
272    SpaceBetween,
273    SpaceAround,
274    SpaceEvenly,
275    Stretch,
276}
277
278#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
279pub enum FontStyle {
280    #[default]
281    Normal,
282    Italic,
283    Oblique,
284}
285
286#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
287pub enum TextAlign {
288    #[default]
289    Left,
290    Right,
291    Center,
292    Justify,
293}
294
295#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
296pub enum TextDecoration {
297    #[default]
298    None,
299    Underline,
300    LineThrough,
301}
302
303#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
304pub enum TextTransform {
305    #[default]
306    None,
307    Uppercase,
308    Lowercase,
309    Capitalize,
310}
311
312/// Overflow behavior for container elements.
313#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
314pub enum Overflow {
315    /// Content can overflow the container bounds (default).
316    #[default]
317    Visible,
318    /// Content is clipped to the container bounds.
319    Hidden,
320}
321
322/// Text overflow behavior when text exceeds available width.
323#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
324pub enum TextOverflow {
325    /// Normal wrapping (default).
326    #[default]
327    Wrap,
328    /// Single-line truncation with "..." appended.
329    Ellipsis,
330    /// Single-line truncation without any indicator.
331    Clip,
332}
333
334/// Text direction for BiDi support.
335#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
336#[serde(rename_all = "lowercase")]
337pub enum Direction {
338    /// Left-to-right (default).
339    #[default]
340    Ltr,
341    /// Right-to-left (Arabic, Hebrew).
342    Rtl,
343    /// Auto-detect from first strong character.
344    Auto,
345}
346
347/// Line breaking algorithm.
348#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
349#[serde(rename_all = "lowercase")]
350pub enum LineBreaking {
351    /// Knuth-Plass optimal line breaking (default). Minimizes global raggedness.
352    #[default]
353    Optimal,
354    /// Simple greedy line breaking. Fills lines left-to-right, breaks at first overflow.
355    Greedy,
356}
357
358/// CSS `hyphens` property controlling word hyphenation.
359#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
360#[serde(rename_all = "lowercase")]
361pub enum Hyphens {
362    /// No hyphenation, not even at soft hyphens.
363    None,
364    /// Only break at soft hyphens (U+00AD) in the text.
365    #[default]
366    Manual,
367    /// Algorithmic hyphenation using language rules.
368    Auto,
369}
370
371/// An RGBA color.
372#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
373pub struct Color {
374    pub r: f64, // 0.0 - 1.0
375    pub g: f64,
376    pub b: f64,
377    pub a: f64,
378}
379
380impl Color {
381    pub const BLACK: Color = Color {
382        r: 0.0,
383        g: 0.0,
384        b: 0.0,
385        a: 1.0,
386    };
387    pub const WHITE: Color = Color {
388        r: 1.0,
389        g: 1.0,
390        b: 1.0,
391        a: 1.0,
392    };
393    pub const TRANSPARENT: Color = Color {
394        r: 0.0,
395        g: 0.0,
396        b: 0.0,
397        a: 0.0,
398    };
399
400    pub fn rgb(r: f64, g: f64, b: f64) -> Self {
401        Self { r, g, b, a: 1.0 }
402    }
403
404    pub fn hex(hex: &str) -> Self {
405        let hex = hex.trim_start_matches('#');
406        let (r, g, b) = match hex.len() {
407            3 => {
408                let r = u8::from_str_radix(&hex[0..1].repeat(2), 16).unwrap_or(0);
409                let g = u8::from_str_radix(&hex[1..2].repeat(2), 16).unwrap_or(0);
410                let b = u8::from_str_radix(&hex[2..3].repeat(2), 16).unwrap_or(0);
411                (r, g, b)
412            }
413            6 => {
414                let r = u8::from_str_radix(&hex[0..2], 16).unwrap_or(0);
415                let g = u8::from_str_radix(&hex[2..4], 16).unwrap_or(0);
416                let b = u8::from_str_radix(&hex[4..6], 16).unwrap_or(0);
417                (r, g, b)
418            }
419            _ => (0, 0, 0),
420        };
421        Self {
422            r: r as f64 / 255.0,
423            g: g as f64 / 255.0,
424            b: b as f64 / 255.0,
425            a: 1.0,
426        }
427    }
428}
429
430impl Default for Color {
431    fn default() -> Self {
432        Color::BLACK
433    }
434}
435
436/// Values for each edge (top, right, bottom, left).
437#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
438pub struct EdgeValues<T: Copy> {
439    pub top: T,
440    pub right: T,
441    pub bottom: T,
442    pub left: T,
443}
444
445impl<T: Copy> EdgeValues<T> {
446    pub fn uniform(v: T) -> Self {
447        Self {
448            top: v,
449            right: v,
450            bottom: v,
451            left: v,
452        }
453    }
454}
455
456/// Values for each corner (top-left, top-right, bottom-right, bottom-left).
457#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
458pub struct CornerValues {
459    pub top_left: f64,
460    pub top_right: f64,
461    pub bottom_right: f64,
462    pub bottom_left: f64,
463}
464
465impl CornerValues {
466    pub fn uniform(v: f64) -> Self {
467        Self {
468            top_left: v,
469            top_right: v,
470            bottom_right: v,
471            bottom_left: v,
472        }
473    }
474}
475
476/// Resolved style: all values are concrete (no Option, no Auto for computed values).
477/// This is what the layout engine works with after style resolution.
478#[derive(Debug, Clone)]
479pub struct ResolvedStyle {
480    // Box model
481    pub width: SizeConstraint,
482    pub height: SizeConstraint,
483    pub min_width: f64,
484    pub min_height: f64,
485    pub max_width: f64,
486    pub max_height: f64,
487    pub padding: Edges,
488    pub margin: MarginEdges,
489
490    // Display
491    pub display: Display,
492
493    // Flex
494    pub flex_direction: FlexDirection,
495    pub justify_content: JustifyContent,
496    pub align_items: AlignItems,
497    pub align_self: Option<AlignItems>,
498    pub flex_wrap: FlexWrap,
499    pub align_content: AlignContent,
500    pub flex_grow: f64,
501    pub flex_shrink: f64,
502    pub flex_basis: SizeConstraint,
503    pub gap: f64,
504    pub row_gap: f64,
505    pub column_gap: f64,
506
507    // Grid
508    pub grid_template_columns: Option<Vec<GridTrackSize>>,
509    pub grid_template_rows: Option<Vec<GridTrackSize>>,
510    pub grid_auto_rows: Option<GridTrackSize>,
511    pub grid_auto_columns: Option<GridTrackSize>,
512    pub grid_placement: Option<GridPlacement>,
513
514    // Text
515    pub font_family: String,
516    pub font_size: f64,
517    pub font_weight: u32,
518    pub font_style: FontStyle,
519    pub line_height: f64,
520    pub text_align: TextAlign,
521    pub letter_spacing: f64,
522    pub text_decoration: TextDecoration,
523    pub text_transform: TextTransform,
524    pub hyphens: Hyphens,
525    pub lang: Option<String>,
526    pub direction: Direction,
527    pub text_overflow: TextOverflow,
528    pub line_breaking: LineBreaking,
529
530    // Visual
531    pub color: Color,
532    pub background_color: Option<Color>,
533    pub opacity: f64,
534    pub overflow: Overflow,
535    pub border_width: Edges,
536    pub border_color: EdgeValues<Color>,
537    pub border_radius: CornerValues,
538
539    // Positioning
540    pub position: Position,
541    pub top: Option<f64>,
542    pub right: Option<f64>,
543    pub bottom: Option<f64>,
544    pub left: Option<f64>,
545
546    // Page behavior
547    pub breakable: bool,
548    pub break_before: bool,
549    pub min_widow_lines: u32,
550    pub min_orphan_lines: u32,
551}
552
553#[derive(Debug, Clone, Copy)]
554pub enum SizeConstraint {
555    Fixed(f64),
556    Auto,
557}
558
559impl Style {
560    /// Resolve this style against a parent's resolved style and available dimensions.
561    pub fn resolve(&self, parent: Option<&ResolvedStyle>, available_width: f64) -> ResolvedStyle {
562        let parent_font_size = parent.map(|p| p.font_size).unwrap_or(12.0);
563        let parent_color = parent.map(|p| p.color).unwrap_or(Color::BLACK);
564        let parent_font_family = parent
565            .map(|p| p.font_family.clone())
566            .unwrap_or_else(|| "Helvetica".to_string());
567
568        let font_size = self.font_size.unwrap_or(parent_font_size);
569
570        ResolvedStyle {
571            width: self
572                .width
573                .map(|d| match d {
574                    Dimension::Pt(v) => SizeConstraint::Fixed(v),
575                    Dimension::Percent(p) => SizeConstraint::Fixed(available_width * p / 100.0),
576                    Dimension::Auto => SizeConstraint::Auto,
577                })
578                .unwrap_or(SizeConstraint::Auto),
579
580            height: self
581                .height
582                .map(|d| match d {
583                    Dimension::Pt(v) => SizeConstraint::Fixed(v),
584                    Dimension::Percent(p) => SizeConstraint::Fixed(p), // height % is complex, simplified
585                    Dimension::Auto => SizeConstraint::Auto,
586                })
587                .unwrap_or(SizeConstraint::Auto),
588
589            min_width: self
590                .min_width
591                .and_then(|d| d.resolve(available_width))
592                .unwrap_or(0.0),
593            min_height: self.min_height.and_then(|d| d.resolve(0.0)).unwrap_or(0.0),
594            max_width: self
595                .max_width
596                .and_then(|d| d.resolve(available_width))
597                .unwrap_or(f64::INFINITY),
598            max_height: self
599                .max_height
600                .and_then(|d| d.resolve(0.0))
601                .unwrap_or(f64::INFINITY),
602
603            padding: self.padding.unwrap_or_default(),
604            margin: self.margin.unwrap_or_default(),
605
606            display: self.display.unwrap_or_default(),
607
608            flex_direction: self.flex_direction.unwrap_or_default(),
609            justify_content: self.justify_content.unwrap_or_default(),
610            align_items: self.align_items.unwrap_or_default(),
611            align_self: self.align_self,
612            flex_wrap: self.flex_wrap.unwrap_or_default(),
613            align_content: self.align_content.unwrap_or_default(),
614            flex_grow: self.flex_grow.unwrap_or(0.0),
615            flex_shrink: self.flex_shrink.unwrap_or(1.0),
616            flex_basis: self
617                .flex_basis
618                .map(|d| match d {
619                    Dimension::Pt(v) => SizeConstraint::Fixed(v),
620                    Dimension::Percent(p) => SizeConstraint::Fixed(available_width * p / 100.0),
621                    Dimension::Auto => SizeConstraint::Auto,
622                })
623                .unwrap_or(SizeConstraint::Auto),
624            gap: self.gap.unwrap_or(0.0),
625            row_gap: self.row_gap.or(self.gap).unwrap_or(0.0),
626            column_gap: self.column_gap.or(self.gap).unwrap_or(0.0),
627
628            grid_template_columns: self.grid_template_columns.clone(),
629            grid_template_rows: self.grid_template_rows.clone(),
630            grid_auto_rows: self.grid_auto_rows.clone(),
631            grid_auto_columns: self.grid_auto_columns.clone(),
632            grid_placement: self.grid_placement.clone(),
633
634            font_family: self.font_family.clone().unwrap_or(parent_font_family),
635            font_size,
636            font_weight: self
637                .font_weight
638                .unwrap_or(parent.map(|p| p.font_weight).unwrap_or(400)),
639            font_style: self
640                .font_style
641                .unwrap_or(parent.map(|p| p.font_style).unwrap_or_default()),
642            line_height: self
643                .line_height
644                .unwrap_or(parent.map(|p| p.line_height).unwrap_or(1.4)),
645            text_align: {
646                let direction = self
647                    .direction
648                    .unwrap_or(parent.map(|p| p.direction).unwrap_or_default());
649                self.text_align.unwrap_or_else(|| {
650                    if matches!(direction, Direction::Rtl) {
651                        TextAlign::Right
652                    } else {
653                        parent.map(|p| p.text_align).unwrap_or_default()
654                    }
655                })
656            },
657            letter_spacing: self.letter_spacing.unwrap_or(0.0),
658            text_decoration: self
659                .text_decoration
660                .unwrap_or(parent.map(|p| p.text_decoration).unwrap_or_default()),
661            text_transform: self
662                .text_transform
663                .unwrap_or(parent.map(|p| p.text_transform).unwrap_or_default()),
664            hyphens: self
665                .hyphens
666                .unwrap_or(parent.map(|p| p.hyphens).unwrap_or_default()),
667            lang: self
668                .lang
669                .clone()
670                .or_else(|| parent.and_then(|p| p.lang.clone())),
671            direction: self
672                .direction
673                .unwrap_or(parent.map(|p| p.direction).unwrap_or_default()),
674            text_overflow: self.text_overflow.unwrap_or_default(),
675            line_breaking: self
676                .line_breaking
677                .unwrap_or(parent.map(|p| p.line_breaking).unwrap_or_default()),
678
679            color: self.color.unwrap_or(parent_color),
680            background_color: self.background_color,
681            opacity: self.opacity.unwrap_or(1.0),
682            overflow: self.overflow.unwrap_or_default(),
683
684            border_width: self
685                .border_width
686                .map(|e| Edges {
687                    top: e.top,
688                    right: e.right,
689                    bottom: e.bottom,
690                    left: e.left,
691                })
692                .unwrap_or_default(),
693
694            border_color: self
695                .border_color
696                .unwrap_or(EdgeValues::uniform(Color::BLACK)),
697            border_radius: self.border_radius.unwrap_or(CornerValues::uniform(0.0)),
698
699            position: self.position.unwrap_or_default(),
700            top: self.top,
701            right: self.right,
702            bottom: self.bottom,
703            left: self.left,
704
705            breakable: self.wrap.unwrap_or(true),
706            break_before: self.break_before.unwrap_or(false),
707            min_widow_lines: self.min_widow_lines.unwrap_or(2),
708            min_orphan_lines: self.min_orphan_lines.unwrap_or(2),
709        }
710    }
711}