Skip to main content

mathtex_engine/
session.rs

1use alloc::format;
2use alloc::string::{String, ToString};
3
4use mathtex_font::{
5    FontData, FontError, FontQuery, FontSystem, NoFontSystem, ShapeRequest, ShapedText,
6};
7use mathtex_ir::{
8    Fragment, FragmentKind, FragmentMetadata,
9};
10
11use crate::emit::IrEmitter;
12use crate::format::{FormatImage, FormatResourceKind, SessionState};
13use crate::generated::{
14    generated_node_to_fragment, GeneratedFontSystemAdapter, GeneratedFormatCache,
15    GeneratedPlatformAdapter, GeneratedResourceProvider,
16};
17use crate::platform::{LimitError, Platform};
18use crate::primitive::{PrimitiveEntry, PrimitiveRegistry, PrimitiveRegistryError};
19use crate::profile::{EngineKind, EngineProfile, EngineSemantics};
20use crate::resource::{Resource, ResourceError, ResourceKind, ResourceProvider, ResourceRequest};
21
22/// Engine initialized from a format image with associated resource, platform, and font boundaries.
23#[derive(Clone, Debug)]
24pub struct Engine<P, R, H, F = NoFontSystem> {
25    profile: P,
26    semantics: EngineSemantics,
27    format: FormatImage,
28    generated_format: GeneratedFormatCache,
29    resources: R,
30    platform: H,
31    fonts: F,
32    primitives: PrimitiveRegistry,
33}
34
35impl<P, R, H> Engine<P, R, H>
36where
37    P: EngineProfile,
38    R: ResourceProvider,
39    H: Platform,
40{
41    /// Constructs an engine without a font system.
42    pub fn new(
43        profile: P,
44        format: FormatImage,
45        resources: R,
46        platform: H,
47    ) -> Result<Self, BuildError> {
48        let semantics = EngineSemantics::from_profile(&profile);
49        let primitives = PrimitiveRegistry::from_specs(semantics.primitives())
50            .map_err(BuildError::InvalidPrimitives)?;
51        let generated_format = generated_format_for(&semantics, &format, &resources)
52            .map_err(BuildError::FormatPreloadFailed)?;
53        Ok(Self {
54            profile,
55            semantics,
56            generated_format,
57            format,
58            resources,
59            platform,
60            fonts: NoFontSystem,
61            primitives,
62        })
63    }
64}
65
66impl<P, R, H, F> Engine<P, R, H, F>
67where
68    P: EngineProfile,
69    R: ResourceProvider,
70    H: Platform,
71    F: FontSystem,
72{
73    /// Constructs an engine with an explicit font system.
74    pub fn new_with_fonts(
75        profile: P,
76        format: FormatImage,
77        resources: R,
78        platform: H,
79        fonts: F,
80    ) -> Result<Self, BuildError> {
81        let semantics = EngineSemantics::from_profile(&profile);
82        let primitives = PrimitiveRegistry::from_specs(semantics.primitives())
83            .map_err(BuildError::InvalidPrimitives)?;
84        let generated_format = generated_format_for(&semantics, &format, &resources)
85            .map_err(BuildError::FormatPreloadFailed)?;
86        Ok(Self {
87            profile,
88            semantics,
89            generated_format,
90            format,
91            resources,
92            platform,
93            fonts,
94            primitives,
95        })
96    }
97
98    /// Returns a staged builder for this engine profile.
99    pub fn builder(profile: P) -> EngineBuilder<P, (), (), NoFontSystem> {
100        EngineBuilder::new(profile)
101    }
102
103    /// Create a session, the session holds a clone of the mutable format state.
104    #[must_use]
105    pub fn new_session(&self) -> EngineSession<'_, P, R, H, F> {
106        EngineSession {
107            engine: self,
108            state: self.format.instantiate_session_state(),
109        }
110    }
111
112    /// Returns the engine profile.
113    #[must_use]
114    pub fn profile(&self) -> &P {
115        &self.profile
116    }
117
118    /// Computed semantics derived from the engine profile.
119    #[must_use]
120    pub fn semantics(&self) -> &EngineSemantics {
121        &self.semantics
122    }
123
124    /// Returns the loaded format image.
125    #[must_use]
126    pub fn format(&self) -> &FormatImage {
127        &self.format
128    }
129
130    /// Preloaded generated engine format image.
131    #[must_use]
132    pub fn generated_format(&self) -> &GeneratedFormatCache {
133        &self.generated_format
134    }
135
136    /// Returns the resource provider.
137    #[must_use]
138    pub fn resources(&self) -> &R {
139        &self.resources
140    }
141
142    /// Returns the platform adapter.
143    #[must_use]
144    pub fn platform(&self) -> &H {
145        &self.platform
146    }
147
148    /// Returns the font system.
149    #[must_use]
150    pub fn fonts(&self) -> &F {
151        &self.fonts
152    }
153
154    /// Primitive dispatch table for this engine profile.
155    #[must_use]
156    pub fn primitives(&self) -> &PrimitiveRegistry {
157        &self.primitives
158    }
159}
160
161/// Staged builder for constructing an engine with typed resource, platform, and font parameters.
162#[derive(Clone, Debug)]
163#[must_use]
164pub struct EngineBuilder<P, R, H, F = NoFontSystem> {
165    profile: P,
166    format: Option<FormatImage>,
167    resources: Option<R>,
168    platform: Option<H>,
169    fonts: F,
170}
171
172impl<P> EngineBuilder<P, (), (), NoFontSystem> {
173    /// Creates a new builder for an engine with the given profile.
174    pub fn new(profile: P) -> Self {
175        Self {
176            profile,
177            format: None,
178            resources: None,
179            platform: None,
180            fonts: NoFontSystem,
181        }
182    }
183}
184
185impl<P, R, H, F> EngineBuilder<P, R, H, F> {
186    /// Sets the format image for the engine being built.
187    pub fn format(mut self, format: FormatImage) -> Self {
188        self.format = Some(format);
189        self
190    }
191
192    /// Sets the resource provider for the engine being built.
193    pub fn resources<NextR>(self, resources: NextR) -> EngineBuilder<P, NextR, H, F> {
194        EngineBuilder {
195            profile: self.profile,
196            format: self.format,
197            resources: Some(resources),
198            platform: self.platform,
199            fonts: self.fonts,
200        }
201    }
202
203    /// Sets the platform adapter for the engine being built.
204    pub fn platform<NextH>(self, platform: NextH) -> EngineBuilder<P, R, NextH, F> {
205        EngineBuilder {
206            profile: self.profile,
207            format: self.format,
208            resources: self.resources,
209            platform: Some(platform),
210            fonts: self.fonts,
211        }
212    }
213
214    /// Sets the font system for the engine being built.
215    pub fn fonts<NextF>(self, fonts: NextF) -> EngineBuilder<P, R, H, NextF> {
216        EngineBuilder {
217            profile: self.profile,
218            format: self.format,
219            resources: self.resources,
220            platform: self.platform,
221            fonts,
222        }
223    }
224}
225
226impl<P, R, H, F> EngineBuilder<P, R, H, F>
227where
228    P: EngineProfile,
229    R: ResourceProvider,
230    H: Platform,
231    F: FontSystem,
232{
233    /// Validates the builder state and constructs an engine.
234    pub fn build(self) -> Result<Engine<P, R, H, F>, BuildError> {
235        let format = self.format.ok_or(BuildError::MissingFormat)?;
236        if format.profile != self.profile.id() {
237            return Err(BuildError::ProfileFormatMismatch {
238                profile: self.profile.id().0,
239                format_profile: format.profile.0,
240            });
241        }
242
243        let resources = self.resources.expect("resources are required by type");
244        let platform = self.platform.expect("platform is required by type");
245
246        Engine::new_with_fonts(self.profile, format, resources, platform, self.fonts)
247    }
248}
249
250/// Per fragment execution state cloned from the cached format at session creation.
251#[derive(Clone, Debug)]
252pub struct EngineSession<'a, P, R, H, F = NoFontSystem> {
253    engine: &'a Engine<P, R, H, F>,
254    state: SessionState,
255}
256
257impl<P, R, H, F> EngineSession<'_, P, R, H, F>
258where
259    P: EngineProfile,
260    R: ResourceProvider,
261    H: Platform,
262    F: FontSystem,
263{
264    /// Typesets an inline math string and returns an IR fragment.
265    pub fn layout_fragment(&mut self, input: &str) -> Result<Fragment, EngineError> {
266        self.layout_fragment_input(FragmentInput::math_inline(input))
267    }
268
269    /// Typesets the given fragment input and returns an IR fragment.
270    pub fn layout_fragment_input(&mut self, input: FragmentInput) -> Result<Fragment, EngineError> {
271        self.check_input_limits(input.body.as_str())?;
272        let generated_source = GeneratedFragmentSource::new(&input);
273        let metadata = FragmentMetadata {
274            engine_profile: self.engine.semantics.profile.0.to_string(),
275            format_id: self.engine.format.id.clone(),
276            fragment_kind: input.kind,
277        };
278
279        let mut generated = self.generated_engine();
280        if !generated.begin_primary_input(
281            input.source_name.as_str(),
282            generated_source.tex.clone().into_bytes(),
283        ) {
284            return Err(EngineError::Layout {
285                message: "generated TeX engine rejected primary input".to_string(),
286            });
287        }
288        generated.begin_fragment_capture();
289        let ran_main_control = generated.run_main_control();
290        generated.end_fragment_capture();
291        if !ran_main_control {
292            // A surfaced TeX error carries a captured message, a bare fatal abort does not.
293            let message = match generated.last_error_message() {
294                Some(error) => format!("generated TeX engine reported an error: {error}"),
295                None => generated_abort_error(generated.last_abort_status()),
296            };
297            return Err(EngineError::Layout { message });
298        }
299
300        self.check_resource_request_limits(generated.resource_request_count())?;
301        if let Some(message) = generated_transcript_error(generated.transcript_bytes()) {
302            return Err(EngineError::Layout { message });
303        }
304
305        let root = generated
306            .captured_fragment_root()
307            .ok_or_else(|| EngineError::Layout {
308                message: "generated TeX engine did not capture a fragment root".to_string(),
309            })?;
310        let fragment =
311            generated_node_to_fragment(&generated, root, metadata).ok_or_else(|| {
312                EngineError::Layout {
313                    message: "generated TeX engine could not convert captured fragment root"
314                        .to_string(),
315                }
316            })?;
317        self.check_layout_node_limits(fragment.nodes.len())?;
318        Ok(fragment)
319    }
320
321    /// Instantiate the generated engine seeded from cached format state.
322    #[must_use]
323    pub fn generated_engine(&self) -> mathtex_portable_engine_generated::PortableTexEngine<'_> {
324        self.engine
325            .generated_format
326            .instantiate(
327                generated_profile_for(&self.engine.semantics),
328                GeneratedResourceProvider::new(&self.engine.resources),
329            )
330            .with_platform(GeneratedPlatformAdapter::new(&self.engine.platform))
331            .with_font_platform(GeneratedFontSystemAdapter::new(&self.engine.fonts))
332    }
333
334    /// Reads a TeX input file by name from the resource provider.
335    pub fn load_input(&self, name: &str) -> Result<Resource, ResourceError> {
336        self.engine.resources.read(name, ResourceKind::TexInput)
337    }
338
339    /// Reads a TeX package by name from the resource provider.
340    pub fn load_package(&self, name: &str) -> Result<Resource, ResourceError> {
341        self.engine.resources.read(name, ResourceKind::Package)
342    }
343
344    /// Reads a LaTeX document class by name from the resource provider.
345    pub fn load_class(&self, name: &str) -> Result<Resource, ResourceError> {
346        self.engine.resources.read_class(name)
347    }
348
349    /// Reads a font definition file by name from the resource provider.
350    pub fn load_font_definition(&self, name: &str) -> Result<Resource, ResourceError> {
351        self.engine.resources.read_font_definition(name)
352    }
353
354    /// Reads a package support file by name from the resource provider.
355    pub fn load_package_support(&self, name: &str) -> Result<Resource, ResourceError> {
356        self.engine.resources.read_package_support(name)
357    }
358
359    /// Reads a font file by name from the resource provider.
360    pub fn load_font_resource(&self, name: &str) -> Result<Resource, ResourceError> {
361        self.engine.resources.read_font(name)
362    }
363
364    /// Reads a font encoding file by name from the resource provider.
365    pub fn load_encoding(&self, name: &str) -> Result<Resource, ResourceError> {
366        self.engine.resources.read_encoding(name)
367    }
368
369    /// Reads a font map file by name from the resource provider.
370    pub fn load_map(&self, name: &str) -> Result<Resource, ResourceError> {
371        self.engine.resources.read_map(name)
372    }
373
374    /// Reads a TeX configuration resource by name from the resource provider.
375    pub fn load_config(&self, name: &str) -> Result<Resource, ResourceError> {
376        self.engine.resources.read_config(name)
377    }
378
379    /// Reads a package asset from the resource provider.
380    pub fn load_asset(&self, package: &str, name: &str) -> Result<Resource, ResourceError> {
381        self.engine.resources.read_asset(package, name)
382    }
383
384    /// Reads a format image resource by name from the resource provider.
385    pub fn load_format_image(&self, name: &str) -> Result<Resource, ResourceError> {
386        self.engine.resources.read_format_image(name)
387    }
388
389    /// Loads a font matching the given query from the engine's font system.
390    pub fn load_font(&self, query: &FontQuery) -> Result<FontData, FontError> {
391        self.engine.fonts.load_font(query)
392    }
393
394    /// Shapes a text run via the engine's font system.
395    pub fn shape_text(&self, request: &ShapeRequest<'_>) -> Result<ShapedText, FontError> {
396        self.engine.fonts.shape_text(request)
397    }
398
399    /// Returns the primitive dispatch entry for a control sequence name.
400    #[must_use]
401    pub fn primitive(&self, name: &str) -> Option<&PrimitiveEntry> {
402        self.engine.primitives.get(name)
403    }
404
405    /// Computed semantics for this session.
406    #[must_use]
407    pub fn semantics(&self) -> &EngineSemantics {
408        &self.engine.semantics
409    }
410
411    /// IR emitter seeded with session metadata, defaults to inline math fragment kind.
412    #[must_use]
413    pub fn ir_emitter(&self) -> IrEmitter {
414        IrEmitter::new(FragmentMetadata {
415            engine_profile: self.engine.semantics.profile.0.to_string(),
416            format_id: self.engine.format.id.clone(),
417            fragment_kind: FragmentKind::MathInline,
418        })
419    }
420
421    /// IR emitter seeded with session metadata and the given fragment kind.
422    #[must_use]
423    pub fn ir_emitter_for(&self, kind: FragmentKind) -> IrEmitter {
424        IrEmitter::new(FragmentMetadata {
425            engine_profile: self.engine.semantics.profile.0.to_string(),
426            format_id: self.engine.format.id.clone(),
427            fragment_kind: kind,
428        })
429    }
430
431    /// Session local state cloned from the cached format at session creation.
432    #[must_use]
433    pub fn state(&self) -> &SessionState {
434        &self.state
435    }
436
437    /// Mutable access to the session's local state.
438    #[must_use]
439    pub fn state_mut(&mut self) -> &mut SessionState {
440        &mut self.state
441    }
442
443    fn check_input_limits(&self, input: &str) -> Result<(), EngineError> {
444        let actual = input.len();
445        let limit = self.engine.platform.limits().max_input_bytes;
446        if actual > limit {
447            return Err(EngineError::Limit(LimitError::InputTooLarge {
448                actual,
449                limit,
450            }));
451        }
452        Ok(())
453    }
454
455    fn check_resource_request_limits(&self, actual: usize) -> Result<(), EngineError> {
456        let limit = self.engine.platform.limits().max_resource_requests;
457        if actual > limit {
458            return Err(EngineError::Limit(LimitError::TooManyResourceRequests {
459                actual,
460                limit,
461            }));
462        }
463        Ok(())
464    }
465
466    fn check_layout_node_limits(&self, actual: usize) -> Result<(), EngineError> {
467        let limit = self.engine.platform.limits().max_layout_nodes;
468        if actual > limit {
469            return Err(EngineError::Limit(LimitError::TooManyLayoutNodes {
470                actual,
471                limit,
472            }));
473        }
474        Ok(())
475    }
476}
477
478fn generated_profile_for(
479    semantics: &EngineSemantics,
480) -> mathtex_portable_engine_generated::EngineProfile {
481    mathtex_portable_engine_generated::EngineProfile {
482        id: semantics.profile.0,
483        kind: match semantics.kind {
484            EngineKind::Tex => mathtex_portable_engine_generated::EngineProfileKind::Tex,
485            EngineKind::Etex => mathtex_portable_engine_generated::EngineProfileKind::Etex,
486            EngineKind::Xetex => mathtex_portable_engine_generated::EngineProfileKind::Xetex,
487        },
488        etex: semantics.extensions.etex,
489        xetex: semantics.extensions.xetex,
490        unicode_scalars: semantics.catcodes.unicode_scalars,
491        unicode_math: semantics.mathcodes.unicode_math,
492        native_fonts: semantics.fonts.host_native_fonts,
493    }
494}
495
496fn generated_format_for<R>(
497    semantics: &EngineSemantics,
498    format: &FormatImage,
499    resources: &R,
500) -> Result<GeneratedFormatCache, FormatPreloadError>
501where
502    R: ResourceProvider,
503{
504    let profile = generated_profile_for(semantics);
505    let base = GeneratedFormatCache::initialized(profile);
506    if !format_has_generated_preload_resources(format) {
507        return Ok(base);
508    }
509
510    let provider = CachedGeneratedFormatResources { format, resources };
511    let mut engine = base.instantiate(profile, GeneratedResourceProvider::new(provider));
512
513    for resource in generated_format_preload_resources(format) {
514        let mut bytes = Vec::with_capacity(resource.bytes.len() + GENERATED_FORMAT_FINALIZER.len());
515        bytes.extend_from_slice(resource.bytes.as_slice());
516        bytes.extend_from_slice(GENERATED_FORMAT_FINALIZER);
517        if !engine.begin_primary_input(resource.name.as_str(), bytes) {
518            return Err(FormatPreloadError::InputRejected {
519                name: resource.name.clone(),
520            });
521        }
522        let completed = engine.run_format_initialization();
523        if let Some(request) = generated_failed_resource_request(&engine) {
524            return Err(FormatPreloadError::Resource {
525                name: resource.name.clone(),
526                resource: request.name.clone(),
527            });
528        }
529        if let Some(message) = generated_transcript_error(engine.transcript_bytes()) {
530            return Err(FormatPreloadError::Transcript {
531                name: resource.name.clone(),
532                message,
533            });
534        }
535        if !completed {
536            if let Some(message) = generated_transcript_abort_error(engine.transcript_bytes()) {
537                return Err(FormatPreloadError::Transcript {
538                    name: resource.name.clone(),
539                    message,
540                });
541            }
542            return Err(FormatPreloadError::Aborted {
543                name: resource.name.clone(),
544                status: engine.last_abort_status(),
545            });
546        }
547    }
548    // All packages, hence all \patterns, are loaded. Pack the trie to drop ~24 MB of builder scratch.
549    engine.finalize_trie();
550    Ok(GeneratedFormatCache::from_engine_owned(engine))
551}
552
553const GENERATED_FORMAT_FINALIZER: &[u8] = br"\dump";
554
555fn generated_failed_resource_request<'a>(
556    engine: &'a mathtex_portable_engine_generated::PortableTexEngine<'_>,
557) -> Option<&'a mathtex_portable_engine_generated::PortableResourceRequestRecord> {
558    engine.resource_request_records().iter().find(|request| {
559        request.byte_len.is_none() && !is_optional_generated_resource_probe(request)
560    })
561}
562
563fn is_optional_generated_resource_probe(
564    request: &mathtex_portable_engine_generated::PortableResourceRequestRecord,
565) -> bool {
566    let basename = generated_resource_probe_basename(request.name.as_str());
567    basename == "texsys.aux" || basename == "babel-texput.cfg"
568}
569
570fn generated_resource_probe_basename(mut name: &str) -> &str {
571    loop {
572        if let Some(stripped) = name.strip_prefix("./") {
573            name = stripped;
574            continue;
575        }
576        if let Some(stripped) = name.strip_prefix("[]") {
577            name = stripped;
578            continue;
579        }
580        if let Some(stripped) = name.strip_prefix(':') {
581            name = stripped;
582            continue;
583        }
584        break name;
585    }
586}
587
588fn generated_format_preload_resources(
589    format: &FormatImage,
590) -> impl Iterator<Item = &crate::format::FormatResource> {
591    match &format.state {
592        crate::format::FormatState::Structured(snapshot) => Some(snapshot.resources.iter()),
593        _ => None,
594    }
595    .into_iter()
596    .flatten()
597    .filter(|resource| {
598        matches!(
599            resource.kind,
600            FormatResourceKind::TexInput | FormatResourceKind::Package
601        )
602    })
603}
604
605fn format_has_generated_preload_resources(format: &FormatImage) -> bool {
606    let crate::format::FormatState::Structured(snapshot) = &format.state else {
607        return false;
608    };
609    snapshot.resources.iter().any(|resource| {
610        matches!(
611            resource.kind,
612            FormatResourceKind::TexInput | FormatResourceKind::Package
613        )
614    })
615}
616
617#[derive(Clone, Copy, Debug)]
618struct CachedGeneratedFormatResources<'a, R> {
619    format: &'a FormatImage,
620    resources: &'a R,
621}
622
623impl<R> ResourceProvider for CachedGeneratedFormatResources<'_, R>
624where
625    R: ResourceProvider,
626{
627    fn read_request(&self, request: &ResourceRequest) -> Result<Resource, ResourceError> {
628        if matches!(request.kind, ResourceKind::TexInput | ResourceKind::Package) {
629            if let Some(resource) = cached_format_resource(self.format, request) {
630                return Ok(resource);
631            }
632        }
633        self.resources.read_request(request)
634    }
635}
636
637fn cached_format_resource(format: &FormatImage, request: &ResourceRequest) -> Option<Resource> {
638    let crate::format::FormatState::Structured(snapshot) = &format.state else {
639        return None;
640    };
641    snapshot
642        .resources
643        .iter()
644        .find(|resource| {
645            format_resource_kind_to_resource_kind(resource.kind) == Some(request.kind)
646                && resource.name == request.canonical_name()
647        })
648        .map(|resource| Resource {
649            canonical_name: resource.name.clone(),
650            kind: request.kind,
651            bytes: resource.bytes.clone(),
652        })
653}
654
655fn format_resource_kind_to_resource_kind(kind: FormatResourceKind) -> Option<ResourceKind> {
656    match kind {
657        FormatResourceKind::TexInput => Some(ResourceKind::TexInput),
658        FormatResourceKind::Package => Some(ResourceKind::Package),
659    }
660}
661
662struct GeneratedFragmentSource {
663    tex: String,
664}
665
666impl GeneratedFragmentSource {
667    fn new(input: &FragmentInput) -> Self {
668        let mut tex = String::with_capacity(input.body.len() + 96);
669        match input.kind {
670            FragmentKind::Text => {
671                tex.push_str(r"\hbox{");
672            }
673            FragmentKind::MathInline => {
674                tex.push_str(r"\hbox{$");
675            }
676            FragmentKind::MathDisplay => {
677                tex.push_str(r"\hbox{$\displaystyle ");
678            }
679            _ => {
680                tex.push_str(r"\hbox{");
681            }
682        }
683
684        tex.push_str(input.body.as_str());
685
686        // Invoke primitive end under LaTeX, this is a harmless `\relax` under plain TeX.
687        match input.kind {
688            FragmentKind::Text => tex.push_str(r"\relax}\csname @@end\endcsname\end"),
689            FragmentKind::MathInline | FragmentKind::MathDisplay => {
690                tex.push_str(r"\relax$}\csname @@end\endcsname\end")
691            }
692            _ => tex.push_str(r"\relax}\csname @@end\endcsname\end"),
693        }
694
695        Self { tex }
696    }
697}
698
699fn generated_transcript_error(transcript: &[u8]) -> Option<String> {
700    let transcript = core::str::from_utf8(transcript).ok()?;
701    for (error_start, _) in transcript.match_indices('!').rev() {
702        let error_tail = &transcript[error_start..];
703        let error = error_tail
704            .lines()
705            .next()
706            .unwrap_or("generated TeX engine reported an error")
707            .trim();
708        if !error.starts_with("! ") {
709            continue;
710        }
711        let has_line_context = error_tail
712            .lines()
713            .skip(1)
714            .take(4)
715            .any(|line| line.trim_start().starts_with("l."));
716        let has_compact_line_context = error_tail
717            .get(..error_tail.len().min(512))
718            .is_some_and(|tail| tail.contains("l."));
719        if has_line_context || has_compact_line_context || error.contains("Emergency stop") {
720            return Some(format!("generated TeX engine reported an error: {error}"));
721        }
722    }
723    None
724}
725
726fn generated_transcript_abort_error(transcript: &[u8]) -> Option<String> {
727    let transcript = core::str::from_utf8(transcript).ok()?;
728    transcript
729        .match_indices('!')
730        .rev()
731        .find_map(|(error_start, _)| {
732            let error = transcript[error_start..].lines().next()?.trim();
733            error
734                .starts_with("! ")
735                .then(|| format!("generated TeX engine aborted after error: {error}"))
736        })
737}
738
739fn generated_abort_error(status: Option<i32>) -> String {
740    match status {
741        Some(status) => format!("generated TeX engine aborted with status {status}"),
742        None => "generated TeX engine aborted".to_string(),
743    }
744}
745
746/// Typed fragment input for layout.
747#[derive(Clone, Debug, PartialEq, Eq)]
748pub struct FragmentInput {
749    /// Source name used in source map entries.
750    pub source_name: String,
751    /// TeX source body to be typeset.
752    pub body: String,
753    /// Fragment kind controlling how the body is wrapped and parsed.
754    pub kind: FragmentKind,
755}
756
757impl FragmentInput {
758    /// Inline math input with source name "input".
759    #[must_use]
760    pub fn math_inline(body: impl Into<String>) -> Self {
761        Self::new("input", body, FragmentKind::MathInline)
762    }
763
764    /// Display math input with source name "input".
765    #[must_use]
766    pub fn math_display(body: impl Into<String>) -> Self {
767        Self::new("input", body, FragmentKind::MathDisplay)
768    }
769
770    /// Text input with source name "input".
771    #[must_use]
772    pub fn text(body: impl Into<String>) -> Self {
773        Self::new("input", body, FragmentKind::Text)
774    }
775
776    /// Constructs a fragment input with the given source name, body, and fragment kind.
777    #[must_use]
778    pub fn new(
779        source_name: impl Into<String>,
780        body: impl Into<String>,
781        kind: FragmentKind,
782    ) -> Self {
783        Self {
784            source_name: source_name.into(),
785            body: body.into(),
786            kind,
787        }
788    }
789}
790
791/// Error returned when constructing an engine.
792#[derive(Clone, Debug, PartialEq, Eq)]
793#[non_exhaustive]
794pub enum BuildError {
795    /// No format image was supplied to the builder.
796    MissingFormat,
797    /// The primitive registry rejected the profile's primitive specifications.
798    InvalidPrimitives(PrimitiveRegistryError),
799    /// Format resource preloading failed during engine build.
800    FormatPreloadFailed(FormatPreloadError),
801    /// The format image's profile does not match the requested engine profile.
802    ProfileFormatMismatch {
803        /// Requested profile.
804        profile: &'static str,
805        /// Profile recorded in the format image.
806        format_profile: &'static str,
807    },
808}
809
810/// Error encountered while preloading format resources into the generated engine.
811#[derive(Clone, Debug, PartialEq, Eq)]
812#[non_exhaustive]
813pub enum FormatPreloadError {
814    /// The generated engine rejected the given input file.
815    InputRejected {
816        /// Name of the resource that was rejected.
817        name: String,
818    },
819    /// The generated engine aborted before completing initialization.
820    Aborted {
821        /// Name of the resource being processed when the abort occurred.
822        name: String,
823        /// Exit status from the aborted engine, if available.
824        status: Option<i32>,
825    },
826    /// A required resource could not be loaded during preload.
827    Resource {
828        /// Name of the format resource being processed.
829        name: String,
830        /// Name of the missing resource that was requested.
831        resource: String,
832    },
833    /// The engine transcript contained a TeX error during preload.
834    Transcript {
835        /// Name of the format resource being processed when the error occurred.
836        name: String,
837        /// Error message extracted from the engine transcript.
838        message: String,
839    },
840}
841
842/// Error returned by layout operations.
843#[derive(Clone, Debug, PartialEq, Eq)]
844#[non_exhaustive]
845pub enum EngineError {
846    /// Platform limit exceeded during layout.
847    Limit(LimitError),
848    /// Input could not be parsed.
849    Parse {
850        /// Description of the parse error.
851        message: String,
852    },
853    /// TeX engine reported a layout error.
854    Layout {
855        /// Description of the layout error.
856        message: String,
857    },
858    /// A required resource could not be satisfied.
859    Resource {
860        /// Description of the resource error.
861        message: String,
862    },
863}
864
865#[cfg(test)]
866mod tests {
867    use alloc::borrow::Cow;
868    use alloc::vec::Vec;
869
870    use super::*;
871    use crate::{
872        EngineKind, EnginePatch, EngineProfile, ExtensionPolicy, FormatImage, FormatSnapshot,
873        HostLimits, InMemoryResourceProvider, LimitError, MacroDefinition, NoopPlatform,
874        PrimitiveKind, PrimitiveOpcode, PrimitiveRegistryError, PrimitiveSpec, ProfileId,
875        ResourceFontSystem, TexProfile, XetexProfile,
876    };
877    use mathtex_font::InMemoryFontSystem;
878    use mathtex_ir::{ByteSpan, Direction, FragmentKind, LayoutNodeKind, Length};
879
880    const TEST_FORMAT_INPUT: &str = "mathtex-test-format.tex";
881    const TEST_FORMAT_SETUP: &[u8] = br"\catcode`\{=1 \catcode`\}=2 \catcode`\#=6 \catcode`\$=3 \catcode`\^=7 \catcode`\_=8 \def\usepackage#1{\input #1.sty\relax} \def\text#1{\hbox{#1}} \def\hostbox#1{\Uhostbox #1\relax}";
882    const TEST_MATH_FONT_SETUP: &[u8] = br"\catcode`\{=1 \catcode`\}=2 \catcode`\#=6 \catcode`\$=3 \catcode`\^=7 \catcode`\_=8 \def\usepackage#1{\input #1.sty\relax} \def\text#1{\hbox{#1}} \def\hostbox#1{\Uhostbox #1\relax} \font\tenrm=cmr10 \font\teni=cmmi10 \font\tensy=cmsy10 \font\tenex=cmex10 \textfont0=\tenrm \scriptfont0=\tenrm \scriptscriptfont0=\tenrm \textfont1=\teni \scriptfont1=\teni \scriptscriptfont1=\teni \textfont2=\tensy \scriptfont2=\tensy \scriptscriptfont2=\tensy \textfont3=\tenex \scriptfont3=\tenex \scriptscriptfont3=\tenex";
883
884    fn test_format(
885        id: &'static str,
886        profile: ProfileId,
887        setup: &'static [u8],
888    ) -> (FormatImage, InMemoryResourceProvider) {
889        let resources = InMemoryResourceProvider::new().with_resource(
890            TEST_FORMAT_INPUT,
891            ResourceKind::TexInput,
892            setup,
893        );
894        let format = FormatImage::initializer(id, profile)
895            .preload_tex_input(TEST_FORMAT_INPUT)
896            .build(&resources)
897            .expect("test format should preload setup");
898        (format, resources)
899    }
900
901    fn basic_test_format(profile: ProfileId) -> (FormatImage, InMemoryResourceProvider) {
902        test_format("latex", profile, TEST_FORMAT_SETUP)
903    }
904
905    fn math_test_format(profile: ProfileId) -> (FormatImage, InMemoryResourceProvider) {
906        test_format("plain-math", profile, TEST_MATH_FONT_SETUP)
907    }
908
909    #[derive(Clone, Copy, Debug)]
910    struct PlainFixtureResources;
911
912    impl PlainFixtureResources {
913        const TFMS: &'static [(&'static str, &'static [u8])] = &[
914            (
915                "cmbsy10",
916                include_bytes!("../../../vendor/mathtex-fixtures/plain-tex/tfm/cmbsy10.tfm"),
917            ),
918            (
919                "cmbx10",
920                include_bytes!("../../../vendor/mathtex-fixtures/plain-tex/tfm/cmbx10.tfm"),
921            ),
922            (
923                "cmbx5",
924                include_bytes!("../../../vendor/mathtex-fixtures/plain-tex/tfm/cmbx5.tfm"),
925            ),
926            (
927                "cmbx6",
928                include_bytes!("../../../vendor/mathtex-fixtures/plain-tex/tfm/cmbx6.tfm"),
929            ),
930            (
931                "cmbx7",
932                include_bytes!("../../../vendor/mathtex-fixtures/plain-tex/tfm/cmbx7.tfm"),
933            ),
934            (
935                "cmbx8",
936                include_bytes!("../../../vendor/mathtex-fixtures/plain-tex/tfm/cmbx8.tfm"),
937            ),
938            (
939                "cmbx9",
940                include_bytes!("../../../vendor/mathtex-fixtures/plain-tex/tfm/cmbx9.tfm"),
941            ),
942            (
943                "cmcsc10",
944                include_bytes!("../../../vendor/mathtex-fixtures/plain-tex/tfm/cmcsc10.tfm"),
945            ),
946            (
947                "cmdunh10",
948                include_bytes!("../../../vendor/mathtex-fixtures/plain-tex/tfm/cmdunh10.tfm"),
949            ),
950            (
951                "cmex10",
952                include_bytes!("../../../vendor/mathtex-fixtures/plain-tex/tfm/cmex10.tfm"),
953            ),
954            (
955                "cmmi10",
956                include_bytes!("../../../vendor/mathtex-fixtures/plain-tex/tfm/cmmi10.tfm"),
957            ),
958            (
959                "cmmi5",
960                include_bytes!("../../../vendor/mathtex-fixtures/plain-tex/tfm/cmmi5.tfm"),
961            ),
962            (
963                "cmmi6",
964                include_bytes!("../../../vendor/mathtex-fixtures/plain-tex/tfm/cmmi6.tfm"),
965            ),
966            (
967                "cmmi7",
968                include_bytes!("../../../vendor/mathtex-fixtures/plain-tex/tfm/cmmi7.tfm"),
969            ),
970            (
971                "cmmi8",
972                include_bytes!("../../../vendor/mathtex-fixtures/plain-tex/tfm/cmmi8.tfm"),
973            ),
974            (
975                "cmmi9",
976                include_bytes!("../../../vendor/mathtex-fixtures/plain-tex/tfm/cmmi9.tfm"),
977            ),
978            (
979                "cmmib10",
980                include_bytes!("../../../vendor/mathtex-fixtures/plain-tex/tfm/cmmib10.tfm"),
981            ),
982            (
983                "cmr10",
984                include_bytes!("../../../vendor/mathtex-fixtures/plain-tex/tfm/cmr10.tfm"),
985            ),
986            (
987                "cmr5",
988                include_bytes!("../../../vendor/mathtex-fixtures/plain-tex/tfm/cmr5.tfm"),
989            ),
990            (
991                "cmr6",
992                include_bytes!("../../../vendor/mathtex-fixtures/plain-tex/tfm/cmr6.tfm"),
993            ),
994            (
995                "cmr7",
996                include_bytes!("../../../vendor/mathtex-fixtures/plain-tex/tfm/cmr7.tfm"),
997            ),
998            (
999                "cmr8",
1000                include_bytes!("../../../vendor/mathtex-fixtures/plain-tex/tfm/cmr8.tfm"),
1001            ),
1002            (
1003                "cmr9",
1004                include_bytes!("../../../vendor/mathtex-fixtures/plain-tex/tfm/cmr9.tfm"),
1005            ),
1006            (
1007                "cmsl10",
1008                include_bytes!("../../../vendor/mathtex-fixtures/plain-tex/tfm/cmsl10.tfm"),
1009            ),
1010            (
1011                "cmsl8",
1012                include_bytes!("../../../vendor/mathtex-fixtures/plain-tex/tfm/cmsl8.tfm"),
1013            ),
1014            (
1015                "cmsl9",
1016                include_bytes!("../../../vendor/mathtex-fixtures/plain-tex/tfm/cmsl9.tfm"),
1017            ),
1018            (
1019                "cmsltt10",
1020                include_bytes!("../../../vendor/mathtex-fixtures/plain-tex/tfm/cmsltt10.tfm"),
1021            ),
1022            (
1023                "cmss10",
1024                include_bytes!("../../../vendor/mathtex-fixtures/plain-tex/tfm/cmss10.tfm"),
1025            ),
1026            (
1027                "cmssbx10",
1028                include_bytes!("../../../vendor/mathtex-fixtures/plain-tex/tfm/cmssbx10.tfm"),
1029            ),
1030            (
1031                "cmssi10",
1032                include_bytes!("../../../vendor/mathtex-fixtures/plain-tex/tfm/cmssi10.tfm"),
1033            ),
1034            (
1035                "cmssq8",
1036                include_bytes!("../../../vendor/mathtex-fixtures/plain-tex/tfm/cmssq8.tfm"),
1037            ),
1038            (
1039                "cmssqi8",
1040                include_bytes!("../../../vendor/mathtex-fixtures/plain-tex/tfm/cmssqi8.tfm"),
1041            ),
1042            (
1043                "cmsy10",
1044                include_bytes!("../../../vendor/mathtex-fixtures/plain-tex/tfm/cmsy10.tfm"),
1045            ),
1046            (
1047                "cmsy5",
1048                include_bytes!("../../../vendor/mathtex-fixtures/plain-tex/tfm/cmsy5.tfm"),
1049            ),
1050            (
1051                "cmsy6",
1052                include_bytes!("../../../vendor/mathtex-fixtures/plain-tex/tfm/cmsy6.tfm"),
1053            ),
1054            (
1055                "cmsy7",
1056                include_bytes!("../../../vendor/mathtex-fixtures/plain-tex/tfm/cmsy7.tfm"),
1057            ),
1058            (
1059                "cmsy8",
1060                include_bytes!("../../../vendor/mathtex-fixtures/plain-tex/tfm/cmsy8.tfm"),
1061            ),
1062            (
1063                "cmsy9",
1064                include_bytes!("../../../vendor/mathtex-fixtures/plain-tex/tfm/cmsy9.tfm"),
1065            ),
1066            (
1067                "cmti10",
1068                include_bytes!("../../../vendor/mathtex-fixtures/plain-tex/tfm/cmti10.tfm"),
1069            ),
1070            (
1071                "cmti7",
1072                include_bytes!("../../../vendor/mathtex-fixtures/plain-tex/tfm/cmti7.tfm"),
1073            ),
1074            (
1075                "cmti8",
1076                include_bytes!("../../../vendor/mathtex-fixtures/plain-tex/tfm/cmti8.tfm"),
1077            ),
1078            (
1079                "cmti9",
1080                include_bytes!("../../../vendor/mathtex-fixtures/plain-tex/tfm/cmti9.tfm"),
1081            ),
1082            (
1083                "cmtt10",
1084                include_bytes!("../../../vendor/mathtex-fixtures/plain-tex/tfm/cmtt10.tfm"),
1085            ),
1086            (
1087                "cmtt8",
1088                include_bytes!("../../../vendor/mathtex-fixtures/plain-tex/tfm/cmtt8.tfm"),
1089            ),
1090            (
1091                "cmtt9",
1092                include_bytes!("../../../vendor/mathtex-fixtures/plain-tex/tfm/cmtt9.tfm"),
1093            ),
1094            (
1095                "cmu10",
1096                include_bytes!("../../../vendor/mathtex-fixtures/plain-tex/tfm/cmu10.tfm"),
1097            ),
1098            (
1099                "manfnt",
1100                include_bytes!("../../../vendor/mathtex-fixtures/plain-tex/tfm/manfnt.tfm"),
1101            ),
1102        ];
1103
1104        fn base_name(name: &str) -> &str {
1105            name.rsplit(['/', '\\']).next().unwrap_or(name)
1106        }
1107
1108        fn font_name(name: &str) -> &str {
1109            let name = Self::base_name(name);
1110            name.strip_suffix(".tfm").unwrap_or(name)
1111        }
1112    }
1113
1114    impl ResourceProvider for PlainFixtureResources {
1115        fn read_request(&self, request: &ResourceRequest) -> Result<Resource, ResourceError> {
1116            let name = request.canonical_name();
1117            let base_name = Self::base_name(name.as_str());
1118            let bytes = match request.kind {
1119                ResourceKind::TexInput if base_name == "plain.tex" => Some(
1120                    include_bytes!("../../../vendor/mathtex-fixtures/plain-tex/base/plain.tex")
1121                        .as_slice(),
1122                ),
1123                ResourceKind::TexInput if base_name == "hyphen" || base_name == "hyphen.tex" => {
1124                    Some(
1125                        include_bytes!(
1126                            "../../../vendor/mathtex-fixtures/plain-tex/hyphen/hyphen.tex"
1127                        )
1128                        .as_slice(),
1129                    )
1130                }
1131                ResourceKind::Font => {
1132                    let font_name = Self::font_name(base_name);
1133                    Self::TFMS
1134                        .iter()
1135                        .find_map(|(name, bytes)| (*name == font_name).then_some(*bytes))
1136                }
1137                _ => None,
1138            };
1139
1140            bytes
1141                .map(|bytes| Resource::from_request(request, bytes.to_vec()))
1142                .ok_or_else(|| ResourceError::NotFound {
1143                    name,
1144                    kind: request.kind,
1145                })
1146        }
1147    }
1148
1149    fn assert_real_plain_format_loads<R>(resources: R)
1150    where
1151        R: ResourceProvider + Copy,
1152    {
1153        let profile = TexProfile;
1154        let format = FormatImage::plain(profile.id())
1155            .build(&resources)
1156            .expect("real plain.tex should resolve");
1157        let engine = EngineBuilder::new(profile)
1158            .format(format)
1159            .resources(resources)
1160            .platform(NoopPlatform::default())
1161            .build()
1162            .expect("engine should build with real plain format");
1163        let mut session = engine.new_session();
1164
1165        let fragment = session
1166            .layout_fragment_input(FragmentInput::text(r"\tenrm A"))
1167            .expect("real plain format should define plain font macros");
1168        let has_glyph_node = fragment
1169            .nodes
1170            .iter()
1171            .any(|node| matches!(node.kind, LayoutNodeKind::GlyphRun(_)));
1172
1173        assert_eq!(fragment.metadata.format_id, "plain");
1174        assert!(has_glyph_node);
1175    }
1176
1177    #[cfg(feature = "std")]
1178    #[derive(Clone, Copy, Debug)]
1179    struct HostResolverTestResources;
1180
1181    #[cfg(feature = "std")]
1182    impl HostResolverTestResources {
1183        fn is_available() -> bool {
1184            Self::resolve("plain.tex").is_some()
1185                && Self::resolve("hyphen.tex").is_some()
1186                && Self::resolve("cmr10.tfm").is_some()
1187        }
1188
1189        fn is_latex_available() -> bool {
1190            Self::resolve("latex.ltx").is_some() && Self::resolve("cmr10.tfm").is_some()
1191        }
1192
1193        fn resolve(name: &str) -> Option<Vec<u8>> {
1194            let output = std::process::Command::new("kpsewhich")
1195                .arg(name)
1196                .output()
1197                .ok()?;
1198            if !output.status.success() {
1199                return None;
1200            }
1201            let path = core::str::from_utf8(&output.stdout).ok()?.trim();
1202            if path.is_empty() {
1203                return None;
1204            }
1205            std::fs::read(path).ok()
1206        }
1207
1208        fn candidate_names(request: &ResourceRequest) -> Vec<String> {
1209            let name = normalize_host_resolver_name(request.canonical_name());
1210            let mut candidates = Vec::with_capacity(4);
1211            candidates.push(name.clone());
1212
1213            if std::path::Path::new(&name).extension().is_none() {
1214                match request.kind {
1215                    ResourceKind::Package => candidates.push(format!("{name}.sty")),
1216                    ResourceKind::Class => candidates.push(format!("{name}.cls")),
1217                    ResourceKind::FontDefinition => candidates.push(format!("{name}.fd")),
1218                    ResourceKind::Font => {
1219                        candidates.push(format!("{name}.tfm"));
1220                        candidates.push(format!("{name}.otf"));
1221                        candidates.push(format!("{name}.ttf"));
1222                    }
1223                    ResourceKind::Encoding => candidates.push(format!("{name}.enc")),
1224                    ResourceKind::Map => candidates.push(format!("{name}.map")),
1225                    _ => {}
1226                }
1227            }
1228
1229            candidates
1230        }
1231    }
1232
1233    #[cfg(feature = "std")]
1234    fn normalize_host_resolver_name(mut name: String) -> String {
1235        while let Some(stripped) = name.strip_prefix("./") {
1236            name = stripped.to_string();
1237        }
1238        name
1239    }
1240
1241    #[cfg(feature = "std")]
1242    impl ResourceProvider for HostResolverTestResources {
1243        fn read_request(&self, request: &ResourceRequest) -> Result<Resource, ResourceError> {
1244            for candidate in Self::candidate_names(request) {
1245                if let Some(bytes) = Self::resolve(&candidate) {
1246                    return Ok(Resource::from_request(request, bytes));
1247                }
1248            }
1249
1250            Err(ResourceError::NotFound {
1251                name: request.canonical_name(),
1252                kind: request.kind,
1253            })
1254        }
1255    }
1256
1257    #[test]
1258    fn builder_rejects_format_for_different_profile() {
1259        let format = FormatImage::empty("latex", XetexProfile.id());
1260
1261        let error = EngineBuilder::new(TexProfile)
1262            .format(format)
1263            .resources(InMemoryResourceProvider::new())
1264            .platform(NoopPlatform::default())
1265            .build()
1266            .expect_err("profile mismatch must be rejected");
1267
1268        assert_eq!(
1269            error,
1270            BuildError::ProfileFormatMismatch {
1271                profile: "tex",
1272                format_profile: "xetex",
1273            }
1274        );
1275    }
1276
1277    #[test]
1278    fn transcript_error_detection_requires_tex_error_shape() {
1279        assert_eq!(
1280            generated_transcript_error(b"LaTeX says hello! This is only a message."),
1281            None
1282        );
1283        assert_eq!(
1284            generated_transcript_error(b"before,! Emergency stop.\nl.12 \\bad\n"),
1285            Some("generated TeX engine reported an error: ! Emergency stop.".to_string())
1286        );
1287    }
1288
1289    #[test]
1290    fn builder_reports_generated_format_preload_failure() {
1291        let profile = TexProfile;
1292        let resources = InMemoryResourceProvider::new().with_resource(
1293            TEST_FORMAT_INPUT,
1294            ResourceKind::TexInput,
1295            br"\input missing-format-dependency.tex".to_vec(),
1296        );
1297        let format = FormatImage::initializer("missing-dependency", profile.id())
1298            .preload_tex_input(TEST_FORMAT_INPUT)
1299            .build(&resources)
1300            .expect("format initializer should capture preloaded resource bytes");
1301
1302        let error = EngineBuilder::new(profile)
1303            .format(format)
1304            .resources(resources)
1305            .platform(NoopPlatform::default())
1306            .build()
1307            .expect_err("missing nested preload resource must fail engine build");
1308
1309        match error {
1310            BuildError::FormatPreloadFailed(FormatPreloadError::Resource { name, resource }) => {
1311                assert_eq!(name, TEST_FORMAT_INPUT);
1312                assert_eq!(resource, "missing-format-dependency.tex");
1313            }
1314            other => panic!("unexpected preload error: {other:?}"),
1315        }
1316    }
1317
1318    #[test]
1319    fn session_instantiates_generated_engine_from_cached_format() {
1320        let format = FormatImage::empty("latex", XetexProfile.id());
1321        let engine = EngineBuilder::new(XetexProfile)
1322            .format(format)
1323            .resources(InMemoryResourceProvider::new())
1324            .platform(NoopPlatform::default())
1325            .build()
1326            .expect("engine should build");
1327        let session = engine.new_session();
1328        let generated = session.generated_engine();
1329
1330        assert_eq!(
1331            generated.profile(),
1332            mathtex_portable_engine_generated::EngineProfile::xetex()
1333        );
1334        assert!(!generated.profile().native_fonts);
1335        assert_eq!(generated.resource_request_count(), 0);
1336    }
1337
1338    #[derive(Clone, Debug)]
1339    struct CustomSemanticProfile {
1340        fonts: crate::FontSemantics,
1341        extensions: ExtensionPolicy,
1342    }
1343
1344    impl EngineProfile for CustomSemanticProfile {
1345        fn id(&self) -> ProfileId {
1346            ProfileId("custom-semantic")
1347        }
1348
1349        fn kind(&self) -> EngineKind {
1350            EngineKind::Xetex
1351        }
1352
1353        fn primitives(&self) -> &[PrimitiveSpec] {
1354            &[]
1355        }
1356
1357        fn catcode_defaults(&self) -> crate::CatcodeDefaults {
1358            crate::CatcodeDefaults {
1359                unicode_scalars: self.extensions.xetex,
1360            }
1361        }
1362
1363        fn mathcode_defaults(&self) -> crate::MathcodeDefaults {
1364            crate::MathcodeDefaults {
1365                unicode_math: false,
1366            }
1367        }
1368
1369        fn register_defaults(&self) -> crate::RegisterDefaults {
1370            crate::RegisterDefaults::extended()
1371        }
1372
1373        fn font_semantics(&self) -> crate::FontSemantics {
1374            self.fonts
1375        }
1376
1377        fn extension_policy(&self) -> ExtensionPolicy {
1378            self.extensions
1379        }
1380    }
1381
1382    #[test]
1383    fn generated_engine_uses_custom_profile_semantic_flags() {
1384        let profile = CustomSemanticProfile {
1385            fonts: crate::FontSemantics::tex(),
1386            extensions: ExtensionPolicy {
1387                etex: true,
1388                xetex: true,
1389            },
1390        };
1391        let format = FormatImage::empty("custom-semantic", profile.id());
1392        let engine = EngineBuilder::new(profile)
1393            .format(format)
1394            .resources(InMemoryResourceProvider::new())
1395            .platform(NoopPlatform::default())
1396            .build()
1397            .expect("engine should build");
1398        let generated = engine.new_session().generated_engine().profile();
1399
1400        assert_eq!(generated.id, "custom-semantic");
1401        assert_eq!(
1402            generated.kind,
1403            mathtex_portable_engine_generated::EngineProfileKind::Xetex
1404        );
1405        assert!(generated.etex);
1406        assert!(generated.xetex);
1407        assert!(generated.unicode_scalars);
1408        assert!(!generated.unicode_math);
1409        assert!(!generated.native_fonts);
1410    }
1411
1412    #[derive(Clone, Debug)]
1413    struct PrimitiveProfile {
1414        primitives: Vec<PrimitiveSpec>,
1415    }
1416
1417    impl EngineProfile for PrimitiveProfile {
1418        fn id(&self) -> ProfileId {
1419            ProfileId("primitive-profile")
1420        }
1421
1422        fn kind(&self) -> EngineKind {
1423            EngineKind::Etex
1424        }
1425
1426        fn primitives(&self) -> &[PrimitiveSpec] {
1427            &self.primitives
1428        }
1429
1430        fn catcode_defaults(&self) -> crate::CatcodeDefaults {
1431            crate::CatcodeDefaults::default()
1432        }
1433
1434        fn mathcode_defaults(&self) -> crate::MathcodeDefaults {
1435            crate::MathcodeDefaults::default()
1436        }
1437
1438        fn register_defaults(&self) -> crate::RegisterDefaults {
1439            crate::RegisterDefaults::default()
1440        }
1441
1442        fn font_semantics(&self) -> crate::FontSemantics {
1443            crate::FontSemantics::default()
1444        }
1445
1446        fn extension_policy(&self) -> crate::ExtensionPolicy {
1447            crate::ExtensionPolicy::default()
1448        }
1449    }
1450
1451    #[test]
1452    fn engine_exposes_profile_primitive_dispatch_entries() {
1453        let profile = PrimitiveProfile {
1454            primitives: alloc::vec![PrimitiveSpec {
1455                name: Cow::Borrowed("hbox"),
1456                opcode: PrimitiveOpcode(44),
1457                kind: PrimitiveKind::Layout,
1458            }],
1459        };
1460        let format = FormatImage::empty("plain", profile.id());
1461        let engine = EngineBuilder::new(profile)
1462            .format(format)
1463            .resources(InMemoryResourceProvider::new())
1464            .platform(NoopPlatform::default())
1465            .build()
1466            .expect("engine should build");
1467        let session = engine.new_session();
1468
1469        let primitive = session.primitive("hbox").expect("hbox primitive");
1470
1471        assert_eq!(primitive.opcode, PrimitiveOpcode(44));
1472        assert_eq!(primitive.kind, PrimitiveKind::Layout);
1473        assert!(session.primitive("missing").is_none());
1474    }
1475
1476    #[test]
1477    fn engine_caches_runtime_semantics_for_shared_tex_core() {
1478        let profile = TexProfile;
1479        let format = FormatImage::empty("plain", profile.id());
1480        let engine = EngineBuilder::new(profile)
1481            .format(format)
1482            .resources(InMemoryResourceProvider::new())
1483            .platform(NoopPlatform::default())
1484            .build()
1485            .expect("engine should build");
1486        let session = engine.new_session();
1487
1488        assert_eq!(engine.semantics().profile, ProfileId("tex"));
1489        assert_eq!(session.semantics(), engine.semantics());
1490        assert!(!engine.semantics().has_patch(EnginePatch::Xetex));
1491        assert!(!engine.semantics().is_xetex());
1492        assert!(!engine.semantics().catcodes.unicode_scalars);
1493    }
1494
1495    #[test]
1496    fn xetex_profile_is_runtime_patch_over_shared_core() {
1497        let profile = XetexProfile;
1498        let format = FormatImage::empty("latex", profile.id());
1499        let engine = EngineBuilder::new(profile)
1500            .format(format)
1501            .resources(InMemoryResourceProvider::new())
1502            .platform(NoopPlatform::default())
1503            .build()
1504            .expect("engine should build");
1505        let session = engine.new_session();
1506
1507        assert_eq!(engine.semantics().profile, ProfileId("xetex"));
1508        assert_eq!(engine.semantics().kind, EngineKind::Xetex);
1509        assert!(engine.semantics().has_patch(EnginePatch::Etex));
1510        assert!(engine.semantics().has_patch(EnginePatch::Xetex));
1511        assert!(engine.semantics().is_xetex());
1512        assert!(engine.semantics().catcodes.unicode_scalars);
1513        assert!(engine.semantics().fonts.shaped_text);
1514        assert!(session.primitive("XeTeXrevision").is_some());
1515    }
1516
1517    #[derive(Clone, Debug)]
1518    struct PolicyProfile {
1519        primitives: Vec<PrimitiveSpec>,
1520        extensions: ExtensionPolicy,
1521    }
1522
1523    impl EngineProfile for PolicyProfile {
1524        fn id(&self) -> ProfileId {
1525            ProfileId("policy-profile")
1526        }
1527
1528        fn kind(&self) -> EngineKind {
1529            EngineKind::Xetex
1530        }
1531
1532        fn primitives(&self) -> &[PrimitiveSpec] {
1533            &self.primitives
1534        }
1535
1536        fn catcode_defaults(&self) -> crate::CatcodeDefaults {
1537            crate::CatcodeDefaults {
1538                unicode_scalars: self.extensions.xetex,
1539            }
1540        }
1541
1542        fn mathcode_defaults(&self) -> crate::MathcodeDefaults {
1543            crate::MathcodeDefaults {
1544                unicode_math: self.extensions.xetex,
1545            }
1546        }
1547
1548        fn register_defaults(&self) -> crate::RegisterDefaults {
1549            crate::RegisterDefaults::extended()
1550        }
1551
1552        fn font_semantics(&self) -> crate::FontSemantics {
1553            crate::FontSemantics {
1554                unicode_fonts: self.extensions.xetex,
1555                shaped_text: self.extensions.xetex,
1556                unicode_math_fonts: self.extensions.xetex,
1557                host_native_fonts: false,
1558            }
1559        }
1560
1561        fn extension_policy(&self) -> crate::ExtensionPolicy {
1562            self.extensions
1563        }
1564    }
1565
1566    #[test]
1567    fn custom_profile_extension_policy_becomes_runtime_patch_flags() {
1568        let profile = PolicyProfile {
1569            primitives: alloc::vec![PrimitiveSpec {
1570                name: Cow::Borrowed("customxetex"),
1571                opcode: PrimitiveOpcode(800),
1572                kind: PrimitiveKind::Extension,
1573            }],
1574            extensions: ExtensionPolicy {
1575                etex: false,
1576                xetex: true,
1577            },
1578        };
1579        let format = FormatImage::empty("custom", profile.id());
1580        let engine = EngineBuilder::new(profile)
1581            .format(format)
1582            .resources(InMemoryResourceProvider::new())
1583            .platform(NoopPlatform::default())
1584            .build()
1585            .expect("engine should build");
1586
1587        assert!(engine.semantics().has_patch(EnginePatch::Etex));
1588        assert!(engine.semantics().has_patch(EnginePatch::Xetex));
1589        assert!(engine.primitives().get("customxetex").is_some());
1590    }
1591
1592    #[test]
1593    fn engine_rejects_duplicate_primitive_names() {
1594        let profile = PrimitiveProfile {
1595            primitives: alloc::vec![
1596                PrimitiveSpec {
1597                    name: Cow::Borrowed("input"),
1598                    opcode: PrimitiveOpcode(1),
1599                    kind: PrimitiveKind::Resource,
1600                },
1601                PrimitiveSpec {
1602                    name: Cow::Borrowed("input"),
1603                    opcode: PrimitiveOpcode(2),
1604                    kind: PrimitiveKind::Resource,
1605                },
1606            ],
1607        };
1608        let format = FormatImage::empty("plain", profile.id());
1609
1610        let error = EngineBuilder::new(profile)
1611            .format(format)
1612            .resources(InMemoryResourceProvider::new())
1613            .platform(NoopPlatform::default())
1614            .build()
1615            .expect_err("duplicate primitive names should fail");
1616
1617        assert_eq!(
1618            error,
1619            BuildError::InvalidPrimitives(PrimitiveRegistryError::DuplicateName {
1620                name: "input".to_string(),
1621            })
1622        );
1623    }
1624
1625    #[test]
1626    fn session_loads_packages_through_configured_provider() {
1627        let profile = TexProfile;
1628        let format = FormatImage::empty("latex", profile.id());
1629        let resources = InMemoryResourceProvider::new().with_resource(
1630            "amsmath.sty",
1631            ResourceKind::Package,
1632            br"\ProvidesPackage{amsmath}".to_vec(),
1633        );
1634        let engine = EngineBuilder::new(profile)
1635            .format(format)
1636            .resources(resources)
1637            .platform(NoopPlatform::default())
1638            .build()
1639            .expect("engine should build");
1640
1641        let session = engine.new_session();
1642        let package = session
1643            .load_package("amsmath.sty")
1644            .expect("package should be loaded through provider");
1645
1646        assert_eq!(package.kind, ResourceKind::Package);
1647        assert_eq!(package.bytes, br"\ProvidesPackage{amsmath}".to_vec());
1648    }
1649
1650    #[test]
1651    fn session_loads_typed_resources_through_provider() {
1652        let profile = TexProfile;
1653        let format = FormatImage::empty("latex", profile.id());
1654        let mut resources = InMemoryResourceProvider::new()
1655            .with_resource("article.cls", ResourceKind::Class, b"class")
1656            .with_resource("ot1cmr.fd", ResourceKind::FontDefinition, b"fd")
1657            .with_resource("size10.clo", ResourceKind::PackageSupport, b"support")
1658            .with_resource("latinmodern-math.otf", ResourceKind::Font, b"font")
1659            .with_resource("t1.enc", ResourceKind::Encoding, b"encoding")
1660            .with_resource("pdftex.map", ResourceKind::Map, b"map")
1661            .with_resource("texmf.cnf", ResourceKind::Config, b"config")
1662            .with_resource("latex.fmt", ResourceKind::FormatImage, b"format");
1663        resources.insert_request(
1664            crate::ProviderResourceRequest::asset("mhchem", "arrows.dat"),
1665            b"asset",
1666        );
1667        let engine = EngineBuilder::new(profile)
1668            .format(format)
1669            .resources(resources)
1670            .platform(NoopPlatform::default())
1671            .build()
1672            .expect("engine should build");
1673        let session = engine.new_session();
1674
1675        let class = session.load_class("article.cls").expect("class");
1676        let font_definition = session
1677            .load_font_definition("ot1cmr.fd")
1678            .expect("font definition");
1679        let package_support = session
1680            .load_package_support("size10.clo")
1681            .expect("package support");
1682        let font = session
1683            .load_font_resource("latinmodern-math.otf")
1684            .expect("font");
1685        let encoding = session.load_encoding("t1.enc").expect("encoding");
1686        let map = session.load_map("pdftex.map").expect("map");
1687        let config = session.load_config("texmf.cnf").expect("config");
1688        let asset = session.load_asset("mhchem", "arrows.dat").expect("asset");
1689        let format_image = session
1690            .load_format_image("latex.fmt")
1691            .expect("format image");
1692
1693        assert_eq!(class.kind, ResourceKind::Class);
1694        assert_eq!(class.bytes, b"class");
1695        assert_eq!(font_definition.kind, ResourceKind::FontDefinition);
1696        assert_eq!(font_definition.bytes, b"fd");
1697        assert_eq!(package_support.kind, ResourceKind::PackageSupport);
1698        assert_eq!(package_support.bytes, b"support");
1699        assert_eq!(font.kind, ResourceKind::Font);
1700        assert_eq!(font.bytes, b"font");
1701        assert_eq!(encoding.kind, ResourceKind::Encoding);
1702        assert_eq!(encoding.bytes, b"encoding");
1703        assert_eq!(map.kind, ResourceKind::Map);
1704        assert_eq!(map.bytes, b"map");
1705        assert_eq!(config.kind, ResourceKind::Config);
1706        assert_eq!(config.bytes, b"config");
1707        assert_eq!(asset.kind, ResourceKind::Asset);
1708        assert_eq!(asset.canonical_name, "mhchem/arrows.dat");
1709        assert_eq!(asset.bytes, b"asset");
1710        assert_eq!(format_image.kind, ResourceKind::FormatImage);
1711        assert_eq!(format_image.bytes, b"format");
1712    }
1713
1714    #[test]
1715    fn repeated_sessions_reuse_cached_format_metadata() {
1716        let profile = TexProfile;
1717        let format = FormatImage::empty("latex+amsmath", profile.id());
1718        let engine = EngineBuilder::new(profile)
1719            .format(format)
1720            .resources(InMemoryResourceProvider::new())
1721            .platform(NoopPlatform::default())
1722            .build()
1723            .expect("engine should build");
1724
1725        let first = engine.new_session();
1726        let second = engine.new_session();
1727
1728        let first_ir = first.ir_emitter().finish();
1729        let second_ir = second.ir_emitter().finish();
1730
1731        assert_eq!(first_ir.metadata.format_id, "latex+amsmath");
1732        assert_eq!(second_ir.metadata.format_id, "latex+amsmath");
1733        assert_eq!(first_ir.metadata.engine_profile, "tex");
1734        assert_eq!(second_ir.metadata.engine_profile, "tex");
1735    }
1736
1737    #[test]
1738    fn layout_fragment_input_uses_generated_engine_without_manual_core() {
1739        let profile = TexProfile;
1740        let (format, resources) = basic_test_format(profile.id());
1741        let resources = resources
1742            .with_resource(
1743                "cmr10",
1744                ResourceKind::Font,
1745                include_bytes!("../../../vendor/texlive-source/texk/web2c/tests/cmr10.tfm")
1746                    .to_vec(),
1747            )
1748            .with_resource(
1749                "cmmi10",
1750                ResourceKind::Font,
1751                include_bytes!(
1752                    "../../../vendor/texlive-source/texk/web2c/tests/generated-tfm/cmmi10.tfm"
1753                )
1754                .to_vec(),
1755            )
1756            .with_resource(
1757                "cmmi10.tfm",
1758                ResourceKind::Font,
1759                include_bytes!(
1760                    "../../../vendor/texlive-source/texk/web2c/tests/generated-tfm/cmmi10.tfm"
1761                )
1762                .to_vec(),
1763            )
1764            .with_resource(
1765                "cmsy10",
1766                ResourceKind::Font,
1767                include_bytes!(
1768                    "../../../vendor/texlive-source/texk/web2c/tests/generated-tfm/cmsy10.tfm"
1769                )
1770                .to_vec(),
1771            )
1772            .with_resource(
1773                "cmsy10.tfm",
1774                ResourceKind::Font,
1775                include_bytes!(
1776                    "../../../vendor/texlive-source/texk/web2c/tests/generated-tfm/cmsy10.tfm"
1777                )
1778                .to_vec(),
1779            )
1780            .with_resource(
1781                "cmex10",
1782                ResourceKind::Font,
1783                include_bytes!(
1784                    "../../../vendor/texlive-source/texk/web2c/tests/generated-tfm/cmex10.tfm"
1785                )
1786                .to_vec(),
1787            )
1788            .with_resource(
1789                "cmex10.tfm",
1790                ResourceKind::Font,
1791                include_bytes!(
1792                    "../../../vendor/texlive-source/texk/web2c/tests/generated-tfm/cmex10.tfm"
1793                )
1794                .to_vec(),
1795            );
1796        let engine = EngineBuilder::new(profile)
1797            .format(format)
1798            .resources(resources)
1799            .platform(NoopPlatform::default())
1800            .build()
1801            .expect("engine should build");
1802        let mut session = engine.new_session();
1803
1804        let body = r"\font\tenrm=cmr10 \tenrm A";
1805        let fragment = session
1806            .layout_fragment_input(FragmentInput::new("equation.tex", body, FragmentKind::Text))
1807            .expect("generated engine should layout a boxed text fragment");
1808        let _glyph_node = fragment
1809            .nodes
1810            .iter()
1811            .find(|node| matches!(node.kind, LayoutNodeKind::GlyphRun(_)))
1812            .expect("glyph run should be emitted");
1813
1814        assert_eq!(fragment.metadata.fragment_kind, FragmentKind::Text);
1815    }
1816
1817    #[test]
1818    fn layout_rejects_input_that_exceeds_platform_limit() {
1819        let profile = TexProfile;
1820        let format = FormatImage::empty("latex", profile.id());
1821        let platform = NoopPlatform::with_limits(HostLimits {
1822            max_input_bytes: 4,
1823            ..HostLimits::default()
1824        });
1825        let engine = EngineBuilder::new(profile)
1826            .format(format)
1827            .resources(InMemoryResourceProvider::new())
1828            .platform(platform)
1829            .build()
1830            .expect("engine should build");
1831        let mut session = engine.new_session();
1832
1833        let error = session
1834            .layout_fragment("12345")
1835            .expect_err("input should exceed limit");
1836
1837        assert_eq!(
1838            error,
1839            EngineError::Limit(LimitError::InputTooLarge {
1840                actual: 5,
1841                limit: 4,
1842            })
1843        );
1844    }
1845
1846    #[test]
1847    fn layout_fragment_loads_tex_inputs_inside_generated_engine() {
1848        let profile = TexProfile;
1849        let (format, resources) = basic_test_format(profile.id());
1850        let resources = resources.with_resource(
1851            "snippet.tex",
1852            ResourceKind::TexInput,
1853            br"\vrule width 1pt height 2pt depth 0pt".to_vec(),
1854        );
1855        let engine = EngineBuilder::new(profile)
1856            .format(format)
1857            .resources(resources)
1858            .platform(NoopPlatform::default())
1859            .build()
1860            .expect("engine should build");
1861        let mut session = engine.new_session();
1862
1863        let body = r"\input snippet.tex";
1864        let fragment = session
1865            .layout_fragment_input(FragmentInput::text(body))
1866            .expect("generated engine should own input loading");
1867        assert!(fragment
1868            .nodes
1869            .iter()
1870            .any(|node| matches!(node.kind, LayoutNodeKind::Rule(_))));
1871    }
1872
1873    #[test]
1874    fn layout_fragment_loads_packages_inside_generated_engine() {
1875        let profile = TexProfile;
1876        let (format, resources) = basic_test_format(profile.id());
1877        let resources = resources
1878            .with_resource(
1879                "cmr10",
1880                ResourceKind::Font,
1881                include_bytes!("../../../vendor/texlive-source/texk/web2c/tests/cmr10.tfm")
1882                    .to_vec(),
1883            )
1884            .with_resource(
1885                "testpkg.sty",
1886                ResourceKind::Package,
1887                br"\def\pkgchar{A}".to_vec(),
1888            );
1889        let engine = EngineBuilder::new(profile)
1890            .format(format)
1891            .resources(resources)
1892            .platform(NoopPlatform::default())
1893            .build()
1894            .expect("engine should build");
1895        let mut session = engine.new_session();
1896
1897        let body = r"\font\tenrm=cmr10 \tenrm A \usepackage{testpkg}\pkgchar";
1898        let fragment = session
1899            .layout_fragment_input(FragmentInput::text(body))
1900            .expect("generated engine should expand package loading through TeX");
1901        assert!(fragment
1902            .nodes
1903            .iter()
1904            .any(|node| matches!(node.kind, LayoutNodeKind::GlyphRun(_))));
1905    }
1906
1907    #[test]
1908    fn generated_format_cache_reuses_preloaded_package_state() {
1909        let profile = TexProfile;
1910        let format_resources = InMemoryResourceProvider::new()
1911            .with_resource(TEST_FORMAT_INPUT, ResourceKind::TexInput, TEST_FORMAT_SETUP)
1912            .with_resource(
1913                "cachedpkg.sty",
1914                ResourceKind::Package,
1915                br"\def\cachedpkgchar{A}".to_vec(),
1916            );
1917        let format = FormatImage::initializer("latex+cachedpkg", profile.id())
1918            .preload_tex_input(TEST_FORMAT_INPUT)
1919            .preload_package("cachedpkg")
1920            .build(&format_resources)
1921            .expect("format should preload package bytes");
1922        let resources = InMemoryResourceProvider::new().with_resource(
1923            "cmr10",
1924            ResourceKind::Font,
1925            include_bytes!("../../../vendor/texlive-source/texk/web2c/tests/cmr10.tfm").to_vec(),
1926        );
1927        let engine = EngineBuilder::new(profile)
1928            .format(format)
1929            .resources(resources)
1930            .platform(NoopPlatform::default())
1931            .build()
1932            .expect("engine should build");
1933        let mut session = engine.new_session();
1934
1935        let body = r"\font\tenrm=cmr10 \tenrm \cachedpkgchar";
1936        let fragment = session
1937            .layout_fragment_input(FragmentInput::text(body))
1938            .expect("preloaded generated package macro should be cached");
1939        let has_glyph_node = fragment
1940            .nodes
1941            .iter()
1942            .any(|node| matches!(node.kind, LayoutNodeKind::GlyphRun(_)));
1943
1944        assert_eq!(fragment.metadata.format_id, "latex+cachedpkg");
1945        assert!(has_glyph_node);
1946        assert!(!fragment
1947            .source_map
1948            .sources
1949            .iter()
1950            .any(|source| source.name == "cachedpkg.sty"));
1951    }
1952
1953    #[test]
1954    fn generated_format_cache_reuses_preloaded_tex_input_state() {
1955        let profile = TexProfile;
1956        let format_resources = InMemoryResourceProvider::new()
1957            .with_resource(TEST_FORMAT_INPUT, ResourceKind::TexInput, TEST_FORMAT_SETUP)
1958            .with_resource(
1959                "plain.tex",
1960                ResourceKind::TexInput,
1961                br"\def\plainchar{A}".to_vec(),
1962            );
1963        let format = FormatImage::initializer("plain", profile.id())
1964            .preload_tex_input(TEST_FORMAT_INPUT)
1965            .preload_tex_input("plain.tex")
1966            .build(&format_resources)
1967            .expect("format should preload TeX input bytes");
1968        let resources = InMemoryResourceProvider::new().with_resource(
1969            "cmr10",
1970            ResourceKind::Font,
1971            include_bytes!("../../../vendor/texlive-source/texk/web2c/tests/cmr10.tfm").to_vec(),
1972        );
1973        let engine = EngineBuilder::new(profile)
1974            .format(format)
1975            .resources(resources)
1976            .platform(NoopPlatform::default())
1977            .build()
1978            .expect("engine should build");
1979        let mut session = engine.new_session();
1980
1981        let body = r"\font\tenrm=cmr10 \tenrm \plainchar";
1982        let fragment = session
1983            .layout_fragment_input(FragmentInput::text(body))
1984            .expect("preloaded generated TeX input macro should be cached");
1985        let has_glyph_node = fragment
1986            .nodes
1987            .iter()
1988            .any(|node| matches!(node.kind, LayoutNodeKind::GlyphRun(_)));
1989
1990        assert_eq!(fragment.metadata.format_id, "plain");
1991        assert!(has_glyph_node);
1992        assert!(!fragment
1993            .source_map
1994            .sources
1995            .iter()
1996            .any(|source| source.name == "plain.tex"));
1997    }
1998
1999    #[test]
2000    fn math_fragment_uses_format_owned_math_catcodes_and_fonts() {
2001        let profile = TexProfile;
2002        let (format, resources) = math_test_format(profile.id());
2003        let resources = resources
2004            .with_resource(
2005                "cmr10",
2006                ResourceKind::Font,
2007                include_bytes!("../../../vendor/texlive-source/texk/web2c/tests/cmr10.tfm")
2008                    .to_vec(),
2009            )
2010            .with_resource(
2011                "cmmi10",
2012                ResourceKind::Font,
2013                include_bytes!(
2014                    "../../../vendor/texlive-source/texk/web2c/tests/generated-tfm/cmmi10.tfm"
2015                )
2016                .to_vec(),
2017            )
2018            .with_resource(
2019                "cmsy10",
2020                ResourceKind::Font,
2021                include_bytes!(
2022                    "../../../vendor/texlive-source/texk/web2c/tests/generated-tfm/cmsy10.tfm"
2023                )
2024                .to_vec(),
2025            )
2026            .with_resource(
2027                "cmex10",
2028                ResourceKind::Font,
2029                include_bytes!(
2030                    "../../../vendor/texlive-source/texk/web2c/tests/generated-tfm/cmex10.tfm"
2031                )
2032                .to_vec(),
2033            );
2034        let engine = EngineBuilder::new(profile)
2035            .format(format)
2036            .resources(resources)
2037            .platform(NoopPlatform::default())
2038            .build()
2039            .expect("engine should build");
2040        let mut session = engine.new_session();
2041
2042        let fragment = session
2043            .layout_fragment_input(FragmentInput::math_inline("x^2"))
2044            .expect("generated engine should layout superscript math from format catcodes");
2045        let glyph_count = fragment
2046            .nodes
2047            .iter()
2048            .filter(|node| matches!(node.kind, LayoutNodeKind::GlyphRun(_)))
2049            .count();
2050
2051        assert_eq!(fragment.metadata.format_id, "plain-math");
2052        assert!(glyph_count >= 2);
2053    }
2054
2055    fn math_fixture() -> (FormatImage, InMemoryResourceProvider) {
2056        let (format, resources) = math_test_format(TexProfile.id());
2057        let resources = resources
2058            .with_resource(
2059                "cmr10",
2060                ResourceKind::Font,
2061                include_bytes!("../../../vendor/texlive-source/texk/web2c/tests/cmr10.tfm")
2062                    .to_vec(),
2063            )
2064            .with_resource(
2065                "cmmi10",
2066                ResourceKind::Font,
2067                include_bytes!(
2068                    "../../../vendor/texlive-source/texk/web2c/tests/generated-tfm/cmmi10.tfm"
2069                )
2070                .to_vec(),
2071            )
2072            .with_resource(
2073                "cmsy10",
2074                ResourceKind::Font,
2075                include_bytes!(
2076                    "../../../vendor/texlive-source/texk/web2c/tests/generated-tfm/cmsy10.tfm"
2077                )
2078                .to_vec(),
2079            )
2080            .with_resource(
2081                "cmex10",
2082                ResourceKind::Font,
2083                include_bytes!(
2084                    "../../../vendor/texlive-source/texk/web2c/tests/generated-tfm/cmex10.tfm"
2085                )
2086                .to_vec(),
2087            );
2088        (format, resources)
2089    }
2090
2091    #[derive(Debug, Default)]
2092    struct HostBoxTestPlatform {
2093        diagnostics: crate::platform::NoopDiagnosticSink,
2094        requests: alloc::rc::Rc<core::cell::RefCell<Vec<crate::HostBoxRequest>>>,
2095        response: Option<crate::HostBox>,
2096    }
2097
2098    impl Platform for HostBoxTestPlatform {
2099        fn diagnostics(&self) -> &dyn crate::DiagnosticSink {
2100            &self.diagnostics
2101        }
2102
2103        fn host_box(&self, request: crate::HostBoxRequest) -> Option<crate::HostBox> {
2104            self.requests.borrow_mut().push(request);
2105            self.response.clone()
2106        }
2107    }
2108
2109    fn host_box_session(
2110        response: Option<crate::HostBox>,
2111    ) -> (
2112        alloc::rc::Rc<core::cell::RefCell<Vec<crate::HostBoxRequest>>>,
2113        Engine<TexProfile, InMemoryResourceProvider, HostBoxTestPlatform>,
2114    ) {
2115        let (format, resources) = math_fixture();
2116        let requests = alloc::rc::Rc::new(core::cell::RefCell::new(Vec::new()));
2117        let platform = HostBoxTestPlatform {
2118            diagnostics: crate::platform::NoopDiagnosticSink,
2119            requests: requests.clone(),
2120            response,
2121        };
2122        let engine = EngineBuilder::new(TexProfile)
2123            .format(format)
2124            .resources(resources)
2125            .platform(platform)
2126            .build()
2127            .expect("engine should build");
2128        (requests, engine)
2129    }
2130
2131    #[test]
2132    fn host_box_without_host_typesets_a_deterministic_zero_size_box() {
2133        let (_, engine) = host_box_session(None);
2134        let mut session = engine.new_session();
2135        let with_box = session
2136            .layout_fragment_input(FragmentInput::math_inline(r"a\hostbox{5}b"))
2137            .expect("\\hostbox should fall back to an empty box without a host");
2138        let with_box_again = session
2139            .layout_fragment_input(FragmentInput::math_inline(r"a\hostbox{5}b"))
2140            .expect("fallback rendering should be repeatable");
2141        let plain = session
2142            .layout_fragment_input(FragmentInput::math_inline("ab"))
2143            .expect("plain math should render");
2144
2145        // The zero width fallback participates as an Ord atom with no width of its own.
2146        assert_eq!(with_box.surface.width, plain.surface.width);
2147        assert_eq!(with_box.surface, with_box_again.surface);
2148        assert_eq!(with_box.nodes.len(), with_box_again.nodes.len());
2149    }
2150
2151    #[test]
2152    fn host_box_places_host_metrics_and_render_data_as_one_ord_atom() {
2153        let response = crate::HostBox {
2154            width: 5 * 65536,
2155            height: 12 * 65536,
2156            depth: 3 * 65536,
2157            runs: Vec::from([crate::HostBoxRun {
2158                font_name: "HostFont".into(),
2159                font_size: 10 * 65536,
2160                glyphs: Vec::from([crate::HostBoxGlyph {
2161                    glyph: 42,
2162                    x: 0,
2163                    y: 0,
2164                    advance: 5 * 65536,
2165                }]),
2166            }]),
2167            rules: Vec::from([crate::HostBoxRule {
2168                x: 0,
2169                y: -2 * 65536,
2170                width: 5 * 65536,
2171                height: 26214,
2172            }]),
2173        };
2174        let (requests, engine) = host_box_session(Some(response));
2175        let mut session = engine.new_session();
2176        let fragment = session
2177            .layout_fragment_input(FragmentInput::math_inline(r"a\hostbox{7}b"))
2178            .expect("host box math should render");
2179        let plain = session
2180            .layout_fragment_input(FragmentInput::math_inline("ab"))
2181            .expect("plain math should render");
2182
2183        let host_runs: Vec<_> = fragment
2184            .nodes
2185            .iter()
2186            .filter_map(|node| match &node.kind {
2187                LayoutNodeKind::GlyphRun(run) if run.font.name == "HostFont" => Some(run),
2188                _ => None,
2189            })
2190            .collect();
2191        assert_eq!(host_runs.len(), 1, "exactly one host glyph run expected");
2192        assert_eq!(host_runs[0].glyphs.len(), 1);
2193        assert_eq!(host_runs[0].glyphs[0].glyph_id.0, 42);
2194        // Host fonts intern to synthetic ids above the base, clear of engine font numbers.
2195        assert!(host_runs[0].font.id.0 >= 0x8000_0000);
2196        assert_eq!(host_runs[0].font.size, Length::from_scaled_points(10 * 65536));
2197        let host_rules = fragment
2198            .nodes
2199            .iter()
2200            .filter(|node| {
2201                matches!(
2202                    &node.kind,
2203                    LayoutNodeKind::Rule(rule) if rule.size.width == Length::from_scaled_points(5 * 65536)
2204                        && rule.size.height == Length::from_scaled_points(26214)
2205                )
2206            })
2207            .count();
2208        assert_eq!(host_rules, 1, "exactly one host rule expected");
2209
2210        // Ord spacing: a, the box, and b are all Ord atoms, so the box adds exactly its own width.
2211        assert_eq!(
2212            fragment.surface.width.0,
2213            plain.surface.width.0 + 5 * 65536,
2214            "host box should add its width with Ord spacing"
2215        );
2216        // The atom's metrics drive the line: baseline is its height, total adds its depth.
2217        assert_eq!(fragment.surface.baseline.0, 12 * 65536);
2218        assert_eq!(fragment.surface.height.0, (12 + 3) * 65536);
2219
2220        let requests = requests.borrow();
2221        assert_eq!(requests.len(), 1);
2222        assert_eq!(requests[0].token, 7);
2223        assert_eq!(requests[0].style, crate::HostBoxStyle::Text);
2224        assert_eq!(requests[0].font_size, 10 * 65536);
2225    }
2226
2227    #[test]
2228    fn host_box_keeps_width_through_fraction_rebox() {
2229        let response = crate::HostBox {
2230            width: 5 * 65536,
2231            height: 8 * 65536,
2232            depth: 0,
2233            runs: Vec::from([crate::HostBoxRun {
2234                font_name: "HostFont".into(),
2235                font_size: 10 * 65536,
2236                glyphs: Vec::from([crate::HostBoxGlyph {
2237                    glyph: 42,
2238                    x: 0,
2239                    y: 0,
2240                    advance: 5 * 65536,
2241                }]),
2242            }]),
2243            rules: Vec::new(),
2244        };
2245        let (_, engine) = host_box_session(Some(response));
2246        let mut session = engine.new_session();
2247        // The denominator is narrower than "ab", so make_fraction runs it through rebox.
2248        let fragment = session
2249            .layout_fragment_input(FragmentInput::math_inline(r"{ab\over\hostbox{7}}"))
2250            .expect("fraction with host box denominator should render");
2251
2252        let host_width = Length::from_scaled_points(5 * 65536);
2253        let host_boxes: Vec<_> = fragment
2254            .nodes
2255            .iter()
2256            .filter(|node| {
2257                matches!(&node.kind, LayoutNodeKind::Box(b) if b.metrics.width == host_width)
2258            })
2259            .collect();
2260        assert_eq!(host_boxes.len(), 1, "rebox dissolves the shell, one record box remains");
2261        let host_box = host_boxes[0];
2262        let parent = fragment
2263            .nodes
2264            .iter()
2265            .find_map(|node| match &node.kind {
2266                LayoutNodeKind::Box(b) if b.children.contains(&host_box.id) => Some(b),
2267                _ => None,
2268            })
2269            .expect("record box should sit inside the reboxed line");
2270        // Centering glue must split the leftover space, not the full line width.
2271        let expected = (parent.metrics.width.0 - 5 * 65536) / 2;
2272        let offset = host_box.origin.x.0;
2273        assert!(
2274            (offset - expected).abs() <= 2,
2275            "record box must start at (line - box)/2 = {expected}, got {offset}"
2276        );
2277        assert!(offset > 0, "reboxed line must indent the record box");
2278        // Render data survives the rebox copy.
2279        assert!(fragment.nodes.iter().any(|node| {
2280            matches!(&node.kind, LayoutNodeKind::GlyphRun(run) if run.font.name == "HostFont")
2281        }));
2282    }
2283
2284    #[test]
2285    fn host_box_reports_style_and_size_context_per_site() {
2286        let (requests, engine) = host_box_session(None);
2287        let mut session = engine.new_session();
2288        session
2289            .layout_fragment_input(FragmentInput::math_inline(r"\hostbox{1}"))
2290            .expect("inline math host box should render");
2291        session
2292            .layout_fragment_input(FragmentInput::math_display(r"\hostbox{2}"))
2293            .expect("display math host box should render");
2294        session
2295            .layout_fragment_input(FragmentInput::math_inline(r"x^{\hostbox{3}}"))
2296            .expect("script host box should render");
2297        session
2298            .layout_fragment_input(FragmentInput::math_inline(r"x^{y^{\hostbox{4}}}"))
2299            .expect("scriptscript host box should render");
2300        session
2301            .layout_fragment_input(FragmentInput::math_inline(r"\text{\tenrm\hostbox{5}}"))
2302            .expect("hbox content host box should render");
2303
2304        let requests = requests.borrow();
2305        let by_token = |token: i32| {
2306            requests
2307                .iter()
2308                .find(|request| request.token == token)
2309                .copied()
2310                .unwrap_or_else(|| panic!("token {token} should reach the host"))
2311        };
2312        assert_eq!(by_token(1).style, crate::HostBoxStyle::Text);
2313        assert_eq!(by_token(2).style, crate::HostBoxStyle::Text);
2314        assert_eq!(by_token(3).style, crate::HostBoxStyle::Script);
2315        assert_eq!(by_token(4).style, crate::HostBoxStyle::ScriptScript);
2316        assert_eq!(by_token(5).style, crate::HostBoxStyle::Text);
2317        // Inside \text{} the request carries the selected text font's at size.
2318        assert_eq!(by_token(5).font_size, 10 * 65536);
2319    }
2320
2321    #[cfg(feature = "std")]
2322    #[test]
2323    fn real_plain_format_loads_through_generated_engine_when_texlive_is_available() {
2324        if !HostResolverTestResources::is_available() {
2325            return;
2326        }
2327
2328        assert_real_plain_format_loads(HostResolverTestResources);
2329    }
2330
2331    #[test]
2332    fn real_plain_format_loads_from_vendored_fixture() {
2333        assert_real_plain_format_loads(PlainFixtureResources);
2334    }
2335
2336    #[cfg(feature = "std")]
2337    #[test]
2338    fn real_latex_format_loads_through_generated_engine_when_texlive_is_available() {
2339        if !HostResolverTestResources::is_latex_available() {
2340            return;
2341        }
2342
2343        let profile = XetexProfile;
2344        let format = FormatImage::latex(profile.id())
2345            .build(&HostResolverTestResources)
2346            .expect("real latex.ltx should resolve");
2347        let engine = EngineBuilder::new(profile)
2348            .format(format)
2349            .resources(HostResolverTestResources)
2350            .platform(NoopPlatform::default())
2351            .build()
2352            .expect("engine should build with real latex format");
2353        let mut session = engine.new_session();
2354
2355        let fragment = session
2356            .layout_fragment_input(FragmentInput::text(r"\font\tenrm=cmr10 \tenrm A"))
2357            .expect("real latex format should leave the engine usable for fragments");
2358        let has_glyph_node = fragment
2359            .nodes
2360            .iter()
2361            .any(|node| matches!(node.kind, LayoutNodeKind::GlyphRun(_)));
2362
2363        assert_eq!(fragment.metadata.format_id, "latex");
2364        assert!(has_glyph_node);
2365    }
2366
2367    #[test]
2368    fn math_fragment_supports_text_macro_inside_generated_engine() {
2369        let profile = TexProfile;
2370        let (format, resources) = math_test_format(profile.id());
2371        let resources = resources
2372            .with_resource(
2373                "cmr10",
2374                ResourceKind::Font,
2375                include_bytes!("../../../vendor/texlive-source/texk/web2c/tests/cmr10.tfm")
2376                    .to_vec(),
2377            )
2378            .with_resource(
2379                "cmmi10",
2380                ResourceKind::Font,
2381                include_bytes!(
2382                    "../../../vendor/texlive-source/texk/web2c/tests/generated-tfm/cmmi10.tfm"
2383                )
2384                .to_vec(),
2385            )
2386            .with_resource(
2387                "cmsy10",
2388                ResourceKind::Font,
2389                include_bytes!(
2390                    "../../../vendor/texlive-source/texk/web2c/tests/generated-tfm/cmsy10.tfm"
2391                )
2392                .to_vec(),
2393            )
2394            .with_resource(
2395                "cmex10",
2396                ResourceKind::Font,
2397                include_bytes!(
2398                    "../../../vendor/texlive-source/texk/web2c/tests/generated-tfm/cmex10.tfm"
2399                )
2400                .to_vec(),
2401            );
2402        let engine = EngineBuilder::new(profile)
2403            .format(format)
2404            .resources(resources)
2405            .platform(NoopPlatform::default())
2406            .build()
2407            .expect("engine should build");
2408        let mut session = engine.new_session();
2409
2410        let body = r"\font\tenrm=cmr10 \font\teni=cmmi10 \font\tensy=cmsy10 \font\tenex=cmex10 \textfont0=\tenrm \scriptfont0=\tenrm \scriptscriptfont0=\tenrm \textfont1=\teni \scriptfont1=\teni \scriptscriptfont1=\teni \textfont2=\tensy \scriptfont2=\tensy \scriptscriptfont2=\tensy \textfont3=\tenex \scriptfont3=\tenex \scriptscriptfont3=\tenex \text{\tenrm A}";
2411        let fragment = session
2412            .layout_fragment_input(FragmentInput::math_inline(body))
2413            .expect("generated engine should allow text boxes inside math");
2414        let _glyph_node = fragment
2415            .nodes
2416            .iter()
2417            .find(|node| matches!(node.kind, LayoutNodeKind::GlyphRun(_)))
2418            .expect("text macro should emit glyph IR");
2419
2420        assert_eq!(fragment.metadata.fragment_kind, FragmentKind::MathInline);
2421    }
2422
2423    #[test]
2424    fn generated_package_loading_resolves_package_assets() {
2425        let profile = TexProfile;
2426        let (format, resources) = basic_test_format(profile.id());
2427        let mut resources = resources
2428            .with_resource(
2429                "cmr10",
2430                ResourceKind::Font,
2431                include_bytes!("../../../vendor/texlive-source/texk/web2c/tests/cmr10.tfm")
2432                    .to_vec(),
2433            )
2434            .with_resource(
2435                "assetpkg.sty",
2436                ResourceKind::Package,
2437                br"\input data.dat\relax".to_vec(),
2438            );
2439        resources.insert_request(
2440            crate::ProviderResourceRequest::asset("assetpkg", "data.dat"),
2441            br"\def\assetpkgchar{A}".to_vec(),
2442        );
2443        let engine = EngineBuilder::new(profile)
2444            .format(format)
2445            .resources(resources)
2446            .platform(NoopPlatform::default())
2447            .build()
2448            .expect("engine should build");
2449        let mut session = engine.new_session();
2450
2451        let body = r"\font\tenrm=cmr10 \usepackage{assetpkg}\tenrm \assetpkgchar";
2452        let fragment = session
2453            .layout_fragment_input(FragmentInput::text(body))
2454            .expect("generated engine should load package-owned TeX assets");
2455
2456        assert!(fragment
2457            .nodes
2458            .iter()
2459            .any(|node| matches!(node.kind, LayoutNodeKind::GlyphRun(_))));
2460    }
2461
2462    #[test]
2463    fn generated_layout_does_not_expand_legacy_host_macros() {
2464        let profile = TexProfile;
2465        let format = FormatImage::with_snapshot(
2466            "latex+macros",
2467            profile.id(),
2468            FormatSnapshot {
2469                macros: alloc::vec![MacroDefinition {
2470                    name: "speedtext".into(),
2471                    replacement: br"\text{speed}".to_vec(),
2472                }],
2473                ..FormatSnapshot::default()
2474            },
2475        );
2476        let engine = EngineBuilder::new(profile)
2477            .format(format)
2478            .resources(InMemoryResourceProvider::new())
2479            .platform(NoopPlatform::default())
2480            .build()
2481            .expect("engine should build");
2482        let mut session = engine.new_session();
2483
2484        let error = session
2485            .layout_fragment(r"a+\speedtext")
2486            .expect_err("generated engine should not use legacy host macro tables");
2487
2488        match error {
2489            EngineError::Layout { message } => {
2490                assert!(message.contains("generated TeX engine reported an error"));
2491            }
2492            other => panic!("unexpected error: {other:?}"),
2493        }
2494    }
2495
2496    #[test]
2497    fn sessions_load_and_shape_text_through_font_system() {
2498        let profile = XetexProfile;
2499        let format = FormatImage::empty("latex", profile.id());
2500        let fonts = InMemoryFontSystem::new()
2501            .with_font("Latin Modern Math", b"font")
2502            .with_fallback_shaping();
2503        let engine = EngineBuilder::new(profile)
2504            .format(format)
2505            .resources(InMemoryResourceProvider::new())
2506            .platform(NoopPlatform::default())
2507            .fonts(fonts)
2508            .build()
2509            .expect("engine should build");
2510        let session = engine.new_session();
2511
2512        let font = session
2513            .load_font(&FontQuery {
2514                family: "Latin Modern Math".to_string(),
2515                size: Length::from_scaled_points(655_360),
2516                math: true,
2517            })
2518            .expect("font should load");
2519        let shaped = session
2520            .shape_text(&ShapeRequest {
2521                font: font.id,
2522                text: "xy",
2523                direction: Direction::LeftToRight,
2524                source: Some(ByteSpan { start: 10, end: 12 }),
2525                script: None,
2526                features: Vec::new(),
2527            })
2528            .expect("text should shape through font system");
2529
2530        assert_eq!(font.canonical_name, "Latin Modern Math");
2531        assert_eq!(shaped.glyphs.len(), 2);
2532        assert_eq!(
2533            shaped.glyphs[0].cluster,
2534            Some(ByteSpan { start: 10, end: 11 })
2535        );
2536        assert_eq!(
2537            shaped.glyphs[1].cluster,
2538            Some(ByteSpan { start: 11, end: 12 })
2539        );
2540    }
2541
2542    #[test]
2543    fn sessions_can_load_fonts_from_resource_provider_adapter() {
2544        let profile = XetexProfile;
2545        let format = FormatImage::empty("latex", profile.id());
2546        let font_resources = InMemoryResourceProvider::new().with_resource(
2547            "Latin Modern Math.otf",
2548            ResourceKind::Font,
2549            b"font-bytes",
2550        );
2551        let engine = EngineBuilder::new(profile)
2552            .format(format)
2553            .resources(InMemoryResourceProvider::new())
2554            .platform(NoopPlatform::default())
2555            .fonts(ResourceFontSystem::new(font_resources))
2556            .build()
2557            .expect("engine should build");
2558        let session = engine.new_session();
2559
2560        let font = session
2561            .load_font(&FontQuery {
2562                family: "Latin Modern Math.otf".to_string(),
2563                size: Length::from_scaled_points(655_360),
2564                math: true,
2565            })
2566            .expect("font should load from ResourceProvider");
2567
2568        assert_eq!(font.canonical_name, "Latin Modern Math.otf");
2569        assert_eq!(&**font.bytes().expect("library owned bytes"), b"font-bytes");
2570        assert!(session
2571            .shape_text(&ShapeRequest {
2572                font: font.id,
2573                text: "x",
2574                direction: Direction::LeftToRight,
2575                source: None,
2576                script: None,
2577                features: Vec::new(),
2578            })
2579            .is_err());
2580    }
2581
2582    #[test]
2583    fn sessions_clone_cached_format_state_without_sharing_mutations() {
2584        let profile = TexProfile;
2585        let format = FormatImage::with_snapshot(
2586            "latex+text",
2587            profile.id(),
2588            FormatSnapshot {
2589                macros: alloc::vec![MacroDefinition {
2590                    name: "text".into(),
2591                    replacement: b"initial".to_vec(),
2592                }],
2593                ..FormatSnapshot::default()
2594            },
2595        );
2596        let engine = EngineBuilder::new(profile)
2597            .format(format)
2598            .resources(InMemoryResourceProvider::new())
2599            .platform(NoopPlatform::default())
2600            .build()
2601            .expect("engine should build");
2602
2603        let mut first = engine.new_session();
2604        let second = engine.new_session();
2605
2606        first
2607            .state_mut()
2608            .define_macro("text", b"session-local".to_vec());
2609
2610        assert_eq!(
2611            first
2612                .state()
2613                .macro_definition("text")
2614                .expect("first session macro")
2615                .replacement,
2616            b"session-local".to_vec()
2617        );
2618        assert_eq!(
2619            second
2620                .state()
2621                .macro_definition("text")
2622                .expect("second session macro")
2623                .replacement,
2624            b"initial".to_vec()
2625        );
2626
2627        match &engine.format().state {
2628            crate::FormatState::Structured(snapshot) => {
2629                assert_eq!(snapshot.macros[0].replacement, b"initial".to_vec());
2630            }
2631            other => panic!("unexpected format state: {other:?}"),
2632        }
2633    }
2634
2635    #[test]
2636    fn session_creates_metadata_seeded_ir_emitter() {
2637        let profile = TexProfile;
2638        let format = FormatImage::empty("latex", profile.id());
2639        let engine = EngineBuilder::new(profile)
2640            .format(format)
2641            .resources(InMemoryResourceProvider::new())
2642            .platform(NoopPlatform::default())
2643            .build()
2644            .expect("engine should build");
2645        let session = engine.new_session();
2646
2647        let fragment = session.ir_emitter().finish();
2648
2649        assert_eq!(fragment.metadata.engine_profile, "tex");
2650        assert_eq!(fragment.metadata.format_id, "latex");
2651        assert_eq!(fragment.metadata.fragment_kind, FragmentKind::MathInline);
2652    }
2653
2654    #[test]
2655    fn session_creates_mode_specific_ir_emitter() {
2656        let profile = TexProfile;
2657        let format = FormatImage::empty("latex", profile.id());
2658        let engine = EngineBuilder::new(profile)
2659            .format(format)
2660            .resources(InMemoryResourceProvider::new())
2661            .platform(NoopPlatform::default())
2662            .build()
2663            .expect("engine should build");
2664        let session = engine.new_session();
2665
2666        let fragment = session.ir_emitter_for(FragmentKind::Text).finish();
2667
2668        assert_eq!(fragment.metadata.fragment_kind, FragmentKind::Text);
2669    }
2670}