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.
- checksums.yaml +4 -4
- data/.rubocop.yml +22 -9
- data/.rubocop_todo.yml +108 -1
- data/CLAUDE.md +13 -6
- data/README.adoc +132 -0
- data/lib/obp/access/element_registry.rb +3 -1
- data/lib/obp/access/elements/base.rb +8 -3
- data/lib/obp/access/elements/disp_formula.rb +53 -0
- data/lib/obp/access/elements/footnotes.rb +54 -0
- data/lib/obp/access/elements/non_normative_example.rb +28 -0
- data/lib/obp/access/elements/protected_content_note.rb +25 -0
- data/lib/obp/access/elements/root.rb +12 -20
- data/lib/obp/access/elements/table_wrap.rb +17 -3
- data/lib/obp/access/fetcher.rb +4 -1
- data/lib/obp/access/inline_renderer.rb +4 -1
- data/lib/obp/access/parser.rb +1 -1
- data/lib/obp/access/raw_html_parser.rb +41 -0
- data/lib/obp/access/renderer.rb +23 -11
- data/lib/obp/access/table_mapper.rb +117 -0
- data/lib/obp/access/table_term_extractor.rb +248 -0
- data/lib/obp/access/urn.rb +55 -0
- data/lib/obp/access/version.rb +1 -1
- data/lib/obp/access.rb +49 -2
- data/obp-access.gemspec +33 -0
- metadata +10 -2
|
@@ -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
|
|
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 <<
|
|
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
|
data/lib/obp/access/fetcher.rb
CHANGED
|
@@ -48,7 +48,10 @@ module Obp
|
|
|
48
48
|
"v-loc" => "#{API_URL}##{@urn}",
|
|
49
49
|
)
|
|
50
50
|
|
|
51
|
-
Net::HTTP.start(
|
|
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
|
|
25
|
+
elsif %i[label skip].include?(type)
|
|
23
26
|
nil
|
|
24
27
|
elsif type == :element
|
|
25
28
|
render_children(xml, node)
|
data/lib/obp/access/parser.rb
CHANGED
|
@@ -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
|
data/lib/obp/access/renderer.rb
CHANGED
|
@@ -20,23 +20,35 @@ module Obp
|
|
|
20
20
|
def render(node:, target: nil)
|
|
21
21
|
return unless css_classes_match?(node)
|
|
22
22
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
30
|
+
def render_children(node, xml)
|
|
31
|
+
section_path = xml.first&.path
|
|
32
|
+
return unless section_path
|
|
33
33
|
|
|
34
|
-
|
|
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.
|
|
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
|
data/lib/obp/access/urn.rb
CHANGED
|
@@ -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
|
data/lib/obp/access/version.rb
CHANGED