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
@@ -1,9 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'tmpdir'
3
4
  require 'yaml'
4
5
 
5
6
  module Arrolio
6
7
  class GenericFlowBuilder
8
+ INLINE_SVG_PREFIX = 'inline-svg:'
9
+
7
10
  attr_reader :layout_spec, :rules, :asset_resolver
8
11
 
9
12
  def initialize(layout_spec:, rules:, asset_resolver: nil)
@@ -19,32 +22,20 @@ module Arrolio
19
22
  source = content_for(document, sequence['role'])
20
23
  next if sequence['role'].to_s != 'cover' && sequence['role'].to_s != 'back_of_cover' && source.empty?
21
24
 
22
- flowables << sequence_start(document, sequence)
25
+ start = sequence_start(document, sequence)
26
+ flowables << start
27
+ # The part-title overlay consumes layout space in the
28
+ # reference (title glyph top to the first heading: 58pt on
29
+ # the first body page); reserve it so following content
30
+ # lands where the reference puts it.
31
+ flowables << Flowables::Spacer.new(title_block_space) if start.title_template
23
32
  build_sequence_content(document, source, sequence, flowables)
24
33
  flowables << Flowables::PageBreak.new if sequence['page_break_after']
25
34
  end
26
- append_inline_footnote_markers(document, flowables)
27
35
  append_endnotes(document, flowables)
28
36
  flowables
29
37
  end
30
38
 
31
- # Emits a FootnoteMarkerFlowable for each Document#footnote when the
32
- # flavor opts in via `flow_rules.page_bottom_footnotes: true`. The
33
- # engine collects these onto the page where they appear (the last
34
- # body page by default) and the renderer draws them at the bottom.
35
- #
36
- # This is a pragmatic bridge until the adapter can emit markers
37
- # inline at `<fn>` reference sites (TODO 60). Opt-in keeps it
38
- # disabled by default so existing flavors are unaffected.
39
- def append_inline_footnote_markers(document, flowables)
40
- return if document.footnotes.empty?
41
- return unless @rules['page_bottom_footnotes']
42
-
43
- document.footnotes.each do |footnote|
44
- flowables << Flowables::FootnoteMarkerFlowable.new(footnote)
45
- end
46
- end
47
-
48
39
  def append_endnotes(document, flowables)
49
40
  return if document.footnotes.empty?
50
41
  return unless @rules['endnotes']
@@ -72,14 +63,20 @@ module Arrolio
72
63
  Array(@rules['page_sequences'])
73
64
  end
74
65
 
66
+ def title_block_space
67
+ (@rules.dig('title', 'block_space') || 0.0).to_f
68
+ end
69
+
75
70
  def sequence_start(document, sequence)
71
+ title = sequence['role'].to_s == 'body' ? title_text(document) : nil
76
72
  Flowables::PageSequenceStart.new(
77
73
  role: sequence.fetch('role'),
78
74
  header_template: interpolate(sequence['header'], document),
79
75
  footer_template: sequence['footer'],
80
- header_align: sequence['header_align'] || :right,
76
+ header_align: sequence['header_align'],
81
77
  footer_align: sequence['footer_align'] || :center,
82
- initial_page_number: sequence['initial_page_number']
78
+ initial_page_number: sequence['initial_page_number'],
79
+ title_template: title
83
80
  )
84
81
  end
85
82
 
@@ -87,10 +84,31 @@ module Arrolio
87
84
  if sequence['build_content'] == 'cover_content'
88
85
  build_cover_content(document, out)
89
86
  elsif source.is_a?(Array)
90
- build_sections(source, out)
87
+ build_sections(source, out,
88
+ between_breaks: preface_clause_breaks?(sequence))
91
89
  end
92
90
  end
93
91
 
92
+ # mn2pdf gives each preface clause its own page sequence (ToC
93
+ # page, then Foreword on a fresh page) when the flavor opts in.
94
+ def preface_clause_breaks?(sequence)
95
+ sequence['role'].to_s == 'preface' &&
96
+ @rules.dig('preface', 'page_break_between_clauses')
97
+ end
98
+
99
+ # The first body page opens with the part title ("Part 1 -
100
+ # {part title}") - the docidentifier's trailing part number
101
+ # supplies the prefix.
102
+ def title_text(document)
103
+ return nil unless document.title_block
104
+
105
+ text = document.title_block.inline_runs.map(&:text).join.strip
106
+ return nil if text.empty?
107
+
108
+ part = document.metadata[:docidentifier].to_s[/-(\d+)\z/, 1]
109
+ part ? "Part #{part} - #{text}" : text
110
+ end
111
+
94
112
  def content_for(document, role)
95
113
  case role.to_s
96
114
  when 'preface' then document.preface
@@ -116,15 +134,23 @@ module Arrolio
116
134
  end
117
135
  end
118
136
 
119
- def build_sections(sections, out)
137
+ def build_sections(sections, out, between_breaks: false)
120
138
  section_rules = @rules.fetch('section', {})
121
139
  break_between = section_rules.fetch('insert_page_break_before', false)
140
+ break_numbers = section_rules.fetch('page_break_before_numbers', [])
122
141
  sections.each_with_index do |section, index|
123
- out << Flowables::PageBreak.new if break_between && index.positive?
142
+ forced = break_numbers.include?(section.number.to_s)
143
+ needs_break = (break_between || forced || between_breaks) &&
144
+ index.positive? && !bibliography_section?(section)
145
+ out << Flowables::PageBreak.new if needs_break
124
146
  build_section(section, out)
125
147
  end
126
148
  end
127
149
 
150
+ def bibliography_section?(section)
151
+ section.children.any?(Content::BibliographyItem)
152
+ end
153
+
128
154
  def build_section(section, out)
129
155
  if section.heading?
130
156
  out << Flowables::HeadingFlowable.new(
@@ -138,14 +164,24 @@ module Arrolio
138
164
 
139
165
  children = section.children
140
166
  children = prefix_number(section.number, children) if !section.heading? && section.number
141
- children.each { |child| append_child(child, out) }
167
+ children.each_with_index do |child, index|
168
+ append_child(child, out, followed_by_keep: keeps_with_previous?(children[index + 1]))
169
+ end
170
+ end
171
+
172
+ # Tables and lists pull their preceding paragraph with them
173
+ # across page boundaries (XSL-FO keep-with-next on the lead-in;
174
+ # the reference breaks freely between text and figures — 2.3's
175
+ # paragraph ends p5 with Figure 1 opening p6).
176
+ def keeps_with_previous?(child)
177
+ child.is_a?(Content::Table) || child.is_a?(Content::List)
142
178
  end
143
179
 
144
180
  def prefix_number(number, children)
145
181
  return children if children.empty? || !children.first.is_a?(Content::Paragraph)
146
182
 
147
183
  first = children.first
148
- prefix = Content::InlineRun.new("#{number} ", style_id: :caption_label)
184
+ prefix = Content::InlineRun.new("#{number} ", style_id: :caption_label)
149
185
  updated = Content::Paragraph.new(
150
186
  [prefix] + first.inline_runs,
151
187
  style_id: first.style_id,
@@ -157,23 +193,53 @@ module Arrolio
157
193
 
158
194
  def emit_footnote_markers_for(paragraph, out)
159
195
  return if paragraph.footnote_refs.empty?
196
+ return unless @rules['page_bottom_footnotes']
160
197
  return unless @document
161
198
 
162
199
  paragraph.footnote_refs.each do |ref_id|
163
200
  footnote = @document.footnotes.find { |fn| fn.id == ref_id || fn.marker == ref_id }
164
201
  next unless footnote
165
202
 
166
- out << Flowables::FootnoteMarkerFlowable.new(footnote)
203
+ out << Flowables::FootnoteMarkerFlowable.new(
204
+ footnote, style: resolve(:footnote)
205
+ )
167
206
  end
168
207
  end
169
208
 
170
- def append_child(child, out)
209
+ def append_child(child, out, standalone: true, followed_by_keep: false,
210
+ in_term: false)
171
211
  case child
172
212
  when Content::Paragraph
173
- out << paragraph_flowable(child)
213
+ flowable = paragraph_flowable(child, standalone: standalone)
214
+ # mn2pdf keeps a paragraph with the table/figure that
215
+ # follows it, so the caption is never orphaned at a page
216
+ # bottom and the reference's page-end whitespace before
217
+ # table sections is reproduced.
218
+ if followed_by_keep
219
+ flowable = Flowables::TextFlowable.new(
220
+ flowable.runs,
221
+ style: flowable.style.with(keep_with_next: true),
222
+ measurer: flowable.measurer
223
+ )
224
+ end
225
+ out << flowable
174
226
  emit_footnote_markers_for(child, out)
175
227
  when Content::Note
176
- out << note_flowable(child)
228
+ note = note_flowable(child)
229
+ before = in_term ? term_config.fetch('note_spacing', 0.0).to_f : 0.0
230
+ after = in_term ? term_config.fetch('note_spacing_after', before) : 0.0
231
+ parts = []
232
+ parts << Flowables::Spacer.new(before) if before.positive?
233
+ parts << note
234
+ parts << Flowables::Spacer.new(after) if after.positive?
235
+ out << if parts.length == 1
236
+ note
237
+ else
238
+ Flowables::GroupFlowable.new(parts,
239
+ style: Style::Definition.new(keep_together: false))
240
+ end
241
+ when Content::Example
242
+ out << example_flowable(child)
177
243
  when Content::FigureGroup
178
244
  figure_group_flowable(child, out)
179
245
  when Content::TermEntry
@@ -183,7 +249,11 @@ module Arrolio
183
249
  when Content::Section
184
250
  build_section(child, out)
185
251
  when Content::Table
186
- out << Flowables::TableFlowable.new(child, style: resolve(child.style_id))
252
+ out << Flowables::TableFlowable.new(child,
253
+ style: resolve(child.style_id),
254
+ caption_runs: caption_runs_for(child),
255
+ caption_style: resolve(:figure_caption).with(align: :left),
256
+ **table_geometry)
187
257
  when Content::List
188
258
  out << list_flowable(child)
189
259
  when Content::Image
@@ -196,51 +266,230 @@ module Arrolio
196
266
  end
197
267
 
198
268
  def note_flowable(note)
199
- body = note.body.map { |paragraph| paragraph_flowable(paragraph) }
269
+ body = note.body.map { |node| note_body_flowable(node) }
270
+ body = with_note_body_spacing(body)
200
271
  Flowables::NoteFlowable.new(
201
- note.label,
272
+ formatted_note_label(note.label),
202
273
  body,
203
- style: resolve(note.style_id)
274
+ style: resolve(note.style_id),
275
+ label_style: resolve(:note_label)
204
276
  )
205
277
  end
206
278
 
207
- def figure_group_flowable(group, out)
208
- out << image_flowable(group.image) if group.image
209
- return unless group.caption
279
+ # A note's trailing list sits ~7pt below its text in the
280
+ # reference (3.1.3.1: text -> list 20pt vs a plain 13pt pitch).
281
+ def with_note_body_spacing(body)
282
+ spacing = (@rules.dig('note', 'body_spacing') || 0.0).to_f
283
+ return body if spacing.zero? || body.length < 2
284
+
285
+ body.flat_map.with_index do |flowable, index|
286
+ index < body.length - 1 ? [flowable, Flowables::Spacer.new(spacing)] : [flowable]
287
+ end
288
+ end
289
+
290
+ def note_body_flowable(node)
291
+ return list_flowable(node, container: :note) if node.is_a?(Content::List)
292
+
293
+ paragraph_flowable(node)
294
+ end
295
+
296
+ def formatted_note_label(label)
297
+ return '' if label.nil? || label.empty?
298
+
299
+ suffix = @rules.dig('note', 'label_suffix') || ':'
300
+ stripped = label.strip.chomp(':').strip
301
+ return '' if stripped.empty?
210
302
 
211
- caption_style = resolve(:figure_caption)
212
- runs = group.caption.inline_runs.map do |run|
213
- InlineRun.new(run.text, style: caption_style)
303
+ suffix.start_with?(':') ? "#{stripped}#{suffix} " : "#{stripped} #{suffix} "
304
+ end
305
+
306
+ # Examples render the label as a block heading with the body
307
+ # indented 35.4pt — the FOP example layout.
308
+ def example_flowable(example)
309
+ body = example.body.map { |paragraph| paragraph_flowable(paragraph) }
310
+ Flowables::NoteFlowable.new(
311
+ example.label,
312
+ body,
313
+ style: resolve(example.style_id),
314
+ body_indent: 35.4,
315
+ label_mode: :block
316
+ )
317
+ end
318
+
319
+ # Figures are atomic: image + caption render as ONE flowable
320
+ # so the caption can never be orphaned onto the next page.
321
+ # Caption gap is flavor geometry (ref: image bottom to caption
322
+ # ~28pt; figure blocks separated by ~24pt).
323
+ def figure_group_flowable(group, out)
324
+ figure_config = @rules['figure'] || {}
325
+ caption_gap = figure_config.fetch('caption_gap', 0.0).to_f
326
+ block_gap = figure_config.fetch('block_gap', 0.0).to_f
327
+
328
+ image = image_flowable(group.image) if group.image
329
+ if group.caption
330
+ caption_style = resolve(:figure_caption).with(margin_top: caption_gap)
331
+ runs = group.caption.inline_runs.map do |run|
332
+ InlineRun.new(run.text, style: caption_style)
333
+ end
334
+ caption = Flowables::TextFlowable.new(runs, style: caption_style)
214
335
  end
215
- out << Flowables::TextFlowable.new(runs, style: caption_style)
336
+ return out << caption if image.nil?
337
+
338
+ image = with_block_gap(image, block_gap)
339
+ out << if caption
340
+ Flowables::FigureFlowable.new(image, caption)
341
+ else
342
+ image
343
+ end
344
+ end
345
+
346
+ def with_block_gap(flowable, gap)
347
+ return flowable if gap.zero?
348
+
349
+ style = flowable.style.with(margin_top: flowable.style.margin_top + gap)
350
+ flowable.class.new(flowable.src,
351
+ natural_width: flowable.natural_width,
352
+ natural_height: flowable.natural_height,
353
+ display_width: flowable.display_width,
354
+ alt: flowable.alt,
355
+ style: style)
356
+ end
357
+
358
+ # An entry's head must keep number + preferred + two
359
+ # definition lines together (the reference moves whole entry
360
+ # heads when those don't fit, leaving its characteristic
361
+ # 50-90pt terms-region page-end gaps).
362
+ def widen_first_definition_widows(out)
363
+ widows = term_config.fetch('definition_widows', 1).to_i
364
+ last = out.last
365
+ return unless widows > 1 && last.is_a?(Flowables::TextFlowable)
366
+
367
+ out[-1] = Flowables::TextFlowable.new(
368
+ last.runs,
369
+ style: last.style.with(widows: widows),
370
+ measurer: last.measurer
371
+ )
372
+ end
373
+
374
+ # Re-emits the last paragraph flowable with the configured
375
+ # between-sibling space (XSL-FO space-before semantics).
376
+ def apply_sibling_spacing(_item, out)
377
+ spacing = term_config.fetch('definition_paragraph_space_before', 0.0).to_f
378
+ return if spacing.zero? || out.empty?
379
+
380
+ last = out.last
381
+ return unless last.is_a?(Flowables::TextFlowable)
382
+
383
+ out[-1] = Flowables::TextFlowable.new(last.runs,
384
+ style: last.style.with(margin_top: spacing),
385
+ measurer: last.measurer)
386
+ end
387
+
388
+ def term_config
389
+ @rules['term'] || {}
216
390
  end
217
391
 
218
392
  def term_entry_flowable(entry, out)
219
393
  if entry.number
394
+ number_style = resolve(:term).with(margin_top: 12.0, margin_bottom: 0.0)
395
+ out << Flowables::TextFlowable.new(
396
+ [InlineRun.new(entry.number, style: number_style)],
397
+ style: number_style
398
+ )
399
+ end
400
+ if entry.preferred
401
+ preferred_style = resolve(:term).with(margin_top: 0.0, margin_bottom: 6.0)
220
402
  out << Flowables::TextFlowable.new(
221
- [InlineRun.new(entry.number, style: resolve(:term))],
222
- style: resolve(:term)
403
+ entry.preferred.inline_runs.map do |run|
404
+ InlineRun.new(run.text, style: resolve(run.style_id),
405
+ baseline_shift: run.baseline_shift,
406
+ font_size_scale: run.font_size_scale,
407
+ href: run.href)
408
+ end,
409
+ style: preferred_style
223
410
  )
224
411
  end
225
- out << paragraph_flowable(entry.preferred) if entry.preferred
226
- entry.definition.each { |paragraph| out << paragraph_flowable(paragraph) }
412
+ # FOP applies space-before BETWEEN sibling blocks (not before
413
+ # the first) — term definitions' second+ paragraphs (the
414
+ # "(For notes...)" line) and the SOURCE line each carry it.
415
+ entry.definition.each_with_index do |item, index|
416
+ append_child(item, out, standalone: false, in_term: true)
417
+ if index.zero?
418
+ widen_first_definition_widows(out)
419
+ else
420
+ apply_sibling_spacing(item, out)
421
+ end
422
+ end
227
423
  return unless entry.source
228
424
 
229
- out << paragraph_flowable(entry.source)
425
+ source_style = resolve(entry.source.style_id)
426
+ .with(margin_top: term_config.fetch('source_space_before', 2.0).to_f,
427
+ margin_bottom: 2.0)
428
+ source_runs = entry.source.inline_runs.map do |run|
429
+ InlineRun.new(run.text, style: resolve(run.style_id),
430
+ baseline_shift: run.baseline_shift,
431
+ font_size_scale: run.font_size_scale,
432
+ href: run.href)
433
+ end
434
+ out << Flowables::TextFlowable.new(source_runs, style: source_style)
230
435
  end
231
436
 
232
437
  def bibliography_item_flowable(item, out)
233
- marker_text = item.tag.to_s
234
- body = item.formattedref ? [paragraph_flowable(item.formattedref)] : []
438
+ tag = item.tag.to_s
439
+ body_para = bibliography_body_paragraph(item)
440
+ if tag.empty?
441
+ out << paragraph_flowable(body_para)
442
+ return
443
+ end
444
+
445
+ body = paragraph_flowable(body_para)
235
446
  out << Flowables::NoteFlowable.new(
236
- marker_text,
237
- body,
238
- style: resolve(item.style_id),
239
- marker_width: 36.0
447
+ tag,
448
+ [body],
449
+ style: resolve(item.style_id || :bibitem),
450
+ label_style: resolve(:bibitem_marker),
451
+ marker_width: 24.0
240
452
  )
241
453
  end
242
454
 
243
- def paragraph_flowable(paragraph)
455
+ def bibliography_body_paragraph(item)
456
+ runs = []
457
+ if item.formattedref
458
+ runs.concat(item.formattedref.inline_runs.map do |r|
459
+ Content::InlineRun.new(r.text, style_id: r.style_id)
460
+ end)
461
+ end
462
+ Content::Paragraph.new(runs, style_id: item.style_id || :bibitem,
463
+ id: item.id)
464
+ end
465
+
466
+ def marker_width_of(tag)
467
+ return 0.0 if tag.to_s.empty?
468
+
469
+ style = resolve(:bibitem_marker)
470
+ GlyphMeasurer.new(font_name: style.font_name)
471
+ .width_of_string("#{tag} ", font_size: style.font_size)
472
+ end
473
+
474
+ def caption_runs_for(table)
475
+ return nil if table.caption.nil?
476
+
477
+ paragraph_flowable(table.caption, standalone: false).runs
478
+ end
479
+
480
+ # Table geometry (minimum row height, cell padding, footnote
481
+ # size) is flavor configuration — extracted from the flavor's
482
+ # XSL row/cell styles — not engine policy.
483
+ def table_geometry
484
+ config = @rules['table'] || {}
485
+ {
486
+ min_row_height: config.fetch('min_row_height', 0.0).to_f,
487
+ cell_padding: config.fetch('cell_padding', 2.0).to_f,
488
+ footnote_font_size: config['footnote_font_size']&.to_f
489
+ }
490
+ end
491
+
492
+ def paragraph_flowable(paragraph, standalone: false)
244
493
  runs = paragraph.inline_runs.map do |run|
245
494
  InlineRun.new(
246
495
  run.text,
@@ -250,7 +499,9 @@ module Arrolio
250
499
  href: run.href
251
500
  )
252
501
  end
253
- Flowables::TextFlowable.new(runs, style: resolve(paragraph.style_id))
502
+ style = resolve(paragraph.style_id)
503
+ style = style.with(margin_bottom: 10.0) if standalone && paragraph.style_id == :body
504
+ Flowables::TextFlowable.new(runs, style: style)
254
505
  end
255
506
 
256
507
  def preformatted_flowable(preformatted)
@@ -263,29 +514,64 @@ module Arrolio
263
514
  Flowables::TextFlowable.new(runs, style: style)
264
515
  end
265
516
 
266
- def list_flowable(list)
517
+ def list_flowable(list, container: nil)
267
518
  items = list.items.each_with_index.map do |item, index|
268
- marker = item.marker.nil? ? default_marker(list, index) : item.marker
269
- body = item.content.filter_map do |content|
270
- paragraph_flowable(content) if content.is_a?(Content::Paragraph)
519
+ marker = item.marker.nil? ? default_marker(list, index, container: container) : item.marker
520
+ body = item.content.flat_map do |content|
521
+ flowable_for_list_content(content)
271
522
  end
272
523
  [marker, body]
273
524
  end
274
- Flowables::ListFlowable.new(items, kind: list.kind, style: resolve(list.style_id))
525
+ Flowables::ListFlowable.new(items, kind: list.kind,
526
+ style: resolve(list.style_id),
527
+ **list_geometry(list.kind, container: container))
528
+ end
529
+
530
+ # List marker geometry (indent, marker column, inter-item
531
+ # spacing) is flavor configuration extracted from the flavor's
532
+ # XSL list styles — not engine policy.
533
+ # A kind-specific map (e.g. geometry.ordered) overrides the
534
+ # shared defaults — flavors whose bullets indent but whose
535
+ # ordered markers start at the margin (which also restores
536
+ # nested lists' depth).
537
+ def list_geometry(kind, container: nil)
538
+ config = @rules.dig('list', 'geometry') || {}
539
+ config = config.merge(config[kind.to_s] || {})
540
+ # Lists nested in note bodies sit deeper: the reference puts
541
+ # 3.1.3.1's note bullets at +43.7pt from the note column
542
+ # (marker x=143 from the page), pitch 17pt.
543
+ config = config.merge(@rules.dig('note', 'list') || {}) if container == :note
544
+ {
545
+ marker_indent: config.fetch('marker_indent', 0.0).to_f,
546
+ marker_width: config.fetch('marker_width', 18.0).to_f,
547
+ body_indent: config.fetch('body_indent', 6.0).to_f,
548
+ item_spacing: config.fetch('item_spacing', 0.0).to_f
549
+ }
275
550
  end
276
551
 
277
- def default_marker(list, index)
552
+ def flowable_for_list_content(content)
553
+ case content
554
+ when Content::Paragraph then [paragraph_flowable(content)]
555
+ when Content::List then [list_flowable(content)]
556
+ else []
557
+ end
558
+ end
559
+
560
+ def default_marker(list, index, container: nil)
278
561
  defaults = @rules.dig('list', 'defaults') || {}
279
562
  if list.ordered?
280
563
  format = defaults['ordered_marker'] || '%d.'
281
564
  format.sub('%d', (index + 1).to_s)
282
565
  else
566
+ nested = @rules.dig('note', 'list') || {}
567
+ return nested['bullet_marker'] if container == :note && nested['bullet_marker']
568
+
283
569
  defaults['bullet_marker'] || '■ '
284
570
  end
285
571
  end
286
572
 
287
573
  def image_flowable(image)
288
- source = asset_resolver.resolve(image.src)
574
+ source = resolve_image_source(image)
289
575
  image_rules = @rules['image'] || {}
290
576
  default_width = (image_rules['default_natural_width'] || 400).to_f
291
577
  default_height = (image_rules['default_natural_height'] || 300).to_f
@@ -303,11 +589,25 @@ module Arrolio
303
589
  )
304
590
  end
305
591
 
592
+ def resolve_image_source(image)
593
+ return asset_resolver.resolve(image.src) unless image.src.is_a?(String)
594
+ return write_inline_svg(image.src) if image.src.start_with?(INLINE_SVG_PREFIX)
595
+
596
+ asset_resolver.resolve(image.src)
597
+ end
598
+
599
+ def write_inline_svg(prefixed)
600
+ svg_xml = prefixed[INLINE_SVG_PREFIX.length..]
601
+ path = File.join(Dir.mktmpdir('arrolio-svg'), 'figure.svg')
602
+ File.write(path, svg_xml)
603
+ path
604
+ end
605
+
306
606
  def svg_dimension(source, name)
307
607
  return nil unless source && File.exist?(source) && source.match?(/\.svg\z/i)
308
608
 
309
609
  value = File.read(source)[/(?:#{name})=["']([\d.]+)/, 1]
310
- value&.to_f
610
+ value && (value.to_f * SVG_PX_TO_PT)
311
611
  end
312
612
 
313
613
  def resolve(style_id)
@@ -21,17 +21,28 @@ module Arrolio
21
21
  !@metrics.nil?
22
22
  end
23
23
 
24
- def width_of_string(str, font_size:)
25
- return estimate(str, font_size) unless available?
24
+ def metrics_for(font_name)
25
+ return @metrics if font_name.nil? || font_name.to_s == @font_name
26
26
 
27
- @metrics.width_of_string(str, font_size: font_size)
27
+ ::Arrolio::FontMetrics::Registry[font_name.to_s]
28
+ end
29
+
30
+ # +font_name+ lets a paragraph-level measurer price a run that
31
+ # carries a different font (e.g. a bold run inside a regular
32
+ # paragraph). Falls back to the instance font when nil.
33
+ def width_of_string(str, font_size:, font_name: nil)
34
+ metrics = metrics_for(font_name)
35
+ return estimate(str, font_size) if metrics.nil?
36
+
37
+ metrics.width_of_string(str, font_size: font_size)
28
38
  end
29
39
 
30
40
  # Width of a run including character_spacing (Tc — extra space
31
41
  # added after every glyph) and word_spacing (Tw — extra space
32
42
  # added after every space glyph). Matches PDF rendering rules.
33
- def width_of_run(str, font_size:, character_spacing: 0, word_spacing: 0)
34
- base = width_of_string(str, font_size: font_size)
43
+ def width_of_run(str, font_size:, character_spacing: 0, word_spacing: 0,
44
+ font_name: nil)
45
+ base = width_of_string(str, font_size: font_size, font_name: font_name)
35
46
  char_count = str.length
36
47
  word_count = str.count('  ')
37
48
  extra = (character_spacing.to_f * [char_count - 1, 0].max) +
@@ -35,7 +35,8 @@ module Arrolio
35
35
  @text,
36
36
  font_size: font_size * @font_size_scale,
37
37
  character_spacing: style.character_spacing,
38
- word_spacing: style.word_spacing
38
+ word_spacing: style.word_spacing,
39
+ font_name: style.font_name
39
40
  )
40
41
  end
41
42
 
@@ -87,27 +87,33 @@ module Arrolio
87
87
  { before: 0.0, after: 0.0, start: 0.0, end: 0.0 }
88
88
  end
89
89
 
90
+ # XSL-FO geometry: the body region is the page content
91
+ # rectangle (page minus page margins). The before/after/start/
92
+ # end extents size the auxiliary regions that sit INSIDE the
93
+ # margins — they never displace the body. Shrinking the body
94
+ # by the extents wasted the whole header/footer strip on
95
+ # every page.
90
96
  def compute_regions
91
97
  pw = width
92
98
  ph = height
93
99
  m = @margins
94
100
  e = @region_extents
95
- inner_w = pw - m[:left] - m[:right] - e[:start] - e[:end]
96
- inner_h = ph - m[:top] - m[:bottom] - e[:before] - e[:after]
101
+ inner_w = pw - m[:left] - m[:right]
102
+ inner_h = ph - m[:top] - m[:bottom]
97
103
  {
98
104
  body: Region.new(name: :body,
99
- x: m[:left] + e[:start],
100
- y: m[:bottom] + e[:after],
105
+ x: m[:left],
106
+ y: m[:bottom],
101
107
  width: inner_w,
102
108
  height: inner_h),
103
109
  before: Region.new(name: :before,
104
- x: m[:left] + e[:start],
105
- y: ph - m[:top] - e[:before],
110
+ x: m[:left],
111
+ y: ph - m[:top],
106
112
  width: inner_w,
107
113
  height: e[:before]),
108
114
  after: Region.new(name: :after,
109
- x: m[:left] + e[:start],
110
- y: m[:bottom],
115
+ x: m[:left],
116
+ y: m[:bottom] - e[:after],
111
117
  width: inner_w,
112
118
  height: e[:after]),
113
119
  start: Region.new(name: :start,