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,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'open3'
4
+
5
+ module Arrolio
6
+ module Harness
7
+ # Extracts text from PDF files for diff/matching. Uses poppler's
8
+ # +pdftotext+ as the primary backend (reliable with CIDFont
9
+ # subsets); falls back to returning an empty string if the
10
+ # tool is unavailable.
11
+ class TextExtractor
12
+ attr_reader :path
13
+
14
+ def initialize(path)
15
+ @path = path.to_s
16
+ end
17
+
18
+ # Returns an Array of Strings — one per page. Uses +pdftotext+
19
+ # in raw mode (no layout reconstruction) for maximum text
20
+ # recovery. For layout-aware extraction, pass +layout: true+.
21
+ def pages(layout: false)
22
+ return [] unless pdftotext_available?
23
+
24
+ args = ['pdftotext']
25
+ args << '-layout' if layout
26
+ args << '-q' << @path << '-'
27
+ stdout, _stderr, status = Open3.capture3(*args)
28
+ return [] unless status.success?
29
+
30
+ text = stdout.force_encoding('UTF-8').scrub
31
+ split_by_page(text)
32
+ end
33
+
34
+ # Returns all text as a single String (pages joined by "\f").
35
+ def full_text(layout: false)
36
+ pages(layout: layout).join("\f")
37
+ end
38
+
39
+ # Word count across all pages.
40
+ def word_count
41
+ pages.sum { |text| text.split.length }
42
+ end
43
+
44
+ private
45
+
46
+ def pdftotext_available?
47
+ _, _, status = Open3.capture3('which', 'pdftotext')
48
+ status.success?
49
+ end
50
+
51
+ def split_by_page(text)
52
+ return [text] unless text.include?("\f")
53
+
54
+ text.split("\f", -1)
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Arrolio
4
+ module Harness
5
+ autoload :PdfDiff, 'arrolio/harness/pdf_diff'
6
+ autoload :TextDiff, 'arrolio/harness/text_diff'
7
+ autoload :TextExtractor, 'arrolio/harness/text_extractor'
8
+ autoload :PixelDiff, 'arrolio/harness/pixel_diff'
9
+ end
10
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Arrolio
4
+ # One inline run within a paragraph: a contiguous span of text
5
+ # sharing a single Style. A paragraph is laid out as a list of
6
+ # InlineRun instances. Lives at the Layout level so that other
7
+ # Layout modules (InlineBuilder, TextLayout, FO::Compiler) all
8
+ # share the same type.
9
+ #
10
+ # Carries optional baseline_shift (:sub / :sup / nil) and
11
+ # font_size_scale for subscript/superscript rendering, plus
12
+ # optional href for hyperlink annotations.
13
+ class InlineRun
14
+ attr_reader :text, :style, :baseline_shift, :font_size_scale, :href
15
+
16
+ def initialize(text, style: Style.new, baseline_shift: nil,
17
+ font_size_scale: 1.0, href: nil)
18
+ @text = text.to_s
19
+ @style = style
20
+ @baseline_shift = baseline_shift
21
+ @font_size_scale = font_size_scale.to_f
22
+ @href = href
23
+ end
24
+
25
+ def empty?
26
+ @text.empty?
27
+ end
28
+
29
+ def length
30
+ @text.length
31
+ end
32
+
33
+ def width(measurer, font_size: style.font_size)
34
+ measurer.width_of_run(
35
+ @text,
36
+ font_size: font_size * @font_size_scale,
37
+ character_spacing: style.character_spacing,
38
+ word_spacing: style.word_spacing
39
+ )
40
+ end
41
+
42
+ def hyperlink?
43
+ !@href.nil? && !@href.empty?
44
+ end
45
+
46
+ def ==(other)
47
+ other.is_a?(self.class) &&
48
+ text == other.text &&
49
+ style == other.style &&
50
+ baseline_shift == other.baseline_shift &&
51
+ font_size_scale == other.font_size_scale &&
52
+ href == other.href
53
+ end
54
+
55
+ def hash
56
+ [text, style, baseline_shift, font_size_scale, href].hash
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Arrolio
4
+ class LayoutSpec
5
+ # Declares a named content flow into a region. Declarative
6
+ # only — Engine::Paged takes a flat Flowable list and a body
7
+ # template today.
8
+ Flow = Struct.new(:name, :region, :source, :default_style_id,
9
+ keyword_init: true) do
10
+ def initialize(*)
11
+ super
12
+ self.name = self.name.to_sym if self.name
13
+ freeze
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'yaml'
4
+
5
+ module Arrolio
6
+ class LayoutSpec
7
+ # Loads a LayoutSpec from YAML. Lengths accept mm/cm/in/pt/px.
8
+ module Loader
9
+ MM_TO_PT = 2.83464567
10
+ CM_TO_PT = 28.3464567
11
+ IN_TO_PT = 72.0
12
+ PT_TO_PT = 1.0
13
+ PX_TO_PT = 0.75
14
+
15
+ module_function
16
+
17
+ def load(source)
18
+ build_from_hash(read_yaml(source))
19
+ end
20
+
21
+ def read_yaml(source)
22
+ case source
23
+ when Hash then source
24
+ when String
25
+ if File.exist?(source)
26
+ result = YAML.safe_load_file(source, aliases: true)
27
+ result.is_a?(Hash) ? result : {}
28
+ else
29
+ result = YAML.safe_load(source, aliases: true)
30
+ result.is_a?(Hash) ? result : {}
31
+ end
32
+ when IO, StringIO
33
+ result = YAML.safe_load(source.read, aliases: true)
34
+ result.is_a?(Hash) ? result : {}
35
+ else
36
+ raise ArgumentError, "Loader needs Hash/String/IO; got #{source.class}"
37
+ end
38
+ end
39
+
40
+ def build_from_hash(hash)
41
+ templates = build_templates(hash.fetch('page_templates', {}))
42
+ default = hash.fetch('default_page_template') do
43
+ templates.keys.first or
44
+ raise LayoutSpecError, 'no page templates in layout spec', spec_field: :page_templates
45
+ end
46
+ styles = Style::Registry.new(hash.fetch('styles', {}))
47
+ flows = hash.fetch('flows', {}).transform_values do |f|
48
+ f = { 'region' => f } unless f.is_a?(Hash)
49
+ Flow.new(
50
+ name: f['name'],
51
+ region: f['region']&.to_sym,
52
+ source: f['source'],
53
+ default_style_id: f['default_style_id']&.to_sym
54
+ )
55
+ end
56
+ LayoutSpec.new(
57
+ page_templates: templates,
58
+ default_page_template: default,
59
+ styles: styles,
60
+ flows: flows,
61
+ font_paths: hash.fetch('font_paths', {}),
62
+ header_footer: hash.fetch('header_footer', {}),
63
+ cover_logo: hash.fetch('cover_logo', {}),
64
+ font_manifest: hash.fetch('font_manifest', {})
65
+ )
66
+ end
67
+
68
+ def build_templates(hash)
69
+ hash.to_h do |name, attrs|
70
+ attrs = {} unless attrs.is_a?(Hash)
71
+ [name, build_template(name, attrs)]
72
+ end
73
+ end
74
+
75
+ def build_template(name, attrs)
76
+ PageTemplate.new(
77
+ name: name,
78
+ page_size: attrs['page_size'] || PageTemplate::LETTER,
79
+ margins: build_margins(attrs),
80
+ region_extents: build_region_extents(attrs.fetch('region_extents', {}))
81
+ )
82
+ end
83
+
84
+ def build_margins(attrs)
85
+ margins = attrs['margins']
86
+ return PageTemplate::LETTER.first if margins.nil?
87
+
88
+ case margins
89
+ when Numeric, String then { all: parse_length(margins) }
90
+ when Hash
91
+ margins.to_h { |k, v| [k.to_sym, parse_length(v)] }
92
+ else
93
+ raise LayoutSpecError, 'margins must be number/string/hash', spec_field: :margins
94
+ end
95
+ end
96
+
97
+ def build_region_extents(hash)
98
+ hash.to_h { |k, v| [k.to_sym, parse_length(v)] }
99
+ end
100
+
101
+ def parse_length(value)
102
+ return value.to_f if value.is_a?(Numeric)
103
+
104
+ case value.to_s
105
+ when /\A([\d.]+)mm\z/ then Regexp.last_match(1).to_f * MM_TO_PT
106
+ when /\A([\d.]+)cm\z/ then Regexp.last_match(1).to_f * CM_TO_PT
107
+ when /\A([\d.]+)in\z/ then Regexp.last_match(1).to_f * IN_TO_PT
108
+ when /\A([\d.]+)px\z/ then Regexp.last_match(1).to_f * PX_TO_PT
109
+ when /\A([\d.]+)pt\z/ then Regexp.last_match(1).to_f * PT_TO_PT
110
+ when /\A[\d.]+\z/ then value.to_f
111
+ else
112
+ raise LayoutSpecError, "cannot parse length #{value.inspect}", spec_field: :length
113
+ end
114
+ end
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,127 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Arrolio
4
+ class LayoutSpec
5
+ # Page geometry template ("fo:simple-page-master" in XSL-FO).
6
+ # Coordinates are PDF-default user space: origin at the
7
+ # bottom-left of the page, +y+ grows upward. Region +y+ is the
8
+ # bottom edge of the region.
9
+ class PageTemplate
10
+ LETTER = [612.0, 792.0].freeze
11
+ A4 = [595.276, 841.89].freeze
12
+
13
+ attr_reader :name, :page_size, :margins, :region_extents, :regions
14
+
15
+ def initialize(name:, page_size: LETTER, margins: 72, region_extents: {})
16
+ @name = name.to_sym
17
+ @page_size = resolve_page_size(page_size)
18
+ @margins = normalise_margins(margins)
19
+ @region_extents = default_region_extents.merge(region_extents.to_h)
20
+ @regions = compute_regions.freeze
21
+ freeze
22
+ end
23
+
24
+ def body_region
25
+ @regions[:body]
26
+ end
27
+
28
+ def region(name)
29
+ @regions[name.to_sym]
30
+ end
31
+
32
+ def width
33
+ @page_size[0]
34
+ end
35
+
36
+ def height
37
+ @page_size[1]
38
+ end
39
+
40
+ def ==(other)
41
+ other.is_a?(self.class) &&
42
+ name == other.name &&
43
+ page_size == other.page_size &&
44
+ margins == other.margins &&
45
+ region_extents == other.region_extents
46
+ end
47
+
48
+ alias eql? ==
49
+
50
+ def hash
51
+ [self.class, name, page_size, margins, region_extents].hash
52
+ end
53
+
54
+ private
55
+
56
+ def resolve_page_size(spec)
57
+ case spec
58
+ when Symbol, String
59
+ constant = spec.to_s.upcase.to_sym
60
+ self.class.const_defined?(constant, false) ? self.class.const_get(constant) : LETTER
61
+ when Array
62
+ [spec[0].to_f, spec[1].to_f]
63
+ else
64
+ LETTER
65
+ end
66
+ end
67
+
68
+ def normalise_margins(m)
69
+ case m
70
+ when Numeric
71
+ { top: m.to_f, right: m.to_f, bottom: m.to_f, left: m.to_f }
72
+ when Hash
73
+ base = m.transform_keys(&:to_sym)
74
+ all = base[:all]
75
+ {
76
+ top: base[:top] || all || 72.0,
77
+ right: base[:right] || all || 72.0,
78
+ bottom: base[:bottom] || all || 72.0,
79
+ left: base[:left] || all || 72.0
80
+ }
81
+ else
82
+ { top: 72.0, right: 72.0, bottom: 72.0, left: 72.0 }
83
+ end
84
+ end
85
+
86
+ def default_region_extents
87
+ { before: 0.0, after: 0.0, start: 0.0, end: 0.0 }
88
+ end
89
+
90
+ def compute_regions
91
+ pw = width
92
+ ph = height
93
+ m = @margins
94
+ 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]
97
+ {
98
+ body: Region.new(name: :body,
99
+ x: m[:left] + e[:start],
100
+ y: m[:bottom] + e[:after],
101
+ width: inner_w,
102
+ height: inner_h),
103
+ before: Region.new(name: :before,
104
+ x: m[:left] + e[:start],
105
+ y: ph - m[:top] - e[:before],
106
+ width: inner_w,
107
+ height: e[:before]),
108
+ after: Region.new(name: :after,
109
+ x: m[:left] + e[:start],
110
+ y: m[:bottom],
111
+ width: inner_w,
112
+ height: e[:after]),
113
+ start: Region.new(name: :start,
114
+ x: m[:left],
115
+ y: m[:bottom] + e[:after],
116
+ width: e[:start],
117
+ height: inner_h),
118
+ end: Region.new(name: :end,
119
+ x: pw - m[:right] - e[:end],
120
+ y: m[:bottom] + e[:after],
121
+ width: e[:end],
122
+ height: inner_h)
123
+ }
124
+ end
125
+ end
126
+ end
127
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Arrolio
4
+ class LayoutSpec
5
+ # Selects the appropriate page template based on page parity
6
+ # (odd/even) and the configured template set. Supports three
7
+ # modes:
8
+ # - Single template (current behavior): always returns the
9
+ # same template for every page.
10
+ # - Odd/even templates: returns different templates for odd
11
+ # vs even pages, typically mirroring the inner/outer margins
12
+ # for double-sided printing.
13
+ # - Custom mapping: caller provides a proc or hash mapping
14
+ # page numbers to template names.
15
+ class PageTemplateSelector
16
+ attr_reader :default_template, :odd_template, :even_template
17
+
18
+ # +default_template:+ template name used when no odd/even
19
+ # templates are configured.
20
+ # +odd_template:+ template name for odd pages (1, 3, 5, ...).
21
+ # +even_template:+ template name for even pages (2, 4, 6, ...).
22
+ def initialize(default_template:, odd_template: nil, even_template: nil)
23
+ @default_template = default_template.to_sym
24
+ @odd_template = odd_template&.to_sym
25
+ @even_template = even_template&.to_sym
26
+ freeze
27
+ end
28
+
29
+ # Returns the template name for the given page number.
30
+ def name_for(page_number)
31
+ return @default_template unless @odd_template || @even_template
32
+
33
+ if page_number.odd?
34
+ (@odd_template || @default_template)
35
+ else
36
+ (@even_template || @default_template)
37
+ end
38
+ end
39
+
40
+ # Does this selector use different templates for odd/even?
41
+ def alternating?
42
+ !(@odd_template.nil? && @even_template.nil?)
43
+ end
44
+
45
+ def ==(other)
46
+ other.is_a?(self.class) &&
47
+ default_template == other.default_template &&
48
+ odd_template == other.odd_template &&
49
+ even_template == other.even_template
50
+ end
51
+ alias eql? ==
52
+
53
+ def hash
54
+ [self.class, default_template, odd_template, even_template].hash
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Arrolio
4
+ class LayoutSpec
5
+ # Named rectangle within a PageTemplate. The five standard
6
+ # regions are +:body+, +:before+ (header), +:after+ (footer),
7
+ # +:start+ (left), +:end+ (right). Pure geometry; runtime
8
+ # consumed state lives in Frame.
9
+ class Region
10
+ attr_reader :name, :x, :y, :width, :height
11
+
12
+ def initialize(name:, x:, y:, width:, height:)
13
+ @name = name.to_sym
14
+ @x = x.to_f
15
+ @y = y.to_f
16
+ @width = width.to_f
17
+ @height = height.to_f
18
+ freeze
19
+ end
20
+
21
+ def area
22
+ @width * @height
23
+ end
24
+
25
+ def ==(other)
26
+ other.is_a?(self.class) &&
27
+ name == other.name && x == other.x && y == other.y &&
28
+ width == other.width && height == other.height
29
+ end
30
+
31
+ alias eql? ==
32
+
33
+ def hash
34
+ [self.class, name, x, y, width, height].hash
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Arrolio
4
+ class LayoutSpec
5
+ class ScaleLength
6
+ attr_reader :base_value, :scale_factor
7
+
8
+ def initialize(base_value:, scale_factor: 1.0)
9
+ @base_value = base_value.to_f
10
+ @scale_factor = scale_factor.to_f
11
+ freeze
12
+ end
13
+
14
+ def resolved
15
+ @base_value * @scale_factor
16
+ end
17
+
18
+ def with_scale(factor)
19
+ self.class.new(base_value: @base_value, scale_factor: factor)
20
+ end
21
+
22
+ def ==(other)
23
+ other.is_a?(self.class) &&
24
+ base_value == other.base_value &&
25
+ scale_factor == other.scale_factor
26
+ end
27
+ alias eql? ==
28
+
29
+ def hash
30
+ [self.class, base_value, scale_factor].hash
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Arrolio::LayoutSpec — typed layout description, YAML-loadable.
4
+ module Arrolio
5
+ class LayoutSpec
6
+ autoload :PageTemplate, 'arrolio/layout_spec/page_template'
7
+ autoload :PageTemplateSelector, 'arrolio/layout_spec/page_template_selector'
8
+ autoload :ScaleLength, 'arrolio/layout_spec/scale_length'
9
+ autoload :Region, 'arrolio/layout_spec/region'
10
+ autoload :Flow, 'arrolio/layout_spec/flow'
11
+ autoload :Loader, 'arrolio/layout_spec/loader'
12
+
13
+ attr_reader :page_templates, :default_page_template, :styles, :flows,
14
+ :font_paths, :header_footer_config, :cover_logo_config,
15
+ :font_manifest_config
16
+
17
+ def initialize(page_templates:, default_page_template:,
18
+ styles:, flows: {}, font_paths: {},
19
+ header_footer: {}, cover_logo: {}, font_manifest: {})
20
+ @font_paths = font_paths.transform_keys(&:to_s).freeze
21
+ @page_templates = page_templates.transform_keys(&:to_sym).freeze
22
+ @default_page_template = default_page_template.to_sym
23
+ @styles = styles
24
+ @flows = flows.transform_keys(&:to_sym).freeze
25
+ @header_footer_config = (header_footer || {}).freeze
26
+ @cover_logo_config = (cover_logo || {}).freeze
27
+ @font_manifest_config = (font_manifest || {}).freeze
28
+ validate!
29
+ freeze
30
+ end
31
+
32
+ def page_template(name = nil)
33
+ key = (name || @default_page_template).to_sym
34
+ @page_templates[key] ||
35
+ (raise LayoutSpecError,
36
+ "Unknown page template #{key.inspect}; have: #{@page_templates.keys.inspect}",
37
+ spec_field: :page_template)
38
+ end
39
+
40
+ def resolve_style(name, fallback: nil)
41
+ @styles.resolve(name, fallback: fallback || Style::Definition.new)
42
+ end
43
+
44
+ def flow(name)
45
+ @flows[name.to_sym]
46
+ end
47
+
48
+ def ==(other)
49
+ other.is_a?(self.class) &&
50
+ page_templates == other.page_templates &&
51
+ default_page_template == other.default_page_template &&
52
+ styles == other.styles &&
53
+ flows == other.flows &&
54
+ header_footer_config == other.header_footer_config &&
55
+ cover_logo_config == other.cover_logo_config &&
56
+ font_manifest_config == other.font_manifest_config
57
+ end
58
+
59
+ alias eql? ==
60
+
61
+ def hash
62
+ [self.class, page_templates, default_page_template, styles, flows,
63
+ header_footer_config, cover_logo_config, font_manifest_config].hash
64
+ end
65
+
66
+ private
67
+
68
+ def validate!
69
+ unless @page_templates.key?(@default_page_template)
70
+ raise LayoutSpecError,
71
+ "Default page template #{@default_page_template.inspect} is not in page_templates",
72
+ spec_field: :default_page_template
73
+ end
74
+
75
+ unless @styles.is_a?(Style::Registry)
76
+ raise LayoutSpecError,
77
+ "styles must be a Style::Registry (got #{@styles.class})",
78
+ spec_field: :styles
79
+ end
80
+
81
+ @page_templates.each do |name, tpl|
82
+ next if tpl.is_a?(PageTemplate)
83
+
84
+ raise LayoutSpecError,
85
+ "page_templates[#{name.inspect}] must be a PageTemplate",
86
+ spec_field: :page_templates
87
+ end
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Arrolio
4
+ # Structured logging facade. Replaces scattered +warn+ calls with
5
+ # level-filtered, silencable output. Default level +:warn+ keeps
6
+ # production rendering quiet; set +Arrolio::Logger.level = :debug+
7
+ # or +ARROOLIO_LOG_LEVEL=debug+ to restore tracing during development.
8
+ class Logger
9
+ LEVELS = { debug: 0, info: 1, warn: 2, error: 3 }.freeze
10
+ DEFAULT_LEVEL = :warn
11
+
12
+ @level = LEVELS.fetch(DEFAULT_LEVEL)
13
+ @target = $stderr
14
+
15
+ class << self
16
+ attr_accessor :target
17
+ attr_reader :level
18
+
19
+ def level=(name)
20
+ @level = LEVELS.fetch(name.to_sym) { LEVELS.fetch(DEFAULT_LEVEL) }
21
+ end
22
+
23
+ def level_name
24
+ LEVELS.key(@level) || DEFAULT_LEVEL
25
+ end
26
+
27
+ LEVELS.each_key do |name|
28
+ define_method(name) do |msg|
29
+ emit(name, msg)
30
+ end
31
+ end
32
+
33
+ # Reset to defaults — used by specs and between pipeline runs.
34
+ def reset!
35
+ @level = LEVELS.fetch(DEFAULT_LEVEL)
36
+ @target = $stderr
37
+ end
38
+
39
+ private
40
+
41
+ def emit(level_name, msg)
42
+ threshold = LEVELS.fetch(level_name)
43
+ return if threshold < @level
44
+
45
+ @target.puts "[#{level_name}] #{msg}"
46
+ end
47
+ end
48
+
49
+ # Pick up the env var on first load. Allows
50
+ # +ARROOLIO_LOG_LEVEL=debug bundle exec rake+ without code changes.
51
+ env_level = ENV['ARROOLIO_LOG_LEVEL'].to_s.strip
52
+ self.level = env_level unless env_level.empty?
53
+ end
54
+ end