metanorma-oiml 0.1.0
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 +7 -0
- data/LICENSE +21 -0
- data/README.adoc +291 -0
- data/data/themes/oiml/assets/oiml-logo-full-dark.svg +1 -0
- data/data/themes/oiml/assets/oiml-logo-full-light.svg +1 -0
- data/data/themes/oiml/assets/oiml-logo-icon-dark.svg +1 -0
- data/data/themes/oiml/assets/oiml-logo-icon-light.svg +1 -0
- data/data/themes/oiml/assets/oiml-logo.svg +1 -0
- data/data/themes/oiml/theme.yaml +56 -0
- data/exe/oiml-sts +10 -0
- data/lib/metanorma/oiml/document/root.rb +29 -0
- data/lib/metanorma/oiml/document.rb +35 -0
- data/lib/metanorma/oiml/html/renderer.rb +14 -0
- data/lib/metanorma/oiml/html.rb +14 -0
- data/lib/metanorma/oiml/sts/cli.rb +89 -0
- data/lib/metanorma/oiml/sts/html_renderer/assets/metanorma-icon-dark.svg +1 -0
- data/lib/metanorma/oiml/sts/html_renderer/assets/metanorma-icon-light.svg +1 -0
- data/lib/metanorma/oiml/sts/html_renderer/assets/oiml-logo-dark.svg +1 -0
- data/lib/metanorma/oiml/sts/html_renderer/assets/oiml-logo.svg +1 -0
- data/lib/metanorma/oiml/sts/html_renderer/assets/oiml-smart-logo-dark.svg +1 -0
- data/lib/metanorma/oiml/sts/html_renderer/assets/oiml-smart-logo.svg +1 -0
- data/lib/metanorma/oiml/sts/html_renderer/assets/page.js +145 -0
- data/lib/metanorma/oiml/sts/html_renderer/assets/theme.css +509 -0
- data/lib/metanorma/oiml/sts/html_renderer/ruby.rb +1071 -0
- data/lib/metanorma/oiml/sts/html_renderer/templates/_element.html.liquid +1 -0
- data/lib/metanorma/oiml/sts/html_renderer/templates/_footer.html.liquid +21 -0
- data/lib/metanorma/oiml/sts/html_renderer/templates/_hero.html.liquid +9 -0
- data/lib/metanorma/oiml/sts/html_renderer/templates/_img.html.liquid +1 -0
- data/lib/metanorma/oiml/sts/html_renderer/templates/_link.html.liquid +1 -0
- data/lib/metanorma/oiml/sts/html_renderer/templates/_meta_header.html.liquid +1 -0
- data/lib/metanorma/oiml/sts/html_renderer/templates/document.html.liquid +43 -0
- data/lib/metanorma/oiml/sts/html_renderer.rb +19 -0
- data/lib/metanorma/oiml/sts/mathml_builder.rb +93 -0
- data/lib/metanorma/oiml/sts/parity_validator/normaliser.rb +95 -0
- data/lib/metanorma/oiml/sts/parity_validator/report.rb +137 -0
- data/lib/metanorma/oiml/sts/parity_validator.rb +23 -0
- data/lib/metanorma/oiml/sts/transformer/back_transformer.rb +55 -0
- data/lib/metanorma/oiml/sts/transformer/base.rb +71 -0
- data/lib/metanorma/oiml/sts/transformer/block_dispatcher.rb +145 -0
- data/lib/metanorma/oiml/sts/transformer/body_transformer.rb +23 -0
- data/lib/metanorma/oiml/sts/transformer/context.rb +27 -0
- data/lib/metanorma/oiml/sts/transformer/dl_transformer.rb +49 -0
- data/lib/metanorma/oiml/sts/transformer/document_transformer.rb +65 -0
- data/lib/metanorma/oiml/sts/transformer/figure_transformer.rb +49 -0
- data/lib/metanorma/oiml/sts/transformer/footnote_collector.rb +33 -0
- data/lib/metanorma/oiml/sts/transformer/formula_transformer.rb +45 -0
- data/lib/metanorma/oiml/sts/transformer/front_transformer.rb +84 -0
- data/lib/metanorma/oiml/sts/transformer/id_generator.rb +39 -0
- data/lib/metanorma/oiml/sts/transformer/inline_transformer.rb +14 -0
- data/lib/metanorma/oiml/sts/transformer/list_transformer.rb +82 -0
- data/lib/metanorma/oiml/sts/transformer/model_builder.rb +322 -0
- data/lib/metanorma/oiml/sts/transformer/note_transformer.rb +103 -0
- data/lib/metanorma/oiml/sts/transformer/paragraph_transformer.rb +574 -0
- data/lib/metanorma/oiml/sts/transformer/reference_transformer.rb +107 -0
- data/lib/metanorma/oiml/sts/transformer/rendered_text_extractor.rb +168 -0
- data/lib/metanorma/oiml/sts/transformer/section_transformer.rb +233 -0
- data/lib/metanorma/oiml/sts/transformer/source_document.rb +246 -0
- data/lib/metanorma/oiml/sts/transformer/standard.rb +37 -0
- data/lib/metanorma/oiml/sts/transformer/table_transformer.rb +290 -0
- data/lib/metanorma/oiml/sts/transformer/term_transformer.rb +236 -0
- data/lib/metanorma/oiml/sts/transformer.rb +47 -0
- data/lib/metanorma/oiml/sts/validation_report.rb +43 -0
- data/lib/metanorma/oiml/sts/validator.rb +126 -0
- data/lib/metanorma/oiml/sts/version.rb +9 -0
- data/lib/metanorma/oiml/sts.rb +84 -0
- data/lib/metanorma-oiml.rb +10 -0
- data/schematron/oiml-sts.sch +70 -0
- metadata +221 -0
|
@@ -0,0 +1,1071 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "cgi"
|
|
4
|
+
require "liquid"
|
|
5
|
+
require "sts"
|
|
6
|
+
|
|
7
|
+
module Metanorma
|
|
8
|
+
module Oiml
|
|
9
|
+
module Sts
|
|
10
|
+
module HtmlRenderer
|
|
11
|
+
# STS XML → HTML renderer. Parses the input once into the sts-ruby
|
|
12
|
+
# typed model and emits HTML through Liquid templates — no DOM
|
|
13
|
+
# library, no XSLT engine.
|
|
14
|
+
#
|
|
15
|
+
# Architecture:
|
|
16
|
+
#
|
|
17
|
+
# render
|
|
18
|
+
# ├── coerce_model (XML string → Sts::NisoSts::Standard)
|
|
19
|
+
# ├── render_node (model tree → fragment, via DISPATCH)
|
|
20
|
+
# └── assemble_document (fragment → full page via
|
|
21
|
+
# templates/document.html.liquid with
|
|
22
|
+
# assets/sts.css and the OIML logo)
|
|
23
|
+
#
|
|
24
|
+
# Element markup lives in templates/ and is overridable per
|
|
25
|
+
# organization with Ruby.new(templates_dir:, assets_dir:) — no
|
|
26
|
+
# code changes needed.
|
|
27
|
+
class Ruby
|
|
28
|
+
TEMPLATES_DIR = File.expand_path("templates", __dir__)
|
|
29
|
+
ASSETS_DIR = File.expand_path("assets", __dir__)
|
|
30
|
+
|
|
31
|
+
NISO = ::Sts::NisoSts
|
|
32
|
+
TBX = ::Sts::TbxIsoTml
|
|
33
|
+
|
|
34
|
+
# demodulized model class name → handler method. Anything not
|
|
35
|
+
# listed renders its children transparently (see #render_children).
|
|
36
|
+
# NisoSts is the canonical NISO STS namespace; TbxIsoTml covers
|
|
37
|
+
# the terminology model and table cells.
|
|
38
|
+
DISPATCH = {
|
|
39
|
+
"MetadataIso" => :meta, "MetadataStd" => :meta,
|
|
40
|
+
"RegMeta" => :meta, "NatMeta" => :meta,
|
|
41
|
+
"Body" => :children,
|
|
42
|
+
"Section" => :section, "App" => :section, "TermSection" => :section,
|
|
43
|
+
"Label" => :label,
|
|
44
|
+
"Title" => :title,
|
|
45
|
+
"Standard" => :standard, "Adoption" => :standard,
|
|
46
|
+
"Paragraph" => :paragraph,
|
|
47
|
+
"List" => :list, "ListItem" => :list_item,
|
|
48
|
+
"DefList" => :def_list, "Term" => :term, "Def" => :def_item,
|
|
49
|
+
"TableWrap" => :table_wrap, "Table" => :table,
|
|
50
|
+
"Thead" => :thead, "Tbody" => :tbody, "Tr" => :tr,
|
|
51
|
+
"Td" => :td, "Th" => :th,
|
|
52
|
+
"Figure" => :figure, "Caption" => :caption, "Graphic" => :graphic,
|
|
53
|
+
"DisplayFormula" => :disp_formula, "InlineFormula" => :inline_formula,
|
|
54
|
+
"Preformat" => :preformat,
|
|
55
|
+
"NonNormativeNote" => :note, "NonNormativeExample" => :example,
|
|
56
|
+
"DispQuote" => :quote,
|
|
57
|
+
"ReferenceList" => :ref_list, "Reference" => :ref,
|
|
58
|
+
"MixedCitation" => :citation, "ElementCitation" => :citation,
|
|
59
|
+
"ReferenceStandard" => :std, "StandardIdentification" => :std_ident,
|
|
60
|
+
"Originator" => :originator,
|
|
61
|
+
"CopyrightStatement" => :copyright,
|
|
62
|
+
"Fn" => :fn, "Xref" => :xref,
|
|
63
|
+
"ExtLink" => :ext_link, "Uri" => :ext_link,
|
|
64
|
+
"Break" => :brk, "ProcessingMeta" => :skip,
|
|
65
|
+
}.freeze
|
|
66
|
+
|
|
67
|
+
# Inline phrase-level elements → HTML tag (constant-keyed).
|
|
68
|
+
# NisoSts for Monospace/Sub/Sup/Sc/Strike/Underline;
|
|
69
|
+
# TbxIsoTml for Bold/Italic (terminology namespace).
|
|
70
|
+
INLINE_TAGS = {
|
|
71
|
+
TBX::Bold => "strong",
|
|
72
|
+
TBX::Italic => "em",
|
|
73
|
+
NISO::Monospace => "code",
|
|
74
|
+
NISO::Sub => "sub",
|
|
75
|
+
NISO::Sup => "sup",
|
|
76
|
+
NISO::Sc => "span",
|
|
77
|
+
NISO::Strike => "s",
|
|
78
|
+
NISO::Underline => "u",
|
|
79
|
+
}.freeze
|
|
80
|
+
|
|
81
|
+
META_ID_NAMES = %w[StandardIdentification
|
|
82
|
+
StandardRef
|
|
83
|
+
DocumentIdentification].freeze
|
|
84
|
+
|
|
85
|
+
PUBLISHER_NAME = "International Organization of Legal Metrology"
|
|
86
|
+
PUBLISHER_ADDRESS = "Bureau International de Métrologie Légale · 11 rue Turgot, 75009 Paris, France"
|
|
87
|
+
|
|
88
|
+
def initialize(templates_dir: nil, assets_dir: nil, publisher_name: nil, publisher_address: nil, license_text: nil)
|
|
89
|
+
@templates_dir = templates_dir || TEMPLATES_DIR
|
|
90
|
+
@assets_dir = assets_dir || ASSETS_DIR
|
|
91
|
+
@publisher_name = publisher_name
|
|
92
|
+
@publisher_address = publisher_address
|
|
93
|
+
@license_text = license_text || "All rights reserved"
|
|
94
|
+
@liquid_env = Liquid::Environment.new
|
|
95
|
+
@liquid_env.file_system = Liquid::LocalFileSystem.new(@templates_dir)
|
|
96
|
+
@template_cache = {}
|
|
97
|
+
@mapping_cache = Hash.new { |h, klass| h[klass] = build_mapping(klass) }
|
|
98
|
+
@toc = []
|
|
99
|
+
@deferred_footnotes = []
|
|
100
|
+
@depth = 0
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Render +model_or_xml+. With +full_document: true+ (default)
|
|
104
|
+
# the fragment is assembled into a complete branded page;
|
|
105
|
+
# with +full_document: false+ the bare fragment is returned.
|
|
106
|
+
def render(model_or_xml, full_document: true)
|
|
107
|
+
model = coerce_model(model_or_xml)
|
|
108
|
+
@assemble = full_document
|
|
109
|
+
@toc = []
|
|
110
|
+
@depth = 0
|
|
111
|
+
body = render_node(model)
|
|
112
|
+
return body unless full_document
|
|
113
|
+
|
|
114
|
+
assemble_document(body, model)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
private
|
|
118
|
+
|
|
119
|
+
attr_reader :templates_dir, :assets_dir
|
|
120
|
+
|
|
121
|
+
# Accept either a parsed model or a raw XML string; the
|
|
122
|
+
# latter is parsed via the sts-ruby typed model.
|
|
123
|
+
def coerce_model(input)
|
|
124
|
+
return input if input.is_a?(Lutaml::Model::Serializable)
|
|
125
|
+
|
|
126
|
+
::Sts::NisoSts::Standard.from_xml(input.to_s)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# --------------------------------------------------------------
|
|
130
|
+
# Dispatch
|
|
131
|
+
# --------------------------------------------------------------
|
|
132
|
+
|
|
133
|
+
def render_node(node)
|
|
134
|
+
case node
|
|
135
|
+
when String then escape(node)
|
|
136
|
+
when Mml::V3::Math then mathml_to_html(node)
|
|
137
|
+
when *INLINE_TAGS.keys then render_inline_tag(node)
|
|
138
|
+
else
|
|
139
|
+
handler = DISPATCH[node.class.name.split("::").last]
|
|
140
|
+
handler ? send(handler, node) : render_children(node)
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Mml::V3::Math serializes with mml: prefix and compact
|
|
145
|
+
# element spacing; MN HTML uses bare element names with
|
|
146
|
+
# newlines between them. Strip the prefix and add newlines
|
|
147
|
+
# so Nokogiri's text extraction produces the same whitespace
|
|
148
|
+
# between MathML leaf elements.
|
|
149
|
+
def mathml_to_html(node)
|
|
150
|
+
node.to_xml.to_s
|
|
151
|
+
.gsub(/<(\/?)mml:/, '<\1')
|
|
152
|
+
.gsub(/\s+xmlns:mml="[^"]*"/, "")
|
|
153
|
+
.gsub(/\s+xmlns:xlink="[^"]*"/, "")
|
|
154
|
+
.gsub(/></, ">\n<")
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# --------------------------------------------------------------
|
|
158
|
+
# Handlers
|
|
159
|
+
# --------------------------------------------------------------
|
|
160
|
+
|
|
161
|
+
def children(node) = render_children(node)
|
|
162
|
+
|
|
163
|
+
# Renders the document: front matter first, then ONE <main>
|
|
164
|
+
# wrapping body AND back matter (annexes, bibliography), so
|
|
165
|
+
# back content stays in the main flow instead of becoming
|
|
166
|
+
# grid items behind the TOC.
|
|
167
|
+
def standard(node)
|
|
168
|
+
front = document_child(node, "Front")
|
|
169
|
+
body = document_child(node, "Body")
|
|
170
|
+
back = document_child(node, "Back")
|
|
171
|
+
|
|
172
|
+
parts = []
|
|
173
|
+
parts << render_children(front) if front
|
|
174
|
+
main_content = []
|
|
175
|
+
main_content << render_children(body) if body
|
|
176
|
+
main_content << render_children(back) if back
|
|
177
|
+
parts << render_element("main", main_content.join)
|
|
178
|
+
parts.join
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# First child model of +node+ whose demodulized class name
|
|
182
|
+
# equals +demodulized+, in document order.
|
|
183
|
+
def document_child(node, demodulized)
|
|
184
|
+
mapping = @mapping_cache[node.class][:elements]
|
|
185
|
+
node.element_order.each do |entry|
|
|
186
|
+
next unless entry.node_type == :element
|
|
187
|
+
|
|
188
|
+
attr = mapping[entry.name.to_s]
|
|
189
|
+
next unless attr
|
|
190
|
+
|
|
191
|
+
item = node.public_send(attr)
|
|
192
|
+
item = item.first if item.is_a?(Array)
|
|
193
|
+
return item if item && item.class.name.split("::").last == demodulized
|
|
194
|
+
end
|
|
195
|
+
nil
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def section(node)
|
|
199
|
+
sec_id = section_id(node)
|
|
200
|
+
title_text = extract_title(node)
|
|
201
|
+
register_toc(node, sec_id, title_text)
|
|
202
|
+
previous_section_id = @current_section_id
|
|
203
|
+
previous_label = @current_label
|
|
204
|
+
label_node = own_label(node)
|
|
205
|
+
suppress_label(label_node) if label_node
|
|
206
|
+
raw_label = label_node ? plain_text(label_node).gsub(/\s+/, " ").strip : nil
|
|
207
|
+
@current_label = display_label(node, raw_label)
|
|
208
|
+
# Boilerplate front-matter (Copyright, Feedback) renders as
|
|
209
|
+
# a muted card; every other section renders uniformly with
|
|
210
|
+
# its own heading — including unnumbered prose sections like
|
|
211
|
+
# Foreword and Introduction.
|
|
212
|
+
frontmatter = frontmatter_section?(sec_id, title_text)
|
|
213
|
+
@depth += 1
|
|
214
|
+
@current_section_id = sec_id
|
|
215
|
+
inner = render_children(node)
|
|
216
|
+
render_element("section", inner, id: sec_id, css: frontmatter ? "frontmatter" : nil)
|
|
217
|
+
ensure
|
|
218
|
+
@depth -= 1
|
|
219
|
+
@current_section_id = previous_section_id
|
|
220
|
+
@current_label = previous_label
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# Only the boilerplate front-matter sections (copyright,
|
|
224
|
+
# feedback) get the muted-card treatment. Other unnumbered
|
|
225
|
+
# sections (Foreword, Introduction) render as normal sections.
|
|
226
|
+
def frontmatter_section?(sec_id, title_text)
|
|
227
|
+
return false unless @depth.zero? && @current_label.nil?
|
|
228
|
+
return true if sec_id && %w[copyright feedback].include?(sec_id.downcase)
|
|
229
|
+
|
|
230
|
+
title_match = TITLE_DOWNCASE_MAP[title_text.to_s.downcase]
|
|
231
|
+
title_match == :frontmatter
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
TITLE_DOWNCASE_MAP = {
|
|
235
|
+
"copyright" => :frontmatter,
|
|
236
|
+
"feedback" => :frontmatter,
|
|
237
|
+
}.freeze
|
|
238
|
+
|
|
239
|
+
# The section's/list item's own <label> child model (nil when
|
|
240
|
+
# absent or a bare string, e.g. TableWrap#label).
|
|
241
|
+
def own_label(node)
|
|
242
|
+
return nil unless node.class.method_defined?(:label)
|
|
243
|
+
|
|
244
|
+
label = node.label
|
|
245
|
+
label if label.is_a?(Lutaml::Model::Serializable)
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
# Marks a label model as consumed by its container (section
|
|
249
|
+
# heading prefix / list-item marker) so the generic label
|
|
250
|
+
# handler does not render it a second time.
|
|
251
|
+
def suppress_label(node)
|
|
252
|
+
suppressed_labels[node] = true
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
def label_suppressed?(node)
|
|
256
|
+
suppressed_labels.key?(node)
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
def suppressed_labels
|
|
260
|
+
@suppressed_labels ||= {}.compare_by_identity
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
# Section's <title> child text, nil when absent.
|
|
264
|
+
def extract_title(node)
|
|
265
|
+
title_node = find_child(node, "Title")
|
|
266
|
+
return nil unless title_node
|
|
267
|
+
|
|
268
|
+
text = plain_text(title_node).gsub(/\s+/, " ").strip
|
|
269
|
+
text.empty? ? nil : text
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
# Term sections (id starts with "term-") carry the term
|
|
273
|
+
# number in <title> and the preferred name as the bold run
|
|
274
|
+
# of the first <p>. For the TOC entry, append the preferred
|
|
275
|
+
# name so the entry reads "3.1 NISO STS".
|
|
276
|
+
def append_term_name(node, sec_id, title_text)
|
|
277
|
+
return title_text unless sec_id&.start_with?("term-")
|
|
278
|
+
return title_text if title_text.to_s.match?(/\s/)
|
|
279
|
+
|
|
280
|
+
name = preferred_term_name(node)
|
|
281
|
+
return title_text if name.nil? || name.empty?
|
|
282
|
+
|
|
283
|
+
"#{title_text} #{name}"
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
# The preferred name is the bold run of the section's first
|
|
287
|
+
# <p> child (the term heading paragraph). Returns nil when
|
|
288
|
+
# the section is not a term section or the heading paragraph
|
|
289
|
+
# is absent / has no bold run.
|
|
290
|
+
def preferred_term_name(node)
|
|
291
|
+
paragraph = find_child(node, "Paragraph")
|
|
292
|
+
return nil unless paragraph
|
|
293
|
+
|
|
294
|
+
bold = find_child(paragraph, "Bold")
|
|
295
|
+
return nil unless bold
|
|
296
|
+
|
|
297
|
+
text = plain_text(bold).gsub(/\s+/, " ").strip
|
|
298
|
+
text.empty? ? nil : text
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
# Sections built without an id (e.g. front-matter sections)
|
|
302
|
+
# get a slug from their title so they are linkable and can
|
|
303
|
+
# appear in the TOC.
|
|
304
|
+
def section_id(node)
|
|
305
|
+
return node.id if node.class.method_defined?(:id) && node.id
|
|
306
|
+
|
|
307
|
+
title_node = find_child(node, "Title")
|
|
308
|
+
return nil unless title_node
|
|
309
|
+
|
|
310
|
+
plain_text(title_node).gsub(/\s+/, " ").strip
|
|
311
|
+
.downcase.gsub(/[^a-z0-9]+/, "-")
|
|
312
|
+
.gsub(/\A-|-\z/, "")
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
# The display form of a section label: annex letters gain the
|
|
316
|
+
# "Annex" prefix ("Annex A"), everything else renders bare.
|
|
317
|
+
def display_label(node, label_text)
|
|
318
|
+
return nil if label_text.nil? || label_text.empty?
|
|
319
|
+
return "Annex #{label_text}" if node.class.name.split("::").last == "App"
|
|
320
|
+
|
|
321
|
+
label_text
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
# Records the section in the interactive table of contents.
|
|
325
|
+
# Apps are sections too; their titles come from the same walk.
|
|
326
|
+
# The entry text carries the section number ("1 Scope").
|
|
327
|
+
# Term sections (id starts with "term-") append the preferred
|
|
328
|
+
# name (the bold text of the first <p>) so the entry reads
|
|
329
|
+
# "3.1 NISO STS" instead of just "3.1".
|
|
330
|
+
def register_toc(node, sec_id, title_text = nil)
|
|
331
|
+
title_node = find_child(node, "Title")
|
|
332
|
+
return unless sec_id && title_node
|
|
333
|
+
|
|
334
|
+
label_node = own_label(node)
|
|
335
|
+
label_text = display_label(node, label_node ? plain_text(label_node).gsub(/\s+/, " ").strip : nil)
|
|
336
|
+
title_text ||= plain_text(title_node).gsub(/\s+/, " ").strip
|
|
337
|
+
title_text = "#{label_text} #{title_text}" if label_text
|
|
338
|
+
title_text = append_term_name(node, sec_id, title_text)
|
|
339
|
+
register_toc_entry(id: sec_id, title: title_text, depth: @depth)
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
def register_toc_entry(id:, title:, depth:)
|
|
343
|
+
@toc << { id: id, title: title, depth: depth }
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
# First direct child model whose demodulized class name matches.
|
|
347
|
+
def find_child(node, demodulized_name)
|
|
348
|
+
return nil unless node.is_a?(Lutaml::Model::Serializable)
|
|
349
|
+
|
|
350
|
+
mapping = @mapping_cache[node.class][:elements]
|
|
351
|
+
node.element_order.each do |entry|
|
|
352
|
+
next unless entry.node_type == :element
|
|
353
|
+
|
|
354
|
+
attr = mapping[entry.name.to_s]
|
|
355
|
+
next unless attr
|
|
356
|
+
|
|
357
|
+
value = node.public_send(attr)
|
|
358
|
+
value = value.first if value.is_a?(Array)
|
|
359
|
+
return value if value && value.class.name.split("::").last == demodulized_name
|
|
360
|
+
end
|
|
361
|
+
nil
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
def label(node)
|
|
365
|
+
return "" if label_suppressed?(node)
|
|
366
|
+
|
|
367
|
+
render_element("span", render_inline(node), css: "label")
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
def title(node)
|
|
371
|
+
return render_element("em", render_inline(node)) if @in_std
|
|
372
|
+
|
|
373
|
+
level = (1 + @depth).clamp(2, 4)
|
|
374
|
+
anchor = if @current_section_id
|
|
375
|
+
%(<a class="h-anchor" href="##{@current_section_id}" aria-label="Link to this section">§</a>)
|
|
376
|
+
else
|
|
377
|
+
""
|
|
378
|
+
end
|
|
379
|
+
# The section number captured from its <label> prefixes the
|
|
380
|
+
# heading ("1 Scope"); consumed so nested content never
|
|
381
|
+
# inherits it.
|
|
382
|
+
prefix = ""
|
|
383
|
+
if @current_label && !@current_label.empty?
|
|
384
|
+
prefix = %(<span class="sec-label">#{escape(@current_label)}</span> )
|
|
385
|
+
@current_label = nil
|
|
386
|
+
end
|
|
387
|
+
render_element("h#{level}", prefix + render_inline(node) + anchor)
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
def paragraph(node) = render_element("p", render_inline(node), id: node.id)
|
|
391
|
+
|
|
392
|
+
# A list whose items carry explicit <label> markers from the
|
|
393
|
+
# source renders without HTML bullets — the label IS the
|
|
394
|
+
# marker (mn-labeled-list); otherwise the browser's bullets
|
|
395
|
+
# would duplicate the source's markers.
|
|
396
|
+
def list(node)
|
|
397
|
+
tag = node.list_type == "order" ? "ol" : "ul"
|
|
398
|
+
css = labeled_list?(node) ? "mn-labeled-list" : nil
|
|
399
|
+
render_element(tag, render_children(node), css: css)
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
def labeled_list?(node)
|
|
403
|
+
return false unless node.class.method_defined?(:list_item)
|
|
404
|
+
|
|
405
|
+
Array(node.list_item).any? { |item| own_label(item) }
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
def list_item(node)
|
|
409
|
+
label_node = own_label(node)
|
|
410
|
+
suppress_label(label_node) if label_node
|
|
411
|
+
inner = render_children(node)
|
|
412
|
+
if label_node
|
|
413
|
+
marker = render_element("span", escape(plain_text(label_node).strip), css: "li-label")
|
|
414
|
+
inner = marker + inner
|
|
415
|
+
end
|
|
416
|
+
render_element("li", inner)
|
|
417
|
+
end
|
|
418
|
+
|
|
419
|
+
def def_list(node) = render_element("dl", render_children(node))
|
|
420
|
+
|
|
421
|
+
def term(node) = render_element("dt", render_element("p", render_children(node)))
|
|
422
|
+
|
|
423
|
+
def def_item(node) = render_element("dd", render_children(node))
|
|
424
|
+
|
|
425
|
+
# Table number + caption render as a caption band above the
|
|
426
|
+
# table; the raw label/caption children are skipped so they
|
|
427
|
+
# don't repeat inside the box.
|
|
428
|
+
def table_wrap(node)
|
|
429
|
+
inner = render_children(node, skip: %w[label caption])
|
|
430
|
+
render_element("div", table_caption_line(node) + inner, css: "table-wrap")
|
|
431
|
+
end
|
|
432
|
+
|
|
433
|
+
def table_caption_line(node)
|
|
434
|
+
label_text = node.class.method_defined?(:label) ? node.label.to_s.strip : ""
|
|
435
|
+
caption_node = node.caption if node.class.method_defined?(:caption)
|
|
436
|
+
title_node = caption_node.title if caption_node&.class&.method_defined?(:title)
|
|
437
|
+
title_html = title_node ? render_inline(title_node) : ""
|
|
438
|
+
return "" if label_text.empty? && title_html.empty?
|
|
439
|
+
|
|
440
|
+
parts = []
|
|
441
|
+
parts << %(<span class="tc-label">#{escape(label_text)}</span>) unless label_text.empty?
|
|
442
|
+
parts << title_html unless title_html.empty?
|
|
443
|
+
# NB: class is tbl-caption — "table-caption" collides with
|
|
444
|
+
# Tailwind's own display utility (display: table-caption).
|
|
445
|
+
%(<p class="tbl-caption">#{parts.join('<span class="tc-delim"> — </span>')}</p>)
|
|
446
|
+
end
|
|
447
|
+
|
|
448
|
+
# Render the table, then append a <tfoot> with one row per
|
|
449
|
+
# footnote encountered in any cell. MN renders table-cell
|
|
450
|
+
# footnotes this way (one <td> per footnote in <tfoot>), so
|
|
451
|
+
# the body text appears as a matchable cell instead of being
|
|
452
|
+
# deferred to end-of-document like paragraph footnotes.
|
|
453
|
+
def table(node)
|
|
454
|
+
@table_fns ||= []
|
|
455
|
+
@table_fns.push([])
|
|
456
|
+
body = render_children(node)
|
|
457
|
+
collected = @table_fns.pop
|
|
458
|
+
parts = [body]
|
|
459
|
+
unless collected.empty?
|
|
460
|
+
tfoot_cells = collected.map do |fn_body|
|
|
461
|
+
cell = render_element("p", escape(fn_body))
|
|
462
|
+
inner_div = %(<div class="TableFootnote">#{cell}</div>)
|
|
463
|
+
%(<td colspan="4">#{inner_div}</td>)
|
|
464
|
+
end.join
|
|
465
|
+
parts << "<tfoot><tr>#{tfoot_cells}</tr></tfoot>"
|
|
466
|
+
end
|
|
467
|
+
render_element("table", parts.join)
|
|
468
|
+
end
|
|
469
|
+
|
|
470
|
+
def thead(node) = render_element("thead", render_children(node))
|
|
471
|
+
|
|
472
|
+
def tbody(node) = render_element("tbody", render_children(node))
|
|
473
|
+
|
|
474
|
+
def tr(node) = render_element("tr", render_children(node))
|
|
475
|
+
|
|
476
|
+
def td(node) = render_element("td", render_children(node))
|
|
477
|
+
|
|
478
|
+
def th(node) = render_element("th", render_children(node))
|
|
479
|
+
|
|
480
|
+
def figure(node) = render_element("figure", render_children(node))
|
|
481
|
+
|
|
482
|
+
def caption(node) = render_element("figcaption", render_children(node))
|
|
483
|
+
|
|
484
|
+
def graphic(node)
|
|
485
|
+
render_liquid("_img.html.liquid", {
|
|
486
|
+
"src" => node.href.to_s,
|
|
487
|
+
"alt" => node.alttext.to_s,
|
|
488
|
+
})
|
|
489
|
+
end
|
|
490
|
+
|
|
491
|
+
def disp_formula(node) = render_element("div", render_inline(node), css: "formula")
|
|
492
|
+
|
|
493
|
+
def inline_formula(node) = render_element("span", render_inline(node), css: "formula")
|
|
494
|
+
|
|
495
|
+
def preformat(node) = render_element("pre", render_inline(node))
|
|
496
|
+
|
|
497
|
+
# Notes carrying their own label (term notes, "Note 1 to
|
|
498
|
+
# entry: …") render the label inside the first paragraph, the
|
|
499
|
+
# way Metanorma marks up term notes; plain notes get the kind
|
|
500
|
+
# label ("NOTE") — also injected inside the first <p> so it
|
|
501
|
+
# appears in the same text node MN produces for parity.
|
|
502
|
+
def note(node)
|
|
503
|
+
label_node = own_label(node)
|
|
504
|
+
suppress_label(label_node) if label_node
|
|
505
|
+
inner = render_children(node)
|
|
506
|
+
label_text = label_node ? plain_text(label_node).strip : "NOTE"
|
|
507
|
+
label_html = %(<span class="note-label">#{escape(label_text)}</span> )
|
|
508
|
+
inner = inner.sub(/<p(?:\s[^>]*)?>/, "\\0#{label_html}")
|
|
509
|
+
render_element("div", inner, css: "note")
|
|
510
|
+
end
|
|
511
|
+
|
|
512
|
+
def example(node)
|
|
513
|
+
label_node = own_label(node)
|
|
514
|
+
suppress_label(label_node) if label_node
|
|
515
|
+
inner = render_children(node)
|
|
516
|
+
if label_node
|
|
517
|
+
label_html = %(<span class="example-label">#{escape(plain_text(label_node).strip)}</span> )
|
|
518
|
+
inner = inner.sub(/<p(?:\s[^>]*)?>/, "\\0#{label_html}")
|
|
519
|
+
else
|
|
520
|
+
inner = kind_label("EXAMPLE") + inner
|
|
521
|
+
end
|
|
522
|
+
render_element("div", inner, css: "example")
|
|
523
|
+
end
|
|
524
|
+
|
|
525
|
+
def kind_label(text)
|
|
526
|
+
%(<span class="note-label">#{text}</span> )
|
|
527
|
+
end
|
|
528
|
+
|
|
529
|
+
def quote(node) = render_element("blockquote", render_children(node))
|
|
530
|
+
|
|
531
|
+
# A ref-list with a title (the back-matter bibliography) gets a
|
|
532
|
+
# TOC entry and an anchor; an untitled one (e.g. the normative
|
|
533
|
+
# references list nested in its numbered section) stays
|
|
534
|
+
# anonymous — the enclosing section carries the TOC entry.
|
|
535
|
+
def ref_list(node)
|
|
536
|
+
title_node = find_child(node, "Title")
|
|
537
|
+
title_text = title_node ? plain_text(title_node).gsub(/\s+/, " ").strip : nil
|
|
538
|
+
sec_id = title_text ? title_text.downcase.gsub(/[^a-z0-9]+/, "-").gsub(/\A-|-\z/, "") : nil
|
|
539
|
+
sec_id = nil if sec_id && sec_id.empty?
|
|
540
|
+
register_toc_entry(id: sec_id, title: title_text, depth: @depth) if sec_id
|
|
541
|
+
previous_section_id = @current_section_id
|
|
542
|
+
@current_section_id = sec_id
|
|
543
|
+
render_element("div", render_children(node), id: sec_id, css: "ref-list")
|
|
544
|
+
ensure
|
|
545
|
+
@current_section_id = previous_section_id
|
|
546
|
+
end
|
|
547
|
+
|
|
548
|
+
# A ref with a structured <std> renders label + std (std-ref
|
|
549
|
+
# and title); the plain mixed-citation is skipped to avoid
|
|
550
|
+
# repeating the same reference three times. The label sits
|
|
551
|
+
# inside the body <p> — a reference reads "[1] Citation…".
|
|
552
|
+
def ref(node)
|
|
553
|
+
# Prefer the structured <std> child (std-ref + title joined
|
|
554
|
+
# by ", " — the NISO STS citation idiom) when present; it
|
|
555
|
+
# carries the same text as mixed-citation without duplication.
|
|
556
|
+
std_node = find_model(node, "ReferenceStandard")
|
|
557
|
+
content = std_node ? render_node(std_node) : render_children(node, skip: %w[label])
|
|
558
|
+
body = render_element("p", ref_label(node) + content, css: "ref-body")
|
|
559
|
+
render_element("div", body, css: "ref")
|
|
560
|
+
end
|
|
561
|
+
|
|
562
|
+
def ref_label(node)
|
|
563
|
+
label_node = find_model(node, "Label")
|
|
564
|
+
return "" unless label_node
|
|
565
|
+
|
|
566
|
+
render_element("span", render_inline(label_node), css: "label") + " "
|
|
567
|
+
end
|
|
568
|
+
|
|
569
|
+
# First typed model in the tree whose demodulized class name
|
|
570
|
+
# equals +demodulized+, depth-first.
|
|
571
|
+
def find_model(node, demodulized)
|
|
572
|
+
found = nil
|
|
573
|
+
walk_models(node) do |model|
|
|
574
|
+
found ||= model if model.class.name.split("::").last == demodulized
|
|
575
|
+
end
|
|
576
|
+
found
|
|
577
|
+
end
|
|
578
|
+
|
|
579
|
+
def citation(node) = render_element("span", render_inline(node), css: "citation")
|
|
580
|
+
|
|
581
|
+
# <std> inside a bibliography ref: keep everything inline —
|
|
582
|
+
# titles become <em>, never headings. std-ref and title join
|
|
583
|
+
# with the NISO STS citation idiom ", ". NisoSts stores
|
|
584
|
+
# title as a bare String on ReferenceStandard (not as a
|
|
585
|
+
# separate Title model), so we read it via #title and escape.
|
|
586
|
+
def std(node)
|
|
587
|
+
@in_std = true
|
|
588
|
+
parts = []
|
|
589
|
+
ref_node = find_model(node, "StandardRef")
|
|
590
|
+
parts << render_element("span", render_inline(ref_node), css: "std-ref") if ref_node
|
|
591
|
+
title_str = node.title if node.class.method_defined?(:title)
|
|
592
|
+
parts << render_element("em", escape(title_str.to_s)) if title_str && !title_str.to_s.empty?
|
|
593
|
+
render_element("span", parts.join(", "), css: "std")
|
|
594
|
+
ensure
|
|
595
|
+
@in_std = false
|
|
596
|
+
end
|
|
597
|
+
|
|
598
|
+
def std_ident(node) = render_element("span", render_inline(node), css: "std-ident")
|
|
599
|
+
|
|
600
|
+
# <originator> inside std-ref: separated, quieter.
|
|
601
|
+
def originator(node) = render_element("span", render_inline(node), css: "originator")
|
|
602
|
+
|
|
603
|
+
def doc_id(node) = render_element("span", render_inline(node), css: "doc-id")
|
|
604
|
+
|
|
605
|
+
def copyright(node) = render_element("span", render_inline(node), css: "copyright")
|
|
606
|
+
|
|
607
|
+
# Fn inside a paragraph renders as an inline <sup> marker only
|
|
608
|
+
# (the body paragraphs are already rendered as the fn's
|
|
609
|
+
# children — but block-level <div> inside <p> breaks the
|
|
610
|
+
# paragraph in HTML parsers, so we keep just the marker when
|
|
611
|
+
# Fn inside a paragraph: render inline <sup> marker only.
|
|
612
|
+
# The fn body paragraphs are collected in @deferred_footnotes
|
|
613
|
+
# and rendered at the end of the document so they become
|
|
614
|
+
# matchable <p> elements (block <p> inside inline context
|
|
615
|
+
# breaks HTML parsers). Fn inside a table cell routes the
|
|
616
|
+
# body to the enclosing table's <tfoot> instead (see #table),
|
|
617
|
+
# matching MN's table-footnote layout and avoiding duplicate
|
|
618
|
+
# rendering at end of document.
|
|
619
|
+
def fn(node)
|
|
620
|
+
label_node = own_label(node)
|
|
621
|
+
suppress_label(label_node) if label_node
|
|
622
|
+
label_text = label_node ? plain_text(label_node).strip : ""
|
|
623
|
+
display_label = label_text.match?(/\A\d+\z/) ? "#{label_text})" : label_text
|
|
624
|
+
# NisoSts::Fn stores paragraphs in #paragraph; TbxIsoTml::Fn
|
|
625
|
+
# stores them in #p. Accept either so the renderer handles
|
|
626
|
+
# footnotes from paragraphs and table cells uniformly.
|
|
627
|
+
fn_paragraphs = if node.class.method_defined?(:paragraph)
|
|
628
|
+
Array(node.paragraph)
|
|
629
|
+
elsif node.class.method_defined?(:p)
|
|
630
|
+
Array(node.p)
|
|
631
|
+
else
|
|
632
|
+
[]
|
|
633
|
+
end
|
|
634
|
+
body_parts = fn_paragraphs.map { |para| plain_text(para) }
|
|
635
|
+
body_text = body_parts.join(" ")
|
|
636
|
+
unless body_text.empty?
|
|
637
|
+
if @table_fns&.last
|
|
638
|
+
@table_fns.last.push(body_text)
|
|
639
|
+
else
|
|
640
|
+
@deferred_footnotes << { "label" => display_label, "body" => body_text }
|
|
641
|
+
end
|
|
642
|
+
end
|
|
643
|
+
render_element("sup", escape(display_label), css: "fn-label")
|
|
644
|
+
end
|
|
645
|
+
|
|
646
|
+
def ext_link(node)
|
|
647
|
+
render_link(href: node.href, text: inline_or_content(node))
|
|
648
|
+
end
|
|
649
|
+
|
|
650
|
+
def xref(node)
|
|
651
|
+
rid = node.rid.to_s
|
|
652
|
+
text = render_inline(node).strip
|
|
653
|
+
text = "[#{rid}]" if text.empty?
|
|
654
|
+
|
|
655
|
+
render_link(href: "##{rid}", text: text)
|
|
656
|
+
end
|
|
657
|
+
|
|
658
|
+
def brk(_node) = "<br/>"
|
|
659
|
+
|
|
660
|
+
def skip(_node) = ""
|
|
661
|
+
|
|
662
|
+
def meta(node)
|
|
663
|
+
return "" if @assemble
|
|
664
|
+
|
|
665
|
+
title = find_text(node, ["TitleWrap", "Title"])
|
|
666
|
+
docid = find_text(node, META_ID_NAMES)
|
|
667
|
+
return "" if title.empty? && docid.empty?
|
|
668
|
+
|
|
669
|
+
render_liquid("_meta_header.html.liquid", {
|
|
670
|
+
"docid" => docid, "title" => title
|
|
671
|
+
})
|
|
672
|
+
end
|
|
673
|
+
|
|
674
|
+
def render_inline_tag(node)
|
|
675
|
+
tag = INLINE_TAGS[node.class]
|
|
676
|
+
inner = render_inline(node)
|
|
677
|
+
return render_element("span", inner, css: "small-caps") if tag == "span"
|
|
678
|
+
|
|
679
|
+
render_element(tag, inner)
|
|
680
|
+
end
|
|
681
|
+
|
|
682
|
+
# --------------------------------------------------------------
|
|
683
|
+
# Walking (document-order traversal of the typed model)
|
|
684
|
+
# --------------------------------------------------------------
|
|
685
|
+
|
|
686
|
+
# Children of a non-mixed container in document order.
|
|
687
|
+
# Repeated elements of a collection appear as one element_order
|
|
688
|
+
# entry PER ITEM, so collection items are consumed one entry at
|
|
689
|
+
# a time (per-attribute index counters). +skip+ lists element
|
|
690
|
+
# names the caller renders itself (e.g. table-wrap's label and
|
|
691
|
+
# caption, which become the caption band).
|
|
692
|
+
def render_children(node, skip: [])
|
|
693
|
+
return render_inline(node) if mixed_model?(node)
|
|
694
|
+
|
|
695
|
+
mapping = @mapping_cache[node.class][:elements]
|
|
696
|
+
indices = Hash.new(0)
|
|
697
|
+
node.element_order.filter_map do |entry|
|
|
698
|
+
case entry.node_type
|
|
699
|
+
when :text
|
|
700
|
+
escape(entry.text_content.to_s)
|
|
701
|
+
when :element
|
|
702
|
+
next if skip.include?(entry.name.to_s)
|
|
703
|
+
|
|
704
|
+
attr = mapping[entry.name.to_s]
|
|
705
|
+
next unless attr
|
|
706
|
+
|
|
707
|
+
item = collection_item_at(node, attr, indices)
|
|
708
|
+
next unless item
|
|
709
|
+
|
|
710
|
+
rendered = render_node(item)
|
|
711
|
+
rendered unless rendered.empty?
|
|
712
|
+
end
|
|
713
|
+
end.join
|
|
714
|
+
end
|
|
715
|
+
|
|
716
|
+
# Mixed-content (inline) walk; non-mixed models route back to
|
|
717
|
+
# the ordered walk.
|
|
718
|
+
def render_inline(node)
|
|
719
|
+
return render_children(node) unless mixed_model?(node)
|
|
720
|
+
|
|
721
|
+
node.each_mixed_content.filter_map do |child|
|
|
722
|
+
rendered = child.is_a?(String) ? escape(child) : render_node(child)
|
|
723
|
+
rendered unless rendered.empty?
|
|
724
|
+
end.join
|
|
725
|
+
end
|
|
726
|
+
|
|
727
|
+
def mixed_model?(node)
|
|
728
|
+
@mapping_cache[node.class][:mixed]
|
|
729
|
+
end
|
|
730
|
+
|
|
731
|
+
# element name → attribute name + mixed-content flag, per model
|
|
732
|
+
# class (cached).
|
|
733
|
+
def build_mapping(klass)
|
|
734
|
+
return { elements: {}, mixed: false } unless klass.respond_to?(:mappings_for)
|
|
735
|
+
|
|
736
|
+
instance = klass.allocate
|
|
737
|
+
xml_mapping = klass.mappings_for(:xml, instance.lutaml_register)
|
|
738
|
+
elements = {}
|
|
739
|
+
xml_mapping.mapping_elements_hash.each_value do |rule_or_array|
|
|
740
|
+
Array(rule_or_array).each { |rule| elements[rule.name.to_s] = rule.to }
|
|
741
|
+
end
|
|
742
|
+
{ elements: elements, mixed: xml_mapping.mixed_content? }
|
|
743
|
+
rescue Lutaml::Model::Error
|
|
744
|
+
{ elements: {}, mixed: false }
|
|
745
|
+
end
|
|
746
|
+
|
|
747
|
+
def collection_item_at(node, attr, indices)
|
|
748
|
+
value = node.public_send(attr)
|
|
749
|
+
return value unless value.is_a?(Array)
|
|
750
|
+
|
|
751
|
+
item = value[indices[attr]]
|
|
752
|
+
indices[attr] += 1
|
|
753
|
+
item
|
|
754
|
+
end
|
|
755
|
+
|
|
756
|
+
# --------------------------------------------------------------
|
|
757
|
+
# Document assembly (full page)
|
|
758
|
+
# --------------------------------------------------------------
|
|
759
|
+
|
|
760
|
+
def assemble_document(body, model)
|
|
761
|
+
meta = meta_info(model)
|
|
762
|
+
footnotes = deferred_footnotes_html
|
|
763
|
+
title_p = body_title_paragraph(meta)
|
|
764
|
+
render_liquid("document.html.liquid", {
|
|
765
|
+
"lang" => "en",
|
|
766
|
+
"title" => meta[:title],
|
|
767
|
+
"css" => stylesheet,
|
|
768
|
+
"logo_light" => logo_svg_light,
|
|
769
|
+
"logo_dark" => logo_svg_dark,
|
|
770
|
+
"docid" => meta[:docid],
|
|
771
|
+
"toc" => toc_html,
|
|
772
|
+
"hero" => hero_html(meta),
|
|
773
|
+
"content" => title_p + body + footnotes,
|
|
774
|
+
"footer" => footer_html(meta),
|
|
775
|
+
"js" => javascript,
|
|
776
|
+
})
|
|
777
|
+
end
|
|
778
|
+
|
|
779
|
+
# MN renders the document title as a <p class="zzSTDTitle1">
|
|
780
|
+
# at the start of the body. Emitting a matching paragraph
|
|
781
|
+
# keeps the title in the parity check's paragraph set.
|
|
782
|
+
def body_title_paragraph(meta)
|
|
783
|
+
return "" if meta[:title].to_s.empty?
|
|
784
|
+
|
|
785
|
+
render_element("p", escape(meta[:title]), css: "doc-title")
|
|
786
|
+
end
|
|
787
|
+
|
|
788
|
+
# Deferred footnotes collected from fn() calls during rendering.
|
|
789
|
+
# Each fn body becomes a matchable <p> at the end of the document.
|
|
790
|
+
def deferred_footnotes_html
|
|
791
|
+
return "" if @deferred_footnotes.empty?
|
|
792
|
+
|
|
793
|
+
parts = @deferred_footnotes.map do |fn|
|
|
794
|
+
render_element("p", escape(fn["body"]), css: "footnote")
|
|
795
|
+
end
|
|
796
|
+
parts.join("\n")
|
|
797
|
+
end
|
|
798
|
+
|
|
799
|
+
def hero_html(meta)
|
|
800
|
+
return "" if meta[:title].empty?
|
|
801
|
+
|
|
802
|
+
chips = []
|
|
803
|
+
chips << meta[:docid] unless meta[:docid].empty?
|
|
804
|
+
chips << "Series #{meta[:series]}" unless meta[:series].empty?
|
|
805
|
+
chips << meta[:year] unless meta[:year].empty?
|
|
806
|
+
chips << "EN"
|
|
807
|
+
render_liquid("_hero.html.liquid", {
|
|
808
|
+
"eyebrow" => @publisher_name || PUBLISHER_NAME,
|
|
809
|
+
"title" => meta[:title],
|
|
810
|
+
"chips" => chips,
|
|
811
|
+
})
|
|
812
|
+
end
|
|
813
|
+
|
|
814
|
+
def footer_html(meta)
|
|
815
|
+
render_liquid("_footer.html.liquid", {
|
|
816
|
+
"copyright" => meta[:copyright],
|
|
817
|
+
"holder" => meta[:holder],
|
|
818
|
+
"year" => meta[:year],
|
|
819
|
+
"docid" => meta[:docid],
|
|
820
|
+
"series" => meta[:series],
|
|
821
|
+
"publisher_name" => @publisher_name || PUBLISHER_NAME,
|
|
822
|
+
"publisher_address" => @publisher_address || PUBLISHER_ADDRESS,
|
|
823
|
+
"license" => @license_text,
|
|
824
|
+
"version" => ::Metanorma::Oiml::Sts::VERSION,
|
|
825
|
+
"mn_icon_light" => metanorma_icon_light,
|
|
826
|
+
"mn_icon_dark" => metanorma_icon_dark,
|
|
827
|
+
})
|
|
828
|
+
end
|
|
829
|
+
|
|
830
|
+
# Metanorma icon (aequitate verum) in light/dark variants,
|
|
831
|
+
# from metanorma.org.
|
|
832
|
+
def metanorma_icon_light
|
|
833
|
+
@mn_icon_light ||= File.read(File.join(assets_dir, "metanorma-icon-light.svg"))
|
|
834
|
+
.sub(/\A<\?xml[^?]*\?>\s*/, "")
|
|
835
|
+
end
|
|
836
|
+
|
|
837
|
+
def metanorma_icon_dark
|
|
838
|
+
@mn_icon_dark ||= File.read(File.join(assets_dir, "metanorma-icon-dark.svg"))
|
|
839
|
+
.sub(/\A<\?xml[^?]*\?>\s*/, "")
|
|
840
|
+
end
|
|
841
|
+
|
|
842
|
+
# Flat TOC list with depth classes (indented via CSS); consumed
|
|
843
|
+
# by the scroll-spy script in the page.
|
|
844
|
+
def toc_html
|
|
845
|
+
return "" if @toc.empty?
|
|
846
|
+
|
|
847
|
+
items = @toc.map do |entry|
|
|
848
|
+
%(<li><a class="toc-link toc-link-d#{entry[:depth]}" href="##{entry[:id]}">#{escape(entry[:title])}</a></li>)
|
|
849
|
+
end.join
|
|
850
|
+
%(<nav id="toc" class="toc-panel sticky top-[68px] self-start max-h-[calc(100vh-5rem)] overflow-y-auto py-4 pr-3 text-sm [scrollbar-width:thin]" aria-label="Contents"><h2 class="toc-heading m-0 mb-2 ml-4 text-xs font-bold tracking-[0.14em] uppercase text-ink-faint">Contents</h2><ol class="toc-list list-none m-0 p-0">#{items}</ol></nav>)
|
|
851
|
+
end
|
|
852
|
+
|
|
853
|
+
def meta_info(model)
|
|
854
|
+
meta_node = find_meta_node(model)
|
|
855
|
+
return { title: "", docid: "" } unless meta_node
|
|
856
|
+
|
|
857
|
+
{ title: title_text(meta_node),
|
|
858
|
+
docid: doc_id(meta_node),
|
|
859
|
+
year: find_text(meta_node, ["CopyrightYear", "PubDate", "Year"]),
|
|
860
|
+
series: doc_series(meta_node),
|
|
861
|
+
copyright: find_text(meta_node, ["CopyrightStatement"]),
|
|
862
|
+
holder: find_text(meta_node, ["CopyrightHolder"]) }
|
|
863
|
+
end
|
|
864
|
+
|
|
865
|
+
# NisoSts TitleWrap stores the title parts as bare Strings
|
|
866
|
+
# (main:, intro:, compl:, full:) rather than as typed Title
|
|
867
|
+
# model children, so plain_text on the wrap returns "".
|
|
868
|
+
# Prefer main, then fall back to compl / full / intro, then
|
|
869
|
+
# fall back to the generic find_text walk for older layouts.
|
|
870
|
+
def title_text(meta_node)
|
|
871
|
+
wrap = find_model(meta_node, "TitleWrap")
|
|
872
|
+
return find_text(meta_node, ["Title"]) unless wrap
|
|
873
|
+
|
|
874
|
+
%i[main compl full intro].each do |attr|
|
|
875
|
+
next unless wrap.class.method_defined?(attr)
|
|
876
|
+
val = wrap.public_send(attr)
|
|
877
|
+
val = val.text if val.respond_to?(:text) && !val.text.nil?
|
|
878
|
+
return val.to_s.strip unless val.to_s.strip.empty?
|
|
879
|
+
end
|
|
880
|
+
find_text(meta_node, ["Title"])
|
|
881
|
+
end
|
|
882
|
+
|
|
883
|
+
# Display form of the document identifier, rebuilt from the
|
|
884
|
+
# decomposed <std-ident> parts ("OIML" + "x" + "999" →
|
|
885
|
+
# "OIML X 999"), dated with the copyright year
|
|
886
|
+
# ("OIML X 999:2026"). Falls back to raw text extraction when
|
|
887
|
+
# the std-ident carries no originator (legacy/other layouts).
|
|
888
|
+
def doc_id(meta_node)
|
|
889
|
+
std_ident = find_model(meta_node, "StandardIdentification")
|
|
890
|
+
return find_text(meta_node, META_ID_NAMES) unless std_ident
|
|
891
|
+
|
|
892
|
+
originator = find_text(std_ident, %w[Originator])
|
|
893
|
+
return find_text(meta_node, META_ID_NAMES) if originator.empty?
|
|
894
|
+
|
|
895
|
+
parts = [originator,
|
|
896
|
+
find_text(std_ident, %w[DocType]).upcase,
|
|
897
|
+
find_text(std_ident, %w[DocNumber])].reject(&:empty?)
|
|
898
|
+
id = parts.join(" ")
|
|
899
|
+
part_number = find_text(std_ident, %w[PartNumber])
|
|
900
|
+
id += "-#{part_number}" unless part_number.empty?
|
|
901
|
+
year = find_text(meta_node, %w[CopyrightYear])
|
|
902
|
+
id += ":#{year}" if !id.empty? && year.match?(/\A\d{4}\z/)
|
|
903
|
+
id
|
|
904
|
+
end
|
|
905
|
+
|
|
906
|
+
# The oiml-doc-series letter recorded in
|
|
907
|
+
# <custom-meta name="oiml-doc-series"> (X 999 §4).
|
|
908
|
+
def doc_series(meta_node)
|
|
909
|
+
series = ""
|
|
910
|
+
walk_models(meta_node) do |model|
|
|
911
|
+
next unless model.instance_of?(::Sts::NisoSts::CustomMeta)
|
|
912
|
+
|
|
913
|
+
name = model.class.method_defined?(:meta_name) ? model.meta_name.to_s : ""
|
|
914
|
+
value = model.class.method_defined?(:meta_value) ? model.meta_value.to_s : ""
|
|
915
|
+
series = value if name == "oiml-doc-series" && !value.empty?
|
|
916
|
+
end
|
|
917
|
+
series
|
|
918
|
+
end
|
|
919
|
+
|
|
920
|
+
# Yields every typed model in the tree, depth-first.
|
|
921
|
+
def walk_models(node, &block)
|
|
922
|
+
return unless node.is_a?(Lutaml::Model::Serializable)
|
|
923
|
+
|
|
924
|
+
yield node
|
|
925
|
+
node.class.attributes.each_value do |attr_def|
|
|
926
|
+
Array(node.public_send(attr_def.name)).compact.each do |child|
|
|
927
|
+
walk_models(child, &block) if child.is_a?(Lutaml::Model::Serializable)
|
|
928
|
+
end
|
|
929
|
+
end
|
|
930
|
+
end
|
|
931
|
+
|
|
932
|
+
# First metadata block found under the document front matter.
|
|
933
|
+
# Recognises any subclass of the NisoSts metadata containers
|
|
934
|
+
# (MetadataIso → <iso-meta>, MetadataStd → <std-meta>) via
|
|
935
|
+
# is_a?, so OIML (or any other future subclass) is picked up
|
|
936
|
+
# without extending a hard-coded class-name list.
|
|
937
|
+
def find_meta_node(node)
|
|
938
|
+
return node if meta_node?(node)
|
|
939
|
+
return nil unless node.is_a?(Lutaml::Model::Serializable)
|
|
940
|
+
|
|
941
|
+
node.class.attributes.each_value do |attr_def|
|
|
942
|
+
value = node.public_send(attr_def.name)
|
|
943
|
+
Array(value).compact.each do |child|
|
|
944
|
+
next unless child.is_a?(Lutaml::Model::Serializable)
|
|
945
|
+
|
|
946
|
+
found = find_meta_node(child)
|
|
947
|
+
return found if found
|
|
948
|
+
end
|
|
949
|
+
end
|
|
950
|
+
nil
|
|
951
|
+
end
|
|
952
|
+
|
|
953
|
+
def meta_node?(node)
|
|
954
|
+
node.is_a?(::Sts::NisoSts::MetadataIso) ||
|
|
955
|
+
node.is_a?(::Sts::NisoSts::MetadataStd)
|
|
956
|
+
end
|
|
957
|
+
|
|
958
|
+
def stylesheet
|
|
959
|
+
@stylesheet ||= File.read(File.join(assets_dir, "theme.css"))
|
|
960
|
+
end
|
|
961
|
+
|
|
962
|
+
# Light- and dark-mode logo variants (theme picks visibility).
|
|
963
|
+
# Uses the OIML SMART logos (distinct from the legacy OIML logo).
|
|
964
|
+
def logo_svg_light
|
|
965
|
+
@logo_svg_light ||= read_asset("oiml-smart-logo.svg")
|
|
966
|
+
end
|
|
967
|
+
|
|
968
|
+
def logo_svg_dark
|
|
969
|
+
@logo_svg_dark ||= read_asset("oiml-smart-logo-dark.svg")
|
|
970
|
+
end
|
|
971
|
+
|
|
972
|
+
def read_asset(name)
|
|
973
|
+
File.read(File.join(assets_dir, name))
|
|
974
|
+
.sub(/\A<\?xml[^?]*\?>\s*/, "")
|
|
975
|
+
end
|
|
976
|
+
|
|
977
|
+
# Page behaviour: scroll-spy TOC, mobile TOC drawer, back-to-top,
|
|
978
|
+
# heading-anchor copy. Vanilla JS, no dependencies.
|
|
979
|
+
def javascript
|
|
980
|
+
@javascript ||= File.read(File.join(assets_dir, "page.js"))
|
|
981
|
+
end
|
|
982
|
+
|
|
983
|
+
# --------------------------------------------------------------
|
|
984
|
+
# Text extraction
|
|
985
|
+
# --------------------------------------------------------------
|
|
986
|
+
|
|
987
|
+
# First non-empty plain text under the node whose demodulized
|
|
988
|
+
# class name is in +names+.
|
|
989
|
+
def find_text(node, names)
|
|
990
|
+
if names.include?(node.class.name.split("::").last)
|
|
991
|
+
text = plain_text(node).gsub(/\s+/, " ").strip
|
|
992
|
+
return text unless text.empty?
|
|
993
|
+
end
|
|
994
|
+
|
|
995
|
+
node.class.attributes.each_value do |attr_def|
|
|
996
|
+
value = node.public_send(attr_def.name)
|
|
997
|
+
Array(value).compact.each do |child|
|
|
998
|
+
next unless child.is_a?(Lutaml::Model::Serializable)
|
|
999
|
+
|
|
1000
|
+
found = find_text(child, names)
|
|
1001
|
+
return found unless found.empty?
|
|
1002
|
+
end
|
|
1003
|
+
end
|
|
1004
|
+
""
|
|
1005
|
+
end
|
|
1006
|
+
|
|
1007
|
+
def plain_text(node)
|
|
1008
|
+
return node.to_s unless node.is_a?(Lutaml::Model::Serializable)
|
|
1009
|
+
|
|
1010
|
+
children = mixed_model?(node) ? node.each_mixed_content.to_a : ordered_content_parts(node)
|
|
1011
|
+
children.map do |child|
|
|
1012
|
+
child.is_a?(String) ? child : plain_text(child)
|
|
1013
|
+
end.join
|
|
1014
|
+
end
|
|
1015
|
+
|
|
1016
|
+
def ordered_content_parts(node)
|
|
1017
|
+
mapping = @mapping_cache[node.class][:elements]
|
|
1018
|
+
indices = Hash.new(0)
|
|
1019
|
+
node.element_order.filter_map do |entry|
|
|
1020
|
+
if entry.node_type == :text
|
|
1021
|
+
entry.text_content.to_s
|
|
1022
|
+
else
|
|
1023
|
+
attr = mapping[entry.name.to_s]
|
|
1024
|
+
next unless attr
|
|
1025
|
+
|
|
1026
|
+
collection_item_at(node, attr, indices)
|
|
1027
|
+
end
|
|
1028
|
+
end
|
|
1029
|
+
end
|
|
1030
|
+
|
|
1031
|
+
def inline_or_content(node)
|
|
1032
|
+
rendered = render_inline(node)
|
|
1033
|
+
rendered.empty? ? node.content.to_s : rendered
|
|
1034
|
+
end
|
|
1035
|
+
|
|
1036
|
+
# --------------------------------------------------------------
|
|
1037
|
+
# Liquid emitters
|
|
1038
|
+
# --------------------------------------------------------------
|
|
1039
|
+
|
|
1040
|
+
def render_element(tag, content, id: nil, css: nil)
|
|
1041
|
+
render_liquid("_element.html.liquid", {
|
|
1042
|
+
"tag" => tag, "id" => id,
|
|
1043
|
+
"css" => css, "content" => content
|
|
1044
|
+
})
|
|
1045
|
+
end
|
|
1046
|
+
|
|
1047
|
+
def render_link(href:, text:)
|
|
1048
|
+
render_liquid("_link.html.liquid", {
|
|
1049
|
+
"href" => href.to_s, "text" => text
|
|
1050
|
+
})
|
|
1051
|
+
end
|
|
1052
|
+
|
|
1053
|
+
def render_liquid(template_name, assigns)
|
|
1054
|
+
template = @template_cache[template_name] ||= begin
|
|
1055
|
+
path = File.join(templates_dir, template_name)
|
|
1056
|
+
# chomp: template files must not contribute their own
|
|
1057
|
+
# trailing newline into the output (it surfaces as a
|
|
1058
|
+
# visible space between inline elements)
|
|
1059
|
+
Liquid::Template.parse(File.read(path).chomp, environment: @liquid_env)
|
|
1060
|
+
end
|
|
1061
|
+
template.render(assigns)
|
|
1062
|
+
end
|
|
1063
|
+
|
|
1064
|
+
def escape(text)
|
|
1065
|
+
CGI.escapeHTML(text.to_s)
|
|
1066
|
+
end
|
|
1067
|
+
end
|
|
1068
|
+
end
|
|
1069
|
+
end
|
|
1070
|
+
end
|
|
1071
|
+
end
|