relaton-render 1.4.0.pre.alpha.3 → 3.0.0.pre.alpha.1

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.
@@ -0,0 +1,152 @@
1
+ require "nokogiri"
2
+ require "twitter_cldr"
3
+ require_relative "parse_contributors"
4
+ require_relative "parse_extract"
5
+ require_relative "parse_id"
6
+
7
+ module Relaton
8
+ module Render
9
+ class Parse
10
+ def initialize(options)
11
+ @lang = options[:lang] || "en"
12
+ @script = options[:script] || "Latn"
13
+ @i18n = options[:i18n]
14
+ end
15
+
16
+ def extract(doc)
17
+ host = host(doc)
18
+ ret = simple_xml2hash(doc).merge(simple_or_host_xml2hash(doc, host))
19
+ .merge(host_xml2hash(host))
20
+ .merge(series_xml2hash(doc, host))
21
+ ret
22
+ #i18n(ret)
23
+ end
24
+
25
+ def simple_xml2hash(doc)
26
+ creators, role = creatornames(doc)
27
+ { type: type(doc), title: title(doc), extent_raw: extent(doc),
28
+ size_raw: size(doc), uri_raw: uri(doc), doi: doi(doc),
29
+ authoritative_identifier: authoritative_identifier(doc),
30
+ other_identifier_raw: other_identifier(doc), biblio_tag: biblio_tag(doc),
31
+ status_raw: status(doc), creators: creators, role_raw: role,
32
+ language: language(doc), script: script(doc), locale: locale(doc) }
33
+ end
34
+
35
+ def simple_or_host_xml2hash(doc, host)
36
+ { edition_raw: edition(doc, host), edition_num: edition_num(doc, host),
37
+ medium_raw: medium(doc, host), draft_raw: draft(doc, host),
38
+ place_raw: place(doc, host), publisher_raw: publisher(doc, host),
39
+ publisher_abbrev_raw: publisher_abbrev(doc, host),
40
+ authorizer_raw: authorizer(doc, host),
41
+ distributor_raw: distributor(doc, host),
42
+ access_location: access_location(doc, host),
43
+ date: date(doc, host), date_updated: date_updated(doc, host),
44
+ date_accessed: date_accessed(doc, host) }
45
+ end
46
+
47
+ def host_xml2hash(host)
48
+ creators, role = creatornames(host)
49
+ { host_creators: creators, host_role_raw: role,
50
+ host_title: title(host) }
51
+ end
52
+
53
+ def series_xml2hash(doc, host)
54
+ series = series(doc)
55
+ host and series ||= series(host)
56
+ series_xml2hash1(series, doc)
57
+ end
58
+
59
+ def series_xml2hash1(series, doc)
60
+ series or return {}
61
+ ret = { series_formatted: series_formatted(series, doc),
62
+ series_title: series_title(series, doc),
63
+ series_abbr: series_abbr(series, doc),
64
+ series_run: series_run(series, doc),
65
+ series_num: series_num(series, doc),
66
+ series_partnumber: series_partnumber(series, doc),
67
+ series_place: series_place(series, doc),
68
+ series_org: series_org(series, doc),
69
+ series_dates: series_dates(series, doc) }
70
+ ret.each do |k, v|
71
+ %i(series_num series_dates).include?(k) and next
72
+ ret[k] = esc(v)
73
+ end
74
+ ret
75
+ end
76
+
77
+ #KILL
78
+ def other_identifier_i18n(hash)
79
+ #ret << "#{type}: #{esc id.id}"
80
+ hash[:other_identifier]&.map! do |i|
81
+ i.include?("<esc>") ? @i18n.select(hash).l10n(i) : i
82
+ end
83
+ end
84
+
85
+ ALLOWED_INLINE_TAGS = %w[
86
+ em eref strong stem sub sup tt underline keyword ruby
87
+ strike smallcap xref br link hr pagebreak bookmark
88
+ image index index-xref concept add del span erefstack
89
+ date fn
90
+ ].freeze
91
+
92
+ private
93
+
94
+ def blank?(text)
95
+ text.nil? || text.empty?
96
+ end
97
+
98
+ def esc(text)
99
+ blank?(text) and return text
100
+ "<esc>#{text}</esc>"
101
+ end
102
+
103
+ # Strip any tag not in ALLOWED_INLINE_TAGS, in both literal (<tag>) and
104
+ # entity-encoded (&lt;tag&gt;) forms. Used to defend Liquid template
105
+ # output against embedded structural markup (most commonly <title>) in
106
+ # bibliographic text fields, where relaton-bib may surface either form
107
+ # depending on lutaml-model serialisation behaviour.
108
+ #
109
+ # Authors embed inline markup either literally (<em>foo</em>) or
110
+ # entity-encoded (&lt;em&gt;foo&lt;/em&gt;); both are intended as
111
+ # markup, and downstream template rendering decodes entities anyway
112
+ # (see HTMLEntities.decode in template.rb), so we normalise the
113
+ # encoded form to literal up front and let a single Nokogiri-fragment
114
+ # walk handle both. Elements whose name is in the allow-list are
115
+ # preserved verbatim with their entire subtree, so wrappers that
116
+ # legitimately carry block content (notably <fn><p>…</p></fn>)
117
+ # round-trip intact rather than being shredded one tag at a time.
118
+ # Disallowed elements are unwrapped (tag dropped, children kept),
119
+ # recursing first so allowed wrappers nested inside disallowed
120
+ # wrappers still survive. Nokogiri re-escapes any genuine `<` left in
121
+ # text positions on serialisation, so plain prose like "5 < 10" is
122
+ # preserved correctly.
123
+ def sanitise_inline_markup(str)
124
+ blank?(str) and return str
125
+ normalised = str.gsub("&lt;", "<").gsub("&gt;", ">")
126
+ frag = Nokogiri::XML.fragment(normalised)
127
+ unwrap_disallowed(frag)
128
+ save_opts = Nokogiri::XML::Node::SaveOptions::AS_XML |
129
+ Nokogiri::XML::Node::SaveOptions::NO_DECLARATION
130
+ frag.to_xml(encoding: "UTF-8", save_with: save_opts)
131
+ end
132
+
133
+ def unwrap_disallowed(node)
134
+ node.children.to_a.each do |child|
135
+ next unless child.element?
136
+ next if ALLOWED_INLINE_TAGS.include?(child.name)
137
+
138
+ unwrap_disallowed(child)
139
+ child.replace(child.children)
140
+ end
141
+ end
142
+
143
+ def wrap_in_esc(obj)
144
+ case obj
145
+ when String then esc(obj)
146
+ when Array then obj.map { |e| wrap_in_esc(e) }
147
+ else obj
148
+ end
149
+ end
150
+ end
151
+ end
152
+ end
@@ -0,0 +1,184 @@
1
+ module Relaton
2
+ module Render
3
+ class Parse
4
+ def content(node)
5
+ node.nil? and return node
6
+ node.is_a?(String) and
7
+ return sanitise_inline_markup(node.strip)
8
+ .gsub(/>\n\s*</, "><").gsub(/\n\s*/, " ")
9
+ node.content.is_a?(Array) and return node.content.map { |x| content(x) }
10
+ ret = sanitise_inline_markup(node.content.strip)
11
+ # safeguard against indented XML
12
+ ret.gsub(/>\n\s*</, "><").gsub(/\n\s*/, " ")
13
+ end
14
+
15
+ def extract_orgname(org)
16
+ content(org.name&.first)
17
+ end
18
+
19
+ def extract_personname(person)
20
+ surname = person.name.surname || person.name.completename
21
+ given, middle, initials = given_and_middle_name(person)
22
+ { surname: wrap_in_esc(content(surname)),
23
+ given: given,
24
+ middle: middle,
25
+ initials: initials }
26
+ end
27
+
28
+ def given_and_middle_name(person)
29
+ forenames = forenames_parse(person)
30
+ initials = extract_initials(person)
31
+ forenames.empty? and initials.empty? and return [nil, nil, nil]
32
+ initials.empty? and initials = initials_from_forenames(forenames)
33
+ [forenames.first, forenames[1..-1],
34
+ wrap_in_esc(Array(initials))]
35
+ end
36
+
37
+ def extract_initials(person)
38
+ initials = content(person.name.formatted_initials)&.sub(/(.)\.?$/, "\\1.")
39
+ &.split /(?<=\.) /
40
+ initials ||= person.name.forename.map(&:initial)
41
+ .compact.map { |x| x.sub(/(.)\.?$/, "\\1.") }
42
+ initials
43
+ end
44
+
45
+ def forenames_parse(person)
46
+ person.name.forename.map do |x|
47
+ x.content.nil? || x.content.empty? ? esc("#{x.initial}.") : content(x)
48
+ end
49
+ end
50
+
51
+ # de S. => one initial, M.-J. => one initial
52
+ def initials_parse(person)
53
+ i = content(person.name.formatted_initials) or
54
+ return person.name.forename.map(&:initial)
55
+ .compact.map { |x| x.sub(/(.)\.?$/, "\\1.") }
56
+
57
+ i.sub(/(.)\.?$/, "\\1.")
58
+ .scan(/.+?\.(?=(?:$|\s|\p{Alpha}))/).map(&:strip)
59
+ end
60
+
61
+ def initials_from_forenames(forenames)
62
+ forenames.map(&:split).flatten.map { |x| "#{x[0]}." }
63
+ end
64
+
65
+ def extractname(contributor)
66
+ org = contributor.organization
67
+ person = contributor.person
68
+ return { nonpersonal: extract_orgname(org) } if org
69
+ return extract_personname(person) if person
70
+
71
+ nil
72
+ end
73
+
74
+ def contributor_role(contributors)
75
+ contributors.length.positive? or return nil
76
+ role = contributors[0].role.first
77
+ desc = Array(role&.description).map(&:content).join("\n")
78
+ type = role&.type
79
+ desc.empty? ? type : desc
80
+ end
81
+
82
+ def creatornames(doc)
83
+ cr = creatornames1(doc)
84
+ cr.empty? and return [nil, nil]
85
+ [cr.map { |x| extractname(x) }, contributor_role(cr)]
86
+ end
87
+
88
+ def creatornames_roles_allowed
89
+ %w(author performer adapter translator editor distributor authorizer)
90
+ end
91
+
92
+ def creatornames1(doc)
93
+ cr = []
94
+ doc.nil? and return []
95
+ creatornames_roles_allowed.each do |r|
96
+ add = pick_contributor(doc, r)
97
+ add.nil? and next
98
+ cr = add and break
99
+ end
100
+ cr.nil? and cr = Array(doc.contributor)
101
+ cr
102
+ end
103
+
104
+ def datepick(date)
105
+ date.nil? and return nil
106
+ at = date.at
107
+ from = date.from
108
+ to = date.to
109
+ at and return { on: at.to_s }
110
+ from and return { from: from.to_s, to: to&.to_s }
111
+ nil
112
+ end
113
+
114
+ def date1(date)
115
+ %w(published issued circulated).each do |t|
116
+ ret = date.detect { |x| x.type == t } and
117
+ return ret
118
+ end
119
+ date.reject { |x| x.type == "accessed" }.first
120
+ end
121
+
122
+ # year-only
123
+ def date(doc, host)
124
+ ret = date1(Array(doc.date))
125
+ host and ret ||= date1(Array(host.date))
126
+ datepick(ret)&.transform_values do |v|
127
+ v&.sub(/-.*$/, "")
128
+ end
129
+ end
130
+
131
+ def date_updated(doc, host)
132
+ ret = Array(doc.date).detect { |x| x.type == "updated" }
133
+ host and ret ||= Array(host.date).detect { |x| x.type == "updated" }
134
+ datepick(ret)
135
+ end
136
+
137
+ def date_accessed(doc, host)
138
+ ret = Array(doc.date).detect { |x| x.type == "accessed" }
139
+ host and ret ||= Array(host.date).detect { |x| x.type == "accessed" }
140
+ datepick(ret)
141
+ end
142
+
143
+ def publisher(doc, host)
144
+ x = pick_contributor(doc, "publisher")
145
+ host and x ||= pick_contributor(host, "publisher")
146
+ x.nil? and return nil
147
+ x.map { |c| extractname(c) }
148
+ end
149
+
150
+ def publisher_abbrev(doc, host)
151
+ x = pick_contributor(doc, "publisher")
152
+ host and x ||= pick_contributor(host, "publisher")
153
+ x.nil? and return nil
154
+ x.map do |c|
155
+ content(c.organization.abbreviation) ||
156
+ content(c.organization.name.first)
157
+ end
158
+ end
159
+
160
+ def distributor(doc, host)
161
+ x = pick_contributor(doc, "distributor")
162
+ host and x ||= pick_contributor(host, "distributor")
163
+ x.nil? and return nil
164
+ x.map { |c| extractname(c) }
165
+ end
166
+
167
+ def authorizer(doc, host)
168
+ x = pick_contributor(doc, "authorizer") ||
169
+ pick_contributor(doc, "publisher")
170
+ host and x ||= pick_contributor(host, "authorizer") ||
171
+ pick_contributor(host, "publisher")
172
+ x.nil? and return nil
173
+ x.map { |c| extractname(c) }
174
+ end
175
+
176
+ def pick_contributor(doc, role)
177
+ ret = Array(doc.contributor).select do |c|
178
+ Array(c.role).any? { |r| r.type == role }
179
+ end
180
+ ret.empty? ? nil : ret
181
+ end
182
+ end
183
+ end
184
+ end
@@ -0,0 +1,200 @@
1
+ module Relaton
2
+ module Render
3
+ class Parse
4
+ def host(doc)
5
+ doc.relation&.detect { |r| r.type == "includedIn" }&.bibitem
6
+ end
7
+
8
+ # TODO : first is naive choice
9
+ def title(doc)
10
+ doc.nil? || doc.title.nil? || doc.title.empty? and return nil
11
+ t = Array(doc.title).select { |x| x.language == @lang }
12
+ t.empty? and t = Array(doc.title)
13
+ t1 = t.select { |x| x.type == "main" }
14
+ t1.empty? and t1 = t
15
+ t1.first or return
16
+ esc(content(t1.first))
17
+ end
18
+
19
+ def medium(doc, host)
20
+ x = doc.medium || host&.medium or return nil
21
+ %w(content genre form carrier size scale).each_with_object({}) do |i, m|
22
+ m[i] = x.send i
23
+ end.compact
24
+ end
25
+
26
+ def size(doc)
27
+ x = doc.size or return nil
28
+ x.value.each_with_object({}) do |v, m|
29
+ m[v.type] ||= []
30
+ m[v.type] << v.content
31
+ end
32
+ end
33
+
34
+ def edition(doc, host)
35
+ ret = content(doc.edition || host&.edition)
36
+ ret &&= esc(ret)
37
+ ret
38
+ end
39
+
40
+ def edition_num(doc, host)
41
+ doc.edition&.number || host&.edition&.number
42
+ end
43
+
44
+ def place(doc, host)
45
+ x = Array(doc.place)
46
+ x.empty? && host and x = Array(host.place)
47
+ x.empty? and return x
48
+ x.map { |p| place1(p) }
49
+ end
50
+
51
+ def place1(place)
52
+ c = place.city
53
+ r = place.region
54
+ n = place.country
55
+ c.nil? && r.empty? && n.empty? and return place.formatted_place
56
+ [c, *r.map(&:content), *n.map(&:content)].compact.join(", ")
57
+ end
58
+
59
+ def series(doc)
60
+ doc.series&.detect { |s| s.type == "main" } ||
61
+ doc.series&.detect { |s| s.type.nil? } ||
62
+ doc.series&.first
63
+ end
64
+
65
+ def series_title(series, _doc)
66
+ series.nil? and return nil
67
+ t = Array(series.title).select { |x| x.language == @lang }
68
+ t.empty? and t = Array(series.title)
69
+ t1 = t.select { |x| x.type == "main" }
70
+ t1.empty? and t1 = t
71
+ t1.first.nil? and return nil
72
+ content(t1.first)
73
+ end
74
+
75
+ def series_formatted(series, _doc)
76
+ content(series.formattedref)
77
+ end
78
+
79
+ def series_abbr(series, _doc)
80
+ content(series.abbreviation)
81
+ end
82
+
83
+ def series_num(series, _doc)
84
+ series.number&.strip
85
+ end
86
+
87
+ def series_partnumber(series, _doc)
88
+ series.partnumber
89
+ end
90
+
91
+ def series_run(series, _doc)
92
+ series.run
93
+ end
94
+
95
+ def series_org(series, _doc)
96
+ series.organization
97
+ end
98
+
99
+ def series_place(series, _doc)
100
+ p = series.place or return nil
101
+ place1(p)
102
+ end
103
+
104
+ def series_dates(series, _doc)
105
+ f = series.from
106
+ t = series.to
107
+ f || t or return nil
108
+ "#{f}–#{t}"
109
+ end
110
+
111
+ def access_location(doc, host)
112
+ x = doc.accesslocation || host&.accesslocation or
113
+ return nil
114
+ Array(x).first
115
+ end
116
+
117
+ def included(type)
118
+ ["article", "inbook", "incollection", "inproceedings"].include? type
119
+ end
120
+
121
+ def type(doc)
122
+ type = doc.type and return type
123
+ doc.relation&.any? { |r| r.type == "includedIn" } and return "inbook"
124
+ "book"
125
+ end
126
+
127
+ def language(doc)
128
+ doc.language&.first || @lang
129
+ end
130
+
131
+ def script(doc)
132
+ doc.script&.first || @script
133
+ end
134
+
135
+ def locale(doc)
136
+ # TODO not yet implemented in relaton-bib
137
+ # doc.locale&.first
138
+ end
139
+
140
+ def extent1(localities)
141
+ localities.each_with_object({}) do |l, ret|
142
+ ret[(l.type || "page").to_sym] = {
143
+ from: localized_string_or_text(l.reference_from),
144
+ to: localized_string_or_text(l.reference_to),
145
+ }
146
+ end
147
+ end
148
+
149
+ def localized_string_or_text(str)
150
+ case str
151
+ when Relaton::Bib::LocalizedString then content(str)
152
+ when String then str
153
+ end
154
+ end
155
+
156
+ def extent(doc)
157
+ Array(doc.extent).each_with_object([]) do |e, acc|
158
+ case e
159
+ when Relaton::Bib::Extent, Relaton::Bib::LocalityStack
160
+ if e.locality.any?
161
+ a = e.locality.each_with_object([]) do |e1, m|
162
+ if e1.is_a?(Relaton::Bib::LocalityStack)
163
+ m << extent1(e1.locality)
164
+ else
165
+ m.empty? and m << {}
166
+ m[-1].merge!(extent1(Array(e1)))
167
+ end
168
+ end
169
+ acc << a
170
+ else
171
+ Array(e.locality_stack).each do |stack|
172
+ a = stack.locality.each_with_object([{}]) do |e1, m|
173
+ m[-1].merge!(extent1(Array(e1)))
174
+ end
175
+ acc << a
176
+ end
177
+ end
178
+ when Relaton::Bib::Locality
179
+ acc << extent1(Array(e))
180
+ end
181
+ end
182
+ end
183
+
184
+ def draft(doc, host)
185
+ { iteration: iter_ordinal(doc) || iter_ordinal(host),
186
+ status: status(doc) || status(host) }
187
+ end
188
+
189
+ def iter_ordinal(doc)
190
+ iter = doc&.status&.iteration or return nil
191
+ iter
192
+ end
193
+
194
+ def status(doc)
195
+ v = doc&.status&.stage&.content
196
+ #@i18n.get.dig("stage", v) || v
197
+ end
198
+ end
199
+ end
200
+ end
@@ -0,0 +1,128 @@
1
+ module Relaton
2
+ module Render
3
+ class Parse
4
+ # filter applied across full list of auth_id
5
+ def auth_id_filter(ids = [])
6
+ id_scope_filter(ids)
7
+ end
8
+
9
+ def id_scope_filter(ids)
10
+ ids.detect(&:scope) or return ids
11
+ id_sort_by_scope(ids).each_with_object([]) do |(k, v), m|
12
+ case k
13
+ when "IEEE"
14
+ f = id_extract_by_scope(v, "trademark")
15
+ f.empty? and f = id_extract_by_scope(v, nil)
16
+ m << f
17
+ else m << id_extract_by_scope(v, nil)
18
+ end
19
+ end.flatten
20
+ end
21
+
22
+ def id_sort_by_scope(ids)
23
+ ids.each_with_object({}) do |i, m|
24
+ m[i.type] ||= {}
25
+ m[i.type][i.scope] ||= []
26
+ m[i.type][i.scope] << i
27
+ end
28
+ end
29
+
30
+ def id_extract_by_scope(idents, key)
31
+ if idents.key?(key)
32
+ idents[key]
33
+ else []
34
+ end
35
+ end
36
+
37
+ # list of successive filters on individual auth_id instances
38
+ def auth_id_allow
39
+ [->(x) { x.language == @lang && x.primary },
40
+ ->(x) { x.primary },
41
+ ->(x) { x.language == @lang },
42
+ ->(_x) { true }]
43
+ end
44
+
45
+ def authoritative_identifier(doc)
46
+ out = []
47
+ [auth_id_filter(Array(doc.docidentifier)), Array(doc.docidentifier)].each do |a|
48
+ out = authoritative_identifier_select(a)
49
+ out.empty? or break
50
+ end
51
+ # prevent l10n of identifier contents
52
+ out.map(&:content).map { |i| esc(i.strip) }
53
+ end
54
+
55
+ def authoritative_identifier_select(idents)
56
+ out = []
57
+ auth_id_allow.each do |p|
58
+ out = idents.select do |x|
59
+ p.call(x) &&
60
+ !authoritative_identifier_exclude.include?(id_type_norm(x))
61
+ end
62
+ out.empty? or break
63
+ end
64
+ out
65
+ end
66
+
67
+ def authoritative_identifier_exclude
68
+ %w(METANORMA METANORMA-ORDINAL AUTHOR-DATE TITLE URN ISO-REFERENCE) +
69
+ other_identifier_include
70
+ end
71
+
72
+ def other_identifier(doc)
73
+ Array(doc.docidentifier).each_with_object([]) do |id, ret|
74
+ type = id_type_norm(id)
75
+ other_identifier_include.include? type or next
76
+ ret << [type, id.content]
77
+ end
78
+ end
79
+
80
+ def other_identifier_include
81
+ %w(ISSN ISBN DOI)
82
+ end
83
+
84
+ def doi(doc)
85
+ out = Array(doc.docidentifier).each_with_object([]) do |id, ret|
86
+ type = id.type&.sub(/^(DOI)\..*$/i, "\\1") or next
87
+ type.casecmp("doi").zero? or next
88
+ ret << id.content
89
+ end
90
+ out.empty? ? nil : out.map { |i| esc(i.strip) }
91
+ end
92
+
93
+ def id_type_norm(id)
94
+ id.type or return nil
95
+ m = /^(ISBN|ISSN)\./i.match(id.type) or return id.type.upcase
96
+ m[1].upcase
97
+ end
98
+
99
+ def biblio_tag(doc)
100
+ ret = Array(doc.docidentifier).detect do |id|
101
+ id.scope&.downcase == "biblio-tag"
102
+ end
103
+ ret&.content
104
+ end
105
+
106
+ def uri(doc)
107
+ uri = nil
108
+ %w(citation uri src).each do |t|
109
+ uri = uri_type_select(doc, t) and break
110
+ end
111
+ uri ||= Array(doc.source).detect do |u|
112
+ u.language == @lang && !u.type&.casecmp("doi")&.zero?
113
+ end
114
+ uri ||= Array(doc.source).detect { |u| !u.type&.casecmp("doi")&.zero? }
115
+ uri or return nil
116
+ uri.content.to_s.strip
117
+ end
118
+
119
+ def uri_type_select(doc, type)
120
+ uri = Array(doc.source).detect do |u|
121
+ u.type&.downcase == type && u.language == @lang
122
+ end and return uri
123
+ uri = Array(doc.source).detect { |u| u.type&.downcase == type } and return uri
124
+ nil
125
+ end
126
+ end
127
+ end
128
+ end