Skip to main content

merman_render/
environment.rs

1//! Operation-owned render services and deterministic policy.
2
3use crate::math::MathRenderer;
4use crate::resources::{OperationWorkMeter, RenderResourcePolicy};
5use crate::svg::IconRegistry;
6use crate::text::{
7    DeterministicTextMeasurer, TextMeasurer, TextMetrics, TextStyle, WrapMode,
8    append_text_width_em, estimate_text_width_em, is_html_collapsible_ascii_whitespace,
9};
10use crate::{RenderCapability, RenderCapabilityPolicy};
11use merman_core::runtime::{OperationContext, OperationTiming, RuntimePolicy, RuntimePolicyError};
12use merman_core::time::LocalTimeZoneProvenance;
13use merman_core::{OperationControl, OperationPhase};
14use std::fmt;
15use std::num::NonZeroU64;
16use std::sync::Arc;
17use std::sync::atomic::{AtomicU64, Ordering};
18use unicode_segmentation::UnicodeSegmentation;
19
20/// A render phase that may select a distinct complete text-measurement profile.
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
22pub enum TextMeasurementPhase {
23    Layout,
24    Wrap,
25    SvgBBox,
26    ComputedLength,
27}
28
29impl TextMeasurementPhase {
30    pub const ALL: [Self; 4] = [
31        Self::Layout,
32        Self::Wrap,
33        Self::SvgBBox,
34        Self::ComputedLength,
35    ];
36
37    const fn index(self) -> usize {
38        match self {
39            Self::Layout => 0,
40            Self::Wrap => 1,
41            Self::SvgBBox => 2,
42            Self::ComputedLength => 3,
43        }
44    }
45}
46
47/// Stable name for one complete [`TextMeasurer`] profile.
48#[derive(Debug, Clone, PartialEq, Eq, Hash)]
49pub struct MeasurementProfileId(Arc<str>);
50
51impl MeasurementProfileId {
52    pub fn new(value: impl Into<String>) -> Result<Self, InvalidMeasurementProfileIdentity> {
53        let value = value.into();
54        let value = value.trim();
55        if value.is_empty() {
56            return Err(InvalidMeasurementProfileIdentity::EmptyProfile);
57        }
58        Ok(Self(Arc::from(value)))
59    }
60
61    pub fn as_str(&self) -> &str {
62        &self.0
63    }
64}
65
66impl fmt::Display for MeasurementProfileId {
67    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
68        f.write_str(self.as_str())
69    }
70}
71
72/// Observable identity for a measurer and its ordered decorator chain.
73#[derive(Debug, Clone, PartialEq, Eq, Hash)]
74pub struct TextMeasurementProfileIdentity {
75    profile: MeasurementProfileId,
76    version: Arc<str>,
77    decorators: Arc<[Arc<str>]>,
78}
79
80impl TextMeasurementProfileIdentity {
81    pub fn new(
82        profile: MeasurementProfileId,
83        version: impl Into<String>,
84    ) -> Result<Self, InvalidMeasurementProfileIdentity> {
85        let version = version.into();
86        let version = version.trim();
87        if version.is_empty() {
88            return Err(InvalidMeasurementProfileIdentity::EmptyVersion);
89        }
90        Ok(Self {
91            profile,
92            version: Arc::from(version),
93            decorators: Arc::from([]),
94        })
95    }
96
97    pub fn with_decorators<I, S>(
98        mut self,
99        decorators: I,
100    ) -> Result<Self, InvalidMeasurementProfileIdentity>
101    where
102        I: IntoIterator<Item = S>,
103        S: Into<String>,
104    {
105        let mut validated = Vec::new();
106        for decorator in decorators {
107            let decorator = decorator.into();
108            let decorator = decorator.trim();
109            if decorator.is_empty() {
110                return Err(InvalidMeasurementProfileIdentity::EmptyDecorator);
111            }
112            validated.push(Arc::from(decorator));
113        }
114        self.decorators = validated.into();
115        Ok(self)
116    }
117
118    pub fn profile(&self) -> &MeasurementProfileId {
119        &self.profile
120    }
121
122    pub fn version(&self) -> &str {
123        &self.version
124    }
125
126    pub fn decorators(&self) -> &[Arc<str>] {
127        &self.decorators
128    }
129}
130
131#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
132pub enum InvalidMeasurementProfileIdentity {
133    #[error("text measurement profile name cannot be empty")]
134    EmptyProfile,
135    #[error("text measurement profile version cannot be empty")]
136    EmptyVersion,
137    #[error("text measurement decorator identity cannot be empty")]
138    EmptyDecorator,
139}
140
141/// A named, complete measurer profile. Specialized trait methods remain part of the profile.
142#[derive(Clone)]
143pub struct TextMeasurementProfile {
144    identity: TextMeasurementProfileIdentity,
145    backend: Arc<dyn TextMeasurer + Send + Sync>,
146    builtin: Option<BuiltinTextMeasurementProfile>,
147}
148
149impl TextMeasurementProfile {
150    pub fn new(
151        identity: TextMeasurementProfileIdentity,
152        backend: Arc<dyn TextMeasurer + Send + Sync>,
153    ) -> Self {
154        Self {
155            identity,
156            backend,
157            builtin: None,
158        }
159    }
160
161    pub fn identity(&self) -> &TextMeasurementProfileIdentity {
162        &self.identity
163    }
164
165    fn new_builtin(
166        identity: TextMeasurementProfileIdentity,
167        backend: Arc<dyn TextMeasurer + Send + Sync>,
168        builtin: BuiltinTextMeasurementProfile,
169    ) -> Self {
170        Self {
171            identity,
172            backend,
173            builtin: Some(builtin),
174        }
175    }
176}
177
178impl fmt::Debug for TextMeasurementProfile {
179    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
180        f.debug_struct("TextMeasurementProfile")
181            .field("identity", &self.identity)
182            .finish_non_exhaustive()
183    }
184}
185
186#[derive(Debug, Clone, Copy, PartialEq, Eq)]
187enum BuiltinTextMeasurementProfile {
188    Deterministic,
189}
190
191/// Crate-private proof that one operation resolves to a concrete built-in profile route.
192///
193/// The public `TextMeasurer` extension surface cannot construct or name this value. Sequence may
194/// carry it between two stages of the same render operation, but a custom or host-backed measurer
195/// cannot replay built-in authority to validate cached measurements.
196#[derive(Debug, Clone, Copy, PartialEq, Eq)]
197pub(crate) struct BuiltinTextMeasurementOperationCarrier {
198    profile: BuiltinTextMeasurementProfile,
199    phase: TextMeasurementPhase,
200    operation: TextMeasurementOperation,
201}
202
203impl BuiltinTextMeasurementOperationCarrier {
204    pub(crate) const fn into_inline_html(self) -> Option<InlineHtmlMeasurementCarrier> {
205        match (self.phase, self.operation) {
206            (TextMeasurementPhase::Wrap, TextMeasurementOperation::WrappedWithRawWidth) => {
207                Some(InlineHtmlMeasurementCarrier::builtin(self.profile))
208            }
209            _ => None,
210        }
211    }
212
213    pub(crate) fn into_svg_computed_length(
214        self,
215        style: &TextStyle,
216    ) -> Option<BuiltinSvgComputedLength> {
217        match (self.phase, self.operation) {
218            (TextMeasurementPhase::ComputedLength, TextMeasurementOperation::ComputedLength) => {
219                Some(BuiltinSvgComputedLength::new(style))
220            }
221            _ => None,
222        }
223    }
224}
225
226/// Private authority for one complete rich HTML measurement operation.
227///
228/// Only [`RoutedTextMeasurer`] can attach a built-in profile after resolving the operation's
229/// owning phase. Arbitrary `TextMeasurer` implementations receive [`Self::opaque`], so custom and
230/// host routes cannot copy or replay built-in authority through the public trait surface.
231#[derive(Debug, Clone, Copy, PartialEq, Eq)]
232pub(crate) struct InlineHtmlMeasurementCarrier {
233    builtin: Option<BuiltinTextMeasurementProfile>,
234}
235
236impl InlineHtmlMeasurementCarrier {
237    pub(crate) const fn opaque() -> Self {
238        Self { builtin: None }
239    }
240
241    const fn builtin(profile: BuiltinTextMeasurementProfile) -> Self {
242        Self {
243            builtin: Some(profile),
244        }
245    }
246
247    pub(crate) const fn is_builtin(self) -> bool {
248        self.builtin.is_some()
249    }
250
251    pub(crate) fn begin_inline_html_width(
252        self,
253        style: &TextStyle,
254    ) -> Option<BuiltinInlineHtmlWidth> {
255        self.builtin.map(|_| BuiltinInlineHtmlWidth::new(style))
256    }
257}
258
259#[derive(Debug, Clone)]
260struct BuiltinInlineRawLineWidth {
261    font_size: f64,
262    committed_em: f64,
263    pending_grapheme: String,
264}
265
266/// Streaming `getComputedTextLength()` state for a qualified built-in SVG text route.
267///
268/// Flowchart's createText wrapper probes every growing word prefix. Retaining completed grapheme
269/// width plus only the extendable final grapheme avoids rescanning the complete prefix while
270/// preserving sequence-aware Unicode width across arbitrary chunk boundaries. Host-backed and
271/// opaque custom measurers cannot construct this state, so their observable callback sequence
272/// remains unchanged.
273#[derive(Debug, Clone)]
274#[cfg_attr(
275    not(feature = "all-diagrams"),
276    allow(
277        dead_code,
278        reason = "Shared operation support has different consumers in each diagram selection."
279    )
280)]
281pub(crate) struct BuiltinSvgComputedLength {
282    line: BuiltinInlineRawLineWidth,
283}
284
285#[cfg_attr(
286    not(feature = "all-diagrams"),
287    allow(
288        dead_code,
289        reason = "Shared operation support has different consumers in each diagram selection."
290    )
291)]
292impl BuiltinSvgComputedLength {
293    fn new(style: &TextStyle) -> Self {
294        Self {
295            line: BuiltinInlineRawLineWidth::new(style),
296        }
297    }
298
299    pub(crate) fn deterministic(style: &TextStyle) -> Self {
300        Self::new(style)
301    }
302
303    pub(crate) fn push_text(&mut self, text: &str) {
304        self.line.push_text(text);
305    }
306
307    pub(crate) fn width_px(&self) -> f64 {
308        let width = self.line.width_px();
309        if width.is_finite() && width >= 0.0 {
310            width
311        } else {
312            0.0
313        }
314    }
315
316    pub(crate) fn reset(&mut self) {
317        self.line.reset();
318    }
319}
320
321impl BuiltinInlineRawLineWidth {
322    fn new(style: &TextStyle) -> Self {
323        Self {
324            font_size: style.font_size.max(1.0),
325            committed_em: 0.0,
326            pending_grapheme: String::new(),
327        }
328    }
329
330    fn push_text(&mut self, text: &str) {
331        if text.is_empty() {
332            return;
333        }
334        self.pending_grapheme.push_str(text);
335        let last_grapheme_start = self
336            .pending_grapheme
337            .grapheme_indices(true)
338            .next_back()
339            .map_or(0, |(index, _)| index);
340        if last_grapheme_start > 0 {
341            append_text_width_em(
342                &mut self.committed_em,
343                &self.pending_grapheme[..last_grapheme_start],
344            );
345            self.pending_grapheme.drain(..last_grapheme_start);
346        }
347    }
348
349    fn push_char(&mut self, ch: char) {
350        let mut encoded = [0_u8; 4];
351        self.push_text(ch.encode_utf8(&mut encoded));
352    }
353
354    fn width_px(&self) -> f64 {
355        (self.committed_em + estimate_text_width_em(&self.pending_grapheme)) * self.font_size
356    }
357
358    fn reset(&mut self) {
359        self.committed_em = 0.0;
360        self.pending_grapheme.clear();
361    }
362}
363
364#[derive(Debug, Clone)]
365struct BuiltinNormalizedTextWidth {
366    line: BuiltinInlineRawLineWidth,
367    max_width_px: f64,
368    pending_blank_width_px: f64,
369    line_index: usize,
370    line_has_non_whitespace: bool,
371}
372
373impl BuiltinNormalizedTextWidth {
374    fn new(line: BuiltinInlineRawLineWidth) -> Self {
375        Self {
376            line,
377            max_width_px: 0.0,
378            pending_blank_width_px: 0.0,
379            line_index: 0,
380            line_has_non_whitespace: false,
381        }
382    }
383
384    fn push_char(&mut self, ch: char) {
385        if ch == '\n' {
386            self.finish_line();
387            return;
388        }
389
390        if !is_html_collapsible_ascii_whitespace(ch) && !self.line_has_non_whitespace {
391            // Completed whitespace-only lines cease to be trailing as soon as a later visible
392            // scalar arrives. This mirrors `normalized_text_lines` trimming only the final blank
393            // suffix while retaining interior whitespace-only lines.
394            self.max_width_px = self.max_width_px.max(self.pending_blank_width_px);
395            self.pending_blank_width_px = 0.0;
396            self.line_has_non_whitespace = true;
397        }
398        self.line.push_char(ch);
399    }
400
401    fn push_text(&mut self, text: &str) {
402        let mut start = 0usize;
403        for (index, ch) in text.char_indices() {
404            if ch != '\n' {
405                continue;
406            }
407            self.push_line_segment(&text[start..index]);
408            self.finish_line();
409            start = index + ch.len_utf8();
410        }
411        self.push_line_segment(&text[start..]);
412    }
413
414    fn push_line_segment(&mut self, text: &str) {
415        if text.is_empty() {
416            return;
417        }
418        if !self.line_has_non_whitespace
419            && text
420                .chars()
421                .any(|ch| !is_html_collapsible_ascii_whitespace(ch))
422        {
423            self.max_width_px = self.max_width_px.max(self.pending_blank_width_px);
424            self.pending_blank_width_px = 0.0;
425            self.line_has_non_whitespace = true;
426        }
427        self.line.push_text(text);
428    }
429
430    fn finish_line(&mut self) {
431        let width = self.line.width_px();
432        if self.line_index == 0 || self.line_has_non_whitespace {
433            self.max_width_px = self.max_width_px.max(width);
434        } else {
435            self.pending_blank_width_px = self.pending_blank_width_px.max(width);
436        }
437        self.line_index = self.line_index.saturating_add(1);
438        self.line_has_non_whitespace = false;
439        self.line.reset();
440    }
441
442    fn finished_width_px(&self) -> f64 {
443        if self.line_index == 0 || self.line_has_non_whitespace {
444            self.max_width_px.max(self.line.width_px())
445        } else {
446            // `DeterministicTextMeasurer::normalized_text_lines` removes the trailing blank
447            // suffix, but always retains the first logical line (already committed above).
448            self.max_width_px
449        }
450    }
451}
452
453#[derive(Debug, Clone, Copy, PartialEq, Eq)]
454enum InlineHtmlBreakState {
455    AfterLt,
456    AfterB,
457    AfterBr,
458    AfterSlash,
459}
460
461#[derive(Debug, Clone)]
462struct PendingInlineHtmlBreak {
463    state: InlineHtmlBreakState,
464    literal: BuiltinNormalizedTextWidth,
465}
466
467/// Exact streaming width state for a qualified built-in HTML measurement route.
468///
469/// Mermaid's pinned `createText.ts:addHtmlSpan` decodes HTML into a real span, so a valid `<br>`
470/// contributes a DOM line break before `getBoundingClientRect()`. This state follows the selected
471/// built-in backend's grapheme accumulation order and its existing `normalized_text_lines`
472/// behavior, without retaining or rescanning the potentially unbounded whitespace in an
473/// incomplete tag. It intentionally recognizes only ASCII space, tab, CR, and LF inside `<br>`;
474/// the wider ECMAScript `\s` set and browser text shaping remain bounded browser residuals.
475#[derive(Debug, Clone)]
476pub(crate) struct BuiltinInlineHtmlWidth {
477    normalized: BuiltinNormalizedTextWidth,
478    pending_break: Option<PendingInlineHtmlBreak>,
479}
480
481impl BuiltinInlineHtmlWidth {
482    fn new(style: &TextStyle) -> Self {
483        Self {
484            normalized: BuiltinNormalizedTextWidth::new(BuiltinInlineRawLineWidth::new(style)),
485            pending_break: None,
486        }
487    }
488
489    pub(crate) fn push_text(&mut self, text: &str) {
490        if self.pending_break.is_none() && !text.contains('<') {
491            self.normalized.push_text(text);
492            return;
493        }
494        for ch in text.chars() {
495            self.push_char(ch);
496        }
497    }
498
499    fn push_char(&mut self, ch: char) {
500        let mut current = Some(ch);
501        while let Some(ch) = current.take() {
502            let Some(mut pending) = self.pending_break.take() else {
503                if ch == '<' {
504                    let mut literal = self.normalized.clone();
505                    literal.push_char(ch);
506                    self.pending_break = Some(PendingInlineHtmlBreak {
507                        state: InlineHtmlBreakState::AfterLt,
508                        literal,
509                    });
510                } else {
511                    self.normalized.push_char(ch);
512                }
513                continue;
514            };
515
516            match pending.state {
517                InlineHtmlBreakState::AfterLt if matches!(ch, 'b' | 'B') => {
518                    pending.literal.push_char(ch);
519                    pending.state = InlineHtmlBreakState::AfterB;
520                    self.pending_break = Some(pending);
521                }
522                InlineHtmlBreakState::AfterB if matches!(ch, 'r' | 'R') => {
523                    pending.literal.push_char(ch);
524                    pending.state = InlineHtmlBreakState::AfterBr;
525                    self.pending_break = Some(pending);
526                }
527                InlineHtmlBreakState::AfterBr if matches!(ch, ' ' | '\t' | '\r' | '\n') => {
528                    pending.literal.push_char(ch);
529                    self.pending_break = Some(pending);
530                }
531                InlineHtmlBreakState::AfterBr if ch == '/' => {
532                    pending.literal.push_char(ch);
533                    pending.state = InlineHtmlBreakState::AfterSlash;
534                    self.pending_break = Some(pending);
535                }
536                InlineHtmlBreakState::AfterBr | InlineHtmlBreakState::AfterSlash if ch == '>' => {
537                    self.normalized.push_char('\n');
538                }
539                _ => {
540                    self.normalized = pending.literal;
541                    current = Some(ch);
542                }
543            }
544        }
545    }
546
547    pub(crate) fn width_px(&self) -> f64 {
548        self.pending_break.as_ref().map_or_else(
549            || self.normalized.finished_width_px(),
550            |pending| pending.literal.finished_width_px(),
551        )
552    }
553}
554
555fn deterministic_profile() -> TextMeasurementProfile {
556    let profile = MeasurementProfileId::new("merman.deterministic-text")
557        .expect("static deterministic profile id is valid");
558    let identity = TextMeasurementProfileIdentity::new(
559        profile,
560        concat!("merman-render@", env!("CARGO_PKG_VERSION")),
561    )
562    .expect("static deterministic profile version is valid");
563    TextMeasurementProfile::new_builtin(
564        identity,
565        Arc::new(DeterministicTextMeasurer::default()),
566        BuiltinTextMeasurementProfile::Deterministic,
567    )
568}
569
570/// Why a configured host attempt used its named fallback profile.
571#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
572pub enum HostFallbackReason {
573    Missing,
574    Invalid,
575    Error,
576}
577
578/// The exact [`TextMeasurer`] operation performed through a phase facade and its required host
579/// result shape. Both types are generated from the independently versioned host
580/// text-measurement protocol shared by every binding.
581pub use crate::generated::text_measurement_abi::{
582    TEXT_MEASUREMENT_PROTOCOL_VERSION, TextMeasurementOperation, TextMeasurementResultKind,
583};
584
585/// The concrete backend kind that produced one result.
586#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
587pub enum TextMeasurementSource {
588    Profile,
589    Host,
590}
591
592/// Actual provenance recorded after one measurement completes.
593#[derive(Debug, Clone, PartialEq, Eq, Hash)]
594pub struct TextMeasurementProvenance {
595    pub phase: TextMeasurementPhase,
596    pub operation: TextMeasurementOperation,
597    pub source: TextMeasurementSource,
598    pub identity: TextMeasurementProfileIdentity,
599    pub fallback_reason: Option<HostFallbackReason>,
600}
601
602/// One distinct provenance key and its total call count.
603#[derive(Debug, Clone, PartialEq, Eq)]
604pub struct TextMeasurementSummary {
605    provenance: TextMeasurementProvenance,
606    count: u64,
607}
608
609impl TextMeasurementSummary {
610    pub fn provenance(&self) -> &TextMeasurementProvenance {
611        &self.provenance
612    }
613
614    pub const fn count(&self) -> u64 {
615        self.count
616    }
617}
618
619/// Bounded snapshot of measurement provenance aggregated by distinct route outcome.
620#[derive(Debug, Clone, Default, PartialEq, Eq)]
621pub struct TextMeasurementReport {
622    entries: Vec<TextMeasurementSummary>,
623}
624
625impl TextMeasurementReport {
626    pub fn entries(&self) -> &[TextMeasurementSummary] {
627        &self.entries
628    }
629}
630
631#[derive(Debug, Clone, Copy, PartialEq, Eq)]
632enum TextMeasurementRouteOutcome {
633    Profile,
634    Host,
635    Fallback(HostFallbackReason),
636}
637
638impl TextMeasurementRouteOutcome {
639    const ALL: [Self; 5] = [
640        Self::Profile,
641        Self::Host,
642        Self::Fallback(HostFallbackReason::Missing),
643        Self::Fallback(HostFallbackReason::Invalid),
644        Self::Fallback(HostFallbackReason::Error),
645    ];
646
647    const fn index(self) -> usize {
648        match self {
649            Self::Profile => 0,
650            Self::Host => 1,
651            Self::Fallback(HostFallbackReason::Missing) => 2,
652            Self::Fallback(HostFallbackReason::Invalid) => 3,
653            Self::Fallback(HostFallbackReason::Error) => 4,
654        }
655    }
656}
657
658#[derive(Debug)]
659struct TextMeasurementRecorder {
660    counts: [AtomicU64;
661        TextMeasurementPhase::ALL.len()
662            * TextMeasurementOperation::ALL.len()
663            * TextMeasurementRouteOutcome::ALL.len()],
664}
665
666impl Default for TextMeasurementRecorder {
667    fn default() -> Self {
668        Self {
669            counts: std::array::from_fn(|_| AtomicU64::new(0)),
670        }
671    }
672}
673
674impl TextMeasurementRecorder {
675    const fn slot(
676        phase: TextMeasurementPhase,
677        operation: TextMeasurementOperation,
678        outcome: TextMeasurementRouteOutcome,
679    ) -> usize {
680        (phase.index() * TextMeasurementOperation::ALL.len() + operation.index())
681            * TextMeasurementRouteOutcome::ALL.len()
682            + outcome.index()
683    }
684
685    fn record(
686        &self,
687        phase: TextMeasurementPhase,
688        operation: TextMeasurementOperation,
689        outcome: TextMeasurementRouteOutcome,
690    ) {
691        let counter = &self.counts[Self::slot(phase, operation, outcome)];
692        let _ = counter.fetch_update(Ordering::Relaxed, Ordering::Relaxed, |count| {
693            Some(count.saturating_add(1))
694        });
695    }
696
697    fn report(&self, policy: &TextMeasurementPolicy) -> TextMeasurementReport {
698        let mut entries = Vec::new();
699        for phase in TextMeasurementPhase::ALL {
700            for operation in TextMeasurementOperation::ALL {
701                for outcome in TextMeasurementRouteOutcome::ALL {
702                    let count =
703                        self.counts[Self::slot(phase, operation, outcome)].load(Ordering::Relaxed);
704                    if count == 0 {
705                        continue;
706                    }
707
708                    let provenance = match (&policy.routes[phase.index()], outcome) {
709                        (
710                            TextMeasurementRouteConfig::Profile(profile),
711                            TextMeasurementRouteOutcome::Profile,
712                        ) => TextMeasurementProvenance {
713                            phase,
714                            operation,
715                            source: TextMeasurementSource::Profile,
716                            identity: profile.identity.clone(),
717                            fallback_reason: None,
718                        },
719                        (
720                            TextMeasurementRouteConfig::Host { identity, .. },
721                            TextMeasurementRouteOutcome::Host,
722                        ) => TextMeasurementProvenance {
723                            phase,
724                            operation,
725                            source: TextMeasurementSource::Host,
726                            identity: identity.clone(),
727                            fallback_reason: None,
728                        },
729                        (
730                            TextMeasurementRouteConfig::Host { fallback, .. },
731                            TextMeasurementRouteOutcome::Fallback(reason),
732                        ) => TextMeasurementProvenance {
733                            phase,
734                            operation,
735                            source: TextMeasurementSource::Profile,
736                            identity: fallback.identity.clone(),
737                            fallback_reason: Some(reason),
738                        },
739                        _ => unreachable!(
740                            "recorded text measurement outcome does not match the configured route"
741                        ),
742                    };
743                    entries.push(TextMeasurementSummary { provenance, count });
744                }
745            }
746        }
747        TextMeasurementReport { entries }
748    }
749}
750
751/// A host callback failure or invalid result converted to explicit fallback provenance.
752#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
753#[error("{message}")]
754pub struct HostTextMeasurementError {
755    message: Arc<str>,
756    fallback_reason: HostFallbackReason,
757}
758
759impl HostTextMeasurementError {
760    /// Creates an error reported by the host callback or its transport.
761    pub fn new(message: impl Into<String>) -> Self {
762        Self {
763            message: Arc::from(message.into()),
764            fallback_reason: HostFallbackReason::Error,
765        }
766    }
767
768    /// Creates an error for a callback value that violates the measurement contract.
769    #[doc(hidden)]
770    pub fn invalid_value(message: impl Into<String>) -> Self {
771        Self {
772            message: Arc::from(message.into()),
773            fallback_reason: HostFallbackReason::Invalid,
774        }
775    }
776
777    pub fn message(&self) -> &str {
778        &self.message
779    }
780
781    fn fallback_reason(&self) -> HostFallbackReason {
782        self.fallback_reason
783    }
784}
785
786#[derive(Debug, Clone, Copy)]
787pub struct HostTextMeasurementRequest<'a> {
788    pub operation: TextMeasurementOperation,
789    pub phase: TextMeasurementPhase,
790    pub text: &'a str,
791    pub style: &'a TextStyle,
792    pub max_width: Option<f64>,
793    pub wrap_mode: WrapMode,
794}
795
796#[derive(Debug, Clone, Copy)]
797pub enum HostTextMeasurement {
798    Metrics(TextMetrics),
799    Length(f64),
800    HorizontalExtents {
801        left: f64,
802        right: f64,
803    },
804    WrappedWithRawWidth {
805        metrics: TextMetrics,
806        raw_width: Option<f64>,
807    },
808}
809
810pub type HostMeasurementResult = Result<Option<HostTextMeasurement>, HostTextMeasurementError>;
811
812/// Fallible, operation-aware host counterpart of [`TextMeasurer`].
813///
814/// Returning `Ok(None)` declines exactly the requested operation. Returning a result variant that
815/// does not match `request.operation`, or an invalid value, uses the configured fallback and is
816/// recorded as [`HostFallbackReason::Invalid`].
817pub trait HostTextMeasurer: Send + Sync {
818    fn measure(&self, request: HostTextMeasurementRequest<'_>) -> HostMeasurementResult;
819}
820
821#[derive(Clone)]
822enum TextMeasurementRouteConfig {
823    Profile(TextMeasurementProfile),
824    Host {
825        identity: TextMeasurementProfileIdentity,
826        backend: Arc<dyn HostTextMeasurer>,
827        fallback: TextMeasurementProfile,
828    },
829}
830
831/// Observable configured route for one phase.
832#[derive(Debug, Clone, PartialEq, Eq)]
833pub struct TextMeasurementRoute {
834    pub phase: TextMeasurementPhase,
835    pub primary_source: TextMeasurementSource,
836    pub primary: TextMeasurementProfileIdentity,
837    pub fallback: Option<TextMeasurementProfileIdentity>,
838}
839
840/// Immutable routing policy for all text-measurement phases in one environment.
841#[derive(Clone)]
842pub struct TextMeasurementPolicy {
843    routes: [TextMeasurementRouteConfig; 4],
844}
845
846impl fmt::Debug for TextMeasurementPolicy {
847    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
848        let routes = TextMeasurementPhase::ALL.map(|phase| self.route(phase));
849        f.debug_struct("TextMeasurementPolicy")
850            .field("routes", &routes)
851            .finish()
852    }
853}
854
855impl TextMeasurementPolicy {
856    pub fn deterministic() -> Self {
857        Self::uniform(deterministic_profile())
858    }
859
860    pub fn uniform(profile: TextMeasurementProfile) -> Self {
861        Self {
862            routes: std::array::from_fn(|_| TextMeasurementRouteConfig::Profile(profile.clone())),
863        }
864    }
865
866    pub fn with_profile_for_phase(
867        mut self,
868        phase: TextMeasurementPhase,
869        profile: TextMeasurementProfile,
870    ) -> Self {
871        self.routes[phase.index()] = TextMeasurementRouteConfig::Profile(profile);
872        self
873    }
874
875    pub fn host_display(
876        identity: TextMeasurementProfileIdentity,
877        host: Arc<dyn HostTextMeasurer>,
878        host_phases: impl IntoIterator<Item = TextMeasurementPhase>,
879    ) -> Self {
880        Self::host_display_with_fallback(identity, host, host_phases, deterministic_profile())
881    }
882
883    pub fn host_display_with_fallback(
884        identity: TextMeasurementProfileIdentity,
885        host: Arc<dyn HostTextMeasurer>,
886        host_phases: impl IntoIterator<Item = TextMeasurementPhase>,
887        fallback: TextMeasurementProfile,
888    ) -> Self {
889        let mut policy = Self::uniform(fallback.clone());
890        for phase in host_phases {
891            policy.routes[phase.index()] = TextMeasurementRouteConfig::Host {
892                identity: identity.clone(),
893                backend: Arc::clone(&host),
894                fallback: fallback.clone(),
895            };
896        }
897        policy
898    }
899
900    pub fn route(&self, phase: TextMeasurementPhase) -> TextMeasurementRoute {
901        match &self.routes[phase.index()] {
902            TextMeasurementRouteConfig::Profile(profile) => TextMeasurementRoute {
903                phase,
904                primary_source: TextMeasurementSource::Profile,
905                primary: profile.identity.clone(),
906                fallback: None,
907            },
908            TextMeasurementRouteConfig::Host {
909                identity, fallback, ..
910            } => TextMeasurementRoute {
911                phase,
912                primary_source: TextMeasurementSource::Host,
913                primary: identity.clone(),
914                fallback: Some(fallback.identity.clone()),
915            },
916        }
917    }
918
919    pub fn routes(&self) -> [TextMeasurementRoute; 4] {
920        TextMeasurementPhase::ALL.map(|phase| self.route(phase))
921    }
922}
923
924impl Default for TextMeasurementPolicy {
925    fn default() -> Self {
926        Self::deterministic()
927    }
928}
929
930/// Session-aware facade that routes specialized operations to their named phases.
931pub struct RoutedTextMeasurer<'a> {
932    default_phase: TextMeasurementPhase,
933    policy: &'a TextMeasurementPolicy,
934    recorder: &'a TextMeasurementRecorder,
935    work_meter: &'a OperationWorkMeter,
936    controlled_operation_phase: Option<OperationPhase>,
937}
938
939trait CancelledTextMeasurement: Sized {
940    fn cancelled() -> Self;
941}
942
943impl CancelledTextMeasurement for f64 {
944    fn cancelled() -> Self {
945        0.0
946    }
947}
948
949impl CancelledTextMeasurement for (f64, f64) {
950    fn cancelled() -> Self {
951        (0.0, 0.0)
952    }
953}
954
955impl CancelledTextMeasurement for TextMetrics {
956    fn cancelled() -> Self {
957        Self {
958            width: 0.0,
959            height: 0.0,
960            line_count: 1,
961        }
962    }
963}
964
965impl CancelledTextMeasurement for (TextMetrics, Option<f64>) {
966    fn cancelled() -> Self {
967        (TextMetrics::cancelled(), None)
968    }
969}
970
971impl RoutedTextMeasurer<'_> {
972    fn controlled_cancellation_value<T: CancelledTextMeasurement>(&self) -> Option<T> {
973        let phase = self.controlled_operation_phase?;
974        self.work_meter
975            .checkpoint(phase)
976            .is_err()
977            .then(T::cancelled)
978    }
979
980    fn phase_for(&self, operation: TextMeasurementOperation) -> TextMeasurementPhase {
981        match operation {
982            TextMeasurementOperation::ComputedLength => TextMeasurementPhase::ComputedLength,
983            TextMeasurementOperation::BBoxX
984            | TextMeasurementOperation::BBoxXWithAsciiOverhang
985            | TextMeasurementOperation::TitleBBoxX
986            | TextMeasurementOperation::SimpleBBoxWidth
987            | TextMeasurementOperation::RawBBoxWidth
988            | TextMeasurementOperation::RawBBoxHeight
989            | TextMeasurementOperation::BoundingClientRectWidth
990            | TextMeasurementOperation::TspanBBoxWidth
991            | TextMeasurementOperation::TspanBBoxHeight
992            | TextMeasurementOperation::CreateTextBBoxYOffset
993            | TextMeasurementOperation::CreateTextMiddleBBoxYOffset
994            | TextMeasurementOperation::MermaidCalculateTextDimensions
995            | TextMeasurementOperation::SimpleBBoxHeight => TextMeasurementPhase::SvgBBox,
996            TextMeasurementOperation::CanvasMeasureTextWidth => TextMeasurementPhase::Layout,
997            TextMeasurementOperation::WrapProbeBBoxWidth => TextMeasurementPhase::Wrap,
998            TextMeasurementOperation::Wrapped | TextMeasurementOperation::WrappedWithRawWidth => {
999                TextMeasurementPhase::Wrap
1000            }
1001            TextMeasurementOperation::Measure => self.default_phase,
1002        }
1003    }
1004
1005    pub(crate) fn builtin_operation_carrier(
1006        &self,
1007        operation: TextMeasurementOperation,
1008    ) -> Option<BuiltinTextMeasurementOperationCarrier> {
1009        let phase = self.phase_for(operation);
1010        match &self.policy.routes[phase.index()] {
1011            TextMeasurementRouteConfig::Profile(profile) => {
1012                profile
1013                    .builtin
1014                    .map(|profile| BuiltinTextMeasurementOperationCarrier {
1015                        profile,
1016                        phase,
1017                        operation,
1018                    })
1019            }
1020            // A host-routed operation remains observable even when its fallback is built-in. It
1021            // must stay opaque so callback order, failure position, and provenance cannot be
1022            // predicted away.
1023            TextMeasurementRouteConfig::Host { .. } => None,
1024        }
1025    }
1026
1027    fn resolve<T: CancelledTextMeasurement>(
1028        &self,
1029        request: HostTextMeasurementRequest<'_>,
1030        decode_host: impl FnOnce(HostTextMeasurement) -> Option<T>,
1031        profile_call: impl FnOnce(&(dyn TextMeasurer + Send + Sync)) -> T,
1032    ) -> T {
1033        let phase = request.phase;
1034        let operation = request.operation;
1035        if let Some(cancelled) = self.controlled_cancellation_value() {
1036            return cancelled;
1037        }
1038        match &self.policy.routes[phase.index()] {
1039            TextMeasurementRouteConfig::Profile(profile) => {
1040                let value = profile_call(profile.backend.as_ref());
1041                self.recorder
1042                    .record(phase, operation, TextMeasurementRouteOutcome::Profile);
1043                if let Some(cancelled) = self.controlled_cancellation_value() {
1044                    return cancelled;
1045                }
1046                value
1047            }
1048            TextMeasurementRouteConfig::Host {
1049                backend, fallback, ..
1050            } => {
1051                let attempt = backend.measure(request);
1052                if let Some(cancelled) = self.controlled_cancellation_value() {
1053                    return cancelled;
1054                }
1055                let decoded = match &attempt {
1056                    Ok(Some(value)) if validate_host_text_measurement(&request, value).is_ok() => {
1057                        decode_host(*value)
1058                    }
1059                    Ok(None) | Err(_) => None,
1060                    Ok(Some(_)) => None,
1061                };
1062                if let Some(value) = decoded {
1063                    self.recorder
1064                        .record(phase, operation, TextMeasurementRouteOutcome::Host);
1065                    return value;
1066                }
1067
1068                let reason = match attempt {
1069                    Ok(Some(_)) => HostFallbackReason::Invalid,
1070                    Ok(None) => HostFallbackReason::Missing,
1071                    Err(error) => error.fallback_reason(),
1072                };
1073                // `TextMeasurer` is intentionally infallible. A controlled route therefore
1074                // observes the canonical operation before entering another opaque backend and
1075                // returns a neutral value only to unwind toward the caller's fallible boundary.
1076                if let Some(cancelled) = self.controlled_cancellation_value() {
1077                    return cancelled;
1078                }
1079                let value = profile_call(fallback.backend.as_ref());
1080                self.recorder.record(
1081                    phase,
1082                    operation,
1083                    TextMeasurementRouteOutcome::Fallback(reason),
1084                );
1085                if let Some(cancelled) = self.controlled_cancellation_value() {
1086                    return cancelled;
1087                }
1088                value
1089            }
1090        }
1091    }
1092
1093    fn request<'a>(
1094        &self,
1095        operation: TextMeasurementOperation,
1096        text: &'a str,
1097        style: &'a TextStyle,
1098        max_width: Option<f64>,
1099        wrap_mode: WrapMode,
1100    ) -> HostTextMeasurementRequest<'a> {
1101        HostTextMeasurementRequest {
1102            operation,
1103            phase: self.phase_for(operation),
1104            text,
1105            style,
1106            max_width,
1107            wrap_mode,
1108        }
1109    }
1110}
1111
1112impl TextMeasurer for RoutedTextMeasurer<'_> {
1113    fn cancellation_requested(&self) -> bool {
1114        self.controlled_operation_phase
1115            .is_some_and(|phase| self.work_meter.checkpoint(phase).is_err())
1116    }
1117
1118    #[allow(private_interfaces)]
1119    fn builtin_operation_carrier(
1120        &self,
1121        operation: TextMeasurementOperation,
1122    ) -> Option<BuiltinTextMeasurementOperationCarrier> {
1123        RoutedTextMeasurer::builtin_operation_carrier(self, operation)
1124    }
1125
1126    #[allow(private_interfaces)]
1127    fn begin_svg_text_computed_length(
1128        &self,
1129        style: &TextStyle,
1130    ) -> Option<BuiltinSvgComputedLength> {
1131        self.builtin_operation_carrier(TextMeasurementOperation::ComputedLength)
1132            .and_then(|carrier| carrier.into_svg_computed_length(style))
1133    }
1134
1135    fn measure(&self, text: &str, style: &TextStyle) -> TextMetrics {
1136        self.resolve(
1137            self.request(
1138                TextMeasurementOperation::Measure,
1139                text,
1140                style,
1141                None,
1142                WrapMode::SvgLike,
1143            ),
1144            decode_host_metrics,
1145            |profile| profile.measure(text, style),
1146        )
1147    }
1148
1149    fn measure_svg_text_computed_length_px(&self, text: &str, style: &TextStyle) -> f64 {
1150        self.resolve(
1151            self.request(
1152                TextMeasurementOperation::ComputedLength,
1153                text,
1154                style,
1155                None,
1156                WrapMode::SvgLike,
1157            ),
1158            decode_host_length,
1159            |profile| profile.measure_svg_text_computed_length_px(text, style),
1160        )
1161    }
1162
1163    fn measure_svg_text_bbox_x(&self, text: &str, style: &TextStyle) -> (f64, f64) {
1164        self.resolve(
1165            self.request(
1166                TextMeasurementOperation::BBoxX,
1167                text,
1168                style,
1169                None,
1170                WrapMode::SvgLike,
1171            ),
1172            decode_host_extents,
1173            |profile| profile.measure_svg_text_bbox_x(text, style),
1174        )
1175    }
1176
1177    fn measure_svg_text_bbox_x_with_ascii_overhang(
1178        &self,
1179        text: &str,
1180        style: &TextStyle,
1181    ) -> (f64, f64) {
1182        self.resolve(
1183            self.request(
1184                TextMeasurementOperation::BBoxXWithAsciiOverhang,
1185                text,
1186                style,
1187                None,
1188                WrapMode::SvgLike,
1189            ),
1190            decode_host_extents,
1191            |profile| profile.measure_svg_text_bbox_x_with_ascii_overhang(text, style),
1192        )
1193    }
1194
1195    fn measure_svg_title_bbox_x(&self, text: &str, style: &TextStyle) -> (f64, f64) {
1196        self.resolve(
1197            self.request(
1198                TextMeasurementOperation::TitleBBoxX,
1199                text,
1200                style,
1201                None,
1202                WrapMode::SvgLike,
1203            ),
1204            decode_host_extents,
1205            |profile| profile.measure_svg_title_bbox_x(text, style),
1206        )
1207    }
1208
1209    fn measure_svg_simple_text_bbox_width_px(&self, text: &str, style: &TextStyle) -> f64 {
1210        self.resolve(
1211            self.request(
1212                TextMeasurementOperation::SimpleBBoxWidth,
1213                text,
1214                style,
1215                None,
1216                WrapMode::SvgLike,
1217            ),
1218            decode_host_length,
1219            |profile| profile.measure_svg_simple_text_bbox_width_px(text, style),
1220        )
1221    }
1222
1223    fn measure_svg_raw_text_bbox_width_px(&self, text: &str, style: &TextStyle) -> f64 {
1224        self.resolve(
1225            self.request(
1226                TextMeasurementOperation::RawBBoxWidth,
1227                text,
1228                style,
1229                None,
1230                WrapMode::SvgLike,
1231            ),
1232            decode_host_length,
1233            |profile| profile.measure_svg_raw_text_bbox_width_px(text, style),
1234        )
1235    }
1236
1237    fn measure_svg_raw_text_bbox_height_px(&self, text: &str, style: &TextStyle) -> f64 {
1238        self.resolve(
1239            self.request(
1240                TextMeasurementOperation::RawBBoxHeight,
1241                text,
1242                style,
1243                None,
1244                WrapMode::SvgLike,
1245            ),
1246            decode_host_length,
1247            |profile| profile.measure_svg_raw_text_bbox_height_px(text, style),
1248        )
1249    }
1250
1251    fn measure_svg_text_bounding_client_rect_width_px(&self, text: &str, style: &TextStyle) -> f64 {
1252        self.resolve(
1253            self.request(
1254                TextMeasurementOperation::BoundingClientRectWidth,
1255                text,
1256                style,
1257                None,
1258                WrapMode::SvgLike,
1259            ),
1260            decode_host_length,
1261            |profile| profile.measure_svg_text_bounding_client_rect_width_px(text, style),
1262        )
1263    }
1264
1265    fn measure_svg_tspan_text_bbox_width_px(&self, text: &str, style: &TextStyle) -> f64 {
1266        self.resolve(
1267            self.request(
1268                TextMeasurementOperation::TspanBBoxWidth,
1269                text,
1270                style,
1271                None,
1272                WrapMode::SvgLike,
1273            ),
1274            decode_host_length,
1275            |profile| profile.measure_svg_tspan_text_bbox_width_px(text, style),
1276        )
1277    }
1278
1279    fn measure_svg_tspan_text_bbox_height_px(&self, text: &str, style: &TextStyle) -> f64 {
1280        self.resolve(
1281            self.request(
1282                TextMeasurementOperation::TspanBBoxHeight,
1283                text,
1284                style,
1285                None,
1286                WrapMode::SvgLike,
1287            ),
1288            decode_host_length,
1289            |profile| profile.measure_svg_tspan_text_bbox_height_px(text, style),
1290        )
1291    }
1292
1293    fn measure_svg_create_text_bbox_y_offset_px(&self, text: &str, style: &TextStyle) -> f64 {
1294        self.resolve(
1295            self.request(
1296                TextMeasurementOperation::CreateTextBBoxYOffset,
1297                text,
1298                style,
1299                None,
1300                WrapMode::SvgLike,
1301            ),
1302            decode_host_length,
1303            |profile| profile.measure_svg_create_text_bbox_y_offset_px(text, style),
1304        )
1305    }
1306
1307    fn measure_svg_create_text_middle_bbox_y_offset_px(
1308        &self,
1309        text: &str,
1310        style: &TextStyle,
1311    ) -> f64 {
1312        self.resolve(
1313            self.request(
1314                TextMeasurementOperation::CreateTextMiddleBBoxYOffset,
1315                text,
1316                style,
1317                None,
1318                WrapMode::SvgLike,
1319            ),
1320            decode_host_length,
1321            |profile| profile.measure_svg_create_text_middle_bbox_y_offset_px(text, style),
1322        )
1323    }
1324
1325    fn measure_svg_simple_text_bbox_width_for_wrap_px(&self, text: &str, style: &TextStyle) -> f64 {
1326        self.resolve(
1327            self.request(
1328                TextMeasurementOperation::WrapProbeBBoxWidth,
1329                text,
1330                style,
1331                None,
1332                WrapMode::SvgLike,
1333            ),
1334            decode_host_length,
1335            |profile| profile.measure_svg_simple_text_bbox_width_for_wrap_px(text, style),
1336        )
1337    }
1338
1339    fn measure_mermaid_calculate_text_dimensions(
1340        &self,
1341        text: &str,
1342        style: &TextStyle,
1343    ) -> TextMetrics {
1344        self.resolve(
1345            self.request(
1346                TextMeasurementOperation::MermaidCalculateTextDimensions,
1347                text,
1348                style,
1349                None,
1350                WrapMode::SvgLike,
1351            ),
1352            decode_host_metrics,
1353            |profile| profile.measure_mermaid_calculate_text_dimensions(text, style),
1354        )
1355    }
1356
1357    fn measure_canvas_text_width_px(&self, text: &str, style: &TextStyle) -> f64 {
1358        self.resolve(
1359            self.request(
1360                TextMeasurementOperation::CanvasMeasureTextWidth,
1361                text,
1362                style,
1363                None,
1364                WrapMode::SvgLike,
1365            ),
1366            decode_host_length,
1367            |profile| profile.measure_canvas_text_width_px(text, style),
1368        )
1369    }
1370
1371    fn measure_svg_simple_text_bbox_height_px(&self, text: &str, style: &TextStyle) -> f64 {
1372        self.resolve(
1373            self.request(
1374                TextMeasurementOperation::SimpleBBoxHeight,
1375                text,
1376                style,
1377                None,
1378                WrapMode::SvgLike,
1379            ),
1380            decode_host_length,
1381            |profile| profile.measure_svg_simple_text_bbox_height_px(text, style),
1382        )
1383    }
1384
1385    fn measure_wrapped(
1386        &self,
1387        text: &str,
1388        style: &TextStyle,
1389        max_width: Option<f64>,
1390        wrap_mode: WrapMode,
1391    ) -> TextMetrics {
1392        self.resolve(
1393            self.request(
1394                TextMeasurementOperation::Wrapped,
1395                text,
1396                style,
1397                max_width,
1398                wrap_mode,
1399            ),
1400            decode_host_metrics,
1401            |profile| profile.measure_wrapped(text, style, max_width, wrap_mode),
1402        )
1403    }
1404
1405    fn measure_wrapped_with_raw_width(
1406        &self,
1407        text: &str,
1408        style: &TextStyle,
1409        max_width: Option<f64>,
1410        wrap_mode: WrapMode,
1411    ) -> (TextMetrics, Option<f64>) {
1412        self.resolve(
1413            self.request(
1414                TextMeasurementOperation::WrappedWithRawWidth,
1415                text,
1416                style,
1417                max_width,
1418                wrap_mode,
1419            ),
1420            decode_host_wrapped_with_raw_width,
1421            |profile| profile.measure_wrapped_with_raw_width(text, style, max_width, wrap_mode),
1422        )
1423    }
1424}
1425
1426fn decode_host_metrics(measurement: HostTextMeasurement) -> Option<TextMetrics> {
1427    match measurement {
1428        HostTextMeasurement::Metrics(metrics) => Some(metrics),
1429        _ => None,
1430    }
1431}
1432
1433fn decode_host_length(measurement: HostTextMeasurement) -> Option<f64> {
1434    match measurement {
1435        HostTextMeasurement::Length(length) => Some(length),
1436        _ => None,
1437    }
1438}
1439
1440fn decode_host_extents(measurement: HostTextMeasurement) -> Option<(f64, f64)> {
1441    match measurement {
1442        HostTextMeasurement::HorizontalExtents { left, right } => Some((left, right)),
1443        _ => None,
1444    }
1445}
1446
1447fn decode_host_wrapped_with_raw_width(
1448    measurement: HostTextMeasurement,
1449) -> Option<(TextMetrics, Option<f64>)> {
1450    match measurement {
1451        HostTextMeasurement::WrappedWithRawWidth { metrics, raw_width } => {
1452            Some((metrics, raw_width))
1453        }
1454        _ => None,
1455    }
1456}
1457
1458/// Checks a host callback value against the complete operation request.
1459///
1460/// The validator is the single authority for result shape and numeric bounds across direct
1461/// renderer hosts and every binding transport.
1462pub fn validate_host_text_measurement(
1463    request: &HostTextMeasurementRequest<'_>,
1464    measurement: &HostTextMeasurement,
1465) -> Result<(), HostTextMeasurementError> {
1466    let result_kind = match measurement {
1467        HostTextMeasurement::Metrics(_) => TextMeasurementResultKind::Metrics,
1468        HostTextMeasurement::Length(_) => TextMeasurementResultKind::Length,
1469        HostTextMeasurement::HorizontalExtents { .. } => {
1470            TextMeasurementResultKind::HorizontalExtents
1471        }
1472        HostTextMeasurement::WrappedWithRawWidth { .. } => {
1473            TextMeasurementResultKind::WrappedWithRawWidth
1474        }
1475    };
1476    let required_kind = request.operation.required_result_kind();
1477    if result_kind != required_kind {
1478        return Err(HostTextMeasurementError::invalid_value(format!(
1479            "host text measurement operation `{}` requires `{}` but returned `{}`",
1480            request.operation.external_name(),
1481            required_kind.external_name(),
1482            result_kind.external_name(),
1483        )));
1484    }
1485
1486    let valid = match measurement {
1487        HostTextMeasurement::Metrics(metrics) => valid_metrics(request, metrics),
1488        HostTextMeasurement::Length(value) => {
1489            value.is_finite() && (request.operation.accepts_signed_length() || *value >= 0.0)
1490        }
1491        HostTextMeasurement::HorizontalExtents { left, right } => valid_extents(*left, *right),
1492        HostTextMeasurement::WrappedWithRawWidth { metrics, raw_width } => {
1493            valid_metrics(request, metrics)
1494                && raw_width.is_none_or(|value| value.is_finite() && value >= 0.0)
1495        }
1496    };
1497    if valid {
1498        Ok(())
1499    } else {
1500        Err(HostTextMeasurementError::invalid_value(format!(
1501            "host text measurement operation `{}` returned an invalid `{}` value",
1502            request.operation.external_name(),
1503            result_kind.external_name(),
1504        )))
1505    }
1506}
1507
1508fn valid_metrics(request: &HostTextMeasurementRequest<'_>, metrics: &TextMetrics) -> bool {
1509    metrics.width.is_finite()
1510        && metrics.height.is_finite()
1511        && metrics.width >= 0.0
1512        && metrics.height >= 0.0
1513        && metrics.line_count > 0
1514        && metrics.line_count <= request.text.len().saturating_add(1)
1515}
1516
1517fn valid_extents(left: f64, right: f64) -> bool {
1518    left.is_finite()
1519        && right.is_finite()
1520        && left >= 0.0
1521        && right >= 0.0
1522        && (left + right).is_finite()
1523}
1524
1525#[cfg(feature = "math")]
1526fn default_math_renderer() -> Option<Arc<dyn MathRenderer + Send + Sync>> {
1527    Some(Arc::new(crate::math::RatexMathRenderer))
1528}
1529
1530#[cfg(not(feature = "math"))]
1531fn default_math_renderer() -> Option<Arc<dyn MathRenderer + Send + Sync>> {
1532    None
1533}
1534
1535/// Immutable render services and the policy used to capture one operation context.
1536#[derive(Clone)]
1537pub struct RenderEnvironment {
1538    text_measurement: TextMeasurementPolicy,
1539    capability_policy: RenderCapabilityPolicy,
1540    math_renderer: Option<Arc<dyn MathRenderer + Send + Sync>>,
1541    icon_registry: Option<IconRegistry>,
1542    runtime_policy: RuntimePolicy,
1543    resource_policy: RenderResourcePolicy,
1544}
1545
1546impl fmt::Debug for RenderEnvironment {
1547    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1548        f.debug_struct("RenderEnvironment")
1549            .field("text_measurement", &self.text_measurement)
1550            .field("capability_policy", &self.capability_policy)
1551            .field(
1552                "has_math_renderer",
1553                &(self.capability_policy.allows(RenderCapability::Math)
1554                    && self.math_renderer.is_some()),
1555            )
1556            .field("has_icon_registry", &self.icon_registry.is_some())
1557            .field("runtime_policy", &self.runtime_policy)
1558            .field("resource_policy", &self.resource_policy)
1559            .finish_non_exhaustive()
1560    }
1561}
1562
1563impl RenderEnvironment {
1564    /// Creates a target-independent environment with fixed time, UTC, and a fixed seed.
1565    ///
1566    /// When the `math` capability is compiled, the environment also installs its built-in math
1567    /// renderer. Builds without that capability leave the service absent so family admission can
1568    /// return a typed missing-capability error.
1569    pub fn deterministic() -> Self {
1570        Self {
1571            text_measurement: TextMeasurementPolicy::deterministic(),
1572            capability_policy: RenderCapabilityPolicy::unrestricted(),
1573            math_renderer: default_math_renderer(),
1574            icon_registry: None,
1575            runtime_policy: RuntimePolicy::deterministic(),
1576            resource_policy: RenderResourcePolicy::interactive(),
1577        }
1578    }
1579
1580    /// Creates an environment backed by native clock, timezone, and randomness adapters.
1581    ///
1582    /// Timing remains an explicit opt-in because it adds work and observable diagnostics.
1583    pub fn try_native() -> Result<Self, RuntimePolicyError> {
1584        Ok(Self::deterministic().with_runtime_policy(RuntimePolicy::try_native()?))
1585    }
1586
1587    pub fn with_text_measurement_policy(mut self, policy: TextMeasurementPolicy) -> Self {
1588        self.text_measurement = policy;
1589        self
1590    }
1591
1592    /// Restricts optional renderer capabilities for every operation begun by this environment.
1593    ///
1594    /// This is primarily useful to artifact owners whose public feature contract can be narrower
1595    /// than Cargo's resolved dependency feature union.
1596    pub const fn with_capability_policy(mut self, policy: RenderCapabilityPolicy) -> Self {
1597        self.capability_policy = policy;
1598        self
1599    }
1600
1601    /// Installs the math renderer compiled into this renderer, if present.
1602    ///
1603    /// Facades use this to select the canonical compiled capability instead of duplicating Cargo
1604    /// feature checks in each transport layer.
1605    pub fn with_compiled_math_renderer(mut self) -> Self {
1606        self.math_renderer = default_math_renderer();
1607        self
1608    }
1609
1610    pub fn with_math_renderer(mut self, renderer: Arc<dyn MathRenderer + Send + Sync>) -> Self {
1611        self.math_renderer = Some(renderer);
1612        self
1613    }
1614
1615    pub fn without_math_renderer(mut self) -> Self {
1616        self.math_renderer = None;
1617        self
1618    }
1619
1620    pub fn with_icon_registry(mut self, registry: IconRegistry) -> Self {
1621        self.icon_registry = Some(registry);
1622        self
1623    }
1624
1625    pub fn with_runtime_policy(mut self, policy: RuntimePolicy) -> Self {
1626        self.runtime_policy = policy;
1627        self
1628    }
1629
1630    pub fn runtime_policy(&self) -> &RuntimePolicy {
1631        &self.runtime_policy
1632    }
1633
1634    pub const fn with_resource_policy(mut self, policy: RenderResourcePolicy) -> Self {
1635        self.resource_policy = policy;
1636        self
1637    }
1638
1639    /// Captures time, timezone rules, random seed, and provenance exactly once.
1640    pub fn begin_session(&self) -> Result<RenderSession, RuntimePolicyError> {
1641        self.begin_session_with_control(OperationControl::new())
1642    }
1643
1644    /// Captures one render session using caller-owned cancellation/deadline state.
1645    ///
1646    /// The control is cloned into the operation work meter so layout adapters, SVG emission, and
1647    /// postprocessing observe the same operation scope. This method does not create another
1648    /// control or expose a second cancellation state.
1649    pub fn begin_session_with_control(
1650        &self,
1651        control: OperationControl,
1652    ) -> Result<RenderSession, RuntimePolicyError> {
1653        let operation_context = self.runtime_policy.begin_operation()?;
1654        Ok(self.begin_session_in_context(operation_context, control))
1655    }
1656
1657    /// Begins one render session from caller-captured operation state.
1658    ///
1659    /// This entry point deliberately does not call [`RuntimePolicy::begin_operation`]. Facades
1660    /// that already own the source-to-output operation use it to keep parsing and rendering on
1661    /// the same runtime context and cancellation/deadline control.
1662    pub fn begin_session_in_context(
1663        &self,
1664        operation_context: OperationContext,
1665        control: OperationControl,
1666    ) -> RenderSession {
1667        RenderSession {
1668            text_measurement: self.text_measurement.clone(),
1669            measurement_recorder: Box::default(),
1670            capability_policy: self.capability_policy,
1671            math_renderer: self.math_renderer.clone(),
1672            icon_registry: self.icon_registry.clone(),
1673            operation_context,
1674            resource_policy: self.resource_policy,
1675            work_meter: Arc::new(OperationWorkMeter::new_with_control(
1676                self.resource_policy,
1677                control,
1678            )),
1679        }
1680    }
1681}
1682
1683impl Default for RenderEnvironment {
1684    fn default() -> Self {
1685        Self::deterministic()
1686    }
1687}
1688
1689/// Opaque operation session. Family code receives only the narrow projection it needs.
1690pub struct RenderSession {
1691    text_measurement: TextMeasurementPolicy,
1692    // Keep movable family artifacts compact for bounded worker stacks.
1693    measurement_recorder: Box<TextMeasurementRecorder>,
1694    capability_policy: RenderCapabilityPolicy,
1695    math_renderer: Option<Arc<dyn MathRenderer + Send + Sync>>,
1696    icon_registry: Option<IconRegistry>,
1697    operation_context: OperationContext,
1698    resource_policy: RenderResourcePolicy,
1699    work_meter: Arc<OperationWorkMeter>,
1700}
1701
1702impl RenderSession {
1703    pub fn text_measurer(&self, default_phase: TextMeasurementPhase) -> RoutedTextMeasurer<'_> {
1704        self.routed_text_measurer(default_phase, None)
1705    }
1706
1707    pub(crate) fn controlled_text_measurer(
1708        &self,
1709        default_phase: TextMeasurementPhase,
1710        operation_phase: OperationPhase,
1711    ) -> RoutedTextMeasurer<'_> {
1712        self.routed_text_measurer(default_phase, Some(operation_phase))
1713    }
1714
1715    fn routed_text_measurer(
1716        &self,
1717        default_phase: TextMeasurementPhase,
1718        controlled_operation_phase: Option<OperationPhase>,
1719    ) -> RoutedTextMeasurer<'_> {
1720        RoutedTextMeasurer {
1721            default_phase,
1722            policy: &self.text_measurement,
1723            recorder: &self.measurement_recorder,
1724            work_meter: self.work_meter.as_ref(),
1725            controlled_operation_phase,
1726        }
1727    }
1728
1729    pub fn text_measurement_route(&self, phase: TextMeasurementPhase) -> TextMeasurementRoute {
1730        self.text_measurement.route(phase)
1731    }
1732
1733    pub fn text_measurement_report(&self) -> TextMeasurementReport {
1734        self.measurement_recorder.report(&self.text_measurement)
1735    }
1736
1737    pub fn operation_context(&self) -> &OperationContext {
1738        &self.operation_context
1739    }
1740
1741    pub fn operation_timing(&self) -> Option<OperationTiming> {
1742        self.operation_context.timing()
1743    }
1744
1745    pub const fn unix_millis(&self) -> i64 {
1746        self.operation_context.unix_millis()
1747    }
1748
1749    pub const fn local_date(&self) -> merman_core::time::CivilDate {
1750        self.operation_context.today_local()
1751    }
1752
1753    pub fn local_time_zone(&self) -> &merman_core::time::LocalTimeZone {
1754        self.operation_context.local_time_zone()
1755    }
1756
1757    pub fn render_seed(&self) -> NonZeroU64 {
1758        self.operation_context.derive_nonzero_u64("render.root", 0)
1759    }
1760
1761    pub const fn resource_policy(&self) -> RenderResourcePolicy {
1762        self.resource_policy
1763    }
1764
1765    /// Reports effective operation availability after policy and backend/service resolution.
1766    pub(crate) fn supports_capability(&self, capability: RenderCapability) -> bool {
1767        if !self.capability_policy.allows(capability) {
1768            return false;
1769        }
1770        match capability {
1771            RenderCapability::LayoutCytoscape => crate::layout_cytoscape_available(),
1772            RenderCapability::LayoutElk => crate::layout_elk_available(),
1773            RenderCapability::Math => self.math_renderer.is_some(),
1774        }
1775    }
1776
1777    pub(crate) fn work_meter(&self) -> &Arc<OperationWorkMeter> {
1778        &self.work_meter
1779    }
1780
1781    /// Checks the operation-owned control at an SVG/render phase boundary.
1782    pub(crate) fn checkpoint(&self, phase: OperationPhase) -> crate::Result<()> {
1783        self.work_meter().checkpoint(phase).map_err(Into::into)
1784    }
1785
1786    pub fn math_renderer(&self) -> Option<&(dyn MathRenderer + Send + Sync)> {
1787        if self.supports_capability(RenderCapability::Math) {
1788            self.math_renderer.as_deref()
1789        } else {
1790            None
1791        }
1792    }
1793
1794    pub fn icon_registry(&self) -> Option<&IconRegistry> {
1795        self.icon_registry.as_ref()
1796    }
1797
1798    /// Freezes the observable policy and provenance accumulated so far.
1799    pub fn report(&self) -> RenderSessionReport {
1800        RenderSessionReport {
1801            measurement_routes: self.text_measurement.routes(),
1802            measurement: self.measurement_recorder.report(&self.text_measurement),
1803            operation_context: self.operation_context.clone(),
1804            local_time_zone: self
1805                .operation_context
1806                .local_time_zone()
1807                .provenance()
1808                .clone(),
1809            resource_policy: self.resource_policy,
1810            layout_work_units: self.work_meter.used(),
1811        }
1812    }
1813}
1814
1815/// Immutable environment evidence accumulated by an operation session.
1816#[derive(Debug, Clone, PartialEq, Eq)]
1817pub struct RenderSessionReport {
1818    measurement_routes: [TextMeasurementRoute; 4],
1819    measurement: TextMeasurementReport,
1820    operation_context: OperationContext,
1821    local_time_zone: LocalTimeZoneProvenance,
1822    resource_policy: RenderResourcePolicy,
1823    layout_work_units: usize,
1824}
1825
1826impl RenderSessionReport {
1827    pub fn measurement_routes(&self) -> &[TextMeasurementRoute; 4] {
1828        &self.measurement_routes
1829    }
1830
1831    pub fn measurement(&self) -> &TextMeasurementReport {
1832        &self.measurement
1833    }
1834
1835    pub fn operation_context(&self) -> &OperationContext {
1836        &self.operation_context
1837    }
1838
1839    pub const fn unix_millis(&self) -> i64 {
1840        self.operation_context.unix_millis()
1841    }
1842
1843    pub const fn local_date(&self) -> merman_core::time::CivilDate {
1844        self.operation_context.today_local()
1845    }
1846
1847    pub fn local_time_zone(&self) -> &LocalTimeZoneProvenance {
1848        &self.local_time_zone
1849    }
1850
1851    pub fn render_seed(&self) -> NonZeroU64 {
1852        self.operation_context.derive_nonzero_u64("render.root", 0)
1853    }
1854
1855    pub const fn resource_policy(&self) -> RenderResourcePolicy {
1856        self.resource_policy
1857    }
1858
1859    /// Returns the deterministic owner-accounted layout and geometry work consumed so far.
1860    ///
1861    /// This value is useful for resource-policy calibration. It is not elapsed time, an
1862    /// instruction count, or a portable latency estimate.
1863    pub const fn layout_work_units(&self) -> usize {
1864        self.layout_work_units
1865    }
1866}
1867
1868#[cfg(all(test, feature = "all-diagrams"))]
1869mod tests {
1870    use super::*;
1871    use std::sync::Mutex;
1872    use std::sync::atomic::{AtomicUsize, Ordering};
1873
1874    fn inline_html_carrier<M: TextMeasurer + ?Sized>(measurer: &M) -> InlineHtmlMeasurementCarrier {
1875        measurer
1876            .builtin_operation_carrier(TextMeasurementOperation::WrappedWithRawWidth)
1877            .and_then(BuiltinTextMeasurementOperationCarrier::into_inline_html)
1878            .unwrap_or_else(InlineHtmlMeasurementCarrier::opaque)
1879    }
1880
1881    #[test]
1882    fn deterministic_profile_identity_tracks_the_render_crate() {
1883        let profile = deterministic_profile();
1884
1885        assert_eq!(
1886            profile.identity().profile().as_str(),
1887            "merman.deterministic-text"
1888        );
1889        assert_eq!(
1890            profile.identity().version(),
1891            concat!("merman-render@", env!("CARGO_PKG_VERSION"))
1892        );
1893    }
1894
1895    #[test]
1896    fn text_measurement_operations_have_stable_external_mappings() {
1897        let mappings = TextMeasurementOperation::ALL
1898            .map(|operation| (operation.external_code(), operation.external_name()));
1899
1900        assert_eq!(
1901            mappings,
1902            [
1903                (0, "measure"),
1904                (1, "computed-length"),
1905                (2, "bbox-x"),
1906                (3, "bbox-x-with-ascii-overhang"),
1907                (4, "title-bbox-x"),
1908                (5, "simple-bbox-width"),
1909                (6, "raw-bbox-width"),
1910                (7, "tspan-bbox-width"),
1911                (8, "tspan-bbox-height"),
1912                (9, "wrap-probe-bbox-width"),
1913                (10, "simple-bbox-height"),
1914                (11, "wrapped"),
1915                (12, "wrapped-with-raw-width"),
1916                (13, "bounding-client-rect-width"),
1917                (14, "create-text-bbox-y-offset"),
1918                (15, "mermaid-calculate-text-dimensions"),
1919                (16, "canvas-measure-text-width"),
1920                (17, "create-text-middle-bbox-y-offset"),
1921                (18, "raw-bbox-height"),
1922            ]
1923        );
1924    }
1925
1926    #[test]
1927    fn deterministic_environment_projects_one_operation_context_into_the_report() {
1928        let runtime_policy = RuntimePolicy::deterministic()
1929            .with_fixed_unix_millis(1_704_067_200_000)
1930            .try_with_fixed_local_offset_minutes(480)
1931            .expect("valid fixed offset")
1932            .with_fixed_seed(77);
1933        let environment = RenderEnvironment::deterministic().with_runtime_policy(runtime_policy);
1934
1935        let session = environment.begin_session().expect("render session");
1936        let captured = session.operation_context().clone();
1937        let report = session.report();
1938
1939        assert_eq!(captured.unix_millis(), 1_704_067_200_000);
1940        assert_eq!(captured.seed(), 77);
1941        assert_eq!(captured.local_time_zone().fixed_offset_minutes(), Some(480));
1942        assert_eq!(report.operation_context(), &captured);
1943        assert_eq!(report.unix_millis(), captured.unix_millis());
1944        assert_eq!(report.operation_context().seed(), captured.seed());
1945        assert_eq!(
1946            report.render_seed(),
1947            captured.derive_nonzero_u64("render.root", 0)
1948        );
1949        assert_eq!(
1950            report.local_time_zone(),
1951            captured.local_time_zone().provenance()
1952        );
1953    }
1954
1955    #[test]
1956    fn caller_captured_context_and_control_define_one_render_session() {
1957        let operation_context = RuntimePolicy::deterministic()
1958            .with_fixed_unix_millis(1_704_067_200_123)
1959            .with_fixed_seed(91)
1960            .begin_operation()
1961            .expect("caller operation context");
1962        let control = OperationControl::new();
1963        let session = RenderEnvironment::deterministic()
1964            .begin_session_in_context(operation_context.clone(), control.clone());
1965
1966        assert_eq!(session.operation_context(), &operation_context);
1967
1968        control.cancel();
1969        let error = session
1970            .checkpoint(OperationPhase::Layout)
1971            .expect_err("shared control should cancel the render session");
1972        let crate::Error::Cancelled(cancelled) = error else {
1973            panic!("expected structured cancellation");
1974        };
1975        assert_eq!(cancelled.phase, OperationPhase::Layout);
1976        assert_eq!(cancelled.reason, merman_core::CancelReason::Requested);
1977    }
1978
1979    #[test]
1980    fn descriptor_drives_host_result_validation_for_every_operation() {
1981        let style = TextStyle::default();
1982        let request = |operation| HostTextMeasurementRequest {
1983            operation,
1984            phase: TextMeasurementPhase::Layout,
1985            text: "contract",
1986            style: &style,
1987            max_width: None,
1988            wrap_mode: WrapMode::SvgLike,
1989        };
1990        let valid_metrics_value = HostTextMeasurement::Metrics(metrics(10.0));
1991        let valid_length = HostTextMeasurement::Length(10.0);
1992        let negative_length = HostTextMeasurement::Length(-10.0);
1993        let invalid_length = HostTextMeasurement::Length(f64::NAN);
1994        let valid_extents = HostTextMeasurement::HorizontalExtents {
1995            left: 1.0,
1996            right: 2.0,
1997        };
1998        let valid_wrapped = HostTextMeasurement::WrappedWithRawWidth {
1999            metrics: metrics(10.0),
2000            raw_width: Some(11.0),
2001        };
2002
2003        for operation in TextMeasurementOperation::ALL {
2004            let required = operation.required_result_kind();
2005            assert_eq!(
2006                validate_host_text_measurement(&request(operation), &valid_metrics_value).is_ok(),
2007                required == TextMeasurementResultKind::Metrics,
2008                "{} metrics contract",
2009                operation.external_name()
2010            );
2011            assert_eq!(
2012                validate_host_text_measurement(&request(operation), &valid_length).is_ok(),
2013                required == TextMeasurementResultKind::Length,
2014                "{} length contract",
2015                operation.external_name()
2016            );
2017            assert_eq!(
2018                validate_host_text_measurement(&request(operation), &negative_length).is_ok(),
2019                required == TextMeasurementResultKind::Length && operation.accepts_signed_length(),
2020                "{} signed-length contract",
2021                operation.external_name()
2022            );
2023            assert!(
2024                validate_host_text_measurement(&request(operation), &invalid_length).is_err(),
2025                "{} accepted a non-finite length",
2026                operation.external_name()
2027            );
2028            assert_eq!(
2029                validate_host_text_measurement(&request(operation), &valid_extents).is_ok(),
2030                required == TextMeasurementResultKind::HorizontalExtents,
2031                "{} extents contract",
2032                operation.external_name()
2033            );
2034            assert_eq!(
2035                validate_host_text_measurement(&request(operation), &valid_wrapped).is_ok(),
2036                required == TextMeasurementResultKind::WrappedWithRawWidth,
2037                "{} wrapped contract",
2038                operation.external_name()
2039            );
2040        }
2041    }
2042
2043    #[test]
2044    fn checked_host_measurement_rejects_malformed_numeric_boundaries() {
2045        let style = TextStyle::default();
2046        let request = |operation, text| HostTextMeasurementRequest {
2047            operation,
2048            phase: TextMeasurementPhase::Layout,
2049            text,
2050            style: &style,
2051            max_width: None,
2052            wrap_mode: WrapMode::SvgLike,
2053        };
2054        let metrics_value = |width, height, line_count| {
2055            HostTextMeasurement::Metrics(TextMetrics {
2056                width,
2057                height,
2058                line_count,
2059            })
2060        };
2061
2062        let metrics_request = request(TextMeasurementOperation::Measure, "abc");
2063        assert!(
2064            validate_host_text_measurement(&metrics_request, &metrics_value(1.0, 2.0, 4)).is_ok()
2065        );
2066        for invalid in [
2067            metrics_value(f64::NAN, 2.0, 1),
2068            metrics_value(f64::INFINITY, 2.0, 1),
2069            metrics_value(-1.0, 2.0, 1),
2070            metrics_value(1.0, f64::NAN, 1),
2071            metrics_value(1.0, f64::INFINITY, 1),
2072            metrics_value(1.0, -2.0, 1),
2073            metrics_value(1.0, 2.0, 0),
2074            metrics_value(1.0, 2.0, 5),
2075        ] {
2076            assert!(validate_host_text_measurement(&metrics_request, &invalid).is_err());
2077        }
2078
2079        let length_request = request(TextMeasurementOperation::ComputedLength, "abc");
2080        for value in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY, -1.0] {
2081            assert!(
2082                validate_host_text_measurement(
2083                    &length_request,
2084                    &HostTextMeasurement::Length(value),
2085                )
2086                .is_err()
2087            );
2088        }
2089        for operation in [
2090            TextMeasurementOperation::CreateTextBBoxYOffset,
2091            TextMeasurementOperation::CreateTextMiddleBBoxYOffset,
2092        ] {
2093            assert!(
2094                validate_host_text_measurement(
2095                    &request(operation, "abc"),
2096                    &HostTextMeasurement::Length(-1.0),
2097                )
2098                .is_ok()
2099            );
2100        }
2101
2102        let extents_request = request(TextMeasurementOperation::BBoxX, "abc");
2103        for (left, right) in [
2104            (f64::NAN, 1.0),
2105            (1.0, f64::INFINITY),
2106            (-1.0, 1.0),
2107            (1.0, -1.0),
2108            (f64::MAX, f64::MAX),
2109        ] {
2110            assert!(
2111                validate_host_text_measurement(
2112                    &extents_request,
2113                    &HostTextMeasurement::HorizontalExtents { left, right },
2114                )
2115                .is_err()
2116            );
2117        }
2118
2119        let wrapped_request = request(TextMeasurementOperation::WrappedWithRawWidth, "abc");
2120        assert!(
2121            validate_host_text_measurement(
2122                &wrapped_request,
2123                &HostTextMeasurement::WrappedWithRawWidth {
2124                    metrics: TextMetrics {
2125                        width: 10.0,
2126                        height: 20.0,
2127                        line_count: 1,
2128                    },
2129                    raw_width: Some(1.0),
2130                },
2131            )
2132            .is_ok(),
2133            "raw width may be smaller than wrapped width"
2134        );
2135        for raw_width in [f64::NAN, f64::INFINITY, -1.0] {
2136            assert!(
2137                validate_host_text_measurement(
2138                    &wrapped_request,
2139                    &HostTextMeasurement::WrappedWithRawWidth {
2140                        metrics: metrics(10.0),
2141                        raw_width: Some(raw_width),
2142                    },
2143                )
2144                .is_err()
2145            );
2146        }
2147    }
2148
2149    struct OperationAwareHost {
2150        operations: Arc<Mutex<Vec<TextMeasurementOperation>>>,
2151    }
2152
2153    impl HostTextMeasurer for OperationAwareHost {
2154        fn measure(&self, request: HostTextMeasurementRequest<'_>) -> HostMeasurementResult {
2155            self.operations
2156                .lock()
2157                .expect("operation probe lock")
2158                .push(request.operation);
2159            match request.operation {
2160                TextMeasurementOperation::ComputedLength => {
2161                    Ok(Some(HostTextMeasurement::Length(73.25)))
2162                }
2163                TextMeasurementOperation::BoundingClientRectWidth => {
2164                    Ok(Some(HostTextMeasurement::Length(91.875)))
2165                }
2166                TextMeasurementOperation::CreateTextBBoxYOffset => {
2167                    Ok(Some(HostTextMeasurement::Length(-1.25)))
2168                }
2169                TextMeasurementOperation::MermaidCalculateTextDimensions => {
2170                    Ok(Some(HostTextMeasurement::Metrics(metrics(82.5))))
2171                }
2172                TextMeasurementOperation::CanvasMeasureTextWidth => {
2173                    Ok(Some(HostTextMeasurement::Length(94.25)))
2174                }
2175                TextMeasurementOperation::CreateTextMiddleBBoxYOffset => {
2176                    Ok(Some(HostTextMeasurement::Length(-2.5)))
2177                }
2178                _ => Ok(None),
2179            }
2180        }
2181    }
2182
2183    #[test]
2184    fn host_computed_length_receives_exact_operation_and_is_authoritative() {
2185        let operations = Arc::new(Mutex::new(Vec::new()));
2186        let policy = TextMeasurementPolicy::host_display_with_fallback(
2187            identity("test.operation-aware-host", "v1", &[]),
2188            Arc::new(OperationAwareHost {
2189                operations: Arc::clone(&operations),
2190            }),
2191            [TextMeasurementPhase::ComputedLength],
2192            deterministic_profile(),
2193        );
2194        let session = RenderEnvironment::deterministic()
2195            .with_text_measurement_policy(policy)
2196            .begin_session()
2197            .expect("begin render session");
2198
2199        let length = session
2200            .text_measurer(TextMeasurementPhase::Layout)
2201            .measure_svg_text_computed_length_px("operation", &TextStyle::default());
2202
2203        assert_eq!(length, 73.25);
2204        assert_eq!(
2205            *operations.lock().expect("operation probe lock"),
2206            [TextMeasurementOperation::ComputedLength]
2207        );
2208    }
2209
2210    #[test]
2211    fn host_bounding_client_rect_width_receives_exact_operation_and_is_authoritative() {
2212        let operations = Arc::new(Mutex::new(Vec::new()));
2213        let policy = TextMeasurementPolicy::host_display_with_fallback(
2214            identity("test.operation-aware-host", "v1", &[]),
2215            Arc::new(OperationAwareHost {
2216                operations: Arc::clone(&operations),
2217            }),
2218            [TextMeasurementPhase::SvgBBox],
2219            deterministic_profile(),
2220        );
2221        let session = RenderEnvironment::deterministic()
2222            .with_text_measurement_policy(policy)
2223            .begin_session()
2224            .expect("begin render session");
2225
2226        let length = session
2227            .text_measurer(TextMeasurementPhase::Layout)
2228            .measure_svg_text_bounding_client_rect_width_px("operation", &TextStyle::default());
2229
2230        assert_eq!(length, 91.875);
2231        assert_eq!(
2232            *operations.lock().expect("operation probe lock"),
2233            [TextMeasurementOperation::BoundingClientRectWidth]
2234        );
2235    }
2236
2237    #[test]
2238    fn host_create_text_bbox_y_offset_accepts_signed_authoritative_values() {
2239        let operations = Arc::new(Mutex::new(Vec::new()));
2240        let policy = TextMeasurementPolicy::host_display_with_fallback(
2241            identity("test.operation-aware-host", "v1", &[]),
2242            Arc::new(OperationAwareHost {
2243                operations: Arc::clone(&operations),
2244            }),
2245            [TextMeasurementPhase::SvgBBox],
2246            deterministic_profile(),
2247        );
2248        let session = RenderEnvironment::deterministic()
2249            .with_text_measurement_policy(policy)
2250            .begin_session()
2251            .expect("begin render session");
2252
2253        let offset = session
2254            .text_measurer(TextMeasurementPhase::Layout)
2255            .measure_svg_create_text_bbox_y_offset_px("operation", &TextStyle::default());
2256
2257        assert_eq!(offset, -1.25);
2258        assert_eq!(
2259            *operations.lock().expect("operation probe lock"),
2260            [TextMeasurementOperation::CreateTextBBoxYOffset]
2261        );
2262    }
2263
2264    #[test]
2265    fn host_create_text_middle_bbox_y_offset_is_a_distinct_signed_operation() {
2266        let operations = Arc::new(Mutex::new(Vec::new()));
2267        let policy = TextMeasurementPolicy::host_display_with_fallback(
2268            identity("test.operation-aware-host", "v1", &[]),
2269            Arc::new(OperationAwareHost {
2270                operations: Arc::clone(&operations),
2271            }),
2272            [TextMeasurementPhase::SvgBBox],
2273            deterministic_profile(),
2274        );
2275        let session = RenderEnvironment::deterministic()
2276            .with_text_measurement_policy(policy)
2277            .begin_session()
2278            .expect("begin render session");
2279
2280        let offset = session
2281            .text_measurer(TextMeasurementPhase::Layout)
2282            .measure_svg_create_text_middle_bbox_y_offset_px("operation", &TextStyle::default());
2283
2284        assert_eq!(offset, -2.5);
2285        assert_eq!(
2286            *operations.lock().expect("operation probe lock"),
2287            [TextMeasurementOperation::CreateTextMiddleBBoxYOffset]
2288        );
2289    }
2290
2291    #[test]
2292    fn host_source_specific_width_operations_are_authoritative() {
2293        let operations = Arc::new(Mutex::new(Vec::new()));
2294        let policy = TextMeasurementPolicy::host_display_with_fallback(
2295            identity("test.operation-aware-host", "v1", &[]),
2296            Arc::new(OperationAwareHost {
2297                operations: Arc::clone(&operations),
2298            }),
2299            [TextMeasurementPhase::SvgBBox, TextMeasurementPhase::Layout],
2300            deterministic_profile(),
2301        );
2302        let session = RenderEnvironment::deterministic()
2303            .with_text_measurement_policy(policy)
2304            .begin_session()
2305            .expect("begin render session");
2306        let measurer = session.text_measurer(TextMeasurementPhase::Layout);
2307
2308        assert_eq!(
2309            measurer
2310                .measure_mermaid_calculate_text_dimensions("operation", &TextStyle::default())
2311                .width,
2312            82.5,
2313        );
2314        assert_eq!(
2315            measurer.measure_canvas_text_width_px("operation", &TextStyle::default()),
2316            94.25
2317        );
2318        assert_eq!(
2319            *operations.lock().expect("operation probe lock"),
2320            [
2321                TextMeasurementOperation::MermaidCalculateTextDimensions,
2322                TextMeasurementOperation::CanvasMeasureTextWidth,
2323            ]
2324        );
2325    }
2326
2327    fn identity(
2328        profile: &str,
2329        version: &str,
2330        decorators: &[&str],
2331    ) -> TextMeasurementProfileIdentity {
2332        TextMeasurementProfileIdentity::new(
2333            MeasurementProfileId::new(profile).expect("valid test profile"),
2334            version,
2335        )
2336        .expect("valid test version")
2337        .with_decorators(decorators.iter().copied())
2338        .expect("valid test decorators")
2339    }
2340
2341    fn metrics(width: f64) -> TextMetrics {
2342        TextMetrics {
2343            width,
2344            height: width + 1.0,
2345            line_count: 1,
2346        }
2347    }
2348
2349    #[derive(Debug, Default)]
2350    struct SpecializedProfile;
2351
2352    impl TextMeasurer for SpecializedProfile {
2353        fn measure(&self, _text: &str, _style: &TextStyle) -> TextMetrics {
2354            metrics(1.0)
2355        }
2356
2357        fn measure_svg_text_computed_length_px(&self, _text: &str, _style: &TextStyle) -> f64 {
2358            2.0
2359        }
2360
2361        fn measure_svg_text_bbox_x(&self, _text: &str, _style: &TextStyle) -> (f64, f64) {
2362            (3.0, 4.0)
2363        }
2364
2365        fn measure_svg_text_bbox_x_with_ascii_overhang(
2366            &self,
2367            _text: &str,
2368            _style: &TextStyle,
2369        ) -> (f64, f64) {
2370            (5.0, 6.0)
2371        }
2372
2373        fn measure_svg_title_bbox_x(&self, _text: &str, _style: &TextStyle) -> (f64, f64) {
2374            (7.0, 8.0)
2375        }
2376
2377        fn measure_svg_simple_text_bbox_width_px(&self, _text: &str, _style: &TextStyle) -> f64 {
2378            9.0
2379        }
2380
2381        fn measure_svg_raw_text_bbox_width_px(&self, _text: &str, _style: &TextStyle) -> f64 {
2382            10.0
2383        }
2384
2385        fn measure_svg_tspan_text_bbox_width_px(&self, _text: &str, _style: &TextStyle) -> f64 {
2386            10.5
2387        }
2388
2389        fn measure_svg_tspan_text_bbox_height_px(&self, _text: &str, _style: &TextStyle) -> f64 {
2390            11.5
2391        }
2392
2393        fn measure_svg_create_text_bbox_y_offset_px(&self, _text: &str, _style: &TextStyle) -> f64 {
2394            -1.25
2395        }
2396
2397        fn measure_svg_create_text_middle_bbox_y_offset_px(
2398            &self,
2399            _text: &str,
2400            _style: &TextStyle,
2401        ) -> f64 {
2402            -2.5
2403        }
2404
2405        fn measure_svg_simple_text_bbox_width_for_wrap_px(
2406            &self,
2407            _text: &str,
2408            _style: &TextStyle,
2409        ) -> f64 {
2410            11.0
2411        }
2412
2413        fn measure_mermaid_calculate_text_dimensions(
2414            &self,
2415            _text: &str,
2416            _style: &TextStyle,
2417        ) -> TextMetrics {
2418            metrics(11.25)
2419        }
2420
2421        fn measure_canvas_text_width_px(&self, _text: &str, _style: &TextStyle) -> f64 {
2422            11.75
2423        }
2424
2425        fn measure_svg_simple_text_bbox_height_px(&self, _text: &str, _style: &TextStyle) -> f64 {
2426            12.0
2427        }
2428
2429        fn measure_wrapped(
2430            &self,
2431            _text: &str,
2432            _style: &TextStyle,
2433            _max_width: Option<f64>,
2434            _wrap_mode: WrapMode,
2435        ) -> TextMetrics {
2436            metrics(13.0)
2437        }
2438
2439        fn measure_wrapped_with_raw_width(
2440            &self,
2441            _text: &str,
2442            _style: &TextStyle,
2443            _max_width: Option<f64>,
2444            _wrap_mode: WrapMode,
2445        ) -> (TextMetrics, Option<f64>) {
2446            (metrics(14.0), Some(15.0))
2447        }
2448    }
2449
2450    struct DecliningHost;
2451
2452    impl HostTextMeasurer for DecliningHost {
2453        fn measure(&self, _request: HostTextMeasurementRequest<'_>) -> HostMeasurementResult {
2454            Ok(None)
2455        }
2456    }
2457
2458    struct ForgedCarrierProfile;
2459
2460    impl TextMeasurer for ForgedCarrierProfile {
2461        #[allow(private_interfaces)]
2462        fn builtin_operation_carrier(
2463            &self,
2464            operation: TextMeasurementOperation,
2465        ) -> Option<BuiltinTextMeasurementOperationCarrier> {
2466            Some(BuiltinTextMeasurementOperationCarrier {
2467                profile: BuiltinTextMeasurementProfile::Deterministic,
2468                phase: TextMeasurementPhase::SvgBBox,
2469                operation,
2470            })
2471        }
2472
2473        fn measure(&self, _text: &str, _style: &TextStyle) -> TextMetrics {
2474            metrics(1.0)
2475        }
2476    }
2477
2478    #[test]
2479    fn private_operation_carriers_only_qualify_builtin_profile_routes() {
2480        assert!(
2481            BuiltinTextMeasurementOperationCarrier {
2482                profile: BuiltinTextMeasurementProfile::Deterministic,
2483                phase: TextMeasurementPhase::SvgBBox,
2484                operation: TextMeasurementOperation::WrappedWithRawWidth,
2485            }
2486            .into_inline_html()
2487            .is_none(),
2488            "the inline planner requires both the wrapped operation and its Wrap owner phase"
2489        );
2490
2491        let builtin_session = RenderEnvironment::deterministic()
2492            .begin_session()
2493            .expect("begin built-in session");
2494        let builtin = builtin_session.text_measurer(TextMeasurementPhase::Layout);
2495        let builtin_carrier = inline_html_carrier(&builtin);
2496        assert!(builtin_carrier.is_builtin());
2497        let sequence_carrier = builtin
2498            .builtin_operation_carrier(TextMeasurementOperation::MermaidCalculateTextDimensions)
2499            .expect("sequence measurement route is built-in");
2500        assert_eq!(sequence_carrier.phase, TextMeasurementPhase::SvgBBox);
2501        assert_eq!(
2502            sequence_carrier.operation,
2503            TextMeasurementOperation::MermaidCalculateTextDimensions
2504        );
2505        assert!(
2506            builtin
2507                .builtin_operation_carrier(TextMeasurementOperation::MermaidCalculateTextDimensions)
2508                .is_some()
2509        );
2510
2511        let custom_profile = TextMeasurementProfile::new(
2512            identity("test.custom", "v1", &[]),
2513            Arc::new(ForgedCarrierProfile),
2514        );
2515        let custom_session = RenderEnvironment::deterministic()
2516            .with_text_measurement_policy(TextMeasurementPolicy::uniform(custom_profile))
2517            .begin_session()
2518            .expect("begin custom profile session");
2519        let custom = custom_session.text_measurer(TextMeasurementPhase::Layout);
2520        assert!(!inline_html_carrier(&custom).is_builtin());
2521        assert!(
2522            custom
2523                .builtin_operation_carrier(TextMeasurementOperation::MermaidCalculateTextDimensions)
2524                .is_none()
2525        );
2526
2527        let host_policy = TextMeasurementPolicy::host_display(
2528            identity("test.host", "v1", &[]),
2529            Arc::new(DecliningHost),
2530            [TextMeasurementPhase::Wrap, TextMeasurementPhase::SvgBBox],
2531        );
2532        let host_session = RenderEnvironment::deterministic()
2533            .with_text_measurement_policy(host_policy)
2534            .begin_session()
2535            .expect("begin host session");
2536        let host = host_session.text_measurer(TextMeasurementPhase::Layout);
2537        assert!(
2538            !inline_html_carrier(&host).is_builtin(),
2539            "host routes remain opaque even when their fallback is deterministic"
2540        );
2541        assert!(
2542            host.builtin_operation_carrier(
2543                TextMeasurementOperation::MermaidCalculateTextDimensions
2544            )
2545            .is_none()
2546        );
2547    }
2548
2549    #[test]
2550    fn builtin_inline_stream_matches_backend_order_and_supported_br_normalization() {
2551        fn assert_cases(
2552            backend: &dyn TextMeasurer,
2553            carrier: InlineHtmlMeasurementCarrier,
2554            style: &TextStyle,
2555            cases: &[(&str, &[&str])],
2556        ) {
2557            for (text, chunks) in cases {
2558                assert_eq!(chunks.concat(), *text);
2559                let expected = backend
2560                    .measure_wrapped(text, style, None, WrapMode::HtmlLike)
2561                    .width;
2562                let mut streamed = carrier
2563                    .begin_inline_html_width(style)
2564                    .expect("built-in carrier starts a streaming width");
2565                for chunk in *chunks {
2566                    streamed.push_text(chunk);
2567                }
2568                assert_eq!(
2569                    streamed.width_px().to_bits(),
2570                    expected.to_bits(),
2571                    "text={text:?}, chunks={chunks:?}"
2572                );
2573            }
2574        }
2575
2576        let cases: &[(&str, &[&str])] = &[
2577            ("AVATAR office", &["A", "VAT", "AR ", "office"]),
2578            (
2579                "alpha<br   />omega",
2580                &["alpha<", "b", "r ", "  /", ">", "omega"],
2581            ),
2582            ("<b<br/>tail", &["<b<", "br", "/>tail"]),
2583            ("wide<br / >literal", &["wide<br ", "/ ", ">literal"]),
2584            // ECMAScript `\s` also includes form feed. The headless built-in intentionally keeps
2585            // that spelling literal until browser-grade HTML parsing is available.
2586            (
2587                "wide<br\u{000C}/>literal",
2588                &["wide<br", "\u{000C}", "/>literal"],
2589            ),
2590            (
2591                "wide\n                         ",
2592                &["wide\n", "             ", "            "],
2593            ),
2594            (" \n  ", &[" ", "\n", "  "]),
2595            ("i\n\u{00a0}", &["i\n", "\u{00a0}"]),
2596            ("A\u{301}πŸ‘©β€πŸ’»Ω…Ψ±Ψ­Ψ¨Ψ§δΈ–η•Œ", &["A\u{301}", "πŸ‘©β€πŸ’»", "Ω…Ψ±Ψ­Ψ¨Ψ§", "δΈ–η•Œ"]),
2597            ("πŸ‘©β€πŸ”¬", &["πŸ‘©", "\u{200d}", "πŸ”¬"]),
2598            ("πŸ‘¨β€πŸ‘©β€πŸ‘§β€πŸ‘¦", &["πŸ‘¨β€", "πŸ‘©", "\u{200d}πŸ‘§β€", "πŸ‘¦"]),
2599            ("πŸ‘πŸ½", &["πŸ‘", "🏽"]),
2600            ("πŸ‡¨πŸ‡³", &["πŸ‡¨", "πŸ‡³"]),
2601            ("1️⃣", &["1", "\u{fe0f}", "\u{20e3}"]),
2602            ("πŸ˜€\u{fe0e}", &["πŸ˜€", "\u{fe0e}"]),
2603            ("kδΈ­A+", &["kδΈ­", "A+"]),
2604        ];
2605        let style = TextStyle {
2606            font_family: Some("\"trebuchet ms\", verdana, arial, sans-serif".to_string()),
2607            font_size: 16.0,
2608            font_weight: Some("700".to_string()),
2609            font_style: Some("italic".to_string()),
2610        };
2611
2612        let deterministic_session = RenderEnvironment::deterministic()
2613            .begin_session()
2614            .expect("begin deterministic session");
2615        let deterministic_carrier =
2616            inline_html_carrier(&deterministic_session.text_measurer(TextMeasurementPhase::Wrap));
2617        assert_cases(
2618            &DeterministicTextMeasurer::default(),
2619            deterministic_carrier,
2620            &style,
2621            cases,
2622        );
2623        let long_whitespace = " ".repeat(8_192);
2624        let long_break = format!("wide<br{long_whitespace}/>tail");
2625        let expected = DeterministicTextMeasurer::default()
2626            .measure_wrapped(&long_break, &style, None, WrapMode::HtmlLike)
2627            .width;
2628        let mut streamed = deterministic_carrier
2629            .begin_inline_html_width(&style)
2630            .expect("deterministic carrier starts a streaming width");
2631        streamed.push_text("wide<br");
2632        streamed.push_text(&long_whitespace);
2633        streamed.push_text("/>tail");
2634        assert_eq!(streamed.width_px().to_bits(), expected.to_bits());
2635
2636        let mut unknown_font = style.clone();
2637        unknown_font.font_family = Some("fixture-private-font".to_string());
2638        assert_cases(
2639            &DeterministicTextMeasurer::default(),
2640            deterministic_carrier,
2641            &unknown_font,
2642            cases,
2643        );
2644
2645        let combining_tail = format!("A{}", "\u{0301}".repeat(1_024));
2646        assert_cases(
2647            &DeterministicTextMeasurer::default(),
2648            deterministic_carrier,
2649            &style,
2650            &[(combining_tail.as_str(), &[combining_tail.as_str()])],
2651        );
2652    }
2653
2654    #[test]
2655    fn builtin_svg_computed_length_stream_is_sequence_aware_and_reversible() {
2656        let backend = DeterministicTextMeasurer::default();
2657        let style = TextStyle {
2658            font_size: 16.0,
2659            ..TextStyle::default()
2660        };
2661        let cases: &[(&str, &[&str])] = &[
2662            ("πŸ‘©β€πŸ”¬", &["πŸ‘©", "\u{200d}", "πŸ”¬"]),
2663            ("πŸ‘¨β€πŸ‘©β€πŸ‘§β€πŸ‘¦", &["πŸ‘¨", "\u{200d}πŸ‘©β€", "πŸ‘§", "\u{200d}πŸ‘¦"]),
2664            ("πŸ‘πŸ½", &["πŸ‘", "🏽"]),
2665            ("πŸ‡¨πŸ‡³", &["πŸ‡¨", "πŸ‡³"]),
2666            ("1️⃣", &["1", "\u{fe0f}", "\u{20e3}"]),
2667            ("πŸ˜€\u{fe0e}", &["πŸ˜€", "\u{fe0e}"]),
2668            ("kδΈ­A+", &["kδΈ­", "A+"]),
2669        ];
2670
2671        for (text, chunks) in cases {
2672            let mut streamed = BuiltinSvgComputedLength::deterministic(&style);
2673            let mut prefix = String::new();
2674            for chunk in *chunks {
2675                prefix.push_str(chunk);
2676                streamed.push_text(chunk);
2677                let expected = backend.measure_svg_text_computed_length_px(&prefix, &style);
2678                assert_eq!(
2679                    streamed.width_px().to_bits(),
2680                    expected.to_bits(),
2681                    "text={text:?}, prefix={prefix:?}"
2682                );
2683            }
2684
2685            let checkpoint = streamed.clone();
2686            let expected = backend.measure_svg_text_computed_length_px(text, &style);
2687            assert_eq!(checkpoint.width_px().to_bits(), expected.to_bits());
2688            streamed.push_text("A");
2689            assert_ne!(
2690                streamed.width_px().to_bits(),
2691                checkpoint.width_px().to_bits()
2692            );
2693            streamed = checkpoint;
2694            assert_eq!(streamed.width_px().to_bits(), expected.to_bits());
2695            streamed.reset();
2696            assert_eq!(streamed.width_px(), 0.0);
2697        }
2698    }
2699
2700    #[test]
2701    fn named_complete_profile_preserves_every_specialized_method_and_identity() {
2702        let profile_identity = identity(
2703            "test.specialized",
2704            "v3",
2705            &["fixture-map@v2", "host-adjustment@v1"],
2706        );
2707        let profile =
2708            TextMeasurementProfile::new(profile_identity.clone(), Arc::new(SpecializedProfile));
2709        let environment = RenderEnvironment::deterministic()
2710            .with_text_measurement_policy(TextMeasurementPolicy::uniform(profile));
2711        let session = environment.begin_session().expect("begin render session");
2712        let measurer = session.text_measurer(TextMeasurementPhase::SvgBBox);
2713        let style = TextStyle::default();
2714
2715        assert_eq!(measurer.measure("x", &style).width, 1.0);
2716        assert_eq!(
2717            measurer.measure_svg_text_computed_length_px("x", &style),
2718            2.0
2719        );
2720        assert_eq!(measurer.measure_svg_text_bbox_x("x", &style), (3.0, 4.0));
2721        assert_eq!(
2722            measurer.measure_svg_text_bbox_x_with_ascii_overhang("x", &style),
2723            (5.0, 6.0)
2724        );
2725        assert_eq!(measurer.measure_svg_title_bbox_x("x", &style), (7.0, 8.0));
2726        assert_eq!(
2727            measurer.measure_svg_simple_text_bbox_width_px("x", &style),
2728            9.0
2729        );
2730        assert_eq!(
2731            measurer.measure_svg_raw_text_bbox_width_px("x", &style),
2732            10.0
2733        );
2734        assert_eq!(
2735            measurer.measure_svg_tspan_text_bbox_width_px("x", &style),
2736            10.5
2737        );
2738        assert_eq!(
2739            measurer.measure_svg_tspan_text_bbox_height_px("x", &style),
2740            11.5
2741        );
2742        assert_eq!(
2743            measurer.measure_svg_create_text_bbox_y_offset_px("x", &style),
2744            -1.25
2745        );
2746        assert_eq!(
2747            measurer.measure_svg_create_text_middle_bbox_y_offset_px("x", &style),
2748            -2.5
2749        );
2750        assert_eq!(
2751            measurer.measure_svg_simple_text_bbox_width_for_wrap_px("x", &style),
2752            11.0
2753        );
2754        assert_eq!(
2755            measurer
2756                .measure_mermaid_calculate_text_dimensions("x", &style)
2757                .width,
2758            11.25
2759        );
2760        assert_eq!(measurer.measure_canvas_text_width_px("x", &style), 11.75);
2761        assert_eq!(
2762            measurer.measure_svg_simple_text_bbox_height_px("x", &style),
2763            12.0
2764        );
2765        assert_eq!(
2766            measurer
2767                .measure_wrapped("x", &style, Some(10.0), WrapMode::HtmlLike)
2768                .width,
2769            13.0
2770        );
2771        assert_eq!(
2772            measurer
2773                .measure_wrapped_with_raw_width("x", &style, Some(10.0), WrapMode::HtmlLike,)
2774                .1,
2775            Some(15.0)
2776        );
2777        let route = session.text_measurement_route(TextMeasurementPhase::SvgBBox);
2778        assert_eq!(route.primary, profile_identity);
2779        assert_eq!(session.text_measurement_report().entries().len(), 17);
2780    }
2781
2782    #[test]
2783    fn repeated_measurements_are_aggregated_into_one_bounded_summary() {
2784        let policy = TextMeasurementPolicy::deterministic();
2785        let recorder = TextMeasurementRecorder::default();
2786
2787        for _ in 0..10_000 {
2788            recorder.record(
2789                TextMeasurementPhase::Layout,
2790                TextMeasurementOperation::Measure,
2791                TextMeasurementRouteOutcome::Profile,
2792            );
2793        }
2794
2795        let report = recorder.report(&policy);
2796        assert_eq!(report.entries().len(), 1);
2797        assert_eq!(report.entries()[0].count(), 10_000);
2798        assert_eq!(
2799            report.entries()[0].provenance().operation,
2800            TextMeasurementOperation::Measure
2801        );
2802        assert_eq!(
2803            report.entries()[0].provenance().phase,
2804            TextMeasurementPhase::Layout
2805        );
2806    }
2807
2808    #[derive(Clone)]
2809    enum HostOutcome {
2810        Measured(TextMetrics),
2811        Length(f64),
2812        Missing,
2813        Invalid,
2814        Error,
2815    }
2816
2817    struct CountingHost {
2818        calls: Arc<AtomicUsize>,
2819        outcome: HostOutcome,
2820    }
2821
2822    impl HostTextMeasurer for CountingHost {
2823        fn measure(&self, _request: HostTextMeasurementRequest<'_>) -> HostMeasurementResult {
2824            self.calls.fetch_add(1, Ordering::Relaxed);
2825            match self.outcome {
2826                HostOutcome::Measured(metrics) => Ok(Some(HostTextMeasurement::Metrics(metrics))),
2827                HostOutcome::Length(length) => Ok(Some(HostTextMeasurement::Length(length))),
2828                HostOutcome::Missing => Ok(None),
2829                HostOutcome::Invalid => Err(HostTextMeasurementError::invalid_value(
2830                    "invalid host value",
2831                )),
2832                HostOutcome::Error => Err(HostTextMeasurementError::new("host failed")),
2833            }
2834        }
2835    }
2836
2837    struct CountingFallback(Arc<AtomicUsize>);
2838
2839    impl TextMeasurer for CountingFallback {
2840        fn measure(&self, _text: &str, _style: &TextStyle) -> TextMetrics {
2841            self.0.fetch_add(1, Ordering::Relaxed);
2842            metrics(41.0)
2843        }
2844
2845        fn measure_svg_text_computed_length_px(&self, _text: &str, _style: &TextStyle) -> f64 {
2846            self.0.fetch_add(1, Ordering::Relaxed);
2847            42.0
2848        }
2849    }
2850
2851    fn host_policy(
2852        outcome: HostOutcome,
2853        host_calls: &Arc<AtomicUsize>,
2854        fallback_calls: &Arc<AtomicUsize>,
2855    ) -> TextMeasurementPolicy {
2856        let fallback = TextMeasurementProfile::new(
2857            identity("test.fallback", "v1", &[]),
2858            Arc::new(CountingFallback(Arc::clone(fallback_calls))),
2859        );
2860        TextMeasurementPolicy::host_display_with_fallback(
2861            identity("test.host", "v2", &["browser@stable"]),
2862            Arc::new(CountingHost {
2863                calls: Arc::clone(host_calls),
2864                outcome,
2865            }),
2866            [
2867                TextMeasurementPhase::Layout,
2868                TextMeasurementPhase::ComputedLength,
2869            ],
2870            fallback,
2871        )
2872    }
2873
2874    struct CancellingMissingHost {
2875        calls: Arc<AtomicUsize>,
2876        control: OperationControl,
2877    }
2878
2879    impl HostTextMeasurer for CancellingMissingHost {
2880        fn measure(&self, _request: HostTextMeasurementRequest<'_>) -> HostMeasurementResult {
2881            self.calls.fetch_add(1, Ordering::Relaxed);
2882            self.control.cancel();
2883            Ok(None)
2884        }
2885    }
2886
2887    #[test]
2888    fn host_cancellation_skips_fallback_and_future_primary_calls() {
2889        let control = OperationControl::new();
2890        let host_calls = Arc::new(AtomicUsize::new(0));
2891        let fallback_calls = Arc::new(AtomicUsize::new(0));
2892        let fallback = TextMeasurementProfile::new(
2893            identity("test.cancelled-fallback", "v1", &[]),
2894            Arc::new(CountingFallback(Arc::clone(&fallback_calls))),
2895        );
2896        let policy = TextMeasurementPolicy::host_display_with_fallback(
2897            identity("test.cancelling-host", "v1", &[]),
2898            Arc::new(CancellingMissingHost {
2899                calls: Arc::clone(&host_calls),
2900                control: control.clone(),
2901            }),
2902            [TextMeasurementPhase::Layout],
2903            fallback,
2904        );
2905        let session = RenderEnvironment::deterministic()
2906            .with_text_measurement_policy(policy)
2907            .begin_session_with_control(control)
2908            .expect("begin controlled render session");
2909
2910        let measurer =
2911            session.controlled_text_measurer(TextMeasurementPhase::Layout, OperationPhase::Layout);
2912        let measured = measurer.measure("label", &TextStyle::default());
2913        let measured_after_cancellation = measurer.measure("second label", &TextStyle::default());
2914
2915        assert_eq!(measured.width, 0.0);
2916        assert_eq!(measured.height, 0.0);
2917        assert_eq!(measured.line_count, 1);
2918        assert_eq!(measured_after_cancellation.width, 0.0);
2919        assert_eq!(measured_after_cancellation.height, 0.0);
2920        assert_eq!(measured_after_cancellation.line_count, 1);
2921        assert_eq!(host_calls.load(Ordering::Relaxed), 1);
2922        assert_eq!(fallback_calls.load(Ordering::Relaxed), 0);
2923        assert!(matches!(
2924            session.checkpoint(OperationPhase::Layout),
2925            Err(crate::Error::Cancelled(_))
2926        ));
2927    }
2928
2929    #[test]
2930    fn host_success_and_each_fallback_reason_are_recorded_from_actual_calls() {
2931        let scenarios = [
2932            (HostOutcome::Measured(metrics(73.0)), None, 73.0),
2933            (
2934                HostOutcome::Length(73.0),
2935                Some(HostFallbackReason::Invalid),
2936                41.0,
2937            ),
2938            (
2939                HostOutcome::Missing,
2940                Some(HostFallbackReason::Missing),
2941                41.0,
2942            ),
2943            (
2944                HostOutcome::Measured(TextMetrics {
2945                    width: f64::NAN,
2946                    height: 10.0,
2947                    line_count: 1,
2948                }),
2949                Some(HostFallbackReason::Invalid),
2950                41.0,
2951            ),
2952            (
2953                HostOutcome::Invalid,
2954                Some(HostFallbackReason::Invalid),
2955                41.0,
2956            ),
2957            (HostOutcome::Error, Some(HostFallbackReason::Error), 41.0),
2958        ];
2959
2960        for (outcome, expected_reason, expected_width) in scenarios {
2961            let host_calls = Arc::new(AtomicUsize::new(0));
2962            let fallback_calls = Arc::new(AtomicUsize::new(0));
2963            let environment = RenderEnvironment::deterministic()
2964                .with_text_measurement_policy(host_policy(outcome, &host_calls, &fallback_calls));
2965            let session = environment.begin_session().expect("begin render session");
2966            let measured = session
2967                .text_measurer(TextMeasurementPhase::Layout)
2968                .measure("label", &TextStyle::default());
2969
2970            assert_eq!(measured.width, expected_width);
2971            assert_eq!(host_calls.load(Ordering::Relaxed), 1);
2972            assert_eq!(
2973                fallback_calls.load(Ordering::Relaxed),
2974                usize::from(expected_reason.is_some())
2975            );
2976            let report = session.text_measurement_report();
2977            assert_eq!(report.entries().len(), 1);
2978            assert_eq!(
2979                report.entries()[0].provenance().fallback_reason,
2980                expected_reason
2981            );
2982        }
2983    }
2984
2985    struct StyleCapturingHost {
2986        observed_font_style: Arc<Mutex<Option<String>>>,
2987    }
2988
2989    impl HostTextMeasurer for StyleCapturingHost {
2990        fn measure(&self, request: HostTextMeasurementRequest<'_>) -> HostMeasurementResult {
2991            if request.operation != TextMeasurementOperation::Wrapped {
2992                return Ok(None);
2993            }
2994            *self
2995                .observed_font_style
2996                .lock()
2997                .expect("font style probe lock") = request.style.font_style.clone();
2998            Ok(Some(HostTextMeasurement::Metrics(metrics(73.25))))
2999        }
3000    }
3001
3002    #[test]
3003    fn host_success_receives_italic_style_without_fallback_adjustment() {
3004        let observed_font_style = Arc::new(Mutex::new(None));
3005        let fallback_calls = Arc::new(AtomicUsize::new(0));
3006        let fallback = TextMeasurementProfile::new(
3007            identity("test.italic-fallback", "v1", &[]),
3008            Arc::new(CountingFallback(Arc::clone(&fallback_calls))),
3009        );
3010        let policy = TextMeasurementPolicy::host_display_with_fallback(
3011            identity("test.italic-host", "v1", &[]),
3012            Arc::new(StyleCapturingHost {
3013                observed_font_style: Arc::clone(&observed_font_style),
3014            }),
3015            [TextMeasurementPhase::Wrap],
3016            fallback,
3017        );
3018        let environment = RenderEnvironment::deterministic().with_text_measurement_policy(policy);
3019        let session = environment.begin_session().expect("begin render session");
3020        let style = TextStyle {
3021            font_style: Some("italic".to_string()),
3022            ..TextStyle::default()
3023        };
3024
3025        let measured = session
3026            .text_measurer(TextMeasurementPhase::Layout)
3027            .measure_wrapped("italic label", &style, Some(200.0), WrapMode::HtmlLike);
3028
3029        assert_eq!(measured.width, 73.25);
3030        assert_eq!(
3031            observed_font_style
3032                .lock()
3033                .expect("font style probe lock")
3034                .as_deref(),
3035            Some("italic")
3036        );
3037        assert_eq!(fallback_calls.load(Ordering::Relaxed), 0);
3038    }
3039
3040    #[test]
3041    fn operation_specific_length_is_authoritative_and_invalid_lengths_fallback() {
3042        let host_calls = Arc::new(AtomicUsize::new(0));
3043        let fallback_calls = Arc::new(AtomicUsize::new(0));
3044        let environment = RenderEnvironment::deterministic().with_text_measurement_policy(
3045            host_policy(HostOutcome::Length(73.0), &host_calls, &fallback_calls),
3046        );
3047        let session = environment.begin_session().expect("begin render session");
3048
3049        assert_eq!(
3050            session
3051                .text_measurer(TextMeasurementPhase::Layout)
3052                .measure_svg_text_computed_length_px("label", &TextStyle::default()),
3053            73.0
3054        );
3055        assert_eq!(host_calls.load(Ordering::Relaxed), 1);
3056        assert_eq!(fallback_calls.load(Ordering::Relaxed), 0);
3057        let report = session.text_measurement_report();
3058        assert_eq!(
3059            report.entries()[0].provenance().operation,
3060            TextMeasurementOperation::ComputedLength
3061        );
3062        assert_eq!(
3063            report.entries()[0].provenance().source,
3064            TextMeasurementSource::Host
3065        );
3066        assert_eq!(report.entries()[0].provenance().fallback_reason, None);
3067
3068        for invalid_length in [-1.0, f64::NAN] {
3069            let host_calls = Arc::new(AtomicUsize::new(0));
3070            let fallback_calls = Arc::new(AtomicUsize::new(0));
3071            let environment =
3072                RenderEnvironment::deterministic().with_text_measurement_policy(host_policy(
3073                    HostOutcome::Length(invalid_length),
3074                    &host_calls,
3075                    &fallback_calls,
3076                ));
3077            let session = environment.begin_session().expect("render session");
3078
3079            assert_eq!(
3080                session
3081                    .text_measurer(TextMeasurementPhase::ComputedLength)
3082                    .measure_svg_text_computed_length_px("label", &TextStyle::default()),
3083                42.0
3084            );
3085            assert_eq!(host_calls.load(Ordering::Relaxed), 1);
3086            assert_eq!(fallback_calls.load(Ordering::Relaxed), 1);
3087            assert_eq!(
3088                session.text_measurement_report().entries()[0]
3089                    .provenance()
3090                    .fallback_reason,
3091                Some(HostFallbackReason::Invalid)
3092            );
3093        }
3094    }
3095
3096    struct ExtentHost {
3097        calls: Arc<AtomicUsize>,
3098        value: (f64, f64),
3099    }
3100
3101    impl HostTextMeasurer for ExtentHost {
3102        fn measure(&self, request: HostTextMeasurementRequest<'_>) -> HostMeasurementResult {
3103            if request.operation != TextMeasurementOperation::BBoxX {
3104                return Ok(None);
3105            }
3106            self.calls.fetch_add(1, Ordering::Relaxed);
3107            Ok(Some(HostTextMeasurement::HorizontalExtents {
3108                left: self.value.0,
3109                right: self.value.1,
3110            }))
3111        }
3112    }
3113
3114    struct ExtentFallback(Arc<AtomicUsize>);
3115
3116    impl TextMeasurer for ExtentFallback {
3117        fn measure(&self, _text: &str, _style: &TextStyle) -> TextMetrics {
3118            metrics(1.0)
3119        }
3120
3121        fn measure_svg_text_bbox_x(&self, _text: &str, _style: &TextStyle) -> (f64, f64) {
3122            self.0.fetch_add(1, Ordering::Relaxed);
3123            (3.0, 4.0)
3124        }
3125    }
3126
3127    #[test]
3128    fn host_bbox_accepts_non_negative_extents_and_rejects_invalid_values() {
3129        for (host_value, expected, expected_source, expected_reason) in [
3130            ((1.5, 12.0), (1.5, 12.0), TextMeasurementSource::Host, None),
3131            (
3132                (-1.5, 12.0),
3133                (3.0, 4.0),
3134                TextMeasurementSource::Profile,
3135                Some(HostFallbackReason::Invalid),
3136            ),
3137            (
3138                (12.0, -1.5),
3139                (3.0, 4.0),
3140                TextMeasurementSource::Profile,
3141                Some(HostFallbackReason::Invalid),
3142            ),
3143            (
3144                (f64::NAN, 12.0),
3145                (3.0, 4.0),
3146                TextMeasurementSource::Profile,
3147                Some(HostFallbackReason::Invalid),
3148            ),
3149        ] {
3150            let host_calls = Arc::new(AtomicUsize::new(0));
3151            let fallback_calls = Arc::new(AtomicUsize::new(0));
3152            let fallback = TextMeasurementProfile::new(
3153                identity("test.extent-fallback", "v1", &[]),
3154                Arc::new(ExtentFallback(Arc::clone(&fallback_calls))),
3155            );
3156            let policy = TextMeasurementPolicy::host_display_with_fallback(
3157                identity("test.extent-host", "v1", &[]),
3158                Arc::new(ExtentHost {
3159                    calls: Arc::clone(&host_calls),
3160                    value: host_value,
3161                }),
3162                [TextMeasurementPhase::SvgBBox],
3163                fallback,
3164            );
3165            let session = RenderEnvironment::deterministic()
3166                .with_text_measurement_policy(policy)
3167                .begin_session()
3168                .expect("render session");
3169
3170            assert_eq!(
3171                session
3172                    .text_measurer(TextMeasurementPhase::SvgBBox)
3173                    .measure_svg_text_bbox_x("A", &TextStyle::default()),
3174                expected
3175            );
3176            assert_eq!(host_calls.load(Ordering::Relaxed), 1);
3177            assert_eq!(
3178                fallback_calls.load(Ordering::Relaxed),
3179                usize::from(expected_reason.is_some())
3180            );
3181            let report = session.text_measurement_report();
3182            assert_eq!(report.entries().len(), 1);
3183            assert_eq!(report.entries()[0].provenance().source, expected_source);
3184            assert_eq!(
3185                report.entries()[0].provenance().fallback_reason,
3186                expected_reason
3187            );
3188        }
3189    }
3190
3191    #[test]
3192    fn session_exposes_services_and_derives_a_nonzero_render_seed() {
3193        let limits = RenderResourcePolicy::trusted_native();
3194        let environment = RenderEnvironment::deterministic()
3195            .with_runtime_policy(RuntimePolicy::deterministic().with_fixed_seed(0))
3196            .with_math_renderer(Arc::new(crate::math::NoopMathRenderer))
3197            .with_icon_registry(crate::svg::IconRegistryBuilder::new().build().unwrap())
3198            .with_resource_policy(limits);
3199
3200        let session = environment.begin_session().expect("begin render session");
3201        assert_eq!(session.operation_context().seed(), 0);
3202        assert_eq!(
3203            session.render_seed(),
3204            session
3205                .operation_context()
3206                .derive_nonzero_u64("render.root", 0)
3207        );
3208        assert_eq!(session.resource_policy(), limits);
3209        assert!(session.math_renderer().is_some());
3210        assert!(session.icon_registry().is_some());
3211        assert_eq!(session.report().operation_context().seed(), 0);
3212    }
3213
3214    #[cfg(feature = "math")]
3215    #[test]
3216    fn without_math_renderer_disables_the_compiled_default() {
3217        let session = RenderEnvironment::deterministic()
3218            .without_math_renderer()
3219            .begin_session()
3220            .expect("begin render session");
3221
3222        assert!(session.math_renderer().is_none());
3223    }
3224
3225    #[test]
3226    fn capability_policy_masks_installed_services_and_compiled_backends() {
3227        let session = RenderEnvironment::deterministic()
3228            .with_math_renderer(Arc::new(crate::math::NoopMathRenderer))
3229            .with_capability_policy(RenderCapabilityPolicy::deny_all())
3230            .begin_session()
3231            .expect("begin render session");
3232
3233        assert!(!session.supports_capability(RenderCapability::LayoutCytoscape));
3234        assert!(!session.supports_capability(RenderCapability::LayoutElk));
3235        assert!(!session.supports_capability(RenderCapability::Math));
3236        assert!(session.math_renderer().is_none());
3237
3238        let session = RenderEnvironment::deterministic()
3239            .with_math_renderer(Arc::new(crate::math::NoopMathRenderer))
3240            .with_capability_policy(
3241                RenderCapabilityPolicy::deny_all().with_allowed(RenderCapability::Math),
3242            )
3243            .begin_session()
3244            .expect("begin render session");
3245        assert!(session.supports_capability(RenderCapability::Math));
3246        assert!(session.math_renderer().is_some());
3247    }
3248}