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,168 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Metanorma
4
+ module Oiml
5
+ module Sts
6
+ module Transformer
7
+ # Extracts plain text from typed model objects for non-math content
8
+ # (titles, labels, list items, xref visible text). Stems are NEVER
9
+ # text-extracted — the paragraph transformer routes them through
10
+ # MathML passthrough via InlineFormula + Mml::V3::Math.
11
+ class RenderedTextExtractor
12
+ STEM_TYPES = [
13
+ Metanorma::Document::Components::Inline::StemInlineElement,
14
+ Metanorma::Document::Components::Inline::FmtStemElement,
15
+ Metanorma::Document::Components::Inline::AsciimathElement,
16
+ Metanorma::Document::Components::Inline::MathElement,
17
+ ].freeze
18
+
19
+ WALKABLE_TYPES = [
20
+ Metanorma::Document::Components::Paragraphs::ParagraphBlock,
21
+ Metanorma::Document::Components::Inline::SemxElement,
22
+ Metanorma::Document::Components::Inline::StrongRawElement,
23
+ Metanorma::Document::Components::Inline::EmRawElement,
24
+ Metanorma::Document::Components::Inline::SpanElement,
25
+ Metanorma::Document::Components::Inline::FmtPreferredElement,
26
+ Metanorma::Document::Components::Inline::FmtXrefLabelElement,
27
+ Metanorma::Document::Components::Inline::FmtConceptElement,
28
+ Metanorma::Document::Components::Inline::ConceptElement,
29
+ Metanorma::StandardDocument::Sections::ContentSection,
30
+ ].freeze
31
+
32
+ def self.text_of(node)
33
+ return "" if node.nil?
34
+ return node if node.is_a?(String)
35
+ return "" if stem_type?(node)
36
+ return "" unless walkable?(node)
37
+
38
+ walker = new
39
+ walker.collect(node)
40
+ walker.to_text
41
+ end
42
+
43
+ def self.stem_type?(node)
44
+ STEM_TYPES.any? { |t| node.is_a?(t) }
45
+ end
46
+ public_class_method :stem_type?
47
+
48
+ def self.walkable?(node)
49
+ WALKABLE_TYPES.any? { |t| node.is_a?(t) } ||
50
+ node.class.method_defined?(:each_mixed_content)
51
+ end
52
+ public_class_method :walkable?
53
+
54
+ def initialize
55
+ @parts = []
56
+ @seen = {}
57
+ end
58
+
59
+ def to_text
60
+ @parts.join.strip
61
+ end
62
+
63
+ def append_text(text)
64
+ @parts << text
65
+ end
66
+
67
+ def collect_node_public(child)
68
+ collect_node(child)
69
+ end
70
+
71
+ def collect(node)
72
+ return if node.nil? || @seen.key?(node.object_id)
73
+ @seen[node.object_id] = true
74
+
75
+ if node.is_a?(String)
76
+ @parts << node
77
+ return
78
+ end
79
+ return unless self.class.walkable?(node)
80
+
81
+ collect_content_mapped_text?(node) ||
82
+ node.each_mixed_content { |child| collect_node(child) }
83
+ end
84
+
85
+ private
86
+
87
+ # Elements like NameWithIdElement map their whole content to a
88
+ # plain `text` attribute (no mixed_content), so walking yields
89
+ # nothing. SpanElement declares BOTH mixed_content and a text
90
+ # mapping — only take the attribute shortcut when there is no
91
+ # mixed content to walk.
92
+ def collect_content_mapped_text?(node)
93
+ return false unless node.class.method_defined?(:text)
94
+ return false unless node.class.method_defined?(:mixed_content?) && !node.mixed_content?
95
+
96
+ text = node.text
97
+ return false unless text.is_a?(String) && !text.empty?
98
+
99
+ @parts << text
100
+ true
101
+ end
102
+
103
+ def collect_node(child)
104
+ if child.is_a?(String)
105
+ @parts << child
106
+ return
107
+ end
108
+
109
+ case child
110
+ when *STEM_TYPES
111
+ # Math content is handled by the paragraph transformer via
112
+ # MathML passthrough. Text extraction is intentionally a no-op.
113
+ nil
114
+ when Metanorma::Document::Components::Inline::XrefElement
115
+ @parts << xref_text(child)
116
+ when Metanorma::Document::Components::Inline::SemxElement
117
+ collect(child)
118
+ collect_semx_inlines(child)
119
+ when Metanorma::Document::Components::Inline::StrongRawElement,
120
+ Metanorma::Document::Components::Inline::EmRawElement,
121
+ Metanorma::Document::Components::Inline::SpanElement
122
+ collect(child)
123
+ else
124
+ collect(child)
125
+ end
126
+ end
127
+
128
+ SEMX_CHILD_ATTRS = %i[
129
+ stem_child strong em sup sub tt underline strike
130
+ preferred_child name_child title_child
131
+ verbal_definition_child definition_child
132
+ concept_child fn_child link_child
133
+ ].freeze
134
+
135
+ def collect_semx_inlines(semx)
136
+ SEMX_CHILD_ATTRS.each do |attr|
137
+ next unless semx.class.method_defined?(attr)
138
+ val = semx.public_send(attr)
139
+ next if val.nil?
140
+ Array(val).each { |v| collect_node(v) }
141
+ end
142
+ end
143
+
144
+ def xref_text(xref)
145
+ txt = array_or_string_to_s(xref.text)
146
+ return txt unless txt.nil? || txt.empty?
147
+
148
+ rid = xref.target if xref.class.method_defined?(:target)
149
+ return "" unless rid.is_a?(String) && !rid.empty?
150
+
151
+ case rid
152
+ when /\Asec-(.+)\z/, /\Afig-(.+)\z/, /\Atable-(.+)\z/, /\Afn-(.+)\z/
153
+ Regexp.last_match(1)
154
+ else
155
+ rid
156
+ end
157
+ end
158
+
159
+ def array_or_string_to_s(val)
160
+ return val if val.is_a?(String)
161
+ return "" unless val.is_a?(Array)
162
+ val.map(&:to_s).reject(&:empty?).join.strip
163
+ end
164
+ end
165
+ end
166
+ end
167
+ end
168
+ end
@@ -0,0 +1,233 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Metanorma
4
+ module Oiml
5
+ module Sts
6
+ module Transformer
7
+ class SectionTransformer < Base
8
+ def transform(source_clause)
9
+ title_text = extract_title(source_clause)
10
+ id_val = source_clause.id if source_clause.is_a?(Lutaml::Model::Serializable) && source_clause.class.method_defined?(:id) && source_clause.id
11
+
12
+ content = dispatcher.dispatch_section_blocks(source_clause)
13
+
14
+ # Dispatch nested clauses for any section type carrying a
15
+ # clause collection (IsoClauseSection AND IsoAnnexSection —
16
+ # annex subsections like the Examples annex's entries).
17
+ nested_clauses = []
18
+ if source_clause.is_a?(Lutaml::Model::Serializable) && source_clause.class.method_defined?(:clause)
19
+ nested_clauses = Array(source_clause.clause).map { |sub| transform(sub) }.compact
20
+ end
21
+
22
+ # Dispatch terms sections (a top-level <terms> carries its
23
+ # entries directly in .term; a clause's nested terms section
24
+ # appears in .terms). Each nested terms-section (e.g.
25
+ # "3.1 General definitions") is preserved as its own Sec so
26
+ # its heading renders for parity; only the top-level section
27
+ # whose .term collection we're flattening skips the wrapper.
28
+ if source_clause.class.method_defined?(:term) && Array(source_clause.term).any?
29
+ Array(source_clause.term).each do |term|
30
+ term_sec = term_transformer.transform(term)
31
+ nested_clauses << term_sec if term_sec
32
+ end
33
+ end
34
+ if source_clause.class.method_defined?(:terms)
35
+ Array(source_clause.terms).each do |terms_section|
36
+ terms_sec = transform_terms_section(terms_section)
37
+ nested_clauses << terms_sec if terms_sec
38
+ end
39
+ end
40
+
41
+ attrs = {}
42
+ attrs[:id] = id_val if id_val
43
+ attrs[:title] = ::Sts::NisoSts::Title.new(content: [title_text]) if title_text && !title_text.empty?
44
+ autonum = extract_autonum(source_clause)
45
+ attrs[:label] = ::Sts::NisoSts::Label.new(content: [autonum]) if autonum
46
+
47
+ paragraphs, lists, figs, tables, notes, examples, formulas, def_lists, preformats, sub_secs = [], [], [], [], [], [], [], [], [], []
48
+ content.each do |item|
49
+ case item
50
+ when ::Sts::NisoSts::Paragraph then paragraphs << item
51
+ when ::Sts::NisoSts::List then lists << item
52
+ when ::Sts::NisoSts::Figure then figs << item
53
+ when ::Sts::TbxIsoTml::TableWrap then tables << item
54
+ when ::Sts::NisoSts::NonNormativeNote then notes << item
55
+ when ::Sts::NisoSts::NonNormativeExample then examples << item
56
+ when ::Sts::NisoSts::DisplayFormula then formulas << item
57
+ when ::Sts::NisoSts::DefList then def_lists << item
58
+ when ::Sts::NisoSts::Preformat then preformats << item
59
+ when ::Sts::NisoSts::Section then sub_secs << item
60
+ end
61
+ end
62
+ nested_clauses.concat(sub_secs)
63
+
64
+ attrs[:paragraphs] = paragraphs if paragraphs.any?
65
+ attrs[:list] = lists if lists.any?
66
+ attrs[:fig] = figs if figs.any?
67
+ attrs[:table_wrap] = tables if tables.any?
68
+ attrs[:non_normative_note] = notes if notes.any?
69
+ attrs[:non_normative_example] = examples if examples.any?
70
+ attrs[:disp_formula] = formulas if formulas.any?
71
+ attrs[:def_list] = def_lists if def_lists.any?
72
+ attrs[:preformat] = preformats if preformats.any?
73
+ attrs[:sec] = nested_clauses if nested_clauses.any?
74
+
75
+ ::Sts::NisoSts::Section.new(attrs)
76
+ end
77
+
78
+ # Build a Sec for a nested terms-section (e.g. "3.1 General
79
+ # definitions") that wraps its term entries. The wrapper's
80
+ # title and autonum are emitted so the renderer produces the
81
+ # "3.1 General definitions" heading that appears in MN HTML.
82
+ def transform_terms_section(terms_section)
83
+ title_text = extract_title(terms_section)
84
+ autonum = extract_autonum(terms_section)
85
+
86
+ nested = Array(terms_section.term).map { |term| term_transformer.transform(term) }.compact
87
+
88
+ attrs = {}
89
+ attrs[:id] = terms_section.id if terms_section.class.method_defined?(:id) && terms_section.id
90
+ attrs[:title] = ::Sts::NisoSts::Title.new(content: [title_text]) if title_text && !title_text.empty?
91
+ attrs[:label] = ::Sts::NisoSts::Label.new(content: [autonum]) if autonum
92
+ attrs[:sec] = nested if nested.any?
93
+ return nil unless title_text || autonum || nested.any?
94
+
95
+ ::Sts::NisoSts::Section.new(attrs)
96
+ end
97
+
98
+ # Section number from the presentation XML: the autonum
99
+ # attribute (annexes, term notes) or the fmt-title/fmt-name
100
+ # caption-label's autonum semx ("1", "8.1", "3.1"). The delim
101
+ # between number and title is a <tab/>, which text extraction
102
+ # drops, so the caption-label span must be isolated —
103
+ # splitting the whole fmt-title text would glue number and
104
+ # title together ("1Scope").
105
+ def extract_autonum(source_clause)
106
+ if source_clause.class.method_defined?(:autonum) && source_clause.autonum
107
+ return source_clause.autonum.to_s
108
+ end
109
+
110
+ title_wrapper = %i[fmt_title fmt_name].filter_map do |method|
111
+ source_clause.public_send(method) if source_clause.class.method_defined?(method)
112
+ end.first
113
+ title_wrapper = Array(title_wrapper).first
114
+ return nil unless title_wrapper
115
+
116
+ caption_label_text(title_wrapper)
117
+ end
118
+
119
+ private
120
+
121
+ def extract_title(source_clause)
122
+ return nil unless source_clause.class.method_defined?(:title) && source_clause.title
123
+ title = source_clause.title
124
+ return extract_with_stems(title) if title_is_mixed_with_stems?(title)
125
+ RenderedTextExtractor.text_of(title)
126
+ end
127
+
128
+ # Title text where stem elements contribute their visible
129
+ # AsciiMath tokens (e.g. "D_(\"min\")" → " D min ") so the
130
+ # parity check sees content where it would otherwise see "()".
131
+ # Source text from asciimath matches what Nokogiri's text
132
+ # extraction produces from MN HTML after the document-level
133
+ # normaliser lowercases both; case sensitivity is therefore
134
+ # not an issue here, and we never parse MathML XML.
135
+ def extract_with_stems(title)
136
+ parts = collect_title_parts(title, [])
137
+ parts.join.strip
138
+ end
139
+
140
+ def title_is_mixed_with_stems?(title)
141
+ title.class.method_defined?(:each_mixed_content)
142
+ end
143
+
144
+ def collect_title_parts(node, parts)
145
+ return parts unless node.is_a?(Lutaml::Model::Serializable)
146
+ return parts unless node.class.method_defined?(:each_mixed_content)
147
+
148
+ node.each_mixed_content do |child|
149
+ if child.is_a?(String)
150
+ parts << child
151
+ elsif stem_node?(child)
152
+ parts << stem_visible_text(child)
153
+ elsif xref_node?(child)
154
+ parts << xref_visible_text(child)
155
+ elsif child.is_a?(Lutaml::Model::Serializable)
156
+ collect_title_parts(child, parts)
157
+ end
158
+ end
159
+ parts
160
+ end
161
+
162
+ STEM_NODE_CLASSES = [
163
+ "Metanorma::Document::Components::Inline::StemInlineElement",
164
+ "Metanorma::Document::Components::Inline::FmtStemElement",
165
+ "Metanorma::Document::Components::TextElements::StemElement",
166
+ "Metanorma::Document::Components::Inline::AsciimathElement",
167
+ "Metanorma::Document::Components::Inline::MathElement",
168
+ ].freeze
169
+
170
+ def stem_node?(node)
171
+ STEM_NODE_CLASSES.include?(node.class.name)
172
+ end
173
+
174
+ def xref_node?(node)
175
+ node.is_a?(Metanorma::Document::Components::Inline::XrefElement)
176
+ end
177
+
178
+ def stem_visible_text(stem)
179
+ # The typed stem has an `asciimath` attribute mapping to
180
+ # Asciiml (a typed model with a `:value` string). For
181
+ # Semx wrappers the asciimath sits on the inner stem; for
182
+ # inline stems on the stem itself.
183
+ asciimath_value(asciimath_of(stem)).then do |raw|
184
+ text = raw.to_s.tr("_", " ").gsub(/[()"\\]/, "").strip
185
+ text.empty? ? "" : " #{text} "
186
+ end
187
+ end
188
+
189
+ def asciimath_of(stem)
190
+ stem.is_a?(Metanorma::Document::Components::TextElements::StemElement) ||
191
+ stem.is_a?(Metanorma::Document::Components::Inline::StemInlineElement) ||
192
+ stem.is_a?(Metanorma::Document::Components::Inline::FmtStemElement) ? stem.asciimath : nil
193
+ end
194
+
195
+ def asciimath_value(asciiml)
196
+ return nil unless asciiml
197
+ asciiml.is_a?(Metanorma::Document::Components::TextElements::Asciiml) ? asciiml.value : asciiml.to_s
198
+ end
199
+
200
+ def xref_visible_text(xref)
201
+ txt = Array(xref.text).join
202
+ txt = xref.target.to_s if txt.empty? && xref.target
203
+ case txt
204
+ when /\Asec-(.+)\z/, /\Afig-(.+)\z/, /\Atable-(.+)\z/, /\Afn-(.+)\z/
205
+ "#{Regexp.last_match(1)} "
206
+ else
207
+ txt
208
+ end
209
+ end
210
+
211
+ # Text of the <span class="fmt-caption-label"> inside a
212
+ # fmt-title — the bare section number.
213
+ def caption_label_text(fmt_title)
214
+ return nil unless fmt_title.class.method_defined?(:each_mixed_content)
215
+
216
+ label_span = nil
217
+ fmt_title.each_mixed_content do |child|
218
+ if child.is_a?(Metanorma::Document::Components::Inline::SpanElement) &&
219
+ child.class_attr == "fmt-caption-label"
220
+ label_span = child
221
+ break
222
+ end
223
+ end
224
+ return nil unless label_span
225
+
226
+ text = RenderedTextExtractor.text_of(label_span).strip
227
+ text.empty? ? nil : text
228
+ end
229
+ end
230
+ end
231
+ end
232
+ end
233
+ end
@@ -0,0 +1,246 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "metanorma/document"
4
+ require "metanorma/oiml/document"
5
+
6
+ module Metanorma
7
+ module Oiml
8
+ module Sts
9
+ module Transformer
10
+ class SourceDocument
11
+ attr_reader :typed_root
12
+
13
+ def self.parse(input)
14
+ xml_string = input.class.method_defined?(:read) ? input.read : input.to_s
15
+ typed = Metanorma::Oiml::Document::Root.from_xml(xml_string)
16
+ new(typed)
17
+ end
18
+
19
+ # Reader entry point for the metanorma-core document_transformers
20
+ # contract (+reader.from_xml(String) -> model+); an alias of {.parse}.
21
+ def self.from_xml(input)
22
+ parse(input)
23
+ end
24
+
25
+ def initialize(typed_root)
26
+ @typed_root = typed_root
27
+ end
28
+
29
+ def bibdata
30
+ typed_root.bibdata
31
+ end
32
+
33
+ def language
34
+ return "en" unless bibdata
35
+ langs = bibdata.language rescue nil
36
+ return "en" unless langs
37
+ lang = langs.is_a?(Array) ? langs.first : langs
38
+ val = lang.is_a?(String) ? lang : lang.value.to_s
39
+ val.strip.match?(/\A[a-z]{2}(-[A-Z]{2})?\z/) ? val.strip : "en"
40
+ end
41
+
42
+ def docidentifier
43
+ return nil unless bibdata
44
+ ids = bibdata.doc_identifier rescue nil
45
+ return nil unless ids
46
+ Array(ids).each do |id_obj|
47
+ next if id_obj.type
48
+ return text_of(id_obj)
49
+ end
50
+ text_of(Array(ids).first)
51
+ end
52
+
53
+ def text_of(obj)
54
+ return obj.to_s if obj.is_a?(String)
55
+ return "" unless obj
56
+ val = (obj.value rescue nil) || (obj.content rescue nil)
57
+ return "" unless val
58
+ Array(val).map { |v| v.is_a?(String) ? v : text_of(v) }.join.strip
59
+ end
60
+
61
+ def title(type: nil)
62
+ return nil unless bibdata
63
+ titles = bibdata.titles rescue nil
64
+ return nil unless titles
65
+ titles = Array(titles)
66
+ t = titles.find { |x| type.nil? || (x.type == type rescue false) } || titles.first
67
+ text_of(t)
68
+ end
69
+
70
+ def formatted_title
71
+ return title unless bibdata
72
+ # The composed model #title can raise on documents whose front
73
+ # lacks the title structure it expects; fall back to the
74
+ # generic wrapper title rather than failing the transform.
75
+ iso_title = (bibdata.title rescue nil)
76
+ return title unless iso_title
77
+ main = abstract_title_value(iso_title.title_main)
78
+ part_prefix = abstract_title_value(iso_title.title_part_prefix)
79
+ part = abstract_title_value(iso_title.title_part)
80
+ segments = [main]
81
+ if part_prefix && !part_prefix.empty?
82
+ segments << (part && !part.empty? ? "#{part_prefix}:#{part}" : part_prefix)
83
+ elsif part && !part.empty?
84
+ segments << part
85
+ end
86
+ segments.reject! { |s| s.nil? || s.empty? }
87
+ return title if segments.empty?
88
+ segments.join(" — ")
89
+ end
90
+
91
+ def abstract_title_value(abstract)
92
+ return nil if abstract.nil?
93
+ val = abstract.value rescue nil
94
+ val.nil? ? nil : val.to_s.strip
95
+ end
96
+
97
+ def copyright_year
98
+ return nil unless bibdata
99
+ cps = bibdata.copyright rescue nil
100
+ return nil unless cps
101
+ cp = Array(cps).first
102
+ return nil unless cp
103
+ from = cp.from rescue nil
104
+ return text_of(from) unless from.nil?
105
+ nil
106
+ end
107
+
108
+ def copyright_holder
109
+ return nil unless bibdata
110
+ cps = bibdata.copyright rescue nil
111
+ return nil unless cps
112
+ cp = Array(cps).first
113
+ return nil unless cp
114
+ owner = cp.owner rescue nil
115
+ return nil unless owner
116
+ org = Array(owner.organization).first rescue nil
117
+ return nil unless org
118
+ text_of(org.name) rescue nil
119
+ end
120
+
121
+ def publisher
122
+ copyright_holder
123
+ end
124
+
125
+ def pub_date
126
+ copyright_year
127
+ end
128
+
129
+ def foreword
130
+ typed_root.preface&.foreword
131
+ end
132
+
133
+ def introduction
134
+ typed_root.preface&.introduction
135
+ end
136
+
137
+ # Top-level sections of every kind — <clause>, <terms>,
138
+ # <definitions>, <references> (normative references) — in
139
+ # document order. The XML's physical order differs (references
140
+ # trails the clauses); displayorder carries the intended one.
141
+ SECTION_COLLECTIONS = %i[clause terms definitions references].freeze
142
+
143
+ def sections
144
+ secs = typed_root.sections
145
+ return [] unless secs
146
+
147
+ SECTION_COLLECTIONS.flat_map { |attr| Array(secs.public_send(attr)) }
148
+ .sort_by do |s|
149
+ s.class.method_defined?(:displayorder) && s.displayorder ? s.displayorder : Float::INFINITY
150
+ end
151
+ end
152
+
153
+ def annexes
154
+ Array(typed_root.annex)
155
+ end
156
+
157
+ def bibliography
158
+ bib = typed_root.bibliography
159
+ return [] unless bib
160
+ Array(bib.references)
161
+ end
162
+
163
+ def front?
164
+ !foreword.nil? || !introduction.nil?
165
+ end
166
+
167
+ def has_metadata?
168
+ !docidentifier.nil?
169
+ end
170
+
171
+ def has_back?
172
+ annexes.any? || bibliography.any?
173
+ end
174
+
175
+ def term_autonum_for(term_id)
176
+ @term_autonum_cache ||= build_term_autonum_cache
177
+ @term_autonum_cache[term_id]
178
+ end
179
+
180
+ private
181
+
182
+ def build_term_autonum_cache
183
+ cache = {}
184
+ walk_for_terms(typed_root) do |term|
185
+ next unless term.is_a?(Metanorma::IsoDocument::Terms::IsoTerm)
186
+ autonum = extract_term_autonum(term)
187
+ next unless autonum && !autonum.empty?
188
+ cache[term.id] = autonum if term.id
189
+ anchor = term.anchor if term.class.method_defined?(:anchor)
190
+ cache[anchor] = autonum if anchor && !cache.key?(anchor)
191
+ end
192
+ cache
193
+ end
194
+
195
+ def walk_for_terms(node, seen = {}, &blk)
196
+ return unless node.is_a?(Object) && !node.is_a?(String)
197
+ return if seen[node.object_id]
198
+ seen[node.object_id] = true
199
+ blk.call(node)
200
+ attrs = node.class.instance_variable_get(:@attributes) rescue nil
201
+ return unless attrs
202
+ attrs.each do |name, _|
203
+ next unless node.class.method_defined?(name)
204
+ val = node.public_send(name) rescue next
205
+ next if val.nil?
206
+ Array(val).each { |v| walk_for_terms(v, seen, &blk) if v.is_a?(Object) && !v.is_a?(String) && !v.is_a?(Array) }
207
+ end
208
+ end
209
+
210
+ def extract_term_autonum(term)
211
+ label_arr = Array(term.fmt_xref_label)
212
+ label = label_arr.first
213
+ return nil unless label
214
+ return nil unless label.class.method_defined?(:each_mixed_content)
215
+ parts = []
216
+ label.each_mixed_content do |n|
217
+ if n.is_a?(String) && !n.strip.empty?
218
+ parts << n.strip
219
+ else
220
+ cn = n.class.name.split("::").last
221
+ txt = semx_text(n) if cn == "SemxElement"
222
+ txt = span_text(n) if cn == "SpanElement"
223
+ parts << txt if txt && !txt.empty?
224
+ end
225
+ end
226
+ parts.join.strip
227
+ end
228
+
229
+ def semx_text(semx)
230
+ return "" unless semx.class.method_defined?(:each_mixed_content)
231
+ parts = []
232
+ semx.each_mixed_content { |n| parts << n if n.is_a?(String) }
233
+ parts.join
234
+ end
235
+
236
+ def span_text(span)
237
+ return "" unless span.class.method_defined?(:each_mixed_content)
238
+ parts = []
239
+ span.each_mixed_content { |n| parts << n if n.is_a?(String) }
240
+ parts.join
241
+ end
242
+ end
243
+ end
244
+ end
245
+ end
246
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Metanorma
4
+ module Oiml
5
+ module Sts
6
+ module Transformer
7
+ # Adapter conforming to the metanorma-core
8
+ # +Processor#document_transformers+ contract: +.new(model, options)+,
9
+ # where +model+ is the {SourceDocument} produced by
10
+ # {SourceDocument.from_xml}; +#transform+ returns a target responding to
11
+ # +#to_xml+.
12
+ #
13
+ # The adapter is its own target: its +#to_xml+ runs the full
14
+ # {DocumentTransformer#transform_to_xml} pipeline (model build plus the
15
+ # namespace / processing-meta / lang post-processing on the serialised
16
+ # string), so the pipeline output matches the standalone
17
+ # {Metanorma::Oiml::Sts::Transformer.convert}. Returning the raw
18
+ # +#transform+ model instead would drop that post-processing.
19
+ class Standard
20
+ def initialize(model, options = {})
21
+ @model = model
22
+ @options = options
23
+ end
24
+
25
+ def transform
26
+ @document_transformer = DocumentTransformer.new(Context.new(@model))
27
+ self
28
+ end
29
+
30
+ def to_xml(**_options)
31
+ @document_transformer.transform_to_xml(@model)
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end