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
data/exe/arrolio2pdf ADDED
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'arrolio'
4
+
5
+ input = ARGV[0]
6
+ output = ARGV[1] || 'out.pdf'
7
+ flavor_dir = ARGV[2] || ENV.fetch('ARROLIO_FLAVOR_DIR', nil)
8
+
9
+ unless input && File.exist?(input)
10
+ warn 'usage: arrolio2pdf <input.xml> [output.pdf] [flavor_dir]'
11
+ warn ' (or set ARROLIO_FLAVOR_DIR env var)'
12
+ exit 2
13
+ end
14
+
15
+ unless flavor_dir && Dir.exist?(flavor_dir)
16
+ warn "error: flavor directory not found: #{flavor_dir.inspect}"
17
+ warn ' pass it as the 3rd argument or set ARROLIO_FLAVOR_DIR'
18
+ exit 2
19
+ end
20
+
21
+ xml = File.read(input)
22
+ Arrolio::ConfigDrivenPipeline.render(
23
+ xml,
24
+ io: output,
25
+ flavor_dir: File.expand_path(flavor_dir),
26
+ input_path: File.expand_path(input)
27
+ )
28
+ puts "wrote #{output} (#{File.size(output)} bytes)"
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Arrolio
4
+ class AssetResolver
5
+ attr_reader :base_dirs
6
+
7
+ def initialize(base_dirs:)
8
+ @base_dirs = Array(base_dirs).map { |directory| File.expand_path(directory.to_s) }.freeze
9
+ freeze
10
+ end
11
+
12
+ def resolve(source)
13
+ return source if source.nil? || source.empty?
14
+ return source if absolute?(source)
15
+
16
+ @base_dirs.each do |base_dir|
17
+ candidate = File.join(base_dir, source)
18
+ return candidate if File.exist?(candidate)
19
+ end
20
+ source
21
+ end
22
+
23
+ def self.from_input_path(input_path, extra: [])
24
+ directories = []
25
+ directories << File.dirname(File.expand_path(input_path)) if input_path
26
+ directories.concat(Array(extra))
27
+ new(base_dirs: directories)
28
+ end
29
+
30
+ private
31
+
32
+ def absolute?(source)
33
+ source.start_with?('/', 'http://', 'https://')
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Arrolio
4
+ # Color value object. Parses CSS/XSL color specifications and
5
+ # provides normalized RGB components for the renderer. Supports:
6
+ # hex (#RGB, #RRGGBB), rgb(r,g,b), rgb(r%,g%,b%), named colors.
7
+ #
8
+ # The renderer calls +to_render+ to get the format pdfrb expects:
9
+ # +Float+ for grayscale, +[:rgb, r, g, b]+ for color.
10
+ class Color
11
+ attr_reader :red, :green, :blue, :alpha
12
+
13
+ # Named colors from the SVG/CSS specification subset commonly
14
+ # used in XSL-FO documents.
15
+ NAMED_COLORS = {
16
+ 'black' => [0, 0, 0],
17
+ 'white' => [255, 255, 255],
18
+ 'red' => [255, 0, 0],
19
+ 'green' => [0, 128, 0],
20
+ 'blue' => [0, 0, 255],
21
+ 'yellow' => [255, 255, 0],
22
+ 'cyan' => [0, 255, 255],
23
+ 'magenta' => [255, 0, 255],
24
+ 'gray' => [128, 128, 128],
25
+ 'grey' => [128, 128, 128],
26
+ 'silver' => [192, 192, 192],
27
+ 'maroon' => [128, 0, 0],
28
+ 'purple' => [128, 0, 128],
29
+ 'olive' => [128, 128, 0],
30
+ 'navy' => [0, 0, 128],
31
+ 'teal' => [0, 128, 128]
32
+ }.freeze
33
+
34
+ def initialize(red:, green:, blue:, alpha: 1.0)
35
+ @red = clamp01(red.to_f)
36
+ @green = clamp01(green.to_f)
37
+ @blue = clamp01(blue.to_f)
38
+ @alpha = clamp01(alpha.to_f)
39
+ freeze
40
+ end
41
+
42
+ # Factory: parse any supported color specification.
43
+ def self.parse(spec)
44
+ return spec if spec.is_a?(self)
45
+ return nil if spec.nil? || spec.to_s.empty?
46
+
47
+ case spec.to_s.downcase
48
+ when /\A#([0-9a-f])([0-9a-f])([0-9a-f])\z/
49
+ new(red: Regexp.last_match(1).to_i(16) * 17 / 255.0,
50
+ green: Regexp.last_match(2).to_i(16) * 17 / 255.0,
51
+ blue: Regexp.last_match(3).to_i(16) * 17 / 255.0)
52
+ when /\A#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})\z/
53
+ new(red: Regexp.last_match(1).to_i(16) / 255.0,
54
+ green: Regexp.last_match(2).to_i(16) / 255.0,
55
+ blue: Regexp.last_match(3).to_i(16) / 255.0)
56
+ when /\Argb\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)\z/
57
+ new(red: Regexp.last_match(1).to_i / 255.0,
58
+ green: Regexp.last_match(2).to_i / 255.0,
59
+ blue: Regexp.last_match(3).to_i / 255.0)
60
+ when /\Argb\(\s*([\d.]+)%\s*,\s*([\d.]+)%\s*,\s*([\d.]+)%\s*\)\z/
61
+ new(red: Regexp.last_match(1).to_f / 100.0,
62
+ green: Regexp.last_match(2).to_f / 100.0,
63
+ blue: Regexp.last_match(3).to_f / 100.0)
64
+ else
65
+ parse_named(spec.to_s.downcase)
66
+ end
67
+ end
68
+
69
+ def self.parse_named(name)
70
+ return nil unless NAMED_COLORS.key?(name)
71
+
72
+ r, g, b = NAMED_COLORS[name]
73
+ new(red: r / 255.0, green: g / 255.0, blue: b / 255.0)
74
+ end
75
+ private_class_method :parse_named
76
+
77
+ # Returns the format pdfrb's canvas expects: +[:rgb, r, g, b]+.
78
+ def to_render
79
+ [:rgb, @red, @green, @blue]
80
+ end
81
+
82
+ def grayscale?
83
+ @red == @green && @green == @blue
84
+ end
85
+
86
+ def to_grayscale_float
87
+ (0.299 * @red) + (0.587 * @green) + (0.114 * @blue)
88
+ end
89
+
90
+ def ==(other)
91
+ other.is_a?(self.class) &&
92
+ red == other.red && green == other.green &&
93
+ blue == other.blue && alpha == other.alpha
94
+ end
95
+ alias eql? ==
96
+
97
+ def hash
98
+ [self.class, red, green, blue, alpha].hash
99
+ end
100
+
101
+ private
102
+
103
+ def clamp01(v)
104
+ v.clamp(0.0, 1.0)
105
+ end
106
+ end
107
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Arrolio
4
+ # A multi-column frame that splits a region's width into N equal
5
+ # columns. The engine flows content column-by-column: when one
6
+ # column overflows, content moves to the next column; when all
7
+ # columns overflow, the page breaks. This is the foundation for
8
+ # multi-column body text (e.g., 2-column technical specs).
9
+ #
10
+ # Usage in layout spec YAML:
11
+ # page_templates:
12
+ # body:
13
+ # columns: 2
14
+ # column_gap: 6mm
15
+ class ColumnSet
16
+ attr_reader :column_count, :gap, :total_width, :total_height
17
+
18
+ def initialize(column_count:, gap:, total_width:, total_height:)
19
+ @column_count = column_count.to_i
20
+ @gap = gap.to_f
21
+ @total_width = total_width.to_f
22
+ @total_height = total_height.to_f
23
+ freeze
24
+ end
25
+
26
+ # Width of a single column (total - gaps) / count.
27
+ def column_width
28
+ return @total_width if @column_count <= 1
29
+
30
+ (@total_width - (@gap * (@column_count - 1))) / @column_count
31
+ end
32
+
33
+ # X position of column at +index+ (0-based).
34
+ def column_x(index)
35
+ return 0.0 if @column_count <= 1
36
+
37
+ index * (column_width + @gap)
38
+ end
39
+
40
+ # Enumerate each column as a [x, width] pair.
41
+ def each_column
42
+ return enum_for(:each_column) unless block_given?
43
+
44
+ @column_count.times { |i| yield(column_x(i), column_width) }
45
+ end
46
+
47
+ def single_column?
48
+ @column_count <= 1
49
+ end
50
+
51
+ def ==(other)
52
+ other.is_a?(self.class) &&
53
+ column_count == other.column_count &&
54
+ gap == other.gap &&
55
+ total_width == other.total_width &&
56
+ total_height == other.total_height
57
+ end
58
+ alias eql? ==
59
+
60
+ def hash
61
+ [self.class, column_count, gap, total_width, total_height].hash
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Arrolio
4
+ # Placeholder. TODO.complete/24 will build the real facade.
5
+ class Composer
6
+ def initialize(**_opts); end
7
+
8
+ def write(_target, io: nil); end
9
+ end
10
+ end
@@ -0,0 +1,144 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'yaml'
4
+
5
+ module Arrolio
6
+ class ConfigDrivenPipeline
7
+ attr_reader :flavor_dir, :layout_spec, :adapter_rules, :flow_rules, :manifest, :strict
8
+
9
+ def self.render(xml, io:, flavor_dir:, input_path: nil,
10
+ extra_image_dirs: [], logo_path: nil, metadata: {},
11
+ strict: false)
12
+ new(
13
+ flavor_dir: flavor_dir,
14
+ input_path: input_path,
15
+ extra_image_dirs: extra_image_dirs,
16
+ logo_path: logo_path,
17
+ strict: strict
18
+ ).render(xml, io: io, metadata: metadata)
19
+ end
20
+
21
+ def initialize(flavor_dir:, input_path: nil, extra_image_dirs: [],
22
+ logo_path: nil, strict: false)
23
+ @flavor_dir = File.expand_path(flavor_dir)
24
+ @input_path = input_path
25
+ @extra_image_dirs = Array(extra_image_dirs)
26
+ @logo_path = logo_path
27
+ @strict = strict
28
+ @manifest = load_manifest
29
+ @layout_spec = load_layout_spec
30
+ @adapter_rules = load_yaml('adapter_rules.yml')
31
+ @flow_rules = load_yaml('flow_rules.yml')
32
+ end
33
+
34
+ def render(xml, io:, metadata: {})
35
+ adapter = GenericAdapter.new(rules: @adapter_rules, layout_spec: @layout_spec)
36
+ document = adapter.convert(xml)
37
+ document_metadata = document.metadata.merge(metadata.transform_keys(&:to_sym))
38
+ register_fonts
39
+
40
+ resolver = AssetResolver.from_input_path(
41
+ @input_path,
42
+ extra: @extra_image_dirs + [@flavor_dir]
43
+ )
44
+ flowables = GenericFlowBuilder.new(
45
+ layout_spec: @layout_spec,
46
+ rules: @flow_rules,
47
+ asset_resolver: resolver
48
+ ).build(document)
49
+ engine = Engine::Paged.new(layout_spec: @layout_spec, flowables: flowables)
50
+ pages = engine.layout
51
+ populate_toc(pages, engine.context)
52
+
53
+ Renderer::Pdf.new.render(
54
+ pages,
55
+ io: io,
56
+ logo_path: @logo_path,
57
+ metadata: document_metadata,
58
+ font_paths: @layout_spec.font_paths,
59
+ context: engine.context,
60
+ layout_spec: @layout_spec
61
+ )
62
+ end
63
+
64
+ private
65
+
66
+ def load_manifest
67
+ Flavor::Manifest.load(@flavor_dir)
68
+ rescue FlavorError => e
69
+ Arrolio::Logger.debug "no manifest loaded for #{@flavor_dir}: #{e.message}"
70
+ nil
71
+ end
72
+
73
+ def load_yaml(filename)
74
+ path = File.join(@flavor_dir, filename)
75
+ YAML.safe_load_file(path, aliases: true) || {}
76
+ end
77
+
78
+ def load_layout_spec
79
+ source = load_yaml('layout_spec.yml')
80
+ source['font_paths'] = resolved_font_paths(source['font_paths'] || {})
81
+ LayoutSpec::Loader.build_from_hash(source)
82
+ end
83
+
84
+ def resolved_font_paths(paths)
85
+ paths.to_h do |name, path|
86
+ resolved = path.to_s.start_with?('/') ? path : File.join(@flavor_dir, path.to_s)
87
+ [name, resolved]
88
+ end
89
+ end
90
+
91
+ def register_fonts
92
+ FontMetrics::Registry.register_ttf_all(@layout_spec.font_paths, strict: @strict)
93
+ end
94
+
95
+ def populate_toc(pages, context)
96
+ entries = context&.heading_entries
97
+ return unless entries&.any?
98
+
99
+ toc_rules = @flow_rules['toc'] || {}
100
+ toc_flowables = TocBuilder.build_flowables(context, @layout_spec, rules: toc_rules)
101
+ return if toc_flowables.empty?
102
+
103
+ toc_page_index = pages.index { |page| page.template_role == :preface }
104
+ return unless toc_page_index
105
+
106
+ toc_page = pages[toc_page_index]
107
+ region = toc_page.regions[:body]
108
+ return unless region
109
+
110
+ toc_rules_full = @flow_rules['toc'] || {}
111
+ toc_top_offset = (toc_rules_full['top_offset'] || 40).to_f
112
+
113
+ cursor_y = region.y + region.height - toc_top_offset
114
+ new_boxes = []
115
+ toc_flowables.each do |flowable|
116
+ flowable.height(region.width, context)
117
+ boxes, consumed = flowable.emit(region.x, cursor_y, region.width, context)
118
+ new_boxes.concat(boxes)
119
+ cursor_y -= consumed
120
+ end
121
+
122
+ updated_region = Output::Region.new(
123
+ name: region.name,
124
+ x: region.x,
125
+ y: region.y,
126
+ width: region.width,
127
+ height: region.height,
128
+ placed_boxes: region.placed_boxes + new_boxes
129
+ )
130
+ updated_page = Output::Page.new(
131
+ number: toc_page.number,
132
+ template_name: toc_page.template_name,
133
+ template_role: toc_page.template_role,
134
+ page_size: toc_page.page_size,
135
+ regions: toc_page.regions.merge(body: updated_region),
136
+ header_text: toc_page.header_text,
137
+ footer_text: toc_page.footer_text,
138
+ header_align: toc_page.header_align,
139
+ footer_align: toc_page.footer_align
140
+ )
141
+ pages[toc_page_index] = updated_page
142
+ end
143
+ end
144
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Arrolio
4
+ module Content
5
+ # A single bibliography entry: citation tag (e.g. "[Smith2020]")
6
+ # and a formatted reference Paragraph. Renders with a
7
+ # hanging-indent so wrapped lines align under the text, not the
8
+ # citation tag.
9
+ class BibliographyItem
10
+ attr_reader :tag, :formattedref, :id, :style_id
11
+
12
+ def initialize(formattedref:, tag: nil, id: nil, style_id: :bibitem)
13
+ @tag = tag
14
+ @formattedref = formattedref
15
+ @id = id&.to_s
16
+ @style_id = style_id.to_sym
17
+ freeze
18
+ end
19
+
20
+ def text
21
+ @formattedref.is_a?(Paragraph) ? @formattedref.text : @formattedref.to_s
22
+ end
23
+
24
+ def empty?
25
+ @tag.nil? && (@formattedref.nil? || (@formattedref.is_a?(Paragraph) && @formattedref.empty?))
26
+ end
27
+
28
+ def ==(other)
29
+ other.is_a?(self.class) &&
30
+ tag == other.tag &&
31
+ formattedref == other.formattedref &&
32
+ id == other.id &&
33
+ style_id == other.style_id
34
+ end
35
+ alias eql? ==
36
+
37
+ def hash
38
+ [self.class, tag, formattedref, id, style_id].hash
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Arrolio
4
+ module Content
5
+ class Builder
6
+ attr_reader :metadata, :sections
7
+
8
+ def self.build(metadata: {}, **opts, &block)
9
+ builder = new(metadata: metadata, **opts)
10
+ block&.call(builder)
11
+ builder.to_document
12
+ end
13
+
14
+ def initialize(metadata: {})
15
+ @metadata = metadata
16
+ @sections = []
17
+ end
18
+
19
+ def section(title = nil, number: nil, level: 1, id: nil,
20
+ style_id: :section_body, title_style_id: nil, &block)
21
+ sb = SectionBuilder.new(title: title, number: number, level: level, id: id,
22
+ style_id: style_id, title_style_id: title_style_id)
23
+ block&.call(sb)
24
+ @sections << sb.build
25
+ self
26
+ end
27
+
28
+ def to_document
29
+ Document.new(metadata: @metadata, sections: @sections)
30
+ end
31
+ end
32
+
33
+ class SectionBuilder
34
+ attr_reader :title, :level, :number, :id, :style_id, :title_style_id
35
+
36
+ def initialize(title:, number:, level:, id:, style_id:, title_style_id:)
37
+ @title = title
38
+ @number = number
39
+ @level = level
40
+ @id = id
41
+ @style_id = style_id
42
+ @title_style_id = title_style_id
43
+ @children = []
44
+ end
45
+
46
+ def paragraph(text = nil, style_id: :body, id: nil, &block)
47
+ runs = block ? ParagraphBuilder.new.runs(&block) : [InlineRun.new(text.to_s, style_id: :inline)]
48
+ @children << Paragraph.new(runs, style_id: style_id, id: id)
49
+ self
50
+ end
51
+
52
+ def list(items, kind: :bullet, style_id: :list, id: nil)
53
+ @children << List.new(items, kind: kind, style_id: style_id, id: id)
54
+ self
55
+ end
56
+
57
+ def table(header: [], body: [], column_widths: nil, style_id: :table, id: nil)
58
+ @children << Table.new(header: header, body: body,
59
+ column_widths: column_widths, style_id: style_id, id: id)
60
+ self
61
+ end
62
+
63
+ def section(title = nil, **opts, &block)
64
+ opts[:level] ||= @level + 1
65
+ sub = SectionBuilder.new(title: title, number: opts[:number],
66
+ level: opts[:level], id: opts[:id],
67
+ style_id: opts.fetch(:style_id, @style_id),
68
+ title_style_id: opts[:title_style_id])
69
+ block&.call(sub)
70
+ @children << sub.build
71
+ self
72
+ end
73
+
74
+ def build
75
+ Section.new(title: @title, level: @level, number: @number, id: @id,
76
+ children: @children, style_id: @style_id, title_style_id: @title_style_id)
77
+ end
78
+ end
79
+
80
+ class ParagraphBuilder
81
+ def initialize
82
+ @runs = []
83
+ end
84
+
85
+ def text(str, style_id: :inline)
86
+ @runs << InlineRun.new(str.to_s, style_id: style_id)
87
+ end
88
+
89
+ def strong(str)
90
+ @runs << InlineRun.new(str.to_s, style_id: :strong)
91
+ end
92
+
93
+ def em(str)
94
+ @runs << InlineRun.new(str.to_s, style_id: :em)
95
+ end
96
+
97
+ def link(str, href)
98
+ @runs << InlineRun.new(str.to_s, style_id: :link, href: href)
99
+ end
100
+
101
+ def runs
102
+ return @runs unless block_given?
103
+ yield self
104
+ @runs
105
+ end
106
+ end
107
+ end
108
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'arrolio/error'
4
+
5
+ module Arrolio
6
+ module Content
7
+ class Document
8
+ attr_reader :metadata, :sections, :preface, :bibliography, :cover,
9
+ :style_id, :footnotes
10
+
11
+ def self.build(metadata: {}, **opts, &block)
12
+ Builder.build(metadata: metadata, **opts, &block)
13
+ end
14
+
15
+ def initialize(metadata: {}, sections: [], preface: [], bibliography: [],
16
+ cover: nil, style_id: :document, footnotes: [])
17
+ @metadata = metadata.to_h.freeze
18
+ @sections = validate_sections(Array(sections), field: :sections).freeze
19
+ @preface = validate_sections(Array(preface), field: :preface).freeze
20
+ @bibliography = validate_sections(Array(bibliography), field: :bibliography).freeze
21
+ @cover = cover
22
+ @style_id = style_id.to_sym
23
+ @footnotes = Array(footnotes).freeze
24
+ freeze
25
+ end
26
+
27
+ def title = @metadata[:title] || @metadata['title']
28
+ def docidentifier = @metadata[:docidentifier] || @metadata['docidentifier']
29
+ def edition = @metadata[:edition] || @metadata['edition']
30
+
31
+ def ==(other)
32
+ other.is_a?(self.class) &&
33
+ metadata == other.metadata && sections == other.sections &&
34
+ preface == other.preface && bibliography == other.bibliography &&
35
+ cover == other.cover && style_id == other.style_id &&
36
+ footnotes == other.footnotes
37
+ end
38
+ alias eql? ==
39
+
40
+ def hash
41
+ [self.class, metadata, sections, preface, bibliography, cover,
42
+ style_id, footnotes].hash
43
+ end
44
+
45
+ private
46
+
47
+ def validate_sections(array, field:)
48
+ array.each do |item|
49
+ next if item.is_a?(Section)
50
+
51
+ raise ::Arrolio::ContentError.new(
52
+ "#{field} must contain only Content::Section instances; got #{item.class}",
53
+ node: item
54
+ )
55
+ end
56
+ array
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Arrolio
4
+ module Content
5
+ # A semantic example group. Same shape as Note but carries the
6
+ # :example style_id by default so the flow builder can apply the
7
+ # XSL's `example-body-style` margin-left to the body Paragraphs.
8
+ class Example
9
+ attr_reader :label, :body, :id, :style_id
10
+
11
+ def initialize(body:, label: '', id: nil, style_id: :example)
12
+ @label = label.to_s
13
+ @body = Array(body).freeze
14
+ @id = id&.to_s
15
+ @style_id = style_id.to_sym
16
+ freeze
17
+ end
18
+
19
+ def body_text
20
+ @body.map do |node|
21
+ node.is_a?(Paragraph) ? node.text : node.to_s
22
+ end.join(' ')
23
+ end
24
+
25
+ def empty?
26
+ @body.empty? || @body.all? { |node| node.is_a?(Paragraph) && node.empty? }
27
+ end
28
+
29
+ def ==(other)
30
+ other.is_a?(self.class) &&
31
+ label == other.label &&
32
+ body == other.body &&
33
+ id == other.id &&
34
+ style_id == other.style_id
35
+ end
36
+ alias eql? ==
37
+
38
+ def hash
39
+ [self.class, label, body, id, style_id].hash
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Arrolio
4
+ module Content
5
+ # A figure group: optional Image plus optional caption Paragraph.
6
+ # Replaces the previous flat [Image, Paragraph] emission from
7
+ # convert_figure. The flow builder renders the group as a
8
+ # keep-together unit so the caption never lands on a different
9
+ # page from the image.
10
+ class FigureGroup
11
+ attr_reader :image, :caption, :id, :style_id
12
+
13
+ def initialize(image: nil, caption: nil, id: nil, style_id: :figure)
14
+ @image = image
15
+ @caption = caption
16
+ @id = id&.to_s
17
+ @style_id = style_id.to_sym
18
+ freeze
19
+ end
20
+
21
+ def empty?
22
+ @image.nil? && (@caption.nil? || (@caption.is_a?(Paragraph) && @caption.empty?))
23
+ end
24
+
25
+ def ==(other)
26
+ other.is_a?(self.class) &&
27
+ image == other.image &&
28
+ caption == other.caption &&
29
+ id == other.id &&
30
+ style_id == other.style_id
31
+ end
32
+ alias eql? ==
33
+
34
+ def hash
35
+ [self.class, image, caption, id, style_id].hash
36
+ end
37
+ end
38
+ end
39
+ end