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,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Arrolio
4
+ module Renderer
5
+ # Builds an XMP metadata packet (RDF/XML wrapped in xpacket
6
+ # processing instructions). XMP carries Dublin Core properties
7
+ # (dc:title, dc:creator) alongside PDF-specific properties
8
+ # (pdf:Producer, xmp:CreatorTool). The packet is stored as a
9
+ # stream on the PDF catalog's /Metadata entry.
10
+ class XmpBuilder
11
+ XPACKET_BEGIN = '<?xpacket begin="\xEF\xBB\xBF" id="W5M0MpCehiHzreSzNTczkc9d"?>'
12
+ XPACKET_END = '<?xpacket end="w"?>'
13
+
14
+ attr_reader :metadata
15
+
16
+ def initialize(metadata = {})
17
+ @metadata = metadata
18
+ end
19
+
20
+ def build
21
+ [XPACKET_BEGIN, xmp_body, XPACKET_END].join("\n")
22
+ end
23
+
24
+ private
25
+
26
+ def xmp_body
27
+ %(<x:xmpmeta xmlns:x="adobe:ns:meta/">) +
28
+ %(<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">) +
29
+ rdf_descriptions +
30
+ %(</rdf:RDF>) +
31
+ %(</x:xmpmeta>)
32
+ end
33
+
34
+ def rdf_descriptions
35
+ [dc_description, pdf_description, xmp_description].compact.join
36
+ end
37
+
38
+ def dc_description
39
+ return '' unless @metadata[:title] || @metadata[:author]
40
+
41
+ inner = []
42
+ if @metadata[:title]
43
+ inner << (%(<dc:title><rdf:Alt><rdf:li xml:lang="x-default">) +
44
+ escape(@metadata[:title].to_s) +
45
+ %(</rdf:li></rdf:Alt></dc:title>))
46
+ end
47
+ if @metadata[:author]
48
+ inner << (%(<dc:creator><rdf:Seq><rdf:li>) +
49
+ escape(@metadata[:author].to_s) +
50
+ %(</rdf:li></rdf:Seq></dc:creator>))
51
+ end
52
+ %(<rdf:Description rdf:about="" xmlns:dc="http://purl.org/dc/elements/1.1/">) +
53
+ inner.join +
54
+ %(</rdf:Description>)
55
+ end
56
+
57
+ def pdf_description
58
+ producer = escape(@metadata[:producer] || 'Arrolio + Pdfrb')
59
+ %(<rdf:Description rdf:about="" xmlns:pdf="http://ns.adobe.com/pdf/1.3/">) +
60
+ %(<pdf:Producer>) + producer + %(</pdf:Producer>) +
61
+ %(</rdf:Description>)
62
+ end
63
+
64
+ def xmp_description
65
+ creator_tool = escape(@metadata[:creator_tool] || 'Arrolio (Ruby)')
66
+ create_date = xmp_date(@metadata[:created_at])
67
+ modify_date = xmp_date(@metadata[:modified_at])
68
+ %(<rdf:Description rdf:about="" xmlns:xmp="http://ns.adobe.com/xap/1.0/">) +
69
+ %(<xmp:CreatorTool>) + creator_tool + %(</xmp:CreatorTool>) +
70
+ (create_date ? %(<xmp:CreateDate>) + create_date + %(</xmp:CreateDate>) : '') +
71
+ (modify_date ? %(<xmp:ModifyDate>) + modify_date + %(</xmp:ModifyDate>) : '') +
72
+ %(</rdf:Description>)
73
+ end
74
+
75
+ def xmp_date(time)
76
+ t = time || Time.now
77
+ t = Time.parse(t.to_s) unless t.is_a?(Time)
78
+ t.strftime('%Y-%m-%dT%H:%M:%S%:z')
79
+ rescue StandardError
80
+ nil
81
+ end
82
+
83
+ def escape(str)
84
+ str.to_s
85
+ .gsub('&', '&amp;')
86
+ .gsub('<', '&lt;')
87
+ .gsub('>', '&gt;')
88
+ .gsub('"', '&quot;')
89
+ end
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'stringio'
4
+
5
+ # Arrolio::Renderer — walks Output::Page[] and emits bytes.
6
+ module Arrolio
7
+ module Renderer
8
+ autoload :Pdf, 'arrolio/renderer/pdf'
9
+ autoload :FontRegistry, 'arrolio/renderer/font_registry'
10
+ autoload :OutlineBuilder, 'arrolio/renderer/outline_builder'
11
+ autoload :XmpBuilder, 'arrolio/renderer/xmp_builder'
12
+ autoload :AccessibilityTagger, 'arrolio/renderer/accessibility_tagger'
13
+ autoload :LinkAnnotator, 'arrolio/renderer/link_annotator'
14
+ autoload :StructureTreeBuilder, 'arrolio/renderer/structure_tree_builder'
15
+ autoload :SignatureConfig, 'arrolio/renderer/signature_config'
16
+ end
17
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Arrolio
4
+ module Style
5
+ class Definition
6
+ attr_reader :font_name, :font_size, :fill_color, :stroke_color,
7
+ :line_spacing, :align, :valign,
8
+ :margin_top, :margin_right, :margin_bottom, :margin_left,
9
+ :padding_top, :padding_right, :padding_bottom, :padding_left,
10
+ :character_spacing, :word_spacing, :line_break,
11
+ :keep_together, :page_break_before, :page_break_after,
12
+ :keep_with_next, :widows, :orphans, :parent
13
+
14
+ DEFAULTS = {
15
+ font_name: 'Helvetica', font_size: 12.0,
16
+ fill_color: nil, stroke_color: nil,
17
+ line_spacing: 1.2, align: :left, valign: :top,
18
+ margin_top: 0.0, margin_right: 0.0, margin_bottom: 0.0, margin_left: 0.0,
19
+ padding_top: 0.0, padding_right: 0.0, padding_bottom: 0.0, padding_left: 0.0,
20
+ character_spacing: 0.0, word_spacing: 0.0,
21
+ line_break: :greedy,
22
+ keep_together: false, page_break_before: false, page_break_after: false,
23
+ keep_with_next: false, widows: 2, orphans: 2, parent: nil
24
+ }.freeze
25
+
26
+ def initialize(**opts)
27
+ merged = DEFAULTS.merge(opts.transform_keys(&:to_sym))
28
+ @font_name = merged.fetch(:font_name).to_s
29
+ @font_size = (merged.fetch(:font_size) || 0).to_f
30
+ @fill_color = merged.fetch(:fill_color)
31
+ @stroke_color = merged.fetch(:stroke_color)
32
+ @line_spacing = (merged.fetch(:line_spacing) || 0).to_f
33
+ @align = merged.fetch(:align).to_sym
34
+ @valign = merged.fetch(:valign).to_sym
35
+ @margin_top = (merged.fetch(:margin_top) || 0).to_f
36
+ @margin_right = (merged.fetch(:margin_right) || 0).to_f
37
+ @margin_bottom = (merged.fetch(:margin_bottom) || 0).to_f
38
+ @margin_left = (merged.fetch(:margin_left) || 0).to_f
39
+ @padding_top = (merged.fetch(:padding_top) || 0).to_f
40
+ @padding_right = (merged.fetch(:padding_right) || 0).to_f
41
+ @padding_bottom = (merged.fetch(:padding_bottom) || 0).to_f
42
+ @padding_left = (merged.fetch(:padding_left) || 0).to_f
43
+ @character_spacing = (merged.fetch(:character_spacing) || 0).to_f
44
+ @word_spacing = (merged.fetch(:word_spacing) || 0).to_f
45
+ @line_break = merged.fetch(:line_break).to_sym
46
+ @keep_together = truthy?(merged.fetch(:keep_together))
47
+ @page_break_before = truthy?(merged.fetch(:page_break_before))
48
+ @page_break_after = truthy?(merged.fetch(:page_break_after))
49
+ @keep_with_next = truthy?(merged.fetch(:keep_with_next))
50
+ @widows = merged.fetch(:widows).to_i
51
+ @orphans = merged.fetch(:orphans).to_i
52
+ @parent = merged.fetch(:parent)
53
+ freeze
54
+ end
55
+
56
+ def with(**opts)
57
+ self.class.new(**to_h, **opts.transform_keys(&:to_sym))
58
+ end
59
+
60
+ def space_before = @margin_top
61
+ def space_after = @margin_bottom
62
+
63
+ def ==(other)
64
+ other.is_a?(self.class) && to_h == other.to_h
65
+ end
66
+ alias eql? ==
67
+
68
+ def hash = to_h.hash
69
+
70
+ def to_h
71
+ {
72
+ font_name: @font_name, font_size: @font_size,
73
+ fill_color: @fill_color, stroke_color: @stroke_color,
74
+ line_spacing: @line_spacing, align: @align, valign: @valign,
75
+ margin_top: @margin_top, margin_right: @margin_right,
76
+ margin_bottom: @margin_bottom, margin_left: @margin_left,
77
+ padding_top: @padding_top, padding_right: @padding_right,
78
+ padding_bottom: @padding_bottom, padding_left: @padding_left,
79
+ character_spacing: @character_spacing, word_spacing: @word_spacing,
80
+ line_break: @line_break, keep_together: @keep_together,
81
+ page_break_before: @page_break_before, page_break_after: @page_break_after,
82
+ keep_with_next: @keep_with_next, widows: @widows, orphans: @orphans,
83
+ parent: @parent
84
+ }
85
+ end
86
+
87
+ private
88
+
89
+ def truthy?(v)
90
+ case v
91
+ when true, 'true', 'always' then true
92
+ when Integer then v.positive?
93
+ else false
94
+ end
95
+ end
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'yaml'
4
+
5
+ module Arrolio
6
+ module Style
7
+ module Loader
8
+ module_function
9
+
10
+ def load(source)
11
+ Registry.new(load_hash(source) || {})
12
+ end
13
+
14
+ def load_hash(source)
15
+ case source
16
+ when Hash then source
17
+ when String then ::YAML.safe_load(source, aliases: true) || {}
18
+ when IO, StringIO then ::YAML.safe_load(source.read, aliases: true) || {}
19
+ else
20
+ raise ArgumentError, 'Loader.load needs a Hash, String, or IO'
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'set'
4
+
5
+ module Arrolio
6
+ module Style
7
+ class Registry
8
+ attr_reader :overrides
9
+
10
+ def initialize(overrides = {})
11
+ @overrides = {}
12
+ overrides.each { |name, attrs| register(name, **attrs) }
13
+ freeze
14
+ end
15
+
16
+ def register(name, **opts)
17
+ @overrides[name.to_sym] = opts.transform_keys(&:to_sym)
18
+ name.to_sym
19
+ end
20
+
21
+ def resolve(name, fallback: Definition.new)
22
+ key = name.to_sym
23
+ return fallback unless @overrides.key?(key)
24
+
25
+ chain = []
26
+ cursor = key
27
+ seen = Set.new
28
+ while cursor && @overrides.key?(cursor) && seen.add?(cursor)
29
+ chain << @overrides[cursor]
30
+ parent = @overrides[cursor][:parent]
31
+ cursor = parent&.to_sym
32
+ end
33
+ merged = chain.reverse_each.reduce({}) { |acc, h| acc.merge(h) }
34
+ merged.delete(:parent)
35
+ Definition.new(**merged)
36
+ end
37
+
38
+ def names = @overrides.keys
39
+ def key?(name) = @overrides.key?(name.to_sym)
40
+ def [](name) = @overrides.[](name.to_sym)
41
+
42
+ def ==(other)
43
+ other.is_a?(self.class) && overrides == other.overrides
44
+ end
45
+
46
+ alias eql? ==
47
+
48
+ def hash
49
+ [self.class, overrides].hash
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Arrolio
4
+ module Style
5
+ autoload :Definition, 'arrolio/style/definition'
6
+ autoload :Registry, 'arrolio/style/registry'
7
+ autoload :Loader, 'arrolio/style/loader'
8
+ end
9
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Arrolio
4
+ module Table
5
+ # Computes column widths for a Content::Table based on natural
6
+ # content widths. Each column gets at least its natural width
7
+ # (longest unbreakable token + padding). Remaining space is
8
+ # distributed proportionally to the column's natural width, so
9
+ # wider columns get more extra space.
10
+ #
11
+ # If the total natural width exceeds the available width, columns
12
+ # are scaled down proportionally (each gets a share proportional
13
+ # to its natural width, but never below a minimum).
14
+ class AutoLayout
15
+ MIN_COLUMN_WIDTH = 20.0 # points — prevents columns from collapsing
16
+ CELL_PADDING = 8.0 # points — left + right padding per cell
17
+
18
+ attr_reader :table, :available_width, :measurer
19
+
20
+ def initialize(table, available_width:, measurer: nil)
21
+ @table = table
22
+ @available_width = available_width.to_f
23
+ @measurer = measurer || default_measurer
24
+ end
25
+
26
+ # Returns an Array of Float widths (one per column), summing
27
+ # to +available_width+.
28
+ def compute
29
+ return Array.new(column_count, @available_width / column_count) if column_count.zero?
30
+
31
+ natural = natural_widths
32
+ distribute(natural)
33
+ end
34
+
35
+ private
36
+
37
+ def column_count
38
+ @table.column_count
39
+ end
40
+
41
+ # Natural width of each column = max cell natural width across rows.
42
+ def natural_widths
43
+ widths = Array.new(column_count, MIN_COLUMN_WIDTH)
44
+ @table.rows.each do |row|
45
+ row.cells.each_with_index do |cell, ci|
46
+ next if ci >= widths.length
47
+
48
+ w = cell_natural_width(cell)
49
+ widths[ci] = w if w > widths[ci]
50
+ end
51
+ end
52
+ widths
53
+ end
54
+
55
+ def cell_natural_width(cell)
56
+ longest = cell.text.split(/\s+/).max_by(&:length) || ''
57
+ @measurer.width_of_string(longest, font_size: 10.0) + CELL_PADDING
58
+ end
59
+
60
+ # Distribute +available_width+ across columns based on their
61
+ # natural widths. If naturals fit, distribute extra space
62
+ # proportionally. If they overflow, scale down proportionally
63
+ # (but never below MIN_COLUMN_WIDTH).
64
+ def distribute(natural)
65
+ total_natural = natural.sum
66
+ if total_natural >= @available_width
67
+ scale_down(natural, total_natural)
68
+ else
69
+ distribute_extra(natural, total_natural)
70
+ end
71
+ end
72
+
73
+ def scale_down(natural, total_natural)
74
+ ratio = @available_width / total_natural
75
+ natural.map { |w| [w * ratio, MIN_COLUMN_WIDTH].max }
76
+ end
77
+
78
+ def distribute_extra(natural, total_natural)
79
+ extra = @available_width - total_natural
80
+ natural.map.with_index do |w, _i|
81
+ share = total_natural.positive? ? (w / total_natural) : 0.0
82
+ w + (extra * share)
83
+ end
84
+ end
85
+
86
+ def default_measurer
87
+ GlyphMeasurer.new(font_name: 'Times New Roman')
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Arrolio
4
+ # Table layout algorithms. Currently:
5
+ # - AutoLayout — content-based column width computation.
6
+ module Table
7
+ autoload :AutoLayout, 'arrolio/table/auto_layout'
8
+ end
9
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Arrolio
4
+ # Unicode text direction detection for BIDI-aware layout. Detects
5
+ # whether a string is predominantly left-to-right (Latin, CJK),
6
+ # right-to-left (Arabic, Hebrew), or mixed. Used by the text
7
+ # layout engine to apply correct character ordering within lines.
8
+ #
9
+ # This module implements the "paragraph level" detection from
10
+ # Unicode UAX #9 (BIDI Algorithm) — a simplified version that
11
+ # suffices for most standards documents. Full BIDI reordering
12
+ # (handling embedded runs, override controls) is future work.
13
+ module TextDirection
14
+ LTR = :ltr
15
+ RTL = :rtl
16
+ MIXED = :mixed
17
+
18
+ # Unicode blocks containing RTL characters.
19
+ RTL_RANGES = [
20
+ 0x0590..0x05FF, # Hebrew
21
+ 0x0600..0x06FF, # Arabic
22
+ 0x0700..0x074F, # Syriac
23
+ 0x0750..0x077F, # Arabic Supplement
24
+ 0x08A0..0x08FF, # Arabic Extended-A
25
+ 0xFB1D..0xFB4F, # Hebrew Presentation Forms
26
+ 0xFB50..0xFDFF, # Arabic Presentation Forms-A
27
+ 0xFE70..0xFEFF # Arabic Presentation Forms-B
28
+ ].freeze
29
+
30
+ module_function
31
+
32
+ # Returns the dominant direction of a string.
33
+ # +:ltr+ — all or predominantly LTR characters.
34
+ # +:rtl+ — predominantly RTL characters.
35
+ # +:mixed+ — significant presence of both LTR and RTL.
36
+ def detect(string)
37
+ return LTR if string.nil? || string.empty?
38
+
39
+ chars = string.each_char.to_a
40
+ rtl_count = chars.count { |c| rtl?(c) }
41
+ ltr_count = chars.count { |c| ltr?(c) }
42
+
43
+ return LTR if rtl_count.zero?
44
+ return RTL if ltr_count.zero?
45
+
46
+ ratio = rtl_count.to_f / (rtl_count + ltr_count)
47
+ ratio > 0.6 ? RTL : (ratio < 0.3 ? LTR : MIXED)
48
+ end
49
+
50
+ # Is this character an RTL character?
51
+ def rtl?(char)
52
+ cp = char.ord
53
+ RTL_RANGES.any? { |range| range.cover?(cp) }
54
+ end
55
+
56
+ # Is this character an LTR character (Latin, Cyrillic, Greek,
57
+ # CJK, etc.)? Excludes whitespace, punctuation, and digits
58
+ # (which are neutral in BIDI).
59
+ def ltr?(char)
60
+ cp = char.ord
61
+ return false if cp < 0x0041 # below 'A'
62
+ return false if rtl?(char)
63
+ return false if neutral?(char)
64
+
65
+ # Latin, Latin Extended, Cyrillic, Greek, CJK, etc.
66
+ true
67
+ end
68
+
69
+ # BIDI-neutral characters (whitespace, punctuation, digits).
70
+ def neutral?(char)
71
+ char.match?(/\s/) || char.match?(/[0-9]/) || char.match?(/[^\p{L}]/)
72
+ end
73
+
74
+ # Reverses a string character-by-character for RTL rendering.
75
+ # Does NOT do full BIDI reordering — only flips the base
76
+ # direction. Adequate for simple RTL text without embedded LTR.
77
+ def reverse_for_rtl(string)
78
+ string.reverse
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Arrolio
4
+ module TextLayout
5
+ # A candidate line-break position within a list of InlineRuns.
6
+ class BreakOpportunity
7
+ attr_reader :run_index, :char_offset, :width_before, :type
8
+
9
+ def initialize(run_index:, char_offset:, width_before:, type:)
10
+ @run_index = run_index
11
+ @char_offset = char_offset
12
+ @width_before = width_before.to_f
13
+ @type = type
14
+ freeze
15
+ end
16
+
17
+ def forced?
18
+ @type == :forced
19
+ end
20
+
21
+ def soft?
22
+ @type == :soft
23
+ end
24
+
25
+ def ==(other)
26
+ other.is_a?(self.class) &&
27
+ run_index == other.run_index &&
28
+ char_offset == other.char_offset &&
29
+ width_before == other.width_before &&
30
+ type == other.type
31
+ end
32
+
33
+ alias eql? ==
34
+
35
+ def hash
36
+ [self.class, run_index, char_offset, width_before, type].hash
37
+ end
38
+
39
+ class << self
40
+ def each_in(runs, measurer:, font_size:)
41
+ return enum_for(:each_in, runs, measurer: measurer, font_size: font_size) unless block_given?
42
+
43
+ yield new(run_index: 0, char_offset: 0, width_before: 0.0, type: :start)
44
+
45
+ cumulative = 0.0
46
+ runs.each_with_index do |run, run_index|
47
+ text = run.text
48
+ i = 0
49
+ while i < text.length
50
+ ch = text[i]
51
+ if ch == "\n"
52
+ yield new(run_index: run_index, char_offset: i,
53
+ width_before: cumulative, type: :forced)
54
+ i += 1
55
+ next
56
+ end
57
+
58
+ if ch == ' '
59
+ cumulative += char_width(run, ' ', measurer, font_size)
60
+ yield new(run_index: run_index, char_offset: i + 1,
61
+ width_before: cumulative, type: :soft)
62
+ i += 1
63
+ next
64
+ end
65
+
66
+ if ch == '-' || ch == '—' || ch == '­'
67
+ cumulative += char_width(run, ch, measurer, font_size)
68
+ yield new(run_index: run_index, char_offset: i + 1,
69
+ width_before: cumulative, type: :soft)
70
+ i += 1
71
+ next
72
+ end
73
+
74
+ if ch == '​'
75
+ yield new(run_index: run_index, char_offset: i,
76
+ width_before: cumulative, type: :soft)
77
+ i += 1
78
+ next
79
+ end
80
+
81
+ cumulative += char_width(run, ch, measurer, font_size)
82
+ i += 1
83
+ end
84
+ end
85
+
86
+ yield new(run_index: runs.length, char_offset: 0,
87
+ width_before: cumulative, type: :end)
88
+ end
89
+
90
+ private
91
+
92
+ def char_width(run, char, measurer, font_size)
93
+ measurer.width_of_run(
94
+ char,
95
+ font_size: font_size,
96
+ character_spacing: run.style.character_spacing,
97
+ word_spacing: 0
98
+ )
99
+ end
100
+ end
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Arrolio
4
+ module TextLayout
5
+ # First-fit greedy line breaker. FOP's default. Linear time.
6
+ class Greedy
7
+ attr_reader :runs, :measurer, :width, :align
8
+
9
+ def initialize(runs, measurer:, width:, align: :left)
10
+ @runs = runs
11
+ @measurer = measurer
12
+ @width = width.to_f
13
+ @align = align
14
+ end
15
+
16
+ def layout
17
+ opps = opportunities
18
+ return [] if opps.empty? || opps.length == 1
19
+
20
+ lines = []
21
+ line_start = 0
22
+ i = 1
23
+ while i < opps.length
24
+ opp = opps[i]
25
+ prev = opps[i - 1]
26
+
27
+ if opp.forced?
28
+ emit_line(lines, opps, line_start, i, prev.width_before)
29
+ line_start = i + 1
30
+ i += 2
31
+ next
32
+ end
33
+
34
+ if overflows?(opps, line_start, opp) && (i - line_start > 1)
35
+ emit_line(lines, opps, line_start, i - 1, prev.width_before)
36
+ line_start = i - 1
37
+ end
38
+ i += 1
39
+ end
40
+
41
+ emit_final_line(lines, opps, line_start) unless lines_built_complete?(opps, line_start)
42
+ lines
43
+ end
44
+
45
+ private
46
+
47
+ def opportunities
48
+ BreakOpportunity.each_in(@runs, measurer: @measurer,
49
+ font_size: font_size).to_a
50
+ end
51
+
52
+ def font_size
53
+ @runs.first&.style&.font_size || 12
54
+ end
55
+
56
+ def overflows?(opps, line_start, opp)
57
+ opp.width_before - opps[line_start].width_before > @width
58
+ end
59
+
60
+ def emit_line(lines, opps, start_idx, end_idx, _line_width)
61
+ placed = build_placed_runs(opps, start_idx, end_idx)
62
+ width_used = opps[end_idx].width_before - opps[start_idx].width_before
63
+ lines << Line.new(placed, width: width_used,
64
+ max_width: @width, align: @align)
65
+ end
66
+
67
+ def emit_final_line(lines, opps, line_start)
68
+ last = opps.last
69
+ prev_w = line_start.positive? ? opps[line_start - 1].width_before : 0.0
70
+ return if last.width_before <= prev_w && !lines.empty?
71
+
72
+ emit_line(lines, opps, line_start, opps.length - 1, last.width_before)
73
+ end
74
+
75
+ def lines_built_complete?(opps, line_start)
76
+ line_start >= opps.length - 1
77
+ end
78
+
79
+ def build_placed_runs(opps, start_idx, end_idx)
80
+ return [] if start_idx >= end_idx
81
+
82
+ start_opp = opps[start_idx]
83
+ end_opp = opps[end_idx] || opps.last
84
+ start_run = start_opp.run_index
85
+ start_off = start_opp.char_offset
86
+ end_run = end_opp.run_index
87
+ end_off = end_opp.char_offset
88
+
89
+ placed = []
90
+ x_offset = 0.0
91
+ (start_run..end_run).each do |run_idx|
92
+ next unless run_idx < @runs.length
93
+
94
+ run = @runs[run_idx]
95
+ s_off = run_idx == start_run ? start_off : 0
96
+ e_off = run_idx == end_run ? end_off : run.text.length
97
+ next if s_off >= e_off
98
+
99
+ slice = run.text[s_off, e_off - s_off]
100
+ next if slice.nil? || slice.empty?
101
+
102
+ sub_run = InlineRun.new(slice, style: run.style)
103
+ placed << Line::PlacedRun.new(run: sub_run, x_offset: x_offset)
104
+ x_offset += sub_run.width(@measurer)
105
+ end
106
+ placed
107
+ end
108
+ end
109
+ end
110
+ end