Skip to main content

latexml_core/common/relaxng/
tex.rs

1//! Schema-doc TeX emission. Port of `RelaxNG.pm` lines 545–815.
2//!
3//! Walks the simplified AST (in [`Relaxng::modules`] and the lookup
4//! tables populated by [`super::simplify`]) and produces a single
5//! `schema.tex` string consumable by `latexmlman.sty`'s `\schemamodule`
6//! / `\patterndef` / `\elementdef` / `\attrdef` / `\moduleref` /
7//! `\patternref` / `\elementref` macros.
8//!
9//! Behaviour intentionally byte-equivalent (modulo whitespace settled
10//! at the unit-test level) with the Perl `documentModules` for the
11//! same simplified state. The schema-doc-style omissions
12//! (`SKIP_SVG`/`SKIP_ARIA`/`SKIP_XHTML`) live on [`Options`] with the
13//! same defaults as upstream.
14
15// The `to_tex*` methods take `&mut self` because `EmitState` is a
16// state-machine accumulator threaded through the entire walk — depth,
17// per-module counters, and the schema-mappings cache all mutate as
18// emission proceeds. Clippy's default `wrong_self_convention` rule
19// expects `to_*` to consume `self`; that's the wrong shape here.
20#![allow(clippy::wrong_self_convention)]
21
22use std::collections::BTreeMap;
23
24use rustc_hash::FxHashMap as HashMap;
25
26use super::{CombineOp, DefCombiner, Pattern, Relaxng};
27
28/// Result of `detect_element_choice`: a combiner (Choice/Group/
29/// Interleave) plus the list of `(element_name, element_body)` pairs
30/// that participate in the choice.
31type ElementChoice<'a> = (CombineOp, Vec<(String, &'a [Pattern])>);
32
33/// Schema-doc emission options. Defaults match Perl's
34/// `$SKIP_SVG=1;$SKIP_ARIA=1;$SKIP_XHTML=1` constants.
35#[derive(Debug, Clone, Copy)]
36pub struct Options {
37  pub skip_svg:   bool,
38  pub skip_aria:  bool,
39  pub skip_xhtml: bool,
40}
41
42impl Default for Options {
43  fn default() -> Self {
44    Options {
45      skip_svg:   true,
46      skip_aria:  true,
47      skip_xhtml: true,
48    }
49  }
50}
51
52/// Emission state — mutated as `document_modules` walks the AST.
53struct EmitState<'a> {
54  rng:                &'a Relaxng,
55  opts:               Options,
56  /// Mirrors Perl's `$$self{defined_patterns}{$name}`.
57  /// `1`  = at least one `\patterndef{name}` already emitted,
58  /// `-1` = at least one `\patternadd{name}` emitted but no `\patterndef` yet.
59  /// Final pass upgrades `\patternadd` → `\patterndefadd` for any -1.
60  defined_patterns:   HashMap<String, i8>,
61  /// Element tags claimed by more than one singleton-element define —
62  /// see [`ambiguous_element_tags`].
63  ambiguous_elements: rustc_hash::FxHashSet<String>,
64}
65
66/// Element tags hosted by more than one define (`element TAG {…}`
67/// appearing in several patterns).
68///
69/// In LaTeXML's XML schema every tag is unique (`section = element
70/// ltx:section {…}`), so the docs can identify a definition by its
71/// element name alone — refs render `\elementref{TAG}` and the define
72/// renders as an Element card (the Perl theme). HTML profiles break
73/// that assumption: dozens of defines share `div` / `span` / `h6`,
74/// and reporting the tag erases the only identifying handle — content
75/// models read `(div | div | div)` and "Used by" lists say `div`.
76/// Rendering switches on this set: ambiguous tags keep the define's
77/// own name (`\patternref` + a patterndef card); unique tags keep the
78/// legacy element-folded rendering byte-for-byte.
79///
80/// Hosts are counted across all three places a tag claims a define:
81/// the singleton-fold registry (`elementdefs`), doc-carrying singleton
82/// defines (whose stored def collapses to a bare `Element`), and the
83/// use-site graph (`element:TAG@pattern:HOST` entries — which also
84/// counts tags appearing in multi-element choices like
85/// `element h2 {…} | element h3 {…}`).
86fn ambiguous_element_tags(rng: &Relaxng) -> rustc_hash::FxHashSet<String> {
87  let mut hosts: HashMap<&str, rustc_hash::FxHashSet<&str>> = HashMap::default();
88  for (qname, tag) in &rng.elementdefs {
89    hosts
90      .entry(tag.as_str())
91      .or_default()
92      .insert(qname.as_str());
93  }
94  for (qname, pat) in &rng.defs {
95    if let Pattern::Element { name, .. } = pat {
96      hosts
97        .entry(name.as_str())
98        .or_default()
99        .insert(qname.as_str());
100    }
101  }
102  for uses in rng.uses_name.values() {
103    for u in uses {
104      if let Some(rest) = u.strip_prefix("element:")
105        && let Some((tag, host)) = rest.split_once('@')
106      {
107        // Normalize to the bare define qname so the same define
108        // counted from `elementdefs`/`defs` (plain qname) and from
109        // the uses graph (`pattern:`-prefixed) stays one host.
110        let host = host.strip_prefix("pattern:").unwrap_or(host);
111        hosts.entry(tag).or_default().insert(host);
112      }
113    }
114  }
115  hosts
116    .into_iter()
117    .filter(|(_, h)| h.len() > 1)
118    .map(|(tag, _)| tag.to_string())
119    .collect()
120}
121
122/// Top-level emission. Returns a single `schema.tex` string.
123pub fn document_modules(rng: &Relaxng, opts: Options) -> String {
124  let mut emit = EmitState {
125    rng,
126    opts,
127    defined_patterns: HashMap::default(),
128    ambiguous_elements: ambiguous_element_tags(rng),
129  };
130  let mut docs = String::new();
131  // Each Module renders as one page (`--splitat=section`), regardless
132  // of def count. Page-size is mitigated client-side by CSS lazy
133  // paint and a JS search/filter input — splitting the module across
134  // multiple pages would break Ctrl-F across the whole module, which
135  // is the primary navigation affordance.
136  for module in &rng.modules {
137    let (op, name, content) = match module {
138      Pattern::Module { name, body } => ("module", name.clone(), body),
139      _ => continue,
140    };
141    let _ = op;
142    if emit.opts.skip_svg && is_svg_module(&name) {
143      continue;
144    }
145    let mod_name = strip_urn_prefix(&name);
146
147    // Modules typically wrap their content in a single
148    // `Pattern::Grammar`; descend into it so the iteration sees each
149    // individual def directly.
150    let to_emit: Vec<&Pattern> = content
151      .iter()
152      .flat_map(|item| match item {
153        Pattern::Grammar { body, .. } => body.iter().collect::<Vec<_>>(),
154        other => vec![other],
155      })
156      .collect();
157
158    let mut preamble = String::new();
159    // Outer-Grammar "Includes:" preamble line (collected once,
160    // emitted on the first synthetic group's page if partitioning).
161    for item in content {
162      if let Pattern::Grammar { body, .. } = item {
163        let mods: Vec<String> = body
164          .iter()
165          .filter_map(|d| match d {
166            Pattern::Module { name, .. } => Some(name.clone()),
167            _ => None,
168          })
169          .collect();
170        if !mods.is_empty() {
171          let refs: Vec<String> = mods
172            .iter()
173            .map(|m| format!("\\moduleref{{{}}}", clean_tex(m)))
174            .collect();
175          if !preamble.is_empty() {
176            preamble.push('\n');
177          }
178          preamble.push_str(&format!(
179            "\\par\\noindent\\textit{{Includes:}} {}.",
180            refs.join(", ")
181          ));
182        }
183      }
184    }
185
186    // Render each def, preserving source order in `defs`.
187    let mut defs: Vec<String> = Vec::new();
188    for item in &to_emit {
189      // Module entries already accounted for in the Includes line.
190      if matches!(item, Pattern::Module { .. }) {
191        continue;
192      }
193      let rendered = emit.to_tex(item);
194      if rendered.is_empty() {
195        continue;
196      }
197      match item {
198        Pattern::Doc(_) | Pattern::Start { .. } => {
199          if !preamble.is_empty() {
200            preamble.push('\n');
201          }
202          preamble.push_str(&rendered);
203        },
204        _ => defs.push(rendered),
205      }
206    }
207
208    docs.push_str(&format!("\n\\begin{{schemamodule}}{{{}}}", mod_name));
209    if !preamble.is_empty() {
210      docs.push('\n');
211      docs.push_str(&preamble);
212    }
213    let body = defs.join("\n");
214    if !body.is_empty() {
215      docs.push_str(&format!(
216        "\n\\begin{{description}}\n{}\n\\end{{description}}",
217        body
218      ));
219    }
220    docs.push_str("\n\\end{schemamodule}");
221  }
222  // Final pass: any pattern emitted only as `\patternadd` becomes
223  // `\patterndefadd`. Mirrors Perl `$docs =~ s/\\patternadd\{$name\}/\\patterndefadd{$name}/s`
224  // — single substitution per name.
225  let mut keys: Vec<String> = emit
226    .defined_patterns
227    .iter()
228    .filter(|(_, v)| **v < 0)
229    .map(|(k, _)| k.clone())
230    .collect();
231  keys.sort();
232  for name in keys {
233    let from = format!("\\patternadd{{{}}}", name);
234    let to = format!("\\patterndefadd{{{}}}", name);
235    if let Some(idx) = docs.find(&from) {
236      docs.replace_range(idx..idx + from.len(), &to);
237    }
238  }
239  docs
240}
241
242// ----- string-escape helpers ---------------------------------------------
243
244/// `cleanTeX`: escape `#`, escape `_`, wrap `<...>` in `\texttt{...}`,
245/// strip URN prefix, recognise `#PCDATA`.
246pub fn clean_tex(s: &str) -> String {
247  if s == "#PCDATA" {
248    return String::from(r"\typename{text}");
249  }
250  let mut out = strip_urn_prefix(s);
251  // Order matters: escape # before \texttt{...} expansion (no #s in
252  // the wrapper), and _ at the end so the others' inserted text
253  // doesn't collide.
254  out = out.replace('#', "\\#");
255  out = wrap_angle_text(&out);
256  out = out.replace('_', "\\_");
257  out
258}
259
260/// `cleanTeXName`: clean_tex + strip any leading prefix listed in
261/// `display_strip_prefixes`. The strip list is auto-populated from
262/// the schema's `default namespace` URI (mapped to its prefix) — see
263/// `Relaxng::auto_strip_primary_namespace`. So a LaTeXML schema
264/// (primary `http://dlmf.nist.gov/LaTeXML` → `ltx`) renders
265/// `\elementref{para}` rather than `\elementref{ltx:para}`; an
266/// XHTML-flavoured schema's `xhtml:div` reads as `div`; a MathML
267/// schema's `m:math` reads as `math`.
268fn clean_tex_name(s: &str, strip_prefixes: &[String]) -> String {
269  let cleaned = clean_tex(s);
270  for prefix in strip_prefixes {
271    let with_colon = format!("{}:", prefix);
272    if let Some(rest) = cleaned.strip_prefix(&with_colon) {
273      return rest.to_string();
274    }
275  }
276  cleaned
277}
278
279fn strip_urn_prefix(s: &str) -> String {
280  s.strip_prefix("urn:x-LaTeXML:RelaxNG:")
281    .unwrap_or(s)
282    .to_string()
283}
284
285/// Replace each `<TEXT>` substring with `\texttt{TEXT}`.
286fn wrap_angle_text(s: &str) -> String {
287  let mut out = String::with_capacity(s.len());
288  let bytes = s.as_bytes();
289  let mut i = 0;
290  while i < bytes.len() {
291    if bytes[i] == b'<'
292      && let Some(end) = s[i + 1..].find('>')
293    {
294      let inner = &s[i + 1..i + 1 + end];
295      out.push_str("\\texttt{");
296      out.push_str(inner);
297      out.push('}');
298      i += 1 + end + 1;
299      continue;
300    }
301    out.push(bytes[i] as char);
302    i += 1;
303  }
304  out
305}
306
307// ----- main dispatcher ----------------------------------------------------
308
309impl EmitState<'_> {
310  fn to_tex(&mut self, p: &Pattern) -> String {
311    match p {
312      // Trailing blank line so adjacent `<a:documentation>` annotations
313      // (trang emits one per `## comment` block separated by a blank
314      // line) survive into TeX as *paragraph-separated* prose, not a
315      // single run-on. LaTeXML reads a blank line as `\par`.
316      Pattern::Doc(s) => format!("{}\n\n", clean_tex(s)),
317      Pattern::Ref { qname } => self.to_tex_ref(qname),
318      Pattern::Def { combiner, name, body } => {
319        let combiner_label = match combiner {
320          DefCombiner::Group => "",
321          DefCombiner::Choice => "choice",
322          DefCombiner::Interleave => "interleave",
323        };
324        self.to_tex_def(combiner_label, name, body)
325      },
326      Pattern::Element { name, body } => self.to_tex_element(name, body),
327      Pattern::Attribute { name, body } => self.to_tex_attribute(name, body),
328      Pattern::Combination { op, body } => self.to_tex_combination(*op, body),
329      Pattern::Data(t) => format!("\\typename{{{}}}", clean_tex(t)),
330      Pattern::Value(v) => format!("\\attrval{{{}}}", clean_tex(v)),
331      Pattern::Start { body } => {
332        // Module-level <start>: emit as a paragraph, not a description-
333        // list item. The Perl original emitted
334        // `\item[\textit{Start}]\textbf{==}\ root`, but that depended
335        // on living inside the moduledescription environment that the
336        // old section-split layout opened. With per-def subsection
337        // splitting there's no enclosing list at module scope, so
338        // module-preamble notes flow as prose.
339        let (docs, spec) = self.extract_docs(body);
340        let content = spec
341          .iter()
342          .map(|p| self.to_tex(p))
343          .collect::<Vec<_>>()
344          .join(" ");
345        let mut s = format!("\\par\\noindent\\textit{{Start symbol:}} {}", content);
346        if !docs.is_empty() {
347          s.push_str(&format!(" \\par{}", docs));
348        }
349        s
350      },
351      Pattern::Grammar { body, .. } => {
352        // The grammar's leading <include>'s become an "Includes" line
353        // of `\moduleref{…}`s, then the rest of the body (defs, doc,
354        // etc.) flows through normally. Module preamble is paragraph
355        // text — see Pattern::Start above for rationale.
356        let mut mods: Vec<String> = Vec::new();
357        let mut rest: Vec<&Pattern> = Vec::new();
358        for d in body {
359          match d {
360            Pattern::Module { name, .. } => mods.push(name.clone()),
361            other => rest.push(other),
362          }
363        }
364        let mut out = String::new();
365        if !mods.is_empty() {
366          let refs: Vec<String> = mods
367            .iter()
368            .map(|m| format!("\\moduleref{{{}}}", clean_tex(m)))
369            .collect();
370          out.push_str(&format!(
371            "\\par\\noindent\\textit{{Includes:}} {}.\n",
372            refs.join(", ")
373          ));
374        }
375        for r in rest {
376          out.push_str(&self.to_tex(r));
377          out.push('\n');
378        }
379        out
380      },
381      Pattern::Module { name, .. } => {
382        // Standalone Module reference (rare — most are absorbed into
383        // the parent Grammar's "Includes" line). Emit as a brief
384        // paragraph note rather than a list item.
385        if self.opts.skip_svg && is_svg_module(name) {
386          format!(
387            "\\par\\noindent\\textit{{Module}} \\texttt{{{}}} \\textit{{included}}.",
388            clean_tex(name)
389          )
390        } else {
391          format!(
392            "\\par\\noindent\\textit{{Module}} \\moduleref{{{}}} \\textit{{included}}.",
393            clean_tex(name)
394          )
395        }
396      },
397      Pattern::ParentRef { qname } => self.to_tex_ref(qname),
398      Pattern::ElementRef { qname } => format!(
399        "\\elementref{{{}}}",
400        clean_tex_name(qname, &self.rng.display_strip_prefixes)
401      ),
402      Pattern::Override { module, .. } => self.to_tex(module),
403      Pattern::Text => clean_tex("#PCDATA"),
404    }
405  }
406
407  fn to_tex_ref(&self, name: &str) -> String {
408    if let Some(el) = self.rng.elementdefs.get(name) {
409      // Ambiguous tags (every `*.elem` define in an HTML profile is a
410      // `div`) fall through to the `\patternref` rendering below — the
411      // element name doesn't identify the definition, the pattern
412      // name does, and it links the define's own card.
413      if !self.ambiguous_elements.contains(el) {
414        let cleaned = clean_tex_name(el, &self.rng.display_strip_prefixes);
415        if self.opts.skip_xhtml && cleaned == "xhtml:*" {
416          return String::from("\\texttt{xhtml:*}");
417        }
418        return format!("\\elementref{{{}}}", cleaned);
419      }
420    }
421    if (name.ends_with("_attributes") || name.ends_with("_model"))
422      && let Some(def) = self.rng.defs.get(name)
423    {
424      // Read-only recursion is fine here; we don't mutate state on
425      // the ref-expansion path (Perl doesn't either).
426      let cloned = def.clone();
427      let mut tmp = EmitState {
428        rng:                self.rng,
429        opts:               self.opts,
430        defined_patterns:   HashMap::default(),
431        ambiguous_elements: self.ambiguous_elements.clone(),
432      };
433      return tmp.to_tex(&cloned);
434    }
435    let stripped = strip_first_qualifier(name);
436    if self.opts.skip_svg && stripped == "svg" {
437      return String::from("\\texttt{svg:svg}");
438    }
439    format!("\\patternref{{{}}}", clean_tex(&stripped))
440  }
441
442  fn to_tex_def(&mut self, combiner: &str, qname: &str, data: &[Pattern]) -> String {
443    // Singleton-element define (`X = element TAG {…}`) — the
444    // simplifier registers these in `elementdefs` and keeps the Def
445    // wrapper so the rendering can choose by tag uniqueness. Unique
446    // tag: render the element card directly, exactly as the
447    // previously-folded AST did (XML-schema docs unchanged).
448    // Ambiguous tag (HTML profiles where every define is a `div`):
449    // fall through to the generic path, which emits a
450    // `\patterndef{X}` card — the anchor that `\patternref{X}` links
451    // from `to_tex_ref` need — with the element as a sibling card.
452    if combiner.is_empty()
453      && self.rng.elementdefs.contains_key(qname)
454      && data.len() == 1
455      && let Pattern::Element { name, body } = &data[0]
456      && !self.ambiguous_elements.contains(name)
457    {
458      return self.to_tex_element(name, body);
459    }
460    if self.opts.skip_aria && qname.contains("aria") {
461      return String::new();
462    }
463    if qname.ends_with("_attributes") || qname.ends_with("_model") {
464      return String::new();
465    }
466    let stripped = strip_first_qualifier(qname);
467    if self.opts.skip_svg && stripped.starts_with("svg") {
468      return String::new();
469    }
470    let cleaned_name = clean_tex(&stripped);
471    let (docs, spec) = self.extract_docs(data);
472
473    // Compact card for a singleton-element define whose tag is
474    // ambiguous (`X = element div {…}` in a profile where many defines
475    // are a `div`). The element-choice rendering below would emit
476    // `Content: \elementref{div}` plus an anonymous "Element div"
477    // sibling card — two cards, both titled by the uninformative tag.
478    // Collapse them into one patterndef card: an `Element:` fact row
479    // naming the rendered tag, then the element's own attribute /
480    // content rows. The patterndef anchor is what `\patternref{X}`
481    // links (`to_tex_ref` falls back to it for ambiguous tags).
482    if combiner.is_empty()
483      && spec.len() == 1
484      && let Pattern::Element { name, body } = &spec[0]
485      && self.ambiguous_elements.contains(name)
486      && !is_wildcard_name(name)
487    {
488      if matches!(self.defined_patterns.get(&cleaned_name), Some(v) if *v > 0) {
489        return String::new();
490      }
491      self.defined_patterns.insert(cleaned_name.clone(), 1);
492      let (el_docs, el_spec) = self.extract_docs(body);
493      let merged_docs = format!("{}{}", docs, el_docs);
494      let (attr, content) = self.to_tex_body(&el_spec);
495      let mut card = format!(
496        "\\item[\\textit{{Element}}:] \\texttt{{{}}}",
497        clean_tex_name(name, &self.rng.display_strip_prefixes)
498      );
499      card.push_str(&attr);
500      if !content.is_empty() {
501        card.push_str(&format!("\\item[\\textit{{Content}}:] {}", content));
502      }
503      if let Some(uses) = self.symbol_uses(qname) {
504        card.push_str(&format!("\\item[\\textit{{Used by}}:] {}", uses));
505      }
506      let mut out = format!(
507        "\\patterndef{{{}}}{{{}}}{{{}}}\n",
508        cleaned_name, merged_docs, card
509      );
510      // Anonymous elements nested in the content render inline as
511      // `\elementref` links; give them their sibling cards so the
512      // links resolve on this page (same idiom as the generic
513      // patterndef path below).
514      for (el_name, el_body) in &collect_element_descendants(&el_spec) {
515        let cleaned_el = clean_tex_name(el_name, &self.rng.display_strip_prefixes);
516        let (el_attr, el_content) = self.to_tex_body(el_body);
517        let mut eb = el_attr;
518        if !el_content.is_empty() {
519          eb.push_str(&format!("\\item[\\textit{{Content}}:] {}", el_content));
520        }
521        eb.push_str(&format!(
522          "\\item[\\textit{{Used by}}:] \\patternref{{{}}}",
523          cleaned_name
524        ));
525        out.push_str(&format!("\\elementdef{{{}}}{{}}{{{}}}\n", cleaned_el, eb));
526      }
527      return out;
528    }
529
530    // Compact rendering for `X = element a {B} | element b {B} | …`
531    // shapes, where every alternative is a same-bodied element. The
532    // generic path emits one `\elementdef` card per branch wrapped in
533    // `(... ~\textbar~ ...)`; that produces orphan `(`, `|`, `)` text
534    // around the cards once LaTeXML promotes each `\item` into a
535    // sibling list. The compact form lists the names monospaced and
536    // shows the shared body once.
537    if combiner.is_empty()
538      && let Some((op, elements)) = self.detect_element_choice(&spec)
539    {
540      let (pattern_body, element_defs) =
541        self.render_element_choice(qname, &cleaned_name, op, &elements);
542      if matches!(self.defined_patterns.get(&cleaned_name), Some(v) if *v > 0) {
543        return String::new();
544      }
545      self.defined_patterns.insert(cleaned_name.clone(), 1);
546      let mut out = format!(
547        "\\patterndef{{{}}}{{{}}}{{{}}}\n",
548        cleaned_name, docs, pattern_body
549      );
550      for ed in element_defs {
551        out.push_str(&ed);
552      }
553      return out;
554    }
555
556    let (attr, content) = self.to_tex_body(&spec);
557
558    if !combiner.is_empty() {
559      let mut body = attr;
560      if !content.is_empty() {
561        let sep = if combiner == "choice" {
562          "\\textbar="
563        } else {
564          "\\&="
565        };
566        body.push_str(&format!("\\item[{}] {}", sep, content));
567      }
568      self
569        .defined_patterns
570        .entry(cleaned_name.clone())
571        .or_insert(-1);
572      return format!("\\patternadd{{{}}}{{{}}}{{{}}}\n", cleaned_name, docs, body);
573    }
574
575    // Bare def
576    let mut attr = attr;
577    let mut content = content;
578    if attr.is_empty() && cleaned_name.contains("\\_attributes") {
579      attr = String::from("\\item[\\textit{Attributes:}] \\textit{empty}");
580    }
581    if content.is_empty() && cleaned_name.contains("\\_model") {
582      content = String::from("\\textit{empty}");
583    }
584    let mut body = attr;
585    if !content.is_empty() {
586      body.push_str(&format!("\\item[\\textit{{Content}}:] {}", content));
587    }
588    // Expansion line (when defs[qname] is content-shaped and differs).
589    if !cleaned_name.contains("\\_attributes")
590      && let Some(stored) = self.rng.defs.get(qname)
591      && self.is_content(stored)
592      && !self.is_attributes(stored)
593    {
594      let (xattr, xcontent) = self.to_tex_body(std::slice::from_ref(stored));
595      // Suppress when the stored form differs from `content` only by
596      // the outer `(...)` wrap that `to_tex_combination(Group)` adds:
597      // for patterns like `anyElement` the def-args path renders the
598      // body unwrapped while the stored-Combination path re-wraps it,
599      // and emitting both yields a near-duplicate Expansion block.
600      let unwrapped = strip_outer_parens(&xcontent);
601      if xattr.is_empty() && !xcontent.is_empty() && xcontent != content && unwrapped != content {
602        body.push_str(&format!("\\item[\\textit{{Expansion}}:] {}", xcontent));
603      }
604    }
605    if let Some(uses) = self.symbol_uses(qname) {
606      body.push_str(&format!("\\item[\\textit{{Used by}}:] {}", uses));
607    }
608    if matches!(self.defined_patterns.get(&cleaned_name), Some(v) if *v > 0) {
609      return String::new();
610    }
611    self.defined_patterns.insert(cleaned_name.clone(), 1);
612    // Extract any nested non-wildcard `Pattern::Element` descendants
613    // into sibling `\elementdef` cards so the inline `\elementref`
614    // links in the patterndef body resolve to a card on the same
615    // page. Skip when no descendants — most patterns have none.
616    let extras = collect_element_descendants(&spec);
617    let mut out = format!("\\patterndef{{{}}}{{{}}}{{{}}}\n", cleaned_name, docs, body);
618    for (el_name, el_body) in &extras {
619      let cleaned_el = clean_tex_name(el_name, &self.rng.display_strip_prefixes);
620      let (el_attr, el_content) = self.to_tex_body(el_body);
621      let mut eb = el_attr;
622      if !el_content.is_empty() {
623        eb.push_str(&format!("\\item[\\textit{{Content}}:] {}", el_content));
624      }
625      eb.push_str(&format!(
626        "\\item[\\textit{{Used by}}:] \\patternref{{{}}}",
627        cleaned_name
628      ));
629      out.push_str(&format!("\\elementdef{{{}}}{{}}{{{}}}\n", cleaned_el, eb));
630    }
631    out
632  }
633
634  fn to_tex_element(&mut self, qname: &str, data: &[Pattern]) -> String {
635    let local = qname.strip_prefix("ltx:").unwrap_or(qname);
636    if self.opts.skip_xhtml && local == "xhtml:*" {
637      return String::new();
638    }
639    // Wildcard element names (`*`, `*:*`, `prefix:*`) come from `<anyName/>`
640    // / `<nsName/>` in the source schema — they describe "an element of
641    // any name", not a real definable element. Render them inline as a
642    // content-model expression so they sit gracefully inside the parent
643    // pattern's body. Emitting `\elementdef{*}{...}` here would inject a
644    // nested definition card (with its own Content/Attribute rows) into
645    // the parent card, which is the rendering bug visible on patterns
646    // like `anyElement`.
647    if is_wildcard_name(qname) {
648      return self.render_inline_element(qname, data);
649    }
650    let cleaned = clean_tex_name(qname, &self.rng.display_strip_prefixes);
651    let (docs, spec) = self.extract_docs(data);
652    let (attr, content) = self.to_tex_body(&spec);
653    let content = if content.is_empty() {
654      String::from("\\typename{empty}")
655    } else {
656      content
657    };
658    let mut body = attr;
659    body.push_str(&format!("\\item[\\textit{{Content}}:] {}", content));
660    if let Some(ename) = self.rng.element_reverse_defs.get(qname)
661      && let Some(uses) = self.symbol_uses(ename)
662    {
663      body.push_str(&format!("\\item[\\textit{{Used by}}:] {}", uses));
664    }
665    format!("\\elementdef{{{}}}{{{}}}{{{}}}\n", cleaned, docs, body)
666  }
667
668  fn to_tex_attribute(&mut self, name: &str, data: &[Pattern]) -> String {
669    let cleaned = clean_tex_name(name, &self.rng.display_strip_prefixes);
670    if let Some(rest) = cleaned.strip_prefix('!') {
671      return format!(
672        "\\item[\\textit{{Excluding attribute }}]\\texttt{{{}}}",
673        rest
674      );
675    }
676    // Same wildcard-handling rationale as `to_tex_element`: render inline
677    // so the parent pattern's body doesn't pick up a nested `\attrdef`
678    // item card for a name like `*` or `*:*`.
679    if is_wildcard_name(&cleaned) {
680      return self.render_inline_attribute(&cleaned, data);
681    }
682    let (docs, spec) = self.extract_docs(data);
683    let content = if spec.is_empty() {
684      String::from("\\typename{text}")
685    } else {
686      spec
687        .iter()
688        .map(|p| self.to_tex(p))
689        .collect::<Vec<_>>()
690        .join(" ")
691    };
692    format!("\\attrdef{{{}}}{{{}}}{{{}}}", cleaned, docs, content)
693  }
694
695  /// Inline rendering of an `<element>` pattern when it sits inside
696  /// another pattern's body — produce text that won't trip LaTeXML's
697  /// `\item` promotion.
698  ///
699  /// For real-name elements (e.g. `xhtml:div`) returns just
700  /// `\elementref{xhtml:div}` — a link to the sibling `\elementdef`
701  /// card that `to_tex_def`'s extraction emits. The body itself
702  /// belongs on that card, not inlined here.
703  ///
704  /// For wildcard names (`*`, `*:*`, `prefix:*`), there is no card to
705  /// link to, so render the wildcard inline as
706  /// `\textit{element}~\texttt{NAME}~\{BODY\}` so the body's content
707  /// model is at least visible somewhere.
708  fn render_inline_element(&mut self, qname: &str, data: &[Pattern]) -> String {
709    if !is_wildcard_name(qname) {
710      return format!(
711        "\\elementref{{{}}}",
712        clean_tex_name(qname, &self.rng.display_strip_prefixes)
713      );
714    }
715    let cleaned = clean_tex_name(qname, &self.rng.display_strip_prefixes);
716    let (_docs, spec) = self.extract_docs(data);
717    let parts: Vec<String> = spec.iter().map(|p| self.to_tex(p)).collect();
718    let parts: Vec<String> = parts.into_iter().filter(|s| !s.is_empty()).collect();
719    if parts.is_empty() {
720      format!("\\textit{{element}}~\\texttt{{{}}}", cleaned)
721    } else {
722      format!(
723        "\\textit{{element}}~\\texttt{{{}}}~\\{{{}\\}}",
724        cleaned,
725        parts.join(", ")
726      )
727    }
728  }
729
730  /// Detect a pattern body that's "purely a list of element
731  /// definitions" — either a bare singleton (`pattern X = element Y {…}`
732  /// with a leading `## doc` that blocks the simplify shortcut) or a
733  /// Choice/Group/Interleave of N non-wildcard elements
734  /// (`X = element a {…} | element b {…} | …`). Both shapes
735  /// mishandle in the generic path: nested `\elementdef` macros
736  /// render as cards inside the patterndef body, jumping out of
737  /// their `\item` and leaving empty `<dd>`s or orphan `(... | ...)`
738  /// punctuation. `to_tex_def` swaps them for an alphabetised
739  /// `\elementref` Choice expression in the patterndef body plus
740  /// sibling `\elementdef` cards (one per unique name).
741  ///
742  /// Returns `(combiner, [(element_name, element_body)])`.
743  fn detect_element_choice<'a>(&self, spec: &'a [Pattern]) -> Option<ElementChoice<'a>> {
744    if spec.len() != 1 {
745      return None;
746    }
747    // Bare singleton: `spec = [Element]`. Treat as a 1-element Choice
748    // (op doesn't matter — the renderer never inserts a separator for
749    // a single name).
750    if let Pattern::Element { name, body } = &spec[0] {
751      if is_wildcard_name(name) {
752        return None;
753      }
754      return Some((CombineOp::Choice, vec![(name.clone(), body.as_slice())]));
755    }
756    let Pattern::Combination { op, body } = &spec[0] else {
757      return None;
758    };
759    if !matches!(
760      op,
761      CombineOp::Choice | CombineOp::Group | CombineOp::Interleave
762    ) {
763      return None;
764    }
765    if body.is_empty() {
766      return None;
767    }
768    let mut elements: Vec<(String, &'a [Pattern])> = Vec::with_capacity(body.len());
769    for child in body {
770      let Pattern::Element { name, body: el_body } = child else {
771        return None;
772      };
773      if is_wildcard_name(name) {
774        return None;
775      }
776      elements.push((name.clone(), el_body.as_slice()));
777    }
778    Some((*op, elements))
779  }
780
781  /// Render `detect_element_choice` output. Returns:
782  ///
783  /// * the patterndef body — a single `Content` line whose value is an alphabetised expression
784  ///   `(name1 | name2 | …)` of `\elementref` links to the sibling cards. The post-pass's
785  ///   `render_content_models` then pretty-prints it with operator- leading layout. Plus the
786  ///   regular Used-by line.
787  /// * a list of `\elementdef{name}{}{…}` strings — one per unique element name (deduped,
788  ///   source-order kept). Each carries its own body (so distinct-bodied variants of the same name
789  ///   keep their first-seen body), plus a Used-by line citing the parent pattern.
790  fn render_element_choice(
791    &mut self,
792    qname: &str,
793    parent_clean: &str,
794    op: CombineOp,
795    elements: &[(String, &[Pattern])],
796  ) -> (String, Vec<String>) {
797    // Pattern body: one Choice/Group/Interleave expression of
798    // alphabetised \elementref links. Use the op-appropriate join
799    // string so the post-pass tokenizer sees the same operator
800    // tokens it does for any other content-model expression.
801    let mut sorted_names: Vec<String> = elements.iter().map(|(n, _)| n.clone()).collect();
802    sorted_names.sort();
803    sorted_names.dedup();
804    let names_tex: Vec<String> = sorted_names
805      .iter()
806      .map(|n| {
807        format!(
808          "\\elementref{{{}}}",
809          clean_tex_name(n, &self.rng.display_strip_prefixes)
810        )
811      })
812      .collect();
813    let sep = match op {
814      CombineOp::Choice => " ~\\textbar~ ",
815      CombineOp::Interleave => " ~\\&~ ",
816      CombineOp::Group => ", ",
817      _ => " ~\\textbar~ ",
818    };
819    let content = if names_tex.len() == 1 {
820      names_tex[0].clone()
821    } else {
822      format!("({})", names_tex.join(sep))
823    };
824    let mut body = format!("\\item[\\textit{{Content}}:] {}", content);
825    if let Some(uses) = self.symbol_uses(qname) {
826      body.push_str(&format!("\\item[\\textit{{Used by}}:] {}", uses));
827    }
828
829    // Sibling \elementdef cards. Dedupe by name (first occurrence
830    // wins): two `element a {B1} | element a {B2}` branches collapse
831    // to a single `xhtml:a` card carrying B1, since the post-pass
832    // can only assign `id="schema.xhtml..a"` to one anchor anyway.
833    let mut seen: rustc_hash::FxHashSet<String> = rustc_hash::FxHashSet::default();
834    let mut element_defs: Vec<String> = Vec::new();
835    for (name, el_body) in elements {
836      if !seen.insert(name.clone()) {
837        continue;
838      }
839      let cleaned = clean_tex_name(name, &self.rng.display_strip_prefixes);
840      let (el_attr, el_content) = self.to_tex_body(el_body);
841      let mut eb = el_attr;
842      if !el_content.is_empty() {
843        eb.push_str(&format!("\\item[\\textit{{Content}}:] {}", el_content));
844      }
845      eb.push_str(&format!(
846        "\\item[\\textit{{Used by}}:] \\patternref{{{}}}",
847        parent_clean
848      ));
849      element_defs.push(format!("\\elementdef{{{}}}{{}}{{{}}}\n", cleaned, eb));
850    }
851    (body, element_defs)
852  }
853
854  /// Inline rendering of an `<attribute><anyName/>...</attribute>` (or
855  /// `<nsName/>`) pattern: `\textit{attribute}~\texttt{NAME}=CONTENT`.
856  fn render_inline_attribute(&mut self, cleaned: &str, data: &[Pattern]) -> String {
857    let (_docs, spec) = self.extract_docs(data);
858    let content = if spec.is_empty() {
859      String::from("\\typename{text}")
860    } else {
861      spec
862        .iter()
863        .map(|p| self.to_tex(p))
864        .collect::<Vec<_>>()
865        .join(" ")
866    };
867    format!("\\textit{{attribute}}~\\texttt{{{}}}={}", cleaned, content)
868  }
869
870  fn to_tex_combination(&mut self, op: CombineOp, data: &[Pattern]) -> String {
871    // Collapse adjacent wildcard pairs (`*` followed by `*:*`) — they
872    // come from a single `<anyName/>` and would otherwise render twice.
873    let dedup_owned: Vec<Pattern>;
874    let data: &[Pattern] = if has_wildcard_pair(data) {
875      dedup_owned = dedupe_wildcard_pairs(data);
876      &dedup_owned
877    } else {
878      data
879    };
880    // Render Element / Attribute children inline (text-shape) instead
881    // of as `\elementdef` / `\attrdef` cards. The card macros expand
882    // to `\item[…]` which LaTeXML promotes out of the surrounding
883    // paragraph, leaving the Combination's `(`, `~\textbar~`, `)`
884    // tokens as orphan text fragments around an unrelated sibling
885    // list. Inline rendering keeps the whole Combination on a single
886    // text line. (For wildcard names, `to_tex_element`/`to_tex_attribute`
887    // already routes to inline.)
888    let inner: Vec<String> = data
889      .iter()
890      .map(|p| match p {
891        Pattern::Element { name, body } if !is_wildcard_name(name) => {
892          self.render_inline_element(name, body)
893        },
894        Pattern::Attribute { name, body } if !is_wildcard_name(name) => {
895          let cleaned = clean_tex_name(name, &self.rng.display_strip_prefixes);
896          self.render_inline_attribute(&cleaned, body)
897        },
898        _ => self.to_tex(p),
899      })
900      .collect();
901    match op {
902      CombineOp::Group => {
903        if inner.len() == 1 {
904          inner.into_iter().next().unwrap()
905        } else {
906          format!("({})", inner.join(", "))
907        }
908      },
909      CombineOp::Interleave => format!("({})", inner.join(" ~\\&~ ")),
910      CombineOp::Choice => format!("({})", inner.join(" ~\\textbar~ ")),
911      CombineOp::Optional => {
912        // Single attribute body: emit without the textsuperscript wrapper.
913        if inner.len() == 1 && matches!(data[0], Pattern::Attribute { .. }) {
914          inner.into_iter().next().unwrap()
915        } else {
916          format!(
917            "{}\\textsuperscript{{?}}",
918            inner.first().cloned().unwrap_or_default()
919          )
920        }
921      },
922      CombineOp::ZeroOrMore | CombineOp::OneOrMore => {
923        // Note: Perl emits ^{*} for both zeroOrMore and oneOrMore — preserved.
924        format!(
925          "{}\\textsuperscript{{*}}",
926          inner.first().cloned().unwrap_or_default()
927        )
928      },
929      CombineOp::List => format!("({})", inner.join(", ")),
930    }
931  }
932
933  // ----- helpers ----------------------------------------------------------
934
935  /// Pull leading `Doc` items from `data`, return (docs-joined, rest).
936  /// Mirrors `toTeXExtractDocs`.
937  fn extract_docs(&mut self, data: &[Pattern]) -> (String, Vec<Pattern>) {
938    let mut docs = String::new();
939    let mut rest = Vec::with_capacity(data.len());
940    for item in data {
941      if let Pattern::Doc(_) = item {
942        docs.push_str(&self.to_tex(item));
943      } else {
944        rest.push(item.clone());
945      }
946    }
947    (docs, rest)
948  }
949
950  /// Partition `data` into `(attrs_string, content_string)`, with the
951  /// same heuristics as Perl `toTeXBody`. Recursive expansion of
952  /// `*_attributes` / `*_model` refs, and pattern refs whose name ends
953  /// with `attributes` flow into the attribute list as-is.
954  fn to_tex_body(&mut self, data: &[Pattern]) -> (String, String) {
955    let mut attributes: Vec<String> = Vec::new();
956    let mut content: Vec<String> = Vec::new();
957    let mut attr_patterns: Vec<String> = Vec::new();
958    // Perl uses shift+unshift to inline-expand `*_attributes`/`*_model`
959    // refs and attribute-shaped Combinations as their members are
960    // encountered. A front-poppable deque mirrors that traversal.
961    // Dedupe `*`/`*:*` wildcard pairs at this layer too: the def-args
962    // path (`<define>` with a single wildcard `<element>` body) feeds
963    // them straight in without a Combination wrapper.
964    let dedup_owned: Vec<Pattern>;
965    let data: &[Pattern] = if has_wildcard_pair(data) {
966      dedup_owned = dedupe_wildcard_pairs(data);
967      &dedup_owned
968    } else {
969      data
970    };
971    // Group "trivial-body" attributes by their datatype so a long run
972    // of identical `attribute foo {text}` rows collapses into a single
973    // `Text attributes: a, b, c` line. Wildcards (`*`, `*:*`) skip the
974    // grouping path — they have no enumerable name. Attributes carrying
975    // a `Doc` annotation also skip it (we'd lose the doc otherwise).
976    // Key = the type label ("text" / "string" / …); BTreeMap gives a
977    // stable alphabetical render order across types.
978    let mut grouped: BTreeMap<String, Vec<String>> = BTreeMap::new();
979    let mut deque: std::collections::VecDeque<Pattern> = data.iter().cloned().collect();
980    while let Some(item) = deque.pop_front() {
981      match &item {
982        Pattern::Attribute { name, body } => {
983          if let Some(t) = simple_attr_type(body)
984            && !is_wildcard_name(name)
985          {
986            grouped
987              .entry(t)
988              .or_default()
989              .push(clean_tex_name(name, &self.rng.display_strip_prefixes));
990            continue;
991          }
992          attributes.push(self.to_tex(&item));
993        },
994        Pattern::Combination { body, .. } if self.is_attributes(&item) => {
995          for c in body.iter().cloned().rev() {
996            deque.push_front(c);
997          }
998        },
999        Pattern::Ref { qname } if qname.ends_with("_attributes") || qname.ends_with("_model") => {
1000          if let Some(def) = self.rng.defs.get(qname).cloned() {
1001            deque.push_front(def);
1002          }
1003        },
1004        Pattern::Ref { qname } if qname_ends_with_attributes(qname) => {
1005          attr_patterns.push(self.to_tex(&item));
1006        },
1007        // Direct element children — render inline (just the link)
1008        // instead of `\elementdef{…}` so a pattern body like
1009        // `text, element a {…}, text` doesn't end up with the card
1010        // promoting itself out of the surrounding paragraph.
1011        // `to_tex_def` extracts these into sibling cards.
1012        Pattern::Element { name, body } if !is_wildcard_name(name) => {
1013          content.push(self.render_inline_element(name, body));
1014        },
1015        _ => content.push(self.to_tex(&item)),
1016      }
1017    }
1018
1019    let mut attr_str = String::new();
1020    if !attr_patterns.is_empty() {
1021      attr_str.push_str("\\item[\\textit{Attributes}:] ");
1022      attr_str.push_str(&attr_patterns.join(", "));
1023    }
1024    // Grouped lines render before the per-attribute cards: the bulk
1025    // overview reads first, then any non-trivial typed attributes.
1026    // Each name is wrapped in `\texttt{...}` so it lands under
1027    // `.ltx_font_typewriter` (var(--font-code) — SF Mono / Fira Mono
1028    // / etc.) in the rendered HTML; commas stay in body type so the
1029    // names visually separate.
1030    for (type_name, mut names) in grouped {
1031      names.sort();
1032      let monospaced: Vec<String> = names.iter().map(|n| format!("\\texttt{{{}}}", n)).collect();
1033      attr_str.push_str(&format!(
1034        "\\item[\\textit{{{}}}:] {}",
1035        attr_group_label(&type_name),
1036        monospaced.join(", "),
1037      ));
1038    }
1039    for a in attributes {
1040      attr_str.push_str(&a);
1041    }
1042    let content_str = content.join(", ");
1043    (attr_str, content_str)
1044  }
1045
1046  /// Pred: does `item` describe purely attribute content?
1047  fn is_attributes(&self, item: &Pattern) -> bool {
1048    match item {
1049      Pattern::Attribute { .. } => true,
1050      Pattern::Ref { qname } => self
1051        .rng
1052        .defs
1053        .get(qname)
1054        .map(|p| self.is_attributes(p))
1055        .unwrap_or(false),
1056      Pattern::Combination {
1057        op:
1058          CombineOp::Optional
1059          | CombineOp::Choice
1060          | CombineOp::Group
1061          | CombineOp::ZeroOrMore
1062          | CombineOp::OneOrMore,
1063        body,
1064      } => body.iter().all(|p| self.is_attributes(p)),
1065      _ => false,
1066    }
1067  }
1068
1069  /// Pred: does `item` describe purely element / `#PCDATA` content?
1070  fn is_content(&self, item: &Pattern) -> bool {
1071    match item {
1072      Pattern::Element { .. } | Pattern::Grammar { .. } => true,
1073      Pattern::Ref { qname } => {
1074        if self.rng.elementdefs.contains_key(qname) {
1075          return true;
1076        }
1077        self
1078          .rng
1079          .defs
1080          .get(qname)
1081          .map(|p| self.is_content(p))
1082          .unwrap_or(false)
1083      },
1084      Pattern::Combination {
1085        op:
1086          CombineOp::Optional
1087          | CombineOp::Choice
1088          | CombineOp::Group
1089          | CombineOp::ZeroOrMore
1090          | CombineOp::OneOrMore,
1091        body,
1092      } => body.iter().all(|p| self.is_content(p)),
1093      Pattern::Text => true,
1094      _ => false,
1095    }
1096  }
1097
1098  /// Format the "Used by:" link list for `qname`. Returns `None` when
1099  /// the symbol has no recorded uses.
1100  fn symbol_uses(&self, qname: &str) -> Option<String> {
1101    let uses = self.rng.uses_name.get(qname)?;
1102    let mut sorted: Vec<&String> = uses.iter().collect();
1103    // Sort on the host-stripped form so the `@pattern:HOST` qualifier
1104    // doesn't perturb the legacy ordering (`:` sorts below `@`, which
1105    // would e.g. flip the `*` / `*:*` wildcard pair); the full string
1106    // breaks ties between same-tag entries from different hosts.
1107    sorted.sort_by(|a, b| {
1108      let ka = a.split('@').next().unwrap_or(a);
1109      let kb = b.split('@').next().unwrap_or(b);
1110      ka.cmp(kb).then_with(|| a.cmp(b))
1111    });
1112    // Use sites come in three shapes:
1113    //  * `pattern:G:NAME`               — a reference inside define NAME;
1114    //  * `pattern:G:NAME_attributes` / `pattern:G:NAME_model`         — LaTeXML's convention
1115    //    pairing a `*_model` define with element NAME: report the element;
1116    //  * `element:TAG@pattern:G:HOST`   — a reference inside `element TAG {…}` hosted by define
1117    //    HOST (`@`-suffix recorded by the simplifier; absent for elements outside any define).
1118    //    Report the element when TAG names a unique definition; otherwise the host pattern is the
1119    //    only identifying handle (HTML profiles, where TAG is a generic `div`/`span`).
1120    // Pattern links group before element links; each group keeps the
1121    // raw sort order. Dedup is needed because one definition may be
1122    // referenced under several (TAG, HOST) pairs that render the same
1123    // link.
1124    let mut pattern_parts: Vec<String> = Vec::new();
1125    let mut element_parts: Vec<String> = Vec::new();
1126    for u in sorted {
1127      if self.opts.skip_svg && u.contains("SVG.") {
1128        continue;
1129      }
1130      if let Some(rest) = u.strip_prefix("pattern:") {
1131        if let Some(idx) = rest.find(':') {
1132          let after = &rest[idx + 1..];
1133          if let Some(name) = after
1134            .strip_suffix("_attributes")
1135            .or_else(|| after.strip_suffix("_model"))
1136          {
1137            element_parts.push(format!(
1138              "\\elementref{{{}}}",
1139              clean_tex_name(name, &self.rng.display_strip_prefixes)
1140            ));
1141          } else {
1142            pattern_parts.push(format!("\\patternref{{{}}}", clean_tex(after)));
1143          }
1144        }
1145        continue;
1146      }
1147      if let Some(rest) = u.strip_prefix("element:") {
1148        let (tag, host) = match rest.split_once('@') {
1149          Some((t, h)) => (t, Some(h)),
1150          None => (rest, None),
1151        };
1152        if self.ambiguous_elements.contains(tag)
1153          && let Some(hrest) = host.and_then(|h| h.strip_prefix("pattern:"))
1154          && let Some(idx) = hrest.find(':')
1155        {
1156          pattern_parts.push(format!("\\patternref{{{}}}", clean_tex(&hrest[idx + 1..])));
1157          continue;
1158        }
1159        element_parts.push(format!(
1160          "\\elementref{{{}}}",
1161          clean_tex_name(tag, &self.rng.display_strip_prefixes)
1162        ));
1163      }
1164    }
1165    let mut parts: Vec<String> = Vec::new();
1166    for p in pattern_parts.into_iter().chain(element_parts) {
1167      if !parts.contains(&p) {
1168        parts.push(p);
1169      }
1170    }
1171    if parts.is_empty() {
1172      None
1173    } else {
1174      Some(parts.join(", "))
1175    }
1176  }
1177}
1178
1179/// Heuristic: is this module name an SVG module? Matches both the
1180/// URN-prefixed form (`urn:x-LaTeXML:RelaxNG:svg:…`, the path-aware
1181/// LaTeXML pipeline form) and the bare `svg…` filename stems trang
1182/// emits when expanding LaTeXML.rnc with the OASIS catalog (which
1183/// strips the `urn:` prefix). LaTeXML's own modules don't start with
1184/// `svg`, so the prefix match doesn't false-positive.
1185fn is_svg_module(name: &str) -> bool { name.contains(":svg:") || name.starts_with("svg") }
1186
1187fn strip_first_qualifier(s: &str) -> String {
1188  // `s/^\w+://` — strip a leading prefix up to the first colon, IF
1189  // that prefix is `\w+`. Otherwise return as-is.
1190  if let Some(idx) = s.find(':') {
1191    let prefix = &s[..idx];
1192    if !prefix.is_empty() && prefix.chars().all(|c| c.is_alphanumeric() || c == '_') {
1193      return s[idx + 1..].to_string();
1194    }
1195  }
1196  s.to_string()
1197}
1198
1199/// Walk `spec` (recursively into `Combination`s but never into
1200/// `Element` bodies) and collect every distinct non-wildcard
1201/// `Pattern::Element { name, body }`. First occurrence per name wins
1202/// — so `element a {B1} | element a {B2}` extracts as a single
1203/// `xhtml:a` carrying `B1`, since the post-pass can only assign
1204/// `id="schema.xhtml..a"` to one anchor anyway.
1205///
1206/// The collected list drives `to_tex_def`'s per-element sibling
1207/// `\elementdef` extraction: pattern bodies render Elements inline as
1208/// `\elementref{name}` links, and the corresponding card sits as a
1209/// sibling of the patterndef so the link resolves on the same page.
1210fn collect_element_descendants(spec: &[Pattern]) -> Vec<(String, &[Pattern])> {
1211  let mut out = Vec::new();
1212  let mut seen: rustc_hash::FxHashSet<String> = rustc_hash::FxHashSet::default();
1213  for p in spec {
1214    walk_for_elements(p, &mut out, &mut seen);
1215  }
1216  out
1217}
1218
1219fn walk_for_elements<'a>(
1220  p: &'a Pattern,
1221  out: &mut Vec<(String, &'a [Pattern])>,
1222  seen: &mut rustc_hash::FxHashSet<String>,
1223) {
1224  match p {
1225    Pattern::Element { name, body } => {
1226      if !is_wildcard_name(name) && seen.insert(name.clone()) {
1227        out.push((name.clone(), body.as_slice()));
1228      }
1229      // Don't recurse into Element body — that's the element's own
1230      // content model, not separate elements that should become
1231      // siblings of the parent pattern.
1232    },
1233    Pattern::Combination { body, .. } => {
1234      for c in body {
1235        walk_for_elements(c, out, seen);
1236      }
1237    },
1238    _ => {},
1239  }
1240}
1241
1242/// Classify an `<attribute>` body as a "simple type" suitable for
1243/// grouping in the `to_tex_body` compression line. Returns the type
1244/// label (e.g. `"text"`, `"string"`, `"integer"`) when the body is one
1245/// of the trivial shapes:
1246///
1247/// * empty (`<attribute name="foo"/>` — implicitly text-valued),
1248/// * `[Pattern::Text]` (RNC `attribute foo {text}`),
1249/// * `[Pattern::Data(t)]` (RNC `attribute foo {xsd:t}`).
1250///
1251/// A body carrying a `Doc` annotation is rejected: the per-attribute
1252/// docstring would be lost in the grouped form, so those keep their
1253/// individual `\attrdef` cards.
1254fn simple_attr_type(body: &[Pattern]) -> Option<String> {
1255  if body.iter().any(|p| matches!(p, Pattern::Doc(_))) {
1256    return None;
1257  }
1258  match body {
1259    [] => Some("text".into()),
1260    [Pattern::Text] => Some("text".into()),
1261    [Pattern::Data(t)] => Some(t.clone()),
1262    _ => None,
1263  }
1264}
1265
1266/// Format the kicker label for a grouped-attribute line:
1267/// `"text" → "Text attributes"`, `"string" → "String attributes"`,
1268/// `"anyURI" → "AnyURI attributes"`. Capitalises the first character
1269/// of the type name and appends ` attributes`.
1270fn attr_group_label(type_name: &str) -> String {
1271  let cleaned = clean_tex(type_name);
1272  let mut chars = cleaned.chars();
1273  match chars.next() {
1274    None => "Attributes".into(),
1275    Some(c) => format!(
1276      "{}{} attributes",
1277      c.to_uppercase().collect::<String>(),
1278      chars.as_str()
1279    ),
1280  }
1281}
1282
1283/// True if `name` is an `<anyName/>` / `<nsName/>` wildcard:
1284/// `*` (no namespace), `*:*` (any namespace, any local) or `prefix:*`
1285/// (any local within a namespace). These names come from the scanner's
1286/// expansion of `<anyName/>` / `<nsName/>` in `scan_name_class` and
1287/// don't denote real definable element / attribute names.
1288fn is_wildcard_name(name: &str) -> bool { name == "*" || name == "*:*" || name.ends_with(":*") }
1289
1290/// True if `data` contains an adjacent `*` then `*:*` Element pair
1291/// (or the same shape for Attribute). Used as a cheap pre-check so the
1292/// dedupe path only allocates when there's actually something to fold.
1293fn has_wildcard_pair(data: &[Pattern]) -> bool {
1294  data.windows(2).any(|w| is_wildcard_pair(&w[0], &w[1]))
1295}
1296
1297fn is_wildcard_pair(a: &Pattern, b: &Pattern) -> bool {
1298  match (a, b) {
1299    (Pattern::Element { name: n1, .. }, Pattern::Element { name: n2, .. })
1300    | (Pattern::Attribute { name: n1, .. }, Pattern::Attribute { name: n2, .. }) => {
1301      n1 == "*" && n2 == "*:*"
1302    },
1303    _ => false,
1304  }
1305}
1306
1307/// Walk `data` and collapse each adjacent `*` / `*:*` Element- or
1308/// Attribute-pair (produced by the scanner from a single `<anyName/>`)
1309/// into the single `*:*` form. The pair always shares its body since
1310/// `scan_pattern_element` / `scan_pattern_attribute` build both members
1311/// from the same `body_proto.clone()`, so dropping the `*` half loses
1312/// no information.
1313fn dedupe_wildcard_pairs(data: &[Pattern]) -> Vec<Pattern> {
1314  let mut out = Vec::with_capacity(data.len());
1315  let mut i = 0;
1316  while i < data.len() {
1317    if i + 1 < data.len() && is_wildcard_pair(&data[i], &data[i + 1]) {
1318      // Keep the `*:*` member (data[i+1]) — it's the broader form and
1319      // reads more clearly in the rendered content model.
1320      out.push(data[i + 1].clone());
1321      i += 2;
1322    } else {
1323      out.push(data[i].clone());
1324      i += 1;
1325    }
1326  }
1327  out
1328}
1329
1330/// If `s` is wrapped in matching outer parentheses (no other unbalanced
1331/// content at top level), return the unwrapped slice; otherwise `s`.
1332/// Used by the Expansion suppression in `to_tex_def` to detect when the
1333/// stored-Combination form differs from the raw def-args form by only
1334/// an outer `(...)` wrap.
1335fn strip_outer_parens(s: &str) -> &str {
1336  let bytes = s.as_bytes();
1337  if bytes.first() != Some(&b'(') || bytes.last() != Some(&b')') {
1338    return s;
1339  }
1340  // Confirm the outer `(` matches the outer `)` (no `(A)(B)` slip).
1341  let mut depth = 0i32;
1342  for (i, b) in bytes.iter().enumerate() {
1343    match b {
1344      b'(' => depth += 1,
1345      b')' => {
1346        depth -= 1;
1347        if depth == 0 && i + 1 != bytes.len() {
1348          return s;
1349        }
1350      },
1351      _ => {},
1352    }
1353  }
1354  if depth != 0 {
1355    return s;
1356  }
1357  &s[1..s.len() - 1]
1358}
1359
1360fn qname_ends_with_attributes(qname: &str) -> bool {
1361  // Matches the Perl regex `[^a-zA-Z]attributes$`.
1362  let rest = qname.strip_suffix("attributes").unwrap_or("");
1363  if qname == "attributes" {
1364    return false;
1365  }
1366  match rest.chars().last() {
1367    Some(c) => !c.is_ascii_alphabetic() && qname.ends_with("attributes"),
1368    None => false,
1369  }
1370}
1371
1372// ----- unit tests ---------------------------------------------------------
1373
1374#[cfg(test)]
1375mod tests {
1376  use super::*;
1377
1378  #[test]
1379  fn clean_tex_pcdata() {
1380    assert_eq!(clean_tex("#PCDATA"), r"\typename{text}");
1381  }
1382
1383  #[test]
1384  fn clean_tex_underscore() {
1385    assert_eq!(clean_tex("foo_bar"), r"foo\_bar");
1386  }
1387
1388  #[test]
1389  fn clean_tex_strips_urn() {
1390    assert_eq!(clean_tex("urn:x-LaTeXML:RelaxNG:foo"), "foo");
1391  }
1392
1393  #[test]
1394  fn clean_tex_wraps_angles() {
1395    // <text> in the middle of a name → \texttt{text}.
1396    assert_eq!(clean_tex("a<b>c"), r"a\texttt{b}c");
1397  }
1398
1399  #[test]
1400  fn clean_tex_escapes_hash() {
1401    assert_eq!(clean_tex("foo#bar"), r"foo\#bar");
1402  }
1403
1404  #[test]
1405  fn clean_tex_name_consults_strip_list() {
1406    let strip_ltx = vec!["ltx".to_string()];
1407    assert_eq!(clean_tex_name("ltx:para", &strip_ltx), "para");
1408    // Prefixes not in the strip list survive intact.
1409    assert_eq!(clean_tex_name("xhtml:div", &strip_ltx), "xhtml:div");
1410    // Multi-prefix list — first match wins.
1411    let strip_both = vec!["xhtml".to_string(), "ltx".to_string()];
1412    assert_eq!(clean_tex_name("xhtml:div", &strip_both), "div");
1413    assert_eq!(clean_tex_name("ltx:para", &strip_both), "para");
1414    // Empty strip list, no strip.
1415    assert_eq!(clean_tex_name("ltx:para", &[]), "ltx:para");
1416  }
1417
1418  #[test]
1419  fn document_modules_emits_schemamodule() {
1420    let mut rng = Relaxng::default();
1421    rng.modules.push(Pattern::Module {
1422      name: "test".into(),
1423      body: vec![],
1424    });
1425    let out = document_modules(&rng, Options::default());
1426    assert!(out.contains("\\begin{schemamodule}{test}"));
1427    assert!(out.contains("\\end{schemamodule}"));
1428  }
1429
1430  #[test]
1431  fn document_modules_skips_svg_module_when_skip_svg() {
1432    let mut rng = Relaxng::default();
1433    rng.modules.push(Pattern::Module {
1434      name: "x:svg:foo".into(),
1435      body: vec![],
1436    });
1437    let out = document_modules(&rng, Options::default());
1438    assert!(!out.contains("schemamodule"));
1439  }
1440
1441  #[test]
1442  fn combination_rendering() {
1443    let rng = Relaxng::default();
1444    let mut emit = EmitState {
1445      rng:                &rng,
1446      opts:               Options::default(),
1447      defined_patterns:   HashMap::default(),
1448      ambiguous_elements: ambiguous_element_tags(&rng),
1449    };
1450    let body = vec![Pattern::Ref { qname: "g:A".into() }, Pattern::Ref {
1451      qname: "g:B".into(),
1452    }];
1453    let group = emit.to_tex_combination(CombineOp::Group, &body);
1454    assert_eq!(group, "(\\patternref{A}, \\patternref{B})");
1455    let choice = emit.to_tex_combination(CombineOp::Choice, &body);
1456    assert_eq!(choice, "(\\patternref{A} ~\\textbar~ \\patternref{B})");
1457    let inter = emit.to_tex_combination(CombineOp::Interleave, &body);
1458    assert_eq!(inter, "(\\patternref{A} ~\\&~ \\patternref{B})");
1459  }
1460
1461  #[test]
1462  fn singleton_group_collapses_in_combination() {
1463    let rng = Relaxng::default();
1464    let mut emit = EmitState {
1465      rng:                &rng,
1466      opts:               Options::default(),
1467      defined_patterns:   HashMap::default(),
1468      ambiguous_elements: ambiguous_element_tags(&rng),
1469    };
1470    let body = vec![Pattern::Ref { qname: "g:Only".into() }];
1471    let result = emit.to_tex_combination(CombineOp::Group, &body);
1472    assert_eq!(result, "\\patternref{Only}");
1473  }
1474
1475  #[test]
1476  fn element_renders_with_content_and_used_by() {
1477    let mut rng = Relaxng::default();
1478    rng
1479      .element_reverse_defs
1480      .insert("foo".into(), "g:Foo".into());
1481    rng
1482      .uses_name
1483      .entry("g:Foo".into())
1484      .or_default()
1485      .insert("element:bar".into());
1486    let mut emit = EmitState {
1487      rng:                &rng,
1488      opts:               Options::default(),
1489      defined_patterns:   HashMap::default(),
1490      ambiguous_elements: ambiguous_element_tags(&rng),
1491    };
1492    let out = emit.to_tex_element("foo", &[Pattern::Text]);
1493    assert!(out.contains("\\elementdef{foo}"));
1494    assert!(out.contains("\\item[\\textit{Content}:]"));
1495    assert!(out.contains("\\elementref{bar}"));
1496  }
1497
1498  #[test]
1499  fn def_emits_patterndef_then_skips_duplicates() {
1500    let rng = Relaxng::default();
1501    let mut emit = EmitState {
1502      rng:                &rng,
1503      opts:               Options::default(),
1504      defined_patterns:   HashMap::default(),
1505      ambiguous_elements: ambiguous_element_tags(&rng),
1506    };
1507    let body = vec![Pattern::Text];
1508    let first = emit.to_tex_def("", "g:X", &body);
1509    let second = emit.to_tex_def("", "g:X", &body);
1510    assert!(first.contains("\\patterndef{X}"));
1511    assert_eq!(second, "");
1512  }
1513
1514  #[test]
1515  fn def_combine_choice_emits_patternadd() {
1516    let rng = Relaxng::default();
1517    let mut emit = EmitState {
1518      rng:                &rng,
1519      opts:               Options::default(),
1520      defined_patterns:   HashMap::default(),
1521      ambiguous_elements: ambiguous_element_tags(&rng),
1522    };
1523    let out = emit.to_tex_def("choice", "g:X", &[Pattern::Text]);
1524    assert!(out.contains("\\patternadd{X}"));
1525    // -1 marker recorded so the post-pass can upgrade if no \patterndef was emitted.
1526    assert_eq!(emit.defined_patterns.get("X").copied(), Some(-1));
1527  }
1528
1529  #[test]
1530  fn trivial_text_attributes_collapse_into_grouped_line() {
1531    // A long run of `attribute foo {text}?, ...` (the MathML on-event
1532    // attribute pattern) used to render as 30+ ATTRIBUTE / = text rows.
1533    // Compressed form: a single `Text attributes: a, b, c` line, names
1534    // sorted alphabetically.
1535    let xml = r##"
1536      <grammar xmlns="http://relaxng.org/ns/structure/1.0">
1537        <define name="OnEvent">
1538          <group>
1539            <optional><attribute name="onclick"><text/></attribute></optional>
1540            <optional><attribute name="onabort"><text/></attribute></optional>
1541            <optional><attribute name="onblur"><text/></attribute></optional>
1542          </group>
1543        </define>
1544      </grammar>
1545    "##;
1546    use crate::common::relaxng::scan::scan_string;
1547    let mut rng = Relaxng::default();
1548    let raw = scan_string(&mut rng, xml).expect("scan");
1549    let wrapped = vec![Pattern::Module { name: "m".into(), body: raw }];
1550    let _ = crate::common::relaxng::simplify::simplify_top(&mut rng, wrapped);
1551    let out = document_modules(&rng, Options::default());
1552
1553    assert!(
1554      out.contains(
1555        "\\item[\\textit{Text attributes}:] \\texttt{onabort}, \\texttt{onblur}, \\texttt{onclick}"
1556      ),
1557      "expected sorted Text attributes line with monospaced names, got:\n{}",
1558      out
1559    );
1560    assert!(
1561      !out.contains("\\attrdef{onclick}"),
1562      "trivial text attribute should not render as a per-attribute card:\n{}",
1563      out
1564    );
1565  }
1566
1567  #[test]
1568  fn typed_attributes_grouped_per_type_label() {
1569    // Mixed simple types: text, xsd:string, xsd:integer. Each type
1570    // gets its own grouped line; non-trivial bodies stay as cards.
1571    let xml = r##"
1572      <grammar xmlns="http://relaxng.org/ns/structure/1.0"
1573               datatypeLibrary="http://www.w3.org/2001/XMLSchema-datatypes">
1574        <define name="P">
1575          <group>
1576            <attribute name="a"><text/></attribute>
1577            <attribute name="b"><data type="string"/></attribute>
1578            <attribute name="c"><data type="integer"/></attribute>
1579            <attribute name="d">
1580              <choice><value>x</value><value>y</value></choice>
1581            </attribute>
1582          </group>
1583        </define>
1584      </grammar>
1585    "##;
1586    use crate::common::relaxng::scan::scan_string;
1587    let mut rng = Relaxng::default();
1588    let raw = scan_string(&mut rng, xml).expect("scan");
1589    let wrapped = vec![Pattern::Module { name: "m".into(), body: raw }];
1590    let _ = crate::common::relaxng::simplify::simplify_top(&mut rng, wrapped);
1591    let out = document_modules(&rng, Options::default());
1592
1593    assert!(
1594      out.contains("\\item[\\textit{Text attributes}:] \\texttt{a}"),
1595      "{}",
1596      out
1597    );
1598    assert!(
1599      out.contains("\\item[\\textit{String attributes}:] \\texttt{b}"),
1600      "{}",
1601      out
1602    );
1603    assert!(
1604      out.contains("\\item[\\textit{Integer attributes}:] \\texttt{c}"),
1605      "{}",
1606      out
1607    );
1608    // The enum-bodied attribute must keep its individual card.
1609    assert!(out.contains("\\attrdef{d}"), "{}", out);
1610  }
1611
1612  #[test]
1613  fn anyelement_renders_inline_without_nested_cards() {
1614    // Regression: `anyElement = element (*) {(attribute * {text}|text|anyElement)*}`
1615    // used to emit `\elementdef{*}{...}` and `\attrdef{*}{...}` cards
1616    // nested inside the `\patterndef{anyElement}{...}` body, and the
1617    // `*` / `*:*` wildcard pair from `<anyName/>` was double-rendered.
1618    let xml = r##"
1619      <grammar xmlns="http://relaxng.org/ns/structure/1.0">
1620        <define name="anyElement">
1621          <element>
1622            <anyName/>
1623            <zeroOrMore>
1624              <choice>
1625                <attribute><anyName/><text/></attribute>
1626                <text/>
1627                <ref name="anyElement"/>
1628              </choice>
1629            </zeroOrMore>
1630          </element>
1631        </define>
1632      </grammar>
1633    "##;
1634    use crate::common::relaxng::scan::scan_string;
1635    let mut rng = Relaxng::default();
1636    let raw = scan_string(&mut rng, xml).expect("scan");
1637    let wrapped = vec![Pattern::Module { name: "m".into(), body: raw }];
1638    let _ = crate::common::relaxng::simplify::simplify_top(&mut rng, wrapped);
1639    let out = document_modules(&rng, Options::default());
1640
1641    assert!(
1642      out.contains("\\patterndef{anyElement}"),
1643      "expected anyElement patterndef, got:\n{}",
1644      out
1645    );
1646    assert!(
1647      !out.contains("\\elementdef{*}"),
1648      "wildcard element rendered as nested elementdef card:\n{}",
1649      out
1650    );
1651    assert!(
1652      !out.contains("\\elementdef{*:*}"),
1653      "wildcard element rendered as nested elementdef card:\n{}",
1654      out
1655    );
1656    assert!(
1657      !out.contains("\\attrdef{*}"),
1658      "wildcard attribute rendered as nested attrdef card:\n{}",
1659      out
1660    );
1661    assert!(
1662      !out.contains("\\attrdef{*:*}"),
1663      "wildcard attribute rendered as nested attrdef card:\n{}",
1664      out
1665    );
1666    assert!(
1667      out.contains("\\textit{element}~\\texttt{*:*}"),
1668      "expected inline element render for wildcard:\n{}",
1669      out
1670    );
1671    assert!(
1672      out.contains("\\textit{attribute}~\\texttt{*:*}"),
1673      "expected inline attribute render for wildcard:\n{}",
1674      out
1675    );
1676    // Expansion line should be suppressed — content already shows the full body.
1677    assert!(
1678      !out.contains("\\textit{Expansion}"),
1679      "Expansion duplicates Content for anyElement; should be suppressed:\n{}",
1680      out
1681    );
1682  }
1683
1684  #[test]
1685  fn dedupe_wildcard_pairs_collapses_adjacent() {
1686    let body = vec![
1687      Pattern::Element {
1688        name: "*".into(),
1689        body: vec![Pattern::Text],
1690      },
1691      Pattern::Element {
1692        name: "*:*".into(),
1693        body: vec![Pattern::Text],
1694      },
1695      Pattern::Ref { qname: "x".into() },
1696    ];
1697    let folded = dedupe_wildcard_pairs(&body);
1698    assert_eq!(folded.len(), 2);
1699    match &folded[0] {
1700      Pattern::Element { name, .. } => assert_eq!(name, "*:*"),
1701      other => panic!("expected Element *:*, got {:?}", other),
1702    }
1703  }
1704
1705  #[test]
1706  fn strip_outer_parens_only_when_outer_match() {
1707    assert_eq!(strip_outer_parens("(abc)"), "abc");
1708    assert_eq!(strip_outer_parens("(a)(b)"), "(a)(b)");
1709    assert_eq!(strip_outer_parens("abc"), "abc");
1710    assert_eq!(strip_outer_parens("(a"), "(a");
1711  }
1712
1713  #[test]
1714  fn element_choice_renders_as_namelinks_plus_sibling_cards() {
1715    // `X = element a {B} | element b {B} | element c {B}` — Pattern
1716    // Content shows an alphabetised Choice expression of name links;
1717    // sibling \elementdef cards carry per-element bodies. No nested
1718    // `\elementdef` inside the patterndef body — that produced
1719    // orphan `(... | ... | ...)` punctuation in earlier renderings.
1720    let xml = r##"
1721      <grammar xmlns="http://relaxng.org/ns/structure/1.0"
1722               ns="http://example.org/ns">
1723        <define name="X">
1724          <choice>
1725            <element name="a"><ref name="B"/></element>
1726            <element name="b"><ref name="B"/></element>
1727            <element name="c"><ref name="B"/></element>
1728          </choice>
1729        </define>
1730        <define name="B"><text/></define>
1731      </grammar>
1732    "##;
1733    use crate::common::relaxng::scan::scan_string;
1734    let mut rng = Relaxng::default();
1735    let raw = scan_string(&mut rng, xml).expect("scan");
1736    let wrapped = vec![Pattern::Module { name: "m".into(), body: raw }];
1737    let _ = crate::common::relaxng::simplify::simplify_top(&mut rng, wrapped);
1738    let out = document_modules(&rng, Options::default());
1739
1740    // Pattern body: a single Content line with alphabetised name links
1741    // joined by Choice operator, NO nested \elementdef cards.
1742    let patterndef = out
1743      .find("\\patterndef{X}")
1744      .map(|i| {
1745        &out[i..out[i..]
1746          .find("\\elementdef")
1747          .map(|j| i + j)
1748          .unwrap_or(out.len())]
1749      })
1750      .expect("patterndef X present");
1751    assert!(
1752      patterndef.contains("\\item[\\textit{Content}:]"),
1753      "patterndef should expose Content line, got:\n{}",
1754      patterndef
1755    );
1756    let pos_a = patterndef
1757      .find("\\elementref{namespace1:a}")
1758      .expect("a present");
1759    let pos_b = patterndef
1760      .find("\\elementref{namespace1:b}")
1761      .expect("b present");
1762    let pos_c = patterndef
1763      .find("\\elementref{namespace1:c}")
1764      .expect("c present");
1765    assert!(
1766      pos_a < pos_b && pos_b < pos_c,
1767      "expected alphabetised order: {}",
1768      patterndef
1769    );
1770    assert!(
1771      patterndef.contains("\\textbar"),
1772      "expected | separator: {}",
1773      patterndef
1774    );
1775    assert!(
1776      !patterndef.contains("\\elementdef{namespace1:"),
1777      "patterndef body should NOT carry nested elementdef cards:\n{}",
1778      patterndef
1779    );
1780
1781    // Sibling \elementdef cards exist for each unique name with the
1782    // body and a Used-by line pointing back at the parent pattern.
1783    assert!(out.contains("\\elementdef{namespace1:a}"), "{}", out);
1784    assert!(out.contains("\\elementdef{namespace1:b}"), "{}", out);
1785    assert!(out.contains("\\elementdef{namespace1:c}"), "{}", out);
1786    assert!(
1787      out.contains("\\patternref{X}"),
1788      "per-element card should cite parent in Used by:\n{}",
1789      out
1790    );
1791  }
1792
1793  #[test]
1794  fn differing_bodies_keep_first_occurrence_per_name() {
1795    // `X = element a {b1} | element b {b2}` — distinct bodies, both
1796    // still Pattern::Element. Pattern Content lists both names; each
1797    // gets a sibling card with its own body. Two `element a` would
1798    // collapse to one card (first wins), since both can't share id.
1799    let xml = r##"
1800      <grammar xmlns="http://relaxng.org/ns/structure/1.0"
1801               ns="http://example.org/ns">
1802        <define name="X">
1803          <choice>
1804            <element name="a"><text/></element>
1805            <element name="a"><ref name="B"/></element>
1806            <element name="b"><text/></element>
1807          </choice>
1808        </define>
1809        <define name="B"><text/></define>
1810      </grammar>
1811    "##;
1812    use crate::common::relaxng::scan::scan_string;
1813    let mut rng = Relaxng::default();
1814    let raw = scan_string(&mut rng, xml).expect("scan");
1815    let wrapped = vec![Pattern::Module { name: "m".into(), body: raw }];
1816    let _ = crate::common::relaxng::simplify::simplify_top(&mut rng, wrapped);
1817    let out = document_modules(&rng, Options::default());
1818
1819    // Pattern body lists 'a' and 'b' (deduped, alphabetised).
1820    let patterndef = out
1821      .find("\\patterndef{X}")
1822      .map(|i| {
1823        &out[i..out[i..]
1824          .find("\\elementdef")
1825          .map(|j| i + j)
1826          .unwrap_or(out.len())]
1827      })
1828      .unwrap();
1829    assert_eq!(
1830      patterndef.matches("\\elementref{namespace1:a}").count(),
1831      1,
1832      "duplicate 'a' should appear once in body: {}",
1833      patterndef
1834    );
1835    // One \elementdef per unique name (a, b).
1836    assert_eq!(
1837      out.matches("\\elementdef{namespace1:a}").count(),
1838      1,
1839      "{}",
1840      out
1841    );
1842    assert_eq!(
1843      out.matches("\\elementdef{namespace1:b}").count(),
1844      1,
1845      "{}",
1846      out
1847    );
1848  }
1849
1850  #[test]
1851  fn unmatched_patternadd_upgrades_to_patterndefadd() {
1852    let mut rng = Relaxng::default();
1853    rng.modules.push(Pattern::Module {
1854      name: "m".into(),
1855      body: vec![Pattern::Def {
1856        combiner: DefCombiner::Choice,
1857        name:     "g:Lonely".into(),
1858        body:     vec![Pattern::Text],
1859      }],
1860    });
1861    let out = document_modules(&rng, Options::default());
1862    assert!(
1863      out.contains("\\patterndefadd{Lonely}"),
1864      "expected upgrade, got:\n{}",
1865      out
1866    );
1867    assert!(
1868      !out.contains("\\patternadd{Lonely}"),
1869      "patternadd should have been replaced"
1870    );
1871  }
1872}