arrolio 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +2 -2
  3. data/Rakefile +15 -0
  4. data/TODO.complete/56-generic-adapter-mece-decomposition.md +20 -0
  5. data/TODO.complete/60-adapter-fidelity-footnotes-notes-examples.md +92 -55
  6. data/TODO.complete/70-ttf-font-metrics-parity.md +68 -0
  7. data/TODO.complete/71-cover-layout-parity.md +45 -0
  8. data/TODO.complete/72-table-layout-parity.md +49 -0
  9. data/TODO.complete/73-toc-leaders-parity.md +55 -0
  10. data/TODO.complete/74-svg-image-rendering-parity.md +76 -0
  11. data/TODO.complete/75-mathml-formula-rendering-parity.md +52 -0
  12. data/TODO.complete/76-odd-even-page-templates-parity.md +50 -0
  13. data/TODO.complete/77-text-formatting-parity.md +55 -0
  14. data/TODO.complete/78-bibliography-formatting-parity.md +62 -0
  15. data/TODO.complete/79-line-breaking-parity.md +80 -0
  16. data/TODO.complete/80-parity-diff-harness-in-ci.md +68 -0
  17. data/TODO.complete/81-conditional-style-refinements.md +70 -0
  18. data/TODO.complete/82-semantic-presentation-dedup.md +65 -0
  19. data/TODO.complete/83-nested-list-support.md +74 -0
  20. data/TODO.complete/84-figure-svg-rendering.md +70 -0
  21. data/TODO.complete/85-locality-reference-formatting.md +48 -0
  22. data/TODO.complete/86-list-item-text-overflow.md +80 -0
  23. data/TODO.complete/87-odd-even-page-templates.md +55 -0
  24. data/TODO.complete/88-hyphenation-support.md +52 -0
  25. data/TODO.complete/89-table-rowspan.md +49 -0
  26. data/TODO.complete/90-mathml-stacked-layout.md +65 -0
  27. data/TODO.complete/91-parity-diff-diagnostics.md +50 -0
  28. data/TODO.complete/92-table-cell-height-parity.md +66 -0
  29. data/TODO.complete/93-figure-text-searchability.md +49 -0
  30. data/TODO.complete/94-fo-space-resolution.md +63 -0
  31. data/TODO.complete/95-title-header-placement.md +55 -0
  32. data/TODO.complete/96-pagination-drift-parity.md +517 -0
  33. data/TODO.complete/README.md +58 -38
  34. data/lib/arrolio/config_driven_pipeline.rb +45 -1
  35. data/lib/arrolio/content/document.rb +4 -3
  36. data/lib/arrolio/content/section.rb +3 -0
  37. data/lib/arrolio/content/table.rb +25 -14
  38. data/lib/arrolio/embedded_block_extractor.rb +54 -0
  39. data/lib/arrolio/engine/paged.rb +109 -28
  40. data/lib/arrolio/flavor/manifest.rb +0 -1
  41. data/lib/arrolio/flavor/registry.rb +1 -1
  42. data/lib/arrolio/flowable.rb +21 -0
  43. data/lib/arrolio/flowables/figure_flowable.rb +17 -0
  44. data/lib/arrolio/flowables/footnote_marker_flowable.rb +32 -13
  45. data/lib/arrolio/flowables/group_flowable.rb +57 -0
  46. data/lib/arrolio/flowables/list_flowable.rb +46 -18
  47. data/lib/arrolio/flowables/note_flowable.rb +84 -9
  48. data/lib/arrolio/flowables/page_sequence_start.rb +8 -4
  49. data/lib/arrolio/flowables/positioned_block.rb +55 -0
  50. data/lib/arrolio/flowables/rotated_text.rb +51 -0
  51. data/lib/arrolio/flowables/table_flowable.rb +303 -71
  52. data/lib/arrolio/flowables/text_flowable.rb +67 -11
  53. data/lib/arrolio/flowables/toc_line_flowable.rb +70 -7
  54. data/lib/arrolio/flowables.rb +4 -0
  55. data/lib/arrolio/font/embedder.rb +22 -3
  56. data/lib/arrolio/font_scanner.rb +133 -0
  57. data/lib/arrolio/generic_adapter/document_extraction.rb +101 -0
  58. data/lib/arrolio/generic_adapter/footnote_extraction.rb +81 -0
  59. data/lib/arrolio/generic_adapter/heading_extraction.rb +92 -0
  60. data/lib/arrolio/generic_adapter/inline_run_collection.rb +136 -0
  61. data/lib/arrolio/generic_adapter/list_conversion.rb +70 -0
  62. data/lib/arrolio/generic_adapter/metadata_extraction.rb +46 -0
  63. data/lib/arrolio/generic_adapter/table_conversion.rb +105 -0
  64. data/lib/arrolio/generic_adapter.rb +263 -317
  65. data/lib/arrolio/generic_flow_builder/bibliography.rb +47 -0
  66. data/lib/arrolio/generic_flow_builder/figures.rb +90 -0
  67. data/lib/arrolio/generic_flow_builder/lists.rb +66 -0
  68. data/lib/arrolio/generic_flow_builder/notes.rb +61 -0
  69. data/lib/arrolio/generic_flow_builder/sequences.rb +102 -0
  70. data/lib/arrolio/generic_flow_builder/tables.rb +28 -0
  71. data/lib/arrolio/generic_flow_builder/terms.rb +89 -0
  72. data/lib/arrolio/generic_flow_builder.rb +92 -187
  73. data/lib/arrolio/glyph_measurer.rb +16 -5
  74. data/lib/arrolio/inline_run.rb +2 -1
  75. data/lib/arrolio/layout_spec/page_template.rb +14 -8
  76. data/lib/arrolio/math_ml/inline_run_extractor.rb +272 -0
  77. data/lib/arrolio/math_ml.rb +7 -0
  78. data/lib/arrolio/output/page.rb +5 -4
  79. data/lib/arrolio/output/placed_box.rb +5 -2
  80. data/lib/arrolio/renderer/pdf/assets.rb +94 -0
  81. data/lib/arrolio/renderer/pdf/font_embedding.rb +77 -0
  82. data/lib/arrolio/renderer/pdf/metadata.rb +52 -0
  83. data/lib/arrolio/renderer/pdf.rb +140 -215
  84. data/lib/arrolio/style/definition.rb +7 -3
  85. data/lib/arrolio/style/registry.rb +6 -1
  86. data/lib/arrolio/table/auto_layout.rb +19 -10
  87. data/lib/arrolio/table/grid.rb +105 -0
  88. data/lib/arrolio/table.rb +1 -0
  89. data/lib/arrolio/text_layout/greedy.rb +15 -1
  90. data/lib/arrolio/text_layout/knuth_plass/breaker.rb +216 -92
  91. data/lib/arrolio/text_layout/knuth_plass/item_builder.rb +31 -17
  92. data/lib/arrolio/text_layout/knuth_plass.rb +3 -0
  93. data/lib/arrolio/text_layout/line.rb +10 -2
  94. data/lib/arrolio/version.rb +1 -1
  95. data/lib/arrolio.rb +8 -4
  96. data/scripts/parity_check.rb +52 -0
  97. data/scripts/parity_diff.rb +89 -0
  98. data/scripts/xsl_profiles/standoc.yml +8 -2
  99. data/scripts/xsl_to_config.rb +28 -1
  100. metadata +91 -5
@@ -55,7 +55,7 @@ module Arrolio
55
55
  io: io,
56
56
  logo_path: @logo_path,
57
57
  metadata: document_metadata,
58
- font_paths: @layout_spec.font_paths,
58
+ font_paths: resolved_font_paths_for_renderer,
59
59
  context: engine.context,
60
60
  layout_spec: @layout_spec
61
61
  )
@@ -90,6 +90,50 @@ module Arrolio
90
90
 
91
91
  def register_fonts
92
92
  FontMetrics::Registry.register_ttf_all(@layout_spec.font_paths, strict: @strict)
93
+ resolve_fonts_via_fontist
94
+ end
95
+
96
+ def resolved_font_paths_for_renderer
97
+ merged = @layout_spec.font_paths.dup
98
+ scanner = ::Arrolio::FontScanner.new
99
+ collect_referenced_font_names.each do |name|
100
+ next if merged.key?(name)
101
+
102
+ path = scanner.resolve(name)
103
+ merged[name] = path if path
104
+ end
105
+ merged
106
+ end
107
+
108
+ def resolve_fonts_via_fontist
109
+ scanner = ::Arrolio::FontScanner.new
110
+ referenced = collect_referenced_font_names
111
+ referenced.each do |name|
112
+ next if FontMetrics::Registry.ttf_paths.key?(name)
113
+
114
+ path = scanner.resolve(name)
115
+ FontMetrics::Registry.register_ttf(name, path) if path
116
+ end
117
+ end
118
+
119
+ def collect_referenced_font_names
120
+ names = []
121
+ @layout_spec.styles.names.each do |style_name|
122
+ style = @layout_spec.resolve_style(style_name)
123
+ names << style.font_name if style.font_name && !style.font_name.empty?
124
+ end
125
+ names.uniq
126
+ end
127
+
128
+ def find_font_path(font_name)
129
+ result = Fontist::Font.find(font_name)
130
+ return nil unless result
131
+
132
+ font = result.is_a?(Array) ? result.first : result
133
+ path = font.is_a?(String) ? font : font.path
134
+ path && File.exist?(path) ? path : nil
135
+ rescue StandardError
136
+ nil
93
137
  end
94
138
 
95
139
  def populate_toc(pages, context)
@@ -1,19 +1,19 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'arrolio/error'
4
3
 
5
4
  module Arrolio
6
5
  module Content
7
6
  class Document
8
7
  attr_reader :metadata, :sections, :preface, :bibliography, :cover,
9
- :style_id, :footnotes
8
+ :style_id, :footnotes, :title_block
10
9
 
11
10
  def self.build(metadata: {}, **opts, &block)
12
11
  Builder.build(metadata: metadata, **opts, &block)
13
12
  end
14
13
 
15
14
  def initialize(metadata: {}, sections: [], preface: [], bibliography: [],
16
- cover: nil, style_id: :document, footnotes: [])
15
+ cover: nil, style_id: :document, footnotes: [],
16
+ title_block: nil)
17
17
  @metadata = metadata.to_h.freeze
18
18
  @sections = validate_sections(Array(sections), field: :sections).freeze
19
19
  @preface = validate_sections(Array(preface), field: :preface).freeze
@@ -21,6 +21,7 @@ module Arrolio
21
21
  @cover = cover
22
22
  @style_id = style_id.to_sym
23
23
  @footnotes = Array(footnotes).freeze
24
+ @title_block = title_block
24
25
  freeze
25
26
  end
26
27
 
@@ -17,6 +17,9 @@ module Arrolio
17
17
  freeze
18
18
  end
19
19
 
20
+ # A title-less numbered clause is an inline-header clause
21
+ # (standoc inline-header): the number prefixes the first
22
+ # paragraph, it never gets its own heading line.
20
23
  def heading? = !@title.nil?
21
24
 
22
25
  def ==(other)
@@ -3,15 +3,18 @@
3
3
  module Arrolio
4
4
  module Content
5
5
  class Table
6
- attr_reader :header, :body, :column_widths, :style_id, :id
6
+ attr_reader :header, :body, :column_widths, :style_id, :id, :caption,
7
+ :footnotes
7
8
 
8
9
  def initialize(header: [], body: [], column_widths: nil,
9
- style_id: :table, id: nil)
10
+ style_id: :table, id: nil, caption: nil, footnotes: [])
10
11
  @header = header.map { |row| coerce_row(row) }.freeze
11
12
  @body = body.map { |row| coerce_row(row) }.freeze
12
13
  @column_widths = column_widths&.map(&:to_f)&.freeze
13
14
  @style_id = style_id.to_sym
14
15
  @id = id
16
+ @caption = caption
17
+ @footnotes = Array(footnotes).freeze
15
18
  freeze
16
19
  end
17
20
 
@@ -19,8 +22,11 @@ module Arrolio
19
22
  @header + @body
20
23
  end
21
24
 
25
+ # Colspan-aware column count. Rowspan can still hide columns
26
+ # (a row covered from above has fewer cells); Table::Grid is
27
+ # the authoritative count for layout.
22
28
  def column_count
23
- rows.map { |r| r.cells.length }.max || 0
29
+ rows.map { |r| r.cells.sum(&:colspan) }.max || 0
24
30
  end
25
31
 
26
32
  def ==(other)
@@ -29,13 +35,14 @@ module Arrolio
29
35
  body == other.body &&
30
36
  column_widths == other.column_widths &&
31
37
  style_id == other.style_id &&
32
- id == other.id
38
+ id == other.id &&
39
+ footnotes == other.footnotes
33
40
  end
34
41
 
35
42
  alias eql? ==
36
43
 
37
44
  def hash
38
- [self.class, header, body, column_widths, style_id, id].hash
45
+ [self.class, header, body, column_widths, style_id, id, footnotes].hash
39
46
  end
40
47
 
41
48
  private
@@ -53,10 +60,11 @@ module Arrolio
53
60
  class Table::Row
54
61
  include Enumerable
55
62
 
56
- attr_reader :cells
63
+ attr_reader :cells, :min_height
57
64
 
58
- def initialize(cells)
65
+ def initialize(cells, min_height: 0.0)
59
66
  @cells = Array(cells).freeze
67
+ @min_height = min_height.to_f.freeze
60
68
  freeze
61
69
  end
62
70
 
@@ -69,26 +77,28 @@ module Arrolio
69
77
  end
70
78
 
71
79
  def ==(other)
72
- other.is_a?(self.class) && cells == other.cells
80
+ other.is_a?(self.class) && cells == other.cells &&
81
+ min_height == other.min_height
73
82
  end
74
83
 
75
84
  alias eql? ==
76
85
 
77
86
  def hash
78
- [self.class, cells].hash
87
+ [self.class, cells, min_height].hash
79
88
  end
80
89
  end
81
90
 
82
91
  class Table::Cell
83
- attr_reader :content, :colspan, :rowspan, :style_id, :align
92
+ attr_reader :content, :colspan, :rowspan, :style_id, :align, :valign
84
93
 
85
94
  def initialize(content = [], colspan: 1, rowspan: 1,
86
- style_id: :table_cell, align: nil)
95
+ style_id: :table_cell, align: nil, valign: nil)
87
96
  @content = Array(content).freeze
88
97
  @colspan = colspan.to_i
89
98
  @rowspan = rowspan.to_i
90
99
  @style_id = style_id.to_sym
91
- @align = align
100
+ @align = align&.to_sym
101
+ @valign = valign&.to_sym
92
102
  freeze
93
103
  end
94
104
 
@@ -104,13 +114,14 @@ module Arrolio
104
114
  colspan == other.colspan &&
105
115
  rowspan == other.rowspan &&
106
116
  style_id == other.style_id &&
107
- align == other.align
117
+ align == other.align &&
118
+ valign == other.valign
108
119
  end
109
120
 
110
121
  alias eql? ==
111
122
 
112
123
  def hash
113
- [self.class, content, colspan, rowspan, style_id, align].hash
124
+ [self.class, content, colspan, rowspan, style_id, align, valign].hash
114
125
  end
115
126
  end
116
127
  end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Arrolio
4
+ # Extracts block-level elements (notes, examples) that are nested
5
+ # inside paragraph elements. In standoc presentation XML, notes can
6
+ # appear as children of <p>, not just as direct children of <clause>.
7
+ # Without extraction, these embedded blocks are silently dropped
8
+ # during inline run collection.
9
+ class EmbeddedBlockExtractor
10
+ SECTION_NAMES = ['clause', 'terms', 'definitions', 'annex', 'annex2'].freeze
11
+
12
+ def initialize(rules, converter_registry)
13
+ @rules = rules
14
+ @converter_registry = converter_registry
15
+ end
16
+
17
+ def extract(elem)
18
+ blocks = []
19
+ block_names = embedded_block_names
20
+ return blocks if block_names.empty?
21
+
22
+ elem.each_recursive do |descendant|
23
+ next unless block_names.include?(descendant.name)
24
+ next if inside_section?(descendant, elem)
25
+
26
+ mapping_entry = @rules['element_mapping'][descendant.name]
27
+ converter = @converter_registry[mapping_entry['content_type']]
28
+ next unless converter
29
+
30
+ result = yield(converter, descendant, mapping_entry)
31
+ blocks.concat(result)
32
+ end
33
+ blocks
34
+ end
35
+
36
+ private
37
+
38
+ def embedded_block_names
39
+ block_level = @rules['block_level_elements'] || []
40
+ mapping = @rules['element_mapping'] || {}
41
+ block_level.select { |name| mapping.key?(name) }
42
+ end
43
+
44
+ def inside_section?(descendant, root)
45
+ ancestor = descendant.parent
46
+ while ancestor && ancestor != root
47
+ return true if SECTION_NAMES.include?(ancestor.name)
48
+
49
+ ancestor = ancestor.parent
50
+ end
51
+ false
52
+ end
53
+ end
54
+ end
@@ -15,7 +15,8 @@ module Arrolio
15
15
  def layout
16
16
  @pages = []
17
17
  @page_count_opened = 0
18
- @footnote_page_map = {} # footnote.id / marker -> first page_number
18
+ @footnote_page_map = {}
19
+ @prev_space_after = 0.0
19
20
  context = FlowContext.new(layout_spec: @layout_spec, page_number: 1)
20
21
  pending = @flowables.dup
21
22
  page_state = open_page(context)
@@ -37,16 +38,18 @@ module Arrolio
37
38
  page_state.footer = @current_footer
38
39
  page_state.header_align = @current_header_align
39
40
  page_state.footer_align = @current_footer_align
41
+ page_state.title_template = flowable.title_template
42
+ @title_template = nil
43
+ @prev_space_after = 0.0
40
44
  next
41
45
  end
42
46
 
43
47
  if flowable.is_a?(Flowables::PageBreak)
44
48
  page_state = open_page(context)
49
+ @prev_space_after = 0.0
45
50
  next
46
51
  end
47
52
 
48
- # Footnote anchors: record on the current page without
49
- # consuming frame space (the flowable has zero height).
50
53
  if flowable.is_a?(Flowables::FootnoteMarkerFlowable)
51
54
  key = flowable.footnote.id || flowable.footnote.marker
52
55
  @footnote_page_map[key] ||= page_state.page_number
@@ -55,16 +58,16 @@ module Arrolio
55
58
  end
56
59
 
57
60
  page_state = open_page(context) if flowable.page_break_before? && @pages.any? && !page_state.fresh
58
- page_state = open_page(context) if page_state.frame.full?
59
-
60
- width = page_state.frame.width
61
- if flowable.keep_together? &&
62
- flowable.height(width, context) > page_state.frame.remaining_height &&
63
- flowable.height(width, context) <= page_state.frame.height
61
+ if page_state.frame.full?
64
62
  page_state = open_page(context)
63
+ @prev_space_after = 0.0
65
64
  end
66
65
 
67
- # Record heading entries for ToC + outline building.
66
+ page_state.frame.width
67
+ page_state = whole_move_page(flowable, page_state, context)
68
+
69
+ page_state = keep_with_next_page(flowable, pending, page_state, context)
70
+
68
71
  if flowable.is_a?(Flowables::HeadingFlowable)
69
72
  context.record_heading(number: flowable.number,
70
73
  title: flowable.title,
@@ -85,37 +88,61 @@ module Arrolio
85
88
 
86
89
  private
87
90
 
88
- def place(page_state, flowable, context, pending)
91
+ # Returns the page state that content was last placed on — a
92
+ # whole-flowable move to a fresh page must propagate, or the
93
+ # caller keeps filling the old page and content falls out of
94
+ # document order.
95
+ def place(page_state, flowable, context, pending, moved_page: false)
89
96
  frame = page_state.frame
90
97
  width = frame.width
91
98
  natural = flowable.height(width, context)
92
99
 
93
100
  if natural <= frame.remaining_height
94
101
  emit(page_state, flowable, context)
95
- elsif flowable.splittable?
96
- head, tail = flowable.split(width, frame.remaining_height, context)
97
- if head
98
- emit(page_state, head, context)
99
- pending.unshift(tail) if tail
100
- else
101
- return page_state unless frame.full?
102
-
103
- new_state = open_page(context)
104
- place(new_state, flowable, context, pending)
105
- end
106
- else
102
+ @prev_space_after = flowable.space_after.to_f
103
+ return page_state
104
+ end
105
+
106
+ unless flowable.splittable?
107
107
  emit(page_state, flowable, context)
108
+ @prev_space_after = flowable.space_after.to_f
109
+ return page_state
108
110
  end
109
- page_state
111
+
112
+ head, tail = flowable.split(width, frame.remaining_height, context)
113
+ if head
114
+ emit(page_state, head, context)
115
+ @prev_space_after = head.space_after.to_f
116
+ pending.unshift(tail) if tail
117
+ return page_state
118
+ end
119
+ if moved_page
120
+ # Already on a fresh page and still no fitting head — the
121
+ # first unsplittable chunk exceeds a full page. Draw it
122
+ # (overflowing) rather than dropping content or looping.
123
+ emit(page_state, flowable, context)
124
+ @prev_space_after = flowable.space_after.to_f
125
+ return page_state
126
+ end
127
+
128
+ # The flowable's first chunk does not fit the remainder;
129
+ # retry on a fresh page where it can split properly.
130
+ @prev_space_after = 0.0
131
+ place(open_page(context), flowable, context, pending, moved_page: true)
110
132
  end
111
133
 
112
134
  def emit(page_state, flowable, context)
113
135
  frame = page_state.frame
114
136
  x = frame.x
115
137
  y_top = frame.cursor_y
116
- boxes, consumed = flowable.emit(x, y_top, frame.width, context)
138
+
139
+ curr_before = flowable.space_before.to_f
140
+ overlap = [@prev_space_after, curr_before].min
141
+ adjusted_y = y_top + overlap
142
+
143
+ boxes, consumed = flowable.emit(x, adjusted_y, frame.width, context)
117
144
  page_state.body_boxes.concat(boxes)
118
- frame.consume!(consumed)
145
+ frame.consume!(consumed - overlap)
119
146
  end
120
147
 
121
148
  def open_page(context)
@@ -137,7 +164,7 @@ module Arrolio
137
164
  role: @current_role || :body,
138
165
  header: @current_header,
139
166
  footer: @current_footer,
140
- header_align: @current_header_align || :right,
167
+ header_align: header_align_for(page_number),
141
168
  footer_align: @current_footer_align || :center,
142
169
  footnotes: []
143
170
  )
@@ -145,6 +172,58 @@ module Arrolio
145
172
  state
146
173
  end
147
174
 
175
+ # keep-together: an atomic flowable taller than the page
176
+ # remainder (but not the whole page) moves intact.
177
+ def whole_move_page(flowable, page_state, context)
178
+ width = page_state.frame.width
179
+ natural = flowable.height(width, context)
180
+ return page_state if !flowable.keep_together? ||
181
+ natural <= page_state.frame.remaining_height ||
182
+ natural > page_state.frame.height
183
+
184
+ @prev_space_after = 0.0
185
+ open_page(context)
186
+ end
187
+
188
+ # XSL-FO keep-with-next: the keep CHAIN — consecutive
189
+ # keep-with-next flowables plus the next flowable's minimum
190
+ # height — moves to a fresh page when it would not fit (an
191
+ # orphaned heading above a moved figure; a term number
192
+ # without its definition's first paragraph).
193
+ def keep_with_next_page(flowable, pending, page_state, context)
194
+ return page_state unless flowable.keep_with_next? && pending.first
195
+
196
+ width = page_state.frame.width
197
+ chain_h = flowable.height(width, context)
198
+ return page_state if chain_h > page_state.frame.remaining_height
199
+
200
+ pending.each do |next_flowable|
201
+ chain_h += if next_flowable.keep_with_next?
202
+ next_flowable.height(width, context)
203
+ else
204
+ next_flowable.min_keep_height(width, context)
205
+ end
206
+ if chain_h > page_state.frame.remaining_height
207
+ @prev_space_after = 0.0
208
+ return open_page(context)
209
+ end
210
+ break unless next_flowable.keep_with_next?
211
+ end
212
+ page_state
213
+ end
214
+
215
+ # Pick header alignment based on page parity. Even pages
216
+ # have left-aligned headers; odd pages have right-aligned
217
+ # headers (matches the XSL-FO convention used by every
218
+ # flavor: insertHeaderEven left, insertHeaderOdd right).
219
+ def header_align_for(page_number)
220
+ explicit = @current_header_align
221
+ return explicit if explicit
222
+ return :left if page_number.even?
223
+
224
+ :right
225
+ end
226
+
148
227
  def finalize_pages
149
228
  total = @pages.length
150
229
  built = @pages.map do |state|
@@ -165,7 +244,8 @@ module Arrolio
165
244
  footer_text: format_text(state.footer, state.page_number, total),
166
245
  header_align: state.header_align,
167
246
  footer_align: state.footer_align,
168
- footnotes: state.footnotes
247
+ footnotes: state.footnotes,
248
+ title_text: state.title_template
169
249
  )
170
250
  end
171
251
  @pages.replace(built)
@@ -181,6 +261,7 @@ module Arrolio
181
261
  OpenPage = Struct.new(:template, :page_number, :frame, :body_boxes,
182
262
  :fresh, :role, :header, :footer,
183
263
  :header_align, :footer_align, :footnotes,
264
+ :title_template,
184
265
  keyword_init: true)
185
266
  end
186
267
  end
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'yaml'
4
- require 'arrolio/error'
5
4
 
6
5
  module Arrolio
7
6
  module Flavor
@@ -11,7 +11,7 @@ module Arrolio
11
11
  # .render(xml, io:, **opts) — render XML to PDF bytes
12
12
  #
13
13
  # Example:
14
- # Arrolio::Flavor::Registry.register(:my_flavor, Arrolio::Oiml::Pipeline)
14
+ # Arrolio::Flavor::Registry.register(:my_flavor, Arrolio::MyFlavorPipeline)
15
15
  # Arrolio::Flavor::Registry.for(:my_flavor).render(xml, io: io)
16
16
  class Registry
17
17
  @flavors = {}
@@ -2,6 +2,20 @@
2
2
 
3
3
  # Arrolio::Flowable — abstract base for everything the page-flow
4
4
  # engine places into Frames.
5
+ #
6
+ # Margin contract: +space_before+/+space_after+ expose the style's
7
+ # margins. The ENGINE collapses min(prev.space_after,
8
+ # curr.space_before) into the gap and adds it above the flowable's
9
+ # emit origin. Two conventions coexist and must not be mixed:
10
+ #
11
+ # - TextFlowable counts its margins INSIDE height/emit (consumed
12
+ # includes before+after; the engine subtracts the overlap).
13
+ # - ListFlowable and its subclasses (lists, notes) do NOT count
14
+ # margins in height — spacing around them comes from explicit
15
+ # Spacers (see GenericFlowBuilder's term notes).
16
+ #
17
+ # A flowable's height() and emit() must agree on whether margins
18
+ # are included, or fit checks and drawing diverge.
5
19
  module Arrolio
6
20
  class Flowable
7
21
  attr_reader :style
@@ -30,6 +44,13 @@ module Arrolio
30
44
  @style.keep_with_next
31
45
  end
32
46
 
47
+ # Height that must stay on the same page as a preceding
48
+ # keep-with-next flowable: the whole flowable when atomic, the
49
+ # first line (plus space-before) when splittable.
50
+ def min_keep_height(width, context = nil)
51
+ height(width, context)
52
+ end
53
+
33
54
  def page_break_before?
34
55
  @style.page_break_before
35
56
  end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Arrolio
4
+ module Flowables
5
+ # A figure is atomic: the image and its caption never split
6
+ # across a page boundary (FOP keep-together on figure blocks —
7
+ # an orphaned caption at a page top is a rendering defect).
8
+ class FigureFlowable < GroupFlowable
9
+ def initialize(image, caption)
10
+ super([image, caption])
11
+ end
12
+
13
+ def image = @parts.first
14
+ def caption = @parts.last
15
+ end
16
+ end
17
+ end
@@ -2,27 +2,46 @@
2
2
 
3
3
  module Arrolio
4
4
  module Flowables
5
- # Zero-height placeholder that signals "the footnote with this id
6
- # appears on the current page". The engine detects these during
7
- # layout and records the footnote on the current Output::Page so
8
- # the renderer can draw it at the bottom of the page.
5
+ # Anchors a footnote to the page it is referenced on and reserves
6
+ # the vertical space its body needs at the page bottom.
9
7
  #
10
- # Carries a reference to the full Content::Footnote (marker +
11
- # body). The marker is also rendered inline (as a superscript
12
- # indicator) by the flow builder when it constructs the run
13
- # sequence; this flowable's sole job is to anchor the footnote
14
- # to a page.
8
+ # The footnote's layout policy lives here so the engine's height
9
+ # accounting and the renderer's drawing agree by construction:
10
+ # both ask `.body_flowable` for the same laid-out block. The
11
+ # marker itself renders inline (superscript) from the paragraph's
12
+ # run sequence; this flowable's job is space + page attribution.
15
13
  class FootnoteMarkerFlowable < Flowable
16
14
  attr_reader :footnote
17
15
 
18
- def initialize(footnote)
19
- super(style: Style::Definition.new)
16
+ def initialize(footnote, style: Style::Definition.new(font_size: 9.0))
17
+ super(style: style)
20
18
  @footnote = footnote
21
19
  end
22
20
 
23
- def height(_width, _context = nil) = 0.0
21
+ # The footnote as it renders at the page bottom: marker + ")"
22
+ # prefix, body paragraphs joined, footnote style, 2pt gap after.
23
+ def self.body_flowable(footnote, style)
24
+ footnote_style = style.with(margin_top: 0.0, margin_bottom: 2.0)
25
+ runs = [InlineRun.new("#{footnote.marker}) ", style: footnote_style)]
26
+ footnote.body.each do |node|
27
+ next unless node.is_a?(Content::Paragraph)
24
28
 
25
- def emit(_x, _y, _width, _context = nil) = [[], 0.0]
29
+ node.inline_runs.each do |run|
30
+ runs << InlineRun.new(run.text, style: footnote_style)
31
+ end
32
+ end
33
+ TextFlowable.new(runs, style: footnote_style)
34
+ end
35
+
36
+ def height(width, _context = nil)
37
+ self.class.body_flowable(@footnote, @style).height(width)
38
+ end
39
+
40
+ # Emits nothing into the body area - the consumed height frees
41
+ # the bottom zone where the renderer draws the footnote body.
42
+ def emit(_x, _y, width, _context = nil)
43
+ [[], height(width)]
44
+ end
26
45
  end
27
46
  end
28
47
  end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Arrolio
4
+ module Flowables
5
+ # A keep-together composite: the whole group moves to the next
6
+ # page when it does not fit the remainder (XSL-FO
7
+ # keep-together). Groups taller than a full page split at child
8
+ # boundaries instead of overflowing.
9
+ class GroupFlowable < Flowable
10
+ attr_reader :parts
11
+
12
+ def initialize(parts, style: Style::Definition.new(keep_together: true))
13
+ super(style: style)
14
+ @parts = Array(parts)
15
+ end
16
+
17
+ def height(width, context = nil)
18
+ @parts.sum { |p| p.height(width, context) }
19
+ end
20
+
21
+ def emit(x, y, width, context = nil)
22
+ boxes = []
23
+ consumed = 0.0
24
+ cursor = y
25
+ @parts.each do |part|
26
+ part_h = part.height(width, context)
27
+ part_boxes, = part.emit(x, cursor, width, context)
28
+ boxes.concat(part_boxes)
29
+ cursor -= part_h
30
+ consumed += part_h
31
+ end
32
+ [boxes, consumed]
33
+ end
34
+
35
+ def splittable?
36
+ true
37
+ end
38
+
39
+ def do_split(width, remaining_height, context = nil)
40
+ head = []
41
+ tail = []
42
+ used = 0.0
43
+ @parts.each do |part|
44
+ part_h = part.height(width, context)
45
+ if used + part_h <= remaining_height || head.empty?
46
+ head << part
47
+ used += part_h
48
+ else
49
+ tail << part
50
+ end
51
+ end
52
+ [self.class.new(head, style: @style),
53
+ tail.empty? ? nil : self.class.new(tail, style: @style)]
54
+ end
55
+ end
56
+ end
57
+ end