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}