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, Measure, 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/// **A page is paper, in a dark window as much as a light one.** The colours in
27/// a document are the document's: a heading the author made near-black is drawn
28/// near-black, and drawing it on a dark ground because the desktop asked for a
29/// dark desktop makes it invisible. Found by opening a deck Impress wrote, whose
30/// title is dark by its master page's style and disappeared. So the page keeps
31/// its own ground and the window's chrome — menus, panels, the grid's headers —
32/// follows the desktop. That is what every office application and every PDF
33/// viewer does with a page, and what the theme is for everywhere else.
34#[derive(Debug, Clone, Copy)]
35pub struct Palette {
36    /// What the page itself is.
37    pub paper: Color32,
38    /// The colour of text the document does not colour.
39    pub ink: Color32,
40    /// The colour of a link the document does not colour.
41    pub link: Color32,
42}
43
44impl Default for Palette {
45    fn default() -> Self {
46        Self {
47            paper: Color32::from_rgb(0xff, 0xff, 0xff),
48            // ODF's own default, and what a producer means by writing no colour.
49            ink: Color32::from_rgb(0x00, 0x00, 0x00),
50            // Legible on paper and recognizable as a link, which the window's own
51            // hyperlink colour is not: that one is chosen against the window's
52            // background and can be a pale blue meant for a dark panel.
53            link: Color32::from_rgb(0x1a, 0x5f, 0xb4),
54        }
55    }
56}
57
58/// What a resolved run of text is drawn with.
59///
60/// `inherited` is the size in points of the text this run sits inside, which is
61/// what a relative font size is relative to; `zoom` scales points to the screen.
62pub fn text_format(
63    properties: &TextProperties,
64    inherited: f32,
65    zoom: f32,
66    palette: Palette,
67) -> TextFormat {
68    let size = match properties.size {
69        Some(measure) => measure.resolve(inherited),
70        None => inherited,
71    };
72    let variant = Variant {
73        bold: properties.bold.unwrap_or(false),
74        italic: properties.italic.unwrap_or(false),
75    };
76    let family = match &properties.font_family {
77        Some(name) if !name.is_empty() => family_of(name, variant),
78        // A document that names no family is drawn in egui's own, which is the
79        // one face that is certainly present.
80        _ => FontFamily::Proportional,
81    };
82
83    let (scale, valign) = match properties.position {
84        Some(Position::Super) => (SCRIPT_SCALE, Align::TOP),
85        Some(Position::Sub) => (SCRIPT_SCALE, Align::BOTTOM),
86        _ => (1.0, Align::BOTTOM),
87    };
88
89    let color = properties.color.map_or(palette.ink, color32);
90    let line = Stroke::new((size * zoom * 0.06).max(1.0), color);
91
92    TextFormat {
93        font_id: FontId::new(size * zoom * scale, family),
94        color,
95        background: properties.background.map_or(Color32::TRANSPARENT, color32),
96        underline: if properties.underline.unwrap_or(false) {
97            line
98        } else {
99            Stroke::NONE
100        },
101        strikethrough: if properties.strike.unwrap_or(false) {
102            line
103        } else {
104            Stroke::NONE
105        },
106        valign,
107        // The italic face was asked for by name above. egui's own `italics` skews
108        // the regular face, which is a different drawing from the one the font
109        // designer made, and setting both would skew an italic face further.
110        italics: false,
111        ..TextFormat::default()
112    }
113}
114
115/// The same, for a run the document marks as a hyperlink.
116pub fn link_format(
117    properties: &TextProperties,
118    inherited: f32,
119    zoom: f32,
120    palette: Palette,
121) -> TextFormat {
122    let mut format = text_format(properties, inherited, zoom, palette);
123    if properties.color.is_none() {
124        format.color = palette.link;
125        if format.underline == Stroke::NONE {
126            format.underline = Stroke::new(1.0, format.color);
127        }
128    }
129    format
130}
131
132/// The size in points a run is drawn at, for handing to whatever it contains.
133pub fn size_of(properties: &TextProperties, inherited: f32) -> f32 {
134    properties
135        .size
136        .map_or(inherited, |measure| measure.resolve(inherited))
137}
138
139/// An ODF colour as egui's.
140pub fn color32(color: Color) -> Color32 {
141    Color32::from_rgb(color.r, color.g, color.b)
142}
143
144/// A length or proportion resolved to points, for a line height.
145pub fn line_height(measure: Option<Measure>, size: f32) -> Option<f32> {
146    measure.map(|m| m.resolve(size))
147}