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,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Arrolio
4
+ module Flavor
5
+ # Flavor registry. Flavors register their pipeline class at
6
+ # load time; applications look up flavors by name. This enables
7
+ # multiple flavors to coexist without
8
+ # the core engine knowing about any of them.
9
+ #
10
+ # A flavor's pipeline class must respond to:
11
+ # .render(xml, io:, **opts) — render XML to PDF bytes
12
+ #
13
+ # Example:
14
+ # Arrolio::Flavor::Registry.register(:my_flavor, Arrolio::Oiml::Pipeline)
15
+ # Arrolio::Flavor::Registry.for(:my_flavor).render(xml, io: io)
16
+ class Registry
17
+ @flavors = {}
18
+
19
+ class << self
20
+ attr_reader :flavors
21
+
22
+ # Register a flavor by name. +pipeline_class+ must respond
23
+ # to +.render(xml, io:, **opts)+.
24
+ def register(name, pipeline_class)
25
+ @flavors[name.to_sym] = pipeline_class
26
+ end
27
+
28
+ # Look up a registered flavor. Returns the pipeline class
29
+ # or raises if not found.
30
+ def for(name)
31
+ key = name.to_sym
32
+ @flavors[key] ||
33
+ raise(ArgumentError,
34
+ "Unknown flavor #{name.inspect}; registered: #{@flavors.keys.inspect}")
35
+ end
36
+
37
+ # All registered flavor names.
38
+ def names
39
+ @flavors.keys
40
+ end
41
+
42
+ # Is a flavor registered?
43
+ def registered?(name)
44
+ @flavors.key?(name.to_sym)
45
+ end
46
+
47
+ # Clear all registrations — used in specs.
48
+ def reset!
49
+ @flavors.clear
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Arrolio
4
+ # Flavor system — allows external document flavors to plug into
5
+ # Arrolio without modifying core. Each flavor lives in its own
6
+ # directory (XSL + 3 generated YAML files) outside the gem and is
7
+ # rendered via Arrolio::ConfigDrivenPipeline.
8
+ #
9
+ # Arrolio core is flavor-agnostic: it knows about Content::Document,
10
+ # Flowable, Engine, and Renderer — but NOT about any specific XML
11
+ # format, XSL stylesheet, or document structure.
12
+ #
13
+ # Usage:
14
+ # # In the flavor's gem:
15
+ # Arrolio::Flavor::Registry.register(:my_flavor, MyFlavorPipeline)
16
+ #
17
+ # # In the application:
18
+ # Arrolio::Flavor::Registry.for(:my_flavor).render(xml, io: f)
19
+ module Flavor
20
+ autoload :Registry, 'arrolio/flavor/registry'
21
+ autoload :Manifest, 'arrolio/flavor/manifest'
22
+ end
23
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Arrolio
4
+ # State threaded through every flowable fit/emit call. Holds
5
+ # page-level state flowables need: current page number, total
6
+ # page count (filled in between pass 1 and pass 2), the running
7
+ # citation table, and the LayoutSpec for style resolution.
8
+ class FlowContext
9
+ attr_accessor :page_number, :page_count, :renderer
10
+ attr_reader :layout_spec, :citations, :heading_entries
11
+
12
+ def initialize(layout_spec:, page_number: 1, page_count: nil)
13
+ @layout_spec = layout_spec
14
+ @page_number = page_number
15
+ @page_count = page_count
16
+ @citations = {}
17
+ @heading_entries = []
18
+ end
19
+
20
+ def citation_for(ref_id)
21
+ @citations[ref_id.to_s]
22
+ end
23
+
24
+ def record_citation(ref_id, page_number)
25
+ @citations[ref_id.to_s] ||= page_number
26
+ end
27
+
28
+ def record_heading(number:, title:, level:, page_number:, id: nil)
29
+ @heading_entries << { number: number, title: title, level: level,
30
+ page_number: page_number, id: id }
31
+ end
32
+
33
+ def total_pages
34
+ @page_count
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Arrolio::Flowable — abstract base for everything the page-flow
4
+ # engine places into Frames.
5
+ module Arrolio
6
+ class Flowable
7
+ attr_reader :style
8
+
9
+ def initialize(style: Style::Definition.new)
10
+ @style = style
11
+ end
12
+
13
+ def height(_width, _context = nil)
14
+ raise NotImplementedError, "#{self.class}#height not implemented"
15
+ end
16
+
17
+ def emit(_x, _y, _width, _context = nil)
18
+ raise NotImplementedError, "#{self.class}#emit not implemented"
19
+ end
20
+
21
+ def splittable?
22
+ false
23
+ end
24
+
25
+ def keep_together?
26
+ @style.keep_together
27
+ end
28
+
29
+ def keep_with_next?
30
+ @style.keep_with_next
31
+ end
32
+
33
+ def page_break_before?
34
+ @style.page_break_before
35
+ end
36
+
37
+ def page_break_after?
38
+ @style.page_break_after
39
+ end
40
+
41
+ def space_before
42
+ @style.margin_top
43
+ end
44
+
45
+ def space_after
46
+ @style.margin_bottom
47
+ end
48
+
49
+ def split(width, remaining_height, context = nil)
50
+ natural = height(width, context)
51
+ if natural <= remaining_height
52
+ [self, nil]
53
+ elsif splittable?
54
+ do_split(width, remaining_height, context)
55
+ elsif keep_together?
56
+ [nil, self]
57
+ else
58
+ [self, nil]
59
+ end
60
+ end
61
+
62
+ def do_split(_width, _remaining_height, _context = nil)
63
+ [self, nil]
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Arrolio
4
+ module Flowables
5
+ # Zero-height placeholder that signals "the footnote with this id
6
+ # appears on the current page". The engine detects these during
7
+ # layout and records the footnote on the current Output::Page so
8
+ # the renderer can draw it at the bottom of the page.
9
+ #
10
+ # Carries a reference to the full Content::Footnote (marker +
11
+ # body). The marker is also rendered inline (as a superscript
12
+ # indicator) by the flow builder when it constructs the run
13
+ # sequence; this flowable's sole job is to anchor the footnote
14
+ # to a page.
15
+ class FootnoteMarkerFlowable < Flowable
16
+ attr_reader :footnote
17
+
18
+ def initialize(footnote)
19
+ super(style: Style::Definition.new)
20
+ @footnote = footnote
21
+ end
22
+
23
+ def height(_width, _context = nil) = 0.0
24
+
25
+ def emit(_x, _y, _width, _context = nil) = [[], 0.0]
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Arrolio
4
+ module Flowables
5
+ # A section heading: optional number + title text. Lives
6
+ # separate from TextFlowable so the adapter and Engine can
7
+ # recognise headings by type (for outline / TOC building).
8
+ class HeadingFlowable < TextFlowable
9
+ attr_reader :level, :number, :title, :id
10
+
11
+ def initialize(title, level: 1, number: nil, id: nil,
12
+ style: Style::Definition.new, measurer: nil)
13
+ full_text = number ? "#{number} #{title}" : title.to_s
14
+ super([InlineRun.new(full_text, style: style)],
15
+ style: style, measurer: measurer)
16
+ @title = title
17
+ @level = level.to_i
18
+ @number = number
19
+ @id = id
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Arrolio
4
+ module Flowables
5
+ # Renders a PNG / JPEG / SVG via Pdfrb's image registry. Scales
6
+ # to +display_width+ while preserving aspect ratio; if
7
+ # +display_width+ is nil, scales to fit the available width.
8
+ #
9
+ # Image registration is deferred to the render pass: the PlacedBox
10
+ # carries the source path, and the renderer resolves it (including
11
+ # SVG-to-PNG rasterization if needed) when it walks the page tree.
12
+ class ImageFlowable < Flowable
13
+ attr_reader :src, :natural_width, :natural_height, :display_width, :alt
14
+
15
+ def initialize(src, natural_width:, natural_height:,
16
+ display_width: nil, alt: nil,
17
+ style: Style::Definition.new)
18
+ super(style: style)
19
+ @src = src.to_s
20
+ @natural_width = natural_width.to_f
21
+ @natural_height = natural_height.to_f
22
+ @display_width = display_width
23
+ @alt = alt
24
+ end
25
+
26
+ def height(width, _context = nil)
27
+ return 0.0 if @natural_width.zero?
28
+
29
+ actual_w = actual_width(width)
30
+ actual_w * (@natural_height / @natural_width)
31
+ end
32
+
33
+ def emit(x, y, width, _context = nil)
34
+ return [[], 0.0] if @natural_width.zero?
35
+
36
+ actual_w = actual_width(width)
37
+ actual_h = actual_w * (@natural_height / @natural_width)
38
+ box = Output::PlacedBox.image(
39
+ x: x, y: y - actual_h,
40
+ width: actual_w, height: actual_h,
41
+ image_ref: @src
42
+ )
43
+ [[box], actual_h]
44
+ end
45
+
46
+ private
47
+
48
+ def actual_width(width)
49
+ @display_width && @display_width < width ? @display_width : width
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Arrolio
4
+ module Flowables
5
+ # Hanging-indent list. Each item has a marker column (fixed
6
+ # width) and a body column (wraps under body, not marker).
7
+ class ListFlowable < Flowable
8
+ attr_reader :items, :kind, :marker_width, :body_indent
9
+
10
+ def initialize(items, kind: :bullet, style: Style::Definition.new,
11
+ marker_width: 18.0, body_indent: 6.0)
12
+ super(style: style)
13
+ @kind = kind.to_sym
14
+ @marker_width = marker_width.to_f
15
+ @body_indent = body_indent.to_f
16
+ @items = items
17
+ freeze
18
+ end
19
+
20
+ def height(width, context = nil)
21
+ @items.sum do |item|
22
+ _, body_flowables = item
23
+ body_width = width - @marker_width - @body_indent
24
+ body_flowables.sum { |f| f.height(body_width, context) }
25
+ end
26
+ end
27
+
28
+ def emit(x, y, width, context = nil)
29
+ boxes = []
30
+ consumed = 0.0
31
+ cursor = y
32
+ body_x = x + @marker_width + @body_indent
33
+ body_width = width - @marker_width - @body_indent
34
+
35
+ @items.each_with_index do |item, idx|
36
+ marker_text, body_flowables = item
37
+ marker = marker_text || default_marker(idx)
38
+
39
+ if marker.is_a?(String)
40
+ if marker.empty?
41
+ # no marker to draw
42
+ else
43
+ mboxes, = marker_flowable(marker, x, cursor, width, context).emit(x, cursor, width, context)
44
+ boxes.concat(mboxes)
45
+ end
46
+ elsif marker
47
+ # marker is a Flowable (e.g. NoteFlowable passes a TextFlowable)
48
+ mboxes, = marker.emit(x, cursor, width, context)
49
+ boxes.concat(mboxes)
50
+ end
51
+
52
+ body_flowables.each do |f|
53
+ fh = f.height(body_width, context)
54
+ fboxes, = f.emit(body_x, cursor, body_width, context)
55
+ boxes.concat(fboxes)
56
+ cursor -= fh
57
+ consumed += fh
58
+ end
59
+ end
60
+
61
+ [boxes, consumed]
62
+ end
63
+
64
+ def splittable?
65
+ true
66
+ end
67
+
68
+ def do_split(width, remaining_height, context = nil)
69
+ head_items = []
70
+ tail_items = []
71
+ used = 0.0
72
+
73
+ @items.each do |item|
74
+ _, body_flowables = item
75
+ item_h = body_flowables.sum { |f| f.height(width - @marker_width - @body_indent, context) }
76
+ if used + item_h <= remaining_height || head_items.empty?
77
+ head_items << item
78
+ used += item_h
79
+ else
80
+ tail_items << item
81
+ end
82
+ end
83
+
84
+ head = if head_items.empty?
85
+ nil
86
+ else
87
+ self.class.new(head_items, kind: @kind, style: @style,
88
+ marker_width: @marker_width,
89
+ body_indent: @body_indent)
90
+ end
91
+ tail = if tail_items.empty?
92
+ nil
93
+ else
94
+ self.class.new(tail_items, kind: @kind, style: @style,
95
+ marker_width: @marker_width,
96
+ body_indent: @body_indent)
97
+ end
98
+ [head, tail]
99
+ end
100
+
101
+ private
102
+
103
+ def default_marker(idx)
104
+ case @kind
105
+ when :ordered then "#{idx + 1}."
106
+ when :bullet then "\u25A0"
107
+ else "\u2022"
108
+ end
109
+ end
110
+
111
+ def marker_flowable(text, _x, _y, _w, _ctx)
112
+ marker_style = @style.with(font_size: @style.font_size * 0.6)
113
+ TextFlowable.new([InlineRun.new(text, style: marker_style)], style: marker_style)
114
+ end
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Arrolio
4
+ module Flowables
5
+ # Note / example callout: italic label in marker column, body
6
+ # indented. Reuses ListFlowable's hanging-indent infrastructure.
7
+ class NoteFlowable < ListFlowable
8
+ def initialize(label_text, body_flowables, style: Style::Definition.new,
9
+ marker_width: 22.0, body_indent: 6.0)
10
+ label_style = style.with(font_size: style.font_size)
11
+ marker_run = InlineRun.new("#{label_text} ", style: label_style)
12
+ marker_flow = TextFlowable.new([marker_run], style: label_style)
13
+ super([[marker_flow, body_flowables]],
14
+ kind: :note, style: style,
15
+ marker_width: marker_width, body_indent: body_indent)
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Arrolio
4
+ module Flowables
5
+ class PageBreak < Flowable
6
+ def height(_width, _context = nil) = 0.0
7
+
8
+ def emit(_x, _y, _width, _context = nil) = [[], 0.0]
9
+
10
+ def page_break_after?
11
+ true
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Arrolio
4
+ module Flowables
5
+ # Forces a page break AND switches the active page sequence:
6
+ # role + header/footer templates apply to subsequent pages
7
+ # until the next PageSequenceStart.
8
+ class PageSequenceStart < Flowable
9
+ attr_reader :role, :header_template, :footer_template,
10
+ :header_align, :footer_align, :initial_page_number
11
+
12
+ def initialize(role:, header_template: nil, footer_template: nil,
13
+ header_align: :right, footer_align: :center,
14
+ initial_page_number: nil)
15
+ @role = role.to_sym
16
+ @header_template = header_template
17
+ @footer_template = footer_template
18
+ @header_align = header_align.to_sym
19
+ @footer_align = footer_align.to_sym
20
+ @initial_page_number = initial_page_number
21
+ end
22
+
23
+ def height(_width, _context = nil) = 0.0
24
+
25
+ def emit(_x, _y, _width, _context = nil) = [[], 0.0]
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Arrolio
4
+ module Flowables
5
+ class Spacer < Flowable
6
+ attr_reader :amount
7
+
8
+ def initialize(amount, style: Style::Definition.new)
9
+ super(style: style)
10
+ @amount = amount.to_f
11
+ end
12
+
13
+ def height(_width, _context = nil)
14
+ @amount
15
+ end
16
+
17
+ def emit(_x, _y, _width, _context = nil)
18
+ [[], @amount]
19
+ end
20
+
21
+ def splittable?
22
+ true
23
+ end
24
+
25
+ def do_split(_width, remaining_height, _context = nil)
26
+ if @amount <= remaining_height
27
+ [self, nil]
28
+ else
29
+ [self.class.new(remaining_height), self.class.new(@amount - remaining_height)]
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,147 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Arrolio
4
+ module Flowables
5
+ # Real table layout: equal-width columns by default (TODO 04
6
+ # adds auto/fixed), borders per cell, header row repeat.
7
+ class TableFlowable < Flowable
8
+ attr_reader :table, :column_widths
9
+
10
+ def initialize(table, style: Style::Definition.new)
11
+ super(style: style)
12
+ @table = table
13
+ end
14
+
15
+ def height(width, _context = nil)
16
+ cols = column_widths_for(width)
17
+ total = 0.0
18
+ rows.each do |row|
19
+ total += row_height(row, cols)
20
+ end
21
+ total
22
+ end
23
+
24
+ def emit(x, y, width, context = nil)
25
+ cols = column_widths_for(width)
26
+ boxes = []
27
+ consumed = 0.0
28
+ cursor = y
29
+ header_rows, body_rows = partition_rows
30
+
31
+ (header_rows + body_rows).each do |row|
32
+ rh = row_height(row, cols)
33
+ cursor -= rh
34
+ consumed += rh
35
+ cell_x = x.to_f
36
+ row.cells.each_with_index do |cell, ci|
37
+ cw = cols[ci] || (width / [row.cells.length, 1].max)
38
+ render_cell(cell, cell_x, cursor, cw, rh, boxes, context)
39
+ cell_x += cw
40
+ end
41
+ end
42
+
43
+ [boxes, consumed]
44
+ end
45
+
46
+ def splittable?
47
+ true
48
+ end
49
+
50
+ def do_split(width, remaining_height, _context = nil)
51
+ cols = column_widths_for(width)
52
+ head_rows = []
53
+ tail_rows = []
54
+ used = 0.0
55
+ header, body = partition_rows
56
+
57
+ body.each do |row|
58
+ rh = row_height(row, cols)
59
+ if used + rh <= remaining_height || head_rows.empty?
60
+ head_rows << row
61
+ used += rh
62
+ else
63
+ tail_rows << row
64
+ end
65
+ end
66
+
67
+ if head_rows.any?
68
+ head_table = rebuild_table(header, head_rows)
69
+ head = self.class.new(head_table, style: @style)
70
+ else
71
+ head = nil
72
+ end
73
+
74
+ tail_table = rebuild_table(header, tail_rows)
75
+ tail = self.class.new(tail_table, style: @style)
76
+ [head, tail]
77
+ end
78
+
79
+ private
80
+
81
+ def rows
82
+ @table.rows
83
+ end
84
+
85
+ def partition_rows
86
+ [@table.header, @table.body]
87
+ end
88
+
89
+ def column_widths_for(width)
90
+ return Array.new(@table.column_count, width.to_f / [@table.column_count, 1].max) if @table.column_count.zero?
91
+
92
+ @cached_widths ||= Table::AutoLayout.new(@table, available_width: width).compute
93
+ end
94
+
95
+ def row_height(row, cols)
96
+ max_h = 0.0
97
+ row.cells.each_with_index do |cell, ci|
98
+ cw = cols[ci] || 50.0
99
+ content_h = cell.content.sum do |node|
100
+ paragraph_height(node, cw)
101
+ end
102
+ max_h = [max_h, content_h].max
103
+ end
104
+ max_h + 4.0
105
+ end
106
+
107
+ def paragraph_height(paragraph, width)
108
+ return 0.0 unless paragraph.is_a?(Content::Paragraph)
109
+
110
+ style = cell_style(paragraph)
111
+ runs = paragraph.inline_runs.map { |r| InlineRun.new(r.text, style: style) }
112
+ TextFlowable.new(runs, style: style).height(width)
113
+ end
114
+
115
+ def render_cell(cell, x, y, w, h, boxes, _context)
116
+ style = cell_style(cell)
117
+ runs = []
118
+ cell.content.each do |node|
119
+ if node.is_a?(Content::Paragraph)
120
+ runs.concat(node.inline_runs.map { |r| InlineRun.new(r.text, style: style) })
121
+ end
122
+ end
123
+ if runs.any?
124
+ tf = TextFlowable.new(runs, style: style)
125
+ tboxes, = tf.emit(x + 2, y + h - 2, w - 4, nil)
126
+ boxes.concat(tboxes)
127
+ end
128
+ # Cell border: rectangle around the full cell.
129
+ boxes << Output::PlacedBox.rect(x: x, y: y, width: w, height: h,
130
+ stroke_width: 0.5, stroke_color: 'black')
131
+ end
132
+
133
+ def rebuild_table(header, body)
134
+ Content::Table.new(header: header.map { |r| r.cells.map(&:text) },
135
+ body: body.map { |r| r.cells.map(&:text) })
136
+ end
137
+
138
+ # Use the parent table's style (which inherits from body →
139
+ # Times New Roman) rather than hard-coding Times-Roman.
140
+ # Falls back to Times-Roman only if the table style has no
141
+ # font_name (shouldn't happen in practice).
142
+ def cell_style(_cell_or_para)
143
+ @style.font_name ? @style : Style::Definition.new(font_name: 'Times-Roman', font_size: 10)
144
+ end
145
+ end
146
+ end
147
+ end