metanorma-oiml 0.1.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 (68) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.adoc +291 -0
  4. data/data/themes/oiml/assets/oiml-logo-full-dark.svg +1 -0
  5. data/data/themes/oiml/assets/oiml-logo-full-light.svg +1 -0
  6. data/data/themes/oiml/assets/oiml-logo-icon-dark.svg +1 -0
  7. data/data/themes/oiml/assets/oiml-logo-icon-light.svg +1 -0
  8. data/data/themes/oiml/assets/oiml-logo.svg +1 -0
  9. data/data/themes/oiml/theme.yaml +56 -0
  10. data/exe/oiml-sts +10 -0
  11. data/lib/metanorma/oiml/document/root.rb +29 -0
  12. data/lib/metanorma/oiml/document.rb +35 -0
  13. data/lib/metanorma/oiml/html/renderer.rb +14 -0
  14. data/lib/metanorma/oiml/html.rb +14 -0
  15. data/lib/metanorma/oiml/sts/cli.rb +89 -0
  16. data/lib/metanorma/oiml/sts/html_renderer/assets/metanorma-icon-dark.svg +1 -0
  17. data/lib/metanorma/oiml/sts/html_renderer/assets/metanorma-icon-light.svg +1 -0
  18. data/lib/metanorma/oiml/sts/html_renderer/assets/oiml-logo-dark.svg +1 -0
  19. data/lib/metanorma/oiml/sts/html_renderer/assets/oiml-logo.svg +1 -0
  20. data/lib/metanorma/oiml/sts/html_renderer/assets/oiml-smart-logo-dark.svg +1 -0
  21. data/lib/metanorma/oiml/sts/html_renderer/assets/oiml-smart-logo.svg +1 -0
  22. data/lib/metanorma/oiml/sts/html_renderer/assets/page.js +145 -0
  23. data/lib/metanorma/oiml/sts/html_renderer/assets/theme.css +509 -0
  24. data/lib/metanorma/oiml/sts/html_renderer/ruby.rb +1071 -0
  25. data/lib/metanorma/oiml/sts/html_renderer/templates/_element.html.liquid +1 -0
  26. data/lib/metanorma/oiml/sts/html_renderer/templates/_footer.html.liquid +21 -0
  27. data/lib/metanorma/oiml/sts/html_renderer/templates/_hero.html.liquid +9 -0
  28. data/lib/metanorma/oiml/sts/html_renderer/templates/_img.html.liquid +1 -0
  29. data/lib/metanorma/oiml/sts/html_renderer/templates/_link.html.liquid +1 -0
  30. data/lib/metanorma/oiml/sts/html_renderer/templates/_meta_header.html.liquid +1 -0
  31. data/lib/metanorma/oiml/sts/html_renderer/templates/document.html.liquid +43 -0
  32. data/lib/metanorma/oiml/sts/html_renderer.rb +19 -0
  33. data/lib/metanorma/oiml/sts/mathml_builder.rb +93 -0
  34. data/lib/metanorma/oiml/sts/parity_validator/normaliser.rb +95 -0
  35. data/lib/metanorma/oiml/sts/parity_validator/report.rb +137 -0
  36. data/lib/metanorma/oiml/sts/parity_validator.rb +23 -0
  37. data/lib/metanorma/oiml/sts/transformer/back_transformer.rb +55 -0
  38. data/lib/metanorma/oiml/sts/transformer/base.rb +71 -0
  39. data/lib/metanorma/oiml/sts/transformer/block_dispatcher.rb +145 -0
  40. data/lib/metanorma/oiml/sts/transformer/body_transformer.rb +23 -0
  41. data/lib/metanorma/oiml/sts/transformer/context.rb +27 -0
  42. data/lib/metanorma/oiml/sts/transformer/dl_transformer.rb +49 -0
  43. data/lib/metanorma/oiml/sts/transformer/document_transformer.rb +65 -0
  44. data/lib/metanorma/oiml/sts/transformer/figure_transformer.rb +49 -0
  45. data/lib/metanorma/oiml/sts/transformer/footnote_collector.rb +33 -0
  46. data/lib/metanorma/oiml/sts/transformer/formula_transformer.rb +45 -0
  47. data/lib/metanorma/oiml/sts/transformer/front_transformer.rb +84 -0
  48. data/lib/metanorma/oiml/sts/transformer/id_generator.rb +39 -0
  49. data/lib/metanorma/oiml/sts/transformer/inline_transformer.rb +14 -0
  50. data/lib/metanorma/oiml/sts/transformer/list_transformer.rb +82 -0
  51. data/lib/metanorma/oiml/sts/transformer/model_builder.rb +322 -0
  52. data/lib/metanorma/oiml/sts/transformer/note_transformer.rb +103 -0
  53. data/lib/metanorma/oiml/sts/transformer/paragraph_transformer.rb +574 -0
  54. data/lib/metanorma/oiml/sts/transformer/reference_transformer.rb +107 -0
  55. data/lib/metanorma/oiml/sts/transformer/rendered_text_extractor.rb +168 -0
  56. data/lib/metanorma/oiml/sts/transformer/section_transformer.rb +233 -0
  57. data/lib/metanorma/oiml/sts/transformer/source_document.rb +246 -0
  58. data/lib/metanorma/oiml/sts/transformer/standard.rb +37 -0
  59. data/lib/metanorma/oiml/sts/transformer/table_transformer.rb +290 -0
  60. data/lib/metanorma/oiml/sts/transformer/term_transformer.rb +236 -0
  61. data/lib/metanorma/oiml/sts/transformer.rb +47 -0
  62. data/lib/metanorma/oiml/sts/validation_report.rb +43 -0
  63. data/lib/metanorma/oiml/sts/validator.rb +126 -0
  64. data/lib/metanorma/oiml/sts/version.rb +9 -0
  65. data/lib/metanorma/oiml/sts.rb +84 -0
  66. data/lib/metanorma-oiml.rb +10 -0
  67. data/schematron/oiml-sts.sch +70 -0
  68. metadata +221 -0
@@ -0,0 +1,290 @@
1
+ # frozen_string_literal: true
2
+ module Metanorma
3
+ module Oiml
4
+ module Sts
5
+ module Transformer
6
+ class TableTransformer < Base
7
+ def transform(source_table)
8
+ attrs = {}
9
+ attrs[:id] = source_table.id if source_table.class.method_defined?(:id) && source_table.id
10
+ label = table_label(source_table)
11
+ attrs[:label] = label if label
12
+ caption = table_caption(source_table)
13
+ attrs[:caption] = caption if caption
14
+ table = build_table(source_table)
15
+ attrs[:table] = [table] if table
16
+ ::Sts::TbxIsoTml::TableWrap.new(attrs)
17
+ end
18
+
19
+ private
20
+
21
+ # "Table 2" from the presentation XML autonum attribute.
22
+ def table_label(source_table)
23
+ return nil unless source_table.class.method_defined?(:autonum) && source_table.autonum
24
+
25
+ "Table #{source_table.autonum}"
26
+ end
27
+
28
+ def table_caption(source_table)
29
+ return nil unless source_table.class.method_defined?(:name) && source_table.name
30
+
31
+ text = RenderedTextExtractor.text_of(source_table.name).strip
32
+ return nil if text.empty?
33
+
34
+ ::Sts::NisoSts::Caption.new(
35
+ title: ::Sts::NisoSts::Title.new(content: [text])
36
+ )
37
+ end
38
+
39
+ def build_table(source_table)
40
+ attrs = {}
41
+ if source_table.class.method_defined?(:thead) && source_table.thead
42
+ attrs[:thead] = build_section(source_table.thead, ::Sts::TbxIsoTml::Thead)
43
+ end
44
+ if source_table.class.method_defined?(:tbody) && source_table.tbody
45
+ attrs[:tbody] = build_section(source_table.tbody, ::Sts::TbxIsoTml::Tbody)
46
+ end
47
+ ::Sts::TbxIsoTml::Table.new(attrs)
48
+ end
49
+
50
+ def build_section(source_section, klass)
51
+ trs = Array(source_section.tr).map { |tr| build_tr(tr) }
52
+ klass.new(tr: trs)
53
+ end
54
+
55
+ def build_tr(tr)
56
+ th_cells = Array(tr.th).map { |c| build_cell(c, ::Sts::TbxIsoTml::Th) }
57
+ td_cells = Array(tr.td).map { |c| build_cell(c, ::Sts::TbxIsoTml::Td) }
58
+ attrs = {}
59
+ attrs[:th] = th_cells if th_cells.any?
60
+ attrs[:td] = td_cells if td_cells.any?
61
+ ::Sts::TbxIsoTml::Tr.new(attrs)
62
+ end
63
+
64
+ def build_cell(cell, klass)
65
+ entries = cell_entries(cell)
66
+ fns = cell_footnotes(cell)
67
+ klass.new do |c|
68
+ entries.each do |kind, value|
69
+ case kind
70
+ when :text then c.content value
71
+ when :inline_formula then c.inline_formula value
72
+ when :monospace then c.monospace value
73
+ when :bold then c.bold value
74
+ when :italic then c.italic value
75
+ end
76
+ end
77
+ fns.each { |fn| c.fn fn }
78
+ end
79
+ end
80
+
81
+ # Extracts <fn> footnotes from the cell. MN table cells with
82
+ # footnote:[...] in AsciiDoc produce an inline <fn> carrying
83
+ # the footnote body (with paragraphs and math). STS preserves
84
+ # the footnote as a sibling <fn> element inside the cell so
85
+ # the renderer can show its content.
86
+ def cell_footnotes(cell)
87
+ return [] unless cell.class.method_defined?(:each_mixed_content)
88
+
89
+ cell.each_mixed_content.filter_map do |node|
90
+ next if node.is_a?(String)
91
+ next unless node.is_a?(Metanorma::Document::Components::Inline::FnElement)
92
+
93
+ build_fn(node)
94
+ end
95
+ end
96
+
97
+ def build_fn(fn_element)
98
+ paragraphs = Array(fn_element.p).map.with_index { |p, i| build_fn_paragraph(p, fn_element, first: i.zero?) }
99
+ return nil if paragraphs.empty?
100
+
101
+ attrs = { id: fn_element.id, p: paragraphs }
102
+ label = fn_label(fn_element)
103
+ attrs[:label] = ::Sts::NisoSts::Label.new(content: [label]) if label
104
+ ::Sts::TbxIsoTml::Fn.new(attrs)
105
+ end
106
+
107
+ # Fn#p is typed as Sts::NisoSts::Paragraph (uses :text attr).
108
+ # Our paragraph_transformer produces Sts::NisoSts::Paragraph.
109
+ # Convert by extracting text — footnote bodies in MN table
110
+ # cells drop inline math, so we accept text-only. The fn's
111
+ # reference letter prefixes the first paragraph to match
112
+ # MN's "<sup>a</sup> body" rendering.
113
+ def build_fn_paragraph(mn_p, fn_element, first:)
114
+ iso_p = paragraph_transformer.transform(mn_p)
115
+ text = extract_paragraph_text(iso_p)
116
+ label = fn_label(fn_element)
117
+ text = "#{label} #{text}" if first && label && !text.empty?
118
+ ::Sts::NisoSts::Paragraph.new(text: [text])
119
+ end
120
+
121
+ def extract_paragraph_text(iso_paragraph)
122
+ parts = []
123
+ iso_paragraph.element_order.each do |entry|
124
+ if entry.node_type == :text
125
+ parts << entry.text_content.to_s
126
+ elsif entry.node_type == :element && entry.name == "inline-formula"
127
+ # Best-effort: extract any mi/mn/mo text from the math
128
+ parts << extract_formula_text(iso_paragraph)
129
+ end
130
+ end
131
+ parts.join.strip
132
+ end
133
+
134
+ def extract_formula_text(iso_paragraph)
135
+ Array(iso_paragraph.inline_formula).map do |f|
136
+ math = f.math
137
+ next "" unless math
138
+ extract_math_text(math.to_xml)
139
+ end.join
140
+ end
141
+
142
+ # Strip comments and MathML/XML tags from a math element's
143
+ # serialised XML so the parity validator compares only the
144
+ # visible text. Comment stripping uses String#index (linear,
145
+ # correct for multi-char terminators); tag stripping uses
146
+ # simple non-nested character classes.
147
+ def extract_math_text(math_xml)
148
+ stripped = strip_xml_comments(math_xml.to_s)
149
+ stripped
150
+ .gsub(%r{</?\w+:[^>]*>}, " ")
151
+ .gsub(/<\/?[^>]*>/, " ")
152
+ .gsub(/\s+/, " ")
153
+ .strip
154
+ end
155
+
156
+ # Removes every `<!-- ... -->` comment from +str+. Linear in
157
+ # the input size; correct for comments containing `>` (which
158
+ # the previous regex-based stripper would prematurely match).
159
+ # An unterminated comment runs to end-of-string.
160
+ def strip_xml_comments(str)
161
+ result = String.new
162
+ pos = 0
163
+ while (open_idx = str.index("<!--", pos))
164
+ result << str[pos, open_idx - pos]
165
+ close_idx = str.index("-->", open_idx + 4)
166
+ break if close_idx.nil?
167
+ pos = close_idx + 3
168
+ end
169
+ result << str[pos..] unless pos.nil? || pos >= str.length
170
+ result
171
+ end
172
+
173
+ def fn_label(fn_element)
174
+ ref = fn_element.reference
175
+ ref.to_s unless ref.nil? || ref.to_s.empty?
176
+ end
177
+
178
+ # Walks the cell's mixed content in document order and produces
179
+ # an array of [:text, String] | [:inline_formula, InlineFormula] |
180
+ # [:monospace|:bold|:italic, model] tuples. The builder in
181
+ # build_cell routes them to the correct typed collection on the
182
+ # Td/Th model, preserving cross-type ordering via lutaml-model's
183
+ # mixed_content element_order.
184
+ def cell_entries(cell)
185
+ entries = []
186
+ current_text = nil
187
+ flush = lambda do
188
+ return if current_text.nil?
189
+ entries << [:text, current_text]
190
+ current_text = nil
191
+ end
192
+
193
+ walk_cell(cell) do |kind, node, text|
194
+ case kind
195
+ when :text
196
+ current_text = (current_text || "") + text
197
+ when :stem
198
+ formula = build_inline_formula(node)
199
+ if formula
200
+ flush.call
201
+ entries << [:inline_formula, formula]
202
+ else
203
+ mirror_text = cell_mirror_text(node)
204
+ current_text = (current_text || "") + mirror_text if mirror_text && !mirror_text.empty?
205
+ end
206
+ when :monospace
207
+ flush.call
208
+ entries << [:monospace, ::Sts::NisoSts::Monospace.new(content: [text])]
209
+ when :bold
210
+ flush.call
211
+ entries << [:bold, ::Sts::TbxIsoTml::Bold.new(value: [text])]
212
+ when :italic
213
+ flush.call
214
+ entries << [:italic, ::Sts::TbxIsoTml::Italic.new(value: [text])]
215
+ end
216
+ end
217
+ flush.call
218
+ entries
219
+ end
220
+
221
+ def walk_cell(cell)
222
+ return unless cell.class.method_defined?(:each_mixed_content)
223
+
224
+ @cell_stem_mirrors = build_cell_stem_mirrors(cell)
225
+
226
+ cell.each_mixed_content do |node|
227
+ if node.is_a?(String)
228
+ yield(:text, node, node)
229
+ next
230
+ end
231
+
232
+ case node.class.name&.split("::")&.last
233
+ when "StemInlineElement", "StemBlockElement"
234
+ yield(:stem, node, nil)
235
+ when "FmtStemElement"
236
+ next
237
+ when "AsciimathElement"
238
+ next
239
+ when "TtElement", "MonospaceElement"
240
+ yield(:monospace, node, RenderedTextExtractor.text_of(node))
241
+ when "StrongRawElement", "StrongElement"
242
+ yield(:bold, node, RenderedTextExtractor.text_of(node))
243
+ when "EmRawElement", "EmElement"
244
+ yield(:italic, node, RenderedTextExtractor.text_of(node))
245
+ else
246
+ text = RenderedTextExtractor.text_of(node)
247
+ yield(:text, node, text) if text && !text.empty?
248
+ end
249
+ end
250
+ end
251
+
252
+ def build_inline_formula(stem_node)
253
+ mirror = @cell_stem_mirrors && @cell_stem_mirrors[stem_node.id]
254
+ source = mirror || stem_node
255
+ ::Metanorma::Oiml::Sts::MathmlBuilder.inline_formula_from_stem(
256
+ source,
257
+ klass: ::Sts::NisoSts::InlineFormula
258
+ )
259
+ end
260
+
261
+ def build_cell_stem_mirrors(cell)
262
+ map = {}
263
+ return map unless cell.class.method_defined?(:each_mixed_content)
264
+
265
+ cell.each_mixed_content do |node|
266
+ next unless node.is_a?(Metanorma::Document::Components::Inline::FmtStemElement)
267
+ Array(node.semx).each do |semx|
268
+ source = semx.source if semx.is_a?(Metanorma::Document::Components::Inline::SemxElement)
269
+ map[source] = node if source && !source.empty?
270
+ end
271
+ end
272
+ map
273
+ end
274
+
275
+ def cell_mirror_text(stem_node)
276
+ mirror = @cell_stem_mirrors && @cell_stem_mirrors[stem_node.id]
277
+ return nil unless mirror
278
+
279
+ Array(mirror.semx).map do |semx|
280
+ next nil unless semx.is_a?(Metanorma::Document::Components::Inline::SemxElement)
281
+ semx.text
282
+ end.compact.join
283
+ rescue StandardError
284
+ nil
285
+ end
286
+ end
287
+ end
288
+ end
289
+ end
290
+ end
@@ -0,0 +1,236 @@
1
+ # frozen_string: true
2
+
3
+ module Metanorma
4
+ module Oiml
5
+ module Sts
6
+ module Transformer
7
+ class TermTransformer < Base
8
+ def transform(source_term)
9
+ content = []
10
+
11
+ term_number = extract_term_number(source_term)
12
+ add_preferred_name(content, source_term)
13
+ add_definition(content, source_term)
14
+ add_standalone_paragraphs(content, source_term)
15
+ add_lists(content, source_term)
16
+ add_term_notes(content, source_term)
17
+ add_term_examples(content, source_term)
18
+ add_sources(content, source_term)
19
+
20
+ attrs = {}
21
+ attrs[:id] = source_term.id if source_term.id
22
+ # The term number ("3.1") becomes the section's <title>,
23
+ # which the renderer emits as a TermNum heading matching
24
+ # MN's "<h3 class='TermNum'>3.5.12</h3>" layout. Previously
25
+ # it was emitted as a leading <p>, which the parity check
26
+ # did not see as a heading.
27
+ attrs[:title] = ::Sts::NisoSts::Title.new(content: [term_number]) if term_number
28
+ paragraphs, lists, notes = partition_content(content)
29
+ attrs[:paragraphs] = paragraphs if paragraphs.any?
30
+ attrs[:list] = lists if lists.any?
31
+ attrs[:non_normative_note] = notes if notes.any?
32
+
33
+ nested = Array(source_term.term).map { |sub| transform(sub) }
34
+ attrs[:sec] = nested if nested.any?
35
+
36
+ ::Sts::NisoSts::Section.new(attrs)
37
+ end
38
+
39
+ def partition_content(content)
40
+ paragraphs = content.grep(::Sts::NisoSts::Paragraph)
41
+ lists = content.grep(::Sts::NisoSts::List)
42
+ notes = content.grep(::Sts::NisoSts::NonNormativeNote)
43
+ others = content.reject do |c|
44
+ [::Sts::NisoSts::Paragraph, ::Sts::NisoSts::List,
45
+ ::Sts::NisoSts::NonNormativeNote].any? { |k| c.is_a?(k) }
46
+ end
47
+ [paragraphs + others, lists, notes]
48
+ end
49
+
50
+ private
51
+
52
+ # The term number ("3.1") from the term's fmt-name caption label.
53
+ def extract_term_number(source_term)
54
+ section_transformer.extract_autonum(source_term)
55
+ end
56
+
57
+ def add_preferred_name(content, source_term)
58
+ para = preferred_name_paragraph(source_term)
59
+ content << para if para
60
+ end
61
+
62
+ # Preferred names often contain math (e.g. "maximum capacity
63
+ # (E_max)"). The typed TermNameElement drops stems, so we prefer
64
+ # the rendered fmt_preferred tree and route it through the
65
+ # paragraph transformer so MathML passes through verbatim.
66
+ # Falls back to text-only paragraph when no rendered tree exists.
67
+ def preferred_name_paragraph(source_term)
68
+ fmt = Array(source_term.fmt_preferred).first if source_term.class.method_defined?(:fmt_preferred)
69
+ if fmt && fmt.class.method_defined?(:p)
70
+ p = Array(fmt.p).first
71
+ return paragraph_transformer.transform(p) if p
72
+ end
73
+
74
+ text = preferred_name_text(source_term)
75
+ return nil if text.nil? || text.empty?
76
+
77
+ ::Sts::NisoSts::Paragraph.new(text: [text])
78
+ end
79
+
80
+ def preferred_name_text(source_term)
81
+ preferred = Array(source_term.preferred).first
82
+ return nil unless preferred
83
+
84
+ expression = preferred.expression
85
+ return RenderedTextExtractor.text_of(preferred) unless expression
86
+
87
+ names = Array(expression.name)
88
+ return RenderedTextExtractor.text_of(preferred) if names.empty?
89
+
90
+ RenderedTextExtractor.text_of(names.first)
91
+ end
92
+
93
+ def add_definition(content, source_term)
94
+ definition = Array(source_term.definition).first
95
+ return unless definition
96
+
97
+ vd = definition.verbal_definition
98
+ return unless vd
99
+
100
+ Array(vd.p).each { |p| content << paragraph_transformer.transform(p) }
101
+ end
102
+
103
+ def add_standalone_paragraphs(content, source_term)
104
+ Array(source_term.p).each { |p| content << paragraph_transformer.transform(p) }
105
+ end
106
+
107
+ def add_lists(content, source_term)
108
+ definition = Array(source_term.definition).first
109
+ return unless definition
110
+
111
+ vd = definition.verbal_definition
112
+ return unless vd
113
+
114
+ Array(vd.ul).each { |ul| content << list_transformer.transform(ul) }
115
+ Array(vd.ol).each { |ol| content << list_transformer.transform(ol) }
116
+ end
117
+
118
+ def add_list_items(content, list)
119
+ Array(list.listitem).each do |li|
120
+ Array(li.paragraphs).each { |p| content << paragraph_transformer.transform(p) }
121
+ end
122
+ end
123
+
124
+ # Term notes become <non-normative-note> carrying their own
125
+ # label ("Note 1 to entry:") — plain "Note" notes leave the
126
+ # kind label to the renderer. Nested <ul>/<ol> inside the
127
+ # termnote become sibling <list> children of the note so the
128
+ # renderer emits them after the paragraphs.
129
+ def add_term_notes(content, source_term)
130
+ Array(source_term.termnote).each do |tn|
131
+ paragraphs = Array(tn.p).map { |p| paragraph_transformer.transform(p) }
132
+ lists = Array(tn.ul).map { |ul| list_transformer.transform(ul) } +
133
+ Array(tn.ol).map { |ol| list_transformer.transform(ol) }
134
+ next if paragraphs.empty? && lists.empty?
135
+
136
+ attrs = { p: paragraphs }
137
+ attrs[:list] = lists if lists.any?
138
+ label = term_note_label(tn)
139
+ attrs[:label] = ::Sts::NisoSts::Label.new(content: [label]) if label
140
+ content << ::Sts::NisoSts::NonNormativeNote.new(attrs)
141
+ end
142
+ end
143
+
144
+ # MN renders term notes "<span class='term-note-label'>
145
+ # Term notes become <non-normative-note> with a "Note:" label
146
+ # — matching MN's "<span class='termnote_label'>Note: </span>"
147
+ # prefix inside the note paragraph. The entity's autonum
148
+ # ("Note 1 to entry:") isn't surfaced in MN's term notes, so
149
+ # we mirror that here.
150
+ def term_note_label(_note_obj)
151
+ "Note:"
152
+ end
153
+
154
+ def add_term_examples(content, source_term)
155
+ Array(source_term.termexample).each { |te| add_typed_note_paragraphs(content, te, "Example", colon: true) }
156
+ end
157
+
158
+ def add_typed_note_paragraphs(content, note_obj, label, colon: false)
159
+ suffix = colon ? ": " : " "
160
+ Array(note_obj.p).each_with_index do |p, i|
161
+ para = paragraph_transformer.transform(p)
162
+ if i.zero?
163
+ existing = para.content.to_a
164
+ para.content = ["#{label}#{suffix}#{existing.first || ''}"] + existing[1..]
165
+ end
166
+ content << para
167
+ end
168
+ Array(note_obj.ul).each { |ul| content << list_transformer.transform(ul) }
169
+ Array(note_obj.ol).each { |ol| content << list_transformer.transform(ol) }
170
+ end
171
+
172
+ def build_list(list, list_type)
173
+ list_transformer.transform_with_type(list, list_type)
174
+ end
175
+
176
+ def add_sources(content, source_term)
177
+ Array(source_term.source).each { |s| content << build_source(s) }
178
+ end
179
+
180
+ def build_source(source)
181
+ origin = source.origin
182
+ return nil unless origin
183
+
184
+ citeas = origin.citeas
185
+ locality = extract_locality(origin)
186
+ ordinal = bib_ordinal(origin)
187
+
188
+ text = "[SOURCE: #{citeas}"
189
+ if locality
190
+ text << ", #{locality}"
191
+ text << "[#{ordinal}]" if ordinal
192
+ text << " "
193
+ end
194
+ text << "]"
195
+ ::Sts::NisoSts::Paragraph.new(text: [text])
196
+ end
197
+
198
+ def extract_locality(origin)
199
+ ls_arr = origin.locality_stack
200
+ return nil unless ls_arr.is_a?(Array) && ls_arr.any?
201
+
202
+ parts = []
203
+ ls_arr.each do |ls|
204
+ Array(ls.bib_locality).each do |loc|
205
+ from = loc.reference_from
206
+ val = case from
207
+ when String then from
208
+ when nil then ""
209
+ else
210
+ cv = from.content rescue nil
211
+ cv ? Array(cv).first.to_s : from.to_s
212
+ end
213
+ parts << val if val && !val.empty?
214
+ end
215
+ end
216
+ parts.empty? ? nil : parts.join(", ")
217
+ end
218
+
219
+ def bib_ordinal(origin)
220
+ bibitemid = origin.bibitemid
221
+ return nil unless bibitemid
222
+
223
+ bib = context.source.typed_root.bibliography
224
+ return nil unless bib
225
+
226
+ items = Array(bib.references).flat_map { |rs| Array(rs.references) }
227
+ items.each_with_index do |item, i|
228
+ return (i + 1).to_s if item.id == bibitemid || item.anchor == bibitemid
229
+ end
230
+ nil
231
+ end
232
+ end
233
+ end
234
+ end
235
+ end
236
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string: true
2
+
3
+ # OIML Metanorma presentation XML → OIML NISO STS XML transformer.
4
+ #
5
+ # Architecture:
6
+ # - {SourceDocument}: parses input via Metanorma::Oiml::Document::Root
7
+ # (typed model, on the metanorma-document framework). Zero Nokogiri.
8
+ # - {Context}: shared state during one conversion run.
9
+ # - {ModelBuilder}: factory methods for sts-ruby model instances.
10
+ # - {DocumentTransformer}: orchestrator. Produces a Sts::NisoSts::Standard
11
+ # root via sts-ruby models and serializes via lutaml-model.
12
+ # - Per-element transformers walk typed-model source objects and emit
13
+ # sts-ruby model instances. Zero Nokogiri.
14
+ # - {RenderedTextExtractor}: unified text extraction from typed models.
15
+ # - {BlockDispatcher}: Open/Closed dispatch by typed model class name.
16
+ module Metanorma::Oiml::Sts::Transformer
17
+ autoload :SourceDocument, "metanorma/oiml/sts/transformer/source_document"
18
+ autoload :Standard, "metanorma/oiml/sts/transformer/standard"
19
+ autoload :Context, "metanorma/oiml/sts/transformer/context"
20
+ autoload :ModelBuilder, "metanorma/oiml/sts/transformer/model_builder"
21
+ autoload :IdGenerator, "metanorma/oiml/sts/transformer/id_generator"
22
+ autoload :FootnoteCollector, "metanorma/oiml/sts/transformer/footnote_collector"
23
+ autoload :Base, "metanorma/oiml/sts/transformer/base"
24
+ autoload :DocumentTransformer, "metanorma/oiml/sts/transformer/document_transformer"
25
+ autoload :FrontTransformer, "metanorma/oiml/sts/transformer/front_transformer"
26
+ autoload :BodyTransformer, "metanorma/oiml/sts/transformer/body_transformer"
27
+ autoload :BackTransformer, "metanorma/oiml/sts/transformer/back_transformer"
28
+ autoload :SectionTransformer, "metanorma/oiml/sts/transformer/section_transformer"
29
+ autoload :ParagraphTransformer, "metanorma/oiml/sts/transformer/paragraph_transformer"
30
+ autoload :InlineTransformer, "metanorma/oiml/sts/transformer/inline_transformer"
31
+ autoload :ListTransformer, "metanorma/oiml/sts/transformer/list_transformer"
32
+ autoload :TableTransformer, "metanorma/oiml/sts/transformer/table_transformer"
33
+ autoload :FigureTransformer, "metanorma/oiml/sts/transformer/figure_transformer"
34
+ autoload :FormulaTransformer, "metanorma/oiml/sts/transformer/formula_transformer"
35
+ autoload :NoteTransformer, "metanorma/oiml/sts/transformer/note_transformer"
36
+ autoload :ReferenceTransformer, "metanorma/oiml/sts/transformer/reference_transformer"
37
+ autoload :TermTransformer, "metanorma/oiml/sts/transformer/term_transformer"
38
+ autoload :DlTransformer, "metanorma/oiml/sts/transformer/dl_transformer"
39
+ autoload :BlockDispatcher, "metanorma/oiml/sts/transformer/block_dispatcher"
40
+ autoload :RenderedTextExtractor, "metanorma/oiml/sts/transformer/rendered_text_extractor"
41
+
42
+ def self.convert(input)
43
+ source = SourceDocument.parse(input)
44
+ context = Context.new(source)
45
+ DocumentTransformer.new(context).transform_to_xml(source)
46
+ end
47
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "nokogiri"
4
+
5
+ module Metanorma
6
+ module Oiml
7
+ module Sts
8
+ # Immutable value object holding the result of validating an OIML STS
9
+ # XML document. `errors` is an array of {Finding} structs.
10
+ class ValidationReport
11
+ Finding = Struct.new(:rule_id, :message, :xpath, keyword_init: true)
12
+
13
+ attr_reader :errors
14
+
15
+ def initialize(errors: [])
16
+ @errors = errors
17
+ end
18
+
19
+ def valid?
20
+ errors.empty?
21
+ end
22
+
23
+ def add(rule_id:, message:, xpath: nil)
24
+ @errors << Finding.new(rule_id: rule_id, message: message, xpath: xpath)
25
+ end
26
+
27
+ def freeze
28
+ @errors.freeze
29
+ super
30
+ end
31
+
32
+ def to_s
33
+ if valid?
34
+ "OIML STS validation passed (0 errors)"
35
+ else
36
+ "OIML STS validation FAILED (#{errors.size} errors):\n" \
37
+ "#{errors.map { |e| " [#{e.rule_id}] #{e.message}#{' at ' + e.xpath if e.xpath}" }.join("\n")}"
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end