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
@@ -2,43 +2,84 @@
2
2
 
3
3
  module Arrolio
4
4
  module Flowables
5
- # Real table layout: equal-width columns by default (TODO 04
6
- # adds auto/fixed), borders per cell, header row repeat.
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, :column_widths
11
+ attr_reader :table, :continued, :caption_text, :caption_runs, :grid,
12
+ :min_row_height, :cell_padding, :footnote_font_size
9
13
 
10
- def initialize(table, style: Style::Definition.new)
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
- total = 0.0
18
- rows.each do |row|
19
- total += row_height(row, cols)
20
- end
21
- total
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, context = nil)
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
- header_rows, body_rows = partition_rows
30
-
31
- (header_rows + body_rows).each do |row|
32
- rh = row_height(row, cols)
33
- cursor -= rh
34
- consumed += rh
35
- cell_x = x.to_f
36
- row.cells.each_with_index do |cell, ci|
37
- cw = cols[ci] || (width / [row.cells.length, 1].max)
38
- render_cell(cell, cell_x, cursor, cw, rh, boxes, context)
39
- cell_x += cw
40
- end
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
- head_rows = []
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
- body.each do |row|
58
- rh = row_height(row, cols)
59
- if used + rh <= remaining_height || head_rows.empty?
60
- head_rows << row
61
- used += rh
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
- tail_rows << row
113
+ tail_groups << group
64
114
  end
65
115
  end
66
116
 
67
- if head_rows.any?
68
- head_table = rebuild_table(header, head_rows)
69
- head = self.class.new(head_table, style: @style)
70
- else
71
- head = nil
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
- tail_table = rebuild_table(header, tail_rows)
75
- tail = self.class.new(tail_table, style: @style)
76
- [head, tail]
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(@table.column_count, width.to_f / [@table.column_count, 1].max) if @table.column_count.zero?
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
- @cached_widths ||= Table::AutoLayout.new(@table, available_width: width).compute
194
+ def inner_width(cols, placement)
195
+ column_span_width(cols, placement) - (@cell_padding * 2)
93
196
  end
94
197
 
95
- def row_height(row, cols)
96
- max_h = 0.0
97
- row.cells.each_with_index do |cell, ci|
98
- cw = cols[ci] || 50.0
99
- content_h = cell.content.sum do |node|
100
- paragraph_height(node, cw)
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
- max_h + 4.0
234
+ heights
105
235
  end
106
236
 
107
- def paragraph_height(paragraph, width)
108
- return 0.0 unless paragraph.is_a?(Content::Paragraph)
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 render_cell(cell, x, y, w, h, boxes, _context)
116
- style = cell_style(cell)
255
+ def cell_paragraph_runs(cell, style)
117
256
  runs = []
118
257
  cell.content.each do |node|
119
- if node.is_a?(Content::Paragraph)
120
- runs.concat(node.inline_runs.map { |r| InlineRun.new(r.text, style: style) })
121
- end
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
- if runs.any?
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
- tboxes, = tf.emit(x + 2, y + h - 2, w - 4, nil)
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
- # Cell border: rectangle around the full cell.
277
+ stroke = header ? 0.7 : 0.5
129
278
  boxes << Output::PlacedBox.rect(x: x, y: y, width: w, height: h,
130
- stroke_width: 0.5, stroke_color: 'black')
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 rebuild_table(header, body)
134
- Content::Table.new(header: header.map { |r| r.cells.map(&:text) },
135
- body: body.map { |r| r.cells.map(&:text) })
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
- # Use the parent table's style (which inherits from body →
139
- # Times New Roman) rather than hard-coding Times-Roman.
140
- # Falls back to Times-Roman only if the table style has no
141
- # font_name (shouldn't happen in practice).
142
- def cell_style(_cell_or_para)
143
- @style.font_name ? @style : Style::Definition.new(font_name: 'Times-Roman', font_size: 10)
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
- laid_out(width).length * line_height
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
- lines = laid_out(width)
24
- return [[], 0.0] if lines.empty?
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: y - (line_height * lines.length),
28
- width: width, height: line_height * lines.length,
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], box.height]
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
- head_count = (remaining_height.to_f / line_h).floor
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-leader + page number. One line tall.
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
- gap = width - title_w - page_w - 10
28
- dots = gap.positive? ? ('.' * (gap / measurer.space_width(font_size: @style.font_size)).floor) : ' '
29
- text = "#{@title} #{dots} #{page_str}"
30
- tf = TextFlowable.new([InlineRun.new(text, style: @style)], style: @style)
31
- tf.emit(x, y, width, context)
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
@@ -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