Skip to main content

lightweight_pdf_core/
element.rs

1//! Element catalog (V1 subset through concept Phase 5, see
2//! `plan/02-elementcatalog-and-features.md`).
3
4use crate::image::Image;
5use crate::list::List;
6use crate::style::{Align, Border, Color, Common, FontKey, Overflow, TextStyle};
7use crate::table::Table;
8use crate::theme::ThemeRole;
9
10/// One element in the document tree. Enum-based (not `Box<dyn Layoutable>`)
11/// — a closed, small set of primitives.
12///
13/// `serde` (issue #17): internally tagged on a `type` field
14/// (`{"type": "text", "content": "...", ...}`), `snake_case` variant
15/// names. `Image`'s JSON shape is base64 (see its own doc comment), not
16/// the same fields its Rust builder exposes.
17#[cfg_attr(
18    feature = "serde",
19    derive(serde::Serialize, serde::Deserialize),
20    serde(tag = "type", rename_all = "snake_case")
21)]
22#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
23#[derive(Clone, Debug)]
24pub enum Element {
25    Text(Text),
26    Row(Row),
27    Column(Column),
28    Spacer(Spacer),
29    Line(Line),
30    Rect(Rect),
31    Table(Table),
32    Image(Image),
33    List(List),
34    TableOfContents(TableOfContents),
35    /// Forces a page break at this point in the enclosing flow, regardless
36    /// of whether the remaining content would still fit (Phase 2).
37    PageBreak,
38}
39
40/// Generates the match arms shared by `Element::common`/`common_mut`: both
41/// are exact mirrors of each other, differing only in `&`/`&mut`. `$ref` is
42/// spliced in front of each `.common` field access, so invoking this with
43/// `&` vs `&mut` produces the two accessors from one written body.
44macro_rules! common_accessor {
45    ($self:expr, $($ref:tt)*) => {
46        match $self {
47            Element::Text(t) => Some($($ref)* t.common),
48            Element::Row(r) => Some($($ref)* r.common),
49            Element::Column(c) => Some($($ref)* c.common),
50            Element::Line(l) => Some($($ref)* l.common),
51            Element::Rect(r) => Some($($ref)* r.common),
52            Element::Table(t) => Some($($ref)* t.common),
53            Element::Image(i) => Some($($ref)* i.common),
54            Element::List(l) => Some($($ref)* l.common),
55            Element::TableOfContents(t) => Some($($ref)* t.common),
56            Element::Spacer(_) | Element::PageBreak => None,
57        }
58    };
59}
60
61impl Element {
62    /// Shared style properties, where applicable. `Spacer` and `PageBreak`
63    /// carry no `Common` (nothing to size/clip/keep-with-next).
64    pub fn common(&self) -> Option<&Common> {
65        common_accessor!(self, &)
66    }
67
68    /// Mutable counterpart to [`Self::common`] — used by `List`'s layout
69    /// translation to make item content fill the remaining row width
70    /// (`flex(1.0)`) without needing a bespoke setter per element variant.
71    pub fn common_mut(&mut self) -> Option<&mut Common> {
72        common_accessor!(self, &mut)
73    }
74}
75
76/// Generates the shared `Common`-backed builder methods for a wrapper type
77/// that has a `pub common: Common` field. Avoids repeating five setters on
78/// every element type (Text, Row, Column, Line, Rect).
79macro_rules! common_builder_methods {
80    () => {
81        pub fn width(mut self, width: f32) -> Self {
82            self.common.width = Some(width);
83            self
84        }
85
86        pub fn height(mut self, height: f32) -> Self {
87            self.common.height = Some(height);
88            self
89        }
90
91        pub fn flex(mut self, factor: f32) -> Self {
92            self.common.flex = Some(factor);
93            self
94        }
95
96        pub fn padding(mut self, padding: f32) -> Self {
97            self.common.padding = padding;
98            self
99        }
100
101        pub fn corner_radius(mut self, radius: f32) -> Self {
102            self.common.corner_radius = radius;
103            self
104        }
105
106        pub fn overflow(mut self, overflow: Overflow) -> Self {
107            self.common.overflow = overflow;
108            self
109        }
110
111        pub fn background(mut self, color: Color) -> Self {
112            self.common.background = Some(color);
113            self
114        }
115
116        pub fn border(mut self, border: Border) -> Self {
117            self.common.border = Some(border);
118            self
119        }
120
121        /// See `plan/05-overflow-and-robustness.md` Grundprinzip 9: only
122        /// placed on a page if the following sibling also still fits.
123        pub fn keep_with_next(mut self) -> Self {
124            self.common.keep_with_next = true;
125            self
126        }
127    };
128}
129
130// ---------------------------------------------------------------------
131// Text
132// ---------------------------------------------------------------------
133
134#[cfg_attr(
135    feature = "serde",
136    derive(serde::Serialize, serde::Deserialize),
137    serde(deny_unknown_fields, default)
138)]
139#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
140#[derive(Clone, Debug, Default)]
141pub struct Text {
142    pub content: String,
143    #[cfg_attr(feature = "serde", serde(default))]
144    pub style: TextStyle,
145    pub url: Option<String>,
146    /// Registers this element as an internal jump target other `Text`
147    /// elements can point at via `.link_to(name)` — analogous to an HTML
148    /// `id`. Independent of `url`/`link_to`: an element can be a target,
149    /// a source, both, or neither.
150    pub anchor: Option<String>,
151    /// Internal counterpart to `url`: jumps to whatever element in this
152    /// document called `.anchor(name)` with the same name, instead of an
153    /// external URI. If both `url` and `link_to` are set, `url` wins.
154    pub link_to: Option<String>,
155    /// Set by `.heading1()`/`.heading2()`/`.heading3()` (1/2/3), or
156    /// explicitly via `.outline_level(n)` for text that should appear in
157    /// the PDF bookmark sidebar without being an actual heading preset.
158    /// `None` (the default for plain `Text`) means "not a bookmark".
159    pub outline_level: Option<u8>,
160    /// Theme eligibility (`Document::theme(..)`, ADR/issue #16): `Some`
161    /// means "resolve this element's style from the theme's matching role
162    /// the next time it's added to a themed `Document`." `Text::new()`
163    /// defaults this to `Some(ThemeRole::Body)`; every style-mutating
164    /// method below (`.size()`, `.bold()`, `.color()`, ...) clears it back
165    /// to `None` since the caller has taken over styling by hand. The
166    /// `.heading1()`/`.heading2()`/`.heading3()`/`.caption()`/`.muted()`/
167    /// `.table_header()` presets re-set a specific role afterwards.
168    pub role: Option<ThemeRole>,
169    /// Set only by `Text::rich(..)` (issue #11) — a sequence of
170    /// independently-styled runs instead of one `style` for the whole
171    /// `content`. When `Some`, layout/render use this instead of
172    /// `content`/`style` (`content` is still populated, as the spans'
173    /// text concatenated, so anything that only reads `content` — e.g. a
174    /// future plain-text export — degrades to unstyled text instead of
175    /// seeing nothing). Rich text doesn't (yet) support
176    /// `url`/`anchor`/`link_to`/`outline_level`/`Align::Justify` — plain
177    /// `Text` remains the only way to get those.
178    /// Boxed, not `Option<Vec<Span>>` directly: `Text` is the payload of
179    /// `Element`'s largest variant (in turn embedded in `LayoutResult`
180    /// and every `Row`/`Column`'s `children: Vec<Element>`), and a bare
181    /// `Vec` here would cost every plain `Text` (the overwhelming
182    /// majority, where this field is always `None`) the full 24 bytes;
183    /// `Option<Box<Vec<Span>>>` costs 8.
184    pub spans: Option<Box<Vec<Span>>>,
185    /// Set by `.hyphenate(lang)` (issue #13, Stage 2): before wrapping,
186    /// each word gets Knuth-Liang break points inserted as soft hyphens
187    /// (U+00AD) for `lang`, on top of Stage 1's always-on soft-hyphen
188    /// support (an author-inserted U+00AD works with or without this).
189    /// `None` (the default) means "only break where the author put a
190    /// soft hyphen, if anywhere." Only consulted for plain `Text`; a
191    /// `Text::rich(..)` ignores it, same as `Align::Justify`.
192    /// Requires the `hyphenation` cargo feature — with it disabled this
193    /// silently has no effect, since skipping automatic hyphenation only
194    /// changes where a line wraps, not what the text says.
195    pub hyphenate: Option<HyphenationLanguage>,
196    pub common: Common,
197}
198
199/// A language `.hyphenate(lang)` can insert Knuth-Liang break points for
200/// (`lightweight-pdf-layout`'s `hyphenation` feature; see that crate's
201/// `hyphenate` module for the dictionaries themselves).
202#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize), serde(rename_all = "snake_case"))]
203#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
204#[derive(Clone, Copy, Debug, PartialEq, Eq)]
205pub enum HyphenationLanguage {
206    EnglishUs,
207    German,
208}
209
210/// One independently-styled run within `Text::rich(..)`.
211#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize), serde(deny_unknown_fields))]
212#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
213#[derive(Clone, Debug)]
214pub struct Span {
215    pub text: String,
216    #[cfg_attr(feature = "serde", serde(default))]
217    pub style: TextStyle,
218}
219
220impl Span {
221    pub fn new(text: impl Into<String>, style: TextStyle) -> Self {
222        Span { text: text.into(), style }
223    }
224}
225
226impl Text {
227    pub fn new(content: impl Into<String>) -> Self {
228        Text {
229            content: content.into(),
230            style: TextStyle::default(),
231            url: None,
232            anchor: None,
233            link_to: None,
234            outline_level: None,
235            role: Some(ThemeRole::Body),
236            spans: None,
237            hyphenate: None,
238            common: Common::default(),
239        }
240    }
241
242    /// A `Text` made of independently-styled `Span`s instead of one
243    /// uniform style — the paragraph still wraps and paginates as a
244    /// single unit, word boundaries and line breaks span across spans
245    /// freely, and mixed sizes on the same line share one baseline (see
246    /// `lightweight-pdf-layout::text::wrap_spans`).
247    pub fn rich(spans: impl IntoIterator<Item = Span>) -> Self {
248        let spans: Vec<Span> = spans.into_iter().collect();
249        let content = spans.iter().map(|s| s.text.as_str()).collect::<Vec<_>>().concat();
250        let style = spans.first().map(|s| s.style).unwrap_or_default();
251        Text {
252            content,
253            style,
254            url: None,
255            anchor: None,
256            link_to: None,
257            outline_level: None,
258            role: None,
259            spans: Some(Box::new(spans)),
260            hyphenate: None,
261            common: Common::default(),
262        }
263    }
264
265    pub fn url(mut self, url: impl Into<String>) -> Self {
266        self.url = Some(url.into());
267        self
268    }
269
270    pub fn anchor(mut self, name: impl Into<String>) -> Self {
271        self.anchor = Some(name.into());
272        self
273    }
274
275    pub fn link_to(mut self, anchor: impl Into<String>) -> Self {
276        self.link_to = Some(anchor.into());
277        self
278    }
279
280    pub fn outline_level(mut self, level: u8) -> Self {
281        self.outline_level = Some(level);
282        self
283    }
284
285    /// Opts this `Text` into automatic (Knuth-Liang) hyphenation for
286    /// `lang` — see the `hyphenate` field's doc comment for scope and the
287    /// `hyphenation` cargo feature it requires.
288    pub fn hyphenate(mut self, lang: HyphenationLanguage) -> Self {
289        self.hyphenate = Some(lang);
290        self
291    }
292
293    /// Opts a `Text` into theme resolution under `role` without going
294    /// through one of the named presets — e.g. a custom role-like use
295    /// that isn't `.heading1()`/`.caption()`/etc.
296    pub fn role(mut self, role: ThemeRole) -> Self {
297        self.role = Some(role);
298        self
299    }
300
301    pub fn size(mut self, size: f32) -> Self {
302        self.style.size = size;
303        self.role = None;
304        self
305    }
306
307    pub fn bold(mut self) -> Self {
308        self.style.font = FontKey::SANS_BOLD;
309        self.role = None;
310        self
311    }
312
313    pub fn italic(mut self) -> Self {
314        self.style.font = FontKey::SANS_ITALIC;
315        self.role = None;
316        self
317    }
318
319    pub fn bold_italic(mut self) -> Self {
320        self.style.font = FontKey::SANS_BOLD_ITALIC;
321        self.role = None;
322        self
323    }
324
325    pub fn font(mut self, font: FontKey) -> Self {
326        self.style.font = font;
327        self.role = None;
328        self
329    }
330
331    pub fn color(mut self, color: Color) -> Self {
332        self.style.color = color;
333        self.role = None;
334        self
335    }
336
337    /// Unlike the other style setters, `.align()` does *not* clear
338    /// `role`: alignment is a positioning choice independent of which
339    /// named style a `Text` resolves from (`.heading1().align(Center)`
340    /// should stay theme-eligible as a heading, just centered) — see
341    /// `theme::apply_theme`, which resolves every role field except
342    /// `align` and always leaves whatever `.align()` set alone.
343    pub fn align(mut self, align: Align) -> Self {
344        self.style.align = align;
345        self
346    }
347
348    pub fn line_height(mut self, line_height: f32) -> Self {
349        self.style.line_height = line_height;
350        self.role = None;
351        self
352    }
353
354    /// Heading presets (Phase 6, `plan/02-elementcatalog-and-features.md`):
355    /// thin wrappers over `.size()`/`.bold()`, additionally setting
356    /// `keep_with_next` so a heading never ends up alone at the bottom of
357    /// a page without its following content
358    /// (`plan/05-overflow-and-robustness.md` Grundprinzip 9), and
359    /// `outline_level` so the PDF bookmark sidebar can be derived from the
360    /// heading hierarchy without a separate API (`.outline_level(n)`
361    /// overrides this for the rare case the derivation doesn't fit).
362    pub fn heading1(self) -> Self {
363        self.size(24.0).bold().keep_with_next().outline_level(1).role(ThemeRole::Heading1)
364    }
365
366    pub fn heading2(self) -> Self {
367        self.size(18.0).bold().keep_with_next().outline_level(2).role(ThemeRole::Heading2)
368    }
369
370    pub fn heading3(self) -> Self {
371        self.size(14.0).bold().keep_with_next().outline_level(3).role(ThemeRole::Heading3)
372    }
373
374    /// `Theme::caption` preset — a smaller, muted-gray label (e.g. under
375    /// an image, or a secondary line under a heading).
376    pub fn caption(self) -> Self {
377        self.size(9.0).color(Color::rgb(0x66, 0x66, 0x66)).role(ThemeRole::Caption)
378    }
379
380    /// `Theme::muted` preset — body-sized text in the same muted gray as
381    /// `.caption()`, for de-emphasized inline text rather than a label.
382    pub fn muted(self) -> Self {
383        self.color(Color::rgb(0x66, 0x66, 0x66)).role(ThemeRole::Muted)
384    }
385
386    /// `Theme::table_header` preset. `Table::header([...])` cells built
387    /// from plain strings pick this role up automatically (see
388    /// `theme::apply_theme`); use this directly for a `Text` header cell
389    /// built by hand, or for header-like text outside a `Table`.
390    pub fn table_header(self) -> Self {
391        self.bold().role(ThemeRole::TableHeader)
392    }
393
394    common_builder_methods!();
395}
396
397impl From<&str> for Text {
398    fn from(value: &str) -> Self {
399        Text::new(value)
400    }
401}
402
403impl From<String> for Text {
404    fn from(value: String) -> Self {
405        Text::new(value)
406    }
407}
408
409// ---------------------------------------------------------------------
410// Row / Column
411// ---------------------------------------------------------------------
412
413#[cfg_attr(
414    feature = "serde",
415    derive(serde::Serialize, serde::Deserialize),
416    serde(deny_unknown_fields, default)
417)]
418#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
419#[derive(Clone, Debug, Default)]
420pub struct Row {
421    pub children: Vec<Element>,
422    pub gap: f32,
423    pub align: Align,
424    pub common: Common,
425}
426
427#[cfg_attr(
428    feature = "serde",
429    derive(serde::Serialize, serde::Deserialize),
430    serde(deny_unknown_fields, default)
431)]
432#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
433#[derive(Clone, Debug, Default)]
434pub struct Column {
435    pub children: Vec<Element>,
436    pub gap: f32,
437    pub align: Align,
438    pub common: Common,
439}
440
441macro_rules! container_impl {
442    ($ty:ident) => {
443        impl $ty {
444            pub fn new() -> Self {
445                Self::default()
446            }
447
448            pub fn child(mut self, child: impl Into<Element>) -> Self {
449                self.children.push(child.into());
450                self
451            }
452
453            pub fn children(mut self, children: impl IntoIterator<Item = impl Into<Element>>) -> Self {
454                self.children.extend(children.into_iter().map(Into::into));
455                self
456            }
457
458            pub fn gap(mut self, gap: f32) -> Self {
459                self.gap = gap;
460                self
461            }
462
463            pub fn align(mut self, align: Align) -> Self {
464                self.align = align;
465                self
466            }
467
468            common_builder_methods!();
469        }
470    };
471}
472
473container_impl!(Row);
474container_impl!(Column);
475
476// ---------------------------------------------------------------------
477// Spacer
478// ---------------------------------------------------------------------
479
480#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize), serde(deny_unknown_fields))]
481#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
482#[derive(Clone, Copy, Debug)]
483pub struct Spacer {
484    pub size: f32,
485}
486
487impl Spacer {
488    pub fn new(size: f32) -> Self {
489        Spacer { size }
490    }
491}
492
493// ---------------------------------------------------------------------
494// Line
495// ---------------------------------------------------------------------
496
497#[cfg_attr(
498    feature = "serde",
499    derive(serde::Serialize, serde::Deserialize),
500    serde(deny_unknown_fields, default)
501)]
502#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
503#[derive(Clone, Debug)]
504pub struct Line {
505    pub thickness: f32,
506    pub color: Color,
507    pub common: Common,
508}
509
510impl Default for Line {
511    fn default() -> Self {
512        Line {
513            thickness: 1.0,
514            color: Color::BLACK,
515            common: Common::default(),
516        }
517    }
518}
519
520impl Line {
521    pub fn new() -> Self {
522        Self::default()
523    }
524
525    pub fn thickness(mut self, thickness: f32) -> Self {
526        self.thickness = thickness;
527        self
528    }
529
530    pub fn color(mut self, color: Color) -> Self {
531        self.color = color;
532        self
533    }
534
535    common_builder_methods!();
536}
537
538// ---------------------------------------------------------------------
539// Rect
540// ---------------------------------------------------------------------
541
542#[cfg_attr(
543    feature = "serde",
544    derive(serde::Serialize, serde::Deserialize),
545    serde(deny_unknown_fields, default)
546)]
547#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
548#[derive(Clone, Debug, Default)]
549pub struct Rect {
550    pub common: Common,
551}
552
553impl Rect {
554    pub fn new() -> Self {
555        Self::default()
556    }
557
558    common_builder_methods!();
559}
560
561// ---------------------------------------------------------------------
562// TableOfContents (issue #10)
563// ---------------------------------------------------------------------
564
565/// Self-populating from every `Text::outline_level`/`.heading1()`-etc.
566/// heading in the document (the same source the PDF bookmark sidebar is
567/// built from), with correct page numbers — the two-pass layout already
568/// determines those in pass 1, this element just renders them in pass 2
569/// (see `lightweight-pdf-layout::toc`). Entries are always left-aligned,
570/// one per line, indented by heading depth, with a leader (`.leader()`)
571/// filling the gap to a right-hand page number; `.style` controls
572/// font/size/color for every entry uniformly.
573#[cfg_attr(
574    feature = "serde",
575    derive(serde::Serialize, serde::Deserialize),
576    serde(deny_unknown_fields, default)
577)]
578#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
579#[derive(Clone, Debug)]
580pub struct TableOfContents {
581    /// Only headings at this `outline_level` or shallower become entries
582    /// (default `3`).
583    pub max_depth: u8,
584    pub style: TextStyle,
585    /// Character repeated between an entry's title and its page number
586    /// (default `.`); set to `' '` for no visible leader.
587    pub leader: char,
588    /// Internal: how many matching headings to skip before this
589    /// instance's first entry. Set only by the layout crate when a
590    /// `TableOfContents` itself splits across a page boundary — always
591    /// `0` on one an author constructs.
592    #[cfg_attr(feature = "serde", serde(skip))]
593    pub skip: usize,
594    pub common: Common,
595}
596
597impl Default for TableOfContents {
598    fn default() -> Self {
599        TableOfContents {
600            max_depth: 3,
601            style: TextStyle::default(),
602            leader: '.',
603            skip: 0,
604            common: Common::default(),
605        }
606    }
607}
608
609impl TableOfContents {
610    pub fn new() -> Self {
611        Self::default()
612    }
613
614    pub fn max_depth(mut self, depth: u8) -> Self {
615        self.max_depth = depth;
616        self
617    }
618
619    pub fn leader(mut self, leader: char) -> Self {
620        self.leader = leader;
621        self
622    }
623
624    pub fn style(mut self, style: TextStyle) -> Self {
625        self.style = style;
626        self
627    }
628
629    common_builder_methods!();
630}
631
632// ---------------------------------------------------------------------
633// Element From-impls (ADR/03-builder-api-design.md point 3)
634// ---------------------------------------------------------------------
635
636macro_rules! element_from {
637    ($ty:ident) => {
638        impl From<$ty> for Element {
639            fn from(value: $ty) -> Self {
640                Element::$ty(value)
641            }
642        }
643    };
644}
645
646element_from!(Text);
647element_from!(Row);
648element_from!(Column);
649element_from!(Spacer);
650element_from!(Line);
651element_from!(Rect);
652element_from!(Table);
653element_from!(Image);
654element_from!(List);
655element_from!(TableOfContents);
656
657impl From<&str> for Element {
658    fn from(value: &str) -> Self {
659        Element::Text(Text::new(value))
660    }
661}
662
663impl From<String> for Element {
664    fn from(value: String) -> Self {
665        Element::Text(Text::new(value))
666    }
667}