arrolio 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (272) hide show
  1. checksums.yaml +7 -0
  2. data/.rspec +2 -0
  3. data/.rubocop.yml +343 -0
  4. data/CHANGELOG.md +18 -0
  5. data/CLAUDE.md +154 -0
  6. data/Gemfile +15 -0
  7. data/LICENSE +24 -0
  8. data/README.adoc +95 -0
  9. data/Rakefile +21 -0
  10. data/TODO.complete/01-true-type-font-metrics.md +56 -0
  11. data/TODO.complete/02-font-embedding.md +51 -0
  12. data/TODO.complete/03-knuth-plass-breaker.md +42 -0
  13. data/TODO.complete/04-table-flowable.md +60 -0
  14. data/TODO.complete/05-list-flowable.md +50 -0
  15. data/TODO.complete/06-notes-and-examples.md +41 -0
  16. data/TODO.complete/07-odd-even-templates-and-page-numbering.md +63 -0
  17. data/TODO.complete/08-image-flowable.md +45 -0
  18. data/TODO.complete/09-toc-and-leaders.md +66 -0
  19. data/TODO.complete/10-hyperlinks-and-destinations.md +46 -0
  20. data/TODO.complete/11-pdf-outline-bookmarks.md +47 -0
  21. data/TODO.complete/12-metadata-info-dict.md +42 -0
  22. data/TODO.complete/13-xmp-metadata.md +43 -0
  23. data/TODO.complete/14-footnotes.md +43 -0
  24. data/TODO.complete/15-color-management.md +33 -0
  25. data/TODO.complete/16-pdfua-struct-tree.md +35 -0
  26. data/TODO.complete/17-alt-text-and-actual-text.md +28 -0
  27. data/TODO.complete/18-math-rendering.md +41 -0
  28. data/TODO.complete/19-preformatted-and-code.md +31 -0
  29. data/TODO.complete/20-page-break-controls.md +46 -0
  30. data/TODO.complete/21-svg-renderer.md +40 -0
  31. data/TODO.complete/22-definition-lists.md +36 -0
  32. data/TODO.complete/23-multi-column.md +34 -0
  33. data/TODO.complete/24-cover-page-fidelity.md +40 -0
  34. data/TODO.complete/25-i18n-and-multi-language.md +28 -0
  35. data/TODO.complete/26-pixel-diff-harness.md +36 -0
  36. data/TODO.complete/27-pdfrb-only-text-extraction.md +37 -0
  37. data/TODO.complete/28-restore-nested-module-style.md +41 -0
  38. data/TODO.complete/29-font-embedding-wiring-debug.md +72 -0
  39. data/TODO.complete/30-adapter-decomposition.md +76 -0
  40. data/TODO.complete/31-subscript-superscript-inline-runs.md +56 -0
  41. data/TODO.complete/32-debug-output-cleanup.md +59 -0
  42. data/TODO.complete/33-comprehensive-adapter-specs.md +55 -0
  43. data/TODO.complete/34-table-column-auto-layout.md +50 -0
  44. data/TODO.complete/35-image-path-resolution.md +66 -0
  45. data/TODO.complete/36-xsl-converter-completeness.md +40 -0
  46. data/TODO.complete/37-content-model-completeness.md +54 -0
  47. data/TODO.complete/38-pipeline-config-externalization.md +55 -0
  48. data/TODO.complete/39-two-pass-cross-reference-resolution.md +66 -0
  49. data/TODO.complete/40-vertical-writing-modes.md +63 -0
  50. data/TODO.complete/41-pdf-annotations-hyperlinks.md +53 -0
  51. data/TODO.complete/42-font-manifest-configuration.md +75 -0
  52. data/TODO.complete/43-shrink-to-fit-containers.md +52 -0
  53. data/TODO.complete/44-back-of-book-index-generation.md +51 -0
  54. data/TODO.complete/45-pdf-forms-acroform.md +52 -0
  55. data/TODO.complete/46-pdf-digital-signatures.md +47 -0
  56. data/TODO.complete/47-ocp-flavor-separation.md +50 -0
  57. data/TODO.complete/48-oiml-as-pure-configuration.md +80 -0
  58. data/TODO.complete/49-oiml-flowbuilder-as-configuration.md +39 -0
  59. data/TODO.complete/50-delete-oiml-ruby-code.md +53 -0
  60. data/TODO.complete/51-xsl-as-authoritative-source.md +68 -0
  61. data/TODO.complete/52-flavor-packaging-separation.md +49 -0
  62. data/TODO.complete/53-second-flavor-spike.md +74 -0
  63. data/TODO.complete/54-flavor-manifest-contract.md +72 -0
  64. data/TODO.complete/55-header-footer-style-configuration.md +57 -0
  65. data/TODO.complete/56-generic-adapter-mece-decomposition.md +56 -0
  66. data/TODO.complete/57-strict-section-typing.md +49 -0
  67. data/TODO.complete/58-flavor-packaging-gem-convention.md +47 -0
  68. data/TODO.complete/59-xsl-converter-profiles.md +76 -0
  69. data/TODO.complete/60-adapter-fidelity-footnotes-notes-examples.md +62 -0
  70. data/TODO.complete/61-error-model-and-strict-mode.md +62 -0
  71. data/TODO.complete/62-font-resolution-and-strict-fonts.md +66 -0
  72. data/TODO.complete/63-purge-hardcoded-vocabulary-from-generic-core.md +68 -0
  73. data/TODO.complete/64-selector-driven-adapter-specs.md +46 -0
  74. data/TODO.complete/66-semantic-content-types.md +171 -0
  75. data/TODO.complete/README.md +48 -0
  76. data/TODO.implementation/01-foundation.md +46 -0
  77. data/TODO.implementation/02-content-contract.md +47 -0
  78. data/TODO.implementation/03-layoutspec-contract.md +46 -0
  79. data/TODO.implementation/04-style-system.md +43 -0
  80. data/TODO.implementation/05-visitor-pattern.md +40 -0
  81. data/TODO.implementation/06-afm-data-and-parser.md +37 -0
  82. data/TODO.implementation/07-afm-metrics.md +41 -0
  83. data/TODO.implementation/08-truetype-tables-and-metrics.md +48 -0
  84. data/TODO.implementation/09-font-registry-and-glyph-measurer.md +49 -0
  85. data/TODO.implementation/10-break-opportunity-and-inline-run.md +47 -0
  86. data/TODO.implementation/11-greedy-line-breaker.md +48 -0
  87. data/TODO.implementation/12-knuth-plass-line-breaker.md +46 -0
  88. data/TODO.implementation/13-line-and-alignment.md +45 -0
  89. data/TODO.implementation/14-knuth-elements-and-breaker.md +56 -0
  90. data/TODO.implementation/15-refactor-text-layout-on-knuth.md +47 -0
  91. data/TODO.implementation/16-flowable-and-frame.md +52 -0
  92. data/TODO.implementation/17-flow-context.md +50 -0
  93. data/TODO.implementation/18-text-flowable.md +52 -0
  94. data/TODO.implementation/19-spacer-and-page-break.md +52 -0
  95. data/TODO.implementation/20-engine-paged.md +63 -0
  96. data/TODO.implementation/21-output-tree.md +56 -0
  97. data/TODO.implementation/22-pdf-renderer.md +77 -0
  98. data/TODO.implementation/23-end-to-end-smoke.md +53 -0
  99. data/TODO.implementation/24-page-template-and-region.md +51 -0
  100. data/TODO.implementation/25-page-sequence-master.md +54 -0
  101. data/TODO.implementation/26-two-pass-layout.md +66 -0
  102. data/TODO.implementation/27-table-model.md +48 -0
  103. data/TODO.implementation/28-table-layouts.md +53 -0
  104. data/TODO.implementation/29-table-renderer.md +55 -0
  105. data/TODO.implementation/30-table-flowable.md +53 -0
  106. data/TODO.implementation/31-list-model.md +45 -0
  107. data/TODO.implementation/32-list-flowable.md +54 -0
  108. data/TODO.implementation/33-image-flowable.md +59 -0
  109. data/TODO.implementation/34-svg-renderer.md +58 -0
  110. data/TODO.implementation/35-svg-as-form-xobject.md +38 -0
  111. data/TODO.implementation/36-inline-builder.md +50 -0
  112. data/TODO.implementation/37-hyperlink.md +53 -0
  113. data/TODO.implementation/38-sub-superscript.md +44 -0
  114. data/TODO.implementation/39-leader-and-field-runs.md +60 -0
  115. data/TODO.implementation/40-bookmark-outline.md +45 -0
  116. data/TODO.implementation/41-destinations.md +52 -0
  117. data/TODO.implementation/42-toc-builder.md +52 -0
  118. data/TODO.implementation/43-footnotes.md +51 -0
  119. data/TODO.implementation/44-multi-column.md +45 -0
  120. data/TODO.implementation/45-page-numbering-and-section-tracking.md +50 -0
  121. data/TODO.implementation/46-color-management.md +47 -0
  122. data/TODO.implementation/47-metadata-and-xmp.md +41 -0
  123. data/TODO.implementation/48-struct-tree-and-marked-content.md +49 -0
  124. data/TODO.implementation/49-alt-text-and-actual-text.md +43 -0
  125. data/TODO.implementation/50-composer-facade.md +59 -0
  126. data/TODO.implementation/51-style-presets.md +44 -0
  127. data/TODO.implementation/52-oiml-adapter-and-e2e-pipeline.md +120 -0
  128. data/TODO.implementation/53-oiml-layout-spec.md +121 -0
  129. data/TODO.implementation/54-oiml-pipeline.md +97 -0
  130. data/TODO.implementation/55-pdf-comparator.md +98 -0
  131. data/TODO.implementation/56-diff-formatters.md +95 -0
  132. data/TODO.implementation/57-path-locator-and-enrichment.md +74 -0
  133. data/TODO.implementation/58-rspec-matchers-and-harness-api.md +124 -0
  134. data/TODO.implementation/59-pixel-diff-mode.md +93 -0
  135. data/TODO.implementation/60-yard-documentation.md +43 -0
  136. data/TODO.implementation/61-benchmarks-and-stress-tests.md +50 -0
  137. data/TODO.implementation/62-validation-framework.md +107 -0
  138. data/TODO.implementation/63-layout-rules-profile.md +110 -0
  139. data/TODO.implementation/64-pdfa-and-pdfua-conformance.md +74 -0
  140. data/TODO.implementation/65-feature-extractor.md +64 -0
  141. data/TODO.implementation/66-auto-fixer.md +75 -0
  142. data/TODO.implementation/README.md +169 -0
  143. data/data/arrolio/afm/Courier-Bold.afm +342 -0
  144. data/data/arrolio/afm/Courier-BoldOblique.afm +342 -0
  145. data/data/arrolio/afm/Courier-Oblique.afm +342 -0
  146. data/data/arrolio/afm/Courier.afm +342 -0
  147. data/data/arrolio/afm/Helvetica-Bold.afm +2827 -0
  148. data/data/arrolio/afm/Helvetica-BoldOblique.afm +2827 -0
  149. data/data/arrolio/afm/Helvetica-Oblique.afm +3051 -0
  150. data/data/arrolio/afm/Helvetica.afm +3051 -0
  151. data/data/arrolio/afm/Symbol.afm +213 -0
  152. data/data/arrolio/afm/Times-Bold.afm +2588 -0
  153. data/data/arrolio/afm/Times-BoldItalic.afm +2384 -0
  154. data/data/arrolio/afm/Times-Italic.afm +2667 -0
  155. data/data/arrolio/afm/Times-Roman.afm +2419 -0
  156. data/data/arrolio/afm/ZapfDingbats.afm +225 -0
  157. data/data/arrolio/glyphlist.txt +4306 -0
  158. data/exe/arrolio2pdf +28 -0
  159. data/lib/arrolio/asset_resolver.rb +36 -0
  160. data/lib/arrolio/color.rb +107 -0
  161. data/lib/arrolio/column_set.rb +64 -0
  162. data/lib/arrolio/composer.rb +10 -0
  163. data/lib/arrolio/config_driven_pipeline.rb +144 -0
  164. data/lib/arrolio/content/bibliography_item.rb +42 -0
  165. data/lib/arrolio/content/builder.rb +108 -0
  166. data/lib/arrolio/content/document.rb +60 -0
  167. data/lib/arrolio/content/example.rb +43 -0
  168. data/lib/arrolio/content/figure_group.rb +39 -0
  169. data/lib/arrolio/content/footnote.rb +46 -0
  170. data/lib/arrolio/content/form_field.rb +47 -0
  171. data/lib/arrolio/content/formula.rb +34 -0
  172. data/lib/arrolio/content/heading.rb +42 -0
  173. data/lib/arrolio/content/hyperlink.rb +42 -0
  174. data/lib/arrolio/content/image.rb +36 -0
  175. data/lib/arrolio/content/index_entry.rb +52 -0
  176. data/lib/arrolio/content/inline_run.rb +51 -0
  177. data/lib/arrolio/content/list.rb +60 -0
  178. data/lib/arrolio/content/note.rb +48 -0
  179. data/lib/arrolio/content/page_break.rb +28 -0
  180. data/lib/arrolio/content/paragraph.rb +38 -0
  181. data/lib/arrolio/content/preformatted.rb +47 -0
  182. data/lib/arrolio/content/section.rb +39 -0
  183. data/lib/arrolio/content/table.rb +117 -0
  184. data/lib/arrolio/content/term_entry.rb +43 -0
  185. data/lib/arrolio/content.rb +27 -0
  186. data/lib/arrolio/data_dir.rb +28 -0
  187. data/lib/arrolio/engine/cross_reference_registry.rb +69 -0
  188. data/lib/arrolio/engine/paged.rb +186 -0
  189. data/lib/arrolio/engine.rb +9 -0
  190. data/lib/arrolio/error.rb +70 -0
  191. data/lib/arrolio/flavor/manifest.rb +111 -0
  192. data/lib/arrolio/flavor/registry.rb +54 -0
  193. data/lib/arrolio/flavor.rb +23 -0
  194. data/lib/arrolio/flow_context.rb +37 -0
  195. data/lib/arrolio/flowable.rb +66 -0
  196. data/lib/arrolio/flowables/footnote_marker_flowable.rb +28 -0
  197. data/lib/arrolio/flowables/heading_flowable.rb +23 -0
  198. data/lib/arrolio/flowables/image_flowable.rb +53 -0
  199. data/lib/arrolio/flowables/list_flowable.rb +117 -0
  200. data/lib/arrolio/flowables/note_flowable.rb +19 -0
  201. data/lib/arrolio/flowables/page_break.rb +15 -0
  202. data/lib/arrolio/flowables/page_sequence_start.rb +28 -0
  203. data/lib/arrolio/flowables/spacer.rb +34 -0
  204. data/lib/arrolio/flowables/table_flowable.rb +147 -0
  205. data/lib/arrolio/flowables/text_flowable.rb +95 -0
  206. data/lib/arrolio/flowables/toc_line_flowable.rb +35 -0
  207. data/lib/arrolio/flowables/two_column_block.rb +56 -0
  208. data/lib/arrolio/flowables.rb +19 -0
  209. data/lib/arrolio/font/embedder.rb +197 -0
  210. data/lib/arrolio/font/manifest.rb +114 -0
  211. data/lib/arrolio/font/resolver.rb +161 -0
  212. data/lib/arrolio/font/text_encoder.rb +28 -0
  213. data/lib/arrolio/font.rb +10 -0
  214. data/lib/arrolio/font_metrics/afm_metrics.rb +91 -0
  215. data/lib/arrolio/font_metrics/registry.rb +55 -0
  216. data/lib/arrolio/font_metrics/true_type_metrics.rb +95 -0
  217. data/lib/arrolio/font_metrics.rb +10 -0
  218. data/lib/arrolio/frame.rb +36 -0
  219. data/lib/arrolio/generic_adapter.rb +672 -0
  220. data/lib/arrolio/generic_flow_builder.rb +338 -0
  221. data/lib/arrolio/glyph_measurer.rb +71 -0
  222. data/lib/arrolio/harness/pdf_diff.rb +104 -0
  223. data/lib/arrolio/harness/pixel_diff.rb +122 -0
  224. data/lib/arrolio/harness/text_diff.rb +53 -0
  225. data/lib/arrolio/harness/text_extractor.rb +58 -0
  226. data/lib/arrolio/harness.rb +10 -0
  227. data/lib/arrolio/inline_run.rb +59 -0
  228. data/lib/arrolio/layout_spec/flow.rb +17 -0
  229. data/lib/arrolio/layout_spec/loader.rb +117 -0
  230. data/lib/arrolio/layout_spec/page_template.rb +127 -0
  231. data/lib/arrolio/layout_spec/page_template_selector.rb +58 -0
  232. data/lib/arrolio/layout_spec/region.rb +38 -0
  233. data/lib/arrolio/layout_spec/scale_length.rb +34 -0
  234. data/lib/arrolio/layout_spec.rb +90 -0
  235. data/lib/arrolio/logger.rb +54 -0
  236. data/lib/arrolio/output/page.rb +69 -0
  237. data/lib/arrolio/output/placed_box.rb +68 -0
  238. data/lib/arrolio/output/region.rb +32 -0
  239. data/lib/arrolio/output.rb +10 -0
  240. data/lib/arrolio/renderer/accessibility_tagger.rb +70 -0
  241. data/lib/arrolio/renderer/font_registry.rb +32 -0
  242. data/lib/arrolio/renderer/link_annotator.rb +62 -0
  243. data/lib/arrolio/renderer/outline_builder.rb +102 -0
  244. data/lib/arrolio/renderer/pdf.rb +493 -0
  245. data/lib/arrolio/renderer/signature_config.rb +59 -0
  246. data/lib/arrolio/renderer/structure_tree_builder.rb +117 -0
  247. data/lib/arrolio/renderer/xmp_builder.rb +92 -0
  248. data/lib/arrolio/renderer.rb +17 -0
  249. data/lib/arrolio/style/definition.rb +98 -0
  250. data/lib/arrolio/style/loader.rb +25 -0
  251. data/lib/arrolio/style/registry.rb +53 -0
  252. data/lib/arrolio/style.rb +9 -0
  253. data/lib/arrolio/table/auto_layout.rb +91 -0
  254. data/lib/arrolio/table.rb +9 -0
  255. data/lib/arrolio/text_direction.rb +81 -0
  256. data/lib/arrolio/text_layout/break_opportunity.rb +103 -0
  257. data/lib/arrolio/text_layout/greedy.rb +110 -0
  258. data/lib/arrolio/text_layout/knuth_plass/breaker.rb +265 -0
  259. data/lib/arrolio/text_layout/knuth_plass/item.rb +84 -0
  260. data/lib/arrolio/text_layout/knuth_plass/item_builder.rb +103 -0
  261. data/lib/arrolio/text_layout/knuth_plass.rb +12 -0
  262. data/lib/arrolio/text_layout/line.rb +72 -0
  263. data/lib/arrolio/text_layout.rb +11 -0
  264. data/lib/arrolio/toc_builder.rb +31 -0
  265. data/lib/arrolio/version.rb +5 -0
  266. data/lib/arrolio/writing_mode.rb +75 -0
  267. data/lib/arrolio.rb +42 -0
  268. data/script/smoke.rb +32 -0
  269. data/scripts/write_content_extras.py +227 -0
  270. data/scripts/xsl_profiles/standoc.yml +218 -0
  271. data/scripts/xsl_to_config.rb +391 -0
  272. metadata +394 -0
@@ -0,0 +1,672 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'yaml'
4
+ require 'rexml/document'
5
+
6
+ module Arrolio
7
+ # Generic, configuration-driven XML adapter. Reads a set of
8
+ # declarative rules (YAML) that describe how to parse a specific
9
+ # document format's presentation XML into a Content::Document.
10
+ #
11
+ # A flavor is defined entirely by:
12
+ #
13
+ # adapter_rules.yml — element selectors, mappings, style resolution
14
+ # layout_spec.yml — styles, page templates, flows
15
+ # flow_rules.yml — page sequence structure, cover layout
16
+ #
17
+ # No Ruby code is needed for a new flavor. The adapter has ZERO
18
+ # hard-coded element names — every XML element/attribute the adapter
19
+ # touches is named in the rules' `selectors` block.
20
+ class GenericAdapter
21
+ DEFAULT_SELECTORS = {
22
+ 'heading' => 'fmt-title',
23
+ 'heading_depth_attribute' => 'depth',
24
+ 'list_item' => 'li',
25
+ 'list_item_label' => 'fmt-name',
26
+ 'paragraph' => 'p',
27
+ 'term_number' => 'fmt-name',
28
+ 'term_preferred' => 'fmt-preferred',
29
+ 'term_definition' => 'fmt-definition',
30
+ 'term_source' => 'fmt-termsource',
31
+ 'note_label' => 'fmt-name',
32
+ 'figure_image' => 'image',
33
+ 'figure_caption' => 'fmt-name',
34
+ 'figure_caption_fallback' => 'name',
35
+ 'table_row' => 'tr',
36
+ 'table_cell' => ['td', 'th'].freeze,
37
+ 'table_header' => 'thead',
38
+ 'table_body' => 'tbody',
39
+ 'biblio_tag' => 'biblio-tag',
40
+ 'biblio_formattedref' => 'formattedref',
41
+ 'image_src_attribute' => 'src',
42
+ 'image_alt_attribute' => 'alt',
43
+ 'id_attribute' => 'id',
44
+ 'autonum_class' => 'fmt-autonum-delim',
45
+ 'stem' => 'stem',
46
+ 'stem_formatted' => 'fmt-stem',
47
+ 'math' => 'math',
48
+ 'preface_container' => 'preface',
49
+ 'preface_children' => ['clause', 'foreword'].freeze,
50
+ 'bibliography_container' => 'bibliography',
51
+ 'bibliography_reference' => 'references',
52
+ 'bibliography_item' => 'bibitem',
53
+ 'fallback_title' => 'title',
54
+ 'span' => 'span',
55
+ 'tab_inline' => 'tab',
56
+ 'break_inline' => 'br'
57
+ }.freeze
58
+
59
+ attr_reader :rules, :layout_spec, :selectors
60
+
61
+ def initialize(rules:, layout_spec: nil)
62
+ @rules = rules.is_a?(String) ? YAML.safe_load_file(rules) : rules
63
+ @layout_spec = layout_spec
64
+ @selectors = DEFAULT_SELECTORS.merge(@rules['selectors'] || {})
65
+ end
66
+
67
+ def convert(xml)
68
+ xml = xml.read if xml.is_a?(IO) || xml.is_a?(StringIO)
69
+ xml = xml.to_s.dup.force_encoding('UTF-8').scrub
70
+ rexml = xml.is_a?(REXML::Document) ? xml : REXML::Document.new(xml)
71
+ root = rexml.root
72
+
73
+ Content::Document.new(
74
+ metadata: extract_metadata(root),
75
+ sections: extract_sections(root),
76
+ preface: extract_preface(root),
77
+ bibliography: extract_bibliography(root),
78
+ cover: extract_cover(root),
79
+ footnotes: extract_footnotes(root)
80
+ )
81
+ end
82
+
83
+ private
84
+
85
+ def selector(key)
86
+ @selectors[key]
87
+ end
88
+
89
+ # ---- Metadata extraction ----
90
+
91
+ def extract_metadata(root)
92
+ config = @rules['metadata'] || {}
93
+ bibdata_path = config['root'] || 'bibdata'
94
+ bibdata = find_first(root, bibdata_path)
95
+ return {} unless bibdata
96
+
97
+ result = {}
98
+ (config['fields'] || {}).each do |key, xpath|
99
+ value = text_of(find_first(bibdata, xpath))
100
+ result[key.to_sym] = value if value && !value.empty?
101
+ end
102
+ result
103
+ end
104
+
105
+ def extract_cover(root)
106
+ metadata = extract_metadata(root)
107
+ config = @rules['cover'] || {}
108
+ result = {}
109
+ (config['fields'] || ['docidentifier', 'edition', 'title_main', 'title_part']).each do |field|
110
+ result[field.to_sym] = metadata[field.to_sym] if metadata[field.to_sym]
111
+ end
112
+ result.empty? ? nil : result
113
+ end
114
+
115
+ # ---- Section extraction ----
116
+
117
+ def extract_sections(root)
118
+ sections_config = @rules['sections'] || {}
119
+ container = sections_config['container'] || 'sections'
120
+ sections_elem = find_first(root, container)
121
+ return [] unless sections_elem
122
+
123
+ children = []
124
+ each_direct_child(sections_elem) do |child|
125
+ next unless map_element(child.name, sections_elem) == 'section'
126
+
127
+ children << convert_clause(child)
128
+ end
129
+ children
130
+ end
131
+
132
+ def extract_preface(root)
133
+ preface_elem = find_first(root, selector('preface_container'))
134
+ return [] unless preface_elem
135
+
136
+ result = []
137
+ each_element(preface_elem) do |child|
138
+ next if child == preface_elem
139
+ next unless Array(selector('preface_children')).include?(child.name)
140
+
141
+ result << convert_clause(child)
142
+ end
143
+ result
144
+ end
145
+
146
+ def extract_footnotes(root)
147
+ container_name = selector('footnote_container')
148
+ body_name = selector('footnote_body')
149
+ marker_name = selector('footnote_marker')
150
+ return [] unless container_name && body_name
151
+
152
+ footnotes = []
153
+ each_element(root) do |elem|
154
+ next unless elem.name == container_name
155
+
156
+ marker = elem.attribute('id')&.value || elem.attribute(marker_name)&.value || ''
157
+ body_elem = find_first(elem, body_name)
158
+ body = []
159
+ if body_elem
160
+ para_name = selector('paragraph')
161
+ each_element(body_elem) do |child|
162
+ next unless child.name == para_name
163
+
164
+ body << convert_paragraph(child)
165
+ end
166
+ end
167
+ next if body.empty?
168
+
169
+ footnotes << Content::Footnote.new(
170
+ marker: marker,
171
+ body: body,
172
+ id: elem.attribute(selector('id_attribute'))&.value
173
+ )
174
+ end
175
+ footnotes
176
+ end
177
+
178
+ def extract_bibliography(root)
179
+ biblio_elem = find_first(root, selector('bibliography_container'))
180
+ return [] unless biblio_elem
181
+
182
+ result = []
183
+ each_element(biblio_elem) do |child|
184
+ next if child == biblio_elem
185
+ next unless child.name == selector('bibliography_reference')
186
+
187
+ items = []
188
+ each_child(child, selector('bibliography_item')) { |bi| items << convert_bibitem(bi) }
189
+ result << Content::Section.new(
190
+ title: 'Bibliography',
191
+ level: 1,
192
+ children: items,
193
+ style_id: :section_body_1,
194
+ title_style_id: :heading_1
195
+ )
196
+ end
197
+ result
198
+ end
199
+
200
+ # ---- Element converters (driven by rules) ----
201
+
202
+ def convert_clause(elem)
203
+ title, number = extract_heading(elem)
204
+ children = convert_children(elem)
205
+ level = clause_level(elem)
206
+ Content::Section.new(
207
+ title: title, level: level, number: number,
208
+ id: elem.attribute(selector('id_attribute'))&.value, children: children,
209
+ style_id: :"section_body_#{level}",
210
+ title_style_id: :"heading_#{level}"
211
+ )
212
+ end
213
+
214
+ def clause_level(elem)
215
+ heading_name = selector('heading')
216
+ depth_attr = selector('heading_depth_attribute')
217
+ fmt_title = elem.elements.first { |e| e.name == heading_name }
218
+ depth = fmt_title&.attribute(depth_attr)&.value.to_i
219
+ [depth, 1].max
220
+ end
221
+
222
+ def convert_children(parent)
223
+ children = []
224
+ mapping = @rules['element_mapping'] || {}
225
+ skip = @rules['skip_elements'] || []
226
+
227
+ each_direct_child(parent) do |child|
228
+ next if skip.include?(child.name)
229
+
230
+ type = mapping[child.name]&.dig('content_type')
231
+ next if type.nil?
232
+
233
+ case type
234
+ when 'section'
235
+ children << convert_clause(child)
236
+ when 'paragraph'
237
+ children << convert_paragraph(child)
238
+ when 'table'
239
+ children << convert_table(child)
240
+ when 'figure'
241
+ children.concat(convert_figure(child))
242
+ when 'list'
243
+ children << convert_list(child, kind: mapping[child.name]['kind']&.to_sym || :bullet)
244
+ when 'term'
245
+ children.concat(convert_term(child))
246
+ when 'note'
247
+ children.concat(convert_note(child))
248
+ when 'preformatted'
249
+ children << Content::Preformatted.new(
250
+ text_of(child).split("\n", -1),
251
+ style_id: :preformatted
252
+ )
253
+ end
254
+ end
255
+ children
256
+ end
257
+
258
+ def convert_paragraph(elem)
259
+ runs = collect_inline_runs(elem)
260
+ refs = extract_footnote_refs(elem)
261
+ Content::Paragraph.new(runs, style_id: paragraph_style(elem),
262
+ id: elem.attribute(selector('id_attribute'))&.value,
263
+ footnote_refs: refs)
264
+ end
265
+
266
+ def extract_footnote_refs(elem)
267
+ marker_name = selector('footnote_marker')
268
+ return [] unless marker_name
269
+
270
+ refs = []
271
+ elem.each_recursive do |node|
272
+ next unless node.is_a?(REXML::Element) && node.name == marker_name
273
+
274
+ id = node.attribute('id')&.value ||
275
+ node.attribute(selector('id_attribute'))&.value ||
276
+ text_of(node)
277
+ refs << id.to_s if id && !id.to_s.empty?
278
+ end
279
+ refs
280
+ end
281
+
282
+ def convert_table(elem)
283
+ header = convert_table_rows(find_first(elem, selector('table_header')), true)
284
+ body = convert_table_rows(find_first(elem, selector('table_body')), false)
285
+ Content::Table.new(header: header, body: body, style_id: :table,
286
+ id: elem.attribute(selector('id_attribute'))&.value)
287
+ end
288
+
289
+ def convert_table_rows(parent, is_header)
290
+ return [] unless parent
291
+
292
+ rows = []
293
+ row_name = selector('table_row')
294
+ cell_names = Array(selector('table_cell'))
295
+ each_child(parent, row_name) do |tr|
296
+ cells = []
297
+ each_element(tr) do |cell|
298
+ next unless cell_names.include?(cell.name)
299
+
300
+ runs = collect_inline_runs(cell)
301
+ cells << Content::Table::Cell.new(
302
+ [Content::Paragraph.new(runs, style_id: is_header ? :table_header_cell : :table_cell)],
303
+ colspan: (cell.attribute('colspan')&.value || 1).to_i,
304
+ rowspan: (cell.attribute('rowspan')&.value || 1).to_i,
305
+ style_id: is_header ? :table_header_cell : :table_cell
306
+ )
307
+ end
308
+ rows << Content::Table::Row.new(cells)
309
+ end
310
+ rows
311
+ end
312
+
313
+ def convert_list(elem, kind:)
314
+ items = []
315
+ item_name = selector('list_item')
316
+ label_name = selector('list_item_label')
317
+ para_name = selector('paragraph')
318
+ each_child(elem, item_name) do |li|
319
+ marker = nil
320
+ label_elem = find_first(li, label_name)
321
+ marker = text_of(label_elem) if label_elem
322
+
323
+ paras = []
324
+ each_element(li) do |child|
325
+ next unless child.name == para_name
326
+
327
+ paras << convert_paragraph(child)
328
+ end
329
+ content = paras.empty? ? [Content::Paragraph.new(collect_inline_runs(li))] : paras
330
+ items << Content::List::Item.new(content, marker: marker)
331
+ end
332
+ Content::List.new(items, kind: kind, style_id: kind == :ordered ? :list_ordered : :list_bullet)
333
+ end
334
+
335
+ def convert_figure(elem)
336
+ image_name = selector('figure_image')
337
+ src_attr = selector('image_src_attribute')
338
+ alt_attr = selector('image_alt_attribute')
339
+ image_elem = find_direct_child(elem, image_name)
340
+ image = nil
341
+ if image_elem
342
+ src = image_elem.attribute(src_attr)&.value
343
+ if src && !src.empty?
344
+ image = Content::Image.new(src,
345
+ alt: image_elem.attribute(alt_attr)&.value,
346
+ id: image_elem.attribute(selector('id_attribute'))&.value)
347
+ end
348
+ end
349
+ name = find_first(elem, selector('figure_caption')) ||
350
+ find_first(elem, selector('figure_caption_fallback'))
351
+ caption = nil
352
+ if name
353
+ runs = collect_inline_runs(name)
354
+ caption = Content::Paragraph.new(runs, style_id: :figure_caption) unless runs.empty?
355
+ end
356
+ return [] if image.nil? && caption.nil?
357
+
358
+ [Content::FigureGroup.new(image: image, caption: caption, id: elem.attribute(selector('id_attribute'))&.value)]
359
+ end
360
+
361
+ def convert_term(elem)
362
+ number = nil
363
+ number_elem = find_first(elem, selector('term_number'))
364
+ number = text_of(number_elem).strip if number_elem
365
+ number = nil if number && number.empty?
366
+
367
+ preferred = nil
368
+ preferred_elem = find_first(elem, selector('term_preferred'))
369
+ if preferred_elem
370
+ runs = collect_inline_runs(preferred_elem, default_style: :term)
371
+ preferred = Content::Paragraph.new(runs, style_id: :term) unless runs.empty?
372
+ end
373
+
374
+ definition = []
375
+ definition_elem = find_first(elem, selector('term_definition'))
376
+ if definition_elem
377
+ para_name = selector('paragraph')
378
+ REXML::XPath.each(definition_elem, ".//#{para_name}") do |p|
379
+ next unless p.is_a?(REXML::Element)
380
+
381
+ definition << convert_paragraph(p)
382
+ end
383
+ end
384
+
385
+ source = nil
386
+ source_elem = find_first(elem, selector('term_source'))
387
+ if source_elem
388
+ runs = collect_inline_runs(source_elem, default_style: :bibitem)
389
+ source = Content::Paragraph.new(runs, style_id: :bibitem) unless runs.empty?
390
+ end
391
+
392
+ return [] if number.nil? && preferred.nil? && definition.empty? && source.nil?
393
+
394
+ [Content::TermEntry.new(number: number, preferred: preferred,
395
+ definition: definition, source: source,
396
+ id: elem.attribute(selector('id_attribute'))&.value)]
397
+ end
398
+
399
+ def convert_note(elem)
400
+ label_elem = find_first(elem, selector('note_label'))
401
+ label = label_elem ? text_of(label_elem).strip : ''
402
+
403
+ para_name = selector('paragraph')
404
+ body = []
405
+ each_element(elem) do |child|
406
+ next unless child.name == para_name
407
+
408
+ body << convert_paragraph(child)
409
+ end
410
+ return [] if body.empty?
411
+
412
+ [Content::Note.new(label: label, body: body, id: elem.attribute(selector('id_attribute'))&.value)]
413
+ end
414
+
415
+ def convert_bibitem(bi)
416
+ tag_name = selector('biblio_tag')
417
+ tag = find_first(bi, tag_name)
418
+ tag_text = nil
419
+ if tag
420
+ tag_runs = collect_inline_runs(tag, default_style: :bibitem)
421
+ replacement = (@rules['tab_replacements'] || {})[tag.name] || "\t"
422
+ tag_runs.map! { |r| r.text == "\t" ? Content::InlineRun.new(replacement, style_id: r.style_id) : r }
423
+ tag_text = tag_runs.map(&:text).join.strip
424
+ end
425
+ formattedref = find_first(bi, selector('biblio_formattedref'))
426
+ ref_paragraph = nil
427
+ if formattedref
428
+ runs = collect_inline_runs(formattedref)
429
+ ref_paragraph = Content::Paragraph.new(runs, style_id: :bibitem) unless runs.empty?
430
+ end
431
+ return [] if tag_text.nil? && ref_paragraph.nil?
432
+
433
+ [Content::BibliographyItem.new(tag: tag_text, formattedref: ref_paragraph,
434
+ id: bi.attribute(selector('id_attribute'))&.value)]
435
+ end
436
+
437
+ # ---- Inline run collection (driven by rules) ----
438
+
439
+ def collect_inline_runs(elem, default_style: :inline)
440
+ runs = []
441
+ stem_name = selector('stem')
442
+ stem_formatted_name = selector('stem_formatted')
443
+ math_name = selector('math')
444
+ tab_name = selector('tab_inline')
445
+ br_name = selector('break_inline')
446
+ skip_metadata = @rules['skip_metadata_elements'].to_a
447
+ block_level = @rules['block_level_elements'].to_a
448
+
449
+ walker = lambda do |node, style|
450
+ case node
451
+ when REXML::Text
452
+ text = normalize_text(node.value)
453
+ next if text.nil? || text.empty?
454
+
455
+ runs << Content::InlineRun.new(text, style_id: style)
456
+ when REXML::Element
457
+ new_style = resolve_inline_style(node, style)
458
+ case node.name
459
+ when tab_name
460
+ runs << Content::InlineRun.new("\t", style_id: style)
461
+ when br_name
462
+ runs << Content::InlineRun.new("\n", style_id: style)
463
+ when stem_name
464
+ unless sibling_exists?(node.parent, stem_formatted_name)
465
+ walk_stem(node, style, runs, stem_formatted_name, math_name)
466
+ end
467
+ when stem_formatted_name
468
+ walk_math(node, style, runs)
469
+ when *skip_metadata
470
+ next
471
+ when *block_level
472
+ next
473
+ else
474
+ node.children.each { |c| walker.call(c, new_style) }
475
+ end
476
+ end
477
+ end
478
+ elem.children.each { |c| walker.call(c, default_style) }
479
+ runs
480
+ end
481
+
482
+ def resolve_inline_style(elem, current)
483
+ styles = @rules['inline_styles'] || {}
484
+ mapped = styles[elem.name]
485
+ return mapped.to_sym if mapped
486
+
487
+ if elem.name == selector('span')
488
+ span_styles = @rules['span_class_styles'] || {}
489
+ cls = elem.attribute('class')&.value
490
+ mapped = span_styles[cls]
491
+ return mapped.to_sym if mapped
492
+ end
493
+ current
494
+ end
495
+
496
+ def walk_stem(stem_elem, style, runs, formatted_name, math_name)
497
+ target = find_first(stem_elem, formatted_name) || find_first(stem_elem, math_name)
498
+ walk_math(target, style, runs) if target
499
+ end
500
+
501
+ def walk_math(elem, style, runs)
502
+ return unless elem
503
+
504
+ elem.each_recursive do |child|
505
+ next unless child.is_a?(REXML::Text)
506
+
507
+ text = normalize_text(child.value)
508
+ next if text.nil? || text.empty?
509
+
510
+ runs << Content::InlineRun.new(text, style_id: style)
511
+ end
512
+ end
513
+
514
+ # ---- Heading extraction ----
515
+
516
+ def extract_heading(clause)
517
+ heading_config = @rules['heading'] || {}
518
+ source = heading_config['source'] || selector('heading')
519
+ heading_elem = find_first(clause, source)
520
+ if heading_elem
521
+ extract_from_heading(heading_elem)
522
+ else
523
+ title = find_first(clause, selector('fallback_title'))
524
+ [title ? text_of(title) : nil, nil]
525
+ end
526
+ end
527
+
528
+ def extract_from_heading(heading_elem)
529
+ parts = []
530
+ number_parts = []
531
+ title_parts = []
532
+ autonum_class = selector('autonum_class')
533
+
534
+ walk_heading_text(heading_elem) do |text, parent|
535
+ parts << text
536
+ element_attr = parent&.attribute('element')&.value
537
+ if element_attr == 'autonum' || autonum_class_parent?(parent, autonum_class)
538
+ number_parts << text
539
+ elsif element_attr == 'title'
540
+ title_parts << text
541
+ end
542
+ end
543
+
544
+ number = number_parts.join.strip
545
+ number = nil if number.empty?
546
+
547
+ title = if title_parts.any?
548
+ title_parts.join.strip
549
+ elsif parts.include?("\t")
550
+ parts.join.split("\t", 2).last&.strip
551
+ else
552
+ all_text = parts.join.strip
553
+ all_text.empty? ? nil : all_text
554
+ end
555
+ title = nil if title.nil? || title.empty?
556
+ [title, number]
557
+ end
558
+
559
+ def walk_heading_text(element, &block)
560
+ element.children.each do |child|
561
+ case child
562
+ when REXML::Text
563
+ yield(child.value, child.parent)
564
+ when REXML::Element
565
+ walk_heading_text(child, &block)
566
+ end
567
+ end
568
+ end
569
+
570
+ def autonum_class_parent?(node, autonum_class)
571
+ parent = node.parent
572
+ parent && parent.attribute('class')&.value == autonum_class
573
+ end
574
+
575
+ # ---- Text helpers ----
576
+
577
+ def normalize_text(raw)
578
+ return raw unless raw.is_a?(String)
579
+ return raw unless raw.include?("\n")
580
+
581
+ stripped = raw.strip
582
+ return raw.match?(/ /) ? ' ' : nil if stripped.empty?
583
+
584
+ stripped.gsub(/\s+/, ' ')
585
+ end
586
+
587
+ def text_of(elem)
588
+ return '' unless elem
589
+
590
+ collect_text(elem).strip
591
+ end
592
+
593
+ def collect_text(node)
594
+ case node
595
+ when REXML::Text then node.value
596
+ when REXML::Element then node.children.map { |c| collect_text(c) }.join
597
+ else ''
598
+ end
599
+ end
600
+
601
+ # ---- XML traversal helpers ----
602
+
603
+ def find_first(parent, path)
604
+ return nil unless parent
605
+
606
+ nodes = [parent]
607
+ path.to_s.split('/').each do |segment|
608
+ name = segment[/\A[^\[]+/]
609
+ predicate = segment.match(/\[@([^=]+)=['"]([^'"]+)['"]\]/)
610
+ nodes = nodes.flat_map do |node|
611
+ node.elements.to_a.select do |element|
612
+ next false unless element.name == name
613
+ predicate.nil? || element.attribute(predicate[1])&.value == predicate[2]
614
+ end
615
+ end
616
+ end
617
+ nodes.first
618
+ end
619
+
620
+ def find_direct_child(parent, name)
621
+ return nil unless parent
622
+
623
+ parent.children.each do |c|
624
+ return c if c.is_a?(REXML::Element) && c.name == name
625
+ end
626
+ nil
627
+ end
628
+
629
+ def each_direct_child(parent)
630
+ return enum_for(:each_direct_child, parent) unless block_given?
631
+ return unless parent
632
+
633
+ parent.children.each { |c| yield c if c.is_a?(REXML::Element) }
634
+ end
635
+
636
+ def each_child(parent, name)
637
+ return enum_for(:each_child, parent, name) unless block_given?
638
+ return unless parent
639
+
640
+ parent.elements.each { |e| yield e if e.name == name }
641
+ end
642
+
643
+ def each_element(parent, &block)
644
+ return enum_for(:each_element, parent) unless block_given?
645
+ return unless parent
646
+
647
+ parent.each_element(&block)
648
+ end
649
+
650
+ def sibling_exists?(parent, name)
651
+ return false unless parent.is_a?(REXML::Element)
652
+
653
+ parent.each_element { |e| return true if e.name == name }
654
+ false
655
+ end
656
+
657
+ def has_class?(elem, cls)
658
+ elem.attribute('class')&.value&.start_with?(cls)
659
+ end
660
+
661
+ def paragraph_style(elem)
662
+ cls = elem.attribute('class')&.value
663
+ style_map = @rules['paragraph_styles'] || {}
664
+ style_map[cls] || :body
665
+ end
666
+
667
+ def map_element(name, _parent)
668
+ mapping = @rules['element_mapping'] || {}
669
+ mapping[name]&.dig('content_type')
670
+ end
671
+ end
672
+ end