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
data/exe/arrolio2pdf
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'arrolio'
|
|
4
|
+
|
|
5
|
+
input = ARGV[0]
|
|
6
|
+
output = ARGV[1] || 'out.pdf'
|
|
7
|
+
flavor_dir = ARGV[2] || ENV.fetch('ARROLIO_FLAVOR_DIR', nil)
|
|
8
|
+
|
|
9
|
+
unless input && File.exist?(input)
|
|
10
|
+
warn 'usage: arrolio2pdf <input.xml> [output.pdf] [flavor_dir]'
|
|
11
|
+
warn ' (or set ARROLIO_FLAVOR_DIR env var)'
|
|
12
|
+
exit 2
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
unless flavor_dir && Dir.exist?(flavor_dir)
|
|
16
|
+
warn "error: flavor directory not found: #{flavor_dir.inspect}"
|
|
17
|
+
warn ' pass it as the 3rd argument or set ARROLIO_FLAVOR_DIR'
|
|
18
|
+
exit 2
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
xml = File.read(input)
|
|
22
|
+
Arrolio::ConfigDrivenPipeline.render(
|
|
23
|
+
xml,
|
|
24
|
+
io: output,
|
|
25
|
+
flavor_dir: File.expand_path(flavor_dir),
|
|
26
|
+
input_path: File.expand_path(input)
|
|
27
|
+
)
|
|
28
|
+
puts "wrote #{output} (#{File.size(output)} bytes)"
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Arrolio
|
|
4
|
+
class AssetResolver
|
|
5
|
+
attr_reader :base_dirs
|
|
6
|
+
|
|
7
|
+
def initialize(base_dirs:)
|
|
8
|
+
@base_dirs = Array(base_dirs).map { |directory| File.expand_path(directory.to_s) }.freeze
|
|
9
|
+
freeze
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def resolve(source)
|
|
13
|
+
return source if source.nil? || source.empty?
|
|
14
|
+
return source if absolute?(source)
|
|
15
|
+
|
|
16
|
+
@base_dirs.each do |base_dir|
|
|
17
|
+
candidate = File.join(base_dir, source)
|
|
18
|
+
return candidate if File.exist?(candidate)
|
|
19
|
+
end
|
|
20
|
+
source
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def self.from_input_path(input_path, extra: [])
|
|
24
|
+
directories = []
|
|
25
|
+
directories << File.dirname(File.expand_path(input_path)) if input_path
|
|
26
|
+
directories.concat(Array(extra))
|
|
27
|
+
new(base_dirs: directories)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def absolute?(source)
|
|
33
|
+
source.start_with?('/', 'http://', 'https://')
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Arrolio
|
|
4
|
+
# Color value object. Parses CSS/XSL color specifications and
|
|
5
|
+
# provides normalized RGB components for the renderer. Supports:
|
|
6
|
+
# hex (#RGB, #RRGGBB), rgb(r,g,b), rgb(r%,g%,b%), named colors.
|
|
7
|
+
#
|
|
8
|
+
# The renderer calls +to_render+ to get the format pdfrb expects:
|
|
9
|
+
# +Float+ for grayscale, +[:rgb, r, g, b]+ for color.
|
|
10
|
+
class Color
|
|
11
|
+
attr_reader :red, :green, :blue, :alpha
|
|
12
|
+
|
|
13
|
+
# Named colors from the SVG/CSS specification subset commonly
|
|
14
|
+
# used in XSL-FO documents.
|
|
15
|
+
NAMED_COLORS = {
|
|
16
|
+
'black' => [0, 0, 0],
|
|
17
|
+
'white' => [255, 255, 255],
|
|
18
|
+
'red' => [255, 0, 0],
|
|
19
|
+
'green' => [0, 128, 0],
|
|
20
|
+
'blue' => [0, 0, 255],
|
|
21
|
+
'yellow' => [255, 255, 0],
|
|
22
|
+
'cyan' => [0, 255, 255],
|
|
23
|
+
'magenta' => [255, 0, 255],
|
|
24
|
+
'gray' => [128, 128, 128],
|
|
25
|
+
'grey' => [128, 128, 128],
|
|
26
|
+
'silver' => [192, 192, 192],
|
|
27
|
+
'maroon' => [128, 0, 0],
|
|
28
|
+
'purple' => [128, 0, 128],
|
|
29
|
+
'olive' => [128, 128, 0],
|
|
30
|
+
'navy' => [0, 0, 128],
|
|
31
|
+
'teal' => [0, 128, 128]
|
|
32
|
+
}.freeze
|
|
33
|
+
|
|
34
|
+
def initialize(red:, green:, blue:, alpha: 1.0)
|
|
35
|
+
@red = clamp01(red.to_f)
|
|
36
|
+
@green = clamp01(green.to_f)
|
|
37
|
+
@blue = clamp01(blue.to_f)
|
|
38
|
+
@alpha = clamp01(alpha.to_f)
|
|
39
|
+
freeze
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Factory: parse any supported color specification.
|
|
43
|
+
def self.parse(spec)
|
|
44
|
+
return spec if spec.is_a?(self)
|
|
45
|
+
return nil if spec.nil? || spec.to_s.empty?
|
|
46
|
+
|
|
47
|
+
case spec.to_s.downcase
|
|
48
|
+
when /\A#([0-9a-f])([0-9a-f])([0-9a-f])\z/
|
|
49
|
+
new(red: Regexp.last_match(1).to_i(16) * 17 / 255.0,
|
|
50
|
+
green: Regexp.last_match(2).to_i(16) * 17 / 255.0,
|
|
51
|
+
blue: Regexp.last_match(3).to_i(16) * 17 / 255.0)
|
|
52
|
+
when /\A#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})\z/
|
|
53
|
+
new(red: Regexp.last_match(1).to_i(16) / 255.0,
|
|
54
|
+
green: Regexp.last_match(2).to_i(16) / 255.0,
|
|
55
|
+
blue: Regexp.last_match(3).to_i(16) / 255.0)
|
|
56
|
+
when /\Argb\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)\z/
|
|
57
|
+
new(red: Regexp.last_match(1).to_i / 255.0,
|
|
58
|
+
green: Regexp.last_match(2).to_i / 255.0,
|
|
59
|
+
blue: Regexp.last_match(3).to_i / 255.0)
|
|
60
|
+
when /\Argb\(\s*([\d.]+)%\s*,\s*([\d.]+)%\s*,\s*([\d.]+)%\s*\)\z/
|
|
61
|
+
new(red: Regexp.last_match(1).to_f / 100.0,
|
|
62
|
+
green: Regexp.last_match(2).to_f / 100.0,
|
|
63
|
+
blue: Regexp.last_match(3).to_f / 100.0)
|
|
64
|
+
else
|
|
65
|
+
parse_named(spec.to_s.downcase)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def self.parse_named(name)
|
|
70
|
+
return nil unless NAMED_COLORS.key?(name)
|
|
71
|
+
|
|
72
|
+
r, g, b = NAMED_COLORS[name]
|
|
73
|
+
new(red: r / 255.0, green: g / 255.0, blue: b / 255.0)
|
|
74
|
+
end
|
|
75
|
+
private_class_method :parse_named
|
|
76
|
+
|
|
77
|
+
# Returns the format pdfrb's canvas expects: +[:rgb, r, g, b]+.
|
|
78
|
+
def to_render
|
|
79
|
+
[:rgb, @red, @green, @blue]
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def grayscale?
|
|
83
|
+
@red == @green && @green == @blue
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def to_grayscale_float
|
|
87
|
+
(0.299 * @red) + (0.587 * @green) + (0.114 * @blue)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def ==(other)
|
|
91
|
+
other.is_a?(self.class) &&
|
|
92
|
+
red == other.red && green == other.green &&
|
|
93
|
+
blue == other.blue && alpha == other.alpha
|
|
94
|
+
end
|
|
95
|
+
alias eql? ==
|
|
96
|
+
|
|
97
|
+
def hash
|
|
98
|
+
[self.class, red, green, blue, alpha].hash
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
private
|
|
102
|
+
|
|
103
|
+
def clamp01(v)
|
|
104
|
+
v.clamp(0.0, 1.0)
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Arrolio
|
|
4
|
+
# A multi-column frame that splits a region's width into N equal
|
|
5
|
+
# columns. The engine flows content column-by-column: when one
|
|
6
|
+
# column overflows, content moves to the next column; when all
|
|
7
|
+
# columns overflow, the page breaks. This is the foundation for
|
|
8
|
+
# multi-column body text (e.g., 2-column technical specs).
|
|
9
|
+
#
|
|
10
|
+
# Usage in layout spec YAML:
|
|
11
|
+
# page_templates:
|
|
12
|
+
# body:
|
|
13
|
+
# columns: 2
|
|
14
|
+
# column_gap: 6mm
|
|
15
|
+
class ColumnSet
|
|
16
|
+
attr_reader :column_count, :gap, :total_width, :total_height
|
|
17
|
+
|
|
18
|
+
def initialize(column_count:, gap:, total_width:, total_height:)
|
|
19
|
+
@column_count = column_count.to_i
|
|
20
|
+
@gap = gap.to_f
|
|
21
|
+
@total_width = total_width.to_f
|
|
22
|
+
@total_height = total_height.to_f
|
|
23
|
+
freeze
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Width of a single column (total - gaps) / count.
|
|
27
|
+
def column_width
|
|
28
|
+
return @total_width if @column_count <= 1
|
|
29
|
+
|
|
30
|
+
(@total_width - (@gap * (@column_count - 1))) / @column_count
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# X position of column at +index+ (0-based).
|
|
34
|
+
def column_x(index)
|
|
35
|
+
return 0.0 if @column_count <= 1
|
|
36
|
+
|
|
37
|
+
index * (column_width + @gap)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Enumerate each column as a [x, width] pair.
|
|
41
|
+
def each_column
|
|
42
|
+
return enum_for(:each_column) unless block_given?
|
|
43
|
+
|
|
44
|
+
@column_count.times { |i| yield(column_x(i), column_width) }
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def single_column?
|
|
48
|
+
@column_count <= 1
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def ==(other)
|
|
52
|
+
other.is_a?(self.class) &&
|
|
53
|
+
column_count == other.column_count &&
|
|
54
|
+
gap == other.gap &&
|
|
55
|
+
total_width == other.total_width &&
|
|
56
|
+
total_height == other.total_height
|
|
57
|
+
end
|
|
58
|
+
alias eql? ==
|
|
59
|
+
|
|
60
|
+
def hash
|
|
61
|
+
[self.class, column_count, gap, total_width, total_height].hash
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'yaml'
|
|
4
|
+
|
|
5
|
+
module Arrolio
|
|
6
|
+
class ConfigDrivenPipeline
|
|
7
|
+
attr_reader :flavor_dir, :layout_spec, :adapter_rules, :flow_rules, :manifest, :strict
|
|
8
|
+
|
|
9
|
+
def self.render(xml, io:, flavor_dir:, input_path: nil,
|
|
10
|
+
extra_image_dirs: [], logo_path: nil, metadata: {},
|
|
11
|
+
strict: false)
|
|
12
|
+
new(
|
|
13
|
+
flavor_dir: flavor_dir,
|
|
14
|
+
input_path: input_path,
|
|
15
|
+
extra_image_dirs: extra_image_dirs,
|
|
16
|
+
logo_path: logo_path,
|
|
17
|
+
strict: strict
|
|
18
|
+
).render(xml, io: io, metadata: metadata)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def initialize(flavor_dir:, input_path: nil, extra_image_dirs: [],
|
|
22
|
+
logo_path: nil, strict: false)
|
|
23
|
+
@flavor_dir = File.expand_path(flavor_dir)
|
|
24
|
+
@input_path = input_path
|
|
25
|
+
@extra_image_dirs = Array(extra_image_dirs)
|
|
26
|
+
@logo_path = logo_path
|
|
27
|
+
@strict = strict
|
|
28
|
+
@manifest = load_manifest
|
|
29
|
+
@layout_spec = load_layout_spec
|
|
30
|
+
@adapter_rules = load_yaml('adapter_rules.yml')
|
|
31
|
+
@flow_rules = load_yaml('flow_rules.yml')
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def render(xml, io:, metadata: {})
|
|
35
|
+
adapter = GenericAdapter.new(rules: @adapter_rules, layout_spec: @layout_spec)
|
|
36
|
+
document = adapter.convert(xml)
|
|
37
|
+
document_metadata = document.metadata.merge(metadata.transform_keys(&:to_sym))
|
|
38
|
+
register_fonts
|
|
39
|
+
|
|
40
|
+
resolver = AssetResolver.from_input_path(
|
|
41
|
+
@input_path,
|
|
42
|
+
extra: @extra_image_dirs + [@flavor_dir]
|
|
43
|
+
)
|
|
44
|
+
flowables = GenericFlowBuilder.new(
|
|
45
|
+
layout_spec: @layout_spec,
|
|
46
|
+
rules: @flow_rules,
|
|
47
|
+
asset_resolver: resolver
|
|
48
|
+
).build(document)
|
|
49
|
+
engine = Engine::Paged.new(layout_spec: @layout_spec, flowables: flowables)
|
|
50
|
+
pages = engine.layout
|
|
51
|
+
populate_toc(pages, engine.context)
|
|
52
|
+
|
|
53
|
+
Renderer::Pdf.new.render(
|
|
54
|
+
pages,
|
|
55
|
+
io: io,
|
|
56
|
+
logo_path: @logo_path,
|
|
57
|
+
metadata: document_metadata,
|
|
58
|
+
font_paths: @layout_spec.font_paths,
|
|
59
|
+
context: engine.context,
|
|
60
|
+
layout_spec: @layout_spec
|
|
61
|
+
)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
def load_manifest
|
|
67
|
+
Flavor::Manifest.load(@flavor_dir)
|
|
68
|
+
rescue FlavorError => e
|
|
69
|
+
Arrolio::Logger.debug "no manifest loaded for #{@flavor_dir}: #{e.message}"
|
|
70
|
+
nil
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def load_yaml(filename)
|
|
74
|
+
path = File.join(@flavor_dir, filename)
|
|
75
|
+
YAML.safe_load_file(path, aliases: true) || {}
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def load_layout_spec
|
|
79
|
+
source = load_yaml('layout_spec.yml')
|
|
80
|
+
source['font_paths'] = resolved_font_paths(source['font_paths'] || {})
|
|
81
|
+
LayoutSpec::Loader.build_from_hash(source)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def resolved_font_paths(paths)
|
|
85
|
+
paths.to_h do |name, path|
|
|
86
|
+
resolved = path.to_s.start_with?('/') ? path : File.join(@flavor_dir, path.to_s)
|
|
87
|
+
[name, resolved]
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def register_fonts
|
|
92
|
+
FontMetrics::Registry.register_ttf_all(@layout_spec.font_paths, strict: @strict)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def populate_toc(pages, context)
|
|
96
|
+
entries = context&.heading_entries
|
|
97
|
+
return unless entries&.any?
|
|
98
|
+
|
|
99
|
+
toc_rules = @flow_rules['toc'] || {}
|
|
100
|
+
toc_flowables = TocBuilder.build_flowables(context, @layout_spec, rules: toc_rules)
|
|
101
|
+
return if toc_flowables.empty?
|
|
102
|
+
|
|
103
|
+
toc_page_index = pages.index { |page| page.template_role == :preface }
|
|
104
|
+
return unless toc_page_index
|
|
105
|
+
|
|
106
|
+
toc_page = pages[toc_page_index]
|
|
107
|
+
region = toc_page.regions[:body]
|
|
108
|
+
return unless region
|
|
109
|
+
|
|
110
|
+
toc_rules_full = @flow_rules['toc'] || {}
|
|
111
|
+
toc_top_offset = (toc_rules_full['top_offset'] || 40).to_f
|
|
112
|
+
|
|
113
|
+
cursor_y = region.y + region.height - toc_top_offset
|
|
114
|
+
new_boxes = []
|
|
115
|
+
toc_flowables.each do |flowable|
|
|
116
|
+
flowable.height(region.width, context)
|
|
117
|
+
boxes, consumed = flowable.emit(region.x, cursor_y, region.width, context)
|
|
118
|
+
new_boxes.concat(boxes)
|
|
119
|
+
cursor_y -= consumed
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
updated_region = Output::Region.new(
|
|
123
|
+
name: region.name,
|
|
124
|
+
x: region.x,
|
|
125
|
+
y: region.y,
|
|
126
|
+
width: region.width,
|
|
127
|
+
height: region.height,
|
|
128
|
+
placed_boxes: region.placed_boxes + new_boxes
|
|
129
|
+
)
|
|
130
|
+
updated_page = Output::Page.new(
|
|
131
|
+
number: toc_page.number,
|
|
132
|
+
template_name: toc_page.template_name,
|
|
133
|
+
template_role: toc_page.template_role,
|
|
134
|
+
page_size: toc_page.page_size,
|
|
135
|
+
regions: toc_page.regions.merge(body: updated_region),
|
|
136
|
+
header_text: toc_page.header_text,
|
|
137
|
+
footer_text: toc_page.footer_text,
|
|
138
|
+
header_align: toc_page.header_align,
|
|
139
|
+
footer_align: toc_page.footer_align
|
|
140
|
+
)
|
|
141
|
+
pages[toc_page_index] = updated_page
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Arrolio
|
|
4
|
+
module Content
|
|
5
|
+
# A single bibliography entry: citation tag (e.g. "[Smith2020]")
|
|
6
|
+
# and a formatted reference Paragraph. Renders with a
|
|
7
|
+
# hanging-indent so wrapped lines align under the text, not the
|
|
8
|
+
# citation tag.
|
|
9
|
+
class BibliographyItem
|
|
10
|
+
attr_reader :tag, :formattedref, :id, :style_id
|
|
11
|
+
|
|
12
|
+
def initialize(formattedref:, tag: nil, id: nil, style_id: :bibitem)
|
|
13
|
+
@tag = tag
|
|
14
|
+
@formattedref = formattedref
|
|
15
|
+
@id = id&.to_s
|
|
16
|
+
@style_id = style_id.to_sym
|
|
17
|
+
freeze
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def text
|
|
21
|
+
@formattedref.is_a?(Paragraph) ? @formattedref.text : @formattedref.to_s
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def empty?
|
|
25
|
+
@tag.nil? && (@formattedref.nil? || (@formattedref.is_a?(Paragraph) && @formattedref.empty?))
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def ==(other)
|
|
29
|
+
other.is_a?(self.class) &&
|
|
30
|
+
tag == other.tag &&
|
|
31
|
+
formattedref == other.formattedref &&
|
|
32
|
+
id == other.id &&
|
|
33
|
+
style_id == other.style_id
|
|
34
|
+
end
|
|
35
|
+
alias eql? ==
|
|
36
|
+
|
|
37
|
+
def hash
|
|
38
|
+
[self.class, tag, formattedref, id, style_id].hash
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Arrolio
|
|
4
|
+
module Content
|
|
5
|
+
class Builder
|
|
6
|
+
attr_reader :metadata, :sections
|
|
7
|
+
|
|
8
|
+
def self.build(metadata: {}, **opts, &block)
|
|
9
|
+
builder = new(metadata: metadata, **opts)
|
|
10
|
+
block&.call(builder)
|
|
11
|
+
builder.to_document
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def initialize(metadata: {})
|
|
15
|
+
@metadata = metadata
|
|
16
|
+
@sections = []
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def section(title = nil, number: nil, level: 1, id: nil,
|
|
20
|
+
style_id: :section_body, title_style_id: nil, &block)
|
|
21
|
+
sb = SectionBuilder.new(title: title, number: number, level: level, id: id,
|
|
22
|
+
style_id: style_id, title_style_id: title_style_id)
|
|
23
|
+
block&.call(sb)
|
|
24
|
+
@sections << sb.build
|
|
25
|
+
self
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def to_document
|
|
29
|
+
Document.new(metadata: @metadata, sections: @sections)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
class SectionBuilder
|
|
34
|
+
attr_reader :title, :level, :number, :id, :style_id, :title_style_id
|
|
35
|
+
|
|
36
|
+
def initialize(title:, number:, level:, id:, style_id:, title_style_id:)
|
|
37
|
+
@title = title
|
|
38
|
+
@number = number
|
|
39
|
+
@level = level
|
|
40
|
+
@id = id
|
|
41
|
+
@style_id = style_id
|
|
42
|
+
@title_style_id = title_style_id
|
|
43
|
+
@children = []
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def paragraph(text = nil, style_id: :body, id: nil, &block)
|
|
47
|
+
runs = block ? ParagraphBuilder.new.runs(&block) : [InlineRun.new(text.to_s, style_id: :inline)]
|
|
48
|
+
@children << Paragraph.new(runs, style_id: style_id, id: id)
|
|
49
|
+
self
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def list(items, kind: :bullet, style_id: :list, id: nil)
|
|
53
|
+
@children << List.new(items, kind: kind, style_id: style_id, id: id)
|
|
54
|
+
self
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def table(header: [], body: [], column_widths: nil, style_id: :table, id: nil)
|
|
58
|
+
@children << Table.new(header: header, body: body,
|
|
59
|
+
column_widths: column_widths, style_id: style_id, id: id)
|
|
60
|
+
self
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def section(title = nil, **opts, &block)
|
|
64
|
+
opts[:level] ||= @level + 1
|
|
65
|
+
sub = SectionBuilder.new(title: title, number: opts[:number],
|
|
66
|
+
level: opts[:level], id: opts[:id],
|
|
67
|
+
style_id: opts.fetch(:style_id, @style_id),
|
|
68
|
+
title_style_id: opts[:title_style_id])
|
|
69
|
+
block&.call(sub)
|
|
70
|
+
@children << sub.build
|
|
71
|
+
self
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def build
|
|
75
|
+
Section.new(title: @title, level: @level, number: @number, id: @id,
|
|
76
|
+
children: @children, style_id: @style_id, title_style_id: @title_style_id)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
class ParagraphBuilder
|
|
81
|
+
def initialize
|
|
82
|
+
@runs = []
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def text(str, style_id: :inline)
|
|
86
|
+
@runs << InlineRun.new(str.to_s, style_id: style_id)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def strong(str)
|
|
90
|
+
@runs << InlineRun.new(str.to_s, style_id: :strong)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def em(str)
|
|
94
|
+
@runs << InlineRun.new(str.to_s, style_id: :em)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def link(str, href)
|
|
98
|
+
@runs << InlineRun.new(str.to_s, style_id: :link, href: href)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def runs
|
|
102
|
+
return @runs unless block_given?
|
|
103
|
+
yield self
|
|
104
|
+
@runs
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'arrolio/error'
|
|
4
|
+
|
|
5
|
+
module Arrolio
|
|
6
|
+
module Content
|
|
7
|
+
class Document
|
|
8
|
+
attr_reader :metadata, :sections, :preface, :bibliography, :cover,
|
|
9
|
+
:style_id, :footnotes
|
|
10
|
+
|
|
11
|
+
def self.build(metadata: {}, **opts, &block)
|
|
12
|
+
Builder.build(metadata: metadata, **opts, &block)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def initialize(metadata: {}, sections: [], preface: [], bibliography: [],
|
|
16
|
+
cover: nil, style_id: :document, footnotes: [])
|
|
17
|
+
@metadata = metadata.to_h.freeze
|
|
18
|
+
@sections = validate_sections(Array(sections), field: :sections).freeze
|
|
19
|
+
@preface = validate_sections(Array(preface), field: :preface).freeze
|
|
20
|
+
@bibliography = validate_sections(Array(bibliography), field: :bibliography).freeze
|
|
21
|
+
@cover = cover
|
|
22
|
+
@style_id = style_id.to_sym
|
|
23
|
+
@footnotes = Array(footnotes).freeze
|
|
24
|
+
freeze
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def title = @metadata[:title] || @metadata['title']
|
|
28
|
+
def docidentifier = @metadata[:docidentifier] || @metadata['docidentifier']
|
|
29
|
+
def edition = @metadata[:edition] || @metadata['edition']
|
|
30
|
+
|
|
31
|
+
def ==(other)
|
|
32
|
+
other.is_a?(self.class) &&
|
|
33
|
+
metadata == other.metadata && sections == other.sections &&
|
|
34
|
+
preface == other.preface && bibliography == other.bibliography &&
|
|
35
|
+
cover == other.cover && style_id == other.style_id &&
|
|
36
|
+
footnotes == other.footnotes
|
|
37
|
+
end
|
|
38
|
+
alias eql? ==
|
|
39
|
+
|
|
40
|
+
def hash
|
|
41
|
+
[self.class, metadata, sections, preface, bibliography, cover,
|
|
42
|
+
style_id, footnotes].hash
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def validate_sections(array, field:)
|
|
48
|
+
array.each do |item|
|
|
49
|
+
next if item.is_a?(Section)
|
|
50
|
+
|
|
51
|
+
raise ::Arrolio::ContentError.new(
|
|
52
|
+
"#{field} must contain only Content::Section instances; got #{item.class}",
|
|
53
|
+
node: item
|
|
54
|
+
)
|
|
55
|
+
end
|
|
56
|
+
array
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Arrolio
|
|
4
|
+
module Content
|
|
5
|
+
# A semantic example group. Same shape as Note but carries the
|
|
6
|
+
# :example style_id by default so the flow builder can apply the
|
|
7
|
+
# XSL's `example-body-style` margin-left to the body Paragraphs.
|
|
8
|
+
class Example
|
|
9
|
+
attr_reader :label, :body, :id, :style_id
|
|
10
|
+
|
|
11
|
+
def initialize(body:, label: '', id: nil, style_id: :example)
|
|
12
|
+
@label = label.to_s
|
|
13
|
+
@body = Array(body).freeze
|
|
14
|
+
@id = id&.to_s
|
|
15
|
+
@style_id = style_id.to_sym
|
|
16
|
+
freeze
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def body_text
|
|
20
|
+
@body.map do |node|
|
|
21
|
+
node.is_a?(Paragraph) ? node.text : node.to_s
|
|
22
|
+
end.join(' ')
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def empty?
|
|
26
|
+
@body.empty? || @body.all? { |node| node.is_a?(Paragraph) && node.empty? }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def ==(other)
|
|
30
|
+
other.is_a?(self.class) &&
|
|
31
|
+
label == other.label &&
|
|
32
|
+
body == other.body &&
|
|
33
|
+
id == other.id &&
|
|
34
|
+
style_id == other.style_id
|
|
35
|
+
end
|
|
36
|
+
alias eql? ==
|
|
37
|
+
|
|
38
|
+
def hash
|
|
39
|
+
[self.class, label, body, id, style_id].hash
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Arrolio
|
|
4
|
+
module Content
|
|
5
|
+
# A figure group: optional Image plus optional caption Paragraph.
|
|
6
|
+
# Replaces the previous flat [Image, Paragraph] emission from
|
|
7
|
+
# convert_figure. The flow builder renders the group as a
|
|
8
|
+
# keep-together unit so the caption never lands on a different
|
|
9
|
+
# page from the image.
|
|
10
|
+
class FigureGroup
|
|
11
|
+
attr_reader :image, :caption, :id, :style_id
|
|
12
|
+
|
|
13
|
+
def initialize(image: nil, caption: nil, id: nil, style_id: :figure)
|
|
14
|
+
@image = image
|
|
15
|
+
@caption = caption
|
|
16
|
+
@id = id&.to_s
|
|
17
|
+
@style_id = style_id.to_sym
|
|
18
|
+
freeze
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def empty?
|
|
22
|
+
@image.nil? && (@caption.nil? || (@caption.is_a?(Paragraph) && @caption.empty?))
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def ==(other)
|
|
26
|
+
other.is_a?(self.class) &&
|
|
27
|
+
image == other.image &&
|
|
28
|
+
caption == other.caption &&
|
|
29
|
+
id == other.id &&
|
|
30
|
+
style_id == other.style_id
|
|
31
|
+
end
|
|
32
|
+
alias eql? ==
|
|
33
|
+
|
|
34
|
+
def hash
|
|
35
|
+
[self.class, image, caption, id, style_id].hash
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|