Skip to main content

latexml_core/common/
model.rs

1use std::{
2  cell::RefCell,
3  fs::File,
4  io::{BufRead, BufReader},
5};
6
7use libxml::tree::Node;
8use once_cell::sync::Lazy;
9use regex::Regex;
10use rustc_hash::FxHashSet as HashSet;
11
12use super::arena::SymHashMap;
13use crate::{
14  common::{
15    arena::{self, SymStr},
16    error::*,
17    relaxng::Relaxng,
18    xml::XML_NS,
19  },
20  document::Document,
21  pin,
22  util::pathname,
23};
24
25// use common::font::*;
26
27pub const LTX_NAMESPACE: &str = "http://dlmf.nist.gov/LaTeXML";
28pub type IndirectModel = SymHashMap<SymHashMap<SymStr>>;
29
30static PREFIXED_LOCALNAME_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"^([^:]+):(.+)$").unwrap());
31static TAG_MODEL_LINE_RE: Lazy<Regex> =
32  Lazy::new(|| Regex::new(r"^([^\{]+)\{(.*?)\}\((.*?)\)$").unwrap());
33// Mirrors Perl Model.pm L149: `m/^([^:=]+):=\(?([^)]*?)\)?$/` — the
34// `\(?…\)?` pair strips the surrounding parens from
35// `classname:=(elt1,elt2,...)` so the elements split cleanly.
36static CLASS_MODEL_LINE_RE: Lazy<Regex> =
37  Lazy::new(|| Regex::new(r"^([^:=]+):=\(?([^)]*?)\)?$").unwrap());
38static NAMESPACE_MODEL_LINE_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"^([^=]+)=(.*?)$").unwrap());
39
40#[derive(Default, Debug)]
41pub struct TagFrame {
42  model:      HashSet<SymStr>,
43  attributes: HashSet<SymStr>,
44}
45
46static DEFAULT_TAG_FRAME: Lazy<TagFrame> = Lazy::new(TagFrame::default);
47
48#[derive(Default, Debug)]
49pub struct Model {
50  pub schema:                      Option<Relaxng>,
51  pub schema_data:                 Option<Vec<SymStr>>,
52  pub schema_class:                SymHashMap<HashSet<SymStr>>,
53  pub code_namespace_prefixes:     SymHashMap<SymStr>,
54  pub code_namespaces:             SymHashMap<SymStr>,
55  pub document_namespace_prefixes: SymHashMap<SymStr>,
56  pub document_namespaces:         SymHashMap<SymStr>,
57  // doctype_namespaces: SymHashMap<SymStr>,
58  // namespace_errors: usize,
59  pub permissive:                  bool,
60  pub no_compiled:                 bool,
61  pub debug_mode:                  bool,
62  pub namespace_errors:            u8,
63  pub tagprop:                     SymHashMap<TagFrame>,
64}
65
66#[thread_local]
67pub static MODEL: Lazy<RefCell<Model>> = Lazy::new(|| RefCell::new(Model::new()));
68
69macro_rules! model {
70  () => {
71    (*MODEL).borrow()
72  };
73}
74macro_rules! model_mut {
75  () => {
76    (*MODEL).borrow_mut()
77  };
78}
79
80pub fn initialize_model() {
81  let mut global_model = MODEL.borrow_mut();
82  *global_model = Model::new();
83}
84
85/// Eagerly initialize this thread's `#[thread_local]` `MODEL` Lazy.
86///
87/// `Model::new()` interns the `xml` namespace prefix/URI via `arena::pin`,
88/// so its `Lazy` initializer reaches into the `ARENA` thread-local. Forcing
89/// it at conversion entry — *after* [`arena::force_init`](crate::common::arena::force_init)
90/// and before `STATE`/`STOMACH` are built — keeps that init from running
91/// re-entrantly from within another root's initialization, the macOS
92/// `#[thread_local]` hazard behind issue #217. No behavioral change on
93/// Linux (a later `set_model` for a schema-driven model still replaces it).
94pub(crate) fn force_init() { Lazy::force(&MODEL); }
95
96impl Model {
97  pub fn new() -> Self {
98    let mut model = Model::default();
99    // model.xpath.register_function("match-font", |x, y| {font::match_font(x,y)})
100    model.register_namespace("xml", Some(XML_NS));
101    model.register_document_namespace("xml", Some(XML_NS));
102    model
103  }
104  ///**********************************************************************
105  /// Namespaces
106  ///**********************************************************************
107  /// There are TWO namespace mappings!!!
108  /// One for coding, one for the document output.
109  ///
110  /// Coding: this namespace mapping associates prefixes to namespace URIs for
111  ///   use in the latexml code, constructors and such.
112  ///   This must be a one to one mapping and there are no default namespaces.
113  /// Document: this namespace mapping associates prefixes to namespace URIs
114  ///   as used in the generated document, and will be the
115  ///   set of prefixes used in the generated output.
116  ///   This mapping may also use a prefix of "#default" which is for
117  ///   the unprefixed form of elements (not used for attributes!)
118  pub fn register_namespace(&mut self, codeprefix: &str, namespace_opt: Option<&str>) {
119    self.register_namespace_sym(arena::pin(codeprefix), namespace_opt.map(arena::pin))
120  }
121  pub fn register_namespace_sym(&mut self, codeprefix: SymStr, namespace_opt: Option<SymStr>) {
122    // double-check empty strings are None
123    let namespace_opt_checked = namespace_opt.filter(|val| *val != pin!(""));
124    match namespace_opt_checked {
125      Some(namespace) => {
126        self
127          .code_namespace_prefixes
128          .insert_sym(namespace, codeprefix);
129        self.code_namespaces.insert_sym(codeprefix, namespace);
130      },
131      None => {
132        if let Some(prev) = self.code_namespaces.get_sym(codeprefix) {
133          self.code_namespace_prefixes.remove_sym(*prev);
134        };
135        self.code_namespaces.remove_sym(codeprefix);
136      },
137    };
138  }
139
140  /// Register a prefix in the DOCUMENT mapping — the second of the two
141  /// mappings described on [`Model::register_namespace`] above.
142  ///
143  /// An empty `docprefix` is the `#default` entry, i.e. the unprefixed form of
144  /// elements (never attributes). `None` for the namespace unbinds the prefix.
145  pub fn register_document_namespace(&mut self, docprefix: &str, namespace_opt: Option<&str>) {
146    let default_sym = pin!("#default");
147    let docprefix_sym = if docprefix.is_empty() {
148      default_sym
149    } else {
150      arena::pin(docprefix)
151    };
152
153    match namespace_opt {
154      Some(namespace) => {
155        // Since the default namespace url can still ALSO have a prefix associated,
156        // we prepend "DEFAULT#url" when using as a hash key in the prefixes table.
157        let ns_sym = arena::pin(namespace);
158        let regnamespace = if docprefix_sym == default_sym {
159          arena::pin(s!("DEFAULT#{namespace}"))
160        } else {
161          ns_sym
162        };
163        self
164          .document_namespace_prefixes
165          .insert_sym(regnamespace, docprefix_sym);
166        self.document_namespaces.insert_sym(docprefix_sym, ns_sym);
167      },
168      None => {
169        if let Some(prev) = self.document_namespaces.get_sym(docprefix_sym) {
170          self.document_namespace_prefixes.remove_sym(*prev);
171        };
172        self.document_namespaces.remove_sym(docprefix_sym);
173      },
174    };
175  }
176
177  pub fn set_relaxng_schema(&mut self, schema: &str) {
178    self.schema_data = Some(vec![pin!("RelaxNG"), arena::pin(schema)]);
179  }
180  /// TODO: This is another component that would fit perfectly as a compiler plugin.
181  /// For now, simply reimplementing the runtime loading of
182  /// LaTeXML.model as-is from Model.pm
183  pub fn load_compiled_schema(&mut self, path: &str) {
184    let compiled_fh = File::open(path).unwrap();
185    let compiled_reader = BufReader::new(&compiled_fh);
186    let content: String = compiled_reader
187      .lines()
188      .map_while(std::result::Result::ok)
189      .collect::<Vec<_>>()
190      .join("\n");
191    self.load_compiled_schema_str(&content, path);
192  }
193
194  /// Same as [`Self::load_compiled_schema`] but consumes an already-
195  /// loaded `.model` body. `source` is used purely for diagnostic
196  /// messages (the `note_begin`/`note_end` envelope and the
197  /// malformed-line panic). Avoids the disk read when the model is
198  /// served from the binary's own embedded RelaxNG table.
199  pub fn load_compiled_schema_str(&mut self, content: &str, source: &str) {
200    note_begin(&s!("Loading compiled schema {}\n", source));
201    for line in content.lines() {
202      if let Some(caps) = TAG_MODEL_LINE_RE.captures(line) {
203        let tag = caps.get(1).map_or("", |m| m.as_str());
204        let attr = caps.get(2).map_or("", |m| m.as_str());
205        let children = caps.get(3).map_or("", |m| m.as_str());
206        self.add_tag_attribute(tag, attr.split(',').collect());
207        self.add_tag_content(tag, children.split(',').collect());
208      } else if let Some(caps) = CLASS_MODEL_LINE_RE.captures(line) {
209        let classname = caps.get(1).map_or("", |m| m.as_str());
210        let elements = caps.get(2).map_or("", |m| m.as_str());
211        let mut class_set = HashSet::default();
212        for set_element in elements.split(',') {
213          class_set.insert(arena::pin(set_element));
214        }
215        self.set_schema_class(classname, class_set);
216      } else if let Some(caps) = NAMESPACE_MODEL_LINE_RE.captures(line) {
217        let prefix = caps.get(1).map_or("", |m| m.as_str());
218        let namespace = caps.get(2).map_or("", |m| m.as_str());
219        self.register_document_namespace(prefix, Some(namespace));
220      } else {
221        panic!("Fatal:internal:{source} Compiled model '{source}' is malformatted at \"{line}\"");
222      }
223    }
224    note_end(&s!("Loading compiled schema {source}\n"));
225  }
226  pub fn add_tag_content(&mut self, tag: &str, elements: Vec<&str>) {
227    let frame = self.tagprop.entry(tag).or_default();
228
229    for element in elements {
230      frame.model.insert(arena::pin(element));
231    }
232  }
233
234  // pub fn get_tag_attributes(&self, tag: &str) -> Vec<&str> {
235  //   match self.tagprop.get(&arena::pin(tag)) {
236  //     Some(h) => {
237  //       let mut keys: Vec<&str> = h.attributes.iter().map(|s| arena::resolve(*s)).collect();
238  //       keys.sort_unstable();
239  //       keys
240  //     },
241  //     None => Vec::new(),
242  //   }
243  // }
244
245  pub fn add_tag_attribute(&mut self, tag: &str, attributes: Vec<&str>) {
246    let frame = self.tagprop.entry(tag).or_default();
247
248    for attribute in attributes {
249      frame.attributes.insert(arena::pin(attribute));
250    }
251  }
252
253  pub fn set_schema_class(&mut self, classname: &str, content: HashSet<SymStr>) {
254    self.schema_class.insert(classname, content);
255  }
256
257  /// Serialise the loaded schema into the `.model` plain-text format
258  /// emitted by Perl `LaTeXML::Common::Model::compileSchema`
259  /// (Model.pm L121-136). Three kinds of lines, all newline-separated:
260  ///
261  /// * `prefix=namespace` for every entry in `document_namespaces` (sorted by prefix).
262  /// * `classname:=(elt1,elt2,...)` for every entry in `schema_class` (sorted by classname; each
263  ///   element list sorted).
264  /// * `tag{attr1,attr2}(child1,child2)` for every entry in `tagprop` (sorted by tag; attrs and
265  ///   children sorted; tags whose name starts with `!` are skipped — they are content-model-only
266  ///   negations).
267  ///
268  /// Output is identical to the Perl tool so a downstream
269  /// `tools/compileschema.sh` can diff Rust vs. Perl-generated
270  /// `LaTeXML.model` files byte-for-byte (modulo schema content).
271  pub fn dump_compiled_schema(&self) -> String {
272    fn sym_to_string(sym: SymStr) -> String { arena::with(sym, |s| s.to_string()) }
273    fn syms_sorted(set: impl IntoIterator<Item = SymStr>) -> Vec<String> {
274      let mut v: Vec<String> = set.into_iter().map(sym_to_string).collect();
275      v.sort();
276      v
277    }
278    let mut out = String::new();
279    let prefixes = syms_sorted(self.document_namespaces.keys().copied());
280    for prefix in &prefixes {
281      let ns_opt = self
282        .document_namespaces
283        .get_sym(arena::pin(prefix.as_str()));
284      let ns = match ns_opt {
285        Some(v) => sym_to_string(*v),
286        None => continue,
287      };
288      out.push_str(prefix);
289      out.push('=');
290      out.push_str(&ns);
291      out.push('\n');
292    }
293    let classnames = syms_sorted(self.schema_class.keys().copied());
294    for classname in &classnames {
295      let elements = match self.schema_class.get_sym(arena::pin(classname.as_str())) {
296        Some(set) => set,
297        None => continue,
298      };
299      let elt_names = syms_sorted(elements.iter().copied());
300      out.push_str(classname);
301      out.push_str(":=(");
302      out.push_str(&elt_names.join(","));
303      out.push_str(")\n");
304    }
305    let tags = syms_sorted(self.tagprop.keys().copied());
306    for tag in &tags {
307      if tag.starts_with('!') {
308        continue;
309      }
310      let frame = match self.tagprop.get_sym(arena::pin(tag.as_str())) {
311        Some(f) => f,
312        None => continue,
313      };
314      let attrs = syms_sorted(frame.attributes.iter().copied());
315      let children = syms_sorted(frame.model.iter().copied());
316      out.push_str(tag);
317      out.push('{');
318      out.push_str(&attrs.join(","));
319      out.push_str("}(");
320      out.push_str(&children.join(","));
321      out.push_str(")\n");
322    }
323    out
324  }
325  pub fn describe_model(&self) {}
326  fn load_internal_extensions(&mut self) {
327    if !self.tagprop.contains_key("ltx:_CaptureBlock_") {
328      // Synthesize ltx:_CaptureBlock_ to act like the union of ltx:block,
329      // ltx:logical-block, ltx:sectional-block, Caption, FrontMatter,
330      // BackMatter (Perl Common/Model.pm loadInternalExtensions L96-97).
331      // FrontMatter/BackMatter were missing here, so a captured box that
332      // legitimately holds frontmatter/backmatter content was modelled
333      // more narrowly than Perl.
334      self.synthesize_element("ltx:_CaptureBlock_", &[
335        "ltx:block",
336        "ltx:logical-block",
337        "ltx:sectional-block",
338        "Caption",
339        "FrontMatter",
340        "BackMatter",
341      ]);
342      let cb_entry = self.tagprop.entry("ltx:_CaptureBlock_").or_default();
343      cb_entry.model.insert(pin!("svg:g"));
344      cb_entry.model.insert(pin!("svg:foreignObject"));
345    }
346  }
347
348  /// Clone the tagprop's (allowed content & attributes) of @other to $tag
349  fn synthesize_element(&mut self, tag: &str, others: &[&str]) {
350    let mut to_add_in_model = Vec::new();
351    let mut to_add_in_attrs = Vec::new();
352    for other in others {
353      if let Some(content) = self.schema_class.get(other) {
354        for child in content {
355          to_add_in_model.push(*child);
356        }
357      } else if let Some(entry) = self.tagprop.get(other) {
358        for child in &entry.model {
359          to_add_in_model.push(*child);
360        }
361        for attr in &entry.attributes {
362          to_add_in_attrs.push(*attr);
363        }
364      }
365    }
366    let capture = self.tagprop.entry(tag).or_default();
367    for child in to_add_in_model {
368      capture.model.insert(child);
369    }
370    for attr in to_add_in_attrs {
371      capture.attributes.insert(attr);
372    }
373  }
374}
375
376pub fn set_relaxng_schema(schema: &str) { model_mut!().set_relaxng_schema(schema) }
377pub fn add_schema_declaration(document: &mut Document) {
378  if let Some(ref schema) = model!().schema {
379    schema.add_schema_declaration(document);
380  }
381}
382
383pub fn load_schema(search_paths: &[&str]) -> Result<()> {
384  // Only load once
385  let mut model = model_mut!();
386  if model.schema.is_some() {
387    return Ok(());
388  }
389  let mut name = String::new();
390  if model.schema_data.is_none() {
391    // TODO: Return this code path to normal once we properly load schemas
392    Warn!("expected", "<model>", "TODO");
393    // Warn('expected', '<model>', undef, "No Schema Model has been declared; assuming LaTeXML");
394    // // article ??? or what ? undef gives problems!
395
396    model.register_document_namespace("ltx", Some(LTX_NAMESPACE));
397    model.set_relaxng_schema("LaTeXML");
398    model.register_namespace("ltx", Some(LTX_NAMESPACE));
399    model.register_namespace("svg", Some("http://www.w3.org/2000/svg"));
400    model.register_namespace("xlink", Some("http://www.w3.org/1999/xlink")); // Needed for SVG
401    model.register_namespace("m", Some("http://www.w3.org/1998/Math/MathML"));
402    model.register_namespace("xhtml", Some("http://www.w3.org/1999/xhtml"));
403    model.permissive = true;
404  } // Actually, they could have declared all sorts of Tags....
405  // Only RelaxNG schemas are supported (DTD support removed from Rust port)
406  if let Some(ref data) = model.schema_data {
407    if data[0] == pin!("RelaxNG") {
408      name = arena::to_string(data[1]);
409      model.schema = Some(Relaxng {
410        name: name.clone(),
411        ..Relaxng::default()
412      });
413    } else {
414      let message = arena::with(data[0], |schema_type_str| {
415        s!("Can't load a schema of type {schema_type_str:?}")
416      });
417      Error!("unknown", "schematype", message)
418    }
419  }
420
421  if !model.no_compiled && model.schema.is_some() {
422    let paths: Option<Vec<String>> = if search_paths.is_empty() {
423      None
424    } else {
425      Some(search_paths.iter().map(ToString::to_string).collect())
426    };
427    let pathname_opt = pathname::find(&name, pathname::PathnameFindOptions {
428      paths,
429      extensions: Some(vec![s!("model")]),
430      installation_subdir: Some(s!("resources/RelaxNG")),
431    });
432
433    match pathname_opt {
434      Some(compiled_path) => model.load_compiled_schema(&compiled_path),
435      None => {
436        // No `.model` on the searchpath. Try the embedded RelaxNG
437        // table for a same-named compiled schema first (the
438        // distribution path — binary running outside the source
439        // tree); fall through to raw `.rng` parsing if that misses
440        // too.
441        let embed_key = format!("{}.model", name);
442        if let Some(content) = crate::common::relaxng::embedded::lookup(&embed_key) {
443          model.load_compiled_schema_str(content, &format!("<embedded>/{embed_key}"));
444        } else {
445          let paths: Vec<&std::path::Path> =
446            search_paths.iter().map(std::path::Path::new).collect();
447          // Seed the scanner with the model's registered code-namespace prefixes
448          // (e.g. `ltx` → dlmf from `base_schema`), so a schema whose target
449          // namespace appears only as a default `ns=` — with no `xmlns:` prefix,
450          // exactly how `LaTeXML.rng` is written — resolves to that conventional
451          // prefix instead of a synthetic `namespaceN` the runtime never matches
452          // (Perl: the scanner delegates `encodeQName` to the model; #652).
453          // Collect first: the immutable borrow ends before `schema.as_mut()`.
454          let code_prefix_seed: Vec<(String, String)> = model
455            .code_namespace_prefixes
456            .iter()
457            .map(|(uri, prefix)| {
458              (
459                arena::with(*uri, |s| s.to_string()),
460                arena::with(*prefix, |s| s.to_string()),
461              )
462            })
463            .collect();
464          let schema = model.schema.as_mut().unwrap();
465          schema.code_namespace_prefixes = code_prefix_seed.into_iter().collect();
466          let schema_name = schema.name.clone();
467          match schema.load_schema(&schema_name, &paths) {
468            Err(err) => {
469              let msg = format!("load_schema failed for {}: {}", schema_name, err);
470              Warn!("expected", "RelaxNG", msg);
471            },
472            // A raw `.rng` was scanned but the tag/attribute/namespace/class
473            // tables the runtime consults are NOT baked into it (unlike a
474            // compiled `.model`). Distil them now — else `tagprop` stays empty
475            // and every element is rejected (#652). `compute_model_data` owns its
476            // result, so the immutable `model.schema` borrow ends before the
477            // `add_*`/`register_*`/`set_*` mutations below.
478            Ok(()) => {
479              let data = model.schema.as_ref().unwrap().compute_model_data();
480              for (tag, children) in &data.tag_contents {
481                model.add_tag_content(tag, children.iter().map(String::as_str).collect());
482              }
483              for (tag, attrs) in &data.tag_attributes {
484                model.add_tag_attribute(tag, attrs.iter().map(String::as_str).collect());
485              }
486              for (name, members) in &data.schema_classes {
487                model.set_schema_class(name, members.iter().map(arena::pin).collect());
488              }
489              for (prefix, uri) in &data.namespaces {
490                model.register_document_namespace(prefix, Some(uri));
491              }
492              // Perl RelaxNG.pm L78: register the schema's primary namespace as
493              // the DEFAULT document namespace, so the output serializes it with
494              // no prefix (the schema's unprefixed default-`ns=` elements).
495              // Generalized from Perl's hardcoded dlmf to the schema's ACTUAL
496              // primary namespace, so a schema with a different default namespace
497              // gets ITS namespace as the output default — full namespace
498              // expressivity, user-directed (#652). For a LaTeXML-namespace
499              // schema this is identical to Perl. (`""` prefix → `#default`.)
500              let primary = model.schema.as_ref().unwrap().primary_namespace.clone();
501              if let Some(primary) = primary {
502                model.register_document_namespace("", Some(&primary));
503              }
504            },
505          }
506        }
507      },
508    };
509  }
510  model.load_internal_extensions();
511  if model.debug_mode {
512    model.describe_model()
513  }
514
515  Ok(())
516}
517
518pub fn get_document_namespace_prefix(
519  namespace: &str,
520  forattribute: bool,
521  probe: bool,
522) -> Option<SymStr> {
523  // Get the prefix associated with the namespace url, noting that for elements, it might by
524  // "#default", but for attributes would never be.
525  // log!("Searching for {:?} in {:?}", namespace, self.document_namespace_prefixes);
526  let mut docprefix = if !forattribute {
527    model!()
528      .document_namespace_prefixes
529      .get(&s!("DEFAULT#{namespace}"))
530      .copied()
531  } else {
532    None
533  };
534  let ns_sym = arena::pin(namespace);
535  if docprefix.is_none() {
536    docprefix = model!()
537      .document_namespace_prefixes
538      .get_sym(ns_sym)
539      .copied();
540  }
541
542  // Perl Model.pm `getDocumentNamespacePrefix` L242: for a non-LaTeXML
543  // namespace with no explicit document prefix, fall back to the registered
544  // CODE prefix. This is what lets FOREIGN namespaces — built-in (`svg`, `m`,
545  // `xlink`, `xhtml`) or supplied by a third-party / runtime `.rhai` binding via
546  // `RegisterNamespace` — serialize under their conventional prefix instead of a
547  // synthetic `namespaceN` + "no prefix registered" warning (#652). The LaTeXML
548  // namespace is excluded: it is the default document namespace (registered from
549  // the schema's primary `ns=`) and serializes with no prefix.
550  if docprefix.is_none() && namespace != LTX_NAMESPACE {
551    docprefix = model!().code_namespace_prefixes.get_sym(ns_sym).copied();
552  }
553
554  if docprefix.is_none() && !probe {
555    {
556      model_mut!().namespace_errors += 1;
557    }
558    let ns_err = s!("namespace{}", &model!().namespace_errors.to_string());
559    docprefix = Some(arena::pin(&ns_err));
560    {
561      model_mut!().register_document_namespace(&ns_err, Some(namespace));
562    }
563    let message2 = if let Some(dp) = docprefix {
564      arena::with(dp, |dp_str| s!("Using '{dp_str}' instead"))
565    } else {
566      String::from("No prefix to fall back on.")
567    };
568    Warn!(
569      "malformed",
570      namespace,
571      "No prefix has been registered for namespace (in document)",
572      message2
573    );
574  }
575  let default_sym = pin!("#default");
576  docprefix.filter(|p| p != &default_sym)
577}
578
579pub fn get_document_namespace(docprefix: &str, probe: bool) -> Option<String> {
580  let h_default_sym = pin!("#default");
581  let docprefix_sym = if docprefix.is_empty() {
582    h_default_sym
583  } else {
584    arena::pin(docprefix)
585  };
586  let ns_str = match model!().document_namespaces.get_sym(docprefix_sym) {
587    None => String::new(),
588    Some(sym) => arena::with(*sym, |s| {
589      if s.starts_with("DEFAULT#") {
590        s.replacen("DEFAULT#", "", 1)
591      } else {
592        s.to_string()
593      }
594    }),
595  };
596
597  if docprefix_sym != h_default_sym && ns_str.is_empty() && !probe {
598    {
599      model_mut!().namespace_errors += 1;
600    }
601    let ns_error = s!(
602      "http://example.com/namespace{}",
603      &model!().namespace_errors.to_string()
604    );
605    {
606      model_mut!().register_document_namespace(docprefix, Some(&ns_error));
607    }
608    let msg1 = arena::with(docprefix_sym, |dp_str| {
609      s!("No namespace has been registered for prefix '{dp_str}' (in document)")
610    });
611    let msg2 = s!("Using '{ns_str}' instead");
612    let err = || {
613      Error!("malformed", docprefix, msg1, msg2);
614      Ok(())
615    };
616    err().ok();
617  }
618  if ns_str.is_empty() {
619    None
620  } else {
621    Some(ns_str)
622  }
623}
624
625/// Get the (code) prefix associated with $namespace,
626/// creating a dummy prefix and signalling an error if none has been registered.
627///
628/// In the following:
629/// $forattribute is 1 if the namespace is for an attribute (in which case, there must be a
630/// non-empty prefix) $probe, if non 0, just test for namespace, without creating an entry
631/// if missing.
632pub fn get_namespace_prefix(namespace: &str, _forattribute: bool, probe: bool) -> Option<SymStr> {
633  let mut codeprefix: Option<SymStr> = None;
634  let ns_sym = arena::pin(namespace);
635  let mut model = model_mut!();
636  if !namespace.is_empty() {
637    codeprefix = model.code_namespace_prefixes.get_sym(ns_sym).copied();
638
639    if codeprefix.is_some() && !probe {
640      {
641        let docprefix = model.document_namespace_prefixes.get_sym(ns_sym);
642        // if there's a doc prefix and it's NOT already used in code namespace mapping
643        if docprefix.is_some() && !model.code_namespaces.contains_key_sym(docprefix.unwrap()) {
644          codeprefix = docprefix.copied();
645        }
646      }
647    } else {
648      // Else synthesize one
649      model.namespace_errors += 1;
650      let auto_prefix = arena::pin(s!("namespace{}", &model.namespace_errors.to_string()));
651      codeprefix = Some(auto_prefix);
652    }
653    model.register_namespace_sym(codeprefix.unwrap(), Some(arena::pin(namespace)));
654    // Warn!('malformed', $namespace, undef,
655    //   "No prefix has been registered for namespace '$namespace' (in code)",
656    //   "Using '$codeprefix' instead"); }
657  }
658
659  codeprefix
660}
661
662pub fn get_namespace(codeprefix: &str, probe: bool) -> Result<Option<SymStr>> {
663  let mut model = model_mut!();
664  let mut ns: Option<SymStr> = model.code_namespaces.get(codeprefix).copied();
665  if ns.is_none() && !probe {
666    model.namespace_errors += 1;
667    let example_namespace = s!(
668      "http://example.com/namespace{}",
669      &model.namespace_errors.to_string()
670    );
671    ns = Some(arena::pin(&example_namespace));
672    model.register_namespace(codeprefix, Some(&example_namespace));
673    Error!(
674      "malformed",
675      codeprefix,
676      s!("No namespace has been registered for prefix '{codeprefix}' (in code)"),
677      s!("Using '{example_namespace}' instead")
678    );
679  }
680  Ok(ns)
681}
682
683/// Get the node's qualified name in standard form
684/// Ie. using the registered (code) prefix for that namespace.
685/// NOTE: Reconsider how _Capture_ & _WildCard_ should be integrated!?!
686/// Build a `prefix:local` qname with one exact-capacity allocation, avoiding the
687/// `format!` machinery (`s!`) that showed up under `get_node_qname` in profiles —
688/// this runs on every model check / serialization step.
689#[inline]
690fn prefixed_qname(prefix: &str, local: &str) -> String {
691  let mut q = String::with_capacity(prefix.len() + 1 + local.len());
692  q.push_str(prefix);
693  q.push(':');
694  q.push_str(local);
695  q
696}
697
698pub fn get_node_qname(node: &Node) -> SymStr {
699  use libxml::tree::NodeType::*;
700  let node_type = node.get_type();
701  if node_type.is_none() {
702    return pin!("#BrokenNode");
703  }
704  // per-node hot path: the literal branches use the call-site-cached `pin!`
705  // (branch+load) rather than a per-call `pin_static` arena probe.
706  match node_type.unwrap() {
707    TextNode => pin!("#PCDATA"),
708    DocumentNode => pin!("#Document"),
709    CommentNode => pin!("#Comment"),
710    PiNode => pin!("#ProcessingInstruction"),
711    DTDNode => pin!("#DTD"),
712    NamespaceDecl => {
713      // match node.declared_uri() {
714      //   Some(ns) => match self.get_namespace_prefix(ns, false, true) {
715      //     Some(prefix) => s!("xmlns:")+prefix,
716      //     None => s!("xmlns")
717      //   },
718      //   None => s!("xmlns")
719      // }
720      pin!("xmlns")
721    },
722    ElementNode | AttributeNode => {
723      let name_str = node.get_name();
724      // Use the actual namespace prefix from the node when available.
725      // For SVG/MathML/etc with explicit prefix, use that prefix directly.
726      if let Some(ns) = node.get_namespace() {
727        let prefix = ns.get_prefix();
728        if prefix.is_empty() {
729          // Default namespace — use ltx: prefix. The document we build declares
730          // ltx as ITS default namespace, so an empty prefix means ltx here, and
731          // this deliberately does NOT read the namespace URI to confirm it:
732          // `Namespace::get_href` allocates a String per call, and on this path
733          // (~140 call sites, once per node and per attribute) that measured a
734          // 3.4% whole-conversion regression. A node whose DEFAULT namespace is
735          // something else can only come from a foreign document — see
736          // [`get_foreign_node_qname`], which is what reads such a tree.
737          arena::pin(prefixed_qname("ltx", &name_str))
738        } else {
739          // Explicit prefix (e.g., "svg", "m") — use it
740          arena::pin(prefixed_qname(&prefix, &name_str))
741        }
742      } else {
743        // No namespace — special cases for non-namespaced elements
744        match name_str.as_str() {
745          "song" | "verse" => arena::pin(name_str),
746          regular => arena::pin(prefixed_qname("ltx", regular)),
747        }
748      }
749    },
750    // Need others?
751    _ => {
752      // Defense-in-depth (issue #217): a node with an unexpected libxml2 type
753      // reached qname resolution. Degrade to the same `#BrokenNode` sentinel
754      // the `node_type.is_none()` arm above already returns, rather than the
755      // original hard `panic!`. LaTeXML never builds such nodes, so on a
756      // healthy tree this arm is unreachable; it exists only so a stray
757      // corrupt/foreign node can never crash qname resolution.
758      pin!("#BrokenNode")
759    },
760  }
761}
762
763/// [`get_node_qname`] for a node read out of a FOREIGN document — a tree parsed
764/// by [`crate::common::xml::parse_fragment`] rather than built by us. Used by
765/// `Document::append_tree`, the one place such a tree is ever walked.
766///
767/// The difference is confined to the DEFAULT-namespace case. Perl's single
768/// `Model::getNodeQName` always maps a namespace URI to its registered code
769/// prefix (`Common/Model.pm` → `getNamespacePrefix`), which is what gives
770/// `RegisterNamespace` (`Package.pm:2049`) its effect on absorbed content: an
771/// xhtml snippet arrives as `<p xmlns="…/1999/xhtml">`, i.e. an EMPTY libxml
772/// prefix over a non-ltx URI, and must be re-created as `xhtml:p`. Mislabelling
773/// it `ltx:p` would strip exactly the namespace the XHTML post-processor keys on
774/// (`copy-foreign` matches `xhtml:*`), silently dropping the raw HTML.
775///
776/// Splitting that off from `get_node_qname` is a pure PERFORMANCE factoring with
777/// no behavioural difference, because inside our own document an element either
778/// sits in the ltx default namespace or carries an explicit code prefix — the
779/// re-created `xhtml:p` above included. So only a foreign tree can present the
780/// ambiguous shape, and reading the URI on the shared path would cost every
781/// other caller a `String` allocation per node (measured: 3.4%).
782pub fn get_foreign_node_qname(node: &Node) -> SymStr {
783  if let Some(ns) = node.get_namespace()
784    && ns.get_prefix().is_empty()
785  {
786    let href = ns.get_href();
787    if !href.is_empty() && href != LTX_NAMESPACE {
788      // `.copied()` yields an owned SymStr, so the model guard does not outlive
789      // this expression (no borrow held across the arena pins below).
790      let registered = model!()
791        .code_namespace_prefixes
792        .get_sym(arena::pin(href.as_str()))
793        .copied();
794      // An UNREGISTERED URI keeps the historical ltx fallback, i.e. whatever
795      // `get_node_qname` would have said.
796      if let Some(code_prefix) = registered {
797        let name_str = node.get_name();
798        return arena::pin(arena::with(code_prefix, |p| prefixed_qname(p, &name_str)));
799      }
800    }
801  }
802  get_node_qname(node)
803}
804
805/// Borrow a node's qualified name (`ltx:section`, `#PCDATA`, …) as a `&str`
806/// for the duration of `caller`.
807///
808/// The closure form is the point: qnames are interned, and lending the arena's
809/// copy lets a caller compare or match on the name without the `String` that
810/// [`get_node_qname`] + `to_string` would allocate on every node of a walk.
811pub fn with_node_qname<R, FnR>(node: &Node, caller: FnR) -> R
812where FnR: FnOnce(&str) -> R {
813  let qsym = get_node_qname(node);
814  arena::with(qsym, |qname_str| caller(qname_str))
815}
816
817/// Same as get_node_qname, but using the Document namespace prefixes
818pub fn get_node_document_qname(node: &Node) -> SymStr {
819  use libxml::tree::NodeType::*;
820  let node_type = node.get_type();
821  if node_type.is_none() {
822    return pin!("#BrokenNode");
823  }
824
825  match node_type.unwrap() {
826    TextNode => pin!("#PCDATA"),
827    DocumentNode => pin!("#Document"),
828    CommentNode => pin!("#Comment"),
829    PiNode => pin!("#ProcessingInstruction"),
830    DTDNode => pin!("#DTD"),
831
832    // TODO
833    // elsif ($type == XML_NAMESPACE_DECL) {
834    //   my $ns = $node->declaredURI;
835    //   my $prefix = $ns && $self->getDocumentNamespacePrefix($ns, 0, 1);
836    //   return ($prefix ? 'xmlns:' . $prefix : 'xmlns'); }
837    NamespaceDecl => pin!("xmlns"),
838
839    ElementNode | AttributeNode => {
840      let empty_sym = pin!("");
841      let mut prefix = empty_sym;
842      if let Some(ns) = node.get_namespace() {
843        let href = ns.get_href();
844        if !href.is_empty() {
845          prefix = get_document_namespace_prefix(&href, false, true).unwrap_or(empty_sym);
846        }
847      }
848      if prefix == empty_sym {
849        arena::pin(node.get_name())
850      } else {
851        arena::pin(arena::with(prefix, |prefix_str| {
852          s!("{}:{}", prefix_str, node.get_name())
853        }))
854      }
855    },
856    // Need others?
857    t => {
858      panic!("Fatal:misdefined:<caller> should not ask for qualified name for node of type {t:?}")
859    },
860  }
861}
862
863/// Read a possibly-PREFIXED attribute off a node, resolving the prefix the same
864/// way `Document::set_attribute` does on the write side (via [`decode_qname`]).
865///
866/// libxml stores `xml:id` as local name `id` in the built-in xml namespace, and
867/// `xmlGetProp` matches on the plain name — so `get_attribute("xml:id")` finds
868/// NOTHING. That asymmetry is invisible in Rust bindings (which read ids through
869/// `get_attribute_ns`) but bites any caller that writes an attribute by qualified
870/// name and then tries to read it back by the same name, which is exactly what a
871/// script does after `generateID`.
872pub fn get_node_attribute(node: &Node, key: &str) -> Option<String> {
873  if let Some(ns_uri) = attribute_namespace(key) {
874    let local = key.split_once(':').map_or(key, |(_, l)| l);
875    if let Some(value) = node.get_attribute_ns(local, &ns_uri) {
876      return Some(value);
877    }
878  }
879  node.get_attribute(key)
880}
881
882/// Remove a possibly-PREFIXED attribute — the counterpart of
883/// [`get_node_attribute`], with the same namespace resolution.
884pub fn remove_node_attribute(node: &mut Node, key: &str) -> std::result::Result<(), String> {
885  if let Some(ns_uri) = attribute_namespace(key) {
886    let local = key.split_once(':').map_or(key, |(_, l)| l).to_string();
887    if node.get_attribute_ns(&local, &ns_uri).is_some() {
888      return node
889        .remove_attribute_ns(&local, &ns_uri)
890        .map_err(|e| e.to_string());
891    }
892  }
893  node.remove_attribute(key).map_err(|e| e.to_string())
894}
895
896/// The namespace URI an attribute name resolves to, or `None` when it is
897/// unprefixed (or its prefix is not registered). `xml:` is built in — the one
898/// prefix [`decode_qname`] deliberately hands back whole for libxml to special-case.
899fn attribute_namespace(key: &str) -> Option<String> {
900  let (prefix, _) = key.split_once(':')?;
901  if prefix == "xml" {
902    return Some(XML_NS.to_string());
903  }
904  match decode_qname(key) {
905    Ok((ns_uri, _local)) => ns_uri,
906    Err(_) => None,
907  }
908}
909
910/// Given a Qualified name, possibly prefixed with a namespace prefix,
911/// as defined by the code namespace mapping,
912/// return the NamespaceURI and localname.
913pub fn decode_qname(codetag: &str) -> Result<(Option<String>, String)> {
914  match PREFIXED_LOCALNAME_RE.captures(codetag) {
915    Some(captures) => {
916      let prefix = captures.get(1).map_or("", |m| m.as_str());
917      let localname = captures.get(2).map_or("", |m| m.as_str());
918
919      if prefix == "xml" {
920        Ok((None, codetag.to_string()))
921      } else {
922        Ok((
923          get_namespace(prefix, false)?.map(arena::to_string),
924          localname.to_string(),
925        ))
926      }
927    },
928    None => Ok((None, codetag.to_string())),
929  }
930}
931
932/// TODO: We need a proper data model to deal with the Symbol - String distinction.
933/// For now let's allocate strings and release the arena, but this is a CODE SMELL!
934pub fn decode_qname_sym(sym: SymStr) -> Result<(Option<String>, String)> {
935  let codetag = arena::to_string(sym);
936  decode_qname(&codetag)
937}
938
939//**********************************************************************
940// Document Structure Queries
941//**********************************************************************
942// NOTE: These are public, but perhaps should be passed
943// to submodel, in case it can evolve to more precision?
944// However, it would need more context to do that.
945
946/// The single faithful containment check (Perl `Common/Model.pm::canContain`),
947/// keyed on interned `SymStr` names — the arena's native currency. This is the
948/// canonical implementation; [`can_contain`] is a thin `&str` wrapper over it.
949///
950/// It is the entry point the serializer's `#PCDATA` indentation test
951/// (`Document::serialize_into`) and the digestion auto-open/close logic use, so
952/// it stays allocation-lean: the common exact-match branch interns nothing, and
953/// the negation/namespace-wildcard probes fire only when the exact child is
954/// absent from the content model.
955///
956/// The `ns:*` namespace-wildcard fallback (below) is why a FOREIGN element whose
957/// content model is a wildcard — `ltx:rawhtml`'s `xhtml:*`, `svg:*` — is handled
958/// correctly: it has no exact `tagprop` entry, so an exact-match-only check
959/// wrongly answered `can_contain(xhtml:b, #PCDATA) == false`, and the serializer
960/// then treated mixed HTML content as block and injected indentation whitespace
961/// that HTML treats as significant (`<xhtml:b>\n  bold  </xhtml:b>`).
962/// arXiv/html_feedback#680 (xworld21).
963pub fn can_contain_sym(tag: SymStr, child: SymStr) -> bool {
964  // Handle obvious cases explicitly (Perl Common/Model.pm::canContain).
965  if tag == pin!("#PCDATA") || tag == pin!("#Comment") || tag == pin!("") {
966    return false;
967  } else if tag == pin!("_WildCard_") {
968    return true;
969  }
970  // A `_Capture_` tag contains anything; a `_Capture_`/`_CaptureBlock_` child is
971  // contained by anything (with or without a namespace prefix).
972  if arena::with(tag, |t| t.ends_with("_Capture_"))
973    || arena::with(child, |c| {
974      c.ends_with("_Capture_") || c.ends_with("_CaptureBlock_")
975    })
976  {
977    return true;
978  }
979  if child == pin!("_WildCard_")
980    || child == pin!("#Comment")
981    || child == pin!("#ProcessingInstruction")
982    || child == pin!("#DTD")
983  {
984    return true;
985  }
986
987  let model = model!();
988  if model.permissive && tag == pin!("#Document") && child != pin!("#PCDATA") {
989    return true; // No schema? Punt!
990  }
991
992  // Query tag properties, falling back to the `ns:*` wildcard entry when this
993  // exact tag has none of its own, then the most-specific-first chain — Perl
994  // `Common/Model.pm`. The fallback is what lets a wildcard content model such
995  // as `ltx:rawhtml{}(xhtml:*)` accept every concrete `xhtml:p`/`xhtml:b`.
996  let frame = model.tagprop.get_sym(tag);
997  let wildcard_frame = if frame.is_none() {
998    namespace_wildcard_sym(tag).and_then(|w| model.tagprop.get_sym(w))
999  } else {
1000    None
1001  };
1002  let content = &frame
1003    .or(wildcard_frame)
1004    .unwrap_or(&*DEFAULT_TAG_FRAME)
1005    .model;
1006  content.contains(&pin!("ANY")) || set_allows(content, child)
1007}
1008
1009/// `&str` wrapper over the canonical [`can_contain_sym`]; interns both names and
1010/// delegates. Kept for call sites that hold string names rather than `SymStr`.
1011pub fn can_contain(tag: &str, child: &str) -> bool {
1012  can_contain_sym(arena::pin(tag), arena::pin(child))
1013}
1014
1015/// Perl `Model::canContain`/`canHaveAttribute` fall back to the NAMESPACE
1016/// WILDCARD entry when a prefixed tag has no `tagprop` entry of its own
1017/// (`Common/Model.pm`: `if (!$model && ($tag =~ /^(\w*):/)) { $xtag = $1 . ':*' }`).
1018/// This is how a wildcard schema element such as `xhtml:*` — the content model of
1019/// `ltx:rawhtml` — governs every concrete `xhtml:p`/`xhtml:b` absorbed into it.
1020///
1021/// Two knowingly narrower details than the Perl regex, neither reachable with the
1022/// schema we compile (checked against `resources/RelaxNG/LaTeXML.model`, whose
1023/// only `ns:*` entries are `*:*` and `xhtml:*`):
1024/// * Perl falls back when the tag has no entry OR its entry has no `model` /
1025///   `attributes` key; a Rust `TagFrame` always has both, possibly empty, so the
1026///   callers fall back only when the whole frame is missing. Reaching that
1027///   difference needs a schema with BOTH an empty-set entry and a matching `ns:*`
1028///   entry — e.g. an `ltx:*`, which no LaTeXML schema declares.
1029/// * Perl's `\w*` does not match `*`, so it never derives a wildcard from a tag
1030///   that is already one; `split_once` would, but only for the `*:*` entry, which
1031///   maps to itself and is looked up only when it has no frame — and it has one.
1032fn namespace_wildcard_sym(tag: SymStr) -> Option<SymStr> {
1033  arena::with(tag, |t| t.split_once(':').map(|(ns, _)| s!("{ns}:*"))).map(|w| arena::pin(&w))
1034}
1035
1036/// Perl's most-specific-first membership chain over a `tagprop` set — shared by
1037/// `canContain`'s child test and `canHaveAttribute`'s attribute test
1038/// (`Common/Model.pm`). Order: exact, `!exact`, then for a PREFIXED key the
1039/// namespace wildcard `ns:*` / `!ns:*` and finally `*:*` / `!*:*`; for an
1040/// unprefixed key, `*` / `!*`. A `!`-prefixed entry is an explicit exclusion and
1041/// always beats the broader wildcard that follows it. The compiled model really
1042/// does carry these (e.g. `ltx:XMText{!aria:*,!xml:*,*:*,…}`), so an exact-match-only
1043/// test both over-rejects (ignoring `*:*`) and under-rejects (ignoring `!ns:*`).
1044///
1045/// Allocation profile: the exact-match branch interns nothing (the caller's
1046/// `key` is already a `SymStr`). On an exact miss we probe the broadest ALLOWING
1047/// wildcard first — a cached `pin!` for `*` / `*:*`, no allocation — and build a
1048/// `!key` / `ns:*` probe string only when it can still change the answer. So the
1049/// serializer's hot `can_contain(<block element>, #PCDATA)` query, whose model
1050/// carries no `*`, returns here having interned nothing. Any owned probe string
1051/// is built inside `arena::with` and interned only AFTER that borrow is released:
1052/// the `BufferBackend` interner can realloc on a new intern, which would dangle a
1053/// resolved `&str` still held from `key`.
1054fn set_allows(set: &HashSet<SymStr>, key: SymStr) -> bool {
1055  if set.contains(&key) {
1056    return true; // exact hit — the common case, interns nothing
1057  }
1058  if set.is_empty() {
1059    return false;
1060  }
1061  // Exact miss. The unprefixed hot path (the serializer's `#PCDATA` query) can
1062  // only be turned `true` by `*`, a cached `pin!` — so a block element's model,
1063  // which has no `*`, returns here without interning any probe string.
1064  if !arena::with(key, |k| k.contains(':')) {
1065    // Unprefixed key — Perl only consults `!*` / `*`, both cached pins.
1066    if !set.contains(&pin!("*")) || set.contains(&pin!("!*")) {
1067      return false;
1068    }
1069    // `*` allows it unless an explicit `!key` excludes it — build that one probe.
1070    let negated = arena::with(key, |k| s!("!{k}"));
1071    return !set.contains(&arena::pin(&negated));
1072  }
1073  // Prefixed key (cold path — the serializer never queries one). Perl order:
1074  // !key, ns:*, !ns:*, !*:*, *:*. Each probe string is built inside `arena::with`
1075  // and interned only after that borrow drops (BufferBackend may realloc).
1076  let negated = arena::with(key, |k| s!("!{k}"));
1077  if set.contains(&arena::pin(&negated)) {
1078    return false;
1079  }
1080  let (ns_star, neg_ns_star) = arena::with(key, |k| {
1081    let ns = k.split_once(':').map_or("", |(ns, _)| ns);
1082    (s!("{ns}:*"), s!("!{ns}:*"))
1083  });
1084  if set.contains(&arena::pin(&ns_star)) {
1085    return true;
1086  }
1087  if set.contains(&arena::pin(&neg_ns_star)) {
1088    return false;
1089  }
1090  if set.contains(&pin!("!*:*")) {
1091    return false;
1092  }
1093  set.contains(&pin!("*:*"))
1094}
1095
1096pub fn can_have_attribute(tag: SymStr, attrib: SymStr) -> bool {
1097  // Handle obvious cases explicitly.
1098  if let Some(early_choice) = arena::with(tag, |tag_str| match tag_str {
1099    "#PCDATA" | "#Comment" | "#Document" | "#ProcessingInstruction" | "#DTD" => Some(false),
1100    "_WildCard_" => Some(true),
1101    other if other.ends_with("_Capture_") => Some(true),
1102    _ => None,
1103  }) {
1104    return early_choice;
1105  };
1106  // Perl: `return 1 if $attrib =~ /^_/;` — internal bookkeeping attributes are
1107  // always permitted, whatever the schema says about the element.
1108  if arena::with(attrib, |a| a.starts_with('_')) {
1109    return true;
1110  }
1111  let model = model!();
1112  if model.permissive {
1113    return true;
1114  }
1115
1116  // Else query tag properties, falling back to the `ns:*` wildcard entry when
1117  // this exact tag has none of its own (Perl canHaveAttribute).
1118  let frame = model.tagprop.get_sym(tag);
1119  let wildcard_frame = if frame.is_none() {
1120    namespace_wildcard_sym(tag).and_then(|w| model.tagprop.get_sym(w))
1121  } else {
1122    None
1123  };
1124  let attributes = &frame
1125    .or(wildcard_frame)
1126    .unwrap_or(&*DEFAULT_TAG_FRAME)
1127    .attributes;
1128  set_allows(attributes, attrib)
1129}
1130
1131pub fn is_node_in_schema_class(class_name: &str, tag: &Node) -> bool {
1132  let tag = get_node_qname(tag);
1133  is_in_schema_class(arena::pin(class_name), tag)
1134}
1135pub fn is_in_schema_class(class_name: SymStr, tag: SymStr) -> bool {
1136  match model!().schema_class.get_sym(class_name) {
1137    Some(class) => class.contains(&tag),
1138    _ => false,
1139  }
1140}
1141
1142//**********************************************************************
1143// Accessors
1144//**********************************************************************
1145
1146pub fn get_tags() -> Vec<SymStr> { model!().tagprop.keys().copied().collect() }
1147
1148pub fn get_tag_contents(tag: SymStr) -> Vec<SymStr> {
1149  match model!().tagprop.get_sym(tag) {
1150    Some(h) => h.model.iter().copied().collect(),
1151    None => Vec::new(),
1152  }
1153}
1154pub fn set_model(new_model: Model) {
1155  let mut model = model_mut!();
1156  *model = new_model;
1157}
1158pub fn is_permissive() -> bool { model!().permissive }
1159
1160pub fn with_schema_data<FnR, R>(caller: FnR) -> R
1161where FnR: FnOnce(Option<&Vec<SymStr>>) -> R {
1162  caller(model!().schema_data.as_ref())
1163}
1164pub fn set_schema(schema: Relaxng) {
1165  let mut model = model_mut!();
1166  model.schema = Some(schema);
1167}
1168pub fn set_schema_class(classname: &str, content: HashSet<SymStr>) {
1169  model_mut!().set_schema_class(classname, content)
1170}
1171pub fn add_tag_content(tag: &str, elements: Vec<&str>) {
1172  model_mut!().add_tag_content(tag, elements)
1173}
1174pub fn add_tag_attribute(tag: &str, attributes: Vec<&str>) {
1175  model_mut!().add_tag_attribute(tag, attributes)
1176}
1177
1178pub(crate) fn compute_indirect_model_aux(
1179  tag: SymStr,
1180  start_opt: Option<SymStr>,
1181  desirability: usize,
1182  openability: &mut SymHashMap<u32>,
1183  desc: &mut SymHashMap<SymHashMap<usize>>,
1184) {
1185  let start = match start_opt {
1186    Some(s) => s,
1187    None => pin!(""),
1188  };
1189
1190  // A bit tricky here, we need to release the model_mut!() borrow immediately, which is why we
1191  // move ownership of the tag strings into the tag_contents vector.
1192  // That leads to a bunch of .clone()s later one, but stays close to the original algorithm
1193  let tag_contents: Vec<SymStr> = get_tag_contents(tag);
1194
1195  for kid in tag_contents {
1196    // Memoise on (kid, start) to bound recursion in cyclic schemas, but
1197    // retain the *maximum* desirability observed across paths — the
1198    // outer loop in compute_indirect_model picks the highest-scoring
1199    // starting tag, so the score stored here must reflect the best path,
1200    // not the first one the hashmap iteration happened to surface.
1201    //
1202    // The prior "first visit wins" behavior (WISDOM #49) caused paralists
1203    // test-harness runs to assign `desc[#PCDATA][ltx:text] = 50` when
1204    // `contents(text)` iterated `ltx:picture` before `#PCDATA`: the
1205    // sub-recursion `text → picture → #PCDATA` inserted 50 first and the
1206    // direct `text → #PCDATA` path was skipped, forcing the auto-open
1207    // path to pick `<ltx:picture>` instead of `<ltx:text>`.
1208    let prior = desc.entry_sym(kid).or_default().get_sym(start).copied();
1209    if let Some(prior_d) = prior
1210      && prior_d >= desirability
1211    {
1212      continue;
1213    }
1214
1215    if start != pin!("") {
1216      desc
1217        .entry_sym(kid)
1218        .or_default()
1219        .insert_sym(start, desirability);
1220    }
1221
1222    if kid != pin!("#PCDATA")
1223      && let Some(priority) = openability.get_sym(kid).copied()
1224    {
1225      let inner = if start != pin!("") { start } else { kid };
1226      // Perl Document.pm L220: `$desirability * $x`. We keep integer
1227      // arithmetic (priorities scaled by 100), so this is a scaled multiply.
1228      let next_desirability = desirability * (priority as usize) / 100;
1229      compute_indirect_model_aux(kid, Some(inner), next_desirability, openability, desc);
1230    }
1231  }
1232}
1233/// Bind an OUTPUT-document prefix to a namespace URI, on the current model.
1234///
1235/// The document half of the two mappings described on
1236/// [`Model::register_namespace`] — these are the prefixes that appear in the
1237/// generated XML, and the one place `#default` is meaningful (for unprefixed
1238/// elements; never for attributes). Passing `None` unbinds the prefix.
1239pub fn register_document_namespace(docprefix: &str, namespace_opt: Option<&str>) {
1240  model_mut!().register_document_namespace(docprefix, namespace_opt)
1241}
1242
1243/// Returns all registered document namespace prefixes and their URIs.
1244pub fn get_document_namespace_prefixes() -> Vec<(String, String)> {
1245  model!()
1246    .document_namespace_prefixes
1247    .iter()
1248    .map(|(ns_sym, prefix_sym)| {
1249      let prefix = arena::with(*prefix_sym, |s| s.to_string());
1250      let ns = arena::with(*ns_sym, |s| s.to_string());
1251      (prefix, ns)
1252    })
1253    .collect()
1254}
1255
1256/// Bind a CODE prefix to a namespace URI, on the current model.
1257///
1258/// The coding half of the two mappings described on
1259/// [`Model::register_namespace`] — the prefixes constructors and bindings write,
1260/// which must be one-to-one and admit no default namespace. Passing `None`
1261/// unbinds the prefix.
1262pub fn register_namespace(codeprefix: &str, namespace_opt: Option<&str>) {
1263  model_mut!().register_namespace(codeprefix, namespace_opt)
1264}
1265
1266pub fn with_code_namespaces<FnR, R>(caller: FnR) -> R
1267where FnR: FnOnce(&SymHashMap<SymStr>) -> R {
1268  caller(&model!().code_namespaces)
1269}
1270
1271#[cfg(test)]
1272mod wildcard_resolution_tests {
1273  //! Direct coverage for the Perl `Common/Model.pm` resolution helpers shared by
1274  //! `canContain` (child test) and `canHaveAttribute` (attribute test). The
1275  //! end-to-end script-bindings guard only exercises the plain `*` branch via
1276  //! `xhtml:*`, so the negation and namespace-wildcard branches are pinned here.
1277  use super::*;
1278
1279  fn set_of(keys: &[&str]) -> HashSet<SymStr> { keys.iter().map(|k| arena::pin(*k)).collect() }
1280  // `&str` shims over the sym-native helpers under test, so the cases read the
1281  // same as the schema keys they mirror.
1282  fn allows(set: &HashSet<SymStr>, key: &str) -> bool { set_allows(set, arena::pin(key)) }
1283  fn wildcard(tag: &str) -> Option<String> {
1284    namespace_wildcard_sym(arena::pin(tag)).map(|w| arena::with(w, str::to_string))
1285  }
1286
1287  #[test]
1288  fn namespace_wildcard_tag_only_applies_to_prefixed_tags() {
1289    assert_eq!(wildcard("xhtml:p").as_deref(), Some("xhtml:*"));
1290    assert_eq!(wildcard("ltx:para").as_deref(), Some("ltx:*"));
1291    // Unprefixed names have no namespace wildcard to fall back on.
1292    assert_eq!(wildcard("para"), None);
1293    assert_eq!(wildcard("#PCDATA"), None);
1294  }
1295
1296  #[test]
1297  fn exact_membership_wins_and_empty_sets_reject() {
1298    let s = set_of(&["class", "href"]);
1299    assert!(allows(&s, "class"));
1300    assert!(!allows(&s, "style"));
1301    assert!(!allows(&HashSet::default(), "class"));
1302  }
1303
1304  #[test]
1305  fn a_negation_beats_the_wildcard_that_would_otherwise_allow_it() {
1306    // Perl order: exact, !exact, ns:*, !ns:*, !*:*, *:*  — the `!` entries are
1307    // explicit exclusions and must beat the broader wildcard that follows them.
1308    // This is the real shape of a compiled entry, e.g.
1309    // `ltx:XMText{!aria:*,!xml:*,*:*,about,…}`.
1310    let s = set_of(&["!aria:*", "!xml:*", "*:*", "about"]);
1311    assert!(allows(&s, "about"), "exact entry allows");
1312    assert!(allows(&s, "data:foo"), "*:* allows an unexcluded namespace");
1313    assert!(!allows(&s, "aria:label"), "!aria:* excludes its namespace");
1314    assert!(!allows(&s, "xml:lang"), "!xml:* excludes its namespace");
1315
1316    // An exact exclusion beats a namespace wildcard that would allow it.
1317    let s2 = set_of(&["!svg:width", "svg:*"]);
1318    assert!(allows(&s2, "svg:height"));
1319    assert!(!allows(&s2, "svg:width"));
1320
1321    // `!*:*` denies every namespaced key that is not exactly listed.
1322    let s3 = set_of(&["!*:*", "svg:width"]);
1323    assert!(allows(&s3, "svg:width"));
1324    assert!(!allows(&s3, "svg:height"));
1325  }
1326
1327  #[test]
1328  fn unprefixed_keys_use_the_bare_star_not_the_namespaced_one() {
1329    // `*:*` governs PREFIXED keys only; an unprefixed `class` needs `*`.
1330    let namespaced_only = set_of(&["*:*"]);
1331    assert!(!allows(&namespaced_only, "class"));
1332    assert!(allows(&namespaced_only, "svg:width"));
1333
1334    // This is the entry that lets attributes survive on absorbed xhtml markup:
1335    // the compiled model carries `xhtml:*{*,*:*}`.
1336    let html_wildcard = set_of(&["*", "*:*"]);
1337    assert!(allows(&html_wildcard, "class"));
1338    assert!(allows(&html_wildcard, "xlink:href"));
1339
1340    // …and `!*` denies the unprefixed ones.
1341    let denied = set_of(&["!*", "*:*"]);
1342    assert!(!allows(&denied, "class"));
1343    assert!(allows(&denied, "svg:width"));
1344
1345    // An explicit `!key` exclusion denies the key even when a bare `*` would
1346    // otherwise allow it (the `*`-present branch of the unprefixed fast path).
1347    let star_but_excluded = set_of(&["*", "!class"]);
1348    assert!(
1349      !allows(&star_but_excluded, "class"),
1350      "!class excludes despite *"
1351    );
1352    assert!(
1353      allows(&star_but_excluded, "id"),
1354      "* still allows an unexcluded name"
1355    );
1356  }
1357}
1358
1359#[cfg(test)]
1360mod schema_load_tests {
1361  //! #652: a raw `.rng` selected via `RelaxNGSchema()` (no compiled `.model`)
1362  //! must populate the runtime tag/attribute tables through `load_schema`, so
1363  //! the document validates instead of every element being rejected.
1364  use super::*;
1365
1366  #[test]
1367  fn raw_rng_scan_populates_tagprop_end_to_end() {
1368    // A minimal self-contained grammar, no `.model` alongside it → forces the
1369    // runtime raw-`.rng` scan path.
1370    let dir = std::env::temp_dir().join(format!("lxo652_{}", std::process::id()));
1371    std::fs::create_dir_all(&dir).expect("mkdir");
1372    let rng = "<grammar xmlns=\"http://relaxng.org/ns/structure/1.0\">\
1373       <start><ref name=\"document\"/></start>\
1374       <define name=\"document\"><element name=\"document\">\
1375         <zeroOrMore><ref name=\"para\"/></zeroOrMore></element></define>\
1376       <define name=\"para\"><element name=\"para\">\
1377         <attribute name=\"class\"/><text/></element></define>\
1378       </grammar>";
1379    std::fs::write(dir.join("lxo652schema.rng"), rng).expect("write rng");
1380
1381    initialize_model();
1382    model_mut!().set_relaxng_schema("lxo652schema");
1383    let dir_str = dir.to_str().unwrap();
1384    load_schema(&[dir_str]).expect("load_schema");
1385
1386    // Before the fix these were all false (empty tagprop) → the document was
1387    // rejected and the output emptied.
1388    assert!(
1389      can_contain("#Document", "document"),
1390      "document root must be allowed after a raw .rng scan"
1391    );
1392    assert!(can_contain("document", "para"), "document may contain para");
1393    assert!(
1394      can_have_attribute(pin!("para"), pin!("class")),
1395      "para must allow its @class"
1396    );
1397    assert!(
1398      !can_have_attribute(pin!("para"), pin!("bogus")),
1399      "para must NOT allow an undeclared attribute"
1400    );
1401
1402    let _ = std::fs::remove_dir_all(&dir);
1403  }
1404
1405  /// #652 (reopened): a schema whose target namespace is a **default `ns=`** (no
1406  /// `xmlns:` prefix — how real `LaTeXML.rng` is written) must resolve to the
1407  /// conventional code prefix (`ltx`) that `base_schema` registered, NOT a
1408  /// synthetic `namespace1`. Perl: the scanner delegates `encodeQName` to the
1409  /// model, which consults `code_namespace_prefixes`.
1410  #[test]
1411  fn default_ns_schema_uses_registered_code_prefix() {
1412    let dir = std::env::temp_dir().join(format!("lxo652def_{}", std::process::id()));
1413    std::fs::create_dir_all(&dir).expect("mkdir");
1414    // dlmf as the DEFAULT namespace; NO xmlns:ltx declaration in the schema.
1415    let default_ns = "<grammar xmlns=\"http://relaxng.org/ns/structure/1.0\" \
1416         ns=\"http://dlmf.nist.gov/LaTeXML\">\
1417       <start><ref name=\"document\"/></start>\
1418       <define name=\"document\"><element name=\"document\">\
1419         <zeroOrMore><ref name=\"para\"/></zeroOrMore></element></define>\
1420       <define name=\"para\"><element name=\"para\">\
1421         <attribute name=\"class\"/><text/></element></define>\
1422       </grammar>";
1423    std::fs::write(dir.join("lxo652def.rng"), default_ns).expect("write rng");
1424
1425    initialize_model();
1426    // Mimic base_schema.rs:11 — the engine registers `ltx` for the dlmf namespace
1427    // on every conversion, before any RelaxNGSchema() runs.
1428    model_mut!().register_namespace("ltx", Some(LTX_NAMESPACE));
1429    model_mut!().set_relaxng_schema("lxo652def");
1430    let dir_str = dir.to_str().unwrap();
1431    load_schema(&[dir_str]).expect("load_schema");
1432
1433    // The document root is in the default (dlmf) namespace → it must be reachable
1434    // under the registered `ltx` prefix, not a synthesized `namespaceN`.
1435    assert!(
1436      can_contain("#Document", "ltx:document"),
1437      "default-ns root must resolve to the registered ltx: prefix (#652)"
1438    );
1439    assert!(
1440      can_contain("ltx:document", "ltx:para"),
1441      "ltx:document may contain ltx:para"
1442    );
1443    assert!(
1444      !can_contain("#Document", "namespace1:document"),
1445      "must NOT invent a synthetic namespace1 prefix for a registered namespace (#652)"
1446    );
1447
1448    let _ = std::fs::remove_dir_all(&dir);
1449  }
1450
1451  /// #652 fallback guard: a namespace the engine did NOT register, but the
1452  /// schema declares an explicit `xmlns:` prefix for, keeps that declared prefix
1453  /// (Perl `getNamespacePrefix`: code prefix → document prefix → synthesize).
1454  #[test]
1455  fn schema_declared_prefix_survives_when_unregistered_in_code() {
1456    let dir = std::env::temp_dir().join(format!("lxo652decl_{}", std::process::id()));
1457    std::fs::create_dir_all(&dir).expect("mkdir");
1458    // `ex` is declared in the schema (xmlns:ex) but never registered in code.
1459    let rng = "<grammar xmlns=\"http://relaxng.org/ns/structure/1.0\" \
1460         ns=\"http://example.com/ex\" xmlns:ex=\"http://example.com/ex\">\
1461       <start><ref name=\"root\"/></start>\
1462       <define name=\"root\"><element name=\"root\"><empty/></element></define>\
1463       </grammar>";
1464    std::fs::write(dir.join("lxo652decl.rng"), rng).expect("write rng");
1465
1466    initialize_model();
1467    model_mut!().set_relaxng_schema("lxo652decl");
1468    load_schema(&[dir.to_str().unwrap()]).expect("load_schema");
1469
1470    assert!(
1471      can_contain("#Document", "ex:root"),
1472      "schema-declared xmlns:ex prefix must be used when code has none (#652)"
1473    );
1474    let _ = std::fs::remove_dir_all(&dir);
1475  }
1476
1477  /// #652 output-serialization: the schema's primary (default `ns=`) namespace
1478  /// becomes the DEFAULT document namespace — serialized with NO prefix — rather
1479  /// than being forgotten (Perl RelaxNG.pm L78).
1480  #[test]
1481  fn schema_primary_ns_becomes_default_document_namespace() {
1482    let dir = std::env::temp_dir().join(format!("lxo652out_{}", std::process::id()));
1483    std::fs::create_dir_all(&dir).expect("mkdir");
1484    let rng = "<grammar xmlns=\"http://relaxng.org/ns/structure/1.0\" \
1485         ns=\"http://dlmf.nist.gov/LaTeXML\">\
1486       <start><ref name=\"document\"/></start>\
1487       <define name=\"document\"><element name=\"document\"><empty/></element></define>\
1488       </grammar>";
1489    std::fs::write(dir.join("lxo652out.rng"), rng).expect("write rng");
1490
1491    initialize_model();
1492    model_mut!().register_namespace("ltx", Some(LTX_NAMESPACE));
1493    model_mut!().set_relaxng_schema("lxo652out");
1494    load_schema(&[dir.to_str().unwrap()]).expect("load_schema");
1495
1496    // The primary namespace is the output default (`#default` → dlmf) …
1497    assert_eq!(
1498      get_document_namespace("", true).as_deref(),
1499      Some(LTX_NAMESPACE),
1500      "schema primary ns must be the default document namespace (#652)"
1501    );
1502    // … so it serializes with NO prefix (probe → no synthetic namespaceN + warning).
1503    assert_eq!(
1504      get_document_namespace_prefix(LTX_NAMESPACE, false, true),
1505      None,
1506      "the default document namespace serializes without a prefix (#652)"
1507    );
1508  }
1509
1510  /// #652 foreign prefixes: a namespace registered only in CODE — a built-in
1511  /// (`svg`) or a third-party / runtime `.rhai` `RegisterNamespace` — serializes
1512  /// under that code prefix, never a synthetic `namespaceN` + warning (Perl
1513  /// Model.pm `getDocumentNamespacePrefix` L242 code-prefix fallback).
1514  #[test]
1515  fn foreign_and_runtime_namespaces_serialize_under_their_code_prefix() {
1516    initialize_model();
1517    // Built-in foreign namespace, as base_schema registers it.
1518    model_mut!().register_namespace("svg", Some("http://www.w3.org/2000/svg"));
1519    // A third-party / runtime binding's RegisterNamespace (e.g. a BookML .rhai).
1520    model_mut!().register_namespace("bk", Some("http://bookml.example/ns"));
1521
1522    assert_eq!(
1523      get_document_namespace_prefix("http://www.w3.org/2000/svg", false, true),
1524      Some(pin!("svg")),
1525      "built-in foreign svg namespace serializes under its code prefix (#652)"
1526    );
1527    assert_eq!(
1528      get_document_namespace_prefix("http://bookml.example/ns", false, true),
1529      Some(pin!("bk")),
1530      "a runtime/third-party registered namespace serializes under its prefix (#652)"
1531    );
1532  }
1533}