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