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,574 @@
1
+ # frozen_string_literal: true
2
+ module Metanorma
3
+ module Oiml
4
+ module Sts
5
+ module Transformer
6
+ class ParagraphTransformer < Base
7
+ PRESENTATION_CLASSES = %w[
8
+ FmtTitleElement FmtXrefLabelElement FmtNameElement FmtFnLabelElement
9
+ FmtAnnotationStartElement FmtAnnotationEndElement FmtConceptElement
10
+ FmtStemElement
11
+ LocalizedString LocalizedStrings FmtVal AsciiMath
12
+ BrElement TabElement Bookmark
13
+ ].freeze
14
+
15
+ def transform(source_para)
16
+ id_val = nil
17
+ if source_para.is_a?(Lutaml::Model::Serializable) && source_para.class.method_defined?(:id)
18
+ id_val = source_para.id
19
+ end
20
+
21
+ @seen_nodes = {}
22
+ @stem_mirrors = build_stem_mirror_map(source_para)
23
+ entries = []
24
+ current_text = nil
25
+ flush_text = lambda do
26
+ return if current_text.nil?
27
+ entries << [:text, clean_text(current_text)]
28
+ current_text = nil
29
+ end
30
+
31
+ walk_inline(source_para) do |kind, node, text|
32
+ case kind
33
+ when :text
34
+ current_text = (current_text || "") + text
35
+ when :bold
36
+ flush_text.call
37
+ entries << [:bold, text]
38
+ when :italic
39
+ flush_text.call
40
+ entries << [:italic, text]
41
+ when :sub
42
+ flush_text.call
43
+ entries << [:sub, text]
44
+ when :sup
45
+ flush_text.call
46
+ entries << [:sup, text]
47
+ when :tt
48
+ flush_text.call
49
+ entries << [:monospace, text]
50
+ when :stem
51
+ formula = build_inline_formula(node)
52
+ if formula
53
+ flush_text.call
54
+ entries << [:inline_formula, formula]
55
+ else
56
+ text = mirror_text(node)
57
+ current_text = (current_text || "") + text if text && !text.empty?
58
+ end
59
+ when :xref
60
+ x = build_xref(node)
61
+ if x
62
+ flush_text.call
63
+ entries << [:xref, x]
64
+ end
65
+ when :link
66
+ l = build_link(node)
67
+ if l
68
+ flush_text.call
69
+ entries << [:ext_link, l]
70
+ end
71
+ when :eref
72
+ if text && !text.empty?
73
+ current_text = (current_text || "") + text
74
+ end
75
+ when :concept
76
+ if text && !text.empty?
77
+ current_text = (current_text || "") + text
78
+ end
79
+ when :fn
80
+ fn = build_fn(node)
81
+ if fn
82
+ flush_text.call
83
+ entries << [:fn, fn]
84
+ end
85
+ end
86
+ end
87
+ flush_text.call
88
+
89
+ ::Sts::NisoSts::Paragraph.new(id: id_val) do |p|
90
+ entries.each do |kind, value|
91
+ case kind
92
+ when :text then p.text value
93
+ when :bold then p.bold value
94
+ when :italic then p.italic value
95
+ when :sub then p.sub value
96
+ when :sup then p.sup value
97
+ when :monospace then p.monospace value
98
+ when :inline_formula then p.inline_formula value
99
+ when :xref then p.xref value
100
+ when :ext_link then p.ext_link value
101
+ when :fn then p.fn value
102
+ end
103
+ end
104
+ end
105
+ end
106
+
107
+ private
108
+
109
+ def walk_inline(obj, &block)
110
+ return unless obj.class.method_defined?(:each_mixed_content)
111
+
112
+ nodes = obj.each_mixed_content.to_a
113
+ nodes.each_with_index do |node, index|
114
+ following = nodes[index + 1]
115
+ if semantic_with_mirror?(node, following)
116
+ emit_mirror(node, following, &block)
117
+ next
118
+ end
119
+
120
+ walk_node(node, &block)
121
+ end
122
+ end
123
+
124
+ # A semantic element (<link>, <eref>, ...) immediately followed
125
+ # by its <semx> presentation mirror (semx@source == element@id):
126
+ # the pair is emitted as ONE unit — the mirror carries the
127
+ # renderable form, the semantic original lends its kind
128
+ # (style/type) when the mirror doesn't declare one (NISO STS
129
+ # @ref-type maps from Metanorma's @type/@style).
130
+ def semantic_with_mirror?(node, following)
131
+ return false if node.is_a?(String)
132
+ return false unless following.is_a?(Metanorma::Document::Components::Inline::SemxElement)
133
+ return false unless following.class.method_defined?(:source) && following.source
134
+
135
+ node.class.method_defined?(:id) && node.id && node.id == following.source
136
+ end
137
+
138
+ def emit_mirror(node, mirror, &block)
139
+ @mirror_style = node_style(node)
140
+ walk_node(mirror, &block)
141
+ ensure
142
+ @mirror_style = nil
143
+ end
144
+
145
+ def node_style(node)
146
+ %i[style type].each do |attr|
147
+ next unless node.class.method_defined?(attr)
148
+
149
+ val = node.public_send(attr).to_s
150
+ return val unless val.empty?
151
+ end
152
+ nil
153
+ end
154
+
155
+ # Walks a single node yielded by each_mixed_content. Recurses
156
+ # into SemxElement wrappers so fmt-preferred / fmt-concept trees
157
+ # (which wrap content in semx) walk the same way as body
158
+ # paragraphs (which usually have direct inline children).
159
+ # Semx mirrors inline children into named attributes as well as
160
+ # its mixed content — @seen_nodes prevents double-yielding.
161
+ def walk_node(node, &block)
162
+ if node.is_a?(String)
163
+ yield(:text, node, node)
164
+ return
165
+ end
166
+ return if @seen_nodes[node.object_id]
167
+
168
+ @seen_nodes[node.object_id] = true
169
+
170
+ class_name = node.class.name&.split("::")&.last
171
+ return if PRESENTATION_CLASSES.include?(class_name)
172
+ return if presentation_span?(node)
173
+
174
+ case class_name
175
+ when "SemxElement"
176
+ # Mixed content first; named child attributes only as a
177
+ # fallback for fmt trees that store inlines there (the two
178
+ # views mirror each other — walking both duplicates links.
179
+ yield_count = 0
180
+ counting = lambda do |kind, node, text|
181
+ yield_count += 1
182
+ yield(kind, node, text)
183
+ end
184
+ walk_inline(node, &counting)
185
+ walk_semx_named_children(node, &counting) if yield_count.zero?
186
+ when "EmRawElement", "EmElement"
187
+ yield(:italic, node, build_inline_container(node, ::Sts::TbxIsoTml::Italic))
188
+ when "StrongRawElement", "StrongElement"
189
+ yield(:bold, node, build_inline_container(node, ::Sts::TbxIsoTml::Bold))
190
+ when "SubElement"
191
+ yield(:sub, node, build_inline_container(node, ::Sts::NisoSts::Sub))
192
+ when "SupElement"
193
+ yield(:sup, node, build_inline_container(node, ::Sts::NisoSts::Sup))
194
+ when "TtElement", "MonospaceElement"
195
+ yield(:tt, node, build_inline_container(node, ::Sts::NisoSts::Monospace))
196
+ when "StemInlineElement", "StemBlockElement"
197
+ yield(:stem, node, nil)
198
+ when "AsciimathElement"
199
+ nil
200
+ when "SpanElement"
201
+ walk_inline(node, &block)
202
+ when "LinkElement"
203
+ yield(:link, node, text_of(node))
204
+ when "XrefElement"
205
+ yield(:xref, node, nil)
206
+ when "FmtXrefElement", "FmtErefElement"
207
+ yield(:xref, node, RenderedTextExtractor.text_of(node))
208
+ when "ErefElement"
209
+ yield(:eref, node, eref_text(node))
210
+ when "ConceptElement"
211
+ yield(:concept, node, concept_text(node))
212
+ when "FnElement"
213
+ yield(:fn, node, nil)
214
+ else
215
+ # Never silently drop content: unhandled inline classes
216
+ # degrade to their extracted text.
217
+ fallback_text = RenderedTextExtractor.text_of(node)
218
+ yield(:text, node, fallback_text) if fallback_text && !fallback_text.empty?
219
+ end
220
+ end
221
+
222
+ # Named child attributes on SemxElement that store inline
223
+ # content (strong_child, em_child, fmt_xref, etc.). Walked
224
+ # when each_mixed_content yields nothing — fmt-* mirrors land
225
+ # here because the typed model promotes them out of mixed
226
+ # content into dedicated typed collections.
227
+ SEMX_NAMED_CHILDREN = %i[
228
+ stem_child strong em sup sub tt underline strike
229
+ preferred_child name_child title_child
230
+ verbal_definition_child definition_child
231
+ concept_child fn_child link_child
232
+ fmt_xref fmt_eref fmt_concept fmt_link fmt_stem fmt_preferred
233
+ fmt_definition fmt_termsource fmt_name fmt_fn_body
234
+ ].freeze
235
+
236
+ def walk_semx_named_children(semx, &block)
237
+ SEMX_NAMED_CHILDREN.each do |attr|
238
+ next unless semx.class.method_defined?(attr)
239
+ val = semx.public_send(attr)
240
+ next if val.nil?
241
+ Array(val).each { |v| walk_node(v, &block) }
242
+ end
243
+ end
244
+
245
+ # Build a typed Sts inline container (Bold/Italic/Sub/Sup/
246
+ # Monospace) whose content is the recursively-walked children
247
+ # of `node`. Used for inline elements that wrap further inline
248
+ # content — most importantly <strong>max capacity (<stem>
249
+ # E_max </stem>)</strong>, where the stem must survive as an
250
+ # inline-formula INSIDE the bold (not as a sibling text drop).
251
+ def build_inline_container(node, klass)
252
+ children = build_inline_children(node)
253
+ klass.new do |container|
254
+ children.each { |child| attach_inline_child(container, child) }
255
+ end
256
+ end
257
+
258
+ # Routes a child to the matching typed-collection attribute on
259
+ # the container. NisoSts stores text on a different attribute
260
+ # per class (Paragraph#text, TbxIsoTml::Bold/Italic#value,
261
+ # everything else has #content). Sub/Sup only declare content,
262
+ # so non-string children degrade to their #to_s form.
263
+ def attach_inline_child(container, child)
264
+ case container
265
+ when ::Sts::NisoSts::Paragraph
266
+ attach_to_paragraph(container, child)
267
+ when ::Sts::TbxIsoTml::Bold, ::Sts::TbxIsoTml::Italic
268
+ attach_to_rich_inline(container, child)
269
+ else
270
+ container.content child.to_s
271
+ end
272
+ end
273
+
274
+ def attach_to_rich_inline(container, child)
275
+ case child
276
+ when String then container.value child
277
+ when ::Sts::NisoSts::InlineFormula then container.value inline_formula_text(child)
278
+ when ::Sts::TbxIsoTml::Bold then container.bold child
279
+ when ::Sts::TbxIsoTml::Italic then container.italic child
280
+ when ::Sts::NisoSts::Sub then container.sub child
281
+ when ::Sts::NisoSts::Sup then container.sup child
282
+ when ::Sts::NisoSts::Monospace then container.monospace child
283
+ when ::Sts::TbxIsoTml::Xref then container.xref child
284
+ when ::Sts::NisoSts::ExtLink then container.ext_link child
285
+ else
286
+ container.value child.to_s
287
+ end
288
+ end
289
+
290
+ # sts 0.6.0's TbxIsoTml::Bold/Italic don't declare an
291
+ # inline_formula collection, so a formula nested inside a
292
+ # <strong>/<em>wider container</strong> can't keep its MathML.
293
+ # Fall back to the formula's visible text — matches what a
294
+ # text-only reader sees in the source (e.g. "E_max" inside
295
+ # "<strong>max capacity (<stem>E_max</stem>)</strong>").
296
+ # Leading/trailing whitespace is preserved so "(<stem>v</stem>)"
297
+ # renders as "( v )" matching MN's text extraction.
298
+ def inline_formula_text(formula)
299
+ math = formula.math
300
+ return "" unless math
301
+
302
+ text = math.to_xml.to_s
303
+ .gsub(/<[^>]+>/, " ")
304
+ .gsub(/\s+/, " ")
305
+ text = " #{text.strip} " unless text.strip.empty?
306
+ text
307
+ end
308
+
309
+ def attach_to_paragraph(container, child)
310
+ case child
311
+ when String then container.text child
312
+ when ::Sts::NisoSts::InlineFormula then container.inline_formula child
313
+ when ::Sts::TbxIsoTml::Bold then container.bold child
314
+ when ::Sts::TbxIsoTml::Italic then container.italic child
315
+ when ::Sts::NisoSts::Sub then container.sub child
316
+ when ::Sts::NisoSts::Sup then container.sup child
317
+ when ::Sts::NisoSts::Monospace then container.monospace child
318
+ when ::Sts::TbxIsoTml::Xref then container.xref child
319
+ when ::Sts::NisoSts::ExtLink then container.ext_link child
320
+ when ::Sts::NisoSts::Fn then container.fn child
321
+ else
322
+ container.text child.to_s
323
+ end
324
+ end
325
+
326
+ # Recursively walks node's children, returning an array of
327
+ # strings and Sts inline model instances (InlineFormula, Xref,
328
+ # ExtLink, Bold, Italic, Sub, Sup) in document order. Stems
329
+ # become InlineFormula via MathML passthrough; nested strong/
330
+ # em become nested Bold/Italic.
331
+ def build_inline_children(node)
332
+ return [] unless node.class.method_defined?(:each_mixed_content)
333
+
334
+ entries = []
335
+ current_text = nil
336
+
337
+ walk_inline(node) do |kind, child, payload|
338
+ case kind
339
+ when :text
340
+ current_text = (current_text || "") + payload
341
+ when :bold, :italic, :sub, :sup, :tt
342
+ if current_text
343
+ entries << clean_text(current_text)
344
+ current_text = nil
345
+ end
346
+ entries << payload
347
+ when :stem
348
+ formula = build_inline_formula(child)
349
+ next unless formula
350
+
351
+ if current_text
352
+ entries << clean_text(current_text)
353
+ current_text = nil
354
+ end
355
+ entries << formula
356
+ when :xref
357
+ x = build_xref(child)
358
+ next unless x
359
+
360
+ if current_text
361
+ entries << clean_text(current_text)
362
+ current_text = nil
363
+ end
364
+ entries << x
365
+ when :link
366
+ l = build_link(child)
367
+ next unless l
368
+
369
+ if current_text
370
+ entries << clean_text(current_text)
371
+ current_text = nil
372
+ end
373
+ entries << l
374
+ when :eref, :concept
375
+ current_text = (current_text || "") + payload if payload && !payload.empty?
376
+ end
377
+ end
378
+ entries << clean_text(current_text) if current_text
379
+ entries
380
+ end
381
+ # MathML passthrough: parse the stem's <math> into Mml::V3::Math
382
+ # and wrap it in an InlineFormula. Prefers the formatted mirror
383
+ # (fmt-stem) when one exists for this stem's id — the formatted
384
+ # version carries rendered MathML (European decimal commas,
385
+ # applied symbol rules) matching MN's HTML output.
386
+ def build_inline_formula(stem_node)
387
+ mirror = lookup_stem_mirror(stem_node)
388
+ source_for_math = mirror || stem_node
389
+ ::Metanorma::Oiml::Sts::MathmlBuilder.inline_formula_from_stem(source_for_math)
390
+ end
391
+
392
+ # Footnote bodies become a sibling <fn> element inside the
393
+ # paragraph. Each fn paragraph is run through paragraph_
394
+ # transformer; the result is flattened to text because
395
+ # Sts::TbxIsoTml::Fn#p is typed as NisoSts::Paragraph (uses
396
+ # :text, not :content) and the math roundtrip drops msub.
397
+ def build_fn(fn_element)
398
+ paragraphs = Array(fn_element.p).map.with_index do |p, i|
399
+ build_fn_paragraph_text(p, fn_element, first: i.zero?)
400
+ end
401
+ return nil if paragraphs.empty?
402
+
403
+ attrs = { id: fn_element.id, paragraph: paragraphs }
404
+ label = fn_element.reference if fn_element.is_a?(Metanorma::Document::Components::Inline::FnElement)
405
+ attrs[:label] = ::Sts::NisoSts::Label.new(content: [label]) if label && !label.to_s.empty?
406
+ ::Sts::NisoSts::Fn.new(attrs)
407
+ end
408
+
409
+ def build_fn_paragraph_text(mn_p, fn_element, first:)
410
+ text = RenderedTextExtractor.text_of(mn_p)
411
+ label = fn_element.reference if fn_element.is_a?(Metanorma::Document::Components::Inline::FnElement)
412
+ text = "#{label})#{text}" if first && label && !label.to_s.empty? && !text.empty?
413
+ ::Sts::NisoSts::Paragraph.new(text: [text])
414
+ end
415
+
416
+ # Builds a map of stem_id → FmtStemElement mirror by scanning
417
+ # the paragraph's descendant fmt-stem elements. Each fmt-stem's
418
+ # inner semx carries a `source` matching the original stem's id.
419
+ def build_stem_mirror_map(paragraph)
420
+ map = {}
421
+ return map unless paragraph.class.method_defined?(:each_mixed_content)
422
+
423
+ walk_all(paragraph) do |node|
424
+ next unless node.is_a?(Metanorma::Document::Components::Inline::FmtStemElement)
425
+ Array(node.semx).each do |semx|
426
+ source = semx.source if semx.class.method_defined?(:source)
427
+ map[source] = node if source && !source.empty?
428
+ end
429
+ end
430
+ map
431
+ end
432
+
433
+ def lookup_stem_mirror(stem_node)
434
+ return nil unless stem_node.class.method_defined?(:id)
435
+ return nil unless stem_node.id
436
+
437
+ @stem_mirrors ? @stem_mirrors[stem_node.id] : nil
438
+ end
439
+
440
+ # When a fmt-stem mirror carries plain text (no MathML — e.g.
441
+ # numbers with thousands separator: "3 000", "1 000"), MN
442
+ # renders them as text rather than math. Expose the mirror's
443
+ # text so the caller can emit it as a plain string instead
444
+ # of dropping the content.
445
+ def mirror_text(stem_node)
446
+ mirror = lookup_stem_mirror(stem_node)
447
+ return nil unless mirror
448
+
449
+ Array(mirror.semx).map do |semx|
450
+ next nil unless semx.is_a?(Metanorma::Document::Components::Inline::SemxElement)
451
+ semx.text
452
+ end.compact.join
453
+ rescue StandardError
454
+ nil
455
+ end
456
+
457
+ # Depth-first walk of every node under `root` (no dedup — we
458
+ # need to find every fmt-stem even when one is nested inside
459
+ # another's semx).
460
+ def walk_all(root, &block)
461
+ queue = [root]
462
+ until queue.empty?
463
+ node = queue.shift
464
+ next if node.nil?
465
+ if node.is_a?(String)
466
+ block.call(node)
467
+ next
468
+ end
469
+ block.call(node)
470
+ node.class.attributes.each do |attr_name, _|
471
+ next unless node.class.method_defined?(attr_name)
472
+ val = node.public_send(attr_name) rescue next
473
+ next if val.nil?
474
+ Array(val).each { |v| queue << v if v.is_a?(Lutaml::Model::Serializable) }
475
+ end
476
+ end
477
+ end
478
+
479
+ def build_xref(node)
480
+ rid = nil
481
+ [:target, :refid, :to].each do |attr|
482
+ next unless node.class.method_defined?(attr)
483
+ val = node.public_send(attr)
484
+ rid = val if val.is_a?(String) && !val.empty?
485
+ break if rid
486
+ end
487
+ return nil unless rid
488
+
489
+ visible = xref_visible_text(node, rid)
490
+ ModelBuilder.xref(rid: rid, ref_type: ref_type_for(node), value: visible)
491
+ end
492
+
493
+ def xref_visible_text(node, rid)
494
+ explicit = text_of(node)
495
+ return explicit unless explicit.empty?
496
+ case rid
497
+ when /\Asec-(.+)\z/, /\Afig-(.+)\z/, /\Atable-(.+)\z/, /\Afn-(.+)\z/
498
+ Regexp.last_match(1)
499
+ else
500
+ rid
501
+ end
502
+ end
503
+
504
+ def ref_type_for(node)
505
+ style = node.style if node.class.method_defined?(:style)
506
+ style = style.to_s if style
507
+ style = @mirror_style if (style.nil? || style.empty?) && @mirror_style
508
+ case style
509
+ when "clause", "section" then "sec"
510
+ when "table" then "table"
511
+ when "figure", "fig" then "fig"
512
+ when "fn", "footnote" then "fn"
513
+ else "other"
514
+ end
515
+ end
516
+
517
+ def build_link(node)
518
+ href = nil
519
+ [:target, :href].each do |attr|
520
+ next unless node.class.method_defined?(attr)
521
+ val = node.public_send(attr)
522
+ href = val if val.is_a?(String) && !val.empty?
523
+ break if href
524
+ end
525
+ return nil unless href
526
+
527
+ visible = href.start_with?("mailto:") ? href.sub("mailto:", "") : link_text(node)
528
+ ModelBuilder.ext_link(xlink_href: href, content: [visible])
529
+ end
530
+
531
+ def link_text(node)
532
+ c = node.content if node.class.method_defined?(:content)
533
+ c = Array(c) unless c.is_a?(Array)
534
+ joined = c.map(&:to_s).join.strip
535
+ joined.empty? ? text_of(node) : joined
536
+ end
537
+
538
+ def eref_text(node)
539
+ citeas = node.citeas if node.class.method_defined?(:citeas)
540
+ return citeas.to_s if citeas && !citeas.to_s.empty?
541
+ text_of(node)
542
+ end
543
+
544
+ def concept_text(node)
545
+ renderterm = Array(node.renderterm).first.to_s if node.class.method_defined?(:renderterm)
546
+ xref_node = Array(node.xref).first if node.class.method_defined?(:xref)
547
+ return renderterm unless xref_node
548
+
549
+ rid = xref_node.target if xref_node.class.method_defined?(:target)
550
+ autonum = source.term_autonum_for(rid) if rid
551
+ return "#{renderterm} (#{autonum})" if autonum && !autonum.empty?
552
+ "#{renderterm} (#{rid})"
553
+ end
554
+
555
+ def presentation_span?(node)
556
+ return false unless node.is_a?(Metanorma::Document::Components::Inline::SpanElement)
557
+ cls = node.class_attr
558
+ cls.is_a?(String) && %w[fmt-autonum-delim fmt-caption-delim citesec citefig citetbl stdpublisher stddocNumber stdyear].any? { |c| cls.include?(c) }
559
+ end
560
+
561
+ def text_of(obj)
562
+ return obj.to_s if obj.is_a?(String)
563
+ return "" unless obj
564
+ RenderedTextExtractor.text_of(obj)
565
+ end
566
+
567
+ def clean_text(text)
568
+ text.to_s.gsub(/link:mailto:([^\s\[]+)/, '\1').gsub(/link:([^\s\[]+)/, '\1')
569
+ end
570
+ end
571
+ end
572
+ end
573
+ end
574
+ end
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+ module Metanorma
3
+ module Oiml
4
+ module Sts
5
+ module Transformer
6
+ class ReferenceTransformer < Base
7
+ def transform_section(ref_section)
8
+ bibitems = extract_bibitems(ref_section)
9
+ title_text = extract_title(ref_section)
10
+
11
+ paragraphs = intro_paragraphs(ref_section)
12
+ label = section_transformer.extract_autonum(ref_section)
13
+ normative = paragraphs.any? || !label.nil?
14
+ # Bibliography entries are numbered ([1], [2], …); normative
15
+ # reference entries are not (ISO/Metanorma convention).
16
+ refs = bibitems.map.with_index { |b, i| transform_bibitem(b, normative ? nil : i + 1) }.compact
17
+
18
+ unless normative
19
+ # Back-matter bibliography: a bare ref-list.
20
+ return ModelBuilder.ref_list(content_type: "bibliography", title: title_text, ref: refs)
21
+ end
22
+
23
+ # In-body normative references: a numbered <sec> whose intro
24
+ # paragraphs precede the (untitled) ref-list.
25
+ ref_list = ModelBuilder.ref_list(content_type: "normative-refs", title: nil, ref: refs)
26
+ attrs = {}
27
+ attrs[:id] = ref_section.id if ref_section.class.method_defined?(:id) && ref_section.id
28
+ attrs[:label] = ::Sts::NisoSts::Label.new(content: [label]) if label
29
+ attrs[:title] = ::Sts::NisoSts::Title.new(content: [title_text]) if title_text && !title_text.empty?
30
+ attrs[:paragraphs] = paragraphs if paragraphs.any?
31
+ attrs[:ref_list] = [ref_list] if ref_list
32
+ ::Sts::NisoSts::Section.new(attrs)
33
+ end
34
+
35
+ def transform_bibitem(bibitem, ordinal = nil)
36
+ identifier = extract_docidentifier(bibitem)
37
+ formattedref = extract_formattedref(bibitem)
38
+ label_text = ordinal ? "[#{ordinal}]" : nil
39
+
40
+ content_parts = []
41
+ content_parts << identifier if identifier
42
+ content_parts << formattedref if formattedref
43
+
44
+ ModelBuilder.ref(
45
+ label: label_text,
46
+ mixed_citation: content_parts.join(", "),
47
+ std: build_std(identifier, formattedref)
48
+ )
49
+ end
50
+
51
+ private
52
+
53
+ # Every bibliographic <std> carries a <title> (OIML X 999
54
+ # 5.4); the publication year rides inside the <std-ref>
55
+ # designation, which is type="dated" when the identifier
56
+ # embeds a year ("…:2012", "…-2022") and "undated" otherwise
57
+ # (NISO STS idiom — <std> has no <pub-date> in the base tag
58
+ # suite).
59
+ def build_std(identifier, formattedref)
60
+ return nil unless identifier || formattedref
61
+
62
+ type = identifier.to_s.match?(/(:\d{4}|-\d{4}\b)/) ? "dated" : "undated"
63
+ attrs = { type: type }
64
+ attrs[:std_ref] = ::Sts::NisoSts::StandardRef.new(type: type, value: identifier) if identifier
65
+ attrs[:title] = formattedref.to_s if formattedref
66
+ ::Sts::NisoSts::ReferenceStandard.new(attrs)
67
+ end
68
+
69
+ def extract_bibitems(ref_section)
70
+ refs = ref_section.class.method_defined?(:references) ? ref_section.references : nil
71
+ return Array(refs) if refs
72
+ []
73
+ end
74
+
75
+ # Boilerplate paragraphs of an in-body references section
76
+ # ("The following documents are referred to ...").
77
+ def intro_paragraphs(ref_section)
78
+ return [] unless ref_section.class.method_defined?(:p)
79
+
80
+ Array(ref_section.p).map { |p| paragraph_transformer.transform(p) }
81
+ end
82
+
83
+ def extract_title(ref_section)
84
+ return nil unless ref_section.class.method_defined?(:title) && ref_section.title
85
+ RenderedTextExtractor.text_of(ref_section.title)
86
+ end
87
+
88
+ def extract_docidentifier(bibitem)
89
+ return nil unless bibitem.class.method_defined?(:docidentifier)
90
+ ids = Array(bibitem.docidentifier)
91
+ primary = ids.find { |i| !(i.class.method_defined?(:type) && i.type) } || ids.first
92
+ return nil unless primary
93
+ val = primary.class.method_defined?(:id) ? primary.id : primary.to_s
94
+ val.to_s.strip
95
+ end
96
+
97
+ def extract_formattedref(bibitem)
98
+ fr = bibitem.class.method_defined?(:formatted_ref) ? bibitem.formatted_ref : nil
99
+ return nil unless fr
100
+ text = RenderedTextExtractor.text_of(fr)
101
+ text.empty? ? nil : text
102
+ end
103
+ end
104
+ end
105
+ end
106
+ end
107
+ end