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.
- 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.rb +362 -62
- 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.rb +131 -32
- 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/knuth_plass/breaker.rb +194 -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 +3 -1
- 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 +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
|
|
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
|
-
@
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
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
|
|
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
|
@@ -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,85 @@ 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, 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
|
|
81
|
-
@
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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:
|
|
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
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
210
|
-
|
|
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
|
|
216
|
-
|
|
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
|
-
|
|
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
|
-
|
|
308
|
+
group = nil
|
|
309
|
+
|
|
238
310
|
(start_item...stop_item).each do |i|
|
|
239
311
|
item = @items[i]
|
|
240
|
-
next unless item
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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
|
-
|
|
259
|
-
|
|
260
|
-
|
|
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?
|