arrolio 0.1.1 → 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.
@@ -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
-
219
- def build_outline(context, _pages)
220
- entries = context.heading_entries
221
- return unless entries&.any?
222
74
 
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,
@@ -280,24 +122,6 @@ module Arrolio
280
122
  style.nil? || style.font_size.nil? ? Style::Definition.new(font_size: 9.0) : style
281
123
  end
282
124
 
283
- def render_cover_logo(canvas, page)
284
- logo_style = cover_logo_style
285
- w = logo_style[:width]
286
- h = logo_style[:height]
287
- margin = logo_style[:margin]
288
- x = page.width - margin - w
289
- y = page.height - margin - h
290
- invoke = invoke_xobject_op
291
- return unless invoke
292
-
293
- canvas.save_graphics_state do
294
- canvas.concat(w, 0, 0, h, x, y)
295
- canvas.emit_op(invoke, @logo_ref)
296
- end
297
- rescue StandardError => e
298
- Arrolio::Logger.warn "logo render failed: #{e.class}: #{e.message[0, 80]}"
299
- end
300
-
301
125
  def invoke_xobject_op
302
126
  return Pdfrb::Content::Operator::InvokeXObject if defined?(Pdfrb::Content::Operator::InvokeXObject)
303
127
 
@@ -433,10 +257,9 @@ module Arrolio
433
257
  font_name = run.style.font_name
434
258
  ref = embedded_or_standard(font_name)
435
259
  y, size = baseline_position_for(run, baseline_y)
436
- measurer = GlyphMeasurer.new(font_name: font_name)
437
260
  cursor = 0.0
438
261
  run_start = nil
439
- text_chunks(run.text, justify).each do |chunk|
262
+ text_chunks(run.text, justify).each_with_index do |chunk, ci|
440
263
  x = run_x + extra_offset + cursor
441
264
  run_start ||= x
442
265
  payload = encoded_or_text(font_name, chunk)
@@ -449,7 +272,10 @@ module Arrolio
449
272
  end
450
273
  record_link_if_needed(run, x, y, size) if cursor.zero?
451
274
  canvas.text(payload, **opts)
452
- cursor += measurer.width_of_string(chunk, font_size: size)
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
453
279
  # Justify must SHRINK as well as stretch: a compressed
454
280
  # line (ratio > -1) relies on negative space adjustment —
455
281
  # skipping it draws the line at natural width, past the
@@ -100,11 +100,25 @@ module Arrolio
100
100
  next if slice.nil? || slice.empty?
101
101
 
102
102
  sub_run = InlineRun.new(slice, style: run.style)
103
- placed << Line::PlacedRun.new(run: sub_run, x_offset: x_offset)
103
+ placed << Line::PlacedRun.new(run: sub_run, x_offset: x_offset,
104
+ chunk_widths: chunk_widths(run, slice))
104
105
  x_offset += sub_run.width(@measurer)
105
106
  end
106
107
  placed
107
108
  end
109
+
110
+ # Width of each emission chunk (text split after
111
+ # whitespace) — the same chunking the renderer uses. The
112
+ # breaker owns measurement; the renderer advances by these.
113
+ def chunk_widths(run, slice)
114
+ font = run.style.font_name
115
+ size = run.style.font_size * run.font_size_scale
116
+ slice.split(/(?<=\s)(?=\S)/).map do |chunk|
117
+ @measurer.width_of_run(chunk, font_size: size,
118
+ character_spacing: run.style.character_spacing,
119
+ word_spacing: 0, font_name: font)
120
+ end
121
+ end
108
122
  end
109
123
  end
110
124
  end
@@ -76,7 +76,8 @@ module Arrolio
76
76
 
77
77
  # Internal: a run of same-signature text being merged into a
78
78
  # single PlacedRun (one canvas.text call at render time).
79
- RunGroup = Struct.new(:signature, :text, :x_offset, keyword_init: true)
79
+ RunGroup = Struct.new(:signature, :text, :x_offset, :chunk_widths,
80
+ keyword_init: true)
80
81
 
81
82
  private
82
83
 
@@ -302,10 +303,16 @@ module Arrolio
302
303
  # line's last box (at the break) is dropped: it is discarded
303
304
  # by width accounting and would otherwise inflate the
304
305
  # renderer's justify word count.
306
+ # Chunk widths follow the emission chunking (text split
307
+ # AFTER whitespace): a chunk's width is its boxes plus the
308
+ # trailing glue(s). Hyphen parts join the same chunk (no
309
+ # space between them).
305
310
  def build_placed_runs(start_item, stop_item)
306
311
  last_box = last_box_index_in(start_item, stop_item)
307
312
  placed = []
308
313
  group = nil
314
+ open_w = 0.0
315
+ close_pending = false
309
316
 
310
317
  (start_item...stop_item).each do |i|
311
318
  item = @items[i]
@@ -324,14 +331,28 @@ module Arrolio
324
331
  if group.nil? || group.signature != signature
325
332
  flush_placed_group(placed, group) if group
326
333
  group = RunGroup.new(signature: signature, text: String.new,
327
- x_offset: item_offset(first_box_in(start_item, stop_item), i))
334
+ x_offset: item_offset(first_box_in(start_item, stop_item), i),
335
+ chunk_widths: [])
336
+ open_w = 0.0
337
+ close_pending = false
338
+ end
339
+ if close_pending
340
+ group.chunk_widths << open_w
341
+ open_w = 0.0
342
+ close_pending = false
328
343
  end
329
344
  group.text << slice
345
+ open_w += item.width.to_f
330
346
  elsif item.glue? && group && i < last_box
331
347
  group.text << ' '
348
+ open_w += item.width.to_f
349
+ close_pending = true
332
350
  end
333
351
  end
334
- flush_placed_group(placed, group) if group
352
+ if group
353
+ group.chunk_widths << open_w if close_pending || open_w.positive?
354
+ flush_placed_group(placed, group)
355
+ end
335
356
  placed
336
357
  end
337
358
 
@@ -359,7 +380,8 @@ module Arrolio
359
380
  baseline_shift: baseline_shift,
360
381
  font_size_scale: font_size_scale,
361
382
  href: href)
362
- placed << Line::PlacedRun.new(run: run, x_offset: group.x_offset)
383
+ placed << Line::PlacedRun.new(run: run, x_offset: group.x_offset,
384
+ chunk_widths: group.chunk_widths)
363
385
  end
364
386
  end
365
387
  end
@@ -5,7 +5,13 @@ module Arrolio
5
5
  # One laid-out line: a list of PlacedRun tuples plus the
6
6
  # line's content width, target width, and alignment.
7
7
  class Line
8
- PlacedRun = Struct.new(:run, :x_offset, keyword_init: true) do
8
+ # +chunk_widths+ carries the width of each emission chunk
9
+ # (text split after whitespace), computed by the BREAKER that
10
+ # owns glyph measurement. The renderer advances by these
11
+ # widths — it never measures, so layout and render cannot
12
+ # drift apart (the source of the word-overlap and
13
+ # measure-overflow bugs).
14
+ PlacedRun = Struct.new(:run, :x_offset, :chunk_widths, keyword_init: true) do
9
15
  def freeze
10
16
  run.freeze
11
17
  super
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Arrolio
4
- VERSION = '0.1.1'
4
+ VERSION = '0.1.2'
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: arrolio
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.1
4
+ version: 0.1.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.
@@ -384,6 +384,13 @@ files:
384
384
  - lib/arrolio/generic_adapter/metadata_extraction.rb
385
385
  - lib/arrolio/generic_adapter/table_conversion.rb
386
386
  - lib/arrolio/generic_flow_builder.rb
387
+ - lib/arrolio/generic_flow_builder/bibliography.rb
388
+ - lib/arrolio/generic_flow_builder/figures.rb
389
+ - lib/arrolio/generic_flow_builder/lists.rb
390
+ - lib/arrolio/generic_flow_builder/notes.rb
391
+ - lib/arrolio/generic_flow_builder/sequences.rb
392
+ - lib/arrolio/generic_flow_builder/tables.rb
393
+ - lib/arrolio/generic_flow_builder/terms.rb
387
394
  - lib/arrolio/glyph_measurer.rb
388
395
  - lib/arrolio/harness.rb
389
396
  - lib/arrolio/harness/pdf_diff.rb
@@ -411,6 +418,9 @@ files:
411
418
  - lib/arrolio/renderer/link_annotator.rb
412
419
  - lib/arrolio/renderer/outline_builder.rb
413
420
  - lib/arrolio/renderer/pdf.rb
421
+ - lib/arrolio/renderer/pdf/assets.rb
422
+ - lib/arrolio/renderer/pdf/font_embedding.rb
423
+ - lib/arrolio/renderer/pdf/metadata.rb
414
424
  - lib/arrolio/renderer/signature_config.rb
415
425
  - lib/arrolio/renderer/structure_tree_builder.rb
416
426
  - lib/arrolio/renderer/xmp_builder.rb