1use harfrust::{Direction, FontRef, ShapeOptions, ShaperData, UnicodeBuffer};
14use serde::{Deserialize, Serialize};
15use unicode_bidi::BidiInfo;
16use unicode_segmentation::UnicodeSegmentation;
17
18use crate::{ErrorCode, FileMakerError, FontManager, Result, Size, Unit};
19
20#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
22#[serde(rename_all = "snake_case")]
23pub enum TextOverflow {
24 #[default]
26 Wrap,
27 Shrink,
29 Ellipsis,
31 Clip,
33 Expand,
35 Error,
37}
38
39#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
41#[serde(rename_all = "snake_case")]
42pub enum WritingMode {
43 #[default]
45 Horizontal,
46 Vertical,
48}
49
50#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
52pub struct TextOptions {
53 pub font: String,
55 pub font_size: Unit,
57 pub min_font_size: Unit,
59 pub bounds: Size,
61 pub max_lines: Option<usize>,
63 pub overflow: TextOverflow,
65 pub line_height: u32,
67 pub writing_mode: WritingMode,
69}
70
71impl TextOptions {
72 pub fn validate(&self) -> Result<()> {
74 if self.font.is_empty()
75 || self.font_size <= Unit::ZERO
76 || self.min_font_size <= Unit::ZERO
77 || self.min_font_size > self.font_size
78 || self.bounds.width < Unit::ZERO
79 || self.bounds.height < Unit::ZERO
80 || self.max_lines == Some(0)
81 || !(500_000..=4_000_000).contains(&self.line_height)
82 {
83 return Err(layout_error("text options are invalid"));
84 }
85 Ok(())
86 }
87}
88
89#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
91pub struct Glyph {
92 pub id: u16,
94 pub cluster: u32,
96 pub advance_x: Unit,
98 pub advance_y: Unit,
100 pub offset_x: Unit,
102 pub offset_y: Unit,
104}
105
106#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
108pub struct GlyphRun {
109 pub font: String,
111 pub rtl: bool,
113 pub text: String,
115 pub glyphs: Vec<Glyph>,
117 pub width: Unit,
119}
120
121#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
123pub struct TextLine {
124 pub runs: Vec<GlyphRun>,
126 pub width: Unit,
128 pub height: Unit,
130}
131
132#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
134#[serde(rename_all = "snake_case")]
135pub enum TextDiagnostic {
136 Clipped,
138 Ellipsized,
140 Shrunk,
142 VerticalWritingUnavailable,
144 ColorEmojiRequiresExporter,
146}
147
148#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
150pub struct TextLayout {
151 #[serde(default)]
153 pub writing_mode: WritingMode,
154 pub lines: Vec<TextLine>,
156 pub measured: Size,
158 pub font_size: Unit,
160 pub diagnostics: Vec<TextDiagnostic>,
162}
163
164pub struct TextEngine<'a> {
166 fonts: &'a FontManager,
167}
168
169impl<'a> TextEngine<'a> {
170 #[must_use]
172 pub const fn new(fonts: &'a FontManager) -> Self {
173 Self { fonts }
174 }
175
176 pub fn layout(&self, text: &str, options: &TextOptions) -> Result<TextLayout> {
178 options.validate()?;
179 if text.len() > 4 * 1024 * 1024 {
180 return Err(FileMakerError::new(
181 ErrorCode::LimitExceeded,
182 "text exceeds engine hard limit",
183 ));
184 }
185 let mut layout = match options.overflow {
186 TextOverflow::Shrink => self.shrink_to_fit(text, options),
187 TextOverflow::Ellipsis => self.ellipsize(text, options),
188 _ => self.layout_at_size(text, options, options.font_size),
189 }?;
190 if contains_emoji(text) {
191 layout
192 .diagnostics
193 .push(TextDiagnostic::ColorEmojiRequiresExporter);
194 }
195 Ok(layout)
196 }
197
198 fn layout_at_size(&self, text: &str, options: &TextOptions, size: Unit) -> Result<TextLayout> {
199 match options.writing_mode {
200 WritingMode::Horizontal => self.layout_horizontal_at_size(text, options, size),
201 WritingMode::Vertical => self.layout_vertical_at_size(text, options, size),
202 }
203 }
204
205 fn layout_horizontal_at_size(
206 &self,
207 text: &str,
208 options: &TextOptions,
209 size: Unit,
210 ) -> Result<TextLayout> {
211 let raw_lines = break_lines(text, options.bounds.width, |candidate| {
212 self.measure_line(candidate, &options.font, size)
213 })?;
214 let line_height = size.checked_scale(i64::from(options.line_height))?;
215 let mut lines = Vec::with_capacity(raw_lines.len());
216 let mut max_width = Unit::ZERO;
217 for line in raw_lines {
218 let runs = self.shape_bidi_line(&line, &options.font, size)?;
219 let width = sum_run_widths(&runs)?;
220 max_width = max_width.max(width);
221 lines.push(TextLine {
222 runs,
223 width,
224 height: line_height,
225 });
226 }
227 let natural_height = line_height.checked_scale(
228 i64::try_from(lines.len()).map_err(|_| layout_error("line count overflow"))?
229 * 1_000_000,
230 )?;
231 self.finish_layout(lines, max_width, natural_height, options, size)
232 }
233
234 fn layout_vertical_at_size(
235 &self,
236 text: &str,
237 options: &TextOptions,
238 size: Unit,
239 ) -> Result<TextLayout> {
240 let raw_columns = break_lines(text, options.bounds.height, |candidate| {
241 self.measure_vertical_line(candidate, &options.font, size)
242 })?;
243 let column_width = size.checked_scale(i64::from(options.line_height))?;
244 let mut lines = Vec::with_capacity(raw_columns.len());
245 let mut max_height = Unit::ZERO;
246 for column in raw_columns {
247 let runs = self.shape_vertical_line(&column, &options.font, size)?;
248 let height = sum_run_widths(&runs)?;
249 max_height = max_height.max(height);
250 lines.push(TextLine {
251 runs,
252 width: height,
253 height: column_width,
254 });
255 }
256 let natural_width = column_width.checked_scale(
257 i64::try_from(lines.len()).map_err(|_| layout_error("column count overflow"))?
258 * 1_000_000,
259 )?;
260 self.finish_layout(lines, natural_width, max_height, options, size)
261 }
262
263 fn finish_layout(
264 &self,
265 mut lines: Vec<TextLine>,
266 natural_width: Unit,
267 natural_height: Unit,
268 options: &TextOptions,
269 size: Unit,
270 ) -> Result<TextLayout> {
271 let line_overflow = options.max_lines.is_some_and(|max| lines.len() > max);
272 let box_overflow =
273 natural_width > options.bounds.width || natural_height > options.bounds.height;
274 let mut diagnostics = Vec::new();
275 if line_overflow || box_overflow {
276 match options.overflow {
277 TextOverflow::Error => {
278 return Err(layout_error("text does not fit requested bounds"))
279 }
280 TextOverflow::Clip | TextOverflow::Wrap => {
281 diagnostics.push(TextDiagnostic::Clipped);
282 }
283 TextOverflow::Expand => {}
284 TextOverflow::Shrink | TextOverflow::Ellipsis => {
285 return Err(layout_error("invalid text overflow phase"))
286 }
287 }
288 }
289 if let Some(max) = options.max_lines {
290 lines.truncate(max);
291 }
292 let measured = if options.overflow == TextOverflow::Expand {
293 Size::new(natural_width, natural_height)?
294 } else {
295 options.bounds
296 };
297 Ok(TextLayout {
298 writing_mode: options.writing_mode,
299 lines,
300 measured,
301 font_size: size,
302 diagnostics,
303 })
304 }
305
306 fn shrink_to_fit(&self, text: &str, options: &TextOptions) -> Result<TextLayout> {
307 let mut size = options.font_size;
308 loop {
309 let mut adjusted = options.clone();
310 adjusted.overflow = TextOverflow::Error;
311 match self.layout_at_size(text, &adjusted, size) {
312 Ok(mut layout) => {
313 if size != options.font_size {
314 layout.diagnostics.push(TextDiagnostic::Shrunk);
315 }
316 return Ok(layout);
317 }
318 Err(error) if error.code() == ErrorCode::LayoutInvalid => {}
319 Err(error) => return Err(error),
320 }
321 if size <= options.min_font_size {
322 return Err(layout_error("text does not fit at minimum font size"));
323 }
324 let next = size.checked_scale(950_000)?.max(options.min_font_size);
325 if next == size {
326 return Err(layout_error("font shrinking did not converge"));
327 }
328 size = next;
329 }
330 }
331
332 fn ellipsize(&self, text: &str, options: &TextOptions) -> Result<TextLayout> {
333 let mut adjusted = options.clone();
334 adjusted.overflow = TextOverflow::Error;
335 match self.layout_at_size(text, &adjusted, options.font_size) {
336 Ok(layout) => return Ok(layout),
337 Err(error) if error.code() == ErrorCode::LayoutInvalid => {}
338 Err(error) => return Err(error),
339 }
340 let mut graphemes: Vec<&str> = text.graphemes(true).collect();
341 loop {
342 if graphemes.pop().is_none() {
343 return Err(layout_error("ellipsis does not fit requested bounds"));
344 }
345 let candidate = format!("{}…", graphemes.concat());
346 match self.layout_at_size(&candidate, &adjusted, options.font_size) {
347 Ok(mut layout) => {
348 layout.diagnostics.push(TextDiagnostic::Ellipsized);
349 return Ok(layout);
350 }
351 Err(error) if error.code() == ErrorCode::LayoutInvalid => {}
352 Err(error) => return Err(error),
353 }
354 }
355 }
356
357 fn measure_line(&self, text: &str, font: &str, size: Unit) -> Result<Unit> {
358 sum_run_widths(&self.shape_bidi_line(text, font, size)?)
359 }
360
361 fn measure_vertical_line(&self, text: &str, font: &str, size: Unit) -> Result<Unit> {
362 sum_run_widths(&self.shape_vertical_line(text, font, size)?)
363 }
364
365 fn shape_bidi_line(&self, text: &str, primary: &str, size: Unit) -> Result<Vec<GlyphRun>> {
366 if text.is_empty() {
367 return Ok(Vec::new());
368 }
369 let bidi = BidiInfo::new(text, None);
370 let paragraph = bidi
371 .paragraphs
372 .first()
373 .ok_or_else(|| layout_error("BiDi paragraph is missing"))?;
374 let (_, visual_runs) = bidi.visual_runs(paragraph, 0..text.len());
375 let mut result = Vec::new();
376 for range in visual_runs {
377 let rtl = bidi.levels[range.start].is_rtl();
378 let direction = if rtl {
379 Direction::RightToLeft
380 } else {
381 Direction::LeftToRight
382 };
383 result.extend(self.shape_font_fallback(&text[range], primary, size, direction)?);
384 }
385 Ok(result)
386 }
387
388 fn shape_vertical_line(&self, text: &str, primary: &str, size: Unit) -> Result<Vec<GlyphRun>> {
389 if text.is_empty() {
390 return Ok(Vec::new());
391 }
392 self.shape_font_fallback(text, primary, size, Direction::TopToBottom)
393 }
394
395 fn shape_font_fallback(
396 &self,
397 text: &str,
398 primary: &str,
399 size: Unit,
400 direction: Direction,
401 ) -> Result<Vec<GlyphRun>> {
402 let mut chunks: Vec<(&str, std::ops::Range<usize>)> = Vec::new();
403 for (offset, grapheme) in text.grapheme_indices(true) {
404 let font = self
405 .fonts
406 .select_for_grapheme(primary, grapheme)?
407 .name
408 .as_str();
409 let end = offset + grapheme.len();
410 if let Some((last_font, range)) = chunks.last_mut() {
411 if *last_font == font {
412 range.end = end;
413 continue;
414 }
415 }
416 chunks.push((font, offset..end));
417 }
418 if direction == Direction::RightToLeft {
419 chunks.reverse();
420 }
421 chunks
422 .into_iter()
423 .map(|(font, range)| self.shape_run(&text[range], font, size, direction))
424 .collect()
425 }
426
427 fn shape_run(
428 &self,
429 text: &str,
430 font_name: &str,
431 size: Unit,
432 direction: Direction,
433 ) -> Result<GlyphRun> {
434 let font = self.fonts.get(font_name)?;
435 let face = FontRef::from_index(&font.bytes, font.face_index)
436 .map_err(|_| font_error("registered font cannot be shaped"))?;
437 let mut buffer = UnicodeBuffer::new();
438 buffer.push_str(text);
439 buffer.set_direction(direction);
440 let shaper_data = ShaperData::new(&face);
441 let shaper = shaper_data.shaper(&face).build();
442 let shaped = shaper.shape(buffer, ShapeOptions::default());
443 let upem = i64::from(font.units_per_em()?);
444 let mut width = Unit::ZERO;
445 let mut glyphs = Vec::with_capacity(shaped.len());
446 for (info, position) in shaped.glyph_infos().iter().zip(shaped.glyph_positions()) {
447 let advance_x = scale_font_unit(position.x_advance, size, upem)?;
448 let advance_y = scale_font_unit(position.y_advance, size, upem)?;
449 let inline_advance =
450 if matches!(direction, Direction::TopToBottom | Direction::BottomToTop) {
451 absolute_unit(advance_y)?
452 } else {
453 absolute_unit(advance_x)?
454 };
455 width = width.checked_add(inline_advance)?;
456 glyphs.push(Glyph {
457 id: u16::try_from(info.glyph_id).map_err(|_| font_error("glyph ID exceeds u16"))?,
458 cluster: info.cluster,
459 advance_x,
460 advance_y,
461 offset_x: scale_font_unit(position.x_offset, size, upem)?,
462 offset_y: scale_font_unit(position.y_offset, size, upem)?,
463 });
464 }
465 Ok(GlyphRun {
466 font: font_name.to_owned(),
467 rtl: direction == Direction::RightToLeft,
468 text: text.to_owned(),
469 glyphs,
470 width,
471 })
472 }
473}
474
475pub(crate) fn break_lines(
476 text: &str,
477 max_width: Unit,
478 mut measure: impl FnMut(&str) -> Result<Unit>,
479) -> Result<Vec<String>> {
480 let mut lines = Vec::new();
481 let mut probes = 0_usize;
482 for paragraph in text.split('\n') {
483 let mut line = String::new();
484 for part in paragraph.split_word_bounds() {
485 let candidate = format!("{line}{part}");
486 let candidate_width = measure_bounded(&candidate, &mut probes, &mut measure)?;
487 if !line.is_empty() && candidate_width > max_width {
488 lines.push(line.trim_end().to_owned());
489 line.clear();
490 append_overlong(
491 part.trim_start(),
492 max_width,
493 &mut line,
494 &mut lines,
495 &mut probes,
496 &mut measure,
497 )?;
498 } else if candidate_width > max_width {
499 line = candidate;
500 let part = std::mem::take(&mut line);
501 append_overlong(
502 &part,
503 max_width,
504 &mut line,
505 &mut lines,
506 &mut probes,
507 &mut measure,
508 )?;
509 } else {
510 line = candidate;
511 }
512 }
513 lines.push(line);
514 }
515 if lines.is_empty() {
516 lines.push(String::new());
517 }
518 Ok(lines)
519}
520
521fn append_overlong(
522 source: &str,
523 max_width: Unit,
524 line: &mut String,
525 lines: &mut Vec<String>,
526 probes: &mut usize,
527 measure: &mut impl FnMut(&str) -> Result<Unit>,
528) -> Result<()> {
529 for grapheme in source.graphemes(true) {
530 let candidate = format!("{line}{grapheme}");
531 if !line.is_empty() && measure_bounded(&candidate, probes, measure)? > max_width {
532 lines.push(std::mem::take(line));
533 grapheme.clone_into(line);
534 } else {
535 *line = candidate;
536 }
537 }
538 Ok(())
539}
540
541fn measure_bounded(
542 source: &str,
543 probes: &mut usize,
544 measure: &mut impl FnMut(&str) -> Result<Unit>,
545) -> Result<Unit> {
546 const MAX_LINE_BREAK_PROBES: usize = 100_000;
547 *probes = probes
548 .checked_add(1)
549 .ok_or_else(|| limit_error("line-break probe count overflow"))?;
550 if *probes > MAX_LINE_BREAK_PROBES {
551 return Err(limit_error("line breaking exceeds its operation budget"));
552 }
553 measure(source)
554}
555
556fn sum_run_widths(runs: &[GlyphRun]) -> Result<Unit> {
557 runs.iter()
558 .try_fold(Unit::ZERO, |total, run| total.checked_add(run.width))
559}
560
561fn scale_font_unit(value: i32, size: Unit, units_per_em: i64) -> Result<Unit> {
562 Unit::from_ratio(
563 i128::from(value) * i128::from(size.raw()),
564 i128::from(units_per_em) * i128::from(Unit::PER_POINT),
565 )
566}
567
568fn absolute_unit(value: Unit) -> Result<Unit> {
569 value
570 .raw()
571 .checked_abs()
572 .map(Unit::from_raw)
573 .ok_or_else(|| layout_error("glyph advance overflow"))
574}
575
576fn layout_error(message: impl Into<String>) -> FileMakerError {
577 FileMakerError::new(ErrorCode::LayoutInvalid, message)
578}
579
580fn font_error(message: impl Into<String>) -> FileMakerError {
581 FileMakerError::new(ErrorCode::FontMissing, message)
582}
583
584fn limit_error(message: impl Into<String>) -> FileMakerError {
585 FileMakerError::new(ErrorCode::LimitExceeded, message)
586}
587
588fn contains_emoji(text: &str) -> bool {
589 text.chars().any(|character| {
590 matches!(
591 character as u32,
592 0x1F000..=0x1FAFF | 0x2600..=0x27BF | 0xFE0F | 0x200D
593 )
594 })
595}