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