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