Skip to main content

graphitepdf_style/
lib.rs

1pub use graphitepdf_font::{
2    FontDescriptor, FontSource, FontStyle, FontWeight as FontVariantWeight, StandardFont,
3};
4pub use graphitepdf_layout::EdgeInsets;
5use graphitepdf_primitives::{Color, Pt};
6
7pub use graphitepdf_stylesheet::{
8    Container as StylesheetContainer, ExpandedStyle as StylesheetExpandedStyle,
9    SafeStyle as StylesheetSafeStyle, Style as StylesheetMap, StyleValue, Stylesheet,
10};
11
12#[derive(Clone, Debug, PartialEq)]
13pub struct Style {
14    pub width: Option<Pt>,
15    pub height: Option<Pt>,
16    pub margin: EdgeInsets,
17    pub padding: EdgeInsets,
18    pub background_color: Option<Color>,
19    pub color: Option<Color>,
20    pub font_size: Option<Pt>,
21    pub font_family: Option<String>,
22    pub font_style: Option<FontStyle>,
23    pub font_weight: Option<FontVariantWeight>,
24    pub font_source: Option<FontSource>,
25    pub flex_direction: FlexDirection,
26    pub justify_content: JustifyContent,
27    pub align_items: AlignItems,
28}
29
30impl Default for Style {
31    fn default() -> Self {
32        Self {
33            width: None,
34            height: None,
35            margin: EdgeInsets::default(),
36            padding: EdgeInsets::default(),
37            background_color: None,
38            color: Some(Color::BLACK),
39            font_size: Some(Pt::new(12.0)),
40            font_family: None,
41            font_style: None,
42            font_weight: None,
43            font_source: None,
44            flex_direction: FlexDirection::default(),
45            justify_content: JustifyContent::default(),
46            align_items: AlignItems::default(),
47        }
48    }
49}
50
51impl Style {
52    pub fn from_stylesheet(container: &StylesheetContainer, stylesheet: &Stylesheet) -> Self {
53        let mut style = Self::default();
54        style.apply_stylesheet(container, stylesheet);
55        style
56    }
57
58    pub fn apply_stylesheet(&mut self, container: &StylesheetContainer, stylesheet: &Stylesheet) {
59        let resolved = stylesheet.resolve(container);
60        self.apply_resolved_stylesheet(&resolved);
61    }
62
63    pub fn apply_resolved_stylesheet(&mut self, style: &StylesheetMap) {
64        if let Some(value) = stylesheet_pt(style, "width") {
65            self.width = Some(value);
66        }
67        if let Some(value) = stylesheet_pt(style, "height") {
68            self.height = Some(value);
69        }
70
71        apply_edge_insets(
72            &mut self.margin,
73            style,
74            ["marginTop", "marginRight", "marginBottom", "marginLeft"],
75        );
76        apply_edge_insets(
77            &mut self.padding,
78            style,
79            ["paddingTop", "paddingRight", "paddingBottom", "paddingLeft"],
80        );
81
82        if let Some(value) = stylesheet_color(style, "backgroundColor") {
83            self.background_color = Some(value);
84        }
85        if let Some(value) = stylesheet_color(style, "color") {
86            self.color = Some(value);
87        }
88        if let Some(value) = stylesheet_pt(style, "fontSize") {
89            self.font_size = Some(value);
90        }
91        if let Some(value) = stylesheet_string(style, "fontFamily") {
92            self.font_family = Some(value.to_string());
93        }
94        if let Some(value) = stylesheet_font_style(style, "fontStyle") {
95            self.font_style = Some(value);
96        }
97        if let Some(value) = stylesheet_font_weight(style, "fontWeight") {
98            self.font_weight = Some(value);
99        }
100        if let Some(value) = stylesheet_string(style, "fontSource") {
101            self.font_source = Some(FontSource::remote(value));
102        }
103        if let Some(value) = stylesheet_string(style, "fontSourceLocal") {
104            self.font_source = Some(FontSource::local(value));
105        }
106        if let Some(value) = stylesheet_string(style, "fontSourceDataUri") {
107            self.font_source = Some(FontSource::data_uri(value));
108        }
109        if let Some(value) = stylesheet_standard_font(style, "fontSourceStandard") {
110            self.font_source = Some(FontSource::standard(value));
111        }
112        if let Some(value) = stylesheet_flex_direction(style, "flexDirection") {
113            self.flex_direction = value;
114        }
115        if let Some(value) = stylesheet_justify_content(style, "justifyContent") {
116            self.justify_content = value;
117        }
118        if let Some(value) = stylesheet_align_items(style, "alignItems") {
119            self.align_items = value;
120        }
121    }
122
123    pub fn font_descriptor(&self) -> Option<FontDescriptor> {
124        let mut descriptor = FontDescriptor::new(self.font_family.clone()?);
125
126        if let Some(value) = self.font_style {
127            descriptor = descriptor.with_style(value);
128        }
129        if let Some(value) = self.font_weight {
130            descriptor = descriptor.with_weight(value);
131        }
132
133        Some(descriptor)
134    }
135
136    pub fn to_layout_style(&self) -> graphitepdf_layout::LayoutStyle {
137        graphitepdf_layout::LayoutStyle {
138            width: self.width,
139            height: self.height,
140            margin: Some(self.margin),
141            padding: Some(self.padding),
142            background_color: self.background_color,
143            color: self.color,
144            font_family: self.font_family.clone(),
145            font_style: self.font_style,
146            font_weight: self.font_weight,
147            font_source: self.font_source.clone(),
148            font_size: self.font_size,
149            line_height: None,
150            z_index: None,
151            page_break_before: None,
152            page_break_after: None,
153        }
154    }
155
156    pub fn from_layout_style(style: &graphitepdf_layout::LayoutStyle) -> Self {
157        Self {
158            width: style.width,
159            height: style.height,
160            margin: style.margin.unwrap_or_default(),
161            padding: style.padding.unwrap_or_default(),
162            background_color: style.background_color,
163            color: style.color,
164            font_size: style.font_size,
165            font_family: style.font_family.clone(),
166            font_style: style.font_style,
167            font_weight: style.font_weight,
168            font_source: style.font_source.clone(),
169            flex_direction: FlexDirection::default(),
170            justify_content: JustifyContent::default(),
171            align_items: AlignItems::default(),
172        }
173    }
174}
175
176#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
177pub enum FlexDirection {
178    #[default]
179    Column,
180    Row,
181}
182
183#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
184pub enum JustifyContent {
185    #[default]
186    Start,
187    Center,
188    End,
189    SpaceBetween,
190}
191
192#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
193pub enum AlignItems {
194    #[default]
195    Start,
196    Center,
197    End,
198    Stretch,
199}
200
201fn apply_edge_insets(target: &mut EdgeInsets, style: &StylesheetMap, keys: [&str; 4]) {
202    if let Some(value) = stylesheet_pt(style, keys[0]) {
203        target.top = value;
204    }
205    if let Some(value) = stylesheet_pt(style, keys[1]) {
206        target.right = value;
207    }
208    if let Some(value) = stylesheet_pt(style, keys[2]) {
209        target.bottom = value;
210    }
211    if let Some(value) = stylesheet_pt(style, keys[3]) {
212        target.left = value;
213    }
214}
215
216fn stylesheet_pt(style: &StylesheetMap, key: &str) -> Option<Pt> {
217    stylesheet_f32(style, key).map(Pt::new)
218}
219
220fn stylesheet_f32(style: &StylesheetMap, key: &str) -> Option<f32> {
221    match style.get(key)? {
222        StyleValue::Number(value) => Some(*value as f32),
223        StyleValue::String(value) => value.trim().parse::<f32>().ok(),
224        _ => None,
225    }
226}
227
228fn stylesheet_string<'a>(style: &'a StylesheetMap, key: &str) -> Option<&'a str> {
229    match style.get(key)? {
230        StyleValue::String(value) => Some(value.as_str()),
231        _ => None,
232    }
233}
234
235fn stylesheet_color(style: &StylesheetMap, key: &str) -> Option<Color> {
236    let value = stylesheet_string(style, key)?;
237    parse_color(value)
238}
239
240fn stylesheet_font_style(style: &StylesheetMap, key: &str) -> Option<FontStyle> {
241    match stylesheet_string(style, key)?
242        .trim()
243        .to_ascii_lowercase()
244        .as_str()
245    {
246        "normal" => Some(FontStyle::Normal),
247        "italic" => Some(FontStyle::Italic),
248        "oblique" => Some(FontStyle::Oblique),
249        _ => None,
250    }
251}
252
253fn stylesheet_font_weight(style: &StylesheetMap, key: &str) -> Option<FontVariantWeight> {
254    let value = style.get(key)?;
255    let number = match value {
256        StyleValue::Number(value) => *value as u16,
257        StyleValue::String(value) => value.trim().parse::<u16>().ok()?,
258        _ => return None,
259    };
260
261    FontVariantWeight::new(number).ok()
262}
263
264fn stylesheet_standard_font(style: &StylesheetMap, key: &str) -> Option<StandardFont> {
265    match stylesheet_string(style, key)?.trim() {
266        "Times-Roman" => Some(StandardFont::TimesRoman),
267        "Times-Bold" => Some(StandardFont::TimesBold),
268        "Times-Italic" => Some(StandardFont::TimesItalic),
269        "Times-BoldItalic" => Some(StandardFont::TimesBoldItalic),
270        "Helvetica" => Some(StandardFont::Helvetica),
271        "Helvetica-Bold" => Some(StandardFont::HelveticaBold),
272        "Helvetica-Oblique" => Some(StandardFont::HelveticaOblique),
273        "Helvetica-BoldOblique" => Some(StandardFont::HelveticaBoldOblique),
274        "Courier" => Some(StandardFont::Courier),
275        "Courier-Bold" => Some(StandardFont::CourierBold),
276        "Courier-Oblique" => Some(StandardFont::CourierOblique),
277        "Courier-BoldOblique" => Some(StandardFont::CourierBoldOblique),
278        "Symbol" => Some(StandardFont::Symbol),
279        "ZapfDingbats" => Some(StandardFont::ZapfDingbats),
280        _ => None,
281    }
282}
283
284fn stylesheet_flex_direction(style: &StylesheetMap, key: &str) -> Option<FlexDirection> {
285    match stylesheet_string(style, key)?.trim() {
286        "column" => Some(FlexDirection::Column),
287        "row" => Some(FlexDirection::Row),
288        _ => None,
289    }
290}
291
292fn stylesheet_justify_content(style: &StylesheetMap, key: &str) -> Option<JustifyContent> {
293    match stylesheet_string(style, key)?.trim() {
294        "start" | "flex-start" => Some(JustifyContent::Start),
295        "center" => Some(JustifyContent::Center),
296        "end" | "flex-end" => Some(JustifyContent::End),
297        "space-between" => Some(JustifyContent::SpaceBetween),
298        _ => None,
299    }
300}
301
302fn stylesheet_align_items(style: &StylesheetMap, key: &str) -> Option<AlignItems> {
303    match stylesheet_string(style, key)?.trim() {
304        "start" | "flex-start" => Some(AlignItems::Start),
305        "center" => Some(AlignItems::Center),
306        "end" | "flex-end" => Some(AlignItems::End),
307        "stretch" => Some(AlignItems::Stretch),
308        _ => None,
309    }
310}
311
312fn parse_color(value: &str) -> Option<Color> {
313    let trimmed = value.trim();
314
315    match trimmed {
316        "black" => return Some(Color::BLACK),
317        "white" => return Some(Color::WHITE),
318        _ => {}
319    }
320
321    let hex = trimmed.strip_prefix('#')?;
322    match hex.len() {
323        6 => Some(Color::rgb(
324            u8::from_str_radix(&hex[0..2], 16).ok()?,
325            u8::from_str_radix(&hex[2..4], 16).ok()?,
326            u8::from_str_radix(&hex[4..6], 16).ok()?,
327        )),
328        8 => Some(Color::rgba(
329            u8::from_str_radix(&hex[0..2], 16).ok()?,
330            u8::from_str_radix(&hex[2..4], 16).ok()?,
331            u8::from_str_radix(&hex[4..6], 16).ok()?,
332            u8::from_str_radix(&hex[6..8], 16).ok()?,
333        )),
334        _ => None,
335    }
336}
337
338impl From<&Style> for graphitepdf_layout::LayoutStyle {
339    fn from(value: &Style) -> Self {
340        value.to_layout_style()
341    }
342}
343
344impl From<Style> for graphitepdf_layout::LayoutStyle {
345    fn from(value: Style) -> Self {
346        value.to_layout_style()
347    }
348}
349
350impl From<graphitepdf_layout::LayoutStyle> for Style {
351    fn from(value: graphitepdf_layout::LayoutStyle) -> Self {
352        Self::from_layout_style(&value)
353    }
354}
355
356#[cfg(test)]
357mod tests {
358    use super::*;
359
360    fn stylesheet_style(entries: [(&str, StyleValue); 11]) -> StylesheetMap {
361        entries
362            .into_iter()
363            .map(|(key, value)| (key.to_string(), value))
364            .collect()
365    }
366
367    #[test]
368    fn builds_style_from_stylesheet_and_exposes_font_descriptor() {
369        let container = StylesheetContainer {
370            width: 200.0,
371            height: 300.0,
372            dpi: None,
373            rem_base: Some(10.0),
374            orientation: None,
375        };
376        let stylesheet = Stylesheet::new(StyleValue::Object(stylesheet_style([
377            ("width", 24.into()),
378            ("marginTop", 12.into()),
379            ("marginRight", 14.into()),
380            ("paddingLeft", 8.into()),
381            ("backgroundColor", "#112233".into()),
382            ("color", "#AABBCCDD".into()),
383            ("fontFamily", "Inter".into()),
384            ("fontStyle", "italic".into()),
385            ("fontWeight", 600.into()),
386            ("fontSourceStandard", "Helvetica-Bold".into()),
387            ("justifyContent", "center".into()),
388        ])));
389
390        let style = Style::from_stylesheet(&container, &stylesheet);
391
392        assert_eq!(style.width, Some(Pt::new(24.0)));
393        assert_eq!(style.margin.top, Pt::new(12.0));
394        assert_eq!(style.margin.right, Pt::new(14.0));
395        assert_eq!(style.padding.left, Pt::new(8.0));
396        assert_eq!(style.background_color, Some(Color::rgb(0x11, 0x22, 0x33)));
397        assert_eq!(style.color, Some(Color::rgba(0xAA, 0xBB, 0xCC, 0xDD)));
398        assert_eq!(style.font_style, Some(FontStyle::Italic));
399        assert_eq!(style.font_weight, Some(FontVariantWeight::SEMI_BOLD));
400        assert_eq!(
401            style.font_source,
402            Some(FontSource::standard(StandardFont::HelveticaBold))
403        );
404        assert_eq!(style.justify_content, JustifyContent::Center);
405
406        let descriptor = style
407            .font_descriptor()
408            .expect("font descriptor should exist");
409        assert_eq!(descriptor.family(), "Inter");
410        assert_eq!(descriptor.font_style(), FontStyle::Italic);
411        assert_eq!(descriptor.font_weight(), FontVariantWeight::SEMI_BOLD);
412
413        let layout_style = style.to_layout_style();
414        assert_eq!(layout_style.font_family.as_deref(), Some("Inter"));
415        assert_eq!(layout_style.padding.unwrap_or_default().left, Pt::new(8.0));
416    }
417
418    #[test]
419    fn applying_partial_stylesheet_preserves_existing_values() {
420        let mut style = Style {
421            width: Some(Pt::new(42.0)),
422            font_family: Some(String::from("Existing")),
423            ..Style::default()
424        };
425        let resolved = stylesheet_style([
426            ("height", 100.into()),
427            ("marginTop", 3.into()),
428            ("marginRight", 0.into()),
429            ("paddingLeft", 0.into()),
430            ("backgroundColor", "#000000".into()),
431            ("color", "#FFFFFFFF".into()),
432            ("fontFamily", StyleValue::Null),
433            ("fontStyle", StyleValue::Null),
434            ("fontWeight", StyleValue::Null),
435            ("fontSourceStandard", StyleValue::Null),
436            ("alignItems", "stretch".into()),
437        ]);
438
439        style.apply_resolved_stylesheet(&resolved);
440
441        assert_eq!(style.width, Some(Pt::new(42.0)));
442        assert_eq!(style.height, Some(Pt::new(100.0)));
443        assert_eq!(style.margin.top, Pt::new(3.0));
444        assert_eq!(style.font_family.as_deref(), Some("Existing"));
445        assert_eq!(style.align_items, AlignItems::Stretch);
446    }
447
448    #[test]
449    fn converts_layout_style_back_into_compat_style() {
450        let layout_style = graphitepdf_layout::LayoutStyle::new()
451            .with_width(Pt::new(72.0))
452            .with_height(Pt::new(24.0))
453            .with_margin(EdgeInsets::all(Pt::new(6.0)))
454            .with_padding(EdgeInsets::all(Pt::new(4.0)))
455            .with_font_family("Inter")
456            .with_font_style(FontStyle::Italic)
457            .with_font_weight(FontVariantWeight::BOLD)
458            .with_font_source(FontSource::standard(StandardFont::HelveticaBold))
459            .with_font_size(Pt::new(14.0))
460            .with_background_color(Color::rgb(0x11, 0x22, 0x33));
461
462        let style = Style::from_layout_style(&layout_style);
463
464        assert_eq!(style.width, Some(Pt::new(72.0)));
465        assert_eq!(style.height, Some(Pt::new(24.0)));
466        assert_eq!(style.margin, EdgeInsets::all(Pt::new(6.0)));
467        assert_eq!(style.padding, EdgeInsets::all(Pt::new(4.0)));
468        assert_eq!(style.font_family.as_deref(), Some("Inter"));
469        assert_eq!(style.font_style, Some(FontStyle::Italic));
470        assert_eq!(style.font_weight, Some(FontVariantWeight::BOLD));
471        assert_eq!(
472            style.font_source,
473            Some(FontSource::standard(StandardFont::HelveticaBold))
474        );
475        assert_eq!(style.font_size, Some(Pt::new(14.0)));
476        assert_eq!(style.background_color, Some(Color::rgb(0x11, 0x22, 0x33)));
477    }
478}