metanorma-document 0.2.9 → 0.2.12

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 (390) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop_todo.yml +164 -66
  3. data/CLAUDE.md +26 -0
  4. data/README.adoc +144 -0
  5. data/Rakefile +14 -2
  6. data/TODO.finalize/01-split-multi-class-files.md +8 -0
  7. data/TODO.finalize/02-remove-data-dist-iso-document-frontend.md +6 -0
  8. data/TODO.finalize/03-align-frontend-iife-and-gha-build.md +11 -0
  9. data/TODO.finalize/04-theme-per-flavor-config-driven.md +9 -0
  10. data/TODO.finalize/05-migrate-all-html-to-liquid.md +16 -0
  11. data/TODO.finalize/06-integrate-pubid-library.md +16 -0
  12. data/TODO.finalize/07-replace-relaton-models-with-relaton-bib.md +39 -0
  13. data/TODO.finalize/08-refactor-renderer-architecture.md +25 -0
  14. data/TODO.finalize/09-eliminate-code-smells.md +14 -0
  15. data/TODO.finalize/10-comprehensive-spec-coverage.md +24 -0
  16. data/TODO.finalize/11-config-driven-flavor-metadata.md +20 -0
  17. data/TODO.finalize/12-add-frozen-string-literal.md +6 -0
  18. data/TODO.finalize/13-extract-composition-from-god-class.md +96 -0
  19. data/TODO.finalize/14-config-driven-section-ordering.md +31 -0
  20. data/TODO.finalize/15-theme-directories-per-flavor.md +46 -0
  21. data/TODO.finalize/16-theme-lutaml-model-schema.md +44 -0
  22. data/data/javascripts/components/mn-footnotes.js +68 -0
  23. data/data/javascripts/components/mn-lightbox.js +18 -2
  24. data/data/javascripts/components/mn-shortcuts.js +2 -0
  25. data/data/javascripts/core/mn-navigation.js +8 -0
  26. data/data/javascripts/core/mn-theme.js +9 -9
  27. data/data/logos/oiml-logo-full-dark.svg +1 -0
  28. data/data/logos/oiml-logo-full-light.svg +1 -0
  29. data/data/logos/oiml-logo-icon-dark.svg +1 -0
  30. data/data/logos/oiml-logo-icon-light.svg +1 -0
  31. data/data/logos/oiml-logo.svg +1 -0
  32. data/data/logos/pdfa-logo.svg +31 -94
  33. data/data/stylesheets/base/_layout.css +3 -0
  34. data/data/stylesheets/base/_reset.css +3 -3
  35. data/data/stylesheets/base/_typography.css +0 -5
  36. data/data/stylesheets/components/bibliography.css +3 -3
  37. data/data/stylesheets/components/cover.css +3 -1
  38. data/data/stylesheets/components/figure.css +13 -1
  39. data/data/stylesheets/components/footnote.css +65 -5
  40. data/data/stylesheets/components/formula.css +44 -0
  41. data/data/stylesheets/components/header.css +17 -2
  42. data/data/stylesheets/components/inline.css +1 -1
  43. data/data/stylesheets/components/table.css +15 -2
  44. data/data/stylesheets/components/term.css +0 -1
  45. data/data/themes/bipm.yaml +60 -0
  46. data/data/themes/cc.yaml +43 -0
  47. data/data/themes/icc.yaml +39 -0
  48. data/data/themes/iec.yaml +44 -0
  49. data/data/themes/ieee.yaml +43 -0
  50. data/data/themes/ietf.yaml +45 -0
  51. data/data/themes/iho.yaml +46 -0
  52. data/data/themes/iso.yaml +40 -0
  53. data/data/themes/itu.yaml +45 -0
  54. data/data/themes/ogc.yaml +51 -0
  55. data/data/themes/oiml.yaml +56 -0
  56. data/data/themes/pdfa.yaml +44 -0
  57. data/data/themes/ribose.yaml +43 -0
  58. data/exe/metanorma-document +11 -0
  59. data/frontend/e2e/sample-html.test.ts +256 -0
  60. data/frontend/index.html +12 -0
  61. data/frontend/package-lock.json +4725 -0
  62. data/frontend/package.json +32 -0
  63. data/frontend/postcss.config.js +6 -0
  64. data/frontend/src/App.vue +79 -0
  65. data/frontend/src/app.ts +21 -0
  66. data/frontend/src/components/AppSidebar.vue +77 -0
  67. data/frontend/src/components/DocumentReader.vue +180 -0
  68. data/frontend/src/components/MirrorRenderer.vue +23 -0
  69. data/frontend/src/components/ReadingProgressBar.vue +29 -0
  70. data/frontend/src/components/TextRenderer.vue +131 -0
  71. data/frontend/src/components/TocItem.vue +57 -0
  72. data/frontend/src/components/TopBar.vue +49 -0
  73. data/frontend/src/components/__tests__/MirrorRenderer.test.ts +178 -0
  74. data/frontend/src/components/blocks/AdmonitionBlock.vue +74 -0
  75. data/frontend/src/components/blocks/ClauseBlock.vue +53 -0
  76. data/frontend/src/components/blocks/ExampleBlock.vue +21 -0
  77. data/frontend/src/components/blocks/FallbackBlock.vue +18 -0
  78. data/frontend/src/components/blocks/FigureBlock.vue +26 -0
  79. data/frontend/src/components/blocks/FootnotesBlock.vue +25 -0
  80. data/frontend/src/components/blocks/ListBlocks.vue +62 -0
  81. data/frontend/src/components/blocks/NoteBlock.vue +21 -0
  82. data/frontend/src/components/blocks/ParagraphBlock.vue +25 -0
  83. data/frontend/src/components/blocks/QuoteBlock.vue +25 -0
  84. data/frontend/src/components/blocks/ReviewBlock.vue +39 -0
  85. data/frontend/src/components/blocks/SourcecodeBlock.vue +52 -0
  86. data/frontend/src/components/blocks/TableBlock.vue +48 -0
  87. data/frontend/src/components/blocks/index.ts +36 -0
  88. data/frontend/src/stores/__tests__/documentStore.fixture.test.ts +272 -0
  89. data/frontend/src/stores/__tests__/documentStore.test.ts +143 -0
  90. data/frontend/src/stores/__tests__/ebookStore.test.ts +145 -0
  91. data/frontend/src/stores/__tests__/uiStore.test.ts +42 -0
  92. data/frontend/src/stores/documentStore.ts +153 -0
  93. data/frontend/src/stores/ebookStore.ts +179 -0
  94. data/frontend/src/stores/uiStore.ts +32 -0
  95. data/frontend/src/styles.css +156 -0
  96. data/frontend/src/types/__tests__/rendering-contract.test.ts +172 -0
  97. data/frontend/src/types/rendering-contract.ts +209 -0
  98. data/frontend/src/utils/__tests__/typeMetadata.test.ts +102 -0
  99. data/frontend/src/utils/typeMetadata.ts +121 -0
  100. data/frontend/tailwind.config.js +18 -0
  101. data/frontend/test/fixtures/iso-document.json +271 -0
  102. data/frontend/tmp/debug_e2e.cjs +46 -0
  103. data/frontend/tsconfig.json +25 -0
  104. data/frontend/tsconfig.node.json +11 -0
  105. data/frontend/vite.config.ts +37 -0
  106. data/frontend/vitest.e2e.config.ts +9 -0
  107. data/lib/metanorma/bipm_document/metadata/bipm_bibliographic_item.rb +0 -14
  108. data/lib/metanorma/bipm_document/metadata/depiction_element.rb +18 -0
  109. data/lib/metanorma/bipm_document/metadata.rb +1 -1
  110. data/lib/metanorma/document/cli.rb +116 -0
  111. data/lib/metanorma/document/components/ancillary_blocks/callout_annotation.rb +27 -0
  112. data/lib/metanorma/document/components/ancillary_blocks/sourcecode_block.rb +0 -65
  113. data/lib/metanorma/document/components/ancillary_blocks/sourcecode_body.rb +18 -0
  114. data/lib/metanorma/document/components/ancillary_blocks.rb +4 -0
  115. data/lib/metanorma/document/components/bib_data/bib_data.rb +7 -0
  116. data/lib/metanorma/document/components/blocks/classification_value.rb +21 -0
  117. data/lib/metanorma/document/components/blocks/permission_model.rb +28 -0
  118. data/lib/metanorma/document/components/blocks/recommendation_model.rb +28 -0
  119. data/lib/metanorma/document/components/blocks/requirement_base.rb +30 -0
  120. data/lib/metanorma/document/components/blocks/requirement_classification.rb +20 -0
  121. data/lib/metanorma/document/components/blocks/requirement_description.rb +43 -0
  122. data/lib/metanorma/document/components/blocks/requirement_inherit.rb +24 -0
  123. data/lib/metanorma/document/components/blocks/requirement_model.rb +0 -131
  124. data/lib/metanorma/document/components/blocks.rb +8 -7
  125. data/lib/metanorma/document/components/id_elements/image.rb +2 -0
  126. data/lib/metanorma/document/components/inline/asciimath_element.rb +2 -0
  127. data/lib/metanorma/document/components/inline/attribution_element.rb +2 -0
  128. data/lib/metanorma/document/components/inline/bcp14_element.rb +2 -0
  129. data/lib/metanorma/document/components/inline/biblio_tag_element.rb +2 -0
  130. data/lib/metanorma/document/components/inline/br_element.rb +2 -0
  131. data/lib/metanorma/document/components/inline/comma_element.rb +2 -0
  132. data/lib/metanorma/document/components/inline/concept_element.rb +2 -0
  133. data/lib/metanorma/document/components/inline/display_text_element.rb +2 -0
  134. data/lib/metanorma/document/components/inline/em_raw_element.rb +2 -0
  135. data/lib/metanorma/document/components/inline/enum_comma_element.rb +2 -0
  136. data/lib/metanorma/document/components/inline/eref_element.rb +2 -0
  137. data/lib/metanorma/document/components/inline/fmt_admitted_element.rb +4 -0
  138. data/lib/metanorma/document/components/inline/fmt_annotation_body_element.rb +4 -0
  139. data/lib/metanorma/document/components/inline/fmt_annotation_end_element.rb +4 -0
  140. data/lib/metanorma/document/components/inline/fmt_annotation_start_element.rb +4 -0
  141. data/lib/metanorma/document/components/inline/fmt_concept_element.rb +4 -0
  142. data/lib/metanorma/document/components/inline/fmt_definition_element.rb +4 -0
  143. data/lib/metanorma/document/components/inline/fmt_fn_body_element.rb +4 -0
  144. data/lib/metanorma/document/components/inline/fmt_fn_label_element.rb +4 -0
  145. data/lib/metanorma/document/components/inline/fmt_footnote_container_element.rb +4 -0
  146. data/lib/metanorma/document/components/inline/fmt_identifier_element.rb +4 -0
  147. data/lib/metanorma/document/components/inline/fmt_name_element.rb +4 -0
  148. data/lib/metanorma/document/components/inline/fmt_preferred_element.rb +4 -0
  149. data/lib/metanorma/document/components/inline/fmt_sourcecode_element.rb +4 -0
  150. data/lib/metanorma/document/components/inline/fmt_stem_element.rb +10 -0
  151. data/lib/metanorma/document/components/inline/fmt_termsource_element.rb +4 -0
  152. data/lib/metanorma/document/components/inline/fmt_title_element.rb +4 -0
  153. data/lib/metanorma/document/components/inline/fmt_xref_element.rb +4 -0
  154. data/lib/metanorma/document/components/inline/fmt_xref_label_element.rb +4 -0
  155. data/lib/metanorma/document/components/inline/fn_element.rb +2 -0
  156. data/lib/metanorma/document/components/inline/link_element.rb +2 -0
  157. data/lib/metanorma/document/components/inline/localized_string_element.rb +2 -0
  158. data/lib/metanorma/document/components/inline/localized_strings_element.rb +2 -0
  159. data/lib/metanorma/document/components/inline/location_element.rb +24 -0
  160. data/lib/metanorma/document/components/inline/math_element.rb +2 -0
  161. data/lib/metanorma/document/components/inline/name_with_id_element.rb +4 -0
  162. data/lib/metanorma/document/components/inline/rendered_display.rb +23 -0
  163. data/lib/metanorma/document/components/inline/rendered_math_element.rb +21 -0
  164. data/lib/metanorma/document/components/inline/semantic_content.rb +51 -0
  165. data/lib/metanorma/document/components/inline/semantic_math_element.rb +20 -0
  166. data/lib/metanorma/document/components/inline/semx_child_element.rb +2 -0
  167. data/lib/metanorma/document/components/inline/semx_element.rb +13 -1
  168. data/lib/metanorma/document/components/inline/small_cap_element.rb +2 -0
  169. data/lib/metanorma/document/components/inline/span_element.rb +10 -15
  170. data/lib/metanorma/document/components/inline/stem_inline_element.rb +7 -1
  171. data/lib/metanorma/document/components/inline/strong_raw_element.rb +2 -0
  172. data/lib/metanorma/document/components/inline/sub_element.rb +2 -0
  173. data/lib/metanorma/document/components/inline/sup_element.rb +2 -0
  174. data/lib/metanorma/document/components/inline/tab_element.rb +2 -0
  175. data/lib/metanorma/document/components/inline/title_with_annotation_element.rb +4 -0
  176. data/lib/metanorma/document/components/inline/tt_element.rb +2 -0
  177. data/lib/metanorma/document/components/inline/variant_title_element.rb +2 -0
  178. data/lib/metanorma/document/components/inline/vocabulary.rb +184 -0
  179. data/lib/metanorma/document/components/inline/xref_element.rb +10 -0
  180. data/lib/metanorma/document/components/inline.rb +12 -0
  181. data/lib/metanorma/document/components/multi_paragraph/quote_author_element.rb +20 -0
  182. data/lib/metanorma/document/components/multi_paragraph/quote_block.rb +0 -29
  183. data/lib/metanorma/document/components/multi_paragraph.rb +2 -0
  184. data/lib/metanorma/document/components/reference_elements/source_element.rb +0 -31
  185. data/lib/metanorma/document/components/reference_elements/source_modification.rb +21 -0
  186. data/lib/metanorma/document/components/reference_elements/source_origin.rb +28 -0
  187. data/lib/metanorma/document/components/reference_elements.rb +2 -2
  188. data/lib/metanorma/document/components/tables/col_element.rb +18 -0
  189. data/lib/metanorma/document/components/tables/col_group_element.rb +18 -0
  190. data/lib/metanorma/document/components/tables/table_block.rb +0 -22
  191. data/lib/metanorma/document/components/tables/table_body_section.rb +16 -0
  192. data/lib/metanorma/document/components/tables/table_foot_section.rb +16 -0
  193. data/lib/metanorma/document/components/tables/table_head_section.rb +16 -0
  194. data/lib/metanorma/document/components/tables/table_section.rb +0 -25
  195. data/lib/metanorma/document/components/tables.rb +5 -3
  196. data/lib/metanorma/document/relaton/address.rb +0 -27
  197. data/lib/metanorma/document/relaton/bib_item_locality.rb +3 -12
  198. data/lib/metanorma/document/relaton/copyright_association.rb +0 -10
  199. data/lib/metanorma/document/relaton/copyright_owner.rb +16 -0
  200. data/lib/metanorma/document/relaton/edition.rb +4 -7
  201. data/lib/metanorma/document/relaton/formatted_address.rb +29 -0
  202. data/lib/metanorma/document/relaton/logo_element.rb +18 -0
  203. data/lib/metanorma/document/relaton/organization.rb +0 -14
  204. data/lib/metanorma/document/relaton/phone.rb +3 -9
  205. data/lib/metanorma/document/relaton/place_type.rb +0 -13
  206. data/lib/metanorma/document/relaton/price_type.rb +3 -9
  207. data/lib/metanorma/document/relaton/region_element.rb +18 -0
  208. data/lib/metanorma/document/relaton.rb +6 -1
  209. data/lib/metanorma/document/version.rb +1 -1
  210. data/lib/metanorma/document.rb +2 -0
  211. data/lib/metanorma/html/asset_pipeline.rb +1 -0
  212. data/lib/metanorma/html/base_renderer.rb +261 -1008
  213. data/lib/metanorma/html/bipm_renderer.rb +15 -144
  214. data/lib/metanorma/html/cc_renderer.rb +1 -45
  215. data/lib/metanorma/html/component/base.rb +2 -7
  216. data/lib/metanorma/html/component/index_section.rb +41 -43
  217. data/lib/metanorma/html/drops/admonition_drop.rb +1 -4
  218. data/lib/metanorma/html/drops/biblio_entry_drop.rb +50 -0
  219. data/lib/metanorma/html/drops/block_element_drop.rb +7 -2
  220. data/lib/metanorma/html/drops/example_drop.rb +1 -4
  221. data/lib/metanorma/html/drops/figure_drop.rb +31 -22
  222. data/lib/metanorma/html/drops/footnote_drop.rb +1 -1
  223. data/lib/metanorma/html/drops/formula_drop.rb +45 -16
  224. data/lib/metanorma/html/drops/note_drop.rb +9 -7
  225. data/lib/metanorma/html/drops/sourcecode_drop.rb +12 -5
  226. data/lib/metanorma/html/drops.rb +2 -0
  227. data/lib/metanorma/html/generator.rb +2 -2
  228. data/lib/metanorma/html/icc_renderer.rb +0 -39
  229. data/lib/metanorma/html/iec_renderer.rb +1 -44
  230. data/lib/metanorma/html/ieee_renderer.rb +1 -45
  231. data/lib/metanorma/html/ietf_renderer.rb +1 -48
  232. data/lib/metanorma/html/iho_renderer.rb +1 -46
  233. data/lib/metanorma/html/iso_renderer.rb +290 -343
  234. data/lib/metanorma/html/itu_renderer.rb +1 -45
  235. data/lib/metanorma/html/ogc_renderer.rb +15 -76
  236. data/lib/metanorma/html/oiml_renderer.rb +18 -38
  237. data/lib/metanorma/html/pdfa_renderer.rb +0 -39
  238. data/lib/metanorma/html/renderers/block_renderer.rb +372 -0
  239. data/lib/metanorma/html/renderers/inline_renderer.rb +655 -0
  240. data/lib/metanorma/html/renderers/pubid_renderer.rb +69 -0
  241. data/lib/metanorma/html/renderers/section_renderer.rb +193 -0
  242. data/lib/metanorma/html/renderers.rb +8 -0
  243. data/lib/metanorma/html/ribose_renderer.rb +1 -45
  244. data/lib/metanorma/html/standard_renderer.rb +341 -229
  245. data/lib/metanorma/html/templates/_audio.html.liquid +1 -0
  246. data/lib/metanorma/html/templates/_biblio_entry.html.liquid +1 -0
  247. data/lib/metanorma/html/templates/_bipm_cover.html.liquid +27 -0
  248. data/lib/metanorma/html/templates/_boilerplate.html.liquid +1 -0
  249. data/lib/metanorma/html/templates/_bookmark.html.liquid +1 -0
  250. data/lib/metanorma/html/templates/_br.html.liquid +1 -0
  251. data/lib/metanorma/html/templates/_definition_list.html.liquid +6 -0
  252. data/lib/metanorma/html/templates/_element.html.liquid +1 -0
  253. data/lib/metanorma/html/templates/_fn_marker.html.liquid +1 -0
  254. data/lib/metanorma/html/templates/_footnotes.html.liquid +6 -6
  255. data/lib/metanorma/html/templates/_formula.html.liquid +1 -1
  256. data/lib/metanorma/html/templates/_header.html.liquid +1 -1
  257. data/lib/metanorma/html/templates/_heading.html.liquid +1 -0
  258. data/lib/metanorma/html/templates/_image.html.liquid +1 -0
  259. data/lib/metanorma/html/templates/_inline_span.html.liquid +1 -0
  260. data/lib/metanorma/html/templates/_iso_cover.html.liquid +1 -1
  261. data/lib/metanorma/html/templates/_link.html.liquid +1 -0
  262. data/lib/metanorma/html/templates/_list.html.liquid +3 -0
  263. data/lib/metanorma/html/templates/_list_item.html.liquid +1 -0
  264. data/lib/metanorma/html/templates/_main_content.html.liquid +1 -0
  265. data/lib/metanorma/html/templates/_math_container.html.liquid +1 -0
  266. data/lib/metanorma/html/templates/_paragraph.html.liquid +1 -0
  267. data/lib/metanorma/html/templates/_prefatory_section.html.liquid +1 -0
  268. data/lib/metanorma/html/templates/_pubid_identifier.html.liquid +1 -0
  269. data/lib/metanorma/html/templates/_quote.html.liquid +4 -0
  270. data/lib/metanorma/html/templates/_ref_date.html.liquid +1 -0
  271. data/lib/metanorma/html/templates/_ref_title.html.liquid +1 -0
  272. data/lib/metanorma/html/templates/_section.html.liquid +5 -0
  273. data/lib/metanorma/html/templates/_standard_cover.html.liquid +31 -0
  274. data/lib/metanorma/html/templates/_standard_doc_title.html.liquid +3 -0
  275. data/lib/metanorma/html/templates/_stem_span.html.liquid +1 -0
  276. data/lib/metanorma/html/templates/_table.html.liquid +9 -0
  277. data/lib/metanorma/html/templates/_term_domain.html.liquid +1 -0
  278. data/lib/metanorma/html/templates/_term_example.html.liquid +1 -0
  279. data/lib/metanorma/html/templates/_term_note.html.liquid +1 -0
  280. data/lib/metanorma/html/templates/_term_number.html.liquid +1 -0
  281. data/lib/metanorma/html/templates/_term_source.html.liquid +1 -0
  282. data/lib/metanorma/html/templates/_term_text_note.html.liquid +1 -0
  283. data/lib/metanorma/html/templates/_video.html.liquid +1 -0
  284. data/lib/metanorma/html/templates/_wrapped_preface.html.liquid +1 -0
  285. data/lib/metanorma/html/templates/document.html.liquid +4 -0
  286. data/lib/metanorma/html/theme.rb +180 -107
  287. data/lib/metanorma/html.rb +13 -0
  288. data/lib/metanorma/ietf_document/metadata/ietf_bib_data_extension_type.rb +0 -25
  289. data/lib/metanorma/ietf_document/metadata/pi_settings.rb +32 -0
  290. data/lib/metanorma/ietf_document/metadata.rb +2 -0
  291. data/lib/metanorma/iso_document/metadata/doctype_element.rb +20 -0
  292. data/lib/metanorma/iso_document/metadata/iso_bib_data_extension_type.rb +0 -48
  293. data/lib/metanorma/iso_document/metadata/iso_bibliographic_item.rb +0 -72
  294. data/lib/metanorma/iso_document/metadata/iso_document_status.rb +0 -30
  295. data/lib/metanorma/iso_document/metadata/iso_localized_title.rb +4 -1
  296. data/lib/metanorma/iso_document/metadata/language_element.rb +18 -0
  297. data/lib/metanorma/iso_document/metadata/project_number.rb +22 -0
  298. data/lib/metanorma/iso_document/metadata/script_element.rb +18 -0
  299. data/lib/metanorma/iso_document/metadata/stage_element.rb +26 -0
  300. data/lib/metanorma/iso_document/metadata/stagename_element.rb +18 -0
  301. data/lib/metanorma/iso_document/metadata/structured_identifier.rb +0 -17
  302. data/lib/metanorma/iso_document/metadata/title_collection.rb +1 -0
  303. data/lib/metanorma/iso_document/metadata.rb +9 -4
  304. data/lib/metanorma/iso_document/terms/iso_term.rb +0 -13
  305. data/lib/metanorma/iso_document/terms/term_domain_element.rb +18 -0
  306. data/lib/metanorma/iso_document/terms.rb +1 -0
  307. data/lib/metanorma/itu_document/metadata/itu_bib_data_extension_type.rb +0 -37
  308. data/lib/metanorma/itu_document/metadata/meeting_date.rb +18 -0
  309. data/lib/metanorma/itu_document/metadata/meeting_element.rb +18 -0
  310. data/lib/metanorma/itu_document/metadata/study_period.rb +18 -0
  311. data/lib/metanorma/itu_document/metadata.rb +3 -4
  312. data/lib/metanorma/mirror/default_registry.rb +170 -0
  313. data/lib/metanorma/mirror/handler_registry.rb +60 -0
  314. data/lib/metanorma/mirror/handler_result.rb +37 -0
  315. data/lib/metanorma/mirror/handlers/admonition.rb +18 -0
  316. data/lib/metanorma/mirror/handlers/example.rb +21 -0
  317. data/lib/metanorma/mirror/handlers/figure.rb +53 -0
  318. data/lib/metanorma/mirror/handlers/formula.rb +35 -0
  319. data/lib/metanorma/mirror/handlers/inline/rich_html_renderer.rb +159 -0
  320. data/lib/metanorma/mirror/handlers/inline/text_extractor.rb +116 -0
  321. data/lib/metanorma/mirror/handlers/inline.rb +225 -0
  322. data/lib/metanorma/mirror/handlers/list.rb +110 -0
  323. data/lib/metanorma/mirror/handlers/note.rb +26 -0
  324. data/lib/metanorma/mirror/handlers/paragraph.rb +19 -0
  325. data/lib/metanorma/mirror/handlers/quote.rb +16 -0
  326. data/lib/metanorma/mirror/handlers/review.rb +19 -0
  327. data/lib/metanorma/mirror/handlers/section.rb +149 -0
  328. data/lib/metanorma/mirror/handlers/sourcecode.rb +27 -0
  329. data/lib/metanorma/mirror/handlers/structural.rb +68 -0
  330. data/lib/metanorma/mirror/handlers/table.rb +81 -0
  331. data/lib/metanorma/mirror/handlers/term.rb +100 -0
  332. data/lib/metanorma/mirror/handlers.rb +60 -0
  333. data/lib/metanorma/mirror/id_strategy/positional.rb +166 -0
  334. data/lib/metanorma/mirror/id_strategy/preserve.rb +12 -0
  335. data/lib/metanorma/mirror/id_strategy.rb +34 -0
  336. data/lib/metanorma/mirror/math_util.rb +53 -0
  337. data/lib/metanorma/mirror/metadata.rb +37 -0
  338. data/lib/metanorma/mirror/metanorma_to_mirror.rb +101 -0
  339. data/lib/metanorma/mirror/model/container.rb +47 -0
  340. data/lib/metanorma/mirror/model/factory.rb +52 -0
  341. data/lib/metanorma/mirror/model/guide.rb +25 -0
  342. data/lib/metanorma/mirror/model/leaf.rb +17 -0
  343. data/lib/metanorma/mirror/model/mark.rb +53 -0
  344. data/lib/metanorma/mirror/model/node.rb +47 -0
  345. data/lib/metanorma/mirror/model/soft_break.rb +33 -0
  346. data/lib/metanorma/mirror/model/text.rb +34 -0
  347. data/lib/metanorma/mirror/model.rb +16 -0
  348. data/lib/metanorma/mirror/output/builder.rb +39 -0
  349. data/lib/metanorma/mirror/output/formats/base_format.rb +90 -0
  350. data/lib/metanorma/mirror/output/formats/inline_format.rb +81 -0
  351. data/lib/metanorma/mirror/output/formats.rb +53 -0
  352. data/lib/metanorma/mirror/output/html_renderer.rb +95 -0
  353. data/lib/metanorma/mirror/output/html_renderers/block_renderers.rb +178 -0
  354. data/lib/metanorma/mirror/output/html_renderers/inline_renderer.rb +61 -0
  355. data/lib/metanorma/mirror/output/html_renderers/list_renderers.rb +96 -0
  356. data/lib/metanorma/mirror/output/html_renderers/mark_renderers.rb +64 -0
  357. data/lib/metanorma/mirror/output/html_renderers/section_renderers.rb +129 -0
  358. data/lib/metanorma/mirror/output/html_renderers/structural_renderers.rb +80 -0
  359. data/lib/metanorma/mirror/output/html_renderers/table_renderers.rb +87 -0
  360. data/lib/metanorma/mirror/output/html_renderers.rb +76 -0
  361. data/lib/metanorma/mirror/output/pipeline.rb +92 -0
  362. data/lib/metanorma/mirror/output/pipeline_context.rb +22 -0
  363. data/lib/metanorma/mirror/output.rb +14 -0
  364. data/lib/metanorma/mirror/rewriter.rb +121 -0
  365. data/lib/metanorma/mirror/safe_attr.rb +16 -0
  366. data/lib/metanorma/mirror/serialization/json_serializer.rb +25 -0
  367. data/lib/metanorma/mirror/serialization/yaml_serializer.rb +20 -0
  368. data/lib/metanorma/mirror/serialization.rb +10 -0
  369. data/lib/metanorma/mirror/transformer.rb +22 -0
  370. data/lib/metanorma/mirror.rb +32 -0
  371. data/lib/metanorma/standard_document/annotation.rb +37 -0
  372. data/lib/metanorma/standard_document/block_attributes.rb +15 -2
  373. data/lib/metanorma/standard_document/blocks/block_source.rb +0 -23
  374. data/lib/metanorma/standard_document/blocks/source_origin_element.rb +23 -0
  375. data/lib/metanorma/standard_document/blocks.rb +1 -1
  376. data/lib/metanorma/standard_document/terms/term_expression.rb +0 -31
  377. data/lib/metanorma/standard_document/terms/term_name_element.rb +29 -0
  378. data/lib/metanorma/standard_document/terms.rb +1 -0
  379. data/script/render_both.rb +18 -0
  380. metadata +262 -13
  381. data/.github/workflows/rake.yml +0 -21
  382. data/.github/workflows/release.yml +0 -25
  383. data/.github/workflows/roundtrip-samples.yml +0 -132
  384. data/.gitignore +0 -19
  385. data/.rspec +0 -3
  386. data/.rubocop.yml +0 -22
  387. data/Gemfile +0 -28
  388. data/bin/console +0 -15
  389. data/bin/setup +0 -8
  390. data/metanorma-document.gemspec +0 -35
@@ -6,15 +6,16 @@ require "cgi"
6
6
 
7
7
  module Metanorma
8
8
  module Html
9
- # Renders BasicDocument components to HTML.
10
- # Subclassed by StandardRenderer and flavor-specific renderers.
11
- # Owns the full HTML document generation pipeline: body content, header,
12
- # footer, ToC sidebar, CSS (via Theme), and JavaScript.
9
+ module Renderers
10
+ autoload :InlineRenderer, "metanorma/html/renderers/inline_renderer"
11
+ autoload :BlockRenderer, "metanorma/html/renderers/block_renderer"
12
+ autoload :SectionRenderer, "metanorma/html/renderers/section_renderer"
13
+ autoload :PubidRenderer, "metanorma/html/renderers/pubid_renderer"
14
+ end
15
+
13
16
  class BaseRenderer
14
17
  LOGO_DIR = File.expand_path("../../../data/logos", __dir__)
15
18
 
16
- # HTML-specific class names for inline spans, keyed by the XML span role.
17
- # The XML class_attr is INPUT only — we never emit it in HTML.
18
19
  SPAN_ROLE_CLASSES = {
19
20
  "boldtitle" => "title-text",
20
21
  "nonboldtitle" => "subtitle-text",
@@ -44,8 +45,6 @@ module Metanorma
44
45
 
45
46
  METANORMA_LOGO = "metanorma-logo.svg"
46
47
 
47
- # Type-to-method registry for OCP dispatch.
48
- # Each subclass gets its own hash; lookup traverses ancestors.
49
48
  class << self
50
49
  def render_registry
51
50
  @render_registry ||= {}
@@ -64,8 +63,12 @@ module Metanorma
64
63
  end
65
64
  end
66
65
 
66
+ attr_reader :inline_renderer, :block_renderer, :section_renderer,
67
+ :pubid_renderer, :index_term_collector, :footnote_collector
68
+ private :inline_renderer, :block_renderer, :section_renderer,
69
+ :pubid_renderer
70
+
67
71
  def initialize
68
- @output = +""
69
72
  @toc_entries = []
70
73
  @figure_entries = []
71
74
  @table_entries = []
@@ -73,86 +76,107 @@ module Metanorma
73
76
  @footnote_collector = Component::FootnoteCollector.new
74
77
  @current_section_id = nil
75
78
  @current_section_number = nil
76
- end
77
79
 
78
- # --- Public API ---
80
+ @inline_renderer = Renderers::InlineRenderer.new(self)
81
+ @block_renderer = Renderers::BlockRenderer.new(self)
82
+ @section_renderer = Renderers::SectionRenderer.new(self)
83
+ @pubid_renderer = Renderers::PubidRenderer.new(self)
84
+ end
79
85
 
80
- # Facade object for Drops to call renderer methods without exposing
81
- # the full private interface. Delegates via method_missing with an
82
- # explicit allowlist — adding new delegations only requires updating
83
- # DELEGATED_METHODS, not writing a new one-liner.
84
86
  class RendererContext
85
- DELEGATED_METHODS = %i[
86
- safe_attr escape_html extract_block_label extract_plain_text
87
- capture_output render_paragraph render_mixed_inline
88
- render_inline_element render_unordered_list render_ordered_list
89
- render_definition_list render_sourcecode render_table
90
- render_figure render_quote render_formula render_note
91
- render_image render_stem_content register_figure_entry
92
- render_liquid render_block_children
93
- ].freeze
94
-
95
87
  def initialize(renderer)
96
88
  @renderer = renderer
97
89
  end
98
90
 
99
- def respond_to_missing?(method_name, include_private = false)
100
- DELEGATED_METHODS.include?(method_name) || super
101
- end
102
-
103
- private
104
-
105
- def method_missing(method_name, ...)
106
- if DELEGATED_METHODS.include?(method_name)
107
- @renderer.public_send(method_name, ...)
108
- else
109
- super
110
- end
111
- end
91
+ def safe_attr(...) = @renderer.safe_attr(...)
92
+ def escape_html(...) = @renderer.escape_html(...)
93
+ def extract_block_label(...)= @renderer.extract_block_label(...)
94
+ def extract_plain_text(...)= @renderer.extract_plain_text(...)
95
+ def render_paragraph(...) = @renderer.render_paragraph(...)
96
+ def render_mixed_inline(...)= @renderer.render_mixed_inline(...)
97
+ def render_inline_element(...)= @renderer.render_inline_element(...)
98
+ def render_unordered_list(...)= @renderer.render_unordered_list(...)
99
+ def render_ordered_list(...)= @renderer.render_ordered_list(...)
100
+ def render_definition_list(...)= @renderer.render_definition_list(...)
101
+ def render_sourcecode(...) = @renderer.render_sourcecode(...)
102
+ def render_table(...) = @renderer.render_table(...)
103
+ def render_figure(...) = @renderer.render_figure(...)
104
+ def render_quote(...) = @renderer.render_quote(...)
105
+ def render_formula(...) = @renderer.render_formula(...)
106
+ def render_note(...) = @renderer.render_note(...)
107
+ def render_image(...) = @renderer.render_image(...)
108
+ def render_stem_content(...)= @renderer.render_stem_content(...)
109
+ def register_figure_entry(...)= @renderer.register_figure_entry(...)
110
+ def render_liquid(...) = @renderer.render_liquid(...)
111
+ def render_note_children(...) = @renderer.render_note_children(...)
112
+ def render_simple_children(...) = @renderer.render_simple_children(...)
113
+ def render_full_block_children(...) = @renderer.render_full_block_children(...)
112
114
  end
113
115
 
114
116
  def renderer_context
115
117
  @renderer_context ||= RendererContext.new(self)
116
118
  end
117
119
 
118
- def to_html
119
- @output
120
- end
121
-
122
120
  def toc_entries
123
121
  @toc_entries
124
122
  end
125
123
 
126
- # Generate a complete HTML document from a presentation XML document.
127
- def generate_full_document(document)
124
+ attr_writer :document, :theme
125
+
126
+ def generate_full_document(document, **)
128
127
  @document = document
129
128
  validate_presentation_xml!
130
129
 
131
- # First pass: render body content (collects ToC entries as side effect)
132
- render(@document)
133
- body = @output
130
+ body = render(@document) || ""
134
131
 
135
132
  assemble_document(body)
136
133
  end
137
134
 
138
- # --- Flavor configuration hooks (override in subclasses) ---
135
+ # --- Flavor configuration hooks ---
136
+
137
+ FLAVOR_MAP = {
138
+ "IsoDocument" => :iso,
139
+ "IecDocument" => :iec,
140
+ "IeeeDocument" => :ieee,
141
+ "IetfDocument" => :ietf,
142
+ "ItuDocument" => :itu,
143
+ "IhoDocument" => :iho,
144
+ "BipmDocument" => :bipm,
145
+ "OgcDocument" => :ogc,
146
+ "OimlDocument" => :oiml,
147
+ "CcDocument" => :cc,
148
+ "IccDocument" => :icc,
149
+ "RiboseDocument" => :ribose,
150
+ "PdfaDocument" => :pdfa,
151
+ }.freeze
139
152
 
140
153
  def theme
141
- @theme ||= Theme.new
154
+ @theme ||= resolve_theme
155
+ end
156
+
157
+ def resolve_theme
158
+ flavor = flavor_name
159
+ flavor ? Theme.load(flavor) : Theme.new
160
+ end
161
+
162
+ def flavor_name
163
+ return nil unless defined?(@document) && @document
164
+
165
+ @document.class.name&.split("::")&.detect do |ns|
166
+ FLAVOR_MAP.key?(ns)
167
+ end&.then { |ns| FLAVOR_MAP[ns] }
142
168
  end
143
169
 
144
170
  def flavor_publishers(_doc_id)
145
- []
171
+ theme.publishers
146
172
  end
147
173
 
148
174
  def flavor_publisher_name
149
- pubs = flavor_publishers(extract_primary_doc_id)
150
- pubs.empty? ? nil : pubs.join("/")
175
+ theme.publisher_name
151
176
  end
152
177
 
153
- # Map of publisher name => logo filename. Override in flavor renderers.
154
178
  def publisher_logo_map
155
- {}
179
+ theme.logos_light
156
180
  end
157
181
 
158
182
  def flavor_font_url
@@ -165,7 +189,7 @@ module Metanorma
165
189
  TEMPLATE_CACHE_MUTEX = Mutex.new
166
190
 
167
191
  def render_liquid(template_name, assigns)
168
- template_path = File.join(TEMPLATES_ROOT, template_name)
192
+ template_path = theme.resolve_template(template_name)
169
193
  template = TEMPLATE_CACHE_MUTEX.synchronize do
170
194
  TEMPLATE_CACHE[template_path] ||= Liquid::Template.parse(File.read(template_path))
171
195
  end
@@ -211,7 +235,7 @@ module Metanorma
211
235
  end
212
236
 
213
237
  def header_title_text
214
- raw = html_title.to_s.split(" — ").first.to_s.gsub(/<[^>]+>/, "")
238
+ raw = html_title.to_s.split(" — ").first.to_s
215
239
  raw.length > 60 ? "#{raw[0, 57]}..." : raw
216
240
  end
217
241
 
@@ -220,7 +244,7 @@ module Metanorma
220
244
  logo_map = publisher_logo_map
221
245
  return "" if publishers.empty? && logo_map.empty?
222
246
 
223
- # Use flavor-declared publishers; fall back to logo map keys
247
+ dark_logo_map = theme.logos_dark
224
248
  display_pubs = publishers.empty? ? logo_map.keys : publishers
225
249
 
226
250
  display_pubs.filter_map do |pub|
@@ -230,18 +254,28 @@ module Metanorma
230
254
  svg = load_logo_svg(filename, height: 26)
231
255
  next unless svg
232
256
 
233
- "<span class=\"brand-logo\" aria-label=\"#{pub} logo\">#{svg}</span>"
257
+ light_span = "<span class=\"brand-logo brand-logo-light\" aria-label=\"#{pub} logo\">#{svg}</span>"
258
+
259
+ dark_span = ""
260
+ dark_filename = dark_logo_map[pub]
261
+ if dark_filename
262
+ dark_svg = load_logo_svg(dark_filename, height: 26)
263
+ if dark_svg
264
+ dark_span = "<span class=\"brand-logo brand-logo-dark\" aria-label=\"#{pub} logo\">#{dark_svg}</span>"
265
+ end
266
+ end
267
+
268
+ "#{light_span}\n#{dark_span}"
234
269
  end.join("\n")
235
270
  end
236
271
 
237
272
  def load_logo_svg(filename, height: 32)
238
- path = File.join(LOGO_DIR, filename)
273
+ path = theme.resolve_asset(filename) || File.join(LOGO_DIR, filename)
239
274
  return nil unless File.exist?(path)
240
275
 
241
276
  svg = File.read(path)
242
277
  svg = svg.sub(/\A<\?xml[^?]*\?>\s*/, "")
243
278
  svg = svg.sub(/\A\s*<!--.*?-->\s*/m, "")
244
- svg = svg.sub(/<path[^>]*style="fill:#e3000f[^"]*"[^>]*\/>/, "")
245
279
  svg = svg.sub(/<svg\s/, '<svg class="header-logo" ')
246
280
  svg = if svg.match?(/<svg[^>]*\sheight="[^"]*"/)
247
281
  svg.sub(/(<svg[^>]*?)(\sheight="[^"]*")/,
@@ -297,7 +331,10 @@ module Metanorma
297
331
  def build_styles
298
332
  pipeline = AssetPipeline.new
299
333
  css = pipeline.compile_css(flavor_css: flavor_css_module)
300
- "#{theme.to_css_root}\n#{css}\n#{theme.to_css_extras}"
334
+ parts = [theme.to_css_root, css, theme.to_css_extras]
335
+ custom_css_path = theme.theme_css_path
336
+ parts << File.read(custom_css_path) if custom_css_path
337
+ parts.join("\n")
301
338
  end
302
339
 
303
340
  # --- Validation ---
@@ -316,12 +353,11 @@ module Metanorma
316
353
  return false unless node
317
354
  return false if node.is_a?(String)
318
355
 
319
- if node.is_a?(Metanorma::Document::Root) && node.type == "presentation"
320
- return true
321
- end
322
-
323
356
  if node.is_a?(Lutaml::Model::Serializable)
324
357
  node_attrs = node.class.attributes
358
+ if node_attrs.key?(:type) && node.type == "presentation"
359
+ return true
360
+ end
325
361
  if node_attrs.key?(:fmt_title) && node.fmt_title
326
362
  return true
327
363
  end
@@ -363,16 +399,21 @@ module Metanorma
363
399
  end
364
400
 
365
401
  def html_title
366
- bibdata = @document.bibdata
367
- return "Document" unless bibdata
402
+ extract_display_title(@document.bibdata) || "Document"
403
+ end
368
404
 
369
- titles = bibdata.titles
370
- if titles
371
- title = bibdata.title_for("en")
372
- title.to_s
373
- else
374
- "Document"
375
- end
405
+ def extract_display_title(bibdata)
406
+ return nil unless bibdata
407
+
408
+ title = bibdata.title_for("en") if bibdata.is_a?(Metanorma::Document::Components::BibData::BibData)
409
+ return title.to_s if title && !title.to_s.empty?
410
+
411
+ titles = safe_attr(bibdata, :title)
412
+ return nil unless titles && !titles.is_a?(String) && !titles.empty?
413
+
414
+ en = titles.find { |t| safe_attr(t, :language) == "en" }
415
+ found = en || titles.first
416
+ extract_text_value(found).to_s
376
417
  end
377
418
 
378
419
  def extract_primary_doc_id
@@ -393,7 +434,7 @@ module Metanorma
393
434
  text.strip.empty? ? nil : text.strip
394
435
  end
395
436
 
396
- # --- CSS loader ---
437
+ # --- Registration helpers ---
397
438
 
398
439
  def register_toc_entry(id:, level:, text:)
399
440
  @toc_entries << { id: id, level: level, text: text }
@@ -403,12 +444,14 @@ module Metanorma
403
444
  @figure_entries << { id: id, text: text }
404
445
  end
405
446
 
447
+ def figure_entries
448
+ @figure_entries
449
+ end
450
+
406
451
  def register_table_entry(id:, text:)
407
452
  @table_entries << { id: id, text: text }
408
453
  end
409
454
 
410
- attr_reader :index_term_collector, :footnote_collector
411
-
412
455
  def extract_plain_text(node)
413
456
  return node.to_s if node.is_a?(String)
414
457
  return extract_text_value(node).to_s unless node.is_a?(Lutaml::Model::Serializable)
@@ -431,38 +474,39 @@ module Metanorma
431
474
  if el.text?
432
475
  parts << el.text_content.to_s
433
476
  elsif el.name == "tab"
434
- parts << "\u00A0\u00A0"
477
+ parts << " "
435
478
  elsif el.name == "br"
436
479
  parts << " "
437
480
  elsif el.element?
438
481
  attr_name = element_to_attr[el.name]
439
- next unless attr_name
440
-
441
- coll = node.public_send(attr_name)
442
- obj = if coll.is_a?(Array)
443
- idx = indices[attr_name]
444
- indices[attr_name] += 1
445
- coll[idx]
446
- else
447
- coll
448
- end
449
- parts << extract_plain_text(obj) if obj
482
+ if attr_name
483
+ coll = node.public_send(attr_name)
484
+ obj = if coll.is_a?(Array)
485
+ idx = indices[attr_name]
486
+ indices[attr_name] += 1
487
+ coll[idx]
488
+ else
489
+ coll
490
+ end
491
+ text = extract_plain_text(obj)
492
+ parts << (text.empty? ? " " : text)
493
+ elsif el.name == "span"
494
+ parts << " "
495
+ end
450
496
  end
451
497
  end
452
498
  end
453
499
 
454
- # Fallback: try .text
455
500
  if parts.join.strip.empty?
456
501
  t = safe_attr(node, :text)
457
502
  parts << (t.is_a?(Array) ? t.join : t.to_s) if t
458
503
  end
459
504
 
460
- parts.join.strip.gsub("\u00A0", " ")
505
+ parts.join.strip.gsub(" ", " ")
461
506
  end
462
507
 
463
- # Dispatch to the appropriate render method via type registry.
464
- # Lookups traverse the ancestor chain so subclasses inherit
465
- # parent registrations and can override them independently.
508
+ # --- Dispatch ---
509
+
466
510
  def render(node, **)
467
511
  return escape_html(node) if node.is_a?(String)
468
512
 
@@ -470,20 +514,18 @@ module Metanorma
470
514
  method ? public_send(method, node, **) : ""
471
515
  end
472
516
 
473
- # Dispatch to the appropriate inline render method via type registry.
474
517
  def render_inline_element(element, **)
475
- return "" if element.nil?
476
- return escape_html(element) if element.is_a?(String)
477
-
478
- method = lookup_dispatch(element.class, :inline_registry)
479
- if method
480
- public_send(method, element)
481
- elsif element.is_a?(Lutaml::Model::Serializable) && element.mixed?
482
- render_mixed_inline(element)
483
- end
518
+ @inline_renderer.render_inline_element(element)
484
519
  end
485
520
 
486
- # --- Type registrations (class-level, evaluated at class load time) ---
521
+ def is_title_element?(node, section)
522
+ title = safe_attr(section, :title)
523
+ return false unless title
524
+
525
+ node.equal?(title)
526
+ end
527
+
528
+ # --- Type registrations ---
487
529
 
488
530
  register_render Metanorma::Document::Components::Paragraphs::ParagraphBlock,
489
531
  :render_paragraph
@@ -582,7 +624,6 @@ module Metanorma
582
624
  :render_index
583
625
  register_inline_render Metanorma::Document::Components::Blocks::NoteBlock,
584
626
  :render_note_inline
585
- # All Fmt* elements delegate to render_mixed_inline
586
627
  register_inline_render Metanorma::Document::Components::Inline::FmtNameElement,
587
628
  :render_mixed_inline
588
629
  register_inline_render Metanorma::Document::Components::Inline::FmtTitleElement,
@@ -647,941 +688,163 @@ module Metanorma
647
688
  nil
648
689
  end
649
690
 
650
- # --- Block-level rendering ---
651
-
652
- def render_paragraph(p, **_opts)
653
- attrs = element_attrs(id: safe_attr(p, :id),
654
- style: alignment_style(safe_attr(
655
- p, :alignment
656
- )))
657
- tag("p", attrs) { render_mixed_inline(p) }
658
- end
659
-
660
- def render_table(table, **_opts)
661
- attrs = element_attrs(id: safe_attr(table, :id), class: "table-block")
662
- table_id = safe_attr(table, :id)
663
- name_el = safe_attr(table, :fmt_name) || safe_attr(table, :name)
664
- if table_id && name_el
665
- register_table_entry(id: table_id, text: extract_plain_text(name_el))
666
- end
667
- col_count = table_column_count(table)
668
- @output << "<div class=\"table-scroll-wrapper\">"
669
- tag("table", attrs) do
670
- name_el = safe_attr(table, :fmt_name) || safe_attr(table, :name)
671
- if name_el
672
- @output << "<caption>"
673
- render_inline_element(name_el)
674
- @output << "</caption>"
675
- end
676
- @output << "<colgroup>" if table.colgroup
677
- render_table_colgroup(table.colgroup) if table.colgroup
678
- @output << "</colgroup>" if table.colgroup
679
- render_table_section(table.thead, "thead") if table.thead
680
- render_table_section(table.tbody, "tbody") if table.tbody
681
- if table.tfoot || (table.note && !table.note.empty?)
682
- @output << "<tfoot>"
683
- render_table_section_rows(table.tfoot) if table.tfoot
684
- if table.note && !table.note.empty?
685
- @output << "<tr><td colspan=\"#{col_count}\" class=\"table-notes\">"
686
- table.note.each { |n| render_note(n) }
687
- @output << "</td></tr>"
688
- end
689
- @output << "</tfoot>"
690
- end
691
- end
692
- @output << "</div>"
693
- end
694
-
695
- def table_column_count(table)
696
- if table.colgroup&.col && !table.colgroup.col.empty?
697
- return table.colgroup.col.size
698
- end
699
-
700
- # Walk all rows to find max column count, accounting for colspan
701
- max_cols = 0
702
- %i[thead tbody tfoot].each do |section|
703
- sec = table.public_send(section)
704
- next unless sec&.tr
705
-
706
- sec.tr.each do |tr|
707
- cols = 0
708
- Array(tr.th).each do |th|
709
- cols += th.colspan && th.colspan > 1 ? th.colspan : 1
710
- end
711
- Array(tr.td).each do |td|
712
- cols += td.colspan && td.colspan > 1 ? td.colspan : 1
713
- end
714
- max_cols = cols if cols > max_cols
715
- end
716
- end
717
- max_cols.positive? ? max_cols : 1
718
- end
719
-
720
- def render_table_colgroup(colgroup)
721
- colgroup.col&.each do |col|
722
- attrs = element_attrs(style: col.width ? "width: #{col.width}" : nil)
723
- @output << "<col#{attrs}>"
724
- end
725
- end
726
-
727
- def render_table_section(section, tag_name)
728
- @output << "<#{tag_name}>"
729
- render_table_section_rows(section)
730
- @output << "</#{tag_name}>"
731
- end
732
-
733
- def render_table_section_rows(section)
734
- section.tr&.each do |tr|
735
- @output << "<tr>"
736
- walked = walk_ordered(tr) do |type, obj|
737
- next unless type == :element
738
-
739
- render_table_cell(obj)
740
- end
741
- unless walked
742
- Array(tr.th).each { |th| render_table_cell(th, "th") }
743
- Array(tr.td).each { |td| render_table_cell(td, "td") }
744
- end
745
- @output << "</tr>"
746
- end
747
- end
748
-
749
- def render_unordered_list(ul, **_opts)
750
- attrs = element_attrs(id: safe_attr(ul, :id))
751
- tag("ul", attrs) do
752
- ul.listitem&.each { |li| render_list_item(li) }
753
- end
754
- end
755
-
756
- def render_table_cell(cell, force_tag = nil)
757
- tag_name = force_tag || (cell.is_a?(Metanorma::Document::Components::Tables::HeaderTableCell) ? "th" : "td")
758
- attrs = element_attrs(
759
- colspan: safe_attr(cell, :colspan),
760
- rowspan: safe_attr(cell, :rowspan),
761
- align: safe_attr(cell, :alignment),
762
- valign: safe_attr(cell, :vertical_alignment),
763
- )
764
- @output << "<#{tag_name}#{attrs}>"
765
- render_cell_content(cell)
766
- @output << "</#{tag_name}>"
767
- end
768
-
769
- def render_ordered_list(ol, **_opts)
770
- attrs = element_attrs(id: safe_attr(ol, :id),
771
- start: safe_attr(ol, :start), type: safe_attr(ol, :type_attr))
772
- tag("ol", attrs) do
773
- ol.listitem&.each { |li| render_list_item(li) }
774
- end
775
- end
776
-
777
- def render_list_item(li)
778
- li_id = safe_attr(li, :id)
779
- attrs = li_id ? %( id="#{escape_html(li_id)}") : ""
780
- @output << "<li#{attrs}>"
781
- render_mixed_content_in_order(li)
782
- @output << "</li>"
783
- end
784
-
785
- # Render all children of a mixed-content node in document order,
786
- # dispatching block elements to their render methods.
787
- def render_mixed_content_in_order(node)
788
- node.each_mixed_content do |child|
789
- case child
790
- when String
791
- @output << escape_html(child)
792
- else
793
- if block_element?(child)
794
- render(child)
795
- else
796
- render_inline_element(child)
797
- end
798
- end
799
- end
800
- end
801
-
802
- # Collect all renderable children from a node in document order,
803
- # sorted by displayorder when available. Uses walk_ordered to traverse
804
- # element_order, and also gathers typed attributes that may not appear
805
- # in element_order (e.g. terms, definitions on section models).
806
- def collect_ordered_children(section)
807
- children = []
808
-
809
- walk_ordered(section) do |type, obj|
810
- next if %i[text tab].include?(type)
811
-
812
- children << obj
813
- end
814
-
815
- # Gather typed attributes that may not appear in element_order
816
- supplementary_attrs = %i[terms definitions]
817
- supplementary_attrs.each do |attr|
818
- val = safe_attr(section, attr)
819
- next if val.nil?
820
-
821
- Array(val).each do |v|
822
- children << v unless children.include?(v)
823
- end
824
- end
825
-
826
- children.compact!
827
- sort_by_displayorder(children)
828
- end
829
-
830
- # Render children of a section in displayorder, skipping title elements.
831
- def render_ordered_content(section, level = 1)
832
- children = collect_ordered_children(section)
833
- children.each do |node|
834
- next if node.is_a?(String)
835
- next if is_title_element?(node, section)
836
-
837
- render(node, level: level + 1)
838
- end
839
- end
840
-
841
- def sort_by_displayorder(children)
842
- children.sort_by do |node|
843
- order = if node.is_a?(Lutaml::Model::Serializable) &&
844
- node.class.attributes.key?(:displayorder)
845
- node.displayorder
846
- end
847
- order &&= order.to_i
848
- order || Float::INFINITY
849
- end
850
- end
851
-
852
- def render_definition_list(dl, **_opts)
853
- attrs = element_attrs(id: safe_attr(dl, :id))
854
- tag("dl", attrs) do
855
- dl.dt&.each_with_index do |dt, i|
856
- @output << "<dt>"
857
- render_mixed_inline(dt)
858
- @output << "</dt>"
859
- dd = dl.dd&.[](i)
860
- if dd
861
- @output << "<dd>"
862
- render_mixed_inline(dd)
863
- @output << "</dd>"
864
- end
865
- end
866
- end
867
- end
868
-
869
- def render_figure(figure, **_opts)
870
- drop = Drops::FigureDrop.from_model(figure, renderer: renderer_context)
871
- @output << render_liquid("_figure.html.liquid", { "block" => drop })
872
- end
873
-
874
- def render_image(image)
875
- src_val = safe_attr(image, :src) || safe_attr(image, :source)
876
- attrs = element_attrs(
877
- id: safe_attr(image, :id),
878
- src: src_val,
879
- alt: safe_attr(image, :alt),
880
- height: safe_attr(image, :height),
881
- width: safe_attr(image, :width),
882
- )
883
- @output << "<img#{attrs} />"
884
- end
885
-
886
- def render_video(video)
887
- attrs = element_attrs(
888
- id: safe_attr(video, :id),
889
- src: safe_attr(video, :src),
890
- )
891
- @output << "<video#{attrs} controls></video>"
892
- end
893
-
894
- def render_audio(audio)
895
- attrs = element_attrs(
896
- id: safe_attr(audio, :id),
897
- src: safe_attr(audio, :src),
898
- )
899
- @output << "<audio#{attrs} controls></audio>"
900
- end
901
-
902
- def render_note(note, **_opts)
903
- drop = Drops::NoteDrop.from_model(note, renderer: renderer_context)
904
- @output << render_liquid("_note.html.liquid", { "block" => drop })
905
- end
906
-
907
- def render_example(example, **_opts)
908
- drop = Drops::ExampleDrop.from_model(example,
909
- renderer: renderer_context)
910
- @output << render_liquid("_example.html.liquid", { "block" => drop })
911
- end
912
-
913
- def render_sourcecode(sc, **_opts)
914
- drop = Drops::SourcecodeDrop.from_model(sc, renderer: renderer_context)
915
- @output << render_liquid("_sourcecode.html.liquid", { "block" => drop })
916
- end
917
-
918
- def render_formula(formula, **_opts)
919
- drop = Drops::FormulaDrop.from_model(formula,
920
- renderer: renderer_context)
921
- @output << render_liquid("_formula.html.liquid", { "block" => drop })
922
- end
923
-
924
- def render_quote(quote, **_opts)
925
- attrs = element_attrs(id: safe_attr(quote, :id), class: "quote")
926
- tag("blockquote", attrs) do
927
- quote.paragraphs&.each { |para| render_paragraph(para) }
928
- quote.ul&.each { |ul| render_unordered_list(ul) }
929
- quote.ol&.each { |ol| render_ordered_list(ol) }
930
- if quote.attribution
931
- @output << "<footer>"
932
- render_mixed_inline(quote.attribution)
933
- @output << "</footer>"
934
- end
935
- end
936
- end
937
-
938
- def render_admonition(admonition, **_opts)
939
- drop = Drops::AdmonitionDrop.from_model(admonition,
940
- renderer: renderer_context)
941
- @output << render_liquid("_admonition.html.liquid", { "block" => drop })
942
- end
943
-
944
- def render_bookmark(bookmark, **_opts)
945
- @output << %(<a id="#{escape_html(safe_attr(bookmark, :id).to_s)}"></a>)
946
- end
947
-
948
- # Renders the typed child collections of a block element (paragraphs,
949
- # lists, nested blocks) in a standard order. Used by Drops and section
950
- # rendering to avoid duplicating the enumeration in each consumer.
951
- #
952
- # +children+ is a Hash mapping attr names to render-method symbols:
953
- # { paragraphs: :render_paragraph, ul: :render_unordered_list, ... }
954
- #
955
- # Each key is sent to +model+ via safe_attr; non-nil results are
956
- # dispatched to the corresponding render method.
957
- def render_block_children(model, children:)
958
- children.each do |attr, render_method|
959
- values = safe_attr(model, attr)
960
- next if values.nil?
961
-
962
- Array(values).each { |v| public_send(render_method, v) }
963
- end
964
- end
965
-
966
- # Standard child set for container blocks (example, note, etc.)
967
- BLOCK_CHILDREN = {
968
- paragraphs: :render_paragraph,
969
- ul: :render_unordered_list,
970
- ol: :render_ordered_list,
971
- dl: :render_definition_list,
972
- sourcecode: :render_sourcecode,
973
- table: :render_table,
974
- figure: :render_figure,
975
- quote: :render_quote,
976
- formula: :render_formula,
977
- }.freeze
978
-
979
- # Minimal child set for simple blocks (admonition, note with content only)
980
- SIMPLE_CHILDREN = {
981
- paragraphs: :render_paragraph,
982
- }.freeze
983
-
984
- # Note-style child set (paragraphs + lists + dl, no nested blocks)
985
- NOTE_CHILDREN = {
986
- paragraphs: :render_paragraph,
987
- ul: :render_unordered_list,
988
- ol: :render_ordered_list,
989
- dl: :render_definition_list,
990
- quote: :render_quote,
991
- }.freeze
992
-
993
- # --- Section rendering ---
994
-
995
- def render_basic_section(section, level: 1, **_opts)
996
- attrs = element_attrs(id: safe_attr(section, :id))
997
- tag("div", attrs) do
998
- render_section_title(section, level)
999
- section.blocks&.each { |block| render(block) }
1000
- section.notes&.each { |note| render_note(note) }
1001
- end
1002
- end
1003
-
1004
- def render_hierarchical_section(section, level: 1, **_opts)
1005
- attrs = element_attrs(id: safe_attr(section, :id))
1006
- tag("div", attrs) do
1007
- render_section_title(section, level)
1008
- render_section_content(section, level)
1009
- section.subsections&.each { |sub| render(sub, level: level + 1) }
1010
- end
1011
- end
1012
-
1013
- def render_content_section(section, level: 1, **_opts)
1014
- render_hierarchical_section(section, level: level, **_opts)
1015
- end
1016
-
1017
- def render_section_title(section, level)
1018
- titles = section.title
1019
- return unless titles && !titles.empty?
1020
-
1021
- h = "h#{[[level, 6].min, 1].max}"
1022
- title_text = extract_title_text(titles)
1023
- @output << "<#{h}>#{escape_html(title_text)}</#{h}>"
1024
- end
1025
-
1026
- def render_section_content(section, _level)
1027
- section.blocks&.each { |block| render(block) }
1028
- section.notes&.each { |note| render_note(note) }
1029
- end
1030
-
1031
- # --- Inline content rendering ---
1032
-
1033
- # Walk element_order in document order, resolving each element to its
1034
- # Ruby object via the XML mapping, and yielding (element_order_entry, resolved_object)
1035
- # to the given block. Handles tab elements as nbsp. Skips elements not in the mapping.
1036
- #
1037
- # This is the single ordered-walk primitive used by all mixed content renderers:
1038
- # - render_mixed_inline (inline-only)
1039
- # - render_cell_content (mixed block/inline)
1040
- # - render_semx_content (filtered display attrs only)
1041
- def walk_ordered(node, allow_filter: nil)
1042
- return false unless node.is_a?(Lutaml::Model::Serializable)
1043
- return false unless node.element_order.is_a?(Array) && !node.element_order.empty?
1044
-
1045
- xml_mapping = node.class.mappings_for(:xml, node.lutaml_register)
1046
- return false unless xml_mapping
1047
-
1048
- element_to_attr = {}
1049
- xml_mapping.mapping_elements_hash.each_value do |rule_or_array|
1050
- Array(rule_or_array).each do |rule|
1051
- element_to_attr[rule.name] = rule.to
1052
- element_to_attr[rule.name.to_s] = rule.to if rule.name.is_a?(Symbol)
1053
- end
1054
- end
1055
-
1056
- skip_indices = build_semx_skip_set(node)
691
+ # --- Delegation to sub-renderers ---
1057
692
 
1058
- indices = Hash.new(0)
1059
-
1060
- node.element_order.each_with_index do |el, i|
1061
- next if skip_indices.include?(i)
1062
-
1063
- if el.text?
1064
- text = el.text_content
1065
- yield :text, text if text && block_given?
1066
- elsif el.element?
1067
- # Handle <tab/> elements
1068
- if el.name == "tab"
1069
- yield :tab, nil if block_given?
1070
- next
1071
- end
1072
-
1073
- attr_name = element_to_attr[el.name]
1074
- next unless attr_name
1075
-
1076
- # Apply optional filter (used by semx to skip semantic attrs)
1077
- next if allow_filter && !allow_filter.include?(attr_name)
1078
-
1079
- coll = node.public_send(attr_name)
1080
- obj = if coll.is_a?(Array)
1081
- idx = indices[attr_name]
1082
- indices[attr_name] += 1
1083
- coll[idx]
1084
- else
1085
- coll
1086
- end
1087
- yield :element, obj if obj && block_given?
1088
- end
1089
- end
1090
- true
1091
- end
1092
-
1093
- # In presentation XML, semantic elements are followed by <semx> wrappers
1094
- # or <fmt-*> display elements. Skip source elements to avoid duplicates.
1095
- def build_semx_skip_set(node)
1096
- skip_after = {
1097
- "link" => "semx",
1098
- "xref" => "semx",
1099
- "eref" => "semx",
1100
- "stem" => nil,
1101
- "concept" => "fmt-concept",
1102
- "refterm" => nil,
1103
- "renderterm" => nil,
1104
- "origin" => "semx",
1105
- }
1106
- skip = {}
1107
- node.element_order.each_with_index do |el, i|
1108
- next unless el.element?
1109
-
1110
- next_tag = skip_after[el.name]
1111
- next unless next_tag
1112
-
1113
- next_el = node.element_order[i + 1]
1114
- if next_tag.nil?
1115
- skip[i] = true
1116
- elsif next_el&.element? && next_el.name == next_tag
1117
- skip[i] = true
1118
- end
1119
- end
1120
- skip
1121
- end
1122
-
1123
- # Table cells can contain both block-level content (p, ul, ol, dl)
1124
- # and inline content (text, em, strong, etc.) in document order.
1125
- def render_cell_content(cell)
1126
- walked = walk_ordered(cell) do |type, obj|
1127
- case type
1128
- when :text
1129
- @output << escape_html(obj)
1130
- when :tab
1131
- @output << "\u00a0\u00a0"
1132
- when :element
1133
- if block_element?(obj)
1134
- render(obj)
1135
- else
1136
- render_inline_element(obj)
1137
- end
1138
- end
1139
- end
1140
- unless walked
1141
- render_mixed_content_in_order(cell)
1142
- end
693
+ # Inline rendering delegation
694
+ def walk_ordered(node, allow_filter: nil, &)
695
+ @inline_renderer.walk_ordered(node, allow_filter: allow_filter, &)
1143
696
  end
1144
697
 
1145
698
  def render_mixed_inline(node)
1146
- # Models using map_all_content: raw XML in content
1147
- if raw_content_node?(node)
1148
- raw = node.content
1149
- if raw.is_a?(String) && !raw.strip.empty?
1150
- @output << render_raw_content(raw)
1151
- return
1152
- end
1153
- end
1154
-
1155
- if node.is_a?(Lutaml::Model::Serializable) && node.element_order && !node.element_order.empty?
1156
- render_ordered_inline(node)
1157
- elsif node.is_a?(Lutaml::Model::Serializable)
1158
- node.each_mixed_content do |child|
1159
- case child
1160
- when String
1161
- @output << escape_html(child)
1162
- else
1163
- render_inline_element(child)
1164
- end
1165
- end
1166
- else
1167
- render_inline_collections(node)
1168
- end
699
+ @inline_renderer.render_mixed_inline(node)
1169
700
  end
1170
701
 
1171
- def raw_content_node?(node)
1172
- node.is_a?(Lutaml::Model::Serializable) &&
1173
- node.content.is_a?(String)
1174
- rescue NoMethodError
1175
- false
1176
- end
702
+ def render_cell_content(cell) = @inline_renderer.render_cell_content(cell)
703
+ def render_em(el) = @inline_renderer.render_em(el)
704
+ def render_strong(el) = @inline_renderer.render_strong(el)
705
+ def render_tt(el) = @inline_renderer.render_tt(el)
706
+ def render_sub(el) = @inline_renderer.render_sub(el)
707
+ def render_sup(el) = @inline_renderer.render_sup(el)
708
+ def render_small_caps(el) = @inline_renderer.render_small_caps(el)
709
+ def render_underline(el) = @inline_renderer.render_underline(el)
710
+ def render_strike(el) = @inline_renderer.render_strike(el)
711
+ def render_br(*) = @inline_renderer.render_br
712
+ def render_tab(*) = @inline_renderer.render_tab
713
+ def render_span(el) = @inline_renderer.render_span(el)
714
+ def render_fn_inline(el) = @inline_renderer.render_fn_inline(el)
715
+ def render_stem(el) = @inline_renderer.render_stem(el)
716
+ def render_semx_inline(el) = @inline_renderer.render_semx_inline(el)
717
+ def render_fmt_xref(el) = @inline_renderer.render_fmt_xref(el)
718
+ def render_comma(*) = @inline_renderer.render_comma
719
+ def render_math(el) = @inline_renderer.render_math(el)
720
+ def render_asciimath(el) = @inline_renderer.render_asciimath(el)
721
+ def render_index(el) = @inline_renderer.render_index(el)
722
+ def render_note_inline(el) = @inline_renderer.render_note_inline(el)
1177
723
 
1178
- # Iterate element_order directly, preserving whitespace text nodes
1179
- # that each_mixed_content drops (it skips text where text.strip.empty?)
1180
- def render_ordered_inline(node)
1181
- walked = walk_ordered(node) do |type, obj|
1182
- case type
1183
- when :text
1184
- @output << escape_html(obj)
1185
- when :tab
1186
- @output << "\u00a0\u00a0"
1187
- when :element
1188
- render_inline_element(obj)
1189
- end
1190
- end
1191
- unless walked
1192
- node.each_mixed_content do |child|
1193
- case child
1194
- when String
1195
- @output << escape_html(child)
1196
- else
1197
- render_inline_element(child)
1198
- end
1199
- end
1200
- end
724
+ def render_semx_content(el,
725
+ **)
726
+ @inline_renderer.render_semx_content(el, **)
1201
727
  end
1202
728
 
1203
- # Inline adapter methods for registry dispatch
1204
-
1205
- def render_em(el)
1206
- render_inline_tag("em", el)
1207
- end
729
+ def render_stem_content(stem) = @inline_renderer.render_stem_content(stem)
730
+ def render_link(link) = @inline_renderer.render_link(link)
731
+ def render_xref(xref) = @inline_renderer.render_xref(xref)
732
+ def render_eref(eref) = @inline_renderer.render_eref(eref)
733
+ def render_fn(fn) = @inline_renderer.render_fn(fn)
734
+ def render_concept(concept) = @inline_renderer.render_concept(concept)
735
+ def render_fmt_stem(fmt_stem) = @inline_renderer.render_fmt_stem(fmt_stem)
736
+ def render_mixed_content_in_order(node) = @inline_renderer.render_mixed_content_in_order(node)
1208
737
 
1209
- def render_strong(el)
1210
- render_inline_tag("strong", el)
738
+ # Block rendering delegation
739
+ def render_paragraph(p,
740
+ **)
741
+ @block_renderer.render_paragraph(p, **)
1211
742
  end
1212
743
 
1213
- def render_tt(el)
1214
- render_inline_tag("tt", el)
744
+ def render_table(table,
745
+ **)
746
+ @block_renderer.render_table(table, **)
1215
747
  end
1216
748
 
1217
- def render_sub(el)
1218
- render_inline_tag("sub", el)
749
+ def render_unordered_list(ul,
750
+ **)
751
+ @block_renderer.render_unordered_list(ul, **)
1219
752
  end
1220
753
 
1221
- def render_sup(el)
1222
- render_inline_tag("sup", el)
754
+ def render_ordered_list(ol,
755
+ **)
756
+ @block_renderer.render_ordered_list(ol, **)
1223
757
  end
1224
758
 
1225
- def render_small_caps(el)
1226
- render_inline_tag("span", el, class: "small-caps")
759
+ def render_definition_list(dl,
760
+ **)
761
+ @block_renderer.render_definition_list(dl, **)
1227
762
  end
1228
763
 
1229
- def render_underline(el)
1230
- render_inline_tag("u", el)
764
+ def render_figure(figure,
765
+ **)
766
+ @block_renderer.render_figure(figure, **)
1231
767
  end
1232
768
 
1233
- def render_strike(el)
1234
- render_inline_tag("s", el)
1235
- end
769
+ def render_image(image) = @block_renderer.render_image(image)
770
+ def render_video(video) = @block_renderer.render_video(video)
771
+ def render_audio(audio) = @block_renderer.render_audio(audio)
772
+ def render_note(note, **) = @block_renderer.render_note(note, **)
1236
773
 
1237
- def render_br(*)
1238
- @output << "<br />"
774
+ def render_example(example,
775
+ **)
776
+ @block_renderer.render_example(example, **)
1239
777
  end
1240
778
 
1241
- def render_tab(*)
1242
- @output << "\u00a0\u00a0"
779
+ def render_sourcecode(sc,
780
+ **)
781
+ @block_renderer.render_sourcecode(sc, **)
1243
782
  end
1244
783
 
1245
- def render_span(el)
1246
- xml_class = safe_attr(el, :class_attr).to_s
1247
- html_class = html_class_for_span(xml_class) unless xml_class.empty?
1248
- attrs = element_attrs(style: safe_attr(el, :style), class: html_class)
1249
- tag("span", attrs) { render_mixed_inline(el) }
784
+ def render_formula(formula,
785
+ **)
786
+ @block_renderer.render_formula(formula, **)
1250
787
  end
1251
788
 
1252
- def render_fn_inline(el)
1253
- render_fn(el)
789
+ def render_quote(quote,
790
+ **)
791
+ @block_renderer.render_quote(quote, **)
1254
792
  end
1255
793
 
1256
- def render_stem(el)
1257
- @output << render_stem_content(el)
794
+ def render_admonition(admonition,
795
+ **)
796
+ @block_renderer.render_admonition(admonition, **)
1258
797
  end
1259
798
 
1260
- def render_semx_inline(el)
1261
- render_semx_content(el)
799
+ def render_bookmark(bookmark,
800
+ **)
801
+ @block_renderer.render_bookmark(bookmark, **)
1262
802
  end
1263
803
 
1264
- def render_fmt_xref(el)
1265
- target = safe_attr(el, :target) || safe_attr(el, :to_attr)
1266
- if target
1267
- attrs = element_attrs(href: "##{escape_html(target)}", class: "xref")
1268
- tag("a", attrs) { render_mixed_inline(el) }
1269
- else
1270
- render_mixed_inline(el)
1271
- end
804
+ def render_block_children(model,
805
+ children:)
806
+ @block_renderer.render_block_children(model, children: children)
1272
807
  end
1273
808
 
1274
- def render_comma(*)
1275
- @output << ", "
1276
- end
809
+ def render_note_children(model) = @block_renderer.render_note_children(model)
810
+ def render_simple_children(model) = @block_renderer.render_simple_children(model)
811
+ def render_full_block_children(model) = @block_renderer.render_full_block_children(model)
1277
812
 
1278
- def render_math(el)
1279
- @output << el.content.to_s
813
+ # Section rendering delegation
814
+ def render_basic_section(section,
815
+ **)
816
+ @section_renderer.render_basic_section(section, **)
1280
817
  end
1281
818
 
1282
- def render_asciimath(el)
1283
- @output << %(<span class="stem">#{escape_html(Array(el.text).join)}</span>)
819
+ def render_hierarchical_section(section,
820
+ **)
821
+ @section_renderer.render_hierarchical_section(section, **)
1284
822
  end
1285
823
 
1286
- def render_index(el)
1287
- collect_index_term(el)
1288
- ""
824
+ def render_content_section(section,
825
+ **)
826
+ @section_renderer.render_content_section(section, **)
1289
827
  end
1290
828
 
1291
- def render_note_inline(el)
1292
- render_note(el)
829
+ def render_ordered_content(section,
830
+ level = 1)
831
+ @section_renderer.render_ordered_content(section, level)
1293
832
  end
1294
833
 
1295
- def render_inline_collections(node)
1296
- texts = node.text
1297
- if texts.is_a?(Array)
1298
- texts.each do |t|
1299
- if t.is_a?(String)
1300
- @output << escape_html(t)
1301
- else
1302
- render_inline_element(t)
1303
- end
1304
- end
1305
- elsif texts.is_a?(String)
1306
- @output << escape_html(texts)
1307
- end
834
+ def collect_ordered_children(section) = @section_renderer.collect_ordered_children(section)
835
+ def sort_by_displayorder(children) = @section_renderer.sort_by_displayorder(children)
1308
836
 
1309
- inline_attrs = %i[em strong smallcap sub sup tt underline strike
1310
- xref eref link span stem concept fn br tab keyword
1311
- fmt_annotation_start fmt_annotation_end
1312
- fmt_stem fmt_fn_label fmt_concept
1313
- bookmark image semx fmt_xref_label]
1314
- inline_attrs.each do |attr|
1315
- values = safe_attr(node, attr)
1316
- next if values.nil?
1317
-
1318
- Array(values).each { |v| render_inline_element(v) }
1319
- end
837
+ def render_preface(preface,
838
+ **)
839
+ @section_renderer.render_preface(preface, **)
1320
840
  end
1321
841
 
1322
- # Render SemxElement display content only, skipping semantic linkage.
1323
- # semx wraps both semantic data (origin, xref, source, etc.) and
1324
- # display content (fmt-xref, span, strong, etc.). Only render display.
1325
- def render_semx_content(element, **_opts)
1326
- display_attrs = %i[text fmt_xref fmt_link fmt_concept span strong em sup p semx
1327
- asciimath math sub_child tt_child br_child tab_child
1328
- stem_child figure_child formula_child sourcecode_child]
1329
- label_stripped = false
1330
-
1331
- walked = walk_ordered(element,
1332
- allow_filter: display_attrs) do |type, obj|
1333
- case type
1334
- when :text
1335
- text = obj
1336
- if !label_stripped
1337
- text = deduplicate_semx_text(text, @output)
1338
- label_stripped = true
1339
- end
1340
- @output << escape_html(text)
1341
- when :element
1342
- if obj.is_a?(Metanorma::Document::Components::Paragraphs::ParagraphBlock)
1343
- render_paragraph(obj)
1344
- else
1345
- render_inline_element(obj)
1346
- end
1347
- end
1348
- end
1349
-
1350
- unless walked
1351
- display_attrs.each do |attr|
1352
- val = safe_attr(element, attr)
1353
- next if val.nil?
1354
-
1355
- if val.is_a?(Array)
1356
- val.each do |v|
1357
- if v.is_a?(Metanorma::Document::Components::Paragraphs::ParagraphBlock)
1358
- render_paragraph(v)
1359
- else
1360
- render_inline_element(v)
1361
- end
1362
- end
1363
- elsif val.is_a?(String)
1364
- @output << escape_html(val)
1365
- else
1366
- render_inline_element(val)
1367
- end
1368
- end
1369
- end
1370
- end
1371
-
1372
- def deduplicate_semx_text(semx_text, output)
1373
- first_word = semx_text[/\A\s*(\S+)/, 1]
1374
- return semx_text unless first_word
1375
-
1376
- tail = output[-200..]
1377
- return semx_text unless tail&.rstrip&.end_with?(first_word)
1378
-
1379
- semx_text.sub(/\A\s*#{Regexp.escape(first_word)}\s*/, "")
1380
- end
1381
-
1382
- def render_inline_tag(tag_name, element, **extra_attrs)
1383
- tag(tag_name, element_attrs(**extra_attrs)) do
1384
- render_mixed_inline(element)
1385
- end
1386
- end
1387
-
1388
- # Process raw XML content from map_all_content models.
1389
- # Strips source elements (xref, eref, stem) that have a following <semx>
1390
- # wrapper, keeping only the semx display content.
1391
- def render_raw_content(raw_xml)
1392
- doc = Nokogiri::XML.fragment(raw_xml)
1393
- # Convert fmt-link elements to HTML <a> tags before stripping wrappers
1394
- doc.css("fmt-link").each do |el|
1395
- target = el["target"] || el["href"]
1396
- if target
1397
- display_text = target.delete_prefix("mailto:")
1398
- a = doc.document.create_element("a", display_text, "href" => target)
1399
- el.replace(a)
1400
- else
1401
- el.replace(el.children)
1402
- end
1403
- end
1404
- # Remove source elements that precede a <semx> sibling,
1405
- # deduplicating any label text that appears in both the source
1406
- # paragraph and the semx display content.
1407
- doc.traverse do |node|
1408
- next unless node.element?
1409
- next unless %w[xref eref stem link].include?(node.name)
1410
-
1411
- next_sib = node.next_sibling
1412
- while next_sib.is_a?(Nokogiri::XML::Text) && next_sib.text.strip.empty?
1413
- next_sib = next_sib.next_sibling
1414
- end
1415
- next unless next_sib&.element? && next_sib.name == "semx"
1416
-
1417
- deduplicate_semx_label(node, next_sib)
1418
- node.remove
1419
- end
1420
- # Strip presentation wrappers, keeping inner content
1421
- %w[semx fmt-xref].each do |tag|
1422
- doc.css(tag).each { |el| el.replace(el.children) }
1423
- end
1424
- # Remap XML class names to HTML-specific class names
1425
- doc.css("[class]").each do |el|
1426
- el["class"] = el["class"].split(/\s+/).map do |c|
1427
- html_class_for_span(c)
1428
- end.join(" ")
1429
- end
1430
- doc.inner_html
1431
- end
1432
-
1433
- def deduplicate_semx_label(source_node, semx_node)
1434
- first_text = semx_node.children.find do |c|
1435
- c.text? && !c.text.strip.empty?
1436
- end
1437
- return unless first_text
1438
-
1439
- semx_prefix = first_text.text[/\A(\s*\S+)/, 1]
1440
- return unless semx_prefix && !semx_prefix.strip.empty?
1441
-
1442
- prev = source_node.previous_sibling
1443
- return unless prev.is_a?(Nokogiri::XML::Text)
1444
-
1445
- label = semx_prefix.strip
1446
- prev_text = prev.text.rstrip
1447
- return unless prev_text.end_with?(label)
1448
-
1449
- prev.content = prev_text.sub(/#{Regexp.escape(label)}\s*\z/, "")
1450
- first_text.content = first_text.text.sub(
1451
- /\A\s*#{Regexp.escape(label)}\s*/, ""
1452
- )
1453
- end
1454
-
1455
- def render_link(link)
1456
- target = safe_attr(link, :target) || safe_attr(link, :href)
1457
- attrs = element_attrs(href: target, id: safe_attr(link, :id))
1458
- tag("a", attrs) do
1459
- content = safe_attr(link, :content)
1460
- if content && !Array(content).join.strip.empty?
1461
- render_mixed_inline(link)
1462
- else
1463
- display_text = target.to_s.delete_prefix("mailto:")
1464
- @output << escape_html(display_text)
1465
- end
1466
- end
1467
- end
1468
-
1469
- def render_xref(xref)
1470
- target = safe_attr(xref, :target) || safe_attr(xref, :to_attr)
1471
- attrs = element_attrs(href: "##{escape_html(target)}",
1472
- id: safe_attr(xref,
1473
- :id))
1474
- tag("a", attrs) { render_mixed_inline(xref) }
1475
- end
1476
-
1477
- def render_eref(eref)
1478
- citeas = safe_attr(eref, :citeas)
1479
- if citeas
1480
- @output << escape_html(citeas)
1481
- else
1482
- render_mixed_inline(eref)
1483
- end
1484
- end
1485
-
1486
- def render_fn(fn)
1487
- fn_id = safe_attr(fn, :id)
1488
- number = @footnote_collector.register(fn)
1489
-
1490
- attrs = element_attrs(id: fn_id, class: "fn-marker")
1491
- tag("span", attrs) do
1492
- label = safe_attr(fn, :fn_label) || safe_attr(fn, :reference)
1493
- if label
1494
- @output << "<sup><a href=\"##{escape_html("footnote-#{number}")}\" " \
1495
- "class=\"fn-link\" id=\"#{escape_html("fnref-#{number}")}\">" \
1496
- "#{escape_html(label.to_s)}</a></sup>"
1497
- end
1498
- end
1499
- end
1500
-
1501
- def render_concept(concept)
1502
- render_mixed_inline(concept)
1503
- end
1504
-
1505
- # --- Stem/math rendering ---
1506
-
1507
- def render_fmt_stem(fmt_stem)
1508
- semx_items = Array(fmt_stem.semx)
1509
- return if semx_items.empty?
1510
-
1511
- semx = semx_items.first
1512
- math_items = Array(semx.math)
1513
- ascii_items = Array(semx.asciimath)
1514
-
1515
- # Collect source formats for interactive copy dropdown
1516
- source_formats = {}
1517
- if ascii_items.any?
1518
- ascii_text = ascii_items.map { |a| a.text.to_s.strip }.join
1519
- source_formats["asciimath"] = ascii_text unless ascii_text.empty?
1520
- end
1521
-
1522
- data_attrs = ""
1523
- unless source_formats.empty?
1524
- data_attrs = " data-stem-formats='#{escape_html(source_formats.to_json)}'"
1525
- end
1526
-
1527
- if math_items.any?
1528
- content = math_items.map { |m| m.content.to_s }.join
1529
- unless content.empty?
1530
- @output << "<span class=\"math-container\"#{data_attrs}>"
1531
- @output << "<math xmlns=\"http://www.w3.org/1998/Math/MathML\">#{content}</math>"
1532
- @output << "</span>"
1533
- end
1534
- elsif ascii_items.any?
1535
- # No MathML — render asciimath as fallback
1536
- text = ascii_items.map { |a| a.text.to_s.strip }.join
1537
- @output << "<span class=\"stem\"#{data_attrs}>#{escape_html(text)}</span>"
1538
- end
1539
- end
1540
-
1541
- def render_stem_content(stem)
1542
- return "" if stem.nil?
1543
-
1544
- # StemInlineElement — source element, skip (rendered via FmtStemElement)
1545
- if stem.is_a?(Metanorma::Document::Components::Inline::StemInlineElement)
1546
- return ""
1547
- end
1548
-
1549
- # FmtStemElement — already handled by render_fmt_stem
1550
- if stem.is_a?(Metanorma::Document::Components::Inline::FmtStemElement)
1551
- return ""
1552
- end
1553
-
1554
- # TextElements::StemElement — block math (no fmt- counterpart for display formulas)
1555
- if stem.is_a?(Metanorma::Document::Components::TextElements::StemElement)
1556
- if stem.math
1557
- math_val = stem.math
1558
- return math_val.to_xml if math_val.is_a?(Nokogiri::XML::Node)
1559
- return math_val.to_xml if defined?(Moxml::Document) && math_val.is_a?(Moxml::Document)
1560
-
1561
- math_items = Array(math_val)
1562
- math_items.join
1563
- elsif stem.asciimath
1564
- text = extract_text_value(stem.asciimath)
1565
- return %(<span class="stem">#{escape_html(text)}</span>)
1566
- end
1567
- if stem.latexmath
1568
- text = extract_text_value(stem.latexmath)
1569
- return %(<span class="stem">#{escape_html(text)}</span>)
1570
- end
1571
- end
1572
-
1573
- text = extract_text_value(stem)
1574
- text.empty? ? "" : %(<span class="stem">#{escape_html(text)}</span>)
1575
- end
842
+ # Pubid rendering delegation
843
+ def parse_pubid(docidentifier_string) = @pubid_renderer.parse_pubid(docidentifier_string)
844
+ def pubid_to_html(identifier) = @pubid_renderer.pubid_to_html(identifier)
1576
845
 
1577
846
  # --- Helper methods ---
1578
847
 
1579
- def tag(name, attrs_str)
1580
- @output << "<#{name}#{attrs_str}>"
1581
- yield
1582
- @output << "</#{name}>"
1583
- end
1584
-
1585
848
  def element_attrs(**attrs)
1586
849
  parts = []
1587
850
  attrs.each do |k, v|
@@ -1644,7 +907,6 @@ module Metanorma
1644
907
  end
1645
908
 
1646
909
  def extract_block_label(block, default)
1647
- # Presentation XML puts label in <name> child element
1648
910
  names = safe_attr(block, :name)
1649
911
  if names && !names.empty?
1650
912
  name = names.is_a?(Array) ? names.first : names
@@ -1652,7 +914,6 @@ module Metanorma
1652
914
  return text unless text.to_s.strip.empty?
1653
915
  end
1654
916
 
1655
- # Fallback: autonum XML attribute
1656
917
  autonum = safe_attr(block, :autonum)
1657
918
  if autonum && !autonum.to_s.empty?
1658
919
  number = autonum.to_s
@@ -1669,9 +930,10 @@ module Metanorma
1669
930
  end
1670
931
 
1671
932
  def extract_title_text(titles)
1672
- return "" if titles.nil? || titles.empty?
933
+ return "" if titles.nil?
934
+ return extract_text_value(titles).to_s unless titles.is_a?(Array)
935
+ return "" if titles.empty?
1673
936
 
1674
- titles = Array(titles)
1675
937
  title = titles.first
1676
938
  extract_text_value(title).to_s
1677
939
  end
@@ -1709,29 +971,20 @@ module Metanorma
1709
971
  end
1710
972
 
1711
973
  def render_footnotes_section
1712
- return if @footnote_collector.empty?
974
+ return nil if @footnote_collector.empty?
1713
975
 
1714
976
  drops = @footnote_collector.to_a.map do |entry|
1715
977
  content_html = ""
1716
978
  if entry.content && !entry.content.empty?
1717
- content_html = capture_output do
1718
- Array(entry.content).each { |p| render_paragraph(p) }
1719
- end
979
+ content_html = Array(entry.content).filter_map do |p|
980
+ render_paragraph(p)
981
+ end.join
1720
982
  end
1721
983
  Drops::FootnoteDrop.new(entry, content_html)
1722
984
  end
1723
985
 
1724
- @output << render_liquid("_footnotes.html.liquid",
1725
- { "footnotes" => drops })
1726
- end
1727
-
1728
- def capture_output
1729
- old_output = @output
1730
- @output = +""
1731
- yield
1732
- result = @output
1733
- @output = old_output
1734
- result
986
+ render_liquid("_footnotes.html.liquid",
987
+ { "footnotes" => drops })
1735
988
  end
1736
989
  end
1737
990
  end