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
@@ -100,11 +100,25 @@ module Arrolio
100
100
  next if slice.nil? || slice.empty?
101
101
 
102
102
  sub_run = InlineRun.new(slice, style: run.style)
103
- placed << Line::PlacedRun.new(run: sub_run, x_offset: x_offset)
103
+ placed << Line::PlacedRun.new(run: sub_run, x_offset: x_offset,
104
+ chunk_widths: chunk_widths(run, slice))
104
105
  x_offset += sub_run.width(@measurer)
105
106
  end
106
107
  placed
107
108
  end
109
+
110
+ # Width of each emission chunk (text split after
111
+ # whitespace) — the same chunking the renderer uses. The
112
+ # breaker owns measurement; the renderer advances by these.
113
+ def chunk_widths(run, slice)
114
+ font = run.style.font_name
115
+ size = run.style.font_size * run.font_size_scale
116
+ slice.split(/(?<=\s)(?=\S)/).map do |chunk|
117
+ @measurer.width_of_run(chunk, font_size: size,
118
+ character_spacing: run.style.character_spacing,
119
+ word_spacing: 0, font_name: font)
120
+ end
121
+ end
108
122
  end
109
123
  end
110
124
  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,86 @@ 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, :chunk_widths,
80
+ keyword_init: true)
81
+
75
82
  private
76
83
 
77
84
  # Active-node dynamic programming. Each active node represents
78
85
  # a feasible break position. We iterate through items and
79
86
  # 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|
87
+ def solve(emergency_stretch: 0.0)
88
+ @emergency_stretch = emergency_stretch.to_f
89
+ # The emergency pass accepts any STRETCH ratio (TeX's
90
+ # \emergencystretch philosophy: a loose line beats an
91
+ # overfull one). A fixed stretch budget alone cannot
92
+ # bridge the dead zone between over-shrunk and too-loose
93
+ # breaks when candidates sit far short of the measure.
94
+ @stretch_limit = emergency_stretch.positive? ? Float::INFINITY : TOLERANCE
95
+ active = [Node.new(position: -1, line: 0, fitness: 1,
96
+ total_demerits: 0, previous: nil)]
97
+ @items.each_with_index do |item, i|
84
98
  next unless feasible_break?(i)
85
99
 
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
100
+ best = best_candidate(active.filter_map { |a| try_break(a, i) })
101
+ next unless best
102
+
103
+ if item.penalty? && item.forced_break?
104
+ # A forced break must be taken: no earlier active node
105
+ # may span across it (otherwise the DP happily skips
106
+ # forced breaks when the parfill glue makes the single
107
+ # line feasible).
108
+ active = [best]
109
+ else
110
+ active << best
111
+ end
112
+ end
113
+ best_final_node(active.select { |n| n.position == @items.length - 1 })
114
+ end
115
+
116
+ # True when the solution contains no line whose adjustment
117
+ # ratio escaped TOLERANCE via a forced break. When false, the
118
+ # paragraph needs the emergency pass.
119
+ def feasible?(final_node)
120
+ return false if final_node.nil?
121
+
122
+ prev_item = 0
123
+ reconstruct_breaks(final_node).each_with_index do |br, line_idx|
124
+ natural = compute_line_width(prev_item, br)
125
+ ratio = adjustment_ratio(natural, line_width_for(line_idx),
126
+ prev_item, br)
127
+ return false if ratio.abs > TOLERANCE
128
+
129
+ prev_item = br
90
130
  end
91
- @active
131
+ true
132
+ end
133
+
134
+ def build_lines(final_node)
135
+ lines = []
136
+ prev_item = 0
137
+ reconstruct_breaks(final_node).each_with_index do |br, line_idx|
138
+ width = line_width_for(line_idx)
139
+ placed = build_placed_runs(prev_item, br)
140
+ lines << Line.new(placed, width: compute_line_width(prev_item, br),
141
+ max_width: width, align: @align)
142
+ prev_item = br
143
+ end
144
+ lines
92
145
  end
93
146
 
94
147
  def feasible_break?(i)
@@ -96,33 +149,37 @@ module Arrolio
96
149
  (item.penalty? && !item.no_break?) || (item.glue? && i.positive? && @items[i - 1].box?)
97
150
  end
98
151
 
99
- def prune?(node, _current)
100
- node.line >= @line_widths.length + 10
101
- end
102
-
103
152
  def try_break(node, break_pos)
104
153
  item = @items[break_pos]
105
154
  width = line_width_for(node.line)
106
- line_w = compute_line_width(node.position + 1, break_pos)
155
+ line_start = node.position + 1
156
+ line_w = compute_line_width(line_start, break_pos)
107
157
 
108
158
  is_forced = item.penalty? && item.forced_break?
109
- ratio = adjustment_ratio(line_w, width, break_pos)
159
+ ratio = adjustment_ratio(line_w, width, line_start, break_pos)
110
160
 
111
161
  # Forced breaks are always accepted but still penalized
112
162
  # 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
163
+ # TeX semantics: shrink is a hard limit (ratio < -1 needs
164
+ # more compression than the glue has - an overfull box),
165
+ # stretch is soft up to TOLERANCE. Allowing ratio < -1
166
+ # made the DP prefer overfull compression over loose
167
+ # lines (clamped badness scores compression cheaper).
168
+ return nil if !is_forced && (ratio > @stretch_limit || ratio < -1.0)
169
+
170
+ ratio = 0.0 if ratio.infinite?
118
171
  demerits = demerits_for(ratio, break_pos)
172
+ # A forced break is always TAKEN (correctness) but an
173
+ # overfull line through it is a last resort: without this,
174
+ # free shrink made the DP pack whole paragraphs onto one
175
+ # line via the un-rejectable final penalty.
176
+ demerits += OVERFULL_FORCED_PENALTY if is_forced && ratio < -1.0
119
177
  total = node.total_demerits + demerits
120
- fitness = fitness_class(ratio)
121
178
 
122
179
  Node.new(
123
180
  position: break_pos,
124
181
  line: node.line + 1,
125
- fitness: fitness,
182
+ fitness: fitness_class(ratio),
126
183
  total_demerits: total,
127
184
  previous: node
128
185
  )
@@ -137,10 +194,11 @@ module Arrolio
137
194
  end
138
195
 
139
196
  # 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)
197
+ # (negative) a line is. 0 = perfect fit. Emergency stretch
198
+ # widens the feasibility window without changing shrink.
199
+ def adjustment_ratio(natural_width, target_width, line_start, line_end)
200
+ glue_stretch = line_stretch(line_start, line_end) + @emergency_stretch
201
+ glue_shrink = line_shrink(line_start, line_end)
144
202
 
145
203
  if natural_width < target_width
146
204
  glue_stretch.positive? ? (target_width - natural_width) / glue_stretch : Infinity
@@ -153,10 +211,14 @@ module Arrolio
153
211
 
154
212
  def demerits_for(ratio, break_pos)
155
213
  penalty = @items[break_pos].penalty? ? @items[break_pos].penalty : 0
214
+ # FOP justifies every line to the measure after breaking:
215
+ # shrink (ratio < 0) is free, unlike TeX's cubic badness.
216
+ # Penalizing it made the DP prefer an extra loose line
217
+ # where the reference compresses (5.6's 4-line vs our 5).
156
218
  badness = if ratio.negative?
157
- (100 * ((-ratio) ** 3)).to_i
158
- else
159
- 0
219
+ 0
220
+ else
221
+ (100 * (ratio ** 3)).clamp(0, 10_000).to_i
160
222
  end
161
223
  # Standard Knuth-Plass demerits. Forced breaks (penalty =
162
224
  # -Infinity) use d = (1 + badness)^2 — the penalty term
@@ -181,21 +243,25 @@ module Arrolio
181
243
  end
182
244
  end
183
245
 
246
+ # Prefix sums so range width/stretch/shrink queries are O(1).
247
+ def build_prefix_sums
248
+ @prefix_width = [0.0]
249
+ @prefix_stretch = [0.0]
250
+ @prefix_shrink = [0.0]
251
+ @items.each do |item|
252
+ @prefix_width << (@prefix_width.last + item.width.to_f)
253
+ @prefix_stretch << (@prefix_stretch.last + (item.glue? ? item.stretch : 0.0))
254
+ @prefix_shrink << (@prefix_shrink.last + (item.glue? ? item.shrink : 0.0))
255
+ end
256
+ end
257
+
184
258
  # Sum of widths from item +start+ to item +stop+ (exclusive).
185
259
  # Discards trailing glue at the break point.
186
260
  def compute_line_width(start, stop)
187
261
  return 0.0 if start >= stop
188
262
 
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
263
+ last_box = last_box_index_in(start, stop)
264
+ last_box < start ? 0.0 : @prefix_width[last_box + 1] - @prefix_width[start]
199
265
  end
200
266
 
201
267
  def last_box_index_in(start, stop)
@@ -206,16 +272,12 @@ module Arrolio
206
272
  idx
207
273
  end
208
274
 
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
275
+ def line_stretch(line_start, line_end)
276
+ @prefix_stretch[line_end] - @prefix_stretch[line_start]
213
277
  end
214
278
 
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
279
+ def line_shrink(line_start, line_end)
280
+ @prefix_shrink[line_end] - @prefix_shrink[line_start]
219
281
  end
220
282
 
221
283
  def line_width_for(line_idx)
@@ -232,32 +294,94 @@ module Arrolio
232
294
  breaks.reverse
233
295
  end
234
296
 
235
- def build_placed_runs(start_item, stop_item, _width)
297
+ # Merges contiguous Box + Glue items into PlacedRuns — one
298
+ # canvas.text call per group — so pdftotext sees word
299
+ # separators instead of glued-together Tj fragments. A new
300
+ # group starts whenever the rendering signature (style,
301
+ # baseline shift, scale, href) changes, so bold/italic and
302
+ # sub/superscript runs keep their formatting. Glue past the
303
+ # line's last box (at the break) is dropped: it is discarded
304
+ # by width accounting and would otherwise inflate the
305
+ # renderer's justify word count.
306
+ # Chunk widths follow the emission chunking (text split
307
+ # AFTER whitespace): a chunk's width is its boxes plus the
308
+ # trailing glue(s). Hyphen parts join the same chunk (no
309
+ # space between them).
310
+ def build_placed_runs(start_item, stop_item)
311
+ last_box = last_box_index_in(start_item, stop_item)
236
312
  placed = []
237
- x_offset = 0.0
313
+ group = nil
314
+ open_w = 0.0
315
+ close_pending = false
316
+
238
317
  (start_item...stop_item).each do |i|
239
318
  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
319
+ next unless item
320
+ next if item.glue? && item.stretch.infinite?
321
+
322
+ if item.box?
323
+ run = @runs[item.run_index]
324
+ next unless run
325
+
326
+ slice = run.text[item.char_offset, item.char_length]
327
+ next if slice.nil? || slice.empty?
328
+
329
+ signature = [run.style, run.baseline_shift,
330
+ run.font_size_scale, run.href]
331
+ if group.nil? || group.signature != signature
332
+ flush_placed_group(placed, group) if group
333
+ group = RunGroup.new(signature: signature, text: String.new,
334
+ x_offset: item_offset(first_box_in(start_item, stop_item), i),
335
+ chunk_widths: [])
336
+ open_w = 0.0
337
+ close_pending = false
338
+ end
339
+ if close_pending
340
+ group.chunk_widths << open_w
341
+ open_w = 0.0
342
+ close_pending = false
343
+ end
344
+ group.text << slice
345
+ open_w += item.width.to_f
346
+ elsif item.glue? && group && i < last_box
347
+ group.text << ' '
348
+ open_w += item.width.to_f
349
+ close_pending = true
350
+ end
351
+ end
352
+ if group
353
+ group.chunk_widths << open_w if close_pending || open_w.positive?
354
+ flush_placed_group(placed, group)
254
355
  end
255
356
  placed
256
357
  end
257
358
 
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)
359
+ # Offsets are relative to the line's FIRST BOX, not the
360
+ # break-following item: the leading glue is discarded at a
361
+ # break, and pricing it shifted every continuation line
362
+ # right by one space width (a phantom indent that also
363
+ # shortened the measure and caused extra wraps).
364
+ def first_box_in(start_item, stop_item)
365
+ (start_item...stop_item).each do |i|
366
+ return i if @items[i]&.box?
367
+ end
368
+ start_item
369
+ end
370
+
371
+ def item_offset(start_item, item_idx)
372
+ @prefix_width[item_idx] - @prefix_width[start_item]
373
+ end
374
+
375
+ def flush_placed_group(placed, group)
376
+ return if group.text.empty?
377
+
378
+ style, baseline_shift, font_size_scale, href = group.signature
379
+ run = InlineRun.new(group.text, style: style,
380
+ baseline_shift: baseline_shift,
381
+ font_size_scale: font_size_scale,
382
+ href: href)
383
+ placed << Line::PlacedRun.new(run: run, x_offset: group.x_offset,
384
+ chunk_widths: group.chunk_widths)
261
385
  end
262
386
  end
263
387
  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
@@ -5,7 +5,13 @@ module Arrolio
5
5
  # One laid-out line: a list of PlacedRun tuples plus the
6
6
  # line's content width, target width, and alignment.
7
7
  class Line
8
- PlacedRun = Struct.new(:run, :x_offset, keyword_init: true) do
8
+ # +chunk_widths+ carries the width of each emission chunk
9
+ # (text split after whitespace), computed by the BREAKER that
10
+ # owns glyph measurement. The renderer advances by these
11
+ # widths — it never measures, so layout and render cannot
12
+ # drift apart (the source of the word-overlap and
13
+ # measure-overflow bugs).
14
+ PlacedRun = Struct.new(:run, :x_offset, :chunk_widths, keyword_init: true) do
9
15
  def freeze
10
16
  run.freeze
11
17
  super
@@ -38,9 +44,11 @@ module Arrolio
38
44
  @align == :justify
39
45
  end
40
46
 
47
+ # Negative when the line is overfull (TeX shrink): the
48
+ # renderer compresses inter-word gaps. Clamping at zero made
49
+ # compressed lines draw at natural width, past the measure.
41
50
  def justify_stretch
42
51
  return 0.0 unless justified?
43
- return 0.0 if @width >= @max_width
44
52
 
45
53
  gaps = word_gap_count
46
54
  return 0.0 if gaps.zero?
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Arrolio
4
- VERSION = '0.1.0'
4
+ VERSION = '0.1.2'
5
5
  end
data/lib/arrolio.rb CHANGED
@@ -1,12 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'pdfrb'
4
+ require 'pdfrb/content/operators'
5
+ require 'mml'
4
6
 
5
7
  # Arrolio -- a flavor-agnostic paged-media layout engine.
6
- # Core knows NOTHING about specific document formats (OIML,
7
- # ISO, IEC, BSI). Flavors are loaded on demand:
8
- # require 'arroolio'
9
- # require 'arrolio/oiml' # Load the OIML flavor
8
+ # Core knows NOTHING about specific document formats. Each flavor
9
+ # lives in its own directory (XSL + 3 generated YAML files) outside
10
+ # the gem and is rendered via Arrolio::ConfigDrivenPipeline.
10
11
  module Arrolio
11
12
  autoload :VERSION, 'arrolio/version'
12
13
  autoload :Error, 'arrolio/error'
@@ -36,7 +37,10 @@ module Arrolio
36
37
  autoload :AssetResolver, 'arrolio/asset_resolver'
37
38
  autoload :GenericAdapter, 'arrolio/generic_adapter'
38
39
  autoload :GenericFlowBuilder, 'arrolio/generic_flow_builder'
40
+ autoload :EmbeddedBlockExtractor, 'arrolio/embedded_block_extractor'
39
41
  autoload :ConfigDrivenPipeline, 'arrolio/config_driven_pipeline'
40
42
  autoload :TocBuilder, 'arrolio/toc_builder'
43
+ autoload :FontScanner, 'arrolio/font_scanner'
41
44
  autoload :Composer, 'arrolio/composer'
45
+ autoload :MathML, 'arrolio/math_ml'
42
46
  end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'arrolio'
4
+ require 'fileutils'
5
+
6
+ module ParityCheck
7
+ REFERENCE = File.expand_path('~/src/mn/mn-samples-oiml/_site/documents/r060/1/document.pdf')
8
+ FIXTURE = File.expand_path('~/src/mn/mn-samples-oiml/_site/documents/r060/1/document.presentation.xml')
9
+ SOURCE_DIR = File.expand_path('~/src/mn/mn-samples-oiml/sources/r060/1')
10
+ FLAVOR_DIR = File.expand_path('../flavors/oiml', __dir__)
11
+ OUTPUT = File.expand_path('../tmp/parity_check.pdf', __dir__)
12
+
13
+ def self.run
14
+ unless File.exist?(REFERENCE) && File.exist?(FIXTURE)
15
+ warn 'OIML fixture or reference PDF not found. Skipping parity check.'
16
+ warn " Reference: #{REFERENCE}" unless File.exist?(REFERENCE)
17
+ warn " Fixture: #{FIXTURE}" unless File.exist?(FIXTURE)
18
+ return
19
+ end
20
+
21
+ FileUtils.mkdir_p(File.dirname(OUTPUT))
22
+ xml = File.read(FIXTURE)
23
+ Arrolio::ConfigDrivenPipeline.render(
24
+ xml,
25
+ io: OUTPUT,
26
+ flavor_dir: FLAVOR_DIR,
27
+ input_path: FIXTURE,
28
+ extra_image_dirs: [SOURCE_DIR]
29
+ )
30
+
31
+ require 'arrolio/harness'
32
+ diff = Arrolio::Harness::PdfDiff.new(OUTPUT, REFERENCE)
33
+ result = diff.call
34
+
35
+ puts "Overall similarity: #{(result.overall_similarity * 100).round(2)}%"
36
+ puts "Our pages: #{result.ours_pages}, Reference pages: #{result.theirs_pages}"
37
+ puts ''
38
+ result.per_page.each do |page|
39
+ pct = (page.similarity * 100).round(1)
40
+ status = if page.similarity > 0.5 then 'OK'
41
+ elsif page.similarity > 0.1 then 'PARTIAL'
42
+ else 'LOW'
43
+ end
44
+ puts " Page #{page.number}: #{pct}% [#{status}]"
45
+ end
46
+ puts ''
47
+ puts 'Target: > 50% overall similarity'
48
+ result.overall_similarity
49
+ end
50
+ end
51
+
52
+ ParityCheck.run