arrolio 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +2 -2
  3. data/Rakefile +15 -0
  4. data/TODO.complete/56-generic-adapter-mece-decomposition.md +20 -0
  5. data/TODO.complete/60-adapter-fidelity-footnotes-notes-examples.md +92 -55
  6. data/TODO.complete/70-ttf-font-metrics-parity.md +68 -0
  7. data/TODO.complete/71-cover-layout-parity.md +45 -0
  8. data/TODO.complete/72-table-layout-parity.md +49 -0
  9. data/TODO.complete/73-toc-leaders-parity.md +55 -0
  10. data/TODO.complete/74-svg-image-rendering-parity.md +76 -0
  11. data/TODO.complete/75-mathml-formula-rendering-parity.md +52 -0
  12. data/TODO.complete/76-odd-even-page-templates-parity.md +50 -0
  13. data/TODO.complete/77-text-formatting-parity.md +55 -0
  14. data/TODO.complete/78-bibliography-formatting-parity.md +62 -0
  15. data/TODO.complete/79-line-breaking-parity.md +80 -0
  16. data/TODO.complete/80-parity-diff-harness-in-ci.md +68 -0
  17. data/TODO.complete/81-conditional-style-refinements.md +70 -0
  18. data/TODO.complete/82-semantic-presentation-dedup.md +65 -0
  19. data/TODO.complete/83-nested-list-support.md +74 -0
  20. data/TODO.complete/84-figure-svg-rendering.md +70 -0
  21. data/TODO.complete/85-locality-reference-formatting.md +48 -0
  22. data/TODO.complete/86-list-item-text-overflow.md +80 -0
  23. data/TODO.complete/87-odd-even-page-templates.md +55 -0
  24. data/TODO.complete/88-hyphenation-support.md +52 -0
  25. data/TODO.complete/89-table-rowspan.md +49 -0
  26. data/TODO.complete/90-mathml-stacked-layout.md +65 -0
  27. data/TODO.complete/91-parity-diff-diagnostics.md +50 -0
  28. data/TODO.complete/92-table-cell-height-parity.md +66 -0
  29. data/TODO.complete/93-figure-text-searchability.md +49 -0
  30. data/TODO.complete/94-fo-space-resolution.md +63 -0
  31. data/TODO.complete/95-title-header-placement.md +55 -0
  32. data/TODO.complete/96-pagination-drift-parity.md +517 -0
  33. data/TODO.complete/README.md +58 -38
  34. data/lib/arrolio/config_driven_pipeline.rb +45 -1
  35. data/lib/arrolio/content/document.rb +4 -3
  36. data/lib/arrolio/content/section.rb +3 -0
  37. data/lib/arrolio/content/table.rb +25 -14
  38. data/lib/arrolio/embedded_block_extractor.rb +54 -0
  39. data/lib/arrolio/engine/paged.rb +109 -28
  40. data/lib/arrolio/flavor/manifest.rb +0 -1
  41. data/lib/arrolio/flavor/registry.rb +1 -1
  42. data/lib/arrolio/flowable.rb +21 -0
  43. data/lib/arrolio/flowables/figure_flowable.rb +17 -0
  44. data/lib/arrolio/flowables/footnote_marker_flowable.rb +32 -13
  45. data/lib/arrolio/flowables/group_flowable.rb +57 -0
  46. data/lib/arrolio/flowables/list_flowable.rb +46 -18
  47. data/lib/arrolio/flowables/note_flowable.rb +84 -9
  48. data/lib/arrolio/flowables/page_sequence_start.rb +8 -4
  49. data/lib/arrolio/flowables/positioned_block.rb +55 -0
  50. data/lib/arrolio/flowables/rotated_text.rb +51 -0
  51. data/lib/arrolio/flowables/table_flowable.rb +303 -71
  52. data/lib/arrolio/flowables/text_flowable.rb +67 -11
  53. data/lib/arrolio/flowables/toc_line_flowable.rb +70 -7
  54. data/lib/arrolio/flowables.rb +4 -0
  55. data/lib/arrolio/font/embedder.rb +22 -3
  56. data/lib/arrolio/font_scanner.rb +133 -0
  57. data/lib/arrolio/generic_adapter/document_extraction.rb +101 -0
  58. data/lib/arrolio/generic_adapter/footnote_extraction.rb +81 -0
  59. data/lib/arrolio/generic_adapter/heading_extraction.rb +92 -0
  60. data/lib/arrolio/generic_adapter/inline_run_collection.rb +136 -0
  61. data/lib/arrolio/generic_adapter/list_conversion.rb +70 -0
  62. data/lib/arrolio/generic_adapter/metadata_extraction.rb +46 -0
  63. data/lib/arrolio/generic_adapter/table_conversion.rb +105 -0
  64. data/lib/arrolio/generic_adapter.rb +263 -317
  65. data/lib/arrolio/generic_flow_builder/bibliography.rb +47 -0
  66. data/lib/arrolio/generic_flow_builder/figures.rb +90 -0
  67. data/lib/arrolio/generic_flow_builder/lists.rb +66 -0
  68. data/lib/arrolio/generic_flow_builder/notes.rb +61 -0
  69. data/lib/arrolio/generic_flow_builder/sequences.rb +102 -0
  70. data/lib/arrolio/generic_flow_builder/tables.rb +28 -0
  71. data/lib/arrolio/generic_flow_builder/terms.rb +89 -0
  72. data/lib/arrolio/generic_flow_builder.rb +92 -187
  73. data/lib/arrolio/glyph_measurer.rb +16 -5
  74. data/lib/arrolio/inline_run.rb +2 -1
  75. data/lib/arrolio/layout_spec/page_template.rb +14 -8
  76. data/lib/arrolio/math_ml/inline_run_extractor.rb +272 -0
  77. data/lib/arrolio/math_ml.rb +7 -0
  78. data/lib/arrolio/output/page.rb +5 -4
  79. data/lib/arrolio/output/placed_box.rb +5 -2
  80. data/lib/arrolio/renderer/pdf/assets.rb +94 -0
  81. data/lib/arrolio/renderer/pdf/font_embedding.rb +77 -0
  82. data/lib/arrolio/renderer/pdf/metadata.rb +52 -0
  83. data/lib/arrolio/renderer/pdf.rb +140 -215
  84. data/lib/arrolio/style/definition.rb +7 -3
  85. data/lib/arrolio/style/registry.rb +6 -1
  86. data/lib/arrolio/table/auto_layout.rb +19 -10
  87. data/lib/arrolio/table/grid.rb +105 -0
  88. data/lib/arrolio/table.rb +1 -0
  89. data/lib/arrolio/text_layout/greedy.rb +15 -1
  90. data/lib/arrolio/text_layout/knuth_plass/breaker.rb +216 -92
  91. data/lib/arrolio/text_layout/knuth_plass/item_builder.rb +31 -17
  92. data/lib/arrolio/text_layout/knuth_plass.rb +3 -0
  93. data/lib/arrolio/text_layout/line.rb +10 -2
  94. data/lib/arrolio/version.rb +1 -1
  95. data/lib/arrolio.rb +8 -4
  96. data/scripts/parity_check.rb +52 -0
  97. data/scripts/parity_diff.rb +89 -0
  98. data/scripts/xsl_profiles/standoc.yml +8 -2
  99. data/scripts/xsl_to_config.rb +28 -1
  100. metadata +91 -5
@@ -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 unless page.footnotes && !page.footnotes.empty?
253
-
254
- hf = header_footer_style
255
- y = hf[:margin_bottom] + 20
256
- page.footnotes.each do |footnote|
257
- marker = footnote.marker.to_s
258
- body_text = footnote.body.map do |node|
259
- node.is_a?(Arrolio::Content::Paragraph) ? node.text : node.to_s
260
- end.join(' ')
261
- line = "#{marker} #{body_text}".strip
262
- next if line.empty?
263
-
264
- payload = encoded_or_text(hf[:font_name], line)
265
- ref = embedded_or_standard(hf[:font_name])
266
- canvas.text(payload, at: [hf[:margin_lr], y], font: ref, size: 7.0)
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 render_cover_logo(canvas, page)
274
- logo_style = cover_logo_style
275
- w = logo_style[:width]
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
- baseline_y = y_top - (idx * line_height) - ascender
361
- line_x = x_origin + line.x_offset
362
- render_line_runs(canvas, line, line_x, baseline_y, style)
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
- x = line_x + pr.x_offset + extra_offset
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
- opts = { at: [x, y], font: ref, size: size }
377
- if run.style.character_spacing && !run.style.character_spacing.zero?
378
- opts[:char_spacing] = run.style.character_spacing
379
- end
380
- if run.style.word_spacing && !run.style.word_spacing.zero?
381
- opts[:word_spacing] = run.style.word_spacing
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
- record_link_if_needed(run, x, y, opts[:size])
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: 2, orphans: 2, parent: nil
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 rows.
42
+ # Natural width of each column = max cell natural width across
43
+ # rows. Placements come from the occupancy grid, so cells land
44
+ # in the columns they actually occupy (rowspan cells in later
45
+ # rows no longer shift into the wrong slot). Cells with colspan
46
+ # distribute their natural width equally across the spanned
47
+ # slots so a wide header doesn't accidentally collapse.
42
48
  def natural_widths
43
49
  widths = Array.new(column_count, MIN_COLUMN_WIDTH)
44
- @table.rows.each do |row|
45
- row.cells.each_with_index do |cell, ci|
46
- next if ci >= widths.length
50
+ @grid.placements.each do |placement|
51
+ span = [placement.colspan, 1].max
52
+ span = [span, column_count - placement.column_index].min
53
+ share = cell_natural_width(placement.cell) / span.to_f
54
+ span.times do |offset|
55
+ idx = placement.column_index + offset
56
+ break if idx >= widths.length
47
57
 
48
- w = cell_natural_width(cell)
49
- widths[ci] = w if w > widths[ci]
58
+ widths[idx] = [widths[idx], share].max
50
59
  end
51
60
  end
52
61
  widths
@@ -84,7 +93,7 @@ module Arrolio
84
93
  end
85
94
 
86
95
  def default_measurer
87
- GlyphMeasurer.new(font_name: 'Times New Roman')
96
+ GlyphMeasurer.new(font_name: 'Times-Roman')
88
97
  end
89
98
  end
90
99
  end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Arrolio
4
+ module Table
5
+ # Occupancy grid over a Content::Table, honoring colspan and
6
+ # rowspan. Answers the two questions every table consumer asks:
7
+ # which column does each cell actually occupy, and which rows are
8
+ # welded together by a vertical span (and therefore cannot be
9
+ # split apart across pages).
10
+ #
11
+ # Built once per layout pass and shared by AutoLayout (natural
12
+ # widths), TableFlowable (row heights, cell placement), and
13
+ # splitting (atomic row groups).
14
+ class Grid
15
+ # One cell's position in the grid. +row_index+ is the row the
16
+ # cell STARTS in — a rowspan cell is placed and drawn only
17
+ # there, with a height covering every spanned row.
18
+ Placement = Struct.new(:cell, :row_index, :column_index,
19
+ :colspan, :rowspan, keyword_init: true) do
20
+ def spans_multiple_rows?
21
+ rowspan > 1
22
+ end
23
+
24
+ def spans_multiple_columns?
25
+ colspan > 1
26
+ end
27
+ end
28
+
29
+ attr_reader :placements, :row_count, :column_count
30
+
31
+ def self.build(table)
32
+ rows = table.rows
33
+ occupied = {}
34
+ placements = []
35
+ rows.each_with_index do |row, row_index|
36
+ column_index = 0
37
+ row.cells.each do |cell|
38
+ column_index += 1 while occupied[[row_index, column_index]]
39
+ colspan = cell.colspan.to_i
40
+ rowspan = cell.rowspan.to_i
41
+ placements << Placement.new(
42
+ cell: cell, row_index: row_index, column_index: column_index,
43
+ colspan: colspan, rowspan: rowspan
44
+ )
45
+ rowspan.times do |r|
46
+ colspan.times do |c|
47
+ occupied[[row_index + r, column_index + c]] = true
48
+ end
49
+ end
50
+ column_index += colspan
51
+ end
52
+ end
53
+ new(placements: placements, row_count: rows.length,
54
+ column_count: occupied.keys.map(&:last).max.to_i + 1)
55
+ end
56
+
57
+ def initialize(placements:, row_count:, column_count:)
58
+ @placements = placements.freeze
59
+ @row_count = row_count
60
+ @column_count = column_count
61
+ @by_row = Array.new(row_count) { [] }
62
+ @placements.each { |p| @by_row[p.row_index] << p }
63
+ @by_row.each(&:freeze)
64
+ freeze
65
+ end
66
+
67
+ def placements_starting_in(row_index)
68
+ @by_row[row_index] || []
69
+ end
70
+
71
+ # Rows welded together by rowspan cells, as arrays of row
72
+ # indexes in ascending order. Rows not covered by any span come
73
+ # back as single-element groups. Splitting the table across
74
+ # pages must happen between groups, never inside one.
75
+ def atomic_row_groups
76
+ groups = []
77
+ group = []
78
+ welded = welded_rows
79
+ (0...@row_count).each do |row_index|
80
+ group << row_index
81
+ next if welded.include?(row_index + 1)
82
+
83
+ groups << group.freeze
84
+ group = []
85
+ end
86
+ groups << group.freeze unless group.empty?
87
+ groups.freeze
88
+ end
89
+
90
+ private
91
+
92
+ # Row indexes > 0 whose content starts in the row above (i.e.
93
+ # a rowspan from above covers their first column slot).
94
+ def welded_rows
95
+ covered = {}
96
+ @placements.each do |p|
97
+ next unless p.spans_multiple_rows?
98
+
99
+ (1...p.rowspan).each { |r| covered[p.row_index + r] = true }
100
+ end
101
+ covered
102
+ end
103
+ end
104
+ end
105
+ end
data/lib/arrolio/table.rb CHANGED
@@ -5,5 +5,6 @@ module Arrolio
5
5
  # - AutoLayout — content-based column width computation.
6
6
  module Table
7
7
  autoload :AutoLayout, 'arrolio/table/auto_layout'
8
+ autoload :Grid, 'arrolio/table/grid'
8
9
  end
9
10
  end