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/// What a match of a search is painted over: the current one in orange and the
186/// rest in yellow, both light enough for black text, whatever the theme.
187pub fn match_fill(current: bool) -> Color32 {
188    if current {
189        Color32::from_rgb(255, 150, 40)
190    } else {
191        Color32::from_rgb(250, 220, 90)
192    }
193}
194
195#[cfg(test)]
196mod tests {
197    use super::*;
198
199    /// `LibreOffice`'s own "Internet Link" style, which every hyperlink this
200    /// file draws is at least as likely to carry as no colour at all.
201    fn navy() -> Color {
202        Color {
203            r: 0x00,
204            g: 0x00,
205            b: 0x80,
206        }
207    }
208
209    #[test]
210    fn for_theme_light_is_the_default_palette() {
211        let light = Palette::for_theme(false);
212        let default = Palette::default();
213        assert_eq!(light.paper, default.paper);
214        assert_eq!(light.ink, default.ink);
215        assert_eq!(light.link, default.link);
216    }
217
218    #[test]
219    fn for_theme_dark_moves_paper_ink_and_link_together() {
220        let dark = Palette::for_theme(true);
221        let light = Palette::for_theme(false);
222        assert_ne!(dark.paper, light.paper);
223        assert_ne!(dark.ink, light.ink);
224        assert_ne!(dark.link, light.link);
225    }
226
227    #[test]
228    fn text_format_keeps_a_documents_own_colour_in_either_theme() {
229        let properties = TextProperties {
230            color: Some(navy()),
231            ..TextProperties::default()
232        };
233        for dark_mode in [false, true] {
234            let format = text_format(
235                &properties,
236                DEFAULT_SIZE,
237                1.0,
238                Palette::for_theme(dark_mode),
239            );
240            assert_eq!(format.color, color32(navy()));
241        }
242    }
243
244    #[test]
245    fn text_format_falls_back_to_the_palettes_ink_when_unset() {
246        let properties = TextProperties::default();
247        let palette = Palette::for_theme(true);
248        let format = text_format(&properties, DEFAULT_SIZE, 1.0, palette);
249        assert_eq!(format.color, palette.ink);
250    }
251
252    /// The regression this guards: `LibreOffice` resolves a colour into every
253    /// hyperlink's "Internet Link" style whether an author touched it or not,
254    /// so a link that only followed an *unset* colour almost never followed
255    /// the theme in a real document. `link_format` has to win against a
256    /// colour the document actually carries, not just against none at all.
257    #[test]
258    fn link_format_overrides_a_documents_own_resolved_colour() {
259        let properties = TextProperties {
260            color: Some(navy()),
261            ..TextProperties::default()
262        };
263        let palette = Palette::for_theme(true);
264        let format = link_format(&properties, DEFAULT_SIZE, 1.0, palette);
265        assert_eq!(format.color, palette.link);
266        assert_ne!(format.color, color32(navy()));
267    }
268
269    #[test]
270    fn link_format_underline_follows_the_link_colour_not_the_old_text_colour() {
271        let properties = TextProperties {
272            color: Some(navy()),
273            underline: Some(true),
274            ..TextProperties::default()
275        };
276        let palette = Palette::for_theme(true);
277        let format = link_format(&properties, DEFAULT_SIZE, 1.0, palette);
278        assert_eq!(format.underline.color, palette.link);
279    }
280
281    #[test]
282    fn link_format_underlines_a_link_with_no_styling_at_all() {
283        let properties = TextProperties::default();
284        let palette = Palette::for_theme(false);
285        let format = link_format(&properties, DEFAULT_SIZE, 1.0, palette);
286        assert_eq!(format.color, palette.link);
287        assert!(format.underline.width >= 1.0);
288    }
289}