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
|
@@ -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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
-
|
|
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:
|
|
160
|
+
title_style_id: title_style_for(context, level)
|
|
211
161
|
)
|
|
212
162
|
end
|
|
213
163
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
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
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
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
|
-
|
|
290
|
-
|
|
264
|
+
[Content::FigureGroup.new(image: image, caption: caption, id: elem.attribute(selector('id_attribute'))&.value)]
|
|
265
|
+
end
|
|
291
266
|
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
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
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
314
|
-
|
|
315
|
-
|
|
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
|
-
|
|
324
|
-
|
|
325
|
-
next unless child.name == para_name
|
|
313
|
+
parts = viewbox.split(/\s+/)
|
|
314
|
+
return [nil, nil] unless parts.length == 4
|
|
326
315
|
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
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
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
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
|
-
|
|
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
|
-
|
|
395
|
-
|
|
396
|
-
|
|
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
|
-
|
|
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::
|
|
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
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
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
|