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
|
@@ -2,43 +2,84 @@
|
|
|
2
2
|
|
|
3
3
|
module Arrolio
|
|
4
4
|
module Flowables
|
|
5
|
-
# Real table layout
|
|
6
|
-
#
|
|
5
|
+
# Real table layout driven by Table::Grid: colspan and rowspan
|
|
6
|
+
# aware cell placement, per-flavor row minimum height, vertical
|
|
7
|
+
# alignment inside cells, borders per cell, header row repeat,
|
|
8
|
+
# continuation caption on split, and table footnotes rendered
|
|
9
|
+
# below the last row.
|
|
7
10
|
class TableFlowable < Flowable
|
|
8
|
-
attr_reader :table, :
|
|
11
|
+
attr_reader :table, :continued, :caption_text, :caption_runs, :grid,
|
|
12
|
+
:min_row_height, :cell_padding, :footnote_font_size
|
|
9
13
|
|
|
10
|
-
|
|
14
|
+
CAPTION_DASH = Regexp.new("[\u00A0\u2009\u0020]\u2014").freeze
|
|
15
|
+
FOOTNOTE_SPACING = 10.0
|
|
16
|
+
|
|
17
|
+
def initialize(table, style: Style::Definition.new,
|
|
18
|
+
continued: false, caption_text: nil, caption_runs: nil,
|
|
19
|
+
caption_style: nil, min_row_height: 0.0,
|
|
20
|
+
cell_padding: 2.0, footnote_font_size: nil)
|
|
11
21
|
super(style: style)
|
|
12
22
|
@table = table
|
|
23
|
+
@continued = continued
|
|
24
|
+
@caption_runs = caption_runs
|
|
25
|
+
@caption_text = caption_text || caption_runs&.map(&:text)&.join
|
|
26
|
+
@caption_style = caption_style
|
|
27
|
+
@min_row_height = min_row_height.to_f
|
|
28
|
+
@cell_padding = cell_padding.to_f
|
|
29
|
+
@footnote_font_size = footnote_font_size ||
|
|
30
|
+
(@style.font_size * 0.9).round(1)
|
|
31
|
+
@grid = Table::Grid.build(table)
|
|
13
32
|
end
|
|
14
33
|
|
|
15
34
|
def height(width, _context = nil)
|
|
16
35
|
cols = column_widths_for(width)
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
36
|
+
caption_height(width) + row_heights(cols).sum +
|
|
37
|
+
footnote_block_height(width, cols)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# What must stay on the same page as a preceding
|
|
41
|
+
# keep-with-next flowable: the caption, the (repeated) header
|
|
42
|
+
# rows, and the first welded body group — a caption or header
|
|
43
|
+
# alone at a page bottom is an orphan.
|
|
44
|
+
def min_keep_height(width, _context = nil)
|
|
45
|
+
cols = column_widths_for(width)
|
|
46
|
+
heights = row_heights(cols)
|
|
47
|
+
header_rows = @table.header.length
|
|
48
|
+
first_body_group = grid.atomic_row_groups.find { |g| g.first >= header_rows }
|
|
49
|
+
keep_rows = first_body_group ? first_body_group.last + 1 : header_rows
|
|
50
|
+
caption_height(width) + heights[0...[keep_rows, heights.length].min].sum
|
|
22
51
|
end
|
|
23
52
|
|
|
24
|
-
def emit(x, y, width,
|
|
53
|
+
def emit(x, y, width, _context = nil)
|
|
25
54
|
cols = column_widths_for(width)
|
|
55
|
+
heights = row_heights(cols)
|
|
26
56
|
boxes = []
|
|
27
57
|
consumed = 0.0
|
|
28
58
|
cursor = y
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
59
|
+
|
|
60
|
+
# The caption travels with the table: the head part draws the
|
|
61
|
+
# full caption, continuations draw the short form. This keeps
|
|
62
|
+
# the caption from being orphaned at the bottom of a page.
|
|
63
|
+
if @caption_text
|
|
64
|
+
caption_h = emit_caption(x, cursor, width, boxes)
|
|
65
|
+
cursor -= caption_h
|
|
66
|
+
consumed += caption_h
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
row_bottoms = row_bottom_positions(cursor, heights)
|
|
70
|
+
grid.placements.each do |placement|
|
|
71
|
+
cell_y = row_bottoms[placement.row_index + placement.rowspan - 1]
|
|
72
|
+
cell_h = heights[placement.row_index, placement.rowspan].sum
|
|
73
|
+
cell_w = column_span_width(cols, placement)
|
|
74
|
+
cell_x = x + cols[0, placement.column_index].sum
|
|
75
|
+
render_cell(placement.cell, cell_x, cell_y, cell_w, cell_h, boxes,
|
|
76
|
+
header: header_row?(placement.row_index))
|
|
41
77
|
end
|
|
78
|
+
consumed += heights.sum
|
|
79
|
+
cursor = row_bottoms[-1] || cursor
|
|
80
|
+
|
|
81
|
+
fn_h = emit_footnotes(x, cursor, width, cols, boxes)
|
|
82
|
+
consumed += fn_h
|
|
42
83
|
|
|
43
84
|
[boxes, consumed]
|
|
44
85
|
end
|
|
@@ -47,33 +88,48 @@ module Arrolio
|
|
|
47
88
|
true
|
|
48
89
|
end
|
|
49
90
|
|
|
91
|
+
# Splits at welded-group boundaries only — a rowspan cell is
|
|
92
|
+
# never cut by a page break. When the first group already
|
|
93
|
+
# exceeds the remaining space, no head is returned and the
|
|
94
|
+
# engine moves the whole table to the next page. Footnotes
|
|
95
|
+
# travel with the part carrying the last body row.
|
|
50
96
|
def do_split(width, remaining_height, _context = nil)
|
|
51
97
|
cols = column_widths_for(width)
|
|
52
|
-
|
|
53
|
-
tail_rows = []
|
|
54
|
-
used = 0.0
|
|
98
|
+
heights = row_heights(cols)
|
|
55
99
|
header, body = partition_rows
|
|
100
|
+
header_count = header.length
|
|
56
101
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
102
|
+
head_groups = []
|
|
103
|
+
tail_groups = []
|
|
104
|
+
# The head part also carries the caption and the repeated
|
|
105
|
+
# header rows — they consume the same budget as body rows.
|
|
106
|
+
used = caption_height(width) + heights[0, header_count].sum
|
|
107
|
+
body_row_groups.each do |group|
|
|
108
|
+
group_h = heights[group.first, group.length].sum
|
|
109
|
+
if used + group_h <= remaining_height
|
|
110
|
+
head_groups << group
|
|
111
|
+
used += group_h
|
|
62
112
|
else
|
|
63
|
-
|
|
113
|
+
tail_groups << group
|
|
64
114
|
end
|
|
65
115
|
end
|
|
66
116
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
117
|
+
head_rows = head_groups.flat_map { |g| body[g.first - header_count, g.length] }
|
|
118
|
+
tail_rows = tail_groups.flat_map { |g| body[g.first - header_count, g.length] }
|
|
119
|
+
|
|
120
|
+
return [nil, whole_table_copy] if head_rows.empty?
|
|
121
|
+
if tail_rows.empty?
|
|
122
|
+
return [build_split(header, head_rows, @continued,
|
|
123
|
+
footnotes: @table.footnotes), nil]
|
|
72
124
|
end
|
|
73
125
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
126
|
+
[build_split(header, head_rows, @continued, footnotes: []),
|
|
127
|
+
build_split(header, tail_rows, true, footnotes: @table.footnotes)]
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def whole_table_copy
|
|
131
|
+
build_split(@table.header, @table.body, @continued,
|
|
132
|
+
footnotes: @table.footnotes)
|
|
77
133
|
end
|
|
78
134
|
|
|
79
135
|
private
|
|
@@ -86,61 +142,237 @@ module Arrolio
|
|
|
86
142
|
[@table.header, @table.body]
|
|
87
143
|
end
|
|
88
144
|
|
|
145
|
+
def header_row?(row_index)
|
|
146
|
+
row_index < @table.header.length
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Body row groups in ABSOLUTE row indexes, welded together by
|
|
150
|
+
# rowspan spans so a split never cuts through a vertical span.
|
|
151
|
+
# Groups that reach into the header are clipped to the body
|
|
152
|
+
# range — the span still welds the body rows it covers, and the
|
|
153
|
+
# header repeats on every continuation part anyway.
|
|
154
|
+
def body_row_groups
|
|
155
|
+
header_count = @table.header.length
|
|
156
|
+
total = rows.length
|
|
157
|
+
return [] if header_count >= total
|
|
158
|
+
|
|
159
|
+
grid.atomic_row_groups
|
|
160
|
+
.map { |group| group.select { |r| r >= header_count } }
|
|
161
|
+
.reject(&:empty?)
|
|
162
|
+
.map(&:freeze)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def build_split(header, body_rows, continued, footnotes:)
|
|
166
|
+
table = Content::Table.new(
|
|
167
|
+
header: header, body: body_rows,
|
|
168
|
+
column_widths: @table.column_widths,
|
|
169
|
+
style_id: @table.style_id, id: @table.id,
|
|
170
|
+
caption: @table.caption,
|
|
171
|
+
footnotes: footnotes || []
|
|
172
|
+
)
|
|
173
|
+
self.class.new(table, style: @style, continued: continued,
|
|
174
|
+
caption_text: @caption_text,
|
|
175
|
+
caption_style: @caption_style,
|
|
176
|
+
min_row_height: @min_row_height,
|
|
177
|
+
cell_padding: @cell_padding,
|
|
178
|
+
footnote_font_size: @footnote_font_size)
|
|
179
|
+
end
|
|
180
|
+
|
|
89
181
|
def column_widths_for(width)
|
|
90
|
-
return Array.new(
|
|
182
|
+
return Array.new(grid.column_count, width.to_f / [grid.column_count, 1].max) if grid.column_count.zero?
|
|
183
|
+
|
|
184
|
+
@cached_widths ||= Table::AutoLayout.new(@table,
|
|
185
|
+
available_width: width,
|
|
186
|
+
grid: grid).compute
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def column_span_width(cols, placement)
|
|
190
|
+
cols[placement.column_index, placement.colspan].sum ||
|
|
191
|
+
(50.0 * placement.colspan)
|
|
192
|
+
end
|
|
91
193
|
|
|
92
|
-
|
|
194
|
+
def inner_width(cols, placement)
|
|
195
|
+
column_span_width(cols, placement) - (@cell_padding * 2)
|
|
93
196
|
end
|
|
94
197
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
198
|
+
# Row heights: single-row cells drive their row's height; a
|
|
199
|
+
# multi-row cell that does not fit in the spanned rows pushes
|
|
200
|
+
# the deficit into every spanned row equally. Every row obeys
|
|
201
|
+
# the flavor's minimum height and its own min-height attribute.
|
|
202
|
+
def row_heights(cols)
|
|
203
|
+
heights = Array.new(rows.length, 0.0)
|
|
204
|
+
grid.placements.each do |placement|
|
|
205
|
+
next if placement.spans_multiple_rows?
|
|
206
|
+
|
|
207
|
+
content = cell_content_height(placement.cell,
|
|
208
|
+
inner_width(cols, placement),
|
|
209
|
+
header: header_row?(placement.row_index))
|
|
210
|
+
heights[placement.row_index] = [heights[placement.row_index], content].max
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
heights.each_with_index do |h, row_index|
|
|
214
|
+
floors = [@min_row_height, rows[row_index].min_height]
|
|
215
|
+
heights[row_index] = [h + (@cell_padding * 2), *floors].max
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
grid.placements.each do |placement|
|
|
219
|
+
next unless placement.spans_multiple_rows?
|
|
220
|
+
|
|
221
|
+
spanned = heights[placement.row_index, placement.rowspan]
|
|
222
|
+
content = cell_content_height(placement.cell,
|
|
223
|
+
inner_width(cols, placement),
|
|
224
|
+
header: header_row?(placement.row_index)) +
|
|
225
|
+
(@cell_padding * 2)
|
|
226
|
+
deficit = content - spanned.sum
|
|
227
|
+
next unless deficit.positive?
|
|
228
|
+
|
|
229
|
+
share = deficit / placement.rowspan
|
|
230
|
+
placement.rowspan.times do |r|
|
|
231
|
+
heights[placement.row_index + r] += share
|
|
101
232
|
end
|
|
102
|
-
max_h = [max_h, content_h].max
|
|
103
233
|
end
|
|
104
|
-
|
|
234
|
+
heights
|
|
105
235
|
end
|
|
106
236
|
|
|
107
|
-
def
|
|
108
|
-
|
|
237
|
+
def row_bottom_positions(top, heights)
|
|
238
|
+
bottoms = []
|
|
239
|
+
cursor = top
|
|
240
|
+
heights.each do |h|
|
|
241
|
+
cursor -= h
|
|
242
|
+
bottoms << cursor
|
|
243
|
+
end
|
|
244
|
+
bottoms
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def cell_content_height(cell, width, header: false)
|
|
248
|
+
style = cell_style(header: header)
|
|
249
|
+
runs = cell_paragraph_runs(cell, style)
|
|
250
|
+
return 0.0 if runs.empty?
|
|
109
251
|
|
|
110
|
-
style = cell_style(paragraph)
|
|
111
|
-
runs = paragraph.inline_runs.map { |r| InlineRun.new(r.text, style: style) }
|
|
112
252
|
TextFlowable.new(runs, style: style).height(width)
|
|
113
253
|
end
|
|
114
254
|
|
|
115
|
-
def
|
|
116
|
-
style = cell_style(cell)
|
|
255
|
+
def cell_paragraph_runs(cell, style)
|
|
117
256
|
runs = []
|
|
118
257
|
cell.content.each do |node|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
258
|
+
next unless node.is_a?(Content::Paragraph)
|
|
259
|
+
|
|
260
|
+
runs.concat(node.inline_runs.map { |r| InlineRun.new(r.text, style: style) })
|
|
122
261
|
end
|
|
123
|
-
|
|
262
|
+
runs
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def render_cell(cell, x, y, w, h, boxes, header: false)
|
|
266
|
+
style = cell_style(header: header)
|
|
267
|
+
style = style.with(align: cell.align) if cell.align
|
|
268
|
+
runs = cell_paragraph_runs(cell, style)
|
|
269
|
+
unless runs.empty?
|
|
124
270
|
tf = TextFlowable.new(runs, style: style)
|
|
125
|
-
|
|
271
|
+
inner_w = w - (@cell_padding * 2)
|
|
272
|
+
content_h = tf.height(inner_w)
|
|
273
|
+
text_top = text_top_for(cell, y, h, content_h)
|
|
274
|
+
tboxes, = tf.emit(x + @cell_padding, text_top, inner_w, nil)
|
|
126
275
|
boxes.concat(tboxes)
|
|
127
276
|
end
|
|
128
|
-
|
|
277
|
+
stroke = header ? 0.7 : 0.5
|
|
129
278
|
boxes << Output::PlacedBox.rect(x: x, y: y, width: w, height: h,
|
|
130
|
-
stroke_width:
|
|
279
|
+
stroke_width: stroke, stroke_color: 'black')
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
# Vertical placement of cell content. Default (and :top) hugs
|
|
283
|
+
# the cell's top padding; :middle and :bottom center or sink
|
|
284
|
+
# the content block within the cell.
|
|
285
|
+
def text_top_for(cell, cell_y, cell_h, content_h)
|
|
286
|
+
slack = [cell_h - content_h - (@cell_padding * 2), 0.0].max
|
|
287
|
+
lift = case cell.valign
|
|
288
|
+
when :middle then slack / 2.0
|
|
289
|
+
when :bottom then slack
|
|
290
|
+
else 0.0
|
|
291
|
+
end
|
|
292
|
+
cell_y + cell_h - @cell_padding - lift
|
|
131
293
|
end
|
|
132
294
|
|
|
133
|
-
def
|
|
134
|
-
|
|
135
|
-
|
|
295
|
+
def footnote_block_height(width, _cols)
|
|
296
|
+
return 0.0 if @table.footnotes.empty?
|
|
297
|
+
|
|
298
|
+
FOOTNOTE_SPACING +
|
|
299
|
+
@table.footnotes.sum { |fn| footnote_flowable(fn).height(width) }
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
def emit_footnotes(x, y, width, _cols, boxes)
|
|
303
|
+
return 0.0 if @table.footnotes.empty?
|
|
304
|
+
|
|
305
|
+
cursor = y - FOOTNOTE_SPACING
|
|
306
|
+
consumed = FOOTNOTE_SPACING
|
|
307
|
+
@table.footnotes.each do |fn|
|
|
308
|
+
tf = footnote_flowable(fn)
|
|
309
|
+
tboxes, = tf.emit(x, cursor, width, nil)
|
|
310
|
+
boxes.concat(tboxes)
|
|
311
|
+
h = tf.height(width)
|
|
312
|
+
cursor -= h
|
|
313
|
+
consumed += h
|
|
314
|
+
end
|
|
315
|
+
consumed
|
|
136
316
|
end
|
|
137
317
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
318
|
+
def footnote_flowable(fn)
|
|
319
|
+
style = @style.with(font_size: @footnote_font_size, align: :left,
|
|
320
|
+
margin_top: 0.0, margin_bottom: 2.0)
|
|
321
|
+
runs = [InlineRun.new("#{fn.marker} ", style: style)]
|
|
322
|
+
fn.body.each do |node|
|
|
323
|
+
next unless node.is_a?(Content::Paragraph)
|
|
324
|
+
|
|
325
|
+
runs.concat(node.inline_runs.map { |r| InlineRun.new(r.text, style: style) })
|
|
326
|
+
end
|
|
327
|
+
TextFlowable.new(runs, style: style)
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
def emit_caption(x, y, width, boxes)
|
|
331
|
+
runs = if @continued
|
|
332
|
+
text = "#{short_caption_text} (continued)"
|
|
333
|
+
[InlineRun.new(text, style: caption_style)]
|
|
334
|
+
elsif @caption_runs
|
|
335
|
+
@caption_runs
|
|
336
|
+
else
|
|
337
|
+
[InlineRun.new(@caption_text, style: caption_style)]
|
|
338
|
+
end
|
|
339
|
+
tf = TextFlowable.new(runs, style: caption_style)
|
|
340
|
+
line_h = tf.height(width)
|
|
341
|
+
tboxes, = tf.emit(x, y, width, nil)
|
|
342
|
+
boxes.concat(tboxes)
|
|
343
|
+
line_h
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
def caption_style
|
|
347
|
+
@caption_style || @style.with(align: :left)
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
def caption_height(width)
|
|
351
|
+
return 0.0 if @caption_text.nil?
|
|
352
|
+
|
|
353
|
+
caption_flowable(width).height(width)
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
def caption_flowable(_width)
|
|
357
|
+
runs = @caption_runs || [InlineRun.new(@caption_text, style: caption_style)]
|
|
358
|
+
TextFlowable.new(runs, style: caption_style)
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
def short_caption_text
|
|
362
|
+
parts = @caption_text.split(CAPTION_DASH)
|
|
363
|
+
return @caption_text if parts.length < 2
|
|
364
|
+
|
|
365
|
+
parts.first.to_s
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
# Resolve a cell's effective style. Header cells get the bold
|
|
369
|
+
# variant of the table style. Body cells fall back to the
|
|
370
|
+
# table style unless the cell declares its own style_id.
|
|
371
|
+
def cell_style(header: false)
|
|
372
|
+
base = @style.font_name ? @style : Style::Definition.new(font_name: 'Times-Roman', font_size: 10)
|
|
373
|
+
return base unless header
|
|
374
|
+
|
|
375
|
+
base.with(font_name: "#{base.font_name} Bold")
|
|
144
376
|
end
|
|
145
377
|
end
|
|
146
378
|
end
|
|
@@ -7,38 +7,72 @@ module Arrolio
|
|
|
7
7
|
# :knuth_plass). Both strategies produce TextLayout::Line[]
|
|
8
8
|
# with the same shape, so the rest of the pipeline is agnostic.
|
|
9
9
|
class TextFlowable < Flowable
|
|
10
|
-
attr_reader :runs, :measurer
|
|
10
|
+
attr_reader :runs, :measurer, :hanging_indent
|
|
11
11
|
|
|
12
|
-
def initialize(runs, style: Style::Definition.new, measurer: nil
|
|
12
|
+
def initialize(runs, style: Style::Definition.new, measurer: nil,
|
|
13
|
+
hanging_indent: 0.0)
|
|
13
14
|
super(style: style)
|
|
14
15
|
@runs = Array(runs)
|
|
15
16
|
@measurer = measurer || GlyphMeasurer.new(font_name: style.font_name)
|
|
17
|
+
@hanging_indent = hanging_indent.to_f
|
|
16
18
|
end
|
|
17
19
|
|
|
20
|
+
# Inline math with relational operators lays out stacked in
|
|
21
|
+
# the reference (JEuclid gives the formula box roughly two
|
|
22
|
+
# text lines of vertical extent); simple sub/superscripts stay
|
|
23
|
+
# compact. The operators only ever come from math content.
|
|
24
|
+
MATH_OPERATOR = /[\u2264\u2265\u00d7\u00f7\u00b1\u2212\u2260]|\+\d/
|
|
25
|
+
MATH_LINE_FACTOR = 1.9
|
|
26
|
+
|
|
18
27
|
def height(width, _context = nil)
|
|
19
|
-
|
|
28
|
+
effective_width = width - @hanging_indent
|
|
29
|
+
laid = laid_out(effective_width)
|
|
30
|
+
text_height = (laid.length * line_height) +
|
|
31
|
+
(math_line_count(laid) * line_height * (MATH_LINE_FACTOR - 1.0))
|
|
32
|
+
text_height + space_before + space_after
|
|
20
33
|
end
|
|
21
34
|
|
|
22
35
|
def emit(x, y, width, _context = nil)
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
36
|
+
effective_width = width - @hanging_indent
|
|
37
|
+
lines = laid_out(effective_width)
|
|
38
|
+
text_height = (lines.length * line_height) +
|
|
39
|
+
(math_line_count(lines) * line_height * (MATH_LINE_FACTOR - 1.0))
|
|
40
|
+
total = text_height + space_before + space_after
|
|
41
|
+
return [[], total] if lines.empty?
|
|
42
|
+
|
|
43
|
+
text_y = y - space_before
|
|
44
|
+
heights = lines.map do |line|
|
|
45
|
+
math = line.placed_runs.any? { |pr| pr.run.text.match?(MATH_OPERATOR) }
|
|
46
|
+
line_height * (math ? MATH_LINE_FACTOR : 1.0)
|
|
47
|
+
end
|
|
26
48
|
box = Output::PlacedBox.text(
|
|
27
|
-
x: x, y:
|
|
28
|
-
width: width, height:
|
|
29
|
-
lines: lines, line_height: line_height, style: @style
|
|
49
|
+
x: x, y: text_y - text_height,
|
|
50
|
+
width: width, height: text_height,
|
|
51
|
+
lines: lines, line_height: line_height, style: @style,
|
|
52
|
+
hanging_indent: @hanging_indent, line_heights: heights
|
|
30
53
|
)
|
|
31
|
-
[[box],
|
|
54
|
+
[[box], total]
|
|
32
55
|
end
|
|
33
56
|
|
|
34
57
|
def splittable?
|
|
35
58
|
true
|
|
36
59
|
end
|
|
37
60
|
|
|
61
|
+
# Widows for keep-with-next predecessors: how many lines must
|
|
62
|
+
# follow when the paragraph starts after a kept block.
|
|
63
|
+
def min_keep_height(_width, _context = nil)
|
|
64
|
+
space_before + ([widows_count, 1].max * line_height)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def widows_count
|
|
68
|
+
@style.widows ? @style.widows.to_i : 1
|
|
69
|
+
end
|
|
70
|
+
|
|
38
71
|
def do_split(width, remaining_height, _context = nil)
|
|
39
72
|
lines = laid_out(width)
|
|
40
73
|
line_h = line_height
|
|
41
|
-
|
|
74
|
+
available = remaining_height - space_before
|
|
75
|
+
head_count = (available.to_f / line_h).floor
|
|
42
76
|
head_count = [head_count, lines.length].min
|
|
43
77
|
head_count = 1 if head_count < 1 && lines.any?
|
|
44
78
|
|
|
@@ -83,13 +117,35 @@ module Arrolio
|
|
|
83
117
|
line_spacing: @style.line_spacing)
|
|
84
118
|
end
|
|
85
119
|
|
|
120
|
+
def math_line_count(lines)
|
|
121
|
+
lines.count do |line|
|
|
122
|
+
line.placed_runs.any? { |pr| pr.run.text.match?(MATH_OPERATOR) }
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Rebuilds a run list from laid-out lines. A line break
|
|
127
|
+
# consumes the inter-word glue, and placed runs don't carry it,
|
|
128
|
+
# so a separator is re-inserted at each line boundary —
|
|
129
|
+
# otherwise re-layout glues the boundary words together
|
|
130
|
+
# ("…increasing and" + "decreasing loads."). Hyphen breaks
|
|
131
|
+
# already join their words; they must not gain a space.
|
|
86
132
|
def collect_runs(lines)
|
|
87
133
|
runs = []
|
|
88
134
|
lines.each do |line|
|
|
135
|
+
first = line.placed_runs.first&.run
|
|
136
|
+
runs << InlineRun.new(' ', style: first.style) if separator_needed?(runs.last, first)
|
|
89
137
|
line.placed_runs.each { |pr| runs << pr.run }
|
|
90
138
|
end
|
|
91
139
|
runs
|
|
92
140
|
end
|
|
141
|
+
|
|
142
|
+
def separator_needed?(last_run, first_run)
|
|
143
|
+
return false if last_run.nil? || first_run.nil?
|
|
144
|
+
return false if last_run.text.end_with?('-')
|
|
145
|
+
return false if last_run.text.match?(/\s\z/) || first_run.text.match?(/\A\s/)
|
|
146
|
+
|
|
147
|
+
true
|
|
148
|
+
end
|
|
93
149
|
end
|
|
94
150
|
end
|
|
95
151
|
end
|
|
@@ -2,8 +2,18 @@
|
|
|
2
2
|
|
|
3
3
|
module Arrolio
|
|
4
4
|
module Flowables
|
|
5
|
-
# One ToC line: title + dot
|
|
5
|
+
# One ToC line: title + dot leader + page number, all on one
|
|
6
|
+
# line. The title is left-aligned, the page number is
|
|
7
|
+
# right-aligned, and the gap between is filled with dot leaders.
|
|
8
|
+
#
|
|
9
|
+
# Emits three PlacedBox values:
|
|
10
|
+
# 1. text box for the title at x=0
|
|
11
|
+
# 2. text box for the page number, right-aligned at x=width
|
|
12
|
+
# 3. (optional) line box for the dot leader between them
|
|
6
13
|
class TocLineFlowable < Flowable
|
|
14
|
+
LEADER_CHAR = '.'
|
|
15
|
+
LEADER_GAP = 4.0 # pt between title/page and the leader run
|
|
16
|
+
|
|
7
17
|
attr_reader :title, :page_number, :level
|
|
8
18
|
|
|
9
19
|
def initialize(title, page_number, level: 1, style: Style::Definition.new)
|
|
@@ -20,15 +30,68 @@ module Arrolio
|
|
|
20
30
|
end
|
|
21
31
|
|
|
22
32
|
def emit(x, y, width, context = nil)
|
|
23
|
-
page_str = @page_number.to_s
|
|
24
33
|
measurer = GlyphMeasurer.new(font_name: @style.font_name)
|
|
34
|
+
line_h = height(width, context)
|
|
35
|
+
# Apply the style's margin_left as an indent (ToC sub-entries
|
|
36
|
+
# use this to nest visually under their parent entry).
|
|
37
|
+
indent = @style.margin_left.to_f
|
|
38
|
+
x += indent
|
|
39
|
+
width -= indent
|
|
40
|
+
page_str = @page_number.to_s
|
|
25
41
|
title_w = measurer.width_of_string(@title, font_size: @style.font_size)
|
|
26
42
|
page_w = measurer.width_of_string(page_str, font_size: @style.font_size)
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
43
|
+
page_x = x + width - page_w
|
|
44
|
+
leader_w = page_x - (x + title_w + LEADER_GAP)
|
|
45
|
+
|
|
46
|
+
boxes = []
|
|
47
|
+
boxes.concat(emit_title(x, y, width, line_h, context))
|
|
48
|
+
boxes.concat(emit_page_number(page_x, y, page_w, line_h, context))
|
|
49
|
+
boxes.concat(emit_leader(x + title_w + LEADER_GAP, y, leader_w, line_h, measurer)) if leader_w.positive?
|
|
50
|
+
[boxes, line_h]
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def emit_title(x, y, _width, line_h, context)
|
|
56
|
+
tf = TextFlowable.new([InlineRun.new(@title, style: @style)],
|
|
57
|
+
style: @style.with(align: :left))
|
|
58
|
+
boxes, _consumed = tf.emit(x, y, x_origin_width(@title, @style), context)
|
|
59
|
+
position_boxes_at(boxes, x, y, line_h)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def emit_page_number(page_x, y, page_w, _line_h, context)
|
|
63
|
+
tf = TextFlowable.new([InlineRun.new(@page_number.to_s, style: @style)],
|
|
64
|
+
style: @style.with(align: :left))
|
|
65
|
+
boxes, _consumed = tf.emit(page_x, y, page_w, context)
|
|
66
|
+
boxes
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def emit_leader(start_x, y, leader_w, _line_h, measurer)
|
|
70
|
+
dot_w = measurer.width_of_string(LEADER_CHAR, font_size: @style.font_size)
|
|
71
|
+
count = (leader_w / dot_w).floor
|
|
72
|
+
return [] if count <= 0
|
|
73
|
+
|
|
74
|
+
leader_text = LEADER_CHAR * count
|
|
75
|
+
tf = TextFlowable.new([InlineRun.new(leader_text, style: @style)],
|
|
76
|
+
style: @style.with(align: :left))
|
|
77
|
+
boxes, _consumed = tf.emit(start_x, y, leader_w, nil)
|
|
78
|
+
boxes
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# TextFlowable.width is the natural width of the text; for the
|
|
82
|
+
# title box we want exactly the title width so the leader
|
|
83
|
+
# starts right after.
|
|
84
|
+
def x_origin_width(text, style)
|
|
85
|
+
GlyphMeasurer.new(font_name: style.font_name)
|
|
86
|
+
.width_of_string(text, font_size: style.font_size)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# The TextFlowable emits a single box for a single-line
|
|
90
|
+
# paragraph; trust that and return as-is. (No-op retained for
|
|
91
|
+
# symmetry with future flowables that may need offset
|
|
92
|
+
# adjustment.)
|
|
93
|
+
def position_boxes_at(boxes, _x, _y, _line_h)
|
|
94
|
+
boxes
|
|
32
95
|
end
|
|
33
96
|
end
|
|
34
97
|
end
|
data/lib/arrolio/flowables.rb
CHANGED
|
@@ -10,10 +10,14 @@ module Arrolio
|
|
|
10
10
|
autoload :PageSequenceStart, 'arrolio/flowables/page_sequence_start'
|
|
11
11
|
autoload :ListFlowable, 'arrolio/flowables/list_flowable'
|
|
12
12
|
autoload :NoteFlowable, 'arrolio/flowables/note_flowable'
|
|
13
|
+
autoload :FigureFlowable, 'arrolio/flowables/figure_flowable'
|
|
14
|
+
autoload :GroupFlowable, 'arrolio/flowables/group_flowable'
|
|
13
15
|
autoload :ImageFlowable, 'arrolio/flowables/image_flowable'
|
|
14
16
|
autoload :TocLineFlowable, 'arrolio/flowables/toc_line_flowable'
|
|
15
17
|
autoload :TwoColumnBlock, 'arrolio/flowables/two_column_block'
|
|
16
18
|
autoload :TableFlowable, 'arrolio/flowables/table_flowable'
|
|
17
19
|
autoload :FootnoteMarkerFlowable, 'arrolio/flowables/footnote_marker_flowable'
|
|
20
|
+
autoload :PositionedBlock, 'arrolio/flowables/positioned_block'
|
|
21
|
+
autoload :RotatedText, 'arrolio/flowables/rotated_text'
|
|
18
22
|
end
|
|
19
23
|
end
|