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}