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
@@ -87,16 +87,35 @@ module Arrolio
87
87
  sf = Fontisan::FontLoader.load(path)
88
88
  cm = sf.table('cmap').unicode_mappings
89
89
  @subset_cp_to_gid = cm.transform_keys(&:to_i)
90
- @subset_gid_to_cp = @subset_cp_to_gid.invert
90
+ @subset_gid_to_cp = invert_prefer_ascii(@subset_cp_to_gid)
91
91
  end
92
92
  rescue StandardError => e
93
93
  Arrolio::Logger.warn "subset cmap extract failed: #{e.message[0,80]}"
94
94
  @subset_cp_to_gid = @unicode_to_gid
95
- @subset_gid_to_cp = @unicode_to_gid.invert
95
+ @subset_gid_to_cp = invert_prefer_ascii(@unicode_to_gid)
96
+ end
97
+
98
+ # Build GID -> codepoint map, preferring ASCII codepoints when
99
+ # multiple codepoints share the same GID. Fonts commonly have
100
+ # both U+0020 (SPACE) and U+00A0 (NO-BREAK SPACE) mapped to
101
+ # the same glyph; without preference, Hash#invert would keep
102
+ # U+00A0, breaking text extraction (pdftotext reads no-break
103
+ # spaces as solid characters, not word separators).
104
+ def invert_prefer_ascii(cp_to_gid)
105
+ result = {}
106
+ cp_to_gid.each do |cp, gid|
107
+ existing = result[gid]
108
+ if existing.nil?
109
+ result[gid] = cp
110
+ elsif cp < 0x80 && existing >= 0x80
111
+ result[gid] = cp
112
+ end
113
+ end
114
+ result
96
115
  end
97
116
 
98
117
  def build_tounicode_stream
99
- gid_to_cp = @subset_gid_to_cp || @unicode_to_gid.invert
118
+ gid_to_cp = @subset_gid_to_cp || invert_prefer_ascii(@unicode_to_gid)
100
119
  lines = []
101
120
  lines << '/CIDInit /ProcSet findresource begin'
102
121
  lines << '12 dict begin'
@@ -0,0 +1,133 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fontisan'
4
+
5
+ module Arrolio
6
+ # Scans font directories for TTF files and builds an index of
7
+ # family-name → file-path mappings using Fontisan to read each
8
+ # font's internal name tables. Used by ConfigDrivenPipeline to
9
+ # resolve font family names referenced by layout styles to actual
10
+ # TTF files on disk.
11
+ class FontScanner
12
+ FONT_DIRECTORIES = [
13
+ File.expand_path('~/.fontist/fonts'),
14
+ '/Library/Fonts',
15
+ '/System/Library/Fonts',
16
+ File.expand_path('~/Library/Fonts')
17
+ ].freeze
18
+
19
+ attr_reader :index
20
+
21
+ def initialize(directories = FONT_DIRECTORIES)
22
+ @directories = directories
23
+ @index = nil
24
+ end
25
+
26
+ def resolve(font_name)
27
+ build_index unless @index
28
+ @index[font_name.to_s]
29
+ end
30
+
31
+ def build_index
32
+ @index = {}
33
+ @directories.each do |dir|
34
+ scan_directory(dir)
35
+ end
36
+ @index
37
+ end
38
+
39
+ private
40
+
41
+ def scan_directory(dir)
42
+ Dir.glob(File.join(dir, '**', '*.ttf')).each do |path|
43
+ add_to_index(path)
44
+ end
45
+ Dir.glob(File.join(dir, '**', '*.TTF')).each do |path|
46
+ add_to_index(path)
47
+ end
48
+ end
49
+
50
+ def add_to_index(path)
51
+ info = read_font_info(path)
52
+ return unless info
53
+
54
+ info[:names].each do |name|
55
+ @index[name] ||= path
56
+ end
57
+ end
58
+
59
+ def read_font_info(path)
60
+ font = Fontisan::FontLoader.load(path)
61
+ name_table = font.table('name')
62
+ return nil unless name_table
63
+
64
+ storage = name_table.string_storage
65
+ names = extract_names(name_table, storage)
66
+ weight = extract_weight(font)
67
+ style_suffix = extract_style_suffix(font)
68
+ family = name_id(name_table, storage, 1)
69
+
70
+ names.map { |n| @index[n] ||= path }
71
+ if family && style_suffix
72
+ @index["#{family} #{style_suffix}"] ||= path
73
+ end
74
+ @index[family] ||= path if family
75
+ return nil unless family
76
+
77
+ { names: names, family: family, weight: weight }
78
+ rescue StandardError
79
+ nil
80
+ end
81
+
82
+ def extract_style_suffix(font)
83
+ os2 = font.table('OS/2')
84
+ return nil unless os2
85
+
86
+ flags = os2.fs_selection.to_i
87
+ weight = os2.us_weight_class.to_i
88
+ parts = []
89
+ parts << 'Bold' if flags.anybits?(32) || weight >= 700
90
+ parts << 'Italic' if flags.anybits?(1)
91
+ parts.empty? ? nil : parts.join(' ')
92
+ rescue StandardError
93
+ nil
94
+ end
95
+
96
+ def extract_names(name_table, storage)
97
+ names = []
98
+ [1, 4, 6, 16].each do |name_id|
99
+ name = name_id(name_table, storage, name_id)
100
+ names << name if name && !name.empty?
101
+ end
102
+ # Register family + variant suffix for bold/italic resolution
103
+ family = name_id(name_table, storage, 1)
104
+ subfamily = name_id(name_table, storage, 2)
105
+ if family && subfamily
106
+ names << "#{family} #{subfamily}" unless subfamily == 'Regular'
107
+ names << family if subfamily == 'Regular'
108
+ end
109
+ names.uniq
110
+ end
111
+
112
+ def extract_weight(font)
113
+ os2 = font.table('OS/2')
114
+ return nil unless os2
115
+
116
+ os2.weight_class rescue nil
117
+ end
118
+
119
+ def name_id(name_table, storage, id)
120
+ record = name_table.name_records.find do |r|
121
+ r.name_id == id && r.platform_id == 3
122
+ end
123
+ return nil unless record
124
+
125
+ storage.byteslice(record.string_offset, record.string_length)
126
+ .force_encoding('UTF-16BE')
127
+ .encode('UTF-8', invalid: :replace, undef: :replace)
128
+ .strip
129
+ rescue StandardError
130
+ nil
131
+ end
132
+ end
133
+ end
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Arrolio
4
+ class GenericAdapter
5
+ # Extracted conversion seam (TODO 56): one module per concern,
6
+ # included into the adapter. The class keeps dispatch and glue.
7
+ module DocumentExtraction
8
+ def extract_sections(root)
9
+ sections_config = @rules['sections'] || {}
10
+ container = sections_config['container'] || 'sections'
11
+ sections_elem = find_first(root, container)
12
+ return [] unless sections_elem
13
+
14
+ children = []
15
+ each_direct_child(sections_elem) do |child|
16
+ next unless map_element(child.name, sections_elem) == 'section'
17
+
18
+ children << convert_clause(child)
19
+ end
20
+ children
21
+ end
22
+
23
+ def extract_title_block(root)
24
+ sections_config = @rules['sections'] || {}
25
+ container = sections_config['container'] || 'sections'
26
+ sections_elem = find_first(root, container)
27
+ return nil unless sections_elem
28
+
29
+ para_name = selector('paragraph')
30
+ each_direct_child(sections_elem) do |child|
31
+ next unless child.name == para_name
32
+
33
+ cls = child.attribute('class')&.value
34
+ title_style = (@rules['paragraph_styles'] || {})[cls]
35
+ next unless title_style
36
+
37
+ return Content::Paragraph.new(
38
+ title_block_runs(root),
39
+ style_id: title_style.to_sym,
40
+ id: child.attribute(selector('id_attribute'))&.value
41
+ )
42
+ end
43
+ nil
44
+ end
45
+
46
+ def title_block_runs(root)
47
+ metadata = extract_metadata(root)
48
+ part = metadata[:part_number].to_s
49
+ title_part = metadata[:title_part].to_s
50
+ locality = @rules.dig('i18n', 'locality_part').to_s
51
+ locality = 'Part' if locality.empty?
52
+ text = if part.empty? && title_part.empty?
53
+ ''
54
+ elsif part.empty?
55
+ title_part
56
+ else
57
+ "#{locality} #{part} - #{title_part}".strip
58
+ end
59
+ [Content::InlineRun.new(text, style_id: :doc_title)]
60
+ end
61
+
62
+ def extract_preface(root)
63
+ preface_elem = find_first(root, selector('preface_container'))
64
+ return [] unless preface_elem
65
+
66
+ result = []
67
+ each_element(preface_elem) do |child|
68
+ next if child == preface_elem
69
+ next unless Array(selector('preface_children')).include?(child.name)
70
+
71
+ result << convert_clause(child, context: :preface)
72
+ end
73
+ result
74
+ end
75
+
76
+ def extract_bibliography(root)
77
+ biblio_elem = find_first(root, selector('bibliography_container'))
78
+ return [] unless biblio_elem
79
+
80
+ result = []
81
+ each_element(biblio_elem) do |child|
82
+ next if child == biblio_elem
83
+ next unless child.name == selector('bibliography_reference')
84
+
85
+ items = []
86
+ each_child(child, selector('bibliography_item')) do |bi|
87
+ items.concat(convert_bibitem(bi))
88
+ end
89
+ result << Content::Section.new(
90
+ title: 'Bibliography',
91
+ level: 1,
92
+ children: items,
93
+ style_id: :section_body_1,
94
+ title_style_id: title_style_for('bibliography', 1)
95
+ )
96
+ end
97
+ result
98
+ end
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Arrolio
4
+ class GenericAdapter
5
+ # Footnote extraction, split from the adapter body (TODO 56
6
+ # MECE split). Included as a module so the extraction logic
7
+ # stays with its selectors while the adapter class stays lean.
8
+ module FootnoteExtraction
9
+ # Footnotes come in two shapes: the formatted container
10
+ # (fmt-footnote-container + fmt-fn-body) and the raw marker
11
+ # element (<fn reference="a">) whose body is its own paragraphs.
12
+ # Both register in Document#footnotes, keyed by id; the raw form
13
+ # is what body text and table cells reference.
14
+ def extract_footnotes(root)
15
+ container_name = selector('footnote_container')
16
+ body_name = selector('footnote_body')
17
+ marker_name = selector('footnote_marker')
18
+ return [] unless marker_name || (container_name && body_name)
19
+
20
+ footnotes = []
21
+ ids = []
22
+ # Recursive: footnote containers and raw <fn> markers live at
23
+ # arbitrary depths inside clauses, tables, and terms.
24
+ root.each_recursive do |elem|
25
+ if container_name && elem.name == container_name
26
+
27
+ marker = elem.attribute('id')&.value || elem.attribute(marker_name)&.value || ''
28
+ body_elem = find_first(elem, body_name)
29
+ body = []
30
+ if body_elem
31
+ para_name = selector('paragraph')
32
+ each_element(body_elem) do |child|
33
+ next unless child.name == para_name
34
+
35
+ body << convert_paragraph(child)
36
+ end
37
+ end
38
+ next if body.empty?
39
+
40
+ id = elem.attribute(selector('id_attribute'))&.value
41
+ next if id && ids.include?(id)
42
+
43
+ ids << id if id
44
+ footnotes << Content::Footnote.new(
45
+ marker: marker,
46
+ body: body,
47
+ id: id
48
+ )
49
+ elsif marker_name && elem.name == marker_name
50
+ id = elem.attribute('id')&.value ||
51
+ elem.attribute(selector('id_attribute'))&.value
52
+ next if id && ids.include?(id)
53
+
54
+ marker = elem.attribute('reference')&.value || id || ''
55
+ body = footnote_body_paragraphs(elem)
56
+ next if body.empty?
57
+
58
+ ids << id if id
59
+ footnotes << Content::Footnote.new(
60
+ marker: marker,
61
+ body: body,
62
+ id: id
63
+ )
64
+ end
65
+ end
66
+ footnotes
67
+ end
68
+
69
+ def footnote_body_paragraphs(elem)
70
+ para_name = selector('paragraph')
71
+ paragraphs = []
72
+ each_element(elem) do |child|
73
+ next unless child.name == para_name
74
+
75
+ paragraphs << convert_paragraph(child)
76
+ end
77
+ paragraphs
78
+ end
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Arrolio
4
+ class GenericAdapter
5
+ # Extracted conversion seam (TODO 56): one module per concern,
6
+ # included into the adapter. The class keeps dispatch and glue.
7
+ module HeadingExtraction
8
+ # The level derives from the autonumber's dotted depth ("5.1.1"
9
+ # → 3, "A.1" → 2) when present — the semantic truth — falling
10
+ # back to the fmt-title depth attribute. Titles without either
11
+ # stay level 1. Relying on the attribute alone mis-leveled most
12
+ # sections (3.x, 5.1.x) because their fmt-title carries no depth.
13
+ def clause_level(elem, number: nil)
14
+ dotted = number.to_s.split('.').length
15
+ return dotted if dotted > 1 && number.to_s.match?(/\A[\dA-Z]/)
16
+
17
+ heading_name = selector('heading')
18
+ depth_attr = selector('heading_depth_attribute')
19
+ fmt_title = elem.elements.first { |e| e.name == heading_name }
20
+ depth = fmt_title&.attribute(depth_attr)&.value.to_i
21
+ [depth, 1].max
22
+ end
23
+
24
+ def extract_heading(clause)
25
+ heading_config = @rules['heading'] || {}
26
+ source = heading_config['source'] || selector('heading')
27
+ heading_elem = find_first(clause, source)
28
+ if heading_elem
29
+ extract_from_heading(heading_elem)
30
+ else
31
+ title = find_first(clause, selector('fallback_title'))
32
+ [title ? text_of(title) : nil, nil]
33
+ end
34
+ end
35
+
36
+ def extract_from_heading(heading_elem)
37
+ parts = []
38
+ number_parts = []
39
+ title_parts = []
40
+ non_number_parts = []
41
+ autonum_class = selector('autonum_class')
42
+ autonum_delim_class = selector('autonum_delim_class') || 'fmt-autonum-delim'
43
+ caption_label_class = selector('caption_label_class') || 'fmt-caption-label'
44
+
45
+ walk_heading_text(heading_elem) do |text, parent|
46
+ parts << text
47
+ element_attr = parent&.attribute('element')&.value
48
+ parent_class = parent&.attribute('class')&.value
49
+ if element_attr == 'autonum' ||
50
+ parent_class == autonum_class ||
51
+ parent_class == autonum_delim_class ||
52
+ parent_class == caption_label_class
53
+ number_parts << text
54
+ elsif element_attr == 'title'
55
+ title_parts << text
56
+ non_number_parts << text
57
+ else
58
+ non_number_parts << text
59
+ end
60
+ end
61
+
62
+ number = number_parts.map(&:strip).reject(&:empty?).join
63
+ number = nil if number.empty?
64
+
65
+ title = if title_parts.any?
66
+ title_parts.join.strip
67
+ elsif parts.include?("\t")
68
+ parts.join.split("\t", 2).last&.strip
69
+ else
70
+ non_number_parts.join.strip
71
+ end
72
+ title = nil if title.nil? || title.empty?
73
+ [title, number]
74
+ end
75
+
76
+ def walk_heading_text(element, &block)
77
+ element.children.each do |child|
78
+ next if child.is_a?(REXML::Element) &&
79
+ (XREF_ELEMENTS.include?(child.name) ||
80
+ child.attribute('element')&.value == 'xref')
81
+
82
+ case child
83
+ when REXML::Text
84
+ yield(child.value, child.parent)
85
+ when REXML::Element
86
+ walk_heading_text(child, &block)
87
+ end
88
+ end
89
+ end
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,136 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Arrolio
4
+ class GenericAdapter
5
+ # Extracted conversion seam (TODO 56): one module per concern,
6
+ # included into the adapter. The class keeps dispatch and glue.
7
+ module InlineRunCollection
8
+ UNICODE_SPACES = Regexp.new("[\u00A0\u1680\u2000-\u200B\u202F\u205F\u3000\uFEFF]")
9
+ def collect_inline_runs(elem, default_style: :inline, exclude: nil,
10
+ footnote_markers: false)
11
+ runs = []
12
+ stem_name = selector('stem')
13
+ fn_name = footnote_markers ? selector('footnote_marker') : nil
14
+ stem_formatted_name = selector('stem_formatted')
15
+ math_name = selector('math')
16
+ tab_name = selector('tab_inline')
17
+ br_name = selector('break_inline')
18
+ xref_name = selector('biblio_formattedref') ? 'fmt-xref' : nil
19
+ skip_metadata = @rules['skip_metadata_elements'].to_a
20
+ block_level = @rules['block_level_elements'].to_a
21
+ inline_styles = @rules['inline_styles'] || {}
22
+
23
+ walker = lambda do |node, style, baseline: Content::InlineRun::BASELINE_NORMAL, scale: 1.0, in_xref: false|
24
+ case node
25
+ when REXML::Text
26
+ raw = normalize_text(node.value)
27
+ next if raw.nil? || raw.empty?
28
+
29
+ text = in_xref ? format_locality_text(raw) : raw
30
+ runs << Content::InlineRun.new(text, style_id: style,
31
+ baseline_shift: baseline,
32
+ font_size_scale: scale)
33
+ when REXML::Element
34
+ next if exclude && node.name == exclude
35
+
36
+ if fn_name && node.name == fn_name
37
+ marker = node.attribute('reference')&.value ||
38
+ node.attribute('id')&.value
39
+ unless marker.nil? || marker.empty?
40
+ runs << Content::InlineRun.new(
41
+ marker, style_id: style,
42
+ baseline_shift: Content::InlineRun::BASELINE_SUP,
43
+ font_size_scale: 0.7
44
+ )
45
+ end
46
+ next
47
+ end
48
+
49
+ new_style = resolve_inline_style(node, style)
50
+ sub_baseline, sub_scale = baseline_for_style(inline_styles, node,
51
+ baseline, scale)
52
+ child_in_xref = in_xref || (xref_name && node.name == xref_name)
53
+ case node.name
54
+ when tab_name
55
+ runs << Content::InlineRun.new("\t", style_id: style)
56
+ when br_name
57
+ runs << Content::InlineRun.new("\n", style_id: style)
58
+ when stem_name
59
+ unless sibling_exists?(node.parent, stem_formatted_name)
60
+ walk_stem(node, style, runs, stem_formatted_name, math_name)
61
+ end
62
+ when stem_formatted_name
63
+ walk_math(node, style, runs)
64
+ when *skip_metadata
65
+ next
66
+ when *block_level
67
+ next
68
+ else
69
+ node.children.each do |c|
70
+ walker.call(c, new_style, baseline: sub_baseline, scale: sub_scale, in_xref: child_in_xref)
71
+ end
72
+ end
73
+ end
74
+ end
75
+ elem.children.each { |c| walker.call(c, default_style) }
76
+ runs
77
+ end
78
+
79
+ def format_locality_text(text)
80
+ text.gsub(/([a-zA-Z]+)=/, '\\1 ')
81
+ end
82
+
83
+ def walk_stem(stem_elem, style, runs, formatted_name, math_name)
84
+ target = find_first(stem_elem, formatted_name) || find_first(stem_elem, math_name)
85
+ walk_math(target, style, runs) if target
86
+ end
87
+
88
+ # Walks a MathML tree emitting InlineRuns with appropriate
89
+ # baseline_shift + font_size_scale. Delegates the actual MathML
90
+ # parsing to the plurimath/mml gem via MathML::InlineRunExtractor,
91
+ # keeping this adapter free of its own MathML grammar.
92
+ def walk_math(elem, style, runs)
93
+ return unless elem
94
+
95
+ math_name = selector('math')
96
+ math_elem = if elem.name == math_name
97
+ elem
98
+ else
99
+ found = nil
100
+ elem.each_recursive { |e| found = e if e.name == math_name && found.nil? }
101
+ found
102
+ end
103
+ return unless math_elem
104
+
105
+ math_xml = serialize_element_to_xml(math_elem)
106
+ extracted = ::Arrolio::MathML::InlineRunExtractor.extract_from_xml(math_xml,
107
+ base_style: style)
108
+ runs.concat(extracted)
109
+ end
110
+
111
+ def serialize_element_to_xml(elem)
112
+ io = StringIO.new(+'')
113
+ REXML::Formatters::Default.new.write(elem, io)
114
+ io.string
115
+ end
116
+
117
+ # Newline-bearing text nodes keep ONE space at each boundary:
118
+ # 'where\n<stem>' must render 'where Y' — stripping the
119
+ # trailing whitespace glued words to the following element and
120
+ # made long formula runs unbreakable (clipped at the page
121
+ # edge).
122
+ def normalize_text(raw)
123
+ return raw unless raw.is_a?(String)
124
+ cleaned = raw.gsub(UNICODE_SPACES, ' ')
125
+ return cleaned unless cleaned.include?("\n")
126
+
127
+ stripped = cleaned.strip
128
+ return cleaned.match?(/\s/) ? ' ' : nil if stripped.empty?
129
+
130
+ lead = cleaned.match?(/\A\s/) ? ' ' : ''
131
+ trail = cleaned.match?(/\s\z/) ? ' ' : ''
132
+ lead + stripped.gsub(/\s+/, ' ') + trail
133
+ end
134
+ end
135
+ end
136
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Arrolio
4
+ class GenericAdapter
5
+ # Extracted conversion seam (TODO 56): one module per concern,
6
+ # included into the adapter. The class keeps dispatch and glue.
7
+ module ListConversion
8
+ def convert_list(elem, kind:)
9
+ return convert_definition_list(elem) if elem.name == 'dl'
10
+
11
+ items = []
12
+ item_name = selector('list_item')
13
+ label_name = selector('list_item_label')
14
+ para_name = selector('paragraph')
15
+ list_mapping = (@rules['element_mapping'] || {}).select do |_, v|
16
+ v['content_type'] == 'list'
17
+ end
18
+ each_child(elem, item_name) do |li|
19
+ # Ordered lists carry their autonum in the label element;
20
+ # unordered lists don't number (the label text is a
21
+ # placeholder) — they render the flavor's bullet instead.
22
+ marker = nil
23
+ if kind != :bullet
24
+ label_elem = find_first(li, label_name)
25
+ marker = text_of(label_elem) if label_elem
26
+ end
27
+
28
+ content = []
29
+ each_element(li) do |child|
30
+ next if child.parent && child.parent != li
31
+
32
+ if child.name == para_name
33
+ content << convert_paragraph(child)
34
+ elsif list_mapping.key?(child.name)
35
+ nested_kind = list_mapping[child.name]['kind']&.to_sym || :bullet
36
+ content << convert_list(child, kind: nested_kind)
37
+ end
38
+ end
39
+ content = [Content::Paragraph.new(collect_inline_runs(li))] if content.empty?
40
+ items << Content::List::Item.new(content, marker: marker)
41
+ end
42
+ Content::List.new(items, kind: kind, style_id: kind == :ordered ? :list_ordered : :list_bullet)
43
+ end
44
+
45
+ def convert_definition_list(elem)
46
+ items = []
47
+ para_name = selector('paragraph')
48
+ current_marker = nil
49
+
50
+ each_direct_child(elem) do |child|
51
+ case child.name
52
+ when 'dt'
53
+ current_marker = text_of(child).strip
54
+ when 'dd'
55
+ content = []
56
+ each_element(child) do |c|
57
+ next unless c.name == para_name && c.parent == child
58
+ content << convert_paragraph(c)
59
+ end
60
+ content = [Content::Paragraph.new(collect_inline_runs(child))] if content.empty?
61
+ items << Content::List::Item.new(content, marker: current_marker)
62
+ current_marker = nil
63
+ end
64
+ end
65
+
66
+ Content::List.new(items, kind: :definition, style_id: :list_bullet)
67
+ end
68
+ end
69
+ end
70
+ end