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,56 @@
1
+ ---
2
+ priority: P0
3
+ phase: 6
4
+ depends_on: [20]
5
+ layer: output
6
+ est: 2d
7
+ status: in_progress
8
+ ---
9
+
10
+ ## Problem
11
+
12
+ Engine produces laid-out pages but currently they're tied to Pdfrb's
13
+ page object. The Output tree should be a medium-neutral representation
14
+ of "what goes where" — separate from both the layout algorithm and
15
+ the renderer. The PDF renderer walks the Output tree; future
16
+ renderers (PostScript, PPML, an in-memory canvas for tests) walk the
17
+ same tree.
18
+
19
+ ## Approach
20
+
21
+ Files under `lib/arrolio/output/`:
22
+
23
+ - `page.rb` — `Page` value object:
24
+ - `number` (Integer)
25
+ - `template_ref` (Symbol or PageTemplate reference)
26
+ - `regions` (Hash of region_name → Region)
27
+ - `static_content` (Array of StaticContent)
28
+ - `size` (Array `[w, h]`)
29
+
30
+ - `region.rb` — `Region` value object:
31
+ - `name` (`:body`, `:before`, `:after`, ...)
32
+ - `frame` (the consumed Frame snapshot)
33
+ - `placed_boxes` (Array of PlacedBox)
34
+
35
+ - `placed_box.rb` — `PlacedBox` value object:
36
+ - `x`, `y`, `width`, `height` (Float)
37
+ - `content_ref` (opaque reference to the source flowable or run)
38
+ - `style` (resolved Style::Definition snapshot)
39
+ - `kind` (`:text`, `:image`, `:shape`, ...)
40
+
41
+ - `static_content.rb` — region name + a list of flowables (rendered
42
+ in pass 2 with resolved citations).
43
+
44
+ The Output tree is fully reified before rendering — no lazy
45
+ generation. This makes rendering straightforward and lets us diff
46
+ Output trees between runs.
47
+
48
+ ## Done-When
49
+
50
+ - [ ] Engine returns `[Output::Page]`.
51
+ - [ ] Each Page has at least a body Region with placed_boxes.
52
+ - [ ] StaticContent records region + flowables but doesn't render
53
+ until pass 2.
54
+ - [ ] Output tree is fully frozen; no mutation after construction.
55
+ - [ ] `Output::Dumper.to_yaml(page)` produces a stable, diffable
56
+ representation.
@@ -0,0 +1,77 @@
1
+ ---
2
+ priority: P0
3
+ phase: 6
4
+ depends_on: [21]
5
+ layer: render
6
+ est: 3d
7
+ status: in_progress
8
+ ---
9
+
10
+ ## Problem
11
+
12
+ The PDF renderer walks `Output::Page[]` and emits PDF bytes via
13
+ Pdfrb. This is where Arrolio stops being medium-neutral and becomes
14
+ PDF-specific. The renderer must NOT make layout decisions — every
15
+ position and style comes from the Output tree.
16
+
17
+ ## Approach
18
+
19
+ File: `lib/arrolio/renderer/pdf.rb`.
20
+
21
+ ```ruby
22
+ class Arrolio::Renderer::Pdf
23
+ def render(pages, io:)
24
+ pdfrb_doc = Pdfrb::Document.new
25
+ pages.each { |page| render_page(pdfrb_doc, page) }
26
+ pdfrb_doc.write(io: io)
27
+ end
28
+
29
+ private
30
+
31
+ def render_page(doc, output_page)
32
+ pdfrb_page = doc.pages.add(media_box: [0, 0, *output_page.size])
33
+ canvas = pdfrb_page.canvas
34
+ output_page.regions.each_value do |region|
35
+ render_region(canvas, region)
36
+ end
37
+ output_page.static_content.each { |sc| render_static(canvas, sc) }
38
+ end
39
+
40
+ def render_region(canvas, region)
41
+ region.placed_boxes.each { |box| render_box(canvas, box) }
42
+ end
43
+
44
+ def render_box(canvas, box)
45
+ case box.kind
46
+ when :text then render_text(canvas, box)
47
+ when :image then render_image(canvas, box)
48
+ when :shape then render_shape(canvas, box)
49
+ end
50
+ end
51
+ end
52
+ ```
53
+
54
+ Mapping table from Arrolio Output concepts to Pdfrb primitives:
55
+
56
+ | Arrolio | Pdfrb |
57
+ |---|---|
58
+ | `Output::Page` of size `[w,h]` | `doc.pages.add(media_box: [0,0,w,h])` |
59
+ | `PlacedBox` kind `:text` | `canvas.text(content, at:, font:, size:, char_spacing:, word_spacing:)` |
60
+ | `PlacedBox` kind `:image` | `name = doc.images.add(path); canvas.image(name, at:, width:, height:)` |
61
+ | `PlacedBox` kind `:shape` (rectangle, line) | `canvas.rectangle/line + canvas.fill/stroke` |
62
+ | `StaticContent` | render via the same path, but at region-specific coordinates |
63
+
64
+ The renderer holds no state between pages — each page renders from
65
+ the Output tree afresh.
66
+
67
+ ## Done-When
68
+
69
+ - [ ] `Renderer::Pdf.new.render([Output::Page.new(...)], io: StringIO.new)`
70
+ produces bytes starting with `%PDF-`.
71
+ - [ ] A single text page renders the text correctly.
72
+ - [ ] Multi-page documents produce N pages with correct page numbers
73
+ in PDF metadata.
74
+ - [ ] Round-trip: rendered PDF re-read by Pdfrb has the expected
75
+ number of pages and extractable text.
76
+ - [ ] No layout decisions leak into the renderer (all coordinates
77
+ come from Output::PlacedBox).
@@ -0,0 +1,53 @@
1
+ ---
2
+ priority: P0
3
+ phase: 6
4
+ depends_on: [22]
5
+ layer: output
6
+ est: 1d
7
+ status: in_progress
8
+ ---
9
+
10
+ ## Problem
11
+
12
+ Need a single test that proves the full pipeline works end-to-end:
13
+ Content → LayoutSpec → Engine → Output → Renderer → PDF bytes that
14
+ read back correctly. This is the MVP milestone.
15
+
16
+ ## Approach
17
+
18
+ File: `spec/arrolio/end_to_end_smoke_spec.rb`.
19
+
20
+ Build the simplest non-trivial document:
21
+ - A4 page, 25mm margins.
22
+ - One section with a heading and two paragraphs of body text.
23
+ - Use the standard 14 Type1 fonts (Helvetica + Times).
24
+
25
+ Run it through the pipeline:
26
+ ```ruby
27
+ content = Arrolio::Content::Document.build { ... }
28
+ layout = Arrolio::LayoutSpec.build { ... }
29
+ pages = Arrolio::Engine::Paged.new(layout_spec: layout,
30
+ flowables: FlowBuilder.(content, layout)).layout
31
+ out = StringIO.new
32
+ Arrolio::Renderer::Pdf.new.render(pages, io: out)
33
+
34
+ # Assertions
35
+ reopened = Pdfrb::Document.new(io: StringIO.new(out.string))
36
+ expect(reopened.pages.count).to eq(1)
37
+ extracted = Pdfrb::Task::ExtractText.(reopened).first
38
+ expect(extracted).to include("Hello, World!")
39
+ ```
40
+
41
+ Also a multi-page variant: 50 paragraphs that overflow to 2+ pages.
42
+
43
+ This spec becomes the regression canary — any breaking change in
44
+ any layer fails it.
45
+
46
+ ## Done-When
47
+
48
+ - [ ] Single-page smoke spec passes.
49
+ - [ ] Multi-page overflow spec passes (≥ 2 pages, text extractable).
50
+ - [ ] Round-trip: write → read → re-write → re-read produces same
51
+ text extraction.
52
+ - [ ] The spec runs in < 2 seconds.
53
+ - [ ] Spec is marked `:e2e` so it can be excluded from fast unit runs.
@@ -0,0 +1,51 @@
1
+ ---
2
+ priority: P0
3
+ phase: 7
4
+ depends_on: [03, 20]
5
+ layer: template
6
+ est: 2d
7
+ status: in_progress
8
+ ---
9
+
10
+ ## Problem
11
+
12
+ Documents need more than one page geometry: cover pages differ from
13
+ body pages, running headers/footers vary, odd/even pages mirror each
14
+ other. The PageTemplate + Region types define static geometry; the
15
+ engine instantiates them per output page.
16
+
17
+ ## Approach
18
+
19
+ Files under `lib/arrolio/layout_spec/`:
20
+
21
+ - `page_template.rb` (extended from TODO 03):
22
+ - `name` (Symbol).
23
+ - `page_size` (`[w, h]` or named like `:A4`, `:Letter`).
24
+ - `margins` (Hash with `:top`, `:right`, `:bottom`, `:left`).
25
+ - `region_extents` (Hash `:before => 15mm`, etc.).
26
+ - Computed `regions` (Hash of name → Region with absolute geometry).
27
+
28
+ - `region.rb`:
29
+ - `name`, `x`, `y`, `width`, `height`.
30
+ - `flow_ref` (optional — names the Flow that fills this region).
31
+ - `to_frame` → returns a fresh `Frame` for runtime use.
32
+
33
+ Helper: `PageSizes` constant mapping `:A4` → `[595, 842]`, `:Letter`
34
+ → `[612, 792]`, `:Legal` → `[612, 1008]`, etc.
35
+
36
+ Region geometry computed from page size + margins + region extents:
37
+ ```
38
+ body = Region(x: left_margin, y: bottom_margin,
39
+ width: page_w - L - R,
40
+ height: page_h - T - B - before_extent - after_extent)
41
+ ```
42
+
43
+ ## Done-When
44
+
45
+ - [ ] `PageTemplate.new(page_size: :A4, margins: 25)` produces a
46
+ template with body region of 545×743pt.
47
+ - [ ] Asymmetric margins honoured.
48
+ - [ ] `Region#to_frame` returns a Frame with `remaining_height ==
49
+ height`.
50
+ - [ ] All 14 standard page sizes supported.
51
+ - [ ] Specs cover A4, Letter, asymmetric margins, with-header-footer.
@@ -0,0 +1,54 @@
1
+ ---
2
+ priority: P0
3
+ phase: 7
4
+ depends_on: [24]
5
+ layer: template
6
+ est: 2d
7
+ status: pending
8
+ ---
9
+
10
+ ## Problem
11
+
12
+ The engine needs to know which PageTemplate to use for each output
13
+ page. FOP's `fo:page-sequence-master` solves this with rules: first,
14
+ odd, even, blank, last. Same model in Ruby.
15
+
16
+ ## Approach
17
+
18
+ File: `lib/arrolio/layout_spec/page_sequence_master.rb`.
19
+
20
+ ```ruby
21
+ class Arrolio::LayoutSpec::PageSequenceMaster
22
+ attr_reader :rules, :fallback
23
+
24
+ def initialize(rules: {}, fallback: nil)
25
+ # rules: { first: template_name, odd: ..., even: ..., blank: ..., last: ... }
26
+ # All values are Symbol template names.
27
+ end
28
+
29
+ def template_name_for(page_number:, total: nil, blank: false)
30
+ # Returns a Symbol template name. Selection order:
31
+ # 1. first page (page_number == 1) && rules[:first]
32
+ # 2. blank page && rules[:blank]
33
+ # 3. last page (page_number == total) && rules[:last]
34
+ # 4. odd/even by parity
35
+ # 5. fallback or rules[:default]
36
+ end
37
+ end
38
+ ```
39
+
40
+ Engine integration:
41
+ - Track page_number, total (set in pass 1), and "is this a blank page"
42
+ (page with no body content, only static).
43
+ - On each new page, query the master for the template name.
44
+ - Look up the actual PageTemplate in the LayoutSpec.
45
+
46
+ ## Done-When
47
+
48
+ - [ ] `template_name_for(page_number: 1)` returns `rules[:first]` if set.
49
+ - [ ] `template_name_for(page_number: 4, total: 4)` returns
50
+ `rules[:last]` if set.
51
+ - [ ] `template_name_for(page_number: 2)` returns `rules[:even]`.
52
+ - [ ] `template_name_for(page_number: 3)` returns `rules[:odd]`.
53
+ - [ ] Falls through to fallback when no rule matches.
54
+ - [ ] Engine uses the selected template to size each new page.
@@ -0,0 +1,66 @@
1
+ ---
2
+ priority: P0
3
+ phase: 7
4
+ depends_on: [25]
5
+ layer: template
6
+ est: 2d
7
+ status: pending
8
+ ---
9
+
10
+ ## Problem
11
+
12
+ Cross-references (`fo:page-number-citation`, "Page X of Y") need to
13
+ know page numbers that aren't decided until layout completes. The
14
+ engine runs two passes:
15
+
16
+ 1. **Pass 1**: lay out everything with placeholder field values.
17
+ Record which page each citation target lands on.
18
+ 2. **Pass 2**: re-render the static-content regions (headers/footers)
19
+ with resolved citation values.
20
+
21
+ Body content is NOT re-rendered in pass 2 — its layout is stable.
22
+ Only the static regions need field resolution.
23
+
24
+ ## Approach
25
+
26
+ Modify `Arrolio::Engine::Paged` (TODO 20):
27
+
28
+ ```ruby
29
+ def layout
30
+ pass1_result = layout_pass1
31
+ context.page_count = pass1_result.pages.length
32
+ pass2(pass1_result.pages)
33
+ end
34
+
35
+ def pass2(pages)
36
+ pages.each_with_index do |page, i|
37
+ context.page_number = i + 1
38
+ page.render_static_content(context)
39
+ end
40
+ pages
41
+ end
42
+ ```
43
+
44
+ The `Output::Page#render_static_content(context)` method:
45
+ - For each `StaticContent` entry, resolve its flowables' field runs.
46
+ - Rebuild the placed boxes for the static region only.
47
+ - Replace the region's `placed_boxes` with the new ones.
48
+
49
+ Field runs (TODO 39) implement `resolve(context)` returning a String.
50
+ The static-content flow is built once during pass 1, but its field
51
+ runs are evaluated against `context` each time the region is
52
+ re-rendered.
53
+
54
+ Citation recording: when the engine places a flowable that has a
55
+ `citation_target_id`, it records
56
+ `context.record_citation(target_id, current_page_number)`.
57
+
58
+ ## Done-When
59
+
60
+ - [ ] A document with "Page X of Y" in the footer renders correctly
61
+ (X = current page, Y = total).
62
+ - [ ] A `page-number-citation` to a section heading on page 5
63
+ resolves to "5" in static content.
64
+ - [ ] Body layout is identical between pass 1 and pass 2 (no relayout).
65
+ - [ ] Pass 2 only re-evaluates field runs; no other work.
66
+ - [ ] 100-page document: pass 2 completes in < 1 second.
@@ -0,0 +1,48 @@
1
+ ---
2
+ priority: P0
3
+ phase: 8
4
+ depends_on: [02, 16]
5
+ layer: table
6
+ est: 2d
7
+ status: in_progress
8
+ ---
9
+
10
+ ## Problem
11
+
12
+ Tables are the hardest layout primitive. Need typed Row/Cell/Column
13
+ model that the layout algorithms (TODO 28) and renderer (TODO 29)
14
+ operate on. Cells can span multiple columns or rows. Header rows
15
+ repeat across page breaks.
16
+
17
+ ## Approach
18
+
19
+ Files under `lib/arrolio/table/`:
20
+
21
+ - `table.rb` — value object holding `rows` (Array of Row),
22
+ `column_specs` (Array of ColumnSpec), `style` (TableStyle).
23
+ Methods: `num_columns`, `num_rows`, `header_rows`, `body_rows`.
24
+
25
+ - `row.rb` — `Row = Struct.new(:cells, :header, :style,
26
+ keyword_init: true)`. `header?` alias. Cells is an Array of Cell.
27
+
28
+ - `cell.rb` — `Cell = Struct.new(:content, :colspan, :rowspan,
29
+ :style, keyword_init: true)`. `content` is a Flowable or String
30
+ (Strings auto-wrapped in TextFlowable). `colspan?`, `rowspan?`
31
+ predicates.
32
+
33
+ - `column_spec.rb` — `ColumnSpec = Struct.new(:width, :min, :max,
34
+ :auto, keyword_init: true)`. Class methods: `.fixed(width)`,
35
+ `.auto(min:, max:)`. `fixed?` predicate.
36
+
37
+ Cell flowables: `Cell#flowables` normalises content into a list of
38
+ `Flowable` instances. Strings become `TextFlowable` with the cell's
39
+ style.
40
+
41
+ ## Done-When
42
+
43
+ - [ ] Construct a 3-row × 3-column table; verify `num_rows == 3`,
44
+ `num_columns == 3`.
45
+ - [ ] A row marked `header: true` appears in `header_rows`.
46
+ - [ ] A cell with `colspan: 2` reports `colspan? == true`.
47
+ - [ ] String cell content auto-wraps in TextFlowable.
48
+ - [ ] Specs cover construction, header detection, span predicates.
@@ -0,0 +1,53 @@
1
+ ---
2
+ priority: P0
3
+ phase: 8
4
+ depends_on: [27, 16]
5
+ layer: table
6
+ est: 3d
7
+ status: pending
8
+ ---
9
+
10
+ ## Problem
11
+
12
+ Given a Table + target width, compute the column widths and row
13
+ heights. Two strategies: Fixed (caller-specified widths) and Auto
14
+ (CSS 2.1 §17.5.2.2 — distribute surplus by content).
15
+
16
+ ## Approach
17
+
18
+ Files under `lib/arrolio/table_layout/`:
19
+
20
+ - `resolved_grid.rb` — value object: `column_widths`, `row_heights`,
21
+ `cell_rects` (per-cell { x, y, width, height }). `total_width`,
22
+ `total_height`.
23
+
24
+ - `fixed.rb` — Fixed strategy:
25
+ - Take column widths from specs.
26
+ - Scale proportionally if sum != target_width.
27
+ - Compute each row's height as the max cell content height at the
28
+ cell's resolved width.
29
+
30
+ - `auto.rb` — Auto strategy (CSS 2.1):
31
+ 1. For each cell, measure min-content width (longest unbreakable
32
+ word) and max-content width (single line).
33
+ 2. Aggregate to per-column min/max (respecting colspan: distribute
34
+ spanned cell's min/max evenly across spanned columns).
35
+ 3. Distribute target_width:
36
+ - If surplus over max: distribute by max-content ratio.
37
+ - If between min and max: distribute by max ratio of the slack.
38
+ - If less than min: each column gets its min (may overflow).
39
+ 4. Compute row heights same as Fixed.
40
+
41
+ Both strategies return a `ResolvedGrid`.
42
+
43
+ ## Done-When
44
+
45
+ - [ ] Fixed layout: `Fixed.new(table, target_width: 300).layout`
46
+ honours the spec widths.
47
+ - [ ] Fixed layout scales when target_width != sum(specs).
48
+ - [ ] Auto layout: a column containing "antidisestablishmentarianism"
49
+ gets at least the word's width.
50
+ - [ ] Auto layout: narrow column for short content, wide for long.
51
+ - [ ] ResolvedGrid.cell_rects respects colspan (cell spans 2 columns'
52
+ combined width) and rowspan.
53
+ - [ ] Specs cover both strategies + colspan/rowspan.
@@ -0,0 +1,55 @@
1
+ ---
2
+ priority: P0
3
+ phase: 8
4
+ depends_on: [28, 22]
5
+ layer: table
6
+ est: 2d
7
+ status: pending
8
+ ---
9
+
10
+ ## Problem
11
+
12
+ Given a resolved grid (column widths + cell rects), render the table:
13
+ draw borders as a single-line grid (border-collapse model), fill
14
+ backgrounds, place each cell's content via its flowables. The
15
+ renderer must produce `PlacedBox[]` entries that the PDF renderer
16
+ (TODO 22) consumes.
17
+
18
+ ## Approach
19
+
20
+ File: `lib/arrolio/table_renderer.rb`.
21
+
22
+ ```ruby
23
+ class Arrolio::TableRenderer
24
+ def initialize(table, grid)
25
+ def render(canvas, x_origin, y_origin, context) -> Float (total height)
26
+ end
27
+ ```
28
+
29
+ Steps:
30
+ 1. **Backgrounds**: for each row, fill the row's rect with the row's
31
+ `background_color` (or table's `header_background` for header rows).
32
+ 2. **Borders**: border-collapse grid.
33
+ - Vertical lines: at each column boundary (including outer left/right).
34
+ - Horizontal lines: at each row boundary (including outer top/bottom).
35
+ - Stroke colour from `table.style.border_color`; width from
36
+ `table.style.border_width`.
37
+ 3. **Content**: for each cell, stack its flowables vertically inside
38
+ the cell rect (accounting for `cell_padding`).
39
+
40
+ Output: emits `canvas` operations directly OR returns PlacedBox[]
41
+ (we'll do canvas operations directly for simplicity; the PlacedBox
42
+ indirection is for non-PDF renderers, deferred).
43
+
44
+ PDF coordinate flip: PDF y grows up; table y grows down. Convert via
45
+ `y_origin - rect.y - rect.height`.
46
+
47
+ ## Done-When
48
+
49
+ - [ ] A 2×2 table renders as a visible grid with content in each cell.
50
+ - [ ] Header row background differs from body rows when
51
+ `header_background` is set.
52
+ - [ ] Border-collapse: adjacent cells share a single 1pt line, not
53
+ doubled.
54
+ - [ ] Cell content respects `cell_padding`.
55
+ - [ ] Colspan cell spans the combined width visually.
@@ -0,0 +1,53 @@
1
+ ---
2
+ priority: P0
3
+ phase: 8
4
+ depends_on: [29, 16]
5
+ layer: table
6
+ est: 2d
7
+ status: in_progress
8
+ ---
9
+
10
+ ## Problem
11
+
12
+ Tables longer than a single page must split between rows (never
13
+ mid-cell). Header rows repeat on every fragment. The TableFlowable
14
+ wraps a Table so it integrates with the page-flow engine like any
15
+ other Flowable.
16
+
17
+ ## Approach
18
+
19
+ File: `lib/arrolio/flowables/table_flowable.rb`.
20
+
21
+ ```ruby
22
+ class Arrolio::Flowables::TableFlowable < Arrolio::Flowable
23
+ def initialize(table, algorithm: :auto)
24
+ def height(width, context)
25
+ def render(canvas, x, y, width, context)
26
+ def splittable?; true; end
27
+ def do_split(width, remaining_height, context) -> [head, tail]
28
+ end
29
+ ```
30
+
31
+ `do_split` algorithm:
32
+ 1. Layout the table at `width` to get the resolved grid.
33
+ 2. Walk rows; cumulate heights.
34
+ 3. Header rows always go in the head.
35
+ 4. Body rows: include in head if `cum_height + row_h <= remaining_height`;
36
+ otherwise include in tail.
37
+ 5. If no body rows fit (`head_rows == header_rows`), return
38
+ `[nil, self]` so the engine advances to a new page.
39
+ 6. Build two tables: `head_table` (header + first N body rows),
40
+ `tail_table` (header + remaining body rows — header repeats!).
41
+ 7. Return `[TableFlowable.new(head_table), TableFlowable.new(tail_table)]`.
42
+
43
+ `render` uses TableRenderer.
44
+
45
+ ## Done-When
46
+
47
+ - [ ] A short table (fits on one page) renders without splitting.
48
+ - [ ] A 30-row table on a single-page frame splits into multiple
49
+ pages, header row repeated on each.
50
+ - [ ] Split is between rows, never mid-cell.
51
+ - [ ] `do_split` with insufficient remaining returns `[nil, self]`.
52
+ - [ ] Engine then advances and renders the table on the next page.
53
+ - [ ] Colspan/rowspan preserved across splits.
@@ -0,0 +1,45 @@
1
+ ---
2
+ priority: P1
3
+ phase: 9
4
+ depends_on: [02, 16]
5
+ layer: list
6
+ est: 1d
7
+ status: pending
8
+ ---
9
+
10
+ ## Problem
11
+
12
+ Lists (bullet, numbered, definition) need a typed model: items with
13
+ markers, nested lists, marker schemes (decimal, alpha, roman, bullet
14
+ variants).
15
+
16
+ ## Approach
17
+
18
+ Files under `lib/arrolio/list/`:
19
+
20
+ - `list.rb` — value object: `items` (Array of Item), `marker_scheme`
21
+ (Symbol), `style`. Methods: `nested?`, `level`.
22
+
23
+ - `item.rb` — `Item = Struct.new(:marker, :content, :sublists,
24
+ keyword_init: true)`. `marker` may be a String (explicit) or nil
25
+ (use scheme). `content` is a Flowable or Array. `sublists` is
26
+ Array of List (for nesting).
27
+
28
+ - `marker_scheme.rb` — module of constants:
29
+ - `BULLET` ("•")
30
+ - `DASH` ("—")
31
+ - `DECIMAL` (Proc: `->(i) { "#{i}." }`)
32
+ - `LOWER_ALPHA`, `UPPER_ALPHA`
33
+ - `LOWER_ROMAN`, `UPPER_ROMAN`
34
+ - Custom schemes via Proc.
35
+
36
+ Nested lists indent and may use different schemes at different
37
+ levels (decimal at level 0, lower-alpha at level 1, etc.).
38
+
39
+ ## Done-When
40
+
41
+ - [ ] A 3-item bullet list has `marker_scheme == :bullet`.
42
+ - [ ] `MarkerScheme::DECIMAL.call(3) == "3."`.
43
+ - [ ] `MarkerScheme::LOWER_ROMAN.call(4) == "iv."`.
44
+ - [ ] A nested list (`item.sublists`) reports `level == 1`.
45
+ - [ ] Specs cover all built-in schemes + nesting.
@@ -0,0 +1,54 @@
1
+ ---
2
+ priority: P1
3
+ phase: 9
4
+ depends_on: [31, 18]
5
+ layer: list
6
+ est: 2d
7
+ status: pending
8
+ ---
9
+
10
+ ## Problem
11
+
12
+ A list flowable must:
13
+ - Render items as a two-column layout (label gutter + body).
14
+ - Auto-size the gutter to the widest marker.
15
+ - Apply hanging indent so wrapped lines align with the first line.
16
+ - Support nested lists (deeper indent).
17
+ - Be splittable between items (one item per page minimum).
18
+
19
+ ## Approach
20
+
21
+ File: `lib/arrolio/flowables/list_flowable.rb`.
22
+
23
+ ```ruby
24
+ class Arrolio::Flowables::ListFlowable < Arrolio::Flowable
25
+ def initialize(list, level: 0)
26
+ def height(width, context)
27
+ def render(canvas, x, y, width, context)
28
+ def splittable?; true; end
29
+ def do_split(width, remaining_height, context)
30
+ end
31
+ ```
32
+
33
+ Layout algorithm:
34
+ 1. For each item, compute the marker text (from scheme or explicit).
35
+ 2. Label column width = max marker width + small gap (e.g. 6pt).
36
+ 3. Body column width = `width - label_column - indent`.
37
+ 4. Render item:
38
+ - Place marker in label column (right-aligned to body column).
39
+ - Place content flowable in body column.
40
+ - Hanging indent: if content wraps, subsequent paragraphs in the
41
+ item align with the body column's left edge.
42
+ 5. Nested lists: render as a sub-ListFlowable indented further.
43
+
44
+ Splitting: between items only. If first item doesn't fit on the
45
+ current frame, advance page.
46
+
47
+ ## Done-When
48
+
49
+ - [ ] A 3-item bullet list renders with "•" markers in the left gutter.
50
+ - [ ] Decimal-numbered list auto-sizes gutter to "12." or wider.
51
+ - [ ] Wrapped content lines align with the first line (hanging indent).
52
+ - [ ] Nested list indents further and uses different marker scheme.
53
+ - [ ] A list longer than the page splits between items.
54
+ - [ ] Specs cover all of the above.