metanorma-mirror 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +25 -0
  3. data/README.adoc +41 -0
  4. data/lib/metanorma/mirror/class_table.rb +50 -0
  5. data/lib/metanorma/mirror/default_registry.rb +23 -0
  6. data/lib/metanorma/mirror/handler_registry.rb +60 -0
  7. data/lib/metanorma/mirror/handler_result.rb +37 -0
  8. data/lib/metanorma/mirror/handlers/admonition.rb +18 -0
  9. data/lib/metanorma/mirror/handlers/example.rb +21 -0
  10. data/lib/metanorma/mirror/handlers/figure.rb +53 -0
  11. data/lib/metanorma/mirror/handlers/formula.rb +35 -0
  12. data/lib/metanorma/mirror/handlers/imagemap.rb +43 -0
  13. data/lib/metanorma/mirror/handlers/inline/catalog.rb +36 -0
  14. data/lib/metanorma/mirror/handlers/inline/rich_html_renderer.rb +132 -0
  15. data/lib/metanorma/mirror/handlers/inline/text_extractor.rb +114 -0
  16. data/lib/metanorma/mirror/handlers/inline.rb +155 -0
  17. data/lib/metanorma/mirror/handlers/list.rb +110 -0
  18. data/lib/metanorma/mirror/handlers/note.rb +26 -0
  19. data/lib/metanorma/mirror/handlers/paragraph.rb +19 -0
  20. data/lib/metanorma/mirror/handlers/quote.rb +16 -0
  21. data/lib/metanorma/mirror/handlers/review.rb +19 -0
  22. data/lib/metanorma/mirror/handlers/section.rb +149 -0
  23. data/lib/metanorma/mirror/handlers/sourcecode.rb +27 -0
  24. data/lib/metanorma/mirror/handlers/structural.rb +68 -0
  25. data/lib/metanorma/mirror/handlers/svgmap.rb +52 -0
  26. data/lib/metanorma/mirror/handlers/table.rb +81 -0
  27. data/lib/metanorma/mirror/handlers/term.rb +100 -0
  28. data/lib/metanorma/mirror/handlers.rb +62 -0
  29. data/lib/metanorma/mirror/id_strategy/positional.rb +151 -0
  30. data/lib/metanorma/mirror/id_strategy/preserve.rb +12 -0
  31. data/lib/metanorma/mirror/id_strategy.rb +34 -0
  32. data/lib/metanorma/mirror/math_util.rb +53 -0
  33. data/lib/metanorma/mirror/metadata.rb +37 -0
  34. data/lib/metanorma/mirror/model/container.rb +50 -0
  35. data/lib/metanorma/mirror/model/factory.rb +34 -0
  36. data/lib/metanorma/mirror/model/guide.rb +29 -0
  37. data/lib/metanorma/mirror/model/leaf.rb +17 -0
  38. data/lib/metanorma/mirror/model/mark.rb +39 -0
  39. data/lib/metanorma/mirror/model/node.rb +42 -0
  40. data/lib/metanorma/mirror/model/soft_break.rb +32 -0
  41. data/lib/metanorma/mirror/model/text.rb +27 -0
  42. data/lib/metanorma/mirror/model.rb +16 -0
  43. data/lib/metanorma/mirror/output/builder.rb +39 -0
  44. data/lib/metanorma/mirror/output/formats/base_format.rb +90 -0
  45. data/lib/metanorma/mirror/output/formats/inline_format.rb +142 -0
  46. data/lib/metanorma/mirror/output/formats.rb +53 -0
  47. data/lib/metanorma/mirror/output/pipeline.rb +109 -0
  48. data/lib/metanorma/mirror/output/pipeline_context.rb +22 -0
  49. data/lib/metanorma/mirror/output.rb +12 -0
  50. data/lib/metanorma/mirror/rewriter.rb +123 -0
  51. data/lib/metanorma/mirror/safe_attr.rb +16 -0
  52. data/lib/metanorma/mirror/serialization/json_serializer.rb +27 -0
  53. data/lib/metanorma/mirror/serialization/yaml_serializer.rb +20 -0
  54. data/lib/metanorma/mirror/serialization.rb +10 -0
  55. data/lib/metanorma/mirror/transformer.rb +110 -0
  56. data/lib/metanorma/mirror/version.rb +7 -0
  57. data/lib/metanorma/mirror.rb +98 -0
  58. metadata +197 -0
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Metanorma
4
+ module Mirror
5
+ module Handlers
6
+ module Structural
7
+ def self.preface(element, context:)
8
+ attrs = {}
9
+ attrs[:semx_id] = SafeAttr.read(element, :semx_id)
10
+ attrs[:displayorder] = SafeAttr.read(element, :displayorder)
11
+
12
+ content = []
13
+ %i[abstract foreword introduction acknowledgements
14
+ executivesummary].each do |attr|
15
+ child = SafeAttr.read(element, attr)
16
+ next unless child
17
+
18
+ result = context.registry.handle(child, context: context)
19
+ result.append_to(content)
20
+ end
21
+
22
+ element_content = SafeAttr.read(element, :content)
23
+ Array(element_content).each do |child|
24
+ result = context.registry.handle(child, context: context)
25
+ result.append_to(content)
26
+ end
27
+
28
+ Handlers.build_node("preface", attrs: attrs.compact, content: content)
29
+ end
30
+
31
+ def self.sections(element, context:)
32
+ attrs = {}
33
+ attrs[:semx_id] = SafeAttr.read(element, :semx_id)
34
+ attrs[:displayorder] = SafeAttr.read(element, :displayorder)
35
+
36
+ content = []
37
+ %i[clause terms definitions references floating_title].each do |attr|
38
+ collection = SafeAttr.read(element, attr)
39
+ next unless collection
40
+
41
+ Array(collection).each do |child|
42
+ result = context.registry.handle(child, context: context)
43
+ result.append_to(content)
44
+ end
45
+ end
46
+
47
+ Handlers.build_node("sections", attrs: attrs.compact,
48
+ content: content)
49
+ end
50
+
51
+ def self.bibliography(element, context:)
52
+ attrs = {}
53
+ attrs[:semx_id] = SafeAttr.read(element, :semx_id)
54
+
55
+ content = []
56
+ refs = SafeAttr.read(element, :references)
57
+ Array(refs).each do |ref|
58
+ result = context.registry.handle(ref, context: context)
59
+ result.append_to(content)
60
+ end
61
+
62
+ Handlers.build_node("bibliography", attrs: attrs.compact,
63
+ content: content)
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Metanorma
4
+ module Mirror
5
+ module Handlers
6
+ # SVG image map: a wrapped figure plus the href-overwriting
7
+ # targets that bind the SVG's own links to document anchors.
8
+ # Duck-typed over SafeAttr reads — zero model constants.
9
+ module Svgmap
10
+ REFERENCE_KINDS = %i[xref link eref].freeze
11
+
12
+ def self.call(element, context:)
13
+ attrs = {
14
+ id: SafeAttr.read(element, :id),
15
+ semx_id: SafeAttr.read(element, :semx_id),
16
+ links: link_map(element),
17
+ }.compact
18
+ content = figure_content(element, context)
19
+ Handlers.build_node("svgmap", attrs: attrs, content: content)
20
+ end
21
+
22
+ def self.figure_content(element, context)
23
+ content = []
24
+ Array(SafeAttr.read(element, :figure)).each do |fig|
25
+ context.registry.handle(fig, context: context).append_to(content)
26
+ end
27
+ content
28
+ end
29
+
30
+ def self.link_map(element)
31
+ links = {}
32
+ Array(SafeAttr.read(element, :target)).each do |target|
33
+ href = SafeAttr.read(target, :href)
34
+ links[href] = reference_of(target) if href && !href.empty?
35
+ end
36
+ links.empty? ? nil : links
37
+ end
38
+
39
+ def self.reference_of(carrier)
40
+ REFERENCE_KINDS.each do |kind|
41
+ ref = SafeAttr.read(carrier, kind)
42
+ next if ref.nil?
43
+
44
+ return SafeAttr.read(ref, :target) ||
45
+ SafeAttr.read(ref, :bibitemid)
46
+ end
47
+ nil
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Metanorma
4
+ module Mirror
5
+ module Handlers
6
+ module Table
7
+ def self.call(element, context:)
8
+ attrs = table_attrs(element)
9
+ content = []
10
+
11
+ thead = SafeAttr.read(element, :thead)
12
+ if thead
13
+ rows = extract_rows(thead, context:)
14
+ unless rows.empty?
15
+ content << Handlers.build_node("table_head",
16
+ content: rows)
17
+ end
18
+ end
19
+
20
+ tbody = SafeAttr.read(element, :tbody)
21
+ if tbody
22
+ rows = extract_rows(tbody, context:)
23
+ unless rows.empty?
24
+ content << Handlers.build_node("table_body",
25
+ content: rows)
26
+ end
27
+ end
28
+
29
+ tfoot = SafeAttr.read(element, :tfoot)
30
+ if tfoot
31
+ rows = extract_rows(tfoot, context:)
32
+ unless rows.empty?
33
+ content << Handlers.build_node("table_foot",
34
+ content: rows)
35
+ end
36
+ end
37
+
38
+ Handlers.build_node("table", attrs: attrs, content: content)
39
+ end
40
+
41
+ def self.extract_rows(table_section, context:)
42
+ Array(table_section.tr).map do |tr|
43
+ cells = Array(tr.td).map { |td| build_cell(td, context:) }
44
+ Array(tr.th).each { |th| cells << build_cell(th, context:) }
45
+
46
+ row_attrs = {}
47
+ row_attrs[:id] = SafeAttr.read(tr, :id)
48
+ Handlers.build_node("table_row", attrs: row_attrs.compact,
49
+ content: cells)
50
+ end
51
+ end
52
+
53
+ def self.build_cell(cell, context:)
54
+ attrs = {}
55
+ attrs[:colspan] = SafeAttr.read(cell, :colspan)
56
+ attrs[:rowspan] = SafeAttr.read(cell, :rowspan)
57
+ attrs[:align] = SafeAttr.read(cell, :align)
58
+ attrs[:valign] = SafeAttr.read(cell, :valign)
59
+
60
+ content = Inline.extract_inline(cell, context:)
61
+
62
+ Handlers.build_node("table_cell", attrs: attrs.compact,
63
+ content: content)
64
+ end
65
+
66
+ def self.table_attrs(element)
67
+ attrs = {}
68
+ attrs[:id] = SafeAttr.read(element, :id)
69
+ attrs[:width] = SafeAttr.read(element, :width)
70
+ attrs[:align] = SafeAttr.read(element, :align)
71
+ attrs[:unnumbered] = SafeAttr.read(element, :unnumbered)
72
+ attrs[:semx_id] = SafeAttr.read(element, :semx_id)
73
+
74
+ name = SafeAttr.read(element, :name)
75
+ attrs[:title] = Handlers.extract_name_text(name) if name
76
+ attrs.compact
77
+ end
78
+ end
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Metanorma
4
+ module Mirror
5
+ module Handlers
6
+ module Term
7
+ def self.call(element, context:)
8
+ attrs = {}
9
+ attrs[:id] = SafeAttr.read(element, :id)
10
+ attrs[:anchor] = SafeAttr.read(element, :anchor)
11
+
12
+ fmt_name = SafeAttr.read(element, :fmt_name)
13
+ if fmt_name
14
+ number = Inline.extract_formatted_text(fmt_name)
15
+ attrs[:number] = number unless number.empty?
16
+ end
17
+
18
+ content = []
19
+
20
+ # Term name from fmt_preferred paragraphs
21
+ extract_fmt_paragraphs(element, :fmt_preferred, content, context)
22
+
23
+ # Definition from fmt_definition → semx → p
24
+ fmt_def = SafeAttr.read(element, :fmt_definition)
25
+ if fmt_def
26
+ extract_semx_paragraphs(fmt_def, content, context)
27
+ end
28
+
29
+ # Source from fmt_termsource
30
+ fmt_ts_list = SafeAttr.read(element, :fmt_termsource)
31
+ if fmt_ts_list && !fmt_ts_list.empty?
32
+ source_text = Inline.extract_formatted_text(fmt_ts_list.first)
33
+ unless source_text.empty?
34
+ content << Handlers.build_node("paragraph",
35
+ attrs: { class: "source" },
36
+ content: [context.text_node(source_text)])
37
+ end
38
+ end
39
+
40
+ # Term notes
41
+ SafeAttr.read(element, :termnote)&.each do |tn|
42
+ note_attrs = { id: SafeAttr.read(tn, :id) }
43
+ fn = SafeAttr.read(tn, :fmt_name)
44
+ if fn
45
+ num = Inline.extract_formatted_text(fn)
46
+ note_attrs[:number] = num unless num.empty?
47
+ end
48
+ note_content = context.extract_named_collections(tn, %i[p ul ol dl])
49
+ content << Handlers.build_node("note", attrs: note_attrs.compact,
50
+ content: note_content)
51
+ end
52
+
53
+ # Term examples
54
+ SafeAttr.read(element, :termexample)&.each do |te|
55
+ ex_attrs = { id: SafeAttr.read(te, :id) }
56
+ ex_content = context.extract_named_collections(te, %i[p ul ol dl])
57
+ content << Handlers.build_node("example", attrs: ex_attrs.compact,
58
+ content: ex_content)
59
+ end
60
+
61
+ # Nested terms
62
+ SafeAttr.read(element, :term)&.each do |t|
63
+ result = context.registry.handle(t, context: context)
64
+ result.append_to(content)
65
+ end
66
+
67
+ Handlers.build_node("term", attrs: attrs.compact, content: content)
68
+ end
69
+
70
+ def self.extract_fmt_paragraphs(element, attr_name, content, context)
71
+ fmt_list = SafeAttr.read(element, attr_name)
72
+ return unless fmt_list
73
+
74
+ fmt_list.each do |fmt_el|
75
+ ps = SafeAttr.read(fmt_el, :p)
76
+ next unless ps
77
+
78
+ ps.each do |p|
79
+ result = context.registry.handle(p, context: context)
80
+ result.append_to(content)
81
+ end
82
+ end
83
+ end
84
+
85
+ # fmt_definition wraps content in semx elements → extract p from semx
86
+ def self.extract_semx_paragraphs(fmt_def, content, context)
87
+ semx_list = SafeAttr.read(fmt_def, :semx)
88
+ return unless semx_list
89
+
90
+ Array(semx_list).each do |s|
91
+ SafeAttr.read(s, :p)&.each do |p|
92
+ result = context.registry.handle(p, context: context)
93
+ result.append_to(content)
94
+ end
95
+ end
96
+ end
97
+ end
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Metanorma
4
+ module Mirror
5
+ module Handlers
6
+ autoload :Paragraph, "#{__dir__}/handlers/paragraph"
7
+ autoload :Section, "#{__dir__}/handlers/section"
8
+ autoload :List, "#{__dir__}/handlers/list"
9
+ autoload :Table, "#{__dir__}/handlers/table"
10
+ autoload :Figure, "#{__dir__}/handlers/figure"
11
+ autoload :Svgmap, "#{__dir__}/handlers/svgmap"
12
+ autoload :Imagemap, "#{__dir__}/handlers/imagemap"
13
+ autoload :Sourcecode, "#{__dir__}/handlers/sourcecode"
14
+ autoload :Admonition, "#{__dir__}/handlers/admonition"
15
+ autoload :Formula, "#{__dir__}/handlers/formula"
16
+ autoload :Example, "#{__dir__}/handlers/example"
17
+ autoload :Note, "#{__dir__}/handlers/note"
18
+ autoload :Quote, "#{__dir__}/handlers/quote"
19
+ autoload :Review, "#{__dir__}/handlers/review"
20
+ autoload :Inline, "#{__dir__}/handlers/inline"
21
+ autoload :Structural, "#{__dir__}/handlers/structural"
22
+ autoload :Term, "#{__dir__}/handlers/term"
23
+
24
+ COMMON_ATTRS = %i[id semx_id].freeze
25
+
26
+ def self.build_node(type, attrs: {}, content: [])
27
+ if content.nil? || content.empty?
28
+ Model::Leaf.new(type: type, attrs: attrs)
29
+ else
30
+ Model::Container.new(type: type, attrs: attrs, content: content)
31
+ end
32
+ end
33
+
34
+ def self.build_text(text, marks: [])
35
+ Model::Text.new(text: text, marks: marks)
36
+ end
37
+
38
+ def self.build_mark(type, attrs: {})
39
+ Model::Mark.new(type: type, attrs: attrs)
40
+ end
41
+
42
+ def self.extract_attrs(element, extra_attrs: {})
43
+ attrs = {}
44
+ COMMON_ATTRS.each do |attr|
45
+ attrs[attr] = SafeAttr.read(element, attr)
46
+ end
47
+ extra_attrs.each do |attr, source_attr|
48
+ attrs[attr] = SafeAttr.read(element, source_attr || attr)
49
+ end
50
+ attrs.compact
51
+ end
52
+
53
+ def self.extract_name_text(name)
54
+ Inline::TextExtractor.extract_name_text(name)
55
+ end
56
+
57
+ def self.extract_bibdata_title(bibdata)
58
+ Metadata.title_from_bibdata(bibdata)
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,151 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Metanorma
4
+ module Mirror
5
+ module IdStrategy
6
+ # Assign positional IDs (sec-X.Y.Z, table-N, fig-N, anx-X) to elements
7
+ # that have UUID IDs. Elements with author-assigned explicit IDs are
8
+ # preserved. Cross-reference targets are translated to use the new IDs.
9
+ #
10
+ # Positional IDs are derived from the element's resolved section number
11
+ # (the "number" attribute on the presentation XML element), which
12
+ # represents the document's structural position.
13
+ #
14
+ # Example:
15
+ # UUID element with number "5.4" → id: "sec-5.4"
16
+ # UUID element with number "A.2" → id: "anx-A.2"
17
+ # Explicit id "sec-3.1.3.4" → id: "sec-3.1.3.4" (unchanged)
18
+ class Positional < Base
19
+ @categories = {}
20
+
21
+ class << self
22
+ # Registry of Metanorma model classes → positional-ID category
23
+ # (:section, :annex, :figure, :table). Adding new flavors or new
24
+ # model types requires only a new register_category call — no
25
+ # edits to derive/element_category (OCP).
26
+ def categories
27
+ @categories
28
+ end
29
+
30
+ def register_category(model_class, category)
31
+ categories[model_class] = category
32
+ self
33
+ end
34
+
35
+ def unregister_category(model_class)
36
+ categories.delete(model_class)
37
+ self
38
+ end
39
+
40
+ def category_for(element)
41
+ return categories[element.class] if categories.key?(element.class)
42
+
43
+ categories.each do |klass, category|
44
+ return category if element.is_a?(klass)
45
+ end
46
+ nil
47
+ end
48
+ end
49
+
50
+ def initialize
51
+ @id_map = {}
52
+ end
53
+
54
+ def assign_id(element)
55
+ raw = raw_id(element)
56
+ return raw unless uuid?(raw)
57
+
58
+ positional = derive(element)
59
+ return raw unless positional
60
+
61
+ @id_map[raw] = positional
62
+ positional
63
+ end
64
+
65
+ def finalize!(document)
66
+ translate_targets(document)
67
+ document
68
+ end
69
+
70
+ private
71
+
72
+ def raw_id(element)
73
+ SafeAttr.read(element, :id)
74
+ end
75
+
76
+ def uuid?(id)
77
+ id&.start_with?("_")
78
+ end
79
+
80
+ def derive(element)
81
+ number = extract_number(element)
82
+ return nil unless number && !number.strip.empty?
83
+
84
+ case self.class.category_for(element)
85
+ when :section
86
+ number.match?(/\A[\d.]+\z/) ? "sec-#{number}" : nil
87
+ when :annex
88
+ number.match?(/\A[A-Z]/) ? "anx-#{number}" : nil
89
+ when :figure
90
+ "fig-#{number}"
91
+ when :table
92
+ "table-#{number}"
93
+ end
94
+ end
95
+
96
+ def extract_number(element)
97
+ number = SafeAttr.read(element, :number)
98
+ return number if number && !number.strip.empty?
99
+
100
+ attrs = element.class.attributes
101
+ return nil unless attrs.key?(:fmt_title)
102
+
103
+ fmt_title = element.fmt_title
104
+ return nil unless fmt_title
105
+
106
+ parts = collect_autonum_text(fmt_title)
107
+ parts.empty? ? nil : parts.join(".")
108
+ end
109
+
110
+ def collect_autonum_text(node)
111
+ parts = []
112
+ attrs = node.class.attributes
113
+
114
+ if attrs.key?(:semx) && node.semx
115
+ node.semx.each do |s|
116
+ next unless s.element_attr == "autonum"
117
+
118
+ text = s.text&.join
119
+ parts << text if text && !text.empty?
120
+ end
121
+ end
122
+
123
+ if attrs.key?(:span) && node.span
124
+ node.span.each { |sp| parts.concat(collect_autonum_text(sp)) }
125
+ end
126
+
127
+ parts
128
+ end
129
+
130
+ def translate_targets(node)
131
+ case node
132
+ when Model::Text
133
+ node.marks.each { |mark| translate_mark_target(mark) }
134
+ when Model::Container
135
+ node.content.each { |child| translate_targets(child) }
136
+ end
137
+ end
138
+
139
+ def translate_mark_target(mark)
140
+ return unless mark.type == "xref"
141
+
142
+ target = mark["target"]
143
+ return unless target
144
+
145
+ translated = @id_map[target]
146
+ mark["target"] = translated if translated
147
+ end
148
+ end
149
+ end
150
+ end
151
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Metanorma
4
+ module Mirror
5
+ module IdStrategy
6
+ # Preserve all IDs as-is (default behavior).
7
+ # UUID elements keep their UUID IDs. Explicit IDs are unchanged.
8
+ class Preserve < Base
9
+ end
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Metanorma
4
+ module Mirror
5
+ module IdStrategy
6
+ autoload :Preserve, "#{__dir__}/id_strategy/preserve"
7
+ autoload :Positional, "#{__dir__}/id_strategy/positional"
8
+
9
+ # Base class for ID assignment strategies.
10
+ #
11
+ # A strategy controls how element IDs are assigned during mirror
12
+ # generation. Subclasses override assign_id and finalize!.
13
+ #
14
+ # assign_id(element) — called per element during node construction
15
+ # finalize!(document) — called once after the full mirror tree is built
16
+ #
17
+ # Adding a new strategy = adding a new class. No changes to handlers
18
+ # or the converter (OCP).
19
+ class Base
20
+ # Returns the ID string to assign to the mirror node for this element.
21
+ def assign_id(element)
22
+ SafeAttr.read(element, :id)
23
+ end
24
+
25
+ # Post-process the completed mirror document after all IDs are assigned.
26
+ # Use this to translate cross-reference targets, etc.
27
+ # Returns the (possibly modified) document.
28
+ def finalize!(document)
29
+ document
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Metanorma
4
+ module Mirror
5
+ # MathUtil is the single home for math-content extraction helpers.
6
+ #
7
+ # These helpers convert mathML XML objects into strings (stripped of
8
+ # XML declarations) and extract asciimath from stem elements. They
9
+ # are used by handlers and renderers that need to carry math content
10
+ # through the mirror pipeline.
11
+ module MathUtil
12
+ STRIP_XML_DECL_PATTERN = /\A<\?xml[^?]*\?>\s?/
13
+ private_constant :STRIP_XML_DECL_PATTERN
14
+
15
+ def self.strip_xml_decl(math_xml)
16
+ math_xml.sub(STRIP_XML_DECL_PATTERN, "")
17
+ end
18
+
19
+ def self.mathml_from_math(math)
20
+ xml = math.is_a?(Array) ? math.map(&:to_xml).join : math.to_xml
21
+ strip_xml_decl(xml)
22
+ end
23
+
24
+ # Extract asciimath text from a stem element. Returns nil if no
25
+ # asciimath is present.
26
+ def self.asciimath_from_stem(stem)
27
+ asciimath = SafeAttr.read(stem, :asciimath)
28
+ return nil unless asciimath.is_a?(Array)
29
+
30
+ parts = asciimath.filter_map do |a|
31
+ next a if a.is_a?(String)
32
+ next SafeAttr.read(a, :text).to_s if a.is_a?(Lutaml::Model::Serializable)
33
+
34
+ nil
35
+ end
36
+ joined = parts.join.strip
37
+ joined.empty? ? nil : joined
38
+ end
39
+
40
+ # Best-effort plain-text extraction from a stem element. Tries
41
+ # asciimath first; falls back to SafeAttr :text.
42
+ def self.text_from_stem(stem)
43
+ asciimath = asciimath_from_stem(stem)
44
+ return asciimath if asciimath
45
+
46
+ text = SafeAttr.read(stem, :text)
47
+ return text.to_s if text.is_a?(String) && !text.strip.empty?
48
+
49
+ ""
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Metanorma
4
+ module Mirror
5
+ # Extracts metadata (title, etc.) from a parsed Metanorma document's
6
+ # bibdata. This is a standalone service that the pipeline and other
7
+ # consumers can use without depending on the Handlers layer.
8
+ module Metadata
9
+ # Returns the first title string from a bibdata object, or nil.
10
+ # The bibdata must respond to `title` and return a String, an Array
11
+ # of Strings, or an Array of Lutaml::Model::Serializables with
12
+ # `content`.
13
+ def self.title_from_bibdata(bibdata)
14
+ return nil unless bibdata
15
+
16
+ title = bibdata.title
17
+ return nil unless title
18
+
19
+ case title
20
+ when String then title
21
+ when Array
22
+ first = title.first
23
+ return nil unless first
24
+
25
+ if first.is_a?(String)
26
+ first
27
+ elsif first.is_a?(Lutaml::Model::Serializable)
28
+ Array(first.content).join
29
+ else
30
+ first.to_s
31
+ end
32
+ else title.to_s
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end