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