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(text_fit_error(
352 "text does not fit requested bounds",
353 options,
354 ))
355 }
356 TextOverflow::Clip | TextOverflow::Wrap => {
357 diagnostics.push(TextDiagnostic::Clipped);
358 }
359 TextOverflow::Expand => {}
360 TextOverflow::Shrink | TextOverflow::Ellipsis => {
361 return Err(layout_error("invalid text overflow phase"))
362 }
363 }
364 }
365 if let Some(max) = options.max_lines {
366 lines.truncate(max);
367 }
368 let measured = if options.overflow == TextOverflow::Expand {
369 Size::new(measured_width, measured_height)?
370 } else {
371 options.bounds
372 };
373 Ok(TextLayout {
374 writing_mode: options.writing_mode,
375 lines,
376 measured,
377 font_size: size,
378 paint_offset_y: Unit::ZERO,
379 diagnostics,
380 align_x: options.align_x,
381 padding_inline: options.padding_inline,
382 padding: Insets::default(),
383 })
384 }
385
386 fn shrink_to_fit(&self, text: &str, options: &TextOptions) -> Result<TextLayout> {
387 let mut size = options.font_size;
388 loop {
389 let mut adjusted = options.clone();
390 adjusted.overflow = TextOverflow::Error;
391 match self.layout_at_size(text, &adjusted, size) {
392 Ok(mut layout) => {
393 if size != options.font_size {
394 layout.diagnostics.push(TextDiagnostic::Shrunk);
395 }
396 return Ok(layout);
397 }
398 Err(error) if error.code() == ErrorCode::LayoutInvalid => {}
399 Err(error) => return Err(error),
400 }
401 if size <= options.min_font_size {
402 return Err(text_fit_error(
403 "text does not fit at minimum font size",
404 options,
405 ));
406 }
407 let next = size.checked_scale(950_000)?.max(options.min_font_size);
408 if next == size {
409 return Err(layout_error("font shrinking did not converge"));
410 }
411 size = next;
412 }
413 }
414
415 fn ellipsize(&self, text: &str, options: &TextOptions) -> Result<TextLayout> {
416 let mut adjusted = options.clone();
417 adjusted.overflow = TextOverflow::Error;
418 match self.layout_at_size(text, &adjusted, options.font_size) {
419 Ok(layout) => return Ok(layout),
420 Err(error) if error.code() == ErrorCode::LayoutInvalid => {}
421 Err(error) => return Err(error),
422 }
423 let mut graphemes: Vec<&str> = text.graphemes(true).collect();
424 loop {
425 if graphemes.pop().is_none() {
426 return Err(layout_error("ellipsis does not fit requested bounds"));
427 }
428 let candidate = format!("{}…", graphemes.concat());
429 match self.layout_at_size(&candidate, &adjusted, options.font_size) {
430 Ok(mut layout) => {
431 layout.diagnostics.push(TextDiagnostic::Ellipsized);
432 return Ok(layout);
433 }
434 Err(error) if error.code() == ErrorCode::LayoutInvalid => {}
435 Err(error) => return Err(error),
436 }
437 }
438 }
439
440 fn measure_line(&self, text: &str, font: &str, size: Unit) -> Result<Unit> {
441 sum_run_widths(&crate::text_shaping::shape_bidi_line(
442 text, font, size, self.fonts,
443 )?)
444 }
445
446 fn measure_vertical_line(&self, text: &str, font: &str, size: Unit) -> Result<Unit> {
447 sum_run_widths(&crate::text_shaping::shape_vertical_line(
448 text, font, size, self.fonts,
449 )?)
450 }
451}
452
453fn text_fit_error(message: &str, options: &TextOptions) -> FileMakerError {
454 layout_error(message).with_text_context(crate::error::TextFailureContextV1 {
455 available_width_raw: options.bounds.width.raw(),
456 available_height_raw: options.bounds.height.raw(),
457 font_size_raw: options.font_size.raw(),
458 minimum_font_size_raw: options.min_font_size.raw(),
459 })
460}