1use 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#[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#[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#[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#[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#[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#[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#[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 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#[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
465pub enum HostFallbackReason {
466 Missing,
467 Invalid,
468 Error,
469}
470
471pub use crate::generated::text_measurement_abi::{
475 TEXT_MEASUREMENT_PROTOCOL_VERSION, TextMeasurementOperation, TextMeasurementResultKind,
476};
477
478#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
480pub enum TextMeasurementSource {
481 Profile,
482 Host,
483}
484
485#[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#[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#[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#[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 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 #[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
705pub trait HostTextMeasurer: Send + Sync {
711 fn measure(&self, request: HostTextMeasurementRequest<'_>) -> HostMeasurementResult;
712
713 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#[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#[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
833pub 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 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 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 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
1467pub 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#[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 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 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 pub const fn with_capability_policy(mut self, policy: RenderCapabilityPolicy) -> Self {
1606 self.capability_policy = policy;
1607 self
1608 }
1609
1610 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 pub fn begin_session(&self) -> Result<RenderSession, RuntimePolicyError> {
1650 self.begin_session_with_control(OperationControl::new())
1651 }
1652
1653 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 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
1698pub struct RenderSession {
1700 text_measurement: TextMeasurementPolicy,
1701 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 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 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 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#[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 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 (
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 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 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}