obp-access 0.1.4 → 0.1.6

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.
@@ -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,31 @@ 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")
40
+ return "" unless table
41
+
42
+ table = table.dup
43
+ table.css(".sts-unknown-element").each(&:remove)
44
+ table.children.map(&:to_xml).join
45
+ end
46
+
34
47
  def caption_label
35
48
  @caption_label ||= node.at_css(".sts-caption-label")&.content
36
49
  end
37
50
 
38
51
  def caption_text
39
- @caption_text ||= node.at_css(".sts-caption")&.content
52
+ @caption_text ||= node.at_css(".sts-caption-title")&.content ||
53
+ node.at_css(".sts-caption")&.content
40
54
  end
41
55
  end
42
56
  end
@@ -48,7 +48,10 @@ module Obp
48
48
  "v-loc" => "#{API_URL}##{@urn}",
49
49
  )
50
50
 
51
- Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http|
51
+ Net::HTTP.start(
52
+ uri.hostname, uri.port, use_ssl: true,
53
+ open_timeout: 30, read_timeout: 60
54
+ ) do |http|
52
55
  http.request(request)
53
56
  end
54
57
  end
@@ -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)
@@ -55,7 +55,7 @@ module Obp
55
55
  end
56
56
 
57
57
  def tab_data
58
- @tab_data ||= state.filter_map { |attr| attr["tabs"] }.first.last
58
+ @tab_data ||= state.filter_map { |attr| attr["tabs"] }.first&.last || {}
59
59
  end
60
60
 
61
61
  def titles
@@ -0,0 +1,41 @@
1
+ module Obp
2
+ class Access
3
+ # Parser that converts an already-downloaded OBP HTML fragment (e.g.
4
+ # captured via a browser / waffle-punch) without touching the network.
5
+ # Duck-types with Parser for the Access pipeline.
6
+ class RawHtmlParser < Parser
7
+ def initialize(urn:, directory:, html:, caption: nil, titles: nil)
8
+ super(urn:, directory:)
9
+ @raw_html = html
10
+ @caption = caption
11
+ @raw_titles = titles
12
+ end
13
+
14
+ def html
15
+ @raw_html
16
+ end
17
+
18
+ def available_languages
19
+ [urn.language]
20
+ end
21
+
22
+ private
23
+
24
+ def state
25
+ raise "RawHtmlParser does not fetch OBP state"
26
+ end
27
+
28
+ def tab_data
29
+ { "caption" => @caption, "description" => title }
30
+ end
31
+
32
+ def title
33
+ @caption
34
+ end
35
+
36
+ def titles
37
+ @raw_titles || { urn.language => title }
38
+ end
39
+ end
40
+ end
41
+ end
@@ -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,6 +1,8 @@
1
1
  module Obp
2
2
  class Access
3
3
  class Urn
4
+ DOC_TYPE_SEGMENTS = %w[ts tr pas guide iwa].freeze
5
+
4
6
  attr_reader :raw, :language, :base
5
7
 
6
8
  def initialize(raw)
@@ -26,6 +28,59 @@ module Obp
26
28
  def hash
27
29
  raw.hash
28
30
  end
31
+
32
+ def parts
33
+ @parts ||= raw.split(":")
34
+ end
35
+
36
+ # Originator segment: "iso", "iec", "itu", ...
37
+ def originator
38
+ parts[2]
39
+ end
40
+
41
+ # Document type segment if present ("ts", "tr", ...), else nil
42
+ def doc_type_segment
43
+ segments = identifier_segments
44
+ segments.find { |s| DOC_TYPE_SEGMENTS.include?(s) }
45
+ end
46
+
47
+ # Document number, including part number when present.
48
+ # "iso:std:iso:80000:-12:ed-2:v1:en" → "80000-12"
49
+ def doc_number
50
+ identifier_segments
51
+ .reject { |s| DOC_TYPE_SEGMENTS.include?(s) }
52
+ .join
53
+ end
54
+
55
+ def edition
56
+ segment = parts.find { |p| p.start_with?("ed-") }
57
+ segment&.delete_prefix("ed-")
58
+ end
59
+
60
+ def version
61
+ segment = parts.find { |p| p.match?(/\Av\d+\z/) }
62
+ segment&.delete_prefix("v")
63
+ end
64
+
65
+ def doc_type
66
+ case doc_type_segment
67
+ when "ts" then "TS"
68
+ when "tr" then "TR"
69
+ when "pas" then "PAS"
70
+ when "guide" then "Guide"
71
+ when "iwa" then "IWA"
72
+ else "IS"
73
+ end
74
+ end
75
+
76
+ private
77
+
78
+ # Segments between the originator and the edition segment.
79
+ def identifier_segments
80
+ start_index = 3
81
+ end_index = parts.index { |p| p.start_with?("ed-") } || parts.size
82
+ parts[start_index...end_index]
83
+ end
29
84
  end
30
85
  end
31
86
  end
@@ -1,5 +1,5 @@
1
1
  module Obp
2
2
  class Access
3
- VERSION = "0.1.4".freeze
3
+ VERSION = "0.1.6".freeze
4
4
  end
5
5
  end