arrolio 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) 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.rb +362 -62
  66. data/lib/arrolio/glyph_measurer.rb +16 -5
  67. data/lib/arrolio/inline_run.rb +2 -1
  68. data/lib/arrolio/layout_spec/page_template.rb +14 -8
  69. data/lib/arrolio/math_ml/inline_run_extractor.rb +272 -0
  70. data/lib/arrolio/math_ml.rb +7 -0
  71. data/lib/arrolio/output/page.rb +5 -4
  72. data/lib/arrolio/output/placed_box.rb +5 -2
  73. data/lib/arrolio/renderer/pdf.rb +131 -32
  74. data/lib/arrolio/style/definition.rb +7 -3
  75. data/lib/arrolio/style/registry.rb +6 -1
  76. data/lib/arrolio/table/auto_layout.rb +19 -10
  77. data/lib/arrolio/table/grid.rb +105 -0
  78. data/lib/arrolio/table.rb +1 -0
  79. data/lib/arrolio/text_layout/knuth_plass/breaker.rb +194 -92
  80. data/lib/arrolio/text_layout/knuth_plass/item_builder.rb +31 -17
  81. data/lib/arrolio/text_layout/knuth_plass.rb +3 -0
  82. data/lib/arrolio/text_layout/line.rb +3 -1
  83. data/lib/arrolio/version.rb +1 -1
  84. data/lib/arrolio.rb +8 -4
  85. data/scripts/parity_check.rb +52 -0
  86. data/scripts/parity_diff.rb +89 -0
  87. data/scripts/xsl_profiles/standoc.yml +8 -2
  88. data/scripts/xsl_to_config.rb +28 -1
  89. metadata +81 -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
 
@@ -240,6 +240,7 @@ module Arrolio
240
240
  pdfrb_page.value[:Resources] = catalog.value[:Resources]
241
241
  canvas = pdfrb_page.canvas
242
242
  render_cover_logo(canvas, output_page) if output_page.template_role == :cover && @logo_ref
243
+ render_page_title(canvas, output_page) if output_page.title_text
243
244
  render_header_footer(canvas, output_page) unless output_page.template_role == :cover
244
245
  render_page_footnotes(canvas, output_page) unless output_page.template_role == :cover
245
246
  (output_page.static_regions.values + output_page.regions.values).each do |region|
@@ -248,28 +249,37 @@ module Arrolio
248
249
  @link_annotator.flush(pdfrb_page)
249
250
  end
250
251
 
252
+ # Footnote bodies occupy the zone the engine reserved at the
253
+ # page bottom. The layout comes from
254
+ # FootnoteMarkerFlowable.body_flowable - the same policy the
255
+ # engine used to reserve the space - so the drawn block fits
256
+ # the reserved zone exactly.
251
257
  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
258
+ return if page.footnotes.nil? || page.footnotes.empty?
259
+
260
+ style = footnote_style
261
+ body = page.body_region
262
+ width = body ? body.width : page.width * 0.85
263
+ x = body ? body.x : page.width * 0.075
264
+ flowables = page.footnotes.map do |footnote|
265
+ Flowables::FootnoteMarkerFlowable.body_flowable(footnote, style)
266
+ end
267
+ total = flowables.sum { |flow| flow.height(width) }
268
+ y = header_footer_style[:margin_bottom] + 20 + total
269
+ flowables.each do |flow|
270
+ boxes, = flow.emit(x, y, width, nil)
271
+ boxes.each { |box| render_box(canvas, box) }
272
+ y -= flow.height(width)
268
273
  end
269
274
  rescue StandardError => e
270
275
  Arrolio::Logger.warn "footnote render failed: #{e.class}: #{e.message[0, 80]}"
271
276
  end
272
277
 
278
+ def footnote_style
279
+ style = @layout_spec&.resolve_style(:footnote)
280
+ style.nil? || style.font_size.nil? ? Style::Definition.new(font_size: 9.0) : style
281
+ end
282
+
273
283
  def render_cover_logo(canvas, page)
274
284
  logo_style = cover_logo_style
275
285
  w = logo_style[:width]
@@ -296,6 +306,24 @@ module Arrolio
296
306
  end
297
307
  end
298
308
 
309
+ def render_page_title(canvas, page)
310
+ header_footer_style
311
+ body = page.regions[:body]
312
+ return unless body
313
+
314
+ font_name = 'Times New Roman Bold'
315
+ size = 18.0
316
+ measurer = GlyphMeasurer.new(font_name: font_name)
317
+ tw = measurer.width_of_string(page.title_text, font_size: size)
318
+ x = (page.width - tw) / 2.0
319
+ baseline = body.y + body.height - 9.0
320
+ ref = embedded_or_standard(font_name)
321
+ payload = encoded_or_text(font_name, page.title_text)
322
+ canvas.text(payload, at: [x, baseline], font: ref, size: size)
323
+ rescue StandardError => e
324
+ Arrolio::Logger.warn "render_page_title failed: #{e.message[0, 80]}"
325
+ end
326
+
299
327
  def render_header_footer(canvas, page)
300
328
  hf_style = header_footer_style
301
329
  margin_top = hf_style[:margin_top]
@@ -344,48 +372,119 @@ module Arrolio
344
372
  when :rect then render_rect(canvas, box)
345
373
  when :line then render_line(canvas, box)
346
374
  when :image then render_image(canvas, box)
375
+ when :rotated_text then render_rotated_text(canvas, box)
347
376
  end
348
377
  end
349
378
 
379
+ def render_rotated_text(canvas, box)
380
+ text = box.data[:text]
381
+ style = box.data[:style]
382
+ angle = box.data[:angle].to_f
383
+ line_height = box.data[:line_height].to_f
384
+ font_name = style.font_name
385
+ ref = embedded_or_standard(font_name)
386
+ payload = encoded_or_text(font_name, text)
387
+ rad = angle * Math::PI / 180.0
388
+ cos = Math.cos(rad)
389
+ sin = Math.sin(rad)
390
+ baseline_y = box.y + line_height - GlyphMeasurer.new(font_name: font_name)
391
+ .ascender(font_size: style.font_size)
392
+ # PDF text matrix is [a b c d e f] where (a,d) scale, (b,c) rotate,
393
+ # (e,f) translate. We rotate around the baseline starting point.
394
+ canvas.text(payload, at: [box.x, baseline_y], font: ref, size: style.font_size,
395
+ tm: [cos, sin, -sin, cos, box.x, baseline_y])
396
+ rescue StandardError => e
397
+ Arrolio::Logger.warn "render_rotated_text failed: #{e.message[0, 80]}"
398
+ end
399
+
350
400
  def render_text(canvas, box)
351
401
  lines = box.data[:lines]
352
402
  line_height = box.data[:line_height]
353
403
  style = box.data[:style]
404
+ hanging_indent = box.data[:hanging_indent] || 0.0
354
405
  measurer = GlyphMeasurer.new(font_name: style.font_name)
355
406
  ascender = measurer.ascender(font_size: style.font_size)
356
407
  x_origin = box.x
357
408
  y_top = box.y + box.height
358
409
 
410
+ heights = box.data[:line_heights] ||
411
+ Array.new(lines.length, line_height)
412
+ offset = 0.0
359
413
  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)
414
+ # A math line's slot is taller than a text line; the
415
+ # reference draws the stacked expression at the BOTTOM of
416
+ # its slot (the text-level parens stay at the line top).
417
+ drop = heights[idx] - line_height
418
+ baseline_y = y_top - offset - ascender - drop
419
+ indent = idx.zero? ? 0 : hanging_indent
420
+ line_x = x_origin + indent + line.x_offset
421
+ render_line_runs(canvas, line, line_x, baseline_y, style,
422
+ last_line: idx == lines.length - 1)
423
+ offset += heights[idx]
363
424
  end
364
425
  end
365
426
 
366
- def render_line_runs(canvas, line, line_x, baseline_y, _paragraph_style)
367
- justify = line.justified? ? line.justify_stretch : 0.0
427
+ def render_line_runs(canvas, line, line_x, baseline_y, _paragraph_style, last_line: false)
428
+ justify = line.justified? && !last_line ? line.justify_stretch : 0.0
368
429
  extra_offset = 0.0
369
430
  line.placed_runs.each do |pr|
370
431
  run = pr.run
371
- x = line_x + pr.x_offset + extra_offset
432
+ run_x = line_x + pr.x_offset
372
433
  font_name = run.style.font_name
373
434
  ref = embedded_or_standard(font_name)
374
- payload = encoded_or_text(font_name, run.text)
375
435
  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
436
+ measurer = GlyphMeasurer.new(font_name: font_name)
437
+ cursor = 0.0
438
+ run_start = nil
439
+ text_chunks(run.text, justify).each do |chunk|
440
+ x = run_x + extra_offset + cursor
441
+ run_start ||= x
442
+ payload = encoded_or_text(font_name, chunk)
443
+ opts = { at: [x, y], font: ref, size: size }
444
+ if run.style.character_spacing && !run.style.character_spacing.zero?
445
+ opts[:char_spacing] = run.style.character_spacing
446
+ end
447
+ if run.style.word_spacing && !run.style.word_spacing.zero?
448
+ opts[:word_spacing] = run.style.word_spacing
449
+ end
450
+ record_link_if_needed(run, x, y, size) if cursor.zero?
451
+ canvas.text(payload, **opts)
452
+ cursor += measurer.width_of_string(chunk, font_size: size)
453
+ # Justify must SHRINK as well as stretch: a compressed
454
+ # line (ratio > -1) relies on negative space adjustment —
455
+ # skipping it draws the line at natural width, past the
456
+ # measure.
457
+ extra_offset += (chunk.count(' ') * justify) unless justify.zero?
382
458
  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?
459
+ draw_underline(canvas, run, run_start, y, size, cursor) if run.style.underline
386
460
  end
387
461
  end
388
462
 
463
+ # PDF word spacing (Tw) does not apply to two-byte CID-encoded
464
+ # embedded fonts, so justification cannot rely on it. Instead a
465
+ # justified line is drawn as one positioned text operation per
466
+ # word — FOP's model — with each word's x carrying the stretch
467
+ # accumulated so far. The separating space stays attached to
468
+ # the preceding word so text extraction still sees word gaps.
469
+ def text_chunks(text, justify)
470
+ return [text] if justify.zero?
471
+
472
+ text.split(/(?<=\s)(?=\S)/)
473
+ end
474
+
475
+ # Draws a thin horizontal rule below a run's baseline when the
476
+ # style's `underline` flag is set. Matches FOP/HTML convention:
477
+ # the rule sits ~1pt below baseline, thickness ~5% of font size.
478
+ def draw_underline(canvas, _run, x, baseline_y, size, width)
479
+ weight = [size * 0.05, 0.5].max
480
+ underline_y = baseline_y - (size * 0.18)
481
+ canvas.line(x, underline_y, x + width, underline_y)
482
+ canvas.line_width = weight
483
+ canvas.stroke
484
+ rescue StandardError => e
485
+ Arrolio::Logger.warn "draw_underline failed: #{e.message[0, 60]}"
486
+ end
487
+
389
488
  # Returns [y, font_size] for a run, applying baseline shift and
390
489
  # font size scaling for subscript/superscript runs.
391
490
  # 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