arrolio 0.1.0 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.rubocop.yml +2 -2
- data/Rakefile +15 -0
- data/TODO.complete/56-generic-adapter-mece-decomposition.md +20 -0
- data/TODO.complete/60-adapter-fidelity-footnotes-notes-examples.md +92 -55
- data/TODO.complete/70-ttf-font-metrics-parity.md +68 -0
- data/TODO.complete/71-cover-layout-parity.md +45 -0
- data/TODO.complete/72-table-layout-parity.md +49 -0
- data/TODO.complete/73-toc-leaders-parity.md +55 -0
- data/TODO.complete/74-svg-image-rendering-parity.md +76 -0
- data/TODO.complete/75-mathml-formula-rendering-parity.md +52 -0
- data/TODO.complete/76-odd-even-page-templates-parity.md +50 -0
- data/TODO.complete/77-text-formatting-parity.md +55 -0
- data/TODO.complete/78-bibliography-formatting-parity.md +62 -0
- data/TODO.complete/79-line-breaking-parity.md +80 -0
- data/TODO.complete/80-parity-diff-harness-in-ci.md +68 -0
- data/TODO.complete/81-conditional-style-refinements.md +70 -0
- data/TODO.complete/82-semantic-presentation-dedup.md +65 -0
- data/TODO.complete/83-nested-list-support.md +74 -0
- data/TODO.complete/84-figure-svg-rendering.md +70 -0
- data/TODO.complete/85-locality-reference-formatting.md +48 -0
- data/TODO.complete/86-list-item-text-overflow.md +80 -0
- data/TODO.complete/87-odd-even-page-templates.md +55 -0
- data/TODO.complete/88-hyphenation-support.md +52 -0
- data/TODO.complete/89-table-rowspan.md +49 -0
- data/TODO.complete/90-mathml-stacked-layout.md +65 -0
- data/TODO.complete/91-parity-diff-diagnostics.md +50 -0
- data/TODO.complete/92-table-cell-height-parity.md +66 -0
- data/TODO.complete/93-figure-text-searchability.md +49 -0
- data/TODO.complete/94-fo-space-resolution.md +63 -0
- data/TODO.complete/95-title-header-placement.md +55 -0
- data/TODO.complete/96-pagination-drift-parity.md +517 -0
- data/TODO.complete/README.md +58 -38
- data/lib/arrolio/config_driven_pipeline.rb +45 -1
- data/lib/arrolio/content/document.rb +4 -3
- data/lib/arrolio/content/section.rb +3 -0
- data/lib/arrolio/content/table.rb +25 -14
- data/lib/arrolio/embedded_block_extractor.rb +54 -0
- data/lib/arrolio/engine/paged.rb +109 -28
- data/lib/arrolio/flavor/manifest.rb +0 -1
- data/lib/arrolio/flavor/registry.rb +1 -1
- data/lib/arrolio/flowable.rb +21 -0
- data/lib/arrolio/flowables/figure_flowable.rb +17 -0
- data/lib/arrolio/flowables/footnote_marker_flowable.rb +32 -13
- data/lib/arrolio/flowables/group_flowable.rb +57 -0
- data/lib/arrolio/flowables/list_flowable.rb +46 -18
- data/lib/arrolio/flowables/note_flowable.rb +84 -9
- data/lib/arrolio/flowables/page_sequence_start.rb +8 -4
- data/lib/arrolio/flowables/positioned_block.rb +55 -0
- data/lib/arrolio/flowables/rotated_text.rb +51 -0
- data/lib/arrolio/flowables/table_flowable.rb +303 -71
- data/lib/arrolio/flowables/text_flowable.rb +67 -11
- data/lib/arrolio/flowables/toc_line_flowable.rb +70 -7
- data/lib/arrolio/flowables.rb +4 -0
- data/lib/arrolio/font/embedder.rb +22 -3
- data/lib/arrolio/font_scanner.rb +133 -0
- data/lib/arrolio/generic_adapter/document_extraction.rb +101 -0
- data/lib/arrolio/generic_adapter/footnote_extraction.rb +81 -0
- data/lib/arrolio/generic_adapter/heading_extraction.rb +92 -0
- data/lib/arrolio/generic_adapter/inline_run_collection.rb +136 -0
- data/lib/arrolio/generic_adapter/list_conversion.rb +70 -0
- data/lib/arrolio/generic_adapter/metadata_extraction.rb +46 -0
- data/lib/arrolio/generic_adapter/table_conversion.rb +105 -0
- data/lib/arrolio/generic_adapter.rb +263 -317
- data/lib/arrolio/generic_flow_builder/bibliography.rb +47 -0
- data/lib/arrolio/generic_flow_builder/figures.rb +90 -0
- data/lib/arrolio/generic_flow_builder/lists.rb +66 -0
- data/lib/arrolio/generic_flow_builder/notes.rb +61 -0
- data/lib/arrolio/generic_flow_builder/sequences.rb +102 -0
- data/lib/arrolio/generic_flow_builder/tables.rb +28 -0
- data/lib/arrolio/generic_flow_builder/terms.rb +89 -0
- data/lib/arrolio/generic_flow_builder.rb +92 -187
- data/lib/arrolio/glyph_measurer.rb +16 -5
- data/lib/arrolio/inline_run.rb +2 -1
- data/lib/arrolio/layout_spec/page_template.rb +14 -8
- data/lib/arrolio/math_ml/inline_run_extractor.rb +272 -0
- data/lib/arrolio/math_ml.rb +7 -0
- data/lib/arrolio/output/page.rb +5 -4
- data/lib/arrolio/output/placed_box.rb +5 -2
- data/lib/arrolio/renderer/pdf/assets.rb +94 -0
- data/lib/arrolio/renderer/pdf/font_embedding.rb +77 -0
- data/lib/arrolio/renderer/pdf/metadata.rb +52 -0
- data/lib/arrolio/renderer/pdf.rb +140 -215
- data/lib/arrolio/style/definition.rb +7 -3
- data/lib/arrolio/style/registry.rb +6 -1
- data/lib/arrolio/table/auto_layout.rb +19 -10
- data/lib/arrolio/table/grid.rb +105 -0
- data/lib/arrolio/table.rb +1 -0
- data/lib/arrolio/text_layout/greedy.rb +15 -1
- data/lib/arrolio/text_layout/knuth_plass/breaker.rb +216 -92
- data/lib/arrolio/text_layout/knuth_plass/item_builder.rb +31 -17
- data/lib/arrolio/text_layout/knuth_plass.rb +3 -0
- data/lib/arrolio/text_layout/line.rb +10 -2
- data/lib/arrolio/version.rb +1 -1
- data/lib/arrolio.rb +8 -4
- data/scripts/parity_check.rb +52 -0
- data/scripts/parity_diff.rb +89 -0
- data/scripts/xsl_profiles/standoc.yml +8 -2
- data/scripts/xsl_to_config.rb +28 -1
- metadata +91 -5
|
@@ -55,7 +55,7 @@ module Arrolio
|
|
|
55
55
|
io: io,
|
|
56
56
|
logo_path: @logo_path,
|
|
57
57
|
metadata: document_metadata,
|
|
58
|
-
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.
|
|
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
|
data/lib/arrolio/engine/paged.rb
CHANGED
|
@@ -15,7 +15,8 @@ module Arrolio
|
|
|
15
15
|
def layout
|
|
16
16
|
@pages = []
|
|
17
17
|
@page_count_opened = 0
|
|
18
|
-
@footnote_page_map = {}
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
@@ -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::
|
|
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 = {}
|
data/lib/arrolio/flowable.rb
CHANGED
|
@@ -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
|
-
#
|
|
6
|
-
#
|
|
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
|
-
#
|
|
11
|
-
#
|
|
12
|
-
#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|