Skip to main content

odox_ui/
format.rs

1//! Turning resolved ODF properties into what egui draws text with.
2//
3// Author: David M. Anderson
4// Built with AI assistance (Claude, Anthropic)
5
6use eframe::egui::{Align, Color32, FontFamily, FontId, Stroke, TextFormat};
7use odox_core::{Color, Position, TextProperties};
8
9use crate::fonts::{Variant, family_of};
10
11/// The font size a document that names none is drawn at.
12///
13/// ODF has no default, and every producer writes one into its default paragraph
14/// style, so this is reached only by a document with no styles at all.
15pub const DEFAULT_SIZE: f32 = 12.0;
16
17/// How a superscript or subscript is drawn, as a proportion of the run's size.
18///
19/// ODF writes the offset and the scale per run, and honouring them exactly would
20/// mean placing a glyph at a baseline offset egui's text layout does not offer.
21/// This is the proportion every office application uses by default.
22const SCRIPT_SCALE: f32 = 0.58;
23
24/// The colours a document is drawn in where the document itself does not say.
25///
26/// The document's own colours are always the document's: a colour the author
27/// set is drawn as set, whatever the window's theme. Where a document sets
28/// none, [`Palette::for_theme`] follows the window instead of staying fixed —
29/// paper and ink move together, the way a page and its own unset text would
30/// if it were reprinted for the window it is read in, so a document that
31/// never colours itself is legible in both. The chrome around the page —
32/// menus, panels, the grid's headers — already follows the desktop by the
33/// same principle, one level up.
34///
35/// This does not reach a document that colours its own text but leaves the
36/// page unset: that colour is drawn as set, on paper that now follows the
37/// window, and an author's own dark heading can still land on dark paper.
38/// That is the narrower case the old, page-only version of this rule was
39/// written against; recolouring an author's own choice for contrast is a
40/// larger step than this file takes.
41///
42/// `link` is the one colour that does not follow this rule: [`link_format`]
43/// draws every hyperlink in it regardless of what the document says, because
44/// almost every ODF producer writes a resolved colour into a hyperlink's
45/// character style whether an author touched it or not — "the document set
46/// a colour" is barely ever true of a link's blue in the way it is of a
47/// heading's, so treating it as an author's choice would mean this file's
48/// dark mode never reaching almost any real hyperlink.
49#[derive(Debug, Clone, Copy)]
50pub struct Palette {
51    /// What the page itself is.
52    pub paper: Color32,
53    /// The colour of text the document does not colour.
54    pub ink: Color32,
55    /// The colour every hyperlink is drawn in, whatever the document says.
56    pub link: Color32,
57}
58
59impl Default for Palette {
60    fn default() -> Self {
61        Self {
62            paper: Color32::from_rgb(0xff, 0xff, 0xff),
63            // ODF's own default, and what a producer means by writing no colour.
64            ink: Color32::from_rgb(0x00, 0x00, 0x00),
65            // Legible on paper and recognizable as a link, which the window's own
66            // hyperlink colour is not: that one is chosen against the window's
67            // background and can be a pale blue meant for a dark panel.
68            link: Color32::from_rgb(0x1a, 0x5f, 0xb4),
69        }
70    }
71}
72
73impl Palette {
74    /// The palette for a window in, or not in, dark mode.
75    ///
76    /// All three move together, because they are the unset document's own
77    /// page and the unset document's own text, and a page redrawn dark with
78    /// text left black would be unreadable rather than themed. The dark ink
79    /// is an off-white rather than pure white, and the dark link a lighter,
80    /// more saturated blue than the light palette's — both chosen to meet
81    /// WCAG AA contrast against the dark paper (measured: ink 11.2:1, link
82    /// 5.4:1; AA needs 4.5:1) rather than merely looking legible in one shot.
83    pub fn for_theme(dark_mode: bool) -> Self {
84        if dark_mode {
85            Self {
86                paper: Color32::from_rgb(0x1e, 0x1e, 0x1e),
87                ink: Color32::from_rgb(0xd4, 0xd4, 0xd4),
88                link: Color32::from_rgb(0x37, 0x94, 0xff),
89            }
90        } else {
91            Self::default()
92        }
93    }
94}
95
96/// What a resolved run of text is drawn with.
97///
98/// `inherited` is the size in points of the text this run sits inside, which is
99/// what a relative font size is relative to; `zoom` scales points to the screen.
100pub fn text_format(
101    properties: &TextProperties,
102    inherited: f32,
103    zoom: f32,
104    palette: Palette,
105) -> TextFormat {
106    let size = match properties.size {
107        Some(measure) => measure.resolve(inherited),
108        None => inherited,
109    };
110    let variant = Variant {
111        bold: properties.bold.unwrap_or(false),
112        italic: properties.italic.unwrap_or(false),
113    };
114    let family = match &properties.font_family {
115        Some(name) if !name.is_empty() => family_of(name, variant),
116        // A document that names no family is drawn in egui's own, which is the
117        // one face that is certainly present.
118        _ => FontFamily::Proportional,
119    };
120
121    let (scale, valign) = match properties.position {
122        Some(Position::Super) => (SCRIPT_SCALE, Align::TOP),
123        Some(Position::Sub) => (SCRIPT_SCALE, Align::BOTTOM),
124        _ => (1.0, Align::BOTTOM),
125    };
126
127    let color = properties.color.map_or(palette.ink, color32);
128    let line = Stroke::new((size * zoom * 0.06).max(1.0), color);
129
130    TextFormat {
131        font_id: FontId::new(size * zoom * scale, family),
132        color,
133        background: properties.background.map_or(Color32::TRANSPARENT, color32),
134        underline: if properties.underline.unwrap_or(false) {
135            line
136        } else {
137            Stroke::NONE
138        },
139        strikethrough: if properties.strike.unwrap_or(false) {
140            line
141        } else {
142            Stroke::NONE
143        },
144        valign,
145        // The italic face was asked for by name above. egui's own `italics` skews
146        // the regular face, which is a different drawing from the one the font
147        // designer made, and setting both would skew an italic face further.
148        italics: false,
149        ..TextFormat::default()
150    }
151}
152
153/// The same, for a run the document marks as a hyperlink.
154///
155/// Always `palette.link`, even where `properties` carries its own colour:
156/// [`Palette`]'s own doc says why a hyperlink's colour does not get the
157/// respect this file gives every other one.
158pub fn link_format(
159    properties: &TextProperties,
160    inherited: f32,
161    zoom: f32,
162    palette: Palette,
163) -> TextFormat {
164    let mut format = text_format(properties, inherited, zoom, palette);
165    format.color = palette.link;
166    // The underline keeps the width the document asked for, if any, but its
167    // colour follows the text it underlines rather than whatever colour that
168    // text used to be.
169    format.underline = Stroke::new(format.underline.width.max(1.0), format.color);
170    format
171}
172
173/// The size in points a run is drawn at, for handing to whatever it contains.
174pub fn size_of(properties: &TextProperties, inherited: f32) -> f32 {
175    properties
176        .size
177        .map_or(inherited, |measure| measure.resolve(inherited))
178}
179
180/// An ODF colour as egui's.
181pub fn color32(color: Color) -> Color32 {
182    Color32::from_rgb(color.r, color.g, color.b)
183}
184
185#[cfg(test)]
186mod tests {
187    use super::*;
188
189    /// `LibreOffice`'s own "Internet Link" style, which every hyperlink this
190    /// file draws is at least as likely to carry as no colour at all.
191    fn navy() -> Color {
192        Color {
193            r: 0x00,
194            g: 0x00,
195            b: 0x80,
196        }
197    }
198
199    #[test]
200    fn for_theme_light_is_the_default_palette() {
201        let light = Palette::for_theme(false);
202        let default = Palette::default();
203        assert_eq!(light.paper, default.paper);
204        assert_eq!(light.ink, default.ink);
205        assert_eq!(light.link, default.link);
206    }
207
208    #[test]
209    fn for_theme_dark_moves_paper_ink_and_link_together() {
210        let dark = Palette::for_theme(true);
211        let light = Palette::for_theme(false);
212        assert_ne!(dark.paper, light.paper);
213        assert_ne!(dark.ink, light.ink);
214        assert_ne!(dark.link, light.link);
215    }
216
217    #[test]
218    fn text_format_keeps_a_documents_own_colour_in_either_theme() {
219        let properties = TextProperties {
220            color: Some(navy()),
221            ..TextProperties::default()
222        };
223        for dark_mode in [false, true] {
224            let format = text_format(
225                &properties,
226                DEFAULT_SIZE,
227                1.0,
228                Palette::for_theme(dark_mode),
229            );
230            assert_eq!(format.color, color32(navy()));
231        }
232    }
233
234    #[test]
235    fn text_format_falls_back_to_the_palettes_ink_when_unset() {
236        let properties = TextProperties::default();
237        let palette = Palette::for_theme(true);
238        let format = text_format(&properties, DEFAULT_SIZE, 1.0, palette);
239        assert_eq!(format.color, palette.ink);
240    }
241
242    /// The regression this guards: `LibreOffice` resolves a colour into every
243    /// hyperlink's "Internet Link" style whether an author touched it or not,
244    /// so a link that only followed an *unset* colour almost never followed
245    /// the theme in a real document. `link_format` has to win against a
246    /// colour the document actually carries, not just against none at all.
247    #[test]
248    fn link_format_overrides_a_documents_own_resolved_colour() {
249        let properties = TextProperties {
250            color: Some(navy()),
251            ..TextProperties::default()
252        };
253        let palette = Palette::for_theme(true);
254        let format = link_format(&properties, DEFAULT_SIZE, 1.0, palette);
255        assert_eq!(format.color, palette.link);
256        assert_ne!(format.color, color32(navy()));
257    }
258
259    #[test]
260    fn link_format_underline_follows_the_link_colour_not_the_old_text_colour() {
261        let properties = TextProperties {
262            color: Some(navy()),
263            underline: Some(true),
264            ..TextProperties::default()
265        };
266        let palette = Palette::for_theme(true);
267        let format = link_format(&properties, DEFAULT_SIZE, 1.0, palette);
268        assert_eq!(format.underline.color, palette.link);
269    }
270
271    #[test]
272    fn link_format_underlines_a_link_with_no_styling_at_all() {
273        let properties = TextProperties::default();
274        let palette = Palette::for_theme(false);
275        let format = link_format(&properties, DEFAULT_SIZE, 1.0, palette);
276        assert_eq!(format.color, palette.link);
277        assert!(format.underline.width >= 1.0);
278    }
279}