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
data/lib/arrolio/renderer/pdf.rb
CHANGED
|
@@ -12,6 +12,15 @@ module Arrolio
|
|
|
12
12
|
# subset + embed each font for the codepoints actually used
|
|
13
13
|
# in the document.
|
|
14
14
|
class Pdf
|
|
15
|
+
|
|
16
|
+
autoload :FontEmbedding, 'arrolio/renderer/pdf/font_embedding'
|
|
17
|
+
autoload :Assets, 'arrolio/renderer/pdf/assets'
|
|
18
|
+
autoload :Metadata, 'arrolio/renderer/pdf/metadata'
|
|
19
|
+
|
|
20
|
+
include FontEmbedding
|
|
21
|
+
include Assets
|
|
22
|
+
include Metadata
|
|
23
|
+
|
|
15
24
|
MM_TO_PT = 2.83464567
|
|
16
25
|
|
|
17
26
|
attr_reader :document, :fonts
|
|
@@ -41,83 +50,6 @@ module Arrolio
|
|
|
41
50
|
io: io.is_a?(String) ? nil : io)
|
|
42
51
|
end
|
|
43
52
|
|
|
44
|
-
def apply_metadata(metadata)
|
|
45
|
-
info = @document.catalog.value[:Info]
|
|
46
|
-
info ||= @document.add({})
|
|
47
|
-
@document.catalog.value[:Info] = info
|
|
48
|
-
info.value[:Title] = metadata[:title] if metadata[:title]
|
|
49
|
-
info.value[:Author] = metadata[:author] if metadata[:author]
|
|
50
|
-
info.value[:Creator] = 'Arrolio (Ruby)'
|
|
51
|
-
info.value[:Producer] = 'Arrolio + Pdfrb'
|
|
52
|
-
end
|
|
53
|
-
|
|
54
|
-
# Emit an XMP metadata packet (RDF/XML) as a stream on the
|
|
55
|
-
# catalog's /Metadata entry. PDF readers use this for Dublin
|
|
56
|
-
# Core properties alongside the legacy /Info dict.
|
|
57
|
-
def attach_xmp_metadata(metadata)
|
|
58
|
-
xmp = XmpBuilder.new(metadata).build
|
|
59
|
-
stream = @document.add(
|
|
60
|
-
{ Type: :Metadata, Subtype: :XML, Length: xmp.bytesize },
|
|
61
|
-
type: Pdfrb::Model::Cos::Stream
|
|
62
|
-
)
|
|
63
|
-
stream.stream = xmp
|
|
64
|
-
@document.catalog.value[:Metadata] =
|
|
65
|
-
Pdfrb::Model::Reference.new(stream.oid, stream.gen)
|
|
66
|
-
rescue StandardError => e
|
|
67
|
-
Arrolio::Logger.warn "XMP attach failed: #{e.class}: #{e.message[0, 80]}"
|
|
68
|
-
end
|
|
69
|
-
|
|
70
|
-
def register_logo(path)
|
|
71
|
-
return nil unless path
|
|
72
|
-
|
|
73
|
-
@document.images.add(path)
|
|
74
|
-
rescue StandardError => e
|
|
75
|
-
Arrolio::Logger.warn "logo load failed: #{e.class}: #{e.message[0, 80]}"
|
|
76
|
-
nil
|
|
77
|
-
end
|
|
78
|
-
|
|
79
|
-
def register_image(path)
|
|
80
|
-
return @images[path] if @images.key?(path)
|
|
81
|
-
|
|
82
|
-
resolved = resolve_image_for_pdfrb(path)
|
|
83
|
-
return nil unless resolved
|
|
84
|
-
|
|
85
|
-
@images[path] = @document.images.add(resolved)
|
|
86
|
-
rescue StandardError => e
|
|
87
|
-
Arrolio::Logger.warn "register_image failed for #{path}: #{e.class}: #{e.message[0, 80]}"
|
|
88
|
-
nil
|
|
89
|
-
end
|
|
90
|
-
|
|
91
|
-
# Pdfrb supports JPEG, PNG, and PDF — not SVG. If +path+ is an
|
|
92
|
-
# SVG file, rasterize it to PNG via rsvg-convert and cache the
|
|
93
|
-
# result. Returns the path to a pdfrb-compatible image file.
|
|
94
|
-
def resolve_image_for_pdfrb(path)
|
|
95
|
-
return path unless path.to_s.end_with?('.svg', '.SVG')
|
|
96
|
-
return path unless File.exist?(path)
|
|
97
|
-
|
|
98
|
-
png_path = svg_to_png_cache_path(path)
|
|
99
|
-
return png_path if File.exist?(png_path)
|
|
100
|
-
|
|
101
|
-
rasterize_svg(path, png_path)
|
|
102
|
-
File.exist?(png_path) ? png_path : path
|
|
103
|
-
end
|
|
104
|
-
|
|
105
|
-
def svg_to_png_cache_path(svg_path)
|
|
106
|
-
digest = Digest::MD5.file(svg_path).hexdigest
|
|
107
|
-
File.join(Dir.tmpdir, 'arrolio-svg-' + digest + '.png')
|
|
108
|
-
end
|
|
109
|
-
|
|
110
|
-
def rasterize_svg(svg_path, png_path)
|
|
111
|
-
require 'open3'
|
|
112
|
-
result = Open3.capture3('rsvg-convert', '-d', '96', '-p', '96',
|
|
113
|
-
'-o', png_path, svg_path)
|
|
114
|
-
return if result[2].success?
|
|
115
|
-
|
|
116
|
-
Arrolio::Logger.warn "rsvg-convert failed: #{result[1]}"
|
|
117
|
-
rescue StandardError => e
|
|
118
|
-
Arrolio::Logger.warn "rasterize_svg failed: #{e.class}: #{e.message[0, 80]}"
|
|
119
|
-
end
|
|
120
|
-
|
|
121
53
|
def header_footer_style
|
|
122
54
|
return @header_footer_style if @header_footer_style
|
|
123
55
|
|
|
@@ -134,102 +66,12 @@ module Arrolio
|
|
|
134
66
|
}.freeze
|
|
135
67
|
end
|
|
136
68
|
|
|
137
|
-
def cover_logo_style
|
|
138
|
-
return @cover_logo_style if @cover_logo_style
|
|
139
|
-
|
|
140
|
-
config = @layout_spec&.cover_logo_config || {}
|
|
141
|
-
width_mm = config['width_mm'] || 35.0
|
|
142
|
-
ratio = config['aspect_ratio'] || (1459.0 / 1667.0)
|
|
143
|
-
margin_mm = config['margin_mm'] || 25.5
|
|
144
|
-
@cover_logo_style = {
|
|
145
|
-
width: width_mm * MM_TO_PT,
|
|
146
|
-
height: width_mm * ratio * MM_TO_PT,
|
|
147
|
-
margin: margin_mm * MM_TO_PT
|
|
148
|
-
}.freeze
|
|
149
|
-
end
|
|
150
69
|
|
|
151
70
|
private
|
|
152
71
|
|
|
153
|
-
# Pre-scan all pages, collect codepoints per font family,
|
|
154
|
-
# then call Font::Embedder for each font_path.
|
|
155
|
-
def prepare_embedded_fonts(pages)
|
|
156
|
-
return if @font_paths.empty?
|
|
157
|
-
|
|
158
|
-
codepoints = Hash.new { |h, k| h[k] = [] }
|
|
159
|
-
pages.each { |page| collect_codepoints(page, codepoints) }
|
|
160
|
-
Arrolio::Logger.debug "collected codepoints for fonts: #{codepoints.keys.inspect}"
|
|
161
|
-
@font_paths.each do |font_name, path|
|
|
162
|
-
exists = File.exist?(path)
|
|
163
|
-
has_cp = codepoints.key?(font_name)
|
|
164
|
-
cp_count = codepoints[font_name]&.length || 0
|
|
165
|
-
Arrolio::Logger.debug "font #{font_name}: exists=#{exists} cp=#{cp_count}"
|
|
166
|
-
next unless exists
|
|
167
|
-
next unless has_cp
|
|
168
|
-
|
|
169
|
-
cps = codepoints[font_name].uniq
|
|
170
|
-
embedder = Font::Embedder.new(@document, path,
|
|
171
|
-
base_font_name: font_name)
|
|
172
|
-
ref = embedder.embed(cps)
|
|
173
|
-
attach_to_resources(font_name, ref)
|
|
174
|
-
@font_embedders[font_name] = embedder
|
|
175
|
-
@font_encoders[font_name] = Font::TextEncoder.new(embedder)
|
|
176
|
-
@font_refs[font_name] = ref
|
|
177
|
-
rescue StandardError => e
|
|
178
|
-
Arrolio::Logger.warn "embed failed for #{font_name}: #{e.class}: #{e.message[0,100]}"
|
|
179
|
-
Arrolio::Logger.debug e.backtrace.first(5).join("\n")
|
|
180
|
-
end
|
|
181
|
-
end
|
|
182
72
|
|
|
183
|
-
def attach_to_resources(font_name, ref)
|
|
184
|
-
catalog = @document.catalog
|
|
185
|
-
res = catalog.value[:Resources]
|
|
186
|
-
Arrolio::Logger.debug "attach_to_resources: Resources=#{res.class}"
|
|
187
|
-
unless res.is_a?(Hash) && res[:Font].is_a?(Hash)
|
|
188
|
-
catalog.value[:Resources] = { Font: {} }
|
|
189
|
-
end
|
|
190
|
-
font_hash = catalog.value[:Resources][:Font]
|
|
191
|
-
key = (format('EF%d', (font_hash.length + 1))).to_sym
|
|
192
|
-
font_hash[key] = ref
|
|
193
|
-
@embedded_resource_keys ||= {}
|
|
194
|
-
@embedded_resource_keys[font_name] = key
|
|
195
|
-
end
|
|
196
73
|
|
|
197
|
-
def collect_codepoints(page, by_font)
|
|
198
|
-
(page.static_regions.values + page.regions.values).each do |region|
|
|
199
|
-
region.placed_boxes.each do |box|
|
|
200
|
-
next unless box.kind == :text
|
|
201
|
-
next unless box.data.is_a?(Hash) && box.data[:lines]
|
|
202
|
-
|
|
203
|
-
box.data[:lines].each do |line|
|
|
204
|
-
next unless line.is_a?(TextLayout::Line)
|
|
205
|
-
line.placed_runs.each do |pr|
|
|
206
|
-
next unless pr.run.is_a?(InlineRun)
|
|
207
|
-
# Collect under the RUN'S font name, not the
|
|
208
|
-
# paragraph's. Italic/bold runs have different
|
|
209
|
-
# font_names and need their own subsets.
|
|
210
|
-
run_font = pr.run.style.font_name
|
|
211
|
-
next unless @font_paths.key?(run_font)
|
|
212
|
-
pr.run.text.each_codepoint { |cp| by_font[run_font] << cp }
|
|
213
|
-
end
|
|
214
|
-
end
|
|
215
|
-
end
|
|
216
|
-
end
|
|
217
|
-
end
|
|
218
74
|
|
|
219
|
-
def build_outline(context, _pages)
|
|
220
|
-
entries = context.heading_entries
|
|
221
|
-
return unless entries&.any?
|
|
222
|
-
|
|
223
|
-
pdfrb_pages = @document.pages.to_a
|
|
224
|
-
ob = OutlineBuilder.new(document: @document,
|
|
225
|
-
entries: entries,
|
|
226
|
-
pdfrb_pages: pdfrb_pages)
|
|
227
|
-
result = ob.build
|
|
228
|
-
Arrolio::Logger.debug "outline build returned: #{result.class}"
|
|
229
|
-
rescue StandardError => e
|
|
230
|
-
Arrolio::Logger.warn "outline build failed: #{e.class}: #{e.message[0,150]}"
|
|
231
|
-
Arrolio::Logger.debug e.backtrace.first(5).join("\n")
|
|
232
|
-
end
|
|
233
75
|
|
|
234
76
|
def render_page(output_page)
|
|
235
77
|
pdfrb_page = @document.pages.add(media_box: [0, 0,
|
|
@@ -240,6 +82,7 @@ module Arrolio
|
|
|
240
82
|
pdfrb_page.value[:Resources] = catalog.value[:Resources]
|
|
241
83
|
canvas = pdfrb_page.canvas
|
|
242
84
|
render_cover_logo(canvas, output_page) if output_page.template_role == :cover && @logo_ref
|
|
85
|
+
render_page_title(canvas, output_page) if output_page.title_text
|
|
243
86
|
render_header_footer(canvas, output_page) unless output_page.template_role == :cover
|
|
244
87
|
render_page_footnotes(canvas, output_page) unless output_page.template_role == :cover
|
|
245
88
|
(output_page.static_regions.values + output_page.regions.values).each do |region|
|
|
@@ -248,44 +91,35 @@ module Arrolio
|
|
|
248
91
|
@link_annotator.flush(pdfrb_page)
|
|
249
92
|
end
|
|
250
93
|
|
|
94
|
+
# Footnote bodies occupy the zone the engine reserved at the
|
|
95
|
+
# page bottom. The layout comes from
|
|
96
|
+
# FootnoteMarkerFlowable.body_flowable - the same policy the
|
|
97
|
+
# engine used to reserve the space - so the drawn block fits
|
|
98
|
+
# the reserved zone exactly.
|
|
251
99
|
def render_page_footnotes(canvas, page)
|
|
252
|
-
return
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
page.
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
y -= 9.0
|
|
100
|
+
return if page.footnotes.nil? || page.footnotes.empty?
|
|
101
|
+
|
|
102
|
+
style = footnote_style
|
|
103
|
+
body = page.body_region
|
|
104
|
+
width = body ? body.width : page.width * 0.85
|
|
105
|
+
x = body ? body.x : page.width * 0.075
|
|
106
|
+
flowables = page.footnotes.map do |footnote|
|
|
107
|
+
Flowables::FootnoteMarkerFlowable.body_flowable(footnote, style)
|
|
108
|
+
end
|
|
109
|
+
total = flowables.sum { |flow| flow.height(width) }
|
|
110
|
+
y = header_footer_style[:margin_bottom] + 20 + total
|
|
111
|
+
flowables.each do |flow|
|
|
112
|
+
boxes, = flow.emit(x, y, width, nil)
|
|
113
|
+
boxes.each { |box| render_box(canvas, box) }
|
|
114
|
+
y -= flow.height(width)
|
|
268
115
|
end
|
|
269
116
|
rescue StandardError => e
|
|
270
117
|
Arrolio::Logger.warn "footnote render failed: #{e.class}: #{e.message[0, 80]}"
|
|
271
118
|
end
|
|
272
119
|
|
|
273
|
-
def
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
h = logo_style[:height]
|
|
277
|
-
margin = logo_style[:margin]
|
|
278
|
-
x = page.width - margin - w
|
|
279
|
-
y = page.height - margin - h
|
|
280
|
-
invoke = invoke_xobject_op
|
|
281
|
-
return unless invoke
|
|
282
|
-
|
|
283
|
-
canvas.save_graphics_state do
|
|
284
|
-
canvas.concat(w, 0, 0, h, x, y)
|
|
285
|
-
canvas.emit_op(invoke, @logo_ref)
|
|
286
|
-
end
|
|
287
|
-
rescue StandardError => e
|
|
288
|
-
Arrolio::Logger.warn "logo render failed: #{e.class}: #{e.message[0, 80]}"
|
|
120
|
+
def footnote_style
|
|
121
|
+
style = @layout_spec&.resolve_style(:footnote)
|
|
122
|
+
style.nil? || style.font_size.nil? ? Style::Definition.new(font_size: 9.0) : style
|
|
289
123
|
end
|
|
290
124
|
|
|
291
125
|
def invoke_xobject_op
|
|
@@ -296,6 +130,24 @@ module Arrolio
|
|
|
296
130
|
end
|
|
297
131
|
end
|
|
298
132
|
|
|
133
|
+
def render_page_title(canvas, page)
|
|
134
|
+
header_footer_style
|
|
135
|
+
body = page.regions[:body]
|
|
136
|
+
return unless body
|
|
137
|
+
|
|
138
|
+
font_name = 'Times New Roman Bold'
|
|
139
|
+
size = 18.0
|
|
140
|
+
measurer = GlyphMeasurer.new(font_name: font_name)
|
|
141
|
+
tw = measurer.width_of_string(page.title_text, font_size: size)
|
|
142
|
+
x = (page.width - tw) / 2.0
|
|
143
|
+
baseline = body.y + body.height - 9.0
|
|
144
|
+
ref = embedded_or_standard(font_name)
|
|
145
|
+
payload = encoded_or_text(font_name, page.title_text)
|
|
146
|
+
canvas.text(payload, at: [x, baseline], font: ref, size: size)
|
|
147
|
+
rescue StandardError => e
|
|
148
|
+
Arrolio::Logger.warn "render_page_title failed: #{e.message[0, 80]}"
|
|
149
|
+
end
|
|
150
|
+
|
|
299
151
|
def render_header_footer(canvas, page)
|
|
300
152
|
hf_style = header_footer_style
|
|
301
153
|
margin_top = hf_style[:margin_top]
|
|
@@ -344,48 +196,121 @@ module Arrolio
|
|
|
344
196
|
when :rect then render_rect(canvas, box)
|
|
345
197
|
when :line then render_line(canvas, box)
|
|
346
198
|
when :image then render_image(canvas, box)
|
|
199
|
+
when :rotated_text then render_rotated_text(canvas, box)
|
|
347
200
|
end
|
|
348
201
|
end
|
|
349
202
|
|
|
203
|
+
def render_rotated_text(canvas, box)
|
|
204
|
+
text = box.data[:text]
|
|
205
|
+
style = box.data[:style]
|
|
206
|
+
angle = box.data[:angle].to_f
|
|
207
|
+
line_height = box.data[:line_height].to_f
|
|
208
|
+
font_name = style.font_name
|
|
209
|
+
ref = embedded_or_standard(font_name)
|
|
210
|
+
payload = encoded_or_text(font_name, text)
|
|
211
|
+
rad = angle * Math::PI / 180.0
|
|
212
|
+
cos = Math.cos(rad)
|
|
213
|
+
sin = Math.sin(rad)
|
|
214
|
+
baseline_y = box.y + line_height - GlyphMeasurer.new(font_name: font_name)
|
|
215
|
+
.ascender(font_size: style.font_size)
|
|
216
|
+
# PDF text matrix is [a b c d e f] where (a,d) scale, (b,c) rotate,
|
|
217
|
+
# (e,f) translate. We rotate around the baseline starting point.
|
|
218
|
+
canvas.text(payload, at: [box.x, baseline_y], font: ref, size: style.font_size,
|
|
219
|
+
tm: [cos, sin, -sin, cos, box.x, baseline_y])
|
|
220
|
+
rescue StandardError => e
|
|
221
|
+
Arrolio::Logger.warn "render_rotated_text failed: #{e.message[0, 80]}"
|
|
222
|
+
end
|
|
223
|
+
|
|
350
224
|
def render_text(canvas, box)
|
|
351
225
|
lines = box.data[:lines]
|
|
352
226
|
line_height = box.data[:line_height]
|
|
353
227
|
style = box.data[:style]
|
|
228
|
+
hanging_indent = box.data[:hanging_indent] || 0.0
|
|
354
229
|
measurer = GlyphMeasurer.new(font_name: style.font_name)
|
|
355
230
|
ascender = measurer.ascender(font_size: style.font_size)
|
|
356
231
|
x_origin = box.x
|
|
357
232
|
y_top = box.y + box.height
|
|
358
233
|
|
|
234
|
+
heights = box.data[:line_heights] ||
|
|
235
|
+
Array.new(lines.length, line_height)
|
|
236
|
+
offset = 0.0
|
|
359
237
|
lines.each_with_index do |line, idx|
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
238
|
+
# A math line's slot is taller than a text line; the
|
|
239
|
+
# reference draws the stacked expression at the BOTTOM of
|
|
240
|
+
# its slot (the text-level parens stay at the line top).
|
|
241
|
+
drop = heights[idx] - line_height
|
|
242
|
+
baseline_y = y_top - offset - ascender - drop
|
|
243
|
+
indent = idx.zero? ? 0 : hanging_indent
|
|
244
|
+
line_x = x_origin + indent + line.x_offset
|
|
245
|
+
render_line_runs(canvas, line, line_x, baseline_y, style,
|
|
246
|
+
last_line: idx == lines.length - 1)
|
|
247
|
+
offset += heights[idx]
|
|
363
248
|
end
|
|
364
249
|
end
|
|
365
250
|
|
|
366
|
-
def render_line_runs(canvas, line, line_x, baseline_y, _paragraph_style)
|
|
367
|
-
justify = line.justified? ? line.justify_stretch : 0.0
|
|
251
|
+
def render_line_runs(canvas, line, line_x, baseline_y, _paragraph_style, last_line: false)
|
|
252
|
+
justify = line.justified? && !last_line ? line.justify_stretch : 0.0
|
|
368
253
|
extra_offset = 0.0
|
|
369
254
|
line.placed_runs.each do |pr|
|
|
370
255
|
run = pr.run
|
|
371
|
-
|
|
256
|
+
run_x = line_x + pr.x_offset
|
|
372
257
|
font_name = run.style.font_name
|
|
373
258
|
ref = embedded_or_standard(font_name)
|
|
374
|
-
payload = encoded_or_text(font_name, run.text)
|
|
375
259
|
y, size = baseline_position_for(run, baseline_y)
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
260
|
+
cursor = 0.0
|
|
261
|
+
run_start = nil
|
|
262
|
+
text_chunks(run.text, justify).each_with_index do |chunk, ci|
|
|
263
|
+
x = run_x + extra_offset + cursor
|
|
264
|
+
run_start ||= x
|
|
265
|
+
payload = encoded_or_text(font_name, chunk)
|
|
266
|
+
opts = { at: [x, y], font: ref, size: size }
|
|
267
|
+
if run.style.character_spacing && !run.style.character_spacing.zero?
|
|
268
|
+
opts[:char_spacing] = run.style.character_spacing
|
|
269
|
+
end
|
|
270
|
+
if run.style.word_spacing && !run.style.word_spacing.zero?
|
|
271
|
+
opts[:word_spacing] = run.style.word_spacing
|
|
272
|
+
end
|
|
273
|
+
record_link_if_needed(run, x, y, size) if cursor.zero?
|
|
274
|
+
canvas.text(payload, **opts)
|
|
275
|
+
# Advance by the width the BREAKER computed — the
|
|
276
|
+
# renderer never measures, so layout and emission
|
|
277
|
+
# cannot drift (the word-overlap bug class).
|
|
278
|
+
cursor += pr.chunk_widths[ci].to_f
|
|
279
|
+
# Justify must SHRINK as well as stretch: a compressed
|
|
280
|
+
# line (ratio > -1) relies on negative space adjustment —
|
|
281
|
+
# skipping it draws the line at natural width, past the
|
|
282
|
+
# measure.
|
|
283
|
+
extra_offset += (chunk.count(' ') * justify) unless justify.zero?
|
|
382
284
|
end
|
|
383
|
-
|
|
384
|
-
canvas.text(payload, **opts)
|
|
385
|
-
extra_offset += (run.text.count(' ') * justify) if justify.positive?
|
|
285
|
+
draw_underline(canvas, run, run_start, y, size, cursor) if run.style.underline
|
|
386
286
|
end
|
|
387
287
|
end
|
|
388
288
|
|
|
289
|
+
# PDF word spacing (Tw) does not apply to two-byte CID-encoded
|
|
290
|
+
# embedded fonts, so justification cannot rely on it. Instead a
|
|
291
|
+
# justified line is drawn as one positioned text operation per
|
|
292
|
+
# word — FOP's model — with each word's x carrying the stretch
|
|
293
|
+
# accumulated so far. The separating space stays attached to
|
|
294
|
+
# the preceding word so text extraction still sees word gaps.
|
|
295
|
+
def text_chunks(text, justify)
|
|
296
|
+
return [text] if justify.zero?
|
|
297
|
+
|
|
298
|
+
text.split(/(?<=\s)(?=\S)/)
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
# Draws a thin horizontal rule below a run's baseline when the
|
|
302
|
+
# style's `underline` flag is set. Matches FOP/HTML convention:
|
|
303
|
+
# the rule sits ~1pt below baseline, thickness ~5% of font size.
|
|
304
|
+
def draw_underline(canvas, _run, x, baseline_y, size, width)
|
|
305
|
+
weight = [size * 0.05, 0.5].max
|
|
306
|
+
underline_y = baseline_y - (size * 0.18)
|
|
307
|
+
canvas.line(x, underline_y, x + width, underline_y)
|
|
308
|
+
canvas.line_width = weight
|
|
309
|
+
canvas.stroke
|
|
310
|
+
rescue StandardError => e
|
|
311
|
+
Arrolio::Logger.warn "draw_underline failed: #{e.message[0, 60]}"
|
|
312
|
+
end
|
|
313
|
+
|
|
389
314
|
# Returns [y, font_size] for a run, applying baseline shift and
|
|
390
315
|
# font size scaling for subscript/superscript runs.
|
|
391
316
|
# Subscript: shift down 0.2em, scale 0.7x.
|
|
@@ -9,7 +9,8 @@ module Arrolio
|
|
|
9
9
|
:padding_top, :padding_right, :padding_bottom, :padding_left,
|
|
10
10
|
:character_spacing, :word_spacing, :line_break,
|
|
11
11
|
:keep_together, :page_break_before, :page_break_after,
|
|
12
|
-
:keep_with_next, :widows, :orphans, :parent
|
|
12
|
+
:keep_with_next, :widows, :orphans, :parent,
|
|
13
|
+
:underline, :text_transform
|
|
13
14
|
|
|
14
15
|
DEFAULTS = {
|
|
15
16
|
font_name: 'Helvetica', font_size: 12.0,
|
|
@@ -20,7 +21,8 @@ module Arrolio
|
|
|
20
21
|
character_spacing: 0.0, word_spacing: 0.0,
|
|
21
22
|
line_break: :greedy,
|
|
22
23
|
keep_together: false, page_break_before: false, page_break_after: false,
|
|
23
|
-
keep_with_next: false, widows:
|
|
24
|
+
keep_with_next: false, widows: 1, orphans: 1, parent: nil,
|
|
25
|
+
underline: false, text_transform: nil
|
|
24
26
|
}.freeze
|
|
25
27
|
|
|
26
28
|
def initialize(**opts)
|
|
@@ -50,6 +52,8 @@ module Arrolio
|
|
|
50
52
|
@widows = merged.fetch(:widows).to_i
|
|
51
53
|
@orphans = merged.fetch(:orphans).to_i
|
|
52
54
|
@parent = merged.fetch(:parent)
|
|
55
|
+
@underline = truthy?(merged.fetch(:underline))
|
|
56
|
+
@text_transform = merged.fetch(:text_transform)
|
|
53
57
|
freeze
|
|
54
58
|
end
|
|
55
59
|
|
|
@@ -80,7 +84,7 @@ module Arrolio
|
|
|
80
84
|
line_break: @line_break, keep_together: @keep_together,
|
|
81
85
|
page_break_before: @page_break_before, page_break_after: @page_break_after,
|
|
82
86
|
keep_with_next: @keep_with_next, widows: @widows, orphans: @orphans,
|
|
83
|
-
parent: @parent
|
|
87
|
+
parent: @parent, underline: @underline, text_transform: @text_transform
|
|
84
88
|
}
|
|
85
89
|
end
|
|
86
90
|
|
|
@@ -9,18 +9,21 @@ module Arrolio
|
|
|
9
9
|
|
|
10
10
|
def initialize(overrides = {})
|
|
11
11
|
@overrides = {}
|
|
12
|
+
@resolution_cache = {}
|
|
12
13
|
overrides.each { |name, attrs| register(name, **attrs) }
|
|
13
14
|
freeze
|
|
14
15
|
end
|
|
15
16
|
|
|
16
17
|
def register(name, **opts)
|
|
17
18
|
@overrides[name.to_sym] = opts.transform_keys(&:to_sym)
|
|
19
|
+
@resolution_cache.clear
|
|
18
20
|
name.to_sym
|
|
19
21
|
end
|
|
20
22
|
|
|
21
23
|
def resolve(name, fallback: Definition.new)
|
|
22
24
|
key = name.to_sym
|
|
23
25
|
return fallback unless @overrides.key?(key)
|
|
26
|
+
return @resolution_cache[key] if @resolution_cache.key?(key)
|
|
24
27
|
|
|
25
28
|
chain = []
|
|
26
29
|
cursor = key
|
|
@@ -32,7 +35,9 @@ module Arrolio
|
|
|
32
35
|
end
|
|
33
36
|
merged = chain.reverse_each.reduce({}) { |acc, h| acc.merge(h) }
|
|
34
37
|
merged.delete(:parent)
|
|
35
|
-
Definition.new(**merged)
|
|
38
|
+
result = Definition.new(**merged)
|
|
39
|
+
@resolution_cache[key] = result
|
|
40
|
+
result
|
|
36
41
|
end
|
|
37
42
|
|
|
38
43
|
def names = @overrides.keys
|
|
@@ -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
|