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
@@ -0,0 +1,272 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'mml'
4
+
5
+ module Arrolio
6
+ module MathML
7
+ # Walks a parsed Mml tree (from +Mml.parse(xml_string, version: 3)+)
8
+ # and emits an Array of +Content::InlineRun+ values, applying
9
+ # +baseline_shift+ and +font_size_scale+ to match the math
10
+ # layout semantics of each MathML element.
11
+ #
12
+ # The extractor relies on the canonical Mml gem (plurimath/mml)
13
+ # for parsing, keeping Arrolio free of its own MathML grammar.
14
+ # MathML presentation elements map as follows:
15
+ #
16
+ # <mi> → italic math run (:math_identifier)
17
+ # <mn>, <mo>, <mtext> → regular math run (parent style)
18
+ # <ms> → quoted string run (parent style)
19
+ # <msub> → base + subscript run
20
+ # <msup> → base + superscript run
21
+ # <msubsup> → base + sub + sup
22
+ # <munder>/<mover> → rendered as sub/sup for visual fidelity
23
+ # <mfrac> → numerator + '/' + denominator (inline)
24
+ # <mrow>, <mstyle> → transparent group, recurse children
25
+ #
26
+ # Anything else recurses transparently.
27
+ class InlineRunExtractor
28
+
29
+ # Each entry is [value_attribute, child_collection_attribute, kind]
30
+ # - value_attribute: node method returning the text content (or nil)
31
+ # - child_collection_attribute: node method returning child
32
+ # elements of that type (Array)
33
+ # - kind: :text | :identifier | :transparent | :subscript_container
34
+ # | :superscript_container | :fraction | :ignored
35
+ ELEMENT_HANDLERS = {
36
+ 'Mml::V3::Mi' => [:value, :mi_value, :identifier],
37
+ 'Mml::V3::Mn' => [:value, :mn_value, :text],
38
+ 'Mml::V3::Mo' => [:value, :mo_value, :text],
39
+ 'Mml::V3::Mtext' => [:value, :mtext_value, :text],
40
+ 'Mml::V3::Ms' => [:value, :ms_value, :text],
41
+ 'Mml::V3::Mrow' => [nil, nil, :transparent_all],
42
+ 'Mml::V3::Mstyle' => [nil, nil, :transparent_all],
43
+ 'Mml::V3::Msub' => [nil, :msub_value, :subscript_container],
44
+ 'Mml::V3::Msup' => [nil, :msup_value, :superscript_container],
45
+ 'Mml::V3::Msubsup' => [nil, :msubsup_value, :subsup_container],
46
+ 'Mml::V3::Munder' => [nil, :munder_value, :subscript_container],
47
+ 'Mml::V3::Mover' => [nil, :mover_value, :superscript_container],
48
+ 'Mml::V3::Munderover' => [nil, :munderover_value, :subsup_container],
49
+ 'Mml::V3::Mfrac' => [nil, :mfrac_value, :fraction],
50
+ 'Mml::V3::Menclose' => [nil, :menclose_value, :transparent],
51
+ 'Mml::V3::Mphantom' => [nil, :mphantom_value, :transparent],
52
+ 'Mml::V3::Mpadded' => [nil, :mpadded_value, :transparent],
53
+ 'Mml::V3::Merror' => [nil, :merror_value, :transparent],
54
+ 'Mml::V3::Semantics' => [nil, :semantics_value, :transparent],
55
+ 'Mml::V3::Math' => [nil, nil, :transparent_all]
56
+ }.freeze
57
+
58
+ attr_reader :base_style
59
+
60
+ def sub_sup_scale = 0.7
61
+
62
+ def default_baseline
63
+ ::Arrolio::Content::InlineRun::BASELINE_NORMAL
64
+ end
65
+
66
+ def sub_baseline
67
+ ::Arrolio::Content::InlineRun::BASELINE_SUB
68
+ end
69
+
70
+ def sup_baseline
71
+ ::Arrolio::Content::InlineRun::BASELINE_SUP
72
+ end
73
+
74
+ def initialize(base_style: :math)
75
+ @base_style = base_style.to_sym
76
+ end
77
+
78
+ def extract(math_node)
79
+ runs = []
80
+ walk(math_node, runs, baseline: default_baseline, scale: 1.0,
81
+ style_id: base_style)
82
+ runs
83
+ end
84
+
85
+ # Parse +xml_string+ as MathML and extract the runs. Returns
86
+ # an empty Array if parsing fails.
87
+ def self.extract_from_xml(xml_string, base_style: :math)
88
+ return [] if xml_string.nil? || xml_string.to_s.empty?
89
+
90
+ parsed = ::Mml.parse(xml_string.to_s, version: 3)
91
+ new(base_style: base_style).extract(parsed)
92
+ rescue StandardError => e
93
+ Arrolio::Logger.warn "Mml parse failed: #{e.class}: #{e.message[0, 80]}"
94
+ []
95
+ end
96
+
97
+ private
98
+
99
+ def walk(node, runs, baseline:, scale:, style_id:)
100
+ return unless node
101
+
102
+ handler = ELEMENT_HANDLERS[node.class.name]
103
+ if handler
104
+ dispatch_handler(node, handler, runs, baseline: baseline,
105
+ scale: scale, style_id: style_id)
106
+ else
107
+ # Unknown element: recurse transparently into all child collections.
108
+ walk_all_children(node, runs, baseline: baseline, scale: scale,
109
+ style_id: style_id)
110
+ end
111
+ end
112
+
113
+ def dispatch_handler(node, handler, runs, baseline:, scale:, style_id:)
114
+ _value_attr, child_attr, kind = handler
115
+ effective_style = kind == :identifier ? :math_identifier : style_id
116
+ if handler.first
117
+ emit_text_value(node, runs, baseline: baseline, scale: scale,
118
+ style_id: effective_style)
119
+ end
120
+ case kind
121
+ when :identifier
122
+ emit_collection_as(node, child_attr, runs, baseline: baseline, scale: scale,
123
+ style_id: :math_identifier)
124
+ when :text
125
+ emit_collection_as(node, child_attr, runs, baseline: baseline, scale: scale,
126
+ style_id: style_id)
127
+ when :transparent
128
+ walk_collection(node, child_attr, runs, baseline: baseline, scale: scale,
129
+ style_id: style_id)
130
+ when :transparent_all
131
+ walk_all_children(node, runs, baseline: baseline, scale: scale,
132
+ style_id: style_id)
133
+ when :subscript_container
134
+ process_script_container(node, runs, baseline: baseline, scale: scale,
135
+ style_id: style_id,
136
+ script_baseline: sub_baseline,
137
+ script_position: 1)
138
+ when :superscript_container
139
+ process_script_container(node, runs, baseline: baseline, scale: scale,
140
+ style_id: style_id,
141
+ script_baseline: sup_baseline,
142
+ script_position: 1)
143
+ when :subsup_container
144
+ process_subsup_container(node, runs, baseline: baseline, scale: scale,
145
+ style_id: style_id)
146
+ when :fraction
147
+ process_fraction_container(node, runs, baseline: baseline, scale: scale,
148
+ style_id: style_id)
149
+ end
150
+ end
151
+
152
+ def emit_text_value(node, runs, baseline:, scale:, style_id:)
153
+ value = safe_value(node)
154
+ return if value.nil? || value.empty?
155
+
156
+ runs << ::Arrolio::Content::InlineRun.new(value,
157
+ style_id: style_id,
158
+ baseline_shift: baseline,
159
+ font_size_scale: scale)
160
+ end
161
+
162
+ def emit_collection_as(node, attr, runs, baseline:, scale:, style_id:)
163
+ children = safe_collection(node, attr)
164
+ children.each { |c| walk(c, runs, baseline: baseline, scale: scale, style_id: style_id) }
165
+ end
166
+
167
+ def walk_collection(node, attr, runs, baseline:, scale:, style_id:)
168
+ safe_collection(node, attr).each do |child|
169
+ walk(child, runs, baseline: baseline, scale: scale, style_id: style_id)
170
+ end
171
+ end
172
+
173
+ # For <msub>/<msup>: the node IS the container. Its children
174
+ # are the base (first) and the script (second).
175
+ def process_script_container(node, runs, baseline:, scale:, style_id:,
176
+ script_baseline:, script_position:)
177
+ children = all_child_elements(node)
178
+ base = children.first
179
+ script = children[script_position]
180
+ if base
181
+ walk(base, runs, baseline: baseline, scale: scale,
182
+ style_id: style_id)
183
+ end
184
+ return unless script
185
+
186
+ walk(script, runs, baseline: script_baseline,
187
+ scale: reduce_scale(scale),
188
+ style_id: style_id)
189
+ end
190
+
191
+ # For <msubsup>: base + sub + sup
192
+ def process_subsup_container(node, runs, baseline:, scale:, style_id:)
193
+ children = all_child_elements(node)
194
+ base = children[0]
195
+ sub = children[1]
196
+ sup = children[2]
197
+ if base
198
+ walk(base, runs, baseline: baseline, scale: scale,
199
+ style_id: style_id)
200
+ end
201
+ if sub
202
+ walk(sub, runs, baseline: sub_baseline,
203
+ scale: reduce_scale(scale), style_id: style_id)
204
+ end
205
+ return unless sup
206
+
207
+ walk(sup, runs, baseline: sup_baseline,
208
+ scale: reduce_scale(scale), style_id: style_id)
209
+ end
210
+
211
+ # For <mfrac>: numerator + '/' + denominator, all at reduced scale.
212
+ def process_fraction_container(node, runs, baseline:, scale:, style_id:)
213
+ children = all_child_elements(node)
214
+ numerator = children[0]
215
+ denominator = children[1]
216
+ child_scale = reduce_scale(scale)
217
+ if numerator
218
+ walk(numerator, runs, baseline: sup_baseline, scale: child_scale,
219
+ style_id: style_id)
220
+ end
221
+ runs << ::Arrolio::Content::InlineRun.new('/', style_id: style_id,
222
+ baseline_shift: baseline,
223
+ font_size_scale: scale)
224
+ return unless denominator
225
+
226
+ walk(denominator, runs, baseline: sub_baseline, scale: child_scale,
227
+ style_id: style_id)
228
+ end
229
+
230
+ def walk_all_children(node, runs, baseline:, scale:, style_id:)
231
+ %i[mi_value mn_value mo_value mtext_value ms_value mrow_value
232
+ mstyle_value msub_value msup_value msubsup_value mfrac_value
233
+ menclose_value mphantom_value mpadded_value merror_value
234
+ mover_value munder_value munderover_value semantics_value
235
+ msqrt_value mroot_value mtable_value math_value].each do |attr|
236
+ safe_collection(node, attr).each do |child|
237
+ walk(child, runs, baseline: baseline, scale: scale, style_id: style_id)
238
+ end
239
+ end
240
+ end
241
+
242
+ def all_child_elements(node)
243
+ %i[mi_value mn_value mo_value mtext_value ms_value mrow_value
244
+ mstyle_value msub_value msup_value msubsup_value mfrac_value
245
+ menclose_value mphantom_value mpadded_value merror_value
246
+ mover_value munder_value munderover_value semantics_value
247
+ msqrt_value mroot_value mtable_value].flat_map do |attr|
248
+ safe_collection(node, attr)
249
+ end
250
+ end
251
+
252
+ def safe_collection(node, attr)
253
+ return [] unless node.is_a?(::Mml::CommonElements)
254
+
255
+ node.public_send(attr)
256
+ rescue StandardError
257
+ []
258
+ end
259
+
260
+ def safe_value(node)
261
+ value = node.value
262
+ value.is_a?(Array) ? value.join : value.to_s
263
+ rescue StandardError
264
+ nil
265
+ end
266
+
267
+ def reduce_scale(current_scale)
268
+ (current_scale * sub_sup_scale).round(4)
269
+ end
270
+ end
271
+ end
272
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Arrolio
4
+ module MathML
5
+ autoload :InlineRunExtractor, 'arrolio/math_ml/inline_run_extractor'
6
+ end
7
+ end
@@ -5,13 +5,13 @@ module Arrolio
5
5
  class Page
6
6
  attr_reader :number, :template_name, :template_role, :page_size,
7
7
  :regions, :static_regions, :header_text, :footer_text,
8
- :header_align, :footer_align, :footnotes
8
+ :header_align, :footer_align, :footnotes, :title_text
9
9
 
10
10
  def initialize(number:, template_name:, page_size:,
11
11
  regions:, static_regions: {}, template_role: :body,
12
12
  header_text: nil, footer_text: nil,
13
- header_align: :right, footer_align: :center,
14
- footnotes: [])
13
+ header_align: nil, footer_align: :center,
14
+ footnotes: [], title_text: nil)
15
15
  @number = number.to_i
16
16
  @template_name = template_name.to_sym
17
17
  @template_role = template_role.to_sym
@@ -20,9 +20,10 @@ module Arrolio
20
20
  @static_regions = static_regions.transform_keys(&:to_sym).freeze
21
21
  @header_text = header_text
22
22
  @footer_text = footer_text
23
- @header_align = header_align.to_sym
23
+ @header_align = header_align ? header_align.to_sym : :right
24
24
  @footer_align = footer_align.to_sym
25
25
  @footnotes = Array(footnotes).freeze
26
+ @title_text = title_text
26
27
  freeze
27
28
  end
28
29
 
@@ -17,11 +17,14 @@ module Arrolio
17
17
  end
18
18
 
19
19
  class << self
20
- def text(x:, y:, width:, height:, lines:, line_height:, style:, link_dest: nil)
20
+ def text(x:, y:, width:, height:, lines:, line_height:, style:,
21
+ hanging_indent: 0.0, line_heights: nil, link_dest: nil)
21
22
  new(x: x, y: y, width: width, height: height, kind: :text,
22
23
  data: { lines: Array(lines).freeze,
23
24
  line_height: line_height.to_f,
24
- style: style },
25
+ line_heights: line_heights&.map(&:to_f)&.freeze,
26
+ style: style,
27
+ hanging_indent: hanging_indent.to_f },
25
28
  link_dest: link_dest)
26
29
  end
27
30
 
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Arrolio
4
+ module Renderer
5
+ class Pdf
6
+ # Extracted concern: Pdfrb coupling points kept out of the
7
+ # emission path.
8
+ module Assets
9
+ def register_logo(path)
10
+ return nil unless path
11
+
12
+ @document.images.add(path)
13
+ rescue StandardError => e
14
+ Arrolio::Logger.warn "logo load failed: #{e.class}: #{e.message[0, 80]}"
15
+ nil
16
+ end
17
+
18
+ def register_image(path)
19
+ return @images[path] if @images.key?(path)
20
+
21
+ resolved = resolve_image_for_pdfrb(path)
22
+ return nil unless resolved
23
+
24
+ @images[path] = @document.images.add(resolved)
25
+ rescue StandardError => e
26
+ Arrolio::Logger.warn "register_image failed for #{path}: #{e.class}: #{e.message[0, 80]}"
27
+ nil
28
+ end
29
+
30
+ # Pdfrb supports JPEG, PNG, and PDF — not SVG. If +path+ is an
31
+ # SVG file, rasterize it to PNG via rsvg-convert and cache the
32
+ # result. Returns the path to a pdfrb-compatible image file.
33
+ def resolve_image_for_pdfrb(path)
34
+ return path unless path.to_s.end_with?('.svg', '.SVG')
35
+ return path unless File.exist?(path)
36
+
37
+ png_path = svg_to_png_cache_path(path)
38
+ return png_path if File.exist?(png_path)
39
+
40
+ rasterize_svg(path, png_path)
41
+ File.exist?(png_path) ? png_path : path
42
+ end
43
+
44
+ def svg_to_png_cache_path(svg_path)
45
+ digest = Digest::MD5.file(svg_path).hexdigest
46
+ File.join(Dir.tmpdir, 'arrolio-svg-' + digest + '.png')
47
+ end
48
+
49
+ def rasterize_svg(svg_path, png_path)
50
+ require 'open3'
51
+ result = Open3.capture3('rsvg-convert', '-d', '96', '-p', '96',
52
+ '-o', png_path, svg_path)
53
+ return if result[2].success?
54
+
55
+ Arrolio::Logger.warn "rsvg-convert failed: #{result[1]}"
56
+ rescue StandardError => e
57
+ Arrolio::Logger.warn "rasterize_svg failed: #{e.class}: #{e.message[0, 80]}"
58
+ end
59
+
60
+ def cover_logo_style
61
+ return @cover_logo_style if @cover_logo_style
62
+
63
+ config = @layout_spec&.cover_logo_config || {}
64
+ width_mm = config['width_mm'] || 35.0
65
+ ratio = config['aspect_ratio'] || (1459.0 / 1667.0)
66
+ margin_mm = config['margin_mm'] || 25.5
67
+ @cover_logo_style = {
68
+ width: width_mm * MM_TO_PT,
69
+ height: width_mm * ratio * MM_TO_PT,
70
+ margin: margin_mm * MM_TO_PT
71
+ }.freeze
72
+ end
73
+
74
+ def render_cover_logo(canvas, page)
75
+ logo_style = cover_logo_style
76
+ w = logo_style[:width]
77
+ h = logo_style[:height]
78
+ margin = logo_style[:margin]
79
+ x = page.width - margin - w
80
+ y = page.height - margin - h
81
+ invoke = invoke_xobject_op
82
+ return unless invoke
83
+
84
+ canvas.save_graphics_state do
85
+ canvas.concat(w, 0, 0, h, x, y)
86
+ canvas.emit_op(invoke, @logo_ref)
87
+ end
88
+ rescue StandardError => e
89
+ Arrolio::Logger.warn "logo render failed: #{e.class}: #{e.message[0, 80]}"
90
+ end
91
+ end
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Arrolio
4
+ module Renderer
5
+ class Pdf
6
+ # Extracted concern: Pdfrb coupling points kept out of the
7
+ # emission path.
8
+ module FontEmbedding
9
+ # Pre-scan all pages, collect codepoints per font family,
10
+ # then call Font::Embedder for each font_path.
11
+ def prepare_embedded_fonts(pages)
12
+ return if @font_paths.empty?
13
+
14
+ codepoints = Hash.new { |h, k| h[k] = [] }
15
+ pages.each { |page| collect_codepoints(page, codepoints) }
16
+ Arrolio::Logger.debug "collected codepoints for fonts: #{codepoints.keys.inspect}"
17
+ @font_paths.each do |font_name, path|
18
+ exists = File.exist?(path)
19
+ has_cp = codepoints.key?(font_name)
20
+ cp_count = codepoints[font_name]&.length || 0
21
+ Arrolio::Logger.debug "font #{font_name}: exists=#{exists} cp=#{cp_count}"
22
+ next unless exists
23
+ next unless has_cp
24
+
25
+ cps = codepoints[font_name].uniq
26
+ embedder = Font::Embedder.new(@document, path,
27
+ base_font_name: font_name)
28
+ ref = embedder.embed(cps)
29
+ attach_to_resources(font_name, ref)
30
+ @font_embedders[font_name] = embedder
31
+ @font_encoders[font_name] = Font::TextEncoder.new(embedder)
32
+ @font_refs[font_name] = ref
33
+ rescue StandardError => e
34
+ Arrolio::Logger.warn "embed failed for #{font_name}: #{e.class}: #{e.message[0,100]}"
35
+ Arrolio::Logger.debug e.backtrace.first(5).join("\n")
36
+ end
37
+ end
38
+
39
+ def attach_to_resources(font_name, ref)
40
+ catalog = @document.catalog
41
+ res = catalog.value[:Resources]
42
+ Arrolio::Logger.debug "attach_to_resources: Resources=#{res.class}"
43
+ unless res.is_a?(Hash) && res[:Font].is_a?(Hash)
44
+ catalog.value[:Resources] = { Font: {} }
45
+ end
46
+ font_hash = catalog.value[:Resources][:Font]
47
+ key = (format('EF%d', (font_hash.length + 1))).to_sym
48
+ font_hash[key] = ref
49
+ @embedded_resource_keys ||= {}
50
+ @embedded_resource_keys[font_name] = key
51
+ end
52
+
53
+ def collect_codepoints(page, by_font)
54
+ (page.static_regions.values + page.regions.values).each do |region|
55
+ region.placed_boxes.each do |box|
56
+ next unless box.kind == :text
57
+ next unless box.data.is_a?(Hash) && box.data[:lines]
58
+
59
+ box.data[:lines].each do |line|
60
+ next unless line.is_a?(TextLayout::Line)
61
+ line.placed_runs.each do |pr|
62
+ next unless pr.run.is_a?(InlineRun)
63
+ # Collect under the RUN'S font name, not the
64
+ # paragraph's. Italic/bold runs have different
65
+ # font_names and need their own subsets.
66
+ run_font = pr.run.style.font_name
67
+ next unless @font_paths.key?(run_font)
68
+ pr.run.text.each_codepoint { |cp| by_font[run_font] << cp }
69
+ end
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Arrolio
4
+ module Renderer
5
+ class Pdf
6
+ # Extracted concern: Pdfrb coupling points kept out of the
7
+ # emission path.
8
+ module Metadata
9
+ def apply_metadata(metadata)
10
+ info = @document.catalog.value[:Info]
11
+ info ||= @document.add({})
12
+ @document.catalog.value[:Info] = info
13
+ info.value[:Title] = metadata[:title] if metadata[:title]
14
+ info.value[:Author] = metadata[:author] if metadata[:author]
15
+ info.value[:Creator] = 'Arrolio (Ruby)'
16
+ info.value[:Producer] = 'Arrolio + Pdfrb'
17
+ end
18
+
19
+ # Emit an XMP metadata packet (RDF/XML) as a stream on the
20
+ # catalog's /Metadata entry. PDF readers use this for Dublin
21
+ # Core properties alongside the legacy /Info dict.
22
+ def attach_xmp_metadata(metadata)
23
+ xmp = XmpBuilder.new(metadata).build
24
+ stream = @document.add(
25
+ { Type: :Metadata, Subtype: :XML, Length: xmp.bytesize },
26
+ type: Pdfrb::Model::Cos::Stream
27
+ )
28
+ stream.stream = xmp
29
+ @document.catalog.value[:Metadata] =
30
+ Pdfrb::Model::Reference.new(stream.oid, stream.gen)
31
+ rescue StandardError => e
32
+ Arrolio::Logger.warn "XMP attach failed: #{e.class}: #{e.message[0, 80]}"
33
+ end
34
+
35
+ def build_outline(context, _pages)
36
+ entries = context.heading_entries
37
+ return unless entries&.any?
38
+
39
+ pdfrb_pages = @document.pages.to_a
40
+ ob = OutlineBuilder.new(document: @document,
41
+ entries: entries,
42
+ pdfrb_pages: pdfrb_pages)
43
+ result = ob.build
44
+ Arrolio::Logger.debug "outline build returned: #{result.class}"
45
+ rescue StandardError => e
46
+ Arrolio::Logger.warn "outline build failed: #{e.class}: #{e.message[0,150]}"
47
+ Arrolio::Logger.debug e.backtrace.first(5).join("\n")
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end