arrolio 0.1.0

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 (272) hide show
  1. checksums.yaml +7 -0
  2. data/.rspec +2 -0
  3. data/.rubocop.yml +343 -0
  4. data/CHANGELOG.md +18 -0
  5. data/CLAUDE.md +154 -0
  6. data/Gemfile +15 -0
  7. data/LICENSE +24 -0
  8. data/README.adoc +95 -0
  9. data/Rakefile +21 -0
  10. data/TODO.complete/01-true-type-font-metrics.md +56 -0
  11. data/TODO.complete/02-font-embedding.md +51 -0
  12. data/TODO.complete/03-knuth-plass-breaker.md +42 -0
  13. data/TODO.complete/04-table-flowable.md +60 -0
  14. data/TODO.complete/05-list-flowable.md +50 -0
  15. data/TODO.complete/06-notes-and-examples.md +41 -0
  16. data/TODO.complete/07-odd-even-templates-and-page-numbering.md +63 -0
  17. data/TODO.complete/08-image-flowable.md +45 -0
  18. data/TODO.complete/09-toc-and-leaders.md +66 -0
  19. data/TODO.complete/10-hyperlinks-and-destinations.md +46 -0
  20. data/TODO.complete/11-pdf-outline-bookmarks.md +47 -0
  21. data/TODO.complete/12-metadata-info-dict.md +42 -0
  22. data/TODO.complete/13-xmp-metadata.md +43 -0
  23. data/TODO.complete/14-footnotes.md +43 -0
  24. data/TODO.complete/15-color-management.md +33 -0
  25. data/TODO.complete/16-pdfua-struct-tree.md +35 -0
  26. data/TODO.complete/17-alt-text-and-actual-text.md +28 -0
  27. data/TODO.complete/18-math-rendering.md +41 -0
  28. data/TODO.complete/19-preformatted-and-code.md +31 -0
  29. data/TODO.complete/20-page-break-controls.md +46 -0
  30. data/TODO.complete/21-svg-renderer.md +40 -0
  31. data/TODO.complete/22-definition-lists.md +36 -0
  32. data/TODO.complete/23-multi-column.md +34 -0
  33. data/TODO.complete/24-cover-page-fidelity.md +40 -0
  34. data/TODO.complete/25-i18n-and-multi-language.md +28 -0
  35. data/TODO.complete/26-pixel-diff-harness.md +36 -0
  36. data/TODO.complete/27-pdfrb-only-text-extraction.md +37 -0
  37. data/TODO.complete/28-restore-nested-module-style.md +41 -0
  38. data/TODO.complete/29-font-embedding-wiring-debug.md +72 -0
  39. data/TODO.complete/30-adapter-decomposition.md +76 -0
  40. data/TODO.complete/31-subscript-superscript-inline-runs.md +56 -0
  41. data/TODO.complete/32-debug-output-cleanup.md +59 -0
  42. data/TODO.complete/33-comprehensive-adapter-specs.md +55 -0
  43. data/TODO.complete/34-table-column-auto-layout.md +50 -0
  44. data/TODO.complete/35-image-path-resolution.md +66 -0
  45. data/TODO.complete/36-xsl-converter-completeness.md +40 -0
  46. data/TODO.complete/37-content-model-completeness.md +54 -0
  47. data/TODO.complete/38-pipeline-config-externalization.md +55 -0
  48. data/TODO.complete/39-two-pass-cross-reference-resolution.md +66 -0
  49. data/TODO.complete/40-vertical-writing-modes.md +63 -0
  50. data/TODO.complete/41-pdf-annotations-hyperlinks.md +53 -0
  51. data/TODO.complete/42-font-manifest-configuration.md +75 -0
  52. data/TODO.complete/43-shrink-to-fit-containers.md +52 -0
  53. data/TODO.complete/44-back-of-book-index-generation.md +51 -0
  54. data/TODO.complete/45-pdf-forms-acroform.md +52 -0
  55. data/TODO.complete/46-pdf-digital-signatures.md +47 -0
  56. data/TODO.complete/47-ocp-flavor-separation.md +50 -0
  57. data/TODO.complete/48-oiml-as-pure-configuration.md +80 -0
  58. data/TODO.complete/49-oiml-flowbuilder-as-configuration.md +39 -0
  59. data/TODO.complete/50-delete-oiml-ruby-code.md +53 -0
  60. data/TODO.complete/51-xsl-as-authoritative-source.md +68 -0
  61. data/TODO.complete/52-flavor-packaging-separation.md +49 -0
  62. data/TODO.complete/53-second-flavor-spike.md +74 -0
  63. data/TODO.complete/54-flavor-manifest-contract.md +72 -0
  64. data/TODO.complete/55-header-footer-style-configuration.md +57 -0
  65. data/TODO.complete/56-generic-adapter-mece-decomposition.md +56 -0
  66. data/TODO.complete/57-strict-section-typing.md +49 -0
  67. data/TODO.complete/58-flavor-packaging-gem-convention.md +47 -0
  68. data/TODO.complete/59-xsl-converter-profiles.md +76 -0
  69. data/TODO.complete/60-adapter-fidelity-footnotes-notes-examples.md +62 -0
  70. data/TODO.complete/61-error-model-and-strict-mode.md +62 -0
  71. data/TODO.complete/62-font-resolution-and-strict-fonts.md +66 -0
  72. data/TODO.complete/63-purge-hardcoded-vocabulary-from-generic-core.md +68 -0
  73. data/TODO.complete/64-selector-driven-adapter-specs.md +46 -0
  74. data/TODO.complete/66-semantic-content-types.md +171 -0
  75. data/TODO.complete/README.md +48 -0
  76. data/TODO.implementation/01-foundation.md +46 -0
  77. data/TODO.implementation/02-content-contract.md +47 -0
  78. data/TODO.implementation/03-layoutspec-contract.md +46 -0
  79. data/TODO.implementation/04-style-system.md +43 -0
  80. data/TODO.implementation/05-visitor-pattern.md +40 -0
  81. data/TODO.implementation/06-afm-data-and-parser.md +37 -0
  82. data/TODO.implementation/07-afm-metrics.md +41 -0
  83. data/TODO.implementation/08-truetype-tables-and-metrics.md +48 -0
  84. data/TODO.implementation/09-font-registry-and-glyph-measurer.md +49 -0
  85. data/TODO.implementation/10-break-opportunity-and-inline-run.md +47 -0
  86. data/TODO.implementation/11-greedy-line-breaker.md +48 -0
  87. data/TODO.implementation/12-knuth-plass-line-breaker.md +46 -0
  88. data/TODO.implementation/13-line-and-alignment.md +45 -0
  89. data/TODO.implementation/14-knuth-elements-and-breaker.md +56 -0
  90. data/TODO.implementation/15-refactor-text-layout-on-knuth.md +47 -0
  91. data/TODO.implementation/16-flowable-and-frame.md +52 -0
  92. data/TODO.implementation/17-flow-context.md +50 -0
  93. data/TODO.implementation/18-text-flowable.md +52 -0
  94. data/TODO.implementation/19-spacer-and-page-break.md +52 -0
  95. data/TODO.implementation/20-engine-paged.md +63 -0
  96. data/TODO.implementation/21-output-tree.md +56 -0
  97. data/TODO.implementation/22-pdf-renderer.md +77 -0
  98. data/TODO.implementation/23-end-to-end-smoke.md +53 -0
  99. data/TODO.implementation/24-page-template-and-region.md +51 -0
  100. data/TODO.implementation/25-page-sequence-master.md +54 -0
  101. data/TODO.implementation/26-two-pass-layout.md +66 -0
  102. data/TODO.implementation/27-table-model.md +48 -0
  103. data/TODO.implementation/28-table-layouts.md +53 -0
  104. data/TODO.implementation/29-table-renderer.md +55 -0
  105. data/TODO.implementation/30-table-flowable.md +53 -0
  106. data/TODO.implementation/31-list-model.md +45 -0
  107. data/TODO.implementation/32-list-flowable.md +54 -0
  108. data/TODO.implementation/33-image-flowable.md +59 -0
  109. data/TODO.implementation/34-svg-renderer.md +58 -0
  110. data/TODO.implementation/35-svg-as-form-xobject.md +38 -0
  111. data/TODO.implementation/36-inline-builder.md +50 -0
  112. data/TODO.implementation/37-hyperlink.md +53 -0
  113. data/TODO.implementation/38-sub-superscript.md +44 -0
  114. data/TODO.implementation/39-leader-and-field-runs.md +60 -0
  115. data/TODO.implementation/40-bookmark-outline.md +45 -0
  116. data/TODO.implementation/41-destinations.md +52 -0
  117. data/TODO.implementation/42-toc-builder.md +52 -0
  118. data/TODO.implementation/43-footnotes.md +51 -0
  119. data/TODO.implementation/44-multi-column.md +45 -0
  120. data/TODO.implementation/45-page-numbering-and-section-tracking.md +50 -0
  121. data/TODO.implementation/46-color-management.md +47 -0
  122. data/TODO.implementation/47-metadata-and-xmp.md +41 -0
  123. data/TODO.implementation/48-struct-tree-and-marked-content.md +49 -0
  124. data/TODO.implementation/49-alt-text-and-actual-text.md +43 -0
  125. data/TODO.implementation/50-composer-facade.md +59 -0
  126. data/TODO.implementation/51-style-presets.md +44 -0
  127. data/TODO.implementation/52-oiml-adapter-and-e2e-pipeline.md +120 -0
  128. data/TODO.implementation/53-oiml-layout-spec.md +121 -0
  129. data/TODO.implementation/54-oiml-pipeline.md +97 -0
  130. data/TODO.implementation/55-pdf-comparator.md +98 -0
  131. data/TODO.implementation/56-diff-formatters.md +95 -0
  132. data/TODO.implementation/57-path-locator-and-enrichment.md +74 -0
  133. data/TODO.implementation/58-rspec-matchers-and-harness-api.md +124 -0
  134. data/TODO.implementation/59-pixel-diff-mode.md +93 -0
  135. data/TODO.implementation/60-yard-documentation.md +43 -0
  136. data/TODO.implementation/61-benchmarks-and-stress-tests.md +50 -0
  137. data/TODO.implementation/62-validation-framework.md +107 -0
  138. data/TODO.implementation/63-layout-rules-profile.md +110 -0
  139. data/TODO.implementation/64-pdfa-and-pdfua-conformance.md +74 -0
  140. data/TODO.implementation/65-feature-extractor.md +64 -0
  141. data/TODO.implementation/66-auto-fixer.md +75 -0
  142. data/TODO.implementation/README.md +169 -0
  143. data/data/arrolio/afm/Courier-Bold.afm +342 -0
  144. data/data/arrolio/afm/Courier-BoldOblique.afm +342 -0
  145. data/data/arrolio/afm/Courier-Oblique.afm +342 -0
  146. data/data/arrolio/afm/Courier.afm +342 -0
  147. data/data/arrolio/afm/Helvetica-Bold.afm +2827 -0
  148. data/data/arrolio/afm/Helvetica-BoldOblique.afm +2827 -0
  149. data/data/arrolio/afm/Helvetica-Oblique.afm +3051 -0
  150. data/data/arrolio/afm/Helvetica.afm +3051 -0
  151. data/data/arrolio/afm/Symbol.afm +213 -0
  152. data/data/arrolio/afm/Times-Bold.afm +2588 -0
  153. data/data/arrolio/afm/Times-BoldItalic.afm +2384 -0
  154. data/data/arrolio/afm/Times-Italic.afm +2667 -0
  155. data/data/arrolio/afm/Times-Roman.afm +2419 -0
  156. data/data/arrolio/afm/ZapfDingbats.afm +225 -0
  157. data/data/arrolio/glyphlist.txt +4306 -0
  158. data/exe/arrolio2pdf +28 -0
  159. data/lib/arrolio/asset_resolver.rb +36 -0
  160. data/lib/arrolio/color.rb +107 -0
  161. data/lib/arrolio/column_set.rb +64 -0
  162. data/lib/arrolio/composer.rb +10 -0
  163. data/lib/arrolio/config_driven_pipeline.rb +144 -0
  164. data/lib/arrolio/content/bibliography_item.rb +42 -0
  165. data/lib/arrolio/content/builder.rb +108 -0
  166. data/lib/arrolio/content/document.rb +60 -0
  167. data/lib/arrolio/content/example.rb +43 -0
  168. data/lib/arrolio/content/figure_group.rb +39 -0
  169. data/lib/arrolio/content/footnote.rb +46 -0
  170. data/lib/arrolio/content/form_field.rb +47 -0
  171. data/lib/arrolio/content/formula.rb +34 -0
  172. data/lib/arrolio/content/heading.rb +42 -0
  173. data/lib/arrolio/content/hyperlink.rb +42 -0
  174. data/lib/arrolio/content/image.rb +36 -0
  175. data/lib/arrolio/content/index_entry.rb +52 -0
  176. data/lib/arrolio/content/inline_run.rb +51 -0
  177. data/lib/arrolio/content/list.rb +60 -0
  178. data/lib/arrolio/content/note.rb +48 -0
  179. data/lib/arrolio/content/page_break.rb +28 -0
  180. data/lib/arrolio/content/paragraph.rb +38 -0
  181. data/lib/arrolio/content/preformatted.rb +47 -0
  182. data/lib/arrolio/content/section.rb +39 -0
  183. data/lib/arrolio/content/table.rb +117 -0
  184. data/lib/arrolio/content/term_entry.rb +43 -0
  185. data/lib/arrolio/content.rb +27 -0
  186. data/lib/arrolio/data_dir.rb +28 -0
  187. data/lib/arrolio/engine/cross_reference_registry.rb +69 -0
  188. data/lib/arrolio/engine/paged.rb +186 -0
  189. data/lib/arrolio/engine.rb +9 -0
  190. data/lib/arrolio/error.rb +70 -0
  191. data/lib/arrolio/flavor/manifest.rb +111 -0
  192. data/lib/arrolio/flavor/registry.rb +54 -0
  193. data/lib/arrolio/flavor.rb +23 -0
  194. data/lib/arrolio/flow_context.rb +37 -0
  195. data/lib/arrolio/flowable.rb +66 -0
  196. data/lib/arrolio/flowables/footnote_marker_flowable.rb +28 -0
  197. data/lib/arrolio/flowables/heading_flowable.rb +23 -0
  198. data/lib/arrolio/flowables/image_flowable.rb +53 -0
  199. data/lib/arrolio/flowables/list_flowable.rb +117 -0
  200. data/lib/arrolio/flowables/note_flowable.rb +19 -0
  201. data/lib/arrolio/flowables/page_break.rb +15 -0
  202. data/lib/arrolio/flowables/page_sequence_start.rb +28 -0
  203. data/lib/arrolio/flowables/spacer.rb +34 -0
  204. data/lib/arrolio/flowables/table_flowable.rb +147 -0
  205. data/lib/arrolio/flowables/text_flowable.rb +95 -0
  206. data/lib/arrolio/flowables/toc_line_flowable.rb +35 -0
  207. data/lib/arrolio/flowables/two_column_block.rb +56 -0
  208. data/lib/arrolio/flowables.rb +19 -0
  209. data/lib/arrolio/font/embedder.rb +197 -0
  210. data/lib/arrolio/font/manifest.rb +114 -0
  211. data/lib/arrolio/font/resolver.rb +161 -0
  212. data/lib/arrolio/font/text_encoder.rb +28 -0
  213. data/lib/arrolio/font.rb +10 -0
  214. data/lib/arrolio/font_metrics/afm_metrics.rb +91 -0
  215. data/lib/arrolio/font_metrics/registry.rb +55 -0
  216. data/lib/arrolio/font_metrics/true_type_metrics.rb +95 -0
  217. data/lib/arrolio/font_metrics.rb +10 -0
  218. data/lib/arrolio/frame.rb +36 -0
  219. data/lib/arrolio/generic_adapter.rb +672 -0
  220. data/lib/arrolio/generic_flow_builder.rb +338 -0
  221. data/lib/arrolio/glyph_measurer.rb +71 -0
  222. data/lib/arrolio/harness/pdf_diff.rb +104 -0
  223. data/lib/arrolio/harness/pixel_diff.rb +122 -0
  224. data/lib/arrolio/harness/text_diff.rb +53 -0
  225. data/lib/arrolio/harness/text_extractor.rb +58 -0
  226. data/lib/arrolio/harness.rb +10 -0
  227. data/lib/arrolio/inline_run.rb +59 -0
  228. data/lib/arrolio/layout_spec/flow.rb +17 -0
  229. data/lib/arrolio/layout_spec/loader.rb +117 -0
  230. data/lib/arrolio/layout_spec/page_template.rb +127 -0
  231. data/lib/arrolio/layout_spec/page_template_selector.rb +58 -0
  232. data/lib/arrolio/layout_spec/region.rb +38 -0
  233. data/lib/arrolio/layout_spec/scale_length.rb +34 -0
  234. data/lib/arrolio/layout_spec.rb +90 -0
  235. data/lib/arrolio/logger.rb +54 -0
  236. data/lib/arrolio/output/page.rb +69 -0
  237. data/lib/arrolio/output/placed_box.rb +68 -0
  238. data/lib/arrolio/output/region.rb +32 -0
  239. data/lib/arrolio/output.rb +10 -0
  240. data/lib/arrolio/renderer/accessibility_tagger.rb +70 -0
  241. data/lib/arrolio/renderer/font_registry.rb +32 -0
  242. data/lib/arrolio/renderer/link_annotator.rb +62 -0
  243. data/lib/arrolio/renderer/outline_builder.rb +102 -0
  244. data/lib/arrolio/renderer/pdf.rb +493 -0
  245. data/lib/arrolio/renderer/signature_config.rb +59 -0
  246. data/lib/arrolio/renderer/structure_tree_builder.rb +117 -0
  247. data/lib/arrolio/renderer/xmp_builder.rb +92 -0
  248. data/lib/arrolio/renderer.rb +17 -0
  249. data/lib/arrolio/style/definition.rb +98 -0
  250. data/lib/arrolio/style/loader.rb +25 -0
  251. data/lib/arrolio/style/registry.rb +53 -0
  252. data/lib/arrolio/style.rb +9 -0
  253. data/lib/arrolio/table/auto_layout.rb +91 -0
  254. data/lib/arrolio/table.rb +9 -0
  255. data/lib/arrolio/text_direction.rb +81 -0
  256. data/lib/arrolio/text_layout/break_opportunity.rb +103 -0
  257. data/lib/arrolio/text_layout/greedy.rb +110 -0
  258. data/lib/arrolio/text_layout/knuth_plass/breaker.rb +265 -0
  259. data/lib/arrolio/text_layout/knuth_plass/item.rb +84 -0
  260. data/lib/arrolio/text_layout/knuth_plass/item_builder.rb +103 -0
  261. data/lib/arrolio/text_layout/knuth_plass.rb +12 -0
  262. data/lib/arrolio/text_layout/line.rb +72 -0
  263. data/lib/arrolio/text_layout.rb +11 -0
  264. data/lib/arrolio/toc_builder.rb +31 -0
  265. data/lib/arrolio/version.rb +5 -0
  266. data/lib/arrolio/writing_mode.rb +75 -0
  267. data/lib/arrolio.rb +42 -0
  268. data/script/smoke.rb +32 -0
  269. data/scripts/write_content_extras.py +227 -0
  270. data/scripts/xsl_profiles/standoc.yml +218 -0
  271. data/scripts/xsl_to_config.rb +391 -0
  272. metadata +394 -0
@@ -0,0 +1,338 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'yaml'
4
+
5
+ module Arrolio
6
+ class GenericFlowBuilder
7
+ attr_reader :layout_spec, :rules, :asset_resolver
8
+
9
+ def initialize(layout_spec:, rules:, asset_resolver: nil)
10
+ @layout_spec = layout_spec
11
+ @rules = rules.is_a?(String) ? YAML.safe_load_file(rules, aliases: true) : rules
12
+ @asset_resolver = asset_resolver || AssetResolver.new(base_dirs: [])
13
+ end
14
+
15
+ def build(document)
16
+ @document = document
17
+ flowables = []
18
+ page_sequences.each do |sequence|
19
+ source = content_for(document, sequence['role'])
20
+ next if sequence['role'].to_s != 'cover' && sequence['role'].to_s != 'back_of_cover' && source.empty?
21
+
22
+ flowables << sequence_start(document, sequence)
23
+ build_sequence_content(document, source, sequence, flowables)
24
+ flowables << Flowables::PageBreak.new if sequence['page_break_after']
25
+ end
26
+ append_inline_footnote_markers(document, flowables)
27
+ append_endnotes(document, flowables)
28
+ flowables
29
+ end
30
+
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
+ def append_endnotes(document, flowables)
49
+ return if document.footnotes.empty?
50
+ return unless @rules['endnotes']
51
+
52
+ flowables << Flowables::PageSequenceStart.new(
53
+ role: :endnotes,
54
+ header_template: nil,
55
+ footer_template: nil
56
+ )
57
+ document.footnotes.each do |footnote|
58
+ marker_text = footnote.marker.to_s
59
+ body = footnote.body.map { |paragraph| paragraph_flowable(paragraph) }
60
+ flowables << Flowables::NoteFlowable.new(
61
+ marker_text,
62
+ body,
63
+ style: resolve(footnote.style_id),
64
+ marker_width: 22.0
65
+ )
66
+ end
67
+ end
68
+
69
+ private
70
+
71
+ def page_sequences
72
+ Array(@rules['page_sequences'])
73
+ end
74
+
75
+ def sequence_start(document, sequence)
76
+ Flowables::PageSequenceStart.new(
77
+ role: sequence.fetch('role'),
78
+ header_template: interpolate(sequence['header'], document),
79
+ footer_template: sequence['footer'],
80
+ header_align: sequence['header_align'] || :right,
81
+ footer_align: sequence['footer_align'] || :center,
82
+ initial_page_number: sequence['initial_page_number']
83
+ )
84
+ end
85
+
86
+ def build_sequence_content(document, source, sequence, out)
87
+ if sequence['build_content'] == 'cover_content'
88
+ build_cover_content(document, out)
89
+ elsif source.is_a?(Array)
90
+ build_sections(source, out)
91
+ end
92
+ end
93
+
94
+ def content_for(document, role)
95
+ case role.to_s
96
+ when 'preface' then document.preface
97
+ when 'body' then document.sections
98
+ when 'bibliography' then document.bibliography
99
+ else []
100
+ end
101
+ end
102
+
103
+ def build_cover_content(document, out)
104
+ Array(@rules['cover_content']).each do |entry|
105
+ next unless condition_matches?(entry['when'], document)
106
+
107
+ case entry['type'].to_s
108
+ when 'spacer'
109
+ out << Flowables::Spacer.new(entry.fetch('size').to_f)
110
+ when 'text'
111
+ style = resolve(entry.fetch('style'))
112
+ style = style.with(align: :center) if entry.fetch('align', 'center').to_sym == :center
113
+ text = interpolate(entry.fetch('source'), document)
114
+ out << Flowables::TextFlowable.new([InlineRun.new(text, style: style)], style: style)
115
+ end
116
+ end
117
+ end
118
+
119
+ def build_sections(sections, out)
120
+ section_rules = @rules.fetch('section', {})
121
+ break_between = section_rules.fetch('insert_page_break_before', false)
122
+ sections.each_with_index do |section, index|
123
+ out << Flowables::PageBreak.new if break_between && index.positive?
124
+ build_section(section, out)
125
+ end
126
+ end
127
+
128
+ def build_section(section, out)
129
+ if section.heading?
130
+ out << Flowables::HeadingFlowable.new(
131
+ section.title.to_s,
132
+ level: section.level,
133
+ number: section.number,
134
+ id: section.id,
135
+ style: resolve(section.title_style_id)
136
+ )
137
+ end
138
+
139
+ children = section.children
140
+ children = prefix_number(section.number, children) if !section.heading? && section.number
141
+ children.each { |child| append_child(child, out) }
142
+ end
143
+
144
+ def prefix_number(number, children)
145
+ return children if children.empty? || !children.first.is_a?(Content::Paragraph)
146
+
147
+ first = children.first
148
+ prefix = Content::InlineRun.new("#{number} ", style_id: :caption_label)
149
+ updated = Content::Paragraph.new(
150
+ [prefix] + first.inline_runs,
151
+ style_id: first.style_id,
152
+ id: first.id,
153
+ footnote_refs: first.footnote_refs
154
+ )
155
+ [updated] + children.drop(1)
156
+ end
157
+
158
+ def emit_footnote_markers_for(paragraph, out)
159
+ return if paragraph.footnote_refs.empty?
160
+ return unless @document
161
+
162
+ paragraph.footnote_refs.each do |ref_id|
163
+ footnote = @document.footnotes.find { |fn| fn.id == ref_id || fn.marker == ref_id }
164
+ next unless footnote
165
+
166
+ out << Flowables::FootnoteMarkerFlowable.new(footnote)
167
+ end
168
+ end
169
+
170
+ def append_child(child, out)
171
+ case child
172
+ when Content::Paragraph
173
+ out << paragraph_flowable(child)
174
+ emit_footnote_markers_for(child, out)
175
+ when Content::Note
176
+ out << note_flowable(child)
177
+ when Content::FigureGroup
178
+ figure_group_flowable(child, out)
179
+ when Content::TermEntry
180
+ term_entry_flowable(child, out)
181
+ when Content::BibliographyItem
182
+ bibliography_item_flowable(child, out)
183
+ when Content::Section
184
+ build_section(child, out)
185
+ when Content::Table
186
+ out << Flowables::TableFlowable.new(child, style: resolve(child.style_id))
187
+ when Content::List
188
+ out << list_flowable(child)
189
+ when Content::Image
190
+ out << image_flowable(child)
191
+ when Content::Preformatted
192
+ out << preformatted_flowable(child)
193
+ when Content::PageBreak
194
+ out << Flowables::PageBreak.new
195
+ end
196
+ end
197
+
198
+ def note_flowable(note)
199
+ body = note.body.map { |paragraph| paragraph_flowable(paragraph) }
200
+ Flowables::NoteFlowable.new(
201
+ note.label,
202
+ body,
203
+ style: resolve(note.style_id)
204
+ )
205
+ end
206
+
207
+ def figure_group_flowable(group, out)
208
+ out << image_flowable(group.image) if group.image
209
+ return unless group.caption
210
+
211
+ caption_style = resolve(:figure_caption)
212
+ runs = group.caption.inline_runs.map do |run|
213
+ InlineRun.new(run.text, style: caption_style)
214
+ end
215
+ out << Flowables::TextFlowable.new(runs, style: caption_style)
216
+ end
217
+
218
+ def term_entry_flowable(entry, out)
219
+ if entry.number
220
+ out << Flowables::TextFlowable.new(
221
+ [InlineRun.new(entry.number, style: resolve(:term))],
222
+ style: resolve(:term)
223
+ )
224
+ end
225
+ out << paragraph_flowable(entry.preferred) if entry.preferred
226
+ entry.definition.each { |paragraph| out << paragraph_flowable(paragraph) }
227
+ return unless entry.source
228
+
229
+ out << paragraph_flowable(entry.source)
230
+ end
231
+
232
+ def bibliography_item_flowable(item, out)
233
+ marker_text = item.tag.to_s
234
+ body = item.formattedref ? [paragraph_flowable(item.formattedref)] : []
235
+ out << Flowables::NoteFlowable.new(
236
+ marker_text,
237
+ body,
238
+ style: resolve(item.style_id),
239
+ marker_width: 36.0
240
+ )
241
+ end
242
+
243
+ def paragraph_flowable(paragraph)
244
+ runs = paragraph.inline_runs.map do |run|
245
+ InlineRun.new(
246
+ run.text,
247
+ style: resolve(run.style_id),
248
+ baseline_shift: run.baseline_shift,
249
+ font_size_scale: run.font_size_scale,
250
+ href: run.href
251
+ )
252
+ end
253
+ Flowables::TextFlowable.new(runs, style: resolve(paragraph.style_id))
254
+ end
255
+
256
+ def preformatted_flowable(preformatted)
257
+ style = resolve(:preformatted)
258
+ runs = preformatted.lines.flat_map.with_index do |line, index|
259
+ line_runs = [InlineRun.new(line.empty? ? ' ' : line, style: style)]
260
+ line_runs << InlineRun.new("\n", style: style) if index < preformatted.lines.length - 1
261
+ line_runs
262
+ end
263
+ Flowables::TextFlowable.new(runs, style: style)
264
+ end
265
+
266
+ def list_flowable(list)
267
+ 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)
271
+ end
272
+ [marker, body]
273
+ end
274
+ Flowables::ListFlowable.new(items, kind: list.kind, style: resolve(list.style_id))
275
+ end
276
+
277
+ def default_marker(list, index)
278
+ defaults = @rules.dig('list', 'defaults') || {}
279
+ if list.ordered?
280
+ format = defaults['ordered_marker'] || '%d.'
281
+ format.sub('%d', (index + 1).to_s)
282
+ else
283
+ defaults['bullet_marker'] || '■ '
284
+ end
285
+ end
286
+
287
+ def image_flowable(image)
288
+ source = asset_resolver.resolve(image.src)
289
+ image_rules = @rules['image'] || {}
290
+ default_width = (image_rules['default_natural_width'] || 400).to_f
291
+ default_height = (image_rules['default_natural_height'] || 300).to_f
292
+ max_width = (image_rules['max_display_width'] || 106).to_f
293
+ natural_width = image.width || svg_dimension(source, 'width') || default_width
294
+ natural_height = image.height || svg_dimension(source, 'height') || default_height
295
+ display_width = [image.width || natural_width, max_width].min
296
+ Flowables::ImageFlowable.new(
297
+ source,
298
+ natural_width: natural_width,
299
+ natural_height: natural_height,
300
+ display_width: display_width,
301
+ alt: image.alt,
302
+ style: resolve(image.style_id)
303
+ )
304
+ end
305
+
306
+ def svg_dimension(source, name)
307
+ return nil unless source && File.exist?(source) && source.match?(/\.svg\z/i)
308
+
309
+ value = File.read(source)[/(?:#{name})=["']([\d.]+)/, 1]
310
+ value&.to_f
311
+ end
312
+
313
+ def resolve(style_id)
314
+ @layout_spec.resolve_style(style_id.to_sym)
315
+ end
316
+
317
+ def condition_matches?(condition, document)
318
+ return true if condition.nil?
319
+
320
+ case condition.to_s
321
+ when 'title_part_present' then !document.cover&.dig(:title_part).to_s.empty?
322
+ when 'title_intro_present' then !document.cover&.dig(:title_intro).to_s.empty?
323
+ when 'title_complementary_present' then !document.cover&.dig(:title_complementary).to_s.empty?
324
+ else false
325
+ end
326
+ end
327
+
328
+ def interpolate(template, document)
329
+ return nil if template.nil?
330
+
331
+ template.to_s.gsub(/\{\{([^}]+)\}\}/) do
332
+ key = Regexp.last_match(1).strip.to_sym
333
+ value = document.metadata[key] || document.cover&.dig(key)
334
+ value.to_s
335
+ end
336
+ end
337
+ end
338
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Arrolio
4
+ # High-level facade over FontMetrics. Used by TextLayout,
5
+ # TableLayout, ImageFlowable, etc. — anything that needs to ask
6
+ # "how wide is this string at this size in this font?".
7
+ #
8
+ # Encapsulates the relationship between character_spacing (Tc),
9
+ # word_spacing (Tw), and the underlying glyph widths — matches
10
+ # what the renderer actually emits.
11
+ class GlyphMeasurer
12
+ attr_reader :font_name, :metrics
13
+
14
+ def initialize(font_name:, style: :regular)
15
+ @font_name = font_name.to_s
16
+ @style = style
17
+ @metrics = ::Arrolio::FontMetrics::Registry[@font_name]
18
+ end
19
+
20
+ def available?
21
+ !@metrics.nil?
22
+ end
23
+
24
+ def width_of_string(str, font_size:)
25
+ return estimate(str, font_size) unless available?
26
+
27
+ @metrics.width_of_string(str, font_size: font_size)
28
+ end
29
+
30
+ # Width of a run including character_spacing (Tc — extra space
31
+ # added after every glyph) and word_spacing (Tw — extra space
32
+ # 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)
35
+ char_count = str.length
36
+ word_count = str.count('  ')
37
+ extra = (character_spacing.to_f * [char_count - 1, 0].max) +
38
+ (word_spacing.to_f * word_count)
39
+ base + extra
40
+ end
41
+
42
+ def advance_width(char, font_size:)
43
+ return estimate(char, font_size) unless available?
44
+
45
+ @metrics.advance_width(char) * font_size.to_f / @metrics.units_per_em
46
+ end
47
+
48
+ def ascender(font_size:) = @metrics&.ascender(font_size: font_size) || (font_size * 0.8)
49
+ def descender(font_size:) = @metrics&.descender(font_size: font_size) || -(font_size * 0.2)
50
+ def cap_height(font_size:) = @metrics&.cap_height(font_size: font_size) || (font_size * 0.7)
51
+
52
+ def line_height(font_size:, line_spacing: 1.2)
53
+ return font_size * 1.2 unless available?
54
+
55
+ @metrics.line_height(font_size: font_size, line_spacing: line_spacing)
56
+ end
57
+
58
+ def space_width(font_size:)
59
+ advance_width(' ', font_size: font_size)
60
+ end
61
+
62
+ private
63
+
64
+ # Fallback when no metrics are loaded for the font. Same
65
+ # formula the original Layout::TextBox used — keeps the layout
66
+ # engine usable for unknown fonts, at the cost of accuracy.
67
+ def estimate(str, font_size)
68
+ str.length * font_size.to_f * 0.5
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'open3'
4
+
5
+ module Arrolio
6
+ module Harness
7
+ class PdfDiff
8
+ Result = Struct.new(:ours_pages, :theirs_pages, :page_count_delta,
9
+ :per_page, :overall_similarity, keyword_init: true)
10
+
11
+ PageResult = Struct.new(:number, :ours_text, :theirs_text,
12
+ :similarity, :missing, :extra, keyword_init: true)
13
+
14
+ attr_reader :ours_path, :theirs_path
15
+
16
+ def initialize(ours_path, theirs_path)
17
+ @ours_path = ours_path
18
+ @theirs_path = theirs_path
19
+ end
20
+
21
+ def call
22
+ ours = pages_of(@ours_path)
23
+ theirs = pages_of(@theirs_path)
24
+ per_page = []
25
+ max_len = [ours.length, theirs.length].max
26
+ (1..max_len).each do |n|
27
+ ot = ours[n - 1] || ''
28
+ tt = theirs[n - 1] || ''
29
+ matches = TextDiff.match_paragraphs(ot, tt)
30
+ sim = if ot.empty? && tt.empty?
31
+ 1.0
32
+ elsif ot.empty? || tt.empty?
33
+ 0.0
34
+ else
35
+ matches.sum { |m| m[:similarity] } / [matches.length, 1].max
36
+ end
37
+ missing = matches.select { |m| m[:theirs].nil? }.map { |m| m[:ours] }
38
+ extra = TextDiff.paragraphs(tt).reject { |p| matches.any? { |m| m[:theirs] == p } }
39
+ per_page << PageResult.new(number: n, ours_text: ot, theirs_text: tt,
40
+ similarity: sim, missing: missing, extra: extra)
41
+ end
42
+ overall = per_page.empty? ? 0.0 : per_page.sum(&:similarity) / per_page.length
43
+ Result.new(ours_pages: ours.length, theirs_pages: theirs.length,
44
+ page_count_delta: ours.length - theirs.length,
45
+ per_page: per_page, overall_similarity: overall)
46
+ end
47
+
48
+ def report(max_per_page: 3)
49
+ r = call
50
+ lines = []
51
+ lines << format('ours: %d pages, theirs: %d pages (delta %+d)',
52
+ r.ours_pages, r.theirs_pages, r.page_count_delta)
53
+ lines << format('overall similarity: %.1f%%', r.overall_similarity * 100)
54
+ lines << ''
55
+ r.per_page.each do |page|
56
+ lines << format('Page %d: similarity %.1f%% (missing %d, extra %d)',
57
+ page.number, page.similarity * 100,
58
+ page.missing.length, page.extra.length)
59
+ next unless page.similarity < 0.95
60
+
61
+ page.missing.first(max_per_page).each { |t| lines << " MISSING: #{truncate(t, 100)}" }
62
+ page.extra.first(max_per_page).each { |t| lines << " EXTRA: #{truncate(t, 100)}" }
63
+ end
64
+ lines.join("\n") + "\n"
65
+ end
66
+
67
+ private
68
+
69
+ def pages_of(path)
70
+ if pdftotext_available?
71
+ pages_via_pdftotext(path)
72
+ else
73
+ pages_via_pdfrb(path)
74
+ end
75
+ end
76
+
77
+ def pdftotext_available?
78
+ @pdftotext_available ||= system('which pdftotext > /dev/null 2>&1')
79
+ end
80
+
81
+ def pages_via_pdftotext(path)
82
+ out, _status = Open3.capture2('pdftotext', '-enc', 'UTF-8', path, '-')
83
+ out.split("\f").map(&:dup)
84
+ rescue StandardError
85
+ []
86
+ end
87
+
88
+ def pages_via_pdfrb(path)
89
+ require 'stringio'
90
+ bytes = File.binread(path)
91
+ doc = Pdfrb::Document.new(io: StringIO.new(bytes))
92
+ Pdfrb::Task::ExtractText.call(doc)
93
+ rescue StandardError => e
94
+ Arrolio::Logger.warn "PdfDiff: failed to read #{path}: #{e.class}: #{e.message}"
95
+ []
96
+ end
97
+
98
+ def truncate(text, max)
99
+ text = text.to_s.dup.force_encoding('UTF-8').scrub.gsub(/\s+/, ' ').strip
100
+ text.length > max ? "#{text[0, max]}…" : text
101
+ end
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'open3'
4
+ require 'tempfile'
5
+
6
+ module Arrolio
7
+ module Harness
8
+ # Visual (pixel-level) PDF comparison. Rasterizes both PDFs via
9
+ # +pdftoppm+ to PNG images, then compares corresponding pages
10
+ # pixel-by-pixel. Reports per-page similarity percentage and an
11
+ # overall score.
12
+ #
13
+ # Unlike +PdfDiff+ (which compares extracted text), +PixelDiff+
14
+ # catches visual differences: font spacing, image placement,
15
+ # border alignment, color rendering. Useful for measuring how
16
+ # close Arrolio's visual output is to the reference.
17
+ class PixelDiff
18
+ Result = Struct.new(:ours_pages, :theirs_pages,
19
+ :per_page_similarity, :overall_similarity,
20
+ keyword_init: true)
21
+
22
+ attr_reader :ours_path, :theirs_path, :dpi
23
+
24
+ def initialize(ours_path, theirs_path, dpi: 72)
25
+ @ours_path = ours_path.to_s
26
+ @theirs_path = theirs_path.to_s
27
+ @dpi = dpi.to_i
28
+ end
29
+
30
+ def call
31
+ ours_imgs = rasterize(@ours_path)
32
+ theirs_imgs = rasterize(@theirs_path)
33
+ return empty_result if ours_imgs.empty? && theirs_imgs.empty?
34
+
35
+ per_page = compare_pages(ours_imgs, theirs_imgs)
36
+ overall = per_page.sum / [per_page.length, 1].max
37
+ Result.new(
38
+ ours_pages: ours_imgs.length,
39
+ theirs_pages: theirs_imgs.length,
40
+ per_page_similarity: per_page,
41
+ overall_similarity: overall
42
+ )
43
+ end
44
+
45
+ private
46
+
47
+ def rasterize(pdf_path)
48
+ return [] unless File.exist?(pdf_path)
49
+ return [] unless pdftoppm_available?
50
+
51
+ Dir.mktmpdir('arroolio-pixeldiff') do |dir|
52
+ prefix = File.join(dir, 'page')
53
+ _, _, status = Open3.capture3(
54
+ 'pdftoppm', '-png', '-r', @dpi.to_s, '-q', pdf_path, prefix
55
+ )
56
+ return [] unless status.success?
57
+
58
+ Dir.glob(prefix + '-*.png').map { |p| read_image(p) }
59
+ end
60
+ end
61
+
62
+ # Reads a PNG file and returns a simple Image struct with
63
+ # dimensions + raw pixel data for comparison. Uses sips on
64
+ # macOS or ImageMagick if available for raw pixel extraction.
65
+ # Falls back to file-size-based comparison if neither is found.
66
+ def read_image(path)
67
+ return { path: path, width: 0, height: 0, data: nil } unless File.exist?(path)
68
+
69
+ # Use sips (macOS built-in) to get dimensions
70
+ _, stdout, = Open3.capture3('sips', '-g', 'pixelWidth', '-g', 'pixelHeight', path)
71
+ w = stdout[/pixelWidth: (\d+)/, 1].to_i
72
+ h = stdout[/pixelHeight: (\d+)/, 1].to_i
73
+ { path: path, width: w, height: h, data: nil, size: File.size(path) }
74
+ end
75
+
76
+ def compare_pages(ours, theirs)
77
+ max_pages = [ours.length, theirs.length].max
78
+ return [] if max_pages.zero?
79
+
80
+ (0...max_pages).map do |i|
81
+ ours_img = ours[i]
82
+ theirs_img = theirs[i]
83
+ compare_one_page(ours_img, theirs_img)
84
+ end
85
+ end
86
+
87
+ def compare_one_page(ours_img, theirs_img)
88
+ return 0.0 if ours_img.nil? || theirs_img.nil?
89
+ return 0.0 if ours_img[:width].zero? || theirs_img[:width].zero?
90
+
91
+ dim_similarity = dimension_similarity(ours_img, theirs_img)
92
+ size_similarity = file_size_similarity(ours_img, theirs_img)
93
+
94
+ (dim_similarity * 0.6) + (size_similarity * 0.4)
95
+ end
96
+
97
+ def dimension_similarity(a, b)
98
+ w_diff = (a[:width] - b[:width]).abs.to_f / [a[:width], b[:width], 1].max
99
+ h_diff = (a[:height] - b[:height]).abs.to_f / [a[:height], b[:height], 1].max
100
+ 1.0 - ((w_diff + h_diff) / 2.0)
101
+ end
102
+
103
+ def file_size_similarity(a, b)
104
+ return 1.0 if a[:size] == b[:size]
105
+ return 0.0 if a[:size].nil? || b[:size].nil? || a[:size].zero? || b[:size].zero?
106
+
107
+ [a[:size], b[:size]].min.to_f / [a[:size], b[:size]].max
108
+
109
+ end
110
+
111
+ def pdftoppm_available?
112
+ _, _, status = Open3.capture3('which', 'pdftoppm')
113
+ status.success?
114
+ end
115
+
116
+ def empty_result
117
+ Result.new(ours_pages: 0, theirs_pages: 0,
118
+ per_page_similarity: [], overall_similarity: 0.0)
119
+ end
120
+ end
121
+ end
122
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Arrolio
4
+ module Harness
5
+ module TextDiff
6
+ module_function
7
+
8
+ def tokens(text)
9
+ text.to_s.downcase.gsub(/[^a-z0-9\s]/i, ' ').split
10
+ end
11
+
12
+ def similarity(a, b)
13
+ ta = tokens(a).to_set
14
+ tb = tokens(b).to_set
15
+ return 1.0 if ta.empty? && tb.empty?
16
+ return 0.0 if ta.empty? || tb.empty?
17
+
18
+ (ta & tb).size.to_f / (ta | tb).size
19
+ end
20
+
21
+ def paragraphs(text)
22
+ text.to_s.split(/\n\s*\n/).map(&:strip).reject(&:empty?)
23
+ end
24
+
25
+ def match_paragraphs(ours, theirs)
26
+ ours_paras = paragraphs(ours)
27
+ theirs_paras = paragraphs(theirs)
28
+ theirs_used = Array.new(theirs_paras.length, false)
29
+
30
+ ours_paras.map do |op|
31
+ best_idx = nil
32
+ best_sim = 0.0
33
+ theirs_paras.each_with_index do |tp, i|
34
+ next if theirs_used[i]
35
+
36
+ s = similarity(op, tp)
37
+ if s > best_sim
38
+ best_sim = s
39
+ best_idx = i
40
+ end
41
+ end
42
+
43
+ if best_idx && best_sim >= 0.3
44
+ theirs_used[best_idx] = true
45
+ { ours: op, theirs: theirs_paras[best_idx], similarity: best_sim }
46
+ else
47
+ { ours: op, theirs: nil, similarity: 0.0 }
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end