arrolio 0.1.0 → 0.1.1

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 (89) 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.rb +362 -62
  66. data/lib/arrolio/glyph_measurer.rb +16 -5
  67. data/lib/arrolio/inline_run.rb +2 -1
  68. data/lib/arrolio/layout_spec/page_template.rb +14 -8
  69. data/lib/arrolio/math_ml/inline_run_extractor.rb +272 -0
  70. data/lib/arrolio/math_ml.rb +7 -0
  71. data/lib/arrolio/output/page.rb +5 -4
  72. data/lib/arrolio/output/placed_box.rb +5 -2
  73. data/lib/arrolio/renderer/pdf.rb +131 -32
  74. data/lib/arrolio/style/definition.rb +7 -3
  75. data/lib/arrolio/style/registry.rb +6 -1
  76. data/lib/arrolio/table/auto_layout.rb +19 -10
  77. data/lib/arrolio/table/grid.rb +105 -0
  78. data/lib/arrolio/table.rb +1 -0
  79. data/lib/arrolio/text_layout/knuth_plass/breaker.rb +194 -92
  80. data/lib/arrolio/text_layout/knuth_plass/item_builder.rb +31 -17
  81. data/lib/arrolio/text_layout/knuth_plass.rb +3 -0
  82. data/lib/arrolio/text_layout/line.rb +3 -1
  83. data/lib/arrolio/version.rb +1 -1
  84. data/lib/arrolio.rb +8 -4
  85. data/scripts/parity_check.rb +52 -0
  86. data/scripts/parity_diff.rb +89 -0
  87. data/scripts/xsl_profiles/standoc.yml +8 -2
  88. data/scripts/xsl_to_config.rb +28 -1
  89. metadata +81 -5
@@ -15,12 +15,13 @@ module Arrolio
15
15
  MIN_COLUMN_WIDTH = 20.0 # points — prevents columns from collapsing
16
16
  CELL_PADDING = 8.0 # points — left + right padding per cell
17
17
 
18
- attr_reader :table, :available_width, :measurer
18
+ attr_reader :table, :available_width, :measurer, :grid
19
19
 
20
- def initialize(table, available_width:, measurer: nil)
20
+ def initialize(table, available_width:, measurer: nil, grid: nil)
21
21
  @table = table
22
22
  @available_width = available_width.to_f
23
23
  @measurer = measurer || default_measurer
24
+ @grid = grid || Grid.build(table)
24
25
  end
25
26
 
26
27
  # Returns an Array of Float widths (one per column), summing
@@ -35,18 +36,26 @@ module Arrolio
35
36
  private
36
37
 
37
38
  def column_count
38
- @table.column_count
39
+ [@table.column_count, @grid.column_count].max
39
40
  end
40
41
 
41
- # Natural width of each column = max cell natural width across rows.
42
+ # Natural width of each column = max cell natural width across
43
+ # rows. Placements come from the occupancy grid, so cells land
44
+ # in the columns they actually occupy (rowspan cells in later
45
+ # rows no longer shift into the wrong slot). Cells with colspan
46
+ # distribute their natural width equally across the spanned
47
+ # slots so a wide header doesn't accidentally collapse.
42
48
  def natural_widths
43
49
  widths = Array.new(column_count, MIN_COLUMN_WIDTH)
44
- @table.rows.each do |row|
45
- row.cells.each_with_index do |cell, ci|
46
- next if ci >= widths.length
50
+ @grid.placements.each do |placement|
51
+ span = [placement.colspan, 1].max
52
+ span = [span, column_count - placement.column_index].min
53
+ share = cell_natural_width(placement.cell) / span.to_f
54
+ span.times do |offset|
55
+ idx = placement.column_index + offset
56
+ break if idx >= widths.length
47
57
 
48
- w = cell_natural_width(cell)
49
- widths[ci] = w if w > widths[ci]
58
+ widths[idx] = [widths[idx], share].max
50
59
  end
51
60
  end
52
61
  widths
@@ -84,7 +93,7 @@ module Arrolio
84
93
  end
85
94
 
86
95
  def default_measurer
87
- GlyphMeasurer.new(font_name: 'Times New Roman')
96
+ GlyphMeasurer.new(font_name: 'Times-Roman')
88
97
  end
89
98
  end
90
99
  end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Arrolio
4
+ module Table
5
+ # Occupancy grid over a Content::Table, honoring colspan and
6
+ # rowspan. Answers the two questions every table consumer asks:
7
+ # which column does each cell actually occupy, and which rows are
8
+ # welded together by a vertical span (and therefore cannot be
9
+ # split apart across pages).
10
+ #
11
+ # Built once per layout pass and shared by AutoLayout (natural
12
+ # widths), TableFlowable (row heights, cell placement), and
13
+ # splitting (atomic row groups).
14
+ class Grid
15
+ # One cell's position in the grid. +row_index+ is the row the
16
+ # cell STARTS in — a rowspan cell is placed and drawn only
17
+ # there, with a height covering every spanned row.
18
+ Placement = Struct.new(:cell, :row_index, :column_index,
19
+ :colspan, :rowspan, keyword_init: true) do
20
+ def spans_multiple_rows?
21
+ rowspan > 1
22
+ end
23
+
24
+ def spans_multiple_columns?
25
+ colspan > 1
26
+ end
27
+ end
28
+
29
+ attr_reader :placements, :row_count, :column_count
30
+
31
+ def self.build(table)
32
+ rows = table.rows
33
+ occupied = {}
34
+ placements = []
35
+ rows.each_with_index do |row, row_index|
36
+ column_index = 0
37
+ row.cells.each do |cell|
38
+ column_index += 1 while occupied[[row_index, column_index]]
39
+ colspan = cell.colspan.to_i
40
+ rowspan = cell.rowspan.to_i
41
+ placements << Placement.new(
42
+ cell: cell, row_index: row_index, column_index: column_index,
43
+ colspan: colspan, rowspan: rowspan
44
+ )
45
+ rowspan.times do |r|
46
+ colspan.times do |c|
47
+ occupied[[row_index + r, column_index + c]] = true
48
+ end
49
+ end
50
+ column_index += colspan
51
+ end
52
+ end
53
+ new(placements: placements, row_count: rows.length,
54
+ column_count: occupied.keys.map(&:last).max.to_i + 1)
55
+ end
56
+
57
+ def initialize(placements:, row_count:, column_count:)
58
+ @placements = placements.freeze
59
+ @row_count = row_count
60
+ @column_count = column_count
61
+ @by_row = Array.new(row_count) { [] }
62
+ @placements.each { |p| @by_row[p.row_index] << p }
63
+ @by_row.each(&:freeze)
64
+ freeze
65
+ end
66
+
67
+ def placements_starting_in(row_index)
68
+ @by_row[row_index] || []
69
+ end
70
+
71
+ # Rows welded together by rowspan cells, as arrays of row
72
+ # indexes in ascending order. Rows not covered by any span come
73
+ # back as single-element groups. Splitting the table across
74
+ # pages must happen between groups, never inside one.
75
+ def atomic_row_groups
76
+ groups = []
77
+ group = []
78
+ welded = welded_rows
79
+ (0...@row_count).each do |row_index|
80
+ group << row_index
81
+ next if welded.include?(row_index + 1)
82
+
83
+ groups << group.freeze
84
+ group = []
85
+ end
86
+ groups << group.freeze unless group.empty?
87
+ groups.freeze
88
+ end
89
+
90
+ private
91
+
92
+ # Row indexes > 0 whose content starts in the row above (i.e.
93
+ # a rowspan from above covers their first column slot).
94
+ def welded_rows
95
+ covered = {}
96
+ @placements.each do |p|
97
+ next unless p.spans_multiple_rows?
98
+
99
+ (1...p.rowspan).each { |r| covered[p.row_index + r] = true }
100
+ end
101
+ covered
102
+ end
103
+ end
104
+ end
105
+ end
data/lib/arrolio/table.rb CHANGED
@@ -5,5 +5,6 @@ module Arrolio
5
5
  # - AutoLayout — content-based column width computation.
6
6
  module Table
7
7
  autoload :AutoLayout, 'arrolio/table/auto_layout'
8
+ autoload :Grid, 'arrolio/table/grid'
8
9
  end
9
10
  end
@@ -12,20 +12,38 @@ module Arrolio
12
12
  # converges in O(n) for typical text because most candidate
13
13
  # breaks are pruned early.
14
14
  #
15
+ # When no break sequence is feasible within TOLERANCE (every
16
+ # candidate line is too loose), a second pass runs with
17
+ # emergency stretch added to every line's stretchability —
18
+ # TeX's \emergencystretch. Without it, the only surviving path
19
+ # is the forced final break, which packs the whole paragraph
20
+ # into one overfull line and silently runs off the page.
21
+ #
15
22
  # Produces TextLayout::Line[] output — compatible with the
16
23
  # existing Greedy breaker so the engine can swap freely.
17
24
  class Breaker
18
25
  Infinity = Float::INFINITY
19
26
 
20
- # Tolerance for adjustment ratio — lines with ratio above
21
- # this are considered "underfull" and penalized.
22
- TOLERANCE = 100.0
27
+ # Tolerance for adjustment ratio — lines with |ratio| above
28
+ # this are infeasible and rejected. Standard TeX default
29
+ # (\tolerance=200) maps to ratio ≈ 1.26 because badness is
30
+ # 100*|r|^3. We use a slightly looser limit (2.5) to allow
31
+ # reasonable stretch on tight paragraphs while rejecting
32
+ # the pathological overfills greedy avoids by construction.
33
+ TOLERANCE = 2.5
34
+
35
+ # Stretch added to every line during the emergency pass.
36
+ # Comparable to TeX's \emergencystretch = 2em at body size.
37
+ EMERGENCY_STRETCH = 20.0
23
38
 
24
39
  # Extra penalty for consecutive flagged breaks (hyphens).
25
40
  FLAGGED_PENALTY = 3000.0
26
41
 
27
- # Extra penalty for the last line being underfull.
28
- DEMERITS_LAST_LINE = 50.0
42
+ # Last-resort cost for a forced break landing an overfull
43
+ # line (ratio beyond the available shrink). Must exceed the
44
+ # worst badness demerits ((1+10000)^2 = 1e8) so ANY
45
+ # breakable alternative wins.
46
+ OVERFULL_FORCED_PENALTY = 1_000_000_000.0
29
47
 
30
48
  attr_reader :items, :line_widths, :runs, :measurer, :align
31
49
 
@@ -44,51 +62,85 @@ module Arrolio
44
62
  end
45
63
 
46
64
  def layout
47
- nodes = active_nodes
48
- return [] if nodes.empty?
49
-
50
- # Only consider nodes that reached the end of the items
51
- # (the FINISHED penalty at the last position).
52
- final_nodes = nodes.select { |n| n.position == @items.length - 1 }
53
- return [] if final_nodes.empty?
54
-
55
- # Backtrack from the best final node.
56
- breaks = reconstruct_breaks(best_final_node(final_nodes))
65
+ build_prefix_sums
66
+ node = solve
67
+ node = solve(emergency_stretch: EMERGENCY_STRETCH) unless feasible?(node)
68
+ return [] if node.nil?
57
69
 
58
- # Build Line objects from the break points.
59
- lines = []
60
- prev_item = 0
61
- breaks.each_with_index do |br, line_idx|
62
- width = line_width_for(line_idx)
63
- placed = build_placed_runs(prev_item, br, width)
64
- used = compute_line_width(prev_item, br)
65
- lines << Line.new(placed, width: used, max_width: width, align: @align)
66
- prev_item = br
67
- end
68
- lines
70
+ build_lines(node)
69
71
  end
70
72
 
71
73
  # Internal: a node in the dynamic programming graph.
72
74
  Node = Struct.new(:position, :line, :fitness, :total_demerits,
73
75
  :previous, keyword_init: true)
74
76
 
77
+ # Internal: a run of same-signature text being merged into a
78
+ # single PlacedRun (one canvas.text call at render time).
79
+ RunGroup = Struct.new(:signature, :text, :x_offset, keyword_init: true)
80
+
75
81
  private
76
82
 
77
83
  # Active-node dynamic programming. Each active node represents
78
84
  # a feasible break position. We iterate through items and
79
85
  # try breaking at each feasible position.
80
- def active_nodes
81
- @active = [Node.new(position: -1, line: 0, fitness: 1,
82
- total_demerits: 0, previous: nil)]
83
- @items.each_with_index do |_item, i|
86
+ def solve(emergency_stretch: 0.0)
87
+ @emergency_stretch = emergency_stretch.to_f
88
+ # The emergency pass accepts any STRETCH ratio (TeX's
89
+ # \emergencystretch philosophy: a loose line beats an
90
+ # overfull one). A fixed stretch budget alone cannot
91
+ # bridge the dead zone between over-shrunk and too-loose
92
+ # breaks when candidates sit far short of the measure.
93
+ @stretch_limit = emergency_stretch.positive? ? Float::INFINITY : TOLERANCE
94
+ active = [Node.new(position: -1, line: 0, fitness: 1,
95
+ total_demerits: 0, previous: nil)]
96
+ @items.each_with_index do |item, i|
84
97
  next unless feasible_break?(i)
85
98
 
86
- @active = @active.reject { |a| prune?(a, i) }
87
- candidates = @active.filter_map { |a| try_break(a, i) }
88
- best = best_candidate(candidates)
89
- @active << best if best
99
+ best = best_candidate(active.filter_map { |a| try_break(a, i) })
100
+ next unless best
101
+
102
+ if item.penalty? && item.forced_break?
103
+ # A forced break must be taken: no earlier active node
104
+ # may span across it (otherwise the DP happily skips
105
+ # forced breaks when the parfill glue makes the single
106
+ # line feasible).
107
+ active = [best]
108
+ else
109
+ active << best
110
+ end
111
+ end
112
+ best_final_node(active.select { |n| n.position == @items.length - 1 })
113
+ end
114
+
115
+ # True when the solution contains no line whose adjustment
116
+ # ratio escaped TOLERANCE via a forced break. When false, the
117
+ # paragraph needs the emergency pass.
118
+ def feasible?(final_node)
119
+ return false if final_node.nil?
120
+
121
+ prev_item = 0
122
+ reconstruct_breaks(final_node).each_with_index do |br, line_idx|
123
+ natural = compute_line_width(prev_item, br)
124
+ ratio = adjustment_ratio(natural, line_width_for(line_idx),
125
+ prev_item, br)
126
+ return false if ratio.abs > TOLERANCE
127
+
128
+ prev_item = br
129
+ end
130
+ true
131
+ end
132
+
133
+ def build_lines(final_node)
134
+ lines = []
135
+ prev_item = 0
136
+ reconstruct_breaks(final_node).each_with_index do |br, line_idx|
137
+ width = line_width_for(line_idx)
138
+ placed = build_placed_runs(prev_item, br)
139
+ lines << Line.new(placed, width: compute_line_width(prev_item, br),
140
+ max_width: width, align: @align)
141
+ prev_item = br
90
142
  end
91
- @active
143
+ lines
92
144
  end
93
145
 
94
146
  def feasible_break?(i)
@@ -96,33 +148,37 @@ module Arrolio
96
148
  (item.penalty? && !item.no_break?) || (item.glue? && i.positive? && @items[i - 1].box?)
97
149
  end
98
150
 
99
- def prune?(node, _current)
100
- node.line >= @line_widths.length + 10
101
- end
102
-
103
151
  def try_break(node, break_pos)
104
152
  item = @items[break_pos]
105
153
  width = line_width_for(node.line)
106
- line_w = compute_line_width(node.position + 1, break_pos)
154
+ line_start = node.position + 1
155
+ line_w = compute_line_width(line_start, break_pos)
107
156
 
108
157
  is_forced = item.penalty? && item.forced_break?
109
- ratio = adjustment_ratio(line_w, width, break_pos)
158
+ ratio = adjustment_ratio(line_w, width, line_start, break_pos)
110
159
 
111
160
  # Forced breaks are always accepted but still penalized
112
161
  # for bad ratios so the algorithm prefers shorter lines.
113
- unless is_forced
114
- return nil if ratio.abs > TOLERANCE
115
- end
116
-
117
- ratio = 0.0 if ratio == Infinity || ratio == -Infinity
162
+ # TeX semantics: shrink is a hard limit (ratio < -1 needs
163
+ # more compression than the glue has - an overfull box),
164
+ # stretch is soft up to TOLERANCE. Allowing ratio < -1
165
+ # made the DP prefer overfull compression over loose
166
+ # lines (clamped badness scores compression cheaper).
167
+ return nil if !is_forced && (ratio > @stretch_limit || ratio < -1.0)
168
+
169
+ ratio = 0.0 if ratio.infinite?
118
170
  demerits = demerits_for(ratio, break_pos)
171
+ # A forced break is always TAKEN (correctness) but an
172
+ # overfull line through it is a last resort: without this,
173
+ # free shrink made the DP pack whole paragraphs onto one
174
+ # line via the un-rejectable final penalty.
175
+ demerits += OVERFULL_FORCED_PENALTY if is_forced && ratio < -1.0
119
176
  total = node.total_demerits + demerits
120
- fitness = fitness_class(ratio)
121
177
 
122
178
  Node.new(
123
179
  position: break_pos,
124
180
  line: node.line + 1,
125
- fitness: fitness,
181
+ fitness: fitness_class(ratio),
126
182
  total_demerits: total,
127
183
  previous: node
128
184
  )
@@ -137,10 +193,11 @@ module Arrolio
137
193
  end
138
194
 
139
195
  # Adjustment ratio: how stretched (positive) or shrunk
140
- # (negative) a line is. 0 = perfect fit.
141
- def adjustment_ratio(natural_width, target_width, break_pos)
142
- glue_stretch = total_stretch(break_pos)
143
- glue_shrink = total_shrink(break_pos)
196
+ # (negative) a line is. 0 = perfect fit. Emergency stretch
197
+ # widens the feasibility window without changing shrink.
198
+ def adjustment_ratio(natural_width, target_width, line_start, line_end)
199
+ glue_stretch = line_stretch(line_start, line_end) + @emergency_stretch
200
+ glue_shrink = line_shrink(line_start, line_end)
144
201
 
145
202
  if natural_width < target_width
146
203
  glue_stretch.positive? ? (target_width - natural_width) / glue_stretch : Infinity
@@ -153,10 +210,14 @@ module Arrolio
153
210
 
154
211
  def demerits_for(ratio, break_pos)
155
212
  penalty = @items[break_pos].penalty? ? @items[break_pos].penalty : 0
213
+ # FOP justifies every line to the measure after breaking:
214
+ # shrink (ratio < 0) is free, unlike TeX's cubic badness.
215
+ # Penalizing it made the DP prefer an extra loose line
216
+ # where the reference compresses (5.6's 4-line vs our 5).
156
217
  badness = if ratio.negative?
157
- (100 * ((-ratio) ** 3)).to_i
158
- else
159
- 0
218
+ 0
219
+ else
220
+ (100 * (ratio ** 3)).clamp(0, 10_000).to_i
160
221
  end
161
222
  # Standard Knuth-Plass demerits. Forced breaks (penalty =
162
223
  # -Infinity) use d = (1 + badness)^2 — the penalty term
@@ -181,21 +242,25 @@ module Arrolio
181
242
  end
182
243
  end
183
244
 
245
+ # Prefix sums so range width/stretch/shrink queries are O(1).
246
+ def build_prefix_sums
247
+ @prefix_width = [0.0]
248
+ @prefix_stretch = [0.0]
249
+ @prefix_shrink = [0.0]
250
+ @items.each do |item|
251
+ @prefix_width << (@prefix_width.last + item.width.to_f)
252
+ @prefix_stretch << (@prefix_stretch.last + (item.glue? ? item.stretch : 0.0))
253
+ @prefix_shrink << (@prefix_shrink.last + (item.glue? ? item.shrink : 0.0))
254
+ end
255
+ end
256
+
184
257
  # Sum of widths from item +start+ to item +stop+ (exclusive).
185
258
  # Discards trailing glue at the break point.
186
259
  def compute_line_width(start, stop)
187
260
  return 0.0 if start >= stop
188
261
 
189
- sum = 0.0
190
- (start...stop).each do |i|
191
- item = @items[i]
192
- next if item.nil?
193
-
194
- # Trailing glue after last box is discarded at break.
195
- last_box = last_box_index_in(start, stop)
196
- sum += item.width if i <= last_box
197
- end
198
- sum
262
+ last_box = last_box_index_in(start, stop)
263
+ last_box < start ? 0.0 : @prefix_width[last_box + 1] - @prefix_width[start]
199
264
  end
200
265
 
201
266
  def last_box_index_in(start, stop)
@@ -206,16 +271,12 @@ module Arrolio
206
271
  idx
207
272
  end
208
273
 
209
- def total_stretch(break_pos)
210
- sum = 0.0
211
- @items[0..break_pos].each { |item| sum += item.stretch if item.glue? }
212
- sum
274
+ def line_stretch(line_start, line_end)
275
+ @prefix_stretch[line_end] - @prefix_stretch[line_start]
213
276
  end
214
277
 
215
- def total_shrink(break_pos)
216
- sum = 0.0
217
- @items[0..break_pos].each { |item| sum += item.shrink if item.glue? }
218
- sum
278
+ def line_shrink(line_start, line_end)
279
+ @prefix_shrink[line_end] - @prefix_shrink[line_start]
219
280
  end
220
281
 
221
282
  def line_width_for(line_idx)
@@ -232,32 +293,73 @@ module Arrolio
232
293
  breaks.reverse
233
294
  end
234
295
 
235
- def build_placed_runs(start_item, stop_item, _width)
296
+ # Merges contiguous Box + Glue items into PlacedRuns — one
297
+ # canvas.text call per group — so pdftotext sees word
298
+ # separators instead of glued-together Tj fragments. A new
299
+ # group starts whenever the rendering signature (style,
300
+ # baseline shift, scale, href) changes, so bold/italic and
301
+ # sub/superscript runs keep their formatting. Glue past the
302
+ # line's last box (at the break) is dropped: it is discarded
303
+ # by width accounting and would otherwise inflate the
304
+ # renderer's justify word count.
305
+ def build_placed_runs(start_item, stop_item)
306
+ last_box = last_box_index_in(start_item, stop_item)
236
307
  placed = []
237
- x_offset = 0.0
308
+ group = nil
309
+
238
310
  (start_item...stop_item).each do |i|
239
311
  item = @items[i]
240
- next unless item&.box?
241
-
242
- run = @runs[item.run_index]
243
- next unless run
244
-
245
- slice = run.text[item.char_offset, item.char_length]
246
- next if slice.nil? || slice.empty?
247
-
248
- sub = InlineRun.new(slice, style: run.style,
249
- baseline_shift: run.baseline_shift,
250
- font_size_scale: run.font_size_scale,
251
- href: run.href)
252
- placed << Line::PlacedRun.new(run: sub, x_offset: x_offset)
253
- x_offset += item.width
312
+ next unless item
313
+ next if item.glue? && item.stretch.infinite?
314
+
315
+ if item.box?
316
+ run = @runs[item.run_index]
317
+ next unless run
318
+
319
+ slice = run.text[item.char_offset, item.char_length]
320
+ next if slice.nil? || slice.empty?
321
+
322
+ signature = [run.style, run.baseline_shift,
323
+ run.font_size_scale, run.href]
324
+ if group.nil? || group.signature != signature
325
+ flush_placed_group(placed, group) if group
326
+ group = RunGroup.new(signature: signature, text: String.new,
327
+ x_offset: item_offset(first_box_in(start_item, stop_item), i))
328
+ end
329
+ group.text << slice
330
+ elsif item.glue? && group && i < last_box
331
+ group.text << ' '
332
+ end
254
333
  end
334
+ flush_placed_group(placed, group) if group
255
335
  placed
256
336
  end
257
337
 
258
- def finalize_node(position, line, fitness, total_demerits, previous)
259
- Node.new(position: position, line: line, fitness: fitness,
260
- total_demerits: total_demerits, previous: previous)
338
+ # Offsets are relative to the line's FIRST BOX, not the
339
+ # break-following item: the leading glue is discarded at a
340
+ # break, and pricing it shifted every continuation line
341
+ # right by one space width (a phantom indent that also
342
+ # shortened the measure and caused extra wraps).
343
+ def first_box_in(start_item, stop_item)
344
+ (start_item...stop_item).each do |i|
345
+ return i if @items[i]&.box?
346
+ end
347
+ start_item
348
+ end
349
+
350
+ def item_offset(start_item, item_idx)
351
+ @prefix_width[item_idx] - @prefix_width[start_item]
352
+ end
353
+
354
+ def flush_placed_group(placed, group)
355
+ return if group.text.empty?
356
+
357
+ style, baseline_shift, font_size_scale, href = group.signature
358
+ run = InlineRun.new(group.text, style: style,
359
+ baseline_shift: baseline_shift,
360
+ font_size_scale: font_size_scale,
361
+ href: href)
362
+ placed << Line::PlacedRun.new(run: run, x_offset: group.x_offset)
261
363
  end
262
364
  end
263
365
  end
@@ -22,6 +22,12 @@ module Arrolio
22
22
  @runs.each_with_index do |run, run_idx|
23
23
  build_items_for_run(run, run_idx, items)
24
24
  end
25
+ # Parfillskip: zero-width glue with infinite stretch before
26
+ # the FINISHED penalty (TeX's \parfillskip). Makes the last
27
+ # line of a paragraph never loose, so short final lines are
28
+ # always feasible. Paragraphs with no feasible intermediate
29
+ # break are rescued by Breaker's emergency-stretch pass.
30
+ items << Glue.new(width: 0, stretch: Float::INFINITY, shrink: 0)
25
31
  items << FINISHED
26
32
  items
27
33
  end
@@ -38,6 +44,26 @@ module Arrolio
38
44
  return
39
45
  end
40
46
 
47
+ build_word_items(run, run_idx, text, 0, items)
48
+ end
49
+
50
+ # A run with embedded newlines wraps WITHIN each segment
51
+ # (a segment can be a whole paragraph) and places a forced
52
+ # break between segments. Emitting each segment as a single
53
+ # box made long segments unbreakable overfull lines.
54
+ def build_newline_items(run, run_idx, text, items)
55
+ # -1 keeps trailing empty segments: a lone "\n" run
56
+ # must still emit its forced-break penalty.
57
+ segments = text.split("\n", -1)
58
+ base = 0
59
+ segments.each_with_index do |seg, i|
60
+ build_word_items(run, run_idx, seg, base, items) unless seg.empty?
61
+ base += seg.length + 1
62
+ items << Penalty.new(penalty: -Float::INFINITY, run_index: run_idx) if i < segments.length - 1
63
+ end
64
+ end
65
+
66
+ def build_word_items(run, run_idx, text, base_offset, items)
41
67
  words = text.split(/(\s+)/)
42
68
  offset = 0
43
69
  words.each do |word|
@@ -46,31 +72,18 @@ module Arrolio
46
72
  stretch = measure(' ', run) * 0.5
47
73
  shrink = measure(' ', run) * 0.33
48
74
  items << Glue.new(width: width, stretch: stretch, shrink: shrink,
49
- run_index: run_idx, char_offset: offset)
75
+ run_index: run_idx, char_offset: base_offset + offset)
50
76
  elsif word.include?('-')
51
- build_hyphenated_word(word, run, run_idx, offset, items)
77
+ build_hyphenated_word(word, run, run_idx, base_offset + offset, items)
52
78
  elsif !word.empty?
53
79
  width = measure(word, run)
54
80
  items << Box.new(width: width, run_index: run_idx,
55
- char_offset: offset, char_length: word.length)
81
+ char_offset: base_offset + offset, char_length: word.length)
56
82
  end
57
83
  offset += word.length
58
84
  end
59
85
  end
60
86
 
61
- def build_newline_items(run, run_idx, text, items)
62
- segments = text.split("\n")
63
- segments.each_with_index do |seg, i|
64
- unless seg.empty?
65
- width = measure(seg, run)
66
- items << Box.new(width: width, run_index: run_idx,
67
- char_offset: text.index(seg) || 0,
68
- char_length: seg.length)
69
- end
70
- items << Penalty.new(penalty: -Float::INFINITY, run_index: run_idx) if i < segments.length - 1
71
- end
72
- end
73
-
74
87
  def build_hyphenated_word(word, run, run_idx, offset, items)
75
88
  parts = word.split(/(-)/)
76
89
  part_offset = offset
@@ -94,7 +107,8 @@ module Arrolio
94
107
  text,
95
108
  font_size: run.style.font_size * run.font_size_scale,
96
109
  character_spacing: run.style.character_spacing,
97
- word_spacing: 0
110
+ word_spacing: 0,
111
+ font_name: run.style.font_name
98
112
  )
99
113
  end
100
114
  end
@@ -5,6 +5,9 @@ module Arrolio
5
5
  module TextLayout
6
6
  module KnuthPlass
7
7
  autoload :Item, 'arrolio/text_layout/knuth_plass/item'
8
+ autoload :Box, 'arrolio/text_layout/knuth_plass/item'
9
+ autoload :Glue, 'arrolio/text_layout/knuth_plass/item'
10
+ autoload :Penalty, 'arrolio/text_layout/knuth_plass/item'
8
11
  autoload :Breaker, 'arrolio/text_layout/knuth_plass/breaker'
9
12
  autoload :ItemBuilder, 'arrolio/text_layout/knuth_plass/item_builder'
10
13
  end
@@ -38,9 +38,11 @@ module Arrolio
38
38
  @align == :justify
39
39
  end
40
40
 
41
+ # Negative when the line is overfull (TeX shrink): the
42
+ # renderer compresses inter-word gaps. Clamping at zero made
43
+ # compressed lines draw at natural width, past the measure.
41
44
  def justify_stretch
42
45
  return 0.0 unless justified?
43
- return 0.0 if @width >= @max_width
44
46
 
45
47
  gaps = word_gap_count
46
48
  return 0.0 if gaps.zero?