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