metanorma-document 0.3.0 → 0.5.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.
Files changed (197) hide show
  1. checksums.yaml +4 -4
  2. data/BUGS.sts/08-list-items-duplicate-bullets.md +45 -0
  3. data/CLAUDE.md +3 -2
  4. data/Rakefile +30 -5
  5. data/data/stylesheets/components/header.css +3 -5
  6. data/data/stylesheets/components/lists.css +14 -0
  7. data/frontend/dist/app.css +1 -0
  8. data/frontend/dist/app.iife.js +24 -0
  9. data/frontend/package-lock.json +7 -505
  10. data/frontend/tailwind.config.js +5 -0
  11. data/lib/metanorma/{oiml_document → csa_document}/root.rb +2 -2
  12. data/lib/metanorma/csa_document.rb +9 -0
  13. data/lib/metanorma/document/cli.rb +52 -0
  14. data/lib/metanorma/document/components/ancillary_blocks/sourcecode_body.rb +14 -0
  15. data/lib/metanorma/document/components/inline/comma_element.rb +3 -0
  16. data/lib/metanorma/document/components/inline/enum_comma_element.rb +3 -0
  17. data/lib/metanorma/document/components/inline/eref_element.rb +4 -1
  18. data/lib/metanorma/document/components/inline/fmt_termsource_element.rb +4 -2
  19. data/lib/metanorma/document/components/inline/math_element.rb +13 -7
  20. data/lib/metanorma/document/components/inline/vocabulary.rb +4 -0
  21. data/lib/metanorma/document/components/multi_paragraph/admonition_block.rb +6 -1
  22. data/lib/metanorma/document/components/paragraphs/paragraph_block.rb +3 -0
  23. data/lib/metanorma/document/components/reference_elements/callout.rb +3 -2
  24. data/lib/metanorma/document/components/tables/table_cell.rb +3 -0
  25. data/lib/metanorma/document/elements/input.rb +44 -0
  26. data/lib/metanorma/document/elements.rb +1 -0
  27. data/lib/metanorma/document/relaton/address.rb +5 -0
  28. data/lib/metanorma/document/relaton/affiliation.rb +3 -0
  29. data/lib/metanorma/document/relaton/bib_item_size.rb +8 -9
  30. data/lib/metanorma/document/relaton/bib_item_type.rb +3 -0
  31. data/lib/metanorma/document/relaton/bibliographic_date.rb +5 -0
  32. data/lib/metanorma/document/relaton/contact_method.rb +5 -0
  33. data/lib/metanorma/document/relaton/contribution_info.rb +6 -2
  34. data/lib/metanorma/document/relaton/contributor.rb +4 -0
  35. data/lib/metanorma/document/relaton/contributor_role.rb +3 -0
  36. data/lib/metanorma/document/relaton/copyright_association.rb +3 -0
  37. data/lib/metanorma/document/relaton/copyright_owner.rb +3 -0
  38. data/lib/metanorma/document/relaton/date_time.rb +3 -0
  39. data/lib/metanorma/document/relaton/document_identifier.rb +4 -0
  40. data/lib/metanorma/document/relaton/document_relation.rb +8 -0
  41. data/lib/metanorma/document/relaton/document_status.rb +4 -0
  42. data/lib/metanorma/document/relaton/formatted_address.rb +4 -0
  43. data/lib/metanorma/document/relaton/full_name.rb +14 -3
  44. data/lib/metanorma/document/relaton/keyword_type.rb +3 -0
  45. data/lib/metanorma/document/relaton/locality_stack.rb +6 -9
  46. data/lib/metanorma/document/relaton/localized_name.rb +8 -5
  47. data/lib/metanorma/document/relaton/logo_element.rb +5 -0
  48. data/lib/metanorma/document/relaton/medium_type.rb +5 -16
  49. data/lib/metanorma/document/relaton/org_identifier.rb +2 -0
  50. data/lib/metanorma/document/relaton/org_subdivision.rb +2 -0
  51. data/lib/metanorma/document/relaton/org_subdivision_identifier.rb +2 -0
  52. data/lib/metanorma/document/relaton/organization.rb +3 -0
  53. data/lib/metanorma/document/relaton/person.rb +7 -1
  54. data/lib/metanorma/document/relaton/person_identifier.rb +3 -0
  55. data/lib/metanorma/document/relaton/place_type.rb +5 -0
  56. data/lib/metanorma/document/relaton/region_element.rb +3 -9
  57. data/lib/metanorma/document/relaton/relaton_collection.rb +2 -0
  58. data/lib/metanorma/document/relaton/series_type.rb +7 -0
  59. data/lib/metanorma/document/relaton/typed_note.rb +4 -0
  60. data/lib/metanorma/document/relaton/typed_title_string.rb +4 -0
  61. data/lib/metanorma/document/relaton/typed_uri.rb +3 -8
  62. data/lib/metanorma/document/relaton/validity_type.rb +8 -10
  63. data/lib/metanorma/document/relaton/variant_full_name.rb +3 -0
  64. data/lib/metanorma/document/relaton/variant_org_name.rb +3 -0
  65. data/lib/metanorma/document/relaton/version_info.rb +5 -0
  66. data/lib/metanorma/document/relaton/vocab_id_type.rb +6 -12
  67. data/lib/metanorma/document/relaton.rb +0 -14
  68. data/lib/metanorma/document/version.rb +1 -1
  69. data/lib/metanorma/document.rb +2 -1
  70. data/lib/metanorma/html/asset_pipeline.rb +1 -0
  71. data/lib/metanorma/html/base_renderer.rb +106 -236
  72. data/lib/metanorma/html/concerns/metadata_extraction.rb +61 -0
  73. data/lib/metanorma/html/concerns/presentation_validation.rb +61 -0
  74. data/lib/metanorma/html/concerns/svg_processing.rb +32 -0
  75. data/lib/metanorma/html/concerns/text_extraction.rb +93 -0
  76. data/lib/metanorma/html/concerns/toc_registry.rb +47 -0
  77. data/lib/metanorma/html/csa_renderer.rb +9 -0
  78. data/lib/metanorma/html/drops/figure_drop.rb +33 -10
  79. data/lib/metanorma/html/drops/sourcecode_drop.rb +3 -6
  80. data/lib/metanorma/html/flavor.rb +6 -1
  81. data/lib/metanorma/html/generator.rb +8 -8
  82. data/lib/metanorma/html/iso_renderer.rb +0 -142
  83. data/lib/metanorma/html/renderers/block_renderer.rb +41 -5
  84. data/lib/metanorma/html/renderers/element_order_traversal.rb +15 -1
  85. data/lib/metanorma/html/renderers/inline_renderer.rb +16 -76
  86. data/lib/metanorma/html/standard_renderer.rb +195 -35
  87. data/lib/metanorma/html/templates/_list_item.html.liquid +1 -1
  88. data/lib/metanorma/html/theme.rb +38 -11
  89. data/lib/metanorma/html.rb +1 -52
  90. data/lib/metanorma/iso_document/sections/iso_preface.rb +27 -13
  91. data/lib/metanorma/mirror/default_registry.rb +21 -0
  92. data/lib/metanorma/mirror/handlers/inline/catalog.rb +7 -12
  93. data/lib/metanorma/mirror/handlers/inline/rich_html_renderer.rb +2 -3
  94. data/lib/metanorma/mirror/handlers/sourcecode.rb +1 -1
  95. data/lib/metanorma/mirror/model/guide.rb +3 -2
  96. data/lib/metanorma/mirror/output/formats/inline_format.rb +64 -3
  97. data/lib/metanorma/mirror/output/pipeline.rb +1 -0
  98. data/lib/metanorma/mirror/output.rb +0 -2
  99. data/lib/metanorma/mirror/rewriter.rb +3 -1
  100. data/lib/metanorma/registers/setup.rb +6 -2
  101. data/lib/metanorma/standard_document/annotation_container.rb +2 -0
  102. data/lib/metanorma/standard_document/elements/input.rb +4 -25
  103. data/lib/metanorma/standard_document/metadata/metanorma_extension.rb +12 -2
  104. data/lib/metanorma/standard_document/metadata/presentation_metadata.rb +107 -0
  105. data/lib/metanorma/standard_document/metadata/semantic_metadata.rb +18 -0
  106. data/lib/metanorma/standard_document/metadata/unitsml/amount_of_substance.rb +19 -0
  107. data/lib/metanorma/standard_document/metadata/unitsml/base_quantity.rb +34 -0
  108. data/lib/metanorma/standard_document/metadata/unitsml/dimension.rb +39 -0
  109. data/lib/metanorma/standard_document/metadata/unitsml/dimension_set.rb +19 -0
  110. data/lib/metanorma/standard_document/metadata/unitsml/electric_current.rb +19 -0
  111. data/lib/metanorma/standard_document/metadata/unitsml/enumerated_root_unit.rb +23 -0
  112. data/lib/metanorma/standard_document/metadata/unitsml/length.rb +19 -0
  113. data/lib/metanorma/standard_document/metadata/unitsml/luminous_intensity.rb +19 -0
  114. data/lib/metanorma/standard_document/metadata/unitsml/mass.rb +19 -0
  115. data/lib/metanorma/standard_document/metadata/unitsml/namespace.rb +17 -0
  116. data/lib/metanorma/standard_document/metadata/unitsml/plane_angle.rb +19 -0
  117. data/lib/metanorma/standard_document/metadata/unitsml/prefix.rb +30 -0
  118. data/lib/metanorma/standard_document/metadata/unitsml/prefix_name.rb +21 -0
  119. data/lib/metanorma/standard_document/metadata/unitsml/prefix_set.rb +19 -0
  120. data/lib/metanorma/standard_document/metadata/unitsml/prefix_symbol.rb +21 -0
  121. data/lib/metanorma/standard_document/metadata/unitsml/quantity.rb +28 -0
  122. data/lib/metanorma/standard_document/metadata/unitsml/quantity_name.rb +21 -0
  123. data/lib/metanorma/standard_document/metadata/unitsml/quantity_set.rb +19 -0
  124. data/lib/metanorma/standard_document/metadata/unitsml/root_units.rb +19 -0
  125. data/lib/metanorma/standard_document/metadata/unitsml/thermodynamic_temperature.rb +19 -0
  126. data/lib/metanorma/standard_document/metadata/unitsml/time.rb +19 -0
  127. data/lib/metanorma/standard_document/metadata/unitsml/unit.rb +32 -0
  128. data/lib/metanorma/standard_document/metadata/unitsml/unit_name.rb +21 -0
  129. data/lib/metanorma/standard_document/metadata/unitsml/unit_set.rb +19 -0
  130. data/lib/metanorma/standard_document/metadata/unitsml/unit_symbol.rb +36 -0
  131. data/lib/metanorma/standard_document/metadata/unitsml/unit_system.rb +23 -0
  132. data/lib/metanorma/standard_document/metadata/unitsml/unitsml_root.rb +28 -0
  133. data/lib/metanorma/standard_document/metadata/unitsml.rb +39 -0
  134. data/lib/metanorma/standard_document/metadata.rb +4 -0
  135. data/lib/metanorma/standard_document/sections/misc_container.rb +8 -6
  136. data/lib/metanorma/standard_document/sections/sections.rb +7 -0
  137. data/lib/metanorma/un_document/blocks/un_admonition_block.rb +86 -0
  138. data/lib/metanorma/un_document/blocks.rb +9 -0
  139. data/lib/metanorma/un_document/root.rb +8 -0
  140. data/lib/metanorma/un_document/sections/un_abstract_section.rb +48 -0
  141. data/lib/metanorma/un_document/sections/un_preface.rb +19 -2
  142. data/lib/metanorma/un_document/sections.rb +1 -0
  143. data/lib/metanorma/un_document/un_text_element.rb +116 -0
  144. data/lib/metanorma/un_document.rb +2 -0
  145. data/script/benchmark_render.rb +35 -0
  146. data/tasks/roundtrip_samples.rake +2 -2
  147. metadata +65 -54
  148. data/.rubocop_todo.yml +0 -220
  149. data/TODO.finalize/01-split-multi-class-files.md +0 -8
  150. data/TODO.finalize/02-remove-data-dist-iso-document-frontend.md +0 -6
  151. data/TODO.finalize/03-align-frontend-iife-and-gha-build.md +0 -11
  152. data/TODO.finalize/04-theme-per-flavor-config-driven.md +0 -9
  153. data/TODO.finalize/05-migrate-all-html-to-liquid.md +0 -16
  154. data/TODO.finalize/06-integrate-pubid-library.md +0 -16
  155. data/TODO.finalize/07-replace-relaton-models-with-relaton-bib.md +0 -39
  156. data/TODO.finalize/08-refactor-renderer-architecture.md +0 -25
  157. data/TODO.finalize/09-eliminate-code-smells.md +0 -14
  158. data/TODO.finalize/10-comprehensive-spec-coverage.md +0 -24
  159. data/TODO.finalize/11-config-driven-flavor-metadata.md +0 -20
  160. data/TODO.finalize/12-add-frozen-string-literal.md +0 -6
  161. data/TODO.finalize/13-extract-composition-from-god-class.md +0 -96
  162. data/TODO.finalize/14-config-driven-section-ordering.md +0 -31
  163. data/TODO.finalize/15-theme-directories-per-flavor.md +0 -46
  164. data/TODO.finalize/16-theme-lutaml-model-schema.md +0 -44
  165. data/data/logos/oiml-logo-full-dark.svg +0 -1
  166. data/data/logos/oiml-logo-full-light.svg +0 -1
  167. data/data/logos/oiml-logo-icon-dark.svg +0 -1
  168. data/data/logos/oiml-logo-icon-light.svg +0 -1
  169. data/data/logos/oiml-logo.svg +0 -1
  170. data/data/themes/oiml.yaml +0 -56
  171. data/docs/html-renderer.adoc +0 -261
  172. data/lib/data/dist/iso_document/frontend/assets/index-mwzbzmnK.js +0 -22
  173. data/lib/data/dist/iso_document/frontend/assets/index-pGvKTNid.css +0 -1
  174. data/lib/data/dist/iso_document/frontend/assets/iso-red.svg +0 -53
  175. data/lib/data/dist/iso_document/frontend/index.html +0 -13
  176. data/lib/metanorma/document/relaton/bib_item_size_type.rb +0 -21
  177. data/lib/metanorma/document/relaton/bibliographic_date_type.rb +0 -22
  178. data/lib/metanorma/document/relaton/document_relation_type.rb +0 -31
  179. data/lib/metanorma/document/relaton/iso4217_code.rb +0 -17
  180. data/lib/metanorma/document/relaton/iso8601_date.rb +0 -17
  181. data/lib/metanorma/document/relaton/personal_identifier_type.rb +0 -21
  182. data/lib/metanorma/document/relaton/region_type.rb +0 -20
  183. data/lib/metanorma/document/relaton/series_type_type.rb +0 -21
  184. data/lib/metanorma/document/relaton/specific_locality_type.rb +0 -25
  185. data/lib/metanorma/document/relaton/title_type.rb +0 -21
  186. data/lib/metanorma/html/oiml_renderer.rb +0 -33
  187. data/lib/metanorma/mirror/output/html_renderer.rb +0 -95
  188. data/lib/metanorma/mirror/output/html_renderers/block_renderers.rb +0 -178
  189. data/lib/metanorma/mirror/output/html_renderers/inline_renderer.rb +0 -61
  190. data/lib/metanorma/mirror/output/html_renderers/list_renderers.rb +0 -96
  191. data/lib/metanorma/mirror/output/html_renderers/mark_renderers.rb +0 -41
  192. data/lib/metanorma/mirror/output/html_renderers/section_renderers.rb +0 -129
  193. data/lib/metanorma/mirror/output/html_renderers/structural_renderers.rb +0 -80
  194. data/lib/metanorma/mirror/output/html_renderers/table_renderers.rb +0 -87
  195. data/lib/metanorma/mirror/output/html_renderers.rb +0 -76
  196. data/lib/metanorma/oiml_document.rb +0 -9
  197. data/lib/metanorma/standard_document/annotation.rb +0 -37
@@ -15,7 +15,23 @@ module Metanorma
15
15
  "metanorma/html/renderers/element_order_traversal"
16
16
  end
17
17
 
18
+ module Concerns
19
+ autoload :MetadataExtraction,
20
+ "metanorma/html/concerns/metadata_extraction"
21
+ autoload :PresentationValidation,
22
+ "metanorma/html/concerns/presentation_validation"
23
+ autoload :SvgProcessing, "metanorma/html/concerns/svg_processing"
24
+ autoload :TextExtraction, "metanorma/html/concerns/text_extraction"
25
+ autoload :TocRegistry, "metanorma/html/concerns/toc_registry"
26
+ end
27
+
18
28
  class BaseRenderer
29
+ include Concerns::MetadataExtraction
30
+ include Concerns::PresentationValidation
31
+ include Concerns::SvgProcessing
32
+ include Concerns::TextExtraction
33
+ include Concerns::TocRegistry
34
+
19
35
  LOGO_DIR = File.expand_path("../../../data/logos", __dir__)
20
36
 
21
37
  SPAN_ROLE_CLASSES = {
@@ -115,14 +131,11 @@ module Metanorma
115
131
  @renderer_context ||= RendererContext.new(self)
116
132
  end
117
133
 
118
- def toc_entries
119
- @toc_entries
120
- end
121
-
122
134
  attr_writer :document, :theme
123
135
 
124
- def generate_full_document(document, **)
136
+ def generate_full_document(document, theme: nil, **)
125
137
  @document = document
138
+ @theme_override = theme
126
139
  validate_presentation_xml!
127
140
 
128
141
  body = render(@document) || ""
@@ -130,6 +143,14 @@ module Metanorma
130
143
  assemble_document(body)
131
144
  end
132
145
 
146
+ # Renders only the document body content (no html/head/styles
147
+ # assembly) — for embedding classic-rendered content into a host page.
148
+ def generate_body(document, **)
149
+ @document = document
150
+ validate_presentation_xml!
151
+ render(@document) || ""
152
+ end
153
+
133
154
  # --- Flavor configuration hooks ---
134
155
 
135
156
  def theme
@@ -137,10 +158,27 @@ module Metanorma
137
158
  end
138
159
 
139
160
  def resolve_theme
161
+ return load_theme_override(@theme_override) if @theme_override
162
+
140
163
  flavor = flavor_name
141
164
  flavor ? Theme.load(flavor) : Theme.new
142
165
  end
143
166
 
167
+ # A theme override given to Generator.generate: a Theme instance,
168
+ # a path to a theme directory (theme.yaml with assets/, templates/
169
+ # and custom.css alongside), or a path to a bare theme.yaml file.
170
+ # This is how an organization customizes the output without
171
+ # changing any code.
172
+ def load_theme_override(override)
173
+ return override if override.is_a?(Theme)
174
+
175
+ if File.directory?(override)
176
+ Theme.from_theme_dir(override)
177
+ else
178
+ Theme.from_file(override)
179
+ end
180
+ end
181
+
144
182
  def flavor_name
145
183
  return nil unless defined?(@document) && @document
146
184
 
@@ -257,25 +295,6 @@ module Metanorma
257
295
  end.join("\n")
258
296
  end
259
297
 
260
- def load_logo_svg(filename, height: 32)
261
- path = theme.resolve_asset(filename) || File.join(LOGO_DIR, filename)
262
- return nil unless File.exist?(path)
263
-
264
- svg = File.read(path)
265
- svg = svg.sub(/\A<\?xml[^?]*\?>\s*/, "")
266
- svg = svg.sub(/\A\s*<!--.*?-->\s*/m, "")
267
- svg = svg.sub(/<svg\s/, '<svg class="header-logo" ')
268
- svg = if svg.match?(/<svg[^>]*\sheight="[^"]*"/)
269
- svg.sub(/(<svg[^>]*?)(\sheight="[^"]*")/,
270
- "\\1 height=\"#{height}\"")
271
- else
272
- svg.sub(/(<svg\b)/, "\\1 height=\"#{height}\"")
273
- end
274
- svg.sub(/(<svg[^>]*?)\swidth="[^"]*"/, '\1')
275
- rescue StandardError
276
- nil
277
- end
278
-
279
298
  def build_footer
280
299
  mn_logo = load_logo_svg(METANORMA_LOGO, height: 20)
281
300
  render_liquid("_footer.html.liquid", {
@@ -284,22 +303,6 @@ module Metanorma
284
303
  })
285
304
  end
286
305
 
287
- # --- ToC generation ---
288
-
289
- def build_toc_html(entries)
290
- entry_drops = entries.map { |e| Drops::TocEntryDrop.new(e) }
291
- figure_drops = @figure_entries.map { |f| Drops::FigureListEntryDrop.new(f) }
292
- table_drops = @table_entries.map { |t| Drops::FigureListEntryDrop.new(t) }
293
- has_special_lists = !@figure_entries.empty? || !@table_entries.empty?
294
-
295
- render_liquid("_toc.html.liquid", {
296
- "entries" => entry_drops,
297
- "figures" => figure_drops,
298
- "tables" => table_drops,
299
- "has_special_lists" => has_special_lists,
300
- })
301
- end
302
-
303
306
  # --- Scripts ---
304
307
 
305
308
  def build_scripts
@@ -325,177 +328,28 @@ module Metanorma
325
328
  parts.join("\n")
326
329
  end
327
330
 
328
- # --- Validation ---
329
-
330
- def validate_presentation_xml!
331
- has_presentation = check_presentation_markers(@document)
332
- return if has_presentation
333
-
334
- raise ArgumentError,
335
- "HTML generation requires Presentation XML input. " \
336
- "Semantic XML does not contain formatting data needed for HTML. " \
337
- "Use a '.presentation.xml' file instead."
338
- end
339
-
340
- def check_presentation_markers(node)
341
- return false unless node
342
- return false if node.is_a?(String)
343
-
344
- if node.is_a?(Lutaml::Model::Serializable)
345
- node_attrs = node.class.attributes
346
- if node_attrs.key?(:type) && node.type == "presentation"
347
- return true
348
- end
349
- if node_attrs.key?(:fmt_title) && node.fmt_title
350
- return true
351
- end
352
- if node_attrs.key?(:displayorder) && node.displayorder
353
- return true
354
- end
355
-
356
- %i[preface sections annex bibliography].each do |attr|
357
- next unless node_attrs.key?(attr)
358
-
359
- val = node.public_send(attr)
360
- next unless val
361
-
362
- Array(val).each { |v| return true if check_presentation_markers(v) }
363
- end
364
-
365
- node.each_mixed_content do |child|
366
- next if child.is_a?(String)
367
- return true if check_presentation_markers(child)
368
- end
369
- end
370
-
371
- false
372
- end
373
-
374
- # --- Metadata extraction ---
375
-
376
- def language
377
- bibdata = @document.bibdata
378
- return "en" unless bibdata
379
-
380
- langs = bibdata.language
381
- if langs && !langs.empty?
382
- lang = langs.find { |l| l.current == "true" } || langs.first
383
- lang.value || lang.to_s
384
- else
385
- "en"
386
- end
387
- end
388
-
389
- def html_title
390
- extract_display_title(@document.bibdata) || "Document"
391
- end
392
-
393
- def extract_display_title(bibdata)
394
- return nil unless bibdata
395
-
396
- title = bibdata.title_for("en") if bibdata.is_a?(Metanorma::Document::Components::BibData::BibData)
397
- return title.to_s if title && !title.to_s.empty?
398
-
399
- titles = safe_attr(bibdata, :title)
400
- return nil unless titles && !titles.is_a?(String) && !titles.empty?
401
-
402
- en = titles.find { |t| safe_attr(t, :language) == "en" }
403
- found = en || titles.first
404
- extract_text_value(found).to_s
405
- end
406
-
407
- def extract_primary_doc_id
408
- bibdata = @document.bibdata
409
- return nil unless bibdata
410
-
411
- identifiers = bibdata.doc_identifier
412
- return nil unless identifiers && !identifiers.empty?
413
-
414
- first_id = identifiers.first
415
- text = if first_id.is_a?(String)
416
- first_id
417
- elsif first_id.is_a?(Lutaml::Model::Serializable)
418
- Array(first_id.value).join
419
- else
420
- first_id.to_s
421
- end
422
- text.strip.empty? ? nil : text.strip
423
- end
424
-
425
- # --- Registration helpers ---
426
-
427
- def register_toc_entry(id:, level:, text:)
428
- @toc_entries << { id: id, level: level, text: text }
429
- end
430
-
431
- def register_figure_entry(id:, text:)
432
- @figure_entries << { id: id, text: text }
433
- end
434
-
435
- def figure_entries
436
- @figure_entries
437
- end
438
-
439
- def register_table_entry(id:, text:)
440
- @table_entries << { id: id, text: text }
441
- end
442
-
443
- def extract_plain_text(node)
444
- return node.to_s if node.is_a?(String)
445
- return extract_text_value(node).to_s unless node.is_a?(Lutaml::Model::Serializable)
446
-
447
- parts = []
448
- xml_mapping = node.class.mappings_for(:xml, node.lutaml_register)
449
-
450
- if node.element_order.is_a?(Array) && xml_mapping
451
- element_to_attr =
452
- Renderers::ElementOrderTraversal.element_to_attr_map(xml_mapping)
453
-
454
- indices = Hash.new(0)
455
- node.element_order.each do |el|
456
- next unless el.is_a?(Lutaml::Xml::Element)
457
-
458
- if el.text?
459
- parts << el.text_content.to_s
460
- elsif el.name == "tab"
461
- parts << " "
462
- elsif el.name == "br"
463
- parts << " "
464
- elsif el.element?
465
- attr_name = element_to_attr[el.name]
466
- if attr_name
467
- coll = node.public_send(attr_name)
468
- obj = if coll.is_a?(Array)
469
- idx = indices[attr_name]
470
- indices[attr_name] += 1
471
- coll[idx]
472
- else
473
- coll
474
- end
475
- text = extract_plain_text(obj)
476
- parts << (text.empty? ? " " : text)
477
- elsif el.name == "span"
478
- parts << " "
479
- end
480
- end
481
- end
482
- end
483
-
484
- if parts.join.strip.empty?
485
- t = safe_attr(node, :text)
486
- parts << (t.is_a?(Array) ? t.join : t.to_s) if t
487
- end
488
-
489
- parts.join.strip.gsub(" ", " ")
490
- end
491
-
492
331
  # --- Dispatch ---
493
332
 
494
333
  def render(node, **)
495
334
  return escape_html(node) if node.is_a?(String)
496
335
 
497
336
  method = lookup_dispatch(node.class, :render_registry)
498
- method ? public_send(method, node, **) : ""
337
+ return public_send(method, node, **) if method
338
+
339
+ record_render_warning(
340
+ "no renderer registered for #{node.class} — content skipped",
341
+ )
342
+ ""
343
+ end
344
+
345
+ # Emit a render warning once per distinct message. Warnings mark
346
+ # content that was skipped or degraded rather than rendered.
347
+ def record_render_warning(message)
348
+ @render_warnings ||= {}
349
+ return if @render_warnings.key?(message)
350
+
351
+ @render_warnings[message] = true
352
+ warn "metanorma-document: #{message}"
499
353
  end
500
354
 
501
355
  def render_inline_element(element, **)
@@ -527,6 +381,8 @@ module Metanorma
527
381
  :render_note
528
382
  register_render Metanorma::Document::Components::AncillaryBlocks::ExampleBlock,
529
383
  :render_example
384
+ register_render Metanorma::StandardDocument::Blocks::Form,
385
+ :render_form
530
386
  register_render Metanorma::Document::Components::AncillaryBlocks::SourcecodeBlock,
531
387
  :render_sourcecode
532
388
  register_render Metanorma::Document::Components::AncillaryBlocks::FormulaBlock,
@@ -592,6 +448,8 @@ module Metanorma
592
448
  :render_fmt_stem
593
449
  register_inline_render Metanorma::Document::Components::Inline::CommaElement,
594
450
  :render_comma
451
+ register_inline_render Metanorma::StandardDocument::Elements::Input,
452
+ :render_input
595
453
  register_inline_render Metanorma::Document::Components::Inline::EnumCommaElement,
596
454
  :render_comma
597
455
  register_inline_render Metanorma::Document::Components::IdElements::Bookmark,
@@ -643,7 +501,31 @@ module Metanorma
643
501
  register_inline_render klass, :render_mixed_inline
644
502
  end
645
503
 
504
+ # Inline-ish classes that are also reached through the BLOCK
505
+ # dispatch (as direct children of block containers, e.g. fmt-title
506
+ # as a child of clause). fmt-title and variant-title duplicate the
507
+ # semantic `title` attribute that sections render — skip them.
508
+ register_render Metanorma::Document::Components::Inline::FmtTitleElement,
509
+ :render_noop
510
+ register_render Metanorma::Document::Components::Inline::VariantTitleElement,
511
+ :render_noop
512
+ # fmt-xref-label carries the cross-reference link label; render it
513
+ # through when it appears in block context.
514
+ register_render Metanorma::Document::Components::Inline::FmtXrefLabelElement,
515
+ :render_block_inline_content
516
+
646
517
  def lookup_dispatch(type_class, registry_method)
518
+ # Registry contents are fixed once renderer classes are loaded, so
519
+ # the resolved method per (registry, node class) is memoized for
520
+ # the lifetime of this renderer instance.
521
+ cache = (@dispatch_cache ||= {})
522
+ key = [registry_method, type_class]
523
+ return cache[key] if cache.key?(key)
524
+
525
+ cache[key] = resolve_dispatch(type_class, registry_method)
526
+ end
527
+
528
+ def resolve_dispatch(type_class, registry_method)
647
529
  self.class.ancestors.each do |ancestor|
648
530
  next unless ancestor.is_a?(Class) && (ancestor == BaseRenderer || ancestor < BaseRenderer)
649
531
 
@@ -658,6 +540,13 @@ module Metanorma
658
540
  ""
659
541
  end
660
542
 
543
+ # Block-dispatch entry point for inline content reached as a direct
544
+ # child of a block container (render passes keyword args; the inline
545
+ # pipeline does not accept them).
546
+ def render_block_inline_content(el, **)
547
+ render_mixed_inline(el)
548
+ end
549
+
661
550
  def render_noop_inline(*)
662
551
  nil
663
552
  end
@@ -690,6 +579,7 @@ module Metanorma
690
579
  def render_semx_inline(el) = @inline_renderer.render_semx_inline(el)
691
580
  def render_fmt_xref(el) = @inline_renderer.render_fmt_xref(el)
692
581
  def render_comma(*) = @inline_renderer.render_comma
582
+ def render_input(el) = @inline_renderer.render_input(el)
693
583
  def render_math(el) = @inline_renderer.render_math(el)
694
584
  def render_asciimath(el) = @inline_renderer.render_asciimath(el)
695
585
  def render_index(el) = @inline_renderer.render_index(el)
@@ -707,7 +597,7 @@ module Metanorma
707
597
  def render_fn(fn) = @inline_renderer.render_fn(fn)
708
598
  def render_concept(concept) = @inline_renderer.render_concept(concept)
709
599
  def render_fmt_stem(fmt_stem) = @inline_renderer.render_fmt_stem(fmt_stem)
710
- def render_mixed_content_in_order(node) = @inline_renderer.render_mixed_content_in_order(node)
600
+ def render_mixed_content_in_order(node, **) = @inline_renderer.render_mixed_content_in_order(node, **)
711
601
 
712
602
  # Block rendering delegation
713
603
  def render_paragraph(p,
@@ -750,6 +640,8 @@ module Metanorma
750
640
  @block_renderer.render_example(example, **)
751
641
  end
752
642
 
643
+ def render_form(form, **) = @block_renderer.render_form(form, **)
644
+
753
645
  def render_sourcecode(sc,
754
646
  **)
755
647
  @block_renderer.render_sourcecode(sc, **)
@@ -861,7 +753,12 @@ level = 1)
861
753
  end
862
754
 
863
755
  obj.public_send(method_name)
864
- rescue NoMethodError
756
+ rescue NoMethodError => e
757
+ # Only swallow "method missing on this object" (duck-typing across
758
+ # model classes). A NoMethodError raised *inside* the getter is a
759
+ # real bug — let it surface.
760
+ raise unless e.receiver.equal?(obj)
761
+
865
762
  nil
866
763
  end
867
764
 
@@ -876,7 +773,8 @@ level = 1)
876
773
  target_id: @current_section_id,
877
774
  target_text: @current_section_number,
878
775
  )
879
- rescue StandardError
776
+ rescue StandardError => e
777
+ record_render_warning("index term dropped: #{e.class}: #{e.message}")
880
778
  nil
881
779
  end
882
780
 
@@ -916,34 +814,6 @@ level = 1)
916
814
  CGI.escapeHTML(text.to_s)
917
815
  end
918
816
 
919
- def extract_text_value(val)
920
- return nil if val.nil?
921
- return val if val.is_a?(String)
922
-
923
- if val.is_a?(Array)
924
- val.map { |v| extract_text_value(v) }.join
925
- elsif val.is_a?(Lutaml::Model::Serializable)
926
- c = safe_attr(val, :content)
927
- if c && !c.equal?(val)
928
- extract_text_value(c)
929
- else
930
- t = safe_attr(val, :text)
931
- if t
932
- extract_text_value(t)
933
- else
934
- v = safe_attr(val, :value)
935
- if v
936
- extract_text_value(v)
937
- else
938
- val.to_s
939
- end
940
- end
941
- end
942
- else
943
- val.to_s
944
- end
945
- end
946
-
947
817
  def render_footnotes_section
948
818
  return nil if @footnote_collector.empty?
949
819
 
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Metanorma
4
+ module Html
5
+ module Concerns
6
+ # Document metadata extraction from bibdata, mixed into
7
+ # BaseRenderer: page language, HTML title, and the primary document
8
+ # identifier used in the header.
9
+ module MetadataExtraction
10
+ def language
11
+ bibdata = @document.bibdata
12
+ return "en" unless bibdata
13
+
14
+ langs = bibdata.language
15
+ if langs && !langs.empty?
16
+ lang = langs.find { |l| l.current == "true" } || langs.first
17
+ lang.value || lang.to_s
18
+ else
19
+ "en"
20
+ end
21
+ end
22
+
23
+ def html_title
24
+ extract_display_title(@document.bibdata) || "Document"
25
+ end
26
+
27
+ def extract_display_title(bibdata)
28
+ return nil unless bibdata
29
+
30
+ title = bibdata.title_for("en") if bibdata.is_a?(Metanorma::Document::Components::BibData::BibData)
31
+ return title.to_s if title && !title.to_s.empty?
32
+
33
+ titles = safe_attr(bibdata, :title)
34
+ return nil unless titles && !titles.is_a?(String) && !titles.empty?
35
+
36
+ en = titles.find { |t| safe_attr(t, :language) == "en" }
37
+ found = en || titles.first
38
+ extract_text_value(found).to_s
39
+ end
40
+
41
+ def extract_primary_doc_id
42
+ bibdata = @document.bibdata
43
+ return nil unless bibdata
44
+
45
+ identifiers = bibdata.doc_identifier
46
+ return nil unless identifiers && !identifiers.empty?
47
+
48
+ first_id = identifiers.first
49
+ text = if first_id.is_a?(String)
50
+ first_id
51
+ elsif first_id.is_a?(Lutaml::Model::Serializable)
52
+ Array(first_id.value).join
53
+ else
54
+ first_id.to_s
55
+ end
56
+ text.strip.empty? ? nil : text.strip
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Metanorma
4
+ module Html
5
+ module Concerns
6
+ # Presentation-XML validation mixed into BaseRenderer. HTML
7
+ # generation requires Presentation XML input — semantic XML does not
8
+ # contain the formatting data needed for HTML — so the document is
9
+ # checked for presentation markers before rendering begins.
10
+ module PresentationValidation
11
+ def validate_presentation_xml!
12
+ has_presentation = check_presentation_markers(@document)
13
+ return if has_presentation
14
+
15
+ raise ArgumentError,
16
+ "HTML generation requires Presentation XML input. " \
17
+ "Semantic XML does not contain formatting data needed for HTML. " \
18
+ "Use a '.presentation.xml' file instead."
19
+ end
20
+
21
+ # Name kept for API stability (public renderer API); the boolean
22
+ # return predates the Naming/PredicateMethod convention.
23
+ # rubocop:disable Naming/PredicateMethod
24
+ def check_presentation_markers(node)
25
+ return false unless node
26
+ return false if node.is_a?(String)
27
+
28
+ if node.is_a?(Lutaml::Model::Serializable)
29
+ node_attrs = node.class.attributes
30
+ if node_attrs.key?(:type) && node.type == "presentation"
31
+ return true
32
+ end
33
+ if node_attrs.key?(:fmt_title) && node.fmt_title
34
+ return true
35
+ end
36
+ if node_attrs.key?(:displayorder) && node.displayorder
37
+ return true
38
+ end
39
+
40
+ %i[preface sections annex bibliography].each do |attr|
41
+ next unless node_attrs.key?(attr)
42
+
43
+ val = node.public_send(attr)
44
+ next unless val
45
+
46
+ Array(val).each { |v| return true if check_presentation_markers(v) }
47
+ end
48
+
49
+ node.each_mixed_content do |child|
50
+ next if child.is_a?(String)
51
+ return true if check_presentation_markers(child)
52
+ end
53
+ end
54
+
55
+ false
56
+ end
57
+ # rubocop:enable Naming/PredicateMethod
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Metanorma
4
+ module Html
5
+ module Concerns
6
+ # SVG logo loading and normalization mixed into BaseRenderer.
7
+ # Logos ship as standalone SVG files; for inline embedding the xml
8
+ # prolog and leading comments are stripped, a CSS class is added,
9
+ # and width/height are normalized to the requested display height.
10
+ module SvgProcessing
11
+ def load_logo_svg(filename, height: 32)
12
+ path = theme.resolve_asset(filename) || File.join(BaseRenderer::LOGO_DIR, filename)
13
+ return nil unless File.exist?(path)
14
+
15
+ svg = File.read(path)
16
+ svg = svg.sub(/\A<\?xml[^?]*\?>\s*/, "")
17
+ svg = svg.sub(/\A\s*<!--.*?-->\s*/m, "")
18
+ svg = svg.sub(/<svg\s/, '<svg class="header-logo" ')
19
+ svg = if svg.match?(/<svg[^>]*\sheight="[^"]*"/)
20
+ svg.sub(/(<svg[^>]*?)(\sheight="[^"]*")/,
21
+ "\\1 height=\"#{height}\"")
22
+ else
23
+ svg.sub(/(<svg\b)/, "\\1 height=\"#{height}\"")
24
+ end
25
+ svg.sub(/(<svg[^>]*?)\swidth="[^"]*"/, '\1')
26
+ rescue StandardError
27
+ nil
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end