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,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Arrolio
4
+ module Content
5
+ # A footnote reference in the content tree. Carries the marker
6
+ # (e.g. "1", "a", "*") and the footnote body (paragraphs).
7
+ # The adapter emits these from <fn> elements; the FlowBuilder
8
+ # places the marker inline and defers the body to the page's
9
+ # footnote zone (rendered at the bottom of the page).
10
+ class Footnote
11
+ attr_reader :marker, :body, :id, :style_id
12
+
13
+ def initialize(marker:, body:, id: nil, style_id: :footnote)
14
+ @marker = marker.to_s
15
+ @body = Array(body).freeze
16
+ @id = id&.to_s
17
+ @style_id = style_id.to_sym
18
+ freeze
19
+ end
20
+
21
+ def body_text
22
+ @body.map do |node|
23
+ node.is_a?(Paragraph) ? node.text : node.to_s
24
+ end.join(' ')
25
+ end
26
+
27
+ def empty?
28
+ @marker.empty? && @body.empty?
29
+ end
30
+
31
+ def ==(other)
32
+ other.is_a?(self.class) &&
33
+ marker == other.marker &&
34
+ body == other.body &&
35
+ id == other.id &&
36
+ style_id == other.style_id
37
+ end
38
+
39
+ alias eql? ==
40
+
41
+ def hash
42
+ [self.class, marker, body, id, style_id].hash
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Arrolio
4
+ module Content
5
+ # A form field for interactive PDF AcroForm. Carries the field
6
+ # type, name, value, and geometry. The renderer emits it as a
7
+ # PDAcroForm widget (text field, checkbox, radio button) during
8
+ # post-processing. This model captures the semantic intent;
9
+ # the actual PDF widget creation is a renderer post-pass.
10
+ class FormField
11
+ TYPES = %i[text checkbox radio signature].freeze
12
+
13
+ attr_reader :type, :name, :value, :page_number,
14
+ :x, :y, :width, :height, :style_id
15
+
16
+ def initialize(type:, name:, page_number:, x:, y:, width:, height:, value: nil, style_id: :form_field)
17
+ @type = type.to_sym
18
+ @name = name.to_s
19
+ @value = value
20
+ @page_number = page_number.to_i
21
+ @x = x.to_f
22
+ @y = y.to_f
23
+ @width = width.to_f
24
+ @height = height.to_f
25
+ @style_id = style_id.to_sym
26
+ freeze
27
+ end
28
+
29
+ def text? = @type == :text
30
+ def checkbox? = @type == :checkbox
31
+ def radio? = @type == :radio
32
+
33
+ def ==(other)
34
+ other.is_a?(self.class) &&
35
+ type == other.type && name == other.name &&
36
+ value == other.value && page_number == other.page_number &&
37
+ x == other.x && y == other.y &&
38
+ width == other.width && height == other.height
39
+ end
40
+ alias eql? ==
41
+
42
+ def hash
43
+ [self.class, type, name, value, page_number, x, y, width, height].hash
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Arrolio
4
+ module Content
5
+ # A formula — MathML root + rendered text fallback. Carries the
6
+ # math structure separately from how it's rendered (text fallback
7
+ # today, proper math typesetting in the future). The renderer can
8
+ # later use the MathML for accessible representation while the
9
+ # fallback provides visual rendering.
10
+ class Formula
11
+ attr_reader :mathml, :text_fallback, :style_id
12
+
13
+ def initialize(mathml:, text_fallback:, style_id: :body)
14
+ @mathml = mathml.to_s
15
+ @text_fallback = text_fallback.to_s
16
+ @style_id = style_id.to_sym
17
+ freeze
18
+ end
19
+
20
+ def ==(other)
21
+ other.is_a?(self.class) &&
22
+ mathml == other.mathml &&
23
+ text_fallback == other.text_fallback &&
24
+ style_id == other.style_id
25
+ end
26
+
27
+ alias eql? ==
28
+
29
+ def hash
30
+ [self.class, mathml, text_fallback, style_id].hash
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Arrolio
4
+ module Content
5
+ # A semantic heading. Distinct from +Paragraph+ so the renderer
6
+ # can emit PDF structure tags (for accessibility/ToC), the engine
7
+ # can record heading → page mappings for cross-references, and the
8
+ # FlowBuilder can apply distinct rendering rules (keep_together,
9
+ # page-break-before, outline level).
10
+ class Heading
11
+ attr_reader :level, :number, :title, :id, :style_id
12
+
13
+ def initialize(level:, title:, number: nil, id: nil, style_id: :heading_1)
14
+ @level = level.to_i
15
+ @number = number&.to_s
16
+ @title = title.to_s
17
+ @id = id&.to_s
18
+ @style_id = style_id.to_sym
19
+ freeze
20
+ end
21
+
22
+ def ==(other)
23
+ other.is_a?(self.class) &&
24
+ level == other.level &&
25
+ number == other.number &&
26
+ title == other.title &&
27
+ id == other.id &&
28
+ style_id == other.style_id
29
+ end
30
+
31
+ alias eql? ==
32
+
33
+ def hash
34
+ [self.class, level, number, title, id, style_id].hash
35
+ end
36
+
37
+ def inline_header?
38
+ !number.nil? && title.empty?
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Arrolio
4
+ module Content
5
+ # Inline runs wrapped with a hyperlink target. The renderer
6
+ # emits a PDF /Annot of subtype /Link with the URI (external)
7
+ # or /Dest reference (internal cross-reference).
8
+ class Hyperlink
9
+ attr_reader :runs, :target, :id
10
+
11
+ def initialize(runs, target:, internal: false, id: nil)
12
+ @runs = Array(runs).freeze
13
+ @target = target.to_s
14
+ @internal_link = internal ? true : false
15
+ @id = id&.to_s
16
+ freeze
17
+ end
18
+
19
+ def ==(other)
20
+ other.is_a?(self.class) &&
21
+ runs == other.runs &&
22
+ target == other.target &&
23
+ internal? == other.internal? &&
24
+ id == other.id
25
+ end
26
+
27
+ alias eql? ==
28
+
29
+ def hash
30
+ [self.class, runs, target, internal?, id].hash
31
+ end
32
+
33
+ def internal?
34
+ @internal_link
35
+ end
36
+
37
+ def external?
38
+ !internal?
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Arrolio
4
+ module Content
5
+ class Image
6
+ attr_reader :src, :width, :height, :alt, :style_id, :id
7
+
8
+ def initialize(src, width: nil, height: nil, alt: nil,
9
+ style_id: :figure_image, id: nil)
10
+ @src = src.to_s
11
+ @width = width&.to_f
12
+ @height = height&.to_f
13
+ @alt = alt
14
+ @style_id = style_id.to_sym
15
+ @id = id
16
+ freeze
17
+ end
18
+
19
+ def ==(other)
20
+ other.is_a?(self.class) &&
21
+ src == other.src &&
22
+ width == other.width &&
23
+ height == other.height &&
24
+ alt == other.alt &&
25
+ style_id == other.style_id &&
26
+ id == other.id
27
+ end
28
+
29
+ alias eql? ==
30
+
31
+ def hash
32
+ [self.class, src, width, height, alt, style_id, id].hash
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Arrolio
4
+ module Content
5
+ # A single index entry — a term and the page(s) where it appears.
6
+ # Part of a back-of-book index section. The adapter extracts
7
+ # these from <index>/<primary> elements; the FlowBuilder renders
8
+ # them alphabetically with page numbers.
9
+ class IndexEntry
10
+ attr_reader :term, :page_numbers, :see_also, :style_id
11
+
12
+ def initialize(term:, page_numbers:, see_also: [], style_id: :index_entry)
13
+ @term = term.to_s
14
+ @page_numbers = Array(page_numbers).map(&:to_i).sort.uniq.freeze
15
+ @see_also = Array(see_also).map(&:to_s).freeze
16
+ @style_id = style_id.to_sym
17
+ freeze
18
+ end
19
+
20
+ def add_page(number)
21
+ return self if @page_numbers.include?(number.to_i)
22
+
23
+ self.class.new(
24
+ term: @term,
25
+ page_numbers: @page_numbers + [number.to_i],
26
+ see_also: @see_also,
27
+ style_id: @style_id
28
+ )
29
+ end
30
+
31
+ def first_letter
32
+ @term[0]&.upcase || ''
33
+ end
34
+
35
+ def page_numbers_str
36
+ @page_numbers.join(', ')
37
+ end
38
+
39
+ def ==(other)
40
+ other.is_a?(self.class) &&
41
+ term == other.term &&
42
+ page_numbers == other.page_numbers &&
43
+ see_also == other.see_also
44
+ end
45
+ alias eql? ==
46
+
47
+ def hash
48
+ [self.class, term, page_numbers, see_also].hash
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Arrolio
4
+ module Content
5
+ # A single inline run of text within a paragraph. Carries the
6
+ # +text+ to render, the +style_id+ for visual styling (bold,
7
+ # italic, etc.), optional +href+ for hyperlinks, and optional
8
+ # +baseline_shift+ / +font_size_scale+ for subscript/superscript
9
+ # positioning (used by MathML msub/msup rendering).
10
+ class InlineRun
11
+ BASELINE_NORMAL = nil
12
+ BASELINE_SUB = :sub
13
+ BASELINE_SUP = :sup
14
+
15
+ attr_reader :text, :style_id, :href, :baseline_shift, :font_size_scale
16
+
17
+ def initialize(text, style_id: :inline, href: nil,
18
+ baseline_shift: BASELINE_NORMAL, font_size_scale: 1.0)
19
+ @text = text.to_s
20
+ @style_id = style_id.to_sym
21
+ @href = href
22
+ @baseline_shift = baseline_shift
23
+ @font_size_scale = font_size_scale.to_f
24
+ freeze
25
+ end
26
+
27
+ def ==(other)
28
+ other.is_a?(self.class) &&
29
+ text == other.text &&
30
+ style_id == other.style_id &&
31
+ href == other.href &&
32
+ baseline_shift == other.baseline_shift &&
33
+ font_size_scale == other.font_size_scale
34
+ end
35
+
36
+ alias eql? ==
37
+
38
+ def hash
39
+ [self.class, text, style_id, href, baseline_shift, font_size_scale].hash
40
+ end
41
+
42
+ def subscript?
43
+ @baseline_shift == BASELINE_SUB
44
+ end
45
+
46
+ def superscript?
47
+ @baseline_shift == BASELINE_SUP
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Arrolio
4
+ module Content
5
+ class List
6
+ attr_reader :items, :kind, :style_id, :id
7
+
8
+ def initialize(items = [], kind: :bullet, style_id: :list, id: nil)
9
+ @items = items.map { |i| i.is_a?(Item) ? i : Item.new(i) }.freeze
10
+ @kind = kind.to_sym
11
+ @style_id = style_id.to_sym
12
+ @id = id
13
+ freeze
14
+ end
15
+
16
+ def ordered? = @kind == :ordered
17
+ def bullet? = @kind == :bullet
18
+
19
+ def ==(other)
20
+ other.is_a?(self.class) &&
21
+ items == other.items &&
22
+ kind == other.kind &&
23
+ style_id == other.style_id &&
24
+ id == other.id
25
+ end
26
+
27
+ alias eql? ==
28
+
29
+ def hash
30
+ [self.class, items, kind, style_id, id].hash
31
+ end
32
+
33
+ class Item
34
+ attr_reader :content, :marker
35
+
36
+ def initialize(content, marker: nil)
37
+ @content = Array(content).freeze
38
+ @marker = marker
39
+ freeze
40
+ end
41
+
42
+ def text
43
+ @content.map do |node|
44
+ node.is_a?(Paragraph) ? node.text : node.to_s
45
+ end.join(' ')
46
+ end
47
+
48
+ def ==(other)
49
+ other.is_a?(self.class) && content == other.content && marker == other.marker
50
+ end
51
+
52
+ alias eql? ==
53
+
54
+ def hash
55
+ [self.class, content, marker].hash
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Arrolio
4
+ module Content
5
+ # A semantic note group: optional label ("NOTE 1", "WARNING") plus
6
+ # one or more body Paragraphs. Distinct from a bare Paragraph
7
+ # because the flow builder renders it with a hanging indent and
8
+ # the label in a marker column (matching the XSL's fo:list-block
9
+ # rendering for notes/examples).
10
+ #
11
+ # Wraps the legacy Paragraph-with-`:note`-style emission so flavors
12
+ # see richer structure without breaking older flow builders.
13
+ class Note
14
+ attr_reader :label, :body, :id, :style_id
15
+
16
+ def initialize(body:, label: '', id: nil, style_id: :note)
17
+ @label = label.to_s
18
+ @body = Array(body).freeze
19
+ @id = id&.to_s
20
+ @style_id = style_id.to_sym
21
+ freeze
22
+ end
23
+
24
+ def body_text
25
+ @body.map do |node|
26
+ node.is_a?(Paragraph) ? node.text : node.to_s
27
+ end.join(' ')
28
+ end
29
+
30
+ def empty?
31
+ @body.empty? || @body.all? { |node| node.is_a?(Paragraph) && node.empty? }
32
+ end
33
+
34
+ def ==(other)
35
+ other.is_a?(self.class) &&
36
+ label == other.label &&
37
+ body == other.body &&
38
+ id == other.id &&
39
+ style_id == other.style_id
40
+ end
41
+ alias eql? ==
42
+
43
+ def hash
44
+ [self.class, label, body, id, style_id].hash
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Arrolio
4
+ module Content
5
+ # An explicit page break in the content tree. Emitted by the
6
+ # adapter when it encounters a <pagebreak/> element or a clause
7
+ # with a page-break-before hint. The FlowBuilder converts this
8
+ # to a Flowables::PageBreak.
9
+ class PageBreak
10
+ attr_reader :id
11
+
12
+ def initialize(id: nil)
13
+ @id = id&.to_s
14
+ freeze
15
+ end
16
+
17
+ def ==(other)
18
+ other.is_a?(self.class) && id == other.id
19
+ end
20
+
21
+ alias eql? ==
22
+
23
+ def hash
24
+ [self.class, id].hash
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Arrolio
4
+ module Content
5
+ class Paragraph
6
+ attr_reader :inline_runs, :style_id, :id, :footnote_refs
7
+
8
+ def initialize(inline_runs = [], style_id: :body, id: nil, footnote_refs: [])
9
+ @inline_runs = Array(inline_runs).freeze
10
+ @style_id = style_id.to_sym
11
+ @id = id
12
+ @footnote_refs = Array(footnote_refs).freeze
13
+ freeze
14
+ end
15
+
16
+ def text
17
+ @inline_runs.map(&:text).join
18
+ end
19
+
20
+ def empty?
21
+ @inline_runs.empty? || text.strip.empty?
22
+ end
23
+
24
+ def ==(other)
25
+ other.is_a?(self.class) &&
26
+ inline_runs == other.inline_runs &&
27
+ style_id == other.style_id &&
28
+ id == other.id &&
29
+ footnote_refs == other.footnote_refs
30
+ end
31
+ alias eql? ==
32
+
33
+ def hash
34
+ [self.class, inline_runs, style_id, id, footnote_refs].hash
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Arrolio
4
+ module Content
5
+ # A preformatted text block. Preserves whitespace (spaces, tabs,
6
+ # newlines) and renders in monospace font without justification.
7
+ # Used for code blocks, ASCII art, and any content where the
8
+ # visual layout of whitespace matters.
9
+ #
10
+ # Each line becomes a separate inline run in the paragraph so
11
+ # the renderer can apply line-by-line font metrics without the
12
+ # greedy line breaker collapsing internal whitespace.
13
+ class Preformatted
14
+ attr_reader :lines, :language, :style_id, :id
15
+
16
+ def initialize(lines, language: nil, style_id: :preformatted, id: nil)
17
+ @lines = Array(lines).map(&:to_s).freeze
18
+ @language = language&.to_s
19
+ @style_id = style_id.to_sym
20
+ @id = id&.to_s
21
+ freeze
22
+ end
23
+
24
+ def text
25
+ @lines.join("\n")
26
+ end
27
+
28
+ def empty?
29
+ @lines.empty? || @lines.all?(&:empty?)
30
+ end
31
+
32
+ def ==(other)
33
+ other.is_a?(self.class) &&
34
+ lines == other.lines &&
35
+ language == other.language &&
36
+ style_id == other.style_id &&
37
+ id == other.id
38
+ end
39
+
40
+ alias eql? ==
41
+
42
+ def hash
43
+ [self.class, lines, language, style_id, id].hash
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Arrolio
4
+ module Content
5
+ class Section
6
+ attr_reader :title, :level, :number, :id, :children, :style_id, :title_style_id
7
+
8
+ def initialize(title: nil, level: 1, number: nil, id: nil,
9
+ children: [], style_id: :section_body, title_style_id: nil)
10
+ @title = title
11
+ @level = level.to_i
12
+ @number = number
13
+ @id = id
14
+ @children = Array(children).freeze
15
+ @style_id = style_id.to_sym
16
+ @title_style_id = (title_style_id || default_title_style).to_sym
17
+ freeze
18
+ end
19
+
20
+ def heading? = !@title.nil?
21
+
22
+ def ==(other)
23
+ other.is_a?(self.class) &&
24
+ title == other.title && level == other.level && number == other.number &&
25
+ id == other.id && children == other.children &&
26
+ style_id == other.style_id && title_style_id == other.title_style_id
27
+ end
28
+ alias eql? ==
29
+
30
+ def hash
31
+ [self.class, title, level, number, id, children, style_id, title_style_id].hash
32
+ end
33
+
34
+ private
35
+
36
+ def default_title_style = "heading_#{@level}"
37
+ end
38
+ end
39
+ end