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,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Arrolio
4
+ module Flowables
5
+ # A paragraph of flowing text. Selects a line-breaking strategy
6
+ # based on the style's +line_break+ attribute (:greedy or
7
+ # :knuth_plass). Both strategies produce TextLayout::Line[]
8
+ # with the same shape, so the rest of the pipeline is agnostic.
9
+ class TextFlowable < Flowable
10
+ attr_reader :runs, :measurer
11
+
12
+ def initialize(runs, style: Style::Definition.new, measurer: nil)
13
+ super(style: style)
14
+ @runs = Array(runs)
15
+ @measurer = measurer || GlyphMeasurer.new(font_name: style.font_name)
16
+ end
17
+
18
+ def height(width, _context = nil)
19
+ laid_out(width).length * line_height
20
+ end
21
+
22
+ def emit(x, y, width, _context = nil)
23
+ lines = laid_out(width)
24
+ return [[], 0.0] if lines.empty?
25
+
26
+ box = Output::PlacedBox.text(
27
+ x: x, y: y - (line_height * lines.length),
28
+ width: width, height: line_height * lines.length,
29
+ lines: lines, line_height: line_height, style: @style
30
+ )
31
+ [[box], box.height]
32
+ end
33
+
34
+ def splittable?
35
+ true
36
+ end
37
+
38
+ def do_split(width, remaining_height, _context = nil)
39
+ lines = laid_out(width)
40
+ line_h = line_height
41
+ head_count = (remaining_height.to_f / line_h).floor
42
+ head_count = [head_count, lines.length].min
43
+ head_count = 1 if head_count < 1 && lines.any?
44
+
45
+ head_lines = lines[0...head_count]
46
+ tail_lines = lines[head_count..] || []
47
+
48
+ head_runs = collect_runs(head_lines)
49
+ tail_runs = collect_runs(tail_lines)
50
+
51
+ head = head_runs.empty? ? nil : self.class.new(head_runs, style: @style, measurer: @measurer)
52
+ tail = tail_runs.empty? ? nil : self.class.new(tail_runs, style: @style, measurer: @measurer)
53
+ [head, tail]
54
+ end
55
+
56
+ private
57
+
58
+ def laid_out(width)
59
+ case @style.line_break
60
+ when :knuth_plass
61
+ layout_knuth_plass(width)
62
+ else
63
+ layout_greedy(width)
64
+ end
65
+ end
66
+
67
+ def layout_greedy(width)
68
+ TextLayout::Greedy.new(@runs, measurer: @measurer,
69
+ width: width,
70
+ align: @style.align).layout
71
+ end
72
+
73
+ def layout_knuth_plass(width)
74
+ items = TextLayout::KnuthPlass::ItemBuilder.new(@runs, measurer: @measurer).build
75
+ TextLayout::KnuthPlass::Breaker.new(
76
+ items: items, line_widths: [width], runs: @runs,
77
+ measurer: @measurer, align: @style.align
78
+ ).layout
79
+ end
80
+
81
+ def line_height
82
+ @measurer.line_height(font_size: @style.font_size,
83
+ line_spacing: @style.line_spacing)
84
+ end
85
+
86
+ def collect_runs(lines)
87
+ runs = []
88
+ lines.each do |line|
89
+ line.placed_runs.each { |pr| runs << pr.run }
90
+ end
91
+ runs
92
+ end
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Arrolio
4
+ module Flowables
5
+ # One ToC line: title + dot-leader + page number. One line tall.
6
+ class TocLineFlowable < Flowable
7
+ attr_reader :title, :page_number, :level
8
+
9
+ def initialize(title, page_number, level: 1, style: Style::Definition.new)
10
+ super(style: style)
11
+ @title = title.to_s
12
+ @page_number = page_number.to_i
13
+ @level = level.to_i
14
+ end
15
+
16
+ def height(_width, _context = nil)
17
+ GlyphMeasurer.new(font_name: @style.font_name)
18
+ .line_height(font_size: @style.font_size,
19
+ line_spacing: @style.line_spacing)
20
+ end
21
+
22
+ def emit(x, y, width, context = nil)
23
+ page_str = @page_number.to_s
24
+ measurer = GlyphMeasurer.new(font_name: @style.font_name)
25
+ title_w = measurer.width_of_string(@title, font_size: @style.font_size)
26
+ page_w = measurer.width_of_string(page_str, font_size: @style.font_size)
27
+ gap = width - title_w - page_w - 10
28
+ dots = gap.positive? ? ('.' * (gap / measurer.space_width(font_size: @style.font_size)).floor) : ' '
29
+ text = "#{@title} #{dots} #{page_str}"
30
+ tf = TextFlowable.new([InlineRun.new(text, style: @style)], style: @style)
31
+ tf.emit(x, y, width, context)
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Arrolio
4
+ module Flowables
5
+ # Two-column layout for a cover-page header. Renders left and right
6
+ # content blocks side-by-side without table borders, matching the
7
+ # reference PDF's two-column fo:table layout. Used by flow builders
8
+ # to place flavor-specific cover text in two columns.
9
+ class TwoColumnBlock < Flowable
10
+ attr_reader :left_flowables, :right_flowables, :left_ratio, :style
11
+
12
+ # +left_flowables:+ Array of TextFlowable for the left column.
13
+ # +right_flowables:+ Array of TextFlowable for the right column.
14
+ # +left_ratio:+ Fraction of width for the left column (default 0.5).
15
+ def initialize(left_flowables:, right_flowables:, left_ratio: 0.5,
16
+ style: Style::Definition.new)
17
+ super(style: style)
18
+ @left_flowables = Array(left_flowables)
19
+ @right_flowables = Array(right_flowables)
20
+ @left_ratio = left_ratio.to_f
21
+ end
22
+
23
+ def height(width, context = nil)
24
+ left_w = width * @left_ratio
25
+ right_w = width * (1.0 - @left_ratio)
26
+ left_h = @left_flowables.sum { |f| f.height(left_w, context) }
27
+ right_h = @right_flowables.sum { |f| f.height(right_w, context) }
28
+ [left_h, right_h].max
29
+ end
30
+
31
+ def emit(x, y, width, context = nil)
32
+ left_w = width * @left_ratio
33
+ right_w = width * (1.0 - @left_ratio)
34
+ right_x = x + left_w
35
+ boxes = []
36
+
37
+ left_cursor = y
38
+ @left_flowables.each do |f|
39
+ fboxes, consumed = f.emit(x, left_cursor, left_w, context)
40
+ boxes.concat(fboxes)
41
+ left_cursor -= consumed
42
+ end
43
+
44
+ right_cursor = y
45
+ @right_flowables.each do |f|
46
+ fboxes, consumed = f.emit(right_x, right_cursor, right_w, context)
47
+ boxes.concat(fboxes)
48
+ right_cursor -= consumed
49
+ end
50
+
51
+ consumed = [y - left_cursor, y - right_cursor].max
52
+ [boxes, consumed]
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Arrolio::Flowables — concrete Flowable subclasses.
4
+ module Arrolio
5
+ module Flowables
6
+ autoload :TextFlowable, 'arrolio/flowables/text_flowable'
7
+ autoload :Spacer, 'arrolio/flowables/spacer'
8
+ autoload :PageBreak, 'arrolio/flowables/page_break'
9
+ autoload :HeadingFlowable, 'arrolio/flowables/heading_flowable'
10
+ autoload :PageSequenceStart, 'arrolio/flowables/page_sequence_start'
11
+ autoload :ListFlowable, 'arrolio/flowables/list_flowable'
12
+ autoload :NoteFlowable, 'arrolio/flowables/note_flowable'
13
+ autoload :ImageFlowable, 'arrolio/flowables/image_flowable'
14
+ autoload :TocLineFlowable, 'arrolio/flowables/toc_line_flowable'
15
+ autoload :TwoColumnBlock, 'arrolio/flowables/two_column_block'
16
+ autoload :TableFlowable, 'arrolio/flowables/table_flowable'
17
+ autoload :FootnoteMarkerFlowable, 'arrolio/flowables/footnote_marker_flowable'
18
+ end
19
+ end
@@ -0,0 +1,197 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fontisan'
4
+
5
+ module Arrolio
6
+ module Font
7
+ class Embedder
8
+ attr_reader :document, :font_path, :base_font_name
9
+
10
+ def initialize(document, font_path, base_font_name: nil)
11
+ @document = document
12
+ @font_path = font_path
13
+ @base_font_name = base_font_name || File.basename(font_path, '.*')
14
+ @fontisan_font = Fontisan::FontLoader.load(font_path)
15
+ @unicode_to_gid = @fontisan_font.table('cmap').unicode_mappings.transform_keys(&:to_i)
16
+ @subset_cp_to_gid = nil
17
+ @subset_gid_to_cp = nil
18
+ end
19
+
20
+ def glyph_id_for_codepoint(codepoint)
21
+ @unicode_to_gid[codepoint.to_i] || 0
22
+ end
23
+
24
+ def subset_gid_for_codepoint(codepoint)
25
+ return glyph_id_for_codepoint(codepoint) unless @subset_cp_to_gid
26
+ @subset_cp_to_gid[codepoint.to_i] || 0
27
+ end
28
+
29
+ def subset(codepoints)
30
+ gids = codepoints.map { |cp| glyph_id_for_codepoint(cp) }.uniq.sort
31
+ gids << 0
32
+ options = Fontisan::Subset::Options.new(profile: 'pdf')
33
+ builder = Fontisan::Subset::Builder.new(@fontisan_font, gids, options)
34
+ { bytes: builder.build, gid_map: builder.mapping }
35
+ end
36
+
37
+ def embed(codepoints)
38
+ sub = subset(codepoints)
39
+ bytes = sub[:bytes]
40
+ extract_subset_cmap(bytes)
41
+ stream = @document.add(
42
+ { Length: bytes.bytesize, Length1: bytes.bytesize },
43
+ type: Pdfrb::Model::Cos::Stream
44
+ )
45
+ stream.stream = bytes
46
+ font_file2 = Pdfrb::Model::Reference.new(stream.oid, stream.gen)
47
+ descriptor = @document.add(
48
+ { Type: :FontDescriptor, FontName: @base_font_name.to_sym,
49
+ Flags: 32, FontBBox: font_bbox, ItalicAngle: 0,
50
+ Ascent: ascent, Descent: descent, CapHeight: cap_height,
51
+ StemV: 80, FontFile2: font_file2 },
52
+ type: Pdfrb::Model::Type::FontDescriptor
53
+ )
54
+ cid_font = @document.add(
55
+ { Type: :Font, Subtype: :CIDFontType2,
56
+ BaseFont: @base_font_name.to_sym,
57
+ CIDSystemInfo: { Registry: 'Adobe', Ordering: 'Identity', Supplement: 0 },
58
+ CIDToGIDMap: :Identity,
59
+ FontDescriptor: Pdfrb::Model::Reference.new(descriptor.oid, descriptor.gen),
60
+ W: widths_array },
61
+ type: Pdfrb::Model::Type::CIDFont
62
+ )
63
+ tounicode_data = build_tounicode_stream
64
+ tounicode = @document.add(
65
+ { Length: tounicode_data.bytesize },
66
+ type: Pdfrb::Model::Cos::Stream
67
+ )
68
+ tounicode.stream = tounicode_data
69
+ type0 = @document.add(
70
+ { Type: :Font, Subtype: :Type0,
71
+ BaseFont: @base_font_name.to_sym,
72
+ Encoding: :'Identity-H',
73
+ DescendantFonts: [Pdfrb::Model::Reference.new(cid_font.oid, cid_font.gen)],
74
+ ToUnicode: Pdfrb::Model::Reference.new(tounicode.oid, tounicode.gen) },
75
+ type: Pdfrb::Model::Type::FontType0
76
+ )
77
+ Pdfrb::Model::Reference.new(type0.oid, type0.gen)
78
+ end
79
+
80
+ private
81
+
82
+ def extract_subset_cmap(bytes)
83
+ require 'tmpdir'
84
+ Dir.mktmpdir do |dir|
85
+ path = File.join(dir, 'subset.ttf')
86
+ File.binwrite(path, bytes)
87
+ sf = Fontisan::FontLoader.load(path)
88
+ cm = sf.table('cmap').unicode_mappings
89
+ @subset_cp_to_gid = cm.transform_keys(&:to_i)
90
+ @subset_gid_to_cp = @subset_cp_to_gid.invert
91
+ end
92
+ rescue StandardError => e
93
+ Arrolio::Logger.warn "subset cmap extract failed: #{e.message[0,80]}"
94
+ @subset_cp_to_gid = @unicode_to_gid
95
+ @subset_gid_to_cp = @unicode_to_gid.invert
96
+ end
97
+
98
+ def build_tounicode_stream
99
+ gid_to_cp = @subset_gid_to_cp || @unicode_to_gid.invert
100
+ lines = []
101
+ lines << '/CIDInit /ProcSet findresource begin'
102
+ lines << '12 dict begin'
103
+ lines << 'begincmap'
104
+ lines << '/CIDSystemInfo << /Registry (Adobe) /Ordering (UCS) /Supplement 0 >> def'
105
+ lines << '/CMapName /Adobe-Identity-UCS def'
106
+ lines << '/CMapType 2 def'
107
+ lines << '1 begincodespacerange'
108
+ lines << '<0000> <FFFF>'
109
+ lines << 'endcodespacerange'
110
+ entries = gid_to_cp.map { |gid, cp| "<#{format('%04X', gid)}> <#{format('%04X', cp)}>" }
111
+ entries.each_slice(100) do |slice|
112
+ lines << "#{slice.length} beginbfchar"
113
+ slice.each { |e| lines << e }
114
+ lines << 'endbfchar'
115
+ end
116
+ lines << 'endcmap'
117
+ lines << 'CMapName currentdict /CMap defineresource pop'
118
+ lines << 'end'
119
+ lines << 'end'
120
+ lines.join("\n") + "\n"
121
+ end
122
+
123
+ def widths_array
124
+ return [] unless @subset_gid_to_cp
125
+ upem = head_units_per_em
126
+ hmtx = @fontisan_font.table('hmtx')
127
+ return [] unless hmtx
128
+ groups = {}
129
+ @subset_gid_to_cp.each do |subset_gid, cp|
130
+ orig_gid = @unicode_to_gid[cp]
131
+ next unless orig_gid
132
+ metric = hmtx.metric_for(orig_gid)
133
+ w = (metric && metric[:advance_width]) || 0
134
+ groups[subset_gid] = (w * 1000 / upem).to_i
135
+ end
136
+ result = []
137
+ sorted = groups.keys.sort
138
+ cur_s = nil
139
+ cur_w = []
140
+ sorted.each do |gid|
141
+ if cur_s.nil?
142
+ cur_s = gid
143
+ cur_w = [groups[gid]]
144
+ elsif gid == cur_s + cur_w.length
145
+ cur_w << groups[gid]
146
+ else
147
+ result << cur_s << cur_w
148
+ cur_s = gid
149
+ cur_w = [groups[gid]]
150
+ end
151
+ end
152
+ result << cur_s << cur_w if cur_s
153
+ result
154
+ end
155
+
156
+ def head_units_per_em
157
+ head = @fontisan_font.table('head')
158
+ head ? head.units_per_em : 1000
159
+ rescue StandardError
160
+ 1000
161
+ end
162
+
163
+ def font_bbox
164
+ head = @fontisan_font.table('head')
165
+ return [0, 0, 1000, 1000] unless head
166
+ s = 1000.0 / head.units_per_em
167
+ [(head.x_min * s).to_i, (head.y_min * s).to_i, (head.x_max * s).to_i, (head.y_max * s).to_i]
168
+ rescue StandardError
169
+ [0, 0, 1000, 1000]
170
+ end
171
+
172
+ def ascent
173
+ hhea = @fontisan_font.table('hhea')
174
+ return 800 unless hhea
175
+ (hhea.ascender * 1000 / head_units_per_em).to_i
176
+ rescue StandardError
177
+ 800
178
+ end
179
+
180
+ def descent
181
+ hhea = @fontisan_font.table('hhea')
182
+ return -200 unless hhea
183
+ (hhea.descender * 1000 / head_units_per_em).to_i
184
+ rescue StandardError
185
+ -200
186
+ end
187
+
188
+ def cap_height
189
+ os2 = @fontisan_font.table('OS/2')
190
+ return 700 unless os2
191
+ (os2.cap_height * 1000 / head_units_per_em).to_i
192
+ rescue StandardError
193
+ 700
194
+ end
195
+ end
196
+ end
197
+ end
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Arrolio
4
+ module Font
5
+ # Declarative font configuration. Maps family names to variant
6
+ # file paths (regular, bold, italic, bold_italic) and provides
7
+ # fallback chain resolution. Replaces the flat +font_paths+
8
+ # hash on LayoutSpec with a richer structure.
9
+ #
10
+ # Manifest format (YAML):
11
+ # families:
12
+ # "Body Font":
13
+ # regular: /path/to/times.ttf
14
+ # bold: /path/to/timesbd.ttf
15
+ # italic: /path/to/timesi.ttf
16
+ # bold_italic: /path/to/timesbi.ttf
17
+ # fallback: ["Fallback1", "Fallback2"]
18
+ class Manifest
19
+ attr_reader :families, :fallback_chain
20
+
21
+ # +families:+ Hash mapping family name (String) to a Hash of
22
+ # variant → file path. Variants: :regular, :bold, :italic,
23
+ # :bold_italic.
24
+ # +fallback_chain:+ Array of family names tried in order when
25
+ # the primary family doesn't have a needed variant.
26
+ def initialize(families:, fallback_chain: [])
27
+ @families = normalize_families(families)
28
+ @fallback_chain = Array(fallback_chain).map(&:to_s)
29
+ freeze
30
+ end
31
+
32
+ # Resolves a font name + weight + style to a file path.
33
+ # Tries the primary family first, then the fallback chain.
34
+ # Returns nil if no path is found.
35
+ def resolve(font_name, weight: :regular, style: :regular)
36
+ variant = variant_key(weight, style)
37
+ path = lookup(@families[font_name.to_s], variant)
38
+ return path if path
39
+
40
+ @fallback_chain.each do |family|
41
+ path = lookup(@families[family], variant)
42
+ return path if path
43
+ end
44
+ nil
45
+ end
46
+
47
+ # Returns all (family, variant, path) triples as a flat hash
48
+ # suitable for the existing +font_paths+ API.
49
+ def to_flat_paths
50
+ result = {}
51
+ @families.each do |family, variants|
52
+ variants.each do |variant, path|
53
+ key = variant_name(family, variant)
54
+ result[key] = path if path && File.exist?(path)
55
+ end
56
+ end
57
+ result
58
+ end
59
+
60
+ # Factory: builds a Manifest from a YAML/Hash representation.
61
+ def self.from_hash(hash)
62
+ new(
63
+ families: hash.fetch('families', hash.fetch(:families, {})),
64
+ fallback_chain: hash.fetch('fallback', hash.fetch(:fallback_chain, []))
65
+ )
66
+ end
67
+
68
+ def ==(other)
69
+ other.is_a?(self.class) &&
70
+ families == other.families &&
71
+ fallback_chain == other.fallback_chain
72
+ end
73
+ alias eql? ==
74
+
75
+ def hash
76
+ [self.class, families, fallback_chain].hash
77
+ end
78
+
79
+ private
80
+
81
+ def normalize_families(raw)
82
+ raw.to_h.transform_keys(&:to_s).transform_values do |variants|
83
+ variants.to_h.transform_keys(&:to_sym)
84
+ end
85
+ end
86
+
87
+ def variant_key(weight, style)
88
+ is_bold = weight.to_s.downcase == 'bold'
89
+ is_italic = style.to_s.downcase == 'italic'
90
+ if is_bold && is_italic then :bold_italic
91
+ elsif is_bold then :bold
92
+ elsif is_italic then :italic
93
+ else :regular
94
+ end
95
+ end
96
+
97
+ def lookup(family_variants, variant)
98
+ return nil unless family_variants
99
+
100
+ family_variants[variant] || family_variants[:regular]
101
+ end
102
+
103
+ def variant_name(family, variant)
104
+ case variant
105
+ when :regular then family
106
+ when :bold then "#{family} Bold"
107
+ when :italic then "#{family} Italic"
108
+ when :bold_italic then "#{family} BoldItalic"
109
+ else family
110
+ end
111
+ end
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,161 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'yaml'
4
+
5
+ module Arrolio
6
+ module Font
7
+ # Resolves font family names to absolute TTF paths using a
8
+ # configurable chain of resolvers. Replaces the absolute-path
9
+ # entries in `layout_spec.yml` with portable family-name
10
+ # declarations.
11
+ #
12
+ # Resolution order (first hit wins):
13
+ # 1. FontManifest (explicit mapping in the flavor)
14
+ # 2. font_paths from layout_spec (legacy explicit paths)
15
+ # 3. fontist manifest (if the +fontist+ gem is available at
16
+ # runtime; otherwise this step is skipped silently)
17
+ # 4. PDF standard 14 fallback (e.g. "Helvetica" → built-in)
18
+ #
19
+ # Strict mode (strict: true) raises RenderError listing every
20
+ # unresolved required font; lenient mode silently produces nil
21
+ # entries and the renderer falls back to a default.
22
+ class Resolver
23
+ STANDARD_14 = [
24
+ 'Helvetica', 'Helvetica-Bold', 'Helvetica-Oblique', 'Helvetica-BoldOblique',
25
+ 'Times-Roman', 'Times-Bold', 'Times-Italic', 'Times-BoldItalic',
26
+ 'Courier', 'Courier-Bold', 'Courier-Oblique', 'Courier-BoldOblique',
27
+ 'Symbol', 'ZapfDingbats'
28
+ ].freeze
29
+
30
+ attr_reader :manifest, :font_paths, :use_fontist, :strict,
31
+ :resolved, :unresolved
32
+
33
+ def initialize(manifest: nil, font_paths: {}, use_fontist: true,
34
+ strict: false)
35
+ @manifest = manifest
36
+ @font_paths = font_paths.transform_keys(&:to_s)
37
+ @use_fontist = use_fontist
38
+ @strict = strict
39
+ @resolved = {}
40
+ @unresolved = []
41
+ end
42
+
43
+ # Resolve a single family name. Returns the absolute path (String)
44
+ # or nil if not found. Does NOT raise in strict mode — use
45
+ # +#resolve_all!+ for that.
46
+ def resolve(family_name)
47
+ return resolved[family_name.to_s] if resolved.key?(family_name.to_s)
48
+
49
+ path = lookup(family_name.to_s)
50
+ resolved[family_name.to_s] = path
51
+ track_unresolved(family_name.to_s, path)
52
+ path
53
+ end
54
+
55
+ # Resolve every family declared by the manifest or font_paths.
56
+ # In strict mode, raises RenderError with the full unresolved
57
+ # list. Returns a Hash { family => path } otherwise.
58
+ def resolve_all!
59
+ families_to_resolve.each { |family| resolve(family) }
60
+ return resolved unless strict && unresolved.any?
61
+
62
+ raise ::Arrolio::RenderError.new(
63
+ "strict mode: unresolved required fonts: #{unresolved.join(', ')}",
64
+ missing_fonts: unresolved.map { |name| [name, nil] }
65
+ )
66
+ end
67
+
68
+ def self.from_layout_spec(layout_spec, strict: false)
69
+ new(
70
+ manifest: layout_font_manifest(layout_spec),
71
+ font_paths: layout_spec.font_paths,
72
+ use_fontist: true,
73
+ strict: strict
74
+ )
75
+ end
76
+
77
+ def self.standard_14?(family_name)
78
+ STANDARD_14.include?(family_name.to_s)
79
+ end
80
+
81
+ private
82
+
83
+ def lookup(family)
84
+ return manifest_path(family) if @manifest&.path_for(family)
85
+ return @font_paths[family] if @font_paths.key?(family) && File.exist?(@font_paths[family].to_s)
86
+
87
+ fontist_path = fontist_lookup(family)
88
+ return fontist_path if fontist_path
89
+
90
+ STANDARD_14.include?(family) ? family : nil
91
+ end
92
+
93
+ def manifest_path(family)
94
+ path = @manifest.path_for(family)
95
+ path && File.exist?(path) ? path : nil
96
+ end
97
+
98
+ def fontist_lookup(family)
99
+ return nil unless @use_fontist
100
+ return nil unless fontist_available?
101
+
102
+ fontist_manifest = ::Fontist.manifest
103
+ fontist_manifest.install(family) unless fontist_manifest.any? { |e| e.name == family }
104
+ ::Fontist::Font.find(family).paths.first
105
+ rescue StandardError
106
+ nil
107
+ end
108
+
109
+ def fontist_available?
110
+ Object.const_defined?(:Fontist)
111
+ end
112
+
113
+ def families_to_resolve
114
+ required = @manifest ? @manifest.required_families : []
115
+ (required + @font_paths.keys).uniq
116
+ end
117
+
118
+ def track_unresolved(family, path)
119
+ return if path
120
+
121
+ is_standard = self.class.standard_14?(family)
122
+ @unresolved << family unless is_standard
123
+ end
124
+
125
+ def self.layout_font_manifest(layout_spec)
126
+ return nil unless layout_spec.is_a?(::Arrolio::LayoutSpec)
127
+
128
+ config = layout_spec.font_manifest_config
129
+ return nil unless config && config['families']
130
+
131
+ FontManifest.new(config['families'], config.fetch('fallback', []))
132
+ end
133
+ private_class_method :layout_font_manifest
134
+ end
135
+
136
+ # Internal lightweight view over a `font_manifest:` block in
137
+ # layout_spec.yml. Maps family names to variant paths and exposes
138
+ # the required-family list.
139
+ class FontManifest
140
+ attr_reader :families, :fallback
141
+
142
+ def initialize(families, fallback = [])
143
+ @families = families.transform_keys(&:to_s)
144
+ @fallback = Array(fallback)
145
+ freeze
146
+ end
147
+
148
+ def path_for(family)
149
+ entry = @families[family.to_s]
150
+ return nil unless entry
151
+
152
+ path = entry['regular'] || entry[:regular]
153
+ path&.to_s
154
+ end
155
+
156
+ def required_families
157
+ @families.keys
158
+ end
159
+ end
160
+ end
161
+ end