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,48 @@
1
+ ---
2
+ priority: P0
3
+ phase: 2
4
+ depends_on: [01]
5
+ layer: metrics
6
+ est: 2d
7
+ status: in_progress
8
+ ---
9
+
10
+ ## Problem
11
+
12
+ TTF/OTF advance widths live in the `hmtx` table indexed by glyph ID.
13
+ To go Unicode → glyph ID we need the `cmap` table. To scale we need
14
+ `head.units_per_em`. And `hhea` / `OS/2` give ascender/descender
15
+ (line height).
16
+
17
+ ## Approach
18
+
19
+ Files under `lib/arrolio/font/true_type/`:
20
+
21
+ - `file.rb` — sfnt header + table directory; lazy table access.
22
+ - `head.rb` — `units_per_em`, `bbox`, `index_to_loc_format`,
23
+ `mac_style` (bold/italic flags).
24
+ - `hhea.rb` — `ascender`, `descender`, `line_gap`,
25
+ `number_of_hmetrics`.
26
+ - `hmtx.rb` — `advance_width(glyph_id)`, `lsb(glyph_id)`; handles
27
+ `numberOfHMetrics < numGlyphs` case.
28
+ - `cmap.rb` — Unicode → glyph ID; subtable selection prefers
29
+ `(3,12)` > `(0,*)` > `(3,1)` > `(3,0)`; supports formats 0, 4, 6, 12.
30
+ - `os2.rb` — `s_typo_ascender/descender/line_gap`,
31
+ `s_cap_height`, `sx_height`, `us_win_ascent/descent`,
32
+ `fs_selection` (bold/italic flags).
33
+
34
+ Under `lib/arrolio/font_metrics/`:
35
+
36
+ - `true_type_metrics.rb` — wraps `TrueType::File`; implements the
37
+ universal metrics interface from TODO 09.
38
+
39
+ ## Done-When
40
+
41
+ - [ ] Loading DejaVuSans.ttf (or similar fixture) succeeds.
42
+ - [ ] `ttf.head.units_per_em` matches `ttfname`/`fc-query` output.
43
+ - [ ] `ttf.cmap.glyph_id_for(0x41)` returns the same value FreeType
44
+ reports for "A".
45
+ - [ ] `ttf.hmtx.advance_width(0)` matches the .notdef glyph width.
46
+ - [ ] `TrueTypeMetrics#width_of_string` matches FreeType to within
47
+ 0.1pt at 12pt.
48
+ - [ ] Specs cover cmap formats 4 and 12 (provide two test TTFs).
@@ -0,0 +1,49 @@
1
+ ---
2
+ priority: P0
3
+ phase: 2
4
+ depends_on: [07, 08]
5
+ layer: metrics
6
+ est: 1d
7
+ status: in_progress
8
+ ---
9
+
10
+ ## Problem
11
+
12
+ Engine and TextLayout need one stable interface for "ask the font about
13
+ glyph metrics" regardless of whether the font is AFM-backed (standard
14
+ Type1) or TTF-backed. Plus a high-level measurer that combines metrics
15
+ with PDF's `Tc`/`Tw` operator semantics.
16
+
17
+ ## Approach
18
+
19
+ Files:
20
+
21
+ - `lib/arrolio/font_metrics/interface.rb` — module documenting the
22
+ contract every metrics class implements: `units_per_em`,
23
+ `advance_width(char)`, `width_of_string(str, font_size:)`,
24
+ `ascender(font_size:)`, `descender(font_size:)`,
25
+ `cap_height(font_size:)`, `x_height(font_size:)`,
26
+ `line_height(font_size:, line_spacing:)`.
27
+ - `lib/arrolio/font_metrics/registry.rb` — singleton; maps font name
28
+ to a metrics instance; memoises; supports `register_ttf(name, path)`.
29
+ - `lib/arrolio/glyph_measurer.rb` — high-level facade over a metrics
30
+ instance. Methods:
31
+ - `width_of_string(str, font_size:)` — direct.
32
+ - `width_of_run(str, font_size:, character_spacing:, word_spacing:)`
33
+ — adds `character_spacing * (chars - 1)` and `word_spacing * words`.
34
+ - `line_height(font_size:, line_spacing:)`.
35
+ - `space_width(font_size:)`.
36
+ - `available?` — false when no metrics loaded (falls back to 0.5em
37
+ estimate to keep layout usable).
38
+
39
+ ## Done-When
40
+
41
+ - [ ] `Registry["Helvetica"]` returns an `AfmMetrics` instance,
42
+ memoised across calls.
43
+ - [ ] `Registry["Unknown"]` returns `nil`.
44
+ - [ ] After `Registry.register_ttf("MyFont", "/path/x.ttf")`,
45
+ `Registry["MyFont"]` returns a `TrueTypeMetrics`.
46
+ - [ ] `GlyphMeasurer.new(font_name: "Helvetica").width_of_run("a b c",
47
+ font_size: 12, character_spacing: 1, word_spacing: 2)` matches
48
+ the PDF spec formula.
49
+ - [ ] Specs cover both AFM and TTF paths through GlyphMeasurer.
@@ -0,0 +1,47 @@
1
+ ---
2
+ priority: P0
3
+ phase: 3
4
+ depends_on: [04, 09]
5
+ layer: text
6
+ est: 1d
7
+ status: in_progress
8
+ ---
9
+
10
+ ## Problem
11
+
12
+ Line breaking iterates over a list of `InlineRun`s. Each run is a
13
+ contiguous span of text sharing one `Style`. The line breaker must
14
+ find break opportunities across run boundaries (the wrap algorithm
15
+ cannot force a line break just because the style changed).
16
+
17
+ ## Approach
18
+
19
+ Files:
20
+
21
+ - `lib/arrolio/inline_run.rb` — frozen value object:
22
+ `text`, `style`. Methods: `empty?`, `length`, `width(measurer)`,
23
+ `==`, `eql?`, `hash`.
24
+ - `lib/arrolio/text_layout/break_opportunity.rb` — finds break
25
+ points in a run list. Yields `BreakOpportunity` value objects:
26
+ - `run_index` — index into the run list.
27
+ - `char_offset` — position within that run.
28
+ - `width_before` — cumulative width up to (and including) the break.
29
+ - `type` — `:start`, `:soft`, `:forced`, `:end`.
30
+
31
+ Opportunities are created at:
32
+ - Paragraph start (always, `:start`).
33
+ - After each space (`:soft`).
34
+ - After hyphen / em-dash / soft hyphen (`:soft`).
35
+ - At zero-width space (U+200B) (`:soft`, no width consumed).
36
+ - At newline (`:forced`).
37
+ - At end of paragraph (`:end`).
38
+
39
+ ## Done-When
40
+
41
+ - [ ] `InlineRun.new("Hello", style:).width(measurer)` returns a Float.
42
+ - [ ] `BreakOpportunity.each_in(runs, measurer:, font_size:)` yields
43
+ every opportunity in order with correct cumulative widths.
44
+ - [ ] A forced newline produces a `:forced` opportunity.
45
+ - [ ] A paragraph with no spaces yields only `:start` and `:end`.
46
+ - [ ] Specs cover: ASCII, multi-run with style change at space,
47
+ explicit newline, soft hyphen.
@@ -0,0 +1,48 @@
1
+ ---
2
+ priority: P0
3
+ phase: 3
4
+ depends_on: [10]
5
+ layer: text
6
+ est: 1d
7
+ status: in_progress
8
+ ---
9
+
10
+ ## Problem
11
+
12
+ The simplest correct line breaker: first-fit word wrap. Walk runs
13
+ left-to-right, accumulate words, emit a line when the next word would
14
+ exceed the target width. FOP's default. Linear time, fast.
15
+
16
+ ## Approach
17
+
18
+ File: `lib/arrolio/text_layout/greedy.rb`.
19
+
20
+ ```ruby
21
+ class Arrolio::TextLayout::Greedy
22
+ def initialize(runs, measurer:, width:, align: :left)
23
+ def layout -> [Line, Line, ...]
24
+ end
25
+ ```
26
+
27
+ Algorithm:
28
+ 1. Enumerate break opportunities.
29
+ 2. Track `line_start` index into the opportunity list.
30
+ 3. For each subsequent opportunity, compute `line_width = opp.width_before
31
+ - opps[line_start].width_before`.
32
+ 4. If `line_width > target_width` and we have at least one opportunity
33
+ on this line, emit a line ending at the previous opportunity; reset
34
+ `line_start`.
35
+ 5. Force-break at `:forced` opportunities.
36
+ 6. At end, emit the trailing line.
37
+
38
+ Output: list of `Line` objects (TODO 13). This TODO builds Lines
39
+ without alignment offsets; TODO 13 adds offsets.
40
+
41
+ ## Done-When
42
+
43
+ - [ ] A single short word on one line: `"Hello"` at width 500 → 1 line.
44
+ - [ ] Long text wraps to multiple lines; no line exceeds target width
45
+ except for unbreakable words.
46
+ - [ ] Forced newline breaks the line.
47
+ - [ ] Style change at a space does not produce a spurious line break.
48
+ - [ ] Per-line `width` is correct (matches sum of placed run widths).
@@ -0,0 +1,46 @@
1
+ ---
2
+ priority: P1
3
+ phase: 3
4
+ depends_on: [10]
5
+ layer: text
6
+ est: 2d
7
+ status: in_progress
8
+ ---
9
+
10
+ ## Problem
11
+
12
+ Greedy line breaking produces uneven right margins and unbalanced
13
+ justify. Knuth-Plass (TeX, Pango) minimises the sum of squared
14
+ shrink/stretch ratios across the paragraph, producing visually
15
+ balanced text. Opt-in via `style.line_break: :knuth_plass`.
16
+
17
+ ## Approach
18
+
19
+ File: `lib/arrolio/text_layout/knuth_plass.rb`.
20
+
21
+ Same I/O as Greedy: `(runs, measurer, width, align)` → `[Line]`.
22
+
23
+ Algorithm (simplified — TODO 14 generalises):
24
+ 1. Enumerate break opportunities.
25
+ 2. `best[i]` = optimal cost to reach opportunity i. `best[0] = 0`.
26
+ 3. For each pair `(i, j)` with `i < j`:
27
+ - Compute `line_width = opps[j].width_before - opps[i].width_before`.
28
+ - If `:forced` at j, treat as zero-cost break.
29
+ - If `line_width > target`, allow but with very high badness.
30
+ - Otherwise badness = `((target - line_width) / target) ** 2 * 100`.
31
+ - `candidate_cost = best[i] + badness`. Keep the minimum.
32
+ 4. Walk back from end to recover the chosen break sequence.
33
+ 5. Skip empty segments (would produce empty trailing lines).
34
+
35
+ Knuth's full algorithm has penalties, fitness classes, and demerits.
36
+ This TODO uses the simplified version. TODO 14 lifts it to a general
37
+ `Breaker` with full Knuth semantics.
38
+
39
+ ## Done-When
40
+
41
+ - [ ] Single short word → 1 line.
42
+ - [ ] Long paragraph wraps to multiple lines without empty trailing line.
43
+ - [ ] For a sample paragraph, KnuthPlass produces line widths whose
44
+ variance is ≤ Greedy's variance on the same paragraph (i.e. more
45
+ balanced).
46
+ - [ ] Output interface is identical to Greedy (swappable).
@@ -0,0 +1,45 @@
1
+ ---
2
+ priority: P0
3
+ phase: 3
4
+ depends_on: [11]
5
+ layer: text
6
+ est: 1d
7
+ status: in_progress
8
+ ---
9
+
10
+ ## Problem
11
+
12
+ The line breakers emit lists of `Line` objects. Each `Line` carries
13
+ the placed runs (with x-offsets), the line width, and the alignment.
14
+ The renderer needs these to know exactly where to draw each run.
15
+
16
+ ## Approach
17
+
18
+ Files:
19
+
20
+ - `lib/arrolio/text_layout/line.rb` — `Line` value object:
21
+ - `placed_runs` — Array of `PlacedRun` (run + x_offset within line).
22
+ - `width` — actual content width of the line.
23
+ - `max_width` — target width (for alignment reference).
24
+ - `align` — `:left`, `:right`, `:center`, `:justify`.
25
+ - `x_offset` — line's left-edge offset based on alignment:
26
+ - left: 0
27
+ - right: `max_width - width`
28
+ - center: `(max_width - width) / 2`
29
+ - `justify_stretch` — extra width per word gap for justify alignment
30
+ (last line of a paragraph is never justified).
31
+ - `lib/arrolio/text_layout/placed_run.rb` — `Struct.new(:run,
32
+ :x_offset, keyword_init: true)`.
33
+
34
+ Both breakers (Greedy, KnuthPlass) produce `Line[]` via the same code
35
+ path — extract shared line-building into a private helper.
36
+
37
+ ## Done-When
38
+
39
+ - [ ] `Line#x_offset` for centered alignment matches
40
+ `(max_width - width) / 2`.
41
+ - [ ] `Line#justify_stretch` is 0 for the last line in a paragraph.
42
+ - [ ] `Line#justify_stretch` evenly distributes slack across word gaps
43
+ for non-last lines.
44
+ - [ ] A renderer that consumes `Line#placed_runs` can place every run
45
+ at the correct x position.
@@ -0,0 +1,56 @@
1
+ ---
2
+ priority: P1
3
+ phase: 4
4
+ depends_on: [10]
5
+ layer: knuth
6
+ est: 2d
7
+ status: pending
8
+ ---
9
+
10
+ ## Problem
11
+
12
+ FOP's greatest insight: one algorithm (Knuth-Plass dynamic
13
+ programming) handles line breaks, page breaks, column balancing, and
14
+ table row distribution alike. The universal input is the Knuth element
15
+ triple: Box (fixed-size content), Glue (inter-content with
16
+ stretch/shrink), Penalty (break cost). My current pdfrb has this
17
+ implicit inside TextLayout only.
18
+
19
+ ## Approach
20
+
21
+ Files under `lib/arrolio/knuth/`:
22
+
23
+ - `element.rb` — base class. Each element has a `width` (Float) and a
24
+ `flagged?` boolean (for penalty/forced breaks).
25
+ - `box.rb` — content with fixed size: `Box.new(width: 30.0, content_ref)`.
26
+ - `glue.rb` — inter-content: `Glue.new(width:, stretch:, shrink:)`.
27
+ - `penalty.rb` — break cost: `Penalty.new(width:, penalty:, flagged:)`.
28
+ Special values:
29
+ - `Penalty::INFINITE` (no break here)
30
+ - `Penalty::FORCED` (must break here)
31
+
32
+ File: `lib/arrolio/breaker.rb`.
33
+
34
+ ```ruby
35
+ class Arrolio::Breaker
36
+ def initialize(elements, target_width:, tolerance: 1.0)
37
+ def break -> [BreakPoint, BreakPoint, ...]
38
+ end
39
+ ```
40
+
41
+ Algorithm (Knuth-Plass DP):
42
+ 1. Active set starts with node 0 at total cost 0.
43
+ 2. For each feasible break point, compute badness from preceding
44
+ active nodes; pick the minimum-cost predecessor.
45
+ 3. Returns the chosen break-point sequence.
46
+
47
+ A `BreakPoint` references the element index and the predecessor.
48
+
49
+ ## Done-When
50
+
51
+ - [ ] Knuth elements round-trip through serialisation.
52
+ - [ ] `Breaker` on a synthetic Box-Glue-Penalty sequence produces
53
+ optimal breaks matching a hand-computed result.
54
+ - [ ] `Penalty::FORCED` always breaks.
55
+ - [ ] `Penalty::INFINITE` never breaks.
56
+ - [ ] Specs cover under-full, over-full, and exact-fit cases.
@@ -0,0 +1,47 @@
1
+ ---
2
+ priority: P2
3
+ phase: 4
4
+ depends_on: [11, 12, 14]
5
+ layer: knuth
6
+ est: 2d
7
+ status: pending
8
+ ---
9
+
10
+ ## Problem
11
+
12
+ Greedy and KnuthPlass line breakers were built independently and
13
+ duplicate the line-assembly logic. Once the universal `Breaker`
14
+ exists (TODO 14), refactor both algorithms to produce Knuth
15
+ element sequences from InlineRuns, then break them with the universal
16
+ Breaker. Same output, less code, easier to extend.
17
+
18
+ ## Approach
19
+
20
+ Files to modify:
21
+
22
+ - `lib/arrolio/text_layout/knuth_sequence_builder.rb` — new class.
23
+ Takes `InlineRun[]` + `GlyphMeasurer`, produces a `Knuth::Element[]`
24
+ sequence:
25
+ - Each character → `Box(width: char_width)`.
26
+ - Each space → `Glue(width: space_width, stretch:, shrink:)` plus a
27
+ `Penalty(width: 0, penalty: 0, flagged: false)` before the glue
28
+ (Knuth's standard space-break pattern).
29
+ - Each hyphenation point → `Penalty(width: hyphen_width, penalty:
30
+ 50, flagged: true)`.
31
+ - Each newline → `Penalty(width: 0, penalty: -Infinity, flagged: true)`.
32
+
33
+ - `lib/arrolio/text_layout/greedy.rb` and `knuth_plass.rb` — refactor
34
+ to use `KnuthSequenceBuilder` + `Breaker` with different parameters:
35
+ - Greedy: `tolerance: Infinity` (always accept first fit).
36
+ - KnuthPlass: `tolerance: 1.0` (standard).
37
+
38
+ - A `LineAssembler` reconstitutes `Line[]` from the breaker's output
39
+ and the original run list.
40
+
41
+ ## Done-When
42
+
43
+ - [ ] All TODOs 11 and 12 specs still pass after refactor.
44
+ - [ ] Both breakers use the same `KnuthSequenceBuilder`.
45
+ - [ ] Both use the same `Breaker` (different params).
46
+ - [ ] The shared `LineAssembler` produces identical Lines to before.
47
+ - [ ] Code size shrinks (no duplicate line-assembly logic).
@@ -0,0 +1,52 @@
1
+ ---
2
+ priority: P0
3
+ phase: 5
4
+ depends_on: [04, 13]
5
+ layer: flowable
6
+ est: 2d
7
+ status: in_progress
8
+ ---
9
+
10
+ ## Problem
11
+
12
+ The page-flow engine places "flowables" — paragraphs, tables, images,
13
+ spacers, page breaks — into frames, splitting and advancing pages as
14
+ needed. The Flowable base class defines the contract every flowable
15
+ must satisfy; Frame tracks remaining space.
16
+
17
+ ## Approach
18
+
19
+ Files:
20
+
21
+ - `lib/arrolio/flowable.rb` — abstract base.
22
+ - `height(width, context)` → Float: natural height at this width.
23
+ - `render(canvas, x, y, width, context)` → Float: actual height
24
+ consumed (may differ from `height` if rendering adjusts).
25
+ - `splittable?` — true if the flowable supports `split`.
26
+ - `keep_together?` — true if it must not be split.
27
+ - `page_break_before?`, `page_break_after?` — bool flags from style.
28
+ - `split(width, remaining_height, context)` → `[head, tail]` where
29
+ head fits in remaining_height, tail is the rest (or nil).
30
+ - `do_split(width, remaining_height, context)` — subclass hook.
31
+ - Default `split`: if `height <= remaining_height`, `[self, nil]`;
32
+ if not splittable and not keep_together, render whole `[self, nil]`;
33
+ if keep_together, `[nil, self]`.
34
+
35
+ - `lib/arrolio/frame.rb` — rectangular region tracking consumed space.
36
+ - `x`, `y`, `width`, `height`, `consumed`.
37
+ - `remaining_height`, `full?`, `consume!(h)`, `cursor_y`, `clone_empty`.
38
+ - `available_width_at(y)` — for text wrap (FloatRegistry hook).
39
+
40
+ - `lib/arrolio/float_registry.rb` — tracks floated boxes for wrap;
41
+ `available_width_at(frame_x, full_width, y)` returns
42
+ `[x_start, available_width]`.
43
+
44
+ ## Done-When
45
+
46
+ - [ ] `Flowable.new.height(100, ctx)` raises `NotImplementedError`.
47
+ - [ ] A subclass that returns height 50 and renders 50 actually does.
48
+ - [ ] `Frame.new(x: 0, y: 0, width: 100, height: 200).consume!(50)`
49
+ leaves `remaining_height == 150`.
50
+ - [ ] `FloatRegistry` with a left-floated image reduces
51
+ `available_width_at` on the floated y-range.
52
+ - [ ] Specs cover the abstract contract + 1 concrete subclass.
@@ -0,0 +1,50 @@
1
+ ---
2
+ priority: P0
3
+ phase: 5
4
+ depends_on: [16]
5
+ layer: flowable
6
+ est: 1d
7
+ status: in_progress
8
+ ---
9
+
10
+ ## Problem
11
+
12
+ Every `render` call gets a `FlowContext`. It carries page-level state
13
+ flowables need: current page number, total page count (for "Page X of
14
+ Y"), resolved citation targets, bookmark targets, the float registry,
15
+ and a reference to the document (for emitting indirect objects like
16
+ hyperlink annotations).
17
+
18
+ ## Approach
19
+
20
+ File: `lib/arrolio/flow_context.rb`.
21
+
22
+ ```ruby
23
+ class Arrolio::FlowContext
24
+ attr_reader :document, :citations, :bookmarks, :float_registry
25
+ attr_accessor :page_number, :page_count
26
+
27
+ def initialize(document: nil, page_number: 1, page_count: nil)
28
+ ...
29
+ end
30
+
31
+ def citation_for(ref_id) # filled in pass 1, read in pass 2
32
+ def record_citation(ref_id, page_number)
33
+ def record_bookmark(ref_id, page_number, y)
34
+ def total_pages # nil until pass 1 completes
35
+ end
36
+ ```
37
+
38
+ The context is passed by reference, so mutating it during layout is
39
+ intentional. Pass 2 (after the document is fully laid out) reads the
40
+ recorded citations/bookmarks to render static content with resolved
41
+ field values.
42
+
43
+ ## Done-When
44
+
45
+ - [ ] `FlowContext.new(page_number: 5)` exposes `page_number == 5`.
46
+ - [ ] `record_citation("ch1", 7)` then `citation_for("ch1") == 7`.
47
+ - [ ] `total_pages` returns nil until `page_count = N` is set.
48
+ - [ ] Document reference is propagated to children that need to emit
49
+ indirect objects (TODO 37 hyperlinks).
50
+ - [ ] Specs cover recording, lookup, two-pass resolution.
@@ -0,0 +1,52 @@
1
+ ---
2
+ priority: P0
3
+ phase: 5
4
+ depends_on: [13, 16, 17]
5
+ layer: flowable
6
+ est: 2d
7
+ status: in_progress
8
+ ---
9
+
10
+ ## Problem
11
+
12
+ The fundamental flowable: a paragraph of text. Wraps an `InlineRun[]`
13
+ + `Style`; uses TextLayout (Greedy or KnuthPlass) for line breaking;
14
+ splittable across pages.
15
+
16
+ ## Approach
17
+
18
+ File: `lib/arrolio/flowables/text_flowable.rb`.
19
+
20
+ ```ruby
21
+ class Arrolio::Flowables::TextFlowable < Arrolio::Flowable
22
+ attr_reader :runs, :measurer
23
+
24
+ def initialize(text_or_runs, style:, measurer: nil)
25
+ def height(width, context)
26
+ def render(canvas, x, y, width, context)
27
+ def splittable?; true; end
28
+ def do_split(width, remaining_height, context)
29
+ end
30
+ ```
31
+
32
+ Internals:
33
+ - `laid_out(width)` runs Greedy or KnuthPlass based on
34
+ `style.line_break`; returns `Line[]`.
35
+ - `height(width, ctx) = lines.length * line_height`.
36
+ - `render(canvas, x, y, width, ctx)` walks lines; for each PlacedRun,
37
+ calls `canvas.text(run.text, at:, font:, size:, char_spacing:,
38
+ word_spacing:)` per the run's style.
39
+ - `do_split` allocates lines to head until `cum_height <= remaining_height`;
40
+ remaining lines form the tail.
41
+
42
+ `line_height = measurer.line_height(font_size:, line_spacing:)`.
43
+
44
+ ## Done-When
45
+
46
+ - [ ] A single-line paragraph at width 500 renders exactly one line.
47
+ - [ ] A long paragraph splits cleanly across pages; both halves
48
+ render correct text.
49
+ - [ ] Style span (bold run inside body) preserves per-run font in
50
+ emitted `canvas.text` calls.
51
+ - [ ] Justify alignment applies per-line word spacing.
52
+ - [ ] Specs cover single-line, multi-line, splittable behaviour.
@@ -0,0 +1,52 @@
1
+ ---
2
+ priority: P0
3
+ phase: 5
4
+ depends_on: [16]
5
+ layer: flowable
6
+ est: 1d
7
+ status: in_progress
8
+ ---
9
+
10
+ ## Problem
11
+
12
+ Need two simple flowables: vertical `Spacer` (reserve empty space) and
13
+ `PageBreak` (force the engine to advance to the next page before
14
+ continuing).
15
+
16
+ ## Approach
17
+
18
+ File: `lib/arrolio/flowables/spacer.rb`.
19
+
20
+ ```ruby
21
+ class Arrolio::Flowables::Spacer < Arrolio::Flowable
22
+ def initialize(amount)
23
+ def height(width, context) = amount
24
+ def render(*); amount; end
25
+ def splittable?; true; end
26
+ def do_split(width, remaining_height, context)
27
+ # If amount fits, return self. Else split into two spacers.
28
+ end
29
+ end
30
+ ```
31
+
32
+ File: `lib/arrolio/flowables/page_break.rb`.
33
+
34
+ ```ruby
35
+ class Arrolio::Flowables::PageBreak < Arrolio::Flowable
36
+ def height(*); 0.0; end
37
+ def render(*); 0.0; end
38
+ def page_break_after?; true; end # engine recognises this
39
+ end
40
+ ```
41
+
42
+ Engine treats `PageBreak` as a sentinel: instead of placing it, it
43
+ advances to a new page. Both flowables are zero-cost on render.
44
+
45
+ ## Done-When
46
+
47
+ - [ ] `Spacer.new(50).height(100, ctx) == 50`.
48
+ - [ ] `Spacer.new(100).do_split(100, 30, ctx)` returns
49
+ `[Spacer(30), Spacer(70)]`.
50
+ - [ ] `PageBreak.new.page_break_after? == true`.
51
+ - [ ] Engine consumes `PageBreak` without rendering it.
52
+ - [ ] Specs cover both.
@@ -0,0 +1,63 @@
1
+ ---
2
+ priority: P0
3
+ phase: 5
4
+ depends_on: [17, 18, 19]
5
+ layer: flowable
6
+ est: 3d
7
+ status: in_progress
8
+ ---
9
+
10
+ ## Problem
11
+
12
+ The driver that walks a Flowable list, places each into a Frame,
13
+ advances pages when the frame is full, honours keep-together and
14
+ page-break directives, and runs two passes for citation resolution.
15
+ This is the heart of the engine.
16
+
17
+ ## Approach
18
+
19
+ File: `lib/arrolio/engine/paged.rb`.
20
+
21
+ ```ruby
22
+ class Arrolio::Engine::Paged
23
+ attr_reader :layout_spec, :flowables, :context, :pages
24
+
25
+ def initialize(layout_spec:, flowables:, context: nil)
26
+ def layout -> [Arrolio::Output::Page]
27
+ end
28
+ ```
29
+
30
+ Algorithm:
31
+ 1. Initialise `pending = flowables.dup`, `current_page = new_page`,
32
+ `current_frame = current_page.body_frame`.
33
+ 2. Loop:
34
+ - Shift next flowable.
35
+ - `PageBreak` → new page; continue.
36
+ - `page_break_before?` and not first flowable → new page.
37
+ - `keep_together?` and `height > remaining_height` and `height <=
38
+ frame.height` → new page first.
39
+ - If frame full → new page.
40
+ - `render_on_page(flowable, current_page, current_frame)` →
41
+ `[consumed, remainder]`.
42
+ - `frame.consume!(consumed)`.
43
+ - If remainder and consumed == 0 and frame not full → new page;
44
+ unshift remainder.
45
+ - Else unshift remainder.
46
+ 3. After all flowables placed: `context.page_count = pages.length`.
47
+ 4. Pass 2: for each page, render its static-content regions
48
+ (running headers/footers) with resolved citation values.
49
+
50
+ Each page is created from the layout_spec's PageSequenceMaster
51
+ selection (TODO 24, 25). For now (Phase 5), use a single body
52
+ template.
53
+
54
+ ## Done-When
55
+
56
+ - [ ] A single-paragraph document produces 1 page.
57
+ - [ ] 50 long paragraphs across multiple pages.
58
+ - [ ] `PageBreak` forces a new page.
59
+ - [ ] `keep_together: true` flowable that doesn't fit moves to next
60
+ page whole.
61
+ - [ ] 100-paragraph doc lays out in < 5 seconds.
62
+ - [ ] No infinite loops on over-large content (TODO 19's split
63
+ contract prevents this).