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.
- checksums.yaml +4 -4
- data/.rubocop.yml +2 -2
- data/Rakefile +15 -0
- data/TODO.complete/56-generic-adapter-mece-decomposition.md +20 -0
- data/TODO.complete/60-adapter-fidelity-footnotes-notes-examples.md +92 -55
- data/TODO.complete/70-ttf-font-metrics-parity.md +68 -0
- data/TODO.complete/71-cover-layout-parity.md +45 -0
- data/TODO.complete/72-table-layout-parity.md +49 -0
- data/TODO.complete/73-toc-leaders-parity.md +55 -0
- data/TODO.complete/74-svg-image-rendering-parity.md +76 -0
- data/TODO.complete/75-mathml-formula-rendering-parity.md +52 -0
- data/TODO.complete/76-odd-even-page-templates-parity.md +50 -0
- data/TODO.complete/77-text-formatting-parity.md +55 -0
- data/TODO.complete/78-bibliography-formatting-parity.md +62 -0
- data/TODO.complete/79-line-breaking-parity.md +80 -0
- data/TODO.complete/80-parity-diff-harness-in-ci.md +68 -0
- data/TODO.complete/81-conditional-style-refinements.md +70 -0
- data/TODO.complete/82-semantic-presentation-dedup.md +65 -0
- data/TODO.complete/83-nested-list-support.md +74 -0
- data/TODO.complete/84-figure-svg-rendering.md +70 -0
- data/TODO.complete/85-locality-reference-formatting.md +48 -0
- data/TODO.complete/86-list-item-text-overflow.md +80 -0
- data/TODO.complete/87-odd-even-page-templates.md +55 -0
- data/TODO.complete/88-hyphenation-support.md +52 -0
- data/TODO.complete/89-table-rowspan.md +49 -0
- data/TODO.complete/90-mathml-stacked-layout.md +65 -0
- data/TODO.complete/91-parity-diff-diagnostics.md +50 -0
- data/TODO.complete/92-table-cell-height-parity.md +66 -0
- data/TODO.complete/93-figure-text-searchability.md +49 -0
- data/TODO.complete/94-fo-space-resolution.md +63 -0
- data/TODO.complete/95-title-header-placement.md +55 -0
- data/TODO.complete/96-pagination-drift-parity.md +517 -0
- data/TODO.complete/README.md +58 -38
- data/lib/arrolio/config_driven_pipeline.rb +45 -1
- data/lib/arrolio/content/document.rb +4 -3
- data/lib/arrolio/content/section.rb +3 -0
- data/lib/arrolio/content/table.rb +25 -14
- data/lib/arrolio/embedded_block_extractor.rb +54 -0
- data/lib/arrolio/engine/paged.rb +109 -28
- data/lib/arrolio/flavor/manifest.rb +0 -1
- data/lib/arrolio/flavor/registry.rb +1 -1
- data/lib/arrolio/flowable.rb +21 -0
- data/lib/arrolio/flowables/figure_flowable.rb +17 -0
- data/lib/arrolio/flowables/footnote_marker_flowable.rb +32 -13
- data/lib/arrolio/flowables/group_flowable.rb +57 -0
- data/lib/arrolio/flowables/list_flowable.rb +46 -18
- data/lib/arrolio/flowables/note_flowable.rb +84 -9
- data/lib/arrolio/flowables/page_sequence_start.rb +8 -4
- data/lib/arrolio/flowables/positioned_block.rb +55 -0
- data/lib/arrolio/flowables/rotated_text.rb +51 -0
- data/lib/arrolio/flowables/table_flowable.rb +303 -71
- data/lib/arrolio/flowables/text_flowable.rb +67 -11
- data/lib/arrolio/flowables/toc_line_flowable.rb +70 -7
- data/lib/arrolio/flowables.rb +4 -0
- data/lib/arrolio/font/embedder.rb +22 -3
- data/lib/arrolio/font_scanner.rb +133 -0
- data/lib/arrolio/generic_adapter/document_extraction.rb +101 -0
- data/lib/arrolio/generic_adapter/footnote_extraction.rb +81 -0
- data/lib/arrolio/generic_adapter/heading_extraction.rb +92 -0
- data/lib/arrolio/generic_adapter/inline_run_collection.rb +136 -0
- data/lib/arrolio/generic_adapter/list_conversion.rb +70 -0
- data/lib/arrolio/generic_adapter/metadata_extraction.rb +46 -0
- data/lib/arrolio/generic_adapter/table_conversion.rb +105 -0
- data/lib/arrolio/generic_adapter.rb +263 -317
- data/lib/arrolio/generic_flow_builder/bibliography.rb +47 -0
- data/lib/arrolio/generic_flow_builder/figures.rb +90 -0
- data/lib/arrolio/generic_flow_builder/lists.rb +66 -0
- data/lib/arrolio/generic_flow_builder/notes.rb +61 -0
- data/lib/arrolio/generic_flow_builder/sequences.rb +102 -0
- data/lib/arrolio/generic_flow_builder/tables.rb +28 -0
- data/lib/arrolio/generic_flow_builder/terms.rb +89 -0
- data/lib/arrolio/generic_flow_builder.rb +92 -187
- data/lib/arrolio/glyph_measurer.rb +16 -5
- data/lib/arrolio/inline_run.rb +2 -1
- data/lib/arrolio/layout_spec/page_template.rb +14 -8
- data/lib/arrolio/math_ml/inline_run_extractor.rb +272 -0
- data/lib/arrolio/math_ml.rb +7 -0
- data/lib/arrolio/output/page.rb +5 -4
- data/lib/arrolio/output/placed_box.rb +5 -2
- data/lib/arrolio/renderer/pdf/assets.rb +94 -0
- data/lib/arrolio/renderer/pdf/font_embedding.rb +77 -0
- data/lib/arrolio/renderer/pdf/metadata.rb +52 -0
- data/lib/arrolio/renderer/pdf.rb +140 -215
- data/lib/arrolio/style/definition.rb +7 -3
- data/lib/arrolio/style/registry.rb +6 -1
- data/lib/arrolio/table/auto_layout.rb +19 -10
- data/lib/arrolio/table/grid.rb +105 -0
- data/lib/arrolio/table.rb +1 -0
- data/lib/arrolio/text_layout/greedy.rb +15 -1
- data/lib/arrolio/text_layout/knuth_plass/breaker.rb +216 -92
- data/lib/arrolio/text_layout/knuth_plass/item_builder.rb +31 -17
- data/lib/arrolio/text_layout/knuth_plass.rb +3 -0
- data/lib/arrolio/text_layout/line.rb +10 -2
- data/lib/arrolio/version.rb +1 -1
- data/lib/arrolio.rb +8 -4
- data/scripts/parity_check.rb +52 -0
- data/scripts/parity_diff.rb +89 -0
- data/scripts/xsl_profiles/standoc.yml +8 -2
- data/scripts/xsl_to_config.rb +28 -1
- 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
|
|
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
|
|
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
|
|
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
|