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 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/// Text overflow strategy.
23#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
24#[serde(rename_all = "snake_case")]
25pub enum TextOverflow {
26    /// Wrap at Unicode word boundaries.
27    #[default]
28    Wrap,
29    /// Reduce font size down to the explicit minimum.
30    Shrink,
31    /// Replace the final fitting graphemes with an ellipsis.
32    Ellipsis,
33    /// Retain glyphs and expose a clipping diagnostic.
34    Clip,
35    /// Expand the measured box to fit.
36    Expand,
37    /// Reject overflow.
38    Error,
39}
40
41/// Writing direction selected before shaping.
42#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
43#[serde(rename_all = "snake_case")]
44pub enum WritingMode {
45    /// Horizontal lines with `BiDi` runs.
46    #[default]
47    Horizontal,
48    /// Top-to-bottom columns flowing from right to left.
49    Vertical,
50}
51
52/// Explicit text measurement options.
53#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
54pub struct TextOptions {
55    /// Primary registered font.
56    pub font: String,
57    /// Requested font size.
58    pub font_size: Unit,
59    /// Minimum size used only by `Shrink`.
60    pub min_font_size: Unit,
61    /// Available layout box.
62    pub bounds: Size,
63    /// Optional maximum line count.
64    pub max_lines: Option<usize>,
65    /// Overflow behavior.
66    pub overflow: TextOverflow,
67    /// Line-height multiplier in millionths.
68    pub line_height: u32,
69    /// Horizontal or vertical writing.
70    pub writing_mode: WritingMode,
71    /// Inline alignment applied after shaping and wrapping.
72    pub align_x: Alignment,
73    /// Symmetric padding on the writing mode's inline axis.
74    #[serde(default)]
75    pub padding_inline: Unit,
76}
77
78impl TextOptions {
79    /// Validates numeric text bounds.
80    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/// One positioned glyph in fixed-point output coordinates.
103#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
104pub struct Glyph {
105    /// Font glyph ID.
106    pub id: u16,
107    /// UTF-8 byte cluster in the source run.
108    pub cluster: u32,
109    /// Horizontal advance.
110    pub advance_x: Unit,
111    /// Vertical advance.
112    pub advance_y: Unit,
113    /// Horizontal offset.
114    pub offset_x: Unit,
115    /// Vertical offset.
116    pub offset_y: Unit,
117}
118
119/// Contiguous font/direction shaping result.
120#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
121pub struct GlyphRun {
122    /// Explicit font name.
123    pub font: String,
124    /// Whether the run has right-to-left direction.
125    pub rtl: bool,
126    /// Original logical UTF-8 slice.
127    pub text: String,
128    /// Positioned glyphs.
129    pub glyphs: Vec<Glyph>,
130    /// Total horizontal advance.
131    pub width: Unit,
132}
133
134/// One visual line.
135#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
136pub struct TextLine {
137    /// Logical line text before bidi shaping, retained for deterministic pagination.
138    #[serde(default)]
139    pub source_text: String,
140    /// Visual-order glyph runs.
141    pub runs: Vec<GlyphRun>,
142    /// Inline advance: width for horizontal lines, height for vertical columns.
143    pub width: Unit,
144    /// Block advance: height for horizontal lines, width for vertical columns.
145    pub height: Unit,
146}
147
148/// Non-fatal text diagnostic.
149#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
150#[serde(rename_all = "snake_case")]
151pub enum TextDiagnostic {
152    /// Content is clipped by the requested box.
153    Clipped,
154    /// Content was truncated and ellipsized.
155    Ellipsized,
156    /// Font size was reduced.
157    Shrunk,
158    /// An imported or manually constructed scene reports unavailable vertical writing.
159    VerticalWritingUnavailable,
160    /// Color emoji requires an exporter-specific capability.
161    ColorEmojiRequiresExporter,
162}
163
164/// Complete deterministic shaped layout.
165#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
166pub struct TextLayout {
167    /// Horizontal lines or top-to-bottom right-to-left columns.
168    #[serde(default)]
169    pub writing_mode: WritingMode,
170    /// Lines or columns in block-flow order.
171    pub lines: Vec<TextLine>,
172    /// Natural or constrained measurement.
173    pub measured: Size,
174    /// Effective font size after shrinking.
175    pub font_size: Unit,
176    /// Paint-only vertical translation retained by every visual exporter.
177    /// Positive values move text toward the page bottom; measurement and
178    /// pagination continue to use the unshifted geometry.
179    #[serde(default)]
180    pub paint_offset_y: Unit,
181    /// Non-fatal diagnostics.
182    pub diagnostics: Vec<TextDiagnostic>,
183    /// Inline alignment applied to every resolved line.
184    #[serde(default = "default_alignment")]
185    pub align_x: Alignment,
186    /// Symmetric padding on the writing mode's inline axis.
187    #[serde(default)]
188    pub padding_inline: Unit,
189    /// Resolved per-side padding around the text block.
190    #[serde(default)]
191    pub padding: Insets,
192}
193
194const fn default_alignment() -> Alignment {
195    Alignment::Start
196}
197
198impl TextLayout {
199    /// Returns the measured inline offset for one line inside a box.
200    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
215/// Unicode text engine backed only by explicit fonts.
216pub struct TextEngine<'a> {
217    fonts: &'a FontManager,
218}
219
220impl<'a> TextEngine<'a> {
221    /// Creates an engine over an explicit deterministic registry.
222    #[must_use]
223    pub const fn new(fonts: &'a FontManager) -> Self {
224        Self { fonts }
225    }
226
227    /// Performs line breaking, `BiDi` run construction, font fallback, shaping, and measurement.
228    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}