Skip to main content

appcore_filemaker/
text.rs

1// =============================================================================
2//        #######
3//     ###       ###     F: text.rs
4//    ##   ## ##   ##    P: AppCore-Runtime
5//         ## ##
6//                       C: 2026/08/30 05:00:00 by dnettoRaw
7//    ##   ## ##   ##    U: 2026/08/30 05:00:00 by dnettoRaw
8//      ###########      S: 1.0.2-rc
9// =============================================================================
10
11//! Defines bounded text contracts and behavior for this crate.
12
13use 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/// Text overflow strategy.
21#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
22#[serde(rename_all = "snake_case")]
23pub enum TextOverflow {
24    /// Wrap at Unicode word boundaries.
25    #[default]
26    Wrap,
27    /// Reduce font size down to the explicit minimum.
28    Shrink,
29    /// Replace the final fitting graphemes with an ellipsis.
30    Ellipsis,
31    /// Retain glyphs and expose a clipping diagnostic.
32    Clip,
33    /// Expand the measured box to fit.
34    Expand,
35    /// Reject overflow.
36    Error,
37}
38
39/// Writing direction selected before shaping.
40#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
41#[serde(rename_all = "snake_case")]
42pub enum WritingMode {
43    /// Horizontal lines with `BiDi` runs.
44    #[default]
45    Horizontal,
46    /// Top-to-bottom columns flowing from right to left.
47    Vertical,
48}
49
50/// Explicit text measurement options.
51#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
52pub struct TextOptions {
53    /// Primary registered font.
54    pub font: String,
55    /// Requested font size.
56    pub font_size: Unit,
57    /// Minimum size used only by `Shrink`.
58    pub min_font_size: Unit,
59    /// Available layout box.
60    pub bounds: Size,
61    /// Optional maximum line count.
62    pub max_lines: Option<usize>,
63    /// Overflow behavior.
64    pub overflow: TextOverflow,
65    /// Line-height multiplier in millionths.
66    pub line_height: u32,
67    /// Horizontal or vertical writing.
68    pub writing_mode: WritingMode,
69}
70
71impl TextOptions {
72    /// Validates numeric text bounds.
73    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/// One positioned glyph in fixed-point output coordinates.
90#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
91pub struct Glyph {
92    /// Font glyph ID.
93    pub id: u16,
94    /// UTF-8 byte cluster in the source run.
95    pub cluster: u32,
96    /// Horizontal advance.
97    pub advance_x: Unit,
98    /// Vertical advance.
99    pub advance_y: Unit,
100    /// Horizontal offset.
101    pub offset_x: Unit,
102    /// Vertical offset.
103    pub offset_y: Unit,
104}
105
106/// Contiguous font/direction shaping result.
107#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
108pub struct GlyphRun {
109    /// Explicit font name.
110    pub font: String,
111    /// Whether the run has right-to-left direction.
112    pub rtl: bool,
113    /// Original logical UTF-8 slice.
114    pub text: String,
115    /// Positioned glyphs.
116    pub glyphs: Vec<Glyph>,
117    /// Total horizontal advance.
118    pub width: Unit,
119}
120
121/// One visual line.
122#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
123pub struct TextLine {
124    /// Visual-order glyph runs.
125    pub runs: Vec<GlyphRun>,
126    /// Inline advance: width for horizontal lines, height for vertical columns.
127    pub width: Unit,
128    /// Block advance: height for horizontal lines, width for vertical columns.
129    pub height: Unit,
130}
131
132/// Non-fatal text diagnostic.
133#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
134#[serde(rename_all = "snake_case")]
135pub enum TextDiagnostic {
136    /// Content is clipped by the requested box.
137    Clipped,
138    /// Content was truncated and ellipsized.
139    Ellipsized,
140    /// Font size was reduced.
141    Shrunk,
142    /// An imported or manually constructed scene reports unavailable vertical writing.
143    VerticalWritingUnavailable,
144    /// Color emoji requires an exporter-specific capability.
145    ColorEmojiRequiresExporter,
146}
147
148/// Complete deterministic shaped layout.
149#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
150pub struct TextLayout {
151    /// Horizontal lines or top-to-bottom right-to-left columns.
152    #[serde(default)]
153    pub writing_mode: WritingMode,
154    /// Lines or columns in block-flow order.
155    pub lines: Vec<TextLine>,
156    /// Natural or constrained measurement.
157    pub measured: Size,
158    /// Effective font size after shrinking.
159    pub font_size: Unit,
160    /// Non-fatal diagnostics.
161    pub diagnostics: Vec<TextDiagnostic>,
162}
163
164/// Unicode text engine backed only by explicit fonts.
165pub struct TextEngine<'a> {
166    fonts: &'a FontManager,
167}
168
169impl<'a> TextEngine<'a> {
170    /// Creates an engine over an explicit deterministic registry.
171    #[must_use]
172    pub const fn new(fonts: &'a FontManager) -> Self {
173        Self { fonts }
174    }
175
176    /// Performs line breaking, `BiDi` run construction, font fallback, shaping, and measurement.
177    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}