Skip to main content

deckcraft_model/
text.rs

1//! Text bodies: body properties, paragraphs, runs, list styles.
2
3use serde::{Deserialize, Serialize};
4
5use crate::style::{ColorRef, Fill, Line};
6
7#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
8#[serde(rename_all = "camelCase")]
9pub enum Anchor {
10    Top,
11    Middle,
12    Bottom,
13    Justified,
14    Distributed,
15}
16
17impl Anchor {
18    pub fn xml(self) -> &'static str {
19        match self {
20            Anchor::Top => "t",
21            Anchor::Middle => "ctr",
22            Anchor::Bottom => "b",
23            Anchor::Justified => "just",
24            Anchor::Distributed => "dist",
25        }
26    }
27    pub fn from_xml(s: &str) -> Option<Self> {
28        Some(match s {
29            "t" => Anchor::Top,
30            "ctr" => Anchor::Middle,
31            "b" => Anchor::Bottom,
32            "just" => Anchor::Justified,
33            "dist" => Anchor::Distributed,
34            _ => return None,
35        })
36    }
37}
38
39#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
40#[serde(rename_all = "camelCase", tag = "kind")]
41pub enum AutoFit {
42    /// Do not autofit.
43    None,
44    /// Shrink text on overflow: font scale and line-spacing reduction (0–1).
45    Shrink {
46        font_scale: f64,
47        line_reduction: f64,
48    },
49    /// Resize shape to fit text.
50    Shape,
51}
52
53#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
54#[serde(rename_all = "camelCase")]
55pub enum TextDir {
56    Horizontal,
57    Vertical,
58    Vertical270,
59    Stacked,
60    EaVertical,
61}
62
63impl TextDir {
64    pub fn xml(self) -> &'static str {
65        match self {
66            TextDir::Horizontal => "horz",
67            TextDir::Vertical => "vert",
68            TextDir::Vertical270 => "vert270",
69            TextDir::Stacked => "wordArtVert",
70            TextDir::EaVertical => "eaVert",
71        }
72    }
73    pub fn from_xml(s: &str) -> Option<Self> {
74        Some(match s {
75            "horz" => TextDir::Horizontal,
76            "vert" | "mongolianVert" => TextDir::Vertical,
77            "vert270" => TextDir::Vertical270,
78            "wordArtVert" | "wordArtVertRtl" => TextDir::Stacked,
79            "eaVert" => TextDir::EaVertical,
80            _ => return None,
81        })
82    }
83}
84
85/// `bodyPr`: insets in points, anchoring, wrapping, autofit, columns, direction, warp.
86#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
87#[serde(default, rename_all = "camelCase")]
88pub struct BodyProps {
89    pub inset_l: Option<f64>,
90    pub inset_t: Option<f64>,
91    pub inset_r: Option<f64>,
92    pub inset_b: Option<f64>,
93    pub anchor: Option<Anchor>,
94    pub anchor_ctr: Option<bool>,
95    pub wrap: Option<bool>,
96    pub autofit: Option<AutoFit>,
97    pub vert: Option<TextDir>,
98    /// Extra text rotation in degrees.
99    pub rot: Option<f64>,
100    pub columns: Option<u32>,
101    pub col_spacing: Option<f64>,
102    /// WordArt transform (`prstTxWarp`), e.g. `textArchUp`.
103    pub warp: Option<String>,
104    /// The warp's adjust values (`prstTxWarp/avLst`: `adj` or `adj1`, `adj2`…, raw guide values);
105    /// empty = the preset's defaults.
106    #[serde(skip_serializing_if = "Vec::is_empty")]
107    pub warp_adj: Vec<f64>,
108    pub rtl_col: Option<bool>,
109    /// Keep text upright when the shape rotates.
110    pub upright: Option<bool>,
111}
112
113impl BodyProps {
114    pub fn inherit(&mut self, p: &BodyProps) {
115        self.inset_l = self.inset_l.or(p.inset_l);
116        self.inset_t = self.inset_t.or(p.inset_t);
117        self.inset_r = self.inset_r.or(p.inset_r);
118        self.inset_b = self.inset_b.or(p.inset_b);
119        self.anchor = self.anchor.or(p.anchor);
120        self.anchor_ctr = self.anchor_ctr.or(p.anchor_ctr);
121        self.wrap = self.wrap.or(p.wrap);
122        self.autofit = self.autofit.or(p.autofit);
123        self.vert = self.vert.or(p.vert);
124        self.rot = self.rot.or(p.rot);
125        self.columns = self.columns.or(p.columns);
126        self.col_spacing = self.col_spacing.or(p.col_spacing);
127        if self.warp.is_none() {
128            self.warp.clone_from(&p.warp);
129            self.warp_adj.clone_from(&p.warp_adj);
130        }
131        self.rtl_col = self.rtl_col.or(p.rtl_col);
132        self.upright = self.upright.or(p.upright);
133    }
134}
135
136#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
137#[serde(rename_all = "camelCase")]
138pub enum Align {
139    Left,
140    Center,
141    Right,
142    Justify,
143    /// Justified with kashida (tatweel) elongation for Arabic (`justLow`).
144    JustLow,
145    Distributed,
146}
147
148impl Align {
149    pub fn xml(self) -> &'static str {
150        match self {
151            Align::Left => "l",
152            Align::Center => "ctr",
153            Align::Right => "r",
154            Align::Justify => "just",
155            Align::JustLow => "justLow",
156            Align::Distributed => "dist",
157        }
158    }
159    pub fn from_xml(s: &str) -> Option<Self> {
160        Some(match s {
161            "l" => Align::Left,
162            "ctr" => Align::Center,
163            "r" => Align::Right,
164            "just" | "thaiDist" => Align::Justify,
165            "justLow" => Align::JustLow,
166            "dist" => Align::Distributed,
167            _ => return None,
168        })
169    }
170}
171
172/// Spacing as a percentage of the line (1.0 = single) or in points.
173#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
174#[serde(rename_all = "camelCase", tag = "kind", content = "v")]
175pub enum Spacing {
176    Pct(f64),
177    Pts(f64),
178}
179
180#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
181#[serde(rename_all = "camelCase", tag = "kind")]
182pub enum Bullet {
183    None,
184    Char {
185        char: String,
186    },
187    /// `arabicPeriod`, `arabicParenR`, `romanUcPeriod`, `romanLcPeriod`, `alphaUcPeriod`,
188    /// `alphaLcParenR`, `alphaLcPeriod`, `circleNumDbPlain`…
189    AutoNum {
190        scheme: String,
191        #[serde(default = "start_one")]
192        start_at: u32,
193    },
194    Picture {
195        media: crate::MediaId,
196    },
197}
198
199fn start_one() -> u32 {
200    1
201}
202
203#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
204#[serde(rename_all = "camelCase")]
205pub struct TabStop {
206    pub pos: f64,
207    /// `l`, `ctr`, `r`, `dec`.
208    pub align: String,
209}
210
211#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
212#[serde(default, rename_all = "camelCase")]
213pub struct ParaProps {
214    pub align: Option<Align>,
215    /// Left margin (points).
216    pub margin_left: Option<f64>,
217    pub margin_right: Option<f64>,
218    /// First-line indent relative to the margin (negative = hanging).
219    pub indent: Option<f64>,
220    pub line_spacing: Option<Spacing>,
221    pub space_before: Option<Spacing>,
222    pub space_after: Option<Spacing>,
223    pub bullet: Option<Bullet>,
224    pub bullet_font: Option<String>,
225    /// `None` = follow text colour.
226    pub bullet_color: Option<ColorRef>,
227    /// Bullet size as a fraction of the text size.
228    pub bullet_size: Option<f64>,
229    pub tabs: Option<Vec<TabStop>>,
230    pub default_tab: Option<f64>,
231    pub rtl: Option<bool>,
232    pub font_align: Option<String>,
233    pub east_asian_line_break: Option<bool>,
234    /// `latinLnBrk`: Latin words may break inside (after `/`, `\`); off by default.
235    pub latin_line_break: Option<bool>,
236    /// `hangingPunct`: closing East Asian punctuation may hang past the end of a line.
237    pub hanging_punct: Option<bool>,
238}
239
240impl ParaProps {
241    pub fn inherit(&mut self, p: &ParaProps) {
242        self.align = self.align.or(p.align);
243        self.margin_left = self.margin_left.or(p.margin_left);
244        self.margin_right = self.margin_right.or(p.margin_right);
245        self.indent = self.indent.or(p.indent);
246        self.line_spacing = self.line_spacing.or(p.line_spacing);
247        self.space_before = self.space_before.or(p.space_before);
248        self.space_after = self.space_after.or(p.space_after);
249        if self.bullet.is_none() {
250            self.bullet.clone_from(&p.bullet);
251        }
252        if self.bullet_font.is_none() {
253            self.bullet_font.clone_from(&p.bullet_font);
254        }
255        if self.bullet_color.is_none() {
256            self.bullet_color.clone_from(&p.bullet_color);
257        }
258        self.bullet_size = self.bullet_size.or(p.bullet_size);
259        if self.tabs.is_none() {
260            self.tabs.clone_from(&p.tabs);
261        }
262        self.default_tab = self.default_tab.or(p.default_tab);
263        self.rtl = self.rtl.or(p.rtl);
264        if self.font_align.is_none() {
265            self.font_align.clone_from(&p.font_align);
266        }
267        self.east_asian_line_break = self.east_asian_line_break.or(p.east_asian_line_break);
268        self.latin_line_break = self.latin_line_break.or(p.latin_line_break);
269        self.hanging_punct = self.hanging_punct.or(p.hanging_punct);
270    }
271}
272
273#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
274#[serde(rename_all = "camelCase")]
275pub enum Caps {
276    None,
277    Small,
278    All,
279}
280
281#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
282#[serde(rename_all = "camelCase")]
283pub enum Strike {
284    None,
285    Single,
286    Double,
287}
288
289/// A hyperlink or action on text or a shape.
290#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
291#[serde(rename_all = "camelCase", tag = "kind")]
292pub enum Action {
293    Url { url: String },
294    Slide { slide: crate::SlideId },
295    NextSlide,
296    PreviousSlide,
297    FirstSlide,
298    LastSlide,
299    EndShow,
300    LastViewed,
301    CustomShow { name: String },
302    Program { path: String },
303    PlayMedia,
304}
305
306#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
307#[serde(rename_all = "camelCase")]
308pub struct Hyperlink {
309    pub action: Action,
310    #[serde(default)]
311    pub tooltip: String,
312    #[serde(default)]
313    pub highlight_click: bool,
314}
315
316#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
317#[serde(default, rename_all = "camelCase")]
318pub struct RunProps {
319    /// Latin font family, or a theme reference `+mj-lt` / `+mn-lt`.
320    pub font: Option<String>,
321    pub font_ea: Option<String>,
322    pub font_cs: Option<String>,
323    pub font_sym: Option<String>,
324    /// Size in points.
325    pub size: Option<f64>,
326    pub bold: Option<bool>,
327    pub italic: Option<bool>,
328    /// `none`, `sng`, `dbl`, `heavy`, `dotted`, `dash`, `wavy`…
329    pub underline: Option<String>,
330    pub underline_color: Option<ColorRef>,
331    pub strike: Option<Strike>,
332    /// Baseline offset as a fraction of the size (0.3 = superscript 30%).
333    pub baseline: Option<f64>,
334    pub caps: Option<Caps>,
335    /// Character spacing in points.
336    pub spacing: Option<f64>,
337    /// Kern at and above this size (points); 0 = off.
338    pub kern: Option<f64>,
339    pub fill: Option<Fill>,
340    pub outline: Option<Line>,
341    pub highlight: Option<ColorRef>,
342    pub link: Option<Hyperlink>,
343    pub lang: Option<String>,
344    pub shadow: Option<crate::style::Shadow>,
345    pub glow: Option<crate::style::Glow>,
346    /// Spelling/grammar errors are not flagged.
347    pub no_proof: Option<bool>,
348}
349
350impl RunProps {
351    pub fn inherit(&mut self, p: &RunProps) {
352        macro_rules! take {
353            ($($f:ident),*) => { $( if self.$f.is_none() { self.$f.clone_from(&p.$f); } )* };
354        }
355        take!(
356            font,
357            font_ea,
358            font_cs,
359            font_sym,
360            size,
361            bold,
362            italic,
363            underline,
364            underline_color,
365            strike,
366            baseline,
367            caps,
368            spacing,
369            kern,
370            fill,
371            outline,
372            highlight,
373            link,
374            lang,
375            shadow,
376            glow,
377            no_proof
378        );
379    }
380}
381
382#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
383#[serde(rename_all = "camelCase", tag = "kind")]
384pub enum RunKind {
385    Text,
386    /// A line break inside the paragraph (Shift+Enter).
387    Break,
388    /// A field: `slidenum`, `datetime`, `datetime1`…`datetime13`, or other; `text` holds the last value.
389    Field {
390        field: String,
391    },
392    /// An inline equation (OMML kept as XML; `text` is its linear form).
393    Math {
394        omml: String,
395    },
396}
397
398#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
399#[serde(rename_all = "camelCase")]
400pub struct Run {
401    #[serde(default)]
402    pub text: String,
403    #[serde(default, skip_serializing_if = "is_default_props")]
404    pub props: RunProps,
405    #[serde(default = "text_kind", skip_serializing_if = "is_text_kind")]
406    pub kind: RunKind,
407}
408
409fn is_default_props(p: &RunProps) -> bool {
410    *p == RunProps::default()
411}
412fn text_kind() -> RunKind {
413    RunKind::Text
414}
415fn is_text_kind(k: &RunKind) -> bool {
416    *k == RunKind::Text
417}
418
419impl Run {
420    pub fn new(text: impl Into<String>) -> Self {
421        Run {
422            text: text.into(),
423            props: RunProps::default(),
424            kind: RunKind::Text,
425        }
426    }
427    pub fn with(text: impl Into<String>, props: RunProps) -> Self {
428        Run {
429            text: text.into(),
430            props,
431            kind: RunKind::Text,
432        }
433    }
434    /// The characters this run contributes to the paragraph (a break is one `\u{b}`).
435    pub fn content(&self) -> &str {
436        match self.kind {
437            RunKind::Break => "\u{b}",
438            _ => &self.text,
439        }
440    }
441    pub fn char_len(&self) -> usize {
442        self.content().chars().count()
443    }
444}
445
446#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
447#[serde(default, rename_all = "camelCase")]
448pub struct Paragraph {
449    /// Outline level 0–8.
450    pub level: u8,
451    #[serde(skip_serializing_if = "is_default_para")]
452    pub props: ParaProps,
453    pub runs: Vec<Run>,
454    /// Properties of the paragraph end mark (used for an empty paragraph's caret).
455    #[serde(skip_serializing_if = "is_default_props")]
456    pub end_props: RunProps,
457}
458
459fn is_default_para(p: &ParaProps) -> bool {
460    *p == ParaProps::default()
461}
462
463impl Paragraph {
464    pub fn new(text: &str) -> Self {
465        Paragraph {
466            runs: if text.is_empty() {
467                vec![]
468            } else {
469                vec![Run::new(text)]
470            },
471            ..Default::default()
472        }
473    }
474    pub fn text(&self) -> String {
475        self.runs.iter().map(|r| r.content()).collect()
476    }
477    pub fn char_len(&self) -> usize {
478        self.runs.iter().map(Run::char_len).sum()
479    }
480    pub fn is_empty(&self) -> bool {
481        self.char_len() == 0
482    }
483    /// Merge adjacent text runs with identical properties.
484    pub fn normalize(&mut self) {
485        let mut out: Vec<Run> = Vec::with_capacity(self.runs.len());
486        for r in self.runs.drain(..) {
487            if r.kind == RunKind::Text && r.text.is_empty() {
488                continue;
489            }
490            if let Some(last) = out.last_mut()
491                && last.kind == RunKind::Text
492                && r.kind == RunKind::Text
493                && last.props == r.props
494            {
495                last.text.push_str(&r.text);
496                continue;
497            }
498            out.push(r);
499        }
500        self.runs = out;
501    }
502}
503
504/// Per-level paragraph + run defaults (`lstStyle`, master text styles, `defaultTextStyle`).
505#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
506#[serde(default, rename_all = "camelCase")]
507pub struct LevelStyle {
508    pub para: ParaProps,
509    pub run: RunProps,
510}
511
512#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
513#[serde(default, rename_all = "camelCase")]
514pub struct ListStyle {
515    /// Levels 1–9 (index 0–8); `None` = not specified.
516    pub levels: Vec<Option<LevelStyle>>,
517}
518
519impl ListStyle {
520    pub fn level(&self, l: u8) -> Option<&LevelStyle> {
521        self.levels.get(l as usize).and_then(|x| x.as_ref())
522    }
523    pub fn set(&mut self, l: u8, s: LevelStyle) {
524        let i = (l as usize).min(8);
525        if self.levels.len() <= i {
526            self.levels.resize(i + 1, None);
527        }
528        if let Some(slot) = self.levels.get_mut(i) {
529            *slot = Some(s);
530        }
531    }
532    pub fn is_empty(&self) -> bool {
533        self.levels.iter().all(Option::is_none)
534    }
535}
536
537#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
538#[serde(default, rename_all = "camelCase")]
539pub struct TextBody {
540    pub body: BodyProps,
541    #[serde(skip_serializing_if = "ListStyle::is_empty")]
542    pub list_style: ListStyle,
543    pub paragraphs: Vec<Paragraph>,
544}
545
546impl TextBody {
547    pub fn from_text(text: &str) -> Self {
548        TextBody {
549            paragraphs: text.split('\n').map(Paragraph::new).collect(),
550            ..Default::default()
551        }
552    }
553    /// Plain text with `\n` between paragraphs.
554    pub fn text(&self) -> String {
555        self.paragraphs
556            .iter()
557            .map(Paragraph::text)
558            .collect::<Vec<_>>()
559            .join("\n")
560    }
561    pub fn is_empty(&self) -> bool {
562        self.paragraphs.iter().all(Paragraph::is_empty)
563    }
564    /// Total characters, counting one separator between paragraphs.
565    pub fn char_len(&self) -> usize {
566        let n: usize = self.paragraphs.iter().map(Paragraph::char_len).sum();
567        n + self.paragraphs.len().saturating_sub(1)
568    }
569}