arrolio 0.1.1 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ab501984c833a8804a70cc43a2071447d90df26cf8acfa7157fde128cb448142
4
- data.tar.gz: c00ded55fa2e3475266ba5a536d31304291a17c2acda7ef4521a60270033587f
3
+ metadata.gz: e9d301e46536064070bccbf04046e06cdb94b4eab0aba48480c1d8206ab067e9
4
+ data.tar.gz: f6f3d63a924dbba3cb2af4d0b818e9b26516a1e7feb315e9c9730cc79d680e7f
5
5
  SHA512:
6
- metadata.gz: 05fef4fce876ea60edb85810aa0f622bdd88c57a88bc1f8f60be653313b3e2ad57c724e87993eb61181c036d823e91f56076812aa43c40b6a100dcd396ab47aa
7
- data.tar.gz: 52e00d5f7c32b2535dd5e730612a9830b330becb50d7259a91298ebbdcbc32e3cf7b877c860763c3042a6dee850cd756ff15f927a62e8bef14edc24cd30dd3d2
6
+ metadata.gz: d4e6680ffacf7bbbd4712464d491d2a25113af8042ab21dae643b96b1c7c24d50b57f0133a782f29516baf5040fd3ee7af5e6a4206695b5f74e301145708c6d8
7
+ data.tar.gz: 481a67ecf12a9875149753453985ce2ab458717b7a05097581fc30723ef831ac3b17e113915611c50ba4a95672c29c47829eee39ec9bf5b798a408b1c3ef8cd5
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Arrolio
4
+ class GenericFlowBuilder
5
+ # Extracted emission seam: one module per content family, the
6
+ # same pattern as GenericAdapter's decomposition. The class
7
+ # keeps build(), dispatch, and shared helpers.
8
+ module Bibliography
9
+ def bibliography_item_flowable(item, out)
10
+ tag = item.tag.to_s
11
+ body_para = bibliography_body_paragraph(item)
12
+ if tag.empty?
13
+ out << paragraph_flowable(body_para)
14
+ return
15
+ end
16
+
17
+ body = paragraph_flowable(body_para)
18
+ out << Flowables::NoteFlowable.new(
19
+ tag,
20
+ [body],
21
+ style: resolve(item.style_id || :bibitem),
22
+ label_style: resolve(:bibitem_marker),
23
+ marker_width: 24.0
24
+ )
25
+ end
26
+
27
+ def bibliography_body_paragraph(item)
28
+ runs = []
29
+ if item.formattedref
30
+ runs.concat(item.formattedref.inline_runs.map do |r|
31
+ Content::InlineRun.new(r.text, style_id: r.style_id)
32
+ end)
33
+ end
34
+ Content::Paragraph.new(runs, style_id: item.style_id || :bibitem,
35
+ id: item.id)
36
+ end
37
+
38
+ def marker_width_of(tag)
39
+ return 0.0 if tag.to_s.empty?
40
+
41
+ style = resolve(:bibitem_marker)
42
+ GlyphMeasurer.new(font_name: style.font_name)
43
+ .width_of_string("#{tag} ", font_size: style.font_size)
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Arrolio
4
+ class GenericFlowBuilder
5
+ # Extracted emission seam: one module per content family, the
6
+ # same pattern as GenericAdapter's decomposition. The class
7
+ # keeps build(), dispatch, and shared helpers.
8
+ module Figures
9
+ INLINE_SVG_PREFIX = 'inline-svg:'
10
+ # Figures are atomic: image + caption render as ONE flowable
11
+ # so the caption can never be orphaned onto the next page.
12
+ # Caption gap is flavor geometry (ref: image bottom to caption
13
+ # ~28pt; figure blocks separated by ~24pt).
14
+ def figure_group_flowable(group, out)
15
+ figure_config = @rules['figure'] || {}
16
+ caption_gap = figure_config.fetch('caption_gap', 0.0).to_f
17
+ block_gap = figure_config.fetch('block_gap', 0.0).to_f
18
+
19
+ image = image_flowable(group.image) if group.image
20
+ if group.caption
21
+ caption_style = resolve(:figure_caption).with(margin_top: caption_gap)
22
+ runs = group.caption.inline_runs.map do |run|
23
+ InlineRun.new(run.text, style: caption_style)
24
+ end
25
+ caption = Flowables::TextFlowable.new(runs, style: caption_style)
26
+ end
27
+ return out << caption if image.nil?
28
+
29
+ image = with_block_gap(image, block_gap)
30
+ out << if caption
31
+ Flowables::FigureFlowable.new(image, caption)
32
+ else
33
+ image
34
+ end
35
+ end
36
+
37
+ def with_block_gap(flowable, gap)
38
+ return flowable if gap.zero?
39
+
40
+ style = flowable.style.with(margin_top: flowable.style.margin_top + gap)
41
+ flowable.class.new(flowable.src,
42
+ natural_width: flowable.natural_width,
43
+ natural_height: flowable.natural_height,
44
+ display_width: flowable.display_width,
45
+ alt: flowable.alt,
46
+ style: style)
47
+ end
48
+
49
+ def image_flowable(image)
50
+ source = resolve_image_source(image)
51
+ image_rules = @rules['image'] || {}
52
+ default_width = (image_rules['default_natural_width'] || 400).to_f
53
+ default_height = (image_rules['default_natural_height'] || 300).to_f
54
+ max_width = (image_rules['max_display_width'] || 106).to_f
55
+ natural_width = image.width || svg_dimension(source, 'width') || default_width
56
+ natural_height = image.height || svg_dimension(source, 'height') || default_height
57
+ display_width = [image.width || natural_width, max_width].min
58
+ Flowables::ImageFlowable.new(
59
+ source,
60
+ natural_width: natural_width,
61
+ natural_height: natural_height,
62
+ display_width: display_width,
63
+ alt: image.alt,
64
+ style: resolve(image.style_id)
65
+ )
66
+ end
67
+
68
+ def resolve_image_source(image)
69
+ return asset_resolver.resolve(image.src) unless image.src.is_a?(String)
70
+ return write_inline_svg(image.src) if image.src.start_with?(INLINE_SVG_PREFIX)
71
+
72
+ asset_resolver.resolve(image.src)
73
+ end
74
+
75
+ def write_inline_svg(prefixed)
76
+ svg_xml = prefixed[INLINE_SVG_PREFIX.length..]
77
+ path = File.join(Dir.mktmpdir('arrolio-svg'), 'figure.svg')
78
+ File.write(path, svg_xml)
79
+ path
80
+ end
81
+
82
+ def svg_dimension(source, name)
83
+ return nil unless source && File.exist?(source) && source.match?(/\.svg\z/i)
84
+
85
+ value = File.read(source)[/(?:#{name})=["']([\d.]+)/, 1]
86
+ value && (value.to_f * SVG_PX_TO_PT)
87
+ end
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Arrolio
4
+ class GenericFlowBuilder
5
+ # Extracted emission seam: one module per content family, the
6
+ # same pattern as GenericAdapter's decomposition. The class
7
+ # keeps build(), dispatch, and shared helpers.
8
+ module Lists
9
+ def list_flowable(list, container: nil)
10
+ items = list.items.each_with_index.map do |item, index|
11
+ marker = item.marker.nil? ? default_marker(list, index, container: container) : item.marker
12
+ body = item.content.flat_map do |content|
13
+ flowable_for_list_content(content)
14
+ end
15
+ [marker, body]
16
+ end
17
+ Flowables::ListFlowable.new(items, kind: list.kind,
18
+ style: resolve(list.style_id),
19
+ **list_geometry(list.kind, container: container))
20
+ end
21
+
22
+ # List marker geometry (indent, marker column, inter-item
23
+ # spacing) is flavor configuration extracted from the flavor's
24
+ # XSL list styles — not engine policy.
25
+ # A kind-specific map (e.g. geometry.ordered) overrides the
26
+ # shared defaults — flavors whose bullets indent but whose
27
+ # ordered markers start at the margin (which also restores
28
+ # nested lists' depth).
29
+ def list_geometry(kind, container: nil)
30
+ config = @rules.dig('list', 'geometry') || {}
31
+ config = config.merge(config[kind.to_s] || {})
32
+ # Lists nested in note bodies sit deeper: the reference puts
33
+ # 3.1.3.1's note bullets at +43.7pt from the note column
34
+ # (marker x=143 from the page), pitch 17pt.
35
+ config = config.merge(@rules.dig('note', 'list') || {}) if container == :note
36
+ {
37
+ marker_indent: config.fetch('marker_indent', 0.0).to_f,
38
+ marker_width: config.fetch('marker_width', 18.0).to_f,
39
+ body_indent: config.fetch('body_indent', 6.0).to_f,
40
+ item_spacing: config.fetch('item_spacing', 0.0).to_f
41
+ }
42
+ end
43
+
44
+ def flowable_for_list_content(content)
45
+ case content
46
+ when Content::Paragraph then [paragraph_flowable(content)]
47
+ when Content::List then [list_flowable(content)]
48
+ else []
49
+ end
50
+ end
51
+
52
+ def default_marker(list, index, container: nil)
53
+ defaults = @rules.dig('list', 'defaults') || {}
54
+ if list.ordered?
55
+ format = defaults['ordered_marker'] || '%d.'
56
+ format.sub('%d', (index + 1).to_s)
57
+ else
58
+ nested = @rules.dig('note', 'list') || {}
59
+ return nested['bullet_marker'] if container == :note && nested['bullet_marker']
60
+
61
+ defaults['bullet_marker'] || '■ '
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Arrolio
4
+ class GenericFlowBuilder
5
+ # Extracted emission seam: one module per content family, the
6
+ # same pattern as GenericAdapter's decomposition. The class
7
+ # keeps build(), dispatch, and shared helpers.
8
+ module Notes
9
+ def note_flowable(note)
10
+ body = note.body.map { |node| note_body_flowable(node) }
11
+ body = with_note_body_spacing(body)
12
+ Flowables::NoteFlowable.new(
13
+ formatted_note_label(note.label),
14
+ body,
15
+ style: resolve(note.style_id),
16
+ label_style: resolve(:note_label)
17
+ )
18
+ end
19
+
20
+ # A note's trailing list sits ~7pt below its text in the
21
+ # reference (3.1.3.1: text -> list 20pt vs a plain 13pt pitch).
22
+ def with_note_body_spacing(body)
23
+ spacing = (@rules.dig('note', 'body_spacing') || 0.0).to_f
24
+ return body if spacing.zero? || body.length < 2
25
+
26
+ body.flat_map.with_index do |flowable, index|
27
+ index < body.length - 1 ? [flowable, Flowables::Spacer.new(spacing)] : [flowable]
28
+ end
29
+ end
30
+
31
+ def note_body_flowable(node)
32
+ return list_flowable(node, container: :note) if node.is_a?(Content::List)
33
+
34
+ paragraph_flowable(node)
35
+ end
36
+
37
+ def formatted_note_label(label)
38
+ return '' if label.nil? || label.empty?
39
+
40
+ suffix = @rules.dig('note', 'label_suffix') || ':'
41
+ stripped = label.strip.chomp(':').strip
42
+ return '' if stripped.empty?
43
+
44
+ suffix.start_with?(':') ? "#{stripped}#{suffix} " : "#{stripped} #{suffix} "
45
+ end
46
+
47
+ # Examples render the label as a block heading with the body
48
+ # indented 35.4pt — the FOP example layout.
49
+ def example_flowable(example)
50
+ body = example.body.map { |paragraph| paragraph_flowable(paragraph) }
51
+ Flowables::NoteFlowable.new(
52
+ example.label,
53
+ body,
54
+ style: resolve(example.style_id),
55
+ body_indent: 35.4,
56
+ label_mode: :block
57
+ )
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Arrolio
4
+ class GenericFlowBuilder
5
+ # Extracted emission seam: one module per content family, the
6
+ # same pattern as GenericAdapter's decomposition. The class
7
+ # keeps build(), dispatch, and shared helpers.
8
+ module Sequences
9
+ def page_sequences
10
+ Array(@rules['page_sequences'])
11
+ end
12
+
13
+ def sequence_start(document, sequence)
14
+ title = sequence['role'].to_s == 'body' ? title_text(document) : nil
15
+ Flowables::PageSequenceStart.new(
16
+ role: sequence.fetch('role'),
17
+ header_template: interpolate(sequence['header'], document),
18
+ footer_template: sequence['footer'],
19
+ header_align: sequence['header_align'],
20
+ footer_align: sequence['footer_align'] || :center,
21
+ initial_page_number: sequence['initial_page_number'],
22
+ title_template: title
23
+ )
24
+ end
25
+
26
+ def build_sequence_content(document, source, sequence, out)
27
+ if sequence['build_content'] == 'cover_content'
28
+ build_cover_content(document, out)
29
+ elsif source.is_a?(Array)
30
+ build_sections(source, out,
31
+ between_breaks: preface_clause_breaks?(sequence))
32
+ end
33
+ end
34
+
35
+ # mn2pdf gives each preface clause its own page sequence (ToC
36
+ # page, then Foreword on a fresh page) when the flavor opts in.
37
+ def preface_clause_breaks?(sequence)
38
+ sequence['role'].to_s == 'preface' &&
39
+ @rules.dig('preface', 'page_break_between_clauses')
40
+ end
41
+
42
+ # The first body page opens with the part title ("Part 1 -
43
+ # {part title}") - the docidentifier's trailing part number
44
+ # supplies the prefix.
45
+ def title_text(document)
46
+ return nil unless document.title_block
47
+
48
+ text = document.title_block.inline_runs.map(&:text).join.strip
49
+ return nil if text.empty?
50
+
51
+ part = document.metadata[:docidentifier].to_s[/-(\d+)\z/, 1]
52
+ part ? "Part #{part} - #{text}" : text
53
+ end
54
+
55
+ def content_for(document, role)
56
+ case role.to_s
57
+ when 'preface' then document.preface
58
+ when 'body' then document.sections
59
+ when 'bibliography' then document.bibliography
60
+ else []
61
+ end
62
+ end
63
+
64
+ def build_cover_content(document, out)
65
+ Array(@rules['cover_content']).each do |entry|
66
+ next unless condition_matches?(entry['when'], document)
67
+
68
+ case entry['type'].to_s
69
+ when 'spacer'
70
+ out << Flowables::Spacer.new(entry.fetch('size').to_f)
71
+ when 'text'
72
+ style = resolve(entry.fetch('style'))
73
+ style = style.with(align: :center) if entry.fetch('align', 'center').to_sym == :center
74
+ text = interpolate(entry.fetch('source'), document)
75
+ out << Flowables::TextFlowable.new([InlineRun.new(text, style: style)], style: style)
76
+ end
77
+ end
78
+ end
79
+
80
+ def append_endnotes(document, flowables)
81
+ return if document.footnotes.empty?
82
+ return unless @rules['endnotes']
83
+
84
+ flowables << Flowables::PageSequenceStart.new(
85
+ role: :endnotes,
86
+ header_template: nil,
87
+ footer_template: nil
88
+ )
89
+ document.footnotes.each do |footnote|
90
+ marker_text = footnote.marker.to_s
91
+ body = footnote.body.map { |paragraph| paragraph_flowable(paragraph) }
92
+ flowables << Flowables::NoteFlowable.new(
93
+ marker_text,
94
+ body,
95
+ style: resolve(footnote.style_id),
96
+ marker_width: 22.0
97
+ )
98
+ end
99
+ end
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Arrolio
4
+ class GenericFlowBuilder
5
+ # Extracted emission seam: one module per content family, the
6
+ # same pattern as GenericAdapter's decomposition. The class
7
+ # keeps build(), dispatch, and shared helpers.
8
+ module Tables
9
+ def caption_runs_for(table)
10
+ return nil if table.caption.nil?
11
+
12
+ paragraph_flowable(table.caption, standalone: false).runs
13
+ end
14
+
15
+ # Table geometry (minimum row height, cell padding, footnote
16
+ # size) is flavor configuration — extracted from the flavor's
17
+ # XSL row/cell styles — not engine policy.
18
+ def table_geometry
19
+ config = @rules['table'] || {}
20
+ {
21
+ min_row_height: config.fetch('min_row_height', 0.0).to_f,
22
+ cell_padding: config.fetch('cell_padding', 2.0).to_f,
23
+ footnote_font_size: config['footnote_font_size']&.to_f
24
+ }
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Arrolio
4
+ class GenericFlowBuilder
5
+ # Extracted emission seam: one module per content family, the
6
+ # same pattern as GenericAdapter's decomposition. The class
7
+ # keeps build(), dispatch, and shared helpers.
8
+ module Terms
9
+ def term_config
10
+ @rules['term'] || {}
11
+ end
12
+
13
+ def term_entry_flowable(entry, out)
14
+ if entry.number
15
+ number_style = resolve(:term).with(margin_top: 12.0, margin_bottom: 0.0)
16
+ out << Flowables::TextFlowable.new(
17
+ [InlineRun.new(entry.number, style: number_style)],
18
+ style: number_style
19
+ )
20
+ end
21
+ if entry.preferred
22
+ preferred_style = resolve(:term).with(margin_top: 0.0, margin_bottom: 6.0)
23
+ out << Flowables::TextFlowable.new(
24
+ entry.preferred.inline_runs.map do |run|
25
+ InlineRun.new(run.text, style: resolve(run.style_id),
26
+ baseline_shift: run.baseline_shift,
27
+ font_size_scale: run.font_size_scale,
28
+ href: run.href)
29
+ end,
30
+ style: preferred_style
31
+ )
32
+ end
33
+ # FOP applies space-before BETWEEN sibling blocks (not before
34
+ # the first) — term definitions' second+ paragraphs (the
35
+ # "(For notes...)" line) and the SOURCE line each carry it.
36
+ entry.definition.each_with_index do |item, index|
37
+ append_child(item, out, standalone: false, in_term: true)
38
+ if index.zero?
39
+ widen_first_definition_widows(out)
40
+ else
41
+ apply_sibling_spacing(item, out)
42
+ end
43
+ end
44
+ return unless entry.source
45
+
46
+ source_style = resolve(entry.source.style_id)
47
+ .with(margin_top: term_config.fetch('source_space_before', 2.0).to_f,
48
+ margin_bottom: 2.0)
49
+ source_runs = entry.source.inline_runs.map do |run|
50
+ InlineRun.new(run.text, style: resolve(run.style_id),
51
+ baseline_shift: run.baseline_shift,
52
+ font_size_scale: run.font_size_scale,
53
+ href: run.href)
54
+ end
55
+ out << Flowables::TextFlowable.new(source_runs, style: source_style)
56
+ end
57
+
58
+ # An entry's head must keep number + preferred + two
59
+ # definition lines together (the reference moves whole entry
60
+ # heads when those don't fit, leaving its characteristic
61
+ # 50-90pt terms-region page-end gaps).
62
+ def widen_first_definition_widows(out)
63
+ widows = term_config.fetch('definition_widows', 1).to_i
64
+ last = out.last
65
+ return unless widows > 1 && last.is_a?(Flowables::TextFlowable)
66
+
67
+ out[-1] = Flowables::TextFlowable.new(
68
+ last.runs,
69
+ style: last.style.with(widows: widows),
70
+ measurer: last.measurer
71
+ )
72
+ end
73
+
74
+ # Re-emits the last paragraph flowable with the configured
75
+ # between-sibling space (XSL-FO space-before semantics).
76
+ def apply_sibling_spacing(_item, out)
77
+ spacing = term_config.fetch('definition_paragraph_space_before', 0.0).to_f
78
+ return if spacing.zero? || out.empty?
79
+
80
+ last = out.last
81
+ return unless last.is_a?(Flowables::TextFlowable)
82
+
83
+ out[-1] = Flowables::TextFlowable.new(last.runs,
84
+ style: last.style.with(margin_top: spacing),
85
+ measurer: last.measurer)
86
+ end
87
+ end
88
+ end
89
+ end