1use crate::{Alignment, ErrorCode, FileMakerError, FontManager, Insets, Result, Size, Unit};
14pub(crate) use helpers::break_lines;
15use helpers::{contains_emoji, layout_error, sum_run_widths};
16use serde::{Deserialize, Serialize};
17use unicode_segmentation::UnicodeSegmentation;
18
19#[path = "text_helpers.rs"]
20mod helpers;
21
22#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
24#[serde(rename_all = "snake_case")]
25pub enum TextOverflow {
26 #[default]
28 Wrap,
29 Shrink,
31 Ellipsis,
33 Clip,
35 Expand,
37 Error,
39}
40
41#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
43#[serde(rename_all = "snake_case")]
44pub enum WritingMode {
45 #[default]
47 Horizontal,
48 Vertical,
50}
51
52#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
54pub struct TextOptions {
55 pub font: String,
57 pub font_size: Unit,
59 pub min_font_size: Unit,
61 pub bounds: Size,
63 pub max_lines: Option<usize>,
65 pub overflow: TextOverflow,
67 pub line_height: u32,
69 pub writing_mode: WritingMode,
71 pub align_x: Alignment,
73 #[serde(default)]
75 pub padding_inline: Unit,
76}
77
78impl TextOptions {
79 pub fn validate(&self) -> Result<()> {
81 if self.font.is_empty()
82 || self.font_size <= Unit::ZERO
83 || self.min_font_size <= Unit::ZERO
84 || self.min_font_size > self.font_size
85 || self.bounds.width < Unit::ZERO
86 || self.bounds.height < Unit::ZERO
87 || self.padding_inline < Unit::ZERO
88 || self.padding_inline.checked_scale(2_000_000)?
89 > match self.writing_mode {
90 WritingMode::Horizontal => self.bounds.width,
91 WritingMode::Vertical => self.bounds.height,
92 }
93 || self.max_lines == Some(0)
94 || !(500_000..=4_000_000).contains(&self.line_height)
95 {
96 return Err(layout_error("text options are invalid"));
97 }
98 Ok(())
99 }
100}
101
102#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
104pub struct Glyph {
105 pub id: u16,
107 pub cluster: u32,
109 pub advance_x: Unit,
111 pub advance_y: Unit,
113 pub offset_x: Unit,
115 pub offset_y: Unit,
117}
118
119#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
121pub struct GlyphRun {
122 pub font: String,
124 pub rtl: bool,
126 pub text: String,
128 pub glyphs: Vec<Glyph>,
130 pub width: Unit,
132}
133
134#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
136pub struct TextLine {
137 #[serde(default)]
139 pub source_text: String,
140 pub runs: Vec<GlyphRun>,
142 pub width: Unit,
144 pub height: Unit,
146}
147
148#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
150#[serde(rename_all = "snake_case")]
151pub enum TextDiagnostic {
152 Clipped,
154 Ellipsized,
156 Shrunk,
158 VerticalWritingUnavailable,
160 ColorEmojiRequiresExporter,
162}
163
164#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
166pub struct TextLayout {
167 #[serde(default)]
169 pub writing_mode: WritingMode,
170 pub lines: Vec<TextLine>,
172 pub measured: Size,
174 pub font_size: Unit,
176 #[serde(default)]
180 pub paint_offset_y: Unit,
181 pub diagnostics: Vec<TextDiagnostic>,
183 #[serde(default = "default_alignment")]
185 pub align_x: Alignment,
186 #[serde(default)]
188 pub padding_inline: Unit,
189 #[serde(default)]
191 pub padding: Insets,
192}
193
194const fn default_alignment() -> Alignment {
195 Alignment::Start
196}
197
198impl TextLayout {
199 pub fn inline_offset(&self, available: Unit, line: &TextLine) -> Result<Unit> {
201 let content = available
202 .checked_sub(self.padding_inline.checked_scale(2_000_000)?)?
203 .max(Unit::ZERO);
204 let remaining = content.checked_sub(line.width)?.max(Unit::ZERO);
205 match self.align_x {
206 Alignment::Start => Ok(self.padding_inline),
207 Alignment::Center => Ok(self
208 .padding_inline
209 .checked_add(Unit::from_raw(remaining.raw() / 2))?),
210 Alignment::End => Ok(self.padding_inline.checked_add(remaining)?),
211 }
212 }
213}
214
215pub struct TextEngine<'a> {
217 fonts: &'a FontManager,
218}
219
220impl<'a> TextEngine<'a> {
221 #[must_use]
223 pub const fn new(fonts: &'a FontManager) -> Self {
224 Self { fonts }
225 }
226
227 pub fn layout(&self, text: &str, options: &TextOptions) -> Result<TextLayout> {
229 options.validate()?;
230 if text.len() > 4 * 1024 * 1024 {
231 return Err(FileMakerError::new(
232 ErrorCode::LimitExceeded,
233 "text exceeds engine hard limit",
234 ));
235 }
236 let mut layout = match options.overflow {
237 TextOverflow::Shrink => self.shrink_to_fit(text, options),
238 TextOverflow::Ellipsis => self.ellipsize(text, options),
239 _ => self.layout_at_size(text, options, options.font_size),
240 }?;
241 if contains_emoji(text) {
242 layout
243 .diagnostics
244 .push(TextDiagnostic::ColorEmojiRequiresExporter);
245 }
246 Ok(layout)
247 }
248
249 fn layout_at_size(&self, text: &str, options: &TextOptions, size: Unit) -> Result<TextLayout> {
250 match options.writing_mode {
251 WritingMode::Horizontal => self.layout_horizontal_at_size(text, options, size),
252 WritingMode::Vertical => self.layout_vertical_at_size(text, options, size),
253 }
254 }
255
256 fn layout_horizontal_at_size(
257 &self,
258 text: &str,
259 options: &TextOptions,
260 size: Unit,
261 ) -> Result<TextLayout> {
262 let content_width = options
263 .bounds
264 .width
265 .checked_sub(options.padding_inline.checked_scale(2_000_000)?)?;
266 let raw_lines = break_lines(text, content_width, |candidate| {
267 self.measure_line(candidate, &options.font, size)
268 })?;
269 let line_height = size.checked_scale(i64::from(options.line_height))?;
270 let mut lines = Vec::with_capacity(raw_lines.len());
271 let mut max_width = Unit::ZERO;
272 for line in raw_lines {
273 let runs =
274 crate::text_shaping::shape_bidi_line(&line, &options.font, size, self.fonts)?;
275 let width = sum_run_widths(&runs)?;
276 max_width = max_width.max(width);
277 lines.push(TextLine {
278 source_text: line,
279 runs,
280 width,
281 height: line_height,
282 });
283 }
284 let natural_height = line_height.checked_scale(
285 i64::try_from(lines.len()).map_err(|_| layout_error("line count overflow"))?
286 * 1_000_000,
287 )?;
288 self.finish_layout(lines, max_width, natural_height, options, size)
289 }
290
291 fn layout_vertical_at_size(
292 &self,
293 text: &str,
294 options: &TextOptions,
295 size: Unit,
296 ) -> Result<TextLayout> {
297 let content_height = options
298 .bounds
299 .height
300 .checked_sub(options.padding_inline.checked_scale(2_000_000)?)?;
301 let raw_columns = break_lines(text, content_height, |candidate| {
302 self.measure_vertical_line(candidate, &options.font, size)
303 })?;
304 let column_width = size.checked_scale(i64::from(options.line_height))?;
305 let mut lines = Vec::with_capacity(raw_columns.len());
306 let mut max_height = Unit::ZERO;
307 for column in raw_columns {
308 let runs =
309 crate::text_shaping::shape_vertical_line(&column, &options.font, size, self.fonts)?;
310 let height = sum_run_widths(&runs)?;
311 max_height = max_height.max(height);
312 lines.push(TextLine {
313 source_text: column,
314 runs,
315 width: height,
316 height: column_width,
317 });
318 }
319 let natural_width = column_width.checked_scale(
320 i64::try_from(lines.len()).map_err(|_| layout_error("column count overflow"))?
321 * 1_000_000,
322 )?;
323 self.finish_layout(lines, natural_width, max_height, options, size)
324 }
325
326 fn finish_layout(
327 &self,
328 mut lines: Vec<TextLine>,
329 natural_width: Unit,
330 natural_height: Unit,
331 options: &TextOptions,
332 size: Unit,
333 ) -> Result<TextLayout> {
334 let line_overflow = options.max_lines.is_some_and(|max| lines.len() > max);
335 let (measured_width, measured_height) = match options.writing_mode {
336 WritingMode::Horizontal => (
337 natural_width.checked_add(options.padding_inline.checked_scale(2_000_000)?)?,
338 natural_height,
339 ),
340 WritingMode::Vertical => (
341 natural_width,
342 natural_height.checked_add(options.padding_inline.checked_scale(2_000_000)?)?,
343 ),
344 };
345 let box_overflow =
346 measured_width > options.bounds.width || measured_height > options.bounds.height;
347 let mut diagnostics = Vec::new();
348 if line_overflow || box_overflow {
349 match options.overflow {
350 TextOverflow::Error => {
351 return Err(layout_error("text does not fit requested bounds"))
352 }
353 TextOverflow::Clip | TextOverflow::Wrap => {
354 diagnostics.push(TextDiagnostic::Clipped);
355 }
356 TextOverflow::Expand => {}
357 TextOverflow::Shrink | TextOverflow::Ellipsis => {
358 return Err(layout_error("invalid text overflow phase"))
359 }
360 }
361 }
362 if let Some(max) = options.max_lines {
363 lines.truncate(max);
364 }
365 let measured = if options.overflow == TextOverflow::Expand {
366 Size::new(measured_width, measured_height)?
367 } else {
368 options.bounds
369 };
370 Ok(TextLayout {
371 writing_mode: options.writing_mode,
372 lines,
373 measured,
374 font_size: size,
375 paint_offset_y: Unit::ZERO,
376 diagnostics,
377 align_x: options.align_x,
378 padding_inline: options.padding_inline,
379 padding: Insets::default(),
380 })
381 }
382
383 fn shrink_to_fit(&self, text: &str, options: &TextOptions) -> Result<TextLayout> {
384 let mut size = options.font_size;
385 loop {
386 let mut adjusted = options.clone();
387 adjusted.overflow = TextOverflow::Error;
388 match self.layout_at_size(text, &adjusted, size) {
389 Ok(mut layout) => {
390 if size != options.font_size {
391 layout.diagnostics.push(TextDiagnostic::Shrunk);
392 }
393 return Ok(layout);
394 }
395 Err(error) if error.code() == ErrorCode::LayoutInvalid => {}
396 Err(error) => return Err(error),
397 }
398 if size <= options.min_font_size {
399 return Err(layout_error("text does not fit at minimum font size"));
400 }
401 let next = size.checked_scale(950_000)?.max(options.min_font_size);
402 if next == size {
403 return Err(layout_error("font shrinking did not converge"));
404 }
405 size = next;
406 }
407 }
408
409 fn ellipsize(&self, text: &str, options: &TextOptions) -> Result<TextLayout> {
410 let mut adjusted = options.clone();
411 adjusted.overflow = TextOverflow::Error;
412 match self.layout_at_size(text, &adjusted, options.font_size) {
413 Ok(layout) => return Ok(layout),
414 Err(error) if error.code() == ErrorCode::LayoutInvalid => {}
415 Err(error) => return Err(error),
416 }
417 let mut graphemes: Vec<&str> = text.graphemes(true).collect();
418 loop {
419 if graphemes.pop().is_none() {
420 return Err(layout_error("ellipsis does not fit requested bounds"));
421 }
422 let candidate = format!("{}…", graphemes.concat());
423 match self.layout_at_size(&candidate, &adjusted, options.font_size) {
424 Ok(mut layout) => {
425 layout.diagnostics.push(TextDiagnostic::Ellipsized);
426 return Ok(layout);
427 }
428 Err(error) if error.code() == ErrorCode::LayoutInvalid => {}
429 Err(error) => return Err(error),
430 }
431 }
432 }
433
434 fn measure_line(&self, text: &str, font: &str, size: Unit) -> Result<Unit> {
435 sum_run_widths(&crate::text_shaping::shape_bidi_line(
436 text, font, size, self.fonts,
437 )?)
438 }
439
440 fn measure_vertical_line(&self, text: &str, font: &str, size: Unit) -> Result<Unit> {
441 sum_run_widths(&crate::text_shaping::shape_vertical_line(
442 text, font, size, self.fonts,
443 )?)
444 }
445}