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,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Arrolio
4
+ module Content
5
+ class Table
6
+ attr_reader :header, :body, :column_widths, :style_id, :id
7
+
8
+ def initialize(header: [], body: [], column_widths: nil,
9
+ style_id: :table, id: nil)
10
+ @header = header.map { |row| coerce_row(row) }.freeze
11
+ @body = body.map { |row| coerce_row(row) }.freeze
12
+ @column_widths = column_widths&.map(&:to_f)&.freeze
13
+ @style_id = style_id.to_sym
14
+ @id = id
15
+ freeze
16
+ end
17
+
18
+ def rows
19
+ @header + @body
20
+ end
21
+
22
+ def column_count
23
+ rows.map { |r| r.cells.length }.max || 0
24
+ end
25
+
26
+ def ==(other)
27
+ other.is_a?(self.class) &&
28
+ header == other.header &&
29
+ body == other.body &&
30
+ column_widths == other.column_widths &&
31
+ style_id == other.style_id &&
32
+ id == other.id
33
+ end
34
+
35
+ alias eql? ==
36
+
37
+ def hash
38
+ [self.class, header, body, column_widths, style_id, id].hash
39
+ end
40
+
41
+ private
42
+
43
+ def coerce_row(row)
44
+ case row
45
+ when Row then row
46
+ when Array then Row.new(row.map { |c| c.is_a?(Cell) ? c : Cell.new(c) })
47
+ else
48
+ raise ContentError, "Table row must be an Array or Table::Row (got #{row.class})"
49
+ end
50
+ end
51
+ end
52
+
53
+ class Table::Row
54
+ include Enumerable
55
+
56
+ attr_reader :cells
57
+
58
+ def initialize(cells)
59
+ @cells = Array(cells).freeze
60
+ freeze
61
+ end
62
+
63
+ def each(&block)
64
+ @cells.each(&block)
65
+ end
66
+
67
+ def length
68
+ @cells.length
69
+ end
70
+
71
+ def ==(other)
72
+ other.is_a?(self.class) && cells == other.cells
73
+ end
74
+
75
+ alias eql? ==
76
+
77
+ def hash
78
+ [self.class, cells].hash
79
+ end
80
+ end
81
+
82
+ class Table::Cell
83
+ attr_reader :content, :colspan, :rowspan, :style_id, :align
84
+
85
+ def initialize(content = [], colspan: 1, rowspan: 1,
86
+ style_id: :table_cell, align: nil)
87
+ @content = Array(content).freeze
88
+ @colspan = colspan.to_i
89
+ @rowspan = rowspan.to_i
90
+ @style_id = style_id.to_sym
91
+ @align = align
92
+ freeze
93
+ end
94
+
95
+ def text
96
+ @content.map do |node|
97
+ node.is_a?(Paragraph) ? node.text : node.to_s
98
+ end.join(' ')
99
+ end
100
+
101
+ def ==(other)
102
+ other.is_a?(self.class) &&
103
+ content == other.content &&
104
+ colspan == other.colspan &&
105
+ rowspan == other.rowspan &&
106
+ style_id == other.style_id &&
107
+ align == other.align
108
+ end
109
+
110
+ alias eql? ==
111
+
112
+ def hash
113
+ [self.class, content, colspan, rowspan, style_id, align].hash
114
+ end
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Arrolio
4
+ module Content
5
+ # A dictionary term entry: optional number, preferred term text,
6
+ # definition Paragraphs, and optional source citation. Maps to
7
+ # the `<term>` element in standoc vocabulary. Renders as a
8
+ # hanging-indent group in the flow builder.
9
+ class TermEntry
10
+ attr_reader :number, :preferred, :definition, :source, :id, :style_id
11
+
12
+ def initialize(number: nil, preferred: nil, definition: [],
13
+ source: nil, id: nil, style_id: :term)
14
+ @number = number&.to_s
15
+ @preferred = preferred
16
+ @definition = Array(definition).freeze
17
+ @source = source
18
+ @id = id&.to_s
19
+ @style_id = style_id.to_sym
20
+ freeze
21
+ end
22
+
23
+ def empty?
24
+ @preferred.nil? && @definition.empty? && @number.nil?
25
+ end
26
+
27
+ def ==(other)
28
+ other.is_a?(self.class) &&
29
+ number == other.number &&
30
+ preferred == other.preferred &&
31
+ definition == other.definition &&
32
+ source == other.source &&
33
+ id == other.id &&
34
+ style_id == other.style_id
35
+ end
36
+ alias eql? ==
37
+
38
+ def hash
39
+ [self.class, number, preferred, definition, source, id, style_id].hash
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Arrolio
4
+ module Content
5
+ autoload :Document, 'arrolio/content/document'
6
+ autoload :Section, 'arrolio/content/section'
7
+ autoload :Paragraph, 'arrolio/content/paragraph'
8
+ autoload :InlineRun, 'arrolio/content/inline_run'
9
+ autoload :Heading, 'arrolio/content/heading'
10
+ autoload :Hyperlink, 'arrolio/content/hyperlink'
11
+ autoload :Formula, 'arrolio/content/formula'
12
+ autoload :Preformatted, 'arrolio/content/preformatted'
13
+ autoload :PageBreak, 'arrolio/content/page_break'
14
+ autoload :Footnote, 'arrolio/content/footnote'
15
+ autoload :IndexEntry, 'arrolio/content/index_entry'
16
+ autoload :FormField, 'arrolio/content/form_field'
17
+ autoload :Table, 'arrolio/content/table'
18
+ autoload :List, 'arrolio/content/list'
19
+ autoload :Image, 'arrolio/content/image'
20
+ autoload :Builder, 'arrolio/content/builder'
21
+ autoload :Note, 'arrolio/content/note'
22
+ autoload :Example, 'arrolio/content/example'
23
+ autoload :TermEntry, 'arrolio/content/term_entry'
24
+ autoload :FigureGroup, 'arrolio/content/figure_group'
25
+ autoload :BibliographyItem, 'arrolio/content/bibliography_item'
26
+ end
27
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Arrolio
4
+ module DataDir
5
+ DELIMITER = '/'
6
+
7
+ class << self
8
+ def root
9
+ File.expand_path('../../data/arrolio', __dir__)
10
+ end
11
+
12
+ def resolve(*segments)
13
+ path = File.join(root, *segments)
14
+ raise Arrolio::Error, "missing data file: #{segments.join(DELIMITER)}" unless File.exist?(path)
15
+
16
+ path
17
+ end
18
+
19
+ def afm(name)
20
+ File.join(root, 'afm', "#{name}.afm")
21
+ end
22
+
23
+ def glyphlist
24
+ File.join(root, 'glyphlist.txt')
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Arrolio
4
+ module Engine
5
+ # Collects heading → page-number mappings during layout and
6
+ # resolves cross-references for ToC, page citations, and
7
+ # internal links. The engine records entries during pass 1;
8
+ # after layout is complete, the registry provides resolved
9
+ # references for deferred rendering (e.g., ToC page that
10
+ # appears at the front of the document but needs page numbers
11
+ # from the body).
12
+ #
13
+ # This implements the "deferred rendering" pattern: the ToC
14
+ # section is rendered last (from the registry's resolved data)
15
+ # but appears first in the document. The engine's existing
16
+ # FlowContext#heading_entries feeds into this registry.
17
+ class CrossReferenceRegistry
18
+ Entry = Struct.new(:id, :number, :title, :level, :page_number,
19
+ keyword_init: true)
20
+
21
+ attr_reader :entries
22
+
23
+ def initialize
24
+ @entries = []
25
+ @by_id = {}
26
+ end
27
+
28
+ # Records a heading during layout pass 1.
29
+ def record(id:, number:, title:, level:, page_number:)
30
+ entry = Entry.new(
31
+ id: id, number: number, title: title,
32
+ level: level, page_number: page_number
33
+ )
34
+ @entries << entry
35
+ @by_id[id.to_s] = entry if id
36
+ entry
37
+ end
38
+
39
+ # Resolves a cross-reference target to a page number.
40
+ # Returns nil if the target hasn't been recorded.
41
+ def page_number_for(target_id)
42
+ @by_id[target_id.to_s]&.page_number
43
+ end
44
+
45
+ # Resolves a cross-reference target to its full entry.
46
+ def entry_for(target_id)
47
+ @by_id[target_id.to_s]
48
+ end
49
+
50
+ # All entries, suitable for ToC generation. Entries are
51
+ # in document order (the order they were recorded).
52
+ # Pass +max_level:+ to filter to a maximum heading level
53
+ # (e.g., only levels 1-2 for a compact ToC).
54
+ def toc_entries(max_level: nil)
55
+ return @entries.dup if max_level.nil?
56
+
57
+ @entries.select { |e| e.level <= max_level }
58
+ end
59
+
60
+ def empty?
61
+ @entries.empty?
62
+ end
63
+
64
+ def count
65
+ @entries.length
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,186 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Arrolio
4
+ module Engine
5
+ # Two-pass page-flow driver.
6
+ class Paged
7
+ attr_reader :layout_spec, :flowables, :pages, :context
8
+
9
+ def initialize(layout_spec:, flowables:)
10
+ @layout_spec = layout_spec
11
+ @flowables = flowables
12
+ @pages = []
13
+ end
14
+
15
+ def layout
16
+ @pages = []
17
+ @page_count_opened = 0
18
+ @footnote_page_map = {} # footnote.id / marker -> first page_number
19
+ context = FlowContext.new(layout_spec: @layout_spec, page_number: 1)
20
+ pending = @flowables.dup
21
+ page_state = open_page(context)
22
+
23
+ until pending.empty?
24
+ flowable = pending.shift
25
+
26
+ if flowable.is_a?(Flowables::PageSequenceStart)
27
+ @current_role = flowable.role
28
+ @current_header = flowable.header_template
29
+ @current_footer = flowable.footer_template
30
+ @current_header_align = flowable.header_align
31
+ @current_footer_align = flowable.footer_align
32
+ unless page_state.fresh && page_state.body_boxes.empty?
33
+ page_state = open_page(context)
34
+ end
35
+ page_state.role = @current_role
36
+ page_state.header = @current_header
37
+ page_state.footer = @current_footer
38
+ page_state.header_align = @current_header_align
39
+ page_state.footer_align = @current_footer_align
40
+ next
41
+ end
42
+
43
+ if flowable.is_a?(Flowables::PageBreak)
44
+ page_state = open_page(context)
45
+ next
46
+ end
47
+
48
+ # Footnote anchors: record on the current page without
49
+ # consuming frame space (the flowable has zero height).
50
+ if flowable.is_a?(Flowables::FootnoteMarkerFlowable)
51
+ key = flowable.footnote.id || flowable.footnote.marker
52
+ @footnote_page_map[key] ||= page_state.page_number
53
+ page_state.footnotes << flowable.footnote unless page_state.footnotes.include?(flowable.footnote)
54
+ next
55
+ end
56
+
57
+ page_state = open_page(context) if flowable.page_break_before? && @pages.any? && !page_state.fresh
58
+ page_state = open_page(context) if page_state.frame.full?
59
+
60
+ width = page_state.frame.width
61
+ if flowable.keep_together? &&
62
+ flowable.height(width, context) > page_state.frame.remaining_height &&
63
+ flowable.height(width, context) <= page_state.frame.height
64
+ page_state = open_page(context)
65
+ end
66
+
67
+ # Record heading entries for ToC + outline building.
68
+ if flowable.is_a?(Flowables::HeadingFlowable)
69
+ context.record_heading(number: flowable.number,
70
+ title: flowable.title,
71
+ level: flowable.level,
72
+ page_number: page_state.page_number,
73
+ id: flowable.id)
74
+ end
75
+
76
+ page_state.fresh = false
77
+ page_state = place(page_state, flowable, context, pending)
78
+ end
79
+
80
+ @context = context
81
+ context.page_count = @pages.length
82
+ finalize_pages
83
+ @pages
84
+ end
85
+
86
+ private
87
+
88
+ def place(page_state, flowable, context, pending)
89
+ frame = page_state.frame
90
+ width = frame.width
91
+ natural = flowable.height(width, context)
92
+
93
+ if natural <= frame.remaining_height
94
+ emit(page_state, flowable, context)
95
+ elsif flowable.splittable?
96
+ head, tail = flowable.split(width, frame.remaining_height, context)
97
+ if head
98
+ emit(page_state, head, context)
99
+ pending.unshift(tail) if tail
100
+ else
101
+ return page_state unless frame.full?
102
+
103
+ new_state = open_page(context)
104
+ place(new_state, flowable, context, pending)
105
+ end
106
+ else
107
+ emit(page_state, flowable, context)
108
+ end
109
+ page_state
110
+ end
111
+
112
+ def emit(page_state, flowable, context)
113
+ frame = page_state.frame
114
+ x = frame.x
115
+ y_top = frame.cursor_y
116
+ boxes, consumed = flowable.emit(x, y_top, frame.width, context)
117
+ page_state.body_boxes.concat(boxes)
118
+ frame.consume!(consumed)
119
+ end
120
+
121
+ def open_page(context)
122
+ template = @layout_spec.page_template
123
+ body = template.body_region
124
+ @page_count_opened = (@page_count_opened || 0) + 1
125
+ page_number = @page_count_opened
126
+
127
+ frame = Frame.new(x: body.x, y: body.y,
128
+ width: body.width, height: body.height)
129
+ context.page_number = page_number
130
+
131
+ state = OpenPage.new(
132
+ template: template,
133
+ page_number: page_number,
134
+ frame: frame,
135
+ body_boxes: [],
136
+ fresh: true,
137
+ role: @current_role || :body,
138
+ header: @current_header,
139
+ footer: @current_footer,
140
+ header_align: @current_header_align || :right,
141
+ footer_align: @current_footer_align || :center,
142
+ footnotes: []
143
+ )
144
+ @pages << state
145
+ state
146
+ end
147
+
148
+ def finalize_pages
149
+ total = @pages.length
150
+ built = @pages.map do |state|
151
+ template = state.template
152
+ body = template.body_region
153
+ region = Output::Region.new(
154
+ name: :body,
155
+ x: body.x, y: body.y, width: body.width, height: body.height,
156
+ placed_boxes: state.body_boxes
157
+ )
158
+ Output::Page.new(
159
+ number: state.page_number,
160
+ template_name: template.name,
161
+ template_role: state.role,
162
+ page_size: template.page_size,
163
+ regions: { body: region },
164
+ header_text: format_text(state.header, state.page_number, total),
165
+ footer_text: format_text(state.footer, state.page_number, total),
166
+ header_align: state.header_align,
167
+ footer_align: state.footer_align,
168
+ footnotes: state.footnotes
169
+ )
170
+ end
171
+ @pages.replace(built)
172
+ end
173
+
174
+ def format_text(template_text, current, _total)
175
+ return nil if template_text.nil?
176
+
177
+ template_text.to_s.gsub('%d', current.to_s)
178
+ end
179
+ end
180
+
181
+ OpenPage = Struct.new(:template, :page_number, :frame, :body_boxes,
182
+ :fresh, :role, :header, :footer,
183
+ :header_align, :footer_align, :footnotes,
184
+ keyword_init: true)
185
+ end
186
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Arrolio::Engine — page-flow layout drivers.
4
+ module Arrolio
5
+ module Engine
6
+ autoload :Paged, 'arrolio/engine/paged'
7
+ autoload :CrossReferenceRegistry, 'arrolio/engine/cross_reference_registry'
8
+ end
9
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Arrolio
4
+ # Base class for every Arrolio failure mode. Subclasses carry
5
+ # typed metadata that callers can inspect (e.g. +page_number+ on
6
+ # a +LayoutError+, +oid+ on a +RenderError+).
7
+ class Error < StandardError; end
8
+
9
+ # Raised by Engine::* when a layout pass fails (overflow that
10
+ # can't be resolved, broken cross-reference, missing template).
11
+ class LayoutError < Error
12
+ attr_reader :page_number, :template_name
13
+
14
+ def initialize(message, page_number: nil, template_name: nil)
15
+ @page_number = page_number
16
+ @template_name = template_name
17
+ super(message)
18
+ end
19
+ end
20
+
21
+ # Raised by Renderer::* when the render pass fails.
22
+ class RenderError < Error
23
+ attr_reader :missing_fonts
24
+
25
+ def initialize(message, missing_fonts: nil)
26
+ @missing_fonts = missing_fonts
27
+ super(message)
28
+ end
29
+ end
30
+
31
+ # Raised when the content tree violates Arrolio's content
32
+ # contract (e.g. a Section with a non-String title, or a
33
+ # Paragraph with malformed InlineRuns).
34
+ class ContentError < Error
35
+ attr_reader :node
36
+
37
+ def initialize(message, node: nil)
38
+ @node = node
39
+ super(message)
40
+ end
41
+ end
42
+
43
+ # Raised when a LayoutSpec is missing required entries or has
44
+ # invalid values (unknown page-template name, unresolvable style
45
+ # reference, etc.).
46
+ class LayoutSpecError < Error
47
+ attr_reader :spec_field
48
+
49
+ def initialize(message, spec_field: nil)
50
+ @spec_field = spec_field
51
+ super(message)
52
+ end
53
+ end
54
+
55
+ # Raised when a flavor is missing required files (manifest.yml,
56
+ # layout_spec.yml, etc.) or has malformed configuration. Carries
57
+ # the flavor directory and/or manifest path for diagnosis.
58
+ class FlavorError < Error
59
+ attr_reader :flavor_dir, :manifest_path, :missing_fields, :missing_role
60
+
61
+ def initialize(message, flavor_dir: nil, manifest_path: nil,
62
+ missing_fields: nil, missing_role: nil)
63
+ @flavor_dir = flavor_dir
64
+ @manifest_path = manifest_path
65
+ @missing_fields = missing_fields
66
+ @missing_role = missing_role
67
+ super(message)
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'yaml'
4
+ require 'arrolio/error'
5
+
6
+ module Arrolio
7
+ module Flavor
8
+ # Typed view over a flavor's manifest.yml. Declares the flavor's
9
+ # name, version, upstream XSL, required/optional fonts, and the
10
+ # paths to its three config files. Validates the manifest at load
11
+ # time so broken flavors fail loudly with `Arrolio::FlavorError`.
12
+ class Manifest
13
+ REQUIRED_FIELDS = ['name', 'version', 'config_files'].freeze
14
+
15
+ attr_reader :path, :name, :version, :description, :upstream,
16
+ :doctypes, :required_fonts, :optional_fonts,
17
+ :config_files
18
+
19
+ def self.load(flavor_dir)
20
+ manifest_path = File.join(flavor_dir, 'manifest.yml')
21
+ unless File.exist?(manifest_path)
22
+ raise ::Arrolio::FlavorError.new(
23
+ "manifest.yml not found in #{flavor_dir}",
24
+ flavor_dir: flavor_dir
25
+ )
26
+ end
27
+
28
+ data = YAML.safe_load_file(manifest_path, aliases: true)
29
+ unless data.is_a?(Hash)
30
+ raise ::Arrolio::FlavorError.new(
31
+ "manifest.yml at #{manifest_path} is not a YAML mapping",
32
+ flavor_dir: flavor_dir
33
+ )
34
+ end
35
+
36
+ validate_required_fields!(data, manifest_path)
37
+ validate_config_files!(data, manifest_path, flavor_dir)
38
+
39
+ new(manifest_path, data)
40
+ end
41
+
42
+ def initialize(path, data)
43
+ @path = path
44
+ @name = data.fetch('name')
45
+ @version = data.fetch('version')
46
+ @description = data['description']
47
+ @upstream = data['upstream'] || {}
48
+ @doctypes = data['doctypes'] || []
49
+ fonts = data['fonts'] || {}
50
+ @required_fonts = fonts['required'] || []
51
+ @optional_fonts = fonts['optional'] || []
52
+ @config_files = data.fetch('config_files')
53
+ freeze
54
+ end
55
+
56
+ def config_path_for(role, flavor_dir = File.dirname(@path))
57
+ file = config_files[role.to_s] || config_files[role.to_sym]
58
+ return nil unless file
59
+
60
+ File.join(flavor_dir, file)
61
+ end
62
+
63
+ def ==(other)
64
+ other.is_a?(self.class) &&
65
+ path == other.path &&
66
+ name == other.name &&
67
+ version == other.version &&
68
+ description == other.description &&
69
+ doctypes == other.doctypes &&
70
+ required_fonts == other.required_fonts &&
71
+ optional_fonts == other.optional_fonts &&
72
+ config_files == other.config_files
73
+ end
74
+
75
+ alias eql? ==
76
+
77
+ def hash
78
+ [self.class, path, name, version, config_files].hash
79
+ end
80
+
81
+ def self.validate_required_fields!(data, manifest_path)
82
+ missing = REQUIRED_FIELDS.reject { |field| data.key?(field) }
83
+ return if missing.empty?
84
+
85
+ raise ::Arrolio::FlavorError.new(
86
+ "manifest at #{manifest_path} is missing required fields: #{missing.inspect}",
87
+ manifest_path: manifest_path, missing_fields: missing
88
+ )
89
+ end
90
+ private_class_method :validate_required_fields!
91
+
92
+ def self.validate_config_files!(data, manifest_path, flavor_dir)
93
+ config_files = data['config_files']
94
+ return unless config_files.is_a?(Hash)
95
+
96
+ ['layout_spec', 'adapter_rules', 'flow_rules'].each do |role|
97
+ filename = config_files[role]
98
+ next unless filename
99
+ next if File.exist?(File.join(flavor_dir, filename))
100
+
101
+ raise ::Arrolio::FlavorError.new(
102
+ "manifest at #{manifest_path} references #{role}=#{filename}, " \
103
+ 'but the file does not exist',
104
+ manifest_path: manifest_path, missing_role: role
105
+ )
106
+ end
107
+ end
108
+ private_class_method :validate_config_files!
109
+ end
110
+ end
111
+ end