Class: Metanorma::Html::BaseRenderer

Inherits:
Object
  • Object
show all
Defined in:
lib/metanorma/html/base_renderer.rb

Overview

Renders BasicDocument components to HTML. Subclassed by StandardRenderer and flavor-specific renderers. Owns the full HTML document generation pipeline: body content, header, footer, ToC sidebar, CSS (via Theme), and JavaScript.

Direct Known Subclasses

StandardRenderer

Defined Under Namespace

Classes: RendererContext

Constant Summary collapse

LOGO_DIR =
File.expand_path("../../../data/logos", __dir__)
SPAN_ROLE_CLASSES =

HTML-specific class names for inline spans, keyed by the XML span role. The XML class_attr is INPUT only — we never emit it in HTML.

{
  "boldtitle" => "title-text",
  "nonboldtitle" => "subtitle-text",
  "citeapp" => "xref-app",
  "citefig" => "xref-fig",
  "citesec" => "xref-section",
  "citetbl" => "xref-table",
  "fmt-autonum-delim" => "number-delim",
  "fmt-caption-label" => "caption-label",
  "fmt-caption-delim" => "caption-delim",
  "fmt-element-name" => "element-label",
  "fmt-comma" => "comma",
  "fmt-conn" => "connector",
  "fmt-label-delim" => "label-delim",
  "fmt-obligation" => "obligation-text",
  "fmt-xref-container" => "xref-container",
  "fmt-xref-label" => "xref-label",
  "std_publisher" => "ref-publisher",
  "stdpublisher" => "ref-publisher-name",
  "stddocNumber" => "ref-doc-number",
  "stddocTitle" => "ref-title",
  "stddocPartNumber" => "ref-part-number",
  "stdyear" => "ref-year",
  "date" => "date",
  "smallcap" => "small-caps",
}.freeze
METANORMA_LOGO =
"metanorma-logo.svg"
TEMPLATE_CACHE =

— Document Assembly —

{}
TEMPLATE_CACHE_MUTEX =
Mutex.new
BLOCK_TYPES =
{
  Metanorma::Document::Components::Paragraphs::ParagraphBlock => true,
  Metanorma::Document::Components::Tables::TableBlock => true,
  Metanorma::Document::Components::Lists::UnorderedList => true,
  Metanorma::Document::Components::Lists::OrderedList => true,
  Metanorma::Document::Components::Lists::DefinitionList => true,
  Metanorma::Document::Components::AncillaryBlocks::FigureBlock => true,
  Metanorma::Document::Components::Blocks::NoteBlock => true,
  Metanorma::Document::Components::AncillaryBlocks::ExampleBlock => true,
  Metanorma::Document::Components::AncillaryBlocks::SourcecodeBlock => true,
  Metanorma::Document::Components::AncillaryBlocks::FormulaBlock => true,
  Metanorma::Document::Components::MultiParagraph::QuoteBlock => true,
  Metanorma::Document::Components::MultiParagraph::AdmonitionBlock => true,
  Metanorma::Document::Components::Sections::HierarchicalSection => true,
  Metanorma::Document::Components::Sections::BasicSection => true,
  Metanorma::Document::Components::Sections::ContentSection => true,
}.freeze

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initializeBaseRenderer

Returns a new instance of BaseRenderer.



72
73
74
75
76
77
78
79
80
81
# File 'lib/metanorma/html/base_renderer.rb', line 72

def initialize
  @output = +""
  @toc_entries = []
  @figure_entries = []
  @table_entries = []
  @index_term_collector = Component::IndexTermCollector.new
  @footnote_collector = Component::FootnoteCollector.new
  @current_section_id = nil
  @current_section_number = nil
end

Instance Attribute Details

#footnote_collectorObject (readonly)

Returns the value of attribute footnote_collector.



419
420
421
# File 'lib/metanorma/html/base_renderer.rb', line 419

def footnote_collector
  @footnote_collector
end

#index_term_collectorObject (readonly)

Returns the value of attribute index_term_collector.



419
420
421
# File 'lib/metanorma/html/base_renderer.rb', line 419

def index_term_collector
  @index_term_collector
end

Class Method Details

.inline_registryObject



63
64
65
# File 'lib/metanorma/html/base_renderer.rb', line 63

def inline_registry
  @inline_registry ||= {}
end

.register_inline_render(type_class, method_name) ⇒ Object



67
68
69
# File 'lib/metanorma/html/base_renderer.rb', line 67

def register_inline_render(type_class, method_name)
  inline_registry[type_class] = method_name
end

.register_render(type_class, method_name) ⇒ Object



59
60
61
# File 'lib/metanorma/html/base_renderer.rb', line 59

def register_render(type_class, method_name)
  render_registry[type_class] = method_name
end

.render_registryObject



55
56
57
# File 'lib/metanorma/html/base_renderer.rb', line 55

def render_registry
  @render_registry ||= {}
end

Instance Method Details

#alignment_style(alignment) ⇒ Object



1535
1536
1537
1538
1539
# File 'lib/metanorma/html/base_renderer.rb', line 1535

def alignment_style(alignment)
  return nil if alignment.nil? || alignment.to_s.empty?

  "text-align: #{alignment}"
end

#assemble_document(body) ⇒ Object



181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
# File 'lib/metanorma/html/base_renderer.rb', line 181

def assemble_document(body)
  toc_html = build_toc_html(@toc_entries)
  header = build_header
  footer = build_footer

  render_liquid("document.html.liquid", {
                  "lang" => language,
                  "title" => html_title,
                  "font_url" => flavor_font_url,
                  "styles" => build_styles,
                  "header" => header,
                  "toc" => toc_html,
                  "body" => body,
                  "footer" => footer,
                  "scripts" => build_scripts,
                })
end

#block_element?(obj) ⇒ Boolean

Returns:

  • (Boolean)


1491
1492
1493
# File 'lib/metanorma/html/base_renderer.rb', line 1491

def block_element?(obj)
  BLOCK_TYPES[obj.class] || BLOCK_TYPES.any? { |type, _| obj.is_a?(type) }
end


261
262
263
264
265
266
267
# File 'lib/metanorma/html/base_renderer.rb', line 261

def build_footer
   = load_logo_svg(METANORMA_LOGO, height: 20)
  render_liquid("_footer.html.liquid", {
                  "mn_logo" => ,
                  "generated_at" => Time.now.strftime("%Y-%m-%d %H:%M"),
                })
end

#build_headerObject

— Header and Footer —



201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
# File 'lib/metanorma/html/base_renderer.rb', line 201

def build_header
  doc_id = extract_primary_doc_id
  pub_logos = build_publisher_logos
  pub_name = flavor_publisher_name
  display_id = if pub_name && doc_id && !doc_id.start_with?(pub_name)
                 "#{pub_name} #{doc_id}"
               else
                 doc_id
               end

  render_liquid("_header.html.liquid", {
                  "publisher_logos" => pub_logos,
                  "doc_id" => display_id,
                  "doc_title" => header_title_text,
                })
end

#build_publisher_logosObject



223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
# File 'lib/metanorma/html/base_renderer.rb', line 223

def build_publisher_logos
  publishers = flavor_publishers(extract_primary_doc_id)
  logo_map = publisher_logo_map
  return "" if publishers.empty? && logo_map.empty?

  # Use flavor-declared publishers; fall back to logo map keys
  display_pubs = publishers.empty? ? logo_map.keys : publishers

  display_pubs.filter_map do |pub|
    filename = logo_map[pub]
    next unless filename

    svg = load_logo_svg(filename, height: 26)
    next unless svg

    "<span class=\"brand-logo\" aria-label=\"#{pub} logo\">#{svg}</span>"
  end.join("\n")
end

#build_scriptsObject

— Scripts —



287
288
289
290
291
# File 'lib/metanorma/html/base_renderer.rb', line 287

def build_scripts
  pipeline = AssetPipeline.new
  compiled = pipeline.compile_js(flavor_js: flavor_js_module)
  "<script>\n#{compiled}\n</script>"
end

#build_semx_skip_set(node) ⇒ Object

In presentation XML, semantic elements are followed by <semx> wrappers or <fmt-*> display elements. Skip source elements to avoid duplicates.



979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
# File 'lib/metanorma/html/base_renderer.rb', line 979

def build_semx_skip_set(node)
  skip_after = {
    "link" => "semx",
    "xref" => "semx",
    "eref" => "semx",
    "stem" => nil,
    "concept" => "fmt-concept",
    "refterm" => nil,
    "renderterm" => nil,
    "origin" => "semx",
  }
  skip = {}
  node.element_order.each_with_index do |el, i|
    next unless el.element?

    next_tag = skip_after[el.name]
    next unless next_tag

    next_el = node.element_order[i + 1]
    if next_tag.nil?
      skip[i] = true
    elsif next_el&.element? && next_el.name == next_tag
      skip[i] = true
    end
  end
  skip
end

#build_stylesObject



301
302
303
304
305
# File 'lib/metanorma/html/base_renderer.rb', line 301

def build_styles
  pipeline = AssetPipeline.new
  css = pipeline.compile_css(flavor_css: flavor_css_module)
  "#{theme.to_css_root}\n#{css}\n#{theme.to_css_extras}"
end

#build_toc_html(entries) ⇒ Object

— ToC generation —



271
272
273
274
275
276
277
278
279
280
281
282
283
# File 'lib/metanorma/html/base_renderer.rb', line 271

def build_toc_html(entries)
  entry_drops = entries.map { |e| Drops::TocEntryDrop.new(e) }
  figure_drops = @figure_entries.map { |f| Drops::FigureListEntryDrop.new(f) }
  table_drops = @table_entries.map { |t| Drops::FigureListEntryDrop.new(t) }
  has_special_lists = !@figure_entries.empty? || !@table_entries.empty?

  render_liquid("_toc.html.liquid", {
                  "entries" => entry_drops,
                  "figures" => figure_drops,
                  "tables" => table_drops,
                  "has_special_lists" => has_special_lists,
                })
end

#capture_outputObject



1597
1598
1599
1600
1601
1602
1603
1604
# File 'lib/metanorma/html/base_renderer.rb', line 1597

def capture_output
  old_output = @output
  @output = +""
  yield
  result = @output
  @output = old_output
  result
end

#check_presentation_markers(node) ⇒ Object



319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
# File 'lib/metanorma/html/base_renderer.rb', line 319

def check_presentation_markers(node)
  return false unless node
  return false if node.is_a?(String)

  if node.is_a?(Metanorma::Document::Root) && node.type == "presentation"
    return true
  end

  if node.is_a?(Lutaml::Model::Serializable)
    return true if begin
      node.fmt_title
    rescue StandardError
      nil
    end
    return true if begin
      node.displayorder
    rescue StandardError
      nil
    end

    %i[preface sections annex bibliography].each do |attr|
      val = begin
        node.public_send(attr)
      rescue StandardError
        nil
      end
      next unless val

      Array(val).each { |v| return true if check_presentation_markers(v) }
    end

    node.each_mixed_content do |child|
      next if child.is_a?(String)
      return true if check_presentation_markers(child)
    end
  end

  false
end

#collect_index_term(element) ⇒ Object



1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
# File 'lib/metanorma/html/base_renderer.rb', line 1501

def collect_index_term(element)
  primary = safe_attr(element, :primary)
  return unless primary && !primary.to_s.strip.empty?

  @index_term_collector.add(
    primary: primary.to_s.strip,
    secondary: safe_attr(element, :secondary)&.to_s&.strip,
    tertiary: safe_attr(element, :tertiary)&.to_s&.strip,
    target_id: @current_section_id,
    target_text: @current_section_number,
  )
rescue StandardError
  nil
end

#collect_ordered_children(section) ⇒ Object

Collect all renderable children from a node in document order, sorted by displayorder when available. Uses walk_ordered to traverse element_order, and also gathers typed attributes that may not appear in element_order (e.g. terms, definitions on section models).



737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
# File 'lib/metanorma/html/base_renderer.rb', line 737

def collect_ordered_children(section)
  children = []

  walk_ordered(section) do |type, obj|
    next if %i[text tab].include?(type)

    children << obj
  end

  # Gather typed attributes that may not appear in element_order
  supplementary_attrs = %i[terms definitions]
  supplementary_attrs.each do |attr|
    val = safe_attr(section, attr)
    next if val.nil?

    Array(val).each do |v|
      children << v unless children.include?(v)
    end
  end

  children.compact!
  sort_by_displayorder(children)
end

#deduplicate_semx_label(source_node, semx_node) ⇒ Object



1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
# File 'lib/metanorma/html/base_renderer.rb', line 1312

def deduplicate_semx_label(source_node, semx_node)
  first_text = semx_node.children.find { |c| c.text? && !c.text.strip.empty? }
  return unless first_text

  semx_prefix = first_text.text[/\A(\s*\S+)/, 1]
  return unless semx_prefix && !semx_prefix.strip.empty?

  prev = source_node.previous_sibling
  return unless prev.is_a?(Nokogiri::XML::Text)

  label = semx_prefix.strip
  prev_text = prev.text.rstrip
  return unless prev_text.end_with?(label)

  prev.content = prev_text.sub(/#{Regexp.escape(label)}\s*\z/, "")
  first_text.content = first_text.text.sub(/\A\s*#{Regexp.escape(label)}\s*/, "")
end

#deduplicate_semx_text(semx_text, output) ⇒ Object



1255
1256
1257
1258
1259
1260
1261
1262
1263
# File 'lib/metanorma/html/base_renderer.rb', line 1255

def deduplicate_semx_text(semx_text, output)
  first_word = semx_text[/\A\s*(\S+)/, 1]
  return semx_text unless first_word

  tail = output[-200..]
  return semx_text unless tail&.rstrip&.end_with?(first_word)

  semx_text.sub(/\A\s*#{Regexp.escape(first_word)}\s*/, "")
end

#element_attrs(**attrs) ⇒ Object



1459
1460
1461
1462
1463
1464
1465
1466
1467
# File 'lib/metanorma/html/base_renderer.rb', line 1459

def element_attrs(**attrs)
  parts = []
  attrs.each do |k, v|
    next if v.nil? || v == false || (v.is_a?(String) && v.empty?)

    parts << %( #{k}="#{escape_html(v.to_s)}")
  end
  parts.join
end

#escape_html(text) ⇒ Object



1549
1550
1551
# File 'lib/metanorma/html/base_renderer.rb', line 1549

def escape_html(text)
  CGI.escapeHTML(text.to_s)
end

#extract_block_label(block, default) ⇒ Object



1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
# File 'lib/metanorma/html/base_renderer.rb', line 1516

def extract_block_label(block, default)
  # Presentation XML puts label in <name> child element
  names = safe_attr(block, :name)
  if names && !names.empty?
    name = names.is_a?(Array) ? names.first : names
    text = extract_text_value(name)
    return text unless text.to_s.strip.empty?
  end

  # Fallback: autonum XML attribute
  autonum = safe_attr(block, :autonum)
  if autonum && !autonum.to_s.empty?
    number = autonum.to_s
    return "#{default} #{number}"
  end

  default
end

#extract_plain_text(node) ⇒ Object



421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
# File 'lib/metanorma/html/base_renderer.rb', line 421

def extract_plain_text(node)
  return node.to_s if node.is_a?(String)
  return extract_text_value(node).to_s unless node.is_a?(Lutaml::Model::Serializable)

  parts = []
  xml_mapping = node.class.mappings_for(:xml, node.lutaml_register)

  if node.element_order.is_a?(Array) && xml_mapping
    element_to_attr = {}
    xml_mapping.mapping_elements_hash.each_value do |rule_or_array|
      Array(rule_or_array).each do |rule|
        element_to_attr[rule.name.to_s] = rule.to
      end
    end

    indices = Hash.new(0)
    node.element_order.each do |el|
      next unless el.is_a?(Lutaml::Xml::Element)

      if el.text?
        parts << el.text_content.to_s
      elsif el.name == "tab"
        parts << "\u00A0\u00A0"
      elsif el.name == "br"
        parts << " "
      elsif el.element?
        attr_name = element_to_attr[el.name]
        next unless attr_name

        coll = node.public_send(attr_name)
        obj = if coll.is_a?(Array)
                idx = indices[attr_name]
                indices[attr_name] += 1
                coll[idx]
              else
                coll
              end
        parts << extract_plain_text(obj) if obj
      end
    end
  end

  # Fallback: try .text
  if parts.join.strip.empty?
    t = safe_attr(node, :text)
    parts << (t.is_a?(Array) ? t.join : t.to_s) if t
  end

  parts.join.strip.gsub("\u00A0", " ")
end

#extract_primary_doc_idObject



387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
# File 'lib/metanorma/html/base_renderer.rb', line 387

def extract_primary_doc_id
  bibdata = @document.bibdata
  return nil unless bibdata

  identifiers = bibdata.doc_identifier
  return nil unless identifiers && !identifiers.empty?

  first_id = identifiers.first
  text = if first_id.is_a?(String)
           first_id
         elsif first_id.is_a?(Lutaml::Model::Serializable)
           Array(first_id.value).join
         else
           first_id.to_s
         end
  text.strip.empty? ? nil : text.strip
end

#extract_text_value(val) ⇒ Object



1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
# File 'lib/metanorma/html/base_renderer.rb', line 1553

def extract_text_value(val)
  return nil if val.nil?
  return val if val.is_a?(String)

  if val.is_a?(Array)
    val.map { |v| extract_text_value(v) }.join
  elsif val.is_a?(Lutaml::Model::Serializable)
    c = safe_attr(val, :content)
    if c && !c.equal?(val)
      extract_text_value(c)
    else
      t = safe_attr(val, :text)
      if t
        extract_text_value(t)
      else
        v = safe_attr(val, :value)
        if v
          extract_text_value(v)
        else
          val.to_s
        end
      end
    end
  else
    val.to_s
  end
end

#extract_title_text(titles) ⇒ Object



1541
1542
1543
1544
1545
1546
1547
# File 'lib/metanorma/html/base_renderer.rb', line 1541

def extract_title_text(titles)
  return "" if titles.nil? || titles.empty?

  titles = Array(titles)
  title = titles.first
  extract_text_value(title).to_s
end

#flavor_css_moduleObject



297
298
299
# File 'lib/metanorma/html/base_renderer.rb', line 297

def flavor_css_module
  nil
end

#flavor_font_urlObject



163
164
165
# File 'lib/metanorma/html/base_renderer.rb', line 163

def flavor_font_url
  theme.font_url
end

#flavor_js_moduleObject



293
294
295
# File 'lib/metanorma/html/base_renderer.rb', line 293

def flavor_js_module
  nil
end

#flavor_publisher_nameObject



153
154
155
156
# File 'lib/metanorma/html/base_renderer.rb', line 153

def flavor_publisher_name
  pubs = flavor_publishers(extract_primary_doc_id)
  pubs.empty? ? nil : pubs.join("/")
end

#flavor_publishers(_doc_id) ⇒ Object



149
150
151
# File 'lib/metanorma/html/base_renderer.rb', line 149

def flavor_publishers(_doc_id)
  []
end

#generate_full_document(document) ⇒ Object

Generate a complete HTML document from a presentation XML document.



132
133
134
135
136
137
138
139
140
141
# File 'lib/metanorma/html/base_renderer.rb', line 132

def generate_full_document(document)
  @document = document
  validate_presentation_xml!

  # First pass: render body content (collects ToC entries as side effect)
  render(@document)
  body = @output

  assemble_document(body)
end

#header_title_textObject



218
219
220
221
# File 'lib/metanorma/html/base_renderer.rb', line 218

def header_title_text
  raw = html_title.to_s.split("").first.to_s.gsub(/<[^>]+>/, "")
  raw.length > 60 ? "#{raw[0, 57]}..." : raw
end

#html_class_for_span(xml_class) ⇒ Object



1487
1488
1489
# File 'lib/metanorma/html/base_renderer.rb', line 1487

def html_class_for_span(xml_class)
  SPAN_ROLE_CLASSES[xml_class] || "span-#{xml_class}"
end

#html_titleObject



374
375
376
377
378
379
380
381
382
383
384
385
# File 'lib/metanorma/html/base_renderer.rb', line 374

def html_title
  bibdata = @document.bibdata
  return "Document" unless bibdata

  titles = bibdata.titles
  if titles
    title = bibdata.title_for("en")
    title.to_s
  else
    "Document"
  end
end

#languageObject

— Metadata extraction —



361
362
363
364
365
366
367
368
369
370
371
372
# File 'lib/metanorma/html/base_renderer.rb', line 361

def language
  bibdata = @document.bibdata
  return "en" unless bibdata

  langs = bibdata.language
  if langs && !langs.empty?
    lang = langs.find { |l| l.current == "true" } || langs.first
    lang.value || lang.to_s
  else
    "en"
  end
end

#load_logo_svg(filename, height: 32) ⇒ Object



242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
# File 'lib/metanorma/html/base_renderer.rb', line 242

def load_logo_svg(filename, height: 32)
  path = File.join(LOGO_DIR, filename)
  return nil unless File.exist?(path)

  svg = File.read(path)
  svg = svg.sub(/\A<\?xml[^?]*\?>\s*/, "")
  svg = svg.sub(/\A\s*<!--.*?-->\s*/m, "")
  svg = svg.sub(/<path[^>]*style="fill:#e3000f[^"]*"[^>]*\/>/, "")
  svg = svg.sub(/<svg\s/, '<svg class="header-logo" ')
  svg = if svg.match?(/<svg[^>]*\sheight="[^"]*"/)
          svg.sub(/(<svg[^>]*?)(\sheight="[^"]*")/, "\\1 height=\"#{height}\"")
        else
          svg.sub(/(<svg\b)/, "\\1 height=\"#{height}\"")
        end
  svg.sub(/(<svg[^>]*?)\swidth="[^"]*"/, '\1')
rescue StandardError
  nil
end

#lookup_dispatch(type_class, registry_method) ⇒ Object



570
571
572
573
574
575
576
577
578
579
# File 'lib/metanorma/html/base_renderer.rb', line 570

def lookup_dispatch(type_class, registry_method)
  self.class.ancestors.each do |ancestor|
    next unless ancestor.is_a?(Class) && (ancestor == BaseRenderer || ancestor < BaseRenderer)

    registry = ancestor.public_send(registry_method)
    method_name = registry[type_class]
    return method_name if method_name
  end
  nil
end

#publisher_logo_mapObject

Map of publisher name => logo filename. Override in flavor renderers.



159
160
161
# File 'lib/metanorma/html/base_renderer.rb', line 159

def publisher_logo_map
  {}
end

#raw_content_node?(node) ⇒ Boolean

Returns:

  • (Boolean)


1055
1056
1057
1058
1059
1060
# File 'lib/metanorma/html/base_renderer.rb', line 1055

def raw_content_node?(node)
  node.is_a?(Lutaml::Model::Serializable) &&
    node.content.is_a?(String)
rescue NoMethodError
  false
end

#register_figure_entry(id:, text:) ⇒ Object



411
412
413
# File 'lib/metanorma/html/base_renderer.rb', line 411

def register_figure_entry(id:, text:)
  @figure_entries << { id: id, text: text }
end

#register_table_entry(id:, text:) ⇒ Object



415
416
417
# File 'lib/metanorma/html/base_renderer.rb', line 415

def register_table_entry(id:, text:)
  @table_entries << { id: id, text: text }
end

#register_toc_entry(id:, level:, text:) ⇒ Object

— CSS loader —



407
408
409
# File 'lib/metanorma/html/base_renderer.rb', line 407

def register_toc_entry(id:, level:, text:)
  @toc_entries << { id: id, level: level, text: text }
end

#render(node) ⇒ Object

Dispatch to the appropriate render method via type registry. Lookups traverse the ancestor chain so subclasses inherit parent registrations and can override them independently.



475
476
477
478
479
480
# File 'lib/metanorma/html/base_renderer.rb', line 475

def render(node, **)
  return escape_html(node) if node.is_a?(String)

  method = lookup_dispatch(node.class, :render_registry)
  method ? public_send(method, node, **) : ""
end

#render_admonition(admonition, **_opts) ⇒ Object



868
869
870
871
# File 'lib/metanorma/html/base_renderer.rb', line 868

def render_admonition(admonition, **_opts)
  drop = Drops::AdmonitionDrop.from_model(admonition, renderer: renderer_context)
  @output << render_liquid("_admonition.html.liquid", { "block" => drop })
end

#render_asciimath(el) ⇒ Object



1166
1167
1168
# File 'lib/metanorma/html/base_renderer.rb', line 1166

def render_asciimath(el)
  @output << %(<span class="stem">#{escape_html(Array(el.text).join)}</span>)
end

#render_audio(audio) ⇒ Object



826
827
828
829
830
831
832
# File 'lib/metanorma/html/base_renderer.rb', line 826

def render_audio(audio)
  attrs = element_attrs(
    id: safe_attr(audio, :id),
    src: safe_attr(audio, :src),
  )
  @output << "<audio#{attrs} controls></audio>"
end

#render_basic_section(section, level: 1, **_opts) ⇒ Object

— Section rendering —



879
880
881
882
883
884
885
886
# File 'lib/metanorma/html/base_renderer.rb', line 879

def render_basic_section(section, level: 1, **_opts)
  attrs = element_attrs(id: safe_attr(section, :id))
  tag("div", attrs) do
    render_section_title(section, level)
    section.blocks&.each { |block| render(block) }
    section.notes&.each { |note| render_note(note) }
  end
end

#render_bookmark(bookmark, **_opts) ⇒ Object



873
874
875
# File 'lib/metanorma/html/base_renderer.rb', line 873

def render_bookmark(bookmark, **_opts)
  @output << %(<a id="#{escape_html(safe_attr(bookmark, :id).to_s)}"></a>)
end

#render_brObject



1121
1122
1123
# File 'lib/metanorma/html/base_renderer.rb', line 1121

def render_br(*)
  @output << "<br />"
end

#render_cell_content(cell) ⇒ Object

Table cells can contain both block-level content (p, ul, ol, dl) and inline content (text, em, strong, etc.) in document order.



1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
# File 'lib/metanorma/html/base_renderer.rb', line 1009

def render_cell_content(cell)
  walked = walk_ordered(cell) do |type, obj|
    case type
    when :text
      @output << escape_html(obj)
    when :tab
      @output << "\u00a0\u00a0"
    when :element
      if block_element?(obj)
        render(obj)
      else
        render_inline_element(obj)
      end
    end
  end
  unless walked
    render_mixed_content_in_order(cell)
  end
end

#render_commaObject



1158
1159
1160
# File 'lib/metanorma/html/base_renderer.rb', line 1158

def render_comma(*)
  @output << ", "
end

#render_concept(concept) ⇒ Object



1374
1375
1376
# File 'lib/metanorma/html/base_renderer.rb', line 1374

def render_concept(concept)
  render_mixed_inline(concept)
end

#render_content_section(section, level: 1, **_opts) ⇒ Object



897
898
899
# File 'lib/metanorma/html/base_renderer.rb', line 897

def render_content_section(section, level: 1, **_opts)
  render_hierarchical_section(section, level: level, **_opts)
end

#render_definition_list(dl, **_opts) ⇒ Object



784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
# File 'lib/metanorma/html/base_renderer.rb', line 784

def render_definition_list(dl, **_opts)
  attrs = element_attrs(id: safe_attr(dl, :id))
  tag("dl", attrs) do
    dl.dt&.each_with_index do |dt, i|
      @output << "<dt>"
      render_mixed_inline(dt)
      @output << "</dt>"
      dd = dl.dd&.[](i)
      if dd
        @output << "<dd>"
        render_mixed_inline(dd)
        @output << "</dd>"
      end
    end
  end
end

#render_em(el) ⇒ Object

Inline adapter methods for registry dispatch



1089
1090
1091
# File 'lib/metanorma/html/base_renderer.rb', line 1089

def render_em(el)
  render_inline_tag("em", el)
end

#render_eref(eref) ⇒ Object



1350
1351
1352
1353
1354
1355
1356
1357
# File 'lib/metanorma/html/base_renderer.rb', line 1350

def render_eref(eref)
  citeas = safe_attr(eref, :citeas)
  if citeas
    @output << escape_html(citeas)
  else
    render_mixed_inline(eref)
  end
end

#render_example(example, **_opts) ⇒ Object



839
840
841
842
# File 'lib/metanorma/html/base_renderer.rb', line 839

def render_example(example, **_opts)
  drop = Drops::ExampleDrop.from_model(example, renderer: renderer_context)
  @output << render_liquid("_example.html.liquid", { "block" => drop })
end

#render_figure(figure, **_opts) ⇒ Object



801
802
803
804
# File 'lib/metanorma/html/base_renderer.rb', line 801

def render_figure(figure, **_opts)
  drop = Drops::FigureDrop.from_model(figure, renderer: renderer_context)
  @output << render_liquid("_figure.html.liquid", { "block" => drop })
end

#render_fmt_stem(fmt_stem) ⇒ Object

— Stem/math rendering —



1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
# File 'lib/metanorma/html/base_renderer.rb', line 1380

def render_fmt_stem(fmt_stem)
  semx_items = Array(fmt_stem.semx)
  return if semx_items.empty?

  semx = semx_items.first
  math_items = Array(semx.math)
  ascii_items = Array(semx.asciimath)

  # Collect source formats for interactive copy dropdown
  source_formats = {}
  if ascii_items.any?
    ascii_text = ascii_items.map { |a| a.text.to_s.strip }.join
    source_formats["asciimath"] = ascii_text unless ascii_text.empty?
  end

  data_attrs = ""
  unless source_formats.empty?
    data_attrs = " data-stem-formats='#{escape_html(source_formats.to_json)}'"
  end

  if math_items.any?
    content = math_items.map { |m| m.content.to_s }.join
    unless content.empty?
      @output << "<span class=\"math-container\"#{data_attrs}>"
      @output << "<math xmlns=\"http://www.w3.org/1998/Math/MathML\">#{content}</math>"
      @output << "</span>"
    end
  elsif ascii_items.any?
    # No MathML — render asciimath as fallback
    text = ascii_items.map { |a| a.text.to_s.strip }.join
    @output << "<span class=\"stem\"#{data_attrs}>#{escape_html(text)}</span>"
  end
end

#render_fmt_xref(el) ⇒ Object



1148
1149
1150
1151
1152
1153
1154
1155
1156
# File 'lib/metanorma/html/base_renderer.rb', line 1148

def render_fmt_xref(el)
  target = safe_attr(el, :target) || safe_attr(el, :to_attr)
  if target
    attrs = element_attrs(href: "##{escape_html(target)}", class: "xref")
    tag("a", attrs) { render_mixed_inline(el) }
  else
    render_mixed_inline(el)
  end
end

#render_fn(fn) ⇒ Object



1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
# File 'lib/metanorma/html/base_renderer.rb', line 1359

def render_fn(fn)
  fn_id = safe_attr(fn, :id)
  number = @footnote_collector.register(fn)

  attrs = element_attrs(id: fn_id, class: "fn-marker")
  tag("span", attrs) do
    label = safe_attr(fn, :fn_label) || safe_attr(fn, :reference)
    if label
      @output << "<sup><a href=\"##{escape_html("footnote-#{number}")}\" " \
                 "class=\"fn-link\" id=\"#{escape_html("fnref-#{number}")}\">" \
                 "#{escape_html(label.to_s)}</a></sup>"
    end
  end
end

#render_fn_inline(el) ⇒ Object



1136
1137
1138
# File 'lib/metanorma/html/base_renderer.rb', line 1136

def render_fn_inline(el)
  render_fn(el)
end

#render_footnotes_sectionObject



1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
# File 'lib/metanorma/html/base_renderer.rb', line 1581

def render_footnotes_section
  return if @footnote_collector.empty?

  drops = @footnote_collector.to_a.map do |entry|
    content_html = ""
    if entry.content && !entry.content.empty?
      content_html = capture_output do
        Array(entry.content).each { |p| render_paragraph(p) }
      end
    end
    Drops::FootnoteDrop.new(entry, content_html)
  end

  @output << render_liquid("_footnotes.html.liquid", { "footnotes" => drops })
end

#render_formula(formula, **_opts) ⇒ Object



849
850
851
852
# File 'lib/metanorma/html/base_renderer.rb', line 849

def render_formula(formula, **_opts)
  drop = Drops::FormulaDrop.from_model(formula, renderer: renderer_context)
  @output << render_liquid("_formula.html.liquid", { "block" => drop })
end

#render_hierarchical_section(section, level: 1, **_opts) ⇒ Object



888
889
890
891
892
893
894
895
# File 'lib/metanorma/html/base_renderer.rb', line 888

def render_hierarchical_section(section, level: 1, **_opts)
  attrs = element_attrs(id: safe_attr(section, :id))
  tag("div", attrs) do
    render_section_title(section, level)
    render_section_content(section, level)
    section.subsections&.each { |sub| render(sub, level: level + 1) }
  end
end

#render_image(image) ⇒ Object



806
807
808
809
810
811
812
813
814
815
816
# File 'lib/metanorma/html/base_renderer.rb', line 806

def render_image(image)
  src_val = safe_attr(image, :src) || safe_attr(image, :source)
  attrs = element_attrs(
    id: safe_attr(image, :id),
    src: src_val,
    alt: safe_attr(image, :alt),
    height: safe_attr(image, :height),
    width: safe_attr(image, :width),
  )
  @output << "<img#{attrs} />"
end

#render_index(el) ⇒ Object



1170
1171
1172
1173
# File 'lib/metanorma/html/base_renderer.rb', line 1170

def render_index(el)
  collect_index_term(el)
  ""
end

#render_inline_collections(node) ⇒ Object



1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
# File 'lib/metanorma/html/base_renderer.rb', line 1179

def render_inline_collections(node)
  texts = node.text
  if texts.is_a?(Array)
    texts.each do |t|
      if t.is_a?(String)
        @output << escape_html(t)
      else
        render_inline_element(t)
      end
    end
  elsif texts.is_a?(String)
    @output << escape_html(texts)
  end

  inline_attrs = %i[em strong smallcap sub sup tt underline strike
                    xref eref link span stem concept fn br tab keyword
                    fmt_annotation_start fmt_annotation_end
                    fmt_stem fmt_fn_label fmt_concept
                    bookmark image semx fmt_xref_label]
  inline_attrs.each do |attr|
    values = safe_attr(node, attr)
    next if values.nil?

    Array(values).each { |v| render_inline_element(v) }
  end
end

#render_inline_element(element) ⇒ Object

Dispatch to the appropriate inline render method via type registry.



483
484
485
486
487
488
489
490
491
492
493
# File 'lib/metanorma/html/base_renderer.rb', line 483

def render_inline_element(element, **)
  return "" if element.nil?
  return escape_html(element) if element.is_a?(String)

  method = lookup_dispatch(element.class, :inline_registry)
  if method
    public_send(method, element)
  elsif element.is_a?(Lutaml::Model::Serializable) && element.mixed?
    render_mixed_inline(element)
  end
end

#render_inline_tag(tag_name, element, **extra_attrs) ⇒ Object



1265
1266
1267
# File 'lib/metanorma/html/base_renderer.rb', line 1265

def render_inline_tag(tag_name, element, **extra_attrs)
  tag(tag_name, element_attrs(**extra_attrs)) { render_mixed_inline(element) }
end


1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
# File 'lib/metanorma/html/base_renderer.rb', line 1330

def render_link(link)
  target = safe_attr(link, :target) || safe_attr(link, :href)
  attrs = element_attrs(href: target, id: safe_attr(link, :id))
  tag("a", attrs) do
    content = safe_attr(link, :content)
    if content && !Array(content).join.strip.empty?
      render_mixed_inline(link)
    else
      display_text = target.to_s.delete_prefix("mailto:")
      @output << escape_html(display_text)
    end
  end
end

#render_liquid(template_name, assigns) ⇒ Object



172
173
174
175
176
177
178
179
# File 'lib/metanorma/html/base_renderer.rb', line 172

def render_liquid(template_name, assigns)
  template_path = File.join(TEMPLATES_ROOT, template_name)
  template = TEMPLATE_CACHE_MUTEX.synchronize do
    TEMPLATE_CACHE[template_path] ||= Liquid::Template.parse(File.read(template_path))
  end
  assigns = assigns.transform_keys(&:to_s) if assigns.is_a?(Hash)
  template.render(assigns)
end

#render_list_item(li) ⇒ Object



708
709
710
711
712
713
714
# File 'lib/metanorma/html/base_renderer.rb', line 708

def render_list_item(li)
  li_id = safe_attr(li, :id)
  attrs = li_id ? %( id="#{escape_html(li_id)}") : ""
  @output << "<li#{attrs}>"
  render_mixed_content_in_order(li)
  @output << "</li>"
end

#render_math(el) ⇒ Object



1162
1163
1164
# File 'lib/metanorma/html/base_renderer.rb', line 1162

def render_math(el)
  @output << el.content.to_s
end

#render_mixed_content_in_order(node) ⇒ Object

Render all children of a mixed-content node in document order, dispatching block elements to their render methods.



718
719
720
721
722
723
724
725
726
727
728
729
730
731
# File 'lib/metanorma/html/base_renderer.rb', line 718

def render_mixed_content_in_order(node)
  node.each_mixed_content do |child|
    case child
    when String
      @output << escape_html(child)
    else
      if block_element?(child)
        render(child)
      else
        render_inline_element(child)
      end
    end
  end
end

#render_mixed_inline(node) ⇒ Object



1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
# File 'lib/metanorma/html/base_renderer.rb', line 1029

def render_mixed_inline(node)
  # Models using map_all_content (e.g. RawParagraph): raw XML in content
  if raw_content_node?(node)
    raw = node.content
    if raw.is_a?(String) && !raw.strip.empty?
      @output << render_raw_content(raw)
      return
    end
  end

  if node.is_a?(Lutaml::Model::Serializable) && node.element_order && !node.element_order.empty?
    render_ordered_inline(node)
  elsif node.is_a?(Lutaml::Model::Serializable)
    node.each_mixed_content do |child|
      case child
      when String
        @output << escape_html(child)
      else
        render_inline_element(child)
      end
    end
  else
    render_inline_collections(node)
  end
end

#render_noopObject



581
582
583
# File 'lib/metanorma/html/base_renderer.rb', line 581

def render_noop(*)
  ""
end

#render_noop_inlineObject



585
586
587
# File 'lib/metanorma/html/base_renderer.rb', line 585

def render_noop_inline(*)
  nil
end

#render_note(note, **_opts) ⇒ Object



834
835
836
837
# File 'lib/metanorma/html/base_renderer.rb', line 834

def render_note(note, **_opts)
  drop = Drops::NoteDrop.from_model(note, renderer: renderer_context)
  @output << render_liquid("_note.html.liquid", { "block" => drop })
end

#render_note_inline(el) ⇒ Object



1175
1176
1177
# File 'lib/metanorma/html/base_renderer.rb', line 1175

def render_note_inline(el)
  render_note(el)
end

#render_ordered_content(section, level = 1) ⇒ Object

Render children of a section in displayorder, skipping title elements.



762
763
764
765
766
767
768
769
770
# File 'lib/metanorma/html/base_renderer.rb', line 762

def render_ordered_content(section, level = 1)
  children = collect_ordered_children(section)
  children.each do |node|
    next if node.is_a?(String)
    next if is_title_element?(node, section)

    render(node, level: level + 1)
  end
end

#render_ordered_inline(node) ⇒ Object

Iterate element_order directly, preserving whitespace text nodes that each_mixed_content drops (it skips text where text.strip.empty?)



1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
# File 'lib/metanorma/html/base_renderer.rb', line 1064

def render_ordered_inline(node)
  walked = walk_ordered(node) do |type, obj|
    case type
    when :text
      @output << escape_html(obj)
    when :tab
      @output << "\u00a0\u00a0"
    when :element
      render_inline_element(obj)
    end
  end
  unless walked
    node.each_mixed_content do |child|
      case child
      when String
        @output << escape_html(child)
      else
        render_inline_element(child)
      end
    end
  end
end

#render_ordered_list(ol, **_opts) ⇒ Object



701
702
703
704
705
706
# File 'lib/metanorma/html/base_renderer.rb', line 701

def render_ordered_list(ol, **_opts)
  attrs = element_attrs(id: safe_attr(ol, :id), start: safe_attr(ol, :start), type: safe_attr(ol, :type_attr))
  tag("ol", attrs) do
    ol.listitem&.each { |li| render_list_item(li) }
  end
end

#render_paragraph(p, **_opts) ⇒ Object

— Block-level rendering —



591
592
593
594
# File 'lib/metanorma/html/base_renderer.rb', line 591

def render_paragraph(p, **_opts)
  attrs = element_attrs(id: safe_attr(p, :id), style: alignment_style(safe_attr(p, :alignment)))
  tag("p", attrs) { render_mixed_inline(p) }
end

#render_quote(quote, **_opts) ⇒ Object



854
855
856
857
858
859
860
861
862
863
864
865
866
# File 'lib/metanorma/html/base_renderer.rb', line 854

def render_quote(quote, **_opts)
  attrs = element_attrs(id: safe_attr(quote, :id), class: "quote")
  tag("blockquote", attrs) do
    quote.paragraphs&.each { |para| render_paragraph(para) }
    quote.ul&.each { |ul| render_unordered_list(ul) }
    quote.ol&.each { |ol| render_ordered_list(ol) }
    if quote.attribution
      @output << "<footer>"
      render_mixed_inline(quote.attribution)
      @output << "</footer>"
    end
  end
end

#render_raw_content(raw_xml) ⇒ Object

Process raw XML content from map_all_content models (e.g. RawParagraph). Strips source elements (xref, eref, stem) that have a following <semx> wrapper, keeping only the semx display content.



1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
# File 'lib/metanorma/html/base_renderer.rb', line 1272

def render_raw_content(raw_xml)
  doc = Nokogiri::XML.fragment(raw_xml)
  # Convert fmt-link elements to HTML <a> tags before stripping wrappers
  doc.css("fmt-link").each do |el|
    target = el["target"] || el["href"]
    if target
      display_text = target.delete_prefix("mailto:")
      a = doc.document.create_element("a", display_text, "href" => target)
      el.replace(a)
    else
      el.replace(el.children)
    end
  end
  # Remove source elements that precede a <semx> sibling,
  # deduplicating any label text that appears in both the source
  # paragraph and the semx display content.
  doc.traverse do |node|
    next unless node.element?
    next unless %w[xref eref stem link].include?(node.name)

    next_sib = node.next_sibling
    while next_sib.is_a?(Nokogiri::XML::Text) && next_sib.text.strip.empty?
      next_sib = next_sib.next_sibling
    end
    next unless next_sib&.element? && next_sib.name == "semx"

    deduplicate_semx_label(node, next_sib)
    node.remove
  end
  # Strip presentation wrappers, keeping inner content
  %w[semx fmt-xref].each do |tag|
    doc.css(tag).each { |el| el.replace(el.children) }
  end
  # Remap XML class names to HTML-specific class names
  doc.css("[class]").each do |el|
    el["class"] = el["class"].split(/\s+/).map { |c| html_class_for_span(c) }.join(" ")
  end
  doc.inner_html
end

#render_section_content(section, _level) ⇒ Object



910
911
912
913
# File 'lib/metanorma/html/base_renderer.rb', line 910

def render_section_content(section, _level)
  section.blocks&.each { |block| render(block) }
  section.notes&.each { |note| render_note(note) }
end

#render_section_title(section, level) ⇒ Object



901
902
903
904
905
906
907
908
# File 'lib/metanorma/html/base_renderer.rb', line 901

def render_section_title(section, level)
  titles = section.title
  return unless titles && !titles.empty?

  h = "h#{[[level, 6].min, 1].max}"
  title_text = extract_title_text(titles)
  @output << "<#{h}>#{escape_html(title_text)}</#{h}>"
end

#render_semx_content(element, **_opts) ⇒ Object

Render SemxElement display content only, skipping semantic linkage. semx wraps both semantic data (origin, xref, source, etc.) and display content (fmt-xref, span, strong, etc.). Only render display.



1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
# File 'lib/metanorma/html/base_renderer.rb', line 1209

def render_semx_content(element, **_opts)
  display_attrs = %i[text fmt_xref fmt_link fmt_concept span strong em sup p semx
                     asciimath math sub_child tt_child br_child tab_child
                     stem_child figure_child formula_child sourcecode_child]
  label_stripped = false

  walked = walk_ordered(element, allow_filter: display_attrs) do |type, obj|
    case type
    when :text
      text = obj
      if !label_stripped
        text = deduplicate_semx_text(text, @output)
        label_stripped = true
      end
      @output << escape_html(text)
    when :element
      if obj.is_a?(Metanorma::Document::Components::Paragraphs::ParagraphBlock)
        render_paragraph(obj)
      else
        render_inline_element(obj)
      end
    end
  end

  unless walked
    display_attrs.each do |attr|
      val = safe_attr(element, attr)
      next if val.nil?

      if val.is_a?(Array)
        val.each do |v|
          if v.is_a?(Metanorma::Document::Components::Paragraphs::ParagraphBlock)
            render_paragraph(v)
          else
            render_inline_element(v)
          end
        end
      elsif val.is_a?(String)
        @output << escape_html(val)
      else
        render_inline_element(val)
      end
    end
  end
end

#render_semx_inline(el) ⇒ Object



1144
1145
1146
# File 'lib/metanorma/html/base_renderer.rb', line 1144

def render_semx_inline(el)
  render_semx_content(el)
end

#render_small_caps(el) ⇒ Object



1109
1110
1111
# File 'lib/metanorma/html/base_renderer.rb', line 1109

def render_small_caps(el)
  render_inline_tag("span", el, class: "small-caps")
end

#render_sourcecode(sc, **_opts) ⇒ Object



844
845
846
847
# File 'lib/metanorma/html/base_renderer.rb', line 844

def render_sourcecode(sc, **_opts)
  drop = Drops::SourcecodeDrop.from_model(sc, renderer: renderer_context)
  @output << render_liquid("_sourcecode.html.liquid", { "block" => drop })
end

#render_span(el) ⇒ Object



1129
1130
1131
1132
1133
1134
# File 'lib/metanorma/html/base_renderer.rb', line 1129

def render_span(el)
  xml_class = safe_attr(el, :class_attr).to_s
  html_class = html_class_for_span(xml_class) unless xml_class.empty?
  attrs = element_attrs(style: safe_attr(el, :style), class: html_class)
  tag("span", attrs) { render_mixed_inline(el) }
end

#render_stem(el) ⇒ Object



1140
1141
1142
# File 'lib/metanorma/html/base_renderer.rb', line 1140

def render_stem(el)
  @output << render_stem_content(el)
end

#render_stem_content(stem) ⇒ Object



1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
# File 'lib/metanorma/html/base_renderer.rb', line 1414

def render_stem_content(stem)
  return "" if stem.nil?

  # StemInlineElement — source element, skip (rendered via FmtStemElement)
  if stem.is_a?(Metanorma::Document::Components::Inline::StemInlineElement)
    return ""
  end

  # FmtStemElement — already handled by render_fmt_stem
  if stem.is_a?(Metanorma::Document::Components::Inline::FmtStemElement)
    return ""
  end

  # TextElements::StemElement — block math (no fmt- counterpart for display formulas)
  if stem.is_a?(Metanorma::Document::Components::TextElements::StemElement)
    if stem.math
      begin
        return stem.math.to_xml
      rescue StandardError
        math_items = Array(stem.math)
        return math_items.map { |m| m.is_a?(Lutaml::Model::Serializable) ? m.content.to_s : m.to_s }.join
      end
    end
    if stem.asciimath
      text = extract_text_value(stem.asciimath)
      return %(<span class="stem">#{escape_html(text)}</span>)
    end
    if stem.latexmath
      text = extract_text_value(stem.latexmath)
      return %(<span class="stem">#{escape_html(text)}</span>)
    end
  end

  text = extract_text_value(stem)
  text.empty? ? "" : %(<span class="stem">#{escape_html(text)}</span>)
end

#render_strike(el) ⇒ Object



1117
1118
1119
# File 'lib/metanorma/html/base_renderer.rb', line 1117

def render_strike(el)
  render_inline_tag("s", el)
end

#render_strong(el) ⇒ Object



1093
1094
1095
# File 'lib/metanorma/html/base_renderer.rb', line 1093

def render_strong(el)
  render_inline_tag("strong", el)
end

#render_sub(el) ⇒ Object



1101
1102
1103
# File 'lib/metanorma/html/base_renderer.rb', line 1101

def render_sub(el)
  render_inline_tag("sub", el)
end

#render_sup(el) ⇒ Object



1105
1106
1107
# File 'lib/metanorma/html/base_renderer.rb', line 1105

def render_sup(el)
  render_inline_tag("sup", el)
end

#render_tabObject



1125
1126
1127
# File 'lib/metanorma/html/base_renderer.rb', line 1125

def render_tab(*)
  @output << "\u00a0\u00a0"
end

#render_table(table, **_opts) ⇒ Object



596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
# File 'lib/metanorma/html/base_renderer.rb', line 596

def render_table(table, **_opts)
  attrs = element_attrs(id: safe_attr(table, :id), class: "table-block")
  table_id = safe_attr(table, :id)
  name_el = safe_attr(table, :fmt_name) || safe_attr(table, :name)
  if table_id && name_el
    register_table_entry(id: table_id, text: extract_plain_text(name_el))
  end
  col_count = table_column_count(table)
  @output << "<div class=\"table-scroll-wrapper\">"
  tag("table", attrs) do
    name_el = safe_attr(table, :fmt_name) || safe_attr(table, :name)
    if name_el
      @output << "<caption>"
      render_inline_element(name_el)
      @output << "</caption>"
    end
    @output << "<colgroup>" if table.colgroup
    render_table_colgroup(table.colgroup) if table.colgroup
    @output << "</colgroup>" if table.colgroup
    render_table_section(table.thead, "thead") if table.thead
    render_table_section(table.tbody, "tbody") if table.tbody
    if table.tfoot || (table.note && !table.note.empty?)
      @output << "<tfoot>"
      render_table_section_rows(table.tfoot) if table.tfoot
      if table.note && !table.note.empty?
        @output << "<tr><td colspan=\"#{col_count}\" class=\"table-notes\">"
        table.note.each { |n| render_note(n) }
        @output << "</td></tr>"
      end
      @output << "</tfoot>"
    end
  end
  @output << "</div>"
end

#render_table_cell(cell, force_tag = nil) ⇒ Object



688
689
690
691
692
693
694
695
696
697
698
699
# File 'lib/metanorma/html/base_renderer.rb', line 688

def render_table_cell(cell, force_tag = nil)
  tag_name = force_tag || (cell.is_a?(Metanorma::Document::Components::Tables::HeaderTableCell) ? "th" : "td")
  attrs = element_attrs(
    colspan: safe_attr(cell, :colspan),
    rowspan: safe_attr(cell, :rowspan),
    align: safe_attr(cell, :alignment),
    valign: safe_attr(cell, :vertical_alignment),
  )
  @output << "<#{tag_name}#{attrs}>"
  render_cell_content(cell)
  @output << "</#{tag_name}>"
end

#render_table_colgroup(colgroup) ⇒ Object



652
653
654
655
656
657
# File 'lib/metanorma/html/base_renderer.rb', line 652

def render_table_colgroup(colgroup)
  colgroup.col&.each do |col|
    attrs = element_attrs(style: col.width ? "width: #{col.width}" : nil)
    @output << "<col#{attrs}>"
  end
end

#render_table_section(section, tag_name) ⇒ Object



659
660
661
662
663
# File 'lib/metanorma/html/base_renderer.rb', line 659

def render_table_section(section, tag_name)
  @output << "<#{tag_name}>"
  render_table_section_rows(section)
  @output << "</#{tag_name}>"
end

#render_table_section_rows(section) ⇒ Object



665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
# File 'lib/metanorma/html/base_renderer.rb', line 665

def render_table_section_rows(section)
  section.tr&.each do |tr|
    @output << "<tr>"
    walked = walk_ordered(tr) do |type, obj|
      next unless type == :element

      render_table_cell(obj)
    end
    unless walked
      Array(tr.th).each { |th| render_table_cell(th, "th") }
      Array(tr.td).each { |td| render_table_cell(td, "td") }
    end
    @output << "</tr>"
  end
end

#render_tt(el) ⇒ Object



1097
1098
1099
# File 'lib/metanorma/html/base_renderer.rb', line 1097

def render_tt(el)
  render_inline_tag("tt", el)
end

#render_underline(el) ⇒ Object



1113
1114
1115
# File 'lib/metanorma/html/base_renderer.rb', line 1113

def render_underline(el)
  render_inline_tag("u", el)
end

#render_unordered_list(ul, **_opts) ⇒ Object



681
682
683
684
685
686
# File 'lib/metanorma/html/base_renderer.rb', line 681

def render_unordered_list(ul, **_opts)
  attrs = element_attrs(id: safe_attr(ul, :id))
  tag("ul", attrs) do
    ul.listitem&.each { |li| render_list_item(li) }
  end
end

#render_video(video) ⇒ Object



818
819
820
821
822
823
824
# File 'lib/metanorma/html/base_renderer.rb', line 818

def render_video(video)
  attrs = element_attrs(
    id: safe_attr(video, :id),
    src: safe_attr(video, :src),
  )
  @output << "<video#{attrs} controls></video>"
end

#render_xref(xref) ⇒ Object



1344
1345
1346
1347
1348
# File 'lib/metanorma/html/base_renderer.rb', line 1344

def render_xref(xref)
  target = safe_attr(xref, :target) || safe_attr(xref, :to_attr)
  attrs = element_attrs(href: "##{escape_html(target)}", id: safe_attr(xref, :id))
  tag("a", attrs) { render_mixed_inline(xref) }
end

#renderer_contextObject



119
120
121
# File 'lib/metanorma/html/base_renderer.rb', line 119

def renderer_context
  @renderer_context ||= RendererContext.new(self)
end

#safe_attr(obj, method_name) ⇒ Object



1495
1496
1497
1498
1499
# File 'lib/metanorma/html/base_renderer.rb', line 1495

def safe_attr(obj, method_name)
  obj.public_send(method_name)
rescue NoMethodError
  nil
end

#sort_by_displayorder(children) ⇒ Object



772
773
774
775
776
777
778
779
780
781
782
# File 'lib/metanorma/html/base_renderer.rb', line 772

def sort_by_displayorder(children)
  children.sort_by do |node|
    order = begin
      node.displayorder
    rescue StandardError
      nil
    end
    order &&= order.to_i
    order || Float::INFINITY
  end
end

#table_column_count(table) ⇒ Object



631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
# File 'lib/metanorma/html/base_renderer.rb', line 631

def table_column_count(table)
  if table.colgroup&.col && !table.colgroup.col.empty?
    return table.colgroup.col.size
  end

  # Walk all rows to find max column count, accounting for colspan
  max_cols = 0
  %i[thead tbody tfoot].each do |section|
    sec = table.public_send(section)
    next unless sec&.tr

    sec.tr.each do |tr|
      cols = 0
      Array(tr.th).each { |th| cols += th.colspan && th.colspan > 1 ? th.colspan : 1 }
      Array(tr.td).each { |td| cols += td.colspan && td.colspan > 1 ? td.colspan : 1 }
      max_cols = cols if cols > max_cols
    end
  end
  max_cols.positive? ? max_cols : 1
end

#tag(name, attrs_str) ⇒ Object

— Helper methods —



1453
1454
1455
1456
1457
# File 'lib/metanorma/html/base_renderer.rb', line 1453

def tag(name, attrs_str)
  @output << "<#{name}#{attrs_str}>"
  yield
  @output << "</#{name}>"
end

#themeObject

— Flavor configuration hooks (override in subclasses) —



145
146
147
# File 'lib/metanorma/html/base_renderer.rb', line 145

def theme
  @theme ||= Theme.new
end

#to_htmlObject



123
124
125
# File 'lib/metanorma/html/base_renderer.rb', line 123

def to_html
  @output
end

#toc_entriesObject



127
128
129
# File 'lib/metanorma/html/base_renderer.rb', line 127

def toc_entries
  @toc_entries
end

#validate_presentation_xml!Object

— Validation —

Raises:

  • (ArgumentError)


309
310
311
312
313
314
315
316
317
# File 'lib/metanorma/html/base_renderer.rb', line 309

def validate_presentation_xml!
  has_presentation = check_presentation_markers(@document)
  return if has_presentation

  raise ArgumentError,
        "HTML generation requires Presentation XML input. " \
        "Semantic XML does not contain formatting data needed for HTML. " \
        "Use a '.presentation.xml' file instead."
end

#walk_ordered(node, allow_filter: nil) ⇒ Object

Walk element_order in document order, resolving each element to its Ruby object via the XML mapping, and yielding (element_order_entry, resolved_object) to the given block. Handles tab elements as nbsp. Skips elements not in the mapping.

This is the single ordered-walk primitive used by all mixed content renderers:

  • render_mixed_inline (inline-only)

  • render_cell_content (mixed block/inline)

  • render_semx_content (filtered display attrs only)



925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
# File 'lib/metanorma/html/base_renderer.rb', line 925

def walk_ordered(node, allow_filter: nil)
  return false unless node.is_a?(Lutaml::Model::Serializable)
  return false unless node.element_order.is_a?(Array) && !node.element_order.empty?

  xml_mapping = node.class.mappings_for(:xml, node.lutaml_register)
  return false unless xml_mapping

  element_to_attr = {}
  xml_mapping.mapping_elements_hash.each_value do |rule_or_array|
    Array(rule_or_array).each do |rule|
      element_to_attr[rule.name] = rule.to
      element_to_attr[rule.name.to_s] = rule.to if rule.name.is_a?(Symbol)
    end
  end

  skip_indices = build_semx_skip_set(node)

  indices = Hash.new(0)

  node.element_order.each_with_index do |el, i|
    next if skip_indices.include?(i)

    if el.text?
      text = el.text_content
      yield :text, text if text && block_given?
    elsif el.element?
      # Handle <tab/> elements
      if el.name == "tab"
        yield :tab, nil if block_given?
        next
      end

      attr_name = element_to_attr[el.name]
      next unless attr_name

      # Apply optional filter (used by semx to skip semantic attrs)
      next if allow_filter && !allow_filter.include?(attr_name)

      coll = node.public_send(attr_name)
      obj = if coll.is_a?(Array)
              idx = indices[attr_name]
              indices[attr_name] += 1
              coll[idx]
            else
              coll
            end
      yield :element, obj if obj && block_given?
    end
  end
  true
end