arrolio 0.1.0 → 0.1.2

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 (100) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +2 -2
  3. data/Rakefile +15 -0
  4. data/TODO.complete/56-generic-adapter-mece-decomposition.md +20 -0
  5. data/TODO.complete/60-adapter-fidelity-footnotes-notes-examples.md +92 -55
  6. data/TODO.complete/70-ttf-font-metrics-parity.md +68 -0
  7. data/TODO.complete/71-cover-layout-parity.md +45 -0
  8. data/TODO.complete/72-table-layout-parity.md +49 -0
  9. data/TODO.complete/73-toc-leaders-parity.md +55 -0
  10. data/TODO.complete/74-svg-image-rendering-parity.md +76 -0
  11. data/TODO.complete/75-mathml-formula-rendering-parity.md +52 -0
  12. data/TODO.complete/76-odd-even-page-templates-parity.md +50 -0
  13. data/TODO.complete/77-text-formatting-parity.md +55 -0
  14. data/TODO.complete/78-bibliography-formatting-parity.md +62 -0
  15. data/TODO.complete/79-line-breaking-parity.md +80 -0
  16. data/TODO.complete/80-parity-diff-harness-in-ci.md +68 -0
  17. data/TODO.complete/81-conditional-style-refinements.md +70 -0
  18. data/TODO.complete/82-semantic-presentation-dedup.md +65 -0
  19. data/TODO.complete/83-nested-list-support.md +74 -0
  20. data/TODO.complete/84-figure-svg-rendering.md +70 -0
  21. data/TODO.complete/85-locality-reference-formatting.md +48 -0
  22. data/TODO.complete/86-list-item-text-overflow.md +80 -0
  23. data/TODO.complete/87-odd-even-page-templates.md +55 -0
  24. data/TODO.complete/88-hyphenation-support.md +52 -0
  25. data/TODO.complete/89-table-rowspan.md +49 -0
  26. data/TODO.complete/90-mathml-stacked-layout.md +65 -0
  27. data/TODO.complete/91-parity-diff-diagnostics.md +50 -0
  28. data/TODO.complete/92-table-cell-height-parity.md +66 -0
  29. data/TODO.complete/93-figure-text-searchability.md +49 -0
  30. data/TODO.complete/94-fo-space-resolution.md +63 -0
  31. data/TODO.complete/95-title-header-placement.md +55 -0
  32. data/TODO.complete/96-pagination-drift-parity.md +517 -0
  33. data/TODO.complete/README.md +58 -38
  34. data/lib/arrolio/config_driven_pipeline.rb +45 -1
  35. data/lib/arrolio/content/document.rb +4 -3
  36. data/lib/arrolio/content/section.rb +3 -0
  37. data/lib/arrolio/content/table.rb +25 -14
  38. data/lib/arrolio/embedded_block_extractor.rb +54 -0
  39. data/lib/arrolio/engine/paged.rb +109 -28
  40. data/lib/arrolio/flavor/manifest.rb +0 -1
  41. data/lib/arrolio/flavor/registry.rb +1 -1
  42. data/lib/arrolio/flowable.rb +21 -0
  43. data/lib/arrolio/flowables/figure_flowable.rb +17 -0
  44. data/lib/arrolio/flowables/footnote_marker_flowable.rb +32 -13
  45. data/lib/arrolio/flowables/group_flowable.rb +57 -0
  46. data/lib/arrolio/flowables/list_flowable.rb +46 -18
  47. data/lib/arrolio/flowables/note_flowable.rb +84 -9
  48. data/lib/arrolio/flowables/page_sequence_start.rb +8 -4
  49. data/lib/arrolio/flowables/positioned_block.rb +55 -0
  50. data/lib/arrolio/flowables/rotated_text.rb +51 -0
  51. data/lib/arrolio/flowables/table_flowable.rb +303 -71
  52. data/lib/arrolio/flowables/text_flowable.rb +67 -11
  53. data/lib/arrolio/flowables/toc_line_flowable.rb +70 -7
  54. data/lib/arrolio/flowables.rb +4 -0
  55. data/lib/arrolio/font/embedder.rb +22 -3
  56. data/lib/arrolio/font_scanner.rb +133 -0
  57. data/lib/arrolio/generic_adapter/document_extraction.rb +101 -0
  58. data/lib/arrolio/generic_adapter/footnote_extraction.rb +81 -0
  59. data/lib/arrolio/generic_adapter/heading_extraction.rb +92 -0
  60. data/lib/arrolio/generic_adapter/inline_run_collection.rb +136 -0
  61. data/lib/arrolio/generic_adapter/list_conversion.rb +70 -0
  62. data/lib/arrolio/generic_adapter/metadata_extraction.rb +46 -0
  63. data/lib/arrolio/generic_adapter/table_conversion.rb +105 -0
  64. data/lib/arrolio/generic_adapter.rb +263 -317
  65. data/lib/arrolio/generic_flow_builder/bibliography.rb +47 -0
  66. data/lib/arrolio/generic_flow_builder/figures.rb +90 -0
  67. data/lib/arrolio/generic_flow_builder/lists.rb +66 -0
  68. data/lib/arrolio/generic_flow_builder/notes.rb +61 -0
  69. data/lib/arrolio/generic_flow_builder/sequences.rb +102 -0
  70. data/lib/arrolio/generic_flow_builder/tables.rb +28 -0
  71. data/lib/arrolio/generic_flow_builder/terms.rb +89 -0
  72. data/lib/arrolio/generic_flow_builder.rb +92 -187
  73. data/lib/arrolio/glyph_measurer.rb +16 -5
  74. data/lib/arrolio/inline_run.rb +2 -1
  75. data/lib/arrolio/layout_spec/page_template.rb +14 -8
  76. data/lib/arrolio/math_ml/inline_run_extractor.rb +272 -0
  77. data/lib/arrolio/math_ml.rb +7 -0
  78. data/lib/arrolio/output/page.rb +5 -4
  79. data/lib/arrolio/output/placed_box.rb +5 -2
  80. data/lib/arrolio/renderer/pdf/assets.rb +94 -0
  81. data/lib/arrolio/renderer/pdf/font_embedding.rb +77 -0
  82. data/lib/arrolio/renderer/pdf/metadata.rb +52 -0
  83. data/lib/arrolio/renderer/pdf.rb +140 -215
  84. data/lib/arrolio/style/definition.rb +7 -3
  85. data/lib/arrolio/style/registry.rb +6 -1
  86. data/lib/arrolio/table/auto_layout.rb +19 -10
  87. data/lib/arrolio/table/grid.rb +105 -0
  88. data/lib/arrolio/table.rb +1 -0
  89. data/lib/arrolio/text_layout/greedy.rb +15 -1
  90. data/lib/arrolio/text_layout/knuth_plass/breaker.rb +216 -92
  91. data/lib/arrolio/text_layout/knuth_plass/item_builder.rb +31 -17
  92. data/lib/arrolio/text_layout/knuth_plass.rb +3 -0
  93. data/lib/arrolio/text_layout/line.rb +10 -2
  94. data/lib/arrolio/version.rb +1 -1
  95. data/lib/arrolio.rb +8 -4
  96. data/scripts/parity_check.rb +52 -0
  97. data/scripts/parity_diff.rb +89 -0
  98. data/scripts/xsl_profiles/standoc.yml +8 -2
  99. data/scripts/xsl_to_config.rb +28 -1
  100. metadata +91 -5
@@ -18,6 +18,24 @@ module Arrolio
18
18
  # hard-coded element names — every XML element/attribute the adapter
19
19
  # touches is named in the rules' `selectors` block.
20
20
  class GenericAdapter
21
+ autoload :InlineRunCollection, 'arrolio/generic_adapter/inline_run_collection'
22
+ autoload :HeadingExtraction, 'arrolio/generic_adapter/heading_extraction'
23
+ autoload :ListConversion, 'arrolio/generic_adapter/list_conversion'
24
+ autoload :MetadataExtraction, 'arrolio/generic_adapter/metadata_extraction'
25
+ autoload :DocumentExtraction, 'arrolio/generic_adapter/document_extraction'
26
+ autoload :FootnoteExtraction, 'arrolio/generic_adapter/footnote_extraction'
27
+
28
+ include FootnoteExtraction
29
+ include InlineRunCollection
30
+ include HeadingExtraction
31
+ include ListConversion
32
+ include MetadataExtraction
33
+ include DocumentExtraction
34
+
35
+ autoload :TableConversion, 'arrolio/generic_adapter/table_conversion'
36
+
37
+ include TableConversion
38
+
21
39
  DEFAULT_SELECTORS = {
22
40
  'heading' => 'fmt-title',
23
41
  'heading_depth_attribute' => 'depth',
@@ -56,6 +74,25 @@ module Arrolio
56
74
  'break_inline' => 'br'
57
75
  }.freeze
58
76
 
77
+ # Maps content_type (declared in rules.element_mapping) to a
78
+ # builder method. Adding a new content type = adding one entry
79
+ # here plus a builder method below — convert_children itself
80
+ # never needs to change (Open/Closed Principle).
81
+ CONVERTER_REGISTRY = {
82
+ 'section' => :convert_section,
83
+ 'paragraph' => :convert_paragraph,
84
+ 'table' => :convert_table,
85
+ 'figure' => :convert_figure,
86
+ 'list' => :convert_list,
87
+ 'term' => :convert_term,
88
+ 'note' => :convert_note,
89
+ 'example' => :convert_example,
90
+ 'preformatted' => :convert_preformatted
91
+ }.freeze
92
+
93
+ XREF_ELEMENTS = ['fmt-xref-label', 'fmt-xref', 'xref'].freeze
94
+
95
+
59
96
  attr_reader :rules, :layout_spec, :selectors
60
97
 
61
98
  def initialize(rules:, layout_spec: nil)
@@ -76,7 +113,8 @@ module Arrolio
76
113
  preface: extract_preface(root),
77
114
  bibliography: extract_bibliography(root),
78
115
  cover: extract_cover(root),
79
- footnotes: extract_footnotes(root)
116
+ footnotes: extract_footnotes(root),
117
+ title_block: extract_title_block(root)
80
118
  )
81
119
  end
82
120
 
@@ -88,136 +126,47 @@ module Arrolio
88
126
 
89
127
  # ---- Metadata extraction ----
90
128
 
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
129
 
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
130
 
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
131
 
115
132
  # ---- Section extraction ----
116
133
 
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
134
 
123
- children = []
124
- each_direct_child(sections_elem) do |child|
125
- next unless map_element(child.name, sections_elem) == 'section'
126
135
 
127
- children << convert_clause(child)
128
- end
129
- children
130
- end
131
136
 
132
- def extract_preface(root)
133
- preface_elem = find_first(root, selector('preface_container'))
134
- return [] unless preface_elem
135
137
 
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)
138
+ # Context-specific title styles (e.g. the XSL rule "level-1
139
+ # titles inside the preface are centered") live in the flavor's
140
+ # `title_styles:` map keyed "{context}_{level}" — no core
141
+ # knowledge of any flavor's heading look.
142
+ def title_style_for(context, level)
143
+ configured = @rules.dig('title_styles', "#{context}_#{level}")
144
+ return :"heading_#{level}" unless configured
140
145
 
141
- result << convert_clause(child)
142
- end
143
- result
146
+ configured.to_sym
144
147
  end
145
148
 
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
149
 
200
150
  # ---- Element converters (driven by rules) ----
201
151
 
202
- def convert_clause(elem)
152
+ def convert_clause(elem, context: 'body')
203
153
  title, number = extract_heading(elem)
204
154
  children = convert_children(elem)
205
- level = clause_level(elem)
155
+ level = clause_level(elem, number: number)
206
156
  Content::Section.new(
207
157
  title: title, level: level, number: number,
208
158
  id: elem.attribute(selector('id_attribute'))&.value, children: children,
209
159
  style_id: :"section_body_#{level}",
210
- title_style_id: :"heading_#{level}"
160
+ title_style_id: title_style_for(context, level)
211
161
  )
212
162
  end
213
163
 
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
164
+
165
+ # Maps content_type (declared in rules.element_mapping) to a
166
+ # Each builder accepts a child element and returns one or more
167
+ # Content nodes. The dispatch_converter helper wraps single
168
+ # returns in an Array so the caller can always concat.
169
+ private
221
170
 
222
171
  def convert_children(parent)
223
172
  children = []
@@ -230,33 +179,55 @@ module Arrolio
230
179
  type = mapping[child.name]&.dig('content_type')
231
180
  next if type.nil?
232
181
 
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
182
+ converter = CONVERTER_REGISTRY[type]
183
+ next unless converter
184
+
185
+ children.concat(dispatch_converter(converter, child, mapping[child.name]))
186
+ next unless type == 'paragraph'
187
+
188
+ children.concat(extract_embedded_blocks(child))
254
189
  end
255
190
  children
256
191
  end
257
192
 
193
+ def extract_embedded_blocks(elem)
194
+ extractor = Arrolio::EmbeddedBlockExtractor.new(@rules, CONVERTER_REGISTRY)
195
+ extractor.extract(elem) do |converter, descendant, mapping_entry|
196
+ dispatch_converter(converter, descendant, mapping_entry)
197
+ end
198
+ end
199
+
200
+ # Routes to a converter. 'list' needs the kind argument pulled
201
+ # from the mapping entry; all others take just the child.
202
+ def dispatch_converter(converter, child, mapping_entry)
203
+ if converter == :convert_list
204
+ result = send(converter, child, kind: mapping_entry['kind']&.to_sym || :bullet)
205
+ result.is_a?(Array) ? result : [result]
206
+ elsif converter == :convert_preformatted
207
+ [send(converter, child)]
208
+ else
209
+ result = send(converter, child)
210
+ result.is_a?(Array) ? result : [result]
211
+ end
212
+ end
213
+
214
+ def convert_preformatted(child)
215
+ Content::Preformatted.new(
216
+ text_of(child).split("\n", -1),
217
+ style_id: :preformatted
218
+ )
219
+ end
220
+
221
+ def convert_section(child)
222
+ convert_clause(child)
223
+ end
224
+
225
+ # A paragraph's <fn> reference contributes only its marker; the
226
+ # body renders in the page's footnote zone (engine collects
227
+ # FootnoteMarkerFlowables per page). Inlining the body into the
228
+ # runs doubles the text and displaces the flow.
258
229
  def convert_paragraph(elem)
259
- runs = collect_inline_runs(elem)
230
+ runs = collect_inline_runs(elem, footnote_markers: true)
260
231
  refs = extract_footnote_refs(elem)
261
232
  Content::Paragraph.new(runs, style_id: paragraph_style(elem),
262
233
  id: elem.attribute(selector('id_attribute'))&.value,
@@ -279,83 +250,86 @@ module Arrolio
279
250
  refs
280
251
  end
281
252
 
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
253
+ def convert_figure(elem)
254
+ image = extract_figure_image(elem)
255
+ name = find_first(elem, selector('figure_caption')) ||
256
+ find_first(elem, selector('figure_caption_fallback'))
257
+ caption = nil
258
+ if name
259
+ runs = collect_inline_runs(name)
260
+ caption = Content::Paragraph.new(runs, style_id: :figure_caption) unless runs.empty?
261
+ end
262
+ return [] if image.nil? && caption.nil?
288
263
 
289
- def convert_table_rows(parent, is_header)
290
- return [] unless parent
264
+ [Content::FigureGroup.new(image: image, caption: caption, id: elem.attribute(selector('id_attribute'))&.value)]
265
+ end
291
266
 
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)
267
+ def extract_figure_image(elem)
268
+ image_name = selector('figure_image')
269
+ image_elem = find_direct_child(elem, image_name)
299
270
 
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
271
+ if image_elem
272
+ svg_elem = nil
273
+ image_elem.each_recursive { |e| svg_elem = e if e.name == 'svg' && svg_elem.nil? }
274
+ if svg_elem
275
+ svg_xml = serialize_element_to_xml(svg_elem)
276
+ width, height = svg_viewbox_dimensions(svg_elem)
277
+ return Content::Image.new(
278
+ "inline-svg:#{svg_xml}",
279
+ width: width,
280
+ height: height,
281
+ id: image_elem.attribute(selector('id_attribute'))&.value
306
282
  )
307
283
  end
308
- rows << Content::Table::Row.new(cells)
284
+
285
+ src = image_elem.attribute(selector('image_src_attribute'))&.value
286
+ return nil if src.nil? || src.empty?
287
+
288
+ return Content::Image.new(
289
+ src,
290
+ alt: image_elem.attribute(selector('image_alt_attribute'))&.value,
291
+ id: image_elem.attribute(selector('id_attribute'))&.value
292
+ )
309
293
  end
310
- rows
294
+
295
+ svg_elem = nil
296
+ elem.each_recursive { |e| svg_elem = e if e.name == 'svg' && svg_elem.nil? }
297
+ return nil unless svg_elem
298
+
299
+ svg_xml = serialize_element_to_xml(svg_elem)
300
+ width, height = svg_viewbox_dimensions(svg_elem)
301
+ Content::Image.new(
302
+ "inline-svg:#{svg_xml}",
303
+ width: width,
304
+ height: height,
305
+ id: elem.attribute(selector('id_attribute'))&.value
306
+ )
311
307
  end
312
308
 
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
309
+ def svg_viewbox_dimensions(svg_elem)
310
+ viewbox = svg_elem.attribute('viewBox')&.value
311
+ return [nil, nil] unless viewbox
322
312
 
323
- paras = []
324
- each_element(li) do |child|
325
- next unless child.name == para_name
313
+ parts = viewbox.split(/\s+/)
314
+ return [nil, nil] unless parts.length == 4
326
315
 
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)
316
+ # SVG user units are CSS pixels (96dpi); layout works in PDF
317
+ # points (72dpi). FOP applies this factor to viewport-less
318
+ # SVGs, so figures come out a third smaller than their raw
319
+ # viewBox numbers.
320
+ width = parts[2].to_f * (72.0 / 96.0)
321
+ height = parts[3].to_f * (72.0 / 96.0)
322
+ width.positive? ? [width, height] : [nil, nil]
333
323
  end
334
324
 
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?
325
+ def nested_in_term?(node, boundary)
326
+ ancestor = node.parent
327
+ while ancestor && ancestor != boundary
328
+ return true if ancestor.name == 'term'
357
329
 
358
- [Content::FigureGroup.new(image: image, caption: caption, id: elem.attribute(selector('id_attribute'))&.value)]
330
+ ancestor = ancestor.parent
331
+ end
332
+ false
359
333
  end
360
334
 
361
335
  def convert_term(elem)
@@ -375,10 +349,15 @@ module Arrolio
375
349
  definition_elem = find_first(elem, selector('term_definition'))
376
350
  if definition_elem
377
351
  para_name = selector('paragraph')
352
+ # Only paragraphs of THIS term: nested <term> entries are
353
+ # converted separately, and their paragraphs must not be
354
+ # swallowed into the parent definition.
378
355
  REXML::XPath.each(definition_elem, ".//#{para_name}") do |p|
379
356
  next unless p.is_a?(REXML::Element)
357
+ next if p.parent != definition_elem && nested_in_term?(p, definition_elem)
380
358
 
381
359
  definition << convert_paragraph(p)
360
+ extract_embedded_blocks(p).each { |block| definition << block }
382
361
  end
383
362
  end
384
363
 
@@ -389,27 +368,100 @@ module Arrolio
389
368
  source = Content::Paragraph.new(runs, style_id: :bibitem) unless runs.empty?
390
369
  end
391
370
 
371
+ # A termnote belongs to its entry (rendered inside it, with
372
+ # the entry's note styling) — not as a sibling block.
373
+ nested = []
374
+ each_element(elem) do |child|
375
+ next unless child.parent == elem
376
+
377
+ case child.name
378
+ when 'termnote'
379
+ definition.concat(convert_note(child))
380
+ when elem.name
381
+ # Nested terms (e.g. 3.1.3.1-3.1.3.4 under "load cell")
382
+ # are entries in their own right; dropping them loses whole
383
+ # definitions.
384
+ nested.concat(convert_term(child))
385
+ end
386
+ end
387
+
392
388
  return [] if number.nil? && preferred.nil? && definition.empty? && source.nil?
393
389
 
394
- [Content::TermEntry.new(number: number, preferred: preferred,
395
- definition: definition, source: source,
396
- id: elem.attribute(selector('id_attribute'))&.value)]
390
+ entry = Content::TermEntry.new(number: number, preferred: preferred,
391
+ definition: definition, source: source,
392
+ id: elem.attribute(selector('id_attribute'))&.value)
393
+ [entry] + nested
397
394
  end
398
395
 
396
+ # A note's body is paragraphs PLUS embedded lists (a termnote
397
+ # may end in a bullet list of capabilities, for example) - the
398
+ # list elements are siblings of the paragraphs, not children.
399
399
  def convert_note(elem)
400
400
  label_elem = find_first(elem, selector('note_label'))
401
401
  label = label_elem ? text_of(label_elem).strip : ''
402
402
 
403
+ para_name = selector('paragraph')
404
+ body = []
405
+ each_element(elem) do |child|
406
+ case child.name
407
+ when para_name
408
+ body << convert_paragraph(child)
409
+ extract_embedded_blocks(child).each { |block| body << block }
410
+ when 'ul', 'ol'
411
+ dispatch_list(child, body)
412
+ end
413
+ end
414
+ return [] if body.empty?
415
+
416
+ [Content::Note.new(label: label, body: body, id: elem.attribute(selector('id_attribute'))&.value)]
417
+ end
418
+
419
+ def dispatch_list(elem, body)
420
+ mapping_entry = (@rules['element_mapping'] || {})[elem.name]
421
+ kind = mapping_entry&.fetch('kind', nil)&.to_sym || :bullet
422
+ converted = convert_list(elem, kind: kind)
423
+ body.concat(converted.is_a?(Array) ? converted : [converted])
424
+ end
425
+
426
+ # An example renders its label as a block heading ("Example:"),
427
+ # so the label is the localized element name only — the autonum
428
+ # in fmt-name belongs to cross-references, not the display.
429
+ def convert_example(elem)
430
+ label_elem = find_first(elem, selector('note_label'))
431
+ label = element_name_text(label_elem) || (label_elem ? text_of(label_elem).strip : '')
432
+
403
433
  para_name = selector('paragraph')
404
434
  body = []
405
435
  each_element(elem) do |child|
406
436
  next unless child.name == para_name
407
437
 
408
- body << convert_paragraph(child)
438
+ para = convert_paragraph(child)
439
+ # Example bodies take the example style (leading, indents) —
440
+ # the source paragraphs carry the generic inline style.
441
+ body << Content::Paragraph.new(para.inline_runs, style_id: :example,
442
+ id: para.id,
443
+ footnote_refs: para.footnote_refs)
409
444
  end
410
445
  return [] if body.empty?
411
446
 
412
- [Content::Note.new(label: label, body: body, id: elem.attribute(selector('id_attribute'))&.value)]
447
+ [Content::Example.new(label: label, body: body, id: elem.attribute(selector('id_attribute'))&.value)]
448
+ end
449
+
450
+ def element_name_text(label_elem)
451
+ return nil unless label_elem
452
+
453
+ name_class = selector('element_name')
454
+ return nil unless name_class
455
+
456
+ span = nil
457
+ label_elem.each_recursive do |node|
458
+ next unless node.is_a?(REXML::Element) && node.name == selector('span') &&
459
+ node.attribute('class')&.value == name_class
460
+
461
+ span = node
462
+ break
463
+ end
464
+ span ? text_of(span).strip : nil
413
465
  end
414
466
 
415
467
  def convert_bibitem(bi)
@@ -436,47 +488,20 @@ module Arrolio
436
488
 
437
489
  # ---- Inline run collection (driven by rules) ----
438
490
 
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
491
+
492
+
493
+ def baseline_for_style(rules, element, current_baseline, current_scale)
494
+ mapping = rules[element.name]
495
+ return [current_baseline, current_scale] unless mapping
496
+
497
+ case mapping.to_sym
498
+ when :subscript
499
+ [Content::InlineRun::BASELINE_SUB, (current_scale * 0.7).round(4)]
500
+ when :superscript
501
+ [Content::InlineRun::BASELINE_SUP, (current_scale * 0.7).round(4)]
502
+ else
503
+ [current_baseline, current_scale]
477
504
  end
478
- elem.children.each { |c| walker.call(c, default_style) }
479
- runs
480
505
  end
481
506
 
482
507
  def resolve_inline_style(elem, current)
@@ -493,96 +518,17 @@ module Arrolio
493
518
  current
494
519
  end
495
520
 
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
521
 
504
- elem.each_recursive do |child|
505
- next unless child.is_a?(REXML::Text)
506
522
 
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
523
 
514
524
  # ---- Heading extraction ----
515
525
 
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
526
 
528
- def extract_from_heading(heading_elem)
529
- parts = []
530
- number_parts = []
531
- title_parts = []
532
- autonum_class = selector('autonum_class')
533
527
 
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
528
 
575
529
  # ---- Text helpers ----
576
530
 
577
- def normalize_text(raw)
578
- return raw unless raw.is_a?(String)
579
- return raw unless raw.include?("\n")
580
531
 
581
- stripped = raw.strip
582
- return raw.match?(/ /) ? ' ' : nil if stripped.empty?
583
-
584
- stripped.gsub(/\s+/, ' ')
585
- end
586
532
 
587
533
  def text_of(elem)
588
534
  return '' unless elem