obp-access 0.1.3 → 0.1.5

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: bb730bc50a9d42e2fab9002aa77d4bfd9c495324d8d7e3a2dc5ea88eacbcedc9
4
- data.tar.gz: 8be58734fa23ab768dfa1bbe2d4907b738ef602f8bfeadc9b0d5293ba22ccce9
3
+ metadata.gz: 728e6533423209e6fc2a17945ca7afc15f7b36c8bfe39451e1ad8f66d6554bb5
4
+ data.tar.gz: 92a04641a9ebc2774e60518e560871287c9855998848f2d3d2ffddcee95f0221
5
5
  SHA512:
6
- metadata.gz: 6b159d5f9b615f94cadaa30c0bbb75cedc4a1761abf712cd1160fb227fbdc8dc7c2413fded7002b2132d5aedf2284081b17b34bf3055e7beab3688bf6e6767bd
7
- data.tar.gz: 3c1fc77d1d229be553bc0b694eb16688dac4e2d0099fe0162bba4a51aa1de4c4a8978c8c6e4221bbae5d96f50771384be9efbec2e89f74cc6a0ad40f0b5403e8
6
+ metadata.gz: e4f9adc699152992207abfc9986d5e06e8db3a110f37eb2ed1e6cfd4f3dcd57d6f4ee63614ed44b26a40bcf70d355a3ebd2cf51041ba961040a5063a9ee88802
7
+ data.tar.gz: 883124569b65db99872fa2f12de85070ee72dea94fa113b6af87d94a8ecc2bb2c61f7eaf0a261eacdb69d817e032c9820810fb5973301043476f0f15768e5989
data/.rubocop.yml CHANGED
@@ -1,18 +1,31 @@
1
1
  # Auto-generated by Cimas: Do not edit it manually!
2
2
  # See https://github.com/metanorma/cimas
3
3
  inherit_from:
4
+ - https://raw.githubusercontent.com/riboseinc/oss-guides/main/ci/rubocop.yml
5
+ # .rubocop_todo.yml MUST be the last entry. inherit_from is last-wins:
6
+ # if listed before oss-guides, the shared config's stricter Metrics/
7
+ # (MethodLength, BlockLength, etc.) rules override the todo's per-file
8
+ # grandfathering, and the todo becomes inert on those cops. Empirically
9
+ # verified on suma 2026-07-06: with todo listed first, 34 Metrics/* offenses
10
+ # remained; moved last, cleared. Every gem in the metanorma-org fleet
11
+ # already ships a `.rubocop_todo.yml` on its live tree (audited 2026-07-06,
12
+ # 54/54 have it), so this reference is safe to emit unconditionally.
4
13
  - .rubocop_todo.yml
5
- - https://raw.githubusercontent.com/riboseinc/oss-guides/master/ci/rubocop.yml
14
+
15
+ # Rubocop plugins enabled centrally so every metanorma-org gem picks them up
16
+ # on cimas sync — best practice belongs at the shared-template layer, not
17
+ # per-repo. Per ronaldtse feedback on metanorma/ci#332.
18
+ plugins:
19
+ - rubocop-rspec
20
+ - rubocop-performance
21
+ - rubocop-rake
6
22
 
7
23
  # local repo-specific modifications
8
24
  # ...
9
25
 
10
26
  AllCops:
11
- NewCops: enable
12
- SuggestExtensions: false
13
-
14
- Metrics/MethodLength:
15
- Max: 30
16
-
17
- Layout/LineLength:
18
- Max: 120
27
+ # 3.3 matches the org-wide minimum being pushed via #274 (Raise minimum
28
+ # Ruby version to 3.3 due to EOL of 3.2 on 2026-03-31). Was 3.4 before
29
+ # this commit — 3.4 was above the org's stated minimum and would have
30
+ # applied Rubocop rules that fail-close on gems still targeting 3.3.
31
+ TargetRubyVersion: 3.3
data/.rubocop_todo.yml CHANGED
@@ -1,6 +1,6 @@
1
1
  # This configuration was generated by
2
2
  # `rubocop --auto-gen-config`
3
- # on 2026-06-13 11:32:12 UTC using RuboCop version 1.86.1.
3
+ # on 2026-07-18 01:14:30 UTC using RuboCop version 1.88.2.
4
4
  # The point is for the user to remove these configuration records
5
5
  # one by one as the offenses are removed from the code base.
6
6
  # Note that changes in the inspected code, or installation of new
@@ -11,9 +11,116 @@ Gemspec/RequiredRubyVersion:
11
11
  Exclude:
12
12
  - 'obp-access.gemspec'
13
13
 
14
+ # Offense count: 139
15
+ # This cop supports safe autocorrection (--autocorrect).
16
+ # Configuration parameters: Max, AllowHeredoc, AllowURI, AllowQualifiedName, URISchemes, AllowRBSInlineAnnotation, AllowCopDirectives, AllowedPatterns, SplitStrings.
17
+ # URISchemes: http, https
18
+ Layout/LineLength:
19
+ Exclude:
20
+ - 'fetch_en_html.rb'
21
+ - 'fetch_html.rb'
22
+ - 'lib/obp/access/catalog.rb'
23
+ - 'lib/obp/access/cli.rb'
24
+ - 'lib/obp/access/domain_extractor.rb'
25
+ - 'lib/obp/access/elements/base.rb'
26
+ - 'lib/obp/access/elements/bibliography.rb'
27
+ - 'lib/obp/access/elements/figure_group.rb'
28
+ - 'lib/obp/access/elements/index.rb'
29
+ - 'lib/obp/access/elements/root.rb'
30
+ - 'lib/obp/access/elements/terminology/definition.rb'
31
+ - 'lib/obp/access/elements/terminology/example.rb'
32
+ - 'lib/obp/access/elements/terminology/tig.rb'
33
+ - 'lib/obp/access/elements/terminology/tig_deprecated.rb'
34
+ - 'lib/obp/access/grammar_parser.rb'
35
+ - 'lib/obp/access/inline_renderer.rb'
36
+ - 'lib/obp/access/parser.rb'
37
+ - 'lib/obp/access/retriever.rb'
38
+ - 'obp-access.gemspec'
39
+ - 'spec/obp/access/access_spec.rb'
40
+ - 'spec/obp/access/cli_spec.rb'
41
+ - 'spec/obp/access/deliverable_spec.rb'
42
+ - 'spec/obp/access/elements/bib_ref_spec.rb'
43
+ - 'spec/obp/access/elements/disp_formula_spec.rb'
44
+ - 'spec/obp/access/elements/figure_group_spec.rb'
45
+ - 'spec/obp/access/elements/figure_spec.rb'
46
+ - 'spec/obp/access/elements/footnotes_spec.rb'
47
+ - 'spec/obp/access/elements/index_spec.rb'
48
+ - 'spec/obp/access/elements/non_normative_example_spec.rb'
49
+ - 'spec/obp/access/elements/protected_content_note_spec.rb'
50
+ - 'spec/obp/access/elements/section_title_spec.rb'
51
+ - 'spec/obp/access/elements/table_wrap_spec.rb'
52
+ - 'spec/obp/access/elements/terminology/definition_spec.rb'
53
+ - 'spec/obp/access/elements/terminology/tig_spec.rb'
54
+ - 'spec/obp/access/grammar_parser_spec.rb'
55
+ - 'spec/obp/access/inline_renderer_spec.rb'
56
+ - 'spec/obp/access/retriever_spec.rb'
57
+
58
+ # Offense count: 1
59
+ # This cop supports safe autocorrection (--autocorrect).
60
+ Lint/SafeNavigationWithEmpty:
61
+ Exclude:
62
+ - 'lib/obp/access/elements/disp_formula.rb'
63
+
14
64
  # Offense count: 3
15
65
  # Configuration parameters: AllowedMethods, AllowedPatterns, CountRepeatedAttributes, Max.
16
66
  Metrics/AbcSize:
17
67
  Exclude:
18
68
  - 'lib/obp/access/elements/bibliography/bib_ref.rb'
19
69
  - 'lib/obp/access/elements/root.rb'
70
+
71
+ # Offense count: 11
72
+ # Configuration parameters: CountComments, CountAsOne, AllowedMethods, AllowedPatterns.
73
+ Metrics/MethodLength:
74
+ Max: 16
75
+
76
+ # Offense count: 1
77
+ # Configuration parameters: IgnoredMetadata.
78
+ RSpec/DescribeClass:
79
+ Exclude:
80
+ - '**/spec/features/**/*'
81
+ - '**/spec/requests/**/*'
82
+ - '**/spec/routing/**/*'
83
+ - '**/spec/system/**/*'
84
+ - '**/spec/views/**/*'
85
+ - 'spec/obp/access/elements/terminology/tig_spec.rb'
86
+
87
+ # Offense count: 44
88
+ # Configuration parameters: CountAsOne.
89
+ RSpec/ExampleLength:
90
+ Max: 26
91
+
92
+ # Offense count: 1
93
+ # This cop supports safe autocorrection (--autocorrect).
94
+ RSpec/IteratedExpectation:
95
+ Exclude:
96
+ - 'spec/obp/access/catalog_spec.rb'
97
+
98
+ # Offense count: 1
99
+ RSpec/MultipleDescribes:
100
+ Exclude:
101
+ - 'spec/obp/access/elements/terminology/tig_spec.rb'
102
+
103
+ # Offense count: 65
104
+ RSpec/MultipleExpectations:
105
+ Max: 11
106
+
107
+ # Offense count: 14
108
+ # Configuration parameters: CustomTransform, IgnoreMethods, IgnoreMetadata, InflectorPath, EnforcedInflector.
109
+ # SupportedInflectors: default, active_support
110
+ RSpec/SpecFilePathFormat:
111
+ Exclude:
112
+ - '**/spec/routing/**/*'
113
+ - 'spec/obp/access/access_spec.rb'
114
+ - 'spec/obp/access/elements/bib_ref_spec.rb'
115
+ - 'spec/obp/access/elements/disp_formula_spec.rb'
116
+ - 'spec/obp/access/elements/figure_group_spec.rb'
117
+ - 'spec/obp/access/elements/figure_spec.rb'
118
+ - 'spec/obp/access/elements/footnotes_spec.rb'
119
+ - 'spec/obp/access/elements/index_spec.rb'
120
+ - 'spec/obp/access/elements/non_normative_example_spec.rb'
121
+ - 'spec/obp/access/elements/protected_content_note_spec.rb'
122
+ - 'spec/obp/access/elements/root_spec.rb'
123
+ - 'spec/obp/access/elements/section_title_spec.rb'
124
+ - 'spec/obp/access/elements/section_type_spec.rb'
125
+ - 'spec/obp/access/elements/table_wrap_spec.rb'
126
+ - 'spec/obp/access/elements/terminology/definition_spec.rb'
data/README.adoc CHANGED
@@ -86,6 +86,117 @@ The output is NISO STS XML with TBX-Basic terminology markup:
86
86
  * Domains are extracted as `<tbx:subjectField>`
87
87
  * Deprecated terms use `<tbx:normativeAuthorization value="deprecatedTerm"/>`
88
88
 
89
+ === Tables and terms as YAML
90
+
91
+ Besides STS XML, the same preview HTML can be read as YAML table data.
92
+ ISO standards that carry terminology in tables rather than TBX term
93
+ sections (the ISO 80000 quantity tables, "list of equivalent terms"
94
+ tables) become machine-readable through two views:
95
+
96
+ [source,ruby]
97
+ ----
98
+ obp = Obp::Access.fetch("iso:std:iso:80000-12:ed-2:v2:en")
99
+ obp.to_tables # => YAML string: every table in the document
100
+ obp.to_terms # => YAML string: term entries from term-carrying tables
101
+ ----
102
+
103
+ Both reuse the already-fetched preview HTML, so no extra OBP request is
104
+ made. A document without tables yields an empty YAML array (`--- []`).
105
+
106
+ ==== Generic tables (`to_tables`)
107
+
108
+ `Obp::Access::TableMapper` maps every table-carrying node —
109
+ `div.sts-table-wrap` and `div.sts-array`, in document order — to plain
110
+ hashes, arrays and strings:
111
+
112
+ [source,yaml]
113
+ ----
114
+ - id: iso_std_iso_80000-12_ed-2_v2_en_tab_1 # wrapper id, if present
115
+ section: '3' # containing clause ("index"/"bibl"/... stay raw)
116
+ label: Table 1 # .sts-caption-label, if present
117
+ caption: Quantities and units used in condensed matter physics
118
+ header:
119
+ - - text: Item No.
120
+ - text: Quantity
121
+ colspan: 3 # colspan/rowspan only when greater than 1
122
+ - text: Unit
123
+ - text: Remarks
124
+ rows:
125
+ - - text: 12-1.1
126
+ - text: lattice vector
127
+ - text: R
128
+ - text: translation vector that maps the crystal lattice on itself
129
+ - text: m
130
+ - text: The non-SI unit ångström (Å) is widely used ...
131
+ ----
132
+
133
+ Cell text is plain stripped text: OBP `sts-unknown-element` placeholder
134
+ spans are removed, and `<br>` / `div.sts-p` boundaries become spaces so
135
+ multi-paragraph cells do not glue words together.
136
+
137
+ ==== Term entries (`to_terms`)
138
+
139
+ `Obp::Access::TableTermExtractor` consumes the generic table model (not
140
+ raw HTML) and extracts entries from term-carrying tables. Recognition
141
+ rules, first match wins per table:
142
+
143
+ . *Quantity/item tables* (ISO 80000 family): at least 4 columns, the
144
+ table sits in a numbered clause, and a majority of body rows start
145
+ with an item number (`12-1.1`, `4-1`). Column roles are resolved from
146
+ the joined header text — this handles the two-row colspan headers in
147
+ both English (Item No. / Quantity / Unit / Remarks) and French
148
+ (N° / Grandeur / Unité / Remarques) — falling back to the ISO 80000
149
+ column order item, designation, symbol, definition, unit, remarks.
150
+ . *Equivalent-terms tables*: a header row naming languages in at least
151
+ 2 columns. One entry per row, with `designations` keyed by downcased
152
+ language name; rows with a single designation (letter dividers) are
153
+ skipped.
154
+ . Anything else is not a term table and yields no entries.
155
+
156
+ [source,yaml]
157
+ ----
158
+ - id: 12-1.1
159
+ designation: lattice vector
160
+ definition: translation vector that maps the crystal lattice on itself
161
+ symbol: R
162
+ unit: m
163
+ remarks: The non-SI unit ångström (Å) is widely used ...
164
+ source:
165
+ table: iso_std_iso_80000-12_ed-2_v2_en_tab_1
166
+ section: '3'
167
+ - id: '2'
168
+ designations:
169
+ english: absorption, atmospheric
170
+ francais: absorption atmosphérique
171
+ deutsch: atmosphärische Absorption
172
+ source:
173
+ section: '1.1'
174
+ ----
175
+
176
+ Only non-empty fields are emitted (`id` and `designation` are always
177
+ present); duplicate item numbers within one table keep the first row.
178
+ For equivalent-terms tables the entry `id` is the row's first cell when
179
+ it looks like an item number, else the 1-based row index.
180
+
181
+ ==== Known limitations
182
+
183
+ * Column mapping is header-text and position based; exotic header
184
+ layouts (rowspans in `thead`, role columns in unusual order) are not
185
+ resolved.
186
+ * Quantity tables are recognized only in numbered clauses and only when
187
+ a strict majority of rows start with an item number; continuation
188
+ rows (blank item number) are skipped rather than merged into the
189
+ previous entry.
190
+ * Item numbers must match `\d+-\d+(\.\d+)?` (U+2011 non-breaking hyphen
191
+ is normalized to `-`); rows printed differently in the source, such
192
+ as the `3.10.2` typo in ISO 80000-3, are skipped.
193
+ * Cell text is flattened: subscripts/superscripts, inline formulas and
194
+ images lose their markup (`a1` for a₁), and French gender markers stay
195
+ glued to designations (`vecteur du réseau, m`).
196
+ * Equivalent-terms designations are keyed by the downcased header text
197
+ as printed (`francais` in ISO 5843-6); mojibake headers (e.g.
198
+ `PyCCKMM` for Russian) are not recognized as languages.
199
+
89
200
  == Credits
90
201
 
91
202
  This gem is developed, maintained and funded by
@@ -11,8 +11,10 @@ module Obp
11
11
  @elements ||= []
12
12
  end
13
13
 
14
+ # Flattened unique class names across all registered elements, so
15
+ # callers can test membership against individual node classes.
14
16
  def css_classes
15
- @css_classes ||= elements.filter_map(&:classes).uniq
17
+ @css_classes ||= elements.filter_map(&:classes).flatten.uniq
16
18
  end
17
19
  end
18
20
  end
@@ -17,12 +17,17 @@ module Obp
17
17
  nil
18
18
  end
19
19
 
20
+ # Subset match: OBP varies class order and adds marker classes
21
+ # (e.g. "commentable"), so a node matches when it carries at
22
+ # least the registered classes, regardless of order or extras.
20
23
  def match_node?
21
- node.classes == self.class.classes
24
+ (self.class.classes - node.classes).empty?
22
25
  end
23
26
 
24
27
  def render(target:)
25
- effective_target = insertion_target || target
28
+ # Top-level nodes without their own insertion target (e.g. a
29
+ # floating sts-p between sections) default to the body.
30
+ effective_target = insertion_target || target || "body"
26
31
  effective_target = "#{effective_target}#{path_suffix}" if path_suffix
27
32
  document.at(effective_target).public_send(insert_method, to_xml)
28
33
  end
@@ -0,0 +1,53 @@
1
+ module Obp
2
+ class Access
3
+ class Renderer
4
+ class Elements
5
+ class DispFormula < Base
6
+ def self.classes
7
+ %w[sts-disp-formula-panel]
8
+ end
9
+
10
+ private
11
+
12
+ def content
13
+ Nokogiri::XML::Builder.new do |xml|
14
+ xml.public_send(:"disp-formula") do
15
+ xml.label label if label
16
+ if math
17
+ xml << math.to_xml
18
+ elsif graphic_path
19
+ xml.graphic("xlink:href": graphic_path)
20
+ end
21
+ end
22
+ end
23
+ end
24
+
25
+ def label
26
+ text = node.at_css(".sts-disp-formula-label")&.text&.strip
27
+ text unless text&.empty?
28
+ end
29
+
30
+ def math
31
+ @math ||= node.at_css("math")
32
+ end
33
+
34
+ # OBP serves formula images from its own graphics store, which
35
+ # Imager does not download; fall back to the original src.
36
+ def graphic_path
37
+ @graphic_path ||= begin
38
+ img = node.at_css("img")
39
+ img && (stored_image_path(img) || img.attr("src"))
40
+ end
41
+ end
42
+
43
+ def stored_image_path(img)
44
+ images = metas["images"]
45
+ images[img.attr("src")] if images.is_a?(Hash)
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
52
+
53
+ Obp::Access::ElementRegistry.register(Obp::Access::Renderer::Elements::DispFormula)
@@ -8,7 +8,7 @@ module Obp
8
8
  end
9
9
 
10
10
  def match_node?
11
- super && node.css("img").any?
11
+ super && node.css("img").one?
12
12
  end
13
13
 
14
14
  private
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Obp
2
4
  class Access
3
5
  class Renderer
@@ -16,29 +18,30 @@ module Obp
16
18
  def content
17
19
  Nokogiri::XML::Builder.new do |xml|
18
20
  xml.public_send(:"fig-group") do
19
- render_caption(xml, node)
21
+ render_caption_from(xml, node)
20
22
  node.css("img").each { |img| render_figure(xml, img) }
21
23
  end
22
24
  end
23
25
  end
24
26
 
25
- def render_caption(xml, children)
26
- xml.label children.at(".sts-caption-label").content
27
- xml.caption do
28
- xml.title children.at(".sts-caption-title").content
29
- end
30
- end
31
-
32
27
  def render_figure(xml, img)
33
28
  xml.fig do
34
- div = img.previous
35
- xml.label div.at(".sts-caption-label").content
36
- xml.caption do
37
- xml.title div.at(".sts-caption-title").content
38
- end
29
+ # OBP HTML often places no caption between grouped imgs; guard accordingly.
30
+ render_caption_from(xml, img.previous_element) if img.previous_element
39
31
  xml.graphic("xlink:href": local_image_path(img))
40
32
  end
41
33
  end
34
+
35
+ def render_caption_from(xml, source)
36
+ label = source&.at(".sts-caption-label")
37
+ title = source&.at(".sts-caption-title")
38
+ xml.label label.content if label
39
+ return unless title
40
+
41
+ xml.caption do
42
+ xml.title title.content
43
+ end
44
+ end
42
45
  end
43
46
  end
44
47
  end
@@ -0,0 +1,47 @@
1
+ module Obp
2
+ class Access
3
+ class Renderer
4
+ class Elements
5
+ # OBP emits page footnotes as a trailing "sts-footnotes" container
6
+ # holding one "sts-fn" div per note, each text prefixed with the
7
+ # note number.
8
+ class Footnotes < Base
9
+ def self.classes
10
+ %w[sts-footnotes]
11
+ end
12
+
13
+ private
14
+
15
+ def insertion_target
16
+ "back"
17
+ end
18
+
19
+ def content
20
+ Nokogiri::XML::Builder.new do |xml|
21
+ xml.public_send(:"fn-group") do
22
+ node.css("div.sts-fn").each { |note| render_fn(xml, note) }
23
+ end
24
+ end
25
+ end
26
+
27
+ def render_fn(xml, note)
28
+ xml.fn(id: "fn_#{fn_id(note)}") do
29
+ xml.label fn_id(note)
30
+ xml.p fn_text(note)
31
+ end
32
+ end
33
+
34
+ def fn_id(note)
35
+ note.attr("id").split("_").last
36
+ end
37
+
38
+ def fn_text(note)
39
+ note.at_css("div").text.strip.sub(/\A\d+\s*/, "")
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
46
+
47
+ Obp::Access::ElementRegistry.register(Obp::Access::Renderer::Elements::Footnotes)
@@ -0,0 +1,28 @@
1
+ module Obp
2
+ class Access
3
+ class Renderer
4
+ class Elements
5
+ # Renders the example label; the content div.sts-p children are
6
+ # rendered by Paragraph when the renderer recurses into them.
7
+ class NonNormativeExample < Base
8
+ def self.classes
9
+ %w[sts-non-normative-example]
10
+ end
11
+
12
+ private
13
+
14
+ def content
15
+ Nokogiri::XML::Builder.new do |xml|
16
+ xml.public_send(:"non-normative-example") do
17
+ label_node = node.at_css(".sts-non-normative-example-label")
18
+ xml.label label_node.text if label_node
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
27
+
28
+ Obp::Access::ElementRegistry.register(Obp::Access::Renderer::Elements::NonNormativeExample)
@@ -0,0 +1,25 @@
1
+ module Obp
2
+ class Access
3
+ class Renderer
4
+ class Elements
5
+ # OBP replaces paywalled sections with this placeholder ("Only
6
+ # informative sections of standards are publicly available...").
7
+ # Skip it explicitly: render nothing and halt recursion, so the
8
+ # placeholder text never leaks into the output.
9
+ class ProtectedContentNote < Base
10
+ def self.classes
11
+ %w[sts-protected-content-note]
12
+ end
13
+
14
+ # Accepts (and ignores) the render target; the nil return tells
15
+ # the renderer to halt recursion into this subtree.
16
+ def render(*)
17
+ nil
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
24
+
25
+ Obp::Access::ElementRegistry.register(Obp::Access::Renderer::Elements::ProtectedContentNote)
@@ -4,7 +4,7 @@ module Obp
4
4
  class Elements
5
5
  class TableWrap < Base
6
6
  def self.classes
7
- %w[sts-table-wrap fig-index]
7
+ %w[sts-table-wrap]
8
8
  end
9
9
 
10
10
  def match_node?
@@ -26,17 +26,28 @@ module Obp
26
26
  xml.title caption_text
27
27
  end
28
28
  end
29
- xml.table { xml << node.at_css("table").inner_html }
29
+ xml.table { xml << table_markup }
30
30
  end
31
31
  end
32
32
  end
33
33
 
34
+ # Serialize the children as XML so void elements (e.g. <col>)
35
+ # self-close; inner_html emits unclosed HTML voids that swallow
36
+ # following rows when the builder re-parses the fragment. OBP's
37
+ # "sts-unknown-element" placeholders are stripped first.
38
+ def table_markup
39
+ table = node.at_css("table").dup
40
+ table.css(".sts-unknown-element").each(&:remove)
41
+ table.children.map(&:to_xml).join
42
+ end
43
+
34
44
  def caption_label
35
45
  @caption_label ||= node.at_css(".sts-caption-label")&.content
36
46
  end
37
47
 
38
48
  def caption_text
39
- @caption_text ||= node.at_css(".sts-caption")&.content
49
+ @caption_text ||= node.at_css(".sts-caption-title")&.content ||
50
+ node.at_css(".sts-caption")&.content
40
51
  end
41
52
  end
42
53
  end
@@ -15,11 +15,12 @@ module Obp
15
15
  private
16
16
 
17
17
  def id
18
- node.parent.at_css("div.sts-tbx-label").text.strip
18
+ node.parent.at_css("div.sts-tbx-label")&.text&.strip || ""
19
19
  end
20
20
 
21
21
  def index
22
- node.path.match(/\[(\d+)\](?=\z)/)[1].to_i - 1
22
+ match = node.path.match(/\[(\d+)\](?=\z)/)
23
+ match ? match[1].to_i - 1 : 0
23
24
  end
24
25
 
25
26
  def normative_authorization
@@ -6,6 +6,9 @@ module Obp
6
6
  %w[sts-xref] => :xref,
7
7
  %w[sts-std-ref] => :std_ref,
8
8
  %w[sts-label] => :label,
9
+ # OBP's marker for content it could not map; drop its placeholder
10
+ # text ("[no rendering defined for element: ...]").
11
+ %w[sts-unknown-element] => :skip,
9
12
  }.freeze
10
13
 
11
14
  def render_inline(xml, node)
@@ -19,7 +22,7 @@ module Obp
19
22
  def render_node_by_type(xml, node, type)
20
23
  if CONTAINER_TYPES.key?(type)
21
24
  render_container(xml, node, CONTAINER_TYPES[type])
22
- elsif type == :label
25
+ elsif %i[label skip].include?(type)
23
26
  nil
24
27
  elsif type == :element
25
28
  render_children(xml, node)
@@ -20,23 +20,35 @@ module Obp
20
20
  def render(node:, target: nil)
21
21
  return unless css_classes_match?(node)
22
22
 
23
- ElementRegistry.elements.each do |element_class|
24
- element = element_class.new(document:, metas:, node:)
25
- next unless element.match_node?
26
-
27
- xml = element.render(target:)
28
- section_path = xml.first.path
23
+ element = matching_element(node)
24
+ # A nil xml means the element rendered nothing (e.g. an explicit
25
+ # skip element); recursion into its children halts with it.
26
+ xml = element&.render(target:)
27
+ render_children(node, xml) if xml
28
+ end
29
29
 
30
- node.children.each do |child|
31
- render(node: child, target: section_path)
32
- end
30
+ def render_children(node, xml)
31
+ section_path = xml.first&.path
32
+ return unless section_path
33
33
 
34
- xml
34
+ node.children.each do |child|
35
+ render(node: child, target: section_path)
35
36
  end
36
37
  end
37
38
 
39
+ # First match wins: the matching element with the most registered
40
+ # classes is the most specific (e.g. Terminology beats Section for
41
+ # "sts-section sts-tbx-sec"); ties resolve in registration order
42
+ # because max_by keeps the first maximum.
43
+ def matching_element(node)
44
+ ElementRegistry.elements.filter_map do |element_class|
45
+ element = element_class.new(document:, metas:, node:)
46
+ element if element.match_node?
47
+ end.max_by { |element| element.class.classes.size }
48
+ end
49
+
38
50
  def css_classes_match?(node)
39
- ElementRegistry.css_classes.any?(node.classes)
51
+ ElementRegistry.css_classes.intersect?(node.classes)
40
52
  end
41
53
  end
42
54
  end
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "nokogiri"
4
+ require "yaml"
5
+
6
+ module Obp
7
+ class Access
8
+ # Maps the table-carrying nodes of an OBP preview HTML fragment —
9
+ # div.sts-table-wrap and div.sts-array, in document order — to a
10
+ # generic table model built from plain hashes, arrays and strings,
11
+ # ready for YAML serialization. This is an additive read of the same
12
+ # HTML source the Converter renders to STS XML; TableTermExtractor
13
+ # consumes this model.
14
+ class TableMapper
15
+ # Both wrapper kinds carry a single <table>.
16
+ WRAP_SELECTOR = "div.sts-table-wrap, div.sts-array"
17
+
18
+ # Section ids look like "toc_iso_std_iso_80000-12_ed-2_v2_en_sec_3";
19
+ # the clause key is whatever follows "_sec_" ("3", "1.1", "index").
20
+ SECTION_ID_PATTERN = /_sec_(.+)\z/
21
+
22
+ attr_reader :source
23
+
24
+ def initialize(source:)
25
+ @source = source
26
+ end
27
+
28
+ def tables
29
+ @tables ||= wraps.map { |node| map_table(node) }
30
+ end
31
+
32
+ def to_yaml
33
+ tables.to_yaml
34
+ end
35
+
36
+ private
37
+
38
+ def document
39
+ @document ||= Nokogiri::HTML(source.gsub(/[[:space:]]/, " "))
40
+ end
41
+
42
+ def wraps
43
+ @wraps ||= begin
44
+ nodes = document.css("body > div.sts-standard").css(WRAP_SELECTOR)
45
+ nodes.each { |node| node.css(".sts-unknown-element").each(&:remove) }
46
+ nodes
47
+ end
48
+ end
49
+
50
+ def map_table(node)
51
+ {
52
+ "id" => node["id"],
53
+ "section" => section_of(node),
54
+ "label" => text_of(node.at_css(".sts-caption-label")),
55
+ "caption" => caption_of(node),
56
+ "header" => map_rows(node.css("thead tr")),
57
+ "rows" => map_rows(body_rows(node)),
58
+ }.compact
59
+ end
60
+
61
+ def section_of(node)
62
+ section = node.ancestors("div").find do |ancestor|
63
+ ancestor.classes.include?("sts-section")
64
+ end
65
+ section&.[]("id")&.[](SECTION_ID_PATTERN, 1)
66
+ end
67
+
68
+ def caption_of(node)
69
+ text_of(node.at_css(".sts-caption-title")) ||
70
+ text_of(node.at_css(".sts-caption"))
71
+ end
72
+
73
+ # Body rows come from <tbody> when the source splits head/body;
74
+ # otherwise every <tr> outside a <thead> is a body row.
75
+ def body_rows(node)
76
+ rows = node.css("tbody tr")
77
+ return rows unless rows.empty?
78
+
79
+ node.css("table tr").reject { |tr| tr.ancestors("thead").any? }
80
+ end
81
+
82
+ def map_rows(rows)
83
+ rows.map do |tr|
84
+ tr.element_children.map { |cell| map_cell(cell) }
85
+ end.reject(&:empty?)
86
+ end
87
+
88
+ def map_cell(cell)
89
+ mapped = { "text" => cell_text(cell) }
90
+ %w[colspan rowspan].each do |attribute|
91
+ span = cell[attribute].to_i
92
+ mapped[attribute] = span if span > 1
93
+ end
94
+ mapped
95
+ end
96
+
97
+ # Plain stripped cell text. OBP's unknown-element placeholders are
98
+ # removed at the wrapper level; <br> and block-level div.sts-p
99
+ # boundaries become spaces so multi-paragraph cells do not glue
100
+ # words together.
101
+ def cell_text(cell)
102
+ copy = cell.dup
103
+ copy.css("br, div.sts-p").each do |node|
104
+ node.add_previous_sibling(" ")
105
+ end
106
+ copy.content.gsub(/[[:space:]]+/, " ").strip
107
+ end
108
+
109
+ def text_of(node)
110
+ return unless node
111
+
112
+ text = node.content.gsub(/[[:space:]]+/, " ").strip
113
+ text unless text.empty?
114
+ end
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,248 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module Obp
6
+ class Access
7
+ # Extracts term entries from TableMapper's generic table model.
8
+ #
9
+ # Recognition rules, first match wins per table:
10
+ #
11
+ # 1. Quantity/item tables (the ISO 80000 family): at least 4 columns,
12
+ # placed in a numbered clause (the terms clause), and a majority of
13
+ # body rows whose first cell is an item number (e.g. "12-1.1").
14
+ # Column roles come from header text when possible ("Quantity
15
+ # Symbol", "Grandeur Symbole", ...), falling back to the ISO 80000
16
+ # column order [item, designation, symbol, definition, unit,
17
+ # remarks].
18
+ # 2. Equivalent-terms tables: a header row naming languages in at
19
+ # least 2 columns; one entry per row with designations keyed by
20
+ # the downcased language name.
21
+ #
22
+ # Anything else is not a term table and yields no entries.
23
+ class TableTermExtractor
24
+ # "12-1.1" or "4-1"; OBP sometimes prints U+2011 NON-BREAKING
25
+ # HYPHEN, normalized away by normalize_item.
26
+ ITEM_PATTERN = /\A\d+-\d+(?:\.\d+)?\z/
27
+
28
+ MIN_QUANTITY_COLUMNS = 4
29
+ MIN_LANGUAGE_COLUMNS = 2
30
+
31
+ # Terms clauses are numbered ("3", "3.2"); foreword, intro, bibl
32
+ # and index sections are not.
33
+ NUMERIC_SECTION = /\A\d+(\.\d+)*\z/
34
+
35
+ # Checked in order against each column's joined header text; the
36
+ # first unassigned column matching wins, so "Quantity Symbol"
37
+ # becomes "symbol" and not "designation".
38
+ COLUMN_ROLE_PATTERNS = [
39
+ ["symbol", /symbol|symbole/i],
40
+ ["definition", /definition|définition/i],
41
+ ["unit", /unit|unité/i],
42
+ ["remarks", /remark|remarque/i],
43
+ ["item", /item\s+no|n[°º]|numéro|no\./i],
44
+ ["designation", /quantity|grandeur|term|terme|name|nom/i],
45
+ ].freeze
46
+
47
+ # Fallback when header-text matching cannot place item+designation.
48
+ POSITIONAL_ROLES = %w[
49
+ item designation symbol definition unit remarks
50
+ ].freeze
51
+
52
+ # Language names recognized in equivalent-terms table headers.
53
+ LANGUAGES = %w[
54
+ english french français francais anglais russian russe русский
55
+ german deutsch allemand spanish español espagnol italian italiano
56
+ italien chinese chinois japanese japonais arabic arabe portuguese
57
+ portugais korean coréen
58
+ ].freeze
59
+
60
+ attr_reader :tables
61
+
62
+ def initialize(tables:)
63
+ @tables = tables
64
+ end
65
+
66
+ def entries
67
+ @entries ||= tables.flat_map { |table| table_entries(table) }
68
+ end
69
+
70
+ def to_yaml
71
+ entries.to_yaml
72
+ end
73
+
74
+ private
75
+
76
+ def table_entries(table)
77
+ if quantity_table?(table)
78
+ quantity_entries(table)
79
+ elsif equivalent_terms_table?(table)
80
+ equivalent_entries(table)
81
+ else
82
+ []
83
+ end
84
+ end
85
+
86
+ def quantity_table?(table)
87
+ return false unless NUMERIC_SECTION.match?(table["section"].to_s)
88
+ return false unless column_count(table) >= MIN_QUANTITY_COLUMNS
89
+
90
+ rows = table["rows"]
91
+ return false if rows.empty?
92
+
93
+ rows.count { |row| item_number?(cell_text(row, 0)) } * 2 > rows.size
94
+ end
95
+
96
+ def quantity_entries(table)
97
+ roles = column_roles(table)
98
+ seen = {}
99
+ table["rows"].filter_map do |row|
100
+ id = normalize_item(cell_text(row, roles["item"]))
101
+ next unless ITEM_PATTERN.match?(id)
102
+ next if seen[id]
103
+
104
+ seen[id] = true
105
+ quantity_entry(table, row, roles, id)
106
+ end
107
+ end
108
+
109
+ def quantity_entry(table, row, roles, id)
110
+ entry = { "id" => id,
111
+ "designation" => cell_text(row, roles["designation"]).to_s }
112
+ %w[definition symbol unit remarks].each do |role|
113
+ text = cell_text(row, roles[role])
114
+ entry[role] = text unless text.nil? || text.empty?
115
+ end
116
+ entry["source"] = source_of(table)
117
+ entry
118
+ end
119
+
120
+ def equivalent_terms_table?(table)
121
+ !language_header_row(table).nil?
122
+ end
123
+
124
+ def equivalent_entries(table)
125
+ languages = language_columns(language_header_row(table))
126
+ seen = {}
127
+ table["rows"].each_with_index.filter_map do |row, index|
128
+ entry = equivalent_entry(table, row, languages, index)
129
+ next if entry.nil? || seen[entry["id"]]
130
+
131
+ seen[entry["id"]] = true
132
+ entry
133
+ end
134
+ end
135
+
136
+ def equivalent_entry(table, row, languages, index)
137
+ designations = languages.each_with_object({}) do |(column, lang), hash|
138
+ text = cell_text(row, column)
139
+ hash[lang] = text unless text.nil? || text.empty?
140
+ end
141
+ # Rows with a single designation are letter dividers ("A", "B",
142
+ # ...), not equivalences.
143
+ return if designations.size < 2
144
+
145
+ { "id" => entry_id(row, index),
146
+ "designations" => designations,
147
+ "source" => source_of(table) }
148
+ end
149
+
150
+ def entry_id(row, index)
151
+ first = normalize_item(cell_text(row, 0))
152
+ ITEM_PATTERN.match?(first) ? first : (index + 1).to_s
153
+ end
154
+
155
+ def language_header_row(table)
156
+ table["header"].find do |row|
157
+ row.count { |cell| language?(cell["text"]) } >= MIN_LANGUAGE_COLUMNS
158
+ end
159
+ end
160
+
161
+ def language_columns(header_row)
162
+ columns = {}
163
+ position = 0
164
+ header_row.each do |cell|
165
+ columns[position] = cell["text"].downcase if language?(cell["text"])
166
+ position += cell["colspan"] || 1
167
+ end
168
+ columns
169
+ end
170
+
171
+ def language?(text)
172
+ LANGUAGES.include?(text.to_s.downcase)
173
+ end
174
+
175
+ # First unassigned column matching each role pattern wins; matched
176
+ # columns are blanked so later roles cannot claim them again.
177
+ def column_roles(table)
178
+ headers = column_header_texts(table)
179
+ roles = {}
180
+ COLUMN_ROLE_PATTERNS.each do |role, pattern|
181
+ column = headers.index { |text| text.match?(pattern) }
182
+ next unless column
183
+
184
+ headers[column] = ""
185
+ roles[role] = column
186
+ end
187
+ return roles if roles["item"] && roles["designation"]
188
+
189
+ positional_roles(table)
190
+ end
191
+
192
+ def positional_roles(table)
193
+ POSITIONAL_ROLES.first(column_count(table)).each_with_index.to_h
194
+ end
195
+
196
+ # Each column's header text joined across header rows, colspans
197
+ # repeated ("Quantity" + "Symbol" => "Quantity Symbol").
198
+ def column_header_texts(table)
199
+ grid = table["header"].map { |row| expand_row(row) }
200
+ (0...grid_width(grid)).map do |column|
201
+ grid.filter_map { |row| row[column] }.reject(&:empty?).join(" ")
202
+ end
203
+ end
204
+
205
+ def grid_width(grid)
206
+ grid.map(&:size).max || 0
207
+ end
208
+
209
+ def expand_row(row)
210
+ row.flat_map { |cell| [cell["text"]] * (cell["colspan"] || 1) }
211
+ end
212
+
213
+ def column_count(table)
214
+ (table["header"] + table["rows"])
215
+ .map { |row| row.sum { |cell| cell["colspan"] || 1 } }.max || 0
216
+ end
217
+
218
+ def cell_text(row, column)
219
+ return if column.nil?
220
+
221
+ cell_at(row, column)&.fetch("text")
222
+ end
223
+
224
+ def cell_at(row, column)
225
+ position = 0
226
+ row.each do |cell|
227
+ span = cell["colspan"] || 1
228
+ return cell if column >= position && column < position + span
229
+
230
+ position += span
231
+ end
232
+ nil
233
+ end
234
+
235
+ def item_number?(text)
236
+ ITEM_PATTERN.match?(normalize_item(text))
237
+ end
238
+
239
+ def normalize_item(text)
240
+ text.to_s.tr("\u2011", "-")
241
+ end
242
+
243
+ def source_of(table)
244
+ { "table" => table["id"], "section" => table["section"] }.compact
245
+ end
246
+ end
247
+ end
248
+ end
@@ -1,5 +1,5 @@
1
1
  module Obp
2
2
  class Access
3
- VERSION = "0.1.3".freeze
3
+ VERSION = "0.1.5".freeze
4
4
  end
5
5
  end
data/lib/obp/access.rb CHANGED
@@ -19,6 +19,8 @@ require_relative "access/parser"
19
19
  require_relative "access/converter"
20
20
  require_relative "access/imager"
21
21
  require_relative "access/renderer"
22
+ require_relative "access/table_mapper"
23
+ require_relative "access/table_term_extractor"
22
24
  require_relative "access/cli"
23
25
 
24
26
  require_relative "access/elements/base"
@@ -36,6 +38,10 @@ require_relative "access/elements/table_wrap"
36
38
  require_relative "access/elements/title"
37
39
  require_relative "access/elements/paragraph"
38
40
  require_relative "access/elements/non_normative_note"
41
+ require_relative "access/elements/non_normative_example"
42
+ require_relative "access/elements/disp_formula"
43
+ require_relative "access/elements/footnotes"
44
+ require_relative "access/elements/protected_content_note"
39
45
  require_relative "access/elements/copyright"
40
46
  require_relative "access/elements/bibliography"
41
47
  require_relative "access/elements/terminology"
@@ -91,6 +97,18 @@ module Obp
91
97
  Sts::NisoSts::Standard.from_xml(to_xml)
92
98
  end
93
99
 
100
+ # YAML serialization of every table in the OBP preview HTML
101
+ # (TableMapper's generic table model).
102
+ def to_tables
103
+ table_mapper.to_yaml
104
+ end
105
+
106
+ # YAML serialization of term entries extracted from the document's
107
+ # term-carrying tables (TableTermExtractor over the table model).
108
+ def to_terms
109
+ TableTermExtractor.new(tables: table_mapper.tables).to_yaml
110
+ end
111
+
94
112
  def to_xml_file
95
113
  path = File.join(tmpdir, "#{urn.safe}.xml")
96
114
  File.write(path, to_xml(pretty: true))
@@ -107,6 +125,12 @@ module Obp
107
125
  @parser ||= Parser.new(urn:, directory: tmpdir)
108
126
  end
109
127
 
128
+ # Shares Parser#html (memoized) with the STS conversion path, so
129
+ # reading tables does not re-POST to the OBP.
130
+ def table_mapper
131
+ @table_mapper ||= TableMapper.new(source: parser.html)
132
+ end
133
+
110
134
  def pretty_print(xml)
111
135
  Nokogiri::XML(xml, &:noblanks).to_xml
112
136
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: obp-access
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.3
4
+ version: 0.1.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-06-13 00:00:00.000000000 Z
11
+ date: 2026-07-18 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: nokogiri
@@ -93,13 +93,17 @@ files:
93
93
  - lib/obp/access/elements/bibliography.rb
94
94
  - lib/obp/access/elements/bibliography/bib_ref.rb
95
95
  - lib/obp/access/elements/copyright.rb
96
+ - lib/obp/access/elements/disp_formula.rb
96
97
  - lib/obp/access/elements/figure.rb
97
98
  - lib/obp/access/elements/figure_group.rb
99
+ - lib/obp/access/elements/footnotes.rb
98
100
  - lib/obp/access/elements/index.rb
99
101
  - lib/obp/access/elements/introduction.rb
100
102
  - lib/obp/access/elements/list.rb
103
+ - lib/obp/access/elements/non_normative_example.rb
101
104
  - lib/obp/access/elements/non_normative_note.rb
102
105
  - lib/obp/access/elements/paragraph.rb
106
+ - lib/obp/access/elements/protected_content_note.rb
103
107
  - lib/obp/access/elements/root.rb
104
108
  - lib/obp/access/elements/section.rb
105
109
  - lib/obp/access/elements/section_title.rb
@@ -123,6 +127,8 @@ files:
123
127
  - lib/obp/access/parser.rb
124
128
  - lib/obp/access/renderer.rb
125
129
  - lib/obp/access/retriever.rb
130
+ - lib/obp/access/table_mapper.rb
131
+ - lib/obp/access/table_term_extractor.rb
126
132
  - lib/obp/access/urn.rb
127
133
  - lib/obp/access/version.rb
128
134
  homepage: ''