relaton-render 1.4.0.pre.alpha.4 → 3.0.0.pre.alpha.2

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: 50d68785ac0e26c28b114b0080e9d2057f701801d641baf0fa955819edaaa7f3
4
- data.tar.gz: 2c150fa61c2c5a073c372ab8492f897a25294f02f104cc5a442b17cd8efe4104
3
+ metadata.gz: 9f7785209c01d3c056fa9d516f1cafe5ba50325f28ac5514cc823fba010eb385
4
+ data.tar.gz: 76b64df9733dd15acb659ca35ca7cdafdd1aaac8246c2881ab544e9949f77221
5
5
  SHA512:
6
- metadata.gz: 2206dd123a1cab3a5ae0602e4de1cc71b71aaaac67f1a68474db7c367b10a29a52d08dc477770770295b48d89cfbae263bd314818d9cf5bcb1f722576a479f7c
7
- data.tar.gz: 7270b105580e34e9637172aa3953d50b6bf9bdb51a9d9d9dfe55f6438bb9d026cf2d891b2c6f3b74add5b9bca73b4c8916fd6d33369e972b9ae87207398d33b1
6
+ metadata.gz: b2fa95a3486e245bfcafca4ce8b0b14295a5a7eb2220ce6720b958a8e7b73c3ec585f3761e2f749309938f64b102561562b81e999a24f8d45153abf4faedf95e
7
+ data.tar.gz: 1152dfa988e10a2b1f1772c7e2c82a707d01ffa52c61f4b97ed69e128eb2de700a93dd12e16e5351e124c4e717ed2976bafc148cdd245e243c6a7c962da1b57d
@@ -0,0 +1,48 @@
1
+ module Relaton
2
+ module Render
3
+ class Date
4
+ def initialize(date, options)
5
+ @date = date
6
+ @r = options[:renderer]
7
+ @type = options[:type]
8
+ @bibitem = options[:bibitem]
9
+ end
10
+
11
+ def render
12
+ return @date if @date.nil? || /^\d+$/.match?(@date)
13
+
14
+ render1(granularity)
15
+ end
16
+
17
+ # @r.date is using document language setting, not citation-specific
18
+ def render1(format)
19
+ datef = dateparse(format, @r.lang.to_sym)
20
+ case @r.date[format]
21
+ when "to_full_s", "to_long_s", "to_medium_s", "to_short_s"
22
+ datef.send @r.date[format]
23
+ else
24
+ datef.to_additional_s(@r.date[format])
25
+ end
26
+ end
27
+
28
+ def granularity
29
+ case @date
30
+ when /^\d+-\d+$/ then "month_year"
31
+ when /^\d+-\d+-\d+$/ then "day_month_year"
32
+ else "date_time"
33
+ end
34
+ end
35
+
36
+ def dateparse(format, lang)
37
+ case format
38
+ when "date_time"
39
+ DateTime.parse(@date).localize(lang, timezone: "Zulu")
40
+ when "day_month_year"
41
+ DateTime.parse(@date).localize(lang, timezone: "Zulu").to_date
42
+ when "month_year"
43
+ DateTime.parse("#{@date}-01").localize(lang, timezone: "Zulu").to_date
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,266 @@
1
+ require_relative "date"
2
+
3
+ module Relaton
4
+ module Render
5
+ class Fields
6
+ def initialize(options)
7
+ @r = options[:renderer]
8
+ end
9
+
10
+ # the @i18n.select choices here need to rely on
11
+ # fields already present, they can't rely on fields they are yet to build
12
+ def compound_fields_format(hash)
13
+ name_fields_format(hash)
14
+ role_fields_format(hash)
15
+ date_fields_format(hash)
16
+ edition_fields_format(hash)
17
+ place_fields_format(hash)
18
+ series_fields_format(hash)
19
+ misc_fields_format(hash)
20
+ end
21
+
22
+ def comma(hash)
23
+ "#{@r.i18n.select(hash).get.dig('punct', 'comma') || ','} "
24
+ end
25
+
26
+ def place_fields_format(hash)
27
+ hash[:place_raw]&.map! do |p|
28
+ # TODO use enum-comma?
29
+ if p.is_a?(Array)
30
+ p.join(comma(hash))
31
+ else p
32
+ end
33
+ end
34
+ hash[:place] =
35
+ nameformat(hash[:place_raw].map { |x| { nonpersonal: x } }, hash)
36
+ end
37
+
38
+ def name_fields_format(hash)
39
+ [%i(creatornames creators), %i(host_creatornames host_creators),
40
+ %i(publisher publisher_raw), %i(distributor distributor_raw),
41
+ %i(authorizer authorizer_raw)]
42
+ .each do |k|
43
+ hash[k[0]] = nameformat(hash[k[1]], hash)
44
+ end
45
+ hash[:publisher_abbrev] = hash[:publisher_abbrev_raw]&.join(comma(hash))
46
+ hash[:authorcite] = authorciteformat(hash[:creators], hash)
47
+ end
48
+
49
+ def role_fields_format(hash)
50
+ hash[:role] = role_inflect(hash[:creators], hash[:role_raw], hash)
51
+ hash[:host_role] =
52
+ role_inflect(hash[:host_creators], hash[:host_role_raw], hash)
53
+ end
54
+
55
+ def edition_fields_format(hash)
56
+ hash[:edition] =
57
+ editionformat(hash[:edition_raw], hash[:edition_num], hash)
58
+ hash[:draft] = draftformat(hash[:draft_raw], hash)
59
+ end
60
+
61
+ def misc_fields_format(hash)
62
+ hash[:medium] = mediumformat(hash[:medium_raw], hash)
63
+ hash[:extent] = extentformat(hash[:extent_raw], hash)
64
+ hash[:size] = sizeformat(hash[:size_raw], hash)
65
+ hash[:uri] = uriformat(hash[:uri_raw])
66
+ hash[:status] = statusformat(hash[:status_raw], hash)
67
+ hash[:other_identifier] =
68
+ otheridentifierformat(hash[:other_identifier_raw], hash)
69
+ hash
70
+ end
71
+
72
+ def otheridentifierformat(otherids, _hash)
73
+ otherids&.map do |i|
74
+ # ret = "#{i[0]}: <esc>#{i[1]}</esc>"
75
+ # @r.i18n.select(hash).l10n(ret) # no i18n!
76
+ "#{i[0]}: #{i[1]}"
77
+ end
78
+ end
79
+
80
+ def statusformat(status, hash)
81
+ status.nil? and return
82
+ @r.i18n.select(hash).get.dig("stage", status) || status
83
+ end
84
+
85
+ # date => disambiguated_date, the disambiguation happens in citations/
86
+ def date_fields_format(hash)
87
+ [%i(date date), %i(disambiguated_date date),
88
+ %i(date_updated date_updated),
89
+ %i(date_accessed date_accessed)].each do |k|
90
+ hash[k[0]] = dateformat(hash[k[1]], hash, k)
91
+ k[1] == :date &&
92
+ !%w(standard webresource website).include?(hash[:type]) and
93
+ hash[k[0]] ||= @r.i18n.select(nil).get["no_date"]
94
+ end
95
+ end
96
+
97
+ def mediumformat(medium, hash)
98
+ medium.nil? and return nil
99
+ ret = %w(content genre form carrier size
100
+ scale).each_with_object([]) do |i, m|
101
+ m << "<esc>#{medium[i]}</esc>" if medium[i]
102
+ m
103
+ end.compact
104
+ i = @r.i18n.select(hash)
105
+ i.l10n(ret.join(comma(hash)))
106
+ end
107
+
108
+ def series_fields_format(hash)
109
+ parts =
110
+ %i(series_title series_abbr series_num series_partnumber
111
+ series_run series_formatted series_dates series_place series_org)
112
+ series_out = parts.each_with_object({}) do |i, m|
113
+ m[i] = hash[i]
114
+ end
115
+ t = hash[:type] == "article" ? @r.journaltemplate : @r.seriestemplate
116
+ hash[:series] = t.render(series_out, hash)
117
+ end
118
+
119
+ def nameformat(names, hash)
120
+ names.nil? and return names
121
+ parts = %i(surname initials given middle nonpersonal)
122
+ names_out = names.each_with_object({}) do |n, m|
123
+ parts.each do |i|
124
+ m[i] ||= []
125
+ m[i] << n[i]
126
+ end
127
+ end
128
+ @r.nametemplate.render(names_out, hash)
129
+ end
130
+
131
+ def authorciteformat(names, hash)
132
+ names.nil? || @r.authorcitetemplate.nil? and return names
133
+ parts = %i(surname initials given middle nonpersonal)
134
+ names_out = names.each_with_object({}) do |n, m|
135
+ parts.each do |i|
136
+ m[i] ||= []
137
+ m[i] << n[i]
138
+ end
139
+ end
140
+ @r.authorcitetemplate.render(names_out, hash)
141
+ end
142
+
143
+ def role_inflect(contribs, role, hash)
144
+ role.nil? || contribs.empty? ||
145
+ %w(author publisher distributor
146
+ authorizer).include?(role) and return nil
147
+ number = contribs.size > 1 ? "pl" : "sg"
148
+ x = @r.i18n.select(hash).get[role]
149
+ x.is_a?(Hash) or return role
150
+ x[number] || role
151
+ end
152
+
153
+ def editionformat(edn, num, hash)
154
+ num || edn && !edn.empty? or return
155
+ edn_num = edn&.gsub(/<\/?esc>/, "")
156
+ num || /^\d+$/.match?(edn_num) and
157
+ return @r.i18n.select(hash)
158
+ .populate("edition_ordinal", { "var1" => num || edn_num.to_i })
159
+ @r.i18n.select(hash).populate("edition_cardinal", { "var1" => edn })
160
+ end
161
+
162
+ def draftformat(num, hash)
163
+ num.nil? ||
164
+ (num.is_a?(Hash) && num[:status].nil? &&
165
+ num[:iteration].nil?) and return nil
166
+ @r.i18n.select(hash).draft.sub("%", num)
167
+ end
168
+
169
+ def extentformat(extent, hash)
170
+ extent.map do |stack|
171
+ stack.map do |e|
172
+ e1 = e.transform_values { |v| v.is_a?(Hash) ? range(v) : v }
173
+ ret = e.each_with_object({}) do |(k, v), m|
174
+ extentformat1(k, v, m, e1, hash)
175
+ m
176
+ end
177
+ @r.extenttemplate.render(hash.merge(ret), hash)
178
+ end.join(" ")
179
+ end.join("; ")
180
+ end
181
+
182
+ def extentformat1(key, val, hash, norm_hash, context)
183
+ if %i(volume issue page).include?(key)
184
+ hash["#{key}_raw".to_sym] = norm_hash[key]
185
+ hash[key] =
186
+ pagevolformat(norm_hash[key], val, key.to_s, false, context)
187
+ end
188
+ end
189
+
190
+ def range(hash)
191
+ hash[:on] and return hash[:on]
192
+ hash.has_key?(:from) && hash[:from].nil? and return nil
193
+ !hash[:from] and return hash
194
+ hash[:to] && hash[:to] != hash[:from] and
195
+ return "#{hash[:from]}&#x2013;#{hash[:to]}"
196
+ hash[:from]
197
+ end
198
+
199
+ def sizeformat(size, hash)
200
+ return nil unless size
201
+
202
+ ret = size.transform_values do |v|
203
+ @r.i18n.select(hash).l10n(v.join(" + "))
204
+ end
205
+ .each_with_object({}) do |(k, v), m|
206
+ sizeformat1(k, v, m, hash)
207
+ m
208
+ end
209
+ @r.sizetemplate.render(ret.merge(type: hash[:type]), hash)
210
+ end
211
+
212
+ def sizeformat1(key, val, hash, context)
213
+ case key
214
+ when "volume", "issue", "page"
215
+ hash["#{key}_raw".to_sym] = val
216
+ hash[key.to_sym] = pagevolformat(val, nil, key, true, context)
217
+ when "data" then hash[:data] = val
218
+ when "duration" then hash[:duration] = val
219
+ end
220
+ end
221
+
222
+ def pagevolformat(value, value_raw, type, is_size, hash)
223
+ value.nil? and return nil
224
+ num = "pl"
225
+ if is_size
226
+ value == "1" and num = "sg"
227
+ else
228
+ value_raw[:to] or num = "sg"
229
+ end
230
+ i = @r.i18n.select(hash)
231
+ i.l10n(i.get[is_size ? "size" : "extent"][type][num]
232
+ .sub("%", value))
233
+ end
234
+
235
+ def date_range(hash)
236
+ hash[:from] && !hash[:to] and return "#{hash[:from]}&#x2013;"
237
+ range(hash)
238
+ end
239
+
240
+ def dateformat(date, hash, type)
241
+ date.nil? and return nil
242
+ date.is_a?(String) and return date
243
+ %i(from to on).each do |k|
244
+ date[k] = @r.dateklass
245
+ .new(date[k], renderer: @r, bibitem: hash, type: type).render
246
+ end
247
+ date_range(date)
248
+ end
249
+
250
+ def uriformat(uri)
251
+ uri.nil? || uri.empty? and return nil
252
+ # do not process uri contents in l10n
253
+ "<fmt-link target='#{uri}'><esc>#{uri}</esc></fmt-link>"
254
+ end
255
+
256
+ private
257
+
258
+ def tw_cldr_lang
259
+ if @r.lang != "zh" then @r.lang.to_sym
260
+ elsif @r.script == "Hant" then :"zh-tw"
261
+ else :"zh-cn"
262
+ end
263
+ end
264
+ end
265
+ end
266
+ end
@@ -25,6 +25,7 @@ module Relaton
25
25
  CiteTemplate = Struct.new(:template_raw)
26
26
 
27
27
  def initialize(options = {})
28
+ options = deep_symbolize(options)
28
29
  @lang = options[:language] || options[:lang] || "en"
29
30
  @renderer = Iso690::Renderer.new(
30
31
  lang: @lang,
@@ -145,6 +146,17 @@ module Relaton
145
146
  end
146
147
  end
147
148
 
149
+ # 1.x accepted string- and symbol-keyed options alike (the old
150
+ # engine deep-symbolized via metanorma-utils); isodoc's bibrenderer
151
+ # and every flavor still pass string keys.
152
+ def deep_symbolize(value)
153
+ case value
154
+ when Hash then value.to_h { |k, v| [k.to_sym, deep_symbolize(v)] }
155
+ when Array then value.map { |v| deep_symbolize(v) }
156
+ else value
157
+ end
158
+ end
159
+
148
160
  def warn_general_config
149
161
  return if General.config_warned
150
162
 
@@ -0,0 +1,178 @@
1
+ module Relaton
2
+ module Render
3
+ class Citations
4
+ def initialize(opt = {})
5
+ # invoking i18n hash, but we preference the specific renderer's i18n
6
+ @i18n = opt[:i18n]
7
+ @lang = opt[:lang] # document lang and script
8
+ @script = opt[:script]
9
+ # hash of renderers, to be applied to specific citation by selector
10
+ @renderer = opt[:renderer]
11
+ end
12
+
13
+ def renderer(_cite)
14
+ @renderer[:default]
15
+ end
16
+
17
+ # takes array of { id, type, author, date, ord, data }
18
+ def render(ret)
19
+ cites = citations(ret)
20
+ enhance_data(cites)
21
+ cites.each_key do |k|
22
+ cites[k] = render1(cites[k])
23
+ end
24
+ cites
25
+ end
26
+
27
+ def enhance_data(cites)
28
+ ret = extract_uris_for_lookup(cites)
29
+ ret.empty? and return
30
+ # functionality removed: date needs to be given explicitly
31
+ # @renderer.urls_exist_concurrent(ret.keys).each do |k, v|
32
+ # ret[k].each { |u| add_date_accessed(cites[u], k, v) }
33
+ # end
34
+ end
35
+
36
+ def extract_uris_for_lookup(cites)
37
+ cites.each_with_object({}) do |(k, v), m|
38
+ u = extract_uri_for_lookup(v) or next
39
+ m[u] ||= []
40
+ m[u] << k
41
+ end
42
+ end
43
+
44
+ def extract_uri_for_lookup(cite)
45
+ t = renderer(cite).renderer(cite[:type] || "misc").template_raw
46
+ c = cite[:data]
47
+ t.is_a?(String) or return
48
+ (/\{\{\s*date_accessed\s*\}\}/.match?(t) &&
49
+ /\{\{\s*uri\s*\}\}/.match?(t) &&
50
+ c[:uri_raw] && !c[:date_accessed]) or return
51
+ c[:uri_raw]
52
+ end
53
+
54
+ def add_date_accessed(cite, uri, status)
55
+ r = renderer(cite)
56
+ if status
57
+ cite[:data][:date_accessed] = { on: ::Date.today.to_s }
58
+ cite[:data] = r.fieldsklass.new(renderer: r)
59
+ .compound_fields_format(cite[:data])
60
+ else
61
+ r.url_warn(uri)
62
+ end
63
+ end
64
+
65
+ def render1(cit)
66
+ ref, ref1, r = render1_prep(cit)
67
+ i = @i18n.select(cit[:data])
68
+ cit[:formattedref] =
69
+ r.valid_parse(i.l10n(ref1))
70
+ cit[:citation][:full] = r.valid_parse(i.l10n(ref))
71
+ %i(type data renderer).each { |x| cit.delete(x) }
72
+ cit
73
+ end
74
+
75
+ def use_terminator?(ref, final, _cit)
76
+ !ref || ref.empty? and return false
77
+ !ref.end_with?(final)
78
+ end
79
+
80
+ def render1_prep(cit)
81
+ r = renderer(cit)
82
+ ref = r.renderer(cit[:type] || "misc").render(cit[:data], cit[:data])
83
+ final = @i18n.select(cit[:data]).get
84
+ .dig("punct", "biblio-terminator") || "."
85
+ ref1 = ref
86
+ use_terminator?(ref, final, cit) and ref1 += final
87
+ [ref, ref1, r]
88
+ end
89
+
90
+ # TODO: configure how multiple ids are joined, from template?
91
+ def citations(ret)
92
+ ret = disambig_author_date_citations(ret)
93
+ ret.each_value do |b|
94
+ i = @i18n.select(b[:data])
95
+ b[:citation][:default] =
96
+ i.l10n(b[:data][:authoritative_identifier]&.first || "")
97
+ d = b[:data].merge(citestyle: "short")
98
+ b[:citation][:short] = i.l10n(renderer(b).citeshorttemplate.render(d, d))
99
+ citations_iterate_cite_styles(b, i)
100
+ end
101
+ ret
102
+ end
103
+
104
+ def citations_iterate_cite_styles(bib, i18n)
105
+ r = renderer(bib)
106
+ r.citetemplate.citation_styles.each do |style|
107
+ bib[:citation][style] =
108
+ i18n.l10n(r.citetemplate
109
+ .render(bib.merge(citestyle: style)
110
+ .merge(bib[:data]), bib[:data]))
111
+ end
112
+ end
113
+
114
+ # takes array of { id, type, author, date, ord, data }
115
+ def disambig_author_date_citations(ret)
116
+ author_date_to_hash(suffix_date(sort_ord(author_date_breakdown(ret))))
117
+ end
118
+
119
+ def author_date_breakdown(ret)
120
+ ret.each_with_object({}) do |b, m|
121
+ m[b[:author]] ||= {}
122
+ m[b[:author]][b[:date]] ||= []
123
+ m[b[:author]][b[:date]] << b
124
+ end
125
+ end
126
+
127
+ def sort_ord(ret)
128
+ ret.each do |author, v|
129
+ v.each_key do |date|
130
+ ret[author][date].sort_by! { |a| a[:ord] }
131
+ end
132
+ end
133
+ end
134
+
135
+ def suffix_date(ret)
136
+ ret.each do |k, v|
137
+ v.each do |k1, v1|
138
+ s = v1.reject { |b| b[:date].nil? }.size
139
+ s.zero? || k.nil? and next
140
+ suffix_date1(ret, k, k1, s)
141
+ copy_date_fields(ret, k, k1)
142
+ end
143
+ end
144
+ ret
145
+ end
146
+
147
+ def suffix_date1(ret, key1, key2, size)
148
+ size > 1 or return
149
+ ret[key1][key2].each_with_index do |b, i|
150
+ b[:date].nil? and next
151
+ b[:disambiguated_date] = b[:date] + ("a".ord + i).chr.to_s
152
+ end
153
+ end
154
+
155
+ def copy_date_fields(ret, key1, key2)
156
+ ret[key1][key2].each do |b|
157
+ b[:date].nil? and next
158
+ b[:data][:date] = b[:date]
159
+ b[:disambiguated_date] ||= b[:date]
160
+ b[:data][:disambiguated_date] = b[:disambiguated_date]
161
+ end
162
+ end
163
+
164
+ def author_date_to_hash(ret)
165
+ ret.each_with_object({}) do |(_k, v), m|
166
+ v.each_value do |v1|
167
+ v1.each do |b|
168
+ m[b[:id]] = { author: @i18n.select(b[:data]).l10n(b[:author]),
169
+ date: b[:disambiguated_date] || b[:date],
170
+ citation: {},
171
+ data: b[:data], type: b[:type] }
172
+ end
173
+ end
174
+ end
175
+ end
176
+ end
177
+ end
178
+ end
@@ -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