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